Changelog¶
All notable changes to this project are documented here. The format follows Keep a Changelog and the project adheres to Semantic Versioning.
[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).