asry 0.1.0

Sans-I/O cut/batch/whisper/align state machine for speech-to-text indexing pipelines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
//! Sans-I/O types for resolving out-of-vocab characters
//! during wav2vec2 alignment tokenization.
//!
//! ## Where these flow
//!
//! Asry's alignment dispatcher is Sans-I/O: the library
//! never owns ASR / alignment workers, never calls back into
//! caller code, and never blocks on user policy. Per-chunk
//! OOV policy is supplied alongside the alignment work item
//! itself, not via a separate command/response round-trip:
//!
//! ```text
//! AlignmentSet::detect_oov(text, lang) -> Vec<OovEvent>
//! └─ caller pairs each event with a decision via a helper
//! from this module (or its own loop) producing a per-
//! chunk Vec<ResolvedOov>
//!
//! AlignWorkItem { runs, oov_decisions: Vec<Vec<ResolvedOov>>, .. }
//! └─ alignment pool reads `oov_decisions[run_idx]` for each
//! run and threads it into `tokenize_with_word_map`; the
//! dispatcher recomputes events for the chunk's text and
//! refuses to apply a payload whose events do not match
//! by identity (kind / char_index / word_index / language).
//! Length, outer-shape, OR per-position identity mismatch
//! fails loudly as
//! `::TokenizationFailed` instead of
//! silently mis-aligning a stale-but-same-length payload.
//! ```
//!
//! For whole-chunk alignment use `AlignmentSet::detect_oov`
//! (an `alignment`-feature method; not linked here because
//! `AlignmentSet` doesn't exist under a bare `emissions` build)
//! and supply a single inner `Vec<ResolvedOov>`. For per-run
//! alignment use `AlignmentSet::detect_oov_per_run` and supply
//! one inner vec per run.
//!
//! ## Why the caller decides
//!
//! WhisperX wildcards every OOV char (matches `*` placeholder
//! 1:1). That produces continuous alignment but plausible-but-
//! wrong word ranges on pronounced symbols (`&` in `AT&T` is
//! pronounced as the word "and"; aligning it to whichever vocab
//! item wins the frame yields confidently-wrong timing).
//! Asry's earlier defaults baked the policy into the
//! tokenizer (`whisperx-strict-tokenizer` Cargo feature
//! flipped between fail-closed and wildcard-all) — that
//! denied the caller per-language / per-deployment / per-call
//! choice.
//!
//! Surfacing OOV as data passes the policy decision back to the
//! caller: the library detects OOV chars and returns them as
//! events; the caller applies whatever policy fits their
//! workflow (fail-closed, wildcard-all, fail on `&` but
//! wildcard digits, consult an ops dashboard, etc.). The
//! default policy lives in caller-side helper functions in
//! this module, not inside the library's hot path.

use crate::types::Lang;

/// What kind of wildcard-generating position this event
/// describes. Lets caller policy treat structural wildcards
/// (tokenizer-mechanical positions where a glyph was stripped
/// during normalisation) differently from semantic OOV
/// (chars the model dictionary doesn't have).
///
/// introduced so
/// `fail_closed_all_decisions` truly fails on every wildcard
/// (pre-fix, boundary + internal-punct wildcards bypassed the
/// OOV policy entirely — strict callers got wildcard tokens
/// without their consent).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum OovKind {
  /// Semantic OOV: the tokenizer encountered a char (digit,
  /// letter, pronounced symbol) that the wav2vec2 vocab
  /// doesn't have. Carries the offending char for per-class
  /// policy (e.g. wildcard alphanumeric, fail-closed `&`).
  Symbol(char),
  /// Boundary-punctuation wildcard: the per-language
  /// normaliser stripped a leading or trailing punct char
  /// during normalisation; the tokenizer mechanically pads
  /// the word with a wildcard at the same position to
  /// preserve CTC alignment count. The original char is
  /// already gone by the time this event is emitted.
  BoundaryPunct,
  /// Internal-punctuation wildcard: a `.` (or other
  /// `is_skippable_internal_punct` char) appears inside a
  /// word; asry emits a wildcard at the source position
  /// so dotted acronyms like `U.S.A` align as `U * S * A`.
  /// Carries the offending char for callers that want to
  /// distinguish (e.g. allow `.` but fail other internal
  /// punct).
  InternalPunct(char),
}

/// One wildcard-generating position detected during
/// tokenization.
///
/// Returned by `AlignmentSet::detect_oov[_per_run]`. The
/// caller produces a matching [`OovDecision`] for each event
/// (in the same order) and threads it into the alignment work
/// item via `AlignWorkItem.oov_decisions`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OovEvent {
  /// What kind of wildcard-generating position this is —
  /// semantic OOV vs. structural (boundary / internal-punct).
  kind: OovKind,
  /// Zero-based char index in the chunk's normalised text.
  /// Boundary-punct events index the word's leading/trailing
  /// position (post-normalisation); internal-punct events
  /// index the source position of the punct char.
  char_index: usize,
  /// Zero-based word index (separator-counted) the position
  /// belongs to. Useful for callers that want per-word
  /// policy.
  word_index: usize,
  /// The language being aligned. Caller policy can switch on
  /// this (e.g. wildcard-all under `Lang::En` but fail-closed
  /// under `Lang::Ko`).
  language: Lang,
}

impl OovEvent {
  /// Construct from positional fields + language stamp.
  #[must_use]
  pub const fn new(kind: OovKind, char_index: usize, word_index: usize, language: Lang) -> Self {
    Self {
      kind,
      char_index,
      word_index,
      language,
    }
  }

  /// What kind of wildcard-generating position this is.
  #[must_use]
  pub const fn kind(&self) -> &OovKind {
    &self.kind
  }

  /// Zero-based char index in the chunk's normalised text.
  #[must_use]
  pub const fn char_index(&self) -> usize {
    self.char_index
  }

  /// Zero-based word index (separator-counted).
  #[must_use]
  pub const fn word_index(&self) -> usize {
    self.word_index
  }

  /// Language being aligned for this position.
  #[must_use]
  pub const fn language(&self) -> &Lang {
    &self.language
  }

  /// Replace the language stamp. Used by
  /// `AlignmentSet::detect_oov` under `AlignerKey::Any`
  /// fallback so caller policy sees the requested language
  /// rather than the fallback aligner's construction lang.
  pub fn set_language(&mut self, language: Lang) {
    self.language = language;
  }

  /// Convenience accessor: the offending char when the kind
  /// is `Symbol` or `InternalPunct`. Returns `None` for
  /// `BoundaryPunct` (the original char was stripped during
  /// normalisation and is no longer recoverable).
  #[must_use]
  pub fn char(&self) -> Option<char> {
    match self.kind {
      OovKind::Symbol(c) | OovKind::InternalPunct(c) => Some(c),
      OovKind::BoundaryPunct => None,
    }
  }

  /// Per-position identity check used by `tokenize_with_word_map`
  /// to validate a `ResolvedOov` payload against the chunk's
  /// freshly-detected events.
  ///
  /// Compares the three positional fields (`kind`,
  /// `char_index`, `word_index`) but **not** `language`.
  /// the
  /// `language` field is a caller-policy stamp that
  /// `AlignmentSet::detect_oov` overrides under
  /// `AlignerKey::Any` fallback to the caller-requested
  /// language; the inner `Aligner` always re-detects with its
  /// own construction language. Including `language` in
  /// identity equality made every Any-fallback chunk with an
  /// OOV fail `TokenizationFailed`, even though the events
  /// describe the same text position. Positional fields are
  /// the actual identity; language is metadata for caller
  /// policy.
  #[must_use]
  pub fn matches_position(&self, other: &OovEvent) -> bool {
    self.kind == other.kind
      && self.char_index == other.char_index
      && self.word_index == other.word_index
  }
}

/// Caller's decision for one [`OovEvent`].
///
/// The caller produces one decision per event in the same
/// order. Length / shape mismatches against the chunk's
/// detected events surface as
/// [`AlignmentError::Tokenization`](crate::types::AlignmentError::Tokenization)
/// — the alignment dispatcher refuses to apply stale or
/// out-of-shape decisions silently.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum OovDecision {
  /// Match WhisperX's `clean_char.append('*')`: emit
  /// `WILDCARD_TOKEN_ID = -1`. The CTC trellis aligns this
  /// position to whichever non-blank vocab item carries the
  /// highest log-probability at each frame. Continuous
  /// alignment at the cost of plausible-but-wrong timing on
  /// pronounced symbols.
  Wildcard,
  /// Drop the word alignment entirely. On the `alignment` pool
  /// path the cached ASR transcript still ships in the resulting
  /// [`Transcript`](crate::types::Transcript) — `Transcript::words()`
  /// is just empty for this chunk — and the drop is recorded as
  /// [`AlignmentError::SemanticOutOfVocab`](crate::types::AlignmentError::SemanticOutOfVocab).
  /// A bare `emissions` caller (no ASR transcript, no pool) instead
  /// gets `EmissionsError::SemanticOutOfVocab` back from
  /// `tokenize_with_word_map` and owns whatever text it tokenised.
  /// Honest at the cost of dropped timing.
  FailClosed,
}

/// One resolved OOV: the original event paired with the
/// caller's decision.
///
/// The dispatcher refuses to apply a `ResolvedOov` payload
/// whose embedded `event` does not match the freshly-detected
/// event at the same position via [`OovEvent::matches_position`]
/// (compares `kind`, `char_index`, `word_index` — but NOT
/// `language`, which is caller-policy metadata, not
/// positional identity). This binds the decision to the text
/// it was made for: a stale `[Wildcard]` decision produced
/// for digit OOV `[(Symbol('4'), …)]` cannot be applied to
/// `&` OOV `[(Symbol('&'), …)]` in a different chunk, even
/// if the lengths happen to match — the kind mismatch fails
/// the per-position identity check.
///
/// prior shape
/// passed bare `Vec<OovDecision>` which carried no event
/// identity, so a stale same-length decisions vec would
/// silently bypass policy.
///
/// identity
/// initially included `language`, which broke
/// `AlignerKey::Any` fallback (`AlignmentSet::detect_oov`
/// patches event language to the caller's requested lang,
/// but the fallback `Aligner` re-detects with its own
/// construction lang).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOov {
  /// The OOV event the decision was made for. Must match the
  /// freshly-detected event at the same position or the
  /// dispatcher rejects the payload.
  event: OovEvent,
  /// What to do with this position.
  decision: OovDecision,
}

impl ResolvedOov {
  /// Pair an event with the caller's decision.
  #[must_use]
  pub const fn new(event: OovEvent, decision: OovDecision) -> Self {
    Self { event, decision }
  }

  /// The OOV event this decision was made for.
  #[must_use]
  pub const fn event(&self) -> &OovEvent {
    &self.event
  }

  /// The caller's decision for this position.
  #[must_use]
  pub const fn decision(&self) -> OovDecision {
    self.decision
  }
}

/// Default Sans-I/O policy:
/// * Semantic OOV: alphanumeric / apostrophe → wildcard;
/// pronounced symbol → fail-closed.
/// * Boundary-punct + internal-punct (structural wildcards):
/// wildcard. They reflect tokenizer mechanics, not caller
/// text — failing-closed on `U.S.A.`'s internal `.` would
/// cripple normal English alignment.
///
/// Encodes the "WhisperX-style alphanumeric, fail-closed-on-
/// pronounced" behaviour asry shipped before the
/// `whisperx-strict-tokenizer` Cargo feature was removed.
///
/// Pure caller-side helper. Callers wanting per-language /
/// per-deployment policy should write their own loop over
/// `events`.
#[must_use]
pub fn default_oov_decisions(events: &[OovEvent]) -> Vec<ResolvedOov> {
  events
    .iter()
    .map(|ev| {
      let decision = match &ev.kind {
        OovKind::Symbol(c) => {
          if c.is_alphanumeric() || *c == '\'' || *c == '\u{2019}' {
            OovDecision::Wildcard
          } else {
            OovDecision::FailClosed
          }
        }
        // Structural wildcards: keep historical behaviour.
        OovKind::BoundaryPunct | OovKind::InternalPunct(_) => OovDecision::Wildcard,
      };
      ResolvedOov {
        event: ev.clone(),
        decision,
      }
    })
    .collect()
}

/// WhisperX-bit-equivalent: every OOV → wildcard. Replaces the
/// removed `whisperx-strict-tokenizer` Cargo feature for
/// callers that want WhisperX-1:1 outputs and accept the
/// silent-misalignment risk on pronounced symbols.
#[must_use]
pub fn wildcard_all_decisions(events: &[OovEvent]) -> Vec<ResolvedOov> {
  events
    .iter()
    .map(|ev| ResolvedOov {
      event: ev.clone(),
      decision: OovDecision::Wildcard,
    })
    .collect()
}

/// Strictest: every OOV → fail-closed. Use for workflows where
/// even one wildcard alignment is too much (e.g. legal /
/// medical transcription pipelines that read PII aloud).
#[must_use]
pub fn fail_closed_all_decisions(events: &[OovEvent]) -> Vec<ResolvedOov> {
  events
    .iter()
    .map(|ev| ResolvedOov {
      event: ev.clone(),
      decision: OovDecision::FailClosed,
    })
    .collect()
}

#[cfg(test)]
mod tests {
  use super::*;

  fn ev(c: char) -> OovEvent {
    OovEvent {
      kind: OovKind::Symbol(c),
      char_index: 0,
      word_index: 0,
      language: Lang::En,
    }
  }

  fn boundary_ev() -> OovEvent {
    OovEvent {
      kind: OovKind::BoundaryPunct,
      char_index: 0,
      word_index: 0,
      language: Lang::En,
    }
  }

  fn internal_ev(c: char) -> OovEvent {
    OovEvent {
      kind: OovKind::InternalPunct(c),
      char_index: 0,
      word_index: 0,
      language: Lang::En,
    }
  }

  fn decisions_only(resolved: &[ResolvedOov]) -> Vec<OovDecision> {
    resolved.iter().map(|r| r.decision).collect()
  }

  #[test]
  fn default_wildcards_alphanumeric() {
    let events = vec![ev('4'), ev('a'), ev('Z')];
    let resolved = default_oov_decisions(&events);
    assert_eq!(
      decisions_only(&resolved),
      vec![
        OovDecision::Wildcard,
        OovDecision::Wildcard,
        OovDecision::Wildcard,
      ]
    );
    // Identity binding: each ResolvedOov carries its own event.
    for (r, e) in resolved.iter().zip(events.iter()) {
      assert_eq!(&r.event, e);
    }
  }

  #[test]
  fn default_wildcards_apostrophes() {
    let events = vec![ev('\''), ev('\u{2019}')];
    assert_eq!(
      decisions_only(&default_oov_decisions(&events)),
      vec![OovDecision::Wildcard, OovDecision::Wildcard]
    );
  }

  #[test]
  fn default_fails_closed_on_pronounced_symbols() {
    let events = vec![ev('&'), ev('@'), ev('%'), ev(',')];
    assert_eq!(
      decisions_only(&default_oov_decisions(&events)),
      vec![
        OovDecision::FailClosed,
        OovDecision::FailClosed,
        OovDecision::FailClosed,
        OovDecision::FailClosed,
      ]
    );
  }

  #[test]
  fn wildcard_all_does_what_it_says() {
    let events = vec![ev('a'), ev('&'), ev(',')];
    assert_eq!(
      decisions_only(&wildcard_all_decisions(&events)),
      vec![
        OovDecision::Wildcard,
        OovDecision::Wildcard,
        OovDecision::Wildcard,
      ]
    );
  }

  #[test]
  fn fail_closed_all_does_what_it_says() {
    let events = vec![ev('a'), ev('&'), ev(',')];
    assert_eq!(
      decisions_only(&fail_closed_all_decisions(&events)),
      vec![
        OovDecision::FailClosed,
        OovDecision::FailClosed,
        OovDecision::FailClosed,
      ]
    );
  }

  /// structural
  /// wildcards (boundary + internal-punct) get Wildcard
  /// under the default policy — matches historical behaviour.
  #[test]
  fn default_wildcards_structural_kinds() {
    let events = vec![boundary_ev(), internal_ev('.')];
    assert_eq!(
      decisions_only(&default_oov_decisions(&events)),
      vec![OovDecision::Wildcard, OovDecision::Wildcard],
    );
  }

  /// Strict policy applies to EVERY wildcard-generating
  /// position, including structural ones — that's the point
  /// of `fail_closed_all_decisions` for workflows where any
  /// wildcard alignment is unacceptable.
  #[test]
  fn fail_closed_all_includes_structural_wildcards() {
    let events = vec![ev('a'), boundary_ev(), internal_ev('.')];
    assert_eq!(
      decisions_only(&fail_closed_all_decisions(&events)),
      vec![
        OovDecision::FailClosed,
        OovDecision::FailClosed,
        OovDecision::FailClosed,
      ],
    );
  }

  #[test]
  fn empty_events_returns_empty_decisions() {
    assert!(default_oov_decisions(&[]).is_empty());
    assert!(wildcard_all_decisions(&[]).is_empty());
    assert!(fail_closed_all_decisions(&[]).is_empty());
  }
}