Skip to main content

sicada_decode/
lattice.rs

1//! Decoding to a lattice rather than to a single answer.
2//!
3//! [`viterbi_decode`](crate::viterbi::viterbi_decode) returns the best path and
4//! throws the rest away. That is enough to read out a transcript and nothing
5//! else: no confidence, no *n*-best, and above all no second pass, because
6//! rescoring needs the alternatives the first pass considered.
7//!
8//! A lattice keeps them. It is the part of `graph ∘ dense` that survived the
9//! beam, with states as `(frame, graph state)` pairs and arcs as the graph arcs
10//! between two surviving states. Each arc's graph cost and acoustic cost are
11//! kept apart in a [`LatticeWeight`], so a rescoring pass can rebuild one half
12//! without disturbing the other.
13//!
14//! The construction follows Kaldi's `LatticeFasterDecoder`. The one thing worth
15//! stating explicitly, because it is what separates a lattice from a backtrace:
16//! **an arc is kept because both its endpoints survived, not because it was the
17//! best way to reach its endpoint.** Keeping only the improving arcs would give
18//! a tree, namely the Viterbi backtrace, and no alternatives at all.
19
20use rustc_hash::FxHashMap;
21
22use sicada::algorithms::connect::connect;
23use sicada::algorithms::prune::{PruneOptions, prune as prune_fst};
24use sicada::arc::{Arc, ArcLabel, ArcStateId};
25use sicada::error::OpenFstError;
26use sicada::fst::{Fst, MutableFst};
27use sicada::fsts::vector_fst::VectorFst;
28
29use crate::dense::{DenseFst, FromScore};
30use crate::frontier::{DecodeOptions, NO_AUX, Token, prune, relax_cost};
31use crate::lattice_weight::{LatticeArc, LatticeWeight};
32
33/// How wide to search, and how much of what was found to keep.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct LatticeDecodeOptions {
36    /// The beam the search itself runs under.
37    pub search: DecodeOptions,
38    /// Paths worse than the best by more than this are dropped from the
39    /// lattice.
40    ///
41    /// Separate from `search.beam`, and usually smaller: the search beam has to
42    /// be generous because a path that looks bad now may win later, while the
43    /// lattice beam judges finished paths and can afford to be strict.
44    /// `f32::INFINITY` keeps everything the search saw.
45    pub lattice_beam: f32,
46}
47
48impl Default for LatticeDecodeOptions {
49    fn default() -> Self {
50        Self {
51            search: DecodeOptions::default(),
52            // Kaldi's default.
53            lattice_beam: 8.0,
54        }
55    }
56}
57
58impl LatticeDecodeOptions {
59    /// No beam anywhere: the lattice is the whole of `graph ∘ dense`.
60    ///
61    /// What the tests compare against, since it is the only setting under which
62    /// the lattice and the composition are the same object.
63    pub fn exhaustive() -> Self {
64        Self {
65            search: DecodeOptions::exhaustive(),
66            lattice_beam: f32::INFINITY,
67        }
68    }
69}
70
71/// The lattice type for a decoding graph over `A`.
72///
73/// Same labels and state ids as the graph; the weight is the one that keeps the
74/// two costs apart.
75pub type Lattice<A> = VectorFst<LatticeArc<A>>;
76
77/// The lattice state an `aux` names.
78#[inline(always)]
79fn state_of<A: Arc>(aux: u32) -> A::StateId {
80    A::StateId::from_usize(aux as usize)
81}
82
83/// A graph arc that reached a state, kept until it is known whether that state
84/// survived the beam.
85struct Pending<A: Arc> {
86    from: u32,
87    to: A::StateId,
88    ilabel: A::Label,
89    olabel: A::Label,
90    graph: f32,
91    acoustic: f32,
92}
93
94/// Decodes to a lattice, or to `None` if the beam killed every path.
95///
96/// The graph's input labels are matched against the acoustic model's columns
97/// and survive on the lattice's input side, so an alignment can still be read
98/// off it; the output labels are the words.
99///
100/// # Errors
101///
102/// As [`viterbi_decode`](crate::viterbi::viterbi_decode): an input label naming
103/// no column is a graph/model mismatch, and epsilon arcs that do not settle
104/// mean a cycle of them costs less than nothing.
105pub fn lattice_decode<A, G>(
106    graph: &G,
107    dense: &DenseFst<'_, A>,
108    opts: &LatticeDecodeOptions,
109) -> Result<Option<Lattice<A>>, OpenFstError>
110where
111    A: Arc,
112    A::Weight: FromScore,
113    A::StateId: ArcStateId,
114    G: Fst<A>,
115{
116    let Some(start) = graph.start() else {
117        return Ok(None);
118    };
119
120    let mut lattice: Lattice<A> = VectorFst::new();
121    let mut current: FxHashMap<A::StateId, Token> = FxHashMap::default();
122    let mut next: FxHashMap<A::StateId, Token> = FxHashMap::default();
123    let mut queue: Vec<A::StateId> = Vec::new();
124    let mut costs: Vec<f32> = Vec::new();
125    let mut pending: Vec<Pending<A>> = Vec::new();
126
127    current.insert(
128        start,
129        Token {
130            cost: 0.0,
131            aux: NO_AUX,
132        },
133    );
134    settle_epsilons(graph, &mut current, &mut queue, f32::INFINITY)?;
135    allocate(&mut lattice, &mut current);
136    lattice.set_start(state_of::<A>(current[&start].aux));
137    emit_epsilons(graph, &current, &mut lattice);
138
139    for t in 0..dense.num_frames() {
140        let frame = dense.frame(t);
141        next.clear();
142        pending.clear();
143
144        for (&state, &token) in &current {
145            for arc in graph.arcs(state) {
146                if arc.ilabel() == A::Label::epsilon() {
147                    continue;
148                }
149                let Some(column) = dense.column_of(arc.ilabel()) else {
150                    return Err(OpenFstError::InvalidOperation(format!(
151                        "lattice_decode: the graph has input label {} at state {state:?}, which \
152                         names no column of a {}-symbol acoustic matrix",
153                        arc.ilabel(),
154                        dense.num_symbols()
155                    )));
156                };
157                let graph_cost = arc.weight().to_cost();
158                let acoustic = frame[column];
159                relax_cost(
160                    &mut next,
161                    arc.nextstate(),
162                    token.cost + graph_cost + acoustic,
163                    NO_AUX,
164                );
165                // Kept whatever the relaxation decided: the lattice wants every
166                // arc between two surviving states, not only the winning one.
167                pending.push(Pending {
168                    from: token.aux,
169                    to: arc.nextstate(),
170                    ilabel: arc.ilabel(),
171                    olabel: arc.olabel(),
172                    graph: graph_cost,
173                    acoustic,
174                });
175            }
176        }
177
178        if next.is_empty() {
179            return Ok(None);
180        }
181        settle_and_prune(graph, &mut next, &mut queue, &mut costs, &opts.search)?;
182        allocate(&mut lattice, &mut next);
183
184        for step in &pending {
185            let Some(to) = next.get(&step.to) else {
186                continue;
187            };
188            lattice.add_arc(
189                state_of::<A>(step.from),
190                LatticeArc::<A>::new(
191                    step.ilabel,
192                    step.olabel,
193                    LatticeWeight::new(step.graph, step.acoustic),
194                    state_of::<A>(to.aux),
195                ),
196            );
197        }
198        emit_epsilons(graph, &next, &mut lattice);
199
200        std::mem::swap(&mut current, &mut next);
201    }
202
203    let mut reached_the_end = false;
204    for (&state, &token) in &current {
205        let final_cost = graph.final_weight(state).to_cost();
206        if !final_cost.is_finite() {
207            continue;
208        }
209        reached_the_end = true;
210        lattice.set_final(
211            state_of::<A>(token.aux),
212            LatticeWeight::new(final_cost, 0.0),
213        );
214    }
215    if !reached_the_end {
216        return Ok(None);
217    }
218
219    // Most of what was allocated leads nowhere: a token survives the beam by
220    // being cheap to *reach*, which says nothing about whether the rest of the
221    // audio can be decoded from it.
222    connect(&mut lattice);
223    if lattice.start().is_none() {
224        return Ok(None);
225    }
226
227    if opts.lattice_beam.is_finite() {
228        prune_fst(
229            &mut lattice,
230            &PruneOptions::threshold(LatticeWeight::new(opts.lattice_beam, 0.0)),
231        )?;
232        if lattice.start().is_none() {
233            return Ok(None);
234        }
235    }
236
237    Ok(Some(lattice))
238}
239
240/// Gives every state in `frontier` a lattice state.
241///
242/// Written over the *lattice's* arc type rather than the graph's: `Lattice<A>`
243/// mentions `A` only behind its associated types, and an associated type does
244/// not determine the type it came from, so the graph's arc could not be
245/// inferred here.
246fn allocate<LA: Arc>(lattice: &mut VectorFst<LA>, frontier: &mut FxHashMap<LA::StateId, Token>) {
247    for token in frontier.values_mut() {
248        token.aux = lattice.add_state().as_usize() as u32;
249    }
250}
251
252/// Relaxes the graph's epsilon arcs over `frontier` until nothing improves.
253///
254/// The same fixed point [`viterbi`](crate::viterbi) computes, without the
255/// backpointers: here the arcs are read off the survivors afterwards.
256fn settle_epsilons<A, G>(
257    graph: &G,
258    frontier: &mut FxHashMap<A::StateId, Token>,
259    queue: &mut Vec<A::StateId>,
260    cutoff: f32,
261) -> Result<(), OpenFstError>
262where
263    A: Arc,
264    A::Weight: FromScore,
265    G: Fst<A>,
266{
267    queue.clear();
268    queue.extend(frontier.keys().copied());
269
270    let budget = frontier.len().saturating_mul(64).saturating_add(1024);
271    let mut steps = 0usize;
272
273    while let Some(state) = queue.pop() {
274        steps += 1;
275        if steps > budget {
276            return Err(OpenFstError::InvalidOperation(
277                "lattice_decode: the graph's epsilon arcs do not settle, which means a cycle of \
278                 them costs less than nothing"
279                    .into(),
280            ));
281        }
282        let token = frontier[&state];
283        for arc in graph.arcs(state) {
284            if arc.ilabel() != A::Label::epsilon() {
285                continue;
286            }
287            let cost = token.cost + arc.weight().to_cost();
288            if cost > cutoff {
289                continue;
290            }
291            if relax_cost(frontier, arc.nextstate(), cost, NO_AUX) {
292                queue.push(arc.nextstate());
293            }
294        }
295    }
296    Ok(())
297}
298
299/// Settles the epsilons, prunes, and settles again under the tighter cutoff.
300fn settle_and_prune<A, G>(
301    graph: &G,
302    frontier: &mut FxHashMap<A::StateId, Token>,
303    queue: &mut Vec<A::StateId>,
304    costs: &mut Vec<f32>,
305    opts: &DecodeOptions,
306) -> Result<f32, OpenFstError>
307where
308    A: Arc,
309    A::Weight: FromScore,
310    G: Fst<A>,
311{
312    let cutoff = prune(frontier, opts, costs);
313    settle_epsilons(graph, frontier, queue, cutoff)?;
314    // Epsilon arcs only added states at or under the cutoff, so the beam still
315    // holds; the cap may not.
316    if frontier.len() > opts.max_active {
317        return Ok(prune(frontier, opts, costs));
318    }
319    Ok(cutoff)
320}
321
322/// Adds a lattice arc for every epsilon arc between two states of `frontier`.
323fn emit_epsilons<A, G>(graph: &G, frontier: &FxHashMap<A::StateId, Token>, lattice: &mut Lattice<A>)
324where
325    A: Arc,
326    A::Weight: FromScore,
327    G: Fst<A>,
328{
329    for (&state, &token) in frontier {
330        for arc in graph.arcs(state) {
331            if arc.ilabel() != A::Label::epsilon() {
332                continue;
333            }
334            let Some(to) = frontier.get(&arc.nextstate()) else {
335                continue;
336            };
337            lattice.add_arc(
338                state_of::<A>(token.aux),
339                LatticeArc::<A>::new(
340                    arc.ilabel(),
341                    arc.olabel(),
342                    // Consuming no frame, an epsilon arc costs the acoustic
343                    // model nothing.
344                    LatticeWeight::new(arc.weight().to_cost(), 0.0),
345                    state_of::<A>(to.aux),
346                ),
347            );
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use sicada::algorithms::arcsort::{ILabelCompare, arc_sort};
356    use sicada::algorithms::compose::compose;
357    use sicada::algorithms::shortest_path::{ShortestPathOptions, shortest_path};
358    use sicada::arc::StdArc;
359    use sicada::fst::ExpandedFst;
360    use sicada::fsts::vector_fst::StdVectorFst;
361    use sicada::properties::K_FST_PROPERTIES;
362    use sicada::string::string_fst_to_output_labels;
363    use sicada::weight::Weight;
364    use sicada::weights::float_weight::TropicalWeight;
365
366    use crate::viterbi::viterbi_decode;
367
368    // A small xorshift, so the random cases are the same every run.
369    struct Rng(u64);
370
371    impl Rng {
372        fn next(&mut self) -> u64 {
373            self.0 ^= self.0 << 13;
374            self.0 ^= self.0 >> 7;
375            self.0 ^= self.0 << 17;
376            self.0
377        }
378        fn below(&mut self, n: usize) -> usize {
379            (self.next() % n as u64) as usize
380        }
381        fn cost(&mut self) -> f32 {
382            self.below(4096) as f32 / 64.0
383        }
384    }
385
386    fn random_graph(rng: &mut Rng, symbols: usize) -> StdVectorFst {
387        let states = 1 + rng.below(6);
388        let mut graph: StdVectorFst = VectorFst::new();
389        for _ in 0..states {
390            graph.add_state();
391        }
392        graph.set_start(0);
393        for from in 0..states as i32 {
394            for _ in 0..1 + rng.below(4) {
395                let ilabel = if rng.below(4) == 0 {
396                    0
397                } else {
398                    1 + rng.below(symbols) as i32
399                };
400                let olabel = if rng.below(3) == 0 {
401                    0
402                } else {
403                    10 * (1 + rng.below(symbols) as i32)
404                };
405                let to = rng.below(states) as i32;
406                graph.add_arc(
407                    from,
408                    StdArc::new(ilabel, olabel, TropicalWeight(rng.cost()), to),
409                );
410            }
411            if rng.below(3) == 0 {
412                graph.set_final(from, TropicalWeight(rng.cost()));
413            }
414        }
415        graph.properties(K_FST_PROPERTIES, true);
416        graph
417    }
418
419    // `dense ∘ graph`, connected: what an exhaustive lattice should be.
420    fn composition(graph: &StdVectorFst, dense: &DenseFst<'_, StdArc>) -> StdVectorFst {
421        let mut sorted = graph.clone();
422        arc_sort(&mut sorted, &ILabelCompare);
423        let mut composed: StdVectorFst = VectorFst::new();
424        compose(dense, &sorted, &mut composed).expect("a composition");
425        composed
426    }
427
428    // The lattice's own best path, read out the same way the oracle's is.
429    fn best_of(lattice: &Lattice<StdArc>) -> (Vec<i32>, f32) {
430        let mut best: Lattice<StdArc> = VectorFst::new();
431        shortest_path(lattice, &mut best, &ShortestPathOptions::default()).expect("a best path");
432        let (labels, weight) = string_fst_to_output_labels(&best).expect("a single path");
433        (
434            labels.into_iter().filter(|&l| l != 0).collect(),
435            weight.total(),
436        )
437    }
438
439    fn free_graph() -> StdVectorFst {
440        let mut fst = VectorFst::new();
441        fst.add_state();
442        fst.set_start(0);
443        fst.set_final(0, TropicalWeight::one());
444        for label in 1..=3 {
445            fst.add_arc(0, StdArc::new(label, label * 10, TropicalWeight::one(), 0));
446        }
447        fst.properties(K_FST_PROPERTIES, true);
448        fst
449    }
450
451    #[test]
452    fn its_best_path_is_the_one_the_viterbi_decoder_finds() {
453        let scores = [
454            5.0, 1.0, 9.0, //
455            0.5, 4.0, 4.0, //
456            3.0, 0.25, 3.0,
457        ];
458        let dense = DenseFst::<StdArc>::new(&scores, 3, 3).unwrap();
459        let graph = free_graph();
460
461        let lattice = lattice_decode(&graph, &dense, &LatticeDecodeOptions::exhaustive())
462            .unwrap()
463            .expect("a lattice");
464        let expected = viterbi_decode(&graph, &dense, &DecodeOptions::exhaustive())
465            .unwrap()
466            .expect("a path");
467
468        let (labels, total) = best_of(&lattice);
469        assert_eq!(labels, expected.labels);
470        assert!((total - expected.weight.0).abs() < 1e-5);
471    }
472
473    // The two halves are the point of the whole weight, so they are checked
474    // rather than only their sum: the acoustic half of the best path has to be
475    // the frame scores it actually used.
476    #[test]
477    fn the_two_costs_stay_apart() {
478        let scores = [
479            5.0, 1.0, 9.0, //
480            0.5, 4.0, 4.0,
481        ];
482        let dense = DenseFst::<StdArc>::new(&scores, 2, 3).unwrap();
483        // Every arc costs the graph 0.25, so the graph half is 2 x 0.25.
484        let mut graph: StdVectorFst = VectorFst::new();
485        graph.add_state();
486        graph.set_start(0);
487        graph.set_final(0, TropicalWeight::one());
488        for label in 1..=3 {
489            graph.add_arc(0, StdArc::new(label, label * 10, TropicalWeight(0.25), 0));
490        }
491        graph.properties(K_FST_PROPERTIES, true);
492
493        let lattice = lattice_decode(&graph, &dense, &LatticeDecodeOptions::exhaustive())
494            .unwrap()
495            .unwrap();
496        let mut best: Lattice<StdArc> = VectorFst::new();
497        shortest_path(&lattice, &mut best, &ShortestPathOptions::default()).unwrap();
498        let (_, weight) = string_fst_to_output_labels(&best).unwrap();
499
500        assert!((weight.graph - 0.5).abs() < 1e-6, "{weight}");
501        // Frame 0 picks symbol 2 (1.0), frame 1 picks symbol 1 (0.5).
502        assert!((weight.acoustic - 1.5).abs() < 1e-6, "{weight}");
503        // And rescoring the acoustic half is what keeping them apart is for.
504        assert!((weight.total_scaled(0.5) - (0.5 + 0.75)).abs() < 1e-6);
505    }
506
507    // With no beam anywhere, the lattice *is* `graph ∘ dense`: the same states
508    // and the same arcs, because every pair survives and every arc between two
509    // survivors is kept. That equality is the strongest statement available
510    // about the construction, so it is the one the random cases check.
511    #[test]
512    fn an_unpruned_lattice_is_the_composition() {
513        let symbols = 4;
514        let mut rng = Rng(0x00C0_FFEE_1234_5678);
515        let mut compared = 0;
516
517        for round in 0..200 {
518            let graph = random_graph(&mut rng, symbols);
519            let frames = 1 + rng.below(5);
520            let scores: Vec<f32> = (0..frames * symbols).map(|_| rng.cost()).collect();
521            let dense = DenseFst::<StdArc>::new(&scores, frames, symbols).unwrap();
522
523            let expected = composition(&graph, &dense);
524            let lattice =
525                lattice_decode(&graph, &dense, &LatticeDecodeOptions::exhaustive()).unwrap();
526
527            let Some(lattice) = lattice else {
528                assert!(
529                    expected.start().is_none(),
530                    "round {round}: no lattice, but the composition has {} states",
531                    expected.num_states()
532                );
533                continue;
534            };
535            compared += 1;
536
537            assert_eq!(
538                lattice.num_states(),
539                expected.num_states(),
540                "round {round}: states"
541            );
542            assert_eq!(
543                lattice.count_arcs(),
544                expected.count_arcs(),
545                "round {round}: arcs"
546            );
547
548            let mut best: StdVectorFst = VectorFst::new();
549            shortest_path(&expected, &mut best, &ShortestPathOptions::default()).unwrap();
550            let (labels, weight) = string_fst_to_output_labels(&best).unwrap();
551            let labels: Vec<i32> = labels.into_iter().filter(|&l| l != 0).collect();
552
553            let (mine, total) = best_of(&lattice);
554            assert!(
555                (total - weight.0).abs() < 1e-4,
556                "round {round}: lattice {total} vs composition {}",
557                weight.0
558            );
559            assert_eq!(mine, labels, "round {round}");
560        }
561
562        assert!(
563            compared > 100,
564            "only {compared} rounds had a lattice at all"
565        );
566    }
567
568    // The lattice beam drops paths, so the lattice shrinks, but never so far
569    // that the best path gets worse.
570    //
571    // The *cost* is what is asserted, not the labels. Two paths can tie
572    // exactly, and then which one `shortest_path` returns is not determined;
573    // removing one of them legitimately changes the answer without changing
574    // how good it is. Round 131 of these seeds is such a case.
575    #[test]
576    fn pruning_keeps_the_best_path() {
577        let symbols = 4;
578        let mut rng = Rng(0xBEEF_4321_9876);
579        let mut shrank = 0;
580
581        for round in 0..200 {
582            let graph = random_graph(&mut rng, symbols);
583            let frames = 1 + rng.below(5);
584            let scores: Vec<f32> = (0..frames * symbols).map(|_| rng.cost()).collect();
585            let dense = DenseFst::<StdArc>::new(&scores, frames, symbols).unwrap();
586
587            let whole =
588                lattice_decode(&graph, &dense, &LatticeDecodeOptions::exhaustive()).unwrap();
589            let pruned = lattice_decode(
590                &graph,
591                &dense,
592                &LatticeDecodeOptions {
593                    search: DecodeOptions::exhaustive(),
594                    lattice_beam: 2.0,
595                },
596            )
597            .unwrap();
598
599            match (whole, pruned) {
600                (None, None) => {}
601                (Some(whole), Some(pruned)) => {
602                    assert!(
603                        pruned.count_arcs() <= whole.count_arcs(),
604                        "round {round}: pruning grew the lattice"
605                    );
606                    if pruned.count_arcs() < whole.count_arcs() {
607                        shrank += 1;
608                    }
609                    let (whole_labels, whole_cost) = best_of(&whole);
610                    let (pruned_labels, pruned_cost) = best_of(&pruned);
611                    assert!(
612                        (pruned_cost - whole_cost).abs() < 1e-4,
613                        "round {round}: {pruned_cost} vs {whole_cost}, {pruned_labels:?} vs {whole_labels:?}"
614                    );
615                }
616                (whole, pruned) => panic!(
617                    "round {round}: whole {:?}, pruned {:?}",
618                    whole.is_some(),
619                    pruned.is_some()
620                ),
621            }
622        }
623
624        assert!(shrank > 20, "the beam never removed anything in {shrank}");
625    }
626
627    #[test]
628    fn a_graph_that_reaches_no_final_state_has_no_lattice() {
629        let mut graph: StdVectorFst = VectorFst::new();
630        graph.add_state();
631        graph.set_start(0);
632        graph.add_arc(0, StdArc::new(1, 1, TropicalWeight::one(), 0));
633        graph.properties(K_FST_PROPERTIES, true);
634
635        let scores = [1.0, 1.0, 1.0];
636        let dense = DenseFst::<StdArc>::new(&scores, 1, 3).unwrap();
637        assert!(
638            lattice_decode(&graph, &dense, &LatticeDecodeOptions::exhaustive())
639                .unwrap()
640                .is_none()
641        );
642    }
643
644    // The alignment has to survive: the input labels say which acoustic column
645    // each frame used, which a second pass needs in order to rescore it.
646    #[test]
647    fn the_input_labels_are_still_the_acoustic_columns() {
648        let scores = [5.0, 1.0, 9.0];
649        let dense = DenseFst::<StdArc>::new(&scores, 1, 3).unwrap();
650        let lattice = lattice_decode(&free_graph(), &dense, &LatticeDecodeOptions::exhaustive())
651            .unwrap()
652            .unwrap();
653
654        for state in lattice.states() {
655            for arc in lattice.arcs(state) {
656                assert_eq!(
657                    arc.olabel(),
658                    arc.ilabel() * 10,
659                    "the graph maps label n to output 10n"
660                );
661                assert!(dense.column_of(arc.ilabel()).is_some());
662            }
663        }
664    }
665}