Skip to main content

string_alignment/
string_alignment.rs

1//! String Alignment Example
2//!
3//! This example demonstrates how to compute optimal string alignments using weighted finite state
4//! transducers. It extends the edit distance algorithm to track and visualize the actual sequence
5//! of operations that transform one string into another.
6//!
7//! Key concepts demonstrated:
8//! - Building alignment transducers that preserve transformation information
9//! - Extracting optimal alignment paths from composed FSTs
10//! - Visualizing alignments with different formatting options
11//! - Handling multiple optimal alignments
12//! - FST-based path extraction for alignment reconstruction
13//!
14//! Related examples:
15//! - edit_distance.rs: Shows basic edit distance computation with FSTs
16//! - spell_checking.rs: Uses edit distance for spell correction
17//!
18//! Usage: cargo run --example string_alignment
19
20use anyhow::Result;
21use arcweight::prelude::*;
22
23/// Represents a single alignment operation
24#[derive(Debug, Clone, PartialEq)]
25enum AlignmentOp {
26    Match(char, char),      // Characters match
27    Substitute(char, char), // Substitute source -> target
28    Insert(char),           // Insert character into source
29    Delete(char),           // Delete character from source
30}
31
32/// Represents a complete alignment between two strings
33#[derive(Debug, Clone)]
34struct Alignment {
35    operations: Vec<AlignmentOp>,
36    cost: f32,
37    #[allow(dead_code)]
38    source: String,
39    #[allow(dead_code)]
40    target: String,
41}
42
43impl Alignment {
44    /// Create visualization of the alignment
45    fn visualize(&self) -> String {
46        let mut source_line = String::new();
47        let mut alignment_line = String::new();
48        let mut target_line = String::new();
49
50        for op in &self.operations {
51            match op {
52                AlignmentOp::Match(s, t) => {
53                    source_line.push(*s);
54                    alignment_line.push('|');
55                    target_line.push(*t);
56                }
57                AlignmentOp::Substitute(s, t) => {
58                    source_line.push(*s);
59                    alignment_line.push('*');
60                    target_line.push(*t);
61                }
62                AlignmentOp::Insert(c) => {
63                    source_line.push('-');
64                    alignment_line.push('+');
65                    target_line.push(*c);
66                }
67                AlignmentOp::Delete(c) => {
68                    source_line.push(*c);
69                    alignment_line.push('-');
70                    target_line.push('-');
71                }
72            }
73        }
74
75        format!("{source_line}\n{alignment_line}\n{target_line}")
76    }
77
78    /// Create a detailed description of the alignment
79    fn describe(&self) -> String {
80        let mut description = Vec::new();
81        let mut source_pos = 0;
82        let mut target_pos = 0;
83
84        for op in &self.operations {
85            match op {
86                AlignmentOp::Match(s, _) => {
87                    description.push(format!(
88                        "Match '{s}' at positions {source_pos}/{target_pos}"
89                    ));
90                    source_pos += 1;
91                    target_pos += 1;
92                }
93                AlignmentOp::Substitute(s, t) => {
94                    description.push(format!(
95                        "Substitute '{s}' -> '{t}' at positions {source_pos}/{target_pos}"
96                    ));
97                    source_pos += 1;
98                    target_pos += 1;
99                }
100                AlignmentOp::Insert(c) => {
101                    description.push(format!("Insert '{c}' at target position {target_pos}"));
102                    target_pos += 1;
103                }
104                AlignmentOp::Delete(c) => {
105                    description.push(format!("Delete '{c}' at source position {source_pos}"));
106                    source_pos += 1;
107                }
108            }
109        }
110
111        description.join("\n")
112    }
113}
114
115/// Builds an FST that computes edit distance and preserves alignment information
116/// by encoding operations in the output symbols
117fn build_alignment_fst(
118    source: &str,
119    target: &str,
120    insertion_cost: f32,
121    deletion_cost: f32,
122    substitution_cost: f32,
123) -> VectorFst<TropicalWeight> {
124    let mut fst = VectorFst::new();
125    let source_chars: Vec<char> = source.chars().collect();
126    let target_chars: Vec<char> = target.chars().collect();
127    let m = source_chars.len();
128    let n = target_chars.len();
129
130    // Create states for the edit distance lattice
131    let mut states = vec![vec![]; m + 1];
132    for state_row in states.iter_mut().take(m + 1) {
133        for _j in 0..=n {
134            state_row.push(fst.add_state());
135        }
136    }
137
138    // Set start state and final state
139    fst.set_start(states[0][0]);
140    fst.set_final(states[m][n], TropicalWeight::one());
141
142    // Add transitions with operation encoding in output symbols
143    // We'll use a simple encoding: 1=match, 2=substitute, 3=insert, 4=delete
144    const MATCH: u32 = 1;
145    const SUBSTITUTE: u32 = 2;
146    const INSERT: u32 = 3;
147    const DELETE: u32 = 4;
148
149    for i in 0..=m {
150        #[allow(clippy::needless_range_loop)]
151        for j in 0..=n {
152            let current_state = states[i][j];
153
154            // Deletion: consume source character, no target character
155            if i < m {
156                let next_state = states[i + 1][j];
157                fst.add_arc(
158                    current_state,
159                    Arc::new(
160                        source_chars[i] as u32,
161                        DELETE,
162                        TropicalWeight::new(deletion_cost),
163                        next_state,
164                    ),
165                );
166            }
167
168            // Insertion: no source character, consume target character
169            if j < n {
170                let next_state = states[i][j + 1];
171                fst.add_arc(
172                    current_state,
173                    Arc::new(
174                        0, // epsilon input
175                        INSERT,
176                        TropicalWeight::new(insertion_cost),
177                        next_state,
178                    ),
179                );
180            }
181
182            // Match or substitution: consume both characters
183            if i < m && j < n {
184                let next_state = states[i + 1][j + 1];
185                let source_char = source_chars[i] as u32;
186                let target_char = target_chars[j] as u32;
187
188                if source_char == target_char {
189                    // Match
190                    fst.add_arc(
191                        current_state,
192                        Arc::new(source_char, MATCH, TropicalWeight::one(), next_state),
193                    );
194                } else {
195                    // Substitution
196                    fst.add_arc(
197                        current_state,
198                        Arc::new(
199                            source_char,
200                            SUBSTITUTE,
201                            TropicalWeight::new(substitution_cost),
202                            next_state,
203                        ),
204                    );
205                }
206            }
207        }
208    }
209
210    fst
211}
212
213/// Extracts alignment from the shortest path through the alignment FST
214fn extract_alignment_from_path(
215    source: &str,
216    target: &str,
217    shortest_path_fst: &VectorFst<TropicalWeight>,
218) -> Result<Alignment> {
219    let source_chars: Vec<char> = source.chars().collect();
220    let target_chars: Vec<char> = target.chars().collect();
221    let mut operations = Vec::new();
222    let mut total_cost = 0.0;
223
224    // Find path through the FST by following arcs from start to final state
225    let start_state = shortest_path_fst.start().unwrap();
226    let mut current_state = start_state;
227    let mut source_pos = 0;
228    let mut target_pos = 0;
229
230    // Extract operations by traversing the path
231    let mut arc_iter = shortest_path_fst.arcs(current_state);
232    let mut has_arcs = arc_iter.by_ref().count() > 0;
233
234    while current_state != shortest_path_fst.start().unwrap() || has_arcs {
235        let mut found_arc = false;
236
237        if let Some(arc) = shortest_path_fst.arcs(current_state).next() {
238            // Follow the first (and should be only) arc in shortest path
239            let _input_symbol = arc.ilabel;
240            let output_symbol = arc.olabel;
241            let weight = arc.weight.value();
242
243            total_cost += weight;
244
245            match output_symbol {
246                1 => {
247                    // Match
248                    if source_pos < source_chars.len() && target_pos < target_chars.len() {
249                        operations.push(AlignmentOp::Match(
250                            source_chars[source_pos],
251                            target_chars[target_pos],
252                        ));
253                        source_pos += 1;
254                        target_pos += 1;
255                    }
256                }
257                2 => {
258                    // Substitute
259                    if source_pos < source_chars.len() && target_pos < target_chars.len() {
260                        operations.push(AlignmentOp::Substitute(
261                            source_chars[source_pos],
262                            target_chars[target_pos],
263                        ));
264                        source_pos += 1;
265                        target_pos += 1;
266                    }
267                }
268                3 => {
269                    // Insert
270                    if target_pos < target_chars.len() {
271                        operations.push(AlignmentOp::Insert(target_chars[target_pos]));
272                        target_pos += 1;
273                    }
274                }
275                4 => {
276                    // Delete
277                    if source_pos < source_chars.len() {
278                        operations.push(AlignmentOp::Delete(source_chars[source_pos]));
279                        source_pos += 1;
280                    }
281                }
282                _ => {
283                    // Unknown operation
284                    found_arc = false;
285                }
286            }
287
288            if output_symbol <= 4 {
289                current_state = arc.nextstate;
290                found_arc = true;
291            }
292        }
293
294        if !found_arc {
295            break;
296        }
297
298        // Update has_arcs for next iteration
299        let mut arc_iter = shortest_path_fst.arcs(current_state);
300        has_arcs = arc_iter.by_ref().count() > 0;
301    }
302
303    Ok(Alignment {
304        operations,
305        cost: total_cost,
306        source: source.to_string(),
307        target: target.to_string(),
308    })
309}
310
311/// Compute string alignment using FST-based approach
312fn compute_alignment_fst(source: &str, target: &str) -> Result<Alignment> {
313    // Build alignment FST
314    let alignment_fst = build_alignment_fst(source, target, 1.0, 1.0, 1.0);
315
316    // Find shortest path
317    let shortest = shortest_path(&alignment_fst, ShortestPathConfig::default())?;
318
319    // Extract alignment from shortest path
320    extract_alignment_from_path(source, target, &shortest)
321}
322
323/// Demonstrate string alignment functionality with both FST-based and manual examples  
324fn demonstrate_string_alignment() -> Result<()> {
325    println!("String Alignment Example");
326    println!("========================\n");
327
328    // First demonstrate FST-based alignment computation
329    println!("1. FST-based Alignment Computation:");
330    println!("------------------------------------");
331
332    let test_pairs = vec![("kitten", "sitting"), ("hello", "hallo"), ("cat", "dog")];
333
334    for (source, target) in &test_pairs {
335        println!("\nComputing alignment for '{source}' -> '{target}':");
336        match compute_alignment_fst(source, target) {
337            Ok(alignment) => {
338                let cost = alignment.cost;
339                println!("FST-computed cost: {cost}");
340                println!("Visualization:");
341                let viz = alignment.visualize();
342                println!("{viz}");
343            }
344            Err(e) => {
345                println!("Error computing FST alignment: {e}");
346                println!("Falling back to manual example...");
347            }
348        }
349    }
350
351    println!("\n2. Manual Alignment Examples (for comparison):");
352    println!("-----------------------------------------------");
353
354    // Manually create some alignment examples to show the concept
355    let examples = vec![
356        (
357            "kitten",
358            "sitting",
359            vec![
360                AlignmentOp::Substitute('k', 's'),
361                AlignmentOp::Match('i', 'i'),
362                AlignmentOp::Match('t', 't'),
363                AlignmentOp::Match('t', 't'),
364                AlignmentOp::Substitute('e', 'i'),
365                AlignmentOp::Insert('n'),
366                AlignmentOp::Substitute('n', 'g'),
367            ],
368            3.0,
369        ),
370        (
371            "hello",
372            "hallo",
373            vec![
374                AlignmentOp::Match('h', 'h'),
375                AlignmentOp::Substitute('e', 'a'),
376                AlignmentOp::Match('l', 'l'),
377                AlignmentOp::Match('l', 'l'),
378                AlignmentOp::Match('o', 'o'),
379            ],
380            1.0,
381        ),
382        (
383            "cat",
384            "dog",
385            vec![
386                AlignmentOp::Substitute('c', 'd'),
387                AlignmentOp::Substitute('a', 'o'),
388                AlignmentOp::Substitute('t', 'g'),
389            ],
390            3.0,
391        ),
392    ];
393
394    for (source, target, operations, cost) in examples {
395        let alignment = Alignment {
396            operations,
397            cost,
398            source: source.to_string(),
399            target: target.to_string(),
400        };
401
402        println!("\nAligning '{source}' -> '{target}':");
403        let cost = alignment.cost;
404        println!("Cost: {cost}");
405        println!("Visualization:");
406        let viz = alignment.visualize();
407        println!("{viz}");
408    }
409
410    // Show how the same transformation can have multiple optimal paths
411    println!("\n3. Multiple Optimal Alignments:");
412    println!("--------------------------------");
413    println!("For transforming 'abc' -> 'aec' (cost 1):");
414
415    let alignment1 = Alignment {
416        operations: vec![
417            AlignmentOp::Match('a', 'a'),
418            AlignmentOp::Substitute('b', 'e'),
419            AlignmentOp::Match('c', 'c'),
420        ],
421        cost: 1.0,
422        source: "abc".to_string(),
423        target: "aec".to_string(),
424    };
425
426    println!("\nOption 1 - Direct substitution:");
427    let viz = alignment1.visualize();
428    println!("{viz}");
429
430    // Alternative with deletion and insertion (if they had equal cost)
431    println!("\nOption 2 - Delete and insert (if costs were equal):");
432    let alignment2 = Alignment {
433        operations: vec![
434            AlignmentOp::Match('a', 'a'),
435            AlignmentOp::Delete('b'),
436            AlignmentOp::Insert('e'),
437            AlignmentOp::Match('c', 'c'),
438        ],
439        cost: 2.0,
440        source: "abc".to_string(),
441        target: "aec".to_string(),
442    };
443    let viz = alignment2.visualize();
444    println!("{viz}");
445
446    // Biological sequence example
447    println!("\n4. Biological Sequence Alignment:");
448    println!("---------------------------------");
449
450    let dna_alignment = Alignment {
451        operations: vec![
452            AlignmentOp::Match('a', 'a'),
453            AlignmentOp::Match('c', 'c'),
454            AlignmentOp::Substitute('g', 't'),
455            AlignmentOp::Match('t', 't'),
456            AlignmentOp::Match('a', 'a'),
457            AlignmentOp::Match('c', 'c'),
458            AlignmentOp::Match('g', 'g'),
459            AlignmentOp::Match('t', 't'),
460        ],
461        cost: 1.0,
462        source: "acgtacgt".to_string(),
463        target: "acttacgt".to_string(),
464    };
465
466    println!("DNA sequence alignment:");
467    println!("Sequence 1: acgtacgt");
468    println!("Sequence 2: acttacgt");
469    let cost = dna_alignment.cost;
470    println!("\nOptimal alignment (cost: {cost}):");
471    let viz = dna_alignment.visualize();
472    println!("{viz}");
473
474    // Count operation types
475    let mut matches = 0;
476    let mut substitutions = 0;
477    let mut indels = 0;
478
479    for op in &dna_alignment.operations {
480        match op {
481            AlignmentOp::Match(_, _) => matches += 1,
482            AlignmentOp::Substitute(_, _) => substitutions += 1,
483            AlignmentOp::Insert(_) | AlignmentOp::Delete(_) => indels += 1,
484        }
485    }
486
487    println!("\nAlignment statistics:");
488    println!("  Matches: {matches}");
489    println!("  Substitutions: {substitutions}");
490    println!("  Insertions/Deletions: {indels}");
491    println!(
492        "  Similarity: {:.1}%",
493        (matches as f32 / dna_alignment.operations.len() as f32) * 100.0
494    );
495
496    // Show detailed operation description
497    println!("\n5. Detailed Operation Description:");
498    println!("----------------------------------");
499    println!("Operations for 'hello' -> 'hallo':");
500
501    let hello_alignment = Alignment {
502        operations: vec![
503            AlignmentOp::Match('h', 'h'),
504            AlignmentOp::Substitute('e', 'a'),
505            AlignmentOp::Match('l', 'l'),
506            AlignmentOp::Match('l', 'l'),
507            AlignmentOp::Match('o', 'o'),
508        ],
509        cost: 1.0,
510        source: "hello".to_string(),
511        target: "hallo".to_string(),
512    };
513
514    let desc = hello_alignment.describe();
515    println!("{desc}");
516
517    Ok(())
518}
519
520fn main() -> Result<()> {
521    demonstrate_string_alignment()?;
522
523    println!("\n=== Summary ===");
524    println!("This example showed how to:");
525    println!("- Build FSTs that encode alignment operations in output symbols");
526    println!("- Extract alignment paths from FST shortest paths");
527    println!("- Visualize string transformations with detailed operations");
528    println!("- Handle multiple optimal alignments");
529    println!("- Apply alignment to biological sequences");
530    println!("- Compare FST-based and manual alignment approaches");
531
532    Ok(())
533}