Skip to main content

spell_checking/
spell_checking.rs

1//! Spell Checking Example
2//!
3//! This example demonstrates how to use Finite State Transducers (FSTs) to find
4//! spelling corrections within a specified edit distance. It shows:
5//! 1. Building a dictionary FST from a list of words
6//! 2. Creating an edit distance FST that accepts strings within distance k
7//! 3. Composing FSTs to find matching words
8//! 4. Finding the shortest paths to get the best corrections
9//! 5. Extracting and ranking results by edit distance
10//!
11//! This is a practical application showing how FSTs can be used for spell
12//! checking and fuzzy string matching in real-world applications.
13//!
14//! Related examples:
15//! - edit_distance.rs: Shows the basic edit distance computation that this builds upon
16//! - string_alignment.rs: Shows how to visualize the actual transformations
17//!
18//! Usage:
19//! ```bash
20//! cargo run --example spell_checking
21//! ```
22
23use arcweight::prelude::*;
24use std::collections::HashMap;
25
26/// Creates a dictionary FST from a list of words using a trie structure.
27///
28/// # Arguments
29/// * `words` - A slice of words to include in the dictionary
30///
31/// # Returns
32/// An FST that accepts exactly the words in the input list
33fn build_dictionary_fst(words: &[&str]) -> VectorFst<TropicalWeight> {
34    let mut fst = VectorFst::new();
35    let start = fst.add_state();
36    fst.set_start(start);
37
38    // Build a trie structure
39    let mut state_map: HashMap<Vec<char>, u32> = HashMap::new();
40    state_map.insert(vec![], start);
41
42    for word in words {
43        let chars: Vec<char> = word.chars().collect();
44        let mut prefix = vec![];
45
46        for (i, &ch) in chars.iter().enumerate() {
47            let current_state = *state_map.get(&prefix).unwrap();
48            prefix.push(ch);
49
50            if !state_map.contains_key(&prefix) {
51                let new_state = fst.add_state();
52                state_map.insert(prefix.clone(), new_state);
53                fst.add_arc(
54                    current_state,
55                    Arc::new(ch as u32, ch as u32, TropicalWeight::one(), new_state),
56                );
57            }
58
59            // If this is the last character, mark as final
60            if i == chars.len() - 1 {
61                let final_state = *state_map.get(&prefix).unwrap();
62                fst.set_final(final_state, TropicalWeight::one());
63            }
64        }
65    }
66
67    fst
68}
69
70/// Creates an FST that accepts all words within edit distance k of a target word
71///
72/// # Arguments
73/// * `target` - The target word to match against
74/// * `k` - Maximum allowed edit distance
75///
76/// # Returns
77/// An FST that accepts words within edit distance k of target
78fn build_edit_distance_fst(target: &str, k: usize) -> VectorFst<TropicalWeight> {
79    let mut fst = VectorFst::new();
80    let target_chars: Vec<char> = target.chars().collect();
81    let n = target_chars.len();
82
83    // Create states: (position in target, edits used)
84    let mut states = vec![vec![]; n + 1];
85    for (i, state_row) in states.iter_mut().enumerate().take(n + 1) {
86        for _j in 0..=k.min(i + k) {
87            state_row.push(fst.add_state());
88        }
89    }
90
91    // Start state
92    fst.set_start(states[0][0]);
93
94    // Final states - at end of target with <= k edits
95    for j in 0..=k.min(n + k) {
96        if j < states[n].len() {
97            fst.set_final(states[n][j], TropicalWeight::new(j as f32));
98        }
99    }
100
101    // Add transitions
102    for i in 0..n {
103        for j in 0..states[i].len() {
104            if j > i + k {
105                continue; // Skip impossible states
106            }
107
108            let current = states[i][j];
109
110            // Match (no cost)
111            if j < states[i + 1].len() {
112                fst.add_arc(
113                    current,
114                    Arc::new(
115                        target_chars[i] as u32,
116                        target_chars[i] as u32,
117                        TropicalWeight::one(),
118                        states[i + 1][j],
119                    ),
120                );
121            }
122
123            // If we can still make edits
124            if j < k {
125                // Substitution (cost 1)
126                if j + 1 < states[i + 1].len() {
127                    for c in b'a'..=b'z' {
128                        if c as char != target_chars[i] {
129                            fst.add_arc(
130                                current,
131                                Arc::new(
132                                    c as u32,
133                                    c as u32,
134                                    TropicalWeight::new(1.0),
135                                    states[i + 1][j + 1],
136                                ),
137                            );
138                        }
139                    }
140                }
141
142                // Deletion in target (consume target char with epsilon)
143                if j + 1 < states[i + 1].len() {
144                    fst.add_arc(
145                        current,
146                        Arc::new(
147                            0, // epsilon
148                            0, // epsilon
149                            TropicalWeight::new(1.0),
150                            states[i + 1][j + 1],
151                        ),
152                    );
153                }
154
155                // Insertion (consume input char)
156                if j + 1 < states[i].len() {
157                    for c in b'a'..=b'z' {
158                        fst.add_arc(
159                            current,
160                            Arc::new(
161                                c as u32,
162                                c as u32,
163                                TropicalWeight::new(1.0),
164                                states[i][j + 1],
165                            ),
166                        );
167                    }
168                }
169            }
170        }
171    }
172
173    // Handle insertions at the end
174    for j in 0..states[n].len() {
175        if j < k && j + 1 < states[n].len() {
176            let current = states[n][j];
177            for c in b'a'..=b'z' {
178                fst.add_arc(
179                    current,
180                    Arc::new(
181                        c as u32,
182                        c as u32,
183                        TropicalWeight::new(1.0),
184                        states[n][j + 1],
185                    ),
186                );
187            }
188        }
189    }
190
191    fst
192}
193
194/// Finds spelling corrections in dictionary within edit distance of target
195fn find_spelling_corrections(
196    dict_fst: &VectorFst<TropicalWeight>,
197    target: &str,
198    max_distance: usize,
199) -> Result<Vec<(String, f32)>> {
200    // Build edit distance FST
201    let edit_fst = build_edit_distance_fst(target, max_distance);
202
203    // Compose dictionary with edit distance FST
204    let composed: VectorFst<TropicalWeight> = compose_default(dict_fst, &edit_fst)?;
205
206    // Find shortest paths
207    let config = ShortestPathConfig {
208        nshortest: 10,
209        ..Default::default()
210    };
211    let shortest: VectorFst<TropicalWeight> = shortest_path(&composed, config)?;
212
213    // Extract words and distances
214    let mut results = Vec::new();
215
216    if let Some(start) = shortest.start() {
217        extract_paths(&shortest, start, &mut Vec::new(), 0.0, &mut results);
218    }
219
220    // Deduplicate results and keep the best score for each word
221    let mut word_scores: HashMap<String, f32> = HashMap::new();
222    for (word, score) in results {
223        word_scores
224            .entry(word)
225            .and_modify(|e| *e = e.min(score))
226            .or_insert(score);
227    }
228
229    // Convert back to vec and sort by distance
230    let mut final_results: Vec<(String, f32)> = word_scores.into_iter().collect();
231    final_results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
232    Ok(final_results)
233}
234
235/// Helper to extract paths from FST
236fn extract_paths(
237    fst: &VectorFst<TropicalWeight>,
238    state: u32,
239    path: &mut Vec<char>,
240    cost: f32,
241    results: &mut Vec<(String, f32)>,
242) {
243    if fst.is_final(state) {
244        let word: String = path.iter().collect();
245        if let Some(weight) = fst.final_weight(state) {
246            results.push((word, cost + weight.value()));
247        }
248    }
249
250    for arc in fst.arcs(state) {
251        if arc.olabel != 0 {
252            path.push(arc.olabel as u8 as char);
253            extract_paths(fst, arc.nextstate, path, cost + arc.weight.value(), results);
254            path.pop();
255        } else {
256            extract_paths(fst, arc.nextstate, path, cost + arc.weight.value(), results);
257        }
258    }
259}
260
261fn main() -> Result<()> {
262    println!("Spell Checking Example");
263    println!("======================\n");
264
265    // Build a dictionary of common English words
266    let dictionary = vec![
267        "hello",
268        "world",
269        "help",
270        "held",
271        "hell",
272        "hold",
273        "hero",
274        "here",
275        "hear",
276        "heap",
277        "heal",
278        "health",
279        "helm",
280        "helps",
281        "friend",
282        "friends",
283        "friendship",
284        "friendly",
285        "fresh",
286        "spell",
287        "spelling",
288        "spelled",
289        "spells",
290        "special",
291        "check",
292        "checking",
293        "checked",
294        "checker",
295        "checks",
296        "correct",
297        "correction",
298        "corrected",
299        "correctly",
300        "corrects",
301        "example",
302        "examples",
303        "exemplary",
304        "exempt",
305        "exemplify",
306    ];
307
308    let dictionary_len = dictionary.len();
309    println!("Dictionary contains {dictionary_len} words\n");
310
311    // Build the dictionary FST
312    let dict_fst = build_dictionary_fst(&dictionary);
313
314    // Test words with typos
315    let test_words = vec![
316        ("helo", 2),    // "hello" with one deletion
317        ("wrold", 2),   // "world" with transposition
318        ("frend", 2),   // "friend" with deletion
319        ("chekc", 2),   // "check" with transposition
320        ("speling", 2), // "spelling" with deletion
321        ("corect", 2),  // "correct" with deletion
322        ("exmple", 2),  // "example" with deletion
323        ("healht", 2),  // "health" with transposition
324    ];
325
326    for (misspelled, max_distance) in test_words {
327        println!(
328            "Finding spelling corrections for '{misspelled}' (max edit distance: {max_distance}):"
329        );
330        let line = "-".repeat(50);
331        println!("{line}");
332
333        let corrections = find_spelling_corrections(&dict_fst, misspelled, max_distance)?;
334
335        if corrections.is_empty() {
336            println!("  No spelling corrections found within edit distance {max_distance}");
337        } else {
338            for (word, distance) in corrections.iter().take(5) {
339                println!("  {word} (distance: {distance})");
340            }
341        }
342        println!();
343    }
344
345    // Demonstrate finding words within edit distance 1
346    println!("\nWords within edit distance 1 of 'help':");
347    let line = "=".repeat(40);
348    println!("{line}");
349
350    let corrections = find_spelling_corrections(&dict_fst, "help", 1)?;
351    for (word, distance) in corrections {
352        if distance <= 1.0 {
353            println!("  {word} (distance: {distance})");
354        }
355    }
356
357    // Show how different edit distances affect results
358    println!("\n\nEffect of different edit distances for 'wrld':");
359    let line = "=".repeat(50);
360    println!("{line}");
361
362    for k in 1..=3 {
363        println!("\nEdit distance <= {k}:");
364        let corrections = find_spelling_corrections(&dict_fst, "wrld", k)?;
365        for (word, distance) in corrections.iter().take(5) {
366            println!("  {word} (distance: {distance})");
367        }
368    }
369
370    Ok(())
371}