Skip to main content

sicada_decode/
viterbi.rs

1//! Frame-synchronous Viterbi beam search over a decoding graph.
2//!
3//! What this computes is the best path of `graph ∘ dense`, without ever building
4//! that composition. The composition's states are pairs `(graph state, frame)`,
5//! and the frame moves in lockstep for every one of them, so the whole thing
6//! can be walked one frame at a time keeping only the graph states alive at
7//! that frame. That is the standard decoder shape (Kaldi's `SimpleDecoder` and
8//! `FasterDecoder`, k2's `intersect_dense_pruned`), and it keeps decoding linear
9//! in `T` without materialising the composition.
10//!
11//! Two kinds of arc leave a graph state:
12//!
13//! - an **emitting** arc, whose input label names a column of the acoustic
14//!   matrix. Taking it consumes one frame and pays that frame's score.
15//! - an **epsilon** arc, which consumes no frame. These are relaxed to a fixed
16//!   point within the frame, before and after each emitting step.
17//!
18//! [`viterbi_decode`] returns the best path only. To keep the alternatives as
19//! well, so that a second pass has something to rescore, use
20//! [`lattice_decode`](crate::lattice::lattice_decode).
21
22use rustc_hash::FxHashMap;
23
24use sicada::arc::{Arc, ArcLabel};
25use sicada::error::OpenFstError;
26use sicada::fst::Fst;
27use sicada::weight::PathWeight;
28
29use crate::dense::{DenseFst, FromScore};
30use crate::frontier::{DecodeOptions, NO_AUX, Token, prune};
31
32/// The best path through the graph, given the acoustic scores.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Decoded<A: Arc> {
35    /// The output labels along the path, epsilons removed.
36    pub labels: Vec<A::Label>,
37    /// The path's weight: the graph's costs, the acoustic scores and the final
38    /// weight, all multiplied together.
39    pub weight: A::Weight,
40}
41
42/// One step of a path, kept so the answer can be read back.
43///
44/// A frame keeps at most `max_active` of these, but never releases the ones
45/// behind it, so the arena grows with `T × max_active`. Reference-counted
46/// tokens that free a dead prefix are what Kaldi does and are outstanding here.
47#[derive(Debug, Clone, Copy)]
48struct Link<L> {
49    prev: u32,
50    olabel: L,
51}
52
53/// Returns the best path of `graph ∘ dense`, or `None` if the beam killed every
54/// path before the last frame.
55///
56/// The graph's *input* labels are matched against the acoustic model's columns
57/// and its *output* labels are what comes back, which is the usual arrangement:
58/// input side indexed by the model, output side in words.
59///
60/// # Errors
61///
62/// A non-epsilon input label that names no column of `dense` is a mismatch
63/// between the graph and the acoustic model, and is reported rather than
64/// skipped: dropping such an arc would return a worse path with no indication
65/// that anything was wrong. Remove disambiguation symbols from the graph before
66/// decoding.
67pub fn viterbi_decode<A, G>(
68    graph: &G,
69    dense: &DenseFst<'_, A>,
70    opts: &DecodeOptions,
71) -> Result<Option<Decoded<A>>, OpenFstError>
72where
73    A: Arc,
74    A::Weight: FromScore + PathWeight,
75    G: Fst<A>,
76{
77    let Some(start) = graph.start() else {
78        return Ok(None);
79    };
80
81    let mut links: Vec<Link<A::Label>> = Vec::new();
82    let mut current: FxHashMap<A::StateId, Token> = FxHashMap::default();
83    let mut next: FxHashMap<A::StateId, Token> = FxHashMap::default();
84    let mut queue: Vec<A::StateId> = Vec::new();
85    let mut costs: Vec<f32> = Vec::new();
86
87    current.insert(
88        start,
89        Token {
90            cost: 0.0,
91            aux: NO_AUX,
92        },
93    );
94    relax_epsilons(graph, &mut current, &mut links, &mut queue, f32::INFINITY)?;
95
96    for t in 0..dense.num_frames() {
97        let frame = dense.frame(t);
98        next.clear();
99
100        for (&state, &token) in &current {
101            for arc in graph.arcs(state) {
102                if arc.ilabel() == A::Label::epsilon() {
103                    continue;
104                }
105                let Some(column) = dense.column_of(arc.ilabel()) else {
106                    return Err(OpenFstError::InvalidOperation(format!(
107                        "viterbi_decode: the graph has input label {} at state {state:?}, which \
108                         names no column of a {}-symbol acoustic matrix",
109                        arc.ilabel(),
110                        dense.num_symbols()
111                    )));
112                };
113                let cost = token.cost + arc.weight().to_cost() + frame[column];
114                relax(
115                    &mut next,
116                    &mut links,
117                    arc.nextstate(),
118                    cost,
119                    token.aux,
120                    arc.olabel(),
121                );
122            }
123        }
124
125        if next.is_empty() {
126            return Ok(None);
127        }
128        let cutoff = prune(&mut next, opts, &mut costs);
129        relax_epsilons(graph, &mut next, &mut links, &mut queue, cutoff)?;
130        // Epsilon arcs can only have added states at or under the cutoff, so
131        // the beam still holds; the cap may not, and is re-applied.
132        if next.len() > opts.max_active {
133            prune(&mut next, opts, &mut costs);
134        }
135
136        std::mem::swap(&mut current, &mut next);
137    }
138
139    let mut best: Option<(f32, u32)> = None;
140    for (&state, &token) in &current {
141        let final_cost = graph.final_weight(state).to_cost();
142        if !final_cost.is_finite() {
143            continue;
144        }
145        let total = token.cost + final_cost;
146        if best.is_none_or(|(so_far, _)| total < so_far) {
147            best = Some((total, token.aux));
148        }
149    }
150
151    Ok(best.map(|(total, link)| Decoded {
152        labels: trace_back(&links, link),
153        weight: A::Weight::from_cost(total),
154    }))
155}
156
157/// Records `cost` at `state` if it beats what is already there.
158///
159/// Written over the state-id and label types rather than over the arc: `A`
160/// would appear only behind `A::StateId` and `A::Label`, and an associated type
161/// does not determine the type it came from, so every call would have to name
162/// the arc.
163#[inline]
164fn relax<S, L>(
165    frontier: &mut FxHashMap<S, Token>,
166    links: &mut Vec<Link<L>>,
167    state: S,
168    cost: f32,
169    prev_link: u32,
170    olabel: L,
171) -> bool
172where
173    S: std::hash::Hash + Eq,
174    L: ArcLabel,
175{
176    match frontier.get_mut(&state) {
177        Some(token) if token.cost <= cost => false,
178        slot => {
179            // An epsilon output label adds nothing to read back, so the path
180            // keeps pointing at whatever came before it.
181            let link = if olabel == L::epsilon() {
182                prev_link
183            } else {
184                links.push(Link {
185                    prev: prev_link,
186                    olabel,
187                });
188                (links.len() - 1) as u32
189            };
190            let token = Token { cost, aux: link };
191            match slot {
192                Some(existing) => *existing = token,
193                None => {
194                    frontier.insert(state, token);
195                }
196            }
197            true
198        }
199    }
200}
201
202/// Relaxes the graph's epsilon arcs over `frontier` until nothing improves.
203///
204/// Epsilon arcs consume no frame, so they may be taken any number of times
205/// within one; the fixed point is the shortest distance over them. A decoding
206/// graph's epsilon arcs cost nothing or cost something, never less than
207/// nothing, so every relaxation strictly lowers a cost and the loop ends. A
208/// graph that breaks that is reported rather than spun on.
209fn relax_epsilons<A, G>(
210    graph: &G,
211    frontier: &mut FxHashMap<A::StateId, Token>,
212    links: &mut Vec<Link<A::Label>>,
213    queue: &mut Vec<A::StateId>,
214    cutoff: f32,
215) -> Result<(), OpenFstError>
216where
217    A: Arc,
218    A::Weight: FromScore,
219    G: Fst<A>,
220{
221    queue.clear();
222    queue.extend(frontier.keys().copied());
223
224    // Generous: every state may legitimately be re-reached once per other
225    // state on its epsilon path. Anything past this is a negative cycle.
226    let budget = frontier.len().saturating_mul(64).saturating_add(1024);
227    let mut steps = 0usize;
228
229    while let Some(state) = queue.pop() {
230        steps += 1;
231        if steps > budget {
232            return Err(OpenFstError::InvalidOperation(
233                "viterbi_decode: the graph's epsilon arcs do not settle, which means a cycle of \
234                 them costs less than nothing"
235                    .into(),
236            ));
237        }
238        let token = frontier[&state];
239        for arc in graph.arcs(state) {
240            if arc.ilabel() != A::Label::epsilon() {
241                continue;
242            }
243            let cost = token.cost + arc.weight().to_cost();
244            if cost > cutoff {
245                continue;
246            }
247            if relax(
248                frontier,
249                links,
250                arc.nextstate(),
251                cost,
252                token.aux,
253                arc.olabel(),
254            ) {
255                queue.push(arc.nextstate());
256            }
257        }
258    }
259    Ok(())
260}
261
262/// Walks the links back to the start, yielding the labels in order.
263fn trace_back<L: Copy>(links: &[Link<L>], mut link: u32) -> Vec<L> {
264    let mut labels = Vec::new();
265    while link != NO_AUX {
266        let step = links[link as usize];
267        labels.push(step.olabel);
268        link = step.prev;
269    }
270    labels.reverse();
271    labels
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use sicada::algorithms::arcsort::{ILabelCompare, arc_sort};
278    use sicada::algorithms::compose::compose;
279    use sicada::algorithms::shortest_path::{ShortestPathOptions, shortest_path};
280    use sicada::arc::StdArc;
281    use sicada::fst::MutableFst;
282    use sicada::fsts::vector_fst::{StdVectorFst, VectorFst};
283    use sicada::properties::K_FST_PROPERTIES;
284    use sicada::string::string_fst_to_output_labels;
285    use sicada::weight::Weight;
286    use sicada::weights::float_weight::TropicalWeight;
287
288    // The answer the decoder is supposed to agree with: build the whole
289    // composition and take its shortest path.
290    //
291    // This is exactly the work the decoder exists to avoid, which is why it
292    // makes a good oracle: it shares no code with the thing under test beyond
293    // the FST types themselves.
294    fn by_composition(
295        graph: &StdVectorFst,
296        dense: &DenseFst<'_, StdArc>,
297    ) -> Option<(Vec<i32>, f32)> {
298        // `dense ∘ graph`, not the other way round: composition matches the
299        // left FST's *output* labels against the right one's *input* labels,
300        // and it is the acoustic symbols that meet, leaving the graph's words
301        // on the output side.
302        let mut sorted = graph.clone();
303        arc_sort(&mut sorted, &ILabelCompare);
304        let mut composed: StdVectorFst = VectorFst::new();
305        compose(dense, &sorted, &mut composed).expect("a composition");
306        composed.start()?;
307        let mut best: StdVectorFst = VectorFst::new();
308        shortest_path(&composed, &mut best, &ShortestPathOptions::default()).expect("a best path");
309        best.start()?;
310        let (labels, weight) = string_fst_to_output_labels(&best).expect("a single path");
311        // The decoder reports the labels a reader wants, so epsilon outputs,
312        // which the path does carry, are dropped on both sides.
313        Some((labels.into_iter().filter(|&l| l != 0).collect(), weight.0))
314    }
315
316    // A graph over 3 symbols (columns 0..2, labels 1..3) that accepts any
317    // sequence, mapping label 1 to output 10, 2 to 20, 3 to 30.
318    fn free_graph() -> StdVectorFst {
319        let mut fst = VectorFst::new();
320        fst.add_state();
321        fst.set_start(0);
322        fst.set_final(0, TropicalWeight::one());
323        for label in 1..=3 {
324            fst.add_arc(0, StdArc::new(label, label * 10, TropicalWeight::one(), 0));
325        }
326        fst.properties(K_FST_PROPERTIES, true);
327        fst
328    }
329
330    #[test]
331    fn it_picks_the_best_symbol_in_every_frame() {
332        // Frame 0 likes symbol 2, frame 1 likes symbol 0, frame 2 likes 1.
333        let scores = [
334            5.0, 1.0, 9.0, //
335            0.5, 4.0, 4.0, //
336            3.0, 0.25, 3.0,
337        ];
338        let dense = DenseFst::<StdArc>::new(&scores, 3, 3).unwrap();
339        let decoded = viterbi_decode(&free_graph(), &dense, &DecodeOptions::exhaustive())
340            .unwrap()
341            .expect("a path");
342
343        assert_eq!(decoded.labels, vec![20, 10, 20]);
344        assert!((decoded.weight.0 - (1.0 + 0.5 + 0.25)).abs() < 1e-6);
345    }
346
347    #[test]
348    fn it_agrees_with_composing_and_taking_the_shortest_path() {
349        let scores = [
350            5.0, 1.0, 9.0, //
351            0.5, 4.0, 4.0, //
352            3.0, 0.25, 3.0, //
353            2.0, 2.5, 0.75,
354        ];
355        let dense = DenseFst::<StdArc>::new(&scores, 4, 3).unwrap();
356        let graph = free_graph();
357
358        let (labels, weight) = by_composition(&graph, &dense).expect("an answer");
359        let decoded = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive())
360            .unwrap()
361            .expect("a path");
362
363        assert_eq!(decoded.labels, labels);
364        assert!((decoded.weight.0 - weight).abs() < 1e-5);
365    }
366
367    // The same agreement over graphs that constrain what may follow what, and
368    // that carry their own costs.
369    #[test]
370    fn it_agrees_on_a_graph_that_forbids_repeats() {
371        // 3 states: after emitting symbol s you may not emit s again.
372        let mut graph: StdVectorFst = VectorFst::new();
373        for _ in 0..4 {
374            graph.add_state();
375        }
376        graph.set_start(0);
377        for from in 0..4 {
378            for label in 1..=3i32 {
379                if from == label {
380                    continue;
381                }
382                graph.add_arc(
383                    from,
384                    StdArc::new(label, label * 10, TropicalWeight(label as f32 * 0.1), label),
385                );
386            }
387        }
388        for state in 1..4 {
389            graph.set_final(state, TropicalWeight(0.5));
390        }
391        graph.properties(K_FST_PROPERTIES, true);
392
393        let scores = [
394            5.0, 1.0, 9.0, //
395            0.5, 4.0, 4.0, //
396            3.0, 0.25, 3.0, //
397            2.0, 2.5, 0.75, //
398            1.0, 1.0, 1.0,
399        ];
400        let dense = DenseFst::<StdArc>::new(&scores, 5, 3).unwrap();
401
402        let (labels, weight) = by_composition(&graph, &dense).expect("an answer");
403        let decoded = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive())
404            .unwrap()
405            .expect("a path");
406
407        assert_eq!(decoded.labels, labels);
408        assert!((decoded.weight.0 - weight).abs() < 1e-5);
409    }
410
411    // Epsilon arcs consume no frame, so a path may take several of them
412    // between two frames. The composition oracle handles them by construction,
413    // which is why it is worth comparing against.
414    #[test]
415    fn it_agrees_when_the_graph_has_epsilon_arcs() {
416        let mut graph: StdVectorFst = VectorFst::new();
417        for _ in 0..3 {
418            graph.add_state();
419        }
420        graph.set_start(0);
421        graph.set_final(2, TropicalWeight::one());
422        // 0 --1:10--> 0, and an epsilon chain 0 -> 1 -> 2 that emits 99.
423        graph.add_arc(0, StdArc::new(1, 10, TropicalWeight::one(), 0));
424        graph.add_arc(0, StdArc::new(2, 20, TropicalWeight(0.2), 0));
425        graph.add_arc(0, StdArc::new(0, 0, TropicalWeight(0.3), 1));
426        graph.add_arc(1, StdArc::new(0, 99, TropicalWeight(0.4), 2));
427        graph.add_arc(2, StdArc::new(1, 10, TropicalWeight::one(), 0));
428        graph.properties(K_FST_PROPERTIES, true);
429
430        let scores = [
431            0.5, 2.0, 9.0, //
432            2.0, 0.5, 9.0, //
433            0.5, 2.0, 9.0,
434        ];
435        let dense = DenseFst::<StdArc>::new(&scores, 3, 3).unwrap();
436
437        let (labels, weight) = by_composition(&graph, &dense).expect("an answer");
438        let decoded = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive())
439            .unwrap()
440            .expect("a path");
441
442        assert_eq!(decoded.labels, labels);
443        assert!((decoded.weight.0 - weight).abs() < 1e-5);
444    }
445
446    // A small xorshift, so the random cases below are the same every run.
447    struct Rng(u64);
448
449    impl Rng {
450        fn next(&mut self) -> u64 {
451            self.0 ^= self.0 << 13;
452            self.0 ^= self.0 >> 7;
453            self.0 ^= self.0 << 17;
454            self.0
455        }
456
457        fn below(&mut self, n: usize) -> usize {
458            (self.next() % n as u64) as usize
459        }
460
461        // A non-negative cost with enough distinct values that two different
462        // paths rarely land on the same total.
463        fn cost(&mut self) -> f32 {
464            self.below(4096) as f32 / 64.0
465        }
466    }
467
468    // Random graphs cover interactions between epsilon closure and pruning.
469    #[test]
470    fn it_agrees_with_the_composition_on_random_graphs() {
471        let symbols = 4;
472        let mut rng = Rng(0x5EED_1234_9ABC_DEF1);
473        let mut compared = 0;
474
475        for round in 0..200 {
476            let states = 1 + rng.below(6);
477            let mut graph: StdVectorFst = VectorFst::new();
478            for _ in 0..states {
479                graph.add_state();
480            }
481            graph.set_start(0);
482            for from in 0..states as i32 {
483                for _ in 0..1 + rng.below(4) {
484                    // A quarter of the arcs consume no frame.
485                    let ilabel = if rng.below(4) == 0 {
486                        0
487                    } else {
488                        1 + rng.below(symbols) as i32
489                    };
490                    let olabel = if rng.below(3) == 0 {
491                        0
492                    } else {
493                        10 * (1 + rng.below(symbols) as i32)
494                    };
495                    let to = rng.below(states) as i32;
496                    graph.add_arc(
497                        from,
498                        StdArc::new(ilabel, olabel, TropicalWeight(rng.cost()), to),
499                    );
500                }
501                if rng.below(3) == 0 {
502                    graph.set_final(from, TropicalWeight(rng.cost()));
503                }
504            }
505            graph.properties(K_FST_PROPERTIES, true);
506
507            let frames = 1 + rng.below(5);
508            let scores: Vec<f32> = (0..frames * symbols).map(|_| rng.cost()).collect();
509            let dense = DenseFst::<StdArc>::new(&scores, frames, symbols).unwrap();
510
511            let expected = by_composition(&graph, &dense);
512            let decoded = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive()).unwrap();
513
514            match (expected, decoded) {
515                (None, None) => {}
516                (Some((labels, weight)), Some(decoded)) => {
517                    compared += 1;
518                    assert!(
519                        (decoded.weight.0 - weight).abs() < 1e-4,
520                        "round {round}: decoder {} vs composition {weight}",
521                        decoded.weight.0
522                    );
523                    assert_eq!(decoded.labels, labels, "round {round}");
524                }
525                (expected, decoded) => {
526                    panic!("round {round}: composition {expected:?}, decoder {decoded:?}")
527                }
528            }
529        }
530
531        // Guards against the graphs degenerating into ones that decode to
532        // nothing, which would make the whole test vacuous.
533        assert!(compared > 100, "only {compared} rounds had a path at all");
534    }
535
536    #[test]
537    fn a_beam_that_keeps_the_best_path_does_not_change_the_answer() {
538        let scores = [
539            5.0, 1.0, 9.0, //
540            0.5, 4.0, 4.0, //
541            3.0, 0.25, 3.0,
542        ];
543        let dense = DenseFst::<StdArc>::new(&scores, 3, 3).unwrap();
544        let graph = free_graph();
545
546        let wide = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive())
547            .unwrap()
548            .unwrap();
549        let narrow = viterbi_decode(
550            &graph,
551            &dense,
552            &DecodeOptions {
553                beam: 0.001,
554                max_active: 1,
555                min_active: 0,
556            },
557        )
558        .unwrap()
559        .unwrap();
560
561        assert_eq!(narrow.labels, wide.labels);
562        assert!((narrow.weight.0 - wide.weight.0).abs() < 1e-6);
563    }
564
565    #[test]
566    fn a_graph_the_model_does_not_match_is_reported() {
567        let mut graph: StdVectorFst = VectorFst::new();
568        graph.add_state();
569        graph.set_start(0);
570        graph.set_final(0, TropicalWeight::one());
571        // Column 7 does not exist in a 3-symbol matrix.
572        graph.add_arc(0, StdArc::new(8, 1, TropicalWeight::one(), 0));
573        graph.properties(K_FST_PROPERTIES, true);
574
575        let scores = [1.0, 1.0, 1.0];
576        let dense = DenseFst::<StdArc>::new(&scores, 1, 3).unwrap();
577        let err = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive()).unwrap_err();
578        assert!(format!("{err}").contains("names no column"), "{err}");
579    }
580
581    #[test]
582    fn a_graph_that_reaches_no_final_state_decodes_to_nothing() {
583        let mut graph: StdVectorFst = VectorFst::new();
584        graph.add_state();
585        graph.set_start(0);
586        graph.add_arc(0, StdArc::new(1, 1, TropicalWeight::one(), 0));
587        graph.properties(K_FST_PROPERTIES, true);
588
589        let scores = [1.0, 1.0, 1.0];
590        let dense = DenseFst::<StdArc>::new(&scores, 1, 3).unwrap();
591        assert_eq!(
592            viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive()).unwrap(),
593            None
594        );
595    }
596
597    #[test]
598    fn an_epsilon_cycle_that_costs_less_than_nothing_is_reported() {
599        let mut graph: StdVectorFst = VectorFst::new();
600        graph.add_state();
601        graph.add_state();
602        graph.set_start(0);
603        graph.set_final(1, TropicalWeight::one());
604        graph.add_arc(0, StdArc::new(0, 0, TropicalWeight(-1.0), 1));
605        graph.add_arc(1, StdArc::new(0, 0, TropicalWeight(-1.0), 0));
606        graph.properties(K_FST_PROPERTIES, true);
607
608        let scores = [1.0, 1.0, 1.0];
609        let dense = DenseFst::<StdArc>::new(&scores, 1, 3).unwrap();
610        let err = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive()).unwrap_err();
611        assert!(format!("{err}").contains("less than nothing"), "{err}");
612    }
613
614    #[test]
615    fn no_frames_decodes_the_graphs_own_best_path() {
616        let graph = free_graph();
617        let dense = DenseFst::<StdArc>::new(&[], 0, 3).unwrap();
618        let decoded = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive())
619            .unwrap()
620            .expect("the empty path");
621        assert!(decoded.labels.is_empty());
622        assert_eq!(decoded.weight, TropicalWeight::one());
623    }
624}