Version: 1.0 Date: 2025-10-26 Status: Design Proposal
This document proposes extending liblevenshtein-rust with weighted finite-state transducer (WFST) composition to enable hierarchical error correction that combines:
This enables applications like contextual spell checking ("I saw to movies" → "I saw two movies"), grammar correction, and machine translation with integrated error handling.
Word-level correction only:
let dict = PathMapDictionary::from_terms(vec!["to", "too", "two"]);
let transducer = Transducer::new(dict, Algorithm::Standard);
// Query "too" with distance 1
for word in transducer.query("too", 1) {
println!("{}", word);
}
// Output: "to", "too" (both within distance 1)
// ❌ Cannot distinguish which is correct in context
Context-aware correction via composition:
// Level 1: Word-level Levenshtein automaton (spelling candidates)
let dictionary = PathMapDictionary::from_terms(vec!["to", "too", "two", "I", "saw"]);
let spell_checker = WeightedTransducer::new(dictionary, Algorithm::Standard);
// Level 2: Bigram language model (grammar scoring)
let bigram_model = NgramModel::from_corpus(training_text, 2);
// Compose: spelling candidates × grammar model
let corrector = spell_checker.compose(&bigram_model);
// Query with context
let sentence = "I saw too movies";
for correction in corrector.query_best_paths(sentence, max_distance: 1, k: 3) {
println!("{}: {:.4}", correction.text, correction.score);
}
// Output:
// "I saw two movies": 0.0234 ✅ Best grammatical correction
// "I saw to movies": 0.0012
// "I saw too movies": 0.0003 (original, low probability)
Contextual Spell Checking
Grammar Correction
OCR Post-Correction
Speech Recognition Refinement
Programming Language "Did You Mean" Suggestions
calculate_totak → calculate_totalfucntion → function, reutrn → returnstr.apeend() → str.append() (method exists)Language Classification (Spoken/Programming)
A weighted finite-state transducer extends finite-state automata with:
Formal Definition:
A WFST $T = (\Sigma, \Delta, Q, I, F, E, \lambda, \rho)$ where:
\Sigma$: input alphabet\Delta$: output alphabetQ$: finite set of statesI \subseteq Q$: initial statesF \subseteq Q$: final statesE \subseteq Q \times (\Sigma \cup \{\varepsilon\}) \times (\Delta \cup \{\varepsilon\}) \times \mathbb{R} \times Q$: transitions (source, in, out, weight, target)\lambda: Q \to \mathbb{R}$: initial weights\rho: Q \to \mathbb{R}$: final weightsDefinition: For transducers $T_1: \Sigma \to \Gamma$ and $T_2: \Gamma \to \Delta$, their composition $T = T_1 \circ T_2$ is a transducer $\Sigma \to \Delta$ where:
\Sigma$ ($T_1$'s input)\Delta$ ($T_2$'s output)\Gamma$ ($T_1$'s output = $T_2$'s input) is internalPath weight: $w(p) = \bigoplus_i w(e_i)$ where $\oplus$ is the semiring addition (e.g., min for tropical, + for probability)
Example:
T₁ (Levenshtein): "too" --[weight=1.0]--> {"to", "too", "two"}
T₂ (Bigram LM): "saw two" --[weight=-2.5]--> [likely]
"saw to" --[weight=-5.8]--> [unlikely]
T = T₁ ∘ T₂: "saw too" --> "saw two" [weight=3.5] ✅ Best path
| Semiring | $\oplus$ (addition) | $\otimes$ (multiplication) | Use Case |
|---|---|---|---|
| Tropical | $\min$ | $+$ | Shortest path (edit distance) |
| Log | $-\log(e^{-x} + e^{-y})$ | $+$ | Probability (log-space) |
| Probability | $+$ | $\times$ | Probability (linear space) |
| Boolean | $\lor$ | $\land$ | Acceptor (recognition) |
Current liblevenshtein: Uses tropical semiring (minimum edit distance)
Proposed: Support log-semiring for probability-based language models
┌─────────────────────────────────────────────────────────────┐
│ User Application │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────────────┴─────────────────────────────────┐
│ Hierarchical Corrector │
│ (Composes word-level + sequence-level automata) │
└───────────────────────────┬─────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌───────▼────────┐ ┌──────▼───────┐ ┌───────▼────────┐
│ Word-Level │ │ Bigram LM │ │ Trigram LM │
│ Levenshtein │ │ Transducer │ │ Transducer │
│ Transducer │ │ │ │ │
│ │ │ │ │ │
│ Input: text │ │ Input: words │ │ Input: word │
│ Output: words │ │ Output: words│ │ sequences │
│ Weight: edits │ │ Weight: P(w₂│w₁)│ Weight: P(w₃│w₂w₁)│
└────────────────┘ └──────────────┘ └────────────────┘
│ │ │
└───────────────────┼───────────────────┘
│
┌───────────▼──────────┐
│ Dictionary │
│ Backend │
│ (PathMap, DAWG, │
│ SuffixAutomaton) │
└──────────────────────┘
T_1 \circ T_2 \circ \cdots \circ T_n$/// Semiring for weighted automata operations.
///
/// Defines addition (⊕) and multiplication (⊗) operations
/// with identity elements (zero, one).
pub trait Weight: Clone + PartialOrd + Debug {
/// Zero element (identity for ⊕)
fn zero() -> Self;
/// One element (identity for ⊗)
fn one() -> Self;
/// Addition operation (⊕)
fn add(&self, other: &Self) -> Self;
/// Multiplication operation (⊗)
fn multiply(&self, other: &Self) -> Self;
/// Check if this is the zero element
fn is_zero(&self) -> bool {
self == &Self::zero()
}
/// Check if this is the one element
fn is_one(&self) -> bool {
self == &Self::one()
}
}
/// Tropical semiring: (min, +, ∞, 0)
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct TropicalWeight(pub f64);
impl Weight for TropicalWeight {
fn zero() -> Self {
TropicalWeight(f64::INFINITY)
}
fn one() -> Self {
TropicalWeight(0.0)
}
fn add(&self, other: &Self) -> Self {
TropicalWeight(self.0.min(other.0)) // min for shortest path
}
fn multiply(&self, other: &Self) -> Self {
TropicalWeight(self.0 + other.0) // + for path concatenation
}
}
/// Log semiring: (-log(e^(-x) + e^(-y)), +, ∞, 0)
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
pub struct LogWeight(pub f64);
impl Weight for LogWeight {
fn zero() -> Self {
LogWeight(f64::INFINITY)
}
fn one() -> Self {
LogWeight(0.0)
}
fn add(&self, other: &Self) -> Self {
// Log-sum-exp trick for numerical stability
if self.0 == f64::INFINITY {
return *other;
}
if other.0 == f64::INFINITY {
return *self;
}
let min = self.0.min(other.0);
let max = self.0.max(other.0);
LogWeight(-((-(max - min)).exp() + 1.0).ln() + max)
}
fn multiply(&self, other: &Self) -> Self {
LogWeight(self.0 + other.0)
}
}
/// A state in a weighted finite-state transducer.
#[derive(Clone, Debug)]
pub struct WeightedState<W: Weight> {
/// State identifier
pub id: usize,
/// Outgoing transitions
pub transitions: Vec<WeightedTransition<W>>,
/// Final weight (if this is a final state)
pub final_weight: Option<W>,
}
/// A weighted transition between states.
#[derive(Clone, Debug)]
pub struct WeightedTransition<W: Weight> {
/// Input symbol (None for ε-transition)
pub input: Option<u8>,
/// Output symbol (None for ε-transition)
pub output: Option<u8>,
/// Transition weight
pub weight: W,
/// Target state
pub target: usize,
}
/// Weighted transducer wrapping a dictionary backend.
///
/// Converts dictionary transitions to weighted transitions
/// using edit distance as weights (Tropical semiring).
pub struct WeightedTransducer<D: Dictionary, W: Weight> {
dictionary: D,
algorithm: Algorithm,
_weight: PhantomData<W>,
}
impl<D: Dictionary> WeightedTransducer<D, TropicalWeight> {
/// Create a weighted transducer from a dictionary.
///
/// Uses edit distance as weights in the Tropical semiring.
pub fn new(dictionary: D, algorithm: Algorithm) -> Self {
Self {
dictionary,
algorithm,
_weight: PhantomData,
}
}
/// Query for weighted paths within max_distance.
///
/// Returns weighted candidates with edit distance as weight.
pub fn query_weighted(&self, term: &str, max_distance: usize)
-> impl Iterator<Item = WeightedCandidate<TropicalWeight>> + '_
{
// Leverage existing Transducer query infrastructure
// Wrap results with TropicalWeight based on computed distance
self.dictionary
.query_with_distance(term, max_distance)
.map(|candidate| WeightedCandidate {
input: term.to_string(),
output: candidate.term,
weight: TropicalWeight(candidate.distance as f64),
})
}
}
/// A weighted candidate from transducer query.
#[derive(Clone, Debug)]
pub struct WeightedCandidate<W: Weight> {
pub input: String,
pub output: String,
pub weight: W,
}
/// N-gram language model as a weighted transducer.
///
/// Assigns probabilities to word sequences based on n-gram statistics.
pub struct NgramTransducer<W: Weight> {
/// N-gram order (2 for bigram, 3 for trigram, etc.)
order: usize,
/// N-gram probabilities: context → (word → probability)
/// Stored as log-probabilities for numerical stability
ngrams: HashMap<Vec<String>, HashMap<String, W>>,
/// Vocabulary size (for backoff smoothing)
vocab_size: usize,
/// Smoothing parameter (Laplace, Kneser-Ney, etc.)
smoothing: SmoothingType,
}
#[derive(Clone, Debug)]
pub enum SmoothingType {
/// Add-k smoothing (Laplace for k=1)
Laplace(f64),
/// Kneser-Ney smoothing (more sophisticated)
KneserNey { discount: f64 },
/// No smoothing (use only observed n-grams)
None,
}
impl NgramTransducer<LogWeight> {
/// Train n-gram model from text corpus.
///
/// # Arguments
/// * `corpus` - Training text
/// * `order` - N-gram order (2=bigram, 3=trigram)
/// * `smoothing` - Smoothing method for unseen n-grams
pub fn from_corpus(
corpus: &str,
order: usize,
smoothing: SmoothingType
) -> Self {
let words: Vec<String> = corpus
.split_whitespace()
.map(|w| w.to_lowercase())
.collect();
let mut ngrams = HashMap::new();
let vocab: HashSet<_> = words.iter().cloned().collect();
let vocab_size = vocab.len();
// Count n-grams
for window in words.windows(order) {
let context: Vec<String> = window[..order-1].to_vec();
let word = window[order-1].clone();
ngrams
.entry(context)
.or_insert_with(HashMap::new)
.entry(word)
.and_modify(|count: &mut usize| *count += 1)
.or_insert(1);
}
// Convert counts to log-probabilities with smoothing
let ngrams = ngrams
.into_iter()
.map(|(context, counts)| {
let total: usize = counts.values().sum();
let probs = counts
.into_iter()
.map(|(word, count)| {
let prob = match smoothing {
SmoothingType::Laplace(k) => {
(count as f64 + k) / (total as f64 + k * vocab_size as f64)
}
SmoothingType::KneserNey { discount } => {
// Simplified Kneser-Ney
((count as f64 - discount).max(0.0) / total as f64)
+ (discount / total as f64) * (/* continuation prob */)
}
SmoothingType::None => count as f64 / total as f64,
};
(word, LogWeight(-prob.ln())) // negative log probability
})
.collect();
(context, probs)
})
.collect();
Self {
order,
ngrams,
vocab_size,
smoothing,
}
}
/// Get probability (weight) of word given context.
pub fn probability(&self, context: &[String], word: &str) -> LogWeight {
// Look up n-gram probability
if let Some(probs) = self.ngrams.get(context) {
if let Some(&weight) = probs.get(word) {
return weight;
}
}
// Backoff to lower-order n-gram or uniform distribution
if context.len() > 1 {
self.probability(&context[1..], word)
} else {
// Uniform distribution fallback
LogWeight(-((1.0 / self.vocab_size as f64).ln()))
}
}
}
/// Composition of two weighted transducers: T = T₁ ∘ T₂
///
/// The output alphabet of T₁ must match the input alphabet of T₂.
pub struct ComposedTransducer<W: Weight> {
/// First transducer (T₁)
t1: Arc<dyn WeightedTransducerTrait<W>>,
/// Second transducer (T₂)
t2: Arc<dyn WeightedTransducerTrait<W>>,
/// Lazy composition: compute states on demand
state_cache: RwLock<HashMap<(usize, usize), usize>>,
}
impl<W: Weight> ComposedTransducer<W> {
/// Compose two transducers: T₁ ∘ T₂
pub fn new(
t1: Arc<dyn WeightedTransducerTrait<W>>,
t2: Arc<dyn WeightedTransducerTrait<W>>,
) -> Self {
Self {
t1,
t2,
state_cache: RwLock::new(HashMap::new()),
}
}
/// Get or create composed state (q₁, q₂) → q
fn get_or_create_state(&self, q1: usize, q2: usize) -> usize {
let mut cache = self.state_cache.write().unwrap();
let key = (q1, q2);
if let Some(&q) = cache.get(&key) {
return q;
}
let q = cache.len();
cache.insert(key, q);
q
}
/// Compute outgoing transitions for composed state (q₁, q₂)
fn transitions(&self, q1: usize, q2: usize) -> Vec<WeightedTransition<W>> {
let mut result = Vec::new();
// Get transitions from both transducers
let t1_trans = self.t1.transitions(q1);
let t2_trans = self.t2.transitions(q2);
// Match ε-ε transitions
for tr1 in &t1_trans {
if tr1.output.is_none() { // ε-transition in T₁
for tr2 in &t2_trans {
if tr2.input.is_none() { // ε-transition in T₂
result.push(WeightedTransition {
input: tr1.input,
output: tr2.output,
weight: tr1.weight.multiply(&tr2.weight),
target: self.get_or_create_state(tr1.target, tr2.target),
});
}
}
}
}
// Match output of T₁ with input of T₂
for tr1 in &t1_trans {
if let Some(label1) = tr1.output {
for tr2 in &t2_trans {
if tr2.input == Some(label1) {
result.push(WeightedTransition {
input: tr1.input,
output: tr2.output,
weight: tr1.weight.multiply(&tr2.weight),
target: self.get_or_create_state(tr1.target, tr2.target),
});
}
}
}
}
result
}
}
Idea: Don't pre-compute entire composed automaton. Generate states and transitions as needed during query.
Complexity:
\mathcal{O}(\lvert T_{1}\rvert \times \lvert T_{2}\rvert)$ worst case, but often much better with pruning\mathcal{O}(\text{visited} \text{states})$ - typically $\ll$ $\mathcal{O}(\lvert T_{1}\rvert \times \lvert T_{2}\rvert)$Pseudocode:
function LazyCompose(T₁, T₂, input):
q₁ ← T₁.initial_state
q₂ ← T₂.initial_state
frontier ← PriorityQueue[(q₁, q₂, path, weight)]
visited ← Set()
frontier.push((q₁, q₂, [], W.one()))
while not frontier.empty():
(s₁, s₂, path, w) ← frontier.pop()
if (s₁, s₂) in visited:
continue
visited.add((s₁, s₂))
if T₁.is_final(s₁) and T₂.is_final(s₂):
yield (path, w.multiply(T₁.final_weight(s₁)).multiply(T₂.final_weight(s₂)))
for tr₁ in T₁.transitions(s₁):
for tr₂ in T₂.transitions(s₂):
if tr₁.output == tr₂.input: // Match intermediate symbol
new_path ← path + [tr₁.input → tr₂.output]
new_weight ← w.multiply(tr₁.weight).multiply(tr₂.weight)
frontier.push((tr₁.target, tr₂.target, new_path, new_weight))
Idea: Find top-k shortest paths through composed automaton using A* with admissible heuristic.
Complexity:
\mathcal{O}(k \times \lvert E\rvert \log \lvert V\rvert)$ using Dijkstra-style priority queue\mathcal{O}(\lvert V\rvert + k)$Pseudocode:
function KBestPaths(T, input, k):
pq ← PriorityQueue[(state, path, weight)]
results ← []
counts ← HashMap[state → count]
pq.push((T.initial_state, [], W.one()))
while not pq.empty() and len(results) < k:
(state, path, weight) ← pq.pop()
counts[state] ← counts.get(state, 0) + 1
if counts[state] > k:
continue // Already found k paths through this state
if T.is_final(state):
final_weight ← weight.multiply(T.final_weight(state))
results.append((path, final_weight))
if len(results) == k:
break
for tr in T.transitions(state):
new_path ← path + [tr]
new_weight ← weight.multiply(tr.weight)
pq.push((tr.target, new_path, new_weight))
return results
Idea: Compose n transducers efficiently: $T = T_{1} \circ T_{2} \circ ... \circ T_{n}$
Approaches:
((T_{1} \circ T_{2}) \circ T_{3}) \circ ...$ (standard binary composition)Complexity (Allauzen & Mohri):
\mathcal{O}(\lvert T\rvert_Q \cdot \min (d(T_{1})\cdot d(T_{3}), d(T_{2})) + \lvert T\rvert_E)$n > 2/// Hierarchical corrector combining spelling and grammar.
pub struct HierarchicalCorrector {
/// Word-level spelling correction
spell_checker: WeightedTransducer<PathMapDictionary, TropicalWeight>,
/// Sequence-level language model
language_model: NgramTransducer<LogWeight>,
/// Composition configuration
config: CorrectionConfig,
}
#[derive(Clone, Debug)]
pub struct CorrectionConfig {
/// Maximum edit distance for spelling
pub max_distance: usize,
/// Number of best paths to return
pub k_best: usize,
/// Weight for spelling vs. grammar (interpolation)
pub spell_weight: f64,
pub grammar_weight: f64,
/// Minimum score threshold
pub min_score: f64,
}
impl HierarchicalCorrector {
/// Create corrector from dictionary and language model.
pub fn new(
dictionary: PathMapDictionary,
language_model: NgramTransducer<LogWeight>,
config: CorrectionConfig,
) -> Self {
let spell_checker = WeightedTransducer::new(dictionary, Algorithm::Standard);
Self {
spell_checker,
language_model,
config,
}
}
/// Correct a sentence with context-aware scoring.
///
/// Returns k-best corrected sentences with scores.
pub fn correct(&self, sentence: &str) -> Vec<CorrectionResult> {
let words: Vec<&str> = sentence.split_whitespace().collect();
let mut results = Vec::new();
// Generate candidate corrections for each word
let candidates_per_word: Vec<Vec<WeightedCandidate<_>>> = words
.iter()
.map(|word| {
self.spell_checker
.query_weighted(word, self.config.max_distance)
.collect()
})
.collect();
// Compose: spelling candidates × language model
// Use dynamic programming to find k-best paths
let corrected_sequences = self.compose_and_score(
&candidates_per_word,
&words,
);
// Convert to user-friendly results
for (sequence, score) in corrected_sequences {
results.push(CorrectionResult {
original: sentence.to_string(),
corrected: sequence.join(" "),
score,
edits: self.compute_edits(sentence, &sequence),
});
}
results
}
/// Compose spelling candidates with language model.
fn compose_and_score(
&self,
candidates_per_word: &[Vec<WeightedCandidate<TropicalWeight>>],
original_words: &[&str],
) -> Vec<(Vec<String>, f64)> {
// Dynamic programming: maintain k-best partial paths
let mut beam: Vec<(Vec<String>, f64)> = vec![(vec![], 0.0)];
for (i, candidates) in candidates_per_word.iter().enumerate() {
let mut next_beam = Vec::new();
for (path, path_score) in &beam {
for candidate in candidates {
let mut new_path = path.clone();
new_path.push(candidate.output.clone());
// Compute combined score: spelling + grammar
let spell_cost = candidate.weight.0 * self.config.spell_weight;
let grammar_cost = if i > 0 {
// Use previous word as context for bigram
let context = vec![path.last().unwrap().clone()];
self.language_model
.probability(&context, &candidate.output)
.0 * self.config.grammar_weight
} else {
0.0 // No context for first word
};
let new_score = path_score + spell_cost + grammar_cost;
next_beam.push((new_path, new_score));
}
}
// Keep only k-best
next_beam.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
next_beam.truncate(self.config.k_best);
beam = next_beam;
}
beam
}
/// Compute edit operations between original and corrected.
fn compute_edits(&self, original: &str, corrected: &[String]) -> Vec<Edit> {
// Use Levenshtein alignment to determine edits
// ... (implementation details)
vec![] // Placeholder
}
}
/// A correction result with score and edit operations.
#[derive(Clone, Debug)]
pub struct CorrectionResult {
pub original: String,
pub corrected: String,
pub score: f64,
pub edits: Vec<Edit>,
}
#[derive(Clone, Debug)]
pub enum Edit {
Keep { position: usize, word: String },
Replace { position: usize, from: String, to: String },
Insert { position: usize, word: String },
Delete { position: usize, word: String },
}
use liblevenshtein::prelude::*;
use liblevenshtein::weighted::{HierarchicalCorrector, NgramTransducer, CorrectionConfig};
// Build dictionary
let words = vec!["I", "saw", "to", "too", "two", "movies", "the", "store"];
let dict = PathMapDictionary::from_terms(words);
// Train language model from corpus
let training_corpus = r#"
I saw two movies at the store.
I went to the store.
I saw too many people.
The two of us went together.
"#;
let lm = NgramTransducer::from_corpus(training_corpus, 2, SmoothingType::Laplace(1.0));
// Create corrector
let config = CorrectionConfig {
max_distance: 1,
k_best: 3,
spell_weight: 1.0,
grammar_weight: 2.0, // Favor grammatical corrections
min_score: 0.0,
};
let corrector = HierarchicalCorrector::new(dict, lm, config);
// Correct sentence
let sentence = "I saw too movies";
for result in corrector.correct(sentence) {
println!("{} (score: {:.4})", result.corrected, result.score);
for edit in result.edits {
println!(" {:?}", edit);
}
}
// Output:
// I saw two movies (score: -3.2451) ✅ Best: grammatically correct
// Replace { position: 2, from: "too", to: "two" }
//
// I saw to movies (score: -5.8732)
// Replace { position: 2, from: "too", to: "to" }
//
// I saw too movies (score: -7.1234) (original, low score)
// Keep { ... }
let sentence = "Teh quck brown fox jumps ovr teh lazy dog";
for result in corrector.correct(sentence).take(1) {
println!("Original: {}", result.original);
println!("Corrected: {}", result.corrected);
println!("\nEdits:");
for edit in result.edits {
match edit {
Edit::Replace { from, to, .. } => println!(" {} → {}", from, to),
_ => {}
}
}
}
// Output:
// Original: Teh quck brown fox jumps ovr teh lazy dog
// Corrected: The quick brown fox jumps over the lazy dog
//
// Edits:
// Teh → The
// quck → quick
// ovr → over
// teh → the
// Train on grammatically correct corpus
let grammar_corpus = r#"
He doesn't like it.
They were happy.
She has many books.
"#;
let lm = NgramTransducer::from_corpus(grammar_corpus, 2, SmoothingType::KneserNey { discount: 0.75 });
let dict = PathMapDictionary::from_terms(vec![
"he", "she", "they", "don't", "doesn't", "was", "were", "like", "it", "happy"
]);
let corrector = HierarchicalCorrector::new(dict, lm, config);
// Correct grammatical errors
let sentences = vec![
"He don't like it", // → "He doesn't like it"
"They was happy", // → "They were happy"
];
for sentence in sentences {
let corrected = corrector.correct(sentence).into_iter().next().unwrap();
println!("{} → {}", sentence, corrected.corrected);
}
use liblevenshtein::weighted::{HierarchicalCorrector, CodeContextModel};
// Multi-level correction for programming languages:
// Level 1: Lexical (keyword/identifier spelling)
// Level 2: Syntactic (grammar rules, AST patterns)
// Level 3: Semantic (type information, scope, API usage)
/// Compiler error corrector with multi-level analysis.
pub struct CompilerCorrector {
/// Level 1: Lexical correction (keywords, identifiers)
lexical: WeightedTransducer<PathMapDictionary, TropicalWeight>,
/// Level 2: Syntax patterns (common code constructs)
syntax_model: NgramTransducer<LogWeight>,
/// Level 3: Semantic context (type-aware, scope-aware)
semantic_model: SemanticContextModel,
}
impl CompilerCorrector {
/// Create corrector for a specific programming language.
pub fn for_language(language: &str, project_symbols: Vec<String>) -> Self {
// Level 1: Build lexical dictionary
let mut keywords = match language {
"rust" => vec![
"fn", "let", "mut", "pub", "impl", "trait", "struct", "enum",
"match", "if", "else", "for", "while", "loop", "return", "use",
// ...
],
"python" => vec![
"def", "class", "if", "elif", "else", "for", "while", "return",
"import", "from", "try", "except", "finally", "with", "as",
// ...
],
_ => vec![],
};
// Add project-specific identifiers (functions, variables, types)
keywords.extend(project_symbols);
let lexical_dict = PathMapDictionary::from_terms(keywords);
let lexical = WeightedTransducer::new(lexical_dict, Algorithm::Standard);
// Level 2: Train syntax model on code corpus
let syntax_corpus = load_syntax_corpus(language);
let syntax_model = NgramTransducer::from_corpus(
&syntax_corpus,
3, // Trigrams capture more structure
SmoothingType::KneserNey { discount: 0.75 },
);
// Level 3: Semantic model (type system, API signatures)
let semantic_model = SemanticContextModel::new(language);
Self {
lexical,
syntax_model,
semantic_model,
}
}
/// Suggest corrections for a compiler error.
///
/// # Arguments
/// * `error_token` - The misspelled or incorrect token
/// * `context` - Surrounding code context (AST, type info, scope)
///
/// # Returns
/// Ranked suggestions with explanations
pub fn suggest(&self, error_token: &str, context: &CodeContext) -> Vec<Suggestion> {
let mut suggestions = Vec::new();
// Level 1: Lexical candidates (spelling correction)
let lexical_candidates = self.lexical
.query_weighted(error_token, 2) // Allow up to 2 edits
.collect::<Vec<_>>();
// Level 2: Filter by syntax (grammatically valid in context)
let syntax_filtered: Vec<_> = lexical_candidates
.iter()
.filter(|candidate| {
self.is_syntactically_valid(&candidate.output, context)
})
.collect();
// Level 3: Rank by semantics (type-correct, in-scope, correct usage)
for candidate in syntax_filtered {
let semantic_score = self.semantic_model.score(
&candidate.output,
context,
);
// Combine scores: lexical + syntax + semantic
let total_score =
candidate.weight.0 * 0.2 + // Lexical (edit distance)
self.syntax_score(&candidate.output, context) * 0.3 + // Syntax
semantic_score * 0.5; // Semantic (most important)
suggestions.push(Suggestion {
text: candidate.output.clone(),
score: total_score,
explanation: self.explain_suggestion(&candidate.output, context),
});
}
// Sort by score and return top-k
suggestions.sort_by(|a, b| a.score.partial_cmp(&b.score).unwrap());
suggestions.truncate(5);
suggestions
}
fn is_syntactically_valid(&self, token: &str, context: &CodeContext) -> bool {
// Check if token fits grammatically in context
// Example: After "let" keyword, expect identifier, not another keyword
match context.position {
Position::AfterKeyword(kw) if kw == "let" => {
!self.is_keyword(token) // Must be identifier
}
Position::MethodCall { receiver_type } => {
self.semantic_model.has_method(receiver_type, token)
}
_ => true,
}
}
fn syntax_score(&self, token: &str, context: &CodeContext) -> f64 {
// Use syntax model to score token in context
let context_tokens = vec![
context.prev_token.clone(),
context.prev_prev_token.clone(),
];
self.syntax_model.probability(&context_tokens, token).0
}
fn explain_suggestion(&self, suggestion: &str, context: &CodeContext) -> String {
// Generate human-readable explanation
format!("did you mean `{}`?", suggestion)
}
fn is_keyword(&self, token: &str) -> bool {
// Check if token is a language keyword
// ...
false
}
}
/// Code context for error correction.
#[derive(Clone, Debug)]
pub struct CodeContext {
/// Position in code (after keyword, in expression, etc.)
position: Position,
/// Previous tokens for n-gram context
prev_token: String,
prev_prev_token: String,
/// Current scope (available identifiers)
scope: Vec<String>,
/// Type information (if available)
type_info: Option<TypeInfo>,
/// AST node type
ast_node: AstNodeType,
}
#[derive(Clone, Debug)]
pub enum Position {
AfterKeyword(String),
InExpression,
MethodCall { receiver_type: String },
TypeAnnotation,
// ...
}
/// Semantic context model (type system, API knowledge).
pub struct SemanticContextModel {
/// Type system information
type_system: TypeSystem,
/// API method signatures
api_methods: HashMap<String, Vec<MethodSignature>>,
/// Variable scoping rules
scope_rules: ScopeRules,
}
impl SemanticContextModel {
fn score(&self, token: &str, context: &CodeContext) -> f64 {
let mut score = 0.0;
// Check if token is in scope
if context.scope.contains(&token.to_string()) {
score += 5.0; // Strongly prefer in-scope identifiers
}
// Check if token is type-correct
if let Some(type_info) = &context.type_info {
if self.type_system.is_type_correct(token, type_info) {
score += 3.0;
}
}
// Check if method exists on type
if let Position::MethodCall { receiver_type } = &context.position {
if self.has_method(receiver_type, token) {
score += 4.0;
}
}
-score // Negate for minimization (lower is better)
}
fn has_method(&self, type_name: &str, method_name: &str) -> bool {
self.api_methods
.get(type_name)
.map(|methods| methods.iter().any(|m| m.name == method_name))
.unwrap_or(false)
}
}
/// Suggestion with explanation.
#[derive(Clone, Debug)]
pub struct Suggestion {
pub text: String,
pub score: f64,
pub explanation: String,
}
Example Usage:
// Setup for Rust project
let project_symbols = vec![
"calculate_total", "process_data", "UserAccount", "validate_input", // ...
];
let corrector = CompilerCorrector::for_language("rust", project_symbols);
// Example 1: Variable name typo
let context = CodeContext {
position: Position::InExpression,
prev_token: "let".to_string(),
prev_prev_token: "".to_string(),
scope: vec!["calculate_total".to_string(), "user_account".to_string()],
type_info: None,
ast_node: AstNodeType::LetBinding,
};
for suggestion in corrector.suggest("calculate_totak", &context) {
println!("{} (score: {:.2})", suggestion.text, suggestion.score);
}
// Output:
// calculate_total (score: 0.5) ✅ In scope, similar spelling
// Example 2: Method name typo
let context = CodeContext {
position: Position::MethodCall {
receiver_type: "String".to_string(),
},
prev_token: ".".to_string(),
prev_prev_token: "str".to_string(),
scope: vec![],
type_info: Some(TypeInfo { type_name: "String".to_string() }),
ast_node: AstNodeType::MethodCall,
};
for suggestion in corrector.suggest("apeend", &context) {
println!("{}", suggestion.explanation);
}
// Output:
// did you mean `append`? ✅ Method exists on String
// Example 3: Keyword typo
let context = CodeContext {
position: Position::AfterKeyword("".to_string()),
prev_token: "".to_string(),
prev_prev_token: "".to_string(),
scope: vec![],
type_info: None,
ast_node: AstNodeType::TopLevel,
};
for suggestion in corrector.suggest("fucntion", &context) {
println!("{}", suggestion.text);
}
// Output (language-specific):
// function (JavaScript/TypeScript)
// fn (Rust)
// def (Python)
Real Compiler Integration:
// Rust compiler plugin example
impl CompilerPlugin for LevenshteinSuggester {
fn on_error(&self, error: &CompilerError) -> Vec<Suggestion> {
match error.kind {
ErrorKind::UnresolvedName { name, span } => {
let context = self.extract_context(span);
self.corrector.suggest(name, &context)
}
ErrorKind::UnknownMethod { method, receiver_type, span } => {
let context = CodeContext {
position: Position::MethodCall {
receiver_type: receiver_type.clone(),
},
// ... extract from AST
};
self.corrector.suggest(method, &context)
}
_ => vec![],
}
}
}
Benefits for Programming Languages:
Multi-level Analysis
Context-Aware
let → suggest identifier, not keywordProject-Specific
use liblevenshtein::weighted::{NgramTransducer, LogWeight};
/// Language classifier using n-gram models.
///
/// Trains separate n-gram models for each language, then scores
/// text against all models to identify the most likely language.
pub struct LanguageClassifier {
/// Language models: language_code → n-gram model
models: HashMap<String, NgramTransducer<LogWeight>>,
/// Language names for user-friendly output
language_names: HashMap<String, String>,
}
impl LanguageClassifier {
/// Create classifier for spoken languages.
pub fn for_spoken_languages(training_data: Vec<(String, String)>) -> Self {
let mut models = HashMap::new();
let mut language_names = HashMap::new();
for (lang_code, corpus) in training_data {
// Train character-level trigrams (better for language ID)
let model = NgramTransducer::from_corpus(
&corpus,
3, // Character trigrams
SmoothingType::KneserNey { discount: 0.75 },
);
models.insert(lang_code.clone(), model);
language_names.insert(lang_code.clone(), Self::language_name(&lang_code));
}
Self {
models,
language_names,
}
}
/// Create classifier for programming languages.
pub fn for_programming_languages() -> Self {
let mut training_data = vec![];
// Rust corpus (keywords, syntax patterns)
let rust_corpus = r#"
fn main() { let mut x = 0; impl Trait for Struct { fn method() {} } }
match value { Some(x) => println!("{}", x), None => {} }
pub struct Point { x: i32, y: i32 } use std::collections::HashMap;
"#.repeat(100); // Repeat for sufficient statistics
training_data.push(("rust".to_string(), rust_corpus));
// Python corpus
let python_corpus = r#"
def main(): class MyClass: pass import sys from typing import List
for i in range(10): if x == y: print(f"{x}") elif x > y: pass else: return None
"#.repeat(100);
training_data.push(("python".to_string(), python_corpus));
// JavaScript corpus
let js_corpus = r#"
function main() { const x = 0; let y = [1, 2]; var z = {a: 1};
for (const item of items) { console.log(`${item}`); } async () => await fetch();
"#.repeat(100);
training_data.push(("javascript".to_string(), js_corpus));
// C++ corpus
let cpp_corpus = r#"
int main() { std::vector<int> v; class MyClass { public: void method(); };
for (auto& x : v) { std::cout << x << std::endl; } namespace ns { template<typename T> }
"#.repeat(100);
training_data.push(("cpp".to_string(), cpp_corpus));
Self::for_spoken_languages(training_data)
}
/// Classify a text snippet.
///
/// Returns ranked languages with log-likelihood scores.
pub fn classify(&self, text: &str, top_k: usize) -> Vec<LanguageScore> {
let mut scores = Vec::new();
for (lang_code, model) in &self.models {
// Score text using n-gram model (sum of log-probabilities)
let log_prob = self.score_text(text, model);
scores.push(LanguageScore {
language_code: lang_code.clone(),
language_name: self.language_names[lang_code].clone(),
score: log_prob,
confidence: 0.0, // Computed later
});
}
// Sort by score (higher is better for log-prob)
scores.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());
// Compute confidence as softmax over scores
let sum_exp: f64 = scores.iter().map(|s| (-s.score).exp()).sum();
for score in &mut scores {
score.confidence = (-score.score).exp() / sum_exp;
}
scores.truncate(top_k);
scores
}
/// Score text against a language model.
fn score_text(&self, text: &str, model: &NgramTransducer<LogWeight>) -> f64 {
let chars: Vec<String> = text
.chars()
.map(|c| c.to_string())
.collect();
let mut total_log_prob = 0.0;
// Score character trigrams
for window in chars.windows(3) {
let context = vec![window[0].clone(), window[1].clone()];
let char = &window[2];
let log_weight = model.probability(&context, char);
total_log_prob += log_weight.0;
}
// Normalize by length
total_log_prob / chars.len() as f64
}
fn language_name(code: &str) -> String {
match code {
"en" => "English",
"es" => "Spanish",
"fr" => "French",
"de" => "German",
"rust" => "Rust",
"python" => "Python",
"javascript" => "JavaScript",
"cpp" => "C++",
_ => code,
}.to_string()
}
}
/// Language identification result.
#[derive(Clone, Debug)]
pub struct LanguageScore {
pub language_code: String,
pub language_name: String,
pub score: f64, // Log-probability
pub confidence: f64, // Softmax probability (0-1)
}
Example Usage - Spoken Languages:
// Train on multilingual corpus
let training_data = vec![
("en".to_string(), load_corpus("en_corpus.txt")),
("es".to_string(), load_corpus("es_corpus.txt")),
("fr".to_string(), load_corpus("fr_corpus.txt")),
("de".to_string(), load_corpus("de_corpus.txt")),
];
let classifier = LanguageClassifier::for_spoken_languages(training_data);
// Classify text snippets
let texts = vec![
"The quick brown fox jumps over the lazy dog",
"El rápido zorro marrón salta sobre el perro perezoso",
"Le rapide renard brun saute par-dessus le chien paresseux",
"Der schnelle braune Fuchs springt über den faulen Hund",
];
for text in texts {
let results = classifier.classify(text, 3);
println!("\nText: {}", text);
for result in results {
println!(" {}: {:.1}% (score: {:.2})",
result.language_name,
result.confidence * 100.0,
result.score);
}
}
// Output:
// Text: The quick brown fox...
// English: 95.3% (score: -2.1) ✅
// German: 3.2% (score: -5.4)
// French: 1.5% (score: -6.7)
//
// Text: El rápido zorro...
// Spanish: 94.8% (score: -2.3) ✅
// French: 3.1% (score: -5.6)
// English: 2.1% (score: -6.1)
Example Usage - Programming Languages:
let classifier = LanguageClassifier::for_programming_languages();
let code_snippets = vec![
r#"fn main() { let x = 5; println!("x = {}", x); }"#,
r#"def main(): x = 5; print(f"x = {x}")"#,
r#"function main() { const x = 5; console.log(`x = ${x}`); }"#,
r#"int main() { int x = 5; std::cout << "x = " << x << std::endl; }"#,
];
for code in code_snippets {
let results = classifier.classify(code, 3);
println!("\nCode: {}", code);
println!(" Detected: {} ({:.1}%)",
results[0].language_name,
results[0].confidence * 100.0);
}
// Output:
// Code: fn main() { let x = 5; ...
// Detected: Rust (89.3%) ✅
//
// Code: def main(): x = 5; ...
// Detected: Python (92.1%) ✅
//
// Code: function main() { const x = 5; ...
// Detected: JavaScript (87.6%) ✅
//
// Code: int main() { int x = 5; ...
// Detected: C++ (90.4%) ✅
Applications:
IDE/Editor Auto-Detection
// Auto-detect language when opening file without extension
let code = read_file("mystery_file");
let lang = classifier.classify(&code, 1).first().unwrap();
set_syntax_highlighting(lang.language_code);
Content Routing
// Route messages to appropriate translation service
let message = get_user_message();
let lang = classifier.classify(&message, 1).first().unwrap();
let translation = translate(message, lang.language_code, "en");
Code Search Filtering
// Filter search results by detected language
let query = "function implementation";
let results = search_codebase(query);
let rust_results: Vec<_> = results
.into_iter()
.filter(|r| {
let lang = classifier.classify(&r.content, 1).first().unwrap();
lang.language_code == "rust"
})
.collect();
Syntax Highlighter Selection
// Markdown code blocks without language tag
let code_block = extract_code_block(markdown);
if code_block.language.is_none() {
let lang = classifier.classify(&code_block.content, 1).first().unwrap();
code_block.language = Some(lang.language_code);
}
apply_syntax_highlighting(&code_block);
Benefits:
Character-Level N-grams
Fast Classification
\mathcal{O}(\text{text} \text{length})$ scoringMultilingual Support
Confidence Scores
// OCR often confuses similar-looking characters
let ocr_dict = PathMapDictionary::from_terms(vec![
// Common OCR confusions
"0", "O", "o", // zero vs letter O
"1", "l", "I", // one vs lowercase L vs uppercase I
"5", "S", // five vs S
// ... full dictionary
]);
// Train LM on expected text domain (e.g., English prose)
let lm = NgramTransducer::from_corpus(english_corpus, 3, SmoothingType::KneserNey { discount: 0.75 });
let corrector = HierarchicalCorrector::new(ocr_dict, lm, CorrectionConfig {
max_distance: 2, // Allow more errors for OCR
k_best: 5,
spell_weight: 0.5,
grammar_weight: 2.0, // Heavily favor grammatical output
min_score: -10.0,
});
let ocr_output = "The qu1ck br0wn f0x jumps 0ver the 1azy d0g";
let corrected = corrector.correct(ocr_output).first().unwrap();
println!("{}", corrected.corrected);
// Output: "The quick brown fox jumps over the lazy dog"
Files to Create:
src/weighted/mod.rs - Weighted automata modulesrc/weighted/weight.rs - Weight trait and semiringssrc/weighted/tropical.rs - TropicalWeight implementationsrc/weighted/log.rs - LogWeight implementationTasks:
Weight trait (semiring abstraction)TropicalWeight (min, +)LogWeight (log-sum-exp, +)Files to Create:
src/weighted/transducer.rs - WeightedTransducer wrapping Dictionarysrc/weighted/state.rs - WeightedState and WeightedTransitionTasks:
WeightedTransducerTraitWeightedTransducer<D, W> wrapperFiles to Create:
src/weighted/ngram.rs - N-gram transducersrc/weighted/smoothing.rs - Smoothing algorithmsTasks:
Files to Create:
src/weighted/composition.rs - Composition algorithmssrc/weighted/kbest.rs - K-best path extractionTasks:
\varepsilon$-transition handlingFiles to Create:
src/weighted/corrector.rs - High-level HierarchicalCorrector APITasks:
HierarchicalCorrectorFiles to Create:
examples/contextual_spell_check.rsexamples/grammar_correction.rsexamples/ocr_post_correction.rsdocs/WEIGHTED_AUTOMATA.md - User guideTasks:
Tasks:
Target Performance:
| Operation | Time Complexity | Space Complexity |
|---|---|---|
Composition ($T_{1} \circ T_{2}$) | $\mathcal{O}(\lvert T_{1}\rvert \times \lvert T_{2}\rvert)$ worst case | $\mathcal{O}(\lvert T_{1}\rvert \times \lvert T_{2}\rvert)$ |
| Lazy Composition | $\mathcal{O}(\text{visited} \text{states})$ | $\mathcal{O}(\text{visited} \text{states})$ |
| K-best paths | $\mathcal{O}(k \times \lvert E\rvert \log \lvert V\rvert)$ | $\mathcal{O}(\lvert V\rvert + k)$ |
| N-gram training | $\mathcal{O}(\text{corpus} \text{length} \times n)$ | $\mathcal{O}(\text{distinct} n-\text{grams})$ |
| Sentence correction | $\mathcal{O}(\text{words} \times \text{candidates}^{2} \times k)$ | $\mathcal{O}(\text{words} \times k)$ |
N-gram Language Model:
\mathcal{O}(V^{2})$ where V = vocabulary size\mathcal{O}(V^{3})$ (can be large!)Example: 100K vocabulary, bigram
Composition State Space:
\lvert T_1\rvert \times \lvert T_2\rvert$ statesSetup:
Expected Performance:
Comparison:
Mohri, M., Pereira, F. C., & Riley, M. D. (2002) "Weighted finite-state transducers in speech recognition" Computer Speech & Language, 16(1), 69-88. DOI: 10.1006/csla.2001.0184
Mohri, M. (2009) "Weighted automata algorithms" Handbook of Weighted Automata, 213-254. DOI: 10.1007/978-3-642-01492-5_6
Allauzen, C., & Mohri, M. (2009) "N-way composition of weighted finite-state transducers" International Journal of Foundations of Computer Science, 20(4), 613-627. DOI: 10.1142/S0129054109006747
\mathcal{O}(\lvert T\rvert \cdot \min (d_{1}d_{3}, d_{2}))$ for 3-way compositionSchulz, K. U., & Mihov, S. (2002) "Fast string correction with Levenshtein automata" International Journal on Document Analysis and Recognition, 5(1), 67-85. DOI: 10.1007/s10032-002-0082-8
Katz, S. M. (1987) "Estimation of probabilities from sparse data for the language model component of a speech recognizer" IEEE Transactions on Acoustics, Speech, and Signal Processing, 35(3), 400-401. DOI: 10.1109/TASSP.1987.1165125
Chen, S. F., & Goodman, J. (1999) "An empirical study of smoothing techniques for language modeling" Computer Speech & Language, 13(4), 359-394. DOI: 10.1006/csla.1999.0128
Eppstein, D. (1998) "Finding the k shortest paths" SIAM Journal on Computing, 28(2), 652-673. DOI: 10.1137/S0097539795290477
OpenFst Library
Kaldi Speech Recognition Toolkit
src/transducer/ - Existing Levenshtein automata (Level 1)src/dictionary/ - Dictionary backends (can be weighted)suffix-automaton.md - Substring matching (orthogonal feature)Question: Should we support multiple semirings or standardize on one?
Options:
Recommendation: Start with B (Log only), add A as future enhancement
Question: How to store large n-gram models efficiently?
Options:
Recommendation: Start with A, add B/C for production use
Question: Should we support neural LM (LSTM, Transformer) in addition to n-grams?
Options:
Recommendation: Start with A, design API to allow B as plugin
Question: Can this support interactive applications (< 100ms latency)?
Challenges:
Solutions:
Recommendation: Benchmark aggressively, provide tuning knobs
This design proposes a weighted finite-state transducer framework for hierarchical error correction, enabling:
The implementation leverages:
Applications: Contextual spell checking, grammar correction, OCR post-correction, speech recognition refinement.
Estimated Effort: 8-10 weeks for complete implementation including optimization.
Academic Foundation:
Next Steps:
Document Version: 1.0 Last Updated: 2025-10-26 Author: Claude (AI Assistant) Reviewer: (Pending)
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 |