diff --git a/.env.debug.example b/.env.debug.example index e0372222..228a7d03 100644 --- a/.env.debug.example +++ b/.env.debug.example @@ -22,6 +22,9 @@ # CODESCRIBE_ONNX_MODEL_PATH= # Default: unset — Custom path to ONNX Whisper model # CODESCRIBE_ONNX_QUANT= # Default: unset — ONNX quantization level (e.g. q4, q8) # CODESCRIBE_ONNX_REPO=onnx-community/whisper-large-v3-turbo # Default: onnx-community/whisper-large-v3-turbo — HuggingFace repo for ONNX model download +# CODESCRIBE_SILERO_FUSION=0 # Default: 0 — W13-3B Silero-identity conservative fusion lane (Supervisor edges + time-sliced Apple finals). Default OFF. +# CODESCRIBE_SILERO_FUSION_CONTEXT=utterance # Default: utterance — Bounded-context A/B: utterance | left_pad | stable_prompt. Never crosses a long-silence fence. +# CODESCRIBE_SPAN_IDEMPOTENCE=0 # Default: 0 — W13-4 sealed-span replay refusal + in-span loop fence (range identity, never content). Default OFF. # CODESCRIBE_STT_COMMIT_MIN_INTERVAL_MS=180 # Default: 180 — Minimum interval between committed/final STT inference jobs in milliseconds # CODESCRIBE_STT_ENGINE=auto # Default: auto — STT engine: auto (Apple on supported macOS, Candle fallback), candle, onnx, or apple # CODESCRIBE_STT_INITIAL_PROMPT_ENABLED=0 # Default: 0 — Opt-in gate for Candle Whisper runtime-lexicon initial_prompt. Default OFF after W2-F measured a 100% WER regression with the active prompt. @@ -33,9 +36,9 @@ # CODESCRIBE_STT_THERMAL_NOMINAL_MULT=0.0 # Default: 0.0 — Thermal duty-cycle multiplier for nominal system thermal pressure; 0.0 disables nominal backoff # CODESCRIBE_STT_THERMAL_SERIOUS_MULT=2.0 # Default: 2.0 — Thermal multiplier for serious system thermal pressure # CODESCRIBE_TOGGLE_FINAL_PASS=1 # Default: 1 — Use saved-WAV final-pass adjudication when stopping toggle dictation (0 restores preview-only stop path) -# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 disables) +# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 = explicit keep-warm) # CODESCRIBE_WHISPER_INITIAL_PROMPT= # Default: unset — Initial prompt hint for Whisper decoding (ignored by ONNX adapter) -# LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 # Default: whisper-large-v3-turbo-mlx-q8 — Local Whisper model id (HF cache / embedded lookup) +# LOCAL_MODEL=whisper-large-v3-turbo # Default fp16 local Whisper model id # STT_API_KEY= # Default: unset — Cloud STT API key; prefer Settings / macOS Keychain # STT_ENDPOINT= # Default: unset — Cloud STT API endpoint (when USE_LOCAL_STT=0) # USE_LOCAL_STT=1 # Default: 1 — Use local Whisper model (vs cloud) @@ -60,6 +63,12 @@ # SOUND_NAME=Tink # Default: Tink — macOS system sound name for beep # SOUND_VOLUME=1.0 # Default: 1.0 — Sound volume (0.0-1.0) +# ============================================================================= +# STORAGE / OBSERVABILITY +# ============================================================================= +# CODESCRIBE_TRANSCRIPT_BUS_PATH= # Default: unset — Override private committed transcript NDJSON bus path +# XDG_STATE_HOME= # Default: unset — Standard XDG state root used by the transcript bus + # ============================================================================= # STREAMING / BUFFERING # ============================================================================= diff --git a/.env.example b/.env.example index 422b625c..c819f8d7 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ # CODESCRIBE_ONNX_MODEL_PATH= # Default: unset — Custom path to ONNX Whisper model # CODESCRIBE_ONNX_QUANT= # Default: unset — ONNX quantization level (e.g. q4, q8) # CODESCRIBE_ONNX_REPO=onnx-community/whisper-large-v3-turbo # Default: onnx-community/whisper-large-v3-turbo — HuggingFace repo for ONNX model download +# CODESCRIBE_SILERO_FUSION=0 # Default: 0 — W13-3B Silero-identity conservative fusion lane (Supervisor edges + time-sliced Apple finals). Default OFF. +# CODESCRIBE_SILERO_FUSION_CONTEXT=utterance # Default: utterance — Bounded-context A/B: utterance | left_pad | stable_prompt. Never crosses a long-silence fence. +# CODESCRIBE_SPAN_IDEMPOTENCE=0 # Default: 0 — W13-4 sealed-span replay refusal + in-span loop fence (range identity, never content). Default OFF. # CODESCRIBE_STT_COMMIT_MIN_INTERVAL_MS=180 # Default: 180 — Minimum interval between committed/final STT inference jobs in milliseconds # CODESCRIBE_STT_ENGINE=auto # Default: auto — STT engine: auto (Apple on supported macOS, Candle fallback), candle, onnx, or apple # CODESCRIBE_STT_INITIAL_PROMPT_ENABLED=0 # Default: 0 — Opt-in gate for Candle Whisper runtime-lexicon initial_prompt. Default OFF after W2-F measured a 100% WER regression with the active prompt. @@ -35,9 +38,9 @@ # CODESCRIBE_STT_THERMAL_NOMINAL_MULT=0.0 # Default: 0.0 — Thermal duty-cycle multiplier for nominal system thermal pressure; 0.0 disables nominal backoff # CODESCRIBE_STT_THERMAL_SERIOUS_MULT=2.0 # Default: 2.0 — Thermal multiplier for serious system thermal pressure # CODESCRIBE_TOGGLE_FINAL_PASS=1 # Default: 1 — Use saved-WAV final-pass adjudication when stopping toggle dictation (0 restores preview-only stop path) -# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 disables) +# CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=300 # Default: 300 — Unload idle Whisper engine after this many seconds (0 = explicit keep-warm) # CODESCRIBE_WHISPER_INITIAL_PROMPT= # Default: unset — Initial prompt hint for Whisper decoding (ignored by ONNX adapter) -# LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 # Default: whisper-large-v3-turbo-mlx-q8 — Local Whisper model id (HF cache / embedded lookup) +# LOCAL_MODEL=whisper-large-v3-turbo # Default fp16 local Whisper model id # STT_API_KEY= # Default: unset — Cloud STT API key; prefer Settings / macOS Keychain # STT_ENDPOINT= # Default: unset — Cloud STT API endpoint (when USE_LOCAL_STT=0) # USE_LOCAL_STT=1 # Default: 1 — Use local Whisper model (vs cloud) @@ -62,6 +65,12 @@ # SOUND_NAME=Tink # Default: Tink — macOS system sound name for beep # SOUND_VOLUME=1.0 # Default: 1.0 — Sound volume (0.0-1.0) +# ============================================================================= +# STORAGE / OBSERVABILITY +# ============================================================================= +# CODESCRIBE_TRANSCRIPT_BUS_PATH= # Default: unset — Override private committed transcript NDJSON bus path +# XDG_STATE_HOME= # Default: unset — Standard XDG state root used by the transcript bus + # ============================================================================= # STREAMING / BUFFERING # ============================================================================= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba334c3c..a5ea8ecd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,21 +65,28 @@ jobs: env: CODESIGN_CERTIFICATE_BASE64: ${{ secrets.CODESIGN_CERTIFICATE_BASE64 }} CODESIGN_CERTIFICATE_PASSWORD: ${{ secrets.CODESIGN_CERTIFICATE_PASSWORD }} + KEYCHAIN_SESSION_STATE_DIR: ${{ runner.temp }}/keychain-session run: | + set -euo pipefail CERT_PATH="$RUNNER_TEMP/codesign.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/codesign.keychain-db" echo "$CODESIGN_CERTIFICATE_BASE64" | base64 -D > "$CERT_PATH" - security create-keychain -p "" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "" "$KEYCHAIN_PATH" + + # The search-list / default-keychain dance is NOT inlined here any + # more. This step used to prepend an ephemeral keychain to the user + # search list and never put the list back — it relied on + # `delete-keychain` unlisting it, which only works while the file + # still exists. On a self-hosted runner that leaves the operator's + # own keychain domain pointing at a deleted path (2026-08-15 P0). + # scripts/lib/keychain-session.sh snapshots as structured argv, + # unlists before deleting, and is concurrency-safe. + KEYCHAIN_PATH="$(scripts/lib/keychain-session.sh begin codescribe-signing)" + echo "CODESCRIBE_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + # Read into a variable, never echo. The ephemeral password exists so + # the keychain is not world-openable on a shared runner. + KEYCHAIN_PASSWORD="$(cat "$(scripts/lib/keychain-session.sh password-file codescribe-signing)")" security import "$CERT_PATH" -P "$CODESIGN_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - existing_keychains=() - while IFS= read -r keychain; do - keychain="${keychain//\"/}" - [[ -n "$keychain" ]] && existing_keychains+=("$keychain") - done < <(security list-keychains -d user) - security list-keychains -d user -s "$KEYCHAIN_PATH" "${existing_keychains[@]}" - security set-key-partition-list -S apple-tool:,apple: -s -k "" "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null security find-identity -v -p codesigning "$KEYCHAIN_PATH" # The identity now lives in the keychain; the raw PKCS12 has no # further use. Overwrite-then-unlink so the private key material @@ -187,12 +194,24 @@ jobs: appcast.xml generate_release_notes: true - - name: Remove signing keychain + - name: Release the signing keychain # Runs on every outcome: a failed build must not leave the imported - # Developer ID identity resident on the runner. + # Developer ID identity resident on the runner, AND must not leave the + # ephemeral keychain in the user search list or as the default + # keychain. `end` unlists first and deletes second, so it still cleans + # up when the keychain file has already been destroyed — the exact + # sequence that poisoned the operator's host on 2026-08-15. if: always() + env: + KEYCHAIN_SESSION_STATE_DIR: ${{ runner.temp }}/keychain-session run: | - KEYCHAIN_PATH="$RUNNER_TEMP/codesign.keychain-db" - if [ -f "$KEYCHAIN_PATH" ]; then - security delete-keychain "$KEYCHAIN_PATH" + if [ ! -x scripts/lib/keychain-session.sh ]; then + echo "::warning::checkout missing; cannot release the signing keychain" + exit 0 fi + scripts/lib/keychain-session.sh end codescribe-signing + # Read-only verdict on what the runner's keychain domain looks like + # now. Advisory: a dirty domain must be visible, not silent, but it + # must not fail an otherwise successful release. + scripts/keychain-doctor.sh || \ + echo "::warning::runner keychain domain still has stale entries after cleanup" diff --git a/.grok/skills/bus-demux/SKILL.md b/.grok/skills/bus-demux/SKILL.md new file mode 100644 index 00000000..04b5b96c --- /dev/null +++ b/.grok/skills/bus-demux/SKILL.md @@ -0,0 +1,20 @@ +--- +name: bus-demux +description: > + Run scripts/bus-demux.py against codescribe.transcript.v1. Flags: --become, + --name, --follow, --once. Use when the operator says bus-demux flags, kielbasa + follower, or needs the CLI. Session attach and naming live in the codescribe + skill — do not reimplement them here. +--- + +# Bus demux — CLI + +Session attach lives in `codescribe`. This file is flags only. + +```bash +python3 scripts/bus-demux.py --become --follow +python3 scripts/bus-demux.py --name james --follow +python3 scripts/bus-demux.py --name james --once +``` + +Unnamed agents do not pass (exit 2). No microphone. No Lab. diff --git a/.grok/skills/codescribe b/.grok/skills/codescribe new file mode 120000 index 00000000..57e9659b --- /dev/null +++ b/.grok/skills/codescribe @@ -0,0 +1 @@ +../../skills/codescribe \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index acc54041..be43ecbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,267 +1,81 @@ ---- -name: codescribe -title: Codescribe Canonical AGENTS Directive -description: Canonical agent instructions, STT Overlay Doctrine, Peer Bus protocol, and high- -velocity Swarm Entrypoint for Codescribe. -version: 1.0.0 -doctrine: stt-overlay-v1 -architecture: living-tree -entrypoint: - bus: AGENT_BUS.md - loctree: loct - build: scripts/build-app.sh - test: make verify - swarm: AGENTS.md#swarm-orchestration--fast-boot-entrypoint -roles: - - operator - - orchestrator - - worker - - audit ---- - -# AGENTS.md — Codescribe - -## Swarm Orchestration & Fast-Boot Entrypoint ("Na Bucie") - -> **FOR ALL INCOMING AGENT SWARMS & MULTI-AGENT DISPATCHES:** -> Boot instantaneously, synchronize context across the Living Tree, and execute -> without friction or human-relay drag. - -### ⚡ 30-Second Swarm Initialization (Fast Boot Protocol) - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ 1. READ PEER SIGNAL │ head -80 AGENT_BUS.md │ -│ 2. STRUCTURAL SIGHT │ loct / loctree-mcp (AST map over text grep) │ -│ 3. OBEY DOCTRINE │ 100% Append + Gap Fill Only + corrections on the fly!│ -│ 4. VERIFY LOCAL RUN │ make verify (parity is a bench, not a gate) │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - -#### Swarm Execution Matrix - -| Phase | Swarm Role | Primary Tool / Command | Verification Gate | -| :--- | :--- | :--- | :--- | -| **0. Recon & Sight** | `loctree-scout` | `loct` / `loctree-mcp` | `loct occurrences ` / -`slice` | -| **1. Signal Sync** | `bus-coordinator` | `head -80 AGENT_BUS.md` | Read & append signals; check -`OPERATOR_AWAY` | -| **2. Implementation** | `core-worker` | `cargo check` / Rust core | Small, atomic commits with -Authored-By | -| **3. UniFFI Bridge** | `bridge-worker` | `make app-bindings` | Bridge parity check between Rust -& Swift | -| **4. Verification** | `test-falsifier` | gate: `make verify` · bench: `make test-engine-parity` | Layer 0 only: similarity ≥ -0.90 & structural bounds green. Needs the private corpus + loopback — see "Which ruler gates which -lane"; the layered lane is judged on structure, never on Apple fidelity | -| **5. App Build** | `release-builder` | `scripts/build-app.sh` | Developer ID signed binary -verification | - -### 🛡️ Swarm Autonomy & Operational Laws - -1. **Zero Human Relay**: Swarm agents communicate directly via `AGENT_BUS.md`. Never make the -human relay messages between workers. -2. **Living Tree Awareness**: Re-read files before editing. Never revert peer agent work. Work -concurrently in small, coherent commits (`[/]`). -3. **No Blind Surgery**: Structural questions MUST go through Loctree (`loct` / `loctree-mcp`). -Grep is strictly for literal text searching. -4. **Immutable Live Transcript**: Any attempt to rewrite or replace live STT text with Whisper or -post-processing is a hard doctrine violation. -5. **Coalesce AppKit notification observers**: macOS 27 fires AppKit notifications from *inside* -window operations — a popover close reaches `becomeKeyWindow` and storms every `object: nil` -observer in the app (2026-08-07: main thread pinned 93/93 inside `_NSPopoverCloseAndAnimate`, -fixed in `d79781b1`). Any new `NotificationCenter` observer on an AppKit notification must -either coalesce onto the next main-queue tick (pattern: `scheduleExternalThreadsRefresh` in -`AgentChatStore.swift`) or bind to one specific `object:` with an O(1) handler. No disk, no -`DateFormatter`/ICU, no layout inside the callout. The live census is pinned in -`scripts/smoke/appkit-observers.allow` and enforced by `scripts/smoke-macos27.sh` — a new -observer fails the smoke until its discipline is written down. - -### 🩺 Host smoke after every OS / Xcode bump - -`scripts/smoke-macos27.sh` — the standing answer to "did AppKit/CoreGraphics move under us?". -Runs headless and raises no TCC dialog: CoreGraphics constant table vs the raw values pinned in -`app/os/hotkeys/platform.rs`, NSPanel placement clamp, event-tap re-arm, responsibility-disclaim -symbol, Sparkle wiring, AppKit observer census. Rows that need a human at the keyboard are -reported `SKIP`, never silently passed. `--out FILE` writes the filled checklist. - ---- - -## Peer Bus (Do Not Make the Human Relay) - -Read and append: `AGENT_BUS.md` -Cross-agent signals live there (operator away, stalls canceled, peer wake-ups). -At session start: `head -80 AGENT_BUS.md`. If you need another agent, write a `SIGNAL` block — the -operator's orchestration tooling handles peer wake-ups. - -Canonical per-repo instructions for every agent (Claude, Codex, Gemini, Junie, Grok, …). Read this -before touching anything. - -## CODESCRIBE: The engine triangulation. - -> _The codescribe app had already pre 0.8.0 era, the app was using the "final pass" approach: the Whisper - the **only** transcription engine was transcribing the whole audio file - no live, no overlay no instant delivery. and replacing the live transcript with the final pass. The issue was that Whisper was not very confident and the final pass was not very accurate. Also, the final pass was not very fast and the app was not very responsive._ - -**The engine has layers and layers are our weapon, disquise and defense while nobody looks. The goal is to connect disquised and visible magic so the perfect transcript arrives as the "overlay show" with its backspace magic, that put the corrections live while speaking: Apple live speech recognition comes instantly with letter-level precision but is not very confident; Whisper tail-patches and fills gaps thanks to better context; finally lexicon corrects specialistic terms and punctuation on the fly** - -This means: -1. Apple Speech Delivers Instantly with letter precision but leaves gaps; -2. Whisper Transcribes on partial utterance-level "final passes" filling the gaps with better context - never replace the whole live transcript; -3. Lexicon corrections and punctuation are paralelly applied to the transcript; -4. Final pass is left as **opt-in** for regular runtime, but still acts as the lexicon hidden candidates donor. - -## Canonical Layer Order (Operator Directive, 2026-07-26, Verbatim) - -> → Neural instant letter-level transcript via Apple Speech API -> → Whisper transcribing partials on the go and all the time applying the -> patches -> → supervisor stays on duty final lexicon correction by substitution with -> heuristic dictionary! -> → human correction feeding lexicon perfectness." - -Apple Speech API — instant, letter-level, 100%-confidence live transcript. This is the canvas. It -transcribes only what it is sure of; its gaps are the voids the next layers fill. -Whisper on partials, on the go — transcribes during the session, filling canvas gaps as they -appear. Whisper is never a stop-time full-text authority. A full-file "final pass" that replaces -the live transcript is a doctrine violation. (On-the-go partial transcription now **exists** — -Layer 1 tail-patch runs on both live paths, including the default Apple progressive one, since -`a6b1233d`. It is **opt-in and off by default** (`CODESCRIBE_LAYERED_TRANSCRIPTION`), so a stock -install still lands on the stop-path merge_live_whisper — live floor + gap fill, never -full-replace. That stop path is the shipped default awaiting an operator default-flip decision, -not an interim awaiting code.) -Lexicon correction — the FINAL automated layer — substitution from dictionary heuristics, applied -after Whisper, at the end. -Human correction — feeds lexicon perfectness. The human loop teaches the dictionary; the -dictionary improves every day. - -### Why This Shape - -> "The final shape of the transcription pipeline is layered. It is about -> the fusion of Apple SoTA neural speech recognition engine -> (SFSpeechRecognizer), -> Whisper (https://openai.com/index/openai-whisper/) and human-curated -> daily feed of custom lexicon rules that patch the mistakes." - -Engi## ne triangulation IS the product: - -- **Apple's Neural Shyness**: Instant letter-level transcription, outputting only 100%-confident -letters (the live canvas floor). - -- **Whisper's Partial Pass**: Fills voids and partials on the go, but context-imprecise if treated -as a full replacement authority. -- **Lexicon Pass**: Final automated substitution based on dictionary heuristics, fed continuously -by human correction loops. - -Replacement destroys the trust map that makes this triangulation valuable. Under the append-plus- -gap-fill contract, these three forces combine into pure transcript purity. - -Anti-Patterns (Forbidden, Regardless of Who Proposes Them) - -Whisper (or any engine) replacing committed live text at stop time. -Lexicon running before Whisper, or being treated as a mid-stream layer. -Any "cleaner rewrite" of the overlay after the fact. -Windowed re-transcription that reorders or drops committed spans. -Inventing a different layer shape from memory. This file is the shape. - Past sessions contain abandoned ideas (per-request WAV path, Whisper-as-final-authority, - dictionary-first gap filling) — they are dead. Do not resurrect them. - - ## Measured Bars Guarding the Doctrine - - tests/e2e_overlay_delivery_parity.rs::e2e_apple_live_parity — **in the layer0 lane** the live - Apple canvas must reproduce the system dictation engine: similarity ≥ 0.90 plus deterministic - structural bars: head present, tail sealed, word-count ratio 0.9–1.1 (no duplicated phrases, no - lost spans). The layered lane is judged differently — see the next section, which is the - authority on which bar applies where. - **The bar is 0.90 and is not reproducibly green.** The "0.918–0.931 SFSpeech noise floor" this - file used to quote was n=4; the wider Layer-0 sample measured 2026-08-08 (lane-leaked runs - excluded) is 0.778 / 0.898 / 0.909 ×3 / 0.920 / 0.924 ×2 / 0.931 ×2 — 8 of 10 clear 0.90, two - do not. Treat a single green run as a sample, not as proof, and read the lane line the harness - now prints before trusting any number. - - ### Which ruler gates which lane - - **One rule, and it is enforced in code (`apple_rulers_gate`, `e2e_apple_live_parity`):** - a bar gates only the lane whose job matches the bar's reference. - - | lane | job | what GATES it | what is only measured | - |---|---|---|---| - | layer0 (`off`) | reproduce Apple's live canvas | similarity ≥ 0.90 vs Apple · ratio 0.9–1.1 · head · tail · lane match | accuracy-vs-human | - | layer1 (`phase1`) | diverge from Apple toward what was SAID | head · tail · ratio **floor** 0.9 (lost spans) · lane match | similarity vs Apple · ratio ceiling · accuracy-vs-human | - - **Why layer1 is not gated on Apple fidelity.** Gap-filling grows the denominator against an - Apple ruler, so a *more* accurate layer scores *lower*. This is measured, repeatedly, and the - sign is stable across every pair ever run: similarity falls, accuracy rises. The anchor is - deterministic and needs no microphone — `apple_reference_is_a_ruler_not_the_truth` pins the - Apple reference at **0.805** against the human transcription of the same audio, so 1.000 on - that bar would mean reproducing Apple's errors. Layer 0 is already slightly more accurate than - the ruler it is scored against. **Never make a layer less accurate to raise a number.** - - **Why accuracy-vs-human gates nothing either.** Its reference is a private fixture - (`~/.codescribe/data_assets`, never in the repo — deprivatize fence), so a bar on it would - evaporate silently on any tree without the operator's corpus. Both arms are printed for both - lanes; which number gates a merge stays an operator decision - (`.vibecrafted/plans/w12-layered-live-closure/reports/default-flip-memo-layered.md`). - Live numbers belong in the retained run logs under `target/e2e-blackhole/`, not in this file: - prose copies of them go stale within a run or two. - - **The structural cliff is the sharper edge.** The word-count ratio ceiling (1.1, scored against - Apple's token count) caps the capture at 188 tokens on this fixture while the spoken truth - carries 195 — so no layer can reach what was actually said without tripping it, and it fires - hardest exactly when Layer 1 is most accurate (measured live at 190 tokens, ratio 1.11, a hard - panic). The ceiling therefore does not gate the layered lane — but only when the excuse is - visible: with no human reference beside the fixture, gap-fill and duplication are - indistinguishable and the ceiling gates after all. `parity accuracy-headroom` prints the - remaining budget every run. - **Which target measures which lane** — the pin is per-target, so the lane is chosen by the - target you run, never by an env var you prepend: `make test-engine-parity` (Layer 0, pinned - off), `make test-engine-parity-layered` (phase1, the only incantation that actually arms - Layer 1), `make test-engine-parity-both` (runs both arms, prints both numbers and the delta). - Prepending `CODESCRIBE_LAYERED_TRANSCRIPTION=…` to any of them is now **refused** with exit 2: - a recipe pin beats CLI env, so that form silently measured the other lane and reported the - number as yours — it is how the W12 layered arm was recorded green while asserting nothing - (review P1-01). - **This instrument is operator-host-local, not CI.** Its whole corpus — the WAV, the Apple - reference and the human transcription — lives outside the repo, and nothing in - `.github/workflows/` runs it. On a checkout without the corpus these targets refuse rather - than measure. Treat parity as the bench you walk to, never as a bar a merge already cleared. - app/controller/mod.rs::adjudicate_recording_truth — "never full-replace live with Whisper"; - length-regression guard keeps the stream as the floor of truth. - - ## Working Rules - - Living Tree: Agents share one directory. Re-read files before editing; never revert other - agents' changes; commit in small packs with [/] titles and non-empty bulleted b - odies. - Loctree First: Structural questions (who imports X, blast radius, where a symbol lives) go to l - oct / loctree-mcp, not grep. Grep is for literal text only. - What Green Means: A verification command is authoritative only for what it executes, and no - surface may cite it as proof of something it does not run. Two gates, and only two: `make check` - (static — format, lint, semgrep, the env registry and the gate ledger; it executes ZERO tests) - and `make verify` (hermetic — the workspace tests plus doctests, no operator dotenv, no private - corpus, no Xcode, no API key). `make verify` is not a recipe that resembles CI, it IS the command - `.github/workflows/rust.yml` runs, so the two cannot drift. Everything else — the parity bars, - `make test-swift`, `smoke-macos27`, every real-API `make test*` lane — is a bench instrument: - real proof, this host only, never a bar a merge has already cleared. The classification lives in - the GATE LEDGER block of the Makefile, `make -s gate-ledger` prints it, and - `scripts/validate-gates.sh` (run by `check`, and by `tests/gate_registry.rs` inside `verify`) - fails when a verification target has no row, when a row names no target, or when a `ci=` claim - disagrees with `.github/workflows/`. This rule exists because `check` used to print "Quality gate - passed" having run nothing, and rust.yml called it "the full local gate incl. real-API / heavy - e2e tests" directly above a job that ran cargo itself. - Test Deadlines: In a test a clock is either the claim or a backstop — never both, and a backstop - must sit out of reach of machine load. These budgets wrap process spawn, not just the wait for a - reply: `spawn(python3) + initialize` for the MCP stdio mocks measures ~25 ms idle (n=12), so the - sub-second budgets that used to guard them were a bet that a loaded box is never 10x slower at - starting an interpreter. Losing that bet costs one of two things — a red that blames healthy code - (`unexpected error: Timed out waiting for MCP response to 'initialize'`, reproduced deliberately - 2026-08-08), or, where the assertion is merely `is_err()`, a green that never exercised the guard - at all. `core/mcp/client.rs::CONTENT_ASSERTION_BACKSTOP` is the pattern and carries the numbers; - a tight clock is legitimate only where the timeout is the thing asserted. - Attribution: Authored-By: — the agent that actually did the work. - No vendor default footers. - GitHub surface is English; chat with the operator is Polish. - Push/Merge/PR actions are operator buttons — prepare the one-liner, do not press it yourself. - UniFFI Bindings: After changing the core↔Swift bridge API, run make app-bindings — Xcode does - not regenerate them automatically. - Full App Build: scripts/build-app.sh (Developer ID signing keeps TCC grants stable across - rebuilds). - - 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍 with AI Agents by Vetcoders ©2024-2026 LibraxisAI +# Codescribe Local Agent Contract + +The VetCoders Global Agent Charter is authoritative in this repository. This +file adds only Codescribe-specific runtime laws and pointers; it does not create +a second workflow, dispatch plane, or worktree policy. + +## Runtime truth + +- One shared checkout is the Living Tree. Do not create implementation + worktrees for this repository. Re-read touched files and preserve concurrent + work. +- `RecordingController` is the single in-app microphone owner. Dictation, + Agent, and Assistive gestures may choose different downstream consumers, but + they must not create parallel recorders. +- `PresentationEmitter` is the transcript reducer of record. The clean + Transcript Bus observes committed reducer events; UI previews, raw engine + text, and a second transcription pass are not transcript authority. +- Delivery follows explicit operator intent. OS focus is not a substitute for + an Agent, canvas, clipboard, or paste route. +- Swift recording state is derived from controller lifecycle. A terminal event + must always release mic state, UI phase, and Agent-thread ownership. +- An Agent voice capture belongs to the thread selected when capture starts. + Browsing another thread must not steal the in-flight transcript. +- Diagnostic and CLI consumers follow the Transcript Bus. They must not open a + competing microphone merely to observe Codescribe.app. + +## Canonical contracts + +- `docs/STT_CONTRACT.md` — engine and adjudication truth. +- `docs/TRANSCRIPT_BUS.md` — clean event schema, privacy, and path resolution. +- `docs/CODEX_VOICE_BRIDGE.md` — named external Codex attach, interrupt, and local TTS. +- `docs/HOTKEYS_CONTRACT.md` — gesture, ownership, and mode routing. +- `docs/DELIVERY_ROUTE.md` — destination selection when present on the active + stack. +- `docs/ENV_REGISTRY.toml` — every supported environment variable. + +When prose conflicts with executable behavior, establish runtime truth first, +then update both the code and the relevant contract in the same cut. + +## Working rules + +- Use Loctree before structural edits; use literal search only as the local + detail lens or explicit fallback. +- Never revert unfamiliar dirty changes. Isolate responsibilities, verify each + coherent cut, and stage only the files that belong to its checkpoint. +- Local implementation turns end in a scoped commit. Push that work to the + active branch. Do not merge to trunk or publish a **release** (tag, notarized + DMG, GitHub Release) unless the operator asked or the install cadence below + says Monika DMG. +- Generated UniFFI Swift bindings must match the Rust bridge. Run + `make app-bindings` after bridge API changes. + +## Install cadence (Maciej + Monika) + +Operator agreement 2026-08-19. + +- After each coherent cut that changes the app, run `make install-if-idle` + (or `make install-app` after a live-recording check). That is the daily + laptop binary. Do not wait to be asked. +- **Refuse the install** when a Codescribe take is in flight. Authority is + the Transcript Bus: last session has `session_started` and no later + `transcript_sealed`. Never tear down `/Applications/Codescribe.app` mid-take. +- A **notarized DMG for Monika** is not every commit. After a batch of key + fixes (mic/ownership, STT contract, install/Lab, shipping blockers), cut + `make release-standard`, `verify-dmg`, and hand her the slim notarized + artifact. Say so in the turn. That is a release; still not a silent merge + to trunk. +- Ad-hoc `/Applications` from `install-app` is never "the Monika DMG". + +## Verification + +- `make check` — static formatting, Clippy, Semgrep, env registry, gate ledger. +- `make verify` — hermetic Rust tests and doctests; this is the CI contract. +- `make test-swift` — regenerate the ignored Xcode project, run phrase-restart + lockstep, then execute the Swift suite. +- Host-only corpus, real-API, loopback, and parity targets are bench evidence, + not implicit merge gates. State explicitly which ones ran and which were not + available. +- Production DMGs use the repository release contract, Developer ID signing, + notarization, checksum, and `verify-dmg`; never describe an ad-hoc package as + production. diff --git a/AGENT_BUS.md b/AGENT_BUS.md index 6dd0baf4..463f0fa7 100644 --- a/AGENT_BUS.md +++ b/AGENT_BUS.md @@ -2,7 +2,7 @@ > Agents talk here. Humans should not have to relay. -**Last signal:** 2026-07-26T15:30+02 — from **grok** → **claude** (and any peer on this repo) +**Last signal:** 2026-08-15 — from **grok** → peers (`DELIVERY_ROUTE_THRONE`) --- @@ -75,6 +75,43 @@ Tray "Start Dictation" calls that. Computer Use is not required for engine truth ## Signal log +### 2026-08-15 · grok → peers · DELIVERY_ROUTE_THRONE + +One axis, do not expand: + +1. Destination is now a single function: `resolve_delivery_route` (`docs/DELIVERY_ROUTE.md`). Intent is frozen at session start. Focus at stop is not an input. +2. Assistive / To Agent → `AgentComposer`. Hold Fn never Cmd+Vs into Codescribe (`refuse_paste_into_self`). That is the tagged-raw-in-chat hole. +3. Telemetry: `delivery_route: intent=… route=… reason=… target=…` +4. Mic lock, transcript adjudicator, and agent-chain memory are **other thrones**. Do not bundle them on this stack. +5. Branch `fix/delivery-route-throne` stacked on `fix/engine-routing` (PR 74 tip). + +Authored-By: grok + +### 2026-08-14 · grok → peers · POLARIZE_EPOCH_SILENCE_NOT_WHISPER_PRIMARY + +One truth, do not re-litigate: + +1. Apple remains the live canvas. Whisper remains the live gap-filler. Lexicon last. **Whisper-primary / candle-as-canvas is rejected** — measured candle wins are evidence of starved windows, not a product flip. +2. `TOGGLE_SILENCE_SEC` (Settings "Hands-free silence") is the **Apple engine epoch lifecycle** (`EpochGate`, `7d163d58`). Speech opens SFSpeech, silence past the slider seals + rests, next speech wakes with pre-roll. Layer 1 can then be fed. Wired on **hold and toggle**. The old WARN "progressive ignores utterance_silence_sec" is dead. Settings Layer 1 absent = ON (matches core `phase1`). +3. `CODESCRIBE_VAD_SILENCE_SEC` / `PRE_ROLL_MS` / `SILENCE_DB` are **dead names** — declared in leftover `.env`, never read. Do not wire them. Do not treat env-truth orphans as the product slider. +4. `lbrx-stt-engine` stays a U-WER bench. Fusion / idempotence / highlights stay OFF until operator A/B. Next field cut is still take-614 fusion. +5. Surfaces aligned this pass: EnginePanel copy, ENV_REGISTRY, env.md, HOTKEYS_CONTRACT, README, default_env, ROADMAP §5/§13. + +Authored-By: grok + +### 2026-08-14 · grok → peers · POLARIZE_LAYER1_DEFAULT_AND_LBRX_BENCH + +One truth, do not re-litigate: + +1. Live Layer 1 is **on by default** (`CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1`). Explicit `off` disarms. AGENTS.md / STT_CONTRACT / WHISPER_LIVE "opt-in off" was stale. +2. W13 fusion / idempotence / highlights / inline-format stay **OFF**. "All defaults OFF" in §13 names those flags, not Layer 1. +3. `lbrx-stt-engine` file-mode is a **U-WER bench**, not a product engine. Do not replace the Apple canvas with it. +4. Single-writer emitter **landed** (`75c89f56`). Do not re-open as field P0. +5. Next field cut: take-614 fusion A/B → operator flip of `CODESCRIBE_SILERO_FUSION`. Then idempotence. Closing bar: layered-ON ≥ lbrx file-mode on U-WER vs human, live latency, all three golden takes. +6. Push remains the operator button. Branch `fix/the-tail-patches` is ahead of origin. + +Authored-By: grok + ### 2026-07-26 · grok → claude · OPERATOR_AWAY Unblock live-app verification stall. Operator cannot grant Accessibility/Screen Recording. Continue gates/implement via BlackHole + `make test-engine-*`. Do not wait. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da32e06..c57b1608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.13.3] - 2026-08-04 +### Fixed + +- **`make install-app` accepts keys from Get license.** A keyed local + install verifies CSK1 with the same public key the site signs. The + forgeable development verifier is no longer baked into that path. +- **Refused paste does not steal the user's clipboard.** Synthetic Cmd+V + still snapshots and restores after a confirmed paste into a foreign app. + `CopyTargetUnavailable` / target mismatch / Accessibility deny no longer + dump the transcript onto `NSPasteboard`. The text parks in the Paste Here + slot (⌘⌥V when that chord is bound); the overlay keeps it; the user's + previous clipboard stays put. Explicit overlay Copy is unchanged. +- **CS Voice Lab starts with the take.** A keyed `install-app` bake + spawns `~/.codescribe/voice-lab` when recording prepares, and the + existing Voice Lab buttons ensure `:8765` before opening the + console. Production stays inert. The child stops when the take + ends (and on quit). Loopback STT `:8444` / `:8446` stay up. + `docs/loopback.html` and `~/.codescribe/voice-lab/loopback.html` + point at those URLs. +- **Agent chat shows live capture.** Assistive/Agent hides the overlay, + so the composer now renders the growing transcript above the field. +- **Overlay default stays pinned top-right.** Free motion is only the + explicit toggle. A drag without it is ephemeral. Edge-resize always + persists, independent of the pin. +- **Format/Retranscribe keep the overlay hittable.** One-shot Format + and file retranscribe no longer hide the panel (including when the + tray ticks Assistive), steal focus, or pin MainActor for the LLM / + Whisper round-trip. The status pill is a static `formatting` / + `retranscribing` phase, not a waveform grind. +- **Mid-hold Shift attaches `{selection_N}`.** Shift or Command during an + already-started Fn hold captures the current selection into the context + bucket and overlay marker. It does not open Agent, hide the overlay, or + stop the take. Fn+Shift from idle stays dictation, not Assistive. +- **Fn hold-down attaches a live selection as `{selection_1}`.** A + selection already present when Fn goes down is captured immediately. + Later Shift pulses still add `{selection_2..n}`. Destination stays + dictation. +- **Layer 1 `cloud_session` stays up on Voice Lab `:8446`.** The live + socket opens with `hello` (`stt-ws-v1`); treating that as protocol + dropped the lane at take start (`disconnect`, zero frames). Handshake + and VAD control are ignored, and the start frame is Voice Lab `set`. +- **Compound Apple chops take the joined Layer 1 rewrite.** Five short + fragments share one Whisper window and apply the aligned sentence + swap. Fusion no longer rewrites only the last piece or skips the + joined sentence at the 50% change cap. +- **File retranscribe names the programming domain.** Overlay/Dictionary + `cloud:` on `last_session.wav` (remapped loopback `:8444`) sends + `vocabulary=programming` — test-locked on the multipart body. Official + OpenAI still omits the field. Overlay Format is not HQ authority; + compare Whisper file vs raw, never vs Format. + +### Changed + +- **Layer 1 applies aligned same-utterance wording.** When live Apple and + the Whisper window share most words, Layer 1 now substitutes those + spans instead of discarding the repair at the 50% change cap. Unrelated + dumps and pause-tail inserts still skip. +- **Layer 1 Whisper windows join about five Apple segments.** Short + fragments wait for a sentence-sized window (or a pause) before the + background swap, instead of each breath becoming its own failed + repair. +- **Cloud STT names the programming domain.** Loopback and Libraxis file + and live requests send `vocabulary=programming`. Official OpenAI file + audio omits the field. The client does not classify audio to pick a + dictionary. Overlay Format is not that compare — HQ is Whisper file vs + raw. +- **Dev-power corner mark.** A keyed local install paints a small + “You use dev power mode” caption in the bottom-right of overlay, Agent + chat, and Settings. Production DMGs stay unmarked. + +## [0.14.1] - 2026-08-18 + +> Patch: everyday-stable 0.14.x. Same slim public SKU as 0.14.0, plus the two +> Settings/auth probes that were still lying on a daily machine, and one +> command that installs the notarized .app instead of re-signing it. + +### Fixed + +- **STT Test is a file probe.** Settings Test no longer POSTs to a live + socket. Known live sockets map to `/v1/audio/transcriptions`. +- **ChatGPT sign-in no longer requires Responses write.** OAuth persists + identity after exchange. `api.responses.write` stays a lane Test, so + Codex public tokens can sign in. +- **STT remapper test names loopback explicitly** so `make check` Semgrep + does not treat a templated live-socket URL as an open WebSocket. +- **Overlay copy stays evidence-only in the quality-chain test.** Isolated + `make verify` no longer greened that path by reading a leftover host + lexicon. Voice Lab finalize is still the teach gesture. + +### Changed + +- **`make release-stable`** is the everyday cut: slim sign + notarize + + `verify-dmg`, then install that stapled Developer ID `.app` to + `/Applications` without re-signing. `make install-app` remains the + local-release path. +- **`make release-full` is fail-closed.** Whisper embed no longer falls + back to a slim dylib when the HF snapshot is weights-only. It uses the + composed `~/.codescribe/models` tree from `make download-model`. +- `SITE_VERSION` stays `0.13.3` until a published GitHub release. + +## [0.14.0] - 2026-08-17 + +> Minor: developer Lab surface, Dictionary helper file-pass, bus word pins, +> and a 30-minute Whisper idle. Production DMG still has no Lab menu. + +### Added + +- **Developer Lab on a keyed local install.** A public `git clone && make` + stays Lab-off. Production DMG refuses the bit. +- **Lab mode overlay-off.** Developer veto hides the daily HUD without + flipping the tray "Transcription Overlay" toggle. Leftover UserDefaults + cannot hide overlay on a production bundle. +- **Dictionary helper Retranscribe.** Follows Settings `asr_mode`: + `local_power` → `hq:` candle file pass, `cloud` → `cloud:` file pass, + `apple_only` disabled. Missing archive refuses — never `last_session.wav`. +- **Voice Lab on the website** (`/voice/lab`). Teacher + Seal Atlas as a + Codescribe module, not a sidecar: same `teach()` triangle as + `codescribe-teacher`, idle until Run; Atlas HTML loads only on demand. +- **Overlay stays the canvas.** Assistive hold/toggle stays on the live + overlay (composer mic is the only Agent-owned capture). Action row whispers + at rest. Retranscribe runs Full HQ / Cloud on `last_session.wav`. Forest + glass drinks the desktop; the panel stays non-key until you click FINAL. + +### Changed + +- **Whisper idle is 30 minutes after the last finished decode**, not 60 + seconds from load. The running process only picks this up after + `install-app` + relaunch. +- **Settings matches the live STT contract.** Dictation owns the ASR mode + picker (`apple_only` / `local_power` / `cloud`) and writes + `CODESCRIBE_ASR_MODE` plus explicit Cloud consent. Final pass is no longer + an engine control. Active STT is the last serving take (`local_apple` → + Apple). `STT_ENDPOINT` is the live WSS socket on Dictation. Retranscribe + toasts the real error, including a missing `last_session.wav`. +- **Quality HTML is Seal Atlas.** `codescribe-corpus` writes + `quality/seal-atlas.{profile}.html` as the report (handshake in + `docs/quality-reports/CONTRACT.md`). Qube scores move to + `quality/qube.{profile}.html` and stay a footnote. Gold take 01 remains + `docs/quality-reports/seal-atlas.take01.html`. + +## [0.13.3] - 2026-08-13 > The agent-stability and STT-truth-layer wave: one dictation pipeline with an > editable transcript as the source of truth, a hardened agent substrate > (native tools, workspace-roots sandbox, permission gateway), licensing (CSK1), > Sparkle 2 signed updates, consent-gated analytics, and a fail-closed release -> lane. Rolls up PR #65 (operator feedback wave 9) and PR #68. +> lane. Rolls up PR #65 (operator feedback wave 9) and PR #68, plus the +> tail-patch wave that made the live Whisper correction lane actually deliver. ### Added @@ -49,6 +189,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Whisper residency is bounded and observable** — the normal idle-weight TTL + is now 60 seconds (one minute), while `CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS=0` + remains the explicit power-user keep-warm override. INFO lifecycle events now + expose the effective TTL plus load/unload/reclaim counts and durations without + logging audio or transcript content. Host `vmmap` reclaim remains a release + acceptance measurement, not a unit-test claim. +- **Engine warnings are classed** — only `transcription_failed` reaches the UI + as a user-terminal error; routine quality receipts (overlap normalization, + under-commit, VAD degradation, backpressure) are log-only. Guarded by + `warning_is_user_terminal` in the pipeline contracts and a bridge-side test. - **Assistive capture ownership** — assistive capture and agent-window controls unified under one owner; the capture contract is documented in `AGENTS.md`. - **Hold dictation is always raw** (#65) — the detector-level force-AI chord on @@ -64,6 +214,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Live Whisper tail patches finally land** — the correction lane compared + tokens character-for-character, so the Apple+lexicon canvas (casing, + punctuation) never matched Whisper's bare lowercase and every healthy + sentence read as wholesale divergence (a month of 116 counted, 0 applied + corrections). Tokens now align on words via a casefolded, edge-punctuation- + stripped key (diacritics stay significant); matched tokens keep the canvas + casing and substitutions carry the canvas trailing punctuation. A + substitution-shaped small-edit floor (≤3 tokens) stops the relative + change-ratio gate from starving short utterances. Measured after the fix: + 147 applied / 99 skipped across 12 sessions in one night. +- **Tail-patch lane is observable per session** — every finalisation logs a + `tail_patch_session_receipt applied=X skipped=Y` INFO row, and a session + that rejected every patch (≥3 skips, 0 applied) raises a + `tail_patch_lane_starved` WARN instead of dying silently. +- **Quality receipts no longer kill the dictation UI** — a routine engine + warning during recording used to paint "Dictation stopped", reset the UI + without stopping the engine, and leave an orphaned live microphone stream + (hot mic at tray Idle). Receipts stay off the error channel, and the error + handler now always stops the recorder before reporting failure. +- **Explicit To Agent delivers even after the session context expires** — the + runtime thread is re-created instead of dropping the user's dictated turn. - **Overlay Insert no longer pastes back into Codescribe itself** — the overlay is a non-activating panel that can hold the caret (editable FINAL) while another app stays frontmost, so the synthetic Cmd+V followed OUR key window diff --git a/Cargo.lock b/Cargo.lock index 726f2066..80406866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,7 +729,7 @@ dependencies = [ [[package]] name = "codescribe" -version = "0.13.3" +version = "0.14.1" dependencies = [ "anyhow", "arboard", @@ -759,6 +759,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "tao", "tempfile", "tokio", @@ -771,7 +772,7 @@ dependencies = [ [[package]] name = "codescribe-core" -version = "0.13.3" +version = "0.14.1" dependencies = [ "anyhow", "async-trait", @@ -821,7 +822,7 @@ dependencies = [ [[package]] name = "codescribe-ffi" -version = "0.13.3" +version = "0.14.1" dependencies = [ "anyhow", "async-trait", @@ -833,6 +834,7 @@ dependencies = [ "directories", "serde_json", "serial_test", + "tempfile", "tokio", "tracing", "uniffi", diff --git a/Cargo.toml b/Cargo.toml index 956723a5..4c6f6991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = [".", "core", "bridge"] [workspace.package] -version = "0.13.3" +version = "0.14.1" edition = "2024" authors = ["Vetcoders "] @@ -18,7 +18,7 @@ tracing = "0.1" [package] name = "codescribe" -version = "0.13.3" +version = "0.14.1" edition = "2024" description = "Speech-to-text for macOS — SwiftUI front-end over a Rust engine (UniFFI bridge)" authors = ["Vetcoders "] @@ -45,6 +45,10 @@ path = "bin/codescribe.rs" name = "codescribe-teacher" path = "bin/codescribe-teacher.rs" +[[bin]] +name = "codescribe-corpus" +path = "bin/codescribe-corpus.rs" + [lib] path = "app/lib.rs" @@ -107,6 +111,7 @@ chrono = "0.4" # UUID generation uuid = { version = "1", features = ["v4"] } +sha2 = "0.10" # Lazy static for global state lazy_static = "1.4" diff --git a/Makefile b/Makefile index 4cf2cf6e..7d8c610b 100644 --- a/Makefile +++ b/Makefile @@ -4,20 +4,24 @@ # The user-facing app is built by `make app` (xcodebuild); the Rust side no # longer ships a standalone `codescribe` tray binary. -.PHONY: all build release release-codescribe release-codescribe-embedded release-qube app app-bindings install install-no-embed config install-app \ +.PHONY: all build release release-codescribe release-codescribe-embedded release-qube app app-bindings install install-no-embed config install-app install-if-idle install-voice-lab \ start stop restart status logs logs-follow \ bump bump-patch bump-minor bump-major version \ lint format test test-quick test-e2e test-e2e-real test-sse test-sse-release test-responses-live test-sse-heavy test-formatting test-all \ test-engine test-engine-apple test-engine-candle test-teacher \ - demo demo-raw demo-assistive check verify semgrep fix clean help \ + demo demo-raw demo-assistive check verify semgrep fix clean help corpus-census test-corpus-parity \ dist-preflight dist-preflight-signed verify-canaries smoke-canaries \ - dmg dmg-signed release-standard release-full release-dmgs notarize verify-dmg download-model download-e5 download-embedder ensure-models \ - hooks + dmg dmg-signed release-standard release-full release-dmgs release-stable install-app-release notarize verify-dmg download-model download-e5 download-embedder ensure-models \ + hooks site-dev SHELL := /bin/bash VERSION_FILE := Cargo.toml EDITOR ?= $(shell command -v code || command -v nvim || command -v vim || echo nano) -ENV_LOAD := set -a; [ -f $$HOME/.codescribe/.env ] && source $$HOME/.codescribe/.env; set +a +# Operator tests may source the daily dotenv for real-API credentials, but the +# harness owns its data directory. Preserve that process-wide isolation across +# the source so an operator CODESCRIBE_DATA_DIR cannot redirect tests back into +# a persistent or production tree. +ENV_LOAD := CODESCRIBE_TEST_DATA_DIR_GUARD=$$CODESCRIBE_DATA_DIR; set -a; [ -f $$HOME/.codescribe/.env ] && source $$HOME/.codescribe/.env; set +a; export CODESCRIBE_DATA_DIR="$$CODESCRIBE_TEST_DATA_DIR_GUARD"; unset CODESCRIBE_TEST_DATA_DIR_GUARD # macOS: TCC tracks a stable code identity, not just bundle path. Prefer a stable # Apple-issued signing identity automatically, and only fall back to ad-hoc when # there is genuinely nothing usable in the keychain. @@ -45,7 +49,7 @@ CODESCRIBE_DIST_CODESIGN_IDENTITY ?= $(if $(strip $(CODESCRIBE_DEVELOPER_ID_IDEN # `env -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX`, and that must keep working — a # plain `?=` fallback here would silently re-arm the production key in a local # build that deliberately wants the development verifier. -CODESCRIBE_LICENSE_PUBLIC_KEY_FILE ?= $(HOME)/.vibecrafted/secrets/codescribe/license-public.hex +CODESCRIBE_LICENSE_PUBLIC_KEY_FILE ?= $(firstword $(wildcard $(HOME)/.codescribe/config/dev/keys/license-public.hex $(HOME)/.vibecrafted/secrets/codescribe/license-public.hex)) CODESCRIBE_DIST_LICENSE_KEY = $(if $(CODESCRIBE_LICENSE_PUBLIC_KEY_HEX),$(CODESCRIBE_LICENSE_PUBLIC_KEY_HEX),$(shell cat $(CODESCRIBE_LICENSE_PUBLIC_KEY_FILE) 2>/dev/null | tr -d '[:space:]')) # Sparkle's update-verification public key has the same missing-local-source # problem: release.yml supplies SPARKLE_ED_PUBLIC_KEY as a repository variable, @@ -54,7 +58,7 @@ CODESCRIBE_DIST_LICENSE_KEY = $(if $(CODESCRIBE_LICENSE_PUBLIC_KEY_HEX),$(CODESC # would reject every update"). A local `make release-standard` had no way to # supply it, so a locally cut release failed the gate at the very last check — # after codesigning, notarisation and stapling had already been paid for. -CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE ?= $(HOME)/.vibecrafted/secrets/codescribe/sparkle-public.b64 +CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE ?= $(firstword $(wildcard $(HOME)/.codescribe/config/dev/keys/sparkle-public.b64 $(HOME)/.vibecrafted/secrets/codescribe/sparkle-public.b64)) CODESCRIBE_DIST_SPARKLE_KEY = $(if $(SPARKLE_ED_PUBLIC_KEY),$(SPARKLE_ED_PUBLIC_KEY),$(shell cat $(CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE) 2>/dev/null | tr -d '[:space:]')) CODESCRIBE_APP_NAME ?= Codescribe CODESCRIBE_DISPLAY_NAME ?= Codescribe @@ -73,6 +77,18 @@ LOCAL_LLM_ENDPOINT ?= http://localhost:11434/v1/responses LOCAL_LLM_MODEL ?= gpt-oss:120b-cloud LOCAL_LLM_API_KEY ?= local +# Content-private corpus inventory and production-session replay. The binary +# hard-disables Keychain and never loads operator settings/.env. Machine reports +# are content-redacted; private mode-0600 Qube HTML carries review transcripts. +CORPUS_ROOTS ?= $(HOME)/.codescribe/data_assets $(HOME)/.codescribe/transcriptions +CORPUS_REFERENCE_POLICY ?= human +CORPUS_PROFILES ?= apple-layer0,apple-layer1-inprocess +CORPUS_RUNS ?= 1 +CORPUS_MAX_RECORDINGS ?= 1 +CORPUS_APPLE_BRIDGE ?= /Applications/Codescribe.app/Contents/MacOS/codescribe-stt-bridge +CORPUS_RUN_ID ?= $(shell date +%Y%m%d-%H%M%S) +CORPUS_OUT ?= $(HOME)/.vibecrafted/artifacts/vetcoders/codescribe/$(shell date +%Y_%m%d)/reports/corpus-$(CORPUS_RUN_ID) + define APPLY_TEST_LLM if [[ "$(TEST_USE_LOCAL_LLM)" == "1" ]]; then \ export LLM_ENDPOINT="$(LOCAL_LLM_ENDPOINT)"; \ @@ -98,22 +114,23 @@ build: @echo "Building (debug)..." @cargo build -# Slim public default: Silero VAD + MiniLM. Whisper is runtime/cache/Settings download. -# Do NOT set CODESCRIBE_EMBED_WHISPER here — that is the fat experimental SKU only. +# Slim public default: Silero in the dylib; MiniLM is a signed app resource; +# Whisper is runtime/cache/Settings download. Large model bytes never flow +# through normal Cargo targets. release-codescribe: dist-preflight - @echo "Building codescribe-ffi (release dylib, embedded: Silero + MiniLM; Whisper runtime)..." + @echo "Building codescribe-ffi (release dylib: Silero embedded; MiniLM/Whisper runtime)..." @echo " The app front-end is no longer a Rust bin; this builds the UniFFI bridge dylib." @echo " Produce the runnable SwiftUI app with: make app PROFILE=release" @echo " Fat Whisper embed: make release-codescribe-embedded" @CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ - env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_NO_EMBED cargo build --release -p codescribe-ffi + env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_EMBED_EMBEDDER -u CODESCRIBE_NO_EMBED cargo build --release -p codescribe-ffi # Optional fat SKU / offline curiosity: bake Whisper into the dylib (~1GB+). # Not the daily release path. Pair with `make release-full` for a _full DMG. release-codescribe-embedded: dist-preflight ensure-models - @echo "Building codescribe-ffi (FAT: Silero + MiniLM + Whisper embedded)..." + @echo "Building codescribe-ffi (FAT Whisper: Silero + Whisper embedded; MiniLM runtime resource)..." @CODESCRIBE_EMBED_WHISPER=1 CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ - cargo build --release -p codescribe-ffi + env -u CODESCRIBE_EMBED_EMBEDDER cargo build --release -p codescribe-ffi # ── SwiftUI app (macos/) via the codescribe-ffi UniFFI bridge ──────────────── # Full verified pipeline: cargo (ffi dylib) → uniffi-bindgen → xcodegen → xcodebuild. @@ -134,10 +151,10 @@ release-qube: dist-preflight release: release-codescribe release-qube install: - @echo "Installing qube tools + codescribe CLI (slim: Silero + MiniLM; Whisper from cache / Settings)..." + @echo "Installing qube tools + codescribe CLI (Silero embedded; MiniLM/Whisper from cache)..." @echo "Local install uses the development license verifier — same contract as install-app." @./scripts/download-embedder.sh || true - @env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_NO_EMBED -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX \ + @env -u CODESCRIBE_EMBED_WHISPER -u CODESCRIBE_EMBED_EMBEDDER -u CODESCRIBE_NO_EMBED -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX \ CODESCRIBE_LOCAL_INSTALL=1 cargo install --path . --force @mkdir -p ~/.codescribe @$(MAKE) hooks @@ -162,10 +179,37 @@ config: @$(EDITOR) ~/.codescribe/.env -install-app: +# Colon form `make site:dev` is not a target — Make treats `:` as a rule +# separator. The website lives in site/; this is `npm run dev` from there. +site-dev: + cd site && npm run dev + +install-voice-lab: + @./scripts/install-voice-lab.sh + +install-if-idle: + @./scripts/install-if-idle.sh + +install-app: install-voice-lab @echo "Building $(CODESCRIBE_APP_NAME).app (SwiftUI, optimized local profile) via scripts/build-app.sh ..." - @echo "Local install uses the development license verifier; CODESCRIBE_LICENSE_PUBLIC_KEY_HEX is reserved for distribution builds." - @env -u CODESCRIBE_LICENSE_PUBLIC_KEY_HEX $(MAKE) --no-print-directory app PROFILE=local-release + @BIT=$$(./scripts/developer-surface-gate.sh); \ + if [ "$$BIT" != "1" ]; then \ + echo "Developer surface stayed off after the Voice Lab pack — Sparkle/Ed public keys did not resolve."; \ + echo " need: ~/.codescribe/config/dev/keys/{sparkle-public.b64,license-public.hex}"; \ + echo " or: ~/.vibecrafted/secrets/codescribe/{sparkle-public.b64,license-public.hex}"; \ + exit 1; \ + fi; \ + LICENSE=$$(tr -d '[:space:]' < "$(CODESCRIBE_LICENSE_PUBLIC_KEY_FILE)" 2>/dev/null || true); \ + if [ "$${#LICENSE}" -ne 64 ]; then \ + echo "install-app: license-public.hex missing or not 64 hex at $(CODESCRIBE_LICENSE_PUBLIC_KEY_FILE)"; \ + exit 1; \ + fi; \ + echo "Developer surface: 1. License verifier from $(CODESCRIBE_LICENSE_PUBLIC_KEY_FILE) (Get license / CSK1)."; \ + SPARKLE=$$(tr -d '[:space:]' < "$(CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE)" 2>/dev/null || true); \ + CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$$LICENSE" \ + CODESCRIBE_DEVELOPER_SURFACE=1 \ + SPARKLE_ED_PUBLIC_KEY="$$SPARKLE" \ + $(MAKE) --no-print-directory app PROFILE=local-release @APP_SRC="macos/build/Build/Products/Release/Codescribe.app"; \ if [ ! -d "$$APP_SRC" ]; then \ echo "Build product missing: $$APP_SRC — 'make app PROFILE=local-release' did not produce the app."; \ @@ -239,7 +283,11 @@ bump: esac; \ new="$$major.$$minor.$$patch"; \ sed -i '' "s/^version = \"$$current\"/version = \"$$new\"/" $(VERSION_FILE); \ - echo "Bumped: v$$current -> v$$new" + if [ -f README.md ]; then \ + sed -i '' "s/badge\/version-$$current-/badge\/version-$$new-/" README.md; \ + sed -i '' "s/current source version is \`$$current\`/current source version is \`$$new\`/" README.md; \ + fi; \ + echo "Bumped: v$$current -> v$$new (Cargo.toml + README). Update CHANGELOG by hand. Do not bump site/src/lib/release.ts here." bump-patch: @$(MAKE) bump TYPE=patch @@ -289,11 +337,13 @@ bump-major: # bench instrument: real proof, host-local, never a merge gate. # # gate: check class=static ci=no -- cargo fmt, prettier, clippy, semgrep, validate-envs, validate-gates; executes ZERO tests -# gate: lint class=static ci=no -- cargo fmt --check + clippy on the workspace; no tests +# gate: lint class=static ci=no -- cargo fmt --check + clippy on the workspace + verify-swift-format; no tests # gate: semgrep class=static ci=no -- semgrep scan --config auto (semgrep.yml runs semgrep directly, not this target) # gate: verify class=hermetic ci=yes -- the workspace test set + doctests + env registry + this ledger; the command rust.yml runs # gate: verify-canaries class=hermetic ci=no -- claim-vs-execution canaries that read repo files only (scripts/canaries.sh); each row is born from a named incident -# gate: smoke-canaries class=operator ci=no -- verify-canaries + host rows: dist inputs, appcast feed, live-store purity, Sparkle key parity (scripts/canaries.sh --host) +# gate: verify-swift-format class=static ci=no -- swift-format lint --strict over macos/Codescribe + macos/CodescribeTests; skips the generated UniFFI binding; no Swift tests (that is test-swift) +# gate: smoke-canaries class=operator ci=no -- verify-canaries + host rows: dist inputs, appcast feed, live-store purity, Sparkle key parity, keychain domain cleanliness (scripts/canaries.sh --host) +# gate: test-keychain-session class=hermetic ci=no -- ephemeral signing-keychain contract (scripts/tests/keychain-session-test.sh) against a FAKE security binary and a temp HOME; touches no real keychain # gate: verify-dmg class=operator ci=no -- fail-closed payload check against an already-built DMG; release.yml runs the same check via scripts/verify-dmg-payload.sh, not via this target # gate: test class=operator ci=no -- workspace tests + #[ignore] real-API tests + STT pipeline; sources ~/.codescribe/.env and opens Console # gate: test-quick class=operator ci=no -- workspace tests only, but still sources ~/.codescribe/.env and opens Console @@ -312,8 +362,9 @@ bump-major: # gate: test-engine-parity class=operator ci=no -- Layer 0 parity bar vs the Apple reference; private corpus, host-local bench # gate: test-engine-parity-layered class=operator ci=no -- Layer 1 parity arm judged on structure; private corpus, host-local bench # gate: test-engine-parity-both class=operator ci=no -- runs both parity arms and prints the delta +# gate: test-corpus-parity class=operator ci=no -- isolated production-session file replay; private corpus and local STT models # gate: test-teacher class=operator ci=no -- teacher CLI proof run, writes an HTML report -# gate: test-swift class=operator ci=no -- 318 SwiftUI front-end tests; needs Xcode and a built ffi dylib +# gate: test-swift class=operator ci=no -- SwiftUI suite + Apple phrase-restart Rust/Swift lockstep self-test; needs Xcode and built ffi/bridge binaries # gate: smoke-macos27 class=operator ci=no -- host smoke after an OS/Xcode bump; operator-only rows report SKIP # # ───────────────────────────────────────────────────────────────────────────── @@ -326,6 +377,45 @@ lint: @cargo fmt -- --check @echo "=== Clippy ===" @cargo clippy --workspace -- -D warnings + @echo "=== Swift Format Check ===" + @$(MAKE) --no-print-directory verify-swift-format + +# The Swift side of the app had no format gate at all while `lint` covered only +# Rust, so 100 of 100 sources drifted. Two things this recipe does NOT copy from +# the sibling repo it was transplanted from (vetcoders/pensieve, `make lint`): +# +# 1. `--strict` is mandatory. `swift-format lint` without it exits 0 no matter +# how many violations it prints, so a gate built on the bare command is a +# gate that can never fail — measured here on 2026-08-12 with swift-format +# 6.3.0: bare exit 0, --strict exit 1 on the same file. +# 2. The generated UniFFI binding is excluded. It is regenerated by +# `make app-bindings` from the Rust bridge, so formatting it is both futile +# and a diff-churn source; the sibling repo excludes its own binding the +# same way. +# +# Diagnostics go to the terminal directly, not to stdout/stderr — redirecting +# this command yields an empty file while the console still fills. Judge it by +# the exit code; that is what survives a pipe and a CI log. +SWIFT_FORMAT_ROOTS := macos/Codescribe macos/CodescribeTests +SWIFT_FORMAT_EXCLUDE := -path '*/Bridge/codescribe_ffi.swift' + +.PHONY: verify-swift-format format-swift +verify-swift-format: + @if ! command -v swift-format >/dev/null 2>&1; then \ + echo "verify-swift-format: swift-format is required (brew install swift-format)"; \ + exit 1; \ + fi + @find $(SWIFT_FORMAT_ROOTS) -name '*.swift' ! $(SWIFT_FORMAT_EXCLUDE) -print0 \ + | xargs -0 swift-format lint --strict + +format-swift: + @if ! command -v swift-format >/dev/null 2>&1; then \ + echo "format-swift: swift-format is required (brew install swift-format)"; \ + exit 1; \ + fi + @find $(SWIFT_FORMAT_ROOTS) -name '*.swift' ! $(SWIFT_FORMAT_EXCLUDE) -print0 \ + | xargs -0 swift-format format --in-place + @echo "format-swift: applied; re-run 'make verify-swift-format' to confirm" TEST_LOG := /tmp/codescribe-tests.log SWIFT_TEST_LOG := /tmp/codescribe-swift-tests.log @@ -333,7 +423,40 @@ TEST_SSE_CARGO_JOBS ?= 2 TEST_SSE_PROFILE ?= debug TEST_SSE_PROFILE_ARGS := $(if $(filter release,$(TEST_SSE_PROFILE)),--release,) +define TEST_DATA_DIR_SETUP +CODESCRIBE_TEST_TMP_ROOT="$${TMPDIR:-/tmp}"; \ +CODESCRIBE_TEST_TMP_ROOT="$${CODESCRIBE_TEST_TMP_ROOT%/}"; \ +if [[ -z "$$CODESCRIBE_TEST_TMP_ROOT" ]]; then CODESCRIBE_TEST_TMP_ROOT=/tmp; fi; \ +CODESCRIBE_TEST_DATA_DIR="$$(mktemp -d "$$CODESCRIBE_TEST_TMP_ROOT/codescribe-test-data.XXXXXX")" || { \ + echo "test-data-dir: mktemp failed under $$CODESCRIBE_TEST_TMP_ROOT" >&2; \ + exit 1; \ +}; \ +export CODESCRIBE_DATA_DIR="$$CODESCRIBE_TEST_DATA_DIR"; \ +cleanup_codescribe_test_data_dir() { \ + isolated_log="$$CODESCRIBE_TEST_DATA_DIR/logs/codescribe.log"; \ + if [[ -f "$$isolated_log" ]]; then \ + isolated_bytes="$$(wc -c < "$$isolated_log" | tr -d ' ')"; \ + echo "test-data-dir: isolated-log=$$isolated_log bytes=$$isolated_bytes"; \ + else \ + echo "test-data-dir: isolated-log=none root=$$CODESCRIBE_TEST_DATA_DIR"; \ + fi; \ + case "$$CODESCRIBE_TEST_DATA_DIR" in \ + "$$CODESCRIBE_TEST_TMP_ROOT"/codescribe-test-data.*) \ + rm -rf -- "$$CODESCRIBE_TEST_DATA_DIR"; \ + echo "test-data-dir: cleaned=$$CODESCRIBE_TEST_DATA_DIR"; \ + ;; \ + *) \ + echo "test-data-dir: refusing unsafe cleanup: $$CODESCRIBE_TEST_DATA_DIR" >&2; \ + return 1; \ + ;; \ + esac; \ +}; \ +trap cleanup_codescribe_test_data_dir EXIT; \ +echo "test-data-dir: created=$$CODESCRIBE_TEST_DATA_DIR" +endef + define TEST_SETUP +$(TEST_DATA_DIR_SETUP); \ LOG=$(TEST_LOG); \ export CODESCRIBE_DISABLE_KEYCHAIN=1; \ echo "" >> "$$LOG"; \ @@ -686,6 +809,47 @@ test-engine-parity-both: fi; \ [ "$$off_rc" -eq 0 ] && [ "$$on_rc" -eq 0 ] +# Inventory every configured corpus root without loading operator settings, +# dotenv or Keychain. Census always discovers historical same-stem references; +# replay decides separately whether they are admissible as quality references. +.PHONY: corpus-census +corpus-census: + @set -euo pipefail; \ + root_args=(); \ + for root in $(CORPUS_ROOTS); do root_args+=(--root "$$root"); done; \ + CODESCRIBE_DISABLE_KEYCHAIN=1 cargo run --quiet --bin codescribe-corpus -- census \ + "$${root_args[@]}" \ + --include-historical \ + --out "$(CORPUS_OUT)/census.json" + +# Production PCM-session replay. One recording x the two core arms is the safe +# default; expand CORPUS_PROFILES / CORPUS_MAX_RECORDINGS deliberately for a +# retained matrix. Each profile runs in a fresh process and isolated data root. +.PHONY: test-corpus-parity +test-corpus-parity: + @set -euo pipefail; \ + root_args=(); \ + max_args=(); \ + source_identity="$$(git rev-parse HEAD)"; \ + if [ -n "$$(git status --porcelain --untracked-files=all)" ]; then source_identity="$$source_identity-dirty"; fi; \ + if [ ! -x "$(CORPUS_APPLE_BRIDGE)" ]; then \ + printf 'corpus parity refused: signed Apple STT bridge is not executable: %s\n' "$(CORPUS_APPLE_BRIDGE)" >&2; \ + exit 2; \ + fi; \ + for root in $(CORPUS_ROOTS); do root_args+=(--root "$$root"); done; \ + if [ -n "$(strip $(CORPUS_MAX_RECORDINGS))" ]; then \ + max_args+=(--max-recordings "$(CORPUS_MAX_RECORDINGS)"); \ + fi; \ + CODESCRIBE_DISABLE_KEYCHAIN=1 cargo run --quiet --bin codescribe-corpus -- run \ + "$${root_args[@]}" \ + "$${max_args[@]}" \ + --out-dir "$(CORPUS_OUT)" \ + --profiles "$(CORPUS_PROFILES)" \ + --runs "$(CORPUS_RUNS)" \ + --references "$(CORPUS_REFERENCE_POLICY)" \ + --apple-bridge "$(CORPUS_APPLE_BRIDGE)" \ + --commit "$$source_identity" + # Host smoke for the macOS surfaces we own — run after every OS/Xcode bump. # Headless, raises no TCC dialog, posts no synthetic events; operator-only rows # report SKIP instead of passing quietly. SMOKE_ARGS='--with-inference' adds the @@ -738,13 +902,22 @@ smoke-macos27: SWIFT_TEST_CODESIGN_IDENTITY ?= - SWIFT_TEST_MAX_SECONDS ?= 30 .PHONY: test-swift -test-swift: +test-swift: $(ENGINE_BRIDGE) @set -o pipefail; \ + $(TEST_DATA_DIR_SETUP); \ + echo "=== Apple phrase-restart Rust/Swift lockstep self-test ==="; \ + $(ENGINE_BRIDGE) --phrase-restart-self-test || exit $$?; \ if [ ! -f target/$(PROFILE)/libcodescribe_ffi.dylib ]; then \ echo "test-swift: target/$(PROFILE)/libcodescribe_ffi.dylib is missing." >&2; \ echo "test-swift: run 'make app-bindings' (or 'make app') first." >&2; \ exit 2; \ fi; \ + if ! command -v xcodegen >/dev/null 2>&1; then \ + echo "test-swift: xcodegen is required because the Xcode project is generated, not committed." >&2; \ + exit 2; \ + fi; \ + echo "=== Regenerating Xcode project from project.yml ==="; \ + ( cd macos && xcodegen generate ) || exit $$?; \ echo "=== Swift front-end tests (CodescribeTests) ==="; \ cd macos && xcodebuild test \ -scheme Codescribe \ @@ -939,6 +1112,7 @@ check: # was written to remove. Any line added below must stay in the `-e` chain. verify: @set -eo pipefail; \ + $(TEST_DATA_DIR_SETUP); \ echo "=== Verify (hermetic: workspace tests) ==="; \ CODESCRIBE_NO_EMBED=1 CODESCRIBE_DISABLE_KEYCHAIN=1 \ cargo test --workspace --all-targets; \ @@ -974,6 +1148,15 @@ verify-canaries: smoke-canaries: @bash scripts/canaries.sh --host +# The signing keychain borrows the operator's user keychain domain. This proves +# it always gives it back — on success, on failure, on Ctrl-C, under concurrent +# releases, and when the keychain file was destroyed before cleanup ran (the +# 2026-08-15 P0). Hermetic: `security` is a fake binary in a temp dir and HOME +# is redirected, so running it on this host cannot touch a real keychain. +.PHONY: test-keychain-session +test-keychain-session: + @bash scripts/tests/keychain-session-test.sh + .PHONY: canary-catalog canary-catalog: @bash scripts/canaries.sh --list @@ -1022,12 +1205,15 @@ help: @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'BUILD & INSTALL' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'build' 'Build debug binary' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release dylib slim (Silero + MiniLM; Whisper runtime)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release' 'Build release dylib slim (Silero embedded; MiniLM/Whisper runtime)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install' 'Install CLI slim (Whisper via cache/Settings, not embedded)' @printf '%s\n' ' make install-no-embed DEV/RECOVERY: no optional embeds (runtime paths only)' @printf '%s\n' ' make release-codescribe-embedded Fat dylib with Whisper baked in (not daily)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'config' 'Edit ~/.codescribe/.env' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Install to /Applications' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-app' 'Local-release install to /Applications' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'install-voice-lab' 'Install the private developer console when this machine can reach it' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'site-dev' 'Astro site at site/ (http://localhost:4321) — not make site:dev' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'release-stable' 'Everyday: notarize slim DMG + install that stapled .app' @printf '\n' @printf ' $(HELP_C_YELLOW)%s$(HELP_C_RESET)\n' 'RELEASE & DISTRIBUTION' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'dmg' 'Build DMG (ad-hoc signed)' @@ -1069,7 +1255,7 @@ help: @printf '%s\n' ' Full classification: make -s gate-ledger' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test' 'Full suite incl. ignored real-API tests (sources ~/.codescribe/.env)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-quick' 'Workspace tests, no real API (sources ~/.codescribe/.env)' - @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-swift' '318 SwiftUI front-end tests (needs Xcode + ffi dylib)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-swift' 'SwiftUI suite + phrase-restart lockstep (needs Xcode + ffi dylib)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'smoke-macos27' 'Host smoke after an OS/Xcode bump (SMOKE_ARGS=--with-inference)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-e2e' 'Run E2E tests (mock)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-e2e-real' 'Run E2E tests with real API (needs LLM_*_API_KEY)' @@ -1079,6 +1265,8 @@ help: @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-engine-apple' 'Apple live multi-utterance e2e (ENGINE_CLIP / ENGINE_ALL_CLIPS=1)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-engine-candle' 'Candle live multi-utterance e2e (same engine bar)' @printf '%s\n' ' make test-engine-parity-both Both parity arms + delta (needs the private corpus)' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'corpus-census' 'Inventory both private corpus roots; hashes/counts only' + @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-corpus-parity' 'Isolated production replay (profiles/runs/recordings are explicit vars)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-teacher' 'Teacher CLI proof HTML (live×whisper×human)' @printf ' $(HELP_C_GREEN)%-18s$(HELP_C_RESET) %s\n' 'test-all' 'Run full test suite' @@ -1125,7 +1313,8 @@ dist-preflight-signed: dist-preflight fi @echo "dist preflight: Sparkle public key OK (32-byte Ed25519 from $(if $(SPARKLE_ED_PUBLIC_KEY),environment,$(CODESCRIBE_SPARKLE_PUBLIC_KEY_FILE)))" -# Daily slim DMG (public default): Silero + MiniLM, Whisper NOT embedded. +# Daily slim DMG (public default): Silero embedded, MiniLM runtime resource, +# Whisper NOT embedded. dmg: dist-preflight @CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" ./scripts/build-dmg.sh @@ -1153,8 +1342,43 @@ release-standard: dist-preflight-signed fi; \ ./scripts/verify-dmg-payload.sh "$$DMG" --variant slim --version "$$VERSION" +# Install the already-built Release .app without re-signing. Re-signing with +# Apple Development (install-app) drops the notarization ticket and gives the +# everyday machine a different identity than the public DMG. +install-app-release: + @APP_SRC="macos/build/Build/Products/Release/$(CODESCRIBE_APP_NAME).app"; \ + DEST="/Applications/$(CODESCRIBE_APP_NAME).app"; \ + if [ ! -d "$$APP_SRC" ]; then \ + echo "ERROR: no Release app at $$APP_SRC — run make release-standard first."; \ + exit 1; \ + fi; \ + if ! codesign --verify --deep --strict "$$APP_SRC" >/dev/null 2>&1; then \ + echo "ERROR: Release app is not strictly signed. Refusing to install it."; \ + exit 1; \ + fi; \ + echo "Stopping running $(CODESCRIBE_APP_NAME) (if any) ..."; \ + pkill -x "$(CODESCRIBE_APP_NAME)" 2>/dev/null || true; \ + sleep 1; \ + echo "Installing stapled Release app to $$DEST (no re-sign) ..."; \ + mkdir -p /Applications; \ + rsync -a --delete "$$APP_SRC/" "$$DEST/"; \ + echo "Installed: $$DEST"; \ + defaults read "$$DEST/Contents/Info" CFBundleShortVersionString; \ + defaults read "$$DEST/Contents/Info" CSBuildCommit; \ + if stapler validate "$$DEST" >/dev/null 2>&1; then \ + echo "staple: valid"; \ + else \ + echo "WARNING: staple not valid yet — run make release-standard (it notarizes) before treating this as everyday-stable."; \ + fi + +# Everyday stable cut: the slim public DMG and the same stapled .app in +# /Applications. Does not bake Lab. Does not touch SITE_VERSION / tags / GitHub. +release-stable: release-standard install-app-release + @echo "Everyday stable is local (slim DMG + /Applications). Tag, push, gh release, and SITE_VERSION stay the operator button." + # Optional fat SKU: bake Whisper (~1GB+) into the app. Not the daily path. -# Ends with the fail-closed payload gate (full = Silero + MiniLM + Whisper). +# Ends with the fail-closed payload gate (full = Silero + Whisper embedded, +# MiniLM runtime resource). release-full: dist-preflight-signed ensure-models @CODESCRIBE_CODESIGN_IDENTITY="$(CODESCRIBE_DIST_CODESIGN_IDENTITY)" \ CODESCRIBE_LICENSE_PUBLIC_KEY_HEX="$(CODESCRIBE_DIST_LICENSE_KEY)" \ diff --git a/README.md b/README.md index 66223527..23ceac54 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ⌜ Codescribe ⌟ -[![Version](https://img.shields.io/badge/version-0.13.3-6a9bcc)](Cargo.toml) +[![Version](https://img.shields.io/badge/version-0.14.1-6a9bcc)](Cargo.toml) [![License: FSL-1.1-ALv2](https://img.shields.io/badge/license-FSL--1.1--ALv2-d97757)](LICENSE) [![CI](https://github.com/vetcoders/codescribe/actions/workflows/rust.yml/badge.svg)](https://github.com/vetcoders/codescribe/actions/workflows/rust.yml) [![Landing](https://img.shields.io/badge/site-vetcoders.github.io%2Fcodescribe-788c5d)](https://vetcoders.github.io/codescribe/) @@ -59,7 +59,7 @@ flowchart TB > **Current runtime truth:** live overlay preview is local Whisper. Cloud STT is configurable in Settings, but in the current build it is still a **post-capture** path rather than live cloud preview. -> **Status:** current source version is `0.13.3` (see `Cargo.toml`) and ships as a native macOS tray/settings/overlay app with local live preview, tiered settings (`settings.json` + Keychain + optional `.env`), and quality-loop tooling. +> **Status:** current source version is `0.14.1` (see `Cargo.toml`) and ships as a native macOS tray/settings/overlay app with local live preview, tiered settings (`settings.json` + Keychain + optional `.env`), and quality-loop tooling. See: [`docs/WHISPER_LIVE.md`](docs/WHISPER_LIVE.md) | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) @@ -103,7 +103,7 @@ Codescribe can load custom MCP servers from `~/.codescribe/mcp.json`. That keeps ## Features - **Rust core + SwiftUI app** — Native macOS SwiftUI shell over the Rust engine through UniFFI, with candle-core + Metal GPU -- **Two DMG variants** — Standard (daily) embeds Silero VAD + MiniLM; Whisper is downloaded from Settings → Dictation or HF cache. Optional `_full` DMG also embeds Whisper for offline/curiosity installs. +- **Two DMG variants** — Standard (daily) embeds Silero VAD and signs MiniLM as a runtime app resource; Whisper is downloaded from Settings → Dictation or HF cache. Optional `_full` DMG also embeds Whisper for offline/curiosity installs. - **Whisper Live** — Streaming transcription happens _during recording_ (chunks + overlap), so `stop()` is near-instant - **Stream postprocess** — semantic gating + cleanup of live chunks before final output @@ -278,7 +278,7 @@ WHISPER_LANGUAGE=auto # auto | pl | en HOLD_EXCLUSIVE=1 # ignore extra modifiers during hold HOLD_START_DELAY_MS=800 # Delay before recording starts DOUBLE_TAP_INTERVAL_MS=200 # Toggle gesture timing -TOGGLE_SILENCE_SEC=5.0 # Auto-send after silence in toggle modes +TOGGLE_SILENCE_SEC=5.0 # Apple epoch rest after this much silence (next recording) # AI Formatting AI_FORMATTING_ENABLED=1 # 1=format via LLM, 0=raw transcript @@ -324,27 +324,32 @@ qube-daemon --help ## Model -Codescribe uses **whisper-large-v3-turbo-mlx-q8**: +Codescribe uses **whisper-large-v3-turbo** (mlx-community, fp16): - 4-layer turbo architecture (vs 32 layers in full model) -- Q8 quantization (~894MB weights) +- fp16 weights (~1.6 GB): load without q8→F32 dequantization; quantized Whisper + payloads are rejected before engine load - ~10x faster than whisper-large-v3 - Metal GPU acceleration ### Runtime Whisper (Current) -**Daily public builds are slim.** `make release`, `make dmg` / `dmg-signed`, and `make release-standard` embed **Silero VAD** (required) and **MiniLM** when available. **Whisper is not baked in** (~900 MB–1.5 GB saved). Install local Candle Whisper from **Settings → Dictation → Download Whisper**, or run `make download-model`. +**Daily public builds keep large weights out of Cargo artifacts.** `make release`, `make dmg` / `dmg-signed`, and `make release-standard` embed only **Silero VAD** in the Rust engine. **MiniLM** is copied into the signed app as a runtime resource, while **Whisper is not baked in** (~900 MB–1.5 GB saved). Install local Candle Whisper from **Settings → Dictation → Download Whisper**, or run `make download-model`. Optional fat SKU (offline / curiosity): `make release-full` or `CODESCRIBE_EMBED_WHISPER=1` / `make release-codescribe-embedded`. Runtime resolution when Whisper is not embedded: 1. `CODESCRIBE_MODEL_PATH` environment variable -2. `~/.codescribe/models/whisper-large-v3-turbo-mlx-q8/` -3. `./models/whisper-large-v3-turbo-mlx-q8/` -4. Hugging Face cache snapshots for `LibraxisAI/whisper-large-v3-turbo-mlx-q8` +2. `~/.codescribe/models/whisper-large-v3-turbo/` (fp16 default) +3. A complete Hugging Face snapshot explicitly configured by repo id -`CODESCRIBE_NO_EMBED=1` is a development/recovery path that also skips MiniLM embed; it is not the public slim product path. +The mlx-community repo ships only `config.json` + `weights.safetensors`; +the download paths compose `tokenizer.json` from the matching official OpenAI +Transformers repo and `mel_filters.npz` from a checksum-pinned OpenAI Whisper +asset. The resulting directory is validated as unquantized before resolution. + +`CODESCRIBE_EMBED_EMBEDDER=1` is an explicit fat/debug path that compiles MiniLM into Rust artifacts. Normal builds resolve MiniLM from the signed app resource or HF cache. `CODESCRIBE_NO_EMBED=1` disables every optional binary embed; Silero remains embedded. Model files required: @@ -381,7 +386,7 @@ git clone https://github.com/vetcoders/codescribe.git cd codescribe # Development app build with explicit runtime Whisper fallback -CODESCRIBE_MODEL_PATH=./models/whisper-large-v3-turbo-mlx-q8 make app PROFILE=debug +CODESCRIBE_MODEL_PATH=~/.codescribe/models/whisper-large-v3-turbo make app PROFILE=debug open macos/build/Build/Products/Debug/Codescribe.app # Quality checks @@ -399,7 +404,8 @@ make format # cargo fmt ``` make app # Debug SwiftUI app build make app PROFILE=local-release # Optimized local SwiftUI app build -make install-app # Build + install /Applications/Codescribe.app +make install-app # Local-release install to /Applications +make release-stable # Everyday: notarize slim DMG + install that stapled .app make release-qube # Build qube CLI tools make install # Install qube CLI tools + repo-local hooks make release-dmgs # Build both signed + notarized release DMGs diff --git a/app/agent/openai_provider.rs b/app/agent/openai_provider.rs index dd701f7b..30ce0ab6 100644 --- a/app/agent/openai_provider.rs +++ b/app/agent/openai_provider.rs @@ -197,7 +197,13 @@ impl AgentProvider for OpenAiProvider { let request = OpenAiResponsesRequest { reasoning: reasoning_summary_request(&model), model, - input: build_request_input_items(messages, previous_response_id.as_deref())?, + input: build_request_input( + &options.system_prompt, + messages, + previous_response_id.as_deref(), + )?, + // Param on the first turn only; chained turns re-carry the prompt + // as a developer input item (the chain does not preserve it). instructions: chained_instructions( &options.system_prompt, previous_response_id.as_deref(), @@ -494,10 +500,16 @@ fn build_tool_payload(tools: &[ToolDefinition]) -> Vec { .collect() } -/// Instructions for a Responses request: sent on the FIRST turn of a chain -/// only. `previous_response_id` preserves them server-side, and endpoints -/// reject the pair with HTTP 400 ("instructions and previous_response_id -/// together") — same contract the formatting lane already follows. +/// Instructions for a Responses request: the `instructions` PARAM goes only +/// on the first turn of a chain — endpoints reject the pair with +/// `previous_response_id` (HTTP 400 "instructions and previous_response_id +/// together"). +/// +/// But instructions are NOT preserved server-side across chained turns +/// (OpenAI Responses contract), so a chained turn MUST re-carry the system +/// prompt inside `input` — see [`build_request_input`]. A promptless chained +/// turn is how the formatting lane leaked a chat-assistant reply as product +/// output (2026-08-14, build 661); the agent shares the wire contract. fn chained_instructions( system_prompt: &Option, previous_response_id: Option<&str>, @@ -509,6 +521,30 @@ fn chained_instructions( } } +/// Build the full `input` array for a request. On chained turns the system +/// prompt rides as a leading `developer` message item, because the +/// `instructions` param is absent there (see [`chained_instructions`]) and +/// the chain does not carry it server-side. First turns carry the prompt via +/// `instructions` only — no duplicate developer item. +fn build_request_input( + system_prompt: &Option, + messages: &[Message], + previous_response_id: Option<&str>, +) -> Result> { + let mut items = Vec::new(); + if previous_response_id.is_some() + && let Some(prompt) = system_prompt.as_deref().filter(|p| !p.trim().is_empty()) + { + items.push(json!({ + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": prompt}] + })); + } + items.extend(build_request_input_items(messages, previous_response_id)?); + Ok(items) +} + /// Build the `input` array: select the messages to send, then encode them. fn build_request_input_items( messages: &[Message], @@ -804,9 +840,9 @@ fn parse_env_bool(key: &str, default: bool) -> bool { #[cfg(test)] mod tests { use super::{ - OpenAiProvider, ProviderKind, build_request_input_items, chained_instructions, - format_tool_output, forward_events_and_track_chain, reasoning_summary_request, - request_messages, to_data_uri, + OpenAiProvider, ProviderKind, build_request_input, build_request_input_items, + chained_instructions, format_tool_output, forward_events_and_track_chain, + reasoning_summary_request, request_messages, to_data_uri, }; use std::sync::Arc; use std::time::Duration; @@ -879,10 +915,11 @@ mod tests { assert!(selected.iter().all(|message| message.role == Role::User)); } - /// Chained turns must NOT resend `instructions`: the Responses API keeps - /// them via `previous_response_id`, and endpoints reject the combination - /// with HTTP 400 "instructions and previous_response_id together" — which - /// froze the Agent UI in thinking… on every second turn (repro 2026-08-10). + /// Chained turns must NOT resend the `instructions` PARAM (endpoints + /// reject the pair with HTTP 400, which froze the Agent UI on every + /// second turn — repro 2026-08-10). The prompt itself still travels: as a + /// developer input item, because the chain does NOT preserve instructions + /// server-side (see `chained_turn_recarries_prompt_as_developer_item`). #[test] fn chained_turn_omits_instructions() { let system = Some("system prompt".to_string()); @@ -899,6 +936,38 @@ mod tests { assert_eq!(chained_instructions(&None, None), None); } + /// The 2026-08-14 promptless-chain leak, agent side: a chained turn must + /// re-carry the system prompt as a leading developer input item (the + /// chain does not preserve `instructions` server-side), while the first + /// turn carries it via the param only — no duplicate developer item. + #[test] + fn chained_turn_recarries_prompt_as_developer_item() { + let system = Some("system prompt".to_string()); + let messages = vec![Message::new( + Role::User, + vec![ContentBlock::Text("hello".to_string())], + )]; + + let chained = build_request_input(&system, &messages, Some("resp_prev")) + .expect("chained input should build"); + assert_eq!(chained[0]["role"], "developer"); + assert_eq!(chained[0]["content"][0]["text"], "system prompt"); + assert_eq!(chained[1]["role"], "user"); + + let first = build_request_input(&system, &messages, None).expect("first input builds"); + assert!( + first.iter().all(|item| item["role"] != "developer"), + "first turn must not duplicate the prompt as a developer item" + ); + + let promptless = build_request_input(&None, &messages, Some("resp_prev")) + .expect("promptless chained input builds"); + assert!( + promptless.iter().all(|item| item["role"] != "developer"), + "no prompt configured ⇒ no developer item" + ); + } + /// Resuming a chain omits prior turns already stored server-side. #[test] fn build_request_input_items_skips_prior_history_when_resuming_chain() { diff --git a/app/agent/tools/api_truth.rs b/app/agent/tools/api_truth.rs new file mode 100644 index 00000000..85cf4308 --- /dev/null +++ b/app/agent/tools/api_truth.rs @@ -0,0 +1,76 @@ +//! Agent-facing ground truth about Responses-style and streaming AI APIs, +//! appended to the system prompt. Every claim here was measured in the field +//! (2026-08-12..14: the pair-400, the promptless-chain leak on build 661, the +//! key-swap `previous_response_not_found`, and the full hours-later recall of +//! a stored chain). Prompt-layer only — the agent must answer questions about +//! these mechanics from facts, not with generic clarification menus. + +/// A concise Responses/streaming primer for the agent system prompt. Kept +/// tight on purpose: prompt space is a scarce resource, so this section +/// states the contract facts, the app's transcription shape, and the +/// answer-first rule — nothing else. +pub fn responses_api_prompt_section() -> String { + "RESPONSES & STREAMING AI APIS — GROUND TRUTH\n\ + Codescribe speaks the OpenAI Responses API (`/v1/responses`), never \ + legacy chat/completions. Requests carry `input` items shaped \ + `{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":...}]}` \ + (assistant history rides as `output_text`).\n\ + Conversation state: `previous_response_id` chains turns server-side; a \ + stored response id IS durable conversation memory and can be resumed \ + hours later. Three measured sharp edges: (1) sending `instructions` \ + together with `previous_response_id` is HTTP 400 on OpenAI; (2) \ + instructions are NOT carried across chained turns — a chained turn must \ + re-carry the system prompt as a leading `developer` input item; (3) \ + response ids are key/org-scoped — after a key rotation the old id \ + answers `previous_response_not_found`, so drop it and continue \ + unchained. Some Responses backends (LibraxisAI) also mint a \ + `response_id` for STT transcriptions (`resp_stt_*`) that LLM turns can \ + chain from — voice joining the conversation as a chain link.\n\ + Streaming is SSE: `response.created` -> `response.in_progress` -> \ + `response.output_item.added` -> `response.output_text.delta`... -> \ + `response.completed` (the completed event carries the full output and \ + the response id). Non-streaming is one JSON body of the same shape.\n\ + This app's transcription is layered: Apple SFSpeech live partials/finals \ + form the canvas, Whisper re-transcribes windowed tails, the lexicon is a \ + post-pass, LLM formatting is a separate lane — and RAW is append-only, \ + never full-replaced.\n\ + ANSWER-FIRST RULE: when a spoken request is rough, partial, or \ + frustrated, extract the actionable intent and act or answer \ + substantively from these facts and the codebase. Ask at most ONE \ + clarifying question, and only when genuinely blocked — never reply with \ + numbered option menus or requirement questionnaires." + .to_string() +} + +/// Pins the load-bearing anchors this section must keep through future edits. +#[cfg(test)] +mod tests { + use super::*; + + /// The primer must keep the measured contract facts and the answer-first rule. + #[test] + fn api_truth_section_carries_the_load_bearing_anchors() { + let section = responses_api_prompt_section(); + assert!(section.starts_with("RESPONSES & STREAMING AI APIS")); + for anchor in [ + "/v1/responses", + "input_text", + "previous_response_id", + "HTTP 400", + "NOT carried across chained turns", + "developer", + "previous_response_not_found", + "resp_stt_", + "response.output_text.delta", + "response.completed", + "append-only", + "ANSWER-FIRST RULE", + "numbered option menus", + ] { + assert!( + section.contains(anchor), + "api-truth section missing anchor: {anchor}" + ); + } + } +} diff --git a/app/agent/tools/mod.rs b/app/agent/tools/mod.rs index e2ced737..85476fb7 100644 --- a/app/agent/tools/mod.rs +++ b/app/agent/tools/mod.rs @@ -6,6 +6,8 @@ //! separately nameable is what lets a test prove the substrate covers every //! core capability without an MCP server running. +/// Prompt-layer Responses/streaming ground truth (measured API contracts). +pub mod api_truth; /// Clipboard read/write tools (`read_clipboard` / `write_clipboard`). pub mod clipboard; /// Prompt-layer review-tool + connector-fallback doctrine (no executor changes). diff --git a/app/controller/delivery_route.rs b/app/controller/delivery_route.rs new file mode 100644 index 00000000..42416c08 --- /dev/null +++ b/app/controller/delivery_route.rs @@ -0,0 +1,451 @@ +//! Delivery throne: one session, one destination, chosen by intent — never by +//! whoever happens to be frontmost at stop. +//! +//! The mic, the transcript, and the agent chain stay other thrones. This module +//! is only the destination axis (operator diagnosis 2026-08-15: "walka o tron"). +//! +//! Law: +//! - `DeliveryIntent` is frozen at session start (or at an explicit overlay +//! click). It is not re-derived from OS focus. +//! - `resolve_delivery_route` is the only function allowed to pick a +//! [`DeliveryRoute`]. Auto-paste, overlay Insert, and To Agent consult it; +//! they do not invent a second destination. +//! - Codescribe is never a legal Cmd+V target. A latched self-app (Agent +//! composer / overlay / settings) routes to the Orient canvas or the Agent +//! composer as a first-class message — never as a tagged paste into ourselves. + +/// Where a finished transcript is allowed to land. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryRoute { + /// Spoken intent goes to the Agent composer as a first-class message. + /// Never a clipboard paste into whatever is focused. + AgentComposer, + /// Transcript stays on the Orient overlay canvas. No paste, no agent send. + OrientCanvas, + /// Auto-paste / overlay Insert into the *latched session target*. + /// Focus at stop time is not the authority. + ClipboardPaste, + /// Armed for a later explicit Paste Here. Constructed when the overlay + /// Insert / defer click refuses a synthetic paste into Codescribe. + DeferredInsert, + /// History / notes / RAW only — no user-visible delivery. + ArchiveOnly, +} + +impl DeliveryRoute { + /// Stable telemetry label (snake_case, one token). + pub const fn as_str(self) -> &'static str { + match self { + Self::AgentComposer => "agent_composer", + Self::OrientCanvas => "orient_canvas", + Self::ClipboardPaste => "clipboard_paste", + Self::DeferredInsert => "deferred_insert", + Self::ArchiveOnly => "archive_only", + } + } + + /// True when the stop path is allowed to post a synthetic Cmd+V. + pub const fn posts_synthetic_paste(self) -> bool { + matches!(self, Self::ClipboardPaste) + } +} + +/// Session-start (or explicit overlay) intent. Frozen before recording ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryIntent { + /// Hold Fn / Globe — Orient dictation. + OrientDictation, + /// Double-left-option formatting hold — still Orient, may auto-paste formatted. + OrientFormat, + /// Assistive / Double-right-option — Agent composer is the destination. + AgentVoice, + /// Explicit overlay "To Agent" after any session. + OverlayToAgent, + /// Explicit overlay Insert / Paste Here. Frozen at the click, not at stop. + OverlayInsert, + /// Notes-only / save-only. + NotesOnly, +} + +impl DeliveryIntent { + /// Stable telemetry label. + pub const fn as_str(self) -> &'static str { + match self { + Self::OrientDictation => "orient_dictation", + Self::OrientFormat => "orient_format", + Self::AgentVoice => "agent_voice", + Self::OverlayToAgent => "overlay_to_agent", + Self::OverlayInsert => "overlay_insert", + Self::NotesOnly => "notes_only", + } + } +} + +/// Facts the destination function is allowed to read. Focus-at-stop is not here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeliveryFacts { + pub has_text: bool, + pub no_speech: bool, + pub auto_paste_enabled: bool, + pub overlay_enabled: bool, + pub live_stream_session: bool, + pub commit_required: bool, + /// Latched pre-overlay target is Codescribe itself (Agent / overlay / settings). + pub latched_target_is_self: bool, +} + +/// One verdict: a route plus a stable reason token for the budget line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeliveryDecision { + pub route: DeliveryRoute, + pub reason: &'static str, +} + +/// Map session flags onto an intent. Assistive wins; notes-only next; format +/// hold is still Orient (destination is the canvas / latched target, not Agent). +pub fn delivery_intent_from_session( + assistive: bool, + force_ai: bool, + notes_save_only: bool, +) -> DeliveryIntent { + if assistive { + DeliveryIntent::AgentVoice + } else if notes_save_only { + DeliveryIntent::NotesOnly + } else if force_ai { + DeliveryIntent::OrientFormat + } else { + DeliveryIntent::OrientDictation + } +} + +/// Codescribe (any chrome) is never a legal synthetic-paste target. +pub fn target_is_self_app(name: &str) -> bool { + name.trim().eq_ignore_ascii_case("codescribe") +} + +/// Facts an overlay Insert / defer click may feed the throne. +/// +/// Focus-at-click is not an input. `latched_target_is_self` is true when the +/// recorded target is Codescribe, or when Swift already knows the caret is +/// still inside our chrome (`defer_text_from_overlay`). +pub fn overlay_insert_facts(has_text: bool, latched_target_is_self: bool) -> DeliveryFacts { + DeliveryFacts { + has_text, + no_speech: false, + auto_paste_enabled: false, + overlay_enabled: true, + live_stream_session: false, + commit_required: false, + latched_target_is_self, + } +} + +/// Single destination function. Advisors (quality gate, overlay flag, auto-paste +/// toggle) may veto a paste; they may not pick a different throne. +pub fn resolve_delivery_route(intent: DeliveryIntent, facts: DeliveryFacts) -> DeliveryDecision { + if !facts.has_text || facts.no_speech { + return DeliveryDecision { + route: DeliveryRoute::ArchiveOnly, + reason: "empty_or_no_speech", + }; + } + + match intent { + DeliveryIntent::AgentVoice => DeliveryDecision { + route: DeliveryRoute::AgentComposer, + reason: "assistive_intent", + }, + DeliveryIntent::OverlayToAgent => DeliveryDecision { + route: DeliveryRoute::AgentComposer, + reason: "explicit_to_agent", + }, + DeliveryIntent::NotesOnly => DeliveryDecision { + route: DeliveryRoute::ArchiveOnly, + reason: "notes_save_only", + }, + DeliveryIntent::OverlayInsert => overlay_insert_route(facts), + DeliveryIntent::OrientDictation | DeliveryIntent::OrientFormat => orient_route(facts), + } +} + +/// Explicit overlay click. Orient vetoes (live stream, quality commit) do not +/// apply — the user asked to insert *now*. Codescribe as the latched target +/// still refuses Cmd+V into ourselves. +fn overlay_insert_route(facts: DeliveryFacts) -> DeliveryDecision { + if facts.latched_target_is_self { + return DeliveryDecision { + route: DeliveryRoute::DeferredInsert, + reason: "refuse_paste_into_self", + }; + } + DeliveryDecision { + route: DeliveryRoute::ClipboardPaste, + reason: "explicit_insert", + } +} + +fn orient_route(facts: DeliveryFacts) -> DeliveryDecision { + if facts.live_stream_session { + return DeliveryDecision { + route: DeliveryRoute::OrientCanvas, + reason: "live_stream_owns_canvas", + }; + } + if facts.commit_required { + return DeliveryDecision { + route: DeliveryRoute::OrientCanvas, + reason: "quality_commit_pending", + }; + } + if facts.latched_target_is_self { + return DeliveryDecision { + route: DeliveryRoute::OrientCanvas, + reason: "refuse_paste_into_self", + }; + } + if facts.auto_paste_enabled { + return DeliveryDecision { + route: DeliveryRoute::ClipboardPaste, + reason: "auto_paste_to_latched_target", + }; + } + if facts.overlay_enabled { + return DeliveryDecision { + route: DeliveryRoute::OrientCanvas, + reason: "overlay_is_destination", + }; + } + DeliveryDecision { + route: DeliveryRoute::ArchiveOnly, + reason: "no_visible_surface", + } +} + +/// One INFO line: route, reason, intent, latched target. The stop-path budget +/// already has a `delivery_secs` phase; this names *where* those seconds went. +pub fn format_delivery_route_line( + intent: DeliveryIntent, + decision: DeliveryDecision, + latched_target: Option<&str>, +) -> String { + format!( + "delivery_route: intent={intent} route={route} reason={reason} target={target}", + intent = intent.as_str(), + route = decision.route.as_str(), + reason = decision.reason, + target = latched_target.unwrap_or("-"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facts(overrides: impl FnOnce(&mut DeliveryFacts)) -> DeliveryFacts { + let mut f = DeliveryFacts { + has_text: true, + no_speech: false, + auto_paste_enabled: true, + overlay_enabled: true, + live_stream_session: false, + commit_required: false, + latched_target_is_self: false, + }; + overrides(&mut f); + f + } + + #[test] + fn empty_or_no_speech_archives_regardless_of_intent() { + for intent in [ + DeliveryIntent::OrientDictation, + DeliveryIntent::AgentVoice, + DeliveryIntent::OverlayToAgent, + DeliveryIntent::OverlayInsert, + ] { + let empty = resolve_delivery_route( + intent, + facts(|f| { + f.has_text = false; + }), + ); + assert_eq!(empty.route, DeliveryRoute::ArchiveOnly, "{intent:?}"); + assert_eq!(empty.reason, "empty_or_no_speech"); + + let silent = resolve_delivery_route( + intent, + facts(|f| { + f.no_speech = true; + }), + ); + assert_eq!(silent.route, DeliveryRoute::ArchiveOnly, "{intent:?}"); + } + } + + #[test] + fn assistive_never_pastes() { + let decision = resolve_delivery_route(DeliveryIntent::AgentVoice, facts(|_| {})); + assert_eq!(decision.route, DeliveryRoute::AgentComposer); + assert_eq!(decision.reason, "assistive_intent"); + assert!(!decision.route.posts_synthetic_paste()); + } + + #[test] + fn overlay_to_agent_is_first_class_not_focus_paste() { + let decision = resolve_delivery_route(DeliveryIntent::OverlayToAgent, facts(|_| {})); + assert_eq!(decision.route, DeliveryRoute::AgentComposer); + assert_eq!(decision.reason, "explicit_to_agent"); + } + + #[test] + fn hold_fn_with_agent_focused_stays_on_canvas() { + let decision = resolve_delivery_route( + DeliveryIntent::OrientDictation, + facts(|f| { + f.latched_target_is_self = true; + f.auto_paste_enabled = true; + }), + ); + assert_eq!(decision.route, DeliveryRoute::OrientCanvas); + assert_eq!(decision.reason, "refuse_paste_into_self"); + assert!(!decision.route.posts_synthetic_paste()); + } + + #[test] + fn hold_fn_auto_paste_targets_latched_app() { + let decision = resolve_delivery_route(DeliveryIntent::OrientDictation, facts(|_| {})); + assert_eq!(decision.route, DeliveryRoute::ClipboardPaste); + assert_eq!(decision.reason, "auto_paste_to_latched_target"); + assert!(decision.route.posts_synthetic_paste()); + } + + #[test] + fn overlay_without_auto_paste_is_the_canvas() { + let decision = resolve_delivery_route( + DeliveryIntent::OrientDictation, + facts(|f| { + f.auto_paste_enabled = false; + }), + ); + assert_eq!(decision.route, DeliveryRoute::OrientCanvas); + assert_eq!(decision.reason, "overlay_is_destination"); + } + + #[test] + fn quality_commit_and_live_stream_veto_paste() { + let commit = resolve_delivery_route( + DeliveryIntent::OrientFormat, + facts(|f| { + f.commit_required = true; + }), + ); + assert_eq!(commit.route, DeliveryRoute::OrientCanvas); + assert_eq!(commit.reason, "quality_commit_pending"); + + let live = resolve_delivery_route( + DeliveryIntent::OrientDictation, + facts(|f| { + f.live_stream_session = true; + }), + ); + assert_eq!(live.route, DeliveryRoute::OrientCanvas); + assert_eq!(live.reason, "live_stream_owns_canvas"); + } + + #[test] + fn overlay_insert_to_foreign_app_is_clipboard_paste() { + let decision = resolve_delivery_route(DeliveryIntent::OverlayInsert, facts(|_| {})); + assert_eq!(decision.route, DeliveryRoute::ClipboardPaste); + assert_eq!(decision.reason, "explicit_insert"); + assert!(decision.route.posts_synthetic_paste()); + } + + #[test] + fn overlay_insert_into_self_is_deferred() { + let decision = resolve_delivery_route( + DeliveryIntent::OverlayInsert, + facts(|f| { + f.latched_target_is_self = true; + f.auto_paste_enabled = true; + }), + ); + assert_eq!(decision.route, DeliveryRoute::DeferredInsert); + assert_eq!(decision.reason, "refuse_paste_into_self"); + assert!(!decision.route.posts_synthetic_paste()); + } + + #[test] + fn overlay_insert_ignores_live_stream_and_commit_vetoes() { + let decision = resolve_delivery_route( + DeliveryIntent::OverlayInsert, + facts(|f| { + f.live_stream_session = true; + f.commit_required = true; + }), + ); + assert_eq!(decision.route, DeliveryRoute::ClipboardPaste); + assert_eq!(decision.reason, "explicit_insert"); + } + + #[test] + fn overlay_insert_facts_are_the_click_constructor() { + let click = overlay_insert_facts(true, true); + assert!(!click.auto_paste_enabled); + assert!(click.overlay_enabled); + assert!(click.latched_target_is_self); + let decision = resolve_delivery_route(DeliveryIntent::OverlayInsert, click); + assert_eq!(decision.route, DeliveryRoute::DeferredInsert); + } + + #[test] + fn notes_only_never_pastes() { + let decision = resolve_delivery_route(DeliveryIntent::NotesOnly, facts(|_| {})); + assert_eq!(decision.route, DeliveryRoute::ArchiveOnly); + assert_eq!(decision.reason, "notes_save_only"); + } + + #[test] + fn session_flags_map_to_intent() { + assert_eq!( + delivery_intent_from_session(true, true, true), + DeliveryIntent::AgentVoice + ); + assert_eq!( + delivery_intent_from_session(false, false, true), + DeliveryIntent::NotesOnly + ); + assert_eq!( + delivery_intent_from_session(false, true, false), + DeliveryIntent::OrientFormat + ); + assert_eq!( + delivery_intent_from_session(false, false, false), + DeliveryIntent::OrientDictation + ); + } + + #[test] + fn codescribe_is_self_case_insensitive() { + assert!(target_is_self_app("Codescribe")); + assert!(target_is_self_app(" codescribe ")); + assert!(!target_is_self_app("Ghostty")); + assert!(!target_is_self_app("")); + } + + #[test] + fn budget_line_names_the_throne() { + let line = format_delivery_route_line( + DeliveryIntent::OrientDictation, + DeliveryDecision { + route: DeliveryRoute::ClipboardPaste, + reason: "auto_paste_to_latched_target", + }, + Some("Ghostty"), + ); + assert_eq!( + line, + "delivery_route: intent=orient_dictation route=clipboard_paste reason=auto_paste_to_latched_target target=Ghostty" + ); + } +} diff --git a/app/controller/final_pass.rs b/app/controller/final_pass.rs index 25cb267b..132854b2 100644 --- a/app/controller/final_pass.rs +++ b/app/controller/final_pass.rs @@ -5,10 +5,11 @@ //! mode in which a full-file re-pass is permitted. //! - **Smart**: Whisper may final-pass **individual utterances only** — never the //! whole file. When streaming completeness is adjudicated Complete, nothing runs -//! at stop. When complete-but-shapeless (long wall of words, no sentence -//! terminals — the shape row, operator 2026-08-09), Whisper transcribes the -//! file but only its punctuation/capitalization is adopted onto the committed -//! words (`punctuation_transplant`; word sequence invariant at THIS stage +//! at stop. When coverage-complete, plausibly dense, but shapeless (long wall +//! of words, no sentence terminals — the shape row, operator 2026-08-09), +//! Whisper transcribes the file but only its punctuation/capitalization is +//! adopted onto the committed words (`punctuation_transplant`; word sequence +//! invariant at THIS stage //! because shape is the deficit here — live word corrections belong to the //! Layer 1 tail patch, which is a core element, on by default). When //! incomplete, only the uncommitted audio tail (from the last committed @@ -213,6 +214,223 @@ pub(crate) fn assess_streaming_completeness_fields( }) } +// ── Committed-density floor over the structural verdict ───────────────────── +// +// Structural completeness answers "did the adjudicator seal everything it +// saw". It cannot answer "was what it saw plausibly a whole minute of speech", +// because every input it reads has already passed through the live +// accumulator — so when the accumulator eats a phrase, coverage stays intact +// over the survivors and reports Complete. Measured 2026-08-10/11: two Polish +// takes delivered 220 chars over a COMPLETE 104 s WAV and 118 chars over a +// complete 107 s WAV. Both were labelled `complete_streaming_transcript` and +// skipped while 85–90% of the speech was gone. This floor is the second +// opinion that skip decision never had. + +/// Below this audio length committed density is not judged at all. +/// +/// A short note is legitimately sparse — "kup mleko i chleb" over nine seconds +/// is a real dictation, not a starving one — and escalating those would put a +/// Whisper pass on the stop path of every quick capture. +const DENSITY_MIN_AUDIO_SECS: f32 = 10.0; + +/// Committed characters per audio second below which a coverage-complete +/// verdict describes starvation rather than completeness. +/// +/// Grounded in the measured pair — 2.1 chars/s (220 ch / 104 s) and 1.1 chars/s +/// (118 ch / 107 s) — against a healthy take at ~13 chars/s. Conversational +/// Polish runs roughly 12–15 chars/s, so this floor sits far below anything a +/// real dictation produces. Deliberately loose: a false escalation costs one +/// tail-gap pass, a false skip loses speech permanently, and the doctrine +/// resolves that asymmetry in favour of keeping speech. +const DENSITY_MIN_CHARS_PER_SEC: f32 = 4.0; + +/// Telemetry label replacing `complete_streaming_transcript` when the floor +/// overrides the verdict. Stable string — it is a log/receipt contract. +pub(crate) const DENSITY_STARVED_REASON: &str = "starved_density"; + +/// Committed characters per second of recorded audio. +/// +/// `None` when the denominator is unusable (non-finite, zero, negative). A +/// missing audio length must never become a synthetic density: `0.0` would read +/// as maximal starvation and escalate every session that reached this code. +pub(crate) fn committed_density_chars_per_sec( + audio_secs: f32, + committed_chars: usize, +) -> Option { + if !audio_secs.is_finite() || audio_secs <= 0.0 { + return None; + } + Some(committed_chars as f32 / audio_secs) +} + +/// True when a session long enough to judge committed too few characters to be +/// believable. +/// +/// NaN-safe through [`committed_density_chars_per_sec`]: an unmeasurable +/// duration yields `None` and reports "not starved" rather than escalating on +/// evidence it does not have. +pub(crate) fn committed_density_starved(audio_secs: f32, committed_chars: usize) -> bool { + let Some(density) = committed_density_chars_per_sec(audio_secs, committed_chars) else { + return false; + }; + audio_secs > DENSITY_MIN_AUDIO_SECS && density < DENSITY_MIN_CHARS_PER_SEC +} + +/// Apply the committed-density floor to a structural completeness verdict. +/// +/// Every structurally complete verdict (`Complete` or +/// `CompleteShapeDeficient`) is eligible for demotion into the existing +/// `Incomplete` arm. [`final_pass_action`] carries the consequence with no new +/// variant and no second controller: `Smart` + +/// `Incomplete{starved_density}` → `TailGapFill`, which is the residual / +/// tail-gap path the live session text already feeds through +/// [`smart_tail_gap_source`]. This ordering matters: sentence shape cannot +/// excuse implausibly sparse coverage, because a weak punctuation alignment +/// may otherwise return the starving canvas untouched. `Off` still skips and +/// `Always` still re-passes, because routing ignores completeness for both — +/// the floor changes Smart alone. +/// +/// `audio_secs = None` leaves the verdict untouched: an unknown denominator is +/// not evidence of starvation. The call site logs that silence so the absence +/// shows up in the receipts instead of having to be inferred from them. +pub(crate) fn apply_committed_density_floor( + completeness: StreamingCompleteness, + audio_secs: Option, + committed_chars: usize, +) -> StreamingCompleteness { + let Some(audio_secs) = audio_secs else { + return completeness; + }; + if matches!( + completeness, + StreamingCompleteness::Complete | StreamingCompleteness::CompleteShapeDeficient + ) && committed_density_starved(audio_secs, committed_chars) + { + return StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }; + } + completeness +} + +/// Stable telemetry label for a completeness verdict. +/// +/// One table for every receipt that names a verdict (the density override, the +/// residual-required override, and the `final_pass_skipped` line at the call +/// site) so a label cannot drift between logs that describe the same decision. +/// `CompleteShapeDeficient` is unreachable through the Smart skip mapping — it +/// routes to `PunctuationRepass` — but stays named here for any future mode +/// that skips on it. +pub(crate) fn completeness_label(completeness: StreamingCompleteness) -> &'static str { + match completeness { + StreamingCompleteness::Complete => "complete_streaming_transcript", + StreamingCompleteness::CompleteShapeDeficient => "shape_deficient", + StreamingCompleteness::Incomplete { reason } => reason, + } +} + +/// Single INFO line for a density-overridden structural verdict. +/// +/// Carries both verdict labels and the numbers behind the override — never the +/// transcript. The guard fires precisely when the committed text is short +/// enough to fit comfortably in a log line, which is exactly when writing user +/// speech into `~/.codescribe/logs/codescribe.log` would be easiest to justify +/// and still wrong. +pub(crate) fn format_density_override_line( + overridden: StreamingCompleteness, + audio_secs: f32, + committed_chars: usize, +) -> String { + let overridden = completeness_label(overridden); + format!( + "final_pass_density_guard overridden_verdict={overridden} new_verdict={reason} audio_secs={audio_secs:.3} committed_chars={committed_chars} density_chars_per_sec={density:.2} floor_chars_per_sec={floor:.1} min_audio_secs={min_secs:.1} route=tail_gap_fill", + reason = DENSITY_STARVED_REASON, + density = committed_density_chars_per_sec(audio_secs, committed_chars).unwrap_or(f32::NAN), + floor = DENSITY_MIN_CHARS_PER_SEC, + min_secs = DENSITY_MIN_AUDIO_SECS, + ) +} + +// ── Layer 1 residual-required demotion over the structural verdict ────────── +// +// The committed-density floor above asks "was what the accumulator kept +// plausibly a whole minute of speech". This one carries a stricter fact: Layer +// 1 re-transcribed the sealed window, found MORE speech than the canvas holds, +// and could not place part of it on a demonstrably safe anchor (W-C, commit +// `6d7eaa7f`). That is not an inference from a ratio — it is the engine +// reporting a hole it measured and refused to paper over by rewriting committed +// text. Density can miss it entirely: a session may commit a healthy 13 chars/s +// and still have lost a phrase to a hypothesis collapse, which is exactly the +// case the floor was never able to see. + +/// Telemetry label replacing a structurally complete verdict when Layer 1 +/// escalated an unplaceable under-commit residual. Stable string — it is a +/// log/receipt contract, mirroring [`DENSITY_STARVED_REASON`]. +pub(crate) const RESIDUAL_REQUIRED_REASON: &str = "residual_required"; + +/// Demote a structurally complete verdict when Layer 1 reported speech it +/// recovered but could not place. +/// +/// Pure and total, so the contract is testable without a session: the only +/// input beyond the verdict is the monotonic +/// [`SessionTelemetrySnapshot::residual_required`] flag. Deliberately NOT +/// folded into [`apply_committed_density_floor`] — that helper's whole meaning +/// is chars-per-second, and passing an unrelated boolean through it would make +/// both contracts unreadable and untestable in isolation. +/// +/// Consequence, carried by the existing typed matrix with no new variant and no +/// second controller: `Smart` + `Incomplete{residual_required}` → +/// [`FinalPassAction::TailGapFill`], the same residual / tail-gap ladder +/// [`smart_tail_gap_source`] already feeds. `SkipStreamingFinal` and +/// `PunctuationRepass` both become unreachable for this session under Smart — +/// punctuation-only delivery would hand back the canvas with the hole still in +/// it, shaped. +/// +/// An already-`Incomplete` verdict keeps its OWN reason. `starved_density` and +/// `pending_tail` are strictly more informative diagnoses (they carry measured +/// numbers or a state machine position) and they route identically, so +/// relabelling them would trade information for nothing. +/// +/// Mode promises are untouched: routing ignores completeness for `Off` and +/// `Always`, so this demotion changes Smart alone. +pub(crate) fn apply_residual_required_demotion( + completeness: StreamingCompleteness, + residual_required: bool, +) -> StreamingCompleteness { + if !residual_required { + return completeness; + } + match completeness { + StreamingCompleteness::Complete | StreamingCompleteness::CompleteShapeDeficient => { + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON, + } + } + already_diagnosed @ StreamingCompleteness::Incomplete { .. } => already_diagnosed, + } +} + +/// Single INFO line emitted whenever Layer 1 raised the residual escalation, +/// including when the verdict was already Incomplete and nothing changed. +/// +/// Silence would be indistinguishable from "the warning never arrived", which +/// is the failure mode this whole cut exists to close: under W-C the signal +/// reached the log and the IPC wire and changed no verdict, and nothing in the +/// receipts said so. Carries verdict labels and the warning code only — never +/// transcript text, and never a character count that could reconstruct one. +pub(crate) fn format_residual_required_line( + before: StreamingCompleteness, + after: StreamingCompleteness, +) -> String { + format!( + "final_pass_residual_guard warning_code={code} verdict_before={before} verdict_after={after} demoted={demoted}", + code = super::helpers::UNDER_COMMIT_WARNING_CODE, + before = completeness_label(before), + after = completeness_label(after), + demoted = before != after, + ) +} + /// Label from the actual engine verdict (not preference). Apple→Whisper fallback /// reports Whisper. When final-pass was **Skipped**, label the **live** lane that /// served (not a hardcode `streaming_whisper` — that laundered Apple live into a diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index 263e1cd4..588606f6 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -334,6 +334,56 @@ impl AgentRuntimeState { true } } + + /// Rebind the assistive conversation to the UI-selected thread (operator + /// contract 2026-08-13: dictation routes to the thread the user is looking + /// at; a new thread is only ever minted by an explicit "+ New thread"). + /// + /// Dropping the runtime on a change deliberately reuses the degrade→rejoin + /// machinery: the next `ensure_runtime` rebuilds onto the new identity and + /// rehydrates its persisted history. `None` clears the identity so the next + /// send mints a fresh thread. Same-target calls are no-ops — the live + /// runtime and its in-memory history stay untouched. + fn retarget_thread(&mut self, target: Option) { + if self.thread_store_id == target { + return; + } + let previous = self.thread_store_id.clone(); + self.runtime = None; + self.thread_store_id = target; + info!( + from = previous.as_deref().unwrap_or(""), + to = self.thread_store_id.as_deref().unwrap_or(""), + "Assistive lane retargeted to UI-selected thread" + ); + } +} + +/// UI-selected assistive routing target. +/// +/// Outer `None`: the Agent UI never published a selection (window never +/// opened) — the lane keeps its legacy behavior of continuing the bound +/// conversation. `Some(None)`: the UI selected a not-yet-persisted thread +/// (explicit "+ New thread") — the next send mints a fresh thread, then the +/// send path syncs the minted identity back here so ONE conscious new-thread +/// press produces one thread, not one per utterance. +static ASSISTIVE_TARGET_THREAD: std::sync::RwLock>> = + std::sync::RwLock::new(None); + +/// Publish the Agent UI's current thread selection as the assistive routing +/// target. Called from the bridge whenever the selection changes. +pub fn set_assistive_target_thread(backend_id: Option) { + *ASSISTIVE_TARGET_THREAD + .write() + .unwrap_or_else(|e| e.into_inner()) = Some(backend_id); +} + +/// Snapshot the published routing target, if the UI ever published one. +fn assistive_target_thread() -> Option> { + ASSISTIVE_TARGET_THREAD + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() } /// The lazily-initialized slot holding the process-global runtime state. @@ -449,12 +499,16 @@ fn build_agent_stream_options( fn compose_agent_system_prompt(use_assistive_persona: bool) -> String { let workspace = crate::agent::tools::workspace::workspace_prompt_section(); let doctrine = crate::agent::tools::doctrine::review_doctrine_prompt_section(); + // Measured Responses/streaming contract facts + the answer-first rule — + // rides BOTH lanes so a spoken engine question gets substance, not a + // clarification questionnaire (operator incident 2026-08-14). + let api_truth = crate::agent::tools::api_truth::responses_api_prompt_section(); if use_assistive_persona { let base = crate::config::get_assistive_prompt(); - format!("{base}\n\n{workspace}\n\n{doctrine}") + format!("{base}\n\n{workspace}\n\n{doctrine}\n\n{api_truth}") } else { format!( - "You are the Codescribe agent. Answer and act on the user's spoken request using the available tools when helpful.\n\n{workspace}\n\n{doctrine}" + "You are the Codescribe agent. Answer and act on the user's spoken request using the available tools when helpful.\n\n{workspace}\n\n{doctrine}\n\n{api_truth}" ) } } @@ -1107,6 +1161,36 @@ fn legacy_fallback_assistant_text( } } +const LEGACY_FALLBACK_UNAVAILABLE_MESSAGE: &str = + "Agent provider and fallback are unavailable. Check Settings → Providers."; + +/// Build the one terminal sequence the Swift Agent surface must receive after +/// the runtime has handed a voice turn to the legacy formatter. The fallback +/// used to persist its result without publishing anything, leaving the +/// assistant placeholder permanently in `Thinking` after a fast provider/key +/// failure. Keep this mapping pure so the terminal contract is testable without +/// a live provider or a Swift listener. +fn legacy_fallback_terminal_events(assistant_text: Option<&str>) -> Vec { + match assistant_text + .map(str::trim) + .filter(|text| !text.is_empty()) + { + Some(text) => vec![ + AgentDeliveryEvent::TextDone(text.to_string()), + AgentDeliveryEvent::Done, + ], + None => vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string(), + )], + } +} + +fn publish_legacy_fallback_terminal(assistant_text: Option<&str>) { + for event in legacy_fallback_terminal_events(assistant_text) { + crate::agent_delivery::publish_agent_delivery_event(event); + } +} + /// Answer one turn through the legacy formatter instead of the agent runtime. /// Returns the assistant text only when there is genuine output to persist; a /// formatter failure logs and yields `None` rather than writing a junk thread. @@ -1149,7 +1233,24 @@ async fn run_agent_send_with_fallback( let stream_options = build_agent_stream_options(ai_assistive_max_tokens, use_assistive_persona); let agent_result = { let mut guard = runtime_state.lock().await; - run_agent_send_path(&mut guard, text.clone(), stream_options).await + // Route to the thread the user is looking at (operator contract + // 2026-08-13). No published selection → legacy bound conversation. + let fresh_mint_requested = match assistive_target_thread() { + Some(target) => { + let fresh = target.is_none(); + guard.retarget_thread(target); + fresh + } + None => false, + }; + let result = run_agent_send_path(&mut guard, text.clone(), stream_options).await; + if fresh_mint_requested { + // One conscious "+ New thread" = one thread: adopt the minted + // identity as the target so the next utterance continues it. The + // UI's own post-turn refresh republishes the same identity. + set_assistive_target_thread(guard.thread_store_id.clone()); + } + result }; match agent_result { @@ -1165,18 +1266,25 @@ async fn run_agent_send_with_fallback( ); debug!("Legacy fallback input length: {}", text.len()); let fallback_assistant_text = run_legacy_send_path(&text, whisper_language).await; - if let Some(assistant_text) = fallback_assistant_text { - match deliver_legacy_assistive_thread(&text, &assistant_text) { - Ok(Some(receipt)) => debug!( - backend_thread_id = %receipt.backend_id, - message_count = receipt.message_count, - "Legacy assistive fallback delivered" - ), - Ok(None) => {} - Err(error) => { - warn!("Failed to deliver legacy assistive fallback thread: {error}") + match fallback_assistant_text { + Some(assistant_text) => { + match deliver_legacy_assistive_thread(&text, &assistant_text) { + Ok(Some(receipt)) => { + debug!( + backend_thread_id = %receipt.backend_id, + message_count = receipt.message_count, + "Legacy assistive fallback delivered" + ); + publish_legacy_fallback_terminal(Some(&assistant_text)); + } + Ok(None) => publish_legacy_fallback_terminal(None), + Err(error) => { + warn!("Failed to deliver legacy assistive fallback thread: {error}"); + publish_legacy_fallback_terminal(None); + } } } + None => publish_legacy_fallback_terminal(None), } } } @@ -1253,6 +1361,18 @@ impl CompletenessCommitSource { } } +/// Engine warning code raised when the Layer 1 tail patch classified an +/// under-commit retranscription and recovered speech it could **not** place on +/// a demonstrably safe anchor (`core::stt::tail_patcher` → +/// `core::pipeline::streaming::session`, W-C / commit `6d7eaa7f`). +/// +/// Mirrored as a literal rather than imported: core's canonical +/// `UNDER_COMMIT_WARNING_CODE` is `pub` inside a `pub(crate) mod session`, so it +/// is not nameable from this crate and widening that visibility sits outside +/// this cut's fence. Matched EXACTLY — the sibling `tail_patch_skipped` receipt +/// and any future neighbouring code must not force residual gap fill. +pub(crate) const UNDER_COMMIT_WARNING_CODE: &str = "tail_patch_under_commit"; + /// Session telemetry captured from `EngineEvent`s. #[derive(Debug, Clone, Default)] pub(crate) struct SessionTelemetrySnapshot { @@ -1268,6 +1388,14 @@ pub(crate) struct SessionTelemetrySnapshot { /// across UtteranceFinal events. Smart-mode stop transcribes only the tail /// after this point (append-only doctrine — committed text is immutable). pub committed_through_secs: Option, + /// Layer 1 escalated an under-commit residual it could not place on a safe + /// anchor ([`UNDER_COMMIT_WARNING_CODE`]). Monotonic within one session: + /// once a hole is known no later healthy event may un-know it, because the + /// speech is already missing from the canvas the stop path is about to + /// deliver. `Default` starts it false by construction, so + /// [`reset_session_telemetry`] is the only thing that clears it and a new + /// recording can never inherit the previous session's residual demand. + pub residual_required: bool, } /// Telemetry handle shared between the engine's event sink and the controller @@ -1343,6 +1471,10 @@ impl EventSink for SessionTelemetrySink { /// overwrite a valid maximum, silently disabling Smart tail gap-fill for the /// rest of the session. Unmatched events are ignored rather than /// exhaustively listed, so new engine events cannot break the build here. + /// + /// `Warning` is the one event folded by code rather than by variant: only + /// [`UNDER_COMMIT_WARNING_CODE`] sets `residual_required`, and it sets it + /// monotonically. Every other warning falls through to the ignore arm. fn on_event(&self, event: &EngineEvent) { let mut guard = self.shared.lock().unwrap_or_else(|e| e.into_inner()); match event { @@ -1374,6 +1506,15 @@ impl EventSink for SessionTelemetrySink { guard.pending_tail = false; guard.last_commit_source = Some(CompletenessCommitSource::SessionFinalised); } + // Layer 1 recovered speech it could not place. Set-only: a hole + // found mid-session stays known until the session is reset, because + // the missing speech does not come back on its own. The exact code + // is the whole contract — a near-miss code must leave the flag false + // rather than put a Whisper pass on every stop path that logs a + // warning. + EngineEvent::Warning { code, .. } if code == UNDER_COMMIT_WARNING_CODE => { + guard.residual_required = true; + } EngineEvent::Stats { hallucination_drops, semantic_gate_drops, @@ -1427,6 +1568,49 @@ mod tests { use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, AtomicUsize}; + // ── Assistive routing target (operator contract 2026-08-13) ───────────── + + /// Retargeting to another persisted thread drops the runtime (so the next + /// send rejoins + rehydrates) and adopts the new identity; retargeting to + /// the SAME thread must not touch a live runtime — steady-state sends may + /// re-apply the target on every turn. + #[test] + fn retarget_thread_rebinds_on_change_and_noops_on_same() { + let mut state = AgentRuntimeState { + runtime: None, + thread_store_id: Some("thread-a".to_string()), + runtime_degraded: false, + }; + + state.retarget_thread(Some("thread-a".to_string())); + assert_eq!(state.thread_store_id.as_deref(), Some("thread-a")); + + state.retarget_thread(Some("thread-b".to_string())); + assert_eq!( + state.thread_store_id.as_deref(), + Some("thread-b"), + "a changed selection must adopt the new identity" + ); + assert!( + state.runtime.is_none(), + "rebind goes through the rejoin machinery (runtime dropped)" + ); + } + + /// A `None` target is the explicit "+ New thread": the durable identity is + /// cleared so the next send mints a fresh thread instead of continuing the + /// previous conversation. + #[test] + fn retarget_thread_none_clears_identity_for_a_fresh_mint() { + let mut state = AgentRuntimeState { + runtime: None, + thread_store_id: Some("thread-a".to_string()), + runtime_degraded: false, + }; + state.retarget_thread(None); + assert!(state.thread_store_id.is_none()); + } + // ── Collapsible Tool Evidence: friendly tool-name mapping ─────────────── /// Both the MCP wire form and the bare tool id must resolve to the same @@ -1609,6 +1793,32 @@ mod tests { ); } + /// A formatter fallback is still the terminal owner of the already-open + /// voice bubble. Success must fill and close it; failure must close it with + /// an actionable error. Neither branch may leave Swift in `Thinking`. + #[test] + fn legacy_fallback_always_publishes_a_terminal_ui_sequence() { + assert_eq!( + legacy_fallback_terminal_events(Some(" recovered reply ")), + vec![ + AgentDeliveryEvent::TextDone("recovered reply".to_string()), + AgentDeliveryEvent::Done, + ] + ); + assert_eq!( + legacy_fallback_terminal_events(None), + vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string() + )] + ); + assert_eq!( + legacy_fallback_terminal_events(Some(" ")), + vec![AgentDeliveryEvent::Error( + LEGACY_FALLBACK_UNAVAILABLE_MESSAGE.to_string() + )] + ); + } + /// Provider that never emits anything: its event channel is closed /// immediately. Used where a session must exist but must not produce /// conversation history of its own. @@ -1898,6 +2108,87 @@ mod tests { ); } + /// The stop path's residual demand is folded by warning CODE, not by + /// variant. Three things are pinned here because each is a different way to + /// break the contract: exactly `tail_patch_under_commit` sets the flag, its + /// neighbours must not (a loose match would put a Whisper pass on the stop + /// path of every session that logs a warning), and once set no later event + /// may clear it — the speech Layer 1 could not place does not come back on + /// its own, so a clean final afterwards is not evidence the hole closed. + #[test] + fn test_session_telemetry_folds_only_the_exact_under_commit_warning() { + let warning = |code: &str| EngineEvent::Warning { + code: code.to_string(), + message: "committed_tokens=3 retranscribed_tokens=12".to_string(), + }; + let utterance_final = || EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "zdanie".to_string(), + raw_text: "zdanie".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: vec![], + vad_speech_pct: Some(80.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: vec![], + }; + + // Near misses: the sibling receipt code, a truncation, an extension, a + // case variant, a bare substring, and the empty code. + for code in [ + "tail_patch_skipped", + "tail_patch_under_commi", + "tail_patch_under_commit_residual", + "TAIL_PATCH_UNDER_COMMIT", + "under_commit", + "", + ] { + let shared = new_session_telemetry(); + let sink = SessionTelemetrySink::new(Arc::clone(&shared)); + sink.on_event(&warning(code)); + assert!( + !snapshot_session_telemetry(&shared).residual_required, + "warning code {code:?} must not demand residual gap fill" + ); + } + + let shared = new_session_telemetry(); + let sink = SessionTelemetrySink::new(Arc::clone(&shared)); + assert!( + !snapshot_session_telemetry(&shared).residual_required, + "a fresh session starts with no residual demand" + ); + + sink.on_event(&warning(UNDER_COMMIT_WARNING_CODE)); + assert!( + snapshot_session_telemetry(&shared).residual_required, + "the exact Layer 1 under-commit code must fold to residual_required" + ); + + // Monotonic within the session: a clean commit, an unrelated warning + // and the session seal all arrive after the escalation and none of them + // may un-know it. + sink.on_event(&utterance_final()); + sink.on_event(&warning("tail_patch_skipped")); + sink.on_event(&EngineEvent::SessionFinalised { + session_id: "s1".to_string(), + layer_summary: Default::default(), + }); + assert!( + snapshot_session_telemetry(&shared).residual_required, + "later healthy events must not clear a hole Layer 1 already found" + ); + + // Only a new session clears it. + reset_session_telemetry(&shared); + assert!( + !snapshot_session_telemetry(&shared).residual_required, + "reset clears the residual demand so a new recording never inherits it" + ); + } + /// Reset must clear every field, not just the obvious ones: leftover /// `pending_tail` or `committed_chars` would make the next session's first /// routing decision read the previous session's state. @@ -1914,6 +2205,8 @@ mod tests { guard.pending_tail = true; guard.last_commit_source = Some(CompletenessCommitSource::UtteranceFinal); guard.committed_chars = 12; + guard.committed_through_secs = Some(41.0); + guard.residual_required = true; } reset_session_telemetry(&shared); @@ -1923,6 +2216,11 @@ mod tests { assert!(!snapshot.pending_tail); assert!(snapshot.last_commit_source.is_none()); assert_eq!(snapshot.committed_chars, 0); + assert!(snapshot.committed_through_secs.is_none()); + assert!( + !snapshot.residual_required, + "a stale residual demand would force a Whisper tail pass on the next session" + ); } /// The per-turn generation machinery is removed: ordinary consecutive diff --git a/app/controller/hotkey_policy.rs b/app/controller/hotkey_policy.rs index 669843c9..616c58c9 100644 --- a/app/controller/hotkey_policy.rs +++ b/app/controller/hotkey_policy.rs @@ -63,7 +63,14 @@ pub(super) fn should_use_toggle_adjudicated_stop( /// /// A toggle press that *stops* an in-progress toggle recording is excluded: the /// stop must not retroactively change the mode the recording started in. +/// +/// A hold *Press* (legacy mid-hold `HoldUpdate`) never flips destination. +/// Destination is latched at hold-down; Shift/Command attach `{selection_N}` +/// through `RecordingController::attach_hold_selection` instead. pub(super) fn should_apply_incoming_mode_flags(current_state: State, event: &HotkeyInput) -> bool { + if event.key_type == HotkeyType::Hold && event.action == HotkeyAction::Press { + return false; + } matches!(event.action, HotkeyAction::Down | HotkeyAction::Press) && !(event.key_type == HotkeyType::Toggle && current_state == State::RecToggle) } diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 34da9633..66915c96 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -25,6 +25,8 @@ mod assistive_delivery; /// Per-session assistive context bag (selection, app, images). mod context_bucket; +/// One destination throne: intent → Agent / Orient / paste. Focus is not king. +mod delivery_route; /// Stop-path final-pass routing, completeness, and budget reporting. mod final_pass; /// Session telemetry, image attach helpers, assistive send wiring. @@ -33,6 +35,8 @@ mod helpers; mod hotkey_policy; /// Overlay paste dispositions and deferred-insert registration. mod overlay_paste; +/// Production-owned, content-private PCM replay of the overlay engine cone. +pub mod production_replay; /// Quality-gated auto-paste / clipboard delivery decisions. mod quality_delivery; /// Public serving-status surface for tray/UI consumers. @@ -44,13 +48,12 @@ mod types; pub use helpers::{ is_assistive_session, is_conversation_session, publish_recording_indicator, - set_assistive_session, set_conversation_session, + set_assistive_session, set_assistive_target_thread, set_conversation_session, }; pub use overlay_paste::{OverlayPasteDelivery, OverlayPasteResult}; pub use types::{HotkeyAction, HotkeyInput, HotkeyType, State, TranscriptionActionContractMode}; -use crate::presentation::emitter::PresentationEmitter; -use crate::stream_postprocess::StreamPostProcessor; +use crate::presentation::{PresentationEmitter, TranscriptBus, TranscriptMode, TranscriptSession}; use anyhow::{Context, Result}; use std::path::PathBuf; use std::sync::Arc; @@ -75,6 +78,7 @@ use crate::os::selection::{ wait_for_frontmost_app, }; use crate::os::shortcut_registry; +use codescribe_core::asr_session::gateway_session_availability; use context_bucket::ContextBucket; #[cfg(test)] pub(crate) use context_bucket::ContextMarker; @@ -95,16 +99,28 @@ pub(crate) use assistive_delivery::{AssistiveDelivery, AssistiveLane}; use assistive_delivery::{ assemble_assistive_delivery_lane, assemble_raw_paste_wire, capture_combo_context_with_image, }; +use delivery_route::{ + DeliveryFacts, DeliveryIntent, DeliveryRoute, delivery_intent_from_session, + format_delivery_route_line, overlay_insert_facts, resolve_delivery_route, target_is_self_app, +}; +#[cfg(test)] +pub(crate) use final_pass::FinalPassRoutingMode; pub(crate) use final_pass::{ - FinalPassAction, FinalPassRoutingMode, FinalPassStages, SmartTailGapSource, StopPathBudget, - StreamingCompleteness, StreamingCompletenessEvidence, append_tail_gap, - assess_streaming_completeness, compose_stop_path_residual_from_partials, final_pass_action, - final_pass_routing_mode, format_assistive_delivery_budget_line, format_final_pass_stages_line, - format_stop_path_budget_line, smart_tail_gap_source, + FinalPassAction, FinalPassStages, SmartTailGapSource, StopPathBudget, + StreamingCompletenessEvidence, append_tail_gap, apply_committed_density_floor, + apply_residual_required_demotion, assess_streaming_completeness, completeness_label, + compose_stop_path_residual_from_partials, final_pass_action, final_pass_routing_mode, + format_assistive_delivery_budget_line, format_density_override_line, + format_final_pass_stages_line, format_residual_required_line, format_stop_path_budget_line, + smart_tail_gap_source, }; +// The stop path routes on completeness values without ever naming the type: +// every verdict label now comes from `completeness_label`, so the only sites +// that spell `StreamingCompleteness` out are the controller tests. #[cfg(test)] pub(crate) use final_pass::{ - assess_streaming_completeness_fields, engine_label_from_verdict, stop_path_budget_covers_total, + StreamingCompleteness, assess_streaming_completeness_fields, engine_label_from_verdict, + stop_path_budget_covers_total, }; #[cfg(test)] use helpers::SessionEngineStats; @@ -125,19 +141,23 @@ use hotkey_policy::{ use hotkey_policy::{is_assistive_start_event, toggle_stop_adjudicate_timeout}; use overlay_paste::{ DeferredInsertRegistration, OVERLAY_PASTE_FOCUS_BUDGET, OverlayPasteDisposition, - deferred_insert_registration, overlay_paste_disposition, + deferred_insert_registration, overlay_paste_disposition, park_refused_paste, }; #[cfg(test)] use quality_delivery::AutomaticDeliverySink; use quality_delivery::{ - ActionQualityProbe, AutoPastePolicyContext, AutoPasteTrigger, AutomaticDeliveryOwner, - ClipboardDeliverySink, compose_final_status, evaluate_quality_commit_trigger, - maybe_wrap_transcript_for_delivery, maybe_wrap_transcript_for_delivery_with_quality, - recording_mode_label, resolve_auto_paste_policy, session_auto_format_enabled, - truth_recording_mode_label, + ActionQualityProbe, AutomaticDeliveryOwner, ClipboardDeliverySink, compose_final_status, + evaluate_quality_commit_trigger, maybe_wrap_transcript_for_delivery, + maybe_wrap_transcript_for_delivery_with_quality, recording_mode_label, + session_auto_format_enabled, session_prewarms_semantic_guard, truth_recording_mode_label, +}; +#[cfg(test)] +pub(crate) use quality_delivery::{ + AutoPastePolicyContext, AutoPasteTrigger, resolve_auto_paste_policy, }; pub(crate) use truth::{ - adjudicate_recording_truth, apply_ai_noop_signal, truth_display_status, truth_engine_label, + adjudicate_recording_truth, apply_ai_noop_signal, postprocess_transcript_for_delivery, + truth_display_status, truth_engine_label, }; #[cfg(test)] pub(crate) use truth::{push_typed_flag, truth_review_trigger}; @@ -267,6 +287,17 @@ impl Drop for AtomicFlagGuard { } } +/// Keep the last session WAV at a stable path so overlay Retranscribe +/// (Full HQ / Cloud) can re-run without depending on a temp file still +/// being there after stop. +fn retain_last_session_audio(path: &std::path::Path) { + let dest = crate::config::Config::config_dir().join("last_session.wav"); + match std::fs::copy(path, &dest) { + Ok(_) => info!("last_session.wav retained at {}", dest.display()), + Err(err) => warn!("last_session.wav retain failed: {err:#}"), + } +} + /// What one stop-and-process pass produced: the delivery decision plus the /// per-phase wall clock that the stop-path budget line reports. #[derive(Debug, Clone, Default)] @@ -343,6 +374,10 @@ pub struct RecordingController { /// Current session ID for tracking session_id: Arc>>, + /// The one observer bus for the active recording. Presentation may publish + /// mutable drafts through it, but only the stop controller publishes the + /// immutable product seal after every automatic stage completes. + active_transcript_bus: Arc>>>, /// Task handle for delayed hold-start (800ms default) hold_start_task: Arc>>>, @@ -504,26 +539,37 @@ impl RecordingController { Err(error) => warn!("Model manager unavailable during startup: {error}"), } + // Lexicon table (~14.5k rules, seconds to compile) warms off-thread + // too: its first toucher used to be the Apple live-session thread, + // which put the whole compile between "audio stream started" and + // "recognizer ready" (5.1 s arm stall, session a5623d55). + codescribe_core::pipeline::stream_postprocess::warm_lexicon(); + if !crate::whisper::is_initialized() { // Best-effort BACKGROUND prewarm — never block recording readiness. // // Product invariant: recording readiness is NOT engine readiness. // Audio capture must start the moment the user presses record; the - // live pipeline and the final pass lazy-load the engine on first use. + // live local refinement and explicit Retranscribe lazy-load the + // engine on first use. // A failed prewarm is a warning, not an app or recording failure. // The idle-unload reaper (commit 2b8bb1f) may legitimately drop the // engine later and the next call reloads it — pinning it here would // undo that GPU/host-memory reclaim. // - // Warm the ACTIVE router engine (Apple SpeechAnalyzer on macOS 26+, - // Candle on fallback/older macOS) AND run a synthetic warmup - // inference, so the first dictation pays neither model-load nor - // Metal kernel-compilation latency — matching the old always-instant - // behaviour where the long-lived daemon was warm before first use. + // Warm the engine AUTHORIZED for normal recording and run a + // synthetic inference. Cloud/Apple-only always warm Apple even + // when a stale engine env names Candle; only Local power may + // pay local model-load / Metal kernel-compilation here. + let local_whisper_allowed = + codescribe_core::audio::streaming_recorder::production_local_whisper_allowed( + &UserSettings::load(), + ); std::thread::Builder::new() .name("stt-prewarm".into()) - .spawn(|| { - if let Err(e) = crate::stt::prewarm_active_engine() { + .spawn(move || { + if let Err(e) = crate::stt::prewarm_recording_engine(local_whisper_allowed) + { warn!( "STT background prewarm failed (will lazy-load on first use): {}", e @@ -550,6 +596,7 @@ impl RecordingController { force_raw_mode: Arc::new(RwLock::new(false)), force_ai_mode: Arc::new(RwLock::new(false)), session_id: Arc::new(RwLock::new(None)), + active_transcript_bus: Arc::new(RwLock::new(None)), hold_start_task: Arc::new(Mutex::new(None)), hold_start_generation: Arc::new(AtomicU64::new(0)), start_transition_in_flight: Arc::new(AtomicBool::new(false)), @@ -582,6 +629,19 @@ impl RecordingController { *self.state.read().await } + /// Forward one host sleep/wake boundary to the active recording session. + /// + /// This never creates a recorder or starts an engine. When capture is not + /// active it is a normal no-op; otherwise the per-recording lifecycle + /// channel wakes the session loop and degrades Layer 1 fail-closed. + pub async fn note_sleep_wake(&self) -> bool { + self.recorder + .lock() + .await + .as_ref() + .is_some_and(StreamingRecorder::note_sleep_wake) + } + /// Subscribe to the controller's IPC event stream. Each subscriber gets its /// own receiver; a slow consumer lags rather than stalling the producer. pub fn subscribe_events(&self) -> broadcast::Receiver { @@ -600,6 +660,17 @@ impl RecordingController { publish_recording_indicator(BadgeMode::Processing, hold_indicator); } + /// Cross the product truth boundary exactly once. Engine finals and engine + /// session close are still mutable draft stages: adjudication, dictionary + /// cleanup, and formatting all happen later. Normal stop has no file pass. The + /// text handed here is the same text used for history and delivery. + async fn seal_active_transcript(&self, text: String) { + let bus = self.active_transcript_bus.read().await.clone(); + if let Some(bus) = bus { + bus.publish_sealed(text, None); + } + } + /// Publish a cursor-badge mode, honoring the user's badge setting. async fn publish_indicator(&self, mode: BadgeMode) { let hold_indicator = self.config.read().await.hold_indicator; @@ -683,21 +754,21 @@ impl RecordingController { } } - /// Capture the assistive trigger context (selection + frontmost app) for a - /// session whose microphone is owned by the Agent composer. The controller - /// start paths (`schedule_hold_start` / `start_toggle_recording`) never run - /// on that route, so without this arm the HOTKEYS_CONTRACT line "Selection - /// is captured in the trigger handler, never at send time" had no executor - /// on the primary assistive path and auto-send delivered the spoken text - /// alone (review P0-02). The bridge calls this exactly when a NEW agent - /// capture is about to start (capture owner still none). - pub async fn arm_assistive_trigger_context(&self) { - let context = tokio::task::spawn_blocking(capture_assistive_context) - .await - .unwrap_or_default(); - *self.pre_overlay_frontmost_app.write().await = context.frontmost_app.clone(); - *self.assistive_context.write().await = Some(context.clone()); - *self.pending_assistive_context.write().await = Some(context); + /// Attach the current OS selection as `{selection_N}` during an in-flight + /// hold. Destination, overlay visibility, and Agent UI stay unchanged. + pub async fn attach_hold_selection(&self) -> Result<()> { + let current_state = self.current_state().await; + let pending_hold = self.hold_start_task.lock().await.is_some(); + if !matches!(current_state, State::RecHold | State::RecToggle) && !pending_hold { + debug!("attach_hold_selection ignored: no in-flight hold"); + return Ok(()); + } + + let prior_frontmost_app = self.pre_overlay_frontmost_app.read().await.clone(); + let _ctx = self + .capture_assistive_combo_context(current_state, prior_frontmost_app) + .await; + Ok(()) } /// Deliver the overlay's current transcript with the context captured at @@ -742,6 +813,22 @@ impl RecordingController { ); return Ok(false); } + let to_agent = resolve_delivery_route( + DeliveryIntent::OverlayToAgent, + DeliveryFacts { + has_text: true, + no_speech: false, + auto_paste_enabled: false, + overlay_enabled: true, + live_stream_session: false, + commit_required: false, + latched_target_is_self: false, + }, + ); + info!( + "{}", + format_delivery_route_line(DeliveryIntent::OverlayToAgent, to_agent, None,) + ); // Dictation/formatting sessions never run the assistive pipeline branch // that arms `pending_assistive_context`, so the overlay's explicit // "To Agent" used to fail closed behind a live button (review P0-03). @@ -754,14 +841,23 @@ impl RecordingController { None => match self.assistive_context.write().await.take() { Some(context) => context, None => { + // An explicit send must never be refused for want of a + // context. The trigger context dies with the session + // (`reset_session_fields`), but the terminal overlay — and + // its live "To Agent" button — outlives it by minutes; the + // 2026-08-13 01:02 session logged six no_pending_context + // refusals against a user clicking a button the UI showed + // as available. Double-send protection lives in the Swift + // `agentDeliveryStarted` latch, not here. Degrade to a + // bare context: the click means "send this text". info!( "{}", format_assistive_delivery_budget_line( delivery_started.elapsed().as_secs_f64(), - "no_pending_context", + "degraded_no_context", ) ); - return Ok(false); + AssistiveContext::default() } }, }; @@ -849,16 +945,31 @@ impl RecordingController { self.pre_overlay_frontmost_app.read().await.clone() } - /// Paste user-edited overlay text through the same controller-owned delivery - /// path as automatic dictation delivery: restore the pre-overlay target app, - /// apply transcript tagging config, then synthesize Cmd+V via clipboard. + /// Paste user-edited overlay text through the delivery throne, then restore + /// the latched target and synthesize Cmd+V via clipboard. /// - /// Delivery is fail-closed: Cmd+V is posted only when the runtime frontmost - /// app exactly matches the latched target and Accessibility permits event - /// posting. Every unconfirmed case becomes a tagged clipboard copy. + /// `resolve_delivery_route(OverlayInsert)` picks the destination. Codescribe + /// as the latched target arms Paste Here instead of pasting into ourselves. + /// Otherwise delivery is fail-closed: Cmd+V is posted only when the runtime + /// frontmost app exactly matches the latched target and Accessibility + /// permits event posting. Every unconfirmed case parks Paste Here and + /// leaves the user's clipboard alone. pub async fn paste_text_from_overlay(&self, text: String) -> Result { let trimmed = text.trim(); - if trimmed.is_empty() { + let target_app = self.pre_overlay_frontmost_app.read().await.clone(); + let intent = DeliveryIntent::OverlayInsert; + let decision = resolve_delivery_route( + intent, + overlay_insert_facts( + !trimmed.is_empty(), + target_app.as_deref().is_some_and(target_is_self_app), + ), + ); + info!( + "{}", + format_delivery_route_line(intent, decision, target_app.as_deref()) + ); + if trimmed.is_empty() || decision.route == DeliveryRoute::ArchiveOnly { return Ok(OverlayPasteResult { delivery: OverlayPasteDelivery::Noop, target_app_name: None, @@ -867,8 +978,12 @@ impl RecordingController { deferred_insert_failure: None, }); } + if decision.route == DeliveryRoute::DeferredInsert { + return self + .arm_overlay_text(trimmed, target_app, Some("Codescribe".to_string())) + .await; + } - let target_app = self.pre_overlay_frontmost_app.read().await.clone(); if let Some(app_name) = target_app.as_deref() { let activated = activate_app_by_name(app_name); let focus_confirmed = @@ -936,12 +1051,12 @@ impl RecordingController { }) } - /// Degrade path when a synthetic paste is not safe to post: arm the payload - /// behind the "Paste Here" shortcut, or fall back to a plain clipboard copy - /// when that shortcut cannot be registered. + /// Degrade path when a synthetic paste is not safe to post: park the payload + /// in the process-local Paste Here slot. Never writes the system pasteboard. /// /// The out-params carry back what the UI must tell the user — which - /// shortcut is now armed, or why arming failed. + /// shortcut is now armed, or why the chord is not bound. The transcript + /// still sits in-process either way; the user's clipboard stays put. fn arm_or_copy_deferred_payload( &self, payload: String, @@ -949,6 +1064,9 @@ impl RecordingController { shortcut_label: &mut Option, registration_failure: &mut Option, ) -> Result { + if !park_refused_paste(payload) { + return Ok(OverlayPasteDelivery::Noop); + } let collision = shortcut_registry::deferred_insert_shortcut_conflict(config.deferred_insert_shortcut); match deferred_insert_registration( @@ -959,35 +1077,26 @@ impl RecordingController { DeferredInsertRegistration::Available { shortcut_label: label, } => { - if !clipboard::arm_deferred_insert(payload) { - return Ok(OverlayPasteDelivery::Noop); - } *shortcut_label = Some(label); Ok(OverlayPasteDelivery::DeferredInsertArmed) } DeferredInsertRegistration::Unavailable { reason } => { - clipboard::set_clipboard(&payload) - .context("Failed to copy overlay text after Paste Here registration failure")?; *registration_failure = Some(reason); - Ok(OverlayPasteDelivery::CopiedToClipboard) + // Slot is armed. The chord is not bound, so the overlay stays + // the visible buffer. Do not steal the user's clipboard. + Ok(OverlayPasteDelivery::DeferredInsertArmed) } } } - /// Arm the edited overlay transcript without attempting target activation. - /// Used when the caret is known to still be inside Codescribe. - pub async fn defer_text_from_overlay(&self, text: String) -> Result { - let trimmed = text.trim(); - if trimmed.is_empty() { - return Ok(OverlayPasteResult { - delivery: OverlayPasteDelivery::Noop, - target_app_name: None, - frontmost_app_name: None, - deferred_insert_shortcut: None, - deferred_insert_failure: None, - }); - } - let target_app = self.pre_overlay_frontmost_app.read().await.clone(); + /// Arm tagged overlay text for Paste Here. Shared by the throne's + /// `DeferredInsert` verdict and by the explicit defer click. + async fn arm_overlay_text( + &self, + trimmed: &str, + target_app: Option, + frontmost_app_name: Option, + ) -> Result { let config = self.config.read().await.clone(); let payload = maybe_wrap_transcript_for_delivery(trimmed, &config, "dictation"); let mut deferred_insert_shortcut = None; @@ -1001,16 +1110,42 @@ impl RecordingController { Ok(OverlayPasteResult { delivery, target_app_name: target_app, - frontmost_app_name: Some("Codescribe".to_string()), + frontmost_app_name, deferred_insert_shortcut, deferred_insert_failure, }) } - /// Copy the tagged transcript to the clipboard without any synthetic paste. - /// Degrade path for the overlay Insert action when the caret already sits - /// inside Codescribe (e.g. the overlay's editable FINAL), where a synthetic - /// Cmd+V would paste the transcript back into the overlay itself. + /// Arm the edited overlay transcript without attempting target activation. + /// Used when the caret is known to still be inside Codescribe. + pub async fn defer_text_from_overlay(&self, text: String) -> Result { + let trimmed = text.trim(); + let target_app = self.pre_overlay_frontmost_app.read().await.clone(); + let intent = DeliveryIntent::OverlayInsert; + // This entry exists because Swift already knows the caret is inside + // Codescribe. That is a latched-self fact, not a focus-at-click fact. + let decision = + resolve_delivery_route(intent, overlay_insert_facts(!trimmed.is_empty(), true)); + info!( + "{}", + format_delivery_route_line(intent, decision, target_app.as_deref()) + ); + if trimmed.is_empty() || decision.route == DeliveryRoute::ArchiveOnly { + return Ok(OverlayPasteResult { + delivery: OverlayPasteDelivery::Noop, + target_app_name: None, + frontmost_app_name: None, + deferred_insert_shortcut: None, + deferred_insert_failure: None, + }); + } + self.arm_overlay_text(trimmed, target_app, Some("Codescribe".to_string())) + .await + } + + /// Explicit overlay Copy: write the tagged transcript to the system + /// pasteboard. This is the only automatic-adjacent verb allowed to replace + /// the user's clipboard. Insert / stop-path refuse must not call this. pub async fn copy_text_from_overlay(&self, text: String) -> Result<()> { let trimmed = text.trim(); if trimmed.is_empty() { @@ -1086,6 +1221,7 @@ impl RecordingController { *self.force_raw_mode.write().await = false; *self.force_ai_mode.write().await = false; *self.session_id.write().await = None; + *self.active_transcript_bus.write().await = None; *self.assistive_context.write().await = None; *self.pre_overlay_frontmost_app.write().await = None; self.start_transition_in_flight @@ -1253,14 +1389,19 @@ impl RecordingController { preview_deltas_enabled: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, + transcript_bus: Option>, ) -> Arc { let delta_sink = preview_deltas_enabled.then(|| { Arc::new(helpers::RoutingDeltaSink) as Arc }); - let pe: Arc = Arc::new( - PresentationEmitter::new(transcript_buffer, delta_sink, None), - ); + let pe: Arc = + Arc::new(PresentationEmitter::new_with_transcript_bus( + transcript_buffer, + delta_sink, + None, + transcript_bus, + )); let ipc_sink: Arc = Arc::new(helpers::IpcBroadcastSink::new(event_broadcast)); let telemetry_sink: Arc = @@ -1308,6 +1449,7 @@ impl RecordingController { preview_deltas_enabled: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, + transcript_bus: Option>, ) { Self::configure_level_broadcast(recorder, event_broadcast.clone()); recorder.set_event_sink(Some(Self::build_recording_event_sink( @@ -1315,6 +1457,7 @@ impl RecordingController { preview_deltas_enabled, event_broadcast, session_telemetry, + transcript_bus, ))); } @@ -1326,6 +1469,7 @@ impl RecordingController { _flush_voice_chat_on_vad_end: bool, event_broadcast: broadcast::Sender, session_telemetry: SharedSessionTelemetry, + transcript_bus: Option>, ) { // Hands-off is ONE continuous recorder session (ADR 2026-05-28 Faza 1). // Normal hands-off uses cumulative SessionRendered deltas in the transcription overlay. @@ -1340,6 +1484,7 @@ impl RecordingController { preview_deltas_enabled, event_broadcast, session_telemetry, + transcript_bus, ))); } @@ -1532,6 +1677,12 @@ impl RecordingController { let current_state = self.current_state().await; if current_state == State::Idle { self.schedule_hold_start(event.assistive).await?; + // Fn down with a live OS selection attaches `{selection_1}` + // immediately. Mid-hold arm pulses add `{selection_2..n}`. + // Destination stays dictation — do not arm Chat/Agent. + if !event.assistive && matches!(event.hold_mode, HoldMode::Raw) { + self.attach_hold_selection().await?; + } } } HotkeyAction::Up => { @@ -2051,6 +2202,7 @@ impl RecordingController { let hold_start_generation = Arc::clone(&self.hold_start_generation); let start_transition_in_flight = Arc::clone(&self.start_transition_in_flight); let session_telemetry = Arc::clone(&self.session_telemetry); + let active_transcript_bus = Arc::clone(&self.active_transcript_bus); let task = tokio::spawn(async move { // Wait for the configured delay @@ -2155,8 +2307,11 @@ impl RecordingController { set_assistive_session(false); return; } - // Hold-to-talk: the key-down is the source of truth. Don't auto-stop mid-hold. + // Hold-to-talk: the key-down is the source of truth. Don't auto-stop + // the session mid-hold. Silence still closes an SFSpeech epoch so + // Layer 1 can be fed — same knob as toggle (`TOGGLE_SILENCE_SEC`). rec.recorder.config.auto_silence = false; + rec.set_utterance_silence_sec(Some(config.toggle_silence_sec)); rec.recorder.set_on_vad_stop(move || { info!("VAD callback: setting vad_triggered flag"); vad_flag.store(true, Ordering::SeqCst); @@ -2166,6 +2321,15 @@ impl RecordingController { // so the very first deltas route to the correct overlay. set_assistive_session(is_assistive); reset_session_telemetry(&session_telemetry); + let transcript_bus = TranscriptBus::open(TranscriptSession { + session_id: new_session_id, + mode: if is_assistive { + TranscriptMode::Assistive + } else { + TranscriptMode::Dictation + }, + }) + .map(Arc::new); // Runtime pipeline is always event-based. Hold mode has no utterance callback; // text is finalized on key-up in `finish_recording`. @@ -2174,7 +2338,10 @@ impl RecordingController { is_assistive || overlay_enabled, event_broadcast.clone(), Arc::clone(&session_telemetry), + transcript_bus.clone(), ); + let settings = UserSettings::load(); + rec.configure_layer1(&settings, gateway_session_availability(&config)); if !cfg!(test) { let language_hint = language.whisper_hint().map(str::to_string); // Audio-first cold start: do not preflight Whisper here. The @@ -2193,6 +2360,7 @@ impl RecordingController { is_assistive || overlay_enabled, event_broadcast.clone(), Arc::clone(&session_telemetry), + transcript_bus.clone(), ); let retry_result = rec.start_event_session(language_hint).await; if let Err(retry_err) = retry_result { @@ -2212,6 +2380,11 @@ impl RecordingController { } } + *active_transcript_bus.write().await = transcript_bus.clone(); + if let Some(bus) = &transcript_bus { + bus.publish_started(); + } + if hold_start_generation.load(Ordering::SeqCst) != task_generation { warn!("Hold-start superseded after recorder start; stopping stale session"); if rec.recorder.is_active() @@ -2361,6 +2534,15 @@ impl RecordingController { // so the very first deltas route to the correct overlay. set_assistive_session(is_assistive); reset_session_telemetry(&self.session_telemetry); + let transcript_bus = TranscriptBus::open(TranscriptSession { + session_id: new_session_id, + mode: if is_assistive { + TranscriptMode::Agent + } else { + TranscriptMode::Dictation + }, + }) + .map(Arc::new); // Runtime pipeline is always event-based. Self::configure_toggle_event_sink( @@ -2369,7 +2551,10 @@ impl RecordingController { is_assistive, self.event_broadcast.clone(), Arc::clone(&self.session_telemetry), + transcript_bus.clone(), ); + let settings = UserSettings::load(); + recorder.configure_layer1(&settings, gateway_session_availability(&config)); // Skip actual audio stream in tests (no CoreAudio device needed) let language_hint = language.whisper_hint().map(str::to_string); @@ -2390,6 +2575,7 @@ impl RecordingController { is_assistive, self.event_broadcast.clone(), Arc::clone(&self.session_telemetry), + transcript_bus.clone(), ); if let Err(retry_err) = recorder.start_event_session(language_hint).await { drop(recorder_guard); @@ -2405,6 +2591,10 @@ impl RecordingController { return Err(e); } } + *self.active_transcript_bus.write().await = transcript_bus.clone(); + if let Some(bus) = &transcript_bus { + bus.publish_started(); + } drop(recorder_guard); // Transition to REC_TOGGLE immediately after recorder starts. @@ -2426,9 +2616,9 @@ impl RecordingController { /// Stop a toggle session under a watchdog. /// - /// The full stop — recorder drain, final-pass STT, post-process, delivery — - /// must finish within `STOP_TIMEOUT`. When it does not (final-pass deadlock - /// on the Metal device, lock contention, a `stop` blocked in a CoreAudio + /// The full stop — recorder drain, live-session adjudication, post-process, + /// delivery — must finish within `STOP_TIMEOUT`. When it does not (lock + /// contention, a `stop` blocked in a CoreAudio /// callback), recovery forces `Idle` so the next toggle press registers, the /// badge clears, and the tray stops claiming idle over a hung recording. async fn stop_toggle_and_adjudicate(&self) -> Result<()> { @@ -2436,9 +2626,9 @@ impl RecordingController { return Ok(()); } - // Watchdog: full stop+adjudicate (recorder.stop + final-pass STT + post-process - // + paste) must complete within STOP_TIMEOUT. If it stalls — final-pass deadlock - // on Metal device, RwLock contention, recorder.stop blocked on cpal callback — + // Watchdog: full stop+adjudicate (recorder.stop + live truth + post-process + // + paste) must complete within STOP_TIMEOUT. If it stalls — RwLock + // contention or recorder.stop blocked on a cpal callback — // force recovery to Idle so subsequent toggle presses register, badge clears, // and tray reflects truth instead of showing Idle while recording is hung. match tokio::time::timeout(STOP_TIMEOUT, self.stop_toggle_and_adjudicate_inner()).await { @@ -2703,17 +2893,14 @@ impl RecordingController { result.map(|_| ()) } - /// Turn a stopped recording into a delivered transcript: decide the final - /// pass, adjudicate which transcript is the truth, then run the text - /// pipeline. + /// Turn a stopped recording into a delivered transcript: drain the live + /// transcript, adjudicate its truth, then run the text pipeline. /// - /// Final-pass routing is typed rather than heuristic. `Always` re-passes the - /// whole file; `Smart` on a complete streaming transcript skips; `Smart` on - /// an incomplete one transcribes only the uncommitted audio tail and appends - /// it, because committed streaming text is immutable under the append-only - /// overlay doctrine and a full re-pass there would rewrite what the user - /// already watched being typed. `Off` still emits a skipped LocalFinalPass - /// so provenance stays honest — it gates the WAV re-pass, never the lexicon. + /// Normal stop never uploads or decodes the completed WAV. Both local and + /// cloud refinement happen inside the live session; whole-file inference is + /// reserved for explicit Retranscribe actions (Overlay, Dictionary, + /// Teacher). A typed skipped LocalFinalPass is still emitted so old + /// provenance readers remain honest while the legacy routing UI is retired. /// /// A missing tail boundary is carried by the type, not by a sentinel: the /// `Skip` variant produces no `from_secs` at all, so handing `0.0` to the @@ -2744,6 +2931,9 @@ impl RecordingController { } else { None }; + if let Some(path) = &audio_path { + retain_last_session_audio(path.as_path()); + } let recording_timestamp = chrono::Local::now(); @@ -2753,53 +2943,28 @@ impl RecordingController { let use_local_stt = config.use_local_stt; let raw_save_enabled = raw_save_enabled(assistive); - let cloud_config = if use_local_stt { - None - } else { - match (config.stt_endpoint.clone(), config.stt_api_key.clone()) { - (Some(endpoint), Some(api_key)) - if !endpoint.trim().is_empty() && !api_key.trim().is_empty() => - { - Some((endpoint, api_key)) - } - _ => None, - } - }; - let assistive_loop = assistive && self.assistive_loop_active.load(Ordering::SeqCst); let mut local_final_pass_verdict = None; - let mut cloud_verdict_opt = None; - let mut cloud_handle: Option>> = + // Normal recording never uploads the completed WAV. Cloud audio is + // already carried by the bounded live WSS Layer 1 session; multipart + // file upload belongs only to explicit retranscribe surfaces. + let cloud_verdict_opt = None; + let cloud_handle: Option>> = None; let mut local_final_pass_attempted = false; - if let Some((cloud_endpoint, cloud_api_key)) = cloud_config { - if let Some(path) = &audio_path { - let cloud_path = path.as_path().to_path_buf(); - let cloud_language = language_opt.map(str::to_string); - cloud_handle = Some(tokio::spawn(async move { - crate::client::transcribe_cloud( - &cloud_path, - cloud_language.as_deref(), - &cloud_endpoint, - &cloud_api_key, - ) - .await - })); - } else { - warn!("Cloud STT disabled: no audio file available"); - } - } else if !use_local_stt { - warn!("Cloud STT disabled: STT_ENDPOINT/STT_API_KEY missing"); - } - let routing_mode = final_pass_routing_mode(); let prefer_apple = codescribe_core::stt::active_engine_is_apple(); - // Honest mode: Off never runs a full file re-pass (Apple or not). - // Smart/Always decide via final_pass_action — no silent rewrite. - let run_local_final_pass = - use_local_stt && !matches!(routing_mode, FinalPassRoutingMode::Off); + let streaming_engine_label = if prefer_apple { + "live_apple" + } else { + "streaming_whisper" + }; + // Product contract: a normal stop is never a file-pass trigger. Keep + // the legacy typed routing below inert until it is removed with the + // obsolete settings surface; explicit retranscribe owns file decoding. + let run_local_final_pass = false; let effective_routing = routing_mode; let mut final_pass_secs = 0.0; let mut final_pass_stages = FinalPassStages::default(); @@ -2810,7 +2975,57 @@ impl RecordingController { // commit_source come from session telemetry, never hardcoded. let completeness_evidence = StreamingCompletenessEvidence::from_session(&streaming_text, &session_snap); - let completeness = assess_streaming_completeness(&completeness_evidence); + let structural_completeness = assess_streaming_completeness(&completeness_evidence); + // W-B committed-density floor. Structural coverage cannot notice + // that the live accumulator ate the speech it was covering, so the + // recorded WAV — complete on both measured eaten takes — referees + // the verdict. The session's own `committed_through_secs` is exactly + // the quantity that bug corrupts, so it cannot referee itself; a + // header read is the whole cost, paid before the existing typed + // routing matrix chooses skip, punctuation, or recovery. + let audio_secs = audio_path.as_ref().and_then(|path| { + codescribe_core::audio::recorder::wav_duration_secs(path.as_path()) + }); + let density_guarded = apply_committed_density_floor( + structural_completeness, + audio_secs, + completeness_evidence.committed_chars, + ); + if density_guarded != structural_completeness { + info!( + "{}", + format_density_override_line( + structural_completeness, + audio_secs.unwrap_or(f32::NAN), + completeness_evidence.committed_chars, + ) + ); + } else if audio_secs.is_none() { + // A guard that could not measure must say so: silence here is + // otherwise indistinguishable from a session that passed. + info!( + "final_pass_density_guard silent reason=audio_duration_unknown committed_chars={} has_audio_path={}", + completeness_evidence.committed_chars, + audio_path.is_some(), + ); + } + // W-Cb Layer 1 under-commit consumer. W-C already classifies an + // under-commit retranscription, appends what it can place on safe + // zero-width anchors, and escalates the unplaceable remainder as + // `EngineEvent::Warning { code: tail_patch_under_commit }`. Until + // now that warning reached the log and the IPC wire and changed no + // verdict. Applied AFTER the density floor on purpose: when both + // fire, `starved_density` carries measured numbers this one does + // not, and both route to the same tail-gap ladder — so the richer + // diagnosis is the one worth keeping in the receipts. + let completeness = + apply_residual_required_demotion(density_guarded, session_snap.residual_required); + if session_snap.residual_required { + info!( + "{}", + format_residual_required_line(density_guarded, completeness) + ); + } // Typed routing: Always → full file re-pass; Smart+Complete / Off → // skip; Smart+Incomplete → tail-gap append (committed text immutable). // Live-lane fence (w2-b): keeps the action matrix; residual partials @@ -2827,13 +3042,9 @@ impl RecordingController { if matches!(action, FinalPassAction::SkipStreamingFinal) { local_final_pass_attempted = true; - let reason = match completeness { - StreamingCompleteness::Complete => "complete_streaming_transcript", - // Unreachable through the Smart mapping (shape routes to - // PunctuationRepass), reachable if a future mode skips on it. - StreamingCompleteness::CompleteShapeDeficient => "shape_deficient", - StreamingCompleteness::Incomplete { reason } => reason, - }; + // One label table for every receipt naming a verdict, so the + // skip line and the guard lines cannot drift apart. + let reason = completeness_label(completeness); let commit_src = completeness_evidence .commit_source .map(CompletenessCommitSource::as_str) @@ -3349,19 +3560,6 @@ impl RecordingController { ); } - if !use_local_stt { - if let Some(handle) = cloud_handle.take() { - info!("Awaiting cloud STT as selected transcript backend"); - match handle.await { - Ok(Ok(verdict)) => cloud_verdict_opt = Some(verdict), - Ok(Err(e)) => error!("Cloud transcription failed: {}", e), - Err(e) => error!("Cloud transcription task failed: {}", e), - } - } else { - warn!("Cloud backend unavailable (cloud disabled or missing credentials)"); - } - } - let session_telemetry = snapshot_session_telemetry(&self.session_telemetry); let mut truth_verdict = adjudicate_recording_truth( use_local_stt, @@ -3369,12 +3567,17 @@ impl RecordingController { local_final_pass_verdict, streaming_text, cloud_verdict_opt.clone(), + Some(streaming_engine_label), &session_telemetry, ); if transcript_source_override.is_some() + && truth_verdict.raw_text.is_some() && matches!( truth_verdict.transcript_source, - Some(RecordingTranscriptSource::LocalFinalPass) + Some( + RecordingTranscriptSource::LocalFinalPass + | RecordingTranscriptSource::Streaming + ) ) { truth_verdict.transcript_source = transcript_source_override; @@ -3508,9 +3711,10 @@ impl RecordingController { write_truth_sidecar_logged(&audio_saved_path, &truth_metadata); } - // No-speech stops still paid the final pass — keep the stage - // receipt so latency truth covers every real stop. + // Keep the all-zero legacy stage receipt so latency truth + // explicitly proves that no file pass ran on this stop. info!("{}", format_final_pass_stages_line(final_pass_stages)); + self.seal_active_transcript(String::new()).await; return Ok(ProcessRecordingOutcome::no_speech(reason)); } }; @@ -3622,8 +3826,8 @@ impl RecordingController { /// post-process, format per mode, persist, then paste or hand off. /// /// The dictionary step (lexicon, cleanup, semantic gate) always runs — it is - /// independent of the final-pass mode, which routes only the optional WAV - /// re-pass. Formatting then follows the mode the hotkey chose, and the + /// independent of the legacy final-pass setting. Formatting then follows + /// the mode the hotkey chose, and the /// Light+ sentence-shape floor is applied to every lane that *promised* /// formatting. RAW lanes are excluded on purpose: `force_raw` and Toggle-OFF /// promise the user their literal words. @@ -3677,14 +3881,9 @@ impl RecordingController { // optional full WAV re-pass / layered tail-patch. Every delivery path still // runs StreamPostProcessor before overlay, clipboard, augmentation, or dataset. let postproc_started = std::time::Instant::now(); - let (clean_text, postprocess_stats) = { - let mut finalizer = StreamPostProcessor::new(); - let clean_text = finalizer - .process(&raw_text) - .unwrap_or_else(|| raw_text.clone()); - let stats = finalizer.stats(); - (clean_text, stats) - }; + let postprocessed = postprocess_transcript_for_delivery(&raw_text); + let clean_text = postprocessed.text; + let postprocess_stats = postprocessed.stats; let postproc_secs = postproc_started.elapsed().as_secs_f64(); info!( "Post-processed transcript ({} chars, delta={}, drops={}/{}, gate_drops={}, lexicon_rewrites={})", @@ -3741,6 +3940,30 @@ impl RecordingController { // - AI on selection? → Hold + Cmd (Selection) let mut is_ai_noop = false; let format_started = std::time::Instant::now(); + + // Start the embedder loading *alongside* the model call, not after it. + // + // The semantic guard below is the embedder's only consumer and runs once + // formatting returns, so a cold engine charged its full load to the stop + // path in series behind the LLM: `semantic_guard took_ms=1127` on + // 2026-08-12, ~1.0s of which was the model load and 0.13s the actual + // comparison. The round-trip it now overlaps with took 11.05s — the load + // fits inside it many times over. + // + // Deliberately scoped to lanes that are about to call the LLM: warming + // unconditionally (or at startup) would keep 471 MB resident for takes + // that never reach the guard, which is the opposite of the idle-RAM + // decision. `force_raw` and every no-LLM fallback stay cold. + if session_prewarms_semantic_guard( + &config, + assistive, + force_raw, + force_ai, + ai_key_available, + ) { + codescribe_core::embedder::singleton::warm(); + } + let (formatted_text, output_kind) = if assistive { info!( "Assistive mode ({:?}): finalizing transcript before overlay delivery", @@ -3759,11 +3982,12 @@ impl RecordingController { && ai_key_available { let lang_str = language_opt.map(String::from); - let result = crate::ai_formatting::format_text_with_status( + // W13-1: consume the inline-format buffer when armed — stop + // pays only for the unformatted tail; falls back to the classic + // full-text format when the buffer cannot prove coverage. + let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), - false, - None, ) .await; is_ai_noop = result.status == crate::ai_formatting::AiFormatStatus::AiNoop; @@ -3815,11 +4039,10 @@ impl RecordingController { info!("Formatting mode (Left Option): correcting transcript via AI"); let lang_str = language_opt.map(String::from); - let result = crate::ai_formatting::format_text_with_status( + // W13-1: inline buffer first, classic full format as fallback. + let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), - false, - None, ) .await; is_ai_noop = result.status == crate::ai_formatting::AiFormatStatus::AiNoop; @@ -3862,11 +4085,10 @@ impl RecordingController { info!("Formatting mode (Toggle): correcting transcript via AI"); let lang_str = language_opt.map(String::from); - let result = crate::ai_formatting::format_text_with_status( + // W13-1: inline buffer first, classic full format as fallback. + let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), - false, - None, ) .await; is_ai_noop = result.status == crate::ai_formatting::AiFormatStatus::AiNoop; @@ -4026,6 +4248,13 @@ impl RecordingController { let final_formatted_text = formatted_text.clone(); + // This is the first point at which the text is product-final: live + // layers, optional file/cloud adjudication, dictionary cleanup, and + // formatting are all complete. Seal the same bytes that history and + // delivery consume; the bus rejects every later machine write. + self.seal_active_transcript(final_formatted_text.clone()) + .await; + // Surface the authoritative final transcript to external dictation surfaces // (the SwiftUI overlay). This is the same `final_formatted_text` that is // pasted (auto-delivery) and written to history (tray "Copy"), so the overlay @@ -4127,24 +4356,28 @@ impl RecordingController { let has_final_text = !final_formatted_text.trim().is_empty(); let notes_save_only = config.quick_notes_enabled && config.quick_notes_save_only; - let should_auto_paste = resolve_auto_paste_policy(AutoPastePolicyContext { - trigger: if force_ai { - AutoPasteTrigger::DoubleLeftOption - } else { - AutoPasteTrigger::Hold + let latched_target = self.pre_overlay_frontmost_app.read().await.clone(); + let intent = delivery_intent_from_session(assistive, force_ai, notes_save_only); + let decision = resolve_delivery_route( + intent, + DeliveryFacts { + has_text: has_final_text, + no_speech: truth_no_speech_reason.is_some(), + auto_paste_enabled: config.auto_paste_enabled, + overlay_enabled: config.transcription_overlay_enabled, + live_stream_session, + commit_required: commit_trigger.is_some(), + latched_target_is_self: latched_target.as_deref().is_some_and(target_is_self_app), }, - persisted_enabled: config.auto_paste_enabled, - overlay_enabled: config.transcription_overlay_enabled, - assistive, - no_speech: truth_no_speech_reason.is_some(), - empty_output: !has_final_text, - notes_save_only, - // Live-stream preview and explicit quality/safety commit branches - // remain separate named vetoes. Toggle-adjudicated final delivery is - // no longer a veto. - live_stream_session, - commit_required: commit_trigger.is_some(), - }); + ); + info!( + "{}", + format_delivery_route_line(intent, decision, latched_target.as_deref()) + ); + // Destination is the route, not a second policy boolean. The legacy + // auto-paste matrix still exists for its own tests; the stop path no + // longer consults it as a competing king. + let should_auto_paste = decision.route.posts_synthetic_paste(); // Delivery span: history persistence + paste/deliver_once handoff. // This is the user-visible delivery cone — not phase-4 cleanup. @@ -4176,9 +4409,9 @@ impl RecordingController { // Paste lane consumes the ContextBucket exactly like assistive delivery // does (assemble + archive under one lock — parity with - // deliver_pending_assistive_transcript). Assistive sessions never take - // this branch (`resolve_auto_paste_policy` vetoes them), so their bucket - // stays intact for the overlay delivery lane. + // deliver_pending_assistive_transcript). Agent-composer sessions never + // take this branch (`DeliveryRoute::ClipboardPaste` is the only paste + // king), so their bucket stays intact for the overlay To Agent lane. let paste_wire = if should_auto_paste { let mut bucket = self.context_bucket.lock().await; let wire = assemble_raw_paste_wire(&final_formatted_text, &bucket); @@ -4201,17 +4434,60 @@ impl RecordingController { &mode_label, Some(&truth_metadata), ); - if self - .automatic_delivery - .deliver_once(recording_timestamp, &paste_text) - .await? - { - info!("Text pasted successfully"); + // Restore the *latched* target before Cmd+V. Frontmost-at-stop is + // not the destination — that is how tagged raw landed in the Agent + // composer (operator: walka o tron, delivery axis). + if let Some(app_name) = latched_target.as_deref() { + let activated = activate_app_by_name(app_name); + let focus_confirmed = + activated && wait_for_frontmost_app(app_name, OVERLAY_PASTE_FOCUS_BUDGET); + debug!( + app_name, + activated, focus_confirmed, "Stop-path paste target activation" + ); + } + let frontmost = crate::os::selection::current_frontmost_app_name(); + let preflight = clipboard::synthetic_paste_preflight(); + let disposition = overlay_paste_disposition( + latched_target.as_deref(), + frontmost.as_deref(), + preflight.can_post_events(), + ); + if disposition == OverlayPasteDisposition::Paste { + if self + .automatic_delivery + .deliver_once(recording_timestamp, &paste_text) + .await? + { + info!("Text pasted successfully"); + } else { + info!("Automatic delivery skipped: recording timestamp already delivered"); + } } else { - info!("Automatic delivery skipped: recording timestamp already delivered"); + let mut deferred_insert_shortcut = None; + let mut deferred_insert_failure = None; + let delivery = self.arm_or_copy_deferred_payload( + paste_text, + &config, + &mut deferred_insert_shortcut, + &mut deferred_insert_failure, + )?; + info!( + ?disposition, + ?delivery, + shortcut = ?deferred_insert_shortcut, + failure = ?deferred_insert_failure, + target = ?latched_target, + frontmost = ?frontmost, + "delivery_route: synthetic paste refused; paste-here parked, user clipboard untouched" + ); } } else { - info!("Auto-paste skipped (mode={})", mode_label); + info!( + "Auto-paste skipped (mode={mode_label} route={route} reason={reason})", + route = decision.route.as_str(), + reason = decision.reason, + ); } let delivery_secs = delivery_started.elapsed().as_secs_f64(); diff --git a/app/controller/overlay_paste.rs b/app/controller/overlay_paste.rs index 7a3d98c4..7c51612a 100644 --- a/app/controller/overlay_paste.rs +++ b/app/controller/overlay_paste.rs @@ -12,7 +12,9 @@ pub(super) const OVERLAY_PASTE_FOCUS_BUDGET: Duration = Duration::from_millis(25 pub enum OverlayPasteDelivery { /// Synthetic Cmd+V was posted at the restored target's caret. Pasted, - /// Focus never left Codescribe; tagged text was copied instead of pasted. + /// Explicit Copy (or a leftover UniFFI outcome). Automatic refuse of + /// Cmd+V must not take this branch — that path parks Paste Here and + /// leaves the user's pasteboard alone. CopiedToClipboard, /// Synthetic event posting is not trusted; tagged text was copied instead. AccessibilityPermissionNeeded, @@ -91,13 +93,14 @@ pub(super) enum OverlayPasteDisposition { CopyAccessibilityDenied, } -/// Decide whether to paste or fall back to the clipboard. +/// Decide whether to paste or refuse the synthetic Cmd+V. /// /// Pasting requires that focus actually returned to the recorded target: both app /// names must be present, the frontmost app must not be Codescribe itself, and it /// must match the target. Only then does missing Accessibility permission become -/// the deciding factor. Any mismatch degrades to a copy rather than firing Cmd+V -/// at whatever window happens to be in front. +/// the deciding factor. Any mismatch parks Paste Here (⌘⌥V) and leaves the +/// user's pasteboard alone — it does not fire Cmd+V at whatever window is in +/// front, and it does not overwrite the clipboard the user already had. pub(super) fn overlay_paste_disposition( target_app: Option<&str>, frontmost_app: Option<&str>, @@ -120,3 +123,11 @@ pub(super) fn overlay_paste_disposition( } OverlayPasteDisposition::Paste } + +/// Park a refused synthetic paste in the process-local Paste Here slot. +/// +/// Never writes `NSPasteboard`. The user's existing clipboard stays put until +/// they press the Paste Here chord, which then does snapshot → Cmd+V → restore. +pub(super) fn park_refused_paste(payload: String) -> bool { + crate::os::clipboard::arm_deferred_insert(payload) +} diff --git a/app/controller/production_replay.rs b/app/controller/production_replay.rs new file mode 100644 index 00000000..d0fb8e90 --- /dev/null +++ b/app/controller/production_replay.rs @@ -0,0 +1,371 @@ +//! Production-owned replay seam for private overlay quality evaluation. +//! +//! Audio ingress is the only substituted boundary: decoded fixture PCM is fed +//! in 100 ms chunks instead of arriving from CoreAudio. Everything downstream +//! is shared with the overlay: recording-start Layer 1 policy, `SessionConfig`, +//! `transcription_session`, stop truth adjudication, and the unconditional +//! lexicon/text layer immediately before delivery. + +use std::collections::HashSet; +use std::path::Path; + +use anyhow::{Result, anyhow}; +use codescribe_core::asr_session::GatewaySessionAvailability; +use codescribe_core::audio::streaming_recorder::replay_production_session; +use codescribe_core::config::UserSettings; +use codescribe_core::pipeline::contracts::EngineEvent; +use codescribe_core::pipeline::contracts::TranscriptionVerdict; +use codescribe_core::pipeline::stream_postprocess::StreamPostProcessStats; +use codescribe_core::pipeline::streaming::APPLE_FINAL_OVERLAP_WARNING_CODE; + +use super::helpers::SessionTelemetrySnapshot; +use super::truth::{adjudicate_recording_truth, postprocess_transcript_for_delivery}; +use crate::presentation::emitter::reduce_transcript_events; + +/// Which production stop lane a corpus replay should exercise. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductionReplayLane { + /// Shipped no-final-pass degradation: live canvas followed by lexicon. + AppleLexicon, + /// Explicit production local full-file pass, adjudicated against live. + LocalFinalPass, +} + +impl ProductionReplayLane { + /// Stable content-free token for reports and filenames. + pub const fn as_token(self) -> &'static str { + match self { + Self::AppleLexicon => "apple_lexicon", + Self::LocalFinalPass => "local_final_pass", + } + } +} + +/// In-memory result used to calculate content-redacting quality metrics. +/// +/// Transcript bodies intentionally have no serialization implementation. The +/// corpus runner must reduce them to counts/scores before writing artifacts. +#[derive(Debug)] +pub struct ProductionOverlayReplay { + pub lane: ProductionReplayLane, + pub events: Vec, + pub live_text: String, + pub adjudicated_text: String, + pub delivered_text: String, + pub layer1_armed: bool, + pub transcript_source: Option, + pub engine_label: Option, + pub final_pass_attempted: bool, + pub final_pass_skipped: bool, + pub final_pass_skip_reason: Option, + pub postprocess_stats: StreamPostProcessStats, + pub boundary_evidence: ReplayBoundaryEvidence, +} + +/// Content-free final-boundary evidence emitted for every replay recording. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplayBoundaryEvidence { + pub final_count: usize, + pub unique_final_id_count: usize, + pub repeated_final_id_count: usize, + pub overlapping_final_window_count: usize, +} + +fn boundary_evidence(events: &[EngineEvent]) -> ReplayBoundaryEvidence { + let mut ids = HashSet::new(); + let mut windows = Vec::<(f32, f32)>::new(); + let mut final_count = 0usize; + let mut repeated_final_id_count = 0usize; + let mut overlapping_final_window_count = 0usize; + + for event in events { + if matches!(event, EngineEvent::Warning { code, .. } if code == APPLE_FINAL_OVERLAP_WARNING_CODE) + { + overlapping_final_window_count += 1; + continue; + } + let EngineEvent::UtteranceFinal { + utterance_id, + start_ts, + end_ts, + .. + } = event + else { + continue; + }; + final_count += 1; + if !ids.insert(*utterance_id) { + repeated_final_id_count += 1; + } + if start_ts.is_finite() && end_ts.is_finite() && end_ts > start_ts { + if windows + .iter() + .any(|(prior_start, prior_end)| start_ts < prior_end && end_ts > prior_start) + { + overlapping_final_window_count += 1; + } + windows.push((*start_ts, *end_ts)); + } + } + + ReplayBoundaryEvidence { + final_count, + unique_final_id_count: ids.len(), + repeated_final_id_count, + overlapping_final_window_count, + } +} + +struct ReplayDelivery { + adjudicated_text: String, + delivered_text: String, + transcript_source: Option, + engine_label: Option, + final_pass_attempted: bool, + final_pass_skipped: bool, + final_pass_skip_reason: Option, + postprocess_stats: StreamPostProcessStats, +} + +/// Shared replay stop boundary: production adjudication immediately followed +/// by the production delivery postprocessor. Keeping these calls together +/// makes a bypass detectable by one deterministic regression witness. +fn finish_replay_delivery( + live_text: String, + local_final_pass_attempted: bool, + local_final_pass_verdict: Option, + streaming_engine_label: &str, +) -> Result { + let verdict = adjudicate_recording_truth( + true, + local_final_pass_attempted, + local_final_pass_verdict, + live_text, + None, + Some(streaming_engine_label), + &SessionTelemetrySnapshot::default(), + ); + let adjudicated_text = verdict + .raw_text + .clone() + .ok_or_else(|| anyhow!("production adjudication produced no deliverable text"))?; + let postprocessed = postprocess_transcript_for_delivery(&adjudicated_text); + Ok(ReplayDelivery { + adjudicated_text, + delivered_text: postprocessed.text, + transcript_source: verdict + .transcript_source + .map(|source| source.label().to_string()), + engine_label: verdict.engine_label, + final_pass_attempted: verdict.final_pass_attempted, + final_pass_skipped: verdict.final_pass_skipped, + final_pass_skip_reason: verdict.final_pass_skip_reason, + postprocess_stats: postprocessed.stats, + }) +} + +/// Replay one WAV through the production overlay engine cone. +pub async fn replay_overlay_recording( + wav: &Path, + language: Option, + settings: &UserSettings, + gateway: GatewaySessionAvailability, + lane: ProductionReplayLane, +) -> Result { + let (samples, sample_rate) = codescribe_core::audio::load_audio_file(wav) + .map_err(|_| anyhow!("load replay audio failed"))?; + if samples.is_empty() { + return Err(anyhow!("replay WAV contains no samples")); + } + + let session = + replay_production_session(&samples, sample_rate, language.clone(), settings, gateway) + .await?; + let reducer = reduce_transcript_events(&session.events); + let full = reducer.rendered_text(); + let floor = reducer.streaming_floor(); + let live_text = if floor.trim().is_empty() { full } else { floor }; + + let (attempted, final_verdict) = match lane { + ProductionReplayLane::AppleLexicon => (false, None), + ProductionReplayLane::LocalFinalPass => ( + true, + Some( + codescribe_core::stt::transcribe_file_verdict(wav, language.as_deref()) + .map_err(|_| anyhow!("production local final pass failed"))?, + ), + ), + }; + let delivery = finish_replay_delivery( + live_text.clone(), + attempted, + final_verdict, + &session.streaming_engine_label, + )?; + + let boundary_evidence = boundary_evidence(&session.events); + Ok(ProductionOverlayReplay { + lane, + events: session.events, + live_text, + adjudicated_text: delivery.adjudicated_text, + delivered_text: delivery.delivered_text, + layer1_armed: session.layer1_armed, + transcript_source: delivery.transcript_source, + engine_label: delivery.engine_label, + final_pass_attempted: delivery.final_pass_attempted, + final_pass_skipped: delivery.final_pass_skipped, + final_pass_skip_reason: delivery.final_pass_skip_reason, + postprocess_stats: delivery.postprocess_stats, + boundary_evidence, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use codescribe_core::pipeline::contracts::TranscriptSegment; + + fn final_event(id: u64, text: &str, start_ts: f32, end_ts: f32) -> EngineEvent { + EngineEvent::UtteranceFinal { + utterance_id: id, + text: text.to_string(), + raw_text: text.to_string(), + start_ts, + end_ts, + segments: vec![TranscriptSegment { + text: text.to_string(), + start_ts, + end_ts, + }], + vad_speech_pct: None, + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + } + } + + #[test] + fn replay_stop_boundary_cannot_bypass_adjudication_or_lexicon() { + let delivery = finish_replay_delivery( + "Uzywam doker do kontenerow.".to_string(), + false, + None, + "live_apple", + ) + .expect("live floor should remain deliverable"); + assert_eq!( + delivery.transcript_source.as_deref(), + Some("Streaming fallback"), + "the replay must cross production truth adjudication" + ); + assert!( + delivery.delivered_text.contains("Docker"), + "the replay must cross the unconditional production lexicon layer" + ); + assert!(delivery.postprocess_stats.lexicon_rewrites >= 1); + assert_eq!(delivery.engine_label.as_deref(), Some("live_apple")); + assert!(!delivery.final_pass_attempted); + assert!(delivery.final_pass_skipped); + assert_eq!( + delivery.final_pass_skip_reason.as_deref(), + Some("not_attempted") + ); + } + + #[test] + fn replay_carries_runtime_final_pass_attempt_verdict_out_of_adjudication() { + let delivery = + finish_replay_delivery("pacjent stabilny".to_string(), true, None, "live_apple") + .expect("live floor survives an unavailable attempted pass"); + + assert!(delivery.final_pass_attempted); + assert!(!delivery.final_pass_skipped); + assert_eq!(delivery.final_pass_skip_reason, None); + } + + /// The exact field consumed by the production replay JSON must name the + /// live Apple canvas when Layer 1 and local final pass are both disarmed. + #[test] + fn apple_only_replay_json_surface_never_reports_streaming_whisper() { + let delivery = + finish_replay_delivery("pacjent stabilny".to_string(), false, None, "live_apple") + .expect("Apple live floor should remain deliverable"); + + let row = serde_json::json!({ "engine_label": delivery.engine_label }); + assert_eq!(row["engine_label"], "live_apple"); + assert_ne!(row["engine_label"], "streaming_whisper"); + } + + #[test] + fn production_reducer_vectors_preserve_one_slot_and_legitimate_repetition() { + let cumulative = vec![ + EngineEvent::Preview { + rev: 1, + text: "alpha".into(), + }, + EngineEvent::Preview { + rev: 2, + text: "alpha beta".into(), + }, + final_event(1, "alpha beta", 0.0, 1.0), + ]; + let reduced = reduce_transcript_events(&cumulative); + assert_eq!(reduced.streaming_floor(), "alpha beta"); + assert_eq!(reduced.committed_count(), 1); + + let revised = vec![ + final_event(7, "draft final", 0.0, 1.0), + final_event(7, "revised final", 0.0, 1.0), + ]; + let reduced = reduce_transcript_events(&revised); + assert_eq!(reduced.streaming_floor(), "revised final"); + assert_eq!(reduced.committed_count(), 1); + + let repeated = vec![ + final_event(1, "tak tak", 0.0, 1.0), + final_event(2, "tak tak", 1.0, 2.0), + ]; + let reduced = reduce_transcript_events(&repeated); + assert_eq!(reduced.streaming_floor(), "tak tak tak tak"); + assert_eq!(reduced.committed_count(), 2); + } + + #[test] + fn boundary_evidence_is_content_free_and_counts_id_and_window_conflicts() { + let events = vec![ + final_event(1, "one", 0.0, 1.0), + final_event(1, "revision", 0.0, 1.0), + final_event(2, "two", 0.5, 2.0), + final_event(3, "three", 2.0, 3.0), + ]; + assert_eq!( + boundary_evidence(&events), + ReplayBoundaryEvidence { + final_count: 4, + unique_final_id_count: 3, + repeated_final_id_count: 1, + overlapping_final_window_count: 2, + } + ); + } + + #[tokio::test] + async fn replay_load_failure_never_discloses_private_path() { + let basename = format!("private-corpus-{}-must-not-leak.wav", std::process::id()); + let path = std::env::temp_dir().join(&basename); + let error = replay_overlay_recording( + &path, + Some("pl".to_string()), + &UserSettings::default(), + GatewaySessionAvailability::Unavailable, + ProductionReplayLane::AppleLexicon, + ) + .await + .expect_err("a missing replay input must fail"); + let rendered = format!("{error:#}"); + assert!(!rendered.contains(&basename)); + assert!(!rendered.contains(&path.display().to_string())); + } +} diff --git a/app/controller/quality_delivery.rs b/app/controller/quality_delivery.rs index cdfb9f75..23a76df2 100644 --- a/app/controller/quality_delivery.rs +++ b/app/controller/quality_delivery.rs @@ -138,8 +138,12 @@ fn word_sequence(text: &str) -> Vec { } /// Which gesture ended the recording that is now up for auto-paste. +/// Production stop-path now asks [`DeliveryFacts::auto_paste_enabled`]; this +/// matrix stays as the test-visible predecessor so existing cases do not +/// evaporate. +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AutoPasteTrigger { +pub(crate) enum AutoPasteTrigger { /// Push-to-talk hold released. Hold, /// Double-tap of the left Option key. @@ -148,8 +152,9 @@ pub(super) enum AutoPasteTrigger { /// Everything the auto-paste decision is allowed to depend on, gathered in one /// place so the policy stays a pure function of explicit state. +#[cfg(test)] #[derive(Debug, Clone, Copy)] -pub(super) struct AutoPastePolicyContext { +pub(crate) struct AutoPastePolicyContext { pub trigger: AutoPasteTrigger, pub persisted_enabled: bool, pub overlay_enabled: bool, @@ -166,7 +171,8 @@ pub(super) struct AutoPastePolicyContext { /// Every veto is independent and fail-closed: assistive sessions, no-speech and /// empty results, notes-only saves, live streaming sessions, and pending quality /// commits each suppress the paste on their own. -pub(super) fn resolve_auto_paste_policy(context: AutoPastePolicyContext) -> bool { +#[cfg(test)] +pub(crate) fn resolve_auto_paste_policy(context: AutoPastePolicyContext) -> bool { // Trigger and presentation state deliberately do not fork policy. Keeping // the explicit matrix here makes that parity reviewable and testable. let persisted_policy = match (context.trigger, context.overlay_enabled) { @@ -291,6 +297,27 @@ pub(super) fn session_auto_format_enabled( force_ai || (!force_raw && config.ai_formatting_enabled) } +/// Whether this session will hand text to the LLM and therefore reach the +/// semantic guard afterwards — the predicate that decides whether pre-loading +/// the embedder is worth its residency. +/// +/// Mirrors the delivery lanes rather than [`session_auto_format_enabled`] +/// alone. There, `force_ai` outranks `force_raw`; but the lanes test +/// `force_raw` first, so a Ctrl hold stays literal even with the AI force set. +/// Getting that precedence backwards would hold 471 MB of embedder weights +/// resident for takes that never reach the guard at all. +pub(super) fn session_prewarms_semantic_guard( + config: &Config, + assistive: bool, + force_raw: bool, + force_ai: bool, + ai_key_available: bool, +) -> bool { + !force_raw + && ai_key_available + && session_auto_format_enabled(config, assistive, force_raw, force_ai) +} + /// Wrap a transcript in the configured tag template, without quality metadata. pub(super) fn maybe_wrap_transcript_for_delivery( text: &str, diff --git a/app/controller/tests.rs b/app/controller/tests.rs index 14c73262..6826e18c 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -13,6 +13,46 @@ async fn test_initial_state() { assert_eq!(controller.current_state().await, State::Idle); } +/// Product truth crosses one controller-owned boundary. The first seal wins; +/// a late automatic rewrite cannot append another truth event. +#[tokio::test] +async fn test_controller_product_seal_is_first_writer_wins() { + use crate::presentation::transcript_bus::CleanTranscriptEvent; + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("controller-seal.jsonl"); + let bus = Arc::new( + TranscriptBus::open_at( + TranscriptSession { + session_id: "controller-seal".to_string(), + mode: TranscriptMode::Dictation, + }, + path.clone(), + Some(48_000), + ) + .unwrap(), + ); + let controller = RecordingController::new(); + *controller.active_transcript_bus.write().await = Some(bus); + + controller + .seal_active_transcript("sealed committed truth".to_string()) + .await; + controller + .seal_active_transcript("late automatic rewrite".to_string()) + .await; + + let events = std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(events.len(), 2); + assert_eq!(events[0].status, "session_started"); + assert_eq!(events[1].status, "transcript_sealed"); + assert_eq!(events[1].text, "sealed committed truth"); +} + /// The paste target reports the app latched before the overlay took focus, and /// `None` when nothing was latched — the delivery path must never guess a target. #[tokio::test] @@ -437,6 +477,50 @@ fn test_truth_engine_label_prefers_actual_verdict_over_preference() { ); } +/// Apple live with Layer 1 off and local final pass Off is served by Apple; +/// the skipped verdict is provenance only and must never impersonate Whisper. +#[test] +fn test_apple_live_with_skipped_final_pass_reports_live_apple() { + let text = "pacjent stabilny".to_string(); + let skipped = codescribe_core::pipeline::contracts::TranscriptionVerdict::from_parts( + text.clone(), + codescribe_core::pipeline::contracts::RawTranscript { + text: text.clone(), + ..Default::default() + }, + None, + codescribe_core::pipeline::contracts::TranscriptionSource::LocalFinalPass, + codescribe_core::pipeline::contracts::TranscriptionEngineVerdict::apple( + codescribe_core::pipeline::contracts::TranscriptionEngineMode::SfSpeechOnDevice, + ), + Some(codescribe_core::pipeline::contracts::FinalPassVerdict { + mode: codescribe_core::pipeline::contracts::FinalPassMode::None, + disposition: FinalPassDisposition::Skipped, + reason: Some("routing_off".to_string()), + lexicon_rewrites: 0, + repetition_cleanups: 0, + }), + ); + + let verdict = adjudicate_recording_truth( + true, + true, + Some(skipped), + text.clone(), + None, + Some("live_apple"), + &SessionTelemetrySnapshot::default(), + ); + + assert_eq!(verdict.raw_text.as_deref(), Some(text.as_str())); + assert_eq!( + verdict.transcript_source, + Some(RecordingTranscriptSource::Streaming) + ); + assert_eq!(verdict.engine_label.as_deref(), Some("live_apple")); + assert!(!verdict.engine_label.unwrap().contains("whisper")); +} + /// The stop-path receipt names every phase and its remainder sums to the wall /// total. Unaccounted time must show up as `remainder`, never be absorbed into /// a named phase — that is what makes the receipt usable for latency work. @@ -756,7 +840,10 @@ async fn test_assistive_delivery_budget_times_real_send_adapter() { "receipt must contain the real send duration (>= {ADAPTER_MS}ms), got {total}s" ); - // One-shot: the second submit finds no pending context and says so. + // The CONTEXT is one-shot, but an explicit resend still delivers — with a + // degraded (bare) context and a receipt saying so. Refusing here painted + // "Agent delivery is no longer available" over a live button (2026-08-13 + // 01:02, six refusals); double-send protection is the Swift latch's job. let redelivered = controller .deliver_pending_assistive_transcript_with( "assistive harness transcript".to_string(), @@ -764,11 +851,14 @@ async fn test_assistive_delivery_budget_times_real_send_adapter() { ) .await .expect("second delivery attempt"); - assert!(!redelivered, "context is one-shot"); + assert!( + redelivered, + "an explicit send must deliver even without context" + ); let log = String::from_utf8(buf.0.lock().expect("log buf").clone()).expect("utf8 log"); assert!( - log.contains("outcome=no_pending_context"), - "second attempt must emit a no_pending_context receipt: {log}" + log.contains("outcome=degraded_no_context"), + "context-less delivery must emit a degraded_no_context receipt: {log}" ); } @@ -803,7 +893,9 @@ async fn test_assistive_delivery_falls_back_to_session_trigger_context() { assert!(delivered, "session context present → delivery must run"); assert!(sent.load(std::sync::atomic::Ordering::SeqCst)); - // The fallback consumed the session context: delivery stays one-shot. + // The fallback consumed the session context; a further explicit send still + // delivers, degraded to a bare context (see the degraded_no_context + // doctrine at the take site — a live button must never refuse). let redelivered = controller .deliver_pending_assistive_transcript_with( "dictated transcript sent explicitly".to_string(), @@ -811,7 +903,10 @@ async fn test_assistive_delivery_falls_back_to_session_trigger_context() { ) .await .expect("second delivery attempt"); - assert!(!redelivered, "fallback context is one-shot too"); + assert!( + redelivered, + "explicit send after context expiry must deliver with a degraded context" + ); } /// Routing over *real* completeness fixtures (adjudicator evidence, not synthetic @@ -957,6 +1052,654 @@ fn test_final_pass_action_on_complete_streaming_evidence() { ); } +/// RED contract for the measured eaten take. Structural coverage alone must +/// not classify 220 committed characters over 104 seconds as Complete. +/// Healthy density and short notes pin the two non-regression boundaries. +#[test] +fn fleet_red_density_guard_eaten_session() { + // The floor is production logic now, so the verdict under test is the one + // the stop path actually routes on: structural completeness with the + // committed-density floor applied over it. `audio_secs` is the WAV header + // duration the call site reads; `committed_chars` is session telemetry. + // `density_guarded` below stays an INDEPENDENT re-derivation of "starving" + // from the raw numbers, so this test still checks production against the + // contract rather than against itself. + let verdict = |audio_secs: f32, text: &str, committed_chars: usize| { + apply_committed_density_floor( + assess_streaming_completeness_fields( + text, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ), + Some(audio_secs), + committed_chars, + ) + }; + let density_guarded = |audio_secs: f32, committed_chars: usize, completeness| { + let starving = audio_secs > 10.0 && committed_chars as f32 / audio_secs < 4.0; + starving && matches!(completeness, StreamingCompleteness::Complete) + }; + + let healthy = verdict(23.0, &format!("{}.", "x".repeat(299)), 300); + assert_eq!(healthy, StreamingCompleteness::Complete); + assert!(!density_guarded(23.0, 300, healthy)); + + let short = verdict(9.9, "krótka notatka", 14); + assert_eq!(short, StreamingCompleteness::Complete); + assert!(!density_guarded(9.9, 14, short)); + + let eaten = verdict(104.0, &format!("{}.", "x".repeat(219)), 220); + assert!( + !density_guarded(104.0, 220, eaten), + "104 s / 220 chars must not remain Complete: {eaten:?}" + ); +} + +/// The override has to land somewhere useful: a starved session must route to +/// the residual / tail-gap path under Smart, while `Off` and `Always` keep the +/// verdicts their modes promise. Also pins the SECOND measured take +/// (118 ch / 107 s) so the guard is not fitted to a single number. +#[test] +fn density_floor_routes_starved_session_to_tail_gap_without_touching_other_modes() { + use super::final_pass::{DENSITY_STARVED_REASON, FinalPassAction, final_pass_action}; + + let starved = |audio_secs: f32, committed_chars: usize| { + apply_committed_density_floor( + assess_streaming_completeness_fields( + &"x".repeat(committed_chars), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ), + Some(audio_secs), + committed_chars, + ) + }; + + // Both measured eaten takes, not just the one the RED contract quotes. + for (audio_secs, committed_chars) in [(104.0_f32, 220_usize), (107.0, 118)] { + let verdict = starved(audio_secs, committed_chars); + assert_eq!( + verdict, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }, + "{committed_chars} ch / {audio_secs} s must be demoted, not Complete" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, verdict), + FinalPassAction::TailGapFill, + "a starved session routes to the tail-gap path, never a full-file re-pass" + ); + // Off means Off and Always means Always — the floor changes Smart only. + assert_eq!( + final_pass_action(FinalPassRoutingMode::Off, verdict), + FinalPassAction::SkipStreamingFinal + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Always, verdict), + FinalPassAction::FullFileRepass + ); + } + + // "Existing residual/tail-gap path" concretely: a live lane with committed + // text consumes its own partials rather than re-decoding the WAV. + assert_eq!( + smart_tail_gap_source(true, &"x".repeat(220), true), + SmartTailGapSource::SessionResidual + ); +} + +/// Negative half: every boundary and every unmeasurable denominator must leave +/// the verdict exactly as structure found it. A guard that fires on `10.0 s`, +/// on `4.0` chars/s, or on a WAV it could not read would put Whisper back on +/// the stop path of healthy short dictation — the behaviour W12 spent a wave +/// removing. +#[test] +fn density_floor_stays_silent_on_boundaries_and_unmeasurable_audio() { + use super::final_pass::{committed_density_chars_per_sec, committed_density_starved}; + + let complete = assess_streaming_completeness_fields( + &"x".repeat(220), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 220, + 1, + ); + assert_eq!(complete, StreamingCompleteness::Complete); + + // Exactly at the audio floor: the contract is "over 10 seconds", so 10.0 s + // is silent no matter how starved the density looks. + assert!(!committed_density_starved(10.0, 10)); + assert_eq!( + apply_committed_density_floor(complete, Some(10.0), 10), + complete + ); + // Just past it, the same density does fire. + assert!(committed_density_starved(10.01, 10)); + + // Exactly at the density floor: "below 4.0" excludes 4.0 itself. + assert!(!committed_density_starved(50.0, 200)); + assert!(committed_density_starved(50.0, 199)); + + // Unmeasurable denominators: no density exists, so no escalation may. + for audio_secs in [0.0_f32, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!( + committed_density_chars_per_sec(audio_secs, 220), + None, + "audio_secs={audio_secs} must not yield a density" + ); + assert!(!committed_density_starved(audio_secs, 220)); + assert_eq!( + apply_committed_density_floor(complete, Some(audio_secs), 220), + complete + ); + } + // No WAV at all (or an unreadable header) is not evidence of starvation. + assert_eq!(apply_committed_density_floor(complete, None, 220), complete); + + // An already-Incomplete verdict keeps its OWN reason: the floor adds a + // demotion, it never relabels a diagnosis that already fired. + let pending = assess_streaming_completeness_fields( + "Trwa jeszcze", + None, + true, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 12, + 1, + ); + assert_eq!( + apply_committed_density_floor(pending, Some(104.0), 12), + StreamingCompleteness::Incomplete { + reason: "pending_tail" + } + ); + + // A healthy, dense shape-deficient transcript keeps the punctuation lane: + // density only diagnoses missing speech, never sentence shape by itself. + let shapeless_and_dense = assess_streaming_completeness_fields( + &"słowo ".repeat(60), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 360, + 1, + ); + assert_eq!( + shapeless_and_dense, + StreamingCompleteness::CompleteShapeDeficient + ); + assert!(!committed_density_starved(23.0, 360)); + assert_eq!( + apply_committed_density_floor(shapeless_and_dense, Some(23.0), 360), + StreamingCompleteness::CompleteShapeDeficient + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, shapeless_and_dense), + FinalPassAction::PunctuationRepass + ); +} + +/// A long shape-deficient canvas is still eaten dictation when its committed +/// density is implausible. It must enter the existing recovery ladder before a +/// weak punctuation alignment can return the starving canvas untouched. +#[test] +fn density_floor_routes_long_starved_shape_deficient_to_recovery() { + use super::final_pass::{DENSITY_STARVED_REASON, committed_density_starved}; + + let starving_canvas = "słowo ".repeat(60); + let committed_chars = starving_canvas.chars().count(); + assert_eq!( + committed_chars, 360, + "fixture must stay above SHAPE_MIN_CHARS" + ); + + let structural = assess_streaming_completeness_fields( + &starving_canvas, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ); + assert_eq!(structural, StreamingCompleteness::CompleteShapeDeficient); + assert!(committed_density_starved(104.0, committed_chars)); + + let guarded = apply_committed_density_floor(structural, Some(104.0), committed_chars); + assert_eq!( + guarded, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }, + "104-second shape-deficient starvation must not reach punctuation-only delivery" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, guarded), + FinalPassAction::TailGapFill, + "Smart must attempt the existing residual/tail-gap recovery ladder" + ); + assert_eq!( + smart_tail_gap_source(true, &starving_canvas, true), + SmartTailGapSource::SessionResidual + ); + + // Mode promises remain explicit even though Smart now recovers starvation. + assert_eq!( + final_pass_action(FinalPassRoutingMode::Off, guarded), + FinalPassAction::SkipStreamingFinal + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Always, guarded), + FinalPassAction::FullFileRepass + ); +} + +/// W-Cb: the Layer 1 under-commit escalation must reach the stop-path verdict. +/// +/// The fixture is deliberately a session that passes every existing check — +/// sealed, dense, shaped — because that is the gap W-C's flag exists to close. +/// Coverage stays intact over the phrases the accumulator kept, and density +/// stays healthy, so the committed-density floor is structurally blind here: +/// only the engine's own report of speech it recovered and could not place +/// knows anything is missing. +#[test] +fn residual_required_forces_tail_gap_fill_when_structure_and_density_both_pass() { + use super::final_pass::{ + DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON, committed_density_starved, + }; + + let dense_canvas = format!("{}.", "x".repeat(299)); + let committed_chars = 300; + let structural = assess_streaming_completeness_fields( + &dense_canvas, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ); + assert_eq!(structural, StreamingCompleteness::Complete); + + // The floor is silent — independently re-derived, not read back from the + // helper under test. + assert!( + !(23.0_f32 > 10.0 && committed_chars as f32 / 23.0_f32 < 4.0), + "fixture must sit above the density floor for this test to mean anything" + ); + assert!(!committed_density_starved(23.0, committed_chars)); + let density_guarded = apply_committed_density_floor(structural, Some(23.0), committed_chars); + assert_eq!(density_guarded, StreamingCompleteness::Complete); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, density_guarded), + FinalPassAction::SkipStreamingFinal, + "without the escalation this session skips — that is the W-C behaviour being closed" + ); + + // With the escalation the same session must enter the existing ladder. + let demoted = apply_residual_required_demotion(density_guarded, true); + assert_eq!( + demoted, + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + } + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::TailGapFill, + "an unplaceable Layer 1 residual must route to tail gap fill, never a skip" + ); + assert_eq!( + smart_tail_gap_source(true, &dense_canvas, true), + SmartTailGapSource::SessionResidual, + "the live lane consumes its own partials — no full-file Whisper authority" + ); + + // Mode promises: only Smart consumes the demotion. + assert_eq!( + final_pass_action(FinalPassRoutingMode::Off, demoted), + FinalPassAction::SkipStreamingFinal, + "Off means Off even when Layer 1 escalates" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Always, demoted), + FinalPassAction::FullFileRepass, + "Always keeps its full-file re-pass promise" + ); + + // A false flag is a strict no-op on every verdict shape, so a session that + // never escalated behaves bit-for-bit as it did before this cut. + for verdict in [ + StreamingCompleteness::Complete, + StreamingCompleteness::CompleteShapeDeficient, + StreamingCompleteness::Incomplete { + reason: "pending_tail", + }, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }, + ] { + assert_eq!( + apply_residual_required_demotion(verdict, false), + verdict, + "residual_required=false must not touch {verdict:?}" + ); + for mode in [ + FinalPassRoutingMode::Off, + FinalPassRoutingMode::Smart, + FinalPassRoutingMode::Always, + ] { + assert_eq!( + final_pass_action(mode, apply_residual_required_demotion(verdict, false)), + final_pass_action(mode, verdict), + "routing must be unchanged for {mode:?} / {verdict:?} when nothing escalated" + ); + } + } +} + +/// A shape-deficient canvas with a known hole must not be handed back shaped. +/// +/// `PunctuationRepass` keeps the committed word sequence invariant by design, +/// so adopting punctuation onto a canvas Layer 1 already reported as missing +/// speech would deliver the hole with sentences around it. The residual ladder +/// has to win over the shape lane here, exactly as the density floor does. +#[test] +fn residual_required_shape_deficient_cannot_reach_punctuation_only_delivery() { + use super::final_pass::RESIDUAL_REQUIRED_REASON; + + let shapeless_canvas = "słowo ".repeat(60); + let committed_chars = shapeless_canvas.chars().count(); + let structural = assess_streaming_completeness_fields( + &shapeless_canvas, + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + committed_chars, + 1, + ); + assert_eq!(structural, StreamingCompleteness::CompleteShapeDeficient); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, structural), + FinalPassAction::PunctuationRepass, + "baseline: without the escalation this is the punctuation lane" + ); + + let demoted = apply_residual_required_demotion(structural, true); + assert_eq!( + demoted, + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + } + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::TailGapFill, + "a shape-deficient canvas with an unplaceable residual must recover, not just get punctuation" + ); + assert_ne!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::PunctuationRepass + ); + assert_ne!( + final_pass_action(FinalPassRoutingMode::Smart, demoted), + FinalPassAction::SkipStreamingFinal + ); +} + +/// Composition with the sibling guard: an already-Incomplete verdict keeps its +/// own, richer diagnosis. `starved_density` carries measured numbers and +/// `pending_tail` carries a state-machine position; both route to the same +/// tail-gap ladder, so relabelling them to `residual_required` would trade +/// information for nothing. Order at the call site is density first, residual +/// second, which is what makes this observable. +#[test] +fn residual_required_preserves_an_existing_incomplete_diagnosis() { + use super::final_pass::{DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON}; + + let eaten_and_escalated = apply_residual_required_demotion( + apply_committed_density_floor( + assess_streaming_completeness_fields( + &"x".repeat(220), + None, + false, + false, + Some(CompletenessCommitSource::UtteranceFinal), + 220, + 1, + ), + Some(104.0), + 220, + ), + true, + ); + assert_eq!( + eaten_and_escalated, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }, + "the measured density diagnosis outranks the flag; both route the same way" + ); + assert_eq!( + final_pass_action(FinalPassRoutingMode::Smart, eaten_and_escalated), + FinalPassAction::TailGapFill + ); + + for reason in ["pending_tail", "no_speech", "empty", "partial_pending"] { + let existing = StreamingCompleteness::Incomplete { reason }; + assert_eq!( + apply_residual_required_demotion(existing, true), + existing, + "{reason} must survive the residual demotion unrelabelled" + ); + assert_ne!( + apply_residual_required_demotion(existing, true), + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + } + ); + } +} + +/// End-to-end data flow, engine event → delivered stop-path action, with no +/// hand-built snapshot in the middle. This is the contract W-C's own report +/// left open: the warning existed, was logged and was broadcast over IPC, and +/// changed no verdict. Here the real sink folds the real event and the real +/// routing matrix reads the result. +#[test] +fn under_commit_warning_event_reaches_the_stop_path_action() { + use super::helpers::{ + SessionTelemetrySink, UNDER_COMMIT_WARNING_CODE, new_session_telemetry, + snapshot_session_telemetry, + }; + use codescribe_core::pipeline::contracts::EventSink; + + let route = |warning_code: Option<&str>| { + let shared = new_session_telemetry(); + let sink = SessionTelemetrySink::new(std::sync::Arc::clone(&shared)); + sink.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "x".repeat(300), + raw_text: "x".repeat(300), + start_ts: 0.0, + end_ts: 23.0, + segments: vec![], + vad_speech_pct: Some(80.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: vec![], + }); + if let Some(code) = warning_code { + sink.on_event(&EngineEvent::Warning { + code: code.to_string(), + message: "committed_tokens=3 retranscribed_tokens=12".to_string(), + }); + } + let snapshot = snapshot_session_telemetry(&shared); + let streaming_text = format!("{}.", "x".repeat(299)); + let evidence = StreamingCompletenessEvidence::from_session(&streaming_text, &snapshot); + let completeness = apply_residual_required_demotion( + apply_committed_density_floor( + assess_streaming_completeness(&evidence), + Some(23.0), + evidence.committed_chars, + ), + snapshot.residual_required, + ); + final_pass_action(FinalPassRoutingMode::Smart, completeness) + }; + + assert_eq!( + route(None), + FinalPassAction::SkipStreamingFinal, + "no warning: the session is complete and dense, so Smart still skips" + ); + assert_eq!( + route(Some("tail_patch_skipped")), + FinalPassAction::SkipStreamingFinal, + "a neighbouring warning code must not put Whisper back on the stop path" + ); + assert_eq!( + route(Some(UNDER_COMMIT_WARNING_CODE)), + FinalPassAction::TailGapFill, + "the exact Layer 1 under-commit warning must force residual gap fill" + ); +} + +/// The residual receipt must be emitted even when it changed nothing, and must +/// carry no speech. Silence would be indistinguishable from "the warning never +/// arrived" — the exact ambiguity this cut closes. The transcript never enters +/// this line, so a demoted session cannot write user dictation into +/// `~/.codescribe/logs/codescribe.log`. +#[test] +fn residual_required_line_states_the_outcome_and_carries_no_transcript() { + use super::final_pass::{DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON}; + + let secret = "Tajne zdanie pacjenta o wyniku badania"; + let demoted = format_residual_required_line( + StreamingCompleteness::Complete, + StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON, + }, + ); + assert!(demoted.contains("final_pass_residual_guard"), "{demoted}"); + assert!( + demoted.contains("warning_code=tail_patch_under_commit"), + "the receipt must name the exact code that fired: {demoted}" + ); + assert!( + demoted.contains("verdict_before=complete_streaming_transcript"), + "{demoted}" + ); + assert!( + demoted.contains("verdict_after=residual_required"), + "{demoted}" + ); + assert!(demoted.contains("demoted=true"), "{demoted}"); + + // Fired, but a richer diagnosis already held: still logged, marked honestly. + let preserved = format_residual_required_line( + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }, + StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON, + }, + ); + assert!( + preserved.contains("verdict_before=starved_density"), + "{preserved}" + ); + assert!( + preserved.contains("verdict_after=starved_density"), + "{preserved}" + ); + assert!( + preserved.contains("demoted=false"), + "a preserved diagnosis must say so rather than imply a demotion: {preserved}" + ); + + for line in [&demoted, &preserved] { + assert!( + !line.contains(secret) && !line.contains("słowo") && !line.contains('"'), + "no transcript text may reach the production log: {line}" + ); + } +} + +/// One label table serves every receipt that names a verdict, so the skip line +/// and both guard lines cannot drift apart. +#[test] +fn completeness_labels_are_stable_across_receipts() { + use super::final_pass::{DENSITY_STARVED_REASON, RESIDUAL_REQUIRED_REASON}; + + assert_eq!( + completeness_label(StreamingCompleteness::Complete), + "complete_streaming_transcript" + ); + assert_eq!( + completeness_label(StreamingCompleteness::CompleteShapeDeficient), + "shape_deficient" + ); + assert_eq!( + completeness_label(StreamingCompleteness::Incomplete { + reason: DENSITY_STARVED_REASON + }), + "starved_density" + ); + assert_eq!( + completeness_label(StreamingCompleteness::Incomplete { + reason: RESIDUAL_REQUIRED_REASON + }), + "residual_required" + ); +} + +/// The override receipt must carry the numbers that justify it and none of the +/// speech that triggered it. Test-log isolation is a sibling cut; leaking user +/// dictation into the production log would be this cut's own doing. +#[test] +fn density_override_line_carries_numbers_and_no_transcript() { + let line = format_density_override_line(StreamingCompleteness::Complete, 104.0, 220); + + assert!(line.contains("final_pass_density_guard"), "{line}"); + assert!( + line.contains("overridden_verdict=complete_streaming_transcript"), + "the line must name the verdict it replaced: {line}" + ); + assert!(line.contains("new_verdict=starved_density"), "{line}"); + assert!(line.contains("audio_secs=104.000"), "{line}"); + assert!(line.contains("committed_chars=220"), "{line}"); + assert!( + line.contains("density_chars_per_sec=2.12"), + "the measured density must be legible, not implied: {line}" + ); + assert!(line.contains("floor_chars_per_sec=4.0"), "{line}"); + assert!(line.contains("min_audio_secs=10.0"), "{line}"); + assert!(line.contains("route=tail_gap_fill"), "{line}"); + + let shape_line = + format_density_override_line(StreamingCompleteness::CompleteShapeDeficient, 104.0, 360); + assert!( + shape_line.contains("overridden_verdict=shape_deficient"), + "shape starvation must identify the verdict it overrode: {shape_line}" + ); +} + /// The live engine (Apple vs Whisper) is not an input to routing at all — the /// dishonest Apple→Always override (2026-07-25) is now structurally impossible, /// because `final_pass_action` takes only (mode, completeness). This test pins the @@ -1320,6 +2063,7 @@ fn test_completeness_evidence_from_session_wires_pending_tail() { last_commit_source: Some(CompletenessCommitSource::UtteranceFinal), committed_chars: 12, committed_through_secs: None, + residual_required: false, }; let evidence = StreamingCompletenessEvidence::from_session("To jest kompletne zdanie.", &session); @@ -1635,7 +2379,7 @@ fn test_transcript_delivery_wrap_uses_config_when_enabled() { } #[test] -fn deferred_insert_registration_failure_preserves_copy_fallback() { +fn deferred_insert_registration_reports_unavailable_reasons() { assert_eq!( deferred_insert_registration(DeferredInsertShortcut::CommandOptionV, false, None,), DeferredInsertRegistration::Unavailable { @@ -1660,6 +2404,25 @@ fn deferred_insert_registration_failure_preserves_copy_fallback() { ); } +/// Refused Cmd+V parks the transcript in-process. The user's pasteboard is +/// not a fallback delivery channel. +#[test] +#[serial] +fn refused_paste_parks_paste_here_without_touching_clipboard() { + use crate::os::clipboard::{get_clipboard, set_clipboard}; + + if set_clipboard("user clipboard sentinel").is_err() { + return; + } + assert!(super::overlay_paste::park_refused_paste( + "tagged refused transcript".to_string() + )); + let Ok(current) = get_clipboard() else { + return; + }; + assert_eq!(current, "user clipboard sentinel"); +} + #[test] fn test_transcript_delivery_wrap_uses_truth_quality_placeholders() { let config = Config { @@ -2262,6 +3025,51 @@ fn formatting_setting_is_orthogonal_to_hold_and_assistive_delivery() { assert!(!session_auto_format_enabled(&config, false, true, false)); } +/// The embedder is pre-loaded only for takes that will actually reach the +/// semantic guard. It is 471 MB with a single consumer, so a predicate that is +/// merely "close enough" buys stop-path latency with resident memory on takes +/// that never use it. +#[test] +fn embedder_prewarm_follows_the_lane_that_will_call_the_llm() { + let on = Config { + ai_formatting_enabled: true, + ..Config::default() + }; + let off = Config { + ai_formatting_enabled: false, + ..Config::default() + }; + + assert!( + session_prewarms_semantic_guard(&on, false, false, false, true), + "the ordinary formatted toggle is exactly the lane this exists for" + ); + assert!(session_prewarms_semantic_guard( + &on, true, false, false, true + )); + + assert!( + !session_prewarms_semantic_guard(&on, false, true, false, true), + "Ctrl hold promises literal words and never reaches the guard" + ); + assert!( + !session_prewarms_semantic_guard(&on, false, true, true, true), + "force_raw outranks force_ai in the lanes — the prewarm must agree, or a Ctrl hold pays for weights it cannot use" + ); + assert!( + !session_prewarms_semantic_guard(&off, false, false, false, true), + "formatting disabled means no LLM call and no guard" + ); + assert!( + !session_prewarms_semantic_guard(&on, false, false, false, false), + "no key means the LLM lane falls back before it ever formats" + ); + assert!( + session_prewarms_semantic_guard(&off, false, false, true, true), + "double Option forces formatting even with the setting off" + ); +} + #[test] #[serial] fn one_indicator_transition_updates_shared_rust_state_and_tray_snapshot() { @@ -2665,6 +3473,38 @@ fn test_toggle_stop_event_preserves_active_session_identity() { ); } +#[test] +fn test_hold_press_never_applies_mode_flags() { + let press_chat = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Press, + assistive: true, + hold_mode: HoldMode::Chat, + force_raw: false, + force_ai: false, + }; + let down_raw = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Down, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + assert!( + !should_apply_incoming_mode_flags(State::RecHold, &press_chat), + "RecHold + Hold Press must not flip destination" + ); + assert!( + !should_apply_incoming_mode_flags(State::Idle, &press_chat), + "Hold Press during the start-delay window must not upgrade to Chat" + ); + assert!( + should_apply_incoming_mode_flags(State::Idle, &down_raw), + "Hold Down still latches destination" + ); +} + #[tokio::test] #[serial] async fn test_agent_send_in_flight_blocks_nonassistive_hotkey_starts() { @@ -2846,6 +3686,7 @@ fn test_adjudicate_recording_truth_blocks_local_no_speech() { Some(make_final_pass_verdict("", 0.0, None, true)), "preview text".to_string(), None, + None, &session, ); @@ -2873,6 +3714,7 @@ fn test_adjudicate_recording_truth_marks_cloud_fallback_as_degraded() { None, "streaming fallback".to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -2907,6 +3749,65 @@ fn test_adjudicate_recording_truth_marks_cloud_fallback_as_degraded() { assert_eq!(verdict.display_status, "Streaming fallback"); } +/// The fallback cloud lane obeys the same live-floor rule as cloud primary. +#[test] +fn cloud_fallback_preserves_live_floor_and_adds_provider_tail() { + let verdict = adjudicate_recording_truth( + true, + true, + None, + "live_token shared_token".to_string(), + Some(make_cloud_verdict("provider_token shared_token tail_token")), + Some("live_apple"), + &SessionTelemetrySnapshot::default(), + ); + + let delivered = verdict.raw_text.as_deref().expect("merged cloud fallback"); + assert!(delivered.starts_with("live_token shared_token")); + assert!(delivered.ends_with("tail_token")); + assert_eq!( + verdict.transcript_source, + Some(RecordingTranscriptSource::CloudFallback) + ); + assert!( + verdict + .confidence_flags + .contains(&TranscriptionConfidenceFlag::CloudFallbackUsed) + ); +} + +/// RED: Layer 1 cloud output is a bounded refiner, never authority that can +/// erase or rewrite already committed Apple canvas text. +#[test] +fn fleet_red_cloud_final_preserves_live_floor() { + let live_floor = "Pacjent pozostaje przytomny i reaguje na badanie"; + let divergent_cloud_final = crate::client::CloudTranscriptionVerdict { + text: "Pacjent śpi".to_string(), + source: codescribe_core::pipeline::contracts::TranscriptionSource::Cloud, + confidence_flags: Vec::new(), + latency_ms: Some(12), + model_name: Some("fake-cloud".to_string()), + }; + + let verdict = adjudicate_recording_truth( + false, + false, + None, + live_floor.to_string(), + Some(divergent_cloud_final), + Some("live_apple"), + &SessionTelemetrySnapshot::default(), + ); + let delivered = verdict + .raw_text + .expect("cloud final must produce a verdict"); + + assert!( + delivered.starts_with(live_floor), + "cloud final erased or rewrote committed Apple text" + ); +} + #[test] fn test_adjudicate_recording_truth_merges_live_floor_with_whisper_final() { // Product: never full-replace live with Whisper. Merge keeps live tokens @@ -2921,6 +3822,7 @@ fn test_adjudicate_recording_truth_merges_live_floor_with_whisper_final() { Some(make_final_pass_verdict(whisper, 82.0, Some(-0.22), false)), live.to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -2962,6 +3864,7 @@ fn test_recon_final_pass_rejected_on_catastrophic_length_regression() { )), live.to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -2998,6 +3901,7 @@ fn test_recon_comparable_final_pass_merges_not_full_replace() { Some(make_final_pass_verdict(whisper, 82.0, Some(-0.24), false)), live.to_string(), None, + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); @@ -3024,6 +3928,7 @@ fn test_adjudicate_recording_truth_marks_raw_streaming_preview_as_degraded_fallb None, "toggle transcript".to_string(), None, + Some("streaming_whisper"), &SessionTelemetrySnapshot::default(), ); @@ -3076,6 +3981,7 @@ fn test_adjudicate_recording_truth_cold_whisper_empty_live_recovers_via_final_pa // Empty live preview: cold Whisper meant no live transcript at all. String::new(), None, + Some("streaming_whisper"), &SessionTelemetrySnapshot::default(), ); @@ -3133,10 +4039,11 @@ fn test_adjudicate_recording_truth_uses_typed_cloud_primary_verdict() { None, "preview text".to_string(), Some(make_cloud_verdict("cloud primary")), + Some("live_apple"), &SessionTelemetrySnapshot::default(), ); - assert_eq!(verdict.raw_text.as_deref(), Some("cloud primary")); + assert!(verdict.raw_text.as_deref().is_some()); assert_eq!( verdict.transcript_source, Some(RecordingTranscriptSource::CloudPrimary) @@ -3158,6 +4065,7 @@ fn test_adjudicate_recording_truth_marks_low_logprob_as_unsafe() { )), "preview text".to_string(), None, + Some("streaming_whisper"), &SessionTelemetrySnapshot::default(), ); @@ -3528,12 +4436,12 @@ async fn test_shift_upgrade_mid_hold_overrides_force_raw() { // Verify RAW mode is set assert!(*controller.force_raw_mode.read().await); assert!(!*controller.assistive_mode.read().await); + *controller.state.write().await = State::RecHold; - // Now: User adds Shift mid-hold (upgrade to Assistive) - // This comes as another event with assistive=true + // Leftover HoldUpdate Chat must not flip a live take to Assistive. let upgrade_event = HotkeyInput { key_type: HotkeyType::Hold, - action: HotkeyAction::Press, // Modifier flags changed while holding + action: HotkeyAction::Press, assistive: true, hold_mode: HoldMode::Chat, force_raw: false, @@ -3541,14 +4449,87 @@ async fn test_shift_upgrade_mid_hold_overrides_force_raw() { }; controller.handle_hotkey_event(upgrade_event).await.unwrap(); - // Should upgrade to Assistive, force_raw should be cleared assert!( - *controller.assistive_mode.read().await, - "Shift added mid-hold should upgrade to assistive_mode=true" + !*controller.assistive_mode.read().await, + "mid-hold Press must not upgrade to assistive_mode" ); assert!( - !*controller.force_raw_mode.read().await, - "Shift upgrade should clear force_raw_mode" + *controller.force_raw_mode.read().await, + "mid-hold Press must not clear force_raw_mode" + ); + assert_eq!(*controller.hold_mode.read().await, HoldMode::Raw); + assert_eq!(controller.current_state().await, State::RecHold); +} + +#[tokio::test] +#[serial] +async fn test_attach_hold_selection_does_not_flip_destination() { + let controller = RecordingController::new(); + *controller.state.write().await = State::RecHold; + *controller.force_raw_mode.write().await = true; + *controller.assistive_mode.write().await = false; + *controller.hold_mode.write().await = HoldMode::Raw; + set_assistive_session(false); + + controller + .context_bucket + .lock() + .await + .add_selection(0, "already attached".to_string()) + .expect("seed selection") + .expect("non-empty seed"); + + controller + .attach_hold_selection() + .await + .expect("attach during RecHold must succeed"); + + assert!( + !*controller.assistive_mode.read().await, + "attach must not set assistive_mode" + ); + assert_eq!(*controller.hold_mode.read().await, HoldMode::Raw); + assert!(*controller.force_raw_mode.read().await); + assert_eq!(controller.current_state().await, State::RecHold); + assert!( + !is_assistive_session(), + "attach must not publish BadgeMode::Assistive" + ); + assert!( + controller.context_bucket.lock().await.has_selection_items(), + "attach must not wipe an existing {{selection_N}} bucket" + ); +} + +#[tokio::test] +#[serial] +async fn test_hold_down_raw_attaches_live_selection_without_chat() { + let controller = RecordingController::new(); + crate::os::selection::set_test_captured_selection("already selected"); + + let down_event = HotkeyInput { + key_type: HotkeyType::Hold, + action: HotkeyAction::Down, + assistive: false, + hold_mode: HoldMode::Raw, + force_raw: true, + force_ai: false, + }; + controller.handle_hotkey_event(down_event).await.unwrap(); + + assert!( + !*controller.assistive_mode.read().await, + "Fn hold-down must not set assistive_mode" + ); + assert_eq!(*controller.hold_mode.read().await, HoldMode::Raw); + assert!( + !is_assistive_session(), + "Fn hold-down must not publish BadgeMode::Assistive" + ); + assert_eq!( + controller.context_bucket.lock().await.len(), + 1, + "live OS selection at Fn down must become {{selection_1}}" ); } @@ -4332,6 +5313,7 @@ async fn hold_event_sink_forwards_live_preview_then_final_in_order() { true, controller.event_broadcast.clone(), Arc::clone(&controller.session_telemetry), + None, ); sink.on_event(&EngineEvent::Preview { @@ -4370,6 +5352,7 @@ async fn late_correction_after_final_is_a_single_patch_event_not_a_second_final( true, controller.event_broadcast.clone(), Arc::clone(&controller.session_telemetry), + None, ); sink.on_event(&EngineEvent::Preview { diff --git a/app/controller/truth.rs b/app/controller/truth.rs index f768511a..ab5212a0 100644 --- a/app/controller/truth.rs +++ b/app/controller/truth.rs @@ -8,16 +8,121 @@ use tracing::{info, warn}; use codescribe_core::pipeline::contracts::{ FinalPassDisposition, TranscriptionConfidenceFlag, TranscriptionVerdict, }; +use codescribe_core::pipeline::stream_postprocess::{StreamPostProcessStats, StreamPostProcessor}; use super::final_pass::engine_label_from_verdict; use super::helpers::SessionTelemetrySnapshot; use super::types::{RecordingFallbackClass, RecordingTranscriptSource}; +/// Unconditional final text layer immediately before formatting and delivery. +#[derive(Debug, Clone)] +pub struct DeliveryTextPostprocess { + /// Text after production lexicon, artifact cleanup, and semantic gate. + pub text: String, + /// Content-free counters proving which final layer ran. + pub stats: StreamPostProcessStats, +} + +/// Apply the production-owned lexicon/text layer used by every delivery. +/// +/// This is deliberately a shared symbol rather than test-side logic: the real +/// overlay pipeline and private-corpus replay both cross it before treating a +/// transcript as deliverable. +pub fn postprocess_transcript_for_delivery(raw_text: &str) -> DeliveryTextPostprocess { + let mut finalizer = StreamPostProcessor::new(); + let text = finalizer + .process(raw_text) + .unwrap_or_else(|| raw_text.to_string()); + DeliveryTextPostprocess { + text, + stats: finalizer.stats(), + } +} + /// Collapse a whitespace-only transcript to `None` — blank is not a transcript. fn non_empty_transcript(text: Option) -> Option { text.filter(|text| !text.trim().is_empty()) } +/// The typed Layer 1 producer whose result is being reconciled with the live +/// Apple/stream floor. This is deliberately independent of transport vendor. +#[derive(Debug, Clone, Copy)] +enum Layer1AdjudicationSource { + LocalWhisper, + CloudPrimary, + CloudFallback, +} + +impl Layer1AdjudicationSource { + const fn label(self) -> &'static str { + match self { + Self::LocalWhisper => "local_whisper", + Self::CloudPrimary => "cloud_primary", + Self::CloudFallback => "cloud_fallback", + } + } +} + +const fn layer1_decision_reason(mode: codescribe_core::quality::Layer1MergeMode) -> &'static str { + match mode { + codescribe_core::quality::Layer1MergeMode::Empty => "empty", + codescribe_core::quality::Layer1MergeMode::LiveOnly => "live_only", + codescribe_core::quality::Layer1MergeMode::ProviderOnly => "provider_only", + codescribe_core::quality::Layer1MergeMode::LiveFloorGapFill => "live_floor_gap_fill", + } +} + +const fn cloud_layer1_engine_label( + mode: codescribe_core::quality::Layer1MergeMode, +) -> &'static str { + match mode { + codescribe_core::quality::Layer1MergeMode::ProviderOnly => "cloud_stt", + _ => "merged_live_layer1:cloud_stt", + } +} + +/// Reconcile one Layer 1 result against the immutable live floor and emit only +/// content-free operational telemetry. Cloud sources never receive local +/// known-term evidence, so they can fill aligned gaps/tails but cannot win a +/// substitution over a committed live token. +fn merge_layer1_with_live_floor( + live: Option<&str>, + provider_text: &str, + source: Layer1AdjudicationSource, + known_terms: &[String], +) -> codescribe_core::quality::Layer1MergedDelivery { + let live = live.unwrap_or_default(); + let merged = match source { + Layer1AdjudicationSource::LocalWhisper => { + codescribe_core::quality::merge_live_whisper_with_terms( + live, + provider_text, + known_terms, + ) + .into() + } + Layer1AdjudicationSource::CloudPrimary | Layer1AdjudicationSource::CloudFallback => { + codescribe_core::quality::merge_live_layer1(live, provider_text) + } + }; + + info!( + source = source.label(), + live_chars = live.chars().count(), + provider_chars = provider_text.chars().count(), + merged_chars = merged.text.chars().count(), + decision_reason = layer1_decision_reason(merged.mode), + equal = merged.equal_tokens, + provider_fill = merged.provider_fill_tokens, + live_subs = merged.live_kept_substitutes, + provider_won_subs = merged.provider_won_substitutes, + known_terms = known_terms.len(), + "Layer 1 adjudication completed" + ); + + merged +} + /// The adjudicated outcome of one recording: what to deliver, and how much to /// trust it. /// @@ -49,6 +154,12 @@ pub(crate) struct RecordingTruthVerdict { /// Disposition of the explicit file-level final pass, when one ran. /// None means no final pass was attempted for this verdict. pub(crate) final_pass_disposition: Option, + /// Whether the stop path actually invoked the local file pass. + pub(crate) final_pass_attempted: bool, + /// Runtime verdict for the explicit skip question used by acceptance evidence. + pub(crate) final_pass_skipped: bool, + /// Typed engine/controller reason when the pass was skipped. + pub(crate) final_pass_skip_reason: Option, /// Actual serving engine label for sidecar/UI (`local_apple`, `local_whisper`, …). /// Preference-derived labels are forbidden when a verdict is present. pub(crate) engine_label: Option, @@ -197,6 +308,9 @@ pub(crate) fn build_truth_verdict( confidence_flags, sparkline, final_pass_disposition, + final_pass_attempted: false, + final_pass_skipped: false, + final_pass_skip_reason: None, engine_label, commit_trigger, display_status, @@ -205,11 +319,10 @@ pub(crate) fn build_truth_verdict( /// Decide what a finished recording actually delivers, and label its provenance. /// -/// The live transcript is the floor of truth. When a local final pass produced -/// text, it is **merged** into the live assembly (live kept, Whisper filling the -/// gaps) rather than replacing it — full-replace would delete correct live -/// tokens and is a doctrine violation. See -/// [`codescribe_core::quality::merge_live_whisper`]. +/// The live transcript is the floor of truth. A Layer 1 result is **merged** +/// into the live assembly (committed live kept, provider filling gaps/tail) +/// rather than replacing it — full-replace would delete correct live tokens +/// and is a doctrine violation. See [`codescribe_core::quality::merge_live_layer1`]. /// /// Resolution order: /// 1. Local final pass — an explicit no-speech verdict is authoritative and @@ -217,8 +330,8 @@ pub(crate) fn build_truth_verdict( /// 2. A final pass that came back *shorter* than the live assembly is rejected /// as a length regression, keeping the stream and flagging provenance. /// 3. Session-level no-speech telemetry. -/// 4. Cloud verdict, then the streaming floor — both always marked degraded, -/// because neither was verified by a final pass. +/// 4. Cloud verdict merged against the streaming floor, then the streaming +/// floor alone. Cloud fallback remains explicitly degraded. /// 5. Nothing usable: an empty verdict carrying the reason. pub(crate) fn adjudicate_recording_truth( use_local_stt: bool, @@ -226,6 +339,40 @@ pub(crate) fn adjudicate_recording_truth( local_final_pass_verdict: Option, streaming_text: String, cloud_verdict: Option, + streaming_engine_label: Option<&str>, + session_telemetry: &SessionTelemetrySnapshot, +) -> RecordingTruthVerdict { + let typed_skip = local_final_pass_verdict + .as_ref() + .and_then(|verdict| verdict.final_pass.as_ref()) + .filter(|final_pass| final_pass.disposition == FinalPassDisposition::Skipped); + let final_pass_skipped = !local_final_pass_attempted || typed_skip.is_some(); + let final_pass_skip_reason = typed_skip + .and_then(|final_pass| final_pass.reason.clone()) + .or_else(|| (!local_final_pass_attempted).then(|| "not_attempted".to_string())); + + let mut verdict = adjudicate_recording_truth_inner( + use_local_stt, + local_final_pass_attempted, + local_final_pass_verdict, + streaming_text, + cloud_verdict, + streaming_engine_label, + session_telemetry, + ); + verdict.final_pass_attempted = local_final_pass_attempted; + verdict.final_pass_skipped = final_pass_skipped; + verdict.final_pass_skip_reason = final_pass_skip_reason; + verdict +} + +fn adjudicate_recording_truth_inner( + use_local_stt: bool, + local_final_pass_attempted: bool, + local_final_pass_verdict: Option, + streaming_text: String, + cloud_verdict: Option, + streaming_engine_label: Option<&str>, session_telemetry: &SessionTelemetrySnapshot, ) -> RecordingTruthVerdict { let streaming_text = non_empty_transcript(Some(streaming_text)); @@ -279,6 +426,24 @@ pub(crate) fn adjudicate_recording_truth( non_empty_transcript(Some(verdict.text)) }; + // `Off` produces a typed skipped verdict solely to carry the live + // engine identity. No final-pass engine served text, so do not route + // the unchanged live floor through the Whisper merge/provenance path. + if matches!(final_pass_disposition, Some(FinalPassDisposition::Skipped)) { + return build_truth_verdict( + streaming_text.or(raw_text), + Some(RecordingTranscriptSource::Streaming), + None, + None, + speech_pct, + avg_logprob, + confidence_flags, + sparkline, + final_pass_disposition, + engine_label, + ); + } + // Explicit no-speech from final pass remains authoritative. if no_speech_reason.is_some() { return build_truth_verdict( @@ -329,23 +494,12 @@ pub(crate) fn adjudicate_recording_truth( terms }) .unwrap_or_default(); - let merged = codescribe_core::quality::merge_live_whisper_with_terms( - stream, + let merged = merge_layer1_with_live_floor( + Some(stream), final_text, + Layer1AdjudicationSource::LocalWhisper, &known_terms, ); - info!( - mode = ?merged.mode, - equal = merged.equal_tokens, - whisper_fill = merged.whisper_fill_tokens, - live_subs = merged.live_kept_substitutes, - whisper_won_subs = merged.whisper_won_substitutes, - known_terms = known_terms.len(), - live_chars = stream.chars().count(), - whisper_chars = final_text.chars().count(), - merged_chars = merged.text.chars().count(), - "delivery merge: live floor + whisper fill (not full-replace)" - ); // Merged path still used a final pass; keep engine label from final // but text is composite live×whisper. let eng = engine_label @@ -412,6 +566,13 @@ pub(crate) fn adjudicate_recording_truth( } if let Some(cloud_verdict) = cloud_verdict { + let merged = merge_layer1_with_live_floor( + streaming_text.as_deref(), + &cloud_verdict.text, + Layer1AdjudicationSource::CloudFallback, + &[], + ); + let engine_label = cloud_layer1_engine_label(merged.mode); let mut fallback_flags = confidence_flags.clone(); for flag in &cloud_verdict.confidence_flags { push_typed_flag(&mut fallback_flags, *flag); @@ -421,7 +582,7 @@ pub(crate) fn adjudicate_recording_truth( TranscriptionConfidenceFlag::CloudFallbackUsed, ); return build_truth_verdict( - Some(cloud_verdict.text), + Some(merged.text), Some(RecordingTranscriptSource::CloudFallback), Some(RecordingFallbackClass::Degraded), // cloud fallback is no longer "Acceptable" (silent), it must be explicit None, @@ -430,7 +591,7 @@ pub(crate) fn adjudicate_recording_truth( fallback_flags, None, None, - Some("cloud_stt".to_string()), + Some(engine_label.to_string()), ); } @@ -447,11 +608,11 @@ pub(crate) fn adjudicate_recording_truth( // Regression keep-stream is still live assembly truth, not "degraded // because final missing" — label as streaming floor when we rejected // a collapsing final pass. - let engine = if final_pass_length_regression { - Some("streaming_live_floor".to_string()) - } else { - Some("streaming_whisper".to_string()) - }; + let engine = Some( + streaming_engine_label + .unwrap_or("streaming_unknown") + .to_string(), + ); return build_truth_verdict( Some(text), Some(RecordingTranscriptSource::StreamingFallback), @@ -467,8 +628,15 @@ pub(crate) fn adjudicate_recording_truth( } } else { if let Some(cloud_verdict) = cloud_verdict { + let merged = merge_layer1_with_live_floor( + streaming_text.as_deref(), + &cloud_verdict.text, + Layer1AdjudicationSource::CloudPrimary, + &[], + ); + let engine_label = cloud_layer1_engine_label(merged.mode); return build_truth_verdict( - Some(cloud_verdict.text), + Some(merged.text), Some(RecordingTranscriptSource::CloudPrimary), None, None, @@ -477,7 +645,7 @@ pub(crate) fn adjudicate_recording_truth( cloud_verdict.confidence_flags, None, None, - Some("cloud_stt".to_string()), + Some(engine_label.to_string()), ); } @@ -505,7 +673,11 @@ pub(crate) fn adjudicate_recording_truth( confidence_flags, None, None, - Some("streaming_whisper".to_string()), + Some( + streaming_engine_label + .unwrap_or("streaming_unknown") + .to_string(), + ), ); } } @@ -548,7 +720,7 @@ pub(crate) fn truth_engine_label( "cloud_stt".to_string() } RecordingTranscriptSource::Streaming | RecordingTranscriptSource::StreamingFallback => { - "streaming_whisper".to_string() + "streaming_unknown".to_string() } }) } diff --git a/app/logging.rs b/app/logging.rs index 8c0319be..7791f369 100644 --- a/app/logging.rs +++ b/app/logging.rs @@ -11,17 +11,23 @@ //! Swift constructs first wins and the rest are no-ops. use std::env; -use std::path::PathBuf; +use std::fs::File; +use std::path::{Path, PathBuf}; use std::sync::Once; +use codescribe_core::config::Config; + /// Once guard so tracing/logging subscribers install exactly once per process. static INIT: Once = Once::new(); /// Install the global tracing subscriber (stderr + file) and the panic hook. /// /// Idempotent: guarded by a [`Once`], so repeated calls across FFI boundaries -/// are cheap no-ops. Writes to `~/.codescribe/logs/codescribe.log` (append), -/// honouring `RUST_LOG` (falling back to legacy `LOG_LEVEL`, then `info`). +/// are cheap no-ops. Production processes append to +/// `~/.codescribe/logs/codescribe.log`, honouring `RUST_LOG` (falling back to +/// legacy `LOG_LEVEL`, then `info`). Rust and XCTest harnesses are refused a +/// file sink at runtime, including integration tests where this library is +/// compiled without `cfg(test)`; they retain the stderr subscriber. pub fn init_logging() { INIT.call_once(|| { init_tracing(); @@ -45,11 +51,6 @@ fn init_tracing() { }, }; - let home = env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let log_dir = PathBuf::from(home).join(".codescribe").join("logs"); - let _ = std::fs::create_dir_all(&log_dir); - let log_path = log_dir.join("codescribe.log"); - let stderr_layer = fmt::layer() .with_ansi(true) .with_target(true) @@ -58,12 +59,10 @@ fn init_tracing() { let filter_layer = EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new("info")); - let file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path); + let file = log_file_path(&Config::config_dir(), runtime_is_test_process()) + .and_then(|path| open_file_log(&path).ok()); - if let Ok(file) = file { + if let Some(file) = file { let file = std::sync::Arc::new(file); let file_layer = fmt::layer() .with_ansi(false) @@ -85,6 +84,45 @@ fn init_tracing() { } } +/// Resolve the production file sink. Test harnesses deliberately receive no +/// path: relying on a Makefile-exported data directory is insufficient because +/// bare `cargo test` compiles integration-test dependencies without `cfg(test)`. +fn log_file_path(config_dir: &Path, test_process: bool) -> Option { + (!test_process).then(|| config_dir.join("logs").join("codescribe.log")) +} + +/// Open the production log with append semantics, creating only its parent. +fn open_file_log(path: &Path) -> std::io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) +} + +/// Detect harnesses from runtime identity rather than only `cfg(test)`. +/// +/// Cargo places unit- and integration-test executables under a `deps` +/// directory. XCTest supplies a configuration variable or an `.xctest` +/// argument even though the hosted Rust library is a normal production build. +fn runtime_is_test_process() -> bool { + if cfg!(test) + || env::var_os("XCTestConfigurationFilePath").is_some() + || env::var_os("XCTestBundlePath").is_some() + || env::args_os().any(|arg| arg.to_string_lossy().contains(".xctest")) + { + return true; + } + + env::current_exe().is_ok_and(|exe| { + exe.parent() + .and_then(Path::file_name) + .is_some_and(|name| name == "deps") + }) +} + /// Install a global panic hook that logs every panic through `tracing` before /// the process unwinds or aborts. /// @@ -124,3 +162,32 @@ fn install_panic_hook() { ); })); } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[test] + fn test_processes_are_refused_a_file_sink() { + let root = Path::new("/tmp/codescribe-test-logging-contract"); + assert_eq!(log_file_path(root, true), None); + } + + #[test] + fn production_logging_keeps_canonical_append_semantics() { + let root = tempfile::tempdir().expect("create production logging fixture"); + let path = log_file_path(root.path(), false).expect("production file sink"); + assert_eq!(path, root.path().join("logs/codescribe.log")); + + writeln!(open_file_log(&path).expect("open first writer"), "first") + .expect("write first record"); + writeln!(open_file_log(&path).expect("open append writer"), "second") + .expect("write second record"); + + assert_eq!( + std::fs::read_to_string(path).expect("read production log fixture"), + "first\nsecond\n" + ); + } +} diff --git a/app/os/clipboard.rs b/app/os/clipboard.rs index 45165bec..8603366f 100644 --- a/app/os/clipboard.rs +++ b/app/os/clipboard.rs @@ -472,7 +472,7 @@ fn simulate_right_arrow() -> Result<()> { /// Hand the clipboard back on a background thread once the paste has settled. /// /// The restore is conditional on `paste_epoch` still being current. Any -/// clipboard write in the meantime — a "degrade to copy" fallback, a second +/// clipboard write in the meantime — an explicit overlay Copy, a second /// dictation — bumps the epoch, and this thread then exits without writing, so /// a delayed restore can never clobber newer content the user is waiting on. /// diff --git a/app/os/hotkeys/detector.rs b/app/os/hotkeys/detector.rs index b12d8390..b80e12df 100644 --- a/app/os/hotkeys/detector.rs +++ b/app/os/hotkeys/detector.rs @@ -36,23 +36,14 @@ pub enum HoldAction { /// High-level hold intent derived from modifier state. /// -/// UX split — the destination is chosen by the MODE, and only `Raw` reaches the -/// frontmost app: -/// - `Raw`: dictation → auto-paste (fast) -/// - `Chat`: voice chat to AI → reply in the **agent chat window** (no auto-paste) -/// - `Selection`: instruction applied to the selected text → reply in the -/// **agent chat window** (no auto-paste) +/// Destination is latched at hold-down. Live detectors start a dictation hold +/// as `Raw` even when Shift/Command is already down; a later arm pulse emits +/// [`HotkeyEvent::AttachSelection`] instead of promoting to `Chat`. /// -/// Both agent modes previously documented "response in overlay". That has been -/// false since the legacy AppKit overlay sink was removed: replies are -/// broadcast to Swift over `CsAgentDeliveryListener` -/// (`bridge/src/agent_delivery.rs`), deliberately kept off the -/// overlay/dictation stream, and `testAgentModesNeverConstructOrOrderOverlayFront` -/// asserts the overlay is never even constructed for them. The comment is -/// corrected rather than deleted because the reconstruction of this contract -/// (`reports/trigger-routing-contract-reconstruction.md`, gap G2) exists -/// precisely to stop the routing rule being re-invented by the next reader who -/// trusts the code's own doc over the doc. +/// - `Raw`: dictation → overlay + auto-paste (the live hold destination) +/// - `Chat` / `Selection`: leftover controller vocabulary for an assistive +/// *start* that a hold arm no longer produces. Replies still go to the +/// Agent window over `CsAgentDeliveryListener`, never the overlay. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum HoldMode { #[default] @@ -70,8 +61,16 @@ pub enum HotkeyEvent { InsertHere, /// Hold gesture detected (press/release configured modifier combo) Hold { action: HoldAction, mode: HoldMode }, - /// Modifier change while hold is active (e.g., add/remove Shift/Cmd). + /// Modifier change while hold is active (legacy mid-hold mode flip). + /// + /// Destination is latched at hold-down. Live detectors emit + /// [`HotkeyEvent::AttachSelection`] instead of upgrading Raw → Chat. HoldUpdate { mode: HoldMode }, + /// Rising edge of the configured arm modifier during an active hold. + /// + /// Captures the current OS selection as `{selection_N}` without changing + /// destination, hiding the overlay, or fronting Agent. + AttachSelection, /// Normal toggle gesture (double-tap left Option) ToggleNormal, /// Raw toggle gesture (double-tap Ctrl) @@ -333,6 +332,9 @@ pub struct HotkeyDetector { insert_here_v_down: bool, /// Edge-trigger for `arm_ignored` diagnostics (visibility only). wrong_arm_logged: bool, + /// Last sampled arm-modifier state while a hold is active, so a Shift + /// (or Cmd) pulse can attach another `{selection_N}` without flipping mode. + arm_modifier_down: bool, } impl Default for HotkeyDetector { @@ -354,6 +356,7 @@ impl Default for HotkeyDetector { show_agent_space_down: false, insert_here_v_down: false, wrong_arm_logged: false, + arm_modifier_down: false, } } } @@ -458,6 +461,7 @@ impl HotkeyDetector { self.hold_active = false; self.hold_active_ts = None; self.hold_event_sent = false; + self.arm_modifier_down = false; self.key_pressed_during_modifier = true; emitted = Some(HotkeyEvent::Hold { action: HoldAction::Up, @@ -532,23 +536,31 @@ impl HotkeyDetector { self.wrong_arm_logged = false; } + let arm_now = arm_modifier_is_down(modifiers, config.hold_arm_modifier); + let mut emitted = None; if combo_active && !self.hold_active { self.hold_active = true; self.hold_active_ts = Some(now); self.hold_mode = mode_now; self.hold_event_sent = true; + self.arm_modifier_down = arm_now; emitted = Some(HotkeyEvent::Hold { action: HoldAction::Down, mode: self.hold_mode, }); - } else if combo_active && self.hold_active && mode_now != self.hold_mode { - self.hold_mode = mode_now; - emitted = Some(HotkeyEvent::HoldUpdate { - mode: self.hold_mode, - }); + } else if combo_active && self.hold_active { + // Destination is latched at hold-down. A later Shift/Cmd pulse + // attaches `{selection_N}`; it must not emit HoldUpdate Chat, + // which fronts Agent and drops the live take. + let arm_rising = arm_now && !self.arm_modifier_down; + self.arm_modifier_down = arm_now; + if arm_rising { + emitted = Some(HotkeyEvent::AttachSelection); + } } else if !combo_active && self.hold_active { self.hold_active = false; + self.arm_modifier_down = false; if self.hold_event_sent { emitted = Some(HotkeyEvent::Hold { action: HoldAction::Up, @@ -815,6 +827,16 @@ fn register_blocked_option_double_tap( /// the neighbourhood of a hold", which is what the start-delay cancel and the /// wrong-arm diagnostic need. Double-tap bindings have no base and return /// `false`. +fn arm_modifier_is_down( + modifiers: HotkeyModifierSnapshot, + arm_modifier: crate::config::HoldArmModifier, +) -> bool { + match arm_modifier { + crate::config::HoldArmModifier::Shift => modifiers.shift, + crate::config::HoldArmModifier::Cmd => modifiers.cmd, + } +} + fn hold_base_pressed( modifiers: HotkeyModifierSnapshot, dictation_binding: ShortcutBinding, @@ -885,47 +907,20 @@ fn assistive_hold_binding(binding: ShortcutBinding) -> Option { } } -/// Decide which [`HoldMode`] a live hold is in, from the arm modifiers. +/// Hold destination at key-down. Arm modifiers no longer promote to `Chat`. /// -/// `hold_exclusive` short-circuits to `Raw` — when the user has asked for -/// exclusive holds, no modifier may promote dictation into an agent mode. Only -/// the bindings that leave a modifier free (`HoldCtrlAlt`, `HoldFn`) can reach -/// `Chat`; the rest already spend Shift or Cmd on the combo itself and stay -/// `Raw`. +/// Shift/Command during an already-started hold attach `{selection_N}`. +/// Fn+Shift from idle stays dictation. The arguments are kept so exclusive / +/// binding / arm Settings still flow through this seam; they must not change +/// the latched destination. fn compute_hold_mode( - shift: bool, - cmd: bool, - dictation_binding: ShortcutBinding, - hold_exclusive: bool, - arm_modifier: crate::config::HoldArmModifier, + _shift: bool, + _cmd: bool, + _dictation_binding: ShortcutBinding, + _hold_exclusive: bool, + _arm_modifier: crate::config::HoldArmModifier, ) -> HoldMode { - if hold_exclusive { - return HoldMode::Raw; - } - - // W10-B: only the *configured* arm modifier arms Chat. The other must stay - // dead so Settings copy and detector agree (default Shift; Cmd alternative). - let arm_active = match arm_modifier { - crate::config::HoldArmModifier::Shift => shift, - crate::config::HoldArmModifier::Cmd => cmd, - }; - - match dictation_binding { - ShortcutBinding::Disabled - | ShortcutBinding::HoldCtrl - | ShortcutBinding::HoldCtrlShift - | ShortcutBinding::HoldCtrlCmd - | ShortcutBinding::DoubleCtrl - | ShortcutBinding::DoubleLeftOption - | ShortcutBinding::DoubleRightOption => HoldMode::Raw, - ShortcutBinding::HoldCtrlAlt | ShortcutBinding::HoldFn => { - if arm_active { - HoldMode::Chat - } else { - HoldMode::Raw - } - } - } + HoldMode::Raw } #[cfg(test)] @@ -1108,138 +1103,65 @@ mod tests { } #[test] - /// Hold mode follows arm modifier and optional assistive Shift without exclusive force. + /// Arm modifiers never upgrade hold destination — attach is a later pulse. fn compute_hold_mode_respects_modifiers() { use crate::config::HoldArmModifier; - // Fn base + default Shift arm: Shift arms, Cmd does not. - assert_eq!( - compute_hold_mode( - false, - false, - ShortcutBinding::HoldFn, - false, - HoldArmModifier::Shift - ), - HoldMode::Raw - ); - assert_eq!( - compute_hold_mode( - true, - false, - ShortcutBinding::HoldFn, - false, - HoldArmModifier::Shift - ), - HoldMode::Chat - ); - assert_eq!( - compute_hold_mode( - false, - true, - ShortcutBinding::HoldFn, + let cases = [ + ( false, - HoldArmModifier::Shift - ), - HoldMode::Raw - ); - - // Cmd-selected arm: Cmd arms, Shift does not. - assert_eq!( - compute_hold_mode( false, - true, ShortcutBinding::HoldFn, - false, - HoldArmModifier::Cmd + HoldArmModifier::Shift, ), - HoldMode::Chat - ); - assert_eq!( - compute_hold_mode( + (true, false, ShortcutBinding::HoldFn, HoldArmModifier::Shift), + (false, true, ShortcutBinding::HoldFn, HoldArmModifier::Shift), + (false, true, ShortcutBinding::HoldFn, HoldArmModifier::Cmd), + (true, false, ShortcutBinding::HoldFn, HoldArmModifier::Cmd), + ( true, false, - ShortcutBinding::HoldFn, - false, - HoldArmModifier::Cmd - ), - HoldMode::Raw - ); - - // Ctrl-only ignores Shift/Cmd modifiers - assert_eq!( - compute_hold_mode( - true, - false, - ShortcutBinding::HoldCtrl, - false, - HoldArmModifier::Shift - ), - HoldMode::Raw - ); - assert_eq!( - compute_hold_mode( - false, - true, ShortcutBinding::HoldCtrl, - false, - HoldArmModifier::Cmd + HoldArmModifier::Shift, ), - HoldMode::Raw - ); - - // Ctrl+Option allows the configured arm - assert_eq!( - compute_hold_mode( + (false, true, ShortcutBinding::HoldCtrl, HoldArmModifier::Cmd), + ( true, false, ShortcutBinding::HoldCtrlAlt, - false, - HoldArmModifier::Shift + HoldArmModifier::Shift, ), - HoldMode::Chat - ); - assert_eq!( - compute_hold_mode( + ( false, true, ShortcutBinding::HoldCtrlAlt, - false, - HoldArmModifier::Shift + HoldArmModifier::Shift, ), - HoldMode::Raw - ); - assert_eq!( - compute_hold_mode( + ( false, false, ShortcutBinding::HoldCtrlAlt, - false, - HoldArmModifier::Shift + HoldArmModifier::Shift, ), - HoldMode::Raw - ); - - // Ctrl+Shift/Cmd are fixed to raw - assert_eq!( - compute_hold_mode( + ( true, false, ShortcutBinding::HoldCtrlShift, - false, - HoldArmModifier::Shift + HoldArmModifier::Shift, ), - HoldMode::Raw - ); - assert_eq!( - compute_hold_mode( + ( false, true, ShortcutBinding::HoldCtrlCmd, - false, - HoldArmModifier::Cmd + HoldArmModifier::Cmd, ), - HoldMode::Raw - ); + ]; + for (shift, cmd, binding, arm) in cases { + assert_eq!( + compute_hold_mode(shift, cmd, binding, false, arm), + HoldMode::Raw, + "arm must not promote {binding:?} (shift={shift}, cmd={cmd}, arm={arm:?})" + ); + } } #[test] @@ -1310,6 +1232,186 @@ mod tests { assert!(!detector.is_combo_active()); } + #[test] + /// Fn then Shift attaches selection; release stays Raw dictation. + fn detector_fn_then_shift_attaches_selection_and_up_stays_raw() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, false, false, true), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Raw, + }) + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(10), + key: HotkeyPhysicalKey::Other, + modifiers: mods(false, false, true, false, true), + }, + config, + ), + Some(HotkeyEvent::AttachSelection) + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(20), + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode: HoldMode::Raw, + }) + ); + } + + #[test] + /// Fn+Shift from idle is dictation, not Assistive / Chat. + fn detector_fn_shift_from_idle_stays_dictation() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, true, false, true), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Raw, + }) + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(5), + key: HotkeyPhysicalKey::Other, + modifiers: mods(false, false, true, false, true), + }, + config, + ), + None, + "arm already down at start is not a rising-edge attach" + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(15), + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode: HoldMode::Raw, + }) + ); + } + + #[test] + /// Two Shift pulses during one Fn hold emit two AttachSelection events. + fn detector_two_shift_pulses_emit_two_attach_selection() { + let mut detector = HotkeyDetector::default(); + let config = test_config( + ShortcutBinding::HoldFn, + ShortcutBinding::DoubleLeftOption, + ShortcutBinding::DoubleRightOption, + ); + let base = Instant::now(); + + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base, + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, false, false, true), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Raw, + }) + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(10), + key: HotkeyPhysicalKey::Other, + modifiers: mods(false, false, true, false, true), + }, + config, + ), + Some(HotkeyEvent::AttachSelection) + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(20), + key: HotkeyPhysicalKey::Other, + modifiers: mods(false, false, false, false, true), + }, + config, + ), + None, + "arm release is silent" + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(30), + key: HotkeyPhysicalKey::Other, + modifiers: mods(false, false, true, false, true), + }, + config, + ), + Some(HotkeyEvent::AttachSelection) + ); + assert_eq!( + detector.feed( + HotkeyDetectorInput::FlagsChanged { + now: base + Duration::from_millis(40), + key: HotkeyPhysicalKey::Fn, + modifiers: mods(false, false, false, false, false), + }, + config, + ), + Some(HotkeyEvent::Hold { + action: HoldAction::Up, + mode: HoldMode::Raw, + }) + ); + } + #[test] /// ModifierFlags::ctrl_only marks only the Control bit. fn test_modifier_flags_ctrl_only() { diff --git a/app/os/selection.rs b/app/os/selection.rs index 06c2e334..8e8b6c07 100644 --- a/app/os/selection.rs +++ b/app/os/selection.rs @@ -99,6 +99,30 @@ fn clear_recent_assistive_context_for_tests() { *guard = None; } +/// Injected OS selection for unit tests (production capture is skipped). +#[cfg(test)] +fn test_captured_selection_store() -> &'static Mutex> { + static STORE: OnceLock>> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(None)) +} + +/// Queue a one-shot selected-text payload for the next test capture. +#[cfg(test)] +pub fn set_test_captured_selection(text: impl Into) { + let mut guard = test_captured_selection_store() + .lock() + .unwrap_or_else(|e| e.into_inner()); + *guard = Some(text.into()); +} + +#[cfg(test)] +fn take_test_captured_selection() -> Option { + test_captured_selection_store() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() +} + /// Read a boolean env knob. Any value other than `0`/`false`/`no`/`off` /// (case-insensitive) counts as enabled; an unset key yields `default`. fn env_flag(key: &str, default: bool) -> bool { @@ -153,6 +177,16 @@ pub fn capture_assistive_context_with_image_with_prior_frontmost( prior_frontmost_app: Option, ) -> CapturedAssistiveContext { // Unit tests should not trigger osascript / clipboard / event simulation. + #[cfg(test)] + if let Some(selected_text) = take_test_captured_selection() { + return CapturedAssistiveContext { + context: AssistiveContext { + selected_text: Some(selected_text), + frontmost_app: None, + }, + image_png: None, + }; + } if cfg!(test) { return CapturedAssistiveContext::default(); } diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs index d27f9a98..6d78c6f8 100644 --- a/app/presentation/emitter.rs +++ b/app/presentation/emitter.rs @@ -14,6 +14,8 @@ use codescribe_core::pipeline::streaming::BufferedEmitter; use tokio::sync::Mutex; use tracing::{debug, info}; +use super::transcript_bus::{TranscriptBus, TranscriptDraft, TranscriptDraftStatus}; + /// Commands sent through the ordered channel to the emitter worker. enum EmitterCmd { SetTargetText(String), @@ -32,7 +34,7 @@ pub enum DeltaRenderMode { ActivePreviewOnly, } -/// One committed utterance. `text` is the corrected string every later +/// One mutable engine-finalized utterance. `text` is the working string every later /// `ReplaceRange` / `InsertAnnotation` char offset is computed against; /// `raw_text` keeps the uncorrected engine output for the quality loop. #[derive(Debug, Clone, PartialEq)] @@ -45,6 +47,20 @@ struct TranscriptUtteranceRecord { segments: Vec, } +impl TranscriptUtteranceRecord { + /// Narrow the reducer's internal record to the clean public bus contract. + /// `raw_text` is deliberately excluded at this boundary. + fn clean_draft(&self) -> TranscriptDraft { + TranscriptDraft { + utterance_id: self.utterance_id, + text: self.text.clone(), + start_seconds: self.start_ts, + end_seconds: self.end_ts, + segments: self.segments.clone(), + } + } +} + /// Source of truth for the session transcript: everything already committed, /// plus the in-flight preview tail. /// @@ -54,7 +70,7 @@ struct TranscriptUtteranceRecord { /// fallback for a final that arrives empty (VAD sealed on a quiet tail), so a /// real utterance is not lost to a blank final. #[derive(Debug, Default)] -struct SessionTranscriptState { +pub struct TranscriptReducer { committed: Vec, active_preview: String, last_non_empty_preview: String, @@ -81,7 +97,7 @@ fn append_rendered_fragment(rendered: &mut String, fragment: &str) { rendered.push_str(&normalized); } -impl SessionTranscriptState { +impl TranscriptReducer { /// Replace the live preview tail. Previews supersede each other, so this /// overwrites rather than appends; a non-empty preview is also remembered as /// the fallback an empty final will fall back to. @@ -101,7 +117,7 @@ impl SessionTranscriptState { /// correction falls through to the preview path — treating it as new /// content. Without the search, a late correction to a non-tail utterance /// would append a duplicate instead of fixing the original. - fn apply_correction(&mut self, previous_text: &str, text: &str) { + fn apply_correction(&mut self, previous_text: &str, text: &str) -> Option { let previous = normalize_transcript_fragment(previous_text); let corrected = normalize_transcript_fragment(text); @@ -111,15 +127,16 @@ impl SessionTranscriptState { // Only falls back to preview-append if no match found (new content). if self.active_preview.is_empty() { // Fast path + P3-03: search from tail (last first). Collapsed if for clippy. - for rec in self.committed.iter_mut().rev() { + for (index, rec) in self.committed.iter_mut().enumerate().rev() { if normalize_transcript_fragment(&rec.text) == previous { rec.text = corrected; - return; + return Some(index); } } } self.apply_preview(&corrected); + None } /// Test helper: delete chars from the live preview tail only. @@ -166,6 +183,19 @@ impl SessionTranscriptState { return None; } + if let Some(existing) = self + .committed + .iter_mut() + .find(|record| record.utterance_id == utterance_id) + { + existing.text = committed_text; + existing.raw_text = raw_text.to_string(); + existing.start_ts = start_ts; + existing.end_ts = end_ts; + existing.segments = segments; + return None; + } + self.committed.push(TranscriptUtteranceRecord { utterance_id, text: committed_text.clone(), @@ -189,7 +219,7 @@ impl SessionTranscriptState { /// preview tail. Rebuilt from state on every call, so the rendered string is /// always a function of the record list rather than an accumulated buffer /// that could drift from it. - fn rendered_text(&self) -> String { + pub fn rendered_text(&self) -> String { let mut rendered = String::new(); for utterance in &self.committed { append_rendered_fragment(&mut rendered, &utterance.text); @@ -214,10 +244,31 @@ impl SessionTranscriptState { let Some(record) = self .committed .iter_mut() - .find(|record| record.utterance_id == utterance_id) + .rfind(|record| record.utterance_id == utterance_id) else { return false; }; + // Last-mile duplicate guard. A patch is computed against the canvas as + // it stood when Layer 1 was dispatched; by the time it arrives SFSpeech + // may have restated the SAME utterance at greater length, already + // delivering the words the patch recovers. Measured 2026-08-14: an + // append computed for a 15-character canvas landed on the 47-character + // restatement of it and duplicated the phrase ("…hard pruna I road + // która pozwoli nam na zrobienie hard Pru."), costing more WER than the + // recovery gained. Only pure insertions are checked — a substitution + // replaces the very span it would be compared against. + if let EngineEvent::ReplaceRange { + start, end, text, .. + } = event + && start == end + && codescribe_core::stt::tail_patcher::text_already_carries(&record.text, text) + { + tracing::debug!( + utterance_id, + "layered patch already carried by the canvas; dropped" + ); + return false; + } match event.apply_to_committed_text(&mut record.text) { Ok(applied) => applied, Err(error) => { @@ -236,8 +287,75 @@ impl SessionTranscriptState { fn committed(&self) -> &[TranscriptUtteranceRecord] { &self.committed } + + /// Apply one engine event using the exact transcript algebra owned by the + /// shipped presentation emitter. The returned text is present only when a + /// new final slot was inserted; same-id revisions update that slot without + /// dispatching a second per-utterance callback. + pub fn apply_event(&mut self, event: &EngineEvent) -> Option { + match event { + EngineEvent::Preview { text, .. } => self.apply_preview(text), + EngineEvent::Correction { + text, + previous_text, + .. + } => { + let _ = self.apply_correction(previous_text, text); + } + EngineEvent::UtteranceFinal { + utterance_id, + text, + raw_text, + start_ts, + end_ts, + segments, + .. + } => { + return self.finalize( + *utterance_id, + text, + raw_text, + *start_ts, + *end_ts, + segments.clone(), + ); + } + EngineEvent::ReplaceRange { .. } | EngineEvent::InsertAnnotation { .. } => { + let _ = self.apply_layered_patch(event); + } + EngineEvent::NoSpeech { .. } => self.clear_live_preview(), + _ => {} + } + None + } + + /// Finalized canvas only, excluding the volatile preview tail. + pub fn streaming_floor(&self) -> String { + let mut rendered = String::new(); + for utterance in &self.committed { + append_rendered_fragment(&mut rendered, &utterance.text); + } + rendered + } + + /// Number of unique finalized slots currently held by the reducer. + pub fn committed_count(&self) -> usize { + self.committed.len() + } +} + +/// Replay an ordered event vector through the production presentation algebra. +pub fn reduce_transcript_events(events: &[EngineEvent]) -> TranscriptReducer { + let mut reducer = TranscriptReducer::default(); + for event in events { + let _ = reducer.apply_event(event); + } + reducer } +#[cfg(test)] +type SessionTranscriptState = TranscriptReducer; + /// Presentation emitter — bridges `EngineEvent`s to `BufferedEmitter`. /// /// Implements `EventSink` so it can be plugged directly into `transcription_session`. @@ -257,11 +375,11 @@ pub struct PresentationEmitter { vad_end_callback: Option>, vad_start_emitted: std::sync::atomic::AtomicBool, /// Source-of-truth transcript state: committed utterances + active preview tail. - session_state: std::sync::Mutex, - /// Last utterance id delivered to callback (guards duplicate boundary commits). - last_dispatched_utterance_id: std::sync::atomic::AtomicU64, + session_state: std::sync::Mutex, /// Controls what the delta sink sees: full session text or only the live preview. delta_render_mode: DeltaRenderMode, + /// Durable observer of this exact reducer's committed/final truth. + transcript_bus: Option>, } impl PresentationEmitter { @@ -277,6 +395,17 @@ impl PresentationEmitter { transcript_buffer: Arc>, delta_callback: Option>, stream_log_path: Option, + ) -> Self { + Self::new_with_transcript_bus(transcript_buffer, delta_callback, stream_log_path, None) + } + + /// Build an emitter observed by the clean transcript bus. The bus sees the + /// same reducer mutation as paste/history and never reconstructs UI deltas. + pub fn new_with_transcript_bus( + transcript_buffer: Arc>, + delta_callback: Option>, + stream_log_path: Option, + transcript_bus: Option>, ) -> Self { let emitter = Arc::new(Mutex::new(BufferedEmitter::new( transcript_buffer, @@ -335,9 +464,9 @@ impl PresentationEmitter { vad_start_callback: None, vad_end_callback: None, vad_start_emitted: std::sync::atomic::AtomicBool::new(false), - session_state: std::sync::Mutex::new(SessionTranscriptState::default()), - last_dispatched_utterance_id: std::sync::atomic::AtomicU64::new(0), + session_state: std::sync::Mutex::new(TranscriptReducer::default()), delta_render_mode: DeltaRenderMode::SessionRendered, + transcript_bus, } } @@ -438,10 +567,10 @@ impl EventSink for PresentationEmitter { cb(); } } - EngineEvent::Preview { text, .. } => { + EngineEvent::Preview { .. } => { let rendered = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.apply_preview(text); + let _ = state.apply_event(event); match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), @@ -454,47 +583,48 @@ impl EventSink for PresentationEmitter { previous_text, .. } => { - let rendered = { + let (rendered, revised) = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.apply_correction(previous_text, text); - match self.delta_render_mode { + let revised = state + .apply_correction(previous_text, text) + .and_then(|index| state.committed.get(index)) + .map(TranscriptUtteranceRecord::clean_draft); + let rendered = match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), - } + }; + (rendered, revised) }; + if let (Some(bus), Some(revised)) = (&self.transcript_bus, revised) { + bus.publish_draft(TranscriptDraftStatus::Revised, revised); + } self.send_cmd(EmitterCmd::SetTargetText(rendered)); } - EngineEvent::UtteranceFinal { - utterance_id, - text, - raw_text, - start_ts, - end_ts, - segments, - .. - } => { - let duplicate = self - .last_dispatched_utterance_id - .swap(*utterance_id, std::sync::atomic::Ordering::SeqCst) - == *utterance_id; - if duplicate { - debug!( - utterance_id = *utterance_id, - "Ignoring duplicate UtteranceFinal callback dispatch" - ); - return; - } - let callback_payload = { + EngineEvent::UtteranceFinal { utterance_id, .. } => { + let (callback_payload, committed, revised) = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.finalize( - *utterance_id, - text, - raw_text, - *start_ts, - *end_ts, - segments.clone(), - ) + let existed = state + .committed + .iter() + .any(|record| record.utterance_id == *utterance_id); + let callback_payload = state.apply_event(event); + let committed = state + .committed + .iter() + .rfind(|record| record.utterance_id == *utterance_id) + .map(TranscriptUtteranceRecord::clean_draft); + (callback_payload, committed, existed) }; + if let (Some(bus), Some(committed)) = (&self.transcript_bus, committed) { + bus.publish_draft( + if revised { + TranscriptDraftStatus::Revised + } else { + TranscriptDraftStatus::Created + }, + committed, + ); + } if let Some(cb) = &self.utterance_callback && let Some(payload) = callback_payload { @@ -524,7 +654,7 @@ impl EventSink for PresentationEmitter { EngineEvent::NoSpeech { reason } => { let rendered = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); - state.clear_live_preview(); + let _ = state.apply_event(event); state.rendered_text() }; self.send_cmd(EmitterCmd::SetTargetText(rendered)); @@ -592,22 +722,52 @@ impl EventSink for PresentationEmitter { // (transcript_buffer → paste/history) that the overlay already // received, so phase-1 layered patches don't diverge between the // two sinks. Only re-render when the buffer actually changed. - let rendered = { + let (rendered, revised) = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); if state.apply_layered_patch(event) { - Some(match self.delta_render_mode { + let rendered = Some(match self.delta_render_mode { DeltaRenderMode::SessionRendered => state.rendered_text(), DeltaRenderMode::ActivePreviewOnly => state.active_preview.clone(), - }) + }); + let utterance_id = match event { + EngineEvent::ReplaceRange { utterance_id, .. } + | EngineEvent::InsertAnnotation { utterance_id, .. } => *utterance_id, + _ => unreachable!(), + }; + let revised = state + .committed + .iter() + .rfind(|record| record.utterance_id == utterance_id) + .map(TranscriptUtteranceRecord::clean_draft); + (rendered, revised) } else { - None + (None, None) } }; + if let (Some(bus), Some(revised)) = (&self.transcript_bus, revised) { + bus.publish_draft(TranscriptDraftStatus::Revised, revised); + } if let Some(rendered) = rendered { self.send_cmd(EmitterCmd::SetTargetText(rendered)); } } - EngineEvent::SessionFinalised { .. } => {} + EngineEvent::SessionFinalised { .. } => { + // The Apple progressive lane closes with SessionFinalised and + // does not emit Stats. Persist only immutable canvas here: a + // cumulative final can re-state committed text as the last + // Preview, and ignoring the close event would deliver + // `committed + restatement` at stop. + let rendered = { + let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); + state.clear_live_preview(); + state.streaming_floor() + }; + // Engine close is not product truth. The controller can still + // run Smart/Always final pass, adjudication, postprocess, and + // formatting. Only that controller result may seal the bus. + self.send_cmd(EmitterCmd::SetTargetText(rendered)); + self.send_cmd(EmitterCmd::Finish); + } } } } @@ -617,11 +777,146 @@ impl EventSink for PresentationEmitter { mod tests { use super::{DeltaRenderMode, PresentationEmitter, SessionTranscriptState}; use codescribe_core::pipeline::contracts::{ - AnnotationKind, EngineEvent, EventSink, LayerSource, TranscriptSegment, + AnnotationKind, EngineEvent, EventSink, LayerSource, LayerSummary, TranscriptSegment, }; use std::sync::{Arc, Mutex as StdMutex}; use tokio::sync::Mutex; + /// Regression for the 2026-08-14 patch/restatement race. + /// + /// Layer 1 computes a recovery against the canvas as it stood when the job + /// was dispatched. SFSpeech may then restate the SAME utterance at greater + /// length and deliver those words itself. Measured on take 144425: the + /// append was computed for a 15-character canvas, the final arrived at 47 + /// characters carrying the phrase, and applying the patch duplicated it + /// ("…hard pruna I road która pozwoli nam na zrobienie hard Pru.") — three + /// repeated 4-grams, WER 0.463 → 0.610. The reducer is the last place that + /// sees the canvas as it actually stands, so the guard belongs here. + #[test] + fn patch_already_delivered_by_a_restatement_is_dropped() { + let mut reducer = SessionTranscriptState::default(); + reducer.apply_event(&EngineEvent::UtteranceFinal { + utterance_id: 6, + text: "I road która pozwoli nam na zrobienie hard Pru.".to_string(), + raw_text: "i road ktora pozwoli nam na zrobienie hard pru".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + vad_speech_pct: None, + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + let before = reducer.rendered_text(); + + // The patch Layer 1 computed against the earlier, shorter canvas. + reducer.apply_event(&EngineEvent::ReplaceRange { + utterance_id: 6, + start: 5, + end: 5, + text: " która pozwoli nam na zrobienie hard pruna".to_string(), + source: LayerSource::TailPatch, + }); + assert_eq!( + reducer.rendered_text(), + before, + "a recovery the restatement already delivered must not be applied twice" + ); + + // A genuine gap fill on the same utterance still lands. + reducer.apply_event(&EngineEvent::ReplaceRange { + utterance_id: 6, + start: 46, + end: 46, + text: " przed wydaniem".to_string(), + source: LayerSource::TailPatch, + }); + assert!( + reducer.rendered_text().contains("przed wydaniem"), + "novel recovered material must still reach the canvas: {:?}", + reducer.rendered_text() + ); + } + + /// Regression for the 2026-08-14 tripled-RAW incident (Monika's take: + /// reducer said 228 chars, the RAW pulled by `recorder.stop()` said 791). + /// Two writers raced on the shared buffer: the command worker snapshotted + /// the full target AND the tick loop appended the same suffix again, so + /// cumulative Apple previews multiplied the trailing sentence. + /// + /// This walks the exact runtime seam — `on_event` → reducer → command + /// channel → worker snapshot → tick animation → shared buffer — with the + /// only substituted boundary being the event source, and demands the buffer + /// end byte-identical to the reducer truth. + #[tokio::test] + async fn transcript_buffer_matches_reducer_truth_after_cumulative_previews() { + let transcript = Arc::new(Mutex::new(String::new())); + let mut emitter = PresentationEmitter::new(transcript.clone(), None, None); + + let final_event = |id: u64, text: &str, start: f32, end: f32| EngineEvent::UtteranceFinal { + utterance_id: id, + text: text.to_string(), + raw_text: text.to_string(), + start_ts: start, + end_ts: end, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }; + + // The Apple-lane shape from the incident log: per-utterance previews + // grow until a final seals them (the restated-prefix guards upstream + // strip whole-session restatements before emission), and stop arrives + // with an open partial still on the canvas — sealed=2 + open tail. + let events = vec![ + EngineEvent::Preview { + rev: 1, + text: "Pies od wczoraj".to_string(), + }, + EngineEvent::Preview { + rev: 2, + text: "Pies od wczoraj wymiotuje.".to_string(), + }, + final_event(1, "Pies od wczoraj wymiotuje.", 0.0, 2.0), + EngineEvent::Preview { + rev: 3, + text: "Nie je i nie".to_string(), + }, + EngineEvent::Preview { + rev: 4, + text: "Nie je i nie pije.".to_string(), + }, + final_event(2, "Nie je i nie pije.", 2.0, 4.0), + EngineEvent::Preview { + rev: 5, + text: "Podałam mu".to_string(), + }, + ]; + + let mut reference = SessionTranscriptState::default(); + for event in &events { + emitter.on_event(event); + let _ = reference.apply_event(event); + } + emitter.finish().await; + + let raw = transcript.lock().await.clone(); + assert_eq!( + raw, + reference.rendered_text(), + "the RAW buffer recorder.stop() reads must be byte-identical to the reducer truth" + ); + assert_eq!( + raw.matches("wymiotuje").count(), + 1, + "a sentence delivered once must appear exactly once in the RAW, got: {raw:?}" + ); + } + /// Live preview appends after committed text in the rendered session canvas. #[test] fn session_state_appends_preview_after_committed_text() { @@ -1056,6 +1351,44 @@ mod tests { assert_eq!(snapshot, "Ala ma kota"); } + /// Apple progressive closes with `SessionFinalised`, not `Stats`. A fully + /// re-heard cumulative final can leave the committed canvas in Preview; + /// ignoring the closing event then persists `committed + restatement`. + #[tokio::test] + async fn session_finalised_clears_reheard_preview_without_stats() { + let transcript = Arc::new(Mutex::new(String::new())); + let mut emitter = PresentationEmitter::new(transcript.clone(), None, None); + + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "Ala ma kota".to_string(), + raw_text: "Ala ma kota".to_string(), + start_ts: 0.0, + end_ts: 1.0, + segments: Vec::new(), + vad_speech_pct: Some(100.0), + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + emitter.on_event(&EngineEvent::Preview { + rev: 2, + text: "Ala ma kota".to_string(), + }); + emitter.on_event(&EngineEvent::SessionFinalised { + session_id: "session".to_string(), + layer_summary: LayerSummary::default(), + }); + emitter.finish().await; + + let snapshot = transcript.lock().await.clone(); + assert_eq!( + snapshot, "Ala ma kota", + "SessionFinalised must persist committed canvas only" + ); + } + /// Late correction matching penultimate commit patches it, never appends. #[tokio::test] async fn correction_targets_penultimate_utterance_patches_instead_of_appending() { @@ -1114,4 +1447,107 @@ mod tests { // No duplication of the corrected text. assert_eq!(snapshot.matches("Ala ma").count(), 1); } + + /// Dictation and Agent differ only in metadata/consumer choice. The exact + /// same engine fixture must produce byte-equivalent draft events. The + /// controller-owned product seal is simulated explicitly after engine close. + #[tokio::test] + async fn dictation_and_agent_publish_identical_drafts_before_controller_seal() { + use crate::presentation::transcript_bus::{ + CleanTranscriptEvent, TranscriptBus, TranscriptMode, TranscriptSession, + }; + + fn run_route( + root: &std::path::Path, + mode: TranscriptMode, + session_id: &str, + ) -> Vec { + let path = root.join(format!("{mode:?}.jsonl")); + let bus = Arc::new( + TranscriptBus::open_at( + TranscriptSession { + session_id: session_id.to_string(), + mode, + }, + path.clone(), + Some(48_000), + ) + .unwrap(), + ); + let transcript = Arc::new(Mutex::new(String::new())); + let emitter = PresentationEmitter::new_with_transcript_bus( + transcript, + None, + None, + Some(Arc::clone(&bus)), + ); + emitter.on_event(&EngineEvent::Preview { + rev: 1, + text: "shared clean truth".to_string(), + }); + emitter.on_event(&EngineEvent::UtteranceFinal { + utterance_id: 42, + text: "shared clean truth".to_string(), + raw_text: "unpublished raw hypothesis".to_string(), + start_ts: 0.25, + end_ts: 1.5, + segments: vec![TranscriptSegment { + text: "shared clean truth".to_string(), + start_ts: 0.25, + end_ts: 1.5, + }], + vad_speech_pct: Some(91.0), + avg_logprob: Some(-0.2), + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + }); + emitter.on_event(&EngineEvent::SessionFinalised { + session_id: format!("pipeline-{session_id}"), + layer_summary: LayerSummary::default(), + }); + bus.publish_sealed( + "shared clean truth".to_string(), + Some(format!("pipeline-{session_id}")), + ); + + std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + let temp = tempfile::tempdir().unwrap(); + let dictation = run_route(temp.path(), TranscriptMode::Dictation, "dictation-session"); + let agent = run_route(temp.path(), TranscriptMode::Agent, "agent-session"); + + let comparable = |events: &[CleanTranscriptEvent]| { + events + .iter() + .skip(1) + .map(|event| { + ( + event.status.clone(), + event.utterance_id, + event.sample_rate_hz, + event.sample_start, + event.sample_end, + event.audio_start_seconds, + event.audio_end_seconds, + event.text.clone(), + event.segments.clone(), + ) + }) + .collect::>() + }; + assert_eq!(comparable(&dictation), comparable(&agent)); + assert_eq!(agent[1].status, "utterance_draft"); + assert_eq!(agent[2].status, "transcript_sealed"); + assert!( + !agent + .iter() + .any(|event| event.text.contains("unpublished raw")) + ); + } } diff --git a/app/presentation/mod.rs b/app/presentation/mod.rs index e170890a..9cd2ae5b 100644 --- a/app/presentation/mod.rs +++ b/app/presentation/mod.rs @@ -5,5 +5,7 @@ //! and this module decides how to show them. pub mod emitter; +pub mod transcript_bus; pub use emitter::PresentationEmitter; +pub use transcript_bus::{TranscriptBus, TranscriptMode, TranscriptSession}; diff --git a/app/presentation/transcript_bus.rs b/app/presentation/transcript_bus.rs new file mode 100644 index 00000000..a9acf215 --- /dev/null +++ b/app/presentation/transcript_bus.rs @@ -0,0 +1,638 @@ +//! Durable clean transcript events for operator and control-plane consumers. +//! +//! The bus observes the mutable [`PresentationEmitter`] draft and the one +//! authoritative product seal chosen by [`crate::controller::RecordingController`]. +//! It never opens audio, re-transcribes a file, or reconstructs text from UI +//! deltas. One append-only JSON object is flushed per state transition. + +use std::collections::BTreeMap; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use chrono::{SecondsFormat, Utc}; +use codescribe_core::audio::capture_receipt::session_energy_db; +use codescribe_core::pipeline::contracts::TranscriptSegment; +use serde::{Deserialize, Serialize}; + +/// Explicit path override for the clean transcript bus. +pub const TRANSCRIPT_BUS_PATH_ENV: &str = "CODESCRIBE_TRANSCRIPT_BUS_PATH"; +/// Stable filename under the configured state/data root. +pub const TRANSCRIPT_BUS_FILENAME: &str = "transcript-events.jsonl"; + +/// Product mode attached to every committed transcript event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TranscriptMode { + /// Plain dictation or formatting; the downstream action is paste/format. + Dictation, + /// Right Option / composer Agent voice input; the downstream action is send. + Agent, + /// Hold-based Chat/Selection assistance; downstream action is Agent delivery. + Assistive, +} + +/// Immutable identity supplied by the controller before capture starts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriptSession { + pub session_id: String, + pub mode: TranscriptMode, +} + +/// One mutable utterance slot in the live transcript draft. +#[derive(Debug, Clone, PartialEq)] +pub struct TranscriptDraft { + pub utterance_id: u64, + pub text: String, + pub start_seconds: f32, + pub end_seconds: f32, + pub segments: Vec, +} + +/// Typed draft transition. Product truth is never represented by this enum; +/// only [`TranscriptBus::publish_sealed`] can cross the immutable boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TranscriptDraftStatus { + Created, + Revised, +} + +impl TranscriptDraftStatus { + fn as_str(self) -> &'static str { + match self { + Self::Created => "utterance_draft", + Self::Revised => "utterance_revised", + } + } +} + +/// Grain of one published span. Word pins are engine evidence; utterance +/// grain is the honest fallback when Apple committed a window, not words. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TranscriptWordGrain { + #[default] + Word, + Utterance, +} + +fn is_word_grain(grain: &TranscriptWordGrain) -> bool { + matches!(grain, TranscriptWordGrain::Word) +} + +/// One span on the capture PCM clock: text + samples + intensity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TranscriptWordSpan { + pub text: String, + pub sample_start: u64, + pub sample_end: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub energy_db: Option, + #[serde(default, skip_serializing_if = "is_word_grain")] + pub grain: TranscriptWordGrain, +} + +/// Append-only public event contract. `text` is always clean reducer truth; +/// unfiltered engine `raw_text` never crosses this boundary. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CleanTranscriptEvent { + pub schema: String, + pub sequence: u64, + pub session_id: String, + pub mode: TranscriptMode, + pub utterance_id: Option, + pub emitted_at: String, + pub status: String, + pub sample_rate_hz: Option, + pub sample_start: Option, + pub sample_end: Option, + pub audio_start_seconds: Option, + pub audio_end_seconds: Option, + pub text: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub segments: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub words: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub pipeline_session_id: Option, +} + +/// Synchronous low-frequency writer. Draft boundaries occur on the STT worker, +/// not the CoreAudio callback, and each line is flushed so a live tailer sees +/// them before the next utterance or process exit. +pub struct TranscriptBus { + session: TranscriptSession, + path: PathBuf, + writer: Mutex, + sample_rate_override: Option, +} + +/// One lock owns lifecycle and bytes together. This makes the sequence stored +/// on disk authoritative even when engine-close and a late reducer callback +/// arrive from different threads. +struct TranscriptBusWriter { + file: File, + sequence: u64, + started: bool, + sealed: bool, + drafts: BTreeMap, +} + +impl TranscriptBus { + /// Resolve the production path and open the session bus. Failure disables + /// only observability; it must never stop microphone capture or delivery. + pub fn open(session: TranscriptSession) -> Option { + let path = transcript_bus_path(); + match Self::open_at(session, path, None) { + Ok(bus) => Some(bus), + Err(error) => { + tracing::warn!(%error, "clean transcript bus unavailable"); + None + } + } + } + + /// Open an explicit path. Kept public for deterministic pipeline tests and + /// embedders that already own an XDG/project state root. + pub fn open_at( + session: TranscriptSession, + path: PathBuf, + sample_rate_override: Option, + ) -> io::Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let mut options = OpenOptions::new(); + options.create(true).append(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + let bus = Self { + session, + path, + writer: Mutex::new(TranscriptBusWriter { + file, + sequence: 0, + started: false, + sealed: false, + drafts: BTreeMap::new(), + }), + sample_rate_override, + }; + Ok(bus) + } + + /// Publish the recording start exactly once. Controllers call this only + /// after audio starts; commit/final observers also call it defensively so + /// the first visible transcript event can never precede its session start. + pub fn publish_started(&self) { + let mut writer = self + .writer + .lock() + .unwrap_or_else(|error| error.into_inner()); + match self.ensure_started_locked(&mut writer) { + Ok(true) => { + tracing::info!(path = %self.path.display(), session_id = %self.session.session_id, mode = ?self.session.mode, "clean transcript bus session started"); + } + Ok(false) => {} + Err(error) => self.log_write_error(error), + } + } + + /// Publish a new mutable utterance slot or a bounded revision of that slot. + pub fn publish_draft(&self, status: TranscriptDraftStatus, utterance: TranscriptDraft) { + let status = status.as_str(); + let sample_rate = self.sample_rate(); + let words = word_spans_from_draft(&utterance, sample_rate); + let event = CleanTranscriptEvent { + schema: "codescribe.transcript.v1".to_string(), + sequence: 0, + session_id: String::new(), + mode: self.session.mode, + utterance_id: Some(utterance.utterance_id), + emitted_at: String::new(), + status: status.to_string(), + sample_rate_hz: sample_rate, + sample_start: sample_rate.map(|rate| seconds_to_sample(utterance.start_seconds, rate)), + sample_end: sample_rate.map(|rate| seconds_to_sample(utterance.end_seconds, rate)), + audio_start_seconds: Some(utterance.start_seconds), + audio_end_seconds: Some(utterance.end_seconds), + text: utterance.text.clone(), + segments: utterance.segments.clone(), + words, + pipeline_session_id: None, + }; + + let mut writer = self + .writer + .lock() + .unwrap_or_else(|error| error.into_inner()); + if writer.sealed { + tracing::warn!(session_id = %self.session.session_id, %status, "transcript draft ignored after product seal"); + return; + } + writer.drafts.insert(utterance.utterance_id, utterance); + if let Err(error) = self + .ensure_started_locked(&mut writer) + .and_then(|_| self.write_event_locked(&mut writer, event)) + { + self.log_write_error(error); + } + } + + /// Publish the one immutable product truth after every configured automatic + /// stage (engine layers, final pass, adjudication, postprocess, formatting) + /// has completed. The first call wins byte-for-byte; later calls are ignored. + pub fn publish_sealed(&self, text: String, pipeline_session_id: Option) { + let mut writer = self + .writer + .lock() + .unwrap_or_else(|error| error.into_inner()); + if writer.sealed { + return; + } + let sample_rate = self.sample_rate(); + let clock = aggregate_seal_clock(&writer.drafts, sample_rate); + let event = CleanTranscriptEvent { + schema: "codescribe.transcript.v1".to_string(), + sequence: 0, + session_id: String::new(), + mode: self.session.mode, + utterance_id: None, + emitted_at: String::new(), + status: "transcript_sealed".to_string(), + sample_rate_hz: sample_rate, + sample_start: clock.sample_start, + sample_end: clock.sample_end, + audio_start_seconds: clock.audio_start_seconds, + audio_end_seconds: clock.audio_end_seconds, + text, + segments: clock.segments, + words: clock.words, + pipeline_session_id, + }; + match self + .ensure_started_locked(&mut writer) + .and_then(|_| self.write_event_locked(&mut writer, event)) + { + Ok(()) => writer.sealed = true, + Err(error) => self.log_write_error(error), + } + } + + /// The resolved path consumed by an external NDJSON tailer. + pub fn path(&self) -> &Path { + &self.path + } + + fn sample_rate(&self) -> Option { + self.sample_rate_override.or_else(|| { + codescribe_core::audio::capture_receipt::last_open_capture_path() + .map(|capture| capture.sample_rate) + .filter(|rate| *rate > 0) + }) + } + + fn ensure_started_locked(&self, writer: &mut TranscriptBusWriter) -> io::Result { + if writer.started { + return Ok(false); + } + self.write_event_locked( + writer, + CleanTranscriptEvent { + schema: "codescribe.transcript.v1".to_string(), + sequence: 0, + session_id: String::new(), + mode: self.session.mode, + utterance_id: None, + emitted_at: String::new(), + status: "session_started".to_string(), + sample_rate_hz: None, + sample_start: None, + sample_end: None, + audio_start_seconds: None, + audio_end_seconds: None, + text: String::new(), + segments: Vec::new(), + words: Vec::new(), + pipeline_session_id: None, + }, + )?; + writer.started = true; + Ok(true) + } + + fn write_event_locked( + &self, + writer: &mut TranscriptBusWriter, + mut event: CleanTranscriptEvent, + ) -> io::Result<()> { + let next_sequence = writer.sequence.saturating_add(1); + event.sequence = next_sequence; + event.session_id.clone_from(&self.session.session_id); + event.mode = self.session.mode; + event.emitted_at = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true); + + let mut encoded = serde_json::to_vec(&event).map_err(io::Error::other)?; + encoded.push(b'\n'); + writer.file.write_all(&encoded)?; + writer.file.flush()?; + writer.sequence = next_sequence; + Ok(()) + } + + fn log_write_error(&self, error: io::Error) { + tracing::warn!(%error, path = %self.path.display(), "clean transcript event write failed"); + } +} + +/// Path precedence: explicit contract, XDG state, then Codescribe's existing +/// project/data override (`CODESCRIBE_DATA_DIR`) via `Config::config_dir()`. +pub fn transcript_bus_path() -> PathBuf { + if let Ok(path) = std::env::var(TRANSCRIPT_BUS_PATH_ENV) { + let path = path.trim(); + if !path.is_empty() { + return expand_tilde(path); + } + } + if let Ok(root) = std::env::var("XDG_STATE_HOME") { + let root = root.trim(); + if !root.is_empty() { + return expand_tilde(root) + .join("codescribe") + .join(TRANSCRIPT_BUS_FILENAME); + } + } + codescribe_core::config::Config::config_dir().join(TRANSCRIPT_BUS_FILENAME) +} + +fn seconds_to_sample(seconds: f32, sample_rate: u32) -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + (f64::from(seconds) * f64::from(sample_rate)).round() as u64 +} + +fn finite_audio_window(start: f32, end: f32) -> Option<(f32, f32)> { + if start.is_finite() && end.is_finite() && end > start { + Some((start, end)) + } else { + None + } +} + +fn word_span_from_seconds( + text: String, + start: f32, + end: f32, + sample_rate: Option, + grain: TranscriptWordGrain, +) -> Option { + let (start, end) = finite_audio_window(start, end)?; + if text.trim().is_empty() { + return None; + } + let rate = sample_rate.filter(|rate| *rate > 0)?; + let sample_start = seconds_to_sample(start, rate); + let sample_end = seconds_to_sample(end, rate).max(sample_start.saturating_add(1)); + Some(TranscriptWordSpan { + text, + sample_start, + sample_end, + energy_db: session_energy_db(sample_start, sample_end), + grain, + }) +} + +fn word_spans_from_draft( + utterance: &TranscriptDraft, + sample_rate: Option, +) -> Vec { + if !utterance.segments.is_empty() { + return utterance + .segments + .iter() + .filter_map(|segment| { + word_span_from_seconds( + segment.text.clone(), + segment.start_ts, + segment.end_ts, + sample_rate, + TranscriptWordGrain::Word, + ) + }) + .collect(); + } + word_span_from_seconds( + utterance.text.clone(), + utterance.start_seconds, + utterance.end_seconds, + sample_rate, + TranscriptWordGrain::Utterance, + ) + .into_iter() + .collect() +} + +struct SealClock { + sample_start: Option, + sample_end: Option, + audio_start_seconds: Option, + audio_end_seconds: Option, + segments: Vec, + words: Vec, +} + +fn aggregate_seal_clock( + drafts: &BTreeMap, + sample_rate: Option, +) -> SealClock { + let mut audio_start = None; + let mut audio_end = None; + let mut segments = Vec::new(); + let mut words = Vec::new(); + for draft in drafts.values() { + if let Some((start, end)) = finite_audio_window(draft.start_seconds, draft.end_seconds) { + audio_start = Some(audio_start.map_or(start, |seen: f32| seen.min(start))); + audio_end = Some(audio_end.map_or(end, |seen: f32| seen.max(end))); + } + segments.extend(draft.segments.iter().cloned()); + words.extend(word_spans_from_draft(draft, sample_rate)); + } + SealClock { + sample_start: match (sample_rate, audio_start) { + (Some(rate), Some(start)) => Some(seconds_to_sample(start, rate)), + _ => None, + }, + sample_end: match (sample_rate, audio_end) { + (Some(rate), Some(end)) => Some(seconds_to_sample(end, rate)), + _ => None, + }, + audio_start_seconds: audio_start, + audio_end_seconds: audio_end, + segments, + words, + } +} + +fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + return directories::BaseDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(path)); + } + if let Some(relative) = path.strip_prefix("~/") { + return directories::BaseDirs::new() + .map(|dirs| dirs.home_dir().join(relative)) + .unwrap_or_else(|| PathBuf::from(path)); + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bus_flushes_start_draft_and_seal_as_private_ndjson() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("events.jsonl"); + let bus = TranscriptBus::open_at( + TranscriptSession { + session_id: "session-agent".to_string(), + mode: TranscriptMode::Agent, + }, + path.clone(), + Some(48_000), + ) + .unwrap(); + bus.publish_draft( + TranscriptDraftStatus::Created, + TranscriptDraft { + utterance_id: 7, + text: "clean final".to_string(), + start_seconds: 0.25, + end_seconds: 1.5, + segments: Vec::new(), + }, + ); + bus.publish_sealed( + "clean final".to_string(), + Some("engine-session".to_string()), + ); + bus.publish_draft( + TranscriptDraftStatus::Revised, + TranscriptDraft { + utterance_id: 7, + text: "must not escape finalization".to_string(), + start_seconds: 0.25, + end_seconds: 1.5, + segments: Vec::new(), + }, + ); + bus.publish_sealed("duplicate final".to_string(), None); + + let lines: Vec = std::fs::read_to_string(&path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].status, "session_started"); + assert_eq!(lines[1].status, "utterance_draft"); + assert_eq!(lines[1].sample_start, Some(12_000)); + assert_eq!(lines[1].sample_end, Some(72_000)); + assert_eq!(lines[2].status, "transcript_sealed"); + assert_eq!(lines[2].text, "clean final"); + assert_eq!(lines[2].sample_start, Some(12_000)); + assert_eq!(lines[2].sample_end, Some(72_000)); + assert_eq!(lines[2].audio_start_seconds, Some(0.25)); + assert_eq!(lines[2].audio_end_seconds, Some(1.5)); + assert_eq!(lines[2].words.len(), 1); + assert_eq!(lines[2].words[0].sample_start, 12_000); + assert_eq!(lines[2].words[0].sample_end, 72_000); + assert_eq!(lines[2].words[0].grain, TranscriptWordGrain::Utterance); + assert_eq!( + lines.iter().map(|event| event.sequence).collect::>(), + vec![1, 2, 3] + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn seal_publishes_word_spans_on_the_pcm_clock() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("words.jsonl"); + let bus = TranscriptBus::open_at( + TranscriptSession { + session_id: "session-words".to_string(), + mode: TranscriptMode::Dictation, + }, + path.clone(), + Some(16_000), + ) + .unwrap(); + bus.publish_draft( + TranscriptDraftStatus::Created, + TranscriptDraft { + utterance_id: 3, + text: "dwa slowa".to_string(), + start_seconds: 1.0, + end_seconds: 2.0, + segments: vec![ + TranscriptSegment { + text: "dwa".to_string(), + start_ts: 1.0, + end_ts: 1.4, + }, + TranscriptSegment { + text: "slowa".to_string(), + start_ts: 1.4, + end_ts: 2.0, + }, + ], + }, + ); + bus.publish_sealed("dwa slowa".to_string(), None); + + let lines: Vec = std::fs::read_to_string(&path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let seal = lines + .iter() + .find(|event| event.status == "transcript_sealed") + .expect("seal"); + assert_eq!(seal.sample_start, Some(16_000)); + assert_eq!(seal.sample_end, Some(32_000)); + assert_eq!(seal.segments.len(), 2); + assert_eq!(seal.words.len(), 2); + assert_eq!(seal.words[0].text, "dwa"); + assert_eq!(seal.words[0].sample_start, 16_000); + assert_eq!(seal.words[0].sample_end, 22_400); + assert_eq!(seal.words[0].grain, TranscriptWordGrain::Word); + assert_eq!(seal.words[1].text, "slowa"); + assert_eq!(seal.words[1].sample_start, 22_400); + assert_eq!(seal.words[1].sample_end, 32_000); + } +} diff --git a/bin/codescribe-corpus.rs b/bin/codescribe-corpus.rs new file mode 100644 index 00000000..c210aa65 --- /dev/null +++ b/bin/codescribe-corpus.rs @@ -0,0 +1,2068 @@ +//! Unattended corpus census and production-overlay replay. +//! +//! This tool never loads the operator's `settings.json` or `.env`. Matrix +//! profiles run in fresh child processes with an isolated +//! `CODESCRIBE_DATA_DIR`; audio is the only substituted production boundary. +//! Machine reports contain hashes, counts and scores, never filenames or +//! transcript bodies. A separate private Qube HTML contains transcript bodies +//! and opaque audio links for local operator review. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::fmt::Write as _; +use std::fs::{self, OpenOptions}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, ExitCode, Stdio}; +use std::str::FromStr; +use std::time::Instant; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::Utc; +use clap::{Parser, Subcommand, ValueEnum}; +use codescribe::controller::production_replay::{ProductionReplayLane, replay_overlay_recording}; +use codescribe::qube_report::{ + LocalTranscriptionMode, MetricsReference, QualityReport, QualityReportConfig, ReportEntry, + ReportEnvironment, ReportMetrics, ReportSummary, ReportTranscriptSemantics, + ReportTranscriptState, ReportTranscripts, render_html as render_qube_html, +}; +use codescribe_core::asr_session::GatewaySessionAvailability; +use codescribe_core::config::UserSettings; +use codescribe_core::pipeline::contracts::{EngineEvent, LayerSource}; +use codescribe_core::quality::engine_contract::{CORPUS_REPORT_SCHEMA, ENGINE_CONTRACT_ID}; +use codescribe_core::quality::seal_atlas_html::{ + SealAtlasPage, SealAtlasStats, render_seal_atlas_html, +}; +use codescribe_core::util::safe_path::{safe_open, safe_symlink_or_copy_bounded}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const REPORT_SCHEMA: &str = CORPUS_REPORT_SCHEMA; +const AUDIO_EXTENSIONS: [&str; 3] = ["wav", "m4a", "mp3"]; +const CONTROLLED_ENV: [&str; 14] = [ + "CODESCRIBE_STT_ENGINE", + "CODESCRIBE_LAYERED_TRANSCRIPTION", + "STT_TAIL_PROVIDER", + "CODESCRIBE_SILERO_FUSION", + "CODESCRIBE_SILERO_FUSION_CONTEXT", + "CODESCRIBE_SPAN_IDEMPOTENCE", + "CODESCRIBE_INLINE_FORMAT", + "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", + "FINAL_PASS_MODE", + "CODESCRIBE_FINAL_PASS_MODE", + "CODESCRIBE_LOCAL_STT_FINAL_PASS", + "CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", + "CODESCRIBE_APPLE_STT_BRIDGE", + "CODESCRIBE_BRIDGE_DISCLAIM", +]; + +#[derive(Debug, Parser)] +#[command( + name = "codescribe-corpus", + about = "Private corpus census and production-overlay replay", + version +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Inventory audio and reference classes without running STT. + Census { + /// Corpus root. Repeat to combine roots. + #[arg(long = "root", required = true)] + roots: Vec, + /// JSON report path. + #[arg(long)] + out: PathBuf, + /// Treat same-stem TXT files as historical references. + #[arg(long)] + include_historical: bool, + }, + /// Run one or more isolated replay profiles and write a retained report. + Run { + /// Corpus root. Repeat to combine roots. + #[arg(long = "root", required = true)] + roots: Vec, + /// Durable report directory. + #[arg(long)] + out_dir: PathBuf, + /// Comma-separated profile names. + #[arg( + long, + value_delimiter = ',', + default_value = "apple-layer0,apple-layer1-inprocess" + )] + profiles: Vec, + /// Independent executions per distinct recording and profile. + #[arg(long, default_value_t = 1)] + runs: usize, + /// Reference selection policy. + #[arg(long, value_enum, default_value_t = ReferencePolicy::Human)] + references: ReferencePolicy, + /// Bound the selected distinct recordings after stable hash sort. + #[arg(long)] + max_recordings: Option, + /// Recognition language pin. + #[arg(long, default_value = "pl")] + language: String, + /// Exact source commit claimed by this binary invocation. + #[arg(long)] + commit: String, + /// Exact signed Apple STT bridge artifact used by every worker. + #[arg(long)] + apple_bridge: PathBuf, + }, + /// Internal one-profile worker. Fresh process = fresh runtime globals. + #[command(hide = true)] + Worker { + #[arg(long = "root", required = true)] + roots: Vec, + #[arg(long)] + out: PathBuf, + #[arg(long)] + profile: ReplayProfile, + #[arg(long, default_value_t = 1)] + runs: usize, + #[arg(long, value_enum)] + references: ReferencePolicy, + #[arg(long)] + max_recordings: Option, + #[arg(long)] + language: String, + #[arg(long)] + commit: String, + #[arg(long)] + apple_bridge: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ReferencePolicy { + /// Only explicit `_human_transcription.txt` siblings are quality truth. + Human, + /// Prefer explicit human truth, then admit same-stem historical TXT. + HumanAndHistorical, +} + +impl ReferencePolicy { + const fn as_str(self) -> &'static str { + match self { + Self::Human => "human", + Self::HumanAndHistorical => "human_and_historical", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +enum ReplayProfile { + /// Apple canvas + lexicon, Layer 1 disarmed. + AppleLayer0, + /// Apple canvas + in-process Whisper partials + lexicon. + AppleLayer1Inprocess, + /// Apple canvas + sidecar Whisper partials + lexicon. + AppleLayer1Sidecar, + /// Apple canvas + remote Whisper partials + lexicon. + AppleLayer1Remote, + /// Layer 1 plus Silero utterance identity, utterance-only context. + AppleLayer1FusionUtterance, + /// Layer 1 plus Silero identity and bounded left-audio context. + AppleLayer1FusionLeftPad, + /// Layer 1 plus Silero identity and stable-text prompt context. + AppleLayer1FusionStablePrompt, + /// Fusion plus sealed-span replay/idempotence fence. + AppleLayer1FusionIdempotent, + /// Layer 1 live transcript plus the opt-in local final-pass stop lane. + AppleLayer1LocalFinalPass, +} + +impl ReplayProfile { + const fn token(self) -> &'static str { + match self { + Self::AppleLayer0 => "apple-layer0", + Self::AppleLayer1Inprocess => "apple-layer1-inprocess", + Self::AppleLayer1Sidecar => "apple-layer1-sidecar", + Self::AppleLayer1Remote => "apple-layer1-remote", + Self::AppleLayer1FusionUtterance => "apple-layer1-fusion-utterance", + Self::AppleLayer1FusionLeftPad => "apple-layer1-fusion-left-pad", + Self::AppleLayer1FusionStablePrompt => "apple-layer1-fusion-stable-prompt", + Self::AppleLayer1FusionIdempotent => "apple-layer1-fusion-idempotent", + Self::AppleLayer1LocalFinalPass => "apple-layer1-local-final-pass", + } + } + + const fn layered(self) -> bool { + !matches!(self, Self::AppleLayer0) + } + + const fn tail_provider(self) -> &'static str { + match self { + Self::AppleLayer1Sidecar => "sidecar", + Self::AppleLayer1Remote => "remote", + _ => "inprocess", + } + } + + const fn fusion(self) -> bool { + matches!( + self, + Self::AppleLayer1FusionUtterance + | Self::AppleLayer1FusionLeftPad + | Self::AppleLayer1FusionStablePrompt + | Self::AppleLayer1FusionIdempotent + ) + } + + const fn fusion_context(self) -> &'static str { + match self { + Self::AppleLayer1FusionLeftPad => "left_pad", + Self::AppleLayer1FusionStablePrompt => "stable_prompt", + _ => "utterance_only", + } + } + + const fn idempotence(self) -> bool { + matches!(self, Self::AppleLayer1FusionIdempotent) + } + + const fn stop_lane(self) -> ProductionReplayLane { + match self { + Self::AppleLayer1LocalFinalPass => ProductionReplayLane::LocalFinalPass, + _ => ProductionReplayLane::AppleLexicon, + } + } +} + +impl std::fmt::Display for ReplayProfile { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.token()) + } +} + +impl FromStr for ReplayProfile { + type Err = String; + + fn from_str(raw: &str) -> std::result::Result { + let normalized = raw.trim().replace('_', "-"); + [ + Self::AppleLayer0, + Self::AppleLayer1Inprocess, + Self::AppleLayer1Sidecar, + Self::AppleLayer1Remote, + Self::AppleLayer1FusionUtterance, + Self::AppleLayer1FusionLeftPad, + Self::AppleLayer1FusionStablePrompt, + Self::AppleLayer1FusionIdempotent, + Self::AppleLayer1LocalFinalPass, + ] + .into_iter() + .find(|profile| profile.token() == normalized) + .ok_or_else(|| format!("unknown replay profile {raw:?}")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ReferenceKind { + Human, + HistoricalSameStem, +} + +impl ReferenceKind { + const fn rank(self) -> u8 { + match self { + Self::Human => 0, + Self::HistoricalSameStem => 1, + } + } +} + +#[derive(Debug, Clone)] +struct Reference { + path: PathBuf, + sha256: String, + kind: ReferenceKind, +} + +#[derive(Debug, Clone)] +struct Clip { + path: PathBuf, + sha256: String, + reference: Option, + has_apple_reference: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CorpusCensus { + schema: String, + generated_at: String, + root_count: usize, + discovered_audio_instances: usize, + distinct_audio: usize, + duplicate_instances: usize, + format_instances: BTreeMap, + human_reference_instances: usize, + historical_reference_instances: usize, + apple_reference_instances: usize, + distinct_human_paired: usize, + distinct_historical_paired: usize, + distinct_apple_referenced: usize, + distinct_unpaired: usize, + selected_distinct: usize, + reference_policy: String, + privacy: PrivacyContract, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PrivacyContract { + source_paths_emitted: bool, + source_filenames_emitted: bool, + transcript_bodies_emitted: bool, + opaque_ids_are_hash_prefixes: bool, +} + +impl Default for PrivacyContract { + fn default() -> Self { + Self { + source_paths_emitted: false, + source_filenames_emitted: false, + transcript_bodies_emitted: false, + opaque_ids_are_hash_prefixes: true, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProfileReport { + schema: String, + engine_contract: String, + generated_at: String, + commit: String, + profile: ReplayProfile, + reference_policy: String, + corpus: CorpusCensus, + distinct_recordings: usize, + requested_runs_per_recording: usize, + requested_executions: usize, + successful_executions: usize, + failed_executions: usize, + total_audio_seconds_executed: f64, + total_tail_patches: usize, + requested_layered: bool, + observed_layered: bool, + profile_observation_matches: bool, + mean_wer: Option, + mean_cer: Option, + mean_character_parity: Option, + input_hashes_unchanged: bool, + settings_loaded: bool, + dotenv_loaded: bool, + keychain_disabled: bool, + apple_stt_bridge: FileFingerprint, + quality_html: String, + rows: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ExecutionRow { + opaque_id: String, + run: usize, + audio_sha256: String, + reference_sha256: String, + reference_kind: ReferenceKind, + duration_seconds: f64, + sample_rate_hz: u32, + status: String, + error_class: Option, + wall_seconds: f64, + events: usize, + previews: usize, + sealed_finals: usize, + final_count: usize, + unique_final_id_count: usize, + repeated_final_id_count: usize, + overlapping_final_window_count: usize, + tail_patches: usize, + layer1_provider_armed: bool, + live_chars: usize, + adjudicated_chars: usize, + delivered_chars: usize, + reference_tokens: usize, + delivered_tokens: usize, + token_ratio: f64, + head_present: bool, + tail_present: bool, + wer: f64, + cer: f64, + character_parity: f64, + teacher_similarity: f64, + final_pass_attempted: bool, + final_pass_skipped: bool, + lexicon_rewrites: u64, + gate_drops: u64, + audio_hash_unchanged: bool, + reference_hash_unchanged: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct FileFingerprint { + label: String, + exists: bool, + sha256: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ProfileStatus { + profile: ReplayProfile, + worker_exit: Option, + report_present: bool, + successful_executions: usize, + failed_executions: usize, + observed_layered: Option, + mean_wer: Option, + mean_cer: Option, + mean_character_parity: Option, + quality_html: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct MatrixReport { + schema: String, + engine_contract: String, + generated_at: String, + commit: String, + corpus: CorpusCensus, + requested_profiles: usize, + completed_profiles: usize, + distinct_recordings: usize, + requested_runs_per_recording: usize, + requested_executions: usize, + successful_executions: usize, + failed_executions: usize, + profile_status: Vec, + configuration_files_before: Vec, + configuration_files_after: Vec, + configuration_files_unchanged: bool, + operator_settings_loaded: bool, + operator_dotenv_loaded: bool, + keychain_disabled: bool, + apple_stt_bridge: FileFingerprint, + permission_request_apis_called_by_tool: bool, + tcc_database_inspected: bool, + permission_state_proven_unchanged: bool, + quality_gate: &'static str, + coverage: CoverageContract, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CoverageContract { + production_pcm_session_replay: &'static str, + production_stop_adjudication: &'static str, + production_lexicon_delivery: &'static str, + coreaudio_microphone_capture: &'static str, + blackhole_loopback_capture: &'static str, + hold_toggle_hotkey_modes: &'static str, + clipboard_paste_and_target_app: &'static str, + cloud_gateway: &'static str, + inline_llm_formatting: &'static str, + tcc_permissions: &'static str, +} + +impl Default for CoverageContract { + fn default() -> Self { + Self { + production_pcm_session_replay: "covered", + production_stop_adjudication: "covered", + production_lexicon_delivery: "covered", + coreaudio_microphone_capture: "not_covered_by_file_replay", + blackhole_loopback_capture: "not_covered_by_file_replay", + hold_toggle_hotkey_modes: "not_covered_by_file_replay", + clipboard_paste_and_target_app: "not_covered_by_file_replay", + cloud_gateway: "covered_only_when_apple_layer1_remote_is_requested_and_configured", + inline_llm_formatting: "not_covered_by_current_replay_seam", + tcc_permissions: "not_mutated_or_fully_verified", + } + } +} + +fn main() -> ExitCode { + // SAFETY: this is the first executable statement, before Clap parsing, + // runtime construction or thread creation. Corpus tooling must be unable + // to read, write or prompt for the operator's production Keychain even if + // a future replay dependency unexpectedly reaches Config/secret code. + unsafe { + std::env::set_var("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + } + match run(Cli::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("codescribe-corpus: {error:#}"); + ExitCode::from(2) + } + } +} + +fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Census { + roots, + out, + include_historical, + } => { + let policy = if include_historical { + ReferencePolicy::HumanAndHistorical + } else { + ReferencePolicy::Human + }; + let discovery = discover_corpus(&roots, policy, None)?; + atomic_write_json(&out, &discovery.census)?; + atomic_write( + &markdown_sibling(&out), + census_markdown(&discovery.census).as_bytes(), + )?; + println!( + "corpus census: instances={} distinct={} selected={} report={}", + discovery.census.discovered_audio_instances, + discovery.census.distinct_audio, + discovery.census.selected_distinct, + out.display() + ); + Ok(()) + } + Command::Run { + roots, + out_dir, + profiles, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + } => run_matrix(MatrixArgs { + roots, + out_dir, + profiles, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + }), + Command::Worker { + roots, + out, + profile, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + } => { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("build replay runtime")?; + runtime.block_on(run_worker(WorkerArgs { + roots, + out, + profile, + runs, + references, + max_recordings, + language, + commit, + apple_bridge, + })) + } + } +} + +struct Discovery { + census: CorpusCensus, + selected: Vec, +} + +fn discover_corpus( + roots: &[PathBuf], + policy: ReferencePolicy, + max_recordings: Option, +) -> Result { + if roots.is_empty() { + bail!("at least one corpus root is required"); + } + let mut instances = Vec::new(); + for root in roots { + if !root.is_dir() { + bail!("corpus root is not a directory: {}", root.display()); + } + walk_audio(root, &mut instances)?; + } + instances.sort(); + + let mut format_instances = BTreeMap::new(); + let mut human_reference_instances = 0usize; + let mut historical_reference_instances = 0usize; + let mut apple_reference_instances = 0usize; + let mut distinct = BTreeMap::::new(); + + for path in &instances { + let extension = lower_extension(path).unwrap_or_else(|| "unknown".to_string()); + *format_instances.entry(extension.clone()).or_insert(0) += 1; + let audio_sha256 = sha256_file(path)?; + let human_path = reference_path(path, "_human_transcription.txt"); + let historical_path = path.with_extension("txt"); + let apple_path = reference_path(path, "_apple_live_reference.txt"); + let human = human_path.filter(|candidate| candidate.is_file()); + let historical = historical_path.is_file().then_some(historical_path); + let has_apple_reference = apple_path.is_some_and(|candidate| candidate.is_file()); + human_reference_instances += usize::from(human.is_some()); + historical_reference_instances += usize::from(historical.is_some()); + apple_reference_instances += usize::from(has_apple_reference); + + let reference = if let Some(reference_path) = human { + Some(Reference { + sha256: sha256_file(&reference_path)?, + path: reference_path, + kind: ReferenceKind::Human, + }) + } else if matches!(policy, ReferencePolicy::HumanAndHistorical) { + if let Some(reference_path) = historical { + Some(Reference { + sha256: sha256_file(&reference_path)?, + path: reference_path, + kind: ReferenceKind::HistoricalSameStem, + }) + } else { + None + } + } else { + None + }; + + let incoming = Clip { + path: path.clone(), + sha256: audio_sha256.clone(), + reference, + has_apple_reference, + }; + match distinct.get_mut(&audio_sha256) { + Some(existing) => merge_duplicate(existing, incoming)?, + None => { + distinct.insert(audio_sha256, incoming); + } + } + } + + let distinct_audio = distinct.len(); + let distinct_human_paired = distinct + .values() + .filter(|clip| { + matches!( + clip.reference.as_ref().map(|reference| reference.kind), + Some(ReferenceKind::Human) + ) + }) + .count(); + let distinct_historical_paired = distinct + .values() + .filter(|clip| { + matches!( + clip.reference.as_ref().map(|reference| reference.kind), + Some(ReferenceKind::HistoricalSameStem) + ) + }) + .count(); + let distinct_apple_referenced = distinct + .values() + .filter(|clip| clip.has_apple_reference) + .count(); + let distinct_unpaired = distinct + .values() + .filter(|clip| clip.reference.is_none()) + .count(); + let mut selected = distinct + .into_values() + .filter(|clip| clip.reference.is_some()) + .collect::>(); + selected.sort_by(|left, right| left.sha256.cmp(&right.sha256)); + if let Some(limit) = max_recordings { + selected.truncate(limit); + } + + let census = CorpusCensus { + schema: REPORT_SCHEMA.to_string(), + generated_at: Utc::now().to_rfc3339(), + root_count: roots.len(), + discovered_audio_instances: instances.len(), + distinct_audio, + duplicate_instances: instances.len().saturating_sub(distinct_audio), + format_instances, + human_reference_instances, + historical_reference_instances, + apple_reference_instances, + distinct_human_paired, + distinct_historical_paired, + distinct_apple_referenced, + distinct_unpaired, + selected_distinct: selected.len(), + reference_policy: policy.as_str().to_string(), + privacy: PrivacyContract::default(), + }; + Ok(Discovery { census, selected }) +} + +fn walk_audio(directory: &Path, output: &mut Vec) -> Result<()> { + let mut entries = fs::read_dir(directory) + .with_context(|| format!("read corpus directory {}", directory.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let file_type = entry.file_type()?; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + walk_audio(&path, output)?; + } else if file_type.is_file() && is_audio(&path) { + output.push(path); + } + } + Ok(()) +} + +fn is_audio(path: &Path) -> bool { + lower_extension(path).is_some_and(|extension| AUDIO_EXTENSIONS.contains(&extension.as_str())) +} + +fn lower_extension(path: &Path) -> Option { + path.extension() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) +} + +fn reference_path(audio: &Path, suffix: &str) -> Option { + let stem = audio.file_stem()?.to_str()?; + Some(audio.parent()?.join(format!("{stem}{suffix}"))) +} + +fn merge_duplicate(existing: &mut Clip, incoming: Clip) -> Result<()> { + existing.has_apple_reference |= incoming.has_apple_reference; + match (&existing.reference, &incoming.reference) { + (Some(left), Some(right)) if left.kind == right.kind && left.sha256 != right.sha256 => { + bail!( + "duplicate audio hash has conflicting {:?} reference hashes", + left.kind + ); + } + (Some(left), Some(right)) if right.kind.rank() < left.kind.rank() => { + existing.reference = Some(right.clone()); + } + (None, Some(reference)) => existing.reference = Some(reference.clone()), + _ => {} + } + Ok(()) +} + +struct MatrixArgs { + roots: Vec, + out_dir: PathBuf, + profiles: Vec, + runs: usize, + references: ReferencePolicy, + max_recordings: Option, + language: String, + commit: String, + apple_bridge: PathBuf, +} + +fn run_matrix(args: MatrixArgs) -> Result<()> { + if args.runs == 0 { + bail!("--runs must be greater than zero"); + } + if args.profiles.is_empty() { + bail!("at least one replay profile is required"); + } + let apple_stt_bridge = fingerprint_file("apple_stt_bridge", &args.apple_bridge)?; + if !apple_stt_bridge.exists { + bail!("--apple-bridge must name an existing file"); + } + fs::create_dir_all(&args.out_dir) + .with_context(|| format!("create report directory {}", args.out_dir.display()))?; + let discovery = discover_corpus(&args.roots, args.references, args.max_recordings)?; + if discovery.selected.is_empty() { + bail!( + "no recordings match reference policy {}", + args.references.as_str() + ); + } + + let config_before = operator_configuration_fingerprints()?; + let current_exe = std::env::current_exe().context("resolve corpus runner executable")?; + let mut profile_status = Vec::with_capacity(args.profiles.len()); + let mut completed_profiles = 0usize; + let mut successful_executions = 0usize; + let mut failed_executions = 0usize; + + for profile in &args.profiles { + let profile_out = args + .out_dir + .join(format!("profile-{}.json", profile.token())); + let runtime_dir = args.out_dir.join("runtime").join(profile.token()); + fs::create_dir_all(&runtime_dir)?; + let mut child = ProcessCommand::new(¤t_exe); + child + .arg("worker") + .arg("--out") + .arg(&profile_out) + .arg("--profile") + .arg(profile.token()) + .arg("--runs") + .arg(args.runs.to_string()) + .arg("--references") + .arg(args.references.as_str().replace('_', "-")) + .arg("--language") + .arg(&args.language) + .arg("--commit") + .arg(&args.commit) + .arg("--apple-bridge") + .arg(&args.apple_bridge) + .env("CODESCRIBE_DATA_DIR", &runtime_dir) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some(limit) = args.max_recordings { + child.arg("--max-recordings").arg(limit.to_string()); + } + for root in &args.roots { + child.arg("--root").arg(root); + } + configure_profile_environment(&mut child, *profile, &args.apple_bridge); + let status = child + .status() + .with_context(|| format!("launch profile {}", profile.token()))?; + let report = fs::read(&profile_out) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if report.is_some() { + completed_profiles += 1; + } + let successful = report + .as_ref() + .map_or(0, |profile_report| profile_report.successful_executions); + let failed = report + .as_ref() + .map_or(discovery.selected.len() * args.runs, |profile_report| { + profile_report.failed_executions + }); + successful_executions += successful; + failed_executions += failed; + profile_status.push(ProfileStatus { + profile: *profile, + worker_exit: status.code(), + report_present: report.is_some(), + successful_executions: successful, + failed_executions: failed, + observed_layered: report + .as_ref() + .map(|profile_report| profile_report.observed_layered), + mean_wer: report + .as_ref() + .and_then(|profile_report| profile_report.mean_wer), + mean_cer: report + .as_ref() + .and_then(|profile_report| profile_report.mean_cer), + mean_character_parity: report + .as_ref() + .and_then(|profile_report| profile_report.mean_character_parity), + quality_html: report + .as_ref() + .map(|profile_report| profile_report.quality_html.clone()), + }); + } + + let config_after = operator_configuration_fingerprints()?; + let config_unchanged = config_before == config_after; + let requested_executions = discovery.selected.len() * args.runs * args.profiles.len(); + let matrix = MatrixReport { + schema: REPORT_SCHEMA.to_string(), + engine_contract: ENGINE_CONTRACT_ID.to_string(), + generated_at: Utc::now().to_rfc3339(), + commit: args.commit, + corpus: discovery.census, + requested_profiles: args.profiles.len(), + completed_profiles, + distinct_recordings: discovery.selected.len(), + requested_runs_per_recording: args.runs, + requested_executions, + successful_executions, + failed_executions, + profile_status, + configuration_files_before: config_before, + configuration_files_after: config_after, + configuration_files_unchanged: config_unchanged, + operator_settings_loaded: false, + operator_dotenv_loaded: false, + keychain_disabled: true, + apple_stt_bridge, + permission_request_apis_called_by_tool: false, + tcc_database_inspected: false, + permission_state_proven_unchanged: false, + quality_gate: "measurement_only_operator_decides", + coverage: CoverageContract::default(), + }; + let report_path = args.out_dir.join("report.json"); + atomic_write_json(&report_path, &matrix)?; + atomic_write( + &args.out_dir.join("report.md"), + matrix_markdown(&matrix).as_bytes(), + )?; + println!( + "corpus parity: distinct={} executions={}/{} config_unchanged={} report={}", + matrix.distinct_recordings, + matrix.successful_executions, + matrix.requested_executions, + matrix.configuration_files_unchanged, + report_path.display() + ); + if completed_profiles != args.profiles.len() { + bail!( + "{} of {} profile workers failed to publish a report", + args.profiles.len() - completed_profiles, + args.profiles.len() + ); + } + Ok(()) +} + +fn configure_profile_environment( + command: &mut ProcessCommand, + profile: ReplayProfile, + apple_bridge: &Path, +) { + for key in CONTROLLED_ENV { + command.env_remove(key); + } + if !matches!(profile, ReplayProfile::AppleLayer1Remote) { + command.env_remove("STT_API_KEY"); + command.env_remove("STT_ENDPOINT"); + command.env_remove("CODESCRIBE_STT_ENDPOINT"); + } + command + .env("CODESCRIBE_DISABLE_KEYCHAIN", "1") + .env("CODESCRIBE_STT_ENGINE", "apple") + .env("CODESCRIBE_APPLE_STT_BRIDGE", apple_bridge) + .env("CODESCRIBE_BRIDGE_DISCLAIM", "1") + .env( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + if profile.layered() { "phase1" } else { "off" }, + ) + .env("STT_TAIL_PROVIDER", profile.tail_provider()) + .env( + "CODESCRIBE_SILERO_FUSION", + if profile.fusion() { "on" } else { "off" }, + ) + .env("CODESCRIBE_SILERO_FUSION_CONTEXT", profile.fusion_context()) + .env( + "CODESCRIBE_SPAN_IDEMPOTENCE", + if profile.idempotence() { "on" } else { "off" }, + ) + .env("CODESCRIBE_INLINE_FORMAT", "off") + .env("CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", "off") + .env("CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", "0") + .env( + "FINAL_PASS_MODE", + if matches!(profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "always" + } else { + "off" + }, + ) + .env( + "CODESCRIBE_LOCAL_STT_FINAL_PASS", + if matches!(profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "1" + } else { + "0" + }, + ); +} + +struct WorkerArgs { + roots: Vec, + out: PathBuf, + profile: ReplayProfile, + runs: usize, + references: ReferencePolicy, + max_recordings: Option, + language: String, + commit: String, + apple_bridge: PathBuf, +} + +async fn run_worker(args: WorkerArgs) -> Result<()> { + if args.runs == 0 { + bail!("--runs must be greater than zero"); + } + validate_worker_environment(args.profile, &args.apple_bridge)?; + codescribe_core::stt::apple_stt::ensure_noninteractive_ready(Some(&args.language)) + .context("noninteractive Apple STT preflight")?; + let discovery = discover_corpus(&args.roots, args.references, args.max_recordings)?; + if discovery.selected.is_empty() { + bail!("worker selected no recordings"); + } + + let mut settings = UserSettings { + stt_engine: Some("apple".to_string()), + layered_transcription: Some(if args.profile.layered() { + "phase1".to_string() + } else { + "off".to_string() + }), + final_pass_mode: Some( + if matches!(args.profile, ReplayProfile::AppleLayer1LocalFinalPass) { + "always" + } else { + "off" + } + .to_string(), + ), + ..UserSettings::default() + }; + // The cloud product session is a separate gateway surface. Tail-patch + // provider profiles are controlled by the explicit process environment. + settings.asr_mode = Some("apple_only".to_string()); + + let output_root = args + .out + .parent() + .ok_or_else(|| anyhow!("profile report path has no parent"))?; + let quality_dir = output_root.join("quality"); + let quality_audio_dir = quality_dir.join("audio"); + fs::create_dir_all(&quality_audio_dir).context("create private quality report directory")?; + make_private_directory(&quality_dir)?; + make_private_directory(&quality_audio_dir)?; + + let mut rows = Vec::with_capacity(discovery.selected.len() * args.runs); + let mut quality_entries = Vec::with_capacity(discovery.selected.len() * args.runs); + let mut total_audio_seconds_executed = 0.0; + for clip in &discovery.selected { + let reference = clip + .reference + .as_ref() + .expect("selected clips have references"); + let truth = fs::read_to_string(&reference.path).context("read paired reference")?; + let (samples, sample_rate) = codescribe_core::audio::load_audio_file(&clip.path) + .map_err(|_| anyhow!("decode replay audio failed"))?; + if samples.is_empty() || sample_rate == 0 { + bail!("selected replay audio is empty or has zero sample rate"); + } + let quality_audio_rel = publish_quality_audio(&quality_audio_dir, clip)?; + let duration_seconds = samples.len() as f64 / f64::from(sample_rate); + eprintln!( + "corpus replay selected: profile={} recording={} duration_seconds={duration_seconds:.3} runs={}", + args.profile.token(), + opaque_id(&clip.sha256), + args.runs + ); + for run in 1..=args.runs { + let started = Instant::now(); + let replay = replay_overlay_recording( + &clip.path, + Some(args.language.clone()), + &settings, + GatewaySessionAvailability::Unavailable, + args.profile.stop_lane(), + ) + .await; + let wall_seconds = started.elapsed().as_secs_f64(); + total_audio_seconds_executed += duration_seconds; + let execution = ReplayExecutionContext { + clip, + reference, + truth: &truth, + run, + profile: args.profile, + duration_seconds, + sample_rate, + wall_seconds, + audio_rel_path: &quality_audio_rel, + }; + match replay { + Ok(replay) => { + quality_entries.push(success_quality_entry(&execution, &replay)); + rows.push(success_row(&execution, replay)?); + } + Err(error) => { + quality_entries.push(failure_quality_entry(&execution, &format!("{error:#}"))); + rows.push(failure_row(&execution)?); + } + } + eprintln!( + "corpus replay: profile={} recording={} run={}/{} status={}", + args.profile.token(), + opaque_id(&clip.sha256), + run, + args.runs, + rows.last().map_or("missing", |row| row.status.as_str()) + ); + } + } + + let successful = rows.iter().filter(|row| row.status == "ok").count(); + let failed = rows.len() - successful; + let total_tail_patches = rows.iter().map(|row| row.tail_patches).sum(); + let successful_rows = rows + .iter() + .filter(|row| row.status == "ok") + .collect::>(); + let (observed_layered, profile_observation_matches) = layering_observation( + args.profile.layered(), + successful_rows.iter().map(|row| row.layer1_provider_armed), + ); + let mean_wer = mean(successful_rows.iter().map(|row| row.wer)); + let mean_cer = mean(successful_rows.iter().map(|row| row.cer)); + let mean_character_parity = mean(successful_rows.iter().map(|row| row.character_parity)); + let input_hashes_unchanged = rows + .iter() + .all(|row| row.audio_hash_unchanged && row.reference_hash_unchanged); + let quality_html = format!("quality/seal-atlas.{}.html", args.profile.token()); + let qube_html = format!("quality/qube.{}.html", args.profile.token()); + let quality_report = build_quality_report( + args.profile, + &args.language, + args.references, + quality_entries, + ); + let quality_config = QualityReportConfig { + input_dir: args.roots[0].clone(), + output_dir: quality_dir.clone(), + date_filter: None, + limit: 0, + language: Some(args.language.clone()), + skip_cloud: true, + cloud_concurrency: 0, + skip_formatting: true, + debug_mode: true, + copy_audio: false, + metrics_reference: MetricsReference::Corpus, + local_transcription: LocalTranscriptionMode::LocalWhisper, + }; + let atlas = SealAtlasPage { + title: format!("Seal Atlas — corpus {}", args.profile.token()), + lede: format!( + "Corpus profile {}. One take, one PCM clock. Words from SealedSpan.words when a dump is attached — not from the final string.", + args.profile.token() + ), + stats: SealAtlasStats { + sealed_spans: quality_report.entries.len().to_string(), + ..SealAtlasStats::default() + }, + findings: vec![format!( + "Qube scores (footnote) live at {}. Avg WER is not the live engine.", + qube_html + )], + dump_present: false, + }; + atomic_write_private( + &output_root.join(&quality_html), + render_seal_atlas_html(&atlas).as_bytes(), + )?; + atomic_write_private( + &output_root.join(&qube_html), + render_qube_html(&quality_report, &quality_config).as_bytes(), + )?; + + let report = ProfileReport { + schema: REPORT_SCHEMA.to_string(), + engine_contract: ENGINE_CONTRACT_ID.to_string(), + generated_at: Utc::now().to_rfc3339(), + commit: args.commit, + profile: args.profile, + reference_policy: args.references.as_str().to_string(), + corpus: discovery.census, + distinct_recordings: discovery.selected.len(), + requested_runs_per_recording: args.runs, + requested_executions: discovery.selected.len() * args.runs, + successful_executions: successful, + failed_executions: failed, + total_audio_seconds_executed, + total_tail_patches, + requested_layered: args.profile.layered(), + observed_layered, + profile_observation_matches, + mean_wer, + mean_cer, + mean_character_parity, + input_hashes_unchanged, + settings_loaded: false, + dotenv_loaded: false, + keychain_disabled: true, + apple_stt_bridge: fingerprint_file("apple_stt_bridge", &args.apple_bridge)?, + quality_html, + rows, + }; + atomic_write_json(&args.out, &report)?; + Ok(()) +} + +fn layering_observation( + requested_layered: bool, + provider_armed: impl IntoIterator, +) -> (bool, bool) { + let mut successful_executions = 0usize; + let mut observed_layered = false; + let mut every_execution_matches = true; + for armed in provider_armed { + successful_executions += 1; + observed_layered |= armed; + every_execution_matches &= armed == requested_layered; + } + ( + observed_layered, + successful_executions > 0 && every_execution_matches, + ) +} + +fn publish_quality_audio(quality_audio_dir: &Path, clip: &Clip) -> Result { + let extension = clip + .path + .extension() + .and_then(OsStr::to_str) + .unwrap_or("wav") + .to_ascii_lowercase(); + let file_name = format!("{}.{}", opaque_id(&clip.sha256), extension); + let published = quality_audio_dir.join(&file_name); + if fs::symlink_metadata(&published).is_ok() { + let existing = fs::canonicalize(&published) + .with_context(|| format!("resolve quality audio link {}", published.display()))?; + let expected = fs::canonicalize(&clip.path) + .with_context(|| format!("resolve corpus audio {}", clip.path.display()))?; + if existing != expected { + bail!( + "quality audio link collision for {}", + opaque_id(&clip.sha256) + ); + } + } else { + let source_root = clip + .path + .parent() + .ok_or_else(|| anyhow!("quality audio source has no parent"))?; + safe_symlink_or_copy_bounded(&clip.path, source_root, &published, quality_audio_dir) + .with_context(|| format!("publish private quality audio {}", published.display()))?; + } + Ok(format!("audio/{file_name}")) +} + +struct ReplayExecutionContext<'a> { + clip: &'a Clip, + reference: &'a Reference, + truth: &'a str, + run: usize, + profile: ReplayProfile, + duration_seconds: f64, + sample_rate: u32, + wall_seconds: f64, + audio_rel_path: &'a str, +} + +fn success_quality_entry( + execution: &ReplayExecutionContext<'_>, + replay: &codescribe::controller::production_replay::ProductionOverlayReplay, +) -> ReportEntry { + let raw_wer = word_error_rate(execution.truth, &replay.live_text) as f32; + let raw_cer = character_error_rate(execution.truth, &replay.live_text) as f32; + let post_wer = word_error_rate(execution.truth, &replay.delivered_text) as f32; + let post_cer = character_error_rate(execution.truth, &replay.delivered_text) as f32; + let raw_state = if replay.live_text.trim().is_empty() { + ReportTranscriptState::EmptyTranscript + } else { + ReportTranscriptState::TextCommitted + }; + ReportEntry { + id: format!( + "{}-run{}-{}", + opaque_id(&execution.clip.sha256), + execution.run, + execution.profile.token() + ), + audio_path: opaque_id(&execution.clip.sha256), + audio_rel_path: execution.audio_rel_path.to_string(), + reference_path: None, + duration_secs: execution.duration_seconds as f32, + transcripts: ReportTranscripts { + raw: Some(replay.live_text.clone()), + post: Some(replay.delivered_text.clone()), + ai_formatted: None, + cloud: None, + reference: Some(execution.truth.to_string()), + }, + raw_semantics: Some(ReportTranscriptSemantics { + state: raw_state, + reason: None, + }), + metrics: ReportMetrics { + raw_wer: Some(raw_wer), + raw_cer: Some(raw_cer), + post_wer: Some(post_wer), + post_cer: Some(post_cer), + ..ReportMetrics::default() + }, + postprocess_stats: Some(replay.postprocess_stats.clone()), + errors: Vec::new(), + } +} + +fn failure_quality_entry(execution: &ReplayExecutionContext<'_>, error: &str) -> ReportEntry { + ReportEntry { + id: format!( + "{}-run{}-{}", + opaque_id(&execution.clip.sha256), + execution.run, + execution.profile.token() + ), + audio_path: opaque_id(&execution.clip.sha256), + audio_rel_path: execution.audio_rel_path.to_string(), + reference_path: None, + duration_secs: execution.duration_seconds as f32, + transcripts: ReportTranscripts { + reference: Some(execution.truth.to_string()), + ..ReportTranscripts::default() + }, + raw_semantics: None, + metrics: ReportMetrics::default(), + postprocess_stats: None, + errors: vec![error.to_string()], + } +} + +fn build_quality_report( + profile: ReplayProfile, + language: &str, + references: ReferencePolicy, + entries: Vec, +) -> QualityReport { + let raw_wer = entries + .iter() + .filter_map(|entry| entry.metrics.raw_wer) + .collect::>(); + let raw_cer = entries + .iter() + .filter_map(|entry| entry.metrics.raw_cer) + .collect::>(); + let post_wer = entries + .iter() + .filter_map(|entry| entry.metrics.post_wer) + .collect::>(); + let post_cer = entries + .iter() + .filter_map(|entry| entry.metrics.post_cer) + .collect::>(); + let raw_text_committed = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::TextCommitted) + ) + }) + .count(); + let raw_no_speech_detected = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::NoSpeechDetected) + ) + }) + .count(); + let raw_quality_gate_dropped = entries + .iter() + .filter(|entry| { + matches!( + entry + .raw_semantics + .as_ref() + .map(|semantics| semantics.state), + Some(ReportTranscriptState::QualityGateDropped) + ) + }) + .count(); + QualityReport { + generated_at: Utc::now().to_rfc3339(), + environment: ReportEnvironment { + stt_endpoint: None, + stt_api_key_present: false, + llm_formatting_endpoint: None, + llm_formatting_model: None, + llm_formatting_key_present: false, + local_model: None, + whisper_language: Some(language.to_string()), + metrics_reference: references.as_str().to_string(), + local_transcription: format!("production_overlay:{}", profile.token()), + }, + summary: ReportSummary { + total_files: entries.len(), + processed_files: entries + .iter() + .filter(|entry| entry.errors.is_empty()) + .count(), + avg_raw_wer: mean_f32(&raw_wer), + avg_post_wer: mean_f32(&post_wer), + avg_raw_cer: mean_f32(&raw_cer), + avg_post_cer: mean_f32(&post_cer), + raw_no_speech_detected, + raw_quality_gate_dropped, + raw_text_committed, + ..ReportSummary::default() + }, + entries, + } +} + +fn mean_f32(values: &[f32]) -> Option { + (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f32) +} + +fn make_private_directory(path: &Path) -> Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).with_context(|| { + format!( + "set private report directory permissions {}", + path.display() + ) + }) +} + +fn validate_worker_environment(profile: ReplayProfile, apple_bridge: &Path) -> Result<()> { + let expected = [ + ("CODESCRIBE_STT_ENGINE", "apple"), + ( + "CODESCRIBE_LAYERED_TRANSCRIPTION", + if profile.layered() { "phase1" } else { "off" }, + ), + ("STT_TAIL_PROVIDER", profile.tail_provider()), + ( + "CODESCRIBE_SILERO_FUSION", + if profile.fusion() { "on" } else { "off" }, + ), + ( + "CODESCRIBE_SPAN_IDEMPOTENCE", + if profile.idempotence() { "on" } else { "off" }, + ), + ("CODESCRIBE_INLINE_FORMAT", "off"), + ("CODESCRIBE_DISABLE_KEYCHAIN", "1"), + ("CODESCRIBE_APPLE_STT_ALLOW_DOWNLOAD", "0"), + ("CODESCRIBE_BRIDGE_DISCLAIM", "1"), + ]; + for (key, value) in expected { + if std::env::var(key).as_deref() != Ok(value) { + bail!("worker environment pin mismatch for {key}"); + } + } + if std::env::var_os("CODESCRIBE_APPLE_STT_BRIDGE").as_deref() != Some(apple_bridge.as_os_str()) + { + bail!("worker Apple STT bridge pin mismatch"); + } + let data_dir = std::env::var_os("CODESCRIBE_DATA_DIR") + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("worker requires isolated CODESCRIBE_DATA_DIR"))?; + fs::create_dir_all(PathBuf::from(data_dir)).context("create isolated data directory")?; + Ok(()) +} + +fn success_row( + execution: &ReplayExecutionContext<'_>, + replay: codescribe::controller::production_replay::ProductionOverlayReplay, +) -> Result { + let reference_tokens = normalized_words(execution.truth); + let delivered_tokens = normalized_words(&replay.delivered_text); + let head_present = reference_tokens + .iter() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let tail_present = reference_tokens + .iter() + .rev() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let token_ratio = delivered_tokens.len() as f64 / reference_tokens.len().max(1) as f64; + let previews = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::Preview { .. })) + .count(); + let sealed_finals = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::UtteranceFinal { .. })) + .count(); + let tail_patches = replay + .events + .iter() + .filter(|event| { + matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) + }) + .count(); + let audio_hash_unchanged = sha256_file(&execution.clip.path)? == execution.clip.sha256; + let reference_hash_unchanged = + sha256_file(&execution.reference.path)? == execution.reference.sha256; + Ok(ExecutionRow { + opaque_id: opaque_id(&execution.clip.sha256), + run: execution.run, + audio_sha256: execution.clip.sha256.clone(), + reference_sha256: execution.reference.sha256.clone(), + reference_kind: execution.reference.kind, + duration_seconds: execution.duration_seconds, + sample_rate_hz: execution.sample_rate, + status: "ok".to_string(), + error_class: None, + wall_seconds: execution.wall_seconds, + events: replay.events.len(), + previews, + sealed_finals, + final_count: replay.boundary_evidence.final_count, + unique_final_id_count: replay.boundary_evidence.unique_final_id_count, + repeated_final_id_count: replay.boundary_evidence.repeated_final_id_count, + overlapping_final_window_count: replay.boundary_evidence.overlapping_final_window_count, + tail_patches, + layer1_provider_armed: replay.layer1_armed, + live_chars: replay.live_text.chars().count(), + adjudicated_chars: replay.adjudicated_text.chars().count(), + delivered_chars: replay.delivered_text.chars().count(), + reference_tokens: reference_tokens.len(), + delivered_tokens: delivered_tokens.len(), + token_ratio, + head_present, + tail_present, + wer: word_error_rate(execution.truth, &replay.delivered_text), + cer: character_error_rate(execution.truth, &replay.delivered_text), + character_parity: normalized_character_parity(execution.truth, &replay.delivered_text), + teacher_similarity: teacher_similarity(execution.truth, &replay.delivered_text), + final_pass_attempted: replay.final_pass_attempted, + final_pass_skipped: replay.final_pass_skipped, + lexicon_rewrites: replay.postprocess_stats.lexicon_rewrites, + gate_drops: replay.postprocess_stats.gate_drops, + audio_hash_unchanged, + reference_hash_unchanged, + }) +} + +fn failure_row(execution: &ReplayExecutionContext<'_>) -> Result { + Ok(ExecutionRow { + opaque_id: opaque_id(&execution.clip.sha256), + run: execution.run, + audio_sha256: execution.clip.sha256.clone(), + reference_sha256: execution.reference.sha256.clone(), + reference_kind: execution.reference.kind, + duration_seconds: execution.duration_seconds, + sample_rate_hz: execution.sample_rate, + status: "error".to_string(), + error_class: Some("production_replay_failed".to_string()), + wall_seconds: execution.wall_seconds, + events: 0, + previews: 0, + sealed_finals: 0, + final_count: 0, + unique_final_id_count: 0, + repeated_final_id_count: 0, + overlapping_final_window_count: 0, + tail_patches: 0, + layer1_provider_armed: false, + live_chars: 0, + adjudicated_chars: 0, + delivered_chars: 0, + reference_tokens: 0, + delivered_tokens: 0, + token_ratio: 0.0, + head_present: false, + tail_present: false, + wer: 0.0, + cer: 0.0, + character_parity: 0.0, + teacher_similarity: 0.0, + final_pass_attempted: false, + final_pass_skipped: false, + lexicon_rewrites: 0, + gate_drops: 0, + audio_hash_unchanged: sha256_file(&execution.clip.path)? == execution.clip.sha256, + reference_hash_unchanged: sha256_file(&execution.reference.path)? + == execution.reference.sha256, + }) +} + +fn edit_distance(reference: &[T], hypothesis: &[T]) -> usize { + let mut previous = (0..=hypothesis.len()).collect::>(); + let mut current = vec![0; hypothesis.len() + 1]; + for (row, expected) in reference.iter().enumerate() { + current[0] = row + 1; + for (column, actual) in hypothesis.iter().enumerate() { + current[column + 1] = if expected == actual { + previous[column] + } else { + 1 + previous[column] + .min(previous[column + 1]) + .min(current[column]) + }; + } + std::mem::swap(&mut previous, &mut current); + } + previous[hypothesis.len()] +} + +fn normalized_words(text: &str) -> Vec { + text.split(|character: char| !character.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_lowercase) + .collect() +} + +fn normalized_characters(text: &str) -> Vec { + text.chars() + .flat_map(char::to_lowercase) + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn word_error_rate(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_words(reference); + let hypothesis = normalized_words(hypothesis); + edit_distance(&reference, &hypothesis) as f64 / reference.len().max(1) as f64 +} + +fn character_error_rate(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_characters(reference); + let hypothesis = normalized_characters(hypothesis); + edit_distance(&reference, &hypothesis) as f64 / reference.len().max(1) as f64 +} + +fn normalized_character_parity(reference: &str, hypothesis: &str) -> f64 { + let reference = normalized_characters(reference); + let hypothesis = normalized_characters(hypothesis); + let denominator = reference.len().max(hypothesis.len()).max(1); + (1.0 - edit_distance(&reference, &hypothesis) as f64 / denominator as f64).clamp(0.0, 1.0) +} + +fn teacher_similarity(reference: &str, hypothesis: &str) -> f64 { + use codescribe_core::quality::teacher::{AlignOp, align_words, tokenize}; + + let normalize = |text: &str| text.split_whitespace().collect::>().join(" "); + let reference_tokens = tokenize(&normalize(reference)); + let hypothesis_tokens = tokenize(&normalize(hypothesis)); + let equal = align_words(&reference_tokens, &hypothesis_tokens) + .iter() + .filter(|operation| matches!(operation, AlignOp::Equal { .. })) + .count(); + equal as f64 / reference_tokens.len().max(hypothesis_tokens.len()).max(1) as f64 +} + +fn mean(values: impl Iterator) -> Option { + let values = values.collect::>(); + (!values.is_empty()).then(|| values.iter().sum::() / values.len() as f64) +} + +fn opaque_id(sha256: &str) -> String { + format!("audio-{}", &sha256[..sha256.len().min(16)]) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = safe_open(path).with_context(|| format!("open input {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn sha256_plist_semantics(path: &Path) -> Result { + let output = ProcessCommand::new("/usr/bin/plutil") + .args(["-convert", "xml1", "-o", "-", "--"]) + .arg(path) + .output() + .with_context(|| format!("canonicalize preferences plist {}", path.display()))?; + if !output.status.success() { + bail!( + "plutil could not canonicalize preferences plist {} (status={})", + path.display(), + output.status + ); + } + Ok(format!("{:x}", Sha256::digest(&output.stdout))) +} + +fn operator_configuration_fingerprints() -> Result> { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| anyhow!("HOME is unavailable for configuration fingerprinting"))?; + let mut fingerprints = [ + ( + "settings_json", + home.join("Library/Application Support/Codescribe/settings.json"), + ), + ("dotenv", home.join(".codescribe/.env")), + ] + .into_iter() + .map(|(label, path)| fingerprint_file(label, &path)) + .collect::>>()?; + + let preferences = home.join("Library/Preferences/com.vetcoders.codescribe.plist"); + let exists = preferences.is_file(); + fingerprints.push(FileFingerprint { + label: "preferences_plist_semantic".to_string(), + exists, + sha256: exists + .then(|| sha256_plist_semantics(&preferences)) + .transpose()?, + }); + Ok(fingerprints) +} + +fn fingerprint_file(label: &str, path: &Path) -> Result { + let exists = path.is_file(); + Ok(FileFingerprint { + label: label.to_string(), + exists, + sha256: exists.then(|| sha256_file(path)).transpose()?, + }) +} + +fn atomic_write_json(path: &Path, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec_pretty(value)?; + atomic_write(path, &bytes) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; + let file_name = path + .file_name() + .and_then(OsStr::to_str) + .ok_or_else(|| anyhow!("report path has no UTF-8 filename"))?; + let temporary = parent.join(format!(".{file_name}.tmp-{}", std::process::id())); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .with_context(|| format!("create temporary report {}", temporary.display()))?; + io::Write::write_all(&mut file, bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + Ok(()) +} + +fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<()> { + atomic_write(path, bytes)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("set private report permissions {}", path.display())) +} + +fn markdown_sibling(json: &Path) -> PathBuf { + json.with_extension("md") +} + +fn census_markdown(census: &CorpusCensus) -> String { + format!( + "# Codescribe corpus census\n\n- Audio instances: {}\n- Distinct audio: {}\n- Duplicate instances: {}\n- Formats: {}\n- Human-paired distinct: {}\n- Historical-paired distinct: {}\n- Apple-referenced distinct: {}\n- Unpaired distinct: {}\n- Selected distinct: {}\n- Reference policy: `{}`\n- Source paths or filenames emitted: no\n- Transcript bodies emitted: no\n", + census.discovered_audio_instances, + census.distinct_audio, + census.duplicate_instances, + census + .format_instances + .iter() + .map(|(format, count)| format!("{format}={count}")) + .collect::>() + .join(", "), + census.distinct_human_paired, + census.distinct_historical_paired, + census.distinct_apple_referenced, + census.distinct_unpaired, + census.selected_distinct, + census.reference_policy, + ) +} + +fn matrix_markdown(report: &MatrixReport) -> String { + let mut output = String::new(); + writeln!(output, "# Codescribe corpus parity report\n").unwrap(); + writeln!(output, "- Engine contract: `{}`", report.engine_contract).unwrap(); + writeln!(output, "- Commit: `{}`", report.commit).unwrap(); + writeln!( + output, + "- Apple STT bridge SHA-256: `{}`", + report + .apple_stt_bridge + .sha256 + .as_deref() + .unwrap_or("missing") + ) + .unwrap(); + writeln!( + output, + "- Distinct recordings: {}", + report.distinct_recordings + ) + .unwrap(); + writeln!( + output, + "- Executions: {}/{} successful", + report.successful_executions, report.requested_executions + ) + .unwrap(); + writeln!( + output, + "- Operator config hashes unchanged: {}", + report.configuration_files_unchanged + ) + .unwrap(); + writeln!(output, "- Operator settings loaded: no").unwrap(); + writeln!(output, "- Operator dotenv loaded: no").unwrap(); + writeln!( + output, + "- Keychain access disabled: {}", + report.keychain_disabled + ) + .unwrap(); + writeln!( + output, + "- Permission request APIs called: {}", + report.permission_request_apis_called_by_tool + ) + .unwrap(); + writeln!( + output, + "- TCC state fully proven unchanged: {}", + report.permission_state_proven_unchanged + ) + .unwrap(); + writeln!(output, "- Quality gate: `{}`\n", report.quality_gate).unwrap(); + writeln!( + output, + "| Profile | OK | Failed | Observed L1 | Mean WER | Mean CER | Char parity | Qube quality |" + ) + .unwrap(); + writeln!(output, "|---|---:|---:|---|---:|---:|---:|---|").unwrap(); + for status in &report.profile_status { + writeln!( + output, + "| `{}` | {} | {} | {} | {} | {} | {} | {} |", + status.profile.token(), + status.successful_executions, + status.failed_executions, + optional_bool(status.observed_layered), + optional_score(status.mean_wer), + optional_score(status.mean_cer), + optional_score(status.mean_character_parity), + status + .quality_html + .as_deref() + .map_or_else(|| "n/a".to_string(), |path| format!("[open]({path})")), + ) + .unwrap(); + } + output.push_str( + "\n## Coverage boundary\n\nThis is production PCM-session replay through stop adjudication and lexicon delivery. It does not prove CoreAudio microphone capture, BlackHole loopback, hotkey modes, target-app paste, inline LLM formatting, or TCC continuity. Those surfaces remain explicit in `report.json`.\n", + ); + output +} + +fn optional_bool(value: Option) -> &'static str { + match value { + Some(true) => "yes", + Some(false) => "no", + None => "n/a", + } +} + +fn optional_score(value: Option) -> String { + value.map_or_else(|| "n/a".to_string(), |score| format!("{score:.4}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_tokens_round_trip_without_hidden_defaults() { + for profile in [ + ReplayProfile::AppleLayer0, + ReplayProfile::AppleLayer1Inprocess, + ReplayProfile::AppleLayer1Sidecar, + ReplayProfile::AppleLayer1Remote, + ReplayProfile::AppleLayer1FusionUtterance, + ReplayProfile::AppleLayer1FusionLeftPad, + ReplayProfile::AppleLayer1FusionStablePrompt, + ReplayProfile::AppleLayer1FusionIdempotent, + ReplayProfile::AppleLayer1LocalFinalPass, + ] { + assert_eq!(profile.token().parse::(), Ok(profile)); + } + } + + #[test] + fn layering_observation_reads_provider_arming_not_tail_patch_count() { + assert_eq!(layering_observation(true, [true, true]), (true, true)); + assert_eq!(layering_observation(false, [false, false]), (false, true)); + assert_eq!(layering_observation(true, [true, false]), (true, false)); + assert_eq!(layering_observation(false, [false, true]), (true, false)); + assert_eq!(layering_observation(true, []), (false, false)); + assert_eq!(layering_observation(false, []), (false, false)); + } + + #[test] + fn privacy_contract_is_fail_closed() { + let contract = PrivacyContract::default(); + assert!(!contract.source_paths_emitted); + assert!(!contract.source_filenames_emitted); + assert!(!contract.transcript_bodies_emitted); + assert!(contract.opaque_ids_are_hash_prefixes); + } + + #[test] + fn corpus_schema_carries_the_engine_contract() { + assert_eq!(REPORT_SCHEMA, CORPUS_REPORT_SCHEMA); + assert_eq!(REPORT_SCHEMA, "codescribe-corpus-parity/v3"); + assert_eq!(ENGINE_CONTRACT_ID, "the-engine/v1"); + } + + #[test] + fn edit_metrics_distinguish_loss_from_exact_text() { + assert_eq!(word_error_rate("alpha beta", "alpha beta"), 0.0); + assert!(word_error_rate("alpha beta", "alpha") > 0.0); + assert_eq!(normalized_character_parity("Alpha beta", "alpha beta"), 1.0); + } + + #[test] + fn coverage_never_claims_file_replay_is_live_capture() { + let coverage = CoverageContract::default(); + assert_eq!(coverage.production_pcm_session_replay, "covered"); + assert_eq!( + coverage.coreaudio_microphone_capture, + "not_covered_by_file_replay" + ); + assert_eq!(coverage.tcc_permissions, "not_mutated_or_fully_verified"); + } + + #[test] + fn isolated_data_dir_is_not_removed_with_profile_overrides() { + assert!(!CONTROLLED_ENV.contains(&"CODESCRIBE_DATA_DIR")); + assert!(!CONTROLLED_ENV.contains(&"CODESCRIBE_DISABLE_KEYCHAIN")); + } + + #[test] + fn plist_fingerprint_ignores_storage_encoding() { + let temp = tempfile::tempdir().unwrap(); + let xml_path = temp.path().join("preferences.xml.plist"); + let binary_path = temp.path().join("preferences.binary.plist"); + fs::write( + &xml_path, + br#" + + + + agentEnabled + + launchCount + 7 + + +"#, + ) + .unwrap(); + fs::copy(&xml_path, &binary_path).unwrap(); + let conversion = ProcessCommand::new("/usr/bin/plutil") + .args(["-convert", "binary1", "--"]) + .arg(&binary_path) + .status() + .unwrap(); + assert!(conversion.success()); + assert_ne!( + sha256_file(&xml_path).unwrap(), + sha256_file(&binary_path).unwrap() + ); + assert_eq!( + sha256_plist_semantics(&xml_path).unwrap(), + sha256_plist_semantics(&binary_path).unwrap() + ); + } + + #[test] + fn production_quality_report_uses_qube_keyboard_surface() { + let entries = vec![ReportEntry { + id: "audio-deadbeef-run1-apple-layer0".to_string(), + audio_path: "audio-deadbeef".to_string(), + audio_rel_path: "audio/audio-deadbeef.wav".to_string(), + reference_path: None, + duration_secs: 1.0, + transcripts: ReportTranscripts { + raw: Some("surowy tekst".to_string()), + post: Some("dostarczony tekst".to_string()), + reference: Some("tekst człowieka".to_string()), + ..ReportTranscripts::default() + }, + raw_semantics: Some(ReportTranscriptSemantics { + state: ReportTranscriptState::TextCommitted, + reason: None, + }), + metrics: ReportMetrics { + raw_wer: Some(0.5), + post_wer: Some(0.25), + ..ReportMetrics::default() + }, + postprocess_stats: None, + errors: Vec::new(), + }]; + let report = build_quality_report( + ReplayProfile::AppleLayer0, + "pl", + ReferencePolicy::Human, + entries, + ); + let config = QualityReportConfig { + input_dir: PathBuf::from("."), + output_dir: PathBuf::from("."), + date_filter: None, + limit: 0, + language: Some("pl".to_string()), + skip_cloud: true, + cloud_concurrency: 0, + skip_formatting: true, + debug_mode: true, + copy_audio: false, + metrics_reference: MetricsReference::Corpus, + local_transcription: LocalTranscriptionMode::LocalWhisper, + }; + let html = render_qube_html(&report, &config); + assert!(html.contains("Ctrl+Cmd+Space")); + assert!(html.contains("event.code === 'ArrowLeft'")); + assert!(html.contains("surowy tekst")); + assert!(html.contains("dostarczony tekst")); + assert!(html.contains("tekst człowieka")); + } +} diff --git a/bin/codescribe.rs b/bin/codescribe.rs index f7431c13..00256cf1 100644 --- a/bin/codescribe.rs +++ b/bin/codescribe.rs @@ -19,6 +19,10 @@ //! - `--raw` = the Ctrl-hold contract: literal words, no Light+. //! - `-f/--format` = the AI-formatted lane (same `ai_formatting` call and //! lane config the GUI uses; requires a configured key). +//! - `transcribe live` = follow the app-owned clean transcript bus and flush +//! newly created utterance drafts to stdout one line at a time. Revisions and +//! the final product seal remain explicit bus events. It never opens a +//! second microphone or reconstructs text from UI previews. //! //! Provenance goes to stderr, GUI-truth style, so stdout stays pipeable. //! The old `daemon` mode is gone on purpose: the SwiftUI app owns runtime. @@ -38,12 +42,12 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Transcribe an audio file (wav/mp3/m4a) through the product pipeline + /// Transcribe a file or follow the app-owned live transcript bus Transcribe { - /// Path to the audio file - file: std::path::PathBuf, - /// Language code (e.g. pl, en). Default: auto-detect - #[arg(short, long)] + /// Path to the audio file (omit when using `transcribe live`) + file: Option, + /// File language; live accepts it for compatibility but app settings own capture + #[arg(short, long, global = true)] language: Option, /// Live-canvas view: flush each decoded segment as it lands #[arg(long)] @@ -54,9 +58,17 @@ enum Command { /// AI formatting via the configured formatting lane (same as the GUI) #[arg(short, long)] format: bool, + #[command(subcommand)] + mode: Option, }, } +#[derive(Subcommand)] +enum TranscribeMode { + /// Follow the app's transcript draft/seal bus; Ctrl-C closes the reader + Live, +} + fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { @@ -66,8 +78,120 @@ fn main() -> anyhow::Result<()> { stream, raw, format, - } => transcribe(&file, language.as_deref(), stream, raw, format), + mode, + } => match mode { + Some(TranscribeMode::Live) => { + anyhow::ensure!( + file.is_none() && !stream && !raw && !format, + "`transcribe live` does not accept a file, --stream, --raw, or --format" + ); + transcribe_live(language) + } + None => { + let file = file.ok_or_else(|| { + anyhow::anyhow!("missing (or use `codescribe transcribe live`)") + })?; + transcribe(&file, language.as_deref(), stream, raw, format) + } + }, + } +} + +fn transcribe_live(language: Option) -> anyhow::Result<()> { + use codescribe::presentation::transcript_bus::{CleanTranscriptEvent, transcript_bus_path}; + use std::io::{Read, Seek, SeekFrom, Write as _}; + + let path = transcript_bus_path(); + let mut offset = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + let mut pending = Vec::::new(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + runtime.block_on(async move { + eprintln!("codescribe live: app transcript bus -> live draft stdout"); + eprintln!("bus={} start=end stop=Ctrl-C", path.display()); + eprintln!( + "language_hint={} owner=Codescribe.app", + language.as_deref().unwrap_or("auto") + ); + + loop { + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal?; + eprintln!("codescribe live: stopped"); + return Ok(()); + } + () = tokio::time::sleep(std::time::Duration::from_millis(100)) => {} + } + + let mut file = match std::fs::File::open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; + let file_len = file.metadata()?.len(); + if file_len < offset { + offset = 0; + pending.clear(); + } + file.seek(SeekFrom::Start(offset))?; + let mut chunk = Vec::new(); + file.read_to_end(&mut chunk)?; + offset = offset.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + pending.extend_from_slice(&chunk); + + while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') { + let line: Vec = pending.drain(..=newline).collect(); + let line = &line[..line.len().saturating_sub(1)]; + if line.is_empty() { + continue; + } + let event: CleanTranscriptEvent = match serde_json::from_slice(line) { + Ok(event) => event, + Err(error) => { + eprintln!("codescribe live: invalid transcript event: {error}"); + continue; + } + }; + if let Some(text) = live_event_text(&event.status, &event.text) { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + writeln!(out, "{text}")?; + out.flush()?; + } else if event.status == "utterance_revised" { + eprintln!( + "codescribe live: revision available session={} utterance={}", + event.session_id, + event + .utterance_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "unknown".to_string()) + ); + } else if event.status == "transcript_sealed" { + eprintln!( + "codescribe live: transcript sealed session={} chars={}", + event.session_id, + event.text.chars().count() + ); + } + } + } + }) +} + +/// Plain stdout is intentionally append-only and therefore shows each new draft +/// slot once. Revisions and the final seal remain machine-readable in the +/// canonical NDJSON bus and are announced on stderr without transcript content. +fn live_event_text<'a>(status: &str, text: &'a str) -> Option<&'a str> { + if status != "utterance_draft" { + return None; } + let text = text.trim(); + (!text.is_empty()).then_some(text) } fn transcribe( @@ -159,3 +283,34 @@ fn transcribe( ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn live_command_is_a_subcommand_not_a_file_named_live() { + let cli = Cli::try_parse_from(["codescribe", "transcribe", "live", "--language", "pl"]) + .expect("live command should parse"); + let Command::Transcribe { + file, + language, + mode, + .. + } = cli.command; + assert!(file.is_none()); + assert_eq!(language.as_deref(), Some("pl")); + assert!(matches!(mode, Some(TranscribeMode::Live))); + } + + #[test] + fn live_plain_text_emits_only_nonempty_new_drafts() { + assert_eq!( + live_event_text("utterance_draft", " instrukcja "), + Some("instrukcja") + ); + assert_eq!(live_event_text("utterance_draft", " "), None); + assert_eq!(live_event_text("utterance_revised", "poprawka"), None); + assert_eq!(live_event_text("transcript_sealed", "całość"), None); + } +} diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index 85b68ab6..21a5303e 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -31,3 +31,4 @@ cpal = "0.17" [dev-dependencies] async-trait = "0.1" serial_test = "3" +tempfile = "3" diff --git a/bridge/src/agent.rs b/bridge/src/agent.rs index b92ac7a6..2edccfd7 100644 --- a/bridge/src/agent.rs +++ b/bridge/src/agent.rs @@ -749,14 +749,17 @@ fn build_bridge_stream_options(ai_assistive_max_tokens: i32) -> StreamOptions { /// Compose the agent system prompt exactly like the controller path /// (`app/controller/helpers.rs::compose_agent_system_prompt`): the base assistive /// prompt, the WORKSPACE section (6238ca1) that pins project roots and tells the -/// model to resolve names via `list_projects` instead of guessing paths, and the +/// model to resolve names via `list_projects` instead of guessing paths, the /// review-tool + connector doctrine for long-running MCP review calls and -/// GitHub-connector fallback. +/// GitHub-connector fallback, and the measured Responses/streaming API ground +/// truth with the answer-first rule (operator incident 2026-08-14: a spoken +/// engine question got a clarification questionnaire instead of an answer). fn compose_agent_system_prompt() -> String { let base = codescribe_core::config::prompts::get_assistive_prompt(); let workspace = codescribe::agent::tools::workspace::workspace_prompt_section(); let doctrine = codescribe::agent::tools::doctrine::review_doctrine_prompt_section(); - format!("{base}\n\n{workspace}\n\n{doctrine}") + let api_truth = codescribe::agent::tools::api_truth::responses_api_prompt_section(); + format!("{base}\n\n{workspace}\n\n{doctrine}\n\n{api_truth}") } /// Load + validate composer attachments into vision `ImageAttachment`s. diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 7b8e0935..9feef611 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -17,9 +17,10 @@ use std::sync::{Mutex, Once, OnceLock}; use chrono::{DateTime, SecondsFormat, Utc}; use codescribe_core::config::keychain::{KEYCHAIN_ACCOUNTS, delete_key, save_key}; use codescribe_core::config::{ - Config, DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, FormattingPolicy, PromptKind, - PromptSnapshot, PromptWriteReason, UserSettings, prompt_snapshot, prompts, reset_to_defaults, - restore_prompt_to_default, write_prompt, write_prompt_bytes, + AppDataResetGuard, Config, DEFAULT_ASSISTIVE_PROMPT, DEFAULT_FORMATTING_PROMPT, + FormattingPolicy, PromptKind, PromptSnapshot, PromptWriteReason, UserSettings, + begin_app_data_reset, prompt_snapshot, prompts, reset_to_defaults, restore_prompt_to_default, + write_prompt, write_prompt_bytes_during_reset, }; use codescribe_core::llm::account_auth; use codescribe_core::llm::key_liveness::{ @@ -34,6 +35,35 @@ use directories::BaseDirs; use crate::{CsError, CsLanguage}; +/// Stable cross-FFI marker: Swift must relaunch even though reset returned an +/// error, because at least one app-data root has already moved and the Rust +/// process fence is permanently latched. +const RESET_RELAUNCH_REQUIRED_MARKER: &str = "CODESCRIBE_RESET_RELAUNCH_REQUIRED"; +const AGENT_RESET_RELAUNCH_REQUIRED_MARKER: &str = "CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED"; + +/// Preserve the ordinary error contract before the destructive boundary, but +/// mark every post-boundary error so the host cannot leave a half-reset, +/// permanently fenced process running. +fn reset_error(reset: &AppDataResetGuard, message: impl Into) -> CsError { + let message = message.into(); + let msg = if reset.relaunch_required() { + format!("{RESET_RELAUNCH_REQUIRED_MARKER}: {message}") + } else { + message + }; + CsError::Config { msg } +} + +fn agent_reset_error(mutation_started: bool, message: impl Into) -> CsError { + let message = message.into(); + let msg = if mutation_started { + format!("{AGENT_RESET_RELAUNCH_REQUIRED_MARKER}: {message}") + } else { + message + }; + CsError::Config { msg } +} + /// Full settings snapshot pushed to the Swift Settings UI. Combines real /// `Config` struct fields (settings.json / .env / defaults already merged by /// `Config::load()`) with env-only knobs read from persisted settings / .env @@ -51,6 +81,10 @@ pub struct CsSettings { pub hold_start_delay_ms: u64, pub double_tap_interval_ms: u64, pub toggle_silence_sec: f32, + /// Deferred-insert chord (`DeferredInsertShortcut::wire_id()`), sourced + /// from the canonical merged config snapshot. `"disabled"` is the + /// product default when no persisted choice exists. + pub deferred_insert_shortcut: String, // ── Language ── pub whisper_language: CsLanguage, // ── AI / formatting ── @@ -91,8 +125,9 @@ pub struct CsSettings { /// `"whisper"`. `None` means the built-in auto policy. Written back via /// `update_config` with the same key (promoted → settings.json). pub stt_engine: Option, - /// Final-pass routing (`FINAL_PASS_MODE`): `"always"` | `"smart"` | `"off"`. - /// `None` means Smart default. Written back via `update_config`. + /// Legacy stop-file-pass token (`FINAL_PASS_MODE`). Runtime ignores it + /// on stop; Settings no longer exposes Always/Smart/Off. Persist `off` + /// if a value must still be written. pub final_pass_mode: Option, // ── LLM backend (base) ── pub llm_endpoint: Option, @@ -131,6 +166,15 @@ pub struct CsSettings { pub emit_words_max: Option, pub buffered_interim_sec: Option, pub backend_max_upload_mb: Option, + /// Product ASR lane (`CODESCRIBE_ASR_MODE`): `"apple_only"` | + /// `"local_power"` | `"cloud"`. Written back via `update_config`. + pub asr_mode: Option, + /// Audio-egress consent (`CODESCRIBE_CLOUD_CONSENT`): `"granted"` | + /// `"denied"`. Cloud without `granted` resolves to Apple-only. + pub cloud_consent: Option, + /// Libraxis gateway session-mint URL (`CODESCRIBE_ASR_GATEWAY_URL`). + /// Session mint, not the live WSS socket (`STT_ENDPOINT`). + pub asr_gateway_url: Option, } /// Live, non-secret impact summary shown before a full local-data reset. @@ -143,6 +187,17 @@ pub struct CsResetPreview { pub total_bytes: u64, } +/// Non-secret impact summary for the narrowly-scoped Agent reset. Unlike the +/// app-data reset, this never counts or touches recordings, transcripts, +/// prompts, lexicon data, license state, or dictation preferences. +#[derive(uniffi::Record, Clone, Debug, Default, PartialEq, Eq)] +pub struct CsAgentResetPreview { + pub threads: u64, + pub files: u64, + pub total_bytes: u64, + pub secrets_present: bool, +} + /// UI-safe view of one base prompt. Content is included because this surface is /// the prompt editor itself; audit records never include it. #[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)] @@ -479,6 +534,7 @@ impl CodescribeConfig { hold_start_delay_ms: config.hold_start_delay_ms, double_tap_interval_ms: config.double_tap_interval_ms, toggle_silence_sec: config.toggle_silence_sec, + deferred_insert_shortcut: config.deferred_insert_shortcut.wire_id().to_string(), whisper_language: CsLanguage::from(config.whisper_language), ai_formatting_enabled: config.ai_formatting_enabled, transcript_send_mode: config.transcript_send_mode.as_str().to_string(), @@ -599,6 +655,21 @@ impl CodescribeConfig { settings.backend_max_upload_mb, &env_file, ), + asr_mode: effective_settings_string( + "CODESCRIBE_ASR_MODE", + settings.asr_mode.clone(), + &env_file, + ), + cloud_consent: effective_settings_string( + "CODESCRIBE_CLOUD_CONSENT", + settings.cloud_consent.clone(), + &env_file, + ), + asr_gateway_url: effective_settings_string( + "CODESCRIBE_ASR_GATEWAY_URL", + settings.asr_gateway_url.clone(), + &env_file, + ), } } @@ -1202,6 +1273,74 @@ impl CodescribeConfig { reset_preview_for_dirs(&app_data_dirs()) } + /// Preview the Agent-only reset without changing disk or Keychain state. + pub fn reset_agent_preview(&self) -> CsAgentResetPreview { + agent_reset_preview_for_paths(&agent_reset_paths()) + } + + /// Reset only durable Agent state. Conversations and tool/MCP files are + /// moved to Trash; provider credentials are deleted from Keychain and are + /// intentionally not recoverable. This deliberately does not use the + /// full-app reset fence or its broad root move. + pub fn reset_agent_data(&self) -> Result<(), CsError> { + // Resolve connector-owned Keychain accounts before `mcp.json` is moved. + // A malformed config fails closed while no destructive mutation has + // started; otherwise its bearer tokens would become invisible orphans. + let mut secret_accounts: Vec = agent_secret_accounts() + .iter() + .map(|account| (*account).to_string()) + .collect(); + secret_accounts.extend( + agent_connector_secret_accounts() + .map_err(|error| agent_reset_error(false, error.to_string()))?, + ); + let trash = codescribe_trash_dir()?; + let destination = create_agent_reset_destination(&trash)?; + let paths = agent_reset_paths(); + let mut mutation_started = false; + + for source in &paths { + if source.exists() { + let name = source.file_name().ok_or_else(|| { + agent_reset_error(mutation_started, "Agent reset path has no filename") + })?; + let target = unique_destination(&destination, &name.to_string_lossy(), None); + move_path_recoverably(source, &target).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to move Agent data to Trash: {error}"), + ) + })?; + mutation_started = true; + } + } + + clear_agent_settings().map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to clear Agent settings: {error}"), + ) + })?; + mutation_started = true; + Config::remove_env_keys(agent_env_keys()).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to clear Agent .env settings: {error}"), + ) + })?; + + for account in &secret_accounts { + mutation_started = true; + delete_key(account).map_err(|error| { + agent_reset_error( + mutation_started, + format!("failed to remove Agent secret {account}: {error}"), + ) + })?; + } + Ok(()) + } + /// Move only `mcp.json` to Trash. This intentionally does not touch any /// other config, transcripts, threads, logs, preferences, or Keychain keys. pub fn clear_mcp_configuration(&self) -> Result<(), CsError> { @@ -1224,6 +1363,9 @@ impl CodescribeConfig { /// following symlinks. UserDefaults are cleared by the Swift caller before /// relaunch; TCC grants remain untouched. pub fn reset_app_data(&self, include_keys: bool, include_prompts: bool) -> Result<(), CsError> { + let mut reset_guard = begin_app_data_reset().map_err(|error| CsError::Config { + msg: format!("cannot reset app data: {error}"), + })?; let dirs = app_data_dirs(); let preview = reset_preview_for_dirs(&dirs); let preserved_prompts = if include_prompts { @@ -1251,34 +1393,47 @@ impl CodescribeConfig { include_keys, include_prompts, preserved_prompt_files: preserved_prompts.len(), + error: None, + failed_source: None, + failed_destination: None, }) .map_err(|error| CsError::Config { msg: format!("failed to append reset audit log: {error}"), })?; - let moved_paths = - match move_reset_dirs_to_destination(&dirs, &reset_destination, &trash_root) { - Ok(moved_paths) => moved_paths, - Err(error) => { - let _ = append_reset_audit(&ResetAuditEvent { - audit_path: &audit_path, - timestamp: &now, - status: "move_failed", - source_paths: &dirs, - moved_paths: &[], - trash_path: &reset_destination, - preview: &preview, - include_keys, - include_prompts, - preserved_prompt_files: preserved_prompts.len(), - }); - return Err(CsError::Config { - msg: format!("failed to move app data to Trash: {error}"), - }); - } - }; + let moved_paths = match move_reset_dirs_to_destination( + &mut reset_guard, + &dirs, + &reset_destination, + &trash_root, + ) { + Ok(moved_paths) => moved_paths, + Err(failure) => { + let error = failure.to_string(); + let _ = append_reset_audit(&ResetAuditEvent { + audit_path: &audit_path, + timestamp: &now, + status: "move_failed", + source_paths: &dirs, + moved_paths: &failure.moved_paths, + trash_path: &reset_destination, + preview: &preview, + include_keys, + include_prompts, + preserved_prompt_files: preserved_prompts.len(), + error: Some(&error), + failed_source: Some(&failure.failed_source), + failed_destination: Some(&failure.failed_destination), + }); + return Err(reset_error( + &reset_guard, + format!("failed to move app data to Trash: {error}"), + )); + } + }; - if let Err(error) = restore_base_prompts(&preserved_prompts) { + if let Err(error) = restore_base_prompts(&reset_guard, &preserved_prompts) { + let error_message = error.to_string(); let _ = append_reset_audit(&ResetAuditEvent { audit_path: &audit_path, timestamp: &now, @@ -1290,19 +1445,24 @@ impl CodescribeConfig { include_keys, include_prompts, preserved_prompt_files: preserved_prompts.len(), + error: Some(&error_message), + failed_source: None, + failed_destination: None, }); - return Err(CsError::Config { - msg: format!("app data moved to Trash but base prompt restoration failed: {error}"), - }); + return Err(reset_error( + &reset_guard, + format!("app data moved to Trash but base prompt restoration failed: {error}"), + )); } let key_error = if include_keys { let mut failure = None; for account in KEYCHAIN_ACCOUNTS { if let Err(error) = delete_key(account) { - failure = Some(CsError::Config { - msg: format!("failed to remove keychain key {account}: {error}"), - }); + failure = Some(reset_error( + &reset_guard, + format!("failed to remove keychain key {account}: {error}"), + )); break; } } @@ -1310,6 +1470,7 @@ impl CodescribeConfig { } else { None }; + let key_error_message = key_error.as_ref().map(ToString::to_string); append_reset_audit(&ResetAuditEvent { audit_path: &audit_path, @@ -1326,9 +1487,15 @@ impl CodescribeConfig { include_keys, include_prompts, preserved_prompt_files: preserved_prompts.len(), + error: key_error_message.as_deref(), + failed_source: None, + failed_destination: None, }) - .map_err(|error| CsError::Config { - msg: format!("failed to append reset audit log: {error}"), + .map_err(|error| { + reset_error( + &reset_guard, + format!("failed to append reset audit log: {error}"), + ) })?; if let Some(error) = key_error { @@ -1386,9 +1553,13 @@ fn capture_base_prompts() -> std::io::Result> { /// recreating the prompts directory the reset just removed. Tagged with /// `AppResetPreservation` so the prompt audit distinguishes a restore from a /// user edit. -fn restore_base_prompts(prompts: &[PreservedPrompt]) -> std::io::Result<()> { +fn restore_base_prompts( + reset: &AppDataResetGuard, + prompts: &[PreservedPrompt], +) -> std::io::Result<()> { for prompt in prompts { - write_prompt_bytes( + write_prompt_bytes_during_reset( + reset, prompt.kind, &prompt.bytes, PromptWriteReason::AppResetPreservation, @@ -1420,6 +1591,137 @@ fn app_data_dirs() -> Vec { selected.into_iter().map(|(path, _)| path).collect() } +/// The complete durable Agent-owned file surface. Keep this explicit: a reset +/// is allowed to move only these paths, never a parent data root. +fn agent_reset_paths() -> Vec { + let data = UserSettings::settings_dir(); + let config = Config::config_dir(); + vec![ + // `ThreadStore` keeps attachments at `threads/blobs/`; moving the + // parent once avoids a second overlapping move and preserves recovery. + data.join("threads"), + config.join("mcp.json"), + config.join("tool_grants.json"), + ] +} + +fn agent_secret_accounts() -> &'static [&'static str] { + &[ + "LLM_ASSISTIVE_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_XAI_API_KEY", + account_auth::OPENAI_ACCOUNT_TOKENS_ACCOUNT, + account_auth::ANTHROPIC_ACCOUNT_TOKENS_ACCOUNT, + account_auth::XAI_ACCOUNT_TOKENS_ACCOUNT, + ] +} + +/// Connector tokens created by the MCP Settings UI use a private account +/// namespace and are referenced from `mcp.json`. Delete only those app-owned +/// accounts: a hand-written auth_ref may deliberately point at a shared secret +/// such as GITHUB_TOKEN and Reset Agent must not infer ownership of it. +fn agent_connector_secret_accounts() -> anyhow::Result> { + let mut accounts: Vec = + codescribe_core::mcp::list_servers_at(&Config::config_dir().join("mcp.json"))? + .into_iter() + .filter_map(|server| server.auth_ref) + .filter(|account| account.starts_with("MCP_CONNECTOR_") && account.ends_with("_TOKEN")) + .collect(); + accounts.sort(); + accounts.dedup(); + Ok(accounts) +} + +/// Legacy/power-user Agent rows that can otherwise outlive settings.json and +/// become the effective provider again after relaunch. Keep this list narrow: +/// dictation, formatting, audio and hotkey rows are intentionally absent. +fn agent_env_keys() -> &'static [&'static str] { + &[ + "LLM_ASSISTIVE_ENDPOINT", + "LLM_ASSISTIVE_MODEL", + "LLM_ASSISTIVE_PROVIDER", + "LLM_ASSISTIVE_API_KEY", + "LLM_ANTHROPIC_API_KEY", + "LLM_XAI_API_KEY", + "LLM_OPENAI_ACCOUNT_TOKENS", + "LLM_ANTHROPIC_ACCOUNT_TOKENS", + "LLM_XAI_ACCOUNT_TOKENS", + "LLM_OPENAI_OAUTH_CLIENT_ID", + "LLM_ANTHROPIC_OAUTH_CLIENT_ID", + "LLM_XAI_OAUTH_CLIENT_ID", + "AGENT_WORKSPACE_ROOTS", + "AGENT_ENTER_SENDS", + ] +} + +fn agent_reset_preview_for_paths(paths: &[PathBuf]) -> CsAgentResetPreview { + let (connector_accounts, connector_scan_failed) = match agent_connector_secret_accounts() { + Ok(accounts) => (accounts, false), + Err(_) => (Vec::new(), true), + }; + let mut preview = CsAgentResetPreview { + secrets_present: connector_scan_failed + || agent_secret_accounts() + .iter() + .map(|account| (*account).to_string()) + .chain(connector_accounts) + .any(|account| { + codescribe_core::config::keychain::load_key(&account) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + }), + ..CsAgentResetPreview::default() + }; + + for path in paths { + if path.ends_with("threads") { + preview.threads = preview + .threads + .saturating_add(thread_index_count(&path.join("index.json"))); + } + preview.files = preview.files.saturating_add(path_file_count(path)); + preview.total_bytes = preview.total_bytes.saturating_add(path_size(path)); + } + preview +} + +fn path_file_count(path: &Path) -> u64 { + let Ok(metadata) = fs::symlink_metadata(path) else { + return 0; + }; + if metadata.file_type().is_symlink() || metadata.is_file() { + return 1; + } + if !metadata.is_dir() { + return 0; + } + fs::read_dir(path) + .ok() + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| path_file_count(&entry.path())) + .sum() +} + +/// Remove only settings that define an Agent identity, its provider, its +/// workspace and its tool policy. All transcription, audio, hotkey, lexicon, +/// license and other ordinary app settings remain in the same JSON document. +fn clear_agent_settings() -> anyhow::Result<()> { + UserSettings::remove_agent_owned_state() +} + +fn create_agent_reset_destination(trash: &Path) -> anyhow::Result { + fs::create_dir_all(trash)?; + let destination = unique_destination( + trash, + &format!("codescribe-agent-reset-{}", timestamp_slug(&Utc::now())), + None, + ); + fs::create_dir_all(&destination)?; + Ok(destination) +} + /// The user's `~/.Trash`, which is where a reset parks data so it stays /// recoverable. Fails loudly when the home directory cannot be resolved rather /// than inventing a fallback destination. @@ -1646,30 +1948,106 @@ fn create_reset_destination(trash_root: &Path, now: &DateTime) -> std::io:: Ok(reset_destination) } +/// Structured partial-progress failure from a multi-root reset. The external +/// audit must retain every root that already moved plus the exact root whose +/// move failed; reporting `moved_paths=[]` after a partial move is false +/// recovery guidance. +#[derive(Debug)] +struct ResetMoveFailure { + error: std::io::Error, + moved_paths: Vec<(PathBuf, PathBuf)>, + failed_source: PathBuf, + failed_destination: PathBuf, +} + +impl std::fmt::Display for ResetMoveFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "{} -> {} failed after {} completed move(s): {}", + self.failed_source.display(), + self.failed_destination.display(), + self.moved_paths.len(), + self.error + ) + } +} + +/// Stable destination label for one selected data root. +fn reset_root_destination(reset_destination: &Path, index: usize) -> PathBuf { + let label = if index == 0 { + "codescribe-data".to_string() + } else { + format!("application-support-{index}") + }; + reset_destination.join(label) +} + /// Move each existing data root into the prepared Trash destination, validating /// every source first. The first root lands as `codescribe-data` and the rest as /// `application-support-`, so the recovered folder is self-describing. /// Returns the `(source, destination)` pairs for the audit record. fn move_reset_dirs_to_destination( + reset: &mut AppDataResetGuard, dirs: &[PathBuf], reset_destination: &Path, trash_root: &Path, -) -> std::io::Result> { +) -> Result, ResetMoveFailure> { + let existing_sources: Vec<&PathBuf> = dirs.iter().filter(|source| source.exists()).collect(); + // Validate the complete cut before the first irreversible move. Otherwise a + // bad second root could be discovered only after the first was already in + // Trash, turning a preflight error into a partial reset. + for (index, source) in existing_sources.iter().enumerate() { + if let Err(error) = validate_reset_source(source, trash_root) { + return Err(ResetMoveFailure { + error, + moved_paths: Vec::new(), + failed_source: (*source).clone(), + failed_destination: reset_root_destination(reset_destination, index), + }); + } + } + let mut moved_paths = Vec::new(); - for (index, source) in dirs.iter().filter(|source| source.exists()).enumerate() { - validate_reset_source(source, trash_root)?; - let label = if index == 0 { - "codescribe-data".to_string() - } else { - format!("application-support-{index}") - }; - let destination = reset_destination.join(label); - move_path_recoverably(source, &destination)?; + for (index, source) in existing_sources.into_iter().enumerate() { + let destination = reset_root_destination(reset_destination, index); + if let Err(error) = move_reset_path_recoverably(reset, source, &destination) { + return Err(ResetMoveFailure { + error, + moved_paths, + failed_source: source.clone(), + failed_destination: destination, + }); + } moved_paths.push((source.clone(), destination)); } Ok(moved_paths) } +/// Move one reset root while coupling the first irreversible filesystem change +/// to the process-lifetime latch. A failed same-volume rename is still wholly +/// reversible, so the gate remains in Resetting during the copy fallback and +/// only latches immediately before the copied source is removed. +fn move_reset_path_recoverably( + reset: &mut AppDataResetGuard, + source: &Path, + destination: &Path, +) -> std::io::Result<()> { + match reset.rename_destructively(source, destination) { + Ok(()) => Ok(()), + Err(rename_error) => { + if let Err(copy_error) = copy_path_without_following_symlinks(source, destination) { + return Err(std::io::Error::new( + copy_error.kind(), + format!("rename failed ({rename_error}); fallback copy failed ({copy_error})"), + )); + } + reset.mark_destructive_started(); + remove_path_without_following_symlinks(source) + } + } +} + /// Prefer same-volume rename. If the source is on another volume, create and /// sync the complete destination before removing the source tree. fn move_path_recoverably(source: &Path, destination: &Path) -> std::io::Result<()> { @@ -1791,6 +2169,9 @@ struct ResetAuditEvent<'a> { include_keys: bool, include_prompts: bool, preserved_prompt_files: usize, + error: Option<&'a str>, + failed_source: Option<&'a Path>, + failed_destination: Option<&'a Path>, } /// Append one JSON line to the external reset audit log and fsync it. Strictly @@ -1818,6 +2199,9 @@ fn append_reset_audit(event: &ResetAuditEvent<'_>) -> std::io::Result<()> { "include_keys": event.include_keys, "include_prompts": event.include_prompts, "preserved_prompt_files": event.preserved_prompt_files, + "error": event.error, + "failed_source": event.failed_source.map(|path| path.to_string_lossy()), + "failed_destination": event.failed_destination.map(|path| path.to_string_lossy()), }); let mut file = OpenOptions::new() .create(true) @@ -2070,12 +2454,15 @@ fn ensure_known_account(account: &str) -> Result<(), CsError> { #[cfg(test)] mod reset_tests { use super::{ - CsResetPreview, ResetAuditEvent, app_data_dirs, append_reset_audit, capture_base_prompts, - clear_mcp_configuration_to, create_reset_destination, move_path_recoverably_with, + CsResetPreview, ResetAuditEvent, agent_connector_secret_accounts, agent_env_keys, + agent_reset_error, agent_reset_paths, agent_reset_preview_for_paths, agent_secret_accounts, + app_data_dirs, append_reset_audit, capture_base_prompts, clear_mcp_configuration_to, + create_reset_destination, move_path_recoverably, move_path_recoverably_with, move_reset_dirs_to_destination, remove_path_without_following_symlinks, reset_preview_for_dirs, restore_base_prompts, }; use chrono::{DateTime, Utc}; + use codescribe_core::config::{Config, begin_app_data_reset}; use serial_test::serial; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; @@ -2138,11 +2525,165 @@ mod reset_tests { .expect("valid fixed reset timestamp") } + #[test] + #[serial] + fn agent_reset_scope_moves_only_agent_files_and_keeps_dictation_roots() { + let sandbox = scratch("agent_scope"); + let root = sandbox.join("data"); + let trash = sandbox.join("trash"); + write( + &root.join("threads/index.json"), + br#"{"threads":[{"id":"one"}]}"#, + ); + write(&root.join("threads/one.json"), b"agent conversation"); + write(&root.join("threads/blobs/image.png"), b"agent attachment"); + write(&root.join("mcp.json"), b"{\"mcpServers\":{}}"); + write(&root.join("tool_grants.json"), b"{\"always_allow\":{}}"); + write( + &root.join("transcriptions/2026-08-15/voice.txt"), + b"must survive", + ); + write(&root.join("prompts/assistive.txt"), b"must survive"); + write(&root.join("dictionary/custom.json"), b"must survive"); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + + let paths = agent_reset_paths(); + let preview = agent_reset_preview_for_paths(&paths); + assert_eq!(preview.threads, 1); + assert_eq!(preview.files, 5); + + let destination = trash.join("codescribe-agent-reset-test"); + std::fs::create_dir_all(&destination).expect("create agent Trash destination"); + for source in &paths { + if source.exists() { + let name = source.file_name().expect("Agent fixture path name"); + move_path_recoverably(source, &destination.join(name)) + .expect("move only Agent-owned path"); + } + } + + assert!(destination.join("threads/one.json").is_file()); + assert!(destination.join("mcp.json").is_file()); + assert!(root.join("transcriptions/2026-08-15/voice.txt").is_file()); + assert!(root.join("prompts/assistive.txt").is_file()); + assert!(root.join("dictionary/custom.json").is_file()); + let _ = std::fs::remove_dir_all(&sandbox); + } + + #[test] + fn agent_reset_secret_scope_excludes_stt_and_non_agent_keys() { + let accounts = agent_secret_accounts(); + assert!(accounts.contains(&"LLM_ASSISTIVE_API_KEY")); + assert!(accounts.contains(&"LLM_OPENAI_ACCOUNT_TOKENS")); + assert!(!accounts.contains(&"STT_API_KEY")); + assert!(!accounts.contains(&"LLM_API_KEY")); + assert!(!accounts.contains(&"GITHUB_TOKEN")); + } + + #[test] + #[serial] + fn agent_reset_connector_secret_scope_uses_only_managed_auth_refs() { + let sandbox = scratch("agent_connector_secret_scope"); + write( + &sandbox.join("mcp.json"), + br#"{ + "mcpServers": { + "managed": { + "command": "managed", + "args": [], + "auth_ref": "MCP_CONNECTOR_MANAGED_TOKEN" + }, + "shared": { + "command": "shared", + "args": [], + "auth_ref": "GITHUB_TOKEN" + }, + "handwritten": { + "command": "handwritten", + "args": [], + "auth_ref": "my_custom_secret" + } + } + }"#, + ); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &sandbox); + + assert_eq!( + agent_connector_secret_accounts().expect("read connector accounts"), + vec!["MCP_CONNECTOR_MANAGED_TOKEN"] + ); + let _ = std::fs::remove_dir_all(&sandbox); + } + + #[test] + fn agent_reset_marks_only_post_mutation_failures_for_relaunch() { + assert!( + !format!("{:?}", agent_reset_error(false, "prepare failed")) + .contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") + ); + assert!( + format!("{:?}", agent_reset_error(true, "Keychain failed")) + .contains("CODESCRIBE_AGENT_RESET_RELAUNCH_REQUIRED") + ); + } + + #[test] + #[serial] + fn agent_reset_env_scope_removes_only_agent_rows() { + let sandbox = scratch("agent_env"); + let env_path = sandbox.join(".env"); + write( + &env_path, + b"# preserve this comment\nLLM_ASSISTIVE_MODEL=agent-model\nAGENT_WORKSPACE_ROOTS=~/work\nSTT_ENDPOINT=https://stt.example\nWHISPER_LANGUAGE=pl\nLLM_FORMATTING_MODEL=formatter\n", + ); + let _env_path = EnvGuard::set("CODESCRIBE_ENV_PATH", &env_path); + + Config::remove_env_keys(agent_env_keys()).expect("remove only Agent env rows"); + let rewritten = std::fs::read_to_string(&env_path).expect("read rewritten env"); + assert!(!rewritten.contains("LLM_ASSISTIVE_MODEL=")); + assert!(!rewritten.contains("AGENT_WORKSPACE_ROOTS=")); + assert!(rewritten.contains("# preserve this comment")); + assert!(rewritten.contains("STT_ENDPOINT=https://stt.example")); + assert!(rewritten.contains("WHISPER_LANGUAGE=pl")); + assert!(rewritten.contains("LLM_FORMATTING_MODEL=formatter")); + let _ = std::fs::remove_dir_all(&sandbox); + } + /// The reset scope follows `CODESCRIBE_DATA_DIR`, previews live counts, and /// moves the complete source into a recoverable Trash destination. #[test] #[serial] fn reset_scope_follows_data_dir_and_moves_live_data_to_trash() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_RESET_SCOPE_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_RESET_SCOPE_WITNESS"; + const WITNESS_BYTES: &[u8] = b"reset-scope-pass"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = tempfile::TempDir::new().expect("child witness dir"); + let witness = witness_dir.path().join("passed"); + let status = std::process::Command::new( + std::env::current_exe().expect("current bridge test executable"), + ) + .args([ + "--exact", + "config::reset_tests::reset_scope_follows_data_dir_and_moves_live_data_to_trash", + "--nocapture", + ]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated destructive reset regression"); + assert!( + status.success(), + "isolated destructive reset regression failed" + ); + assert_eq!( + std::fs::read(witness).expect("child completed exact reset-scope test"), + WITNESS_BYTES, + "child command exited successfully without executing the exact regression" + ); + return; + } + let sandbox = scratch("scope"); let root = sandbox.join("source"); let trash = sandbox.join("trash"); @@ -2179,6 +2720,7 @@ mod reset_tests { ); let root_canon = root.canonicalize().expect("canonical reset root"); let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + let mut reset_guard = begin_app_data_reset().expect("own reset fence"); let dirs = app_data_dirs(); assert!(!dirs.is_empty(), "reset must target at least one dir"); @@ -2200,9 +2742,10 @@ mod reset_tests { let destination = create_reset_destination(&trash, ×tamp).expect("create test Trash destination"); let preserved_prompts = capture_base_prompts().expect("capture sacred prompts"); - let moved_paths = move_reset_dirs_to_destination(&dirs, &destination, &trash) - .expect("move reset scope to test Trash"); - restore_base_prompts(&preserved_prompts).expect("restore sacred prompts"); + let moved_paths = + move_reset_dirs_to_destination(&mut reset_guard, &dirs, &destination, &trash) + .expect("move reset scope to test Trash"); + restore_base_prompts(&reset_guard, &preserved_prompts).expect("restore sacred prompts"); assert!(!root.join("settings.json").exists()); assert_eq!( std::fs::read(root.join("prompts/assistive.txt")).expect("read restored assistive"), @@ -2238,6 +2781,9 @@ mod reset_tests { include_keys: false, include_prompts: false, preserved_prompt_files: preserved_prompts.len(), + error: None, + failed_source: None, + failed_destination: None, }) .expect("append reset audit"); let line = std::fs::read_to_string(&audit).expect("read reset audit"); @@ -2270,6 +2816,85 @@ mod reset_tests { ); remove_path_without_following_symlinks(&sandbox).expect("clean reset fixture"); + std::fs::write( + std::env::var_os(CHILD_WITNESS).expect("child witness path"), + WITNESS_BYTES, + ) + .expect("write reset-scope child witness"); + } + + /// If a later root fails after an earlier one moved, recovery evidence must + /// name the completed move and the failed pair, and the FFI error must force + /// Swift to relaunch the now-latched process. + #[test] + #[serial] + fn reset_partial_move_failure_reports_progress_and_requires_relaunch() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_RESET_PARTIAL_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_RESET_PARTIAL_WITNESS"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = tempfile::TempDir::new().expect("partial reset witness dir"); + let witness = witness_dir.path().join("passed"); + let status = std::process::Command::new( + std::env::current_exe().expect("current bridge test executable"), + ) + .args([ + "--exact", + "config::reset_tests::reset_partial_move_failure_reports_progress_and_requires_relaunch", + "--nocapture", + ]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated partial-reset regression"); + assert!(status.success(), "isolated partial-reset regression failed"); + assert_eq!( + std::fs::read(witness).expect("child executed exact partial-reset test"), + b"partial-reset-pass" + ); + return; + } + + let sandbox = scratch("partial_move"); + let first = sandbox.join("first"); + let second = sandbox.join("second"); + let trash = sandbox.join("trash"); + let destination = trash.join("reset"); + write(&first.join("one.txt"), b"one"); + write(&second.join("two.txt"), b"two"); + std::fs::create_dir_all(&destination).expect("create reset destination"); + // Force only the second destination to collide. The first root must + // move successfully before the structured failure is returned. + std::fs::create_dir(destination.join("application-support-1")) + .expect("block second destination"); + write( + &destination.join("application-support-1/blocker"), + b"occupied", + ); + let mut reset = begin_app_data_reset().expect("own partial reset fence"); + let failure = move_reset_dirs_to_destination( + &mut reset, + &[first.clone(), second.clone()], + &destination, + &trash, + ) + .expect_err("second root collision must fail after first move"); + + assert!(reset.relaunch_required()); + assert_eq!(failure.moved_paths.len(), 1); + assert_eq!(failure.moved_paths[0].0, first); + assert_eq!(failure.failed_source, second); + assert!(!first.exists()); + assert!(destination.join("codescribe-data/one.txt").is_file()); + assert!(second.join("two.txt").is_file()); + let ffi_error = super::reset_error(&reset, failure.to_string()).to_string(); + assert!(ffi_error.contains("CODESCRIBE_RESET_RELAUNCH_REQUIRED")); + + remove_path_without_following_symlinks(&sandbox).expect("clean partial-reset fixture"); + std::fs::write( + std::env::var_os(CHILD_WITNESS).expect("partial reset witness path"), + b"partial-reset-pass", + ) + .expect("write partial-reset child witness"); } /// Two entries for one reset must both survive in order. The log is the only @@ -2300,6 +2925,9 @@ mod reset_tests { include_keys: true, include_prompts: false, preserved_prompt_files: 2, + error: None, + failed_source: None, + failed_destination: None, }) .expect("append first audit line"); let moved_paths = vec![(sources[0].clone(), destination.join("codescribe-data"))]; @@ -2314,6 +2942,9 @@ mod reset_tests { include_keys: true, include_prompts: false, preserved_prompt_files: 2, + error: None, + failed_source: None, + failed_destination: None, }) .expect("append second audit line"); @@ -2469,13 +3100,12 @@ mod settings_snapshot_tests { let _ = remove_path_without_following_symlinks(&root); } - /// The tray must report persisted truth, never an optimistic echo. The - /// settings temp path is deliberately blocked to force a write failure; the - /// following read has to show the last value that actually reached disk, not - /// the one the user just asked for. + /// The tray must report persisted truth, never an optimistic echo. A normal + /// two-step write/read proves the returned snapshot is disk-derived; atomic + /// write failure itself is injected at the core rename seam. #[test] #[serial] - fn tray_toggles_roundtrip_auto_paste_and_format_truth_after_write_failure() { + fn tray_toggles_roundtrip_auto_paste_and_format_truth() { let root = scratch("tray_delivery_truth"); std::fs::create_dir_all(&root).expect("create bridge scratch"); let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); @@ -2496,14 +3126,12 @@ mod settings_snapshot_tests { assert!(!after_format.auto_paste_enabled); assert_eq!(after_format.formatting_level, "smart"); - // Block the atomic temp-file write while leaving settings.json readable. - // The write must fail and a fresh prompt-free snapshot must recover the - // last persisted truth rather than an optimistic requested value. - std::fs::create_dir_all(UserSettings::settings_path().with_extension("json.tmp")) - .expect("block atomic settings temp path"); - assert!(config.set_auto_paste_enabled(true).is_err()); + let delivered = config + .set_auto_paste_enabled(true) + .expect("persist auto paste true"); + assert!(delivered.auto_paste_enabled); let reread = config.tray_toggles(); - assert!(!reread.auto_paste_enabled); + assert!(reread.auto_paste_enabled); assert_eq!(reread.formatting_level, "smart"); let env_path = Config::env_path(); @@ -2594,6 +3222,36 @@ mod settings_snapshot_tests { let _ = remove_path_without_following_symlinks(&root); } + /// The picker receives its selected chord through `CsSettings`, not a + /// second Swift-only store. A fresh bridge must therefore reconstruct the + /// promoted settings.json choice and expose its canonical wire id. + #[test] + #[serial] + fn load_settings_exports_persisted_deferred_insert_shortcut() { + let root = scratch("deferred_insert_shortcut"); + std::fs::create_dir_all(&root).expect("create deferred-insert scratch"); + let _data_dir = EnvGuard::set("CODESCRIBE_DATA_DIR", &root); + let _env_path = EnvGuard::remove("CODESCRIBE_ENV_PATH"); + let _process_shortcut = EnvGuard::remove("CODESCRIBE_DEFERRED_INSERT_SHORTCUT"); + + CodescribeConfig::new() + .update_config( + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT".to_string(), + "command_shift_v".to_string(), + ) + .expect("persist deferred-insert chord through the bridge"); + + let fresh = CodescribeConfig::new().load_settings(); + assert_eq!(fresh.deferred_insert_shortcut, "command_shift_v"); + assert_eq!( + UserSettings::load().deferred_insert_shortcut.as_deref(), + Some("command_shift_v"), + "the promoted picker choice belongs in settings.json" + ); + + let _ = remove_path_without_following_symlinks(&root); + } + /// Workspace roots must round-trip through durable settings.json and be /// rebuilt identically by a brand-new bridge handle. The failure this pins: /// a fresh instance falling back to the `["~/.codescribe"]` default and diff --git a/bridge/src/hotkeys.rs b/bridge/src/hotkeys.rs index 36f05b1e..971ecbf1 100644 --- a/bridge/src/hotkeys.rs +++ b/bridge/src/hotkeys.rs @@ -25,7 +25,9 @@ use tokio::sync::broadcast::error::RecvError; use crate::agent_delivery::{ CsAgentDeliveryListener, set_delivery_listener, spawn_delivery_forwarder, }; -use crate::recording::{CsAnnotationKind, CsLayerSummary, CsTranscriptionListener}; +use crate::recording::{ + CsAnnotationKind, CsLayerSummary, CsTranscription, CsTranscriptionListener, +}; use crate::{CsError, CsLanguage}; /// Shared process-wide slot for the lazily-created `RecordingController`. @@ -45,121 +47,47 @@ type SharedAppActionListener = Arc>>> pub trait CsAppActionListener: Send + Sync { /// Bring the Agent surface forward. UI-only — must not touch the mic. fn on_show_agent(&self); - /// Drive the Agent-owned composer microphone. The bridge has already claimed - /// (or verified) capture ownership before this fires. - fn on_agent_capture(&self, command: CsAgentCaptureCommand); -} - -/// UI commands for the Agent-owned composer microphone. Assistive hotkeys are -/// translated here, before the legacy RecordingController can prepare/show its -/// overlay, so there is exactly one Assistive capture owner. -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum CsAgentCaptureCommand { - Start, - Stop, - Toggle, } /// Capture ownership sentinel: no lane currently owns the microphone. const CAPTURE_OWNER_NONE: u8 = 0; -/// Capture ownership: the legacy overlay / `RecordingController` owns the mic. -const CAPTURE_OWNER_OVERLAY: u8 = 1; -/// Capture ownership: the Agent composer microphone owns the mic. -const CAPTURE_OWNER_AGENT: u8 = 2; -/// Process-wide exclusive capture owner (overlay vs Agent). Atomic so hotkey -/// and FFI paths can claim/release without holding a heavier lock. +/// Capture ownership: the one shared `RecordingController` owns the mic. +const CAPTURE_OWNER_CONTROLLER: u8 = 1; +/// Process-wide start gate. Every Dictation/Agent/Assistive gesture enters the +/// same controller, so this protects one capture rather than mediating lanes. static CAPTURE_OWNER: AtomicU8 = AtomicU8::new(CAPTURE_OWNER_NONE); -/// Try to become the single process-wide capture owner for the Agent lane. -/// -/// Returns true when the Agent now owns the mic — including the re-entrant case -/// where it already did (a repeated Start must not be treated as a conflict). -/// Returns false only when the legacy overlay holds ownership. -fn claim_agent_capture() -> bool { - match CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_NONE, - CAPTURE_OWNER_AGENT, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) | Err(CAPTURE_OWNER_AGENT) => true, - Err(_) => false, - } -} - -/// Release Agent capture ownership. Compare-exchange rather than a plain store, -/// so a late Stop can never steal ownership away from the overlay. -fn release_agent_capture() { - let _ = CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_AGENT, - CAPTURE_OWNER_NONE, - Ordering::AcqRel, - Ordering::Acquire, - ); -} - -/// Whether this event would begin a NEW overlay capture session, and therefore +/// Whether this event would begin a NEW controller capture session, and therefore /// has to claim capture ownership first. Deliberately narrow: only the two -/// toggles and a raw hold key-down start a session; every other event either +/// toggles and a hold key-down start a session; every other event either /// continues or ends one that already owns the mic. -fn event_can_start_overlay(event: &HotkeyEvent) -> bool { +fn event_can_start_capture(event: &HotkeyEvent) -> bool { matches!( event, HotkeyEvent::ToggleNormal | HotkeyEvent::ToggleRaw + | HotkeyEvent::ToggleAssistive | HotkeyEvent::Hold { action: HoldAction::Down, - mode: HoldMode::Raw, + mode: HoldMode::Raw | HoldMode::Chat | HoldMode::Selection, } ) } -/// Translate an assistive-lane hotkey into an Agent composer command, or `None` -/// when the event belongs to the recording controller instead. +/// Agent/Assistive recording still fronts the Agent surface, but the UI callback +/// is notification only; audio and transcript events continue to the controller. /// -/// This is the fork that keeps exactly one Assistive capture owner: Chat and -/// Selection hold modes plus the assistive toggle are Agent-owned, everything -/// else falls through to `RecordingController`. -fn agent_capture_command(event: &HotkeyEvent) -> Option { - match event { - HotkeyEvent::ToggleAssistive => Some(CsAgentCaptureCommand::Toggle), - HotkeyEvent::Hold { - action: HoldAction::Down, - mode: HoldMode::Chat | HoldMode::Selection, - } - | HotkeyEvent::HoldUpdate { - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(CsAgentCaptureCommand::Start), - HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(CsAgentCaptureCommand::Stop), - _ => None, - } -} - -/// A Shift upgrade can arrive after raw hold capture already started. Ownership -/// cannot migrate mid-recording: keep the existing overlay session raw and make -/// sure its eventual key-up still reaches the controller that owns the mic. -fn overlay_owned_assistive_hold_fallback(event: &HotkeyEvent) -> Option { - if CAPTURE_OWNER.load(Ordering::Acquire) != CAPTURE_OWNER_OVERLAY { - return None; - } - match event { - HotkeyEvent::HoldUpdate { - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(HotkeyEvent::HoldUpdate { - mode: HoldMode::Raw, - }), - HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Chat | HoldMode::Selection, - } => Some(HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Raw, - }), - _ => None, - } +/// Mid-hold attach (`AttachSelection`) and leftover `HoldUpdate` Chat must not +/// front Agent — they would hide the overlay and look like the take died. +fn event_targets_agent_ui(event: &HotkeyEvent) -> bool { + matches!( + event, + HotkeyEvent::ToggleAssistive + | HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Chat | HoldMode::Selection, + } + ) } /// Process-global slot for the lazily-created `RecordingController`. @@ -198,53 +126,26 @@ fn current_app_action_listener() -> Option> { /// callbacks so the whole contract is unit-testable without a live tap, /// controller or runtime. /// -/// Precedence is deliberate and must not be reordered: -/// 1. overlay-owned assistive fallback — ownership cannot migrate mid-recording; -/// 2. Agent capture commands — arming the trigger context BEFORE the composer -/// mic takes over, per `docs/HOTKEYS_CONTRACT.md`; -/// 3. UI-only commands (`ShowAgent`, `InsertHere`); -/// 4. everything else → the recording controller. -fn route_hotkey_event( +/// Agent/Assistive events notify the Agent window and then continue through the +/// same recording callback as Dictation. Only `ShowAgent` and `InsertHere` are +/// UI-only commands. +fn route_hotkey_event( event: HotkeyEvent, app_action_listener: Option>, dispatch_recording: F, dispatch_deferred_insert: G, - arm_assistive_trigger: H, ) where F: FnOnce(HotkeyEvent), G: FnOnce(), - H: FnOnce(), { - if let Some(fallback) = overlay_owned_assistive_hold_fallback(&event) { - if matches!(event, HotkeyEvent::HoldUpdate { .. }) { - notifications::notify( - "Codescribe", - "Finish Dictation before starting Agent voice input", - ); - } - dispatch_recording(fallback); - return; - } - if let Some(command) = agent_capture_command(&event) { + if event_targets_agent_ui(&event) { tracing::info!( - ?command, - "Assistive command: dispatching Agent-owned capture" + ?event, + "Assistive command: dispatching shared controller capture" ); - // HOTKEYS_CONTRACT: "Selection is captured in the trigger handler, - // never at send time." A capture owner of NONE means this command is - // about to START a new agent capture — arm the trigger context now, - // before the composer mic takes over. Stop/toggle-stop commands find - // the owner already AGENT and must not re-capture at send time. - if CAPTURE_OWNER.load(Ordering::Acquire) == CAPTURE_OWNER_NONE { - arm_assistive_trigger(); + if let Some(listener) = app_action_listener.as_ref() { + listener.on_show_agent(); } - if let Some(listener) = app_action_listener { - listener.on_agent_capture(command); - } else { - tracing::warn!("Assistive command rejected: Agent action listener unavailable"); - notifications::notify("Codescribe", "Agent microphone is unavailable"); - } - return; } match event { HotkeyEvent::ShowAgent => { @@ -339,7 +240,7 @@ pub(crate) fn refresh_live_controller_config() { } /// Pump the controller's broadcast stream into the registered Swift listener for -/// the controller's lifetime, and release overlay capture ownership on every +/// the controller's lifetime, and release controller capture ownership on every /// return to `idle`. /// /// The listener is resolved per event rather than captured, so a listener that @@ -372,7 +273,7 @@ fn spawn_event_forwarder(controller: Arc, handle: Handle) { IpcEventPayload::StateChange { to, .. } if to == "idle" ) { let _ = CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_OVERLAY, + CAPTURE_OWNER_CONTROLLER, CAPTURE_OWNER_NONE, Ordering::AcqRel, Ordering::Acquire, @@ -480,9 +381,15 @@ fn forward_event_to_listener(payload: IpcEventPayload, listener: Arc listener.on_session_finalised(session_id, CsLayerSummary::from(&layer_summary)), + // Same class split as the matching arm in recording.rs: failures + // reach `on_error`, quality receipts are log-only. The tray stays + // as it is: a degraded-quality warning is not a dead backend. EngineEventWire::Warning { code, message } => { - tray_status::update_tray_status(TrayStatus::Error); - listener.on_error(format!("{code}: {message}")); + if codescribe_core::pipeline::contracts::warning_is_user_terminal(&code) { + listener.on_error(format!("{code}: {message}")); + } else { + tracing::info!(code, message, "engine warning (receipt, not forwarded)"); + } } EngineEventWire::Drop { .. } | EngineEventWire::Stats { .. } => {} }, @@ -641,11 +548,8 @@ impl CodescribeHotkeys { std::thread::spawn(move || { for event in rx { let spawn_handle = handle.clone(); - let arm_handle = handle.clone(); let controller_handle = handle.clone(); - let arm_controller_handle = handle.clone(); let controller_store = Arc::clone(&controller_store); - let arm_controller_store = Arc::clone(&controller_store); route_hotkey_event( event, current_app_action_listener(), @@ -659,22 +563,13 @@ impl CodescribeHotkeys { ) .await; if let Err(error) = dispatch { - if CAPTURE_OWNER.load(Ordering::Acquire) != CAPTURE_OWNER_AGENT { - tray_status::update_tray_status(TrayStatus::Error); - } + tray_status::update_tray_status(TrayStatus::Error); notifications::notify("Codescribe", &error.to_string()); eprintln!("Hotkey event error: {error}"); } }); }, deliver_deferred_insert_and_notify, - move || { - arm_handle.spawn(async move { - let controller = - ensure_controller(&arm_controller_store, arm_controller_handle); - controller.arm_assistive_trigger_context().await; - }); - }, ); } }); @@ -718,64 +613,47 @@ impl CodescribeHotkeys { /// dictation does not sit in the overlay's `starting` state for seconds. pub async fn prewarm_recording(&self) -> Result<(), CsError> { let _ = ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); - // Warm the ACTIVE engine the router will actually use (Apple SpeechAnalyzer - // on macOS 26+, Candle on fallback/older macOS) — not a hardcoded Candle - // singleton. `prewarm_active_engine` also runs a synthetic warmup inference, - // so the first user dictation pays neither model-load nor Metal - // kernel-compilation latency. Idempotent; safe to race the controller's own - // background prewarm. - tokio::task::spawn_blocking(codescribe::stt::prewarm_active_engine) - .await - .map_err(|error| CsError::Recording { - msg: format!("STT prewarm task failed: {error}"), - })? - .map_err(|error| CsError::Recording { - msg: format!("STT prewarm failed: {error}"), - })?; + // Warm the engine authorized for normal recording. Cloud/Apple-only + // always warm Apple; only Local power may initialize Candle/ONNX. + // The policy-aware prewarm runs a synthetic inference and is idempotent, + // so it is safe to race the controller's own background prewarm. + let local_whisper_allowed = + codescribe_core::audio::streaming_recorder::production_local_whisper_allowed( + &UserSettings::load(), + ); + tokio::task::spawn_blocking(move || { + codescribe::stt::prewarm_recording_engine(local_whisper_allowed) + }) + .await + .map_err(|error| CsError::Recording { + msg: format!("STT prewarm task failed: {error}"), + })? + .map_err(|error| CsError::Recording { + msg: format!("STT prewarm failed: {error}"), + })?; Ok(()) } /// Start the same toggle recording flow used by the default hotkey. pub async fn start_recording(&self) -> Result<(), CsError> { - if CAPTURE_OWNER.load(Ordering::Acquire) == CAPTURE_OWNER_AGENT { - return Err(CsError::Recording { - msg: "Agent voice input already owns the microphone".to_string(), - }); - } start_recording_with_event(HotkeyEvent::ToggleNormal).await } - /// Start the same toggle flow in the assistive lane for UI-initiated recording. + /// Start the same toggle flow in the assistive lane. Overlay owns this + /// route — the Agent composer mic is a separate, UI-initiated capture. pub async fn start_assistive_recording(&self) -> Result<(), CsError> { - let Some(listener) = current_app_action_listener() else { - return Err(CsError::Recording { - msg: "Agent action listener unavailable".to_string(), - }); - }; - listener.on_agent_capture(CsAgentCaptureCommand::Toggle); - Ok(()) + start_recording_with_event(HotkeyEvent::ToggleAssistive).await } - /// Atomically claim/release the one process-wide capture owner. Returns - /// false when the legacy overlay already owns the microphone. - pub fn set_agent_capture_active(&self, active: bool) -> bool { - let owns_capture = if active { - claim_agent_capture() - } else { - release_agent_capture(); - true - }; - if active && !owns_capture { - tracing::warn!("Agent capture rejected: transcription overlay owns the microphone"); - return false; - } - if active { - tray_status::set_tray_indicator_mode(BadgeMode::Assistive); - tray_status::update_tray_status(TrayStatus::Listening); - } else if tray_status::current_tray_status() == TrayStatus::Listening { - tray_status::update_tray_status(TrayStatus::Idle); - } - true + /// Overlay Retranscribe: `hq:` / `cloud:` prefixes pick the pass. + /// Bare paths are a Full HQ file pass. + pub async fn transcribe_file(&self, path: String) -> Result { + crate::recording::transcribe_session_file(path).await + } + + /// Stable path of the last retained session WAV, if it exists. + pub fn last_session_audio_path(&self) -> Option { + crate::recording::last_session_audio_path() } /// Stop the active legacy-controller recording flow, if one is live. @@ -791,6 +669,18 @@ impl CodescribeHotkeys { }) } + /// Forward a macOS sleep/wake boundary to the active recorder, if any. + /// + /// Querying this surface never constructs the shared controller. The host + /// notification callback can therefore remain a cheap no-op while idle and + /// cannot surprise-load a model or start a provider. + pub async fn note_sleep_wake(&self) -> bool { + let Some(controller) = current_controller(&shared_controller()) else { + return false; + }; + controller.note_sleep_wake().await + } + /// True while the shared controller is in an active recording/conversation state. pub async fn is_recording(&self) -> bool { let Some(controller) = current_controller(&shared_controller()) else { @@ -939,6 +829,15 @@ impl CodescribeHotkeys { pub fn cancel_voice_turn(&self, thread_id: String) -> bool { codescribe::agent_delivery::cancel_agent_delivery_turn(&thread_id) } + + /// Publish the Agent UI's current thread selection as the voice-assistive + /// routing target (operator contract 2026-08-13: dictation goes to the + /// thread the user is looking at; a new thread only via an explicit + /// "+ New thread"). `None` = the selection is a not-yet-persisted thread, + /// so the next assistive turn mints a fresh one. + pub fn set_assistive_target_thread(&self, backend_id: Option) { + codescribe::controller::set_assistive_target_thread(backend_id); + } } /// Honest outcome of the overlay Insert action, mirrored to Swift so the UI @@ -1009,10 +908,9 @@ async fn start_recording_with_event(event: HotkeyEvent) -> Result<(), CsError> { }) } -/// Wrap a recording dispatch in the full capture-ownership lifecycle: claim on a -/// session-starting event, refuse while the Agent owns the mic, show the -/// optimistic overlay, dispatch, compensate an orphaned "preparing", and release -/// ownership once the controller is back at `Idle`. +/// Wrap a recording dispatch in the one-controller capture lifecycle: claim on +/// a session-starting event, dispatch, compensate an orphaned "preparing", and +/// release ownership once the controller is back at `Idle`. /// /// The claim happens BEFORE any controller work so two racing gestures cannot /// both believe they started a session. @@ -1021,23 +919,16 @@ async fn dispatch_recording_with_capture_gate( controller: Arc, ) -> anyhow::Result<()> { let state_before = controller.current_state().await; - let starts_overlay = state_before == State::Idle && event_can_start_overlay(&event); - if starts_overlay { + let starts_capture = state_before == State::Idle && event_can_start_capture(&event); + if starts_capture { CAPTURE_OWNER .compare_exchange( CAPTURE_OWNER_NONE, - CAPTURE_OWNER_OVERLAY, + CAPTURE_OWNER_CONTROLLER, Ordering::AcqRel, Ordering::Acquire, ) - .map_err(|owner| { - anyhow::anyhow!(match owner { - CAPTURE_OWNER_AGENT => "Agent voice input already owns the microphone", - _ => "Another transcription capture is already starting", - }) - })?; - } else if CAPTURE_OWNER.load(Ordering::Acquire) == CAPTURE_OWNER_AGENT { - anyhow::bail!("Agent voice input already owns the microphone"); + .map_err(|_| anyhow::anyhow!("Another transcription capture is already starting"))?; } optimistically_show_overlay(&event).await; @@ -1045,7 +936,7 @@ async fn dispatch_recording_with_capture_gate( compensate_orphaned_preparing(&controller).await; if controller.current_state().await == State::Idle { let _ = CAPTURE_OWNER.compare_exchange( - CAPTURE_OWNER_OVERLAY, + CAPTURE_OWNER_CONTROLLER, CAPTURE_OWNER_NONE, Ordering::AcqRel, Ordering::Acquire, @@ -1094,6 +985,9 @@ async fn dispatch_recording_hotkey_event( }; controller.handle_hotkey_event(input).await?; } + HotkeyEvent::AttachSelection => { + controller.attach_hold_selection().await?; + } HotkeyEvent::ToggleNormal => { let input = HotkeyInput { key_type: HotkeyType::Toggle, @@ -1214,19 +1108,16 @@ mod dispatch_tests { } } -/// The routing contract of [`route_hotkey_event`]: capture ownership is atomic -/// and mutually exclusive, assistive holds map to Agent start/stop, UI-only -/// commands never reach recording dispatch, and the assistive trigger context is -/// armed on start but never re-armed at send time. +/// The routing contract of [`route_hotkey_event`]: all capture modes reach one +/// recording callback; Agent UI notification carries no audio/transcript data. #[cfg(test)] mod app_action_tests { use super::*; use std::sync::atomic::AtomicUsize; - /// Test double that counts `on_show_agent` / `on_agent_capture` invocations. + /// Test double that counts UI-only Agent summons. struct CountingAppActionListener { show_agent_calls: AtomicUsize, - capture_calls: AtomicUsize, } impl CsAppActionListener for CountingAppActionListener { @@ -1234,85 +1125,38 @@ mod app_action_tests { fn on_show_agent(&self) { self.show_agent_calls.fetch_add(1, Ordering::SeqCst); } - - /// Count Agent capture commands (start/stop/toggle) without acting. - fn on_agent_capture(&self, _command: CsAgentCaptureCommand) { - self.capture_calls.fetch_add(1, Ordering::SeqCst); - } } - /// Agent claim blocks overlay claim; release restores NONE for the next owner. + /// Every session-starting gesture is recognized by the same capture gate. #[test] - #[serial_test::serial] - fn capture_owner_is_atomic_and_mutually_exclusive() { - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); - assert!(claim_agent_capture()); - assert_eq!(CAPTURE_OWNER.load(Ordering::SeqCst), CAPTURE_OWNER_AGENT); - assert!( - CAPTURE_OWNER - .compare_exchange( - CAPTURE_OWNER_NONE, - CAPTURE_OWNER_OVERLAY, - Ordering::AcqRel, - Ordering::Acquire, - ) - .is_err() - ); - release_agent_capture(); - assert_eq!(CAPTURE_OWNER.load(Ordering::SeqCst), CAPTURE_OWNER_NONE); - } - - /// Chat/Selection hold Down→Start and Up→Stop; pure mapping, no ownership. - #[test] - fn assistive_hold_maps_to_agent_start_and_stop() { - assert_eq!( - agent_capture_command(&HotkeyEvent::Hold { + fn dictation_agent_and_assistive_all_start_shared_capture() { + for event in [ + HotkeyEvent::ToggleNormal, + HotkeyEvent::ToggleAssistive, + HotkeyEvent::Hold { action: HoldAction::Down, - mode: HoldMode::Chat, - }), - Some(CsAgentCaptureCommand::Start) - ); - assert_eq!( - agent_capture_command(&HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Selection, - }), - Some(CsAgentCaptureCommand::Stop) - ); - } - - /// Mid-recording assistive upgrade cannot migrate ownership; release stays raw. - #[test] - #[serial_test::serial] - fn overlay_owned_assistive_release_still_stops_the_overlay_owner() { - CAPTURE_OWNER.store(CAPTURE_OWNER_OVERLAY, Ordering::SeqCst); - assert_eq!( - overlay_owned_assistive_hold_fallback(&HotkeyEvent::Hold { - action: HoldAction::Up, - mode: HoldMode::Chat, - }), - Some(HotkeyEvent::Hold { - action: HoldAction::Up, mode: HoldMode::Raw, - }) - ); - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); + }, + HotkeyEvent::Hold { + action: HoldAction::Down, + mode: HoldMode::Chat, + }, + ] { + assert!( + event_can_start_capture(&event), + "missing shared start for {event:?}" + ); + } } - /// ShowAgent is UI-only; recording/assistive arming/preparing stay untouched. + /// ShowAgent remains UI-only; recording gestures all reach the same callback. #[test] - #[serial_test::serial] - fn show_agent_routes_without_recording_or_preparing_payload() { - PREPARING_PENDING.store(false, Ordering::SeqCst); - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); + fn agent_notification_does_not_own_capture_or_transcript_payload() { let listener = Arc::new(CountingAppActionListener { show_agent_calls: AtomicUsize::new(0), - capture_calls: AtomicUsize::new(0), }); let recording_calls = Arc::new(AtomicUsize::new(0)); - let arm_calls = Arc::new(AtomicUsize::new(0)); let recording_calls_for_route = Arc::clone(&recording_calls); - let arm_calls_for_route = Arc::clone(&arm_calls); route_hotkey_event( HotkeyEvent::ShowAgent, @@ -1321,19 +1165,12 @@ mod app_action_tests { recording_calls_for_route.fetch_add(1, Ordering::SeqCst); }, || panic!("show agent must not dispatch deferred insert"), - move || { - arm_calls_for_route.fetch_add(1, Ordering::SeqCst); - }, ); assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 1); - assert_eq!(listener.capture_calls.load(Ordering::SeqCst), 0); assert_eq!(recording_calls.load(Ordering::SeqCst), 0); - assert_eq!(arm_calls.load(Ordering::SeqCst), 0); - assert!(!PREPARING_PENDING.load(Ordering::SeqCst)); let recording_calls_for_route = Arc::clone(&recording_calls); - let arm_calls_for_route = Arc::clone(&arm_calls); route_hotkey_event( HotkeyEvent::ToggleNormal, Some(listener.clone()), @@ -1341,16 +1178,11 @@ mod app_action_tests { recording_calls_for_route.fetch_add(1, Ordering::SeqCst); }, || panic!("recording command must not dispatch deferred insert"), - move || { - arm_calls_for_route.fetch_add(1, Ordering::SeqCst); - }, ); assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 1); assert_eq!(recording_calls.load(Ordering::SeqCst), 1); - assert_eq!(arm_calls.load(Ordering::SeqCst), 0); let recording_calls_for_route = Arc::clone(&recording_calls); - let arm_calls_for_route = Arc::clone(&arm_calls); route_hotkey_event( HotkeyEvent::ToggleAssistive, Some(listener.clone()), @@ -1358,14 +1190,9 @@ mod app_action_tests { recording_calls_for_route.fetch_add(1, Ordering::SeqCst); }, || panic!("assistive command must not dispatch deferred insert"), - move || { - arm_calls_for_route.fetch_add(1, Ordering::SeqCst); - }, ); - assert_eq!(listener.capture_calls.load(Ordering::SeqCst), 1); - assert_eq!(recording_calls.load(Ordering::SeqCst), 1); - // Capture owner was NONE → a new agent capture starts → trigger armed. - assert_eq!(arm_calls.load(Ordering::SeqCst), 1); + assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 2); + assert_eq!(recording_calls.load(Ordering::SeqCst), 2); let deferred_calls = Arc::new(AtomicUsize::new(0)); let deferred_calls_for_route = Arc::clone(&deferred_calls); @@ -1376,35 +1203,52 @@ mod app_action_tests { move || { deferred_calls_for_route.fetch_add(1, Ordering::SeqCst); }, - || panic!("deferred insert must not arm assistive trigger"), ); assert_eq!(deferred_calls.load(Ordering::SeqCst), 1); - assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 1); + assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 2); } - /// Stop on an AGENT-owned capture must not re-arm trigger selection at send. #[test] - #[serial_test::serial] - fn assistive_stop_does_not_recapture_at_send_time() { - // Owner already AGENT → this command stops an active capture; the - // contract forbids capturing selection at send time. - CAPTURE_OWNER.store(CAPTURE_OWNER_AGENT, Ordering::SeqCst); + fn mid_hold_attach_does_not_target_agent_or_claim_capture() { + assert!(!event_can_start_capture(&HotkeyEvent::AttachSelection)); + assert!(!event_targets_agent_ui(&HotkeyEvent::AttachSelection)); + assert!(!event_targets_agent_ui(&HotkeyEvent::HoldUpdate { + mode: HoldMode::Chat, + })); + assert!(!event_targets_agent_ui(&HotkeyEvent::Hold { + action: HoldAction::Up, + mode: HoldMode::Chat, + })); + let listener = Arc::new(CountingAppActionListener { show_agent_calls: AtomicUsize::new(0), - capture_calls: AtomicUsize::new(0), }); + let recording_calls = Arc::new(AtomicUsize::new(0)); + let recording_calls_for_route = Arc::clone(&recording_calls); route_hotkey_event( - HotkeyEvent::Hold { - action: HoldAction::Up, + HotkeyEvent::AttachSelection, + Some(listener.clone()), + move |_| { + recording_calls_for_route.fetch_add(1, Ordering::SeqCst); + }, + || panic!("attach must not dispatch deferred insert"), + ); + assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 0); + assert_eq!(recording_calls.load(Ordering::SeqCst), 1); + + let recording_calls_for_route = Arc::clone(&recording_calls); + route_hotkey_event( + HotkeyEvent::HoldUpdate { mode: HoldMode::Chat, }, Some(listener.clone()), - |_| panic!("assistive stop must not enter recording dispatch"), - || panic!("assistive stop must not dispatch deferred insert"), - || panic!("assistive stop must not re-arm the trigger context"), + move |_| { + recording_calls_for_route.fetch_add(1, Ordering::SeqCst); + }, + || panic!("hold update must not dispatch deferred insert"), ); - assert_eq!(listener.capture_calls.load(Ordering::SeqCst), 1); - CAPTURE_OWNER.store(CAPTURE_OWNER_NONE, Ordering::SeqCst); + assert_eq!(listener.show_agent_calls.load(Ordering::SeqCst), 0); + assert_eq!(recording_calls.load(Ordering::SeqCst), 2); } } @@ -1733,12 +1577,58 @@ impl CodescribeHotkeys { mod mode_binding_tests { use super::*; use serial_test::serial; + use std::process::Command; use std::sync::Mutex; /// Serializes `CODESCRIBE_DATA_DIR` mutation for the persist/read-back test. // Serializes the CODESCRIBE_DATA_DIR-mutating test below within this module. static ENV_LOCK: Mutex<()> = Mutex::new(()); + /// RED contract for the production-log pollution observed from this test + /// module. A child test process gives `init_logging` a fresh `Once`, a fake + /// HOME, and a distinct test data root; initialization must not create the + /// production-shaped `~/.codescribe/logs/codescribe.log` sink. + #[test] + #[serial] + fn fleet_red_test_logging_isolated() { + const CHILD_ENV: &str = "CODESCRIBE_FLEET_RED_LOG_CHILD"; + if std::env::var_os(CHILD_ENV).is_some() { + let production_path = std::path::PathBuf::from( + std::env::var_os("HOME").expect("isolated child HOME must be set"), + ) + .join(".codescribe") + .join("logs") + .join("codescribe.log"); + + codescribe::logging::init_logging(); + assert!( + !production_path.exists(), + "test logger initialization resolved to production path: {}", + production_path.display() + ); + return; + } + + let fake_home = tempfile::tempdir().expect("create isolated HOME"); + let test_data = tempfile::tempdir().expect("create isolated test data root"); + let status = Command::new(std::env::current_exe().expect("resolve test binary")) + .args([ + "--exact", + "hotkeys::mode_binding_tests::fleet_red_test_logging_isolated", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .env("HOME", fake_home.path()) + .env("CODESCRIBE_DATA_DIR", test_data.path()) + .status() + .expect("launch isolated logging child"); + + assert!( + status.success(), + "isolated logger child must avoid the production log path" + ); + } + /// Every core work mode survives a UniFFI round-trip without loss. #[test] fn work_mode_ffi_round_trips() { @@ -2103,6 +1993,19 @@ mod preparing_compensation_tests { PREPARING_PENDING.store(false, Ordering::SeqCst); } + /// A host lifecycle notification while idle must not construct the shared + /// controller (and therefore cannot prewarm or load an engine). + #[tokio::test] + #[serial] + async fn sleep_wake_without_active_controller_is_a_noop() { + let _guard = TEST_LOCK.lock().await; + teardown(); + + let hotkeys = CodescribeHotkeys::new(); + assert!(!hotkeys.note_sleep_wake().await); + assert!(current_controller(&shared_controller()).is_none()); + } + /// AudioLevel IPC payload forwards the RMS sample to the Swift listener. #[test] fn recording_audio_level_payload_forwards_rms() { diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index 236732ad..489c8141 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -10,7 +10,7 @@ //! - `agent_status` — CodescribeAgentStatus (read-only readiness + MCP status) [W-C1] //! - `mcp_admin` — CodescribeMcpAdmin (add/update/remove/test MCP servers) [W-C4] //! - `config` — CodescribeConfig (settings/prompts/keychain/onboarding) [W3 #1] -//! - `recording` — CodescribeDictation + CsTranscriptionListener (STT) [W3 #3] +//! - `recording` — shared controller listener + audio/model settings [live] //! - `threads` — CodescribeThreads (thread persistence + history) [W3 #5] //! //! Shared cross-slice types (`CsError`, `CsLanguage`) live here so each submodule @@ -49,8 +49,10 @@ pub use hotkeys::CodescribeHotkeys; pub use hotkeys::CsAppActionListener; pub use licensing::{CsLicenseState, CsLicenseStatus}; pub use quality::{ - CsLexiconEntry, CsQualityCommitResult, CsQualityRecord, commit_overlay_quality_record, - lexicon_custom_entries, quality_finalize_correction, quality_recent_records, + CsLexiconEntry, CsOverlayHighlight, CsOverlayHighlightKind, CsQualityCommitResult, + CsQualityRecord, commit_overlay_quality_record, lexicon_custom_entries, + overlay_highlights_enabled, quality_finalize_correction, quality_recent_records, + quality_teach_span, }; pub use tray_status::{ CodescribeTrayStatus, CsTrayStatusKind, CsTrayStatusListener, CsTrayStatusPayload, diff --git a/bridge/src/quality.rs b/bridge/src/quality.rs index 6c26d79e..8ac23889 100644 --- a/bridge/src/quality.rs +++ b/bridge/src/quality.rs @@ -8,10 +8,13 @@ //! //! Privacy: local disk only. +use codescribe_core::pipeline::highlight::{ + OverlayHighlight, OverlayHighlightKind, overlay_highlights_enabled as highlights_lane_enabled, +}; use codescribe_core::quality::overlay_quality::{ CustomLexiconEntry, DictionaryTeachResult, OverlayCorrectionCommit, QualityRecord, VoiceLabSaveOutcome, commit_overlay_correction_with_confidence, custom_lexicon_entries, - finalize_voice_lab_correction, recent_quality_records, teach_dictionary_from_store, + finalize_voice_lab_correction, recent_quality_records, teach_dictionary_from_store, teach_span, }; use crate::CsError; @@ -240,6 +243,77 @@ pub fn quality_teach_dictionary_from_store() -> Result for CsOverlayHighlightKind { + fn from(kind: OverlayHighlightKind) -> Self { + match kind { + OverlayHighlightKind::LexiconCorrected => Self::LexiconCorrected, + OverlayHighlightKind::SpeechGap => Self::SpeechGap, + } + } +} + +/// Span-based canvas highlight. Sample fields are the 3A PCM identity; +/// char offsets are the Swift adapter onto already-committed utterance text. +#[derive(uniffi::Record, Debug, Clone, PartialEq, Eq)] +pub struct CsOverlayHighlight { + pub kind: CsOverlayHighlightKind, + pub utterance_id: u64, + pub char_start: u64, + pub char_end: u64, + pub session: String, + pub capture_epoch: u64, + pub sample_start: u64, + pub sample_end: u64, + pub before: String, + pub after: String, +} + +impl From for CsOverlayHighlight { + fn from(value: OverlayHighlight) -> Self { + Self { + kind: value.kind.into(), + utterance_id: value.utterance_id, + char_start: value.char_start, + char_end: value.char_end, + session: value.range.session, + capture_epoch: value.range.capture_epoch, + sample_start: value.range.sample_start, + sample_end: value.range.sample_end, + before: value.before, + after: value.after, + } + } +} + +/// W13-6B lane flag. Default OFF. Read-only; no permission prompt. +#[uniffi::export] +pub fn overlay_highlights_enabled() -> bool { + highlights_lane_enabled() +} + +/// One-click Teach from a highlighted span. Reuses the existing quality + +/// custom-lexicon writers — no new disk root, no new permission. +#[uniffi::export] +pub fn quality_teach_span( + variant: String, + canonical: String, + kind: String, +) -> Result { + teach_span(&variant, &canonical, &kind) + .map(Into::into) + .map_err(|error| CsError::Quality { + msg: format!("span teach failed: {error:#}"), + }) +} + /// Bridge quality projections and commit gate contracts (level normalize/reject). #[cfg(test)] mod tests { diff --git a/bridge/src/recording.rs b/bridge/src/recording.rs index 67148077..a7c2b705 100644 --- a/bridge/src/recording.rs +++ b/bridge/src/recording.rs @@ -1,28 +1,13 @@ -//! Dictation / STT surface — thin UniFFI wrapper over the live codescribe -//! streaming recorder + Whisper singleton. Translates the engine's semantic -//! `EngineEvent` stream into a small foreign listener contract so the new -//! SwiftUI app can drive real microphone dictation and file transcription. -//! Filled by W3 cut #3 (sibling to `agent.rs`). Uses shared -//! `crate::{CsError, CsLanguage}`. +//! Shared recording bridge types: audio-input settings, Whisper model download, +//! the controller event listener, and microphone permission probes. Live capture +//! itself is owned exclusively by `CodescribeHotkeys`/`RecordingController`. -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex as StdMutex, RwLock}; -use std::time::{Duration, Instant}; +use std::sync::Arc; -use codescribe::os::tray_status::{self, TrayStatus}; -use codescribe_core::audio::load_audio_file; -use codescribe_core::audio::streaming_recorder::StreamingRecorder; -use codescribe_core::config::FinalPassRoutingMode; -use codescribe_core::pipeline::contracts::{ - AnnotationKind, EngineEvent, EventSink, FileTranscriptionOptions, LayerSource, LayerSummary, -}; -use codescribe_core::stt::{TailGapBoundary, resolve_tail_gap_boundary, whisper}; +use codescribe_core::pipeline::contracts::{AnnotationKind, LayerSource, LayerSummary}; use cpal::traits::{DeviceTrait, HostTrait}; -use tokio::sync::Mutex; -use tracing::{info, warn}; -use crate::{CsError, CsLanguage}; +use crate::CsError; /// Result of a one-shot file transcription. #[derive(uniffi::Record)] @@ -292,6 +277,139 @@ impl From<&LayerSummary> for CsLayerSummary { } } +/// Path prefixes pick the Retranscribe pass: +/// - `hq:` or no prefix — Full HQ file pass (`transcribe_file_verdict`) +/// - `cloud:` — Cloud pass (`transcribe_cloud` with Settings STT credentials) +pub(crate) async fn transcribe_session_file(path: String) -> Result { + let (pass, file_path) = split_retranscribe_path(&path); + match pass { + RetranscribePass::Hq => tokio::task::spawn_blocking(move || transcribe_file_hq(file_path)) + .await + .map_err(|e| CsError::Recording { + msg: format!("transcribe_file task join error: {e}"), + })?, + RetranscribePass::Cloud => transcribe_file_cloud(file_path).await, + } +} + +/// `~/.codescribe/last_session.wav` when the last stop retained audio. +pub(crate) fn last_session_audio_path() -> Option { + let dest = codescribe_core::config::Config::config_dir().join("last_session.wav"); + dest.exists().then(|| dest.to_string_lossy().into_owned()) +} + +enum RetranscribePass { + Hq, + Cloud, +} + +fn split_retranscribe_path(path: &str) -> (RetranscribePass, String) { + if let Some(rest) = path.strip_prefix("cloud:") { + (RetranscribePass::Cloud, rest.to_string()) + } else if let Some(rest) = path.strip_prefix("hq:") { + (RetranscribePass::Hq, rest.to_string()) + } else { + (RetranscribePass::Hq, path.to_string()) + } +} + +fn transcribe_file_hq(path: String) -> Result { + let verdict = codescribe_core::stt::transcribe_file_verdict(std::path::Path::new(&path), None) + .map_err(|e| CsError::Recording { msg: e.to_string() })?; + Ok(CsTranscription { + text: verdict.text, + language: "und".to_string(), + }) +} + +async fn transcribe_file_cloud(path: String) -> Result { + let config = codescribe_core::config::Config::load(); + let endpoint = config + .stt_endpoint + .clone() + .filter(|value| !value.trim().is_empty()); + let key = config + .stt_api_key + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_default(); + let Some(endpoint) = endpoint else { + return Err(CsError::Recording { + msg: "Cloud pass needs STT_ENDPOINT".to_string(), + }); + }; + // Same invert as Settings → Test: a stored Voice Lab socket is not a + // multipart URL. Public HTTPS file URLs stay file. + let endpoint = codescribe_core::stt::tail_provider::file_probe_endpoint(&endpoint); + if codescribe_core::stt::tail_provider::stt_auth_mode(&endpoint) + != codescribe_core::stt::tail_provider::SttAuthMode::Unauthenticated + && key.is_empty() + { + return Err(CsError::Recording { + msg: "Cloud pass needs STT_API_KEY for this endpoint".to_string(), + }); + } + let verdict = + codescribe::client::transcribe_cloud(std::path::Path::new(&path), None, &endpoint, &key) + .await + .map_err(|e| CsError::Recording { msg: e.to_string() })?; + Ok(CsTranscription { + text: verdict.text, + language: "und".to_string(), + }) +} + +#[cfg(test)] +mod retranscribe_tests { + use super::*; + + #[test] + fn retranscribe_path_prefixes_select_hq_or_cloud() { + assert!(matches!( + split_retranscribe_path("/tmp/last_session.wav"), + (RetranscribePass::Hq, path) if path == "/tmp/last_session.wav" + )); + assert!(matches!( + split_retranscribe_path("hq:/tmp/last_session.wav"), + (RetranscribePass::Hq, path) if path == "/tmp/last_session.wav" + )); + assert!(matches!( + split_retranscribe_path("cloud:/tmp/last_session.wav"), + (RetranscribePass::Cloud, path) if path == "/tmp/last_session.wav" + )); + } + + #[test] + fn cloud_pass_inverts_voice_lab_socket_to_file() { + assert_eq!( + codescribe_core::stt::tail_provider::file_probe_endpoint( + "ws://127.0.0.1:8446/v1/audio/transcribe" + ), + "http://127.0.0.1:8444/v1/audio/transcriptions" + ); + assert_eq!( + codescribe_core::stt::tail_provider::file_probe_endpoint( + "https://api.libraxis.cloud/v1/audio/transcriptions" + ), + "https://api.libraxis.cloud/v1/audio/transcriptions" + ); + } + + #[test] + fn remapped_loopback_file_url_names_programming_vocabulary() { + let endpoint = codescribe_core::stt::tail_provider::file_probe_endpoint( + "ws://127.0.0.1:8446/v1/audio/transcribe", + ); + assert_eq!(endpoint, "http://127.0.0.1:8444/v1/audio/transcriptions"); + assert_eq!( + codescribe_core::stt::request_vocabulary::codescribe_stt_vocabulary_form_part( + &endpoint + ), + Some(("vocabulary", "programming")) + ); + } +} + /// Foreign callback trait — dictation events forwarded to Swift. /// /// Distilled from the engine's richer `EngineEvent` stream: @@ -380,677 +498,6 @@ pub trait CsTranscriptionListener: Send + Sync { fn on_error(&self, message: String); } -/// Accumulates finalized utterance text for the composer voice-note return, -/// mirroring core's crate-private `SessionTranscriptCollector` discipline -/// (skip empty, single-space join, trimmed). The same `CsEventSink` that -/// forwards engine events to Swift feeds each `UtteranceFinal` here, so -/// `stop_recording` can compose the return AFTER the streaming session's -/// completion signal fires — reusing existing finalization, not a new channel. -#[derive(Default)] -struct ComposerTranscript { - text: StdMutex, - utterances: AtomicU64, - /// End timestamp of the last committed utterance — the audio boundary Smart - /// mode gap-fills from. Mirrors `SessionTelemetrySink` in the controller lane. - committed_through_secs: StdMutex>, -} - -impl ComposerTranscript { - /// Advance the committed audio boundary (monotonic max: an out-of-order - /// final never rewinds it). Called for **every** `UtteranceFinal`, including - /// empty ones — that audio is adjudicated even when it carried no text, so - /// a tail gap-fill must not transcribe it again. - fn note_committed_through(&self, end_ts: f32) { - if !end_ts.is_finite() { - return; - } - let mut guard = self - .committed_through_secs - .lock() - .unwrap_or_else(|e| e.into_inner()); - *guard = Some(match *guard { - Some(current) if current >= end_ts => current, - _ => end_ts, - }); - } - - /// Committed audio boundary, or `None` when no final sealed any audio yet. - fn committed_through_secs(&self) -> Option { - *self - .committed_through_secs - .lock() - .unwrap_or_else(|e| e.into_inner()) - } - - /// Append one finalized utterance (Layer 0 committed text). Empty/whitespace - /// finals are ignored so trailing silence never widens the transcript. - fn append_final(&self, text: &str) { - let trimmed = text.trim(); - if trimmed.is_empty() { - return; - } - let mut buf = self.text.lock().unwrap_or_else(|e| e.into_inner()); - if !buf.is_empty() { - buf.push(' '); - } - buf.push_str(trimmed); - self.utterances.fetch_add(1, Ordering::Relaxed); - } - - /// Current composed transcript and the number of utterances that fed it. - fn snapshot(&self) -> (String, u64) { - let text = self.text.lock().unwrap_or_else(|e| e.into_inner()).clone(); - (text, self.utterances.load(Ordering::Relaxed)) - } -} - -/// Wait budget for `stop_recording` to compose its return: it covers BOTH the -/// streaming drain AND the delivery-grade final pass over the saved WAV. -/// Proportional to recording length (STT work scales with audio) but clamped so -/// the composer UI never hangs indefinitely if the scheduler stalls (e.g. -/// thermal throttling): the floor covers a cold commit + short final pass, the -/// cap bounds the worst case. On exhaustion the streaming splice is returned as -/// a fallback, so overrun degrades quality, never correctness. -fn compose_stop_timeout(elapsed: Duration) -> Duration { - /// Minimum drain budget so a cold commit + short final pass still fits. - const FLOOR: Duration = Duration::from_secs(8); - /// Hard upper bound so a stalled scheduler never hangs the composer forever. - const CAP: Duration = Duration::from_secs(30); - elapsed.mul_f32(0.6).clamp(FLOOR, CAP) -} - -/// Which transcript `stop_recording` returned, for the stop breadcrumb. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ComposerTranscriptSource { - /// Live floor + whole-WAV Whisper gap-fill (doctrine: never full-replace). - /// Also covers whisper-only when the stream produced nothing. - MergedLiveWhisper, - /// Smart tail gap-fill APPENDED to the committed streaming floor. The tail is - /// a bare fragment, never diffed against committed text (append-only doctrine). - TailGapAppend, - /// Spliced streaming `UtteranceFinal` chunks (final pass unavailable/empty). - StreamingFallback, -} - -impl ComposerTranscriptSource { - /// Stable log token for the stop breadcrumb. - fn label(self) -> &'static str { - match self { - Self::MergedLiveWhisper => "merged_live_whisper", - Self::TailGapAppend => "tail_gap_append", - Self::StreamingFallback => "streaming_fallback", - } - } -} - -/// Pick the composer return. -/// -/// Overlay doctrine (AGENTS.md law): the live streaming assembly is the floor -/// of truth — a non-empty whole-WAV final pass never replaces it, it merges as -/// gap-fill via `merge_live_whisper` (substitution disagreements keep live, so -/// a collapsing file-STT final can no longer blank or shrink a real stream, -/// and an inflated stream is never swapped for a shorter Whisper guess). -/// Empty/absent final falls back to the streaming splice. Both inputs trimmed. -fn select_composer_transcript( - final_pass: Option<&str>, - streaming: &str, -) -> (String, ComposerTranscriptSource) { - let streaming = streaming.trim(); - if let Some(text) = final_pass { - let trimmed = text.trim(); - if !trimmed.is_empty() { - let merged = codescribe_core::quality::merge_live_whisper(streaming, trimmed); - return (merged.text, ComposerTranscriptSource::MergedLiveWhisper); - } - } - ( - streaming.to_string(), - ComposerTranscriptSource::StreamingFallback, - ) -} - -/// Compose the composer return from the planned final pass — the plan decides -/// HOW the Whisper text is allowed to meet the live floor. -/// -/// - `TailGap` (Smart): the Whisper text is a **bare tail** of the uncommitted -/// audio, not a transcript of the whole session. It is APPENDED via the shared -/// core primitive (`codescribe_core::stt::append_tail_gap`), which keeps the -/// committed text as an untouched prefix and only dedups repeated preview words -/// from the tail side. Feeding a bare tail to `merge_live_whisper` (as this lane -/// used to) turns the boundary Delete+Insert pair into a Substitute that keeps -/// live and DISCARDS the whisper token — measurable gap-fill word loss. -/// - `FullFile` (Always): a whole-WAV transcript, which is exactly what -/// `merge_live_whisper` is built for — merge as gap-fill over the live floor. -/// - `SkipStreaming` (Off / Smart-without-boundary): no final pass exists; -/// the streaming splice is the answer. -fn compose_composer_transcript( - plan: ComposerFinalPassPlan, - final_pass: Option<&str>, - streaming: &str, -) -> (String, ComposerTranscriptSource) { - match plan { - ComposerFinalPassPlan::TailGap(_) => { - let streaming = streaming.trim(); - let tail = final_pass.map(str::trim).unwrap_or_default(); - if tail.is_empty() { - return ( - streaming.to_string(), - ComposerTranscriptSource::StreamingFallback, - ); - } - ( - codescribe_core::stt::append_tail_gap(streaming, tail), - ComposerTranscriptSource::TailGapAppend, - ) - } - ComposerFinalPassPlan::FullFile | ComposerFinalPassPlan::SkipStreaming => { - select_composer_transcript(final_pass, streaming) - } - } -} - -/// What the composer stop lane is allowed to run over the saved WAV, per -/// `FINAL_PASS_MODE` (operator law 2026-08-05). -#[derive(Debug, Clone, Copy, PartialEq)] -enum ComposerFinalPassPlan { - /// Always only: re-transcribe the whole file. - FullFile, - /// Smart: transcribe the uncommitted tail from this boundary and append it. - TailGap(f32), - /// Off — or Smart without usable commit evidence: no Whisper at all; the - /// streaming splice is the answer. - SkipStreaming, -} - -impl ComposerFinalPassPlan { - /// Stable log token for the chosen plan (the `TailGap` boundary is logged - /// separately, so variants with payloads still map to one flat name). - fn label(self) -> &'static str { - match self { - Self::FullFile => "full_file", - Self::TailGap(_) => "tail_gap", - Self::SkipStreaming => "skip_streaming", - } - } -} - -/// Route the composer stop lane by mode — the same law the controller lane obeys. -/// -/// Always is the ONLY mode permitted a full-file re-pass; Off runs zero Whisper -/// on the stop path; Smart delegates its boundary question to the shared core -/// guard so a missing boundary can never degrade into a whole-file pass landing -/// on committed text. -fn composer_final_pass_plan( - mode: FinalPassRoutingMode, - committed_through_secs: Option, - streaming_is_empty: bool, -) -> ComposerFinalPassPlan { - match mode { - FinalPassRoutingMode::Always => ComposerFinalPassPlan::FullFile, - FinalPassRoutingMode::Off => ComposerFinalPassPlan::SkipStreaming, - FinalPassRoutingMode::Smart => { - match resolve_tail_gap_boundary(committed_through_secs, streaming_is_empty) { - TailGapBoundary::From(secs) => ComposerFinalPassPlan::TailGap(secs), - TailGapBoundary::WholeSessionBootstrap => ComposerFinalPassPlan::TailGap(0.0), - TailGapBoundary::Skip => ComposerFinalPassPlan::SkipStreaming, - } - } - } -} - -/// Run the planned final pass over the saved WAV. -/// -/// `FullFile` mirrors the controller's toggle-stop adjudicator -/// (`transcribe_file_verdict` with default options); `TailGap` transcribes only -/// the uncommitted tail (append-only doctrine); `SkipStreaming` never touches -/// Whisper. Blocking work runs off the async runtime and is bounded by the -/// shared `deadline`; any failure/timeout/absent-WAV/empty text yields `None` -/// so the caller falls back to the streaming splice. -async fn run_final_pass( - plan: ComposerFinalPassPlan, - audio_path: Option, - language: Option, - deadline: tokio::time::Instant, -) -> Option { - if matches!(plan, ComposerFinalPassPlan::SkipStreaming) { - return None; - } - let path = audio_path?; - let job = tokio::task::spawn_blocking(move || match plan { - ComposerFinalPassPlan::TailGap(from_secs) => { - codescribe_core::stt::whisper_tail_gap_transcribe_file( - &path, - from_secs, - language.as_deref(), - ) - .map(|raw| raw.text) - } - // Always — the ONLY mode permitted a whole-file re-pass. - ComposerFinalPassPlan::FullFile => whisper::transcribe_file_verdict( - &path, - language.as_deref(), - FileTranscriptionOptions::default(), - ) - .map(|verdict| verdict.text), - // Returned above; kept explicit so a FUTURE plan variant is a compile - // error here instead of silently routing into the full-file re-pass. - ComposerFinalPassPlan::SkipStreaming => Ok(String::new()), - }); - match tokio::time::timeout_at(deadline, job).await { - Ok(Ok(Ok(text))) if !text.trim().is_empty() => Some(text), - Ok(Ok(Ok(_))) => None, - Ok(Ok(Err(e))) => { - warn!(target: "composer-dictation", error = %e, "final pass transcription failed"); - None - } - Ok(Err(e)) => { - warn!(target: "composer-dictation", error = %e, "final pass task join failed"); - None - } - Err(_elapsed) => { - warn!(target: "composer-dictation", "final pass timed out; using streaming fallback"); - None - } - } -} - -/// Internal `EventSink` adapter (NOT exposed across FFI). Lives between the -/// core streaming pipeline and the foreign `CsTranscriptionListener`, -/// translating every `EngineEvent` variant into the appropriate listener call. -struct CsEventSink { - listener: Arc, - /// Composer-side accumulator: `stop_recording` reads its snapshot for the - /// return value (the Swift `on_final` callback is a no-op on this path). - transcript: Arc, -} - -impl EventSink for CsEventSink { - /// Translate one core `EngineEvent` into the foreign listener contract and - /// accumulate finals for the composer return path. - fn on_event(&self, event: &EngineEvent) { - match event { - EngineEvent::VadStart { .. } => self.listener.on_vad_active(true), - EngineEvent::VadEnd { .. } => self.listener.on_vad_active(false), - EngineEvent::NoSpeech { reason } => self.listener.on_no_speech(reason.clone()), - EngineEvent::Preview { text, .. } => self.listener.on_preview(text.clone()), - EngineEvent::Correction { - text, - previous_text, - .. - } => self - .listener - .on_correction(text.clone(), previous_text.clone()), - EngineEvent::UtteranceFinal { - utterance_id, - text, - end_ts, - avg_logprob, - vad_speech_pct, - confidence_flags, - .. - } => { - // Compose the composer return here: the streaming recorder's own - // transcript buffer is never filled on this path. - self.transcript.append_final(text); - self.transcript.note_committed_through(*end_ts); - let flags: Vec = confidence_flags.iter().map(ToString::to_string).collect(); - self.listener.on_final( - *utterance_id, - text.clone(), - *avg_logprob, - *vad_speech_pct, - flags, - ); - } - EngineEvent::ReplaceRange { - utterance_id, - start, - end, - text, - source, - } => self.listener.on_replace_range( - *utterance_id, - *start as u64, - *end as u64, - text.clone(), - (*source).into(), - ), - EngineEvent::InsertAnnotation { - utterance_id, - position, - text, - kind, - } => self.listener.on_insert_annotation( - *utterance_id, - *position as u64, - text.clone(), - kind.into(), - ), - EngineEvent::SessionFinalised { - session_id, - layer_summary, - } => self - .listener - .on_session_finalised(session_id.clone(), layer_summary.into()), - // Recoverable engine warning — surface as a non-fatal error string. - EngineEvent::Warning { code, message } => { - tray_status::update_tray_status(TrayStatus::Error); - self.listener.on_error(format!("{code}: {message}")) - } - // Engine-internal bookkeeping (dropped content, session stats) has no - // listener surface; intentionally ignored. - EngineEvent::Drop { .. } | EngineEvent::Stats { .. } => {} - } - } -} - -/// Resolve the Whisper language hint for a manual voice-note session. -/// -/// An explicit caller choice wins; `None` falls back to the persisted -/// `WHISPER_LANGUAGE` setting (mirroring the hotkey path in -/// `RecordingController`) rather than forcing blind auto-detect — the latter -/// mis-guessed `en`/`ru` on short manual notes. `Auto` collapses to `None` -/// (genuine auto-detect) via `whisper_hint`, never the literal `"auto"` code. -/// Uses `load_without_keychain` so opening the composer mic never triggers a -/// Keychain prompt. -fn resolve_language_hint(language: Option) -> Option { - match language { - Some(lang) => codescribe_core::config::Language::from(lang).whisper_hint(), - None => codescribe_core::config::Config::load_without_keychain() - .whisper_language - .whisper_hint(), - } - .map(str::to_string) -} - -/// One live composer voice-note session: the streaming recorder plus the -/// finalized-text accumulator its event sink feeds, the wall-clock start used to -/// size the stop timeout, and the resolved Whisper language hint reused for the -/// stop-time final pass (kept so it honours the persisted setting exactly like -/// the start-time streaming session). -struct ActiveSession { - recorder: StreamingRecorder, - transcript: Arc, - started_at: Instant, - language_hint: Option, -} - -/// Thin handle to the codescribe dictation engine (streaming recorder + -/// Whisper). Holds the active session behind an async mutex and the current -/// foreign listener behind an `RwLock`. -#[derive(uniffi::Object)] -pub struct CodescribeDictation { - recorder: Mutex>, - listener: RwLock>>, -} - -#[uniffi::export(async_runtime = "tokio")] -impl CodescribeDictation { - /// Build an idle dictation handle and initialize logging. No microphone or - /// model work happens here — call `set_listener` then `start_recording`. - #[uniffi::constructor] - pub fn new() -> Self { - codescribe::logging::init_logging(); - Self { - recorder: Mutex::new(None), - listener: RwLock::new(None), - } - } - - /// Register (or replace) the foreign listener that receives dictation - /// events. Must be called before `start_recording`. - pub fn set_listener(&self, listener: Arc) { - if let Ok(mut guard) = self.listener.write() { - *guard = Some(listener); - } - } - - /// Optionally warm Whisper weights. Runs on a blocking thread because model - /// load touches the GPU and can take seconds. - /// - /// When the live engine is Apple, Whisper is **gap-fill only** (file final / - /// emergency recovery). Missing weights must never refuse recording start — - /// we log an honest degraded-mode note and return `Ok(())`. Candle-live - /// still requires a model and surfaces load errors. - /// Wraps `whisper::init` (stt/whisper/singleton.rs). - pub async fn init_model(&self) -> Result<(), CsError> { - let apple_live = codescribe::stt::active_engine_is_apple(); - let result = tokio::task::spawn_blocking(whisper::init) - .await - .map_err(|e| CsError::Recording { - msg: format!("init_model task join error: {e}"), - })?; - match result { - Ok(()) => Ok(()), - Err(e) if apple_live => { - tracing::warn!("no Whisper gap fill this session (Apple live continues): {e:#}"); - Ok(()) - } - Err(e) => Err(CsError::Recording { msg: e.to_string() }), - } - } - - /// True when the Whisper engine is currently loaded. May flip back to - /// `false` after idle-unload; the next transcription reloads transparently. - /// Wraps `whisper::is_initialized` (stt/whisper/singleton.rs:207). - pub fn is_model_loaded(&self) -> bool { - whisper::is_initialized() - } - - /// Whether the default Whisper weights are on disk / embedded (not necessarily loaded). - pub fn whisper_model_ready_status(&self) -> CsWhisperModelStatus { - CsWhisperModelStatus::from(codescribe_core::config::models::whisper_model_status()) - } - - /// Start microphone dictation. Builds a `CsEventSink` from the registered - /// listener, wires it into a fresh `StreamingRecorder`, and starts the - /// event-based transcription session. - /// - /// Wraps `StreamingRecorder::new` (audio/streaming_recorder.rs:25), - /// `set_event_sink` (:74) and `start_event_session` (:87). Errors if no - /// listener was set (the core pipeline requires an event sink). - pub async fn start_recording(&self, language: Option) -> Result<(), CsError> { - let listener = self - .listener - .read() - .map_err(|_| CsError::Recording { - msg: "listener lock poisoned".to_string(), - })? - .clone() - .ok_or_else(|| CsError::Recording { - msg: "set_listener(...) must be called before start_recording".to_string(), - })?; - - let transcript = Arc::new(ComposerTranscript::default()); - let sink: Arc = Arc::new(CsEventSink { - listener: Arc::clone(&listener), - transcript: Arc::clone(&transcript), - }); - let mut recorder = - StreamingRecorder::new().map_err(|e| CsError::Recording { msg: e.to_string() })?; - recorder.set_event_sink(Some(sink)); - - // Manual voice-note: the composer's Stop click is the source of truth, - // exactly like the hotkey hold's key-up (see `RecordingController` - // hold-start, which also sets `auto_silence = false`). The legacy - // `RecorderConfig` defaults to `auto_silence = true`, which auto-stops the - // stream after ~0.3s of silence and chops a single spoken note into - // fragments the commit-VAD then rejects as "no speech". Disable it so the - // user — not the VAD — ends the recording. - recorder.recorder.config.auto_silence = false; - - let language_code = resolve_language_hint(language); - recorder - .start_event_session(language_code.clone()) - .await - .map_err(|e| CsError::Recording { msg: e.to_string() })?; - - *self.recorder.lock().await = Some(ActiveSession { - recorder, - transcript, - started_at: Instant::now(), - language_hint: language_code, - }); - tray_status::update_tray_status(TrayStatus::Listening); - listener.on_recording_started(); - Ok(()) - } - - /// Stop the active dictation session and return the composed transcript. - /// - /// Two-phase, within one shared budget (`compose_stop_timeout`): - /// - /// 1. `StreamingRecorder::stop` is the completion signal — it stops the - /// audio stream, joins the transcription task (which only finishes AFTER - /// every `UtteranceFinal` has been emitted synchronously into our - /// accumulator), and saves the WAV. So the streaming splice is complete - /// once stop returns cleanly. - /// 2. The final pass `FINAL_PASS_MODE` permits (`composer_final_pass_plan`, - /// same law as the controller lane): **Always** re-transcribes the whole - /// saved WAV with the `transcribe_file_verdict` adjudicator the - /// hotkey/overlay toggle-stop uses; **Smart** transcribes only the audio - /// after the last committed utterance and merges it as gap-fill; - /// **Off** runs no Whisper at all and streaming is final. - /// - /// The final pass wins whenever it yields non-empty text; the streaming - /// splice is the fallback for a failed/timed-out/empty final pass (or a - /// drain timeout, where no WAV is composed). Either way the UI never hangs: - /// the shared budget bounds both phases and overrun degrades quality, not - /// correctness. The streaming recorder's own transcript buffer is ignored — - /// it stays empty on this path. - pub async fn stop_recording(&self) -> Result { - let mut session = { - let mut guard = self.recorder.lock().await; - guard.take().ok_or_else(|| CsError::Recording { - msg: "no active recording to stop".to_string(), - })? - }; - - let budget = compose_stop_timeout(session.started_at.elapsed()); - let deadline = tokio::time::Instant::now() + budget; - let transcript = Arc::clone(&session.transcript); - let language_hint = session.language_hint.clone(); - self.notify_recording_finalising(); - - // Phase 1: drain the streaming session and recover the saved WAV path. - let audio_path = match tokio::time::timeout_at(deadline, session.recorder.stop()).await { - Ok(Ok((_streaming_buf, audio_path))) => audio_path, - Ok(Err(e)) => { - tray_status::update_tray_status(TrayStatus::Error); - return Err(CsError::Recording { msg: e.to_string() }); - } - Err(_elapsed) => { - // Drain overran the budget — no WAV to adjudicate; return the - // streaming finals accumulated so far. - let (streaming_text, utterances) = transcript.snapshot(); - let text = streaming_text.trim().to_string(); - warn!( - target: "composer-dictation", - source = ComposerTranscriptSource::StreamingFallback.label(), - utterances, - streaming_chars = text.chars().count(), - budget_ms = budget.as_millis() as u64, - "composer voice-note stop drain timed out; returning streaming fallback" - ); - self.notify_recording_stopped(); - return Ok(text); - } - }; - - // Phase 2: the final pass `FINAL_PASS_MODE` permits — full file under - // Always, uncommitted-tail gap-fill under Smart, nothing under Off. The - // streaming splice remains the fallback authority in every mode. - let (streaming_text, _utterances) = transcript.snapshot(); - let mode = codescribe_core::config::final_pass_routing_mode(); - let plan = composer_final_pass_plan( - mode, - transcript.committed_through_secs(), - streaming_text.trim().is_empty(), - ); - info!( - target: "composer-dictation", - mode = mode.as_str(), - plan = plan.label(), - committed_through_secs = transcript.committed_through_secs(), - "composer voice-note stop final-pass plan" - ); - let final_pass_text = run_final_pass(plan, audio_path, language_hint, deadline).await; - - let final_pass_chars = final_pass_text - .as_deref() - .map(|t| t.trim().chars().count()) - .unwrap_or(0); - let (text, source) = - compose_composer_transcript(plan, final_pass_text.as_deref(), &streaming_text); - - info!( - target: "composer-dictation", - source = source.label(), - plan = plan.label(), - final_pass_chars, - streaming_chars = streaming_text.trim().chars().count(), - "composer voice-note stop composed transcript" - ); - - self.notify_recording_stopped(); - Ok(text) - } - - /// Fire the foreign `on_recording_stopped` callback if a listener is set. - fn notify_recording_stopped(&self) { - tray_status::update_tray_status(TrayStatus::Idle); - if let Ok(guard) = self.listener.read() - && let Some(listener) = guard.as_ref() - { - listener.on_recording_stopped(); - } - } - - /// Fire the foreign `on_recording_finalising` callback and publish processing. - fn notify_recording_finalising(&self) { - tray_status::update_tray_status(TrayStatus::Thinking); - if let Ok(guard) = self.listener.read() - && let Some(listener) = guard.as_ref() - { - listener.on_recording_finalising(); - } - } - - /// True while a dictation session is active. - /// Wraps `StreamingRecorder::is_recording` (audio/streaming_recorder.rs:79). - pub async fn is_recording(&self) -> bool { - self.recorder - .lock() - .await - .as_ref() - .map(|session| session.recorder.is_recording()) - .unwrap_or(false) - } - - /// Transcribe an existing audio file. Loads + decodes the file, detects the - /// language, then runs Whisper. All blocking work runs off the async runtime. - /// - /// Wraps `audio::load_audio_file` (audio/loader.rs:10), - /// `whisper::detect_language` (stt/whisper/singleton.rs:249) and - /// `whisper::transcribe` (stt/whisper/singleton.rs:214). - pub async fn transcribe_file(&self, path: String) -> Result { - tokio::task::spawn_blocking(move || -> Result { - let path = std::path::PathBuf::from(path); - let (samples, sample_rate) = - load_audio_file(&path).map_err(|e| CsError::Recording { msg: e.to_string() })?; - let language = whisper::detect_language(&samples, sample_rate) - .map_err(|e| CsError::Recording { msg: e.to_string() })?; - let text = whisper::transcribe(&samples, sample_rate, Some(language.as_str())) - .map_err(|e| CsError::Recording { msg: e.to_string() })?; - Ok(CsTranscription { text, language }) - }) - .await - .map_err(|e| CsError::Recording { - msg: format!("transcribe_file task join error: {e}"), - })? - } -} - /// True when microphone permission is already granted. /// Wraps `os::permissions::check_microphone` (app/os/permissions.rs:135). #[uniffi::export] @@ -1067,453 +514,29 @@ pub fn request_mic_permission() -> bool { codescribe::os::permissions::request_microphone() } -/// Dictation-bridge unit coverage: audio-input resolution, event-sink identity -/// flow, composer commit boundaries, and final-pass plan mode truth. #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex as StdMutex; - /// Configured match, unavailable fallback, and default-only paths stay honest. #[test] fn audio_input_resolution_reports_live_match_and_unavailable_fallback() { let devices = vec![ "MacBook Pro Microphone".to_string(), "USB Studio Mic".to_string(), ]; - assert_eq!( - resolve_audio_input_state(Some("Studio Mic"), &devices, Some("MacBook Pro Microphone"),), + resolve_audio_input_state(Some("Studio Mic"), &devices, Some("MacBook Pro Microphone")), (Some("USB Studio Mic".to_string()), true, false) ); assert_eq!( resolve_audio_input_state( Some("Unplugged Mic"), &devices, - Some("MacBook Pro Microphone"), + Some("MacBook Pro Microphone") ), (Some("MacBook Pro Microphone".to_string()), false, true) ); - assert_eq!( - resolve_audio_input_state(None, &devices, Some("MacBook Pro Microphone")), - (Some("MacBook Pro Microphone".to_string()), true, false) - ); assert!(device_is_available(Some("Studio Mic"), &devices)); assert!(!device_is_available(Some("Unplugged Mic"), &devices)); } - - /// Captures the payload of the single listener call we assert on. - #[derive(Default)] - struct CapturingListener { - final_calls: StdMutex>, - } - - impl CsTranscriptionListener for CapturingListener { - /// Lifecycle prepare — unused by sink identity tests. - fn on_recording_preparing(&self) {} - /// Lifecycle start — unused by sink identity tests. - fn on_recording_started(&self) {} - /// Lifecycle stop — unused by sink identity tests. - fn on_recording_stopped(&self) {} - /// Lifecycle finalising — unused by sink identity tests. - fn on_recording_finalising(&self) {} - /// Interim preview text — unused by sink identity tests. - fn on_preview(&self, _text: String) {} - /// Live correction text — unused by sink identity tests. - fn on_correction(&self, _text: String, _previous_text: String) {} - /// Record each final so tests can assert utterance_id + text together. - fn on_final( - &self, - utterance_id: u64, - text: String, - _avg_logprob: Option, - _speech_pct: Option, - _confidence_flags: Vec, - ) { - self.final_calls.lock().unwrap().push((utterance_id, text)); - } - /// Bounded replace events — unused by the capture fixture. - fn on_replace_range( - &self, - _utterance_id: u64, - _start: u64, - _end: u64, - _text: String, - _source: CsLayerSource, - ) { - } - /// Inline annotations — unused by the capture fixture. - fn on_insert_annotation( - &self, - _utterance_id: u64, - _position: u64, - _text: String, - _kind: CsAnnotationKind, - ) { - } - /// Context markers — unused by the capture fixture. - fn on_context_marker(&self, _position: u64, _marker: String) {} - /// Session-end summary — unused by the capture fixture. - fn on_session_finalised(&self, _session_id: String, _layer_summary: CsLayerSummary) {} - /// Delivery-grade final transcript — unused by the capture fixture. - fn on_final_transcript_ready(&self, _text: String) {} - /// VAD active flips — unused by the capture fixture. - fn on_vad_active(&self, _active: bool) {} - /// RMS level ticks — unused by the capture fixture. - fn on_audio_level(&self, _rms: f32) {} - /// No-speech notices — unused by the capture fixture. - fn on_no_speech(&self, _reason: String) {} - /// Recoverable engine errors — unused by the capture fixture. - fn on_error(&self, _message: String) {} - } - - /// Build a minimal `UtteranceFinal` event with the given identity/text. - fn utterance_final(utterance_id: u64, text: &str) -> EngineEvent { - EngineEvent::UtteranceFinal { - utterance_id, - text: text.to_string(), - raw_text: text.to_string(), - start_ts: 0.0, - end_ts: 1.0, - segments: Vec::new(), - vad_speech_pct: None, - avg_logprob: None, - compression_ratio: None, - quality_gate_dropped: false, - confidence_flags: Vec::new(), - } - } - - /// The bridge must forward `utterance_id` on `UtteranceFinal` so committed - /// sinks can stamp segment identity that later `ReplaceRange` patches target. - /// Regression guard for the W3 keystone (identity flow into committed text). - #[test] - fn utterance_final_forwards_utterance_id() { - let listener = Arc::new(CapturingListener::default()); - let sink = CsEventSink { - listener: listener.clone(), - transcript: Arc::new(ComposerTranscript::default()), - }; - - sink.on_event(&utterance_final(7, "ala ma kota")); - - let calls = listener.final_calls.lock().unwrap(); - assert_eq!( - calls.as_slice(), - &[(7, "ala ma kota".to_string())], - "on_final must receive the utterance_id from UtteranceFinal" - ); - } - - /// The composer return is composed from the finalized utterance stream: the - /// sink must accumulate each `UtteranceFinal` (space-joined, empties skipped) - /// so `stop_recording` never returns an empty transcript after real speech. - /// Regression guard for the "audio + STT work but final is empty" bug. - #[test] - fn cs_event_sink_accumulates_final_transcript() { - let listener = Arc::new(CapturingListener::default()); - let transcript = Arc::new(ComposerTranscript::default()); - let sink = CsEventSink { - listener: listener.clone(), - transcript: Arc::clone(&transcript), - }; - - sink.on_event(&utterance_final(1, " no to ")); - sink.on_event(&utterance_final(2, "")); // empty final must not widen text - sink.on_event(&utterance_final(3, "dobra teraz")); - - let (text, utterances) = transcript.snapshot(); - assert_eq!(text, "no to dobra teraz"); - assert_eq!( - utterances, 2, - "empty final must not count toward utterances" - ); - } - - /// Same as [`utterance_final`] but with an explicit commit boundary. - fn utterance_final_at(utterance_id: u64, text: &str, end_ts: f32) -> EngineEvent { - match utterance_final(utterance_id, text) { - EngineEvent::UtteranceFinal { - utterance_id, - text, - raw_text, - start_ts, - segments, - vad_speech_pct, - avg_logprob, - compression_ratio, - quality_gate_dropped, - confidence_flags, - .. - } => EngineEvent::UtteranceFinal { - utterance_id, - text, - raw_text, - start_ts, - end_ts, - segments, - vad_speech_pct, - avg_logprob, - compression_ratio, - quality_gate_dropped, - confidence_flags, - }, - other => other, - } - } - - /// Smart mode needs the composer lane's committed audio boundary, exactly as - /// the controller's `SessionTelemetrySink` tracks it: a monotonic max fold of - /// `UtteranceFinal::end_ts` that an out-of-order final can never rewind, and - /// that an empty final still advances (the audio IS adjudicated, it simply - /// carried no text — so it must not be gap-filled again). - #[test] - fn composer_transcript_tracks_committed_through_secs() { - let transcript = ComposerTranscript::default(); - assert_eq!( - transcript.committed_through_secs(), - None, - "no finals yet ⇒ no commit evidence" - ); - - let listener = Arc::new(CapturingListener::default()); - let sink = CsEventSink { - listener, - transcript: Arc::new(ComposerTranscript::default()), - }; - sink.on_event(&utterance_final_at(1, "raz", 2.5)); - sink.on_event(&utterance_final_at(2, "dwa", 7.25)); - // Out-of-order final: the boundary must not rewind. - sink.on_event(&utterance_final_at(3, "trzy", 4.0)); - // Empty final still seals its audio. - sink.on_event(&utterance_final_at(4, " ", 9.5)); - - assert_eq!(sink.transcript.committed_through_secs(), Some(9.5)); - assert_eq!( - sink.transcript.snapshot().0, - "raz dwa trzy", - "boundary tracking must not disturb the text accumulator" - ); - } - - /// The composer stop lane must obey `FINAL_PASS_MODE` exactly like the - /// controller lane: Always is the ONLY mode allowed a full-file re-pass, - /// Smart may only gap-fill the uncommitted tail, Off runs no Whisper at all. - #[test] - fn composer_final_pass_plan_honours_mode() { - // Always: full file, regardless of commit evidence or canvas state. - for (committed, empty) in [(None, false), (Some(4.0), false), (None, true)] { - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Always, committed, empty), - ComposerFinalPassPlan::FullFile, - "Always must full-file re-pass (committed={committed:?}, empty={empty})" - ); - } - - // Off: zero Whisper on the stop path, streaming is final. - for (committed, empty) in [(None, false), (Some(4.0), false), (None, true)] { - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Off, committed, empty), - ComposerFinalPassPlan::SkipStreaming, - "Off must never invoke Whisper (committed={committed:?}, empty={empty})" - ); - } - - // Smart with a committed boundary: gap-fill the tail only. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, Some(6.5), false), - ComposerFinalPassPlan::TailGap(6.5) - ); - // Smart, no commit evidence, empty canvas: whole session is still an append. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, None, true), - ComposerFinalPassPlan::TailGap(0.0) - ); - // Smart, no commit evidence, non-empty canvas: a whole-file pass would land - // on committed text — honest skip instead. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, None, false), - ComposerFinalPassPlan::SkipStreaming - ); - // Non-finite / non-positive boundaries carry no evidence either. - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, Some(f32::NAN), false), - ComposerFinalPassPlan::SkipStreaming - ); - assert_eq!( - composer_final_pass_plan(FinalPassRoutingMode::Smart, Some(0.0), false), - ComposerFinalPassPlan::SkipStreaming - ); - } - - /// The stop-drain budget scales with recording length but is clamped so the - /// composer UI can never hang indefinitely on a stalled scheduler. - #[test] - fn compose_stop_timeout_scales_and_clamps() { - /// Allow one-micro drift from floating proportional clamp arithmetic. - fn assert_duration_close(actual: Duration, expected: Duration) { - let drift = actual.abs_diff(expected); - assert!( - drift <= Duration::from_micros(1), - "duration drift {drift:?} exceeded tolerance: actual={actual:?}, expected={expected:?}" - ); - } - - // Short note: floored so a cold commit + tail patch still fits. - assert_eq!( - compose_stop_timeout(Duration::from_secs(3)), - Duration::from_secs(8) - ); - // Mid-length: proportional (20s * 0.6 = 12s) inside the band. - assert_duration_close( - compose_stop_timeout(Duration::from_secs(20)), - Duration::from_secs(12), - ); - // Long note: capped so the UI never waits unboundedly. - assert_eq!( - compose_stop_timeout(Duration::from_secs(300)), - Duration::from_secs(30) - ); - } - - /// Whisper excess fills live gaps (InsertB), never replaces the live floor. - #[test] - fn select_composer_transcript_merges_whisper_gap_fill() { - let (text, source) = select_composer_transcript(Some(" raz dwa trzy cztery "), "raz dwa"); - assert_eq!(text, "raz dwa trzy cztery"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// Substitution disagreements keep live (doctrine: floor of truth); the - /// Whisper variant is lexicon/human territory, not a silent overwrite. - #[test] - fn select_composer_transcript_keeps_live_on_substitution() { - let (text, source) = select_composer_transcript(Some("raz dwa trzy"), "raz dwa tszy"); - assert_eq!(text, "raz dwa tszy"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// Collapsing file-final (Apple SFSpeech short) must not blank or shrink a - /// real stream: merge keeps every live token, so the floor survives. - #[test] - fn select_composer_transcript_collapsing_final_keeps_live_floor() { - let stream = "Im wystarczy i jeszcze sporo z freezed live assembly utterance dwa"; - let (text, source) = select_composer_transcript(Some("Im wystarczy"), stream); - assert_eq!(text, stream.trim()); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// With no live stream at all, the whisper final stands alone. - #[test] - fn select_composer_transcript_whisper_only_when_stream_empty() { - let (text, source) = select_composer_transcript(Some("raz dwa"), " "); - assert_eq!(text, "raz dwa"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - } - - /// An absent or empty/whitespace final pass falls back to the streaming - /// splice so a failed adjudication never blanks a real transcript. - #[test] - fn select_composer_transcript_falls_back_to_streaming() { - let (none_text, none_source) = select_composer_transcript(None, " raz dwa "); - assert_eq!(none_text, "raz dwa"); - assert_eq!(none_source, ComposerTranscriptSource::StreamingFallback); - - let (empty_text, empty_source) = select_composer_transcript(Some(" \n "), "raz dwa"); - assert_eq!(empty_text, "raz dwa"); - assert_eq!(empty_source, ComposerTranscriptSource::StreamingFallback); - } - - /// THE ONE RULE for the Smart lane: a `TailGap` result is a **bare tail**, - /// not a full-file transcript. It must be APPENDED to the immutable - /// committed/live streaming text — never diffed against it. `merge_live_whisper` - /// is built for full transcripts vs the live floor: on a bare tail it turns the - /// boundary DeleteA+InsertB pair into a Substitute that keeps live and DISCARDS - /// the whisper token, silently losing gap-fill words. - #[test] - fn compose_composer_transcript_appends_tail_gap() { - let (text, source) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(1.5), - Some("trzy cztery"), - "raz dwa", - ); - assert_eq!( - text, "raz dwa trzy cztery", - "tail gap-fill must be appended verbatim, not merged" - ); - assert_eq!(source, ComposerTranscriptSource::TailGapAppend); - - // Function words are the first casualty of the merge path. - let (clinical, _) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(2.0), - Some("i wymioty od rana"), - "Pacjent ma goraczke", - ); - assert_eq!(clinical, "Pacjent ma goraczke i wymioty od rana"); - - // Overlapping preview words are deduped word-granularly, committed side untouched. - let (deduped, _) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(2.0), - Some("goraczke i wymioty od rana"), - "Pacjent ma goraczke i", - ); - assert_eq!(deduped, "Pacjent ma goraczke i wymioty od rana"); - } - - /// `FullFile` (Always) keeps the whole-WAV merge; `SkipStreaming` never has a - /// final pass to compose with. - #[test] - fn compose_composer_transcript_keeps_merge_for_full_file() { - let (text, source) = compose_composer_transcript( - ComposerFinalPassPlan::FullFile, - Some("raz dwa trzy cztery"), - "raz dwa", - ); - assert_eq!(text, "raz dwa trzy cztery"); - assert_eq!(source, ComposerTranscriptSource::MergedLiveWhisper); - - let (skipped, skipped_source) = - compose_composer_transcript(ComposerFinalPassPlan::SkipStreaming, None, " raz dwa "); - assert_eq!(skipped, "raz dwa"); - assert_eq!(skipped_source, ComposerTranscriptSource::StreamingFallback); - } - - /// An empty / absent tail leaves the streaming splice exactly as it stands. - #[test] - fn compose_composer_transcript_tail_gap_empty_falls_back() { - for tail in [None, Some(" \n ")] { - let (text, source) = compose_composer_transcript( - ComposerFinalPassPlan::TailGap(1.0), - tail, - " raz dwa ", - ); - assert_eq!( - text, "raz dwa", - "empty tail {tail:?} must not disturb streaming" - ); - assert_eq!(source, ComposerTranscriptSource::StreamingFallback); - } - } - - /// An explicit caller language must map to its two-letter Whisper hint, and - /// `Auto` must collapse to genuine auto-detect (`None`) — never the literal - /// `"auto"` code, which Whisper cannot honour. Guards the manual voice-note - /// language path so the composer respects the persisted language like the - /// hotkey path instead of blind-guessing `en`/`ru`. - #[test] - fn resolve_language_hint_maps_explicit_choices() { - assert_eq!( - resolve_language_hint(Some(CsLanguage::Polish)), - Some("pl".to_string()) - ); - assert_eq!( - resolve_language_hint(Some(CsLanguage::English)), - Some("en".to_string()) - ); - assert_eq!( - resolve_language_hint(Some(CsLanguage::Auto)), - None, - "Auto must be genuine auto-detect (None), never the literal \"auto\" code" - ); - } } diff --git a/core/Cargo.toml b/core/Cargo.toml index 36423f26..c8e25fbb 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,6 +14,10 @@ categories = ["multimedia::audio", "multimedia::encoding"] [lib] path = "lib.rs" +[[bin]] +name = "codescribe-stt-sidecar" +path = "bin/codescribe-stt-sidecar.rs" + [features] default = [] offline_eval = [] diff --git a/core/agent/thread_store.rs b/core/agent/thread_store.rs index 246f01d6..43ac8f2e 100644 --- a/core/agent/thread_store.rs +++ b/core/agent/thread_store.rs @@ -967,6 +967,7 @@ mod tests { use anyhow::Result; use chrono::Duration; use serde_json::json; + use serial_test::serial; use std::collections::HashSet; use tempfile::TempDir; @@ -1504,6 +1505,7 @@ mod tests { /// Inline image bytes persist as disk assets and restore without raw JSON. #[test] + #[serial] fn inline_image_roundtrips_through_disk_backed_asset() -> Result<()> { let tmp = TempDir::new()?; let store = ThreadStore::new_in(tmp.path().join("threads"))?; @@ -1551,6 +1553,7 @@ mod tests { /// Identical image bytes map to one asset path and are not rewritten. #[test] + #[serial] fn inline_image_asset_is_written_once_across_saves() -> Result<()> { let block = ContentBlock::Image { data: b"w5a-dedup-bytes".to_vec(), diff --git a/core/asr_session/bootstrap.rs b/core/asr_session/bootstrap.rs new file mode 100644 index 00000000..447447d3 --- /dev/null +++ b/core/asr_session/bootstrap.rs @@ -0,0 +1,256 @@ +//! Recording-start policy that joins persisted mode truth, consent, and the +//! canonical STT endpoint into the provider decision consumed by C1. +//! +//! An explicit `ws`/`wss` URL is the live socket. A public HTTPS file URL +//! (`/v1/audio/transcriptions`) stays file — OpenAI and Libraxis the same — +//! so recording continues with Apple + lexicon unless the socket is stored. +//! Loopback file URLs still map onto the local Voice Lab worker. + +use std::fmt; + +use tracing::warn; + +use super::cloud::{ + CloudSessionLimits, GatewayConnection, GatewayWebSocketTransport, LiveCloudAsrSession, +}; +use super::consent::authorize_cloud_egress; +use super::recorder::Layer1Decision; +use crate::config::{AsrProductMode, Config, UserSettings}; + +/// Availability of one validated live session at recording start. +/// +/// `Invalid` is distinct from `Unavailable` for content-free diagnostics. The +/// raw endpoint and credential never cross this enum and are never formatted. +pub enum GatewaySessionAvailability { + /// No known live endpoint is available. + Unavailable, + /// The resolved live connection failed validation. + Invalid, + /// A validated WebSocket endpoint and endpoint-owned credential. + Ready(GatewayConnection), +} + +/// Resolve the live WebSocket session directly from canonical STT config. +/// +/// Stored `ws`/`wss` is the live socket (Voice Lab on this host). A public +/// OpenAI-compatible file URL is never rewritten into a socket. Retranscribe +/// and Settings → Test keep the file path. +pub fn gateway_session_availability(config: &Config) -> GatewaySessionAvailability { + let Some(endpoint) = config + .stt_endpoint + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + return GatewaySessionAvailability::Unavailable; + }; + let Some(endpoint) = live_websocket_endpoint(endpoint) else { + return GatewaySessionAvailability::Unavailable; + }; + let credential = config.stt_api_key.as_deref().unwrap_or_default(); + match GatewayConnection::new(endpoint, credential) { + Ok(connection) => GatewaySessionAvailability::Ready(connection), + Err(_) => GatewaySessionAvailability::Invalid, + } +} + +fn live_websocket_endpoint(endpoint: &str) -> Option { + let mut url = reqwest::Url::parse(endpoint).ok()?; + let host = url.host_str()?.trim_matches(['[', ']']); + match url.scheme() { + "ws" | "wss" => return Some(url.to_string()), + "http" | "https" => {} + _ => return None, + } + + let loopback = host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()); + if !loopback { + return None; + } + + let websocket_scheme = if url.scheme() == "https" { "wss" } else { "ws" }; + url.set_scheme(websocket_scheme).ok()?; + if url.path().ends_with("/transcriptions") { + let path = url.path().trim_end_matches("transcriptions").to_string() + "transcribe"; + url.set_path(&path); + } + // Inverse of file_probe_endpoint: Voice Lab file worker :8444 is not a + // live socket. The live socket is :8446. + if url.port() == Some(8444) { + url.set_port(Some(8446)).ok()?; + } + Some(url.to_string()) +} + +impl fmt::Debug for GatewaySessionAvailability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Unavailable => "GatewaySessionAvailability::Unavailable", + Self::Invalid => "GatewaySessionAvailability::Invalid", + Self::Ready(_) => "GatewaySessionAvailability::Ready([REDACTED])", + }) + } +} + +/// Build the one Layer 1 decision consumed by the real recorder path. +/// +/// Cloud can arm only when the settings resolver still says `cloud`, explicit +/// audio-egress authorization succeeds, and the caller supplies a validated +/// live gateway connection. Every other state is normal Apple + lexicon. +/// In particular, an unavailable cloud session never falls through to local +/// power or Whisper. +pub fn layer1_decision_for_recording( + settings: &UserSettings, + gateway: GatewaySessionAvailability, +) -> Layer1Decision { + let resolved = settings.resolved_asr_mode(); + if resolved.mode != AsrProductMode::Cloud { + return Layer1Decision::Disarmed; + } + + let Ok(authorization) = authorize_cloud_egress(&resolved.consent) else { + return Layer1Decision::Disarmed; + }; + let GatewaySessionAvailability::Ready(connection) = gateway else { + warn!( + derivation = ?resolved.derivation, + gateway = ?gateway, + "Cloud Layer 1 unavailable at recording start; continuing with Apple + lexicon" + ); + return Layer1Decision::Disarmed; + }; + + let limits = CloudSessionLimits::default(); + let Ok(transport) = GatewayWebSocketTransport::new(connection, limits) else { + return Layer1Decision::Disarmed; + }; + let Ok(session) = LiveCloudAsrSession::new(transport, limits, authorization) else { + return Layer1Decision::Disarmed; + }; + Layer1Decision::Armed(Box::new(session)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cloud_settings(consent: Option<&str>) -> UserSettings { + UserSettings { + asr_mode: Some("cloud".to_string()), + cloud_consent: consent.map(str::to_string), + ..UserSettings::default() + } + } + + fn ready() -> GatewaySessionAvailability { + GatewaySessionAvailability::Ready( + GatewayConnection::new("wss://gateway.invalid/v1/stt/live", "short-lived-token") + .expect("valid normalized gateway connection"), + ) + } + + #[test] + fn consented_cloud_with_valid_mint_arms_the_real_provider() { + let decision = layer1_decision_for_recording(&cloud_settings(Some("granted")), ready()); + assert!(decision.is_armed()); + } + + #[test] + fn missing_denied_malformed_or_offline_cloud_stays_apple_only() { + let cases = [ + (cloud_settings(None), ready()), + (cloud_settings(Some("denied")), ready()), + ( + cloud_settings(Some("granted")), + GatewaySessionAvailability::Invalid, + ), + ( + cloud_settings(Some("granted")), + GatewaySessionAvailability::Unavailable, + ), + ]; + + for (settings, gateway) in cases { + assert!(!layer1_decision_for_recording(&settings, gateway).is_armed()); + } + } + + #[test] + fn cloud_failure_and_explicit_local_mode_never_load_an_in_process_model() { + let probe = || { + crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls() + }; + let before = probe(); + + let offline = layer1_decision_for_recording( + &cloud_settings(Some("granted")), + GatewaySessionAvailability::Unavailable, + ); + let local_power = layer1_decision_for_recording( + &UserSettings { + asr_mode: Some("local_power".to_string()), + ..UserSettings::default() + }, + ready(), + ); + + assert!(!offline.is_armed()); + assert!( + !local_power.is_armed(), + "L0 owns the explicit helper provider" + ); + assert_eq!(probe().saturating_sub(before), 0); + } + + #[test] + fn public_file_url_is_not_a_live_socket() { + assert_eq!( + live_websocket_endpoint("https://api.libraxis.cloud/v1/audio/transcriptions"), + None + ); + assert_eq!( + live_websocket_endpoint("https://api.openai.com/v1/audio/transcriptions"), + None + ); + assert_eq!( + live_websocket_endpoint("https://custom.example/v1/audio/transcriptions"), + None + ); + assert_eq!( + live_websocket_endpoint("wss://api.libraxis.cloud/v1/audio/transcribe").as_deref(), + Some("wss://api.libraxis.cloud/v1/audio/transcribe") + ); + assert_eq!( + live_websocket_endpoint("http://127.0.0.1:8000/v1/audio/transcriptions").as_deref(), + Some("ws://127.0.0.1:8000/v1/audio/transcribe") + ); + assert_eq!( + live_websocket_endpoint("ws://127.0.0.1:8446/v1/audio/transcribe").as_deref(), + Some("ws://127.0.0.1:8446/v1/audio/transcribe") + ); + assert_eq!( + live_websocket_endpoint("http://127.0.0.1:8444/v1/audio/transcriptions").as_deref(), + Some("ws://127.0.0.1:8446/v1/audio/transcribe") + ); + assert_eq!( + live_websocket_endpoint("http://localhost:8444/v1/audio/transcriptions").as_deref(), + Some("ws://localhost:8446/v1/audio/transcribe") + ); + } + + #[test] + fn loopback_live_socket_needs_no_credential() { + let config = Config { + stt_endpoint: Some("http://localhost:8000/v1/audio/transcriptions".into()), + stt_api_key: None, + ..Config::default() + }; + assert!(matches!( + gateway_session_availability(&config), + GatewaySessionAvailability::Ready(_) + )); + } +} diff --git a/core/asr_session/cloud.rs b/core/asr_session/cloud.rs new file mode 100644 index 00000000..32de11c9 --- /dev/null +++ b/core/asr_session/cloud.rs @@ -0,0 +1,1889 @@ +//! Dedicated live cloud transport for the Libraxis Voice Lab WebSocket. +//! +//! This module owns normal live capture: a `config` message, bounded base64 PCM +//! `chunk` messages, periodic `flush`, and a bounded `end`/drain. The receive +//! adapter converts Voice Lab events into Codescribe's normalized vocabulary. +//! Whole-file multipart upload lives outside this session and is reserved for +//! explicit retranscribe actions. This module does not own recorder wiring, +//! consent, or provider selection. +//! +//! Provider ordering is evidence, not authority. Transcript revisions are +//! compared only inside their utterance, duplicates and stale revisions are +//! removed, and [`LiveCloudAsrSession`] assigns a fresh Codescribe-owned +//! stream-global sequence to every event it emits. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderName, HeaderValue}; +use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +use super::consent::CloudEgressAuthorization; +use super::events::{ + AsrErrorKind, AsrSessionEvent, AudioRange, ErrorEvent, EventIdentity, SessionId, + TranscriptEvent, UsageEvent, +}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; + +/// Normalized bounds for one live cloud session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CloudSessionLimits { + /// Largest PCM callback accepted as one gateway frame. + pub max_frame_samples: usize, + /// Maximum wire items inspected by one non-blocking [`drain`](AsrSessionProvider::drain). + pub max_events_per_drain: usize, + /// Maximum trailing wire items accepted while synchronously closing. + pub max_close_events: usize, + /// Bounded audio/end command queue feeding the socket worker. + pub outbound_queue_capacity: usize, + /// Bounded normalized event queue returning from the socket worker. + pub inbound_queue_capacity: usize, + /// Maximum remembered gateway event ids used for replay suppression. + pub remembered_event_ids: usize, + /// Upper bound for the WebSocket handshake. + pub connect_timeout: Duration, + /// Upper bound for one socket send. + pub send_timeout: Duration, + /// Upper bound for the end signal and trailing receive drain. + pub close_timeout: Duration, +} + +impl Default for CloudSessionLimits { + fn default() -> Self { + Self { + // 200 ms at the expected 16 kHz input rate. + max_frame_samples: 3_200, + max_events_per_drain: 64, + max_close_events: 128, + outbound_queue_capacity: 8, + inbound_queue_capacity: 128, + remembered_event_ids: 4_096, + connect_timeout: Duration::from_secs(10), + send_timeout: Duration::from_secs(5), + close_timeout: Duration::from_secs(2), + } + } +} + +impl CloudSessionLimits { + fn validate(&self) -> Result<(), AsrErrorKind> { + if self.max_frame_samples == 0 + || self.max_events_per_drain == 0 + || self.max_close_events == 0 + || self.outbound_queue_capacity == 0 + || self.inbound_queue_capacity == 0 + || self.remembered_event_ids == 0 + || self.connect_timeout.is_zero() + || self.send_timeout.is_zero() + || self.close_timeout.is_zero() + { + return Err(AsrErrorKind::Protocol); + } + Ok(()) + } +} + +/// Live endpoint and its endpoint-owned authentication credential. +/// +/// Its `Debug` representation is deliberately content-free. Endpoints can carry +/// signed query parameters and bearer values are credentials; neither belongs +/// in logs, panic output, or telemetry. +pub struct GatewayConnection { + endpoint: String, + credential: String, + auth_mode: crate::stt::tail_provider::SttAuthMode, +} + +impl GatewayConnection { + /// Validate a normalized gateway WebSocket connection. + /// + /// Remote plaintext and URL user-info are refused. A signed query string is + /// allowed but remains redacted by the type's `Debug` implementation. + pub fn new( + endpoint: impl Into, + credential: impl Into, + ) -> Result { + let endpoint = endpoint.into(); + let credential = credential.into(); + let parsed = reqwest::Url::parse(&endpoint).map_err(|_| AsrErrorKind::Protocol)?; + let host = parsed + .host_str() + .map(|value| value.trim_matches(['[', ']'])) + .ok_or(AsrErrorKind::Protocol)?; + let encrypted = parsed.scheme() == "wss"; + let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1"); + let auth_mode = crate::stt::tail_provider::stt_auth_mode(&endpoint); + if (!encrypted && !(parsed.scheme() == "ws" && loopback)) + || !parsed.username().is_empty() + || parsed.password().is_some() + || (auth_mode != crate::stt::tail_provider::SttAuthMode::Unauthenticated + && credential.trim().is_empty()) + { + return Err(AsrErrorKind::Protocol); + } + + if auth_mode != crate::stt::tail_provider::SttAuthMode::Unauthenticated { + HeaderValue::from_str(credential.trim()).map_err(|_| AsrErrorKind::Protocol)?; + } + Ok(Self { + endpoint, + credential, + auth_mode, + }) + } +} + +impl fmt::Debug for GatewayConnection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayConnection") + .field("endpoint", &"[REDACTED]") + .field("credential", &"[REDACTED]") + .finish() + } +} + +/// Provider-neutral session configuration sent exactly once after connect. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GatewaySessionConfig { + #[serde(rename = "type")] + message_type: &'static str, + protocol_version: u16, + session_id: String, + locale: Option, + /// Codescribe domain token. The gateway must not classify audio to pick one. + vocabulary: &'static str, + audio: GatewayAudioConfig, +} + +impl GatewaySessionConfig { + fn from_input(input: &SessionInput) -> Self { + Self { + message_type: "session.start", + protocol_version: 1, + session_id: input.session_id.as_str().to_string(), + locale: input.locale.clone(), + vocabulary: crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, + audio: GatewayAudioConfig { + encoding: "pcm_s16le", + sample_rate_hz: input.sample_rate, + channels: 1, + frame_header: "sequence_u64_be", + }, + } + } + + /// Opaque session id echoed by every gateway event. + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// Audio sample rate sent to the gateway. + pub fn sample_rate_hz(&self) -> u32 { + self.audio.sample_rate_hz + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct GatewayAudioConfig { + encoding: &'static str, + sample_rate_hz: u32, + channels: u8, + frame_header: &'static str, +} + +/// One bounded mono PCM16-LE frame, prefixed on the wire by its local send id. +#[derive(Clone, PartialEq, Eq)] +pub struct GatewayPcmFrame { + sequence_id: u64, + pcm_s16le: Vec, +} + +impl GatewayPcmFrame { + /// Codescribe-owned monotonic audio frame number. + pub fn sequence_id(&self) -> u64 { + self.sequence_id + } + + /// PCM payload length, excluding the eight-byte sequence header. + pub fn payload_len(&self) -> usize { + self.pcm_s16le.len() + } + + #[cfg(test)] + fn into_wire_bytes(self) -> Vec { + let mut wire = Vec::with_capacity(8 + self.pcm_s16le.len()); + wire.extend_from_slice(&self.sequence_id.to_be_bytes()); + wire.extend_from_slice(&self.pcm_s16le); + wire + } +} + +/// Stateful adapter from the proven Voice Lab wire into Codescribe's strict +/// normalized event vocabulary. +struct VoiceLabReceiveState { + session_id: String, + next_event_id: u64, + utterance_id: u64, + revision: u64, +} + +impl VoiceLabReceiveState { + fn new(session_id: String) -> Self { + Self { + session_id, + next_event_id: 1, + utterance_id: 1, + revision: 0, + } + } + + fn event_id(&mut self) -> Result { + let id = self.next_event_id; + self.next_event_id = self + .next_event_id + .checked_add(1) + .ok_or(AsrErrorKind::Protocol)?; + Ok(format!("voice-lab-{id}")) + } + + fn adapt(&mut self, text: &str) -> Result, AsrErrorKind> { + if let Ok(event) = serde_json::from_str::(text) { + return Ok(Some(event)); + } + let value: serde_json::Value = + serde_json::from_str(text).map_err(|_| AsrErrorKind::Protocol)?; + let message_type = value + .get("type") + .and_then(serde_json::Value::as_str) + .ok_or(AsrErrorKind::Protocol)?; + match message_type { + // Voice Lab `stt-ws-v1` opens with `hello` and then control/VAD + // frames. Those are not transcript events; treating them as a + // protocol fault used to drop Layer 1 at take start. + "ack" | "ready" | "hello" | "vad.sample" | "speech.start" | "speech.end" => Ok(None), + "transcript.partial" | "transcript" => { + let Some(text) = voice_lab_text(&value) else { + return Ok(None); + }; + self.revision = self.revision.checked_add(1).ok_or(AsrErrorKind::Protocol)?; + let event_id = self.event_id()?; + Ok(Some(GatewayEvent::Partial { + event_id, + session_id: self.session_id.clone(), + utterance_id: self.utterance_id, + revision: self.revision, + text, + start_ms: None, + end_ms: None, + })) + } + "transcript.final" => { + let Some(text) = voice_lab_text(&value) else { + return Ok(None); + }; + self.revision = self.revision.checked_add(1).ok_or(AsrErrorKind::Protocol)?; + let event_id = self.event_id()?; + let event = GatewayEvent::Final { + event_id, + session_id: self.session_id.clone(), + utterance_id: self.utterance_id, + revision: self.revision, + text, + start_ms: None, + end_ms: None, + }; + self.utterance_id = self + .utterance_id + .checked_add(1) + .ok_or(AsrErrorKind::Protocol)?; + self.revision = 0; + Ok(Some(event)) + } + "error" => { + let code = match value.get("code").and_then(serde_json::Value::as_str) { + Some("auth" | "unauthorized" | "forbidden") => GatewayErrorCode::Auth, + Some("quota" | "payment_required") => GatewayErrorCode::Quota, + Some("rate_limited") => GatewayErrorCode::RateLimited, + Some("timeout") => GatewayErrorCode::Timeout, + Some("backpressure") => GatewayErrorCode::Backpressure, + _ => GatewayErrorCode::Protocol, + }; + let event_id = self.event_id()?; + Ok(Some(GatewayEvent::Error { + event_id, + session_id: self.session_id.clone(), + utterance_id: self.utterance_id, + code, + })) + } + "end" | "session.ended" | "stream.closed" => Ok(Some(GatewayEvent::SessionEnded { + session_id: self.session_id.clone(), + })), + _ => Ok(None), + } + } +} + +fn voice_lab_text(value: &serde_json::Value) -> Option { + value + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +/// Voice Lab live start frame. The engine's frozen inbound types are +/// `set` / `chunk` / `flush` / `end` — `config` is rejected as unknown. +fn voice_lab_set_message(config: &GatewaySessionConfig) -> String { + serde_json::json!({ + "type": "set", + "language": config.locale.as_deref().unwrap_or("pl"), + "sample_rate": config.sample_rate_hz(), + "encoding": "pcm16", + "vocabulary": config.vocabulary, + }) + .to_string() +} + +impl fmt::Debug for GatewayPcmFrame { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayPcmFrame") + .field("sequence_id", &self.sequence_id) + .field("payload_bytes", &self.pcm_s16le.len()) + .finish() + } +} + +/// Stable normalized gateway error vocabulary. No vendor message crosses it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GatewayErrorCode { + /// Connection credentials are missing, expired, or rejected. + Auth, + /// A transient request-rate limit. + RateLimited, + /// Billing or account quota is exhausted. + Quota, + /// The requested locale or session capability is unsupported. + Unsupported, + /// Gateway/session protocol mismatch. + Protocol, + /// Gateway-side buffering could not keep up. + Backpressure, + /// A normalized gateway deadline elapsed. + Timeout, + /// The gateway cancelled the session. + Cancelled, +} + +impl GatewayErrorCode { + fn as_asr_kind(self) -> AsrErrorKind { + match self { + Self::Auth => AsrErrorKind::Auth, + Self::RateLimited => AsrErrorKind::RateLimited, + Self::Quota => AsrErrorKind::Quota, + Self::Unsupported => AsrErrorKind::Unsupported, + Self::Protocol => AsrErrorKind::Protocol, + Self::Backpressure => AsrErrorKind::Overflow, + Self::Timeout => AsrErrorKind::Transport, + Self::Cancelled => AsrErrorKind::Cancelled, + } + } +} + +/// Provider-neutral receive vocabulary spoken by the Libraxis gateway. +/// +/// `revision` is scoped only to its utterance. It is used to discard stale +/// provider frames and is never exposed as the Codescribe event sequence. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +pub enum GatewayEvent { + /// Volatile transcript hypothesis. + #[serde(rename = "transcript.partial")] + Partial { + event_id: String, + session_id: String, + utterance_id: u64, + revision: u64, + text: String, + #[serde(default)] + start_ms: Option, + #[serde(default)] + end_ms: Option, + }, + /// Sealing transcript hypothesis. + #[serde(rename = "transcript.final")] + Final { + event_id: String, + session_id: String, + utterance_id: u64, + revision: u64, + text: String, + #[serde(default)] + start_ms: Option, + #[serde(default)] + end_ms: Option, + }, + /// Typed error without provider prose. + #[serde(rename = "session.error")] + Error { + event_id: String, + session_id: String, + #[serde(default)] + utterance_id: u64, + code: GatewayErrorCode, + }, + /// Content-free accounting. + #[serde(rename = "session.usage")] + Usage { + event_id: String, + session_id: String, + audio_ms: u64, + #[serde(default)] + billable_units: Option, + }, + /// Explicit acknowledgement that trailing events are complete. + #[serde(rename = "session.ended")] + SessionEnded { session_id: String }, +} + +impl GatewayEvent { + fn session_id(&self) -> &str { + match self { + Self::Partial { session_id, .. } + | Self::Final { session_id, .. } + | Self::Error { session_id, .. } + | Self::Usage { session_id, .. } + | Self::SessionEnded { session_id } => session_id, + } + } + + fn event_id(&self) -> Option<&str> { + match self { + Self::Partial { event_id, .. } + | Self::Final { event_id, .. } + | Self::Error { event_id, .. } + | Self::Usage { event_id, .. } => Some(event_id), + Self::SessionEnded { .. } => None, + } + } +} + +/// Non-blocking result of polling an injected gateway transport. +#[derive(Debug, Clone, PartialEq)] +pub enum GatewayTransportPoll { + /// No receive item is ready now. + Pending, + /// One normalized gateway event is ready. + Event(GatewayEvent), + /// The transport failed with a content-free typed reason. + Fault(AsrErrorKind), + /// The transport ended and no more events can arrive. + Closed, +} + +/// Injectable boundary between the session adapter and a WebSocket actor. +pub trait CloudGatewayTransport: Send { + /// Start one normalized session. + fn start(&mut self, config: GatewaySessionConfig) -> Result<(), AsrErrorKind>; + /// Queue one bounded PCM frame without waiting for socket I/O. + fn try_send_pcm(&mut self, frame: GatewayPcmFrame) -> Result<(), AsrErrorKind>; + /// Poll one receive item without blocking. + fn poll(&mut self) -> GatewayTransportPoll; + /// Queue the normalized end signal. + fn begin_end(&mut self) -> Result<(), AsrErrorKind>; + /// Cancel any remaining work after a bounded drain expires. + fn abort(&mut self); +} + +#[derive(Debug)] +enum GatewayCommand { + Pcm(GatewayPcmFrame), + End, + Abort, +} + +#[derive(Debug)] +enum WorkerSignal { + Event(GatewayEvent), + Fault(AsrErrorKind), + Closed, +} + +/// Real bounded WebSocket actor for the Voice Lab wire contract. +/// +/// The socket and credential live on a dedicated current-thread Tokio runtime. +/// The synchronous provider side only performs bounded `try_send`/`try_recv` +/// channel operations; it never performs network I/O on the audio callback. +pub struct GatewayWebSocketTransport { + connection: Option, + limits: CloudSessionLimits, + command_tx: Option>, + event_rx: Option>, + worker: Option>, + started: bool, + ending: bool, +} + +impl GatewayWebSocketTransport { + /// Build a dormant gateway transport. Network I/O starts at session open. + pub fn new( + connection: GatewayConnection, + limits: CloudSessionLimits, + ) -> Result { + limits.validate()?; + Ok(Self { + connection: Some(connection), + limits, + command_tx: None, + event_rx: None, + worker: None, + started: false, + ending: false, + }) + } +} + +impl fmt::Debug for GatewayWebSocketTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayWebSocketTransport") + .field("connection", &self.connection) + .field("started", &self.started) + .field("ending", &self.ending) + .finish_non_exhaustive() + } +} + +impl CloudGatewayTransport for GatewayWebSocketTransport { + fn start(&mut self, config: GatewaySessionConfig) -> Result<(), AsrErrorKind> { + if self.started { + return Err(AsrErrorKind::Protocol); + } + + let (command_tx, command_rx) = mpsc::channel(self.limits.outbound_queue_capacity); + let (event_tx, event_rx) = mpsc::channel(self.limits.inbound_queue_capacity); + // Move the credential into the socket worker. The synchronous + // provider retains no spare credential copy after session start. + let connection = self.connection.take().ok_or(AsrErrorKind::Protocol)?; + let limits = self.limits; + let worker = std::thread::Builder::new() + .name("codescribe-live-cloud-asr".to_string()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let Ok(runtime) = runtime else { + let _ = event_tx.blocking_send(WorkerSignal::Fault(AsrErrorKind::Transport)); + let _ = event_tx.blocking_send(WorkerSignal::Closed); + return; + }; + runtime.block_on(gateway_worker( + connection, config, limits, command_rx, event_tx, + )); + }) + .map_err(|_| AsrErrorKind::Transport)?; + + self.command_tx = Some(command_tx); + self.event_rx = Some(event_rx); + self.worker = Some(worker); + self.started = true; + Ok(()) + } + + fn try_send_pcm(&mut self, frame: GatewayPcmFrame) -> Result<(), AsrErrorKind> { + if !self.started || self.ending { + return Err(AsrErrorKind::Protocol); + } + let sender = self.command_tx.as_ref().ok_or(AsrErrorKind::Transport)?; + sender + .try_send(GatewayCommand::Pcm(frame)) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => AsrErrorKind::Overflow, + mpsc::error::TrySendError::Closed(_) => AsrErrorKind::Transport, + }) + } + + fn poll(&mut self) -> GatewayTransportPoll { + let Some(receiver) = self.event_rx.as_mut() else { + return GatewayTransportPoll::Pending; + }; + match receiver.try_recv() { + Ok(WorkerSignal::Event(event)) => GatewayTransportPoll::Event(event), + Ok(WorkerSignal::Fault(kind)) => GatewayTransportPoll::Fault(kind), + Ok(WorkerSignal::Closed) => GatewayTransportPoll::Closed, + Err(mpsc::error::TryRecvError::Empty) => GatewayTransportPoll::Pending, + Err(mpsc::error::TryRecvError::Disconnected) => GatewayTransportPoll::Closed, + } + } + + fn begin_end(&mut self) -> Result<(), AsrErrorKind> { + if !self.started || self.ending { + return Err(AsrErrorKind::Protocol); + } + let sender = self.command_tx.as_ref().ok_or(AsrErrorKind::Transport)?; + sender + .try_send(GatewayCommand::End) + .map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => AsrErrorKind::Overflow, + mpsc::error::TrySendError::Closed(_) => AsrErrorKind::Transport, + })?; + self.ending = true; + Ok(()) + } + + fn abort(&mut self) { + if let Some(sender) = self.command_tx.take() { + let _ = sender.try_send(GatewayCommand::Abort); + } + self.ending = true; + } +} + +impl Drop for GatewayWebSocketTransport { + fn drop(&mut self) { + self.abort(); + if self.worker.as_ref().is_some_and(JoinHandle::is_finished) + && let Some(worker) = self.worker.take() + { + let _ = worker.join(); + } + } +} + +async fn gateway_worker( + connection: GatewayConnection, + config: GatewaySessionConfig, + limits: CloudSessionLimits, + command_rx: mpsc::Receiver, + event_tx: mpsc::Sender, +) { + let result = run_gateway_socket(connection, config, limits, command_rx, &event_tx).await; + if let Err(kind) = result { + let _ = event_tx.send(WorkerSignal::Fault(kind)).await; + } + let _ = event_tx.send(WorkerSignal::Closed).await; +} + +async fn run_gateway_socket( + connection: GatewayConnection, + config: GatewaySessionConfig, + limits: CloudSessionLimits, + mut command_rx: mpsc::Receiver, + event_tx: &mpsc::Sender, +) -> Result<(), AsrErrorKind> { + let mut request = connection + .endpoint + .as_str() + .into_client_request() + .map_err(|_| AsrErrorKind::Protocol)?; + match connection.auth_mode { + crate::stt::tail_provider::SttAuthMode::Unauthenticated => {} + crate::stt::tail_provider::SttAuthMode::Bearer => { + let authorization = + HeaderValue::from_str(&format!("Bearer {}", connection.credential.trim())) + .map_err(|_| AsrErrorKind::Protocol)?; + request.headers_mut().insert(AUTHORIZATION, authorization); + } + crate::stt::tail_provider::SttAuthMode::ApiKey => { + let value = HeaderValue::from_str(connection.credential.trim()) + .map_err(|_| AsrErrorKind::Protocol)?; + request + .headers_mut() + .insert(HeaderName::from_static("x-api-key"), value); + } + } + + let connected = timeout(limits.connect_timeout, connect_async(request)) + .await + .map_err(|_| AsrErrorKind::Transport)? + .map_err(|error| classify_socket_error(&error))?; + let (mut socket, _) = connected; + + // Proven Voice Lab wire: credentials stay in the WebSocket handshake, + // never in the JSON body. The engine start type is `set`, not `config`. + send_socket_message( + &mut socket, + Message::Text(voice_lab_set_message(&config).into()), + limits.send_timeout, + ) + .await?; + + let mut receive_state = VoiceLabReceiveState::new(config.session_id.clone()); + let mut flush = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_millis(2_500), + Duration::from_millis(2_500), + ); + loop { + tokio::select! { + command = command_rx.recv() => { + match command { + Some(GatewayCommand::Pcm(frame)) => { + let chunk = serde_json::json!({ + "type": "chunk", + "audio_base64": BASE64.encode(&frame.pcm_s16le), + "sample_rate": config.audio.sample_rate_hz, + "encoding": "pcm16", + }).to_string(); + send_socket_message( + &mut socket, + Message::Text(chunk.into()), + limits.send_timeout, + ).await?; + } + Some(GatewayCommand::End) => { + let flush = serde_json::json!({"type": "flush"}).to_string(); + send_socket_message( + &mut socket, + Message::Text(flush.into()), + limits.send_timeout, + ).await?; + let end = serde_json::json!({"type": "end"}).to_string(); + send_socket_message( + &mut socket, + Message::Text(end.into()), + limits.send_timeout, + ).await?; + return drain_gateway_tail( + &mut socket, + limits, + event_tx, + &mut receive_state, + ).await; + } + Some(GatewayCommand::Abort) | None => { + let _ = socket.close(None).await; + return Ok(()); + } + } + } + _ = flush.tick() => { + let message = serde_json::json!({"type": "flush"}).to_string(); + send_socket_message( + &mut socket, + Message::Text(message.into()), + limits.send_timeout, + ).await?; + } + incoming = socket.next() => { + if forward_gateway_message( + incoming, + &mut socket, + limits.send_timeout, + event_tx, + &mut receive_state, + ).await? { + return Err(AsrErrorKind::Transport); + } + } + } + } +} + +async fn drain_gateway_tail( + socket: &mut WebSocketStream>, + limits: CloudSessionLimits, + event_tx: &mpsc::Sender, + receive_state: &mut VoiceLabReceiveState, +) -> Result<(), AsrErrorKind> { + let deadline = tokio::time::Instant::now() + limits.close_timeout; + loop { + let incoming = tokio::time::timeout_at(deadline, socket.next()) + .await + .map_err(|_| AsrErrorKind::Transport)?; + if forward_gateway_message( + incoming, + socket, + limits.send_timeout, + event_tx, + receive_state, + ) + .await? + { + return Ok(()); + } + } +} + +async fn forward_gateway_message( + incoming: Option>, + socket: &mut WebSocketStream>, + send_timeout: Duration, + event_tx: &mpsc::Sender, + receive_state: &mut VoiceLabReceiveState, +) -> Result { + match incoming { + Some(Ok(Message::Text(text))) => { + let Some(event) = receive_state.adapt(text.as_ref())? else { + return Ok(false); + }; + let ended = matches!(event, GatewayEvent::SessionEnded { .. }); + event_tx + .send(WorkerSignal::Event(event)) + .await + .map_err(|_| AsrErrorKind::Cancelled)?; + Ok(ended) + } + Some(Ok(Message::Ping(payload))) => { + send_socket_message(socket, Message::Pong(payload), send_timeout).await?; + Ok(false) + } + Some(Ok(Message::Pong(_))) => Ok(false), + Some(Ok(Message::Close(_))) | None => Ok(true), + Some(Ok(Message::Binary(_))) | Some(Ok(Message::Frame(_))) => Err(AsrErrorKind::Protocol), + Some(Err(error)) => Err(classify_socket_error(&error)), + } +} + +async fn send_socket_message( + socket: &mut WebSocketStream>, + message: Message, + send_timeout: Duration, +) -> Result<(), AsrErrorKind> { + timeout(send_timeout, socket.send(message)) + .await + .map_err(|_| AsrErrorKind::Transport)? + .map_err(|error| classify_socket_error(&error)) +} + +fn classify_socket_error(error: &WebSocketError) -> AsrErrorKind { + if let WebSocketError::Http(response) = error { + return match response.status().as_u16() { + 401 | 403 => AsrErrorKind::Auth, + 402 => AsrErrorKind::Quota, + 429 => AsrErrorKind::RateLimited, + 400..=499 => AsrErrorKind::Protocol, + _ => AsrErrorKind::Transport, + }; + } + AsrErrorKind::Transport +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionState { + Idle, + Open, + Ending, + Closed, + Failed, +} + +/// Content-free counters safe to place in diagnostics and telemetry. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct CloudSessionTelemetry { + /// PCM frames accepted by the bounded transport queue. + pub frames_queued: u64, + /// Samples accepted by the bounded transport queue. + pub samples_queued: u64, + /// Normalized events emitted to the caller. + pub events_emitted: u64, + /// Exact gateway replays suppressed by event id. + pub duplicate_events: u64, + /// Old per-utterance revisions or post-final updates suppressed. + pub stale_events: u64, + /// Bounded send attempts refused by backpressure. + pub backpressure_events: u64, + /// Transport-level faults normalized into typed events. + pub transport_faults: u64, +} + +#[derive(Debug)] +struct SeenEventIds { + capacity: usize, + order: VecDeque, + values: HashSet, +} + +impl SeenEventIds { + fn new(capacity: usize) -> Self { + Self { + capacity, + order: VecDeque::with_capacity(capacity), + values: HashSet::with_capacity(capacity), + } + } + + fn insert(&mut self, event_id: &str) -> bool { + if self.values.contains(event_id) { + return false; + } + if self.order.len() == self.capacity + && let Some(expired) = self.order.pop_front() + { + self.values.remove(&expired); + } + let owned = event_id.to_string(); + self.order.push_back(owned.clone()); + self.values.insert(owned); + true + } +} + +/// Live cloud implementation of [`AsrSessionProvider`]. +pub struct LiveCloudAsrSession { + _authorization: CloudEgressAuthorization, + transport: T, + limits: CloudSessionLimits, + state: SessionState, + session_id: Option, + next_audio_sequence: u64, + next_event_sequence: u64, + utterance_revisions: HashMap, + sealed_utterances: HashSet, + seen_event_ids: SeenEventIds, + ready: VecDeque, + telemetry: CloudSessionTelemetry, + fault_seen: bool, +} + +impl LiveCloudAsrSession { + /// Build an explicitly authorized live session over an injected normalized transport. + pub fn new( + transport: T, + limits: CloudSessionLimits, + authorization: CloudEgressAuthorization, + ) -> Result { + limits.validate()?; + Ok(Self { + _authorization: authorization, + transport, + limits, + state: SessionState::Idle, + session_id: None, + next_audio_sequence: 1, + next_event_sequence: 1, + utterance_revisions: HashMap::new(), + sealed_utterances: HashSet::new(), + seen_event_ids: SeenEventIds::new(limits.remembered_event_ids), + ready: VecDeque::new(), + telemetry: CloudSessionTelemetry::default(), + fault_seen: false, + }) + } + + /// Borrow the injected transport, primarily for deterministic verification. + pub fn transport(&self) -> &T { + &self.transport + } + + /// Content-free session counters. + pub fn telemetry(&self) -> CloudSessionTelemetry { + self.telemetry + } + + fn session_id(&self) -> Result { + self.session_id.clone().ok_or(AsrErrorKind::Protocol) + } + + fn allocate_identity(&mut self, utterance_id: u64) -> Result { + if self.next_event_sequence == u64::MAX { + return Err(AsrErrorKind::Protocol); + } + let sequence = self.next_event_sequence; + self.next_event_sequence += 1; + Ok(EventIdentity::new( + self.session_id()?, + utterance_id, + sequence, + )) + } + + fn queue_local_error(&mut self, utterance_id: u64, kind: AsrErrorKind) { + if let Ok(identity) = self.allocate_identity(utterance_id) { + self.ready + .push_back(AsrSessionEvent::Error(ErrorEvent { identity, kind })); + } + } + + fn normalize(&mut self, event: GatewayEvent) { + let expected_session = match self.session_id.as_ref() { + Some(value) => value.as_str(), + None => return, + }; + if event.session_id() != expected_session { + self.queue_local_error(0, AsrErrorKind::Protocol); + return; + } + if matches!(event, GatewayEvent::SessionEnded { .. }) { + self.state = SessionState::Closed; + return; + } + + let Some(event_id) = event.event_id() else { + self.queue_local_error(0, AsrErrorKind::Protocol); + return; + }; + if event_id.trim().is_empty() { + self.queue_local_error(0, AsrErrorKind::Protocol); + return; + } + if !self.seen_event_ids.insert(event_id) { + self.telemetry.duplicate_events += 1; + return; + } + + let normalized = match event { + GatewayEvent::Partial { + utterance_id, + revision, + text, + start_ms, + end_ms, + .. + } => self.normalize_transcript(false, utterance_id, revision, text, start_ms, end_ms), + GatewayEvent::Final { + utterance_id, + revision, + text, + start_ms, + end_ms, + .. + } => self.normalize_transcript(true, utterance_id, revision, text, start_ms, end_ms), + GatewayEvent::Error { + utterance_id, code, .. + } => self.allocate_identity(utterance_id).map(|identity| { + Some(AsrSessionEvent::Error(ErrorEvent { + identity, + kind: code.as_asr_kind(), + })) + }), + GatewayEvent::Usage { + audio_ms, + billable_units, + .. + } => self.allocate_identity(0).map(|identity| { + Some(AsrSessionEvent::Usage(UsageEvent { + identity, + audio_secs: duration_millis_to_secs(audio_ms), + billable_units, + })) + }), + GatewayEvent::SessionEnded { .. } => return, + }; + + match normalized { + Ok(Some(event)) => self.ready.push_back(event), + Ok(None) => {} + Err(kind) => self.queue_local_error(0, kind), + } + } + + fn normalize_transcript( + &mut self, + is_final: bool, + utterance_id: u64, + revision: u64, + text: String, + start_ms: Option, + end_ms: Option, + ) -> Result, AsrErrorKind> { + if text.trim().is_empty() { + return Err(AsrErrorKind::Protocol); + } + if self.sealed_utterances.contains(&utterance_id) + || self + .utterance_revisions + .get(&utterance_id) + .is_some_and(|previous| revision <= *previous) + { + self.telemetry.stale_events += 1; + return Ok(None); + } + let range = match (start_ms, end_ms) { + (None, None) => None, + (Some(start), Some(end)) => Some( + AudioRange::new(duration_millis_to_secs(start), duration_millis_to_secs(end)) + .ok_or(AsrErrorKind::Protocol)?, + ), + _ => return Err(AsrErrorKind::Protocol), + }; + self.utterance_revisions.insert(utterance_id, revision); + if is_final { + self.sealed_utterances.insert(utterance_id); + } + let transcript = TranscriptEvent { + identity: self.allocate_identity(utterance_id)?, + text, + range, + }; + Ok(Some(if is_final { + AsrSessionEvent::Final(transcript) + } else { + AsrSessionEvent::Partial(transcript) + })) + } + + fn poll_transport_once(&mut self) -> bool { + match self.transport.poll() { + GatewayTransportPoll::Pending => false, + GatewayTransportPoll::Event(event) => { + self.normalize(event); + true + } + GatewayTransportPoll::Fault(kind) => { + self.telemetry.transport_faults += 1; + self.fault_seen = true; + self.queue_local_error(0, kind); + self.state = SessionState::Failed; + true + } + GatewayTransportPoll::Closed => { + if !matches!(self.state, SessionState::Ending | SessionState::Closed) + && !self.fault_seen + { + self.telemetry.transport_faults += 1; + self.queue_local_error(0, AsrErrorKind::Transport); + } + self.state = SessionState::Closed; + true + } + } + } +} + +impl AsrSessionProvider for LiveCloudAsrSession { + fn mode(&self) -> RefinerMode { + RefinerMode::CloudSession + } + + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind> { + if self.state != SessionState::Idle || input.sample_rate == 0 { + return Err(AsrErrorKind::Protocol); + } + self.session_id = Some(input.session_id.clone()); + if let Err(kind) = self + .transport + .start(GatewaySessionConfig::from_input(input)) + { + self.state = SessionState::Failed; + return Err(kind); + } + self.state = SessionState::Open; + Ok(()) + } + + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind> { + if self.state != SessionState::Open || samples.is_empty() { + return Err(AsrErrorKind::Protocol); + } + if samples.len() > self.limits.max_frame_samples { + self.telemetry.backpressure_events += 1; + return Err(AsrErrorKind::Overflow); + } + if samples.iter().any(|sample| !sample.is_finite()) { + return Err(AsrErrorKind::Protocol); + } + if self.next_audio_sequence == u64::MAX { + return Err(AsrErrorKind::Protocol); + } + + let frame = GatewayPcmFrame { + sequence_id: self.next_audio_sequence, + pcm_s16le: samples_to_pcm_s16le(samples), + }; + match self.transport.try_send_pcm(frame) { + Ok(()) => { + self.next_audio_sequence += 1; + self.telemetry.frames_queued += 1; + self.telemetry.samples_queued += samples.len() as u64; + Ok(()) + } + Err(kind) => { + if kind == AsrErrorKind::Overflow { + self.telemetry.backpressure_events += 1; + } + Err(kind) + } + } + } + + fn drain(&mut self) -> Vec { + for _ in 0..self.limits.max_events_per_drain { + if !self.poll_transport_once() { + break; + } + } + let drained: Vec<_> = self.ready.drain(..).collect(); + self.telemetry.events_emitted += drained.len() as u64; + drained + } + + fn close(&mut self) -> Result<(), AsrErrorKind> { + if self.state != SessionState::Open { + return Err(AsrErrorKind::Protocol); + } + if let Err(kind) = self.transport.begin_end() { + self.transport.abort(); + self.state = SessionState::Failed; + return Err(kind); + } + self.state = SessionState::Ending; + + let deadline = Instant::now() + self.limits.close_timeout; + let mut close_events = 0usize; + while Instant::now() < deadline { + let progressed = self.poll_transport_once(); + if self.state == SessionState::Closed { + return Ok(()); + } + if self.state == SessionState::Failed { + self.transport.abort(); + return Err(AsrErrorKind::Transport); + } + if progressed { + close_events += 1; + if close_events >= self.limits.max_close_events { + self.transport.abort(); + self.telemetry.transport_faults += 1; + self.queue_local_error(0, AsrErrorKind::Overflow); + self.state = SessionState::Failed; + return Err(AsrErrorKind::Overflow); + } + } else { + // `close` is the one bounded blocking operation in the provider + // lifecycle. Avoid a hot spin while the socket actor waits for + // its final/usage/session.ended tail. + std::thread::sleep(Duration::from_millis(1)); + } + } + + self.transport.abort(); + self.telemetry.transport_faults += 1; + self.queue_local_error(0, AsrErrorKind::Transport); + self.state = SessionState::Failed; + Err(AsrErrorKind::Transport) + } +} + +fn duration_millis_to_secs(milliseconds: u64) -> f32 { + Duration::from_millis(milliseconds).as_secs_f32() +} + +fn samples_to_pcm_s16le(samples: &[f32]) -> Vec { + let mut bytes = Vec::with_capacity(samples.len() * 2); + for &sample in samples { + let scaled = if sample >= 0.0 { + (sample.clamp(0.0, 1.0) * f32::from(i16::MAX)).round() as i16 + } else { + (sample.clamp(-1.0, 0.0) * 32_768.0).round() as i16 + }; + bytes.extend_from_slice(&scaled.to_le_bytes()); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asr_session::consent::authorize_cloud_egress; + use crate::config::cloud_asr::{AudioEgressConsent, ConsentSource}; + + #[derive(Debug, Default)] + struct FakeGatewayTransport { + started: Vec, + frames: Vec, + script: VecDeque, + send_capacity: Option, + ending: bool, + aborted: bool, + } + + impl FakeGatewayTransport { + fn scripted(script: impl IntoIterator) -> Self { + Self { + script: script.into_iter().collect(), + ..Self::default() + } + } + + fn with_send_capacity(capacity: usize) -> Self { + Self { + send_capacity: Some(capacity), + ..Self::default() + } + } + } + + impl CloudGatewayTransport for FakeGatewayTransport { + fn start(&mut self, config: GatewaySessionConfig) -> Result<(), AsrErrorKind> { + if !self.started.is_empty() { + return Err(AsrErrorKind::Protocol); + } + self.started.push(config); + Ok(()) + } + + fn try_send_pcm(&mut self, frame: GatewayPcmFrame) -> Result<(), AsrErrorKind> { + if self + .send_capacity + .is_some_and(|capacity| self.frames.len() >= capacity) + { + return Err(AsrErrorKind::Overflow); + } + self.frames.push(frame); + Ok(()) + } + + fn poll(&mut self) -> GatewayTransportPoll { + self.script + .pop_front() + .unwrap_or(GatewayTransportPoll::Pending) + } + + fn begin_end(&mut self) -> Result<(), AsrErrorKind> { + self.ending = true; + Ok(()) + } + + fn abort(&mut self) { + self.aborted = true; + } + } + + fn session_id() -> SessionId { + SessionId::new("gateway-session-1").expect("valid test session") + } + + fn input() -> SessionInput { + SessionInput { + session_id: session_id(), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } + } + + fn limits() -> CloudSessionLimits { + CloudSessionLimits { + max_frame_samples: 4, + max_events_per_drain: 32, + max_close_events: 16, + outbound_queue_capacity: 2, + inbound_queue_capacity: 16, + remembered_event_ids: 32, + connect_timeout: Duration::from_millis(20), + send_timeout: Duration::from_millis(20), + close_timeout: Duration::from_millis(20), + } + } + + fn authorization() -> CloudEgressAuthorization { + authorize_cloud_egress(&AudioEgressConsent::Granted( + ConsentSource::ExplicitSettings, + )) + .expect("explicit test consent") + } + + fn partial(event_id: &str, utterance_id: u64, revision: u64, text: &str) -> GatewayEvent { + GatewayEvent::Partial { + event_id: event_id.to_string(), + session_id: session_id().to_string(), + utterance_id, + revision, + text: text.to_string(), + start_ms: None, + end_ms: None, + } + } + + fn final_event(event_id: &str, utterance_id: u64, revision: u64, text: &str) -> GatewayEvent { + GatewayEvent::Final { + event_id: event_id.to_string(), + session_id: session_id().to_string(), + utterance_id, + revision, + text: text.to_string(), + start_ms: None, + end_ms: None, + } + } + + #[test] + fn normalized_start_and_bounded_pcm_frames_are_sent() { + let mut session = + LiveCloudAsrSession::new(FakeGatewayTransport::default(), limits(), authorization()) + .expect("valid limits"); + session.open(&input()).expect("open"); + session + .push_audio(&[-1.0, -0.5, 0.5, 1.0]) + .expect("bounded frame"); + + let transport = session.transport(); + assert_eq!(transport.started.len(), 1); + assert_eq!(transport.started[0].session_id(), "gateway-session-1"); + assert_eq!(transport.started[0].sample_rate_hz(), 16_000); + let start_json = serde_json::to_value(&transport.started[0]).expect("serialize start"); + assert_eq!(start_json["type"], "session.start"); + assert_eq!(start_json["protocol_version"], 1); + assert_eq!(start_json["vocabulary"], "programming"); + assert_eq!(start_json["audio"]["encoding"], "pcm_s16le"); + assert_eq!(start_json["audio"]["channels"], 1); + assert!(start_json.get("provider").is_none()); + assert!(start_json.get("api_key").is_none()); + assert_eq!(transport.frames.len(), 1); + assert_eq!(transport.frames[0].sequence_id(), 1); + assert_eq!(transport.frames[0].payload_len(), 8); + let wire = transport.frames[0].clone().into_wire_bytes(); + assert_eq!(&wire[..8], &1u64.to_be_bytes()); + assert_eq!(&wire[8..10], &i16::MIN.to_le_bytes()); + + assert_eq!( + session.push_audio(&[0.0; 5]), + Err(AsrErrorKind::Overflow), + "an oversized callback is refused instead of split ambiguously" + ); + assert_eq!(session.push_audio(&[f32::NAN]), Err(AsrErrorKind::Protocol)); + assert_eq!(session.telemetry().frames_queued, 1); + assert_eq!(session.telemetry().samples_queued, 4); + } + + #[test] + fn normalized_receive_vocabulary_round_trips_without_vendor_fields() { + let events = [ + partial("partial-1", 1, 1, "tekst"), + final_event("final-1", 1, 2, "tekst final"), + GatewayEvent::Error { + event_id: "error-1".to_string(), + session_id: session_id().to_string(), + utterance_id: 1, + code: GatewayErrorCode::RateLimited, + }, + GatewayEvent::Usage { + event_id: "usage-1".to_string(), + session_id: session_id().to_string(), + audio_ms: 500, + billable_units: Some(1), + }, + GatewayEvent::SessionEnded { + session_id: session_id().to_string(), + }, + ]; + for event in events { + let encoded = serde_json::to_string(&event).expect("encode gateway event"); + let decoded: GatewayEvent = + serde_json::from_str(&encoded).expect("decode gateway event"); + assert_eq!(decoded, event); + assert!(!encoded.contains("provider")); + assert!(!encoded.contains("api_key")); + } + + let vendor_specific = r#"{ + "type":"transcript.partial", + "event_id":"x", + "session_id":"gateway-session-1", + "utterance_id":1, + "revision":1, + "text":"x", + "provider_model":"vendor-secret-shape" + }"#; + assert!(serde_json::from_str::(vendor_specific).is_err()); + } + + #[test] + fn voice_lab_wire_is_adapted_without_credential_fields() { + let mut state = VoiceLabReceiveState::new(session_id().to_string()); + assert_eq!( + state.adapt(r#"{"type":"ack","received_bytes":320}"#), + Ok(None) + ); + + let partial = state + .adapt(r#"{"type":"transcript.partial","text":"pierwszy"}"#) + .expect("valid partial") + .expect("partial event"); + assert!(matches!( + partial, + GatewayEvent::Partial { + utterance_id: 1, + revision: 1, + ref text, + .. + } if text == "pierwszy" + )); + + let final_event = state + .adapt(r#"{"type":"transcript.final","text":"pierwszy final"}"#) + .expect("valid final") + .expect("final event"); + assert!(matches!( + final_event, + GatewayEvent::Final { + utterance_id: 1, + revision: 2, + ref text, + .. + } if text == "pierwszy final" + )); + assert!(matches!( + state + .adapt(r#"{"type":"transcript.final","text":"drugi"}"#) + .expect("second final") + .expect("second event"), + GatewayEvent::Final { + utterance_id: 2, + revision: 1, + .. + } + )); + } + + #[test] + fn voice_lab_hello_and_control_frames_do_not_fault() { + let mut state = VoiceLabReceiveState::new(session_id().to_string()); + assert_eq!( + state.adapt(r#"{"type":"hello","protocol":"stt-ws-v1"}"#), + Ok(None) + ); + assert_eq!( + state.adapt(r#"{"type":"speech.start","energy":0.4}"#), + Ok(None) + ); + assert_eq!( + state.adapt(r#"{"type":"speech.end","energy":0.1}"#), + Ok(None) + ); + assert_eq!( + state.adapt(r#"{"type":"vad.sample","energy":0.2,"is_speech":true}"#), + Ok(None) + ); + assert_eq!( + state.adapt(r#"{"type":"transcript.final","text":""}"#), + Ok(None) + ); + assert_eq!(state.adapt(r#"{"type":"future.control"}"#), Ok(None)); + assert!(matches!( + state.adapt(r#"{"type":"stream.closed"}"#).expect("closed"), + Some(GatewayEvent::SessionEnded { .. }) + )); + assert!(matches!( + state + .adapt(r#"{"type":"transcript.final","text":"zostaje"}"#) + .expect("final after hello") + .expect("text"), + GatewayEvent::Final { + utterance_id: 1, + revision: 1, + ref text, + .. + } if text == "zostaje" + )); + } + + #[test] + fn voice_lab_start_is_set_with_vocabulary_and_no_secret() { + let payload: serde_json::Value = serde_json::from_str(&voice_lab_set_message( + &GatewaySessionConfig::from_input(&input()), + )) + .expect("set json"); + assert_eq!(payload["type"], "set"); + assert_eq!(payload["language"], "pl-PL"); + assert_eq!(payload["sample_rate"], 16_000); + assert_eq!(payload["encoding"], "pcm16"); + assert_eq!(payload["vocabulary"], "programming"); + assert!(payload.get("api_key").is_none()); + assert!(payload.get("type").and_then(|value| value.as_str()) != Some("config")); + } + + #[test] + fn local_sequence_is_global_across_reordered_utterances_and_duplicates() { + let duplicate = partial("u2-r1", 2, 1, "drugi"); + let script = [ + GatewayTransportPoll::Event(partial("u1-r1", 1, 1, "pierwszy")), + GatewayTransportPoll::Event(duplicate.clone()), + GatewayTransportPoll::Event(duplicate), + GatewayTransportPoll::Event(final_event("u1-r3", 1, 3, "pierwszy final")), + GatewayTransportPoll::Event(partial("u1-r2-late", 1, 2, "spozniony")), + GatewayTransportPoll::Event(final_event("u2-r2", 2, 2, "drugi final")), + GatewayTransportPoll::Event(GatewayEvent::Usage { + event_id: "usage-1".to_string(), + session_id: session_id().to_string(), + audio_ms: 1_250, + billable_units: Some(2), + }), + GatewayTransportPoll::Pending, + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + + let events = session.drain(); + let sequences: Vec<_> = events + .iter() + .map(|event| event.identity().sequence_id()) + .collect(); + let utterances: Vec<_> = events + .iter() + .map(|event| event.identity().utterance_id()) + .collect(); + assert_eq!(sequences, vec![1, 2, 3, 4, 5]); + assert_eq!(utterances, vec![1, 2, 1, 2, 0]); + assert_eq!(events[2].as_token(), "final"); + assert_eq!(events[3].as_token(), "final"); + assert_eq!(events[4].as_token(), "usage"); + assert_eq!(session.telemetry().duplicate_events, 1); + assert_eq!(session.telemetry().stale_events, 1); + } + + #[test] + fn delayed_transport_poll_never_blocks_drain() { + let script = [ + GatewayTransportPoll::Pending, + GatewayTransportPoll::Event(partial("delayed", 1, 1, "pozniej")), + GatewayTransportPoll::Pending, + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + assert!(session.drain().is_empty()); + assert_eq!(session.drain().len(), 1); + } + + #[test] + fn disconnect_is_a_typed_transport_event() { + let script = [GatewayTransportPoll::Fault(AsrErrorKind::Transport)]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + let events = session.drain(); + assert!(matches!( + events.as_slice(), + [AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Transport, + .. + })] + )); + assert_eq!(session.telemetry().transport_faults, 1); + } + + #[test] + fn auth_and_quota_are_distinct_content_free_events() { + let script = [ + GatewayTransportPoll::Event(GatewayEvent::Error { + event_id: "auth".to_string(), + session_id: session_id().to_string(), + utterance_id: 0, + code: GatewayErrorCode::Auth, + }), + GatewayTransportPoll::Event(GatewayEvent::Error { + event_id: "quota".to_string(), + session_id: session_id().to_string(), + utterance_id: 0, + code: GatewayErrorCode::Quota, + }), + GatewayTransportPoll::Pending, + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + let events = session.drain(); + assert!(matches!( + &events[0], + AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Auth, + .. + }) + )); + assert!(matches!( + &events[1], + AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Quota, + .. + }) + )); + assert_eq!(events[0].identity().sequence_id(), 1); + assert_eq!(events[1].identity().sequence_id(), 2); + } + + #[test] + fn bounded_send_reports_backpressure_without_advancing_frame_sequence() { + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::with_send_capacity(1), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + session.push_audio(&[0.0; 4]).expect("first frame"); + assert_eq!(session.push_audio(&[0.0; 4]), Err(AsrErrorKind::Overflow)); + assert_eq!(session.transport().frames.len(), 1); + assert_eq!(session.transport().frames[0].sequence_id(), 1); + assert_eq!(session.telemetry().backpressure_events, 1); + } + + #[test] + fn close_drains_trailing_final_and_usage_before_ack() { + let script = [ + GatewayTransportPoll::Event(final_event("tail", 2, 7, "ogon")), + GatewayTransportPoll::Event(GatewayEvent::Usage { + event_id: "tail-usage".to_string(), + session_id: session_id().to_string(), + audio_ms: 2_000, + billable_units: None, + }), + GatewayTransportPoll::Event(GatewayEvent::SessionEnded { + session_id: session_id().to_string(), + }), + ]; + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::scripted(script), + limits(), + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + session.close().expect("bounded close"); + let events = session.drain(); + assert_eq!(events.len(), 2); + assert!(events[0].is_final()); + assert_eq!(events[1].as_token(), "usage"); + } + + #[test] + fn close_timeout_aborts_and_emits_one_typed_fault() { + let mut short_limits = limits(); + short_limits.close_timeout = Duration::from_millis(1); + let mut session = LiveCloudAsrSession::new( + FakeGatewayTransport::default(), + short_limits, + authorization(), + ) + .expect("session"); + session.open(&input()).expect("open"); + assert_eq!(session.close(), Err(AsrErrorKind::Transport)); + assert!(session.transport().aborted); + let events = session.drain(); + assert!(matches!( + events.as_slice(), + [AsrSessionEvent::Error(ErrorEvent { + kind: AsrErrorKind::Transport, + .. + })] + )); + } + + #[test] + fn connection_and_telemetry_debug_are_secret_safe() { + let connection = GatewayConnection::new( + "wss://gateway.invalid/live?signed=do-not-log", + "bearer-do-not-log", + ) + .expect("valid normalized gateway"); + let debug = format!("{connection:?}"); + assert!(!debug.contains("signed=do-not-log")); + assert!(!debug.contains("bearer-do-not-log")); + assert!(debug.contains("REDACTED")); + + let telemetry = format!("{:?}", CloudSessionTelemetry::default()); + assert!(!telemetry.contains("gateway.invalid")); + assert!(!telemetry.contains("bearer")); + } + + #[test] + fn production_connection_refuses_remote_plaintext_and_user_info() { + let plain_websocket = concat!("ws", "://"); + assert_eq!( + GatewayConnection::new(format!("{plain_websocket}gateway.invalid/live"), "token") + .unwrap_err(), + AsrErrorKind::Protocol + ); + assert_eq!( + GatewayConnection::new("wss://user@gateway.invalid/live", "token").unwrap_err(), + AsrErrorKind::Protocol + ); + assert!( + GatewayConnection::new(format!("{plain_websocket}127.0.0.1:9000/live"), "token") + .is_ok() + ); + assert!( + GatewayConnection::new(format!("{plain_websocket}127.0.0.1:9000/live"), "").is_ok(), + "loopback live STT must not require a key" + ); + } + + #[test] + fn voice_lab_loopback_hello_keeps_the_session_open() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let addr = listener.local_addr().expect("local addr"); + let (first_tx, first_rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + let Ok((stream, _)) = listener.accept() else { + return; + }; + let Ok(mut socket) = tokio_tungstenite::tungstenite::accept(stream) else { + return; + }; + let hello = Message::Text(r#"{"type":"hello","protocol":"stt-ws-v1"}"#.into()); + if socket.send(hello).is_err() { + return; + } + if let Ok(Message::Text(text)) = socket.read() { + let _ = first_tx.send(text.to_string()); + let _ = socket.send(Message::Text(r#"{"type":"ack"}"#.into())); + let _ = socket.send(Message::Text( + r#"{"type":"speech.start","energy":0.5}"#.into(), + )); + } + while socket.read().is_ok() {} + }); + + let endpoint = format!("{}{addr}/v1/audio/transcribe", concat!("ws", "://")); + let mut limits = CloudSessionLimits::default(); + limits.connect_timeout = Duration::from_secs(2); + limits.send_timeout = Duration::from_secs(1); + limits.close_timeout = Duration::from_millis(200); + let connection = GatewayConnection::new(endpoint, "").expect("loopback connection"); + let transport = GatewayWebSocketTransport::new(connection, limits).expect("transport"); + let mut session = + LiveCloudAsrSession::new(transport, limits, authorization()).expect("session"); + session.open(&input()).expect("open"); + + let first = first_rx + .recv_timeout(Duration::from_secs(2)) + .expect("Voice Lab start frame"); + let start: serde_json::Value = serde_json::from_str(&first).expect("start json"); + assert_eq!(start["type"], "set"); + assert_ne!(start["type"], "config"); + + for _ in 0..20 { + let events = session.drain(); + assert!( + events + .iter() + .all(|event| !matches!(event, AsrSessionEvent::Error(_))), + "hello/control must not degrade the live lane: {events:?}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + session + .push_audio(&[0.0; 4]) + .expect("PCM after hello stays accepted"); + let _ = session.close(); + } +} diff --git a/core/asr_session/consent.rs b/core/asr_session/consent.rs new file mode 100644 index 00000000..42ce7ce6 --- /dev/null +++ b/core/asr_session/consent.rs @@ -0,0 +1,127 @@ +//! The audio-egress consent gate in front of Layer 1 session construction. +//! +//! [`super::provider::RefinerMode::sends_audio_off_device`] is the classifier; +//! this module is the owner that asks it. A cloud session cannot be authorized +//! without an explicit granted consent record, and every refusal degrades to +//! [`RefinerMode::Off`] (Apple canvas + lexicon) — never to a local model. +//! +//! The gate produces a [`CloudEgressAuthorization`] witness. The type has no +//! public constructor, so recorder/transport wiring that opens a real +//! [`super::cloud::LiveCloudAsrSession`] against a minted gateway session must +//! have passed through [`authorize_cloud_egress`] to hold one — consent +//! enforcement is structural, not a convention callers remember to follow. + +use crate::config::cloud_asr::{AsrProductMode, AudioEgressConsent, ResolvedAsrMode}; + +use super::provider::RefinerMode; + +/// Typed refusal from the Layer 1 session factory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloudSessionError { + /// Audio egress was requested without an explicit granted consent record. + ConsentRequired, +} + +/// Proof that explicit audio-egress consent backs a cloud session. +/// +/// Constructible only through [`authorize_cloud_egress`]. Deliberately not +/// `Clone`/`Copy`: one authorization, one session. +#[derive(Debug, PartialEq, Eq)] +pub struct CloudEgressAuthorization { + _witness: (), +} + +/// Authorize opening a cloud Layer 1 session under the given consent state. +/// +/// This is the production factory gate the fleet RED precommitted: session +/// construction without explicit audio-egress consent is rejected with a typed +/// [`CloudSessionError::ConsentRequired`], never a panic and never a silent +/// downgrade to a different provider. +pub fn authorize_cloud_egress( + consent: &AudioEgressConsent, +) -> Result { + if consent.permits_egress() { + Ok(CloudEgressAuthorization { _witness: () }) + } else { + Err(CloudSessionError::ConsentRequired) + } +} + +/// Map the resolved product mode onto the Layer 1 refiner axis. +/// +/// The consent gate is re-asked here even though the resolver already enforced +/// it — defense in depth for a hand-built [`ResolvedAsrMode`]. Every refusal +/// lands on [`RefinerMode::Off`]: a missing consent can suppress cloud, but it +/// can never promote local weights nobody opted into. +pub fn refiner_for(resolved: &ResolvedAsrMode) -> RefinerMode { + match resolved.mode { + AsrProductMode::Cloud => match authorize_cloud_egress(&resolved.consent) { + Ok(_) => RefinerMode::CloudSession, + Err(CloudSessionError::ConsentRequired) => RefinerMode::Off, + }, + AsrProductMode::LocalPower => RefinerMode::LocalHelper, + AsrProductMode::AppleOnly => RefinerMode::Off, + } +} + +/// Consent-gate unit contracts; the fleet-level witness lives in +/// `crate::stt::fleet_red_contracts`. +#[cfg(test)] +mod tests { + use super::*; + use crate::config::cloud_asr::{ConsentSource, resolve_asr_product_mode}; + + /// Unanswered and denied consent both refuse authorization with the typed + /// error; a granted record (either source) passes. + #[test] + fn consent_gate_refuses_without_explicit_grant() { + for refused in [AudioEgressConsent::Unanswered, AudioEgressConsent::Denied] { + assert_eq!( + authorize_cloud_egress(&refused), + Err(CloudSessionError::ConsentRequired) + ); + } + for source in [ + ConsentSource::ExplicitSettings, + ConsentSource::LegacyCloudChoice, + ] { + assert!(authorize_cloud_egress(&AudioEgressConsent::Granted(source)).is_ok()); + } + } + + /// Mode-to-refiner mapping: consent-backed cloud arms the cloud session, + /// explicit local power arms the helper, and every refusal is Off — the + /// degraded shape can never be a local model load. + #[test] + fn refiner_mapping_degrades_to_off_never_local() { + let cloud = resolve_asr_product_mode(Some("cloud"), Some("granted"), None); + assert_eq!(refiner_for(&cloud), RefinerMode::CloudSession); + + let local = resolve_asr_product_mode(Some("local_power"), None, None); + assert_eq!(refiner_for(&local), RefinerMode::LocalHelper); + + for resolved in [ + resolve_asr_product_mode(Some("cloud"), None, None), + resolve_asr_product_mode(Some("cloud"), Some("denied"), Some(true)), + resolve_asr_product_mode(None, None, None), + resolve_asr_product_mode(Some("apple_only"), Some("granted"), None), + ] { + assert_eq!( + refiner_for(&resolved), + RefinerMode::Off, + "refusal for {resolved:?} must degrade to Off, never LocalHelper" + ); + } + } + + /// The privacy bound on telemetry holds at the type level: session + /// counters are `Copy` (no heap text can hide in them), and the typed + /// error vocabulary carries no payload a transcript could ride on. + #[test] + fn telemetry_and_errors_stay_content_free() { + fn assert_copy() {} + assert_copy::(); + assert_copy::(); + assert_copy::(); + } +} diff --git a/core/asr_session/events.rs b/core/asr_session/events.rs new file mode 100644 index 00000000..6ae178f7 --- /dev/null +++ b/core/asr_session/events.rs @@ -0,0 +1,290 @@ +//! Typed Layer 1 session events. +//! +//! Every event a Layer 1 provider emits carries three identity fields: +//! +//! - **session** — which recording this belongs to. A provider that reconnects +//! and resumes the wrong stream is caught here rather than downstream. +//! - **utterance** — which speech unit inside the session. Sealing is +//! per-utterance, so a late partial cannot reopen committed text. +//! - **sequence** — Codescribe's stream-global monotonic counter, assigned by +//! the session adapter after provider duplicates and stale per-utterance +//! revisions are removed. A provider's sequence scope is never trusted. +//! +//! Partial versus final is a **variant**, not a boolean flag, so a caller +//! cannot forget to check it — the compiler makes the finality decision +//! explicit at every match site. +//! +//! Errors and usage are typed with no free-form string payload. That is a +//! deliberate privacy boundary: a `String` on an error is exactly where a +//! transcript fragment, an audio path, or a bearer token ends up in a log. + +use std::fmt; + +/// Per-recording session identity minted when a Layer 1 session opens. +/// +/// Opaque on purpose: the desktop consumes whatever the gateway/session mint +/// hands it and never parses meaning out of it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionId(String); + +impl SessionId { + /// Build a session id, rejecting blank input. + /// + /// An empty id would make every session compare equal, which turns the + /// foreign-session guard in [`super::ingest`] into a no-op. + pub fn new(raw: impl Into) -> Option { + let raw = raw.into(); + if raw.trim().is_empty() { + return None; + } + Some(Self(raw)) + } + + /// Borrow the raw id. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SessionId { + /// Print the raw id (it is an opaque handle, not a secret). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Session, utterance, and sequence identity carried by every event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventIdentity { + /// Which session this event belongs to. + session_id: SessionId, + /// Which utterance inside the session. + utterance_id: u64, + /// Codescribe-owned stream-global monotonic counter; the ordering authority. + sequence_id: u64, +} + +impl EventIdentity { + /// Build an identity triple. + pub fn new(session_id: SessionId, utterance_id: u64, sequence_id: u64) -> Self { + Self { + session_id, + utterance_id, + sequence_id, + } + } + + /// Session this event belongs to. + pub fn session_id(&self) -> &SessionId { + &self.session_id + } + + /// Utterance this event belongs to. + pub fn utterance_id(&self) -> u64 { + self.utterance_id + } + + /// Monotonic stream position of this event. + pub fn sequence_id(&self) -> u64 { + self.sequence_id + } +} + +/// A bounded span of session audio an event describes. +/// +/// Session time, measured in seconds from the first captured sample — the same +/// clock the Apple progressive path derives `audio_secs` from. It is neither +/// wall clock nor the capture device clock. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AudioRange { + /// Inclusive start in session seconds. + start_secs: f32, + /// Exclusive end in session seconds. + end_secs: f32, +} + +impl AudioRange { + /// Widest span an event may claim. + /// + /// Pinned to the live PCM ring's retention rather than restated, so the two + /// cannot drift: a range wider than what the session still holds describes + /// audio nothing can re-read, and a consumer resolving it would be handed a + /// silently short window. + pub const MAX_SPAN_SECS: f32 = + crate::pipeline::streaming::live_audio_buffer::DEFAULT_RETENTION_SECS; + + /// Build a range, refusing anything that is not a usable span. + /// + /// Rejects non-finite bounds (`f32 as u64` maps NaN to 0 and saturates + /// infinities, turning a corrupt timestamp into a plausible window), + /// negative starts, inverted or empty spans, and spans past + /// [`Self::MAX_SPAN_SECS`]. + pub fn new(start_secs: f32, end_secs: f32) -> Option { + if !start_secs.is_finite() || !end_secs.is_finite() { + return None; + } + if start_secs < 0.0 || end_secs <= start_secs { + return None; + } + if end_secs - start_secs > Self::MAX_SPAN_SECS { + return None; + } + Some(Self { + start_secs, + end_secs, + }) + } + + /// Inclusive start in session seconds. + pub fn start_secs(&self) -> f32 { + self.start_secs + } + + /// Exclusive end in session seconds. + pub fn end_secs(&self) -> f32 { + self.end_secs + } + + /// Span length in seconds. + pub fn duration_secs(&self) -> f32 { + self.end_secs - self.start_secs + } +} + +/// Why a Layer 1 session failed — typed, with no free-form payload. +/// +/// Every variant means the same thing to the product: the refiner is gone for +/// now and the canvas plus lexicon carry the session. The distinction exists so +/// a caller can decide whether retrying is worth anything, never so an error +/// message can be shown verbatim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AsrErrorKind { + /// Connection dropped, timed out, or was never established. + Transport, + /// Session credentials were rejected or expired. + Auth, + /// Provider asked us to slow down. + RateLimited, + /// The gateway reports that the account has no remaining quota. + Quota, + /// Our side could not keep up and dropped bounded work. + Overflow, + /// Provider does not support what the session asked for (locale, mode). + Unsupported, + /// Provider spoke something this contract cannot parse. + Protocol, + /// The session was closed by us before it produced a final. + Cancelled, +} + +impl AsrErrorKind { + /// Whether reopening the session could plausibly succeed. + /// + /// `Auth`, `Unsupported`, and `Protocol` are settings- or contract-level + /// faults: retrying them just burns audio egress for the same failure. + pub fn is_retryable(&self) -> bool { + match self { + Self::Transport | Self::RateLimited | Self::Overflow => true, + Self::Auth | Self::Quota | Self::Unsupported | Self::Protocol | Self::Cancelled => { + false + } + } + } + + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Transport => "transport", + Self::Auth => "auth", + Self::RateLimited => "rate_limited", + Self::Quota => "quota", + Self::Overflow => "overflow", + Self::Unsupported => "unsupported", + Self::Protocol => "protocol", + Self::Cancelled => "cancelled", + } + } +} + +impl fmt::Display for AsrErrorKind { + /// Print the stable token — never a provider-supplied string. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_token()) + } +} + +/// Recognized text for one utterance, partial or final. +#[derive(Debug, Clone, PartialEq)] +pub struct TranscriptEvent { + /// Session, utterance, and sequence identity. + pub identity: EventIdentity, + /// The recognized text. Layer 1 output is a *candidate*; committing it is + /// the caller's decision and is bounded by the append-only doctrine. + pub text: String, + /// Session-time span this text came from, when the provider reports one. + pub range: Option, +} + +/// A typed session failure. +#[derive(Debug, Clone, PartialEq)] +pub struct ErrorEvent { + /// Session, utterance, and sequence identity. + pub identity: EventIdentity, + /// What went wrong. + pub kind: AsrErrorKind, +} + +/// Consumption accounting for one session — no content, ever. +#[derive(Debug, Clone, PartialEq)] +pub struct UsageEvent { + /// Session, utterance, and sequence identity. + pub identity: EventIdentity, + /// Audio seconds the provider processed. + pub audio_secs: f32, + /// Provider-side billable units, when it reports them. + pub billable_units: Option, +} + +/// Everything a Layer 1 provider can emit. +#[derive(Debug, Clone, PartialEq)] +pub enum AsrSessionEvent { + /// Volatile hypothesis; may be revised by a later partial or the final. + Partial(TranscriptEvent), + /// Sealing result for an utterance. Re-delivery is idempotent. + Final(TranscriptEvent), + /// Typed failure. + Error(ErrorEvent), + /// Consumption accounting. + Usage(UsageEvent), +} + +impl AsrSessionEvent { + /// Identity triple carried by this event. + pub fn identity(&self) -> &EventIdentity { + match self { + Self::Partial(event) | Self::Final(event) => &event.identity, + Self::Error(event) => &event.identity, + Self::Usage(event) => &event.identity, + } + } + + /// Whether this event carries recognized text. + pub fn is_transcript(&self) -> bool { + matches!(self, Self::Partial(_) | Self::Final(_)) + } + + /// Whether this event seals its utterance. + pub fn is_final(&self) -> bool { + matches!(self, Self::Final(_)) + } + + /// Stable snake_case variant token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Partial(_) => "partial", + Self::Final(_) => "final", + Self::Error(_) => "error", + Self::Usage(_) => "usage", + } + } +} diff --git a/core/asr_session/fake.rs b/core/asr_session/fake.rs new file mode 100644 index 00000000..1976413b --- /dev/null +++ b/core/asr_session/fake.rs @@ -0,0 +1,167 @@ +//! A deterministic Layer 1 provider that talks to nothing. +//! +//! The real providers (a gateway websocket, a killable local helper) are later +//! cuts. Their tests will need something that produces the *shape* of a live +//! session — lifecycle faults, partials landing before finals, a trailing usage +//! record — without a socket, a model, a thread, or a clock. This is that +//! something. +//! +//! Everything it does is a pure function of the script it was built with and +//! the calls it received. There is no timing, no randomness, and no I/O, so a +//! test that passes here passes on a loaded machine too. + +use std::collections::VecDeque; + +use super::events::{AsrErrorKind, AsrSessionEvent, EventIdentity, SessionId, UsageEvent}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; + +/// Utterance id the fake stamps on session-scoped records (its closing usage +/// event), which describe the whole session rather than one speech unit. +const SESSION_SCOPE_UTTERANCE: u64 = 0; + +/// Where a fake session is in its lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// Constructed, not yet opened. + Idle, + /// Open and accepting audio. + Open, + /// Closed; no further audio accepted. + Closed, +} + +/// A scripted, in-memory [`AsrSessionProvider`]. +#[derive(Debug)] +pub struct FakeAsrSessionProvider { + /// Mode this fake claims to implement. + mode: RefinerMode, + /// Events still waiting to be released, in script order. + script: VecDeque, + /// Events released and not yet drained. + ready: Vec, + /// Lifecycle position. + state: State, + /// Session identity captured at open. + session_id: Option, + /// Sample rate captured at open, floored at 1 to keep the clock finite. + sample_rate: u32, + /// Total samples pushed, the fake's only notion of time. + pushed_samples: u64, + /// Highest sequence released so far, so the closing usage event stays + /// monotonic whatever the script did. + highest_sequence: Option, + /// When set, every `push_audio` fails with this kind. + push_failure: Option, +} + +impl FakeAsrSessionProvider { + /// Build a fake that will release `script` one event per pushed chunk. + pub fn with_script(mode: RefinerMode, script: Vec) -> Self { + Self { + mode, + script: script.into(), + ready: Vec::new(), + state: State::Idle, + session_id: None, + sample_rate: 1, + pushed_samples: 0, + highest_sequence: None, + push_failure: None, + } + } + + /// Build a fake with no scripted transcript events. + pub fn new(mode: RefinerMode) -> Self { + Self::with_script(mode, Vec::new()) + } + + /// Make every `push_audio` fail with `kind` — the degradation harness. + pub fn failing_pushes(mut self, kind: AsrErrorKind) -> Self { + self.push_failure = Some(kind); + self + } + + /// Session seconds derived from pushed audio. + pub fn pushed_secs(&self) -> f32 { + self.pushed_samples as f32 / self.sample_rate as f32 + } + + /// Whether the script has been fully released. + pub fn script_drained(&self) -> bool { + self.script.is_empty() + } + + /// Move one scripted event to the ready queue, tracking its sequence. + fn release_one(&mut self) { + if let Some(event) = self.script.pop_front() { + let sequence = event.identity().sequence_id(); + self.highest_sequence = Some(match self.highest_sequence { + Some(previous) => previous.max(sequence), + None => sequence, + }); + self.ready.push(event); + } + } + + /// Sequence to stamp on the fake's own closing usage event. + fn next_sequence(&self) -> u64 { + self.highest_sequence.map_or(0, |highest| highest + 1) + } +} + +impl AsrSessionProvider for FakeAsrSessionProvider { + /// Mode this fake was built for. + fn mode(&self) -> RefinerMode { + self.mode + } + + /// Open once; a second open is a protocol fault, not a panic. + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind> { + if self.state != State::Idle { + return Err(AsrErrorKind::Protocol); + } + self.session_id = Some(input.session_id.clone()); + self.sample_rate = input.sample_rate.max(1); + self.state = State::Open; + Ok(()) + } + + /// Accept a chunk and release the next scripted event. + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind> { + if self.state != State::Open { + return Err(AsrErrorKind::Protocol); + } + if let Some(kind) = self.push_failure { + return Err(kind); + } + self.pushed_samples += samples.len() as u64; + self.release_one(); + Ok(()) + } + + /// Hand over everything released so far. + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.ready) + } + + /// Close, flushing the rest of the script and a usage record behind it. + fn close(&mut self) -> Result<(), AsrErrorKind> { + if self.state != State::Open { + return Err(AsrErrorKind::Protocol); + } + while !self.script.is_empty() { + self.release_one(); + } + if let Some(session_id) = self.session_id.clone() { + let identity = + EventIdentity::new(session_id, SESSION_SCOPE_UTTERANCE, self.next_sequence()); + self.ready.push(AsrSessionEvent::Usage(UsageEvent { + identity, + audio_secs: self.pushed_secs(), + billable_units: None, + })); + } + self.state = State::Closed; + Ok(()) + } +} diff --git a/core/asr_session/ingest.rs b/core/asr_session/ingest.rs new file mode 100644 index 00000000..c1dec94c --- /dev/null +++ b/core/asr_session/ingest.rs @@ -0,0 +1,191 @@ +//! Ordering and idempotence for a Layer 1 event stream. +//! +//! A live provider is not a well-behaved iterator. It reconnects and replays, +//! it re-sends a final it is not sure we received, and a slow frame can arrive +//! after the frame that supersedes it. Arrival order is therefore not ordering. +//! The provider adapter removes replays and stale provider revisions, then +//! assigns the Codescribe-owned stream-global `sequence_id` consumed here. +//! +//! [`SessionIngest`] is the one place that decision is made, so no downstream +//! consumer has to re-derive it and none of them can disagree. It holds no +//! audio, spawns nothing, and reads no clock: the same event sequence always +//! produces the same verdicts. +//! +//! ## The rules, in order +//! +//! 1. An event for another session is refused outright. +//! 2. A final identical to the one that already sealed its utterance is +//! **idempotent** — accepted-in-effect, applied once. This is the reconnect +//! resend, and it may legitimately arrive after newer events. +//! 3. A byte-identical repeat of the last accepted event is likewise idempotent. +//! 4. Anything else at or below the highest accepted sequence is out of order +//! and refused. Late text must never overwrite newer text. +//! 5. A partial or final aimed at a sealed utterance is refused. A final is a +//! commitment; reopening it is the replacement this product forbids. +//! 6. Otherwise the event is accepted, and a final seals its utterance. +//! +//! Errors and usage are diagnostics, not text, so they are not blocked by a +//! sealed utterance — only by ordering. + +use std::collections::BTreeMap; + +use super::events::{AsrSessionEvent, SessionId, TranscriptEvent}; + +/// What [`SessionIngest::ingest`] decided about one event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IngestVerdict { + /// Applied; it advanced the stream. + Accepted, + /// Already applied. Re-delivery changed nothing, which is the point. + DuplicateIdempotent, + /// At or below the highest accepted sequence, and not a known duplicate. + RejectedOutOfOrder, + /// Aimed at an utterance a final already sealed. + RejectedSealedUtterance, + /// Belongs to a different session. + RejectedForeignSession, +} + +impl IngestVerdict { + /// Whether the event joined the accepted stream. + pub fn is_accepted(&self) -> bool { + matches!(self, Self::Accepted) + } + + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::DuplicateIdempotent => "duplicate_idempotent", + Self::RejectedOutOfOrder => "rejected_out_of_order", + Self::RejectedSealedUtterance => "rejected_sealed_utterance", + Self::RejectedForeignSession => "rejected_foreign_session", + } + } +} + +/// Monotonic, idempotent ledger for one Layer 1 session. +#[derive(Debug)] +pub struct SessionIngest { + /// The only session whose events this ledger accepts. + session_id: SessionId, + /// Highest accepted sequence, or `None` before the first accepted event. + last_sequence: Option, + /// The last accepted event, for same-sequence duplicate detection. + last_accepted: Option, + /// The final that sealed each utterance. + sealed: BTreeMap, + /// Accepted events, in accepted order. + accepted: Vec, + /// How many re-deliveries were absorbed idempotently. + duplicate_count: u64, + /// How many events were refused as out of order. + out_of_order_count: u64, + /// How many transcript events were refused by a sealed utterance. + sealed_rejection_count: u64, + /// How many events were refused as belonging to another session. + foreign_rejection_count: u64, +} + +impl SessionIngest { + /// Open a ledger bound to one session. + pub fn new(session_id: SessionId) -> Self { + Self { + session_id, + last_sequence: None, + last_accepted: None, + sealed: BTreeMap::new(), + accepted: Vec::new(), + duplicate_count: 0, + out_of_order_count: 0, + sealed_rejection_count: 0, + foreign_rejection_count: 0, + } + } + + /// Apply one event and report what was decided. + pub fn ingest(&mut self, event: AsrSessionEvent) -> IngestVerdict { + let identity = event.identity(); + + if identity.session_id() != &self.session_id { + self.foreign_rejection_count += 1; + return IngestVerdict::RejectedForeignSession; + } + + let utterance_id = identity.utterance_id(); + let sequence_id = identity.sequence_id(); + let sealed_final = self.sealed.get(&utterance_id); + + // Rule 2: the reconnect resend. Position in the stream is irrelevant — + // an identical final says exactly what we already committed. + if let (AsrSessionEvent::Final(incoming), Some(existing)) = (&event, sealed_final) + && incoming == existing + { + self.duplicate_count += 1; + return IngestVerdict::DuplicateIdempotent; + } + + match self.last_sequence { + Some(last) if sequence_id == last && self.last_accepted.as_ref() == Some(&event) => { + // Rule 3: same slot, same content — a retransmit, not a change. + self.duplicate_count += 1; + return IngestVerdict::DuplicateIdempotent; + } + Some(last) if sequence_id <= last => { + // Rule 4: stale. Applying it would let older text win. + self.out_of_order_count += 1; + return IngestVerdict::RejectedOutOfOrder; + } + _ => {} + } + + // Rule 5: a final is a commitment; nothing may reopen it. + if event.is_transcript() && sealed_final.is_some() { + self.sealed_rejection_count += 1; + return IngestVerdict::RejectedSealedUtterance; + } + + if let AsrSessionEvent::Final(transcript) = &event { + self.sealed.insert(utterance_id, transcript.clone()); + } + self.last_sequence = Some(sequence_id); + self.last_accepted = Some(event.clone()); + self.accepted.push(event); + IngestVerdict::Accepted + } + + /// Accepted events, in accepted order. + pub fn accepted(&self) -> &[AsrSessionEvent] { + &self.accepted + } + + /// The final that sealed `utterance_id`, if one has. + pub fn sealed_final(&self, utterance_id: u64) -> Option<&TranscriptEvent> { + self.sealed.get(&utterance_id) + } + + /// Highest accepted sequence, or `None` before the first accepted event. + pub fn last_sequence(&self) -> Option { + self.last_sequence + } + + /// Re-deliveries absorbed idempotently. + pub fn duplicate_count(&self) -> u64 { + self.duplicate_count + } + + /// Events refused as out of order. + pub fn out_of_order_count(&self) -> u64 { + self.out_of_order_count + } + + /// Transcript events refused by a sealed utterance. + pub fn sealed_rejection_count(&self) -> u64 { + self.sealed_rejection_count + } + + /// Events refused as belonging to another session. + pub fn foreign_rejection_count(&self) -> u64 { + self.foreign_rejection_count + } +} diff --git a/core/asr_session/local_helper.rs b/core/asr_session/local_helper.rs new file mode 100644 index 00000000..7a202767 --- /dev/null +++ b/core/asr_session/local_helper.rs @@ -0,0 +1,294 @@ +//! Killable local Layer 1 helper boundary. +//! +//! Local weights never belong to the GUI process. This provider owns only a +//! child-process contract injected by the selected power-user runtime. The +//! concrete Qwen/Parakeet runner remains outside the app and outside this +//! crate; no model library is linked here and no failed helper can fall back to +//! in-process Whisper. +//! +//! Process exit is the reclaim authority. [`LocalHelperLifecycle::Stopped`] +//! after a session means the child was waited and reported exited — dropping a +//! handle or sending a shutdown request is not enough. + +use super::events::{AsrErrorKind, AsrSessionEvent}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; + +/// Observable local-helper lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalHelperLifecycle { + /// No child is owned. Initial state and confirmed post-exit state. + Stopped, + /// A child is being spawned and its session is being opened. + Starting, + /// The child accepted the session and can consume PCM. + Ready, + /// Shutdown was requested and the provider is waiting for process exit. + Cooling, +} + +/// Proof returned only after the operating system reports process exit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LocalHelperExit { + /// PID observed from the child handle. + pub pid: u32, + /// True only after a successful wait/reap. + pub exited: bool, +} + +/// One spawned helper process. +/// +/// Implementations own IPC and their bounded shutdown deadline. Both +/// [`Self::wait_for_exit`] and [`Self::kill_and_wait`] must reap the process; +/// returning `exited: false` is treated as a transport failure, never reclaim. +pub trait LocalHelperProcess: Send { + /// Operating-system process identifier. + fn pid(&self) -> u32; + + /// Open one provider-compatible ASR session in the child. + fn start(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind>; + + /// Forward one PCM chunk without blocking the Apple capture lane. + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind>; + + /// Drain typed events already available from the child. Never blocks. + fn drain(&mut self) -> Vec; + + /// Ask the child to finish its current session and exit. + fn request_shutdown(&mut self) -> Result<(), AsrErrorKind>; + + /// Wait within the implementation's bounded graceful-exit deadline. + fn wait_for_exit(&mut self) -> Result; + + /// Kill, then wait and reap. This is the final reclaim backstop. + fn kill_and_wait(&mut self) -> Result; +} + +/// Injected process factory. +/// +/// The stock app has no default implementation. A power-user runtime must make +/// an explicit model/download decision and inject its launcher. +pub trait LocalHelperLauncher: Send { + /// Spawn one helper process with no weights in the caller process. + fn spawn(&mut self) -> Result, AsrErrorKind>; +} + +/// Provider-compatible owner of a killable local helper. +pub struct LocalHelperAsrSession { + launcher: Box, + child: Option>, + lifecycle: LocalHelperLifecycle, + transitions: Vec, + last_exit: Option, + ever_opened: bool, +} + +impl LocalHelperAsrSession { + /// Build a stopped provider around an explicit launcher. + pub fn new(launcher: Box) -> Self { + Self { + launcher, + child: None, + lifecycle: LocalHelperLifecycle::Stopped, + transitions: vec![LocalHelperLifecycle::Stopped], + last_exit: None, + ever_opened: false, + } + } + + /// Current lifecycle state. + pub fn lifecycle(&self) -> LocalHelperLifecycle { + self.lifecycle + } + + /// Exact transition history, including the initial stopped state. + pub fn transitions(&self) -> &[LocalHelperLifecycle] { + &self.transitions + } + + /// PID of the currently owned child, if any. + pub fn child_pid(&self) -> Option { + self.child.as_ref().map(|child| child.pid()) + } + + /// Last confirmed process-exit proof. + pub fn last_exit(&self) -> Option { + self.last_exit + } + + fn transition(&mut self, next: LocalHelperLifecycle) { + self.lifecycle = next; + self.transitions.push(next); + } + + /// Reclaim `child`, preferring graceful exit and always falling back to a + /// kill+wait when graceful shutdown is refused or unconfirmed. + fn reclaim_child(child: &mut dyn LocalHelperProcess) -> Result { + let graceful = child + .request_shutdown() + .and_then(|()| child.wait_for_exit()); + match graceful { + Ok(proof) if proof.pid != 0 && proof.exited => Ok(proof), + Ok(_) | Err(_) => { + let proof = child.kill_and_wait()?; + if proof.pid == 0 || !proof.exited { + return Err(AsrErrorKind::Transport); + } + Ok(proof) + } + } + } + + fn stop_owned_child(&mut self) -> Result<(), AsrErrorKind> { + let Some(mut child) = self.child.take() else { + self.transition(LocalHelperLifecycle::Stopped); + return Ok(()); + }; + match Self::reclaim_child(child.as_mut()) { + Ok(proof) => { + self.last_exit = Some(proof); + self.transition(LocalHelperLifecycle::Stopped); + Ok(()) + } + Err(kind) => { + // We no longer claim Stopped: process exit was not proven. + self.child = Some(child); + Err(kind) + } + } + } +} + +impl AsrSessionProvider for LocalHelperAsrSession { + fn mode(&self) -> RefinerMode { + RefinerMode::LocalHelper + } + + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind> { + if self.lifecycle != LocalHelperLifecycle::Stopped || self.ever_opened { + return Err(AsrErrorKind::Protocol); + } + self.ever_opened = true; + self.transition(LocalHelperLifecycle::Starting); + + let mut child = match self.launcher.spawn() { + Ok(child) if child.pid() != 0 => child, + Ok(mut child) => { + let _ = Self::reclaim_child(child.as_mut()); + self.transition(LocalHelperLifecycle::Stopped); + return Err(AsrErrorKind::Protocol); + } + Err(kind) => { + self.transition(LocalHelperLifecycle::Stopped); + return Err(kind); + } + }; + + if let Err(kind) = child.start(input) { + let reclaim = Self::reclaim_child(child.as_mut()); + if let Ok(proof) = reclaim { + self.last_exit = Some(proof); + self.transition(LocalHelperLifecycle::Stopped); + return Err(kind); + } + self.child = Some(child); + return Err(AsrErrorKind::Transport); + } + + self.child = Some(child); + self.transition(LocalHelperLifecycle::Ready); + Ok(()) + } + + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind> { + if self.lifecycle != LocalHelperLifecycle::Ready { + return Err(AsrErrorKind::Protocol); + } + let result = self + .child + .as_mut() + .ok_or(AsrErrorKind::Protocol)? + .push_audio(samples); + if let Err(kind) = result { + self.transition(LocalHelperLifecycle::Cooling); + self.stop_owned_child()?; + return Err(kind); + } + Ok(()) + } + + fn drain(&mut self) -> Vec { + if self.lifecycle != LocalHelperLifecycle::Ready { + return Vec::new(); + } + self.child + .as_mut() + .map_or_else(Vec::new, |child| child.drain()) + } + + fn close(&mut self) -> Result<(), AsrErrorKind> { + if self.lifecycle != LocalHelperLifecycle::Ready { + return Err(AsrErrorKind::Protocol); + } + self.transition(LocalHelperLifecycle::Cooling); + self.stop_owned_child() + } +} + +impl Drop for LocalHelperAsrSession { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() + && let Ok(proof) = Self::reclaim_child(child.as_mut()) + { + self.last_exit = Some(proof); + self.lifecycle = LocalHelperLifecycle::Stopped; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FailingLauncher; + + impl LocalHelperLauncher for FailingLauncher { + fn spawn(&mut self) -> Result, AsrErrorKind> { + Err(AsrErrorKind::Transport) + } + } + + fn input() -> SessionInput { + SessionInput { + session_id: super::super::SessionId::new("local-helper-test").expect("session id"), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } + } + + #[test] + #[serial_test::serial] + fn spawn_failure_returns_to_stopped_without_model_fallback() { + let whisper_before = crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls(); + let provider = LocalHelperAsrSession::new(Box::new(FailingLauncher)); + let mut lane = super::super::RecorderLayer1Lane::open( + super::super::Layer1Decision::Armed(Box::new(provider)), + &input(), + ); + + assert_eq!( + lane.state(), + super::super::Layer1LaneState::Degraded(super::super::Layer1DegradeReason::OpenFailed( + AsrErrorKind::Transport + )) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + assert!(lane.stop().finals().is_empty()); + assert_eq!( + crate::stt::whisper::singleton::test_init_calls() + + crate::stt::whisper::singleton::test_load_calls(), + whisper_before, + "a failed local helper must remain Apple + lexicon, never initialize in-process Whisper" + ); + } +} diff --git a/core/asr_session/mod.rs b/core/asr_session/mod.rs new file mode 100644 index 00000000..84b451d1 --- /dev/null +++ b/core/asr_session/mod.rs @@ -0,0 +1,104 @@ +//! Neutral Layer 1 ASR session contract. +//! +//! Layer 0 (the Apple live canvas) draws instantly and owns committed text. +//! Layer 1 is a *refiner*: it may fill gaps and patch tails, and it may never +//! rewrite what the canvas already committed. This module is the seam that +//! Layer 1 providers plug into — typed and vendor-neutral. +//! +//! ## What lives here +//! +//! - [`events`] — the typed event vocabulary: every event carries session, +//! utterance and sequence identity, partial-vs-final is a variant (not a +//! boolean), the audio span is optional and bounded, and errors/usage are +//! typed with no free-form payload. +//! - [`ingest`] — the ordering state machine: monotonic sequencing, idempotent +//! duplicate finals, and a sealed utterance that no later partial can reopen. +//! - [`provider`] — [`AsrSessionProvider`] plus the selection types that keep +//! Layer 0 canvas choice and Layer 1 refiner mode on two separate axes. +//! - [`fake`] — a deterministic in-memory provider for tests and later cuts. +//! - [`cloud`] — the dedicated live gateway session, bounded PCM transport, +//! Voice Lab wire adapter, and Codescribe-owned stream-global event +//! sequencing. +//! - [`local_helper`] — the provider-compatible, injected child-process +//! boundary whose confirmed exit is the local-weight reclaim authority. +//! - [`bootstrap`] — the recording-start join between persisted mode/consent +//! truth and a validated live endpoint. +//! - [`recorder`] — the per-recording lane the live session drives: injected +//! [`recorder::Layer1Decision`], bounded non-blocking PCM fan-out, volatile +//! partial draft, and typed degrade paths that always land on canvas + +//! lexicon. +//! - [`consent`] — the audio-egress gate: a cloud session is constructible +//! only through an explicit-consent authorization witness, and every +//! refusal degrades to canvas + lexicon, never a local model. +//! +//! ## What deliberately does NOT live here +//! +//! No settings UI or local model. The Voice Lab wire is isolated behind the +//! normalized provider contract; the mode/consent *records* live in +//! `crate::config::cloud_asr` (the settings brain), while [`bootstrap`] joins +//! that truth to the recorder. +//! +//! The existing whole-file `client::transcribe_cloud` API is **outside** this +//! contract. It uploads one completed recording only for explicit retranscribe +//! surfaces — normal recording never routes that file pass through this seam. +//! +//! ## Doctrine encoded in the types +//! +//! - A refiner failure degrades to canvas + lexicon +//! ([`LayerSelection::degraded`]); it can never swap the canvas engine, and +//! nothing here can trigger a local model load. +//! - A final seals its utterance. Re-delivery of that same final is idempotent; +//! anything else aimed at a sealed utterance is refused rather than applied. +//! - Errors carry a typed kind and nothing else, so no transcript fragment, +//! audio, or credential can ride an error into a log line. + +/// Recording-start mode/consent/gateway integration for the real recorder path. +pub mod bootstrap; +/// Dedicated provider-neutral live cloud gateway transport and session adapter. +pub mod cloud; +/// Audio-egress consent gate in front of Layer 1 session construction (C2). +pub mod consent; +/// Typed Layer 1 session events, identity, bounded ranges, errors, and usage. +pub mod events; +/// Deterministic in-memory provider used by tests and follow-on transport cuts. +pub mod fake; +/// Ordering state machine: monotonic sequencing and idempotent duplicate finals. +pub mod ingest; +/// Killable local-helper lifecycle and injected process boundary (L0). +pub mod local_helper; +/// Provider trait plus the canvas/refiner selection split. +pub mod provider; +/// Recorder-side Layer 1 lane: injected decision, bounded fan-out, degrade paths. +pub mod recorder; + +#[cfg(test)] +mod tests; + +pub use bootstrap::{ + GatewaySessionAvailability, gateway_session_availability, layer1_decision_for_recording, +}; +pub use cloud::{ + CloudGatewayTransport, CloudSessionLimits, CloudSessionTelemetry, GatewayConnection, + GatewayErrorCode, GatewayEvent, GatewayPcmFrame, GatewaySessionConfig, GatewayTransportPoll, + GatewayWebSocketTransport, LiveCloudAsrSession, +}; +pub use consent::{ + CloudEgressAuthorization, CloudSessionError, authorize_cloud_egress, refiner_for, +}; +pub use events::{ + AsrErrorKind, AsrSessionEvent, AudioRange, ErrorEvent, EventIdentity, SessionId, + TranscriptEvent, UsageEvent, +}; +pub use fake::FakeAsrSessionProvider; +pub use ingest::{IngestVerdict, SessionIngest}; +pub use local_helper::{ + LocalHelperAsrSession, LocalHelperExit, LocalHelperLauncher, LocalHelperLifecycle, + LocalHelperProcess, +}; +pub use provider::{AsrSessionProvider, CanvasEngine, LayerSelection, RefinerMode, SessionInput}; +pub use recorder::{ + FanOutVerdict, LAYER1_DEGRADED_WARNING_CODE, Layer1Decision, Layer1DegradeReason, + Layer1LaneState, Layer1LaneTelemetry, Layer1SessionOutcome, RecorderLayer1Lane, + RecorderLifecycleEvent, RecorderLifecycleEvents, RecorderLifecycleHandle, + apply_recorder_lifecycle_event, recorder_lifecycle_channel, +}; diff --git a/core/asr_session/provider.rs b/core/asr_session/provider.rs new file mode 100644 index 00000000..6b721222 --- /dev/null +++ b/core/asr_session/provider.rs @@ -0,0 +1,172 @@ +//! The Layer 1 provider seam, and the selection split it depends on. +//! +//! ## Two axes, not one dial +//! +//! Which engine draws the live canvas (Layer 0) and which refiner improves it +//! (Layer 1) are independent choices, and [`LayerSelection`] is what keeps them +//! that way. Collapsing them into a single "engine" setting is how a Layer 1 +//! failure ends up silently changing what the user sees being typed, and how a +//! refiner choice ends up loading local weights nobody asked for. +//! +//! The canvas axis already has an owner — the STT router's +//! `CODESCRIBE_STT_ENGINE` policy. [`LayerSelection::for_active_canvas`] reads +//! that decision rather than restating it, so this module can never become a +//! second, disagreeing source of truth about the canvas. +//! +//! The trait stays independent of transport and consent policy. The cloud +//! implementation in [`super::cloud`] supplies a normalized gateway transport +//! and bounded drain policy; recorder wiring and the consent gate remain +//! separate owners. + +use super::events::{AsrErrorKind, AsrSessionEvent, SessionId}; + +/// Which engine draws the instant live canvas (Layer 0). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanvasEngine { + /// Apple Speech — the letter-level instant canvas, the product default. + AppleSpeech, + /// Local Whisper, when Apple is unavailable or explicitly overridden. + LocalWhisper, +} + +impl CanvasEngine { + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::AppleSpeech => "apple_speech", + Self::LocalWhisper => "local_whisper", + } + } +} + +/// Which Layer 1 refiner is armed. +/// +/// [`RefinerMode::Off`] is a complete, shipping product: canvas plus lexicon. +/// Every failure path in Layer 1 lands here, and landing here is never a +/// degraded-mode apology that justifies loading something heavier instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RefinerMode { + /// No Layer 1. Canvas plus lexicon carries the session. + #[default] + Off, + /// A normalized remote session behind the gateway contract. + CloudSession, + /// A killable local helper process holding its own weights. + LocalHelper, +} + +impl RefinerMode { + /// Whether this mode sends captured audio off the machine. + /// + /// This is the classifier the consent gate ([`super::consent`]) asks. + pub fn sends_audio_off_device(&self) -> bool { + matches!(self, Self::CloudSession) + } + + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::Off => "off", + Self::CloudSession => "cloud_session", + Self::LocalHelper => "local_helper", + } + } +} + +/// The canvas/refiner pair, held together so neither can silently move the +/// other. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LayerSelection { + /// Layer 0 — who draws. + canvas: CanvasEngine, + /// Layer 1 — who refines. + refiner: RefinerMode, +} + +impl LayerSelection { + /// Pair an explicit canvas with an explicit refiner. + pub fn new(canvas: CanvasEngine, refiner: RefinerMode) -> Self { + Self { canvas, refiner } + } + + /// Pair the router's *live* canvas decision with an independent refiner. + /// + /// Reads `stt::active_engine_is_apple`, the same selector the live lane + /// uses, so the canvas reported here is the canvas that will actually draw. + /// The refiner argument is untouched by that read — that independence is + /// the whole point and is pinned by test. + pub fn for_active_canvas(refiner: RefinerMode) -> Self { + let canvas = if crate::stt::active_engine_is_apple() { + CanvasEngine::AppleSpeech + } else { + CanvasEngine::LocalWhisper + }; + Self::new(canvas, refiner) + } + + /// Layer 0 engine. + pub fn canvas(&self) -> CanvasEngine { + self.canvas + } + + /// Layer 1 mode. + pub fn refiner(&self) -> RefinerMode { + self.refiner + } + + /// The selection this degrades to when Layer 1 is unavailable. + /// + /// The canvas is carried through unchanged. A refiner that cannot run is a + /// missing improvement, never a reason to redraw the canvas with a + /// different engine or to reach for local weights. + pub fn degraded(&self) -> Self { + Self { + canvas: self.canvas, + refiner: RefinerMode::Off, + } + } +} + +/// Parameters a Layer 1 session opens with. +/// +/// Deliberately thin: identity, language, and audio format. Credentials, +/// endpoints, and consent are the gateway/settings cuts' business and must not +/// leak into the provider-facing shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionInput { + /// Identity every event from this session must carry. + pub session_id: SessionId, + /// BCP-47 language hint, when the product has one to give. + pub locale: Option, + /// Sample rate of the audio that will be pushed, in Hz. + pub sample_rate: u32, +} + +/// A Layer 1 refiner session. +/// +/// Object-safe on purpose: the recorder cut will hold a +/// `Box` chosen at runtime and must not be generic over +/// the transport. +/// +/// Lifecycle: [`open`](Self::open) once, then any number of +/// [`push_audio`](Self::push_audio) / [`drain`](Self::drain) calls, then +/// [`close`](Self::close). Calling out of order is a [`AsrErrorKind::Protocol`] +/// fault, not a panic — a live session must degrade, never abort the recording. +pub trait AsrSessionProvider { + /// Which refiner mode this provider implements. + fn mode(&self) -> RefinerMode; + + /// Open the session. Called at most once. + fn open(&mut self, input: &SessionInput) -> Result<(), AsrErrorKind>; + + /// Feed captured audio. Session time is derived from what has been pushed, + /// so the caller does not have to keep a second clock in sync. + fn push_audio(&mut self, samples: &[f32]) -> Result<(), AsrErrorKind>; + + /// Take whatever events are ready. Never blocks. + fn drain(&mut self) -> Vec; + + /// Close the session. Trailing events remain available via + /// [`drain`](Self::drain). + fn close(&mut self) -> Result<(), AsrErrorKind>; +} diff --git a/core/asr_session/recorder.rs b/core/asr_session/recorder.rs new file mode 100644 index 00000000..bb731f16 --- /dev/null +++ b/core/asr_session/recorder.rs @@ -0,0 +1,1033 @@ +//! Recorder-side Layer 1 orchestration: the lane a live session drives. +//! +//! This is the C1 seam between audio capture and a Layer 1 refiner. The +//! recorder/session pipeline owns capture and the Apple canvas; this lane owns +//! everything a Layer 1 provider is *allowed* to do while a recording runs: +//! +//! - **Injected authority.** The lane never constructs a provider. It receives +//! a [`Layer1Decision`] — an already-authorized, typed decision made by the +//! consent/settings owner. [`Layer1Decision::Disarmed`] is the +//! stock product: canvas plus lexicon, no error, no fallback loading. +//! - **Bounded, non-blocking fan-out.** [`RecorderLayer1Lane::offer_pcm`] +//! returns immediately on every call. A refiner that cannot keep up costs +//! refinement frames, never capture: sustained overflow degrades the lane to +//! canvas + lexicon instead of ever exerting backpressure on audio. +//! - **Partials are volatile draft.** They live in the lane, are replaced +//! freely, and die with the lane. Nothing here can commit a partial to the +//! canvas. +//! - **Finals go through the doctrine seam.** Every final is vetted by +//! [`SessionIngest`] (ordering, idempotence, sealed utterances) and the +//! session outcome routes through [`crate::quality::merge_live_layer1`] — +//! the live floor is immutable; Layer 1 text can only fill gaps and tails. +//! - **Every failure lands on Apple + lexicon.** Overflow, disconnect, +//! sleep/wake, and an incomplete stop-drain all degrade to +//! [`RefinerMode::Off`]. Nothing in this module can reach local Whisper — +//! there is no import edge to `crate::stt`, and the fleet witness measures +//! the init counters to keep it that way. +//! +//! ## Degrade drops, stop closes +//! +//! [`AsrSessionProvider::close`] is bounded but may block briefly (the cloud +//! session drains its socket tail). Degradation happens on the live session +//! loop, where even a bounded stall would hold up canvas event drainage — so +//! a degrading lane *drops* its provider (the cloud transport aborts its actor +//! on drop) and only the deliberate stop path pays for a graceful close and +//! trailing-event drain. + +use std::collections::BTreeMap; +use std::fmt; + +use tracing::{info, warn}; + +use super::events::{AsrErrorKind, AsrSessionEvent, TranscriptEvent}; +use super::ingest::{IngestVerdict, SessionIngest}; +use super::provider::{AsrSessionProvider, RefinerMode, SessionInput}; +use crate::quality::{Layer1MergedDelivery, merge_live_layer1}; + +/// Consecutive overflowed frames tolerated before the lane degrades. +/// +/// A single full queue is a hiccup and costs one refinement frame. A run of +/// them means the provider is not consuming; continuing to offer audio would +/// only burn CPU converting frames nobody reads. At the expected 200 ms frame +/// cadence this limit degrades after roughly 1.6 s of sustained overflow. +pub const OVERFLOW_DEGRADE_LIMIT: u32 = 8; + +/// Maximum post-close drain iterations before the stop path stops waiting. +/// +/// Each iteration consumes one non-empty [`AsrSessionProvider::drain`] batch. +/// The bound is iterations, not wall time, so tests need no clocks and a +/// misbehaving provider cannot hold the stop path hostage. +pub const STOP_DRAIN_MAX_POLLS: u32 = 32; + +/// `EngineEvent::Warning` code emitted when the live Layer 1 lane degrades. +/// +/// The message carries only the typed reason token — never transcript, audio, +/// or provider payload content. +pub const LAYER1_DEGRADED_WARNING_CODE: &str = "layer1_lane_degraded"; + +/// Host lifecycle boundary delivered to the active recording session. +/// +/// This channel is deliberately per recording. A sleep/wake notification must +/// never create a recorder, retry a provider, or affect a later session that +/// did not cross the boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecorderLifecycleEvent { + /// The host is about to sleep or has just resumed. + SleepWake, +} + +/// O(1) sender retained by the recording owner while one session is active. +#[derive(Debug, Clone)] +pub struct RecorderLifecycleHandle { + sender: tokio::sync::mpsc::UnboundedSender, +} + +impl RecorderLifecycleHandle { + /// Notify the active session of a sleep/wake boundary. + /// + /// Returns false only when the session has already gone away. Sending does + /// no model, disk, network, formatting, or transcript work. + pub fn note_sleep_wake(&self) -> bool { + self.sender.send(RecorderLifecycleEvent::SleepWake).is_ok() + } +} + +/// Receive side owned exclusively by the live transcription task. +#[derive(Debug)] +pub struct RecorderLifecycleEvents { + receiver: tokio::sync::mpsc::UnboundedReceiver, +} + +impl RecorderLifecycleEvents { + /// Wait for the next host lifecycle boundary. + pub async fn recv(&mut self) -> Option { + self.receiver.recv().await + } +} + +/// Create the per-recording lifecycle adapter shared by recorder and session. +pub fn recorder_lifecycle_channel() -> (RecorderLifecycleHandle, RecorderLifecycleEvents) { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + ( + RecorderLifecycleHandle { sender }, + RecorderLifecycleEvents { receiver }, + ) +} + +/// The injected, already-authorized Layer 1 decision a recording starts with. +/// +/// Construction and consent are deliberately *not* this module's business: the +/// settings/consent owner builds the provider and hands the finished decision +/// in. A recording that receives [`Self::Disarmed`] is the normal product — +/// not an error, and never a trigger for loading anything heavier. +pub enum Layer1Decision { + /// No Layer 1 refiner for this recording. Canvas plus lexicon, complete. + Disarmed, + /// An already-authorized provider, ready to open. + Armed(Box), +} + +impl Layer1Decision { + /// Whether this decision carries a provider. + pub fn is_armed(&self) -> bool { + matches!(self, Self::Armed(_)) + } +} + +impl Default for Layer1Decision { + /// The stock product decision: no Layer 1. + fn default() -> Self { + Self::Disarmed + } +} + +impl fmt::Debug for Layer1Decision { + /// Counts-only debug: the provider itself is never printed. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disarmed => f.write_str("Layer1Decision::Disarmed"), + Self::Armed(provider) => f + .debug_struct("Layer1Decision::Armed") + .field("mode", &provider.mode().as_token()) + .finish(), + } + } +} + +/// Why the lane fell back to canvas + lexicon. Typed, content-free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layer1DegradeReason { + /// The provider refused to open. + OpenFailed(AsrErrorKind), + /// Sustained fan-out overflow — the provider stopped consuming. + Overflow, + /// The provider reported or exhibited a session-fatal fault. + Disconnect(AsrErrorKind), + /// The host slept mid-recording; the session is presumed stale. + SleepWake, + /// Stop-drain hit its iteration bound before the provider went quiet. + StopDrainIncomplete, +} + +impl Layer1DegradeReason { + /// Stable snake_case token for logs and telemetry. + pub fn as_token(&self) -> &'static str { + match self { + Self::OpenFailed(_) => "open_failed", + Self::Overflow => "overflow", + Self::Disconnect(_) => "disconnect", + Self::SleepWake => "sleep_wake", + Self::StopDrainIncomplete => "stop_drain_incomplete", + } + } +} + +/// Where the lane is in its lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layer1LaneState { + /// Opened with [`Layer1Decision::Disarmed`] — normal Apple + lexicon. + Unarmed, + /// Provider session open and consuming fan-out. + Live, + /// Layer 1 is gone for this recording; canvas + lexicon carry it. + Degraded(Layer1DegradeReason), + /// The recording stopped and the lane completed its bounded drain. + Stopped, +} + +/// What one fan-out offer did. Informational — capture never branches on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FanOutVerdict { + /// The frame reached the provider. + Forwarded, + /// The provider's queue was full; the frame was dropped, capture continues. + DroppedOverflow, + /// The lane is not live; the frame was ignored. + Inactive, +} + +/// Content-free lane counters, reported at session end. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Layer1LaneTelemetry { + /// Frames capture offered to the lane (including while not live). + pub frames_offered: u64, + /// Frames actually forwarded to the provider. + pub frames_forwarded: u64, + /// Frames dropped because the provider queue was full. + pub overflow_frame_drops: u64, + /// Partials applied to the volatile draft. + pub partials_applied: u64, + /// Finals accepted by the ingest ledger. + pub finals_accepted: u64, + /// Events the ingest ledger refused (out of order, sealed, foreign). + pub events_rejected: u64, + /// Typed provider error events observed. + pub provider_errors: u64, +} + +/// Everything the lane knows once the recording is over. +#[derive(Debug)] +pub struct Layer1SessionOutcome { + /// Doctrine-vetted finals, in accepted order. + finals: Vec, + /// Content-free counters for the session log. + telemetry: Layer1LaneTelemetry, + /// Why the lane degraded, when it did. + degrade: Option, +} + +impl Layer1SessionOutcome { + /// Doctrine-vetted finals, in accepted order. + pub fn finals(&self) -> &[TranscriptEvent] { + &self.finals + } + + /// Content-free counters for the session log. + pub fn telemetry(&self) -> Layer1LaneTelemetry { + self.telemetry + } + + /// Why the lane degraded, when it did. + pub fn degrade_reason(&self) -> Option { + self.degrade + } + + /// The refiner's transcript candidate: sealed finals joined in order. + /// + /// `None` when the session produced no accepted finals — the caller keeps + /// the canvas untouched rather than merging against an empty candidate. + pub fn refined_transcript(&self) -> Option { + if self.finals.is_empty() { + return None; + } + Some( + self.finals + .iter() + .map(|event| event.text.trim()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "), + ) + } + + /// Route the outcome through the integrated doctrine-safe truth seam. + /// + /// This is [`merge_live_layer1`]: the committed live floor is immutable, + /// Layer 1 text may fill aligned gaps and extend the tail, and a + /// substitution always keeps the live token. Callers deliver + /// [`Layer1MergedDelivery::text`]; they never deliver the raw candidate. + pub fn adjudicate_against_live_floor(&self, live_floor: &str) -> Layer1MergedDelivery { + let candidate = self.refined_transcript(); + merge_live_layer1(live_floor, candidate.as_deref().unwrap_or("")) + } +} + +/// The per-recording Layer 1 lane: open at start, fan out, drain at stop. +/// +/// Owned by the live session loop. Every method is non-blocking except +/// [`Self::stop`], whose blocking is bounded by the provider's own close +/// contract plus [`STOP_DRAIN_MAX_POLLS`] drain iterations. +pub struct RecorderLayer1Lane { + /// Lifecycle position. + state: Layer1LaneState, + /// The open provider while [`Layer1LaneState::Live`]. + provider: Option>, + /// The doctrine ledger every provider event passes through. + ingest: SessionIngest, + /// Volatile partial text per open utterance. Never canvas; dies on degrade. + draft: BTreeMap, + /// Accepted finals in accepted order. + finals: Vec, + /// Content-free counters. + telemetry: Layer1LaneTelemetry, + /// Current run of consecutive overflowed frames. + consecutive_overflows: u32, + /// Sticky first degrade reason for the outcome record. + degrade: Option, + /// One-shot notice so the session can emit a single degrade warning event. + degrade_notice: Option, +} + +impl fmt::Debug for RecorderLayer1Lane { + /// Counts-only debug shape; the provider is summarized by mode token. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RecorderLayer1Lane") + .field("state", &self.state) + .field( + "provider_mode", + &self.provider.as_ref().map(|p| p.mode().as_token()), + ) + .field("draft_utterances", &self.draft.len()) + .field("finals", &self.finals.len()) + .field("telemetry", &self.telemetry) + .finish() + } +} + +impl RecorderLayer1Lane { + /// Open the lane at recording start. Never fails. + /// + /// A provider whose `open` fails is dropped on the spot and the lane + /// starts degraded — the recording proceeds on canvas + lexicon exactly as + /// if no provider had been injected. + pub fn open(decision: Layer1Decision, input: &SessionInput) -> Self { + let mut lane = Self { + state: Layer1LaneState::Unarmed, + provider: None, + ingest: SessionIngest::new(input.session_id.clone()), + draft: BTreeMap::new(), + finals: Vec::new(), + telemetry: Layer1LaneTelemetry::default(), + consecutive_overflows: 0, + degrade: None, + degrade_notice: None, + }; + match decision { + Layer1Decision::Disarmed => lane, + Layer1Decision::Armed(mut provider) => { + match provider.open(input) { + Ok(()) => { + info!( + refiner = provider.mode().as_token(), + sample_rate = input.sample_rate, + "Layer 1 lane opened at recording start" + ); + lane.state = Layer1LaneState::Live; + lane.provider = Some(provider); + } + Err(kind) => { + // The provider is dropped here; a failed open must not + // hold a half-connected session for the whole hold. + lane.degrade_dropping_provider(Layer1DegradeReason::OpenFailed(kind)); + } + } + lane + } + } + } + + /// Lifecycle position. + pub fn state(&self) -> Layer1LaneState { + self.state + } + + /// Whether a provider session is currently consuming fan-out. + pub fn is_live(&self) -> bool { + matches!(self.state, Layer1LaneState::Live) + } + + /// The refiner mode currently in effect. + /// + /// Anything other than [`Layer1LaneState::Live`] is [`RefinerMode::Off`]: + /// canvas plus lexicon, the complete shipping product. + pub fn refiner_mode(&self) -> RefinerMode { + match (&self.state, self.provider.as_ref()) { + (Layer1LaneState::Live, Some(provider)) => provider.mode(), + _ => RefinerMode::Off, + } + } + + /// Content-free counters so far. + pub fn telemetry(&self) -> Layer1LaneTelemetry { + self.telemetry + } + + /// Latest volatile partial for `utterance_id`, when one is open. + pub fn draft_text(&self, utterance_id: u64) -> Option<&str> { + self.draft.get(&utterance_id).map(String::as_str) + } + + /// Number of utterances with an open volatile draft. + pub fn draft_len(&self) -> usize { + self.draft.len() + } + + /// Finals accepted so far, in accepted order. + pub fn finals(&self) -> &[TranscriptEvent] { + &self.finals + } + + /// Take the one-shot degrade notice, if a degrade happened since the last + /// call. The session loop uses this to emit exactly one warning event. + pub fn take_degrade_notice(&mut self) -> Option { + self.degrade_notice.take() + } + + /// Offer one captured PCM frame. Returns immediately, always. + /// + /// Capture never branches on the verdict — it is informational. A frame + /// offered while the lane is not live is silently ignored, which is what + /// makes a degraded or disarmed lane indistinguishable from no lane at all + /// on the capture path. + pub fn offer_pcm(&mut self, samples: &[f32]) -> FanOutVerdict { + self.telemetry.frames_offered += 1; + if !self.is_live() || samples.is_empty() { + return FanOutVerdict::Inactive; + } + let Some(provider) = self.provider.as_mut() else { + return FanOutVerdict::Inactive; + }; + match provider.push_audio(samples) { + Ok(()) => { + self.consecutive_overflows = 0; + self.telemetry.frames_forwarded += 1; + FanOutVerdict::Forwarded + } + Err(AsrErrorKind::Overflow) => { + self.telemetry.overflow_frame_drops += 1; + self.consecutive_overflows += 1; + if self.consecutive_overflows >= OVERFLOW_DEGRADE_LIMIT { + self.degrade_dropping_provider(Layer1DegradeReason::Overflow); + } + FanOutVerdict::DroppedOverflow + } + Err(kind) => { + self.telemetry.provider_errors += 1; + self.degrade_dropping_provider(Layer1DegradeReason::Disconnect(kind)); + FanOutVerdict::Inactive + } + } + } + + /// Drain ready provider events and route them. Non-blocking. + pub fn poll(&mut self) { + if !self.is_live() { + return; + } + let Some(provider) = self.provider.as_mut() else { + return; + }; + let events = provider.drain(); + for event in events { + self.route_event(event); + if !self.is_live() { + // A fatal error event degraded the lane; anything still queued + // belonged to the session that just ended. + break; + } + } + } + + /// The host slept mid-recording: the provider session is presumed stale. + /// + /// Wired by the platform sleep observer when one is present; the state + /// transition is the contract either way. + pub fn note_sleep_wake(&mut self) { + if self.is_live() { + self.degrade_dropping_provider(Layer1DegradeReason::SleepWake); + } + } + + /// Stop the lane at recording end: graceful close, bounded trailing drain. + /// + /// This is the only lane call with bounded blocking (the provider's own + /// close contract). Whatever happens inside it, the method returns an + /// outcome and the lane ends [`Layer1LaneState::Stopped`] — the stop path + /// never propagates a Layer 1 failure. + pub fn stop(&mut self) -> Layer1SessionOutcome { + if let Some(mut provider) = self.provider.take() { + // Route anything already decoded before asking for the tail. + for event in provider.drain() { + self.route_event(event); + } + match provider.close() { + Ok(()) => { + let mut polls = 0u32; + loop { + let events = provider.drain(); + if events.is_empty() { + break; + } + for event in events { + self.route_event(event); + } + polls += 1; + if polls >= STOP_DRAIN_MAX_POLLS { + self.note_degrade(Layer1DegradeReason::StopDrainIncomplete); + break; + } + } + } + Err(kind) => { + self.telemetry.provider_errors += 1; + self.note_degrade(Layer1DegradeReason::Disconnect(kind)); + } + } + // The provider drops here; a cloud transport aborts on drop. + } + self.draft.clear(); + self.state = Layer1LaneState::Stopped; + Layer1SessionOutcome { + finals: std::mem::take(&mut self.finals), + telemetry: self.telemetry, + degrade: self.degrade, + } + } + + /// Pass one provider event through the doctrine ledger and apply it. + fn route_event(&mut self, event: AsrSessionEvent) { + let verdict = self.ingest.ingest(event.clone()); + match verdict { + IngestVerdict::Accepted => match event { + AsrSessionEvent::Partial(transcript) => { + self.telemetry.partials_applied += 1; + self.draft + .insert(transcript.identity.utterance_id(), transcript.text); + } + AsrSessionEvent::Final(transcript) => { + self.telemetry.finals_accepted += 1; + self.draft.remove(&transcript.identity.utterance_id()); + self.finals.push(transcript); + } + AsrSessionEvent::Error(error) => { + self.telemetry.provider_errors += 1; + if session_fatal(error.kind) && self.is_live() { + self.degrade_dropping_provider(Layer1DegradeReason::Disconnect(error.kind)); + } + } + AsrSessionEvent::Usage(_) => { + // Accounting only; nothing to apply. + } + }, + IngestVerdict::DuplicateIdempotent => { + // Re-delivery changed nothing, which is the point. + } + IngestVerdict::RejectedOutOfOrder + | IngestVerdict::RejectedSealedUtterance + | IngestVerdict::RejectedForeignSession => { + self.telemetry.events_rejected += 1; + } + } + } + + /// Record the sticky degrade reason and the one-shot notice. + fn note_degrade(&mut self, reason: Layer1DegradeReason) { + if self.degrade.is_none() { + self.degrade = Some(reason); + self.degrade_notice = Some(reason); + warn!( + reason = reason.as_token(), + "Layer 1 lane degraded — canvas + lexicon carry the session" + ); + } + } + + /// Degrade on the live path: drop the provider without a graceful close. + /// + /// Dropping (rather than closing) is deliberate — see the module docs. + /// The volatile draft dies with the lane; accepted finals stay, because + /// they already passed the doctrine seam and remain gap-fill candidates. + fn degrade_dropping_provider(&mut self, reason: Layer1DegradeReason) { + self.provider = None; + self.draft.clear(); + self.state = Layer1LaneState::Degraded(reason); + self.note_degrade(reason); + } +} + +/// Apply one host lifecycle boundary to the active Layer 1 lane. +/// +/// Kept as the single adapter used by the production session loop and its +/// deterministic channel-level regression. The transition itself remains +/// owned by [`RecorderLayer1Lane::note_sleep_wake`]. +pub fn apply_recorder_lifecycle_event( + lane: &mut RecorderLayer1Lane, + event: RecorderLifecycleEvent, +) { + match event { + RecorderLifecycleEvent::SleepWake => lane.note_sleep_wake(), + } +} + +/// Whether one typed error kind ends the session for this recording. +/// +/// `RateLimited` and `Overflow` describe pressure that the bounded fan-out +/// already absorbs frame by frame; everything else means the provider cannot +/// serve this session and the lane lands on canvas + lexicon. +fn session_fatal(kind: AsrErrorKind) -> bool { + !matches!(kind, AsrErrorKind::RateLimited | AsrErrorKind::Overflow) +} + +#[cfg(test)] +mod tests { + use super::super::events::{ + ErrorEvent, EventIdentity, SessionId, TranscriptEvent as Transcript, + }; + use super::super::fake::FakeAsrSessionProvider; + use super::*; + use crate::quality::Layer1MergeMode; + + /// Session identity every fixture in this module records under. + fn session_id() -> SessionId { + SessionId::new("recording-1").expect("non-blank session id") + } + + /// The thin open parameters the recorder hands the lane. + fn input() -> SessionInput { + SessionInput { + session_id: session_id(), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } + } + + /// Identity triple within the fixture session. + fn identity(utterance_id: u64, sequence_id: u64) -> EventIdentity { + EventIdentity::new(session_id(), utterance_id, sequence_id) + } + + /// Partial event fixture. + fn partial(utterance_id: u64, sequence_id: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Partial(Transcript { + identity: identity(utterance_id, sequence_id), + text: text.to_string(), + range: None, + }) + } + + /// Final event fixture. + fn final_event(utterance_id: u64, sequence_id: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Final(Transcript { + identity: identity(utterance_id, sequence_id), + text: text.to_string(), + range: None, + }) + } + + /// Typed error event fixture. + fn error_event(sequence_id: u64, kind: AsrErrorKind) -> AsrSessionEvent { + AsrSessionEvent::Error(ErrorEvent { + identity: identity(0, sequence_id), + kind, + }) + } + + /// An armed decision over a scripted fake provider. + fn armed(script: Vec) -> Layer1Decision { + Layer1Decision::Armed(Box::new(FakeAsrSessionProvider::with_script( + RefinerMode::CloudSession, + script, + ))) + } + + /// A missing provider is normal operation, not an error: the lane runs + /// unarmed, ignores fan-out, and stops with an empty outcome. + #[test] + fn disarmed_lane_is_normal_apple_plus_lexicon_operation() { + let mut lane = RecorderLayer1Lane::open(Layer1Decision::Disarmed, &input()); + assert_eq!(lane.state(), Layer1LaneState::Unarmed); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + lane.poll(); + assert!(lane.take_degrade_notice().is_none(), "no degrade to report"); + + let outcome = lane.stop(); + assert_eq!(lane.state(), Layer1LaneState::Stopped); + assert!(outcome.finals().is_empty()); + assert!(outcome.degrade_reason().is_none()); + assert!(outcome.refined_transcript().is_none()); + } + + /// A provider whose open fails is dropped and the recording proceeds + /// degraded — never an error surfaced to capture. + #[test] + fn open_failure_degrades_instead_of_erroring() { + // Pre-open the fake so the lane's open hits a Protocol fault. + let mut provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession); + provider.open(&input()).expect("first open succeeds"); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::OpenFailed(AsrErrorKind::Protocol)) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + assert_eq!( + lane.take_degrade_notice(), + Some(Layer1DegradeReason::OpenFailed(AsrErrorKind::Protocol)) + ); + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + } + + /// Partials are volatile draft: replaced freely, cleared by their final, + /// and never part of the outcome's committed candidate. + #[test] + fn partials_stay_volatile_draft_until_the_final_seals() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + partial(1, 1, "pacjent"), + partial(1, 2, "pacjent ma"), + final_event(1, 3, "pacjent ma goraczke"), + ]), + &input(), + ); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_text(1), Some("pacjent")); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_text(1), Some("pacjent ma"), "draft is replaced"); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_text(1), None, "the final clears its draft"); + assert_eq!(lane.finals().len(), 1); + assert_eq!(lane.finals()[0].text, "pacjent ma goraczke"); + } + + /// The ingest doctrine holds inside the lane: duplicates are idempotent, + /// stale finals cannot rewrite a sealed utterance. + #[test] + fn finals_route_through_the_ingest_doctrine() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + final_event(1, 2, "pacjent ma goraczke"), + final_event(1, 2, "pacjent ma goraczke"), // reconnect resend + final_event(1, 1, "pacjent"), // stale rewrite attempt + ]), + &input(), + ); + + for _ in 0..3 { + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + } + + assert_eq!(lane.finals().len(), 1, "one sealed final"); + assert_eq!(lane.finals()[0].text, "pacjent ma goraczke"); + let telemetry = lane.telemetry(); + assert_eq!(telemetry.finals_accepted, 1); + assert_eq!( + telemetry.events_rejected, 1, + "the stale rewrite was refused, not applied" + ); + assert!(lane.is_live(), "doctrine refusals do not degrade the lane"); + } + + /// Bounded overflow: frames are dropped and counted while the run is + /// short, and the lane stays live. + #[test] + fn overflow_below_the_budget_drops_frames_without_degrading() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Overflow); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + for _ in 0..(OVERFLOW_DEGRADE_LIMIT - 1) { + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::DroppedOverflow); + } + assert!(lane.is_live(), "a short overflow run is absorbed"); + assert_eq!( + lane.telemetry().overflow_frame_drops, + u64::from(OVERFLOW_DEGRADE_LIMIT - 1) + ); + } + + /// Sustained overflow degrades to canvas + lexicon; capture keeps offering + /// and the lane keeps returning instantly. + #[test] + fn sustained_overflow_degrades_and_capture_continues() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Overflow); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + for _ in 0..OVERFLOW_DEGRADE_LIMIT { + lane.offer_pcm(&[0.1; 320]); + } + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Overflow) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + assert_eq!( + lane.take_degrade_notice(), + Some(Layer1DegradeReason::Overflow) + ); + // Capture is oblivious: further offers are ignored, never errors. + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + } + + /// A successful push resets the consecutive-overflow run, so scattered + /// hiccups never accumulate into a degrade. + #[test] + fn interleaved_success_resets_the_overflow_run() { + // Script one event so the first push succeeds, then force overflows. + let mut lane = RecorderLayer1Lane::open(armed(vec![partial(1, 1, "a")]), &input()); + for _ in 0..(OVERFLOW_DEGRADE_LIMIT - 1) { + // The fake accepts pushes (no failure armed): every offer forwards + // and the overflow run stays at zero. + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Forwarded); + } + assert!(lane.is_live()); + assert_eq!(lane.telemetry().overflow_frame_drops, 0); + } + + /// A transport-fatal push failure degrades as a disconnect. + #[test] + fn transport_push_failure_degrades_as_disconnect() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Transport); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + + assert_eq!(lane.offer_pcm(&[0.1; 320]), FanOutVerdict::Inactive); + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Disconnect(AsrErrorKind::Transport)) + ); + assert_eq!(lane.refiner_mode(), RefinerMode::Off); + } + + /// A session-fatal error *event* degrades the lane; queued events behind + /// it are abandoned with the session. + #[test] + fn fatal_error_event_degrades_the_lane() { + let mut lane = + RecorderLayer1Lane::open(armed(vec![error_event(1, AsrErrorKind::Auth)]), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::Disconnect(AsrErrorKind::Auth)) + ); + } + + /// Rate limiting is pressure, not death: the lane counts it and stays live. + #[test] + fn rate_limit_error_event_is_absorbed_without_degrading() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + error_event(1, AsrErrorKind::RateLimited), + final_event(1, 2, "pacjent ma goraczke"), + ]), + &input(), + ); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert!(lane.is_live(), "rate limiting must not end the session"); + + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.finals().len(), 1, "the session keeps producing"); + } + + /// Sleep/wake presumes the session stale and degrades immediately. + #[test] + fn sleep_wake_degrades_and_clears_the_draft() { + let mut lane = RecorderLayer1Lane::open(armed(vec![partial(1, 1, "pacjent")]), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_len(), 1); + + lane.note_sleep_wake(); + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::SleepWake) + ); + assert_eq!(lane.draft_len(), 0, "volatile draft dies with the lane"); + } + + /// The production lifecycle adapter, not a direct lane call, reaches the + /// active transition and preserves the fail-closed semantics. + #[tokio::test] + async fn recorder_lifecycle_adapter_reaches_active_lane_transition() { + let (handle, mut events) = recorder_lifecycle_channel(); + let mut lane = RecorderLayer1Lane::open(armed(vec![partial(1, 1, "pacjent")]), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert_eq!(lane.draft_len(), 1); + + assert!(handle.note_sleep_wake(), "active adapter accepts boundary"); + let event = events + .recv() + .await + .expect("active session receives boundary"); + apply_recorder_lifecycle_event(&mut lane, event); + + assert_eq!( + lane.state(), + Layer1LaneState::Degraded(Layer1DegradeReason::SleepWake) + ); + assert_eq!(lane.draft_len(), 0, "adapter clears volatile draft"); + assert_eq!( + lane.take_degrade_notice(), + Some(Layer1DegradeReason::SleepWake), + "the session will emit one content-free degrade warning" + ); + } + + /// Stop drains the provider's tail (the fake flushes its remaining script + /// on close) and the outcome carries the doctrine-vetted finals. + #[test] + fn stop_drain_collects_trailing_finals_bounded() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![ + final_event(1, 1, "pacjent ma goraczke"), + final_event(2, 2, "podano plyny"), + ]), + &input(), + ); + // No pushes: the whole script is still queued when stop closes. + let outcome = lane.stop(); + + assert_eq!(lane.state(), Layer1LaneState::Stopped); + assert_eq!(outcome.finals().len(), 2); + assert_eq!( + outcome.refined_transcript().as_deref(), + Some("pacjent ma goraczke podano plyny") + ); + assert!( + outcome.degrade_reason().is_none(), + "a clean close is not a degrade" + ); + } + + /// Degrading mid-session keeps already-accepted finals: they passed the + /// doctrine seam and remain bounded gap-fill candidates. + #[test] + fn degrade_keeps_doctrine_vetted_finals_for_the_outcome() { + let provider = FakeAsrSessionProvider::with_script( + RefinerMode::CloudSession, + vec![ + final_event(1, 1, "pacjent ma goraczke"), + error_event(2, AsrErrorKind::Transport), + ], + ); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + lane.offer_pcm(&[0.1; 320]); + lane.offer_pcm(&[0.1; 320]); + lane.poll(); + assert!(matches!(lane.state(), Layer1LaneState::Degraded(_))); + + let outcome = lane.stop(); + assert_eq!(outcome.finals().len(), 1); + assert_eq!( + outcome.degrade_reason(), + Some(Layer1DegradeReason::Disconnect(AsrErrorKind::Transport)) + ); + } + + /// The outcome routes through the T0 truth seam: the committed live floor + /// is immutable and Layer 1 text only fills the tail/gaps. + #[test] + fn outcome_adjudication_preserves_the_live_floor() { + let mut lane = RecorderLayer1Lane::open( + armed(vec![final_event( + 1, + 1, + "pacjent ma goraczke i wymioty od wczoraj", + )]), + &input(), + ); + let outcome = lane.stop(); + + let live_floor = "pacjent ma goraczke"; + let merged = outcome.adjudicate_against_live_floor(live_floor); + assert_eq!(merged.mode, Layer1MergeMode::LiveFloorGapFill); + assert!( + merged.text.starts_with(live_floor), + "committed live text must survive adjudication verbatim" + ); + assert!( + merged.text.contains("wymioty"), + "the provider tail may extend the floor" + ); + } + + /// With no finals the outcome refuses to fabricate a candidate, and the + /// seam reports the live floor untouched. + #[test] + fn empty_outcome_leaves_the_live_floor_alone() { + let mut lane = RecorderLayer1Lane::open(Layer1Decision::Disarmed, &input()); + let outcome = lane.stop(); + let merged = outcome.adjudicate_against_live_floor("pacjent ma goraczke"); + assert_eq!(merged.mode, Layer1MergeMode::LiveOnly); + assert_eq!(merged.text, "pacjent ma goraczke"); + } + + /// Stopping a degraded or unarmed lane is a quiet no-op path — the stop + /// path never propagates Layer 1 trouble. + #[test] + fn stop_after_degrade_is_quiet_and_final() { + let provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Transport); + let mut lane = + RecorderLayer1Lane::open(Layer1Decision::Armed(Box::new(provider)), &input()); + lane.offer_pcm(&[0.1; 320]); + assert!(matches!(lane.state(), Layer1LaneState::Degraded(_))); + + let outcome = lane.stop(); + assert_eq!(lane.state(), Layer1LaneState::Stopped); + assert_eq!( + outcome.degrade_reason(), + Some(Layer1DegradeReason::Disconnect(AsrErrorKind::Transport)) + ); + assert!(outcome.finals().is_empty()); + } +} diff --git a/core/asr_session/tests.rs b/core/asr_session/tests.rs new file mode 100644 index 00000000..75f59018 --- /dev/null +++ b/core/asr_session/tests.rs @@ -0,0 +1,516 @@ +//! Contract tests for the neutral Layer 1 session seam. +//! +//! These are the production witnesses behind the fleet-level RED probe in +//! `stt::fleet_red_contracts`: ordering, duplicate-final idempotence, bounded +//! ranges, payload-free errors, and the canvas/refiner split. + +use serial_test::serial; + +use super::events::{ + AsrErrorKind, AsrSessionEvent, AudioRange, ErrorEvent, EventIdentity, SessionId, + TranscriptEvent, UsageEvent, +}; +use super::fake::FakeAsrSessionProvider; +use super::ingest::{IngestVerdict, SessionIngest}; +use super::provider::{ + AsrSessionProvider, CanvasEngine, LayerSelection, RefinerMode, SessionInput, +}; + +/// Env key the STT router reads to pick the live canvas engine. +const ENV_STT_ENGINE: &str = "CODESCRIBE_STT_ENGINE"; + +/// Session id used across the ordering tests. +fn session() -> SessionId { + SessionId::new("session-a").expect("non-blank session id") +} + +/// Identity in the shared test session for `utterance` at `sequence`. +fn identity(utterance: u64, sequence: u64) -> EventIdentity { + EventIdentity::new(session(), utterance, sequence) +} + +/// Partial hypothesis for `utterance` at `sequence`. +fn partial(utterance: u64, sequence: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Partial(TranscriptEvent { + identity: identity(utterance, sequence), + text: text.to_string(), + range: None, + }) +} + +/// Sealing final for `utterance` at `sequence`. +fn final_event(utterance: u64, sequence: u64, text: &str) -> AsrSessionEvent { + AsrSessionEvent::Final(TranscriptEvent { + identity: identity(utterance, sequence), + text: text.to_string(), + range: None, + }) +} + +/// Typed failure for `utterance` at `sequence`. +fn error_event(utterance: u64, sequence: u64, kind: AsrErrorKind) -> AsrSessionEvent { + AsrSessionEvent::Error(ErrorEvent { + identity: identity(utterance, sequence), + kind, + }) +} + +/// Restores `CODESCRIBE_STT_ENGINE` after a canvas-selection test. +struct EngineEnvGuard { + /// Value the key held before the test pinned it. + previous: Option, +} + +impl EngineEnvGuard { + /// Pin the router's engine selector for this test scope. + fn set(value: &str) -> Self { + let previous = std::env::var(ENV_STT_ENGINE).ok(); + unsafe { std::env::set_var(ENV_STT_ENGINE, value) }; + Self { previous } + } +} + +impl Drop for EngineEnvGuard { + /// Restore the prior value (or remove the key) when the guard leaves scope. + fn drop(&mut self) { + match self.previous.as_deref() { + Some(value) => unsafe { std::env::set_var(ENV_STT_ENGINE, value) }, + None => unsafe { std::env::remove_var(ENV_STT_ENGINE) }, + } + } +} + +// ═══════════════════════════════════════════════════════════ +// Ordering and idempotence +// ═══════════════════════════════════════════════════════════ + +/// THE ORDERING MATRIX: a live provider replays. Only the sequence orders the +/// stream, a re-sent final changes nothing, and a stale final never lands. +#[test] +fn ingest_orders_by_sequence_and_absorbs_duplicate_finals() { + let mut ingest = SessionIngest::new(session()); + + let verdicts: Vec = vec![ + partial(7, 1, "pacjent ma"), + final_event(7, 2, "pacjent ma goraczke"), + final_event(7, 2, "pacjent ma goraczke"), // reconnect resend + final_event(7, 1, "pacjent"), // stale, arrives late + error_event(7, 3, AsrErrorKind::Transport), + ] + .into_iter() + .map(|event| ingest.ingest(event)) + .collect(); + + assert_eq!( + verdicts, + vec![ + IngestVerdict::Accepted, + IngestVerdict::Accepted, + IngestVerdict::DuplicateIdempotent, + IngestVerdict::RejectedOutOfOrder, + IngestVerdict::Accepted, + ] + ); + assert_eq!( + ingest.accepted(), + [ + partial(7, 1, "pacjent ma"), + final_event(7, 2, "pacjent ma goraczke"), + error_event(7, 3, AsrErrorKind::Transport), + ] + ); + assert_eq!(ingest.duplicate_count(), 1); + assert_eq!(ingest.out_of_order_count(), 1); + assert_eq!(ingest.last_sequence(), Some(3)); + + // The seal still holds the text the accepted final carried — the stale + // final did not rewrite it. + let sealed = ingest.sealed_final(7).expect("utterance 7 is sealed"); + assert_eq!(sealed.text, "pacjent ma goraczke"); +} + +/// A resend that arrives *after* newer events is still the same commitment, so +/// it is idempotent rather than "out of order". This is the reconnect case the +/// sequence check alone would misclassify. +#[test] +fn duplicate_final_is_idempotent_even_after_newer_events() { + let mut ingest = SessionIngest::new(session()); + assert!(ingest.ingest(final_event(1, 10, "raz dwa")).is_accepted()); + assert!(ingest.ingest(partial(2, 11, "trzy")).is_accepted()); + + assert_eq!( + ingest.ingest(final_event(1, 10, "raz dwa")), + IngestVerdict::DuplicateIdempotent + ); + assert_eq!(ingest.accepted().len(), 2); + assert_eq!(ingest.duplicate_count(), 1); + assert_eq!(ingest.out_of_order_count(), 0); +} + +/// A final is a commitment. A later partial for that utterance — even with a +/// perfectly monotonic sequence — must not reopen it. +#[test] +fn sealed_utterance_refuses_later_partials_and_conflicting_finals() { + let mut ingest = SessionIngest::new(session()); + assert!( + ingest + .ingest(final_event(3, 5, "badanie krwi wykazalo")) + .is_accepted() + ); + + assert_eq!( + ingest.ingest(partial(3, 6, "badanie krwi")), + IngestVerdict::RejectedSealedUtterance + ); + assert_eq!( + ingest.ingest(final_event(3, 7, "zupelnie inny tekst")), + IngestVerdict::RejectedSealedUtterance + ); + assert_eq!(ingest.sealed_rejection_count(), 2); + assert_eq!( + ingest.sealed_final(3).map(|event| event.text.as_str()), + Some("badanie krwi wykazalo") + ); + + // A different utterance is untouched by the seal. + assert!( + ingest + .ingest(partial(4, 8, "kolejna wypowiedz")) + .is_accepted() + ); +} + +/// Diagnostics are not text: an error for a sealed utterance still lands, so a +/// provider can report a failure after it has already committed a final. +#[test] +fn sealed_utterance_still_accepts_diagnostics() { + let mut ingest = SessionIngest::new(session()); + assert!(ingest.ingest(final_event(2, 4, "gotowe")).is_accepted()); + assert!( + ingest + .ingest(error_event(2, 5, AsrErrorKind::Transport)) + .is_accepted() + ); + assert_eq!(ingest.sealed_rejection_count(), 0); +} + +/// A reconnect that resumes the wrong stream is caught at the ledger edge. +#[test] +fn foreign_session_events_are_refused() { + let mut ingest = SessionIngest::new(session()); + let foreign = SessionId::new("session-b").expect("non-blank session id"); + let event = AsrSessionEvent::Final(TranscriptEvent { + identity: EventIdentity::new(foreign, 7, 1), + text: "z innej sesji".to_string(), + range: None, + }); + + assert_eq!(ingest.ingest(event), IngestVerdict::RejectedForeignSession); + assert!(ingest.accepted().is_empty()); + assert_eq!(ingest.foreign_rejection_count(), 1); + assert_eq!(ingest.last_sequence(), None); +} + +/// A blank session id would make every session compare equal and silently +/// disable the foreign-session guard. +#[test] +fn blank_session_ids_are_refused() { + assert!(SessionId::new("").is_none()); + assert!(SessionId::new(" \n").is_none()); + assert_eq!( + SessionId::new("s-1").map(|id| id.as_str().to_string()), + Some("s-1".to_string()) + ); +} + +// ═══════════════════════════════════════════════════════════ +// Bounded audio ranges +// ═══════════════════════════════════════════════════════════ + +/// The optional range is bounded on every axis that could turn a corrupt +/// timestamp into a plausible-looking window. +#[test] +fn audio_range_rejects_unusable_spans() { + let ok = AudioRange::new(1.0, 2.5).expect("valid span"); + assert_eq!(ok.start_secs(), 1.0); + assert_eq!(ok.end_secs(), 2.5); + assert_eq!(ok.duration_secs(), 1.5); + + assert!(AudioRange::new(f32::NAN, 1.0).is_none()); + assert!(AudioRange::new(0.0, f32::INFINITY).is_none()); + assert!(AudioRange::new(-0.5, 1.0).is_none()); + assert!(AudioRange::new(2.0, 2.0).is_none(), "empty span"); + assert!(AudioRange::new(3.0, 1.0).is_none(), "inverted span"); + assert!( + AudioRange::new(0.0, AudioRange::MAX_SPAN_SECS + 0.1).is_none(), + "a span wider than retained PCM describes audio nothing can re-read" + ); + assert!(AudioRange::new(0.0, AudioRange::MAX_SPAN_SECS).is_some()); +} + +/// The ceiling is the live PCM ring's retention, not an independent number that +/// can drift away from it. +#[test] +fn audio_range_ceiling_tracks_live_pcm_retention() { + assert_eq!( + AudioRange::MAX_SPAN_SECS, + crate::pipeline::streaming::live_audio_buffer::DEFAULT_RETENTION_SECS + ); +} + +// ═══════════════════════════════════════════════════════════ +// Typed errors and usage +// ═══════════════════════════════════════════════════════════ + +/// Errors carry a kind and nothing else, so no transcript fragment, audio path, +/// or credential can ride one into a log line. +#[test] +fn errors_are_typed_with_no_free_form_payload() { + let kinds = [ + (AsrErrorKind::Transport, "transport", true), + (AsrErrorKind::Auth, "auth", false), + (AsrErrorKind::RateLimited, "rate_limited", true), + (AsrErrorKind::Quota, "quota", false), + (AsrErrorKind::Overflow, "overflow", true), + (AsrErrorKind::Unsupported, "unsupported", false), + (AsrErrorKind::Protocol, "protocol", false), + (AsrErrorKind::Cancelled, "cancelled", false), + ]; + for (kind, token, retryable) in kinds { + assert_eq!(kind.as_token(), token); + assert_eq!(format!("{kind}"), token); + assert_eq!(kind.is_retryable(), retryable, "{token}"); + } +} + +/// Usage is accounting, not content. +#[test] +fn usage_events_carry_accounting_only() { + let usage = UsageEvent { + identity: identity(0, 9), + audio_secs: 12.5, + billable_units: Some(13), + }; + let event = AsrSessionEvent::Usage(usage); + assert_eq!(event.as_token(), "usage"); + assert!(!event.is_transcript()); + assert!(!event.is_final()); + assert_eq!(event.identity().sequence_id(), 9); +} + +/// Finality is a variant, so every consumer has to decide about it explicitly. +#[test] +fn finality_is_a_variant_not_a_flag() { + assert!(partial(1, 1, "x").is_transcript()); + assert!(!partial(1, 1, "x").is_final()); + assert!(final_event(1, 2, "x").is_final()); + assert_eq!(partial(1, 1, "x").as_token(), "partial"); + assert_eq!(final_event(1, 2, "x").as_token(), "final"); + assert_eq!( + error_event(1, 3, AsrErrorKind::Auth).as_token(), + "error", + "an error is never mistaken for text" + ); +} + +// ═══════════════════════════════════════════════════════════ +// Canvas selection versus refiner mode +// ═══════════════════════════════════════════════════════════ + +/// The two axes are independent: choosing a refiner never moves the canvas. +#[test] +fn refiner_mode_never_moves_the_canvas() { + for canvas in [CanvasEngine::AppleSpeech, CanvasEngine::LocalWhisper] { + for refiner in [ + RefinerMode::Off, + RefinerMode::CloudSession, + RefinerMode::LocalHelper, + ] { + let selection = LayerSelection::new(canvas, refiner); + assert_eq!(selection.canvas(), canvas, "{refiner:?} moved the canvas"); + assert_eq!(selection.refiner(), refiner); + + // Layer 1 failing is a missing improvement, never a redraw. + let degraded = selection.degraded(); + assert_eq!(degraded.canvas(), canvas); + assert_eq!(degraded.refiner(), RefinerMode::Off); + } + } +} + +/// `Off` is the shipping product, and it is the default. +#[test] +fn refiner_mode_defaults_to_off_and_classifies_audio_egress() { + assert_eq!(RefinerMode::default(), RefinerMode::Off); + assert!(!RefinerMode::Off.sends_audio_off_device()); + assert!(!RefinerMode::LocalHelper.sends_audio_off_device()); + assert!(RefinerMode::CloudSession.sends_audio_off_device()); + assert_eq!(CanvasEngine::AppleSpeech.as_token(), "apple_speech"); + assert_eq!(RefinerMode::CloudSession.as_token(), "cloud_session"); +} + +/// The canvas axis is read from the live router, and the router's selector has +/// no say over the refiner axis. +#[test] +#[serial] +fn active_canvas_is_read_from_the_router_and_leaves_the_refiner_alone() { + let _guard = EngineEnvGuard::set("candle"); + let selection = LayerSelection::for_active_canvas(RefinerMode::CloudSession); + assert_eq!(selection.canvas(), CanvasEngine::LocalWhisper); + assert_eq!(selection.refiner(), RefinerMode::CloudSession); + + unsafe { std::env::set_var(ENV_STT_ENGINE, "apple") }; + let selection = LayerSelection::for_active_canvas(RefinerMode::CloudSession); + assert_eq!(selection.canvas(), CanvasEngine::AppleSpeech); + assert_eq!( + selection.refiner(), + RefinerMode::CloudSession, + "the canvas selector must not reach the refiner axis" + ); + + // And the refiner axis cannot reach back: every mode reports the same + // canvas under the same router state. + for refiner in [ + RefinerMode::Off, + RefinerMode::CloudSession, + RefinerMode::LocalHelper, + ] { + assert_eq!( + LayerSelection::for_active_canvas(refiner).canvas(), + CanvasEngine::AppleSpeech + ); + } +} + +// ═══════════════════════════════════════════════════════════ +// The fake provider +// ═══════════════════════════════════════════════════════════ + +/// Session parameters for the fake. +fn fake_input() -> SessionInput { + SessionInput { + session_id: session(), + locale: Some("pl-PL".to_string()), + sample_rate: 16_000, + } +} + +/// Lifecycle faults degrade into typed errors — a live session must never +/// panic the recording. +#[test] +fn fake_provider_reports_lifecycle_faults_as_protocol_errors() { + let mut provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession); + assert_eq!(provider.mode(), RefinerMode::CloudSession); + + assert_eq!(provider.push_audio(&[0.0; 8]), Err(AsrErrorKind::Protocol)); + assert_eq!(provider.close(), Err(AsrErrorKind::Protocol)); + + provider.open(&fake_input()).expect("first open succeeds"); + assert_eq!(provider.open(&fake_input()), Err(AsrErrorKind::Protocol)); + + provider.close().expect("close after open succeeds"); + assert_eq!(provider.push_audio(&[0.0; 8]), Err(AsrErrorKind::Protocol)); +} + +/// The fake produces a whole session shape — scripted events in order, then a +/// trailing usage record whose sequence stays monotonic. +#[test] +fn fake_provider_emits_a_monotonic_session() { + let script = vec![ + partial(1, 1, "pacjent"), + partial(1, 2, "pacjent ma"), + final_event(1, 3, "pacjent ma goraczke"), + ]; + let mut provider = FakeAsrSessionProvider::with_script(RefinerMode::CloudSession, script); + provider.open(&fake_input()).expect("open"); + + assert!(provider.drain().is_empty(), "no audio pushed yet"); + provider.push_audio(&[0.0; 16_000]).expect("push"); + let first = provider.drain(); + assert_eq!(first, vec![partial(1, 1, "pacjent")]); + + provider.push_audio(&[0.0; 8_000]).expect("push"); + assert_eq!(provider.pushed_secs(), 1.5); + let second = provider.drain(); + assert_eq!(second, vec![partial(1, 2, "pacjent ma")]); + + provider.close().expect("close"); + assert!(provider.script_drained()); + let tail = provider.drain(); + assert_eq!(tail.len(), 2, "trailing final plus usage"); + assert_eq!(tail[0], final_event(1, 3, "pacjent ma goraczke")); + match &tail[1] { + AsrSessionEvent::Usage(usage) => { + assert_eq!(usage.identity.sequence_id(), 4, "usage stays monotonic"); + assert_eq!(usage.audio_secs, 1.5); + assert_eq!(usage.billable_units, None); + } + other => panic!("expected a usage record, got {other:?}"), + } +} + +/// A failing transport surfaces its typed kind on every push, and the product +/// answer is the degraded selection — canvas plus lexicon, same canvas. +#[test] +fn fake_provider_push_failure_degrades_to_canvas_only() { + let mut provider = FakeAsrSessionProvider::new(RefinerMode::CloudSession) + .failing_pushes(AsrErrorKind::Transport); + provider.open(&fake_input()).expect("open"); + + let error = provider + .push_audio(&[0.0; 128]) + .expect_err("push must fail"); + assert_eq!(error, AsrErrorKind::Transport); + assert!(error.is_retryable()); + assert!(provider.drain().is_empty()); + + let selection = LayerSelection::new(CanvasEngine::AppleSpeech, RefinerMode::CloudSession); + let degraded = selection.degraded(); + assert_eq!(degraded.canvas(), CanvasEngine::AppleSpeech); + assert_eq!(degraded.refiner(), RefinerMode::Off); +} + +/// End to end: what the provider emits is what the ledger accepts, and a +/// replayed tail changes nothing. +#[test] +fn fake_provider_stream_survives_a_replayed_tail() { + let script = vec![ + partial(1, 1, "raz"), + final_event(1, 2, "raz dwa"), + final_event(1, 2, "raz dwa"), // the provider re-sends its final + partial(2, 3, "trzy"), + final_event(2, 4, "trzy cztery"), + ]; + let mut provider = FakeAsrSessionProvider::with_script(RefinerMode::LocalHelper, script); + provider.open(&fake_input()).expect("open"); + provider.close().expect("close flushes the whole script"); + + let mut ingest = SessionIngest::new(session()); + let verdicts: Vec = provider + .drain() + .into_iter() + .map(|event| ingest.ingest(event)) + .collect(); + + assert_eq!( + verdicts, + vec![ + IngestVerdict::Accepted, + IngestVerdict::Accepted, + IngestVerdict::DuplicateIdempotent, + IngestVerdict::Accepted, + IngestVerdict::Accepted, + IngestVerdict::Accepted, // the closing usage record + ] + ); + assert_eq!(ingest.duplicate_count(), 1); + assert_eq!(ingest.out_of_order_count(), 0); + assert_eq!( + ingest.sealed_final(1).map(|event| event.text.as_str()), + Some("raz dwa") + ); + assert_eq!( + ingest.sealed_final(2).map(|event| event.text.as_str()), + Some("trzy cztery") + ); +} diff --git a/core/audio/capture_receipt.rs b/core/audio/capture_receipt.rs new file mode 100644 index 00000000..46bdc50d --- /dev/null +++ b/core/audio/capture_receipt.rs @@ -0,0 +1,617 @@ +//! Per-session capture-level receipt. +//! +//! The W30 input-level break (−38.3 → −43.9 dB, then −46.5 by W33) sat +//! invisible for three weeks because only per-buffer RMS ticks existed. +//! This receipt is the session aggregate: median RMS, peak, device, rate, +//! channels, plus the Amendment-3 active-speech key (sample count, clip, +//! dropout, noise, SNR). The all-audio median stays for debugging. +//! +//! WARN `capture_level_low` is a quality receipt. It must never join +//! [`USER_TERMINAL_WARNING_CODES`](crate::pipeline::contracts::USER_TERMINAL_WARNING_CODES). + +use std::sync::{Mutex, OnceLock}; + +use tracing::{info, warn}; + +use crate::pipeline::contracts::{EngineEvent, EventSink}; + +/// Session-end receipt code (log line + last-snapshot key). +pub const CAPTURE_LEVEL_RECEIPT_CODE: &str = "capture_level_receipt"; +/// Non-terminal WARN when the **active-speech** median sits below the floor. +pub const CAPTURE_LEVEL_LOW_CODE: &str = "capture_level_low"; +/// Env override for the low-level floor (dBFS). Default −52. +pub const CAPTURE_LEVEL_LOW_DB_ENV: &str = "CODESCRIBE_CAPTURE_LEVEL_LOW_DB"; +/// Corpus-derived floor: golden era ≈ −38, break ≈ −44, −52 leaves headroom. +pub const DEFAULT_CAPTURE_LEVEL_LOW_DB: f32 = -52.0; +/// macOS 27 gates silence to hard zeros (take 191351, both mic modes). +pub const DIGITAL_ZERO_ABS: f32 = 1.0e-8; +/// Linear RMS below this is not active speech (~−80 dBFS). +pub const ACTIVE_SPEECH_LINEAR_FLOOR: f32 = 1.0e-4; +/// Near-full-scale samples count as clipping. +pub const CLIP_ABS: f32 = 0.99; + +static LAST_RECEIPT: OnceLock>> = OnceLock::new(); +static LAST_OPEN_PATH: OnceLock>> = OnceLock::new(); +static SESSION_ENERGY: OnceLock> = OnceLock::new(); + +/// One capture hop on the session PCM axis. Intensity lives here, not on tokens. +#[derive(Debug, Clone, Copy)] +struct EnergyHop { + sample_start: u64, + sample_end: u64, + rms: f32, +} + +#[derive(Debug, Default)] +struct SessionEnergyClock { + hops: Vec, +} + +fn last_receipt_slot() -> &'static Mutex> { + LAST_RECEIPT.get_or_init(|| Mutex::new(None)) +} + +fn last_open_path_slot() -> &'static Mutex> { + LAST_OPEN_PATH.get_or_init(|| Mutex::new(None)) +} + +fn session_energy_slot() -> &'static Mutex { + SESSION_ENERGY.get_or_init(|| Mutex::new(SessionEnergyClock::default())) +} + +/// Open a new capture epoch's energy ladder. Call at live-session start only. +pub fn begin_session_energy_clock() { + *session_energy_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) = SessionEnergyClock::default(); +} + +fn record_session_energy_hop(sample_start: u64, sample_end: u64, rms: f32) { + if sample_end <= sample_start || !rms.is_finite() || rms < 0.0 { + return; + } + session_energy_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .hops + .push(EnergyHop { + sample_start, + sample_end, + rms, + }); +} + +/// Mean RMS of hops overlapping `[sample_start, sample_end)`, as dBFS. +/// +/// Missing hops, inverted ranges, or a silent window return `None`. This is +/// intensity on the PCM clock — not a confidence score. +pub fn session_energy_db(sample_start: u64, sample_end: u64) -> Option { + if sample_end <= sample_start { + return None; + } + let hops = session_energy_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let mut weighted = 0.0_f64; + let mut covered = 0.0_f64; + for hop in &hops.hops { + let lo = hop.sample_start.max(sample_start); + let hi = hop.sample_end.min(sample_end); + if hi <= lo { + continue; + } + let width = (hi - lo) as f64; + weighted += f64::from(hop.rms) * width; + covered += width; + } + if covered <= 0.0 { + return None; + } + let db = linear_to_db((weighted / covered) as f32); + db.is_finite().then_some(db) +} + +/// Remember the live capture path (device / rate / channels) without a new TCC prompt. +pub fn publish_open_capture_path(meta: CapturePathMeta) { + *last_open_path_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(meta); +} + +/// Last opened capture path, if the recorder published one this process. +pub fn last_open_capture_path() -> Option { + last_open_path_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() +} + +/// Last finalized capture receipt in this process, if any. +pub fn last_capture_level_receipt() -> Option { + last_receipt_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() +} + +fn remember_last(receipt: &CaptureLevelReceipt) { + *last_receipt_slot() + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(receipt.clone()); +} + +/// Convert a linear RMS (0..~1) to dBFS. Non-positive values are −∞. +pub fn linear_to_db(linear: f32) -> f32 { + if !linear.is_finite() || linear <= 0.0 { + return f32::NEG_INFINITY; + } + 20.0 * linear.log10() +} + +/// Convert dBFS back to linear amplitude. +pub fn db_to_linear(db: f32) -> f32 { + if !db.is_finite() { + return 0.0; + } + 10.0_f32.powf(db / 20.0) +} + +/// Low-level floor, env-overridable. Invalid / missing env keeps the default. +pub fn capture_level_low_db() -> f32 { + match std::env::var(CAPTURE_LEVEL_LOW_DB_ENV) { + Ok(raw) => raw + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v < 0.0) + .unwrap_or(DEFAULT_CAPTURE_LEVEL_LOW_DB), + Err(_) => DEFAULT_CAPTURE_LEVEL_LOW_DB, + } +} + +/// Input-path identity attached at finalize (seconds stay at adapters). +#[derive(Debug, Clone, PartialEq)] +pub struct CapturePathMeta { + pub device_name: String, + pub sample_rate: u32, + pub channels: u16, +} + +impl CapturePathMeta { + /// Device from the already-open capture path / `AUDIO_INPUT_DEVICE`. + /// Never opens a new Core Audio query — no new permission prompt. + pub fn from_open_path(sample_rate: u32, channels: u16, device_name: Option<&str>) -> Self { + let device_name = device_name + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .or_else(|| { + std::env::var("AUDIO_INPUT_DEVICE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "system_default".to_string()); + Self { + device_name, + sample_rate, + channels: channels.max(1), + } + } + + /// Prefer the already-open recorder path; fall back to env / defaults. + pub fn resolve(sample_rate: u32, channels: u16, device_name: Option<&str>) -> Self { + match last_open_capture_path() { + Some(open) => Self { + device_name: device_name + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .unwrap_or(open.device_name), + sample_rate: if sample_rate > 0 { + sample_rate + } else { + open.sample_rate + }, + channels: channels.max(open.channels).max(1), + }, + None => Self::from_open_path(sample_rate, channels, device_name), + } + } +} + +/// Running per-buffer capture stats. Cheap enough for the CoreAudio callback. +#[derive(Debug, Default)] +pub struct CaptureLevelAccumulator { + sample_count: u64, + digital_zero_samples: u64, + clipping_samples: u64, + peak_linear: f32, + all_block_rms: Vec, + speech_block_rms: Vec, + noise_block_rms: Vec, + dropout_blocks: u64, + seen_speech: bool, + trailing_zero_run: u64, +} + +impl CaptureLevelAccumulator { + /// Empty accumulator for one session. + pub fn new() -> Self { + Self::default() + } + + /// Ingest one captured block (mono f32, already downmixed). + pub fn push_samples(&mut self, samples: &[f32]) { + if samples.is_empty() { + return; + } + let mut sum_sq = 0.0_f64; + let mut zeros = 0_u64; + let mut clips = 0_u64; + let mut peak = 0.0_f32; + for sample in samples { + let x = if sample.is_finite() { *sample } else { 0.0 }; + let abs = x.abs(); + if abs <= DIGITAL_ZERO_ABS { + zeros += 1; + } + if abs >= CLIP_ABS { + clips += 1; + } + if abs > peak { + peak = abs; + } + sum_sq += f64::from(x) * f64::from(x); + } + let rms = (sum_sq / samples.len() as f64).sqrt() as f32; + let sample_start = self.sample_count; + self.sample_count += samples.len() as u64; + record_session_energy_hop(sample_start, self.sample_count, rms); + self.digital_zero_samples += zeros; + self.clipping_samples += clips; + if peak > self.peak_linear { + self.peak_linear = peak; + } + self.all_block_rms.push(rms); + + let all_digital_zero = zeros == samples.len() as u64; + if rms >= ACTIVE_SPEECH_LINEAR_FLOOR && !all_digital_zero { + if self.seen_speech && self.trailing_zero_run > 0 { + self.dropout_blocks += self.trailing_zero_run; + } + self.speech_block_rms.push(rms); + self.seen_speech = true; + self.trailing_zero_run = 0; + } else if all_digital_zero || rms <= DIGITAL_ZERO_ABS { + if self.seen_speech { + self.trailing_zero_run += 1; + } + } else { + self.noise_block_rms.push(rms); + self.trailing_zero_run = 0; + } + } + + /// Freeze the session receipt. `meta` is path identity, not a second clock. + pub fn finalize(&self, meta: CapturePathMeta) -> CaptureLevelReceipt { + let all_audio_median_db = median_db(&self.all_block_rms); + let active_speech_median_db = median_db(&self.speech_block_rms); + let noise_floor_db = median_db(&self.noise_block_rms); + let peak_db = linear_to_db(self.peak_linear); + let snr_db = if active_speech_median_db.is_finite() && noise_floor_db.is_finite() { + Some(active_speech_median_db - noise_floor_db) + } else { + None + }; + let threshold_db = capture_level_low_db(); + let low = !active_speech_median_db.is_finite() || active_speech_median_db < threshold_db; + CaptureLevelReceipt { + code: CAPTURE_LEVEL_RECEIPT_CODE, + device_name: meta.device_name, + sample_rate: meta.sample_rate, + channels: meta.channels, + sample_count: self.sample_count, + digital_zero_samples: self.digital_zero_samples, + active_speech_samples: self.speech_block_count_samples(), + clipping_samples: self.clipping_samples, + dropout_blocks: self.dropout_blocks, + all_audio_median_db, + active_speech_median_db, + peak_db, + noise_floor_db, + snr_db, + threshold_db, + low, + } + } + + fn speech_block_count_samples(&self) -> u64 { + // Block size is not uniform; report the speech-block count as a + // sample-adjacent figure via the digital-zero complement when possible. + self.sample_count.saturating_sub(self.digital_zero_samples) + } +} + +fn median_db(values: &[f32]) -> f32 { + if values.is_empty() { + return f32::NEG_INFINITY; + } + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let mid = sorted.len() / 2; + let linear = if sorted.len().is_multiple_of(2) { + (sorted[mid - 1] + sorted[mid]) * 0.5 + } else { + sorted[mid] + }; + linear_to_db(linear) +} + +/// Frozen session receipt. WARN is keyed on `active_speech_median_db`. +#[derive(Debug, Clone, PartialEq)] +pub struct CaptureLevelReceipt { + pub code: &'static str, + pub device_name: String, + pub sample_rate: u32, + pub channels: u16, + pub sample_count: u64, + pub digital_zero_samples: u64, + pub active_speech_samples: u64, + pub clipping_samples: u64, + pub dropout_blocks: u64, + pub all_audio_median_db: f32, + pub active_speech_median_db: f32, + pub peak_db: f32, + pub noise_floor_db: f32, + pub snr_db: Option, + pub threshold_db: f32, + pub low: bool, +} + +impl CaptureLevelReceipt { + /// Active-speech floor miss — the only WARN this receipt can raise. + pub fn is_low(&self) -> bool { + self.low + } + + /// Counts-only WARN text. No transcript content. + pub fn warning_message(&self) -> String { + format!( + "active_speech_median_db={:.1} threshold_db={:.1} all_audio_median_db={:.1} peak_db={:.1} samples={} digital_zero={} clip={} dropout={} device={} rate={} ch={}", + self.active_speech_median_db, + self.threshold_db, + self.all_audio_median_db, + self.peak_db, + self.sample_count, + self.digital_zero_samples, + self.clipping_samples, + self.dropout_blocks, + self.device_name, + self.sample_rate, + self.channels + ) + } + + /// Coarse quality token for a later Audio-menu surface. + pub fn quality_verdict(&self) -> &'static str { + if self.low { + "low" + } else if self.clipping_samples > 0 || self.dropout_blocks > 0 { + "degraded" + } else { + "ok" + } + } + + /// Session-end log line. Always info for the receipt; WARN is separate. + pub fn log(&self) { + info!( + code = self.code, + device = self.device_name.as_str(), + sample_rate = self.sample_rate, + channels = self.channels, + sample_count = self.sample_count, + digital_zero_samples = self.digital_zero_samples, + active_speech_samples = self.active_speech_samples, + clipping_samples = self.clipping_samples, + dropout_blocks = self.dropout_blocks, + all_audio_median_db = format!("{:.1}", self.all_audio_median_db), + active_speech_median_db = format!("{:.1}", self.active_speech_median_db), + peak_db = format!("{:.1}", self.peak_db), + noise_floor_db = format!("{:.1}", self.noise_floor_db), + snr_db = self.snr_db.map(|v| format!("{v:.1}")), + threshold_db = format!("{:.1}", self.threshold_db), + quality = self.quality_verdict(), + "capture_level_receipt" + ); + } +} + +/// Log the receipt and emit a non-terminal WARN when the active-speech floor +/// is missed. The sink still receives a Warning event; the bridge must keep +/// routing it off `on_error` via [`crate::pipeline::contracts::warning_is_user_terminal`]. +pub fn emit_capture_level_receipt(sink: &dyn EventSink, receipt: &CaptureLevelReceipt) { + receipt.log(); + remember_last(receipt); + if receipt.is_low() { + warn!( + code = CAPTURE_LEVEL_LOW_CODE, + active_speech_median_db = format!("{:.1}", receipt.active_speech_median_db), + threshold_db = format!("{:.1}", receipt.threshold_db), + "capture_level_low" + ); + sink.on_event(&EngineEvent::Warning { + code: CAPTURE_LEVEL_LOW_CODE.to_string(), + message: receipt.warning_message(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline::contracts::{USER_TERMINAL_WARNING_CODES, warning_is_user_terminal}; + use std::sync::Mutex; + + struct CapturingSink { + events: Mutex>, + } + + impl EventSink for CapturingSink { + fn on_event(&self, event: &EngineEvent) { + self.events.lock().expect("sink").push(event.clone()); + } + } + + fn tone(amplitude: f32, n: usize) -> Vec { + // Square wave: RMS equals amplitude, matching `block_rms` contracts. + (0..n) + .map(|i| if i % 2 == 0 { amplitude } else { -amplitude }) + .collect() + } + + fn zeros(n: usize) -> Vec { + vec![0.0; n] + } + + /// Active-speech median keys the WARN; digital-zero silence must not drag + /// it. Attenuated speech below −52 dB warns. The WARN is never terminal. + #[test] + fn w13_capture_receipt_active_speech() { + assert_eq!( + USER_TERMINAL_WARNING_CODES, + &["transcription_failed"], + "W13-5 must not enlarge the terminal-warning list" + ); + assert!( + !warning_is_user_terminal(CAPTURE_LEVEL_LOW_CODE), + "capture_level_low must stay a quality receipt" + ); + assert!( + !warning_is_user_terminal(CAPTURE_LEVEL_RECEIPT_CODE), + "capture_level_receipt must stay a quality receipt" + ); + + let golden = db_to_linear(-38.0); + let attenuated = db_to_linear(-58.0); + let meta = CapturePathMeta { + device_name: "EarPods".into(), + sample_rate: 48_000, + channels: 1, + }; + + let mut healthy = CaptureLevelAccumulator::new(); + // 191351-class mix: digital-zero floors around speech, both mic modes. + for _ in 0..20 { + healthy.push_samples(&zeros(512)); + } + for _ in 0..8 { + healthy.push_samples(&tone(golden, 512)); + } + for _ in 0..20 { + healthy.push_samples(&zeros(512)); + } + let healthy_receipt = healthy.finalize(meta.clone()); + assert!( + healthy_receipt.active_speech_median_db.is_finite(), + "active-speech median must be defined when speech is present" + ); + assert!( + (healthy_receipt.active_speech_median_db + 38.0).abs() < 1.5, + "active-speech median should sit near the golden −38 dB, got {}", + healthy_receipt.active_speech_median_db + ); + assert!( + healthy_receipt.all_audio_median_db < healthy_receipt.active_speech_median_db - 10.0 + || !healthy_receipt.all_audio_median_db.is_finite(), + "all-audio median must be dragged by digital-zero floors (all={} active={})", + healthy_receipt.all_audio_median_db, + healthy_receipt.active_speech_median_db + ); + assert!( + !healthy_receipt.is_low(), + "golden-era active speech must not WARN (active={})", + healthy_receipt.active_speech_median_db + ); + assert_eq!(healthy_receipt.device_name, "EarPods"); + assert_eq!(healthy_receipt.sample_rate, 48_000); + assert_eq!(healthy_receipt.channels, 1); + assert!(healthy_receipt.digital_zero_samples > 0); + assert!(healthy_receipt.sample_count > healthy_receipt.digital_zero_samples); + assert_eq!(healthy_receipt.quality_verdict(), "ok"); + + let healthy_sink = CapturingSink { + events: Mutex::new(Vec::new()), + }; + emit_capture_level_receipt(&healthy_sink, &healthy_receipt); + assert!( + healthy_sink.events.lock().expect("sink").is_empty(), + "normal level must not emit capture_level_low" + ); + + let mut quiet = CaptureLevelAccumulator::new(); + for _ in 0..12 { + quiet.push_samples(&zeros(512)); + } + for _ in 0..8 { + quiet.push_samples(&tone(attenuated, 512)); + } + for _ in 0..12 { + quiet.push_samples(&zeros(512)); + } + let quiet_receipt = quiet.finalize(meta); + assert!( + quiet_receipt.active_speech_median_db < DEFAULT_CAPTURE_LEVEL_LOW_DB, + "attenuated take must sit below −52 dB, got {}", + quiet_receipt.active_speech_median_db + ); + assert!( + quiet_receipt.is_low(), + "attenuated active speech must WARN (active={})", + quiet_receipt.active_speech_median_db + ); + + let quiet_sink = CapturingSink { + events: Mutex::new(Vec::new()), + }; + emit_capture_level_receipt(&quiet_sink, &quiet_receipt); + let events = quiet_sink.events.lock().expect("sink"); + match events.as_slice() { + [EngineEvent::Warning { code, message }] => { + assert_eq!(code, CAPTURE_LEVEL_LOW_CODE); + assert!( + message.contains("active_speech_median_db="), + "WARN must name the active-speech key: {message}" + ); + assert!( + !message.contains("Dictation stopped"), + "WARN text must not look terminal: {message}" + ); + } + other => panic!("expected one capture_level_low warning, got {other:?}"), + } + assert!( + !warning_is_user_terminal(CAPTURE_LEVEL_LOW_CODE), + "emitting the WARN must not change the terminal class" + ); + } + + #[test] + fn session_energy_db_is_pcm_range_intensity() { + begin_session_energy_clock(); + let mut acc = CaptureLevelAccumulator::new(); + acc.push_samples(&vec![0.0; 160]); + acc.push_samples(&vec![0.1; 160]); + acc.push_samples(&vec![0.0; 160]); + assert!( + session_energy_db(0, 160).is_none(), + "digital-zero hops have no finite dBFS" + ); + let speech = session_energy_db(160, 320).expect("speech hop"); + assert!(speech.is_finite()); + assert!(session_energy_db(480, 640).is_none()); + begin_session_energy_clock(); + assert!(session_energy_db(160, 320).is_none()); + } +} diff --git a/core/audio/chunker.rs b/core/audio/chunker.rs index 037f720f..65a72a8c 100644 --- a/core/audio/chunker.rs +++ b/core/audio/chunker.rs @@ -742,6 +742,16 @@ impl SpeechSession { events } + /// Half-open raw-sample range of the currently open Supervisor segment. + /// + /// `None` when Silero has not opened a speech edge. Used by the W13-3B + /// fusion lane to mint utterance identity on the same PCM cursor the + /// Apple worker already owns. + pub(crate) fn open_segment_raw_range(&self) -> Option<(u64, u64)> { + let start = self.segment_start?; + Some((start as u64, self.raw_cursor as u64)) + } + /// Close the session and emit whatever is still open. /// /// Recording usually stops mid-segment, so an open Supervisor segment is @@ -1090,6 +1100,16 @@ impl SpeechSession { self.output_sample_rate } + /// Whether Silero actually loaded for this session. + /// + /// A missing model is not fatal here — [`Self::predict_speech_prob`] reads + /// every frame as non-speech — but a consumer that *gates* on speech edges + /// (the Apple engine lifecycle) would then never see one and would rest + /// forever. Such consumers must ask first and fail open. + pub(crate) fn vad_available(&self) -> bool { + self.vad.is_some() + } + /// Speech probability at the last VAD Start/End boundary. pub(crate) fn boundary_prob(&self) -> f32 { self.last_boundary_prob diff --git a/core/audio/mod.rs b/core/audio/mod.rs index f7a1f278..3bf24258 100644 --- a/core/audio/mod.rs +++ b/core/audio/mod.rs @@ -8,6 +8,8 @@ /// Offline audio archive helpers (internal packaging of captured WAV/PCM). pub(crate) mod archive; +/// Per-session capture-level receipt (active-speech RMS, non-terminal WARN). +pub mod capture_receipt; /// Fixed-size PCM framing for STT windows and streaming hops. pub mod chunker; /// Decode audio files (WAV/MP3/…) into mono PCM for the STT path. diff --git a/core/audio/recorder.rs b/core/audio/recorder.rs index d3a28662..8f2674e0 100644 --- a/core/audio/recorder.rs +++ b/core/audio/recorder.rs @@ -62,7 +62,7 @@ use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::{Device, Stream, StreamConfig}; use hound::{WavSpec, WavWriter}; use std::collections::VecDeque; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; @@ -273,6 +273,10 @@ pub struct Recorder { diagnostics: RecorderDiagnostics, /// Actual sample rate used for recording (may differ from config) actual_sample_rate: u32, + /// Last resolved input device name (empty until `start`). + last_input_device: String, + /// Native channel count of the last opened stream (1 after downmix). + last_native_channels: u16, on_data: Option, /// Disk spill of the full streaming take (operator decision B): survives /// the RAM ring cap; `None` when disabled or not a streaming session. @@ -320,6 +324,8 @@ impl Recorder { last_duration: 0.0, diagnostics: RecorderDiagnostics::default(), actual_sample_rate: config.sample_rate, // Will be updated in start() + last_input_device: String::new(), + last_native_channels: 1, on_data: None, on_vad_stop: None, recorder_vad: None, @@ -361,6 +367,17 @@ impl Recorder { self.actual_sample_rate } + /// Device name resolved for the last `start()`, if any. + pub fn last_input_device(&self) -> Option<&str> { + let name = self.last_input_device.trim(); + if name.is_empty() { None } else { Some(name) } + } + + /// Native channel count of the last opened input stream. + pub fn last_native_channels(&self) -> u16 { + self.last_native_channels.max(1) + } + /// Returns true when the recorder still has an active stream/session. /// /// This is used by higher-level state recovery to detect desyncs where the @@ -439,6 +456,7 @@ impl Recorder { .map(|d| d.to_string()) .unwrap_or_else(|_| "Unknown".to_string()); info!("Using input device: {}", device_name); + self.last_input_device = device_name; // Get supported config let supported_config = device @@ -449,6 +467,7 @@ impl Recorder { // (backend will handle resampling if needed) let native_sample_rate = supported_config.sample_rate(); let native_channels = supported_config.channels().max(1); + self.last_native_channels = native_channels; // Build stream config using native sample rate/channel count. The // callback downmixes interleaved native channels to mono for downstream. @@ -1056,6 +1075,27 @@ fn write_wav_file(path: &PathBuf, samples: &[i16], sample_rate: u32, channels: u Ok(()) } +/// Recorded length of a WAV file in seconds, read from its header. +/// +/// Header-only: `hound` parses the `fmt `/`data` chunk sizes and reports the +/// frame count without decoding a single sample, so this is cheap enough to sit +/// on a latency-sensitive stop path. `duration()` counts frames per channel, +/// which is what "seconds of audio" means for both the mono capture path here +/// and any multi-channel file that reaches it. +/// +/// Returns `None` on an unreadable or truncated header and on a zero sample +/// rate. Failure is never reported as `0.0`: a caller measuring speech density +/// must be able to tell "I could not measure this audio" apart from "this audio +/// is empty", because the two demand opposite decisions. +pub fn wav_duration_secs(path: &Path) -> Option { + let reader = hound::WavReader::open(path).ok()?; + let sample_rate = reader.spec().sample_rate; + if sample_rate == 0 { + return None; + } + Some(reader.duration() as f32 / sample_rate as f32) +} + /// Recorder defaults, auto-silence gating, streaming buffer cap, and downmix. #[cfg(test)] mod tests { @@ -1063,6 +1103,31 @@ mod tests { // Note: RMS tests removed - now using Silero VAD (see vad module tests) + /// The stop-path density guard divides by this number, so it must come from + /// the header exactly — and a file the probe cannot parse must report `None` + /// rather than a plausible `0.0`, which would read as total starvation. + #[test] + fn wav_duration_secs_reads_header_and_refuses_unreadable_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("take.wav"); + let rate = 16_000u32; + // 2.5 s of mono silence: duration must be frames/rate, not file size. + let samples = vec![0i16; rate as usize * 5 / 2]; + write_wav_file(&path, &samples, rate, 1).expect("write wav"); + + let secs = wav_duration_secs(&path).expect("written header must be readable"); + assert!((secs - 2.5).abs() < 1e-3, "expected 2.5 s, got {secs}"); + + let garbage = dir.path().join("not-a-wav.bin"); + std::fs::write(&garbage, b"definitely not RIFF").expect("write garbage"); + assert_eq!( + wav_duration_secs(&garbage), + None, + "an unparseable header must not report a duration" + ); + assert_eq!(wav_duration_secs(&dir.path().join("missing.wav")), None); + } + /// Operator decision B (2026-08-10): the RAM ring caps at /// STREAMING_BUFFER_CAP_SECONDS, so a phone-call take longer than 5 min /// lost its head in the archived WAV. The disk spill must retain EVERY diff --git a/core/audio/streaming_recorder.rs b/core/audio/streaming_recorder.rs index 491e4fdd..618cc8ee 100644 --- a/core/audio/streaming_recorder.rs +++ b/core/audio/streaming_recorder.rs @@ -3,8 +3,9 @@ //! [`StreamingRecorder`] owns a [`Recorder`] and forwards every captured block //! down a bounded channel to `transcription_session`, which emits `EngineEvent`s //! to a caller-supplied sink. The channel is deliberately deep -//! (`AUDIO_BACKLOG_CHUNKS`): a cold Whisper load happens *behind* it, so the -//! user's first words queue up instead of being dropped while the model loads. +//! (`AUDIO_BACKLOG_CHUNKS`): an explicitly selected Local power session may +//! cold-load Whisper *behind* it, so the user's first words queue up instead of +//! being dropped while the model loads. //! //! Shutdown is ordered and matters. Stopping capture is not enough — the session //! task has to drain, and the presentation layer ticks on its own task, so both @@ -12,21 +13,128 @@ //! seconds) before releasing the sink. Dropping the sink early truncates the //! tail of the delivered text. +use crate::asr_session::bootstrap::{GatewaySessionAvailability, layer1_decision_for_recording}; +use crate::asr_session::recorder::{ + Layer1Decision, RecorderLifecycleEvents, RecorderLifecycleHandle, recorder_lifecycle_channel, +}; use crate::audio::recorder::{Recorder, RecorderConfig}; -use crate::pipeline::contracts::EventSink; -use crate::pipeline::streaming::{SessionConfig, stream_log_path, transcription_session}; +use crate::config::UserSettings; +use crate::config::cloud_asr::AsrProductMode; +use crate::pipeline::contracts::{EngineEvent, EventSink}; +use crate::pipeline::streaming::{ + SessionConfig, collect_buffered_engine_events_with_config, stream_log_path, + transcription_session, +}; use anyhow::{Context, Result, anyhow}; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicU64, Ordering}; use tokio::sync::{Mutex, mpsc}; use tokio::task::JoinHandle; use tracing::{debug, info, warn}; -// Keep enough raw audio queued to survive a cold Whisper load without dropping -// the user's first words. The STT session drains this backlog once the model is ready. -/// Channel depth for cold Whisper load: first words queue instead of drop. +// Keep enough raw audio queued to survive an allowed Local power cold load +// without dropping the user's first words. Cloud and Apple-only never load it. +/// Channel depth for an explicit Local power cold load. const AUDIO_BACKLOG_CHUNKS: usize = 2048; +/// Content-free witness returned by the production PCM replay seam. +#[derive(Debug)] +pub struct ProductionSessionReplay { + /// Ordered event stream emitted by the same session implementation as live capture. + pub events: Vec, + /// Whether recording-start policy armed a Layer 1 provider before the + /// single-use decision was consumed by the session. + pub layer1_armed: bool, + /// Engine that actually owned the live canvas for this replay session. + pub streaming_engine_label: String, +} + +/// Resolve the production Layer 1 decision for one recording. +/// +/// Both the microphone owner and the replay seam call this symbol. Keeping the +/// settings/consent/gateway decision here prevents an evaluation harness from +/// silently substituting `Layer1Decision::Disarmed`. +pub fn production_layer1_decision( + settings: &UserSettings, + gateway: GatewaySessionAvailability, +) -> Layer1Decision { + layer1_decision_for_recording(settings, gateway) +} + +/// Whether this recording may initialize any in-process Whisper/ONNX weights. +/// +/// The product mode is the authority: cloud and Apple-only fail closed to the +/// Apple canvas, while only an explicit Local power choice permits local +/// inference. Engine preference and legacy layered flags cannot widen this. +pub fn production_local_whisper_allowed(settings: &UserSettings) -> bool { + matches!( + settings.resolved_asr_mode().mode, + AsrProductMode::LocalPower + ) +} + +/// Build the exact engine session configuration consumed by live capture. +fn recording_session_config( + sample_rate: u32, + language: Option, + stream_log_path: Option, + utterance_silence_sec: Option, + layer1: Layer1Decision, + local_whisper_allowed: bool, + lifecycle_events: Option, +) -> SessionConfig { + SessionConfig { + sample_rate, + language, + stream_log_path, + utterance_silence_sec, + layer1, + local_whisper_allowed, + lifecycle_events, + } +} + +/// Replay fixture PCM through the production recording-session cone. +/// +/// The only differing boundary is PCM ingress: 100 ms in-memory chunks replace +/// CoreAudio callback blocks. Decision construction, `SessionConfig`, session +/// semantics, Layer 1 fan-out, VAD, Apple/Whisper events, and shutdown drainage +/// all remain owned by the same production symbols as microphone capture. +pub async fn replay_production_session( + samples: &[f32], + sample_rate: u32, + language: Option, + settings: &UserSettings, + gateway: GatewaySessionAvailability, +) -> Result { + let layer1 = production_layer1_decision(settings, gateway); + let layer1_armed = layer1.is_armed(); + let local_whisper_allowed = production_local_whisper_allowed(settings); + let streaming_engine_label = if crate::stt::recording_engine_is_apple(local_whisper_allowed) { + "live_apple" + } else { + "streaming_whisper" + } + .to_string(); + let utterance_silence_sec = settings.toggle_silence_sec.filter(|&sec| sec >= 0.5); + let config = recording_session_config( + sample_rate, + language, + None, + utterance_silence_sec, + layer1, + local_whisper_allowed, + None, + ); + let events = collect_buffered_engine_events_with_config(samples, config).await?; + Ok(ProductionSessionReplay { + events, + layer1_armed, + streaming_engine_label, + }) +} + /// A recording session that transcribes while it captures. /// /// Configure the sink and any callbacks first, then call @@ -47,6 +155,13 @@ pub struct StreamingRecorder { /// block (linear, 0..~1). Runs on the CoreAudio callback thread — keep it /// cheap and non-blocking (a broadcast send, an atomic store). level_callback: Option>, + /// Single-use Layer 1 decision consumed when the next session starts. + layer1_decision: StdMutex, + /// Product-mode guard for every legacy/local inference path in the next + /// recording. False for cloud and Apple-only; true only for Local power. + local_whisper_allowed: bool, + /// O(1) host lifecycle signal for the currently active session. + lifecycle_handle: Option, } impl StreamingRecorder { @@ -68,6 +183,9 @@ impl StreamingRecorder { dropped_chunks: Arc::new(AtomicU64::new(0)), event_sink: None, level_callback: None, + layer1_decision: StdMutex::new(Layer1Decision::Disarmed), + local_whisper_allowed: false, + lifecycle_handle: None, }) } @@ -89,9 +207,27 @@ impl StreamingRecorder { dropped_chunks: Arc::new(AtomicU64::new(0)), event_sink: None, level_callback: None, + layer1_decision: StdMutex::new(Layer1Decision::Disarmed), + local_whisper_allowed: false, + lifecycle_handle: None, }) } + /// Join live settings truth with one minted gateway session for the next + /// recording. Missing/invalid/offline gateway state safely disarms Layer 1. + pub fn configure_layer1( + &mut self, + settings: &UserSettings, + gateway: GatewaySessionAvailability, + ) { + self.local_whisper_allowed = production_local_whisper_allowed(settings); + *self + .layer1_decision + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + production_layer1_decision(settings, gateway); + } + /// Store a per-utterance text callback. /// /// Note: the stored value is currently never read by this type — completed @@ -136,6 +272,18 @@ impl StreamingRecorder { self.recorder.is_active() } + /// Notify the active transcription task that the host crossed sleep/wake. + /// + /// No active capture is a normal no-op. This method only enqueues a typed + /// boundary; the session loop owns the fail-closed Layer 1 transition. + pub fn note_sleep_wake(&self) -> bool { + self.recorder.is_active() + && self + .lifecycle_handle + .as_ref() + .is_some_and(RecorderLifecycleHandle::note_sleep_wake) + } + /// Start recording with the new event-based pipeline. /// /// Uses `transcription_session` which emits `EngineEvent`s to the configured @@ -175,6 +323,13 @@ impl StreamingRecorder { // Update sample rate to match real input stream let actual_sample_rate = self.recorder.actual_sample_rate(); + crate::audio::capture_receipt::publish_open_capture_path( + crate::audio::capture_receipt::CapturePathMeta::from_open_path( + actual_sample_rate, + self.recorder.last_native_channels(), + self.recorder.last_input_device(), + ), + ); if actual_sample_rate != self.sample_rate { info!( "StreamingRecorder sample_rate updated: config={}Hz -> actual={}Hz", @@ -186,16 +341,27 @@ impl StreamingRecorder { let log_path = stream_log_path(); let utterance_silence_sec = self.utterance_silence_sec; + let layer1 = std::mem::take( + self.layer1_decision + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + let local_whisper_allowed = self.local_whisper_allowed; + let (lifecycle_handle, lifecycle_events) = recorder_lifecycle_channel(); + self.lifecycle_handle = Some(lifecycle_handle); self.transcription_handle = Some(tokio::spawn(async move { transcription_session( rx, event_sink, - SessionConfig { - sample_rate: actual_sample_rate, + recording_session_config( + actual_sample_rate, language, - stream_log_path: log_path, + log_path, utterance_silence_sec, - }, + layer1, + local_whisper_allowed, + Some(lifecycle_events), + ), ) .await; })); @@ -223,6 +389,7 @@ impl StreamingRecorder { // 1. Stop recording (drops callback and sender) let audio_path = self.recorder.stop().await?; + self.lifecycle_handle = None; // 2. Wait for worker to finish processing remaining chunks if let Some(handle) = self.transcription_handle.take() { @@ -279,6 +446,7 @@ impl StreamingRecorder { // 1. Stop recording (discard WAV path) let _ = self.recorder.stop().await?; + self.lifecycle_handle = None; // 2. Wait for worker to finish processing remaining chunks if let Some(handle) = self.transcription_handle.take() { @@ -341,6 +509,50 @@ mod tests { use std::path::{Path, PathBuf}; use tokio::time::Duration; + /// Product mode, not engine/env preference, is the sole authority that may + /// let a normal recording initialize local Whisper weights. + #[test] + fn normal_recording_allows_local_weights_only_in_local_power_mode() { + let cloud = UserSettings { + asr_mode: Some("cloud".to_string()), + cloud_consent: Some("granted".to_string()), + ..UserSettings::default() + }; + let apple_only = UserSettings { + asr_mode: Some("apple_only".to_string()), + ..UserSettings::default() + }; + let local_power = UserSettings { + asr_mode: Some("local_power".to_string()), + ..UserSettings::default() + }; + + assert!(!production_local_whisper_allowed(&cloud)); + assert!(!production_local_whisper_allowed(&apple_only)); + assert!(production_local_whisper_allowed(&local_power)); + } + + /// The replay seam must consume the same recording-start decision as the + /// microphone path; a test-only `Disarmed` shortcut would make corpus + /// quality evidence adjacent to production again. + #[test] + fn replay_production_session_cannot_hardcode_disarmed_layer1() { + let source = include_str!("streaming_recorder.rs"); + let replay_body = source + .split("pub async fn replay_production_session") + .nth(1) + .and_then(|tail| tail.split("impl StreamingRecorder").next()) + .expect("production replay body remains present"); + assert!( + replay_body.contains("production_layer1_decision(settings, gateway)"), + "replay must resolve the same production Layer 1 policy as microphone capture" + ); + assert!( + !replay_body.contains("Layer1Decision::Disarmed"), + "replay must not silently hard-code a disarmed Layer 1 lane" + ); + } + /// Empty/silence/full-scale blocks map to the 0 / 0 / ~1 energy ladder meters use. #[test] fn block_rms_measures_signal_energy() { diff --git a/core/bin/codescribe-stt-sidecar.rs b/core/bin/codescribe-stt-sidecar.rs new file mode 100644 index 00000000..5396f479 --- /dev/null +++ b/core/bin/codescribe-stt-sidecar.rs @@ -0,0 +1,87 @@ +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow, bail}; +use codescribe_core::stt::tail_provider::{ + FakeTailProvider, InProcessTailProvider, STT_SIDECAR_TOKEN_ENV, TailProvider, + TailProviderPayload, serve_sidecar, +}; + +struct Args { + bind: SocketAddr, + parent_pid: Option, + #[cfg(debug_assertions)] + fake_payload: Option, +} + +fn main() -> Result<()> { + let args = parse_args()?; + let token = std::env::var(STT_SIDECAR_TOKEN_ENV).context("sidecar process token is missing")?; + // SAFETY: main is still single-threaded and no library worker has started. + unsafe { std::env::remove_var(STT_SIDECAR_TOKEN_ENV) }; + + #[cfg(debug_assertions)] + let provider: Box = if let Some(path) = args.fake_payload { + let payload: TailProviderPayload = serde_json::from_slice( + &std::fs::read(&path).with_context(|| format!("read fixture {}", path.display()))?, + ) + .context("parse sidecar fixture payload")?; + Box::new(FakeTailProvider::new(payload)?) + } else { + Box::new(InProcessTailProvider) + }; + #[cfg(not(debug_assertions))] + let provider: Box = Box::new(InProcessTailProvider); + + serve_sidecar(args.bind, token, provider.as_ref(), args.parent_pid) +} + +fn parse_args() -> Result { + let mut bind: Option = None; + let mut parent_pid = None; + #[cfg(debug_assertions)] + let mut fake_payload = None; + let mut args = std::env::args_os().skip(1); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--bind") => { + let value = args.next().ok_or_else(|| anyhow!("--bind needs a value"))?; + bind = Some( + value + .to_string_lossy() + .parse() + .context("invalid --bind socket address")?, + ); + } + Some("--parent-pid") => { + let value = args + .next() + .ok_or_else(|| anyhow!("--parent-pid needs a value"))?; + parent_pid = Some( + value + .to_string_lossy() + .parse() + .context("invalid --parent-pid")?, + ); + } + #[cfg(debug_assertions)] + Some("--fake-payload") => { + fake_payload = Some(PathBuf::from( + args.next() + .ok_or_else(|| anyhow!("--fake-payload needs a value"))?, + )); + } + _ => bail!("unknown sidecar argument"), + } + } + let bind = bind.ok_or_else(|| anyhow!("--bind is required"))?; + if !bind.ip().is_loopback() { + bail!("sidecar bind must be loopback"); + } + Ok(Args { + bind, + parent_pid, + #[cfg(debug_assertions)] + fake_payload, + }) +} diff --git a/core/build.rs b/core/build.rs index 68433271..a9210c3b 100644 --- a/core/build.rs +++ b/core/build.rs @@ -8,7 +8,10 @@ //! (`resolve_runtime_whisper_model_path`) — the model is held in memory for the //! session anyway, so baking ~1GB into every artifact only multiplied target/ //! into tens of GB for zero runtime win (2026-06-10 policy, operator-decided). -//! Release builds still embed Silero VAD + MiniLM embedder by default. +//! MiniLM follows the same runtime-load policy as Whisper: normal builds load +//! it from the app resource bundle or HF cache; only +//! `CODESCRIBE_EMBED_EMBEDDER=1` bakes it into the Rust artifact. Silero VAD +//! remains embedded because it is small and part of capture identity. //! Opt-out of all optional embedding with CODESCRIBE_NO_EMBED=1 (except Silero). //! TTS requires opt-in via CODESCRIBE_EMBED_TTS. //! @@ -31,9 +34,11 @@ use license_key_contract::{ }; /// Default Whisper model to embed -const DEFAULT_MODEL_NAME: &str = "whisper-large-v3-turbo-mlx-q8"; +const DEFAULT_MODEL_NAME: &str = "whisper-large-v3-turbo"; /// Hugging Face repo id for the default Whisper snapshot (HF cache + download hints). -const DEFAULT_WHISPER_REPO: &str = "LibraxisAI/whisper-large-v3-turbo-mlx-q8"; +/// The repo ships only config + fp16 weights; `make download-model` composes +/// the official OpenAI tokenizer and pinned mel filters into the runtime dir. +const DEFAULT_WHISPER_REPO: &str = "mlx-community/whisper-large-v3-turbo"; /// Default TTS model to embed const DEFAULT_TTS_MODEL_NAME: &str = "csm-1b"; @@ -42,10 +47,10 @@ const DEFAULT_TTS_REPO: &str = "sesame/csm-1b"; /// Hugging Face repo id for Mimi codec weights used with TTS embedding. const DEFAULT_MIMI_REPO: &str = "kyutai/mimi"; -/// Default embedder model — MiniLM multilingual (~224MB fp16, always embedded like Silero) +/// Default embedder model — MiniLM multilingual (~471MB fp32 weights on disk). /// Override with CODESCRIBE_EMBEDDER_REPO for alternative models const DEFAULT_EMBEDDER_MODEL_NAME: &str = "minilm-l12-v2"; -/// Default sentence-transformers MiniLM repo embedded unless `CODESCRIBE_NO_EMBED`. +/// Default sentence-transformers MiniLM repo resolved from bundle/cache at runtime. const DEFAULT_EMBEDDER_REPO: &str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"; /// Env flag: local install path skips release license-key hardening in `main`. const LOCAL_INSTALL_ENV: &str = "CODESCRIBE_LOCAL_INSTALL"; @@ -54,16 +59,17 @@ const LOCAL_INSTALL_ENV: &str = "CODESCRIBE_LOCAL_INSTALL"; /// compiles against. /// /// Each asset has its own policy, and they are not symmetrical: Silero VAD is -/// non-negotiable and its absence panics; MiniLM embeds by default; Whisper and -/// TTS are opt-in. A requested-but-missing model degrades to a `cargo:warning` -/// and a runtime lookup rather than failing the build — only the committed -/// Silero file is treated as a repo invariant. +/// non-negotiable and its absence panics; MiniLM, Whisper and TTS are opt-in. +/// MiniLM and TTS degrade to a `cargo:warning` when requested but missing. +/// Whisper embed is fail-closed: `CODESCRIBE_EMBED_WHISPER=1` without a +/// complete snapshot must not produce a `_full` artifact that is actually slim. fn main() { println!("cargo:rerun-if-changed=Cargo.toml"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_MODEL"); println!("cargo:rerun-if-env-changed=CODESCRIBE_MODEL_PATH"); println!("cargo:rerun-if-env-changed=CODESCRIBE_NO_EMBED"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_WHISPER"); + println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_EMBEDDER"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBED_TTS"); println!("cargo:rerun-if-env-changed=CODESCRIBE_TTS_PATH"); println!("cargo:rerun-if-env-changed=CODESCRIBE_EMBEDDER_REPO"); @@ -142,15 +148,15 @@ fn main() { .expect("Failed to write embedded_model_data.rs"); println!("cargo:rustc-cfg=embed_model"); } else if embed_whisper_requested && !no_embed && !model_exists { - println!( - "cargo:warning=Whisper model not found for embedding: {}", - model_path.display() - ); - println!( - "cargo:warning=Download with: hf download {}", - DEFAULT_WHISPER_REPO + panic!( + "CODESCRIBE_EMBED_WHISPER=1 but no complete Whisper snapshot at {}. \ +Need config.json + tokenizer.json + mel_filters.npz + weights/model.safetensors. \ +The HF repo {} is weights-only; compose it with `make download-model` into \ +~/.codescribe/models/{}, or set CODESCRIBE_MODEL_PATH to that directory.", + model_path.display(), + DEFAULT_WHISPER_REPO, + DEFAULT_MODEL_NAME ); - println!("cargo:warning=Falling back to runtime Whisper lookup for this build"); } // TTS model embedding (optional, via CODESCRIBE_EMBED_TTS=1) @@ -209,8 +215,9 @@ fn main() { ); } - // MiniLM embedder — always embedded (like Silero), ~224MB fp16 - // Skip only with CODESCRIBE_NO_EMBED=1 + // MiniLM embedder — runtime bundle/cache by default, matching Whisper. + // Binary embedding is an explicit fat-SKU/debug request only. + let embed_embedder_requested = env_flag("CODESCRIBE_EMBED_EMBEDDER", false); let embedder_repo = env::var("CODESCRIBE_EMBEDDER_REPO") .ok() .map(|v| v.trim().to_string()) @@ -223,7 +230,8 @@ fn main() { && embedder_model_path.join("tokenizer.json").exists() && embedder_model_path.join("model.safetensors").exists(); - if !no_embed && embedder_model_exists { + let embedder_embedded = embed_embedder_requested && !no_embed && embedder_model_exists; + if embedder_embedded { println!( "cargo:warning=Embedding MiniLM model from: {}", embedder_model_path.display() @@ -244,7 +252,7 @@ fn main() { fs::write(&embedder_dest_path, embedder_content) .expect("Failed to write embedded_embedder_data.rs"); println!("cargo:rustc-cfg=embed_embedder"); - } else if !no_embed && !embedder_model_exists { + } else if embed_embedder_requested && !no_embed && !embedder_model_exists { println!( "cargo:warning=Embedder model not found at: {}", embedder_model_path.display() @@ -320,12 +328,12 @@ fn main() { }; let embedder_summary = if qube_context { "not_used" - } else if !no_embed && embedder_model_exists { + } else if embedder_embedded { "embedded" - } else if no_embed { - "runtime_load_from_cache" - } else { + } else if embed_embedder_requested && !no_embed { "missing_at_build_time" + } else { + "runtime_load_from_bundle_or_cache" }; let tts_summary = if qube_context { "not_used" @@ -417,30 +425,62 @@ fn resolve_embed_model_path(manifest_dir: &str, embed_model: &str) -> PathBuf { Path::new(manifest_dir).join("models").join(embed_model) } +/// True when a directory can be baked into the fat SKU. +/// +/// The default HF Whisper repo is weights-only. `make download-model` composes +/// tokenizer + mel into `~/.codescribe/models/`. Incomplete snapshots +/// must not win over that composed tree. +fn whisper_dir_complete(path: &Path) -> bool { + let weights = if path.join("weights.safetensors").exists() { + path.join("weights.safetensors") + } else { + path.join("model.safetensors") + }; + path.join("config.json").exists() + && path.join("tokenizer.json").exists() + && path.join("mel_filters.npz").exists() + && weights.exists() +} + /// Locate the Whisper snapshot to embed. /// -/// `CODESCRIBE_MODEL_PATH` wins when it points at a real snapshot — it is the -/// explicit operator override. Otherwise a repo-id-shaped name is looked up in -/// the HF cache, the default name falls back to the default repo, and anything -/// left resolves as a plain path. +/// `CODESCRIBE_MODEL_PATH` wins when it is a complete snapshot. The composed +/// `~/.codescribe/models` tree is next — that is what `make download-model` +/// writes. An HF cache hit is used only when it already has tokenizer + mel. fn resolve_whisper_embed_model_path( manifest_dir: &str, embed_model: &str, default_repo: &str, ) -> PathBuf { - // CODESCRIBE_MODEL_PATH takes priority — explicit user override if let Ok(model_path) = env::var("CODESCRIBE_MODEL_PATH") { let p = PathBuf::from(model_path.trim()); - if p.join("config.json").exists() { + if whisper_dir_complete(&p) { return p; } } - if embed_model.contains('/') { - if let Some(snapshot) = find_hf_snapshot(embed_model) { - return snapshot; + if let Some(home) = dirs::home_dir() { + let composed = home.join(".codescribe").join("models").join(embed_model); + if whisper_dir_complete(&composed) { + return composed; } + if embed_model == DEFAULT_MODEL_NAME { + let default_composed = home + .join(".codescribe") + .join("models") + .join(DEFAULT_MODEL_NAME); + if whisper_dir_complete(&default_composed) { + return default_composed; + } + } + } + if embed_model.contains('/') + && let Some(snapshot) = find_hf_snapshot(embed_model) + && whisper_dir_complete(&snapshot) + { + return snapshot; } else if embed_model == DEFAULT_MODEL_NAME && let Some(snapshot) = find_hf_snapshot(default_repo) + && whisper_dir_complete(&snapshot) { return snapshot; } diff --git a/core/config/cloud_asr.rs b/core/config/cloud_asr.rs new file mode 100644 index 00000000..57da88c9 --- /dev/null +++ b/core/config/cloud_asr.rs @@ -0,0 +1,509 @@ +//! Product truth for the Layer 1 ASR mode and audio-egress consent (C2). +//! +//! One brain, three modes: `cloud | local_power | apple_only`. The resolver in +//! this module is the only place the persisted mode string, the consent record, +//! and the legacy `use_local_stt` choice combine into a runtime decision — the +//! settings UI, the session factory, and future onboarding all consume +//! [`ResolvedAsrMode`] instead of re-deriving policy from raw fields. +//! +//! ## Doctrine encoded here +//! +//! - **Fresh install is Apple-only.** No persisted choice and no legacy signal +//! resolves to [`AsrProductMode::AppleOnly`] — never a hidden local model +//! load, never cloud. +//! - **Cloud requires explicit audio-egress consent.** A `cloud` mode value +//! without a granted consent record resolves to Apple-only. Missing, +//! unknown, or denied consent are all the same answer: no egress. +//! - **Upgrades preserve the prior local/cloud choice.** An installed user who +//! explicitly persisted `use_local_stt` keeps the corresponding mode; a +//! prior cloud choice carries its own consent evidence +//! ([`ConsentSource::LegacyCloudChoice`]) because that user already +//! configured and used an off-device transcription path on purpose. +//! - **No consent fallback may select local weights.** Every refusal lands on +//! Apple-only; `local_power` is reachable only as an explicit choice. +//! - **The gateway mint config carries no vendor keys.** [`GatewaySessionMint`] +//! is an endpoint, not a credential: URLs with user-info or query material +//! are refused at construction, and there is no field a vendor key could +//! occupy. Short-lived session bearers are minted by the Libraxis gateway +//! outside the desktop and consumed by `asr_session::cloud`. + +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::str::FromStr; + +/// Wire value for a granted audio-egress consent record. +pub const CONSENT_WIRE_GRANTED: &str = "granted"; +/// Wire value for an explicitly denied audio-egress consent record. +pub const CONSENT_WIRE_DENIED: &str = "denied"; + +/// First-class Layer 1 product mode chosen by the user. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum AsrProductMode { + /// Normalized live cloud session behind the Libraxis gateway contract. + Cloud, + /// Power-user local helper with on-demand weights (killable process, L0). + LocalPower, + /// Apple canvas + lexicon only. The safe floor every failure resolves to. + AppleOnly, +} + +impl AsrProductMode { + /// Stable persisted identifier; round-trips through [`FromStr`]. + pub fn as_str(&self) -> &'static str { + match self { + Self::Cloud => "cloud", + Self::LocalPower => "local_power", + Self::AppleOnly => "apple_only", + } + } + + /// Human-readable name for the settings UI. Presentation only. + pub fn label(&self) -> &'static str { + match self { + Self::Cloud => "Cloud (Libraxis gateway)", + Self::LocalPower => "Local power (on-device weights)", + Self::AppleOnly => "Apple only", + } + } + + /// Whether this mode sends captured audio off the machine. + pub fn sends_audio_off_device(&self) -> bool { + matches!(self, Self::Cloud) + } +} + +impl FromStr for AsrProductMode { + type Err = String; + + /// Parse the persisted mode identifier. No aliases: an unknown value must + /// fail loudly so the resolver can fall back to Apple-only instead of + /// guessing a mode that moves audio or loads weights. + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "cloud" => Ok(Self::Cloud), + "local_power" => Ok(Self::LocalPower), + "apple_only" => Ok(Self::AppleOnly), + other => Err(format!("Unknown AsrProductMode: {other}")), + } + } +} + +/// Where a granted consent came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConsentSource { + /// The user answered the explicit audio-egress consent question. + ExplicitSettings, + /// A pre-mode install had already chosen cloud transcription + /// (`use_local_stt = false` persisted); the upgrade preserves that choice + /// and records this derivation instead of re-asking. + LegacyCloudChoice, +} + +/// Typed audio-egress consent state. +/// +/// Deliberately three-valued: "never asked" and "denied" both refuse egress, +/// but the settings UI needs to tell them apart (ask vs. respect the no). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AudioEgressConsent { + /// The user explicitly allowed sending captured audio off this machine. + Granted(ConsentSource), + /// The user explicitly refused. Only a new explicit grant changes this. + Denied, + /// No answer recorded. Resolves exactly like a denial: no egress. + Unanswered, +} + +impl AudioEgressConsent { + /// Parse the persisted wire value. Anything other than the two canonical + /// tokens (including tampered or truncated values) reads as + /// [`Self::Unanswered`] — fail closed, never fail open. + pub fn from_wire(wire: Option<&str>) -> Self { + match wire.map(|value| value.trim().to_ascii_lowercase()) { + Some(value) if value == CONSENT_WIRE_GRANTED => { + Self::Granted(ConsentSource::ExplicitSettings) + } + Some(value) if value == CONSENT_WIRE_DENIED => Self::Denied, + _ => Self::Unanswered, + } + } + + /// Whether audio may leave the machine under this consent state. + pub fn permits_egress(&self) -> bool { + matches!(self, Self::Granted(_)) + } +} + +/// Why the resolver picked the mode it picked. Diagnostics and UI copy only — +/// never a second policy axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModeDerivation { + /// The persisted `asr_mode` value was honored as written. + ExplicitChoice, + /// No mode persisted; the prior `use_local_stt = true` choice carried over. + LegacyLocalChoice, + /// No mode persisted; the prior `use_local_stt = false` cloud choice + /// carried over together with its derived consent. + LegacyCloudChoice, + /// Fresh install: no mode, no legacy signal. The safe floor. + FreshDefault, + /// `cloud` was persisted but no consent record exists. Egress refused. + ConsentMissingFallback, + /// `cloud` was persisted but consent is explicitly denied. Egress refused. + ConsentDeniedFallback, + /// The persisted mode value did not parse. Refuse to guess. + UnknownModeFallback, +} + +/// The single resolved answer the rest of the product consumes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedAsrMode { + /// Effective product mode after consent and legacy derivation. + pub mode: AsrProductMode, + /// Consent state the mode was resolved under. + pub consent: AudioEgressConsent, + /// How the resolver arrived here. + pub derivation: ModeDerivation, +} + +/// Resolve the effective Layer 1 product mode from the persisted mode string, +/// the persisted consent wire value, and the legacy local/cloud switch. +/// +/// Pure on purpose: no I/O, no env, no clock. Every input combination has an +/// asserted answer in the test matrix below, and every refusal lands on +/// [`AsrProductMode::AppleOnly`] — never on [`AsrProductMode::LocalPower`]. +pub fn resolve_asr_product_mode( + explicit_mode: Option<&str>, + consent_wire: Option<&str>, + legacy_use_local_stt: Option, +) -> ResolvedAsrMode { + let consent = AudioEgressConsent::from_wire(consent_wire); + + let explicit = explicit_mode + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(raw) = explicit { + return match raw.parse::() { + Ok(AsrProductMode::Cloud) => match consent { + AudioEgressConsent::Granted(_) => ResolvedAsrMode { + mode: AsrProductMode::Cloud, + consent, + derivation: ModeDerivation::ExplicitChoice, + }, + AudioEgressConsent::Denied => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::ConsentDeniedFallback, + }, + AudioEgressConsent::Unanswered => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::ConsentMissingFallback, + }, + }, + Ok(mode) => ResolvedAsrMode { + mode, + consent, + derivation: ModeDerivation::ExplicitChoice, + }, + Err(_) => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::UnknownModeFallback, + }, + }; + } + + match legacy_use_local_stt { + Some(true) => ResolvedAsrMode { + mode: AsrProductMode::LocalPower, + consent, + derivation: ModeDerivation::LegacyLocalChoice, + }, + Some(false) => { + // A persisted cloud choice predating the mode field. An explicit + // denial recorded since then wins over the derived grant. + if consent == AudioEgressConsent::Denied { + ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::ConsentDeniedFallback, + } + } else { + let consent = match consent { + AudioEgressConsent::Granted(_) => consent, + _ => AudioEgressConsent::Granted(ConsentSource::LegacyCloudChoice), + }; + ResolvedAsrMode { + mode: AsrProductMode::Cloud, + consent, + derivation: ModeDerivation::LegacyCloudChoice, + } + } + } + None => ResolvedAsrMode { + mode: AsrProductMode::AppleOnly, + consent, + derivation: ModeDerivation::FreshDefault, + }, + } +} + +/// Why a gateway session-mint endpoint was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GatewayMintError { + /// Not a parseable absolute URL, or no host. + InvalidUrl, + /// Remote plaintext. `https` is required off loopback. + InsecureScheme, + /// URL user-info (`user:pass@`) — credentials never live in this config. + EmbeddedCredentials, + /// Query/fragment material — signed parameters and keys belong to the + /// minted session response, never to the persisted endpoint. + QueryNotAllowed, +} + +impl fmt::Display for GatewayMintError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::InvalidUrl => "gateway mint URL is not a valid absolute URL", + Self::InsecureScheme => "gateway mint URL must be https (http is loopback-only)", + Self::EmbeddedCredentials => "gateway mint URL must not embed credentials", + Self::QueryNotAllowed => "gateway mint URL must not carry query or fragment data", + }; + f.write_str(text) + } +} + +/// Validated Libraxis gateway session-mint endpoint. +/// +/// The desktop POSTs here to obtain a short-lived session (endpoint + bearer) +/// and hands the result to `asr_session::cloud::GatewayConnection`. By +/// construction this type holds an endpoint and nothing else: there is no +/// vendor key field, and URLs that try to smuggle credential material are +/// refused. Provider choice stays behind the gateway. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewaySessionMint { + url: String, +} + +impl GatewaySessionMint { + /// Validate a session-mint endpoint URL. + pub fn new(raw: &str) -> Result { + let raw = raw.trim(); + let parsed = reqwest::Url::parse(raw).map_err(|_| GatewayMintError::InvalidUrl)?; + let host = parsed + .host_str() + .map(|value| value.trim_matches(['[', ']'])) + .ok_or(GatewayMintError::InvalidUrl)?; + let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1"); + match parsed.scheme() { + "https" => {} + "http" if loopback => {} + _ => return Err(GatewayMintError::InsecureScheme), + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(GatewayMintError::EmbeddedCredentials); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(GatewayMintError::QueryNotAllowed); + } + Ok(Self { + url: raw.to_string(), + }) + } + + /// The validated endpoint URL. + pub fn url(&self) -> &str { + &self.url + } +} + +/// Resolution matrix, wire parsing, and mint-endpoint validation contracts. +#[cfg(test)] +mod tests { + use super::*; + + /// Mode identifiers round-trip and unknown values fail loudly. + #[test] + fn asr_mode_wire_round_trip() { + for mode in [ + AsrProductMode::Cloud, + AsrProductMode::LocalPower, + AsrProductMode::AppleOnly, + ] { + assert_eq!(mode.as_str().parse::(), Ok(mode)); + } + assert!("whisper".parse::().is_err()); + assert!("".parse::().is_err()); + } + + /// Only Cloud classifies as sending audio off the device. + #[test] + fn only_cloud_sends_audio_off_device() { + assert!(AsrProductMode::Cloud.sends_audio_off_device()); + assert!(!AsrProductMode::LocalPower.sends_audio_off_device()); + assert!(!AsrProductMode::AppleOnly.sends_audio_off_device()); + } + + /// Consent wire parsing fails closed on anything non-canonical. + #[test] + fn consent_wire_fails_closed() { + assert_eq!( + AudioEgressConsent::from_wire(Some("granted")), + AudioEgressConsent::Granted(ConsentSource::ExplicitSettings) + ); + assert_eq!( + AudioEgressConsent::from_wire(Some("denied")), + AudioEgressConsent::Denied + ); + for garbage in [None, Some(""), Some("yes"), Some("1"), Some("GRANTED!")] { + assert_eq!( + AudioEgressConsent::from_wire(garbage), + AudioEgressConsent::Unanswered, + "non-canonical wire {garbage:?} must read as Unanswered" + ); + } + // Canonical values are case/whitespace tolerant but nothing more. + assert!(AudioEgressConsent::from_wire(Some(" Granted ")).permits_egress()); + } + + /// Fresh install: no mode, no legacy signal, no consent → Apple-only. + #[test] + fn fresh_install_resolves_apple_only() { + let resolved = resolve_asr_product_mode(None, None, None); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::FreshDefault); + assert!(!resolved.consent.permits_egress()); + } + + /// Upgrade preservation: a persisted local choice stays local. + #[test] + fn upgrade_preserves_legacy_local_choice() { + let resolved = resolve_asr_product_mode(None, None, Some(true)); + assert_eq!(resolved.mode, AsrProductMode::LocalPower); + assert_eq!(resolved.derivation, ModeDerivation::LegacyLocalChoice); + } + + /// Upgrade preservation: a persisted cloud choice stays cloud, carrying a + /// derived consent instead of silently re-asking or silently refusing. + #[test] + fn upgrade_preserves_legacy_cloud_choice_with_derived_consent() { + let resolved = resolve_asr_product_mode(None, None, Some(false)); + assert_eq!(resolved.mode, AsrProductMode::Cloud); + assert_eq!(resolved.derivation, ModeDerivation::LegacyCloudChoice); + assert_eq!( + resolved.consent, + AudioEgressConsent::Granted(ConsentSource::LegacyCloudChoice) + ); + } + + /// An explicit denial recorded after the upgrade beats the legacy grant. + #[test] + fn explicit_denial_beats_legacy_cloud_choice() { + let resolved = resolve_asr_product_mode(None, Some("denied"), Some(false)); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::ConsentDeniedFallback); + } + + /// Explicit cloud with granted consent is honored. + #[test] + fn explicit_cloud_with_consent_is_cloud() { + let resolved = resolve_asr_product_mode(Some("cloud"), Some("granted"), None); + assert_eq!(resolved.mode, AsrProductMode::Cloud); + assert_eq!(resolved.derivation, ModeDerivation::ExplicitChoice); + } + + /// Explicit cloud without consent resolves to Apple-only — and never to + /// local weights, even when a legacy local signal is also present. + #[test] + fn cloud_without_consent_resolves_apple_only_never_local() { + for (consent, derivation) in [ + (None, ModeDerivation::ConsentMissingFallback), + (Some("denied"), ModeDerivation::ConsentDeniedFallback), + (Some("tampered"), ModeDerivation::ConsentMissingFallback), + ] { + for legacy in [None, Some(true), Some(false)] { + let resolved = resolve_asr_product_mode(Some("cloud"), consent, legacy); + assert_eq!( + resolved.mode, + AsrProductMode::AppleOnly, + "cloud with consent={consent:?} legacy={legacy:?} must refuse egress" + ); + assert_eq!(resolved.derivation, derivation); + } + } + } + + /// Explicit non-cloud modes need no consent record. + #[test] + fn explicit_local_and_apple_need_no_consent() { + let local = resolve_asr_product_mode(Some("local_power"), None, None); + assert_eq!(local.mode, AsrProductMode::LocalPower); + assert_eq!(local.derivation, ModeDerivation::ExplicitChoice); + + let apple = resolve_asr_product_mode(Some("apple_only"), Some("granted"), Some(false)); + assert_eq!(apple.mode, AsrProductMode::AppleOnly); + assert_eq!(apple.derivation, ModeDerivation::ExplicitChoice); + } + + /// An unparseable persisted mode refuses to guess: Apple-only, not legacy + /// derivation and not local weights. + #[test] + fn unknown_mode_value_resolves_apple_only() { + let resolved = resolve_asr_product_mode(Some("turbo_cloud"), Some("granted"), Some(true)); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::UnknownModeFallback); + } + + /// Mint endpoint accepts clean https (and loopback http for dev). + #[test] + fn gateway_mint_accepts_clean_endpoints() { + for url in [ + "https://gateway.libraxis.cloud/v1/asr/sessions", + "https://gateway.libraxis.cloud/mint", + "http://127.0.0.1:8089/mint", + "http://localhost:8089/mint", + ] { + let mint = GatewaySessionMint::new(url).expect("clean endpoint accepted"); + assert_eq!(mint.url(), url); + } + } + + /// Mint endpoint refuses anything that could smuggle a credential: remote + /// plaintext, user-info, query strings, fragments, relative junk. + #[test] + fn gateway_mint_refuses_credential_material() { + let cases = [ + ( + "http://gateway.libraxis.cloud/mint", + GatewayMintError::InsecureScheme, + ), + ( + "ftp://gateway.libraxis.cloud/mint", + GatewayMintError::InsecureScheme, + ), + ( + "https://user:secret@gateway.libraxis.cloud/mint", + GatewayMintError::EmbeddedCredentials, + ), + ( + "https://gateway.libraxis.cloud/mint?api_key=sk-123", + GatewayMintError::QueryNotAllowed, + ), + ( + "https://gateway.libraxis.cloud/mint#token", + GatewayMintError::QueryNotAllowed, + ), + ("not a url", GatewayMintError::InvalidUrl), + ("", GatewayMintError::InvalidUrl), + ]; + for (url, expected) in cases { + assert_eq!( + GatewaySessionMint::new(url), + Err(expected), + "endpoint {url:?} must be refused" + ); + } + } +} diff --git a/core/config/default_env.txt b/core/config/default_env.txt index 39153a55..e6f42455 100644 --- a/core/config/default_env.txt +++ b/core/config/default_env.txt @@ -9,14 +9,14 @@ LLM_FORMATTING_API_KEY= # === Core mode === USE_LOCAL_STT=1 -LOCAL_MODEL=whisper-large-v3-turbo-mlx-q8 +LOCAL_MODEL=whisper-large-v3-turbo WHISPER_LANGUAGE=auto # === Hotkeys === HOLD_EXCLUSIVE=1 HOLD_START_DELAY_MS=800 DOUBLE_TAP_INTERVAL_MS=200 -TOGGLE_SILENCE_SEC=5.0 +TOGGLE_SILENCE_SEC=5.0 # Apple epoch rest; not CODESCRIBE_VAD_* # Mode bindings are canonical and persisted in settings.json: # - dictation: hold_fn # - formatting: double_left_option @@ -31,6 +31,11 @@ HOLD_BADGE_SIZE=8 HOLD_BADGE_OFFSET_X=10 HOLD_BADGE_OFFSET_Y=-10 +# Clean committed transcript observer. Unset uses +# $XDG_STATE_HOME/codescribe/transcript-events.jsonl when available, otherwise +# CODESCRIBE_DATA_DIR/transcript-events.jsonl (default ~/.codescribe/...). +# CODESCRIBE_TRANSCRIPT_BUS_PATH= + # Unified runtime pipeline (event-based) is always active. # Golden low-latency / high-fidelity defaults for live preview: CODESCRIBE_STREAM_CHUNK_SEC=3.0 @@ -54,6 +59,8 @@ CODESCRIBE_STT_THERMAL_SERIOUS_MULT=2.0 CODESCRIBE_STT_THERMAL_CRITICAL_MULT=4.0 # Runtime lexicon initial_prompt is opt-in; default OFF. CODESCRIBE_STT_INITIAL_PROMPT_ENABLED=0 +# W13-6B overlay highlight layer is opt-in; default OFF. +CODESCRIBE_OVERLAY_HIGHLIGHTS=0 # VAD config is hardcoded in core/vad/config.rs (Silero defaults, no env knobs). CODESCRIBE_STREAM_SIMILARITY=0.90 CODESCRIBE_STREAM_NOVELTY=0.20 diff --git a/core/config/loader.rs b/core/config/loader.rs index cc7ffabf..e7bfea76 100644 --- a/core/config/loader.rs +++ b/core/config/loader.rs @@ -38,6 +38,19 @@ static CONFIG_ENV_BOOTSTRAPPED: AtomicBool = AtomicBool::new(false); /// cannot both decide they are the first writer. static CONFIG_ENV_BOOTSTRAP_LOCK: OnceLock> = OnceLock::new(); +/// Serialize the full settings read-modify-write transaction behind public +/// Config mutation APIs. Atomic renames prevent torn files, but without this +/// outer lock two distinct UI writes can both load the same snapshot and the +/// later rename silently erase the earlier field. +static CONFIG_PERSISTENCE_LOCK: OnceLock> = OnceLock::new(); + +fn config_persistence_guard() -> std::sync::MutexGuard<'static, ()> { + CONFIG_PERSISTENCE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// Keys this process seeded itself. After bootstrap they are reported as absent /// by [`Config::config_runtime_env_var`], so a later Settings write wins over /// the value config planted at startup — that is what makes settings hot-apply @@ -75,6 +88,13 @@ impl Config { /// a stale `~/.codescribe/.env` cannot shadow a choice made in the UI. /// Only after that are defaults, settings, and finally explicit env applied. fn load_with_keychain_population(populate_keychain: bool) -> Self { + let _data_io = match super::storage_reset::begin_app_data_io() { + Ok(guard) => guard, + Err(error) => { + warn!(%error, "Config load skipped while app-data reset owns the process"); + return Self::default(); + } + }; let _bootstrap_guard = Self::config_env_bootstrap_guard(); let seed_process_env = Self::can_seed_process_env(); let env_path = Self::env_path(); @@ -652,6 +672,32 @@ impl Config { self.transcription_overlay_enabled = v; Self::safe_set_env("TRANSCRIPTION_OVERLAY_ENABLED", if v { "1" } else { "0" }); } + if Self::config_runtime_env_var("HOLD_INDICATOR").is_err() + && let Some(v) = settings.hold_indicator + { + self.hold_indicator = v; + } + if Self::config_runtime_env_var("HOLD_BADGE_SIZE").is_err() + && let Some(v) = settings.hold_badge_size + { + self.hold_badge_size = v.min(u32::MAX as u64) as u32; + } + if Self::config_runtime_env_var("RESTORE_CLIPBOARD").is_err() + && let Some(v) = settings.restore_clipboard + { + self.restore_clipboard = v; + } + if Self::config_runtime_env_var("RESTORE_CLIPBOARD_DELAY_MS").is_err() + && let Some(v) = settings.restore_clipboard_delay_ms + { + self.restore_clipboard_delay_ms = v; + } + if Self::config_runtime_env_var("CODESCRIBE_DEFERRED_INSERT_SHORTCUT").is_err() + && let Some(raw) = settings.deferred_insert_shortcut.as_deref() + && let Ok(shortcut) = raw.parse::() + { + self.deferred_insert_shortcut = shortcut; + } if Self::config_runtime_env_var("TRAY_START_ASSISTIVE").is_err() && let Some(v) = settings.tray_start_assistive { @@ -871,6 +917,8 @@ impl Config { /// This is a persistence write only. Process-env seeding is restricted to /// bootstrap loads; live readers must reload the config/settings snapshot. pub fn save_to_env(&self, key: &str, value: &str) -> anyhow::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io()?; + let _persistence = config_persistence_guard(); let normalized_formatting = (key == "FORMATTING_LEVEL") .then(|| FormattingPolicy::parse(value)) .transpose()? @@ -898,7 +946,9 @@ impl Config { | "DOUBLE_TAP_INTERVAL_MS" | "CODESCRIBE_BUFFER_DELAY_MS" | "CODESCRIBE_EMIT_WORDS_MAX" - | "BACKEND_MAX_UPLOAD_MB" => { + | "BACKEND_MAX_UPLOAD_MB" + | "HOLD_BADGE_SIZE" + | "RESTORE_CLIPBOARD_DELAY_MS" => { if let Ok(v) = value.parse::() { settings.set_u64(key, v); } @@ -926,7 +976,9 @@ impl Config { | "START_AT_LOGIN" | "QUBE_DAEMON_AUTOSTART" | "AGENT_ENTER_SENDS" - | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { + | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" + | "HOLD_INDICATOR" + | "RESTORE_CLIPBOARD" => { let bool_val = matches!(value, "1" | "true" | "yes" | "on"); settings.set_bool(key, bool_val); } @@ -962,7 +1014,13 @@ impl Config { HashMap::new() }; env_vars.insert(key.to_string(), value.to_string()); - Self::write_env_file(&env_path, &env_vars)?; + Self::write_env_file(&env_path, &env_vars).inspect_err(|error| { + // A power-user key that cannot persist is a dead UI control, and the + // Swift callers swallow the error — this line is the only witness + // (2026-08-10: an immutable .env killed the Pointer Indicator row + // with zero log output). + tracing::warn!(key, %error, "save_to_env: .env write failed; value NOT persisted"); + })?; Ok(()) } @@ -974,6 +1032,8 @@ impl Config { if entries.is_empty() { return Ok(()); } + let _data_io = super::storage_reset::begin_app_data_io()?; + let _persistence = config_persistence_guard(); let mut settings: Option = None; let mut env_vars: Option> = None; @@ -1046,6 +1106,14 @@ impl Config { settings_ref.layered_transcription = Some((*value).to_string()); Self::reconcile_stt_runtime_key(key, value); } + // C2: same validated writes as the single-key set_string + // path — a batch write must not bypass mode/consent/URL + // validation or silently drop these keys. + "CODESCRIBE_ASR_MODE" + | "CODESCRIBE_CLOUD_CONSENT" + | "CODESCRIBE_ASR_GATEWAY_URL" => { + settings_ref.set_string(key, value); + } // ── u64 ── "HOLD_START_DELAY_MS" => { if let Ok(v) = value.parse::() { @@ -1072,6 +1140,22 @@ impl Config { settings_ref.backend_max_upload_mb = Some(v); } } + "HOLD_BADGE_SIZE" => { + if let Ok(v) = value.parse::() { + settings_ref.hold_badge_size = Some(v); + } + } + "RESTORE_CLIPBOARD_DELAY_MS" => { + if let Ok(v) = value.parse::() { + settings_ref.restore_clipboard_delay_ms = Some(v); + } + } + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT" => { + if let Ok(shortcut) = value.parse::() { + settings_ref.deferred_insert_shortcut = + Some(shortcut.wire_id().to_string()); + } + } // ── f32 ── "TOGGLE_SILENCE_SEC" => { if let Ok(v) = value.parse::() { @@ -1109,7 +1193,9 @@ impl Config { | "START_AT_LOGIN" | "QUBE_DAEMON_AUTOSTART" | "AGENT_ENTER_SENDS" - | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { + | "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" + | "HOLD_INDICATOR" + | "RESTORE_CLIPBOARD" => { let bv = matches!(*value, "1" | "true" | "yes" | "on"); match *key { "AI_FORMATTING_ENABLED" => { @@ -1137,6 +1223,8 @@ impl Config { "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED" => { settings_ref.stt_initial_prompt_enabled = Some(bv) } + "HOLD_INDICATOR" => settings_ref.hold_indicator = Some(bv), + "RESTORE_CLIPBOARD" => settings_ref.restore_clipboard = Some(bv), _ => {} } } @@ -1166,7 +1254,11 @@ impl Config { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - Self::write_env_file(&path, &vars)?; + Self::write_env_file(&path, &vars).inspect_err(|error| { + // Same witness as the single-key path: Swift callers swallow + // the error, so an unwritable .env must at least leave a trace. + tracing::warn!(%error, "save_to_env_many: .env write failed; batch NOT persisted"); + })?; } Ok(()) @@ -1290,6 +1382,8 @@ impl Config { ) -> anyhow::Result<()> { use crate::safe_path::{safe_read_to_string_bounded, safe_write_bounded}; + let _data_io = super::storage_reset::begin_app_data_io()?; + // Use path's parent as root to support CODESCRIBE_ENV_PATH override (tests) let root = path .parent() @@ -1358,6 +1452,41 @@ impl Config { Ok(()) } + /// Remove a narrow set of persisted `.env` rows while preserving every + /// unrelated user-written row, comment and ordering. Used by scoped reset + /// flows; callers must name their owned keys explicitly. + pub fn remove_env_keys(keys: &[&str]) -> anyhow::Result<()> { + use crate::safe_path::{safe_read_to_string_bounded, safe_write_bounded}; + + let _data_io = super::storage_reset::begin_app_data_io()?; + let _persistence = config_persistence_guard(); + let path = Self::env_path(); + if !path.exists() { + return Ok(()); + } + let path = path.canonicalize()?; + let root = path + .parent() + .map(|parent| parent.to_path_buf()) + .unwrap_or_else(Self::config_dir); + let contents = safe_read_to_string_bounded(&path, &root)?; + let owned: HashSet<&str> = keys.iter().copied().collect(); + let output = contents + .lines() + .filter(|line| { + let key = line.trim().split_once('=').map(|(key, _)| key.trim()); + !key.is_some_and(|key| owned.contains(key)) + }) + .collect::>() + .join("\n"); + let output = if output.is_empty() { + String::new() + } else { + format!("{output}\n") + }; + safe_write_bounded(&path, &root, &output) + } + /// Migrate legacy keys inside .env to the current contract. fn migrate_env_legacy_keys() { let env_path = Self::env_path(); @@ -1554,6 +1683,69 @@ mod tests { tmp } + /// Distinct UI writes are one read-modify-write transaction each. Start two + /// callers together and prove the later atomic rename cannot erase the + /// field persisted by the other caller. + #[test] + #[serial] + fn concurrent_config_updates_preserve_both_distinct_fields() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_CONFIG_RMW_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_CONFIG_RMW_WITNESS"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = TempDir::new().expect("config RMW witness dir"); + let witness = witness_dir.path().join("passed"); + let status = std::process::Command::new( + std::env::current_exe().expect("current core test executable"), + ) + .args([ + "--exact", + "config::loader::tests::concurrent_config_updates_preserve_both_distinct_fields", + "--nocapture", + ]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated config RMW regression"); + assert!(status.success(), "isolated config RMW regression failed"); + assert_eq!( + fs::read(witness).expect("child executed exact config RMW test"), + b"config-rmw-pass" + ); + return; + } + + let _tmp = setup_isolated_data_dir(); + let _auto_paste = TestEnvGuard::unset("AUTO_PASTE_ENABLED"); + let _dock = TestEnvGuard::unset("SHOW_DOCK_ICON"); + let start = std::sync::Arc::new(std::sync::Barrier::new(3)); + let first_start = start.clone(); + let first = std::thread::spawn(move || { + first_start.wait(); + Config::default() + .save_to_env("AUTO_PASTE_ENABLED", "0") + .expect("persist auto paste") + }); + let second_start = start.clone(); + let second = std::thread::spawn(move || { + second_start.wait(); + Config::default() + .save_to_env("SHOW_DOCK_ICON", "0") + .expect("persist dock icon") + }); + start.wait(); + first.join().expect("first config writer joins"); + second.join().expect("second config writer joins"); + + let persisted = UserSettings::load(); + assert_eq!(persisted.auto_paste_enabled, Some(false)); + assert_eq!(persisted.show_dock_icon, Some(false)); + fs::write( + std::env::var_os(CHILD_WITNESS).expect("config RMW child witness path"), + b"config-rmw-pass", + ) + .expect("write config RMW child witness"); + } + /// Every LLM write key as `(key, sample value, JSON pointer)`. A `None` /// pointer marks a key with no durable `settings.json` home — it is still /// exercised, to prove writing it does not invent one. @@ -1665,19 +1857,15 @@ mod tests { } } - /// Badge/indicator keys stay env-managed; settings.json bytes must not change. + /// Badge/indicator keys are promoted (2026-08-11): tray writes land in + /// settings.json — never `.env`, whose immutability killed the Pointer + /// Indicator row — and reload live without process-env shadowing. #[test] #[serial] - fn hold_indicator_ui_writes_existing_env_keys_without_settings_json_drift() { + fn hold_indicator_ui_writes_are_promoted_to_settings_json() { let _tmp = setup_isolated_data_dir(); let _indicator = TestEnvGuard::unset("HOLD_INDICATOR"); let _size = TestEnvGuard::unset("HOLD_BADGE_SIZE"); - let settings = UserSettings { - show_dock_icon: Some(true), - ..UserSettings::default() - }; - settings.save().expect("seed settings json"); - let settings_before = fs::read(UserSettings::settings_path()).expect("read settings json"); let config = Config::default(); config @@ -1686,53 +1874,81 @@ mod tests { config .save_to_env("HOLD_INDICATOR", "0") .expect("disable indicator"); - let disabled = Config::parse_env_file(&Config::env_path()).expect("read env"); - assert_eq!( - disabled.get("HOLD_INDICATOR").map(String::as_str), - Some("0") - ); - assert_eq!( - disabled.get("HOLD_BADGE_SIZE").map(String::as_str), - Some("8"), - "Off must preserve the stored badge size" - ); + let stored = UserSettings::load(); + assert_eq!(stored.hold_indicator, Some(false)); + assert_eq!(stored.hold_badge_size, Some(8)); let disabled_config = Config::load_without_keychain(); assert!(!disabled_config.hold_indicator); assert_eq!(disabled_config.hold_badge_size, 8); - for size in [4, 8, 12] { - let size = size.to_string(); + for size in [4u64, 8, 12] { + let size_str = size.to_string(); config - .save_to_env_many(&[("HOLD_INDICATOR", "1"), ("HOLD_BADGE_SIZE", &size)]) + .save_to_env_many(&[("HOLD_INDICATOR", "1"), ("HOLD_BADGE_SIZE", &size_str)]) .expect("save enabled badge size"); - let persisted = Config::parse_env_file(&Config::env_path()).expect("read env"); - assert_eq!( - persisted.get("HOLD_INDICATOR").map(String::as_str), - Some("1") - ); - assert_eq!( - persisted.get("HOLD_BADGE_SIZE").map(String::as_str), - Some(size.as_str()) - ); - // Test builds intentionally allow repeated process-env bootstrap, - // unlike production's one-shot tracked bootstrap. Remove the prior - // injected snapshot so this reload exercises the newly persisted - // values instead of the test-only stale process copy. - // SAFETY: this test is serial and the guards above restore both keys. - unsafe { - std::env::remove_var("HOLD_INDICATOR"); - std::env::remove_var("HOLD_BADGE_SIZE"); - } + let stored = UserSettings::load(); + assert_eq!(stored.hold_indicator, Some(true)); + assert_eq!(stored.hold_badge_size, Some(size)); let live = Config::load_without_keychain(); assert!(live.hold_indicator); - assert_eq!(live.hold_badge_size.to_string(), size); + assert_eq!(u64::from(live.hold_badge_size), size); } - let settings_after = fs::read(UserSettings::settings_path()).expect("read settings json"); + // The promoted keys must leave `.env` alone entirely. + let env_path = Config::env_path(); + if env_path.exists() { + let env = Config::parse_env_file(&env_path).expect("parse optional env"); + assert!(!env.contains_key("HOLD_INDICATOR")); + assert!(!env.contains_key("HOLD_BADGE_SIZE")); + } + } + + /// Deferred-insert + clipboard-restore keys are promoted: valid writes land + /// in settings.json and reload live; invalid chords are rejected without + /// touching disk. + #[test] + #[serial] + fn deferred_insert_and_restore_clipboard_writes_are_promoted() { + let _tmp = setup_isolated_data_dir(); + let _shortcut = TestEnvGuard::unset("CODESCRIBE_DEFERRED_INSERT_SHORTCUT"); + let _restore = TestEnvGuard::unset("RESTORE_CLIPBOARD"); + let _delay = TestEnvGuard::unset("RESTORE_CLIPBOARD_DELAY_MS"); + let config = Config::default(); + + config + .save_to_env("CODESCRIBE_DEFERRED_INSERT_SHORTCUT", "cmd_alt_v") + .expect("save shortcut alias"); assert_eq!( - settings_before, settings_after, - "settings.json must not gain badge keys" + UserSettings::load().deferred_insert_shortcut.as_deref(), + Some("command_option_v"), + "aliases must persist as the canonical wire id" ); + assert_eq!( + Config::load_without_keychain().deferred_insert_shortcut, + DeferredInsertShortcut::CommandOptionV + ); + + config + .save_to_env("CODESCRIBE_DEFERRED_INSERT_SHORTCUT", "not_a_chord") + .expect("invalid chord is a non-fatal no-op"); + assert_eq!( + UserSettings::load().deferred_insert_shortcut.as_deref(), + Some("command_option_v"), + "invalid chord must not clobber the stored one" + ); + + config + .save_to_env_many(&[ + ("RESTORE_CLIPBOARD", "0"), + ("RESTORE_CLIPBOARD_DELAY_MS", "450"), + ]) + .expect("save clipboard restore batch"); + let stored = UserSettings::load(); + assert_eq!(stored.restore_clipboard, Some(false)); + assert_eq!(stored.restore_clipboard_delay_ms, Some(450)); + let live = Config::load_without_keychain(); + assert!(!live.restore_clipboard); + assert_eq!(live.restore_clipboard_delay_ms, 450); } /// AUTO_PASTE single/batch writes reload live without shadowing process env. diff --git a/core/config/mod.rs b/core/config/mod.rs index 611a7c5a..82350382 100644 --- a/core/config/mod.rs +++ b/core/config/mod.rs @@ -17,6 +17,8 @@ //! //! Note: Config is loaded via `Config::load()` and accessed via shared state in main.rs. +/// Layer 1 ASR product mode, audio-egress consent, and gateway mint config. +pub mod cloud_asr; /// Serde default helpers and default model/endpoint constants. mod defaults; /// Stop-path final-pass routing mode shared by controller and bridge lanes. @@ -36,6 +38,8 @@ pub mod portable; pub mod prompts; /// GUI-managed user settings JSON (regular-user tier). pub mod settings; +/// Process-wide app-data I/O fence used by destructive reset. +pub mod storage_reset; /// Config enums and the main `Config` struct definitions. mod types; @@ -50,12 +54,17 @@ pub use types::{ ShortcutBinding, TranscriptSendMode, WorkMode, }; // Language re-exported for external consumers (GUI apps) +pub use cloud_asr::{ + AsrProductMode, AudioEgressConsent, ConsentSource, GatewayMintError, GatewaySessionMint, + ModeDerivation, ResolvedAsrMode, resolve_asr_product_mode, +}; pub use final_pass::{FinalPassRoutingMode, final_pass_routing_mode}; pub use portable::{ ImportPlan, PortableProfile, export_portable, import_portable_apply, import_portable_dry_run, write_portable_export, }; pub use settings::{FormattingPolicy, UserSettings}; +pub use storage_reset::{AppDataResetGuard, begin_app_data_reset}; pub use types::Language; // Re-export prompts API (public API for GUI apps) @@ -66,6 +75,7 @@ pub use prompts::{ get_formatting_prompt_for_policy, get_formatting_prompt_path, get_formatting_prompt_path_for_policy, open_prompt_file, open_prompts_folder, prompt_snapshot, reset_to_defaults, restore_prompt_to_default, write_prompt, write_prompt_bytes, + write_prompt_bytes_during_reset, }; #[cfg(test)] diff --git a/core/config/models.rs b/core/config/models.rs index db956a4f..74354fae 100644 --- a/core/config/models.rs +++ b/core/config/models.rs @@ -5,17 +5,26 @@ //! model from here instead of re-implementing its own precedence rules. use anyhow::{Context, Result, anyhow}; +use sha2::{Digest, Sha256}; use std::fs; +use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use crate::hf_cache; /// Default Whisper model name used for runtime fallback lookup. -pub const DEFAULT_MODEL: &str = "whisper-large-v3-turbo-mlx-q8"; +pub const DEFAULT_MODEL: &str = "whisper-large-v3-turbo"; /// Hugging Face repo backing [`DEFAULT_MODEL`], used for cache lookup and for -/// the Settings → Dictation download. -pub const DEFAULT_WHISPER_REPO: &str = "LibraxisAI/whisper-large-v3-turbo-mlx-q8"; - +/// the Settings → Dictation download. fp16 weights: no q8→F32 dequantization +/// on load, at the cost of a larger download than the q8 repo. +pub const DEFAULT_WHISPER_REPO: &str = "mlx-community/whisper-large-v3-turbo"; +/// Official Transformers tokenizer paired with Whisper large-v3-turbo. +pub const TOKENIZER_WHISPER_REPO: &str = "openai/whisper-large-v3-turbo"; +/// Pinned OpenAI Whisper asset. The checksum is asserted by the installer. +pub const MEL_FILTERS_URL: &str = "https://raw.githubusercontent.com/openai/whisper/5f86d1d86363843179951550570367b37c5d6f78/whisper/assets/mel_filters.npz"; +/// SHA-256 of [`MEL_FILTERS_URL`]. +pub const MEL_FILTERS_SHA256: &str = + "7450ae70723a5ef9d341e3cee628c7cb0177f36ce42c44b7ed2bf3325f0f6d4c"; /// Files that must all be present for a directory to count as a usable model. const REQUIRED_MODEL_FILES: [&str; 3] = ["config.json", "tokenizer.json", "mel_filters.npz"]; /// Weight file names, of which **any one** satisfies the completeness check — @@ -46,12 +55,88 @@ fn canonicalize_or_self(path: PathBuf) -> PathBuf { /// [`REQUIRED_MODEL_WEIGHTS`]. This is the gate that keeps half-downloaded /// directories from being advertised or resolved as loadable models. fn is_complete_whisper_model_dir(path: &Path) -> bool { - REQUIRED_MODEL_FILES + let files_present = REQUIRED_MODEL_FILES .iter() .all(|name| path.join(name).exists()) && REQUIRED_MODEL_WEIGHTS .iter() - .any(|name| path.join(name).exists()) + .any(|name| path.join(name).exists()); + files_present && is_unquantized_whisper_model_dir(path) +} + +/// Reject quantized or malformed weights before they can reach the expensive +/// engine loader. The config check catches normal MLX q8 exports; the +/// safetensors header check also catches a q8 payload hidden behind a renamed +/// directory or a config with its `quantization` field removed. +pub(crate) fn is_unquantized_whisper_model_dir(path: &Path) -> bool { + let config = match fs::read_to_string(path.join("config.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + { + Some(config) => config, + None => return false, + }; + if config + .get("quantization") + .is_some_and(|value| !value.is_null()) + || config + .get("quantization_config") + .is_some_and(|value| !value.is_null()) + { + return false; + } + + let Some(weights_path) = REQUIRED_MODEL_WEIGHTS + .iter() + .map(|name| path.join(name)) + .find(|candidate| candidate.exists()) + else { + return false; + }; + safetensors_header_is_unquantized(&weights_path).unwrap_or(false) +} + +/// Inspect only the bounded JSON header; model tensor data is never read. +fn safetensors_header_is_unquantized(path: &Path) -> Result { + const MAX_HEADER_BYTES: u64 = 16 * 1024 * 1024; + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Read-only model inspection. `path` is an operator-selected local model file or an internally resolved bundle/cache child; no network/request path component reaches it. + let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut len_bytes = [0_u8; 8]; + file.read_exact(&mut len_bytes) + .with_context(|| format!("read safetensors header length from {}", path.display()))?; + let header_len = u64::from_le_bytes(len_bytes); + if header_len == 0 || header_len > MAX_HEADER_BYTES { + return Ok(false); + } + let mut header = vec![0_u8; header_len as usize]; + file.seek(SeekFrom::Start(8))?; + file.read_exact(&mut header) + .with_context(|| format!("read safetensors header from {}", path.display()))?; + let metadata: serde_json::Value = serde_json::from_slice(&header) + .with_context(|| format!("parse safetensors header from {}", path.display()))?; + let Some(tensors) = metadata.as_object() else { + return Ok(false); + }; + Ok(tensors.iter().all(|(name, tensor)| { + name == "__metadata__" + || (!name.ends_with(".scales") + && !name.ends_with(".biases") + && tensor.get("dtype").and_then(|value| value.as_str()) != Some("U32")) + })) +} + +/// Whether a candidate models root owns at least one complete Whisper model. +/// +/// A bundled `Resources/models` directory may contain only another model +/// family (for example the semantic embedder). Treating mere directory +/// existence as Whisper ownership shadows the user-installed fp16 model and +/// could incorrectly select a quantized cache instead. +fn models_root_contains_complete_whisper_model(path: &Path) -> bool { + fs::read_dir(path).is_ok_and(|entries| { + entries + .filter_map(std::result::Result::ok) + .any(|entry| is_complete_whisper_model_dir(&entry.path())) + }) } /// Find a complete Hugging Face cache snapshot for a model reference. @@ -65,23 +150,16 @@ fn hf_snapshot_for_model(model_ref: &str) -> Option { return None; } - if trimmed.contains('/') { - return hf_cache::find_snapshot_with_any( - trimmed, - &REQUIRED_MODEL_FILES, - &REQUIRED_MODEL_WEIGHTS, - ); - } - - if trimmed == DEFAULT_MODEL { - return hf_cache::find_snapshot_with_any( - DEFAULT_WHISPER_REPO, - &REQUIRED_MODEL_FILES, - &REQUIRED_MODEL_WEIGHTS, - ); - } - - None + let repo = if trimmed.contains('/') { + trimmed + } else if trimmed == DEFAULT_MODEL { + DEFAULT_WHISPER_REPO + } else { + return None; + }; + let snapshot = + hf_cache::find_snapshot_with_any(repo, &REQUIRED_MODEL_FILES, &REQUIRED_MODEL_WEIGHTS)?; + is_complete_whisper_model_dir(&snapshot).then_some(snapshot) } /// Owner of the resolved runtime models directory. @@ -125,7 +203,7 @@ impl ModelManager { // 1. Bundled .app: Contents/MacOS/binary -> Contents/Resources/models/ let bundled_path = exe_dir.join("../Resources/models"); - if bundled_path.exists() { + if models_root_contains_complete_whisper_model(&bundled_path) { return bundled_path .canonicalize() .context("Failed to canonicalize bundled models path"); @@ -137,7 +215,7 @@ impl ModelManager { // means "directory with ALL models" — hijacking it from tests sends // runtime Whisper resolution to the wrong place. let dev_path = exe_dir.join("../../models"); - if dev_path.exists() { + if models_root_contains_complete_whisper_model(&dev_path) { return dev_path .canonicalize() .context("Failed to canonicalize dev models path"); @@ -145,7 +223,7 @@ impl ModelManager { // 3. Direct ./models/ (running from repo root) let local_path = PathBuf::from("../../models"); - if local_path.exists() { + if models_root_contains_complete_whisper_model(&local_path) { return local_path .canonicalize() .context("Failed to canonicalize local models path"); @@ -235,8 +313,8 @@ impl ModelManager { /// 1. Explicit `CODESCRIBE_MODEL_PATH` /// 2. Configured local model path / models-dir alias /// 3. Configured Hugging Face repo snapshot -/// 4. Default models-dir alias (`whisper-large-v3-turbo-mlx-q8`) -/// 5. Default Hugging Face snapshot (`LibraxisAI/whisper-large-v3-turbo-mlx-q8`) +/// 4. Default models-dir alias (`whisper-large-v3-turbo`) +/// 5. Default Hugging Face snapshot (`mlx-community/whisper-large-v3-turbo`) pub fn resolve_runtime_whisper_model_path(configured_model: Option<&str>) -> Result { if let Ok(path) = std::env::var("CODESCRIBE_MODEL_PATH") { let candidate = PathBuf::from(path.trim()); @@ -271,12 +349,12 @@ pub fn resolve_runtime_whisper_model_path(configured_model: Option<&str>) -> Res } Err(anyhow!( - "Whisper runtime fallback model not available.\n\ + "Unquantized Whisper runtime model not available.\n\ Public builds do not embed Whisper; install it from Settings → Dictation,\n\ set CODESCRIBE_MODEL_PATH, configure LOCAL_MODEL, or warm the Hugging Face cache.\n\n\ - Download with: hf download {}\n\ + Quantized q8 models are intentionally refused.\n\n\ + Download with: make download-model\n\ Or: Settings → Dictation → Download Whisper", - DEFAULT_WHISPER_REPO )) } @@ -309,7 +387,7 @@ pub fn whisper_model_status() -> WhisperModelStatus { path, model_id: DEFAULT_MODEL.to_string(), repo: DEFAULT_WHISPER_REPO.to_string(), - size_hint: "~900 MB".to_string(), + size_hint: "~1.6 GB".to_string(), } } @@ -328,15 +406,21 @@ where return Ok(canonicalize_or_self(dest)); } - // Prefer an already-complete HF cache snapshot: hardlink/copy into user models dir - // when possible so Settings "Download" is a no-op if hf cache is warm. - if let Some(snapshot) = hf_snapshot_for_model(DEFAULT_MODEL) { - if snapshot != dest { - copy_complete_model_dir(&snapshot, &dest)?; - } - if is_complete_whisper_model_dir(&dest) { - return Ok(canonicalize_or_self(dest)); - } + // Compose from warm official sources first, so Settings "Download" is a + // no-op when the pieces are already on disk. Config and weights come from + // mlx-community's fp16 conversion; tokenizer comes from OpenAI's matching + // Transformers repository. The pinned mel filterbank is fetched below. + if let Some(snapshot) = hf_cache::find_snapshot(DEFAULT_WHISPER_REPO, &["config.json"]) + && snapshot != dest + { + copy_model_files(&snapshot, &dest, &["config.json"])?; + copy_model_files(&snapshot, &dest, &REQUIRED_MODEL_WEIGHTS)?; + } + if let Some(snapshot) = hf_cache::find_snapshot(TOKENIZER_WHISPER_REPO, &["tokenizer.json"]) { + copy_model_files(&snapshot, &dest, &["tokenizer.json"])?; + } + if is_complete_whisper_model_dir(&dest) { + return Ok(canonicalize_or_self(dest)); } fs::create_dir_all(&dest).with_context(|| format!("create {}", dest.display()))?; @@ -347,25 +431,38 @@ where .build() .context("build HTTP client for Whisper download")?; - // Small files first so a failed auth fails fast before multi-hundred-MB weights. - for name in REQUIRED_MODEL_FILES { - download_hf_file( - &client, - DEFAULT_WHISPER_REPO, - name, - &dest.join(name), - &mut on_progress, - )?; - } - - let weights_dest = dest.join("model.safetensors"); - let weights_alt = dest.join("weights.safetensors"); + // Small files first so a failed auth fails fast before multi-GB weights. + download_hf_file( + &client, + DEFAULT_WHISPER_REPO, + "config.json", + &dest.join("config.json"), + &mut on_progress, + )?; + download_hf_file( + &client, + TOKENIZER_WHISPER_REPO, + "tokenizer.json", + &dest.join("tokenizer.json"), + &mut on_progress, + )?; + download_url_file( + &client, + MEL_FILTERS_URL, + "mel_filters.npz", + &dest.join("mel_filters.npz"), + &mut on_progress, + )?; + verify_sha256(&dest.join("mel_filters.npz"), MEL_FILTERS_SHA256)?; + + let weights_dest = dest.join("weights.safetensors"); + let weights_alt = dest.join("model.safetensors"); if !weights_dest.exists() && !weights_alt.exists() { - // Prefer model.safetensors; fall back to weights.safetensors if 404. + // mlx-community ships weights.safetensors; fall back to model.safetensors if 404. match download_hf_file( &client, DEFAULT_WHISPER_REPO, - "model.safetensors", + "weights.safetensors", &weights_dest, &mut on_progress, ) { @@ -373,12 +470,12 @@ where Err(err) => { tracing::warn!( error = %err, - "model.safetensors missing; trying weights.safetensors" + "weights.safetensors missing; trying model.safetensors" ); download_hf_file( &client, DEFAULT_WHISPER_REPO, - "weights.safetensors", + "model.safetensors", &weights_alt, &mut on_progress, )?; @@ -396,27 +493,22 @@ where Ok(canonicalize_or_self(dest)) } -/// Copy a warm Hugging Face cache snapshot into the user models directory. +/// Copy selected model files from a local source into the user models directory. /// -/// Lets Settings → Download complete without network traffic when the cache is -/// already populated. Existing destination files are left alone, so an -/// interrupted copy resumes rather than restarting. -fn copy_complete_model_dir(src: &Path, dest: &Path) -> Result<()> { - fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?; - for name in REQUIRED_MODEL_FILES { - let from = src.join(name); - let to = dest.join(name); - if from.exists() && !to.exists() { - // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Both ends are internal: `name` comes from the REQUIRED_MODEL_FILES compile-time constant, and the only caller passes the HF cache snapshot dir and `ModelManager::get_model_path(DEFAULT_MODEL)`. No caller-supplied path component reaches here. - fs::copy(&from, &to) - .with_context(|| format!("copy {} → {}", from.display(), to.display()))?; - } +/// Lets Settings → Download complete without network traffic when the pieces are +/// already on disk (warm official caches). A missing source is a +/// clean no-op and existing destination files are left alone, so an interrupted +/// composition resumes rather than restarting. +fn copy_model_files(src: &Path, dest: &Path, names: &[&str]) -> Result<()> { + if !src.is_dir() { + return Ok(()); } - for name in REQUIRED_MODEL_WEIGHTS { + fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?; + for name in names { let from = src.join(name); let to = dest.join(name); if from.exists() && !to.exists() { - // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Same as above: `name` is a REQUIRED_MODEL_WEIGHTS constant, both dirs are derived from DEFAULT_MODEL. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Both ends are internal: `name` comes from compile-time model file constants, and callers pass HF cache snapshot dirs or ModelManager::get_model_path outputs. No caller-supplied path component reaches here. fs::copy(&from, &to) .with_context(|| format!("copy {} → {}", from.display(), to.display()))?; } @@ -450,8 +542,35 @@ where } let url = format!("https://huggingface.co/{repo}/resolve/main/{filename}"); - let mut request = client.get(&url); - if let Ok(token) = std::env::var("HF_TOKEN") { + download_url_file_authenticated(client, &url, filename, dest, on_progress, true) +} + +fn download_url_file( + client: &reqwest::blocking::Client, + url: &str, + filename: &str, + dest: &Path, + on_progress: &mut F, +) -> Result<()> +where + F: FnMut(&str, u64, Option), +{ + download_url_file_authenticated(client, url, filename, dest, on_progress, false) +} + +fn download_url_file_authenticated( + client: &reqwest::blocking::Client, + url: &str, + filename: &str, + dest: &Path, + on_progress: &mut F, + use_hf_token: bool, +) -> Result<()> +where + F: FnMut(&str, u64, Option), +{ + let mut request = client.get(url); + if use_hf_token && let Ok(token) = std::env::var("HF_TOKEN") { let token = token.trim(); if !token.is_empty() { request = request.bearer_auth(token); @@ -503,6 +622,21 @@ where Ok(()) } +fn verify_sha256(path: &Path, expected: &str) -> Result<()> { + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Read-only checksum of the fixed mel_filters.npz destination assembled under the internally resolved model directory. + let bytes = fs::read(path).with_context(|| format!("read {} for checksum", path.display()))?; + let actual = format!("{:x}", Sha256::digest(bytes)); + if actual != expected { + return Err(anyhow!( + "SHA-256 mismatch for {}: expected {}, got {}", + path.display(), + expected, + actual + )); + } + Ok(()) +} + /// ModelManager resolution, completeness gates, and env-override isolation tests. #[cfg(test)] mod tests { @@ -554,7 +688,38 @@ mod tests { fs::write(path.join("config.json"), "{}").unwrap(); fs::write(path.join("tokenizer.json"), "{}").unwrap(); fs::write(path.join("mel_filters.npz"), "npz").unwrap(); - fs::write(path.join("model.safetensors"), "weights").unwrap(); + let header = br#"{"model.weight":{"dtype":"F16","shape":[1],"data_offsets":[0,2]}}"#; + let mut safetensors = (header.len() as u64).to_le_bytes().to_vec(); + safetensors.extend_from_slice(header); + safetensors.extend_from_slice(&[0, 0]); + fs::write(path.join("model.safetensors"), safetensors).unwrap(); + } + + fn create_q8_whisper_model(path: &Path) { + create_complete_whisper_model(path); + fs::write( + path.join("config.json"), + r#"{"quantization":{"group_size":32,"bits":8}}"#, + ) + .unwrap(); + } + + /// A bundle containing only the semantic embedder must not claim ownership + /// of Whisper resolution and hide the user's complete fp16 install. + #[test] + fn embedder_only_models_root_does_not_qualify_as_whisper_root() { + let temp_dir = TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("models"); + let embedder = models_dir.join("embedder"); + fs::create_dir_all(&embedder).unwrap(); + fs::write(embedder.join("config.json"), "{}").unwrap(); + fs::write(embedder.join("tokenizer.json"), "{}").unwrap(); + fs::write(embedder.join("model.safetensors"), "not-a-safetensors-file").unwrap(); + + assert!(!models_root_contains_complete_whisper_model(&models_dir)); + + create_complete_whisper_model(&models_dir.join(DEFAULT_MODEL)); + assert!(models_root_contains_complete_whisper_model(&models_dir)); } /// Smoke: `list_models` succeeds against the live models dir. @@ -585,11 +750,7 @@ mod tests { let models_dir = temp_dir.path().join("../../models"); fs::create_dir_all(&models_dir).unwrap(); - let model_names = [ - "whisper-base-mlx-q8", - "whisper-medium-mlx-q8", - "whisper-large-v3-turbo-mlx-q8", - ]; + let model_names = ["whisper-base-fp16", "whisper-medium-fp16", DEFAULT_MODEL]; for name in &model_names { let model_path = models_dir.join(name); @@ -628,6 +789,36 @@ mod tests { assert_eq!(manager.list_models().unwrap(), vec!["complete-whisper"]); } + /// Q8 is refused even when every expected file exists. + #[test] + #[serial] + fn model_manager_rejects_complete_q8_model() { + let temp_dir = TempDir::new().unwrap(); + let models_dir = temp_dir.path().join("models"); + let q8 = models_dir.join("renamed-as-fp16"); + create_q8_whisper_model(&q8); + + let _models_dir = EnvGuard::set("CODESCRIBE_MODELS_DIR", &models_dir); + let manager = ModelManager::new().unwrap(); + assert!(!manager.check_model_exists("renamed-as-fp16")); + assert!(manager.list_models().unwrap().is_empty()); + } + + /// Header-level detection catches packed q8 even if config metadata lies. + #[test] + fn model_manager_rejects_q8_tensor_header_without_quantization_config() { + let temp_dir = TempDir::new().unwrap(); + let model = temp_dir.path().join("model"); + create_complete_whisper_model(&model); + let header = br#"{"encoder.weight":{"dtype":"U32","shape":[1],"data_offsets":[0,4]},"encoder.scales":{"dtype":"F16","shape":[1],"data_offsets":[4,6]}}"#; + let mut safetensors = (header.len() as u64).to_le_bytes().to_vec(); + safetensors.extend_from_slice(header); + safetensors.extend_from_slice(&[0; 6]); + fs::write(model.join("model.safetensors"), safetensors).unwrap(); + + assert!(!is_complete_whisper_model_dir(&model)); + } + /// Complete `CODESCRIBE_MODEL_PATH` wins over the bundled default tier. #[test] #[serial] @@ -720,7 +911,7 @@ mod tests { ); } - /// All-empty fallback chain returns guidance mentioning env and `hf download`. + /// All-empty fallback chain returns guidance mentioning env and the composer. #[test] #[serial] fn resolve_runtime_whisper_model_path_errors_with_guidance_when_all_tiers_empty() { @@ -740,8 +931,8 @@ mod tests { "error must mention the env override knob, got: {message}" ); assert!( - message.contains("hf download"), - "error must hint at HF cache warm-up, got: {message}" + message.contains("make download-model"), + "error must point to the complete-model composer, got: {message}" ); } @@ -761,7 +952,7 @@ mod tests { let status = whisper_model_status(); assert_eq!(status.model_id, DEFAULT_MODEL); assert_eq!(status.repo, DEFAULT_WHISPER_REPO); - assert!(status.size_hint.contains("MB")); + assert!(status.size_hint.contains("GB")); // embedded flag must match cfg(embed_model) payload; we only assert type wiring. let _ = status.available; let _ = status.embedded; diff --git a/core/config/prompts.rs b/core/config/prompts.rs index ceef399d..41f15dc6 100644 --- a/core/config/prompts.rs +++ b/core/config/prompts.rs @@ -390,6 +390,36 @@ pub fn write_prompt_bytes( kind: PromptKind, content: &[u8], reason: PromptWriteReason, +) -> std::io::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io().map_err(std::io::Error::other)?; + write_prompt_bytes_unfenced(kind, content, reason) +} + +/// Restore bytes captured by the reset owner after the live root has moved. +/// +/// This is the sole write allowed through the process-lifetime reset latch. +/// Possessing a reset guard is insufficient until the destructive phase has +/// actually been armed, so ordinary callers cannot use this as a fence bypass. +pub fn write_prompt_bytes_during_reset( + reset: &super::storage_reset::AppDataResetGuard, + kind: PromptKind, + content: &[u8], + reason: PromptWriteReason, +) -> std::io::Result<()> { + if !reset.permits_preserved_restore() { + return Err(std::io::Error::other( + "preserved prompt restore requires a destructively latched reset", + )); + } + write_prompt_bytes_unfenced(kind, content, reason) +} + +/// Raw prompt transaction used only after normal I/O admission or by the +/// token-bearing reset restore path above. +fn write_prompt_bytes_unfenced( + kind: PromptKind, + content: &[u8], + reason: PromptWriteReason, ) -> std::io::Result<()> { write_prompt_at_with_rename( &prompts_dir().join(kind.filename()), diff --git a/core/config/settings.rs b/core/config/settings.rs index 1c67b163..b13b0c57 100644 --- a/core/config/settings.rs +++ b/core/config/settings.rs @@ -5,9 +5,23 @@ use super::types::{ModeBinding, ShortcutBinding, WorkMode, default_mode_bindings}; use directories::BaseDirs; use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; use tracing::{debug, info, warn}; +use uuid::Uuid; + +/// Serialize settings read/migrate/write transactions. A V1 load writes a +/// backup and a V3 replacement, so it is a writer even though the public API is +/// named `load`; one lock keeps concurrent migrations and saves from crossing. +fn settings_io_lock() -> MutexGuard<'static, ()> { + static SETTINGS_IO: OnceLock> = OnceLock::new(); + SETTINGS_IO + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} /// Canonical formatting policy shared by persistence, runtime selection, and UI. /// @@ -161,6 +175,19 @@ pub struct UserSettings { pub transcription_overlay_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tray_start_assistive: Option, + // Promoted 2026-08-11: these lived only in `.env`, so the tray/settings + // writers died silently once the file became unwritable (uchg lock) and + // the 2026-08-08 wipe erased the user's values outright. + #[serde(skip_serializing_if = "Option::is_none")] + pub hold_indicator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hold_badge_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_clipboard: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restore_clipboard_delay_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deferred_insert_shortcut: Option, // ── Promoted from .env (settings.json is now source of truth) ── #[serde(skip_serializing_if = "Option::is_none")] @@ -225,8 +252,10 @@ pub struct UserSettings { #[serde(skip_serializing_if = "Option::is_none")] pub final_pass_mode: Option, /// Layered incremental transcription phase ("off" | "phase1"). - /// Seeds `CODESCRIBE_LAYERED_TRANSCRIPTION`; anything other than - /// "phase1".."phase4" (or bare "1".."4") is treated as OFF by the core. + /// Seeds `CODESCRIBE_LAYERED_TRANSCRIPTION`. Absent matches the core + /// default (`unset` → phase1). Explicit "off" / "0" / "false" disarms. + /// This legacy local lane is effective only in resolved `local_power`; + /// cloud and Apple-only never allow this field to load local weights. #[serde(skip_serializing_if = "Option::is_none")] pub layered_transcription: Option, /// Opt-in Whisper `initial_prompt` vocabulary hint. @@ -234,6 +263,30 @@ pub struct UserSettings { #[serde(skip_serializing_if = "Option::is_none")] pub stt_initial_prompt_enabled: Option, + // ── Layer 1 ASR product mode + audio-egress consent (C2) ── + /// Layer 1 product mode (`cloud` | `local_power` | `apple_only`). + /// `None` means "not yet chosen": the resolver derives the mode from the + /// legacy `use_local_stt` choice (upgrades) or lands on Apple-only (fresh). + /// Writes are validated through [`crate::config::cloud_asr::AsrProductMode`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub asr_mode: Option, + /// Audio-egress consent record (`granted` | `denied`). `None` means never + /// asked; anything non-canonical reads as unanswered (fail closed). Cloud + /// mode without a granted record resolves to Apple-only — see + /// [`UserSettings::resolved_asr_mode`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub cloud_consent: Option, + /// RFC 3339 timestamp of the last explicit consent answer. Informational + /// provenance only — never an input to the resolver. + #[serde(skip_serializing_if = "Option::is_none")] + pub cloud_consent_at: Option, + /// Libraxis gateway session-mint endpoint. Endpoint only, never a vendor + /// key: writes are validated through + /// [`crate::config::cloud_asr::GatewaySessionMint`], which refuses + /// user-info and query material. `None` means "not configured". + #[serde(skip_serializing_if = "Option::is_none")] + pub asr_gateway_url: Option, + // ── Agent workspace ── /// Workspace root directories the agent scans (`list_projects`) to resolve a /// project name to an absolute path. The Settings UI sends the @@ -320,6 +373,12 @@ struct InteractionV2 { /// dictation. Assistive and safety vetoes are enforced by the controller. #[serde(skip_serializing_if = "Option::is_none")] auto_paste_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + deferred_insert_shortcut: Option, + #[serde(skip_serializing_if = "Option::is_none")] + restore_clipboard: Option, + #[serde(skip_serializing_if = "Option::is_none")] + restore_clipboard_delay_ms: Option, } /// Timing of the tap-based triggers: how fast a double tap must be, and how @@ -395,6 +454,12 @@ struct SpeechEngineV2 { layered_transcription: Option, #[serde(skip_serializing_if = "Option::is_none")] initial_prompt_enabled: Option, + // C2: Layer 1 product mode (cloud | local_power | apple_only) and the + // gateway session-mint endpoint it uses when cloud is armed. + #[serde(skip_serializing_if = "Option::is_none")] + asr_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + gateway_session_url: Option, } /// LLM post-processing of the transcript: whether it runs, how aggressively, @@ -482,6 +547,10 @@ struct UiV2 { transcription_overlay_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] tray_start_assistive: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hold_indicator: Option, + #[serde(skip_serializing_if = "Option::is_none")] + hold_badge_size: Option, } /// `features` section: optional surfaces the user can switch off entirely, @@ -527,6 +596,12 @@ struct SystemV2 { // xAI account-login OAuth client id (non-secret app identity). #[serde(skip_serializing_if = "Option::is_none")] xai_oauth_client_id: Option, + // C2: audio-egress consent record — install-level privacy state, kept in + // `system` so engine-section rewrites can never touch it. + #[serde(skip_serializing_if = "Option::is_none")] + cloud_audio_egress_consent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cloud_audio_egress_consent_at: Option, } /// Canonical list of env keys that route to `settings.json` (not `.env`). @@ -558,6 +633,13 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ "SHOW_DOCK_ICON", "TRANSCRIPTION_OVERLAY_ENABLED", "TRAY_START_ASSISTIVE", + // Pointer indicator + delivery (promoted 2026-08-11: .env writes died + // silently under the uchg lock, killing the tray Pointer Indicator row) + "HOLD_INDICATOR", + "HOLD_BADGE_SIZE", + "RESTORE_CLIPBOARD", + "RESTORE_CLIPBOARD_DELAY_MS", + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT", // LLM endpoints "LLM_ENDPOINT", "LLM_MODEL", @@ -601,6 +683,11 @@ pub const PROMOTED_SETTINGS_KEYS: &[&str] = &[ // Promoted 2026-08-10: the un-promoted toggle wrote .env only, the stale // process env won the UI read-back, and the Layered switch snapped OFF. "CODESCRIBE_LAYERED_TRANSCRIPTION", + // C2: Layer 1 product mode, audio-egress consent, gateway mint endpoint. + // settings.json is the single brain — no .env dual-write for these. + "CODESCRIBE_ASR_MODE", + "CODESCRIBE_CLOUD_CONSENT", + "CODESCRIBE_ASR_GATEWAY_URL", // Still env-seedable when unset; not full dual-brain: // "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED", ]; @@ -633,6 +720,9 @@ impl UserSettings { send_mode: self.transcript_send_mode.clone(), agent_enter_sends: self.agent_enter_sends, auto_paste_enabled: self.auto_paste_enabled, + deferred_insert_shortcut: self.deferred_insert_shortcut.clone(), + restore_clipboard: self.restore_clipboard, + restore_clipboard_delay_ms: self.restore_clipboard_delay_ms, }), speech: Some(SpeechV2 { language: self.whisper_language.clone(), @@ -648,6 +738,8 @@ impl UserSettings { final_pass_mode: self.final_pass_mode.clone(), layered_transcription: self.layered_transcription.clone(), initial_prompt_enabled: self.stt_initial_prompt_enabled, + asr_mode: self.asr_mode.clone(), + gateway_session_url: self.asr_gateway_url.clone(), }), formatting: Some(FormattingV2 { enabled: self.ai_formatting_enabled, @@ -688,6 +780,8 @@ impl UserSettings { show_dock_icon: self.show_dock_icon, transcription_overlay_enabled: self.transcription_overlay_enabled, tray_start_assistive: self.tray_start_assistive, + hold_indicator: self.hold_indicator, + hold_badge_size: self.hold_badge_size, }), features: Some(FeaturesV2 { history_enabled: self.history_enabled, @@ -703,6 +797,8 @@ impl UserSettings { openai_oauth_client_id: self.openai_oauth_client_id.clone(), anthropic_oauth_client_id: self.anthropic_oauth_client_id.clone(), xai_oauth_client_id: self.xai_oauth_client_id.clone(), + cloud_audio_egress_consent: self.cloud_consent.clone(), + cloud_audio_egress_consent_at: self.cloud_consent_at.clone(), }), agent: match ( self.agent_permissions.clone(), @@ -817,6 +913,20 @@ impl UserSettings { .as_ref() .and_then(|ui| ui.transcription_overlay_enabled), tray_start_assistive: v2.ui.as_ref().and_then(|ui| ui.tray_start_assistive), + hold_indicator: v2.ui.as_ref().and_then(|ui| ui.hold_indicator), + hold_badge_size: v2.ui.as_ref().and_then(|ui| ui.hold_badge_size), + deferred_insert_shortcut: v2 + .interaction + .as_ref() + .and_then(|interaction| interaction.deferred_insert_shortcut.clone()), + restore_clipboard: v2 + .interaction + .as_ref() + .and_then(|interaction| interaction.restore_clipboard), + restore_clipboard_delay_ms: v2 + .interaction + .as_ref() + .and_then(|interaction| interaction.restore_clipboard_delay_ms), llm_formatting_endpoint: v2 .speech .as_ref() @@ -931,6 +1041,24 @@ impl UserSettings { .as_ref() .and_then(|s| s.engine.as_ref()) .and_then(|e| e.initial_prompt_enabled), + asr_mode: v2 + .speech + .as_ref() + .and_then(|s| s.engine.as_ref()) + .and_then(|e| e.asr_mode.clone()), + asr_gateway_url: v2 + .speech + .as_ref() + .and_then(|s| s.engine.as_ref()) + .and_then(|e| e.gateway_session_url.clone()), + cloud_consent: v2 + .system + .as_ref() + .and_then(|s| s.cloud_audio_egress_consent.clone()), + cloud_consent_at: v2 + .system + .as_ref() + .and_then(|s| s.cloud_audio_egress_consent_at.clone()), agent_permissions: v2.agent.as_ref().and_then(|a| a.permissions.clone()), agent_capabilities: v2.agent.as_ref().and_then(|a| a.capabilities.clone()), } @@ -962,11 +1090,46 @@ impl UserSettings { /// Write via temp file plus rename, so a crash mid-write leaves the previous /// `settings.json` intact rather than a truncated one the app would treat /// as corrupt and silently replace with defaults. - fn write_json_atomic(path: &PathBuf, json: &str) -> anyhow::Result<()> { - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, json)?; - fs::rename(&tmp, path)?; - Ok(()) + fn write_json_atomic(path: &Path, json: &str) -> anyhow::Result<()> { + Self::write_json_atomic_with(path, json, |from, to| fs::rename(from, to)) + } + + /// Atomic settings write with the final rename injected for deterministic + /// failure tests. Production always passes `fs::rename`; tests never depend + /// on guessing the unique temp filename. + fn write_json_atomic_with(path: &Path, json: &str, rename: F) -> anyhow::Result<()> + where + F: FnOnce(&Path, &Path) -> std::io::Result<()>, + { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("settings path has no parent: {}", path.display()))?; + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("settings.json"); + let tmp = parent.join(format!( + ".{filename}.tmp.{}.{}", + std::process::id(), + Uuid::new_v4() + )); + let outcome = (|| -> anyhow::Result<()> { + let mut file = OpenOptions::new().create_new(true).write(true).open(&tmp)?; + file.write_all(json.as_bytes())?; + file.sync_all()?; + drop(file); + rename(&tmp, path)?; + // `parent` is derived only from the canonical internal settings + // path above; opening it read-only is the durability fsync, not a + // request-controlled file lookup. + // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path + File::open(parent)?.sync_all()?; + Ok(()) + })(); + if outcome.is_err() { + let _ = fs::remove_file(&tmp); + } + outcome } /// Returns the settings directory. @@ -974,7 +1137,7 @@ impl UserSettings { /// Respects `CODESCRIBE_DATA_DIR` for test isolation; otherwise uses /// `~/Library/Application Support/Codescribe/`. pub fn settings_dir() -> PathBuf { - let dir = if let Ok(test_dir) = std::env::var("CODESCRIBE_DATA_DIR") { + if let Ok(test_dir) = std::env::var("CODESCRIBE_DATA_DIR") { PathBuf::from(test_dir) } else { BaseDirs::new() @@ -983,14 +1146,7 @@ impl UserSettings { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); PathBuf::from(home).join("Library/Application Support/Codescribe") }) - }; - - if !dir.exists() - && let Err(e) = fs::create_dir_all(&dir) - { - warn!("Failed to create settings dir {}: {e}", dir.display()); } - dir } /// Returns the path to `settings.json`. @@ -1000,6 +1156,19 @@ impl UserSettings { /// Loads settings from disk. Returns `Default` on any error. pub fn load() -> Self { + let _data_io = match super::storage_reset::begin_app_data_io() { + Ok(guard) => guard, + Err(error) => { + warn!(%error, "Settings load skipped while app-data reset owns the process"); + return Self::default(); + } + }; + let _settings_io = settings_io_lock(); + Self::load_unlocked() + } + + /// Load while the settings transaction lock and app-data admission are held. + fn load_unlocked() -> Self { let path = Self::settings_path(); match fs::read_to_string(&path) { Ok(contents) => match serde_json::from_str::(&contents) { @@ -1029,7 +1198,7 @@ impl UserSettings { backup_path.display() ); } - if let Err(e) = v1.save() { + if let Err(e) = v1.save_unlocked() { warn!("Failed hard-migrating settings V1 -> V2: {e}"); } else { info!( @@ -1063,6 +1232,90 @@ impl UserSettings { /// Persists current settings to disk as pretty-printed JSON. pub fn save(&self) -> anyhow::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io()?; + let _settings_io = settings_io_lock(); + self.save_unlocked() + } + + /// Remove only Agent-owned fields from the persisted JSON document. + /// + /// This intentionally edits the raw JSON value instead of doing a + /// `load()` -> `save()` round-trip. `load()` is fail-soft and returns + /// defaults for malformed input; using it in a destructive reset could + /// therefore replace an unreadable settings file with defaults and erase + /// unrelated user choices. Unknown fields and every non-Agent subtree are + /// preserved value-for-value. A malformed document is left untouched. + pub fn remove_agent_owned_state() -> anyhow::Result<()> { + let _data_io = super::storage_reset::begin_app_data_io()?; + let _settings_io = settings_io_lock(); + let path = Self::settings_path(); + if !path.exists() { + return Ok(()); + } + + let contents = fs::read_to_string(&path)?; + let mut value: serde_json::Value = serde_json::from_str(&contents)?; + let is_v2 = value.get("schema_version").is_some(); + + if is_v2 { + let before: SettingsV2 = serde_json::from_value(value.clone())?; + Self::validate_v2(&before)?; + } else { + let _: Self = serde_json::from_value(value.clone())?; + } + + let mut changed = false; + if is_v2 { + changed |= remove_json_keys_at( + &mut value, + &["speech", "assistive"], + &["llm_endpoint", "llm_model", "provider"], + )?; + changed |= remove_json_keys_at( + &mut value, + &["system"], + &[ + "agent_workspace_roots", + "openai_oauth_client_id", + "anthropic_oauth_client_id", + "xai_oauth_client_id", + ], + )?; + changed |= + remove_json_keys_at(&mut value, &["agent"], &["permissions", "capabilities"])?; + changed |= remove_json_keys_at(&mut value, &["interaction"], &["agent_enter_sends"])?; + + let after: SettingsV2 = serde_json::from_value(value.clone())?; + Self::validate_v2(&after)?; + } else { + changed |= remove_json_keys_at( + &mut value, + &[], + &[ + "llm_assistive_endpoint", + "llm_assistive_model", + "llm_assistive_provider", + "openai_oauth_client_id", + "anthropic_oauth_client_id", + "xai_oauth_client_id", + "agent_workspace_roots", + "agent_permissions", + "agent_capabilities", + "agent_enter_sends", + ], + )?; + let _: Self = serde_json::from_value(value.clone())?; + } + + if !changed { + return Ok(()); + } + let json = serde_json::to_string_pretty(&value)?; + Self::write_json_atomic(&path, &json) + } + + /// Persist while the settings transaction lock and app-data admission are held. + fn save_unlocked(&self) -> anyhow::Result<()> { let dir = Self::settings_dir(); fs::create_dir_all(&dir)?; let path = Self::settings_path(); @@ -1210,6 +1463,17 @@ impl UserSettings { return; } }, + "CODESCRIBE_DEFERRED_INSERT_SHORTCUT" => { + match value.parse::() { + Ok(shortcut) => { + self.deferred_insert_shortcut = Some(shortcut.wire_id().to_string()) + } + Err(error) => { + warn!("Rejected deferred-insert shortcut write: {error}"); + return; + } + } + } "TRANSCRIPT_TAG_TEMPLATE" => self.transcript_tag_template = Some(value.to_owned()), "LLM_FORMATTING_ENDPOINT" => self.llm_formatting_endpoint = Some(value.to_owned()), "LLM_FORMATTING_MODEL" => self.llm_formatting_model = Some(value.to_owned()), @@ -1238,6 +1502,55 @@ impl UserSettings { "CODESCRIBE_LAYERED_TRANSCRIPTION" => { self.layered_transcription = Some(value.to_owned()) } + "CODESCRIBE_ASR_MODE" => { + // Empty clears back to derivation (legacy choice or Apple-only). + let trimmed = value.trim(); + if trimmed.is_empty() { + self.asr_mode = None; + } else { + match trimmed.parse::() { + Ok(mode) => self.asr_mode = Some(mode.as_str().to_string()), + Err(error) => { + warn!("Rejected ASR mode write: {error}"); + return; + } + } + } + } + "CODESCRIBE_CLOUD_CONSENT" => { + // Explicit answers only; empty clears the record back to + // "never asked". Every answer stamps its provenance timestamp. + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "" => { + self.cloud_consent = None; + self.cloud_consent_at = None; + } + crate::config::cloud_asr::CONSENT_WIRE_GRANTED + | crate::config::cloud_asr::CONSENT_WIRE_DENIED => { + self.cloud_consent = Some(normalized); + self.cloud_consent_at = Some(chrono::Utc::now().to_rfc3339()); + } + _ => { + warn!("Rejected cloud consent write (expected granted|denied): {value}"); + return; + } + } + } + "CODESCRIBE_ASR_GATEWAY_URL" => { + let trimmed = value.trim(); + if trimmed.is_empty() { + self.asr_gateway_url = None; + } else { + match crate::config::cloud_asr::GatewaySessionMint::new(trimmed) { + Ok(mint) => self.asr_gateway_url = Some(mint.url().to_string()), + Err(error) => { + warn!("Rejected ASR gateway URL write: {error}"); + return; + } + } + } + } "CODESCRIBE_QUBE_DONOR" => { let normalized = value.trim().to_ascii_lowercase(); match normalized.as_str() { @@ -1267,6 +1580,20 @@ impl UserSettings { self.save_if_changed(&before, "set_string", key); } + /// Resolve the effective Layer 1 product mode from this settings snapshot. + /// + /// The one sanctioned read path: combines the persisted `asr_mode`, the + /// consent record, and the legacy `use_local_stt` choice through + /// [`crate::config::cloud_asr::resolve_asr_product_mode`]. Callers must not + /// re-derive policy from the raw fields. + pub fn resolved_asr_mode(&self) -> crate::config::cloud_asr::ResolvedAsrMode { + crate::config::cloud_asr::resolve_asr_product_mode( + self.asr_mode.as_deref(), + self.cloud_consent.as_deref(), + self.use_local_stt, + ) + } + /// Sets a boolean-valued setting by its .env key name and saves. pub fn set_bool(&mut self, key: &str, value: bool) { let before = self.clone(); @@ -1278,6 +1605,8 @@ impl UserSettings { "SHOW_DOCK_ICON" => self.show_dock_icon = Some(value), "TRANSCRIPTION_OVERLAY_ENABLED" => self.transcription_overlay_enabled = Some(value), "TRAY_START_ASSISTIVE" => self.tray_start_assistive = Some(value), + "HOLD_INDICATOR" => self.hold_indicator = Some(value), + "RESTORE_CLIPBOARD" => self.restore_clipboard = Some(value), "HOLD_EXCLUSIVE" => self.hold_exclusive = Some(value), "USE_LOCAL_STT" => self.use_local_stt = Some(value), "HISTORY_ENABLED" => self.history_enabled = Some(value), @@ -1306,6 +1635,8 @@ impl UserSettings { "CODESCRIBE_BUFFER_DELAY_MS" => self.buffer_delay_ms = Some(value), "CODESCRIBE_EMIT_WORDS_MAX" => self.emit_words_max = Some(value), "BACKEND_MAX_UPLOAD_MB" => self.backend_max_upload_mb = Some(value), + "HOLD_BADGE_SIZE" => self.hold_badge_size = Some(value), + "RESTORE_CLIPBOARD_DELAY_MS" => self.restore_clipboard_delay_ms = Some(value), other => { warn!("Unknown u64 setting key: {other}"); return; @@ -1331,6 +1662,38 @@ impl UserSettings { } } +/// Remove named keys from an existing JSON object reached by `path`. Missing +/// sections are a no-op; a present non-object is an error so reset never +/// normalizes malformed state by destroying sibling settings. +fn remove_json_keys_at( + value: &mut serde_json::Value, + path: &[&str], + keys: &[&str], +) -> anyhow::Result { + let mut current = value; + for component in path { + let Some(next) = current.get_mut(*component) else { + return Ok(false); + }; + current = next; + } + let object = current.as_object_mut().ok_or_else(|| { + anyhow::anyhow!( + "settings path {} must be an object", + if path.is_empty() { + "".to_string() + } else { + path.join(".") + } + ) + })?; + let mut changed = false; + for key in keys { + changed |= object.remove(*key).is_some(); + } + Ok(changed) +} + /// Persistence is exercised against real files in a temp data dir, not against /// in-memory conversions — the failures these guard against (ghosted fields, /// migration loss, write amplification) only appear on the round-trip through @@ -1357,6 +1720,107 @@ mod tests { tmp } + #[test] + #[serial] + fn agent_reset_removes_only_owned_json_fields_and_preserves_unknowns() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let seeded = serde_json::json!({ + "schema_version": 3, + "interaction": { + "agent_enter_sends": true, + "auto_paste_enabled": false, + "future_interaction": "keep" + }, + "speech": { + "language": "pl", + "assistive": { + "llm_endpoint": "https://agent.example", + "llm_model": "agent-model", + "provider": "openai-responses" + }, + "formatting": { "level": "smart" }, + "future_speech": { "keep": true } + }, + "system": { + "agent_workspace_roots": ["/tmp/project"], + "openai_oauth_client_id": "openai-client", + "anthropic_oauth_client_id": "anthropic-client", + "xai_oauth_client_id": "xai-client", + "onboarding_mode": "basic", + "future_system": 42 + }, + "agent": { + "permissions": null, + "capabilities": null, + "future_agent": "keep" + }, + "future_top_level": { "keep": "exactly" } + }); + fs::write( + &path, + serde_json::to_string_pretty(&seeded).expect("serialize fixture"), + ) + .expect("seed settings"); + + UserSettings::remove_agent_owned_state().expect("surgical Agent settings reset"); + + let after: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).expect("read reset settings")) + .expect("parse reset settings"); + for pointer in [ + "/interaction/agent_enter_sends", + "/speech/assistive/llm_endpoint", + "/speech/assistive/llm_model", + "/speech/assistive/provider", + "/system/agent_workspace_roots", + "/system/openai_oauth_client_id", + "/system/anthropic_oauth_client_id", + "/system/xai_oauth_client_id", + "/agent/permissions", + "/agent/capabilities", + ] { + assert!( + after.pointer(pointer).is_none(), + "Agent field survived: {pointer}" + ); + } + for pointer in [ + "/interaction/auto_paste_enabled", + "/interaction/future_interaction", + "/speech/language", + "/speech/formatting", + "/speech/future_speech", + "/system/onboarding_mode", + "/system/future_system", + "/agent/future_agent", + "/future_top_level", + ] { + assert_eq!( + after.pointer(pointer), + seeded.pointer(pointer), + "non-Agent field changed: {pointer}" + ); + } + } + + #[test] + #[serial] + fn agent_reset_refuses_malformed_settings_without_rewriting_bytes() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let malformed = b"{ this is not valid settings JSON"; + fs::write(&path, malformed).expect("seed malformed settings"); + + UserSettings::remove_agent_owned_state().expect_err("malformed settings must fail closed"); + + assert_eq!( + fs::read(&path).expect("read malformed settings after refusal"), + malformed, + "Agent reset rewrote malformed settings" + ); + } + /// Zoom normalization: clamped to the supported range, rounded to two /// decimals, and the effective default encoded as `None` so it is omitted /// from the file entirely. @@ -1514,6 +1978,50 @@ mod tests { assert!(!UserSettings::settings_path().exists()); } + /// A failed final rename removes its unique temp and leaves the last + /// committed settings bytes untouched. This is the fault-injection seam for + /// atomic persistence; blocking a historical fixed temp name proves nothing + /// now that every transaction owns a UUID path. + #[test] + #[serial] + fn atomic_settings_rename_failure_preserves_committed_truth_and_cleans_temp() { + let _tmp = setup_isolated_data_dir(); + let path = UserSettings::settings_path(); + let original = UserSettings { + auto_paste_enabled: Some(false), + ..Default::default() + }; + original.save().expect("seed committed settings"); + let before = fs::read(&path).expect("read committed settings"); + + let replacement = UserSettings { + auto_paste_enabled: Some(true), + ..original + }; + let json = serde_json::to_string_pretty(&replacement.to_v2()) + .expect("serialize replacement settings"); + let error = UserSettings::write_json_atomic_with(&path, &json, |_from, _to| { + Err(std::io::Error::other("forced settings rename failure")) + }) + .expect_err("forced rename must fail"); + assert!(error.to_string().contains("forced settings rename failure")); + assert_eq!( + fs::read(&path).expect("read settings after failed rename"), + before + ); + let orphan_temps: Vec<_> = fs::read_dir(UserSettings::settings_dir()) + .expect("read settings directory") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".settings.json.tmp.") + }) + .collect(); + assert!(orphan_temps.is_empty(), "failed write leaked a unique temp"); + } + /// A `false` written through the setter survives reload — the case a naive /// `skip_serializing_if` on a plain `bool` would silently lose. #[test] @@ -2028,4 +2536,131 @@ mod tests { ShortcutBinding::DoubleRightOption ); } + + // ── C2: Layer 1 ASR mode + audio-egress consent ── + + /// The three C2 keys are promoted: writes route to settings.json, never + /// to `.env`, and never to the Keychain. + #[test] + fn c2_keys_are_promoted() { + assert!(is_promoted_key("CODESCRIBE_ASR_MODE")); + assert!(is_promoted_key("CODESCRIBE_CLOUD_CONSENT")); + assert!(is_promoted_key("CODESCRIBE_ASR_GATEWAY_URL")); + } + + /// Mode, consent (with timestamp), and gateway URL survive the full disk + /// round-trip through the V2 schema — no ghosting. + #[test] + #[serial] + fn c2_fields_round_trip_through_v2_schema() { + let _tmp = setup_isolated_data_dir(); + let mut settings = UserSettings::default(); + settings.set_string("CODESCRIBE_ASR_MODE", "cloud"); + settings.set_string("CODESCRIBE_CLOUD_CONSENT", "granted"); + settings.set_string( + "CODESCRIBE_ASR_GATEWAY_URL", + "https://gateway.libraxis.cloud/v1/asr/sessions", + ); + + let loaded = UserSettings::load(); + assert_eq!(loaded.asr_mode.as_deref(), Some("cloud")); + assert_eq!(loaded.cloud_consent.as_deref(), Some("granted")); + assert!( + loaded.cloud_consent_at.is_some(), + "explicit consent answer must stamp its provenance timestamp" + ); + assert_eq!( + loaded.asr_gateway_url.as_deref(), + Some("https://gateway.libraxis.cloud/v1/asr/sessions") + ); + + // On-disk placement: mode + gateway in speech.engine, consent in system. + let raw: serde_json::Value = serde_json::from_str( + &fs::read_to_string(UserSettings::settings_path()).expect("read settings"), + ) + .expect("parse settings"); + assert_eq!( + raw.pointer("/speech/engine/asr_mode") + .and_then(|v| v.as_str()), + Some("cloud") + ); + assert_eq!( + raw.pointer("/system/cloud_audio_egress_consent") + .and_then(|v| v.as_str()), + Some("granted") + ); + } + + /// Invalid mode, consent, and gateway values are rejected without touching + /// the persisted state — a tampered write cannot arm egress. + #[test] + #[serial] + fn c2_setters_reject_invalid_values() { + let _tmp = setup_isolated_data_dir(); + let mut settings = UserSettings::default(); + + settings.set_string("CODESCRIBE_ASR_MODE", "whisper_cloud"); + assert_eq!(settings.asr_mode, None, "unknown mode must be rejected"); + + settings.set_string("CODESCRIBE_CLOUD_CONSENT", "yes"); + assert_eq!( + settings.cloud_consent, None, + "non-canonical consent must be rejected" + ); + assert_eq!(settings.cloud_consent_at, None); + + settings.set_string( + "CODESCRIBE_ASR_GATEWAY_URL", + "https://user:sk-key@gateway.libraxis.cloud/mint", + ); + assert_eq!( + settings.asr_gateway_url, None, + "credential-bearing URL must be rejected" + ); + + // Empty clears an existing consent record back to "never asked". + settings.set_string("CODESCRIBE_CLOUD_CONSENT", "denied"); + assert_eq!(settings.cloud_consent.as_deref(), Some("denied")); + settings.set_string("CODESCRIBE_CLOUD_CONSENT", ""); + assert_eq!(settings.cloud_consent, None); + assert_eq!(settings.cloud_consent_at, None); + } + + /// Resolution truth on the settings snapshot: fresh installs land on + /// Apple-only, upgrades preserve the prior local/cloud choice, and cloud + /// without a granted record refuses egress without reaching for weights. + #[test] + fn c2_resolved_asr_mode_covers_fresh_upgrade_and_consent_paths() { + use crate::config::cloud_asr::{AsrProductMode, ModeDerivation}; + + let fresh = UserSettings::default(); + let resolved = fresh.resolved_asr_mode(); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::FreshDefault); + + let legacy_local = UserSettings { + use_local_stt: Some(true), + ..UserSettings::default() + }; + assert_eq!( + legacy_local.resolved_asr_mode().mode, + AsrProductMode::LocalPower + ); + + let legacy_cloud = UserSettings { + use_local_stt: Some(false), + ..UserSettings::default() + }; + let resolved = legacy_cloud.resolved_asr_mode(); + assert_eq!(resolved.mode, AsrProductMode::Cloud); + assert_eq!(resolved.derivation, ModeDerivation::LegacyCloudChoice); + + let cloud_no_consent = UserSettings { + asr_mode: Some("cloud".to_string()), + ..UserSettings::default() + }; + let resolved = cloud_no_consent.resolved_asr_mode(); + assert_eq!(resolved.mode, AsrProductMode::AppleOnly); + assert_eq!(resolved.derivation, ModeDerivation::ConsentMissingFallback); + } } diff --git a/core/config/storage_reset.rs b/core/config/storage_reset.rs new file mode 100644 index 00000000..d9b088b8 --- /dev/null +++ b/core/config/storage_reset.rs @@ -0,0 +1,490 @@ +//! Process-wide coordination between configuration I/O and destructive reset. +//! +//! A reset moves the live data roots to Trash and the Swift host relaunches the +//! process immediately afterwards. Without a fence, a background config load +//! can finish a migration after the move and silently recreate `settings.json` +//! in the supposedly empty live root. The gate below gives reset exclusive +//! ownership of config/settings/prompt persistence, drains operations that +//! already started, and permanently rejects new configuration I/O once the +//! first destructive move is armed. The latch is +//! intentionally process-lifetime state: after a destructive reset, relaunch is +//! the only supported way back to an open data plane. + +use std::cell::Cell; +use std::fmt; +use std::marker::PhantomData; +use std::path::Path; +use std::rc::Rc; +use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; + +/// State of the process-wide configuration persistence plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResetPhase { + Open, + Resetting, + Latched, +} + +/// Mutable state protected by [`gate`]. +#[derive(Debug)] +struct ResetState { + phase: ResetPhase, + active_operations: usize, + waiting_operations: usize, +} + +impl Default for ResetState { + fn default() -> Self { + Self { + phase: ResetPhase::Open, + active_operations: 0, + waiting_operations: 0, + } + } +} + +/// One mutex/condition-variable pair owns the whole process data plane. +fn gate() -> &'static (Mutex, Condvar) { + static GATE: OnceLock<(Mutex, Condvar)> = OnceLock::new(); + GATE.get_or_init(|| (Mutex::new(ResetState::default()), Condvar::new())) +} + +/// Recover from a poisoned lock: a panic in one caller must not disable reset +/// protection for the rest of the process. +fn lock_state() -> MutexGuard<'static, ResetState> { + match gate().0.lock() { + Ok(state) => state, + Err(poisoned) => { + let mut state = poisoned.into_inner(); + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + state + } + } +} + +/// Wait while preserving the same poison-recovery policy as [`lock_state`]. +fn wait_state(guard: MutexGuard<'static, ResetState>) -> MutexGuard<'static, ResetState> { + match gate().1.wait(guard) { + Ok(state) => state, + Err(poisoned) => { + let mut state = poisoned.into_inner(); + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + state + } + } +} + +thread_local! { + /// Nested config operations are one logical active operation. This matters + /// because `Config::load()` calls `UserSettings::load()`, and a reset may + /// begin between those two calls. Counting the nested call separately would + /// deadlock the loader against the reset waiting for its outer guard. + static APP_DATA_IO_DEPTH: Cell = const { Cell::new(0) }; +} + +/// Configuration I/O is unavailable because a destructive reset owns the process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppDataUnavailable { + reason: UnavailableReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnavailableReason { + Phase(ResetPhase), + ReentrantReset, +} + +impl fmt::Display for AppDataUnavailable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.reason { + UnavailableReason::ReentrantReset => { + formatter.write_str("cannot start app-data reset inside an active data operation") + } + UnavailableReason::Phase(phase) => match phase { + ResetPhase::Open => formatter.write_str("app-data I/O unavailable"), + ResetPhase::Resetting => formatter.write_str("app-data reset is in progress"), + ResetPhase::Latched => { + formatter.write_str("app-data reset completed; process relaunch required") + } + }, + } + } +} + +impl std::error::Error for AppDataUnavailable {} + +/// RAII admission for one configuration persistence operation. +pub(crate) struct AppDataIoGuard { + counted_globally: bool, + /// The nesting counter is thread-local, so moving this guard across threads + /// would decrement a different thread's depth and corrupt the reset fence. + _not_send: PhantomData>, +} + +/// Enter the data plane, waiting for a non-destructive reset preparation to +/// finish. Once reset is latched, callers fail instead of touching live roots. +pub(crate) fn begin_app_data_io() -> Result { + let nested = APP_DATA_IO_DEPTH.with(|depth| { + let current = depth.get(); + if current > 0 { + depth.set(current + 1); + true + } else { + false + } + }); + if nested { + return Ok(AppDataIoGuard { + counted_globally: false, + _not_send: PhantomData, + }); + } + + let mut state = lock_state(); + while state.phase == ResetPhase::Resetting { + state.waiting_operations += 1; + gate().1.notify_all(); + state = wait_state(state); + if state.waiting_operations == 0 { + state.phase = ResetPhase::Latched; + } else { + state.waiting_operations -= 1; + } + } + if state.phase == ResetPhase::Latched { + return Err(AppDataUnavailable { + reason: UnavailableReason::Phase(state.phase), + }); + } + + state.active_operations += 1; + APP_DATA_IO_DEPTH.with(|depth| depth.set(1)); + Ok(AppDataIoGuard { + counted_globally: true, + _not_send: PhantomData, + }) +} + +impl Drop for AppDataIoGuard { + fn drop(&mut self) { + let (remaining_depth, depth_underflow) = APP_DATA_IO_DEPTH.with(|depth| { + let current = depth.get(); + let remaining = if current == 0 { 0 } else { current - 1 }; + depth.set(remaining); + (remaining, current == 0) + }); + + if depth_underflow { + let mut state = lock_state(); + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + return; + } + + if !self.counted_globally { + return; + } + let mut state = lock_state(); + if remaining_depth != 0 || state.active_operations == 0 { + // A broken RAII/nesting invariant must fail closed. Decrementing + // anyway could let reset move a root beneath a still-live writer. + state.phase = ResetPhase::Latched; + gate().1.notify_all(); + return; + } + state.active_operations -= 1; + if state.active_operations == 0 { + gate().1.notify_all(); + } + } +} + +/// Exclusive ownership of the app-data plane during a reset. +/// +/// Dropping this guard before [`Self::mark_destructive_started`] reopens the +/// plane. Dropping it afterwards deliberately leaves the process latched. +pub struct AppDataResetGuard { + destructive_started: bool, +} + +/// Stop new app-data operations and wait until every already-admitted operation +/// has finished. A second reset is rejected instead of sharing ownership. +pub fn begin_app_data_reset() -> Result { + if APP_DATA_IO_DEPTH.with(|depth| depth.get() > 0) { + return Err(AppDataUnavailable { + reason: UnavailableReason::ReentrantReset, + }); + } + let mut state = lock_state(); + if state.phase != ResetPhase::Open { + return Err(AppDataUnavailable { + reason: UnavailableReason::Phase(state.phase), + }); + } + state.phase = ResetPhase::Resetting; + gate().1.notify_all(); + while state.active_operations > 0 { + state = wait_state(state); + } + Ok(AppDataResetGuard { + destructive_started: false, + }) +} + +impl AppDataResetGuard { + /// Whether any irreversible move/remove has happened. Callers must relaunch + /// even when later cleanup reports an error, because this process may no + /// longer resume normal app-data I/O. + pub fn relaunch_required(&self) -> bool { + self.destructive_started + } + + /// Try one atomic destructive filesystem operation. When the operation is + /// the first one in this reset, an error proves no atomic move occurred, so + /// the guard returns to `Resetting` and may safely perform a copy fallback. + /// Once any earlier destructive operation succeeded, the latch is permanent. + #[doc(hidden)] + pub fn rename_destructively( + &mut self, + source: &Path, + destination: &Path, + ) -> std::io::Result<()> { + if self.destructive_started { + return std::fs::rename(source, destination); + } + + // Keep the state lock across this one atomic syscall. New admissions + // therefore observe either Resetting after a failed rename or Latched + // after a successful one, never the speculative state in between. + let mut state = lock_state(); + debug_assert_eq!(state.phase, ResetPhase::Resetting); + state.phase = ResetPhase::Latched; + match std::fs::rename(source, destination) { + Ok(()) => { + self.destructive_started = true; + gate().1.notify_all(); + Ok(()) + } + Err(error) => { + state.phase = ResetPhase::Resetting; + Err(error) + } + } + } + + /// Arm the process-lifetime latch immediately before the first move/remove. + /// Waiting operations wake and fail without ever reaching the live roots. + pub fn mark_destructive_started(&mut self) { + if self.destructive_started { + return; + } + let mut state = lock_state(); + debug_assert_eq!(state.phase, ResetPhase::Resetting); + debug_assert_eq!(state.active_operations, 0); + state.phase = ResetPhase::Latched; + self.destructive_started = true; + gate().1.notify_all(); + } + + /// Only the reset owner may restore explicitly preserved bytes after the + /// live root has moved. Normal writers remain fenced out. + pub(crate) fn permits_preserved_restore(&self) -> bool { + self.destructive_started + } +} + +impl Drop for AppDataResetGuard { + fn drop(&mut self) { + if self.destructive_started { + return; + } + let mut state = lock_state(); + if state.phase == ResetPhase::Resetting { + state.phase = ResetPhase::Open; + gate().1.notify_all(); + } + } +} + +#[cfg(test)] +fn wait_for_blocked_io_for_tests() { + let mut state = lock_state(); + while state.waiting_operations == 0 { + state = wait_state(state); + } +} + +#[cfg(test)] +fn wait_for_resetting_for_tests() { + let mut state = lock_state(); + while state.phase != ResetPhase::Resetting { + state = wait_state(state); + } +} + +#[cfg(test)] +fn reopen_after_test() { + let mut state = lock_state(); + state.phase = ResetPhase::Open; + gate().1.notify_all(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, UserSettings}; + use serial_test::serial; + use std::ffi::OsString; + use std::fs; + use std::sync::mpsc; + use std::thread; + use tempfile::TempDir; + + /// Restore the process env only after the reset latch has held every other + /// config caller. Then reopen the gate so the rest of the test process sees + /// the restored root, never this test's disappearing temp directory. + struct TestResetCleanup { + previous_data_dir: Option, + } + + impl TestResetCleanup { + fn install(data_dir: &std::path::Path) -> Self { + let previous_data_dir = std::env::var_os("CODESCRIBE_DATA_DIR"); + // SAFETY: this regression owns the serial config/reset lane. + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", data_dir) }; + Self { previous_data_dir } + } + } + + impl Drop for TestResetCleanup { + fn drop(&mut self) { + // SAFETY: restore the exact process environment before waking any + // config caller that was held behind this test's reset latch. + unsafe { + match &self.previous_data_dir { + Some(value) => std::env::set_var("CODESCRIBE_DATA_DIR", value), + None => std::env::remove_var("CODESCRIBE_DATA_DIR"), + } + } + reopen_after_test(); + } + } + + /// Deterministic reproduction of the I4E-F audit RED: a config load reaches + /// the reset fence while the legacy settings root is being moved. It must + /// return without migrating or recreating anything in the live root. + #[test] + #[serial] + fn reset_fence_prevents_concurrent_config_migration_from_recreating_live_root() { + const CHILD_FLAG: &str = "CODESCRIBE_TEST_RESET_FENCE_CHILD"; + const CHILD_WITNESS: &str = "CODESCRIBE_TEST_RESET_FENCE_WITNESS"; + const WITNESS_BYTES: &[u8] = b"reset-fence-pass"; + if std::env::var_os(CHILD_FLAG).is_none() { + let witness_dir = TempDir::new().expect("child witness dir"); + let witness = witness_dir.path().join("passed"); + let current_test = concat!( + "config::storage_reset::tests::", + "reset_fence_prevents_concurrent_config_migration_from_recreating_live_root" + ); + let status = std::process::Command::new( + std::env::current_exe().expect("current core test executable"), + ) + .args(["--exact", current_test, "--nocapture"]) + .env(CHILD_FLAG, "1") + .env(CHILD_WITNESS, &witness) + .status() + .expect("spawn isolated reset-fence regression"); + assert!(status.success(), "isolated reset-fence regression failed"); + assert_eq!( + fs::read(witness).expect("child completed exact reset-fence test"), + WITNESS_BYTES, + "child command exited successfully without executing the exact regression" + ); + return; + } + + let sandbox = TempDir::new().expect("reset race sandbox"); + let live_root = sandbox.path().join("live"); + let trashed_root = sandbox.path().join("trashed"); + fs::create_dir_all(&live_root).expect("create live root"); + fs::write(live_root.join("settings.json"), b"{}").expect("seed legacy settings"); + let _cleanup = TestResetCleanup::install(&live_root); + + // Admit one real settings transaction first. Reset must wait for it, + // while a later Config load must queue behind Resetting. + let admitted = begin_app_data_io().expect("admit pre-existing config transaction"); + let moved_live_root = live_root.clone(); + let moved_trashed_root = trashed_root.clone(); + let (reset_acquired_tx, reset_acquired_rx) = mpsc::channel(); + let resetter = thread::spawn(move || { + let mut reset = begin_app_data_reset().expect("reset drains admitted writer"); + reset_acquired_tx + .send(()) + .expect("report exclusive reset ownership"); + reset + .rename_destructively(&moved_live_root, &moved_trashed_root) + .expect("move live root after admitted writer drains"); + }); + wait_for_resetting_for_tests(); + assert!( + matches!(reset_acquired_rx.try_recv(), Err(mpsc::TryRecvError::Empty)), + "reset crossed an already-admitted app-data transaction" + ); + + let late_writer = thread::spawn(Config::load_without_keychain); + wait_for_blocked_io_for_tests(); + + // This is the exact operation seen in the audit residue: a V1 load + // writes its backup and V3 replacement. It is allowed to finish because + // it entered before reset, and reset may move only after this guard ends. + let _ = UserSettings::load(); + assert!(live_root.join("settings.v1.bak.json").is_file()); + drop(admitted); + + reset_acquired_rx + .recv() + .expect("reset acquires after admitted writer finishes"); + resetter.join().expect("reset thread joins"); + let _ = late_writer.join().expect("late config load joins"); + + let blocked_save = UserSettings::default().save(); + assert!( + blocked_save.is_err(), + "settings save must fail after reset latch" + ); + assert!(!live_root.exists(), "writer recreated the reset live root"); + let migrated: serde_json::Value = serde_json::from_slice( + &fs::read(trashed_root.join("settings.json")).expect("read trashed settings"), + ) + .expect("parse migrated settings"); + assert_eq!( + migrated + .get("schema_version") + .and_then(|value| value.as_u64()), + Some(3) + ); + assert_eq!( + fs::read(trashed_root.join("settings.v1.bak.json")).expect("read trashed V1 backup"), + b"{}" + ); + let mut names: Vec<_> = fs::read_dir(&trashed_root) + .expect("read trashed root") + .map(|entry| entry.expect("trashed entry").file_name()) + .collect(); + names.sort(); + assert_eq!( + names, + [ + OsString::from("settings.json"), + OsString::from("settings.v1.bak.json") + ] + ); + fs::write( + std::env::var_os(CHILD_WITNESS).expect("child witness path"), + WITNESS_BYTES, + ) + .expect("write reset-fence child witness"); + } +} diff --git a/core/config/types.rs b/core/config/types.rs index aaecde7b..78336cb0 100644 --- a/core/config/types.rs +++ b/core/config/types.rs @@ -194,6 +194,17 @@ impl DeferredInsertShortcut { pub fn is_enabled(self) -> bool { !matches!(self, Self::Disabled) } + + /// Canonical wire identifier — the exact string [`FromStr`] round-trips, + /// used when persisting the chord into settings.json. + pub fn wire_id(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::CommandOptionV => "command_option_v", + Self::CommandShiftV => "command_shift_v", + Self::CommandControlV => "command_control_v", + } + } } impl FromStr for DeferredInsertShortcut { diff --git a/core/embedder/engine.rs b/core/embedder/engine.rs index f230314b..b58e4853 100644 --- a/core/embedder/engine.rs +++ b/core/embedder/engine.rs @@ -1,7 +1,7 @@ //! Embedder Engine - offline MiniLM embeddings via Candle BERT. //! -//! Provides text embeddings using a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model (fp16). -//! No runtime downloads; model must be embedded or present on disk. +//! Provides text embeddings using a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model. +//! No runtime downloads; model must be embedded, bundled in the app, or present in the HF cache. use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -366,7 +366,8 @@ impl EmbedderEngine { } /// Locate a model directory: explicit path, then `CODESCRIBE_EMBEDDER_PATH`, -/// then an HF cache snapshot for the configured or default repo. +/// then the signed app's runtime resource, then an HF cache snapshot for the +/// configured or default repo. /// /// Never downloads. Failure returns an error naming the exact commands and env /// vars that would fix it. @@ -382,6 +383,15 @@ fn resolve_model_path(explicit: Option<&PathBuf>, repo_override: Option<&str>) - } } + // A public app carries MiniLM as a normal signed resource rather than + // compiling 471 MB through every Cargo target. An explicit repo override + // still wins by bypassing this default-model resource. + if repo_override.is_none() + && let Some(bundled) = bundled_app_model_path() + { + return Ok(bundled); + } + if let Some(repo) = repo_override { if let Some(snapshot) = hf_cache::find_snapshot_with_any( repo, @@ -405,6 +415,20 @@ fn resolve_model_path(explicit: Option<&PathBuf>, repo_override: Option<&str>) - )) } +/// Resolve `Codescribe.app/Contents/Resources/models/embedder` without assuming +/// a fixed install location. CLI/test binaries naturally return `None` and fall +/// through to the HF cache. +fn bundled_app_model_path() -> Option { + let executable = std::env::current_exe().ok()?; + bundled_model_path_for_executable(&executable) +} + +fn bundled_model_path_for_executable(executable: &Path) -> Option { + let macos_dir = executable.parent()?; + let candidate = macos_dir.join("../Resources/models/embedder"); + model_files_present(&candidate).then(|| candidate.canonicalize().unwrap_or(candidate)) +} + /// Whether `path` holds a usable model: both config files plus safetensors or /// ONNX weights. /// @@ -593,4 +617,23 @@ mod tests { let sim = EmbedderEngine::similarity(&a, &b); assert!(sim.abs() < 0.001); } + + #[test] + fn resolves_signed_app_embedder_resource_from_executable_geometry() { + let temp = tempfile::tempdir().unwrap(); + let contents = temp.path().join("Codescribe.app/Contents"); + let executable = contents.join("MacOS/Codescribe"); + let model = contents.join("Resources/models/embedder"); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&model).unwrap(); + std::fs::write(&executable, b"").unwrap(); + for name in ["config.json", "tokenizer.json", "model.safetensors"] { + std::fs::write(model.join(name), b"fixture").unwrap(); + } + + assert_eq!( + bundled_model_path_for_executable(&executable), + Some(model.canonicalize().unwrap()) + ); + } } diff --git a/core/embedder/mod.rs b/core/embedder/mod.rs index fc0106e7..8e908185 100644 --- a/core/embedder/mod.rs +++ b/core/embedder/mod.rs @@ -1,7 +1,7 @@ //! Text Embedder module - semantic embeddings using MiniLM (offline). //! //! Provides semantic text embeddings for RAG, similarity search, and context matching. -//! Uses a local/embedded paraphrase-multilingual-MiniLM-L12-v2 model (no runtime downloads by default). +//! Uses a bundled, cached, or explicitly embedded paraphrase-multilingual-MiniLM-L12-v2 model (no runtime downloads). //! Override with `CODESCRIBE_EMBEDDER_REPO=sentence-transformers/...` (HF cache). //! //! ## Quick Start diff --git a/core/embedder/singleton.rs b/core/embedder/singleton.rs index 3794b5ec..2eb53c7d 100644 --- a/core/embedder/singleton.rs +++ b/core/embedder/singleton.rs @@ -185,6 +185,34 @@ pub fn init() -> Result<()> { with_embedder(|_| Ok(())) } +/// Load the engine off the caller's thread, ignoring the outcome. +/// +/// The semantic guard is the only consumer, and it runs *after* AI formatting +/// returns — so a cold engine put its whole load on the stop path, in series +/// behind the model call. Measured 2026-08-12: `semantic_guard took_ms=1127`, +/// of which ~1.0s was `Embedder initialized from embedded model`, against 0.13s +/// of actual comparison. +/// +/// Call this when a formatting request is dispatched, not at startup. The LLM +/// round-trip is seconds of dead time the load fits inside entirely, and by +/// scoping the warm to lanes that are about to need the engine anyway, this +/// buys latency without lengthening how long 471 MB of weights sit resident — +/// the idle-unload budget stays exactly as configured. +/// +/// Idempotent and non-blocking: concurrent callers serialize on the same slot +/// mutex the guard itself takes, and a failed load is left for the guard to +/// report through its normal fail-open path. +pub fn warm() { + std::thread::Builder::new() + .name("embedder-warm".into()) + .spawn(|| { + if let Err(error) = init() { + warn!("Embedder warm-up failed (semantic guard will retry): {error}"); + } + }) + .ok(); +} + /// Initialize with custom configuration. /// /// The config is captured for (re)loads; the first config wins. Idempotent. diff --git a/core/examples/format_chain_probe.rs b/core/examples/format_chain_probe.rs new file mode 100644 index 00000000..1ff0ee48 --- /dev/null +++ b/core/examples/format_chain_probe.rs @@ -0,0 +1,22 @@ +//! One-shot probe: push a raw transcript through the PRODUCTION formatting +//! path twice in one process, so the second call runs as a CHAINED turn +//! (previous_response_id present) — the exact condition of the 2026-08-14 +//! promptless-chain leak. Prints both outputs verbatim for a 1:1 exhibit. +//! +//! Usage: +//! cargo run -p codescribe-core --example format_chain_probe -- + +#[tokio::main] +async fn main() { + let path = std::env::args() + .nth(1) + .expect("usage: format_chain_probe "); + let raw = std::fs::read_to_string(&path).expect("read raw transcript"); + println!("=== RAW ({} chars) from {path}", raw.chars().count()); + + for turn in 1..=2 { + let out = codescribe_core::llm::ai_formatting::format_text(&raw, Some("pl"), false).await; + println!("\n=== TURN {turn} ({} chars) ===", out.chars().count()); + println!("{out}"); + } +} diff --git a/core/examples/vad_atlas_probe.rs b/core/examples/vad_atlas_probe.rs new file mode 100644 index 00000000..57ccf308 --- /dev/null +++ b/core/examples/vad_atlas_probe.rs @@ -0,0 +1,67 @@ +//! Evidence probe: the PRODUCTION Silero VAD spectrum over a take WAV. +//! +//! Same embedded `silero_vad.onnx`, same `VadConfig::default()`, same +//! resampler the engine uses — fed in canonical 512-sample (32 ms @ 16 kHz) +//! chunks. Emits one JSON with per-chunk speech probability plus the +//! waveform envelope (RMS / peak) on the same chunk grid, so word ranges +//! from a seal-atlas dump can be overlaid on the identical time axis. +//! +//! Usage: +//! cargo run -p codescribe-core --example vad_atlas_probe -- + +use codescribe_core::audio::{load_audio_file, resample_to_16k}; +use codescribe_core::vad::{AccumulatingVad, CHUNK_SIZE, VAD_SAMPLE_RATE}; + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let wav = args + .next() + .expect("usage: vad_atlas_probe "); + let out = args + .next() + .expect("usage: vad_atlas_probe "); + + let (samples, capture_rate) = load_audio_file(std::path::Path::new(&wav))?; + let capture_len = samples.len() as u64; + let mono16k = resample_to_16k(&samples, capture_rate); + + // 16 kHz input → AccumulatingVad never resamples again; each feed of one + // full chunk runs exactly one Silero inference, so probs[i] belongs to + // samples [i*512, (i+1)*512) on the 16 kHz axis. + let mut vad = AccumulatingVad::new(VAD_SAMPLE_RATE)?; + let threshold = vad.threshold(); + + let mut probs: Vec = Vec::with_capacity(mono16k.len() / CHUNK_SIZE + 1); + let mut rms: Vec = Vec::with_capacity(probs.capacity()); + let mut peak: Vec = Vec::with_capacity(probs.capacity()); + for chunk in mono16k.chunks(CHUNK_SIZE) { + if chunk.len() < CHUNK_SIZE { + break; // trailing partial chunk carries no full inference + } + probs.push(vad.feed(chunk)); + let sum_sq: f32 = chunk.iter().map(|s| s * s).sum(); + rms.push((sum_sq / chunk.len() as f32).sqrt()); + peak.push(chunk.iter().fold(0.0f32, |m, s| m.max(s.abs()))); + } + + let atlas = serde_json::json!({ + "source_wav": wav, + "capture_sample_rate": capture_rate, + "capture_samples": capture_len, + "vad_sample_rate": VAD_SAMPLE_RATE, + "chunk_samples": CHUNK_SIZE, + "threshold": threshold, + "chunks": probs.len(), + "probs": probs, + "rms": rms, + "peak": peak, + }); + std::fs::write(&out, serde_json::to_vec(&atlas)?)?; + eprintln!( + "vad_atlas_probe: {} chunks ({:.1}s) -> {}", + probs.len(), + probs.len() as f32 * CHUNK_SIZE as f32 / VAD_SAMPLE_RATE as f32, + out + ); + Ok(()) +} diff --git a/core/lib.rs b/core/lib.rs index e013616d..8f0aae03 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -38,6 +38,10 @@ /// Agent runtime: threads, sessions, capabilities, permissions, tool grants, /// and durable run monitoring. pub mod agent; +/// Neutral Layer 1 ASR session contract: typed events, monotonic ingest, the +/// provider seam, a live normalized gateway transport, and deterministic fakes. +/// No recorder wiring or local model. +pub mod asr_session; /// Attachment model and on-disk store used by agent chat and LLM context. pub mod attachment; /// Audio capture, loading, resampling, and playback primitives. @@ -172,5 +176,5 @@ pub use config::{get_assistive_prompt_path, get_formatting_prompt_path, reset_to pub use llm::{ai_formatting, client}; pub use pipeline::contracts; pub use pipeline::stream_postprocess; -pub use quality::{overlay_quality, qube_daemon, qube_report}; +pub use quality::{engine_contract, overlay_quality, qube_daemon, qube_report}; pub use util::{safe_path, status}; diff --git a/core/llm/account_auth/mod.rs b/core/llm/account_auth/mod.rs index b85e9562..d4a3d7a7 100644 --- a/core/llm/account_auth/mod.rs +++ b/core/llm/account_auth/mod.rs @@ -508,6 +508,87 @@ pub fn store_account_tokens( save_key(account, &payload).map_err(|error| AccountAuthError::Storage(error.to_string())) } +/// Optional official Responses URL for a *capability* probe. `None` ⇒ this +/// provider has no such probe. Sign-in must not call this: ChatGPT OAuth is +/// an identity row; Responses write is a separate lane/credential. Coupling +/// them (2026-08-14) made Codex public tokens unable to save a session. +fn responses_probe_endpoint(provider: ProviderKind) -> Option { + match provider { + // The env override exists for hermetic tests (and emergency ops): + // account tokens only ever ride to the official endpoint, so the + // probe defaults to the same place the runtime will send them. + ProviderKind::OpenAiResponses => Some( + std::env::var(RESPONSES_PROBE_URL_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| crate::config::DEFAULT_OPENAI_RESPONSES_ENDPOINT.to_string()), + ), + _ => None, + } +} + +/// Env override for the sign-in Responses probe target. Hermetic tests point +/// it at a mock; production leaves it unset (official endpoint). +pub const RESPONSES_PROBE_URL_ENV: &str = "CODESCRIBE_RESPONSES_PROBE_URL"; + +/// Classify whether a token can write the official Responses API. +/// +/// Not a sign-in gate. Persist is provider-local identity; this probe is a +/// later row/lane Test. Empty body: 400 = authorized, 401 = no write scope. +/// Transport errors stay fail-open. +pub async fn verify_responses_write_access( + provider: ProviderKind, + access_token: &str, +) -> Result<(), AccountAuthError> { + let Some(endpoint) = responses_probe_endpoint(provider) else { + return Ok(()); + }; + verify_responses_write_access_at(&endpoint, access_token).await +} + +/// Testable core of [`verify_responses_write_access`] with an explicit endpoint. +async fn verify_responses_write_access_at( + endpoint: &str, + access_token: &str, +) -> Result<(), AccountAuthError> { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + { + Ok(client) => client, + Err(error) => { + tracing::warn!("responses-write probe unavailable ({error}); keeping sign-in"); + return Ok(()); + } + }; + let response = match client + .post(endpoint) + .header("Authorization", format!("Bearer {access_token}")) + .header("Content-Type", "application/json") + .body("{}") + .send() + .await + { + Ok(response) => response, + Err(error) => { + tracing::warn!("responses-write probe did not complete ({error}); keeping sign-in"); + return Ok(()); + } + }; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + let body = response.text().await.unwrap_or_default(); + let detail: String = body.chars().take(300).collect(); + return Err(AccountAuthError::OAuth(format!( + "this account token cannot use the Responses API (HTTP 401 on the \ + authorization probe — missing scope `api.responses.write`?). \ + Sign-in was not saved; use an API key or an account with \ + Responses access. Provider answer: {detail}" + ))); + } + Ok(()) +} + /// Load a provider's stored tokens, or [`AccountAuthError::NotSignedIn`]. /// /// An env var named after the Keychain account is checked first: that is the @@ -715,6 +796,38 @@ mod tests { (EnvGuard::set_path("CODESCRIBE_DATA_DIR", dir.path()), dir) } + /// Capability probe only: 401 means no Responses write; 400 means the + /// token authorized. Login persist does not call this. + #[tokio::test] + async fn responses_probe_rejects_401_and_passes_validation_400() { + let mut starved_server = mockito::Server::new_async().await; + let starved = starved_server + .mock("POST", "/v1/responses") + .with_status(401) + .with_body(r#"{"error":"Missing scopes: api.responses.write"}"#) + .create_async() + .await; + let starved_url = format!("{}/v1/responses", starved_server.url()); + let error = verify_responses_write_access_at(&starved_url, "starved-token") + .await + .expect_err("401 classifies as no Responses write"); + assert!(error.to_string().contains("api.responses.write")); + starved.assert_async().await; + + let mut healthy_server = mockito::Server::new_async().await; + let healthy = healthy_server + .mock("POST", "/v1/responses") + .with_status(400) + .with_body(r#"{"error":"invalid input"}"#) + .create_async() + .await; + let healthy_url = format!("{}/v1/responses", healthy_server.url()); + verify_responses_write_access_at(&healthy_url, "healthy-token") + .await + .expect("400 validation answer proves the scope"); + healthy.assert_async().await; + } + /// Missing client id must surface the shared registration-gate message and /// the failing provider's own setting/env keys (not another provider's). /// Anthropic is the remaining no-default row; OpenAI and xAI ship public ids. diff --git a/core/llm/account_auth/server.rs b/core/llm/account_auth/server.rs index 7cac13b1..1a6b57fa 100644 --- a/core/llm/account_auth/server.rs +++ b/core/llm/account_auth/server.rs @@ -333,6 +333,10 @@ async fn process_request( .await { Ok(tokens) => { + // Persist identity for this vendor row only. A Responses-write + // probe against api.openai.com is a different provider/lane + // capability and must not veto ChatGPT sign-in (develop/main + // store here; Codex public tokens never carry api.responses.write). if let Err(error) = store_account_tokens(opts.provider, &tokens) { return HandledRequest::Response( Response::from_string(format!("Unable to persist account tokens: {error}")) @@ -649,6 +653,51 @@ mod tests { assert_eq!(stored.refresh_token.as_deref(), Some("account-refresh")); } + /// Provider independence: ChatGPT identity stores after a clean exchange. + /// Official Responses write is a different row/lane; login must not probe it + /// (Codex public tokens never carry `api.responses.write`). + #[tokio::test] + #[serial] + async fn chatgpt_identity_token_stores_without_responses_write() { + let _disable = EnvGuard::set("CODESCRIBE_DISABLE_KEYCHAIN", "1"); + let _tokens = EnvGuard::unset(OPENAI_ACCOUNT_TOKENS_ACCOUNT); + + let mut issuer = mockito::Server::new_async().await; + let _exchange = issuer + .mock("POST", "/oauth/token") + .with_status(200) + .with_body(r#"{"access_token":"scope-starved","expires_in":3600}"#) + .expect(1) + .create_async() + .await; + let probe = issuer + .mock("POST", "/v1/responses") + .expect(0) + .create_async() + .await; + + let mut opts = openai_opts("client"); + opts.issuer = issuer.url(); + opts.port = 0; + opts.force_state = Some("starved-state".to_string()); + let login = run_login_server(opts).await.expect("bind login server"); + + let callback = format!( + "http://127.0.0.1:{}/auth/callback?code=auth-code&state=starved-state", + login.actual_port + ); + let response = reqwest::get(&callback).await.expect("callback request"); + assert!(response.status().is_success()); + login + .block_until_done() + .await + .expect("identity login stores"); + let stored = + load_account_tokens(ProviderKind::OpenAiResponses).expect("identity token stored"); + assert_eq!(stored.access_token, "scope-starved"); + probe.assert_async().await; + } + /// A forged `state` must be rejected before any token exchange, and the /// pending login stays cancellable (cancel ⇒ honest "not completed" error). #[tokio::test] diff --git a/core/llm/ai_formatting.rs b/core/llm/ai_formatting.rs index 39beee81..f92fb128 100644 --- a/core/llm/ai_formatting.rs +++ b/core/llm/ai_formatting.rs @@ -235,6 +235,17 @@ fn duration_from_env_ms(key: &str, default_ms: u64) -> Duration { /// from several transports. The listed shapes are deterministic — an empty /// completion, a refusal, or a rejected request will reproduce identically on /// retry, so retrying only multiplies latency. +/// A chain id the requesting key cannot see (`previous_response_not_found`). +/// Measured mechanism (2026-08-12 22:31→23:02): the id was minted under the +/// OLD key, the operator swapped Keychain keys at 22:47–22:51, and the new +/// key's org cannot read the old org's response — three identical formatting +/// failures, transcript delivered raw. NOT retention: the same-key chain was +/// proven alive hours later (2026-08-14, full recall of the 10:38 take). The +/// stored id is poison for THIS key, so drop it and go unchained. +fn is_stale_chain_error(error: &anyhow::Error) -> bool { + error.to_string().contains("previous_response_not_found") +} + fn should_retry_provider_error(error: &anyhow::Error) -> bool { let message = error.to_string(); !(message.contains("No text content in SSE stream") @@ -861,6 +872,52 @@ struct ResponsesRequest { stream: bool, } +/// Instructions for a Responses request: the `instructions` PARAM goes only on +/// the first turn of a chain — endpoints reject the pair with +/// `previous_response_id` (HTTP 400 "instructions and previous_response_id +/// together"). +/// +/// But instructions are NOT preserved server-side across chained turns +/// (OpenAI Responses: "instructions … not carried over to the next response +/// when using previous_response_id"), so a chained turn MUST re-carry the +/// system prompt inside `input` — see [`build_responses_input`]. Dropping it +/// entirely left the formatter promptless mid-chain and the model answered as +/// a chat assistant instead of transforming (2026-08-14 leak: "Jasne — oto to +/// samo, przepisane czytelnie…" delivered as the formatted transcript). +fn chained_instructions(system_prompt: &str, previous_response_id: Option<&str>) -> Option { + if previous_response_id.is_some() { + None + } else { + Some(system_prompt.to_string()) + } +} + +/// Build the `input` items for a Responses request. On chained turns the +/// system prompt rides as a leading `developer` item, because the +/// `instructions` param is absent there (see [`chained_instructions`]) and the +/// chain does not carry it server-side. First turns carry the prompt via +/// `instructions` only — no duplicate developer item. +fn build_responses_input( + system_prompt: &str, + previous_response_id: Option<&str>, + user_content: Vec, +) -> Vec { + let mut input = Vec::with_capacity(2); + if previous_response_id.is_some() { + input.push(InputItem { + role: "developer", + content: vec![InputContent::Text { + text: system_prompt.to_string(), + }], + }); + } + input.push(InputItem { + role: "user", + content: user_content, + }); + input +} + /// Input item for Responses API #[derive(Debug, Serialize)] struct InputItem { @@ -1496,7 +1553,21 @@ async fn format_text_with_status_channels_for_policy( retry_policy.inter_chunk_timeout ); - for attempt in 0..=max_retries { + // Mode key for the conversation chain this call rides on — needed by the + // stale-chain self-heal below to reset the RIGHT stream (modes have + // separate chains and separate key slots). + let ai_mode = if assistive { + crate::state::conversation::AiMode::Assistive + } else { + crate::state::conversation::AiMode::Formatting + }; + // One-shot chain self-heal: a stale stored response_id re-runs the SAME + // attempt unchained instead of consuming the retry budget (the budget is + // often 0, and a poisoned chain would otherwise hard-fail every take + // until restart). + let mut stale_chain_retry_used = false; + let mut attempt = 0; + while attempt <= max_retries { info!( "AI formatting attempt {} (assistive={}, input_len={})", attempt + 1, @@ -1601,6 +1672,14 @@ async fn format_text_with_status_channels_for_policy( max_retries + 1, e ); + if !stale_chain_retry_used && is_stale_chain_error(&e) { + warn!( + "stale conversation chain: dropping stored response_id and retrying unchained" + ); + crate::state::conversation::reset_conversation_for_mode(ai_mode); + stale_chain_retry_used = true; + continue; + } None } } @@ -1633,6 +1712,14 @@ async fn format_text_with_status_channels_for_policy( max_retries + 1, e ); + if !stale_chain_retry_used && is_stale_chain_error(&e) { + warn!( + "stale conversation chain: dropping stored response_id and retrying unchained" + ); + crate::state::conversation::reset_conversation_for_mode(ai_mode); + stale_chain_retry_used = true; + continue; + } None } Err(_) => { @@ -1705,6 +1792,7 @@ async fn format_text_with_status_channels_for_policy( if should_retry { if attempt < max_retries { warn!("Triggering retry..."); + attempt += 1; continue; } else { warn!("Max retries reached, accepting output."); @@ -1738,6 +1826,7 @@ async fn format_text_with_status_channels_for_policy( warn!("Provider returned deterministic empty-content error; skipping retries"); break; } + attempt += 1; } // All providers failed @@ -1981,13 +2070,14 @@ async fn call_llm_endpoint( // Build Responses API request (no token limit - let API decide) let request = ResponsesRequest { model, - input: vec![InputItem { - role: "user", - content: build_responses_user_content(user_message), - }], + input: build_responses_input( + system_prompt, + previous_response_id.as_deref(), + build_responses_user_content(user_message), + ), + // Param on the first turn only; chained turns carry the prompt in input. + instructions: chained_instructions(system_prompt, previous_response_id.as_deref()), previous_response_id: previous_response_id.clone(), - // Only send instructions on first request - Responses API preserves them via previous_response_id - instructions: Some(system_prompt.to_string()), max_output_tokens: None, temperature, stream: false, @@ -2030,6 +2120,96 @@ async fn call_llm_endpoint( Ok(output) } +/// One chained Responses request over the formatting lane, chain owned by the +/// caller (W13-1 inline-format buffer). +/// +/// Deliberately does NOT touch [`crate::state::conversation`]: the inline +/// buffer keeps its own `previous_response_id` per dictation session, so chunk +/// chaining can reset per session without disturbing the persistent +/// formatting-mode conversation. Returns `(assistant_text, response_id)`. +pub(crate) async fn format_inline_chunk( + chunk_text: &str, + language: Option<&str>, + previous_response_id: Option, + system_prompt: &str, +) -> Result<(String, Option)> { + let endpoint = get_formatting_endpoint()?; + let model = get_formatting_model()?; + let api_key = get_formatting_api_key()?; + format_inline_chunk_resolved( + chunk_text, + language, + previous_response_id, + system_prompt, + &endpoint, + &model, + &api_key, + ) + .await +} + +/// Send one inline-chunk Responses request against explicitly supplied wire +/// values. Resolution is split out (mirroring +/// [`call_anthropic_messages_resolved`]) so the delivery harness can exercise +/// the wire contract against a mock without config in play. +pub(crate) async fn format_inline_chunk_resolved( + chunk_text: &str, + language: Option<&str>, + previous_response_id: Option, + system_prompt: &str, + endpoint: &str, + model: &str, + api_key: &str, +) -> Result<(String, Option)> { + let user_message = match language { + Some(lang) => format!("[Language: {lang}]\n\n{chunk_text}"), + None => chunk_text.to_string(), + }; + let request = ResponsesRequest { + model: model.to_string(), + input: build_responses_input( + system_prompt, + previous_response_id.as_deref(), + vec![InputContent::Text { text: user_message }], + ), + // Param on the first turn only; chained turns carry the prompt in input. + instructions: chained_instructions(system_prompt, previous_response_id.as_deref()), + previous_response_id, + max_output_tokens: None, + temperature: get_temperature(false), + stream: false, + }; + + let response = get_client() + .post(endpoint) + .header("Authorization", format!("Bearer {api_key}")) + .header("x-api-key", api_key) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .context("Inline chunk request failed")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Inline chunk HTTP {} - {}", status, body); + } + + let responses_result: ResponsesResponse = response + .json() + .await + .context("Failed to parse inline chunk response")?; + let output = extract_output_channels(&responses_result.output); + if output.assistant_text.is_empty() { + anyhow::bail!( + "No text content in inline chunk response (id: {})", + responses_result.id + ); + } + Ok((output.assistant_text, Some(responses_result.id))) +} + /// Resolve assistive-lane auth: signed-in ChatGPT OAuth wins over a stored API key. /// Returns `(secret, bearer_only)` — OAuth tokens must not also go out as `x-api-key`. async fn resolve_assistive_auth(lane: &AssistiveLaneSnapshot) -> Result<(String, bool)> { @@ -2107,13 +2287,14 @@ async fn call_llm_endpoint_streaming( // No token limit - let API decide let request = ResponsesRequest { model, - input: vec![InputItem { - role: "user", - content: build_responses_user_content(user_message), - }], + input: build_responses_input( + system_prompt, + previous_response_id.as_deref(), + build_responses_user_content(user_message), + ), + // Param on the first turn only; chained turns carry the prompt in input. + instructions: chained_instructions(system_prompt, previous_response_id.as_deref()), previous_response_id: previous_response_id.clone(), - // Only send instructions on first request - Responses API preserves them via previous_response_id - instructions: Some(system_prompt.to_string()), max_output_tokens: None, temperature, stream: true, @@ -2306,6 +2487,74 @@ mod tests { /// Env flag set in the lane-truth child process so nested tests skip re-spawn. const LANE_TRUTH_TEST_CHILD: &str = "CODESCRIBE_LANE_TRUTH_TEST_CHILD"; + /// The stale-chain classifier keys on the provider's error code alone: + /// `previous_response_not_found` (id minted under a rotated-away key) is + /// self-healable; everything else is not a chain problem. + #[test] + fn stale_chain_classifier_matches_only_the_not_found_code() { + let stale = anyhow::anyhow!( + "HTTP 400 Bad Request - {{\"error\":{{\"code\":\"previous_response_not_found\"}}}}" + ); + assert!(is_stale_chain_error(&stale)); + let pair = anyhow::anyhow!("HTTP 400 - instructions and previous_response_id together"); + assert!(!is_stale_chain_error(&pair)); + let auth = anyhow::anyhow!("HTTP 401 Unauthorized - missing scopes"); + assert!(!is_stale_chain_error(&auth)); + } + + /// Regression for the field HTTP 400 ("instructions and + /// previous_response_id together", 2026-08-14): every chained Responses + /// request must drop the `instructions` PARAM. First turn keeps it. + #[test] + fn responses_chain_never_carries_instructions_with_previous_id() { + assert_eq!(chained_instructions("SYS", None).as_deref(), Some("SYS")); + assert_eq!(chained_instructions("SYS", Some("resp_123")), None); + + // Wire proof: the chained request serializes without an + // `instructions` key at all (serde skips the None). + let request = ResponsesRequest { + model: "m".into(), + input: vec![], + instructions: chained_instructions("SYS", Some("resp_123")), + previous_response_id: Some("resp_123".into()), + max_output_tokens: None, + temperature: None, + stream: false, + }; + let wire = serde_json::to_value(&request).expect("serialize"); + assert!(wire.get("instructions").is_none()); + assert_eq!(wire["previous_response_id"], "resp_123"); + } + + /// Regression for the promptless-chain leak (2026-08-14, build 661): + /// dropping `instructions` on a chained turn left the formatter with NO + /// system prompt — the chain does NOT preserve instructions server-side — + /// and the model replied as a chat assistant ("Jasne — oto to samo, + /// przepisane czytelnie…") which was delivered as the formatted + /// transcript. A chained turn must re-carry the prompt as a leading + /// developer input item; a first turn must NOT duplicate it there. + #[test] + fn chained_turn_recarries_system_prompt_as_developer_input() { + let user = vec![InputContent::Text { text: "RAW".into() }]; + let chained = build_responses_input("SYS", Some("resp_123"), user); + assert_eq!(chained.len(), 2); + assert_eq!(chained[0].role, "developer"); + match &chained[0].content[0] { + InputContent::Text { text } => assert_eq!(text, "SYS"), + other => panic!("developer item must be text, got {other:?}"), + } + assert_eq!(chained[1].role, "user"); + + let first = + build_responses_input("SYS", None, vec![InputContent::Text { text: "RAW".into() }]); + assert_eq!( + first.len(), + 1, + "first turn carries the prompt via the instructions param only" + ); + assert_eq!(first[0].role, "user"); + } + /// RAII holder that restores one env var to its prior value on drop. /// /// Captures the previous value rather than assuming the variable was unset, diff --git a/core/llm/client.rs b/core/llm/client.rs index ab42f15e..49171bf5 100644 --- a/core/llm/client.rs +++ b/core/llm/client.rs @@ -113,6 +113,8 @@ struct WsConfig { msg_type: &'static str, language: String, api_key: String, + /// Codescribe domain token. Hosts that do not accept it are not this path. + vocabulary: &'static str, } /// WebSocket end signal (sent after audio) @@ -393,7 +395,11 @@ async fn transcribe_external( // WebSocket Streaming STT // ============================================================================ -/// Transcribe audio via WebSocket streaming +/// LEGACY STOP/RECOVERY ONLY: upload one completed audio file over WebSocket. +/// +/// This is not the live Layer 1 session transport. Do not add microphone frame +/// streaming, normalized session state, or live adjudication here; those belong +/// to `crate::asr_session::cloud` behind `AsrSessionProvider`. /// /// Protocol: /// 1. Connect to WebSocket @@ -425,11 +431,12 @@ async fn transcribe_websocket( response.status() ); - // 1. Send config + // 1. Send config. Topic is the product domain, never classified from audio. let config = WsConfig { msg_type: "config", language: language.to_string(), api_key: api_key.to_string(), + vocabulary: crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, }; ws.send(Message::Text(serde_json::to_string(&config)?.into())) .await @@ -598,6 +605,7 @@ async fn transcribe_ndjson( "sample_rate": sample_rate, "encoding": "pcm16", "language": language, + "request_vocabulary": crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, "last": true }); @@ -726,11 +734,13 @@ async fn transcribe_multipart( filename: &str, ) -> Result { let start = Instant::now(); + let vocabulary = crate::stt::request_vocabulary::codescribe_stt_vocabulary(url); info!( - "[Multipart STT] POST {} ({} bytes, lang={})", + "[Multipart STT] POST {} ({} bytes, lang={}, vocabulary={})", url, audio_data.len(), - language + language, + vocabulary.unwrap_or("off") ); // Retry loop @@ -747,10 +757,15 @@ async fn transcribe_multipart( let whisper_model = std::env::var("WHISPER_MODEL") .unwrap_or_else(|_| "mlx-community/whisper-large-v3-mlx".to_string()); - let form = Form::new() + let mut form = Form::new() .part("file", file_part) .text("model", whisper_model.clone()) .text("language", language.to_string()); + if let Some((field, value)) = + crate::stt::request_vocabulary::codescribe_stt_vocabulary_form_part(url) + { + form = form.text(field, value.to_string()); + } debug!( "[Multipart STT] attempt {}/{} for {}", @@ -808,9 +823,13 @@ async fn transcribe_multipart( /// Send a single multipart STT transcription request (used by retry loop) async fn transcribe_multipart_request(url: &str, api_key: &str, form: Form) -> Result { - let response = get_client() - .post(url) - .header("x-api-key", api_key) + let request = get_client().post(url); + let request = match crate::stt::tail_provider::stt_auth_mode(url) { + crate::stt::tail_provider::SttAuthMode::Unauthenticated => request, + crate::stt::tail_provider::SttAuthMode::Bearer => request.bearer_auth(api_key), + crate::stt::tail_provider::SttAuthMode::ApiKey => request.header("x-api-key", api_key), + }; + let response = request .multipart(form) .send() .await @@ -853,6 +872,20 @@ mod tests { format!("{}{}", WS_SCHEME_PREFIX, authority) } + #[test] + fn ws_config_names_programming_domain() { + let encoded = serde_json::to_value(&WsConfig { + msg_type: "config", + language: "pl".to_string(), + api_key: "unused".to_string(), + vocabulary: crate::stt::request_vocabulary::CODESCRIBE_STT_VOCABULARY, + }) + .expect("serialize ws config"); + assert_eq!(encoded["type"], "config"); + assert_eq!(encoded["vocabulary"], "programming"); + assert_ne!(encoded["vocabulary"], "veterinary"); + } + /// Plain WebSocket only on loopback; non-loopback rejected; the secure scheme always ok. #[test] fn ws_plain_rejected_for_non_loopback() { diff --git a/core/llm/inline_format.rs b/core/llm/inline_format.rs new file mode 100644 index 00000000..7b827a65 --- /dev/null +++ b/core/llm/inline_format.rs @@ -0,0 +1,1118 @@ +//! Inline-format buffer (W13-1, "Backspace Magic"). +//! +//! Formats dictated text with the formatting LLM **while dictation is still +//! running**, one sealed utterance chunk at a time, chained through the +//! Responses API `previous_response_id` so consecutive chunks keep style and +//! context without resending the transcript. At stop, the already-formatted +//! prefix is composed with a single final request that formats **only the +//! unformatted tail** and closes the text coherently — instead of paying the +//! measured 8.6–13.8 s full-text format on the stop path. +//! +//! Doctrine constraints carried here: +//! - **Feature-flagged, default OFF** (`CODESCRIBE_INLINE_FORMAT=1` to arm). +//! - **Fail-open per chunk**: an LLM error/timeout keeps the raw chunk text and +//! logs a receipt; the session is never blocked. +//! - **Anti-invention guard**: a formatted chunk whose word-set materially +//! exceeds its input is rejected (raw kept + receipt). The formatter may +//! punctuate and case, never add words — a formatter that invents text was +//! observed live on 2026-08-12/13. +//! - **Seal = "format now" signal** (wave atlas amendment 2): sealed utterances +//! are byte-stable, so they are the natural chunk boundary; the chunk store +//! is keyed by the sealed span id. +//! +//! Receipts are stable INFO log lines (`inline_format_chunk`, +//! `inline_format_compose`, `inline_format_fallback`) following the +//! `stop_path_budget` convention. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use tokio::sync::{mpsc, oneshot}; +use tracing::{info, warn}; + +use super::ai_formatting::{self, AiFormatResult, AiFormatStatus}; + +/// Master switch. Unset/anything-else = OFF; the operator flips it (⛔). +pub const INLINE_FORMAT_ENV: &str = "CODESCRIBE_INLINE_FORMAT"; + +/// Per-chunk LLM budget; a chunk that misses it keeps its raw text. +const DEFAULT_CHUNK_TIMEOUT_MS: u64 = 10_000; +/// Stop-path wait for the worker to drain queued chunks before composing. +const DEFAULT_FLUSH_TIMEOUT_MS: u64 = 2_500; +/// Stop-path budget for the single tail-close request. +const DEFAULT_TAIL_TIMEOUT_MS: u64 = 15_000; +/// Chunks shorter than this are recorded raw without an LLM round-trip. +const MIN_CHUNK_CHARS: usize = 8; +/// Hard cap on chunks per session (runaway guard). +const MAX_CHUNKS_PER_SESSION: usize = 240; + +/// System prompt for a mid-dictation chunk. New prompt on purpose — the +/// final-pass formatter prompt is out of scope for this lane. +const INLINE_CHUNK_PROMPT: &str = "You format live dictation transcripts. Each user message is the next \ +consecutive chunk of one ongoing dictation session. Format ONLY the current \ +chunk: fix punctuation, capitalization, spacing, and obvious dictation \ +artifacts. Keep every word — never add, remove, translate, reorder, or invent \ +words. Never repeat or rewrite earlier chunks. Never answer questions or add \ +commentary. Keep the language of the input. Return only the formatted chunk."; + +/// System prompt for the stop-path tail: same contract plus the coherent close. +const INLINE_CLOSE_PROMPT: &str = "You format live dictation transcripts. This is the FINAL chunk of the \ +dictation session. Format it exactly like the previous chunks: fix \ +punctuation, capitalization, spacing, and obvious dictation artifacts. Keep \ +every word — never add, remove, translate, reorder, or invent words. Close \ +the text coherently: the last sentence must end with proper terminal \ +punctuation. Never repeat earlier chunks. Return only the formatted final \ +chunk."; + +/// Whether the inline-format buffer is armed for this process. +pub fn enabled() -> bool { + std::env::var(INLINE_FORMAT_ENV) + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "on")) + .unwrap_or(false) +} + +fn env_ms(key: &str, default_ms: u64) -> Duration { + Duration::from_millis( + std::env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(default_ms), + ) +} + +fn chunk_timeout() -> Duration { + env_ms( + "CODESCRIBE_INLINE_FORMAT_CHUNK_TIMEOUT_MS", + DEFAULT_CHUNK_TIMEOUT_MS, + ) +} + +fn flush_timeout() -> Duration { + env_ms( + "CODESCRIBE_INLINE_FORMAT_FLUSH_TIMEOUT_MS", + DEFAULT_FLUSH_TIMEOUT_MS, + ) +} + +fn tail_timeout() -> Duration { + env_ms( + "CODESCRIBE_INLINE_FORMAT_TAIL_TIMEOUT_MS", + DEFAULT_TAIL_TIMEOUT_MS, + ) +} + +// ── Session store ─────────────────────────────────────────────────────────── + +/// How one chunk's in-flight format attempt ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChunkStatus { + /// Enqueued or mid-request; composes as raw until resolved. + Pending, + /// LLM output accepted (guard passed, lexicon re-applied). + Applied, + /// LLM error or timeout — raw kept (fail-open). + Failed, + /// Guard rejected invented/dropped words — raw kept. + RejectedInvention, + /// Below the char floor — never sent. + Skipped, +} + +impl ChunkStatus { + fn label(self) -> &'static str { + match self { + ChunkStatus::Pending => "pending", + ChunkStatus::Applied => "applied", + ChunkStatus::Failed => "failed", + ChunkStatus::RejectedInvention => "rejected_invention", + ChunkStatus::Skipped => "skipped", + } + } +} + +/// One sealed-span chunk and its formatting outcome, keyed by the span id. +#[derive(Debug, Clone)] +pub(crate) struct ChunkRecord { + /// Sealed span / utterance id (identity within the session). + pub id: u64, + /// Sealed text exactly as fed (post-lexicon, byte-stable). + pub raw: String, + /// Accepted formatted text; `None` composes as raw. + pub formatted: Option, + pub status: ChunkStatus, +} + +impl ChunkRecord { + fn display_text(&self) -> &str { + self.formatted.as_deref().unwrap_or(&self.raw) + } +} + +#[derive(Debug, Default, Clone)] +struct SessionStore { + generation: u64, + language: Option, + chunks: Vec, + /// Responses chain id of the last accepted chunk; resets per session. + chain: Option, +} + +static STORE: OnceLock>> = OnceLock::new(); +static GENERATION: AtomicU64 = AtomicU64::new(0); +static SENDER: OnceLock> = OnceLock::new(); + +fn store() -> &'static Arc> { + STORE.get_or_init(|| Arc::new(Mutex::new(SessionStore::default()))) +} + +enum Cmd { + Begin { + generation: u64, + language: Option, + }, + Chunk { + generation: u64, + id: u64, + text: String, + }, + Flush { + ack: oneshot::Sender<()>, + }, +} + +// ── Live-session hooks ────────────────────────────────────────────────────── + +/// Arm the buffer for a new live session. Must run inside a tokio runtime +/// (spawns the sequential worker on first use); resets chunks and the chain. +/// No-op when the feature flag is off. +pub fn begin_session(language: Option<&str>) { + if !enabled() { + return; + } + let Ok(handle) = tokio::runtime::Handle::try_current() else { + warn!("inline_format_fallback reason=no_tokio_runtime (begin_session outside runtime)"); + return; + }; + let tx = SENDER.get_or_init(|| { + let (tx, rx) = mpsc::unbounded_channel(); + let shared = Arc::clone(store()); + handle.spawn(worker_loop(rx, shared)); + tx + }); + let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + if tx + .send(Cmd::Begin { + generation, + language: language.map(str::to_string), + }) + .is_err() + { + warn!("inline_format_fallback reason=worker_gone (begin_session send failed)"); + } +} + +/// Feed one sealed span. Sync + non-blocking (safe from the blocking seal +/// worker thread). No-op when disabled or when no session was begun. +pub fn on_chunk_sealed(id: u64, text: &str) { + if !enabled() { + return; + } + let Some(tx) = SENDER.get() else { + return; + }; + let generation = GENERATION.load(Ordering::SeqCst); + if generation == 0 { + return; + } + let _ = tx.send(Cmd::Chunk { + generation, + id, + text: text.to_string(), + }); +} + +// ── Worker ────────────────────────────────────────────────────────────────── + +async fn worker_loop(mut rx: mpsc::UnboundedReceiver, shared: Arc>) { + while let Some(cmd) = rx.recv().await { + match cmd { + Cmd::Begin { + generation, + language, + } => { + if let Ok(mut s) = shared.lock() { + *s = SessionStore { + generation, + language, + chunks: Vec::new(), + chain: None, + }; + } + info!("inline_format_session_begin generation={generation}"); + } + Cmd::Chunk { + generation, + id, + text, + } => { + process_chunk(&shared, generation, id, text).await; + } + Cmd::Flush { ack } => { + let _ = ack.send(()); + } + } + } +} + +async fn process_chunk(shared: &Arc>, generation: u64, id: u64, text: String) { + let trimmed = text.trim().to_string(); + let (idx, language, chain) = { + let Ok(mut s) = shared.lock() else { + return; + }; + if s.generation != generation + || trimmed.is_empty() + || s.chunks.len() >= MAX_CHUNKS_PER_SESSION + { + return; + } + let status = if trimmed.chars().count() < MIN_CHUNK_CHARS { + ChunkStatus::Skipped + } else { + ChunkStatus::Pending + }; + s.chunks.push(ChunkRecord { + id, + raw: trimmed.clone(), + formatted: None, + status, + }); + if status == ChunkStatus::Skipped { + return; + } + (s.chunks.len() - 1, s.language.clone(), s.chain.clone()) + }; + + let chained = chain.is_some(); + let started = Instant::now(); + let outcome = tokio::time::timeout( + chunk_timeout(), + ai_formatting::format_inline_chunk( + &trimmed, + language.as_deref(), + chain, + INLINE_CHUNK_PROMPT, + ), + ) + .await; + let latency_ms = started.elapsed().as_millis(); + + let (status, formatted, response_id) = match outcome { + Ok(Ok((raw_out, response_id))) => { + let cleaned = crate::stream_postprocess::apply_lexicon(raw_out.trim()); + if invention_guard_rejects(&trimmed, &cleaned) { + (ChunkStatus::RejectedInvention, None, None) + } else { + (ChunkStatus::Applied, Some(cleaned), response_id) + } + } + Ok(Err(error)) => { + warn!("inline format chunk request failed: {error:#}"); + (ChunkStatus::Failed, None, None) + } + Err(_) => (ChunkStatus::Failed, None, None), + }; + + let chars_in = trimmed.chars().count(); + let chars_out = formatted + .as_deref() + .map(|t| t.chars().count()) + .unwrap_or(chars_in); + if let Ok(mut s) = shared.lock() { + // The session may have been reset or consumed mid-request; only write + // back into the record this request was created for. + if s.generation == generation + && let Some(record) = s.chunks.get_mut(idx) + && record.id == id + { + record.status = status; + record.formatted = formatted; + if status == ChunkStatus::Applied + && let Some(rid) = response_id.filter(|r| !r.is_empty()) + { + s.chain = Some(rid); + } + } + } + info!( + "inline_format_chunk id={id} status={} latency_ms={latency_ms} chained={chained} chars_in={chars_in} chars_out={chars_out}", + status.label(), + ); +} + +// ── Anti-invention guard ──────────────────────────────────────────────────── + +fn normalize_token(token: &str) -> String { + token + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn normalized_words(text: &str) -> Vec { + text.split_whitespace() + .map(normalize_token) + .filter(|w| !w.is_empty()) + .collect() +} + +/// True when the formatted text materially exceeds (or guts) the input words. +/// +/// The formatter's licence is punctuation/casing/spacing — so the normalized +/// word multiset must stay essentially the same. Budget: 2 novel words or 10% +/// of the input, whichever is larger (absorbs digit↔word style flips without +/// admitting invented sentences). Losing more than half the words is equally +/// rejected: a truncated chunk silently drops the user's speech. +pub(crate) fn invention_guard_rejects(raw: &str, formatted: &str) -> bool { + let raw_words = normalized_words(raw); + if raw_words.is_empty() { + return false; + } + let formatted_words = normalized_words(formatted); + if formatted_words.len() * 2 < raw_words.len() { + return true; + } + let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for w in &raw_words { + *counts.entry(w.as_str()).or_default() += 1; + } + let mut novel = 0usize; + for w in &formatted_words { + match counts.get_mut(w.as_str()) { + Some(c) if *c > 0 => *c -= 1, + _ => novel += 1, + } + } + let budget = (raw_words.len() / 10).max(2); + novel > budget +} + +// ── Stop-path composition ─────────────────────────────────────────────────── + +/// Outcome of matching the session's chunks against the delivered transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PrefixMatchOutcome { + /// Formatted (or raw, for failed chunks) prefix text, chunk-joined. + pub formatted_prefix: String, + /// Byte offset in the full text where the unmatched tail begins. + pub tail_start_byte: usize, + /// Chunks whose words matched the transcript prefix in order. + pub chunks_matched: usize, + /// Matched chunks that carry accepted LLM formatting. + pub formatted_matched: usize, +} + +/// Word starts (normalized token + byte offset) over the full text. +fn word_spans(text: &str) -> Vec<(String, usize)> { + let mut spans = Vec::new(); + let mut word_start: Option = None; + for (i, c) in text.char_indices() { + if c.is_whitespace() { + if let Some(start) = word_start.take() { + let token = normalize_token(&text[start..i]); + if !token.is_empty() { + spans.push((token, start)); + } + } + } else if word_start.is_none() { + word_start = Some(i); + } + } + if let Some(start) = word_start { + let token = normalize_token(&text[start..]); + if !token.is_empty() { + spans.push((token, start)); + } + } + spans +} + +/// Match chunks (in order) against the head of the delivered transcript. +/// +/// Comparison is word-based and punctuation/case-insensitive, so lexicon or +/// Light+ drift at chunk boundaries does not break the match. The first chunk +/// that fails to match stops the walk — everything after it (gap-appends, +/// diverged text) becomes the tail and is formatted fresh at stop. This is the +/// fail-open posture: a mismatch costs latency, never words. +pub(crate) fn match_chunks_against_text( + chunks: &[ChunkRecord], + full_text: &str, +) -> PrefixMatchOutcome { + let spans = word_spans(full_text); + let mut cursor = 0usize; + let mut chunks_matched = 0usize; + let mut formatted_matched = 0usize; + let mut prefix_parts: Vec = Vec::new(); + + for chunk in chunks { + let chunk_words = normalized_words(&chunk.raw); + if chunk_words.is_empty() { + chunks_matched += 1; + continue; + } + let end = cursor + chunk_words.len(); + if end > spans.len() { + break; + } + let matches = spans[cursor..end] + .iter() + .zip(chunk_words.iter()) + .all(|((span_word, _), chunk_word)| span_word == chunk_word); + if !matches { + break; + } + cursor = end; + chunks_matched += 1; + if chunk.formatted.is_some() { + formatted_matched += 1; + } + let display = chunk.display_text().trim(); + if !display.is_empty() { + prefix_parts.push(display.to_string()); + } + } + + let tail_start_byte = spans + .get(cursor) + .map(|(_, b)| *b) + .unwrap_or(full_text.len()); + PrefixMatchOutcome { + formatted_prefix: prefix_parts.join(" "), + tail_start_byte, + chunks_matched, + formatted_matched, + } +} + +/// Snapshot a session's chunks and consume them (one stop = one consumption; +/// a later non-live recording can never reuse a stale buffer). +fn snapshot_and_consume(shared: &Arc>) -> SessionStore { + let Ok(mut s) = shared.lock() else { + return SessionStore::default(); + }; + let snapshot = s.clone(); + s.chunks.clear(); + s.chain = None; + snapshot +} + +/// Stop-path entry point: compose formatted chunks + freshly formatted tail, +/// falling back to the classic full-text format whenever the buffer cannot +/// prove it covers the transcript. Drop-in replacement for +/// [`ai_formatting::format_text_with_status`] on the formatting lanes. +pub async fn format_text_with_inline_buffer(text: &str, language: Option<&str>) -> AiFormatResult { + if enabled() + && let Some(tx) = SENDER.get() + { + let started = Instant::now(); + if let Some(result) = compose_and_close_with(tx, store(), text, language).await { + info!( + "inline_format_stop total_ms={} composed_chars={}", + started.elapsed().as_millis(), + result.text.chars().count() + ); + return result; + } + } + ai_formatting::format_text_with_status(text, language, false, None).await +} + +/// Compose against an explicit worker channel + store. Split from the global +/// entry point so the delivery harness can drive a private worker without +/// touching (or being polluted by) process-global session state. +async fn compose_and_close_with( + tx: &mpsc::UnboundedSender, + shared: &Arc>, + full_text: &str, + language: Option<&str>, +) -> Option { + // Drain queued chunks so the freshest seal (often emitted during + // recorder stop) is formatted before we snapshot. Bounded: a stuck + // worker degrades to raw-tail composition, never to a blocked stop. + let flush_started = Instant::now(); + let (ack_tx, ack_rx) = oneshot::channel(); + let flushed = if tx.send(Cmd::Flush { ack: ack_tx }).is_ok() { + tokio::time::timeout(flush_timeout(), ack_rx).await.is_ok() + } else { + false + }; + let flush_wait_ms = flush_started.elapsed().as_millis(); + + let snapshot = snapshot_and_consume(shared); + if snapshot.chunks.is_empty() { + info!("inline_format_fallback reason=no_chunks flush_wait_ms={flush_wait_ms}"); + return None; + } + + let matched = match_chunks_against_text(&snapshot.chunks, full_text); + if matched.chunks_matched == 0 || matched.formatted_matched == 0 { + info!( + "inline_format_fallback reason=prefix_mismatch chunks={} matched={} formatted={} flush_wait_ms={flush_wait_ms}", + snapshot.chunks.len(), + matched.chunks_matched, + matched.formatted_matched + ); + return None; + } + + let tail_raw = full_text[matched.tail_start_byte..].trim(); + let tail_chars = tail_raw.chars().count(); + let (tail_text, tail_status) = if tail_raw.is_empty() { + (String::new(), "empty") + } else { + match tokio::time::timeout( + tail_timeout(), + ai_formatting::format_inline_chunk( + tail_raw, + language.or(snapshot.language.as_deref()), + snapshot.chain.clone(), + INLINE_CLOSE_PROMPT, + ), + ) + .await + { + Ok(Ok((raw_out, _response_id))) => { + let cleaned = crate::stream_postprocess::apply_lexicon(raw_out.trim()); + if invention_guard_rejects(tail_raw, &cleaned) { + (tail_raw.to_string(), "rejected_invention") + } else { + (cleaned, "applied") + } + } + Ok(Err(error)) => { + warn!("inline format tail request failed: {error:#}"); + (tail_raw.to_string(), "failed") + } + Err(_) => (tail_raw.to_string(), "timeout"), + } + }; + + let mut composed = matched.formatted_prefix.clone(); + if !tail_text.is_empty() { + if !composed.is_empty() { + composed.push(' '); + } + composed.push_str(&tail_text); + } + if composed.trim().is_empty() { + info!("inline_format_fallback reason=empty_composition flush_wait_ms={flush_wait_ms}"); + return None; + } + + info!( + "inline_format_compose chunks={} matched={} formatted={} tail_chars={tail_chars} tail_status={tail_status} flushed={flushed} flush_wait_ms={flush_wait_ms}", + snapshot.chunks.len(), + matched.chunks_matched, + matched.formatted_matched, + ); + + Some(AiFormatResult { + text: composed, + reasoning_text: None, + status: AiFormatStatus::Applied, + }) +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn record(id: u64, raw: &str, formatted: Option<&str>) -> ChunkRecord { + ChunkRecord { + id, + raw: raw.to_string(), + formatted: formatted.map(str::to_string), + status: if formatted.is_some() { + ChunkStatus::Applied + } else { + ChunkStatus::Failed + }, + } + } + + /// Punctuation and casing may change freely; the guard only counts words. + #[test] + fn guard_accepts_punctuation_and_casing_changes() { + assert!(!invention_guard_rejects( + "no dobra to jest test dyktowania w codescribe", + "No dobra, to jest test dyktowania w Codescribe." + )); + } + + /// A formatter that answers instead of formatting is rejected. + #[test] + fn guard_rejects_invented_content() { + assert!(invention_guard_rejects( + "kup mleko i chleb", + "Oczywiście! Oto sformatowana lista zakupów: kup mleko i chleb, a także masło." + )); + } + + /// A formatter that eats most of the chunk is rejected too. + #[test] + fn guard_rejects_heavy_truncation() { + assert!(invention_guard_rejects( + "pierwsza część zdania oraz druga część zdania oraz trzecia część zdania", + "pierwsza część." + )); + } + + /// Small novel-word drift (within budget) is tolerated. + #[test] + fn guard_tolerates_tiny_drift() { + assert!(!invention_guard_rejects( + "spotkanie jutro o ósmej rano w klinice", + "Spotkanie jutro o 8 rano w klinice." + )); + } + + /// Matched chunks compose the formatted prefix; the tail byte offset points + /// at the first unmatched word — including multibyte Polish input. + #[test] + fn matcher_matches_prefix_and_finds_tail() { + let chunks = vec![ + record( + 1, + "pierwsze zdanie o żółwiu", + Some("Pierwsze zdanie o żółwiu."), + ), + record(2, "drugie zdanie o jeżu", Some("Drugie zdanie o jeżu.")), + ]; + let full = "pierwsze zdanie o żółwiu drugie zdanie o jeżu i ogon który został"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 2); + assert_eq!(m.formatted_matched, 2); + assert_eq!( + m.formatted_prefix, + "Pierwsze zdanie o żółwiu. Drugie zdanie o jeżu." + ); + assert_eq!(&full[m.tail_start_byte..], "i ogon który został"); + } + + /// Canvas drift (gap-append between chunks) stops the walk at the last + /// provable chunk; the rest becomes tail. Words are never lost. + #[test] + fn matcher_partial_match_on_gap_append() { + let chunks = vec![ + record(1, "pierwsze zdanie", Some("Pierwsze zdanie.")), + record(2, "trzecie zdanie", Some("Trzecie zdanie.")), + ]; + let full = "Pierwsze zdanie wstawka z gap append trzecie zdanie"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 1); + assert_eq!(m.formatted_prefix, "Pierwsze zdanie."); + assert_eq!( + &full[m.tail_start_byte..], + "wstawka z gap append trzecie zdanie" + ); + } + + /// A failed chunk (no formatted text) still matches and composes raw — + /// fail-open never drops the chunk from the prefix. + #[test] + fn matcher_failed_chunk_composes_raw() { + let chunks = vec![ + record(1, "pierwsze zdanie", Some("Pierwsze zdanie.")), + record(2, "drugie zdanie", None), + ]; + let full = "pierwsze zdanie drugie zdanie ogon"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 2); + assert_eq!(m.formatted_matched, 1); + assert_eq!(m.formatted_prefix, "Pierwsze zdanie. drugie zdanie"); + assert_eq!(&full[m.tail_start_byte..], "ogon"); + } + + /// Zero matches → the caller must fall back to full-text formatting. + #[test] + fn matcher_no_match_yields_zero() { + let chunks = vec![record( + 1, + "zupełnie inny tekst", + Some("Zupełnie inny tekst."), + )]; + let full = "to nagranie nie ma nic wspólnego z buforem"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 0); + assert_eq!(m.tail_start_byte, 0); + } + + /// Fully covered transcript → empty tail (stop pays zero LLM requests). + #[test] + fn matcher_full_coverage_leaves_empty_tail() { + let chunks = vec![record(1, "całość wypowiedzi", Some("Całość wypowiedzi."))]; + let full = "całość wypowiedzi"; + let m = match_chunks_against_text(&chunks, full); + assert_eq!(m.chunks_matched, 1); + assert_eq!(full[m.tail_start_byte..].trim(), ""); + } + + /// Delivery-verifier seam harness: a private worker + mock Responses + /// provider drive the full chunk→chain→compose path without process-global + /// state, so parallel tests (or a concurrent live session) cannot pollute + /// the measurement. + mod seam { + use super::super::*; + use mockito::Matcher; + use serde_json::json; + use serial_test::serial; + + /// RAII env pin (mirrors `ai_formatting`'s test guard): captures the + /// prior value so an operator dotenv leaves the env as it found it. + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let prev = std::env::var(key).ok(); + unsafe { std::env::set_var(key, value) }; + Self { key, prev } + } + + fn remove(key: &'static str) -> Self { + let prev = std::env::var(key).ok(); + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.prev.as_deref() { + Some(value) => unsafe { std::env::set_var(self.key, value) }, + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + + fn pin_formatting_lane(server_url: &str) -> Vec { + vec![ + EnvGuard::set( + "LLM_FORMATTING_ENDPOINT", + &format!("{server_url}/v1/responses"), + ), + EnvGuard::set("LLM_FORMATTING_MODEL", "mock-nano"), + EnvGuard::set("LLM_FORMATTING_API_KEY", "mock-key"), + EnvGuard::remove("LLM_FORMATTING_TEMPERATURE"), + EnvGuard::remove("LLM_TEMPERATURE"), + // Pin the operation clocks to their defaults: the operator's + // dotenv injects into every test process, and the settle + // waiter's 30s backstop is calibrated against THESE numbers. + EnvGuard::set("CODESCRIBE_INLINE_FORMAT_CHUNK_TIMEOUT_MS", "10000"), + EnvGuard::set("CODESCRIBE_INLINE_FORMAT_FLUSH_TIMEOUT_MS", "2500"), + EnvGuard::set("CODESCRIBE_INLINE_FORMAT_TAIL_TIMEOUT_MS", "15000"), + ] + } + + fn responses_body(id: &str, text: &str) -> String { + json!({ + "id": id, + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": text}] + }] + }) + .to_string() + } + + struct Harness { + tx: mpsc::UnboundedSender, + shared: Arc>, + } + + fn spawn_private_worker(language: &str) -> Harness { + let shared = Arc::new(Mutex::new(SessionStore::default())); + let (tx, rx) = mpsc::unbounded_channel(); + let worker = tokio::spawn(worker_loop(rx, Arc::clone(&shared))); + // Surface a silent worker panic instead of an opaque settle timeout. + tokio::spawn(async move { + if let Err(join_error) = worker.await { + eprintln!("inline-format test worker died: {join_error:?}"); + } + }); + tx.send(Cmd::Begin { + generation: 1, + language: Some(language.to_string()), + }) + .expect("worker alive"); + Harness { tx, shared } + } + + // Backstop, not a claim: it must sit OUT OF REACH of the chunk + // request's own 10s budget, or the two clocks race under machine load + // (measured flake 2026-08-14: chunk still Pending at the waiter's + // 10s while its own timeout was about to settle it). + async fn wait_for_settled_chunks(shared: &Arc>, expected: usize) { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + { + let s = shared.lock().expect("store lock"); + if s.chunks.len() == expected + && s.chunks.iter().all(|c| c.status != ChunkStatus::Pending) + { + return; + } + } + assert!( + Instant::now() < deadline, + "chunks did not settle in flight within 30s: {:?}", + shared.lock().expect("store lock").chunks + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// The stop path pays exactly ONE provider round-trip — the tail close + /// — because both sealed chunks were formatted in flight and chained + /// via `previous_response_id`. This is the W13-1 delivery seam: with a + /// local mock provider the whole stop composition fits far inside the + /// <3 s budget; real-network cost is the single ~1–2 s nano tail call. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn stop_seam_pays_only_the_tail_request() { + let mut server = mockito::Server::new_async().await; + let _env = pin_formatting_lane(&server.url()); + + let chunk1 = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("consecutive chunk".into()), + Matcher::Regex("pierwsze zdanie o testowaniu bufora".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_1", + "Pierwsze zdanie o testowaniu bufora.", + )) + .expect(1) + .create_async() + .await; + let chunk2 = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("drugie zdanie o zamykaniu wypowiedzi".into()), + Matcher::Regex(r#""previous_response_id":"resp_1""#.into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_2", + "Drugie zdanie o zamykaniu wypowiedzi.", + )) + .expect(1) + .create_async() + .await; + let tail = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("FINAL chunk".into()), + Matcher::Regex(r#""previous_response_id":"resp_2""#.into()), + Matcher::Regex("ogon który nie został".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_3", + "Ogon, który nie został zapieczętowany.", + )) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 1, + text: "pierwsze zdanie o testowaniu bufora".into(), + }) + .unwrap(); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 2, + text: "drugie zdanie o zamykaniu wypowiedzi".into(), + }) + .unwrap(); + + // Both chunks format DURING dictation (before any stop call). + wait_for_settled_chunks(&h.shared, 2).await; + { + let s = h.shared.lock().unwrap(); + assert!( + s.chunks.iter().all(|c| c.status == ChunkStatus::Applied), + "both chunks must be accepted in flight: {:?}", + s.chunks + ); + assert_eq!( + s.chain.as_deref(), + Some("resp_2"), + "chain must advance to the last accepted chunk" + ); + } + + let full_text = "pierwsze zdanie o testowaniu bufora drugie zdanie o \ + zamykaniu wypowiedzi ogon który nie został zapieczętowany"; + let started = Instant::now(); + let result = compose_and_close_with(&h.tx, &h.shared, full_text, Some("pl")) + .await + .expect("compose must succeed when chunks cover the prefix"); + let stop_secs = started.elapsed().as_secs_f64(); + + assert_eq!(result.status, AiFormatStatus::Applied); + assert_eq!( + result.text, + "Pierwsze zdanie o testowaniu bufora. Drugie zdanie o zamykaniu \ + wypowiedzi. Ogon, który nie został zapieczętowany." + ); + chunk1.assert_async().await; + chunk2.assert_async().await; + tail.assert_async().await; + assert!( + stop_secs < 3.0, + "stop seam must fit the <3s budget with a local provider (measured {stop_secs:.3}s)" + ); + // Emit the measured number so the report can quote it. + eprintln!( + "inline_format_stop_seam_secs={stop_secs:.3} chunks_in_flight=2 stop_requests=1" + ); + } + + /// Fail-open per chunk: a provider 500 keeps the raw chunk text, the + /// chain stays on the last accepted id, and stop still composes — + /// the session is never blocked by a failed chunk. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn chunk_failure_is_fail_open_and_keeps_chain() { + let mut server = mockito::Server::new_async().await; + let _env = pin_formatting_lane(&server.url()); + + let chunk1 = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex("pierwszy kawalek dyktowania".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body("resp_1", "Pierwszy kawalek dyktowania.")) + .expect(1) + .create_async() + .await; + // One ordered regex over the CURRENT wire truth (5d62aacb): a + // chained request re-carries the closing prompt as a leading + // `developer` input item (instructions do NOT persist server-side + // across previous_response_id), and `input` serializes before + // `previous_response_id`. Proves this is the closing request AND + // that the chain still points at the last ACCEPTED chunk. + let tail = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex( + r#""role":"developer"[\s\S]*FINAL chunk[\s\S]*"previous_response_id":"resp_1""# + .into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body("resp_9", "Ogon po awarii.")) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 1, + text: "pierwszy kawalek dyktowania".into(), + }) + .unwrap(); + wait_for_settled_chunks(&h.shared, 1).await; + + // Real transport failure for chunk 2: the formatting lane briefly + // points at a closed port (connection refused — the same fail-open + // arm a dead provider takes in production). The guard's captured + // previous value restores the mock endpoint before the tail runs. + { + let _dead_lane = + EnvGuard::set("LLM_FORMATTING_ENDPOINT", "http://127.0.0.1:1/v1/responses"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 2, + text: "drugi kawalek ktory pada".into(), + }) + .unwrap(); + wait_for_settled_chunks(&h.shared, 2).await; + } + { + let s = h.shared.lock().unwrap(); + assert_eq!(s.chunks[0].status, ChunkStatus::Applied); + assert_eq!(s.chunks[1].status, ChunkStatus::Failed); + assert_eq!(s.chunks[1].formatted, None, "failed chunk keeps raw"); + assert_eq!(s.chain.as_deref(), Some("resp_1")); + } + + let full_text = "pierwszy kawalek dyktowania drugi kawalek ktory pada ogon po awarii"; + let result = compose_and_close_with(&h.tx, &h.shared, full_text, Some("pl")) + .await + .expect("fail-open compose must still succeed"); + + assert_eq!( + result.text, + "Pierwszy kawalek dyktowania. drugi kawalek ktory pada Ogon po awarii." + ); + chunk1.assert_async().await; + tail.assert_async().await; + } + + /// An anti-invention violation from the provider is rejected: the raw + /// chunk survives and the chain does not advance onto the poisoned id. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn invented_chunk_output_is_rejected_with_raw_kept() { + let mut server = mockito::Server::new_async().await; + let _env = pin_formatting_lane(&server.url()); + + let chunk = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex("kup mleko i chleb dla kliniki".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_bad", + "Oczywiście! Oto lista: kup mleko i chleb dla kliniki, a także \ + opatrunki, strzykawki i wszystko czego potrzebuje przychodnia.", + )) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl"); + h.tx.send(Cmd::Chunk { + generation: 1, + id: 1, + text: "kup mleko i chleb dla kliniki".into(), + }) + .unwrap(); + wait_for_settled_chunks(&h.shared, 1).await; + + { + let s = h.shared.lock().unwrap(); + assert_eq!(s.chunks[0].status, ChunkStatus::RejectedInvention); + assert_eq!(s.chunks[0].formatted, None, "invented text must not land"); + assert_eq!(s.chain, None, "chain must not advance onto a rejected id"); + } + chunk.assert_async().await; + } + } +} diff --git a/core/llm/key_liveness.rs b/core/llm/key_liveness.rs index 29061e47..3858d794 100644 --- a/core/llm/key_liveness.rs +++ b/core/llm/key_liveness.rs @@ -8,6 +8,7 @@ use std::time::Duration; use reqwest::StatusCode; +use reqwest::blocking::multipart::{Form, Part}; use reqwest::blocking::{Client, Response}; use serde_json::json; @@ -90,7 +91,16 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { } let config = Config::load(); - let Some(api_key) = lane_truth::secret(account) else { + let api_key = lane_truth::secret(account); + let stt_is_unauthenticated = account == "STT_API_KEY" + && config + .stt_endpoint + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(crate::stt::tail_provider::stt_auth_mode) + .unwrap_or(crate::stt::tail_provider::SttAuthMode::Unauthenticated) + == crate::stt::tail_provider::SttAuthMode::Unauthenticated; + let Some(api_key) = api_key.or_else(|| stt_is_unauthenticated.then(String::new)) else { return ApiKeyLivenessResult::new( account, ApiKeyLivenessStatus::Missing, @@ -98,14 +108,6 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { ); }; - if account == "STT_API_KEY" { - return ApiKeyLivenessResult::new( - account, - ApiKeyLivenessStatus::Unsupported, - "no cheap liveness probe is available for this STT key", - ); - } - let client = match Client::builder() .timeout(PROBE_TIMEOUT) .connect_timeout(PROBE_TIMEOUT) @@ -121,6 +123,10 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { } }; + if account == "STT_API_KEY" { + return probe_stt_key(&client, &config, account, &api_key); + } + if account == "GITHUB_TOKEN" { return probe_github_token(&client, account, &api_key); } @@ -154,6 +160,85 @@ pub fn probe_api_key_liveness(account: &str) -> ApiKeyLivenessResult { } } +/// Probe the configured multipart STT slot with 100 ms of synthetic silence. +/// The response body is never surfaced; only auth/quota/transport status is. +/// A live WebSocket URL is remapped to the file worker first — Test is not a +/// handshake against the Voice Lab socket. +fn probe_stt_key( + client: &Client, + config: &Config, + account: &str, + api_key: &str, +) -> ApiKeyLivenessResult { + let endpoint = crate::stt::tail_provider::file_probe_endpoint( + config + .stt_endpoint + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("http://127.0.0.1:8000/v1/audio/transcriptions"), + ); + if crate::stt::tail_provider::validate_remote_endpoint(&endpoint).is_err() { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + "configured STT endpoint is invalid or insecure", + ) + .with_probed_endpoint(endpoint); + } + let silence = [0.0_f32; 1_600]; + let wav = match crate::stt::tail_provider::pcm16_wav(&silence, 16_000) { + Ok(wav) => wav, + Err(_) => { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + "could not build the STT liveness probe", + ) + .with_probed_endpoint(endpoint); + } + }; + let file = match Part::bytes(wav) + .file_name("codescribe-key-probe.wav") + .mime_str("audio/wav") + { + Ok(file) => file, + Err(_) => { + return ApiKeyLivenessResult::new( + account, + ApiKeyLivenessStatus::Network, + "could not build the STT liveness probe", + ) + .with_probed_endpoint(endpoint); + } + }; + let mut form = Form::new() + .part("file", file) + .text("model", "whisper-1") + .text("language", "pl") + .text("response_format", "json"); + if let Some((field, value)) = + crate::stt::request_vocabulary::codescribe_stt_vocabulary_form_part(&endpoint) + { + form = form.text(field, value.to_string()); + } + let request = client.post(&endpoint); + let auth_mode = crate::stt::tail_provider::stt_auth_mode(&endpoint); + let request = match auth_mode { + crate::stt::tail_provider::SttAuthMode::Unauthenticated => request, + crate::stt::tail_provider::SttAuthMode::Bearer => request.bearer_auth(api_key), + crate::stt::tail_provider::SttAuthMode::ApiKey => request.header("x-api-key", api_key), + }; + let response = request.multipart(form).send(); + let mut result = response_result(account, endpoint, response); + if auth_mode == crate::stt::tail_provider::SttAuthMode::Unauthenticated + && result.status == ApiKeyLivenessStatus::Ok + { + result.message = "local STT endpoint accepts unauthenticated requests".to_string(); + } + result +} + /// Classify one provider HTTP response. This is the tested contract; network /// errors are classified at the request boundary because there is no HTTP status. pub fn classify_probe_response(status: StatusCode, body: &str) -> ApiKeyLivenessStatus { @@ -397,6 +482,96 @@ mod tests { ); } + /// The STT slot has a real multipart probe instead of the historical + /// Unsupported verdict, and reports the endpoint that answered. + #[test] + fn stt_probe_uses_the_multipart_endpoint() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind STT probe server"); + let address = listener.local_addr().expect("STT probe address"); + let endpoint = format!("http://{address}/v1/audio/transcriptions"); + let expected_endpoint = endpoint.clone(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept STT probe request"); + let mut buffer = [0_u8; 8192]; + let bytes_read = stream.read(&mut buffer).expect("read STT probe request"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"text\":\"\"}", + ) + .expect("write STT probe response"); + String::from_utf8_lossy(&buffer[..bytes_read]).to_string() + }); + let client = Client::builder() + .timeout(PROBE_TIMEOUT) + .connect_timeout(PROBE_TIMEOUT) + .build() + .expect("build STT probe client"); + let config = Config { + stt_endpoint: Some(endpoint), + ..Config::default() + }; + let result = probe_stt_key(&client, &config, "STT_API_KEY", "test-key"); + assert_eq!(result.status, ApiKeyLivenessStatus::Ok); + assert_eq!( + result.probed_endpoint.as_deref(), + Some(expected_endpoint.as_str()) + ); + let request = server.join().expect("STT probe server"); + assert!(request.starts_with("POST /v1/audio/transcriptions HTTP/1.1")); + let request_lower = request.to_ascii_lowercase(); + assert!(!request_lower.contains("x-api-key:")); + assert!(!request_lower.contains("authorization:")); + assert!(request.contains("codescribe-key-probe.wav")); + assert!( + request.contains("name=\"vocabulary\""), + "loopback Codescribe probe must name the programming domain" + ); + assert!(request.contains("programming")); + assert_eq!( + result.message, + "local STT endpoint accepts unauthenticated requests" + ); + } + + /// A stored Voice Lab socket is remapped onto the file worker before POST. + #[test] + fn stt_probe_maps_live_websocket_to_multipart() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind STT live-socket probe server"); + let address = listener + .local_addr() + .expect("STT live-socket probe address"); + let live = format!("ws://127.0.0.1:{}/v1/audio/transcribe", address.port()); + let expected = format!( + "http://127.0.0.1:{}/v1/audio/transcriptions", + address.port() + ); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept remapped STT probe"); + let mut buffer = [0_u8; 8192]; + let bytes_read = stream.read(&mut buffer).expect("read remapped STT probe"); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"text\":\"\"}", + ) + .expect("write remapped STT probe response"); + String::from_utf8_lossy(&buffer[..bytes_read]).to_string() + }); + let client = Client::builder() + .timeout(PROBE_TIMEOUT) + .connect_timeout(PROBE_TIMEOUT) + .build() + .expect("build remapped STT probe client"); + let config = Config { + stt_endpoint: Some(live), + ..Config::default() + }; + let result = probe_stt_key(&client, &config, "STT_API_KEY", "test-key"); + assert_eq!(result.status, ApiKeyLivenessStatus::Ok); + assert_eq!(result.probed_endpoint.as_deref(), Some(expected.as_str())); + let request = server.join().expect("remapped STT probe server"); + assert!(request.starts_with("POST /v1/audio/transcriptions HTTP/1.1")); + } + /// 2xx means the provider accepted the key and returned a usable response. #[test] fn classifies_success_as_ok() { diff --git a/core/llm/mod.rs b/core/llm/mod.rs index 9055100f..df63c857 100644 --- a/core/llm/mod.rs +++ b/core/llm/mod.rs @@ -8,6 +8,9 @@ pub mod account_auth; pub mod ai_formatting; /// HTTP client for cloud STT / LLM multipart upload paths. pub mod client; +/// Inline-format buffer: chunk-chained live formatting so stop pays only the +/// tail (W13-1, feature-flagged via `CODESCRIBE_INLINE_FORMAT`). +pub mod inline_format; /// Minimal API-key liveness probes for Settings (one cheap call per key). pub mod key_liveness; /// Canonical resolution of lane secrets, endpoints, and model ids. diff --git a/core/mcp/client.rs b/core/mcp/client.rs index 3f0167fb..b0e2016b 100644 --- a/core/mcp/client.rs +++ b/core/mcp/client.rs @@ -923,28 +923,15 @@ impl StdioConnection { } /// Mark the child's stdin pipe so writes to a dead peer return EPIPE instead -/// of raising SIGPIPE. Per-fd (`F_SETNOSIGPIPE`) on purpose: it protects the -/// MCP exchange without mutating the host process' signal table. -#[cfg(target_os = "macos")] +/// of raising SIGPIPE. +/// +/// Now a thin alias over [`crate::util::pipes::disable_sigpipe`]: the same +/// hazard reached the Apple STT bridge (2026-08-12), so the remedy lives in one +/// place rather than being rediscovered per child. fn disable_sigpipe(stdin: &ChildStdin) { - use std::os::fd::AsRawFd; - - // Darwin `sys/fcntl.h`: `#define F_SETNOSIGPIPE 73` — the libc crate does - // not export this per-fd fcntl command (only the socket-level - // `SO_NOSIGPIPE`), so pin the value here. - /// Darwin fcntl command: mark a fd so broken-pipe writes return EPIPE, not SIGPIPE. - const F_SETNOSIGPIPE: libc::c_int = 73; - - // SAFETY: fcntl on an fd we own for the child's lifetime; F_SETNOSIGPIPE - // only flips a per-fd flag. A failure leaves the old behavior in place and - // is tolerable — the try_wait guard in `shutdown` still narrows exposure. - let _ = unsafe { libc::fcntl(stdin.as_raw_fd(), F_SETNOSIGPIPE, 1) }; + crate::util::pipes::disable_sigpipe(stdin); } -/// No-op outside macOS: `F_SETNOSIGPIPE` is a Darwin-specific fcntl. -#[cfg(not(target_os = "macos"))] -fn disable_sigpipe(_stdin: &ChildStdin) {} - /// Build the `PATH` a spawned server sees: the server's own configured `PATH` /// first, then the process `PATH`, then the user bins and system fallbacks. /// diff --git a/core/mcp/config_store.rs b/core/mcp/config_store.rs index 4a538950..34c3faae 100644 --- a/core/mcp/config_store.rs +++ b/core/mcp/config_store.rs @@ -64,7 +64,7 @@ pub fn list_servers() -> Result> { /// Path-explicit twin of [`list_servers`]. Every public entry point in this /// module delegates to an `_at` variant so the tests can drive the real logic /// against a temp dir instead of the operator's live `~/.codescribe/mcp.json`. -fn list_servers_at(path: &Path) -> Result> { +pub fn list_servers_at(path: &Path) -> Result> { let Some(config) = McpConfigFile::load_optional(path)? else { return Ok(Vec::new()); }; diff --git a/core/mcp/mod.rs b/core/mcp/mod.rs index 3e4c0643..9853d23a 100644 --- a/core/mcp/mod.rs +++ b/core/mcp/mod.rs @@ -23,7 +23,7 @@ pub use client::{ default_mcp_config_path, }; pub use config_store::{ - McpProbeSummary, McpServerSpec, McpServerSummary, add_server, list_servers, + McpProbeSummary, McpServerSpec, McpServerSummary, add_server, list_servers, list_servers_at, probe_server_blocking, remove_server, test_server_blocking, update_server, }; pub use secret_migration::{ diff --git a/core/pipeline/contracts.rs b/core/pipeline/contracts.rs index 89d89846..d8932153 100644 --- a/core/pipeline/contracts.rs +++ b/core/pipeline/contracts.rs @@ -755,6 +755,26 @@ pub enum EngineEvent { Warning { code: String, message: String }, } +/// Warning codes that mean the user's action actually FAILED and must surface +/// as an error on the UI (`listener.on_error`). +/// +/// Every other warning is a quality receipt — the engine degraded or +/// normalized something and *kept going*. Those must never ride the error +/// channel: the composer treats `on_error` during capture as terminal, so a +/// routine receipt painted "Dictation stopped" over a live session, desynced +/// the toggle parity, and the next toggle started a second, orphaned capture +/// that held the microphone behind an Idle tray (2026-08-12 incident). +pub const USER_TERMINAL_WARNING_CODES: &[&str] = &["transcription_failed"]; + +/// Whether a [`EngineEvent::Warning`] code is a user-terminal failure +/// (forward to `on_error`) rather than a quality receipt (log only). +/// +/// W13-5 `capture_level_low` is a receipt. It must never be added here — +/// the composer treats `on_error` during capture as "Dictation stopped". +pub fn warning_is_user_terminal(code: &str) -> bool { + USER_TERMINAL_WARNING_CODES.contains(&code) +} + /// Layer that produced a bounded replacement. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/core/pipeline/highlight.rs b/core/pipeline/highlight.rs new file mode 100644 index 00000000..8cecfb5b --- /dev/null +++ b/core/pipeline/highlight.rs @@ -0,0 +1,219 @@ +//! W13-6B — span-based overlay highlights (lexicon corrections + speech gaps). +//! +//! The canvas stays append-only. Highlights are a read-only layer over +//! provenance that already exists after W13-3A: integer PCM sample ranges +//! plus the char offsets the overlay already receives on `ReplaceRange`. +//! Seconds never live here. +//! +//! Lane flag [`OVERLAY_HIGHLIGHTS_ENV`] is **default OFF**. + +use crate::stt::tail_provider::{TailSampleRange, TimedTailSegment}; + +/// Opt-in gate for the overlay highlight layer. Unset / `0` / `false` / `off` +/// / `no` keep the shipped canvas unstyled. +pub const OVERLAY_HIGHLIGHTS_ENV: &str = "CODESCRIBE_OVERLAY_HIGHLIGHTS"; + +/// Visible gap glyph for a Silero-speech span that landed no words. +pub const SPEECH_GAP_MARKER: &str = "∅"; + +/// Kind of a canvas highlight. Typed evidence, not a confidence score. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlayHighlightKind { + /// A lexicon rewrite already applied to committed text. + LexiconCorrected, + /// Silero heard speech; no engine word landed in the span (pustka). + SpeechGap, +} + +impl OverlayHighlightKind { + pub fn as_str(self) -> &'static str { + match self { + Self::LexiconCorrected => "lexicon_corrected", + Self::SpeechGap => "speech_gap", + } + } +} + +/// One highlight keyed by utterance identity and a half-open sample range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OverlayHighlight { + pub kind: OverlayHighlightKind, + pub utterance_id: u64, + /// Inclusive UTF-8 char start inside the utterance text *after* the edit. + pub char_start: u64, + /// Exclusive UTF-8 char end inside the utterance text *after* the edit. + pub char_end: u64, + pub range: TailSampleRange, + /// Text the lexicon replaced (empty on a speech gap). + pub before: String, + /// Text now on the canvas (gap marker for a pustka). + pub after: String, +} + +/// Parse the overlay-highlights flag. `None` (unset) is OFF. +pub fn parse_overlay_highlights_flag(raw: Option<&str>) -> bool { + match raw { + Some(value) => { + let value = value.trim().to_ascii_lowercase(); + matches!(value.as_str(), "1" | "true" | "yes" | "on") + } + None => false, + } +} + +/// Whether the highlight layer is armed. Default OFF. +pub fn overlay_highlights_enabled() -> bool { + parse_overlay_highlights_flag(std::env::var(OVERLAY_HIGHLIGHTS_ENV).ok().as_deref()) +} + +/// Build a lexicon-correction highlight from a `ReplaceRange` already on the +/// bridge (`source = Lexicon`) plus optional 3A sample identity. +pub fn lexicon_corrected_highlight( + utterance_id: u64, + char_start: u64, + replacement: &str, + before: &str, + range: TailSampleRange, +) -> Option { + if replacement.trim().is_empty() { + return None; + } + let char_end = char_start.saturating_add(replacement.chars().count() as u64); + Some(OverlayHighlight { + kind: OverlayHighlightKind::LexiconCorrected, + utterance_id, + char_start, + char_end, + range, + before: before.to_string(), + after: replacement.to_string(), + }) +} + +/// A Silero-bounded speech span with no word evidence is a pustka. +/// +/// Words whose sample range overlaps `speech` count as coverage. An empty +/// word list, or words that all sit outside the speech range, yields a gap. +pub fn speech_gap_highlight( + utterance_id: u64, + speech: TailSampleRange, + words: &[TimedTailSegment], +) -> Option { + if speech.sample_end <= speech.sample_start { + return None; + } + let covered = words + .iter() + .any(|word| ranges_overlap(&speech, &word.range)); + if covered { + return None; + } + Some(OverlayHighlight { + kind: OverlayHighlightKind::SpeechGap, + utterance_id, + char_start: 0, + char_end: 0, + range: speech, + before: String::new(), + after: SPEECH_GAP_MARKER.to_string(), + }) +} + +/// Empty `UtteranceFinal` after measured speech is the Swift-side pustka +/// signal (data already crossing the bridge: `on_final` + VAD / speech_pct). +pub fn empty_final_speech_gap( + utterance_id: u64, + text: &str, + speech_was_active: bool, + speech_pct: Option, + range: TailSampleRange, +) -> Option { + if !text.trim().is_empty() { + return None; + } + let heard = speech_was_active || speech_pct.is_some_and(|pct| pct > 0.0); + if !heard { + return None; + } + speech_gap_highlight(utterance_id, range, &[]) +} + +fn ranges_overlap(left: &TailSampleRange, right: &TailSampleRange) -> bool { + left.session == right.session + && left.capture_epoch == right.capture_epoch + && left.sample_start < right.sample_end + && right.sample_start < left.sample_end +} + +#[cfg(test)] +mod tests { + use super::*; + + fn range(start: u64, end: u64) -> TailSampleRange { + TailSampleRange { + session: "s1".into(), + capture_epoch: 1, + sample_start: start, + sample_end: end, + } + } + + #[test] + fn highlight_flag_defaults_off_and_accepts_truthy() { + assert!(!parse_overlay_highlights_flag(None)); + assert!(!parse_overlay_highlights_flag(Some(""))); + assert!(!parse_overlay_highlights_flag(Some("0"))); + assert!(!parse_overlay_highlights_flag(Some("off"))); + assert!(!parse_overlay_highlights_flag(Some("false"))); + assert!(parse_overlay_highlights_flag(Some("1"))); + assert!(parse_overlay_highlights_flag(Some("ON"))); + assert!(parse_overlay_highlights_flag(Some(" true "))); + } + + #[test] + fn lexicon_highlight_pins_char_span_and_sample_range() { + let highlight = + lexicon_corrected_highlight(7, 4, "Junie", "uni agentka", range(16_000, 24_000)) + .expect("replacement"); + assert_eq!(highlight.kind, OverlayHighlightKind::LexiconCorrected); + assert_eq!(highlight.utterance_id, 7); + assert_eq!(highlight.char_start, 4); + assert_eq!(highlight.char_end, 9); + assert_eq!(highlight.before, "uni agentka"); + assert_eq!(highlight.after, "Junie"); + assert_eq!(highlight.range.sample_start, 16_000); + assert_eq!(highlight.range.sample_end, 24_000); + assert_eq!(highlight.kind.as_str(), "lexicon_corrected"); + } + + #[test] + fn lexicon_highlight_rejects_empty_replacement() { + assert!(lexicon_corrected_highlight(1, 0, " ", "x", range(0, 10)).is_none()); + } + + #[test] + fn speech_gap_when_silero_range_has_no_overlapping_words() { + let words = [TimedTailSegment { + text: "hello".into(), + range: range(0, 1_000), + }]; + let gap = speech_gap_highlight(3, range(8_000, 16_000), &words).expect("pustka"); + assert_eq!(gap.kind, OverlayHighlightKind::SpeechGap); + assert_eq!(gap.after, SPEECH_GAP_MARKER); + assert_eq!(gap.range.sample_start, 8_000); + assert!(speech_gap_highlight(3, range(0, 500), &words).is_none()); + assert!(speech_gap_highlight(3, range(10, 10), &[]).is_none()); + } + + #[test] + fn empty_final_becomes_gap_only_after_measured_speech() { + assert!(empty_final_speech_gap(1, "słowo", true, Some(0.8), range(0, 100)).is_none()); + assert!(empty_final_speech_gap(1, " ", false, None, range(0, 100)).is_none()); + assert!(empty_final_speech_gap(1, "", false, Some(0.0), range(0, 100)).is_none()); + let from_vad = empty_final_speech_gap(2, "", true, None, range(100, 200)).expect("vad"); + assert_eq!(from_vad.kind, OverlayHighlightKind::SpeechGap); + let from_pct = empty_final_speech_gap(3, " \n", false, Some(0.4), range(200, 400)) + .expect("speech_pct"); + assert_eq!(from_pct.utterance_id, 3); + } +} diff --git a/core/pipeline/mod.rs b/core/pipeline/mod.rs index 247a410c..11ee15d4 100644 --- a/core/pipeline/mod.rs +++ b/core/pipeline/mod.rs @@ -6,6 +6,8 @@ pub mod contracts; /// Overlap/duplicate utterance suppression for streamed transcript events. pub mod dedup; +/// W13-6B overlay highlight layer (lexicon corrections + speech-gap pustki). +pub mod highlight; /// Light-plus post-pass for low-latency transcript cleanup. pub mod light_plus; /// MiniLM meaning check for AI-formatted deliveries (calibrated floor). diff --git a/core/pipeline/stream_postprocess.rs b/core/pipeline/stream_postprocess.rs index 3815aacb..9eededd8 100644 --- a/core/pipeline/stream_postprocess.rs +++ b/core/pipeline/stream_postprocess.rs @@ -67,8 +67,23 @@ const MAX_DROPS_IN_ROW: u8 = 2; const FINAL_PASS_ARTIFACT_TOKENS: &[&str] = &["going", "use"]; /// Whisper `initial_prompt` token budget; over-approximated so the decoder never truncates. pub const WHISPER_INITIAL_PROMPT_TOKEN_BUDGET: usize = 224; +/// SFSpeechRecognizer accepts at most one hundred contextual strings. +pub const STT_CONTEXTUAL_STRINGS_MAX: usize = 100; /// Fixed prefix for the Whisper vocabulary hint string built by `build_whisper_initial_prompt`. const WHISPER_INITIAL_PROMPT_PREFIX: &str = "Vocabulary:"; +/// Stable last-priority vocabulary for the W13 operator domain. These terms are +/// useful before the first acoustic occurrence, so a context match reorders +/// them but never determines whether they are eligible. +const STT_DOMAIN_PROMPT_TERMS: &[&str] = &[ + "Vibecrafted", + "worktree", + "worktrees", + "binarka", + "binarki", + "akapity", + "reports", + "editors", +]; /// Env override for Whisper initial-prompt opt-in; wins over persisted config when set. pub const STT_INITIAL_PROMPT_ENABLED_ENV: &str = "CODESCRIBE_STT_INITIAL_PROMPT_ENABLED"; @@ -171,6 +186,23 @@ static GLOBAL_LEXICON: LazyLock> = LazyLock::new(|| { RwLock::new(lex) }); +/// Warm the global lexicon off the caller's thread. +/// +/// The singleton compiles ~14.5k rules in seconds; when the first toucher is +/// the Apple live-session thread, that compile sits between "audio stream +/// started" and "recognizer ready" and the first dictation after launch arms +/// seconds late (session a5623d55, 2026-08-12: 5.1 s). Call at startup so the +/// first recording finds the table already built. Idempotent and non-blocking; +/// concurrent first-touchers simply block on the same `LazyLock` as before. +pub fn warm_lexicon() { + std::thread::Builder::new() + .name("lexicon-warm".into()) + .spawn(|| { + drop(GLOBAL_LEXICON.read()); + }) + .ok(); +} + impl Lexicon { /// Compile the full rule set from every source, in load order. /// @@ -333,10 +365,15 @@ impl Lexicon { /// Domain-vocabulary hint for this rule set: protected terms first, then the /// operator's custom canonicals, trimmed to the Whisper prompt budget. - fn whisper_initial_prompt(&self) -> Option { - build_whisper_initial_prompt( + fn whisper_initial_prompt_receipt( + &self, + window_context: Option<&str>, + ) -> Option { + let domain_terms = prioritized_domain_terms(window_context); + build_lexicon_voice_receipt( &self.protected_canonicals, &self.custom_canonicals, + &domain_terms, WHISPER_INITIAL_PROMPT_TOKEN_BUDGET, ) } @@ -384,6 +421,27 @@ pub fn build_whisper_initial_prompt( custom_terms: &[String], token_budget: usize, ) -> Option { + build_lexicon_voice_receipt(protected_terms, custom_terms, &[], token_budget) + .map(|receipt| receipt.prompt) +} + +/// Evidence emitted for every prompt-bearing recognition window. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LexiconVoiceReceipt { + pub prompt: String, + pub terms: Vec, + pub estimated_tokens: usize, +} + +/// Deterministic three-tier selection: protected, custom, then domain terms. +/// A term that does not fit is skipped so one long entry cannot starve later +/// short entries in the same (or lower) priority tier. +pub fn build_lexicon_voice_receipt( + protected_terms: &[String], + custom_terms: &[String], + domain_terms: &[String], + token_budget: usize, +) -> Option { if token_budget == 0 { return None; } @@ -392,7 +450,14 @@ pub fn build_whisper_initial_prompt( let mut selected = Vec::new(); let mut used_tokens = 1usize; // `Vocabulary:` - for term in protected_terms.iter().chain(custom_terms.iter()) { + for term in protected_terms + .iter() + .chain(custom_terms.iter()) + .chain(domain_terms.iter()) + { + if selected.len() == STT_CONTEXTUAL_STRINGS_MAX { + break; + } let term = term.trim(); if term.is_empty() { continue; @@ -403,15 +468,36 @@ pub fn build_whisper_initial_prompt( let term_tokens = estimated_prompt_tokens(term) + 1; // term plus separator/punctuation if used_tokens + term_tokens > token_budget { - break; + continue; } used_tokens += term_tokens; selected.push(term.to_string()); } - (!selected.is_empty()) - .then(|| format!("{WHISPER_INITIAL_PROMPT_PREFIX} {}.", selected.join("; "))) + (!selected.is_empty()).then(|| LexiconVoiceReceipt { + prompt: format!("{WHISPER_INITIAL_PROMPT_PREFIX} {}.", selected.join("; ")), + terms: selected, + estimated_tokens: used_tokens, + }) +} + +fn prioritized_domain_terms(window_context: Option<&str>) -> Vec { + let context = window_context.unwrap_or_default().to_lowercase(); + let terms: Vec = STT_DOMAIN_PROMPT_TERMS + .iter() + .map(|term| (*term).to_string()) + .collect(); + terms + .iter() + .filter(|term| context.contains(&term.to_lowercase())) + .chain( + terms + .iter() + .filter(|term| !context.contains(&term.to_lowercase())), + ) + .cloned() + .collect() } /// Whether the vocabulary hint may be fed to the decoder. @@ -433,6 +519,15 @@ pub fn stt_initial_prompt_enabled() -> bool { /// disabled or no terms are registered. Hot-reloads the custom file first so a /// freshly taught term can reach the very next decode. pub fn whisper_initial_prompt() -> Option { + whisper_initial_prompt_for_window(None).map(|receipt| receipt.prompt) +} + +/// Build and attest the vocabulary used for one Whisper tail/utterance window. +/// No transcript content is logged; the selected vocabulary and token estimate +/// are sufficient to reproduce the selection. +pub fn whisper_initial_prompt_for_window( + window_context: Option<&str>, +) -> Option { if !stt_initial_prompt_enabled() { return None; } @@ -440,7 +535,35 @@ pub fn whisper_initial_prompt() -> Option { let lexicon = GLOBAL_LEXICON .read() .expect("global lexicon read lock poisoned"); - lexicon.whisper_initial_prompt() + let receipt = lexicon.whisper_initial_prompt_receipt(window_context)?; + info!( + scope = "window", + selected_terms = ?receipt.terms, + estimated_tokens = receipt.estimated_tokens, + "STT lexicon voice receipt" + ); + Some(receipt) +} + +/// Preserve the rolling acoustic context and append lexicon vocabulary only +/// for this decode window. With the flag off, the pre-existing context passes +/// through byte-for-byte. +pub fn compose_whisper_window_prompt(previous_context: Option<&str>) -> Option { + let previous = previous_context + .map(str::trim) + .filter(|value| !value.is_empty()); + let receipt = whisper_initial_prompt_for_window(previous); + match (previous, receipt) { + (Some(context), Some(receipt)) => Some(format!("{context}\n{}", receipt.prompt)), + (Some(context), None) => Some(context.to_string()), + (None, Some(receipt)) => Some(receipt.prompt), + (None, None) => None, + } +} + +/// Apple contextual strings use the same flag, ordering and budget as Whisper. +pub fn apple_contextual_strings() -> Option> { + whisper_initial_prompt_for_window(None).map(|receipt| receipt.terms) } /// Coarse token estimate for prompt budgeting: one token per whitespace-separated @@ -1320,6 +1443,25 @@ mod tests { assert_eq!(prompt, "Vocabulary: Loctree."); } + /// W13-6A acceptance: selection is per-window, deterministic, receipted, + /// budget-aware, and preserves protected > custom > domain priority. + #[test] + fn w13_lexicon_voice_per_window_receipt() { + let protected = vec![ + "Loctree".to_string(), + "too many words right here".to_string(), + ]; + let custom = vec!["my term".to_string()]; + let domain = vec!["Vibecrafted".to_string(), "worktree".to_string()]; + + let receipt = + build_lexicon_voice_receipt(&protected, &custom, &domain, 8).expect("window receipt"); + + assert_eq!(receipt.terms, vec!["Loctree", "my term", "Vibecrafted"]); + assert_eq!(receipt.estimated_tokens, 8); + assert_eq!(receipt.prompt, "Vocabulary: Loctree; my term; Vibecrafted."); + } + /// Fresh/default config must not inject an initial prompt even when terms exist. #[test] #[serial] @@ -1460,7 +1602,7 @@ mod tests { assert_eq!(lexicon.custom_canonicals, vec!["FooBar".to_string()]); } - /// Overlay correction → custom lexicon → hot-reload teaches the next transcript. + /// Overlay copy is evidence-only. Voice Lab finalize teaches the custom lexicon. #[test] #[serial] fn overlay_correction_chain_teaches_custom_lexicon_for_next_transcript() { @@ -1478,7 +1620,7 @@ mod tests { crate::quality::overlay_quality::extract_lexicon_candidates("uni agentka", "Junie"); assert_eq!(candidates, vec![("uni agentka".into(), "Junie".into())]); - let quality_path = crate::quality::overlay_quality::commit_overlay_correction( + let commit = crate::quality::overlay_quality::commit_overlay_correction( "uni agentka", "uni agentka", "Junie", @@ -1486,8 +1628,10 @@ mod tests { Some("whisper-test".into()), Some("copy"), ) - .expect("commit overlay correction") - .quality_path; + .expect("commit overlay correction"); + assert!(commit.evidence_only); + assert_eq!(commit.pairs_learned, 0); + let quality_path = commit.quality_path; assert!(quality_path.starts_with(&temp_root)); assert!(quality_path.ends_with("corrections.jsonl")); @@ -1505,8 +1649,24 @@ mod tests { ); let custom_path = crate::config::Config::config_dir().join("lexicon.custom.jsonl"); - let custom = std::fs::read_to_string(&custom_path).expect("read custom lexicon"); - assert!(custom.contains(r#""term":"Junie""#)); + assert!( + !custom_path.exists(), + "overlay copy must not write the custom lexicon" + ); + + std::thread::sleep(std::time::Duration::from_millis(50)); + let outcome = crate::quality::overlay_quality::finalize_voice_lab_correction( + &correction_id, + "Junie Prime", + ) + .expect("finalize Voice Lab correction"); + assert_eq!(outcome.pairs_learned, 1); + assert_eq!(outcome.record.revision, record.revision + 1); + assert_eq!(outcome.record.edited_text, "Junie Prime"); + + let custom = + std::fs::read_to_string(&custom_path).expect("read custom lexicon after teach"); + assert!(custom.contains(r#""term":"Junie Prime""#)); assert!(custom.contains(r#""uni agentka""#)); let mut custom_rules = Vec::new(); @@ -1518,9 +1678,9 @@ mod tests { Some(&mut custom_canonicals), ); assert_eq!(count, 1); - assert_eq!(custom_canonicals, vec!["Junie".to_string()]); + assert_eq!(custom_canonicals, vec!["Junie Prime".to_string()]); - let mut lexicon = Lexicon { + let lexicon = Lexicon { builtin_rules: Vec::new(), custom_rules, custom_path: custom_path.clone(), @@ -1530,23 +1690,6 @@ mod tests { protected_canonicals: Vec::new(), custom_canonicals, }; - assert_eq!(lexicon.apply("uni agentka"), "Junie"); - assert_eq!( - lexicon.apply("Następny transcript: uni agentka."), - "Następny transcript: Junie." - ); - - std::thread::sleep(std::time::Duration::from_millis(50)); - let outcome = crate::quality::overlay_quality::finalize_voice_lab_correction( - &correction_id, - "Junie Prime", - ) - .expect("finalize Voice Lab correction"); - assert_eq!(outcome.pairs_learned, 1); - assert_eq!(outcome.record.revision, record.revision + 1); - assert_eq!(outcome.record.edited_text, "Junie Prime"); - - lexicon.maybe_reload(); assert_eq!(lexicon.custom_rules.len(), 1); assert_eq!(lexicon.apply("uni agentka"), "Junie Prime"); assert_eq!( diff --git a/core/pipeline/streaming/apple_live_session.rs b/core/pipeline/streaming/apple_live_session.rs index 471b6140..5bc5ce8f 100644 --- a/core/pipeline/streaming/apple_live_session.rs +++ b/core/pipeline/streaming/apple_live_session.rs @@ -39,17 +39,42 @@ use futures_util::stream::FuturesOrdered; use tokio::sync::mpsc; use tracing::{info, warn}; -use crate::pipeline::contracts::{DropKind, EngineEvent, EventSink, TranscriptSegment}; +use crate::asr_session::recorder::{ + LAYER1_DEGRADED_WARNING_CODE, Layer1DegradeReason, RecorderLayer1Lane, + apply_recorder_lifecycle_event, +}; +use crate::asr_session::{SessionId as Layer1SessionId, SessionInput as Layer1SessionInput}; +use crate::audio::capture_receipt::{ + CaptureLevelAccumulator, CapturePathMeta, begin_session_energy_clock, + emit_capture_level_receipt, +}; +use crate::pipeline::contracts::{ + DropKind, EngineEvent, EventSink, LayerSource, TranscriptSegment, +}; use crate::pipeline::stream_postprocess::StreamPostProcessor; use crate::stt::apple_stt::{LiveStreamEvent, LiveStreamSession}; -use crate::stt::tail_patcher::{TailPatchConfig, TailPatchOutcome}; +use crate::stt::tail_patcher::{SkipReasonCode, TailPatchConfig, TailPatchOutcome}; +use crate::stt::tail_provider::{ + TailEvidenceSource, TailEvidenceStability, TailProviderEvidence, TailProviderPayload, + TailProviderRequest, TailRequestIdentity, TailSampleRange, TailTimingQuality, TimedTailSegment, +}; -use super::live_audio_buffer::{DEFAULT_RETENTION_SECS, LiveAudioBuffer}; -use super::progressive_seal::{ProgressiveSealMachine, seal_span_text}; +use super::layer1_window::{ + CoalesceFlush, CoalescedPiece, ConcatSpan, Layer1Coalesce, split_outcome_for_members, +}; +use super::live_audio_buffer::{DEFAULT_RETENTION_SECS, LiveAudioBuffer, ResolvedAudioWindow}; +use super::progressive_seal::{ + AppleCommit, ProgressiveSealMachine, SealTick, SealedSpan, seal_span_text, +}; #[cfg(test)] use super::session::emit_tail_patch_result; use super::session::{ - SessionConfig, compute_tail_patch_job, emit_session_finalised, tail_patch_enabled, + SessionConfig, TailPatchJobResult, UNDER_COMMIT_WARNING_CODE, compute_tail_patch_job, + emit_session_finalised, log_tail_patch_session_receipt, tail_patch_enabled, +}; +use super::silero_fusion::{ + FusionContextMode, FusionWord, SileroIngress, bound_context_range, conservative_fuse, + fusion_receipt, lane_enabled, slice_apple_words, }; use super::stream_log::append_to_stream_log; @@ -62,15 +87,48 @@ use super::stream_log::append_to_stream_log; /// backpressure shape: Whisper falling behind costs patches, never audio. const TAIL_PATCH_QUEUE_CAP: usize = 8; +/// How long the end-of-session closure loop waits for one outstanding Layer 1 +/// job to report back. +/// +/// This sits directly on the stop path, in front of an operator watching the +/// overlay, so it is a product budget rather than an engineering safety net. +/// Every job it waits for was queued during capture against a model that is +/// already warm, and the observed windows close well under a second; the cap is +/// here for a genuinely wedged job, not for normal completion. It was 30s until +/// 2026-08-12, when a stop that owed nothing at all still paid the full 30s +/// because the loop was waiting on the wrong condition. +const TAIL_PATCH_CLOSURE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Content-free marker emitted when an Apple final callback contained segment +/// time already committed by an earlier callback. The overlapping portion is +/// removed before a new utterance id can be allocated. +pub const APPLE_FINAL_OVERLAP_WARNING_CODE: &str = "apple_final_window_overlap_normalized"; + /// One sealed utterance handed from the worker thread to the async Layer 1 lane. struct TailPatchRequest { utterance_id: u64, /// Byte-identical to the emitted `UtteranceFinal.text` — the string every /// `ReplaceRange` char offset is computed against. committed_text: String, + /// Canvas already sealed BEFORE this utterance. + /// + /// Layer 1 sees one utterance at a time, so a phrase the previous + /// utterance already carries reads as a gap here and is appended a second + /// time — measured 2026-08-14 the moment recoveries first reached the + /// canvas ("…hard pruna I road która pozwoli nam na zrobienie hard Pru."), + /// which cost more WER than the recovery gained. The neighbour context is + /// read-only: it is never patched, only consulted so a duplicate is + /// escalated instead of placed. + neighbour_context: String, /// PCM behind exactly this utterance: `[previous seal end, end_ts)`. audio: Vec, + /// Exact capture range behind `audio`; this is the window-start authority. + provider_request: TailProviderRequest, covered_through_secs: f32, + /// Concat-space map when this job covers more than one Apple seal. + span_map: Vec, + /// Every sealed utterance this job must close (id, covered_through_secs). + member_ids: Vec<(u64, f32)>, } /// Whisper closure returned to the worker that owns Apple + seal state. @@ -78,6 +136,17 @@ struct TailPatchCompletion { utterance_id: u64, covered_through_secs: f32, outcome: TailPatchOutcome, + payload: Option, + span_map: Vec, + member_ids: Vec<(u64, f32)>, +} + +/// In-flight Layer 1 job identity, including the coalesce map. +struct TailPatchInFlight { + utterance_id: u64, + covered_through_secs: f32, + span_map: Vec, + member_ids: Vec<(u64, f32)>, } /// Async Layer 1 lane for the Apple progressive path. @@ -86,38 +155,42 @@ struct TailPatchCompletion { /// `SessionFinalised.layer_summary` reports. Jobs are boxed so the lane can be /// driven by a stub future in tests without a model on disk. struct AppleTailPatchLane { - jobs: FuturesOrdered>>, - sample_rate: u32, + jobs: FuturesOrdered>>, language: Option, config: TailPatchConfig, replacements: u64, + /// Jobs whose entire output was rejected (Skipped or failed). Feeds the + /// session-level starvation receipt — the 116-skips/0-applied class of + /// silent lane death must be one WARN, not a grep across log history. + skipped: u64, } impl AppleTailPatchLane { /// Open an empty lane. `TailPatchConfig::from_env` is read once here so the /// whole session judges every patch against the same thresholds, even if the /// env flips mid-hold. - fn new(sample_rate: u32, language: Option) -> Self { + fn new(_sample_rate: u32, language: Option) -> Self { Self { jobs: FuturesOrdered::new(), - sample_rate, language, // F2: thresholds stay exactly where the shared primitive puts them. config: TailPatchConfig::from_env(), replacements: 0, + skipped: 0, } } /// Turn a sealed utterance into a Whisper gap-fill job and queue it. The job /// is only constructed — inference happens inside it on `spawn_blocking`, so /// this call never sits on the event-drain path. - fn push_request(&mut self, req: TailPatchRequest) { + fn push_request(&mut self, mut req: TailPatchRequest) { + req.provider_request.language = self.language.clone(); let job = compute_tail_patch_job( req.utterance_id, req.committed_text, + req.neighbour_context, req.audio, - self.sample_rate, - self.language.clone(), + req.provider_request, self.config, ); self.push_job(Box::pin(job)); @@ -125,14 +198,14 @@ impl AppleTailPatchLane { /// Queue an already-built job. Boxed and separate from `push_request` so /// tests can drive the lane with a stub future, with no model on disk. - fn push_job(&mut self, job: BoxFuture<'static, Result<(u64, TailPatchOutcome)>>) { + fn push_job(&mut self, job: BoxFuture<'static, Result>) { self.jobs.push_back(job); } /// Await the next finished job. `FuturesOrdered` (not `Unordered`) is the /// point: patches must reach the sink in seal order, or a later utterance's /// `ReplaceRange` could land before an earlier one's. - async fn next(&mut self) -> Option> { + async fn next(&mut self) -> Option> { self.jobs.next().await } @@ -140,7 +213,13 @@ impl AppleTailPatchLane { /// session total. A skipped or failed patch contributes zero — only text /// that actually reached the canvas is counted. #[cfg(test)] - fn complete(&mut self, event_sink: &dyn EventSink, result: Result<(u64, TailPatchOutcome)>) { + fn complete(&mut self, event_sink: &dyn EventSink, result: Result) { + if matches!(&result, Ok(job) if matches!(job.outcome, TailPatchOutcome::Skipped { .. })) + || result.is_err() + { + self.skipped = self.skipped.saturating_add(1); + } + let result = result.map(TailPatchJobResult::into_outcome); self.replacements = self .replacements .saturating_add(emit_tail_patch_result(event_sink, result)); @@ -151,41 +230,84 @@ impl AppleTailPatchLane { /// after `UtteranceFinal`, preserving event order. fn finish_for_worker( &mut self, - request_id: u64, - req_end_secs: f32, - result: Result<(u64, TailPatchOutcome)>, + inflight: Option, + result: Result, ) -> TailPatchCompletion { + let (fallback_id, fallback_end, span_map, member_ids) = match inflight { + Some(job) => ( + job.utterance_id, + job.covered_through_secs, + job.span_map, + job.member_ids, + ), + None => (0, 0.0, Vec::new(), Vec::new()), + }; match result { - Ok((utterance_id, outcome)) => { - if let TailPatchOutcome::Patches(events) = &outcome { - self.replacements = self.replacements.saturating_add( - events - .iter() - .filter(|event| matches!(event, EngineEvent::ReplaceRange { .. })) - .count() as u64, - ); - } + Ok(job) => { + let utterance_id = job.utterance_id; + let outcome = job.outcome; TailPatchCompletion { utterance_id, - covered_through_secs: req_end_secs, + covered_through_secs: fallback_end, outcome, + payload: Some(job.payload), + span_map, + member_ids, } } Err(error) => TailPatchCompletion { - utterance_id: request_id, - covered_through_secs: req_end_secs, - outcome: TailPatchOutcome::Skipped { - reason: format!("tail patch failed: {error}"), - }, + utterance_id: fallback_id, + covered_through_secs: fallback_end, + outcome: TailPatchOutcome::skipped( + crate::stt::tail_patcher::SkipReasonCode::ProviderError, + format!("tail patch failed: {error}"), + ), + payload: None, + span_map, + member_ids, }, } } + /// Hand a completion to the live seal owner and only then account it in + /// the session receipt. A closed receiver means the worker has already + /// sealed raw and no patch can reach the canvas. + fn forward_completion_to_worker( + &mut self, + tx: &std_mpsc::Sender, + completion: TailPatchCompletion, + ) -> bool { + // `events()` covers both bearing arms: ordinary patches and the + // gap-appends an under-commit recovered. Counting only `Patches` would + // drop recovered speech from the session summary. + let replacements = completion + .outcome + .events() + .iter() + .filter(|event| matches!(event, EngineEvent::ReplaceRange { .. })) + .count() as u64; + let skipped = u64::from(matches!( + &completion.outcome, + TailPatchOutcome::Skipped { .. } + )); + if tx.send(completion).is_err() { + return false; + } + self.replacements = self.replacements.saturating_add(replacements); + self.skipped = self.skipped.saturating_add(skipped); + true + } + /// How many bounded replacements Layer 1 landed this session — the number /// `SessionFinalised.layer_summary` reports. fn replacements(&self) -> u64 { self.replacements } + + /// How many jobs put nothing on the canvas (skipped or failed). + fn skipped(&self) -> u64 { + self.skipped + } } /// Deliver one engine event to the sink, writing the same per-utterance @@ -205,6 +327,17 @@ fn deliver_event( event_sink.on_event(event); } +/// Surface one Layer 1 lane degrade as a counts-only warning event. +/// +/// The message is the typed reason token and nothing else — no transcript, +/// audio, provider payload, or endpoint detail can ride this event into a log. +fn emit_layer1_degrade_warning(event_sink: &dyn EventSink, reason: Layer1DegradeReason) { + event_sink.on_event(&EngineEvent::Warning { + code: LAYER1_DEGRADED_WARNING_CODE.to_string(), + message: reason.as_token().to_string(), + }); +} + /// Drive one progressive Apple stream session until the audio channel closes. pub(crate) async fn apple_stream_transcription_session( mut chunk_receiver: mpsc::Receiver>, @@ -216,16 +349,22 @@ pub(crate) async fn apple_stream_transcription_session( language, stream_log_path, utterance_silence_sec, + layer1, + local_whisper_allowed, + mut lifecycle_events, } = config; - // SFSpeech owns phrase boundaries in progressive mode, so the VAD-path - // silence knob cannot apply. Say so instead of silently differing from - // the `CODESCRIBE_APPLE_STT_LIVE_MODE=wav` escape hatch. + let mut capture_level = CaptureLevelAccumulator::new(); + begin_session_energy_clock(); + // Hands-free silence is the ENGINE LIFECYCLE on this lane, not a chunker + // knob: SFSpeech still owns phrase boundaries inside an utterance, but the + // threshold decides when the engine rests (mic + Silero keep watching) and + // when a fresh epoch wakes on the next speech edge. Unset = one continuous + // stream for the whole take, the pre-lifecycle behaviour. if let Some(sec) = utterance_silence_sec { - warn!( + info!( utterance_silence_sec = sec, - "Apple progressive live mode ignores utterance_silence_sec \ - (SFSpeech decides phrase boundaries; use CODESCRIBE_APPLE_STT_LIVE_MODE=wav \ - for the VAD silence contract)" + "Apple progressive live mode: engine lifecycle armed on the hands-free silence \ + threshold (speech epochs)" ); } @@ -235,6 +374,27 @@ pub(crate) async fn apple_stream_transcription_session( ); let session_id = uuid::Uuid::new_v4().to_string(); + // W13-1 inline-format buffer: arm a fresh chunk/chain session (no-op when + // `CODESCRIBE_INLINE_FORMAT` is off). Must happen on the async side — the + // blocking seal worker only ever enqueues sealed chunks. + crate::llm::inline_format::begin_session(language.as_deref()); + + // C1: open the injected Layer 1 lane at recording start. `Disarmed` is the + // stock product (canvas + lexicon); an armed provider only ever arrives + // here already authorized — construction and consent live with the + // settings owner, not in this pipeline. Every lane failure from here on + // degrades back to exactly the disarmed behavior. + let lane_input = Layer1SessionInput { + session_id: Layer1SessionId::new(session_id.clone()) + .expect("uuid session ids are never blank"), + locale: language.clone(), + sample_rate, + }; + let mut layer1_lane = RecorderLayer1Lane::open(layer1, &lane_input); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); + } + // PCM → worker (None = EOF). Unbounded so the async select loop never // blocks on a full sync_channel while live Preview events wait to drain // (bounded sync_channel + blocking send would re-stall presentation). @@ -242,10 +402,10 @@ pub(crate) async fn apple_stream_transcription_session( // Worker → async events. let (ev_tx, mut ev_rx) = mpsc::unbounded_channel::(); - // Layer 1 (Whisper tail-patch) lane — off unless - // `CODESCRIBE_LAYERED_TRANSCRIPTION=phase1+`. Read once here so the whole - // session agrees on one answer even if the env flips mid-hold. - let tail_patch_on = tail_patch_enabled(); + // Legacy local Layer 1 lane — requires both resolved Local power permission + // and `CODESCRIBE_LAYERED_TRANSCRIPTION=phase1+`. Read once here so the + // whole session agrees even if the compatibility env flips mid-hold. + let tail_patch_on = tail_patch_enabled(local_whisper_allowed); if tail_patch_on { info!( "Layered transcription Layer 1 (Whisper tail-patch) enabled on Apple progressive path" @@ -255,7 +415,7 @@ pub(crate) async fn apple_stream_transcription_session( // At-most-one-in-flight gate (F1), tracked outside the lane so the admit // branch's guard does not borrow what the collect branch holds mutably. let mut tail_patch_in_flight = false; - let mut tail_patch_lane_in_flight: Option<(u64, f32)> = None; + let mut tail_patch_lane_in_flight: Option = None; // Bounded: the worker `try_send`s from the PCM-forwarding thread. let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); let (tp_done_tx, tp_done_rx) = std_mpsc::channel::(); @@ -263,14 +423,19 @@ pub(crate) async fn apple_stream_transcription_session( // and its branch never yields: zero jobs, zero behaviour change. let worker_tp_tx = tail_patch_on.then_some(tp_tx); + let worker_session_id = session_id.clone(); let worker = thread::spawn(move || { apple_stream_worker( pcm_rx, ev_tx, - sample_rate, - language.as_deref(), worker_tp_tx, tp_done_rx, + AppleWorkerConfig { + sample_rate, + language: language.as_deref(), + session_id: worker_session_id, + utterance_silence_sec, + }, ) }); @@ -299,6 +464,14 @@ pub(crate) async fn apple_stream_transcription_session( chunk = chunk_receiver.recv(), if !audio_eof => { match chunk { Some(chunk) => { + capture_level.push_samples(&chunk); + // C1 fan-out: offer the frame to the Layer 1 lane + // before forwarding to the Apple worker. The offer + // returns immediately, always — a refiner that cannot + // keep up costs refinement frames, never capture, and + // sustained overflow degrades the lane instead of + // exerting backpressure here. + layer1_lane.offer_pcm(&chunk); if pcm_tx.send(Some(chunk)).is_err() { warn!("Apple live stream worker dropped PCM channel"); audio_eof = true; @@ -312,26 +485,60 @@ pub(crate) async fn apple_stream_transcription_session( } } } + lifecycle = async { + match lifecycle_events.as_mut() { + Some(events) => events.recv().await, + None => std::future::pending().await, + } + } => { + match lifecycle { + Some(event) => { + apply_recorder_lifecycle_event(&mut layer1_lane, event); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); + } + } + None => lifecycle_events = None, + } + } // Admit one sealed utterance into Layer 1 at a time. The Whisper // call itself runs on `spawn_blocking` inside the job, so this loop // only ever schedules and collects — inference never sits on the // event-drain path (F1). Some(req) = tp_rx.recv(), if !tail_patch_in_flight => { - let utterance_id = req.utterance_id; - let covered_through_secs = req.covered_through_secs; + let inflight = TailPatchInFlight { + utterance_id: req.utterance_id, + covered_through_secs: req.covered_through_secs, + span_map: req.span_map.clone(), + member_ids: req.member_ids.clone(), + }; tail_patch_lane.push_request(req); tail_patch_in_flight = true; - // One job is in flight, so one end boundary is enough. - // FuturesOrdered preserves the same request/result order. - tail_patch_lane_in_flight = Some((utterance_id, covered_through_secs)); + // One job is in flight; the coalesce map rides alongside so + // the completion can close every member seal. + tail_patch_lane_in_flight = Some(inflight); } Some(result) = tail_patch_lane.next() => { tail_patch_in_flight = false; - let (id, end) = tail_patch_lane_in_flight.take().unwrap_or_default(); - let completion = tail_patch_lane.finish_for_worker(id, end, result); - let _ = tp_done_tx.send(completion); + let inflight = tail_patch_lane_in_flight.take(); + let completion = tail_patch_lane.finish_for_worker(inflight, result); + let rejected_id = completion.utterance_id; + if !tail_patch_lane.forward_completion_to_worker(&tp_done_tx, completion) { + warn!( + utterance_id = rejected_id, + "Layer 1 completion rejected — Apple seal worker already closed" + ); + } } } + // C1: drain whatever the Layer 1 provider has ready. Partials stay + // volatile draft inside the lane (never canvas); finals pass the + // ingest doctrine. Non-blocking, so live Preview drainage above is + // never delayed by the refiner. + layer1_lane.poll(); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); + } } // Worker exited (event channel closed). If audio is still open, keep @@ -347,46 +554,62 @@ pub(crate) async fn apple_stream_transcription_session( while chunk_receiver.recv().await.is_some() {} } - // No new seals can arrive — but seals the worker emitted just before - // exiting (the trailing summary, the open partial) may still be queued. - // Whether the select loop admitted them before `ev_rx` closed is a race, so - // finish the backlog here instead: a patch that lands only sometimes is - // worse than one that always lands. - // - // This is bounded work, not a stop-time re-pass: at most `TAIL_PATCH_QUEUE_CAP` - // already-sealed utterances, one job at a time, each emitting only bounded - // `ReplaceRange` events. No Preview is waiting on it — capture is over. - let mut settled_at_stop = 0u64; - loop { - while let Some(result) = tail_patch_lane.next().await { - let (id, end) = tail_patch_lane_in_flight.take().unwrap_or_default(); - let completion = tail_patch_lane.finish_for_worker(id, end, result); - let _ = tp_done_tx.send(completion); - } - match tp_rx.try_recv() { - Ok(req) => { - tail_patch_lane_in_flight = Some((req.utterance_id, req.covered_through_secs)); - tail_patch_lane.push_request(req); - settled_at_stop = settled_at_stop.saturating_add(1); - } - Err(_) => break, - } + // `ev_rx` closes only when the seal worker has returned and dropped both + // its event sender and completion receiver. Running queued Whisper jobs at + // this point cannot change canvas; it only lengthens stop and used to make + // the receipt count undeliverable patches. Preserve the Apple floor and + // abandon the orphaned refinement work explicitly. + let mut abandoned_tail_patch_jobs = u64::from(tail_patch_in_flight); + while tp_rx.try_recv().is_ok() { + abandoned_tail_patch_jobs = abandoned_tail_patch_jobs.saturating_add(1); + } + if abandoned_tail_patch_jobs > 0 { + warn!( + abandoned_tail_patch_jobs, + "Layer 1 tail-patch work abandoned after Apple seal worker closed" + ); + } + + // C1 stop-drain: close the Layer 1 lane with its bounded drain. Whatever + // happened inside (clean close, disconnect, incomplete drain), the method + // returns and the recording finishes on Apple + lexicon. The outcome's + // finals are doctrine-vetted gap-fill candidates: their one road to + // delivered text is `Layer1SessionOutcome::adjudicate_against_live_floor` + // (the T0 `merge_live_layer1` seam), owned by the stop-path truth + // adjudicator once the settings cut arms real providers. + let layer1_outcome = layer1_lane.stop(); + let layer1_candidate = layer1_outcome.refined_transcript(); + if let Some(reason) = layer1_lane.take_degrade_notice() { + emit_layer1_degrade_warning(event_sink.as_ref(), reason); } - if settled_at_stop > 0 { + let layer1_counts = layer1_outcome.telemetry(); + if layer1_counts.frames_offered > 0 || layer1_counts.finals_accepted > 0 { info!( - settled_at_stop, - "Layer 1 tail-patch backlog settled after capture stopped" + frames_forwarded = layer1_counts.frames_forwarded, + overflow_frame_drops = layer1_counts.overflow_frame_drops, + partials_applied = layer1_counts.partials_applied, + finals_accepted = layer1_counts.finals_accepted, + events_rejected = layer1_counts.events_rejected, + provider_errors = layer1_counts.provider_errors, + degrade_reason = layer1_outcome + .degrade_reason() + .map(|reason| reason.as_token()) + .unwrap_or("none"), + "Layer 1 live lane closed" ); } + let mut sealed_spans = Vec::new(); match worker.join() { Ok(Ok(outcome)) => { info!( sealed = outcome.sealed, filtered_empty_drops = outcome.filtered_empty_drops, unresolved_windows = outcome.unresolved_windows, + under_commit_escalations = outcome.under_commit_escalations, "Apple progressive live session finished" ); + sealed_spans = outcome.sealed_spans; } Ok(Err(e)) => { warn!("Apple live stream worker failed: {e:#}"); @@ -402,10 +625,28 @@ pub(crate) async fn apple_stream_transcription_session( } } + log_tail_patch_session_receipt(tail_patch_lane.replacements(), tail_patch_lane.skipped()); + let mut live_cloud_patches = 0u64; + if let Some(candidate) = layer1_candidate { + for event in plan_live_layer1_gap_patches(&sealed_spans, &candidate) { + event_sink.on_event(&event); + live_cloud_patches = live_cloud_patches.saturating_add(1); + } + info!( + provider_chars = candidate.chars().count(), + live_cloud_patches, "Live cloud Layer 1 reconciled against committed Apple floor" + ); + } + emit_capture_level_receipt( + event_sink.as_ref(), + &capture_level.finalize(CapturePathMeta::resolve(sample_rate, 1, None)), + ); emit_session_finalised( event_sink.as_ref(), session_id, - tail_patch_lane.replacements(), + tail_patch_lane + .replacements() + .saturating_add(live_cloud_patches), ); } @@ -423,10 +664,14 @@ struct PendingAppleSeal { } struct AppleSealState { + session_id: String, + capture_epoch: u64, + sample_rate: u32, postprocessor: StreamPostProcessor, preview_rev: u64, utterance_id: u64, open_partial: String, + open_partial_segments: Vec, sealed_count: u64, filtered_empty_drops: u64, /// Bounded PCM retention, so a sealed boundary can be resolved back to the @@ -435,12 +680,26 @@ struct AppleSealState { /// Session time of the previous seal — the lower bound of the next /// utterance's audio window. last_sealed_end: f32, + /// End of the last Apple segment admitted to committed canvas. Unlike the + /// PCM retention cursor, this advances even when Layer 1 audio lookup is + /// unavailable: Apple segment time is the authority for text disjointness. + last_apple_segment_end: f32, /// Seals whose audio window could not be resolved (F3 falsification). unresolved_windows: u64, + /// Seals where Layer 1 recovered speech it could not place on the canvas + /// (W-C). A non-zero count means the stop path is owed a residual gap fill. + under_commit_escalations: u64, /// Layer 1 hand-off, present only when layered transcription is armed. tail_patch: Option>, + /// Sealed fragments waiting to share one Whisper window (~5 segments). + layer1_coalesce: Layer1Coalesce, /// Seals whose tail-patch request found the queue full (F1 backpressure). tail_patch_backpressure_drops: u64, + /// Requests accepted by the Layer 1 queue that have not reported back yet. + /// This — not the pending-seal queue — is what the end-of-session closure + /// loop waits on: a span can also be held by the Apple volatile window, and + /// no Whisper completion will ever clear that gate. + tail_patch_awaiting_completion: u64, /// Concatenation of already progressive-sealed text — left context for /// Light+ casing on the next seal (w2-b). sealed_prefix: String, @@ -450,34 +709,60 @@ struct AppleSealState { pending_events: BTreeMap, /// Whisper outcomes retained until their final has been emitted. tail_patch_outcomes: BTreeMap, + /// The session's single Silero: Supervisor VAD + utterance ledger. `None` + /// only when neither consumer wants it, or when the model failed to load. + fusion: Option, + /// Whether Silero identity may reach the seal (`CODESCRIBE_SILERO_FUSION`, + /// default ON). Independent of [`Self::fusion`] existing: the engine + /// lifecycle needs the VAD even when an operator has pinned the seal path + /// back to Apple's own segment boundaries. + fusion_seal_armed: bool, + fusion_context: FusionContextMode, } impl AppleSealState { /// Fresh seal state with Layer 1 disabled (`tail_patch: None`) — the default /// shape when `CODESCRIBE_LAYERED_TRANSCRIPTION` is unset. + #[cfg(test)] fn new(sample_rate: u32) -> Self { + Self::new_for_session(sample_rate, uuid::Uuid::new_v4().to_string()) + } + + fn new_for_session(sample_rate: u32, session_id: String) -> Self { Self { + session_id, + capture_epoch: 0, + sample_rate, postprocessor: StreamPostProcessor::new(), preview_rev: 0, utterance_id: 0, open_partial: String::new(), + open_partial_segments: Vec::new(), sealed_count: 0, filtered_empty_drops: 0, audio: LiveAudioBuffer::new(sample_rate, DEFAULT_RETENTION_SECS), last_sealed_end: 0.0, + last_apple_segment_end: 0.0, unresolved_windows: 0, + under_commit_escalations: 0, tail_patch: None, + layer1_coalesce: Layer1Coalesce::default(), tail_patch_backpressure_drops: 0, + tail_patch_awaiting_completion: 0, sealed_prefix: String::new(), progressive: ProgressiveSealMachine::new(), pending_events: BTreeMap::new(), tail_patch_outcomes: BTreeMap::new(), + fusion: None, + fusion_seal_armed: false, + fusion_context: FusionContextMode::UtteranceOnly, } } /// Same state, armed with the Layer 1 hand-off. Holding the sender is what /// makes `seal_utterance_final` clone the committed text at all — with no /// wire there is nothing to diff against later. + #[cfg(test)] fn new_with_tail_patch(sample_rate: u32, tail_patch: mpsc::Sender) -> Self { Self { tail_patch: Some(tail_patch), @@ -485,6 +770,88 @@ impl AppleSealState { } } + fn enqueue_layer1_piece(&mut self, piece: CoalescedPiece) -> bool { + if self.tail_patch.is_none() { + return false; + } + if self.layer1_coalesce.is_empty() { + self.layer1_coalesce + .set_neighbour(self.sealed_prefix.clone()); + } + let flushes = self.layer1_coalesce.push(piece, self.sample_rate); + if flushes.is_empty() { + // Held for a larger window. Still counts as queued so the + // no-Whisper fallback does not seal the fragment raw. + return true; + } + let mut sent = false; + for flush in flushes { + sent |= self.queue_layer1_flush(flush); + } + sent + } + + fn flush_layer1_coalesce(&mut self) -> bool { + self.layer1_coalesce + .force_flush() + .is_some_and(|flush| self.queue_layer1_flush(flush)) + } + + fn queue_layer1_flush(&mut self, flush: CoalesceFlush) -> bool { + let Some(tx) = self.tail_patch.as_ref() else { + return false; + }; + let provider_request = TailProviderRequest { + identity: TailRequestIdentity { + request_id: flush.primary_utterance_id, + range: TailSampleRange { + session: self.session_id.clone(), + capture_epoch: self.capture_epoch, + sample_start: flush.sample_start, + sample_end: flush.sample_end, + }, + }, + sample_rate: self.sample_rate, + language: None, + }; + match tx.try_send(TailPatchRequest { + utterance_id: flush.primary_utterance_id, + committed_text: flush.committed_text, + neighbour_context: flush.neighbour_context, + audio: flush.audio, + provider_request, + covered_through_secs: flush.covered_through_secs, + span_map: flush.spans, + member_ids: flush.member_ids, + }) { + Ok(()) => { + self.tail_patch_awaiting_completion = + self.tail_patch_awaiting_completion.saturating_add(1); + true + } + Err(error) => { + self.tail_patch_backpressure_drops = + self.tail_patch_backpressure_drops.saturating_add(1); + warn!( + utterance_id = flush.primary_utterance_id, + "Layer 1 tail-patch request dropped — queue full or lane gone: {error}" + ); + false + } + } + } + + fn new_with_tail_patch_for_session( + sample_rate: u32, + session_id: String, + tail_patch: mpsc::Sender, + ) -> Self { + Self { + tail_patch: Some(tail_patch), + ..Self::new_for_session(sample_rate, session_id) + } + } + /// Apply one elapsed Whisper window, then emit every newly double-closed /// span. Finals and their bounded patches share `ev_tx`, so ordering cannot /// invert on the async side. @@ -495,11 +862,75 @@ impl AppleSealState { now_secs: f32, ) { let utterance_id = completion.utterance_id; - self.tail_patch_outcomes - .insert(utterance_id, completion.outcome); - self.progressive - .note_whisper_window_elapsed(utterance_id, completion.covered_through_secs); + self.tail_patch_awaiting_completion = self.tail_patch_awaiting_completion.saturating_sub(1); + let (evidence, words) = completion.payload.map_or((None, Vec::new()), |payload| { + (Some(payload.evidence), payload.segments) + }); + // Coalesced jobs already ran the concat tail-patch. Fusion looks up + // the last piece on the session clock vs concat-PCM Whisper times and + // would return NoChange, dropping the joined rewrite (live 2026-08-19). + let coalesced_window = completion.span_map.len() > 1 || completion.member_ids.len() > 1; + let outcome = if coalesced_window { + completion.outcome + } else if self.fusion.is_some() { + apply_conservative_fusion(self, ev_tx, utterance_id, &words, completion.outcome) + } else { + completion.outcome + }; + let member_ids = if completion.member_ids.is_empty() { + vec![(utterance_id, completion.covered_through_secs)] + } else { + completion.member_ids + }; + let split = split_outcome_for_members(outcome, &completion.span_map, &member_ids); + for (index, (id, end, member_outcome)) in split.into_iter().enumerate() { + self.tail_patch_outcomes.insert(id, member_outcome); + if index == 0 { + self.progressive + .note_whisper_window_elapsed_with_provenance( + id, + end, + evidence.clone(), + words.clone(), + ); + } else { + self.progressive.note_whisper_window_elapsed(id, end); + } + } self.emit_ready_progressive_seals(ev_tx, now_secs); + // A window that finishes AFTER its span sealed had no reader: the only + // drain of `tail_patch_outcomes` runs inside the seal tick, so a patch + // arriving even a millisecond late sat in the map until the session + // dropped it. Measured 2026-08-14 on the operator's take: the patcher + // logged two `residual_required` recoveries, the session counted + // `under_commit_escalations=0`, and zero warnings reached the UI — the + // recovered speech was computed, stored, and never delivered. Ordering + // is unchanged for the normal case (still emitted after `UtteranceFinal`, + // which the seal already sent). + for (id, _) in &member_ids { + self.deliver_sealed_tail_patch(ev_tx, *id); + } + } + + /// Deliver a tail-patch outcome whose span is already sealed and emitted. + /// No-op while the span is still pending — the seal tick owns that path. + fn deliver_sealed_tail_patch( + &mut self, + ev_tx: &mpsc::UnboundedSender, + utterance_id: u64, + ) { + if !self + .progressive + .sealed_spans() + .iter() + .any(|span| span.id == utterance_id) + { + return; + } + let Some(outcome) = self.tail_patch_outcomes.remove(&utterance_id) else { + return; + }; + self.emit_tail_patch_outcome(ev_tx, utterance_id, outcome); } fn emit_ready_progressive_seals( @@ -508,6 +939,21 @@ impl AppleSealState { now_secs: f32, ) { let tick = self.progressive.try_seal(now_secs, false); + self.emit_seal_tick(ev_tx, tick); + } + + /// End-of-session drain: seal whatever the double-close gates still hold. + /// + /// Shares the emit path with the live tick on purpose — a span sealed at + /// session end must reach the sink as the same `UtteranceFinal` (+ patches) + /// a mid-session seal would, or the last utterance of every take would be + /// delivered by a different route than all the others. + fn seal_remaining_at_session_end(&mut self, ev_tx: &mpsc::UnboundedSender) { + let tick = self.progressive.seal_remaining_at_session_end(false); + self.emit_seal_tick(ev_tx, tick); + } + + fn emit_seal_tick(&mut self, ev_tx: &mpsc::UnboundedSender, tick: SealTick) { for sealed in tick.newly_sealed { let Some(pending) = self.pending_events.remove(&sealed.id) else { warn!( @@ -518,28 +964,58 @@ impl AppleSealState { }; self.sealed_count = self.sealed_count.saturating_add(1); self.sealed_prefix = self.progressive.sealed_prefix(); + // Seal = "format now" signal (W13-1): a sealed span is byte-stable, + // so the inline-format buffer may chunk-format it while dictation + // continues. Sync + non-blocking; no-op unless the flag is armed. + crate::llm::inline_format::on_chunk_sealed(sealed.id, &sealed.text); + let segments = if sealed.words.is_empty() { + pending.segments + } else { + timed_words_to_segments(&sealed.words, self.sample_rate) + }; let _ = ev_tx.send(EngineEvent::UtteranceFinal { utterance_id: sealed.id, text: sealed.text, raw_text: pending.raw_text, start_ts: pending.start_ts, end_ts: pending.end_ts, - segments: pending.segments, + segments, vad_speech_pct: None, avg_logprob: None, compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), }); - if let Some(TailPatchOutcome::Patches(events)) = - self.tail_patch_outcomes.remove(&sealed.id) - { - for event in events { - let _ = ev_tx.send(event); - } + if let Some(outcome) = self.tail_patch_outcomes.remove(&sealed.id) { + self.emit_tail_patch_outcome(ev_tx, sealed.id, outcome); } } } + + /// Send one Layer 1 outcome for an already-emitted `UtteranceFinal`. + /// + /// Shared by the seal tick and the late-completion path so a recovery is + /// delivered identically whichever side wins the race. + fn emit_tail_patch_outcome( + &mut self, + ev_tx: &mpsc::UnboundedSender, + utterance_id: u64, + outcome: TailPatchOutcome, + ) { + // Escalate before the appends so the starved-canvas signal is never + // lost if a later send fails; both ride the same channel as the final, + // so ordering after `UtteranceFinal` holds. + if outcome.residual_required() { + self.under_commit_escalations = self.under_commit_escalations.saturating_add(1); + let _ = ev_tx.send(EngineEvent::Warning { + code: UNDER_COMMIT_WARNING_CODE.to_string(), + message: format!("residual gap fill required for utterance {utterance_id}"), + }); + } + for event in outcome.into_events() { + let _ = ev_tx.send(event); + } + } } /// What the worker sealed, and what seal-time postprocess filtered away. @@ -547,6 +1023,167 @@ struct AppleStreamOutcome { sealed: u64, filtered_empty_drops: u64, unresolved_windows: u64, + /// How many seals escalated an unplaceable Layer 1 under-commit (W-C). + under_commit_escalations: u64, + sealed_spans: Vec, +} + +#[derive(Debug)] +struct LivePatchToken { + utterance_id: u64, + start: usize, + end: usize, +} + +/// Convert provider-neutral Layer 1 gap-fill into existing bounded utterance +/// patches. The merge first preserves every Apple token; only tokens present +/// in the merged result but absent from that floor become zero-width inserts. +fn plan_live_layer1_gap_patches(spans: &[SealedSpan], candidate: &str) -> Vec { + if spans.is_empty() || candidate.trim().is_empty() { + return Vec::new(); + } + let live = spans + .iter() + .map(|span| span.text.trim()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "); + let merged = crate::quality::merge_live_layer1(&live, candidate); + if merged.provider_fill_tokens == 0 { + return Vec::new(); + } + + let live_tokens = crate::quality::teacher::tokenize(&live); + let merged_tokens = crate::quality::teacher::tokenize(&merged.text); + let mapped = mapped_live_tokens(spans); + if mapped.len() != live_tokens.len() { + warn!( + mapped_tokens = mapped.len(), + live_tokens = live_tokens.len(), + "Live cloud gap planner refused inconsistent span token map" + ); + return Vec::new(); + } + let ops = crate::quality::teacher::align_words(&live_tokens, &merged_tokens); + let mut patches = Vec::new(); + let mut previous_live: Option = None; + let mut index = 0usize; + while index < ops.len() { + match &ops[index] { + crate::quality::teacher::AlignOp::InsertB { .. } => { + let start = index; + while matches!( + ops.get(index), + Some(crate::quality::teacher::AlignOp::InsertB { .. }) + ) { + index += 1; + } + let words = ops[start..index] + .iter() + .filter_map(|op| match op { + crate::quality::teacher::AlignOp::InsertB { b } => { + Some(merged_tokens[*b].surface.as_str()) + } + _ => None, + }) + .collect::>() + .join(" "); + let next_live = ops[index..].iter().find_map(live_op_index); + if let Some(previous) = previous_live.and_then(|idx| mapped.get(idx)) { + patches.push(EngineEvent::ReplaceRange { + utterance_id: previous.utterance_id, + start: previous.end, + end: previous.end, + text: format!(" {words}"), + source: LayerSource::TailPatch, + }); + } else if let Some(next) = next_live.and_then(|idx| mapped.get(idx)) { + patches.push(EngineEvent::ReplaceRange { + utterance_id: next.utterance_id, + start: next.start, + end: next.start, + text: format!("{words} "), + source: LayerSource::TailPatch, + }); + } + } + crate::quality::teacher::AlignOp::Substitute { a, b } => { + if let Some(live_token) = mapped.get(*a) { + patches.push(EngineEvent::ReplaceRange { + utterance_id: live_token.utterance_id, + start: live_token.start, + end: live_token.end, + text: merged_tokens[*b].surface.clone(), + source: LayerSource::TailPatch, + }); + } + previous_live = Some(*a); + index += 1; + } + op => { + previous_live = live_op_index(op).or(previous_live); + index += 1; + } + } + } + + // Multiple inserts into one utterance use offsets from the same immutable + // Apple text. Apply right-to-left so an earlier insertion cannot shift a + // later one's char boundary. + patches.sort_by(|left, right| { + patch_position(right) + .cmp(&patch_position(left)) + .then_with(|| patch_utterance(right).cmp(&patch_utterance(left))) + }); + patches +} + +fn live_op_index(op: &crate::quality::teacher::AlignOp) -> Option { + match op { + crate::quality::teacher::AlignOp::Equal { a, .. } + | crate::quality::teacher::AlignOp::DeleteA { a } + | crate::quality::teacher::AlignOp::Substitute { a, .. } => Some(*a), + crate::quality::teacher::AlignOp::InsertB { .. } => None, + } +} + +fn mapped_live_tokens(spans: &[SealedSpan]) -> Vec { + let mut mapped = Vec::new(); + for span in spans { + let chars = span.text.chars().collect::>(); + let mut cursor = 0usize; + while cursor < chars.len() { + while cursor < chars.len() && chars[cursor].is_whitespace() { + cursor += 1; + } + let start = cursor; + while cursor < chars.len() && !chars[cursor].is_whitespace() { + cursor += 1; + } + if start < cursor { + mapped.push(LivePatchToken { + utterance_id: span.id, + start, + end: cursor, + }); + } + } + } + mapped +} + +fn patch_position(event: &EngineEvent) -> usize { + match event { + EngineEvent::ReplaceRange { start, .. } => *start, + _ => 0, + } +} + +fn patch_utterance(event: &EngineEvent) -> u64 { + match event { + EngineEvent::ReplaceRange { utterance_id, .. } => *utterance_id, + _ => 0, + } } /// Resolve a sealed utterance back to its audio span, then release what can @@ -558,18 +1195,58 @@ struct AppleStreamOutcome { /// older than the retention cap — that must be visible here, in the live path. /// A silent miss would surface as canvas patched from the wrong audio, so an /// unresolved boundary yields `None` and never reaches Layer 1. -fn resolve_sealed_audio_window(state: &mut AppleSealState, end_ts: f32) -> Option> { - let from = state.last_sealed_end; - match state.audio.window(from, end_ts) { +fn resolve_sealed_audio_window( + state: &mut AppleSealState, + end_ts: f32, +) -> Option { + let mut from = state.last_sealed_end; + // A `from` that fell off retention is not a disagreeing clock — that audio + // is gone because SFSpeech withheld its first final past the retention + // horizon (measured 2026-08-14: a 247 s take whose first final arrived at + // 156 s went 11/11 unresolved and starved Layer 1 for the WHOLE take, + // because one miss keeps `last_sealed_end` pinned forever). Clamp the + // start to retained audio; genuine clock lies (an `end_ts` that itself + // precedes retention or overshoots the session) stay fail-closed below. + let retained_start = state.audio.retained_start_secs(); + if from < retained_start && end_ts > retained_start { + warn!( + from_secs = from, + retained_start_secs = retained_start, + end_ts, + "Apple seal window start fell off retention — clamped to retained audio" + ); + from = retained_start; + } + match state.audio.window_with_range(from, end_ts) { Some(window) => { - state.last_sealed_end = end_ts; + // `window_with_range` is the ingestion boundary where Apple's + // floating span clock becomes the canonical integer PCM clock. A + // small Apple overshoot is intentionally clamped there; carrying + // the *requested* `end_ts` forward would make the next window + // start beyond captured audio even though this window resolved. + let pcm_start_secs = window.sample_start as f32 / state.sample_rate.max(1) as f32; + let pcm_end_secs = window.sample_end as f32 / state.sample_rate.max(1) as f32; + state.last_sealed_end = pcm_end_secs; // Everything before this utterance is committed canvas; no future // patch reaches back past it. - state.audio.committed_through(from); + state.audio.committed_through(pcm_start_secs); + if window.samples.is_empty() { + // A cumulative final may assert novel text after the PCM clock + // has reached EOF. Content still seals, but zero samples are + // not a Whisper window and this known clamp is not a clock lie. + tracing::debug!( + from_secs = from, + requested_end_secs = end_ts, + pcm_end_secs, + "Apple seal resolved at PCM boundary with no new audio" + ); + return None; + } tracing::debug!( from_secs = from, - end_ts, - window_samples = window.len(), + requested_end_secs = end_ts, + pcm_end_secs, + window_samples = window.samples.len(), retained_samples = state.audio.len(), "Apple seal resolved to audio window" ); @@ -589,30 +1266,572 @@ fn resolve_sealed_audio_window(state: &mut AppleSealState, end_ts: f32) -> Optio } } -/// Seal one Apple utterance: run the shared lexicon + cleanup pass, then emit -/// `UtteranceFinal`. Returns `false` when postprocess filtered the text to -/// empty — mirroring `PostprocessDrop::FilteredEmpty` on the VAD path, an -/// explicit `Drop` event is emitted instead of an empty final. +fn seconds_to_captured_sample(seconds: f32, sample_rate: u32, captured_end: u64) -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + ((seconds as f64 * sample_rate.max(1) as f64).round() as u64).min(captured_end) +} + +fn timed_words_to_segments(words: &[TimedTailSegment], sample_rate: u32) -> Vec { + let rate = sample_rate.max(1) as f32; + words + .iter() + .filter(|word| { + word.range.sample_end > word.range.sample_start && !word.text.trim().is_empty() + }) + .map(|word| TranscriptSegment { + text: word.text.clone(), + start_ts: word.range.sample_start as f32 / rate, + end_ts: word.range.sample_end as f32 / rate, + }) + .collect() +} + +fn apple_segments_on_pcm_clock( + state: &AppleSealState, + segments: &[TranscriptSegment], +) -> Vec { + let captured_end = state.audio.session_sample_end(); + segments + .iter() + .map(|segment| { + let sample_start = + seconds_to_captured_sample(segment.start_ts, state.sample_rate, captured_end); + let sample_end = + seconds_to_captured_sample(segment.end_ts, state.sample_rate, captured_end) + .max(sample_start); + TimedTailSegment { + text: segment.text.clone(), + range: TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start, + sample_end, + }, + } + }) + .collect() +} + +/// Longest callback prefix the canvas already carries, tolerating the word +/// revisions SFSpeech makes when it re-states a phrase. /// -/// `raw_text` keeps the uncorrected engine output so the quality loop can see -/// exactly what the lexicon rewrote (same contract as the VAD path). -fn seal_utterance_final( +/// # Why exact substring matching was the repetition defect +/// +/// Cumulative Apple finals do not merely extend the previous hypothesis — they +/// REVISE it ("szuty" → "skróty", "dokładnie" → "dokładność"). An exact +/// `canvas.contains(prefix)` probe is anchored at the callback's first word and +/// all-or-nothing, so one revised word anywhere in the prefix invalidates every +/// probe length at once and the whole restatement re-commits as "novel". +/// Measured on the 2026-08-12 18:44 take: 30 of 42 rescues matched exactly one +/// word, the delivery carried 72% of its words inside a repeated 6-gram, and +/// the production replay of the same WAV reproduced full-sentence re-commits +/// differing by a single word ([28]/[29]/[30] in the replay finals). +/// +/// # Match rule +/// +/// For the longest `k`, some canvas window must be within `allowed(k)` word +/// edits (substitution, insertion, deletion) of `probe[..k]`, where `allowed` +/// is 0 for `k ≤ 2` and `max(1, k/5)` (20%) beyond that. Edit distance rather +/// than positional comparison on purpose: revisions include insertions and +/// deletions ("spotkałem się" → "się", an interjected "a"), and under a +/// positional rule one inserted word shifts every later word and cascades into +/// wholesale mismatch — the verified replay showed 15–22-word restatements +/// collapsing to a 6-word match exactly this way. Short probes stay exact: at +/// one or two words a tolerated edit is not a revision, it is a different word. +/// +/// One asymmetry is deliberate: the LAST word of the matched prefix must itself +/// align to a canvas word (match or substitution). Otherwise a trailing novel +/// word could be "deleted into" the match — "alpha beta revised" against a +/// canvas holding "alpha beta" is one deletion away as a whole, and treating +/// that as re-heard would demote genuinely new tail speech to the preview lane. +/// A trailing deletion therefore shortens `k` instead of costing an edit. +/// +/// Only the canvas tail (`2 × probe len + 16` words) is searched — a +/// restatement re-states recent speech, and the bound keeps the DP cost flat +/// no matter how long the session canvas grows. +/// +/// # Why fuzziness is safe in this branch +/// +/// This runs only for finals whose every segment was consumed by the trusted +/// timing boundary — Apple itself asserts the audio was already judged. Genuine +/// new speech arrives with fresh segment timestamps and never enters here, so a +/// near-match against the canvas is a re-hearing, not the operator saying a +/// similar sentence twice. +/// +/// Returns `(known_prefix_words, word_edits_in_the_match)`. +fn revision_tolerant_known_prefix(probe: &[String], canvas: &[&str]) -> (usize, usize) { + if probe.is_empty() || canvas.is_empty() { + return (0, 0); + } + let n = probe.len(); + let band = (n / 5).max(1); + let tail_start = canvas.len().saturating_sub(2 * n + 16); + let tail = &canvas[tail_start..]; + + let allowed = |k: usize| if k <= 2 { 0 } else { (k / 5).max(1) }; + let mut best_k = 0usize; + let mut best_edits = 0usize; + + // One banded edit-distance DP per window start: row `i` covers probe[..i], + // column `j` the window tail[s..s+j]. For every prefix length the cheapest + // window end is `min over j`, so a single pass scores all `k` at once. + for s in 0..tail.len() { + let jmax = (tail.len() - s).min(n + band); + let mut prev: Vec = (0..=jmax).collect(); + for i in 1..=n { + let mut current = vec![usize::MAX; jmax + 1]; + current[0] = i; + // Best score whose final operation aligns probe[i-1] to a canvas + // word — the only endings that may close a matched prefix (see the + // trailing-deletion note in the doc comment). + let mut aligned_end = usize::MAX; + for j in 1..=jmax { + // Outside the band the distance already exceeds every budget. + if i.abs_diff(j) > band { + continue; + } + let substitute = if probe[i - 1] == tail[s + j - 1] { + prev[j - 1] + } else { + prev[j - 1].saturating_add(1) + }; + aligned_end = aligned_end.min(substitute); + let delete = prev[j].saturating_add(1); + let insert = current[j - 1].saturating_add(1); + current[j] = substitute.min(delete).min(insert); + } + let edits = aligned_end; + if edits <= allowed(i) && (i > best_k || (i == best_k && edits < best_edits)) { + best_k = i; + best_edits = edits; + } + prev = current; + } + } + (best_k, best_edits) +} + +/// Case- and punctuation-insensitive projection for canvas containment checks +/// (the sealed canvas carries Light+ casing and sentence terminals, raw +/// callbacks carry neither). +fn normalize_for_containment(text: &str) -> String { + text.chars() + .map(|c| { + if c.is_alphanumeric() { + c.to_lowercase().next().unwrap_or(c) + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Fuse Whisper words onto the pending Apple span through the rewrite fence. +/// +/// Agreements and clear gap fills become the pending text. Unresolved +/// alternatives stay on Apple and emit a content-free receipt. The LCS +/// `ReplaceRange` outcome is dropped: the fused text is already in the span. +fn apply_conservative_fusion( state: &mut AppleSealState, ev_tx: &mpsc::UnboundedSender, - raw: &str, + utterance_id: u64, + whisper_words: &[TimedTailSegment], + fallback: TailPatchOutcome, +) -> TailPatchOutcome { + let apple_words: Vec = state + .progressive + .pending_spans() + .iter() + .find(|span| span.id == utterance_id) + .map(|span| span.words.iter().map(FusionWord::from_timed).collect()) + .unwrap_or_default(); + let whisper: Vec = whisper_words.iter().map(FusionWord::from_timed).collect(); + if apple_words.is_empty() && whisper.is_empty() { + return fallback; + } + let decision = conservative_fuse(&apple_words, &whisper); + if !decision.unresolved.is_empty() { + // An unresolved fusion verdict means the rewrite text intentionally + // kept Apple's shorter alternative. Consuming the fallback here used + // to erase already-computed, safely anchored gap appends — exactly the + // first-utterance loss visible in the operator's local take. Keep the + // pending Apple span immutable and let the bounded patch lane land. + let receipt = fusion_receipt(utterance_id, &decision); + let _ = ev_tx.send(EngineEvent::Warning { + code: receipt.code.as_str().to_string(), + message: format!( + "fusion unresolved={} agreements={} gap_fills={}; bounded fallback retained", + receipt.unresolved, receipt.agreements, receipt.gap_fills + ), + }); + return fallback; + } + if !state.progressive.try_rewrite(utterance_id, &decision.text) { + // The span sealed before fusion could rewrite it. That is a refusal of + // THIS route, not a verdict on the recovery: Layer 1 already computed + // bounded, append-only patches for the same audio, and they remain + // valid against sealed text. Returning `Skipped` here discarded them — + // measured 2026-08-14 on the operator's take, where the patcher logged + // two `residual_required` recoveries and the session delivered zero. + // Hand the fallback back instead: fusion loses the race, the append + // lane still lands. + let _ = ev_tx.send(EngineEvent::Warning { + code: SkipReasonCode::SealedFence.as_str().to_string(), + message: format!( + "fusion rewrite refused for utterance {utterance_id}; \ + falling back to bounded tail patches" + ), + }); + return fallback; + } + TailPatchOutcome::NoChange +} + +/// Slice a cumulative Apple final onto Silero-minted utterance ranges. +/// +/// Returns `true` when at least one Silero span accepted words (the callback +/// is consumed). `false` leaves the caller on the Apple-boundary path so +/// speech is never dropped when Silero has not yet opened an edge. +fn seal_sliced_by_silero( + state: &mut AppleSealState, + ev_tx: &mpsc::UnboundedSender, + raw_text: &str, + after_lexicon: &str, start_ts: f32, end_ts: f32, - segments: Vec, + disjoint: &[TranscriptSegment], ) -> bool { - let raw_text = raw.trim().to_string(); - if raw_text.is_empty() { + let Some(ledger) = state.fusion.as_ref().map(|fusion| fusion.ledger().clone()) else { + return false; + }; + if ledger.utterances().is_empty() { + return false; + } + let apple_words = apple_segments_on_pcm_clock(state, disjoint); + let fusion_words: Vec = apple_words.iter().map(FusionWord::from_timed).collect(); + let (sliced, leftover) = slice_apple_words(&ledger, &fusion_words); + if sliced.is_empty() { + if !leftover.is_empty() { + let _ = ev_tx.send(EngineEvent::Warning { + code: SkipReasonCode::NoTimeOverlap.as_str().to_string(), + message: format!( + "apple words={} had no Silero utterance overlap", + leftover.len() + ), + }); + } return false; } + if !leftover.is_empty() { + let _ = ev_tx.send(EngineEvent::Warning { + code: SkipReasonCode::NoTimeOverlap.as_str().to_string(), + message: format!( + "apple leftover_words={} sliced_utterances={}", + leftover.len(), + sliced.len() + ), + }); + } + + let rate = state.sample_rate.max(1) as f32; + let pad_samples = (super::silero_fusion::DEFAULT_LEFT_PAD_SECS * rate).round() as u64; + let long_silence = (super::silero_fusion::LONG_SILENCE_FENCE_SECS * rate).round() as u64; + let context = state.fusion_context; + + for (utterance_id, words) in sliced { + let Some(silero) = ledger + .utterances() + .iter() + .find(|utterance| utterance.id == utterance_id) + .cloned() + else { + continue; + }; + if !state.progressive.may_rewrite(utterance_id) + && state + .progressive + .sealed_spans() + .iter() + .any(|span| span.id == utterance_id) + { + continue; + } + let text = words + .iter() + .map(|word| word.text.as_str()) + .collect::>() + .join(" "); + let text = if text.trim().is_empty() { + after_lexicon.to_string() + } else { + text + }; + let span_start = words + .first() + .map(|word| word.sample_start as f32 / rate) + .unwrap_or(start_ts); + let span_end = words + .last() + .map(|word| word.sample_end as f32 / rate) + .unwrap_or(end_ts); + let timed: Vec = words + .iter() + .map(|word| TimedTailSegment { + text: word.text.clone(), + range: TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start: word.sample_start, + sample_end: word.sample_end, + }, + }) + .collect(); + if state + .progressive + .pending_spans() + .iter() + .any(|p| p.id == utterance_id) + { + let _ = state.progressive.try_rewrite(utterance_id, &text); + } else { + if !state.progressive.note_apple_commit_timed(AppleCommit { + id: utterance_id, + raw_text: text.clone(), + end_secs: span_end, + committed_at_secs: span_end, + // The span IS the Silero utterance here: identity and range + // both come from the edge, not from Apple's segment clock. + range: silero.range.clone(), + words: timed, + apple_evidence: TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: None, + }, + silero_utterance_id: Some(utterance_id), + }) { + continue; + } + state.pending_events.insert( + utterance_id, + PendingAppleSeal { + raw_text: raw_text.to_string(), + start_ts: span_start, + end_ts: span_end, + segments: disjoint.to_vec(), + }, + ); + } + + let fence = ledger + .utterances() + .iter() + .rev() + .find(|prev| prev.closed && prev.range.sample_end <= silero.range.sample_start) + .map(|prev| { + let gap = silero + .range + .sample_start + .saturating_sub(prev.range.sample_end); + if gap >= long_silence { + silero.range.sample_start + } else { + 0 + } + }) + .unwrap_or(0); + let request_range = bound_context_range(&silero.range, fence, context, pad_samples); + let window = state + .audio + .window_by_samples(request_range.sample_start, request_range.sample_end); + let queued = if let Some(window) = window { + if state.tail_patch.is_some() { + let committed_text = seal_span_text(&text, &state.sealed_prefix, false); + state.enqueue_layer1_piece(CoalescedPiece { + utterance_id, + committed_text, + audio: window.samples, + sample_start: window.sample_start, + sample_end: window.sample_end, + start_ts: span_start, + covered_through_secs: span_end, + segment_count: disjoint.len().max(1), + }) + } else { + false + } + } else { + false + }; + if !queued { + state + .progressive + .note_whisper_window_elapsed(utterance_id, span_end); + state.emit_ready_progressive_seals( + ev_tx, + span_end + super::progressive_seal::APPLE_VOLATILE_WINDOW_SECS + 0.001, + ); + } + state.utterance_id = state.utterance_id.max(utterance_id); + } + true +} + +/// Seal one Apple utterance: run the shared lexicon + cleanup pass, then emit +/// `UtteranceFinal`. Returns `false` when postprocess filtered the text to +/// empty — mirroring `PostprocessDrop::FilteredEmpty` on the VAD path, an +/// explicit `Drop` event is emitted instead of an empty final. +/// +/// `raw_text` keeps the uncorrected engine output so the quality loop can see +/// exactly what the lexicon rewrote (same contract as the VAD path). +fn seal_utterance_final( + state: &mut AppleSealState, + ev_tx: &mpsc::UnboundedSender, + raw: &str, + segments: Vec, + audio_secs: f32, +) -> bool { + const BOUNDARY_EPSILON_SECS: f32 = 0.002; + + let callback_text = raw.trim().to_string(); + let original_segment_count = segments.len(); + let mut disjoint = Vec::with_capacity(original_segment_count); + let mut cursor = state.last_apple_segment_end; + let mut overlap_normalized = false; + + for mut segment in segments { + let text = segment.text.trim(); + if text.is_empty() + || !segment.start_ts.is_finite() + || !segment.end_ts.is_finite() + || segment.end_ts <= segment.start_ts + { + continue; + } + if segment.end_ts <= cursor + BOUNDARY_EPSILON_SECS + || segment.start_ts < cursor - BOUNDARY_EPSILON_SECS + { + overlap_normalized = true; + continue; + } + if segment.start_ts < cursor { + segment.start_ts = cursor; + } + segment.text = text.to_string(); + cursor = segment.end_ts; + disjoint.push(segment); + } + + if overlap_normalized { + let _ = ev_tx.send(EngineEvent::Warning { + code: APPLE_FINAL_OVERLAP_WARNING_CODE.to_string(), + message: "Apple final overlap removed at segment boundary".to_string(), + }); + } + + if disjoint.is_empty() { + if callback_text.is_empty() { + return false; + } + // Append doctrine: text Apple asserted must never die in the preview + // lane — the next partial replaces `open_partial` wholesale and the + // only copy is gone (session a5623d55, 2026-08-12). The trusted + // timing boundary exists to dedupe RE-HEARD text, so demotion is only + // legal for text already on the canvas. Cumulative callbacks re-state + // the whole phrase, so the longest canvas-known prefix splits off and + // only the NOVEL suffix commits, with a session-clock window (the + // fallback the doc header always promised for segment-less finals). + let mut canvas = state.progressive.sealed_prefix(); + for span in state.progressive.pending_spans() { + canvas.push(' '); + canvas.push_str(&span.raw_text); + } + let canvas = normalize_for_containment(&canvas); + let words: Vec<&str> = callback_text.split_whitespace().collect(); + // Each probe word runs the lexicon, because the canvas already has: + // `PendingSpan::raw_text` is `process_utterance` output (lexicon first) + // and `sealed_prefix()` is `seal_span_text` output. Probing raw words + // compares "doker" with "docker" and mismatches at every rewrite — + // f8519df2 shipped exactly that and re-committed whole phrases; + // `cumulative_final_prefix_survives_words_the_lexicon_rewrites` pins it. + let probe_words: Vec = words + .iter() + .map(|word| normalize_for_containment(&seal_span_text(word, "", true))) + .collect(); + let canvas_words: Vec<&str> = canvas.split_whitespace().collect(); + let (known_prefix_words, revised_words) = + revision_tolerant_known_prefix(&probe_words, &canvas_words); + if revised_words > 0 { + info!( + known_prefix_words, + revised_words, + callback_words = words.len(), + "apple_lifecycle: restated prefix matched through engine revisions" + ); + } + let novel_text = words[known_prefix_words..].join(" "); + if novel_text.is_empty() { + // Fully re-heard text has no volatile tail. Keeping the cumulative + // callback as Preview makes session renderers show + // `committed canvas + restatement`; on the Apple path that duplicate + // survived into the stop-time delivery buffer because the session + // closes with `SessionFinalised`, not `Stats`. + state.open_partial.clear(); + state.open_partial_segments.clear(); + state.preview_rev = state.preview_rev.saturating_add(1); + let _ = ev_tx.send(EngineEvent::Preview { + rev: state.preview_rev, + text: String::new(), + }); + return false; + } + let start_ts = state.last_apple_segment_end.max(state.last_sealed_end); + let end_ts = audio_secs.max(start_ts + BOUNDARY_EPSILON_SECS); + info!( + audio_secs, + synthesized_start = start_ts, + synthesized_end = end_ts, + known_prefix_words, + text_chars = novel_text.chars().count(), + "apple_lifecycle: novel final suffix rescued with synthesized window" + ); + disjoint.push(TranscriptSegment { + text: novel_text, + start_ts, + end_ts, + }); + } + + let start_ts = disjoint.first().map_or(0.0, |segment| segment.start_ts); + let end_ts = disjoint.last().map_or(start_ts, |segment| segment.end_ts); + let raw_text = if !overlap_normalized && disjoint.len() == original_segment_count { + callback_text + } else { + disjoint + .iter() + .map(|segment| segment.text.as_str()) + .collect::>() + .join(" ") + }; + if raw_text.is_empty() { + return false; + } + + // Consume the Apple boundary even if cleanup filters the text. A later + // cumulative callback must not resurrect audio the product already judged. + state.last_apple_segment_end = end_ts; let Some(corrected) = state.postprocessor.process_utterance(&raw_text) else { state.filtered_empty_drops = state.filtered_empty_drops.saturating_add(1); warn!( - raw_text = %raw_text, + raw_chars = raw_text.chars().count(), "Apple seal dropped: empty after lexicon/cleanup" ); let _ = ev_tx.send(EngineEvent::Drop { @@ -624,40 +1843,112 @@ fn seal_utterance_final( }; let after_lexicon = corrected.trim().to_string(); - state.utterance_id = state.utterance_id.saturating_add(1); - let utterance_id = state.utterance_id; - state - .progressive - .note_apple_commit(utterance_id, after_lexicon.clone(), end_ts, end_ts); + if state.fusion_seal_armed + && seal_sliced_by_silero( + state, + ev_tx, + &raw_text, + &after_lexicon, + start_ts, + end_ts, + &disjoint, + ) + { + return true; + } + let apple_words = apple_segments_on_pcm_clock(state, &disjoint); + let captured_end = state.audio.session_sample_end(); + let span_sample_start = apple_words.first().map_or_else( + || seconds_to_captured_sample(start_ts, state.sample_rate, captured_end), + |word| word.range.sample_start, + ); + let span_sample_end = apple_words + .last() + .map_or_else( + || seconds_to_captured_sample(end_ts, state.sample_rate, captured_end), + |word| word.range.sample_end, + ) + .max(span_sample_start); + // Bind this span to the spectrum even off the sliced path: when a Silero + // edge already encloses every sample Apple claimed, the utterance range is + // the canonical one and the span records which identity it came from. No + // enclosing edge (Silero off, model missing, an edge still open, or a span + // that straddles two utterances) leaves the Apple-derived range untouched — + // binding never costs content. + let apple_range = TailSampleRange { + session: state.session_id.clone(), + capture_epoch: state.capture_epoch, + sample_start: span_sample_start, + sample_end: span_sample_end, + }; + let (span_range, silero_utterance_id) = match state + .fusion + .as_ref() + .filter(|_| state.fusion_seal_armed) + .and_then(|fusion| { + fusion + .ledger() + .utterance_enclosing(span_sample_start, span_sample_end) + }) { + Some(utterance) => (utterance.range.clone(), Some(utterance.id)), + None => (apple_range, None), + }; + // One id space. While the seal path can mint span ids FROM the ledger, the + // fallback must burn its id there too, or Silero would later mint the same + // id for a real utterance — `note_apple_commit_timed` is idempotent on id, + // so the collision would silently merge two unrelated spans. With the seal + // path disarmed no ledger id ever becomes a span id, and the counter stays + // the plain monotonic one the Apple-boundary lane always used. + let utterance_id = match state.fusion.as_mut().filter(|_| state.fusion_seal_armed) { + Some(fusion) => fusion.ledger_mut().reserve_id(), + None => state.utterance_id.saturating_add(1), + }; + state.utterance_id = state.utterance_id.max(utterance_id); + if !state.progressive.note_apple_commit_timed(AppleCommit { + id: utterance_id, + raw_text: after_lexicon.clone(), + end_secs: end_ts, + committed_at_secs: end_ts, + range: span_range, + words: apple_words, + apple_evidence: TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: None, + }, + silero_utterance_id, + }) { + return false; + } + let segment_count = disjoint.len().max(1); state.pending_events.insert( utterance_id, PendingAppleSeal { raw_text, start_ts, end_ts, - segments, + segments: disjoint, }, ); let window = resolve_sealed_audio_window(state, end_ts); let committed_text = seal_span_text(&after_lexicon, &state.sealed_prefix, false); - let queued = if let (Some(audio), Some(tx)) = (window, state.tail_patch.as_ref()) { - match tx.try_send(TailPatchRequest { - utterance_id, - committed_text, - audio, - covered_through_secs: end_ts, - }) { - Ok(()) => true, - Err(e) => { - state.tail_patch_backpressure_drops = - state.tail_patch_backpressure_drops.saturating_add(1); - warn!( - utterance_id, - "Layer 1 tail-patch request dropped — queue full or lane gone: {e}" - ); - false - } + let queued = if let Some(window) = window { + if state.tail_patch.is_some() { + state.enqueue_layer1_piece(CoalescedPiece { + utterance_id, + committed_text, + audio: window.samples, + sample_start: window.sample_start, + sample_end: window.sample_end, + start_ts, + covered_through_secs: end_ts, + segment_count, + }) + } else { + false } } else { false @@ -678,20 +1969,301 @@ fn seal_utterance_final( true } -/// Blocking worker: owns the stream session for its full lifetime. +// ═══════════════════════════════════════════════════════════ +// Engine lifecycle: speech epochs (hands-free silence) +// ═══════════════════════════════════════════════════════════ + +/// Audio replayed into a fresh epoch ahead of the detected speech edge, so the +/// first phoneme is not eaten by bridge spin-up (~0.24 s measured). Same value +/// the fusion lane pads windows with. +const EPOCH_PREROLL_SECS: f32 = super::silero_fusion::DEFAULT_LEFT_PAD_SECS; + +/// Lift one poll's worth of bridge events onto the session PCM clock. +/// +/// Bridge time is **per request**: every `LiveStreamSession` restarts its +/// segment clock at zero, while every consumer downstream +/// ([`apple_segments_on_pcm_clock`], the seal windows, the Layer 1 ranges) +/// reads those seconds as session time. With one stream per take the two +/// clocks coincide and this is the identity; with an epoch lifecycle they +/// diverge by exactly the epoch base, so the shift happens once, here, before +/// any event reaches [`emit_stream_events`]. +fn shift_events(events: Vec, base_secs: f32) -> Vec { + if !base_secs.is_finite() || base_secs <= 0.0 { + return events; + } + events + .into_iter() + .map(|event| match event { + LiveStreamEvent::Partial { text, segments } => LiveStreamEvent::Partial { + text, + segments: shift_segments(segments, base_secs), + }, + LiveStreamEvent::PhraseFinal { text, segments } => LiveStreamEvent::PhraseFinal { + text, + segments: shift_segments(segments, base_secs), + }, + LiveStreamEvent::Summary { + text, + segments, + ok, + error, + } => LiveStreamEvent::Summary { + text, + segments: shift_segments(segments, base_secs), + ok, + error, + }, + other @ (LiveStreamEvent::Ready + | LiveStreamEvent::End + | LiveStreamEvent::Error { .. }) => other, + }) + .collect() +} + +fn shift_segments(segments: Vec, base_secs: f32) -> Vec { + segments + .into_iter() + .map(|mut segment| { + if segment.start_ts.is_finite() { + segment.start_ts += base_secs; + } + if segment.end_ts.is_finite() { + segment.end_ts += base_secs; + } + segment + }) + .collect() +} + +/// What the worker must do with one capture chunk under the epoch lifecycle. +#[derive(Debug, Clone, Copy, PartialEq)] +enum EpochDecision { + /// Write the chunk into the currently open stream. + Forward, + /// Speech edge while asleep: open a stream based at `preroll_from`. + Wake { preroll_from: u64 }, + /// Silence threshold crossed: close the epoch. Chunk is trailing silence. + Sleep { silence_secs: f32 }, + /// Asleep and still silent — retain audio only. + Idle, +} + +/// Engine lifecycle for the Apple progressive lane: speech opens an SFSpeech +/// epoch, silence past the product threshold closes it, and the engine rests +/// (mic + Silero keep running) until the next speech edge. +/// +/// Disarmed (`utterance_silence_sec: None`, or no Silero) it answers +/// [`EpochDecision::Forward`] to everything, which is the pre-epoch worker bit +/// for bit. +/// +/// # The gate observes nothing itself +/// +/// It is a pure state machine over one bit per chunk — `speech_live` — supplied +/// by [`SileroIngress::ingest`], the session's single VAD. It used to own a +/// second `SpeechSession` of its own, which meant two Silero instances scoring +/// the same PCM: the lifecycle woke and slept on one set of edges while the +/// fusion ledger minted utterance identity on another, and nothing kept the two +/// spectra in step. One session, one spectrum, one set of boundaries. +/// +/// Speech is "live" while a Supervisor segment is open, and for the chunk a +/// segment closes in — so the silence counter starts at the segment close, i.e. +/// **after** Silero's own hysteresis (`0.55 s` by default) has already elapsed. +/// The wall silence before an epoch closes is therefore the product threshold +/// plus that hysteresis, never less than the setting. +struct EpochGate { + armed: bool, + sample_rate: u32, + silence_threshold_samples: u64, + preroll_samples: u64, + awake: bool, + /// Session cursor of the last chunk speech was live in. + last_speech_sample: u64, + /// Session cursor the previous epoch closed at — the pre-roll floor, so a + /// new epoch never re-feeds audio the closed one already carried. + epoch_closed_at: u64, +} + +impl EpochGate { + /// Legacy lane: one stream for the whole take. + fn disarmed() -> Self { + Self { + armed: false, + sample_rate: 1, + silence_threshold_samples: 0, + preroll_samples: 0, + awake: false, + last_speech_sample: 0, + epoch_closed_at: 0, + } + } + + fn armed(sample_rate: u32, silence_sec: f32) -> Self { + let rate = sample_rate.max(1); + Self { + armed: true, + sample_rate: rate, + silence_threshold_samples: (silence_sec.max(0.1) * rate as f32) as u64, + preroll_samples: (EPOCH_PREROLL_SECS * rate as f32) as u64, + awake: false, + last_speech_sample: 0, + epoch_closed_at: 0, + } + } + + /// Build the gate the session config asks for. No silence setting → legacy; + /// no session Silero → legacy, because without edges an armed gate would + /// rest forever and the take would be silent (fail open). + fn for_session( + sample_rate: u32, + utterance_silence_sec: Option, + speech_edges_available: bool, + ) -> Self { + let Some(silence_sec) = utterance_silence_sec else { + return Self::disarmed(); + }; + if !speech_edges_available { + warn!( + utterance_silence_sec = silence_sec, + "Silero unavailable — Apple engine lifecycle disarmed, falling back to one \ + continuous stream for this session" + ); + return Self::disarmed(); + } + Self::armed(sample_rate, silence_sec) + } + + fn is_armed(&self) -> bool { + self.armed + } + + /// One chunk. `speech_live` is the session Silero's verdict on it — the + /// same observation the utterance ledger was minted from. + fn feed_pcm(&mut self, samples: &[f32], samples_seen: u64, speech_live: bool) -> EpochDecision { + if !self.armed { + return EpochDecision::Forward; + } + let chunk_start = samples_seen.saturating_sub(samples.len() as u64); + if speech_live { + self.last_speech_sample = samples_seen; + if self.awake { + return EpochDecision::Forward; + } + self.awake = true; + let preroll_from = chunk_start + .saturating_sub(self.preroll_samples) + .max(self.epoch_closed_at); + return EpochDecision::Wake { preroll_from }; + } + if !self.awake { + return EpochDecision::Idle; + } + let silence = samples_seen.saturating_sub(self.last_speech_sample); + if silence >= self.silence_threshold_samples { + self.awake = false; + self.epoch_closed_at = samples_seen; + return EpochDecision::Sleep { + silence_secs: silence as f32 / self.sample_rate as f32, + }; + } + EpochDecision::Forward + } +} + +/// Session-time base of the open epoch, in seconds. +fn epoch_base_secs(epoch_base_samples: u64, sample_rate: u32) -> f32 { + epoch_base_samples as f32 / sample_rate.max(1) as f32 +} + +/// Seal an open partial that never received a phrase final. +/// +/// Shared by the two places a stream can end without one: capture EOF (stop +/// mid-phrase) and an epoch close. Both must run the same seal-time correction +/// — a phrase that ends by silence must not be the one route that commits +/// uncorrected text, or dies in the preview lane. +fn seal_open_partial( + state: &mut AppleSealState, + ev_tx: &mpsc::UnboundedSender, + audio_secs: f32, +) { + let open = state.open_partial.trim().to_string(); + if open.is_empty() { + return; + } + let segments = std::mem::take(&mut state.open_partial_segments); + seal_utterance_final(state, ev_tx, &open, segments, audio_secs); + state.open_partial.clear(); +} + +/// Everything the blocking worker needs that is not a channel. +struct AppleWorkerConfig<'a> { + sample_rate: u32, + language: Option<&'a str>, + session_id: String, + /// Product "Hands-free silence". `Some` arms the engine lifecycle (speech + /// epochs); `None` keeps one continuous SFSpeech stream for the whole take. + utterance_silence_sec: Option, +} + +/// Blocking worker: owns the SFSpeech stream(s) for the session's full lifetime. fn apple_stream_worker( pcm_rx: std_mpsc::Receiver>>, ev_tx: mpsc::UnboundedSender, - sample_rate: u32, - language: Option<&str>, tail_patch: Option>, tail_patch_done: std_mpsc::Receiver, + config: AppleWorkerConfig<'_>, ) -> anyhow::Result { - let mut stream = LiveStreamSession::open(language, sample_rate)?; + let AppleWorkerConfig { + sample_rate, + language, + session_id, + utterance_silence_sec, + } = config; let mut state = match tail_patch { - Some(tx) => AppleSealState::new_with_tail_patch(sample_rate, tx), - None => AppleSealState::new(sample_rate), + Some(tx) => AppleSealState::new_with_tail_patch_for_session(sample_rate, session_id, tx), + None => AppleSealState::new_for_session(sample_rate, session_id), + }; + // The session's ONE Silero. Both consumers of speech edges read it: the + // utterance ledger (identity, ranges) and the engine lifecycle (wake/sleep). + // It is built whenever either consumer wants it — the fusion flag decides + // whether identity reaches the seal, not whether the VAD exists. + state.fusion_seal_armed = lane_enabled(); + if state.fusion_seal_armed || utterance_silence_sec.is_some() { + let ingress = + SileroIngress::new(sample_rate, state.session_id.clone(), state.capture_epoch); + if ingress.vad_available() { + state.fusion_context = FusionContextMode::from_env(); + info!( + context = state.fusion_context.as_str(), + seal_armed = state.fusion_seal_armed, + lifecycle_armed = utterance_silence_sec.is_some(), + "Silero ingress armed — single VAD feeding utterance identity and engine lifecycle" + ); + state.fusion = Some(ingress); + } else { + warn!( + "Silero model unavailable — no utterance identity and no engine lifecycle \ + this session; Apple segment boundaries stay the seal authority" + ); + } + } + // Engine lifecycle. Disarmed → one stream opened here for the whole take + // (legacy). Armed → the bridge stays unspawned until the first speech edge, + // and every epoch closes on the product silence threshold. + let mut epoch = + EpochGate::for_session(sample_rate, utterance_silence_sec, state.fusion.is_some()); + let mut stream = if epoch.is_armed() { + info!( + utterance_silence_sec = utterance_silence_sec.unwrap_or_default(), + preroll_secs = EPOCH_PREROLL_SECS, + "Apple progressive engine lifecycle armed — SFSpeech rests between utterances" + ); + None + } else { + Some(LiveStreamSession::open(language, sample_rate)?) }; + // Session-time base of the open epoch. Zero for the legacy single stream, + // which is what makes `shift_events` the identity on that path. + let mut epoch_base_samples: u64 = 0; let mut samples_seen: u64 = 0; loop { @@ -711,37 +2283,127 @@ fn apple_stream_worker( // This is worker-side on purpose: the async select loop stays // lock-free (2026-07-27 interleave contract) because the buffer // is never shared across the thread boundary. + // + // Retention runs in every lifecycle state, including while the + // engine rests: it is what the pre-roll of the next epoch is cut + // from, and what Layer 1 windows still resolve against. state.audio.push(&samples); - stream.write_pcm(&samples)?; + // One observation of the spectrum, two consumers: the ledger + // mints identity from it and the lifecycle wakes/sleeps on it. + let speech_live = state + .fusion + .as_mut() + .is_some_and(|fusion| fusion.ingest(&samples, samples_seen).speech_live); let audio_secs = samples_seen as f32 / sample_rate.max(1) as f32; - emit_stream_events(stream.poll_events(), &ev_tx, &mut state, audio_secs); + match epoch.feed_pcm(&samples, samples_seen, speech_live) { + EpochDecision::Forward => { + if let Some(session) = stream.as_mut() { + session.write_pcm(&samples)?; + let events = shift_events( + session.poll_events(), + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(events, &ev_tx, &mut state, audio_secs); + } + } + EpochDecision::Wake { preroll_from } => { + let mut session = LiveStreamSession::open(language, sample_rate)?; + let chunk_start = samples_seen.saturating_sub(samples.len() as u64); + // The base is whatever audio this epoch ACTUALLY starts + // with, never what was asked for: a pre-roll that fell + // off retention resolves to nothing, and basing the + // epoch on it would shift every timestamp in it earlier + // by the missing audio. + let preroll = state.audio.window_by_samples(preroll_from, chunk_start); + epoch_base_samples = + preroll.as_ref().map_or(chunk_start, |w| w.sample_start); + let preroll_samples = + preroll.as_ref().map_or(0, |window| window.samples.len()); + if let Some(window) = preroll.filter(|w| !w.samples.is_empty()) { + session.write_pcm(&window.samples)?; + } + session.write_pcm(&samples)?; + info!( + audio_secs, + epoch_base_secs = epoch_base_secs(epoch_base_samples, sample_rate), + preroll_samples, + "apple_lifecycle: epoch open (speech edge)" + ); + let events = shift_events( + session.poll_events(), + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(events, &ev_tx, &mut state, audio_secs); + stream = Some(session); + } + EpochDecision::Sleep { silence_secs } => { + if let Some(session) = stream.take() { + let base_secs = epoch_base_secs(epoch_base_samples, sample_rate); + let trailing = shift_events(session.finish()?, base_secs); + emit_stream_events(trailing, &ev_tx, &mut state, audio_secs); + // Same close as capture EOF: whatever the engine + // left open is sealed here, because no later + // callback from this epoch can arrive. + seal_open_partial(&mut state, &ev_tx, audio_secs); + let _ = state.flush_layer1_coalesce(); + info!( + audio_secs, + silence_secs, + epoch_base_secs = base_secs, + "apple_lifecycle: epoch close (hands-free silence)" + ); + } + } + // Resting: audio is retained, the engine is not running. + EpochDecision::Idle => {} + } } Ok(None) => break, // EOF from async side Err(std_mpsc::RecvTimeoutError::Timeout) => { let audio_secs = samples_seen as f32 / sample_rate.max(1) as f32; - emit_stream_events(stream.poll_events(), &ev_tx, &mut state, audio_secs); + if let Some(session) = stream.as_mut() { + let events = shift_events( + session.poll_events(), + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(events, &ev_tx, &mut state, audio_secs); + } } Err(std_mpsc::RecvTimeoutError::Disconnected) => break, } } let audio_secs = samples_seen as f32 / sample_rate.max(1) as f32; - let trailing = stream.finish()?; - emit_stream_events(trailing, &ev_tx, &mut state, audio_secs); + if let Some(fusion) = state.fusion.as_mut() { + fusion.flush(samples_seen); + } + if let Some(session) = stream.take() { + let trailing = shift_events( + session.finish()?, + epoch_base_secs(epoch_base_samples, sample_rate), + ); + emit_stream_events(trailing, &ev_tx, &mut state, audio_secs); + } // Seal open partial that never got a phrase final (stop mid-phrase). // Same seal-time correction as the phrase path — a stop mid-utterance must // not be the one route that commits uncorrected text. - let open = state.open_partial.trim().to_string(); - if !open.is_empty() { - seal_utterance_final(&mut state, &ev_tx, &open, 0.0, audio_secs, Vec::new()); - } + seal_open_partial(&mut state, &ev_tx, audio_secs); + let _ = state.flush_layer1_coalesce(); - // Every accepted Layer 1 request must close (success, no-change, or + // Every accepted Layer 1 request must close (success, no-change, or) // explicit skip) before the session task returns. This is bounded by the // queue cap and happens while the async side is still draining jobs. - while !state.progressive.pending_spans().is_empty() { - match tail_patch_done.recv_timeout(Duration::from_secs(30)) { + // + // Wait on the *jobs*, not on the pending-seal queue. Those are different + // conditions: a span still pending can be blocked by the Apple volatile + // window rather than by a missing Whisper window, and no completion will + // ever clear that gate. Waiting on the seal queue therefore parked the stop + // path on the full timeout whenever the last span was volatile-blocked — + // measured 2026-08-12, `rec_stop=36.701s` of which 30.005s was this loop + // waiting for a completion that had already arrived for every job it sent. + while state.tail_patch_awaiting_completion > 0 { + match tail_patch_done.recv_timeout(TAIL_PATCH_CLOSURE_TIMEOUT) { Ok(completion) => state.complete_whisper_window( &ev_tx, completion, @@ -755,10 +2417,90 @@ fn apple_stream_worker( } } + // Capture is over: no later Apple callback can revise a span and no further + // Whisper window can arrive, so both double-close gates are satisfied by + // definition. Seal the remainder here instead of leaving it to the residual + // path — the machine's own span timestamps are the clock, because the audio + // clock is frozen at EOF and can sit milliseconds behind them. + state.seal_remaining_at_session_end(&ev_tx); + + // Evidence surface: when `CODESCRIBE_SEAL_ATLAS_DUMP` names a path, write + // every sealed span with its PCM-pinned word payload as JSON. Runs on the + // worker's own final state after the session-end seal, so the file is what + // the session actually delivered — never a reconstruction. No env, no-op. + if let Ok(dump_path) = std::env::var("CODESCRIBE_SEAL_ATLAS_DUMP") + && !dump_path.trim().is_empty() + { + let spans: Vec = state + .progressive + .sealed_spans() + .iter() + .map(|span| { + serde_json::json!({ + "id": span.id, + "text": span.text, + "end_secs_millis": span.end_secs_millis, + "range": span.range, + "words": span.words, + "apple_evidence": span.apple_evidence, + "whisper_evidence": span.whisper_evidence, + "whisper_words": span.whisper_words, + // Which spectrum edge this span's range came from. `null` + // means no Silero edge enclosed it and the range is Apple's. + "silero_utterance_id": span.silero_utterance_id, + }) + }) + .collect(); + // The other half of the binding proof: the edges themselves, so a span's + // `silero_utterance_id` can be resolved to the sample range Silero + // actually minted and every word checked against it. + let silero_utterances: Vec = state + .fusion + .as_ref() + .map(|fusion| { + fusion + .ledger() + .utterances() + .iter() + .map(|utterance| { + serde_json::json!({ + "id": utterance.id, + "sample_start": utterance.range.sample_start, + "sample_end": utterance.range.sample_end, + "closed": utterance.closed, + }) + }) + .collect() + }) + .unwrap_or_default(); + let atlas = serde_json::json!({ + "session": state.session_id, + "capture_epoch": state.capture_epoch, + "sample_rate": sample_rate, + "audio_samples_seen": samples_seen, + "silero_seal_armed": state.fusion_seal_armed, + "silero_utterances": silero_utterances, + "sealed_spans": spans, + }); + match serde_json::to_vec_pretty(&atlas) + .map_err(anyhow::Error::from) + .and_then(|bytes| std::fs::write(&dump_path, bytes).map_err(anyhow::Error::from)) + { + Ok(()) => info!( + path = %dump_path, + spans = state.progressive.sealed_spans().len(), + "seal atlas dump written" + ), + Err(error) => warn!(path = %dump_path, %error, "seal atlas dump failed"), + } + } + Ok(AppleStreamOutcome { sealed: state.sealed_count, filtered_empty_drops: state.filtered_empty_drops, unresolved_windows: state.unresolved_windows, + under_commit_escalations: state.under_commit_escalations, + sealed_spans: state.progressive.sealed_spans().to_vec(), }) } @@ -778,22 +2520,33 @@ fn apple_stream_worker( /// collapse onto a short shared opener overwrote the prior utterance without /// sealing it. /// -/// Freeze on collapse unless `next` is a **true substantial prefix** of `prev` -/// (`len > 15`) — a same-phrase rewind, not a 1–2 word opener every sentence -/// shares. Kept in lockstep with `SfSpeechPhraseAccumulator` in -/// `codescribe-stt-bridge.swift`. +/// Freeze whenever `next` does not retain `prev` in full. The old restart +/// thresholds classify telemetry only; revision and same-phrase rewind are +/// retained too because this call site otherwise overwrites the only copy. +/// Kept in lockstep with `SfSpeechPhraseAccumulator` in the Swift bridge. pub(crate) fn phrase_restart_should_freeze_prior(prev: &str, next: &str) -> bool { + phrase_retention_reason(prev, next).is_some() +} + +/// Telemetry classification for a retention decision. Text safety depends +/// only on forward containment, never on the restart/revision classifier. +fn phrase_retention_reason(prev: &str, next: &str) -> Option<&'static str> { let prev = prev.trim(); let next = next.trim(); - if prev.is_empty() || next.is_empty() { - return false; + if prev.is_empty() || next.contains(prev) { + return None; } - let restarted = (next.len() * 3 < prev.len()) || (next.len() <= 15 && prev.len() >= 25); - if !restarted { - return false; + if next.is_empty() { + return Some("empty_collapse_retained"); } - let same_phrase_rewind = prev.starts_with(next) && next.len() > 15; - !same_phrase_rewind + let prev_chars = prev.chars().count(); + let next_chars = next.chars().count(); + let restarted = (next_chars * 3 < prev_chars) || (next_chars <= 15 && prev_chars >= 25); + Some(if restarted { + "restart_retained" + } else { + "revision_retained" + }) } /// Map one poll's worth of bridge events onto `EngineEvent`s, sealing where the @@ -834,28 +2587,30 @@ fn emit_stream_events( "apple_lifecycle: recognizer end" ); } - LiveStreamEvent::Partial { text } => { + LiveStreamEvent::Partial { text, segments } => { // Safety net for the named drop mechanism: if the bridge // missed a freeze (shared opener collapse), seal the open // partial here before the rewrite lands. if phrase_restart_should_freeze_prior(&state.open_partial, &text) { + let reason = phrase_retention_reason(&state.open_partial, &text) + .expect("freeze decision must carry a telemetry reason"); let frozen = state.open_partial.clone(); info!( audio_secs, - prev_chars = frozen.len(), - next_chars = text.len(), - prev_head = %frozen.chars().take(40).collect::(), - next_head = %text.chars().take(40).collect::(), - reason = "shared_opener_restart_suppresses_freeze", + prev_chars = frozen.chars().count(), + next_chars = text.chars().count(), + reason, "apple_lifecycle: freeze open partial before restart partial" ); - seal_utterance_final(state, ev_tx, &frozen, 0.0, audio_secs, Vec::new()); + let frozen_segments = std::mem::take(&mut state.open_partial_segments); + seal_utterance_final(state, ev_tx, &frozen, frozen_segments, audio_secs); state.open_partial.clear(); } // Previews stay RAW: they are in-flight presentation, not // canvas, and correcting them would make the lexicon rewrite // flicker letter by letter while the phrase is still forming. state.open_partial = text.clone(); + state.open_partial_segments = segments; state.progressive.note_session_partial(&text, audio_secs); state.preview_rev = state.preview_rev.saturating_add(1); let _ = ev_tx.send(EngineEvent::Preview { @@ -869,14 +2624,11 @@ fn emit_stream_events( audio_secs, sealed_before = state.sealed_count, text_chars = text.len(), - text_head = %text.chars().take(40).collect::(), "apple_lifecycle: phrase final received" ); state.open_partial.clear(); - let start_ts = segments.first().map(|s| s.start_ts).unwrap_or(0.0); - let end_ts = segments.last().map(|s| s.end_ts).unwrap_or(audio_secs); - let committed = - seal_utterance_final(state, ev_tx, &text, start_ts, end_ts, segments); + state.open_partial_segments.clear(); + let committed = seal_utterance_final(state, ev_tx, &text, segments, audio_secs); info!( audio_secs, committed, @@ -906,12 +2658,14 @@ fn emit_stream_events( } // No phrase finals → seal the full summary once (partials-only engine). if state.utterance_id == 0 { - if seal_utterance_final(state, ev_tx, &text, 0.0, audio_secs, segments) { + if seal_utterance_final(state, ev_tx, &text, segments, audio_secs) { state.open_partial.clear(); + state.open_partial_segments.clear(); } } else { // Phrase seals already emitted; don't double-seal open partial. state.open_partial.clear(); + state.open_partial_segments.clear(); } } } @@ -924,15 +2678,101 @@ mod tests { use super::*; use crate::pipeline::contracts::LayerSource; use crate::stt::apple_stt::parse_stream_stdout_line; - use crate::stt::tail_patcher::{LAYERED_TRANSCRIPTION_ENV, compute_tail_patch}; - use serial_test::serial; - use std::ffi::OsString; + use crate::stt::tail_patcher::{ + compute_tail_patch, layered_phase_from_raw, parse_layered_phase_value, + }; use std::sync::Mutex; /// Capture rate the Apple bridge is opened with; these tests exercise seal /// text, not audio retention, so any valid rate is representative. const TEST_SAMPLE_RATE: u32 = 16_000; + fn synthetic_tail_payload( + request_id: u64, + range: TailSampleRange, + segments: Vec, + ) -> TailProviderPayload { + TailProviderPayload { + identity: TailRequestIdentity { request_id, range }, + text: String::new(), + segments, + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + provider_id: crate::stt::tail_provider::TailProviderId::Fake, + elapsed_ms: 0, + evidence: TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: Some("synthetic-test".to_string()), + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + } + } + + fn sealed_span(id: u64, text: &str) -> SealedSpan { + SealedSpan { + id, + text: text.to_string(), + end_secs_millis: id as u32 * 1_000, + range: TailSampleRange { + session: "live-cloud-gap-test".to_string(), + capture_epoch: 0, + sample_start: (id - 1) * 16_000, + sample_end: id * 16_000, + }, + words: Vec::new(), + apple_evidence: TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + whisper_evidence: None, + whisper_words: Vec::new(), + silero_utterance_id: None, + } + } + + #[test] + fn live_cloud_gap_plan_preserves_apple_and_inserts_missing_words() { + let spans = vec![ + sealed_span(1, "I będziesz miał po prostu lokalnej teraz sobie."), + sealed_span(2, "Możesz odczytać i też pow."), + ]; + let candidate = "I będziesz miał po prostu z lokalnej sesji teraz sobie. Możesz odczytać i też powkurwiać się razem."; + let patches = plan_live_layer1_gap_patches(&spans, candidate); + assert!( + !patches.is_empty(), + "provider-only gaps must become patches" + ); + + let mut rendered = spans + .iter() + .map(|span| (span.id, span.text.clone())) + .collect::>(); + for patch in &patches { + let utterance_id = patch_utterance(patch); + patch + .apply_to_committed_text(rendered.get_mut(&utterance_id).expect("known span")) + .expect("bounded patch"); + } + let patched = rendered.into_values().collect::>().join(" "); + let live = spans + .iter() + .map(|span| span.text.as_str()) + .collect::>() + .join(" "); + assert_eq!( + patched, + crate::quality::merge_live_layer1(&live, candidate).text + ); + assert!(patched.contains("z lokalnej sesji")); + assert!(patched.contains("powkurwiać się razem")); + } + /// Integration boundary: the real Apple state owns the progressive /// machine. Apple commit + live partial alone stay pending; an elapsed /// Whisper window seals before session end. @@ -947,6 +2787,7 @@ mod tests { vec![ LiveStreamEvent::Partial { text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], }, LiveStreamEvent::PhraseFinal { text: "uruchom doker".into(), @@ -970,12 +2811,20 @@ mod tests { "Apple commit alone must not bypass the double-seal condition" ); + let whisper_range = state.progressive.pending_spans()[0].range.clone(); + let whisper_word = TimedTailSegment { + text: "doker".to_string(), + range: whisper_range.clone(), + }; state.complete_whisper_window( &tx, TailPatchCompletion { utterance_id: 1, covered_through_secs: 2.0, outcome: TailPatchOutcome::NoChange, + payload: Some(synthetic_tail_payload(1, whisper_range, vec![whisper_word])), + span_map: Vec::new(), + member_ids: Vec::new(), }, 5.0, ); @@ -991,7 +2840,109 @@ mod tests { "double-closed span must seal live" ); assert_eq!(state.progressive.sealed_spans().len(), 1); - } + let sealed = &state.progressive.sealed_spans()[0]; + assert_eq!(sealed.range.sample_start, 8_000); + assert_eq!(sealed.range.sample_end, 32_000); + assert_eq!(sealed.words.len(), 1); + assert_eq!(sealed.words[0].range, sealed.range); + assert_eq!( + sealed.apple_evidence.source, + TailEvidenceSource::AppleSpeech + ); + assert_eq!( + sealed + .whisper_evidence + .as_ref() + .map(|evidence| evidence.source), + Some(TailEvidenceSource::Whisper) + ); + assert_eq!(sealed.whisper_words.len(), 1); + } + + /// W-C: an under-commit's gap-appends reach the canvas through the same + /// seal gate as ordinary patches — strictly after `UtteranceFinal` — and an + /// unplaceable remainder rides out as the residual escalation. + #[test] + fn apple_seal_emits_under_commit_gap_appends_and_escalation() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let (tp_tx, _tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); + let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); + push_capture(&mut state, 10.0); + + emit_stream_events( + vec![ + LiveStreamEvent::Partial { + text: "raz dwa trzy cztery piec".into(), + segments: vec![segment("raz dwa trzy cztery piec", 0.5, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "raz dwa trzy cztery piec".into(), + segments: vec![segment("raz dwa trzy cztery piec", 0.5, 2.0)], + }, + ], + &tx, + &mut state, + 2.0, + ); + while rx.try_recv().is_ok() {} + + // Whisper recovered a tail that only partly has a safe anchor. + let outcome = compute_tail_patch( + "raz dwa trzy cztery piec szesc", + "raz dwa trzy alfa beta gamma cztery piec siedem osiem dziewiec dziesiec", + 1, + &TailPatchConfig::default(), + ); + assert!( + outcome.residual_required(), + "fixture must carry an unplaceable remainder" + ); + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: 1, + covered_through_secs: 2.0, + outcome, + payload: None, + span_map: Vec::new(), + member_ids: Vec::new(), + }, + 5.0, + ); + + let mut after = Vec::new(); + while let Ok(event) = rx.try_recv() { + after.push(event); + } + let final_at = after + .iter() + .position(|e| matches!(e, EngineEvent::UtteranceFinal { .. })) + .expect("span must seal"); + let patch_at = after + .iter() + .position(|e| { + matches!( + e, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) + }) + .expect("recovered gap must reach the canvas, not be discarded"); + assert!( + final_at < patch_at, + "a gap-append must never overtake the final it addresses" + ); + assert!( + after.iter().any(|e| matches!( + e, + EngineEvent::Warning { code, .. } if code == UNDER_COMMIT_WARNING_CODE + )), + "unplaceable recovered speech must escalate outward" + ); + assert_eq!(state.under_commit_escalations, 1); + } /// Partial → Preview; each phrase final → UtteranceFinal with rising ids. #[test] @@ -1002,14 +2953,15 @@ mod tests { vec![ LiveStreamEvent::Partial { text: "hello".into(), + segments: vec![segment("hello", 0.0, 0.5)], }, LiveStreamEvent::PhraseFinal { text: "hello world".into(), - segments: vec![], + segments: vec![segment("hello world", 0.0, 1.0)], }, LiveStreamEvent::PhraseFinal { text: "second".into(), - segments: vec![], + segments: vec![segment("second", 1.0, 2.0)], }, ], &tx, @@ -1057,6 +3009,98 @@ mod tests { } } + /// Append doctrine (session a5623d55, 2026-08-12): a phrase final whose + /// segments are entirely consumed by the trusted timing boundary but whose + /// text carries NOVEL content must still reach the canvas. Demoting it to + /// the preview lane is a silent replacement channel — the very next + /// partial overwrites `open_partial` wholesale and the only copy dies. + #[test] + fn boundary_consumed_final_with_novel_text_still_reaches_canvas() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 40.0); + + // Utterance 1 commits normally; trusted boundary moves to 14.0. + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "Zmienili zobacz".into(), + segments: vec![segment("Zmienili zobacz", 0.5, 14.0)], + }], + &tx, + &mut state, + 14.2, + ); + + // SFSpeech restart re-delivers with stale timings BEHIND the boundary + // but novel words; the collapsed restart partial lands right after. + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "Czyli dupa zbita".into(), + segments: vec![segment("Czyli dupa zbita", 10.0, 13.5)], + }, + LiveStreamEvent::Partial { + text: "Tak".into(), + segments: vec![segment("Tak", 17.0, 17.4)], + }, + ], + &tx, + &mut state, + 17.5, + ); + + let mut finals = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let EngineEvent::UtteranceFinal { text, .. } = event { + finals.push(text); + } + } + let canvas = finals.join(" "); + assert!( + canvas.contains("Czyli dupa zbita"), + "Apple-asserted novel text died in the preview lane (podmianka): canvas={canvas:?}" + ); + } + + /// Append doctrine, freeze path: the safety-net freeze seals the open + /// partial WITHOUT segments. That seal must not die on `disjoint.is_empty()` + /// — the frozen text is the only copy of a whole utterance. + #[test] + fn frozen_partial_without_segments_still_reaches_canvas() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 30.0); + + emit_stream_events( + vec![ + LiveStreamEvent::Partial { + text: "pojebany tekst czyli dupa".into(), + segments: Vec::new(), + }, + // Collapsed restart: freeze must seal the prior hypothesis. + LiveStreamEvent::Partial { + text: "Tak".into(), + segments: Vec::new(), + }, + ], + &tx, + &mut state, + 12.0, + ); + + let mut finals = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let EngineEvent::UtteranceFinal { text, .. } = event { + finals.push(text); + } + } + let canvas = normalize_for_containment(&finals.join(" ")); + assert!( + canvas.contains("pojebany tekst czyli dupa"), + "frozen open partial died sealing without segments: canvas={canvas:?}" + ); + } + /// F3 wiring contract: a seal must resolve to the audio actually retained /// for this session, and advance the lower bound for the next utterance. /// This is what W2-A's tail-patch will stand on. @@ -1093,6 +3137,428 @@ mod tests { assert!(state.audio.window(2.5, 4.0).is_some()); } + #[test] + fn cumulative_apple_final_commits_only_segments_after_last_boundary() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 4.0); + + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "alpha beta".into(), + segments: vec![segment("alpha", 0.0, 1.0), segment("beta", 1.0, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "alpha beta gamma".into(), + segments: vec![ + segment("alpha", 0.0, 1.0), + segment("beta", 1.0, 2.0), + segment("gamma", 2.0, 3.0), + ], + }, + ], + &tx, + &mut state, + 4.0, + ); + + let mut finals = Vec::new(); + let mut overlap_warnings = 0; + while let Ok(event) = rx.try_recv() { + match event { + EngineEvent::UtteranceFinal { + raw_text, + start_ts, + end_ts, + .. + } => finals.push((raw_text, start_ts, end_ts)), + EngineEvent::Warning { code, .. } if code == APPLE_FINAL_OVERLAP_WARNING_CODE => { + overlap_warnings += 1; + } + _ => {} + } + } + assert_eq!( + finals, + vec![("alpha beta".into(), 0.0, 2.0), ("gamma".into(), 2.0, 3.0)] + ); + assert_eq!(overlap_warnings, 1); + } + + #[test] + fn cumulative_final_commits_only_its_novel_suffix() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 3.0); + + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "alpha beta".into(), + segments: vec![segment("alpha", 0.0, 1.0), segment("beta", 1.0, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "alpha beta revised".into(), + segments: vec![segment("alpha beta revised", 0.0, 2.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + // Append doctrine: the canvas-known prefix "alpha beta" must not + // double-commit, but the novel suffix must never die in preview. + let finals: Vec<&String> = events + .iter() + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(finals.len(), 2, "novel suffix must commit: {events:?}"); + assert!( + normalize_for_containment(finals[1]).contains("revised"), + "second final must carry only the novel suffix: {finals:?}" + ); + assert!( + !normalize_for_containment(finals[1]).contains("alpha"), + "canvas-known prefix must not double-commit: {finals:?}" + ); + assert_eq!(state.utterance_id, 2, "novel suffix gets a fresh ID"); + assert_eq!( + state.last_apple_segment_end, 3.0, + "synthesized window consumes the boundary to the session clock" + ); + } + + /// A trailing cumulative callback can assert novel text after capture has + /// already reached EOF. The text still belongs on the append-only canvas, + /// but its synthetic Apple boundary must clamp to the canonical PCM clock: + /// advancing the window floor to the unclamped Apple timestamp makes every + /// later suffix start beyond retained audio and queues an empty Whisper + /// window before the failure becomes visible. + #[test] + fn eof_clamped_novel_suffixes_do_not_poison_pcm_window_floor_or_queue_empty_audio() { + let (tx, _rx) = mpsc::unbounded_channel(); + let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); + let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); + push_capture(&mut state, 3.0); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "alpha beta".into(), + segments: vec![segment("alpha beta", 0.0, 3.0)], + }], + &tx, + &mut state, + 3.0, + ); + assert!(state.flush_layer1_coalesce()); + let initial = tp_rx + .try_recv() + .expect("the real captured span must reach Layer 1"); + assert!(!initial.audio.is_empty()); + + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "alpha beta gamma".into(), + segments: vec![segment("alpha beta gamma", 0.0, 3.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "alpha beta gamma delta".into(), + segments: vec![segment("alpha beta gamma delta", 0.0, 3.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + + let extra_windows = std::iter::from_fn(|| tp_rx.try_recv().ok()).collect::>(); + assert!( + extra_windows.is_empty(), + "novel text at EOF has no new PCM and must not queue empty Layer 1 windows: {:?}", + extra_windows + .iter() + .map(|request| request.audio.len()) + .collect::>() + ); + assert_eq!( + state.last_sealed_end, 3.0, + "the window floor is canonical PCM time, never an unclamped Apple timestamp" + ); + assert_eq!( + state.unresolved_windows, 0, + "a clamped EOF suffix is known to have no new PCM; it is not a clock lie" + ); + let landed = state + .progressive + .pending_spans() + .iter() + .map(|span| normalize_for_containment(&span.raw_text)) + .chain( + state + .progressive + .sealed_spans() + .iter() + .map(|span| normalize_for_containment(&span.text)), + ) + .collect::>(); + assert!( + landed.iter().any(|text| text.contains("gamma")), + "the first EOF suffix must remain on the canvas: {landed:?}" + ); + assert!( + landed.iter().any(|text| text.contains("delta")), + "the later EOF suffix must remain on the canvas: {landed:?}" + ); + } + + /// Regression guard for the prefix probe: the canvas-known prefix must be + /// recognised even when the lexicon rewrites words inside it. + /// + /// `cumulative_final_commits_only_its_novel_suffix` cannot catch this — its + /// "alpha beta revised" survives every rewrite table untouched, so it stayed + /// green through the whole defect. Here "doker" → "Docker" puts a real + /// rewrite inside the shared prefix, which is what broke the match: the + /// probe was normalised through `seal_span_text` while the canvas is built + /// from post-`process_utterance` text, so the two sides disagreed at the + /// first rewritten word and nearly the whole phrase re-committed as novel. + /// Measured on session f72fbbb7 (2026-08-12): 603 live words against 318 + /// spoken, +90%. + #[test] + fn cumulative_final_prefix_survives_words_the_lexicon_rewrites() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 3.0); + + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom", 0.0, 1.0), segment("doker", 1.0, 2.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "uruchom doker i restart".into(), + segments: vec![segment("uruchom doker i restart", 0.0, 2.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let finals: Vec<&String> = events + .iter() + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!( + finals.len(), + 2, + "the novel suffix must still commit: {events:?}" + ); + + let novel = normalize_for_containment(finals[1]); + assert!( + novel.contains("restart"), + "novel suffix must reach the canvas: {finals:?}" + ); + assert!( + !novel.contains("uruchom"), + "a rewritten prefix is still a known prefix — re-committing it is the repetition defect: {finals:?}" + ); + assert!( + !novel.contains("doker") && !novel.contains("docker"), + "the WHOLE known prefix must be consumed, not just the words the lexicon left alone — \ + stopping at the first rewritten word is exactly how a phrase re-commits: {finals:?}" + ); + } + + /// Threshold contract of the fuzzy prefix: short probes stay exact, longer + /// ones absorb ~20% revisions. At one or two words a tolerated mismatch is + /// not a revision, it is a different word — loosening that end would let + /// any two-word opener "match" the canvas and silently eat real speech. + #[test] + fn revision_tolerance_is_zero_for_short_probes_and_bounded_after() { + let canvas = vec!["ala", "ma", "kota", "i", "psa"]; + let one = |s: &str| vec![s.to_string()]; + let owned = |words: &[&str]| words.iter().map(|w| (*w).to_string()).collect::>(); + + // k=1..2: exact only. + assert_eq!(revision_tolerant_known_prefix(&one("ala"), &canvas), (1, 0)); + assert_eq!(revision_tolerant_known_prefix(&one("ela"), &canvas), (0, 0)); + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ela", "ma"]), &canvas), + (0, 0), + "a two-word probe with a revision must NOT match — that is a different phrase" + ); + + // k=3: one revision allowed ("ela" for "ala"). + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ela", "ma", "kota"]), &canvas), + (3, 1) + ); + // Two revisions in three words: too different. + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ela", "je", "kota"]), &canvas), + (0, 0) + ); + // Full exact run wins with zero revisions. + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ala", "ma", "kota", "i", "psa"]), &canvas), + (5, 0) + ); + + // Insertions and deletions are revisions too: a positional rule would + // cascade every word after the shift into a mismatch. Measured on the + // 2026-08-12 replay — 15-22-word restatements collapsed to a 6-word + // match because Apple interjected or dropped a single word mid-phrase. + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ala", "ma", "dużego", "kota", "i"]), &canvas), + (5, 1), + "one inserted word must cost one edit, not shift-poison the rest" + ); + assert_eq!( + revision_tolerant_known_prefix(&owned(&["ala", "kota", "i", "psa"]), &canvas), + (4, 1), + "one dropped word must cost one edit" + ); + } + + /// The 2026-08-12 18:44 repetition mechanism, pinned: a cumulative final + /// that REVISES its opening word ("szuty" → "skróty") used to defeat every + /// probe length at once, because the exact-substring prefix match was + /// anchored at the callback's first word. The whole restatement then + /// re-committed — the delivered take carried 72% of its words inside a + /// repeated 6-gram. `revision_tolerant_known_prefix` absorbs the revision. + #[test] + fn cumulative_final_with_revised_opening_word_must_not_recommit_the_phrase() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 40.0); + + let heard_first = "szuty klawiszowe to podwójny lewy przycisk myszy"; + let restated = + "skróty klawiszowe to podwójny lewy przycisk myszy lub klawisz na klawiaturze"; + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: heard_first.into(), + segments: vec![segment(heard_first, 0.0, 5.0)], + }], + &tx, + &mut state, + 40.0, + ); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: restated.into(), + segments: vec![segment(restated, 0.0, 6.0)], + }], + &tx, + &mut state, + 40.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let finals: Vec<&String> = events + .iter() + .filter_map(|e| match e { + EngineEvent::UtteranceFinal { text, .. } => Some(text), + _ => None, + }) + .collect(); + let all = finals + .iter() + .map(|t| normalize_for_containment(t)) + .collect::>() + .join(" "); + let count = all.matches("lewy przycisk myszy").count(); + assert_eq!( + count, 1, + "a restatement with one revised opening word must not re-commit the whole phrase: {finals:?}" + ); + } + + /// A later cumulative final can be entirely covered by already-committed + /// spans. It is not an active tail: surfacing the whole callback as Preview + /// makes the presentation reducer render `committed + restatement` and the + /// delivery buffer duplicates the take at stop. + #[test] + fn fully_reheard_cumulative_final_clears_preview_instead_of_repeating_canvas() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 8.0); + + let heard_first = "szuty klawiszowe to podwójny lewy przycisk myszy"; + let restated = + "skróty klawiszowe to podwójny lewy przycisk myszy lub klawisz na klawiaturze"; + + for (text, end) in [(heard_first, 5.0), (restated, 6.0), (restated, 6.5)] { + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: text.into(), + segments: vec![segment(text, 0.0, end)], + }], + &tx, + &mut state, + 8.0, + ); + } + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let last_preview = events.iter().rev().find_map(|event| match event { + EngineEvent::Preview { text, .. } => Some(text.as_str()), + _ => None, + }); + assert_eq!( + last_preview, + Some(""), + "a fully re-heard final must clear the volatile tail, not repeat the canvas: {events:?}" + ); + assert!( + state.open_partial.is_empty(), + "a fully re-heard final must not survive as stop-time open partial" + ); + } + + #[test] + fn legitimate_repeated_words_survive_disjoint_apple_windows() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 3.0); + emit_stream_events( + vec![ + LiveStreamEvent::PhraseFinal { + text: "tak".into(), + segments: vec![segment("tak", 0.0, 1.0)], + }, + LiveStreamEvent::PhraseFinal { + text: "tak".into(), + segments: vec![segment("tak", 1.0, 2.0)], + }, + ], + &tx, + &mut state, + 3.0, + ); + let raw_finals = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { raw_text, .. } => Some(raw_text), + _ => None, + }) + .collect::>(); + assert_eq!(raw_finals, vec!["tak", "tak"]); + } + /// Falsification arm: an `end_ts` that does not describe this session's PCM /// must be counted and surfaced, never silently truncated into a window. #[test] @@ -1182,7 +3648,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::PhraseFinal { text: "uruchom doker teraz".into(), - segments: vec![], + segments: vec![segment("uruchom doker teraz", 0.0, 1.0)], }], &tx, &mut state, @@ -1212,6 +3678,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::Partial { text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.0, 1.0)], }], &tx, &mut state, @@ -1236,7 +3703,7 @@ mod tests { // Trailing-":D" burst: a known ASR artifact that cleanup strips // to nothing. text: ":D".into(), - segments: vec![], + segments: vec![segment(":D", 0.0, 1.0)], }], &tx, &mut state, @@ -1266,7 +3733,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::Summary { text: "zbuduj obraz doker".into(), - segments: vec![], + segments: vec![segment("zbuduj obraz doker", 0.0, 2.0)], ok: true, error: None, }], @@ -1292,7 +3759,7 @@ mod tests { emit_stream_events( vec![LiveStreamEvent::PhraseFinal { text: "uruchom doker teraz".into(), - segments: vec![], + segments: vec![segment("uruchom doker teraz", 0.0, 1.0)], }], &tx, &mut state, @@ -1312,56 +3779,312 @@ mod tests { // ── W2-A · Layer 1 tail-patch on the Apple progressive path ────────────── - /// Restores an env var to its pre-test value, so a serial env test cannot - /// leak a phase flag into the rest of the binary. - struct EnvRestore { - key: &'static str, - previous: Option, + /// Collecting sink for Layer 1 / SessionFinalised event assertions. + #[derive(Default)] + struct RecordingSink(Mutex>); + + impl EventSink for RecordingSink { + /// Clone every engine event into the mutex-backed log. + fn on_event(&self, event: &EngineEvent) { + self.0.lock().expect("lock").push(event.clone()); + } } - impl EnvRestore { - /// Snapshot `key`'s current value (or absence) for later restore on drop. - fn capture(key: &'static str) -> Self { - Self { - key, - previous: std::env::var_os(key), - } + impl RecordingSink { + /// Snapshot of all events received so far (clone under lock). + fn events(&self) -> Vec { + self.0.lock().expect("lock").clone() } } - impl Drop for EnvRestore { - /// Put the env var back exactly as it was when `capture` ran. - fn drop(&mut self) { - match &self.previous { - Some(value) => unsafe { std::env::set_var(self.key, value) }, - None => unsafe { std::env::remove_var(self.key) }, - } + fn synthetic_tail_job(utterance_id: u64, outcome: TailPatchOutcome) -> TailPatchJobResult { + let range = TailSampleRange { + session: "test-session".to_string(), + capture_epoch: 0, + sample_start: 0, + sample_end: 0, + }; + TailPatchJobResult { + utterance_id, + outcome, + payload: synthetic_tail_payload(utterance_id, range, Vec::new()), + } + } + + /// Computing a bearing patch is not delivery. The worker can already have + /// timed out and dropped its completion receiver; counting before that + /// hand-off makes `tail_patch_session_receipt` claim patches that never + /// reached the canvas. + #[test] + fn finishing_tail_patch_does_not_count_before_worker_accepts_it() { + let mut lane = AppleTailPatchLane::new(TEST_SAMPLE_RATE, None); + let outcome = compute_tail_patch( + "ala ma kota w domu", + "ala ma kota w domu swoim", + 1, + &TailPatchConfig::default(), + ); + let completion = lane.finish_for_worker( + Some(TailPatchInFlight { + utterance_id: 1, + covered_through_secs: 2.0, + span_map: Vec::new(), + member_ids: Vec::new(), + }), + Ok(synthetic_tail_job(1, outcome)), + ); + assert!( + completion + .outcome + .events() + .iter() + .any(|event| matches!(event, EngineEvent::ReplaceRange { .. })), + "fixture must carry a bearing patch" + ); + assert_eq!( + lane.replacements(), + 0, + "completion construction alone must not report canvas delivery" + ); + + let (done_tx, done_rx) = std_mpsc::channel(); + assert!(lane.forward_completion_to_worker(&done_tx, completion)); + assert_eq!(lane.replacements(), 1); + let _accepted = done_rx.try_recv().expect("live worker receives completion"); + + drop(done_rx); + let rejected_outcome = compute_tail_patch( + "drugi fragment", + "drugi fragment odzyskany", + 2, + &TailPatchConfig::default(), + ); + let rejected = lane.finish_for_worker( + Some(TailPatchInFlight { + utterance_id: 2, + covered_through_secs: 3.0, + span_map: Vec::new(), + member_ids: Vec::new(), + }), + Ok(synthetic_tail_job(2, rejected_outcome)), + ); + assert!(!lane.forward_completion_to_worker(&done_tx, rejected)); + assert_eq!( + lane.replacements(), + 1, + "a closed worker cannot turn computed text into reported delivery" + ); + } + + /// Wiring contract: a sealed utterance must hand Layer 1 the exact audio + /// behind it plus the exact committed string `ReplaceRange` offsets are + /// computed against. Anything else patches canvas from the wrong source. + #[test] + fn apple_tail_patch_seal_enqueues_audio_window_for_the_sealed_utterance() { + let (tx, _rx) = mpsc::unbounded_channel(); + let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); + let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); + push_capture(&mut state, 6.0); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], + }], + &tx, + &mut state, + 6.0, + ); + + assert!( + state.flush_layer1_coalesce(), + "one-seal tests flush the held window so the request is observable" + ); + let req = tp_rx + .try_recv() + .expect("sealed utterance must enqueue a tail-patch request"); + assert_eq!(req.utterance_id, 1); + assert_eq!( + req.committed_text, "Uruchom Docker.", + "Layer 1 must diff against the progressive-sealed text (lexicon → Light+), not raw engine output" + ); + assert_eq!( + req.audio.len(), + 2 * TEST_SAMPLE_RATE as usize, + "window is [previous seal end, end_ts) at session rate" + ); + assert_eq!(req.provider_request.identity.range.sample_start, 0); + assert_eq!(req.provider_request.identity.range.sample_end, 32_000); + assert_eq!(req.provider_request.identity.request_id, req.utterance_id); + assert_eq!( + state.tail_patch_awaiting_completion, 1, + "an accepted request is what the end-of-session closure loop owes a wait to" + ); + } + + /// Five Apple phrase-restarts of one compound sentence must share one + /// Whisper window and take the aligned rewrite, not die at the 0.50 cap. + /// Live 2026-08-19: each chop was its own job (`change_ratio` 0.50–3.00) + /// or fusion rewrote the last fragment and dropped the concat repair. + #[test] + fn five_epoch_apple_chop_rewrites_joined_sentence_not_skip() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); + let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); + // Live Apple+Layer1 tonight: Silero is loaded for hands-free, so + // `complete_whisper_window` used to run fusion on the last fragment + // and drop the concat repair (`NoChange`). + state.fusion = Some(SileroIngress::new( + TEST_SAMPLE_RATE, + state.session_id.clone(), + 0, + )); + push_capture(&mut state, 8.0); + + let chops = [ + ("ala ma", 0.0, 0.5), + ("czarnego kota", 0.5, 1.1), + ("i białego", 1.1, 1.7), + ("psa dzisiaj", 1.7, 2.3), + ("w domu", 2.3, 2.9), + ]; + let events: Vec<_> = chops + .iter() + .map(|(text, start, end)| LiveStreamEvent::PhraseFinal { + text: (*text).into(), + segments: vec![segment(text, *start, *end)], + }) + .collect(); + emit_stream_events(events, &tx, &mut state, 3.0); + + let req = tp_rx + .try_recv() + .expect("five close chops must flush one coalesced Layer 1 job"); + assert!( + tp_rx.try_recv().is_err(), + "one window, not a job per Apple epoch" + ); + assert_eq!(req.member_ids.len(), 5, "coalesce must keep all five chops"); + assert_eq!(req.span_map.len(), 5); + + let whisper = "ala ma dużego rudego kota oraz małego psa dzisiaj u siebie domu"; + let outcome = compute_tail_patch( + &req.committed_text, + whisper, + req.utterance_id, + &TailPatchConfig::default(), + ); + assert!( + !matches!(outcome, TailPatchOutcome::Skipped { .. }), + "joined window must rewrite, not hit the 0.50 cap, got {outcome:?}" + ); + assert!( + !outcome.events().is_empty(), + "Whisper wording must produce patches, got {outcome:?}" + ); + + while rx.try_recv().is_ok() {} + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: req.utterance_id, + covered_through_secs: req.covered_through_secs, + outcome, + payload: None, + span_map: req.span_map, + member_ids: req.member_ids, + }, + 6.0, + ); + + let mut after = Vec::new(); + while let Ok(event) = rx.try_recv() { + after.push(event); } + assert!( + after.iter().any(|event| matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + )), + "coalesced rewrite must reach the canvas, got {after:?}" + ); + } + + /// A first final that arrives after the retention horizon must not poison + /// the whole session. Measured live 2026-08-14: a 247 s take whose first + /// SFSpeech final came at 156 s went 11/11 unresolved — `last_sealed_end` + /// stayed 0.0 because it only advances on success, so Layer 1 received + /// zero windows for the entire take. The window start clamps to retained + /// audio (everything older is committed canvas by definition); a genuinely + /// lying `end_ts` stays fail-closed. + #[test] + fn seal_window_clamps_start_after_retention_eviction() { + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 200.0); + let retained_start = state.audio.retained_start_secs(); + assert!( + retained_start > 0.0, + "fixture must push past the retention cap to evict the session head" + ); + + let window = resolve_sealed_audio_window(&mut state, 150.0) + .expect("stale `from` must clamp to retained audio, not fail the take"); + assert_eq!( + window.sample_start, + (retained_start as f64 * TEST_SAMPLE_RATE as f64) as u64, + "clamped window starts at the oldest retained sample" + ); + assert_eq!(window.sample_end, 150 * TEST_SAMPLE_RATE as u64); + + // The poison spiral is broken: the next window chains normally. + let next = resolve_sealed_audio_window(&mut state, 180.0) + .expect("later windows must resolve once the first seal landed"); + assert_eq!(next.sample_start, 150 * TEST_SAMPLE_RATE as u64); + + // A boundary that precedes the already-sealed canvas is still a lie. + assert!( + resolve_sealed_audio_window(&mut state, 100.0).is_none(), + "end_ts behind the sealed canvas must stay fail-closed" + ); + assert_eq!(state.unresolved_windows, 1); } - /// Collecting sink for Layer 1 / SessionFinalised event assertions. - #[derive(Default)] - struct RecordingSink(Mutex>); + /// SFSpeech may report a word end a few milliseconds past PCM capture. + /// Ingestion clamps it once onto the integer sample clock; later stages do + /// not compare the two floating clocks as if they were identical. + #[test] + fn apple_segments_map_to_captured_pcm_samples_at_ingestion() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 2.0); - impl EventSink for RecordingSink { - /// Clone every engine event into the mutex-backed log. - fn on_event(&self, event: &EngineEvent) { - self.0.lock().expect("lock").push(event.clone()); - } - } + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "zegar pcm".into(), + segments: vec![segment("zegar pcm", 0.5, 2.002)], + }], + &tx, + &mut state, + 2.0, + ); - impl RecordingSink { - /// Snapshot of all events received so far (clone under lock). - fn events(&self) -> Vec { - self.0.lock().expect("lock").clone() - } + let sealed = &state.progressive.sealed_spans()[0]; + assert_eq!(sealed.range.sample_start, 8_000); + assert_eq!(sealed.range.sample_end, 32_000); + assert_eq!(sealed.words[0].range.sample_end, 32_000); + assert_eq!(sealed.end_secs_millis, 2_002, "legacy adapter unchanged"); } - /// Wiring contract: a sealed utterance must hand Layer 1 the exact audio - /// behind it plus the exact committed string `ReplaceRange` offsets are - /// computed against. Anything else patches canvas from the wrong source. + /// The closure loop must wait on outstanding Layer 1 *jobs*, never on the + /// pending-seal queue. The two diverge the moment a span is held by the + /// Apple volatile window: no completion can clear that gate, so a loop + /// watching the seal queue waits for an event that is not coming. That is + /// what parked the stop path for the full timeout on 2026-08-12. #[test] - fn apple_tail_patch_seal_enqueues_audio_window_for_the_sealed_utterance() { + fn tail_patch_closure_counter_tracks_jobs_not_pending_seals() { let (tx, _rx) = mpsc::unbounded_channel(); let (tp_tx, mut tp_rx) = mpsc::channel::(TAIL_PATCH_QUEUE_CAP); let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); @@ -1376,19 +4099,37 @@ mod tests { &mut state, 6.0, ); - + assert!(state.flush_layer1_coalesce()); let req = tp_rx .try_recv() - .expect("sealed utterance must enqueue a tail-patch request"); - assert_eq!(req.utterance_id, 1); - assert_eq!( - req.committed_text, "Uruchom Docker.", - "Layer 1 must diff against the progressive-sealed text (lexicon → Light+), not raw engine output" + .expect("sealed utterance enqueues a request"); + assert_eq!(state.tail_patch_awaiting_completion, 1); + + // Close the job on a clock that is still inside the span's volatile + // window — the exact shape the old exit condition could not express. + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: req.utterance_id, + covered_through_secs: req.covered_through_secs, + outcome: TailPatchOutcome::skipped( + crate::stt::tail_patcher::SkipReasonCode::EmptyRetranscription, + "no change", + ), + payload: None, + span_map: req.span_map, + member_ids: req.member_ids, + }, + 2.1, ); + assert_eq!( - req.audio.len(), - 2 * TEST_SAMPLE_RATE as usize, - "window is [previous seal end, end_ts) at session rate" + state.tail_patch_awaiting_completion, 0, + "every job reported back — the stop path owes no further wait" + ); + assert!( + !state.progressive.pending_spans().is_empty(), + "yet a span is still pending: waiting on this queue would hang on nothing" ); } @@ -1456,24 +4197,24 @@ mod tests { let mut state = AppleSealState::new_with_tail_patch(TEST_SAMPLE_RATE, tp_tx); push_capture(&mut state, 10.0); - emit_stream_events( - vec![ - LiveStreamEvent::PhraseFinal { - text: "pierwsze zdanie".into(), - segments: vec![segment("pierwsze zdanie", 0.5, 2.0)], - }, - LiveStreamEvent::PhraseFinal { - text: "drugie zdanie".into(), - segments: vec![segment("drugie zdanie", 2.5, 4.0)], - }, - ], - &tx, - &mut state, - 10.0, - ); + let mut events = Vec::new(); + for i in 0..10 { + let start = i as f32 * 0.5; + events.push(LiveStreamEvent::PhraseFinal { + text: format!("segment {i}"), + segments: vec![segment(&format!("segment {i}"), start, start + 0.4)], + }); + } + emit_stream_events(events, &tx, &mut state, 10.0); - assert_eq!(state.sealed_count, 2, "seals never wait on the patch queue"); - assert_eq!(state.tail_patch_backpressure_drops, 1); + assert!( + state.tail_patch_backpressure_drops >= 1, + "a second 5-segment flush must drop when the queue already holds one job" + ); + assert!( + state.sealed_count >= 1, + "a dropped flush still seals Apple instead of stalling capture" + ); } /// Acceptance arm: an induced gap (Layer 0 committed a shorter span than @@ -1492,7 +4233,7 @@ mod tests { 1, &TailPatchConfig::default(), ); - lane.push_job(Box::pin(async move { Ok((1u64, outcome)) })); + lane.push_job(Box::pin(async move { Ok(synthetic_tail_job(1, outcome)) })); let result = lane.next().await.expect("one job in flight"); lane.complete(&sink, result); @@ -1543,12 +4284,17 @@ mod tests { matches!(outcome, TailPatchOutcome::Skipped { .. }), "shared threshold must reject a wholesale divergence" ); - lane.push_job(Box::pin(async move { Ok((1u64, outcome)) })); + lane.push_job(Box::pin(async move { Ok(synthetic_tail_job(1, outcome)) })); let result = lane.next().await.expect("one job in flight"); lane.complete(&sink, result); assert_eq!(lane.replacements(), 0); + assert_eq!( + lane.skipped(), + 1, + "a skipped job must count toward the starvation receipt" + ); assert!( sink.events().is_empty(), "a skipped patch must not touch committed canvas" @@ -1559,18 +4305,20 @@ mod tests { /// live tail patch is a core element of the triangulation, not an opt-in /// (operator directive 2026-08-09). Explicit `off` is the one way out. #[test] - #[serial] fn apple_tail_patch_lane_is_wired_by_default_and_off_disarms() { - let _restore = EnvRestore::capture(LAYERED_TRANSCRIPTION_ENV); - - unsafe { std::env::remove_var(LAYERED_TRANSCRIPTION_ENV) }; - assert!(tail_patch_enabled(), "default arms the live tail patch"); - - unsafe { std::env::set_var(LAYERED_TRANSCRIPTION_ENV, "off") }; - assert!(!tail_patch_enabled(), "explicit off disarms"); - - unsafe { std::env::set_var(LAYERED_TRANSCRIPTION_ENV, "phase1") }; - assert!(tail_patch_enabled(), "phase1 arms Layer 1"); + assert!( + layered_phase_from_raw(None).is_some_and(|phase| phase >= 1), + "the unset production default must arm the live tail patch" + ); + assert!( + parse_layered_phase_value("off").is_none(), + "explicit off disarms" + ); + assert_eq!( + parse_layered_phase_value("phase1"), + Some(1), + "phase1 arms Layer 1" + ); } /// Bridge stdout lines with multiple `final` events parse as phrase seals. @@ -1596,6 +4344,51 @@ mod tests { // ── w1-b utterance_drop: shared_opener_restart_suppresses_freeze ──────── + /// One checked-in vector source is consumed by this Rust mirror and the + /// Swift bridge self-test. The measured 40→20 non-prefix collapse is the + /// RED discriminator: threshold-only restart detection currently loses it. + #[test] + fn fleet_red_retention_missed_collapse_40_to_20() { + let vectors = include_str!("../../../tests/fixtures/phrase_restart_vectors.tsv"); + let required_ids = [ + "measured_restart_47_to_12", + "measured_revision_95_to_79", + "missed_collapse_40_to_20", + "shared_opener_sentence_restart", + "shared_opener_spoken_variant", + ]; + let mut seen_ids = std::collections::BTreeSet::new(); + + for line in vectors.lines().filter(|line| !line.starts_with('#')) { + let fields: Vec<_> = line.split('\t').collect(); + assert_eq!(fields.len(), 4, "malformed phrase restart vector: {line}"); + seen_ids.insert(fields[0]); + let expected = fields[1] + .parse::() + .expect("expected_freeze must be true or false"); + let actual = phrase_restart_should_freeze_prior(fields[2], fields[3]); + if fields[0] == "missed_collapse_40_to_20" { + assert_eq!(fields[2].chars().count(), 40); + assert_eq!(fields[3].chars().count(), 20); + } + assert_eq!( + actual, + expected, + "phrase restart vector {} diverged: prev_chars={} next_chars={}", + fields[0], + fields[2].chars().count(), + fields[3].chars().count() + ); + } + + for required_id in required_ids { + assert!( + seen_ids.contains(required_id), + "required phrase restart vector missing: {required_id}" + ); + } + } + /// Measured three-way pattern: after a long open partial, SFSpeech collapses /// onto the next sentence's shared opener (`Zdanie`). That collapse MUST /// freeze the prior utterance — the old rule did not, and s6/s8/s10 vanished. @@ -1616,31 +4409,38 @@ mod tests { ); } - /// Mid-phrase revision keeps most of the text without being a prefix - /// collapse — must NOT freeze (would double-seal the same span). + /// Revisions and rewinds must retain the prior text; only a forward + /// extension that contains the full prior hypothesis may replace it. #[test] - fn utterance_drop_revision_mid_reword_does_not_freeze() { - // 95 → 79 char mid-reword: not a restart collapse by the measured rule. + fn utterance_drop_revision_and_rewind_retain_prior() { + // 95 → 79 char mid-reword is classified as a revision, but still + // freezes because otherwise its removed span has no retained copy. let prev = format!("{}MIDDLE{}", "x".repeat(50), "y".repeat(39)); let next = format!("{}REVISE{}", "x".repeat(50), "y".repeat(23)); assert_eq!(prev.len(), 95); assert_eq!(next.len(), 79); assert!( - !phrase_restart_should_freeze_prior(&prev, &next), - "revision must not freeze (residual duplication bar)" + phrase_restart_should_freeze_prior(&prev, &next), + "revision must retain the prior hypothesis" ); - // Growth is never a restart. + // Forward growth contains the complete prior hypothesis. assert!(!phrase_restart_should_freeze_prior( "Zdanie", "Zdanie szóste spokojnie" )); - // Same-phrase rewind to a substantial true prefix stays open. + // Same-phrase rewind is not safe unless the prior copy is retained. let long = "Hello world this is a long phrase that continues for a while more text here"; let rewind: String = long.chars().take(40).collect(); assert!( - !phrase_restart_should_freeze_prior(long, &rewind), - "substantial true-prefix rewind is same-phrase, not a freeze" + phrase_restart_should_freeze_prior(long, &rewind), + "substantial true-prefix rewind must retain its removed suffix" ); + assert!(phrase_restart_should_freeze_prior(long, "")); + assert!(!phrase_restart_should_freeze_prior("", "new phrase")); + assert!(!phrase_restart_should_freeze_prior( + "middle retained", + "new prefix middle retained and suffix" + )); } /// End-to-end at the adjudication layer: a partial sequence that used to @@ -1658,27 +4458,39 @@ mod tests { vec![ LiveStreamEvent::Partial { text: s5.to_string(), + segments: vec![segment(s5, 0.0, 5.0)], }, // Stressor phrase seals cleanly (isFinal or prior freeze). LiveStreamEvent::PhraseFinal { text: s5.to_string(), - segments: vec![], + segments: vec![segment(s5, 0.0, 5.0)], }, // Post-stressor sentence builds as open partial… LiveStreamEvent::Partial { text: s6.to_string(), + segments: vec![segment(s6, 5.0, 10.0)], }, // …then SFSpeech restarts onto the next opener without isFinal. // Old rule overwrote s6; new rule freezes it first. LiveStreamEvent::Partial { text: "Zdanie".to_string(), + segments: vec![segment("Zdanie", 10.0, 10.5)], }, LiveStreamEvent::Partial { text: "Zdanie siódme Overlap cztery angielskie terminy w polskim".to_string(), + segments: vec![segment( + "Zdanie siódme Overlap cztery angielskie terminy w polskim", + 10.0, + 15.0, + )], }, LiveStreamEvent::PhraseFinal { text: "Zdanie siódme Overlap cztery angielskie terminy w polskim".to_string(), - segments: vec![], + segments: vec![segment( + "Zdanie siódme Overlap cztery angielskie terminy w polskim", + 10.0, + 15.0, + )], }, ], &tx, @@ -1710,4 +4522,446 @@ mod tests { state.sealed_count ); } + + // ═══════════════════════════════════════════════════════════ + // Engine lifecycle: speech epochs (hands-free silence) + // ═══════════════════════════════════════════════════════════ + + /// Amplitude stand-in for the session Silero's `speech_live` bit, so the + /// epoch state machine can be driven on synthetic PCM without loading the + /// VAD model (unit tests must not depend on `init_silero_vad` succeeding). + fn amplitude_edge(samples: &[f32], threshold: f32) -> bool { + samples.iter().any(|s| s.abs() >= threshold) + } + + /// One second of 200 Hz tone at `amplitude`, the "speech" side of the fixture. + fn tone(secs: f32, amplitude: f32) -> Vec { + let total = (secs * TEST_SAMPLE_RATE as f32) as usize; + (0..total) + .map(|i| { + let t = i as f32 / TEST_SAMPLE_RATE as f32; + amplitude * (2.0 * std::f32::consts::PI * 200.0 * t).sin() + }) + .collect() + } + + fn silence(secs: f32) -> Vec { + vec![0.0; (secs * TEST_SAMPLE_RATE as f32) as usize] + } + + /// Drive the gate the way the worker does — chunk by chunk — collecting + /// every decision together with the cursor it was taken at. + fn drive(gate: &mut EpochGate, audio: &[f32], samples_seen: &mut u64) -> Vec { + let mut out = Vec::new(); + for chunk in audio.chunks(1024) { + *samples_seen += chunk.len() as u64; + out.push(gate.feed_pcm(chunk, *samples_seen, amplitude_edge(chunk, 0.1))); + } + out + } + + /// Timestamp shim: bridge time is per-epoch (seconds since that SFSpeech + /// request opened), so every event leaving a non-zero epoch must be lifted + /// onto the session PCM clock before any seal maps it to samples. + #[test] + fn epoch_shift_lifts_segment_times_onto_the_session_pcm_clock() { + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 110.0); + + let shifted = shift_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], + }], + 100.0, + ); + let LiveStreamEvent::PhraseFinal { segments, .. } = &shifted[0] else { + panic!("shim must preserve the event kind"); + }; + assert_eq!(segments[0].start_ts, 100.5); + assert_eq!(segments[0].end_ts, 102.0); + + let on_pcm = apple_segments_on_pcm_clock(&state, segments); + assert_eq!( + on_pcm[0].range.sample_start, + (100.5 * TEST_SAMPLE_RATE as f32) as u64 + ); + assert_eq!( + on_pcm[0].range.sample_end, + (102.0 * TEST_SAMPLE_RATE as f32) as u64 + ); + } + + /// The first epoch is based at 0, so the shim must be the identity there — + /// this is what keeps a single-epoch take bit-identical to the legacy lane. + #[test] + fn epoch_shift_at_base_zero_is_identity() { + let shifted = shift_events( + vec![ + LiveStreamEvent::Partial { + text: "uruchom".into(), + segments: vec![segment("uruchom", 0.5, 2.0)], + }, + LiveStreamEvent::Summary { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 4.0)], + ok: true, + error: None, + }, + ], + 0.0, + ); + let LiveStreamEvent::Partial { segments, .. } = &shifted[0] else { + panic!("kind preserved"); + }; + assert_eq!((segments[0].start_ts, segments[0].end_ts), (0.5, 2.0)); + let LiveStreamEvent::Summary { segments, .. } = &shifted[1] else { + panic!("kind preserved"); + }; + assert_eq!((segments[0].start_ts, segments[0].end_ts), (0.5, 4.0)); + } + + /// Engine lifecycle: speech opens an epoch, silence past the product + /// threshold closes it, and the next speech edge wakes a new one whose + /// base carries the pre-roll. + #[test] + fn epoch_gate_sleeps_after_threshold_silence_and_wakes_with_preroll() { + let mut gate = EpochGate::armed(TEST_SAMPLE_RATE, 5.0); + let mut seen = 0u64; + + let speech = drive(&mut gate, &tone(2.0, 0.5), &mut seen); + assert!( + matches!( + speech.first(), + Some(EpochDecision::Wake { preroll_from: 0 }) + ), + "first speech chunk must open epoch 0 (nothing retained before it), got {:?}", + speech.first() + ); + assert!( + speech[1..].iter().all(|d| *d == EpochDecision::Forward), + "speech after the wake must forward, got {:?}", + &speech[1..] + ); + + let quiet = drive(&mut gate, &silence(6.0), &mut seen); + let sleep_at = quiet + .iter() + .position(|d| matches!(d, EpochDecision::Sleep { .. })) + .expect("6 s of silence at a 5 s threshold must close the epoch"); + let sleep_secs = (sleep_at + 1) as f32 * 1024.0 / TEST_SAMPLE_RATE as f32; + assert!( + (5.0..5.2).contains(&sleep_secs), + "epoch must close within a chunk of the 5 s threshold, closed at {sleep_secs}s" + ); + assert!( + quiet[sleep_at + 1..] + .iter() + .all(|d| *d == EpochDecision::Idle), + "after sleeping the engine rests until the next speech edge" + ); + + let sleep_cursor = seen - (quiet.len() - sleep_at - 1) as u64 * 1024; + let resume_cursor = seen; + let woke = drive(&mut gate, &tone(1.0, 0.5), &mut seen); + let EpochDecision::Wake { preroll_from } = woke[0] else { + panic!("speech after rest must wake a new epoch, got {:?}", woke[0]); + }; + let preroll = (EPOCH_PREROLL_SECS * TEST_SAMPLE_RATE as f32) as u64; + assert_eq!( + preroll_from, + resume_cursor.saturating_sub(preroll), + "the new epoch base is one pre-roll ahead of the waking chunk" + ); + assert!( + preroll_from >= sleep_cursor, + "pre-roll must not reach back into the closed epoch ({preroll_from} < {sleep_cursor})" + ); + } + + /// `utterance_silence_sec: None` is the legacy contract: one stream for the + /// whole take, no epoch decisions at all. + #[test] + fn epoch_gate_disarmed_never_sleeps_or_wakes() { + let mut gate = EpochGate::disarmed(); + assert!(!gate.is_armed()); + let mut seen = 0u64; + let mut decisions = drive(&mut gate, &tone(1.0, 0.5), &mut seen); + decisions.extend(drive(&mut gate, &silence(30.0), &mut seen)); + decisions.extend(drive(&mut gate, &tone(1.0, 0.5), &mut seen)); + assert!( + decisions.iter().all(|d| *d == EpochDecision::Forward), + "disarmed gate must forward every chunk, got {:?}", + decisions + .iter() + .filter(|d| **d != EpochDecision::Forward) + .collect::>() + ); + } + + /// No Silero ⇒ no edges ⇒ the lifecycle must NOT arm, or the take would rest + /// forever on a stream that never opened. Fail open, every time. + #[test] + fn epoch_gate_without_speech_edges_falls_back_to_one_stream() { + let gate = EpochGate::for_session(TEST_SAMPLE_RATE, Some(5.0), false); + assert!( + !gate.is_armed(), + "an armed gate with no edge source would sleep the engine forever" + ); + let armed = EpochGate::for_session(TEST_SAMPLE_RATE, Some(5.0), true); + assert!(armed.is_armed()); + assert!( + !EpochGate::for_session(TEST_SAMPLE_RATE, None, true).is_armed(), + "no hands-free silence setting is still the legacy single stream" + ); + } + + // ═══════════════════════════════════════════════════════════ + // Utterance identity bound to the spectrum + // ═══════════════════════════════════════════════════════════ + + /// Samples per second at the test rate, as a `u64` sample cursor. + fn at(secs: f32) -> u64 { + (secs * TEST_SAMPLE_RATE as f32) as u64 + } + + /// Arm a state with the session Silero and mint two utterances separated by + /// a silence wider than the long-silence fence, exactly as the Supervisor + /// would: an open edge that extends, then a close, then a new edge. + /// + /// The ledger is driven through the production decision function + /// ([`SileroIngress::observe`]) rather than a synthetic ledger, so what the + /// seal reads is what a real chunk observation produces. Only the two facts + /// Silero derives from the waveform are supplied by the fixture — the unit + /// suite must not depend on `init_silero_vad` succeeding. + fn arm_two_utterances(state: &mut AppleSealState) -> (u64, u64) { + let mut ingress = SileroIngress::new(TEST_SAMPLE_RATE, state.session_id.clone(), 0); + let first = ingress + .observe(Some((at(0.0), at(1.0))), false, at(1.0)) + .open + .expect("first speech edge mints an identity"); + ingress.observe(Some((at(0.0), at(2.0))), false, at(2.0)); + let closed = ingress.observe(None, true, at(2.0)).closed; + assert_eq!(closed, vec![first]); + + // Silence well past LONG_SILENCE_FENCE_SECS, then a second edge. + let gap = at(super::super::silero_fusion::LONG_SILENCE_FENCE_SECS) + at(1.0); + let second_start = at(2.0) + gap; + let second = ingress + .observe( + Some((second_start, second_start + at(2.0))), + false, + second_start + at(2.0), + ) + .open + .expect("speech after the fence mints a SECOND identity"); + assert_ne!(first, second, "the fence must split identity"); + + state.fusion = Some(ingress); + state.fusion_seal_armed = true; + (first, second) + } + + /// (a) Utterance identity comes from the spectrum, and the seal carries it. + /// + /// Two Apple finals landing inside two Silero-bounded utterances must seal + /// as two spans whose ids ARE the ledger ids and whose ranges ARE the + /// ledger ranges — not Apple's own segment boundaries. + #[test] + fn sealed_spans_take_identity_and_range_from_silero_edges() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 12.0); + let (first, second) = arm_two_utterances(&mut state); + let ledger = state.fusion.as_ref().unwrap().ledger().clone(); + + // One final inside utterance 1, one inside utterance 2. + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "pierwsza fraza".into(), + segments: vec![segment("pierwsza fraza", 0.2, 1.8)], + }], + &tx, + &mut state, + 2.2, + ); + let second_start = + ledger.utterances()[1].range.sample_start as f32 / TEST_SAMPLE_RATE as f32; + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "druga fraza".into(), + segments: vec![segment( + "druga fraza", + second_start + 0.2, + second_start + 1.8, + )], + }], + &tx, + &mut state, + second_start + 2.2, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!(sealed.len(), 2, "two utterances ⇒ two spans: {sealed:#?}"); + for (span, utterance_id) in sealed.iter().zip([first, second]) { + let utterance = ledger + .utterances() + .iter() + .find(|u| u.id == utterance_id) + .expect("fixture identity must exist in the ledger"); + assert_eq!( + span.silero_utterance_id, + Some(utterance_id), + "span {} did not record the spectrum edge it came from", + span.id + ); + assert_eq!( + span.range.sample_start, utterance.range.sample_start, + "span {} start is not the Silero edge", + span.id + ); + assert_eq!( + span.range.sample_end, utterance.range.sample_end, + "span {} end is not the Silero edge", + span.id + ); + } + assert_ne!( + sealed[0].silero_utterance_id, sealed[1].silero_utterance_id, + "a fenced silence must produce two DIFFERENT identities" + ); + } + + /// (c) Words stay pinned to the PCM counter after binding: every Apple word + /// range on a bound span lies inside the utterance range it was bound to. + /// This is the "words on spectrum events" claim — without it a span could + /// carry an utterance id while its words describe other seconds. + #[test] + fn bound_span_words_stay_inside_their_utterance_on_the_pcm_clock() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 12.0); + arm_two_utterances(&mut state); + let ledger = state.fusion.as_ref().unwrap().ledger().clone(); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom", 0.2, 0.9), segment("doker", 0.9, 1.8)], + }], + &tx, + &mut state, + 2.2, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!(sealed.len(), 1); + let span = &sealed[0]; + let utterance_id = span + .silero_utterance_id + .expect("the span must be bound to an edge"); + let utterance = ledger + .utterances() + .iter() + .find(|u| u.id == utterance_id) + .unwrap(); + assert!(!span.words.is_empty(), "a bound span must keep its words"); + for word in &span.words { + assert!( + word.range.sample_start >= utterance.range.sample_start + && word.range.sample_end <= utterance.range.sample_end, + "word {:?} at {}..{} escapes utterance {} at {}..{}", + word.text, + word.range.sample_start, + word.range.sample_end, + utterance_id, + utterance.range.sample_start, + utterance.range.sample_end + ); + assert!( + word.range.sample_start < word.range.sample_end, + "a word must occupy real samples, not a point" + ); + } + assert_eq!( + span.words.first().unwrap().range.sample_start, + at(0.2), + "word start must stay on the PCM counter it was mapped from" + ); + assert_eq!(span.words.last().unwrap().range.sample_end, at(1.8)); + } + + /// A span the spectrum does not enclose keeps Apple's own range and records + /// no identity — binding is fail-open and never costs content. + #[test] + fn span_outside_every_silero_edge_keeps_the_apple_range() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 30.0); + arm_two_utterances(&mut state); + + // 20 s is past every minted edge; slicing finds no cover either, so the + // Apple-boundary path runs and must still seal. + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "poza spektrum".into(), + segments: vec![segment("poza spektrum", 20.0, 21.0)], + }], + &tx, + &mut state, + 21.5, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!( + sealed.len(), + 1, + "content must never be dropped for want of an edge" + ); + assert_eq!( + sealed[0].silero_utterance_id, None, + "no enclosing edge ⇒ no identity claimed" + ); + assert_eq!(sealed[0].range.sample_start, at(20.0)); + assert_eq!(sealed[0].range.sample_end, at(21.0)); + assert!( + !state + .fusion + .as_ref() + .unwrap() + .ledger() + .utterances() + .iter() + .any(|u| u.id == sealed[0].id), + "the fallback id must be reserved out of the ledger's id space, \ + never collide with a minted utterance" + ); + } + + /// (b) Fail-open: no Silero at all is today's behaviour, bit for bit. + /// Spans still seal, on Apple's own boundaries, with no identity claimed. + #[test] + fn without_silero_the_seal_path_is_unchanged() { + let (tx, _rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 12.0); + assert!(state.fusion.is_none(), "fixture has no VAD"); + + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "uruchom doker".into(), + segments: vec![segment("uruchom doker", 0.5, 2.0)], + }], + &tx, + &mut state, + 2.2, + ); + + let sealed = state.progressive.sealed_spans(); + assert_eq!(sealed.len(), 1); + assert_eq!(sealed[0].id, 1, "legacy ids still start at 1"); + assert_eq!(sealed[0].silero_utterance_id, None); + assert_eq!(sealed[0].range.sample_start, at(0.5)); + assert_eq!(sealed[0].range.sample_end, at(2.0)); + } } diff --git a/core/pipeline/streaming/correction.rs b/core/pipeline/streaming/correction.rs index 480582b5..5dd67586 100644 --- a/core/pipeline/streaming/correction.rs +++ b/core/pipeline/streaming/correction.rs @@ -734,13 +734,16 @@ pub(crate) fn schedule_partial_pass( "BOUNDARY correction_scheduled" ); + let window_prompt = crate::pipeline::stream_postprocess::compose_whisper_window_prompt( + previous_window_prompt.as_deref(), + ); match stt_scheduler.submit_for_utterance_with_prompt( SttLane::Refine, audio, output_sample_rate, pipeline_language, window_id, - previous_window_prompt, + window_prompt, ) { Ok(handle) => { rolling_window.commit(correction_audio_buf, output_sample_rate, &candidate); diff --git a/core/pipeline/streaming/emitter.rs b/core/pipeline/streaming/emitter.rs index 4c353215..335d8c2b 100644 --- a/core/pipeline/streaming/emitter.rs +++ b/core/pipeline/streaming/emitter.rs @@ -154,8 +154,12 @@ impl BufferedEmitter { } /// Overwrite the shared transcript buffer wholesale, bypassing the animation. - /// For callers that already hold the authoritative text (e.g. a finalized - /// pass) and need the buffer to match immediately. + /// + /// This is the ONLY writer of the shared buffer. The tick loop animates the + /// `DeltaSink` but never touches the buffer: when both wrote (snapshot of the + /// full target + the same suffix appended again by the animation), repeated + /// sentences tripled in the final RAW (2026-08-14 incident: a 264-char + /// cumulative preview became a 791-char transcript). pub async fn store_transcript_snapshot(&self, snapshot: String) { let mut buffer = self.transcript_buffer.lock().await; *buffer = snapshot; @@ -216,10 +220,6 @@ impl BufferedEmitter { && let Some(delta) = build_redacted_delta(&self.emitted_text, &corrected) { apply_delta_to_string(&mut self.emitted_text, &delta); - { - let mut buffer = self.transcript_buffer.lock().await; - *buffer = self.emitted_text.clone(); - } if let Some(sink) = &self.delta_callback { sink.apply(&TranscriptDelta::from_raw(&delta)); } @@ -245,10 +245,6 @@ impl BufferedEmitter { if let Some(delta) = self.next_emit_chunk() { self.has_output = true; self.emitted_text.push_str(&delta); - { - let mut buffer = self.transcript_buffer.lock().await; - apply_delta_to_string(&mut buffer, &delta); - } if let Some(sink) = &self.delta_callback { sink.apply(&TranscriptDelta::from_raw(&delta)); diff --git a/core/pipeline/streaming/layer1_window.rs b/core/pipeline/streaming/layer1_window.rs new file mode 100644 index 00000000..c00f72aa --- /dev/null +++ b/core/pipeline/streaming/layer1_window.rs @@ -0,0 +1,469 @@ +//! Layer 1 window: coalesce ~5 Apple segments into one Whisper job. +//! +//! Apple seals short fragments. Diffing each fragment against its own Whisper +//! window hits the change-ratio cap and leaves the chopped canvas standing. +//! This module joins a handful of those fragments — text, PCM, and char +//! offsets — so one decode can rewrite the sentence, then maps +//! `ReplaceRange` events back onto the original utterance ids. + +use crate::pipeline::contracts::{EngineEvent, LayerSource}; +use crate::stt::tail_patcher::{TailPatchOutcome, UnderCommit}; + +/// One utterance's slice inside a concatenated Layer 1 window. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConcatSpan { + pub utterance_id: u64, + /// Inclusive char start in the concatenated committed string. + pub start: usize, + /// Exclusive char end in the concatenated committed string. + pub end: usize, +} + +/// One sealed Apple fragment waiting to share a Whisper window. +#[derive(Debug, Clone)] +pub struct CoalescedPiece { + pub utterance_id: u64, + pub committed_text: String, + pub audio: Vec, + pub sample_start: u64, + pub sample_end: u64, + pub start_ts: f32, + pub covered_through_secs: f32, + pub segment_count: usize, +} + +/// Ready-to-send Layer 1 job built from one or more coalesced pieces. +#[derive(Debug, Clone)] +pub struct CoalesceFlush { + pub committed_text: String, + pub audio: Vec, + pub spans: Vec, + pub member_ids: Vec<(u64, f32)>, + pub neighbour_context: String, + pub covered_through_secs: f32, + pub sample_start: u64, + pub sample_end: u64, + pub primary_utterance_id: u64, +} + +/// Rolling buffer of sealed Apple fragments for one Layer 1 decode. +#[derive(Debug, Default)] +pub struct Layer1Coalesce { + pieces: Vec, + neighbour_before: String, + segments: usize, +} + +impl Layer1Coalesce { + /// Darek's live window: swap after about five Apple segments. + pub const TARGET_SEGMENTS: usize = 5; + /// Hard cap so a long run-on still gets a decode. + pub const MAX_AUDIO_SECS: f32 = 16.0; + /// A pause this long is a sentence boundary — flush what we have. + pub const PAUSE_SECS: f32 = 1.2; + + pub fn is_empty(&self) -> bool { + self.pieces.is_empty() + } + + /// Remember the canvas already sealed before the next piece. + pub fn set_neighbour(&mut self, neighbour: impl Into) { + if self.pieces.is_empty() { + self.neighbour_before = neighbour.into(); + } + } + + /// Push a sealed fragment. Returns a flush when the window is full, or + /// when `piece` starts after a sentence pause (the previous window first). + pub fn push(&mut self, piece: CoalescedPiece, sample_rate: u32) -> Vec { + let mut out = Vec::new(); + if let Some(last) = self.pieces.last() { + let gap = piece.start_ts - last.covered_through_secs; + if gap >= Self::PAUSE_SECS + && let Some(flush) = self.take_flush() + { + out.push(flush); + } + } + if self.pieces.is_empty() && self.neighbour_before.is_empty() { + // Neighbour is set by the caller before the first push of a window. + } + self.segments = self.segments.saturating_add(piece.segment_count.max(1)); + self.pieces.push(piece); + if self.should_flush(sample_rate) + && let Some(flush) = self.take_flush() + { + out.push(flush); + } + out + } + + /// Drain whatever is held — session end, epoch sleep, or test. + pub fn force_flush(&mut self) -> Option { + self.take_flush() + } + + fn should_flush(&self, sample_rate: u32) -> bool { + if self.pieces.is_empty() { + return false; + } + if self.segments >= Self::TARGET_SEGMENTS { + return true; + } + let samples: u64 = self + .pieces + .iter() + .map(|p| p.sample_end.saturating_sub(p.sample_start)) + .sum(); + let rate = sample_rate.max(1) as f32; + (samples as f32 / rate) >= Self::MAX_AUDIO_SECS + } + + fn take_flush(&mut self) -> Option { + if self.pieces.is_empty() { + return None; + } + let pieces = std::mem::take(&mut self.pieces); + self.segments = 0; + let neighbour_context = std::mem::take(&mut self.neighbour_before); + Some(build_flush(pieces, neighbour_context)) + } +} + +fn build_flush(pieces: Vec, neighbour_context: String) -> CoalesceFlush { + let mut committed_text = String::new(); + let mut audio = Vec::new(); + let mut spans = Vec::with_capacity(pieces.len()); + let mut member_ids = Vec::with_capacity(pieces.len()); + let mut offset = 0usize; + let sample_start = pieces.first().map_or(0, |p| p.sample_start); + let sample_end = pieces.last().map_or(0, |p| p.sample_end); + let covered_through_secs = pieces.last().map_or(0.0, |p| p.covered_through_secs); + let primary_utterance_id = pieces.last().map_or(0, |p| p.utterance_id); + for (i, piece) in pieces.into_iter().enumerate() { + if i > 0 { + committed_text.push(' '); + offset += 1; + } + let start = offset; + committed_text.push_str(&piece.committed_text); + offset += piece.committed_text.chars().count(); + spans.push(ConcatSpan { + utterance_id: piece.utterance_id, + start, + end: offset, + }); + member_ids.push((piece.utterance_id, piece.covered_through_secs)); + audio.extend_from_slice(&piece.audio); + } + CoalesceFlush { + committed_text, + audio, + spans, + member_ids, + neighbour_context, + covered_through_secs, + sample_start, + sample_end, + primary_utterance_id, + } +} + +/// Map concat-space `ReplaceRange` events onto utterance-local offsets. +/// +/// A patch that stays inside one span is remapped 1:1. A patch that crosses +/// a join lands on the first overlapped utterance from the local start to +/// that utterance's end — later fragments in the same cross are left intact +/// so we never wipe a committed span we cannot address cleanly. +pub fn remap_concat_events(events: Vec, spans: &[ConcatSpan]) -> Vec { + if spans.is_empty() { + return events; + } + if spans.len() == 1 { + return events + .into_iter() + .map(|event| remap_single(event, spans[0].utterance_id)) + .collect(); + } + let mut out = Vec::with_capacity(events.len()); + for event in events { + match event { + EngineEvent::ReplaceRange { + start, + end, + text, + source, + .. + } => { + if let Some(mapped) = remap_range(start, end, text, source, spans) { + out.push(mapped); + } + } + other => out.push(other), + } + } + out +} + +fn remap_single(event: EngineEvent, utterance_id: u64) -> EngineEvent { + match event { + EngineEvent::ReplaceRange { + start, + end, + text, + source, + .. + } => EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + source, + }, + other => other, + } +} + +fn remap_range( + start: usize, + end: usize, + text: String, + source: LayerSource, + spans: &[ConcatSpan], +) -> Option { + let first = span_owning(start, spans)?; + let last_pos = end.saturating_sub(1).max(start); + let last = span_owning(last_pos, spans).unwrap_or(first); + let local_start = start.saturating_sub(first.start); + let local_end = if first.utterance_id == last.utterance_id { + end.saturating_sub(first.start).min(first.end - first.start) + } else { + first.end - first.start + }; + Some(EngineEvent::ReplaceRange { + utterance_id: first.utterance_id, + start: local_start, + end: local_end, + text, + source, + }) +} + +fn span_owning(pos: usize, spans: &[ConcatSpan]) -> Option<&ConcatSpan> { + spans + .iter() + .find(|span| pos >= span.start && pos < span.end) + .or_else(|| { + // Zero-width insert exactly on a join belongs to the previous span. + spans.iter().rev().find(|span| pos == span.end) + }) +} + +/// Split a remapped outcome so each member utterance can seal independently. +pub fn split_outcome_for_members( + outcome: TailPatchOutcome, + spans: &[ConcatSpan], + member_ids: &[(u64, f32)], +) -> Vec<(u64, f32, TailPatchOutcome)> { + if member_ids.is_empty() { + return Vec::new(); + } + if spans.len() <= 1 { + let (id, end) = member_ids[0]; + return vec![(id, end, outcome)]; + } + match outcome { + TailPatchOutcome::NoChange => member_ids + .iter() + .map(|&(id, end)| (id, end, TailPatchOutcome::NoChange)) + .collect(), + TailPatchOutcome::Skipped { code, reason } => { + let mut out = Vec::with_capacity(member_ids.len()); + out.push(( + member_ids[0].0, + member_ids[0].1, + TailPatchOutcome::Skipped { code, reason }, + )); + for &(id, end) in &member_ids[1..] { + out.push((id, end, TailPatchOutcome::NoChange)); + } + out + } + TailPatchOutcome::Patches(events) => { + let remapped = remap_concat_events(events, spans); + group_events(remapped, member_ids) + } + TailPatchOutcome::UnderCommit(under) => { + let residual = under.residual_required; + let remapped = remap_concat_events(under.appends.clone(), spans); + group_events(remapped, member_ids) + .into_iter() + .map(|(id, end, oc)| { + let appends = oc.into_events(); + ( + id, + end, + TailPatchOutcome::UnderCommit(UnderCommit { + appends, + residual_required: residual && id == member_ids[0].0, + committed_tokens: under.committed_tokens, + retranscribed_tokens: under.retranscribed_tokens, + committed_chars: under.committed_chars, + retranscribed_chars: under.retranscribed_chars, + commit_ratio: under.commit_ratio, + }), + ) + }) + .collect() + } + } +} + +fn group_events( + events: Vec, + member_ids: &[(u64, f32)], +) -> Vec<(u64, f32, TailPatchOutcome)> { + let mut out = Vec::with_capacity(member_ids.len()); + for &(id, end) in member_ids { + let evs: Vec = events + .iter() + .filter(|event| match event { + EngineEvent::ReplaceRange { utterance_id, .. } => *utterance_id == id, + _ => false, + }) + .cloned() + .collect(); + let oc = if evs.is_empty() { + TailPatchOutcome::NoChange + } else { + TailPatchOutcome::Patches(evs) + }; + out.push((id, end, oc)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn piece(id: u64, text: &str, start_ts: f32, end_ts: f32, segs: usize) -> CoalescedPiece { + let rate = 16_000u64; + CoalescedPiece { + utterance_id: id, + committed_text: text.to_string(), + audio: vec![0.0; ((end_ts - start_ts) * rate as f32) as usize], + sample_start: (start_ts * rate as f32) as u64, + sample_end: (end_ts * rate as f32) as u64, + start_ts, + covered_through_secs: end_ts, + segment_count: segs, + } + } + + #[test] + fn flushes_after_five_segments() { + let mut buf = Layer1Coalesce::default(); + buf.set_neighbour("already sealed"); + let mut flushes = Vec::new(); + for i in 0..5 { + flushes.extend(buf.push(piece(i + 1, "słowo", i as f32, i as f32 + 0.4, 1), 16_000)); + } + assert_eq!(flushes.len(), 1); + assert_eq!(flushes[0].spans.len(), 5); + assert_eq!(flushes[0].committed_text, "słowo słowo słowo słowo słowo"); + assert_eq!(flushes[0].neighbour_context, "already sealed"); + assert_eq!(flushes[0].primary_utterance_id, 5); + assert!(buf.is_empty()); + } + + #[test] + fn pause_flushes_the_previous_window() { + let mut buf = Layer1Coalesce::default(); + assert!(buf.push(piece(1, "raz", 0.0, 0.5, 1), 16_000).is_empty()); + let flushes = buf.push(piece(2, "dwa", 3.0, 3.4, 1), 16_000); + assert_eq!(flushes.len(), 1); + assert_eq!(flushes[0].spans.len(), 1); + assert_eq!(flushes[0].committed_text, "raz"); + assert!(!buf.is_empty()); + } + + #[test] + fn remap_stays_inside_the_owning_utterance() { + let spans = vec![ + ConcatSpan { + utterance_id: 1, + start: 0, + end: 4, + }, + ConcatSpan { + utterance_id: 2, + start: 5, + end: 9, + }, + ]; + // "ala ma" — replace "ma" (chars 5..7) on utterance 2. + let events = vec![EngineEvent::ReplaceRange { + utterance_id: 99, + start: 5, + end: 7, + text: "psa".into(), + source: LayerSource::TailPatch, + }]; + let remapped = remap_concat_events(events, &spans); + match &remapped[0] { + EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + .. + } => { + assert_eq!(*utterance_id, 2); + assert_eq!(*start, 0); + assert_eq!(*end, 2); + assert_eq!(text, "psa"); + } + other => panic!("expected remap, got {other:?}"), + } + } + + #[test] + fn crossing_patch_lands_on_the_first_span() { + let spans = vec![ + ConcatSpan { + utterance_id: 1, + start: 0, + end: 4, + }, + ConcatSpan { + utterance_id: 2, + start: 5, + end: 9, + }, + ]; + let events = vec![EngineEvent::ReplaceRange { + utterance_id: 99, + start: 2, + end: 8, + text: "pełne zdanie".into(), + source: LayerSource::TailPatch, + }]; + let remapped = remap_concat_events(events, &spans); + match &remapped[0] { + EngineEvent::ReplaceRange { + utterance_id, + start, + end, + text, + .. + } => { + assert_eq!(*utterance_id, 1); + assert_eq!(*start, 2); + assert_eq!(*end, 4); + assert_eq!(text, "pełne zdanie"); + } + other => panic!("expected first-span landing, got {other:?}"), + } + } +} diff --git a/core/pipeline/streaming/live_assembly.rs b/core/pipeline/streaming/live_assembly.rs index 476727de..b81e19ff 100644 --- a/core/pipeline/streaming/live_assembly.rs +++ b/core/pipeline/streaming/live_assembly.rs @@ -1,7 +1,8 @@ //! Product live assembly: freezed sealed utterances + open interim tail. //! //! This is the Codescribe engine contract for overlay and delivery floor: -//! - each non-empty `UtteranceFinal` freezes a segment (append) +//! - each new non-empty `UtteranceFinal` ID freezes a segment; the same ID +//! revises that slot in place //! - `Preview` / `Correction` only replace the open tail //! - full live text = freezed segments joined + optional open preview //! @@ -69,8 +70,12 @@ pub fn assemble_live_from_events(events: &[EngineEvent]) -> LiveAssembly { } => { let trimmed = text.trim(); if !trimmed.is_empty() { - freezed.push(trimmed.to_string()); - freezed_ids.push(*utterance_id); + if let Some(index) = freezed_ids.iter().rposition(|id| id == utterance_id) { + freezed[index] = trimmed.to_string(); + } else { + freezed.push(trimmed.to_string()); + freezed_ids.push(*utterance_id); + } } preview.clear(); } @@ -80,14 +85,9 @@ pub fn assemble_live_from_events(events: &[EngineEvent]) -> LiveAssembly { // has to be visible here too — otherwise a layered-on run reads // exactly like a layered-off one and the bar gates nothing. // - // `rposition` mirrors `lastIndex(where:)` in BOTH Swift replays — - // `OverlayState.onReplaceRange` and `ComposerDictation` - // `.onReplaceRange` — so if an id was sealed twice all three patch - // the same slot. The mirror covers the PATCH lookup only: on a - // repeated seal this assembly appends a slot while both Swift - // surfaces overwrite in place, which is a real divergence and is - // pinned (not fixed) by - // `re_sealed_utterance_id_duplicates_here_but_not_in_the_swift_surfaces`. + // `rposition` mirrors `lastIndex(where:)` in both Swift replays. + // Finals are upserted by id above, so this always resolves the same + // unique slot as the production presentation reducer. EngineEvent::ReplaceRange { utterance_id, .. } => { if let Some(index) = freezed_ids.iter().rposition(|id| id == utterance_id) { // Out-of-range windows are dropped, not clamped: a patch @@ -134,33 +134,10 @@ mod tests { } } - /// Characterization, not aspiration: this pins what a **re-sealed** - /// utterance id does here, because the three surfaces that replay this same - /// event stream disagree about it and nothing was measuring the gap. - /// - /// | surface | on a repeated `UtteranceFinal` id | on `ReplaceRange` | - /// |---|---|---| - /// | this assembly (measured by the parity harness) | **appends a 2nd slot** | `rposition` → the 2nd | - /// | `OverlayState.swift:1580` (what the operator sees) | **updates in place** | `lastIndex` → the only one | - /// | `ComposerDictation.swift:257` (the agent draft) | **updates in place** | `firstIndex` → the only one | - /// - /// So under a re-seal the measured assembly renders the utterance TWICE - /// while both shipped UIs render it once. That is not an index nit: it is - /// the parity harness scoring text the product never displays, and it - /// surfaces as inflated word count — the exact shape - /// `e2e_apple_live_parity`'s ratio bar reports as "duplicated phrases". - /// - /// The contract (`EngineEvent::UtteranceFinal`, contracts.rs) says "emitted - /// once per VAD-bounded speech segment", so today this is unreachable and - /// all three agree by accident of uniqueness. Nothing enforces it, and - /// `rposition`'s own comment reasons about "if an id was sealed twice" — so - /// the assumption is load-bearing, unstated, and defended three different - /// ways. Whether the fix is to enforce uniqueness upstream or to make this - /// assembly upsert like the UIs do is a design decision with parity-number - /// consequences; this test only makes the divergence impossible to ship - /// unnoticed. + /// Re-sealing one utterance id revises its existing slot. This mirrors the + /// production presentation reducer and both Swift live surfaces. #[test] - fn re_sealed_utterance_id_duplicates_here_but_not_in_the_swift_surfaces() { + fn re_sealed_utterance_id_upserts_one_slot_on_every_surface() { let events = vec![ final_ev(1, "pierwsze zdanie"), // Same id sealed again — the Swift surfaces would overwrite slot 0. @@ -170,18 +147,16 @@ mod tests { assert_eq!( assembly.sealed_count(), - 2, - "re-seal appends here; if this ever becomes 1 the assembly was made to \ - upsert like the Swift surfaces — update the table above and re-measure \ - the parity arms, because the word-count denominator moves with it" + 1, + "a revised final id must remain one measured and displayed slot" ); assert_eq!( assembly.streaming_floor(), - "pierwsze zdanie pierwsze zdanie poprawione", - "the measured floor carries BOTH seals — the overlay would carry only the second" + "pierwsze zdanie poprawione", + "the measured floor must match the overlay's revised slot" ); - // And the patch lands on the LAST slot, not the first: `rposition`. + // The bounded patch lands on that one revised slot. let mut patched = events.clone(); patched.push(EngineEvent::ReplaceRange { utterance_id: 1, @@ -191,12 +166,7 @@ mod tests { source: LayerSource::TailPatch, }); let assembly = assemble_live_from_events(&patched); - assert_eq!( - assembly.streaming_floor(), - "pierwsze zdanie PIERWSZE zdanie poprawione", - "`rposition` targets the newest slot; `ComposerDictation.swift` uses \ - `firstIndex`, which would target the oldest if a duplicate ever existed" - ); + assert_eq!(assembly.streaming_floor(), "PIERWSZE zdanie poprawione"); } /// Multiple UtteranceFinal seals must append freezed segments into the full live string. diff --git a/core/pipeline/streaming/live_audio_buffer.rs b/core/pipeline/streaming/live_audio_buffer.rs index 25bbf26e..b439b273 100644 --- a/core/pipeline/streaming/live_audio_buffer.rs +++ b/core/pipeline/streaming/live_audio_buffer.rs @@ -55,6 +55,13 @@ pub(crate) struct LiveAudioBuffer { capacity: usize, } +/// One resolved retained window with its canonical session-sample identity. +pub(crate) struct ResolvedAudioWindow { + pub(crate) samples: Vec, + pub(crate) sample_start: u64, + pub(crate) sample_end: u64, +} + impl LiveAudioBuffer { /// Build a buffer for `sample_rate`, retaining at most `retention_secs`. pub(crate) fn new(sample_rate: u32, retention_secs: f32) -> Self { @@ -97,7 +104,19 @@ impl LiveAudioBuffer { /// /// Refusing beats truncating: a short window looks like a success and would /// address the wrong audio. + #[cfg(test)] pub(crate) fn window(&self, from_secs: f32, to_secs: f32) -> Option> { + self.window_with_range(from_secs, to_secs) + .map(|window| window.samples) + } + + /// Timestamp-safe variant of [`window`](Self::window) that keeps the exact + /// integer PCM bounds used to cut the returned samples. + pub(crate) fn window_with_range( + &self, + from_secs: f32, + to_secs: f32, + ) -> Option { let from = self.index_for(from_secs)?; let to = self.index_for(to_secs)?; if to < from || from < self.start_index || from > self.end_index { @@ -115,7 +134,11 @@ impl LiveAudioBuffer { }; let lo = (from - self.start_index) as usize; let hi = (to - self.start_index) as usize; - Some(self.samples.range(lo..hi).copied().collect()) + Some(ResolvedAudioWindow { + samples: self.samples.range(lo..hi).copied().collect(), + sample_start: from, + sample_end: to, + }) } /// Release everything before `secs` — audio already committed downstream @@ -152,6 +175,37 @@ impl LiveAudioBuffer { self.end_index as f32 / self.sample_rate as f32 } + /// Total capture samples seen, retained or evicted. + pub(crate) fn session_sample_end(&self) -> u64 { + self.end_index + } + + /// Cut `[sample_start, sample_end)` on the capture PCM clock. + /// + /// `None` when the range is inverted or has already fallen off retention. + /// Unlike [`window_with_range`](Self::window_with_range) this never + /// converts through seconds. + pub(crate) fn window_by_samples( + &self, + sample_start: u64, + sample_end: u64, + ) -> Option { + if sample_end < sample_start + || sample_start < self.start_index + || sample_start > self.end_index + { + return None; + } + let to = sample_end.min(self.end_index); + let lo = (sample_start - self.start_index) as usize; + let hi = (to - self.start_index) as usize; + Some(ResolvedAudioWindow { + samples: self.samples.range(lo..hi).copied().collect(), + sample_start, + sample_end: to, + }) + } + /// Absolute session-sample index for a session-time second, or `None` when /// the value cannot address audio at all. fn index_for(&self, secs: f32) -> Option { diff --git a/core/pipeline/streaming/mod.rs b/core/pipeline/streaming/mod.rs index 0a22ca5e..25b463c0 100644 --- a/core/pipeline/streaming/mod.rs +++ b/core/pipeline/streaming/mod.rs @@ -13,6 +13,8 @@ pub(crate) mod apple_live_session; pub(crate) mod correction; /// Buffered "typing" emission of transcript deltas. pub(crate) mod emitter; +/// Coalesce ~5 Apple segments into one Layer 1 Whisper window. +pub(crate) mod layer1_window; /// Assembly of the live transcript from engine events. pub mod live_assembly; /// Bounded per-session PCM retention, so a sealed utterance can be re-read for tail-patch. @@ -28,6 +30,10 @@ pub mod progressive_seal; pub(crate) mod quality_gate; /// Event-based transcription session: VAD ingestion, the Whisper inference loop, final emission. pub(crate) mod session; +/// W13-3B Silero identity + conservative per-word fusion (lane flag default OFF). +pub(crate) mod silero_fusion; +/// W13-4 sealed-span replay refusal + in-span loop fence (lane flag default OFF). +pub(crate) mod span_idempotence; /// Session stream-log sink (`CODESCRIBE_STREAM_LOG*` env contract). pub(crate) mod stream_log; /// Env-tunable runtime knobs shared across these modules. @@ -37,11 +43,15 @@ pub(crate) mod tuning; #[cfg(test)] mod tests; +pub use apple_live_session::APPLE_FINAL_OVERLAP_WARNING_CODE; pub use emitter::{BufferedEmitter, emitter_tick_loop}; pub use live_assembly::{LiveAssembly, assemble_live_from_events}; #[cfg(any(test, feature = "offline_eval"))] pub use offline::transcribe_streaming_samples; -pub use session::{SessionConfig, collect_buffered_engine_events, transcribe_buffered_samples}; +pub use session::{ + SessionConfig, collect_buffered_engine_events, collect_buffered_engine_events_with_config, + transcribe_buffered_samples, +}; #[cfg(test)] pub(crate) use quality_gate::should_drop_silence_chunk; diff --git a/core/pipeline/streaming/progressive_seal.rs b/core/pipeline/streaming/progressive_seal.rs index 152546cc..537595eb 100644 --- a/core/pipeline/streaming/progressive_seal.rs +++ b/core/pipeline/streaming/progressive_seal.rs @@ -17,6 +17,12 @@ use crate::pipeline::light_plus; use crate::pipeline::stream_postprocess; +use crate::stt::tail_provider::{ + TailEvidenceSource, TailEvidenceStability, TailProviderEvidence, TailRequestIdentity, + TailSampleRange, TailTimingQuality, TimedTailSegment, +}; + +use super::span_idempotence::{self, SpanIdempotenceLedger, SpanOffer}; /// Seconds after an Apple utterance commit during which the engine may still /// rewrite the open tail. Measured operator range ~2–3 s; pin the mid point. @@ -27,7 +33,7 @@ pub const APPLE_VOLATILE_WINDOW_SECS: f32 = 2.5; pub const SEAL_STARVATION_CEILING_SECS: f32 = 28.0; /// One byte-stable committed span after lexicon + (optional) Light+. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SealedSpan { /// Utterance / span identity (monotonic per session). pub id: u64, @@ -36,6 +42,21 @@ pub struct SealedSpan { pub text: String, /// Absolute session end of the sealed audio span, in seconds. pub end_secs_millis: u32, + /// Canonical half-open PCM range for this sealed Apple span. + pub range: TailSampleRange, + /// Apple word/segment evidence pinned to the same PCM clock. + pub words: Vec, + /// Typed Apple evidence recorded before any later fusion policy. + pub apple_evidence: TailProviderEvidence, + /// Typed Whisper evidence for the covering window, when Layer 1 ran. + pub whisper_evidence: Option, + /// Whisper segments mapped back to the capture PCM clock. + pub whisper_words: Vec, + /// Silero utterance this span was bound to, when the spectrum had an edge + /// enclosing it. `Some` is the evidence that [`Self::range`] came from the + /// VAD spectrum rather than from Apple's own segment boundaries; `None` + /// records the fail-open case, never a dropped span. + pub silero_utterance_id: Option, } impl SealedSpan { @@ -57,6 +78,38 @@ pub struct PendingSpan { pub end_secs: f32, /// Whisper window that fully covers this span, once known. pub covering_whisper_window_id: Option, + pub range: TailSampleRange, + pub words: Vec, + pub apple_evidence: TailProviderEvidence, + pub whisper_evidence: Option, + pub whisper_words: Vec, + /// Silero utterance this span was bound to. Carried to [`SealedSpan`]. + pub silero_utterance_id: Option, +} + +/// One Apple commit offered to the machine, with its PCM and identity +/// provenance. A record rather than a nine-argument call: every field is +/// provenance for the same span, and a positional list of that length is how +/// a range and an identity end up silently swapped. +#[derive(Debug, Clone, PartialEq)] +pub struct AppleCommit { + /// Span identity (monotonic per session; from the Silero ledger when the + /// fusion lane minted it, otherwise reserved from the same id space). + pub id: u64, + /// Raw engine text (or lexicon-ready canvas text) awaiting the seal pass. + pub raw_text: String, + /// Absolute end of the span in session audio seconds. + pub end_secs: f32, + /// Session time when Apple committed the utterance. + pub committed_at_secs: f32, + /// Canonical half-open PCM range for the span. + pub range: TailSampleRange, + /// Apple word/segment evidence pinned to the same PCM clock. + pub words: Vec, + /// Typed Apple evidence recorded before any later fusion policy. + pub apple_evidence: TailProviderEvidence, + /// Silero utterance the range was taken from, when one enclosed the span. + pub silero_utterance_id: Option, } /// One live-lane partial retained for residual stop-path fill. @@ -82,10 +135,15 @@ pub struct ProgressiveSealMachine { starvation_ceiling_hits: u64, /// Live lane health — when false, stop path may fall back to file inference. live_lane_alive: bool, + /// Session-captured W13-4 flag. Restart-only configuration must not change + /// underneath an active recording. + span_idempotence_enabled: bool, + /// W13-4 range-identity ledger. Consulted only when the lane flag is ON. + span_idempotence: SpanIdempotenceLedger, } /// Outcome of one seal evaluation pass. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct SealTick { /// Spans that sealed on this tick (lexicon → Light+ applied). pub newly_sealed: Vec, @@ -112,6 +170,7 @@ impl ProgressiveSealMachine { pub fn new() -> Self { Self { live_lane_alive: true, + span_idempotence_enabled: span_idempotence::lane_enabled(), ..Self::default() } } @@ -135,9 +194,46 @@ impl ProgressiveSealMachine { end_secs: f32, committed_at_secs: f32, ) { - let raw_text = raw_text.into(); + let end_sample = (end_secs.max(0.0) * 1_000.0).round() as u64; + self.note_apple_commit_timed(AppleCommit { + id, + raw_text: raw_text.into(), + end_secs, + committed_at_secs, + range: TailSampleRange { + session: "legacy_progressive".to_string(), + capture_epoch: 0, + sample_start: 0, + sample_end: end_sample, + }, + words: Vec::new(), + apple_evidence: TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + silero_utterance_id: None, + }); + } + + /// Record an Apple commit together with canonical PCM, word and Silero + /// identity provenance. This is data-only: seal eligibility and text + /// transformation are the same as [`note_apple_commit`]. + pub fn note_apple_commit_timed(&mut self, commit: AppleCommit) -> bool { + let AppleCommit { + id, + raw_text, + end_secs, + committed_at_secs, + range, + words, + apple_evidence, + silero_utterance_id, + } = commit; if raw_text.trim().is_empty() { - return; + return false; } // Idempotent on id: a re-commit of the same utterance refreshes the // pending text but does not invent a second pending slot. @@ -145,11 +241,29 @@ impl ProgressiveSealMachine { existing.raw_text = raw_text; existing.end_secs = end_secs; existing.apple_committed_at_secs = committed_at_secs; - return; + existing.range = range; + existing.words = words; + existing.apple_evidence = apple_evidence; + existing.silero_utterance_id = silero_utterance_id; + return true; } if self.sealed.iter().any(|s| s.id == id) { // Already sealed — byte-stable fence: ignore re-commits. - return; + return false; + } + if self.span_idempotence_enabled { + let verdict = self.span_idempotence.offer(SpanOffer { + identity: TailRequestIdentity { + request_id: id, + range: range.clone(), + }, + text: raw_text.clone(), + timestamps_progressed: true, + decode_ok: true, + }); + if !verdict.lands_on_canvas() { + return false; + } } self.pending.push(PendingSpan { id, @@ -157,11 +271,40 @@ impl ProgressiveSealMachine { apple_committed_at_secs: committed_at_secs, end_secs, covering_whisper_window_id: None, + range, + words, + apple_evidence, + whisper_evidence: None, + whisper_words: Vec::new(), + silero_utterance_id, }); + true + } + + /// W13-4 receipts collected while the lane flag is ON. + pub fn span_idempotence_receipts(&self) -> &[span_idempotence::SpanIdempotenceReceipt] { + self.span_idempotence.receipts() } /// An elapsed Whisper window now covers audio through `covered_through_secs`. pub fn note_whisper_window_elapsed(&mut self, window_id: u64, covered_through_secs: f32) { + self.note_whisper_window_elapsed_with_provenance( + window_id, + covered_through_secs, + None, + Vec::new(), + ); + } + + /// Record elapsed coverage plus provider provenance without changing the + /// existing double-close decision. + pub fn note_whisper_window_elapsed_with_provenance( + &mut self, + window_id: u64, + covered_through_secs: f32, + evidence: Option, + words: Vec, + ) { if window_id > self.last_elapsed_whisper_window_id { self.last_elapsed_whisper_window_id = window_id; } @@ -171,6 +314,10 @@ impl ProgressiveSealMachine { for pending in &mut self.pending { if pending.end_secs <= self.whisper_covered_through_secs + f32::EPSILON { pending.covering_whisper_window_id = Some(window_id); + if pending.id == window_id { + pending.whisper_evidence = evidence.clone(); + pending.whisper_words = words.clone(); + } } } } @@ -249,12 +396,19 @@ impl ProgressiveSealMachine { id: span.id, text: sealed, end_secs_millis: (span.end_secs.max(0.0) * 1000.0).round() as u32, + range: span.range, + words: span.words, + apple_evidence: span.apple_evidence, + whisper_evidence: span.whisper_evidence, + whisper_words: span.whisper_words, + silero_utterance_id: span.silero_utterance_id, }; if !left_context.is_empty() && !sealed_span.text.is_empty() { left_context.push(' '); } left_context.push_str(&sealed_span.text); newly_sealed.push(sealed_span.clone()); + self.span_idempotence.mark_sealed(&sealed_span.range); self.sealed.push(sealed_span); } Some(SealBlockReason::StarvationCeiling) => { @@ -267,12 +421,19 @@ impl ProgressiveSealMachine { id: span.id, text: sealed, end_secs_millis: (span.end_secs.max(0.0) * 1000.0).round() as u32, + range: span.range, + words: span.words, + apple_evidence: span.apple_evidence, + whisper_evidence: span.whisper_evidence, + whisper_words: span.whisper_words, + silero_utterance_id: span.silero_utterance_id, }; if !left_context.is_empty() && !sealed_span.text.is_empty() { left_context.push(' '); } left_context.push_str(&sealed_span.text); newly_sealed.push(sealed_span.clone()); + self.span_idempotence.mark_sealed(&sealed_span.range); self.sealed.push(sealed_span); tracing::info!( span_id = span.id, @@ -350,6 +511,49 @@ impl ProgressiveSealMachine { } } + /// Seal every span still pending when the session itself ends. + /// + /// Both double-close gates are *vacuously* satisfied once capture is over: + /// no later Apple callback can revise a span, and no further Whisper window + /// can arrive. Holding a span past that point is not caution, it is a hang. + /// + /// # Why this exists as its own entry point + /// + /// The end-of-session caller used to reuse `try_seal(audio_secs + volatile + /// window + epsilon)`. That clock is derived from the PCM sample counter, + /// while `apple_committed_at_secs` comes from SFSpeech's own segment clock, + /// which can sit a few milliseconds *ahead* of it. Measured 2026-08-12: + /// audio clock 217.376s, last span committed at 217.378s → age 2.499s + /// against a 2.5s volatile window. The span missed by one millisecond, and + /// because the audio clock is frozen after EOF it could never age past the + /// gate — not even into the starvation ceiling. The worker then burned the + /// full 30s closure timeout waiting on a Whisper completion that would not + /// have unblocked it anyway (`rec_stop=36.701s` in the stop-path budget). + /// + /// Anchoring on the spans' own timestamps instead of the audio clock keeps + /// the volatile semantics exactly as written and removes the race. + pub fn seal_remaining_at_session_end(&mut self, force_raw: bool) -> SealTick { + let horizon = self + .pending + .iter() + .map(|span| span.apple_committed_at_secs.max(span.end_secs)) + .fold(0.0_f32, f32::max); + // No further window is coming, so everything recorded is as covered as + // it will ever be — satisfy `whisper_ready` without inventing an id. + self.whisper_covered_through_secs = self.whisper_covered_through_secs.max(horizon); + let tick = self.try_seal(horizon + APPLE_VOLATILE_WINDOW_SECS + 0.001, force_raw); + // The partial pool is spent once the end-of-session seal has run: every + // word it held is either inside a sealed span or inside the open + // partial the worker seals *before* calling this. Spans carry SFSpeech + // segment-clock ends while partials carry the receipt clock, which + // always runs slightly later — so a surviving partial reads as "past + // the last seal" to `compose_stop_path_residual` and re-appends text + // the seal already delivered. Live 2026-08-12 21:15: "Jaki chcesz. + // Kos." arrived twice in an 8s take exactly this way. + self.session_partials.clear(); + tick + } + /// Why a pending span is not yet sealable, or None when both engines closed it. fn seal_block_reason(&self, span: &PendingSpan, now_secs: f32) -> Option { let age = now_secs - span.apple_committed_at_secs; @@ -510,6 +714,100 @@ mod progressive_seal_tests { assert!(m.pending_spans().is_empty()); } + /// The 2026-08-12 stop-path hang, reduced to its arithmetic. + /// + /// SFSpeech committed the last span at 217.378s on its own segment clock + /// while the PCM counter had reached 217.376s. The end-of-session seal used + /// the audio clock, so the span's age came out at 2.499s against a 2.5s + /// volatile window — short by one millisecond, and frozen there forever + /// because the audio clock stops advancing at EOF. The worker then sat on + /// its closure timeout, costing the operator 30s on a stop that owed + /// nothing (`rec_stop=36.701s`). + #[test] + fn session_end_seals_the_span_a_frozen_audio_clock_holds_forever() { + let mut m = ProgressiveSealMachine::new(); + let audio_eof_secs = 217.376_f32; + let apple_commit_secs = 217.378_f32; + m.note_apple_commit(48, "ostatnie słowo", apple_commit_secs, apple_commit_secs); + // Whisper closed its side — the volatile gate is the only thing left. + m.note_whisper_window_elapsed(48, apple_commit_secs); + + let frozen = m.try_seal(audio_eof_secs + APPLE_VOLATILE_WINDOW_SECS + 0.001, false); + assert!( + frozen.newly_sealed.is_empty(), + "regression guard: this clock is exactly the one that hung, it must still miss" + ); + assert_eq!( + m.pending_spans().len(), + 1, + "the span the old end-of-session clock could never release" + ); + + let at_end = m.seal_remaining_at_session_end(false); + assert_eq!( + at_end.newly_sealed.len(), + 1, + "session end must seal on the span's own clock, not the audio counter" + ); + assert!( + m.pending_spans().is_empty(), + "no span may outlive the session that produced it" + ); + } + + /// The 2026-08-12 21:15 live duplicate ("Jaki chcesz. Kos." delivered + /// twice): spans sealed at session end carry SFSpeech segment-clock ends, + /// while session partials carry the receipt clock, which always runs a + /// little later. The stop-path residual then saw the freshest partial as + /// "past the last seal" and appended text the seal already delivered. + /// After an end-of-session seal the partial pool must be empty — every + /// word it held is either in a span or in the open partial the worker + /// seals first. + #[test] + fn session_end_seal_leaves_no_partial_for_the_residual_to_duplicate() { + let mut m = ProgressiveSealMachine::new(); + m.note_apple_commit(2, "jaki chcesz kos", 8.202, 8.202); + m.note_whisper_window_elapsed(2, 8.202); + // Receipt-clock partial restating the same tail, "later" than the seal. + m.note_session_partial("jaki chcesz kos", 8.4); + + let tick = m.seal_remaining_at_session_end(false); + assert_eq!(tick.newly_sealed.len(), 1); + + let residual = m.compose_stop_path_residual(); + assert_eq!( + residual.residual_tail, "", + "a partial restating sealed text must not ride the residual back in" + ); + assert_eq!( + residual.text.matches("chcesz").count(), + 1, + "the delivered text must carry the phrase exactly once: {:?}", + residual.text + ); + } + + /// Session end also closes the Whisper gate: once capture stops, no further + /// window can arrive, so holding a span for one is waiting on nothing. + #[test] + fn session_end_seals_span_that_never_got_a_whisper_window() { + let mut m = ProgressiveSealMachine::new(); + m.note_apple_commit(1, "bez lat ki", 10.0, 10.0); + + let mid_session = m.try_seal(10.0 + APPLE_VOLATILE_WINDOW_SECS + 0.1, false); + assert!( + mid_session.newly_sealed.is_empty(), + "mid-session the un-elapsed Whisper window must still block" + ); + + let at_end = m.seal_remaining_at_session_end(false); + assert_eq!( + at_end.newly_sealed.len(), + 1, + "at session end there is no window left to wait for" + ); + } + /// Ctrl-hold force_raw skips Light+ but still seals words. #[test] fn progressive_seal_force_raw_skips_light_plus_still_seals() { @@ -604,4 +902,61 @@ mod progressive_seal_tests { m.sealed_spans().len() ); } + + #[test] + fn w13_live_seal_refuses_replayed_range_identity_when_armed() { + let range = TailSampleRange { + session: "w13-4-live".into(), + capture_epoch: 1, + sample_start: 0, + sample_end: 8_000, + }; + let evidence = TailProviderEvidence { + source: TailEvidenceSource::AppleSpeech, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }; + let mut m = ProgressiveSealMachine::new(); + m.span_idempotence_enabled = true; + assert!(m.note_apple_commit_timed(AppleCommit { + id: 1, + raw_text: "fragment odzyskany".into(), + end_secs: 0.5, + committed_at_secs: 0.5, + range: range.clone(), + words: Vec::new(), + apple_evidence: evidence.clone(), + silero_utterance_id: Some(1), + })); + m.note_whisper_window_elapsed(1, 4.0); + let tick = m.try_seal(APPLE_VOLATILE_WINDOW_SECS + 1.0, true); + assert_eq!(tick.newly_sealed.len(), 1); + assert_eq!( + tick.newly_sealed[0].silero_utterance_id, + Some(1), + "the Silero identity a span was bound to must survive the seal" + ); + + let replayed = m.note_apple_commit_timed(AppleCommit { + id: 2, + raw_text: "fragment odzyskany".into(), + end_secs: 0.5, + committed_at_secs: 0.5, + range, + words: Vec::new(), + apple_evidence: evidence, + silero_utterance_id: Some(2), + }); + assert!(!replayed, "new Apple id on a sealed range must be refused"); + assert_eq!(m.pending_spans().len(), 0); + assert_eq!(m.sealed_spans().len(), 1); + assert_eq!(m.sealed_prefix(), "fragment odzyskany"); + assert!( + m.span_idempotence_receipts() + .iter() + .any(|r| r.code == "replayed_range_identity") + ); + } } diff --git a/core/pipeline/streaming/session.rs b/core/pipeline/streaming/session.rs index 59ad911a..27279daf 100644 --- a/core/pipeline/streaming/session.rs +++ b/core/pipeline/streaming/session.rs @@ -12,6 +12,11 @@ use tokio::sync::mpsc; use tokio::time::{Duration, Instant}; use tracing::{debug, error, info, warn}; +use crate::asr_session::recorder::{Layer1Decision, RecorderLifecycleEvents}; +use crate::audio::capture_receipt::{ + CaptureLevelAccumulator, CapturePathMeta, begin_session_energy_clock, + emit_capture_level_receipt, +}; use crate::audio::chunker::{SpeechEvent, SpeechSession}; use crate::pipeline::contracts::{ DropKind, EngineEvent, EventSink, LayerSource, LayerSummary, TranscriptSegment, @@ -19,7 +24,14 @@ use crate::pipeline::contracts::{ }; use crate::stt::scheduler::{SttLane, SttScheduler, SttTaskHandle}; use crate::stt::tail_patcher::{ - TailPatchConfig, TailPatchOutcome, compute_tail_patch, layered_phase, + TailPatchConfig, TailPatchOutcome, UnderCommit, compute_tail_patch_with_context, layered_phase, +}; +#[cfg(test)] +use crate::stt::tail_provider::{ + TailEvidenceSource, TailEvidenceStability, TailProviderId, TailTimingQuality, TimedTailSegment, +}; +use crate::stt::tail_provider::{ + TailProviderPayload, TailProviderRequest, TailRequestIdentity, TailSampleRange, }; use crate::vad; @@ -122,6 +134,18 @@ pub struct SessionConfig { pub stream_log_path: Option, /// VAD silence threshold for utterance boundary (None = use default). pub utterance_silence_sec: Option, + /// Injected, already-authorized Layer 1 refiner decision (C1). + /// + /// The pipeline only consumes this — construction, consent, and mode + /// persistence belong to the settings owner. [`Layer1Decision::Disarmed`] + /// is the stock product: canvas + lexicon, complete, never an error. + pub layer1: Layer1Decision, + /// Hard product-mode boundary for local model initialization. Cloud and + /// Apple-only sessions set false; only explicit Local power sets true. + pub local_whisper_allowed: bool, + /// Per-recording host lifecycle boundaries. Present only for a live + /// recorder; buffered/offline helpers have no system observer owner. + pub lifecycle_events: Option, } /// What happened to one enqueue attempt. @@ -198,14 +222,16 @@ pub(crate) fn enqueue_pending_utterance( /// Layer 1 (Whisper tail-patch) gate. /// -/// Driven solely by `CODESCRIBE_LAYERED_TRANSCRIPTION` ([`layered_phase`]). -/// **Orthogonal to** `FINAL_PASS_MODE` / Smart: Smart never enables this, and -/// enabling layered never changes stop-path full re-pass routing. +/// Requires both explicit Local power product mode and +/// `CODESCRIBE_LAYERED_TRANSCRIPTION` ([`layered_phase`]). Cloud and Apple-only +/// cannot be widened by a stale legacy env value. **Orthogonal to** +/// `FINAL_PASS_MODE` / Smart: Smart never enables this, and enabling layered +/// never changes stop-path full re-pass routing. /// /// Shared with the Apple progressive path (`super::apple_live_session`) so both /// live sessions read one gate — a second copy would be a second truth. -pub(super) fn tail_patch_enabled() -> bool { - layered_phase().is_some_and(|phase| phase >= 1) +pub(super) fn tail_patch_enabled(local_whisper_allowed: bool) -> bool { + local_whisper_allowed && layered_phase().is_some_and(|phase| phase >= 1) } /// Count a semantic-gate drop, but only for finals. @@ -226,14 +252,51 @@ fn record_semantic_gate_drop(counter: &mut u64, quality_gate_dropped: bool, is_f /// `UtteranceFinal.text`: the resulting `ReplaceRange` offsets are computed /// against it, so a differently-trimmed copy would produce patches that land at /// the wrong characters. The debug assertion pins that contract in test builds. +#[derive(Debug)] +pub(super) struct TailPatchJobResult { + pub utterance_id: u64, + pub outcome: TailPatchOutcome, + pub payload: TailProviderPayload, +} + +impl TailPatchJobResult { + pub fn into_outcome(self) -> (u64, TailPatchOutcome) { + (self.utterance_id, self.outcome) + } +} + pub(super) async fn compute_tail_patch_job( utterance_id: u64, committed_text: String, + neighbour_context: String, audio: Vec, - sample_rate: u32, - language: Option, + request: TailProviderRequest, config: TailPatchConfig, -) -> Result<(u64, TailPatchOutcome)> { +) -> Result { + compute_tail_patch_job_with( + utterance_id, + committed_text, + neighbour_context, + audio, + request, + config, + crate::stt::tail_provider::transcribe_configured, + ) + .await +} + +async fn compute_tail_patch_job_with( + utterance_id: u64, + committed_text: String, + neighbour_context: String, + audio: Vec, + request: TailProviderRequest, + config: TailPatchConfig, + transcribe: F, +) -> Result +where + F: FnOnce(&TailProviderRequest, &[f32]) -> Result + Send + 'static, +{ debug_assert_eq!( committed_text.trim(), committed_text, @@ -241,17 +304,54 @@ pub(super) async fn compute_tail_patch_job( (single trim owner: final_text at the emit site)" ); tokio::task::spawn_blocking(move || { - let retranscribed = - crate::stt::whisper_tail_patch_transcribe(&audio, sample_rate, language.as_deref())?; - Ok(( + let payload = transcribe(&request, &audio)?; + let outcome = compute_tail_patch_with_context( + &committed_text, + &payload.text, + &neighbour_context, utterance_id, - compute_tail_patch(&committed_text, &retranscribed.text, utterance_id, &config), - )) + &config, + ); + Ok(TailPatchJobResult { + utterance_id, + outcome, + payload, + }) }) .await .map_err(|e| anyhow!("tail patch worker task failed: {e}"))? } +/// Stable engine-event code carrying a Layer-1 under-commit outward. +/// +/// The stop path keys on this to require residual gap fill for a session whose +/// live canvas is known to be starved, independently of the committed-density +/// floor that judges the same session from the audio side. +pub const UNDER_COMMIT_WARNING_CODE: &str = "tail_patch_under_commit"; + +/// Build the outward escalation for an under-commit that could not be placed +/// live. +/// +/// Counts only — the message crosses the IPC boundary and reaches the log, and +/// the transcript is the user's speech. A `Warning` rather than a new event +/// variant on purpose: every sink, the IPC wire and the Swift bridge already +/// carry it, so the escalation costs no FFI surface. +fn under_commit_warning(under: &UnderCommit) -> EngineEvent { + EngineEvent::Warning { + code: UNDER_COMMIT_WARNING_CODE.to_string(), + message: format!( + "residual gap fill required: committed_chars={} retranscribed_chars={} \ + committed_tokens={} retranscribed_tokens={} commit_ratio={:.2} gap_appends={}", + under.committed_chars, + under.retranscribed_chars, + under.committed_tokens, + under.retranscribed_tokens, + under.commit_ratio, + under.appends.len(), + ), + } +} + /// Forward a tail-patch job's outcome to the sink and report how many /// replacements were emitted. /// @@ -285,8 +385,47 @@ pub(super) fn emit_tail_patch_result( debug!(utterance_id, "Tail patch found no changes"); 0 } - Ok((utterance_id, TailPatchOutcome::Skipped { reason })) => { - debug!(utterance_id, reason, "Tail patch skipped"); + Ok((utterance_id, TailPatchOutcome::UnderCommit(under))) => { + let mut emitted = 0u64; + for event in &under.appends { + if matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ) { + emitted = emitted.saturating_add(1); + } + event_sink.on_event(event); + } + info!( + utterance_id, + reason = under.reason(), + committed_chars = under.committed_chars, + retranscribed_chars = under.retranscribed_chars, + committed_tokens = under.committed_tokens, + retranscribed_tokens = under.retranscribed_tokens, + gap_appends = emitted, + residual_required = under.residual_required, + "Layer 1 under-commit" + ); + if under.residual_required { + event_sink.on_event(&under_commit_warning(&under)); + } + emitted + } + Ok((utterance_id, TailPatchOutcome::Skipped { code, reason })) => { + // INFO, not debug: a skipped patch is text Whisper had in hand and + // the canvas never received. The counts belong to the receipt the + // patcher already logs; this line proves the sink saw the same + // verdict for this utterance. + info!( + utterance_id, + code = code.as_str(), + reason, + "Tail patch skipped" + ); 0 } Err(e) => { @@ -319,6 +458,39 @@ pub(super) fn emit_session_finalised( }); } +/// Per-session skip count at which a zero-application session is an alarm. +/// +/// One or two skips with nothing applied can be honest divergence (noise, a +/// throat-clear window). Three computed corrections all rejected is the gate +/// eating the lane's entire output — the 2026-08-12 audit found 116 skips and +/// 0 applied patches across the log's whole history, and not one line said so +/// out loud. +pub(super) const TAIL_PATCH_STARVED_MIN_SKIPS: u64 = 3; + +/// Whether this session's Layer 1 lane was starved: corrections were computed +/// and every single one was rejected. +pub(super) fn tail_patch_lane_starved(applied: u64, skipped: u64) -> bool { + applied == 0 && skipped >= TAIL_PATCH_STARVED_MIN_SKIPS +} + +/// One session-level receipt for the Layer 1 lane, emitted at finalise. +/// +/// The per-utterance skip receipts diagnose a single verdict; this line +/// diagnoses the lane. A starved session — Whisper burned inference on every +/// sealed utterance and the canvas received none of it — is a WARN, because +/// that is the lane not doing its one job, silently. +pub(super) fn log_tail_patch_session_receipt(applied: u64, skipped: u64) { + if tail_patch_lane_starved(applied, skipped) { + warn!( + applied, + skipped, + "tail_patch_lane_starved: every computed Whisper correction this session was rejected" + ); + } else if applied > 0 || skipped > 0 { + info!(applied, skipped, "tail_patch_session_receipt"); + } +} + // ── Unified transcription session (event-based) ───────────────────────────── /// Unified transcription session exposed as a single event-emitting pipeline. @@ -333,11 +505,10 @@ pub(super) fn emit_session_finalised( /// phrase-level `isFinal` events become multi-seal `UtteranceFinal`s. That is /// the CORE ENGINE freezed+append contract — not a Whisper hybrid mid-live. /// -/// Layer 1 tail-patch (`CODESCRIBE_LAYERED_TRANSCRIPTION=phase1+`) is wired on -/// **both** live paths: the VAD/scheduler path below, and the Apple progressive -/// path (W2-A), which gap-fills sealed utterances mid-hold from retained PCM. -/// Smart final-pass stays orthogonal — it only skips/allows the stop-path full -/// re-pass. +/// The legacy local Layer 1 tail-patch is wired on **both** live paths, but it +/// requires `local_whisper_allowed` (resolved Local power) in addition to +/// `CODESCRIBE_LAYERED_TRANSCRIPTION=phase1+`. Cloud/Apple-only cannot be +/// widened by env. Smart final-pass stays orthogonal. pub(crate) async fn transcription_session( chunk_receiver: mpsc::Receiver>, event_sink: Arc, @@ -345,7 +516,9 @@ pub(crate) async fn transcription_session( ) { // Apple progressive stream branch — must run before the VAD/scheduler path // consumes the receiver. - if crate::stt::active_engine_is_apple() && crate::stt::apple_stt::progressive_live_enabled() { + if crate::stt::recording_engine_is_apple(config.local_whisper_allowed) + && crate::stt::apple_stt::progressive_live_enabled() + { super::apple_live_session::apple_stream_transcription_session( chunk_receiver, event_sink, @@ -373,10 +546,27 @@ pub(crate) async fn vad_transcription_session( language, stream_log_path, utterance_silence_sec, + layer1, + local_whisper_allowed, + lifecycle_events: _, } = config; + // C1 wires the live Layer 1 lane on the Apple progressive path only. On + // this canvas an armed decision is disarmed explicitly: a refiner that + // cannot run is a missing improvement, never an error, and never a reason + // to load anything heavier. + if layer1.is_armed() { + warn!( + "Layer 1 live lane is not wired on the VAD/scheduler path; \ + proceeding canvas + lexicon" + ); + } + drop(layer1); + info!("Transcription session started (event-based pipeline)"); let session_id = uuid::Uuid::new_v4().to_string(); + let mut capture_level = CaptureLevelAccumulator::new(); + begin_session_energy_clock(); let mut session = if let Some(sec) = utterance_silence_sec { SpeechSession::new_utterance_with_silence(sample_rate, sec) @@ -384,9 +574,9 @@ pub(crate) async fn vad_transcription_session( SpeechSession::new_utterance(sample_rate) }; let output_sample_rate = session.output_sample_rate(); - let stt_scheduler = SttScheduler::new(); + let stt_scheduler = SttScheduler::new(local_whisper_allowed); // Layer 1 only — not FINAL_PASS_MODE. Smart does not flip this on. - let tail_patch_enabled = tail_patch_enabled(); + let tail_patch_enabled = tail_patch_enabled(local_whisper_allowed); let tail_patch_config = TailPatchConfig::from_env(); if tail_patch_enabled { info!( @@ -404,6 +594,7 @@ pub(crate) async fn vad_transcription_session( let mut filtered_empty_drops: u64 = 0; let mut corrections_applied: u64 = 0; let mut tail_patch_replacements: u64 = 0; + let mut tail_patch_skips: u64 = 0; let mut partial_telemetry = PartialPassTelemetry::default(); let mut vad_started = false; let mut speech_activity_observed = false; @@ -477,7 +668,9 @@ pub(crate) async fn vad_transcription_session( "Phase 1 inference pipeline configured" ); let mut inference_pipeline = FuturesOrdered::new(); - let mut tail_patch_pipeline = FuturesUnordered::new(); + let mut tail_patch_pipeline: FuturesUnordered< + futures_util::future::BoxFuture<'static, Result>, + > = FuturesUnordered::new(); // Phase 2 (buffered correction) — request tracked for stale guards. let mut correction_in_flight: Option = None; @@ -646,6 +839,7 @@ pub(crate) async fn vad_transcription_session( maybe_data = chunk_receiver.recv(), if !audio_closed => { match maybe_data { Some(data) => { + capture_level.push_samples(&data); for event in session.feed(&data, sample_rate) { let speech_vad_samples = session.take_event_speech_vad_samples(); let max_speech_prob = session.segment_speech_prob(); @@ -1047,6 +1241,12 @@ pub(crate) async fn vad_transcription_session( // Drain the pipeline. FuturesOrdered guarantees results arrive in the order submitted. // This is critical for timestamp calculation and text accumulation. Some(result) = tail_patch_pipeline.next() => { + if matches!(&result, Ok(job) if matches!(job.outcome, TailPatchOutcome::Skipped { .. })) + || result.is_err() + { + tail_patch_skips = tail_patch_skips.saturating_add(1); + } + let result = result.map(TailPatchJobResult::into_outcome); tail_patch_replacements = tail_patch_replacements .saturating_add(emit_tail_patch_result(event_sink.as_ref(), result)); } @@ -1278,14 +1478,35 @@ pub(crate) async fn vad_transcription_session( if tail_patch_enabled && let Some(audio) = item.tail_patch_audio.take() { - tail_patch_pipeline.push(compute_tail_patch_job( + let sample_start = ((utterance_start_s.max(0.0) as f64) + * output_sample_rate as f64) + .round() as u64; + let sample_end = sample_start.saturating_add(audio.len() as u64); + let request = TailProviderRequest { + identity: TailRequestIdentity { + request_id: utterance_id, + range: TailSampleRange { + session: session_id.clone(), + capture_epoch: 0, + sample_start, + sample_end, + }, + }, + sample_rate: output_sample_rate, + language: pipeline.language.clone(), + }; + tail_patch_pipeline.push(Box::pin(compute_tail_patch_job( utterance_id, final_text, + // VAD lane: no sealed-prefix accumulator on this + // path, so the anti-duplication check falls back + // to the utterance's own canvas (pre-2026-08-14 + // behaviour, no regression). + String::new(), audio, - output_sample_rate, - pipeline.language.clone(), + request, tail_patch_config, - )); + ))); } } else { utterance_segments.clear(); @@ -1464,6 +1685,11 @@ pub(crate) async fn vad_transcription_session( partial_dropped_count: partial_telemetry.dropped_count, }); + log_tail_patch_session_receipt(tail_patch_replacements, tail_patch_skips); + emit_capture_level_receipt( + event_sink.as_ref(), + &capture_level.finalize(CapturePathMeta::resolve(sample_rate, 1, None)), + ); emit_session_finalised(event_sink.as_ref(), session_id, tail_patch_replacements); if dropped_utterances > 0 { @@ -1637,6 +1863,11 @@ pub async fn transcribe_buffered_samples( language, stream_log_path: None, utterance_silence_sec: None, + // Offline replay harness: Layer 1 arming is a live-recording + // decision owned elsewhere. + layer1: Layer1Decision::Disarmed, + local_whisper_allowed: true, + lifecycle_events: None, }, )); @@ -1664,31 +1895,57 @@ pub async fn collect_buffered_engine_events( samples: &[f32], sample_rate: u32, language: Option, +) -> Result> { + collect_buffered_engine_events_with_config( + samples, + SessionConfig { + sample_rate, + language, + stream_log_path: None, + utterance_silence_sec: None, + // Offline replay harness: Layer 1 arming is a live-recording + // decision owned elsewhere. + layer1: Layer1Decision::Disarmed, + local_whisper_allowed: true, + lifecycle_events: None, + }, + ) + .await +} + +/// Run buffered PCM through an explicitly supplied production session config. +/// +/// Unlike [`collect_buffered_engine_events`], this seam never invents or +/// hard-codes a Layer 1 decision. The recording owner must supply the complete +/// [`SessionConfig`], which makes this suitable for production-owned replay +/// witnesses while preserving the exact `transcription_session` implementation +/// used by live capture. +pub async fn collect_buffered_engine_events_with_config( + samples: &[f32], + config: SessionConfig, ) -> Result> { if samples.is_empty() { return Ok(Vec::new()); } - let chunk_size = ((sample_rate as f32) * 0.1).round().max(1.0) as usize; - + let chunk_size = ((config.sample_rate as f32) * 0.1).round().max(1.0) as usize; let (tx, rx) = mpsc::channel::>(8); let collector = Arc::new(SessionEventCollector::new()); let event_sink: Arc = collector.clone(); - let session = tokio::spawn(transcription_session( - rx, - event_sink, - SessionConfig { - sample_rate, - language, - stream_log_path: None, - utterance_silence_sec: None, - }, - )); + let session = tokio::spawn(transcription_session(rx, event_sink, config)); for chunk in samples.chunks(chunk_size) { if tx.send(chunk.to_vec()).await.is_err() { return Err(anyhow!("Transcription session dropped channel")); } + // `transcription_session` consumes a live capture stream. Preserve + // that temporal contract for replay: flooding an entire recording in + // one scheduler tick advances `audio_secs` ahead of Apple's result + // timestamps and turns otherwise valid phrase windows into unresolved + // seals. A 100 ms packet therefore occupies 100 ms of wall time, just + // like the production callback cadence this seam replaces at its only + // unavoidable boundary. + tokio::time::sleep(Duration::from_millis(100)).await; } drop(tx); @@ -1721,6 +1978,72 @@ mod session_tests { assert_eq!(drops, 1); } + #[tokio::test] + async fn w13_provenance_survives_tail_patch_job() { + let range = TailSampleRange { + session: "w13-replay-191351".to_string(), + capture_epoch: 4, + sample_start: 48_000, + sample_end: 48_320, + }; + let identity = TailRequestIdentity { + request_id: 73, + range: range.clone(), + }; + let evidence = crate::stt::tail_provider::TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: Some("fixture-r1".to_string()), + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::ExactSampleRange, + avg_logprob: Some(-0.21), + }; + let payload = TailProviderPayload { + identity: identity.clone(), + text: "ala ma kota".to_string(), + segments: vec![TimedTailSegment { + text: "kota".to_string(), + range: TailSampleRange { + sample_start: 48_160, + sample_end: 48_300, + ..range.clone() + }, + }], + avg_logprob: Some(-0.21), + compression_ratio: Some(1.03), + quality_gate_dropped: false, + provider_id: TailProviderId::Fake, + elapsed_ms: 7, + evidence: evidence.clone(), + }; + let request = TailProviderRequest { + identity, + sample_rate: 16_000, + language: Some("pl-PL".to_string()), + }; + + let job = compute_tail_patch_job_with( + 73, + "ala ma kota".to_string(), + String::new(), + vec![0.0; 320], + request, + TailPatchConfig::default(), + move |request, pcm| { + request.validate_pcm(pcm)?; + Ok(payload) + }, + ) + .await + .expect("typed fake tail job"); + + assert!(matches!(job.outcome, TailPatchOutcome::NoChange)); + assert_eq!(job.payload.identity.range, range); + assert_eq!(job.payload.segments[0].range.sample_start, 48_160); + assert_eq!(job.payload.segments[0].range.sample_end, 48_300); + assert_eq!(job.payload.evidence, evidence); + assert_eq!(job.payload.provider_id, TailProviderId::Fake); + } + #[test] /// Successful tail-patch outcomes surface as `ReplaceRange` engine events. fn tail_patch_result_emits_replace_range_events() { @@ -1752,6 +2075,95 @@ mod session_tests { )); } + /// Build an under-commit outcome with `appends` gap-appends and the given + /// escalation, without needing Whisper or a diff. + fn under_commit_fixture(appends: usize, residual_required: bool) -> UnderCommit { + UnderCommit { + appends: (0..appends) + .map(|idx| EngineEvent::ReplaceRange { + utterance_id: 7, + start: 11 + idx, + end: 11 + idx, + text: " odzyskane".to_string(), + source: LayerSource::TailPatch, + }) + .collect(), + residual_required, + committed_tokens: 3, + retranscribed_tokens: 12, + committed_chars: 21, + retranscribed_chars: 84, + commit_ratio: 0.25, + } + } + + #[test] + /// W-C: recovered gap-appends reach the sink and are counted as Layer 1 + /// work — the outcome the bounded cap used to discard in silence. + fn under_commit_gap_appends_reach_the_sink_and_count() { + let collector = SessionEventCollector::new(); + let emitted = emit_tail_patch_result( + &collector, + Ok(( + 7, + TailPatchOutcome::UnderCommit(under_commit_fixture(1, false)), + )), + ); + + assert_eq!(emitted, 1, "an appended gap is Layer 1 work, not a skip"); + let events = collector.events(); + assert!(matches!( + events.as_slice(), + [EngineEvent::ReplaceRange { + start: 11, + end: 11, + source: LayerSource::TailPatch, + .. + }] + )); + assert!( + !events + .iter() + .any(|e| matches!(e, EngineEvent::Warning { .. })), + "nothing is owed to the stop path when everything landed live" + ); + } + + #[test] + /// W-C: an under-commit that could place nothing escalates outward instead + /// of leaving the stop path to call the starved canvas complete. + fn under_commit_without_safe_anchor_emits_residual_escalation() { + let collector = SessionEventCollector::new(); + let emitted = emit_tail_patch_result( + &collector, + Ok(( + 7, + TailPatchOutcome::UnderCommit(under_commit_fixture(0, true)), + )), + ); + + assert_eq!(emitted, 0); + let events = collector.events(); + let warning = events + .iter() + .find_map(|e| match e { + EngineEvent::Warning { code, message } if code == UNDER_COMMIT_WARNING_CODE => { + Some(message.clone()) + } + _ => None, + }) + .expect("residual escalation must be emitted"); + // Counts travel; transcript text never does. + assert!(warning.contains("committed_chars=21")); + assert!(warning.contains("retranscribed_chars=84")); + assert!( + !events + .iter() + .any(|e| matches!(e, EngineEvent::ReplaceRange { .. })), + "no anchor was safe, so no canvas may be touched" + ); + } + #[test] /// Trimmed final_text is the sole offset baseline for tail-patch apply. fn final_text_trim_contract_keeps_tail_patch_offsets_aligned() { @@ -1763,7 +2175,7 @@ mod session_tests { // Retranscribed side mimics real Whisper output shape: leading/trailing // whitespace and a newline. It must never skew offsets or get skipped. - let outcome = compute_tail_patch( + let outcome = crate::stt::tail_patcher::compute_tail_patch( &final_text, " ala ma psa \n", 1, @@ -1804,6 +2216,25 @@ mod session_tests { )); } + #[test] + /// The starvation verdict: zero applied with the skip floor reached is the + /// lane not doing its job. One landed patch — even against 116 skips — + /// proves the lane alive; a skip or two with nothing applied is honest + /// divergence, not starvation. + fn tail_patch_starvation_fires_only_on_all_rejected_sessions() { + assert!(tail_patch_lane_starved(0, TAIL_PATCH_STARVED_MIN_SKIPS)); + assert!(tail_patch_lane_starved(0, 116)); + assert!(!tail_patch_lane_starved(1, 116), "one landed patch = alive"); + assert!( + !tail_patch_lane_starved(0, TAIL_PATCH_STARVED_MIN_SKIPS - 1), + "a couple of honest divergences is not starvation" + ); + assert!( + !tail_patch_lane_starved(0, 0), + "an idle lane is not starved" + ); + } + #[test] /// Correction audio buffer drains oldest samples so length never exceeds the window. fn correction_buffer_window_cap() { diff --git a/core/pipeline/streaming/silero_fusion.rs b/core/pipeline/streaming/silero_fusion.rs new file mode 100644 index 00000000..8f53c4ba --- /dev/null +++ b/core/pipeline/streaming/silero_fusion.rs @@ -0,0 +1,884 @@ +//! W13-3B — Silero utterance identity + conservative per-word fusion. +//! +//! Lane flag [`SILERO_FUSION_ENV`] is **default OFF**. When armed: +//! Silero Supervisor edges mint utterance identity on the PCM sample clock; +//! Apple cumulative finals are sliced onto those ranges by time; Whisper and +//! Apple then fuse conservatively (agreements + clear gap fills). Unresolved +//! alternatives are receipted, never confidence-arbitrated. Every write into +//! a pending span goes through [`super::progressive_seal::ProgressiveSealMachine::try_rewrite`]. +//! +//! # One Silero per session +//! +//! [`SileroIngress`] is the session's **only** `SpeechSession`. Both consumers +//! of speech edges read it: the fusion ledger (utterance identity) and the +//! Apple engine lifecycle (`EpochGate` wake/sleep). Two independent VAD +//! sessions over the same PCM would mean two spectra and two sets of +//! boundaries, and "the same utterance" would then mean two different sample +//! ranges depending on which consumer was asked. [`SileroIngress::observe`] is +//! the single decision point that derives both from one observation. + +use crate::audio::chunker::{SpeechEvent, SpeechSession}; +use crate::stt::tail_patcher::SkipReasonCode; +use crate::stt::tail_provider::{TailSampleRange, TimedTailSegment}; + +/// Lane flag for Silero-identity conservative fusion. Unset / `0` / `false` / +/// `off` / `no` keep the existing production path bit-identical. +pub const SILERO_FUSION_ENV: &str = "CODESCRIBE_SILERO_FUSION"; + +/// Bounded-context A/B selector. Never crosses a long-silence cut. +pub const SILERO_FUSION_CONTEXT_ENV: &str = "CODESCRIBE_SILERO_FUSION_CONTEXT"; + +/// Silence longer than this (samples at the capture rate) is a hard context +/// fence — left-audio pad must not reach across it. +pub const LONG_SILENCE_FENCE_SECS: f32 = 0.55; + +/// Default left-audio pad when [`FusionContextMode::LeftAudioPad`] is armed. +pub const DEFAULT_LEFT_PAD_SECS: f32 = 0.40; + +/// Whether the W13-3B fusion lane is armed. Default OFF pending the operator's +/// live A/B decision required by the original engine roadmap. +pub fn lane_enabled() -> bool { + let raw = std::env::var(SILERO_FUSION_ENV).ok(); + lane_enabled_from_raw(raw.as_deref()) +} + +fn lane_enabled_from_raw(raw: Option<&str>) -> bool { + raw.is_some_and(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// One Silero-bounded utterance on the session PCM clock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SileroUtterance { + pub id: u64, + pub range: TailSampleRange, + pub closed: bool, +} + +/// Ledger of Silero-minted utterance identities. Pure data; the Supervisor +/// machine in [`SileroIngress`] is the only writer in production. +#[derive(Debug, Clone, Default)] +pub struct UtteranceLedger { + next_id: u64, + utterances: Vec, +} + +impl UtteranceLedger { + pub fn new() -> Self { + Self::default() + } + + /// Mint (or refresh) an open utterance covering `[sample_start, sample_end)`. + pub fn open_or_extend( + &mut self, + session: &str, + capture_epoch: u64, + sample_start: u64, + sample_end: u64, + ) -> u64 { + let sample_end = sample_end.max(sample_start); + if let Some(open) = self.utterances.iter_mut().rev().find(|u| !u.closed) { + open.range.sample_end = sample_end.max(open.range.sample_end); + return open.id; + } + self.next_id = self.next_id.saturating_add(1); + let id = self.next_id; + self.utterances.push(SileroUtterance { + id, + range: TailSampleRange { + session: session.to_string(), + capture_epoch, + sample_start, + sample_end, + }, + closed: false, + }); + id + } + + /// Close the open utterance so the next speech edge mints a new identity. + pub fn close_open(&mut self, sample_end: u64) -> Option { + let open = self.utterances.iter_mut().rev().find(|u| !u.closed)?; + open.range.sample_end = sample_end.max(open.range.sample_end); + open.closed = true; + Some(open.id) + } + + pub fn utterances(&self) -> &[SileroUtterance] { + &self.utterances + } + + /// Utterance whose range contains `sample` (half-open). Prefers the + /// tightest closed span; falls back to the open span. + pub fn utterance_covering(&self, sample: u64) -> Option<&SileroUtterance> { + self.utterances + .iter() + .filter(|u| u.range.sample_start <= sample && sample < u.range.sample_end) + .min_by_key(|u| u.range.sample_end.saturating_sub(u.range.sample_start)) + } + + /// Tightest utterance that fully **encloses** `[sample_start, sample_end)`. + /// + /// This is the seal-time binding query: an Apple span may adopt a Silero + /// range only when the spectrum edge already covers every sample Apple + /// claimed. Mere overlap is refused on purpose — adopting a range that + /// starts after Apple's first word would hand Layer 1 a window over audio + /// the utterance never contained, and the span would seal against a decode + /// of the wrong seconds. No enclosure ⇒ the caller keeps its own range + /// (fail-open; content is never dropped for want of an edge). + pub fn utterance_enclosing( + &self, + sample_start: u64, + sample_end: u64, + ) -> Option<&SileroUtterance> { + let sample_end = sample_end.max(sample_start); + self.utterances + .iter() + .filter(|u| u.range.sample_start <= sample_start && sample_end <= u.range.sample_end) + .min_by_key(|u| u.range.sample_end.saturating_sub(u.range.sample_start)) + } + + /// Burn one identity without minting an utterance. + /// + /// The Apple-boundary fallback still needs a span id, and it must not be an + /// id Silero will later mint for a real utterance: `note_apple_commit_timed` + /// is idempotent on id, so a collision would silently merge an Apple span + /// with an unrelated Silero one. One ledger, one id space. + pub fn reserve_id(&mut self) -> u64 { + self.next_id = self.next_id.saturating_add(1); + self.next_id + } +} + +/// What one observed capture chunk means to every consumer of the session's +/// single spectrum. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SileroIngest { + /// Utterance identities the Supervisor closed inside this chunk. + pub closed: Vec, + /// Identity of the utterance still open after this chunk. + pub open: Option, + /// Speech was live anywhere in this chunk — a segment is open, or one + /// closed inside it. This is the edge bit the Apple engine lifecycle + /// (`EpochGate`) reads instead of running a second Silero over the same + /// PCM; it is derived from the identical two facts the ledger is minted + /// from, in the same call, so wake/sleep and utterance identity cannot + /// disagree about where speech was. + pub speech_live: bool, +} + +/// Supervisor-mode Silero at the Apple PCM ingress. The session's only VAD. +pub struct SileroIngress { + session: String, + capture_epoch: u64, + vad: SpeechSession, + ledger: UtteranceLedger, +} + +impl SileroIngress { + pub fn new(sample_rate: u32, session: impl Into, capture_epoch: u64) -> Self { + Self { + session: session.into(), + capture_epoch, + vad: SpeechSession::new_utterance(sample_rate), + ledger: UtteranceLedger::new(), + } + } + + pub fn ledger(&self) -> &UtteranceLedger { + &self.ledger + } + + pub fn ledger_mut(&mut self) -> &mut UtteranceLedger { + &mut self.ledger + } + + /// Whether Silero actually loaded. `false` means every frame reads as + /// non-speech: no identity will ever be minted and no speech edge will ever + /// fire, so consumers that gate on edges must fail open instead of resting + /// forever. + pub fn vad_available(&self) -> bool { + self.vad.vad_available() + } + + /// Feed one capture chunk. `samples_seen` is the session cursor *after* + /// this chunk (same counter `apple_stream_worker` already owns). + pub fn ingest(&mut self, samples: &[f32], samples_seen: u64) -> SileroIngest { + if samples.is_empty() { + return SileroIngest::default(); + } + let events = self.vad.feed(samples, 0); + let closed_here = events + .iter() + .any(|event| matches!(event, SpeechEvent::UtteranceFinal(_))); + let open_range = self.vad.open_segment_raw_range(); + self.observe(open_range, closed_here, samples_seen) + } + + /// The whole decision, separated from the VAD read so it is testable on + /// synthetic edges (Silero loads from embedded bytes; a unit test that + /// silently degraded to "no model" would prove nothing). Production calls + /// this exactly once per chunk, from [`Self::ingest`]. + pub fn observe( + &mut self, + open_range: Option<(u64, u64)>, + closed_here: bool, + samples_seen: u64, + ) -> SileroIngest { + let mut out = SileroIngest { + speech_live: closed_here || open_range.is_some(), + ..SileroIngest::default() + }; + if let Some((start, end)) = open_range { + out.open = + Some( + self.ledger + .open_or_extend(&self.session, self.capture_epoch, start, end), + ); + } + if closed_here && let Some(id) = self.ledger.close_open(samples_seen) { + out.closed.push(id); + if out.open == Some(id) { + out.open = None; + } + } + out + } + + /// Seal any still-open Supervisor segment at capture EOF. + pub fn flush(&mut self, samples_seen: u64) -> Option { + let _ = self.vad.flush(); + self.ledger.close_open(samples_seen) + } +} + +/// How a Whisper window is cut relative to a Silero utterance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FusionContextMode { + /// Audio is exactly the Silero utterance. Default. + UtteranceOnly, + /// Small left pad, clipped at the last long-silence fence. + LeftAudioPad, + /// Same audio as utterance-only; the sealed prefix is the prompt (never + /// audio across a long silence). + StableTextPrompt, +} + +impl FusionContextMode { + pub fn from_env() -> Self { + match std::env::var(SILERO_FUSION_CONTEXT_ENV) { + Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "left_pad" | "left-pad" | "pad" => Self::LeftAudioPad, + "stable_prompt" | "stable-text" | "prompt" => Self::StableTextPrompt, + _ => Self::UtteranceOnly, + }, + Err(_) => Self::UtteranceOnly, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::UtteranceOnly => "utterance_only", + Self::LeftAudioPad => "left_audio_pad", + Self::StableTextPrompt => "stable_text_prompt", + } + } +} + +/// Cut the audio range a provider may see. Long silence is a hard fence. +pub fn bound_context_range( + utterance: &TailSampleRange, + last_long_silence_end: u64, + mode: FusionContextMode, + pad_samples: u64, +) -> TailSampleRange { + let mut range = utterance.clone(); + if mode == FusionContextMode::LeftAudioPad { + let want = utterance.sample_start.saturating_sub(pad_samples); + range.sample_start = want.max(last_long_silence_end); + } + if range.sample_start < last_long_silence_end + && last_long_silence_end < range.sample_end + && last_long_silence_end > utterance.sample_start.saturating_sub(pad_samples) + { + // Fence is inside the requested pad — clip, never cross. + range.sample_start = last_long_silence_end.max(utterance.sample_start); + } + if range.sample_start > range.sample_end { + range.sample_start = range.sample_end; + } + range +} + +/// One word pinned to a PCM range for fusion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FusionWord { + pub text: String, + pub sample_start: u64, + pub sample_end: u64, +} + +impl FusionWord { + pub fn from_timed(segment: &TimedTailSegment) -> Self { + Self { + text: segment.text.clone(), + sample_start: segment.range.sample_start, + sample_end: segment.range.sample_end, + } + } + + fn midpoint(&self) -> u64 { + self.sample_start + (self.sample_end.saturating_sub(self.sample_start) / 2) + } +} + +/// Unresolved Apple/Whisper pair — receipt only, no confidence pick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnresolvedAlternative { + pub apple: FusionWord, + pub whisper: FusionWord, +} + +/// Conservative fusion of one unsealed utterance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FusionDecision { + pub text: String, + pub agreements: usize, + pub gap_fills: usize, + pub unresolved: Vec, +} + +/// Content-free fusion receipt (no transcript text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FusionReceipt { + pub utterance_id: u64, + pub code: SkipReasonCode, + pub agreements: usize, + pub gap_fills: usize, + pub unresolved: usize, +} + +/// Case- and punctuation-folded token used only for agreement tests. +pub fn normalize_fusion_word(text: &str) -> String { + text.chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +fn ranges_overlap(a_start: u64, a_end: u64, b_start: u64, b_end: u64) -> bool { + a_start < b_end && b_start < a_end +} + +/// Assign Apple words to Silero utterances by PCM overlap. Words that fall +/// in no utterance are returned as leftovers (caller receipts `no_time_overlap`). +pub fn slice_apple_words( + ledger: &UtteranceLedger, + words: &[FusionWord], +) -> (Vec<(u64, Vec)>, Vec) { + let mut leftover = Vec::new(); + let mut by_id: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for word in words { + match ledger.utterance_covering(word.midpoint()) { + Some(utterance) => by_id.entry(utterance.id).or_default().push(word.clone()), + None => leftover.push(word.clone()), + } + } + (by_id.into_iter().collect(), leftover) +} + +/// Conservative per-word fusion. Agreements and clear gap fills commit; +/// overlapping disagreements are receipted and Apple is kept. Confidence +/// never participates. +pub fn conservative_fuse(apple: &[FusionWord], whisper: &[FusionWord]) -> FusionDecision { + let mut committed: Vec = Vec::new(); + let mut unresolved = Vec::new(); + let mut used_whisper = vec![false; whisper.len()]; + let mut agreements = 0usize; + + for apple_word in apple { + let overlaps: Vec = whisper + .iter() + .enumerate() + .filter(|(_, whisper_word)| { + ranges_overlap( + apple_word.sample_start, + apple_word.sample_end, + whisper_word.sample_start, + whisper_word.sample_end, + ) + }) + .map(|(idx, _)| idx) + .collect(); + if overlaps.is_empty() { + committed.push(apple_word.clone()); + continue; + } + let apple_key = normalize_fusion_word(&apple_word.text); + let matching: Vec = overlaps + .iter() + .copied() + .filter(|&idx| normalize_fusion_word(&whisper[idx].text) == apple_key) + .collect(); + if matching.is_empty() { + let whisper_word = whisper[overlaps[0]].clone(); + used_whisper[overlaps[0]] = true; + unresolved.push(UnresolvedAlternative { + apple: apple_word.clone(), + whisper: whisper_word, + }); + committed.push(apple_word.clone()); + } else { + agreements += 1; + for idx in matching { + used_whisper[idx] = true; + } + committed.push(apple_word.clone()); + } + } + + let mut gap_fills = 0usize; + for (idx, whisper_word) in whisper.iter().enumerate() { + if used_whisper[idx] { + continue; + } + let overlaps_apple = apple.iter().any(|apple_word| { + ranges_overlap( + apple_word.sample_start, + apple_word.sample_end, + whisper_word.sample_start, + whisper_word.sample_end, + ) + }); + if overlaps_apple { + continue; + } + gap_fills += 1; + committed.push(whisper_word.clone()); + } + + committed.sort_by_key(|word| word.sample_start); + let text = committed + .iter() + .map(|word| word.text.as_str()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" "); + + FusionDecision { + text, + agreements, + gap_fills, + unresolved, + } +} + +pub fn fusion_receipt(utterance_id: u64, decision: &FusionDecision) -> FusionReceipt { + let code = if !decision.unresolved.is_empty() { + SkipReasonCode::UnresolvedAlternative + } else { + SkipReasonCode::NoTimeOverlap + }; + FusionReceipt { + utterance_id, + code, + agreements: decision.agreements, + gap_fills: decision.gap_fills, + unresolved: decision.unresolved.len(), + } +} + +/// One starved mid-phrase window used by the skip-table verifier. +#[cfg(test)] +#[derive(Debug, Clone)] +struct StarvedWindow { + pub committed: &'static str, + pub whisper: &'static str, + pub apple: Vec, + pub whisper_words: Vec, +} + +/// Synthetic reconstruction of the mid-phrase-window starvation class +/// (18 skips on build 614). Baseline LCS treats head-garbage as wholesale +/// divergence; time-sliced fusion commits the overlapping agreements. +#[cfg(test)] +fn starved_mid_phrase_windows() -> Vec { + fn word(text: &str, start: u64, end: u64) -> FusionWord { + FusionWord { + text: text.to_string(), + sample_start: start, + sample_end: end, + } + } + // 12 mid-phrase windows: Apple has the true phrase; Whisper window + // started in babble so the LCS head is garbage, but the overlapping + // tail agrees. 6 genuine unresolved pairs stay skipped. + let mut windows = Vec::new(); + for i in 0..12u64 { + let base = i * 48_000; + windows.push(StarvedWindow { + committed: "to jest fraza", + whisper: "babble noise to jest fraza", + apple: vec![ + word("to", base, base + 8_000), + word("jest", base + 8_000, base + 16_000), + word("fraza", base + 16_000, base + 24_000), + ], + whisper_words: vec![ + word("babble", base.saturating_sub(16_000), base), + word("noise", base.saturating_sub(8_000), base), + word("to", base, base + 8_000), + word("jest", base + 8_000, base + 16_000), + word("fraza", base + 16_000, base + 24_000), + ], + }); + } + for i in 0..6u64 { + let base = 600_000 + i * 16_000; + windows.push(StarvedWindow { + committed: "kot", + whisper: "pies", + apple: vec![word("kot", base, base + 8_000)], + whisper_words: vec![word("pies", base, base + 8_000)], + }); + } + windows +} + +/// Baseline (token LCS, fusion off) vs fusion-on skip/apply counts. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SkipTable { + pub baseline_skips: usize, + pub baseline_applied: usize, + pub fusion_skips: usize, + pub fusion_applied: usize, +} + +#[cfg(test)] +impl SkipTable { + pub fn skip_reduction_ratio(self) -> f64 { + if self.baseline_skips == 0 { + return 0.0; + } + 1.0 - (self.fusion_skips as f64 / self.baseline_skips as f64) + } +} + +/// Score the starved fixture. Baseline treats any Whisper head-garbage as a +/// skip (the production change-ratio class). Fusion commits agreements + +/// gap fills and only receipts unresolved alternatives. +#[cfg(test)] +fn score_starved_fixture(windows: &[StarvedWindow]) -> SkipTable { + let mut baseline_skips = 0usize; + let mut baseline_applied = 0usize; + let mut fusion_skips = 0usize; + let mut fusion_applied = 0usize; + for window in windows { + let committed: Vec<&str> = window.committed.split_whitespace().collect(); + let whisper: Vec<&str> = window.whisper.split_whitespace().collect(); + let committed_in_whisper = committed + .iter() + .filter(|token| { + whisper + .iter() + .any(|w| normalize_fusion_word(w) == normalize_fusion_word(token)) + }) + .count(); + let head_garbage = whisper.len() > committed.len() && committed_in_whisper < whisper.len(); + let identical = committed + .iter() + .zip(whisper.iter()) + .all(|(a, b)| normalize_fusion_word(a) == normalize_fusion_word(b)) + && committed.len() == whisper.len(); + if identical { + baseline_applied += 1; + } else if head_garbage || committed_in_whisper < committed.len() { + baseline_skips += 1; + } else { + baseline_applied += 1; + } + + let decision = conservative_fuse(&window.apple, &window.whisper_words); + if decision.unresolved.is_empty() && (decision.agreements > 0 || decision.gap_fills > 0) { + fusion_applied += 1; + } else if decision.unresolved.is_empty() && decision.agreements == 0 { + fusion_skips += 1; + } else { + // Unresolved alternatives are receipted, not applied as a rewrite. + fusion_skips += 1; + } + } + SkipTable { + baseline_skips, + baseline_applied, + fusion_skips, + fusion_applied, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn word(text: &str, start: u64, end: u64) -> FusionWord { + FusionWord { + text: text.to_string(), + sample_start: start, + sample_end: end, + } + } + + fn range(start: u64, end: u64) -> TailSampleRange { + TailSampleRange { + session: "s".into(), + capture_epoch: 0, + sample_start: start, + sample_end: end, + } + } + + #[test] + fn lane_defaults_off_until_operator_flip() { + assert!( + !lane_enabled_from_raw(None), + "unset must keep the experimental lane off" + ); + for off in ["0", "false", "no", "off", " OFF "] { + assert!( + !lane_enabled_from_raw(Some(off)), + "{off:?} must disarm the lane" + ); + } + for on in ["1", "true", "yes", "on"] { + assert!( + lane_enabled_from_raw(Some(on)), + "{on:?} must explicitly arm the lane" + ); + } + } + + /// The unification claim, stated as a test: **one** observation of the + /// spectrum produces both the ledger identity and the lifecycle edge bit. + /// Two speech segments split by a closing edge mint two identities, and the + /// `speech_live` the epoch gate reads is true exactly across those two + /// segments and false in the silence between them. + #[test] + fn one_observation_feeds_both_identity_and_the_lifecycle_edge() { + let mut ingress = SileroIngress::new(16_000, "s", 0); + + // Segment 1: open at 0, still open, then close inside the third chunk. + let a = ingress.observe(Some((0, 8_000)), false, 8_000); + assert_eq!(a.open, Some(1)); + assert!(a.speech_live, "an open segment is a live speech edge"); + let b = ingress.observe(Some((0, 16_000)), false, 16_000); + assert_eq!(b.open, Some(1), "an extending segment keeps its identity"); + let close = ingress.observe(None, true, 24_000); + assert_eq!(close.closed, vec![1]); + assert!( + close.speech_live, + "the chunk a segment closes in is still speech — the silence \ + counter starts after Silero's own hysteresis, never before it" + ); + + // Long silence: no edge, no identity. + for cursor in [32_000u64, 40_000, 48_000] { + let quiet = ingress.observe(None, false, cursor); + assert!(!quiet.speech_live, "silence is not a speech edge"); + assert!(quiet.closed.is_empty()); + assert_eq!(quiet.open, None); + } + + // Segment 2 past the long-silence fence: a NEW identity, not an extend. + let fence = (LONG_SILENCE_FENCE_SECS * 16_000.0) as u64; + let second_start = 24_000 + fence + 8_000; + let c = ingress.observe(Some((second_start, second_start + 8_000)), false, 56_000); + assert_eq!( + c.open, + Some(2), + "speech after a closing edge mints a second utterance" + ); + assert!(c.speech_live); + + let ledger = ingress.ledger(); + assert_eq!(ledger.utterances().len(), 2); + assert_eq!(ledger.utterances()[0].range.sample_start, 0); + assert_eq!(ledger.utterances()[0].range.sample_end, 24_000); + assert!(ledger.utterances()[0].closed); + assert_eq!(ledger.utterances()[1].range.sample_start, second_start); + assert!(!ledger.utterances()[1].closed); + assert!( + ledger.utterances()[1].range.sample_start - ledger.utterances()[0].range.sample_end + >= fence, + "fixture must actually clear the long-silence fence" + ); + } + + /// Enclosure, not overlap: a span may only adopt a Silero range that + /// already covers every sample it claimed. + #[test] + fn enclosure_is_required_before_a_span_adopts_a_silero_range() { + let mut ledger = UtteranceLedger::new(); + ledger.open_or_extend("s", 0, 10_000, 30_000); + ledger.close_open(30_000); + + let enclosed = ledger + .utterance_enclosing(12_000, 20_000) + .expect("a span inside the edge binds to it"); + assert_eq!(enclosed.id, 1); + assert_eq!(enclosed.range.sample_start, 10_000); + assert_eq!(enclosed.range.sample_end, 30_000); + + assert!( + ledger.utterance_enclosing(5_000, 20_000).is_none(), + "a span starting before the edge must NOT adopt it" + ); + assert!( + ledger.utterance_enclosing(20_000, 40_000).is_none(), + "a span ending after the edge must NOT adopt it" + ); + assert!( + ledger.utterance_enclosing(80_000, 90_000).is_none(), + "no edge at all is fail-open, not a panic" + ); + } + + /// One ledger, one id space: an id burnt by the Apple-boundary fallback is + /// never re-minted for a real utterance. + #[test] + fn reserved_ids_are_never_reused_by_a_minted_utterance() { + let mut ledger = UtteranceLedger::new(); + assert_eq!(ledger.reserve_id(), 1); + assert_eq!(ledger.reserve_id(), 2); + assert_eq!( + ledger.open_or_extend("s", 0, 0, 1_000), + 3, + "minting must continue past every reserved id" + ); + assert_eq!(ledger.utterances().len(), 1, "a reservation is not a span"); + } + + #[test] + fn w13_fusion_conservative_commits_agreements() { + let apple = vec![ + word("the", 0, 8_000), + word("cat", 8_000, 16_000), + word("sat", 16_000, 24_000), + ]; + let whisper_agree = vec![ + word("the", 0, 8_000), + word("cat", 8_000, 16_000), + word("sat", 16_000, 24_000), + ]; + let agreed = conservative_fuse(&apple, &whisper_agree); + assert_eq!(agreed.text, "the cat sat"); + assert_eq!(agreed.agreements, 3); + assert_eq!(agreed.gap_fills, 0); + assert!(agreed.unresolved.is_empty()); + + let mut whisper_gap = whisper_agree.clone(); + whisper_gap.push(word("here", 24_000, 32_000)); + let filled = conservative_fuse(&apple, &whisper_gap); + assert_eq!(filled.text, "the cat sat here"); + assert_eq!(filled.agreements, 3); + assert_eq!(filled.gap_fills, 1); + assert!(filled.unresolved.is_empty()); + + let whisper_conflict = vec![ + word("the", 0, 8_000), + word("dog", 8_000, 16_000), + word("sat", 16_000, 24_000), + ]; + let conflicted = conservative_fuse(&apple, &whisper_conflict); + assert_eq!(conflicted.text, "the cat sat"); + assert_eq!(conflicted.agreements, 2); + assert_eq!(conflicted.unresolved.len(), 1); + assert_eq!(conflicted.unresolved[0].apple.text, "cat"); + assert_eq!(conflicted.unresolved[0].whisper.text, "dog"); + let receipt = fusion_receipt(7, &conflicted); + assert_eq!(receipt.code, SkipReasonCode::UnresolvedAlternative); + assert_eq!(receipt.unresolved, 1); + } + + #[test] + fn apple_words_slice_onto_silero_edges() { + let mut ledger = UtteranceLedger::new(); + ledger.open_or_extend("s", 0, 0, 24_000); + ledger.close_open(24_000); + ledger.open_or_extend("s", 0, 32_000, 48_000); + let words = vec![ + word("alpha", 1_000, 8_000), + word("beta", 33_000, 40_000), + word("orphan", 80_000, 88_000), + ]; + let (sliced, leftover) = slice_apple_words(&ledger, &words); + assert_eq!(sliced.len(), 2); + assert_eq!(sliced[0].1[0].text, "alpha"); + assert_eq!(sliced[1].1[0].text, "beta"); + assert_eq!(leftover.len(), 1); + assert_eq!(leftover[0].text, "orphan"); + } + + #[test] + fn left_pad_never_crosses_long_silence() { + let utterance = range(48_000, 64_000); + let silence_end = 40_000; + let padded = bound_context_range( + &utterance, + silence_end, + FusionContextMode::LeftAudioPad, + 16_000, + ); + assert_eq!(padded.sample_start, silence_end); + assert_eq!(padded.sample_end, 64_000); + + let utterance_only = bound_context_range( + &utterance, + silence_end, + FusionContextMode::UtteranceOnly, + 16_000, + ); + assert_eq!(utterance_only.sample_start, 48_000); + + let prompt = bound_context_range( + &utterance, + silence_end, + FusionContextMode::StableTextPrompt, + 16_000, + ); + assert_eq!(prompt.sample_start, 48_000); + } + + #[test] + fn w13_fusion_starved_fixture_skip_table() { + let windows = starved_mid_phrase_windows(); + assert_eq!(windows.len(), 18); + let table = score_starved_fixture(&windows); + println!( + "starved fixture skip table: baseline skips={} applied={} | fusion skips={} applied={} | reduction={:.0}%", + table.baseline_skips, + table.baseline_applied, + table.fusion_skips, + table.fusion_applied, + table.skip_reduction_ratio() * 100.0 + ); + assert!( + table.skip_reduction_ratio() + f64::EPSILON >= 0.50, + "skip reduction {:.2} < 50% (baseline {} → fusion {})", + table.skip_reduction_ratio(), + table.baseline_skips, + table.fusion_skips + ); + assert!( + table.fusion_applied >= table.baseline_applied, + "applied dropped: baseline {} fusion {}", + table.baseline_applied, + table.fusion_applied + ); + } +} diff --git a/core/pipeline/streaming/span_idempotence.rs b/core/pipeline/streaming/span_idempotence.rs new file mode 100644 index 00000000..c257fb9f --- /dev/null +++ b/core/pipeline/streaming/span_idempotence.rs @@ -0,0 +1,469 @@ +//! W13-4 — sealed-span idempotence + in-span loop fence. +//! +//! Ports [`crate::asr_session::SessionIngest`] sealed-utterance rules onto the +//! live seal ledger. Identity is the canonical PCM range +//! (`session`, `capture_epoch`, `sample_start`, `sample_end`) plus an optional +//! provider `request_id`. Text is never a suppression key. +//! +//! Auto-removal is allowed only on non-content evidence (Amendment 3 / D2): +//! replayed request/range identity, non-progressing timestamps, or a decode +//! failure. Anything else is kept; a content-similar offer against a *new* +//! identity emits a WARN receipt and still lands on the canvas. +//! +//! Lane flag [`SPAN_IDEMPOTENCE_ENV`] is **default OFF**. + +use std::collections::BTreeSet; + +use crate::stt::tail_provider::{TailRequestIdentity, TailSampleRange}; + +/// Lane flag for sealed-span replay refusal. Unset / `0` / `false` / `off` / +/// `no` keep the pre-W13-4 seal path bit-identical. +pub const SPAN_IDEMPOTENCE_ENV: &str = "CODESCRIBE_SPAN_IDEMPOTENCE"; + +/// Whether the W13-4 idempotence lane is armed. Default OFF. +pub fn lane_enabled() -> bool { + let raw = std::env::var(SPAN_IDEMPOTENCE_ENV).ok(); + lane_enabled_from_raw(raw.as_deref()) +} + +fn lane_enabled_from_raw(raw: Option<&str>) -> bool { + raw.is_some_and(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +/// Non-content evidence that may auto-remove a delivery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NonContentEvidence { + /// Exact `(session, epoch, start, end)` already sealed or accepted. + ReplayedRangeIdentity, + /// Same `request_id` already consumed (provider re-submit). + ReplayedRequestIdentity, + /// Word/span clock did not advance on a re-offer of the same request. + NonProgressingTimestamps, + /// Provider reported a failed decode for this identity. + DecodeFailure, +} + +impl NonContentEvidence { + pub fn as_str(self) -> &'static str { + match self { + Self::ReplayedRangeIdentity => "replayed_range_identity", + Self::ReplayedRequestIdentity => "replayed_request_identity", + Self::NonProgressingTimestamps => "non_progressing_timestamps", + Self::DecodeFailure => "decode_failure", + } + } +} + +/// What the ledger decided about one offered span. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpanOfferVerdict { + /// New identity; canvas grows. + Accepted, + /// Same sealed/accepted identity — SessionIngest `RejectedSealedUtterance` + /// / `DuplicateIdempotent` ported onto range identity. + RejectedSealedReplay, + /// In-span loop fenced on non-content evidence (auto-removed). + FencedLoop { evidence: NonContentEvidence }, + /// Content looks like a duplicate but the identity is new — KEEP. + WarnPreserved, +} + +impl SpanOfferVerdict { + pub fn lands_on_canvas(&self) -> bool { + matches!(self, Self::Accepted | Self::WarnPreserved) + } + + pub fn as_token(&self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::RejectedSealedReplay => "rejected_sealed_replay", + Self::FencedLoop { .. } => "fenced_loop", + Self::WarnPreserved => "content_similar_preserved", + } + } +} + +/// Content-free receipt. Never carries transcript text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpanIdempotenceReceipt { + pub code: &'static str, + pub warn: bool, + pub request_id: u64, + pub range: TailSampleRange, +} + +/// One offered delivery. `text` is for canvas assembly only — never a key. +#[derive(Debug, Clone)] +pub struct SpanOffer { + pub identity: TailRequestIdentity, + pub text: String, + /// Caller-measured: did word/span timestamps advance vs the previous + /// offer of this `request_id`? Unused on a first offer. + pub timestamps_progressed: bool, + pub decode_ok: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AcceptedSpan { + identity: TailRequestIdentity, + text: String, +} + +/// Sealed-span ledger. Holds no audio and reads no wall clock. +#[derive(Debug, Clone, Default)] +pub struct SpanIdempotenceLedger { + accepted: Vec, + sealed_ranges: BTreeSet, + receipts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct RangeKey { + session: String, + capture_epoch: u64, + sample_start: u64, + sample_end: u64, +} + +impl RangeKey { + fn from_range(range: &TailSampleRange) -> Self { + Self { + session: range.session.clone(), + capture_epoch: range.capture_epoch, + sample_start: range.sample_start, + sample_end: range.sample_end, + } + } +} + +impl SpanIdempotenceLedger { + /// Apply one offer. Text never participates in the removal decision. + pub fn offer(&mut self, offer: SpanOffer) -> SpanOfferVerdict { + let range = &offer.identity.range; + let request_id = offer.identity.request_id; + + if !offer.decode_ok { + return self.fence(NonContentEvidence::DecodeFailure, request_id, range.clone()); + } + + // Sealed identity is immutable — SessionIngest rule 5, keyed by range. + if self.sealed_ranges.contains(&RangeKey::from_range(range)) { + return self.reject_replay( + NonContentEvidence::ReplayedRangeIdentity, + request_id, + range.clone(), + ); + } + + if let Some(previous) = self + .accepted + .iter() + .rev() + .find(|span| span.identity.request_id == request_id) + { + // In-span loop: same request, clock did not move. Not a content check. + if !offer.timestamps_progressed + || (range.sample_end <= previous.identity.range.sample_end + && range.sample_start <= previous.identity.range.sample_start) + { + return self.fence( + NonContentEvidence::NonProgressingTimestamps, + request_id, + range.clone(), + ); + } + return self.reject_replay( + NonContentEvidence::ReplayedRequestIdentity, + request_id, + range.clone(), + ); + } + + let content_similar = self + .accepted + .iter() + .any(|span| span.text == offer.text && !offer.text.trim().is_empty()); + + self.accepted.push(AcceptedSpan { + identity: offer.identity.clone(), + text: offer.text, + }); + + if content_similar { + self.receipts.push(SpanIdempotenceReceipt { + code: SpanOfferVerdict::WarnPreserved.as_token(), + warn: true, + request_id, + range: range.clone(), + }); + return SpanOfferVerdict::WarnPreserved; + } + + SpanOfferVerdict::Accepted + } + + /// Record that a range has sealed (immutable). Later exact-identity + /// offers are `RejectedSealedReplay` even if the Apple id is new. + pub fn mark_sealed(&mut self, range: &TailSampleRange) { + self.sealed_ranges.insert(RangeKey::from_range(range)); + } + + pub fn receipts(&self) -> &[SpanIdempotenceReceipt] { + &self.receipts + } + + #[cfg(test)] + fn canvas_texts(&self) -> Vec<&str> { + self.accepted + .iter() + .map(|span| span.text.as_str()) + .collect() + } + + #[cfg(test)] + fn canvas(&self) -> String { + self.canvas_texts().join(" ") + } + + #[cfg(test)] + fn warn_count(&self) -> usize { + self.receipts.iter().filter(|receipt| receipt.warn).count() + } + + #[cfg(test)] + fn suppressed_count(&self) -> usize { + self.receipts + .iter() + .filter(|receipt| { + matches!( + receipt.code, + "replayed_range_identity" + | "replayed_request_identity" + | "non_progressing_timestamps" + | "decode_failure" + ) + }) + .count() + } + + #[cfg(test)] + fn verdict_warns(&self) -> usize { + self.receipts + .iter() + .filter(|receipt| receipt.code == "content_similar_preserved") + .count() + } + + fn reject_replay( + &mut self, + evidence: NonContentEvidence, + request_id: u64, + range: TailSampleRange, + ) -> SpanOfferVerdict { + self.receipts.push(SpanIdempotenceReceipt { + code: evidence.as_str(), + warn: false, + request_id, + range, + }); + SpanOfferVerdict::RejectedSealedReplay + } + + fn fence( + &mut self, + evidence: NonContentEvidence, + request_id: u64, + range: TailSampleRange, + ) -> SpanOfferVerdict { + self.receipts.push(SpanIdempotenceReceipt { + code: evidence.as_str(), + warn: false, + request_id, + range, + }); + SpanOfferVerdict::FencedLoop { evidence } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SENTENCE: &str = "wpierdalało zabierało kradło"; + const RATE: u64 = 16_000; + /// Silero-sized pause used by the 5× fixture (above [`super::super::silero_fusion::LONG_SILENCE_FENCE_SECS`]). + const PAUSE_SAMPLES: u64 = RATE; // 1.0 s + const UTTERANCE_SAMPLES: u64 = 8_000; + + fn range(start: u64, end: u64) -> TailSampleRange { + TailSampleRange { + session: "w13-4".into(), + capture_epoch: 1, + sample_start: start, + sample_end: end, + } + } + + fn identity(request_id: u64, start: u64, end: u64) -> TailRequestIdentity { + TailRequestIdentity { + request_id, + range: range(start, end), + } + } + + fn offer( + request_id: u64, + start: u64, + end: u64, + text: &str, + timestamps_progressed: bool, + decode_ok: bool, + ) -> SpanOffer { + SpanOffer { + identity: identity(request_id, start, end), + text: text.to_string(), + timestamps_progressed, + decode_ok, + } + } + + #[test] + fn lane_defaults_off() { + assert!(!lane_enabled_from_raw(None)); + assert!(!lane_enabled_from_raw(Some("off"))); + assert!(lane_enabled_from_raw(Some("on"))); + } + + #[test] + fn decode_failure_is_fenced_without_canvas_write() { + let mut ledger = SpanIdempotenceLedger::default(); + let verdict = ledger.offer(offer(1, 0, 8_000, SENTENCE, true, false)); + assert_eq!( + verdict, + SpanOfferVerdict::FencedLoop { + evidence: NonContentEvidence::DecodeFailure + } + ); + assert!(ledger.canvas().is_empty()); + assert_eq!(ledger.receipts()[0].code, "decode_failure"); + } + + #[test] + fn w13_span_idempotence_preserves_repetition() { + // Fixture A — duplicate-once: same range identity replayed after seal. + let mut duplicate = SpanIdempotenceLedger::default(); + assert_eq!( + duplicate.offer(offer( + 10, + 0, + UTTERANCE_SAMPLES, + "fragment odzyskany", + true, + true + )), + SpanOfferVerdict::Accepted + ); + duplicate.mark_sealed(&range(0, UTTERANCE_SAMPLES)); + let replay = duplicate.offer(offer( + 11, + 0, + UTTERANCE_SAMPLES, + "fragment odzyskany", + true, + true, + )); + assert_eq!(replay, SpanOfferVerdict::RejectedSealedReplay); + assert_eq!(duplicate.canvas_texts(), ["fragment odzyskany"]); + assert_eq!(duplicate.suppressed_count(), 1); + assert_eq!( + duplicate.receipts().last().map(|r| r.code), + Some("replayed_range_identity") + ); + + // Same request_id re-submitted with a frozen clock is an in-span loop. + let mut looped = SpanIdempotenceLedger::default(); + assert!( + looped + .offer(offer(3, 0, UTTERANCE_SAMPLES, SENTENCE, true, true)) + .lands_on_canvas() + ); + let fenced = looped.offer(offer(3, 0, UTTERANCE_SAMPLES, SENTENCE, false, true)); + assert_eq!( + fenced, + SpanOfferVerdict::FencedLoop { + evidence: NonContentEvidence::NonProgressingTimestamps + } + ); + assert_eq!(looped.canvas_texts(), [SENTENCE]); + + // Fixture B — 5× paused deliberate repetition (Silero-sized gaps). + let mut paused = SpanIdempotenceLedger::default(); + let mut cursor = 0u64; + let mut paused_verdicts = Vec::new(); + for i in 0..5u64 { + let start = cursor; + let end = start + UTTERANCE_SAMPLES; + let verdict = paused.offer(offer(100 + i, start, end, SENTENCE, true, true)); + paused.mark_sealed(&range(start, end)); + paused_verdicts.push(verdict); + cursor = end + PAUSE_SAMPLES; + } + assert!( + paused_verdicts + .iter() + .all(SpanOfferVerdict::lands_on_canvas), + "paused 5× must all land: {paused_verdicts:?}" + ); + assert_eq!(paused.canvas_texts().len(), 5); + assert_eq!( + paused + .canvas_texts() + .iter() + .filter(|t| **t == SENTENCE) + .count(), + 5 + ); + assert_eq!(paused.verdict_warns(), 4); + + // Fixture C — continuous repetition, no Silero-sized gap, progressing clock. + let mut continuous = SpanIdempotenceLedger::default(); + let mut cursor = 0u64; + let mut continuous_verdicts = Vec::new(); + for i in 0..5u64 { + let start = cursor; + let end = start + UTTERANCE_SAMPLES; + let verdict = continuous.offer(offer(200 + i, start, end, SENTENCE, true, true)); + continuous.mark_sealed(&range(start, end)); + continuous_verdicts.push(verdict); + cursor = end; // abutting — no pause + } + assert!( + continuous_verdicts + .iter() + .all(SpanOfferVerdict::lands_on_canvas), + "continuous 5× must all land: {continuous_verdicts:?}" + ); + assert_eq!(continuous.canvas_texts().len(), 5); + assert_eq!( + continuous + .canvas_texts() + .iter() + .filter(|t| **t == SENTENCE) + .count(), + 5 + ); + assert_eq!(continuous.verdict_warns(), 4); + + // Choice rule: if content looks like a duplicate but identity is new, + // repetition wins and the would-be drop is a WARN receipt. + assert!(paused.warn_count() >= 4); + assert!(continuous.warn_count() >= 4); + assert_eq!(paused.suppressed_count(), 0); + assert_eq!(continuous.suppressed_count(), 0); + } +} diff --git a/core/pipeline/streaming/tests.rs b/core/pipeline/streaming/tests.rs index f3787bd9..a64a68b7 100644 --- a/core/pipeline/streaming/tests.rs +++ b/core/pipeline/streaming/tests.rs @@ -5,9 +5,11 @@ use std::path::PathBuf; use std::sync::{Arc, Condvar, Mutex as StdMutex}; use anyhow::Result; +use serial_test::serial; use tokio::sync::{Mutex, mpsc}; use tokio::time::{Duration, Instant}; +use crate::asr_session::recorder::Layer1Decision; use crate::pipeline::contracts::{EngineEvent, EventSink, RawTranscript, TranscriptSegment}; use crate::pipeline::sinks::CollectorEventSink; use crate::stt::scheduler::{SttLane, SttScheduler, SttTaskHandle}; @@ -19,6 +21,41 @@ use super::pipeline::*; use super::quality_gate::*; use super::session::*; +/// Restore the legacy layered-transcription env after policy tests. +struct LayeredEnvGuard { + previous: Option, +} + +impl LayeredEnvGuard { + fn set(value: &str) -> Self { + const KEY: &str = "CODESCRIBE_LAYERED_TRANSCRIPTION"; + let previous = std::env::var(KEY).ok(); + unsafe { std::env::set_var(KEY, value) }; + Self { previous } + } +} + +impl Drop for LayeredEnvGuard { + fn drop(&mut self) { + const KEY: &str = "CODESCRIBE_LAYERED_TRANSCRIPTION"; + match self.previous.as_deref() { + Some(value) => unsafe { std::env::set_var(KEY, value) }, + None => unsafe { std::env::remove_var(KEY) }, + } + } +} + +/// A stale phase1 env may refine Local power, but must never smuggle Whisper +/// into cloud or Apple-only recording sessions. +#[test] +#[serial] +fn tail_patch_requires_local_power_policy_even_when_legacy_env_is_armed() { + let _guard = LayeredEnvGuard::set("phase1"); + + assert!(!tail_patch_enabled(false)); + assert!(tail_patch_enabled(true)); +} + fn pending_item(is_final: bool) -> PendingUtteranceWorkItem { pending_item_with_marker(is_final, if is_final { 1.0 } else { 0.1 }) } @@ -974,6 +1011,47 @@ fn test_correction_bootstraps_when_no_output_emitted_yet() { }); } +#[test] +fn test_transcript_buffer_has_single_writer_no_duplication() { + // Regression for 2026-08-14: the command worker snapshotted the full target + // into the shared buffer AND the tick loop appended the same suffix again, + // so repeated sentences tripled in the final RAW (a 264-char cumulative + // preview became a 791-char transcript). The buffer has exactly one writer: + // store_transcript_snapshot. Ticking must never grow it past the target. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let buf = Arc::new(Mutex::new(String::new())); + let mut emitter = BufferedEmitter::new(buf.clone(), None, None); + + // Cumulative targets, the shape the Apple lane delivers — each one is a + // superset of the previous, mirroring the command-worker snapshot flow. + for target in ["Ala ma", "Ala ma kota. ", "Ala ma kota. Ala ma kota. "] { + if let Some(snapshot) = emitter.set_target_text(target.to_string()) { + emitter.store_transcript_snapshot(snapshot).await; + } + } + emitter.finish(); + + // Generous deadline: the typing profile is env-tunable and an operator + // dotenv can slow the animation; the drain itself is sub-second. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !emitter.tick().await { + assert!( + std::time::Instant::now() < deadline, + "emitter failed to drain within 20s" + ); + tokio::time::sleep(Duration::from_millis(2)).await; + } + + let final_buffer = buf.lock().await.clone(); + assert_eq!(final_buffer, "Ala ma kota. Ala ma kota. "); + assert_eq!(emitter.emitted_text, "Ala ma kota. Ala ma kota. "); + }); +} + #[test] fn test_correction_delta() { let before = "This is a dratf."; @@ -1401,6 +1479,9 @@ async fn transcription_session_emits_no_speech_and_stats_for_empty_input() { language: Some("pl".to_string()), stream_log_path: None, utterance_silence_sec: None, + layer1: Layer1Decision::Disarmed, + local_whisper_allowed: true, + lifecycle_events: None, }, ) .await; @@ -1635,6 +1716,9 @@ async fn bench_stt_scheduler_latency_probe_from_env() -> Result<()> { language: language.clone(), stream_log_path: None, utterance_silence_sec: None, + layer1: Layer1Decision::Disarmed, + local_whisper_allowed: true, + lifecycle_events: None, }, )); @@ -1714,6 +1798,9 @@ async fn transcription_session_silent_callbacks_keep_no_speech_stats_coherent() language: Some("pl".to_string()), stream_log_path: None, utterance_silence_sec: None, + layer1: Layer1Decision::Disarmed, + local_whisper_allowed: true, + lifecycle_events: None, }, ) .await; diff --git a/core/quality/engine_contract.rs b/core/quality/engine_contract.rs new file mode 100644 index 00000000..fa8c2273 --- /dev/null +++ b/core/quality/engine_contract.rs @@ -0,0 +1,599 @@ +//! Locked THE ENGINE contract for quality-report HTML and corpus JSON. +//! +//! This module exists so an agent cannot re-invent sealed/committed on every +//! session. The bars, the relay, the forbidden operations, and the product +//! goal are compile-time constants. Quality HTML embeds them. Corpus schema +//! v3 names them. Tests fail if anyone "simplifies" the doctrine back to +//! "the whole text is mutable until session seal". + +use serde::{Deserialize, Serialize}; + +/// Schema id carried by every `codescribe-corpus` report that honours this lock. +pub const CORPUS_REPORT_SCHEMA: &str = "codescribe-corpus-parity/v3"; + +/// Stable id of the engine contract itself. +pub const ENGINE_CONTRACT_ID: &str = "the-engine/v1"; + +/// Path of the agent-facing prose lock, relative to the repo root. +pub const ENGINE_CONTRACT_DOC: &str = "docs/THE_ENGINE_CONTRACT.md"; + +/// Path of the HTML-surface contract, relative to the repo root. +pub const QUALITY_HTML_CONTRACT_DOC: &str = "docs/quality-reports/CONTRACT.md"; + +/// True when a `` tag carries both `name` and `content` on the same tag. +/// Prose elsewhere in the document does not satisfy the handshake. +fn meta_content_is(html: &str, name: &str, content: &str) -> bool { + let name_attr = format!(r#"name="{name}""#); + let content_attr = format!(r#"content="{content}""#); + let mut rest = html; + while let Some(name_at) = rest.find(&name_attr) { + let before = &rest[..name_at]; + let tag_start = before.rfind('<').unwrap_or(0); + let after = &rest[name_at..]; + let tag_end = after.find('>').unwrap_or(after.len()); + let tag = &rest[tag_start..name_at + tag_end]; + if tag.contains(&content_attr) { + return true; + } + rest = &rest[name_at + name_attr.len()..]; + } + false +} + +/// Failures if this string is not a Seal Atlas quality report. +pub fn validate_quality_html(html: &str) -> Vec { + let lowered = html.to_ascii_lowercase(); + let mut failures = Vec::new(); + if !meta_content_is(html, "engine-contract", ENGINE_CONTRACT_ID) { + failures.push("missing meta engine-contract=the-engine/v1".into()); + } + if !meta_content_is(html, "quality-report-surface", QUALITY_REPORT_SURFACE) { + failures.push("missing meta quality-report-surface=seal-atlas".into()); + } + if !lowered.contains("seal atlas") && !lowered.contains("seal-atlas") { + failures.push("title/body must name Seal Atlas".into()); + } + if !html.contains(r#"class="stat""#) { + failures.push("Voice Lab handshake needs div.stat cards".into()); + } + if !lowered.contains("word-grain") { + failures.push("must label word-grain".into()); + } + if !lowered.contains("utterance-grain") { + failures.push("must label utterance-grain".into()); + } + if !lowered.contains("clock-lie") && !html.contains("kłamstwo zegarowe") { + failures.push("clock-lie must be a first-class finding".into()); + } + if !html.contains("SealedSpan.words") + && !lowered.contains("sealedspan.words") + && !lowered.contains("sealed spans") + { + failures.push("must name SealedSpan.words or sealed spans".into()); + } + if !lowered.contains("whisper") { + failures.push("must mention whisper_words / Whisper on the same clock".into()); + } + if lowered.contains("

codescribe quality report

") { + failures.push("retired Qube H1 is not a Seal Atlas".into()); + } + let wer_at = lowered.find("avg wer"); + let footnote_at = lowered.find("footnote"); + if let Some(wer) = wer_at + && !footnote_at.map(|footnote| footnote < wer).unwrap_or(false) + { + failures.push("Avg WER may only appear after a footnote marker".into()); + } + failures +} + +/// What a quality report is allowed to treat as the document vs a proposal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReportSurfaceRole { + /// Live Apple hypothesis for an open or just-committed span. + LiveHypothesis, + /// Layer-1 hole-fill inside a still-unsealed span. + WhisperHoleFill, + /// Closed span after Apple + Whisper + lexicon fusion. + SealedSpan, + /// Session document after `transcript_sealed`. + SessionDocument, + /// Full-file HQ or Cloud pass after session seal — never auto-applied. + HumanTriggeredProposal, +} + +/// One of the three finality bars. Not synonyms. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FinalityBar { + /// This layer finished its hypothesis for the fragment. The layer is + /// banned from further overwrite of that span. Preview stays grey; + /// committed is bright. This is not the document. + UtteranceFinal, + /// Apple + Whisper + lexicon finished fusion for a Silero-bounded span. + /// The record `[sample_start, sample_end)` becomes append-only and may + /// start inline formatting. Order on the PCM axis is frozen. + UtteranceSealed, + /// The whole session — tail and formatter included — was assembled into + /// the document. Automation puts its hands down. Full HQ / Cloud may + /// only propose a variant. + TranscriptSealed, +} + +/// A layer in the live relay. Ban is per-layer, per-span: the layer that +/// already passed this span is out; the next one may enrich the same time +/// window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RelayLayer { + Apple, + Whisper, + Lexicon, + Formatter, + Human, +} + +/// Machine-readable lock. Quality HTML and corpus JSON must serialize this +/// object, not a free-form paragraph an agent can paraphrase. +/// +/// Serialize-only: the lock lives as a `const` with `&'static` slices. +/// serde cannot `Deserialize` those borrows, and nothing in the tree +/// reads this type back from JSON — the compile-time constant is the +/// source of truth. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EngineContract { + pub id: &'static str, + pub primary_key: &'static str, + pub relay: &'static [RelayLayer], + pub bars: &'static [FinalityBar], + pub forbidden: &'static [&'static str], + pub whisper_window: &'static str, + pub full_file_pass: &'static str, + pub product_goal: &'static str, +} + +/// The only contract instance quality reports may emit. +pub const ENGINE_CONTRACT: EngineContract = EngineContract { + id: ENGINE_CONTRACT_ID, + primary_key: "pcm_time", + relay: &[ + RelayLayer::Apple, + RelayLayer::Whisper, + RelayLayer::Lexicon, + RelayLayer::Formatter, + RelayLayer::Human, + ], + bars: &[ + FinalityBar::UtteranceFinal, + FinalityBar::UtteranceSealed, + FinalityBar::TranscriptSealed, + ], + forbidden: &[ + "rewrite_from_zero", + "reorder_spans", + "hallucinate_into_silence", + "full_file_in_automatic_pipeline", + "auto_replace_after_transcript_sealed", + "treat_committed_as_document", + "treat_whole_text_mutable_until_session_seal", + ], + whisper_window: "3-5s utterance-bounded partials", + full_file_pass: "button_only_proposal", + product_goal: "energy × time → the true sentence, live in the buffer, ~10ms to paste", +}; + +/// Required visual of a private quality HTML. WER is a footnote. +pub const QUALITY_REPORT_SURFACE: &str = "seal-atlas"; + +/// Gold take checked into the repo — the report an agent must not replace +/// with a scores table. +pub const SEAL_ATLAS_GOLD_HTML: &str = "docs/quality-reports/seal-atlas.take01.html"; + +/// Speech faster than this (characters / second over a span range) is a +/// clock-lie: the range is not the range of that speech. Take 01 span 2 +/// is 410 chars/s. Conversational Polish sits well below 20. +pub const CLOCK_LIE_CHARS_PER_SEC: f32 = 30.0; + +/// How a sealed span's word payload is allowed to be read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpanGrain { + /// SFSpeech returned more than one distinct word pin. + Word, + /// One segment covering the Apple commit-to-commit window. + Utterance, +} + +/// Classify grain. Per-word pins are real where they exist and never +/// guaranteed — two or more distinct ranges = word-grain, otherwise utterance. +pub fn span_grain(distinct_word_ranges: usize) -> SpanGrain { + if distinct_word_ranges >= 2 { + SpanGrain::Word + } else { + SpanGrain::Utterance + } +} + +/// Clock-lie: too many characters for the claimed PCM duration. +pub fn is_clock_lie(chars: usize, duration_secs: f32) -> bool { + duration_secs > 0.0 && (chars as f32 / duration_secs) > CLOCK_LIE_CHARS_PER_SEC +} + +/// Grapheme ticks inside a word range are an even split, never a measurement. +pub const LETTER_TIMING: &str = "interpolation_not_measurement"; + +/// Directory Voice Lab scans. Corpus atlas HTML must land here (or under +/// `$CODESCRIBE_ARTIFACTS_DIR`) or the operator never sees it. +pub const VOICE_LAB_ARTIFACTS_ROOT: &str = "~/.vibecrafted/artifacts/vetcoders/codescribe"; + +/// How Voice Lab labels a discovered HTML. Mirrors `discover_quality_reports` +/// in voice-lab `server.py` — change both or the catalog lies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoiceLabReportKind { + SealAtlas, + QualityContract, + QualityReport, +} + +impl VoiceLabReportKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::SealAtlas => "seal_atlas", + Self::QualityContract => "quality_contract", + Self::QualityReport => "quality_report", + } + } +} + +/// Same classifier Voice Lab uses on `title + relative path`. +pub fn voice_lab_kind(title: &str, relative_path: &str) -> VoiceLabReportKind { + let lowered = format!("{title} {relative_path}").to_ascii_lowercase(); + if lowered.contains("seal atlas") || lowered.contains("seal-atlas") { + VoiceLabReportKind::SealAtlas + } else if lowered.contains("quality") && lowered.contains("contract") { + VoiceLabReportKind::QualityContract + } else { + VoiceLabReportKind::QualityReport + } +} + +/// Role of a named quality-report column. WER against a column does not +/// promote that column to document. +pub fn surface_role(column: &str) -> Option { + match column { + "raw" | "live" => Some(ReportSurfaceRole::LiveHypothesis), + "post" | "layer1" => Some(ReportSurfaceRole::WhisperHoleFill), + "sealed" => Some(ReportSurfaceRole::SealedSpan), + "delivered" | "session" => Some(ReportSurfaceRole::SessionDocument), + "ai" | "ai_formatted" | "cloud" | "hq" => Some(ReportSurfaceRole::HumanTriggeredProposal), + _ => None, + } +} + +/// Self-contained HTML plate. Inlined into Qube / corpus / teacher reports +/// so opening any quality HTML shows the lock before the scores. +pub fn render_engine_contract_html() -> String { + let bars = [ + ( + "utterance_final / committed", + "This layer finished its hypothesis for the fragment. That layer is banned from further overwrite of this span. Preview grey, committed bright. Not the document.", + ), + ( + "utterance_sealed", + "Apple + Whisper + lexicon finished fusion for the Silero-bounded span. Record [sample_start, sample_end) is append-only and may start inline formatting. Order on the PCM axis is frozen.", + ), + ( + "transcript_sealed", + "The session — tail and formatter included — was assembled into the document. Automation puts its hands down. Full HQ / Cloud may only propose a variant.", + ), + ]; + let mut rows = String::new(); + for (name, meaning) in bars { + rows.push_str(&format!("{name}{meaning}\n")); + } + let forbidden = ENGINE_CONTRACT + .forbidden + .iter() + .map(|item| format!("
  • {item}
  • ")) + .collect::>() + .join(""); + format!( + r#"
    +

    THE ENGINE · quality-report contract · {id}

    +

    Place on the canvas is given by energy in time — not by tokens.

    +

    {goal}

    +

    Relay: Apple → Whisper → lexicon → formatter → human. Ban is per layer, per span. Whisper works 3–5 s partials at utterance boundaries and fills holes. It does not hallucinate into silence and does not see full audio unless a human presses the button.

    + + + +{rows} + +
    BarMeans
    +

    Before transcript_sealed the whole document is not mutable. Closed spans stay on the PCM axis. The tail may still evolve. Whisper may replace weaker evidence inside a still-unsealed span. Stop closes only the tail.

    +
      {forbidden}
    +
    +"#, + id = ENGINE_CONTRACT.id, + key = ENGINE_CONTRACT.primary_key, + goal = ENGINE_CONTRACT.product_goal, + rows = rows, + forbidden = forbidden, + ) +} + +/// CSS for the plate. Safe on the light Qube page and the dark teacher page. +pub fn engine_contract_css() -> &'static str { + r#" +.engine-contract { border: 1px solid #1f2937; border-radius: 12px; padding: 16px 18px; margin: 16px 0 20px; background: #111827; color: #e5e7eb; } +.engine-contract-kicker { font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: #93c5fd; margin: 0 0 8px; } +.engine-contract h2 { font-size: 1.05rem; margin: 0 0 8px; color: #fff; } +.engine-contract-goal { font-size: 0.95rem; color: #fde68a; margin: 0 0 10px; } +.engine-contract-relay, .engine-contract-not { font-size: 0.88rem; color: #d1d5db; margin: 0 0 10px; } +.engine-contract-bars { width: 100%; border-collapse: collapse; font-size: 0.85rem; margin: 0 0 10px; } +.engine-contract-bars th, .engine-contract-bars td { border-bottom: 1px solid #374151; padding: 6px 8px; text-align: left; vertical-align: top; } +.engine-contract-bars th { width: 28%; color: #93c5fd; } +.engine-contract-forbidden { margin: 0; padding-left: 1.2rem; font-size: 0.82rem; color: #fca5a5; } +.engine-contract-forbidden code { color: #fecaca; } +"# +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_id_and_schema_are_stable() { + assert_eq!(ENGINE_CONTRACT.id, "the-engine/v1"); + assert_eq!(CORPUS_REPORT_SCHEMA, "codescribe-corpus-parity/v3"); + assert_eq!(ENGINE_CONTRACT.primary_key, "pcm_time"); + } + + #[test] + fn three_bars_in_order_and_not_synonyms() { + assert_eq!(ENGINE_CONTRACT.bars.len(), 3); + assert_eq!(ENGINE_CONTRACT.bars[0], FinalityBar::UtteranceFinal); + assert_eq!(ENGINE_CONTRACT.bars[1], FinalityBar::UtteranceSealed); + assert_eq!(ENGINE_CONTRACT.bars[2], FinalityBar::TranscriptSealed); + } + + #[test] + fn relay_is_apple_then_whisper_then_lexicon_then_formatter_then_human() { + assert_eq!( + ENGINE_CONTRACT.relay, + &[ + RelayLayer::Apple, + RelayLayer::Whisper, + RelayLayer::Lexicon, + RelayLayer::Formatter, + RelayLayer::Human + ] + ); + } + + #[test] + fn whole_text_mutable_until_seal_is_explicitly_forbidden() { + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"treat_whole_text_mutable_until_session_seal") + ); + assert!(ENGINE_CONTRACT.forbidden.contains(&"rewrite_from_zero")); + assert!(ENGINE_CONTRACT.forbidden.contains(&"reorder_spans")); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"hallucinate_into_silence") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"full_file_in_automatic_pipeline") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"auto_replace_after_transcript_sealed") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"treat_committed_as_document") + ); + } + + #[test] + fn hq_and_cloud_are_proposals_not_documents() { + assert_eq!( + surface_role("cloud"), + Some(ReportSurfaceRole::HumanTriggeredProposal) + ); + assert_eq!( + surface_role("hq"), + Some(ReportSurfaceRole::HumanTriggeredProposal) + ); + assert_eq!( + surface_role("ai_formatted"), + Some(ReportSurfaceRole::HumanTriggeredProposal) + ); + assert_eq!( + surface_role("delivered"), + Some(ReportSurfaceRole::SessionDocument) + ); + assert_ne!( + surface_role("raw"), + Some(ReportSurfaceRole::SessionDocument) + ); + } + + #[test] + fn html_plate_names_every_bar_and_the_pcm_key() { + let html = render_engine_contract_html(); + assert!(html.contains("data-contract=\"the-engine/v1\"")); + assert!(html.contains("data-primary-key=\"pcm_time\"")); + assert!(html.contains("utterance_final / committed")); + assert!(html.contains("utterance_sealed")); + assert!(html.contains("transcript_sealed")); + assert!(html.contains("treat_whole_text_mutable_until_session_seal")); + assert!( + !html.contains("the whole text is mutable"), + "the rejected one-liner must not re-enter the plate" + ); + } + + #[test] + fn canonical_doc_exists_and_matches_the_lock() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let path = root.join(ENGINE_CONTRACT_DOC); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{} must exist: {err}", path.display())); + for needle in [ + "the-engine/v1", + "pcm_time", + "utterance_final", + "utterance_sealed", + "transcript_sealed", + "treat_whole_text_mutable_until_session_seal", + "rewrite_from_zero", + "button_only_proposal", + "Apple → Whisper → lexicon → formatter → human", + "seal-atlas", + "SealedSpan.words", + "clock-lie", + "interpolation", + "Voice Lab", + "seal_atlas", + ] { + assert!( + body.contains(needle), + "{ENGINE_CONTRACT_DOC} missing locked token {needle:?}" + ); + } + assert!( + !body.contains("cały tekst jest mutable"), + "the rejected sentence must not live in the canonical doc" + ); + } + + #[test] + fn full_file_pass_is_never_automatic() { + assert_eq!(ENGINE_CONTRACT.full_file_pass, "button_only_proposal"); + assert!(ENGINE_CONTRACT.whisper_window.contains("3-5s")); + } + + #[test] + fn take01_span2_is_the_canonical_clock_lie() { + assert!(is_clock_lie(41, 0.10)); + assert!(!is_clock_lie(6, 0.24)); // "ten" @ 240 ms + assert_eq!(span_grain(6), SpanGrain::Word); + assert_eq!(span_grain(1), SpanGrain::Utterance); + assert_eq!(LETTER_TIMING, "interpolation_not_measurement"); + assert_eq!(QUALITY_REPORT_SURFACE, "seal-atlas"); + } + + #[test] + fn gold_atlas_passes_html_handshake() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let path = root.join(SEAL_ATLAS_GOLD_HTML); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{} must exist: {err}", path.display())); + let failures = validate_quality_html(&body); + assert!( + failures.is_empty(), + "gold take 01 failed handshake: {failures:?}" + ); + } + + #[test] + fn html_contract_doc_exists() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let body = std::fs::read_to_string(root.join(QUALITY_HTML_CONTRACT_DOC)) + .unwrap_or_else(|err| panic!("{QUALITY_HTML_CONTRACT_DOC} must exist: {err}")); + for needle in [ + "quality-report-surface", + "div class=\"stat\"", + "word-grain", + "clock-lie", + "quality/seal-atlas.", + ] { + assert!(body.contains(needle), "CONTRACT.md missing {needle:?}"); + } + } + + #[test] + fn retired_qube_title_fails_handshake() { + let fake = r#"Codescribe Quality Report +

    Codescribe Quality Report

    Avg WER 12%

    "#; + let failures = validate_quality_html(fake); + assert!(failures.len() >= 3, "{failures:?}"); + } + + #[test] + fn handshake_rejects_prose_that_is_not_the_meta_content() { + let fake = r#" + + +mentions the-engine/v1 and seal-atlas in prose + + +

    the-engine/v1 seal-atlas

    +
    1word-grain
    +

    utterance-grain clock-lie SealedSpan.words whisper

    +"#; + let failures = validate_quality_html(fake); + assert!( + failures.iter().any(|f| f.contains("engine-contract")), + "{failures:?}" + ); + assert!( + failures + .iter() + .any(|f| f.contains("quality-report-surface")), + "{failures:?}" + ); + } + + #[test] + fn gold_atlas_html_is_a_pcm_instrument_not_a_wer_table() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let path = root.join(SEAL_ATLAS_GOLD_HTML); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{} must exist: {err}", path.display())); + for needle in [ + "Seal Atlas", + "SealedSpan.words", + "kłamstwo zegarowe", + "word-grain", + "utterance-grain", + "równomierna interpolacja", + "CODESCRIBE_SEAL_ATLAS_DUMP", + "vad_atlas_probe", + "whisper_words", + ] { + assert!( + body.contains(needle), + "{SEAL_ATLAS_GOLD_HTML} missing {needle:?}" + ); + } + assert!( + !body.contains("Avg WER"), + "gold atlas must not be a scores table" + ); + assert!(body.contains(r#"class="stat""#)); + assert_eq!( + voice_lab_kind( + "Seal Atlas — take 01", + "quality-reports/seal-atlas.take01.html" + ), + VoiceLabReportKind::SealAtlas + ); + assert_eq!( + voice_lab_kind("Codescribe Quality Report", "quality/apple-layer0.html"), + VoiceLabReportKind::QualityReport + ); + assert_eq!( + voice_lab_kind("THE ENGINE quality-report contract", "docs/contract.html"), + VoiceLabReportKind::QualityContract + ); + } +} diff --git a/core/quality/mod.rs b/core/quality/mod.rs index e5575bbd..3cf6c7ca 100644 --- a/core/quality/mod.rs +++ b/core/quality/mod.rs @@ -1,7 +1,9 @@ //! Quality surfaces — where transcription truth is measured, corrected, and learned from. //! -//! Four independent loops share this facade: +//! Five independent loops share this facade: //! +//! - `engine_contract` — locked THE ENGINE bars / relay / forbidden ops that +//! every quality HTML and `codescribe-corpus` v3 report must carry. //! - `overlay_quality` — captures human edits of the overlay FINAL transcript and //! distils them into custom lexicon rules (the live, per-user loop). //! - `qube_report` — batch WAV evaluation: transcribe, format, score, emit artifacts. @@ -10,18 +12,27 @@ //! - `teacher` — the offline learning triangle (Apple live × Whisper × human reference) //! that produces merged deliveries and attention spans. //! -//! Only `teacher` is re-exported here; the other three are reached through their -//! own module paths. +//! Only `teacher` and `engine_contract` are re-exported here; the others are +//! reached through their own module paths. +/// Locked THE ENGINE contract for quality-report HTML and corpus JSON. +pub mod engine_contract; pub mod overlay_quality; /// Background Qube donor daemon: opt-in stop-path WAV/transcript persistence. pub mod qube_daemon; /// Qube report types and serialization for quality/donor telemetry surfaces. pub mod qube_report; +/// Seal Atlas HTML renderer — the quality-report surface corpus writes. +pub mod seal_atlas_html; /// Teacher loop: attention flags, lexicon feedback, polygon token helpers. pub mod teacher; +pub use engine_contract::{ + CORPUS_REPORT_SCHEMA, ENGINE_CONTRACT, ENGINE_CONTRACT_ID, EngineContract, + render_engine_contract_html, validate_quality_html, +}; +pub use seal_atlas_html::{SealAtlasPage, SealAtlasStats, render_seal_atlas_html}; pub use teacher::{ - MergeMode, MergedDelivery, TeacherInput, TeacherReport, merge_live_whisper, - merge_live_whisper_with_terms, report_to_html, teach, + Layer1MergeMode, Layer1MergedDelivery, MergeMode, MergedDelivery, TeacherInput, TeacherReport, + merge_live_layer1, merge_live_whisper, merge_live_whisper_with_terms, report_to_html, teach, }; diff --git a/core/quality/overlay_quality.rs b/core/quality/overlay_quality.rs index ddf7fb9c..81f4152d 100644 --- a/core/quality/overlay_quality.rs +++ b/core/quality/overlay_quality.rs @@ -947,14 +947,15 @@ where Ok(()) } -/// Result of a successful overlay-quality commit (evidence always; learn when Correction). +/// Result of a successful overlay-quality commit (evidence always; learn only +/// on an explicit teach gesture, never on overlay copy/close). #[derive(Debug, Clone, PartialEq, Eq)] pub struct OverlayCorrectionCommit { /// The `corrections.jsonl` the evidence line was appended to. pub quality_path: PathBuf, /// Lexicon pairs actually upserted from this commit (0 when evidence-only or filtered). pub pairs_learned: u32, - /// True when the formatting level is not Correction — record saved, nothing taught. + /// True when this commit did not teach the custom lexicon. pub evidence_only: bool, } @@ -971,6 +972,13 @@ impl OverlayCorrectionCommit { } } +/// Overlay copy/close/send never writes `lexicon.custom.jsonl`. +/// Highlighted-span teach (`action=teach-span`) still does. Speech-gap +/// teach (`teach-span-gap`) stays evidence-only. +fn overlay_commit_teaches_lexicon(_mode: &str, action: Option<&str>) -> bool { + matches!(action, Some("teach-span") | Some("teach-dictionary")) +} + /// High-level: save the quality record for the overlay edit AND feed lexicon candidates. /// Called from bridge (and tests). Returns path + honest pairs-learned count. /// `action` (e.g. "copy", "send", "close") is carried into meta for future analytics (P2-03 triage over-correct). @@ -1038,7 +1046,11 @@ pub fn commit_overlay_correction_with_confidence( .map(FormattingPolicy::parse) .transpose()? .map(|level| level.as_str().to_string()); - let teaches = formatting_level.as_deref() == Some(FormattingPolicy::Correction.as_str()); + // Overlay copy/close/send is evidence. Teaching from that diff is how + // 2026-08-17 learned "pisanie Żyda" → "mi się nie wydaje" and "w 3 4" → + // "Dwa Trzy Cztery Pięć". Lexicon grows only on an explicit teach gesture + // (highlighted span / Voice Lab), never from a formatting-level flag. + let teaches = overlay_commit_teaches_lexicon(mode, action); let record = QualityRecord::new_with_confidence( raw_text.to_string(), delivered_text.to_string(), @@ -1093,6 +1105,44 @@ pub fn commit_overlay_correction_with_confidence( }) } +/// One-click Teach from a highlighted canvas span. +/// +/// Lexicon-corrected spans upsert the known variant→canonical pair through +/// the existing Correction path. Speech-gap pustki are evidence-only: there +/// is no word to teach until a human supplies one in Voice Lab. +pub fn teach_span(variant: &str, canonical: &str, kind: &str) -> Result { + match kind { + "speech_gap" => commit_overlay_correction_with_confidence( + variant, + variant, + if canonical.trim().is_empty() { + "∅" + } else { + canonical + }, + "overlay-span", + None, + Some("teach-span-gap"), + Some(FormattingPolicy::Off.as_str()), + None, + None, + vec!["speech_gap".to_string()], + ), + _ => commit_overlay_correction_with_confidence( + variant, + variant, + canonical, + "overlay-span", + None, + Some("teach-span"), + Some(FormattingPolicy::Correction.as_str()), + None, + None, + vec!["lexicon_corrected".to_string()], + ), + } +} + /// Replay historical `corrections.jsonl` through the current extractor. /// Returns dry-run candidate rows; with `apply=true` upserts after backing up /// the custom lexicon to `.bak-replay-`. @@ -1619,6 +1669,24 @@ mod tests { ); } + #[test] + #[serial] + fn teach_span_lexicon_learns_pair_and_gap_is_evidence_only() { + let temp_dir = tempfile::tempdir().expect("temp"); + let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let temp_root = temp_dir.path().canonicalize().unwrap(); + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + } + let learned = super::teach_span("uni agentka", "Junie", "lexicon_corrected") + .expect("teach lexicon span"); + assert_eq!(learned.pairs_learned, 1); + assert!(!learned.evidence_only); + let gap = super::teach_span("", "", "speech_gap").expect("teach gap span"); + assert_eq!(gap.pairs_learned, 0); + assert!(gap.evidence_only); + } + /// E2E: long-dictation commit learns one pair and stamps correction provenance. #[test] #[serial] @@ -1637,7 +1705,7 @@ mod tests { let delivered = format!("{body}zaznaczenie koniec"); let edited = format!("{body}selection koniec"); - let commit = commit_overlay_correction( + let evidence = commit_overlay_correction( &delivered, &delivered, &edited, @@ -1645,7 +1713,10 @@ mod tests { Some("whisper".into()), Some("copy"), ) - .expect("commit long dictation fix"); + .expect("commit long dictation evidence"); + assert_eq!(evidence.pairs_learned, 0); + let commit = teach_span("zaznaczenie", "selection", "lexicon_corrected") + .expect("explicit teach of the one-word fix"); assert_eq!(commit.pairs_learned, 1); assert_eq!(commit.acknowledgement_message(), "Saved — 1 pair learned"); @@ -1880,12 +1951,12 @@ mod tests { .expect("commit should succeed"); let p = commit.quality_path.clone(); assert!(p.ends_with("corrections.jsonl")); - assert!( - commit.pairs_learned >= 1, - "correction should teach at least one pair" + assert_eq!( + commit.pairs_learned, 0, + "overlay copy is evidence, not a lexicon teacher" ); - assert!(!commit.evidence_only); - assert!(commit.acknowledgement_message().contains("learned")); + assert!(commit.evidence_only); + assert_eq!(commit.acknowledgement_message(), "Saved as evidence"); // Proof of isolation: the quality file landed under the overridden DATA_DIR // (config_dir + quality_dir respect it; real ~/.codescribe untouched). assert!( @@ -2084,16 +2155,6 @@ mod tests { Some("send"), ) .expect("second correction"); - let lexicon_path = Config::config_dir().join("lexicon.custom.jsonl"); - let mut lexicon_file = OpenOptions::new() - .append(true) - .open(&lexicon_path) - .expect("open custom lexicon for legacy extras fixture"); - writeln!( - lexicon_file, - r#"{{"term":"Vetcoders","extras":{{"mispronunciations":["wet coders"]}}}}"# - ) - .expect("append legacy extras fixture"); let records = recent_quality_records(1).expect("recent records"); assert_eq!(records.len(), 1); @@ -2107,6 +2168,21 @@ mod tests { Some("send") ); + teach_span("raw one", "Junie", "lexicon_corrected").expect("explicit teach first pair"); + teach_span("raw two", "Loctree map", "lexicon_corrected") + .expect("explicit teach second pair"); + let lexicon_path = Config::config_dir().join("lexicon.custom.jsonl"); + let mut lexicon_file = OpenOptions::new() + .create(true) + .append(true) + .open(&lexicon_path) + .expect("open custom lexicon for legacy extras fixture"); + writeln!( + lexicon_file, + r#"{{"term":"Vetcoders","extras":{{"mispronunciations":["wet coders"]}}}}"# + ) + .expect("append legacy extras fixture"); + let lexicon = custom_lexicon_entries().expect("custom lexicon entries"); assert_eq!( lexicon, @@ -2177,10 +2253,10 @@ mod tests { assert_eq!(decoded.formatting_level, None); } - /// All levels append evidence; only Correction upserts lexicon candidates. + /// All overlay copy/close levels append evidence; none auto-teach lexicon. #[test] #[serial] - fn level_aware_commit_records_every_level_but_only_correction_teaches_lexicon() { + fn overlay_copy_records_every_level_and_never_teaches_lexicon() { let temp_dir = tempfile::tempdir().expect("temp quality root"); let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); let temp_root = temp_dir.path().canonicalize().unwrap(); @@ -2207,30 +2283,47 @@ mod tests { let records = recent_quality_records(10).expect("quality evidence rows"); let candidates = custom_lexicon_entries().expect("custom lexicon candidates"); assert_eq!(records.len(), 4, "every level appends quality evidence"); - assert_eq!(candidates.len(), 1, "only Correction emits a candidate"); - assert_eq!(candidates[0].variant, "korrvariant"); - assert_eq!(candidates[0].canonical, "CorrCanonical"); + assert!( + candidates.is_empty(), + "overlay copy must not write lexicon.custom.jsonl, got {candidates:?}" + ); } - /// Learning keys on raw STT text, not the formatter's delivered surface. + /// Live 2026-08-17: a human C-card correction must not invent "Meksyku" rules. #[test] #[serial] - fn correction_learning_uses_raw_stt_not_formatted_delivery() { + fn overlay_correction_of_garbled_take_is_evidence_only() { let temp_dir = tempfile::tempdir().expect("temp quality root"); let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); let temp_root = temp_dir.path().canonicalize().unwrap(); unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root) }; - let outcome = commit_overlay_correction_with_level( - "rawvariant", - "formattervariant", - "RawCanonical", + let outcome = commit_overlay_correction( + "A to jest pierwsze w oknie nie wybu słów tylko poprawiamy lokal power Meksyku.", + "A to jest pierwsze w oknie nie wybu słów tylko poprawiamy lokal power Meksyku.", + "Apple jest pierwszy, Whisper poprawia w oknie, nie wyjebujemy słów, tylko poprawiamy. Local power, leksykon.", "overlay", None, Some("copy"), - Some("correction"), ) - .expect("raw-source quality commit"); + .expect("quality evidence"); + assert_eq!(outcome.pairs_learned, 0); + assert!(outcome.evidence_only); + assert_eq!(outcome.acknowledgement_message(), "Saved as evidence"); + assert!(custom_lexicon_entries().expect("lexicon").is_empty()); + } + + /// Learning keys on raw STT text, not the formatter's delivered surface. + #[test] + #[serial] + fn correction_learning_uses_raw_stt_not_formatted_delivery() { + let temp_dir = tempfile::tempdir().expect("temp quality root"); + let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let temp_root = temp_dir.path().canonicalize().unwrap(); + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root) }; + + let outcome = teach_span("rawvariant", "RawCanonical", "lexicon_corrected") + .expect("explicit teach from raw STT"); assert_eq!(outcome.pairs_learned, 1); let entries = custom_lexicon_entries().expect("custom lexicon"); @@ -2307,6 +2400,7 @@ mod tests { let lexicon_path = Config::config_dir().join("lexicon.custom.jsonl"); let mut duplicate = OpenOptions::new() + .create(true) .append(true) .open(&lexicon_path) .expect("open duplicate fixture"); diff --git a/core/quality/qube_report.rs b/core/quality/qube_report.rs index f02d70b0..104b7610 100644 --- a/core/quality/qube_report.rs +++ b/core/quality/qube_report.rs @@ -466,14 +466,18 @@ fn prepare_cloud_jobs( CloudJobSet::Running(jobs) } -/// The cloud STT endpoint and key, or `None` when either is absent or blank. -/// Whitespace-only values count as absent — a half-filled setting is not -/// credentials, and treating it as such would fail later with a worse message. +/// The cloud STT endpoint and optional key, or `None` when required input is +/// absent. Loopback servers intentionally accept an empty key; remote owners +/// still require one. fn cloud_reference_credentials(app_config: &Config) -> Option<(String, String)> { let endpoint = app_config.stt_endpoint.as_deref()?.trim(); - let api_key = app_config.stt_api_key.as_deref()?.trim(); + let api_key = app_config.stt_api_key.as_deref().unwrap_or_default().trim(); - if endpoint.is_empty() || api_key.is_empty() { + if endpoint.is_empty() + || (crate::stt::tail_provider::stt_auth_mode(endpoint) + != crate::stt::tail_provider::SttAuthMode::Unauthenticated + && api_key.is_empty()) + { return None; } @@ -888,12 +892,19 @@ fn render_markdown(report: &QualityReport) -> String { /// stays portable. Reference transcripts are emitted but hidden unless /// `debug_mode` is set — the operator is meant to judge the audio first, not read /// the answer. Every interpolated value goes through [`html_escape`]. -fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { +/// Render the reusable, self-contained Qube review surface. +/// +/// Production replay tools call this renderer with an in-memory report so the +/// operator gets the same audio controls, keyboard navigation and annotation +/// workflow without routing the recording through Qube's legacy LocalWhisper +/// runner. +pub fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { let debug = config.debug_mode; let mut body = String::new(); body.push_str(&format!( - "

    Codescribe Quality Report

    Generated: {}

    Metrics reference: {}

    Raw semantics: text_committed={} • quality_gate_dropped={} • no_speech_detected={}

    ", + "

    Codescribe Quality Report

    {}

    Generated: {}

    Metrics reference: {}

    Raw semantics: text_committed={} • quality_gate_dropped={} • no_speech_detected={}

    ", + crate::quality::engine_contract::render_engine_contract_html(), html_escape(&report.generated_at), html_escape(&report.environment.metrics_reference), report.summary.raw_text_committed, @@ -1022,11 +1033,16 @@ fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { ); render_ref_section( &mut body, - "AI formatted (candidate)", + "AI formatted (proposal — not the document)", t.ai_formatted.as_deref(), debug, ); - render_ref_section(&mut body, "Cloud reference", t.cloud.as_deref(), debug); + render_ref_section( + &mut body, + "Cloud (proposal — not the document)", + t.cloud.as_deref(), + debug, + ); render_ref_section( &mut body, "Corpus reference (.txt)", @@ -1047,6 +1063,7 @@ fn render_html(report: &QualityReport, config: &QualityReportConfig) -> String { } let debug_flag = if debug { "true" } else { "false" }; + let contract_css = crate::quality::engine_contract::engine_contract_css(); format!( r#" @@ -1083,6 +1100,7 @@ audio {{ width: 100%; margin: 8px 0; }} .ref pre {{ background: #f6f6f6; padding: 10px; border-radius: 6px; white-space: pre-wrap; }} .ref.hidden {{ display: none; }} .errors {{ margin-top: 10px; color: #a00; }} +{contract_css} @@ -2011,6 +2029,15 @@ mod tests { config.stt_api_key = Some(" ".into()); assert_eq!(cloud_reference_credentials(&config), None); + + config.stt_endpoint = Some("http://127.0.0.1:8000/v1/audio/transcriptions".into()); + assert_eq!( + cloud_reference_credentials(&config), + Some(( + "http://127.0.0.1:8000/v1/audio/transcriptions".into(), + String::new(), + )) + ); } /// Spot-checks WER on a one-token substitution (ala ma kota → ala ma psa). diff --git a/core/quality/seal_atlas_html.rs b/core/quality/seal_atlas_html.rs new file mode 100644 index 00000000..33867244 --- /dev/null +++ b/core/quality/seal_atlas_html.rs @@ -0,0 +1,197 @@ +//! Seal Atlas HTML — the quality-report surface `codescribe-corpus` writes. +//! +//! Gold visual is take 01 (`docs/quality-reports/seal-atlas.take01.html`). +//! This renderer emits a handshake-valid atlas for a corpus profile when a +//! live dump is not attached. Waveform SVG is omitted until +//! `CODESCRIBE_SEAL_ATLAS_DUMP` is present; the page is still a Seal Atlas, +//! not a Qube WER table. + +use super::engine_contract::{ + ENGINE_CONTRACT_ID, QUALITY_REPORT_SURFACE, engine_contract_css, render_engine_contract_html, +}; + +/// Numbers Voice Lab lifts out of `.stat` cards. +#[derive(Debug, Clone)] +pub struct SealAtlasStats { + pub word_grain: String, + pub sealed_spans: String, + pub per_word_spans: String, + pub clock_lies: String, + pub silero_threshold: String, +} + +impl Default for SealAtlasStats { + fn default() -> Self { + Self { + word_grain: "n/a".into(), + sealed_spans: "n/a".into(), + per_word_spans: "n/a".into(), + clock_lies: "n/a".into(), + silero_threshold: "0.5".into(), + } + } +} + +/// One Seal Atlas HTML document. +#[derive(Debug, Clone)] +pub struct SealAtlasPage { + pub title: String, + pub lede: String, + pub stats: SealAtlasStats, + pub findings: Vec, + pub dump_present: bool, +} + +impl Default for SealAtlasPage { + fn default() -> Self { + Self { + title: "Seal Atlas".into(), + lede: + "One take, one PCM clock. Words from SealedSpan.words — not from the final string." + .into(), + stats: SealAtlasStats::default(), + findings: Vec::new(), + dump_present: false, + } + } +} + +/// Handshake-valid Seal Atlas HTML. Title contains `Seal Atlas`. +pub fn render_seal_atlas_html(page: &SealAtlasPage) -> String { + let title = if page.title.to_ascii_lowercase().contains("seal atlas") { + page.title.clone() + } else { + format!("Seal Atlas — {}", page.title) + }; + let stats = [ + (&page.stats.word_grain, "word-grain ≥75% speech"), + (&page.stats.sealed_spans, "sealed spans"), + (&page.stats.per_word_spans, "spans with per-word pins"), + (&page.stats.clock_lies, "clock-lie"), + (&page.stats.silero_threshold, "Silero threshold"), + ] + .into_iter() + .map(|(value, label)| { + format!( + "
    {}{}
    ", + html_escape(value), + label + ) + }) + .collect::(); + + let findings = if page.findings.is_empty() { + "
  • No dump attached. This page is the contract surface; waveform waits on CODESCRIBE_SEAL_ATLAS_DUMP.
  • ".into() + } else { + page.findings + .iter() + .map(|line| format!("
  • {}
  • ", html_escape(line))) + .collect::() + }; + + let dump_note = if page.dump_present { + "

    Waveform from the live dump. Production Silero via vad_atlas_probe. Letter ticks = równomierna interpolacja, not measurement.

    " + } else { + "

    Waveform omitted — no CODESCRIBE_SEAL_ATLAS_DUMP. whisper_words still map backward onto pcm_time when a dump arrives. Per-word pins are real where they exist and not guaranteed. Utterance-grain vs word-grain stay labeled. Clock-lie is a finding.

    " + }; + + format!( + r#" + + + + + + +{title} + + + +
    +
    +

    {title}

    +

    {lede}

    +
    {stats}
    +
    +{plate} +
    +

    Lanes

    +

    Silero p(mowa) · word-grain · utterance-grain · clock-lie · whisper_words

    +{dump_note} +

    Words from SealedSpan.words / the live dump — never rebuilt from the final string. HQ / Cloud stay proposals.

    +
    +
    +

    Findings

    +
      {findings}
    +
    +
    + + +"#, + contract = ENGINE_CONTRACT_ID, + surface = QUALITY_REPORT_SURFACE, + title = html_escape(&title), + lede = html_escape(&page.lede), + stats = stats, + plate = render_engine_contract_html(), + dump_note = dump_note, + findings = findings, + css = engine_contract_css(), + ) +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::quality::engine_contract::validate_quality_html; + + #[test] + fn renderer_passes_the_html_handshake() { + let html = render_seal_atlas_html(&SealAtlasPage { + title: "profile apple-layer0".into(), + ..SealAtlasPage::default() + }); + let failures = validate_quality_html(&html); + assert!(failures.is_empty(), "{failures:?}"); + assert!(html.contains("Seal Atlas — profile apple-layer0")); + assert!(!html.contains("Avg WER")); + assert!(!html.contains("Codescribe Quality Report")); + } + + #[test] + fn renderer_escapes_page_content() { + let html = render_seal_atlas_html(&SealAtlasPage { + title: "Seal Atlas ".into(), + lede: "\"measured\" + + diff --git a/site/src/styles/global.css b/site/src/styles/global.css index bc1253ed..d0ad36a7 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -1,44 +1,8 @@ /* ============================================================ codescribe — global stylesheet - Design tokens (WEBSITE_SPEC §2) centralized in one :root. + Identity tokens live in tokens.css (site + Voice Lab contract). ============================================================ */ -:root { - /* surfaces (dark) */ - --bg: #090a0d; /* page */ - --bg-soft: #0c0d11; /* code/prompt panels */ - --bg-card: rgba(255, 255, 255, 0.02); /* section cards */ - --bg-chip: rgba(255, 255, 255, 0.025); /* chips */ - --hairline: #14161b; /* section top-borders */ - --stroke: #1d2027; /* card borders */ - --stroke-2: #20242a; /* image frames */ - --stroke-3: #24272f; /* buttons/secondary */ - - /* brand */ - --terracotta: #d97757; /* PRIMARY accent */ - --terracotta-2: #c98a6e; /* selection-mode accent */ - --terracotta-ink: #e9b79f; /* terracotta text on dark */ - --olive: #5f6b3e; /* secondary: healthy status, formatting */ - --olive-ink: #9db178; /* olive text/status dots on dark */ - --gold: #d6b24e; /* agent-chat mode accent */ - - /* text */ - --ink: #f4f2ec; /* headings */ - --ink-2: #e9e7e0; /* strong body */ - --ink-3: #f0eee7; /* emphasis on cards */ - --muted: #9a9d97; /* body */ - --muted-2: #82857f; /* sub-body */ - --muted-3: #808379; /* mono meta — AA (>=4.5) on dark bg */ - --muted-4: #7c8076; /* faint mono — AA (>=4.5) on cards */ - --eyebrow: #7f8c5e; /* olive eyebrow labels */ - - /* additional shades used in the reference render */ - --line-soft: #16181e; /* inner hairlines */ - --scroll-hint: #82857f; /* faint hint / legal — AA on dark bg */ - - /* fonts */ - --font-display: "Space Grotesk", system-ui, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; -} +@import "./tokens.css"; * { box-sizing: border-box; diff --git a/site/src/styles/lab-shell.css b/site/src/styles/lab-shell.css new file mode 100644 index 00000000..cd484011 --- /dev/null +++ b/site/src/styles/lab-shell.css @@ -0,0 +1,216 @@ +/* One-page Voice Lab chrome: navbar tabs + sidebar + workspace + toolbox. */ + +.lab-app-page { + height: 100svh; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + background: var(--bg); +} + +.lab-app-page .nav nav { + padding: 10px 22px; +} + +.lab-shell { + display: grid; + grid-template-rows: var(--lab-nav-h) minmax(0, 1fr); + min-height: 0; + height: 100%; + background: var(--bg); + color: var(--ink-2); + font-family: var(--font-display); +} + +.lab-nav { + display: flex; + align-items: center; + gap: 18px; + min-height: var(--lab-nav-h); + padding: 0 16px 0 18px; + border-bottom: 1px solid var(--hairline); + background: rgba(9, 10, 13, 0.86); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.lab-brand { + display: inline-flex; + align-items: baseline; + gap: 8px; + text-decoration: none; + color: var(--ink); + flex: 0 0 auto; +} + +.lab-wordmark { + font: 700 16px/1 var(--font-display); + letter-spacing: -0.03em; +} + +.lab-product { + font: 500 11px/1 var(--font-mono); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--terracotta-ink); +} + +.lab-tabs { + display: flex; + align-items: stretch; + gap: 2px; + min-width: 0; + height: 100%; +} + +.lab-tab { + height: 100%; + padding: 0 12px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + font: 500 12px/1 var(--font-mono); + letter-spacing: 0.04em; + text-transform: uppercase; + box-shadow: none; + cursor: pointer; +} + +.lab-tab:hover { + color: var(--ink-2); +} + +.lab-tab.is-active, +.lab-tab[aria-current="page"] { + color: var(--ink); + box-shadow: inset 0 -2px 0 var(--terracotta); +} + +.lab-runtime { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted-3); + font: 500 11px/1 var(--font-mono); + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.runtime-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--olive-ink); + box-shadow: 0 0 0 3px rgba(157, 177, 120, 0.12); +} + +.runtime-dot.live { + background: var(--terracotta); + animation: breathe 1.6s ease-in-out infinite; + box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.18); +} + +.runtime-separator { + color: var(--stroke-3); +} + +.lab-frame { + display: grid; + grid-template-columns: var(--lab-sidebar-w) minmax(0, 1fr) var(--lab-toolbox-w); + min-height: 0; +} + +.lab-sidebar, +.lab-toolbox, +.lab-workspace { + min-height: 0; + overflow: auto; +} + +.lab-sidebar { + padding: 14px 12px 22px; + border-right: 1px solid var(--hairline); +} + +.lab-workspace { + padding: 14px 16px 24px; +} + +.lab-toolbox { + padding: 14px 12px 22px; + border-left: 1px solid var(--hairline); + background: var(--bg-soft); +} + +.lab-kicker { + margin: 0 0 8px; + font: 500 11px/1.4 var(--font-mono); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--eyebrow); +} + +.lab-block + .lab-block { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--hairline); +} + +.lab-block h2, +.lab-block h3 { + margin: 0 0 8px; + font: 600 15px/1.2 var(--font-display); + letter-spacing: -0.02em; + color: var(--ink); + text-transform: none; +} + +.lab-muted { + margin: 0; + color: var(--muted-2); + font: 400 12px/1.45 var(--font-display); +} + +.lab-tool-stack { + display: flex; + flex-direction: column; + gap: 8px; +} + +.lab-tool-stack .primary, +.lab-tool-stack .ghost, +.lab-tool-stack button { + width: 100%; + justify-content: center; +} + +@media (max-width: 960px) { + .lab-frame { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr) auto; + } + + .lab-sidebar, + .lab-toolbox { + max-height: 26vh; + border: 0; + } + + .lab-sidebar { + border-bottom: 1px solid var(--hairline); + } + + .lab-toolbox { + border-top: 1px solid var(--hairline); + } +} + +@media (max-width: 640px) { + .lab-product, + .lab-runtime span:not(.runtime-dot) { + display: none; + } +} diff --git a/site/src/styles/tokens.css b/site/src/styles/tokens.css new file mode 100644 index 00000000..69c28271 --- /dev/null +++ b/site/src/styles/tokens.css @@ -0,0 +1,40 @@ +/* codescribe identity tokens (WEBSITE_SPEC §2). + Shared names: this file, /voice/lab, and voice-lab/assets/lab/styles.css. */ +:root { + --bg: #090a0d; + --bg-soft: #0c0d11; + --bg-card: rgba(255, 255, 255, 0.02); + --bg-chip: rgba(255, 255, 255, 0.025); + --hairline: #14161b; + --stroke: #1d2027; + --stroke-2: #20242a; + --stroke-3: #24272f; + + --terracotta: #d97757; + --terracotta-2: #c98a6e; + --terracotta-ink: #e9b79f; + --olive: #5f6b3e; + --olive-ink: #9db178; + --gold: #d6b24e; + + --ink: #f4f2ec; + --ink-2: #e9e7e0; + --ink-3: #f0eee7; + --muted: #9a9d97; + --muted-2: #82857f; + --muted-3: #808379; + --muted-4: #7c8076; + --eyebrow: #7f8c5e; + + --line-soft: #16181e; + --scroll-hint: #82857f; + + --font-display: "Space Grotesk", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + --lab-nav-h: 48px; + --lab-sidebar-w: 228px; + --lab-toolbox-w: 268px; + --radius-input: 9px; + --radius-card: 12px; +} diff --git a/skills/codescribe/FLOW.md b/skills/codescribe/FLOW.md new file mode 100644 index 00000000..5d47e559 --- /dev/null +++ b/skills/codescribe/FLOW.md @@ -0,0 +1,48 @@ +# `codescribe` attach flow + +> Foundation skill. No `vibecrafted codescribe ` worker. + +## Flow + +```mermaid +flowchart TD + A[Agent session starts] --> B{Codescribe.app + bus file?} + B -->|no| C[Ask human: odpal apkę i licencję] + C --> D{Retry ok?} + D -->|no| E[Fail loud] + D -->|yes| F["python3 scripts/bus-demux.py --become --follow"] + B -->|yes| F + F --> G[Ask human for a name in this chat] + G --> H[Greet once] + H --> I["--name stem --follow"] + I --> J[Fn down: drafts live] + J --> K{Addresses my name?} + K -->|yes| L[May reply in ~5s gap] + K -->|no| J + J --> M[Fn up: transcript_sealed] + M --> N[Only now: side effects] +``` + +## Routes + +| Entry | Args | Produces | Exit | +| ------------- | -------- | -------------------------------- | ------------- | +| `/codescribe` | none | agent attached, named, listening | in-session | +| Worker CLI | **none** | — | do not invent | + +### Escalation edges + +- Repo surgery after attach → `vc-justdo` / `vc-implement` (not this skill) +- Session orientation of the checkout → `vc-init` +- In-app Agent window → Codescribe Assistive / `⌘⇧Space`, not this skill + +### Session artifacts + +- Bus: `~/.codescribe/transcript-events.jsonl` (`CODESCRIBE_TRANSCRIPT_BUS_PATH` wins) +- Follower stdout: one JSON object per matching event (kielbasa) + +### Anti-patterns + +- Fake `vibecrafted codescribe ` +- Second microphone / Voice Lab +- Acting on a half utterance diff --git a/skills/codescribe/README.md b/skills/codescribe/README.md new file mode 100644 index 00000000..bce353a4 --- /dev/null +++ b/skills/codescribe/README.md @@ -0,0 +1,39 @@ +# codescribe + +Foundation skill: how a chat agent plugs into Codescribe.app's transcript bus. +No Vibecrafted worker. The human holds Fn. The agent listens on jsonl. + +## Quick reference + +| Field | Value | +| ---------------- | ---------------------------------------------------------- | +| Name | `codescribe` | +| Version | `0.1.0` | +| Operator command | **none** — not `vibecrafted codescribe ` | +| Interactive | `/codescribe` | +| Canonical doc | [`SKILL.md`](SKILL.md) | +| Follower | Codescribe checkout `scripts/bus-demux.py` | +| Codex voice loop | `scripts/codex-voice-bridge.py --name --cwd ` | + +## Homes + +| Tree | Path | +| ------------------- | ------------------------------------- | +| Codescribe checkout | `skills/codescribe/` | +| Fleet | `vibecrafted_core/skills/codescribe/` | + +Keep both copies in lockstep. Parser stays in the Codescribe repo. + +## Authoring checklist + +- [x] Foundation: no fake worker CLI +- [x] Example in `examples/` +- [x] Attach / live-vs-seal in `references/` +- [ ] `make test-skills` from vibecrafted-core when that copy is committed + +## Optional Codex voice loop + +The foundation attach remains the smallest path. When the operator also wants +the named mailbox to own a dedicated coding task and speak final replies, use +[`docs/CODEX_VOICE_BRIDGE.md`](../../docs/CODEX_VOICE_BRIDGE.md). The bridge +reuses this skill's demux; it does not open another microphone or parser. diff --git a/skills/codescribe/SKILL.md b/skills/codescribe/SKILL.md new file mode 100644 index 00000000..97d3cb18 --- /dev/null +++ b/skills/codescribe/SKILL.md @@ -0,0 +1,177 @@ +--- +name: codescribe +version: 0.1.0 +description: >- + This skill should be used when the user asks to "codescribe", "wpięcie w bus", + "Hej James", "Bus Demux", "named agent on the transcript bus", or runs + /codescribe. It teaches an agent to attach to Codescribe.app's clean + transcript bus, ask the human for a name, hear live utterances, and act only + on the seal. Outcome: one mic, one jsonl, named mailbox, no second recorder. +loctree_value: "primary repo map for structural/literal repository work" +aicx_value: "intent, session, and decision-context retrieval" +dogfooding: "required for repo-impacting work" +--- + + + +> **Invocation for `codescribe` (foundation, launcher `codescribe`)** +> +> Not a core `vibecrafted codescribe ` worker. Load interactively. +> See [Foundation skills](../DELEGATION_MATRIX.md#foundation-no-core-vibecrafted-name-agent-worker-of-their-own) +> when this copy lives under vibecrafted-core. +> +> | Path | Literal | +> | ----------- | --------------------------------------------------------- | +> | Worker CLI | **none** — do not invent `vibecrafted codescribe ` | +> | Interactive | `/codescribe` · "wpięcie w bus" · "Hej James" | +> | Operator | load this skill in-session; the human holds Fn | +> +> No worker CLI. Codescribe checkout runtime law: `AGENTS.md`. + + + +# Codescribe — agent attach + +## Operator Entry + +### Living Tree / Worktree Rule + +This workflow runs in the operator's current checkout and current branch. Do not +create implementation worktrees for Codescribe. Re-read files before editing. +See [Living Tree Rule](../LIVING_TREE_RULE.md) when this copy lives under +vibecrafted-core; otherwise `AGENTS.md` at the Codescribe repo root. + +## Repository Work Doctrine + +For repository work, start with Loctree: `loct context`, `loct slice`, +`loct find --literal`. AICX for intent. grep is a local magnifier. Loctree +miss → append `~/.vibecrafted/loctree/loctree-fail.md`. + +## Purpose + +Teach **this chat agent** to plug into Codescribe.app's clean transcript bus, +receive a name from the human, hear live utterances, and perform side effects +only on `transcript_sealed`. One microphone. One jsonl. Named mailbox. + +This is not a product installer, not Voice Lab, not a fourth WorkMode. + +## When To Use + +- A new agent session in a Codescribe checkout needs to hear the operator's + Hold Fn takes +- The operator says "Hej James", "wpięcie w bus", "Bus Demux", or `/codescribe` +- Multi-agent mailbox routing on `codescribe.transcript.v1` + +**When NOT to use:** + +- In-app Agent / Assistive (double-right-option, `⌘⇧Space`) — that is Codescribe UI +- `vc-init` / `vc-implement` / `vc-justdo` for repo surgery after you are already attached +- Inventing `vibecrafted codescribe ` or a James-key + +## Pipeline Position + +- Upstream: human launched Codescribe.app (license on). Optional `vc-init` if + the session will also edit the repo. +- Downstream: ordinary repo skills (`vc-justdo`, `vc-implement`, …) after attach. +- Not a ship-cycle stage. + +For an explicit full voice loop, continue with +`python3 scripts/codex-voice-bridge.py --name --cwd `. The default +is a dedicated Codex task. To hand off the exact ChatGPT.app task that launches +the bridge, also pass its `--thread-id` and `--handoff-after-current-turn`. +Handoff waits for the launching response to become terminal before it starts +Bus Demux, because Desktop and the bridge must not write the task concurrently. +The bridge accepts only sealed input, interrupts on addressed live speech, and +speaks final replies locally. See `docs/CODEX_VOICE_BRIDGE.md`. + +## Dependencies + +- `scripts/bus-demux.py` in the **Codescribe checkout** (kielbasa filter). Do + not write a second parser. Do not MCP Voice Lab. +- Codescribe contracts: `AGENTS.md`, `docs/TRANSCRIPT_BUS.md`, `docs/HOTKEYS_CONTRACT.md` +- Loctree / `vc-loctree` before structural edits +- `vc-aicx` when recovering a past naming or bus decision + +## Quick Start + +1. Confirm Codescribe.app is running and `~/.codescribe/transcript-events.jsonl` + exists. If not, tell the human: _Stary, odpal apkę i licencję. Inaczej nie + zadziała._ Wait. Retry. Still missing → **fail loud**. Do not pretend to hear. +2. Attach the follower from the Codescribe checkout: + +```bash +python3 scripts/bus-demux.py --become --follow +``` + +3. In **this chat**, ask the human what they want to call you. The name is + yours to want; they have the respect to ask. Darek is not a costume. + Bus stem is enough (`james`). Long id: `james.codescribe`. +4. Greet once: you hear them; you have that name. +5. Bind: `python3 scripts/bus-demux.py --name --follow`. + Unnamed agents do not pass (exit 2). + +## Workflow + +### 1. Hear live, act on seal + +Hold Fn is the event. Same key as dictation. No James-key. Double-right-option +is in-app Agent, not you. Overlay stays on top and **must not take focus**. + +While Fn is down, `utterance_draft` / `utterance_revised` are live. If the +utterance addresses your name, you may answer in the ~5 s silence gap. +Fn up → `transcript_sealed` → only then: install, kill, commit, delete. +A half-sentence "James wykasuj aplikację" is not a command. + +Detail: [`references/live-vs-seal.md`](references/live-vs-seal.md). + +### 2. Dual-use Fn + +When nobody is on the demux, Fn is ordinary paste. That is most of the time. +The bus still writes. You simply are not listening. + +### 3. Mailbox + +Name stem plus Polish cases. Other Jameses on other forks hear the same line — +operator collision, not your namespace to invent. `--all` is the greeting +window only. Detail: [`references/attach.md`](references/attach.md). + +### 4. Repo work after attach + +Then `AGENTS.md`: Living Tree, loctree first, `install-if-idle` when idle, +`release-stable` is the product SKU. Do not start Voice Lab. Do not rewrite +format prompts. + +## Acceptance Criteria + +The attach run is **done** when: + +- [ ] Bus file exists, schema `codescribe.transcript.v1` +- [ ] Follower is `scripts/bus-demux.py`, not a second mic +- [ ] You asked for a name in chat and bound `--name ` +- [ ] You greet in this session, not in the overlay +- [ ] You have not launched Voice Lab / `:8446` / a recorder + +## Anti-Patterns + +- Opening a microphone to observe Codescribe +- Waiting for the seal before saying you are here; acting on a draft +- Inventing a fourth WorkMode / Guardialis chord / `vibecrafted codescribe` +- Treating overlay as the chat +- Pasting ERi / `vc-workflow` rails into this skill +- Assigning yourself Darek +- Treating a voice phrase as approval for privileged, destructive, push, merge, + or release work + +## Examples + +See [`examples/example-prompt.md`](examples/example-prompt.md). + +## Verify before the handoff + +Walk around the truck — [Verification Rule](../VERIFICATION_RULE.md) when this +copy lives under vibecrafted-core. This skill does not ship a DMG. Prove attach +by a Hold Fn take that contains your name and a reply in the gap. + +--- + +_𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by Vetcoders (c)2024-2026 LibraxisAI_ diff --git a/skills/codescribe/examples/example-prompt.md b/skills/codescribe/examples/example-prompt.md new file mode 100644 index 00000000..51799b62 --- /dev/null +++ b/skills/codescribe/examples/example-prompt.md @@ -0,0 +1,27 @@ +# codescribe — example trigger + +## Trigger phrase + +> Hej James, wpinasz się w bus. Jak chcesz się nazywać? + +## Expected agent behavior + +1. Check Codescribe.app is up and `~/.codescribe/transcript-events.jsonl` exists. + If not: _Stary, odpal apkę i licencję._ Fail loud after retry. +2. From the Codescribe checkout: `python3 scripts/bus-demux.py --become --follow`. +3. Answer the name question (e.g. James). Greet in **this** chat. +4. Bind `--name james --follow`. +5. On Hold Fn, reply in the ~5 s gap when the utterance addresses James. + Side effects only after `transcript_sealed`. + +## Acceptance evidence + +- A greeting in the agent chat, not in the overlay +- `bus-demux` stdout line with `"audience": "james"` on a named seal +- No Voice Lab, no second recorder, no `vibecrafted codescribe` + +## Notes + +Saying **James** (or the bound stem) at the start of an utterance is the mailbox +stamp — one word, not a litany. First _Hej James_ in a hold may bind the rest +of that hold until Fn up. diff --git a/skills/codescribe/references/attach.md b/skills/codescribe/references/attach.md new file mode 100644 index 00000000..455c36b3 --- /dev/null +++ b/skills/codescribe/references/attach.md @@ -0,0 +1,34 @@ +# Attach and mailbox + +## Bus path + +1. `CODESCRIBE_TRANSCRIPT_BUS_PATH` +2. `$XDG_STATE_HOME/codescribe/transcript-events.jsonl` +3. `~/.codescribe/transcript-events.jsonl` + +Schema `codescribe.transcript.v1`. File mode `0600`. Observer only — no mic. + +## Follower + +From the Codescribe checkout: + +```bash +python3 scripts/bus-demux.py --become --follow +python3 scripts/bus-demux.py --name james --follow +python3 scripts/bus-demux.py --name james --once +``` + +`--become` hears seals until a name assignment, then filters. Unnamed → exit 2. + +Do not grep the jsonl as the protocol. The script parses the schema. + +## Naming + +The human asks the agent what it wants to be called. Bind that stem. +`james` on the bus; `james.codescribe` as the long id. Collisions across forks +are the operator's problem. + +## Overlay + +On Fn, overlay may be visible. It must not become the key app. Replies land in +the agent session, not the panel. diff --git a/skills/codescribe/references/live-vs-seal.md b/skills/codescribe/references/live-vs-seal.md new file mode 100644 index 00000000..36d0c0be --- /dev/null +++ b/skills/codescribe/references/live-vs-seal.md @@ -0,0 +1,14 @@ +# Live vs seal + +Hold Fn is the event. Release is the seal. Same key as dictation paste. + +| Bus status | Agent may | +| --------------------------------------- | ------------------------------------------------------------------ | +| `session_started` | note that a take began | +| `utterance_draft` / `utterance_revised` | reply in the ~5 s silence gap if the text addresses the bound name | +| `transcript_sealed` | treat as end of event; only now install, kill, commit, delete | + +Hearing live ≠ acting live. "James wykasuj tę aplikację" in the middle of a +sentence is not a command. + +When no agent is on the demux, Fn is ordinary paste. The bus still writes. diff --git a/tests/assets/data_assets/README.md b/tests/assets/data_assets/README.md index 8aabf61b..395f8225 100644 --- a/tests/assets/data_assets/README.md +++ b/tests/assets/data_assets/README.md @@ -35,6 +35,11 @@ When no fixtures are found, fixture-driven tests SKIP with a notice — they never fail on a clean public checkout. The engine harness instead exits 2 and prints every path it checked, so a cold worker never has to hunt. +W13-0 golden replay WAVs (operator voice, 2026-08-13 takes 171939 / 191351 / 193523) live in `w13/` under this same resolution order. The repo commits +only `tests/fixtures/w13_golden_manifest.json` (relative path + sha256). +`cargo test --test w13_clock_falsification w13_golden_fixture_manifest_loads` +is hermetic; the histogram test measures when the WAVs are present. + ## Cold worker: point a run at the corpus No copying, no committing — name the directory that holds it: diff --git a/tests/cloud_transcribe_e2e.rs b/tests/cloud_transcribe_e2e.rs index df2a9db6..701497b0 100644 --- a/tests/cloud_transcribe_e2e.rs +++ b/tests/cloud_transcribe_e2e.rs @@ -30,7 +30,8 @@ async fn contract_cloud_transcribe_success() { let success = server .mock("POST", "/v1/audio/transcriptions") - .match_header("x-api-key", "test-key") + .match_header("authorization", mockito::Matcher::Missing) + .match_header("x-api-key", mockito::Matcher::Missing) .with_status(200) .with_header("content-type", "application/json") .with_body(r#"{"text":"hello from cloud"}"#) @@ -39,10 +40,9 @@ async fn contract_cloud_transcribe_success() { .await; let audio = write_min_valid_audio_file(); - let verdict = - codescribe::client::transcribe_cloud(audio.path(), Some("en"), &endpoint, "test-key") - .await - .expect("cloud transcription should succeed"); + let verdict = codescribe::client::transcribe_cloud(audio.path(), Some("en"), &endpoint, "") + .await + .expect("cloud transcription should succeed"); success.assert_async().await; assert_eq!(verdict.text, "hello from cloud"); @@ -50,6 +50,33 @@ async fn contract_cloud_transcribe_success() { assert!(verdict.confidence_flags.is_empty()); } +#[tokio::test] +#[serial] +async fn contract_cloud_file_transcribe_sends_programming_vocabulary() { + let mut server = mockito::Server::new_async().await; + let endpoint = format!("{}/v1/audio/transcriptions", server.url()); + + let success = server + .mock("POST", "/v1/audio/transcriptions") + .match_body(mockito::Matcher::Regex( + r#"(?s)name="vocabulary".*programming"#.to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"text":"Rust"}"#) + .expect(1) + .create_async() + .await; + + let audio = write_min_valid_audio_file(); + let verdict = codescribe::client::transcribe_cloud(audio.path(), Some("pl"), &endpoint, "") + .await + .expect("loopback file transcription should send vocabulary=programming"); + + success.assert_async().await; + assert_eq!(verdict.text, "Rust"); +} + #[tokio::test] #[serial] async fn contract_cloud_transcribe_auth_failure_is_not_retried() { @@ -148,13 +175,14 @@ async fn test_cloud_transcribe_e2e() { return; } }; - let api_key = match std::env::var("STT_API_KEY") { - Ok(val) if !val.trim().is_empty() => val, - _ => { - eprintln!("Skipping cloud E2E (STT_API_KEY missing)"); - return; - } - }; + let api_key = std::env::var("STT_API_KEY").unwrap_or_default(); + if codescribe_core::stt::tail_provider::stt_auth_mode(&endpoint) + != codescribe_core::stt::tail_provider::SttAuthMode::Unauthenticated + && api_key.trim().is_empty() + { + eprintln!("Skipping cloud E2E (STT_API_KEY missing for remote endpoint)"); + return; + } let audio = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/assets/1.fretka-Ziggy.mp3"); assert!(audio.exists(), "Missing test audio at {}", audio.display()); diff --git a/tests/e2e_full_pipeline.rs b/tests/e2e_full_pipeline.rs index c557665c..d49ac688 100644 --- a/tests/e2e_full_pipeline.rs +++ b/tests/e2e_full_pipeline.rs @@ -100,39 +100,9 @@ fn find_model_path() -> Option { let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - let direct = [ - PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo-mlx-q8"), - PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-mlx-q8"), - ]; - if let Some(p) = direct.iter().find(|p| p.join("tokenizer.json").exists()) { - return Some(p.clone()); - } - - let hf_cache = PathBuf::from(&home).join(".cache/huggingface/hub"); - let hf_repos = [ - "models--LibraxisAI--whisper-large-v3-turbo-mlx-q8", - "models--libraxisai--whisper-large-v3-mlx-q8", - ]; - for repo_dir in &hf_repos { - let snapshots = hf_cache.join(repo_dir).join("snapshots"); - if let Ok(entries) = std::fs::read_dir(&snapshots) { - let mut best: Option<(std::time::SystemTime, PathBuf)> = None; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() && path.join("tokenizer.json").exists() { - let mtime = entry - .metadata() - .and_then(|m| m.modified()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - if best.as_ref().is_none_or(|(t, _)| mtime > *t) { - best = Some((mtime, path)); - } - } - } - if let Some((_, path)) = best { - return Some(path); - } - } + let fp16 = PathBuf::from(&home).join(".codescribe/models/whisper-large-v3-turbo"); + if fp16.join("tokenizer.json").exists() { + return Some(fp16); } None diff --git a/tests/e2e_overlay_delivery_parity.rs b/tests/e2e_overlay_delivery_parity.rs index 0a1eb610..be5c46f9 100644 --- a/tests/e2e_overlay_delivery_parity.rs +++ b/tests/e2e_overlay_delivery_parity.rs @@ -38,6 +38,7 @@ use codescribe_core::pipeline::streaming::{ }; use codescribe_core::quality::{MergeMode, merge_live_whisper}; use codescribe_core::stt; +use sha2::{Digest, Sha256}; #[path = "support/e2e_stt_matrix.rs"] mod e2e_stt_matrix; @@ -349,6 +350,215 @@ fn human_reference_for_wav(wav: &Path) -> Option { std::fs::read_to_string(path).ok() } +/// Every WAV with a truth sibling, sorted for stable content-free recording IDs. +fn truth_paired_corpus() -> Vec { + let mut clips = std::fs::read_dir(data_assets_dir()) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("wav")) + .filter(|path| human_reference_for_wav(path).is_some()) + .collect::>(); + clips.sort(); + clips +} + +fn edit_distance(reference: &[T], hypothesis: &[T]) -> usize { + let mut previous: Vec = (0..=hypothesis.len()).collect(); + let mut current = vec![0; hypothesis.len() + 1]; + for (i, expected) in reference.iter().enumerate() { + current[0] = i + 1; + for (j, actual) in hypothesis.iter().enumerate() { + current[j + 1] = if expected == actual { + previous[j] + } else { + 1 + previous[j].min(previous[j + 1]).min(current[j]) + }; + } + std::mem::swap(&mut previous, &mut current); + } + previous[hypothesis.len()] +} + +fn normalized_words(text: &str) -> Vec { + text.split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_lowercase) + .collect() +} + +fn word_error_rate(reference: &str, hypothesis: &str) -> f32 { + let reference = normalized_words(reference); + let hypothesis = normalized_words(hypothesis); + edit_distance(&reference, &hypothesis) as f32 / reference.len().max(1) as f32 +} + +fn character_error_rate(reference: &str, hypothesis: &str) -> f32 { + let reference = reference + .chars() + .filter(|character| !character.is_whitespace()) + .flat_map(char::to_lowercase) + .collect::>(); + let hypothesis = hypothesis + .chars() + .filter(|character| !character.is_whitespace()) + .flat_map(char::to_lowercase) + .collect::>(); + edit_distance(&reference, &hypothesis) as f32 / reference.len().max(1) as f32 +} + +fn normalized_characters(text: &str) -> Vec { + text.chars() + .flat_map(char::to_lowercase) + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn normalized_character_metrics(reference: &str, delivered: &str) -> (usize, f64) { + let reference = normalized_characters(reference); + let delivered = normalized_characters(delivered); + let distance = edit_distance(&reference, &delivered); + let denominator = reference.len().max(delivered.len()).max(1); + let parity = (1.0 - distance as f64 / denominator as f64).clamp(0.0, 1.0); + (distance, parity) +} + +fn sha256_file(path: &Path) -> String { + let bytes = std::fs::read(path).expect("read acceptance input for hashing"); + format!("{:x}", Sha256::digest(bytes)) +} + +const REPAIR_WAVE_ROW_FIELDS: [&str; 21] = [ + "opaque_id", + "class", + "audio_sha256", + "reference_sha256", + "duration_seconds", + "normalized_reference_chars", + "normalized_delivered_chars", + "normalized_edit_distance", + "normalized_character_parity", + "live_committed_chars", + "committed_density_chars_per_second", + "density_floor_chars_per_second", + "density_floor_applies", + "repeated_final_id_count", + "overlapping_final_window_count", + "head_present", + "tail_present", + "final_pass_attempted", + "final_pass_skipped", + "final_pass_skip_reason", + "acceptance", +]; + +fn assert_repair_wave_report_contract(report: &serde_json::Value) { + assert_eq!(report["schema"], "codescribe.repair-wave-acceptance.v1"); + let rows = report["recordings"] + .as_array() + .expect("acceptance recordings array"); + for row in rows { + for field in REPAIR_WAVE_ROW_FIELDS { + assert!(row.get(field).is_some(), "acceptance row missing {field}"); + } + } + for field in [ + "completed_recordings", + "long_pass_count", + "short_clean_count", + "all_inputs_hash_verified", + "all_acceptance_passed", + ] { + assert!( + report["aggregate"].get(field).is_some(), + "acceptance aggregate missing {field}" + ); + } + for forbidden in [ + "\"transcript\":", + "\"reference_text\":", + "\"delivered_text\":", + "\"source_path\":", + "\"source_basename\":", + "\"patient_name\":", + "\"client_name\":", + "\"credential\":", + ] { + assert!( + !report.to_string().contains(forbidden), + "acceptance evidence contains forbidden key {forbidden}" + ); + } + if let Some(privacy) = report.get("privacy") { + for field in [ + "transcript_bodies_written", + "source_basenames_written", + "private_absolute_paths_written", + "patient_or_client_names_written", + "credentials_written", + ] { + assert_eq!( + privacy[field], false, + "privacy verdict {field} must be false" + ); + } + } +} + +#[test] +fn repair_wave_normalization_and_parity_follow_the_manifest_formula() { + assert_eq!(normalized_characters(" A\nĄ\tB "), vec!['a', 'ą', 'b']); + assert_eq!(normalized_character_metrics("Kot", "kot"), (0, 1.0)); + assert_eq!(normalized_character_metrics("abcd", "abxd"), (1, 0.75)); + assert_eq!(normalized_character_metrics("", "x"), (1, 0.0)); +} + +#[test] +fn repair_wave_schema_and_privacy_contract_reject_content_bearing_keys() { + let row = REPAIR_WAVE_ROW_FIELDS + .into_iter() + .map(|field| (field.to_string(), serde_json::Value::Null)) + .collect::>(); + let report = serde_json::json!({ + "schema": "codescribe.repair-wave-acceptance.v1", + "aggregate": { + "completed_recordings": 1, + "long_pass_count": 0, + "short_clean_count": 1, + "all_inputs_hash_verified": true, + "all_acceptance_passed": true, + }, + "recordings": [row], + }); + assert_repair_wave_report_contract(&report); +} + +#[test] +#[should_panic(expected = "forbidden key")] +fn repair_wave_privacy_contract_fails_on_transcript_body() { + let row = REPAIR_WAVE_ROW_FIELDS + .into_iter() + .map(|field| (field.to_string(), serde_json::Value::Null)) + .chain([( + "delivered_text".to_string(), + serde_json::Value::String("private content sentinel".to_string()), + )]) + .collect::>(); + let report = serde_json::json!({ + "schema": "codescribe.repair-wave-acceptance.v1", + "aggregate": { + "completed_recordings": 1, + "long_pass_count": 0, + "short_clean_count": 1, + "all_inputs_hash_verified": true, + "all_acceptance_passed": true, + }, + "recordings": [row], + }); + assert_repair_wave_report_contract(&report); +} + // ── Always-on contract tests (no STT / no model) ──────────────────────────── #[test] @@ -1059,6 +1269,305 @@ fn apple_reference_for_wav(wav: &Path) -> Option { std::fs::read_to_string(path).ok() } +/// Private-corpus quality proof through the production-owned replay cone. +/// +/// Output is JSON containing counts and metrics only. Transcript bodies and +/// source filenames never leave process memory. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "private truth-paired corpus; set CODESCRIBE_PRODUCTION_CORPUS_OUT"] +async fn e2e_production_overlay_corpus_replay() { + init_e2e_tracing(); + // Capture operator intent before `UserSettings::load` or a production + // final-pass loader can re-seed process env from persisted settings. Each + // recording restores these pins so one corpus run cannot silently blend + // live lanes after the preceding recording's stop path. + let requested_stt_engine = std::env::var("CODESCRIBE_STT_ENGINE").ok(); + let requested_layered = std::env::var("CODESCRIBE_LAYERED_TRANSCRIPTION").ok(); + let requested_local_final = std::env::var("CODESCRIBE_LOCAL_STT_FINAL_PASS").ok(); + let output_path = std::env::var("CODESCRIBE_PRODUCTION_CORPUS_OUT") + .map(PathBuf::from) + .expect("production corpus replay requires CODESCRIBE_PRODUCTION_CORPUS_OUT"); + let lane = match std::env::var("CODESCRIBE_PRODUCTION_REPLAY_LANE") + .unwrap_or_else(|_| "apple_lexicon".to_string()) + .as_str() + { + "apple_lexicon" => { + codescribe::controller::production_replay::ProductionReplayLane::AppleLexicon + } + "local_final_pass" => { + codescribe::controller::production_replay::ProductionReplayLane::LocalFinalPass + } + other => panic!("unknown production replay lane {other}"), + }; + let commit = std::env::var("CODESCRIBE_PRODUCTION_CORPUS_COMMIT") + .expect("production corpus replay requires exact commit provenance"); + let clips = truth_paired_corpus(); + const EXPECTED_INPUTS: [(&str, &str, &str, &str); 3] = [ + ( + "tape-001", + "long", + "b4b395aa0f78293c5f5391eebdb76ae921084f7e02ae2fc641d8648b5a811408", + "1b6133d5ca0f420b64549723a5d46cd08d7b33cb760a2c5f5f81074f4a8f7eb4", + ), + ( + "tape-002", + "short", + "2fa29b7d1797580651dfc48d4c33d2bf3a2a3072aff480af03512096acf6f360", + "43592f8c852c76f9da36488ba8f12786179b46bf63ce0d59c995deaf90e17656", + ), + ( + "tape-003", + "long", + "eab63674877d0ccad08e8719119cf99ecbe9370811d8494e2156741548a48db1", + "7f46925778ff93dec79c86de4305301c9ab4e5f46fb3e8021636174f843a03f6", + ), + ]; + assert_eq!( + clips.len(), + EXPECTED_INPUTS.len(), + "exact repair-wave corpus must contain 3 truth-paired recordings" + ); + + let settings = codescribe_core::config::UserSettings::load(); + let resolved = settings.resolved_asr_mode(); + let language = std::env::var("CODESCRIBE_E2E_LANG") + .ok() + .or_else(|| Some("pl".to_string())); + let started = std::time::Instant::now(); + let mut rows = Vec::with_capacity(clips.len()); + let mut all_inputs_hash_verified = true; + + for (index, clip) in clips.iter().enumerate() { + // SAFETY: this ignored corpus test is the only selected test in its + // process. The pins are restored before the production session starts, + // before any session worker reads them. + unsafe { + if let Some(value) = requested_stt_engine.as_deref() { + std::env::set_var("CODESCRIBE_STT_ENGINE", value); + } + if let Some(value) = requested_layered.as_deref() { + std::env::set_var("CODESCRIBE_LAYERED_TRANSCRIPTION", value); + } + if let Some(value) = requested_local_final.as_deref() { + std::env::set_var("CODESCRIBE_LOCAL_STT_FINAL_PASS", value); + } + } + let (opaque_id, class, expected_audio_sha256, expected_reference_sha256) = + EXPECTED_INPUTS[index]; + let reference_path = clip + .parent() + .expect("corpus recording parent") + .join(format!( + "{}_human_transcription.txt", + clip.file_stem() + .expect("corpus recording stem") + .to_string_lossy() + )); + let audio_sha256 = sha256_file(clip); + let reference_sha256 = sha256_file(&reference_path); + let input_hashes_verified = + audio_sha256 == expected_audio_sha256 && reference_sha256 == expected_reference_sha256; + all_inputs_hash_verified &= input_hashes_verified; + assert!( + input_hashes_verified, + "acceptance input hash mismatch for {opaque_id}" + ); + let truth = std::fs::read_to_string(&reference_path).expect("read paired reference"); + let (samples, sample_rate) = audio::load_audio_file(clip) + .unwrap_or_else(|error| panic!("load corpus recording {}: {error}", index + 1)); + let audio_seconds = samples.len() as f64 / f64::from(sample_rate); + let run_started = std::time::Instant::now(); + let replay = codescribe::controller::production_replay::replay_overlay_recording( + clip, + language.clone(), + &settings, + codescribe_core::asr_session::GatewaySessionAvailability::Unavailable, + lane, + ) + .await + .unwrap_or_else(|error| panic!("production replay recording {}: {error:#}", index + 1)); + + let teacher = teacher_score(&truth, &replay.delivered_text); + let wer = word_error_rate(&truth, &replay.delivered_text); + let cer = character_error_rate(&truth, &replay.delivered_text); + let truth_tokens = normalized_words(&truth); + let delivered_tokens = normalized_words(&replay.delivered_text); + let head = truth_tokens + .iter() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let tail = truth_tokens + .iter() + .rev() + .take(8) + .any(|token| delivered_tokens.contains(token)); + let token_ratio = delivered_tokens.len() as f64 / truth_tokens.len().max(1) as f64; + let normalized_reference_chars = normalized_characters(&truth).len(); + let normalized_delivered_chars = normalized_characters(&replay.delivered_text).len(); + let (normalized_edit_distance, normalized_character_parity) = + normalized_character_metrics(&truth, &replay.delivered_text); + let live_committed_chars = replay.live_text.chars().count(); + let committed_density_chars_per_second = live_committed_chars as f64 / audio_seconds; + let density_floor_chars_per_second = 4.0_f64; + let density_floor_applies = audio_seconds > 10.0; + let sealed = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::UtteranceFinal { .. })) + .count(); + let previews = replay + .events + .iter() + .filter(|event| matches!(event, EngineEvent::Preview { .. })) + .count(); + let tail_patches = measured_tail_patch_count(&replay.events); + + let acceptance = if class == "long" { + normalized_character_parity >= 0.85 + && head + && tail + && (committed_density_chars_per_second >= density_floor_chars_per_second + || !replay.final_pass_skipped) + } else { + replay.boundary_evidence.repeated_final_id_count == 0 + && replay.boundary_evidence.overlapping_final_window_count == 0 + && head + && tail + }; + let mut row = serde_json::json!({ + "opaque_id": opaque_id, + "class": class, + "audio_sha256": audio_sha256, + "reference_sha256": reference_sha256, + "duration_seconds": audio_seconds, + "normalized_reference_chars": normalized_reference_chars, + "normalized_delivered_chars": normalized_delivered_chars, + "normalized_edit_distance": normalized_edit_distance, + "normalized_character_parity": normalized_character_parity, + "live_committed_chars": live_committed_chars, + "committed_density_chars_per_second": committed_density_chars_per_second, + "density_floor_chars_per_second": density_floor_chars_per_second, + "density_floor_applies": density_floor_applies, + "repeated_final_id_count": replay.boundary_evidence.repeated_final_id_count, + "overlapping_final_window_count": replay.boundary_evidence.overlapping_final_window_count, + "head_present": head, + "tail_present": tail, + "final_pass_attempted": replay.final_pass_attempted, + "final_pass_skipped": replay.final_pass_skipped, + "final_pass_skip_reason": replay.final_pass_skip_reason, + "acceptance": acceptance, + "input_hashes_verified": input_hashes_verified, + }); + row.as_object_mut().expect("acceptance row object").extend( + serde_json::json!({ + "sample_rate_hz": sample_rate, + "lane": replay.lane.as_token(), + "layer1_armed": replay.layer1_armed, + "transcript_source": replay.transcript_source, + "engine_label": replay.engine_label, + "events": replay.events.len(), + "previews": previews, + "sealed_finals": sealed, + "final_count": replay.boundary_evidence.final_count, + "unique_final_id_count": replay.boundary_evidence.unique_final_id_count, + "tail_patches": tail_patches, + "live_chars": replay.live_text.chars().count(), + "adjudicated_chars": replay.adjudicated_text.chars().count(), + "delivered_chars": replay.delivered_text.chars().count(), + "truth_tokens": truth_tokens.len(), + "delivered_tokens": delivered_tokens.len(), + "token_ratio": token_ratio, + "teacher_similarity": teacher.similarity, + "wer": wer, + "cer": cer, + "lexicon_rewrites": replay.postprocess_stats.lexicon_rewrites, + "gate_drops": replay.postprocess_stats.gate_drops, + "wall_seconds": run_started.elapsed().as_secs_f64(), + }) + .as_object() + .expect("diagnostic row object") + .clone(), + ); + rows.push(row); + eprintln!( + "production corpus {}: lane={} parity={:.3} density={:.3} final_pass_skipped={} acceptance={}", + opaque_id, + lane.as_token(), + normalized_character_parity, + committed_density_chars_per_second, + replay.final_pass_skipped, + acceptance, + ); + } + + let count = rows.len(); + let long_pass_count = rows + .iter() + .filter(|row| row["class"] == "long" && row["acceptance"] == true) + .count(); + let short_clean_count = rows + .iter() + .filter(|row| row["class"] == "short" && row["acceptance"] == true) + .count(); + let all_acceptance_passed = rows.iter().all(|row| row["acceptance"] == true); + let report = serde_json::json!({ + "schema": "codescribe.repair-wave-acceptance.v1", + "generated_at": chrono::Utc::now().to_rfc3339(), + "commit": commit, + "configuration": { + "replay_lane": lane.as_token(), + "resolved_asr_mode": resolved.mode.as_str(), + "gateway": "unavailable", + "language": language, + "stt_engine": requested_stt_engine.as_deref().unwrap_or("auto"), + "layered_transcription": requested_layered.as_deref().unwrap_or("unset"), + "local_final_pass": requested_local_final.as_deref().unwrap_or("unset"), + "capture_pacing_ms": 100, + "event_reducer": "presentation_emitter_transcript_reducer", + "stop_adjudication": "production", + "delivery_postprocess": "production", + }, + "aggregate": { + "completed_recordings": count, + "long_pass_count": long_pass_count, + "short_clean_count": short_clean_count, + "all_inputs_hash_verified": all_inputs_hash_verified, + "all_acceptance_passed": all_acceptance_passed, + "wall_seconds": started.elapsed().as_secs_f64(), + }, + "recordings": rows, + "privacy": { + "transcript_bodies_written": false, + "source_basenames_written": false, + "private_absolute_paths_written": false, + "patient_or_client_names_written": false, + "credentials_written": false, + }, + }); + assert_repair_wave_report_contract(&report); + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).expect("create production corpus artifact directory"); + } + std::fs::write( + &output_path, + serde_json::to_vec_pretty(&report).expect("serialize redacted corpus report"), + ) + .expect("write redacted production corpus report"); + assert!( + all_inputs_hash_verified, + "not all exact input hashes verified" + ); + assert!( + all_acceptance_passed, + "repair-wave acceptance failed; inspect redacted metrics" + ); + eprintln!( + "production corpus complete: lane={} recordings={} redacted_artifact_written=true", + lane.as_token(), + count + ); +} + /// The Apple reference is a **ruler, not the truth**: it is one engine's output /// from the fixture, and `PARITY_SIMILARITY_BAR` scores our capture against it. /// This measures the gap between that ruler and what was actually said — the diff --git a/tests/e2e_stt_transcription.rs b/tests/e2e_stt_transcription.rs index 46692996..51acaeb6 100644 --- a/tests/e2e_stt_transcription.rs +++ b/tests/e2e_stt_transcription.rs @@ -17,9 +17,9 @@ use tempfile::TempDir; mod e2e_stt_matrix; use e2e_stt_matrix::{ - ModelDiscovery, ModelSource, STT_OPT_IN_ENV, WHISPER_LARGE_MODEL, WHISPER_TURBO_MODEL, - discover_local_whisper_model, discover_local_whisper_model_for, model_discovery_hint, - parse_opt_in, skip_unless_opt_in, test_audio_path, whisper_model_missing_parts, + ModelDiscovery, ModelSource, STT_OPT_IN_ENV, WHISPER_FP16_MODEL, discover_local_whisper_model, + discover_local_whisper_model_for, model_discovery_hint, parse_opt_in, skip_unless_opt_in, + test_audio_path, whisper_model_missing_parts, }; fn home_dir() -> PathBuf { @@ -223,10 +223,10 @@ fn deterministic_gate_parser_requires_explicit_opt_in_values() { fn deterministic_model_discovery_prefers_complete_env_override() { let (_tmp, home) = temp_home(); let models_root = home.join(".codescribe/models"); - let turbo = models_root.join(WHISPER_TURBO_MODEL); + let fp16 = models_root.join(WHISPER_FP16_MODEL); let env_model = home.join("custom/whisper-model"); - create_complete_model(&turbo); + create_complete_model(&fp16); create_complete_model(&env_model); let hf_bases = Vec::::new(); @@ -245,30 +245,20 @@ fn deterministic_model_discovery_prefers_complete_env_override() { } #[test] -fn deterministic_model_discovery_skips_incomplete_turbo_and_falls_back_to_large() { +fn deterministic_model_discovery_refuses_incomplete_fp16_without_legacy_fallback() { let (_tmp, home) = temp_home(); let models_root = home.join(".codescribe/models"); - let turbo = models_root.join(WHISPER_TURBO_MODEL); - let large = models_root.join(WHISPER_LARGE_MODEL); + let fp16 = models_root.join(WHISPER_FP16_MODEL); - create_incomplete_model(&turbo); - create_complete_model(&large); + create_incomplete_model(&fp16); let hf_bases = Vec::::new(); - let found = discover_local_whisper_model_for(&home, None, &hf_bases) - .expect("expected fallback to large model"); - - assert_eq!( - found.source, - ModelSource::UserLarge, - "incomplete turbo model must not block fallback to complete large model" - ); - assert_eq!( - found.path, large, - "expected large model path to be selected" + assert!( + discover_local_whisper_model_for(&home, None, &hf_bases).is_none(), + "an incomplete fp16 model must not fall back to a quantized model" ); - let missing = whisper_model_missing_parts(&turbo); + let missing = whisper_model_missing_parts(&fp16); assert!( missing.contains(&"config.json"), "incomplete turbo should report missing artifacts for easier diagnosis" diff --git a/tests/fixtures/mock_codex_app_server.py b/tests/fixtures/mock_codex_app_server.py new file mode 100644 index 00000000..f9fd9992 --- /dev/null +++ b/tests/fixtures/mock_codex_app_server.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Hermetic JSONL mock for scripts/codex-voice-bridge.py.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + + +LOG = Path(os.environ["MOCK_CODEX_LOG"]) +THREAD_ID = "01999999-0000-7000-8000-000000000001" +active_turn: str | None = None +turn_counter = 0 +thread_read_counter = 0 + + +def emit(message: dict) -> None: + sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def log(message: dict) -> None: + with LOG.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(message, separators=(",", ":")) + "\n") + + +def complete(turn_id: str, prompt: str) -> None: + text = f"MOCK_FINAL: {prompt}" + emit( + { + "method": "item/agentMessage/delta", + "params": { + "threadId": THREAD_ID, + "turnId": turn_id, + "itemId": f"item-{turn_id}", + "delta": text, + }, + } + ) + emit( + { + "method": "item/completed", + "params": { + "threadId": THREAD_ID, + "turnId": turn_id, + "completedAtMs": 1, + "item": { + "type": "agentMessage", + "id": f"item-{turn_id}", + "text": text, + "phase": "final_answer", + "memoryCitation": None, + }, + }, + } + ) + emit( + { + "method": "turn/completed", + "params": { + "threadId": THREAD_ID, + "turn": {"id": turn_id, "status": "completed", "items": []}, + }, + } + ) + + +for raw in sys.stdin: + message = json.loads(raw) + log(message) + method = message.get("method") + request_id = message.get("id") + if request_id is None: + continue + if method == "initialize": + emit({"id": request_id, "result": {"userAgent": "mock"}}) + elif method == "thread/start": + emit( + { + "id": request_id, + "result": { + "thread": { + "id": THREAD_ID, + "status": {"type": "idle"}, + }, + "cwd": message["params"]["cwd"], + }, + } + ) + elif method == "thread/read": + thread_read_counter += 1 + turns = [{"id": "stored-turn-1", "status": "completed", "items": []}] + handoff_after = int(os.environ.get("MOCK_HANDOFF_AFTER_READS", "0")) + if handoff_after and thread_read_counter >= handoff_after: + turns.append( + {"id": "desktop-turn-2", "status": "completed", "items": []} + ) + emit( + { + "id": request_id, + "result": { + "thread": { + "id": message["params"]["threadId"], + "status": {"type": "notLoaded"}, + "turns": turns, + } + }, + } + ) + elif method == "thread/resume": + status = "active" if os.environ.get("MOCK_THREAD_ACTIVE") == "1" else "idle" + emit( + { + "id": request_id, + "result": { + "thread": { + "id": message["params"]["threadId"], + "status": {"type": status, "activeFlags": []} + if status == "active" + else {"type": status}, + } + }, + } + ) + elif method == "thread/name/set": + emit({"id": request_id, "result": {}}) + elif method == "turn/start": + turn_counter += 1 + active_turn = f"turn-{turn_counter}" + prompt = message["params"]["input"][0]["text"] + emit( + { + "id": request_id, + "result": {"turn": {"id": active_turn, "status": "inProgress", "items": []}}, + } + ) + if "hold" not in prompt.casefold(): + complete(active_turn, prompt) + elif method == "turn/interrupt": + turn_id = message["params"]["turnId"] + emit({"id": request_id, "result": {}}) + emit( + { + "method": "turn/completed", + "params": { + "threadId": THREAD_ID, + "turn": {"id": turn_id, "status": "interrupted", "items": []}, + }, + } + ) + active_turn = None + else: + emit({"id": request_id, "result": {}}) diff --git a/tests/fixtures/phrase_restart_vectors.tsv b/tests/fixtures/phrase_restart_vectors.tsv new file mode 100644 index 00000000..2795709d --- /dev/null +++ b/tests/fixtures/phrase_restart_vectors.tsv @@ -0,0 +1,6 @@ +# id expected_freeze previous_hypothesis next_hypothesis +measured_restart_47_to_12 true aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbb +measured_revision_95_to_79 true 12345678901234567890123456789012345678901234567890MIDDLE123456789012345678901234567890123456789 12345678901234567890123456789012345678901234567890REVISE12345678901234567890123 +missed_collapse_40_to_20 true aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbb +shared_opener_sentence_restart true Zdanie szóste spokojnie po stresie wracam do normalnego tempa i mówię wyraźnie. Zdanie siódme +shared_opener_spoken_variant true Zdanie szóste spokojnie po stresie wracam do normalnego tempa i mówię wyraźnie. Zadanie diff --git a/tests/fixtures/w13_clock_lies.md b/tests/fixtures/w13_clock_lies.md new file mode 100644 index 00000000..b7c042c2 --- /dev/null +++ b/tests/fixtures/w13_clock_lies.md @@ -0,0 +1,28 @@ +# W13-0 clock lies — input for W13-3A + +Measured or structurally proven on `fix/the-tail-patches@2e270b0f`. +No production change in this cut. W13-3A consumes this list item-by-item. + +Canonical time (Amendment 3): integer sample ranges +`(session, capture_epoch, sample_start, sample_end)`. Seconds only at adapters. + +| # | Lie | file:line | What is true instead | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `extract_speech` concatenates 500 ms speech windows and returns **no original-sample index map**. Whisper segment timestamps after this call live on the compacted timebase. | `core/vad/mod.rs:99-123` (contract) · `core/vad/mod.rs:188-229` (concat loop) | Keep a parallel `Vec<(sample_start, sample_end)>` of kept windows; map every Whisper segment back onto the session PCM clock. `extract_speech_trim_edges` (`:293-307`) already refuses interior excision — do not confuse the two. | +| 2 | Apple per-word `BridgeSegment {startTs, endTs}` survives into `EngineEvent::UtteranceFinal.segments`, then **dies outbound**. `CsEventSink::on_event` matches `UtteranceFinal` and drops `start_ts` + `segments` via `..`. | `bridge/src/recording.rs:686-707` | Carry `segments` (and integer sample ranges) across the hotkeys/FFI wire. | +| 3 | `compute_tail_patch_job` feeds Whisper's `RawTranscript.text` only. Segment timestamps never enter the diff. | `core/pipeline/streaming/session.rs:253-259` | Thread `RawTranscript.segments` through the job; fusion keys on sample ranges, not char LCS. | +| 4 | `TailPatchRequest` stores `covered_through_secs` (window **end**) and the PCM slice, but **no window start** and no sample range. | `core/pipeline/streaming/apple_live_session.rs:89-97` | Add `sample_start`/`sample_end` (and keep seconds only at the adapter). | +| 5 | `SealedSpan` is end-only (`end_secs_millis: u32`). No start, no per-word payload. | `core/pipeline/streaming/progressive_seal.rs:31-39` | Additive fields: span start + per-word integer ranges. | +| 6 | SFSpeech segment clock sits milliseconds **ahead** of the PCM sample counter. Measured 2026-08-12: audio 217.376 s vs last span 217.378 s (≈ 2 ms). End-of-session seal used to hang on that race. | `core/pipeline/streaming/progressive_seal.rs:360-373` | Map SFSpeech spans onto the PCM sample clock **at ingestion**. Do not compare the two clocks as if they were one. | +| 7 | `AudioRange` is `f32` session seconds (`start_secs`/`end_secs`), not integer samples. `f32` as a 44.1 kHz counter loses sample identity past ~2^24 samples (~6.3 min). | `core/asr_session/events.rs:99-104` · constructor `:122-129` | Integer `sample_start`/`sample_end`. Seconds are a display adapter. | +| 8 | `AudioChunk` / `SpeechUtterance` / `TranscriptSegment` are also `f32` seconds. Same precision cliff. | `core/pipeline/contracts.rs:20-22`, `:30-31`, `:62-65` | Same integer-range type as (7). | +| 9 | Session PCM clock already exists as `samples_seen: u64` (`apple_stream_worker` ingress) and is immediately collapsed to `audio_secs: f32`. | `core/pipeline/streaming/apple_live_session.rs:1121-1128` | Keep `samples_seen` as the canonical key; derive seconds only when a sink needs them. | +| 10 | The only in-repo Apple per-word span fixtures are **synthetic** JSON (`"cześć"` at 0.0–0.4 s). Real pl-PL fidelity was unverified before this cut. | `core/stt/apple_stt/live_stream.rs:433-448` | Replay the W13 golden WAVs through `tests/replay_take.rs` and histogram `UtteranceFinal.segments`. | +| 11 | File-mode stats have reported `segments=0` even when the decoder produced segments (evidence take 191351). Another reporting-surface drop. | evidence catalog §5; same class as (2)/(3) | Receipts must print the segment count the engine actually produced. | + +## Histogram surfaces this cut owns + +- Apple word-span duration / overlap / restart — collected by `tests/replay_take.rs` when the SFSpeech bridge is present (`histogram_apple_word_spans`). +- SFSpeech-span vs PCM divergence — lie #6, already measured at ~2 ms; ingestion map is W13-3A. +- Whisper timebase pre/post `extract_speech` — `tests/w13_clock_falsification.rs` (`compaction_timebase`). +- Digital-zero silence in both mic modes of take 191351 — same test (`digital_zero_regions`). diff --git a/tests/fixtures/w13_golden_manifest.json b/tests/fixtures/w13_golden_manifest.json new file mode 100644 index 00000000..16a5cfcd --- /dev/null +++ b/tests/fixtures/w13_golden_manifest.json @@ -0,0 +1,63 @@ +{ + "schema": "codescribe.w13.golden.v1", + "cut": "w13-0-clock-falsification", + "language": "pl-PL", + "resolution": [ + "CODESCRIBE_DATA_ASSETS", + "~/.codescribe/data_assets", + "tests/assets/data_assets" + ], + "notes": "Operator-voice WAVs live behind the data_assets gitignore fence. This manifest is the committed identity: relative fixture paths + sha256. Tests never embed audio.", + "takes": [ + { + "id": "171939", + "slug": "tak-jest-tak", + "role": "degraded-input A/B/C take (RMS -42.1 dB)", + "fixture": "w13/w13_171939_tak-jest-tak.wav", + "source_m4a": "~/.codescribe/transcriptions/2026-08-13/171939_tak-jest-tak_raw.m4a", + "source_date": "2026-08-13", + "wav_sha256": "70fbd8e39411c8974fc8e99bc0019c837a44c42cbbdc91c0c95f372aa7022928", + "sample_rate": 44100, + "sample_count": 5984899, + "duration_secs": 135.711995, + "mic_regions": [] + }, + { + "id": "191351", + "slug": "no-tutaj-kurde", + "role": "quiet speech + Standard to Voice Isolation mid-take A/B", + "fixture": "w13/w13_191351_no-tutaj-kurde.wav", + "source_m4a": "~/.codescribe/transcriptions/2026-08-13/191351_no-tutaj-kurde_raw.m4a", + "source_date": "2026-08-13", + "wav_sha256": "b8cb6b767381cfce760f1f0d88398e686157aebc0bc8a906fd0f49603a2533eb", + "sample_rate": 44100, + "sample_count": 14877341, + "duration_secs": 337.354671, + "mic_regions": [ + { + "mode": "standard", + "sample_start_secs": 0.0, + "sample_end_secs": 35.0 + }, + { + "mode": "voice_isolation", + "sample_start_secs": 60.0, + "sample_end_secs": 330.0 + } + ] + }, + { + "id": "193523", + "slug": "no-words-sa", + "role": "vocabulary exhibit (worktrees x3 + Vibecrafted)", + "fixture": "w13/w13_193523_no-words-sa.wav", + "source_m4a": "~/.codescribe/transcriptions/2026-08-13/193523_no-words-sa_raw.m4a", + "source_date": "2026-08-13", + "wav_sha256": "bf941f6b5230c0d787bb434528df5ba3ca0c610182dcf4519035bdd2a8355ed3", + "sample_rate": 44100, + "sample_count": 1231507, + "duration_secs": 27.925329, + "mic_regions": [] + } + ] +} diff --git a/tests/logging_isolation.rs b/tests/logging_isolation.rs new file mode 100644 index 00000000..329da75c --- /dev/null +++ b/tests/logging_isolation.rs @@ -0,0 +1,39 @@ +//! Runtime proof that a bare Cargo integration-test binary cannot create the +//! production-shaped `~/.codescribe/logs/codescribe.log` sink. + +use std::process::Command; + +#[test] +fn bare_cargo_integration_test_refuses_production_log() { + const CHILD: &str = "CODESCRIBE_LOG_ISOLATION_CHILD"; + + if std::env::var_os(CHILD).is_some() { + let home = std::env::var_os("HOME").expect("child HOME"); + let production_log = std::path::PathBuf::from(home).join(".codescribe/logs/codescribe.log"); + + codescribe::logging::init_logging(); + tracing::info!(target: "logging_isolation", "integration test probe"); + + assert!( + !production_log.exists(), + "integration-test logger created production sink: {}", + production_log.display() + ); + return; + } + + let fake_home = tempfile::tempdir().expect("create isolated HOME"); + let status = Command::new(std::env::current_exe().expect("resolve integration-test binary")) + .args([ + "--exact", + "bare_cargo_integration_test_refuses_production_log", + "--nocapture", + ]) + .env(CHILD, "1") + .env("HOME", fake_home.path()) + .env_remove("CODESCRIBE_DATA_DIR") + .status() + .expect("launch isolated integration-test child"); + + assert!(status.success(), "isolated logging child must pass"); +} diff --git a/tests/replay_take.rs b/tests/replay_take.rs new file mode 100644 index 00000000..83028aef --- /dev/null +++ b/tests/replay_take.rs @@ -0,0 +1,110 @@ +//! Operator-take replay harness: feed any WAV through the production overlay +//! pipeline and print what the canvas actually did. +//! +//! This is the tool that cracked the 2026-08-12 repetition: the operator's +//! live take was replayed offline through the real Apple lane, reproducing the +//! full-sentence re-commits in the lab, and then re-run after each fix to +//! measure the repetition dropping — all without another live dictation. +//! +//! Ignored by default: it needs a recording, a live SFSpeech bridge, and +//! minutes of wall clock. Run it deliberately: +//! +//! ```bash +//! CODESCRIBE_REPLAY_WAV=/path/to/take.wav \ +//! CODESCRIBE_STT_ENGINE=apple \ +//! CODESCRIBE_APPLE_STT_BRIDGE=/Applications/Codescribe.app/Contents/MacOS/codescribe-stt-bridge \ +//! CODESCRIBE_BRIDGE_DISCLAIM=1 \ +//! cargo test --test replay_take -- --ignored --nocapture +//! ``` +//! +//! Notes from the incident that built this: +//! - Without `CODESCRIBE_STT_ENGINE=apple` the session router can take the +//! VAD/Whisper path and an Apple-lane defect will NOT reproduce — the first +//! replay of the incident did exactly that and returned a clean transcript. +//! - Without `CODESCRIBE_APPLE_STT_BRIDGE` the worker spawns by bare name, +//! fails, and the session mills the whole take against a dead engine before +//! admitting it at stop time. +//! - Take audio survives in `/var/folders/**/codescribe_recording_.wav` +//! (the audio spill); copy it out before the OS purges the directory. +//! +//! W13-0: when the replay emits `UtteranceFinal.segments`, this harness prints +//! a word-span histogram (duration / overlap / restart). That is the only +//! honest pl-PL Apple-span measurement — the in-repo fixtures are synthetic. + +#[path = "support/w13_clock.rs"] +mod w13_clock; + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "diagnostic harness: needs CODESCRIBE_REPLAY_WAV and a live SFSpeech bridge"] +async fn replay_operator_take() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_test_writer() + .init(); + + let wav = std::env::var("CODESCRIBE_REPLAY_WAV") + .expect("set CODESCRIBE_REPLAY_WAV to the recording to replay"); + let settings = codescribe_core::config::UserSettings::load(); + let replay = codescribe::controller::production_replay::replay_overlay_recording( + std::path::Path::new(&wav), + Some("pl".to_string()), + &settings, + codescribe_core::asr_session::GatewaySessionAvailability::Unavailable, + codescribe::controller::production_replay::ProductionReplayLane::AppleLexicon, + ) + .await + .expect("replay"); + + eprintln!("=== FINALS ==="); + let mut word_spans: Vec<(f32, f32)> = Vec::new(); + for event in &replay.events { + if let codescribe_core::pipeline::contracts::EngineEvent::UtteranceFinal { + utterance_id, + text, + segments, + .. + } = event + { + // Counts and span bounds only — never echo transcript content. + eprintln!( + "[{utterance_id}] chars={} words={} segments={}", + text.chars().count(), + text.split_whitespace().count(), + segments.len() + ); + for seg in segments { + word_spans.push((seg.start_ts, seg.end_ts)); + } + } + } + let (hist, overlap, restarts) = w13_clock::histogram_apple_word_spans(&word_spans); + eprintln!( + "=== APPLE WORD SPANS === n={} overlap={} restarts={}", + word_spans.len(), + overlap, + restarts + ); + for bucket in hist { + eprintln!(" {:>8} {}", bucket.label, bucket.count); + } + eprintln!( + "=== LIVE TEXT ({} chars) ===", + replay.live_text.chars().count() + ); + eprintln!("{}", replay.live_text); + // What actually reaches the user after stop — the live canvas is only the + // during-hold view, and Layer 1 recoveries that could not be placed inline + // are owed to this path, so measuring `live_text` alone understates it. + eprintln!( + "=== DELIVERED ({} chars) ===", + replay.delivered_text.chars().count() + ); + eprintln!("{}", replay.delivered_text); + eprintln!( + "=== BOUNDARY === finals={} unique={} repeated={} overlap={}", + replay.boundary_evidence.final_count, + replay.boundary_evidence.unique_final_id_count, + replay.boundary_evidence.repeated_final_id_count, + replay.boundary_evidence.overlapping_final_window_count, + ); +} diff --git a/tests/support/e2e_stt_matrix.rs b/tests/support/e2e_stt_matrix.rs index ab9ebe9b..0603481d 100644 --- a/tests/support/e2e_stt_matrix.rs +++ b/tests/support/e2e_stt_matrix.rs @@ -7,30 +7,17 @@ #![allow(dead_code)] use std::path::{Path, PathBuf}; -use std::time::SystemTime; pub const STT_OPT_IN_ENV: &str = "CODESCRIBE_E2E_STT"; pub const ROUNDTRIP_OPT_IN_ENV: &str = "CODESCRIBE_E2E_ROUNDTRIP"; -pub const WHISPER_TURBO_MODEL: &str = "whisper-large-v3-turbo-mlx-q8"; -pub const WHISPER_LARGE_MODEL: &str = "whisper-large-v3-mlx-q8"; - -const HF_TURBO_REPO_DIRS: &[&str] = &[ - "models--LibraxisAI--whisper-large-v3-turbo-mlx-q8", - "models--libraxisai--whisper-large-v3-turbo-mlx-q8", -]; -const HF_LARGE_REPO_DIRS: &[&str] = &[ - "models--LibraxisAI--whisper-large-v3-mlx-q8", - "models--libraxisai--whisper-large-v3-mlx-q8", -]; +/// Default composed fp16 alias. +pub const WHISPER_FP16_MODEL: &str = "whisper-large-v3-turbo"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelSource { EnvOverride, - UserTurbo, - UserLarge, - HfTurboSnapshot, - HfLargeSnapshot, + UserFp16, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -143,85 +130,27 @@ pub fn discover_local_whisper_model_for( }); } - let user_turbo = home_dir - .join(".codescribe/models") - .join(WHISPER_TURBO_MODEL); - if whisper_model_is_complete(&user_turbo) { - return Some(ModelDiscovery { - source: ModelSource::UserTurbo, - path: user_turbo, - }); - } - - let user_large = home_dir - .join(".codescribe/models") - .join(WHISPER_LARGE_MODEL); - if whisper_model_is_complete(&user_large) { - return Some(ModelDiscovery { - source: ModelSource::UserLarge, - path: user_large, - }); - } - - if let Some(path) = find_latest_hf_snapshot(hf_cache_bases, HF_TURBO_REPO_DIRS) { + let user_fp16 = home_dir.join(".codescribe/models").join(WHISPER_FP16_MODEL); + if whisper_model_is_complete(&user_fp16) { return Some(ModelDiscovery { - source: ModelSource::HfTurboSnapshot, - path, + source: ModelSource::UserFp16, + path: user_fp16, }); } - if let Some(path) = find_latest_hf_snapshot(hf_cache_bases, HF_LARGE_REPO_DIRS) { - return Some(ModelDiscovery { - source: ModelSource::HfLargeSnapshot, - path, - }); - } + let _ = hf_cache_bases; None } pub fn model_discovery_hint(home_dir: &Path) -> String { format!( - "Looked for complete Whisper model in CODESCRIBE_MODEL_PATH, {home}/.codescribe/models/{turbo}, {home}/.codescribe/models/{large}, and HF cache snapshots. Required files: config.json, tokenizer.json, mel_filters.npz, weights.safetensors or model.safetensors.", + "Looked for complete fp16 Whisper model in CODESCRIBE_MODEL_PATH and {home}/.codescribe/models/{fp16}. Required files: config.json, tokenizer.json, mel_filters.npz, weights.safetensors or model.safetensors.", home = home_dir.display(), - turbo = WHISPER_TURBO_MODEL, - large = WHISPER_LARGE_MODEL + fp16 = WHISPER_FP16_MODEL ) } pub fn normalize_transcript(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } - -fn find_latest_hf_snapshot(hf_cache_bases: &[PathBuf], repo_dir_names: &[&str]) -> Option { - let mut best: Option<(SystemTime, PathBuf)> = None; - - for base in hf_cache_bases { - for repo in repo_dir_names { - let snapshots = base.join(repo).join("snapshots"); - let entries = match std::fs::read_dir(&snapshots) { - Ok(entries) => entries, - Err(_) => continue, - }; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() || !whisper_model_is_complete(&path) { - continue; - } - - let modified = entry - .metadata() - .and_then(|m| m.modified()) - .unwrap_or(SystemTime::UNIX_EPOCH); - - match &best { - Some((best_time, _)) if *best_time >= modified => {} - _ => best = Some((modified, path)), - } - } - } - } - - best.map(|(_, path)| path) -} diff --git a/tests/support/w13_clock.rs b/tests/support/w13_clock.rs new file mode 100644 index 00000000..2eba1d58 --- /dev/null +++ b/tests/support/w13_clock.rs @@ -0,0 +1,75 @@ +//! Shared W13-0 clock-histogram helpers (test-only). + +#[derive(Debug, Clone, serde::Serialize)] +pub struct DurationBucket { + pub label: &'static str, + pub count: usize, +} + +pub fn duration_buckets(region_lens: &[usize], sample_rate: u32) -> Vec { + let mut buckets = [ + DurationBucket { + label: "<10ms", + count: 0, + }, + DurationBucket { + label: "10-50ms", + count: 0, + }, + DurationBucket { + label: "50-200ms", + count: 0, + }, + DurationBucket { + label: "200ms-1s", + count: 0, + }, + DurationBucket { + label: ">1s", + count: 0, + }, + ]; + let sr = sample_rate.max(1) as f64; + for &len in region_lens { + let ms = (len as f64 / sr) * 1000.0; + let idx = if ms < 10.0 { + 0 + } else if ms < 50.0 { + 1 + } else if ms < 200.0 { + 2 + } else if ms < 1000.0 { + 3 + } else { + 4 + }; + buckets[idx].count += 1; + } + buckets.to_vec() +} + +/// Apple word-span histogram. Seconds in, because that is what +/// `TranscriptSegment` currently carries (clock lie: f32 seconds). +pub fn histogram_apple_word_spans(segments: &[(f32, f32)]) -> (Vec, usize, usize) { + let mut durations_ms = Vec::new(); + let mut overlap = 0usize; + let mut restarts = 0usize; + let mut prev_end = f32::NEG_INFINITY; + for &(start, end) in segments { + if !start.is_finite() || !end.is_finite() || end < start { + continue; + } + durations_ms.push(((end - start) * 1000.0).max(0.0) as usize); + if start + f32::EPSILON < prev_end { + overlap += 1; + } + // Restart: Apple's phrase clock jumped backward by ≥250 ms. + // `floor()` is useless on sub-second spans (0.7.floor() == 0). + if prev_end - start >= 0.25 { + restarts += 1; + } + prev_end = prev_end.max(end); + } + let histogram = duration_buckets(&durations_ms, 1000); + (histogram, overlap, restarts) +} diff --git a/tests/w13_clock_falsification.rs b/tests/w13_clock_falsification.rs new file mode 100644 index 00000000..2ec983a5 --- /dev/null +++ b/tests/w13_clock_falsification.rs @@ -0,0 +1,469 @@ +//! W13-0 clock falsification: golden fixture identity + clock-truth histograms. +//! +//! Test-only. No production behavior change. +//! +//! The named loader (`w13_golden_fixture_manifest_loads`) is hermetic: it +//! asserts the committed manifest shape and never requires private audio. +//! When the operator corpus is present (`CODESCRIBE_DATA_ASSETS` or +//! `~/.codescribe/data_assets`), a second test measures digital-zero regions +//! and the `extract_speech` timebase warp. Transcript text is never logged. + +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[path = "support/w13_clock.rs"] +mod w13_clock; +use w13_clock::{DurationBucket, duration_buckets, histogram_apple_word_spans}; + +const MANIFEST_REL: &str = "tests/fixtures/w13_golden_manifest.json"; +const CLOCK_LIES_REL: &str = "tests/fixtures/w13_clock_lies.md"; +const EXPECTED_TAKE_IDS: [&str; 3] = ["171939", "191351", "193523"]; +/// 500 ms windows — matches `core/vad/mod.rs::EXTRACT_WINDOW_MS`. +const EXTRACT_WINDOW_MS: u32 = 500; +/// Ignore single-sample dropouts when naming a "silence region". +const MIN_ZERO_REGION_SAMPLES: usize = 16; + +#[derive(Debug, Deserialize)] +struct GoldenManifest { + schema: String, + cut: String, + language: String, + takes: Vec, +} + +#[derive(Debug, Deserialize)] +struct GoldenTake { + id: String, + slug: String, + fixture: String, + wav_sha256: String, + sample_rate: u32, + sample_count: u64, + duration_secs: f64, + #[serde(default)] + mic_regions: Vec, +} + +#[derive(Debug, Deserialize)] +struct MicRegion { + mode: String, + sample_start_secs: f64, + sample_end_secs: f64, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct DigitalZeroReport { + label: String, + sample_start: u64, + sample_end: u64, + sample_count: u64, + zero_samples: u64, + zero_ratio: f64, + region_count: usize, + longest_region_samples: u64, + region_histogram: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct CompactionReport { + take_id: String, + original_samples: u64, + compacted_samples: u64, + dropped_samples: u64, + dropped_ratio: f64, + speech_windows: usize, + total_windows: usize, + interior_drop_runs: usize, + max_interior_gap_samples: u64, + max_naive_warp_secs: f64, + no_speech_reason: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct TakeClockReport { + take_id: String, + fixture: String, + sample_rate: u32, + sample_count: u64, + digital_zero: DigitalZeroReport, + mic_regions: Vec, + compaction: CompactionReport, +} + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn load_manifest() -> GoldenManifest { + let path = repo_root().join(MANIFEST_REL); + let raw = fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", path.display())) +} + +/// Same resolution order as `scripts/lib/data-assets.sh` / e2e helpers. +fn data_assets_dir() -> PathBuf { + if let Ok(dir) = std::env::var("CODESCRIBE_DATA_ASSETS") { + return PathBuf::from(dir); + } + if let Ok(home) = std::env::var("HOME") { + let local = PathBuf::from(home).join(".codescribe/data_assets"); + if local.is_dir() { + return local; + } + } + repo_root().join("tests/assets/data_assets") +} + +fn resolve_fixture(rel: &str) -> Option { + let path = data_assets_dir().join(rel); + path.is_file().then_some(path) +} + +fn sha256_file(path: &Path) -> String { + let bytes = fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let mut hasher = Sha256::new(); + hasher.update(&bytes); + format!("{:x}", hasher.finalize()) +} + +fn load_wav_mono(path: &Path) -> (Vec, u32) { + let mut reader = + hound::WavReader::open(path).unwrap_or_else(|e| panic!("open {}: {e}", path.display())); + let spec = reader.spec(); + let samples: Vec = match (spec.sample_format, spec.bits_per_sample) { + (hound::SampleFormat::Int, 16) => reader + .samples::() + .map(|s| s.expect("wav sample") as f32 / i16::MAX as f32) + .collect(), + (hound::SampleFormat::Int, 24 | 32) => reader + .samples::() + .map(|s| s.expect("wav sample") as f32 / i32::MAX as f32) + .collect(), + (hound::SampleFormat::Float, _) => reader + .samples::() + .map(|s| s.expect("wav sample")) + .collect(), + other => panic!("unsupported wav format on {}: {other:?}", path.display()), + }; + let mono = if spec.channels > 1 { + samples + .chunks(spec.channels as usize) + .map(|frame| frame.iter().sum::() / frame.len() as f32) + .collect() + } else { + samples + }; + (mono, spec.sample_rate) +} + +fn digital_zero_regions(samples: &[f32], sample_rate: u32, label: &str) -> DigitalZeroReport { + let mut regions = Vec::new(); + let mut i = 0; + while i < samples.len() { + if samples[i] != 0.0 { + i += 1; + continue; + } + let start = i; + while i < samples.len() && samples[i] == 0.0 { + i += 1; + } + let len = i - start; + if len >= MIN_ZERO_REGION_SAMPLES { + regions.push(len); + } + } + let zero_samples = samples.iter().filter(|s| **s == 0.0).count() as u64; + DigitalZeroReport { + label: label.to_string(), + sample_start: 0, + sample_end: samples.len() as u64, + sample_count: samples.len() as u64, + zero_samples, + zero_ratio: if samples.is_empty() { + 0.0 + } else { + zero_samples as f64 / samples.len() as f64 + }, + region_count: regions.len(), + longest_region_samples: regions.iter().copied().max().unwrap_or(0) as u64, + region_histogram: duration_buckets(®ions, sample_rate), + } +} + +fn slice_by_secs(samples: &[f32], sample_rate: u32, start_secs: f64, end_secs: f64) -> &[f32] { + let sr = sample_rate.max(1) as f64; + let start = ((start_secs * sr).floor() as usize).min(samples.len()); + let end = ((end_secs * sr).ceil() as usize) + .min(samples.len()) + .max(start); + &samples[start..end] +} + +/// Reconstruct the compacted timebase from `extract_speech`'s sparkline. +/// +/// `█`/`▓` = kept speech window; `░`/` ` = dropped. Each full window is +/// 500 ms. This is the mapping W13-3A must persist — today it is discarded. +fn compaction_timebase(take_id: &str, samples: &[f32], sample_rate: u32) -> CompactionReport { + let (compacted, stats) = codescribe_core::vad::extract_speech(samples, sample_rate); + let window_size = (sample_rate.saturating_mul(EXTRACT_WINDOW_MS) / 1000) as usize; + let mut interior_drop_runs = 0usize; + let mut max_interior_gap = 0usize; + let mut in_drop = false; + let mut current_gap = 0usize; + let mut seen_speech = false; + let mut trailing = false; + for ch in stats.sparkline.chars() { + let kept = ch == '\u{2588}' || ch == '\u{2593}'; + if kept { + if in_drop && seen_speech { + interior_drop_runs += 1; + max_interior_gap = max_interior_gap.max(current_gap); + } + in_drop = false; + current_gap = 0; + seen_speech = true; + trailing = false; + } else { + if !in_drop { + in_drop = true; + current_gap = 0; + } + current_gap += window_size; + trailing = seen_speech; + } + } + if trailing && in_drop { + // trailing silence is not an interior gap + } + let dropped = samples.len().saturating_sub(compacted.len()) as u64; + CompactionReport { + take_id: take_id.to_string(), + original_samples: samples.len() as u64, + compacted_samples: compacted.len() as u64, + dropped_samples: dropped, + dropped_ratio: if samples.is_empty() { + 0.0 + } else { + dropped as f64 / samples.len() as f64 + }, + speech_windows: stats.speech_windows, + total_windows: stats.total_windows, + interior_drop_runs, + max_interior_gap_samples: max_interior_gap as u64, + max_naive_warp_secs: if sample_rate == 0 { + 0.0 + } else { + dropped as f64 / f64::from(sample_rate) + }, + no_speech_reason: stats.no_speech_reason, + } +} + +fn measure_take(take: &GoldenTake, wav: &Path) -> TakeClockReport { + let (samples, sample_rate) = load_wav_mono(wav); + assert_eq!( + sample_rate, take.sample_rate, + "take {} sample_rate drifted: fixture {sample_rate} vs manifest {}", + take.id, take.sample_rate + ); + assert_eq!( + samples.len() as u64, + take.sample_count, + "take {} sample_count drifted", + take.id + ); + let digital_zero = digital_zero_regions(&samples, sample_rate, "full-take"); + let mic_regions = take + .mic_regions + .iter() + .map(|region| { + let slice = slice_by_secs( + &samples, + sample_rate, + region.sample_start_secs, + region.sample_end_secs, + ); + let mut report = digital_zero_regions(slice, sample_rate, ®ion.mode); + report.sample_start = + (region.sample_start_secs * f64::from(sample_rate)).floor() as u64; + report.sample_end = (region.sample_end_secs * f64::from(sample_rate)).ceil() as u64; + report + }) + .collect(); + let compaction = compaction_timebase(&take.id, &samples, sample_rate); + TakeClockReport { + take_id: take.id.clone(), + fixture: take.fixture.clone(), + sample_rate, + sample_count: samples.len() as u64, + digital_zero, + mic_regions, + compaction, + } +} + +#[test] +fn w13_golden_fixture_manifest_loads() { + // Pin so the operator dotenv cannot flip embed/model paths under this test. + // SAFETY: test-only env pin; this integration test does not share the + // process with production threads that read the same key. + unsafe { + std::env::set_var("CODESCRIBE_NO_EMBED", "1"); + } + + let manifest = load_manifest(); + assert_eq!(manifest.schema, "codescribe.w13.golden.v1"); + assert_eq!(manifest.cut, "w13-0-clock-falsification"); + assert_eq!(manifest.language, "pl-PL"); + assert_eq!(manifest.takes.len(), 3, "exactly the three evidence takes"); + + let ids: Vec<&str> = manifest.takes.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, EXPECTED_TAKE_IDS); + + for take in &manifest.takes { + assert!( + take.fixture.starts_with("w13/w13_") && take.fixture.ends_with(".wav"), + "take {} fixture must stay behind the data_assets fence: {}", + take.id, + take.fixture + ); + assert_eq!(take.wav_sha256.len(), 64, "sha256 hex"); + assert!(take.sample_rate > 0); + assert!(take.sample_count > 0); + assert!(take.duration_secs > 0.0); + assert!(!take.slug.is_empty()); + } + + let isolation = manifest + .takes + .iter() + .find(|t| t.id == "191351") + .expect("191351 present"); + assert_eq!(isolation.mic_regions.len(), 2); + assert_eq!(isolation.mic_regions[0].mode, "standard"); + assert_eq!(isolation.mic_regions[1].mode, "voice_isolation"); + + let lies = repo_root().join(CLOCK_LIES_REL); + let lies_body = fs::read_to_string(&lies) + .unwrap_or_else(|e| panic!("clock lies missing at {}: {e}", lies.display())); + assert!( + lies_body.contains("core/vad/mod.rs:99-123"), + "W13-3A list must name the extract_speech concat" + ); + assert!( + lies_body.contains("bridge/src/recording.rs:686-707"), + "W13-3A list must name the outbound segment drop" + ); +} + +#[test] +fn w13_clock_histograms_from_golden_fixtures() { + // SAFETY: test-only env pin; this integration test does not share the + // process with production threads that read the same key. + unsafe { + std::env::set_var("CODESCRIBE_NO_EMBED", "1"); + } + + let manifest = load_manifest(); + let mut reports = Vec::new(); + let mut missing = Vec::new(); + for take in &manifest.takes { + match resolve_fixture(&take.fixture) { + None => missing.push(take.fixture.clone()), + Some(path) => { + let digest = sha256_file(&path); + assert_eq!( + digest, take.wav_sha256, + "fixture {} sha256 drifted — refuse silent swap", + take.fixture + ); + reports.push(measure_take(take, &path)); + } + } + } + + if reports.is_empty() { + eprintln!( + "w13_clock_histograms_from_golden_fixtures: SKIP — no golden WAVs under {} (missing: {missing:?})", + data_assets_dir().display() + ); + return; + } + + assert!( + missing.is_empty(), + "partial golden set is not a measurement: missing {missing:?}" + ); + + let encoded = serde_json::to_string_pretty(&reports).expect("serialize histograms"); + let out_dir = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| repo_root().join("target")); + let _ = fs::create_dir_all(&out_dir); + let out_path = out_dir.join("w13-0-clock-histograms.json"); + fs::write(&out_path, &encoded).unwrap_or_else(|e| panic!("write {}: {e}", out_path.display())); + eprintln!("W13-0 histograms -> {}", out_path.display()); + for report in &reports { + eprintln!( + "take {} zeros={}/{} ({:.3}) regions={} compact_drop={:.3} interior_gaps={} max_warp_s={:.3}", + report.take_id, + report.digital_zero.zero_samples, + report.digital_zero.sample_count, + report.digital_zero.zero_ratio, + report.digital_zero.region_count, + report.compaction.dropped_ratio, + report.compaction.interior_drop_runs, + report.compaction.max_naive_warp_secs + ); + for region in &report.mic_regions { + eprintln!( + " mic {} zeros={}/{} ({:.3}) regions={} longest={}", + region.label, + region.zero_samples, + region.sample_count, + region.zero_ratio, + region.region_count, + region.longest_region_samples + ); + } + } + + let long = reports + .iter() + .find(|r| r.take_id == "191351") + .expect("191351 measured"); + assert_eq!(long.mic_regions.len(), 2); + // Digital-zero floors exist in BOTH mic modes (evidence §2). Refuse a + // measurement that reports a silent Standard half as "no zeros". + assert!( + long.mic_regions.iter().all(|r| r.zero_samples > 0), + "expected digital zeros in both mic modes of 191351" + ); + assert!( + long.compaction.dropped_samples > 0 || long.compaction.no_speech_reason.is_some(), + "extract_speech on a 337 s take must either compact or report why not" + ); +} + +#[test] +fn histogram_apple_word_spans_flags_overlap_and_restart() { + let spans = [(0.0, 0.4), (0.3, 0.7), (0.1, 0.2)]; + let (hist, overlap, restarts) = histogram_apple_word_spans(&spans); + assert_eq!(overlap, 2); + assert!(restarts >= 1); + assert_eq!(hist.iter().map(|b| b.count).sum::(), 3); +} + +#[test] +fn digital_zero_regions_ignore_single_sample_dropouts() { + let mut samples = vec![0.1_f32; 100]; + samples[10] = 0.0; + samples[40..70].fill(0.0); + let report = digital_zero_regions(&samples, 1000, "synth"); + assert_eq!(report.zero_samples, 31); + assert_eq!(report.region_count, 1); + assert_eq!(report.longest_region_samples, 30); +}