Design¶
lexindex is three build-once / query-many indexes over a set of strings, each a flat, relocatable blob.
StringIndex¶
Keys are sorted and deduplicated on build, and each key's id is its rank in sorted order, so ids
are stable for the same key set. It is backed by a single structure — a finite-state transducer
(fst) — that serves both directions:
key → id. The FST stores the sorted keys as a minimised automaton, sharing common prefixes and suffixes, mapping each key to its rank (an output value). It drives prefix, range, fuzzy (Levenshtein) and subsequence iteration by walking the automaton — never a full scan.id → key: a rank-walk, with no stored reverse map. Because a key's id equals the sum of the output values along its accepting path,key(id)reconstructs the key directly from the FST: start at the root with an accumulator of 0, and at each node take the last transition whoseaccumulator + transition output ≤ id(transitions are ordered, so their output prefix-sums are monotone); append that transition's byte, add its output, and descend. When a final state is reached withaccumulator + final output == id, the accumulated bytes are the key. This isO(key length)and needs no auxiliary structure.
Dropping the separate reverse dictionary (a front-coded map in 0.2.0) roughly halves the real-world
blob — /usr/share/dict/words goes from 12.6 to 5.95 bytes/key — because that map only reached its
advertised size on structured keys that share long prefixes, not on a natural vocabulary. The
serialised blob is now simply [magic "BIX4"][fst bytes].
CompactHashIndex¶
The smallest string → dense id map, and smaller than any installable trie. It pairs a minimal
perfect hash with one small fingerprint per key and no stored keys at all:
key → id. The MPH (ptr_hash) maps the key's version-stable 64-bit hash to a slot in[0, n). That slot is the id — but an MPH returns a slot for any input, so a membership check is needed.- membership: a
k-byte fingerprint. Each slot stores afingerprint_bytes-wide fingerprint computed from a second, independent hash of the key.id(key)accepts the slot only if the query's fingerprint matches the stored one. Independence of the two hashes makes the chance a non-member both lands on a used slot and matches its fingerprint256^-fingerprint_bytes— the tunable false-positive rate (≈ 0.4 % at 1 byte, ≈ 0.0015 % at 2, measured to match on real words).
Because the keys themselves are never stored, size is just the MPH (~0.3 B/key — PtrHash is ≈2.4
bits/key) plus the fingerprints (fingerprint_bytes B/key): 1.30 B/key at 1 byte, 2.30 at 2 on real
words — below marisa-trie's 2.98. The trade for that footprint is the false-positive rate and the absence of any
id → key. The serialised blob is [magic "BCH1"][n][fp_bytes][mph length][mph epserde bytes][fingerprints].
PerfectHashIndex¶
A minimal perfect hash maps a fixed set of n distinct strings to distinct slots [0, n) with no
gaps and near-O(1) lookup in tiny space. lexindex builds the MPH with
ptr_hash, keyed on a version-stable 64-bit hash of each
string (FNV-1a + a splitmix64 finalizer — not std's DefaultHasher, which is not guaranteed stable
and so cannot back a serialised MPH). A flat slot → key arena doubles as the membership check: an
MPH returns a slot for any input, so a query is a hit only if the stored key at that slot equals the
query. Build fails, rather than silently corrupting, on the astronomically rare 64-bit hash collision
between two distinct keys.
id_unchecked skips the stored-key comparison — the fastest possible lookup, for a closed vocabulary
where membership is already guaranteed. The serialised blob is [magic "BMP1"][n][mph length][mph
epserde bytes][arena bytes].
PerfectHashIndex stores full keys (exact membership + id → key) where CompactHashIndex stores only
a fingerprint (probabilistic, no reverse); the two share the same version-stable slot hash, so choosing
between them is purely a size-vs-exactness trade, not a different lookup path.
Zero-copy load_mmap¶
All three indexes load two ways. load reads the whole blob into memory; load_mmap memory-maps the
file and borrows the index from the mapped pages — no read, no copy — so load time is independent of
the index size and the OS shares the pages across processes.
The mechanism is a single SharedBytes byte source: an owned Arc<[u8]> or an Arc<memmap2::Mmap>,
exposed as AsRef<[u8]> and 'static. It backs the FST (Map<SharedBytes>), the fingerprint table and
the key arena, so from_bytes (owned) and load_mmap (mapped) share one code path with no
self-referential borrow and no unsafe beyond the single Mmap::map. Every field is read byte-wise
(u64::from_le_bytes, varints), so there is no alignment requirement — for PerfectHashIndex and
CompactHashIndex, load_mmap borrows the arena / fingerprint table (the bulk of the blob) zero-copy
and reads only the small MPH structure into memory, sidestepping the deserialiser's alignment concern entirely.
The one caveat is the usual mmap contract: the mapped file must not be mutated while an index borrows it. Parsing is fully bounds-checked, so loading a truncated or corrupt blob fails cleanly and never reads out of bounds.
Cargo features¶
mph(default) —PerfectHashIndexandCompactHashIndex(pullsptr_hash+epserde).mmap(default) — the zero-copyload_mmappath (pullsmemmap2).python— the PyO3 abi3 extension module.--no-default-features— anfst-only build:StringIndexwith prefix/range/fuzzy/subsequence and ownedsave/load, depending on nothing butfst. It is also free of the informational RustSec advisories (unmaintained / unsound) thatptr_hash's transitive dependency tree currently carries —cargo auditreports those as warnings, not vulnerabilities.