Skip to content

Usage guide

Runnable snippets for every interface, in Python and Rust.

StringIndex — ordered, FST-backed

import lexindex

# Duplicates are removed and keys sorted; the id of a key is its rank in sorted order.
idx = lexindex.StringIndex(["banana", "apple", "apricot", "cherry", "apple"])

len(idx)                 # 4  (duplicate "apple" deduped)
idx.id("apple")          # 0
idx.id("missing")        # None
"cherry" in idx          # True
idx.key(2)               # "banana"  (id → string)

# ordered iteration — automaton-driven, never a full scan
idx.prefix("ap")         # [("apple", 0), ("apricot", 1)]
idx.range("apricot", "cherry")   # [("apricot", 1), ("banana", 2)]  — [lo, hi)
idx.successor("ba")      # ("banana", 2)   — smallest key ≥ query
idx.predecessor("ba")    # ("apricot", 1)  — largest key ≤ query
idx.fuzzy("aple", 1)     # [("apple", 0)]  — Levenshtein edit distance ≤ 1
idx.subsequence("ae")    # [("apple", 0)]  — "a…e" in order, not necessarily contiguous

# lazy iteration in sorted (= id) order — decodes one key at a time, never builds a giant list
list(idx)                # [("apple", 0), ("apricot", 1), ("banana", 2), ("cherry", 3)]
dict(idx)                # {"apple": 0, "apricot": 1, "banana": 2, "cherry": 3}

# batched lookups — one Rust↔Python crossing instead of one per key (named ids_of / keys_of so the
# class is never mistaken for a mapping)
idx.ids_of(["banana", "x"])   # [2, None]
idx.keys_of([0, 2])           # ["apple", "banana"]

Persistence and zero-copy loading

idx.save("catalog.bix")                            # write a flat, relocatable blob
idx = lexindex.StringIndex.load("catalog.bix")     # read it back into RAM
idx = lexindex.StringIndex.load_mmap("catalog.bix") # …or memory-map it: no read, borrowed zero-copy

data = idx.to_bytes()                              # or go through bytes directly
idx = lexindex.StringIndex.from_bytes(data)

load_mmap maps the file and borrows the index from the mapped pages, so load time is independent of the index size and the pages are shared across processes. The mapped file must stay immutable while an index borrows it. Loading an untrusted / truncated blob fails cleanly (ValueError), never corrupts.

PerfectHashIndex — fastest exact lookup

from lexindex import PerfectHashIndex

dict_ = PerfectHashIndex(["GET", "POST", "PUT", "DELETE"])
i = dict_.id("POST")           # dense id in [0, n); membership is verified against the stored key
dict_.key(i)                   # "POST"
dict_.id("PATCH")              # None  — a verified miss, not a hash collision
dict_.id_unchecked("GET")      # fastest lookup; skips verification (closed-vocabulary hot path)

dict_.save("verbs.bmp")
dict_ = PerfectHashIndex.load_mmap("verbs.bmp")   # arena mapped zero-copy; tiny MPH read into RAM

Use id_unchecked only for a fixed / closed vocabulary where membership is already guaranteed — it returns an arbitrary (but valid) slot for an unknown key. Use id everywhere else.

CompactHashIndex — smallest footprint

from lexindex import CompactHashIndex

# fingerprint_bytes ∈ {1, 2, 4}: 1 is smallest (~1.3 B/key) with a ~0.4% membership false-positive
# rate; 2 → ~0.0015%; 4 → effectively exact. No keys are stored, so there is no id → key.
dict_ = CompactHashIndex(["GET", "POST", "PUT", "DELETE"], fingerprint_bytes=1)
i = dict_.id("POST")           # dense id in [0, n); a non-member may rarely read as present
dict_.contains("GET")          # True
dict_.id_unchecked("GET")      # fastest lookup; no fingerprint check (closed-vocabulary hot path)

dict_.save("verbs.bch")
dict_ = CompactHashIndex.load_mmap("verbs.bch")   # fingerprint table mapped zero-copy

CompactHashIndex trades exactness for size: membership is correct except for a 256^-fingerprint_bytes false-positive chance on a non-member, and it cannot map an id back to a string. Reach for it when a fixed vocabulary's on-disk / mmap footprint dominates; use PerfectHashIndex when you need exact membership or id → key, or StringIndex when you need order or fuzzy/prefix.

Rust

use lexindex::{CompactHashIndex, PerfectHashIndex, StringIndex};

let idx = StringIndex::build(["apple", "apricot", "banana"])?;
assert_eq!(idx.id("banana"), Some(2));
assert_eq!(idx.key(0).as_deref(), Some("apple")); // rank-walk over the FST → owned String
assert_eq!(idx.prefix("ap").len(), 2);
let near: Vec<_> = idx.fuzzy("aple", 1)?.into_iter().map(|(k, _)| k).collect();
assert_eq!(near, ["apple"]);

idx.save("catalog.bix")?;
let idx = StringIndex::load_mmap("catalog.bix")?; // zero-copy; no read into RAM

let dict = PerfectHashIndex::build(["GET", "POST", "PUT"])?; // requires the default `mph` feature
assert_eq!(dict.key(dict.id("POST").unwrap()), Some("POST")); // exact reverse lookup

let small = CompactHashIndex::build(["GET", "POST", "PUT"], 1)?; // smallest; ~1.3 B/key, no reverse
assert!(small.contains("POST"));
# std::fs::remove_file("catalog.bix").ok();
# Ok::<(), lexindex::IndexError>(())

Cargo features: mph (default) adds PerfectHashIndex and CompactHashIndex; mmap (default) adds load_mmap; --no-default-features is an fst-only build (StringIndex only, no extra dependencies).

Benchmark

python bench/compare.py measures serialised size on real dictionary words against marisa-trie, DAWG and datrie (the double-crown table above). python bench/scale.py measures build time, peak memory, and lookup latency from 1 M to 100 M real keys. cargo run --release --example bench measures point-lookup latency for StringIndex and PerfectHashIndex against std::HashMap / BTreeMap; cargo run --release --example mmap_zero_copy times the owned load against the zero-copy load_mmap.