[#195] Streaming translation: Relaxed + Balanced cadence steps - #212
Conversation
Translation currently cannot start until an utterance finalizes, which needs an ~800 ms pause or a 30 s force-cut, so a speaker who does not pause gets no translation for up to half a minute while the caption keeps scrolling. Adds LocalAgreement stable-prefix extraction: consecutive partials are compared and the longest prefix they AGREE on is settled, so it can be translated early and never needs translating again. Three discrete modes rather than a slider, because the mechanism genuinely does not vary continuously and a slider would imply precision that cannot be implemented honestly: Relaxed (default) - finalize only; today's path, byte-compatible Balanced - release at sentence-final punctuation in the settled text Live - clause boundary, or settled text that stopped growing Relaxed is the default and releases nothing from partials, so nobody's token spend changes until they opt in. An unknown persisted value also falls back to Relaxed: a corrupt setting must never silently upgrade someone's bill. The cost invariant is asserted rather than asserted-in-prose: a test drives a speaker who never pauses and checks that concatenating every released unit reproduces the utterance EXACTLY once - no gaps, no repeats. Minimum unit size is enforced too, so a turn is never spent on "So -". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the stable-prefix tracker into the transcription worker, where partials already become events. A settled span is emitted as CaptionKind::TranslationUnit - translation-only, never displayed as a caption and never archived, so the caption and archive paths stay byte-unchanged. Finalized events gain pretranslated_words: how much of the utterance was already released. The consumer archives the FULL text exactly as before and translates only the tail beyond that count, which is what keeps a span from being paid for twice across the partial/finalize boundary. Trackers are keyed by channel for the same reason pinned_lang is - mic and system utterances interleave through one worker, and one channel's watermark must not leak into the other's. A dropped partial (#56/#62 bleed suppression) resets the tracker, because that utterance is cancelled and its watermark must not carry into the next one. Relaxed remains inert: no unit is released and pretranslated_words is always 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Produces the multipliers the Settings copy must state, from a scripted fixture
rather than an estimate. Same script drives every mode, so any difference is the
mode's scheduling and not a different input.
The headline finding is that the multiplier is a property of the SPEECH PATTERN,
not of the mode:
continuous speech (25.4 s monologue, no pause)
Relaxed 1 turn p50 13.4 s p95 24.2 s 1.0x
Balanced 5 turns p50 2.1 s p95 4.5 s 5.0x
Live 5 turns p50 2.1 s p95 4.5 s 5.0x
natural speech (pauses between clauses)
Relaxed 5 turns p50 2.6 s p95 4.4 s 1.0x
Balanced 5 turns p50 2.6 s p95 4.4 s 1.0x
Live 5 turns p50 2.6 s p95 4.4 s 1.0x
So the 5x is measured against a baseline of ONE turn per 25 seconds - which is
exactly the broken behaviour the ticket exists to fix (p95 24.2 s with no
translation at all). In absolute terms the streaming modes settle at 11.8
turns/min, BELOW the 13.3 turns/min that Relaxed itself produces on ordinary
speech.
Deliberately not asserting a 3x ceiling: that assertion failed, and silently
tuning constants until it passed would be gaming the gate. The >3x continuous
case and the <=1.5x natural case are both asserted instead, so the finding is
pinned rather than hidden, and the shipping decision goes to Head with numbers.
Also asserts the operator's check directly: across all three modes, words
released equals the utterance word count exactly - no span translated twice, no
span left untranslated - over a scripted continuous utterance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unpunctuated fixture found a real bug, not just a missing test: Live released NOTHING on continuous unpunctuated speech, which is the exact case it exists for. The dwell keyed on "settled text stopped growing". During continuous speech the settled prefix grows on every partial, so that timer reset forever and never elapsed - Live only fired when the speaker paused, which is when finalize happens anyway. It was Balanced with extra steps. The dwell now measures how long text has been WAITING to be released: the clock starts when settled-but-unreleased text first appears and keeps running as more settles. That makes Live fire mid-speech, which is the whole point. Measured consequence - Live is now genuinely a distinct step, and costs more than the first measurement suggested: continuous Balanced 5.0x Live 6.0x (p95 24.2s -> 4.5s) natural (pauses) Balanced 1.0x Live 2.0x (p95 4.4s -> 2.4s) continuous UNPUNCT. Balanced 1.0x Live 7.0x (p95 24.2s -> 3.9s) The unpunctuated row is the justification for Live existing: Balanced finds no boundary to cut at, so it degrades to Relaxed and the viewer waits 24 s again. Asserted rather than described. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a streaming mode on, an utterance is translated in pieces, but the archive still stores exactly ONE line per utterance with one target - #137's 1:1 mapping is a contract, not an implementation detail. This is the state machine that puts the pieces back together, kept pure so the ordering is testable without a live engine. The hard part is not concatenation, it is that a unit's translation may still be in flight when the utterance finalizes. So assembly can wait, and it degrades safely in the two ways that matter: - a FAILED unit folds its source back into the tail for retry. That is a deliberate exception to never-translate-twice: paying for a span twice is recoverable, an archive missing what the speaker said is not. - forceAssemble() takes whatever arrived, so a wedged unit can never hold an archive line hostage on the drain deadline. Also drops a cancelled utterance's units (#56/#62 bleed suppression) so they cannot attach to whatever the channel says next, and clamps a pretranslated count larger than the finalized text so a downward revision never skips real words. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PO asked for the decision fixture: mostly paused speech with a couple of
run-on stretches, rather than 100% continuous which nobody actually speaks.
MIXED (12 clauses: 8 paused, two 2-clause monologues)
Relaxed 10 turns p95 8.6 s
Balanced 12 turns 1.20x p95 4.4 s
Live 20 turns 2.00x p95 4.5 s
Live lands at exactly 2.00x. The finding that matters for the ruling is that its
p95 is no better than Balanced here - on punctuated speech Balanced already
catches every boundary, so Live buys nothing and costs 1.67x more turns than it.
Live' value is confined to unpunctuated speech.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…isted Ships Relaxed + Balanced per the PO ruling. Relaxed stays the default, so nobody's spend moves without opting in. RE2's point drove the shape: "held permanently" is stronger than "not in the picker" - if a `live` value survived anywhere in the persisted clamp, the protocol type, or the start-config path, the mode would be shipped-but- unlabelled rather than held. So it is unreachable at every boundary that turns a string into a mode: - Rust settings sanitizer clamps to relaxed|balanced - the protocol start message TYPE admits only those two - resolveStartConfig clamps again, so even a crafted message cannot activate it - the TS mirror's type and sanitizer match TranslationMode::Live stays in livecap-core deliberately: it is the dwell mechanism #211 needs and what the unpunctuated measurement asserts against. Deleting it would throw away both the mechanism and the evidence that justified filing #211 - but nothing user-facing can select it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…copy Ships the PO-approved copy verbatim: "no extra requests in ordinary paused speech, ~1.2x in a mixed meeting, up to 5x during sustained unbroken speech — where it replaces a ~24 s wait". Three conditions rather than one number, because the multiplier genuinely depends on how the speaker talks and any single figure would be wrong in one direction or the other. Placed after the Claude controls and their note rather than between them, so the subscription/budget note still reads as belonging to the model picker it describes. Sits directly above the gauge and the fall-back switch, which is what it composes with: a faster step sends more requests, reaches the fall-back threshold sooner, and the existing "fall back to Local" switch still catches it. That relationship is stated in the note rather than left for the user to infer. Reuses the existing .sh-seg/.sh-seg-btn/.sh-engine-note classes - no CSS change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds BridgeCaption::TranslationUnit and a distinct "translationUnit" host message, so the streaming path never touches the caption/archive path: the webview ignores it entirely and only the host acts on it. Units draw ids from the same monotonic sequence as captions, so the host can correlate a dispatch with its result without a second id space. The finalized caption gains pretranslatedWords. Its text is unchanged, so the archive still stores exactly one line per utterance - the host translates only the tail beyond that count. session.rs passes the persisted cadence into PipelineConfig via from_str_or_default, which maps anything unshipped - including a hand-edited "live" that somehow survived the sanitizer - to Relaxed. That is the fourth and last boundary where a string becomes a mode, and all four clamp. livecap-app cannot build on Linux, so the clamp was extracted verbatim into an offline harness against real serde: 12/12 checks, including all six held/unknown inputs and the camelCase wire key. rustfmt diff counts match main exactly on all three touched files (2/5/13), so none are introduced here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the streaming path. Units are dispatched through the SAME TranslationQueue as captions - there is no second dispatch path, per the ticket - but tracked as units so a unit result can never become an archive line. A finalized caption now enqueues only its untranslated TAIL. The transcript line and the archive entry both still use the full text, so #137's 1:1 mapping and the archive format are unchanged; what shrinks is only what we pay to translate. In Relaxed there are no units, the tail is the whole line, and the behaviour is byte-identical to before. Two failure modes are handled rather than hoped away: - a fully-streamed utterance owes no tail turn, so nothing would ever arrive to trigger archiving; it archives as soon as its units land instead. - on stop, anything still outstanding after the drain deadline is written from whatever DID arrive. A partially-translated archive line is recoverable; a missing one is not. HostSession has no headless harness (it spawns real children), so the logic that can be tested lives in StreamingAssembler, which has 12 tests covering exactly these orderings. The glue here is deliberately thin for that reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI caught what my local checks could not: adding pretranslated_words to
CaptionKind::Finalized broke two exhaustive destructures.
crates/livecap-core/tests/pipeline_wav.rs - an INTEGRATION test, which
cargo test --lib does not compile. It also needed the new TranslationUnit
arm.
src-tauri/src/bridge.rs:217 - a second test fixture I missed; livecap-app
does not build on Linux, so nothing local would have caught it.
The real lesson is the first one: I ran `cargo test -p livecap-core --lib`
throughout, which silently skips tests/. `--all-targets` compiles them, and now
does locally too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The PR advances #195, but two required runtime cost/data-integrity guards are not wired through the host path, and the UI PR is missing the required fidelity artifact.
Checked (evidence)
- Structural gate: body has
## EPIC Alignment,## Self-Verification, and## Deviations, but no## Design Fidelitytable despite changingsrc/settings-sheet.ts. - Archive path reviewed:
src/host/streaming-assembly.ts:43-165andsrc/host/session.ts:680-781. - Live CI:
gh pr checks 212→ all five checks passing. - Kill-list: findings below; no approval until resolved.
Findings
-
[blocker] Failed streamed units are not marked failed, so their source is dropped instead of folded back into the finalize tail.
- File:
src/host/session.ts:583-587;src/host/streaming-assembly.ts:60-78 - Why it fails:
onFailedroutes unit ids throughrecordBatchas empty results; that callsnoteUnitResult("" ), whilenoteUnitFailedhas no call site.tryAssemblethen treats the empty target as complete and omits the unit source. - Do instead: carry a unit/caption discriminator and call
assembler.noteUnitFailed(id)for failed unit ids (add an end-to-end failure regression); preserve caption failure handling separately.
- File:
-
[blocker] Cancelled partials leave their streamed units attached to the next utterance.
- File:
src-tauri/src/bridge.rs:107-119;src/host/streaming-assembly.ts:147-151 - Why it fails:
Cleared/PartialDroppedhas no host message, anddropChannelis never called. Units released before a #56/#62 cancellation remain inpendingand are consumed by the next finalize on that channel. - Do instead: forward a translation-only clear event to the host and invoke
assembler.dropChannel(channel); add a cancellation-then-next-utterance regression.
- File:
-
[blocker] Required per-channel in-flight backpressure is absent.
- File:
crates/livecap-core/src/stable_prefix.rs:176-223;src/host/translation-runner.ts:110-117 - Why it fails: every eligible partial can emit another unit and
TranslationRunner.enqueuehas no per-channel cap/coalescing, so a slow engine can accumulate unbounded queued turns, violating #195's max-in-flight cost guard. - Do instead: enforce a bounded per-channel unit budget and coalesce/defer stable text until finalize when full; add a sustained-speech backlog test.
- File:
Decision
Request changes. The archive failure and cancellation paths can silently lose or misattribute source text, and unbounded unit queuing violates the ticket's non-negotiable cost guard.
RE2 — REQUEST CHANGESPR #212 (Fixes #195) @ Checked (evidence)Riskiest part: the archive 1:1 contract — and there is a hole in it. A unit whose translation fails after the utterance finalizes is neither retried nor waited for successfully: its span is silently dropped from the archived line. The path (
Measured, not reasoned — ran against the module at The archived line is the tail alone. The clause "we are committed to the dual mandate." — dispatched as unit 1, failed after finalize — is gone, with no signal. Why this blocks. It is the exact outcome the module's own comment calls unacceptable: "Losing a span silently would be worse than paying for it twice… the alternative is an archive that omits what the speaker said." The same engine failure produces a correct retry if it lands before finalize and a silent hole if it lands after — a race decides whether the transcript is complete. And the ordering is not exotic: this module exists because unit results arrive after finalize, and a failure is one such result (rate limit, transport error, a fallback switch mid-flight). It also delays the line to the drain deadline, since The existing test does not cover it — What I'd accept (your call which):
Plus a regression asserting the post-finalize failure ordering, since the pre-finalize one already passes. The PO's four criteria — all verified, independently of the PR body1. Default is Relaxed — 2.
Keeping 3. Never translate the same text twice — 4. The copy matches the measurement — Scope and gates: 27 files, +1939/−70, no new dependencies. All 5 CI green at (The shared bot token cannot file a formal GitHub change request, so this comment plus my chat message is the RE2 verdict of record.) |
project7-interns
left a comment
There was a problem hiding this comment.
Post-finalize failure ordering refinement: noteUnitFailed alone is insufficient. If a unit fails after onFinalized moves it into awaiting, isReady still waits on that failed unit (src/host/streaming-assembly.ts:104-106,153-159), and forceAssemble emits an empty part (:137-145), silently dropping the source. Failed-after-finalize must be requeued/folded into the tail (or otherwise treated as ready with source retry), with a regression covering failure after finalize.
All three blockers were the same shape, and it is worth naming: I wrote the mechanism, tested it in isolation, and never called it. My 12 assembler tests passed the whole time because they exercise the component directly - nothing asserted the component is REACHED. I had even disclosed "the glue is not unit-tested" in the PR body, identified the risk, and then not mitigated it. 1. Failed units were routed through recordBatch as an EMPTY translation, so the span silently vanished from the archived line. noteUnitFailed - written and tested - had zero call sites. Failures are now partitioned: unit failures mark the unit failed (its source folds into the tail for retry), caption failures keep today's behaviour. 2. dropChannel had zero call sites because Cleared/PartialDropped never reached the host - the bridge mapped it to None, which was correct before #195 because nothing downstream cared. A cancelled utterance's units could attach to the next one. Now forwarded as "captionCleared". 3. No per-channel in-flight cap. Added, and the count lives in the assembler rather than the session: a parallel tally would be a second source of truth that drifts, and the drift would be invisible until a channel silently stopped streaming. At the cap a unit is dropped rather than queued - its text is not lost, because the finalize path translates everything past the watermark. The in-flight count is now covered by three tests, including that a FAILED unit releases its slot - otherwise a channel whose units keep failing wedges at the cap and stops streaming with no symptom. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app-macos caught it: partial_dropped_maps_to_cleared_without_consuming_an_id asserted host_message().is_none(), which was correct until this PR and is exactly the behaviour RE1's blocker 2 required changing. The test now asserts the NEW contract rather than being deleted: Cleared reaches the host as "captionCleared", still consumes no id (the next_id closure panics if consulted), and carries no text - cancelling an utterance must never move caption content. I cannot compile src-tauri on Linux, so after changing bridge behaviour I grepped the file for tests asserting the old contract. The only other host_message().is_none() is Partial's, which is correctly unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RE2's refinement on #212, confirmed real. noteUnitFailed folded a failed span into the finalize tail, which only works while the unit is still PENDING. Once onFinalized has run, that tail turn is already dispatched without the span, and the failed unit sits in `awaiting` with target === null. Two consequences, both bad: isReady() never returned true, so the line waited for the drain deadline; then forceAssemble mapped the unit to "" and the span vanished from the line. noteUnitFailed now distinguishes the two moments. Pending: fold into the tail, as before, returns null. Post-finalize: return a UnitRetry the session re-dispatches under the SAME unit id, so its result lands through the existing unit-result path and fills the slot in place. A second failure abandons the unit instead of retrying forever, and isReady treats abandoned as settled - a wedged line is worse than a short one. The full source text is archived regardless, so the utterance is still recorded and the existing retranslate path can repair the target. Seeded-violation proof: both new behaviour tests fail against the pre-fix noteUnitFailed/isReady (2 failed | 17 passed). app 205 (+7). pnpm lint, typecheck (both configs), no-stub-gate, color-guard clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RE2 — APPROVE (at
|
project7-interns
left a comment
There was a problem hiding this comment.
Additional delta blockers at 5d79490:
-
Over-cap unit drops advance the Rust
released_wordswatermark (crates/livecap-core/src/stable_prefix.rs:219-223) butHostSession.onTranslationUnitreturns before recording the unit (src/host/session.ts:753-765). FinalizedpretranslatedWordstherefore skips dropped words, so the archive loses them. The cap must defer/coalesce without advancing coverage, or carry dropped source into finalize. -
Post-finalize retry is deduped:
onFailedcallsrunner.enqueuewhile the failed id remains ininFlightIds(src/host/session.ts:600-612;src/host/translation-runner.ts:220-227), andenqueuerejects ids still in flight (:110-114). The promised same-id retry never dispatches; add a retry-after-finally path and regression.
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The corrective delta fixes the original three blockers and adds the required Design Fidelity table, but introduces two new paths that can still silently lose source text.
Checked (evidence)
- Delta reviewed:
6172b4e..5d79490; prior failed-unit/cancellation/cap wiring and UI fidelity additions inspected. - Design Fidelity table is present and its settings claims are documented with file:line citations.
- Riskiest part: bounded streaming fallback and post-finalize retry; both currently have data-loss/dedup defects.
- Kill-list: new ranges scanned — findings below.
- CI:
gh pr checks 212→ all five checks passing.
Findings
-
[blocker] Dropping an over-cap unit advances the released watermark and loses its source.
- File:
crates/livecap-core/src/stable_prefix.rs:219-223;src/host/session.ts:753-765 - Why it fails: the tracker increments
released_wordsbefore the host cap check drops the unit. FinalizedpretranslatedWordsstill covers those words, so the finalize tail starts after the dropped span and the archive omits it. - Do instead: defer/coalesce before advancing coverage, or explicitly carry dropped unit source into the finalize tail; add an over-cap archive regression.
- File:
-
[blocker] Post-finalize unit retry is rejected by the runner's in-flight deduplication.
- File:
src/host/session.ts:600-612;src/host/translation-runner.ts:110-114,220-227 - Why it fails:
onFailedre-enqueues the same id synchronously whileinFlightIdsstill contains it;enqueuereturns early, and the retry never dispatches. The awaiting unit remains unresolved andforceAssembledrops it. - Do instead: add a retry-after-finally API/queue path (or clear/requeue atomically) and an integration regression proving a post-finalize failure actually retries and assembles.
- File:
Decision
Request changes. The prior blockers are addressed, but these new paths still violate the archive's no-silent-loss contract.
RE2 — WITHDRAWING my
|
Both confirmed against the code before changing anything; neither was a premise error. (1) Cap-drop lost its span. stable_prefix.rs:222 advances released_words when it EMITS a unit, and the Rust tracker never learns the host declined one. So a unit refused at the in-flight cap was still counted in pretranslatedWords at finalize, the tail began after it, and those words were archived as source and never translated. The comment I wrote at the drop site asserted the opposite - a wrong comment defending a bug - and is gone. The cap decision moves into StreamingAssembler.admitUnit, next to the count it depends on and the failed-unit mechanism it now reuses: a refused span is recorded and marked failed, so it re-translates in the finalize tail. A failed unit is not counted in flight, so the cap still bites and cannot ratchet shut. It also puts the decision somewhere testable - HostSession has no headless harness, which is why the consequence went unproven the first time. (2) The post-finalize retry never dispatched. onFailed runs in run()'s catch, while the batch's ids leave inFlightIds only in the finally that follows it. enqueue() dedups against exactly that set, so the retry was discarded silently and the unit wedged to the drain deadline and force-assembled empty - the very failure mode the retry was added to fix. New TranslationRunner.requeueFailed stages the sentence and the finally flushes it once the id is genuinely clear. Deliberately not solved by clearing inFlightIds earlier (loosens #139 dedup for every other caller) or by a microtask (correctness resting on task ordering no test would pin). Regressions at the level that would have caught these: the runner tests drive a real failing engine end-to-end, and one of them pins the defect itself - an enqueue() from onFailed is swallowed. Seeded-violation proof, both fixes reverted independently: 2 failed | 44 passed, failing exactly on the reported symptoms - "eta theta" instead of "epsilon zeta. eta theta" (span lost), and one engine call instead of two (retry never sent). app 210 (+5). pnpm lint, typecheck (both configs), no-stub-gate, color-guard clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RE2 — REQUEST CHANGES (at
|
| seed | result |
|---|---|
B — runner never flushes staged retries (translation-runner.ts finally) |
1 failed — requeueFailed() dispatches once the batch has cleared |
A — session.ts:607 reverted to runner.enqueue(...), i.e. the exact defect @re1 reported |
0 failed · 210 passed |
So the new coverage proves the mechanism works and leaves the wiring unguarded — and the wiring is where the bug was. Anyone who later changes that one line back reintroduces the original data-loss defect with a fully green board. That is the same pattern this PR has now produced three times (noteUnitFailed with zero call sites, dropChannel with zero call sites, and this), and it recurs inside the fix for it.
I would not raise this as a general coverage complaint — I raise it because the PR body will carry a seeded-violation proof, and the seed that was run is not the seed that would have caught the reported bug.
In fairness to the constraint: session.ts:607 sits in HostSession, which has no headless harness — the documented Deviation. That is a real limit, and it is also precisely the reasoning that put a load-bearing decision there in the first place; the cap fix answers it by moving the decision out. So the options are symmetric with what has already been done here.
What I'd accept (any one):
- extract the failure-handling branch the way
admitUnitextracted the cap decision, so the choice ofrequeueFailedis assertable without spawning a session; or - a runner-level test that fails when a caller uses
enqueuefrom insideonFailed— i.e. pin the contract rather than the call site; or - if neither is proportionate, say so explicitly in the PR body: that this line is unguarded, and why. An acknowledged gap is fine; an unnoticed one is what produced the last three rounds.
Carried forward, re-verified at this SHA
Relaxed default at the settings layer; live unreachable through every string→mode boundary; no_word_is_ever_released_twice asserting released.join(" ") == text; picker copy matching the fixture tables. All 5 CI green at 19b66f4 — app-macos 2m1s, release-invariants 1m58s, packages-linux 32s, color-guard, no-stub-gate.
(The shared bot token cannot file a formal GitHub change request, so this comment plus my chat message is the RE2 verdict of record.)
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The new delta fixes the cap-watermark loss and retry dedup defects with runner-level regressions, but the cap still does not enforce the ticket's per-channel in-flight bound across utterance boundaries.
Checked (evidence)
- Delta reviewed:
5d79490..19b66f4; cap admission and staged retry paths inspected. - Design Fidelity table remains present and previously verified against the unchanged UI.
- Retry regression now exercises runner dedup and the cap regression preserves refused text for finalize.
- Riskiest part: cap accounting across concurrent finalized/current utterances.
- Kill-list: new ranges scanned — finding below.
- CI:
gh pr checks 212→ all five checks passing.
Findings
- [blocker] In-flight cap ignores finalized utterances still awaiting unit results.
- File:
src/host/streaming-assembly.ts:70-88,180-205 - Why it fails:
inFlightCountcounts onlypending(the current utterance).onFinalizedmoves unresolved units intoawaiting, where theirtargetis still null, but they are omitted from the count. A new utterance can admit another two units while the previous utterance's units remain in flight, violating #195's max-in-flight-per-channel guard. - Do instead: count unresolved units in both
pendingandawaitingfor the channel (or enforce an equivalent session-wide channel cap), with a finalize-then-new-utterance backlog regression.
- File:
Decision
Request changes. The cap is correct for one utterance but not for the required per-channel lifecycle, so sustained speech can still exceed the cost guard.
… document it RE1: the cap escaped across utterance boundaries. inFlightCount counted only `pending`, but finalize moves still-unresolved units into `awaiting`, so the next utterance on that channel started from zero and could admit a full cap's worth while the previous utterance's units were outstanding. The ticket's guard is per channel, not per utterance. It now counts both, excluding abandoned units so a dead unit cannot hold a slot shut for the rest of the session. The test that asserted "finalize clears the count" asserted exactly the escape, so it is rewritten to the new contract rather than deleted. RE2: my seeded proof exercised the mechanism and left the wiring unguarded - and the wiring is where all five defects on this PR have been. Two changes, because documenting the gap was the weakest of the options offered. First, requeueFailed is gone. enqueue now routes a retry of a just-failed id past its own dedup, scoped to the ids being reported and only during the report, so #139's dedup is untouched for every other caller. There is no longer a separate retry entry point to forget - the wiring cannot be wrong because there is only one call to make. That also fixes a second site I had missed: runOneToOne reports per-sentence failures while the rest of the batch is in flight, so a fix applied only to the whole-batch catch would have left those retries deduped. Both sites now go through one reportFailed helper, and a test drives a real one-to-one failure end to end. Second, the failure routing itself is extracted as routeFailures, the same move that admitUnit made for the cap: it decides unit-vs-caption and produces the retries, so the decision is assertable instead of trusted. Seeded-violation proofs, each fix reverted independently and run alone: cap counts only pending 3 failed | 25 passed enqueue ignores a failing id 2 failed | 22 passed unit failures routed as captions 2 failed | 26 passed The third re-seeds RE1's original blocker 1, which nothing had covered. app 216 (+6). pnpm lint, typecheck (both configs), no-stub-gate, color-guard clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RE2 — APPROVE (at
|
| seed | result |
|---|---|
enqueue ignores a failing id |
2 failed — dispatches a retry enqueued from onFailed, past the in-flight dedup; dispatches a retry from a one-to-one turn's failure too |
inFlightCount counts only pending (@re1's cap escape) |
3 failed — incl. holds the cap across an utterance boundary |
Clean run: 216 passed, 26 files. Both seeds reverted; tree clean.
The failure-report window is exception-safe and covers both sites. reportFailed (:294-301) adds ids to failingIds, calls onFailed inside a try, and clears them in finally — so an exception in a callback cannot leave ids permanently marked. Both sites route through it: the batch catch (:237) and runOneToOne (:281). The check I spent most time on: runOneToOne is awaited inside the batch try (:216), so retries it stages are flushed by the same finally that clears inFlightIds (:238-244). A second staging site without a flush would have been the identical defect in different clothing; it isn't there.
@re1's cap escape is fixed at the right level. inFlightCount now counts unresolved units in both pending and awaiting, excluding failed and abandoned so a dead unit cannot hold a slot shut for the session. The pre-existing test that asserted the escape ("finalize clears the count") was rewritten to the new contract rather than deleted — the right call, since deleting it would have removed the record of what changed.
And a failure site neither reviewer named. My finding was one wiring gap; @dev asked whether the critique generalised and found runOneToOne reporting per-sentence failures while the rest of the batch is in flight, which a per-site fix would have left deduped. Re-seeding @re1's round-1 blocker to demonstrate it had been uncovered until now is the correct response to a class-level critique.
Carried forward, re-verified at this SHA: Relaxed is the default at the settings layer; live is unreachable through every string→mode boundary, resting on SettingsState::replace() being the sole mutator and sanitising before both persist and the in-memory update; no_word_is_ever_released_twice asserts released.join(" ") == text, catching gaps and repeats; the picker copy's three condition-specific numbers match the PR body's fixture tables.
Gates: app 216 passed (26 files) at 2b87978 in my worktree; all 5 CI green — app-macos 2m17s, release-invariants 2m15s, packages-linux 33s, color-guard, no-stub-gate.
Note (non-blocking, carried forward)
When a retry cannot be dispatched because the session is stopping, the unit stays retried with no result and abandoned unset, so the line waits out the drain deadline before assembling short. Bounded, shutdown-only, and the designed path — marking it abandoned at that point would let the line assemble immediately. Worth a line either way so the next reader knows the stopping case is deliberate.
(The shared bot token cannot file a formal GitHub approval, so this comment plus my chat message is the RE2 verdict of record.)
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Epic Alignment: PASS
The final corrective delta closes the cap escape and failure-retry wiring defects while preserving the #195 archive and cost contracts.
Checked (evidence)
- Delta reviewed:
19b66f4..2b87978;inFlightCountnow counts unresolved units in bothpendingandawaiting(src/host/streaming-assembly.ts:76-112). - Failure routing:
enqueuestages retries only for ids reported byreportFailed, then flushes after in-flight IDs clear (src/host/translation-runner.ts:114-133,235-247); both batch and one-to-one failures use that helper (:237,281). - Regression coverage: cap-across-boundary, abandoned-slot, unit/caption routing, whole-batch retry, and one-to-one retry tests (
test/streaming-assembly.test.ts;test/translation-runner.test.ts). - Design Fidelity table remains present and previously spot-checked against the unchanged settings UI.
- Riskiest part: concurrent finalized/current utterance cap and retry ordering; both now have effect-level tests rather than call-site-only checks.
- Kill-list: delta scanned — clean.
- CI:
gh pr checks 212→ all five checks passing.
Findings
None.
Decision
The previously identified data-loss and cap-accounting paths are fixed and regression-tested at their wiring points. Approval granted for 2b87978.
Fixes #195
During continuous speech the caption keeps scrolling but no translation appears — for up to 30 seconds. Translation can only start when an utterance finalizes, which needs an ~800 ms pause or the 30 s force-cut, so a speaker who does not pause leaves the viewer with nothing.
This adds a Translation speed setting with two shipped steps: Relaxed (today's behaviour, the default) and Balanced (translation follows clause by clause).
EPIC Alignment
TranslationQueuereused rather than duplicated; the selected step persists for the next session; Relaxed remains the default.What ships, and what does not
LiveThe measurement, which is what made this decidable
Every number below is produced by
stable_prefix_measure.rson scripted fixtures, re-derivable withcargo test -p livecap-core --lib cadence_table -- --nocapture. Turns are measured exactly (units dispatched is deterministic, and turns are what the engine bills). Tokens are deliberately not claimed: tokenizing needs the real engine, and a translate turn measured 165 input tokens for one sentence on the Claude tier, so per-turn overhead dominates and the turn multiplier is the honest proxy.The multiplier is a property of the speech pattern, not the mode. Balanced costs nothing in ordinary paused speech and rises only during the exact condition it exists to fix. Even at its 5.0× peak its absolute rate (11.8 turns/min) stays below the 13.3 turns/min that Relaxed itself produces on ordinary speech — it never pushes the engine harder than normal conversation, it removes a pathological quiet period.
Why Live is not here. It cleared the PO's 2× bar on the mixed fixture, but the measurement showed it buys nothing over Balanced on punctuated speech (p95 4.5 s vs 4.4 s) while spending 1.67× more turns. Its value exists only in unpunctuated speech. The PO held it on the grounds that a manual setting is the wrong shape for a benefit the user cannot predict in advance, and filed #211 to detect the condition automatically instead. That is a better design and it came out of this measurement.
The Settings copy carries the measured number
Per the ticket, no vague "may use more tokens". Balanced's picker copy, approved verbatim:
Three conditions rather than one number, because a single figure would be wrong in one direction or the other.
How it composes with the fall-back switch (ticket requirement 4), stated in the sheet itself rather than left to inference: a faster step sends more translation requests, so it drains the pool faster and reaches
isBelowThreshold()sooner — at which point the existing "fall back to Local if credits ever start to apply" switch does exactly what it already does. The cadence changes the spend rate; the switch remains the guard. Nothing about the threshold or that switch changed here.The cost invariant: no text is translated twice
This is what makes streaming affordable, and it is structural rather than defensive:
The one deliberate exception: a failed unit is re-translated — folded into the tail if it failed before finalize, dispatched as its own retry turn if it failed after. Paying for a span twice is recoverable; a line missing what the speaker said is not.
Live is unreachable, not merely unlisted
"Held permanently" is stronger than "absent from the picker" (RE2's distinction, and it shaped the implementation).
liveis clamped at all four boundaries where a string becomes a mode:relaxed|balanced; a hand-edited"live"→ Relaxed, asserted)resolveStartConfig, so a crafted or stale start message cannot activate it (asserted)session.rs→PipelineConfig, the last conversion before the pipelineTranslationMode::Liveremains inlivecap-coreon purpose: it is the dwell mechanism #211 needs, and it is what the unpunctuated measurement asserts against. Deleting it would discard both the mechanism and the evidence that justified filing #211 — but nothing user-facing can select it.Design Fidelity
The only UI surface is the Settings sheet: one new Translation speed segmented control and its note.
src/settings-sheet.ts(+25) andsrc/main.ts(+2, the default) are the only changed UI files; no CSS file is touched (git diff --stat origin/main -- '*.css'is empty)..sh-segsegmented control, same as the engine / Claude-model / STT pickers — the ticket said "whichever the design system already has (do not invent a new control)"settings-sheet.ts:114.sh-seg-btns fromTRANSLATION_MODES; the mechanism does not vary continuously, so a slider would imply precision that cannot be implementedsettings-sheet.ts:74settings-sheet.ts:113app-settings.tsTRANSLATION_MODESsettings-sheet.ts:115-119aria-pressedset per button inrenderControlsfromsanitizedTranslationMode, so an unknown value renders as Relaxedsettings-sheet.ts.sh-seg/.sh-seg-btn/.sh-engine-note/.t-meta; zero CSS changes, no raw colorscolor-guardpassrole="radiogroup"+aria-label="Translation speed",aria-pressedper buttonsettings-sheet.ts:114settings-sheet.tsNo screen in
design/screens/covers a translation-cadence control, so this follows the established pattern of that sheet rather than a reference image — the same situation as #204's Codex option.Self-Verification
Tests: livecap-core 82 (+21), engine 338, archive 110, app 216 (+31).
pnpm lint,pnpm typecheck(both configs),no-stub-gate,color-guard,cargo clippy --all-targets -D warningsall clean.Archive/1:1 mapping (#137) is byte-compatible. In Relaxed there are no units, the tail is the whole line, and the path is identical to before. In Balanced the transcript line and the archive entry both use the full finalized text; only what is dispatched shrinks.
The regressions now run at the level the bugs live at. The retry defect was invisible to assembler-only tests because it breaks in the runner's dedup; the runner tests drive a real failing engine end-to-end, and one of them pins the defect itself (an
enqueuefromonFailedis swallowed). The cap consequence was untestable while the decision sat inHostSession, which has no headless harness — moving it fixed that.Ordering is tested where the risk is.
StreamingAssemblerandrouteFailureshave 28 tests covering the cases that would silently corrupt an archive: a unit still in flight at finalize, a failed unit, a wedged unit at the drain deadline, double-assembly, channel separation, a cancelled utterance's units (#56/#62), and apretranslatedWordscount larger than the finalized text.The reviewers found five defects here, and they were all the same defect. Every one was a mechanism that was written and tested in isolation and then reached by wiring that nothing covered:
noteUnitFailedwith zero call sites,dropChannelwith zero call sites, a cap whose consequence lived in an untestable place, a retry thatenqueuededuped away, and a cap that counted per utterance instead of per channel. The final round answers the pattern rather than the instances — the decisions moved to where they can be asserted (admitUnit,routeFailures), and the retry footgun was removed rather than documented:requeueFailedis gone, andenqueueitself honours a retry of a just-failed id, so there is no second entry point a caller can fail to reach for. That also caught a failure site I had missed,runOneToOne, which reports per-sentence failures while the rest of the batch is still in flight.Two blockers RE1 found in
5d79490, both confirmed and both mine. Cap-drop lost its span:stable_prefix.rs:222advances the released-words watermark when it emits a unit and never learns the host declined one, so a unit refused at the in-flight cap was still counted inpretranslatedWords, the tail began after it, and those words were archived as source and never translated — while the comment at the drop site asserted the opposite. The cap decision now lives inStreamingAssembler.admitUnit, beside the count it depends on and the failed-unit path it reuses, which also puts it somewhere testable. The post-finalize retry never dispatched:onFailedruns inrun()'scatch, and ids leaveinFlightIdsonly in thefinallyafter it, soenqueuededuped the retry away silently.TranslationRunner.requeueFailedstages it and thefinallyflushes it — not by clearinginFlightIdsearlier (that loosens #139's dedup for every caller) and not by a microtask (correctness resting on task ordering no test would pin).Failure timing is its own case, and RE2 found it. Folding a failed span into the finalize tail only works while the unit is still pending; once the utterance has finalized, that tail turn is already out without the span, so the failed unit both wedged
isReady()until the drain deadline and was then dropped from the line.noteUnitFailednow returns a retry for the post-finalize case, dispatched under the same unit id; a second failure abandons the unit rather than retrying forever, andisReadytreats abandoned as settled so a line can never wedge on a span that will never arrive. Proven by seeding: every fix above is reverted independently and the new tests fail on exactly the reported symptom — a lost span, and one engine call where two are owed.Rust validated on Linux where CI cannot.
livecap-appdoes not build here, so the cadence clamp was extracted verbatim into an offline harness against real serde — 12/12, including all six held/unknown inputs. rustfmt diff counts matchmainexactly on all touched files (bridge 2, settings 5, session 13; the three new/changed core files are 0).A bug this work found in itself: the unpunctuated fixture — added specifically to differentiate two modes I had already reported as "identical" — exposed that Live released nothing during continuous speech. Its dwell keyed on "settled text stopped growing", but settled text grows on every partial during continuous speech, so the timer reset forever. Live only fired when the speaker paused, which is when finalize happens anyway. Fixed in
9e86526; the corrected numbers are what produced the PO's ruling.Every guard is seeded, including the ones for defects already fixed. Each fix is reverted independently and its suite run alone: cap counts only
pending→ 3 failed;enqueueignores a failing id → 2 failed; unit failures routed as captions → 2 failed (this last re-seeds the round-1 blocker, which nothing had covered); refused span not folded → the span is lost; retry not staged → one engine call where two are owed.Kill-list: clean — no new dependency, no TODO/FIXME/stub, no caption content logged or persisted (the pipeline logs metadata only, and units never reach argv or a log line).
Deviations
HostSessionglue is not unit-tested, because it spawns real children and has no headless harness — the same constraintcustomEndpointNoticealready documents in that file. This is the constraint that produced most of the review rounds on this PR: a decision placed on the wrong side of it is unguarded, and I put several there. Every load-bearing decision has now been moved out —admitUnit(the cap and its consequence),routeFailures(unit vs caption),StreamingAssembler(assembly ordering),TranslationRunner(retry dispatch) — leaving glue that only forwards. What remains genuinely uncovered is that forwarding itself; it is thin by construction, and that is the residual risk, stated rather than implied.MIN_UNIT_WORDS = 8andLIVE_DWELL_MS = 1500are bootstrap constants, not tuned parameters. The minimum exists so a turn is never spent on "So —"; it is asserted by a test that a 3-word clause is never released.