Skip to content

[#211] Escalate Balanced automatically on unpunctuated stretches - #215

Merged
realproject7 merged 2 commits into
mainfrom
task/211-auto-escalation
Aug 5, 2026
Merged

[#211] Escalate Balanced automatically on unpunctuated stretches#215
realproject7 merged 2 commits into
mainfrom
task/211-auto-escalation

Conversation

@realproject7

@realproject7 realproject7 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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

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.

fixture Balanced before Balanced after
natural speech 1.0× · p95 4.4 s unchanged
continuous (punctuated) 5.0× · p95 4.5 s unchanged
mixed meeting 1.2× · p95 4.4 s unchanged — 1.2×, the ticket's target
continuous + unpunctuated 1.0× · p95 24.2 s 5.0× · p95 9.3 s

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_MS is therefore the inter-boundary interval, and > LIVE_DWELL_MS is necessary but badly insufficient.

Measured directly by clause_release_gaps, not estimated:

fixture in-stretch clause gaps max
natural (none — each clause is its own utterance)
continuous 5400, 4500, 5100, 4500 ms 5400 ms
mixed 5100, 3900 ms 5100 ms

ESCALATE_AFTER_MS = 8000 sits ~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 escalated on the first observed boundary, which is not a sustained-presence window.

direction window why
escalate ESCALATE_AFTER_MS = 8000 ms of boundary-free waiting measured floor is the 5400 ms inter-boundary interval; see above
de-escalate DE_ESCALATE_BOUNDARIES = 2 boundaries one boundary is not evidence — a stray period would otherwise de-escalate on a single character, and the speaker would wait another full 8 s before escalation returned

Two 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_window pins the property on a hand-built timeline, probing every 500 ms across the whole window. escalation_does_not_oscillate_on_alternating_speech drives 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 warnings clean. rustfmt parity with main restored — 0 diffs on both files, matching main exactly. no-stub-gate, color-guard pass.

Seeded-violation proofs, each reverted independently:

seed result
ESCALATE_AFTER_MS = 1600 (just over the dwell) 3 failed — and the mixed fixture reads 20 turns / 2.0×, numerically identical to Live
de-escalation removed (escalation latches on) 2 failed
escalation gate removed (Balanced always dwells) 6 failed
de-escalate on the first boundary 2 failed
never de-escalate (escalation latches on) 2 failed

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!(...) in stable_prefix.rs. Verified by setting it to 1400 — the build fails with evaluation 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_itself
  • live_is_the_only_mode_that_helps_unpunctuated_speechbalanced_escalates_to_keep_up_with_unpunctuated_speech

Scope containment checked mechanically: git status --short shows exactly two files, both in livecap-core. No host, protocol, settings, or UI change — so the Relaxed/Balanced UI and the archive/TranslationQueue contracts 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

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>
@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — APPROVE

PR #215 (#211) @ 5c7455fa17ae83232c6a2bd74933ba54ae894861 — all 5 CI checks green. Two files, both in livecap-core, +308/−23. I raised the design objection this PR answers, so I re-derived every number myself rather than checking my own prior reasoning.

Checked (evidence)

Riskiest part: ESCALATE_AFTER_MS. The whole cost story rests on one constant, and its failure mode is silent — a value that looks safe against the stated bound turns Balanced into Live at full price. I seeded exactly that:

ESCALATE_AFTER_MS = 1_600   (> LIVE_DWELL_MS, so the compile-time bound PASSES)

mixed fixture:
  Balanced   20 turns   21.4/min   p50 1500   p95 4500   2.0x
  Live       20 turns   21.4/min   p50 1500   p95 4500   2.0x

Numerically identical to Live — same turns, same p50, same p95. And three tests fail, including the measured guard escalation_threshold_clears_the_inter_boundary_interval. Reverted; tree clean.

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 stable_prefix.rs:107-113.

The constant is derived, not fitted. clause_release_gaps (stable_prefix_measure.rs:479-503) runs the real tracker over each script and measures inter-boundary intervals directly, resetting on on_finalize so a speaker's pause between utterances is not folded into a "gap" — the harness bug @dev found and fixed before trusting the number. escalation_threshold_clears_the_inter_boundary_interval (:507-532) takes the worst gap across all three punctuated fixtures and asserts ESCALATE_AFTER_MS > worst, with a failure message naming the consequence. The assertion is against the measurement, not against a multiplier — so tuning the constant until the mixed fixture reads 1.2× is not what happened, and could not silently happen later.

Cadence table reproduced independently (cargo test -p livecap-core --lib cadence_table -- --nocapture in my own worktree):

fixture Relaxed Balanced Live
natural 1.0× · p95 4400 1.0× · p95 4400 2.0× · p95 2400
continuous 1.0× · p95 24200 5.0× · p95 4500 6.0× · p95 4500
unpunctuated 1.0× · p95 24200 5.0× · p95 9300 7.0× · p95 3900
mixed 1.0× · p95 8600 1.2× · p95 4400 2.0× · p95 4500

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 ESCALATE_AFTER_MS by construction rather than by damping. escalation_cannot_flip_twice_inside_one_window sweeps the window at 500 ms intervals instead of counting flips on one fixture, which is the difference between asserting the property and observing an instance.

The two rewritten tests are strengthened, not weakened — worth checking, since a rewrite is the easy place to lose coverage. balanced_never_releases_text_without_punctuationbalanced_holds_unpunctuated_text_until_the_stretch_proves_itself keeps the original intent (Balanced must not behave like Live) by asserting no release at LIVE_DWELL_MS + 1, adds no release at ESCALATE_AFTER_MS - 1, then requires release exactly at the threshold with reason == Dwell. Both edges of the window are now pinned where previously only a single t=60s probe existed.

Scope: two files, both livecap-core. No host, protocol, settings or UI change, so the Relaxed/Balanced picker and the archive/TranslationQueue contracts are untouched by construction rather than by assertion. TranslationMode unchanged; live remains unreachable at all four boundaries. 88 crate tests pass in my worktree; all 5 CI green.

The honest limit, correctly stated

Unpunctuated 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 project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.rs gap test).
  • Mixed target and unpunctuated improvement are reported in the PR body and covered by seeded tests.
  • Scope: diff contains only the two livecap-core files; 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.

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>
@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — APPROVE (at 1059555)

PR #215 (#211) @ 10595555a562c15d8123878f0deda310ebe0c2fb — all 5 CI checks green. Supersedes my 5c7455f approval, which should not have been given: @re1's finding is a contract failure I endorsed twice.

Owning the miss first, because it shapes what I re-checked

#211 scope item 4 says, verbatim: "Require sustained absence before escalating and sustained presence before de-escalating; state the chosen windows and why." At 5c7455f, escalated cleared on the first observed boundary. I called the hysteresis "structural" in my design review and again in my verdict — reasoning from the asymmetry I had helped propose (time to escalate, event to de-escalate) rather than reading the requirement. The asymmetry does bound escalation frequency, which is what I checked; it says nothing about sustained presence, which is what the ticket asked for. @re1 read the ticket; I read my own model of it. Having contributed to a design makes me the worst-placed reviewer to validate it against the spec, and that is exactly what happened.

So this pass was against the ticket text, not against my prior reasoning.

Checked (evidence)

The fix is correct and the rationale is two-sided rather than a safety margin. DE_ESCALATE_BOUNDARIES = 2 (stable_prefix.rs:121), documented at :109-120: two because one boundary is not evidence — a stray abbreviation or recognizer artefact would drop the speaker into a fresh ESCALATE_AFTER_MS wait, so one full stop opens an 8 s latency hole; and not larger because staying escalated through punctuated speech is not free, since the dwell fires in the gaps between boundaries, which is precisely Live's cost. A window squeezed from both sides is a derived value, not a padded one.

The counting rule is also right, and stated: boundaries are counted whether or not they are large enough to release, because the question is whether the recognizer is punctuating again — an event, not a duration — and interleaved dwell releases do not reset the count, since a dwell is the escalated mechanism working rather than evidence that punctuation stopped.

Seeded the exact behaviour I approved. Replacing the counter with self.escalated = false on the first boundary — i.e. 5c7455f — fails 2 tests (a_single_boundary_does_not_de_escalate, sustained_boundaries_de_escalate_the_stretch). Reverted; tree clean. The new coverage would have caught my miss, which is the property that matters.

Costs are unchanged, which is the point of the fix being cheap. Reproduced in my own worktree:

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 project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 two livecap-core files.
  • 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.

@realproject7
realproject7 merged commit dc416a0 into main Aug 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] Auto-escalate cutting when the recognizer stops punctuating — Live's benefit without Live's standing cost

2 participants