{"slug": "one-trie-three-jobs-zero-benchmarks-won", "title": "One trie, three jobs, zero benchmarks won", "summary": "Developer Andreas Åkesson open-sourced wordtree, a compact trie for word lists that performs exact lookup, typo-tolerant autocomplete, and browsable indexing from a single zero-copy, memory-mapped file, despite losing to specialist crates in every micro-benchmark. The 8-byte-per-node structure loads a 21 MiB English dictionary with one mmap call and keeps live heap equal to serialized size at 21.11 MiB, prioritizing startup time and memory over raw speed for use in a translation app.", "body_md": "# One trie, three jobs, zero benchmarks won\n\n*I dusted off an old project of mine and, with the help of AI, freshened it up\nand made some improvements. This article is written with the help of AI too, but\nit's my project, my design, and I can explain every line.*\n\nI recently open-sourced [wordtree](https://github.com/akesson/wordtree), a compact\ntrie for word lists. First of all, here is what it is *not*: it\nis not the fastest at anything. I benchmarked it against a specialist crate for\neach job it does, and each specialist beat it in its own domain. Exact\nlookup is slower than a `HashMap`\n\n. The file is three times larger than an FST.\nSpelling correction is an order of magnitude slower than symspell.\n\nSo why did I do it? Because I needed it! A structure that loses every micro-benchmark can still be the right dependency.\n\nThe whole comparative study is reproducible — every number below comes from\n[comparisons/REPORT.md](https://github.com/akesson/wordtree/blob/main/comparisons/REPORT.md), regenerable with four `cargo`\n\ncommands against the word lists bundled in the repo.\n\n## Three jobs\n\nwordtree came out of a translation app that needed three things from one big word list, all at once, on devices where startup time and memory both mattered:\n\n**A browsable index.** Group the words into folders (~100 per folder) so a UI can page through them.`path_of(\"apricot\")`\n\nreturns the folder path.**Exact lookup.** Resolve a word to the index of its expression in`O(word length)`\n\n.`index_of(\"apple\")`\n\n→`Some(1)`\n\n.**Typo-tolerant autocomplete.** Frequency-ranked, as-you-type suggestions that*both*extend a prefix (`\"ap\"`\n\n→`apple`\n\n,`apply`\n\n)*and*fix a single typo — substitution, transposition, insertion, or deletion at Damerau-Levenshtein distance ≤ 1 (`\"aple\"`\n\n→`apple`\n\n).`suggestions(\"aple\", …)`\n\n.\n\nEach of those jobs has a specialist crate that does it better. What almost\nnothing does is all three from *one* structure, from one file that loads with\nzero parsing. That last constraint is the whole story, so I'll start there.\n\n## The structure: 8 bytes a node\n\nThe tree is a width-first array of fixed-size nodes: a node is immediately followed by all its siblings, so \"next sibling\" is the next slot and \"first child\" is one 24-bit index.\n\nEach node is exactly 8 bytes:\n\n| field | bits | role |\n|---|---|---|\n`first_child_pos` | 24 | array index of the first child |\n`node_char` | 24 | UTF-32 codepoint (low 3 bytes) |\n`is_folder` | 1 | drives the browsable index |\n`is_last_sibling` | 1 | terminates a sibling run |\n`max_child_percentile` | 10 | best frequency in the subtree — drives top-k pruning |\n| (spare) | 4 |\n\nWhy fixed 8-byte records and not a tidy struct? Because the on-disk format *is*\nthe in-memory format. The tree serialises with [ rkyv](https://rkyv.org), and an\n\n`ArchivedTree`\n\nis queried directly out of an `mmap`\n\n: no parse, no rebuild, no\npointer fix-up. Loading a 21 MiB English dictionary is an `mmap`\n\ncall. For\nEnglish, `live heap == serialized == 21.11 MiB`\n\n: the bytes you store are the bytes\nyou query.That `max_child_percentile`\n\nfield earns its 10 inline bits because it is read on\n*every* node during a suggestion walk. It records the highest word frequency\nanywhere in the subtree below a node, which is exactly the lower bound a\n[pruning-radix-trie](https://seekstorm.com/blog/pruning-radix-trie/)\n(Wolf Garbe's design, which wordtree's pruning is modelled on) needs: if a\nsubtree's best possible frequency can't beat the current top-k, skip the whole\nsubtree. Top-k autocomplete then touches a tiny fraction of the tree.\n\n### Pushing the sparse data off-node\n\nA word needs two more values: its frequency (`percentile`\n\n, 0–1000) and the\n24-bit index of its expression. But only ~28% of nodes actually *end* a word;\nthe rest are interior characters. Storing\nthose 5 bytes inline would waste them on roughly three out of four nodes.\n\nSo they live in side tables instead, all part of the same zero-copy image:\n\n| table | size | role |\n|---|---|---|\n`word_bits` | 1 bit / node | is this node the end of a word? |\n`rank_index` | 1 × u32 / 64 nodes | cumulative word count → `rank(node)` in O(1) |\n`values` | 5 bytes / word | the `(percentile, expr_index)` pair |\n\nThe trick is the classic succinct-structure move: a word node at position `i`\n\nfinds its value at `values[rank(i)]`\n\n, where `rank(i)`\n\nis the number of word-nodes\nbefore it. The `word_bits`\n\nbitvector plus the cumulative `rank_index`\n\nanswer that\nrank query in O(1): popcount the partial 64-bit word, add the precomputed prefix\nsum. The bit probe sits on the hot descent path; the rank query only fires when a\nvalue is actually consumed (an exact lookup, or a suggestion you decided to keep).\n\nMoving those 5 bytes off-node took the node from 12 bytes to 8, which on English\ntrimmed the structure from ~26.5 MiB to ~21.1 MiB (about 20%) with no loss of\nfunction. It also made exact lookup ~10–20% *faster*, because more siblings now\nfit in a cache line and `index_of`\n\nscans siblings linearly. I don't often get\nsmaller and faster out of the same change.\n\n## Edit distance that rides down the trie\n\nThe third job is the interesting one. How do you find every word within Damerau-Levenshtein distance 1 of a typo, frequency-ranked, without scanning the dictionary? (A brute-force DL≤1 scan over English takes ~90–100 ms, far too slow for as-you-type.)\n\nThe answer is to compute the edit distance *incrementally as you walk the trie*.\nEach node carries one dynamic-programming row recording the edit distance between\nthe query and the word spelled by the path from the root to that node. The row at\na node is computed from its parent's row (and, for transposition, its\ngrandparent's). Conceptually, for a query of length `n`\n\n, the row holds\n\n```\nrow[j] = edit_distance(query[0..j], word spelled to this node)\n```\n\nand two quantities matter:\n\n`row[n]`\n\nis the distance from the*whole*query to this node's word. If the node ends a word and`row[n] ≤ K`\n\n, it's a correction.`min(row)`\n\nis the distance to the closest*prefix*, a lower bound on every word in the subtree below. Once`min(row) > K`\n\n, the entire subtree is pruned.\n\nSo the walk descends at most `K`\n\nlevels past the query length and, in practice,\nvisits well under 1% of the tree (0.3% on English, 0.8% on Swedish, measured\nover generated single-edit typos). The same walk also collects the nodes that\nthe completion sweep extends from, so one call returns corrections and\ncompletions together.\n\n### The bug that made me rewrite it\n\nI didn't start here. The first version used a hand-rolled 4-window state machine that tracked a few edit positions as it descended. It looked fine and passed my hand-written tests, and it was wrong.\n\nWhat exposed it was building the comparison harness, specifically a recall\ntable broken down by edit *kind*. Substitution and transposition: fine.\nDeletion: ~6–10% recall. Insertion: **0%**. The state machine, run over a\n*branching* trie rather than a single string, mis-scored mid-word insertions and\ndeletions and pruned the correct word away before it was ever reached. My\nautocomplete had been silently dropping every insertion typo and I had no idea\nuntil a table told me. If you want one reason to build a comparison harness\nbefore trusting your own tests, that's mine.\n\nThe DP-row-over-trie version replaced it and corrects all four single-edit kinds at 100% (more on that below).\n\n### The band: why each node costs three bytes, not `n`\n\nA full row of `n + 1`\n\nvalues per node would make every node cost O(query length).\nBut you don't need the full row. `row[j]`\n\nis at least `|j − depth|`\n\n— you need\nthat many indels just to reconcile the length difference between a depth-`depth`\n\nprefix and a `j`\n\n-character query prefix — so any cell with `|j − depth| > K`\n\nis\nalready `> K`\n\nand can never be a kept correction nor lower a surviving minimum.\n\nOnly the `2K + 1`\n\ncells in a diagonal **band** around `j = depth`\n\ncan ever matter.\nAt the default `K = 1`\n\nthat's **three cells**, stored as a fixed `[u8; 3]`\n\n.\n\nConcretely: the query is `cart`\n\n, the walk is descending root → `c`\n\n→ `ca`\n\n→\n`cat`\n\n, and each row is that node's `[u8; 3]`\n\n. The typo is an inserted `r`\n\n— the\nkind the state machine used to drop.\n\nShifting to band-local coordinates turns every DP neighbour into a *constant*\noffset, so the recurrence carries no per-cell column arithmetic at all:\n\n```\ncur[o] = min(prev[o+1] + 1,     // deletion       (word longer than query)\n             prev[o]   + cost,   // match / substitution\n             cur[o-1]  + 1,      // insertion      (word shorter than query)\n             pp[o]     + 1)      // transposition  (grandparent row)\n```\n\nBy Ukkonen's banding argument (Robert Jacobson has a [readable\nwalkthrough](https://www.robertjacobson.dev/posts/2024-12-02-edit-distance-optimizations/#limited-distance-variant-the-banded-algorithm)),\nthe optimal alignment to any cell whose true distance is ≤ K stays inside the\nband. So every value the search actually acts on is computed exactly, and **the\nkept corrections, their order, and the exact set of visited nodes are\nbit-identical to a full-row walk.** Out-of-band cells may be over-estimated, but\nthey stay `> K`\n\n, so no keep/prune decision changes. Banding changes only the\nper-node cost, O(K) instead of O(n), which is why longer queries gain the most: a\n14-character fuzzy query runs ~80% faster than the full-row walk; short typos\nroughly halve.\n\n(One Rust wrinkle: stable Rust can't size `[u8; 2*K + 1]`\n\nfrom a\n`K`\n\nparameter, so the band *width* `W`\n\nis the const generic and `K = (W − 1) / 2`\n\nis derived. Want distance-2 suggestions? Instantiate the search with `W = 5`\n\n.)\n\n## Losing every axis, on purpose\n\nNow the benchmarks. I picked the best specialist crate for each job and ran them\non the same word lists — [fst](https://crates.io/crates/fst) (BurntSushi's FSA),\n[symspell](https://crates.io/crates/symspell), Wolf Garbe's\n[pruning_radix_trie](https://crates.io/crates/pruning_radix_trie),\n[boomphf](https://crates.io/crates/boomphf) (minimal perfect hash), and plain\n`HashMap`\n\n/`Vec`\n\nbaselines. A correctness gate asserts every engine resolves a\nword to the *same* expression index before any timing is trusted.\n\n**Exact lookup (nanoseconds).** `wordtree`\n\nis the slowest of the bunch; it\nlinearly scans each node's siblings.\n\n| case (en) | wordtree | fst | boomphf | hashmap |\n|---|---|---|---|---|\nshort `on` | 74.3 | 15.3 | 14.1 | 8.1 |\nlong `alphanumerical` | 112.0 | 107.4 | 25.9 | 8.7 |\n\n`HashMap`\n\nwins outright at ~8 ns, flat. wordtree is ~9–13× slower. All are tens\nof nanoseconds in absolute terms, which is fine, but exact lookup is not a reason\nto pick wordtree.\n\n**Size.** The FST is the clear winner: it minimises shared prefixes *and*\nsuffixes (DAWG-like), doing exact lookup *and* spelling correction in ~3× less\nspace than wordtree does anything.\n\n| engine (en) | live heap | serialized |\n|---|---|---|\n| fst | 10.0 MiB | 6.7 MiB |\nwordtree | 21.1 MiB | 21.1 MiB |\n| sorted-vec | 25.1 MiB | — |\n| boomphf | 33.9 MiB | — |\n| hashmap | 38.7 MiB | — |\n| symspell | 300.4 MiB | — |\n\nSo: wordtree is the **smallest of the naive key-storing structures**, but still\n~3× *larger* than an FSA. Its \"size-optimised\" claim holds against a naive trie,\nnot against fst. (Also, wordtree's *build* peaks at ~224 MiB to produce 21 MiB,\nabout 11×, which matters if you generate trees on a constrained device.)\n\n**Spelling correction.** symspell is in another league on latency.\n\n| case (en) | wordtree | symspell | fst-lev | brute force |\n|---|---|---|---|---|\nsub `abxut` | 46.2 µs | 1.5 µs | 132.6 µs | 102.6 ms |\ndel `abut` | 49.3 µs | 8.4 µs | 129.6 µs | 91.5 ms |\n\nsymspell does a handful of hash lookups against a precomputed delete-dictionary;\nwordtree walks the trie. It's ~25–31× slower than symspell on a substitution\ntypo and ~6–10× slower on a deletion. (It is ~2.6–3.1× *faster* than fst's\nLevenshtein automaton, and corrects transpositions that fst misses entirely,\nbut symspell is the one to beat, and it wins.)\n\n**Autocomplete.** Closest race. The combined `suggestions()`\n\ncall runs the\nedit-distance walk every time, so it's the wrong thing to race against a pure\ncompleter (~43 µs). The autocomplete-only `completions()`\n\ncall skips the walk:\n\n| case (en) | wordtree `completions()` | pruning-trie |\n|---|---|---|\n`co` | 3.1 µs | 1.2 µs |\n\nRoughly 2–4× on English and 1–2.5× on Swedish, widening with the prefix's\nfan-out: the pruning trie stays flat at ~1–2 µs whatever the prefix, while\n`completions()`\n\nscales with how many descendants it sweeps. wordtree is\nslightly *ahead* on quality (recall@5 80% vs 74% on English, 96% vs 93% on\nSwedish), but on the latency axis, the one being raced, it's still a loss.\n\nSo on all four axes (lookup speed, size, correction latency, autocomplete latency) a specialist wins on its home turf. Hence the title.\n\n## The one place it doesn't lose: doing all of it from one file\n\nHere's the part the per-axis tables hide. Every alternative above does *one* job\n(boomphf, symspell, pruning-trie) or *two* (fst: lookup + correction). Picking\nspecialists means assembling three or four structures, three or four files, three\nor four load paths, and `HashMap`\n\n/symspell can't be memory-mapped at all, so they\nrebuild at startup.\n\nwordtree folds all three jobs into one structure that loads by `mmap`\n\nwith no\nparse or build step, and returns a deliberately short, frequency-ranked,\nsingle-edit-tolerant list. On correction quality it matches symspell:\n\n| correction recall by edit kind (en) | substitute | transpose | delete | insert |\n|---|---|---|---|---|\n| wordtree | 100% | 100% | 100% | 100% |\n| symspell | 100% | 100% | 100% | 100% |\n| fst-lev | 100% | 0% | 100% | 100% |\n\n(fst's `Levenshtein`\n\nis plain Levenshtein: a transposition costs 2, so it misses\nevery transposed typo at distance 1.) By default wordtree returns a small\nfrequency-capped top-k rather than the exhaustive DL≤1 set symspell gives you,\nthe right trade for an as-you-type box. For a batch spell-checker,\n`corrections_with(q, f, Caps::uniform(n))`\n\nlifts the cap and returns the complete\nset, at 100% of the brute-force oracle.\n\n## When to use it (and when not)\n\n- Need\n**only one** of these jobs, or the lowest latency, or the smallest file? Use the specialist. fst for lookup + fuzzy in minimal space; symspell for exhaustive correction; pruning_radix_trie for pure autocomplete; a`HashMap`\n\nfor raw lookup speed. - Need a\n**browsable index + frequency + typo-tolerant autocomplete from one mmap-able file**, with a short ranked suggestion list and no startup cost? Then one 21 MiB file you`mmap`\n\nand query three ways is a reasonable single dependency, which is the spot wordtree was built for.\n\nThe repo is a snapshot extracted from a private project to accompany this post, not a crate I'm asking you to depend on. But the comparison harness is real and reproducible, the edit-distance walk is worth reading, and the lesson is one I keep relearning: benchmark against the specialists, expect to lose, and find out whether the thing you're actually optimising for (here, three jobs in one zero-copy file) is even on the axis you're measuring. Usually it isn't.\n\n*Reproduce everything:*\n\n```\ncargo run -p comparisons --bin quality --release   # quality tables\ncargo run -p comparisons --bin size    --release   # size + RAM\ncargo bench -p comparisons                         # latency\ncargo test  -p comparisons                         # correctness gate\n```\n\n*Numbers are from one Apple M-series machine; treat them as ratios, not\nabsolutes. Word lists are derived from PanLex and Wiktionary (en 638,545 words,\nsv 113,220).*", "url": "https://wpnews.pro/news/one-trie-three-jobs-zero-benchmarks-won", "canonical_source": "https://akesson.io/wordtree/", "published_at": "2026-09-01 08:41:40+00:00", "updated_at": "2026-09-01 08:56:15.114445+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Andreas Åkesson", "wordtree", "Wolf Garbe", "rkyv"], "alternates": {"html": "https://wpnews.pro/news/one-trie-three-jobs-zero-benchmarks-won", "markdown": "https://wpnews.pro/news/one-trie-three-jobs-zero-benchmarks-won.md", "text": "https://wpnews.pro/news/one-trie-three-jobs-zero-benchmarks-won.txt", "jsonld": "https://wpnews.pro/news/one-trie-three-jobs-zero-benchmarks-won.jsonld"}}