[#211] Escalate Balanced automatically on unpunctuated stretches - #215
Conversation
Balanced releases only at clause boundaries, so a speaker who does not punctuate got nothing from it at all - measured on #195 as 1.0x cost and a 24.2s p95, identical to Relaxed. This makes Balanced notice that condition and fall back to dwell releases for the duration of that stretch, then stop when punctuation returns. No new mode, no UI change, no setting: Live stays held and unreachable. The escalation trigger is ABSENCE OF PUNCTUATION, not speed. That is what keeps the cost where the ticket requires it: the mixed fixture's monologues are punctuated, so boundaries keep arriving and escalation never fires there. fixture Balanced before -> after natural 1.0x p95 4.4s -> unchanged continuous 5.0x p95 4.5s -> unchanged mixed 1.2x p95 4.4s -> unchanged (the ticket's target) unpunctuated 1.0x p95 24.2s -> 5.0x p95 9.3s So escalation charges only where Balanced previously bought nothing, and stays cheaper than Live there too (5 turns vs 7). ESCALATE_AFTER_MS is derived, not picked. RE2 caught the design error that would have sunk this: the wait clock starts partway through EVERY clause, so the floor is the inter-boundary interval, not LIVE_DWELL_MS. Measured directly by clause_release_gaps - worst in-stretch gap 5400ms across the punctuated fixtures - and the constant sits at 8000ms, ~48% above it. My first harness measured 14600ms because it never finalized, folding a speaker's pause into a "gap"; it now mirrors the pipeline's per-utterance reset. Hysteresis is structural. Escalation triggers on elapsed time, de-escalation on an observed boundary - different KINDS of condition, so the minimum flip period is ESCALATE_AFTER_MS by construction. The test asserts that property by probing the whole window rather than counting flips on a fixture. Two tests asserted the old behaviour and are rewritten, not deleted: balanced_never_releases_text_without_punctuation (probed at t=60s) and live_is_the_only_mode_that_helps_unpunctuated_speech. Seeded-violation proofs, each reverted independently: ESCALATE_AFTER_MS = 1600 (just over the dwell) 3 failed - and the mixed fixture reads 20 turns / 2.0x, byte-identical to Live: exactly the outcome Head said to report rather than ship, reached exactly as RE2 predicted de-escalation removed 2 failed escalation gate removed (Balanced always dwells) 6 failed The constant's other two bounds are compile-time assertions, so a later retune cannot get past the compiler; verified by setting it to 1400 (build fails). livecap-core 88 (+6). clippy --all-targets -D warnings clean, rustfmt parity with main restored (0 diffs on both files), no-stub-gate and color-guard pass. Two files touched, both in livecap-core: no host, protocol, settings, or UI change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RE2 — APPROVEPR #215 (#211) @ Checked (evidence)Riskiest part: Numerically identical to Live — same turns, same p50, same p95. And three tests fail, including the measured guard That is the decisive result: the compile-time assertion alone does not catch it (1600 > 1500 passes), which is precisely why the binding constraint had to be a measurement. The code says so in as many words at The constant is derived, not fitted. Cadence table reproduced independently (
Every figure matches the PR body. The acceptance check passes: mixed stays at Balanced's 1.2×, and Live remains 2.0× on the same fixture, so the separation is real rather than asserted. Escalation charges only on the unpunctuated stretch, where Balanced previously bought nothing (24.2s → 9.3s), and costs less there than Live (5 turns vs 7). Hysteresis is structural, and the test probes it properly. Escalate on elapsed time, de-escalate on an observed boundary — asymmetric triggers, so the minimum flip period is The two rewritten tests are strengthened, not weakened — worth checking, since a rewrite is the easy place to lose coverage. Scope: two files, both The honest limit, correctly statedUnpunctuated p95 lands at 9.3s against Live's 3.9s, because the first release must wait out the threshold — it recovers about two-thirds of the 24.2s gap rather than all of it. That is the direct price of not costing 2× on every mixed meeting, and stating it plainly rather than presenting 9.3s as equivalent is the right call. (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: REQUEST CHANGES
Epic Alignment: FAIL
The threshold derivation and mixed/unpunctuated measurements are strong, but the implementation does not satisfy #211's explicit hysteresis contract requiring sustained presence before de-escalation.
Checked (evidence)
- Threshold is derived from measured inter-boundary gaps and bounded against dwell/latency (
crates/livecap-core/src/stable_prefix.rs:82-119;stable_prefix_measure.rsgap test). - Mixed target and unpunctuated improvement are reported in the PR body and covered by seeded tests.
- Scope: diff contains only the two
livecap-corefiles; no UI/host/archive changes. - Riskiest part: hysteresis state transitions; current de-escalation is immediate on the first boundary.
- Kill-list: diff scanned — finding below.
- CI:
gh pr checks 215→ all five checks passing.
Findings
- [blocker] De-escalation is not sustained, contrary to #211's contract.
- File:
crates/livecap-core/src/stable_prefix.rs:248-258 - Why it fails: any single clause boundary immediately clears
escalated, even if punctuation appears only once and the recognizer returns to an unpunctuated stretch on the next partial. The ticket requires “sustained presence before de-escalating”; the PR's event-only transition does not provide that window. - Do instead: add a measured/justified sustained-boundary window (or equivalent consecutive-boundary condition), document the chosen value, and assert that transient punctuation does not de-escalate while sustained punctuation does.
- File:
Decision
Request changes. The cost derivation is evidence-bound, but the state-transition contract is incomplete.
RE1 is right and I checked the ticket text before changing anything. Scope item 4 of #211 says "Require sustained absence before escalating and sustained PRESENCE before de-escalating; state the chosen windows and why." 5c7455f cleared `escalated` on the first observed boundary, which is not a sustained-presence window, so it was a contract failure however the PR described it. It is also wrong on the merits, which is the part worth stating: a single stray period - an abbreviation, or a recognizer artefact - inside genuinely unpunctuated speech would de-escalate on one character, and the speaker would then wait another full ESCALATE_AFTER_MS before escalation returned. One full stop opened an 8s latency hole. De-escalation now requires DE_ESCALATE_BOUNDARIES = 2 boundaries. Two because one boundary is not evidence; not more because staying escalated through punctuated speech is not free - the dwell fires in the gaps between boundaries, which is precisely Live's cost. Boundaries are counted whether or not they are large enough to release, since the question is whether the recognizer is punctuating again, and interleaved dwell releases do not reset the count. Also adds the acceptance criterion I had not covered: escalation must not oscillate on speech that ALTERNATES punctuated and unpunctuated stretches. The new fixture test drives a 10-clause alternating script and records 4 transitions - 2 escalations, 2 de-escalations - 25.5s apart, and asserts consecutive escalations are at least one window apart. Costs are unchanged, which is the point: mixed still 1.2x (escalation never fires there), unpunctuated still 5.0x / p95 9.3s (no boundaries, so nothing to de-escalate on). Seeded-violation proofs, each reverted independently: de-escalate on the first boundary (5c7455f's behaviour) 2 failed never de-escalate (escalation latches) 2 failed livecap-core 90 (+2). clippy --all-targets -D warnings clean, rustfmt 0 diffs on both files, no-stub-gate passes. Still two files, both livecap-core. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RE2 — APPROVE (at
|
| fixture | Balanced | Live |
|---|---|---|
| mixed | 1.2× · p95 4400 | 2.0× · p95 4500 |
| unpunctuated | 5.0× · p95 9300 | 7.0× · p95 3900 |
Mixed still holds Balanced's 1.2× (escalation never fires there), and unpunctuated still recovers 24.2s → 9.3s. Sustained presence cost nothing on either fixture because neither exercises the transient-boundary case it guards.
The previously-uncovered acceptance criterion is now covered with a real fixture. "Escalation does not oscillate on a fixture that alternates punctuated and unpunctuated speech — asserted" was met at 5c7455f only by a hand-built timeline. escalation_does_not_oscillate_on_alternating_speech now drives a 10-clause alternating script (2 punctuated, 3 bare, 2 punctuated, 3 bare) and reports 4 transitions with escalations at 20100 ms and 45600 ms — 25.5 s apart, asserting consecutive escalations are at least one ESCALATE_AFTER_MS window apart. That AC was unmet and neither reviewer had flagged it; @dev found it by re-reading the criteria after @re1's finding, which is the right response to a contract miss.
Carried forward, re-verified at this SHA: ESCALATE_AFTER_MS = 8_000 still derived from measured inter-boundary gaps via clause_release_gaps, with the guard asserting against the worst measured gap rather than a multiplier; the compile-time bounds still necessary-but-insufficient by design. Scope still two files, both livecap-core — no host, protocol, settings or UI change, so the Relaxed/Balanced picker and archive/TranslationQueue contracts are untouched by construction. 90 crate tests pass in my worktree; all 5 CI green (app-macos 2m4s, release-invariants 2m0s, packages-linux 38s, color-guard, no-stub-gate).
(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 delta fulfills #211's explicit sustained-presence contract: Balanced de-escalates only after two observed clause boundaries, while Relaxed/Live, archive, and queue contracts remain untouched.
Checked (evidence)
- Corrective delta reviewed:
5c7455f..10595555a562c15d8123878f0deda310ebe0c2fb; scope is exactly twolivecap-corefiles. - Sustained-presence implementation:
crates/livecap-core/src/stable_prefix.rs:107-121,273-290; first-boundary regression and two-boundary recovery tests cover the prior defect. - Alternating-speech acceptance coverage:
crates/livecap-core/src/stable_prefix_measure.rs:639-688. - Riskiest part: escalation/de-escalation state transitions; the new boundary count prevents a transient period from opening a fresh 8-second latency hole and preserves the measured cost behavior.
- Kill-list: scanned the delta — clean; no mocks, TODOs, logs, swallowed errors, or unrelated scope.
- CI:
gh pr checks 215→ all five checks pass (app-macos, color-guard, no-stub-gate, packages-linux, release-invariants).
Findings
None.
Decision
The corrective commit resolves my prior contract finding with a concrete two-boundary hysteresis window and adds the missing alternating-fixture assertion. The live diff and green checks support approval at the current SHA.
Fixes #211
Balanced releases only at clause boundaries, so a speaker who does not punctuate got nothing from it — measured on #195 as 1.0× cost and a 24.2 s p95, identical to Relaxed. This makes Balanced detect that condition and fall back to dwell releases for the duration of that stretch, then stop when punctuation returns.
No new mode, no setting, no UI change. Live stays held and unreachable at all four boundaries; escalation is internal behaviour of Balanced, exactly as the ticket requires.
EPIC Alignment
TranslationModeunchanged;livestill clamped in the Rust sanitizer, the protocol type,resolveStartConfig, andsession.rs; archive/1:1 mapping andTranslationQueueuntouched; Relaxed and Balanced remain the only selectable steps.The trigger is absence of punctuation, not speed
That single choice is what holds the cost target. The mixed fixture's monologues are punctuated, so boundaries keep arriving, the stretch never proves itself unpunctuated, and escalation never fires there.
Escalation charges only where Balanced previously bought nothing, and stays cheaper than Live even there (5 turns vs 7). Reproduce with
cargo test -p livecap-core --lib cadence_table -- --nocapture.The honest limit: at 9.3 s p95 escalation does not match Live's 3.9 s on unpunctuated speech, because the first release in a stretch must wait out
ESCALATE_AFTER_MS. It recovers roughly two-thirds of the gap from Relaxed's 24.2 s. That is the price of not costing 2× on every mixed meeting, and it is the trade the ticket asks for.The constant is derived, and @re2 caught the error that would have sunk it
I posted the design before building it and claimed the mixed fixture was safe because "waiting time never accumulates" between boundaries. That was false. The wait clock starts as soon as any word settles past the watermark — partway through every clause — so it is running the whole time a clause is spoken. The floor for
ESCALATE_AFTER_MSis therefore the inter-boundary interval, and> LIVE_DWELL_MSis necessary but badly insufficient.Measured directly by
clause_release_gaps, not estimated:ESCALATE_AFTER_MS = 8000sits ~48% above the worst gap, and well below the ~24 s wait it exists to replace.A measurement bug I found and fixed in my own harness: the first version reported a 14 600 ms "gap" on the mixed fixture because it never called
on_finalize, folding a speaker's pause between utterances into a single interval. The pipeline resets the tracker per utterance; the harness now mirrors that. Had I trusted it, I would have set the constant against an artifact.Hysteresis: both windows, per the ticket
Scope item 4 requires sustained absence before escalating and sustained presence before de-escalating. @re1 caught that an earlier revision cleared
escalatedon the first observed boundary, which is not a sustained-presence window.ESCALATE_AFTER_MS= 8000 ms of boundary-free waitingDE_ESCALATE_BOUNDARIES= 2 boundariesTwo and not more, because staying escalated through punctuated speech is not free: the dwell fires in the gaps between boundaries, which is exactly Live's cost. Two is the smallest count that survives a single artefact.
Boundaries are counted whether or not they are large enough to release — the question is whether the recognizer is punctuating, not whether the clause was worth a turn — and interleaved dwell releases do not reset the count, since a dwell is the escalated mechanism working rather than evidence that punctuation stopped.
The two directions use different kinds of window (a duration vs a count) because the two questions are different kinds: "has punctuation stopped?" is about elapsed time, "has it come back?" is about events. Neither can be satisfied by a single partial, which is what keeps the state from chasing noise in either direction.
No-oscillation is asserted twice, at two levels.
escalation_cannot_flip_twice_inside_one_windowpins the property on a hand-built timeline, probing every 500 ms across the whole window.escalation_does_not_oscillate_on_alternating_speechdrives a 10-clause script that alternates punctuated and unpunctuated stretches — the ticket's acceptance criterion — and records 4 transitions (2 escalations, 2 de-escalations), 25.5 s apart, asserting consecutive escalations are at least one window apart.Self-Verification
Tests: livecap-core 90 (+8).
cargo clippy --all-targets -D warningsclean. rustfmt parity with main restored — 0 diffs on both files, matching main exactly.no-stub-gate,color-guardpass.Seeded-violation proofs, each reverted independently:
ESCALATE_AFTER_MS = 1600(just over the dwell)The first seed is the important one: it reproduces the exact failure @re2 predicted, and produces exactly the result Head said to report rather than ship. It is now a guarded configuration rather than a lurking one.
The constant's other two bounds are enforced by the compiler, not by a test, because the failure mode is a later retune:
const _: () = assert!(...)instable_prefix.rs. Verified by setting it to 1400 — the build fails withevaluation panicked: assertion failed: ESCALATE_AFTER_MS > LIVE_DWELL_MS.Two tests asserted the old behaviour and were rewritten, not deleted — flagged because a changed assertion is the easiest thing to skim past, and this is the third such case in this batch:
balanced_never_releases_text_without_punctuation(probed at t=60 s) →balanced_holds_unpunctuated_text_until_the_stretch_proves_itselflive_is_the_only_mode_that_helps_unpunctuated_speech→balanced_escalates_to_keep_up_with_unpunctuated_speechScope containment checked mechanically:
git status --shortshows exactly two files, both inlivecap-core. No host, protocol, settings, or UI change — so the Relaxed/Balanced UI and the archive/TranslationQueuecontracts are untouched by construction rather than by inspection.Kill-list: clean — no new dependency, no TODO/FIXME/stub, no caption content logged or persisted.
Deviations
ESCALATE_AFTER_MS = 8000is derived from five fixtures, not from real speech. The fixtures aresayTTS and over-articulated; a real speaker with longer clauses could exceed a 5.4 s inter-boundary gap and trip escalation mid-clause. The margin is ~48%, and the failure mode is bounded (one extra turn, then de-escalation), but the number should be revisited against a device session rather than treated as settled.is_escalated()exists for tests and measurement only. Nothing user-facing reads it, and escalation is deliberately not surfaced in the UI: it is not a state the user chooses or needs to reason about.