M1-15: Keep the unfinalised transcript tail #40

Merged
wk merged 2 commits from m1-15-volatile-tail into main 2026-08-09 12:02:26 -04:00
Member

Closes #25.

What it does

A force-quit measured on device cost the last 16 seconds of speech of a 210-second memo: the
speech engine was holding them, and the drain that recovers them happens at stop, which a crash
never reaches.

The engine's outstanding phrases now go to <base>_transcription.volatile.vtt inside the
capture's existing .parts folder, rewritten atomically at most every two seconds against a
monotonic clock. Every finalised cue supersedes them. A normal stop deletes the file unread; the
launch sweep appends them to a recovered transcript, marked as never confirmed, and only for the
stretch after the last finalised cue.

Why two speech modules

The short version of this change adds .volatileResults to the transcriber the app already has
and keeps filtering on isFinal. It is wrong in a way that does not show up in testing: Apple
documents that a hypothesis is not guaranteed to be reissued as final, so a phrase delivered once
as a guess would be dropped by that filter — from an ordinary recording that stopped cleanly. That
version would have cost speech from every capture in order to save it from the rare crashed one.

So the analyzer runs two transcriber modules over the one input sequence. The transcript's module
is configured exactly as before and remains the only source of TranscriptCue. Apple recommends
this split for exactly this case.

How a guess is kept out of the transcript

  1. VolatileTail is a different type from TranscriptCue, so the parts writer, the boundary rule
    and the cue list on screen will not take one. Checked by the compiler on every build.
  2. The writer refuses any hypothesis starting before the last finalised cue it has seen, and
    rewrites the file the moment a cue catches up with one. Order-independent, so it cannot be raced.
  3. Recovery re-parses the file strictly and re-checks its times against every part. The documents
    directory is in the Files app, and this path publishes into somebody's transcript.

The provisional cues carry the identifier unconfirmed as well as a NOTE block, because a
conforming parser drops comments and a person reading the file does not read identifiers.

Validated

  • Wes installed the build and ran the device pass; everything healthy. Two transcriber modules do
    coexist on real hardware, and a normal stop still produces the transcript it should.
  • 221 unit tests, 63 of them new. Lint clean.

Not validated

What the second recogniser costs over a long recording. A 20-minute locked-screen capture is
still to be tested, and battery and heat are what it answers. If the cost threatens a multi-hour
memo, closing the issue is the right response rather than tuning it — the recording is worth more
than its tail.

Also open, and the reason the launch diagnostic now reports when a recovered capture ended in
unconfirmed words: how much speech the tail actually recovers in practice, across more than the
one force-quit that produced the 16-second measurement.

Codex

Four passes, dispositions in .agent/m1-15-volatile-tail/SUMMARY.md. The two that changed the
shape of this:

  • The plan review caught the single-module design above, before any of it was written.
  • The code review caught that only the newest hypothesis was kept, when a result is a phrase and
    several can be outstanding — which would have recovered about three seconds of the sixteen and
    reported nothing missing.

One finding rejected: the test review wanted TranscriptRecovery.run exercised directly, which
needs the app's real documents directory and the recorder singleton and so is not a logic-only
test. Its body delegates to recover, which is tested.

Documentation

AGENTS.md's "Do not ask for volatile results" is replaced by the narrower rule this establishes.
The decision record is in stash-docs, in a separate commit — see the note on the PR there.

Closes #25. ## What it does A force-quit measured on device cost the last 16 seconds of speech of a 210-second memo: the speech engine was holding them, and the drain that recovers them happens at stop, which a crash never reaches. The engine's outstanding phrases now go to `<base>_transcription.volatile.vtt` inside the capture's existing `.parts` folder, rewritten atomically at most every two seconds against a monotonic clock. Every finalised cue supersedes them. A normal stop deletes the file unread; the launch sweep appends them to a recovered transcript, marked as never confirmed, and only for the stretch after the last finalised cue. ## Why two speech modules The short version of this change adds `.volatileResults` to the transcriber the app already has and keeps filtering on `isFinal`. It is wrong in a way that does not show up in testing: Apple documents that a hypothesis is not guaranteed to be reissued as final, so a phrase delivered once as a guess would be dropped by that filter — from an ordinary recording that stopped cleanly. That version would have cost speech from every capture in order to save it from the rare crashed one. So the analyzer runs two transcriber modules over the one input sequence. The transcript's module is configured exactly as before and remains the only source of `TranscriptCue`. Apple recommends this split for exactly this case. ## How a guess is kept out of the transcript 1. `VolatileTail` is a different type from `TranscriptCue`, so the parts writer, the boundary rule and the cue list on screen will not take one. Checked by the compiler on every build. 2. The writer refuses any hypothesis starting before the last finalised cue it has seen, and rewrites the file the moment a cue catches up with one. Order-independent, so it cannot be raced. 3. Recovery re-parses the file strictly and re-checks its times against every part. The documents directory is in the Files app, and this path publishes into somebody's transcript. The provisional cues carry the identifier `unconfirmed` as well as a `NOTE` block, because a conforming parser drops comments and a person reading the file does not read identifiers. ## Validated - Wes installed the build and ran the device pass; everything healthy. Two transcriber modules do coexist on real hardware, and a normal stop still produces the transcript it should. - 221 unit tests, 63 of them new. Lint clean. ## Not validated **What the second recogniser costs over a long recording.** A 20-minute locked-screen capture is still to be tested, and battery and heat are what it answers. If the cost threatens a multi-hour memo, closing the issue is the right response rather than tuning it — the recording is worth more than its tail. Also open, and the reason the launch diagnostic now reports when a recovered capture ended in unconfirmed words: how much speech the tail actually recovers in practice, across more than the one force-quit that produced the 16-second measurement. ## Codex Four passes, dispositions in `.agent/m1-15-volatile-tail/SUMMARY.md`. The two that changed the shape of this: - The plan review caught the single-module design above, before any of it was written. - The code review caught that only the newest hypothesis was kept, when a result is a phrase and several can be outstanding — which would have recovered about three seconds of the sixteen and reported nothing missing. One finding rejected: the test review wanted `TranscriptRecovery.run` exercised directly, which needs the app's real documents directory and the recorder singleton and so is not a logic-only test. Its body delegates to `recover`, which is tested. ## Documentation `AGENTS.md`'s "Do not ask for volatile results" is replaced by the narrower rule this establishes. The decision record is in `stash-docs`, in a separate commit — see the note on the PR there.
A force-quit measured on device cost the last 16 seconds of speech of a
210-second memo: the engine was holding it, and the drain that recovers it
happens at stop, which a crash never reaches.

The engine's outstanding phrases now go to a separate file in the capture's
parts folder, rewritten atomically at most every two seconds. Every finalised
cue supersedes them. A normal stop deletes the file unread; the launch sweep
appends them to a recovered transcript, marked as never confirmed.

They come from a second transcriber module. One module asked for both would
have been shorter and wrong: a hypothesis is not guaranteed to be reissued as
final, so the isFinal filter could drop a phrase from a recording that stopped
cleanly — speech lost from every capture to save it from the rare one.

Not validated: nothing has run two speech modules on real hardware. Whether
they coexist, whether the finalised stream is unaffected, and what the second
recogniser costs over a long recording are all open, and StashTests has no
speech engine by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wk merged commit ab9a346460 into main 2026-08-09 12:02:26 -04:00
wk deleted branch m1-15-volatile-tail 2026-08-09 12:02:26 -04:00
Sign in to join this conversation.
No reviewers
No milestone
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
Stash/stash-ios!40
No description provided.