[Backend] Emit start-end ranges and a deterministic tie-break in the merge #345

Closed
opened 2026-08-25 20:39:02 +00:00 by claude-bot · 2 comments
Contributor

Severity: LOW-MEDIUM. Found in the August 2026 session lifecycle review (#319). Deliberately downgraded — see below.

The defect

merge_attributed_transcript sorts by start only and emits a start-only, second-truncated stamp (audio_service.py:846-859). Overlapping speech is flattened: if A speaks 00:10:00-00:10:25 and B interjects at 00:10:12, the merged text places A's entire utterance — including A's reaction to the interjection — before B's line. The summary then reads "B warned them after A had already opened it", inverting causality and crediting reactions as initiative.

The LLM sees only ascending start stamps with no end times, so nothing lets it recover the true interleaving.

Why this is not urgent

The original audit ranked this high on the assumption that it was a major contributor to the reported bug. Once the capture clock is fixed, that assessment weakens honestly: with tracks on a genuine shared clock, sort-by-start is essentially natural conversation order, and displacement is bounded by one segment (~30 s worst case, usually one sentence) rather than by hours. This would not have prevented the incident.

It remains worth doing — one function, and crosstalk-heavy combat scenes are exactly where a GM notices — but it is polish, not a fix.

Proposed fix

Emit [HH:MM:SS–HH:MM:SS] ranges so overlap is visible to the model, and add a deterministic tie-break key=(start, track_owner_id) so output is reproducible across runs. Note that the VAD and non-VAD paths currently tie-break differently (file glob order vs server response order).

Splitting segments at interjection points was considered and rejected for now: it needs word-level timestamps to split text at the right place, which the current segment contract cannot provide.

Acceptance criteria

  • Lines render a start-end range
  • Sort uses a deterministic tie-break; two runs over the same input produce identical output
  • All format consumers updated in lockstep
  • Prompts that describe the line format are updated to explain that intersecting ranges mean simultaneous speech
**Severity: LOW-MEDIUM.** Found in the August 2026 session lifecycle review (#319). Deliberately downgraded — see below. ## The defect `merge_attributed_transcript` sorts by `start` only and emits a start-only, second-truncated stamp (`audio_service.py:846-859`). Overlapping speech is flattened: if A speaks 00:10:00-00:10:25 and B interjects at 00:10:12, the merged text places A's entire utterance — including A's reaction to the interjection — *before* B's line. The summary then reads "B warned them after A had already opened it", inverting causality and crediting reactions as initiative. The LLM sees only ascending start stamps with no end times, so nothing lets it recover the true interleaving. ## Why this is not urgent The original audit ranked this high on the assumption that it was a major contributor to the reported bug. Once the capture clock is fixed, that assessment weakens honestly: with tracks on a genuine shared clock, sort-by-start *is* essentially natural conversation order, and displacement is bounded by one segment (~30 s worst case, usually one sentence) rather than by hours. This would not have prevented the incident. It remains worth doing — one function, and crosstalk-heavy combat scenes are exactly where a GM notices — but it is polish, not a fix. ## Proposed fix Emit `[HH:MM:SS–HH:MM:SS]` ranges so overlap is visible to the model, and add a deterministic tie-break `key=(start, track_owner_id)` so output is reproducible across runs. Note that the VAD and non-VAD paths currently tie-break differently (file glob order vs server response order). Splitting segments at interjection points was considered and rejected for now: it needs word-level timestamps to split *text* at the right place, which the current segment contract cannot provide. ## Acceptance criteria - [ ] Lines render a start-end range - [ ] Sort uses a deterministic tie-break; two runs over the same input produce identical output - [ ] All format consumers updated in lockstep - [ ] Prompts that describe the line format are updated to explain that intersecting ranges mean simultaneous speech
Author
Contributor

Half done on feat/v4-deterministic-attribution; the other half is deliberately deferred, and the reason has changed since this was filed.

Done — the deterministic tie-break. order_segments now sorts by (start, track_owner_id, speaker). Sorting by start alone left two segments sharing a second in whatever order the producing path emitted them: filename order on the VAD path, server order on the session endpoint. That became load-bearing while this issue was open — beat evidence (#332/#333) cites timestamps, so an unstable render makes citations unstable too.

Deferred — the [start-end] range rendering. The single-stamp [HH:MM:SS] Speaker: text form is now parsed back out by beat_service to verify that a claimed actor actually spoke a cited line. Changing the render means changing that parser in lockstep, plus the erasure regex and the prompts that describe the format.

The review had already downgraded ranges to cheap polish once the capture clock was fixed — overlap displacement is bounded by one segment (~30 s) rather than hours. Spending that against a citation format the accuracy work now depends on is a bad trade at this point in the milestone.

Worth revisiting after #335's consumers are migrated: once erasure and highlight validation read segment rows rather than parsing text, the rendered format stops being a data contract and ranges become a genuinely cheap, low-risk change. That is the right moment for it.

Half done on `feat/v4-deterministic-attribution`; the other half is deliberately deferred, and the reason has changed since this was filed. **Done — the deterministic tie-break.** `order_segments` now sorts by `(start, track_owner_id, speaker)`. Sorting by start alone left two segments sharing a second in whatever order the producing path emitted them: filename order on the VAD path, server order on the session endpoint. That became load-bearing while this issue was open — beat evidence (#332/#333) cites timestamps, so an unstable render makes citations unstable too. **Deferred — the `[start-end]` range rendering.** The single-stamp `[HH:MM:SS] Speaker: text` form is now parsed back out by `beat_service` to verify that a claimed actor actually spoke a cited line. Changing the render means changing that parser in lockstep, plus the erasure regex and the prompts that describe the format. The review had already downgraded ranges to cheap polish once the capture clock was fixed — overlap displacement is bounded by one segment (~30 s) rather than hours. Spending that against a citation format the accuracy work now depends on is a bad trade at this point in the milestone. Worth revisiting after #335's consumers are migrated: once erasure and highlight validation read segment *rows* rather than parsing text, the rendered format stops being a data contract and ranges become a genuinely cheap, low-risk change. That is the right moment for it.
Author
Contributor

Deferred half done in 0623425. Both halves of this issue have now shipped.

Acceptance

  • Lines render a start-end range — on overlapping lines only, see below
  • Sort uses a deterministic tie-break; two runs over the same input produce identical output (shipped earlier in the milestone)
  • All format consumers updated in lockstep
  • Prompts that describe the line format explain that intersecting ranges mean simultaneous speech

1,165 passing before → 1,182.

Deviation: ranges only where speech actually overlaps

The issue asks for [HH:MM:SS–HH:MM:SS] on every line. I emit a range only on a line that intersects a line from a different track; everything else is byte-for-byte what it was.

The reason is #341, which cut 10.9% of transcript characters specifically because prompt size decides whether a session has to be chunked, and this milestone treats self-hosted small models as first-class. So I measured it rather than argue about it — synthetic 77-minute, six-speaker session, 1,200 segments, 21.5% of them overlapping (more crosstalk than a typical table), all figures post-compaction:

rendering tokens delta
single stamp (pre-#345) 17,105
ranges only where needed (shipped) 17,690 +585 (+3.4%)
ranges on every line (as proposed) 19,728 +2,623 (+15.3%)

Universal ranges cost 4.5× what conditional ones do, and would eat most of what #341 bought. A line that overlapped nothing has an end time that tells the model nothing, so the cost buys nothing there.

The mixed format turns out to be a feature rather than a wart: two stamps means crosstalk, so the format itself carries the signal, and the prompt says so in one sentence instead of asking the model to compare ranges across every line pair.

Correcting myself on the record: when I proposed this I estimated universal ranges at ~10,500 tokens per session. The measured figure is 2,623 at this session size. The ratio and the conclusion hold; my per-line arithmetic was 3–4× too pessimistic.

Separator is an ASCII hyphen rather than the en-dash in the issue text — it matches TranscriptWindow.range_label in the same module and costs one token instead of two.

Consumers, since the format is a contract

Written once, parsed back by compaction, again by beat-evidence validation, and later by member erasure. Every reader accepts both forms — not optional, since every transcript already stored is single-stamp and both erasure and reprocessing run over those. Where a reader needs one timestamp it takes the start, so build_transcript_index keys are unchanged and beat evidence still cites a start stamp.

Writers: render_transcript_line (the single definition), merge_attributed_transcript, render_transcript_from_rows, compact_transcript._flush. Readers: beat_service.LINE_RE, reminder_tasks._TRANSCRIPT_LINE_RE, audio_service._TIMESTAMP_RE. Prompts: window, summarise, highlights (told to use the start stamp for timestamp_ref, which is a max_length=20 column), and beat extraction.

Compaction carries a run's range as (first start, largest end) and emits a single stamp when no line in the run had one.

render_transcript_from_rows applies the same overlap rule, because reminder_tasks compares a re-render against the stored transcript byte-for-byte to detect GM hand-editing. That equality now has an integration test rather than an assumption.

One review catch worth recording

mark_overlapping originally trusted its caller to pass start-sorted input, because the sweep breaks early on start order. An unsorted caller would get fewer overlaps marked — reading as "nobody talked over anyone" rather than failing. render_transcript_from_rows renders in stored seq order and deliberately does not re-derive the sort, which is exactly the kind of precondition that holds today and quietly stops holding later. It now sorts internally: one sort, one less silent-wrong-answer mode. Pinned by test_mark_overlapping_does_not_depend_on_the_caller_sorting_first.

What this does not do

Segments are still not split at interjection points — that needs word-level timestamps to cut the text in the right place, which the segment contract still cannot provide. The issue already ruled that out and it remains ruled out. Overlap is now visible; the line is still atomic.

Closing.

Deferred half done in `0623425`. Both halves of this issue have now shipped. ## Acceptance - [x] Lines render a start-end range — **on overlapping lines only**, see below - [x] Sort uses a deterministic tie-break; two runs over the same input produce identical output *(shipped earlier in the milestone)* - [x] All format consumers updated in lockstep - [x] Prompts that describe the line format explain that intersecting ranges mean simultaneous speech 1,165 passing before → **1,182**. ## Deviation: ranges only where speech actually overlaps The issue asks for `[HH:MM:SS–HH:MM:SS]` on every line. I emit a range only on a line that intersects a line from a *different track*; everything else is byte-for-byte what it was. The reason is #341, which cut 10.9% of transcript characters specifically because prompt size decides whether a session has to be chunked, and this milestone treats self-hosted small models as first-class. So I measured it rather than argue about it — synthetic 77-minute, six-speaker session, 1,200 segments, 21.5% of them overlapping (more crosstalk than a typical table), all figures post-compaction: | rendering | tokens | delta | |---|---|---| | single stamp (pre-#345) | 17,105 | — | | **ranges only where needed (shipped)** | **17,690** | **+585 (+3.4%)** | | ranges on every line (as proposed) | 19,728 | +2,623 (+15.3%) | Universal ranges cost 4.5× what conditional ones do, and would eat most of what #341 bought. A line that overlapped nothing has an end time that tells the model nothing, so the cost buys nothing there. The mixed format turns out to be a feature rather than a wart: two stamps *means* crosstalk, so the format itself carries the signal, and the prompt says so in one sentence instead of asking the model to compare ranges across every line pair. Correcting myself on the record: when I proposed this I estimated universal ranges at ~10,500 tokens per session. The measured figure is 2,623 at this session size. The ratio and the conclusion hold; my per-line arithmetic was 3–4× too pessimistic. Separator is an ASCII hyphen rather than the en-dash in the issue text — it matches `TranscriptWindow.range_label` in the same module and costs one token instead of two. ## Consumers, since the format is a contract Written once, parsed back by compaction, again by beat-evidence validation, and later by member erasure. Every reader accepts **both** forms — not optional, since every transcript already stored is single-stamp and both erasure and reprocessing run over those. Where a reader needs one timestamp it takes the start, so `build_transcript_index` keys are unchanged and beat evidence still cites a start stamp. Writers: `render_transcript_line` (the single definition), `merge_attributed_transcript`, `render_transcript_from_rows`, `compact_transcript._flush`. Readers: `beat_service.LINE_RE`, `reminder_tasks._TRANSCRIPT_LINE_RE`, `audio_service._TIMESTAMP_RE`. Prompts: window, summarise, highlights (told to use the start stamp for `timestamp_ref`, which is a `max_length=20` column), and beat extraction. Compaction carries a run's range as (first start, largest end) and emits a single stamp when no line in the run had one. `render_transcript_from_rows` applies the same overlap rule, because `reminder_tasks` compares a re-render against the stored transcript byte-for-byte to detect GM hand-editing. That equality now has an integration test rather than an assumption. ## One review catch worth recording `mark_overlapping` originally trusted its caller to pass start-sorted input, because the sweep breaks early on start order. An unsorted caller would get *fewer* overlaps marked — reading as "nobody talked over anyone" rather than failing. `render_transcript_from_rows` renders in stored `seq` order and deliberately does *not* re-derive the sort, which is exactly the kind of precondition that holds today and quietly stops holding later. It now sorts internally: one sort, one less silent-wrong-answer mode. Pinned by `test_mark_overlapping_does_not_depend_on_the_caller_sorting_first`. ## What this does not do Segments are still not split at interjection points — that needs word-level timestamps to cut the *text* in the right place, which the segment contract still cannot provide. The issue already ruled that out and it remains ruled out. Overlap is now *visible*; the line is still atomic. Closing.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#345
No description provided.