Liking cljdoc? Tell your friends :D

Hierarchical Error Correction via Automata Composition

← Documentation Index

Version: 1.0 Date: 2025-10-26 Status: Design Proposal

Executive Summary

This document proposes extending liblevenshtein-rust with weighted finite-state transducer (WFST) composition to enable hierarchical error correction that combines:

  1. Word-level correction - Spelling via Levenshtein automata (current capability)
  2. Sequence-level correction - Grammar via n-gram language models
  3. Context-level scoring - Best path selection using combined scores

This enables applications like contextual spell checking ("I saw to movies" → "I saw two movies"), grammar correction, and machine translation with integrated error handling.

End-to-end spellcheck pipeline: a query is expanded into per-word Levenshtein candidates, those candidates are composed with an n-gram language model, and k-best path search selects the highest-scoring context-aware correction.


Table of Contents

  1. Motivation
  2. Theoretical Background
  3. Architecture Design
  4. Core Components
  5. Composition Algorithms
  6. API Design
  7. Use Cases and Examples
  8. Implementation Plan
  9. Performance Analysis
  10. References

Motivation

Current Limitation

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

Proposed Enhancement

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)

Real-World Applications

  1. Contextual Spell Checking

    • "Their going to the store" → "They're going to the store"
    • "I have alot of work" → "I have a lot of work"
  2. Grammar Correction

    • "He don't like it" → "He doesn't like it"
    • "They was happy" → "They were happy"
  3. OCR Post-Correction

    • Scan errors: "The qu1ck brown fox" → "The quick brown fox"
    • Combined with language model for context
  4. Speech Recognition Refinement

    • Phonetic confusion: "right" vs "write" vs "rite"
    • Disambiguate using grammatical context
  5. Programming Language "Did You Mean" Suggestions

    • Variable/function name typos: calculate_totakcalculate_total
    • Keyword/syntax errors: fucntionfunction, reutrnreturn
    • Semantic corrections: str.apeend()str.append() (method exists)
    • Context-aware: Consider type information, scope, usage patterns
  6. Language Classification (Spoken/Programming)

    • Identify spoken language: English, Spanish, French, etc.
    • Identify programming language: Rust, Python, JavaScript, etc.
    • Use n-gram models trained per language
    • Score text snippet against each language's model
    • Applications: syntax highlighting, auto-detection, content routing

Theoretical Background

Weighted Finite-State Transducers (WFSTs)

A weighted finite-state transducer extends finite-state automata with:

  • Input/output alphabets (not just recognition)
  • Weights on transitions (costs, probabilities, scores)
  • Composition operation (combine multiple transducers)

Formal Definition:

A WFST $T = (\Sigma, \Delta, Q, I, F, E, \lambda, \rho)$ where:

  • $\Sigma$: input alphabet
  • $\Delta$: output alphabet
  • $Q$: finite set of states
  • $I \subseteq Q$: initial states
  • $F \subseteq Q$: final states
  • $E \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 weights

Composition Operation

Definition: 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:

  • Input symbols from $\Sigma$ ($T_1$'s input)
  • Output symbols to $\Delta$ ($T_2$'s output)
  • Intermediate alphabet $\Gamma$ ($T_1$'s output = $T_2$'s input) is internal
  • Weights are combined (usually addition in log-semiring)

Path 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

Semirings for Different Applications

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


Architecture Design

High-Level Architecture

┌─────────────────────────────────────────────────────────────┐
│                     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)  │
                └──────────────────────┘

Component Layers

Layer 1: Dictionary Backend (Existing)

  • PathMapDictionary, DynamicDawg, SuffixAutomaton
  • Provides word recognition
  • No weights (boolean acceptors)

Layer 2: Weighted Transducer (New)

  • WeightedTransducer<D: Dictionary, W: Weight>
  • Wraps dictionary with edit distance weights
  • Implements WFST interface
  • Generic over semiring (Tropical, Log, Probability)

Layer 3: Language Model Transducer (New)

  • NgramTransducer
  • Bigram, trigram, or n-gram probabilities
  • Trained from text corpus
  • Output: weighted word sequences

Layer 4: Composition Engine (New)

  • CompositionEngine
  • Composes transducers: $T_1 \circ T_2 \circ \cdots \circ T_n$
  • Lazy evaluation (on-demand path exploration)
  • k-best path extraction (Viterbi, A*)

Layer 5: Hierarchical Corrector (New)

  • HierarchicalCorrector
  • High-level API for users
  • Combines spelling + grammar + context
  • Returns ranked corrections with scores

Core Components

1. Weight Trait (Semiring Abstraction)

/// 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)
    }
}

