Changelog¶
All notable changes to this project are documented here. The format follows Keep a Changelog and the project adheres to Semantic Versioning.
[Unreleased]¶
Added¶
PerfectHashIndexstores its keys overlapped where that is smaller: 10.88 → 9.25 bytes a key on the 479 823-word dictionary. A new arena encoding, tag0x13, holds a greedy shortest common superstring of the keys — a key inside another costs no bytes, and an overlap between two is stored once — with one packed(start, length − min)entry a slot, the fingerprint in the same entry, and an overflow table for lengths past the entry's field.buildandbuild_with_fingerprintsprice it beside the blocked layout and keep the smaller, a tie to the blocked one, so no key set grows. Over sixteen benchmark corpora the arena shrinks by 46 % on numbers, 33 % on DNA, 13–15 % on words, Chinese words, identifiers and package names, 11 % on Russian titles, 9–10 % on hex keys and UUIDs and 0.1–5 % on other titles; URLs, domains and paths keep the blocked layout.key(id)is still a zero-copy&strand membership one comparison. The merge runs on an Aho–Corasick automaton inO(N log σ + n log n)forNkey bytes, deterministically, ties to the lower slot; it holds 9 bytes a trie node and a few dozen a key, freed before the arena is laid out. A load checks every merged entry once — inside the data and on character boundaries — soload_mmapof a merged arena isO(n), and a crafted entry is refused. Two golden blobs pin the encoding byte for byte and seed theparse_perfectfuzz target.- Compatibility: upgrade readers first. The magic stays
BMP8; lexindex 4.0–4.5 refuse a merged arena asarena: unknown offset encoding, and a blob whose arena stays blocked loads on every 4.x.build_to_file,build_to_file_with_fingerprints, a streamedlexindex buildandOverlay::compact_to_filewrite the blocked layout as before: the same ids asbuild+save, not always the same bytes.planprices the merged arena from a sample and reads high where the overlap grows withn: 9.67 bytes a key on the dictionary against 9.25 built.
Documentation¶
- The perfect hash's tail exhausts its 64 hash seeds only on duplicate hashes, now measured.
Over 10 000 random 256-key tails 9.05 % needed a second hash seed and 8.1 % of those a third —
independent attempts, under which 64 failures are about 10⁻⁶⁷
(
mphf::spike::tail_attempt_histogram, behindbench-mphf).
[4.5.2] — 2026-09-25¶
Fixed¶
- A crafted
DictIndexblob whose restart keys run out of order could make a routed lookup panic. Since 4.4.0route_microblockssends a lookup to its microblock by words it reads off each block's restart keys, and the route counts the bytes the probe shares with the word below it. That count stays inside the probe only while the words ascend, and nothing checked that they did: in a blob libFuzzer's dailyparse_dictrun found, the word taken as below was above and agreed with the probe's zero padding, so the scan began past the probe's end. A debug build panicked on the subtraction; a release build compared the stored suffix against more padding, and a dictionary in a packed code slices the probe from there, which panics in any build. Every read is bounds-checked, so none left the probe. Words out of order now leave their block to its restart run. Only a dictionaryroute_microblockswas called on was exposed, aHashedDictIndex's included; an index this crate built always has its words in order, so none changes. The blob is kept with a test that fails without the check (tests/data/panicking-4.5.0-dict-route.bdx).
[4.5.1] — 2026-09-25¶
Fixed¶
- A
DictIndexlookup at ten million keys and block 32 is back at 4.4.0's time; 4.4.1 had made it up to 23 % slower. 4.4.1 gave every run of two or more blocks whose samples tie a trie of their words — a threshold measured on file paths, whose runs are 183 and 3 572 blocks long. Where runs are short, the trie's reads (its roots, a node, three edge arrays megabytes apart) replaced two or three head compares on adjacent lines: over ten million DNA reads at block 32 all 312 500 blocks sit in 65 536 runs of 3–7, so every lookup took a trie, 6.3 MB of them, for 6.4 more L2-missing fills and 2.3 more page walks a probe. Only a run of eight blocks or more gets a trie now. In a quiet A/B, three processes a variant, alternated (bench/results/ties-ab-2026-09-25-arz-2be75f3.txt): DNA 724 → 595 ns (4.4.0: 588), the routed layout 705 → 605 (600), English titles 677 → 661 (646), URLs 774 → 747 (738); paths at block 256 keep 4.4.1's gain, 700 against 4.4.0's 782; and DNA at 1024, where no samples tie, reads 472–475 in all three. A minimum of 32 read as 8 does on titles and URLs, 660 and 749. Blobs are unchanged. - On a 32-bit target, a crafted blob of a hash index could make a load panic rather than fail.
The count of the perfect hash's remap low words was multiplied in
usize, which wraps there for a header claiming enough entries over a wide enough image; the table came out short, and only the bounds checks on its reads kept a load inside it, by panicking. The count is now checked, and such a blob is refused as too large — which the remap's reads, unchecked since this release (below), depend on. A property test holds the count to the exact one worked out in 128 bits.
Changed¶
DictIndexbuilds 9–33 % faster, on every one of 13 corpora at blocks 32, 256 and 1024, blobs byte for byte the same. A build already ran on every thread; the gain is in the part no thread shared. Training draws its samples from the runs it keeps rather than walking every key; the order check and the key view's page faults run on every thread; the phrase trie a round builds is laid out 64 slots at a time and reads a candidate's head in one load; and a round ranks its candidates by merging the partitions' sorted leads. In a quiet A/B, one build a process, 11 rounds alternated (bench/results/dict-build-ab-2026-09-25-arz-8c99766.txt): English titles at block 32 397 → 349 ms, Chinese titles 272 → 224, URLs 404 → 367, numbers 85 → 60 — the least gain 9.3 % (URLs), the most 32.5 % (numbers at 1024). ART, the quickest builder in the string-index suite, built the English titles in 161 ms in its 4.5.0 campaign, where lexindex's quickest took 400: the gap is narrower, not closed.DoubleArrayIndexbuilds in under half the time, and maps its file as fast as darts-clone opens one. Over jieba 0.42.1's 349 045 words (bench/results/cjk-build-load-2026-09-25-arz-6623184.txt), a build takes 0.159 s where 4.5.0 took 0.354, 6.49 G instructions → 2.49 G: the trie is built from the keys in byte order, each decoded once; a narrow row's search tests a sparse stretch of the array word by word; and the placement tests four words an instruction where the CPU has AVX2. darts-clone still builds in 0.055 s and cedarwood in 0.099 — 2.9× and 1.6× quicker. A load's walk over every slot, which is what lets no query check a bound, now runs on the slots' 32-bit halves, eight slots an AVX2 instruction or four with SSE2, 15.0 M → 2.7 M instructions:load_mmap1.03 → 0.62 ms in a pairwise run against darts-clone'sopenat 0.59–0.66 in the same processes, and 0.70 against 0.81 in the table's run;load2.1 → 1.6. The same slots are refused, and blobs are byte for byte 4.5.0's.- The perfect hash's lookup: a key the first level bumps runs 22.5 % fewer instructions, and the
single lookup is ahead of PtrHash's fast set at a million keys where it was level. The seed's
shift is added to the hash before its field is cut out, one instruction fewer on every placed key.
A bumped key decodes its later level through the step table the first level ships; counts the
remap's window with
popcntwhere the CPU has it, found by std's cached detection — every other target and Miri run the portable build, and a property test holds the two to the same answers; and reads the remap's tables without a bounds check past the one on its entry, on lengths and samples the loader has validated. Per key at a million keys, the harness's loop included: 284.8 → 220.7 instructions a bumped key, 24.7 → 22.8 over all keys, and 2.85 fewer in theidofClosedHashIndex,CompactHashIndex,HashedDictIndexandPerfectHashIndex. In a cool A/B ofbench/mphf_vs, one lookup thread, six processes a commit (bench/results/mphf-cool-ab-2026-09-25-arz-e523ecd.txt): the single lookup 1.8 → 1.65 ns at 1 M against fast's 1.8, and 2.5 → 2.4 at 10 M against 2.9;index_all2.0 → 1.9 and 2.5 → 2.45, against 2.4 and 2.8.
Documentation¶
SECURITY.mdcounts 21unsafe fns and 62 blocks, the three builds for an instruction the x86-64 baseline lacks among them — the remap'spopcntlookup and the double array's AVX2 placement and slot check — each entered only onceis_x86_feature_detected!has found it.- The double array's build and load numbers are 4.5.1's in the README, the design notes and the benchmarks, whose table now says where it still loses (the build) and where it no longer does (the load); the perfect hash's benchmarks record the single lookup's lead.
- The frontier harness times
DoubleArrayIndex, and reports a corpus past its limits as refused rather than as a failure; the tables list only the structures a campaign ran.
[4.5.0] — 2026-09-25¶
Added¶
DoubleArrayIndex: a character-wise double-array trie for matching a lexicon against running text, and the fastest walk measured over Chinese. Over UD Chinese GSDSimp against jieba 0.42.1's 349 045-word lexicon it finds every word at every position in 10.6 ns a character, against daachorse's 15.5 for the same block scan, and walks a position at a time in 13.4 against crawdad's 15.8, darts-clone's 27.8, cedar's 29.0 andStringIndex's 58.6 (bench/results/cjk-walk-2026-09-25-arz-902e4b4.txt). A label is a character — its frequency rank in the lexicon, read from a table indexed by code point — rather than a byte, so a Chinese word of three characters is three steps, not nine, and each eight-byte slot holds everything a step reads: the label, whether a key ends there and whether anything continues, the child row's base and the key's id. A step is one load, and a match costs none past it. It answerscommon_prefix,longest_prefix,for_each_common_prefix,for_each_occurrence— every key at every position, the text decoded once — andid, with the idsStringIndexgives the same keys; nokey(id), no prefix enumeration. 15.62 bytes a word on that lexicon, against 17.75 for darts-clone and yada, 18.40 for crawdad, 47.95 for daachorse and 60.57 held in memory by cedarwood. It loses on the build and the load (bench/results/cjk-build-load-2026-09-25-arz-902e4b4.txt): 0.35 s over the lexicon against darts-clone's 0.06 and cedar's 0.10, andload_mmap, which walks every slot, 1.07 ms against darts-clone'sopen, which checks nothing, 0.78. Every row goes to the lowest base where it fits, and floors — the lowest slot a set of labels could take never moves down — keep that exact fit fast on a small alphabet: 0.5 s over 480 k English words, where the plain search took 16.6. Limits, refused at build: 2²³ keys, 65 535 distinct characters, 2²³ slots. The blob isBDA1, checked slot by slot on every load,load_mmapincluded, so a crafted blob answers wrong ids and never reads out of bounds; a fuzz target (parse_double_array) and a golden blob pinned byte for byte. Python:lexindex.DoubleArrayIndex, whoseoccurrencescounts characters; plugged into jieba it cuts in 30 % less time than jieba's own dictionary, 24 % with the HMM, and 4–6 % less thanStringIndex.occurrences(bench/results/cjk-prefix-py-2026-09-25-arz-902e4b4.txt).inspectreports it;plan,Overlayand the C ABI do not take it.
Documentation¶
pathsis re-measured at 4.4.2 in the million-key frontier, and the Python jieba lane on 4.4.2's wheel.pathsalone, every structure, three rounds (bench/results/frontier-named-2026-09-24-arz-b101076.json): with 4.4.1's tie trieDictIndexreads 712 ns at block 256 and 758 at 1024, where the 4.4.0 campaign read 794 and 826, against XCDAT 15's 618 — still behind, by 1.15× at block 256 and 1.11× at block 32, where it was 1.25× and 1.20×. Every competitor reads 0.971–1.012 of the campaign and every size is byte-identical. Through Python (bench/results/cjk-prefix-py-2026-09-24-arz-b101076.txt), jieba overStringIndex.occurrencesbuilds the DAG in 560 ns a character against 649 over its own dictionary and cuts in 27 % less time, 21 % with the HMM.- The common-prefix table on English words compares the same work, and
DoubleArrayIndexanswers both queries fastest there. Re-measured at 4.4.2 first (bench/results/common-prefix-2026-09-24-arz-b101076.txt:StringIndex561 and 285 ns againstdatrie's 544 and 279, where 3.0.0 read 636 and 357), the table timeddatrie's keys alone against lexindex's(key, id)pairs. It now timesdatrie'sprefix_itemsandlongest_prefix_itemand marisa's ids, keepsdatrie's keys-only row as such, and times each call after an untimed pass of its own (bench/results/common-prefix-2026-09-25-arz-902e4b4.txt):DoubleArrayIndex473 and 234,StringIndex530 and 295,datrie780 and 417, its keys alone 344 and 237. Timed after another structure's pass instead, as before,DoubleArrayIndex'scommon_prefixreads 691 againstStringIndex's 578: its array is 11.7 MB on these words. - The Chinese walk table is re-measured with every double array, each row timed warm. The table
it replaces ran the rows alternated, each after whatever the rows before it had left in cache,
which favoured the rows nearest the turn of a round: it had darts-clone at 41.1 and 34.8 ns and
cedar at 49.5 and 46.2; timed after two passes of its own they read 27.8 and 34.5, 29.0 and 36.2.
yada, crawdad's
TrieandMpTrieand daachorse join them, with a build and load table. - The README and the docs' front page say what
StringIndexis for and what it is not. It is the automaton index — autocomplete, fuzzy search, ordered browse — and for an exactstring ↔ idaloneHashedDictIndexis faster andDictIndexsmaller, and for matching a lexicon against textDoubleArrayIndex.
[4.4.2] — 2026-09-24¶
Changed¶
- A
StringIndexprefix walk —common_prefix,longest_prefix,occurrences— takes its first character from a table: over Chinese text it runs 18–21 % faster. A walk reads the query down the transducer a byte at a time, each node's header read afresh, and a query's first character crosses the widest nodes, the root and the ones that spell the rest of its bytes. Over UD Chinese GSDSimp against jieba 0.42.1's lexicon that character took 649 of a walk's 1 025 instructions. The first walk now derives the state past every first character in the Basic Multilingual Plane — a bitmap of 64-code-point pages up to the highest such character, a count a page, a node address and output sum a character that begins a key — and a walk starts from there, or stops at once when no key begins with its first character; a character outside the plane is walked as before. 581 instructions a character. Against 4.4.1 in one binary over five processes (bench/results/cjk-prefix-ab-2026-09-24-arz-dda19a7.txt): 80.8 → 63.9 ns a character in text order and 93.5 → 76.5 shuffled, 0.79–0.80 and 0.81–0.82 of 4.4.1's time in every process. cedarwood 0.5.0 walks the same text in 49.5 and 46.2 ns and darts-clone 0.32 in 41.1 and 34.8, so lexindex still loses this walk, by 1.3–1.7× and 1.6–2.2× where it lost by 1.6–2.0× and 2.0–2.7×. On five more corpora a walk runs 22 % fewer instructions over a million Chinese titles, 10 % over words and Russian titles, 8 % over English titles and 3 % over URLs (bench/results/prefix-walk-counts-2026-09-24-arz-dda19a7.txt, counts only). The table is held in memory only and no blob changes: 0.29 bytes a key on jieba's lexicon, 0.085 on the Chinese titles, 0.03 on the English and Russian ones. The first walk derives it, not the load, so an index that only answersidnever holds it; on the lexicon that first walk runs 2.9 M instructions more.
Documentation¶
- The common-prefix walk over Chinese text is measured against cedar and darts-clone, and
lexindex still loses it.
docs/benchmarks.mdgains a section on jieba's lexicon over 4 997 sentences: from Rust, 4.4.2'sStringIndexwalks 1.3–1.7× slower than cedarwood 0.5.0 and 1.6–2.2× slower than darts-clone 0.32, holding a seventh of cedar's bytes and half darts-clone's; from Python, jieba over 4.4.0'sStringIndex.occurrencescuts in 25 % less time than over its own dictionary, 18 % with the HMM, the fastest backend measured (bench/results/cjk-prefix-2026-09-24-arz-3a73ed5.txt).
[4.4.1] — 2026-09-24¶
Changed¶
- A
DictIndexlookup that lands among blocks whose samples tie is placed by a trie of their words: on a million file pathsidtakes 8 % less time, at the default block and at 1024. A lookup opens with a binary search over one eight-byte sample a block, taken past the bytes every head shares; where a run of blocks shares the probe's sample, their heads were compared one at a time. On paths that run is the index — 3 899 of 3 907 samples tie at the default block, paths sharing long prefixes — and a lookup made 11.4 head compares, a quarter of its instructions. A load now splits each such run by its heads' eight-byte words past what they share, and a sub-run that ties again the same way past its own prefix: on paths 1 136 nodes and 5 035 edges, nine levels at the deepest. A lookup descends it reading only the probe, checks once against the head it arrives at, and starts the block's scan from that compare: 16.3 % fewer instructions a memberidat 256 and 10.8 % at 1024, and within 0.6 % — all of it fewer — on words, English titles, URLs and UUIDs. Every answer is the one 4.4.0 gives, and no blob changes, byte for byte. The trie is held in memory only: 0.10 bytes a key on paths at 256, 0.02 at 1024, under 0.005 on titles and URLs, nothing where no samples tie. A load of paths runs 11 % more instructions at 256 and 3 % at 1024, and at most 1 % more elsewhere. Against 4.4.0 in one process, each build loaded twice, over three processes (bench/results/id-ab-2026-09-24-arz-b362f43.txt):idon paths 768 → 704 ns at 256 and 812 → 750 at 1024, every allocation of the new build under every one of the old; on English titles, URLs, UUIDs and words, and everykey_into, within ±1.5 %. What it costs is at load, where the trie is built: a mapped load of paths takes 238 µs against 130 at 256 and 118 against 96 at 1024, andfrom_bytes, which reads and checks the whole blob, 4.2 and 0.2 % longer. Elsewherefrom_bytesmoves by at most 1.7 % andload_mmapby at most 5 %, 12 µs. - A load of a
DictIndexin a character code holds half the tables it did. Every load built the code's lookup tables beside the blob — a mapped one too — and they held the alphabet twice, took four bytes a codeword where sixteen bits carry it, and covered all of Unicode at their first level whatever the keys used. On jieba's lexicon afrom_bytesindex held 3.687 bytes a key, 21 % over its 3.040-byte blob and abovemarisa-trie's 3.578, whose load is its file to within 0.1 %; it holds 3.392 now, 5.2 % under marisa's. A million Chinese titles go from 6.474 to 6.243 bytes a key and a million Russian ones from 7.254 to 7.138, the tables halving on all three. Blobs are unchanged, byte for byte (bench/results/resident-2026-09-24-arz-b362f43.txt, which has the marisa rows). What it costs is in spelling the query, where a codeword is widened from sixteen bits:idon the lexicon runs 1 % more instructions and takes 1–2 % more time, 260 → 266 ns in the A/B above, and on the titles 0.6 and 0.7 %;key_intois level on all three.
Documentation¶
- What a mapped
DictIndexbuilds is stated as it is. The README, the docs' front page and the Python module said aload_mmapbuilds only the per-block samples; it also decodes the symbol tables and, where they apply, builds a code's tables and the trie above — 0.07–0.39 bytes a key in all over the corpora measured, of whichdocs/design.mdgives the parts.
[4.4.0] — 2026-09-24¶
Added¶
DictIndex::route_microblocks, in Rust and in Python: a fasterDictIndexsearch for eight bytes of memory a microblock. Once the samples and the head have chosen a block, a lookup walks the block's restart run — the first keys of its microblocks, coded against one another — to find the microblock it scans.route_microblocks()derives, for every block, the bytes its restarts share with its head and each restart's eight bytes past them; a lookup then counts the words below its own instead of walking, and walks only where a word ties it. No answer changes, and no blob, byte for byte: the words are held in memory only,8 / microbytes a key — 0.5 at the default block of 256, 0.25 at 1024 — and the method returns the bytes they take. It is a method rather than the default because it is paid for twice. Routed, the default block would give up its size lead over the smallest trie on four corpora of the thirteen at a million keys —words,domains,identsandpypi, where it leads by 0.34–0.39 B/key — and deriving the words reads every block's restart run, which a lazyload_mmapleaves unread until a lookup needs it. It takes&self, so a dictionary shared through anArc— the Python object,HashedDictIndex::dict— routes in place, and a lookup that races it walks the run. Against the same index unrouted, in one process and in two builds of the harness (bench/results/id-ab-2026-09-24-arz-0216b39.txt): at the default blockidtakes 19–31 % less time on twelve corpora at a million keys and 13–22 % on six at ten million, andids_of16–24 % and 7–17 %; at 1024 keys a blockidtakes 23–36 % and 14–28 % less. With the change below, against 4.3.3, a routedidat the default block takes 23–41 % less time at a million keys and 19–31 % at ten million.paths, whose restarts tie in the eight bytes past what they share, moves between −2.4 and +1.8 %. At 32 keys a block, two microblocks, the words save at most 15 %, and costnumericat ten million 3–10 %.
Changed¶
- A
DictIndexlookup is faster with nothing asked for:idby 4–16 % andids_ofby 4–13 % on twelve corpora of thirteen at a million keys. Most of a lookup's instructions go to two front-coded scans, and the scan matched on the shard's codec for every entry it compared. It now picks the codec and the kind of header code once a run and walks the run in a loop of its own for that pair; reads the probe's last eight bytes once a walk, where every compare that reached the end of the key had folded them a byte at a time; and tests the symbol table's own codes before its escape and phrase prefixes. Blobs are unchanged, byte for byte. Against 4.3.3 in the run above, unrouted:idtakes 4.9–15.5 % less time at the default block on twelve corpora at a million keys and 2.3–11.7 % on six at ten million, 6.3–16.4 % and 2.8–15.7 % at 1024 keys a block, and 3.7–15.2 % and 0.6–14.0 % at 32;ids_ofat the default block 3.7–12.7 % and 2.2–9.6 %. Onpathsevery lookup is 1.1–2.6 % faster.
[4.3.3] — 2026-09-24¶
Fixed¶
DictIndex::idon a blob in a character code is as fast as 4.2.0's again. 4.3.2'sidwas 6 % slower than 4.2.0's on a million Chinese titles. Counted per query, the search was not the cause — in code it costs fewer cycles than 4.2's search in UTF-8 — but spelling the query: 342 instructions and some 130 cycles, run before the first step of the search could issue. The code keeps order, so the blocks a query falls between are the same whether the per-block samples are taken from the heads as coded or as decoded, and a coded blob's loader now takes both. Where the decoded samples tie no more blocks than the coded ones — 51 of 3 907 against 70 on the Chinese titles, none either way on jieba's lexicon —idsearches them with the query's own UTF-8 and spells it while that search waits on its loads. The Russian titles tie 659 decoded against 161 coded, eight bytes of UTF-8 being four letters, and keep the coded route. The decoded samples are derived at load like the coded ones and take as much memory, eight bytes a block; blobs are unchanged, byte for byte. With the change below, against 4.2.0 and 4.3.2 in one process, in two builds of the harness (bench/results/id-ab-2026-09-24-arz-af19cb7.txt): on the Chinese titlesidis level with 4.2.0 (−0.5 and +0.3 %) and 5.7–6.5 % faster than 4.3.2, on the lexicon 6.7–8.0 % faster than 4.2.0, and on the Russian titles 2.7–3.0 % faster than 4.3.2 and 0.8–2.7 % slower than 4.2.0.DictIndex::ids_ofon a blob in a character code is 6–13 % faster. It checked every&strkey as UTF-8 a second time, and grew, zero-filled and cut back an arena the size of the whole batch a key at a time. It now takes astras it is, and spells 32 keys at a time into one arena reused across the batch, so it no longer holds a spelled copy of every key. Against 4.3.2 in the same run: 8.1–8.3 % faster on the Chinese titles, 12.6–12.9 % on the Russian ones and 6.1–6.7 % on the lexicon. That still leaves it 2.8–4.5 % and 1.2–2.4 % slower than 4.2.0's on the two sets of titles, and 5.0–5.3 % faster on the lexicon: on all three corpora a batch is slower than a loop ofidin every version measured, so the spelling has no wait to run beside.
Changed¶
- A
DictIndexlookup compares a head only where the samples leave it open, andidis faster on every corpus but paths. A block whose sample is below the probe's has its head below the probe, and one whose sample is above has its head above, so only a run of blocks sharing the probe's sample leaves heads to compare. Every lookup had also compared the head of the block before that run: one more dependent load and amemcmp, for a block the samples had already chosen. Between 0.4 and 12.4 % of the probes land in a run on thirteen of the fourteen corpora measured; on paths all of them do, since every path shares one byte and past it the samples tie 3 899 blocks of 3 907. On the eleven corpora that take no code, against 4.3.2 in the same run,idis 2.7–5.5 % faster on eight of them, 0.9–4.3 % on identifiers and UUIDs and level on paths;ids_ofis 2.8–5.2 % faster on nine and level on paths, and on identifiers 4.0 % faster in one harness build and 1.5 % slower in the other, whose whole row, the control's included, the machine slowed by 9–25 %.
[4.3.2] — 2026-09-24¶
Fixed¶
DictIndexbuilds faster than 4.2.0 again; 4.3.0 had made every in-memory build 11–81 % slower. Before building, 4.3.0 counted the keys' bytes outside ASCII to learn whether a character code could pay, and where one could it counted their characters and spelled every key: three passes on one thread, in key order, over the caller's keys. Those lie wherever the caller allocated them, so such a pass waits on memory at every key, and the byte count compiled to four instructions a byte, which keeps few of those loads in flight. The count now takes eight bytes a step; the three passes run a range of keys a thread, over one view of the keys that the encoder's threads now share rather than taking their own; and every pass prefetches the key 32 places ahead. The order check and the deduplication in front of the build — a sort's check for a run and adedup_by, two more passes of the same kind — are now one pass that reads ahead. Blobs are unchanged, byte for byte. Over the thirteen corpora of the million-key frontier, at blocks 1024 and 256, alternated with 4.2.0 and 4.3.1 in one run (bench/results/build-ab-2026-09-23-arz-152a00f.txt), a build takes 11–53 % less time than 4.3.1's on every corpus; against 4.2.0's it is 29–35 % faster on Russian titles, 11–14 % on Chinese ones, 3–25 % on eight Latin corpora, and within 2 % on paths, English titles and URLs. The loss was found by re-measuring the frontier at 4.3.1 (bench/results/frontier-1m-2026-09-23-arz-8dc8b73.json), whereDictIndexno longer built first on three corpora of the thirteen, and the corpus sweep at 4.3.1 shows it too (bench/results/sweep-2026-09-23-arz-8dc8b73.json).
[4.3.1] — 2026-09-23¶
Fixed¶
key_intoon a blob in a character code is back at 4.2.0's speed, andidmuch closer to it. 4.3.0 decoded a key a character at a time through the tables the code is stored in — the codeword's lead found among the leads, a branch on the mode and on the lead's width,char::encode_utf8— and then checked the finished key as UTF-8 a second time; it spelled a query through a two-level index, behind a branch on the mode. A codeword now decodes in two reads and one four-byte store, out of a table of the 256 values its first byte can take and one of every character's UTF-8. What the decoder writes is whole characters out of a table built fromchars, so it becomes aStringwithout the second pass; a debug build still checks it, and so do the fuzz targets. Code points below U+0800 — ASCII, Cyrillic and every other script UTF-8 spells in two bytes — are one flat level of the index, and under the eight-bit mode ASCII is in the index too, so spelling a query no longer asks which mode it is in. Blobs are unchanged, byte for byte. Against 4.2.0 in one process, in two builds of the harness (bench/results/charcode-ab-2026-09-23-arz-668a1d3.txt):key_intois 2.5–3.3 % faster on a million Russian titles, where 4.3.0 was 19–23 % slower, within 2 % on a million Chinese ones (4.3.0: 11–12 % slower) and level on jieba's lexicon (4.3.0: 4–5 % slower);idis 1–2 % slower on the Russian titles (4.3.0: 6–7 %), 6–8 % on the Chinese ones (4.3.0: 12–13 %) and 3–4 % faster on the lexicon, as in 4.3.0. English titles, whose blob is 4.2's, read within 2 % of 4.2.0, the band two builds of the harness move by. Most of whatidstill pays is spelling the query: all of it on the Russian titles, where spelling it twice adds 610 instructions andidpays 605 over 4.2.0, and two thirds of the 530 instructions it pays on the Chinese ones.
Documentation¶
- The million-key size table and the README's margin over
marisa-trieare re-measured at 4.3.0 (bench/results/sweep-2026-09-23-arz-4f2a0e9.json), so they show the character code. Of the 264 cells the run shares with the 4.0.0 one, 252 are the same to the byte; the twelve that moved areDictIndexon the Russian and Chinese titles, 6.7–14.7 % smaller, and their margins over marisa's best setting go from 2.2 and 1.6 % to 14 and 9 %, so the README's range now starts at English titles' 3.9 %. At ten million keys (bench/results/sweep10m-2026-09-23-arz-4f2a0e9.json) all 66 cells are the same to the byte: the code takes none of those six corpora. The run's timings are not published, since the machine was busy.
[4.3.0] — 2026-09-23¶
Changed¶
DictIndexspells keys mostly outside ASCII in a character code of their own, and such a blob is 6.7–17.9 % smaller. UTF-8 spends three bytes on a Chinese character and two on a Cyrillic letter, and the dictionary's symbol tables win back only part of that. A build now counts the characters its keys hold and, where they are mostly outside ASCII, may respell every key in a code fitted to them: the most frequent characters a byte each, the rest two. The codewords rise with the characters and none is a prefix of another, so coded keys sort exactly as the keys do — ids stay ranks, a prefix stays a prefix, and every query answers as it did, a query holding a character the code does not spell included. The code is kept only where it pays: it has to take a tenth off the keys, table included, and a twentieth of what it saves has to cover the table, because the codec behind it removes much of the same redundancy on its own. At the default block, jieba's 349 045-word lexicon goes from 3.653 to 3.040 bytes a key (−16.8 %) — undermarisa-trie's smallest setting on it, 3.58, which was smaller than 4.2's blob — a million Chinese Wikipedia titles from 6.270 to 5.851 (−6.7 %) and a million Russian ones from 7.664 to 6.862 (−10.5 %); at 100 000 keys the three come out 17.6, 9.2 and 14.2 % smaller. English titles, words, paths, PyPI names, URLs, identifiers and domains take no code and write the blob 4.2 wrote, byte for byte. The bar is conservative on a small corpus: three thousand random keys over two thousand ideographs would have come out 4.1 % smaller coded, and are kept in UTF-8. What it costs is time, where it is taken, since a query is spelled in the code before the search and a key decoded after it is read: against 4.2.0 in one process,idis 6 % slower on the Russian titles, 10 % on the Chinese ones and level on the lexicon, andkey_into22, 11 and 5 % slower. On a corpus that takes no code both are within 3 % of 4.2.0.- A blob in a character code is
BDX4:BDX3's layout with the code appended as its last section, its length in four header bytesBDX3reserved. 4.3 reads everyBDX3, and a build that takes no code still writes one. 4.2 and earlier refuse aBDX4, and with it aHashedDictIndexwhose dictionary is one, so a reader has to be on 4.3 before the first such build reaches it.DictSections::charsandlexindex inspect --sectionssay what the code weighs. plan()prices the code: it chooses one off its two draws as a build would off the corpus, spells the draws in it and scales the corpus's shape by what the spelling did to them. On the three corpora above theDictIndexestimate lands within +1.6 to +3.1 %, −3.2 to −3.9 % and −0.3 to −0.6 % of the built blob across the three priced blocks, where an estimate priced in UTF-8 would carry what the code saves as its error.StringIndexreads its transducer one step at a time.id,contains,ids_ofand every prefix walk —common_prefix,longest_prefix,occurrences,for_each_common_prefix— went through thefstcrate's node decoder, which reads a whole node — its pack sizes, transition count, end address and final output — before it can say where one byte leads. A walk down one path needs two answers at each node it passes, whether the node is final and where the next byte goes, and now reads only those, from the same bytes. The answers are unchanged, and three checks hold the reader tofst's decoder: a property test over random maps with arbitrary values, the transducer formats before version 3, and theparse_stringfuzz target, which walks every key of a parsed blob both ways. Against 4.2.0 in one process, over a million titles in English, Chinese and Russian and over jieba's lexicon,idis 29–34 % faster and a common-prefix walk 20–26 % faster.
[4.2.0] — 2026-09-23¶
Added¶
StringIndex.occurrences(text): every key that occurs in a text, in one call. A dictionary segmenter — jieba and its ports among them — asks for the keys that start at each character of a sentence, and from Python that was acommon_prefixcall per character, each one a crossing of the binding for a walk that is over within a few characters.occurrencesruns the same walk at every character on the Rust side and returns(start, end, id)triples, withtext[start:end]the key: starts ascend, and within a start the shortest key comes first — the order jieba's word graph is built in. Offsets count characters, as astris indexed, not UTF-8 bytes. The empty key, which would occur at every position, is not reported.StringIndex::for_each_common_prefix, the walk undercommon_prefixandlongest_prefix, is public:f(end, id)for every key that is a prefix of the query, shortest first, with the key&query[..end]and nothing allocated. The walk stops where no key continues, so the query can be the whole rest of a text.
Fixed¶
lexindex.inspect's docstring opened with somebody else's paragraph, a note on the extension module'sgil_used = falsethat has sat aboveinspectsinceinspectwas added in 2.0.0 — sohelp(lexindex.inspect)began with it, and so didinspect's entry on the API page, which is rendered from the same docstring. The note is back beside the#[pymodule]it explains, as a plain comment that cannot become a docstring again.- The API page carried 73 links to nowhere and 26 Sphinx roles printed as typed. The binding's
doc comments in
src/python.rsare Python docstrings —help()and the API page both read them — but they were written as rustdoc:[id](Self::id)is a dead link in Markdown, and[PyStringIndex::to_bytes]names a Rust type no Python caller meets. Those are code spans now, andDictIndexIterator's docstring, which was a note on its lock, describes the iterator. The stub andlexindex/__init__.pyused Sphinx roles (:meth:,:class:, 105 of them), which Markdown shows verbatim; they are double-backtick literals now, as the rest of those docstrings already were. A test holds every docstring the runtime or the stub carries to that. docs.yml: itsrelease: publishedtrigger never fired, in 24 releases —release.ymlcreates the GitHub Release withGITHUB_TOKEN, whose events start no workflow — and its push filter missed two files the site is built from:polars/README.md, which is the Polars page, andsrc/python.rs, whose doc comments are the docstrings the API page renders for every compiled class and function. The trigger is gone and the filter names both, so a change to either redeploys the site on the push that carries it.
[4.1.1] — 2026-09-23¶
Fixed¶
- The latency model is re-fitted to a clean run, and the 1.75× it was quoted at does not
reproduce. The constants
planranks by underObjective::Latency,BalancedandWorkloadcame from a run taken beside other work — its load average was 0.64 when it started and 1.82 when it ended, 2.41 over its last five minutes — and from before the three commits that took 6–13 % off the instructions of aDictIndexlookup ahead of 4.0.0. Re-measured on a freshly booted, idle machine over the same 240 cells (bench/results/latency-model-2026-09-23-arz-7731027.json), every structure'sid(key)reads 9–19 % faster at the median, and at 100 000 keysStringIndexis faster thanDictIndexat block 32 on six corpora of thirteen rather than three. On the published sweep, which neither fit saw, the model now picks the faster ofDictIndexandStringIndexin 29 of 30 cells, never more than 1.02× slower (was 27, 1.18×), and the fastestDictIndexblock in 21 (was 15). Held out a corpus at a time, the orderedid(key)pick is the fastest in 19 of 30 cells and within 5 % of it in 23, never more than 1.11× slower. The 1.75× the 4.0.0 notes put down toBDX3—numericat 100 000 keys — belonged to the loaded run: scored on the new cells, the old constants do no worse than 1.11× either.bench/latency_model.py fitreports the within-5 % count beside the exact one, since 5 % is about what placement in physical memory moves a single-threaded probe on this machine and the ordered candidates are often closer than that. The run also timesHashedDictIndex, closed and at eight fingerprint bits;planstill does not price it, because a newKindbreaks an exhaustivematchand waits for a major release.
Documentation¶
- The
planexamples indocs/usage.mdstill showedStringIndexat 2 744 604 bytes on the word list, the estimate from before 4.0.1 priced the transducer off the run draw; re-run with the new constants, it is 2 882 400. - The word-list ladder and both
rsmarisatables are re-measured on an idle machine, every run started only once a readiness check passed. The ladder (bench/results/dictbench-2026-09-23-arz-fdc28bc.txt) predated the microblock offsets and the three commits to the dictionary's compare path that 4.0.0 shipped: its sizes are 0.3–1.6 % lower now and everyidrow 4–8 %, one to five points more than theStringIndexcontrol moved, and the paragraph under it is recounted — heads and arrays go from 0.44 bytes a key at 32 to 0.06 at 1024, no sample has been stored sinceBDX3, a block of 32 scans 16 entries rather than 31, and 512 stores 2.52 bytes a key rather than 2.81. The word-listrsmarisatable (bench/results/rsmarisa-2026-09-23-arz-fdc28bc.txt) moved little — itsrsmarisarows 1–3 %,DictIndex3–5 % — but its caption described a protocol the harness does not run, and now names the one it does. The eleven-corpus table (bench/results/rsmarisa-corpora-2026-09-23-arz-fdc28bc.txt) did move: the 2026-09-19 run was taken while other benchmarks shared the machine, and itsrsmarisacolumn, whose code has not changed, read 21–44 % slower than it does now. Its finding that the largest block is the fastest on ten corpora of eleven does not survive — the fastest block is 64 on four, 256 on five and 512 on two, and within 10 % of the others on eight — whileDictIndexis still smaller thanrsmarisaon all eleven and answers 1.5–3.7× faster on ten. - The read-scaling table is re-measured the same way
(
bench/results/readscale-2026-09-23-arz-fdc28bc.txt); its 2026-09-19 run shared the machine with other benchmarks too. Its one-thread cells were inflated, so its speedups were: theStringIndexit said scaled best, super-linearly at 2.09× on two threads, now reads 2.00× and scales likeDictIndex(1.95×); eight cores give 5.5–7.8× rather than 5.7–8.1×, andCompactHashIndexsaturates at 413 M lookups a second rather than 385.
[4.1.0] — 2026-09-22¶
Added¶
HashedDictIndex: aDictIndexwhoseidis a hash. A dictionary answersidby searching — the block samples, one block's restarts, one microblock's scan — and that search is the column the frontier campaign lost: XCDAT 1.2–5.1× faster at a million keys, and 1.1–1.8× at ten million on five of the six corpora there.HashedDictIndex::from_dict(dict, fingerprint_bits)keeps the dictionary whole and adds a minimal perfect hash over the keys' 64-bit hashes and a bit-packed table holding each key's rank at its slot,⌈log2 n⌉ + fingerprint_bitsbits wide.idis two hashes of the key and two reads and never touches the dictionary;key(id), prefix, range and iteration go todict(), whose ids are the same ranks. On the campaign's protocol, besideDict256 and XCDAT 15 as controls that read within 4.9 % of the published campaign on all 38 of their cells, it is faster than XCDAT and smaller on all nineteen corpora:id_unchecked3.9–8.9× faster at 1.39–3.16× less space,idat eight fingerprint bits 3.1–7.6× and 1.17–2.77×, and it builds before XCDAT on every one. Against its own dictionary's search it is 7.6–20.5× faster closed and 6.1–17.0× at eight bits, for 2.62 bytes a key on the 480 k-word dictionary, 2.74 at a million keys and 3.24 at ten million.
The width is chosen once, at build, and it decides the contract. From one fingerprint bit up id
answers a stranger as present at 2^-fingerprint_bits, CompactHashIndex's rate; at zero bits
id is the dictionary's exact search, and the hash is id_unchecked alone — a member's rank, and
some rank below n for anything else. Keys sharing a 64-bit hash are told apart by the full
second hash in a side table, so a member's rank is exact at every width. The format is BHD1: a
48-byte header, the dictionary's BDX3 blob byte for byte — checked by DictIndex's own loader —
then the perfect hash, the rank table and the side table. load_mmap borrows the dictionary and
the rank table, and a crafted blob answers wrong ranks, never ranks past the end. Python has the
same class, HashedDictIndex.from_dict(dict, fingerprint_bits=8), with ids_of, in,
indexing and .dict, and the batch forms DictIndex has — ids_of_bytes, ids_into and the
Arrow pair ids_of_arrow and ids_into_arrow — writing uint64 ids and MISSING_ID for a
miss. The C ABI opens BHD1 blobs and builds the index at eight bits as
LEXINDEX_KIND_HASHED_DICT, a sixth kind under the same ABI version: a caller compiled against
the older header meets it only for a blob its library refused. plan does not price it yet, and
Overlay does not take it: its ids are ranks.
- The command line in the wheel. plan, build, inspect and dump were reachable only
through cargo install lexindex, so a pip install could price a corpus from Python code but not
from a shell. The wheel now carries the binary's own program — src/cli.rs, compiled into both —
as a lexindex command and as python -m lexindex, so the subcommands, their output and their
exit statuses are the same whichever way lexindex was installed:
pip install lexindex && lexindex plan keys.txt. Ctrl-C ends a run as it ends the binary's,
rather than waiting for the Rust code to return.
- A Polars plugin, in polars/. lexindex-polars is a package of its own — its own crate,
wheel, version and changelog — carrying four expressions over an index blob named by path:
pl.col("track").lexindex.id("tracks.bdx"), and id_unchecked, contains and key beside it.
They are plugin expressions rather than a map_batches callback, so polars runs them in its own
threads, inside a lazy plan and under the streaming engine, with no GIL; one expression serves all
six index kinds, because the blob says which kind it is. Nothing about the lexindex crate or
wheel changes: polars' plugin ABI moves on polars' cadence, which is not this library's, so it
stays out of the dependency tree and ships separately.
- A fuzz target for BHD1. parse_hashed_dict loads arbitrary bytes as a HashedDictIndex
and holds every rank id, id_unchecked and ids_of answer below the key count; it runs in CI's
fuzz matrix beside parse_dict, and tests/data/golden-4.1.0-hashed.bhd pins the format by its
bytes and seeds the corpus.
- Pull requests are fuzzed. One that touches src/, fuzz/ or the seeds runs every libFuzzer
target, ten minutes between them, under ClusterFuzzLite — OSS-Fuzz's build and fuzzing images, on
the repository's own runners — before it is merged; the daily campaign still fuzzes main.
.clusterfuzzlite/ is the build an OSS-Fuzz project would use, and it builds all ten targets with
the instrumentation intact: 2 448 counters in parse_compact, where an LTO build keeps a few
hundred.
- The C ABI under the sanitizers. The weekly AddressSanitizer run compiled tests/capi.rs to
nothing, since it needs the capi feature, and Miri could not run it at all: the test calls the
C functions through copies of the header's #[repr(C)] enums, which Miri holds to be other types
than the callee's and stops at the first call. Both run it every week now, Miri through the
module's own types, and both pass.
- bench/frontier/run.sh --only PATTERN runs the campaign's processes whose label matches,
into an artifact named frontier-<scale>-subset-*; frontier_lex takes hashed0, hashed8 and
hashed16, and bench/tables.py renders a campaign's HashedDictIndex table with view=hashed.
Fixed¶
bench/frontier/run.shrefused to run at 4.0.1: it buildsfrontier_lexwith--locked, and that crate's lock file still named lexindex 4.0.0 — asbench/mphf_vs's did. CI now fails when a trackedCargo.locknames another version of lexindex thanCargo.tomldoes, so a release commit that misses one is red before it is tagged.- The release preflight checks the tag's own date. It compared
CITATION.cff'sdate-releasedwith the CHANGELOG heading and neither with the day of the tag, so a release prepared on one day and tagged on another passed — 4.0.0 was re-dated twice by hand. It now reads the annotated tag's date, in the tagger's zone, and refuses a mismatch or a lightweight tag before anything is built.
Documentation¶
docs/api.mdrendered four of the public names.Overlay(public since 0.12),DictIndex(2.0),plan(3.0),inspectand the types they return were never on it; every name in__all__is now, and a test fails when one is not.- CI fails when a page cites a
bench/resultsfile that is not committed. A citation of an untracked artifact is a 404 from the day it is pushed; one reached a commit this cycle and was caught by hand before the push. plan_file's page on docs.rs has linked a private constant since 4.0.0, a dead link there, andHashedDictIndex's contract — whatfingerprint_bitsdecides — sat in a private module's documentation, which docs.rs does not render. Both are on the public pages now, and CI denies rustdoc's warnings on the build docs.rs makes, where a link to a private item is reported.SECURITY.mdlisted the fuzzed formats as of 1.1, withoutMPH3, the perfect hash's format since 4.0.0; it namesMPH3andBHD1now. Itsunsafeinventory is recounted from the source — 15unsafe fns where it said 13, 41 blocks where it said 38, eleven in the Python bindings where it said nine,HashedDictIndex's memory maps and the phrase trie's unchecked walk among the difference — and names what it left out: the fourunsafe impls ofSendandSync, theunsafe traitbehind zeroed huge-page tables, and the C ABI's five internalunsafe fns.
[4.0.1] — 2026-09-22¶
Fixed¶
planranked a dense id space wrong, by four orders of magnitude. TheStringIndexestimate was the bytes an fst spends per trie node, read off the hash draw and scaled by the corpus's trie nodes. An fst does not merge by trie node — it merges equal right-languages, and how far it merges is a property of the density of the whole key set, which a hash draw of a hundred thousand keys destroys by construction. On a million decimal ids the estimate read 2 281 924 bytes where the blob weighs 301, and the ladder handed back aDictIndexthree thousand times larger than the index the caller should have built.
The rate is now read off the run draw — consecutive keys of the sorted corpus, whose neighbours are the corpus's own, so the merges that happen in the corpus happen in the draw — and carried flat per key. Scored against the built blob on 19 corpora of a quarter million to nineteen million keys, the estimate lands within 1.1 % median, 6.7 % at the 90th percentile and 11.7 % at worst, against 5.2 / 36.0 / 53.1 for what it replaced, and the ranking is right on every one of the 19.
A dense id space is still the family it cannot price: ten million decimal ids merge to 356 bytes
whole and no draw of a hundred thousand of them can see it, so the number reads about a
hundredfold high. It is two orders under every other index either way, so the ranking holds and
Plan::thin is set — a byte count under that flag is not one to quote.
[4.0.0] — 2026-09-21¶
Changed¶
DictIndexcame down on every cell of the size sweep, and the format isBDX3. Thirteen corpora at a hundred thousand keys, eleven at a million and six at ten million, each at blocks of 128, 256 and 1024: ninety cells against 3.0.0, ninety of them smaller. The median is 13 % at a hundred thousand, 25 % at a million and 33 % at ten million; the ends are 0.9 % onpypiat the smallest scale and 56 % onnumeric. Seven changes, each priced on its own:
The entry headers are one stream a shard wide. BDX2 spent a byte on every front-coded entry's
(lcp, len) pair, four bits each, and two varints on every pair that did not fit. The pair
distribution inside one shard is far narrower than that byte: a frame of reference over a run
(min lcp, min len, two widths, all-ones escaping to the varints at the head of that entry's
suffix) or a learned table of the 2^w - 1 costliest pairs at one to ten bits each carries almost
all of them, and which of the two wins is a property of the shard, so one byte a group chooses it
from every run at once. That is why a shard is now collected before any of it is written — the
code cannot be chosen from a block — and both builds go through the same collection, so a streamed
blob is still byte-identical to one held in memory. At block 1024: paths 14.34 → 12.46 bytes a
key, urls 11.11 → 9.28, dna 7.62 → 7.27.
A shard whose alphabet is narrow skips the symbols. A symbol table spends eight bits on a code
and earns them back by naming runs of bytes; on a shard drawn from four characters, or sixteen, or
sixty-four, that is the wrong trade twice over — two bits already meet the order-0 bound, and no
run of bases is frequent enough to pay for a symbol. The two are priced against each other on the
shard's own bytes, and each shard takes the winner, so a blob holding both shapes gets both. The
codes of a run are continuous — an entry's suffix starts where the one before it ended, mid-byte —
because padding each to a byte costs 0.32 bytes a key on DNA, the whole margin. At block 256: dna
7.36 → 4.34 bytes a key, opaque 13.24 → 10.36, numeric 1.39 → 0.98. uuid keeps its tables on
every shard; seventeen characters do not fit a nibble.
The block sample is taken past the prefix every head shares. A million URLs all begin
https://example.com/, so the eight bytes a block's sample held were the same word for 3 899 of
3 907 blocks and the binary search that opens every lookup answered nothing. The sample is taken
at g, the bytes every head in the blob shares — two bytes of header, none per block. Measured on
a million URLs: 165 samples duplicate their neighbour against 3 899, a probe's run of candidate
blocks is 2.02 against 3 271, and an id compares 1.13 heads against 11.54. Sorting is unchanged,
since every head shares those bytes, and a probe that does not share them is answered by the
boundary rather than a search.
What the whole blob repeats is named once. Front coding takes the head a key shares with the key
before it; what is left still repeats across blocks — /index.html, .example.com/,
- Wikipedia — and no symbol reaches past eight bytes. A dictionary of such spans is mined over
the blob's own suffixes and stored once; a shard that buys it gives up symbols for phrase codes,
a split at s symbols leaving 255 - s byte codes that name 256 phrases in one further byte or
65 536 in two, with 255 still the escape. A suffix is parsed by dynamic programming over the
cheapest coding in bits, and the miner is three rounds of that parse and a count of windows of up
to three adjacent tokens covering three to thirty-two bytes, keeping only candidates whose gain
clears six times what they cost to store. Mining stops after the first round unless one of three
sampled shards would take a split by 3 % on its own bytes — what the miner ranks is what coding a
span once would save, and what decides the format is whether a shard would rather spend those byte
codes on symbols; on a million opaque keys 40 000 spans clear the first bar and no shard takes
one. Each shard then decides for itself and has its table retrained on what its phrases did not
cover, and a blob no shard bought stores no dictionary. The vocabulary is a function of the keys
alone — the miner's candidate pools are a constant rather than the thread count, so the same keys
give byte-identical blobs on machines with one core and sixteen. At block 1024 over a million
keys: urls 9.26 → 7.27 bytes a key, English titles 9.08 → 7.21, paths 12.48 → 9.32.
The per-block samples are derived, not stored. The binary search that opens a lookup runs over
eight bytes of every block's head, and BDX2 carried those bytes a second time in a section of
their own. They are built from the heads at load instead: the same array, in the same place, for
eight bytes a block off every blob — 2.666 bytes a key to 2.635 on the word list at the default
block, 1.099 to 1.037 on a million numeric keys at 128 — and neither the load nor a lookup moved
(five corpora, load within 0.9 % and id and key within 3 %, both directions). load_mmap
borrows every section there now is.
A microblock's start is an offset inside its own block. BDX2 measured every microblock start
from the start of the blob, which needed a u64 base every 64 of them and a delta wide enough to
cross four blocks — thirteen bits an entry on the word list at the default block, and the third
largest section of a dictionary of decimal ids. A block's own start is a word the lookup reads
before it needs any of them, so the array is stored relative to it: no bases at all and eleven
bits an entry, 41 243 bytes against 52 493. It is smaller on every corpus and block measured
(twenty-four cells, eight corpora at 32, 256 and 1024): −0.04 % to −3.7 %, the small blocks
gaining most because they hold the most microblocks — numeric ids −3.7 % at 32 and −2.3 % at the
default, the word list −1.6 % and −0.6 %. It is also one load fewer on the search path, worth
−0.7 % to −1.3 % of the instructions an id retires and the same off key_into.
The vocabulary is the miner's, and nothing prunes it after. A candidate is kept when what it
saves over the whole blob clears its storage — the miner's bar carries a scale, because the
gains are counted on a sample of the suffixes and paid for by every key. A pass then re-asked the
same question with the scale taken off: it parsed those samples and dropped every phrase no
sampled piece picked. A phrase that earns its keep at one key in ten thousand appears zero times
in such a sample, so the pass could only remove what the blob-scaled test had already approved —
3 265 phrases to 1 461 on a million domains, 19 089 to 10 392 on a million paths. Measured over
ten corpora at four blocks, dropping the pass is smaller on 36 of 44 cells and larger on 8, every
loss under 0.17 %: domains −4.3 to −4.9 %, paths −2.0 to −4.6 %, identifiers −0.8 to −3.6 %, urls
and English titles at a hundred thousand −2.3 to −3.0 %, Russian titles −1.2 to −1.6 %. A variant
that kept the pass but only re-ranked, never dropping, lands within 0.08 % of no pass at all, so
the ordering it substituted is worth nothing. It costs the lookup, because a shard that reaches
more phrases gives up symbols for them: id +0.4 to +1.4 % and key_into +0.7 to +4.8 %.
End to end, BDX2 against BDX3 at the default block over a million keys each (lexindex build
--index dict, sizes being a function of the keys): urls 11.25 → 7.51 bytes a key, paths 14.67
→ 9.87, article titles 9.37 → 7.37 in English, 11.36 → 7.67 in Russian and 8.28 →
6.27 in Chinese, DNA 7.70 → 4.37, opaque ids 13.80 → 10.40, numeric 2.13 → 1.01,
identifiers 6.66 → 5.23, domains 5.07 → 4.50, UUIDs 20.45 → 18.04; the dictionary's
479 823 words 2.84 → 2.64 and 889 864 PyPI names 4.91 → 4.17. That is 7.2 % off the word
list at one end and 52.4 % off decimal ids at the other. Against marisa-trie at its own best
num_tries on each corpus, that makes DictIndex the smallest key-storing structure on ten of
the eleven sweep corpora at a million keys and on all six at ten million, where 3.0.0 gave
English titles and urls back to the trie — the two it takes back are won by 0.11 % and 0.33 %,
which is a tie and a lead rather than a comfortable win on either.
plan() prices the dictionary and the vocabulary's growth, so its estimate follows.
What it costs to build. The price is the phrase machinery and nothing else, so it is paid only
by the corpora that buy phrases. lexindex build --index dict end to end, the two binaries
alternated X-2-2-X-X-2-2-X and the minimum of four taken: the word list 0.12 → 0.13 s at 1.29× the
peak, DNA 0.52 → 0.54 s at 1.51×, urls 0.53 → 0.86 s at 1.82×, paths 0.70 → 1.13 s at 1.69×. Most
of that wall clock is reading the file and sorting it, which the format does not touch; the build
call on its own is 1.04× on DNA, 1.31× on decimal ids, 1.42× on the word list, 2.30× on domains
and 3.20–3.75× on titles, urls and paths at a million keys. The miner's own cost is fixed — it
reads a bounded sample of the suffixes and holds a bounded pool of candidates — so it amortises
where a blob that size is actually built: at ten million keys the CLI is 1.20× on urls (6.77 →
8.15 s, peak unchanged at 977 MB) and 1.21× on English article titles (6.14 → 7.42 s, 937 →
977 MB), and the build call 2.42× and 2.09×. Turning the miner off outright is what names its
share: −70 % of the build's retired instructions on urls, −63 % on domains, and −3.1 % on the
word list, where the scout refuses after one round over a sixteenth of the pieces and the blob
comes out byte-identical either way — the 3.1 % is the scout's own price for a decision that saves
the other two thirds. That is what buys the wide end of the size range above; a corpus the scout
turns away keeps only what the other four changes give it.
Those multipliers were measured before the parse was tuned, and the parse is a third of a build's instructions. Six changes to it — the symbol table's eight bytes carried backwards rather than re-read, the double array's step reading one record instead of two and needing no bounds check, the parse token packed from twelve bytes into one word, one word a byte shared across the memo's rows, and one induction variable in the walk — take a build's retired instructions, net of reading and sorting the keys, down 9.9 % on urls, 9.6 % on English titles, 9.2 % on identifiers and 8.8 % on paths, and the build call itself down 2.4–3.6 % in wall clock; the blobs are byte-identical. Dropping the pruning pass takes a further 5.2 % to 8.6 % off the same measure. Corpora that buy no phrase, decimal ids and the word list among them, are flat by construction.
What it costs to read. Against BDX2 over a million keys at block 256, the two binaries
alternated X-2-2-X-X-2-2-X so that code layout is not mistaken for the format: id runs 0.80×
to 1.24× and key_into 0.77× to 1.46×. key_into is the lane that came out ahead — nine
corpora of twelve, by 23 % on UUIDs, 17 % on urls, 16 % on paths and 14 % on Russian titles — and
id is the one that did not: three of twelve, urls 0.80×, opaque ids 0.95×, UUIDs 0.95×. Behind
on both are decimal ids (1.24× / 1.46×), where the blob is 0.49× and a BDX2 lookup is the
fastest of any corpus, and the word list (1.20× / 1.00×) and domains (1.13× / 1.02×), whose short
keys make the coded header the whole cost. A header that is coded rather than a byte is read with
a shift and a mask, and that is the price of the bytes it saves.
BDX1 and BDX2 are refused by name, as one reader and not three, so a dictionary blob written
before 4.0 has to be rebuilt from its keys, which BDX2 stores: 3.x answers key(id) over the
whole blob and 4.0 builds from the list. docs/migration-4.md is the worked path, index by index,
with what happens to the ids. golden-2.2.0-dict.bdx joins the refused fixtures and golden-4.0.0-dict.bdx pins
the new bytes.
plan()pricesBDX3, and theDictIndexestimate lands within 1.0 % of the built blob at the median. Three changes, each scored on the same 69 cells — 23 corpora at each of the three priced blocks.
The old model read a blob as one byte an entry plus a compressed suffix, which is what BDX2
wrote; BDX3 codes the headers and can spend well under a byte on one, so the plan was charging
for a byte that is not there and discounting the suffix to match. The two are now measured apart —
the sample blob is walked rather than added up by section — and the phrase dictionary is priced as
its own term, per key, since it grows with the corpus and not with the shards the tables follow.
The sample is built with the corpus's economics, not its own. The miner admits a phrase on what it saves over the whole blob against what storing it costs once, so a hundred thousand keys afford a tenth of the dictionary a million do: built as an index of its own, the sample reads the suffix ratio 0.71 where the million-key blob spends 0.53 on article titles. No slope fitted below the sample reaches the corpus either — the vocabulary is flat below a hundred thousand keys and falls convexly above them — so the sample is mined at the corpus's key count instead, over every suffix it holds, and the rate is carried flat. A million urls goes from 14 % high to 0.9 % low, three million article titles from 24 % to 1.4 %. That vocabulary is bought once a plan, not once a block: the three priced blocks read the ratio within 0.4 % of each other, and mining at each of them was both two thirds of what a plan spends and less accurate — the smallest block's own draw reads the ratio 21 % high on decimal ids.
And what a stored entry costs comes off a second draw. A (lcp, len) pair is coded against its
shard's own distribution, which is a property of the neighbours a key is coded against; a uniform
draw puts those n / 100 000 positions apart and read a million decimal ids 0.51 bytes a header
where that blob spends 0.34, a third of it. So the plan draws twice — uniformly for what a suffix
compresses to and what an fst spends on a node, and in runs of 4 096 consecutive keys for what an
entry and a block cost — both decided by hash, so plan_file makes the same two draws over a
merged stream that plan makes over the sorted keys, and the two still print the same ladder to
the byte on a 7 343 721-line path list.
1.0 % median, 4.2 % at the 90th percentile, 7.2 % at worst, against 9.0 / 14.2 / 23.9 for the
same model reading one flat-rate sample and 8.8 / 23.8 / 33.3 for the model BDX3 inherited.
Corpora whose suffixes repeat nothing — DNA, UUIDs, opaque ids — land inside 0.5 %; the spread is
an identifier list at 4.3 % low and a 7.3-million-line path list at 7.2 % high. The second draw and
the corpus-economy mine cost a fixed 0.35 s and 44 MB a plan, so the ratio is worst on the
cheapest plan and vanishes on the largest: 1.30× the wall time and 1.39× the peak on a million
urls, 1.22× and 1.62× on a million Chinese titles, 1.06× and 1.12× on ten million English ones,
and no change either way on the streamed 7 343 721-line path list, where the external sort's
run budget is already higher than the miner ever reaches. docs/usage.md carries the numbers and
says which way the estimate errs.
-
The miner's gain maps are a third smaller, and the build spends 6–9 % fewer cycles for the same instructions. A candidate span was keyed by a
u128— two 64-bit rolling hashes carrying its length — and au128aligns a map entry to sixteen, so(key, (gain, span))was padded from 40 bytes to 48. Folding both lanes into one word takes it to 32. Nothing else moved: over fifteen corpus-block cells the blob is byte-identical, and the instruction count falls 0.6–0.9 % while the cycles fall 6.0–9.3 % — these maps were never the build's work, they were its misses.reserve_rehashover them is 8.5 % of the build's cycles against under 1.5 % of its instructions, and 39 % of its page faults, which is what the earlier profile could not explain. A million urls, alternated A-B-A-B: 18.7 → 17.0 G cycles, 0.904 → 0.863 s, 115 k → 96 k faults, peak 293 → 252 MB; a million paths 21.5 → 20.2 G cycles and 121 k → 108 k faults;plan, which mines densely, 187 → 158 MB on the same urls. A sixty-four-bit key can collide, which credits one span with another's gain and drops the loser from the dictionary — bytes, not correctness, at about one run in forty million. -
The latency model is re-fitted to
BDX3, and the fit is worse where it should be. The constants insrc/estimate.rswere fitted toBDX2lanes, so onBDX3they under-priced everyDictIndexlookup by about a third —plan(.., Objective::Latency)was ranking the dictionary with a number the format no longer produces. Re-measured over the same 240 cells (four blocks × three sizes × the corpus set, one process,bench/latency_model.py measure) and re-fitted from that artifact. Mean absolute error onid(key)is 6–13 %; held out a corpus at a time the model still names the fastestid(key)in 30 of 30 cells, the fastestkey(id)in 30 and the fastest under acommon_prefix-heavy workload in 30. Where it got worse is the one place the format moved: picking betweenDictIndexandStringIndexheld out falls to 16 of 30 with a worst case of 1.75×, from 24 and 1.24×, becauseBDX3raised the dictionary'sidlane by about a sixth while the transducer's stayed where it was and the two now cross inside the corpus set rather than outside it.prefix_countis fitted at 26–46 % mean error and is quoted as such: its cost is dominated by how many keys the prefix matches, and the model has no term for that. -
The remap's samples are two levels, and cost half of what they did. Every 64 values of the remap carried the position of their block's first one in a
u32, half a bit a value. The position is what a lookup adds to nothing else, so what it actually needs is the offset from a coarser mark: au32every 2 048 values and au16every 64 above it is 0.27 bits a value against 0.5, and the two loads are independent — both tables are small enough to stay in L1 and only their sum feeds the window's address, so the lookup gains an add and no dependent read. The width is safe by construction rather than by luck: the low bits are chosen to leave the high stream about as dense as it is sparse, so it holds about two positions a value and a super-block spans about 4 096; measured on real tables of 1 M, 10 M and 50 M keys the widest block offset was 4 414, 4 710 and 4 789, a fourteenth of what au16holds, and a sequence that would defy that is refused by name rather than wrapped. The remap's samples fall from 0.0071 / 0.0067 / 0.0065 bits a key to 0.0038 / 0.0035 / 0.0035 at those three sizes — 0.0033 / 0.0032 / 0.0030 off the whole function, which is whereMPH3's last third of a hundredth of a bit was hiding. -
The perfect hash is 1 % smaller again and bumps a sixth fewer keys: a seed is chosen by the product of its keys' positions, not their sum. Of the seeds that place a bucket,
MPH3took the one whose keys' in-slice positions summed lowest, since low positions are what the buckets still to come cannot use. A sum is indifferent to how those positions are spread; the product of the positions plus a constant — PHast'sShiftOnlyProdWrappedobjective, due to Piotr Beling — wants the lowest of them lowest, which packs the front of every slice tighter where the later buckets are placed. It is scored from a 16 KB table oflog₂in fixed point, one entry a key a candidate; the search itself, its candidates and its windows, is unchanged. Measured on 10 M real word-bigram hashes at the shippedλ4.5: 1.935 bits/key against 1.952 and 1.30 % of keys bumped against 1.57 %; at 100 M, 1.932 against 1.948 and 1.29 % against 1.56 %. The constant is flat between 50 and 150 and 95 is shipped. The build runs about 57 more instructions a key for the scoring, 2–3 ns of 63 on a hot machine, at the same cycle count within noise. The seeds change, so thegolden-4.0.0-*fixtures are re-pinned; the format does not. - The checked lookups run on fewer instructions:
CompactHashIndex::id37 % fewer,PerfectHashIndex::id53 % fewer.CompactHashIndex::idcalled its slot lookup and the pair hash out of line and read every fingerprint through the bit-packed path with its overflow and tail checks, and once inlined still paid four bounds checks and anOptionfor a table whose length the loader had already checked. Now the lookup is inlined into the caller as one unit and the default-width fingerprint is one byte load with no check, on the loader's invariant: a blob's perfect hash is validated to map every hash into[0, m)when it is parsed, and the fingerprint table to hold exactlymentries. 1 M URLs, keys read from memory: 198 → 125 instructions a lookup (perf stat, exact counts; 16-bit fingerprints 200 → 178).PerfectHashIndex::idvalidated the stored key as UTF-8 before comparing it with the probe — a pass over every byte that equality with a&stralready implies — and called its slot lookup out of line: 420 → 198 instructions and 920 → 540 cycles a lookup on a hot machine. And every index's byte source now resolves its pointer once, when it is built or loaded, instead of on every access — a tag match, anArcdereference and a range check a lookup: compactid125 → 115 instructions, perfectid198 → 180,DictIndex::id5 490 → 4 950. In this regime the instruction count bounds how many keys' cache misses the core overlaps, so it is the lever: the fingerprint derived from the hash's own state (below) took the compact lookup from 117 to 65 ns on a hot machine where the in-cache hash alone gained 5. - The batch lookups prefetch a key's last line as well as its first.
ids_ofon the three hash indexes pulled in the first byte of a key 32 keys ahead and left the rest of the key to a demand miss; the hash reads a key to its end, and a key of a few dozen bytes lies across two lines as often as not. Naming the last byte too: on 1 M URLsClosedHashIndex::ids_of32 → 17 ns a key andCompactHashIndex::ids_of47 → 22, on 1 M titles 20 → 12 and 29 → 18, on 1 M paths 54 → 48 and 91 → 79 (hot machine, ratios). Cool, at the head of this release and with the lookup work that followed, the same six read 12.8 / 14.6, 9.7 / 13.2 and 41.2 / 47.5 ns a key (bench/results/mphf-strings-2026-09-17-*). A singleidcannot make that prefetch — it has no next key to look at — and does not change. -
The key hash is new — branch-free over the key's length, half the time on real words — and every hash blob moves to
BMP8,BCH8andBCL2. The hash 2.0–3.x shipped read a key eight bytes at a time in a loop and finished with a tail switch; on real keys 6 of its 9.5 ns were branch mispredictions, and withMPH3at 3.8 ns a lookup the hash had become the cost of a string lookup. The new hash reads a key of 4–16 bytes as four overlapping 4-byte loads, 17–32 as its first and last sixteen, longer keys through two multiply lanes over 32-byte blocks that end on the last 32, and 1–3 bytes as one word; the words go through two 64×64→128 multiplies folded to 64 bits, side by side, and one more that merges them with the length — no loop and no branch on the length inside a class, and no bounds check: every load's index is in bounds by construction, and the six checks LLVM could not prove cost 6–16 % of the hash on real keys. The constants are consecutive words of π. The fingerprint is no longer a second pass: it folds the hash's own two lane products under its own constants with the length rotated — one multiply over the hash instead of a second merge and a second lane pair per block, so the pair everyCompactHashIndexlookup computes costs 1.06–1.23× the hash alone where it cost 1.6–1.95× (URLs 13.2 → 7.8 ns, UUIDs 12.2 → 7.1, 125-byte paths 21.7 → 12.6), with the same distribution battery result andhash_keyitself unchanged. Measured A-B-A-B in one process on 2026-09-16, the hash alone over keys read in order: dictionary words 3.9 ns against 7.3, English titles 7.4 against 11.2, URLs 6.5 against 13.2; 8-byte keys are the one loss, 3.8 against 2.9. The distribution battery (bench/results/hash-quality-*) reads the same. End to end,ClosedHashIndex::idover 1 M URLs / UUIDs / titles / paths reads 43 / 39 / 28 / 65 ns against 54.5 / 46 / 32 / 92 in the same protocol over the old hash, a path that includes the key's own cache miss (bench/results/mphf-strings-2026-09-16-*); PtrHash's fast set over xxh3 of the same strings reads 55 / 50 / 38 / 100. Every blob keyed on the old hash would answer wrong ids under the new one, soBMP7,BCH7andBCL1join the refused lists with a message naminglexindex < 4.0and the rebuild;BIX4andOVL2are untouched, and the standaloneMPH1–MPH3tables still load — a table is keyed on nothing but the hashes it was handed.hash_key_bytes(featurebench-mphf) returns the new values. Rebuilding from the keys is the migration; thegolden-4.0.0-*fixtures pin the new bytes and the 2.0.0 ones become refused fixtures. -
The perfect hash is 6 % smaller and bumps half as many keys: its seeds gain modes, and the blob format moves to
MPH3. AnMPH2seed was one shift: a bucket's keys sit at fixed offsets in their slices and the seed slides them along together, 255 positions at stride 2, and a bucket two of whose keys share an offset can never be placed — that was half of the 3 % of keys bumped to the next level. AnMPH3seed is two mode bits and six shift bits: each mode reads a bucket's offsets from a different field of the hash, so the four modes are four independent constellations of the bucket, each slid 64 positions at a stride of a sixty-fourth of the slice, and the seed taken is the lowest-sum placement over all four. Measured on 10 M real word-bigram hashes: 1.954 bits/key against 2.088, 1.58 % of keys bumped against 3.03 %, for 1.4× the single-thread build time (fewer than half of PtrHash's or PHast+'s). The search runs on one occupancy word a key per mode — a key's 64 shifts are 64 consecutive bits of one plane of the map, its run to the wrap and the rest before it — so the feasible shifts of a bucket are the zero bits of an OR, and the lowest sum is at the first free shift at or after some wrap: atrailing_zerosa wrap. Every hash blob written from here on carries anMPH3table, which 3.0 cannot read; theMPH2tables 1.1 to 3.0 wrote still load standalone and keep their seed geometry when written back. Thegolden-4.0.0-*fixtures pin the new bytes. -
A bumped key's remap is a sampled Elias–Fano stream, read in two steps.
MPH2sent a bumped key through four dependent lines after its first-level seed: the next level's seed, a rank word over the lower levels' values, the hole list's select sample and low word, its high words.MPH3stores the remap as a non-decreasing sequence over every value of the lower levels — a value no key takes repeats its predecessor's hole — so it needs no rank: the values' high parts in one unary stream, au32sample a block of 64 values holding the position of the block's first one, and a packed array of their low bits. A lookup reads its block's sample — four bytes a block, under a megabyte for a billion keys, so in cache — then counts through the four words from there with four popcounts and one select, beside its low word; a block whose ones run past those four words, under one lookup in ten thousand on the tables measured, goes on word by word. A bumped key is now its next level's seed, then its four words and its low word side by side, andindex_allhas one prefetch stage fewer. Measured on 10 M real word-bigram hashes: 1.917 bits/key, the remap 0.116 of them at 6 low bits, whereMPH2's rank vector and hole list would take 0.125 for the same sequence; at 100 M, 1.915 and 0.114. Blocks of 128 values would save 0.003 bits a key and need eight words a lookup: 5–20 % slower on a bumped key and 1.5–6 % onindex_all, hot. AnMPH2blob's rank vector and hole list are decoded into the stream when it loads, so it is written back asMPH3under its own seed geometry — not byte for byte, asOVL1becameOVL2in 1.x. -
The perfect hash's tables ask for transparent huge pages. A table of a huge page or more — the first level's seeds from about 9 M keys up — is allocated on a 2 MiB boundary and advised
MADV_HUGEPAGEbefore its first write, so a Linux kernel with transparent huge pages atmadvise(the common default) oralwaysbacks it with 2 MiB pages from the first touch, and a random lookup stops missing the TLB on nearly every seed. Measured against the same process with huge pages turned off: single lookups 10 % faster at 10 M keys and 3–4 % at 100 M, batches 12 % and 2–3 %; bits and build unchanged. Nothing changes below 2 MiB, on other systems, or where the advice is refused; the file-backed tables ofload_mmapcannot get them. No dependency: onemadvisedeclaration. -
index_allfinds each key's bucket once and reads its geometry as immediates. The batch loop pulled a key's seed in 64 keys ahead and then found the key's bucket a second time when its turn came, multiplied the shift by a stride it read from the level, and kept the level's geometry and its own bookkeeping on the stack. It now keeps the buckets it found in a ring of 64, shifts instead of multiplying, and runs each block through a function of its own with the geometry every table of this version has — four modes of 64 shifts, slices of 1024 — folded in as constants: 54 → 47 instructions a key. Measured A-B-A-B against the loop before it: 7–10 % faster at 10 M keys, 3–5 % at 100 M; below 2^18 first-level seeds the batch is the single lookup in a loop, as before, and unchanged. -
index_allpulls a seed in three times on a first level past L3, and a seed's prefetch checks no range. The batch pulled each key's seed in once, 64 keys ahead. From about 75 M keys the first level outgrows a client core's 16 MB L3 and its lines come from DRAM, and with one issued every key that far ahead more are outstanding than the core has fill buffers for: at a billion keys a fifth of the lines were loaded on demand all the same, a stall each, against one in fifty at 16 keys ahead — a lead that was 20 % slower than 64 at 100 M. A first level of 16 MB or more now pulls a seed in 56, 28 and 14 keys ahead, so that a dropped prefetch is issued again while there is still time, at the cost of a hit where the line has come. And no seed prefetch checks its range any more — a bucket is below the table's length by construction, and a prefetch cannot fault — a compare and a branch a key. Measured in one process against the loop before, A-B-A-B on a hot machine: 3–7 % faster from 3 M to 60 M keys, 22 % at 100 M, 19 % at 300 M and 17 % at a billion (10.3 → 8.5 ns/key), 3–7 % on eight threads from 100 M up. - A lookup reads its seed's mode from a table. A seed's top two bits pick which field of the
hash is a key's offset, and the lookup decoded them into a shift on every placed key: a copy of
the seed, a shift and a mask. A 256-byte table indexed by the seed holds the shift instead, one
load from lines that stay in L1. Measured against the lookup before, processes alternated A-B-B-A
on a hot machine, fat LTO: a single lookup 12 % faster at 10 M keys, 7 % at 100 M and 9 % at a
billion (18.6 → 16.9 ns), a loop collecting ids 8 %, and
index_all1–5 %. - An index's own arrays ask for huge pages too, and
from_bytescopies a blob once. The perfect hash's tables were on 2 MiB pages, but the arrays around them sat on 4 KiB pages, and aPerfectHashIndexarena of a few hundred megabytes read at random missed the TLB twice a lookup, for the key's offset and for its bytes. Every byte array an index owns — the arena, aCompactHashIndex's fingerprints, aDictIndex's blocks, aStringIndex's transducer — is now allocated as those tables are, on a 2 MiB boundary from a huge page up and advised before its first write. The fingerprint table is built in place, andfrom_bytescopies the blob once, where it copied it into aVecand then into anArc. Measured at 10 M URLs, UUIDs and Wikipedia titles, processes alternated A-B-B-A against the build before on a hot machine, fat LTO:PerfectHashIndex::id8–11 % faster,ids_of4–14 % andkey10–16 %, built or loaded; its build 7–12 % faster, andfrom_bytes4.6–4.8× (0.86 → 0.19 s on 10 M URLs — 2.1× of that is the copy saved, measured with huge pages off).DictIndex::id2–5 % andkey_into2–6 % faster, its batch within 1 % with huge pages on and off in one build;CompactHashIndex::id1–2 %, its fingerprint table being one line a lookup. The arena gains from 480 k words and 1 M URLs up (id5–10 %,key5–12 %); nothing below 2 MiB moves —CompactHashIndexat 1 M keys,ClosedHashIndexat any size — and build peaks are unchanged. A mapped index reads its file's pages, as before. Mphf::buildover hashes in any order: the serial sort is gone — 2.3–2.7× faster on eight threads, and a third of the peak at 100 M. Hashes handed over out of order were copied and the copy sorted on one thread before the first level could cut them into chunks: 22–25 ns a key on one thread or eight, and a construction peak the size of the keys. Now each thread marks its share of the keys with their group of chunks — two bits a key, four groups over equal shares of the buckets — and counts them by chunk; a group's keys are copied out by chunk when its first chunk is claimed, and each chunk is put in bucket order by one counting sort on the thread that places it, whose cursors end as the chunk's bucket offsets. Grouping every chunk up front by its buckets' two low bytes cost three times that sort, and copying all the keys at once is within 2 % of four copies of a quarter. Measured on splitmix64 hashes in generation order against the build before, processes alternated A-B-B-A on a hot machine, fat LTO: 10 M 84.1 → 72.2 ns/key on one thread and 32.8 → 14.3 on eight, 100 M 87.1 → 71.8 and 35.3 → 12.9; the construction peak above the keys 82 → 30 MB and 84 → 48 MB at 10 M, 818 → 261 MB and 820 → 280 MB at 100 M. The same hashes in ascending order build as before (61.8 / 10.6 ns at 10 M, 61.9 / 10.2 at 100 M), into the same table whatever the order and the thread count. Every index here hands its perfect hash sorted hashes, so the gain isMphf's callers' — and the comparison's with PtrHash and PHast, which bucket their keys inside the build in any order.-
Mphf::buildsearches a bucket's seed over fixed lanes: 1.8–1.9× faster on one thread, 1.3–1.4× on eight, and ahead of PHast+ on both. The seed search spent its time on loops whose trip counts the branch predictor could not know — one a key a mode, a candidate loop a wrap, and a collision check that struck candidates out one at a time — and its placement order was a queue a size class under a binary heap, whose updates were most of the placement's instructions. Now a bucket of 1 to 16 keys runs through a function of its own width, every loop's count known to the compiler. Two keys can meet under a mode's shifts only if their values are equal modulo the slice, so a table of the mode each residue was last seen in sends a mode to the exact check only when two of its keys agree, and the shifts a meeting rules out are a span of the mode's word; the first free shift at or after every wrap comes out of one addition, and a seed's score and the seed are one key, so the lowest needs no order. The buckets wait in a bitmap of priority slots — a bucket's index less its size class's term, in whole buckets, and a place in the slot the class fixes — taken from the lowest set bit behind a cursor. The seeds, and so the tables, are byte-identical, and a test holds the lanes to the search they replace over 24 000 buckets. Measured withbench/mphf_vson a cool machine, splitmix64 keys in generation order, against the run before: 75.2 / 70.6 / 70.3 → 42.8 / 37.5 / 37.5 ns/key on one thread at 1 M / 10 M / 100 M, 16.5 / 13.6 / 12.3 → 12.3 / 10.6 / 8.7 on eight, and 12.8 → 7.7 s for 10⁹ keys on eight; PHast+ builds the same keys in 55.4 / 60.2 / 67.9 ns on one thread, ahead of the old build at every size, and 17.9 / 15.4 / 16.5 on eight. -
Measured against PtrHash and PHast on a cool machine, with three faults in the comparison harness corrected.
bench/mphf_vsat 1 M / 10 M / 100 M splitmix64 keys, one process, A-B-A-B:MPH31.929 / 1.918 / 1.914 bits/key, the smallest function in every table, builds 42.8 / 37.5 / 37.5 ns/key on one thread and 12.3 / 10.6 / 8.7 on eight, the fastest in every table, single lookups 1.9 / 2.6 / 10.0 ns, batches 2.2 / 2.5 / 3.5, a construction peak of 6 / 30 / 262 MB above the keys — againstptr_hash2.1.1's compact set at 2.143 bits, 148–165 ns builds, 4.0 / 5.5 / 16.8 lookups, 4.2 / 4.7 / 6.2 batches and 16 / 156 / 906 MB; its fast set at 2.990 bits, level on the single lookup at 1 M, 1.9; and thephcrate's PHast+ at 2.146–2.160 bits, whose builds read 55.4 / 60.2 / 67.9 ns on one thread and 17.9 / 15.4 / 16.5 on eight. The harness sorted the keys for its dedup, and every build took them sorted:Mphfskips its own sort on sorted hashes, while PtrHash and PHast bucket their keys whatever the order, so every earlier table readMPH3's build at 55–58 ns on one thread with a 60 MB peak at 100 M; the keys are built on in generation order now. Thephcrate's PHast+ hashes with wyhash in the harness: its default is std's SipHash-1-3, which every earlier table timed, so its rows read 2–4× slower than the crate intends (PHast+ 8.8 → 3.5 ns at 1 M with the right hasher, its build 69.6 → 58.3). And the harness's lookup loops were inlined into one function beside each row's build, which cost lexindex 0.7–3.6 ns a lookup and the other rows nothing measurable; each row's loop now stands alone. The tables and the corrections are indocs/benchmarks.md; the string-to-id path end to end, on real key corpora, isbench/mphf_vs'sstringsbinary andbench/results/mphf-strings-*. -
Mphf::index_allanswers the keys the first level bumps in staged prefetches, and the single lookup ranks and selects off derived counts. The batch used to pull in only the first level's seed, sixteen keys ahead, so each of the ~3 % of keys that level bumps made four more dependent misses in turn — its next level's seed, the rank words, the select sample, the high words — and at 100 M keys the batch stopped at 13.5 ns a key against 6.9 for PtrHash'sindex_stream. The keys now go through the first level in blocks of 1024 with each seed pulled in 64 keys ahead, and a block's bumped keys are answered after it in five stages that run eight keys apart, each stage pulling in what the next one reads; below 2^18 first-level seeds (about 1.2 M keys) the level sits in L2 and the batch is the single lookup in a loop. The single lookup gains too: the remap's rank reads a 16-bit count per word instead of counting its block's words in a loop, and the Elias–Fano select compares a window of counts at once and finds the bit inside its word without a loop, where the old scan's mispredicted exits were most of a bumped key's cost. Both count directories are derived on build and on load and never written — the blob format is unchanged — and cost 0.023 bits a key in memory. Inbench/mphf_vsat 1 M / 10 M / 100 M keys: the batch 4.3 / 5.3 / 13.5 → 3.2 / 4.2 / 5.8 ns, ahead of PtrHash compact and balanced (4.6 / 4.9 / 6.3) at every size and never above the single lookup; the single lookup 3.7 / 5.8 / 20.1 → 3.2 / 4.9 / 15.8 ns (compact 4.2 / 6.0 / 17.6). PtrHash's fast set, at 2.99 bits, still leads both lookup columns outright.docs/benchmarks.mdcarries the re-measured tables. planprices eachDictIndexblock as a candidate of its own. The block is a knob worth 2.51 against 2.85 bytes a key on an English word list — a spread wider than the gap between some whole indexes — and the ranking hid it by pricing the default, 256, and nothing else.Fast(32),Balanced(256) andCompact(1024) are three rows on the ladder now, each with its own size and modelled latency,Plan::bestmay name any of them, andlexindex build --index autobuilds the block on the line it marked rather than the default one. On an English word list the three are modelled at 2.83, 2.61 and 2.48 bytes a key against built blobs of 2.85, 2.64 and 2.51 — every one of them within 1.3 %. Two consequences inside. The sample is fitted per block — the compression ratio, the packed per-block arrays and the symbol table all move with the block, and carrying the default block's constants across read 9.5 % high at 32. AndPlan::closecompares the two cheapest candidates of different kinds: two blocks of one index are always within a per cent or two of each other, and warning about that would be warning about every plan.--blockis still refused alongside--index auto, now because naming one would ask the planner to rank the three and then overrule its answer.- The planner draws its sample by hash instead of by position.
step_by(n / 100_000)was neither a sample nor 100 000 keys: integer division gives a step of one at 150 001 keys, so "the sample" was the whole corpus and every constant was read off a build the size of the real index — and above that, a stride over sorted keys takes one key from every prefix group whatever the group's size. A key is kept now while its hash is under a cutoff that falls as the draw fills: a uniform sample of the distinct keys, the same one on every run over the same keys, needing neither the corpus's size in advance nor the corpus in memory — which is what letsplan_fileanswer exactly asplandoes. It is also the more accurate draw. Measured over 23 corpora at all three blocks, the modelledDictIndexsize is within 1.3 % of the build at the median and 5.1 % at worst outside the two corpora the plan flags as thin (from 1.4 % and 5.4 % for a stride), and theStringIndexestimate from 3.4 / 9.8 % median and 90th percentile to 3.0 / 7.6. Against thestep_bydraw this replaces, on the corpora it read worst:idents-full12.3 % → 2.5 %,titles-zhat a million keys 15.5 % → 3.0 %,titles-zhwhole 26.3 % → 7.1 %. lexindex planandlexindex build --index autonow assume an open vocabulary, which means they imply--exactand so rank only the three indexes that can tell a stranger from a member. Left to itself the ranking was won byClosedHashIndexat 0.24 bytes a key, which answers a key it has never seen with some member's id — the right index for a fixed vocabulary, and a trap for a reader who has not decided yet. The two probabilistic indexes are put back by--closed-vocabulary, and the ladder says on stderr that they were left out and how to ask for them; that line is printed only when the default is what excluded them, since any other need rules them out anyway. Naming one with--index closedstill builds it. The library is unchanged:Needs::default()asks only forid(key)in Rust and Python, becauseplan's caller there has already decided and a command line's has not.- A new blob format now ships beside a reader for the old one. 1.0 and 3.0 each refused what
the previous major wrote; from 3.0 on a writer change lands in a minor release whose loader
still reads the previous format, and dropping a reader is reserved for a major with
lexindex dumpas the published way back.BDX2is frozen under that rule.docs/design.md § Versioningcarries it. SECURITY.mdnames the supported version. Its table still said 2.1.x, and promised that 2.1 blobs load — 3.0 refuses theBDX1dictionary. Now: 4.0.x supported, and every row below it says which of its blobs this version refuses and which two still load — the new key hash retiresBMP7,BCH7andBCL1alongsideBDX2, so a 3.x install has four of five indexes to rebuild. Itsunsafeinventory was recounted with it: thirteenunsafe fns and thirty-eight blocks in the library, not the nine and eleven it still claimed. Four of the functions and twenty-seven of the blocks are this release's own — the key hash's unchecked loads, the huge-page allocator, and writing into aVec's spare capacity before extending it — and the document had describedunsafeas confined to memory mapping and one prefetch, which stopped being true in 4.0.- Stale figures in the docs and the source comments, corrected to what the code does. The
DictIndexexample in the README quoted 3.2 B/key (the block-32 figure) anddocs/usage.md2.9, where the default block measures 2.64;src/offsets.rsdescribed two packed arrays where there are three, and priced them at the old block;docs/design.mdgave the per-block directory as 0.147 B/key and 5 % of the blob, themicro = 16figure, against 0.100 and 3.5 % measured at 32; the microblock was documented as one per sixteen keys and the symbol table as one per index rather than one per shard;BDX2was attributed to 2.2. The README's capability table now carries whatload_mmapdoes not borrow — the per-block samples, eight bytes a block. - The research frontier is cited, not just named.
docs/benchmarks.mdtables PDT, SuRF/FST, XCDAT, CoCo-trie, C² and the front-coding line this index descends from (libCSD, IBiS) with their papers, repositories and licences, and the README andbench/compare.pypoint at it. bench/sweep.pyprices marisa at eight and sixteen tries as well. Four tries is its floor on an English word list and was taken for its floor everywhere; on keys with long shared prefixes, and on random identifiers, the deeper recursion is smaller.bench/mphf_vsmeasures the function, not the caller's key array. Its lookup pass read each probe key throughkeys[order[i]]— a random 8-byte fetch from an 80 MB array per query, one DRAM miss charged to every row alike — and the perfect hash's lookup was published as 29 ns at 10 M; with the keys copied into an array in probe order it is 5.8 (PtrHash compact 6.6, PHast+ 14.2), the builds unchanged. The harness now prints every minimum's spread and a batch column (index_allagainstindex_stream), anddocs/benchmarks.mdcarries the re-measured comparison at 1 M, 10 M and 100 M keys — where the 100 M batch is the one column PtrHash leads, 6.9 against 13.5 ns — with ConsensusRecSplit on the authors' harness in its own process: 1.46–1.58 bits a key at 7–49× the build and 15–27× the lookup. The 2026-09-10 results file stays as measured.- The fuzzers run daily, on four processes for thirty minutes a target. They had ten minutes a
week on one core inside
sanitize.yml;fuzz.ymlgives each target two CPU-hours a day, starting from the corpus the last run left and minimising it withcargo fuzz cminbefore it is kept, so what the cache carries is the smallest set that reaches the same coverage rather than every file a month of runs ever wrote.
Added¶
-
A wheel for free-threaded CPython 3.14. The module has told CPython it does not need the GIL since 0.12, and CI has run the suite on
3.14tsince then — but the published wheels areabi3, and free-threading has no stable ABI before 3.15 (PEP 803 putsabi3tthere), sopip installon a3.14tinterpreter fell through to the sdist and compiled the extension itself. It now findslexindex-<version>-cp314-cp314t-<platform>.whlfor the same six platforms the abi3 wheel covers, minus the cross-built macOS x86_64 one — a version-specific build needs the interpreter it is built against, which a cross build does not have. The wheel is the same source with the same guarantees, and its smoke test assertssys._is_gil_enabled()is false after the import, so a release cannot quietly ship a module that switches the GIL back on. -
Kind::answersis public: whether an index of that kind answers everything aNeedsasks for, which is whatplanfilters its five candidates by. A caller that presents the ranking can now say why a kind is missing from it without restating the capability table — which is exactly what the command line above needed. -
lexindex inspect <blob> --sectionsandDictIndex::sections, which return aDictSections: where a dictionary blob's bytes actually go. Thirteen byte fields that sum to the blob — the symbol tables, the block heads, the samples, the three packed offset arrays, and the front-coded data split the way it is written, into one header byte an entry, the(lcp, len)varints of the entries too wide for that byte, and the symbol-coded suffixes — plus the counts of restarts, entries and wide headers. The sum is exact for any blob, including one this crate did not write: the codes are what is left after the headers rather than what a walk managed to read, so a damaged stream leaves the split approximate and the total right. On an English word list at the default block it reports 56 % suffix codes, 34 % header bytes and 3.3 % directory, which is the measurement a change to the format has to start from. -
A C ABI, under the
capifeature: one opaqueLexindexIndexhandle over the five indexes and fourteenlexindex_*functions — open, from bytes, build, save, kind, len,id,ids,contains,key, free, and the version, ABI-version and last-error accessors — declared ininclude/lexindex.h, whichcbindgengenerates fromsrc/capi.rsand CI regenerates to check. Every fallible call returns aLexindexStatus; the message behind a failure is thread-local and read withlexindex_last_error. A C caller opens a blob it may not have written, so the kind is a run-time fact and one handle type serves all five: what a kind cannot answer —keyon the two that store no keys,containson the bare perfect hash — isLEXINDEX_STATUS_UNSUPPORTEDrather than a missing symbol.cargo build --release --features capiexports it from thecdylib;cargo rustc --release --features capi --crate-type staticlibgives the archive.examples/capi.cwalks the whole surface and runs in CI against the built library. The ABI has a number of its own,LEXINDEX_ABI_VERSION, append-only within a crate major. Not in it, each additive when asked:Overlay, the mmap loaders, the automata queries, the fingerprint and block knobs ofbuild. -
plan_file(path, needs, objective): the ranking of a keys file, 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 from a sample; only the three constants no statistic gives still come from one, and that sample is drawn by hash, which does not need the corpus in hand. So this is not an approximation ofplan: 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.lexindex plan <file>andlexindex build --index autouse it, so a ranked build now streams like a named one — where--index autoused to read the corpus in and say on stderr that pricing it meant holding it. Standard input still cannot be planned this way: it cannot be read twice. -
plan_for(keys, needs, objective)andObjective: the ranking can be for something other than the blob size.Memoryis whatplanhas always answered and stays the default —planis exactlyplan_for(…, Objective::Memory), and its signature has not moved.Latencyranks by the modelled cost of oneid(key);Balancedpicks whichever candidate gives up least on the axis it does worse on. The candidates and their sizes do not change with the objective, only the order and soPlan::best. On an English word list asked for a reverse lookup,MemoryanswersDictIndexat 2.48 bytes a key and block 1024,LatencyanswersPerfectHashIndex, four times the size and a third of the wait, andBalancedanswers the same dictionary at block 32 — 17 % faster for 14 % more space.lexindex plan|build --objective memory|latency|balancedand Python'splan(..., objective=...)reach it, and every estimate now carriesnanos. -
Objective::Workload: rank by what the index will actually be asked. AWorkloadweighshits,misses,reverse,prefix,common_prefixandlongest_prefixagainst each other — relative weights, so counts read off a log serve as they are — andbatchsets the keys anids_ofcall holds. Every candidate is priced at the workload's mean operation from constants fitted per operation, and one that cannot answer an operation prices itself out. An operation is a need as well:reverserules out the two indexes that store no keys and a prefix query asks for an ordered one, andPlan::needssays what a ranking was made under. The ordered pair is where it decides something: on an English word listcommon_prefixnine to one against hits answersStringIndexat 420 ns against 1 502 for the fastest dictionary,prefixin the same proportion answersDictIndexat block 32, 298 ns against 824, and batches of 1 024 leavePerfectHashIndexfirst at 74 ns a key. A workload that asks nothing isLatency.lexindex plan|build --objective hits=9,misses=1,batch=64and Python'splan(..., objective={"hits": 9, "misses": 1, "batch": 64})reach it. -
Plan::nanos, and a nanosecond column on the ladder.a + b·s + c·len + d·s·lenper structure, operation andDictIndexblock, wherelenis the mean key length andsislog2of the candidate's 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. Fitted on relative error to 240 cells: eight structures over thirteen corpora at 100 000 keys, eleven at 1 000 000 and six at 10 000 000, timed in one process with every operation over fresh probes of its own and every structure timed at every place in its round.bench/latency_model.pyis the harness; itsfitre-derives the tables insrc/estimate.rsfrom the artifact inbench/results/, so the constants are checkable rather than asserted. Mean absolute error 5–13 % onid(key), 23–28 % at worst on prefix counts. It is an ordering, not a prediction. Fitted with a corpus left out, it names the fastestid(key)on that corpus in 30 of 30 corpus-size cells and the fastest ordered index in 24, never picking one more than 1.24× slower; against the published sweep, which it was not fitted to, it picks betweenDictIndexandStringIndexthe way the measurement does in 24 of 30 cells, never one more than 1.20× slower. And because the cells were timed through the Python binding it reads 311 and 318 ns forStringIndexandDictIndexon the word list where the Rust harness indocs/benchmarks.mdmeasures 234 and 266; that overhead falls on every candidate alike, so it moves the numbers and not the ranking. The ladder says so on its last line, every time it prints one. A corpus under 100 000 keys is priced as its 100 000-key equivalent, and the blob and key length are held at the smallest the fit saw rather than extrapolated past the evidence. -
lexindex build --stream: the command line builds without holding the corpus. With a named--indexand a keys file,buildfeeds the file into the streaming builders instead of reading it into aVec<String>first —auto(the default) past a quarter of the memory/proc/meminforeports available,always/nevereither way, or a size (1000,512M,4G). Measured on a 925 MB, 7 343 721-line path list, the peak resident set falls from 1.34 to 0.28 GB forstring, 1.42 to 0.26 fordict, 1.29 to 0.15 forcompactand forclosed, and 2.96 to 0.97 forperfect— whose blob is itself 935 MB, so it is the one index that cannot finish below the file it was built from. All five blobs are byte for byte the ones the in-memory build writes:--streamis a memory setting, never a format. Standard input is never streamed, because the dictionary builder reads its source three times and a pipe cannot be rewound.--index autostreams as well, sinceplan_fileprices a file without holding it either: the ladder comes off the streamed plan and its winner is built the same way. -
ClosedHashIndex::build_to_file, the fifth and last index to get one, so every kind the command line can build now has a streaming path. One pass hashes each key to its 16-byte pair and drops the string; runs are spilled and merged beside the output and the perfect hash is built from the merged file. There is no second pass — this index stores nothing per key, so what is left in memory is the perfect hash itself, which is the blob. It keeps the checkbuildmakes, one bit per slot, that the construction was minimal and perfect: an index that stores nothing per key has no second chance to notice that two keys were handed one id. -
lexindex dump <blob>, the fourth subcommand: the keys a blob holds, one per line, in id order — sorted order forStringIndexandDictIndex, construction order forPerfectHashIndex, live keys only for anOverlay.lexindex dump old.blob | lexindex build - new.blobis a migration off any format a later version stops reading, and it round-trips: rebuilding from a dump reproduces the blob byte for byte. The two keyless indexes say so rather than writing an empty file, and a key holding a line break is an error naming its id rather than a line a builder would read back as two keys. -
The research frontier, measured.
bench/frontier/builds the C² paper's benchmark — C²-FST, C²-CoCo and C²-MARISA beside FST, CoCo-trie, MARISA, PDT, ART and C-ART, every dependency at a pinned commit — and XCDAT, and runsDictIndexandStringIndexunder the same protocol: one build, then one cold pass over every key in a fixed shuffle, three rounds a structure, each process waiting for a quiet machine.docs/benchmarks.mdtables it at a million and ten million keys. lexindex is on the size–latency front on all thirteen corpora and all six;DictIndexis the smallest structure on twelve of the thirteen at a million keys and five of the six at ten million, the fastest ondnaat ten million, and builds before every compressed trie on every corpus. The one corpus that keeps a trie smaller isnumeric, whereStringIndexholds the whole corpus in a few hundred bytes; C²-MARISA reads faster thanDictIndexon ten of the thirteen and is larger on all of them. Nothing of the competitors is vendored — CoCo-trie is GPLv3, PDT non-commercial — andbench/tables.pyrenders the published frontier tables from their artifacts and checks them like the rest.
Fixed¶
- A streaming build no longer opens one file per spilled run. The external sort merged every run it had written in one heap, a descriptor and a 1 MiB read buffer each — so the resident cost of the merge followed the corpus, in a builder whose whole point is that nothing does. At 256 MiB of keys a run, a thousand runs is a corpus of ~256 GiB: a gigabyte of read buffers, and a descriptor count that outruns a 1 024 soft limit wherever one is still in force. The runs are now collapsed in groups of 128 first, so a merge opens at most 128 readers whatever the corpus was — one extra pass over the spilled bytes per factor of 128, and the common case, a build that spills fewer runs than that, does no extra work at all.
- The hash battery reported 99 999 collisions in 100 000 keys on a machine without a
dictionary — its first scheduled run, on a CI runner. The hash was not at fault: the
pathsfamily was spelled from dictionary words outside the check that there is a dictionary, and the stand-in word made it one key repeated a hundred thousand times. The families that need words are built only where the words exist now, and every family is checked for a repeated key before its collisions are counted. Every number the battery prints is unchanged. StringIndex::from_untrusted_bytesrefuses a hostile transducer structurally, so it no longer depends on unwinding. The loader caughtfst's decoder panic and returned it as anIndexError, which is not a defence underpanic = "abort"— an embedded profile, a size-tuned wasm build — where the same blob aborted the process. The cause was upstream and specific:fst::raw::Fst::newreads the root address out of the footer and then checks it only when it is zero, its(root == EMPTY && len != empty_total) && root + addr_offset != lenshort-circuiting on the first conjunct, so a blob naming a root past its own end is accepted andFst::rootindexes the blob with it — address 881 into 107 bytes for the committedtests/data/panicking-1.0.0-string.bix, raised insidefstbefore this crate sees a node. Every address the walk hands tofstis now measured against the blob first, from the fields that decide how many bytes a node occupies — the state byte, the pack sizes, the transition count and the packed deltas — and a node claiming more than sit below it, or a transition whose delta would be subtracted past the blob's start, is refused before the decoder is called. It is a bounds check and not a second decoder: it reads no input byte, no output and no transition index, because none of those can move a read. Thecatch_unwindstays as a backstop for a decode this crate has not modelled.tests/data/unfitting-4.0.0-string.bixpins the second shape, 40 bytes whose pack sizes underflow the node's end address, found in the first seconds of a fuzz run against an aborting build; both specimens now come back asFormat, andSECURITY.mdno longer carries the exception. What the claim rests on: the same target rebuilt withpanic = "abort", where a panic is a crash and not a caught error, ran 3 832 487 116 executions over 24 CPU-hours without producing one.- The weekly 32-bit Miri job stopped at the first overlay test that streams a perfect hash into a file. The arena is written through a file mapping, which Miri cannot interpret, so that test and every overlay test after it went unchecked on a 32-bit target. The mapping half of the two tests that reach it is compiled out under Miri; the rest of both still runs there.
- An
MPH2blob whose remap ran backwards panicked the loader. The v2 upgrade path decodes the older remap and re-encodes it as the sampled Elias–Fano stream, and it checked that every decoded value lands inside the image but not that the sequence is non-decreasing. The high parts arrive in order because the bitmap is walked in order; the low part under a repeated high part is whatever the blob says, so two entries sharing a high part could come back out of order and reachRemap::encode, which takes a non-decreasing sequence by contract. Now refused as aFormaterror at the boundary, where the range check already was. Found by the nightly fuzz job'sparse_mphftarget, which had been red since 2026-09-16; the specimen istests/data/panicking-3.0.0-mphf.bin. - An
MPH3blob whose level claimed a one-value slice panicked the loader. A level's stride is its slice shifted down by the geometry's mode bits, so a slice narrower than one shift leaves a stride of zero and a shift of 64 — an address the level is then read at with whatever the arithmetic wraps to. The loader checked that the slice is a power of two and fits the level, but not that it is wide enough for the stride it implies;slice_fornever returns under 256, so no build writes one. Now refused as aFormaterror beside the other three shape checks. Found by the sameparse_mphftarget on the run that followed the fix above; the specimen istests/data/panicking-3.0.0-mphf-slice.bin.
[3.0.0] — 2026-09-13¶
Changed¶
-
Breaking:
DictIndexblocks put their headers first, and the format isBDX2. A block held each entry's header immediately before its own suffix; it now holds every header, one byte each, and then every suffix. The same bytes in a different order — a blob is the same length to the byte — but a scan rules most entries out by the shared-prefix length alone, which lives in the header, and 127 of those are two cache lines where they used to be spread over the seven of a 128-key block. On the dictionaryidfell 9 % at 128 keys a block and 15 % at 256 (384 against 437 ns, 487 against 593), 2–4 % at 32 and 64, withkey_intounchanged and every size identical. Measured A-B-A against aStringIndexcontrol that this change cannot touch. ABDX1blob is refused by name — the message says that the fix is to rebuild — so this is a format break, and an index on disk has to be built again. The Python comparison table has not been re-run for it; see the note on that table's reproducibility indocs/benchmarks.md. -
DictIndextrains a symbol table per shard of 65 536 keys. One table for the whole index is a compromise between neighbourhoods that share nothing — the suffixes of a million paths under/usr/shareand of a million under/homeare different languages, and one sampled budget had to cover both. A shard is a whole number of blocks, each shard's table is trained on 10 000 pieces from that shard alone, the tables are trained in parallel, and the blob carries them in order after the header, which names the shard in blocks. A lookup picks its table from the block number it already has: one division, and no memory access it did not already make.pathsat a million keys goes 15.923 → 14.673 bytes a key,titles-ru12.110 → 11.364,idents7.270 → 6.658,titles-enat ten million 7.886 → 7.652 and the dictionary 2.852 → 2.838; what rises isdomains(5.008 → 5.066) and aword.wordgrid (2.47 → 2.50), corpora whose shards are all one language and now pay for a table each. The ladder over 32 / 64 / 128 / 256 / 512 / 1024 keys a block becomes 3.23 / 3.03 / 2.90 / 2.84 / 2.81 / 2.79 bytes a key. Training is linear in the sample, so the shard and the budget trade against each other at a fixed build cost: over eight corpora, shards of 262 144 / 131 072 / 65 536 / 32 768 / 16 384 keys with the budget halving alongside sit 1.5 / 0.8 / 0.4 / 0.4 / 0.9 % above the smallest size each corpus reaches, and 65 536 is the cheap end of that flat bottom. Lookups do not move: A-B-A-B in one process on three corpora givesid+0.5 / −2.0 / −2.4 % against a control moving −0.7 / −2.5 / −0.4 %. Building costs 17–23 % more, and holds one symbol-table trainer per training thread — about 2 MB each — so on sixteen threads the dictionary's in-memory build peaks 32.1 → 51.6 MB and its streamed build 12.7 → 27.1. The trainers are bounded by a 64 MB budget and never byn: one holds 2.6 MB over a million keys and 3.0 over ten million, so past 21 of them they queue instead of multiplying, and a machine with 128 cores keeps the peak a property of the work rather than of its core count — 380 MB of trainer state would otherwise sit under a ten-million-key build that holds 391. The bound is free where it can be measured: capping the same build at 8 trainers ran −0.4 % against uncapped on sixteen threads, and only a cap of 4 cost anything (+2.9 %). On one thread the same two builds peak at 31.2 and 15.3, the second paying only for the eight tables. -
DictIndexsplits every block into microblocks, and the default block is 256. A block was both the unit a head and its arrays are shared over and the unit a lookup scans, so the only way to store less was to scan more: 2.83 bytes a key cost 460 ns ofkey_into, 2.75 cost 853 and the format's floor of 2.70 cost 3 190. A block is now split into microblocks of 32 keys — a block of 32 or fewer is one microblock — and the first key of each microblock past the block's own head is a restart, front-coded against the restart before it. A block's data is its restart run followed by each microblock's run, and a lookup walks the restarts to the one microblock that can hold the probe:block / micro + micro − 2entries scanned, 38 at the default where a block of 256 scanned 255. At the same sizekey_intohalves andiddoes not move — 2.819 B/key at 512 a block answerskey_intoin 244–246 ns andidin 392–399, against the one-level format's 2.827 at 128 in 460–464 and 393 — and the floor moves with it: 2.80 at 1024 a block costs 287–289 ns ofkey_intowhere one level's 2.752 at 256 cost 853–859. Measured A-B-A-B against a build of the previous format in a worktree,StringIndexthe control at 340–353 ns throughout (bench/results/dict-onelevel-ab-2026-09-12-arz-386b2e6.txt) — that session reads about 25 % above the ladder below, control included, so the two are compared each within itself. The ladder over 32 / 64 / 128 / 256 / 512 / 1024 keys a block is now 3.24 / 3.04 / 2.92 / 2.85 / 2.82 / 2.80 bytes a key,id252–254 / 272–284 / 285–288 / 298–302 / 316–322 / 344–347 ns andkey_into154–155 / 169–172 / 184–188 / 207 / 227–232 / 263–272 (two runs in opposite order,bench/results/dict-micro32-2026-09-12-arz-386b2e6.txt). The default block moves 32 → 256, in Rust and in Python, which takes the default index from 3.24 to 2.85 bytes a key — under the 2.955marisa-triestores at its most compact setting on this corpus — forid298–302 ns against 252–254 andkey_into207 against 154–155. The symbol table now trains on runs of a constant length rather than whole blocks: the 20 000-piece budget was spent block by block, so a larger block bought fewer neighbourhoods — 157 at 128 keys a block, 19 at 1024 — and a table trained on 19 of them is a lottery, which is howpathsat 1024 keys a block came to store 0.52 bytes a key more than it needed to, more than the same corpus at 256. A run is 256 consecutive keys, the default block, whatever block the caller picked. Over twelve corpora at three block sizes 28 of the 36 sizes fall and the worst rises 0.05; the dictionary at the default block is sampled identically either way, so its numbers do not move. Building costs 6 % more, from the walk over the keys the old rule skipped. The format isBDX2: the header carries the microblock size and a fourth offset width, the blob gains a packed start per microblock where a block has more than one, and aBDX1blob is refused by name with a message that says the fix is to rebuild. A streamed build now holds(mean head length + 20) / block + 8 / microbytes a key rather than(mean head length + 20) / block, which at the new default is 0.36 against the old default's 0.9. -
DictIndexpacks its per-block offsets, and the index is 8 % smaller at the default block. Where each block's head ends and where its entries start were au32and au64a block — 12 of the 20 bytes a block cost — while both only ever grow by one block's worth at a time. Each is now oneu64base every 64 blocks and a delta of the width the corpus asks for: 10 and 13 bits on the dictionary at the default block. Per-block metadata falls 0.625 → 0.348 bytes a key at block 32 and 0.156 → 0.089 at 128, so the index goes 3.52 → 3.24 and 2.89 → 2.83, and 4.35 → 3.79 at 16 keys a block. A head and a block's data are both read as the span between two entries, and neighbours share a base and the word their deltas are cut from, so the pair costs what one entry costs:iddoes not move at any block size, andkey_intopays 1.7 % at block 32, 5.5 % at 128 and 6.3 % at 256 — two instructions an entry walked, with branch misses and cache misses flat. Measured against the same ladder on the tree before it (bench/results/dict-offsets-2026-09-12-arz-6c699c0.txt); the corpus tables indocs/benchmarks.mdare re-measured too. The 4 GiB ceiling on the block heads is gone with it — the base is a full word, soIndexError::Format("dict: the block heads exceed 4 GiB; use a larger block")can no longer happen. TheBDX2header grows three bytes for the two widths and the superblock shift, and the block starts move behind the block data: their width is only known once the encoding is done, which is what lets a streamed build write every section once and in order. No release wrote the layout this replaces, so nothing outside this tree ever held a blob in it. -
The crate tarball is the Rust crate and nothing else. It now carries the sources, the examples, the integration tests with their golden blobs,
README.mdanddocs/usage.md(both pulled into the crate docs), and the licence, changelog, security policy and citation. CI, the docs site, the Python package, its build files and its examples, the benchmark tooling and its 67 measurement logs stay in the repository: 169 → 71 files, and 712 KB of logs leave everycargo installand docs.rs build.
Added¶
-
A command line:
lexindex plan | build | inspect. Choosing between five indexes no longer needs a Rust file.lexindex plan keys.txt --reverse --prefixprints the ladderPlan's ownDisplaywrites — every candidate the needs allow, cheapest first, with theDictIndexblock it priced and the "build both" / "too thin to quote" caveats.lexindex build keys.txt out.binasks the same planner and builds the winner, writing the ladder it chose from to stderr;--index dict | string | compact | closed | perfectnames one instead, and--blocktakes1..=1024orfast/balanced/compact.lexindex inspect out.binprints the header one field per line, an overlay's base included, in about a millisecond whatever the blob weighs. A keys file is one key per line, UTF-8, in any order,-for standard input; an empty line is skipped and the count reported on stderr, and invalid UTF-8 is an error naming the line rather than a replacement character — 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, 1 for work that fails. No new dependency and no new public API: the argument parsing is hand-rolled in the binary, and the binary computes no number of its own — on the 479 823-word system dictionary the fivebuildruns report 0.26 / 1.26 / 2.84 / 5.95 / 10.90 bytes a key, the figures the tables already carry, andplanputs them at 0.26 / 1.26 / 2.78 / 5.55 / 10.90 without building any of them (−0.7 / −0.1 / −2.1 / −6.7 / −0.02 %).--index autocosts a plan: over that dictionary it takes 287.6–287.9 ms against 116.9–117.4 for--index dict, the difference being the planner's 100 000-key sample, which builds all five candidates whatever the needs narrowed the ranking to (hyperfine, 5 runs each, A-B-A-B, σ ≤ 1.7 ms). Below 100 000 keys the plan builds the real indexes rather than modelling them, so--index autothere builds the corpus twice and naming the index is the way not to. -
An id can be retired without a membership test.
Overlay::retire_id(id)—retire_idfrom Python — sets exactly the tombstoneremovesets, with no key lookup in front of it. Over aCompactHashIndexbase that lookup is the one write a false positive costs something on: a string the base has never seen can match a live id, and removing it retires that id along with the real key behind it. An application that stores the id it was handed when the key was added now has a removal nothing can mislead. An id at or aboveid_space(), or one already retired, isfalseand changes nothing.removebecame that method with a lookup in front, so the tombstone and the live count are maintained in one place rather than two. -
planprices every index before one is built. Five indexes with five corpus-specific size curves is a choice nobody should have to make from a README table, and the spread between them on one corpus is larger than the spread of any one of them across corpora.lexindex::plan(&keys, needs)sorts the keys once, measures what the formats are actually paid in —n, the mean key length, the LCP structure, the trie-node count — and returns aPlanthat ranks every index the caller'sNeedsallow, with aDisplaythat prints the ladder and says why each line is where it is. One build of a 100 000-key sample supplies the three numbers no statistic gives: what the symbol table squeezes a suffix into, the bytes an fst spends per trie node and the bits the perfect hash spends per key. Scored against the built blob on 23 corpora of half a million to ten million keys,DictIndexlands within 1.4 % median, 4.5 % at the 90th percentile and 5.1 % at worst,StringIndexwithin 3.5 / 9.6 / 30.3 — an fst merges equal suffixes, and how much it merges is a property of the whole key set rather than of a sample of it. Below the sample size nothing is modelled: the plan builds the candidates and reports what they weigh. Two candidates inside 1.3× of each other are reported as a tie worth building both ways, and a corpus whose mean suffix is under two bytes — ten million numbers, where the sampled ratio is 19 % off — is flagged rather than quoted.lexindex.plan(keys, prefix=True)from Python returns the same ranking as a dict of plain types, with the paragraph the RustPlanprints undertext. -
A block size can be named instead of measured out.
DictProfile::Fast,BalancedandCompact—block="fast" | "balanced" | "compact"from Python, in the same argument as the number, so there is no second setting to contradict the first — stand for 32, 256 and 1024 keys a block: 3.23 / 2.84 / 2.79 bytes a key,idin 252–254 / 298–302 / 344–347 ns andkey_intoin 154–155 / 207 / 263–272. The names are what the curve is picked over, so a later release can move a name to a better point on it;block=still takes any number in1..=1024. -
An Arrow slice no longer copies the whole column. A sliced Arrow array shares its parent's buffers, and
ids_of_arrowcopied them whole: 1 000 keys taken out of a 480 000-key column cost 9.4× the same keys in an array of their own, nearly all of it a 6.4 MBmemcpyto read 13 KB. Only the offsets and the bytes a chunk names are copied now, and the validity bitmap is trimmed by whole bytes — the slice costs 1.2×, and the unsliced path is unchanged, still handing the buffer over whole in onememcpy. This also settles the Arrow C Data Interface question the 2.0 review left open: the copy is 0.6 % ofids_of_arrowonDictIndexand 5.8 % onCompactHashIndex, the fastest index there is, so a zero-copy PyCapsule path would buy that much for ~200 lines of Arrow FFI or a new dependency. It is not being built. -
bench/reproduce.sh, one command from a checkout to the tables the README cites. It refuses a dirty tree, because a number attributed to a commit whose code was not the one measured is not a number; pins the three competitor versions; verifies all 38 corpus files against their hashes; prints the machine; and ends by comparing the run's Python call floor against the published one, which is the only row in that table nothing in this library can move. Running it found two things it was written to find: the probe set was drawn from asetof words, whose iteration order changes every process, so no two runs ofbench/compare.pyhad ever measured the same probes (now sorted before the seeded shuffle, asbench/corpora.pyalready did); and the same commit on the same machine six hours apart puts that floor at 49 ns and at 110 ns, which is the warning the last step now prints instead of letting the difference land silently in every row. -
Error bars for the lookup tables, and the counters behind them. Every timed number this project publishes is the minimum of a few alternated passes, which is the right estimator for a laptop and carries no width — so a frontier claim rested on an ordering nobody had bounded. Thirty independent passes a structure with a bootstrap interval around the median now say it: past 150 ns the p5–p95 band is 2.4–9.5 % and the minimum is within 1–4 % of the median, but under 50 ns the band is 45 % and the minimum sits 25–29 % low. The probe set turns out to be part of the working set —
ClosedHashIndexdoubles between 100 000 and 400 000 probes a pass whileDictIndex128 moves 7 % — andperfnames the cause: the instruction count a lookup does not change, the cycle count does, and the data TLB starts missing past 200 000 probes. The ordering survives all three probe counts with disjoint intervals, which is what the tables claim; the absolute nanoseconds belong to the harness as much as to the structure.DictIndex128's cost against 32 is now attributed too: 3226 instructions a lookup against 1671, with the same LLC misses — a block scan is linear in the block, so a bigger cache will not close it. -
Read scaling across threads, measured. Every index is immutable once built, so one
load_mmapis shared by reference and answers from every core with no lock, no copy and no per-thread instance — an unclaimed property until now, because nobody had run the ladder. On ten million Wikipedia titles, eight cores give 6.7–7.9× and sixteen hardware threads 8.7–13.2× (the run this entry was written from; the ladder was measured again for the published table, which reads 6.7–8.1× and 9.4–13.5× —docs/benchmarks.mdnames that artifact), the SMT row being worth having because a point lookup is a chain of dependent loads and a second thread fills the stalls.StringIndexscales best (13.2×) for the same reason it is slowest on one thread: a transducer walk is nothing but dependent misses, and two cores have twice the memory-level parallelism one has.CompactHashIndexsaturates first, at 8.7×, having run out of something other than cores — 268 M lookups a second over an index that fits in L3. -
What a cold mapping costs, measured for the first time. Every size this project publishes is the blob on disk, and the number that decides whether an index fits a container limit is the resident set.
local/coldmmapdrops a file's page cache withposix_fadvise, maps it, and reads/proc/self/smapsafter 1 000 random lookups and after a further million. Three findings, on ten million Wikipedia titles.load_mmapis lazy as documented — 0.01 to 0.26 bytes a key resident before the first query. The first thousand queries then leave 4.69 ofDictIndex's 7.88 bytes a key resident while touching only 0.82, the 5.7× being the kernel's readahead, which is buying latency with memory and is the right default:MADV_RANDOMcuts the resident set to what is touched and costs 1.7× on the cold lookups and 3.2× on the warm ones, soload_mmapdoes not set it. And cold start is where the smallest structure wins outright —CompactHashIndexanswers its first thousand queries at 6.3 µs againstDictIndex's 51.5 andStringIndex's 89.3, and on UUID keys at 5.9 µs against 125.3 and 231.0. Past the warm-up every structure is fully resident, so the size table is also the steady-state RSS and only the warm-up distinguishes them. -
Nine structures over the whole corpus set,
bench/sweep.py. The first measurement this project has that can answer where each structure is smallest rather than how big it is on one word list — threeDictIndexblock sizes against threemarisa-trieconfigurations, plus the keyless indexes andStringIndex, on every corpus at every size, and the same question in Rust againstrsmarisa's three cache levels. What it shows is a frontier and not a crown.marisa-trieis the smallest key-storing structure on nine of the eleven million-key corpora, and the margin follows how much the keys share: 1.72× on filesystem paths, 1.49× on Russian Wikipedia titles, and within 4 % on DNA k-mers and domains.DictIndexis smaller where the keys share nothing — 1.32× on opaque base64url ids, 1.62× on UUIDs — which is the same reason it is smaller on English words, a corpus at the favourable end of that distribution rather than a typical one. Both facts are now indocs/benchmarks.mdwith the README's claim scoped to the corpus it was measured on.
The trade is not only size. DictIndex answers 1.8–2.9× faster than marisa's fastest
configuration on ten of the eleven corpora — the eleventh is dense decimal ids, where marisa is
10 % ahead of the default block and 9 % behind a block of 128 — and builds 2.3–4.3× faster, so
where marisa is 1.72× smaller on paths it is also 2.9× slower to answer and 4.3× slower to
build. The two keyless indexes are flat at 0.26 and 1.26 bytes a key on all thirteen corpora at
all three sizes, which makes them the only rows a reader can carry to their own keys without
measuring. And StringIndex compiles ten million dense decimal ids into an FST of 356 bytes —
a real number, and the ceiling of what shared structure can buy rather than a size claim.
-
A pinned corpus set,
bench/corpora.py. Every size this project has published is measured on one corpus, the Fedora word list, while bytes per key is a property of the keys at least as much as of the structure:StringIndexmeasures 0.68 on one corpus and 16.57 on another, andmarisa-triemoves between 2.12 and 6.21 over the same span. Thirteen corpora now exist at 100 000 / 1 000 000 / 10 000 000 keys wherever the source has them — Wikipedia titles in three scripts, the URLs those titles form, Tranco domains, PyPI package names, this filesystem's paths, vendored Rust identifiers, the word list, and seeded UUIDs, dense decimal ids, opaque base64url ids and DNA 24-mers. The sizes are nested, so a difference between two of them is scale and not composition; the downloads are pinned to a dated dump or a permanent list id rather than tolatest; and the SHA-256 of every download and every derived file is inbench/corpora.json, whichpython bench/corpora.py verifychecks. The corpora are gitignored — 4.6 GB — and the manifest is what makes them checkable anyway. No benchmark is on the set yet. -
common_prefixandlongest_prefixon both ordered indexes, in Rust and Python: every key that is a prefix of the query, shortest first, and the longest of them. This is the reverse ofprefixand the query a longest-match tokeniser runs — a vocabulary and a position in a sentence give the entries that start there. It was the one operationmarisa-trie,dawg2anddatrieall had and lexindex did not, which the comparison tables now carry as a column of its own.StringIndexanswers it in a single walk down the transducer,O(query bytes)whatever the index holds — the fastestlongest_prefixin the comparison at 422 ns, againstdatrie's 482,dawg2's 810 andmarisa-trie's 965, and within 3 % ofdatrieoncommon_prefix(726 against 706) at a fifth of its bytes.DictIndexhas no such walk and pays one order lookup per character boundary — 2 157 ns at 32 per block — except onlongest_prefix, which stops at the first hit and so comes in under marisa at 863 ns. The documentation says which is which rather than implying they cost the same. -
DictIndex::build_to_file— the constructor for a corpus that does not fit in memory, andDictIndex.build_to_file(items, path, block=32)in Python. The keys arrive in any order, are sorted in runs spilled beside the output and merged back, and the block data goes into the file as it is encoded. The bytes are exactly whatbuildfollowed bysavewould have written, which the tests assert at four block sizes over both the spilled and the single-run path. Measured over 479 823 words streamed from a file: peak RSS 32.1 → 12.7 MiB for the same 3.52 bytes per key on disk, at about 19 % more build time. What is still held is the block heads and the three per-block arrays — roughly(mean head length + 20) / blockbytes per key, so a larger block holds less as well as storing less. The external sort it shares withStringIndexmoved to its own module. -
DictIndex::build_sortedandbuild_sorted_with_block, for keys already in ascending byte order — a sorted file, a database cursor, the output of an external sort. Adjacent duplicates are dropped exactly asbuilddrops them after sorting, so for the same key set the two produce byte-identical blobs. It is not the faster of the two and does not pretend to be:build's sort is pattern-defeating, recognises an ascending run and returns almost at once (31.9 against 32.1 ms over 479 823 words). What it buys is the check — an unsorted input returns an error instead of an index whose every binary search is wrong, which is whatStringIndex::build_sortedhas given since 0.11 andDictIndexdid not.
Changed¶
-
A batched
DictIndexlookup is no longer a loop.ids_of— and with itids_of_bytesandids_of_arrow, the Python and Arrow batch paths — ranidonce per key, and a singleidis a chain of dependent loads: the binary search over the block samples, then the block's head, then its data. Past the last-level cache each one waits on the last. Thirty-two lookups now advance in lockstep — every lane takes one step of its binary search before any lane takes its second, and the blocks they land on are prefetched whole before any lane is scanned. On real word bigrams, half the probes members, shuffled, against a loop ofidin the same process: 861 → 535 ns a key at 10 M keys and 32 per block (1.61×), 771 → 645 at 128 per block, 377 → 306 at 1 M, and 277 → 281 at 200 k — where the index is 0.6 MB, nothing stalls, and there is nothing to hide. Prefetching the heads as well was tried and measured 7 % slower; it is not in the code. -
DictIndex::keys_ofwalks a block once for the ids that ascend through it. It calledkeyper id, andkeyre-enters the block at its head and decodes forward to the entry, so a run of ids inside one block decoded the same prefix chain again for every one of them. A cursor now stays open while the next id is in the same block and above the current one — which is exactly the shapeprefix_id_rangehands it — and falls back tokeyfor anything else, so an arbitrary order is answered as before and a test pins both againstkeyover shuffled, reversed and repeated ids. Measured over 20 000 three-byte prefixes on 479 823 words, 763 matches each: a prefix's keys throughprefix_id_range+keys_ofgo 135 595 → 71 915 ns at 32 per block and 218 542 → 70 566 at -
That makes the id range the fastest way to enumerate a prefix here, ahead of
prefixitself (103 716 ns, and it decodes an id for every match) and ofmarisa-trie(93 355) — the reverse of whatdocs/benchmarks.mdrecorded, which is corrected with the new artifact behind it. The columns the change cannot touch moved by up to 8 % between the two runs, the extension having been rebuilt in between, so the 1.9× and 3.1× are not that. -
A
DictIndexencodes its blocks on every core. Once the symbol table is fixed a block depends on nothing outside itself, so contiguous ranges of blocks go to their own threads (std::thread::scope, no new dependency) and the parts are concatenated in order. The bytes do not depend on how many threads ran, which a test pins at 1/2/3/5/8/64 threads against the single-threaded blob — and the streaming builder checks from the other side, since its own encoding pass is serial. Measured on this 8-core machine, one core against sixteen: 126.2 → 75.2 ms over 909 776 real file paths and 34.0 → 24.6 ms over 479 823 words. The threads take a&[&str]view rather than the caller's&[S], which would have meant addingS: Syncto a signature that never asked for it; the view costs 16 bytes a key while the encoding runs, which on the paths corpus is less than the doubling the singledatabuffer did (41.6 against 42.4 MiB allocated) and on the shorter words costs 12.5 bytes a key. -
Building a
DictIndexno longer copies every key.build_with_blockcollecteditems.into_iter().map(|s| s.as_ref().to_owned()), one ownedStringper key, before sorting. Sorting the caller's items throughAsRefinstead costs nothing and removes the copy, because the keys are copied into the blocks anyway. Measured with a counting allocator over 479 823 words, from a&[String]— which is what the Python constructor and everybuild(&keys)call pass: the build allocates 42.6 → 17.3 bytes per key and takes 54.0 → 32.1 ms. From an ownedVec<String>moved in, the allocation was never doubled (Vec::into_iter().map(…).collect()reuses the buffer in place and frees each source key as it copies) and the time goes 47.6 → 39.8 ms. -
The comparison table carries a lookup-latency column. Bytes per key on their own read as though the smallest structure were the best one.
bench/compare.pynow times one exact lookup per library over 100 000 probes, half of them plausible strangers, shuffled — every structure taking one pass per round so that none is measured with the caches still warm from its own build, and all of them resident while each one runs. Two costs are named instead of assumed: the Python call boundary is 49 ns, and.geton a miss is each library's own miss path rather than atry/exceptaround__getitem__— timed apart, a miss costs 0.85–0.91× a hit everywhere, while that wrapper written out by hand costs 1.61×, which matters because half the probes are misses. On this corpus the four keyless rows answer in 92–100 ns against a builtindict's 260, andDictIndexat 128 per block is smaller and faster than everymarisa-triesetting measured (2.89 B/key and 389 ns against 2.96–3.07 and 449–490). -
marisa-trieis benchmarked as a curve, not a point, andrsmarisajoins the table. Every marisa number this project published came from the default configuration, while marisa's own documentation says the right setting depends on the data — an open invitation to the objection that the baseline was untuned. Measured over the whole space on the same words:num_tries1/2/3/4/5/8/16/32 gives 3.380/2.997/2.978/2.977/2.977/2.980/2.986/2.998, flat from three and worse past eight;cache_sizeTINY…HUGE gives 2.957/2.964/2.978/3.008/3.066;orderandbinarydo not move it. Its best is 2.955, soDictIndexat 128 per block leads by 2.2 %, not the 3.0 % against the default, and the comparison tables carry three marisa rows.
rsmarisa — a pure-Rust port of marisa-trie, BSD-2-Clause, first published 2026-01-26 — is in the
Rust table for the first time. docs/benchmarks.md had said no succinct LOUDS trie existed in Rust
to depend on and rested a "smallest pure-Rust ordered index" claim on it; both were wrong, and
the second was wrong about this crate as well, since DictIndex shipped at 3.52 B/key in 2.0 and
was never added to that table. Measured: DictIndex at 128 per block dominates rsmarisa at its
most compact setting outright — 2.894 B/key against 3.003, 435 ns against 445 on members, 442
against 473 on misses, and 38 ms against 174 to build — while at 32 per block it is the fastest
structure there (323 ns) and larger than either rsmarisa setting. A frontier, not a crown.
-
The hash quality battery runs in CI. It was
#[ignore]d behindbench-mphfand CI only ran clippy on that feature, so the battery compiled and never executed and the committed artifact was a hand-run. It is a weekly job now. Bit independence covered the slot hash at one key length while avalanche covered both hashes at five, and the notes claimed the wider scope for both; it now runs both hashes over 8/16/24/80-byte keys, sampling at most 128 input bits per pair so the cost stays flat in key length. The cell count goes 346 112 → 1 894 400, so the bound moves 6.0 → 6.5 (PARI/GP: a family-wise 10⁻³ over that many wants 6.211; 6.5 leaves 1.5 × 10⁻⁴). Worst observed: avalanche 4.49 unchanged, bit independence 4.23 → 4.54 over seven times the cells. -
The queries 2.1 added are fuzzed, and
BDX1is checked on a big-endian and a 32-bit target. The fuzz shim asked forid,lower_bound,keyand a walk;prefix_id_range,prefix_count,prefix,range,range_count,successor,predecessoranditer_afterwere untouched by any target. All of them are held to bounds on malformed input now, and on a blob that passed every check the whole ordered surface is cross-checked against a linear walk. The weekly Miri jobs gaineddict_index::: the format stores each block head's first eight bytes as a big-endian word inside an otherwise little-endian blob, which is exactly what the s390x job exists to catch, and it had never run there. Both targets pass — coverage for what ships, not a fix.
Fixed¶
-
Documentation claims a reader could have checked against the table beside them. "
StringIndexis the only structure that answers fuzzy and range queries at all" —DictIndexhas answered range and prefix since 2.1, and only fuzzy and subsequence need the automaton; the capability matrix said it structurally too, merging prefix into one row with fuzzy soDictIndexread as having no prefix four lines above the paragraph saying it does. "marisa-trieremains the pick for exact membership and ordering and the smallest such index" — the table fifteen lines above it disagrees.SECURITY.mdcounted sevenunsafe fns and nineunsafeblocks; it is nine and eleven, "on every index" was never right sinceClosedHashIndexhas nothing to map, and the fuzz inventory listed eight targets where there are nine. And nothing said whyOverlayhas noDictIndexorClosedHashIndexbase, which matters now that the smallest row in the table is the one that cannot take an edit: an addition takes the id after the base's last, which is not a rank, and a closed vocabulary has no membership to ask. -
The benchmark artifacts behind the tables are taken on a clean tree. The one the README cited recorded
commit: 00857e2-dirtyandlexindex: 2.0.0while backing a 2.1.0 table, the hash battery's wasc597099-dirty, anddocs/benchmarks.mdpointed at an artifact from 2026-09-09 that predated the rows above it. One run, one file, both pages citing it.
[2.1.0] — 2026-09-11¶
Added¶
DictIndexanswers prefix and range queries, in Rust and Python:prefix,prefix_iter,prefix_id_range,prefix_count,range,range_iter,range_count,iter_after,successor,predecessor— the ordered surfaceStringIndexalready had, with the same names and the same answers (a test cross-checks the two index types on every one of them). The type had been documented as having none of this because it has no automaton, which conflated two things: a fuzzy query needs the automaton, a prefix does not. Keys sharing a prefix are adjacent in rank, so a prefix is one contiguous id range, andprefix_id_rangefinds it with twolower_bounds.
That makes counting a different operation rather than a faster one: prefix_count is 351 ns
where marisa-trie takes 127 657, because marisa has to enumerate all 763 matches of an
average three-byte prefix to count them — its ids are not lexicographic ranks, so it has no
arithmetic to do instead. At block=128, where the index stores 2.89 bytes per key against
marisa's 2.98, it is also ahead on autocomplete (2 435 ns for the first ten against 2 773) and
level on full enumeration, while returning each match's rank as well.
-
DictIndex::load_mmapandload_mmap_verified, in Rust and Python. The keys, the block data and the two offset arrays are held as the bytes they are serialised as, owned or mapped, an entry decoded where it is read; so a mapping borrows them and the load reads the header, the symbol table and the per-block samples — eight bytes a block, one byte per four keys at the default block. That last array is read rather than borrowed because two binary searches over it open every lookup and only<[u64]>::partition_pointkeeps those searches out of the branch predictor: it selects withhint::select_unpredictable, which needs an alignment a section of a blob does not have, and searching the bytes instead measured 110 ns against 26 for the pair.load_mmapskips the payload checksum and the walk over the arraysloadmakes, every access bounding what the arrays say instead — a crafted file answers wrong, never out of bounds — andload_mmap_verifiedruns both over the mapping. TheBDX1blob is unchanged, and reading a section where it lies rather than as a decoded array costsid303 → 308 ns andkey200 → 209 on the dictionary atblock = 32(StringIndexcontrol 342 → 337; min of five rounds alternated in one process, shuffled probes). -
A hash-quality battery under
bench-mphf, and its committed output inbench/results/. Strict avalanche and bit independence over both hashes and five key lengths, the two-byte differential scan that caught the pre-2.0 collision family, and per-corpus distribution tables over ten key shapes — words, bigrams, a shared prefix, a shared suffix, a dense numeric tail, decimal integers, UUIDs, Cyrillic, DNA and paths. Everything reads as a standard-normalzagainst one bound of 6; a chi-square goes through Wilson–Hilferty rather than(x − k) / sqrt(2k), which understates the tail by two orders of magnitude at 255 degrees of freedom. Nothing in the battery is past the bound: the worst cell of 44 032 is 4.49, where the maximum of that many standard normals sits near 4.6, and the differential scan finds no double collision at all.
Fixed¶
inspectis total on an overlay too. Its live-key count wasbase + additions − retiredon numbers straight from the header, which a crafted blob could make wrap (or panic in a debug build); the counts are checked now, and a header whose counts do not add up is anIndexError, as is one claiming more additions than its section could hold. A base region that is itself an overlay — nothing this crate writes, since a base tag names an index — was parsed by the same routine without a bound, so a few megabytes of nested headers overflowed the stack; it is refused by name. The tombstone words an overlay's retired count comes from are read 64 KiB at a time rather than as one allocation the size of the section (125 MB over a billion ids), and the docs say what an overlay inspection reads. Aparse_inspectfuzz target holdsinspectto a return value on any bytes.
Changed¶
-
DictIndex::keyandkey_intodecode a handful of entries instead of the whole block. An entry stores what it shares with its predecessor, so an entry whose shared-prefix length is at least a later entry's contributes nothing that survives to the key being asked for; the ones that do form a strictly increasing staircase of that length, which a monotonic stack over the block's headers finds in the pass the walk already makes. Every header is still read — a header is what says where the next one begins — but the decode, the expensive half, runs on the staircase alone: 2.5 entries deep on average over the dictionary and a path list, 12 at the deepest with a block of 32 and 18 with a block of 1024, out of up to 1 023.key_intoon the dictionary: 207 → 175 ns at the default block, 697 → 463 at 128 per block, 1 361 → 851 at 256 (idandlower_boundare untouched). The blob, the format and the API are unchanged, so an existingBDX1file gets this by being read by the new version; past a staircase 32 deep the walk decodes every entry as before, which a block holding a chain likea,aa,aaareaches and which is slower, not wrong. -
The weekly sanitizer workflow fuzzes
parse_inspecttoo. The target shipped with theinspectfix but was never listed in the matrix, so the one entry point that reads a header of any of the five formats had no fuzz job of its own. -
Documentation: the
DictIndexblock size is measured across the curve rather than at three points — 4.35 bytes per key at 16 through 2.78 at 256, withid,key_intoandlower_boundbeside each — because 32 is the middle of that curve, not a limit, andbuild_with_blockhas always been the knob. At 128 the index stores 2.89 bytes per key, under the 2.98marisa-trietakes on the same words, and unlike marisa it answerskey(id)andlower_boundat all, a marisa id not being the lexicographic rank. The comparison tables carry both block sizes, and the older claim that beating marisa on an ordered index meant reimplementing marisa is scoped to the trie route it was written about. No code changed. -
Documentation: an
OVL2crosses 1.x → 2.0 only over aBIX4base, since an overlay embeds its base verbatim (README,SECURITY.md,docs/design.md); thebuild_with_fingerprintsentry below said the blob staysBMP6— it isBMP7, like every 2.0PerfectHashIndexblob; "three indexes" is five throughout; theDictIndexsymbol table is called FSST-style, with a table format of its own, so thatBDX1is not mistaken for a reader or writer of the reference format; the perfect-hash comparison scopes its claim to its harness (lexindex takes pre-hashed keys); thebuild_to_filereplay digest is called what it is, a 64-bit probabilistic check;DictIndexis 41 % belowStringIndex, not "a third of" it (3.52 against 5.95); and the two links that pointed outside the docs tree (this file todocs/usage.md,docs/design.mdtoSECURITY.md) are absolute, so they resolve both on GitHub and on the docs site.
[2.0.0] — 2026-09-10¶
Changed¶
- Breaking: the key hash is replaced, and every hash blob written before 2.0 is refused by
name. The per-word round of the 1.0 hash -- a 64-bit multiply and a rotate -- had a two-word
collision family on ordinary text: a difference in the top bits of one word stays in the top
bits of a 64-bit product, and the rotate dropped it into byte 3 of the next word, where that
word's own difference cancelled it -- in both hashes, whatever their constants. Keys of 16
bytes or more differing at bytes 8i+7 and 8i+11 alone collided with probability 13–100 %
(
d↔twithe↔o; a case flip withe↔i; an ASCII/non-ASCII flip witha↔q, always), andCompactHashIndexandClosedHashIndexmerged such pairs into one id, silently:sering.dampeningandsering.tamponingwere one key, and 10⁹ generatedword.wordkeys held nine such pairs. The round is now the full 64×64→128 product folded to 64 bits (lo ^ hi): a single-bit scan over every position pair of a 24-byte key finds no weak pair where the old round had 35, the same 10⁸ keys collide nowhere, the 10⁹ set builds to all 997 504 005 of its keys where the old hash lost nine, and the distribution gates (avalanche, low-bit χ², slot × fingerprint independence) read the same. Cost: the pair hash at 0.90–0.97× on 9–11-byte keys and 0.89× on 80-byte ones; in a lookup, process-alternated on the shuffled dictionary,CompactHashIndex::idmoves by less than the harness's own layout noise — 71.4 → 74.3 ns with one build of the harness, 70.7 → 69.6 with another the same day, the hash's own latency 0.6 ns longer — batchedids_of27.7 → 28.7 and 27.0 → 28.2 (a throughput path: 1.5 % more instructions), while the paths that compute one hash —ClosedHashIndex::id,id_unchecked— do not move. Magics move toBMP7andBCH7(BCL1never shipped and keeps its name);BMP5,BMP6andBCH6join the refused list with a message naminglexindex < 2.0and the rebuild, since a blob keyed on the old hash would answer wrong ids under the new one.BIX4andOVL2are untouched. Rebuilding from the keys is the migration. Found by the streaming builder's distinct-key count disagreeing with its input. - The weekly libFuzzer job runs
parse_closedas well, and every target now starts from the corpus the previous run left — an Actions cache per target — rather than from the seeds alone.
Added¶
DictIndex(Python:DictIndex): an ordered dictionary with the key stored for every id — exactstring ↔ rankboth ways,lower_bound, in-order iteration, and no automata. The sorted keys front-coded in blocks of 32 (a build parameter,1..=1024), the suffixes under a static symbol table — an in-crate FSST-style codec with a table format of its own, 300 lines, trained deterministically on the index's own suffixes — so a blob is a function of its keys. On the dictionary: 3.52 B/key againstStringIndex's 5.95,id300 ns against 345,key197 against 505 (key_intodecodes into a string the caller keeps);idcompares the stored suffixes against the probe without decoding them. BlobBDX1, checked on load like the others and fuzzed after loading (parse_dict, in the weekly job);inspectnames it; no feature needed, so anfst-only build has it too.bench/results/dict-2026-09-10-arz-64b0d35.txt.- Arrow columns as batch input (Python):
ids_of_arrow(column)andids_into_arrow(column, out)on every index take an Arrowutf8/large_utf8column — a pyarrowArrayorChunkedArray, a pandasArrowDtypecolumn, a polarsSeries— and read the keys straight from its offset and data buffers, so no Python string is built or borrowed per key. Packed likeids_of_bytes,MISSING_IDfor a null (ClosedHashIndexraises on one). Through the buffer protocol, not the C Data Interface: no dependency, nounsafe, one copy of the buffers per call. On the shuffled dictionary, per key:CompactHashIndex48 ns against 145 (ids_of) and 105 (ids_of_bytes),PerfectHashIndex55 against 153 / 121,ClosedHashIndex20 against 116 / 87,StringIndex264 against 468 / 435.bench/results/arrow-2026-09-10-arz-b6ae463.txt. ClosedHashIndex(Python:ClosedHashIndex): the minimal perfect hash and nothing else, for a vocabulary known to be closed.id(key) -> u32, noOption-- a member's id, and for any other string some id in[0, n), which is what a perfect hash answers and all this index promises;ids_of,ids_of_bytesandids_intoalongside, and nocontains,[]orin. The same hash asCompactHashIndexover the same keys, so the ids agree with itsid_unchecked; without the fingerprint table the blob is 0.26 bytes per key on the 480 k-word dictionary, a fifth of the smallest fingerprinted index, and a lookup is one perfect-hash probe with no compare behind it: 40 ns against 68 forCompactHashIndex::idover the shuffled dictionary, batchedids_of14 against 28 (local/closedbench, nine alternated rounds in one process, minimum). A new type rather thanfingerprint_bits = 0, because a membership check that always says yes would be a signature that lies. Blob magicBCL1;inspectnames it (BlobKind::ClosedHashIndex); aparse_closedfuzz target covers its framing. Noload_mmap: the whole blob is the perfect hash, read into memory either way.CompactHashIndex::build_to_fileandbuild_bits_to_file(Python:CompactHashIndex.build_to_file(items, path, fingerprint_bytes=1, *, fingerprint_bits=None)): the constructor for a corpus that does not fit in memory, written straight to a file. One pass hashes the keys to their 16-byte pairs, sorted in 256 MiB runs spilled beside the output and merged; the perfect hash is built from the merged file one first-level chunk at a time -- the first level's chunks are now pulled from a feed, a slice or a sequential reader, so the in-memory build runs the same placement and the file is byte for byte whatbuildandsavewrite -- and the fingerprints are written at their slots, in memory under 256 MiB of table and through range files past it. Measured at 100 M real-word pairs, one index per process: 302 MB peak against 8 834 MB for the same keys handed tobuildas a list (254 against 903 at 10 M); the streamed build took 52 s, 36 s of it generating the keys, against 10 s for the list build with its keys already made. At 10⁹ keys, where the list would need about 90 GB, the streamed build peaks at 0.94 GB — the perfect hash's own construction, 0.9 bytes per key, 0.6 of them the table being built and the keys its first level bumped — and takes ten minutes, six of them the generator. Returns the number of distinct keys; an iterable that raises aborts the build with the target untouched; nothing is left beside the output on any exit path.PerfectHashIndex::build_with_fingerprints, andbuild_to_file_with_fingerprints(Python:PerfectHashIndex(keys, fingerprints=True),build_to_file(..., fingerprints=True),has_fingerprints()): one more byte per key -- a fingerprint from the second hash, stored behind each block's offsets -- so a lookup of an absent key stops after one cache miss instead of two, the key itself never read, 255 times in 256. For a workload that is mostly misses: a stop list, a block list, a "seen before" check. Measured on the 480 k-word dictionary, one index per process, six alternations, minimum: an absent probe 166 → 74 ns, a member 163 → 171; batchedids_ofover absent keys 70 → 47; the index 10.90 → 11.90 B/key. Ids are unchanged -- it is the same perfect hash -- and anOverlaycompaction keeps the fingerprints (OverlayKeys::rebuild_like, with a default). The magic isBMP7either way: the arena tag carries the bit, so the format needs no new name for it.- Path forms of the strict loader, and checked forms of the mapping.
StringIndex::load_untrustedisfrom_untrusted_bytesover a file (Python:StringIndex.load_untrusted, andOverlay.load_untrusted(path, base)).load_mmap_verified, on all three indexes, isload_mmapplus the payload checksumloadmakes -- one pass over the mapping at load, pages still shared, nothing copied -- for a file you wrote but did not carry yourself.load_mmap_untrusted, onStringIndex, runs the full validation over the mapping, for a stranger's file too large to copy. All three mapped forms carryload_mmap's obligation, and the two checked ones say why it weighs more for them: the check trusts what it saw once, so map a copy you own.docs/usage.mdhas the loader matrix. The stubs no longer say a mapped hash index is "validated as infrom_bytes": its header is, its payload checksum is skipped by design. Overlay::compact_to_file(path)andcompact_with_remap(). The first writes the rebuilt base straight to a file, streaming the live keys from where the base holds them --StringIndexmerges its ordered iterator with the sorted additions intobuild_sorted_to_file,PerfectHashIndexreplays them intobuild_to_file-- so a base too large to hold twice can still be compacted; the second iscompactplus the old-id → new-id table (u64::MAXfor a retired id). Behind them,OverlayKeys::rebuild_to_file, with a default that materialises and rebuilds. Python:Overlay.compact_to_file(path)andcompact_with_remap(), the table as native-endianuint64bytes.bench/mphf_vs, a one-process harness that builds lexindex's perfect hash,ph0.11's PHast and PHast+, andptr_hash2.1's three parameter sets over the same keys and looks them up in the same order. Its own crate outside the workspace, so the competitors are not dependencies of lexindex; behind it,Mphfis exported -- hidden, and only under thebench-mphffeature.- Python
ids_into(keys, out)on all three index types:ids_of_byteswritten into a buffer the caller owns -- anumpyarray, anarray.array, a writablememoryview-- so a hot loop can reuse one allocation. A read-only, strided or wrongly typed buffer is aBufferError, one shorter thankeysaValueError. inspect(bytes)andinspect_file(path)(Python:inspect(path | bytes)): what a blob is, from its header alone -- the kind, the format, the key count, and the sizes a caller would otherwise have to load it to learn: the perfect hash's region, the key arena or fingerprint table, the side table, an overlay's additions and retired ids with its base inspected in turn. Over a path only the header and the footer are read, so an index of gigabytes inspects in microseconds; nothing is decoded or verified, and a blob from before 1.0 is an error naming the type to rebuild.BlobInfo,BlobKindandOverlayInfoare#[non_exhaustive], so a field can be added later without a major.StringIndex::build_to_file(items, path)(Python:StringIndex.build_to_file):buildfor a corpus that does not fit in memory. The keys come in any order; every 256 MiB of them is sorted and deduplicated in memory and spilled as one run beside the output, and the runs are merged -- one buffered reader each -- intobuild_sorted_to_file, so the blob is byte for byte whatbuildthensavewrites. Peak memory is one run plus a 1 MiB buffer per run, whatever the key count; the transient disk is the distinct keys once, removed on every exit path. A run is an arena of key bytes and a span per key, not aStringeach, so the budget is what it says. Measured at 100 M real-word pairs: 414 MB peak against 11 985 MB forbuildover the same generator, 150 s against 211, blobs identical.
Removed¶
IndexError::Serde. Nothing had constructed it since 1.0 replaced theepserdeloader; a malformed perfect-hash blob isIndexError::Format. Removed outright rather than deprecated first: the key hash above already makes this release a major one.
Fixed¶
-
StringIndex::from_untrusted_bytescosts the graph, not the language, and refuses a key that is not UTF-8. The 1.1.0 validator streamed every key to check its rank, so a blob packing an astronomical language into a few hundred bytes --fstdocuments a billion strings in 896 -- was a denial of service against the one loader meant for a stranger's bytes; and it never looked at the bytes it streamed, so a transducer over non-UTF-8 keys, whichfstpermits and this crate's builder never writes, loaded and then answeredNonefromkey(id)for a live id. The validator now checks the transducer as a graph, in time proportional to its nodes and transitions: every reachable node once, transitions pointing strictly below their node and in increasing byte order, every accepted path valid UTF-8 -- the set of decoder states each node is reachable in is propagated parents-first, so a node shared between a character boundary and the inside of a character is caught -- and outputs that are ranks by construction: a final node carries none, each transition carries the count of keys its node spells before it, and the root's count must be the footer's length. On the 479 823-word dictionary it costs 22.9 ms against 0.7 ms for the owned load, down from 48.1 ms; on a 65 536-key transducer of a few dozen nodes it costs microseconds. Theparse_stringfuzz target now also asserts thatkey(rank)decodes back to the scanned key, and a property test builds rawfstmaps over arbitrary bytes and requires the loader to accept exactly the UTF-8 ones. -
PerfectHashIndex::build_to_filecould publish a file whose keys were not the source's. Pass two checked each replayed key by its slot hash and length only, so a source that replayed two keys sharing a 64-bit hash at equal length --hash::COLLIDING_PAIRis such a pair -- in the other order, or one in place of the other, was accepted, and the file answered each with the other's id. Both passes now fold a second, independent 64-bit hash of every key into an ordered digest that is compared before the rename; a replay that differs in any key or any position is refused withIndexError::Buildand the target is left untouched.
Changed¶
- The streamed build's merge workers share one handle per run: each reads its range of every
run at its own offset (
pread;seek_readon Windows), so the workers are as many as the ranges however many runs there are, where a budget of open readers had held them to three at 10^9 keys (60 runs). Byte-identical (blob digests at 10 M and 10^8 keys), same peak. 10^9 real-word pairs: merge 25–27 → 20 s (I/O-bound from there: 16 GB read and 16 GB written), the whole build 457 → 438 s; at 3·10^8 (18 runs, eight workers before as after) 3.5 s either way.bench/results/peak-compact-stream-pread-2026-09-10-arz-5678a0f.txt. - Both hash builds compute their slots on every thread, and a fingerprint of a shipped width is
one store: once the perfect hash is built, each thread overwrites its share of the sorted
pairs' hashes with their slots — the order is the hash's, so a chunk's first pair continues
the one before it exactly when the hashes are equal — and one pass then writes the fingerprints
with the row a later key needs pulled into cache.
write_fpat 8, 16 and 32 bits is a single store instead of amemcpycall whose length is only known at run time; that call was 2.4 ns of every key placed, in the streamed build's output and fingerprint pass as much as here. Byte-identical (blob digests at 10 M and 10^8 keys), same peak. 10 M real-word keys in memory: placement 102 → 16 + 41 ms, the whole build 580 → 526–530 ms.bench/results/peak-compact-listed-place-2026-09-10-arz-8cb6f73.txt. - The streamed build places its fingerprints on every thread when they go to range files:
the merge cuts as many ranges as the machine has threads, and the fingerprint pass then takes
one merged segment per thread, staging
its range records in a share of one allocation and writing them under each file's lock a
stage at a time; a range record is the slot and the fingerprint at its own width, 5 bytes at
the default instead of 12. The table that fits memory is still filled by one thread — below a
byte, a row shares its byte with its neighbours. Byte-identical (blob digests at 10 M and
10^8 keys). At 10^9 real-word pairs the fingerprint pass takes 12 s against 46, the output
13.5 against 15, the whole build 457 s against 504 (six minutes of either being the
example's key generator) at the same 943 MB peak; at 3·10^8 the pass takes 2.1 s against 10.3.
bench/results/peak-compact-stream-fp-2026-09-10-arz-3ddad05.txt. - The streamed build merges its runs on every thread: each run counts its pairs per
top-bits bin as it is spilled; the merge cuts the hash space into ranges holding equal shares
of the pairs, as many as threads, and merges each range out of every run into its own
segment. The
segments are read back as one stream decoded a buffer at a time rather than a record at a
time, which is where the previous reader lost 5 ns a key behind the perfect hash's
dyniterator; and every buffer of the merge lives in one allocation released whole, so the build's high-water mark stays where it was. Byte-identical (blob digests at 10 M and 10^8 keys). At 10^9 real-word pairs: merge 59 → 25 s, fingerprint pass 49 → 46 s, the whole build 548 → 504 s with six minutes of either being the example's key generator, peak 942 → 943 MB; at 10^8, alternated twice: merge 4.2 → 1.3 s, total 46.4/47.4 → 43.7/43.6 s.bench/results/peak-compact-stream-merge-2026-09-10-arz-b687f89.txt. - Both hash builds sort their pairs on every thread, and the streamed build merges runs and places
fingerprints with less work per pair: a run — or the in-memory pair list — is partitioned in
place by the top of the hash into one part per thread and each part is sorted on its own
thread (the parts abut, so there is no merge and no second buffer); the run merge replaces the
heap's top in place instead of popping and pushing, takes whole records straight out of its
read buffer and writes a pair in one call, and a run is spilled a 64 KiB slab at a time rather
than a pair per call (212 → 193 ms per 256 MiB run; the rest is the disk); the fingerprint
pass sends its representatives
through
index_alla thousand at a time with each fingerprint row prefetched ahead. Byte-identical (blob digests at 10 M and 10^8 keys before and after). At 10^8 real-word pairs the streamed build's own share of a 51 s run — the rest is the example's key generator — falls from about 16 s to 12 s (run sort 0.63 → 0.31 s per 16.7 M pairs, merge 5.6 → 4.2 s, perfect hash 1.55 → 1.27 s, fingerprint pass 2.85 → 2.2 s); the in-memoryCompactHashIndex::buildat 10 M takes 575–591 ms against 762–783 (alternated, two rounds), its memory unchanged.bench/results/peak-compact-stream-time-2026-09-10-arz-0e177fd.txt. - The perfect hash's construction holds a level's pieces only while the gaps beside them are
placed: a chunk's seeds go into the level table as it is placed, its occupancy map into the
level's once both neighbouring gaps have read it, and the keys it bumped are listed in chunk
order as chunks finish, instead of every piece staying alive until a merge that copied all
three again; the next level groups by level hash alone and keeps its input by
retain, the holes are yielded into their encoder rather than listed, and a table on its way to a file is written section by section instead of through ato_bytescopy. Byte-identical (golden fixtures, and blob digests at 10 M and 10^8 keys before and after). The streamedCompactHashIndexbuild at 10^9 real-word pairs peaks at 942 MB, down from 1 717 (0.9 bytes per key: 0.6 after the first level, 0.87 at the second level's grouping), in 587 s against 604; the in-memory build's time is unchanged.bench/results/peak-compact-mem-2026-09-10-arz-71363e5.txt. -
CompactHashIndex::buildandClosedHashIndex::buildfeed the perfect hash straight from the sorted pairs, a chunk at a time as the file build does, rather than extracting the representatives' hashes and staged fingerprints first. Same blob. At 10 M real-word pairs the build adds 21.5 bytes per key above the key list, down from 25.0, and takes 774 ms against 843 (alternated, three rounds); at 2 M the chunk buffers the feed keeps, one per thread, are still visible and the peak is a wash (25.7 / 25.4 / 29.9 bytes per key at 8 / 16 / 32 fingerprint bits against 23.9 / 25.9 / 28.0).bench/results/peak-compact-inmem-2026-09-10-arz-e3e910a.txt. -
An arena past 4 GiB keeps its blocked offsets. The
u32block base that could not reach the data past 4 GiB widens tou64(arena tags0x31/0x32, bit0x20) instead of the layout falling back to the flatu64table: 1.56 bytes per key of offsets instead of 8 -- 0.78 GB instead of 4 for 500 M ten-byte keys. The flat table remains only for a 256-key run past 64 KiB. Below 4 GiB nothing changes, byte for byte, beyond the magic the key hash above moves; a reader from before 2.0 refuses the wide-base tags as an unknown encoding, the same way it refuses the fingerprinted ones. The threshold is injectable in the tests, so both layouts are exercised on a 40-byte arena.PerfectHashIndex::buildnow refuses a key longer thanu32::MAXbytes with the errorbuild_to_filealready gave, instead of laying out a truncated length. - One long key no longer widens the whole arena. A block whose 16 keys outgrew its one-byte
offsets used to move every block to two-byte offsets (0.7 bytes per key more), and a key past
64 KiB the whole arena to the flat table (2.7 more). Now such a block keeps its place and
stride, marks its first offset and puts its real offsets in an overflow table behind the data
(arena tag bit
0x40): 76 bytes for one 10 kB key among a million short ones. An arena no block of which overflows is unchanged byte for byte; a corpus of long keys still takes the wider blocks, and the flat table remains for a handful of keys none of which any block holds -- once the one-byte blocks do not fit, the layout is the cheapest by the lengths. The file build writes the table behind the data too, and a reader from before 2.0 refuses the tag. Alongside, an absent probe under fingerprints is decided on the fingerprint byte before the slot's offsets are read, which took it from 91 to 75 ns: that path is about as long as the reorder buffer is wide, and its one cache miss overlaps the next probe's only while it stays that short. - The perfect hash looks up 20 % faster.
MPH2has had one seed family since 1.1.0, but every lookup still decoded the seed byte into a family and a shift and took the key's offset from a family-dependent run of its hash bits -- a variable shift on the hot path. The value is nowstart + ((offset + stride × seed) mod slice)and nothing else; the family machinery is gone from the builder too. Blobs are unchanged and the golden 1.1.0 fixtures are byte-identical: the same seeds, 2.089 bits/key. In the sweep harness, 10 M bigram hashes, A-B-A-B against 1.1.1's tree: lookup 34.4 → 27.5 ns, build 50.6 → 46.1 ns/key single-threaded; inbench/mphf_vsagainst PHast and PtrHash, lookup 33 → 28.8 ns and build 47.7 → 44.8 ns/key (8.1 → 7.6 on 8 threads). Overlay::savestreams. The base goes to the file through the newOverlayBase::write_base-- a default throughbase_to_bytes, overridden by the three indexes to write from the bytes they already hold -- then the additions and the tombstones, and the header last over its place once the lengths and the payload hash are known. Saving an overlay over a mapped base of a gigabyte no longer makes two copies of it.to_bytesassembles the same sections into oneVec, sized by the newOverlayBase::base_serialized_len; both produce the bytes 1.1 wrote.- The sizes the perfect hash derives from its load factors -- buckets per level, the tail's buckets
and range, the held share at a run's start -- are integer arithmetic over ratios (
9/2,13/2,24/25, a fixed-point0.966) rather thanf64. The output is byte-identical, and the golden blobs say so; what changes is that the determinism promise no longer rests on every target rounding a division the same way, which IEEE 754 guarantees but no test could show. bench/compare.pyrecords the competitors' installed versions (marisa-trie,dawg2,datrie) in its results file, next to lexindex's own.
Documentation¶
docs/benchmarks.mdgains the perfect hash's head-to-head with PtrHash and PHast, re-measured after the lookup change: at 2.09 bits it builds 1.8× faster than the PHast+ it follows and 4.2× faster than PtrHash's compact set, with the fastest lookup of the three; regular PHast is smaller (1.92 bits) at 14× the build.SECURITY.mdlists 1.1.x as the supported line and no longer says anOverlayanswers arbitrary bytes with anErr: its own framing does, but an overlay over aStringIndexhands the base region to the base's loader and inherits that loader's exception unless loaded throughfrom_untrusted_bytes.- Contradictions removed:
StringIndex::from_bytespointed at no check for a stranger's blob whilefrom_untrusted_bytesis that check;build_to_filesaid its file need not matchbuild+savebyte for byte while a test holds it to exactly that, short of a hash collision;ids_of_bytessaid its buffer is native-endian "like the blobs", which are little-endian everywhere;blob::hash_bytessaid it is the perfect hash's slot hash, which ishash::hash_key; the crate doc said the perfect hash implements PTHash's construction, which 1.1.0 replaced with PHast's; the README's determinism note now says "within one lexindex version", asdocs/design.mdalready did; the 1.1.0 entry below saidfrom_untrusted_byteshad no Python binding, and it shipped one.
[1.1.0] — 2026-09-09¶
Fixed¶
-
SECURITY.mdsaid every loader answers arbitrary bytes with anErr;StringIndexcan panic instead. The claim was true of the perfect-hash and overlay loaders and had never been true of the ordered one:fst's node decoder is safe Rust but not total, and the checksum in front of it is public, so a blob crafted to carry a matching one reaches that decoder with an invalid body. A libFuzzer target measured it — 44 bytes in ten minutes, panicking inside the rank spot-check the load itself runs — anddocs/design.mdand thefrom_bytesdocstring had said so since 0.12.1 while the security policy still promised otherwise. The policy now states the exception, and names the four parsers a fuzz target actually covers rather than implying all five. -
An
Overlaystored every added key twice.addowned each string once in aVec<String>, sokey(id)could index, and again as the key of aHashMap<String, u64>, soid(key)could hash. The additions now live once, as the length-prefixed records the blob format already uses, indexed by a map from the key's hash to the record's position — the layout the perfect-hash indexes have always had, down to the side list for the 64-bit collision that essentially never happens, pinned by a test over a real colliding pair since a branch guarding a 2.7e-8 event is otherwise never executed. Measured over a million ten-byte additions on a 100 000-word base with shuffled probes: the resident cost of holding an addition falls from 147 to 54 bytes,addruns about three times faster, andidon an addition is 278 ns against 295 before. Base keys are untouched, since the base answers first. The length sits inside the record rather than in a side table on purpose: a lookup is then the map and the bytes, two dependent accesses, and the version with a side table measured 40 % slower on shuffled probes. -
Overlay::keycould answer someone else's key on a 32-bit target. An id aboveusize::MAXwas narrowed withasbefore the bounds check, so it wrapped onto a real addition instead of returningNone. Reachable only whereusizeis 32 bits and only for an id noaddever issued, which is why no test had caught it; the conversion is nowtry_from. -
PerfectHashIndexwas documented as the "fastest exactstring → dense id" in seven places. Its own benchmark says otherwise:idcosts about what astd::HashMaplookup does, and the claim belongs toid_unchecked, which skips the membership comparison for a closed vocabulary.
Changed¶
-
The perfect hash is rebuilt as PHast's map-or-bump, and its blob format moves to
MPH2. 1.0's table was PtrHash-shaped — buckets placed largest-first, with the buckets in the way evicted and re-placed — and three quarters of its build was that eviction pass reading a random slot-owner table. The new builder never displaces anything: each bucket's one-byte seed slides its keys two values at a time along a 1024-value slice of the table, the seed whose values are lowest is taken, since low values are what the buckets still to come cannot use, and a bucket no seed places is bumped to a smaller table under a fresh hash, down to a tail of a few hundred keys placed exhaustively. Bumped keys reach the first table's holes through a rank bit vector over the lower tables' values and an Elias–Fano list of the holes, which is what keeps a bumped key at about eight bits instead of the sixteen a plain offset table costs. Buckets are placed in fixed chunks with the gaps between them placed serially afterwards, so the table is the same whatever the thread count, and within a chunk the buckets follow PHast's size-weighted priority scaled with the slice, which is what lets a small table place as well as a large one. Measured on 10 M real word-bigram hashes: 49 ns/key on one thread against 280 (85 ms on eight threads against 625), and 2.09 bits/key against 2.39 —CompactHashIndexat 1.26 B/key on the dictionary against 1.30; in-order lookups cost 4.2 ns/key. Small tables gain the most, and nobody had measured them before: 2.78 bits/key at 1 000 keys against 3.19, 2.19 at 10 000 against 2.37. EveryBMP6andBCH6blob written from here on carries anMPH2table, which 1.0 cannot read; theMPH1tables 1.0 wrote still load. The 1.0 hash fixtures are now held to what a reader must promise them, and 1.1 fixtures take over the byte-identity pin. -
Both latency tables are re-measured on 1.1, and the Python one now says what it can support. The Rust table resolves the arena change cleanly:
PerfectHashIndex::idwent 1.103× → 1.010× ofstd::HashMap(290 → 274 ns on a control that got 3.4 % slower) while four rows whose code did not move shifted by at most 2 % —StringIndex,BTreeMap, the FxHash map, andid_unchecked, which is flat at 0.285× → 0.288× precisely because it never reads the arena. Two independent 12-run sessions agree on every lookup minimum within 3.1 %.
The Python table cannot support a version claim at all, which took an A-B-B-A against the released
1.0.0 wheel to establish: in one session every cell landed within 1.01–1.04× on random and
0.98–1.09× on words, and the widest gaps belong to dict and marisa-trie — code identical in
both runs. That retires two readings the cross-session numbers had seemed to support, that
PerfectHashIndex.id gained 9 % and that CompactHashIndex.ids_of lost 37 % on absent pair-corpus
probes; today's 1.0.0 wheel measures the latter cell at 62.0 ns against the 44.8 the 1.0 table
published. The Rust-level win is real and simply does not survive ~180 ns of per-call binding
overhead. local/latency_py.py's publish gate moved from the absolute minima to the ratios for
the same reason it exists: it had refused three tables whose ratios agreed to 1–8 %, with the
dict column — the drift the ratio divides out — the worst offender in every one.
-
The scale table is re-measured on 1.1, as the minimum of five runs per cell rather than the single run behind the 1.0 table, and the file it came from is committed. It is comparable cell by cell with the table it replaces, which the 1.0 one could not claim:
StringIndex, whose build and lookup code has not changed since 0.5.1, holds within 2 % on bothlistrows and is therefore a usable control. Two cells moved on code 1.1 never touched —CompactHashIndexat 1 M reads 0.26 → 0.22 s and 223 → 162 ns, which is the old single sample's noise leaving — and one moved the wrong way and is published rather than smoothed: 10 MStringIndexfrom a generator went 6.5 → 7.7 s with five tight samples and an additive diff on that path.datrie's size in the comparison table is 30.91 B/key, not the 30.69 measured for 1.0. -
PerfectHashIndexaddresses its keys in blocks: 13.62 → 10.94 bytes per key, and writesBMP6. The arena'sn + 1four-byte offsets are gone; sixteen slots now share au32base and carry one-byte cumulative offsets after it, 21 bytes per block, so a key isdata[base + off[k] .. base + off[k + 1]]— two adjacent reads out of one header, 1.31 bytes per key instead of 4. A corpus whose 16-key runs do not fit in 255 bytes gets 256-slot blocks with two-byte offsets (2.02 B/key; the 1 M random-bigram corpus is one, 23.95 → 21.97), and one that fits neither, or exceeds 4 GiB, keeps the flat table — which is also what removes the old 4 GiB cliff, since au32base cannot reach past it. The encoding is chosen in the same pass that writes the data, from the lengths it saw, and recorded in the arena's tag byte.
Reading did not get slower; it got faster, because the whole index is 1.3 MB smaller on the
479 823-word dictionary and stays resident. Alternated A-B-A-B against the flat arena with
CompactHashIndex::id_unchecked — same key hash, no arena — as the control: id 216 → 182 ns
and key 52 → 38, with the control moving under 5 %. Batched ids_of was the one path that
had to be paid for and the one that had to be fixed: a block header is 21 bytes, so its base and
the offset pair regularly land in different cache lines, and prefetching only the base cost 9 %.
Prefetching both puts it level. Build peak did not rise either — 55.1 MB against 56.3 — although
the build now holds one transient u32 per key, since a block's offsets are only final once the
block is.
Six layouts were measured before this one was written (local/arenalayout), and the obvious
candidate lost: a length prefix in the data with a periodic sample costs +17 to +37 ns, because
its "sequential" length reads are hops through the data region rather than through an array.
Cumulative offsets are what removes the summing loop altogether.
-
BMP5blobs still load, and are held to it by a test:golden-1.0.0-perfect.bmpnow has to answer every key with the id a fresh build assigns, refuse every non-member and map zero-copy, whilegolden-1.1.0-perfect.bmptakes over the byte-identity assertion. The new magic exists so that a 1.0 reader refuses a 1.1 blob by name instead of failing inside the arena with "unknown offset encoding". -
build_to_filenow writes bytes identical tobuild+save, and is tested that way rather than by behaviour: both derive the arena's encoding from the same key lengths in the same slot order, so the only way the two writers can disagree is a bug in one of them. -
The PyPI classifier is
Development Status :: 5 - Production/Stable; 1.0 shipped as4 - Beta.
Added¶
-
Benchmark results are files, not terminal output pasted into a table.
bench/compare.pyandbench/scale.pywritebench/results/<table>-<date>-<host>-<commit>.jsoncarrying the CPU model, kernel, rustc, Python, lexindex version, the load average at both ends of the run, and the commit — suffixed-dirtywhen the tree was not clean, because a result measured on uncommitted code is not attributable to the commit it names. Per cell the file keeps the minimum of the repeats with the median and every raw sample beside it, so a run taken on a busy machine is visible in the file instead of averaged into it. The README tables cite the file that filled them. -
from_untrusted_bytesin Python, onStringIndexand onOverlay. The gap it closes is sharper there than in Rust:pyo3_runtime.PanicExceptionderives fromBaseException, so the panic a craftedBIX4blob raises slips straight pastexcept ValueError. Both bindings are tested against the committed specimens — the 111-byte one for the index, and the same bytes sealed into a realOVL2frame for the overlay, which the Rust side writes and pins because both checksums are the crate's and a blob spliced together from Python would be refused by a hash long before its base region was read. The overlay test asserts that the ordinary loader panics on that fixture, so a binding wired to the wrong loader fails rather than passing quietly.docs/usage.mdno longer says the binding does not exist. -
An overlay is only as trustworthy as the base loader it is handed, and the loader's docstring now says so instead of claiming that "every base loader since 1.0" is safe on arbitrary bytes. It is not:
StringIndex::from_bytesmay panic on a crafted transducer, and anOVL2frame passes every check it makes for itself before handing the base region over — so the 111-byte specimen sealed into an overlay panics through it, which a test now pins alongside theErrthatfrom_untrusted_bytesreturns for the same bytes. No new entry point:from_bytes_withalready takes the loader as a closure, so the fix was to name the right one rather than to add a second door. -
A sixth fuzz target,
parse_overlay_untrusted, parsing the embedded base as well as the frame — the seam neither existing target reaches, since the overlay derives the base region's bounds from its own header.OVL1is what makes it fuzzable: no checksum for a mutation to break. 8.5 M executions in three minutes locally, 674 edges, no crashes. Every fuzz job now runs its seed corpus with-runs=0before the timed run, because a target that dies on its own corpus reads exactly like a real finding —parse_stringshipped that way once — andfuzz/Cargo.tomlcarries the rule that a target surviving a contained panic must replace libfuzzer-sys's aborting hook. -
The byte formats are proved on a big-endian target, in the weekly sanitizer workflow: Miri interprets
s390x-unknown-linux-gnu, so no runner is needed, andblob::,arena::and a newMPH1fixture test run there in twelve seconds. Every scalar in every blob is little-endian by construction, butto_ne_bytescompiles, passes on x86 and would silently write a different file elsewhere — flipping the arena's count field to it passescargo testand fails eight tests under the interpreter, which is how the job was checked rather than assumed.mphf::as a whole is out of reach there (its blob tests build a 263 000-key table, hours under interpretation), so the new test reads the committed fixture and writes it back byte for byte instead. -
Dict-style lookup in Python:
idx["apple"]raisesKeyErroron a miss,idx.get("apple")returnsNoneor whatever default is passed, on all four classes. Nothing further — no__setitem__, nokeys/values/items, noMappingregistration — because these indexes are immutablestr -> intlookups and registering as mappings would promise iteration semantics three of the four do not have. One consequence is pinned by a test rather than left to be discovered: defining__getitem__revives the legacy sequence protocol, solist(index)on the two hash indexes raisesTypeErrorfrom the key type instead of iterating. -
Every Python class pickles.
__reduce__onStringIndex,PerfectHashIndex,CompactHashIndexandOverlaynames the class's ownfrom_bytesand hands it the blob — and, for an overlay, the class of the base underneath, sinceOVL2records which base wrote it and its loader takes that class. An index therefore travels to amultiprocessingworker underspawn, which shares nothing, and the tests check exactly that rather than a same-process round trip. Pickling copies the bytes, a memory-mapped index included: a path would not survive a worker that cannot see the same filesystem, and a borrowed mapping would not survive the file changing. When both ends can see the file,saveplusload_mmapstill shares the pages instead of copying them, anddocs/usage.mdsays so. -
StringIndex::from_untrusted_bytes, the loader for a blob someone else wrote. It walks the whole transducer instead of spot-checking it -- every reachable node decoded once, every transition required to point strictly below the node holding it, which is howfstlays nodes out and what makes the walk terminate on bytes that were not -- then streams every key and requires its value to be its rank, so a blob whose values are a permutation of the ranks is refused here and accepted byfrom_bytes. Decoding a malformed node still panics insidefst, because that decoder is the only one there is; the panic is caught at the load boundary and returned asIndexError::Format. Two caveats it documents: underpanic = "abort"a crafted blob aborts rather than returning, and the rejection prints through the process-wide panic hook.tests/data/panicking-1.0.0-string.bixis a real 111-byte specimen, found by libFuzzer againstfrom_bytesand kept so the security policy's exception stays a measured fact. The validation costs 50.8 ms against 1.2 ms for the owned load on the 479 823-word/usr/share/dict/words, or 106 ns per key. In Python it isStringIndex.from_untrusted_bytes, andOverlay.from_untrusted_bytes(data, StringIndex)for an overlay wrapping one. -
Order statistics on
StringIndex:lower_bound,range_count,prefix_countandprefix_id_range, in Rust and Python. Ids are lexicographic ranks, so keys sharing a prefix occupy a contiguous id range --prefix_id_rangehands back that interval, which turns a prefix into an array slice or a bitset window instead of a set of ids to test one at a time. None of them decodes a key, so counting three million matches costs what counting three does: 420 ns forprefix_id_rangeon a three-byte prefix against 238 ns foridon a whole word, and 479 ns forlower_bound, on the 479 823-word/usr/share/dict/words. A property test checks all four against a sortedVecsearched by hand, on an alphabet with two-, three- and four-byte characters, since the ids follow byte order and a prefix bound is built by incrementing one. -
A
parse_stringfuzz target, replacing the one removed before 1.0 for re-finding a panic the crate could not then fix. It loads throughfrom_untrusted_bytesand asserts the loaded index agrees with itself -- every key's id equals its rank, every point lookup agrees with the scan -- so the target now fails on a wrong answer, not only on a crash. It is in the weekly matrix. -
Feature badges on docs.rs: the "Available on crate feature
mph/mmaponly" markers that say which parts of the API a--no-default-featuresbuild does not have.
[1.0.0] — 2026-09-08¶
Changed — breaking¶
-
The minimal perfect hash is now this crate's own, and every pre-1.0 hash-index blob is refused.
PerfectHashIndexandCompactHashIndexwere built onptr_hash, whose pilot table is read unchecked and whose bounding fields are private, so a blob holding one could not be validated from outside the crate that owned it. The new backend writes every array length into its own header (MPH1) and derives them again on load, which is what a loader needs to bound every read it makes.BMP2/BMP3/BMP4andBCH1–BCH5therefore cannot be read at all: each embeds an image the crate no longer links. They are refused with a message naming the version that wrote them; the migration is to rebuild from the key list. -
from_bytesandloadare safe fns on both hash indexes — in Rust and in Python, where there was never anunsafemarker to warn anyone. A crafted blob is now a wrong answer rather than undefined behaviour.load_mmapstaysunsafe, for the one obligation that is actually about mapping: do not modify the file under the map. -
Ids are reproducible. Construction is deterministic and independent of thread count, so the same key set produces the same blob byte for byte.
PerfectHashIndex::build's docstring previously promised the opposite, andCompactHashIndex::build_bits' still did — both say what is true now, and a property test over arbitrary key sets holds all three indexes to it. -
The overlay is checksummed: new format
OVL2.OVL1was the one blob in this library with no integrity check of its own — the base carried one for its own region and the additions and tombstones carried none — so a flipped bit in an addition that stayed valid UTF-8 loaded as a different key, and one in a tombstone word revived a removed id, silently.OVL2puts every section length in the header, adds a header check and a hash over everything after it, and verifies both before reading any of it.OVL1blobs still load, without the checks that format does not carry; saving one again writesOVL2and it gains them. It is the only pre-1.0 format this version still reads — nothing about it was undecodable, so refusing it would have cost a rebuild for nothing. -
Both key hashes read eight bytes at a time. The byte-at-a-time FNV-1a chain that shipped through 0.12 cost one multiply per byte; the replacement costs one multiply-rotate per 8-byte word plus a splitmix64 finalizer with the key's length folded in. A-B-A-B in one process against the old implementation, with a control that touches the same keys: 1.5× on a 9.3-byte dictionary word, 1.7× on a 10.9-byte bigram, 6.2–6.6× on an 80-byte URI-like key. Every hash value therefore changed, which is the second reason no pre-1.0 blob can be read. Held to the same statistics as the hash it replaces before shipping: strict-avalanche worst |z| 4.50 against 4.75, low-bit chi-square over 480 k real words and 1 M bigrams within ±1, slot-against-fingerprint independence within ±1, and the false-positive rate re-measured at 2 M probes — 7 732 accepted against 7 812 expected at 8 fingerprint bits, z = −0.91.
-
New blob formats:
BMP5andBCH6.BCH6drops theoverflow_capfield — it existed to boundptr_hash's unchecked remap, and the new one covers its whole slot range — so its header is 40 bytes rather than 48.
Added¶
-
cargo semver-checksas a CI job, and a versioning policy that says what it cannot see. It passed clean across this release: nothing in the public API shrank, and removingunsafefrom a signature widens rather than breaks it. What makes 1.0 major is the on-disk format, which no API tool can inspect — so the policy indocs/design.mdstates that a blob format is part of the contract, and the CHANGELOG section above is the half of compatibility a human writes. -
The 1.0 blobs are pinned by their bytes, not by invariants.
golden-1.0.0-compact.bchandgolden-1.0.0-perfect.bmpare now asserted byte-identical to a fresh build from the same key list. No earlier release could do this: pre-1.0 ids were not reproducible across builds, so every golden test could only check the properties a correct load must satisfy. These two files are also what seeds theparse_compactandparse_perfectfuzz targets, and a seed that still parses but no longer resembles what the writer emits is the exact failure this release already hit once. -
SECURITY.md, as a threat model rather than a form letter. What the loaders guarantee (soundness, not correctness — a crafted blob answers wrong ids, never out-of-range ones), whyload_mmapis the oneunsafe fnand what its obligation is about, why the checksums are integrity and not authentication, and that the unseeded hashes make this no defence against an adversary who picks the keys. The README anddocs/design.mdlink it rather than restate it. -
A blob compatibility policy (
docs/design.md): the magic table, and the rule that a refusal must name the version that wrote the file.OVL1is read whereBMP*/BCH*are refused because it is decodable and they are not — compatibility is broken where it cannot be kept, not where keeping it is inconvenient. -
The whole library builds on 32-bit targets,
wasm32included —mphtoo. Two things had kept the hash indexes off them:ptr_hashpulled insucds, which refuses any other pointer width, and the MPH's ownu64 → usizenarrowings had never been audited. The dependency left with the backend in this release, and the audit found one narrowing that mattered — the remap's entry count inMphf::from_bytes, where a fabricatedslotswould have truncated into a plausible length instead of failing. Everything else on a load path was alreadytry_from. Thecompile_error!that explained the restriction is gone, and CI cross-checksi686andwasm32. Leavemmapoff onwasm32: there is nothing there to memory-map. -
A libFuzzer target over the MPH's own blob (
parse_mphf), and a seed for it. The two index targets reach that format only behind their own header, where a mutation has to keep two checksums and a length identity intact first — so in practice they fuzz the framing and never the body. The new target starts inside it, and asserts more than "does not crash": every id it gets back must land in[0, n), which is the property that makesfrom_bytesa safe fn.
Fixed¶
- The fuzz seed corpus had gone stale, and nothing said so. 1.0's blob formats made every
committed seed a blob refused on its magic, so all three fuzz targets would have explored one
branch and reported a clean run. The test that exists to catch exactly this is behind the
fuzzingfeature, which no CI job built — it does now, along with a clippy pass over the same feature, and the seeds are current.
Removed¶
ptr_hashandepserde, and 129 crates with them. Themphfeature now has no dependency at all; the crate's whole tree isfstplusmemmap2.cargo auditgoes from three allowed warnings to none — every one came from that tree.
Changed¶
-
The blob-integrity primitives — the header hash and the streaming payload hash — moved from the
mph-gatedhashmodule toblob, which every format shares.StringIndex-only builds could not reach them before, which is why the overlay had none. -
Loading a
PerfectHashIndexno longer hashes every key in the arena. That pass existed to recomputeoverflow_capon each load and was O(n) on the blob's size. -
CompactHashIndexcosts 1.30 B/key at the 8-bit default and 0.80 at 4 bits, against 1.27 and 0.77 on the old backend: the in-crate MPH is 2.390 bits/key whereptr_hashwas 2.169. Measured on/usr/share/dict/words(479 823 keys). -
Every timed table in the README was re-measured on 1.0, in one idle session after the last code change. Three findings, and one non-finding stated as such:
-
PerfectHashIndex::id_uncheckedwent from 0.451× to 0.287× ofstd::HashMap— 111 → 75 ns at 1 M bigrams, on a control that got 6.7% slower. Against aHashMapwith FxHash it is now ~2× faster (75 against 156 ns), where through 0.12 this README said the latency advantage against a fast-hashed map was gone. That claim is retracted; the perfect hash and the 8-byte key hash are what changed, not the conditions. - Builds cost more.
CompactHashIndexat 1 M went 114 → 175 ms andPerfectHashIndex291 → 378 ms: the in-crate backend builds 1.5–1.8× slower thanptr_hashdid, which was accepted as the price of a header a loader can check. At 10 M the picture reverses in the scale table, because that build is eight-threaded and the older session was core-contended. - From Python,
PerfectHashIndexmoved 1.04× wheremarisa-trie,StringIndexandCompactHashIndexall moved 1.18–1.20× on the same corpus — a ~14% gain relative to everything else in the room. - The
dictdenominator moved again and the Python table is therefore not comparable cell by cell with the one it replaces: 327.9 ns then, 258.5 now, on identical keys and untouched code. Its absolute values are now printed in the table so the next session can see the denominator rather than guess at it. The mechanism remains unidentified after having been chased once already.
[0.12.1] — 2026-09-07¶
Fixed¶
- A crafted overlay blob could panic a safe loader.
Overlay::from_bytes_withread the tombstone word count out of the blob and computedcount * 8on it unchecked. A 40-byte blob claiming1 << 61words panicked with an arithmetic overflow in a debug build; in release the product wrapped to zero, the length identitybytes.len() - at == count * 8then passed, and the parser went on to collect2^61elements — a capacity-overflow panic where a cleanIndexError::Formatwas owed. Reproduced in both profiles before the fix, and both regression tests fail against the old parser.
Every count the blob carries is now narrowed with usize::try_from and combined with
checked_mul / checked_add, and the tombstones are read from the byte slice itself
(chunks_exact(8)) after its length has been checked — so a number the blob merely claims can no
longer size an allocation. The same as usize casts truncated base_len and the addition count
on a 32-bit target, where Overlay<StringIndex> is available; StringArena already used
try_from here and the overlay now matches it.
-
Overlay::savewas not atomic. It wasstd::fs::write, so a crash, a full disk or a kill mid-write left a truncated blob under the real name. It now goes through the same writer the three indexes use — temporary +O_EXCL+ fsync + rename + directory fsync — which matters most here: the overlay is the crate's mutable layer, and so the file most often rewritten over a live one. -
A crafted blob could make
len()disagree with the keys. Additions were checked against each other but never against the base, so a hand-made blob could add a key the base already held:len()counted it twice whileid()could only ever answer the base's, leaving the addition's id unreachable.addconsults the base first and revives its id rather than issuing a second one, so no blob this crate writes looks like that. The parser now rejects it over a base whose membership is exact — a newOverlayBase::EXACT_MEMBERSHIP,trueforStringIndexandPerfectHashIndex.CompactHashIndexkeeps the old behaviour, since its false positives would otherwise reject sound blobs, and the constant defaults tofalseso an outside implementation of the trait still compiles. -
PerfectHashIndex::build_to_fileverified pass two by hash alone. The docs promised "a second pass that yields different keys is refused", which a 64-bit hash cannot deliver on its own: an equal-hash key of a different length reachedcopy_from_slicewith mismatched lengths — a panic — on the direct path, and wrote a short record that desynchronised the spill's framing on the windowed one. Both paths now share onereplay_span, which checks the slot's length as well. The length is free (pass one sized the slot from it); a second hash would have cost 8 B/key of build memory for an adversarial case, and was not worth it.
Added¶
- A libFuzzer target and a property test for the overlay's framing.
parse_overlayjoinsparse_compactandparse_perfect, and a seeded-mutation property test works outward from a real blob — one byte flipped, the blob cut short, or one of its three claimed counts replaced by a hostile value — since random bytes never spellOVL1. Both deliberately stub out the base loader rather than re-parse the embedded blob: that isStringIndex::from_bytes's own property, and fuzzing it here would only rediscover thefstnode-decoder panic thatfuzz/Cargo.tomlalready records as out of this crate's scope.
Changed¶
-
Overlay's Python loaders document whose trust contract they inherit.Overlay.from_bytesandOverlay.loadvalidate the overlay's own framing for every base, then hand the rest to the base class's loader — checked forStringIndex, trust-your-own-blob for the two hash indexes. The signature cannot say that and Python has nounsafemarker, so the docstrings and the stubs do, and a test asserts the sentence is still there. -
The README's size claim is qualified to what was measured. "The smallest ordered
string → idindex available in pure Rust" was a universal claim over crates.io that a benchmark of four crates cannot establish; it now reads "the smallest of the pure-Rust ordered indexes measured below".
[0.12.0] — 2026-09-07¶
Added¶
- Free-threading audit, and the two fixes it turned up. PyO3 0.29 declares
Py_MOD_GIL_NOT_USEDfor a#[pymodule]by default, so the extension has been telling free-threaded CPython it does not need the GIL without anyone having checked. Measured on 3.14t with eight threads: sharing any of the three index types is sound — they are immutable after building and nothing raised — but sharing aStringIndexiterator or anOverlayfailed in seven threads out of eight withRuntimeError: Already borrowed, PyO3's borrow flag refusing two simultaneous&mut selfcalls.
Both are now #[pyclass(frozen)] with their mutable state behind a lock taken through PyO3's
lock_py_attached, which detaches from the interpreter before blocking and so cannot deadlock
against it. Sharing one of them serialises instead of raising: a shared iterator hands each key to
exactly one thread, and concurrent Overlay.add calls each get their own id. gil_used = false
is now spelled out in the source next to the reasoning that backs it, rather than left to a
default. The suite runs on 3.14t as well as 3.14, and the two new concurrency tests fail against
the previous build.
ids_of_byteson all three Python index classes — the batchedidpacked into abytesbuffer instead of a list.ids_ofhas to build one Pythonintper key, which for a batch headed straight intonumpyis the whole cost;np.frombuffer(buf, dtype=index.ID_DTYPE)shares the memory instead. One native-endian fixed-width item per key, aligned with the input, with the newMISSING_IDclass attribute where a key is absent — a buffer cannot carryNone.ID_DTYPEandMISSING_IDare class attributes rather than one module constant because the width differs between the index types (StringIndexids are 64-bit, the hash indexes' are 32-bit), so generic code can read them off the class. On the one index size where the sentinel would be a real id — exactlyu32::MAX + 1keys — the method refuses instead of silently aliasing.
Returned as bytes rather than as a buffer-protocol #[pyclass]. Both work under abi3-py311
(Py_bf_getbuffer has been in the limited API since 3.11), so the choice is surface, not
portability: bytes adds no public class and no exported-buffer lifetime to get wrong, and it is
immutable, which is what an answer should be. What it costs is one memcpy of 4 bytes per key.
Measured on 200 000 real-word keys, minimum of nine rounds, machine drift 3.0 % on a calibration
loop with no memory traffic: ids_of 117.5 ns/key, ids_of_bytes 87.9 ns/key — a
1.34× ratio, saving 29.6 ns/key. That saving is the Python int construction and nothing
else, which the decomposition shows rather than assumes: arr.tolist(), which builds the same
ints from an array with no lookup at all, costs 31.6 ns/key — an upper bound, since it also
walks the array. The plan predicted 15–25 ns/key and understated it.
Overlay<I>— edits on top of any of the three indexes without rebuilding them. All three are immutable by construction: an FST and a minimal perfect hash are both built once from the whole key set, so adding one key has always meant rebuilding for the whole corpus. An overlay wraps a base index with a map of keys added since and a bitset of ids retired from it, soaddandremoveare O(1) and the base is untouched. Ids span both in oneu64space — base ids keep their values, additions continue abovebase.len()— and are stable: an id is never reissued, removal never renumbers anything, and re-adding a removed key revives its original id rather than issuing a second one for the same string.compact()rebuilds the base from the live keys when the additions have grown enough to be worth folding in, and is the one operation that renumbers.
What a base can do is expressed in the type rather than in a runtime error: key, keys and
compact need OverlayKeys, which CompactHashIndex cannot implement because it stores no keys,
so an overlay over it offers membership and nothing else. Removal over a probabilistic base carries
that base's false-positive rate — a contains that was never true of a real key can retire an id —
which is documented on remove and is why the property test excludes it.
The base may be shared: OverlayBase is implemented for Arc<I>, so an overlay can wrap an index
the caller still holds and several overlays can sit on one base.
A lookup costs the base plus one bitset probe, and that is what it measures: at 10 M keys, with the
tombstone bitset fully allocated (1.25 MB), Overlay<PerfectHashIndex>::id is 363.0 ns against
the bare index's 354.9 ns on the same deserialised structure — 1.023×, +8.0 ns, minimum of
nine A-B-A-B rounds with a machine drift of 1.5 %.
to_bytes/save serialise the base blob, the additions and the tombstones together; from_bytes_with/
load_with take the base's own loader as a closure. That is deliberate: StringIndex::from_bytes
is safe while the perfect-hash loaders are unsafe fn, and a single generic loader would have had
to be unsafe for all three to accommodate two of them. Neither len nor the live/dead split is
stored — both are derived on load, so a blob cannot disagree with itself about how many keys it
holds — and a malformed blob is rejected with a named Format error rather than half-loaded. The
header records which base wrote the blob and the loader checks it before handing the bytes over,
so a perfect-hash loader is never pointed at bytes written by something else.
Exposed to Python as lexindex.Overlay, taking any of the three index classes. A #[pyclass]
cannot be generic, so the base is a runtime tag there and key/keys/compact raise TypeError
on a CompactHashIndex base where Rust refuses to compile. Overlay.load(path, base) and
Overlay.from_bytes(data, base) take the base class, since each index loads itself; the base
tag turns the wrong class into an error instead of an unchecked read.
Fixed¶
- The
gridcolumn of the Python lookup table did not reproduce, and is restated. Re-measured one day after 0.11.0 published it, from two fresh 11-round passes agreeing to 4.3 % at worst and 0.5 % at the median. The corrected figures:marisa-trie3.08×/2.75× (was 2.50×/2.16×),StringIndex.id1.73×/1.37× (was 1.41×/1.06×),CompactHashIndex.id0.71×/0.41× (was 0.61×/0.32×), itsids_of0.45×/0.26× (was 0.38×/0.20×).PerfectHashIndex.idreproduced. The published ranges move with it: a missing key costsCompactHashIndex0.33–0.41× adict(was 0.32–0.36×), batchedids_ofon members 0.31–0.45× (was 0.31–0.38×), andStringIndex1.37–2.61× (was 1.06–2.61×). Thewordsandrandomcolumns reproduce to within 1 % and stand.
Five runs on the new day — three round counts, idle and under load, on the released 0.11.0 wheel
as well as the working tree — agree with each other and disagree with the old column by up to
30 %. That rules out the extension, the interpreter, the corpus generator and background load.
What moved is the dict denominator, 251 ns then against 194–196 ns now on identical keys; the
mechanism is not identified and the README says so rather than guessing. The protocol defect is
identified: the two passes that validated the old column ran minutes apart inside one session,
which bounds within-session noise and nothing else, so the second pass now has to come from a
separate session.
[0.11.0] — 2026-09-06¶
Added¶
-
StringIndex::build_sortedandbuild_sorted_to_file— build from keys that are already in ascending order, without materialising them.buildhas to collect its input into aVecbefore it can sort it, so a caller who already has the keys ordered (a sorted file, a database cursor, an external sort) pays for a second copy of the corpus; these stream straight into the transducer, andbuild_sorted_to_filestreams the finished index to disk as well, so neither the corpus nor the index has to fit in memory. Measured withexamples/peak.rson the same word-bigram grid: at 10 M keys the streaming build peaks at 49.6 MB againstbuild's 721.9 (14.6×; 20.0 MB of build memory against 129.5 plus a 592 MB key list), and 100 M keys build with a 63.3 MB peak, of which 29.6 MB is the loaded word list — 32× under the 2 GB the task set as its bar. The peaks reproduce to the tenth of a megabyte across runs; the wall time does not, and is left out for that reason (the same 100 M build took 52 s on an idle machine and 101 s under a load average of 7). Adjacent duplicates are dropped exactly asbuilddrops them after sorting, so the two produce byte-identical blobs for the same key set, which is what a test asserts rather than a similarity check: ids are ranks, so any disagreement would renumber every key after the first difference. An input that is not ascending is refused by the transducer builder rather than producing an index that answers wrongly. -
PerfectHashIndex::build_to_file— builds straight to a file without ever holding the keys, for a corpus that does not fit in memory. Measured on the same key set both ways (examples/peak.rs): the process peaks at 471 MB at 10 M keys against 1 272 MB forbuildhanded a list of those keys, 87 against 146 at 1 M. Of the streamed build's 44.1 bytes per key, 23.5 are the output file itself — the arena is filled through a mapping, so its pages stay resident until the kernel writes them back; the anonymous part is 20.6 bytes per key and flat inn, which is what the design predicts (eight for the hash, four for the length, eight for the sorted copy that looks for collisions).
source is a factory, called twice, and that is the shape of the problem rather than an
inconvenience: the arena stores keys in slot order, slot order needs the perfect hash, and the
perfect hash needs every key's hash first. Pass one hashes and records lengths, the offset table
follows from the lengths alone, pass two replays the corpus and writes each key into its place. A
one-shot iterator cannot be passed by construction — the signature states the requirement instead
of documenting it — and a source that replays different keys is refused rather than silently
mis-built.
Keys must be distinct, unlike build, which sorts and deduplicates. A repeated hash in pass
one is either a duplicate key or a genuine 64-bit collision, and which one it is changes n, the
tail ids and therefore every offset already computed. Requiring distinct keys makes a repeat a
collision by definition; a duplicate is caught by comparing the two written entries, and the file
is never published.
The arena is filled one 32 MB window at a time, through a spill file, and the reason is worth a caller's attention because it costs disk space. Keys are stored in slot order, which is random with respect to file offset, so filling the arena in a single pass keeps the whole mapping dirty for the pass's whole duration — a 4 KB page holds ~178 keys at these lengths, so once the kernel starts writing pages back the pass re-dirties them and the build writes its own output dozens of times over. Measured that way on a real filesystem, page cache drained before every run, it cost 2.7× the wall time from 5 M keys up and did not finish at 100 M at all: killed after fifty minutes at 18 % CPU, having written 187.7 GiB for a 2.34 GB file. Pass two therefore appends each key to the region of a sibling spill file belonging to the window its slot falls in, and each window is read back sequentially, filled inside itself and flushed.
| keys | one pass | windowed | bytes written / output size |
|---|---|---|---|
| 5 M | 12.2–16.4 s | 4.2–5.2 s | 55–75× → 1.00–1.01× |
| 10 M | 25.3–34.2 s | 8.5–8.6 s | 55–91× → 1.00× |
| 20 M | 60.6–61.4 s | 17.7–17.9 s | 68–72× → 1.00–1.01× |
| 100 M | did not finish in 50 min | 94.2–102.3 s | 86× → 1.85× |
What identifies the write order as the cause, rather than the filesystem or the machine, is a control that holds everything else fixed — the same file, the same offsets, the same bytes, in sequential against scattered order: 1.0 s against 155–167 s, at 140–151× the bytes. On a RAM filesystem the same reordering costs 1.7× and no I/O at all. 32 MB is the largest window that held 1.0× reproducibly; 64 MB measured 1.4–2.3× and 128 MB 2.9–23×. Re-reading the source once per window instead of spilling was measured and rejected: one pass over a 100 M source costs 32.8 s, and a 2.3 GB arena needs 74 windows.
The cost is transient disk space — about 2.2× the output size has to be free in the target
directory until the build finishes, so a build can fail with ENOSPC on a volume sized exactly
for its output. Below roughly 20 M keys the spill never reaches the disk at all, being written and
consumed within seconds; an arena that fits in one window skips the spill entirely. The times are
below what the single-pass fill measured writing into a RAM filesystem (4.2 / 8.5 / 17.7 against
5.2 / 10.7 / 22.6), because filling one window at a time is easier on the TLB as well as the disk.
StringIndex::build_sorted_to_file never had the problem — it writes in ascending key order
through a buffered writer, measured at 1.02× at 100 M on the same filesystem the same day — and
CompactHashIndex has no arena to fill.
StringIndex.from_sortedandStringIndex.build_sorted_to_filein Python, the binding for the sorted streaming builds above. This is where the case is strongest: a Python list of 10 M keys is 1 076 MB ofstrobjects before any index exists, so the caller who most needs a build that never materialises its input was the one who could not reach it. Handed a generator, the streamed build peaks at 11.6 MB against the list build's 1053.4 at 10 M (91×) and 4.2 against 105.4 at 1 M, measured withVmHWMreset after the corpus loader so the baseline is the steady state.
A Python iterable that raises halfway is indistinguishable, from Rust, from one that ended, so
build_sorted_to_file asks a caller-supplied check inside the atomic write and before the
rename that publishes the file. Without it a generator that blew up mid-corpus would have left a
truncated but perfectly valid index at path, over whatever was there, with the exception
arriving afterwards. A test asserts the file is byte-identical to what it was before the failed
build, and that no temporary is left behind.
-
PerfectHashIndex.build_to_file(source, path)in Python, the binding for the streaming build above.sourceis a zero-argument callable returning an iterable and it is called twice — the factory shape the Rust signature enforces, spelled the Python way (lambda: open(path)). A Python exception in either pass surfaces as that exception with the file atpathleft byte-identical, through the same in-write check the sorted build uses — a deterministic failure would otherwise stop both passes at the same key, and the two passes would agree on a truncated index. A second pass that yields different keys, or a repeated key, is refused withValueError; handing it the iterable instead of a factory is the likely mistake, so that is aTypeErrornaming the fix. -
StringIndex.iter_after(Rust) —iter()resumed after a cursor key, which is the one range the existing API could not express:range_iter(lo, hi)needs an upper bound andprefix_iter("")cannot skip. It exists because the Python iterator needs it, and it is public because a caller paginating a scan across requests needs exactly the same thing.
Changed¶
- The whole build table at 100 M keys, which is the row the streamed perfect-hash build had to be fixed before anyone could take. One process per cell, on a real filesystem, page cache drained before every run:
| build | keys | wall | peak RSS | blob |
|---|---|---|---|---|
PerfectHashIndex::build_to_file |
streamed | 94.2 / 97.6 / 102.3 s | 4 070–4 080 MB | 23.44 B/key |
CompactHashIndex::build (fp = 1) |
list | 18.3 s | 8 371 MB | 1.27 B/key |
StringIndex::build_sorted_to_file, word grid |
streamed | 29.9 / 31.6 s | 62.6 MB | 4.39 B/key |
StringIndex::build_sorted_to_file, sparse pairs |
streamed | 92.9 / 99.4 s | 77.2 / 77.3 MB | 12.51 B/key |
| the generator alone, building nothing | streamed | 32.6 / 33.0 s | 33.5 MB | — |
Two design claims survive a tenfold extrapolation past the largest n previously measured.
CompactHashIndex holds 1.27 bytes per key at 1 M, 10 M and 100 M, to three digits, and the
memory its build adds above the key list is 25.0 B/key against 25.7 at 10 M — 2.7 % below, where
the claim was flatness within 10 %. The streamed transducer build peaks at 0.6 bytes per key at
100 M, half of which is the loaded word list.
The two StringIndex corpora are listed separately on purpose: the 63.3 MB published for this
build in 0.11 is the word grid, and the sparse-pair generator gives 77.2 MB and a 2.8× larger
blob for the same n. Every trie number is a statement about its corpus, and a table that folded
the two together would read as a contradiction.
The PerfectHashIndex row is also a correctness result: 100 M keys built across 74 arena windows,
then every one of them checked — id answers, key(id) returns the key, and the ids are
exactly the dense range [0, n).
From Python, through the same bench/scale.py cell the published table uses, a CompactHashIndex
built from a generator of 100 M keys takes 35.9 / 36.1 s at a 2 452 MB peak — which
retires the extrapolation the README's scale table used to end on (~35 s, ~3 GB) by measuring it.
The point lookup does not move with n: 298–344 ns at 100 M against 302–330 at 10 M.
docs/design.mdrecords thatptr_hashconstruction is not deterministic. TwoPerfectHashIndex::buildcalls over the same key set, in one process, serialise to different bytes. The index is equally correct and every key answers, but the ids are not stable across builds, so a blob cannot be checksummed against a rebuild and two nodes building the same corpus will not agree on ids. Found while writing a byte-identity test forbuild_to_filethat could never have passed — byte-identity is not a propertybuilditself has. Anything that needs stable ids must build once and distribute the blob.
build_to_file's own documentation said the opposite — that the file it writes is
byte-identical to what build + save would have produced. It was written in this cycle and
never shipped, so it is corrected here rather than listed as a fix. It now says what is true: the blob answers exactly the same, key for key, but the bytes and the ids
differ. Two neighbouring claims that read as byte-identity are correct and were left alone:
to_bytes against save (one index, two serialisation paths) and StringIndex::build_sorted
against build (the transducer is deterministic, and a test asserts it).
- The Python lookup table, owed since 0.10 and finally taken (
local/latency_py.py, new README section):dict,marisa-trieand all three indexes over three corpora and three member/miss mixes.CompactHashIndex.idanswers a present key in 0.61–0.71× adict's time and a missing one in 0.32–0.36×; batchedids_ofin 0.31–0.38× and 0.20–0.29×.PerfectHashIndex.idtrades level on members (1.05–1.33×) and wins on misses (0.83–0.96×);marisa-triecosts 1.85–4.29× andStringIndex1.06–2.61×.
Three protocol defects had to be fixed before any of it was publishable, and the third
invalidates the criterion the plan had been using. The harness gated on dict's round-to-round
spread as its stability control, and that spread was 19–79 % while StringIndex sat at 7 % on the
very same rounds — not machine noise, which moves every column alike, but dict itself: at these
sizes its table straddles the L3 boundary (~16 MB at 480 k keys) where CompactHashIndex is
0.6 MB and fully resident. The least stable object in the table had been made its control. Second,
the forms were alternated in a fixed order, so whichever ran first paid to pull the probe list
back into cache after the previous set displaced it; they are rotated now. Third and decisive,
an idle machine is not a quiet one: a cache-resident integer loop touching no memory drifts
13.7 % over twelve rounds, climbing monotonically as the CPU heats, so the "control spread under
2 %" bar was unreachable here whatever the load average said, and four sessions of blaming a
sibling process were half wrong. The estimator is now the minimum over rounds and the criterion is
agreement between two independent runs — worst 7.3 %, median 1.0 % on the published numbers.
A fourth would-be defect was caught by that criterion rather than by inspection: the first run of
a corpus, minutes after a build, disagreed by up to 24 % on two cells, so a settling period is
real and the run is excluded.
-
The ns/op table the 0.10 sessions could not take (
local/latency/): every lookup form of both hash indexes over four member/non-member mixes and two key layouts,std::HashMapas the stability control, all forms alternated inside each round. Two findings reached the README. First, ~90 ns of every published lookup number is reaching the probe key:examples/bench.rsprobes the original allocations in strided order, and handing the same index a contiguous probe list takesPerfectHashIndex::id_uncheckedfrom 109 to 18 ns/op at 1 M whileids_ofdoes not move at all (39.5 scattered against 38.2 contiguous — the software prefetch does for scattered keys what the hardware does for contiguous ones). Second, on missesstd::HashMapwins until its table outgrows the cache — 32 ns againstCompactHashIndex::id's 41 at 1 M, reversing to 105 against 56 at 10 M. The harness also reproduces the README's own table from different code: at 1 M scattered it measures 125.6 / 261.8 / 108.9 / 237.2 ns against the published ~151 / ~273 / ~111 / ~246. -
ids_ofprefetches the key bytes, not just the tables it looks them up in — 2.6× onCompactHashIndexand 2.1× onPerfectHashIndexover 10 M real-word bigrams, A-B-A-B against the unmodified tree with the per-keyidloops as controls (both flat: 200 → 203 ns/key and 427 → 416). Against that per-key loop the batch is now 2.9× and 3.6×, where it was 1.1× and 1.8×: batching aCompactHashIndexlookup used to buy essentially nothing.
The missing prefetch was the first one. A batch's String headers are contiguous but their bytes
are wherever the allocator put them, so the hashing pass was one dependent cache miss per key with
nothing overlapping it, and the careful prefetching downstream was hiding the cheaper halves of
the problem. The per-key id cannot make this prefetch at all — it has no next key to look at —
which is what the batched form is for.
The number depends on where the caller's keys live, and the docs now say so. Contiguous keys —
a Vec built in probe order — are already visible to the hardware prefetcher: that batch runs at
~50 ns/key with or without the change (−6 % to +2 % across 1 M / 5 M / 10 M, no consistent
direction), and the win there is the single FFI crossing. A 50/50 member/non-member batch measures
the same as an all-member one, because what is being hidden is reaching the key at all.
StringIndex.__iter__(Python) streams the transducer instead of rank-walking every key — 2.9× faster over 1 M real-word bigrams (635 → 221 ns/key, three rounds each; the control,id()on the same index, held at 367 ns across both). It buffers 1 024 pairs per refill and resumes throughiter_after, because a#[pyclass]cannot hold a stream that borrows the index across__next__.
The plan asked for 10× and 2.9× is what the ceiling allows, which the measurement says rather than
the estimate that set the bar: on the Rust side the same walk is 569 → 97 ns/key (5.9×), and
the binding adds ~127 ns/key of CPython str and tuple construction that no iteration strategy
removes — prefix(""), which builds the whole list in one call with no per-item __next__ at all,
costs more (399 ns/key) than the lazy iterator. So the rank-walk is gone entirely and what is
left is object construction. Setting the bar as a round multiple, without a measured floor, is the
same defect as the 2 GB bar in the entry above, from the other side.
Fixed¶
- The 0.10.0 build-memory figures were measured against a masked baseline and are corrected here,
not silently swapped.
examples/peak.rsreportsVmHWM − (VmHWM at the moment the key list is ready), andVmHWMis a high-water mark: the transient of loading the word list and sorting the bigrams stayed in it, so every build had to climb past that transient before it registered. The example now resets the mark (echo 5 > /proc/self/clear_refs) once the key list is built. The understatement is a constant 11.0–11.8 MB in every cell — flat innand in the structure, which is what identifies it as the corpus transient rather than anything about the builds. Re-measured with the corrected baseline on both sides (0.9.1 built from the same example), bytes per key above the key list:
| build | published 0.10.0 | corrected |
|---|---|---|
PerfectHashIndex, 2 M |
79.0 → 44.9 (−43 %) | 83.0 → 50.8 (−38.8 %) |
PerfectHashIndex, 10 M |
84.7 → 50.8 (−40 %) | 85.9 → 51.9 (−39.6 %) |
CompactHashIndex 8-bit, 2 M |
42.5 → 25.2 (−41 %) | 48.2 → 30.9 (−35.9 %) |
CompactHashIndex 8-bit, 10 M |
47.6 → 24.6 (−48 %) | 48.7 → 25.7 (−47.2 %) |
CompactHashIndex 16-bit, 2 M |
→ 27.3 | 48.2 → 33.0 |
CompactHashIndex 32-bit, 2 M |
→ 29.2 | 48.2 → 34.9 |
The absolute savings are unchanged, because the constant cancels in a difference: 340 MB off a
10 M PerfectHashIndex build (published 338) and 230 MB off a 10 M CompactHashIndex build
(published 230). What moves is every per-key figure and therefore every percentage, by 1–5 points.
docs/design.md and the CompactHashIndex::build_bits docstring carry the corrected numbers; the
0.10.0 section below is left as it was published. One 0.10.0 claim gains rather than loses: the
build peak was said to rise with the fingerprint width, and on the corrected baseline the 0.9.1
build does not move with it at all (48.2 B/key at 8, 16 and 32 bits), so the width-proportional
staging is visible only in the new code.
- The README's
dictbuild cost had the same defect from the Python side —local/positioning.pytook its baseline right after_random_pairsreturned, while the dict that generator uses to deduplicate was still counted in the high-water mark. It resets the mark too now: adictcosts 71–95 bytes per key above the key list on the three corpora in the README's table, not the 35–96 the range said, and 58–60 at 10 M.
[0.10.0] — 2026-09-04¶
Added¶
- Golden blobs from every published format (
tests/data/, regenerated bylocal/gen_golden.pyfrom the PyPI wheels of 0.5.1, 0.7.0, 0.8.0, 0.8.1 and 0.9.1) are loaded and queried bytests/golden.rs. Every other test in the repo writes a blob and reads it back with the same code, so all of them would keep passing if a dependency bump changed theepserdeimage embedded in an MPH blob. These coverBMP2/BMP3/BMP4andBCH1/BCH3/BCH4/BCH5, including the documented refusal of a 0.5.1BCH1. examples/peak.rsreports the peak resident memory (VmHWM) and wall time of a single build, one index per process, on the same real-word bigramsexamples/bench.rsuses. The build-memory numbers below were measured with it.examples/bench.rsgained an FxHash baseline.std::collections::HashMapis a SipHash map, which is the wrong single number to compare a perfect hash against; the same map with therustc/Firefox hash (written out in the example, not added as a dependency) is the honest one.- libFuzzer targets for the blob framing parsers (
fuzz/, run weekly bysanitize.yml, seeded fromtests/data/). They cover the safe half of the MPH loaders — the code that validates magic, versions, lengths, both checksums, the collision side table, the fingerprint width and the arena's offset table before anything unsafe happens, which is the only place untrusted bytes reach code this crate owns. 29.0 M and 37.5 M executions with no crash on the first run. - A positioning section in the README: which index to pick, and how much the corpus decides it.
Five structures — a bare
ptr_hashMPHF,CompactHashIndex,marisa-trie,StringIndex,PerfectHashIndex— over three corpora built from the same word list, at 479 823 / 1 M / 10 M keys. The structures that store no keys are flat at 1.27 and 0.27 bytes per key on every corpus; the ones that do swing 3–8× (marisa2.12 to 6.21,StringIndex0.68 to 16.57), so the section says which corpus each number comes from and what shared key structure buys. bench/scale.pymeasures a list and a generator separately. Passing a list puts the corpus in the peak, which is what a caller holding the keys pays; passing a generator is whatCompactHashIndex's streaming build exists for and is the only way to see its own footprint.
Changed¶
-
StringIndex::from_bytesno longer claims that checksum verification rules out a panic. It guards accidental corruption, which is all a public, deterministic checksum can do; a blob crafted to carry a matching one reachesfst's node decoder with an invalid body. A third fuzz target demonstrated it in ten minutes with 44 bytes that panic inside the load's own rank spot-check, and that target is deliberately not kept:docs/design.mdalready documented this as the accepted limit of theStringIndextrust boundary, so the defect was in the docstring, not in the code.fstis safe Rust throughout, so the worst case remains a panic or a wrong answer rather than an out-of-bounds read — which is why this loader stays safe while the perfect-hash ones areunsafe. -
PerfectHashIndexbuilds in 43 % less memory — 79.0 → 44.9 bytes per key above the input at 2 M real-word bigrams, and 84.7 → 50.8 at 10 M, where it is 338 MB off the peak of a single build. Four changes, none of which touch the blob: the slot table holds au32key index instead of a 16-byteOption<&str>; the(hash, index)partition that existed to find hash collisions is gone from the common path, replaced by a sorted scratch copy that also feeds the perfect hash and is dropped before the arena is built (a real collision falls back to the old partition, on a#[cold]path); the arena is assembled in one exactly-sized buffer with its offsets written in place, instead of collecting the data and the offsets separately and concatenating them, which held two copies of the corpus across the concatenation; and the arena takes its two totals from the build's own hashing pass rather than re-deriving them in slot order, which measured 4 M extra data-TLB misses at 2 M keys. The build also retires 5.6 % fewer instructions and takes 11 % fewer page faults than 0.9.1 (perf stat, three runs). CompactHashIndexbuilds in 41 % less memory — 42.5 → 25.2 bytes per key above the input at the 8-bit default, and 47.6 → 24.6 at 10 M (230 MB off a single build). The representatives' hashes and their truncated fingerprints are lifted out of the(hash, second hash)pairs before the perfect hash is built, so the pairs are dropped first rather than sitting next to ptr_hash's construction memory alongside a full 64-bit fingerprint per key. The documentation claimed16 · nfor the peak, which was the pairs alone: the measured high-water mark is 25.2 bytes per key at the 8-bit default, rising with the fingerprint width (27.3 at 16 bits, 29.2 at 32), and the docs now say so.- README: "roughly twice as quick as
HashMap" is now scoped to the SipHash map it was measured against.std::collections::HashMaphashes with SipHash; the same map with FxHash measured 196 / 200 ns againstPerfectHashIndex::id_unchecked's 216 / 216 in two independent 12-run sessions. Against a fast-hashed map lexindex's lookup advantage is gone, and the README says so — what remains is the footprint and the serialisable, memory-mappable blob. The scale table is regenerated on this code with both key sources; the streamedCompactHashIndexat 10 M peaks at 304 MB against 988 for the same build handed a list. ptr_hashandepserdeare bounded to a minor series (~1.1,~0.8). Their layout is embedded verbatim in every MPH blob this crate has written, so a change there does not break a build — it stops old files from loading. Moving either is now a decision with a golden-blob test attached rather than somethingcargo updatecan do.- A 32-bit build with the
mphfeature now fails with one sentence naming the feature to turn off.ptr_hash1.1 depends onsucdsunconditionally, andsucdscompile_error!s on any pointer width other than 64, so the build used to die inside a transitive dependency with a dozen errors that named neither lexindex nor anything the caller could change — and acompile_error!of our own would never have been reached, because cargo compiles dependencies first. The minimal-perfect-hash dependencies are therefore declared under[target.'cfg(target_pointer_width = "64")'.dependencies]: off 64-bit they are simply not in the graph, the modules that name their types are not compiled, and the only diagnostic is ours. The dependency graph on 64-bit targets is unchanged (Cargo.lockuntouched). Thefst-only build has no such constraint — checked fori686-unknown-linux-gnuandwasm32-unknown-unknown— and README and the usage guide now say so.
[0.9.1] — 2026-09-04¶
Added¶
serialized_len()on all three indexes: the byte lengthto_bytes/savewould produce, without producing it (the minimal perfect hash is measured through a discarding sink).docs/usage.mdis compiled as doctests, like the README, so its Rust block cannot drift from the API again — it had been callingload_mmapwithoutunsafesince 0.9.0.
Changed¶
- The unsafe loaders are split into a safe framing parser and the unsafe
epserdestep.parse_framevalidates everything a query will trust — magic, header and payload checksums, lengths, the side table, the fingerprint range or the arena — on any bytes, and the property tests now fuzz exactly that half (plus every truncation of a real blob); the MPH region is deserialised only afterwards, behind the caller's contract. The proptests that called the unsafe loaders on arbitrary bytes are gone: they exercised what the contract excludes. StringIndexowned loads spot-check the rank invariant: the first key's value must be 0 and the rank-walk tolen - 1must succeed. A full walk measured 0.7 → 40 ms on the 479 823-word dictionary (58× the owned load), so a permutation of the ranks is deliberately not detected — it cannot violate memory safety, andkey(id)now uses checked arithmetic on the blob-supplied outputs, answeringNoneinstead of wrapping.UnicodeSubsequenceno longer allocates a per-byte rewind table: the rewind target is found by walking back over UTF-8 continuation bytes, the same answer with no allocation per query.- Docs: README's install snippet says
0.9(0.9.0 shipped saying0.8; the release preflight now checks it); "never a full scan" and "2^-bby construction" scoped honestly in the usage guide and the site index, which no longer calls the core "from-scratch"; a note on Unicode semantics (scalar values; no normalisation, case folding or grapheme segmentation); the MPHload_mmap# Safetysections state the trusted-blob precondition alongside the immutability one;bench/compare.pyshuffles its keys instead of handing every library the pre-sorted dictionary, and no longer claims to measure lookup latency; theexamples/bench.rsprobe stride is documented as prime (full coverage for everynbelow it), not merely odd.
Fixed¶
- Width-dependent casts: the fingerprint-table length in
build_bits, the arena's offset count and its per-key offsets are converted withusize::try_frominstead ofascasts, so a header-supplied value that does not fit the platform'susizefails cleanly instead of truncating (mph_lenand the side-byte count already did). Defence in depth rather than a live bug: a 32-bit build of themphfeature does not compile today —ptr_hashpullssucds, which refuses any target that is not 64-bit — and thefst-only build contains none of this code. Thefst-only build is checked fori686-unknown-linux-gnu. release.yml: aworkflow_dispatchrun built neither wheels nor the sdist, because both depended on the tag-only preflight and a skipped dependency skips its dependants; they now run unless preflight actually failed. Tags are gated on the full CI matrix (fmt, clippy, tests on Linux/macOS/Windows, Python 3.11–3.14, MSRV, coverage) throughworkflow_call, and the native wheels are installed and exercised on their runner before anything is published.
[0.9.0] — 2026-08-28¶
Changed¶
from_bytesandloadare nowunsafe fnonPerfectHashIndexandCompactHashIndex(breaking). A function that is unsound for some input belongs behindunsafe fn, and these accept arbitrary bytes: the blob framing is validated and checksummed — accidental corruption still fails cleanly — but the embedded minimal perfect hash is anepserderegion whose pilot tableptr_hashreads unchecked, and the fields that would bound that read are private toptr_hash, so no validation on this side can reject a crafted blob. Upstream reached the same conclusion independently:epserde0.13 madedeserialize_fullanunsafe fn, and PtrHash declined a checkedtry_index()for the same reason.StringIndex::from_bytes/loadstay safe —fstvalidates its own structure and guarantees invalid input cannot violate memory safety. The Python bindings keep their signatures and state the contract in their docstrings.load_mmapis now anunsafe fnon all three indexes (breaking). The mapped bytes are borrowed, not copied, so a write to the file from any process while the index is alive is undefined behaviour — which is exactly whymemmap2::Mmap::mapis itselfunsafe. Wrapping it in a safe function hid a precondition that ordinary safe code (another handle writing the same path) can violate, so the obligation is now stated in a# Safetysection and the call sites carry it.loadis unchanged and remains safe. The Pythonload_mmapkeeps its signature — Python has no way to express the obligation — and documents the same contract in its docstring.CompactHashIndexhashes each key once instead of twice. Both of its hashes are now produced by a single pass over the key's bytes, bit-for-bit identical to the two functions it replaces (a golden test asserts the equality on 2 000 keys, so no stored blob changes meaning). Measured in isolation: 21.3 → 18.2 ns/key on real-word bigrams, 126 → 77 ns/key on 80-byte URI-like keys.- The Python
CompactHashIndexconstructor releases the GIL while hashing. Items are pulled in 4 096-key chunks and hashed with the GIL dropped, so other Python threads keep running through what was previously one long GIL-held stretch; memory behaviour is unchanged (still 16 bytes per key, strings released as they are consumed).
Fixed¶
build_bitsrejects a badfingerprint_bitsbefore touching the iterator. Since 0.8.1 the width was validated after the input had been collected, so an invalid width consumed (and hashed) the entire iterator first — and never returned at all for an endless one.
Documentation¶
- The README's Rust examples are now compiled and run as doctests. They had never been checked
by anything, so an API change could silently invalidate the first code a reader sees. A
#[cfg(doctest)]item includes the file, which runs the snippets without pulling the prose into the API docs. - The fingerprint's false-positive rate is described as a design rate from two uncorrelated hashes rather than as exact probability from independent ones, matching the more careful wording already in the code.
- The point-lookup table is re-measured on this release's code (min of 12 runs, idle machine,
four seconds between runs). The whole session runs ~19% faster than the 0.8.0 one — the
std::HashMapcontrol moved with it — so the table now says which spreads were observed per row and warns that the ratio toHashMapis session-dependent (id_uncheckedmeasured 2.2× here, 1.7× before) rather than presenting it as a constant.
[0.8.1] — 2026-08-27¶
Fixed¶
CompactHashIndexno longer merges two distinct keys that collide in the 64-bit slot hash when their truncated fingerprints also tie. The 0.8.0 build deduplicated on(hash, fingerprint_bits-wide fingerprint), so at narrow widths a genuine hash collision had a2^-fingerprint_bitschance of silently collapsing into one id — the repository's own pinned collision pair reproduces it at 1 bit (len() == 501for 502 distinct keys). The collision side table now stores the full 64-bit second hash regardless of the table width (the build pairs were already 16 bytes, so build memory is unchanged), and the side probe runs before the fingerprint table, which could otherwise answer for a side key whose truncated bits tie its representative's. Two distinct keys now merge only by colliding in both 64-bit hashes at once (≈ 2^-128per pair), independent offingerprint_bits. The blob magic moves toBCH5(same layout): a collision-free 0.8.0BCH4is bit-identical and still loads, one holding a side table is refused with a message naming the rebuild — its truncated side fingerprints cannot be widened without the keys.- Side-table ids are validated on load. Both perfect-hash loaders now require the side ids to be
exactly the tail range
[m, n)— structurally, not via the checksums, which vouch for transport rather than construction. A malformed blob could previously handCompactHashIndex::id()an id at or pastlen(). - Header-length arithmetic is checked for 32-bit targets. The side-table byte count is computed
with
checked_mulandmph_lenconverted with a checked cast, so a fabricated length fails cleanly instead of wrapping or truncating on a 32-bit build (the published wheels are 64-bit, but the crate is not).
Changed¶
- The Python
CompactHashIndexconstructor streams. Items are hashed to their 16-byte pair one at a time as they come off the iterator (under the GIL) and each string is released immediately, so a generator-fed build no longer materialises the whole corpus on the binding side. The other two constructors still collect — those indexes store the keys.
[0.8.0] — 2026-08-27¶
Added¶
- A 64-bit hash collision can no longer fail a build — any key set now builds. Both perfect-hash
indexes build the MPH over one representative per distinct hash value; colliding leftovers get
tail ids served from a tiny side table, consulted only after the main lookup has already
missed, so an index without collisions (every index below ~10^8 keys, in practice) pays one
predictable branch and nothing more.
PerfectHashIndexstays exact even for collided keys (the side probe compares stored keys);CompactHashIndexmatches side keys by fingerprint, leaving only a pair colliding in both hashes at once (2^-(64+fingerprint_bits)per pair) to collapse as if it were a duplicate. Previously such key sets failed the build permanently — the hash is deterministic, so no retry could ever help; at 1 G keys that was a ~2.7 % build-failure rate. - Whole-payload checksum on the perfect-hash blobs. Owned
load/from_bytesnow verify a streaming 64-bit hash over everything after the header, so a flipped byte anywhere — MPH region, arena, fingerprint table, side table — is rejected at load instead of perturbing answers (or aborting insideepserde) later. This makes the accidental-corruption story uniform across all three indexes: every owned load verifies the entire blob. (load_mmapstill trusts the mapped file; a crafted blob remains outside the contract — the hashes are public and deterministic.) CompactHashIndex::build/build_bitsstream. One pass keeps a 16-byte(hash, fingerprint)pair per key and never materialises the strings, so building from a lazy iterator peaks at16 × nbytes over the input regardless of key length — and sorting pairs instead of strings halved build time (249 → 120 ms at 1 M real-word bigrams, same-session A/B), putting it ~2× belowstd::HashMap's build. Lookup cost is unchanged (A/B: 245.2 vs 245.2 ns);PerfectHashIndex::id_uncheckedmeasured 8 % faster (193 → 178 ns) and the verifiedPerfectHashIndex::id~3 % slower (343 → 353 ns — the side-table entry branch, on the one path that already pays a full key compare). Serialised sizes are unchanged to two decimals (+4/+12 bytes per blob, not per key).- Windows and macOS test jobs in CI. The release workflow already shipped wheels for both;
their platform-specific paths (
save's rename-over-existing, permissions, mmap) now run the test suite on every push, not just a cross-build on tags.
Changed¶
- Blob formats:
PerfectHashIndexwritesBMP4,CompactHashIndexwritesBCH4— the v3 layouts plus the side table and the payload checksum;BMP4also drops the storedoverflow_cap, which every load has recomputed from the arena since 0.7.1 anyway. 0.7 blobs (BMP3/BCH3, andBMP2from 0.5/0.6) still load;BCH1/BCH2remain refused. Older lexindex versions cannot read v4 blobs. savestreams. All three indexes write the blob section by section through the atomic writer instead of assembling a serialised copy first, halving peak save memory; the bytes written are identical toto_bytes.
Fixed¶
saveno longer follows a symlink planted at its temporary path. The atomic write opened its sibling temp withFile::create, which follows an existing symlink — so an attacker able to write to the target directory could pre-create a symlink at the predictable<name>.<pid>.<seq>.tmpand redirect the write, truncating an arbitrary file the process could reach. The temp is now openedO_CREAT | O_EXCL(create_new), which refuses any pre-existing path, symlink included; on a name collision the write retries with the next counter (bounded, so a hostile racer cannot spin it). On Unix the parent directory is now fsynced after the rename so the publish is durable across power loss, and an existing file's permissions are preserved rather than reset to the umask default.StringIndexowned loads verify the FST checksum.from_bytes/loadhanded the body tofst::Map::new, which checks length and version but not the stored CRC, so a corrupt owned blob could load and only fail — or mislead — on a later query. Owned loads now callFst::verify(), anO(n)CRC scan, and reject a bad blob at load;load_mmapstill skips it to keep mapping constant-time (a mapped file is trusted intact, as before).PerfectHashIndexnever trusts the header'soverflow_cap. It is now recomputed from the arena on every load,BMP3included, so a blob whose framing checksum was forged alongside a crafted cap still cannot steer a query past the true remap length.CompactHashIndexstores no keys and cannot recompute it, so it remains a trust-your-own-blob format (documented).
Added¶
- Golden hash-stability tests.
hash_keyandfingerprint_bitspin exact outputs for a fixed key set (ASCII and multibyte), so a silently changed constant — which would make every previously saved MPH blob load wrong — fails CI loudly instead. Changing a hash is a format break that must bump the magic, not the table.
Changed¶
- Trust-boundary wording made precise in
README.mdanddocs/design.md: lexindex framing is bounds-checked andStringIndexowned loads verify the FST CRC, but the perfect-hash indexes' embeddedepserdeMPH (whichptr_hashreads unchecked) stays a trust-your-own-blob payload — the header checksum and recomputedoverflow_capguard accidental corruption, not a crafted blob.
[0.7.0] — 2026-08-27¶
Fixed¶
- Non-member queries can no longer read ptr_hash's remap out of bounds — a debug-build panic
and, in release builds, undefined behaviour (
get_uncheckedpast the remap vector), present in every version since the minimal-perfect-hash indexes shipped. ptr_hash's minimalindex()remaps raw slots ≥ n through an internal vector that only covers slots up to the last member-occupied one, and reads it unchecked; a non-member key whose raw slot lands in the trailing free zone — a zone that exists for a few percent of built indexes, depending on construction entropy — indexed past it. Surfaced by CI as a flakyassertion failed: rank < self.count_ones()in the new 4-bit fingerprint test (reproduced locally in 23 runs; the backtrace pinsptr_hash::pack::Packed::index→cacheline-ef::index_unchecked). Both indexes now record the remap's exact length at build time (overflow_cap— the largest memberraw slot − n, plus one) and answerNoneoutright for raw slots past it: those slots are provably free, so no member can live there. The repro loop went from 1 failure in 23 runs to 0 in 120; a regression test rebuilds until the zone occurs and asserts the guard. Lookup cost is unchanged (A-B against the 0.6.0 binary,std::HashMapcontrol: PH 330 vs 331 ns, CHI 244 vs 244); builds pay ~1% for measuring the cap (one streamedindex_no_remappass over the members). id_uncheckedis bounded too. It documents skipping the membership comparison, not the bounds, but it calledmph.index()directly and so kept the defect above for any caller who passed a key that was not in fact a member. It now resolves through the same guarded path and returns a valid slot for any input.subsequencematches characters, not bytes.fst'sSubsequenceautomaton advances one byte at a time, so a query character matched if its bytes appeared anywhere in order:subsequence("é")([C3 A9]) matched"àΩ"([C3 A0 CE A9]), which contains neither character. Every non-ASCII subsequence query was affected. Replaced with an automaton that rewinds a partial character on mismatch — exactly correct rather than conservative, because UTF-8's lead and continuation byte classes are disjoint.saveis atomic. All three indexes write a sibling temporary and rename it into place, so a crash or a full disk leaves the previous file intact instead of a truncated one that still has a valid magic.loadno longer copies the file buffer a second time.
Changed¶
- Blob formats:
PerfectHashIndexwritesBMP3,CompactHashIndexwritesBCH3— the previous layouts plus theoverflow_capfield and a 32-bit check over the lexindex header. The check is not decoration:overflow_capbounds an otherwise unchecked read, so a header that lost bytes in transit must fail loudly rather than steer queries. - Blobs written before 0.7 are healed or refused, never loaded unbounded. A
BMP2(PerfectHashIndex) blob is healed: its arena holds every key, so the bound is recomputed exactly at load, costing O(n) hashes once. ABCH1/BCH2(CompactHashIndex) blob stores no keys, cannot be repaired, and is refused with a message naming the fix — loading it would reinstate the out-of-bounds read. Rebuild those indexes on 0.7. - Construction failure is an error, not a panic.
IndexError::Buildreplaces theunwrap_or_elsefallback path when both parameter sets fail. Loading now also rejects a header claiming more thanu32::MAXkeys (ids areu32) and cross-checks the deserialised MPH's own key count against the header's. - Python: any iterable of keys, any
os.PathLikepath. Constructors took only sequences, so building from a generator raisedTypeError; paths took onlystr, so apathlib.Pathhad to be stringified at every call site. The stubs claimedlist[str]where tuples were always accepted, and were widened to match reality. - Honest wording for measured behaviour. Perfect-hash ids are documented as not reproducible
across builds (ptr_hash's construction is randomised — measured on 50 k keys, ~53 % keep their
id when the same key set is rebuilt); the fingerprint false-positive rate is documented as a
design rate against random non-members, not a defence against chosen queries (both hashes are
deterministic and unseeded); an
epserdeblob is documented as portable only across machines of the same endianness and pointer width. bench/compare.pytimed each competitor's firstimportas part of its build, which flattered lexindex — imported at module scope. Builds are now the median of five runs after a warm-up.
Added¶
- A weekly
sanitizeworkflow: AddressSanitizer over the whole suite (the class of defect fixed above is invisible to a normalcargo test) and Miri over the fst-only build. CI also runscargo test --release, and a release preflight refuses a tag whose version does not matchCargo.toml,pyproject.toml,CITATION.cffand a datedCHANGELOG.mdsection.
[0.6.0] — 2026-08-27¶
Added¶
- Sub-byte fingerprints:
CompactHashIndex::build_bits/fingerprint_bits=(1..=64 bits). The fingerprint table is bit-packed, so size is exactlyfingerprint_bits/8bytes per key on top of the ~0.27 B/key minimal perfect hash, and the membership false-positive rate is exactly2^-fingerprint_bits. On the 479 823-word dictionary: 0.77 B/key at 4 bits (6.25% FP, 3.9× smaller than marisa-trie), 1.02 at 6 bits (1.56%), 1.77 at 12 bits (0.024%) — the existing byte widths keep their exact sizes and rates (1.27 / 2.27 / 4.27 B/key at 8 / 16 / 32 bits).CompactHashIndexstays below marisa-trie's 2.98 B/key at every width up to 21 bits. The advertised rate is measured, not assumed: 2 M random non-member probes landed at 6.253 % (z = +0.18) for 4 bits and 1.555 % (z = −0.83) for 6. Python: keyword-onlyfingerprint_bits=on the constructor plus afingerprint_bitsproperty;fingerprint_byteskeeps its byte semantics unchanged. The docs gained a width-choice table (rate priced per non-member probe).
Changed¶
- Blob format:
CompactHashIndexnow writesBCH2(width field counts bits, table is bit-packed). 0.5.xBCH1blobs still load — including zero-copy under mmap — because their byte-aligned fingerprints are bit-identical to the packed layout at 8× the width; 0.5.x cannot read the newBCH2blobs, hence the 0.6.0 version bump. The default 8-bit path is not taxed by the generality: byte-aligned widths take a straight byte-copy fast path when building, and A-B against the 0.5.1 binary (12 alternated runs,std::HashMapcontrol) put both build (222 vs 225 ms/1 M keys) and lookup (166.4 vs 166.0 ns) inside the control's noise. A 4-bit index answersid()as fast as an 8-bit one (86.9 vs 87.7 ns on the dictionary) and streamsids_offaster (47.6 vs 55.1 ns/key — half the table, better cache residency).
[0.5.1] — 2026-08-27¶
Added¶
- Citation metadata (
CITATION.cff+.zenodo.json): GitHub shows "Cite this repository", and once the repository is enabled in Zenodo's GitHub integration, each release from the next tag on is archived with a DOI. Metadata validated against the CFF 1.2.0 schema.
Changed¶
-
The minimal perfect hash is built with ptr_hash's
default_compactparameters (λ=3.9). Tighter pilot buckets take the MPH from 2.41 to 2.17 bits/key on the 479 823-word dictionary:CompactHashIndexfp=1 drops 1.301 → 1.272 bytes/key (2.34× smaller than marisa-trie's 2.98), fp=2 2.301 → 2.272,PerfectHashIndex13.625 → 13.596. Query time is unchanged (A-B in one binary at 480 k and 5 M keys, plus process-level A-B-A-B with astd::HashMapcontrol); builds pay ~+10 ms per million keys (~3–4%) — a build-once/query-many trade. Compact construction can occasionally fail (pilot eviction chains grow too long), so it falls back to the default parameters automatically. Both parameter sets serialise the same type, so blobs stay compatible in both directions — no format change. -
Batch
ids_ofonPerfectHashIndexandCompactHashIndexstreams its MPH lookups. Per-keyid()walks hash → slot → verify serially, stalling on a cache miss at every step. The batch path now drives ptr_hash'sindex_stream(software-prefetched slot resolution) and prefetches the verification data (arena offsets and spans, fingerprint bytes) a fixed distance ahead, so the memory latency of key i+16 overlaps the compare of key i. Measured against the per-key loop in the same binary:PerfectHashIndex.ids_of1.55× on the 479 823-word dictionary and 1.83× on 5 M real-word bigrams;CompactHashIndex.ids_of1.10× / 1.21× (its fingerprint compare was already a single byte load, so only the slot stream and fingerprint prefetch help). The Pythonids_ofof both classes routes through the streamed core with the GIL released; misses still come back asNone, pinned by tests on both sides. -
The rank-walk (
id → key) picks each FST transition by binary search instead of a linear scan. Transitions are stored in increasing byte order, which makes their subtree-minimum ranks non-decreasing — the walk's invariant already guaranteed the order, the scan just wasn't using it. Near the root of a dictionary FST a node fans out ~50 ways, so the saving concentrates exactly where every reverse lookup must pass:StringIndex.keys_ofover the whole 479 823-word dictionary drops from 775 to 423 ns/key (1.83×), measured back-to-back against the published 0.5.0 wheel on the same machine, with the reconstructed keys verified equal to the sorted dictionary in both. Everything reverse benefits —key,keys_of,dict(index)iteration. -
The speed benchmark (
examples/bench.rs) now uses real dictionary-word bigrams, the same key generator asbench/scale.py, and refuses to run without a word list rather than substitute synthetic keys — the same rulebench/compare.pyhas always enforced. The oldentity-000…Nkeys arrived pre-sorted and hash-degenerate, flattering every build time. On real keys the README table moved both ways and was re-measured whole (one session, min of 12 runs): everybuildreads higher because sorting real input is part of the job, while the lookup gap overstd::HashMapwidened from ~1.25× to ~1.5× (realistic short keys make the byte-wise FNV hash cheaper relative to SipHash). ACompactHashIndex::idrow was added — measured ~238 ns, it beatsHashMapwhile keeping its fingerprint membership check.
Fixed¶
- The hash-collision build error no longer suggests a retry that cannot work. Both MPH builds
said "64-bit key-hash collision; rebuild or use StringIndex" — but the hash is deterministic and
unseeded (that is what makes a serialised MPH reloadable), so rebuilding the same key set fails
identically, forever. The message now says so and points at
StringIndexor changing the keys.
Documentation¶
-
The collision odds behind "build fails on a 64-bit hash collision" are now quantified instead of called astronomically rare:
n(n-1)/2^65, computed exactly (Maxima and PARI/GP agreeing) — 6.2×10⁻⁹ for the dictionary, 2.7×10⁻⁶ at 10 M keys, 2.7×10⁻⁴ at 100 M, ~2.7% at 1 G. Honest below ~10 M; a real design consideration at 10⁸–10⁹, whereStringIndexhas no such failure mode. -
The
256^-kfalse-positive rate is now statistically verified, not just asserted. On the 0.5.0 code, dictionary members with two non-member populations: 2 M random strings measured 0.384% at fp=1 (z = −1.5 against the exact 0.390 625%) and 33/2 M at fp=2 (z = +0.5); 50 000 held-out real words measured 0.310% (z = −2.9). At or below theory in every case — the advertised rate is a ceiling in practice. -
The usage guide now explains what
limitbuys — and what it cannot ("Whatlimitbuys"), replacing 0.5.0's single headline number with the measured behaviour. The speedup is the work not done, so it spans three regimes: prefix/range scale withmatches ÷ limit(measured ~3 000× forprefix("s", limit=10)on an idle machine — the 669× in the 0.5.0 notes was the same query on a loaded one, i.e. conservative), subsequence gains ~80× because early stop saves the expensive traversal itself, and fuzzy gains only ~6× because the eagerly-built Levenshtein automaton is a fixed costlimitcannot skip. Also documented: the ~3 µs per-call floor (asking for 1 match costs the same as 10), and that consuming all matches gains nothing by construction.
[0.5.0] — 2026-08-26¶
Upgrading: rebuild any saved PerfectHashIndex blob — its format changed (see below) and 0.5.0
rejects the old one rather than misreading it. StringIndex and CompactHashIndex blobs load
unchanged. Rust consumers need a 1.85 toolchain. Python users need nothing beyond pip install -U.
Added¶
- Bounded and lazy queries on
StringIndex. Pythonprefix/range/fuzzy/subsequencetake alimit, and Rust gainsprefix_iter/range_iter/fuzzy_iter/subsequence_iterreturning lazy iterators. An autocomplete asking for ten matches now walks ten keys instead of materialising every match: on the 479 823-word dictionary,prefix("s", limit=10)is 0.026 ms against 17.59 ms — 669× faster — and allocates 10 tuples rather than 45 064.prefix("a", limit=10)is 310×,fuzzy("hello", 2, limit=5)3.7×. - The eager forms are now
.collect()over the lazy ones, so there is one walk implementation rather than two. Measured with both variants compiled into one binary and alternated A-B-A-B (the machine was loaded, and in-process alternation is what makes the comparison meaningful): the change is not a regression — five runs gave −7.4 %, −0.3 %, −2.7 %, −1.6 %, −1.9 %. -
fuzzy_iterstill builds its automaton eagerly, so a too-large edit distance errors up front rather than on first use. -
lexindex.__version__in the Python package, read from the installed distribution metadata (so it cannot drift frompyproject.toml) with a0.0.0+unknownfallback when imported from a source tree that was never installed.
Changed¶
PerfectHashIndexis 23% smaller: its key arena now uses 4-byte offsets. The arena addresses each stored key by an offset into a flat buffer, and those offsets wereu64— 8 bytes per key to address a 4.9 MB buffer. They were the single largest part of the structure: 8.0 of its 17.625 bytes per key on the 479 823-word dictionary. Offsets are now 4 bytes, taking the index to 13.625 B/key (−22.7%).- The width is chosen per arena, not capped. An arena above 4 GiB still gets 8-byte offsets, recorded in a header byte, so no corpus that built before will fail to build now.
- Lookups got faster, not slower. Halving the offset table halves the cache footprint of the
two reads every verified lookup makes, which more than pays for the width branch — and the
branch is on a field fixed for the life of the index, so it predicts.
PerfectHashIndex::id, the only path that touches the arena, measured −3.7% (386.8 → 372.4 ns, min of 12 runs on an idle machine). The controls that cannot touch the arena —id_unchecked,StringIndex,std::HashMap,std::BTreeMap— moved +0.0%, +0.2%, +0.7% and −0.3%, which is what makes the −3.7% readable as the change rather than the machine. - Building is cheaper too, since the offset table is assembled in memory before it is written:
the peak RSS of a
PerfectHashIndexbuild on the dictionary falls a further 38.1 → 29.9 MB on top of the saving below, for ~87 → 29.9 MB (−66%) across the release. -
Breaking: the
PerfectHashIndexblob magic is nowBMP2; blobs written by 0.1–0.4 must be rebuilt.StringIndex(BIX4) andCompactHashIndex(BCH1) blobs are untouched, as are their sizes. -
buildno longer copies the corpus to sort it. All three constructors collected their input into an ownedVec<String>before sorting and deduplicating, even though every key is copied again into the structure being built. They now sort the caller's items in place, comparing throughAsRef<str>.PerfectHashIndexadditionally held a third copy: its slot table cloned each key only for the arena to copy it once more, and now borrows instead. On the 479 823-word dictionary (peak RSS of the build itself, one process per variant, order alternated across four pairs; timings A-B-A-B with both implementations in one binary):
| peak RSS | build | |
|---|---|---|
StringIndex |
29.9 → 8.0 MB (−73%) | 1.04× |
PerfectHashIndex |
72.5 → 38.1 MB (−47%) | 1.99× |
Rust callers get the same saving: passing &[String] or an iterator of &str now costs one
pointer per key instead of a copy of the corpus. Ids, key order and every serialised size are
unchanged — this only removes intermediates.
- test_build_releases_the_gil grew its key count: at 400 000 keys the build now takes 49 ms
rather than 268, which tripped the test's own "too fast to tell anything" guard. It refused to
pass vacuously, which is what that guard is for.
- The Python bindings borrow the caller's strings instead of copying them. The constructors and
ids_ofread their keys asPyBackedStr— a view into the Pythonstr— where they previously extracted an ownedVec<String>.buildalready copies the keys it keeps, so that intermediate vector was pure overhead: on the 479 823-word dictionary the peak RSS of a build drops from 44.6 MB to 29.9 MB (−33%), and the build itself is 1.08× faster,ids_of1.05× (both implementations compiled into one extension and alternated A-B-A-B; four independent process pairs for the memory figure, which agreed to within 0.3 MB). -
No API change.
PyBackedStraccepts exactly whatStringdid; the observable contract — accepted types, rejected types and every error message — was diffed against a build of the previous code and is identical. This change left every serialised size untouched; the only size that moves in this release isPerfectHashIndex, from the arena change above. -
PerfectHashIndex.key/keys_ofno longer copy each key twice. Its keys live in an arena, sokeyreturns a&str; both methods then copied that into aStringonly for PyO3 to copy it again into a Pythonstrand drop it. They now build the Python string straight from the arena slice.keys_ofis 1.29× faster (250 -> 194 ns/key on the 479 823-word dictionary, A-B-A-B in one extension) and allocates nothing per key; the single-keykeyis 1.03×, the rest of its cost being the Python call itself.keys_ofstill runs its lookups underPython::detach— only the string construction, which needs the GIL either way, happens with it held. -
The Python bindings release the GIL (
Python::detach) around building, bulk queries (prefix/range/fuzzy/subsequence), batch lookups (ids_of/keys_of) and persistence (save/load/load_mmap/to_bytes/from_bytes), so a threaded caller keeps making progress instead of freezing the interpreter. Previously a background thread got 1 scheduler tick during a 268 ms build of the 479 823-word dictionary; it now runs throughout. -
Single-key accessors (
id,key,contains,id_unchecked,successor,predecessor,__len__) deliberately keep the GIL: they take well under a microsecond, so releasing and reacquiring it would cost more than the work it protects. Their code is untouched, and this change altered no serialised byte of any index. -
The minimum supported Rust version is now 1.85, declared as
rust-versioninCargo.tomland enforced by a CI job that derives its toolchain from that field, so the declaration cannot drift from what is actually built. The crate moved to edition 2024, whose floor is exactly 1.85;cargo fix --editionrequired no source changes in any feature configuration, so the only user-visible effect is the toolchain requirement itself. - Rust consumers on a toolchain older than 1.85 must upgrade —
cargowill refuse to build lexindex rather than fail obscurely. - Python users are unaffected. The published wheels are abi3 and carry no toolchain
requirement;
requires-pythonis unchanged at>=3.11.
[0.4.0] — 2026-07-06¶
Added¶
- Ordered navigation on
StringIndex—successor(query)(smallest key>=query) andpredecessor(query)(largest key<=query), eachO(query length)by seeking the FST (no scan), plus lazy iteration:for key, id in indexin RustStringIndex::iter()decodes one key per step by the rank-walk, so it never materialises the whole key set the wayprefix("")would. - Batched lookups —
ids_of(keys)andkeys_of(ids)onStringIndexandPerfectHashIndex, plusids_of(keys)onCompactHashIndex. Each loops in Rust and crosses the Python↔Rust boundary once instead of per key, so a bulkstring → id/id → stringmapping avoids the per-call FFI overhead. Returns a list aligned with the input,Nonewhere a key/id is absent. Namedids_of/keys_of(notkeys) so a class is never mistaken for a mapping —dict(index)builds{key: id}from the iterator instead. musllinux_1_2wheels (x86_64 + aarch64) for Alpine / musl-based containers, alongside the existing manylinux, macOS, and Windows wheels.- Scale benchmark (
bench/scale.py) measuring build time, peak memory, and lookup latency from 1M to 100M real keys.
Fixed¶
CompactHashIndex::from_bytesguards the fingerprint-table length check with a checked multiply, so a corrupt blob with a fabricated hugenfails cleanly instead of overflowingusize(a debug-build panic; release builds already wrapped to a clean error). Documented the trust boundary shared by both minimal-perfect-hash blobs:from_bytes/loadvalidate the lexindex framing but deserialise the embedded MPH viaepserde, which does not bound-check a corrupted MPH region — feed only blobs you produced (the same contract asload_mmap).StringIndexblobs are fully validated and unaffected.
Testing¶
- Property-based tests (
proptest, dev-dependency only): the rank-walkid ↔ keyround-trip over random prefix-nested and multibyte key sets; thePerfectHashIndexbijection onto[0, n);CompactHashIndexnever false-negatives a member; and everyfrom_bytesdeserialiser rejects arbitrary or (for lexindex-owned bytes) single-byte-flipped input cleanly — never panics or reads out of bounds. Line coverage rose to 97.0%.
[0.3.0] — 2026-07-05¶
Added¶
CompactHashIndex— the smalleststring → dense idmap, and smaller than any installable alternative. A minimal perfect hash (ptr_hash) plus ak-byte fingerprint per key, storing no keys at all. On the real/usr/share/dict/words(479 823 words) it serialises to 1.30 bytes/key atfingerprint_bytes=1and 2.30 at2— 2.3× smaller thanmarisa-trie(2.98) and far below every trie benchmarked. The trade-offs are explicit: membership is probabilistic (a non-member reads as present with probability256^-fingerprint_bytes— measured 0.36 % at 1 byte, 0.001 % at 2) and there is no reverseid → key(the keys are not stored). Reach for it when a fixed vocabulary's footprint is paramount and rare false positives are acceptable; usePerfectHashIndexfor exact membership + reverse, orStringIndexfor ordered/fuzzy queries. Exposed to Python asCompactHashIndex(items, fingerprint_bytes=1)withid/id_unchecked/contains/to_bytes/from_bytes/save/load/load_mmap; in Rust behind the defaultmphfeature.
Changed¶
StringIndexdropped its stored reverse map —id → keyis now reconstructed from the FST by a rank-walk. Each id is the key's rank, i.e. the FST's output, sokey(id)walks the automaton from the root, at each node taking the last transition whose accumulated output stays≤ id, and returns the path once the outputs sum to exactlyid(O(key length), no auxiliary structure). This deletes the front-coded reverse dictionary added in 0.2.0: the serialised blob is now just[magic][fst]. The effect on real-world size is large — on/usr/share/dict/wordstheStringIndexblob shrinks from 12.61 to 5.95 bytes/key (−53 %), because 0.2.0's front-coded map only reached its advertised "~6 B/key" on structured keys that share long prefixes, not on a natural vocabulary. Full prefix / range / fuzzy / subsequence are retained.- Breaking: the on-disk blob magic is now
BIX4;StringIndexblobs written by 0.1.x / 0.2.0 must be rebuilt.PerfectHashIndexblobs are unchanged. - Benchmarks are now measured on real English words, not a synthetic
entity-{i}catalog. Sequential structured keys collapse the FST to a near-regular automaton and report a misleading ~0 bytes/key;bench/compare.pyrefuses synthetic keys and comparessizeandbuildagainstmarisa-trie, DAWG and datrie on/usr/share/dict/words.
[0.2.0] — 2026-07-05¶
Added¶
- Zero-copy
load_mmaponStringIndexandPerfectHashIndex(new defaultmmapfeature, backed bymemmap2): memory-map a saved blob and borrow the index from the mapped pages instead of reading it into RAM, so a multi-gigabyte index loads instantly and its pages are shared across processes.StringIndexmaps the whole blob (FST + front-coded dictionary);PerfectHashIndexmaps the key arena (the bulk) and reads only the small MPH into memory. Exposed to Python asStringIndex.load_mmap/PerfectHashIndex.load_mmap. Reads are byte-wise (no alignment requirement); the mapped file must stay immutable while an index borrows it.--no-default-features(thefst-only build) omits it. - MkDocs documentation site at https://ilgrad.github.io/lexindex/ (Material + mkdocstrings API
reference), and a
mmap_zero_copyexample that times the ownedloadagainst the zero-copyload_mmap. - CI now enforces a 95% line-coverage floor (
cargo llvm-cov) on the Rust core.
Changed¶
StringIndex's reverse map (id → key) is now a front-coded string dictionary instead of a flat arena of raw bytes + one 8-byte offset per key. Because ids are the sorted rank, keys are stored sorted and delta-encoded against their bucket predecessor ((shared-prefix length, suffix), one pointer per 8-key bucket), so on a structured sorted catalog the serialisedStringIndexblob shrinks from ~27 to ~6 bytes/key — below the raw key bytes.PerfectHashIndex(unordered MPH slots, which cannot share prefixes) keeps the flat arena and is unchanged.- Breaking:
StringIndex::key(id)now returnsOption<String>(reconstructed on the fly) rather thanOption<&str>; the PythonStringIndex.keyis unaffected (still returnsstr | None). - Breaking: the on-disk blob magic is now
BIX2;StringIndexblobs written by 0.1.0 must be rebuilt (PerfectHashIndexblobs are unchanged).
[0.1.0] — 2026-06-28¶
First public release — compact, immutable string<->id indexes for huge catalogs; a standalone Rust +
Python library that also pairs with betula-cluster (map string ids to cluster ids and back).
Added¶
StringIndex— ordered, FST-backed index: exactstring <-> id, plus prefix, range, fuzzy (bounded Levenshtein edit distance), and subsequence iteration — all automaton-driven over the FST, never a full scan. Serialises to a flat, relocatable blob (save/load/to_bytes/from_bytes) with fully length- and offset-validated parsing (safe on untrusted input).PerfectHashIndex— minimal-perfect-hash dictionary (ptr_hash): verified-membershipid, a fasterid_uncheckedfor closed vocabularies (~1.25× faster thanstd::HashMapon point lookup), reverse lookup, and persistence (save/load) viaepserde, keyed on a version-stable hash (FNV-1a + splitmix64) so a serialised MPH reloads and queries identically on any build.- Python bindings (PyO3 abi3 extension, CPython 3.11+):
pip install lexindex, zero runtime dependencies, typed (py.typed+ stubs). - Feature gating —
mph(default) providesPerfectHashIndex(pullsptr_hash+epserde);--no-default-featuresis anfst-only build, free of the informational RustSec advisories on theptr_hashdependency tree.fst'slevenshteinis always on for fuzzy search. - Benchmark —
cargo run --release --example benchcompares both indexes againststd::HashMap/BTreeMap(build time, lookup latency, serialised size).