Commit Graph

2 Commits

Author SHA1 Message Date
4455e4d1b1 fix(A.3 #24): clamp LocalAgreement slices against latest.len() to prevent panic on shrinking passes
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)
2026-04-22 07:59:27 +01:00
da2340325f feat(A.3 #24): LocalAgreement-n commit policy
New streaming::commit_policy module implementing ufal's
LocalAgreement-n pattern: tokens emitted by the streaming ASR
pipeline stay tentative until N consecutive passes produce the same
prefix, at which point the agreed prefix commits.

Types:
- Token: text + absolute start/end seconds. PartialEq is text-only so
  identical words from overlapping Whisper windows compare equal even
  when their timestamps drift.
- CommitDecision { newly_committed, tentative }: the partition fed
  back to the live-session worker after each pass.
- CommitPolicy enum with LocalAgreement { n }. Default is n=2 (ufal).
- LocalAgreement: stateful committer with push/flush/reset and a
  last_committed_end_secs accessor. Brief item #25 uses the latter
  to compute the sample-index drain target for aggressive buffer trim.

Invariants exercised by tests:
- first pass is all tentative (need 2 passes to commit under n=2)
- two matching passes commit their common prefix
- divergent second pass commits nothing
- extending agreement commits only the newly-agreed tokens
- tentative tail tracks latest pass only (no stale guesses)
- committed prefix never shrinks, even if later passes contradict
- flush emits any tentative-but-not-committed tail at session end
- flush on empty history is a no-op
- reset clears all commit state
- n=3 requires three matching passes before anything commits
- CommitPolicy::default() is LocalAgreement { n: 2 }

Integration into src-tauri/src/commands/live.rs deferred — the
tentative/committed split needs the B-side 'tentative: bool' field on
LiveResultMessage.segments (workstream-B #24 UI contract) and
validation against real streaming captures before going live.
2026-04-22 07:50:18 +01:00