2. Weighted Transducer State

/// 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,
}

3. Weighted Transducer (Wraps Dictionary)

/// 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,
}

4. N-gram Language Model Transducer

/// 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()))
        }
    }
}

5. Composition Engine

/// 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
    }
}

Composition Algorithms

Algorithm 1: Lazy Composition (On-Demand)

Idea: Don't pre-compute entire composed automaton. Generate states and transitions as needed during query.

Complexity:

  • Time: $\mathcal{O}(\lvert T_{1}\rvert \times \lvert T_{2}\rvert)$ worst case, but often much better with pruning
  • Space: $\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))

Algorithm 2: K-Best Paths (Viterbi / A*)

Idea: Find top-k shortest paths through composed automaton using A* with admissible heuristic.

Complexity:

  • Time: $\mathcal{O}(k \times \lvert E\rvert \log \lvert V\rvert)$ using Dijkstra-style priority queue
  • Space: $\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

Algorithm 3: N-way Composition (Generalization)

Idea: Compose n transducers efficiently: $T = T_{1} \circ T_{2} \circ ... \circ T_{n}$

Approaches:

  1. Left-associative: $((T_{1} \circ T_{2}) \circ T_{3}) \circ ...$ (standard binary composition)
  2. Balanced: Binary tree of compositions (better parallelization)
  3. N-way direct: Simultaneous composition (Allauzen & Mohri, 2009)

Complexity (Allauzen & Mohri):

  • 3-way: $\mathcal{O}(\lvert T\rvert_Q \cdot \min (d(T_{1})\cdot d(T_{3}), d(T_{2})) + \lvert T\rvert_E)$
  • Dramatically faster than binary composition for n > 2

API Design

High-Level Corrector API

/// 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 Cases and Examples

Example 1: Contextual Spell Checking

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 { ... }

Example 2: Multi-Error Correction

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

Example 3: Grammar Correction

// 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);
}

Example 4: Compiler "Did You Mean" Suggestions

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:

  1. Multi-level Analysis

    • Lexical: Spelling (distance 1-2)
    • Syntactic: Grammar rules (keyword placement, syntax patterns)
    • Semantic: Type system (method exists, in scope, type-correct)
  2. Context-Aware

    • After let → suggest identifier, not keyword
    • In method call → suggest methods that exist on the type
    • Type mismatch → suggest identifiers with correct type
  3. Project-Specific

    • Learn from codebase (local variable/function names)
    • API-aware (standard library methods)
    • Convention-following (naming patterns)

Example 5: Language Classification

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:

  1. 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);
    
  2. 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");
    
  3. 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();
    
  4. 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:

  1. Character-Level N-grams

    • Capture language-specific character sequences
    • Robust to misspellings and typos
    • Works with small text snippets
  2. Fast Classification

    • $\mathcal{O}(\text{text} \text{length})$ scoring
    • No neural network overhead
    • Suitable for real-time applications
  3. Multilingual Support

    • Easy to add new languages (just provide corpus)
    • No retraining of existing models required
  4. Confidence Scores

    • Softmax probabilities for ambiguous cases
    • Can set threshold for "unknown language"

Example 6: OCR Post-Correction

// 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"

Implementation Plan

Phase 1: Weight Abstraction (Week 1)

Files to Create:

  • src/weighted/mod.rs - Weighted automata module
  • src/weighted/weight.rs - Weight trait and semirings
  • src/weighted/tropical.rs - TropicalWeight implementation
  • src/weighted/log.rs - LogWeight implementation

Tasks:

  1. ✅ Define Weight trait (semiring abstraction)
  2. ✅ Implement TropicalWeight (min, +)
  3. ✅ Implement LogWeight (log-sum-exp, +)
  4. ✅ Add unit tests for semiring properties
  5. ✅ Add numeric stability tests (log-sum-exp)

Phase 2: Weighted Transducer (Week 2)

