A Pedagogical Guide to Finding Spelling Candidates
When a user types "fone", we want to suggest "phone". When they type "elefant", we want to suggest "elephant". This seems simple, but the underlying algorithms are surprisingly elegant.
The key insight is that spelling errors come in two flavors:
A robust spell checker must handle both. This guide explains how to combine two powerful automata:
Implementation Status: Approach 1 (PhoneticNormalizedDictionary) is fully implemented in
src/dictionary/phonetic_normalized.rs. Approach 3 (Product Automaton) is implemented insrc/phonetic/nfa/product.rs. Approach 2 (Pre-process Dictionary Only) is documented but not yet implemented.
A finite automaton is a simple computing device that reads input one character at a time and decides whether to accept or reject the input.
Example: An automaton that accepts "cat"
┌───┐ c ┌───┐ a ┌───┐ t ╔═══╗
│ 0 │─────▶│ 1 │─────▶│ 2 │─────▶║ 3 ║
└───┘ └───┘ └───┘ ╚═══╝
start accept
State 0: Haven't seen anything yet
State 1: Saw "c"
State 2: Saw "ca"
State 3: Saw "cat" - ACCEPT!
If we're in state 2 and see "t", we move to state 3 (accepting). If we see anything else, we fail.
A Deterministic Finite Automaton (DFA) has exactly one next state for each input character. A Non-deterministic Finite Automaton (NFA) can have:
\varepsilon$) transitions: Move without consuming inputExample: NFA that accepts "phone" OR "fone"
┌───┐ p ┌───┐ h ┌───┐ o ┌───┐ n ┌───┐ e ╔═══╗
│ 0 │─────▶│ 1 │─────▶│ 2 │─────▶│ 3 │─────▶│ 4 │─────▶║ 5 ║
└───┘ └───┘ └───┘ └───┘ └───┘ ╚═══╝
│ ▲
│ f ┌───┐ ε │
└─────▶│ 6 │──────────┘
└───┘
State 0: Start - can take 'p' OR 'f' path
States 1,2: The "ph" path (consuming 'p' then 'h')
State 6: The "f" path (ε-transitions to state 2)
State 2: Implicit merge point (both paths converge here)
States 3,4,5: Shared "one" suffix (5 is accepting)
From state 0, we can try BOTH paths simultaneously!
NFAs are perfect for representing phonetic patterns because sounds can be spelled multiple ways.
Phonetic rules express sound equivalences:
ph → f "phone" sounds like "fone"
c → s / _[ei] "c" before "e" or "i" sounds like "s" (like "cent" → "sent")
tion → shun "nation" sounds like "nashun"
Each rule has:
The Levenshtein distance (edit distance) between two strings is the minimum number of single-character edits to transform one into the other.
Edit operations:
Example: "kitten" → "sitting" (distance = 3)
kitten
sitten (substitution: k → s)
sittin (substitution: e → i)
sitting (insertion: g)
A Levenshtein automaton accepts all strings within edit distance n of a query word. Rather than computing distance for every dictionary word, we build an automaton once and intersect it with the dictionary.
Query: "cat", max distance: 1
The automaton accepts:
- "cat" (distance 0)
- "cats", "scat" (1 insertion)
- "at", "ca" (1 deletion)
- "bat", "cot", "cap" (1 substitution)
Now we understand the building blocks. How do we combine phonetic patterns with typo tolerance?
We have two automata:
We want to accept strings that satisfy both conditions (with some combination of costs).
There are three ways to compose them, depending on when we apply phonetic transformations:
| Approach | Pre-process Query? | Pre-process Dictionary? | When to Use |
|---|---|---|---|
| 1 | Yes | Yes | Maximum speed, less precision |
| 2 | No | Yes | Balance of speed and precision |
| 3 | No | No | Maximum precision, runtime flexibility |
Imagine you're organizing a phone book by pronunciation rather than spelling. All words that sound alike go together:
Pronunciation "fon":
- phone
- fone
- phon
Pronunciation "elefant":
- elephant
At query time, normalize the query to its pronunciation, then look up matches.
Build Time (do once for the dictionary):
Step 1: Define phonetic normalization rules
ph → f
ough → o
tion → shun
silent e at end → (remove)
...
Step 2: Normalize every dictionary word
"phone" → normalize → "fon"
"elephant" → normalize → "elefant"
"knight" → normalize → "nit"
"night" → normalize → "nit" ← Same as knight!
Step 3: Build a trie indexed by normalized form
Store: normalized_form → [original_word1, original_word2, ...]
"fon" → ["phone", "phon"]
"nit" → ["knight", "night", "nit"]
Query Time (do for each search):
User types: "fone"
Step 1: Normalize the query
"fone" → normalize → "fon"
Step 2: Levenshtein search in normalized trie
Find all normalized forms within distance n of "fon"
Matches: "fon" (distance 0)
Step 3: Map back to original words
"fon" → ["phone", "phon"]
Step 4: Return candidates
"phone", "phon"
Dictionary: ["phone", "elephant", "elegance", "phony", "foe", "bone"]
Rules:
ph → f
silent e → (remove)
Build normalized index:
"fon" → ["phone"]
"elefant" → ["elephant"]
"elegans" → ["elegance"]
"foni" → ["phony"]
"fo" → ["foe"]
"bon" → ["bone"]
Query: "fone" (user meant "phone" but typed phonetically)
Step 1: normalize("fone") = "fon"
Step 2: Levenshtein search for "fon" with distance 1
Matches: "fon" (d=0), "fo" (d=1), "bon" (d=1), "foni" (d=1)
Step 3: Map back:
"fon" → "phone" (best match!)
"fo" → "foe" (1 typo)
"bon" → "bone" (1 typo)
"foni" → "phony" (1 typo)
Advantages:
Disadvantages:
class PhoneticNormalizedDictionary:
"""
Uses a FuzzyMultiMap (trie-based multimap with Levenshtein search) to store
normalized forms mapped to their original terms. This is more efficient
than maintaining a separate trie + hashmap because:
1. Unified structure - no separate data structures to synchronize
2. Memory efficient - common prefixes in normalized forms share storage
3. Built-in fuzzy search - FuzzyMultiMap already supports Levenshtein lookup
4. Native multimap - multiple originals per normalized form handled naturally
"""
def __init__(self, terms, rules):
self.rules = rules
# FuzzyMultiMap stores: normalized_form → [original_terms]
# Uses trie structure internally for efficient prefix sharing
# Multimap semantics: same key can have multiple values
self.normalized_map = FuzzyMultiMap()
for term in terms:
normalized = self.normalize(term)
# FuzzyMultiMap handles multiple values per key natively
self.normalized_map.insert(normalized, term)
def normalize(self, word):
"""Apply rules until fixed point"""
while True:
new_word = apply_rules_once(word, self.rules)
if new_word == word:
return word
word = new_word
def query(self, query, max_distance):
"""Find candidates for misspelled query"""
normalized_query = self.normalize(query)
# FuzzyMultiMap.fuzzy_get returns (key, distance, values) tuples
# The trie structure enables efficient Levenshtein search
# while the associated values give us the original terms directly
candidates = []
for norm_form, distance, originals in self.normalized_map.fuzzy_get(
normalized_query, max_distance
):
for original in originals:
candidates.append(Candidate(term=original, distance=distance))
return candidates
Why FuzzyMultiMap instead of HashMap + Trie?
The naive approach uses two separate structures:
Trie for storing normalized forms and enabling Levenshtein searchHashMap<String, Vec<String>> for mapping normalized forms back to originalsUsing liblevenshtein's FuzzyMultiMap (a trie-based multimap) combines both:
This eliminates redundancy and keeps the implementation consistent with liblevenshtein's existing patterns.
The PhoneticNormalizedDictionary in src/dictionary/phonetic_normalized.rs
implements this approach. Here's how to use it:
use liblevenshtein::prelude::*;
// Create dictionary with default Zompist rules
let dict = PhoneticNormalizedDictionary::from_terms([
"phone", "fone", "elephant", "elegance"
]);
// Query - "fone" normalizes to same as "phone"
let results = dict.query("fone", 0);
// Returns both "phone" and "fone"
// With edit distance tolerance
let results = dict.query("elefant", 1);
// Returns "elephant"
// Regex query against normalized forms
let results = dict.query_regex("(f|b)on", 0)?;
// Returns terms whose normalized forms match pattern
// Auto-expand query to match phonetic variants
let results = dict.query_phonetic_pattern("fone", 1)?;
// Expands "fone" to "(ph|f)one" pattern, matches variants
The implementation provides:
from_terms() / from_terms_with_rules() - construct from term iteratorquery() - fuzzy search with Levenshtein tolerancequery_regex() - grep-like pattern matching against normalized formsquery_phonetic_pattern() - auto-expand queries to match phonetic variantsnormalize() - get the normalized form of any termInstead of collapsing words to one pronunciation, expand each word to all possible spellings:
"phone" → ["phone", "fone", "phon", "fon", ...]
Now we can search for the user's exact input among all variants.
This preserves the distinction between variants while still doing heavy work at build time.
Build Time:
Step 1: For each dictionary word, enumerate ALL phonetic variants
"phone" → rule ph→f → "fone"
→ rule silent e → "phon"
→ both rules → "fon"
Variants of "phone": {"phone", "fone", "phon", "fon"}
Step 2: Build trie of all variants
Store: variant → (original_word, phonetic_cost)
"phone" → ("phone", cost=0.0)
"fone" → ("phone", cost=0.1) # ph→f costs 0.1
"phon" → ("phone", cost=0.1) # silent e costs 0.1
"fon" → ("phone", cost=0.2) # both transformations
Query Time:
User types: "fone"
Step 1: Search for "fone" in variant trie (exact or with Levenshtein)
Found: "fone" → ("phone", phonetic_cost=0.1)
Step 2: Return with combined cost
Candidate: "phone"
- Phonetic cost: 0.1 (for ph→f transformation)
- Edit distance: 0 (exact match to variant)
- Total: 0.1
Dictionary: ["phone", "cat"]
Rules (with costs):
ph → f (cost 0.1)
c → k (cost 0.1)
silent e → (remove) (cost 0.05)
Expand dictionary:
From "phone":
"phone" (cost 0.0)
"fone" (cost 0.1) ← ph→f
"phon" (cost 0.05) ← drop e
"fon" (cost 0.15) ← ph→f + drop e
From "cat":
"cat" (cost 0.0)
"kat" (cost 0.1) ← c→k
Query: "fon" with max_distance=1
Step 1: Search variant trie for "fon"
Exact: "fon" → ("phone", phonetic=0.15, edit=0)
Step 2: Also check Levenshtein neighbors
"fo" → no match
"kon" → no match
"fan" → no match
"fone" → ("phone", phonetic=0.1, edit=1) ← insertion of 'e'
Step 3: Combine and rank
"phone" via "fon": total = 0.15 + 0 = 0.15 ✓ best
"phone" via "fone": total = 0.1 + 1 = 1.1
Some words have many variants. "through" with common rules might generate dozens. We must limit expansion:
def expand_with_limit(word, rules, max_variants=100):
"""Enumerate variants with combinatorial explosion protection"""
variants = {word: 0.0} # variant → cost
queue = [(word, 0.0)]
while queue and len(variants) < max_variants:
current, cost = queue.pop(0)
for rule in rules:
if rule.matches(current):
new_variant = rule.apply(current)
new_cost = cost + rule.weight
if new_variant not in variants or variants[new_variant] > new_cost:
variants[new_variant] = new_cost
queue.append((new_variant, new_cost))
return variants
Advantages:
Disadvantages:
class PhoneticExpandedDictionary:
def __init__(self, terms, rules, max_variants_per_term=100):
self.variant_trie = Trie()
self.variant_to_original = {} # variant → [(original, cost), ...]
for term in terms:
variants = expand_with_limit(term, rules, max_variants_per_term)
for variant, phonetic_cost in variants.items():
if variant not in self.variant_to_original:
self.variant_to_original[variant] = []
self.variant_trie.insert(variant)
self.variant_to_original[variant].append((term, phonetic_cost))
def query(self, query, max_distance):
"""Find candidates with cost decomposition"""
results = []
# Levenshtein search in variant trie
matches = levenshtein_search(self.variant_trie, query, max_distance)
for variant, edit_distance in matches:
for original, phonetic_cost in self.variant_to_original[variant]:
total_cost = edit_distance + phonetic_cost
results.append(Candidate(
term=original,
edit_distance=edit_distance,
phonetic_cost=phonetic_cost,
total_cost=total_cost
))
# Deduplicate: keep best path to each original term
return deduplicate_by_original(results)
What if we could search the dictionary while simultaneously considering both phonetic patterns and edit operations?
The product automaton does exactly this. We compose:
The result is a single automaton whose states track both where we are in the phonetic pattern and how many errors we've used.
A state in the product automaton is a pair:
ProductState = (S_nfa, d)
Where:
S_nfa = set of active NFA states (which phonetic paths are alive)
d = edit distance consumed so far
Example:
Query pattern: "(ph|f)one" (matches "phone" or "fone")
Max distance: 1
Initial state: ({0}, 0)
- NFA is at start (state 0)
- Zero errors used
After seeing 'p':
- Path 1: ({1}, 0) ← NFA matched 'p', going toward "phone"
- Path 2: ({0}, 1) ← NFA stayed (insertion error), still at start
After seeing 'h' from Path 1:
- ({2}, 0) ← NFA matched 'h', now at "ph"
And so on...
When processing dictionary character c from state (S, d):
If NFA can transition on 'c':
S' = nfa_step(S, c) ← advance NFA
d' = d ← same distance
This is a perfect match - the dictionary has what the pattern expects.
If d < max_distance:
S' = nfa_advance(S) ← advance NFA by any edge
d' = d + 1 ← pay for the mismatch
Dictionary has 'c', but NFA expected something else.
We pretend NFA got what it wanted, costing 1 edit.
If d < max_distance:
S' = S ← NFA stays put
d' = d + 1 ← pay for extra dictionary character
Dictionary has an extra character the pattern doesn't expect.
Consume 'c' without advancing the NFA.
If d < max_distance:
S' = nfa_advance(S) ← NFA advances
d' = d + 1 ← pay for missing dictionary character
Pattern expects a character the dictionary doesn't have.
Advance NFA without consuming dictionary character.
If d < max_distance AND next dictionary char exists:
Look at c and c_next (adjacent characters)
Try matching pattern "xy" against dictionary "yx"
S' = nfa_step(nfa_step(S, c_next), c) ← swapped order
d' = d + 1
Consume BOTH c and c_next
Handles common typos like "teh" → "the".
If d < max_distance AND next dictionary char exists:
Two dictionary chars → one pattern transition
S' = nfa_advance(S) ← one NFA step
d' = d + 1
Consume BOTH dictionary chars
Handles OCR errors like "rn" being scanned as "m".
If d < max_distance:
One dictionary char → two pattern transitions
S' = nfa_advance(nfa_advance(S)) ← two NFA steps
d' = d + 1
Consume ONE dictionary char
Handles cases like "ä" expanding to "ae".
The seven transition types above all use fixed costs (1.0 for each edit operation). However, not all substitutions are equally likely. A user who types "b" instead of "p" probably misheard or mistyped a similar sound, while "h" instead of "p" is less likely.
Articulatory distance provides gradient substitution costs based on IPA phonetic features:
| Substitution | Phonetic Difference | Cost |
|---|---|---|
| p → b | Voicing only | ~0.1 |
| p → t | Adjacent place | ~0.45 |
| p → k | Distant place | ~1.0 |
| p → h | Place + manner | ~1.0 |
To enable articulatory-weighted substitutions, use ProductAutomatonChar::with_articulatory_costs():
use liblevenshtein::phonetic::nfa::compiler::compile;
use liblevenshtein::phonetic::nfa::product::ProductAutomatonChar;
use liblevenshtein::phonetic::regex::parse;
use liblevenshtein::transducer::{Algorithm, ArticulatoryCosts};
// Compile phonetic pattern
let nfa = compile(&parse("(ph|f)one").unwrap()).unwrap();
// Create articulatory cost configuration
let costs = ArticulatoryCosts::default();
// Create product automaton with articulatory costs
let product = ProductAutomatonChar::with_articulatory_costs(
nfa,
2.0, // max accumulated cost
Algorithm::Standard,
costs,
);
// "bone" now accepted because b→f costs only ~0.1 (voicing)
assert!(product.accepts("bone"));
// "hone" likely rejected because h→f costs ~1.0 (place + manner)
// depending on max_cost threshold
Key insight: With articulatory costs, the product automaton uses the query character and pattern character at each substitution transition to compute a phonetically-informed cost. This improves ranking quality for phonetic spell correction.
For detailed documentation on articulatory distance computation and configuration, see Articulatory Distance Guide.
Let $a$ be the query symbol and $b$ the pattern symbol at a substitution transition. Define:
\beta_{\mathrm{sub}}$ — the base substitution cost (the unit cost of a plain edit; $1.0$ by default),w \in [0, 1]$ — the articulation weight (how much phonetic similarity is allowed to discount an edit),d_{\mathrm{art}}(a, b) \in [0, 1]$ — the articulatory distance, a feature-space (IPA) distance between the two sounds ($0$ = identical articulation, $1$ = maximally different).The articulatory substitution cost blends the base cost with the feature distance:
c_{\mathrm{sub}}(a, b) = \beta_{\mathrm{sub}}\,(1 - w) \;+\; d_{\mathrm{art}}(a, b)\,w, \qquad c_{\mathrm{sub}}(a, b) \in [0,\ \beta_{\mathrm{sub}}].
Because $c_{\mathrm{sub}} \le \beta_{\mathrm{sub}}$ and every other edit operation costs $\beta_{\mathrm{sub}} = 1$, a substitution is only ever discounted — never inflated — relative to the unit-cost model. (Near-identical sounds below a free-substitution threshold are discounted almost to zero.)
min_costThe integer acceptance test (accepts) and the integer edit distance (min_distance) both count operations uniformly. To surface the articulatory cost we need the least-cost alignment, computed by ProductAutomatonChar::min_cost — the fractional twin of min_distance:
\mathrm{min\_cost}(q) \;=\; \min_{\text{alignments } \pi \text{ of } q}\ \sum_{i} c_{\mathrm{op}_i}(\pi),
a uniform-cost (Dijkstra) search over product states $(\text{position},\ \text{NFA state set})$ where every edit edge carries a non-negative cost, so the first accepting configuration popped from the min-priority frontier is provably optimal. Since each operation's cost is $\le$ its unit-cost counterpart,
\mathrm{min\_cost}(q) \;\le\; \mathrm{min\_distance}(q).
PhoneticTransducerChar::with_articulatory_costs builds the query on the articulatory product and reports, for every candidate:
edit_distance $= \mathrm{min\_distance}(q)$ — the integer operation count,total_cost $= \mathrm{min\_cost}(q)$ — the articulatory-weighted alignment cost (the ranking key),phonetic_cost $= \mathrm{min\_cost}(q) - \mathrm{min\_distance}(q) \le 0$ — the articulatory discount a sound-alike alignment earns, so that the candidate constructor's invariant total_cost = edit_distance + phonetic_cost holds.For an exact match, $\mathrm{min\_cost} = \mathrm{min\_distance} = 0$, so phonetic_cost is $0.0$; the default (non-articulatory) path leaves phonetic_cost at $0.0$ for every candidate, preserving byte-for-byte backward compatibility.
use libdictenstein::double_array_trie::char::DoubleArrayTrieChar;
use liblevenshtein::phonetic::nfa::compile;
use liblevenshtein::phonetic::regex::parse;
use liblevenshtein::transducer::{ArticulatoryCosts, PhoneticTransducerChar};
// Pattern "pat"; every dictionary term is one substitution away.
let dict = DoubleArrayTrieChar::from_terms(["pat", "bat", "cat"]);
let nfa = compile(&parse("pat").unwrap()).unwrap();
let transducer =
PhoneticTransducerChar::with_articulatory_costs(dict, nfa, 1, ArticulatoryCosts::default());
for c in transducer.query_sorted("pat") {
println!("{:<4} edit={} phonetic={:+.2} total={:.2}",
c.term, c.edit_distance, c.phonetic_cost, c.total_cost);
}
// pat edit=0 phonetic=+0.00 total=0.00
// bat edit=1 phonetic=-0.99 total=0.01 ← p→b (voiced/voiceless) : near-free
// cat edit=1 phonetic=-0.06 total=0.94 ← p→c (distant) : nearly a full edit
Both bat and cat are edit distance $1$, yet the articulatory total_cost ranks the sound-alike bat ($0.01$) far ahead of the phonetically distant cat ($0.94$) — exactly the behaviour a phonetic corrector wants.
Byte-level note. Articulatory features are defined over phonemes (
chars), and the byte NFA's transition label exposes no "expected byte" to weight against, so byte-level phonetic matching (PhoneticTransducer,PhoneticCandidateByte) is integer edit distance only:phonetic_costis always $0.0$ there. Use the character-level transducer for the articulatory path.
When the dictionary is a MappedDictionary (a term → value map, e.g. term → term-id), query_values yields each match's stored value alongside its costs, so a lexical corrector can emit (term_id, cost) in one pass with no string round-trip:
// term → term-id vocabulary
let dict = DoubleArrayTrieChar::from_terms_with_values([("phone", 100u64), ("phones", 200u64)]);
let transducer = PhoneticTransducerChar::new(dict, compile(&parse("phone").unwrap()).unwrap(), 1);
for c in transducer.query_values("phone") {
// c.value is the stored term-id; c.total_cost carries any articulatory weighting
println!("{} -> id {} (distance {})", c.term, c.value, c.edit_distance);
}
// phone -> id 100 (distance 0)
// phones -> id 200 (distance 1)
This is the phonetic half of the upstream contract for word-level correction: $T_{\mathrm{lex}}$ maps a token's characters to candidate term-ids with a combined edit + phonetic cost. The word-level half — correcting sequences of term-ids — is covered in Chapter 8.
Let's trace through a complete example.
Query pattern: "(ph|f)one" (NFA accepts "phone" or "fone")
Max distance: 1
Dictionary contains: "phone", "fone", "bone", "cone"
NFA structure:
(0)──p──▶(1)──h──▶(2)──o──▶(3)──n──▶(4)──e──▶((5))
│ ▲
└───f──▶(6)───ε───┘
State 0: start
States 1,2: "ph" path
State 6: "f" path (ε-transitions to state 2)
State 2: implicit merge point (both paths converge here)
States 3,4,5: shared "one" suffix (5 is accepting)
---
Searching for "phone" in dictionary:
Step 0: Initial
ProductState = ({0}, 0)
Queue: [({0}, 0, "")]
Step 1: See 'p'
From ({0}, 0):
Match 'p': NFA 0→1, so ({1}, 0, "p") ✓
Insert: ({0}, 1, "p") ✓
Queue: [({1}, 0, "p"), ({0}, 1, "p")]
Step 2: See 'h' (continuing best path)
From ({1}, 0):
Match 'h': NFA 1→2, so ({2}, 0, "ph") ✓
From ({0}, 1):
Can't match 'h' from state 0, only 'p' or 'f'
Subst: ({1 or 6}, 2) but d=2 > max, prune ✗
Step 3: See 'o'
From ({2}, 0):
Match 'o': NFA 2→3, so ({3}, 0, "pho") ✓
Step 4: See 'n'
From ({3}, 0):
Match 'n': NFA 3→4, so ({4}, 0, "phon") ✓
Step 5: See 'e'
From ({4}, 0):
Match 'e': NFA 4→5, so ({5}, 0, "phone") ✓
State 5 is accepting!
Result: "phone" matches with distance 0
---
Searching for "bone" in dictionary:
Step 0: Initial
ProductState = ({0}, 0)
Step 1: See 'b'
From ({0}, 0):
Can't match 'b' (NFA wants 'p' or 'f')
Subst 'b' for 'p': ({1}, 1) ✓
Subst 'b' for 'f': ({6}, 1) ✓
Insert 'b': ({0}, 1) ✓
Step 2: See 'o'
From ({1}, 1):
NFA 1 needs 'h', got 'o'
Subst: ({2}, 2) but d=2 > max, prune ✗
From ({6}, 1):
NFA 6 → 2 on ε, then 2→3 on 'o'
Actually: from state 6, ε→2, then need 'o'
Match 'o': ({3}, 1) ✓
From ({0}, 1):
Insert: ({0}, 2) prune ✗
Step 3: See 'n'
From ({3}, 1):
Match 'n': ({4}, 1) ✓
Step 4: See 'e'
From ({4}, 1):
Match 'e': ({5}, 1) ✓
State 5 is accepting!
Result: "bone" matches with distance 1 (substituted b for f)
A dictionary word is accepted if:
\le$ max_distanceBut wait—what if we've consumed the whole dictionary word but the NFA still needs more characters?
def can_reach_final(nfa_states, current_distance, max_distance):
"""Can we reach NFA accepting state within remaining budget?"""
remaining = max_distance - current_distance
states = nfa_states
for _ in range(remaining + 1):
if any(nfa.is_final(s) for s in states):
return True
states = nfa_advance(states) # Advance by deletion
return False
Each step toward the accepting state costs 1 (deletion from pattern).
The product automaton doesn't search in isolation—it simultaneously traverses the dictionary trie:
Dictionary Trie:
┌─────────────────┐
│ (root) │
└───────┬─────────┘
┌──────┼──────┐
p f b c
│ │ │ │
h o o o
│ │ │ │
o n n n
│ │ │ │
n e e e
│ ✓ ✓ ✓
e
✓
BFS Exploration:
Queue: [(root, initial_product_state)]
1. Pop (root, ({0}, 0))
Children: p, f, b, c
For 'p': transition product automaton
Match: ({1}, 0) → push (trie['p'], ({1}, 0))
Insert: ({0}, 1) → push (trie['p'], ({0}, 1))
For 'f': transition product automaton
Match: ({6}, 0) → push (trie['f'], ({6}, 0))
...
2. Pop (trie['p'], ({1}, 0))
Children: h
For 'h':
Match: ({2}, 0) → push (trie['p']['h'], ({2}, 0))
... continue BFS ...
At each accepting dictionary node, check if product automaton can accept.
Advantages:
Disadvantages:
class ProductAutomaton:
def __init__(self, phonetic_nfa, max_distance, algorithm="standard"):
self.nfa = phonetic_nfa
self.max_distance = max_distance
self.algorithm = algorithm # standard, transposition, merge_and_split
def initial_state(self):
"""Start state: NFA at start with 0 errors"""
return ProductState(
nfa_states=self.nfa.epsilon_closure({self.nfa.start}),
distance=0
)
def is_accepting(self, state):
"""Can we accept from this state?"""
if state.distance > self.max_distance:
return False
return any(self.nfa.is_final(s) for s in state.nfa_states)
def transition(self, state, c):
"""Generate successor states for dictionary character c"""
successors = []
S = state.nfa_states
d = state.distance
# 1. Match
match_states = self.nfa.step(S, c)
if match_states:
successors.append(ProductState(match_states, d))
if d < self.max_distance:
# 2. Substitution
subst_states = self.nfa.advance(S)
if subst_states:
successors.append(ProductState(subst_states, d + 1))
# 3. Insertion (extra dict char)
successors.append(ProductState(S, d + 1))
# 4. Deletion (missing dict char) - handled in accepts()
# 5. Transposition (if enabled)
# 6. Merge (if enabled)
# 7. Split (if enabled)
# ... (see full implementation)
return successors
def accepts(self, input_string):
"""Does this string match the pattern within distance?"""
visited = set()
queue = [(0, self.initial_state())] # (position, state)
while queue:
pos, state = queue.pop(0)
# Dedup
key = (pos, tuple(sorted(state.nfa_states)), state.distance)
if key in visited:
continue
visited.add(key)
# Pruning
if state.distance > self.max_distance:
continue
# End of input
if pos == len(input_string):
if self.can_reach_final(state):
return True
continue
# Process next character
c = input_string[pos]
for next_state in self.transition(state, c):
queue.append((pos + 1, next_state))
# Deletion: advance NFA without consuming input
if state.distance < self.max_distance:
del_states = self.nfa.advance(state.nfa_states)
if del_states:
next_state = ProductState(del_states, state.distance + 1)
queue.append((pos, next_state)) # Same position!
return False
class PhoneticTransducer:
def __init__(self, dictionary, phonetic_nfa, max_distance):
self.dictionary = dictionary
self.product = ProductAutomaton(phonetic_nfa, max_distance)
def query(self, input_string):
"""Find all dictionary words matching the pattern within distance"""
results = []
# BFS through dictionary × product automaton
queue = [(self.dictionary.root(), "", self.product.initial_state())]
while queue:
node, path, state = queue.pop(0)
# Check if this dictionary word matches
if node.is_final():
if self.product.is_accepting(state):
results.append(Candidate(
term=path,
edit_distance=state.distance,
phonetic_cost=0.0 # Could track separately
))
# Explore children
for c, child_node in node.edges():
for next_state in self.product.transition(state, c):
queue.append((child_node, path + c, next_state))
return results
| Scenario | Best Approach | Why |
|---|---|---|
| Autocomplete with millions of queries/sec | 1 | Speed is critical |
| Spell checker showing "did you mean?" | 3 | Need accurate cost decomposition |
| Search engine with static index | 2 | Pre-compute variants, fast queries |
| Interactive editor with custom rules | 3 | Rules change per user |
| Mobile app with memory constraints | 1 or 3 | Small footprint |
| Batch processing OCR corrections | 2 or 3 | Accuracy over speed |
| Metric | Approach 1 | Approach 2 | Approach 3 |
|---|---|---|---|
| Build Time | $\mathcal{O}(D \cdot L \cdot R)$ | $\mathcal{O}(D \cdot V \cdot L)$ | $\mathcal{O}(D \cdot L)$ |
| Memory | $\mathcal{O}(D \cdot L)$ | $\mathcal{O}(D \cdot V \cdot L)$ | $\mathcal{O}(D \cdot L)$ |
| Query Time | $\mathcal{O}(m \cdot R + m \cdot n \cdot L)$ | $\mathcal{O}(V_q \cdot m \cdot n \cdot L)$ | $\mathcal{O}(\lvert NFA\rvert \cdot n \cdot m \cdot L)$ |
| Flexibility | Low | Medium | High |
| Cost Decomposition | No | Yes | Yes |
Where: D=dictionary size, L=avg word length, R=rules, V=variants/word, m=query length, n=max distance, $\lvert NFA\rvert$=NFA states
Speed
▲
│
Approach 1 ●──────────┼────────────────────┐
│ │
│ Approach 2 │
│ ●─────┘
│ /
│ /
│ /
──────────┼──────────/─────────▶ Memory
│ /
│ /
│ /
│ ●
│ Approach 3
│
Approach 1 can conflate too many words:
Approach 2 preserves distinctions:
Approach 3 is most precise:
liblevenshtein-rust/
├── src/
│ ├── phonetic/
│ │ ├── nfa/
│ │ │ ├── product.rs ← ProductAutomatonChar (Approach 3)
│ │ │ └── thompson.rs ← NFA construction
│ │ ├── application.rs ← apply_rules_seq() (for Approach 1)
│ │ └── rules.rs ← Zompist phonetic rules
│ └── transducer/
│ ├── phonetic_transducer.rs ← High-level API
│ └── intersection.rs ← Dict × Automaton composition
use liblevenshtein::phonetic::nfa::{compile, NFAChar};
use liblevenshtein::phonetic::regex::parse;
use liblevenshtein::transducer::PhoneticTransducerChar;
use libdictenstein::double_array_trie::DoubleArrayTrieChar;
// Build dictionary
let dict = DoubleArrayTrieChar::from_terms(["phone", "fone", "bone", "cone"]);
// Build phonetic NFA for pattern "(ph|f)one"
let pattern = compile(&parse("(ph|f)one").unwrap()).unwrap();
// Create transducer (product automaton internally)
let transducer = PhoneticTransducerChar::new(dict, pattern, 1);
// Query
for candidate in transducer.query("fone") {
println!("{}: distance {}", candidate.term, candidate.edit_distance);
}
// Output:
// fone: distance 0
// phone: distance 0 (via ph→f in NFA)
// bone: distance 1 (via b→f substitution)
For Approach 1 (~600 lines of code):
// Not yet implemented, but would look like:
let normalized_dict = PhoneticNormalizedDictionary::new(
terms,
&zompist_rules()
);
// Query normalizes input then does Levenshtein
for candidate in normalized_dict.query("fone", 1) {
println!("{}", candidate.term);
}
For Approach 2 (~1500 lines of code):
// Not yet implemented, but would look like:
let expanded_dict = PhoneticExpandedDictionary::new(
terms,
&zompist_rules(),
100 // max variants per term
);
// Query searches all variants
for candidate in expanded_dict.query("fone", 1) {
println!("{}: phonetic={}, edit={}",
candidate.term,
candidate.phonetic_cost,
candidate.edit_distance);
}
Given the NFA for pattern (c|k)at and dictionary ["cat", "kat", "bat", "hat"]:
Given rules:
ph → f (cost 0.1)c → k (cost 0.1)tion → shun (cost 0.2)Enumerate all variants of "action" with their costs.
For a dictionary of 10,000 English words:
The product automaton in liblevenshtein supports transposition. Extend the pseudocode to handle:
u64)Everything so far has corrected the characters of a word. But the Levenshtein engine is unit-generic: its state, position, and transition machinery never inspect what a "symbol" is — they only test symbols for equality. The same automaton that corrects c-a-t → b-a-t can therefore correct a sentence the-quick-fox → the-quick-dog, once each word is mapped to an integer term-id. This is the word-level ($T_{\mathrm{gram}}$) half of the correction architecture whose phonetic half ($T_{\mathrm{lex}}$) was introduced in §5.4.4.
Fix a vocabulary and assign each word a unique term-id. A sentence becomes a sequence over the alphabet $\Sigma = \{0, 1, \dots, 2^{64}-1\}$ (u64). An n-gram dictionary stores known term-id sequences; correcting a corrupted sequence is then exactly a Levenshtein query — insertion, deletion, substitution, and transposition now act on whole words, not letters.
character level: c · a · t → edit ops act on letters
word level: 10 · 20 · 30 → edit ops act on term-ids
▲ ▲ ▲
the quick fox (each word ↦ a u64 id)
u64 Surface Is NeededThe string entry point (Transducer::query(&str, k)) is a trap for a term-id alphabet. For u64, CharUnit::from_str byte-packs the UTF-8 of the query string 8 bytes at a time — a lossy encoding unrelated to term-ids — and reconstructs matches by byte-unpacking the u64 labels. Over a dictionary built from &[u64] sequences it therefore compiles but silently returns control-byte garbage, never a usable sequence. The query_units* family bypasses both from_str (input) and to_string (output), so a &[u64] query is matched and returned losslessly.
Let $q$ be the query sequence and $\lvert q\rvert$ its length; each method is lazy and completes a dictionary walk in $\mathcal{O}(\lvert q\rvert)$ per matched node.
| Method | Prunes on | Yields per match |
|---|---|---|
query_units(q, k) | integer edit distance $\le k$ | Vec<u64> (the corrected sequence) |
query_units_with_distance(q, k) | integer edit distance $\le k$ | UnitCandidate { term, distance } |
query_units_weighted(q, max_cost, costs) | weighted $f64$ cost $\le$ max_cost | UnitCandidateF64 { term, distance } |
query_units_values(q, k) | integer edit distance $\le k$ | (Vec<u64>, distance, value) |
The weighted variant charges each edit a real-valued OperationCostsF64 and prunes on the accumulated total,
\mathrm{cost}(w) = \sum_{i} c_{\mathrm{op}_i}, \qquad c_{\mathrm{op}_i} \in \mathbb{R}_{\ge 0},
so a language-model weight (e.g. a $-\log P$ per-word score, which is non-negative and additive) can rank one correction ahead of another within the same integer-distance ball, or prune an expensive edit the integer search would have accepted.
The value-returning variant (query_units_values) requires only that the dictionary be a MappedDictionary whose nodes are MappedDictionaryNodes; it is not coupled to any particular backend, working equally over the in-memory, lock-free DynamicDawgU64 and the disk-backed PersistentARTrieU64.
use libdictenstein::dynamic_dawg::DynamicDawgU64;
use liblevenshtein::prelude::*;
use liblevenshtein::transducer::OperationCostsF64;
// An n-gram vocabulary trie; the value is a stand-in n-gram frequency / id.
let dict: DynamicDawgU64<u64> = DynamicDawgU64::new();
dict.insert_sequence_with_value(&[10, 20, 30], 900); // "the quick fox"
dict.insert_sequence_with_value(&[10, 20, 40], 150); // "the quick dog"
dict.insert_sequence_with_value(&[10, 20], 500); // "the quick"
let transducer = Transducer::new(dict, Algorithm::Standard);
let observed = [10u64, 20, 40]; // third word-id corrupted (30 seen as 40)
// Integer correction: everything within one word-edit.
let hits: Vec<Vec<u64>> = transducer.query_units(&observed, 1).collect();
// → [10,20], [10,20,30], [10,20,40]
// Value-returning: corrected sequence + stored frequency, one pass.
for (seq, dist, freq) in transducer.query_units_values(&observed, 1) {
println!("{seq:?} @ distance {dist}, freq {freq}");
}
// [10,20,40] @ distance 0, freq 150
// [10,20] @ distance 1, freq 500
// [10,20,30] @ distance 1, freq 900
// Weighted: make substitutions cost 2.0 with a 1.0 budget — the lone
// substitution [10,20,30] is pruned, the deletion [10,20] survives.
let mut costs = OperationCostsF64::standard();
costs.substitution = 2.0;
let ranked: Vec<Vec<u64>> = transducer
.query_units_weighted(&observed, 1.0, costs)
.map(|c| c.term)
.collect();
// → [10,20,40] (cost 0.0), [10,20] (cost 1.0)
A runnable version is in examples/u64_word_correction.rs.
query_units_values gives $T_{\mathrm{gram}}$ a corrected term-id sequence and its stored per-sequence score in one pass, while the phonetic query_values of §5.4.4 gives $T_{\mathrm{lex}}$ a (term_id, cost) mapping from a token's characters. Composing $T_{\mathrm{lex}}$ with $T_{\mathrm{gram}}$ — combining their costs under a $-\log P$ semiring — yields end-to-end sentence correction. That composition lives downstream (in the libgrammstein layer); this library supplies the two building blocks.
Compositional spelling correction combines the power of phonetic matching with edit distance tolerance. The three approaches offer different trade-offs:
The liblevenshtein-rust library implements Approach 3 via ProductAutomatonChar, providing full cost decomposition and runtime rule flexibility. The other approaches can be built on top of existing primitives (apply_rules_seq() for normalization, dictionary types for storage).
Understanding these trade-offs helps you choose the right approach for your application's constraints on speed, memory, precision, and flexibility.
Automaton: A state machine that processes input and decides accept/reject.
DFA: Deterministic Finite Automaton — exactly one transition per state per input.
Edit Distance: Minimum character edits (insert/delete/substitute) between strings.
Epsilon Closure: All states reachable via epsilon (free) transitions.
Levenshtein Automaton: Accepts all strings within edit distance n of a query.
NFA: Non-deterministic Finite Automaton — can have multiple transitions per input.
Phonetic Rules: Transformations based on pronunciation (ph→f).
Product Automaton: Composition of two automata; states are pairs from each.
Thompson Construction: Algorithm to convert regex to NFA.
Trie: Tree data structure for efficient prefix-based string storage.
For pattern (ph|f)one and max_distance=1:
Accepting
│
▼
({0}, 0) ─p→ ({1}, 0) ─h→ ({2}, 0) ─o→ ({3}, 0) ─n→ ({4}, 0) ─e→ ({5}, 0) ✓
│ │ │ │ │ │
│ insert │ insert │ insert │ insert │ insert │ insert
▼ ▼ ▼ ▼ ▼ ▼
({0}, 1) ({1}, 1) ({2}, 1) ({3}, 1) ({4}, 1) ({5}, 1) ✓
│ │ │ │ │
│ subst │ subst │ subst │ subst │ subst
▼ ▼ ▼ ▼ ▼
({1|6}, 1) ({2}, 1) ({3}, 1) ({4}, 1) ({5}, 1) ✓
({0}, 0) ─f→ ({6}, 0) ─ε→ ({2}, 0) ─o→ ... (same as above)
│
│ insert
▼
({0}, 1) ─f→ ({6}, 1) ...
Legend:
─x→ : Match transition on character x
subst: Substitution (advance NFA, +1 distance)
insert: Insertion (stay in NFA, +1 distance)
✓ : Accepting state
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |