Review feedback (CRITICAL): LocalAgreement::push could panic with an index OOB when a later pass arrived shorter than committed_count. Concrete case: commit [a, b], next pass arrives [a] — lcp_len=1, new_committed=max(1, 2)=2, then latest[2..] panicked because latest.len()==1. A Whisper re-transcription of an overlapping window can legitimately collapse repeated segments, or the user can stop mid-utterance after some tokens were already committed, both of which produce this shape. The committed_count invariant still holds (non-shrinkage) — it is the slicing that was unsafe. Clamps every latest[..] slice against latest.len() before indexing. committed_count stays at new_committed even when the pass is shorter: non-shrinkage is relative to what we have already emitted, not to the current pass length. newly_committed and tentative both return empty when the shorter pass has nothing past the committed prefix. Adds two regression tests: - shorter_pass_after_commit_does_not_panic (commit 2, push 1) - empty_pass_after_commit_does_not_panic (commit 1, push empty)
408 lines
14 KiB
Rust
408 lines
14 KiB
Rust
//! LocalAgreement-n commit policy for streaming transcription.
|
|
//!
|
|
//! Source: ufal/whisper_streaming. Tokens emitted by a streaming ASR
|
|
//! pipeline are held as tentative until `n` consecutive passes produce
|
|
//! the same prefix. Only the agreed prefix is "committed" — the rest
|
|
//! is a tentative tail the UI renders differently (dashed underline
|
|
//! per brief item #24, workstream-B contract).
|
|
//!
|
|
//! This module ships the committer plus a Token type carrying
|
|
//! timestamps so brief item #25 (aggressive buffer trim tied to commit
|
|
//! points) can compute the absolute sample index of the last
|
|
//! committed token and drain the capture buffer up to that point.
|
|
//!
|
|
//! Integration into `src-tauri/src/commands/live.rs` lands in a
|
|
//! separate commit so the tentative/committed partition can be
|
|
//! validated against real streaming captures.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
/// A single token (word or sub-segment) emitted by the ASR pipeline.
|
|
///
|
|
/// Equality on `Token` is text-only — the committer matches tokens
|
|
/// across passes by their spelling, since timestamps drift slightly
|
|
/// between overlapping Whisper windows. Start and end seconds are
|
|
/// absolute (session-relative) so #25 can translate them to sample
|
|
/// indices.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Token {
|
|
pub text: String,
|
|
pub start_secs: f64,
|
|
pub end_secs: f64,
|
|
}
|
|
|
|
impl PartialEq for Token {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.text == other.text
|
|
}
|
|
}
|
|
|
|
impl Eq for Token {}
|
|
|
|
/// Outcome of pushing a new pass through the committer.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct CommitDecision {
|
|
/// Tokens newly committed by this pass. Empty if no new agreement
|
|
/// was reached. Append to the frontend's committed list.
|
|
pub newly_committed: Vec<Token>,
|
|
/// Tentative tail — tokens past the agreement prefix in the most
|
|
/// recent pass. Replaces (not appends to) any previous tentative.
|
|
pub tentative: Vec<Token>,
|
|
}
|
|
|
|
/// Commit policy selector. Keeping this as an enum leaves room for
|
|
/// future policies (AlignAtt, length-capped, etc.) without a breaking
|
|
/// API change.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CommitPolicy {
|
|
/// LocalAgreement-n: `n` consecutive passes must produce the same
|
|
/// prefix before emission. `n = 2` is the ufal default.
|
|
LocalAgreement { n: usize },
|
|
}
|
|
|
|
impl Default for CommitPolicy {
|
|
fn default() -> Self {
|
|
CommitPolicy::LocalAgreement { n: 2 }
|
|
}
|
|
}
|
|
|
|
/// Stateful LocalAgreement-n committer.
|
|
///
|
|
/// Invariants:
|
|
/// - `history` holds at most `n` most-recent passes.
|
|
/// - `committed_count` counts tokens committed so far; these are
|
|
/// always a prefix of every pass in `history`.
|
|
/// - `last_committed_end_secs` is 0 when nothing is committed,
|
|
/// otherwise the `end_secs` of the most recent committed token.
|
|
pub struct LocalAgreement {
|
|
n: usize,
|
|
history: VecDeque<Vec<Token>>,
|
|
committed_count: usize,
|
|
last_committed_end_secs: f64,
|
|
}
|
|
|
|
impl LocalAgreement {
|
|
pub fn new(n: usize) -> Self {
|
|
assert!(n >= 1, "LocalAgreement-n requires n >= 1");
|
|
Self {
|
|
n,
|
|
history: VecDeque::with_capacity(n),
|
|
committed_count: 0,
|
|
last_committed_end_secs: 0.0,
|
|
}
|
|
}
|
|
|
|
pub fn from_policy(policy: CommitPolicy) -> Self {
|
|
match policy {
|
|
CommitPolicy::LocalAgreement { n } => Self::new(n),
|
|
}
|
|
}
|
|
|
|
/// Feed the next pass of transcribed tokens. Returns newly
|
|
/// committed tokens and the current tentative tail.
|
|
pub fn push(&mut self, pass: Vec<Token>) -> CommitDecision {
|
|
self.history.push_back(pass);
|
|
while self.history.len() > self.n {
|
|
self.history.pop_front();
|
|
}
|
|
|
|
// Can't commit anything until we have n passes in hand.
|
|
if self.history.len() < self.n {
|
|
let tentative = self
|
|
.history
|
|
.back()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
return CommitDecision {
|
|
newly_committed: Vec::new(),
|
|
tentative,
|
|
};
|
|
}
|
|
|
|
let lcp_len = longest_common_prefix_len(&self.history);
|
|
|
|
// The agreed prefix can only grow — never shrink below what we
|
|
// already committed. ufal's invariant: once committed, stay
|
|
// committed.
|
|
let new_committed = lcp_len.max(self.committed_count);
|
|
|
|
let latest = self.history.back().expect("history is non-empty here");
|
|
// Clamp every slice against `latest.len()` — a later pass can
|
|
// legitimately arrive shorter than `committed_count` (Whisper
|
|
// re-transcribing an overlapping window with fewer segments,
|
|
// or user stopping mid-word while the committer holds a longer
|
|
// history). Without the clamp, `latest[committed_count..]`
|
|
// panics with an index OOB.
|
|
let old_committed = self.committed_count;
|
|
let latest_len = latest.len();
|
|
let emit_start = old_committed.min(latest_len);
|
|
let emit_end = new_committed.min(latest_len);
|
|
let newly_committed = if emit_end > emit_start {
|
|
latest[emit_start..emit_end].to_vec()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
if let Some(last) = newly_committed.last() {
|
|
self.last_committed_end_secs = last.end_secs;
|
|
}
|
|
// `committed_count` stays at `new_committed` even when the
|
|
// latest pass is shorter — the non-shrinkage invariant holds
|
|
// relative to what we've already emitted, not to the current
|
|
// pass length.
|
|
self.committed_count = new_committed;
|
|
|
|
let tentative_start = new_committed.min(latest_len);
|
|
let tentative = latest[tentative_start..].to_vec();
|
|
|
|
CommitDecision {
|
|
newly_committed,
|
|
tentative,
|
|
}
|
|
}
|
|
|
|
/// End-of-stream: commit anything still tentative in the latest
|
|
/// pass and return it. Callers do this when the session closes so
|
|
/// the final utterance reaches the transcript.
|
|
pub fn flush(&mut self) -> Vec<Token> {
|
|
let Some(latest) = self.history.back().cloned() else {
|
|
return Vec::new();
|
|
};
|
|
if latest.len() <= self.committed_count {
|
|
return Vec::new();
|
|
}
|
|
let flushed = latest[self.committed_count..].to_vec();
|
|
if let Some(last) = flushed.last() {
|
|
self.last_committed_end_secs = last.end_secs;
|
|
}
|
|
self.committed_count = latest.len();
|
|
flushed
|
|
}
|
|
|
|
/// Absolute (session-relative) seconds at the end of the most
|
|
/// recently committed token. `0.0` when nothing has committed yet.
|
|
/// Brief item #25 will multiply this by the capture sample rate to
|
|
/// get the buffer-drain target.
|
|
pub fn last_committed_end_secs(&self) -> f64 {
|
|
self.last_committed_end_secs
|
|
}
|
|
|
|
/// Drop all state — used after a repetition-detector context
|
|
/// reset (#26) so the committer doesn't carry stale history
|
|
/// across the reset boundary.
|
|
pub fn reset(&mut self) {
|
|
self.history.clear();
|
|
self.committed_count = 0;
|
|
self.last_committed_end_secs = 0.0;
|
|
}
|
|
}
|
|
|
|
fn longest_common_prefix_len(passes: &VecDeque<Vec<Token>>) -> usize {
|
|
let Some(first) = passes.front() else {
|
|
return 0;
|
|
};
|
|
let shortest = passes.iter().map(|p| p.len()).min().unwrap_or(0);
|
|
for i in 0..shortest {
|
|
let candidate = &first[i];
|
|
for pass in passes.iter().skip(1) {
|
|
if pass[i] != *candidate {
|
|
return i;
|
|
}
|
|
}
|
|
}
|
|
shortest
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn tok(text: &str, start: f64, end: f64) -> Token {
|
|
Token {
|
|
text: text.into(),
|
|
start_secs: start,
|
|
end_secs: end,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn first_pass_is_all_tentative() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let decision = la.push(vec![tok("hello", 0.0, 0.5), tok("world", 0.5, 1.0)]);
|
|
assert!(decision.newly_committed.is_empty());
|
|
assert_eq!(decision.tentative.len(), 2);
|
|
assert_eq!(la.last_committed_end_secs(), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn two_matching_passes_commit_common_prefix() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("the", 0.0, 0.3), tok("cat", 0.3, 0.6)]);
|
|
let decision = la.push(vec![
|
|
tok("the", 0.0, 0.3),
|
|
tok("cat", 0.3, 0.6),
|
|
tok("sat", 0.6, 0.9),
|
|
]);
|
|
assert_eq!(decision.newly_committed.len(), 2);
|
|
assert_eq!(decision.newly_committed[0].text, "the");
|
|
assert_eq!(decision.newly_committed[1].text, "cat");
|
|
assert_eq!(decision.tentative.len(), 1);
|
|
assert_eq!(decision.tentative[0].text, "sat");
|
|
assert!((la.last_committed_end_secs() - 0.6).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn divergent_second_pass_commits_nothing() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("hello", 0.0, 0.5)]);
|
|
let decision = la.push(vec![tok("yellow", 0.0, 0.5)]);
|
|
assert!(
|
|
decision.newly_committed.is_empty(),
|
|
"no common prefix — must not commit"
|
|
);
|
|
assert_eq!(decision.tentative.len(), 1);
|
|
assert_eq!(decision.tentative[0].text, "yellow");
|
|
}
|
|
|
|
#[test]
|
|
fn extending_agreement_commits_newly_agreed_tokens() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
|
|
let _ = la.push(vec![
|
|
tok("a", 0.0, 0.1),
|
|
tok("b", 0.1, 0.2),
|
|
tok("c", 0.2, 0.3),
|
|
]);
|
|
// Now history has [[a,b], [a,b,c]], committed = 2 (a, b).
|
|
let decision = la.push(vec![
|
|
tok("a", 0.0, 0.1),
|
|
tok("b", 0.1, 0.2),
|
|
tok("c", 0.2, 0.3),
|
|
tok("d", 0.3, 0.4),
|
|
]);
|
|
assert_eq!(decision.newly_committed.len(), 1, "c becomes committed");
|
|
assert_eq!(decision.newly_committed[0].text, "c");
|
|
assert_eq!(decision.tentative.len(), 1);
|
|
assert_eq!(decision.tentative[0].text, "d");
|
|
}
|
|
|
|
#[test]
|
|
fn tentative_tail_tracks_latest_pass_only() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("x", 0.0, 0.1)]);
|
|
let _ = la.push(vec![tok("x", 0.0, 0.1), tok("y_guess", 0.1, 0.2)]);
|
|
// x is committed, tail is y_guess.
|
|
let decision = la.push(vec![tok("x", 0.0, 0.1), tok("y_real", 0.1, 0.2)]);
|
|
assert!(decision.newly_committed.is_empty());
|
|
assert_eq!(decision.tentative.len(), 1);
|
|
assert_eq!(
|
|
decision.tentative[0].text, "y_real",
|
|
"tentative must reflect the latest pass, not carry stale y_guess"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn committed_prefix_never_shrinks() {
|
|
// Even if a later pass contradicts an earlier commit, the
|
|
// committed prefix stays frozen. This is ufal's invariant.
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("foo", 0.0, 0.3)]);
|
|
let _ = la.push(vec![tok("foo", 0.0, 0.3), tok("bar", 0.3, 0.6)]);
|
|
// "foo" is committed.
|
|
assert_eq!(la.committed_count, 1);
|
|
|
|
let decision = la.push(vec![tok("fop", 0.0, 0.3), tok("baz", 0.3, 0.6)]);
|
|
// LCP with previous pass [foo, bar] is 0 — but we already
|
|
// committed "foo", so committed_count stays at 1.
|
|
assert_eq!(la.committed_count, 1);
|
|
assert!(decision.newly_committed.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn shorter_pass_after_commit_does_not_panic() {
|
|
// Regression: committed_count = 2, then a pass arrives with
|
|
// only 1 token (Whisper re-transcribing an overlapping window
|
|
// that collapses repeated segments, or user stopping mid-
|
|
// utterance). `latest[committed_count..]` would index OOB.
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
|
|
assert_eq!(la.committed_count, 2);
|
|
|
|
let decision = la.push(vec![tok("a", 0.0, 0.1)]);
|
|
// committed_count stays at 2 (non-shrinkage invariant).
|
|
assert_eq!(la.committed_count, 2);
|
|
// No new commit, no tentative (nothing past position 2 in the
|
|
// shorter pass).
|
|
assert!(decision.newly_committed.is_empty());
|
|
assert!(decision.tentative.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_pass_after_commit_does_not_panic() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1)]);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1)]);
|
|
let decision = la.push(vec![]);
|
|
assert_eq!(la.committed_count, 1);
|
|
assert!(decision.newly_committed.is_empty());
|
|
assert!(decision.tentative.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn flush_emits_remaining_tentative() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
|
|
let _ = la.push(vec![
|
|
tok("a", 0.0, 0.1),
|
|
tok("b", 0.1, 0.2),
|
|
tok("c", 0.2, 0.3),
|
|
]);
|
|
// Committed: a, b. Tentative: c.
|
|
let flushed = la.flush();
|
|
assert_eq!(flushed.len(), 1);
|
|
assert_eq!(flushed[0].text, "c");
|
|
assert!((la.last_committed_end_secs() - 0.3).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn flush_with_no_history_is_empty() {
|
|
let mut la = LocalAgreement::new(2);
|
|
assert!(la.flush().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reset_clears_commit_state() {
|
|
let mut la = LocalAgreement::new(2);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1)]);
|
|
let _ = la.push(vec![tok("a", 0.0, 0.1), tok("b", 0.1, 0.2)]);
|
|
la.reset();
|
|
assert_eq!(la.committed_count, 0);
|
|
assert_eq!(la.last_committed_end_secs(), 0.0);
|
|
let decision = la.push(vec![tok("z", 0.0, 0.1)]);
|
|
assert!(decision.newly_committed.is_empty());
|
|
assert_eq!(decision.tentative[0].text, "z");
|
|
}
|
|
|
|
#[test]
|
|
fn n_three_requires_three_matching_passes_to_commit() {
|
|
let mut la = LocalAgreement::new(3);
|
|
let _ = la.push(vec![tok("x", 0.0, 0.1)]);
|
|
let _ = la.push(vec![tok("x", 0.0, 0.1)]);
|
|
// Only 2 passes so far; with n=3 no commit yet.
|
|
let decision = la.push(vec![tok("x", 0.0, 0.1), tok("y", 0.1, 0.2)]);
|
|
assert_eq!(
|
|
decision.newly_committed.len(),
|
|
1,
|
|
"on the 3rd matching pass, x becomes committed"
|
|
);
|
|
assert_eq!(decision.newly_committed[0].text, "x");
|
|
}
|
|
|
|
#[test]
|
|
fn from_policy_default_is_local_agreement_n2() {
|
|
let la = LocalAgreement::from_policy(CommitPolicy::default());
|
|
assert_eq!(la.n, 2);
|
|
}
|
|
}
|