Skip to main content

sicada_decode/
align.rs

1//! Exact forced alignment against a known reference.
2//!
3//! The reference is a sequence of `N` phone columns and the acoustic model has
4//! scored `T` frames. Alignment assigns frames to phones by searching a single
5//! chain rather than a lattice of possible transcripts.
6//!
7//! # Chain topology
8//!
9//! States `s_0 … s_N`, where `s_i` means "the first `i` phones are behind us".
10//! `s_0` is the start, `s_N` is the only final state, and four transitions
11//! leave each state, every one of them consuming exactly one frame:
12//!
13//! | transition | reads | goes to | costs | means |
14//! |---|---|---|---|---|
15//! | hold blank | blank | `s_i` | 0 | silence, or phone `i + 1` has not started |
16//! | hold phone | phone `i` | `s_i` | 0 | phone `i` is still sounding (`i ≥ 1`) |
17//! | commit | phone `i + 1` | `s_{i+1}` | 0 | phone `i + 1` starts in this frame |
18//! | skip | blank | `s_{i+1}` | `skip(i + 1)` | phone `i + 1` never happens |
19//!
20//! A skip makes a phone optional at a caller-supplied cost. Skipping is disabled
21//! by default; see [`AlignChain::with_skip_costs`].
22//!
23//! # Exact search
24//!
25//! Every transition consumes one frame and advances at most one phone. A path
26//! of `T` frames can therefore occupy position `i` at frame `t` only when
27//! `t - (T - N) <= i <= t`. [`band`](crate::trellis::band) covers exactly those
28//! reachable cells, so [`align`] needs no beam or pruning threshold.
29//!
30//! The solver reads the original acoustic columns directly. It does not widen
31//! the score matrix to distinguish transitions that read the same phone or
32//! blank column, and it stores the four transition codes in two bits each.
33//!
34//! # Custom topologies
35//!
36//! [`trellis`](crate::trellis) supplies the banded Viterbi and forward-backward
37//! solvers independently of this chain. Implement [`Trellis`] for constraints
38//! such as minimum duration or multi-phone skips. [`AlignChain::against`]
39//! exposes this module's trellis when the raw [`Path`] or posteriors are needed.
40
41use std::ops::Range;
42
43use sicada::arc::{Arc, ArcLabel, ArcStateId};
44use sicada::data_structures::bit_set::DenseBitSet;
45use sicada::error::OpenFstError;
46use sicada::fst::{Fst, MutableFst};
47use sicada::fsts::vector_fst::VectorFst;
48use sicada::properties::K_FST_PROPERTIES;
49use sicada::weight::Weight;
50
51use crate::dense::{DenseFst, FromScore};
52use crate::trellis::{Path, ReversibleTrellis, Step, Trellis, best_path};
53
54// Whether each transition sounds the phone at its destination, indexed by code.
55const SOUNDS: [bool; 4] = [false, true, true, false];
56
57/// A reference to align: the phones in order, and what each one costs to give
58/// up.
59///
60/// Phones are *columns* of the acoustic matrix, not labels. Which label a
61/// column sits on matters only to [`to_fst`](Self::to_fst), because only an FST
62/// has labels; [`align`] never forms one.
63#[derive(Debug, Clone, PartialEq)]
64pub struct AlignChain {
65    /// `phones[p]` is the column position `p` sounds.
66    phones: Vec<u32>,
67    /// `skips[p]` is the cost of giving position `p` up. Infinite forbids it.
68    skips: Vec<f32>,
69    blank: u32,
70}
71
72impl AlignChain {
73    /// A reference every phone of which has to be given frames.
74    ///
75    /// Skipping is forbidden until a caller asks for it, because a skip cost is
76    /// a claim about the phone; see [`with_skip_costs`](Self::with_skip_costs).
77    /// Column 0 is the blank, which is where a CTC model puts it;
78    /// [`with_blank`](Self::with_blank) moves it.
79    pub fn new(phones: impl Into<Vec<u32>>) -> Self {
80        let phones = phones.into();
81        Self {
82            skips: vec![f32::INFINITY; phones.len()],
83            phones,
84            blank: 0,
85        }
86    }
87
88    /// The cost of giving up each position, one per phone.
89    ///
90    /// An infinite cost forbids the skip, and [`new`](Self::new) leaves one
91    /// everywhere. A finite cost is a threshold: the phone is dropped
92    /// only when keeping it costs strictly more, so the number that matters is
93    /// how much acoustic evidence the phone is worth.
94    ///
95    /// Skip costs belong to individual phones rather than to the utterance as a
96    /// whole, so callers can make only the appropriate positions optional.
97    ///
98    /// # Errors
99    ///
100    /// A count that is not the number of phones, or a cost below zero or not a
101    /// number. A negative cost would pay the alignment to throw the reference
102    /// away.
103    pub fn with_skip_costs(mut self, costs: &[f32]) -> Result<Self, OpenFstError> {
104        if costs.len() != self.phones.len() {
105            return Err(OpenFstError::InvalidOperation(format!(
106                "AlignChain: {} skip costs for {} phones",
107                costs.len(),
108                self.phones.len()
109            )));
110        }
111        if let Some(bad) = costs.iter().position(|cost| cost.is_nan() || *cost < 0.0) {
112            return Err(OpenFstError::InvalidOperation(format!(
113                "AlignChain: the skip cost at position {bad} is {}, and a skip that pays for \
114                 itself would drop the reference rather than align it",
115                costs[bad]
116            )));
117        }
118        self.skips.copy_from_slice(costs);
119        Ok(self)
120    }
121
122    /// The same cost for every position.
123    ///
124    /// # Errors
125    ///
126    /// A cost below zero or not a number, as [`with_skip_costs`](Self::with_skip_costs).
127    pub fn with_uniform_skip_cost(self, cost: f32) -> Result<Self, OpenFstError> {
128        let costs = vec![cost; self.phones.len()];
129        self.with_skip_costs(&costs)
130    }
131
132    /// The column meaning "no phone is sounding". Column 0 by default.
133    pub fn with_blank(mut self, column: u32) -> Self {
134        self.blank = column;
135        self
136    }
137
138    /// The number of phones in the reference.
139    #[inline(always)]
140    pub fn num_phones(&self) -> usize {
141        self.phones.len()
142    }
143
144    /// Whether the reference is empty, in which case every frame is blank.
145    #[inline(always)]
146    pub fn is_empty(&self) -> bool {
147        self.phones.is_empty()
148    }
149
150    /// The columns the reference sounds, in order.
151    #[inline(always)]
152    pub fn phones(&self) -> &[u32] {
153        &self.phones
154    }
155
156    /// The cost of giving up each position, in order.
157    #[inline(always)]
158    pub fn skip_costs(&self) -> &[f32] {
159        &self.skips
160    }
161
162    /// The column that means silence.
163    #[inline(always)]
164    pub fn blank(&self) -> u32 {
165        self.blank
166    }
167
168    /// The chain against a matrix of scores, ready to be solved.
169    ///
170    /// [`align`] is this plus [`best_path`] plus reading the answer back into
171    /// phones. Call it directly to get at the [`Path`], whose `codes` are the
172    /// four below and whose `positions` are reference positions, or to run
173    /// [`posteriors`](crate::trellis::posteriors) with an accumulator of your
174    /// own.
175    ///
176    /// # Errors
177    ///
178    /// A phone, or the blank, naming a column the acoustic model does not have.
179    /// Checked once here so that solving never has to.
180    pub fn against<'a, A>(
181        &'a self,
182        dense: &'a DenseFst<'a, A>,
183    ) -> Result<ChainTrellis<'a, A>, OpenFstError>
184    where
185        A: Arc,
186        A::Weight: FromScore,
187    {
188        self.check_columns(dense.num_symbols())?;
189        Ok(ChainTrellis { chain: self, dense })
190    }
191
192    /// The transition that stays put and says nothing: silence, or a phone that
193    /// has not started.
194    pub const HOLD_BLANK: u8 = 0;
195    /// The transition that stays put sounding the phone it is on.
196    pub const HOLD_PHONE: u8 = 1;
197    /// The transition that moves to the next phone and sounds it.
198    pub const COMMIT: u8 = 2;
199    /// The transition that moves to the next phone without sounding it.
200    pub const SKIP: u8 = 3;
201
202    /// Whether a transition sounds the phone of the position it lands in.
203    ///
204    /// The four codes are listed best-first, which is the tie-break; see
205    /// [`ChainTrellis::steps_into`](crate::trellis::Trellis::steps_into).
206    ///
207    /// # Panics
208    ///
209    /// On a code this chain has no transition for.
210    #[inline(always)]
211    pub const fn sounds(code: u8) -> bool {
212        SOUNDS[code as usize]
213    }
214
215    /// The column a frame reads when it sounds `position`, or the blank when it
216    /// sounds nothing.
217    #[inline(always)]
218    fn column(&self, position: Option<usize>) -> u32 {
219        match position {
220            Some(p) => self.phones[p],
221            None => self.blank,
222        }
223    }
224
225    /// Reports a column the acoustic model does not have.
226    ///
227    /// Checked once, so the inner loop can index the frame unconditionally.
228    pub(crate) fn check_columns(&self, num_symbols: usize) -> Result<(), OpenFstError> {
229        let named = std::iter::once((None, self.blank)).chain(
230            self.phones
231                .iter()
232                .enumerate()
233                .map(|(p, &column)| (Some(p), column)),
234        );
235        for (position, column) in named {
236            if column as usize >= num_symbols {
237                let what = match position {
238                    Some(p) => format!("position {p}"),
239                    None => "the blank".to_string(),
240                };
241                return Err(OpenFstError::InvalidOperation(format!(
242                    "AlignChain: {what} is column {column}, which a {num_symbols}-symbol acoustic \
243                     matrix does not have"
244                )));
245            }
246        }
247        Ok(())
248    }
249
250    /// The chain as an ordinary FST, so it can go through the decoders in this
251    /// crate or be composed like anything else.
252    ///
253    /// `label_offset` has to be the one the
254    /// [`DenseFst`] was built with. It is 1 by default,
255    /// because label 0 is epsilon to every FST algorithm and a blank is not one.
256    /// Input labels are the columns, offset.
257    ///
258    /// Output labels name what the frame sounded: `p + 1` for position `p`, and
259    /// `N + 1` for a frame that sounded nothing. *Every* arc carries one, so a
260    /// decoded path's output labels are one per frame and are the alignment
261    /// itself, which [`Alignment::from_output_labels`] reads back. That is what
262    /// makes [`lattice_decode`](crate::lattice::lattice_decode) and
263    /// [`n_best`](crate::nbest::n_best) over this FST produce *alternative*
264    /// alignments, which the exact aligner, returning one answer, does not.
265    ///
266    /// [`align`] is what to use to align. This exists to put the chain in front
267    /// of the rest of the library, and it is also the oracle the exact aligner
268    /// is tested against, since the two share no code.
269    ///
270    /// # Errors
271    ///
272    /// A label that does not fit the arc's label type, or an offset below 1,
273    /// which would put a column on epsilon and so on an arc consuming no frame.
274    pub fn to_fst<A: Arc>(&self, label_offset: i64) -> Result<VectorFst<A>, OpenFstError>
275    where
276        A::Weight: FromScore,
277    {
278        if label_offset < 1 {
279            return Err(OpenFstError::InvalidOperation(
280                "AlignChain::to_fst: column 0 would be epsilon, which consumes no frame".into(),
281            ));
282        }
283        let fits = |value: i64, what: &str| -> Result<A::Label, OpenFstError> {
284            A::Label::from_i64(value).ok_or_else(|| {
285                OpenFstError::InvalidOperation(format!(
286                    "AlignChain::to_fst: {what} {value} does not fit the arc's label type"
287                ))
288            })
289        };
290        let input = |column: u32| fits(label_offset + column as i64, "input label");
291        let n = self.phones.len();
292        // `sounds(Some(p))` is p + 1 and `sounds(None)` is N + 1, so the two
293        // never collide and neither is epsilon.
294        let sounds = |position: Option<usize>| {
295            let value = match position {
296                Some(p) => p as i64 + 1,
297                None => n as i64 + 1,
298            };
299            fits(value, "output label")
300        };
301
302        let mut fst: VectorFst<A> = VectorFst::new();
303        fst.reserve_states(n + 1);
304        for _ in 0..=n {
305            fst.add_state();
306        }
307        fst.set_start(A::StateId::from_usize(0));
308        fst.set_final(A::StateId::from_usize(n), A::Weight::one());
309
310        let blank = input(self.blank)?;
311        let silent = sounds(None)?;
312        for i in 0..=n {
313            let from = A::StateId::from_usize(i);
314            let to = A::StateId::from_usize((i + 1).min(n));
315
316            fst.add_arc(from, A::new(blank, silent, A::Weight::one(), from));
317            if i > 0 {
318                let held = input(self.phones[i - 1])?;
319                fst.add_arc(
320                    from,
321                    A::new(held, sounds(Some(i - 1))?, A::Weight::one(), from),
322                );
323            }
324            if i < n {
325                let next = input(self.phones[i])?;
326                fst.add_arc(from, A::new(next, sounds(Some(i))?, A::Weight::one(), to));
327                // An infinite cost is the absence of the arc, not an arc of
328                // weight zero: `Weight::zero()` would still be an arc, and
329                // algorithms are entitled to keep it.
330                let cost = self.skips[i];
331                if cost.is_finite() {
332                    fst.add_arc(from, A::new(blank, silent, A::Weight::from_cost(cost), to));
333                }
334            }
335        }
336
337        fst.properties(K_FST_PROPERTIES, true);
338        Ok(fst)
339    }
340}
341
342/// Which phone each frame sounded.
343///
344/// Frames the reference does not account for sound nothing, and belong to no
345/// phone; see [`spans`](Self::spans) for why that convention and not the other.
346#[derive(Debug, Clone, PartialEq)]
347pub struct Alignment {
348    /// Per frame, one more than the position sounding in it, or 0 for none.
349    ///
350    /// Held as one number rather than a position and a bit so that a frame
351    /// costs four bytes, and so that no caller can pair a position with the
352    /// wrong bit.
353    sounding: Vec<u32>,
354    num_phones: usize,
355    cost: f32,
356}
357
358impl Alignment {
359    /// The number of frames aligned.
360    #[inline(always)]
361    pub fn num_frames(&self) -> usize {
362        self.sounding.len()
363    }
364
365    /// The number of phones in the reference this came from.
366    #[inline(always)]
367    pub fn num_phones(&self) -> usize {
368        self.num_phones
369    }
370
371    /// The position sounding in `frame`, or `None` for a frame that sounded
372    /// nothing.
373    ///
374    /// # Panics
375    ///
376    /// If `frame` is past the last one.
377    #[inline(always)]
378    pub fn sounding(&self, frame: usize) -> Option<usize> {
379        (self.sounding[frame] as usize).checked_sub(1)
380    }
381
382    /// The whole alignment, one entry per frame.
383    pub fn frames(&self) -> impl ExactSizeIterator<Item = Option<usize>> + '_ {
384        self.sounding.iter().map(|&k| (k as usize).checked_sub(1))
385    }
386
387    /// The alignment's total cost: the acoustic scores of every frame, plus the
388    /// cost of each phone given up.
389    #[inline(always)]
390    pub fn cost(&self) -> f32 {
391        self.cost
392    }
393
394    /// The frames each position occupies, as `[first frame sounding it, last
395    /// frame sounding it + 1)`, or `None` for a position no frame sounded.
396    ///
397    /// Frames assigned to the blank belong to no phone. In particular, trailing
398    /// silence is not included in the last phone's span.
399    pub fn spans(&self) -> Vec<Option<Range<usize>>> {
400        let mut spans = vec![None; self.num_phones];
401        for (frame, &sounding) in self.sounding.iter().enumerate() {
402            let Some(position) = (sounding as usize).checked_sub(1) else {
403                continue;
404            };
405            match &mut spans[position] {
406                slot @ None => *slot = Some(frame..frame + 1),
407                Some(span) => span.end = frame + 1,
408            }
409        }
410        spans
411    }
412
413    /// The frames each *group* of consecutive positions occupies, given how
414    /// many positions each group holds.
415    ///
416    /// The reference is flat, so a word is a run of positions, and this is how
417    /// word times come out of a phone alignment: pass the phone count of each
418    /// word. A group takes its first sounding frame to its last, ignoring the
419    /// blanks in between, since a word does not stop existing because it has a
420    /// pause in the middle of it.
421    ///
422    /// A group whose phones were all skipped has no span.
423    ///
424    /// # Errors
425    ///
426    /// Sizes that do not add up to the number of phones aligned.
427    pub fn group_spans(&self, sizes: &[usize]) -> Result<Vec<Option<Range<usize>>>, OpenFstError> {
428        let total: usize = sizes.iter().sum();
429        if total != self.num_phones {
430            return Err(OpenFstError::InvalidOperation(format!(
431                "Alignment: groups of {total} phones for a {}-phone reference",
432                self.num_phones
433            )));
434        }
435        let spans = self.spans();
436        let mut grouped = Vec::with_capacity(sizes.len());
437        let mut at = 0;
438        for &size in sizes {
439            let mut group: Option<Range<usize>> = None;
440            for span in spans[at..at + size].iter().flatten() {
441                group = Some(match group {
442                    None => span.clone(),
443                    Some(so_far) => so_far.start..span.end,
444                });
445            }
446            grouped.push(group);
447            at += size;
448        }
449        Ok(grouped)
450    }
451
452    /// The positions that took a skip transition and received no frame.
453    pub fn skipped(&self) -> Vec<usize> {
454        let mut sounded = DenseBitSet::new_empty(self.num_phones);
455        for &sounding in &self.sounding {
456            if let Some(position) = (sounding as usize).checked_sub(1) {
457                sounded.insert(position);
458            }
459        }
460        (0..self.num_phones)
461            .filter(|&position| !sounded.contains(position))
462            .collect()
463    }
464
465    /// What each frame paid the acoustic model, in order.
466    ///
467    /// Recomputed from the matrix rather than remembered: a frame that sounded
468    /// nothing paid the blank column, one that sounded position `p` paid `p`'s.
469    /// [`mean_acoustic_cost`](Self::mean_acoustic_cost) is available precisely
470    /// because the alignment can say this without searching again.
471    ///
472    /// # Panics
473    ///
474    /// If `chain` is not the one this was aligned against, or `dense` not the
475    /// matrix it was aligned to.
476    pub fn acoustic_costs<'a, A>(
477        &'a self,
478        chain: &'a AlignChain,
479        dense: &'a DenseFst<'a, A>,
480    ) -> impl ExactSizeIterator<Item = f32> + 'a
481    where
482        A: Arc + 'a,
483        A::Weight: FromScore,
484    {
485        self.sounding.iter().enumerate().map(move |(frame, &k)| {
486            let column = chain.column((k as usize).checked_sub(1));
487            dense.frame(frame)[column as usize]
488        })
489    }
490
491    /// The mean of [`acoustic_costs`](Self::acoustic_costs), or `0.0` for no
492    /// frames.
493    ///
494    /// Scores are negative log probabilities, so smaller is better. Do not use
495    /// this value to choose skip costs: allowing more skips can only reduce the
496    /// acoustic portion of the score.
497    pub fn mean_acoustic_cost<A>(&self, chain: &AlignChain, dense: &DenseFst<'_, A>) -> f32
498    where
499        A: Arc,
500        A::Weight: FromScore,
501    {
502        if self.sounding.is_empty() {
503            return 0.0;
504        }
505        let total: f64 = self
506            .sounding
507            .iter()
508            .enumerate()
509            .map(|(frame, &k)| {
510                let column = chain.column((k as usize).checked_sub(1));
511                dense.frame(frame)[column as usize] as f64
512            })
513            .sum();
514        (total / self.sounding.len() as f64) as f32
515    }
516
517    /// Reads an alignment back from a [`Path`] through
518    /// [`AlignChain::against`].
519    ///
520    /// [`align`] is this on the path [`best_path`] returns. It is separate
521    /// because the path is the more general answer: a caller may want the
522    /// transitions themselves, or may have solved the chain alongside a
523    /// topology of their own.
524    ///
525    /// # Errors
526    ///
527    /// A code the chain has no transition for, which means the path came from
528    /// a different trellis.
529    pub fn from_path(chain: &AlignChain, path: &Path) -> Result<Self, OpenFstError> {
530        let mut sounding = Vec::with_capacity(path.num_frames());
531        for (frame, (&code, &position)) in path.codes().iter().zip(path.positions()).enumerate() {
532            let sounds = *SOUNDS.get(code as usize).ok_or_else(|| {
533                OpenFstError::InvalidOperation(format!(
534                    "Alignment: transition {code} at frame {frame} is not one of the chain's four"
535                ))
536            })?;
537            sounding.push(if sounds { position } else { 0 });
538        }
539        Ok(Self {
540            sounding,
541            num_phones: chain.phones.len(),
542            cost: path.cost(),
543        })
544    }
545
546    /// Reads an alignment back from a path through
547    /// [`AlignChain::to_fst`](AlignChain::to_fst).
548    ///
549    /// The chain's arcs all carry an output label, so a decoded path's labels
550    /// are one per frame. That turns
551    /// [`lattice_decode`](crate::lattice::lattice_decode) and
552    /// [`n_best`](crate::nbest::n_best) over the chain into alternative
553    /// alignments; [`align`] returns the best one directly.
554    ///
555    /// # Errors
556    ///
557    /// A label naming no position, which means the path did not come from this
558    /// chain.
559    pub fn from_output_labels<L: ArcLabel>(
560        chain: &AlignChain,
561        labels: &[L],
562        cost: f32,
563    ) -> Result<Self, OpenFstError> {
564        let num_phones = chain.phones.len();
565        let silent = num_phones as i64 + 1;
566        let mut sounding = Vec::with_capacity(labels.len());
567        for (frame, label) in labels.iter().enumerate() {
568            let value = label.to_i64().unwrap_or(-1);
569            if value == silent {
570                sounding.push(0);
571            } else if value >= 1 && value < silent {
572                sounding.push(value as u32);
573            } else {
574                return Err(OpenFstError::InvalidOperation(format!(
575                    "Alignment: output label {value} at frame {frame} names no position of a \
576                     {num_phones}-phone reference"
577                )));
578            }
579        }
580        Ok(Self {
581            sounding,
582            num_phones,
583            cost,
584        })
585    }
586}
587
588/// [`AlignChain`] against a matrix of scores: the trellis [`align`] solves.
589///
590/// The chain alone has no scores, and a [`Trellis`] is the two together. This
591/// is public because the trellis is the reusable half: `best_path` and
592/// `posteriors` take one, so a caller wanting the raw [`Path`], or a variant
593/// topology of their own, starts here rather than at [`align`]. It is also the
594/// worked example the [`trellis`](crate::trellis) docs point at.
595#[derive(Debug, Clone, Copy)]
596pub struct ChainTrellis<'a, A: Arc> {
597    chain: &'a AlignChain,
598    dense: &'a DenseFst<'a, A>,
599}
600
601impl<A: Arc> ChainTrellis<'_, A> {
602    /// The reference this reads.
603    #[inline(always)]
604    pub fn chain(&self) -> &AlignChain {
605        self.chain
606    }
607}
608
609impl<A: Arc> Trellis<4> for ChainTrellis<'_, A>
610where
611    A::Weight: FromScore,
612{
613    type Frame<'f>
614        = &'f [f32]
615    where
616        Self: 'f;
617
618    #[inline(always)]
619    fn num_frames(&self) -> usize {
620        self.dense.num_frames()
621    }
622
623    #[inline(always)]
624    fn num_positions(&self) -> usize {
625        self.chain.phones.len()
626    }
627
628    #[inline(always)]
629    fn frame(&self, frame: usize) -> &[f32] {
630        self.dense.frame(frame)
631    }
632
633    // Order defines the tie-break: waiting beats sounding, staying beats
634    // advancing, and keeping a phone beats skipping it.
635    #[inline(always)]
636    fn steps_into(&self, frame: &[f32], position: usize) -> [Step; 4] {
637        let blank = Step::new(0, frame[self.chain.blank as usize]);
638        if position == 0 {
639            return [blank, Step::ABSENT, Step::ABSENT, Step::ABSENT];
640        }
641        let phone = frame[self.chain.phones[position - 1] as usize];
642        [
643            blank,
644            Step::new(0, phone),
645            Step::new(1, phone),
646            Step::new(1, self.chain.skips[position - 1] + blank.cost),
647        ]
648    }
649}
650
651impl<A: Arc> ReversibleTrellis<4> for ChainTrellis<'_, A>
652where
653    A::Weight: FromScore,
654{
655    // SICADA-OPT: Spell out the reverse transitions so the backward pass does
656    // not query `steps_into` for every possible advance. The matching axiom
657    // test verifies that this agrees with the forward definition.
658    #[inline(always)]
659    fn steps_out_of(&self, frame: &[f32], position: usize) -> [Step; 4] {
660        let blank = Step::new(0, frame[self.chain.blank as usize]);
661        let hold = if position > 0 {
662            Step::new(0, frame[self.chain.phones[position - 1] as usize])
663        } else {
664            Step::ABSENT
665        };
666        let (commit, skip) = if position < self.chain.phones.len() {
667            (
668                Step::new(1, frame[self.chain.phones[position] as usize]),
669                Step::new(1, self.chain.skips[position] + blank.cost),
670            )
671        } else {
672            (Step::ABSENT, Step::ABSENT)
673        };
674        [blank, hold, commit, skip]
675    }
676}
677
678// The column a transition reads is determined by its destination cell.
679#[inline(always)]
680pub(crate) fn column_read(chain: &AlignChain, code: u8, position: usize) -> u32 {
681    if SOUNDS[code as usize] {
682        chain.phones[position - 1]
683    } else {
684        chain.blank
685    }
686}
687
688/// Aligns `chain` to `dense`: the best path of the chain against the acoustic
689/// scores, exactly.
690///
691/// This is [`best_path`] over [`AlignChain::against`], read back into phones.
692/// A caller who wants the path itself, either to interpret the transitions their
693/// own way or because they have replaced the chain with a topology of their own,
694/// should call those two directly; see [`trellis`](crate::trellis).
695///
696/// Returns `None` when no path exists, which means a reference longer than the
697/// audio with no skips to make up the difference.
698///
699/// # Errors
700///
701/// A phone naming a column the acoustic model does not have, which is a
702/// mismatch between the reference and the model rather than a bad alignment; or
703/// a matrix so large that the traceback plane does not fit in memory, reported
704/// rather than attempted.
705pub fn align<A>(
706    chain: &AlignChain,
707    dense: &DenseFst<'_, A>,
708) -> Result<Option<Alignment>, OpenFstError>
709where
710    A: Arc,
711    A::Weight: FromScore,
712{
713    let trellis = chain.against(dense)?;
714    let Some(path) = best_path(&trellis)? else {
715        return Ok(None);
716    };
717    Alignment::from_path(chain, &path).map(Some)
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use sicada::arc::StdArc;
724    use sicada::fst::ExpandedFst;
725    use sicada::fsts::vector_fst::StdVectorFst;
726
727    use crate::compact::{DeterminizeLatticeOptions, determinize_lattice};
728    use crate::frontier::DecodeOptions;
729    use crate::lattice::{LatticeDecodeOptions, lattice_decode};
730    use crate::nbest::n_best;
731    use crate::trellis::axioms;
732    use crate::viterbi::viterbi_decode;
733
734    // Blank plus three phones.
735    const SYMBOLS: usize = 4;
736
737    // Scores that make one column nearly certain in each frame.
738    fn certain(columns: &[usize]) -> Vec<f32> {
739        let mut scores = vec![10.0; columns.len() * SYMBOLS];
740        for (frame, &column) in columns.iter().enumerate() {
741            scores[frame * SYMBOLS + column] = 0.0;
742        }
743        scores
744    }
745
746    // What the alignment says it cost, recomputed from the reference and the
747    // matrix: the frames' acoustic scores plus the phones given up.
748    //
749    // A traceback that has drifted off the winning path still reports the
750    // winning *cost*, so comparing against an oracle's cost alone would not
751    // catch it. This does.
752    fn recomputed_cost(
753        alignment: &Alignment,
754        chain: &AlignChain,
755        dense: &DenseFst<'_, StdArc>,
756    ) -> f32 {
757        let acoustic: f32 = alignment.acoustic_costs(chain, dense).sum();
758        let skipped: f32 = alignment
759            .skipped()
760            .into_iter()
761            .map(|position| chain.skip_costs()[position])
762            .sum();
763        acoustic + skipped
764    }
765
766    // The answer the aligner is supposed to agree with: build the same chain as
767    // an ordinary FST and decode it with the general decoder.
768    //
769    // The two share no code: one walks a hash-map frontier over an FST's arcs,
770    // the other a banded array of `f32`. An agreement between them is therefore
771    // evidence about the recurrence rather than about a shared mistake.
772    fn by_decoding(chain: &AlignChain, dense: &DenseFst<'_, StdArc>) -> Option<Alignment> {
773        let fst: StdVectorFst = chain.to_fst(1).expect("a chain FST");
774        let decoded =
775            viterbi_decode(&fst, dense, &DecodeOptions::exhaustive()).expect("a decode")?;
776        Some(
777            Alignment::from_output_labels(chain, &decoded.labels, decoded.weight.0)
778                .expect("labels from this chain"),
779        )
780    }
781
782    #[test]
783    fn a_phone_owns_the_frames_that_sound_it() {
784        // Phone 1 for two frames, then silence, then phone 2.
785        let scores = certain(&[1, 1, 0, 2]);
786        let dense = DenseFst::<StdArc>::new(&scores, 4, SYMBOLS).unwrap();
787        let chain = AlignChain::new(vec![1, 2]);
788
789        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
790        assert_eq!(
791            alignment.frames().collect::<Vec<_>>(),
792            vec![Some(0), Some(0), None, Some(1)]
793        );
794        assert_eq!(alignment.spans(), vec![Some(0..2), Some(3..4)]);
795        assert!(alignment.skipped().is_empty());
796        assert!(alignment.cost().abs() < 1e-6, "{}", alignment.cost());
797    }
798
799    // Word times out of a phone alignment, which is usually what a caller
800    // wants from one.
801    #[test]
802    fn a_group_of_phones_spans_its_first_sounding_frame_to_its_last() {
803        // Two words of two phones. The second word's second phone has no
804        // evidence anywhere and is given up.
805        let scores = certain(&[1, 0, 2, 0, 3, 0]);
806        let dense = DenseFst::<StdArc>::new(&scores, 6, SYMBOLS).unwrap();
807        let chain = AlignChain::new(vec![1, 2, 3, 2])
808            .with_uniform_skip_cost(1.0)
809            .unwrap();
810        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
811
812        assert_eq!(alignment.skipped(), vec![3]);
813        // The first word runs across the silence between its two phones.
814        assert_eq!(
815            alignment.group_spans(&[2, 2]).unwrap(),
816            vec![Some(0..3), Some(4..5)]
817        );
818        // A word every phone of which was given up has no time at all.
819        assert_eq!(
820            alignment.group_spans(&[3, 1]).unwrap(),
821            vec![Some(0..5), None]
822        );
823        assert_eq!(alignment.group_spans(&[4]).unwrap(), vec![Some(0..5)]);
824
825        let err = alignment.group_spans(&[2, 1]).unwrap_err();
826        assert!(format!("{err}").contains("groups of 3 phones"), "{err}");
827    }
828
829    // The convention the whole crate's timings rest on: a blank frame is
830    // nobody's.
831    #[test]
832    fn a_blank_frame_belongs_to_no_phone() {
833        // One phone, then eight frames of silence: the end of a line.
834        let scores = certain(&[1, 0, 0, 0, 0, 0, 0, 0, 0]);
835        let dense = DenseFst::<StdArc>::new(&scores, 9, SYMBOLS).unwrap();
836        let chain = AlignChain::new(vec![1]);
837
838        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
839        assert_eq!(
840            alignment.spans(),
841            vec![Some(0..1)],
842            "the phone must not swallow the silence after it"
843        );
844    }
845
846    #[test]
847    fn an_empty_reference_leaves_every_frame_sounding_nothing() {
848        let scores = certain(&[1, 2, 0]);
849        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
850        let chain = AlignChain::new(vec![]);
851
852        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
853        assert!(alignment.frames().all(|sounding| sounding.is_none()));
854        assert_eq!(alignment.spans(), vec![]);
855        // Three frames of blank, two of which the model dislikes.
856        assert!(
857            (alignment.cost() - 20.0).abs() < 1e-6,
858            "{}",
859            alignment.cost()
860        );
861    }
862
863    #[test]
864    fn a_reference_longer_than_the_audio_aligns_to_nothing() {
865        let scores = certain(&[1, 2]);
866        let dense = DenseFst::<StdArc>::new(&scores, 2, SYMBOLS).unwrap();
867        let chain = AlignChain::new(vec![1, 2, 3]);
868        assert_eq!(align(&chain, &dense).unwrap(), None);
869
870        // Not even with skips: a skip consumes a frame like everything else.
871        let chain = chain.with_uniform_skip_cost(0.0).unwrap();
872        assert_eq!(align(&chain, &dense).unwrap(), None);
873    }
874
875    #[test]
876    fn a_phone_the_model_has_no_column_for_is_reported() {
877        let scores = certain(&[1]);
878        let dense = DenseFst::<StdArc>::new(&scores, 1, SYMBOLS).unwrap();
879
880        let err = align(&AlignChain::new(vec![9]), &dense).unwrap_err();
881        assert!(format!("{err}").contains("position 0 is column 9"), "{err}");
882
883        let err = align(&AlignChain::new(vec![1]).with_blank(7), &dense).unwrap_err();
884        assert!(format!("{err}").contains("the blank is column 7"), "{err}");
885    }
886
887    // The reason skipping exists: text that was never spoken.
888    //
889    // Note what a skip is actually weighed against. It consumes a frame like
890    // every other transition, and that frame reads the blank, so giving up a
891    // phone is worth it when `skip` is less than what sounding the phone costs
892    // *over* falling silent, rather than less than what sounding it costs.
893    #[test]
894    fn a_phone_with_no_evidence_is_given_up_only_when_that_is_cheaper() {
895        // Two frames sure of phone 1, then two the model hears as silence. The
896        // third frame is where phone 2 fits best, and even there it costs 3
897        // more than the blank.
898        let scores = [
899            10.0, 0.0, 10.0, 10.0, //
900            10.0, 0.0, 10.0, 10.0, //
901            0.0, 10.0, 3.0, 10.0, //
902            0.0, 10.0, 10.0, 10.0,
903        ];
904        let dense = DenseFst::<StdArc>::new(&scores, 4, SYMBOLS).unwrap();
905        let reference = vec![1, 2];
906
907        // Under that 3, the phone goes.
908        let cheap = AlignChain::new(reference.clone())
909            .with_skip_costs(&[6.0, 1.0])
910            .unwrap();
911        let alignment = align(&cheap, &dense).unwrap().expect("an alignment");
912        assert_eq!(alignment.skipped(), vec![1]);
913        assert_eq!(alignment.spans()[0], Some(0..2));
914        assert_eq!(alignment.spans()[1], None);
915        assert!(
916            (alignment.cost() - 1.0).abs() < 1e-6,
917            "{}",
918            alignment.cost()
919        );
920
921        // Over it, the phone comes back, in the frame that fits it best.
922        let dear = AlignChain::new(reference)
923            .with_skip_costs(&[6.0, 5.0])
924            .unwrap();
925        let alignment = align(&dear, &dense).unwrap().expect("an alignment");
926        assert!(alignment.skipped().is_empty());
927        assert_eq!(alignment.spans(), vec![Some(0..2), Some(2..3)]);
928        assert!(
929            (alignment.cost() - 3.0).abs() < 1e-6,
930            "{}",
931            alignment.cost()
932        );
933    }
934
935    // The threshold has to be strict, or a skip cost set to exactly the
936    // evidence against the phone would throw it away.
937    #[test]
938    fn a_skip_that_only_ties_does_not_happen() {
939        // Frame 1 hears silence; sounding phone 2 there costs 4 more.
940        let scores = [
941            10.0, 0.0, 10.0, 10.0, //
942            0.0, 10.0, 4.0, 10.0,
943        ];
944        let dense = DenseFst::<StdArc>::new(&scores, 2, SYMBOLS).unwrap();
945        let reference = vec![1, 2];
946
947        let tied = AlignChain::new(reference.clone())
948            .with_skip_costs(&[9.0, 4.0])
949            .unwrap();
950        let alignment = align(&tied, &dense).unwrap().expect("an alignment");
951        assert!(
952            alignment.skipped().is_empty(),
953            "a tie has to keep the reference"
954        );
955        assert_eq!(alignment.spans(), vec![Some(0..1), Some(1..2)]);
956        assert!(
957            (alignment.cost() - 4.0).abs() < 1e-6,
958            "{}",
959            alignment.cost()
960        );
961
962        // A hair under, and it is a skip: the threshold is where it says.
963        let under = AlignChain::new(reference)
964            .with_skip_costs(&[9.0, 3.9])
965            .unwrap();
966        let alignment = align(&under, &dense).unwrap().expect("an alignment");
967        assert_eq!(alignment.skipped(), vec![1]);
968    }
969
970    #[test]
971    fn a_skip_cost_that_pays_for_itself_is_refused() {
972        let chain = AlignChain::new(vec![1, 2]);
973        let err = chain.clone().with_skip_costs(&[1.0, -1.0]).unwrap_err();
974        assert!(format!("{err}").contains("position 1"), "{err}");
975        assert!(chain.clone().with_skip_costs(&[f32::NAN, 1.0]).is_err());
976        assert!(
977            chain.clone().with_skip_costs(&[1.0]).is_err(),
978            "wrong count"
979        );
980        assert!(chain.with_uniform_skip_cost(-0.5).is_err());
981    }
982
983    #[test]
984    fn the_alignment_recovers_what_each_frame_paid() {
985        let scores = certain(&[1, 0, 2]);
986        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
987        let chain = AlignChain::new(vec![1, 2]);
988        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
989
990        assert_eq!(
991            alignment.acoustic_costs(&chain, &dense).collect::<Vec<_>>(),
992            vec![0.0, 0.0, 0.0]
993        );
994        assert_eq!(alignment.mean_acoustic_cost(&chain, &dense), 0.0);
995
996        // A reference the audio does not contain costs every frame instead.
997        let wrong = AlignChain::new(vec![3, 3]);
998        let alignment = align(&wrong, &dense).unwrap().expect("an alignment");
999        assert!(
1000            alignment.mean_acoustic_cost(&wrong, &dense) > 5.0,
1001            "an unrelated reference has to be visible in the per-frame cost"
1002        );
1003    }
1004
1005    // A small xorshift, so the random cases below are the same every run.
1006    struct Rng(u64);
1007
1008    impl Rng {
1009        fn next(&mut self) -> u64 {
1010            self.0 ^= self.0 << 13;
1011            self.0 ^= self.0 >> 7;
1012            self.0 ^= self.0 << 17;
1013            self.0
1014        }
1015
1016        fn below(&mut self, n: usize) -> usize {
1017            (self.next() % n as u64) as usize
1018        }
1019
1020        // A cost on a fine enough grid that two paths rarely tie, so the two
1021        // searches' tie-breaking rarely has to agree for the alignments to.
1022        fn cost(&mut self) -> f32 {
1023            self.below(1 << 20) as f32 / 4096.0
1024        }
1025    }
1026
1027    // Every alignment of `num_frames` frames onto `chain`, scored directly.
1028    //
1029    // Exponential, so only for the smallest cases, but it shares nothing at all
1030    // with the aligner, not even the shape of the recurrence.
1031    fn by_brute_force(
1032        chain: &AlignChain,
1033        dense: &DenseFst<'_, StdArc>,
1034        num_frames: usize,
1035    ) -> Option<f32> {
1036        fn walk(
1037            chain: &AlignChain,
1038            dense: &DenseFst<'_, StdArc>,
1039            num_frames: usize,
1040            frame: usize,
1041            position: usize,
1042            cost: f32,
1043            best: &mut Option<f32>,
1044        ) {
1045            if frame == num_frames {
1046                if position == chain.num_phones() && best.is_none_or(|so_far| cost < so_far) {
1047                    *best = Some(cost);
1048                }
1049                return;
1050            }
1051            let scores = dense.frame(frame);
1052            let blank = scores[chain.blank() as usize];
1053            let mut step = |position, extra: f32| {
1054                walk(
1055                    chain,
1056                    dense,
1057                    num_frames,
1058                    frame + 1,
1059                    position,
1060                    cost + extra,
1061                    best,
1062                )
1063            };
1064            step(position, blank);
1065            if position > 0 {
1066                step(position, scores[chain.phones()[position - 1] as usize]);
1067            }
1068            if position < chain.num_phones() {
1069                step(position + 1, scores[chain.phones()[position] as usize]);
1070                let skip = chain.skip_costs()[position];
1071                if skip.is_finite() {
1072                    step(position + 1, skip + blank);
1073                }
1074            }
1075        }
1076
1077        let mut best = None;
1078        walk(chain, dense, num_frames, 0, 0, 0.0, &mut best);
1079        best
1080    }
1081
1082    // Against every alignment there is, on cases small enough to enumerate.
1083    #[test]
1084    fn it_agrees_with_enumerating_every_alignment() {
1085        let mut rng = Rng(0x1234_5678_9ABC_DEF1);
1086        let mut compared = 0;
1087
1088        for round in 0..200 {
1089            let num_frames = 1 + rng.below(7);
1090            let num_phones = rng.below(4);
1091            let phones: Vec<u32> = (0..num_phones)
1092                .map(|_| 1 + rng.below(SYMBOLS - 1) as u32)
1093                .collect();
1094            let chain = AlignChain::new(phones);
1095            // Half the rounds allow skipping, at a cost worth about one frame.
1096            let chain = if rng.below(2) == 0 {
1097                chain
1098                    .with_uniform_skip_cost(rng.below(1 << 12) as f32 / 512.0)
1099                    .unwrap()
1100            } else {
1101                chain
1102            };
1103
1104            let scores: Vec<f32> = (0..num_frames * SYMBOLS).map(|_| rng.cost()).collect();
1105            let dense = DenseFst::<StdArc>::new(&scores, num_frames, SYMBOLS).unwrap();
1106
1107            let expected = by_brute_force(&chain, &dense, num_frames);
1108            let alignment = align(&chain, &dense).unwrap();
1109
1110            match (expected, alignment) {
1111                (None, None) => {}
1112                (Some(expected), Some(alignment)) => {
1113                    compared += 1;
1114                    assert!(
1115                        (alignment.cost() - expected).abs() < 1e-3,
1116                        "round {round}: aligner {} against every path's best {expected}",
1117                        alignment.cost()
1118                    );
1119                    // And the path it reports is the path it priced.
1120                    assert!(
1121                        (recomputed_cost(&alignment, &chain, &dense) - alignment.cost()).abs()
1122                            < 1e-3,
1123                        "round {round}: the traceback does not add up to the cost"
1124                    );
1125                    assert_eq!(alignment.num_frames(), num_frames);
1126                }
1127                (expected, alignment) => {
1128                    panic!("round {round}: brute force {expected:?}, aligner {alignment:?}")
1129                }
1130            }
1131        }
1132
1133        assert!(compared > 150, "only {compared} rounds had an alignment");
1134    }
1135
1136    // Against the same chain decoded as an ordinary FST, at sizes brute force
1137    // cannot reach, which is where the band and the packed traceback start to
1138    // matter.
1139    #[test]
1140    fn it_agrees_with_decoding_the_chain_as_an_fst() {
1141        let mut rng = Rng(0xFEED_FACE_1234_5678);
1142        let mut compared = 0;
1143
1144        for round in 0..200 {
1145            let num_frames = 1 + rng.below(40);
1146            let num_phones = rng.below(12);
1147            let phones: Vec<u32> = (0..num_phones)
1148                .map(|_| 1 + rng.below(SYMBOLS - 1) as u32)
1149                .collect();
1150            let chain = AlignChain::new(phones);
1151            let chain = if rng.below(2) == 0 {
1152                chain
1153                    .with_uniform_skip_cost(rng.below(1 << 12) as f32 / 512.0)
1154                    .unwrap()
1155            } else {
1156                chain
1157            };
1158
1159            let scores: Vec<f32> = (0..num_frames * SYMBOLS).map(|_| rng.cost()).collect();
1160            let dense = DenseFst::<StdArc>::new(&scores, num_frames, SYMBOLS).unwrap();
1161
1162            let expected = by_decoding(&chain, &dense);
1163            let alignment = align(&chain, &dense).unwrap();
1164
1165            match (expected, alignment) {
1166                (None, None) => {}
1167                (Some(expected), Some(alignment)) => {
1168                    compared += 1;
1169                    assert!(
1170                        (alignment.cost() - expected.cost()).abs() < 1e-2,
1171                        "round {round}: aligner {} against the decoder {}",
1172                        alignment.cost(),
1173                        expected.cost()
1174                    );
1175                    assert!(
1176                        (recomputed_cost(&alignment, &chain, &dense) - alignment.cost()).abs()
1177                            < 1e-2,
1178                        "round {round}: the traceback does not add up to the cost"
1179                    );
1180                    assert_eq!(expected.num_frames(), num_frames, "one label per frame");
1181                }
1182                (expected, alignment) => {
1183                    panic!("round {round}: decoder {expected:?}, aligner {alignment:?}")
1184                }
1185            }
1186        }
1187
1188        assert!(compared > 150, "only {compared} rounds had an alignment");
1189    }
1190
1191    // The chain is an FST like any other, so the lattice decoder gives
1192    // alternative alignments the exact aligner does not.
1193    #[test]
1194    fn the_chain_decodes_to_alternative_alignments() {
1195        // Two frames sure of phone 1, and one in between that is torn between
1196        // holding it and falling silent, so the alignment is either three frames
1197        // of phone 1 or two with a gap.
1198        let scores = [
1199            9.0, 0.0, 9.0, 9.0, //
1200            1.0, 0.0, 9.0, 9.0, //
1201            9.0, 0.0, 9.0, 9.0,
1202        ];
1203        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
1204        let chain = AlignChain::new(vec![1]);
1205        let fst: StdVectorFst = chain.to_fst(1).unwrap();
1206
1207        let lattice = lattice_decode(&fst, &dense, &LatticeDecodeOptions::exhaustive())
1208            .unwrap()
1209            .expect("a lattice");
1210        let compact = determinize_lattice(&lattice, &DeterminizeLatticeOptions::default()).unwrap();
1211        let answers = n_best(&compact, 2).unwrap();
1212        assert_eq!(answers.len(), 2);
1213
1214        let best = Alignment::from_output_labels(&chain, &answers[0].words, answers[0].cost())
1215            .expect("an alignment");
1216        assert_eq!(best.spans(), vec![Some(0..3)], "the phone held throughout");
1217        assert_eq!(
1218            align(&chain, &dense).unwrap().unwrap().spans(),
1219            best.spans(),
1220            "and it is what the exact aligner returns"
1221        );
1222
1223        let second = Alignment::from_output_labels(&chain, &answers[1].words, answers[1].cost())
1224            .expect("an alignment");
1225        assert_eq!(
1226            second.frames().collect::<Vec<_>>(),
1227            vec![Some(0), None, Some(0)]
1228        );
1229        assert!((second.cost() - best.cost() - 1.0).abs() < 1e-5);
1230    }
1231
1232    #[test]
1233    fn labels_from_another_chain_are_reported() {
1234        let chain = AlignChain::new(vec![1, 2]);
1235        // 3 is the silent label for a 2-phone reference; 4 names nothing.
1236        assert!(Alignment::from_output_labels(&chain, &[1i32, 3], 0.0).is_ok());
1237        let err = Alignment::from_output_labels(&chain, &[1i32, 4], 0.0).unwrap_err();
1238        assert!(format!("{err}").contains("names no position"), "{err}");
1239        assert!(Alignment::from_output_labels(&chain, &[0i32], 0.0).is_err());
1240    }
1241
1242    #[test]
1243    fn a_chain_fst_puts_its_columns_where_the_matrix_has_them() {
1244        let chain = AlignChain::new(vec![1, 2])
1245            .with_uniform_skip_cost(1.0)
1246            .unwrap();
1247        let fst: StdVectorFst = chain.to_fst(1).unwrap();
1248        assert_eq!(fst.num_states(), 3);
1249        // s_0: hold blank, commit, skip. s_1: those plus hold phone. s_2: hold
1250        // blank and hold phone.
1251        assert_eq!(fst.num_arcs(0), 3);
1252        assert_eq!(fst.num_arcs(1), 4);
1253        assert_eq!(fst.num_arcs(2), 2);
1254        assert!(
1255            fst.states()
1256                .all(|s| fst.arcs(s).all(|arc| arc.ilabel() != 0)),
1257            "every arc has to consume a frame"
1258        );
1259
1260        // Forbidding the skips removes the arcs rather than zero-weighting them.
1261        let fst: StdVectorFst = AlignChain::new(vec![1, 2]).to_fst(1).unwrap();
1262        assert_eq!(fst.num_arcs(0), 2);
1263        assert!(AlignChain::new(vec![1]).to_fst::<StdArc>(0).is_err());
1264    }
1265
1266    // The contract the solvers rely on, run as the checker every trellis is
1267    // told to run, including the requirement that the chain's hand-written
1268    // backward reading is the one its forward reading implies.
1269    #[test]
1270    fn the_chain_obeys_the_trellis_contract() {
1271        let chain = AlignChain::new(vec![1, 2, 1])
1272            .with_skip_costs(&[1.0, 2.0, f32::INFINITY])
1273            .unwrap();
1274        let scores: Vec<f32> = (0..4 * SYMBOLS).map(|i| i as f32 / 3.0).collect();
1275        let dense = DenseFst::<StdArc>::new(&scores, 4, SYMBOLS).unwrap();
1276        axioms::check(&chain.against(&dense).unwrap());
1277
1278        // And with skipping forbidden everywhere, which is a different set of
1279        // absent transitions.
1280        let rigid = AlignChain::new(vec![1, 2, 1]);
1281        axioms::check(&rigid.against(&dense).unwrap());
1282        axioms::check(&AlignChain::new(vec![]).against(&dense).unwrap());
1283    }
1284
1285    // The band is an exact reachability argument, so its edges have to be
1286    // right at both ends: a reference exactly as long as the audio leaves no
1287    // slack at all.
1288    #[test]
1289    fn a_reference_as_long_as_the_audio_has_one_alignment() {
1290        let scores = certain(&[1, 2, 3]);
1291        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
1292        let chain = AlignChain::new(vec![1, 2, 3]);
1293
1294        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
1295        assert_eq!(
1296            alignment.frames().collect::<Vec<_>>(),
1297            vec![Some(0), Some(1), Some(2)]
1298        );
1299        assert!(alignment.cost().abs() < 1e-6);
1300
1301        // Even when every frame would rather be blank.
1302        let scores = [0.0, 10.0, 10.0, 10.0].repeat(3);
1303        let dense = DenseFst::<StdArc>::new(&scores, 3, SYMBOLS).unwrap();
1304        let alignment = align(&chain, &dense).unwrap().expect("an alignment");
1305        assert_eq!(
1306            alignment.frames().collect::<Vec<_>>(),
1307            vec![Some(0), Some(1), Some(2)]
1308        );
1309    }
1310}