Files to Create:

  • src/weighted/transducer.rs - WeightedTransducer wrapping Dictionary
  • src/weighted/state.rs - WeightedState and WeightedTransition

Tasks:

  1. ✅ Define WeightedTransducerTrait
  2. ✅ Implement WeightedTransducer<D, W> wrapper
  3. ✅ Convert existing Transducer queries to weighted
  4. ✅ Add tests: verify weights match edit distances

Phase 3: N-gram Language Model (Week 3-4)

Files to Create:

  • src/weighted/ngram.rs - N-gram transducer
  • src/weighted/smoothing.rs - Smoothing algorithms

Tasks:

  1. ✅ Implement n-gram counting from corpus
  2. ✅ Implement Laplace (add-k) smoothing
  3. ✅ Implement Kneser-Ney smoothing
  4. ✅ Implement backoff for unseen n-grams
  5. ✅ Add tests: verify probabilities sum to 1
  6. ✅ Add benchmarks: training on large corpus

Phase 4: Composition Engine (Week 5-6)

Files to Create:

  • src/weighted/composition.rs - Composition algorithms
  • src/weighted/kbest.rs - K-best path extraction

Tasks:

  1. ✅ Implement lazy composition (on-demand state generation)
  2. ✅ Implement k-best paths (Dijkstra/A* variant)
  3. ✅ Implement $\varepsilon$-transition handling
  4. ✅ Add pruning for efficiency (beam search)
  5. ✅ Add tests: verify composition correctness
  6. ✅ Add benchmarks: composition of large automata

Phase 5: Hierarchical Corrector (Week 7)

Files to Create:

  • src/weighted/corrector.rs - High-level HierarchicalCorrector API

Tasks:

  1. ✅ Implement HierarchicalCorrector
  2. ✅ Implement beam search for k-best sequences
  3. ✅ Implement score interpolation (spelling + grammar)
  4. ✅ Implement edit computation (Levenshtein alignment)
  5. ✅ Add comprehensive integration tests

Phase 6: Examples and Documentation (Week 8)

Files to Create:

  • examples/contextual_spell_check.rs
  • examples/grammar_correction.rs
  • examples/ocr_post_correction.rs
  • docs/WEIGHTED_AUTOMATA.md - User guide

Tasks:

  1. ✅ Write 4-5 runnable examples
  2. ✅ Write comprehensive user guide
  3. ✅ Add API documentation
  4. ✅ Update README.md with weighted automata mention

Phase 7: Optimization (Week 9-10)

Tasks:

  1. ✅ Profile composition engine
  2. ✅ Optimize k-best path extraction
  3. ✅ Add memoization for repeated queries
  4. ✅ Parallelize beam search
  5. ✅ Benchmark against baseline (word-level only)

Target Performance:

  • Composition: < 100ms for bigram LM + 100K dictionary
  • K-best (k=10): < 50ms for 10-word sentence
  • Memory: < 2x word-level transducer

Performance Analysis

Theoretical Complexity

OperationTime ComplexitySpace 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)$

Space Analysis

N-gram Language Model:

  • Bigram: $\mathcal{O}(V^{2})$ where V = vocabulary size
  • Trigram: $\mathcal{O}(V^{3})$ (can be large!)
  • Practical: Use pruning (remove low-prob n-grams), store sparse

Example: 100K vocabulary, bigram

  • Theoretical: 100K × 100K = 10B entries
  • Practical (with pruning): ~1-10M observed bigrams
  • Storage: ~40-400 MB (depends on sparsity)

Composition State Space:

  • Worst case: $\lvert T_1\rvert \times \lvert T_2\rvert$ states
  • Lazy evaluation: Only generate reachable states
  • Pruning: Discard low-probability paths
  • Typical: 1-10% of worst case

Benchmark Estimates

Setup:

  • Dictionary: 100K words
  • Bigram LM: 1M observed bigrams
  • Sentence: 10 words, 1-2 errors
  • Hardware: Modern CPU (2020+)

Expected Performance:

  • Lazy composition setup: < 10ms
  • K-best paths (k=10): 20-100ms
  • Total correction: 50-200ms
  • Memory: 100-500 MB (including LM)

Comparison:

  • Word-level only: 5-20ms (10x faster, but no context)
  • Neural LM (GPT-style): 100-1000ms (better quality, slower)

