Usage guide¶
Runnable snippets for every interface, in Python and Rust.
plan — which index, before you build one¶
Five indexes with five size curves is a choice nobody should have to make from a README table: the
sizes are corpus-specific, and the spread between them on one corpus is larger than the spread of
any one of them across corpora. plan sorts the keys once, measures what the formats are actually
paid in, and prices every index that answers the questions you say you need answered.
import lexindex
keys = ["apple", "apricot", "banana", "blackberry", "blueberry", "cherry"]
p = lexindex.plan(keys, prefix=True) # prefix and range queries; nothing else required
print(p["best"]["kind"]) # the cheapest index that answers them
print(p["text"]) # the whole ladder, and why the winner won
# An index that cannot answer what was asked is left out of the ranking rather than ranked last.
assert {e["kind"] for e in p["estimates"]} == {"DictIndex", "StringIndex"}
Which index wins is a property of your keys and not of this library — six fruit names are not a
corpus, and neither is a range of numbers, which an fst folds into almost nothing. Run it on the
keys you actually have. The keywords are reverse (key(id) as well as id(key)), ordered,
prefix, fuzzy and exact — the last rules out the two probabilistic indexes, which is where
their size comes from. Nothing is required by default, so a bare plan(keys) prices all five.
Past 100 000 keys the three numbers no statistic gives — what the codec squeezes a suffix into, the
bytes an fst spends a key, the bits the perfect hash spends per key — come from two draws of
that size, and each estimate says so in measured. Scored against the built blob on 23 corpora of
half a million to ten million keys, at each of the three priced blocks, DictIndex lands within
1.0 % median, 4.2 % at the 90th percentile and 7.2 % at worst; StringIndex within
1.1 / 6.7 / 11.7 over 19 corpora, because an fst merges equal right-languages and how much it
merges is a property of the density of the whole key set rather than of any statistic of it. A
dense id space is where that still breaks: ten million decimal ids merge to 356 bytes whole, which
no draw of a hundred thousand of them can see, and the estimate reads about a hundredfold high —
the ranking survives, the byte count does not, and thin says so. Below the sample size nothing is modelled: the candidates are built
and reported at what they weigh.
That error is not random in n. Holding a corpus's content fixed and moving only its size — stride
samples of one file, so the difference is scale and nothing else — the estimate reads about 2.8 %
low at a million keys and converges to within 0.2 % by ten million on urls and article titles. It
does not converge on a deep directory tree, where shared prefixes keep lengthening with the key
count: a path list reads level at a million and 7.2 % high at seven. The suffix rate is measured on
a uniform draw of 100 000 keys, whose neighbours are n / 100 000 apart, so what it reads is a
longer suffix than the blob's — plan a large path index expecting the blob to come in under the
number, and re-plan on a sample of the real keys if the margin matters.
What a sample cannot see by itself is how good the vocabulary gets. A shard's symbol table and the blob-wide phrase dictionary are trained on the keys the blob holds, and the miner buys a phrase against what it saves over the whole blob — so a hundred thousand keys afford a tenth of the dictionary a million do, and a sample built as an index of its own reads the suffix ratio 0.71 where the million-key blob spends 0.53 on article titles. The sample is therefore built with the corpus's economics: its phrases mined at the corpus's key count, over every suffix it holds, once for the plan and not once a block. That takes a million urls from 14 % high to 0.9 % low and three million article titles from 24 % to 1.4 %.
There are two draws because the blob is paid in two currencies. What a suffix compresses to is a
property of the whole key set, so it is read off a uniform draw: a key is kept while its hash is
under a cutoff, and the cutoff falls as the draw fills — the same sample on every run over the same
keys, needing neither the corpus's size in advance nor the corpus in memory, which is what lets
plan_file answer as plan does. It is also the more accurate draw for an fst: a stride over sorted
keys takes one key from every prefix group whatever the group's size, and a StringIndex estimate
read off such a sample runs 5 % low on a corpus of shared prefixes where this one lands within
0.4 %. But what a stored (lcp, len) pair and a block's arrays cost is a property of the
neighbours a key is coded against, which a uniform draw destroys — it read a million decimal ids
0.51 bytes a header where that blob spends 0.34, a third of it. So those come off a second draw, of
runs of 4 096 consecutive keys each started by the hash of its first, coded under the same
dictionary.
Two flags say when not to trust the ranking. close means the two cheapest are within 1.3× of each
other, which is inside what an estimate can separate; thin means the mean suffix is under two
bytes, where a compression ratio read from a sample carries least well — ten million decimal numbers
are the worst cell in the score, at 4.1 % high. Either one means build both and measure.
plan_file(path, needs, objective) prices a keys file the same way without ever holding it.
The file is sorted externally — runs in memory, spilled beside it, merged back — and the merge is
where the key count, the mean length and both shared-prefix distances are counted, exactly rather
than sampled; only the three unmeasurable constants still come from a sample, and that sample is
the hash draw above, which does not depend on the corpus being in hand. So it is not an
approximation of plan: on a 925 MB, 7 343 721-line path list the two print the same ladder to
the byte, at 0.27 GB of resident memory against 1.30 and in less wall time. The runs go in a
directory beside the file and are removed however the call ends, so that directory must be
writable.
StringIndex — ordered, FST-backed¶
import lexindex
# Duplicates are removed and keys sorted; the id of a key is its rank in sorted order.
idx = lexindex.StringIndex(["banana", "apple", "apricot", "cherry", "apple"])
# Keys already in ascending byte order build without ever being materialised: the constructor
# has to hold the whole corpus to sort it, these consume the iterable lazily. Measured at 10 M
# keys, the streamed build peaks at 11.6 MB against the list build's 1053.4 -- 91x.
keys = (line.rstrip("\n") for line in open("sorted-keys.txt")) # any lazy iterable
n = lexindex.StringIndex.build_sorted_to_file(keys, "keys.bix") # -> number of keys written
n = lexindex.StringIndex.build_to_file(unsorted, "keys.bix") # any order: sorted in runs on disk
idx2 = lexindex.StringIndex.from_sorted(["apple", "apricot", "banana"]) # or straight to memory
len(idx) # 4 (duplicate "apple" deduped)
idx.id("apple") # 0
idx.id("missing") # None
"cherry" in idx # True
idx.key(2) # "banana" (id → string)
# the dict spelling of the same lookup, on all four classes: KeyError on a miss, or a default
idx["apple"] # 0
idx.get("missing", -1) # -1
# ordered iteration — automaton-driven: prefix/range seek directly; a broad fuzzy or
# subsequence pattern may still walk most of the FST
idx.prefix("ap") # [("apple", 0), ("apricot", 1)]
idx.common_prefix("applesauce") # [("apple", 0)] — the keys that are prefixes OF the query
idx.longest_prefix("applesauce") # ("apple", 0) — what a longest-match tokeniser takes
idx.occurrences("cherry-apple") # [(0, 6, 3), (7, 12, 0)] — every key in a text, as (start, end,
# id) in characters: common_prefix at every position, in one call
idx.range("apricot", "cherry") # [("apricot", 1), ("banana", 2)] — [lo, hi)
idx.successor("ba") # ("banana", 2) — smallest key ≥ query
idx.predecessor("ba") # ("apricot", 1) — largest key ≤ query
idx.fuzzy("aple", 1) # [("apple", 0)] — Levenshtein edit distance ≤ 1
idx.subsequence("ae") # [("apple", 0)] — "a…e" in order, not necessarily contiguous
# "character" above means a Unicode scalar value: no normalisation, case folding or grapheme
# segmentation is applied — normalise before building and querying if you need it
# order statistics — answers about the id space without decoding any key, so the cost does not
# grow with the number of matches
idx.lower_bound("ba") # 2 — how many keys sort below "ba"; an insertion point in 0..=len
idx.prefix_id_range("ap") # (0, 2) — a prefix is a contiguous *slice* of the id space
idx.prefix_count("ap") # 2
idx.range_count("apricot", "cherry") # 2 — what range() would return, uncounted
# lazy iteration in sorted (= id) order — streams the transducer a chunk at a time, so it never
# builds a giant list and never decodes a key twice
list(idx) # [("apple", 0), ("apricot", 1), ("banana", 2), ("cherry", 3)]
dict(idx) # {"apple": 0, "apricot": 1, "banana": 2, "cherry": 3}
# every query takes a limit: stop after that many matches, walking no further
idx.prefix("ap", limit=1) # [("apple", 0)]
idx.fuzzy("aple", 1, limit=1) # [("apple", 0)]
# batched lookups — one Rust↔Python crossing instead of one per key (named ids_of / keys_of so the
# class is never mistaken for a mapping)
idx.ids_of(["banana", "x"]) # [2, None]
idx.keys_of([0, 2]) # ["apple", "banana"]
What limit buys — and what it cannot¶
limit is not a faster walk; it is not doing work whose result would be thrown away. Without it,
prefix("s") on the 479 823-word system dictionary walks the whole s subtree of the FST and
materialises every match — 45 064 (str, int) tuples — even if the caller wanted ten. With it, the
walk stops at the tenth match. The saving is therefore proportional to how much of the result you
discard, and it varies enormously by query (same dictionary, idle machine, min of 9 runs):
| query | matches | full | limit |
speedup |
|---|---|---|---|---|
prefix("s", limit=10) |
45 064 | 10.96 ms | 0.004 ms | ~3 000× |
prefix("a", limit=10) |
25 192 | 4.83 ms | 0.003 ms | ~1 500× |
prefix("un", limit=10) |
20 358 | 4.09 ms | 0.006 ms | ~700× |
subsequence("abc", limit=10) |
1 910 | 41.31 ms | 0.51 ms | ~80× |
fuzzy("hello", 2, limit=5) |
242 | 1.52 ms | 0.26 ms | ~6× |
Three regimes hide in that column, and they tell you when to reach for limit:
- Prefix / range: the walk is cheap, the discarded results were the cost. Speedup tracks
matches ÷ limitalmost linearly. This is the autocomplete case, and it is why the numbers are in the thousands. - Subsequence: the walk itself is expensive. The
.*a.*b.*c.*automaton visits many FST nodes per match produced, so stopping early saves traversal, not just tuples — 80× despite discarding far fewer results thanprefix("un"). - Fuzzy: a fixed cost dominates that
limitcannot skip. The Levenshtein automaton is built eagerly before the walk starts (deliberately, so a too-large distance raises up front rather than on first use).limitonly trims the walk after that, hence single-digit gains.
There is also a floor under every query: one call costs ~3 µs of FFI plus stream construction, so
per-match cost at limit=1 reads ~2 000 ns against a steady ~200 ns from limit≈100 up. Asking
for one match costs about the same as asking for ten — batch your UI accordingly.
If you will consume all matches, limit (or omitting it) changes nothing: the eager form is
the lazy walk collected, measured within noise of each other. In Rust, prefer the *_iter forms
(prefix_iter / range_iter / fuzzy_iter / subsequence_iter) and .take(n) — same machinery,
no intermediate Vec at all.
Persistence and zero-copy loading¶
idx.save("catalog.bix") # write a flat, relocatable blob
idx = lexindex.StringIndex.load("catalog.bix") # read it back into RAM
idx = lexindex.StringIndex.load_mmap("catalog.bix") # …or memory-map it: no read, borrowed zero-copy
idx = lexindex.StringIndex.load_mmap_verified("catalog.bix") # …mapped, with the payload checksum
idx = lexindex.StringIndex.load_untrusted("theirs.bix") # a file someone else wrote, see below
data = idx.to_bytes() # or go through bytes directly
idx = lexindex.StringIndex.from_bytes(data)
import pickle # every class pickles, including Overlay
idx = pickle.loads(pickle.dumps(idx)) # …so one can be sent to a spawned worker
Pickling copies the blob. An index goes onto the wire as its to_bytes() and comes back through
from_bytes, which is what makes it work across a multiprocessing spawn — a path would not
survive a worker that cannot see the same filesystem, and a memory-mapped index would not survive
the file changing. So a mapped index pickles by value like any other, and a large one costs its
serialised size in the pickle: when both ends can see the same file, save plus load_mmap shares
the pages instead of copying them.
load_mmap maps the file and borrows the index from the mapped pages, so load time is independent of
the index size and the pages are shared across processes. The mapped file must stay immutable while an
index borrows it.
The loaders, on two axes — whether the bytes are copied and how far they are checked:
| Loader | Copies | Checks | For |
|---|---|---|---|
from_bytes / load |
yes | header, payload checksum, rank spot check | your own blob |
from_untrusted_bytes / load_untrusted |
yes | the above plus the full validation of the transducer (StringIndex; the hash indexes and DictIndex need none, their loaders are total) |
a stranger's blob |
load_mmap |
no | header only — the payload checksum is skipped by design, since reading every page is what a mapping avoids; DictIndex skips the walk over its per-block arrays for the same reason and bounds every access instead |
your own file, unchanged while mapped |
load_mmap_verified |
no | header and payload checksum, one pass over the mapping at load | your own file, carried by someone else |
load_mmap_untrusted (StringIndex) |
no | the full validation, over the mapping | a stranger's file too large to copy — map a copy you own, since the check trusts what it saw once |
A truncated or corrupt blob is refused with ValueError, and no load can produce undefined
behaviour. StringIndex has one documented exception, and it is the reason SECURITY.md states a
threat model rather than a promise: its blob is an fst transducer whose node decoder is safe Rust
but not total, and the checksum in front of that decoder is public, so bytes crafted to carry a
matching one can panic instead of returning. In Python that surfaces as
pyo3_runtime.PanicException, which derives from BaseException and so slips past
except ValueError.
idx = lexindex.StringIndex.from_untrusted_bytes(data) # a blob someone else wrote
ov = lexindex.Overlay.from_untrusted_bytes(data, lexindex.StringIndex) # …or one wrapping it
That loader checks the transducer as a graph — every reachable node once, in time proportional to
nodes and transitions rather than to the keys they spell, so a tiny blob spelling a billion keys is
refused or accepted in microseconds — requires its values to be ranks and its keys to be UTF-8,
and catches the panic at the load boundary, so a crafted blob raises ValueError like any other
bad input. It costs 32× from_bytes (22.9 ms against 0.7 ms on
479 823 words), which is the trade: pay it once for a stranger's blob, never for your
own. The overlay form exists for the same reason — an overlay's own framing is checksummed and
validated either way, but the base region inside it is handed to a base loader, and over a
StringIndex base that is exactly the choice above. The hash indexes need no such call: their
loaders are total, so Overlay.from_untrusted_bytes over a hash base does the same work as
from_bytes.
One thing neither loader can promise: the contained panic still runs the process-wide hook on its
way out, so a rejection normally prints a panic message to stderr before the ValueError arrives.
Before loading a blob, or instead of it, inspect says what it is from its header alone:
info = lexindex.inspect("catalog.bix") # a path or bytes; only the header and footer are read
info["kind"], info["format"], info["keys"] # 'StringIndex', 'BIX4', 479823
The dict carries the kind, the format, the key count, and the sizes a caller would otherwise have
to load the blob to learn: the perfect hash's region (8 * mph_bytes / keys is its bits per key),
the key arena or the fingerprint table, the side table, and for an overlay its additions and
retired ids with the base inspected in turn. Nothing is decoded or verified, so an index of
gigabytes inspects in microseconds — an overlay excepted, since counting its retired ids reads
its tombstone words, one byte per eight ids — and a blob that inspects cleanly may still fail to
load. A
blob from before 1.0 is a ValueError naming the type to rebuild.
PerfectHashIndex — exact lookup with id → key¶
from lexindex import PerfectHashIndex
dict_ = PerfectHashIndex(["GET", "POST", "PUT", "DELETE"])
i = dict_.id("POST") # dense id in [0, n); membership is verified against the stored key
dict_.key(i) # "POST"
dict_.id("PATCH") # None — a verified miss, not a hash collision
dict_.id_unchecked("GET") # fastest lookup; skips verification (closed-vocabulary hot path)
dict_.save("verbs.bmp")
dict_ = PerfectHashIndex.load_mmap("verbs.bmp") # arena mapped zero-copy; tiny MPH read into RAM
# a corpus that does not fit in memory: hand it a factory — it is called twice — never the iterable
n = PerfectHashIndex.build_to_file(lambda: (line.rstrip("\n") for line in open("keys.txt")), "keys.bmp")
# a workload that is mostly misses (a stop list, a block list): one more byte per key, and an
# absent key stops after one cache miss instead of two — 166 → 74 ns on the 480 k-word dictionary,
# a member +3 ns, the same ids. build_to_file takes the same keyword.
stop = PerfectHashIndex(stop_words, fingerprints=True)
stop.has_fingerprints() # True
Use id_unchecked only for a fixed / closed vocabulary where membership is already guaranteed —
it returns an arbitrary (but valid) slot for an unknown key. Use id everywhere else.
CompactHashIndex — smallest footprint¶
from lexindex import CompactHashIndex
# fingerprint_bytes ∈ {1, 2, 4}; or a keyword-only fingerprint_bits ∈ 1..=64 for finer control.
# 8 bits (the default) is 1.24 B/key with a ~0.4% membership false-positive
# rate; 2 → ~0.0015%; 4 → effectively exact. No keys are stored, so there is no id → key.
dict_ = CompactHashIndex(["GET", "POST", "PUT", "DELETE"], fingerprint_bytes=1)
i = dict_.id("POST") # dense id in [0, n); a non-member may rarely read as present
dict_.contains("GET") # True
dict_.id_unchecked("GET") # fastest lookup; no fingerprint check (closed-vocabulary hot path)
dict_.save("verbs.bch")
dict_ = CompactHashIndex.load_mmap("verbs.bch") # fingerprint table mapped zero-copy
# A corpus that does not fit in memory: hashed as it streams, the pairs sorted in runs beside the
# output, the perfect hash built from them a chunk at a time -- the same file `save` writes.
n = CompactHashIndex.build_to_file((line.rstrip() for line in open("keys.txt")), "verbs.bch")
Choosing the fingerprint width¶
Size is the minimal perfect hash (0.24 B/key, flat in n) plus exactly fingerprint_bits/8 bytes
per key, and
the membership false-positive rate is about 2^-fingerprint_bits — a design rate for
well-distributed keys, measured 6.2530 % at 4 bits and 1.5553 % at 6 over 2 M non-member probes;
not a defence against an adversary who chooses the queries, since both hashes are deterministic and
unseeded — every width is a point on the
same trade-off (measured on /usr/share/dict/words, 479 823 keys):
fingerprint_bits |
bytes/key | false-positive rate | false hits per 1 M non-member probes |
|---|---|---|---|
| 4 | 0.74 | 6.25% | 62 500 |
| 6 | 0.99 | 1.56% | 15 625 |
8 (= fingerprint_bytes=1, default) |
1.24 | 0.39% | 3 906 |
| 12 | 1.74 | 0.024% | 244 |
16 (= fingerprint_bytes=2) |
2.24 | 0.0015% | 15 |
32 (= fingerprint_bytes=4) |
4.24 | 2.3×10⁻⁸% | ~0 |
Pick by the probe mix, not the key count: the rate is per non-member lookup, so a workload that
only ever queries members never sees a false positive at any width, while a filter in front of a
network hop wants the rate priced against the cost of a wasted hop. For scale: marisa-trie's exact
index costs 2.98 B/key on this corpus — CompactHashIndex is below it at every width up to
21 bits (rate 2⁻²¹ ≈ 5×10⁻⁵%).
tiny = CompactHashIndex(keys, fingerprint_bits=4) # 0.74 B/key, 1-in-16 false positives
tiny.fingerprint_bits # -> 4
CompactHashIndex trades exactness for size: membership is correct except for a 2^-fingerprint_bits
false-positive chance on a non-member, and it cannot map an id back to a string. Reach for it when a
fixed vocabulary's on-disk / mmap footprint dominates; use PerfectHashIndex when you need exact
membership or id → key, or StringIndex when you need order or fuzzy/prefix.
ClosedHashIndex — the perfect hash and nothing else¶
from lexindex import ClosedHashIndex
# For a vocabulary known to be closed: every query is a member by construction, so nothing is
# stored to say otherwise. `id` returns a member's id, and for any other string *some* id in
# [0, n) -- there is no `in`, no `[]`, no `contains`. About 0.24 B/key, a fifth of CompactHashIndex.
vocab = ClosedHashIndex(["the", "of", "and", "to"])
i = vocab.id("the") # the id CompactHashIndex([...]).id_unchecked("the") gives
ids = vocab.ids_of(["to", "of"]) # list[int]; ids_of_bytes / ids_into as on the other indexes
vocab.save("vocab.bcl")
vocab = ClosedHashIndex.load("vocab.bcl") # no load_mmap: the whole blob is the perfect hash
The same perfect hash as CompactHashIndex over the same keys, so the ids agree with its
id_unchecked; what is missing is the fingerprint table, and with it the ability to say no.
Reach for it as a token → id map on a hot path where the caller controls the queries — a
tokenizer over its own vocabulary, a join on a key column the index was built from — and for
CompactHashIndex the moment a stranger can ask.
DictIndex — ordered, every key stored, 51 % below StringIndex¶
from lexindex import DictIndex
# Exact string <-> rank both ways, about 2.64 B/key: the sorted keys front-coded in blocks of 256,
# each cut into microblocks of 16, with the suffixes under a symbol table trained on the index
# itself. Ordered, so ranges of keys
# are ranges of ids, and prefix and range fall out of that -- only fuzzy needs an automaton and
# stays with StringIndex.
words = DictIndex(["apple", "apricot", "banana", "cherry"])
words.id("banana") # 2 -- the sorted rank; None if absent
words.key(2) # "banana"
words.prefix("ap") # [("apple", 0), ("apricot", 1)] -- a range, not an automaton
words.common_prefix("bananas") # [("banana", 2)] -- the keys that are prefixes OF the query
words.longest_prefix("bananas") # ("banana", 2) -- what a longest-match tokeniser takes
words.prefix("ap", limit=1) # [("apple", 0)] -- stops there, walks no further
lo, hi = words.prefix_id_range("ap") # (0, 2) -- two order lookups, whatever the match count
words.prefix_count("ap") # 2, without decoding a key
words.keys_of(range(lo, hi)) # ["apple", "apricot"] -- the keys alone, and the fastest
# way to them: no id is decoded, and one walk stays open
# across a block. 1.5x `prefix` on a 763-match prefix.
words.range("apricot", "cherry") # [("apricot", 1), ("banana", 2)]
words.successor("az"), words.predecessor("az") # ("banana", 2), ("apricot", 1)
list(words) # [("apple", 0), ...], lazily
faster = DictIndex(words_list, block=64) # 2.72 B/key against 2.64; id 268–270 ns against 291–298
smaller = DictIndex(words_list, block="compact") # a name for a point on that curve: "fast" is
# 32 keys a block, "balanced" 256, "compact" 1024
words.save("words.bdx")
# A corpus that does not fit in memory: sorted in runs spilled beside the output, encoded into the
# file as it goes. Same bytes as the constructor plus save; returns the number of distinct keys.
DictIndex.build_to_file((line.rstrip("\n") for line in open("words.txt")), "words.bdx")
words = DictIndex.load("words.bdx") # checked like the others
words = DictIndex.load_mmap("words.bdx") # keys and block data borrowed; header, tables and 8 B/block read
words.route_microblocks() # opt-in: 0.5 B/key more memory at this block, in no blob, for a faster
# search; returns those bytes
id finds the block by its head's first eight bytes, walks the block's restarts to one microblock
and compares the stored suffixes there against the query without decoding them; key climbs the
same two runs and decodes only the entries whose shared-prefix length strictly increases up to the
id, a handful rather than one per entry read. A lookup therefore scans block / micro + micro - 2
entries — 30 at the default, where a block of 256 keys holds 255. On the dictionary: 2.64 bytes
per key, id 291–298 ns and key_into 193–194, against 5.95 / 200–210 / 436–445 for
StringIndex — which keeps fuzzy and subsequence iteration, and Overlay.
route_microblocks() trades memory for that walk over the restarts. It derives eight bytes of every
microblock's first key — 0.5 bytes a key at the default block, 0.25 at 1024, held beside the index
and in no blob — and every lookup then picks its microblock off those words, walking the restarts
only where the query's eight bytes tie a restart's. Deriving them reads the start of every block, so
a load_mmap index is paged in whole by the call rather than as queries reach it. On the
dictionary, routed, id reads 217–228 ns at the default block, and 239–241 at block="compact"
for 2.76 bytes a key in memory — faster than "fast" unrouted, at fewer bytes.
At block=512 the same index stores 2.52 bytes per key, well under marisa-trie's 2.98 on this
corpus, and answers prefix, range and key(id) — a marisa id is not the lexicographic rank, so
it has no lower_bound to build a range on. The price is a longer scan: key_into 253 ns
against 193–194 at the default, and id 313–328 against 291–298.
HashedDictIndex — a DictIndex whose id is a hash¶
from lexindex import DictIndex, HashedDictIndex
# A DictIndex with a sidecar: at each key's slot in a minimal perfect hash, the key's rank and
# `fingerprint_bits` of a second hash. `id` is one hash and a read of each table -- the dictionary
# is not searched -- and the ordered queries go to `.dict`, whose ids are the same ranks.
words = HashedDictIndex.from_dict(DictIndex(words_list), fingerprint_bits=8)
words.id("banana") # its rank; a non-member reads as present at 2^-8
words.id_unchecked("banana") # a member's rank, and some rank below len() for anything else
"banana" in words # id's contract again, as are words["banana"] and words.get(...)
words.ids_of(["apple", "cherry"]) # batched: hashed in one pass, the tables' lines in flight
words.dict.key(2) # key, prefix, range, iteration: the dictionary's own
words.save("words.bhd")
words = HashedDictIndex.load("words.bhd") # checked like the others
words = HashedDictIndex.load_mmap("words.bhd") # dictionary and rank table borrowed
closed = HashedDictIndex.from_dict(DictIndex(words_list), fingerprint_bits=0)
closed.id_unchecked("banana") # the smallest sidecar, for a vocabulary the caller controls
closed.id("durian") # None, always: at zero bits `id` is the dictionary's own search
The membership contract is chosen once, at build. From one fingerprint bit up, id answers a
member with its rank and a non-member with None except at 2^-fingerprint_bits —
CompactHashIndex's contract. At zero bits id is DictIndex.id, exact and at the dictionary's
cost, and the hash path is id_unchecked alone. On the 480 k-word dictionary the sidecar is 2.62
bytes per key at zero bits and one more for every eight fingerprint bits, beside the dictionary's
2.64: 5.25 in all, 6.25 at eight bits. On the frontier protocol — every key looked up once, in a
shuffled order — id_unchecked answers in 14 to 78 ns and id at eight bits in 19 to 103 over
nineteen corpora of half a million to ten million keys, against 245 to 716 for the dictionary's own
id and 54 to 673 for XCDAT, the fastest trie measured
(the numbers).
The blob holds the dictionary's own blob byte for byte, so a loaded index answers the ids its
dictionary answers. plan does not price it yet, and Overlay does not take it: its ids are
ranks, as DictIndex's are.
DoubleArrayIndex — a lexicon against running text¶
from lexindex import DoubleArrayIndex, StringIndex
# A character-wise double-array trie: a step is a character, one load each, so the queries that walk
# a text -- the keys it starts with, the longest one, every key anywhere in it -- cost a load a
# character. Duplicates are removed; the ids are the keys' ranks, the same StringIndex gives.
words = ["北京", "北京大学", "大学", "大学生"]
lexicon = DoubleArrayIndex(words)
lexicon.occurrences("北京大学生") # every (start, end, id) in characters: starts ascending, shortest first
lexicon.common_prefix("大学生活") # the keys the text starts with, shortest first: [("大学", 2), ...]
lexicon.longest_prefix("大学生活") # ("大学生", 3), or None
lexicon.id("大学") # the key's rank; "大学" in lexicon, lexicon["大学"] and .get(...) too
lexicon.ids_of(["大学", "x"]) # batched
StringIndex(words).key(lexicon.id("大学")) # nothing comes back out of this one: StringIndex spells an id
lexicon.save("lexicon.bda")
lexicon = DoubleArrayIndex.load("lexicon.bda") # every slot checked
lexicon = DoubleArrayIndex.load_mmap("lexicon.bda") # the slots and the code table borrowed
occurrences is what a segmenter asks: jieba builds its DAG from every word starting at each
position of a sentence, and one call a sentence returns all of them. The empty key, if it was
built in, is a prefix of every query — common_prefix reports it first — but never an occurrence.
It answers these queries and id, and nothing else: no key(id), no prefix, no iteration — keep
the key list, or build a StringIndex or DictIndex over the same keys, whose ids are the same.
A build is refused past 8 388 608 keys, 65 535 distinct characters or a trie that needs more than
8 388 608 slots: about four million Chinese words, fewer long Latin ones — a million word.word
pairs of English take 2.2 M. Every load walks all the slots to check them, load_mmap included,
so a crafted file answers wrong ids and never reads out of bounds; load_mmap skips only the
checksum over the payload.
Overlay — edits without a rebuild¶
Every index is built once from the whole key set, so adding a single key has always meant rebuilding for the whole corpus. An overlay wraps one with the keys added since and the ids retired from it. The base is shared, not copied: it stays usable, and several overlays can sit on one index.
from lexindex import Overlay, PerfectHashIndex
base = PerfectHashIndex.load_mmap("vocab.bmp") # untouched by anything below
ov = Overlay(base)
new_id = ov.add("neologism") # ids continue above len(base)
ov.remove("typo") # the id is retired, nothing is renumbered
ov.save("vocab.ovl")
back = Overlay.load("vocab.ovl", PerfectHashIndex) # pass the base class, not an instance
folded = back.compact() # rebuild the base; this renumbers
folded, remap = back.compact_with_remap() # ...with old id -> new id, uint64 bytes
n = back.compact_to_file("vocab2.bmp") # the same rebuilt base, streamed to a file
An id is never reissued and removal never renumbers, so an id held elsewhere keeps its meaning until
compact() — which is explicit for exactly that reason. load and from_bytes take the base class
because each index loads itself; the blob records which base wrote it, so passing the wrong class is
an error rather than an unchecked read of bytes meant for something else. save is atomic, like the
indexes' own — a crash or a full disk leaves the previous file whole rather than a truncated one —
and streams: an overlay over a mapped base of a gigabyte saves without a copy of the base.
Overlay.load and Overlay.from_bytes are checksummed and validated throughout. The header
carries a check of its own and a hash of everything after it, both verified before any of it is
read, so a flipped bit anywhere raises ValueError rather than loading as a different key or a
revived id. The framing is checked past the checksums too — the lengths, the additions and their
UTF-8, the tombstones against the id space — and the base region goes to the base class's loader,
which validates its own. A blob written by 0.12 still loads, without the two checksums that format
does not carry; saving it again writes the current one and it gains them.
key, keys and compact need the base to store its keys. CompactHashIndex does not, so an
overlay over it answers membership and raises TypeError for the rest — in Rust that same absence is
a compile error, since the methods live on a trait the keyless index cannot implement. Removal over
that base also inherits its false-positive rate: a contains that was never true of a real key can
retire an id, so remove by a key you know is present — or by the id itself:
ids = {key: ov.add(key) for key in ("neologism", "typo")} # whatever the caller already stores
ov.retire_id(ids.pop("typo")) # the tombstone, with no key to be wrong
retire_id sets exactly the tombstone remove sets and asks the base nothing, so it is the one
removal a false positive cannot mislead. An id at or above id_space(), or one already retired, is
False and changes nothing.
Batched lookups into a buffer¶
ids_of returns a list, which means one Python int per key. When the ids are headed for numpy
or array rather than for a loop, ids_of_bytes skips that: it returns the same answers packed as
native-endian fixed-width items, and np.frombuffer shares the memory instead of copying it.
import numpy as np
buf = idx.ids_of_bytes(probes) # 4 bytes/key here, 8 for StringIndex
ids = np.frombuffer(buf, dtype=idx.ID_DTYPE)
found = ids[ids != idx.MISSING_ID] # absent keys come back as MISSING_ID
ID_DTYPE and MISSING_ID are class attributes because the width differs between the index types
(StringIndex ids are 64-bit, the hash indexes' are 32-bit) — read them from the class rather than
hardcoding one. A buffer cannot carry None, so MISSING_ID stands in for an absent key; it is the
largest value of the width, which is never a real id, and ids_of_bytes refuses outright on the one
index size where it would be.
When the same probe batch size comes round again and again, ids_into writes the ids into a buffer
you already own rather than handing back a fresh bytes each call. Anything with a writable
C-contiguous buffer of ID_DTYPE items will do; a read-only, strided or wrongly typed one raises
BufferError, and one shorter than keys raises ValueError. The first len(keys) items are
written and the rest are left as they were.
out = np.empty(batch, dtype=idx.ID_DTYPE) # allocate once
for probes in batches: # each of exactly `batch` keys
idx.ids_into(probes, out)
consume(out)
Arrow columns, without a Python string per key¶
ids_of_arrow takes an Arrow utf8/large_utf8 column — a pyarrow Array or ChunkedArray, a
pandas column of ArrowDtype (through its __arrow_array__), a polars Series (through
to_arrow()) — and reads the keys straight out of its offset and data buffers, so no Python str
is built or borrowed per key; that was half to two thirds of what the list forms cost. The answer
is packed like ids_of_bytes, with MISSING_ID for an absent key and for a null
(ClosedHashIndex raises on a null — a closed vocabulary has no id for it), and ids_into_arrow
writes it into a buffer you own, as ids_into does. The buffers are copied once per call, which
is the price of doing this through the buffer protocol rather than Arrow's C Data Interface: a
memcpy against a hash and a cache miss per key.
import pyarrow as pa
col = pa.array(probes) # or table["word"], a polars Series, a pandas column
ids = np.frombuffer(idx.ids_of_arrow(col), dtype=idx.ID_DTYPE)
idx.ids_into_arrow(col, out) # into memory you own
On the shuffled dictionary (479 823 member probes, min of seven alternated rounds in one process,
local/arrowbench.py): CompactHashIndex 48 ns a key against 145 for ids_of and 105 for
ids_of_bytes; PerfectHashIndex 55 against 153 / 121; ClosedHashIndex 20 against 116 / 87;
StringIndex 264 against 468 / 435, where the transducer walk is most of the cost either way.
Threads, including free-threaded CPython¶
The module tells CPython it does not need the GIL, and the guarantee behind that is:
- The seven index types are immutable after building. Share one across as many threads as you
like and call
id,contains,key,ids_offrom all of them. Building, batch lookups and persistence release the GIL, so other threads keep running while a large index is built or queried. - The two types that hold mutable state — the
StringIndexiterator andOverlay— serialise. Sharing one of those between threads is safe: the calls take an internal lock and queue up rather than raising. A shared iterator hands each key to exactly one thread; concurrentOverlay.addcalls each get their own id.
Verified on CPython 3.14t with eight threads. Without the lock, PyO3's borrow flag turns a shared
iterator or overlay into RuntimeError: Already borrowed on a free-threaded build — seven of eight
threads failed before the fix, which is what tests/test_python.py now pins.
Free-threaded CPython has no stable ABI before 3.15 — PEP 803 puts abi3t there — so the abi3
wheel that serves every other interpreter cannot load on a free-threaded one. 3.14t therefore gets
a wheel of its own, cp314-cp314t, built from the same source with the same guarantees; pip picks
it over the sdist. Older free-threaded builds have no wheel: PyO3 supports 3.14t and not 3.13t.
Rust¶
use lexindex::{
ClosedHashIndex, CompactHashIndex, DictIndex, DoubleArrayIndex, HashedDictIndex,
PerfectHashIndex, StringIndex,
};
let idx = StringIndex::build(["apple", "apricot", "banana"])?;
assert_eq!(idx.id("banana"), Some(2));
assert_eq!(idx.key(0).as_deref(), Some("apple")); // rank-walk over the FST → owned String
assert_eq!(idx.prefix("ap").len(), 2);
let near: Vec<_> = idx.fuzzy("aple", 1)?.into_iter().map(|(k, _)| k).collect();
assert_eq!(near, ["apple"]);
// `save`, `load`, `load_mmap` and their `_untrusted` / `_verified` forms take any `AsRef<Path>`.
let path = std::env::temp_dir().join("lexindex-usage-catalog.bix");
idx.save(&path)?;
let info = lexindex::inspect_file(&path)?; // what the file is, from its header alone
assert_eq!((info.kind, info.keys), (lexindex::BlobKind::StringIndex, Some(3)));
// SAFETY: nothing may modify the file while a mapped index borrows it (see `load_mmap`).
let idx = unsafe { StringIndex::load_mmap(&path) }?; // zero-copy; no read into RAM
assert_eq!(idx.id("banana"), Some(2)); // the same answers, off the mapped file
let dict = PerfectHashIndex::build(["GET", "POST", "PUT"])?; // requires the default `mph` feature
assert_eq!(dict.key(dict.id("POST").unwrap()), Some("POST")); // exact reverse lookup
let small = CompactHashIndex::build(["GET", "POST", "PUT"], 1)?; // 1.24 B/key, no reverse
let tiny = CompactHashIndex::build_bits(["GET", "POST", "PUT"], 4)?; // 0.74 B/key, 6.25% FP rate
assert!(small.contains("POST"));
let closed = ClosedHashIndex::build(["GET", "POST", "PUT"])?; // the perfect hash alone, ~0.24 B/key
assert_eq!(closed.id("POST"), tiny.id_unchecked("POST")); // same hash, same ids; no membership
let words = DictIndex::build(["apple", "apricot", "banana"])?; // ordered, keys stored, ~2.64 B/key
assert_eq!((words.id("banana"), words.key(0).as_deref()), (Some(2), Some("apple")));
assert_eq!(words.lower_bound("ap")..words.lower_bound("aq"), 0..2); // the "ap" keys as an id range
assert_eq!(words.longest_prefix("bananas"), Some(("banana".to_string(), 2))); // longest match
words.route_microblocks(); // opt-in: ~0.5 B/key of memory, in no blob, for a faster search
let hashed = HashedDictIndex::from_dict(words, 8)?; // the dictionary, and a hash sidecar beside it
assert_eq!(hashed.id("banana"), Some(2)); // one hash and a read of each table, not a search
assert_eq!(hashed.dict().key(2).as_deref(), Some("banana")); // ordered queries: the dictionary's
let lexicon = DoubleArrayIndex::build(["北京", "北京大学", "大学", "大学生"])?; // a character a step
assert_eq!(lexicon.longest_prefix("大学生活"), Some(("大学生".to_string(), 3)));
let mut words = Vec::new(); // every key in the text: (start, end) in bytes, and the key's rank
lexicon.for_each_occurrence("北京大学生", |start, end, id| words.push((start, end, id)));
assert_eq!(words, [(0, 6, 0), (0, 12, 1), (6, 12, 2), (6, 15, 3)]);
# drop(idx);
# std::fs::remove_file(&path).ok();
# Ok::<(), lexindex::IndexError>(())
Building a corpus that does not fit in memory¶
Every index but DoubleArrayIndex has a build that never holds the keys, and each takes the shape
its structure allows:
use lexindex::{CompactHashIndex, DictIndex, PerfectHashIndex, StringIndex};
# let dir = std::env::temp_dir();
# let (bix, bmp) = (dir.join("lexindex-usage-stream.bix"), dir.join("lexindex-usage-stream.bmp"));
# let (bch, bdx) = (dir.join("lexindex-usage-stream.bch"), dir.join("lexindex-usage-stream.bdx"));
// `CompactHashIndex` keeps a 16-byte pair per key and drops the string: any iterator will do...
let small = CompactHashIndex::build(["a", "b", "c"].iter(), 1)?;
// ...and past memory the pairs are sorted in runs spilled beside the output, the perfect hash is
// built from the merged runs a chunk at a time, and the file is what `build` + `save` would write.
CompactHashIndex::build_to_file(["c", "a", "b"], &bch, 1)?;
// `StringIndex` needs the keys in ascending byte order and streams them into the transducer.
StringIndex::build_sorted_to_file(["a", "b", "c"], &bix)?;
// ...or takes them in any order through an external sort: runs spilled beside the output, merged.
StringIndex::build_to_file(["c", "a", "b"], &bix)?;
// `DictIndex` sorts the same way, then walks the sorted keys three times: the block heads and the
// count, the symbol table's training sample -- whose stride depends on that count -- then the
// encoding, which goes into the file as it is produced.
DictIndex::build_to_file(["c", "a", "b"], &bdx)?;
// `PerfectHashIndex` stores its keys in slot order, and slot order is only known once the perfect
// hash is built -- so it takes a *factory* and reads the source twice. Keys must be distinct.
PerfectHashIndex::build_to_file(&bmp, || ["c", "a", "b"])?;
# assert!(small.contains("a"));
# assert_eq!(std::fs::read(&bch)?, small.to_bytes()?);
# std::fs::remove_file(&bix).ok();
# std::fs::remove_file(&bmp).ok();
# std::fs::remove_file(&bch).ok();
# std::fs::remove_file(&bdx).ok();
# Ok::<(), lexindex::IndexError>(())
At 10 M real-word pairs the streamed PerfectHashIndex build peaks at 471 MB against 1 272 MB for
the same keys handed to build as a list; the streamed StringIndex build peaks at 49.6 MB against
721.9. The perfect hash's number includes the output file, which it fills through a mapping — its
anonymous memory is 20.6 bytes per key and does not grow with n.
The streamed DictIndex build peaks at 29.4 MB against 66.6 over 479 823 real words, for the same
bytes on disk. What it still holds is the block heads, the per-block arrays and one start a
microblock, about (mean head length + 20) / block + 8 / micro bytes per key — 0.36 at the
default on the dictionary — so a larger block holds less as well as storing less; the block data,
which is the bulk of the index, goes into the file as it is encoded. The rest is the symbol tables:
one trains per shard of 65 536 keys and they train in parallel, so a build holds one trainer per
training thread, about 2.9 MB each and at most 64 MB between them: past 21 trainers they queue
rather than multiply, which is what keeps the peak a property of the work and not of the core
count. It is never a function of n — on one
thread the same two builds peak at 17.2 MB and 34.3, against 12.7 and 32.1 when a single table
covered the whole index. It reads the sorted keys three times, and pays about 26 % in build time
for it.
The streamed CompactHashIndex build peaks at 302 MB at 100 M real-word pairs against 8 834 MB
for the same keys handed to build as a list (254 against 903 at 10 M), and 0.94 GB at 10⁹,
where the list would need about 90 GB. Under 256 MiB of fingerprint table the peak is the run
buffer itself or the perfect hash's construction plus the table, whichever is larger — at 100 M
they are within 5 MB of each other; past it the fingerprints go through range files and the peak
is the perfect hash's own construction, 0.9 bytes per key: 0.6 of it the table being built and
the keys its first level bumped, the rest the second level's grouping of those keys. Its
transient disk is the distinct pairs twice, 32 bytes per key, beside the output, plus twelve more
per key for the range files
past 268 M keys at the default width, so that no byte of the output is ever written at a random
offset. The 10⁹ build took seven and a quarter minutes here, six of them generating the keys.
The perfect hash also needs transient disk space: an output whose key arena exceeds 32 MB is filled window by window through a spill file next to it, so about 2.2× the output size has to be free in the target directory until the build finishes. Filling the arena in one pass instead is what made the build rewrite its own file dozens of times over.
StringIndex::build_to_file is build for a corpus that does not fit: every 256 MiB of keys is
sorted and deduplicated in memory and spilled as one run to a temporary directory beside the
output, the runs are merged — one buffered reader each — into the transducer, and the file is byte
for byte what build then save would have written. Peak memory is one run plus a 1 MiB buffer
per run; the transient disk is the distinct keys once, removed on every exit path. A corpus that
fits in one run never touches the disk before the output. Measured at 100 M real-word pairs
(w1.w2 over /usr/share/dict/words, a 1.30 GB blob), it peaks at 414 MB against 11 985 MB for
the same generator handed to build, and finishes in 150 s against 211: a run is an arena of key
bytes with a span per key, not a String each, so it also sorts faster. At 10 M pairs the corpus
fits in one run — 296 MB against 1 205, 19 s against 24 — and the blobs are identical either way.
Cargo features: mph (default) adds PerfectHashIndex, CompactHashIndex, ClosedHashIndex and
HashedDictIndex; mmap (default) adds load_mmap; --no-default-features is an fst-only build
(StringIndex, DictIndex, DoubleArrayIndex and Overlay, no extra dependencies). All of them compile for 32-bit targets,
wasm32-unknown-unknown included — leave mmap off there, since there is nothing to memory-map.
Editing without a rebuild — Overlay¶
Every index is built once from the whole key set, so adding a single key has always meant
rebuilding for the whole corpus. Overlay<I> wraps one with the keys added since and the ids retired
from it, making add and remove O(1) while the base stays untouched:
use lexindex::{Overlay, StringIndex};
let mut ov = Overlay::new(StringIndex::build(["apple", "banana"])?);
let cherry = ov.add("cherry"); // ids continue above base.len()
assert!(ov.remove("apple")); // the id is retired, nothing is renumbered
assert_eq!(ov.id("apple"), None);
assert_eq!(ov.key(cherry).as_deref(), Some("cherry"));
let blob = ov.to_bytes()?; // base + additions + tombstones in one file
let back = Overlay::from_bytes_with(&blob, StringIndex::from_bytes)?;
assert_eq!(back.len(), 2);
let (folded, remap) = back.compact_with_remap()?; // rebuild the base — this renumbers…
assert_eq!(folded.len(), 2);
assert_eq!(folded.key(remap[cherry as usize]).as_deref(), Some("cherry")); // …and says how
# Ok::<(), lexindex::IndexError>(())
An id is never reissued and removal never renumbers, so an id held elsewhere keeps its meaning for
the overlay's lifetime; compact() is the one call that breaks that, and it is explicit for exactly
that reason. compact_with_remap() returns the old → new id table alongside, and
compact_to_file(path) writes the rebuilt base straight to a file, streaming the live keys from
where the base holds them, for a base too large to hold twice. Lookups cost one base lookup plus a
bitset probe, and a miss in the base costs a hash map probe on top.
The base may be shared: OverlayBase is implemented for Arc<I>, so Overlay<Arc<StringIndex>>
leaves the index usable and lets several overlays sit on one base. That is what the Python bindings
use.
from_bytes_with takes the base's loader rather than picking one, because Overlay<I> is generic
over the base and each base parses its own blob: Overlay::from_bytes_with(&blob,
PerfectHashIndex::from_bytes). All three base loaders are safe fns, so the closure could now be a
trait method — it stays a closure because that is also how a base loaded some other way, or a stub
that skips the base entirely, gets in: the fuzz target and the framing property tests both need it,
and the wrong loader is already refused by the base tag rather than by the type.
key, keys and compact need the base to store its keys, which CompactHashIndex does not — an
overlay over it answers membership and nothing else, and the absence is a compile error rather than a
runtime one. Removal over that base also inherits its false-positive rate: a contains that was
never true of a real key can retire an id, so remove by a key you know is present.
The blob records which base wrote it, so from_bytes_with refuses a mismatch before calling the
loader — handing perfect-hash bytes to the wrong deserialiser is not something a caller can do by
mistake.
C¶
The capi feature exports six of the seven indexes to C — all but DoubleArrayIndex: one opaque
LexindexIndex handle, fourteen lexindex_* functions, and the header include/lexindex.h,
generated from src/capi.rs by cbindgen and regenerated in CI so the two cannot drift.
cargo build --release --features capi # target/release/liblexindex.so
cargo rustc --release --features capi --crate-type staticlib # liblexindex.a instead
cc -std=c11 examples/capi.c -Iinclude -Ltarget/release -llexindex -o capi
LD_LIBRARY_PATH=target/release ./capi
#include "lexindex.h"
const char *keys[] = {"cherry", "apple", "banana", "apricot"};
size_t lens[] = {6, 5, 6, 7};
LexindexIndex *index = NULL;
if (lexindex_index_build(LEXINDEX_KIND_DICT, keys, lens, 4, &index) != LEXINDEX_STATUS_OK)
fprintf(stderr, "%s\n", lexindex_last_error());
uint64_t id;
lexindex_index_id(index, "banana", 6, &id); /* LEXINDEX_STATUS_OK, id 2 */
lexindex_index_id(index, "durian", 6, &id); /* LEXINDEX_STATUS_NOT_FOUND */
uint64_t ids[4];
lexindex_index_ids(index, keys, lens, 4, ids); /* 3 0 2 1; LEXINDEX_NO_ID for a miss */
char key[16]; size_t len;
lexindex_index_key(index, 0, key, sizeof key, &len); /* "apple", NUL-terminated, len 5 */
lexindex_index_key(index, 3, NULL, 0, &len); /* BUFFER_TOO_SMALL, len 6: the size first */
lexindex_index_save(index, "fruit.bdx");
LexindexIndex *again = NULL;
lexindex_index_open("fruit.bdx", &again); /* whatever kind the file holds */
lexindex_index_free(again);
lexindex_index_free(index);
The rules, each of which the header states beside the function it binds:
- Every fallible call returns a
LexindexStatus,LEXINDEX_STATUS_OKbeing zero. A failure leaves its message inlexindex_last_error(), one per thread, valid until the next failure on that thread;NOT_FOUNDis an answer rather than a failure and leaves it alone. A null pointer where one is required isINVALID_ARGUMENTon every function that has a status to return it in. - One handle, six kinds. A C caller opens a blob it may not have written, so the kind is a
run-time fact:
lexindex_index_kindsays which, and what a kind cannot answer isUNSUPPORTEDrather than a missing symbol —keyonLEXINDEX_KIND_COMPACTandLEXINDEX_KIND_CLOSED, which store no keys;containsonLEXINDEX_KIND_CLOSED, the perfect hash alone.idmeans what the kind means: exact for string, dict and perfect; a miss answers as present one time in 256 for compact, and for hashed dict at the width it was built with — eight bits fromlexindex_index_build, exact at zero; every key gets some id for closed. - Keys are
(pointer, length)UTF-8, not NUL-terminated, sostrlenis the caller's; paths are NUL-terminated.keywrites NUL-terminated and reports the length without the terminator;buf = NULL, cap = 0asks for the size, andBUFFER_TOO_SMALLreports it as well. - Ownership. A handle is freed by
lexindex_index_freeand nothing else; keys and paths are borrowed for the call;lexindex_index_from_bytescopies. A handle is immutable, so any number of threads may query one at once. - Versioning.
LEXINDEX_ABI_VERSIONis the ABI the header describes andlexindex_abi_version()the one the library was built with; within a number symbols are only added, and a change to one is a major release of the crate.lexindex_version()is the crate version.
Not in the ABI, each additive when someone asks: Overlay, the mmap loaders, the automata queries
(prefix, range, fuzzy, subsequence), and the fingerprint and block knobs of build.
Command line¶
The crate ships a binary of the same name, so the choice between five indexes can be made without
writing any code: cargo install lexindex, or cargo run --bin lexindex -- from a checkout. The
Python wheel carries the same program, so pip install lexindex installs the same lexindex
command with no Rust toolchain, and python -m lexindex runs it where the environment's scripts
are not on the path. It is a shell over plan_file, the builders, inspect_file and the indexes'
own key iterators, and computes nothing of its own.
$ lexindex plan /usr/share/dict/words --reverse --prefix
479823 keys, mean length 9.3, mean shared prefix 6.3, ranked by size
* DictIndex 1190567 bytes 2.48 B/key 384 ns estimated at block 1024
DictIndex 1252952 bytes 2.61 B/key 336 ns estimated at block 256
DictIndex 1357175 bytes 2.83 B/key 311 ns estimated at block 32
StringIndex 2882400 bytes 6.01 B/key 297 ns estimated
the nanoseconds are a model of this crate's own machine, not a measurement of yours
$ lexindex build /usr/share/dict/words words.bin --reverse --prefix
479823 keys, mean length 9.3, mean shared prefix 6.3, ranked by size
* DictIndex 1190567 bytes 2.48 B/key 384 ns estimated at block 1024
DictIndex 1252952 bytes 2.61 B/key 336 ns estimated at block 256
DictIndex 1357175 bytes 2.83 B/key 311 ns estimated at block 32
StringIndex 2882400 bytes 6.01 B/key 297 ns estimated
the nanoseconds are a model of this crate's own machine, not a measurement of yours
wrote words.bin: DictIndex over 479823 keys, 1205985 bytes (2.51 B/key)
$ lexindex inspect words.bin
kind: DictIndex
format: BDX3
bytes: 1205985
keys: 479823
arena_bytes: 1173645
$ lexindex dump words.bin | head -3
&c
'd
'em
inspect --sections reads a DictIndex whole and says where its bytes went. The sections always
sum to the blob, so the output is an accounting rather than an estimate, and each is given per key
and as a share:
$ lexindex inspect words.bin --sections
kind: DictIndex
format: BDX3
bytes: 1205985
keys: 479823
arena_bytes: 1173645
sections.header: 72 (0.0002 B/key, 0.01 %)
sections.tables: 7215 (0.0150 B/key, 0.60 %)
sections.header_codes: 761 (0.0016 B/key, 0.06 %)
sections.phrases: 4 (0.0000 B/key, 0.00 %)
sections.heads: 4282 (0.0089 B/key, 0.36 %)
sections.head_ends: 659 (0.0014 B/key, 0.05 %)
sections.block_offsets: 1128 (0.0024 B/key, 0.09 %)
sections.micro_offsets: 22501 (0.0469 B/key, 1.87 %)
sections.chars: 0 (0.0000 B/key, 0.00 %)
sections.restart_headers: 9845 (0.0205 B/key, 0.82 %)
sections.restart_wide: 794 (0.0017 B/key, 0.07 %)
sections.restart_codes: 47107 (0.0982 B/key, 3.91 %)
sections.entry_headers: 299889 (0.6250 B/key, 24.87 %)
sections.entry_wide: 48282 (0.1006 B/key, 4.00 %)
sections.entry_codes: 763446 (1.5911 B/key, 63.30 %)
sections.total: 1205985 (2.5134 B/key, 100.00 %)
sections.restarts: 14526
sections.entries: 464828
sections.wide: 24538
Read it as four groups. The codes are the front-coded suffixes under whichever codec the shard
settled on, which is what the index is for. The headers are the (lcp, len) stream and the
varints of the pairs no code could name — 0.75 bytes a key here against the flat 0.97 BDX2 spent,
a quarter of the blob on this corpus and near half of it on dense decimal ids, since there is almost
nothing else to store. The vocabulary — the symbol tables, the code each shard's headers took,
the phrase dictionary and, in a BDX4 blob, the character code its keys are spelled in — is
0.66 % here, and is the part that grows with the shards rather than with the keys. The
directory — the three packed offset arrays and the block heads — is 2.7 % at this block and
never more than a few per cent, which is worth knowing before optimising it. The
per-block samples the opening search reads are not in the list because they are not in the blob:
eight bytes of a head each, built from the heads at load. --sections is a DictIndex's alone; on any other kind it says so.
The same numbers are on DictSections
for a program that would rather not parse text.
dump writes the keys a blob holds, one per line, in id order — sorted order for the two ordered
indexes, construction order for PerfectHashIndex, and live keys only for an Overlay. It exists
for one reason: lexindex dump old.blob | lexindex build - new.blob is the migration path off a
format a later version stops reading, and it round-trips — rebuilding from a dump reproduces the
blob byte for byte. CompactHashIndex and ClosedHashIndex store no keys at all, so dumping one is
an error that says so rather than an empty file.
plan and build take the same five needs, which say what the index must be able to do and so
narrow what may be picked — with none of them the only question asked is id(key), and the two
probabilistic indexes are allowed:
--reverse key(id) as well as id(key)
--ordered ids in lexicographic order, and in-order iteration
--prefix prefix and range queries (an ordered index)
--fuzzy Levenshtein and subsequence queries -- only StringIndex answers this
--exact a non-member must be answered as one -- bars CompactHashIndex and ClosedHashIndex
The command line assumes an open vocabulary, and so implies --exact. Asked to rank with no
needs at all, the honest answer on size is ClosedHashIndex at 0.24 bytes a key — and it answers a
key it has never seen with some member's id, because it stores nothing to check against. That is the
right index for a fixed vocabulary and a trap for anything else, and a ranking is read by someone
who has not yet decided. So plan and --index auto leave the two probabilistic indexes out and
say on stderr that they did:
$ lexindex plan words.txt
479823 keys, mean length 9.3, mean shared prefix 6.3, ranked by size
* DictIndex 1190567 bytes 2.48 B/key 384 ns estimated at block 1024
DictIndex 1252952 bytes 2.61 B/key 336 ns estimated at block 256
DictIndex 1357175 bytes 2.83 B/key 311 ns estimated at block 32
StringIndex 2882400 bytes 6.01 B/key 297 ns estimated
PerfectHashIndex 4641399 bytes 9.67 B/key 131 ns estimated
the nanoseconds are a model of this crate's own machine, not a measurement of yours
excluded: needs exact — CompactHashIndex (a bounded false-positive rate) and ClosedHashIndex
(a stranger gets some member's id). Pass --closed-vocabulary if every key you will ask about is
in this file, and they are ranked with the rest.
--closed-vocabulary is that promise, and puts both back in the ranking. The line is printed only
when the default is what excluded them: ask for --prefix and they were never candidates, so
nothing is explained. Naming an index with --index closed builds it whatever the needs say — the
needs narrow a choice, and naming one is not a choice to narrow. The library is unchanged:
Needs::default() in Rust and Python still asks only for id(key), because a program that calls
plan has a caller who decided, and a command line has a person who is still deciding.
build writes only the blob: the ladder and the one-line summary both go to stderr, so stdout
stays free. --index auto is the default and is what asks the planner;
--index dict | string | compact | closed | perfect names one instead and skips the plan entirely.
On the 479 823-word dictionary that is 468-490 ms against 127-135 over nine alternating runs --
--index auto pays for the planner's two 100 000-key draws, which build every candidate the needs
left, and the dictionary once per priced block. Below 100 000 keys the plan builds the real indexes
rather than modelling them, so an auto build there builds every candidate and then the winner again;
naming the index is how not to.
--objective is what the ladder ranks for. memory is the default and the smallest blob, which
is what plan has always answered; latency is the fastest id(key); balanced is whichever
candidate gives up least on the axis it does worse on. The candidates and their sizes do not change
with it -- only the order, and so what --index auto builds. On the word list above, memory
answers DictIndex at block 1024 and 2.48 B/key, latency answers PerfectHashIndex, four times
the size and a third of the wait, and balanced answers the same dictionary at block 32 -- 19 %
faster for 14 % more space. The block is a candidate of its own, so an objective picks one of those too.
--objective also takes a workload: op=weight pairs between commas, ranked by the workload's mean
operation. The ops are hits and misses (id(key) on a member and on a stranger), reverse
(key(id)), prefix (a prefix or range query, priced as a prefix_count), common_prefix and
longest_prefix; batch=N is the keys an ids_of call holds. The weights are relative, so counts
read off a log serve as they are. An op is also a question the index has to answer: reverse rules
out the two indexes that store no keys and a prefix query asks for an ordered one, whatever the
flags said. The ordered pair is where this decides something. On the same word list
--objective common_prefix=9,hits=1 answers StringIndex at 397 ns against 1853 for the fastest
dictionary, prefix=9,hits=1 answers DictIndex at block 32 -- 276 ns against 806 -- and
hits=9,misses=1,batch=1024 leaves PerfectHashIndex first at 56 ns a key.
The nanoseconds are modelled, and of one machine. a + b·s + c·len + d·s·len per structure,
operation and DictIndex block, where len is the mean key length and s is log2 of the blob
over 64 KiB -- what a lookup waits on is how far its bytes are from the CPU, which the key count
alone does not say. The constants are fitted to 240 cells timed on this crate's own hardware -- a
Ryzen 7 5800HS with 16 MB of L3 -- by bench/latency_model.py, whose fit re-derives the tables in
src/estimate.rs from the artifact under bench/results/. Mean absolute error is 6-12 % on
id(key); on prefix_count it is 26-42 %, because that operation's cost is dominated by how many
keys the prefix actually matches and the model has no term for it. Two things follow. It is an
ordering: fitted with a corpus left out, it names the fastest index on that corpus in 30 of 30
corpus-size cells for id(key), for nine hits to a miss, for batches of 1 024, for key(id) and
for a common_prefix-heavy workload; over ten workloads the mean pick costs 1.000-1.050× the
fastest. Against the published sweep, which it was not fitted to, it names the fastest of every
lane in 28 of 30 cells and picks between DictIndex and StringIndex the way the measurement does
in 29, never choosing one more than 1.02× slower. What it gets wrong is a choice between ordered
indexes, because there the candidates are close: the transducer is the faster at 100 000 keys on
six corpora of thirteen, and the blocks of one dictionary are often within a few per cent of each
other. Held out, it names the fastest ordered id(key) in 19 of 30 cells and one within 5 % of it
in 23, never one more than 1.11× slower -- and 5 % is about what placement in physical memory moves
a single-threaded probe on this machine. And it is high: the cells were timed through the Python
binding, so every number carries that call, and the model reads 297 ns for StringIndex on the
word list where the Rust harness in docs/benchmarks.md measures 256-270. The overhead falls on
every candidate, so it moves the numbers and not the ranking -- but do not quote them as your own.
--block sets the DictIndex block -- 1..=1024, or fast / balanced / compact for 32 / 256 /
1024 -- and requires --index dict. It is refused alongside auto on purpose: the plan prices all
three named blocks as candidates of their own and ranks them with the rest, so a block named beside
auto would ask the planner to choose one and then overrule its answer. --index auto builds the
block on the line it marked; to choose one yourself, read it off the ladder and name both.
--stream decides whether the build holds the corpus. With a named --index and a keys file --
never standard input, which cannot be rewound -- build feeds the file straight into the streaming
builders, and what stays in memory is the index being made rather than the keys. auto (the
default) does it past a quarter of what /proc/meminfo says is available, always and never
force it either way, and a size (1000, 512M, 4G) sets the threshold. On a 925 MB, 7 343 721-line
path list the peak resident set falls from 1.34 to 0.28 GB for string, 1.42 to 0.26 for dict,
1.29 to 0.15 for compact and closed, and 2.96 to 0.97 for perfect -- whose own blob is 935 MB,
so it is the one index that cannot end below the file it was built from. The blobs are byte for
byte the ones the in-memory build writes, so this is a memory setting and never a format.
--index auto streams too, since plan_file prices the file without holding it either: the
ladder is printed from the streamed plan and the winner is then built the same way. What is still
read in is standard input, and a kind with no streaming build in the current feature set — build
says which on stderr rather than pretending.
A keys file is one key per line, UTF-8, in any order; - reads standard input, and duplicates are
removed by the builders. An empty line is skipped -- a file that ends in a newline is the common
case and an empty key is not -- and the count is reported on stderr wherever the command reads the
file in. plan over a file does not read it in, so there it is skipped silently. Invalid UTF-8 is an error
naming the line rather than a replacement character, since the library takes &str and an index
built from lossy bytes would hold keys the file does not.
The exit code is 0, 2 for a command line that does not parse (the usage text follows the
message), and 1 for work that fails -- a missing file, a blob that is not one, a build the library
refuses, a key with a line break in it that dump cannot write as a line. lexindex --help prints the whole surface; three of the five indexes need the mph
feature, and a build without it says so rather than pretending.
Benchmark¶
python bench/compare.py measures serialised size on real dictionary words against marisa-trie,
DAWG and datrie (the double-crown table above). python bench/scale.py measures build time, peak
memory, and lookup latency from 1 M to 100 M real keys, each cell once with the keys handed over as
a list and once as a generator — the second is what CompactHashIndex's streaming build exists for,
and the only way to see its own footprint rather than the corpus's. cargo run --release --example
bench measures point-lookup latency for the indexes against std::HashMap (SipHash and
FxHash) and BTreeMap on real dictionary-word bigrams (it refuses to run without a word list rather
than substitute synthetic keys); cargo run --release --example peak reports the peak resident
memory and wall time of one build, one index per process; cargo run --release --example
mmap_zero_copy times the owned load against the zero-copy load_mmap.