Skip to main content

sicada_decode/
dense.rs

1//! The acoustic model's output, seen as an FST.
2//!
3//! A neural acoustic model hands back a `T × V` matrix: for each of `T` frames,
4//! a score for each of `V` symbols. Decoding is the composition of that matrix
5//! with a decoding graph, so the matrix has to be an FST first.
6//!
7//! As an FST it is a chain of `T + 1` states, with `V` arcs from frame `t` to
8//! frame `t + 1`, one per symbol, weighted by that symbol's score in that
9//! frame. It is an acceptor: the symbol is both input and output label.
10//!
11//! Nothing is materialised. [`DenseFst`] borrows the matrix and computes each
12//! arc as it is asked for, so composing against it costs no copy of the
13//! acoustic scores. It corresponds to a single, unbatched item in k2's
14//! `DenseFsaVec`.
15
16use std::marker::PhantomData;
17
18use sicada::AtomicRc;
19use sicada::arc::{Arc, ArcLabel, ArcStateId};
20use sicada::error::OpenFstError;
21use sicada::fst::{ExpandedFst, Fst};
22use sicada::properties::{
23    K_ACCEPTOR, K_ACCESSIBLE, K_ACYCLIC, K_CO_ACCESSIBLE, K_EPSILONS, K_EXPANDED,
24    K_I_DETERMINISTIC, K_I_EPSILONS, K_I_LABEL_SORTED, K_INITIAL_ACYCLIC, K_NO_EPSILONS,
25    K_NO_I_EPSILONS, K_NO_O_EPSILONS, K_NOT_STRING, K_O_DETERMINISTIC, K_O_EPSILONS,
26    K_O_LABEL_SORTED, K_STRING, K_TOP_SORTED, K_UNWEIGHTED, K_UNWEIGHTED_CYCLES, K_WEIGHTED,
27};
28use sicada::symbol_table::SymbolTable;
29use sicada::weight::Weight;
30
31/// A weight that can be built from one acoustic score.
32///
33/// The scores are *costs*, that is, negative log probabilities where smaller is
34/// better, which is already what both the tropical and the log semiring mean by
35/// a weight's value. Keeping the trait rather than hard-wiring `TropicalWeight`
36/// lets the same decoder run Viterbi (tropical) and forward-sum (log) without a
37/// second implementation.
38pub trait FromScore: Weight {
39    /// The weight for a score of `cost` nats.
40    fn from_cost(cost: f32) -> Self;
41
42    /// The score back out, for a decoder that wants to compare in `f32`.
43    fn to_cost(&self) -> f32;
44}
45
46macro_rules! from_score {
47    ($weight:ty, $float:ty) => {
48        impl FromScore for $weight {
49            #[inline(always)]
50            fn from_cost(cost: f32) -> Self {
51                Self(cost as $float)
52            }
53            #[inline(always)]
54            fn to_cost(&self) -> f32 {
55                self.0 as f32
56            }
57        }
58    };
59}
60
61from_score!(sicada::weights::float_weight::TropicalWeight, f32);
62from_score!(sicada::weights::float_weight::TropicalWeight64, f64);
63from_score!(sicada::weights::float_weight::LogWeight, f32);
64from_score!(sicada::weights::float_weight::Log64Weight, f64);
65
66/// A `T × V` matrix of acoustic scores, read as an FST.
67///
68/// Row-major: the score of symbol `c` in frame `t` is `scores[t * V + c]`.
69#[derive(Debug, Clone)]
70pub struct DenseFst<'a, A: Arc> {
71    scores: &'a [f32],
72    num_frames: usize,
73    num_symbols: usize,
74    /// Column `c` becomes label `c + label_offset`.
75    ///
76    /// The default is 1, not 0. An acoustic model's column 0 is an ordinary
77    /// symbol, usually CTC's blank, but label 0 is epsilon to every FST
78    /// algorithm in sicada, so leaving the columns where they are would turn
79    /// the whole first row into epsilon arcs that consume no frame. The
80    /// decoding graph is expected to carry the same offset.
81    label_offset: i64,
82    props: u64,
83    _arc: PhantomData<A>,
84}
85
86impl<'a, A: Arc> DenseFst<'a, A>
87where
88    A::Weight: FromScore,
89{
90    /// Reads `scores` as `num_frames × num_symbols`, with column `c` as label
91    /// `c + 1`.
92    pub fn new(
93        scores: &'a [f32],
94        num_frames: usize,
95        num_symbols: usize,
96    ) -> Result<Self, OpenFstError> {
97        Self::with_label_offset(scores, num_frames, num_symbols, 1)
98    }
99
100    /// As [`new`](Self::new), with the columns placed at a chosen label.
101    ///
102    /// An offset of 0 puts column 0 on epsilon; that is allowed, because a
103    /// graph may genuinely want it, but the properties then say so.
104    pub fn with_label_offset(
105        scores: &'a [f32],
106        num_frames: usize,
107        num_symbols: usize,
108        label_offset: i64,
109    ) -> Result<Self, OpenFstError> {
110        let expected = num_frames.checked_mul(num_symbols).ok_or_else(|| {
111            OpenFstError::InvalidOperation(format!(
112                "DenseFst: {num_frames} frames of {num_symbols} symbols overflows"
113            ))
114        })?;
115        if scores.len() != expected {
116            return Err(OpenFstError::InvalidOperation(format!(
117                "DenseFst: {} scores for {num_frames} frames of {num_symbols} symbols, expected {expected}",
118                scores.len()
119            )));
120        }
121        if num_symbols == 0 && num_frames > 0 {
122            return Err(OpenFstError::InvalidOperation(
123                "DenseFst: frames with no symbols leave the last frame unreachable".into(),
124            ));
125        }
126        if label_offset < 0 {
127            return Err(OpenFstError::InvalidOperation(
128                "DenseFst: a negative label offset would put a column on kNoLabel".into(),
129            ));
130        }
131        let last = label_offset + num_symbols as i64 - 1;
132        if num_symbols > 0 && A::Label::from_i64(last).is_none() {
133            return Err(OpenFstError::InvalidOperation(format!(
134                "DenseFst: label {last} does not fit the arc's label type"
135            )));
136        }
137
138        let props = Self::compute_properties(scores, num_frames, num_symbols, label_offset);
139        Ok(Self {
140            scores,
141            num_frames,
142            num_symbols,
143            label_offset,
144            props,
145            _arc: PhantomData,
146        })
147    }
148
149    /// The number of frames, which is one less than the number of states.
150    #[inline(always)]
151    pub fn num_frames(&self) -> usize {
152        self.num_frames
153    }
154
155    /// The number of symbols the acoustic model scores.
156    #[inline(always)]
157    pub fn num_symbols(&self) -> usize {
158        self.num_symbols
159    }
160
161    /// The label column 0 was placed on.
162    #[inline(always)]
163    pub fn label_offset(&self) -> i64 {
164        self.label_offset
165    }
166
167    /// The scores of one frame, indexed by column.
168    ///
169    /// A decoder wants exactly this: it walks the *graph*'s arcs and looks up
170    /// each one's label here, rather than iterating this FST's `V` arcs and
171    /// discarding the ones the graph has no use for.
172    #[inline(always)]
173    pub fn frame(&self, t: usize) -> &'a [f32] {
174        &self.scores[t * self.num_symbols..(t + 1) * self.num_symbols]
175    }
176
177    /// The column a label falls in, or `None` if it names no column.
178    #[inline(always)]
179    pub fn column_of(&self, label: A::Label) -> Option<usize> {
180        let index = label.to_i64()? - self.label_offset;
181        (index >= 0 && (index as u64) < self.num_symbols as u64).then_some(index as usize)
182    }
183
184    fn compute_properties(
185        scores: &[f32],
186        num_frames: usize,
187        num_symbols: usize,
188        label_offset: i64,
189    ) -> u64 {
190        // Every claim below is structural: one arc per symbol per frame, in
191        // column order, from frame t to frame t + 1 only.
192        let mut props = K_EXPANDED
193            | K_ACCEPTOR
194            | K_I_DETERMINISTIC
195            | K_O_DETERMINISTIC
196            | K_I_LABEL_SORTED
197            | K_O_LABEL_SORTED
198            | K_ACYCLIC
199            | K_INITIAL_ACYCLIC
200            | K_TOP_SORTED
201            | K_ACCESSIBLE
202            | K_CO_ACCESSIBLE
203            // Vacuously: there are no cycles to weight.
204            | K_UNWEIGHTED_CYCLES;
205
206        // Column 0 sits on epsilon only if it was asked to.
207        if label_offset == 0 && num_symbols > 0 && num_frames > 0 {
208            props |= K_EPSILONS | K_I_EPSILONS | K_O_EPSILONS;
209        } else {
210            props |= K_NO_EPSILONS | K_NO_I_EPSILONS | K_NO_O_EPSILONS;
211        }
212
213        // A string is an FST with exactly one path, which this is when each
214        // frame offers a single symbol, or when there are no frames at all.
215        if num_symbols <= 1 || num_frames == 0 {
216            props |= K_STRING;
217        } else {
218            props |= K_NOT_STRING;
219        }
220
221        // The one claim that is about the numbers rather than the shape. It
222        // costs one pass over the matrix, once, at construction.
223        if scores.iter().all(|&score| score == 0.0) {
224            props |= K_UNWEIGHTED;
225        } else {
226            props |= K_WEIGHTED;
227        }
228        props
229    }
230}
231
232/// The arcs of one frame: one per column, in column order.
233#[derive(Debug, Clone)]
234pub struct DenseArcIter<'a, A: Arc> {
235    row: &'a [f32],
236    column: usize,
237    label_offset: i64,
238    nextstate: A::StateId,
239    _arc: PhantomData<A>,
240}
241
242impl<A: Arc> Iterator for DenseArcIter<'_, A>
243where
244    A::Weight: FromScore,
245{
246    type Item = A;
247
248    #[inline]
249    fn next(&mut self) -> Option<A> {
250        let &score = self.row.get(self.column)?;
251        // Checked once in the constructor, for the largest column.
252        let label = A::Label::from_i64(self.label_offset + self.column as i64)?;
253        self.column += 1;
254        Some(A::new(
255            label,
256            label,
257            A::Weight::from_cost(score),
258            self.nextstate,
259        ))
260    }
261
262    #[inline]
263    fn size_hint(&self) -> (usize, Option<usize>) {
264        let left = self.row.len() - self.column;
265        (left, Some(left))
266    }
267}
268
269impl<A: Arc> ExactSizeIterator for DenseArcIter<'_, A> where A::Weight: FromScore {}
270
271impl<A: Arc> Fst<A> for DenseFst<'_, A>
272where
273    A::Weight: FromScore,
274{
275    type StateIter<'s>
276        = DenseStateIter<A>
277    where
278        Self: 's;
279    type ArcIter<'s>
280        = DenseArcIter<'s, A>
281    where
282        Self: 's;
283
284    #[inline]
285    fn start(&self) -> Option<A::StateId> {
286        Some(A::StateId::from_usize(0))
287    }
288
289    #[inline]
290    fn final_weight(&self, state: A::StateId) -> A::Weight {
291        if state.as_usize() == self.num_frames {
292            A::Weight::one()
293        } else {
294            A::Weight::zero()
295        }
296    }
297
298    #[inline]
299    fn num_arcs(&self, state: A::StateId) -> usize {
300        if state.as_usize() < self.num_frames {
301            self.num_symbols
302        } else {
303            0
304        }
305    }
306
307    #[inline]
308    fn num_input_epsilons(&self, state: A::StateId) -> usize {
309        usize::from(self.label_offset == 0 && self.num_arcs(state) > 0)
310    }
311
312    #[inline]
313    fn num_output_epsilons(&self, state: A::StateId) -> usize {
314        self.num_input_epsilons(state)
315    }
316
317    #[inline]
318    fn num_states_if_known(&self) -> Option<usize> {
319        Some(self.num_frames + 1)
320    }
321
322    #[inline]
323    fn properties(&self, mask: u64, _test: bool) -> u64 {
324        // Everything was settled at construction, so there is nothing `test`
325        // could compute that is not already here.
326        self.props & mask
327    }
328
329    #[inline]
330    fn fst_type(&self) -> &str {
331        "dense"
332    }
333
334    fn input_symbols(&self) -> Option<AtomicRc<SymbolTable>> {
335        None
336    }
337
338    fn output_symbols(&self) -> Option<AtomicRc<SymbolTable>> {
339        None
340    }
341
342    #[inline]
343    fn states<'s>(&'s self) -> Self::StateIter<'s> {
344        DenseStateIter {
345            next: 0,
346            end: self.num_frames + 1,
347            _arc: PhantomData,
348        }
349    }
350
351    #[inline]
352    fn arcs<'s>(&'s self, state: A::StateId) -> Self::ArcIter<'s> {
353        let t = state.as_usize();
354        let row: &[f32] = if t < self.num_frames {
355            self.frame(t)
356        } else {
357            &[]
358        };
359        DenseArcIter {
360            row,
361            column: 0,
362            label_offset: self.label_offset,
363            nextstate: A::StateId::from_usize(t + 1),
364            _arc: PhantomData,
365        }
366    }
367}
368
369impl<A: Arc> ExpandedFst<A> for DenseFst<'_, A>
370where
371    A::Weight: FromScore,
372{
373    #[inline]
374    fn num_states(&self) -> usize {
375        self.num_frames + 1
376    }
377}
378
379/// The states of a [`DenseFst`], which are the frame boundaries.
380#[derive(Debug, Clone)]
381pub struct DenseStateIter<A: Arc> {
382    next: usize,
383    end: usize,
384    _arc: PhantomData<A>,
385}
386
387impl<A: Arc> Iterator for DenseStateIter<A> {
388    type Item = A::StateId;
389
390    #[inline]
391    fn next(&mut self) -> Option<A::StateId> {
392        (self.next < self.end).then(|| {
393            let state = A::StateId::from_usize(self.next);
394            self.next += 1;
395            state
396        })
397    }
398
399    #[inline]
400    fn size_hint(&self) -> (usize, Option<usize>) {
401        let left = self.end - self.next;
402        (left, Some(left))
403    }
404}
405
406impl<A: Arc> ExactSizeIterator for DenseStateIter<A> {}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use sicada::arc::StdArc;
412    use sicada::fst::MutableFst;
413    use sicada::fsts::vector_fst::{StdVectorFst, VectorFst};
414    use sicada::properties::K_FST_PROPERTIES;
415    use sicada::weights::float_weight::TropicalWeight;
416
417    // Two frames over three symbols.
418    const SCORES: [f32; 6] = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
419
420    fn dense() -> DenseFst<'static, StdArc> {
421        DenseFst::new(&SCORES, 2, 3).expect("a dense FST")
422    }
423
424    // The same thing built by hand, to compare a computed FST against a
425    // stored one.
426    fn materialised() -> StdVectorFst {
427        let mut fst = VectorFst::new();
428        for _ in 0..3 {
429            fst.add_state();
430        }
431        fst.set_start(0);
432        fst.set_final(2, TropicalWeight::one());
433        for t in 0..2 {
434            for c in 0..3 {
435                let label = c as i32 + 1;
436                fst.add_arc(
437                    t,
438                    StdArc::new(
439                        label,
440                        label,
441                        TropicalWeight(SCORES[t as usize * 3 + c]),
442                        t + 1,
443                    ),
444                );
445            }
446        }
447        fst
448    }
449
450    #[test]
451    fn it_reads_the_matrix_as_a_chain_of_frames() {
452        let dense = dense();
453        let expected = materialised();
454
455        assert_eq!(dense.start(), expected.start());
456        assert_eq!(dense.num_states(), 3);
457        assert_eq!(
458            dense.states().collect::<Vec<_>>(),
459            expected.states().collect::<Vec<_>>()
460        );
461        for state in expected.states() {
462            assert_eq!(dense.num_arcs(state), expected.num_arcs(state));
463            assert_eq!(
464                dense.arcs(state).collect::<Vec<_>>(),
465                expected.arcs(state).collect::<Vec<_>>(),
466                "state {state}"
467            );
468            assert_eq!(dense.final_weight(state), expected.final_weight(state));
469        }
470    }
471
472    // The properties are claims other algorithms act on, so they are compared
473    // against what sicada computes for the same FST stored.
474    #[test]
475    fn its_properties_are_the_ones_the_same_fst_stored_has() {
476        let dense = dense();
477        let expected = materialised();
478        let computed = expected.properties(K_FST_PROPERTIES, true);
479
480        // The stored FST is also mutable, which this is not.
481        let shared = K_FST_PROPERTIES & !sicada::properties::K_MUTABLE;
482        assert_eq!(
483            dense.properties(shared, true),
484            computed & shared,
485            "dense {:#x} vs vector {:#x}",
486            dense.properties(shared, true),
487            computed & shared
488        );
489    }
490
491    #[test]
492    fn one_symbol_per_frame_is_a_string() {
493        let scores = [0.5, 0.25];
494        let dense = DenseFst::<StdArc>::new(&scores, 2, 1).expect("a dense FST");
495        assert_ne!(dense.properties(K_STRING, true), 0);
496        assert_eq!(dense.properties(K_NOT_STRING, true), 0);
497    }
498
499    #[test]
500    fn no_frames_is_the_empty_string() {
501        let dense = DenseFst::<StdArc>::new(&[], 0, 5).expect("a dense FST");
502        assert_eq!(dense.num_states(), 1);
503        assert_eq!(dense.final_weight(0), TropicalWeight::one());
504        assert_eq!(dense.arcs(0).count(), 0);
505    }
506
507    #[test]
508    fn the_columns_move_off_epsilon_by_default() {
509        let dense = dense();
510        assert_ne!(dense.properties(K_NO_EPSILONS, true), 0);
511        assert!(dense.arcs(0).all(|arc| arc.ilabel() != 0));
512
513        let on_epsilon = DenseFst::<StdArc>::with_label_offset(&SCORES, 2, 3, 0).unwrap();
514        assert_ne!(on_epsilon.properties(K_EPSILONS, true), 0);
515        assert_eq!(on_epsilon.num_input_epsilons(0), 1);
516    }
517
518    #[test]
519    fn a_matrix_of_the_wrong_size_is_refused() {
520        let err = DenseFst::<StdArc>::new(&SCORES, 2, 4).unwrap_err();
521        assert!(format!("{err}").contains("expected 8"), "{err}");
522    }
523
524    #[test]
525    fn a_frame_lookup_is_the_same_score_the_arc_carries() {
526        let dense = dense();
527        for t in 0..dense.num_frames() {
528            for arc in dense.arcs(t as i32) {
529                let column = dense.column_of(arc.ilabel()).expect("a column");
530                assert_eq!(dense.frame(t)[column], arc.weight().to_cost());
531            }
532        }
533        assert_eq!(dense.column_of(0), None, "epsilon names no column");
534        assert_eq!(dense.column_of(4), None, "past the last column");
535    }
536}