References

Academic Papers (with DOIs)

  1. 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

    • Foundational WFST paper
    • Composition, determinization, minimization algorithms
    • Applications to speech recognition with language models
  2. Mohri, M. (2009) "Weighted automata algorithms" Handbook of Weighted Automata, 213-254. DOI: 10.1007/978-3-642-01492-5_6

    • Comprehensive algorithm reference
    • Semiring theory and properties
    • Composition variants and optimizations
  3. 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

    • Efficient n-way composition
    • $\mathcal{O}(\lvert T\rvert \cdot \min (d_{1}d_{3}, d_{2}))$ for 3-way composition
    • Dramatically faster than binary composition
  4. Schulz, 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

    • Current basis for liblevenshtein
    • Levenshtein automaton construction
    • Can be composed with language models (this proposal)
  5. 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

    • Back-off smoothing for n-gram models
    • Handling unseen n-grams
  6. 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

    • Comprehensive comparison of smoothing methods
    • Kneser-Ney smoothing (state-of-the-art)
  7. Eppstein, D. (1998) "Finding the k shortest paths" SIAM Journal on Computing, 28(2), 652-673. DOI: 10.1137/S0097539795290477

    • K-best path algorithms
    • Applicable to weighted automata

Software References

  1. OpenFst Library

  2. Kaldi Speech Recognition Toolkit

Related Work in This Project

  • src/transducer/ - Existing Levenshtein automata (Level 1)
  • src/dictionary/ - Dictionary backends (can be weighted)
  • suffix-automaton.md - Substring matching (orthogonal feature)

Open Questions

1. Semiring Choice

Question: Should we support multiple semirings or standardize on one?

Options:

  • A. Generic over semiring (Tropical, Log, Probability) - flexible but complex API
  • B. Log semiring only - simpler, sufficient for most use cases
  • C. Configurable via feature flags

Recommendation: Start with B (Log only), add A as future enhancement

2. Language Model Storage

Question: How to store large n-gram models efficiently?

Options:

  • A. In-memory HashMap (simple, fast lookup)
  • B. On-disk database (SQLite, RocksDB) - lower memory
  • C. Quantized/compressed (pruning, byte-pair encoding)

Recommendation: Start with A, add B/C for production use

3. Neural Language Models

Question: Should we support neural LM (LSTM, Transformer) in addition to n-grams?

Options:

  • A. N-grams only (classical, fast, explainable)
  • B. Neural LM interface (better quality, slower, requires ML framework)
  • C. Hybrid (neural scores, n-gram structure)

Recommendation: Start with A, design API to allow B as plugin

4. Real-Time Constraints

Question: Can this support interactive applications (< 100ms latency)?

Challenges:

  • Large language models (memory footprint)
  • Composition overhead (state space explosion)
  • K-best path extraction (exponential worst case)

Solutions:

  • Pruning (beam search, threshold-based)
  • Caching (memoize repeated queries)
  • Quantization (reduce LM precision)

Recommendation: Benchmark aggressively, provide tuning knobs


Conclusion

This design proposes a weighted finite-state transducer framework for hierarchical error correction, enabling:

  1. Word-level correction - Spelling via Levenshtein automata (existing)
  2. Sequence-level correction - Grammar via n-gram language models (new)
  3. Composition - Combine automata for context-aware correction (new)
  4. K-best results - Ranked corrections with scores (new)

The implementation leverages:

  • Semiring abstraction for different weight types
  • Lazy composition for efficient state space exploration
  • Beam search for k-best path extraction
  • Classical n-gram models (extensible to neural LMs)

Applications: Contextual spell checking, grammar correction, OCR post-correction, speech recognition refinement.

Estimated Effort: 8-10 weeks for complete implementation including optimization.

Academic Foundation:

  • Mohri et al. (2002) - WFST composition
  • Schulz & Mihov (2002) - Levenshtein automata (current basis)
  • Allauzen & Mohri (2009) - N-way composition
  • Chen & Goodman (1999) - N-gram smoothing

Next Steps:

  1. Review and approve design
  2. Prioritize phases based on use cases
  3. Create GitHub issue with roadmap
  4. Begin Phase 1 implementation (Weight abstraction)

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

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close