[Backend] Emit start-end ranges and a deterministic tie-break in the merge #345
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: LOW-MEDIUM. Found in the August 2026 session lifecycle review (#319). Deliberately downgraded — see below.
The defect
merge_attributed_transcriptsorts bystartonly 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-breakkey=(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
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_segmentsnow 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: textform is now parsed back out bybeat_serviceto 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.
Deferred half done in
0623425. Both halves of this issue have now shipped.Acceptance
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:
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_labelin 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_indexkeys 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 fortimestamp_ref, which is amax_length=20column), 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_rowsapplies the same overlap rule, becausereminder_taskscompares 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_overlappingoriginally 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_rowsrenders in storedseqorder 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 bytest_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.