Skip to main content

sicada_decode/
compact_lattice_weight.rs

1//! The semiring of a *compact* lattice: a cost and the alignment that earned it.
2//!
3//! A lattice has one arc per frame, so a word spans as many arcs as it took
4//! frames to say, and the same word sequence appears once for every way of
5//! lining it up against the audio. That is not what anyone wants to read, to
6//! rescore, or to count *n*-best over.
7//!
8//! A compact lattice has one arc per *word*, with the frames it spanned moved
9//! into the weight: [`CompactLatticeWeight`] is a [`LatticeWeight`] paired with
10//! the sequence of input labels the word consumed. Determinizing over this
11//! semiring is what collapses the alignments: two arcs with the same word merge,
12//! and ⊕ keeps the better alignment rather than both.
13//!
14//! This is Kaldi's `CompactLatticeWeightTpl` (`fstext/lattice-weight.h`).
15//!
16//! It is the same idea as OpenFst's gallic weight, a weight paired with a
17//! string, and sicada has one of those. The reason not to use it: the gallic
18//! types' ⊕ resolves a disagreement between two label sequences by taking a
19//! common prefix, refusing, or keeping a union, and the answer here is none of
20//! those. Two alignments of the same word are both correct; the better-scoring
21//! one wins outright, and its whole sequence survives. That is a different ⊕,
22//! so it is a different semiring.
23
24use std::fmt;
25use std::hash::Hash;
26use std::str::FromStr;
27
28use sicada::arc::ArcLabel;
29use sicada::fst_type::WeightType;
30use sicada::utils::io::{FstScalar, read_scalar, write_scalar};
31use sicada::weight::{
32    Divide, DivideType, IDEMPOTENT, IdempotentWeight, LEFT_SEMIRING, LeftSemiring, PATH,
33    PathWeight, RIGHT_SEMIRING, RightSemiring, Weight, WeightIo,
34};
35use smallvec::SmallVec;
36
37use crate::lattice_weight::LatticeWeight;
38
39/// The alignment a compact-lattice arc carries.
40///
41/// SICADA-OPT: upstream stores this in a `std::vector`, which heap-allocates
42/// every time ⊗ concatenates two of them, and determinization does little else.
43/// Most are short: an arc's own string is one label long, and a word's is
44/// however many frames it took to say.
45pub type Alignment<L> = SmallVec<[L; 8]>;
46
47/// A cost and the input labels that earned it.
48#[derive(Debug, Clone, Default)]
49pub struct CompactLatticeWeight<L: ArcLabel> {
50    weight: LatticeWeight,
51    alignment: Alignment<L>,
52}
53
54impl<L: ArcLabel> CompactLatticeWeight<L> {
55    /// A weight from its cost and its alignment.
56    ///
57    /// The empty alignment is forced when the cost is `zero()`: a semiring has
58    /// exactly one zero, and `(zero, [5])` would be a second one. It would
59    /// absorb under ⊗ and lose under ⊕ exactly as `(zero, [])` does, but be
60    /// unequal to it, so every algorithm that compares against `zero()` would
61    /// miss it.
62    #[inline]
63    pub fn new(weight: LatticeWeight, alignment: Alignment<L>) -> Self {
64        if weight == LatticeWeight::zero() {
65            return Self::zero();
66        }
67        Self { weight, alignment }
68    }
69
70    /// A weight with no alignment yet.
71    #[inline]
72    pub fn from_weight(weight: LatticeWeight) -> Self {
73        Self::new(weight, Alignment::new())
74    }
75
76    /// The cost half.
77    #[inline(always)]
78    pub fn weight(&self) -> &LatticeWeight {
79        &self.weight
80    }
81
82    /// The input labels this weight spans, in order.
83    #[inline(always)]
84    pub fn alignment(&self) -> &[L] {
85        &self.alignment
86    }
87
88    /// Ordering in the semiring: `Greater` means better.
89    ///
90    /// The cost decides; a tie goes to the *shorter* alignment, and then to the
91    /// lexicographically smaller one. Upstream's reason for preferring the
92    /// shorter one is worth keeping: it makes ⊕ a function of its arguments
93    /// rather than of the order they arrived in, which determinization relies
94    /// on to converge.
95    #[inline]
96    fn compare(&self, other: &Self) -> std::cmp::Ordering {
97        use std::cmp::Ordering::*;
98        match compare_lattice_weights(&self.weight, &other.weight) {
99            Equal => {}
100            ordering => return ordering,
101        }
102        match other.alignment.len().cmp(&self.alignment.len()) {
103            Equal => {}
104            ordering => return ordering,
105        }
106        // Both lengths are equal, so this is an ordinary lexicographic
107        // comparison, reversed, since smaller labels are "greater" here for the
108        // same reason smaller costs are.
109        other.alignment.cmp(&self.alignment)
110    }
111}
112
113/// [`LatticeWeight`]'s own ordering, which it keeps private.
114#[inline]
115fn compare_lattice_weights(lhs: &LatticeWeight, rhs: &LatticeWeight) -> std::cmp::Ordering {
116    use std::cmp::Ordering::*;
117    let (mine, theirs) = (lhs.total(), rhs.total());
118    if mine < theirs {
119        Greater
120    } else if mine > theirs {
121        Less
122    } else if lhs.graph < rhs.graph {
123        Greater
124    } else if lhs.graph > rhs.graph {
125        Less
126    } else {
127        Equal
128    }
129}
130
131impl<L: ArcLabel> PartialEq for CompactLatticeWeight<L> {
132    #[inline]
133    fn eq(&self, other: &Self) -> bool {
134        self.weight == other.weight && self.alignment == other.alignment
135    }
136}
137
138impl<L: ArcLabel> Eq for CompactLatticeWeight<L> {}
139
140impl<L: ArcLabel> Hash for CompactLatticeWeight<L> {
141    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
142        self.weight.hash(state);
143        self.alignment.as_slice().hash(state);
144    }
145}
146
147impl<L: ArcLabel> fmt::Display for CompactLatticeWeight<L> {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        // Kaldi's text form: the two costs, then the alignment joined by `_`.
150        write!(f, "{},", self.weight)?;
151        for (index, label) in self.alignment.iter().enumerate() {
152            if index > 0 {
153                write!(f, "_")?;
154            }
155            write!(f, "{label}")?;
156        }
157        Ok(())
158    }
159}
160
161impl<L: ArcLabel> FromStr for CompactLatticeWeight<L> {
162    type Err = String;
163
164    fn from_str(s: &str) -> Result<Self, Self::Err> {
165        let (graph, rest) = s.split_once(',').ok_or_else(|| {
166            format!(
167                "{}: expected `graph,acoustic,alignment`, got {s:?}",
168                Self::type_name()
169            )
170        })?;
171        let (acoustic, labels) = rest.split_once(',').ok_or_else(|| {
172            format!(
173                "{}: expected `graph,acoustic,alignment`, got {s:?}",
174                Self::type_name()
175            )
176        })?;
177        let weight: LatticeWeight = format!("{graph},{acoustic}").parse()?;
178
179        let mut alignment = Alignment::new();
180        for label in labels.split('_').filter(|piece| !piece.is_empty()) {
181            alignment.push(
182                label
183                    .trim()
184                    .parse()
185                    .map_err(|_| format!("{}: {label:?} is not a label", Self::type_name()))?,
186            );
187        }
188        Ok(Self::new(weight, alignment))
189    }
190}
191
192impl<L: ArcLabel> Weight for CompactLatticeWeight<L> {
193    type ReverseWeight = Self;
194
195    #[inline]
196    fn zero() -> Self {
197        Self {
198            weight: LatticeWeight::zero(),
199            alignment: Alignment::new(),
200        }
201    }
202
203    #[inline]
204    fn one() -> Self {
205        Self {
206            weight: LatticeWeight::one(),
207            alignment: Alignment::new(),
208        }
209    }
210
211    #[inline]
212    fn no_weight() -> Self {
213        Self {
214            weight: LatticeWeight::no_weight(),
215            alignment: Alignment::new(),
216        }
217    }
218
219    /// Kaldi's name for this weight, as recorded in an FST file header.
220    ///
221    /// It carries the *sizes*: `"compact"`, then the inner weight's name
222    /// (`"lattice4"` for a pair of `f32`), then the width of one alignment
223    /// label in bytes. So a lattice over `i32` labels is `compactlattice44`,
224    /// and one over `i64` labels is `compactlattice48`.
225    #[inline]
226    fn type_name() -> WeightType {
227        WeightType::new_dynamic(format!(
228            "compact{}{}",
229            LatticeWeight::type_name(),
230            std::mem::size_of::<L>()
231        ))
232    }
233
234    #[inline(always)]
235    fn properties() -> u64 {
236        // Not commutative: ⊗ concatenates alignments, and `a·b` is not `b·a`.
237        LEFT_SEMIRING | RIGHT_SEMIRING | PATH | IDEMPOTENT
238    }
239
240    #[inline]
241    fn plus(&self, rhs: &Self) -> Self {
242        if !self.is_member() || !rhs.is_member() {
243            return Self::no_weight();
244        }
245        if self.compare(rhs).is_ge() {
246            self.clone()
247        } else {
248            rhs.clone()
249        }
250    }
251
252    #[inline]
253    fn times(&self, rhs: &Self) -> Self {
254        if !self.is_member() || !rhs.is_member() {
255            return Self::no_weight();
256        }
257        let weight = self.weight.times(&rhs.weight);
258        if weight == LatticeWeight::zero() {
259            return Self::zero();
260        }
261        let mut alignment = Alignment::with_capacity(self.alignment.len() + rhs.alignment.len());
262        alignment.extend_from_slice(&self.alignment);
263        alignment.extend_from_slice(&rhs.alignment);
264        Self { weight, alignment }
265    }
266
267    #[inline]
268    fn reverse(&self) -> Self::ReverseWeight {
269        let mut alignment = self.alignment.clone();
270        alignment.reverse();
271        Self {
272            weight: self.weight.reverse(),
273            alignment,
274        }
275    }
276
277    #[inline]
278    fn is_member(&self) -> bool {
279        // The zero is unique, so an alignment attached to one is not a weight.
280        self.weight.is_member()
281            && (self.weight != LatticeWeight::zero() || self.alignment.is_empty())
282    }
283
284    #[inline]
285    fn approx_equal(&self, other: &Self, delta: f32) -> bool {
286        self.weight.approx_equal(&other.weight, delta) && self.alignment == other.alignment
287    }
288
289    #[inline]
290    fn quantize(&self, delta: f32) -> Self {
291        Self {
292            weight: self.weight.quantize(delta),
293            alignment: self.alignment.clone(),
294        }
295    }
296}
297
298/// The bytes Kaldi's `CompactLatticeWeightTpl::Write` produces: the cost, then
299/// the alignment's length as an `i32`, then the labels.
300impl<L: ArcLabel + FstScalar> WeightIo for CompactLatticeWeight<L> {
301    fn read<R: std::io::Read>(reader: &mut R) -> std::io::Result<Self> {
302        let weight = LatticeWeight::read(reader)?;
303        let size: i32 = read_scalar(reader)?;
304        if size < 0 {
305            return Err(std::io::Error::new(
306                std::io::ErrorKind::InvalidData,
307                format!("{}: an alignment of {size} labels", Self::type_name()),
308            ));
309        }
310        let mut alignment = Alignment::with_capacity(size as usize);
311        for _ in 0..size {
312            alignment.push(read_scalar(reader)?);
313        }
314        Ok(Self::new(weight, alignment))
315    }
316
317    fn write<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
318        self.weight.write(writer)?;
319        write_scalar(writer, self.alignment.len() as i32)?;
320        for &label in &self.alignment {
321            write_scalar(writer, label)?;
322        }
323        Ok(())
324    }
325}
326
327impl<L: ArcLabel> Divide for CompactLatticeWeight<L> {
328    /// Undoes a ⊗ from one side: the costs subtract and the alignment gives
329    /// back the part `rhs` did not contribute.
330    ///
331    /// SICADA-DIVERGE: upstream aborts the process on every case this returns
332    /// [`Weight::no_weight`] for: dividing by zero, an alignment `rhs` is not a
333    /// prefix or suffix of, or `DivideType::Any`, which has no answer when ⊗
334    /// does not commute. sicada already has a value for "this division has no
335    /// result", and the algorithms that divide already test for it, so there is
336    /// nothing to gain by stopping.
337    fn divide(&self, rhs: &Self, side: DivideType) -> Self {
338        if !self.is_member() || !rhs.is_member() {
339            return Self::no_weight();
340        }
341        if rhs.weight == LatticeWeight::zero() {
342            return Self::no_weight();
343        }
344        if self.weight == LatticeWeight::zero() {
345            return Self::zero();
346        }
347        if rhs.alignment.len() > self.alignment.len() {
348            return Self::no_weight();
349        }
350
351        let weight = self.weight.divide(&rhs.weight, side);
352        if !weight.is_member() {
353            return Self::no_weight();
354        }
355        let split = self.alignment.len() - rhs.alignment.len();
356        let alignment = match side {
357            DivideType::Left => {
358                if self.alignment[..rhs.alignment.len()] != rhs.alignment[..] {
359                    return Self::no_weight();
360                }
361                Alignment::from_slice(&self.alignment[rhs.alignment.len()..])
362            }
363            DivideType::Right => {
364                if self.alignment[split..] != rhs.alignment[..] {
365                    return Self::no_weight();
366                }
367                Alignment::from_slice(&self.alignment[..split])
368            }
369            // Which end to take the alignment off is exactly what `Any` does
370            // not say, and ⊗ here does not commute.
371            DivideType::Any => return Self::no_weight(),
372        };
373        Self::new(weight, alignment)
374    }
375}
376
377impl<L: ArcLabel> LeftSemiring for CompactLatticeWeight<L> {}
378impl<L: ArcLabel> RightSemiring for CompactLatticeWeight<L> {}
379impl<L: ArcLabel> IdempotentWeight for CompactLatticeWeight<L> {}
380impl<L: ArcLabel> PathWeight for CompactLatticeWeight<L> {}
381
382/// An arc of a compact lattice: a word on both sides, the cost and the
383/// alignment in the weight.
384pub type CompactLatticeArc<A> = sicada::arc::ArcTpl<
385    CompactLatticeWeight<<A as sicada::arc::Arc>::Label>,
386    <A as sicada::arc::Arc>::Label,
387    <A as sicada::arc::Arc>::StateId,
388>;
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use sicada::weight::axioms;
394
395    type W = CompactLatticeWeight<i32>;
396
397    fn aligned(graph: f32, acoustic: f32, labels: &[i32]) -> W {
398        W::new(
399            LatticeWeight::new(graph, acoustic),
400            Alignment::from_slice(labels),
401        )
402    }
403
404    fn samples() -> Vec<W> {
405        vec![
406            aligned(0.0, 0.0, &[]),
407            aligned(1.0, 0.5, &[7]),
408            aligned(0.25, 2.0, &[7, 8]),
409            aligned(2.0, -1.0, &[9]),
410            aligned(1.0, 0.5, &[8]),
411            W::zero(),
412        ]
413    }
414
415    #[test]
416    fn it_is_the_semiring_it_says_it_is() {
417        axioms::check(&samples());
418        axioms::check_divide(&samples());
419    }
420
421    // The claim it deliberately does *not* make. ⊗ concatenates, so the two
422    // orders differ, and an algorithm that assumed otherwise would reorder a
423    // word's frames.
424    #[test]
425    fn it_does_not_claim_to_commute() {
426        assert_eq!(W::properties() & sicada::weight::COMMUTATIVE, 0);
427        let a = aligned(0.0, 0.0, &[1]);
428        let b = aligned(0.0, 0.0, &[2]);
429        assert_ne!(a.times(&b), b.times(&a));
430        assert_eq!(a.times(&b).alignment(), &[1, 2]);
431    }
432
433    // Alternative alignments select the cheaper complete weight.
434    #[test]
435    fn plus_keeps_the_better_alignment_whole() {
436        let cheap = aligned(1.0, 1.0, &[5, 5, 6]);
437        let dear = aligned(1.0, 3.0, &[5, 6, 6]);
438        assert_eq!(cheap.plus(&dear), cheap);
439        assert_eq!(dear.plus(&cheap), cheap);
440        assert_eq!(cheap.plus(&dear).alignment(), &[5, 5, 6]);
441    }
442
443    // A tie has to resolve the same way whichever order the two arrive in, or
444    // determinization would not converge.
445    #[test]
446    fn a_tie_prefers_the_shorter_alignment() {
447        let short = aligned(1.0, 1.0, &[5]);
448        let long = aligned(1.0, 1.0, &[5, 5]);
449        assert_eq!(short.plus(&long), short);
450        assert_eq!(long.plus(&short), short);
451
452        let low = aligned(1.0, 1.0, &[4, 9]);
453        let high = aligned(1.0, 1.0, &[5, 5]);
454        assert_eq!(low.plus(&high), low);
455        assert_eq!(high.plus(&low), low);
456    }
457
458    // A second zero would be absorbing under ⊗ and losing under ⊕ just as the
459    // real one is, but unequal to it, so `== zero()` would start missing it.
460    #[test]
461    fn the_zero_is_unique() {
462        assert_eq!(
463            W::new(LatticeWeight::zero(), Alignment::from_slice(&[5])),
464            W::zero()
465        );
466        assert!(W::zero().is_member());
467        assert!(
468            !W {
469                weight: LatticeWeight::zero(),
470                alignment: Alignment::from_slice(&[5]),
471            }
472            .is_member(),
473            "one built behind `new`'s back is not a weight"
474        );
475        assert_eq!(aligned(1.0, 1.0, &[3]).times(&W::zero()), W::zero());
476    }
477
478    #[test]
479    fn dividing_takes_the_alignment_off_the_named_end() {
480        let whole = aligned(3.0, 3.0, &[1, 2, 3]);
481        let head = aligned(1.0, 1.0, &[1]);
482        let tail = aligned(1.0, 1.0, &[3]);
483
484        let rest = whole.divide(&head, DivideType::Left);
485        assert_eq!(rest.alignment(), &[2, 3]);
486        assert_eq!(head.times(&rest), whole);
487
488        let start = whole.divide(&tail, DivideType::Right);
489        assert_eq!(start.alignment(), &[1, 2]);
490        assert_eq!(start.times(&tail), whole);
491
492        // The alignment has to actually be there to be taken off.
493        assert!(!whole.divide(&tail, DivideType::Left).is_member());
494        assert!(!whole.divide(&head, DivideType::Right).is_member());
495        // And `Any` cannot say which end.
496        assert!(!whole.divide(&head, DivideType::Any).is_member());
497    }
498
499    #[test]
500    fn reversing_reverses_the_alignment() {
501        let w = aligned(1.0, 2.0, &[1, 2, 3]);
502        assert_eq!(w.reverse().alignment(), &[3, 2, 1]);
503        assert_eq!(w.reverse().reverse(), w);
504    }
505
506    #[test]
507    fn it_reads_back_what_it_prints() {
508        for weight in samples() {
509            let text = weight.to_string();
510            let parsed: W = text.parse().expect(&text);
511            assert_eq!(parsed, weight, "{text}");
512        }
513        assert_eq!(aligned(1.0, 2.0, &[3, 4]).to_string(), "1,2,3_4");
514        assert!("1,2".parse::<W>().is_err());
515    }
516
517    // The name goes into an FST file header, and it is Kaldi's, sizes and
518    // all: `compact` + the cost pair's name + how wide one label is.
519    #[test]
520    fn its_type_name_is_kaldis() {
521        assert_eq!(W::type_name().as_str(), "compactlattice44");
522        assert_eq!(
523            CompactLatticeWeight::<i64>::type_name().as_str(),
524            "compactlattice48"
525        );
526    }
527
528    // A lattice written here should be one Kaldi reads, which means the bytes
529    // are its bytes: the two costs, then the alignment's length, then the
530    // labels.
531    #[test]
532    fn it_writes_the_bytes_upstream_writes() {
533        let mut bytes = Vec::new();
534        aligned(1.0, 2.0, &[7, 8]).write(&mut bytes).unwrap();
535
536        let mut expected = Vec::new();
537        expected.extend_from_slice(&1.0f32.to_le_bytes());
538        expected.extend_from_slice(&2.0f32.to_le_bytes());
539        expected.extend_from_slice(&2i32.to_le_bytes());
540        expected.extend_from_slice(&7i32.to_le_bytes());
541        expected.extend_from_slice(&8i32.to_le_bytes());
542        assert_eq!(bytes, expected);
543
544        for weight in samples() {
545            let mut bytes = Vec::new();
546            weight.write(&mut bytes).unwrap();
547            let read = W::read(&mut bytes.as_slice()).unwrap();
548            assert_eq!(read, weight);
549        }
550    }
551
552    #[test]
553    fn it_can_be_a_key() {
554        use std::collections::HashSet;
555        let mut seen = HashSet::new();
556        assert!(seen.insert(aligned(1.0, 2.0, &[3])));
557        assert!(!seen.insert(aligned(1.0, 2.0, &[3])));
558        assert!(seen.insert(aligned(1.0, 2.0, &[4])));
559    }
560}