diff --git a/.env.debug.example b/.env.debug.example index 228a7d03..7e7842ba 100644 --- a/.env.debug.example +++ b/.env.debug.example @@ -7,6 +7,11 @@ # Runtime precedence: process env / ~/.codescribe/.env > settings.json > defaults. # Secrets belong in Settings / macOS Keychain; placeholders are never real keys. +# ============================================================================= +# APPLICATION RUNTIME +# ============================================================================= +# CODESCRIBE_APP_RUNTIME_WORKERS=4 # Default: 4 — Named Tokio async workers owned by Codescribe.app (1-16; restart required) + # ============================================================================= # STT / WHISPER # ============================================================================= diff --git a/.env.example b/.env.example index c819f8d7..e432e956 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,11 @@ # Runtime precedence: process env / ~/.codescribe/.env > settings.json > defaults. # Prefer Settings for user-facing options and macOS Keychain for secrets. +# ============================================================================= +# APPLICATION RUNTIME +# ============================================================================= +# CODESCRIBE_APP_RUNTIME_WORKERS=4 # Default: 4 — Named Tokio async workers owned by Codescribe.app (1-16; restart required) + # ============================================================================= # STT / WHISPER # ============================================================================= @@ -26,7 +31,7 @@ # 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_SPAN_IDEMPOTENCE=1 # Default: 1 — PCM-range replay refusal + in-span loop fence; 0 is diagnostic only. # 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. 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 8393c49e..bd103618 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Codescribe Local Agent Contract -The VetCoders Global Agent Charter is authoritative in this repository. This +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. diff --git a/AGENT_BUS.md b/AGENT_BUS.md index 463f0fa7..1cd329fe 100644 --- a/AGENT_BUS.md +++ b/AGENT_BUS.md @@ -226,3 +226,18 @@ ours). 5. **Overlay Timer:** Dodanie widocznego licznika sekundowego do ekranu transkrypcji live. Authored-By: antigravity + +--- + +## SIGNAL · LAYER1_FAIL_CLOSED · 2026-08-21 + +**Od:** codex → wszyscy agenci / operator + +Field falsification supersedes `POLARIZE_LAYER1_DEFAULT_AND_LBRX_BENCH` on one +point only: `CODESCRIBE_LAYERED_TRANSCRIPTION` is now **OFF by default**. +Unset preserves Apple live + lexicon; explicit `phase1` remains an experimental +operator path. Do not restore stock Layer 1 until PCM/span identity reaches one +rewrite fence, gap append is structurally idempotent, deliberate repetition +survives, and bounded stop-drain emits complete receipts. + +Authored-By: codex diff --git a/CHANGELOG.md b/CHANGELOG.md index b1be2175..aa8fd208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **The signed app can install the live named-agent bridge.** Agentic Readiness + keeps the 13-step Setup Wizard intact while letting the operator explicitly + select Codex and/or Claude Code. A checksumed bundle payload installs to the + stable `~/.codescribe/agent-bridge/` runtime with one receipt and managed + markers; foreign skill folders are visible conflicts. The demux follows live + drafts, waits for `transcript_sealed` before state changes, and persists a + provider-session lease/cursor plus active names across provider recovery. + ### Fixed - **`make install-app` accepts keys from Get license.** A keyed local diff --git a/Cargo.lock b/Cargo.lock index fae415b0..232ad8a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,19 +225,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "async-compat" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" -dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -5629,7 +5616,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e7a5a038ebffe8f4cf91416b154ef3c2468b18e828b7009e01b1b99938089f9" dependencies = [ "anyhow", - "async-compat", "bytes", "once_cell", "static_assertions", diff --git a/Makefile b/Makefile index 63a4a1dc..06fc701d 100644 --- a/Makefile +++ b/Makefile @@ -648,21 +648,20 @@ engine-auth: $(ENGINE_BRIDGE) # until streaming bridge v2 lands — that is the point, not a flake. Every run # prints token similarity + a word-level diff for the grinding loop. # -# LANE PINNED, not inherited. `CODESCRIBE_LAYERED_TRANSCRIPTION` is a power-user -# key (not promoted to settings.json), so `Config::inject_file_env_for_runtime` -# copies it out of ~/.codescribe/.env into the process environment. An operator -# running their daily dictation on `phase1` therefore armed Layer 1 *inside this -# Layer-0 target* — and Layer 1 is supposed to diverge from Apple, so the bar -# went red for doing its job. Measured 2026-08-08, one binary, consecutive runs: +# LANE PINNED, not inherited. This key is now promoted to settings.json, but a +# test process still accepts an explicit env override. Before promotion, an +# operator's `phase1` leaked into this Layer-0 target — and Layer 1 is supposed +# to diverge from Apple, so the bar went red for doing its job. Measured +# 2026-08-08, one binary, consecutive runs: # `Other: 1` → 0.931 PASS, then `Other: 22` → 0.833 FAIL. Explicit `off` also -# blocks the injection at source (it only fills keys absent from the env). +# pins the intended measurement at the process boundary. # The lane the CALLER asked for — and it is exactly what the recipe pins below # throw away. `make` cannot see a recipe-level `VAR=x cmd` assignment, but it # does see `VAR=x make …` / `make VAR=x`, so `origin` separates "the caller # pinned a lane" from "nobody asked". The operator's own `~/.codescribe/.env` is -# injected in-process by the core, never into this shell, so a daily `phase1` -# dotenv leaves this empty and the guard below stays silent for daily runs. +# is no longer an unpromoted injection path; the guard still rejects a caller +# request that contradicts the recipe's explicit lane pin. ifneq ($(origin CODESCRIBE_LAYERED_TRANSCRIPTION),undefined) PARITY_LANE_REQUEST := $(CODESCRIBE_LAYERED_TRANSCRIPTION) endif diff --git a/README.md b/README.md index 96a58a8e..6d04e537 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![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/) -**Native macOS tray dictation and assistive voice overlay: instant Apple-neural live canvas, Whisper filling the gaps on the go, lexicon correction last — append-only, never rewritten — plus quality tooling.** +**Native macOS tray dictation and assistive voice overlay: instant Apple-neural live canvas, Whisper repairing the same PCM-bound spans on the go, lexicon correction last — plus quality tooling.** ## Overview @@ -13,9 +13,9 @@ Codescribe is a native macOS menu-bar application that captures audio through gl transcription while you speak, and pastes or routes the final result into the focused application. The shipped product in this repo is a tray app whose SwiftUI front-end has two explicit surfaces: settings and overlays. -The transcription shape is layered and append-only (see `AGENTS.md`, the one rule): Apple Speech is the instant -letter-level live canvas; local Whisper transcribes partials on the go to fill the canvas gaps — it is not the live -engine and not a stop-time authority; lexicon correction by dictionary substitution is the final automated layer, and +The transcription shape is layered and span-bound (see `docs/THE_ENGINE_CONTRACT.md`): Apple Speech is the instant +letter-level live canvas; in Local Power mode, local Whisper transcribes partials on the go to repair weaker wording +inside the same proven audio spans — it is not a stop-time authority; lexicon correction by dictionary substitution is the final automated layer, and human corrections feed the lexicon. Cloud STT is optional and used as a post-capture transcript backend, not as live cloud preview. AI formatting and assistive mode use OpenAI Responses API (`/v1/responses`) by default, configured in Settings or `~/.codescribe/.env`. diff --git a/app/controller/final_pass.rs b/app/controller/final_pass.rs index 132854b2..ed15f55d 100644 --- a/app/controller/final_pass.rs +++ b/app/controller/final_pass.rs @@ -11,14 +11,14 @@ //! 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 +//! Local Power's exact-span Layer 1 tail-patch lane). When //! incomplete, only the uncommitted audio tail (from the last committed //! utterance end) is transcribed and **appended** to the committed streaming //! text. The doctrine behind all of this forbids TRUNCATING overlay text and //! DROPPING transcripts the user already saw (the pre-0.8.0 full-replace //! failure) — it does not forbid live correction of committed words. -//! Pair with `CODESCRIBE_LAYERED_TRANSCRIPTION` (orthogonal toggle) for live -//! Whisper tail-patches during hold — Smart does not force layered on. +//! Local Power arms live Whisper tail-patches independently of this stop +//! policy; the compatibility override is not a final-pass switch. //! - **Off**: hard off at stop — zero Whisper invocation on the stop path; //! streaming (+ post-process) is final. //! diff --git a/app/controller/helpers.rs b/app/controller/helpers.rs index 588606f6..ef763a02 100644 --- a/app/controller/helpers.rs +++ b/app/controller/helpers.rs @@ -1963,6 +1963,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }); let sealed = snapshot_session_telemetry(&shared); assert!(!sealed.pending_tail); @@ -2022,6 +2023,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }; sink.on_event(&utterance_final(1, 0.0, 3.2)); @@ -2076,6 +2078,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }; sink.on_event(&utterance_final(1, 0.0, 3.2)); @@ -2133,6 +2136,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }; // Near misses: the sibling receipt code, a truncation, an extension, a diff --git a/app/controller/mod.rs b/app/controller/mod.rs index 96aead14..bf785b07 100644 --- a/app/controller/mod.rs +++ b/app/controller/mod.rs @@ -74,7 +74,8 @@ use crate::os::hold_badge::BadgeMode; use crate::os::hotkeys::{self, HoldMode}; use crate::os::selection::{ AssistiveContext, capture_assistive_context, - capture_assistive_context_with_image_with_prior_frontmost, capture_frontmost_app_only, + capture_assistive_context_with_image_with_prior_frontmost, + capture_frontmost_app_only_with_prior_frontmost, }; use crate::os::shortcut_registry; use codescribe_core::asr_session::gateway_session_availability; @@ -994,7 +995,8 @@ impl RecordingController { let mut deferred_insert_failure = None; let delivery = match disposition { OverlayPasteDisposition::Paste => { - clipboard::paste_text(&paste_text).context("Failed to paste overlay text")?; + clipboard::paste_and_restore(&paste_text) + .context("Failed to paste overlay text")?; OverlayPasteDelivery::Pasted } OverlayPasteDisposition::CopyAccessibilityDenied => { @@ -2168,9 +2170,12 @@ impl RecordingController { .clone() .unwrap_or_default() } else { - tokio::task::spawn_blocking(capture_frontmost_app_only) - .await - .unwrap_or_default() + let prior = self.pre_overlay_frontmost_app.read().await.clone(); + tokio::task::spawn_blocking(move || { + capture_frontmost_app_only_with_prior_frontmost(prior) + }) + .await + .unwrap_or_default() }; *self.pre_overlay_frontmost_app.write().await = trigger_context.frontmost_app.clone(); *self.assistive_context.write().await = Some(trigger_context); @@ -2434,9 +2439,12 @@ impl RecordingController { .await .unwrap_or_default() } else { - tokio::task::spawn_blocking(capture_frontmost_app_only) - .await - .unwrap_or_default() + let prior = self.pre_overlay_frontmost_app.read().await.clone(); + tokio::task::spawn_blocking(move || { + capture_frontmost_app_only_with_prior_frontmost(prior) + }) + .await + .unwrap_or_default() }; *self.pre_overlay_frontmost_app.write().await = trigger_context.frontmost_app.clone(); *self.assistive_context.write().await = Some(trigger_context); @@ -3969,8 +3977,8 @@ impl RecordingController { { let lang_str = language_opt.map(String::from); // 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. + // closes only the unformatted tail. An active ledger mismatch + // fails open to complete L2 text, never a hidden full-text call. let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), @@ -4025,7 +4033,7 @@ impl RecordingController { info!("Formatting mode (Left Option): correcting transcript via AI"); let lang_str = language_opt.map(String::from); - // W13-1: inline buffer first, classic full format as fallback. + // W13-1: span-keyed inline close; active failure keeps full L2. let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), @@ -4071,7 +4079,7 @@ impl RecordingController { info!("Formatting mode (Toggle): correcting transcript via AI"); let lang_str = language_opt.map(String::from); - // W13-1: inline buffer first, classic full format as fallback. + // W13-1: span-keyed inline close; active failure keeps full L2. let result = codescribe_core::llm::inline_format::format_text_with_inline_buffer( &clean_text, lang_str.as_deref(), diff --git a/app/controller/overlay_paste.rs b/app/controller/overlay_paste.rs index 59cc2007..4e17133b 100644 --- a/app/controller/overlay_paste.rs +++ b/app/controller/overlay_paste.rs @@ -136,13 +136,25 @@ pub(super) fn overlay_paste_disposition( OverlayPasteDisposition::CopyTargetMismatch } +/// Whether a successful `activate` still counts when `NSWorkspace` has not +/// flipped yet. The overlay panel can keep the process named Codescribe after +/// Alacritty/Chrome actually took the key window — requiring a name match here +/// parks every auto-paste as ⌘⌥V. +pub(super) fn overlay_float_still_confirms_activation( + wait_confirmed: bool, + frontmost_after_activate: Option<&str>, +) -> bool { + wait_confirmed + || frontmost_after_activate + .map(str::trim) + .is_some_and(|name| !name.is_empty() && target_is_self_app(name)) +} + /// Activate the latched ambulance and confirm it owns focus. /// /// Codescribe (Agent window) is already this process — no activate. A foreign -/// app must both activate and match `NSWorkspace` within the budget. The -/// floating overlay can leave workspace still naming Codescribe; callers pass -/// this bit into [`overlay_paste_disposition`] so a confirmed Alacritty is -/// not vetoed as "frontmost is Codescribe". +/// app must activate. Matching `NSWorkspace` within the budget is sufficient +/// but not required when the overlay still names this process. pub(super) fn confirm_latched_paste_target(target_app: Option<&str>) -> bool { let Some(name) = target_app.map(str::trim).filter(|n| !n.is_empty()) else { return false; @@ -150,8 +162,14 @@ pub(super) fn confirm_latched_paste_target(target_app: Option<&str>) -> bool { if target_is_self_app(name) { return true; } - crate::os::selection::activate_app_by_name(name) - && crate::os::selection::wait_for_frontmost_app(name, OVERLAY_PASTE_FOCUS_BUDGET) + if !crate::os::selection::activate_app_by_name(name) { + return false; + } + let waited = crate::os::selection::wait_for_frontmost_app(name, OVERLAY_PASTE_FOCUS_BUDGET); + overlay_float_still_confirms_activation( + waited, + crate::os::selection::current_frontmost_app_name().as_deref(), + ) } /// Park a refused synthetic paste in the process-local Paste Here slot. diff --git a/app/controller/production_replay.rs b/app/controller/production_replay.rs index d0fb8e90..6ab875da 100644 --- a/app/controller/production_replay.rs +++ b/app/controller/production_replay.rs @@ -243,6 +243,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, } } diff --git a/app/controller/quality_delivery.rs b/app/controller/quality_delivery.rs index 23a76df2..0a02b4a5 100644 --- a/app/controller/quality_delivery.rs +++ b/app/controller/quality_delivery.rs @@ -202,7 +202,7 @@ pub(super) struct ClipboardDeliverySink; impl AutomaticDeliverySink for ClipboardDeliverySink { /// Paste delivery path: type/paste corrected text into the frontmost app. fn paste(&self, text: &str) -> Result<()> { - clipboard::paste_text(text).context("Failed to paste text") + clipboard::paste_and_restore(text).context("Failed to paste text") } } diff --git a/app/controller/tests.rs b/app/controller/tests.rs index 552f692b..49388cea 100644 --- a/app/controller/tests.rs +++ b/app/controller/tests.rs @@ -1542,6 +1542,7 @@ fn under_commit_warning_event_reaches_the_stop_path_action() { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }); if let Some(code) = warning_code { sink.on_event(&EngineEvent::Warning { @@ -2384,6 +2385,16 @@ fn test_overlay_paste_disposition_decision_table() { } } +#[test] +fn overlay_float_confirms_activation_when_workspace_still_names_codescribe() { + assert!(super::overlay_paste::overlay_float_still_confirms_activation(true, Some("Alacritty"))); + assert!( + super::overlay_paste::overlay_float_still_confirms_activation(false, Some("Codescribe")) + ); + assert!(!super::overlay_paste::overlay_float_still_confirms_activation(false, Some("Safari"))); + assert!(!super::overlay_paste::overlay_float_still_confirms_activation(false, None)); +} + #[test] fn test_transcript_delivery_wrap_is_default_off() { let config = Config::default(); @@ -5332,6 +5343,7 @@ fn test_final_event(utterance_id: u64, text: &str) -> EngineEvent { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, } } diff --git a/app/os/selection.rs b/app/os/selection.rs index 8e8b6c07..6bbc9ce8 100644 --- a/app/os/selection.rs +++ b/app/os/selection.rs @@ -256,7 +256,10 @@ pub fn capture_frontmost_app_only_with_prior_frontmost( prior_frontmost_app: Option, ) -> AssistiveContext { if cfg!(test) { - return AssistiveContext::default(); + return AssistiveContext { + frontmost_app: paste_latch_from_frontmost(None, prior_frontmost_app), + selected_text: None, + }; } if !env_flag("ASSISTIVE_CONTEXT_ENABLED", true) { @@ -265,19 +268,55 @@ pub fn capture_frontmost_app_only_with_prior_frontmost( let include_app = env_flag("ASSISTIVE_CONTEXT_INCLUDE_APP", true); let current_frontmost_app = if include_app { - frontmost_app_name() + current_frontmost_app_name().or_else(frontmost_app_name) } else { None }; - let (frontmost_app, _) = - resolve_effective_frontmost_app(current_frontmost_app, prior_frontmost_app); - + remember_foreign_frontmost(current_frontmost_app.as_deref()); + let prior = prior_frontmost_app + .filter(|name| !is_codescribe_app(name)) + .or_else(last_foreign_frontmost); AssistiveContext { - frontmost_app, + frontmost_app: paste_latch_from_frontmost(current_frontmost_app, prior), selected_text: None, } } +/// Ambulance for auto-paste: never Codescribe. Overlay / osascript-none must +/// not wipe a prior latch and must not store ourselves as the caret. +pub(crate) fn paste_latch_from_frontmost( + current: Option, + prior: Option, +) -> Option { + let (app, _) = resolve_effective_frontmost_app(current, prior); + app.filter(|name| !is_codescribe_app(name)) +} + +fn last_foreign_frontmost_store() -> &'static Mutex> { + static STORE: OnceLock>> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(None)) +} + +fn remember_foreign_frontmost(name: Option<&str>) { + let Some(name) = name + .map(str::trim) + .filter(|s| !s.is_empty() && !is_codescribe_app(s)) + else { + return; + }; + let mut guard = last_foreign_frontmost_store() + .lock() + .unwrap_or_else(|e| e.into_inner()); + *guard = Some(name.to_string()); +} + +fn last_foreign_frontmost() -> Option { + last_foreign_frontmost_store() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() +} + /// Activate a running app by localized name without sending Apple Events. #[cfg(target_os = "macos")] fn activate_running_app_by_name(app_name: &str) -> bool { @@ -1059,6 +1098,27 @@ mod tests { use super::*; use serial_test::serial; + /// Overlay / ourselves must never become the auto-paste ambulance. + #[test] + fn paste_latch_never_stores_codescribe() { + assert_eq!( + paste_latch_from_frontmost(Some("Codescribe".to_string()), None), + None + ); + assert_eq!( + paste_latch_from_frontmost( + Some("Codescribe".to_string()), + Some("Alacritty".to_string()) + ) + .as_deref(), + Some("Alacritty") + ); + assert_eq!( + paste_latch_from_frontmost(Some("Alacritty".to_string()), None).as_deref(), + Some("Alacritty") + ); + } + /// When Codescribe is current, prior frontmost app is preferred and restore flagged. #[test] fn effective_frontmost_prefers_prior_when_codescribe_is_current() { diff --git a/app/presentation/emitter.rs b/app/presentation/emitter.rs index 6d78c6f8..358d7093 100644 --- a/app/presentation/emitter.rs +++ b/app/presentation/emitter.rs @@ -9,7 +9,9 @@ use std::sync::Arc; -use codescribe_core::pipeline::contracts::{DeltaSink, EngineEvent, EventSink, TranscriptSegment}; +use codescribe_core::pipeline::contracts::{ + AcousticTranscriptIdentity, DeltaSink, EngineEvent, EventSink, TranscriptSegment, +}; use codescribe_core::pipeline::streaming::BufferedEmitter; use tokio::sync::Mutex; use tracing::{debug, info}; @@ -45,6 +47,7 @@ struct TranscriptUtteranceRecord { start_ts: f32, end_ts: f32, segments: Vec, + acoustic: Option, } impl TranscriptUtteranceRecord { @@ -57,6 +60,7 @@ impl TranscriptUtteranceRecord { start_seconds: self.start_ts, end_seconds: self.end_ts, segments: self.segments.clone(), + acoustic: self.acoustic.clone(), } } } @@ -129,6 +133,13 @@ impl TranscriptReducer { // Fast path + P3-03: search from tail (last first). Collapsed if for clippy. for (index, rec) in self.committed.iter_mut().enumerate().rev() { if normalize_transcript_fragment(&rec.text) == previous { + if rec.acoustic.is_some() { + tracing::warn!( + utterance_id = rec.utterance_id, + "unanchored late correction refused for PCM-anchored utterance" + ); + return None; + } rec.text = corrected; return Some(index); } @@ -163,10 +174,11 @@ impl TranscriptReducer { utterance_id: u64, text: &str, raw_text: &str, - start_ts: f32, - end_ts: f32, + timing: (f32, f32), segments: Vec, + acoustic: Option, ) -> Option { + let (start_ts, end_ts) = timing; let committed_text = { let normalized = normalize_transcript_fragment(text); if normalized.is_empty() { @@ -193,6 +205,7 @@ impl TranscriptReducer { existing.start_ts = start_ts; existing.end_ts = end_ts; existing.segments = segments; + existing.acoustic = acoustic; return None; } @@ -203,6 +216,7 @@ impl TranscriptReducer { start_ts, end_ts, segments, + acoustic, }); Some(committed_text) } @@ -248,6 +262,13 @@ impl TranscriptReducer { else { return false; }; + if record.acoustic.is_some() { + tracing::warn!( + utterance_id, + "unanchored post-commit patch refused for PCM-anchored utterance" + ); + 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 @@ -309,20 +330,24 @@ impl TranscriptReducer { start_ts, end_ts, segments, + acoustic, .. } => { return self.finalize( *utterance_id, text, raw_text, - *start_ts, - *end_ts, + (*start_ts, *end_ts), segments.clone(), + acoustic.clone(), ); } EngineEvent::ReplaceRange { .. } | EngineEvent::InsertAnnotation { .. } => { let _ = self.apply_layered_patch(event); } + EngineEvent::SidebandEvidence { .. } => { + // Timing evidence is not transcript authority. + } EngineEvent::NoSpeech { .. } => self.clear_live_preview(), _ => {} } @@ -567,6 +592,14 @@ impl EventSink for PresentationEmitter { cb(); } } + EngineEvent::SidebandEvidence { evidence } => { + debug!( + sequence = evidence.sequence, + sample_start = evidence.range.sample_start, + sample_end = evidence.range.sample_end, + "PresentationEmitter observed sideband evidence without mutating text" + ); + } EngineEvent::Preview { .. } => { let rendered = { let mut state = self.session_state.lock().unwrap_or_else(|e| e.into_inner()); @@ -777,8 +810,11 @@ impl EventSink for PresentationEmitter { mod tests { use super::{DeltaRenderMode, PresentationEmitter, SessionTranscriptState}; use codescribe_core::pipeline::contracts::{ - AnnotationKind, EngineEvent, EventSink, LayerSource, LayerSummary, TranscriptSegment, + AcousticTranscriptIdentity, AnnotationKind, EngineEvent, EventSink, LayerSource, + LayerSummary, NonSpeechEvidence, SidebandEvidence, SidebandEvidenceKind, + SidebandProvenance, TranscriptSegment, }; + use codescribe_core::stt::tail_provider::TailSampleRange; use std::sync::{Arc, Mutex as StdMutex}; use tokio::sync::Mutex; @@ -807,6 +843,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); let before = reducer.rendered_text(); @@ -839,6 +876,47 @@ mod tests { ); } + #[test] + fn sideband_evidence_is_byte_stable_in_the_transcript_reducer() { + let mut reducer = SessionTranscriptState::default(); + reducer.apply_event(&EngineEvent::UtteranceFinal { + utterance_id: 11, + text: "Zażółć gęślą jaźń.".to_string(), + raw_text: "Zażółć gęślą jaźń.".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(), + acoustic: None, + }); + let before = reducer.rendered_text(); + + let callback = reducer.apply_event(&EngineEvent::SidebandEvidence { + evidence: SidebandEvidence { + sequence: 3, + range: TailSampleRange { + session: "s".to_string(), + capture_epoch: 0, + sample_start: 16_000, + sample_end: 24_000, + }, + sample_rate_hz: 16_000, + provenance: SidebandProvenance::SileroVad, + evidence: SidebandEvidenceKind::Pause { + duration_samples: 8_000, + non_speech: NonSpeechEvidence::UnknownNonSpeech, + }, + }, + }); + + assert!(callback.is_none()); + assert_eq!(reducer.rendered_text().as_bytes(), before.as_bytes()); + } + /// 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 @@ -866,6 +944,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }; // The Apple-lane shape from the incident log: per-utterance previews @@ -925,9 +1004,9 @@ mod tests { 1, "Pierwszy fragment", "Pierwszy fragment", - 0.0, - 1.0, + (0.0, 1.0), Vec::new(), + None, ); assert_eq!(committed.as_deref(), Some("Pierwszy fragment")); @@ -942,7 +1021,14 @@ mod tests { // A phase-1 ReplaceRange fixing "wrold"→"world" must land in the // committed (paste/history) buffer, not just the overlay. let mut state = SessionTranscriptState::default(); - state.finalize(1, "hello wrold", "hello wrold", 0.0, 1.0, Vec::new()); + state.finalize( + 1, + "hello wrold", + "hello wrold", + (0.0, 1.0), + Vec::new(), + None, + ); let event = EngineEvent::ReplaceRange { utterance_id: 1, start: 6, @@ -954,11 +1040,48 @@ mod tests { assert_eq!(state.rendered_text(), "hello world"); } + #[test] + fn anchored_utterance_refuses_unanchored_post_commit_patch() { + let mut state = SessionTranscriptState::default(); + let range = codescribe_core::stt::tail_provider::TailSampleRange { + session: "anchored".into(), + capture_epoch: 3, + sample_start: 0, + sample_end: 16_000, + }; + state.finalize( + 1, + "Iwo", + "iwo", + (0.0, 1.0), + Vec::new(), + Some(AcousticTranscriptIdentity { + range: range.clone(), + spans: vec![ + codescribe_core::pipeline::contracts::AcousticTranscriptSpan { + text: "Iwo".into(), + range, + grain: codescribe_core::pipeline::contracts::AcousticSpanGrain::Phrase, + }, + ], + }), + ); + let patch = EngineEvent::ReplaceRange { + utterance_id: 1, + start: 0, + end: 3, + text: "piwo".into(), + source: LayerSource::TailPatch, + }; + assert!(!state.apply_layered_patch(&patch)); + assert_eq!(state.rendered_text(), "Iwo"); + } + /// InsertAnnotation appends annotation text into the committed utterance. #[test] fn insert_annotation_lands_in_committed_utterance() { let mut state = SessionTranscriptState::default(); - state.finalize(2, "yes", "yes", 0.0, 1.0, Vec::new()); + state.finalize(2, "yes", "yes", (0.0, 1.0), Vec::new(), None); let event = EngineEvent::InsertAnnotation { utterance_id: 2, position: 3, @@ -975,7 +1098,7 @@ mod tests { // Offsets reference an utterance the authoritative buffer has not // committed yet — drop the patch instead of corrupting another one. let mut state = SessionTranscriptState::default(); - state.finalize(1, "hello", "hello", 0.0, 1.0, Vec::new()); + state.finalize(1, "hello", "hello", (0.0, 1.0), Vec::new(), None); let event = EngineEvent::ReplaceRange { utterance_id: 99, start: 0, @@ -995,9 +1118,9 @@ mod tests { 1, "Pierwszy fragment", "Pierwszy fragment", - 0.0, - 1.0, + (0.0, 1.0), Vec::new(), + None, ); state.apply_preview("drugi parcjal"); state.apply_correction("drugi parcjal", "drugi partial"); @@ -1013,9 +1136,9 @@ mod tests { 1, "Pierwszy fragment", "Pierwszy fragment", - 0.0, - 1.0, + (0.0, 1.0), Vec::new(), + None, ); state.apply_preview("drugi partial"); state.backspace_active_preview(3); @@ -1044,9 +1167,9 @@ mod tests { 7, "Pierwszy fragment", "Pierwszy fragment", - 12.0, - 13.0, + (12.0, 13.0), segments.clone(), + None, ); assert_eq!(payload.as_deref(), Some("Pierwszy fragment")); @@ -1072,7 +1195,7 @@ mod tests { let mut state = SessionTranscriptState::default(); state.apply_preview("raw words"); assert_eq!( - state.finalize(1, "raw words", "raw words", 0.0, 1.0, Vec::new()), + state.finalize(1, "raw words", "raw words", (0.0, 1.0), Vec::new(), None,), Some("raw words".to_string()) ); @@ -1106,6 +1229,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::Correction { rev: 2, @@ -1141,6 +1265,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::Preview { rev: 2, @@ -1188,6 +1313,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }; emitter.on_event(&EngineEvent::Preview { @@ -1245,6 +1371,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::UtteranceFinal { utterance_id: 7, @@ -1258,6 +1385,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); let delivered = delivered.lock().unwrap_or_else(|e| e.into_inner()).clone(); @@ -1293,6 +1421,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::Preview { rev: 2, @@ -1325,6 +1454,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::Preview { rev: 2, @@ -1371,6 +1501,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::Preview { rev: 2, @@ -1412,6 +1543,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::UtteranceFinal { utterance_id: 2, @@ -1425,6 +1557,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); // Late correction targets the *first* (penultimate at arrival) utterance. @@ -1501,6 +1634,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); emitter.on_event(&EngineEvent::SessionFinalised { session_id: format!("pipeline-{session_id}"), diff --git a/app/presentation/transcript_bus.rs b/app/presentation/transcript_bus.rs index a9acf215..b979ddfd 100644 --- a/app/presentation/transcript_bus.rs +++ b/app/presentation/transcript_bus.rs @@ -13,7 +13,9 @@ use std::sync::Mutex; use chrono::{SecondsFormat, Utc}; use codescribe_core::audio::capture_receipt::session_energy_db; -use codescribe_core::pipeline::contracts::TranscriptSegment; +use codescribe_core::pipeline::contracts::{ + AcousticSpanGrain, AcousticTranscriptIdentity, TranscriptSegment, +}; use serde::{Deserialize, Serialize}; /// Explicit path override for the clean transcript bus. @@ -48,6 +50,7 @@ pub struct TranscriptDraft { pub start_seconds: f32, pub end_seconds: f32, pub segments: Vec, + pub acoustic: Option, } /// Typed draft transition. Product truth is never represented by this enum; @@ -74,6 +77,7 @@ impl TranscriptDraftStatus { pub enum TranscriptWordGrain { #[default] Word, + Phrase, Utterance, } @@ -85,6 +89,8 @@ fn is_word_grain(grain: &TranscriptWordGrain) -> bool { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TranscriptWordSpan { pub text: String, + pub session_id: String, + pub capture_epoch: u64, pub sample_start: u64, pub sample_end: u64, #[serde(skip_serializing_if = "Option::is_none")] @@ -93,6 +99,14 @@ pub struct TranscriptWordSpan { pub grain: TranscriptWordGrain, } +/// Falsifiable coverage result for one transcript event. Failed receipts keep +/// the clean reducer bytes visible while refusing to pretend they are anchored. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranscriptCoverageReceipt { + pub passed: bool, + pub code: String, +} + /// 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)] @@ -105,6 +119,7 @@ pub struct CleanTranscriptEvent { pub emitted_at: String, pub status: String, pub sample_rate_hz: Option, + pub capture_epoch: Option, pub sample_start: Option, pub sample_end: Option, pub audio_start_seconds: Option, @@ -115,6 +130,8 @@ pub struct CleanTranscriptEvent { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub words: Vec, #[serde(skip_serializing_if = "Option::is_none")] + pub coverage: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub pipeline_session_id: Option, } @@ -126,6 +143,7 @@ pub struct TranscriptBus { path: PathBuf, writer: Mutex, sample_rate_override: Option, + energy_lookup: fn(u64, u64) -> Option, } /// One lock owns lifecycle and bytes together. This makes the sequence stored @@ -159,6 +177,15 @@ impl TranscriptBus { session: TranscriptSession, path: PathBuf, sample_rate_override: Option, + ) -> io::Result { + Self::open_at_with_energy(session, path, sample_rate_override, session_energy_db) + } + + fn open_at_with_energy( + session: TranscriptSession, + path: PathBuf, + sample_rate_override: Option, + energy_lookup: fn(u64, u64) -> Option, ) -> io::Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -189,6 +216,7 @@ impl TranscriptBus { drafts: BTreeMap::new(), }), sample_rate_override, + energy_lookup, }; Ok(bus) } @@ -214,7 +242,8 @@ impl TranscriptBus { 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 (words, coverage) = word_spans_from_draft(&utterance, self.energy_lookup); + let identity = utterance.acoustic.as_ref().map(|value| &value.range); let event = CleanTranscriptEvent { schema: "codescribe.transcript.v1".to_string(), sequence: 0, @@ -224,13 +253,17 @@ impl TranscriptBus { 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), + capture_epoch: identity.map(|range| range.capture_epoch), + sample_start: identity.map(|range| range.sample_start), + sample_end: identity.map(|range| range.sample_end), + audio_start_seconds: identity + .and_then(|range| samples_to_seconds(range.sample_start, sample_rate)), + audio_end_seconds: identity + .and_then(|range| samples_to_seconds(range.sample_end, sample_rate)), text: utterance.text.clone(), segments: utterance.segments.clone(), words, + coverage: Some(coverage), pipeline_session_id: None, }; @@ -263,7 +296,8 @@ impl TranscriptBus { return; } let sample_rate = self.sample_rate(); - let clock = aggregate_seal_clock(&writer.drafts, sample_rate); + let mut clock = aggregate_seal_clock(&writer.drafts, sample_rate, self.energy_lookup); + reconcile_sealed_text(&text, &mut clock); let event = CleanTranscriptEvent { schema: "codescribe.transcript.v1".to_string(), sequence: 0, @@ -273,6 +307,7 @@ impl TranscriptBus { emitted_at: String::new(), status: "transcript_sealed".to_string(), sample_rate_hz: sample_rate, + capture_epoch: clock.capture_epoch, sample_start: clock.sample_start, sample_end: clock.sample_end, audio_start_seconds: clock.audio_start_seconds, @@ -280,6 +315,7 @@ impl TranscriptBus { text, segments: clock.segments, words: clock.words, + coverage: Some(clock.coverage), pipeline_session_id, }; match self @@ -319,6 +355,7 @@ impl TranscriptBus { emitted_at: String::new(), status: "session_started".to_string(), sample_rate_hz: None, + capture_epoch: None, sample_start: None, sample_end: None, audio_start_seconds: None, @@ -326,6 +363,7 @@ impl TranscriptBus { text: String::new(), segments: Vec::new(), words: Vec::new(), + coverage: None, pipeline_session_id: None, }, )?; @@ -357,6 +395,35 @@ impl TranscriptBus { } } +fn lexical_signature(text: &str) -> Vec { + text.split(|character: char| !character.is_alphanumeric()) + .filter(|token| !token.is_empty()) + .map(str::to_lowercase) + .collect() +} + +fn reconcile_sealed_text(text: &str, clock: &mut SealClock) { + if !clock.coverage.passed { + return; + } + let anchored = clock + .words + .iter() + .map(|span| span.text.as_str()) + .collect::>() + .join(" "); + if lexical_signature(&anchored) != lexical_signature(text) { + clock.words.clear(); + clock.coverage = failed_coverage("sealed_text_not_covered_by_acoustic_spans"); + return; + } + if clock.words.len() == 1 { + // L3 may change punctuation/casing, but a one-phrase receipt keeps the + // exact reducer/Bus bytes on the original PCM identity. + clock.words[0].text = text.to_string(); + } +} + /// 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 { @@ -377,112 +444,159 @@ pub fn transcript_bus_path() -> PathBuf { 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 samples_to_seconds(sample: u64, sample_rate: Option) -> Option { + sample_rate + .filter(|rate| *rate > 0) + .map(|rate| sample as f32 / rate as f32) } -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 bus_grain(grain: AcousticSpanGrain) -> TranscriptWordGrain { + match grain { + AcousticSpanGrain::Word => TranscriptWordGrain::Word, + AcousticSpanGrain::Phrase => TranscriptWordGrain::Phrase, + AcousticSpanGrain::Utterance => TranscriptWordGrain::Utterance, } } -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 failed_coverage(code: &str) -> TranscriptCoverageReceipt { + TranscriptCoverageReceipt { + passed: false, + code: code.to_string(), + } } 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(); + energy_lookup: fn(u64, u64) -> Option, +) -> (Vec, TranscriptCoverageReceipt) { + let Some(acoustic) = &utterance.acoustic else { + return (Vec::new(), failed_coverage("missing_pcm_identity")); + }; + if acoustic.range.session.trim().is_empty() + || acoustic.range.sample_end <= acoustic.range.sample_start + { + return (Vec::new(), failed_coverage("invalid_utterance_identity")); + } + if acoustic.spans.is_empty() && !utterance.text.trim().is_empty() { + return (Vec::new(), failed_coverage("missing_lexical_coverage")); + } + + let mut previous_end = acoustic.range.sample_start; + let mut spans = Vec::with_capacity(acoustic.spans.len()); + let mut missing_energy = false; + for span in &acoustic.spans { + let range = &span.range; + if span.text.trim().is_empty() + || range.session != acoustic.range.session + || range.capture_epoch != acoustic.range.capture_epoch + || range.sample_start < acoustic.range.sample_start + || range.sample_end > acoustic.range.sample_end + || range.sample_end <= range.sample_start + || range.sample_start < previous_end + { + return ( + Vec::new(), + failed_coverage("invalid_or_unordered_lexical_span"), + ); + } + let energy_db = energy_lookup(range.sample_start, range.sample_end); + missing_energy |= energy_db.is_none(); + spans.push(TranscriptWordSpan { + text: span.text.clone(), + session_id: range.session.clone(), + capture_epoch: range.capture_epoch, + sample_start: range.sample_start, + sample_end: range.sample_end, + energy_db, + grain: bus_grain(span.grain), + }); + previous_end = range.sample_end; } - word_span_from_seconds( - utterance.text.clone(), - utterance.start_seconds, - utterance.end_seconds, - sample_rate, - TranscriptWordGrain::Utterance, - ) - .into_iter() - .collect() + + let coverage = if missing_energy { + spans.clear(); + failed_coverage("lexical_span_without_voiced_energy") + } else { + TranscriptCoverageReceipt { + passed: true, + code: "anchored_voiced_coverage".to_string(), + } + }; + (spans, coverage) } struct SealClock { + capture_epoch: Option, sample_start: Option, sample_end: Option, audio_start_seconds: Option, audio_end_seconds: Option, segments: Vec, words: Vec, + coverage: TranscriptCoverageReceipt, } fn aggregate_seal_clock( drafts: &BTreeMap, sample_rate: Option, + energy_lookup: fn(u64, u64) -> Option, ) -> SealClock { - let mut audio_start = None; - let mut audio_end = None; + let mut sample_start = None; + let mut sample_end = None; + let mut capture_epoch = None; + let mut mixed_epochs = false; let mut segments = Vec::new(); let mut words = Vec::new(); + let mut coverage = TranscriptCoverageReceipt { + passed: true, + code: "anchored_voiced_coverage".to_string(), + }; 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))); + if let Some(acoustic) = &draft.acoustic { + if !mixed_epochs { + match capture_epoch { + None => capture_epoch = Some(acoustic.range.capture_epoch), + Some(epoch) if epoch != acoustic.range.capture_epoch => { + mixed_epochs = true; + capture_epoch = None; + sample_start = None; + sample_end = None; + coverage = failed_coverage("multiple_capture_epochs"); + } + Some(_) => {} + } + } + if !mixed_epochs && capture_epoch.is_some() { + sample_start = Some( + sample_start.map_or(acoustic.range.sample_start, |seen: u64| { + seen.min(acoustic.range.sample_start) + }), + ); + sample_end = Some(sample_end.map_or(acoustic.range.sample_end, |seen: u64| { + seen.max(acoustic.range.sample_end) + })); + } } segments.extend(draft.segments.iter().cloned()); - words.extend(word_spans_from_draft(draft, sample_rate)); + let (draft_words, draft_coverage) = word_spans_from_draft(draft, energy_lookup); + words.extend(draft_words); + if !draft_coverage.passed && coverage.passed { + coverage = draft_coverage; + } + } + if drafts.is_empty() { + coverage = failed_coverage("missing_pcm_identity"); } 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, + capture_epoch, + sample_start, + sample_end, + audio_start_seconds: sample_start + .and_then(|sample| samples_to_seconds(sample, sample_rate)), + audio_end_seconds: sample_end.and_then(|sample| samples_to_seconds(sample, sample_rate)), segments, words, + coverage, } } @@ -504,17 +618,59 @@ fn expand_tilde(path: &str) -> PathBuf { mod tests { use super::*; + fn voiced_energy(_start: u64, _end: u64) -> Option { + Some(-24.0) + } + + fn silence(_start: u64, _end: u64) -> Option { + None + } + + fn acoustic( + session: &str, + epoch: u64, + start: u64, + end: u64, + spans: Vec<(&str, u64, u64, AcousticSpanGrain)>, + ) -> AcousticTranscriptIdentity { + let range = codescribe_core::stt::tail_provider::TailSampleRange { + session: session.to_string(), + capture_epoch: epoch, + sample_start: start, + sample_end: end, + }; + AcousticTranscriptIdentity { + range, + spans: spans + .into_iter() + .map(|(text, sample_start, sample_end, grain)| { + codescribe_core::pipeline::contracts::AcousticTranscriptSpan { + text: text.to_string(), + range: codescribe_core::stt::tail_provider::TailSampleRange { + session: session.to_string(), + capture_epoch: epoch, + sample_start, + sample_end, + }, + grain, + } + }) + .collect(), + } + } + #[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( + let bus = TranscriptBus::open_at_with_energy( TranscriptSession { session_id: "session-agent".to_string(), mode: TranscriptMode::Agent, }, path.clone(), Some(48_000), + voiced_energy, ) .unwrap(); bus.publish_draft( @@ -525,6 +681,13 @@ mod tests { start_seconds: 0.25, end_seconds: 1.5, segments: Vec::new(), + acoustic: Some(acoustic( + "pipeline-session", + 4, + 12_000, + 72_000, + vec![("clean final", 12_000, 72_000, AcousticSpanGrain::Utterance)], + )), }, ); bus.publish_sealed( @@ -539,6 +702,7 @@ mod tests { start_seconds: 0.25, end_seconds: 1.5, segments: Vec::new(), + acoustic: None, }, ); bus.publish_sealed("duplicate final".to_string(), None); @@ -563,6 +727,8 @@ mod tests { 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[2].words[0].capture_epoch, 4); + assert_eq!(lines[2].words[0].session_id, "pipeline-session"); assert_eq!( lines.iter().map(|event| event.sequence).collect::>(), vec![1, 2, 3] @@ -582,13 +748,14 @@ mod tests { 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( + let bus = TranscriptBus::open_at_with_energy( TranscriptSession { session_id: "session-words".to_string(), mode: TranscriptMode::Dictation, }, path.clone(), Some(16_000), + voiced_energy, ) .unwrap(); bus.publish_draft( @@ -610,6 +777,16 @@ mod tests { end_ts: 2.0, }, ], + acoustic: Some(acoustic( + "pipeline-words", + 9, + 16_000, + 32_000, + vec![ + ("dwa", 16_000, 22_400, AcousticSpanGrain::Word), + ("slowa", 22_400, 32_000, AcousticSpanGrain::Word), + ], + )), }, ); bus.publish_sealed("dwa slowa".to_string(), None); @@ -634,5 +811,169 @@ mod tests { 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); + assert_eq!(seal.coverage.as_ref().map(|value| value.passed), Some(true)); + } + + #[test] + fn silence_and_missing_identity_cannot_gain_published_lexical_spans() { + let draft = TranscriptDraft { + utterance_id: 1, + text: "modelki trzy".to_string(), + start_seconds: 0.0, + end_seconds: 1.0, + segments: Vec::new(), + acoustic: Some(acoustic( + "silence", + 1, + 0, + 16_000, + vec![("modelki trzy", 0, 16_000, AcousticSpanGrain::Phrase)], + )), + }; + let (words, receipt) = word_spans_from_draft(&draft, silence); + assert!(words.is_empty(), "energy absence cannot mint lexical spans"); + assert!(!receipt.passed); + assert_eq!(receipt.code, "lexical_span_without_voiced_energy"); + + let mut missing = draft; + missing.acoustic = None; + let (words, receipt) = word_spans_from_draft(&missing, voiced_energy); + assert!(words.is_empty()); + assert_eq!(receipt.code, "missing_pcm_identity"); + } + + #[test] + fn w2_05_synthetic_receipts_reject_omission_and_unanchored_insertion() { + let local_start = 48_000; + let local_end = 96_000; + let forbidden = "synthetic unspoken phrase"; + let local_draft = TranscriptDraft { + utterance_id: 1, + text: forbidden.to_string(), + start_seconds: 0.0, + end_seconds: 0.0, + segments: Vec::new(), + acoustic: Some(acoustic( + "synthetic-local-session", + 1, + local_start, + local_end, + Vec::new(), + )), + }; + let (words, receipt) = word_spans_from_draft(&local_draft, voiced_energy); + assert!(words.is_empty()); + assert_eq!(receipt.code, "missing_lexical_coverage"); + + let reference = "Iwo tests FlashVAD and G.A.D.".to_string(); + let dragon_draft = TranscriptDraft { + utterance_id: 1, + text: reference.clone(), + start_seconds: 0.0, + end_seconds: 0.0, + segments: Vec::new(), + acoustic: Some(acoustic( + "synthetic-dragon-session", + 1, + 96_672, + 3_548_352, + vec![(&reference, 96_672, 3_548_352, AcousticSpanGrain::Phrase)], + )), + }; + let drafts = BTreeMap::from([(1, dragon_draft)]); + let mut exact = aggregate_seal_clock(&drafts, Some(48_000), voiced_energy); + reconcile_sealed_text(&reference, &mut exact); + assert!(exact.coverage.passed); + assert_eq!(exact.words[0].text, reference); + for required in ["Iwo", "FlashVAD", "G.A.D."] { + assert!(exact.words[0].text.contains(required)); + } + + let broken_delivery = "Iwo tests FlashVAD and an unspoken tail."; + let mut broken = aggregate_seal_clock(&drafts, Some(48_000), voiced_energy); + reconcile_sealed_text(broken_delivery, &mut broken); + assert!(broken.words.is_empty()); + assert_eq!( + broken.coverage.code, + "sealed_text_not_covered_by_acoustic_spans" + ); + } + + #[test] + fn five_acoustic_iwo_survive_reducer_and_transcript_bus() { + use super::super::emitter::reduce_transcript_events; + use codescribe_core::pipeline::contracts::EngineEvent; + + let spans = (0..5u64) + .map(|i| ("Iwo", i * 1_600, i * 1_600 + 1_600, AcousticSpanGrain::Word)) + .collect(); + let identity = acoustic("take", 1, 0, 8_000, spans); + let event = EngineEvent::UtteranceFinal { + utterance_id: 1, + text: "Iwo Iwo Iwo Iwo Iwo".into(), + raw_text: "Iwo Iwo Iwo Iwo Iwo".into(), + start_ts: 0.0, + end_ts: 0.5, + segments: Vec::new(), + vad_speech_pct: None, + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + confidence_flags: Vec::new(), + acoustic: Some(identity.clone()), + }; + let reducer = reduce_transcript_events(&[event]); + let delivered = reducer + .rendered_text() + .split_whitespace() + .filter(|word| word.eq_ignore_ascii_case("iwo")) + .count(); + assert_eq!( + delivered, + 5, + "reducer delivery: {}", + reducer.rendered_text() + ); + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("events.jsonl"); + let bus = TranscriptBus::open_at_with_energy( + TranscriptSession { + session_id: "iwo-five".to_string(), + mode: TranscriptMode::Dictation, + }, + path.clone(), + Some(16_000), + voiced_energy, + ) + .unwrap(); + bus.publish_started(); + bus.publish_draft( + TranscriptDraftStatus::Created, + TranscriptDraft { + utterance_id: 1, + text: reducer.rendered_text(), + start_seconds: 0.0, + end_seconds: 0.5, + segments: Vec::new(), + acoustic: Some(identity), + }, + ); + bus.publish_sealed(reducer.rendered_text(), None); + let raw = std::fs::read_to_string(&path).unwrap(); + let seal: CleanTranscriptEvent = raw + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .find(|event| event.status == "transcript_sealed") + .expect("seal"); + assert_eq!(seal.words.len(), 5); + assert!(seal.words.iter().all(|word| word.text == "Iwo")); + assert_eq!( + seal.text + .split_whitespace() + .filter(|word| word.eq_ignore_ascii_case("iwo")) + .count(), + 5 + ); } } diff --git a/bridge/Cargo.toml b/bridge/Cargo.toml index caf31e74..881a5bbe 100644 --- a/bridge/Cargo.toml +++ b/bridge/Cargo.toml @@ -19,7 +19,7 @@ path = "uniffi-bindgen.rs" [dependencies] codescribe_core = { package = "codescribe-core", path = "../core" } codescribe = { path = ".." } -uniffi = { version = "0.30", features = ["cli", "tokio"] } +uniffi = { version = "0.30", features = ["cli"] } tokio = { version = "1", features = ["full"] } tracing = { workspace = true } anyhow = "1" diff --git a/bridge/src/agent.rs b/bridge/src/agent.rs index 2edccfd7..562503fe 100644 --- a/bridge/src/agent.rs +++ b/bridge/src/agent.rs @@ -16,7 +16,7 @@ use codescribe_core::llm::lane_truth::assistive_identity; use codescribe_core::llm::provider::provider_supports_vision; use tokio::task::AbortHandle; -use crate::CsError; +use crate::{CsError, application_runtime}; /// Maximum number of image attachments the composer may forward in one message. /// Matches the live app controller's `MAX_AGENT_VISION_IMAGES` so both send paths @@ -98,7 +98,7 @@ pub trait CsAgentListener: Send + Sync { } /// Thin handle to the codescribe agent engine. -#[derive(uniffi::Object, Default)] +#[derive(uniffi::Object, Default, Clone)] pub struct CodescribeAgent { /// In-flight turns keyed by thread id, so `cancel_turn` can abort them. /// Shared (`Arc`) because each turn's RAII guard must be able to deregister @@ -109,7 +109,7 @@ pub struct CodescribeAgent { approvals: Arc, } -#[uniffi::export(async_runtime = "tokio")] +#[uniffi::export] impl CodescribeAgent { /// Construct the FFI handle. Only initialises logging — provider, tools and /// config are resolved lazily per send, so building the Swift app model @@ -153,7 +153,10 @@ impl CodescribeAgent { /// formatting lane. The core call has its own 8-second timeout and never /// participates in the assistive or formatting response chains. pub async fn generate_thread_title(&self, text: String) -> Result, CsError> { - Ok(codescribe_core::llm::ai_formatting::generate_thread_title(&text).await?) + application_runtime::run(async move { + Ok(codescribe_core::llm::ai_formatting::generate_thread_title(&text).await?) + }) + .await? } /// Stream one agent reply for `text` on the conversation identified by @@ -176,7 +179,13 @@ impl CodescribeAgent { thread_id: String, listener: Arc, ) -> Result { - self.run_stream(text, thread_id, Vec::new(), listener).await + let agent = self.clone(); + application_runtime::run(async move { + agent + .run_stream(text, thread_id, Vec::new(), listener) + .await + }) + .await? } /// Stream one agent reply for `text` with `attachments` forwarded as real @@ -197,8 +206,12 @@ impl CodescribeAgent { attachments: Vec, listener: Arc, ) -> Result { - let images = validate_composer_attachments(&attachments)?; - self.run_stream(text, thread_id, images, listener).await + let agent = self.clone(); + application_runtime::run(async move { + let images = validate_composer_attachments(&attachments)?; + agent.run_stream(text, thread_id, images, listener).await + }) + .await? } /// Abort the in-flight turn(s) for `thread_id`. Returns `true` when an diff --git a/bridge/src/application_runtime.rs b/bridge/src/application_runtime.rs new file mode 100644 index 00000000..d2d83301 --- /dev/null +++ b/bridge/src/application_runtime.rs @@ -0,0 +1,515 @@ +//! Process-wide Tokio runtime owned by the Codescribe application. +//! +//! UniFFI's Tokio compatibility adapter is intentionally not the execution +//! authority for app work. Exported async functions use [`run`] to move their +//! root future onto this runtime immediately; the foreign executor only waits +//! for the resulting Tokio join handle. + +use std::collections::BTreeSet; +use std::future::Future; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use tokio::runtime::{Builder, Handle, Runtime}; +use tokio::task::{JoinError, JoinHandle}; + +use crate::CsError; + +const WORKER_ENV: &str = "CODESCRIBE_APP_RUNTIME_WORKERS"; +const DEFAULT_WORKERS: usize = 4; +const MAX_WORKERS: usize = 16; +const WORKER_PREFIX: &str = "codescribe-app-worker-"; +const WORKER_START_TIMEOUT: Duration = Duration::from_secs(2); +const RUNTIME_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + +/// Observable, content-free runtime lifecycle evidence for Swift and probes. +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct CsApplicationRuntimeSnapshot { + /// `not_started`, `running`, or `stopped`. + pub state: String, + /// Configured async worker count for this process. + pub worker_count: u32, + /// Named async workers observed entering the runtime. + pub worker_names: Vec, + /// Named async workers observed leaving the runtime. + pub stopped_worker_names: Vec, + /// Root bridge tasks currently owned by the runtime adapter. + pub active_tasks: u64, +} + +#[derive(Default)] +struct RuntimeState { + runtime: Option, + permanently_stopped: bool, +} + +struct RuntimeMetrics { + worker_count: usize, + worker_names: Mutex>, + stopped_worker_names: Mutex>, + worker_started: Condvar, + active_tasks: AtomicU64, +} + +impl RuntimeMetrics { + fn new(worker_count: usize) -> Self { + Self { + worker_count, + worker_names: Mutex::new(BTreeSet::new()), + stopped_worker_names: Mutex::new(BTreeSet::new()), + worker_started: Condvar::new(), + active_tasks: AtomicU64::new(0), + } + } + + fn is_async_worker_name(&self, name: &str) -> bool { + name.strip_prefix(WORKER_PREFIX) + .and_then(|suffix| suffix.parse::().ok()) + .is_some_and(|index| (1..=self.worker_count).contains(&index)) + } + + fn note_thread_start(&self) { + let Some(name) = std::thread::current().name().map(str::to_string) else { + return; + }; + if !self.is_async_worker_name(&name) { + return; + } + let mut names = self.worker_names.lock().unwrap_or_else(|e| e.into_inner()); + names.insert(name); + self.worker_started.notify_all(); + } + + fn note_thread_stop(&self) { + let Some(name) = std::thread::current().name().map(str::to_string) else { + return; + }; + if !self.is_async_worker_name(&name) { + return; + } + self.stopped_worker_names + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(name); + } + + fn wait_for_workers(&self) -> Result<(), CsError> { + let deadline = Instant::now() + WORKER_START_TIMEOUT; + let mut names = self.worker_names.lock().unwrap_or_else(|e| e.into_inner()); + while names.len() < self.worker_count { + let now = Instant::now(); + if now >= deadline { + return Err(runtime_error(format!( + "application runtime started only {}/{} named workers", + names.len(), + self.worker_count + ))); + } + let wait = deadline.saturating_duration_since(now); + let (next, _) = self + .worker_started + .wait_timeout(names, wait) + .unwrap_or_else(|e| e.into_inner()); + names = next; + } + Ok(()) + } +} + +/// Runtime plus lifecycle/measurement state. One global instance serves the +/// app; tests construct local instances so shutdown never poisons another test. +struct ApplicationRuntime { + state: Mutex, + metrics: Arc, +} + +impl ApplicationRuntime { + fn new(worker_count: usize) -> Self { + Self { + state: Mutex::new(RuntimeState::default()), + metrics: Arc::new(RuntimeMetrics::new(worker_count)), + } + } + + fn start(&self) -> Result { + let mut state = self.state.lock().map_err(|_| { + runtime_error("application runtime lifecycle lock poisoned".to_string()) + })?; + if state.permanently_stopped { + return Err(runtime_error( + "application runtime cannot restart after shutdown".to_string(), + )); + } + if state.runtime.is_some() { + return Ok(self.snapshot_for(&state)); + } + + let name_counter = Arc::new(AtomicUsize::new(0)); + let name_counter_for_runtime = Arc::clone(&name_counter); + let metrics_for_start = Arc::clone(&self.metrics); + let metrics_for_stop = Arc::clone(&self.metrics); + let runtime = Builder::new_multi_thread() + .worker_threads(self.metrics.worker_count) + .thread_name_fn(move || { + let index = name_counter_for_runtime.fetch_add(1, Ordering::SeqCst) + 1; + format!("{WORKER_PREFIX}{index}") + }) + .on_thread_start(move || metrics_for_start.note_thread_start()) + .on_thread_stop(move || metrics_for_stop.note_thread_stop()) + .enable_all() + .build() + .map_err(|error| { + runtime_error(format!( + "application runtime initialization failed: {error}" + )) + })?; + state.runtime = Some(runtime); + self.metrics.wait_for_workers()?; + let snapshot = self.snapshot_for(&state); + tracing::info!( + worker_count = snapshot.worker_count, + worker_names = ?snapshot.worker_names, + "Codescribe application runtime started" + ); + Ok(snapshot) + } + + fn handle(&self) -> Result { + self.start()?; + let state = self.state.lock().map_err(|_| { + runtime_error("application runtime lifecycle lock poisoned".to_string()) + })?; + state + .runtime + .as_ref() + .map(Runtime::handle) + .cloned() + .ok_or_else(|| runtime_error("application runtime is not running".to_string())) + } + + fn spawn(&self, future: F) -> Result, CsError> + where + F: Future + Send + 'static, + T: Send + 'static, + { + self.start()?; + let state = self.state.lock().map_err(|_| { + runtime_error("application runtime lifecycle lock poisoned".to_string()) + })?; + let runtime = state + .runtime + .as_ref() + .ok_or_else(|| runtime_error("application runtime is not running".to_string()))?; + self.metrics.active_tasks.fetch_add(1, Ordering::SeqCst); + let metrics = Arc::clone(&self.metrics); + let handle = runtime.handle().spawn(async move { + let _guard = ActiveTaskGuard { metrics }; + future.await + }); + Ok(AbortOnDropTask { + handle: Some(handle), + }) + } + + async fn run(&self, future: F) -> Result + where + F: Future + Send + 'static, + T: Send + 'static, + { + self.spawn(future)?.join().await.map_err(join_error) + } + + fn block_on(&self, future: F) -> Result + where + F: Future + Send + 'static, + T: Send + 'static, + { + let task = self.spawn(future)?; + let handle = self.handle()?; + std::thread::Builder::new() + .name("codescribe-app-runtime-waiter".to_string()) + .spawn(move || handle.block_on(task.join())) + .map_err(|error| { + runtime_error(format!( + "application runtime waiter failed to start: {error}" + )) + })? + .join() + .map_err(|_| runtime_error("application runtime waiter panicked".to_string()))? + .map_err(join_error) + } + + fn shutdown(&self) -> Result { + let runtime = { + let mut state = self.state.lock().map_err(|_| { + runtime_error("application runtime lifecycle lock poisoned".to_string()) + })?; + if state.permanently_stopped { + return Ok(self.snapshot_for(&state)); + } + state.permanently_stopped = true; + state.runtime.take() + }; + + if let Some(runtime) = runtime { + runtime.shutdown_timeout(RUNTIME_SHUTDOWN_TIMEOUT); + } + let state = self.state.lock().map_err(|_| { + runtime_error("application runtime lifecycle lock poisoned".to_string()) + })?; + let snapshot = self.snapshot_for(&state); + tracing::info!( + active_tasks = snapshot.active_tasks, + stopped_workers = ?snapshot.stopped_worker_names, + "Codescribe application runtime stopped" + ); + Ok(snapshot) + } + + fn snapshot(&self) -> Result { + let state = self.state.lock().map_err(|_| { + runtime_error("application runtime lifecycle lock poisoned".to_string()) + })?; + Ok(self.snapshot_for(&state)) + } + + fn snapshot_for(&self, state: &RuntimeState) -> CsApplicationRuntimeSnapshot { + let lifecycle = if state.runtime.is_some() { + "running" + } else if state.permanently_stopped { + "stopped" + } else { + "not_started" + }; + CsApplicationRuntimeSnapshot { + state: lifecycle.to_string(), + worker_count: self.metrics.worker_count as u32, + worker_names: self + .metrics + .worker_names + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .cloned() + .collect(), + stopped_worker_names: self + .metrics + .stopped_worker_names + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .cloned() + .collect(), + active_tasks: self.metrics.active_tasks.load(Ordering::SeqCst), + } + } +} + +struct ActiveTaskGuard { + metrics: Arc, +} + +impl Drop for ActiveTaskGuard { + fn drop(&mut self) { + self.metrics.active_tasks.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Dropping the foreign wait future aborts its application-owned root task. +/// This prevents a cancelled Swift `Task` from silently detaching bridge work. +struct AbortOnDropTask { + handle: Option>, +} + +impl AbortOnDropTask { + async fn join(mut self) -> Result { + let result = self.handle.as_mut().expect("task handle must exist").await; + self.handle.take(); + result + } +} + +impl Drop for AbortOnDropTask { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + handle.abort(); + } + } +} + +fn configured_worker_count_from(value: Option<&str>) -> usize { + value + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|count| (1..=MAX_WORKERS).contains(count)) + .unwrap_or(DEFAULT_WORKERS) +} + +fn configured_worker_count() -> usize { + let raw = std::env::var(WORKER_ENV).ok(); + let count = configured_worker_count_from(raw.as_deref()); + if raw.as_deref().is_some_and(|value| { + value + .trim() + .parse::() + .ok() + .is_none_or(|parsed| !(1..=MAX_WORKERS).contains(&parsed)) + }) { + tracing::warn!( + env = WORKER_ENV, + value = ?raw, + fallback = DEFAULT_WORKERS, + "invalid application runtime worker count" + ); + } + count +} + +fn global() -> &'static ApplicationRuntime { + static APPLICATION_RUNTIME: OnceLock = OnceLock::new(); + APPLICATION_RUNTIME.get_or_init(|| ApplicationRuntime::new(configured_worker_count())) +} + +fn runtime_error(msg: String) -> CsError { + CsError::Runtime { msg } +} + +fn join_error(error: JoinError) -> CsError { + runtime_error(format!("application runtime task failed: {error}")) +} + +pub(crate) fn start() -> Result { + global().start() +} + +pub(crate) async fn run(future: F) -> Result +where + F: Future + Send + 'static, + T: Send + 'static, +{ + global().run(future).await +} + +pub(crate) fn block_on(future: F) -> Result +where + F: Future + Send + 'static, + T: Send + 'static, +{ + global().block_on(future) +} + +pub(crate) fn snapshot() -> Result { + global().snapshot() +} + +pub(crate) fn shutdown() -> Result { + global().shutdown() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + use std::sync::mpsc; + + struct DropWitness(Arc); + + impl Drop for DropWitness { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[test] + fn application_runtime_owns_uniffi_async_exports() { + let runtime = ApplicationRuntime::new(DEFAULT_WORKERS); + let started = runtime.start().expect("runtime starts"); + assert_eq!(started.worker_count, 4); + assert_eq!( + started.worker_names, + (1..=4) + .map(|index| format!("{WORKER_PREFIX}{index}")) + .collect::>() + ); + eprintln!( + "runtime_probe:start state={} workers={:?}", + started.state, started.worker_names + ); + + let thread_name = runtime + .block_on(async { + std::thread::current() + .name() + .unwrap_or("unnamed") + .to_string() + }) + .expect("task executes"); + assert!(thread_name.starts_with(WORKER_PREFIX), "{thread_name}"); + assert!(!thread_name.contains("async-compat"), "{thread_name}"); + + for source in [ + include_str!("agent.rs"), + include_str!("hotkeys.rs"), + include_str!("recording.rs"), + ] { + assert!( + !source.contains("async_runtime = \"tokio\""), + "UniFFI Tokio fallback must not own exported futures" + ); + assert!( + source.contains("application_runtime::run"), + "every async export module must route through the app runtime" + ); + } + + let stopped = runtime.shutdown().expect("runtime shuts down"); + eprintln!( + "runtime_probe:stop state={} active_tasks={} stopped_workers={:?}", + stopped.state, stopped.active_tasks, stopped.stopped_worker_names + ); + assert_eq!(stopped.active_tasks, 0); + assert_eq!(stopped.stopped_worker_names, stopped.worker_names); + } + + #[test] + fn cancelled_waiter_and_shutdown_leave_no_owned_tasks() { + let runtime = ApplicationRuntime::new(DEFAULT_WORKERS); + runtime.start().expect("runtime starts"); + let dropped = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = mpsc::sync_channel(1); + let witness = Arc::clone(&dropped); + let task = runtime + .spawn(async move { + let _witness = DropWitness(witness); + started_tx.send(()).expect("signal task start"); + std::future::pending::<()>().await; + }) + .expect("task spawns"); + started_rx + .recv_timeout(Duration::from_secs(1)) + .expect("task reached runtime"); + drop(task); + + let deadline = Instant::now() + Duration::from_secs(1); + while !dropped.load(Ordering::SeqCst) && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(dropped.load(Ordering::SeqCst)); + assert_eq!(runtime.snapshot().expect("snapshot").active_tasks, 0); + + let stopped = runtime.shutdown().expect("runtime shuts down"); + assert_eq!(stopped.state, "stopped"); + assert_eq!(stopped.active_tasks, 0); + assert_eq!(stopped.stopped_worker_names, stopped.worker_names); + assert!(runtime.start().is_err(), "shutdown is terminal"); + } + + #[test] + fn worker_policy_defaults_to_four_and_bounds_overrides() { + assert_eq!(configured_worker_count_from(None), 4); + assert_eq!(configured_worker_count_from(Some("")), 4); + assert_eq!(configured_worker_count_from(Some("0")), 4); + assert_eq!(configured_worker_count_from(Some("17")), 4); + assert_eq!(configured_worker_count_from(Some("8")), 8); + } +} diff --git a/bridge/src/config.rs b/bridge/src/config.rs index 9feef611..f760f821 100644 --- a/bridge/src/config.rs +++ b/bridge/src/config.rs @@ -33,7 +33,7 @@ use codescribe_core::llm::model_discovery::{ use codescribe_core::llm::provider::{ALL_PROVIDERS, ProviderKind}; use directories::BaseDirs; -use crate::{CsError, CsLanguage}; +use crate::{CsError, CsLanguage, application_runtime}; /// 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 @@ -897,9 +897,11 @@ impl CodescribeConfig { let mut config = account_auth::DeviceAuthConfig::new(provider, client_id); // Await will re-clamp; start uses the full default budget. config.max_wait = std::time::Duration::from_secs(15 * 60); - let device = account_auth_runtime()? - .block_on(account_auth::request_device_code(&config)) - .map_err(account_auth_to_cs)?; + let request_config = config.clone(); + let device = application_runtime::block_on(async move { + account_auth::request_device_code(&request_config).await + })? + .map_err(account_auth_to_cs)?; let auth_url = device.verification_url.clone(); let user_code = device.user_code.clone(); let mut guard = active_account_login().lock().map_err(|_| CsError::Config { @@ -920,8 +922,7 @@ impl CodescribeConfig { account_auth::LoginFlow::Loopback => { let opts = account_auth::ServerOptions::new(provider, client_id) .map_err(account_auth_to_cs)?; - let login = account_auth_runtime()? - .block_on(account_auth::run_login_server(opts)) + let login = application_runtime::block_on(account_auth::run_login_server(opts))? .map_err(account_auth_to_cs)?; let auth_url = login.auth_url.clone(); let mut guard = active_account_login().lock().map_err(|_| CsError::Config { @@ -980,9 +981,9 @@ impl CodescribeConfig { match login { PendingAccountLogin::Loopback(login) => { let cancel = login.cancel_handle(); - let outcome = account_auth_runtime()?.block_on(async move { + let outcome = application_runtime::block_on(async move { tokio::time::timeout(timeout, login.block_until_done()).await - }); + })?; match outcome { Ok(Ok(())) => { let message = account_auth::account_status(provider).message; @@ -1002,13 +1003,13 @@ impl CodescribeConfig { } PendingAccountLogin::Device { mut config, device } => { config.max_wait = timeout; - let outcome = account_auth_runtime()?.block_on(async move { + let outcome = application_runtime::block_on(async move { tokio::time::timeout( timeout, account_auth::complete_device_code_login(&config, &device), ) .await - }); + })?; match outcome { Ok(Ok(())) => { let message = account_auth::account_status(provider).message; @@ -2303,27 +2304,6 @@ fn key_present(account: &str) -> bool { lane_truth::secret(account).is_some() } -/// Process-wide tokio runtime dedicated to the OAuth login server. This bridge -/// slice is otherwise sync, so the runtime is built lazily and kept alive for -/// the process: `start_account_login` and `await_account_login` are separate FFI -/// calls that must share one reactor for the pending callback server to survive -/// between them. A build failure is cached and returned as a config error rather -/// than retried per call. -fn account_auth_runtime() -> Result<&'static tokio::runtime::Runtime, CsError> { - /// Process-wide OAuth login reactor; build errors are cached as strings. - static RUNTIME: OnceLock> = OnceLock::new(); - match RUNTIME.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_name("codescribe-account-auth") - .build() - .map_err(|error| format!("account auth runtime initialization failed: {error}")) - }) { - Ok(runtime) => Ok(runtime), - Err(msg) => Err(CsError::Config { msg: msg.clone() }), - } -} - /// One in-flight account login — either a loopback callback server (OpenAI) /// or an RFC 8628 device-code poll handle (xAI SuperGrok / OpenCode path). enum PendingAccountLogin { @@ -2353,6 +2333,18 @@ fn active_account_login() -> &'static Mutex> { ACTIVE.get_or_init(|| Mutex::new(None)) } +/// Cancel the content-free OAuth/device-code lifecycle before application +/// runtime shutdown. The shared app runtime then owns the only reactor teardown. +pub(crate) fn cancel_pending_account_login_for_shutdown() { + let pending = active_account_login() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + if let Some(pending) = pending { + pending.cancel(); + } +} + /// Flatten an account-auth error into the bridge's config error. The core error /// type stays out of the FFI surface; only its rendered message crosses, and /// that message is already written to be safe to show a user. diff --git a/bridge/src/hotkeys.rs b/bridge/src/hotkeys.rs index a176eb5c..24fa1cf2 100644 --- a/bridge/src/hotkeys.rs +++ b/bridge/src/hotkeys.rs @@ -28,7 +28,7 @@ use crate::agent_delivery::{ use crate::recording::{ CsAnnotationKind, CsLayerSummary, CsTranscription, CsTranscriptionListener, }; -use crate::{CsError, CsLanguage}; +use crate::{CsError, CsLanguage, application_runtime}; /// Shared process-wide slot for the lazily-created `RecordingController`. /// Mutex so the first hotkey/FFI path wins construction; `Option` until first use. @@ -203,6 +203,40 @@ fn current_controller(controller_store: &SharedController) -> Option Result<(), CsError> { + hotkeys::shutdown_global_hotkey_manager(); + let controller = shared_controller() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + let stop_result = match controller { + Some(controller) => application_runtime::block_on(async move { + if matches!( + controller.current_state().await, + State::RecHold | State::RecToggle | State::Conversation + ) { + controller + .stop_recording_from_external_surface() + .await + .map_err(|error| CsError::Recording { + msg: format!("application shutdown could not stop recording: {error}"), + })?; + } + Ok(()) + }) + .and_then(|result| result), + None => Ok(()), + }; + release_capture_ownership_for_shutdown(); + stop_result +} + /// Collapse a latched paste-target app name to `None` when it carries no /// information, so Swift never renders a blank or whitespace-only app label as /// if it were a known target. @@ -335,6 +369,22 @@ fn forward_event_to_listener(payload: IpcEventPayload, listener: Arc match event { EngineEventWire::VadStart { .. } => listener.on_vad_active(true), EngineEventWire::VadEnd { .. } => listener.on_vad_active(false), + EngineEventWire::SidebandEvidence { evidence } => { + // Content-free timing evidence reaches the app boundary for + // diagnostics, but there is deliberately no UI decoration or + // transcript callback in W2-02. L3 already consumes the + // permitted pause-only subset inside core. + tracing::debug!( + sequence = evidence.sequence, + session = %evidence.range.session, + capture_epoch = evidence.range.capture_epoch, + sample_start = evidence.range.sample_start, + sample_end = evidence.range.sample_end, + provenance = ?evidence.provenance, + kind = ?evidence.evidence, + "Silero sideband evidence (non-text)" + ); + } EngineEventWire::NoSpeech { reason } => listener.on_no_speech(reason), EngineEventWire::Preview { text, .. } => listener.on_preview(text), EngineEventWire::Correction { @@ -498,7 +548,7 @@ async fn compensate_orphaned_preparing(controller: &Arc) { #[derive(uniffi::Object, Default)] pub struct CodescribeHotkeys {} -#[uniffi::export(async_runtime = "tokio")] +#[uniffi::export] impl CodescribeHotkeys { /// Construct the hotkey facade and initialise logging. Creates no listener, /// controller or tap — `start()` owns that. @@ -510,78 +560,81 @@ impl CodescribeHotkeys { /// Start or replace the process-global hotkey listener. pub async fn start(&self) -> Result<(), CsError> { - // Install the process-wide macOS thermal observer once at runtime - // bootstrap so STT duty-cycle throttling (core/stt/scheduler.rs) sees - // real thermal pressure. Without this the scheduler always reads - // ThermalLevel::Nominal and never backs off during hot/long sessions. - // Idempotent: install_thermal_probe guards its own observer singleton. - codescribe::os::thermal::install_thermal_probe(); - - // Seed the hotkey detector atomics from persisted config so the - // CGEventTap honours the user's saved mode bindings / cadence from - // launch. The atomics otherwise hold only compile-time defaults, so - // non-default bindings would never take effect. update_config re-applies - // this on every later settings change for live-reload without restart. - codescribe::os::hotkeys::apply_hotkey_config( - &codescribe_core::config::Config::load_without_keychain(), - ); + application_runtime::run(async move { + // Install the process-wide macOS thermal observer once at runtime + // bootstrap so STT duty-cycle throttling (core/stt/scheduler.rs) sees + // real thermal pressure. Without this the scheduler always reads + // ThermalLevel::Nominal and never backs off during hot/long sessions. + // Idempotent: install_thermal_probe guards its own observer singleton. + codescribe::os::thermal::install_thermal_probe(); + + // Seed the hotkey detector atomics from persisted config so the + // CGEventTap honours the user's saved mode bindings / cadence from + // launch. The atomics otherwise hold only compile-time defaults, so + // non-default bindings would never take effect. update_config re-applies + // this on every later settings change for live-reload without restart. + codescribe::os::hotkeys::apply_hotkey_config( + &codescribe_core::config::Config::load_without_keychain(), + ); - let (tx, rx) = unbounded::(); - let handle = tokio::runtime::Handle::current(); - // Publish the runtime handle so sync config-write surfaces can push fresh - // settings into the live controller (refresh_live_controller_config). - let _ = shared_runtime_handle().set(handle.clone()); - // Bridge the app-side voice-assistive delivery broadcast onto the Swift - // AgentChat listener. Idempotent — a repeated start() does not stack a - // second forwarder. The listener itself is registered separately via - // `set_agent_delivery_listener` and may arrive before or after this. - spawn_delivery_forwarder(handle.clone()); - let controller_store = shared_controller(); - - // Spawn the event-dispatch thread BEFORE bringing up the tap. It drains - // `rx` for the lifetime of the retained sender, so it stays ready whether - // the CGEventTap comes up now (permissions already granted) or later via - // `rearm_after_permission_grant` after a first-run TCC grant. If it were - // spawned only after a successful `install_global_hotkey_manager`, a - // permission-less cold start would leave no consumer, and a later re-arm - // would build a live tap whose events pile up in the channel undispatched. - std::thread::spawn(move || { - for event in rx { - let spawn_handle = handle.clone(); - let controller_handle = handle.clone(); - let controller_store = Arc::clone(&controller_store); - route_hotkey_event( - event, - current_app_action_listener(), - move |recording_event| { - spawn_handle.spawn(async move { - let controller = - ensure_controller(&controller_store, controller_handle); - let dispatch = dispatch_recording_with_capture_gate( - recording_event, - Arc::clone(&controller), - ) - .await; - if let Err(error) = dispatch { - tray_status::update_tray_status(TrayStatus::Error); - notifications::notify("Codescribe", &error.to_string()); - eprintln!("Hotkey event error: {error}"); - } - }); - }, - deliver_deferred_insert_and_notify, - ); - } - }); + let (tx, rx) = unbounded::(); + let handle = tokio::runtime::Handle::current(); + // Publish the runtime handle so sync config-write surfaces can push fresh + // settings into the live controller (refresh_live_controller_config). + let _ = shared_runtime_handle().set(handle.clone()); + // Bridge the app-side voice-assistive delivery broadcast onto the Swift + // AgentChat listener. Idempotent — a repeated start() does not stack a + // second forwarder. The listener itself is registered separately via + // `set_agent_delivery_listener` and may arrive before or after this. + spawn_delivery_forwarder(handle.clone()); + let controller_store = shared_controller(); + + // Spawn the event-dispatch thread BEFORE bringing up the tap. It drains + // `rx` for the lifetime of the retained sender, so it stays ready whether + // the CGEventTap comes up now (permissions already granted) or later via + // `rearm_after_permission_grant` after a first-run TCC grant. If it were + // spawned only after a successful `install_global_hotkey_manager`, a + // permission-less cold start would leave no consumer, and a later re-arm + // would build a live tap whose events pile up in the channel undispatched. + std::thread::spawn(move || { + for event in rx { + let spawn_handle = handle.clone(); + let controller_handle = handle.clone(); + let controller_store = Arc::clone(&controller_store); + route_hotkey_event( + event, + current_app_action_listener(), + move |recording_event| { + spawn_handle.spawn(async move { + let controller = + ensure_controller(&controller_store, controller_handle); + let dispatch = dispatch_recording_with_capture_gate( + recording_event, + Arc::clone(&controller), + ) + .await; + if let Err(error) = dispatch { + tray_status::update_tray_status(TrayStatus::Error); + notifications::notify("Codescribe", &error.to_string()); + eprintln!("Hotkey event error: {error}"); + } + }); + }, + deliver_deferred_insert_and_notify, + ); + } + }); - // Bring up the tap. On a permission-less first launch this returns an - // error, but the sender is retained inside the hotkey service so a later - // `rearm_after_permission_grant` can create the tap and feed the - // already-running dispatch thread — no app restart required. - hotkeys::install_global_hotkey_manager(tx.clone()) - .map_err(|msg| CsError::Recording { msg })?; + // Bring up the tap. On a permission-less first launch this returns an + // error, but the sender is retained inside the hotkey service so a later + // `rearm_after_permission_grant` can create the tap and feed the + // already-running dispatch thread — no app restart required. + hotkeys::install_global_hotkey_manager(tx.clone()) + .map_err(|msg| CsError::Recording { msg })?; - Ok(()) + Ok(()) + }) + .await? } /// Register the Swift overlay listener for the shared controller event stream. @@ -612,39 +665,51 @@ impl CodescribeHotkeys { /// local recorder/model setup after app launch so the first user-triggered /// 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}"), - })?; - Ok(()) + application_runtime::run(async move { + 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}"), + })?; + Ok(()) + }) + .await? } /// Start the same toggle recording flow used by the default hotkey. pub async fn start_recording(&self) -> Result<(), CsError> { - start_recording_with_event(HotkeyEvent::ToggleNormal).await + application_runtime::run(async move { + start_recording_with_event(HotkeyEvent::ToggleNormal).await + }) + .await? } /// 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> { - start_recording_with_event(HotkeyEvent::ToggleAssistive).await + application_runtime::run(async move { + start_recording_with_event(HotkeyEvent::ToggleAssistive).await + }) + .await? } /// 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 + application_runtime::run( + async move { crate::recording::transcribe_session_file(path).await }, + ) + .await? } /// Stable path of the last retained session WAV, if it exists. @@ -654,15 +719,18 @@ impl CodescribeHotkeys { /// Stop the active legacy-controller recording flow, if one is live. pub async fn stop_recording(&self) -> Result<(), CsError> { - let Some(controller) = current_controller(&shared_controller()) else { - return Ok(()); - }; - controller - .stop_recording_from_external_surface() - .await - .map_err(|error| CsError::Recording { - msg: error.to_string(), - }) + application_runtime::run(async move { + let Some(controller) = current_controller(&shared_controller()) else { + return Ok(()); + }; + controller + .stop_recording_from_external_surface() + .await + .map_err(|error| CsError::Recording { + msg: error.to_string(), + }) + }) + .await? } /// Forward a macOS sleep/wake boundary to the active recorder, if any. @@ -671,23 +739,43 @@ impl CodescribeHotkeys { /// 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 + match application_runtime::run(async move { + let Some(controller) = current_controller(&shared_controller()) else { + return false; + }; + controller.note_sleep_wake().await + }) + .await + { + Ok(reached) => reached, + Err(error) => { + tracing::error!(%error, "sleep/wake runtime dispatch failed"); + false + } + } } /// 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 { - return false; - }; - matches!( - controller.current_state().await, - codescribe::controller::State::RecHold - | codescribe::controller::State::RecToggle - | codescribe::controller::State::Conversation - ) + match application_runtime::run(async move { + let Some(controller) = current_controller(&shared_controller()) else { + return false; + }; + matches!( + controller.current_state().await, + codescribe::controller::State::RecHold + | codescribe::controller::State::RecToggle + | codescribe::controller::State::Conversation + ) + }) + .await + { + Ok(recording) => recording, + Err(error) => { + tracing::error!(%error, "recording-state runtime dispatch failed"); + false + } + } } /// True when the configured formatting provider can handle a user-triggered @@ -702,19 +790,22 @@ impl CodescribeHotkeys { text: String, language: Option, ) -> Result { - let language = language.map(|l| l.as_code().to_string()); - let result = codescribe::ai_formatting::format_text_with_status( - &text, - language.as_deref(), - false, - None, - ) - .await; - if result.text.trim().is_empty() { - Ok(text) - } else { - Ok(result.text) - } + application_runtime::run(async move { + let language = language.map(|l| l.as_code().to_string()); + let result = codescribe::ai_formatting::format_text_with_status( + &text, + language.as_deref(), + false, + None, + ) + .await; + if result.text.trim().is_empty() { + Ok(text) + } else { + Ok(result.text) + } + }) + .await? } /// Format overlay text through an explicitly selected one-shot level @@ -726,86 +817,115 @@ impl CodescribeHotkeys { language: Option, level: String, ) -> Result { - let policy = FormattingPolicy::parse(&level).map_err(|error| CsError::Config { - msg: error.to_string(), - })?; - if policy == FormattingPolicy::Off { - return Err(CsError::Config { - msg: "manual format level cannot be 'off'".to_string(), - }); - } - let language = language.map(|l| l.as_code().to_string()); - let result = codescribe::ai_formatting::format_text_with_status_for_policy( - &text, - language.as_deref(), - policy, - ) - .await; - if result.text.trim().is_empty() { - Ok(text) - } else { - Ok(result.text) - } + application_runtime::run(async move { + let policy = FormattingPolicy::parse(&level).map_err(|error| CsError::Config { + msg: error.to_string(), + })?; + if policy == FormattingPolicy::Off { + return Err(CsError::Config { + msg: "manual format level cannot be 'off'".to_string(), + }); + } + let language = language.map(|l| l.as_code().to_string()); + let result = codescribe::ai_formatting::format_text_with_status_for_policy( + &text, + language.as_deref(), + policy, + ) + .await; + if result.text.trim().is_empty() { + Ok(text) + } else { + Ok(result.text) + } + }) + .await? } /// Paste edited overlay text back into the app that was frontmost before the /// overlay. The result includes delivery truth and the app names observed /// at the exact delivery boundary so Swift can explain every degradation. pub async fn paste_text(&self, text: String) -> Result { - let controller = ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); - controller - .paste_text_from_overlay(text) - .await - .map(CsPasteResult::from) - .map_err(|error| CsError::Recording { - msg: error.to_string(), - }) + application_runtime::run(async move { + let controller = + ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); + controller + .paste_text_from_overlay(text) + .await + .map(CsPasteResult::from) + .map_err(|error| CsError::Recording { + msg: error.to_string(), + }) + }) + .await? } /// Arm an edited overlay transcript directly when Swift knows the caret is /// still inside Codescribe. The controller owns tagging and the W1-A copy /// fallback when Paste Here registration is unavailable. pub async fn defer_text(&self, text: String) -> Result { - let controller = ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); - controller - .defer_text_from_overlay(text) - .await - .map(CsPasteResult::from) - .map_err(|error| CsError::Recording { - msg: error.to_string(), - }) + application_runtime::run(async move { + let controller = + ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); + controller + .defer_text_from_overlay(text) + .await + .map(CsPasteResult::from) + .map_err(|error| CsError::Recording { + msg: error.to_string(), + }) + }) + .await? } /// Copy the tagged transcript to the clipboard without a synthetic paste. /// Swift calls this when the caret already sits inside Codescribe, where a /// synthetic Cmd+V would paste the transcript back into the overlay itself. pub async fn copy_text_tagged(&self, text: String) -> Result<(), CsError> { - let controller = ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); - controller - .copy_text_from_overlay(text) - .await - .map_err(|error| CsError::Recording { - msg: error.to_string(), - }) + application_runtime::run(async move { + let controller = + ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); + controller + .copy_text_from_overlay(text) + .await + .map_err(|error| CsError::Recording { + msg: error.to_string(), + }) + }) + .await? } /// Name of the app latched for the current overlay session, if known. /// Read-only: the paste path keeps owning target activation and delivery. pub async fn paste_target_app_name(&self) -> Option { - let controller = current_controller(&shared_controller())?; - normalize_paste_target_app_name(controller.paste_target_app_name().await) + match application_runtime::run(async move { + let controller = current_controller(&shared_controller())?; + normalize_paste_target_app_name(controller.paste_target_app_name().await) + }) + .await + { + Ok(target) => target, + Err(error) => { + tracing::error!(%error, "paste-target runtime dispatch failed"); + None + } + } } /// Deliver an assistive transcript from the editable overlay. The /// controller attaches the trigger-time selection and accepts this once. pub async fn send_assistive_transcript(&self, text: String) -> Result { - let controller = ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); - controller - .deliver_pending_assistive_transcript(text) - .await - .map_err(|error| CsError::Recording { - msg: error.to_string(), - }) + application_runtime::run(async move { + let controller = + ensure_controller(&shared_controller(), tokio::runtime::Handle::current()); + controller + .deliver_pending_assistive_transcript(text) + .await + .map_err(|error| CsError::Recording { + msg: error.to_string(), + }) + }) + .await? } /// Stop the global hotkey listener if it is active. @@ -1034,6 +1154,24 @@ async fn dispatch_recording_hotkey_event( Ok(()) } +#[cfg(test)] +mod application_shutdown_tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn shutdown_releases_controller_capture_ownership() { + CAPTURE_OWNER.store(CAPTURE_OWNER_CONTROLLER, Ordering::SeqCst); + release_capture_ownership_for_shutdown(); + assert_eq!( + CAPTURE_OWNER.load(Ordering::SeqCst), + CAPTURE_OWNER_NONE, + "application shutdown must never leave microphone ownership latched" + ); + } +} + /// One-shot manual format levels: unknown levels and `off` are rejected (a /// manual action must act), while legacy aliases still normalize through the /// same `FormattingPolicy` owner. diff --git a/bridge/src/lib.rs b/bridge/src/lib.rs index 489c8141..2b06176c 100644 --- a/bridge/src/lib.rs +++ b/bridge/src/lib.rs @@ -24,6 +24,8 @@ mod agent; mod agent_delivery; /// Read-only agent readiness and MCP status. mod agent_status; +/// Process-owned async runtime and lifecycle evidence. +mod application_runtime; /// Settings, prompts, keychain, and onboarding config. mod config; /// Global hotkey registration and app-action callbacks. @@ -45,6 +47,7 @@ mod tray_status; pub use agent::{CodescribeAgent, CsAgentListener}; pub use agent_delivery::CsAgentDeliveryListener; +pub use application_runtime::CsApplicationRuntimeSnapshot; pub use hotkeys::CodescribeHotkeys; pub use hotkeys::CsAppActionListener; pub use licensing::{CsLicenseState, CsLicenseStatus}; @@ -70,6 +73,7 @@ pub enum CsError { Recording { msg: String }, License { msg: String }, Quality { msg: String }, + Runtime { msg: String }, } impl std::fmt::Display for CsError { @@ -80,7 +84,8 @@ impl std::fmt::Display for CsError { | CsError::Config { msg } | CsError::Recording { msg } | CsError::License { msg } - | CsError::Quality { msg } => { + | CsError::Quality { msg } + | CsError::Runtime { msg } => { write!(f, "{msg}") } } @@ -89,6 +94,27 @@ impl std::fmt::Display for CsError { impl std::error::Error for CsError {} +/// Start the one process-owned async runtime. Idempotent while running; once +/// shut down it cannot be restarted in the same process. +#[uniffi::export] +pub fn start_application_runtime() -> Result { + application_runtime::start() +} + +/// Content-free lifecycle snapshot used by diagnostics and delivery probes. +#[uniffi::export] +pub fn application_runtime_snapshot() -> Result { + application_runtime::snapshot() +} + +/// Stop controller/account activity first, then tear down every runtime worker. +#[uniffi::export] +pub fn shutdown_application_runtime() -> Result { + config::cancel_pending_account_login_for_shutdown(); + hotkeys::shutdown_application_controller()?; + application_runtime::shutdown() +} + impl From for CsError { /// Map `anyhow` failures onto the Agent error variant by default. fn from(error: anyhow::Error) -> Self { diff --git a/bridge/src/quality.rs b/bridge/src/quality.rs index 8ac23889..5cc5f246 100644 --- a/bridge/src/quality.rs +++ b/bridge/src/quality.rs @@ -13,7 +13,7 @@ use codescribe_core::pipeline::highlight::{ }; use codescribe_core::quality::overlay_quality::{ CustomLexiconEntry, DictionaryTeachResult, OverlayCorrectionCommit, QualityRecord, - VoiceLabSaveOutcome, commit_overlay_correction_with_confidence, custom_lexicon_entries, + VoiceLabSaveOutcome, commit_overlay_correction_with_provenance, custom_lexicon_entries, finalize_voice_lab_correction, recent_quality_records, teach_dictionary_from_store, teach_span, }; @@ -24,19 +24,26 @@ use crate::CsError; pub struct CsQualityCommitResult { /// Lexicon pairs actually upserted (0 when evidence-only or filtered out). pub pairs_learned: u32, - /// True when the formatting level is not Correction. + /// True when no custom-lexicon pair was learned: non-teach evidence, + /// filtered edits, or an explicit teach still below its N-correction gate. pub evidence_only: bool, /// Ready-to-show overlay toast text ("Saved — N pair(s) learned" / "Saved as evidence"). pub acknowledgement: String, + /// Structured progress for one normalized lexical pair. + pub teach_seen: Option, + pub teach_required: Option, } impl From for CsQualityCommitResult { /// Core commit → toast payload (pairs, evidence flag, ready acknowledgement). fn from(commit: OverlayCorrectionCommit) -> Self { + let progress = commit.confirmation_progress(); Self { pairs_learned: commit.pairs_learned, evidence_only: commit.evidence_only, acknowledgement: commit.acknowledgement_message(), + teach_seen: progress.map(|value| value.0), + teach_required: progress.map(|value| value.1), } } } @@ -50,6 +57,7 @@ pub struct CsQualityRecord { pub variant: String, pub edited_text: String, pub action: String, + pub edit_provenance: Option, pub timestamp_ms: u64, pub avg_logprob: Option, pub speech_pct: Option, @@ -65,6 +73,11 @@ impl From for CsQualityRecord { .and_then(serde_json::Value::as_str) .unwrap_or("unknown") .to_string(); + let edit_provenance = record + .meta + .get("edit_provenance") + .and_then(serde_json::Value::as_str) + .map(str::to_string); Self { id: record.logical_id(), revision: record.revision, @@ -72,6 +85,7 @@ impl From for CsQualityRecord { variant: record.delivered_text, edited_text: record.edited_text, action, + edit_provenance, timestamp_ms: record.timestamp_ms, avg_logprob: record.avg_logprob, speech_pct: record.speech_pct, @@ -145,12 +159,14 @@ pub struct CsTokenConfidence { } /// Persist one overlay correction: the quality record always lands, while lexicon -/// learning is gated by `formatting_level`. +/// learning is gated by explicit teach action plus the N-correction threshold. /// -/// Only the Correction level teaches word pairs; higher levels are recorded as -/// evidence with `pairs_learned = 0`, because a creative rewrite is not a -/// transcription fix and would poison the lexicon. An unrecognised -/// `formatting_level` is rejected before anything is written. +/// Only explicit Correction-level teach gestures can contribute word pairs; +/// higher levels are recorded as evidence with `pairs_learned = 0`, because a +/// creative rewrite is not a transcription fix and would poison the lexicon. +/// A qualifying teach remains evidence until its identical pair reaches the +/// configured threshold. An unrecognised `formatting_level` is rejected before +/// anything is written. /// /// The confidence fields (`avg_logprob`, `speech_pct`, `confidence_flags`) are /// stored alongside the text so later analysis can correlate corrections with how @@ -163,13 +179,14 @@ pub fn commit_overlay_quality_record( edited_text: String, action: String, formatting_level: String, + edit_provenance: Option, avg_logprob: Option, speech_pct: Option, confidence_flags: Vec, ) -> Result { // Delegate to core. Model/mode are best-effort for MVP (overlay always). // action carried for meta (over-correct for P2-03: "captureQualityIfEdited gubi action"). - commit_overlay_correction_with_confidence( + commit_overlay_correction_with_provenance( &raw_text, &delivered_text, &edited_text, @@ -177,6 +194,7 @@ pub fn commit_overlay_quality_record( None, Some(&action), Some(&formatting_level), + edit_provenance.as_deref(), avg_logprob, speech_pct, confidence_flags, @@ -336,7 +354,10 @@ mod tests { avg_logprob: Some(-0.5), speech_pct: Some(0.8), confidence_flags: vec!["low_logprob".into()], - meta: serde_json::json!({ "action": "copy" }), + meta: serde_json::json!({ + "action": "copy", + "edit_provenance": "manual_human" + }), }; assert_eq!( @@ -348,6 +369,7 @@ mod tests { variant: "delivered".into(), edited_text: "edited".into(), action: "copy".into(), + edit_provenance: Some("manual_human".into()), timestamp_ms: 42, avg_logprob: Some(-0.5), speech_pct: Some(0.8), @@ -383,6 +405,7 @@ mod tests { "synthetic canonical".into(), "copy".into(), "creative".into(), + None, Some(-1.2), Some(0.75), vec!["test_flag".into()], @@ -424,6 +447,7 @@ mod tests { "mystery".into(), None, None, + None, vec![], ) .expect_err("unknown level must be rejected"); diff --git a/bridge/src/recording.rs b/bridge/src/recording.rs index a7c2b705..9ca7675c 100644 --- a/bridge/src/recording.rs +++ b/bridge/src/recording.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use codescribe_core::pipeline::contracts::{AnnotationKind, LayerSource, LayerSummary}; use cpal::traits::{DeviceTrait, HostTrait}; -use crate::CsError; +use crate::{CsError, application_runtime}; /// Result of a one-shot file transcription. #[derive(uniffi::Record)] @@ -78,33 +78,37 @@ pub fn whisper_model_status() -> CsWhisperModelStatus { } /// Download the default Whisper model (idempotent if already complete). -#[uniffi::export(async_runtime = "tokio")] +#[uniffi::export] pub async fn download_whisper_model( listener: Option>, ) -> Result { - tokio::task::spawn_blocking(move || { - let path = - codescribe_core::config::models::download_default_whisper_model(|file, done, total| { - if let Some(ref listener) = listener { - listener.on_progress( - file.to_string(), - done, - total.map(|t| t as i64).unwrap_or(-1), - ); - } - }) + application_runtime::run(async move { + tokio::task::spawn_blocking(move || { + let path = codescribe_core::config::models::download_default_whisper_model( + |file, done, total| { + if let Some(ref listener) = listener { + listener.on_progress( + file.to_string(), + done, + total.map(|t| t as i64).unwrap_or(-1), + ); + } + }, + ) .map_err(|e| CsError::Recording { msg: e.to_string() })?; - if let Some(ref listener) = listener { - listener.on_complete(path.display().to_string()); - } - Ok(CsWhisperModelStatus::from( - codescribe_core::config::models::whisper_model_status(), - )) + if let Some(ref listener) = listener { + listener.on_complete(path.display().to_string()); + } + Ok(CsWhisperModelStatus::from( + codescribe_core::config::models::whisper_model_status(), + )) + }) + .await + .map_err(|e| CsError::Recording { + msg: format!("download_whisper_model join error: {e}"), + })? }) - .await - .map_err(|e| CsError::Recording { - msg: format!("download_whisper_model join error: {e}"), - })? + .await? } /// Trim a device name and collapse blank/whitespace-only values to `None`, so an diff --git a/core/asr_session/bootstrap.rs b/core/asr_session/bootstrap.rs index 447447d3..bc8d5e9e 100644 --- a/core/asr_session/bootstrap.rs +++ b/core/asr_session/bootstrap.rs @@ -14,8 +14,9 @@ use super::cloud::{ CloudSessionLimits, GatewayConnection, GatewayWebSocketTransport, LiveCloudAsrSession, }; use super::consent::authorize_cloud_egress; -use super::recorder::Layer1Decision; +use super::recorder::{Layer1Decision, LocalTailPatchDisposition}; use crate::config::{AsrProductMode, Config, UserSettings}; +use crate::stt::tail_patcher::{LAYERED_TRANSCRIPTION_ENV, layered_phase_from_raw}; /// Availability of one validated live session at recording start. /// @@ -104,10 +105,29 @@ impl fmt::Debug for GatewaySessionAvailability { pub fn layer1_decision_for_recording( settings: &UserSettings, gateway: GatewaySessionAvailability, +) -> Layer1Decision { + let layered_raw = std::env::var(LAYERED_TRANSCRIPTION_ENV).ok(); + layer1_decision_for_recording_with_layered_raw(settings, gateway, layered_raw.as_deref()) +} + +/// Pure override seam for the recording-start refinement decision. +/// +/// `LocalPower` means Apple-first plus local Whisper patching even when the +/// compatibility env key is absent. Explicit `phase1`..`phase4` remains an +/// armed token. Explicit hard-off and malformed values are named degraded +/// states, so Local power can never masquerade as healthy Apple-only. +pub fn layer1_decision_for_recording_with_layered_raw( + settings: &UserSettings, + gateway: GatewaySessionAvailability, + layered_raw: Option<&str>, ) -> Layer1Decision { let resolved = settings.resolved_asr_mode(); - if resolved.mode != AsrProductMode::Cloud { - return Layer1Decision::Disarmed; + match resolved.mode { + AsrProductMode::LocalPower => { + return Layer1Decision::LocalTailPatch(local_tail_patch_disposition(layered_raw)); + } + AsrProductMode::AppleOnly => return Layer1Decision::Disarmed, + AsrProductMode::Cloud => {} } let Ok(authorization) = authorize_cloud_egress(&resolved.consent) else { @@ -132,6 +152,23 @@ pub fn layer1_decision_for_recording( Layer1Decision::Armed(Box::new(session)) } +fn local_tail_patch_disposition(raw: Option<&str>) -> LocalTailPatchDisposition { + match raw { + None => LocalTailPatchDisposition::ArmedDefault, + Some(value) => match layered_phase_from_raw(Some(value)) { + Some(phase) => LocalTailPatchDisposition::ArmedPhase(phase), + None if matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "off" | "0" | "false" | "no" + ) => + { + LocalTailPatchDisposition::DegradedExplicitOff + } + None => LocalTailPatchDisposition::DegradedInvalidOverride, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -178,7 +215,7 @@ mod tests { } #[test] - fn cloud_failure_and_explicit_local_mode_never_load_an_in_process_model() { + fn local_power_arms_tail_patch_with_final_pass_off_without_loading_during_policy_resolution() { let probe = || { crate::stt::whisper::singleton::test_init_calls() + crate::stt::whisper::singleton::test_load_calls() @@ -192,19 +229,64 @@ mod tests { let local_power = layer1_decision_for_recording( &UserSettings { asr_mode: Some("local_power".to_string()), + final_pass_mode: Some("off".to_string()), ..UserSettings::default() }, ready(), ); assert!(!offline.is_armed()); - assert!( - !local_power.is_armed(), - "L0 owns the explicit helper provider" + assert!(local_power.is_armed()); + assert_eq!( + local_power.local_tail_patch_disposition(), + Some(LocalTailPatchDisposition::ArmedDefault) ); assert_eq!(probe().saturating_sub(before), 0); } + #[test] + fn local_power_explicit_phase_arms_and_hard_off_is_degraded() { + let settings = UserSettings { + asr_mode: Some("local_power".to_string()), + ..UserSettings::default() + }; + let phase = layer1_decision_for_recording_with_layered_raw( + &settings, + GatewaySessionAvailability::Unavailable, + Some("phase1"), + ); + assert_eq!( + phase.local_tail_patch_disposition(), + Some(LocalTailPatchDisposition::ArmedPhase(1)) + ); + + let off = layer1_decision_for_recording_with_layered_raw( + &settings, + GatewaySessionAvailability::Unavailable, + Some("off"), + ); + assert!(!off.is_armed()); + assert_eq!( + off.local_tail_patch_disposition(), + Some(LocalTailPatchDisposition::DegradedExplicitOff) + ); + } + + #[test] + fn apple_only_never_arms_local_tail_patch() { + let settings = UserSettings { + asr_mode: Some("apple_only".to_string()), + ..UserSettings::default() + }; + let decision = layer1_decision_for_recording_with_layered_raw( + &settings, + GatewaySessionAvailability::Unavailable, + Some("phase1"), + ); + assert!(!decision.is_armed()); + assert_eq!(decision.local_tail_patch_disposition(), None); + } + #[test] fn public_file_url_is_not_a_live_socket() { assert_eq!( diff --git a/core/asr_session/cloud.rs b/core/asr_session/cloud.rs index b8129532..41b82114 100644 --- a/core/asr_session/cloud.rs +++ b/core/asr_session/cloud.rs @@ -122,7 +122,7 @@ impl GatewayConnection { 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)) + if !(encrypted || parsed.scheme() == "ws" && loopback) || !parsed.username().is_empty() || parsed.password().is_some() || (auth_mode != crate::stt::tail_provider::SttAuthMode::Unauthenticated diff --git a/core/asr_session/mod.rs b/core/asr_session/mod.rs index 84b451d1..db794b8c 100644 --- a/core/asr_session/mod.rs +++ b/core/asr_session/mod.rs @@ -76,6 +76,7 @@ mod tests; pub use bootstrap::{ GatewaySessionAvailability, gateway_session_availability, layer1_decision_for_recording, + layer1_decision_for_recording_with_layered_raw, }; pub use cloud::{ CloudGatewayTransport, CloudSessionLimits, CloudSessionTelemetry, GatewayConnection, @@ -98,7 +99,7 @@ pub use local_helper::{ 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, + Layer1LaneState, Layer1LaneTelemetry, Layer1SessionOutcome, LocalTailPatchDisposition, + RecorderLayer1Lane, RecorderLifecycleEvent, RecorderLifecycleEvents, RecorderLifecycleHandle, apply_recorder_lifecycle_event, recorder_lifecycle_channel, }; diff --git a/core/asr_session/recorder.rs b/core/asr_session/recorder.rs index bb731f16..26f706c0 100644 --- a/core/asr_session/recorder.rs +++ b/core/asr_session/recorder.rs @@ -6,8 +6,8 @@ //! //! - **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. +//! consent/settings owner. The decision distinguishes Apple-only, local +//! exact-span Whisper, and a generic injected provider. //! - **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 @@ -17,8 +17,9 @@ //! 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. +//! session outcome routes through [`crate::quality::merge_live_layer1`]. A +//! generic full-session candidate remains evidence until it has exact span +//! identity; the Apple path owns the bounded rewrite fence. //! - **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 — @@ -123,6 +124,10 @@ pub fn recorder_lifecycle_channel() -> (RecorderLifecycleHandle, RecorderLifecyc pub enum Layer1Decision { /// No Layer 1 refiner for this recording. Canvas plus lexicon, complete. Disarmed, + /// Local Whisper owns bounded, PCM-identified tail patches for this + /// recording. This is deliberately a recording-start decision, not a + /// second environment read inside the Apple session. + LocalTailPatch(LocalTailPatchDisposition), /// An already-authorized provider, ready to open. Armed(Box), } @@ -130,12 +135,71 @@ pub enum Layer1Decision { impl Layer1Decision { /// Whether this decision carries a provider. pub fn is_armed(&self) -> bool { + matches!( + self, + Self::Armed(_) + | Self::LocalTailPatch( + LocalTailPatchDisposition::ArmedDefault + | LocalTailPatchDisposition::ArmedPhase(_) + ) + ) + } + + /// Whether the generic provider fan-out lane (Cloud) is armed. + pub fn is_provider_armed(&self) -> bool { matches!(self, Self::Armed(_)) } + + /// Recording-start local tail-patch disposition, when local power was the + /// selected product mode. + pub fn local_tail_patch_disposition(&self) -> Option { + match self { + Self::LocalTailPatch(disposition) => Some(*disposition), + Self::Disarmed | Self::Armed(_) => None, + } + } +} + +/// Why the local Whisper tail-patch lane is armed or degraded for one take. +/// +/// This is intentionally distinct from the generic provider lane: Cloud owns +/// provider fan-out, while Local power owns exact-span Whisper jobs. Both are +/// resolved once at recording start and carried through [`Layer1Decision`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalTailPatchDisposition { + /// The product mode does not request local Whisper. + NotApplicable, + /// Local power's product default: Apple live plus local Whisper patches. + ArmedDefault, + /// Explicit `phase1`..`phase4` compatibility token armed the same lane. + ArmedPhase(u8), + /// Local power was selected but an explicit hard-off token disabled its + /// required patcher. This is degraded, never a healthy Apple-only state. + DegradedExplicitOff, + /// Local power was selected but the override token was not understood. + DegradedInvalidOverride, +} + +impl LocalTailPatchDisposition { + /// Whether the Apple session must construct the local tail-patch lane. + pub fn is_armed(self) -> bool { + matches!(self, Self::ArmedDefault | Self::ArmedPhase(_)) + } + + /// Stable content-free token for logs and receipts. + pub fn as_token(self) -> &'static str { + match self { + Self::NotApplicable => "not_applicable", + Self::ArmedDefault => "armed_default", + Self::ArmedPhase(_) => "armed_phase", + Self::DegradedExplicitOff => "degraded_explicit_off", + Self::DegradedInvalidOverride => "degraded_invalid_override", + } + } } impl Default for Layer1Decision { - /// The stock product decision: no Layer 1. + /// Safe fallback decision: no Layer 1. fn default() -> Self { Self::Disarmed } @@ -146,6 +210,10 @@ impl fmt::Debug for Layer1Decision { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Disarmed => f.write_str("Layer1Decision::Disarmed"), + Self::LocalTailPatch(disposition) => f + .debug_struct("Layer1Decision::LocalTailPatch") + .field("disposition", disposition) + .finish(), Self::Armed(provider) => f .debug_struct("Layer1Decision::Armed") .field("mode", &provider.mode().as_token()) @@ -343,7 +411,7 @@ impl RecorderLayer1Lane { degrade_notice: None, }; match decision { - Layer1Decision::Disarmed => lane, + Layer1Decision::Disarmed | Layer1Decision::LocalTailPatch(_) => lane, Layer1Decision::Armed(mut provider) => { match provider.open(input) { Ok(()) => { diff --git a/core/audio/chunker.rs b/core/audio/chunker.rs index 65a72a8c..0e608fdd 100644 --- a/core/audio/chunker.rs +++ b/core/audio/chunker.rs @@ -149,6 +149,25 @@ pub(crate) struct VadErrorStats { pub total_unavailable_frames: u64, } +/// Exact Silero boundary mapped back onto the capture PCM clock. +/// +/// This stays in the audio layer so the chunker does not depend on pipeline +/// event contracts. The streaming Silero fusion layer turns it into typed +/// sideband evidence at the one-VAD session boundary. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct VadBoundaryEvidence { + pub kind: VadBoundaryKind, + pub sample: u64, + pub speech_probability: f32, +} + +/// Which hysteresis edge produced a [`VadBoundaryEvidence`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VadBoundaryKind { + SpeechStart, + SpeechEnd, +} + // ═══════════════════════════════════════════════════════════ // SpeechSession // ═══════════════════════════════════════════════════════════ @@ -208,6 +227,9 @@ pub(crate) struct SpeechSession { segment_peak_prob: f32, /// Speech probability at the last VAD boundary (Start or End). last_boundary_prob: f32, + /// Boundary observations waiting for the session-level Silero ingress. + /// FIFO order is the PCM order in which the iterator produced them. + vad_boundaries: VecDeque, /// Wall-clock instant when this session was created. session_start: Instant, /// Total number of Silero predict() errors in this session. @@ -315,6 +337,7 @@ impl SpeechSession { max_speech_prob: 0.0, segment_peak_prob: 0.0, last_boundary_prob: 0.0, + vad_boundaries: VecDeque::new(), session_start: Instant::now(), vad_predict_errors_total: 0, vad_predict_errors_pending: 0, @@ -443,6 +466,7 @@ impl SpeechSession { max_speech_prob: 0.0, segment_peak_prob: 0.0, last_boundary_prob: 0.0, + vad_boundaries: VecDeque::new(), session_start: Instant::now(), vad_predict_errors_total: 0, vad_predict_errors_pending: 0, @@ -588,20 +612,27 @@ impl SpeechSession { } if let Some(start_sample) = start_event { - let raw_start = self - .vad_to_raw_index(start_sample) - .saturating_sub(self.pre_roll_raw); - self.segment_start = Some(raw_start); - self.last_emit_raw = raw_start; + let raw_boundary = self.vad_to_raw_index(start_sample); + self.vad_boundaries.push_back(VadBoundaryEvidence { + kind: VadBoundaryKind::SpeechStart, + sample: raw_boundary as u64, + speech_probability: speech_prob, + }); + let padded_start = raw_boundary.saturating_sub(self.pre_roll_raw); + self.segment_start = Some(padded_start); + self.last_emit_raw = padded_start; self.last_boundary_prob = speech_prob; self.segment_peak_prob = speech_prob; } if let Some(end_sample) = end_event { - let raw_end = self - .vad_to_raw_index(end_sample) - .saturating_add(self.speech_pad_raw); - self.pending_end = Some(raw_end); + let raw_boundary = self.vad_to_raw_index(end_sample); + self.vad_boundaries.push_back(VadBoundaryEvidence { + kind: VadBoundaryKind::SpeechEnd, + sample: raw_boundary as u64, + speech_probability: speech_prob, + }); + self.pending_end = Some(raw_boundary.saturating_add(self.speech_pad_raw)); self.last_boundary_prob = speech_prob; } @@ -752,6 +783,14 @@ impl SpeechSession { Some((start as u64, self.raw_cursor as u64)) } + /// Drain exact Silero start/end observations in production order. + /// + /// Reading these observations never gates PCM. An empty vector therefore + /// means "no sideband evidence", not "drop or delay audio". + pub(crate) fn take_vad_boundaries(&mut self) -> Vec { + self.vad_boundaries.drain(..).collect() + } + /// Close the session and emit whatever is still open. /// /// Recording usually stops mid-segment, so an open Supervisor segment is @@ -1631,6 +1670,31 @@ mod tests { use super::*; use serial_test::serial; + #[test] + fn vad_boundary_evidence_drains_once_in_pcm_order() { + let mut session = SpeechSession::new_utterance(16_000); + session.vad_boundaries.extend([ + VadBoundaryEvidence { + kind: VadBoundaryKind::SpeechStart, + sample: 512, + speech_probability: 0.91, + }, + VadBoundaryEvidence { + kind: VadBoundaryKind::SpeechEnd, + sample: 16_384, + speech_probability: 0.08, + }, + ]); + + let drained = session.take_vad_boundaries(); + assert_eq!(drained.len(), 2); + assert_eq!(drained[0].kind, VadBoundaryKind::SpeechStart); + assert_eq!(drained[0].sample, 512); + assert_eq!(drained[1].kind, VadBoundaryKind::SpeechEnd); + assert_eq!(drained[1].sample, 16_384); + assert!(session.take_vad_boundaries().is_empty()); + } + /// RAII guard that restores an env var on drop. /// /// The gate config reads `CODESCRIBE_BUFFERED_*` at construction, so these diff --git a/core/audio/streaming_recorder.rs b/core/audio/streaming_recorder.rs index ea34b58e..c50b52a5 100644 --- a/core/audio/streaming_recorder.rs +++ b/core/audio/streaming_recorder.rs @@ -20,8 +20,8 @@ use crate::audio::recorder::{Recorder, RecorderConfig}; use crate::config::UserSettings; use crate::pipeline::contracts::{EngineEvent, EventSink}; use crate::pipeline::streaming::{ - SessionConfig, collect_buffered_engine_events_with_config, stream_log_path, - transcription_session, + SessionConfig, TailPatchSessionReceipt, collect_buffered_engine_events_with_config, + stream_log_path, transcription_session, }; use anyhow::{Context, Result, anyhow}; use std::sync::Arc; @@ -46,6 +46,9 @@ pub struct ProductionSessionReplay { pub layer1_armed: bool, /// Engine that actually owned the live canvas for this replay session. pub streaming_engine_label: String, + /// Typed local tail-patch arming and bounded-drain evidence emitted by the + /// production session, when that session reached finality. + pub tail_patch_receipt: Option, } /// Resolve the production Layer 1 decision for one recording. @@ -110,10 +113,12 @@ pub async fn replay_production_session( None, ); let events = collect_buffered_engine_events_with_config(samples, config).await?; + let tail_patch_receipt = TailPatchSessionReceipt::from_events(&events); Ok(ProductionSessionReplay { events, layer1_armed, streaming_engine_label, + tail_patch_receipt, }) } diff --git a/core/config/default_env.txt b/core/config/default_env.txt index e6f42455..f375a51f 100644 --- a/core/config/default_env.txt +++ b/core/config/default_env.txt @@ -37,6 +37,9 @@ HOLD_BADGE_OFFSET_Y=-10 # CODESCRIBE_TRANSCRIPT_BUS_PATH= # Unified runtime pipeline (event-based) is always active. +# The Swift host starts one named Tokio runtime before constructing async bridge +# surfaces. This value is read once; changing it requires an app restart. +CODESCRIBE_APP_RUNTIME_WORKERS=4 # Golden low-latency / high-fidelity defaults for live preview: CODESCRIBE_STREAM_CHUNK_SEC=3.0 CODESCRIBE_STREAM_OVERLAP_RATIO=0.2 diff --git a/core/config/settings.rs b/core/config/settings.rs index 8e14f1f0..4d4f1c49 100644 --- a/core/config/settings.rs +++ b/core/config/settings.rs @@ -252,8 +252,9 @@ 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`. Absent matches the core - /// default (`unset` → phase1). Explicit "off" / "0" / "false" disarms. + /// Seeds `CODESCRIBE_LAYERED_TRANSCRIPTION`. In Local Power, absent means + /// the required Apple-first patcher default is armed; explicit `off` is a + /// named degraded override. `phase1` remains a compatibility token. #[serde(skip_serializing_if = "Option::is_none")] pub layered_transcription: Option, /// Opt-in Whisper `initial_prompt` vocabulary hint. diff --git a/core/ipc/types.rs b/core/ipc/types.rs index 30a409c9..b44af5ae 100644 --- a/core/ipc/types.rs +++ b/core/ipc/types.rs @@ -11,8 +11,8 @@ use serde::{Deserialize, Serialize}; use crate::pipeline::contracts::{ - AnnotationKind, DropKind, EngineEvent, LayerSource, LayerSummary, TranscriptSegment, - TranscriptionConfidenceFlag, + AnnotationKind, DropKind, EngineEvent, LayerSource, LayerSummary, SidebandEvidence, + TranscriptSegment, TranscriptionConfidenceFlag, }; /// One timestamped envelope on the IPC stream. The payload is flattened, so a @@ -78,6 +78,9 @@ pub enum EngineEventWire { speech_prob: f32, ts_ms: u64, }, + SidebandEvidence { + evidence: SidebandEvidence, + }, NoSpeech { reason: String, }, @@ -101,6 +104,8 @@ pub enum EngineEventWire { compression_ratio: Option, quality_gate_dropped: bool, confidence_flags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + acoustic: Option, }, ReplaceRange { utterance_id: u64, @@ -157,6 +162,9 @@ impl From<&EngineEvent> for EngineEventWire { speech_prob: *speech_prob, ts_ms: *ts_ms, }, + EngineEvent::SidebandEvidence { evidence } => Self::SidebandEvidence { + evidence: evidence.clone(), + }, EngineEvent::NoSpeech { reason } => Self::NoSpeech { reason: reason.clone(), }, @@ -184,6 +192,7 @@ impl From<&EngineEvent> for EngineEventWire { compression_ratio, quality_gate_dropped, confidence_flags, + acoustic, .. } => Self::UtteranceFinal { utterance_id: *utterance_id, @@ -196,6 +205,7 @@ impl From<&EngineEvent> for EngineEventWire { compression_ratio: *compression_ratio, quality_gate_dropped: *quality_gate_dropped, confidence_flags: confidence_flags.clone(), + acoustic: acoustic.clone(), }, EngineEvent::Drop { kind, text, reason } => Self::Drop { kind: drop_kind_to_wire(kind).to_string(), @@ -287,6 +297,11 @@ fn drop_kind_to_wire(kind: &DropKind) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::pipeline::contracts::{ + AcousticSpanGrain, AcousticTranscriptIdentity, AcousticTranscriptSpan, NonSpeechEvidence, + SidebandEvidenceKind, SidebandProvenance, + }; + use crate::stt::tail_provider::TailSampleRange; use serde_json::Value; /// Test helper: force a JSON value into an object map or panic with context. @@ -313,6 +328,24 @@ mod tests { compression_ratio: Some(1.1), quality_gate_dropped: false, confidence_flags: vec![TranscriptionConfidenceFlag::VeryLowSpeech], + acoustic: Some(AcousticTranscriptIdentity { + range: TailSampleRange { + session: "ipc-session".into(), + capture_epoch: 8, + sample_start: 48_000, + sample_end: 120_000, + }, + spans: vec![AcousticTranscriptSpan { + text: "hello world".into(), + range: TailSampleRange { + session: "ipc-session".into(), + capture_epoch: 8, + sample_start: 48_000, + sample_end: 120_000, + }, + grain: AcousticSpanGrain::Phrase, + }], + }), }; let wire = EngineEventWire::from(&event); @@ -329,6 +362,14 @@ mod tests { ); assert_eq!(obj.get("text").and_then(Value::as_str), Some("hello world")); assert!(obj.get("segments").is_some(), "segments must be present"); + assert_eq!( + obj.get("acoustic") + .and_then(|value| value.get("range")) + .and_then(|value| value.get("capture_epoch")) + .and_then(Value::as_u64), + Some(8), + "PCM identity must survive IPC while raw text stays private" + ); assert_eq!( obj.get("vad_speech_pct") .and_then(Value::as_f64) @@ -448,6 +489,54 @@ mod tests { ); } + /// Sideband evidence keeps exact PCM identity and typed provenance on the + /// process wire without becoming transcript text. + #[test] + fn sideband_event_serializes_typed_wire_payload() { + let event = EngineEvent::SidebandEvidence { + evidence: SidebandEvidence { + sequence: 7, + range: TailSampleRange { + session: "session-1".to_string(), + capture_epoch: 3, + sample_start: 32_000, + sample_end: 48_000, + }, + sample_rate_hz: 16_000, + provenance: SidebandProvenance::SileroVad, + evidence: SidebandEvidenceKind::Pause { + duration_samples: 16_000, + non_speech: NonSpeechEvidence::UnknownNonSpeech, + }, + }, + }; + + let wire = EngineEventWire::from(&event); + let json = serde_json::to_value(&wire).expect("serialize sideband wire"); + let obj = must_object(json); + assert_eq!( + obj.get("type").and_then(Value::as_str), + Some("sideband_evidence") + ); + let evidence = obj + .get("evidence") + .and_then(Value::as_object) + .expect("sideband payload"); + assert_eq!(evidence.get("sequence").and_then(Value::as_u64), Some(7)); + assert_eq!( + evidence.get("provenance").and_then(Value::as_str), + Some("silero_vad") + ); + assert_eq!( + evidence + .get("evidence") + .and_then(Value::as_object) + .and_then(|kind| kind.get("non_speech")) + .and_then(Value::as_str), + Some("unknown_non_speech") + ); + } + /// ReplaceRange keeps utterance span, text, and LayerSource spelling on the wire. #[test] fn replace_range_event_serializes_typed_wire_payload() { diff --git a/core/llm/ai_formatting.rs b/core/llm/ai_formatting.rs index f92fb128..abb20fed 100644 --- a/core/llm/ai_formatting.rs +++ b/core/llm/ai_formatting.rs @@ -242,7 +242,7 @@ fn duration_from_env_ms(key: &str, default_ms: u64) -> Duration { /// 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 { +pub(crate) fn is_stale_chain_error(error: &anyhow::Error) -> bool { error.to_string().contains("previous_response_not_found") } @@ -2120,8 +2120,66 @@ 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). +/// One immutable resolution of the existing Formatting lane for an inline +/// dictation session. +/// +/// The secret is intentionally private and this type deliberately has no +/// `Debug` implementation: receipts may name the endpoint/model, never the +/// credential. Pinning this once at recording start prevents a settings edit +/// from continuing an old `previous_response_id` against a different provider +/// identity halfway through a take. +#[derive(Clone)] +pub(crate) struct InlineFormattingLane { + endpoint: String, + model: String, + api_key: String, + system_prompt: String, +} + +impl InlineFormattingLane { + pub(crate) fn endpoint(&self) -> &str { + &self.endpoint + } + + pub(crate) fn model(&self) -> &str { + &self.model + } + + pub(crate) fn system_prompt(&self) -> &str { + &self.system_prompt + } + + #[cfg(test)] + pub(crate) fn for_test(endpoint: String, model: &str, api_key: &str) -> Self { + Self { + endpoint, + model: model.to_string(), + api_key: api_key.to_string(), + system_prompt: crate::config::prompts::get_formatting_prompt_for_policy( + FormattingPolicy::Correction, + ) + .expect("Correction always has a formatting prompt"), + } + } +} + +/// Resolve the existing Formatting lane once for an inline dictation session. +/// This is not a second provider/client/model namespace; it uses the same +/// endpoint, model, credential slot and shared HTTP client as one-shot Format. +pub(crate) fn resolve_inline_formatting_lane() -> Result { + let policy = Config::formatting_policy()?; + let system_prompt = formatting_provider_system_prompt(false, policy) + .context("Inline formatting requires an enabled Formatting policy prompt")?; + Ok(InlineFormattingLane { + endpoint: get_formatting_endpoint()?, + model: get_formatting_model()?, + api_key: get_formatting_api_key()?, + system_prompt, + }) +} + +/// One chained Responses request over a pinned 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 @@ -2132,18 +2190,16 @@ pub(crate) async fn format_inline_chunk( language: Option<&str>, previous_response_id: Option, system_prompt: &str, + lane: &InlineFormattingLane, ) -> 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, + &lane.endpoint, + &lane.model, + &lane.api_key, ) .await } diff --git a/core/llm/inline_format.rs b/core/llm/inline_format.rs index 7b827a65..6a4a714e 100644 --- a/core/llm/inline_format.rs +++ b/core/llm/inline_format.rs @@ -12,18 +12,19 @@ //! - **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. +//! - **Symmetric content guards**: addition, semantic loss, and spoken-token +//! reorder are independently rejected (raw kept + typed receipt). //! - **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. +//! is keyed by session/span/PCM identity. +//! - **Bounded admission**: capture uses `try_send`; overflow is ledgered as a +//! raw L2 fallback and never backpressures the microphone or reducer. //! //! Receipts are stable INFO log lines (`inline_format_chunk`, //! `inline_format_compose`, `inline_format_fallback`) following the //! `stop_path_budget` convention. +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -32,6 +33,7 @@ use tokio::sync::{mpsc, oneshot}; use tracing::{info, warn}; use super::ai_formatting::{self, AiFormatResult, AiFormatStatus}; +use crate::pipeline::contracts::{SidebandEvidence, SidebandEvidenceKind}; /// Master switch. Unset/anything-else = OFF; the operator flips it (⛔). pub const INLINE_FORMAT_ENV: &str = "CODESCRIBE_INLINE_FORMAT"; @@ -46,24 +48,22 @@ const DEFAULT_TAIL_TIMEOUT_MS: u64 = 15_000; 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."; +/// Sealed spans waiting for the single ordered Responses worker. Capture uses +/// `try_send`; a full queue records raw fallback and returns immediately. +const SEALED_SPAN_QUEUE_CAPACITY: usize = 32; + +/// Span-local scheduling contract appended to the configured Formatting lane +/// prompt. The provider/model/credential/base prompt all remain the existing +/// lane; this suffix only fences one ordered span from rewriting its siblings. +const INLINE_CHUNK_PROMPT: &str = "This request is one consecutive stable span of the current live dictation. \ +Format ONLY this span. Keep its spoken content and order: never add, omit, \ +translate, move, repeat, answer, or comment. Return only this formatted span."; + +/// Tail-close suffix over the same configured Formatting lane prompt. +const INLINE_CLOSE_PROMPT: &str = "This request is the FINAL residual stable span of the current live dictation. \ +Format ONLY this tail, keep its spoken content and order, and close its final \ +sentence with proper terminal punctuation. Never repeat earlier spans. Return \ +only the formatted tail."; /// Whether the inline-format buffer is armed for this process. pub fn enabled() -> bool { @@ -113,8 +113,16 @@ pub(crate) enum ChunkStatus { Applied, /// LLM error or timeout — raw kept (fail-open). Failed, - /// Guard rejected invented/dropped words — raw kept. - RejectedInvention, + /// Guard rejected content added by the formatter — raw kept. + RejectedAddition, + /// Guard rejected semantic/content loss — raw kept. + RejectedLoss, + /// Guard rejected a change in spoken token order — raw kept. + RejectedReorder, + /// The bounded queue was full — raw kept without blocking capture. + QueueOverflow, + /// PCM/session identity was invalid or out of order — raw kept. + RejectedIdentity, /// Below the char floor — never sent. Skipped, } @@ -125,17 +133,36 @@ impl ChunkStatus { ChunkStatus::Pending => "pending", ChunkStatus::Applied => "applied", ChunkStatus::Failed => "failed", - ChunkStatus::RejectedInvention => "rejected_invention", + ChunkStatus::RejectedAddition => "rejected_addition", + ChunkStatus::RejectedLoss => "rejected_loss", + ChunkStatus::RejectedReorder => "rejected_reorder", + ChunkStatus::QueueOverflow => "queue_overflow", + ChunkStatus::RejectedIdentity => "rejected_identity", ChunkStatus::Skipped => "skipped", } } } +/// Typed identity carried from the stable Apple/L2 seal into L3. Text is +/// payload; session/span/sample identity is authority. +#[derive(Debug, Clone, PartialEq)] +pub struct StableFormatSpan { + pub session_id: String, + pub capture_epoch: u64, + pub span_id: u64, + pub sample_start: u64, + pub sample_end: u64, + pub text: String, + /// Optional content-free timing context. L3 filters this to measured pause + /// durations only; speech edges and non-speech semantics are not prompts. + pub sideband: Vec, +} + /// 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, + /// Stable session/span/PCM identity from the L2 seal. + pub identity: StableFormatSpan, /// Sealed text exactly as fed (post-lexicon, byte-stable). pub raw: String, /// Accepted formatted text; `None` composes as raw. @@ -149,36 +176,35 @@ impl ChunkRecord { } } -#[derive(Debug, Default, Clone)] +#[derive(Default, Clone)] struct SessionStore { + active: bool, generation: u64, + session_id: String, language: Option, - chunks: Vec, + /// Result ownership is the span id, never array position or text. + chunks: BTreeMap, + /// Arrival order is separately retained and must agree with PCM order. + order: Vec, /// Responses chain id of the last accepted chunk; resets per session. chain: Option, + /// Existing Formatting lane pinned once at recording start. + lane: Option, + /// More than the bounded ledger can represent: close must return full L2. + ledger_overflow: usize, } static STORE: OnceLock>> = OnceLock::new(); static GENERATION: AtomicU64 = AtomicU64::new(0); -static SENDER: OnceLock> = OnceLock::new(); +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<()>, - }, + Chunk { generation: u64, span_id: u64 }, + Flush { ack: oneshot::Sender<()> }, } // ── Live-session hooks ────────────────────────────────────────────────────── @@ -186,7 +212,7 @@ enum Cmd { /// 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>) { +pub fn begin_session(session_id: &str, language: Option<&str>) { if !enabled() { return; } @@ -194,27 +220,51 @@ pub fn begin_session(language: Option<&str>) { 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(); + SENDER.get_or_init(|| { + let (tx, rx) = mpsc::channel(SEALED_SPAN_QUEUE_CAPACITY); 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 { + let lane = match ai_formatting::resolve_inline_formatting_lane() { + Ok(lane) => { + info!( + endpoint = lane.endpoint(), + model = lane.model(), + "inline formatting lane pinned" + ); + Some(lane) + } + Err(error) => { + warn!("inline_format_fallback reason=lane_unavailable error={error:#}"); + None + } + }; + if let Ok(mut s) = store().lock() { + *s = SessionStore { + active: true, generation, + session_id: session_id.to_string(), language: language.map(str::to_string), - }) - .is_err() - { - warn!("inline_format_fallback reason=worker_gone (begin_session send failed)"); + chunks: BTreeMap::new(), + order: Vec::new(), + chain: None, + lane, + ledger_overflow: 0, + }; } + info!( + generation, + session_id, + queue_capacity = SEALED_SPAN_QUEUE_CAPACITY, + "inline_format_session_begin" + ); } /// 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) { +pub fn on_span_sealed(mut span: StableFormatSpan) { if !enabled() { return; } @@ -225,38 +275,124 @@ pub fn on_chunk_sealed(id: u64, text: &str) { if generation == 0 { return; } - let _ = tx.send(Cmd::Chunk { + span.text = span.text.trim().to_string(); + let span_id = span.span_id; + let should_queue = register_stable_span(store(), generation, span); + if !should_queue { + return; + } + match tx.try_send(Cmd::Chunk { generation, - id, - text: text.to_string(), - }); + span_id, + }) { + Ok(()) => { + info!( + generation, + span_id, + status = "queued", + "inline_format_chunk" + ); + } + Err(mpsc::error::TrySendError::Full(_)) => { + settle_without_request(generation, span_id, ChunkStatus::QueueOverflow); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + settle_without_request(generation, span_id, ChunkStatus::Failed); + } + } +} + +fn register_stable_span( + shared: &Arc>, + generation: u64, + span: StableFormatSpan, +) -> bool { + let span_id = span.span_id; + let Ok(mut s) = shared.lock() else { + return false; + }; + if !s.active + || s.generation != generation + || s.session_id != span.session_id + || span.text.is_empty() + || s.chunks.contains_key(&span_id) + { + return false; + } + if s.chunks.len() >= MAX_CHUNKS_PER_SESSION { + s.ledger_overflow = s.ledger_overflow.saturating_add(1); + info!( + generation, + span_id, "inline_format_chunk status=ledger_overflow" + ); + return false; + } + let identity_valid = span.sample_start < span.sample_end + && s.order + .last() + .and_then(|id| s.chunks.get(id)) + .map(|previous| { + previous.identity.capture_epoch < span.capture_epoch + || (previous.identity.capture_epoch == span.capture_epoch + && previous.identity.sample_end <= span.sample_start) + }) + .unwrap_or(true); + let status = if !identity_valid { + ChunkStatus::RejectedIdentity + } else if span.text.chars().count() < MIN_CHUNK_CHARS { + ChunkStatus::Skipped + } else if s.lane.is_none() { + ChunkStatus::Failed + } else { + ChunkStatus::Pending + }; + s.order.push(span_id); + s.chunks.insert( + span_id, + ChunkRecord { + raw: span.text.clone(), + identity: span, + formatted: None, + status, + }, + ); + if status != ChunkStatus::Pending { + info!( + generation, + span_id, + status = status.label(), + "inline_format_chunk" + ); + } + status == ChunkStatus::Pending +} + +fn settle_without_request(generation: u64, span_id: u64, status: ChunkStatus) { + if let Ok(mut s) = store().lock() + && s.generation == generation + && let Some(record) = s.chunks.get_mut(&span_id) + && record.status == ChunkStatus::Pending + { + record.status = status; + info!( + generation, + span_id, + status = status.label(), + "inline_format_chunk" + ); + } } // ── Worker ────────────────────────────────────────────────────────────────── -async fn worker_loop(mut rx: mpsc::UnboundedReceiver, shared: Arc>) { +async fn worker_loop(mut rx: mpsc::Receiver, 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, + span_id, } => { - process_chunk(&shared, generation, id, text).await; + process_chunk(&shared, generation, span_id).await; } Cmd::Flush { ack } => { let _ = ack.send(()); @@ -265,92 +401,220 @@ async fn worker_loop(mut rx: mpsc::UnboundedReceiver, 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 { +async fn process_chunk(shared: &Arc>, generation: u64, span_id: u64) { + let (raw, language, chain, lane, timing_instruction) = { + let Ok(s) = shared.lock() else { return; }; - if s.generation != generation - || trimmed.is_empty() - || s.chunks.len() >= MAX_CHUNKS_PER_SESSION - { + if s.generation != generation || !s.active { return; } - let status = if trimmed.chars().count() < MIN_CHUNK_CHARS { - ChunkStatus::Skipped - } else { - ChunkStatus::Pending + let Some(record) = s.chunks.get(&span_id) else { + return; }; - s.chunks.push(ChunkRecord { - id, - raw: trimmed.clone(), - formatted: None, - status, - }); - if status == ChunkStatus::Skipped { + if record.status != ChunkStatus::Pending { return; } - (s.chunks.len() - 1, s.language.clone(), s.chain.clone()) + let Some(lane) = s.lane.clone() else { + return; + }; + ( + record.raw.clone(), + s.language.clone(), + s.chain.clone(), + lane, + pause_timing_instruction(&record.identity.sideband), + ) }; let chained = chain.is_some(); + let mut system_prompt = format!("{}\n\n{}", lane.system_prompt(), INLINE_CHUNK_PROMPT); + if let Some(timing_instruction) = timing_instruction { + system_prompt.push_str("\n\n"); + system_prompt.push_str(&timing_instruction); + } let started = Instant::now(); let outcome = tokio::time::timeout( chunk_timeout(), - ai_formatting::format_inline_chunk( - &trimmed, - language.as_deref(), - chain, - INLINE_CHUNK_PROMPT, - ), + format_inline_with_chain_recovery(&raw, language.as_deref(), chain, &system_prompt, &lane), ) .await; let latency_ms = started.elapsed().as_millis(); - let (status, formatted, response_id) = match outcome { - Ok(Ok((raw_out, response_id))) => { + let (status, formatted, response_id, guard, chain_reset) = match outcome { + Ok(InlineAttempt { + result: Ok((raw_out, response_id)), + chain_reset, + }) => { 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) + let guard = validate_formatted_span(&raw, &cleaned); + match guard.disposition { + GuardDisposition::Accepted => ( + ChunkStatus::Applied, + Some(cleaned), + response_id, + guard, + chain_reset, + ), + GuardDisposition::RejectedAddition => ( + ChunkStatus::RejectedAddition, + None, + None, + guard, + chain_reset, + ), + GuardDisposition::RejectedLoss => { + (ChunkStatus::RejectedLoss, None, None, guard, chain_reset) + } + GuardDisposition::RejectedReorder => { + (ChunkStatus::RejectedReorder, None, None, guard, chain_reset) + } } } - Ok(Err(error)) => { + Ok(InlineAttempt { + result: Err(error), + chain_reset, + }) => { warn!("inline format chunk request failed: {error:#}"); - (ChunkStatus::Failed, None, None) + ( + ChunkStatus::Failed, + None, + None, + GuardReceipt::default(), + chain_reset, + ) } - Err(_) => (ChunkStatus::Failed, None, None), + Err(_) => ( + ChunkStatus::Failed, + None, + None, + GuardReceipt::default(), + false, + ), }; - let chars_in = trimmed.chars().count(); + let chars_in = raw.chars().count(); let chars_out = formatted .as_deref() .map(|t| t.chars().count()) .unwrap_or(chars_in); + let mut response_advanced = false; 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 + && let Some(record) = s.chunks.get_mut(&span_id) + && record.identity.span_id == span_id { record.status = status; record.formatted = formatted; + if chain_reset { + s.chain = None; + } if status == ChunkStatus::Applied && let Some(rid) = response_id.filter(|r| !r.is_empty()) { s.chain = Some(rid); + response_advanced = true; } } } info!( - "inline_format_chunk id={id} status={} latency_ms={latency_ms} chained={chained} chars_in={chars_in} chars_out={chars_out}", - status.label(), + generation, + span_id, + status = status.label(), + latency_ms, + chained, + chars_in, + chars_out, + added_tokens = guard.added_tokens, + omitted_tokens = guard.omitted_tokens, + reordered = guard.reordered, + response_advanced, + "inline_format_chunk", ); } +/// Convert only measured pause duration into a tightly fenced L3 hint. +/// +/// The hint is a developer instruction, never transcript payload. Speech-edge +/// probability and the unknown non-speech classification are deliberately not +/// promoted into words or named sounds. +fn pause_timing_instruction(sideband: &[SidebandEvidence]) -> Option { + let durations_ms = sideband + .iter() + .filter_map(|evidence| match evidence.evidence { + SidebandEvidenceKind::Pause { + duration_samples, .. + } if evidence.sample_rate_hz > 0 => { + Some(duration_samples.saturating_mul(1_000) / u64::from(evidence.sample_rate_hz)) + } + _ => None, + }) + .collect::>(); + if durations_ms.is_empty() { + return None; + } + let durations = durations_ms + .iter() + .map(|duration| format!("{duration} ms")) + .collect::>() + .join(", "); + Some(format!( + "Measured pause timing adjacent to this span: {durations}. Use this timing only to decide punctuation or paragraph boundaries. It is not word or sound-label evidence: never add transcript words or annotations from it." + )) +} + +/// Result of one chained request, including whether a stale provider chain was +/// discarded before one bounded unchained retry. +struct InlineAttempt { + result: anyhow::Result<(String, Option)>, + chain_reset: bool, +} + +/// Keep stale-chain recovery local to this dictation session. A provider 400 +/// naming `previous_response_not_found` means only the session's response id is +/// poisoned; retry exactly once without it while the caller's timeout remains +/// the single overall budget. +async fn format_inline_with_chain_recovery( + text: &str, + language: Option<&str>, + previous_response_id: Option, + system_prompt: &str, + lane: &ai_formatting::InlineFormattingLane, +) -> InlineAttempt { + let first = ai_formatting::format_inline_chunk( + text, + language, + previous_response_id.clone(), + system_prompt, + lane, + ) + .await; + match first { + Err(error) + if previous_response_id.is_some() && ai_formatting::is_stale_chain_error(&error) => + { + warn!("inline formatting chain stale; retrying this session turn unchained"); + InlineAttempt { + result: ai_formatting::format_inline_chunk( + text, + language, + None, + system_prompt, + lane, + ) + .await, + chain_reset: true, + } + } + result => InlineAttempt { + result, + chain_reset: false, + }, + } +} + // ── Anti-invention guard ──────────────────────────────────────────────────── fn normalize_token(token: &str) -> String { @@ -368,50 +632,112 @@ fn normalized_words(text: &str) -> Vec { .collect() } -/// True when the formatted text materially exceeds (or guts) the input words. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum GuardDisposition { + #[default] + Accepted, + RejectedAddition, + RejectedLoss, + RejectedReorder, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct GuardReceipt { + disposition: GuardDisposition, + added_tokens: usize, + omitted_tokens: usize, + reordered: bool, +} + +/// Symmetric completeness guard for one typed span. /// -/// 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 { +/// Addition, loss, and reorder are measured independently. A one-token budget +/// (or 10% for long spans) permits obvious spelling/number normalization; it +/// does not permit a new clause or a missing phrase. Reorder has no budget: +/// punctuation and paragraphing never require changing spoken token order. +fn validate_formatted_span(raw: &str, formatted: &str) -> GuardReceipt { let raw_words = normalized_words(raw); if raw_words.is_empty() { - return false; + return GuardReceipt::default(); } 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(); + let mut raw_counts: HashMap<&str, usize> = HashMap::new(); for w in &raw_words { - *counts.entry(w.as_str()).or_default() += 1; + *raw_counts.entry(w.as_str()).or_default() += 1; } - let mut novel = 0usize; + let mut output_counts: HashMap<&str, usize> = HashMap::new(); for w in &formatted_words { - match counts.get_mut(w.as_str()) { - Some(c) if *c > 0 => *c -= 1, - _ => novel += 1, + *output_counts.entry(w.as_str()).or_default() += 1; + } + let added_tokens = output_counts + .iter() + .map(|(word, count)| count.saturating_sub(*raw_counts.get(word).unwrap_or(&0))) + .sum(); + let omitted_tokens = raw_counts + .iter() + .map(|(word, count)| count.saturating_sub(*output_counts.get(word).unwrap_or(&0))) + .sum(); + + let mut raw_positions: HashMap<&str, VecDeque> = HashMap::new(); + for (position, word) in raw_words.iter().enumerate() { + raw_positions + .entry(word.as_str()) + .or_default() + .push_back(position); + } + let mut previous_position = None; + let mut reordered = false; + for word in &formatted_words { + let Some(position) = raw_positions + .get_mut(word.as_str()) + .and_then(VecDeque::pop_front) + else { + continue; + }; + if previous_position.is_some_and(|previous| position < previous) { + reordered = true; + break; } + previous_position = Some(position); + } + + let substitution_budget = (raw_words.len() / 10).max(1); + let disposition = if added_tokens > 0 && omitted_tokens == 0 { + GuardDisposition::RejectedAddition + } else if omitted_tokens > 0 && added_tokens == 0 { + GuardDisposition::RejectedLoss + } else if added_tokens > substitution_budget { + GuardDisposition::RejectedAddition + } else if omitted_tokens > substitution_budget { + GuardDisposition::RejectedLoss + } else if reordered { + GuardDisposition::RejectedReorder + } else { + GuardDisposition::Accepted + }; + GuardReceipt { + disposition, + added_tokens, + omitted_tokens, + reordered, } - let budget = (raw_words.len() / 10).max(2); - novel > budget } // ── Stop-path composition ─────────────────────────────────────────────────── -/// Outcome of matching the session's chunks against the delivered transcript. +/// Completeness proof for the typed L2 ledger against controller delivery. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct PrefixMatchOutcome { +pub(crate) struct LedgerValidation { /// 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, + /// Typed spans proven present in the controller's L2 text, in PCM order. + pub spans_validated: usize, + /// Validated spans that carry accepted L3 formatting. + pub formatted_validated: usize, + /// True only when every ledger span was found in its typed PCM order. + pub complete: bool, } /// Word starts (normalized token + byte offset) over the full text. @@ -439,44 +765,75 @@ fn word_spans(text: &str) -> Vec<(String, usize)> { spans } -/// Match chunks (in order) against the head of the delivered transcript. +/// Validate typed ledger order and coverage against the delivered L2 text. /// -/// 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( +/// Text comparison is only a completeness guard after result ownership has +/// already been established by `(session, span_id, PCM range)`. It never +/// chooses which result belongs to which span. A mismatch rejects composition +/// and returns the full L2 controller text unchanged. +pub(crate) fn validate_ledger_against_text( chunks: &[ChunkRecord], full_text: &str, -) -> PrefixMatchOutcome { +) -> LedgerValidation { let spans = word_spans(full_text); let mut cursor = 0usize; - let mut chunks_matched = 0usize; - let mut formatted_matched = 0usize; + let mut spans_validated = 0usize; + let mut formatted_validated = 0usize; let mut prefix_parts: Vec = Vec::new(); + let mut previous_identity = None; + let expected_session = chunks + .first() + .map(|chunk| chunk.identity.session_id.as_str()); for chunk in chunks { + let identity_is_ordered = chunk.identity.sample_start < chunk.identity.sample_end + && chunk.identity.session_id.as_str() == expected_session.unwrap_or_default() + && previous_identity.is_none_or(|(epoch, end)| { + epoch < chunk.identity.capture_epoch + || (epoch == chunk.identity.capture_epoch && end <= chunk.identity.sample_start) + }); + if !identity_is_ordered { + return LedgerValidation { + formatted_prefix: prefix_parts.join(" "), + tail_start_byte: 0, + spans_validated, + formatted_validated, + complete: false, + }; + } + previous_identity = Some((chunk.identity.capture_epoch, chunk.identity.sample_end)); let chunk_words = normalized_words(&chunk.raw); if chunk_words.is_empty() { - chunks_matched += 1; + spans_validated += 1; continue; } let end = cursor + chunk_words.len(); if end > spans.len() { - break; + return LedgerValidation { + formatted_prefix: prefix_parts.join(" "), + tail_start_byte: 0, + spans_validated, + formatted_validated, + complete: false, + }; } let matches = spans[cursor..end] .iter() .zip(chunk_words.iter()) .all(|((span_word, _), chunk_word)| span_word == chunk_word); if !matches { - break; + return LedgerValidation { + formatted_prefix: prefix_parts.join(" "), + tail_start_byte: 0, + spans_validated, + formatted_validated, + complete: false, + }; } cursor = end; - chunks_matched += 1; + spans_validated += 1; if chunk.formatted.is_some() { - formatted_matched += 1; + formatted_validated += 1; } let display = chunk.display_text().trim(); if !display.is_empty() { @@ -488,14 +845,23 @@ pub(crate) fn match_chunks_against_text( .get(cursor) .map(|(_, b)| *b) .unwrap_or(full_text.len()); - PrefixMatchOutcome { + LedgerValidation { formatted_prefix: prefix_parts.join(" "), tail_start_byte, - chunks_matched, - formatted_matched, + spans_validated, + formatted_validated, + complete: spans_validated == chunks.len(), } } +fn ordered_records(snapshot: &SessionStore) -> Option> { + snapshot + .order + .iter() + .map(|span_id| snapshot.chunks.get(span_id).cloned()) + .collect() +} + /// 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 { @@ -503,15 +869,28 @@ fn snapshot_and_consume(shared: &Arc>) -> SessionStore { return SessionStore::default(); }; let snapshot = s.clone(); + s.active = false; + s.generation = s.generation.wrapping_add(1); s.chunks.clear(); + s.order.clear(); s.chain = None; + s.lane = 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. +fn raw_l2_result(text: &str, reason: &str, generation: u64) -> AiFormatResult { + info!(generation, reason, "inline_format_fallback"); + AiFormatResult { + text: text.to_string(), + reasoning_text: None, + status: AiFormatStatus::Failed, + } +} + +/// Stop-path entry point: compose accepted span-keyed results plus a freshly +/// formatted tail. An active live session that cannot prove L2 completeness +/// returns the controller's full L2 text unchanged; only calls outside an +/// active inline session use the existing one-shot formatter. pub async fn format_text_with_inline_buffer(text: &str, language: Option<&str>) -> AiFormatResult { if enabled() && let Some(tx) = SENDER.get() @@ -533,7 +912,7 @@ pub async fn format_text_with_inline_buffer(text: &str, language: Option<&str>) /// 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, + tx: &mpsc::Sender, shared: &Arc>, full_text: &str, language: Option<&str>, @@ -543,63 +922,105 @@ async fn compose_and_close_with( // 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_budget = flush_timeout(); + let sent = tokio::time::timeout(flush_budget, tx.send(Cmd::Flush { ack: ack_tx })) + .await + .is_ok_and(|result| result.is_ok()); + let remaining = flush_budget.saturating_sub(flush_started.elapsed()); + let flushed = + sent && !remaining.is_zero() && tokio::time::timeout(remaining, ack_rx).await.is_ok(); 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}"); + if !snapshot.active { return None; } - - let matched = match_chunks_against_text(&snapshot.chunks, full_text); - if matched.chunks_matched == 0 || matched.formatted_matched == 0 { + if snapshot.ledger_overflow > 0 { + return Some(raw_l2_result( + full_text, + "ledger_overflow", + snapshot.generation, + )); + } + let Some(ordered) = ordered_records(&snapshot) else { + return Some(raw_l2_result( + full_text, + "missing_span_identity", + snapshot.generation, + )); + }; + if ordered.is_empty() { + return Some(raw_l2_result( + full_text, + "no_stable_spans", + snapshot.generation, + )); + } + let validated = validate_ledger_against_text(&ordered, full_text); + if !validated.complete { 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 + generation = snapshot.generation, + spans = ordered.len(), + validated = validated.spans_validated, + formatted = validated.formatted_validated, + flush_wait_ms, + "inline_format_fallback reason=ledger_mismatch" ); - return None; + return Some(raw_l2_result( + full_text, + "ledger_mismatch", + snapshot.generation, + )); } - let tail_raw = full_text[matched.tail_start_byte..].trim(); + let tail_raw = full_text[validated.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 { + let (tail_text, tail_status, tail_degraded) = if tail_raw.is_empty() { + (String::new(), "empty", false) + } else if let Some(lane) = snapshot.lane.as_ref() { + let system_prompt = format!("{}\n\n{}", lane.system_prompt(), INLINE_CLOSE_PROMPT); match tokio::time::timeout( tail_timeout(), - ai_formatting::format_inline_chunk( + format_inline_with_chain_recovery( tail_raw, language.or(snapshot.language.as_deref()), snapshot.chain.clone(), - INLINE_CLOSE_PROMPT, + &system_prompt, + lane, ), ) .await { - Ok(Ok((raw_out, _response_id))) => { + Ok(InlineAttempt { + result: 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") + let guard = validate_formatted_span(tail_raw, &cleaned); + match guard.disposition { + GuardDisposition::Accepted => (cleaned, "applied", false), + GuardDisposition::RejectedAddition => { + (tail_raw.to_string(), "rejected_addition", true) + } + GuardDisposition::RejectedLoss => (tail_raw.to_string(), "rejected_loss", true), + GuardDisposition::RejectedReorder => { + (tail_raw.to_string(), "rejected_reorder", true) + } } } - Ok(Err(error)) => { + Ok(InlineAttempt { + result: Err(error), .. + }) => { warn!("inline format tail request failed: {error:#}"); - (tail_raw.to_string(), "failed") + (tail_raw.to_string(), "failed", true) } - Err(_) => (tail_raw.to_string(), "timeout"), + Err(_) => (tail_raw.to_string(), "timeout", true), } + } else { + (tail_raw.to_string(), "lane_unavailable", true) }; - let mut composed = matched.formatted_prefix.clone(); + let mut composed = validated.formatted_prefix.clone(); if !tail_text.is_empty() { if !composed.is_empty() { composed.push(' '); @@ -608,20 +1029,63 @@ async fn compose_and_close_with( } if composed.trim().is_empty() { info!("inline_format_fallback reason=empty_composition flush_wait_ms={flush_wait_ms}"); - return None; + return Some(raw_l2_result( + full_text, + "empty_composition", + snapshot.generation, + )); + } + + let document_guard = validate_formatted_span(full_text, &composed); + if document_guard.disposition != GuardDisposition::Accepted { + info!( + generation = snapshot.generation, + disposition = ?document_guard.disposition, + added_tokens = document_guard.added_tokens, + omitted_tokens = document_guard.omitted_tokens, + reordered = document_guard.reordered, + "inline_format_fallback reason=document_guard" + ); + return Some(raw_l2_result( + full_text, + "document_guard", + snapshot.generation, + )); } + let fallback_spans = ordered + .iter() + .filter(|record| !matches!(record.status, ChunkStatus::Applied | ChunkStatus::Skipped)) + .count(); + let applied_spans = ordered + .iter() + .filter(|record| record.status == ChunkStatus::Applied) + .count(); + let degraded = !flushed || fallback_spans > 0 || tail_degraded; + 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, + generation = snapshot.generation, + spans = ordered.len(), + validated = validated.spans_validated, + applied = applied_spans, + raw_fallback = fallback_spans, + tail_chars, + tail_status, + flushed, + flush_wait_ms, + "inline_format_compose", ); Some(AiFormatResult { text: composed, reasoning_text: None, - status: AiFormatStatus::Applied, + status: if degraded { + AiFormatStatus::Failed + } else if applied_spans == 0 && tail_raw.is_empty() { + AiFormatStatus::Skipped + } else { + AiFormatStatus::Applied + }, }) } @@ -630,10 +1094,20 @@ async fn compose_and_close_with( #[cfg(test)] mod tests { use super::*; + use crate::pipeline::contracts::{NonSpeechEvidence, SidebandProvenance}; + use crate::stt::tail_provider::TailSampleRange; fn record(id: u64, raw: &str, formatted: Option<&str>) -> ChunkRecord { ChunkRecord { - id, + identity: StableFormatSpan { + session_id: "session-test".to_string(), + capture_epoch: 1, + span_id: id, + sample_start: id.saturating_sub(1) * 16_000, + sample_end: id * 16_000, + text: raw.to_string(), + sideband: Vec::new(), + }, raw: raw.to_string(), formatted: formatted.map(str::to_string), status: if formatted.is_some() { @@ -644,40 +1118,113 @@ mod tests { } } + #[test] + fn l3_consumes_only_pause_duration_as_formatting_context() { + let range = |start, end| TailSampleRange { + session: "session-test".to_string(), + capture_epoch: 1, + sample_start: start, + sample_end: end, + }; + let evidence = vec![ + SidebandEvidence { + sequence: 1, + range: range(8_000, 8_000), + sample_rate_hz: 16_000, + provenance: SidebandProvenance::SileroVad, + evidence: SidebandEvidenceKind::SpeechStart { + speech_probability: 0.91, + }, + }, + SidebandEvidence { + sequence: 2, + range: range(8_000, 24_000), + sample_rate_hz: 16_000, + provenance: SidebandProvenance::SileroVad, + evidence: SidebandEvidenceKind::Pause { + duration_samples: 16_000, + non_speech: NonSpeechEvidence::UnknownNonSpeech, + }, + }, + ]; + + let instruction = pause_timing_instruction(&evidence).expect("pause hint"); + assert!(instruction.contains("1000 ms")); + assert!(instruction.contains("punctuation or paragraph boundaries")); + assert!( + !instruction.contains("0.91"), + "speech probability is not L3 input" + ); + for unsupported in ["laughter", "noise", "cough"] { + assert!( + !instruction.contains(unsupported), + "unmeasured named sound leaked into L3: {unsupported}" + ); + } + + assert!( + pause_timing_instruction(&evidence[..1]).is_none(), + "speech edges alone are not formatter context" + ); + } + /// 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." - )); + assert_eq!( + validate_formatted_span( + "no dobra to jest test dyktowania w codescribe", + "No dobra, to jest test dyktowania w Codescribe." + ) + .disposition, + GuardDisposition::Accepted + ); } - /// A formatter that answers instead of formatting is rejected. + /// Addition is a distinct rejection receipt, not a generic similarity miss. #[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." - )); + fn guard_rejects_addition_independently() { + let receipt = validate_formatted_span( + "kup mleko i chleb dla kliniki", + "Kup mleko i chleb dla kliniki dzisiaj.", + ); + assert_eq!(receipt.disposition, GuardDisposition::RejectedAddition); + assert!(receipt.added_tokens > 0); } - /// A formatter that eats most of the chunk is rejected too. + /// Semantic/content loss has its own rejection receipt. #[test] - fn guard_rejects_heavy_truncation() { - assert!(invention_guard_rejects( - "pierwsza część zdania oraz druga część zdania oraz trzecia część zdania", - "pierwsza część." - )); + fn guard_rejects_loss_independently() { + let receipt = validate_formatted_span( + "pierwsza część zdania oraz druga część zdania", + "Pierwsza część zdania oraz druga część.", + ); + assert_eq!(receipt.disposition, GuardDisposition::RejectedLoss); + assert!(receipt.omitted_tokens > 0); + } + + /// Preserving the multiset is insufficient: moving a spoken phrase fails. + #[test] + fn guard_rejects_reorder_independently() { + let receipt = validate_formatted_span( + "pierwszy pacjent potem drugi pacjent na końcu trzeci pacjent", + "Trzeci pacjent, potem drugi pacjent, na końcu pierwszy pacjent.", + ); + assert_eq!(receipt.disposition, GuardDisposition::RejectedReorder); + assert!(receipt.reordered); } /// 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." - )); + assert_eq!( + validate_formatted_span( + "spotkanie jutro o ósmej rano w klinice", + "Spotkanie jutro o 8 rano w klinice." + ) + .disposition, + GuardDisposition::Accepted + ); } /// Matched chunks compose the formatted prefix; the tail byte offset points @@ -693,9 +1240,10 @@ mod tests { 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); + let m = validate_ledger_against_text(&chunks, full); + assert!(m.complete); + assert_eq!(m.spans_validated, 2); + assert_eq!(m.formatted_validated, 2); assert_eq!( m.formatted_prefix, "Pierwsze zdanie o żółwiu. Drugie zdanie o jeżu." @@ -703,22 +1251,19 @@ mod tests { 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. + /// Canvas drift cannot be used to re-key results by text: composition is + /// refused and the caller returns the complete controller L2 text. #[test] - fn matcher_partial_match_on_gap_append() { + fn ledger_gap_mismatch_refuses_partial_composition() { 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" - ); + let m = validate_ledger_against_text(&chunks, full); + assert!(!m.complete); + assert_eq!(m.spans_validated, 1); + assert_eq!(m.tail_start_byte, 0); } /// A failed chunk (no formatted text) still matches and composes raw — @@ -730,9 +1275,10 @@ mod tests { 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); + let m = validate_ledger_against_text(&chunks, full); + assert!(m.complete); + assert_eq!(m.spans_validated, 2); + assert_eq!(m.formatted_validated, 1); assert_eq!(m.formatted_prefix, "Pierwsze zdanie. drugie zdanie"); assert_eq!(&full[m.tail_start_byte..], "ogon"); } @@ -746,8 +1292,9 @@ mod tests { 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); + let m = validate_ledger_against_text(&chunks, full); + assert!(!m.complete); + assert_eq!(m.spans_validated, 0); assert_eq!(m.tail_start_byte, 0); } @@ -756,11 +1303,53 @@ mod tests { 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); + let m = validate_ledger_against_text(&chunks, full); + assert!(m.complete); + assert_eq!(m.spans_validated, 1); assert_eq!(full[m.tail_start_byte..].trim(), ""); } + /// PCM order is independently required even when the concatenated text + /// would happen to match. + #[test] + fn ledger_rejects_out_of_order_pcm_identity() { + let mut first = record(1, "pierwsze zdanie", Some("Pierwsze zdanie.")); + first.identity.sample_start = 16_000; + first.identity.sample_end = 32_000; + let mut second = record(2, "drugie zdanie", Some("Drugie zdanie.")); + second.identity.sample_start = 0; + second.identity.sample_end = 16_000; + let validation = + validate_ledger_against_text(&[first, second], "pierwsze zdanie drugie zdanie"); + assert!(!validation.complete); + assert_eq!(validation.tail_start_byte, 0); + } + + #[test] + fn ledger_orders_epoch_before_sample_clock_and_allows_epoch_reset() { + let mut first = record(1, "pierwsze zdanie", Some("Pierwsze zdanie.")); + first.identity.capture_epoch = 4; + first.identity.sample_start = 80_000; + first.identity.sample_end = 96_000; + let mut second = record(2, "drugie zdanie", Some("Drugie zdanie.")); + second.identity.capture_epoch = 5; + second.identity.sample_start = 0; + second.identity.sample_end = 16_000; + assert!( + validate_ledger_against_text( + &[first.clone(), second.clone()], + "pierwsze zdanie drugie zdanie" + ) + .complete + ); + + second.identity.capture_epoch = 3; + assert!( + !validate_ledger_against_text(&[first, second], "pierwsze zdanie drugie zdanie") + .complete + ); + } + /// 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 @@ -784,12 +1373,6 @@ mod tests { 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 { @@ -801,22 +1384,20 @@ mod tests { } } - fn pin_formatting_lane(server_url: &str) -> Vec { + fn pin_clocks(chunk_ms: u64, flush_ms: u64, tail_ms: u64) -> Vec { vec![ EnvGuard::set( - "LLM_FORMATTING_ENDPOINT", - &format!("{server_url}/v1/responses"), + "CODESCRIBE_INLINE_FORMAT_CHUNK_TIMEOUT_MS", + &chunk_ms.to_string(), + ), + EnvGuard::set( + "CODESCRIBE_INLINE_FORMAT_FLUSH_TIMEOUT_MS", + &flush_ms.to_string(), + ), + EnvGuard::set( + "CODESCRIBE_INLINE_FORMAT_TAIL_TIMEOUT_MS", + &tail_ms.to_string(), ), - 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"), ] } @@ -832,13 +1413,31 @@ mod tests { } struct Harness { - tx: mpsc::UnboundedSender, + tx: mpsc::Sender, shared: Arc>, } - fn spawn_private_worker(language: &str) -> Harness { - let shared = Arc::new(Mutex::new(SessionStore::default())); - let (tx, rx) = mpsc::unbounded_channel(); + fn private_store(language: &str, server_url: &str) -> Arc> { + Arc::new(Mutex::new(SessionStore { + active: true, + generation: 1, + session_id: "session-test".to_string(), + language: Some(language.to_string()), + chunks: BTreeMap::new(), + order: Vec::new(), + chain: None, + lane: Some(ai_formatting::InlineFormattingLane::for_test( + format!("{server_url}/v1/responses"), + "mock-nano", + "mock-key", + )), + ledger_overflow: 0, + })) + } + + fn spawn_private_worker(language: &str, server_url: &str) -> Harness { + let shared = private_store(language, server_url); + let (tx, rx) = mpsc::channel(SEALED_SPAN_QUEUE_CAPACITY); 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 { @@ -846,14 +1445,33 @@ mod tests { 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 } } + fn span(id: u64, text: &str) -> StableFormatSpan { + StableFormatSpan { + session_id: "session-test".to_string(), + capture_epoch: 1, + span_id: id, + sample_start: id.saturating_sub(1) * 16_000, + sample_end: id * 16_000, + text: text.to_string(), + sideband: Vec::new(), + } + } + + async fn queue_span(harness: &Harness, id: u64, text: &str) { + assert!(register_stable_span(&harness.shared, 1, span(id, text))); + harness + .tx + .send(Cmd::Chunk { + generation: 1, + span_id: id, + }) + .await + .expect("worker alive"); + } + // 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 @@ -864,7 +1482,9 @@ mod tests { { let s = shared.lock().expect("store lock"); if s.chunks.len() == expected - && s.chunks.iter().all(|c| c.status != ChunkStatus::Pending) + && s.chunks + .values() + .all(|chunk| chunk.status != ChunkStatus::Pending) { return; } @@ -885,14 +1505,15 @@ mod tests { /// <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() { + async fn inline_format_closes_span_ordered_document() { let mut server = mockito::Server::new_async().await; - let _env = pin_formatting_lane(&server.url()); + let _env = pin_clocks(10_000, 2_500, 15_000); let chunk1 = server .mock("POST", "/v1/responses") .match_body(Matcher::AllOf(vec![ - Matcher::Regex("consecutive chunk".into()), + Matcher::Regex("consecutive stable span".into()), + Matcher::Regex(r#""instructions":"[^"]+""#.into()), Matcher::Regex("pierwsze zdanie o testowaniu bufora".into()), ])) .with_status(200) @@ -919,55 +1540,64 @@ mod tests { .expect(1) .create_async() .await; - let tail = server + let chunk3 = server .mock("POST", "/v1/responses") .match_body(Matcher::AllOf(vec![ - Matcher::Regex("FINAL chunk".into()), + Matcher::Regex("trzecie zdanie zachowuje kolejnosc".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", + "Trzecie zdanie zachowuje kolejnosc.", + )) + .expect(1) + .create_async() + .await; + let tail = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("FINAL residual stable span".into()), + Matcher::Regex(r#""previous_response_id":"resp_3""#.into()), + Matcher::Regex("ogon który nie został".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body( + "resp_4", "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(); + let h = spawn_private_worker("pl", &server.url()); + queue_span(&h, 1, "pierwsze zdanie o testowaniu bufora").await; + queue_span(&h, 2, "drugie zdanie o zamykaniu wypowiedzi").await; + queue_span(&h, 3, "trzecie zdanie zachowuje kolejnosc").await; - // Both chunks format DURING dictation (before any stop call). - wait_for_settled_chunks(&h.shared, 2).await; + // All three spans format DURING dictation (before any stop call). + wait_for_settled_chunks(&h.shared, 3).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 + .values() + .all(|chunk| chunk.status == ChunkStatus::Applied), + "all spans must be accepted in flight: {:?}", s.chunks ); assert_eq!( s.chain.as_deref(), - Some("resp_2"), + Some("resp_3"), "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"; + zamykaniu wypowiedzi trzecie zdanie zachowuje kolejnosc \ + 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 @@ -978,10 +1608,12 @@ mod tests { assert_eq!( result.text, "Pierwsze zdanie o testowaniu bufora. Drugie zdanie o zamykaniu \ - wypowiedzi. Ogon, który nie został zapieczętowany." + wypowiedzi. Trzecie zdanie zachowuje kolejnosc. Ogon, który nie \ + został zapieczętowany." ); chunk1.assert_async().await; chunk2.assert_async().await; + chunk3.assert_async().await; tail.assert_async().await; assert!( stop_secs < 3.0, @@ -989,7 +1621,7 @@ mod tests { ); // 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" + "inline_format_stop_seam_secs={stop_secs:.3} spans_in_flight=3 stop_requests=1" ); } @@ -1000,7 +1632,7 @@ mod tests { #[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 _env = pin_clocks(10_000, 2_500, 15_000); let chunk1 = server .mock("POST", "/v1/responses") @@ -1011,6 +1643,15 @@ mod tests { .expect(1) .create_async() .await; + let chunk2 = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex("drugi kawalek ktory pada".into())) + .with_status(500) + .with_header("content-type", "text/plain") + .with_body("provider unavailable") + .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 @@ -1020,7 +1661,7 @@ mod tests { 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""# + r#""role":"developer"[\s\S]*FINAL residual stable span[\s\S]*"previous_response_id":"resp_1""# .into(), )) .with_status(200) @@ -1030,35 +1671,21 @@ mod tests { .create_async() .await; - let h = spawn_private_worker("pl"); - h.tx.send(Cmd::Chunk { - generation: 1, - id: 1, - text: "pierwszy kawalek dyktowania".into(), - }) - .unwrap(); + let h = spawn_private_worker("pl", &server.url()); + queue_span(&h, 1, "pierwszy kawalek dyktowania").await; 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; - } + queue_span(&h, 2, "drugi kawalek ktory pada").await; + 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.chunks.get(&1).unwrap().status, ChunkStatus::Applied); + assert_eq!(s.chunks.get(&2).unwrap().status, ChunkStatus::Failed); + assert_eq!( + s.chunks.get(&2).unwrap().formatted, + None, + "failed chunk keeps raw" + ); assert_eq!(s.chain.as_deref(), Some("resp_1")); } @@ -1071,7 +1698,9 @@ mod tests { result.text, "Pierwszy kawalek dyktowania. drugi kawalek ktory pada Ogon po awarii." ); + assert_eq!(result.status, AiFormatStatus::Failed); chunk1.assert_async().await; + chunk2.assert_async().await; tail.assert_async().await; } @@ -1081,7 +1710,7 @@ mod tests { #[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 _env = pin_clocks(10_000, 2_500, 15_000); let chunk = server .mock("POST", "/v1/responses") @@ -1097,22 +1726,182 @@ mod tests { .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(); + let h = spawn_private_worker("pl", &server.url()); + queue_span(&h, 1, "kup mleko i chleb dla kliniki").await; 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.chunks.get(&1).unwrap().status, + ChunkStatus::RejectedAddition + ); + assert_eq!( + s.chunks.get(&1).unwrap().formatted, + None, + "invented text must not land" + ); assert_eq!(s.chain, None, "chain must not advance onto a rejected id"); } chunk.assert_async().await; } + + /// A real delayed HTTP response crosses the chunk clock. The span is + /// retained raw and the chain remains clean. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn chunk_timeout_keeps_l2_and_does_not_advance_chain() { + let mut server = mockito::Server::new_async().await; + let _env = pin_clocks(500, 2_500, 15_000); + let delayed_body = responses_body("resp_late", "Powolny fragment odpowiedzi."); + let delayed = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex("powolny fragment odpowiedzi".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_chunked_body(move |writer| { + std::thread::sleep(Duration::from_millis(1_500)); + writer.write_all(delayed_body.as_bytes()) + }) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl", &server.url()); + queue_span(&h, 1, "powolny fragment odpowiedzi").await; + wait_for_settled_chunks(&h.shared, 1).await; + + { + let s = h.shared.lock().unwrap(); + assert_eq!(s.chunks.get(&1).unwrap().status, ChunkStatus::Failed); + assert_eq!(s.chunks.get(&1).unwrap().formatted, None); + assert_eq!(s.chain, None); + } + delayed.assert_async().await; + } + + /// A response id invalid for the pinned credential is session-local + /// poison: clear it and retry this span once without a previous id. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn stale_chain_retries_once_unchained_and_advances_clean_id() { + let mut server = mockito::Server::new_async().await; + let _env = pin_clocks(10_000, 2_500, 15_000); + + let stale = server + .mock("POST", "/v1/responses") + .match_body(Matcher::Regex( + r#""previous_response_id":"resp_stale""#.into(), + )) + .with_status(400) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"previous_response_not_found"}}"#) + .expect(1) + .create_async() + .await; + let recovered = server + .mock("POST", "/v1/responses") + .match_body(Matcher::AllOf(vec![ + Matcher::Regex("fragment po rotacji klucza".into()), + Matcher::Regex(r#""instructions":"[^"]+""#.into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(responses_body("resp_fresh", "Fragment po rotacji klucza.")) + .expect(1) + .create_async() + .await; + + let h = spawn_private_worker("pl", &server.url()); + h.shared.lock().unwrap().chain = Some("resp_stale".to_string()); + queue_span(&h, 1, "fragment po rotacji klucza").await; + wait_for_settled_chunks(&h.shared, 1).await; + + { + let store = h.shared.lock().unwrap(); + assert_eq!(store.chunks.get(&1).unwrap().status, ChunkStatus::Applied); + assert_eq!(store.chain.as_deref(), Some("resp_fresh")); + } + stale.assert_async().await; + recovered.assert_async().await; + } + + /// Queue pressure is fail-open and synchronous: the second stable span + /// is ledgered as raw without waiting for the worker or capture path. + #[tokio::test] + #[serial_test::serial] + async fn bounded_queue_overflow_keeps_span_identity_and_raw_text() { + let shared = private_store("pl", "http://127.0.0.1:1"); + let (tx, _rx) = mpsc::channel(1); + assert!(register_stable_span( + &shared, + 1, + span(1, "pierwszy stabilny fragment") + )); + tx.try_send(Cmd::Chunk { + generation: 1, + span_id: 1, + }) + .expect("first item fills the queue"); + + let started = Instant::now(); + assert!(register_stable_span( + &shared, + 1, + span(2, "drugi stabilny fragment") + )); + match tx.try_send(Cmd::Chunk { + generation: 1, + span_id: 2, + }) { + Err(mpsc::error::TrySendError::Full(_)) => { + if let Ok(mut store) = shared.lock() + && let Some(record) = store.chunks.get_mut(&2) + { + record.status = ChunkStatus::QueueOverflow; + } + } + other => panic!("expected bounded queue overflow, got {other:?}"), + } + assert!(started.elapsed() < Duration::from_millis(50)); + + let store = shared.lock().unwrap(); + assert_eq!(store.order, vec![1, 2]); + let overflow = store.chunks.get(&2).unwrap(); + assert_eq!(overflow.identity.span_id, 2); + assert_eq!(overflow.status, ChunkStatus::QueueOverflow); + assert_eq!(overflow.display_text(), "drugi stabilny fragment"); + } + + /// Once a live ledger exists, an identity/coverage mismatch returns + /// the controller's complete L2 document. It must not escape into the + /// classic whole-document formatter on the stop path. + #[tokio::test] + #[serial_test::serial] + async fn active_ledger_mismatch_returns_complete_l2_without_full_request() { + let shared = private_store("pl", "http://127.0.0.1:1"); + assert!(register_stable_span( + &shared, + 1, + span(1, "pierwszy stabilny fragment") + )); + { + let mut store = shared.lock().unwrap(); + let record = store.chunks.get_mut(&1).unwrap(); + record.status = ChunkStatus::Applied; + record.formatted = Some("Pierwszy stabilny fragment.".to_string()); + } + let (tx, rx) = mpsc::channel(1); + let worker = tokio::spawn(worker_loop(rx, Arc::clone(&shared))); + let l2 = "kontroler ma inny kompletny tekst warstwy drugiej"; + let result = compose_and_close_with(&tx, &shared, l2, Some("pl")) + .await + .expect("active session always returns a fail-open result"); + drop(tx); + worker.await.expect("worker exits cleanly"); + + assert_eq!(result.text, l2); + assert_eq!(result.status, AiFormatStatus::Failed); + } } } diff --git a/core/pipeline/acoustic_identity.rs b/core/pipeline/acoustic_identity.rs new file mode 100644 index 00000000..dc66399d --- /dev/null +++ b/core/pipeline/acoustic_identity.rs @@ -0,0 +1,771 @@ +//! Occurrence, observation, and mutation receipt for acoustic spans. +//! +//! Three separate identities: +//! - [`OccurrenceIdentity`] — one physical fragment of captured audio +//! - [`ObservationIdentity`] — one producer hypothesis about that fragment +//! - [`MutationReceipt`] — why that hypothesis may keep, correct, insert, +//! stay visible, or be refused +//! +//! Replay is re-delivery of the same [`ObservationIdentity`], not merely the +//! same PCM range. Apple and Whisper on one range are two observations of one +//! occurrence. Two disjoint ranges with the text "Iwo" are two occurrences. +//! +//! Energy hops are quality evidence on the PCM axis. They never hash identity. +//! Mean dBFS is not an ID. + +use std::collections::{BTreeMap, HashSet}; + +use crate::pipeline::contracts::{AcousticSpanGrain, AcousticTranscriptSpan}; +use crate::stt::tail_provider::{ + TailEvidenceSource, TailProviderPayload, TailSampleRange, TimedTailSegment, +}; + +/// Physical occurrence: session, epoch, true half-open PCM range. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct OccurrenceIdentity { + pub session: String, + pub capture_epoch: u64, + pub sample_start: u64, + pub sample_end: u64, +} + +impl OccurrenceIdentity { + pub 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, + } + } + + pub fn range(&self) -> TailSampleRange { + TailSampleRange { + session: self.session.clone(), + capture_epoch: self.capture_epoch, + sample_start: self.sample_start, + sample_end: self.sample_end, + } + } + + pub fn is_anchored(&self) -> bool { + self.sample_end > self.sample_start + } + + fn partition_key(&self) -> (String, u64) { + (self.session.clone(), self.capture_epoch) + } + + fn overlaps(&self, other: &Self) -> bool { + self.session == other.session + && self.capture_epoch == other.capture_epoch + && self.sample_start < other.sample_end + && other.sample_start < self.sample_end + } +} + +/// Who produced a hypothesis about an occurrence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ObservationProducer { + Apple, + Whisper, +} + +impl ObservationProducer { + pub fn from_source(source: TailEvidenceSource) -> Self { + match source { + TailEvidenceSource::AppleSpeech => Self::Apple, + TailEvidenceSource::Whisper => Self::Whisper, + } + } +} + +/// One producer / request / generation hypothesising about one occurrence. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ObservationIdentity { + pub producer: ObservationProducer, + pub request_id: u64, + pub generation: u64, + pub occurrence: OccurrenceIdentity, +} + +/// Proven word pin. Overlap may clip text only through these. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcousticWordPin { + pub text: String, + pub sample_start: u64, + pub sample_end: u64, +} + +/// One hypothesis: identity, text, grain, optional pins. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcousticObservation { + pub identity: ObservationIdentity, + pub text: String, + pub grain: AcousticSpanGrain, + pub pins: Vec, +} + +impl AcousticObservation { + pub fn from_span( + span: &AcousticTranscriptSpan, + producer: ObservationProducer, + request_id: u64, + generation: u64, + ) -> Self { + Self { + identity: ObservationIdentity { + producer, + request_id, + generation, + occurrence: OccurrenceIdentity::from_range(&span.range), + }, + text: span.text.clone(), + grain: span.grain, + pins: Vec::new(), + } + } + + pub fn from_timed_segment( + segment: &TimedTailSegment, + producer: ObservationProducer, + request_id: u64, + generation: u64, + grain: AcousticSpanGrain, + ) -> Self { + let pin = AcousticWordPin { + text: segment.text.clone(), + sample_start: segment.range.sample_start, + sample_end: segment.range.sample_end, + }; + Self { + identity: ObservationIdentity { + producer, + request_id, + generation, + occurrence: OccurrenceIdentity::from_range(&segment.range), + }, + text: segment.text.clone(), + grain, + pins: vec![pin], + } + } + + pub fn from_whisper_payload(payload: &TailProviderPayload) -> Vec { + let producer = ObservationProducer::from_source(payload.evidence.source); + let request_id = payload.identity.request_id; + if payload.segments.is_empty() { + return vec![Self { + identity: ObservationIdentity { + producer, + request_id, + generation: 0, + occurrence: OccurrenceIdentity::from_range(&payload.identity.range), + }, + text: payload.text.clone(), + grain: AcousticSpanGrain::Phrase, + pins: Vec::new(), + }]; + } + payload + .segments + .iter() + .enumerate() + .map(|(generation, segment)| { + Self::from_timed_segment( + segment, + producer, + request_id, + generation as u64, + AcousticSpanGrain::Word, + ) + }) + .collect() + } + + pub fn as_span(&self) -> AcousticTranscriptSpan { + AcousticTranscriptSpan { + text: self.text.clone(), + range: self.identity.occurrence.range(), + grain: self.grain, + } + } +} + +/// Why an observation may mutate, stay visible, or be refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MutationReceipt { + Preserve { + occurrence: OccurrenceIdentity, + }, + Correct { + occurrence: OccurrenceIdentity, + from: ObservationProducer, + to: ObservationProducer, + }, + Insert { + occurrence: OccurrenceIdentity, + }, + KeepVisibleUnanchored { + text: String, + }, + RefuseReplay { + observation: ObservationIdentity, + }, + RefuseOverlapWithoutTextMap { + occurrence: OccurrenceIdentity, + }, +} + +impl MutationReceipt { + pub fn mutation_authority(&self) -> bool { + matches!( + self, + Self::Preserve { .. } | Self::Correct { .. } | Self::Insert { .. } + ) + } + + pub fn visible(&self) -> bool { + !matches!(self, Self::RefuseReplay { .. }) + } +} + +/// Admitted (or refused) observation plus the receipt that justified it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdmittedObservation { + pub observation: AcousticObservation, + pub receipt: MutationReceipt, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OccurrenceSlot { + identity: OccurrenceIdentity, + current: AcousticObservation, +} + +/// Ledger partitioned by `(session, capture_epoch)`. +#[derive(Debug, Clone, Default)] +pub struct ObservationLedger { + partitions: BTreeMap<(String, u64), Vec>, + seen: HashSet, + unanchored: Vec, + read_only: Vec, +} + +impl ObservationLedger { + /// Admit a batch. Each incoming is compared with committed slots and with + /// observations already accepted earlier in this batch. + pub fn admit(&mut self, incoming: &[AcousticObservation]) -> Vec { + let mut results = Vec::with_capacity(incoming.len()); + for observation in incoming { + let admitted = self.admit_one(observation); + match &admitted.receipt { + MutationReceipt::Insert { .. } | MutationReceipt::Preserve { .. } => { + self.record_slot(OccurrenceSlot { + identity: admitted.observation.identity.occurrence.clone(), + current: admitted.observation.clone(), + }); + self.seen.insert(admitted.observation.identity.clone()); + } + MutationReceipt::Correct { occurrence, .. } => { + self.replace_slot(occurrence, admitted.observation.clone()); + self.seen.insert(admitted.observation.identity.clone()); + } + MutationReceipt::KeepVisibleUnanchored { .. } => { + self.unanchored.push(admitted.observation.clone()); + } + MutationReceipt::RefuseOverlapWithoutTextMap { .. } => { + self.read_only.push(admitted.observation.clone()); + } + MutationReceipt::RefuseReplay { .. } => {} + } + results.push(admitted); + } + results + } + + /// Mutation-authority texts in PCM order. Unanchored and read-only evidence + /// stay out of this projection. + pub fn delivery_text(&self) -> String { + let mut slots: Vec<&OccurrenceSlot> = self.partitions.values().flatten().collect(); + slots.sort_by_key(|slot| { + ( + slot.identity.capture_epoch, + slot.identity.sample_start, + slot.identity.sample_end, + ) + }); + slots + .into_iter() + .map(|slot| slot.current.text.as_str()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" ") + } + + /// Visible unanchored text, without mutation authority. + pub fn unanchored_text(&self) -> String { + self.unanchored + .iter() + .map(|observation| observation.text.as_str()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" ") + } + + pub fn committed_spans(&self) -> Vec { + self.partitions + .values() + .flatten() + .map(|slot| slot.current.as_span()) + .collect() + } + + fn admit_one(&self, incoming: &AcousticObservation) -> AdmittedObservation { + if !incoming.identity.occurrence.is_anchored() { + return AdmittedObservation { + observation: incoming.clone(), + receipt: MutationReceipt::KeepVisibleUnanchored { + text: incoming.text.clone(), + }, + }; + } + + if self.seen.contains(&incoming.identity) { + return AdmittedObservation { + observation: incoming.clone(), + receipt: MutationReceipt::RefuseReplay { + observation: incoming.identity.clone(), + }, + }; + } + + let priors = self.priors_for(&incoming.identity.occurrence); + + if let Some(prior) = priors + .iter() + .find(|slot| slot.identity == incoming.identity.occurrence) + { + let from = prior.current.identity.producer; + return AdmittedObservation { + observation: incoming.clone(), + receipt: MutationReceipt::Correct { + occurrence: incoming.identity.occurrence.clone(), + from, + to: incoming.identity.producer, + }, + }; + } + + if let Some(prior) = priors + .iter() + .find(|slot| slot.identity.overlaps(&incoming.identity.occurrence)) + { + return admit_overlap(incoming, prior); + } + + let receipt = if priors.is_empty() { + MutationReceipt::Preserve { + occurrence: incoming.identity.occurrence.clone(), + } + } else { + MutationReceipt::Insert { + occurrence: incoming.identity.occurrence.clone(), + } + }; + + AdmittedObservation { + observation: incoming.clone(), + receipt, + } + } + + fn priors_for(&self, occurrence: &OccurrenceIdentity) -> &[OccurrenceSlot] { + self.partitions + .get(&occurrence.partition_key()) + .map_or(&[], Vec::as_slice) + } + + fn record_slot(&mut self, slot: OccurrenceSlot) { + self.partitions + .entry(slot.identity.partition_key()) + .or_default() + .push(slot); + } + + fn replace_slot(&mut self, occurrence: &OccurrenceIdentity, observation: AcousticObservation) { + if let Some(slots) = self.partitions.get_mut(&occurrence.partition_key()) + && let Some(slot) = slots.iter_mut().find(|slot| slot.identity == *occurrence) + { + slot.current = observation; + } + } +} + +fn admit_overlap(incoming: &AcousticObservation, prior: &OccurrenceSlot) -> AdmittedObservation { + let clip_start = incoming + .identity + .occurrence + .sample_start + .max(prior.identity.sample_end); + if clip_start >= incoming.identity.occurrence.sample_end { + return AdmittedObservation { + observation: incoming.clone(), + receipt: MutationReceipt::RefuseOverlapWithoutTextMap { + occurrence: incoming.identity.occurrence.clone(), + }, + }; + } + + let exclusive = OccurrenceIdentity { + session: incoming.identity.occurrence.session.clone(), + capture_epoch: incoming.identity.occurrence.capture_epoch, + sample_start: clip_start, + sample_end: incoming.identity.occurrence.sample_end, + }; + + let Some(clipped_text) = text_mapped_to_range(incoming, &exclusive) else { + return AdmittedObservation { + observation: incoming.clone(), + receipt: MutationReceipt::RefuseOverlapWithoutTextMap { + occurrence: incoming.identity.occurrence.clone(), + }, + }; + }; + + let mut clipped = incoming.clone(); + clipped.text = clipped_text; + clipped.identity.occurrence = exclusive.clone(); + clipped.pins.retain(|pin| { + pin.sample_start >= exclusive.sample_start && pin.sample_end <= exclusive.sample_end + }); + AdmittedObservation { + observation: clipped, + receipt: MutationReceipt::Insert { + occurrence: exclusive, + }, + } +} + +fn text_mapped_to_range( + observation: &AcousticObservation, + range: &OccurrenceIdentity, +) -> Option { + if observation.pins.is_empty() { + return None; + } + let kept: Vec<&str> = observation + .pins + .iter() + .filter(|pin| pin.sample_start >= range.sample_start && pin.sample_end <= range.sample_end) + .map(|pin| pin.text.as_str()) + .filter(|text| !text.is_empty()) + .collect(); + if kept.is_empty() { + None + } else { + Some(kept.join(" ")) + } +} + +/// How two PCM ranges relate. Text is not an input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcousticSpanRelation { + Same, + Overlapping, + Disjoint, + Unanchored, + DifferentEpoch, +} + +/// Quality evidence for a range. Not part of identity. Not Eq/Hash of the span. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct AcousticQualityEvidence { + pub energy_db: Option, +} + +/// Relate two capture ranges. Energy is ignored on purpose. +pub fn relate(left: &TailSampleRange, right: &TailSampleRange) -> AcousticSpanRelation { + if left.sample_end <= left.sample_start || right.sample_end <= right.sample_start { + return AcousticSpanRelation::Unanchored; + } + if left.session != right.session || left.capture_epoch != right.capture_epoch { + return AcousticSpanRelation::DifferentEpoch; + } + if left.sample_start == right.sample_start && left.sample_end == right.sample_end { + return AcousticSpanRelation::Same; + } + if left.overlaps(right) { + return AcousticSpanRelation::Overlapping; + } + AcousticSpanRelation::Disjoint +} + +/// Mean energy is quality evidence. It never decides Same vs Disjoint. +pub fn mean_energy_is_identity(_evidence: AcousticQualityEvidence) -> bool { + false +} + +/// Admit incoming observations against a fresh ledger seeded with `committed`. +pub fn admit_observations( + committed: &[AcousticObservation], + incoming: &[AcousticObservation], +) -> (ObservationLedger, Vec) { + let mut ledger = ObservationLedger::default(); + ledger.admit(committed); + let admitted = ledger.admit(incoming); + (ledger, admitted) +} + +/// Join mutation-authority texts in admit order. +pub fn mutation_authority_text(admitted: &[AdmittedObservation]) -> String { + admitted + .iter() + .filter(|item| item.receipt.mutation_authority()) + .map(|item| item.observation.text.as_str()) + .filter(|text| !text.trim().is_empty()) + .collect::>() + .join(" ") +} + +/// Span-shaped wrapper used by Apple/L1/bus callsites that still carry +/// [`AcousticTranscriptSpan`]. Producer defaults to Apple so a second copy of +/// the same range from Whisper must go through [`AcousticObservation`]. +pub fn admit_acoustic_spans( + committed: &[AcousticTranscriptSpan], + incoming: &[AcousticTranscriptSpan], +) -> (Vec, Vec) { + let committed_obs: Vec<_> = committed + .iter() + .map(|span| AcousticObservation::from_span(span, ObservationProducer::Apple, 0, 0)) + .collect(); + let incoming_obs: Vec<_> = incoming + .iter() + .map(|span| AcousticObservation::from_span(span, ObservationProducer::Apple, 0, 0)) + .collect(); + let (ledger, admitted) = admit_observations(&committed_obs, &incoming_obs); + let receipts = admitted.into_iter().map(|item| item.receipt).collect(); + (ledger.committed_spans(), receipts) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn range(start: u64, end: u64) -> TailSampleRange { + TailSampleRange { + session: "take".into(), + capture_epoch: 1, + sample_start: start, + sample_end: end, + } + } + + fn iwo(start: u64, end: u64) -> AcousticTranscriptSpan { + AcousticTranscriptSpan { + text: "Iwo".into(), + range: range(start, end), + grain: AcousticSpanGrain::Word, + } + } + + fn apple_obs(span: &AcousticTranscriptSpan, request_id: u64) -> AcousticObservation { + AcousticObservation::from_span(span, ObservationProducer::Apple, request_id, 0) + } + + #[test] + fn five_iwo_on_five_pcm_ranges_all_survive() { + let incoming: Vec<_> = (0..5) + .map(|i| apple_obs(&iwo(i * 1600, i * 1600 + 1600), i)) + .collect(); + let (ledger, receipts) = admit_observations(&[], &incoming); + assert_eq!(ledger.committed_spans().len(), 5); + assert!( + receipts + .iter() + .all(|item| item.receipt.mutation_authority()) + ); + assert_eq!(ledger.delivery_text(), "Iwo Iwo Iwo Iwo Iwo"); + } + + #[test] + fn replaying_the_same_observation_does_not_mint_a_sixth() { + let first = apple_obs(&iwo(0, 1600), 7); + let (mut ledger, _) = admit_observations(&[], std::slice::from_ref(&first)); + let again = ledger.admit(&[first]); + assert!(ledger.committed_spans().len() == 1); + assert!(matches!( + again[0].receipt, + MutationReceipt::RefuseReplay { .. } + )); + assert_eq!(ledger.delivery_text(), "Iwo"); + } + + #[test] + fn apple_then_whisper_same_range_is_correction() { + let apple = apple_obs(&iwo(0, 1600), 1); + let mut whisper = AcousticObservation::from_span( + &AcousticTranscriptSpan { + text: "Ivo".into(), + range: range(0, 1600), + grain: AcousticSpanGrain::Word, + }, + ObservationProducer::Whisper, + 99, + 0, + ); + whisper.text = "Iwo".into(); + let (ledger, receipts) = admit_observations(&[apple], &[whisper]); + assert_eq!(ledger.committed_spans().len(), 1); + assert_eq!(ledger.delivery_text(), "Iwo"); + assert!(matches!( + receipts[0].receipt, + MutationReceipt::Correct { + from: ObservationProducer::Apple, + to: ObservationProducer::Whisper, + .. + } + )); + } + + #[test] + fn overlap_without_pins_does_not_clip_text() { + let committed = [apple_obs(&iwo(0, 48_000), 1)]; + let incoming = AcousticObservation { + identity: ObservationIdentity { + producer: ObservationProducer::Whisper, + request_id: 2, + generation: 0, + occurrence: OccurrenceIdentity::from_range(&range(32_000, 64_000)), + }, + text: "Iwo later".into(), + grain: AcousticSpanGrain::Phrase, + pins: Vec::new(), + }; + let (ledger, receipts) = admit_observations(&committed, &[incoming]); + assert_eq!(ledger.delivery_text(), "Iwo"); + assert!( + !ledger.delivery_text().contains("later"), + "unmapped overlap must not mint a textual duplicate" + ); + assert!(matches!( + receipts[0].receipt, + MutationReceipt::RefuseOverlapWithoutTextMap { .. } + )); + assert_eq!(receipts[0].observation.text, "Iwo later"); + } + + #[test] + fn overlap_with_pins_clips_range_and_text() { + let committed = [apple_obs(&iwo(0, 48_000), 1)]; + let incoming = AcousticObservation { + identity: ObservationIdentity { + producer: ObservationProducer::Whisper, + request_id: 2, + generation: 0, + occurrence: OccurrenceIdentity::from_range(&range(32_000, 64_000)), + }, + text: "Iwo later".into(), + grain: AcousticSpanGrain::Phrase, + pins: vec![ + AcousticWordPin { + text: "Iwo".into(), + sample_start: 32_000, + sample_end: 40_000, + }, + AcousticWordPin { + text: "later".into(), + sample_start: 48_000, + sample_end: 64_000, + }, + ], + }; + let (ledger, _) = admit_observations(&committed, &[incoming]); + assert_eq!(ledger.delivery_text(), "Iwo later"); + let spans = ledger.committed_spans(); + assert_eq!(spans.len(), 2); + assert_eq!(spans[1].text, "later"); + assert_eq!(spans[1].range.sample_start, 48_000); + assert_eq!(spans[1].range.sample_end, 64_000); + } + + #[test] + fn unanchored_range_stays_visible_without_mutation() { + let empty = AcousticObservation::from_span( + &AcousticTranscriptSpan { + text: "Iwo".into(), + range: range(8, 8), + grain: AcousticSpanGrain::Word, + }, + ObservationProducer::Apple, + 1, + 0, + ); + let (ledger, receipts) = admit_observations(&[], &[empty]); + assert!(ledger.committed_spans().is_empty()); + assert_eq!(ledger.unanchored_text(), "Iwo"); + assert!(!receipts[0].receipt.mutation_authority()); + assert!(receipts[0].receipt.visible()); + assert!(matches!( + receipts[0].receipt, + MutationReceipt::KeepVisibleUnanchored { .. } + )); + } + + #[test] + fn two_identical_observations_in_one_batch_second_is_replay() { + let first = apple_obs(&iwo(0, 1600), 3); + let duplicate = first.clone(); + let (ledger, receipts) = admit_observations(&[], &[first, duplicate]); + assert_eq!(ledger.committed_spans().len(), 1); + assert!(receipts[0].receipt.mutation_authority()); + assert!(matches!( + receipts[1].receipt, + MutationReceipt::RefuseReplay { .. } + )); + } + + #[test] + fn different_epoch_is_partitioned_not_refused() { + let epoch_one = apple_obs(&iwo(0, 1600), 1); + let mut epoch_two = apple_obs(&iwo(0, 1600), 2); + epoch_two.identity.occurrence.capture_epoch = 2; + epoch_two.identity.occurrence.session = "take".into(); + let (ledger, receipts) = admit_observations(&[epoch_one], &[epoch_two]); + assert_eq!(ledger.committed_spans().len(), 2); + assert!(receipts[0].receipt.mutation_authority()); + } + + #[test] + fn mean_energy_never_decides_identity() { + assert!(!mean_energy_is_identity(AcousticQualityEvidence { + energy_db: Some(-18.0), + })); + assert_eq!( + relate(&range(0, 1600), &range(1600, 3200)), + AcousticSpanRelation::Disjoint + ); + assert_eq!( + relate(&range(0, 1600), &range(0, 1600)), + AcousticSpanRelation::Same + ); + } + + #[test] + fn string_suffix_dedup_is_the_forbidden_path_for_anchored_spans() { + let incoming: Vec<_> = (0..5) + .map(|i| apple_obs(&iwo(i * 1600, i * 1600 + 1600), i)) + .collect(); + let (ledger, _) = admit_observations(&[], &incoming); + let collapsed = crate::pipeline::dedup::strip_suffix_overlap_live( + "Iwo Iwo Iwo Iwo", + "Iwo Iwo Iwo Iwo Iwo", + ); + assert_ne!( + collapsed.split_whitespace().count(), + 5, + "text overlap is the bug this module exists to refuse" + ); + assert_eq!(ledger.delivery_text().split_whitespace().count(), 5); + } +} diff --git a/core/pipeline/contracts.rs b/core/pipeline/contracts.rs index d8932153..5cf794e5 100644 --- a/core/pipeline/contracts.rs +++ b/core/pipeline/contracts.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; +use crate::stt::tail_provider::TailSampleRange; + // ═══════════════════════════════════════════════════════════ // Audio stage // ═══════════════════════════════════════════════════════════ @@ -612,6 +614,86 @@ pub trait DeltaSink: Send + Sync { // Engine events (intent layer) // ═══════════════════════════════════════════════════════════ +/// Provider that measured a sideband observation. +/// +/// This is deliberately typed rather than a free-form label: consumers may +/// trust only capabilities the named provider actually owns. Plain Silero VAD +/// measures speech probability and speech/non-speech timing; it does not +/// identify laughter, coughs, music, or environmental-noise classes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SidebandProvenance { + SileroVad, +} + +/// Honest classification of a span for which VAD found no speech. +/// +/// `UnknownNonSpeech` is intentionally the only value until a measured +/// classifier provider exists. It must never be rendered as a named sound. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NonSpeechEvidence { + UnknownNonSpeech, +} + +/// What a sideband observation proves on the PCM sample clock. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SidebandEvidenceKind { + /// Silero crossed the speech-on threshold at `range.sample_start`. + SpeechStart { speech_probability: f32 }, + /// Silero crossed the speech-off threshold at `range.sample_start`. + SpeechEnd { speech_probability: f32 }, + /// Closed non-speech gap between two measured speech edges. + /// + /// The classification is explicitly unknown: pause duration is timing + /// evidence, not evidence of laughter/noise/cough semantics. + Pause { + duration_samples: u64, + non_speech: NonSpeechEvidence, + }, +} + +/// Ordered, content-free evidence measured beside the transcript. +/// +/// `range` is the canonical half-open PCM identity for one capture epoch. +/// Speech edges use a zero-width range at the exact boundary sample; pauses +/// use the exact `[end_of_speech, next_start_of_speech)` gap. `sequence` is +/// monotonic within the session and never derives from transcript text. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SidebandEvidence { + pub sequence: u64, + pub range: TailSampleRange, + pub sample_rate_hz: u32, + pub provenance: SidebandProvenance, + pub evidence: SidebandEvidenceKind, +} + +/// Honest timing grain supplied by the recognizer for one lexical span. +/// Phrase/utterance timing must never be expanded into invented word ranges. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcousticSpanGrain { + Word, + Phrase, + Utterance, +} + +/// One lexical hypothesis pinned to the canonical capture PCM clock. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AcousticTranscriptSpan { + pub text: String, + pub range: TailSampleRange, + pub grain: AcousticSpanGrain, +} + +/// Acoustic identity of one committed utterance and its honest-grain spans. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AcousticTranscriptIdentity { + pub range: TailSampleRange, + pub spans: Vec, +} + /// Events emitted by the transcription engine. /// /// These are semantic events — the engine communicates what happened @@ -625,6 +707,12 @@ pub enum EngineEvent { VadStart { speech_prob: f32, ts_ms: u64 }, /// VAD detected speech end. VadEnd { speech_prob: f32, ts_ms: u64 }, + /// Content-free PCM evidence from an orthogonal measured provider. + /// + /// Sideband events are never transcript mutations. Reducers, delivery, + /// and fail-open paths must preserve text byte-for-byte when they arrive + /// or when they are absent. + SidebandEvidence { evidence: SidebandEvidence }, /// Session or utterance completed without usable speech content. /// /// Emitted when VAD sees no speech at all, or when speech-like segments are @@ -692,6 +780,10 @@ pub enum EngineEvent { compression_ratio: Option, quality_gate_dropped: bool, confidence_flags: Vec, + /// Canonical PCM identity. `None` is legacy/unanchored evidence and is + /// surfaced as a failed Transcript Bus coverage receipt. + #[serde(default, skip_serializing_if = "Option::is_none")] + acoustic: Option, }, /// Replace a bounded char range inside an already-committed utterance. @@ -1200,6 +1292,7 @@ mod tests { compression_ratio: Some(1.2), quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }; if let EngineEvent::UtteranceFinal { utterance_id, @@ -1213,6 +1306,7 @@ mod tests { compression_ratio, quality_gate_dropped, confidence_flags, + .. } = event { assert_eq!(utterance_id, 42); @@ -1335,6 +1429,59 @@ mod tests { assert_eq!(committed, "immutable"); } + /// Sideband evidence is serializable PCM truth and never a text command. + #[test] + fn engine_event_sideband_roundtrip_and_noop_apply() { + let event = EngineEvent::SidebandEvidence { + evidence: SidebandEvidence { + sequence: 3, + range: TailSampleRange { + session: "session-abc".to_string(), + capture_epoch: 2, + sample_start: 16_000, + sample_end: 24_000, + }, + sample_rate_hz: 16_000, + provenance: SidebandProvenance::SileroVad, + evidence: SidebandEvidenceKind::Pause { + duration_samples: 8_000, + non_speech: NonSpeechEvidence::UnknownNonSpeech, + }, + }, + }; + + let json = serde_json::to_value(&event).expect("serialize sideband evidence"); + assert_eq!( + json.get("type").and_then(serde_json::Value::as_str), + Some("sideband_evidence") + ); + let payload = json.get("evidence").expect("typed evidence payload"); + assert_eq!( + payload + .get("provenance") + .and_then(serde_json::Value::as_str), + Some("silero_vad") + ); + assert_eq!( + payload + .get("evidence") + .and_then(|value| value.get("non_speech")) + .and_then(serde_json::Value::as_str), + Some("unknown_non_speech") + ); + + let roundtrip: EngineEvent = + serde_json::from_value(json).expect("deserialize sideband evidence"); + assert_eq!(roundtrip, event); + + let mut committed = "byte-stable transcript".to_string(); + let applied = roundtrip + .apply_to_committed_text(&mut committed) + .expect("sideband apply is a noop"); + assert!(!applied); + assert_eq!(committed.as_bytes(), b"byte-stable transcript"); + } + // ── RawTranscript confidence metadata ── /// Default confidence fields are unset / not quality-dropped. @@ -1623,6 +1770,7 @@ mod tests { TranscriptionConfidenceFlag::VeryLowSpeech, TranscriptionConfidenceFlag::PossibleHallucinationLogprob, ], + acoustic: None, }; if let EngineEvent::UtteranceFinal { vad_speech_pct, @@ -1669,6 +1817,7 @@ mod tests { TranscriptionConfidenceFlag::PossibleHallucinationLogprob, TranscriptionConfidenceFlag::QualityGateDropped, ], + acoustic: None, }; if let EngineEvent::UtteranceFinal { vad_speech_pct, diff --git a/core/pipeline/mod.rs b/core/pipeline/mod.rs index 11ee15d4..a53c51af 100644 --- a/core/pipeline/mod.rs +++ b/core/pipeline/mod.rs @@ -2,6 +2,8 @@ //! that fan those events out to consumers, overlap dedup, streaming session //! management, and the post-processing passes applied to emitted text. +/// Occurrence / observation / mutation-receipt ledger for acoustic spans. +pub mod acoustic_identity; /// Event contracts: EngineEvent, sinks trait, and shared pipeline types. pub mod contracts; /// Overlap/duplicate utterance suppression for streamed transcript events. @@ -20,6 +22,10 @@ pub mod stream_postprocess; pub mod streaming; // Re-export core event types for ergonomic access +pub use acoustic_identity::{ + AcousticObservation, MutationReceipt, ObservationIdentity, ObservationLedger, + ObservationProducer, OccurrenceIdentity, admit_acoustic_spans, admit_observations, +}; pub use contracts::{DropKind, EngineEvent, EventSink}; pub use sinks::{CollectorEventSink, DeltaSinkAdapter, FanoutEventSink}; diff --git a/core/pipeline/sinks.rs b/core/pipeline/sinks.rs index bee0bed1..ffca6082 100644 --- a/core/pipeline/sinks.rs +++ b/core/pipeline/sinks.rs @@ -410,6 +410,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); // After final, next utterance must append with a word separator. @@ -449,6 +450,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); adapter.on_event(&EngineEvent::Correction { rev: 2, @@ -554,6 +556,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); assert_eq!(sink.events().len(), 3); diff --git a/core/pipeline/stream_postprocess.rs b/core/pipeline/stream_postprocess.rs index 9eededd8..ca9fc381 100644 --- a/core/pipeline/stream_postprocess.rs +++ b/core/pipeline/stream_postprocess.rs @@ -307,6 +307,29 @@ impl Lexicon { if current_mtime == self.custom_mtime { return; } + self.reload_custom_rules(current_mtime); + } + + /// Move the custom-rule source to a new canonical data directory. + /// + /// The application normally keeps one data directory for its lifetime, but + /// tests and explicit runtime reconfiguration may change `CODESCRIBE_DATA_DIR`. + /// Path identity must therefore participate in hot reload: comparing only + /// mtimes leaves the process-global singleton pinned to whichever directory + /// initialized it first. + fn rebind_custom_path(&mut self, custom_path: PathBuf) { + if self.custom_path == custom_path { + return; + } + self.custom_path = custom_path; + let current_mtime = fs::metadata(&self.custom_path) + .ok() + .and_then(|m| m.modified().ok()); + self.reload_custom_rules(current_mtime); + } + + /// Replace only the custom half of the table, preserving compiled builtins. + fn reload_custom_rules(&mut self, current_mtime: Option) { self.custom_rules.clear(); self.custom_canonicals.clear(); let custom_count = fs::read_to_string(&self.custom_path) @@ -362,21 +385,6 @@ impl Lexicon { fn rule_count(&self) -> usize { self.builtin_rules.len() + self.custom_rules.len() } - - /// 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_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, - ) - } } /// Take the write lock and hot-reload the singleton's custom rules if the file @@ -386,6 +394,7 @@ fn maybe_reload_global_lexicon() { let mut lexicon = GLOBAL_LEXICON .write() .expect("global lexicon write lock poisoned"); + lexicon.rebind_custom_path(Config::config_dir().join("lexicon.custom.jsonl")); lexicon.maybe_reload(); } @@ -408,7 +417,20 @@ fn apply_global_lexicon(text: &str) -> String { /// (e.g. "Loctree" -> "Luxury"). pub fn apply_lexicon(text: &str) -> String { maybe_reload_global_lexicon(); - apply_global_lexicon(text) + apply_active_names( + &apply_global_lexicon(text), + &crate::stt::active_names::active_names(), + ) +} + +fn apply_active_names(text: &str, active_names: &[String]) -> String { + let mut corrected = text.to_string(); + for name in active_names { + if let Some(pattern) = build_word_regex(name) { + corrected = pattern.replace_all(&corrected, name.as_str()).into_owned(); + } + } + corrected } /// Build the domain-vocabulary hint fed into Whisper's `initial_prompt`. @@ -528,14 +550,30 @@ pub fn whisper_initial_prompt() -> Option { pub fn whisper_initial_prompt_for_window( window_context: Option<&str>, ) -> Option { - if !stt_initial_prompt_enabled() { + let active_names = crate::stt::active_names::active_names(); + let lexicon_enabled = stt_initial_prompt_enabled(); + if !lexicon_enabled && active_names.is_empty() { return None; } - maybe_reload_global_lexicon(); - let lexicon = GLOBAL_LEXICON - .read() - .expect("global lexicon read lock poisoned"); - let receipt = lexicon.whisper_initial_prompt_receipt(window_context)?; + let receipt = if lexicon_enabled { + maybe_reload_global_lexicon(); + let lexicon = GLOBAL_LEXICON + .read() + .expect("global lexicon read lock poisoned"); + let protected = active_names + .iter() + .chain(lexicon.protected_canonicals.iter()) + .cloned() + .collect::>(); + build_lexicon_voice_receipt( + &protected, + &lexicon.custom_canonicals, + &prioritized_domain_terms(window_context), + WHISPER_INITIAL_PROMPT_TOKEN_BUDGET, + )? + } else { + build_lexicon_voice_receipt(&active_names, &[], &[], WHISPER_INITIAL_PROMPT_TOKEN_BUDGET)? + }; info!( scope = "window", selected_terms = ?receipt.terms, @@ -1340,6 +1378,18 @@ fn truncate_for_embedding(text: &str) -> String { #[cfg(test)] mod tests { use super::*; + + #[test] + fn active_name_canonicalization_is_exact_not_fuzzy() { + let names = vec!["Iwo".to_string()]; + assert_eq!(apply_active_names("cześć iwo", &names), "cześć Iwo"); + assert_eq!(apply_active_names("lubię piwo", &names), "lubię piwo"); + assert_eq!( + apply_active_names("nieznane imię", &[]), + "nieznane imię", + "unknown or stale names fail open byte-for-byte" + ); + } use serial_test::serial; use std::ffi::OsString; @@ -1369,6 +1419,40 @@ mod tests { } } + #[test] + #[serial] + fn active_lease_reaches_whisper_context_even_when_static_prompt_is_off() { + let temp = tempfile::tempdir().unwrap(); + let lease_dir = temp.path().join("leases"); + std::fs::create_dir_all(&lease_dir).unwrap(); + let heartbeat = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64(); + std::fs::write( + lease_dir.join("iwo.json"), + serde_json::to_vec(&serde_json::json!({ + "schema": "codescribe.agent-bridge.lease.v1", + "name": "iwo", + "active": true, + "heartbeat_unix": heartbeat, + })) + .unwrap(), + ) + .unwrap(); + let _bridge = EnvRestore::capture("CODESCRIBE_AGENT_BRIDGE_HOME"); + let _prompt = EnvRestore::capture(STT_INITIAL_PROMPT_ENABLED_ENV); + unsafe { + std::env::set_var("CODESCRIBE_AGENT_BRIDGE_HOME", temp.path()); + std::env::set_var(STT_INITIAL_PROMPT_ENABLED_ENV, "0"); + } + + let receipt = whisper_initial_prompt_for_window(None).expect("active name prompt"); + assert_eq!(receipt.terms, ["Iwo"]); + assert!(receipt.prompt.contains("Iwo")); + assert_eq!(apply_lexicon("Iwo, lubię piwo"), "Iwo, lubię piwo"); + } + /// Builtin programming lexicon rewrites Whisper mis-hears (e.g. `doker` → `Docker`). #[test] fn test_lexicon_rewrite() { @@ -1554,6 +1638,41 @@ mod tests { assert_eq!(final_pass_guardrail_reason(raw, candidate), None); } + /// The process-global lexicon follows the active data directory, not the + /// directory that happened to initialize the singleton first. + #[test] + #[serial] + fn global_lexicon_rebinds_when_data_dir_changes() { + let _data_dir = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let first = tempfile::tempdir().expect("first data dir"); + let second = tempfile::tempdir().expect("second data dir"); + + std::fs::write( + first.path().join("lexicon.custom.jsonl"), + r#"{"term":"RebindFirst","mispronunciations":["zxq rebind first"]}"#, + ) + .expect("first custom lexicon"); + std::fs::write( + second.path().join("lexicon.custom.jsonl"), + r#"{"term":"RebindSecond","mispronunciations":["zxq rebind second"]}"#, + ) + .expect("second custom lexicon"); + + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", first.path()) }; + assert_eq!(apply_lexicon("mówię zxq rebind first"), "mówię RebindFirst"); + + unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", second.path()) }; + assert_eq!( + apply_lexicon("mówię zxq rebind second"), + "mówię RebindSecond" + ); + assert_eq!( + apply_lexicon("mówię zxq rebind first"), + "mówię zxq rebind first", + "rules from the previous data directory must be discarded" + ); + } + /// Custom lexicon mtime change reloads rules without recompiling builtins. #[test] fn test_hot_reload_picks_up_new_rules() { @@ -1608,12 +1727,16 @@ mod tests { fn overlay_correction_chain_teaches_custom_lexicon_for_next_transcript() { let temp_dir = tempfile::tempdir().expect("temp data dir for quality chain"); let _data_dir = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _min_corrections = EnvRestore::capture("CODESCRIBE_LEXICON_MIN_CORRECTIONS"); let temp_root = temp_dir .path() .canonicalize() .unwrap_or_else(|_| temp_dir.path().to_path_buf()); unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + // This consumer-chain fixture proves stored rules are consumed by + // StreamPostProcessor; threshold behavior lives in overlay_quality. + std::env::set_var("CODESCRIBE_LEXICON_MIN_CORRECTIONS", "1"); } let candidates = diff --git a/core/pipeline/streaming/apple_live_session.rs b/core/pipeline/streaming/apple_live_session.rs index 9e7ec2d8..af4fffe7 100644 --- a/core/pipeline/streaming/apple_live_session.rs +++ b/core/pipeline/streaming/apple_live_session.rs @@ -8,25 +8,25 @@ //! //! Every seal runs the shared `StreamPostProcessor::process_utterance` pass //! (lexicon + cleanup, no semantic gate) BEFORE the text becomes committed -//! canvas — the daily-driver path must satisfy AGENTS.md item 3 ("lexicon -//! corrections applied on the fly"). Correcting after commit would be a -//! post-commit rewrite, which the append-only doctrine forbids. +//! canvas. Local Whisper may improve that pending text only through the same +//! PCM-identified rewrite fence before `UtteranceFinal` is emitted. //! -//! Whisper is never the live engine here. Under -//! `CODESCRIBE_LAYERED_TRANSCRIPTION=phase1+` it runs as Layer 1 gap-fill -//! (W2-A): each sealed utterance resolves to its retained PCM window and is -//! re-transcribed off this path, emitting bounded -//! `ReplaceRange { source: TailPatch }` events — AGENTS.md (THE ONE RULE): -//! filling canvas gaps on the go, never a stop-time full-text authority. -//! Outside that flag Whisper stays the file final-pass / emergency fill -//! (controller stop path). Escape hatch: +//! Whisper is never the primary live engine here. Local Power arms it as the +//! required Layer 1 repair lane: each sealed utterance resolves to its retained PCM window and is +//! re-transcribed off this path. Bounded TailPatch mutations are applied to the +//! exact pending baseline behind one rewrite fence; the resulting +//! `UtteranceFinal` already contains them. No patch event may follow finality — +//! AGENTS.md (THE ONE RULE): filling canvas gaps on the go, never a stop-time +//! full-text authority. +//! Apple-only deliberately omits this lane; explicit off/invalid overrides in +//! Local Power produce a typed degraded state. Escape hatch: //! `CODESCRIBE_APPLE_STT_LIVE_MODE=wav` restores the legacy VAD+scheduler path. //! //! The bridge global lock + child process live on a **dedicated OS thread** //! (MutexGuard is `!Send`); the async session only shuttles PCM in and //! `EngineEvent`s out. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; use std::sync::mpsc as std_mpsc; use std::thread; @@ -48,12 +48,17 @@ use crate::audio::capture_receipt::{ CaptureLevelAccumulator, CapturePathMeta, begin_session_energy_clock, emit_capture_level_receipt, }; +use crate::pipeline::acoustic_identity::{ + AcousticObservation, ObservationLedger, ObservationProducer, mutation_authority_text, +}; use crate::pipeline::contracts::{ - DropKind, EngineEvent, EventSink, LayerSource, TranscriptSegment, + AcousticSpanGrain, DropKind, EngineEvent, EventSink, LayerSource, TranscriptSegment, }; use crate::pipeline::stream_postprocess::StreamPostProcessor; use crate::stt::apple_stt::{LiveStreamEvent, LiveStreamSession}; -use crate::stt::tail_patcher::{SkipReasonCode, TailPatchConfig, TailPatchOutcome}; +use crate::stt::tail_patcher::{ + SkipReasonCode, TailPatchConfig, TailPatchOutcome, compute_tail_patch_with_context, +}; use crate::stt::tail_provider::{ TailEvidenceSource, TailEvidenceStability, TailProviderEvidence, TailProviderPayload, TailProviderRequest, TailRequestIdentity, TailSampleRange, TailTimingQuality, TimedTailSegment, @@ -66,11 +71,10 @@ use super::live_audio_buffer::{DEFAULT_RETENTION_SECS, LiveAudioBuffer, Resolved use super::progressive_seal::{ AppleCommit, ProgressiveSealMachine, SealTick, SealedSpan, seal_span_text, }; -#[cfg(test)] -use super::session::emit_tail_patch_result; use super::session::{ - SessionConfig, TailPatchJobResult, UNDER_COMMIT_WARNING_CODE, compute_tail_patch_job, - emit_session_finalised, log_tail_patch_session_receipt, tail_patch_enabled, + SessionConfig, TailPatchDrainDisposition, TailPatchJobResult, TailPatchSessionReceipt, + UNDER_COMMIT_WARNING_CODE, compute_tail_patch_job, emit_session_finalised, + log_tail_patch_session_receipt, }; use super::silero_fusion::{ FusionContextMode, FusionWord, SileroIngress, bound_context_range, conservative_fuse, @@ -99,6 +103,31 @@ const TAIL_PATCH_QUEUE_CAP: usize = 8; /// because the loop was waiting on the wrong condition. const TAIL_PATCH_CLOSURE_TIMEOUT: Duration = Duration::from_secs(5); +/// Stable outward receipt when accepted Layer 1 work cannot land before the +/// Apple seal worker closes. The Apple canvas remains authoritative. +pub const TAIL_PATCH_DRAIN_TIMEOUT_WARNING_CODE: &str = "tail_patch_drain_timeout"; + +/// Local power was selected but its required live patcher could not arm. +pub const LOCAL_TAIL_PATCH_DEGRADED_WARNING_CODE: &str = "local_tail_patch_degraded"; + +/// The provider result did not prove that it describes the PCM range owned by +/// the pending span. No transcript text is included in this receipt. +pub const TAIL_PATCH_IDENTITY_MISMATCH_WARNING_CODE: &str = "tail_patch_identity_mismatch"; + +/// The same request/event application key reached the rewrite fence twice. +pub const TAIL_PATCH_REPLAY_REFUSED_WARNING_CODE: &str = "tail_patch_replay_refused"; + +/// A correction reached the owner after its target crossed the immutable seal. +pub const TAIL_PATCH_SEALED_FENCE_WARNING_CODE: &str = "tail_patch_sealed_fence"; + +/// The bounded range supplied by the patcher could not be applied to the exact +/// pending string it named. The Apple floor remains untouched. +pub const TAIL_PATCH_APPLY_REFUSED_WARNING_CODE: &str = "tail_patch_apply_refused"; + +/// A full-session refiner result has no per-word PCM identity and arrives only +/// after Apple finals have sealed. It is evidence, never mutation authority. +pub const LAYER1_CANDIDATE_UNBOUND_WARNING_CODE: &str = "layer1_candidate_unbound"; + /// 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. @@ -135,6 +164,7 @@ struct TailPatchRequest { struct TailPatchCompletion { utterance_id: u64, covered_through_secs: f32, + request_identity: Option, outcome: TailPatchOutcome, payload: Option, span_map: Vec, @@ -145,6 +175,7 @@ struct TailPatchCompletion { struct TailPatchInFlight { utterance_id: u64, covered_through_secs: f32, + request_identity: TailRequestIdentity, span_map: Vec, member_ids: Vec<(u64, f32)>, } @@ -158,11 +189,6 @@ struct AppleTailPatchLane { 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 { @@ -175,8 +201,6 @@ impl AppleTailPatchLane { language, // F2: thresholds stay exactly where the shared primitive puts them. config: TailPatchConfig::from_env(), - replacements: 0, - skipped: 0, } } @@ -209,38 +233,23 @@ impl AppleTailPatchLane { self.jobs.next().await } - /// Emit a finished job's patch and fold its replacement count into the - /// 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) { - 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)); - } - /// Convert a finished job into the closure message consumed by the - /// progressive seal owner. Patch events are emitted by that owner only - /// after `UtteranceFinal`, preserving event order. + /// progressive seal owner. The request identity rides separately from the + /// provider payload so failures can still close the exact pending window. fn finish_for_worker( &mut self, inflight: Option, result: Result, ) -> TailPatchCompletion { - let (fallback_id, fallback_end, span_map, member_ids) = match inflight { + let (fallback_id, fallback_end, request_identity, span_map, member_ids) = match inflight { Some(job) => ( job.utterance_id, job.covered_through_secs, + Some(job.request_identity), job.span_map, job.member_ids, ), - None => (0, 0.0, Vec::new(), Vec::new()), + None => (0, 0.0, None, Vec::new(), Vec::new()), }; match result { Ok(job) => { @@ -249,6 +258,7 @@ impl AppleTailPatchLane { TailPatchCompletion { utterance_id, covered_through_secs: fallback_end, + request_identity, outcome, payload: Some(job.payload), span_map, @@ -258,6 +268,7 @@ impl AppleTailPatchLane { Err(error) => TailPatchCompletion { utterance_id: fallback_id, covered_through_secs: fallback_end, + request_identity, outcome: TailPatchOutcome::skipped( crate::stt::tail_patcher::SkipReasonCode::ProviderError, format!("tail patch failed: {error}"), @@ -277,37 +288,11 @@ impl AppleTailPatchLane { 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 @@ -338,6 +323,52 @@ fn emit_layer1_degrade_warning(event_sink: &dyn EventSink, reason: Layer1Degrade }); } +/// Report abandoned local tail-patch work exactly once, before session finality. +fn report_tail_patch_drain_degrade(event_sink: &dyn EventSink, abandoned: u64) { + if abandoned == 0 { + return; + } + event_sink.on_event(&EngineEvent::Warning { + code: TAIL_PATCH_DRAIN_TIMEOUT_WARNING_CODE.to_string(), + message: format!( + "{abandoned} accepted Layer 1 tail-patch job(s) missed the bounded stop drain; Apple live text was preserved" + ), + }); +} + +/// Reconcile job-level terminal buckets after the worker's bounded closure +/// loop. No-change, provider skip, and rewrite-fence refusal all land in +/// `skipped`; `applied` means a completed job whose bounded mutation survived. +fn tail_patch_receipt_after_stop( + armed: bool, + submitted: u64, + applied_jobs: u64, + skipped_jobs: u64, + timeout_residue: u64, +) -> TailPatchSessionReceipt { + let applied = applied_jobs.min(submitted); + let skipped = skipped_jobs.min(submitted.saturating_sub(applied)); + let timed_out = timeout_residue.min(submitted.saturating_sub(applied + skipped)); + let abandoned = submitted.saturating_sub(applied + skipped + timed_out); + TailPatchSessionReceipt::new( + armed, + submitted, + applied, + skipped, + timed_out, + abandoned, + if !armed { + TailPatchDrainDisposition::NotArmed + } else if timed_out > 0 { + TailPatchDrainDisposition::TimedOut + } else if abandoned > 0 { + TailPatchDrainDisposition::Abandoned + } else { + TailPatchDrainDisposition::Completed + }, + ) +} + /// Drive one progressive Apple stream session until the audio channel closes. pub(crate) async fn apple_stream_transcription_session( mut chunk_receiver: mpsc::Receiver>, @@ -376,19 +407,18 @@ pub(crate) async fn apple_stream_transcription_session( // 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()); + crate::llm::inline_format::begin_session(&session_id, 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. + // C1: split the one recording-start decision into its explicit local + // exact-span disposition and (when Cloud is selected) the injected generic + // provider. Construction and consent live with the settings owner. let lane_input = Layer1SessionInput { session_id: Layer1SessionId::new(session_id.clone()) .expect("uuid session ids are never blank"), locale: language.clone(), sample_rate, }; + let local_tail_patch = layer1.local_tail_patch_disposition(); 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); @@ -401,20 +431,33 @@ 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(); + // The local Whisper decision is resolved once from product mode + the + // compatibility phase token before capture starts. Never re-read env here: + // Settings, replay, logging, and runtime must all observe one decision. + let tail_patch_on = local_tail_patch.is_some_and(|decision| decision.is_armed()); if tail_patch_on { info!( - "Layered transcription Layer 1 (Whisper tail-patch) enabled on Apple progressive path" + disposition = local_tail_patch + .map(|decision| decision.as_token()) + .unwrap_or("not_applicable"), + "Local Whisper tail-patch armed on Apple progressive path" + ); + } else if let Some(disposition) = local_tail_patch { + warn!( + disposition = disposition.as_token(), + "Local power degraded: required Whisper tail-patch is not armed" ); + event_sink.on_event(&EngineEvent::Warning { + code: LOCAL_TAIL_PATCH_DEGRADED_WARNING_CODE.to_string(), + message: disposition.as_token().to_string(), + }); } let mut tail_patch_lane = AppleTailPatchLane::new(sample_rate, language.clone()); // 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 = None; + let mut tail_patch_submitted = 0u64; // 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::(); @@ -505,9 +548,11 @@ pub(crate) async fn apple_stream_transcription_session( // only ever schedules and collects — inference never sits on the // event-drain path (F1). Some(req) = tp_rx.recv(), if !tail_patch_in_flight => { + tail_patch_submitted = tail_patch_submitted.saturating_add(1); let inflight = TailPatchInFlight { utterance_id: req.utterance_id, covered_through_secs: req.covered_through_secs, + request_identity: req.provider_request.identity.clone(), span_map: req.span_map.clone(), member_ids: req.member_ids.clone(), }; @@ -560,6 +605,7 @@ pub(crate) async fn apple_stream_transcription_session( // 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() { + tail_patch_submitted = tail_patch_submitted.saturating_add(1); abandoned_tail_patch_jobs = abandoned_tail_patch_jobs.saturating_add(1); } if abandoned_tail_patch_jobs > 0 { @@ -568,7 +614,6 @@ pub(crate) async fn apple_stream_transcription_session( "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 @@ -599,6 +644,10 @@ pub(crate) async fn apple_stream_transcription_session( } let mut sealed_spans = Vec::new(); + let mut accepted_tail_patch_replacements = 0u64; + let mut tail_patch_jobs_applied = 0u64; + let mut tail_patch_jobs_skipped = 0u64; + let mut tail_patch_timeout_residue = 0u64; match worker.join() { Ok(Ok(outcome)) => { info!( @@ -606,8 +655,14 @@ pub(crate) async fn apple_stream_transcription_session( filtered_empty_drops = outcome.filtered_empty_drops, unresolved_windows = outcome.unresolved_windows, under_commit_escalations = outcome.under_commit_escalations, + tail_patch_replacements = outcome.tail_patch_replacements, + tail_patch_refusals = outcome.tail_patch_refusals, "Apple progressive live session finished" ); + accepted_tail_patch_replacements = outcome.tail_patch_replacements; + tail_patch_jobs_applied = outcome.tail_patch_jobs_applied; + tail_patch_jobs_skipped = outcome.tail_patch_jobs_skipped; + tail_patch_timeout_residue = outcome.tail_patch_timeout_residue; sealed_spans = outcome.sealed_spans; } Ok(Err(e)) => { @@ -624,16 +679,33 @@ 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; + let receipt = tail_patch_receipt_after_stop( + tail_patch_on, + tail_patch_submitted, + tail_patch_jobs_applied, + tail_patch_jobs_skipped, + tail_patch_timeout_residue, + ); + log_tail_patch_session_receipt(receipt); + report_tail_patch_drain_degrade( + event_sink.as_ref(), + receipt.timed_out.saturating_add(receipt.abandoned), + ); + event_sink.on_event(&receipt.as_event()); 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); + let unbound_mutations = plan_live_layer1_gap_patches(&sealed_spans, &candidate).len(); + if unbound_mutations > 0 { + event_sink.on_event(&EngineEvent::Warning { + code: LAYER1_CANDIDATE_UNBOUND_WARNING_CODE.to_string(), + message: format!( + "full-session Layer 1 candidate proposed {unbound_mutations} mutations without per-word PCM identity after seal; Apple text preserved" + ), + }); } info!( provider_chars = candidate.chars().count(), - live_cloud_patches, "Live cloud Layer 1 reconciled against committed Apple floor" + unbound_mutations, + "Live cloud Layer 1 candidate retained as evidence; no post-seal mutation" ); } emit_capture_level_receipt( @@ -643,9 +715,7 @@ pub(crate) async fn apple_stream_transcription_session( emit_session_finalised( event_sink.as_ref(), session_id, - tail_patch_lane - .replacements() - .saturating_add(live_cloud_patches), + accepted_tail_patch_replacements, ); } @@ -657,11 +727,24 @@ pub(crate) async fn apple_stream_transcription_session( /// instance (one lexicon reload cadence, one drop counter). struct PendingAppleSeal { raw_text: String, + /// Byte-identical baseline handed to the tail patcher. Patch char offsets + /// are valid only against this string, never against raw Apple text. + layer1_baseline: String, start_ts: f32, end_ts: f32, segments: Vec, } +/// Structural idempotence key for one bounded mutation at the rewrite fence. +/// Text is deliberately absent: identical words spoken in disjoint PCM ranges +/// are different applications and must both survive. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct TailPatchApplicationKey { + request: TailRequestIdentity, + target_utterance_id: u64, + event_ordinal: usize, +} + struct AppleSealState { session_id: String, capture_epoch: u64, @@ -706,8 +789,18 @@ struct AppleSealState { progressive: ProgressiveSealMachine, /// Event payload retained until the machine declares the span sealed. pending_events: BTreeMap, - /// Whisper outcomes retained until their final has been emitted. - tail_patch_outcomes: BTreeMap, + /// Every accepted Layer 1 mutation key for this capture epoch. Replays are + /// refused before they can reach the single rewrite fence. + tail_patch_applications: HashSet, + /// Bounded patch events that actually rewrote a pending span this session. + tail_patch_replacements: u64, + /// Completed provider jobs whose mutation crossed the rewrite fence. + tail_patch_jobs_applied: u64, + /// Completed provider jobs that produced no accepted mutation (no-change, + /// provider skip, identity/range refusal, or sealed-fence refusal). + tail_patch_jobs_skipped: u64, + /// Identity, replay, sealed-fence, or invalid-range refusals. + tail_patch_refusals: u64, /// 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, @@ -717,11 +810,14 @@ struct AppleSealState { /// back to Apple's own segment boundaries. fusion_seal_armed: bool, fusion_context: FusionContextMode, + /// Occurrence / observation ledger for this capture. Apple seals and + /// Whisper windows admit here; text overlap is not authority. + observations: ObservationLedger, } impl AppleSealState { - /// Fresh seal state with Layer 1 disabled (`tail_patch: None`) — the default - /// shape when `CODESCRIBE_LAYERED_TRANSCRIPTION` is unset. + /// Fresh isolated seal state with Layer 1 disabled (`tail_patch: None`). + /// Product-mode arming is injected by the session owner, not this test helper. #[cfg(test)] fn new(sample_rate: u32) -> Self { Self::new_for_session(sample_rate, uuid::Uuid::new_v4().to_string()) @@ -751,10 +847,15 @@ impl AppleSealState { sealed_prefix: String::new(), progressive: ProgressiveSealMachine::new(), pending_events: BTreeMap::new(), - tail_patch_outcomes: BTreeMap::new(), + tail_patch_applications: HashSet::new(), + tail_patch_replacements: 0, + tail_patch_jobs_applied: 0, + tail_patch_jobs_skipped: 0, + tail_patch_refusals: 0, fusion: None, fusion_seal_armed: false, fusion_context: FusionContextMode::UtteranceOnly, + observations: ObservationLedger::default(), } } @@ -860,17 +961,62 @@ impl AppleSealState { completion: TailPatchCompletion, now_secs: f32, ) { + let replacements_before = self.tail_patch_replacements; let utterance_id = completion.utterance_id; self.tail_patch_awaiting_completion = self.tail_patch_awaiting_completion.saturating_sub(1); + let request_identity = completion.request_identity; + let payload_identity = completion + .payload + .as_ref() + .map(|payload| payload.identity.clone()); let (evidence, words) = completion.payload.map_or((None, Vec::new()), |payload| { (Some(payload.evidence), payload.segments) }); + let request_id = request_identity + .as_ref() + .map_or(utterance_id, |identity| identity.request_id); + let whisper_observations: Vec = words + .iter() + .enumerate() + .map(|(generation, segment)| { + AcousticObservation::from_timed_segment( + segment, + ObservationProducer::Whisper, + request_id, + generation as u64, + AcousticSpanGrain::Word, + ) + }) + .collect(); + let admitted = self.observations.admit(&whisper_observations); + let authorized = mutation_authority_text(&admitted); + let whisper_text = words + .iter() + .map(|segment| segment.text.as_str()) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "); // 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 !authorized.is_empty() && authorized != whisper_text { + let baseline = self + .pending_events + .get(&utterance_id) + .map(|pending| pending.layer1_baseline.clone()) + .unwrap_or_default(); + compute_tail_patch_with_context( + &baseline, + &authorized, + "", + utterance_id, + &TailPatchConfig::default(), + ) + } else if authorized.is_empty() && !whisper_observations.is_empty() { + TailPatchOutcome::NoChange } else if self.fusion.is_some() { apply_conservative_fusion(self, ev_tx, utterance_id, &words, completion.outcome) } else { @@ -883,53 +1029,204 @@ impl AppleSealState { }; 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); + let identity_accepted = self.apply_tail_patch_before_seal( + ev_tx, + id, + request_identity.as_ref(), + payload_identity.as_ref(), + &member_outcome, + ); if index == 0 { self.progressive .note_whisper_window_elapsed_with_provenance( id, end, - evidence.clone(), - words.clone(), + identity_accepted.then(|| evidence.clone()).flatten(), + if identity_accepted { + words.clone() + } else { + Vec::new() + }, ); } 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); + if self.tail_patch_replacements > replacements_before { + self.tail_patch_jobs_applied = self.tail_patch_jobs_applied.saturating_add(1); + } else { + self.tail_patch_jobs_skipped = self.tail_patch_jobs_skipped.saturating_add(1); } + self.emit_ready_progressive_seals(ev_tx, now_secs); } - /// 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( + /// Apply a Layer 1 outcome to the pending text behind the one immutable + /// rewrite fence. A completion must prove both request identity and PCM + /// containment; every replay is keyed structurally, never by text. + fn apply_tail_patch_before_seal( &mut self, ev_tx: &mpsc::UnboundedSender, utterance_id: u64, - ) { - if !self + request_identity: Option<&TailRequestIdentity>, + payload_identity: Option<&TailRequestIdentity>, + outcome: &TailPatchOutcome, + ) -> bool { + let Some(identity) = request_identity else { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_IDENTITY_MISMATCH_WARNING_CODE, + utterance_id, + "request identity missing", + ); + return false; + }; + if payload_identity.is_some_and(|payload| payload != identity) { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_IDENTITY_MISMATCH_WARNING_CODE, + utterance_id, + "provider identity differs from admitted request", + ); + return false; + } + let Some(pending) = self .progressive - .sealed_spans() + .pending_spans() + .iter() + .find(|span| span.id == utterance_id) + .cloned() + else { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_SEALED_FENCE_WARNING_CODE, + utterance_id, + "target is no longer pending", + ); + return false; + }; + if identity.range.session != self.session_id + || identity.range.capture_epoch != self.capture_epoch + || !identity.range.contains(&pending.range) + { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_IDENTITY_MISMATCH_WARNING_CODE, + utterance_id, + "request PCM range does not contain target span", + ); + return false; + } + + let mut keys = Vec::with_capacity(outcome.events().len().saturating_add(1)); + keys.push(TailPatchApplicationKey { + request: identity.clone(), + target_utterance_id: utterance_id, + event_ordinal: usize::MAX, + }); + keys.extend( + outcome + .events() + .iter() + .enumerate() + .map(|(event_ordinal, _)| TailPatchApplicationKey { + request: identity.clone(), + target_utterance_id: utterance_id, + event_ordinal, + }), + ); + if keys .iter() - .any(|span| span.id == utterance_id) + .any(|key| self.tail_patch_applications.contains(key)) { - return; + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_REPLAY_REFUSED_WARNING_CODE, + utterance_id, + "structural application key already accepted", + ); + return false; } - let Some(outcome) = self.tail_patch_outcomes.remove(&utterance_id) else { - return; + + let Some(mut rewritten) = self + .pending_events + .get(&utterance_id) + .map(|pending| pending.layer1_baseline.clone()) + else { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_APPLY_REFUSED_WARNING_CODE, + utterance_id, + "exact patch baseline is unavailable", + ); + return false; }; - self.emit_tail_patch_outcome(ev_tx, utterance_id, outcome); + for event in outcome.events() { + if !matches!( + event, + EngineEvent::ReplaceRange { + utterance_id: target, + source: LayerSource::TailPatch, + .. + } if *target == utterance_id + ) { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_APPLY_REFUSED_WARNING_CODE, + utterance_id, + "event does not name the target TailPatch span", + ); + return false; + } + if let Err(error) = event.apply_to_committed_text(&mut rewritten) { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_APPLY_REFUSED_WARNING_CODE, + utterance_id, + &format!("bounded char range rejected: {error:?}"), + ); + return false; + } + } + if !outcome.events().is_empty() + && !self + .progressive + .try_rewrite_anchored(utterance_id, &identity.range, rewritten) + { + self.refuse_tail_patch( + ev_tx, + TAIL_PATCH_SEALED_FENCE_WARNING_CODE, + utterance_id, + "target crossed the seal during application", + ); + return false; + } + + self.tail_patch_applications.extend(keys); + self.tail_patch_replacements = self + .tail_patch_replacements + .saturating_add(outcome.events().len() as u64); + 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}"), + }); + } + true + } + + fn refuse_tail_patch( + &mut self, + ev_tx: &mpsc::UnboundedSender, + code: &str, + utterance_id: u64, + reason: &str, + ) { + self.tail_patch_refusals = self.tail_patch_refusals.saturating_add(1); + let _ = ev_tx.send(EngineEvent::Warning { + code: code.to_string(), + message: format!("utterance {utterance_id}: {reason}; Apple text preserved"), + }); } fn emit_ready_progressive_seals( @@ -966,12 +1263,28 @@ impl AppleSealState { // 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 sideband = self + .fusion + .as_ref() + .map(|fusion| fusion.pause_evidence_for_range(&sealed.range)) + .unwrap_or_default(); + crate::llm::inline_format::on_span_sealed( + crate::llm::inline_format::StableFormatSpan { + session_id: sealed.range.session.clone(), + capture_epoch: sealed.range.capture_epoch, + span_id: sealed.id, + sample_start: sealed.range.sample_start, + sample_end: sealed.range.sample_end, + text: sealed.text.clone(), + sideband, + }, + ); let segments = if sealed.words.is_empty() { pending.segments } else { timed_words_to_segments(&sealed.words, self.sample_rate) }; + let acoustic = codescribe_core_acoustic_identity(&sealed); let _ = ev_tx.send(EngineEvent::UtteranceFinal { utterance_id: sealed.id, text: sealed.text, @@ -984,37 +1297,55 @@ impl AppleSealState { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic, }); - 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); - } +fn codescribe_core_acoustic_identity( + sealed: &SealedSpan, +) -> Option { + use crate::pipeline::contracts::{AcousticTranscriptIdentity, AcousticTranscriptSpan}; + + if sealed.range.sample_end <= sealed.range.sample_start { + return None; + } + + let source = if sealed.whisper_words.is_empty() { + &sealed.words + } else { + &sealed.whisper_words + }; + let mut spans: Vec = source + .iter() + .filter(|word| { + !word.text.trim().is_empty() + && word.range.session == sealed.range.session + && word.range.capture_epoch == sealed.range.capture_epoch + && word.range.sample_end > word.range.sample_start + && word.range.sample_start >= sealed.range.sample_start + && word.range.sample_end <= sealed.range.sample_end + }) + .map(|word| AcousticTranscriptSpan { + text: word.text.clone(), + range: word.range.clone(), + grain: AcousticSpanGrain::Word, + }) + .collect(); + spans.sort_by_key(|span| span.range.sample_start); + if spans.is_empty() { + spans.push(AcousticTranscriptSpan { + text: sealed.text.clone(), + range: sealed.range.clone(), + grain: AcousticSpanGrain::Phrase, + }); } + + Some(AcousticTranscriptIdentity { + range: sealed.range.clone(), + spans, + }) } /// What the worker sealed, and what seal-time postprocess filtered away. @@ -1024,6 +1355,16 @@ struct AppleStreamOutcome { unresolved_windows: u64, /// How many seals escalated an unplaceable Layer 1 under-commit (W-C). under_commit_escalations: u64, + /// Bounded Layer 1 events that crossed the rewrite fence before seal. + tail_patch_replacements: u64, + /// Completions refused by identity, replay, range, or sealed-fence checks. + tail_patch_refusals: u64, + /// Job-level outcome after the single rewrite fence adjudicated it. + tail_patch_jobs_applied: u64, + /// Completed jobs with no accepted mutation, including no-change. + tail_patch_jobs_skipped: u64, + /// Jobs still outstanding when the bounded closure wait expired. + tail_patch_timeout_residue: u64, sealed_spans: Vec, } @@ -1372,12 +1713,15 @@ fn revision_tolerant_known_prefix(probe: &[String], canvas: &[&str]) -> (usize, let allowed = |k: usize| if k <= 2 { 0 } else { (k / 5).max(1) }; let mut best_k = 0usize; let mut best_edits = 0usize; + let mut best_s = 0usize; + let mut best_window_end = 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 window = &tail[s..s + jmax]; let mut prev: Vec = (0..=jmax).collect(); for i in 1..=n { let mut current = vec![usize::MAX; jmax + 1]; @@ -1386,18 +1730,31 @@ fn revision_tolerant_known_prefix(probe: &[String], canvas: &[&str]) -> (usize, // 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; + let mut aligned_j = 0usize; + let probe_token = probe[i - 1].as_str(); + let repetition_delete = window.contains(&probe_token); 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] { + let substitute = if probe_token == 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); + if substitute <= aligned_end { + aligned_end = substitute; + aligned_j = j; + } + // A probe extra that already sits in the canvas window is + // another acoustic occurrence, not an ASR revision. Paying + // the edit budget for it absorbs the fifth "Iwo". + let delete = if repetition_delete { + usize::MAX / 4 + } else { + prev[j].saturating_add(1) + }; let insert = current[j - 1].saturating_add(1); current[j] = substitute.min(delete).min(insert); } @@ -1405,11 +1762,47 @@ fn revision_tolerant_known_prefix(probe: &[String], canvas: &[&str]) -> (usize, if edits <= allowed(i) && (i > best_k || (i == best_k && edits < best_edits)) { best_k = i; best_edits = edits; + best_s = s; + best_window_end = s + aligned_j; } prev = current; } } - (best_k, best_edits) + // An exact restatement of an embedded canvas island is not identity. + // A prefix (leftover only after) or a suffix (leftover only before) is a + // legal restatement. Leftover on both sides means the probe latched onto + // unrelated text. + if best_k == n && best_edits == 0 && best_s > 0 && best_window_end < tail.len() { + return (0, 0); + } + ( + cap_known_prefix_to_canvas_token_counts(probe, canvas, best_k), + best_edits, + ) +} + +/// Extra copies of a canvas token are new acoustic occurrences, not revisions. +fn cap_known_prefix_to_canvas_token_counts(probe: &[String], canvas: &[&str], k: usize) -> usize { + let mut canvas_counts = std::collections::HashMap::<&str, usize>::new(); + for word in canvas { + *canvas_counts.entry(*word).or_insert(0) += 1; + } + let mut used = std::collections::HashMap::<&str, usize>::new(); + let k = k.min(probe.len()); + for (index, token) in probe[..k].iter().enumerate() { + let Some(&canvas_n) = canvas_counts + .get(token.as_str()) + .filter(|count| **count > 0) + else { + continue; + }; + let used_n = used.entry(token.as_str()).or_insert(0); + *used_n += 1; + if *used_n > canvas_n { + return index; + } + } + k } /// Case- and punctuation-insensitive projection for canvas containment checks @@ -1470,7 +1863,20 @@ fn apply_conservative_fusion( }); return fallback; } - if !state.progressive.try_rewrite(utterance_id, &decision.text) { + let evidence_range = whisper_words + .first() + .zip(whisper_words.last()) + .map(|(first, last)| TailSampleRange { + session: first.range.session.clone(), + capture_epoch: first.range.capture_epoch, + sample_start: first.range.sample_start, + sample_end: last.range.sample_end, + }); + if evidence_range.as_ref().is_none_or(|range| { + !state + .progressive + .try_rewrite_anchored(utterance_id, range, &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 @@ -1596,7 +2002,9 @@ fn seal_sliced_by_silero( .iter() .any(|p| p.id == utterance_id) { - let _ = state.progressive.try_rewrite(utterance_id, &text); + let _ = state + .progressive + .try_rewrite_anchored(utterance_id, &silero.range, &text); } else { if !state.progressive.note_apple_commit_timed(AppleCommit { id: utterance_id, @@ -1622,6 +2030,7 @@ fn seal_sliced_by_silero( utterance_id, PendingAppleSeal { raw_text: raw_text.to_string(), + layer1_baseline: seal_span_text(&text, &state.sealed_prefix, false), start_ts: span_start, end_ts: span_end, segments: disjoint.to_vec(), @@ -1827,7 +2236,23 @@ fn seal_utterance_final( // 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 { + // Joined identical tokens look like a Whisper repetition loop to + // `cleanup_artifacts`. Distinct timed segments are separate occurrences + // and must not be collapsed by that heuristic. + let Some(corrected) = (if disjoint.len() >= 2 { + let parts: Vec = disjoint + .iter() + .filter_map(|segment| state.postprocessor.process_utterance(&segment.text)) + .filter(|part| !part.trim().is_empty()) + .collect(); + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } + } else { + state.postprocessor.process_utterance(&raw_text) + }) else { state.filtered_empty_drops = state.filtered_empty_drops.saturating_add(1); warn!( raw_chars = raw_text.chars().count(), @@ -1856,6 +2281,21 @@ fn seal_utterance_final( return true; } let apple_words = apple_segments_on_pcm_clock(state, &disjoint); + let request_id = state.utterance_id.saturating_add(1); + let apple_observations: Vec = apple_words + .iter() + .enumerate() + .map(|(generation, word)| { + AcousticObservation::from_timed_segment( + word, + ObservationProducer::Apple, + request_id, + generation as u64, + AcousticSpanGrain::Word, + ) + }) + .collect(); + let _ = state.observations.admit(&apple_observations); 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), @@ -1892,6 +2332,11 @@ fn seal_utterance_final( Some(utterance) => (utterance.range.clone(), Some(utterance.id)), None => (apple_range, None), }; + let timing_quality = if span_range.sample_end > span_range.sample_start { + TailTimingQuality::ExactSampleRange + } else { + TailTimingQuality::Synthetic + }; // 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, @@ -1914,7 +2359,7 @@ fn seal_utterance_final( source: TailEvidenceSource::AppleSpeech, revision: None, stability: TailEvidenceStability::Final, - timing_quality: TailTimingQuality::ExactSampleRange, + timing_quality, avg_logprob: None, }, silero_utterance_id, @@ -1922,10 +2367,12 @@ fn seal_utterance_final( return false; } let segment_count = disjoint.len().max(1); + let committed_text = seal_span_text(&after_lexicon, &state.sealed_prefix, disjoint.len() >= 2); state.pending_events.insert( utterance_id, PendingAppleSeal { raw_text, + layer1_baseline: committed_text.clone(), start_ts, end_ts, segments: disjoint, @@ -1933,7 +2380,6 @@ fn seal_utterance_final( ); 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(window) = window { if state.tail_patch.is_some() { state.enqueue_layer1_piece(CoalescedPiece { @@ -2289,10 +2735,18 @@ fn apple_stream_worker( state.audio.push(&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 + let silero_ingest = state .fusion .as_mut() - .is_some_and(|fusion| fusion.ingest(&samples, samples_seen).speech_live); + .map(|fusion| fusion.ingest(&samples, samples_seen)); + if let Some(ingest) = silero_ingest.as_ref() { + for evidence in &ingest.sideband { + let _ = ev_tx.send(EngineEvent::SidebandEvidence { + evidence: evidence.clone(), + }); + } + } + let speech_live = silero_ingest.is_some_and(|ingest| ingest.speech_live); let audio_secs = samples_seen as f32 / sample_rate.max(1) as f32; match epoch.feed_pcm(&samples, samples_seen, speech_live) { EpochDecision::Forward => { @@ -2499,6 +2953,11 @@ fn apple_stream_worker( filtered_empty_drops: state.filtered_empty_drops, unresolved_windows: state.unresolved_windows, under_commit_escalations: state.under_commit_escalations, + tail_patch_replacements: state.tail_patch_replacements, + tail_patch_refusals: state.tail_patch_refusals, + tail_patch_jobs_applied: state.tail_patch_jobs_applied, + tail_patch_jobs_skipped: state.tail_patch_jobs_skipped, + tail_patch_timeout_residue: state.tail_patch_awaiting_completion, sealed_spans: state.progressive.sealed_spans().to_vec(), }) } @@ -2820,6 +3279,10 @@ mod tests { TailPatchCompletion { utterance_id: 1, covered_through_secs: 2.0, + request_identity: Some(TailRequestIdentity { + request_id: 1, + range: whisper_range.clone(), + }), outcome: TailPatchOutcome::NoChange, payload: Some(synthetic_tail_payload(1, whisper_range, vec![whisper_word])), span_map: Vec::new(), @@ -2858,9 +3321,9 @@ mod tests { 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. + /// W-C: an under-commit's gap-appends cross the rewrite fence while the + /// span is pending. The final already contains the recovery; no mutation is + /// allowed to follow it, and an unplaceable remainder escalates first. #[test] fn apple_seal_emits_under_commit_gap_appends_and_escalation() { let (tx, mut rx) = mpsc::unbounded_channel(); @@ -2886,21 +3349,41 @@ mod tests { 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(), - ); + let outcome = TailPatchOutcome::UnderCommit(crate::stt::tail_patcher::UnderCommit { + appends: vec![EngineEvent::ReplaceRange { + utterance_id: 1, + start: 24, + end: 24, + text: " szesc".to_string(), + source: LayerSource::TailPatch, + }], + residual_required: true, + committed_tokens: 5, + retranscribed_tokens: 10, + committed_chars: 24, + retranscribed_chars: 60, + commit_ratio: 0.5, + }); assert!( outcome.residual_required(), "fixture must carry an unplaceable remainder" ); + let mut expected = "raz dwa trzy cztery piec".to_string(); + for event in outcome.events() { + event + .apply_to_committed_text(&mut expected) + .expect("fixture patch is bounded against the pending text"); + } + let request_range = state.progressive.pending_spans()[0].range.clone(); state.complete_whisper_window( &tx, TailPatchCompletion { utterance_id: 1, covered_through_secs: 2.0, + request_identity: Some(TailRequestIdentity { + request_id: 1, + range: request_range, + }), outcome, payload: None, span_map: Vec::new(), @@ -2917,30 +3400,233 @@ mod tests { .iter() .position(|e| matches!(e, EngineEvent::UtteranceFinal { .. })) .expect("span must seal"); - let patch_at = after + let final_text = after + .iter() + .find_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text.as_str()), + _ => None, + }) + .expect("span must seal"); + assert_eq!(final_text, seal_span_text(&expected, "", false)); + assert!(after.iter().all(|event| !matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ))); + let warning_at = after .iter() .position(|e| { matches!( e, - EngineEvent::ReplaceRange { - source: LayerSource::TailPatch, - .. - } + EngineEvent::Warning { code, .. } if code == UNDER_COMMIT_WARNING_CODE ) }) - .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" + .expect("unplaceable recovered speech must escalate outward"); + assert!(warning_at < final_at, "degradation must precede finality"); + assert!(state.tail_patch_replacements > 0); + assert_eq!(state.under_commit_escalations, 1); + } + + #[test] + fn tail_patch_replay_is_refused_structurally_before_finality() { + 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, 4.0); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "powtorz".into(), + segments: vec![segment("powtorz", 0.5, 2.0)], + }], + &tx, + &mut state, + 2.0, ); - assert!( - after.iter().any(|e| matches!( - e, - EngineEvent::Warning { code, .. } if code == UNDER_COMMIT_WARNING_CODE - )), - "unplaceable recovered speech must escalate outward" + while rx.try_recv().is_ok() {} + + let identity = TailRequestIdentity { + request_id: 1, + range: state.progressive.pending_spans()[0].range.clone(), + }; + let outcome = TailPatchOutcome::Patches(vec![EngineEvent::ReplaceRange { + utterance_id: 1, + start: 7, + end: 7, + text: " raz".to_string(), + source: LayerSource::TailPatch, + }]); + for _ in 0..2 { + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: 1, + covered_through_secs: 2.0, + request_identity: Some(identity.clone()), + outcome: outcome.clone(), + payload: None, + span_map: Vec::new(), + member_ids: Vec::new(), + }, + 2.1, + ); + } + state.emit_ready_progressive_seals(&tx, 5.0); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + let final_text = events.iter().find_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text.as_str()), + _ => None, + }); + let expected = seal_span_text("powtorz raz", "", false); + assert_eq!(final_text, Some(expected.as_str())); + assert_eq!(state.tail_patch_replacements, 1); + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + EngineEvent::Warning { code, .. } + if code == TAIL_PATCH_REPLAY_REFUSED_WARNING_CODE + )) + .count(), + 1 ); - assert_eq!(state.under_commit_escalations, 1); + assert!(events.iter().all(|event| !matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ))); + } + + #[test] + fn tail_patch_wrong_pcm_identity_preserves_apple_final() { + 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, 4.0); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "apple floor".into(), + segments: vec![segment("apple floor", 0.5, 2.0)], + }], + &tx, + &mut state, + 2.0, + ); + while rx.try_recv().is_ok() {} + + let admitted = TailRequestIdentity { + request_id: 1, + range: state.progressive.pending_spans()[0].range.clone(), + }; + let mut forged_payload_identity = admitted.clone(); + forged_payload_identity.request_id = 999; + assert!(!state.apply_tail_patch_before_seal( + &tx, + 1, + Some(&admitted), + Some(&forged_payload_identity), + &TailPatchOutcome::NoChange, + )); + + let mut wrong_range = admitted.range; + wrong_range.session = "different-session".to_string(); + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: 1, + covered_through_secs: 2.0, + request_identity: Some(TailRequestIdentity { + request_id: 1, + range: wrong_range, + }), + outcome: TailPatchOutcome::Patches(vec![EngineEvent::ReplaceRange { + utterance_id: 1, + start: 0, + end: 5, + text: "whisper".to_string(), + source: LayerSource::TailPatch, + }]), + payload: None, + span_map: Vec::new(), + member_ids: Vec::new(), + }, + 5.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().any(|event| matches!( + event, + EngineEvent::Warning { code, .. } + if code == TAIL_PATCH_IDENTITY_MISMATCH_WARNING_CODE + ))); + let expected = seal_span_text("apple floor", "", false); + assert!(events.iter().any(|event| matches!( + event, + EngineEvent::UtteranceFinal { text, .. } if text == &expected + ))); + assert_eq!(state.tail_patch_replacements, 0); + assert_eq!(state.tail_patch_refusals, 2); + } + + #[test] + fn tail_patch_after_seal_is_typed_and_never_mutates_canvas() { + 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, 4.0); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "sealed floor".into(), + segments: vec![segment("sealed floor", 0.5, 2.0)], + }], + &tx, + &mut state, + 2.0, + ); + let identity = TailRequestIdentity { + request_id: 1, + range: state.progressive.pending_spans()[0].range.clone(), + }; + state.seal_remaining_at_session_end(&tx); + while rx.try_recv().is_ok() {} + + state.complete_whisper_window( + &tx, + TailPatchCompletion { + utterance_id: 1, + covered_through_secs: 2.0, + request_identity: Some(identity), + outcome: TailPatchOutcome::Patches(vec![EngineEvent::ReplaceRange { + utterance_id: 1, + start: 0, + end: 6, + text: "late".to_string(), + source: LayerSource::TailPatch, + }]), + payload: None, + span_map: Vec::new(), + member_ids: Vec::new(), + }, + 5.0, + ); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert_eq!(events.len(), 1); + assert!(matches!( + &events[0], + EngineEvent::Warning { code, .. } + if code == TAIL_PATCH_SEALED_FENCE_WARNING_CODE + )); + assert_eq!( + state.progressive.sealed_spans()[0].text, + seal_span_text("sealed floor", "", false) + ); + assert_eq!(state.tail_patch_replacements, 0); } /// Partial → Preview; each phrase final → UtteranceFinal with rising ids. @@ -2948,6 +3634,7 @@ mod tests { fn emit_maps_partial_and_two_phrase_finals() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 2.0); emit_stream_events( vec![ LiveStreamEvent::Partial { @@ -2990,6 +3677,17 @@ mod tests { )); } + fn count_iwo(text: &str) -> usize { + text.split_whitespace() + .filter(|word| { + word.chars() + .filter(|ch| ch.is_alphabetic()) + .collect::() + .eq_ignore_ascii_case("iwo") + }) + .count() + } + /// Build a timed `TranscriptSegment` for seal-window fixture events. fn segment(text: &str, start_ts: f32, end_ts: f32) -> TranscriptSegment { TranscriptSegment { @@ -3562,7 +4260,7 @@ mod tests { /// must be counted and surfaced, never silently truncated into a window. #[test] fn seal_window_beyond_captured_audio_is_counted_unresolved() { - let (tx, _rx) = mpsc::unbounded_channel(); + let (tx, mut rx) = mpsc::unbounded_channel(); let mut state = AppleSealState::new(TEST_SAMPLE_RATE); push_capture(&mut state, 2.0); @@ -3577,6 +4275,16 @@ mod tests { ); assert_eq!(state.sealed_count, 1, "the text still seals"); + let final_event = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + EngineEvent::UtteranceFinal { acoustic, .. } => Some(acoustic), + _ => None, + }) + .expect("unresolved Apple text still emits a final"); + assert!( + final_event.is_none(), + "an unresolved Apple window must not fabricate acoustic identity" + ); assert_eq!(state.unresolved_windows, 1); assert_eq!( state.last_sealed_end, 0.0, @@ -3796,6 +4504,51 @@ mod tests { } } + /// A bounded stop that abandons accepted refinement must be observable on + /// the ordered event surface; zero abandoned work stays quiet. + #[test] + fn tail_patch_drain_degrade_is_typed_once_and_zero_is_silent() { + let sink = RecordingSink::default(); + report_tail_patch_drain_degrade(&sink, 0); + assert!(sink.events().is_empty()); + + report_tail_patch_drain_degrade(&sink, 2); + emit_session_finalised(&sink, "test-session".to_string(), 0); + let events = sink.events(); + assert_eq!(events.len(), 2); + let EngineEvent::Warning { code, message } = &events[0] else { + panic!("expected typed Warning, got {:?}", events[0]); + }; + assert_eq!(code, TAIL_PATCH_DRAIN_TIMEOUT_WARNING_CODE); + assert!(message.contains('2')); + assert!(message.contains("Apple live text was preserved")); + assert!(matches!(events[1], EngineEvent::SessionFinalised { .. })); + } + + #[test] + fn tail_patch_receipt_uses_worker_adjudicated_job_buckets() { + let receipt = tail_patch_receipt_after_stop(true, 3, 1, 1, 1); + assert_eq!(receipt.applied, 1); + assert_eq!(receipt.skipped, 1); + assert_eq!(receipt.timed_out, 1); + assert_eq!(receipt.abandoned, 0); + assert!(receipt.is_reconciled()); + + let worker_failed = tail_patch_receipt_after_stop(true, 2, 0, 0, 0); + assert_eq!(worker_failed.abandoned, 2); + assert_eq!(worker_failed.drain, TailPatchDrainDisposition::Abandoned); + assert!(worker_failed.is_reconciled()); + + let mixed = tail_patch_receipt_after_stop(true, 4, 0, 0, 2); + assert_eq!(mixed.timed_out, 2); + assert_eq!(mixed.abandoned, 2); + assert_eq!( + mixed.drain, + TailPatchDrainDisposition::TimedOut, + "timeout takes precedence while both terminal counters remain explicit" + ); + } + fn synthetic_tail_job(utterance_id: u64, outcome: TailPatchOutcome) -> TailPatchJobResult { let range = TailSampleRange { session: "test-session".to_string(), @@ -3810,12 +4563,10 @@ mod tests { } } - /// 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. + /// Computing a bearing patch is not delivery. The only application count + /// belongs to the seal owner after its rewrite fence accepts the result. #[test] - fn finishing_tail_patch_does_not_count_before_worker_accepts_it() { + fn finishing_tail_patch_only_hands_identity_to_the_seal_owner() { let mut lane = AppleTailPatchLane::new(TEST_SAMPLE_RATE, None); let outcome = compute_tail_patch( "ala ma kota w domu", @@ -3827,6 +4578,15 @@ mod tests { Some(TailPatchInFlight { utterance_id: 1, covered_through_secs: 2.0, + request_identity: TailRequestIdentity { + request_id: 1, + range: TailSampleRange { + session: "test-session".to_string(), + capture_epoch: 0, + sample_start: 0, + sample_end: 0, + }, + }, span_map: Vec::new(), member_ids: Vec::new(), }), @@ -3840,16 +4600,10 @@ mod tests { .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"); + let accepted = done_rx.try_recv().expect("live worker receives completion"); + assert!(accepted.request_identity.is_some()); drop(done_rx); let rejected_outcome = compute_tail_patch( @@ -3862,17 +4616,21 @@ mod tests { Some(TailPatchInFlight { utterance_id: 2, covered_through_secs: 3.0, + request_identity: TailRequestIdentity { + request_id: 2, + range: TailSampleRange { + session: "test-session".to_string(), + capture_epoch: 0, + sample_start: 0, + sample_end: 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 @@ -3988,6 +4746,7 @@ mod tests { TailPatchCompletion { utterance_id: req.utterance_id, covered_through_secs: req.covered_through_secs, + request_identity: Some(req.provider_request.identity.clone()), outcome, payload: None, span_map: req.span_map, @@ -4000,16 +4759,31 @@ mod tests { 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:?}" + assert!(after.iter().all(|event| !matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ))); + let final_text = after + .iter() + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" "); + assert_ne!( + final_text, + chops + .iter() + .map(|(text, _, _)| *text) + .collect::>() + .join(" "), + "coalesced rewrite must be present in the finals, got {after:?}" ); + assert!(state.tail_patch_replacements > 0); } /// A first final that arrives after the retention horizon must not poison @@ -4111,6 +4885,7 @@ mod tests { TailPatchCompletion { utterance_id: req.utterance_id, covered_through_secs: req.covered_through_secs, + request_identity: Some(req.provider_request.identity.clone()), outcome: TailPatchOutcome::skipped( crate::stt::tail_patcher::SkipReasonCode::EmptyRetranscription, "no change", @@ -4126,6 +4901,11 @@ mod tests { state.tail_patch_awaiting_completion, 0, "every job reported back — the stop path owes no further wait" ); + assert_eq!(state.tail_patch_jobs_applied, 0); + assert_eq!( + state.tail_patch_jobs_skipped, 1, + "NoChange/provider skip is a completed skipped job, never missing arithmetic" + ); assert!( !state.progressive.pending_spans().is_empty(), "yet a span is still pending: waiting on this queue would hang on nothing" @@ -4216,98 +4996,13 @@ mod tests { ); } - /// Acceptance arm: an induced gap (Layer 0 committed a shorter span than - /// the audio actually carried) emits exactly one bounded - /// `ReplaceRange{TailPatch}` and increments the count that - /// `SessionFinalised.layer_summary` reports. - #[tokio::test] - async fn apple_tail_patch_induced_gap_emits_replace_range_and_counts_into_summary() { - let sink = RecordingSink::default(); - let mut lane = AppleTailPatchLane::new(TEST_SAMPLE_RATE, None); - - // Layer 0 sealed the phrase without its tail word; Whisper heard it. - let outcome = compute_tail_patch( - "ala ma kota w domu", - "ala ma kota w domu swoim", - 1, - &TailPatchConfig::default(), - ); - 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(), 1); - let replaces: Vec<_> = sink - .events() - .into_iter() - .filter(|e| { - matches!( - e, - EngineEvent::ReplaceRange { - source: LayerSource::TailPatch, - .. - } - ) - }) - .collect(); - assert_eq!(replaces.len(), 1, "one bounded patch, never a full rewrite"); - - emit_session_finalised(&sink, "session".to_string(), lane.replacements()); - let finalised = sink - .events() - .into_iter() - .find_map(|e| match e { - EngineEvent::SessionFinalised { layer_summary, .. } => Some(layer_summary), - _ => None, - }) - .expect("SessionFinalised"); - assert_eq!(finalised.tail_patch_replacements, 1); - } - - /// F2: the shared `TailPatchConfig` threshold still owns the decision — a - /// re-transcription that diverges wholesale is skipped, not applied. - /// Thresholds are untouched by this cut. - #[tokio::test] - async fn apple_tail_patch_divergent_retranscription_is_skipped_not_applied() { - let sink = RecordingSink::default(); - let mut lane = AppleTailPatchLane::new(TEST_SAMPLE_RATE, None); - - let outcome = compute_tail_patch( - "ala ma kota w domu", - "zupelnie inne zdanie bez zwiazku calkiem", - 1, - &TailPatchConfig::default(), - ); - assert!( - matches!(outcome, TailPatchOutcome::Skipped { .. }), - "shared threshold must reject a wholesale divergence" - ); - 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" - ); - } - - /// The env gate is the only control: default (unset) arms the lane — the - /// 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. + /// Compatibility parser semantics remain strict. Product-mode defaults are + /// resolved at recording bootstrap, not by this parser alone. #[test] - fn apple_tail_patch_lane_is_wired_by_default_and_off_disarms() { + fn layered_phase_compatibility_parser_accepts_phase1_and_off() { assert!( - layered_phase_from_raw(None).is_some_and(|phase| phase >= 1), - "the unset production default must arm the live tail patch" + layered_phase_from_raw(None).is_none(), + "unset means no explicit compatibility override" ); assert!( parse_layered_phase_value("off").is_none(), @@ -4449,6 +5144,7 @@ mod tests { fn utterance_drop_emit_seals_prior_on_shared_opener_partial_restart() { let (tx, mut rx) = mpsc::unbounded_channel(); let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 30.0); let s5 = "Zdanie piąte, szybko bez pauz. Teraz mówię bardzo szybko, bez żadnej przerwy, \ żeby sprawdzić czy silnik nadąża za tempem, którego normalnie unika w \ codziennym dyktowaniu."; @@ -4702,11 +5398,16 @@ mod tests { /// 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); + let mut 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" ); + assert_eq!( + gate.feed_pcm(&[0.0; 1_024], 1_024, false), + EpochDecision::Forward, + "Silero/sideband absence must preserve continuous Apple PCM flow" + ); let armed = EpochGate::for_session(TEST_SAMPLE_RATE, Some(5.0), true); assert!(armed.is_armed()); assert!( @@ -4963,4 +5664,136 @@ mod tests { assert_eq!(sealed[0].range.sample_start, at(0.5)); assert_eq!(sealed[0].range.sample_end, at(2.0)); } + + #[test] + fn five_disjoint_iwo_segments_all_reach_the_final() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 6.0); + let segments: Vec<_> = (0..5) + .map(|i| { + let start = i as f32 * 0.4; + segment("Iwo", start, start + 0.3) + }) + .collect(); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "Iwo Iwo Iwo Iwo Iwo".into(), + segments, + }], + &tx, + &mut state, + 3.0, + ); + let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + let finals: Vec<_> = events + .iter() + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { text, acoustic, .. } => { + Some((text.as_str(), acoustic.as_ref())) + } + _ => None, + }) + .collect(); + assert_eq!( + finals.len(), + 1, + "one Apple final for five disjoint words: {events:?}" + ); + let span_count = finals[0] + .1 + .map(|identity| identity.spans.len()) + .unwrap_or(0); + let iwo_count = count_iwo(finals[0].0); + assert_eq!( + (iwo_count, span_count), + (5, 5), + "delivery text: {}; acoustic: {:?}", + finals[0].0, + finals[0].1 + ); + } + + #[test] + fn cumulative_fifth_iwo_is_not_absorbed_as_a_revision() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = AppleSealState::new(TEST_SAMPLE_RATE); + push_capture(&mut state, 8.0); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "Iwo Iwo Iwo Iwo".into(), + segments: (0..4) + .map(|i| { + let start = i as f32 * 0.4; + segment("Iwo", start, start + 0.3) + }) + .collect(), + }], + &tx, + &mut state, + 2.0, + ); + emit_stream_events( + vec![LiveStreamEvent::PhraseFinal { + text: "Iwo Iwo Iwo Iwo Iwo".into(), + segments: vec![segment("Iwo Iwo Iwo Iwo Iwo", 0.0, 1.6)], + }], + &tx, + &mut state, + 3.0, + ); + let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); + let texts: Vec<_> = events + .iter() + .filter_map(|event| match event { + EngineEvent::UtteranceFinal { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect(); + let iwo_count = texts.iter().map(|text| count_iwo(text)).sum::(); + assert_eq!( + iwo_count, 5, + "the fifth acoustic Iwo must survive the cumulative restatement: {texts:?}" + ); + } +} + +/// Conservation falsifiers from the acoustic-identity cut. These encode the +/// contract, not a parked skip: they must stay green. +#[cfg(test)] +mod conservation_falsifiers { + use super::*; + + fn probe_words(callback: &str) -> Vec { + callback + .split_whitespace() + .map(|word| normalize_for_containment(&seal_span_text(word, "", true))) + .collect() + } + + #[test] + fn cumulative_final_may_not_absorb_an_extra_repetition() { + let canvas = normalize_for_containment("Iwo Iwo Iwo Iwo"); + let canvas_words: Vec<&str> = canvas.split_whitespace().collect(); + let callback = "Iwo Iwo Iwo Iwo Iwo"; + let probe = probe_words(callback); + let (known, _revised) = revision_tolerant_known_prefix(&probe, &canvas_words); + assert_eq!( + known, 4, + "canvas carries four occurrences; the fifth must survive as novel text" + ); + } + + #[test] + fn a_textual_match_elsewhere_in_the_canvas_is_not_identity() { + let canvas = + normalize_for_containment("zupełnie co innego Iwo Iwo Iwo Iwo Iwo dalszy ciąg"); + let canvas_words: Vec<&str> = canvas.split_whitespace().collect(); + let probe = probe_words("Iwo Iwo Iwo Iwo Iwo"); + let (known, _) = revision_tolerant_known_prefix(&probe, &canvas_words); + assert_eq!( + known, 0, + "a match embedded in unrelated canvas text may not consume the final" + ); + } } diff --git a/core/pipeline/streaming/layer1_window.rs b/core/pipeline/streaming/layer1_window.rs index c00f72aa..38919b47 100644 --- a/core/pipeline/streaming/layer1_window.rs +++ b/core/pipeline/streaming/layer1_window.rs @@ -140,6 +140,7 @@ fn build_flush(pieces: Vec, neighbour_context: String) -> Coales 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); + let mut cursor = sample_start; for (i, piece) in pieces.into_iter().enumerate() { if i > 0 { committed_text.push(' '); @@ -154,7 +155,22 @@ fn build_flush(pieces: Vec, neighbour_context: String) -> Coales end: offset, }); member_ids.push((piece.utterance_id, piece.covered_through_secs)); - audio.extend_from_slice(&piece.audio); + let piece_start = piece.sample_start.max(sample_start); + if piece_start > cursor { + audio.resize(audio.len() + (piece_start - cursor) as usize, 0.0); + cursor = piece_start; + } + let skip = cursor.saturating_sub(piece_start) as usize; + if skip < piece.audio.len() { + audio.extend_from_slice(&piece.audio[skip..]); + cursor = piece_start + piece.audio.len() as u64; + } + } + let declared = sample_end.saturating_sub(sample_start) as usize; + if audio.len() < declared { + audio.resize(declared, 0.0); + } else if audio.len() > declared { + audio.truncate(declared); } CoalesceFlush { committed_text, @@ -467,3 +483,60 @@ mod tests { } } } + +/// Conservation falsifier: a coalesced window must carry the PCM range it +/// declares, or Layer 1 never reaches inference. +#[cfg(test)] +mod conservation_falsifiers { + use super::*; + use crate::stt::tail_provider::{TailProviderRequest, TailRequestIdentity, TailSampleRange}; + + fn piece_at(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 coalesced_window_carries_the_pcm_range_it_declares() { + 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_at(i + 1, "słowo", i as f32, i as f32 + 0.4, 1), + 16_000, + )); + } + let flush = &flushes[0]; + let request = TailProviderRequest { + identity: TailRequestIdentity { + request_id: flush.primary_utterance_id, + range: TailSampleRange { + session: "conservation".into(), + capture_epoch: 1, + sample_start: flush.sample_start, + sample_end: flush.sample_end, + }, + }, + sample_rate: 16_000, + language: None, + }; + assert_eq!( + flush.sample_end - flush.sample_start, + flush.audio.len() as u64, + "declared range must equal carried PCM: a window may not promise audio it dropped" + ); + request + .validate_pcm(&flush.audio) + .expect("a coalesced window must be admissible at the provider seam"); + } +} diff --git a/core/pipeline/streaming/live_assembly.rs b/core/pipeline/streaming/live_assembly.rs index b81e19ff..dd1f4544 100644 --- a/core/pipeline/streaming/live_assembly.rs +++ b/core/pipeline/streaming/live_assembly.rs @@ -57,6 +57,7 @@ pub fn assemble_live_from_events(events: &[EngineEvent]) -> LiveAssembly { // patches address utterances by id, so the replay has to remember which // slot belongs to whom. let mut freezed_ids: Vec = Vec::new(); + let mut freezed_acoustic: Vec = Vec::new(); let mut preview = String::new(); for event in events { @@ -66,15 +67,20 @@ pub fn assemble_live_from_events(events: &[EngineEvent]) -> LiveAssembly { preview = text.trim().to_string(); } EngineEvent::UtteranceFinal { - utterance_id, text, .. + utterance_id, + text, + acoustic, + .. } => { let trimmed = text.trim(); if !trimmed.is_empty() { if let Some(index) = freezed_ids.iter().rposition(|id| id == utterance_id) { freezed[index] = trimmed.to_string(); + freezed_acoustic[index] = acoustic.is_some(); } else { freezed.push(trimmed.to_string()); freezed_ids.push(*utterance_id); + freezed_acoustic.push(acoustic.is_some()); } } preview.clear(); @@ -90,6 +96,9 @@ pub fn assemble_live_from_events(events: &[EngineEvent]) -> LiveAssembly { // unique slot as the production presentation reducer. EngineEvent::ReplaceRange { utterance_id, .. } => { if let Some(index) = freezed_ids.iter().rposition(|id| id == utterance_id) { + if freezed_acoustic[index] { + continue; + } // Out-of-range windows are dropped, not clamped: a patch // that does not fit the text it claims to target is a // desync, and half-applying it would corrupt the floor. @@ -131,6 +140,7 @@ mod tests { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, } } diff --git a/core/pipeline/streaming/mod.rs b/core/pipeline/streaming/mod.rs index 25b463c0..958de7af 100644 --- a/core/pipeline/streaming/mod.rs +++ b/core/pipeline/streaming/mod.rs @@ -49,8 +49,9 @@ 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, collect_buffered_engine_events_with_config, - transcribe_buffered_samples, + SessionConfig, TAIL_PATCH_SESSION_RECEIPT_WARNING_CODE, TailPatchDrainDisposition, + TailPatchSessionReceipt, collect_buffered_engine_events, + collect_buffered_engine_events_with_config, transcribe_buffered_samples, }; #[cfg(test)] diff --git a/core/pipeline/streaming/pipeline.rs b/core/pipeline/streaming/pipeline.rs index 1b0dccf8..678e3bd6 100644 --- a/core/pipeline/streaming/pipeline.rs +++ b/core/pipeline/streaming/pipeline.rs @@ -73,6 +73,12 @@ impl TranscriptionPipeline { { return (stripped, newest_end_ts); } + if !segments.is_empty() { + // Anchored segments exist but the timestamp helper declined. + // Text suffix overlap is not identity and must not absorb a + // later acoustic occurrence of the same word. + return (text.to_string(), None); + } (self.strip_overlap(text), None) } diff --git a/core/pipeline/streaming/progressive_seal.rs b/core/pipeline/streaming/progressive_seal.rs index 537595eb..22fc92d6 100644 --- a/core/pipeline/streaming/progressive_seal.rs +++ b/core/pipeline/streaming/progressive_seal.rs @@ -364,11 +364,37 @@ impl ProgressiveSealMachine { /// Attempt to rewrite a span. Sealed spans refuse (Ok(false)); pending /// accept (Ok(true)); unknown ids return Ok(false). pub fn try_rewrite(&mut self, id: u64, new_text: impl Into) -> bool { + let Some(range) = self + .pending + .iter() + .find(|pending| pending.id == id) + .map(|pending| pending.range.clone()) + else { + return false; + }; + self.try_rewrite_anchored(id, &range, new_text) + } + + /// Rewrite only the pending span whose PCM identity acoustically overlaps + /// the admitted evidence. Other anchored spans and their bytes are untouched. + pub fn try_rewrite_anchored( + &mut self, + id: u64, + evidence_range: &TailSampleRange, + new_text: impl Into, + ) -> bool { if !self.may_rewrite(id) { return false; } let new_text = new_text.into(); if let Some(pending) = self.pending.iter_mut().find(|p| p.id == id) { + let same_clock = pending.range.session == evidence_range.session + && pending.range.capture_epoch == evidence_range.capture_epoch; + let overlaps = pending.range.sample_start < evidence_range.sample_end + && evidence_range.sample_start < pending.range.sample_end; + if !same_clock || !overlaps { + return false; + } pending.raw_text = new_text; return true; } @@ -391,7 +417,9 @@ impl ProgressiveSealMachine { let reason = self.seal_block_reason(&span, now_secs); match reason { None => { - let sealed = seal_span_text(&span.raw_text, &left_context, force_raw); + let preserve_occurrences = force_raw || span.words.len() >= 2; + let sealed = + seal_span_text(&span.raw_text, &left_context, preserve_occurrences); let sealed_span = SealedSpan { id: span.id, text: sealed, @@ -416,7 +444,9 @@ impl ProgressiveSealMachine { // silently seal early without the flag. starvation_ceiling_used = true; self.starvation_ceiling_hits = self.starvation_ceiling_hits.saturating_add(1); - let sealed = seal_span_text(&span.raw_text, &left_context, force_raw); + let preserve_occurrences = force_raw || span.words.len() >= 2; + let sealed = + seal_span_text(&span.raw_text, &left_context, preserve_occurrences); let sealed_span = SealedSpan { id: span.id, text: sealed, @@ -639,6 +669,69 @@ mod progressive_seal_tests { assert!(m.pending_spans().is_empty()); } + #[test] + fn anchored_rewrite_requires_overlap_and_preserves_adjacent_span_bytes() { + let mut machine = ProgressiveSealMachine::new(); + assert!(machine.note_apple_commit_timed(AppleCommit { + id: 1, + raw_text: "pierwszy".into(), + end_secs: 1.0, + committed_at_secs: 1.0, + range: TailSampleRange { + session: "overlap".into(), + capture_epoch: 7, + sample_start: 0, + sample_end: 16_000, + }, + words: Vec::new(), + apple_evidence: TailProviderEvidence { + source: crate::stt::tail_provider::TailEvidenceSource::AppleSpeech, + revision: None, + stability: crate::stt::tail_provider::TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + silero_utterance_id: None, + })); + assert!(machine.note_apple_commit_timed(AppleCommit { + id: 2, + raw_text: "drugi".into(), + end_secs: 2.0, + committed_at_secs: 2.0, + range: TailSampleRange { + session: "overlap".into(), + capture_epoch: 7, + sample_start: 16_000, + sample_end: 32_000, + }, + words: Vec::new(), + apple_evidence: TailProviderEvidence { + source: crate::stt::tail_provider::TailEvidenceSource::AppleSpeech, + revision: None, + stability: crate::stt::tail_provider::TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + silero_utterance_id: None, + })); + let disjoint = TailSampleRange { + session: "overlap".into(), + capture_epoch: 7, + sample_start: 16_000, + sample_end: 32_000, + }; + assert!(!machine.try_rewrite_anchored(1, &disjoint, "floating")); + let overlapping = TailSampleRange { + session: "overlap".into(), + capture_epoch: 7, + sample_start: 8_000, + sample_end: 16_000, + }; + assert!(machine.try_rewrite_anchored(1, &overlapping, "poprawiony")); + assert_eq!(machine.pending_spans()[0].raw_text, "poprawiony"); + assert_eq!(machine.pending_spans()[1].raw_text, "drugi"); + } + /// Seal ordering is lexicon → Light+: a lexicon-corrected word at a /// sentence start receives correct casing from the left-context Light+ pass. #[test] diff --git a/core/pipeline/streaming/session.rs b/core/pipeline/streaming/session.rs index 03a6ba27..8087264c 100644 --- a/core/pipeline/streaming/session.rs +++ b/core/pipeline/streaming/session.rs @@ -19,8 +19,8 @@ use crate::audio::capture_receipt::{ }; use crate::audio::chunker::{SpeechEvent, SpeechSession}; use crate::pipeline::contracts::{ - DropKind, EngineEvent, EventSink, LayerSource, LayerSummary, TranscriptSegment, - collect_confidence_flags, + AcousticSpanGrain, AcousticTranscriptIdentity, AcousticTranscriptSpan, DropKind, EngineEvent, + EventSink, LayerSource, LayerSummary, TranscriptSegment, collect_confidence_flags, }; use crate::stt::scheduler::{SttLane, SttScheduler, SttTaskHandle}; use crate::stt::tail_patcher::{ @@ -43,6 +43,32 @@ use super::correction::{ schedule_partial_pass, }; use super::pipeline::{PostprocessDrop, TranscriptionPipeline}; + +fn phrase_acoustic_identity( + session: &str, + capture_epoch: u64, + sample_start: u64, + sample_end: u64, + text: &str, +) -> Option { + if session.trim().is_empty() || text.trim().is_empty() || sample_end <= sample_start { + return None; + } + let range = TailSampleRange { + session: session.to_string(), + capture_epoch, + sample_start, + sample_end, + }; + Some(AcousticTranscriptIdentity { + range: range.clone(), + spans: vec![AcousticTranscriptSpan { + text: text.to_string(), + range, + grain: AcousticSpanGrain::Phrase, + }], + }) +} use super::quality_gate::{ MAX_WORDS_PER_SEC, MIN_SPEECH_RATIO_FOR_INFERENCE, emit_vad_warning, should_drop_short_utterance, should_drop_silence_chunk, text_words_per_second, @@ -137,8 +163,8 @@ pub struct SessionConfig { /// 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. + /// persistence belong to the settings owner. The decision distinguishes + /// Apple-only, local exact-span Whisper, and an injected provider. pub layer1: Layer1Decision, /// Per-recording host lifecycle boundaries. Present only for a live /// recorder; buffered/offline helpers have no system observer owner. @@ -217,18 +243,182 @@ 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. +/// Legacy compatibility override used only to detect an explicitly requested +/// patcher on the unfenced VAD route and refuse it with typed evidence. /// -/// 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. +/// Apple progressive consumes the recording-start [`Layer1Decision`] instead; +/// no live session re-resolves product mode. This remains orthogonal to final +/// pass routing: final-pass off never disables live patching. pub(super) fn tail_patch_enabled() -> bool { layered_phase().is_some_and(|phase| phase >= 1) } +/// Stable event code carrying the typed local tail-patch session receipt. +pub const TAIL_PATCH_SESSION_RECEIPT_WARNING_CODE: &str = "tail_patch_session_receipt"; + +/// Final stop-drain disposition for the local Whisper lane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TailPatchDrainDisposition { + /// Local tail patching was not armed for this recording. + NotArmed, + /// Every submitted job reached a terminal disposition before seal. + Completed, + /// The bounded drain expired with admitted work still outstanding. + TimedOut, + /// Admitted work was lost for a non-timeout reason (for example worker + /// failure) before reaching a terminal patch verdict. + Abandoned, +} + +impl TailPatchDrainDisposition { + fn as_token(self) -> &'static str { + match self { + Self::NotArmed => "not_armed", + Self::Completed => "completed", + Self::TimedOut => "timed_out", + Self::Abandoned => "abandoned", + } + } + + fn from_token(token: &str) -> Option { + match token { + "not_armed" => Some(Self::NotArmed), + "completed" => Some(Self::Completed), + "timed_out" => Some(Self::TimedOut), + "abandoned" => Some(Self::Abandoned), + _ => None, + } + } +} + +/// Content-free proof of local Whisper arming, work admission, application, +/// and bounded stop drainage for one recording. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TailPatchSessionReceipt { + pub armed: bool, + pub submitted: u64, + pub applied: u64, + pub skipped: u64, + /// Jobs whose terminal disposition is bounded stop-drain expiry. + pub timed_out: u64, + /// Jobs discarded for a non-timeout reason after admission. + pub abandoned: u64, + pub drain: TailPatchDrainDisposition, +} + +impl TailPatchSessionReceipt { + /// Construct a receipt. The caller owns counter provenance; this type owns + /// the invariant checks and stable event encoding. + pub fn new( + armed: bool, + submitted: u64, + applied: u64, + skipped: u64, + timed_out: u64, + abandoned: u64, + drain: TailPatchDrainDisposition, + ) -> Self { + let receipt = Self { + armed, + submitted, + applied, + skipped, + timed_out, + abandoned, + drain, + }; + assert!( + receipt.is_reconciled(), + "tail-patch terminal buckets must reconcile exactly to submitted jobs" + ); + receipt + } + + /// Build the production stop receipt. Every job still outstanding after + /// the worker's real bounded closure loop is classified as timed out. + /// `abandoned` is reserved for a distinct non-timeout discard path. + pub fn from_stop( + armed: bool, + submitted: u64, + applied: u64, + skipped: u64, + timeout_residue: u64, + ) -> Self { + Self::new( + armed, + submitted, + applied, + skipped, + timeout_residue, + 0, + if !armed { + TailPatchDrainDisposition::NotArmed + } else if timeout_residue > 0 { + TailPatchDrainDisposition::TimedOut + } else { + TailPatchDrainDisposition::Completed + }, + ) + } + + /// An armed lane that submitted no work is a failed runtime witness, not + /// proof that Layered worked. + pub fn armed_without_submissions(self) -> bool { + self.armed && self.submitted == 0 + } + + /// Whether every submitted job has exactly one terminal bucket. + pub fn is_reconciled(self) -> bool { + self.applied + .saturating_add(self.skipped) + .saturating_add(self.timed_out) + .saturating_add(self.abandoned) + == self.submitted + } + + pub(crate) fn as_event(self) -> EngineEvent { + EngineEvent::Warning { + code: TAIL_PATCH_SESSION_RECEIPT_WARNING_CODE.to_string(), + message: format!( + "armed={} submitted={} applied={} skipped={} timed_out={} abandoned={} drain={}", + self.armed, + self.submitted, + self.applied, + self.skipped, + self.timed_out, + self.abandoned, + self.drain.as_token(), + ), + } + } + + /// Recover the typed receipt from the production ordered event evidence. + pub fn from_events(events: &[EngineEvent]) -> Option { + let message = events.iter().rev().find_map(|event| match event { + EngineEvent::Warning { code, message } + if code == TAIL_PATCH_SESSION_RECEIPT_WARNING_CODE => + { + Some(message.as_str()) + } + _ => None, + })?; + let fields = message + .split_whitespace() + .filter_map(|field| field.split_once('=')) + .collect::>(); + let receipt = Self { + armed: fields.get("armed")?.parse().ok()?, + submitted: fields.get("submitted")?.parse().ok()?, + applied: fields.get("applied")?.parse().ok()?, + skipped: fields.get("skipped")?.parse().ok()?, + timed_out: fields.get("timed_out")?.parse().ok()?, + abandoned: fields.get("abandoned")?.parse().ok()?, + drain: TailPatchDrainDisposition::from_token(fields.get("drain")?)?, + }; + receipt.is_reconciled().then_some(receipt) + } +} + /// Count a semantic-gate drop, but only for finals. /// /// Interim previews are drafts that get re-decoded; counting their drops would @@ -324,6 +514,10 @@ where /// floor that judges the same session from the audio side. pub const UNDER_COMMIT_WARNING_CODE: &str = "tail_patch_under_commit"; +/// The legacy VAD/scheduler route has no pending-span rewrite fence. Explicit +/// Layer 1 requests fail closed here instead of mutating an emitted final. +pub const TAIL_PATCH_ROUTE_UNBOUND_WARNING_CODE: &str = "tail_patch_route_unbound"; + /// Build the outward escalation for an under-commit that could not be placed /// live. /// @@ -474,15 +668,42 @@ pub(super) fn tail_patch_lane_starved(applied: u64, skipped: u64) -> bool { /// 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) { +pub(super) fn log_tail_patch_session_receipt(receipt: TailPatchSessionReceipt) { + if receipt.timed_out > 0 || receipt.abandoned > 0 { warn!( - applied, - skipped, + armed = receipt.armed, + submitted = receipt.submitted, + applied = receipt.applied, + skipped = receipt.skipped, + timed_out = receipt.timed_out, + abandoned = receipt.abandoned, + drain = receipt.drain.as_token(), + "tail_patch_session_degraded: accepted work missed the bounded stop drain" + ); + } else if receipt.armed_without_submissions() { + warn!( + armed = receipt.armed, + submitted = receipt.submitted, + drain = receipt.drain.as_token(), + "tail_patch_lane_unexercised: armed session submitted zero Whisper windows" + ); + } else if tail_patch_lane_starved(receipt.applied, receipt.skipped) { + warn!( + applied = receipt.applied, + skipped = receipt.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"); + } else { + info!( + armed = receipt.armed, + submitted = receipt.submitted, + applied = receipt.applied, + skipped = receipt.skipped, + timed_out = receipt.timed_out, + abandoned = receipt.abandoned, + drain = receipt.drain.as_token(), + "tail_patch_session_receipt" + ); } } @@ -500,11 +721,10 @@ pub(super) fn log_tail_patch_session_receipt(applied: u64, skipped: u64) { /// 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. +/// Local Layer 1 tail-patch is deliberately Apple-progressive-only: that path +/// owns the exact pending-span rewrite fence. The VAD/scheduler path already +/// runs Whisper as its primary engine and refuses any second, unbound mutation +/// lane with typed evidence. Smart/final-pass routing stays orthogonal. pub(crate) async fn transcription_session( chunk_receiver: mpsc::Receiver>, event_sink: Arc, @@ -544,11 +764,15 @@ pub(crate) async fn vad_transcription_session( lifecycle_events: _, } = config; - // C1 wires the live Layer 1 lane on the Apple progressive path only. On + let local_tail_patch_requested = layer1 + .local_tail_patch_disposition() + .is_some_and(|disposition| disposition.is_armed()); + + // C1 wires the live provider 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() { + if layer1.is_provider_armed() { warn!( "Layer 1 live lane is not wired on the VAD/scheduler path; \ proceeding canvas + lexicon" @@ -568,14 +792,22 @@ pub(crate) async fn vad_transcription_session( }; let output_sample_rate = session.output_sample_rate(); let stt_scheduler = SttScheduler::new(); - // Layer 1 only — not FINAL_PASS_MODE. Smart does not flip this on. - let tail_patch_enabled = tail_patch_enabled(); + // This route emits its final before async tail work can complete and owns + // no pending-span fence. Until it adopts the progressive seal owner, an + // explicit Layer 1 request is evidence only — never post-final mutation. + let tail_patch_requested = local_tail_patch_requested || tail_patch_enabled(); + let tail_patch_enabled = false; let tail_patch_config = TailPatchConfig::from_env(); - if tail_patch_enabled { - info!( + if tail_patch_requested { + warn!( phase = layered_phase().unwrap_or(0), - "Layered transcription Layer 1 (Whisper tail-patch) enabled on VAD session path" + "Layered transcription refused on VAD session path without a pending-span fence" ); + event_sink.on_event(&EngineEvent::Warning { + code: TAIL_PATCH_ROUTE_UNBOUND_WARNING_CODE.to_string(), + message: "VAD/scheduler Layer 1 has no exact pending-span rewrite fence; primary text preserved" + .to_string(), + }); } let mut pipeline = TranscriptionPipeline::new(language); @@ -1455,6 +1687,11 @@ pub(crate) async fn vad_transcription_session( avg_logprob, quality_gate_dropped, ); + 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(utterance_audio_samples as u64); event_sink.on_event(&EngineEvent::UtteranceFinal { utterance_id, text: final_text.clone(), @@ -1467,13 +1704,17 @@ pub(crate) async fn vad_transcription_session( compression_ratio, quality_gate_dropped, confidence_flags, + acoustic: phrase_acoustic_identity( + &session_id, + 0, + sample_start, + sample_end, + &final_text, + ), }); if tail_patch_enabled && let Some(audio) = item.tail_patch_audio.take() { - 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 { @@ -1624,6 +1865,11 @@ pub(crate) async fn vad_transcription_session( utterance_avg_logprob, utterance_quality_gate_dropped, ); + 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(utterance_audio_samples as u64); + let acoustic = + phrase_acoustic_identity(&session_id, 0, sample_start, sample_end, &remaining); event_sink.on_event(&EngineEvent::UtteranceFinal { utterance_id, text: remaining, @@ -1636,6 +1882,7 @@ pub(crate) async fn vad_transcription_session( compression_ratio: utterance_compression_ratio, quality_gate_dropped: utterance_quality_gate_dropped, confidence_flags, + acoustic, }); } @@ -1678,7 +1925,9 @@ 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); + let tail_patch_receipt = TailPatchSessionReceipt::from_stop(false, 0, 0, 0, 0); + log_tail_patch_session_receipt(tail_patch_receipt); + event_sink.on_event(&tail_patch_receipt.as_event()); emit_capture_level_receipt( event_sink.as_ref(), &capture_level.finalize(CapturePathMeta::resolve(sample_rate, 1, None)), @@ -1952,6 +2201,98 @@ pub async fn collect_buffered_engine_events_with_config( mod session_tests { use super::*; + #[test] + fn tail_patch_session_receipt_round_trips_through_production_event_shape() { + let receipt = + TailPatchSessionReceipt::new(true, 4, 2, 1, 1, 0, TailPatchDrainDisposition::TimedOut); + assert_eq!( + TailPatchSessionReceipt::from_events(&[receipt.as_event()]), + Some(receipt) + ); + assert!(!receipt.armed_without_submissions()); + + let unexercised = + TailPatchSessionReceipt::new(true, 0, 0, 0, 0, 0, TailPatchDrainDisposition::Completed); + assert!(unexercised.armed_without_submissions()); + } + + #[test] + fn stop_receipt_classifies_completed_and_timeout_residue() { + let completed = TailPatchSessionReceipt::from_stop(true, 3, 2, 1, 0); + assert_eq!(completed.drain, TailPatchDrainDisposition::Completed); + assert_eq!(completed.timed_out, 0); + assert_eq!(completed.abandoned, 0); + + let timed_out = TailPatchSessionReceipt::from_stop(true, 3, 1, 0, 2); + assert_eq!(timed_out.drain, TailPatchDrainDisposition::TimedOut); + assert_eq!(timed_out.timed_out, 2); + assert_eq!(timed_out.abandoned, 0); + assert_eq!( + TailPatchSessionReceipt::from_events(&[timed_out.as_event()]), + Some(timed_out) + ); + } + + #[tokio::test] + async fn vad_route_refuses_unbound_local_tail_patch_mutation() { + let (tx, rx) = mpsc::channel::>(1); + drop(tx); + let collector = Arc::new(SessionEventCollector::new()); + let sink: Arc = collector.clone(); + vad_transcription_session( + rx, + sink, + SessionConfig { + sample_rate: 16_000, + language: Some("pl".to_string()), + stream_log_path: None, + utterance_silence_sec: None, + layer1: Layer1Decision::LocalTailPatch( + crate::asr_session::LocalTailPatchDisposition::ArmedPhase(1), + ), + lifecycle_events: None, + }, + ) + .await; + + let events = collector.events(); + assert!(events.iter().any(|event| matches!( + event, + EngineEvent::Warning { code, .. } + if code == TAIL_PATCH_ROUTE_UNBOUND_WARNING_CODE + ))); + assert!(events.iter().all(|event| !matches!( + event, + EngineEvent::ReplaceRange { + source: LayerSource::TailPatch, + .. + } + ))); + let receipt = TailPatchSessionReceipt::from_events(&events) + .expect("VAD route must emit typed tail-patch evidence"); + assert!(!receipt.armed); + assert_eq!(receipt.submitted, 0); + assert_eq!(receipt.drain, TailPatchDrainDisposition::NotArmed); + let receipt_pos = events + .iter() + .position(|event| { + matches!( + event, + EngineEvent::Warning { code, .. } + if code == TAIL_PATCH_SESSION_RECEIPT_WARNING_CODE + ) + }) + .expect("receipt event"); + let final_pos = events + .iter() + .position(|event| matches!(event, EngineEvent::SessionFinalised { .. })) + .expect("session finalised event"); + assert!( + receipt_pos < final_pos, + "receipt must precede session finality" + ); + } + #[test] /// Quality-gate drops increment only for final utterances, never interim previews. fn semantic_gate_drop_counter_tracks_quality_gate_flag() { diff --git a/core/pipeline/streaming/silero_fusion.rs b/core/pipeline/streaming/silero_fusion.rs index 8f53c4ba..9d25daf0 100644 --- a/core/pipeline/streaming/silero_fusion.rs +++ b/core/pipeline/streaming/silero_fusion.rs @@ -17,7 +17,10 @@ //! 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::audio::chunker::{SpeechEvent, SpeechSession, VadBoundaryEvidence, VadBoundaryKind}; +use crate::pipeline::contracts::{ + NonSpeechEvidence, SidebandEvidence, SidebandEvidenceKind, SidebandProvenance, +}; use crate::stt::tail_patcher::SkipReasonCode; use crate::stt::tail_provider::{TailSampleRange, TimedTailSegment}; @@ -156,7 +159,7 @@ impl UtteranceLedger { /// What one observed capture chunk means to every consumer of the session's /// single spectrum. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct SileroIngest { /// Utterance identities the Supervisor closed inside this chunk. pub closed: Vec, @@ -169,14 +172,20 @@ pub struct SileroIngest { /// from, in the same call, so wake/sleep and utterance identity cannot /// disagree about where speech was. pub speech_live: bool, + /// Newly measured content-free evidence, in PCM/sequence order. + pub sideband: Vec, } /// Supervisor-mode Silero at the Apple PCM ingress. The session's only VAD. pub struct SileroIngress { session: String, capture_epoch: u64, + sample_rate: u32, vad: SpeechSession, ledger: UtteranceLedger, + next_sideband_sequence: u64, + last_speech_end: Option, + sideband: Vec, } impl SileroIngress { @@ -184,8 +193,12 @@ impl SileroIngress { Self { session: session.into(), capture_epoch, + sample_rate, vad: SpeechSession::new_utterance(sample_rate), ledger: UtteranceLedger::new(), + next_sideband_sequence: 0, + last_speech_end: None, + sideband: Vec::new(), } } @@ -212,11 +225,14 @@ impl SileroIngress { return SileroIngest::default(); } let events = self.vad.feed(samples, 0); + let boundaries = self.vad.take_vad_boundaries(); 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) + let mut out = self.observe(open_range, closed_here, samples_seen); + out.sideband = self.observe_boundaries(&boundaries); + out } /// The whole decision, separated from the VAD read so it is testable on @@ -249,6 +265,96 @@ impl SileroIngress { out } + /// Convert the chunker's exact Silero boundaries into ordered pipeline + /// evidence. This is intentionally separate from [`Self::observe`]: the + /// fusion ledger retains its padded STT window semantics, while sideband + /// evidence names the unpadded threshold crossings exactly. + pub(crate) fn observe_boundaries( + &mut self, + boundaries: &[VadBoundaryEvidence], + ) -> Vec { + let mut emitted = Vec::new(); + for boundary in boundaries { + match boundary.kind { + VadBoundaryKind::SpeechStart => { + if let Some(pause_start) = self.last_speech_end.take() + && pause_start < boundary.sample + { + emitted.push(self.push_sideband( + pause_start, + boundary.sample, + SidebandEvidenceKind::Pause { + duration_samples: boundary.sample - pause_start, + non_speech: NonSpeechEvidence::UnknownNonSpeech, + }, + )); + } + emitted.push(self.push_sideband( + boundary.sample, + boundary.sample, + SidebandEvidenceKind::SpeechStart { + speech_probability: boundary.speech_probability, + }, + )); + } + VadBoundaryKind::SpeechEnd => { + emitted.push(self.push_sideband( + boundary.sample, + boundary.sample, + SidebandEvidenceKind::SpeechEnd { + speech_probability: boundary.speech_probability, + }, + )); + self.last_speech_end = Some(boundary.sample); + } + } + } + self.sideband.extend(emitted.iter().cloned()); + emitted + } + + /// Pause evidence associated with a stable span for optional L3 + /// punctuation/paragraphing context. + /// + /// A pause immediately before the raw Silero start overlaps the span's + /// pre-roll, so matching is by the pause end landing inside the canonical + /// span range. Speech edges and unknown sound semantics are not forwarded + /// to the formatter. + pub fn pause_evidence_for_range(&self, range: &TailSampleRange) -> Vec { + self.sideband + .iter() + .filter(|evidence| { + evidence.range.session == range.session + && evidence.range.capture_epoch == range.capture_epoch + && range.sample_start <= evidence.range.sample_end + && evidence.range.sample_end <= range.sample_end + && matches!(evidence.evidence, SidebandEvidenceKind::Pause { .. }) + }) + .cloned() + .collect() + } + + fn push_sideband( + &mut self, + sample_start: u64, + sample_end: u64, + evidence: SidebandEvidenceKind, + ) -> SidebandEvidence { + self.next_sideband_sequence = self.next_sideband_sequence.saturating_add(1); + SidebandEvidence { + sequence: self.next_sideband_sequence, + range: TailSampleRange { + session: self.session.clone(), + capture_epoch: self.capture_epoch, + sample_start, + sample_end: sample_end.max(sample_start), + }, + sample_rate_hz: self.sample_rate, + provenance: SidebandProvenance::SileroVad, + evidence, + } + } + /// Seal any still-open Supervisor segment at capture EOF. pub fn flush(&mut self, samples_seen: u64) -> Option { let _ = self.vad.flush(); @@ -719,6 +825,67 @@ mod tests { ); } + /// Sideband claims stop exactly where Silero's evidence stops: threshold + /// edges plus an unknown non-speech pause between them. + #[test] + fn sideband_edges_and_pause_keep_exact_pcm_ranges_and_order() { + let mut ingress = SileroIngress::new(16_000, "s", 4); + let first = ingress.observe_boundaries(&[ + VadBoundaryEvidence { + kind: VadBoundaryKind::SpeechStart, + sample: 8_000, + speech_probability: 0.81, + }, + VadBoundaryEvidence { + kind: VadBoundaryKind::SpeechEnd, + sample: 24_000, + speech_probability: 0.12, + }, + ]); + assert_eq!(first.len(), 2); + assert_eq!(first[0].sequence, 1); + assert_eq!(first[0].range.sample_start, 8_000); + assert_eq!(first[0].range.sample_end, 8_000); + assert!(matches!( + first[0].evidence, + SidebandEvidenceKind::SpeechStart { .. } + )); + assert_eq!(first[1].sequence, 2); + assert_eq!(first[1].range.sample_start, 24_000); + assert_eq!(first[1].range.sample_end, 24_000); + + let resumed = ingress.observe_boundaries(&[VadBoundaryEvidence { + kind: VadBoundaryKind::SpeechStart, + sample: 40_000, + speech_probability: 0.76, + }]); + assert_eq!(resumed.len(), 2, "pause then the resuming speech edge"); + assert_eq!(resumed[0].sequence, 3); + assert_eq!(resumed[0].range.sample_start, 24_000); + assert_eq!(resumed[0].range.sample_end, 40_000); + assert!(matches!( + resumed[0].evidence, + SidebandEvidenceKind::Pause { + duration_samples: 16_000, + non_speech: NonSpeechEvidence::UnknownNonSpeech, + } + )); + assert_eq!(resumed[1].sequence, 4); + assert!(matches!( + resumed[1].evidence, + SidebandEvidenceKind::SpeechStart { .. } + )); + + let attached = ingress.pause_evidence_for_range(&TailSampleRange { + session: "s".into(), + capture_epoch: 4, + sample_start: 36_000, + sample_end: 48_000, + }); + assert_eq!(attached, vec![resumed[0].clone()]); + assert_eq!(attached[0].provenance, SidebandProvenance::SileroVad); + } + /// Enclosure, not overlap: a span may only adopt a Silero range that /// already covers every sample it claimed. #[test] diff --git a/core/pipeline/streaming/span_idempotence.rs b/core/pipeline/streaming/span_idempotence.rs index c257fb9f..0a7da741 100644 --- a/core/pipeline/streaming/span_idempotence.rs +++ b/core/pipeline/streaming/span_idempotence.rs @@ -10,7 +10,8 @@ //! 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**. +//! Replay refusal is default ON; an explicit false value remains a diagnostic +//! escape hatch, never the product default. use std::collections::BTreeSet; @@ -20,17 +21,17 @@ use crate::stt::tail_provider::{TailRequestIdentity, TailSampleRange}; /// `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. +/// Whether the W13-4 idempotence lane is armed. Default ON. 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| { + !raw.is_some_and(|raw| { matches!( raw.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" + "0" | "false" | "no" | "off" ) }) } @@ -146,13 +147,14 @@ impl SpanIdempotenceLedger { pub fn offer(&mut self, offer: SpanOffer) -> SpanOfferVerdict { let range = &offer.identity.range; let request_id = offer.identity.request_id; + let acoustically_anchored = range.sample_end > range.sample_start; 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)) { + if acoustically_anchored && self.sealed_ranges.contains(&RangeKey::from_range(range)) { return self.reject_replay( NonContentEvidence::ReplayedRangeIdentity, request_id, @@ -210,7 +212,12 @@ impl SpanIdempotenceLedger { /// 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)); + // A zero-length fallback is explicitly unanchored. Treating every + // `[eof, eof)` text suffix as the same acoustic identity would erase + // later words even though no PCM evidence exists to prove a replay. + if range.sample_end > range.sample_start { + self.sealed_ranges.insert(RangeKey::from_range(range)); + } } pub fn receipts(&self) -> &[SpanIdempotenceReceipt] { @@ -333,8 +340,8 @@ mod tests { } #[test] - fn lane_defaults_off() { - assert!(!lane_enabled_from_raw(None)); + fn lane_defaults_on_with_explicit_diagnostic_off() { + assert!(lane_enabled_from_raw(None)); assert!(!lane_enabled_from_raw(Some("off"))); assert!(lane_enabled_from_raw(Some("on"))); } @@ -353,6 +360,22 @@ mod tests { assert_eq!(ledger.receipts()[0].code, "decode_failure"); } + #[test] + fn zero_length_ranges_never_alias_distinct_unanchored_text() { + let mut ledger = SpanIdempotenceLedger::default(); + assert_eq!( + ledger.offer(offer(1, 48_000, 48_000, "gamma", true, true)), + SpanOfferVerdict::Accepted + ); + ledger.mark_sealed(&range(48_000, 48_000)); + assert_eq!( + ledger.offer(offer(2, 48_000, 48_000, "delta", true, true)), + SpanOfferVerdict::Accepted + ); + assert_eq!(ledger.canvas_texts(), ["gamma", "delta"]); + assert_eq!(ledger.suppressed_count(), 0); + } + #[test] fn w13_span_idempotence_preserves_repetition() { // Fixture A — duplicate-once: same range identity replayed after seal. diff --git a/core/pipeline/streaming/tests.rs b/core/pipeline/streaming/tests.rs index 225c88d4..7d9a5b8d 100644 --- a/core/pipeline/streaming/tests.rs +++ b/core/pipeline/streaming/tests.rs @@ -1536,6 +1536,7 @@ fn transcription_events_keep_monotonic_previews_before_final() { compression_ratio: None, quality_gate_dropped: false, confidence_flags: Vec::new(), + acoustic: None, }); let events = sink.events(); diff --git a/core/pipeline/tests/regressions.rs b/core/pipeline/tests/regressions.rs index 96bdfeee..dac7c48c 100644 --- a/core/pipeline/tests/regressions.rs +++ b/core/pipeline/tests/regressions.rs @@ -155,3 +155,33 @@ fn runtime_contract_blocks_legacy_worker_symbols() { } } } + +#[test] +fn production_replay_threads_the_typed_layer1_stop_receipt() { + let recorder = read_workspace_source("core/audio/streaming_recorder.rs"); + + assert!( + recorder.contains("tail_patch_receipt: Option"), + "production replay must expose typed Layer 1 stop evidence" + ); + assert!( + recorder.contains("TailPatchSessionReceipt::from_events(&events)"), + "production replay must derive its receipt from the same event stream consumed by Delivery" + ); +} + +#[test] +fn apple_stop_emits_layer1_receipt_before_session_finalised() { + let source = read_workspace_source("core/pipeline/streaming/apple_live_session.rs"); + let receipt = source + .rfind("event_sink.on_event(&receipt.as_event())") + .expect("Apple stop must emit the typed Layer 1 receipt into the ordered event stream"); + let finalised = source + .rfind("emit_session_finalised(") + .expect("Apple stop must emit SessionFinalised"); + + assert!( + receipt < finalised, + "Layer 1 terminal accounting must reach the event stream before SessionFinalised seals Delivery evidence" + ); +} diff --git a/core/quality/engine_contract.rs b/core/quality/engine_contract.rs index fa8c2273..3c3eadd7 100644 --- a/core/quality/engine_contract.rs +++ b/core/quality/engine_contract.rs @@ -111,9 +111,10 @@ pub enum FinalityBar { /// 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. + /// Apple + Whisper + lexicon / Light+ finished L2 shaping for a + /// time-bounded span. The record `[sample_start, sample_end)` becomes + /// append-only and may schedule the existing Responses formatter. 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 @@ -129,8 +130,8 @@ pub enum FinalityBar { pub enum RelayLayer { Apple, Whisper, - Lexicon, - Formatter, + LexiconLightPlus, + ResponsesFormatter, Human, } @@ -146,10 +147,18 @@ pub struct EngineContract { pub id: &'static str, pub primary_key: &'static str, pub relay: &'static [RelayLayer], + pub machine_layer_count: usize, pub bars: &'static [FinalityBar], pub forbidden: &'static [&'static str], pub whisper_window: &'static str, pub full_file_pass: &'static str, + pub inline_format_role: &'static str, + pub silero_role: &'static str, + pub sideband_role: &'static str, + pub sideband_labels: &'static [&'static str], + pub sideband_absence: &'static str, + pub final_bam_status: &'static str, + pub session_finalised_role: &'static str, pub product_goal: &'static str, } @@ -160,10 +169,11 @@ pub const ENGINE_CONTRACT: EngineContract = EngineContract { relay: &[ RelayLayer::Apple, RelayLayer::Whisper, - RelayLayer::Lexicon, - RelayLayer::Formatter, + RelayLayer::LexiconLightPlus, + RelayLayer::ResponsesFormatter, RelayLayer::Human, ], + machine_layer_count: 4, bars: &[ FinalityBar::UtteranceFinal, FinalityBar::UtteranceSealed, @@ -177,9 +187,24 @@ pub const ENGINE_CONTRACT: EngineContract = EngineContract { "auto_replace_after_transcript_sealed", "treat_committed_as_document", "treat_whole_text_mutable_until_session_seal", + "small_inline_llm", + "infer_named_sound_from_silero", + "infer_span_identity_from_text_similarity", + "deduplicate_intentional_repetition_by_content", + "treat_mean_energy_db_as_identity", + "claim_layered_on_when_no_windows_reach_the_provider", + "final_bam_automatic_producer", + "session_finalised_content_mutation", ], - whisper_window: "3-5s utterance-bounded partials", + whisper_window: "approximately_4s_with_approximately_1s_overlap", full_file_pass: "button_only_proposal", + inline_format_role: "schedule_existing_responses_formatter", + silero_role: "orthogonal_vad_and_pcm_time_evidence", + sideband_role: "content_free_exact_pcm_evidence_never_text_authority", + sideband_labels: &["speech_start", "speech_end", "pause_unknown_non_speech"], + sideband_absence: "fail_open_continuous_apple", + final_bam_status: "superseded_no_automatic_producer", + session_finalised_role: "lifecycle_only", product_goal: "energy × time → the true sentence, live in the buffer, ~10ms to paste", }; @@ -281,7 +306,7 @@ pub fn render_engine_contract_html() -> String { ), ( "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.", + "Apple + Whisper + lexicon / Light+ finished L2 shaping for the time-bounded span. Record [sample_start, sample_end) is append-only and may schedule the existing Responses formatter. Order on the PCM axis is frozen.", ), ( "transcript_sealed", @@ -303,7 +328,7 @@ pub fn render_engine_contract_html() -> String {

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.

+

Four machine layers: L0 Apple → L1 Whisper → L2 lexicon / Light+ → L3 existing Responses formatter; then human. “Inline” is scheduling, not a separate model. Silero is orthogonal VAD and PCM-time evidence: exact speech edges plus pause=unknown_non_speech, never named-sound or text authority. Sideband absence fails open to continuous Apple. Final BAM is superseded and SessionFinalised is lifecycle-only.

@@ -359,16 +384,44 @@ mod tests { #[test] fn relay_is_apple_then_whisper_then_lexicon_then_formatter_then_human() { + assert_eq!(ENGINE_CONTRACT.machine_layer_count, 4); + assert_eq!(ENGINE_CONTRACT.relay.len(), 5); assert_eq!( ENGINE_CONTRACT.relay, &[ RelayLayer::Apple, RelayLayer::Whisper, - RelayLayer::Lexicon, - RelayLayer::Formatter, + RelayLayer::LexiconLightPlus, + RelayLayer::ResponsesFormatter, RelayLayer::Human ] ); + assert_eq!( + &ENGINE_CONTRACT.relay[..ENGINE_CONTRACT.machine_layer_count], + &[ + RelayLayer::Apple, + RelayLayer::Whisper, + RelayLayer::LexiconLightPlus, + RelayLayer::ResponsesFormatter, + ] + ); + assert_eq!( + ENGINE_CONTRACT.relay[ENGINE_CONTRACT.machine_layer_count], + RelayLayer::Human + ); + assert_eq!( + ENGINE_CONTRACT.inline_format_role, + "schedule_existing_responses_formatter" + ); + assert_eq!( + ENGINE_CONTRACT.silero_role, + "orthogonal_vad_and_pcm_time_evidence" + ); + assert_eq!( + ENGINE_CONTRACT.final_bam_status, + "superseded_no_automatic_producer" + ); + assert_eq!(ENGINE_CONTRACT.session_finalised_role, "lifecycle_only"); } #[test] @@ -400,6 +453,21 @@ mod tests { .forbidden .contains(&"treat_committed_as_document") ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"infer_span_identity_from_text_similarity") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"deduplicate_intentional_repetition_by_content") + ); + assert!( + ENGINE_CONTRACT + .forbidden + .contains(&"treat_mean_energy_db_as_identity") + ); } #[test] @@ -456,7 +524,19 @@ mod tests { "treat_whole_text_mutable_until_session_seal", "rewrite_from_zero", "button_only_proposal", - "Apple → Whisper → lexicon → formatter → human", + "Apple → Whisper → Lexicon + Light+ → Responses formatter → human", + "Exactly four machine layers", + "L2 — Lexicon + Light+", + "L3 — Responses formatter", + "Inline describes scheduling", + "Silero is orthogonal", + "Sideband evidence contract", + "unknown_non_speech", + "infer_named_sound_from_silero", + "L3 may consume only measured pause duration", + "one continuous stream with no sideband events", + "Final BAM is superseded", + "SessionFinalised is lifecycle-only", "seal-atlas", "SealedSpan.words", "clock-lie", @@ -475,10 +555,72 @@ mod tests { ); } + #[test] + fn normative_docs_name_four_machine_layers_and_historical_adrs_are_superseded() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + for relative in [ + "docs/THE_ENGINE_CONTRACT.md", + "docs/TRANSCRIPT_LANES.md", + "docs/OVERLAY_STREAMING.md", + "docs/ARCHITECTURE.md", + "docs/WHISPER_LIVE.md", + ] { + let path = root.join(relative); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{} must exist: {err}", path.display())); + let lowered = body + .to_ascii_lowercase() + .split_whitespace() + .collect::>() + .join(" "); + for needle in [ + "four machine layers", + "light+", + "responses", + "formatter", + "silero", + "orthogonal", + "final bam", + "superseded", + "sessionfinalised", + "lifecycle", + ] { + assert!( + lowered.contains(needle), + "{relative} missing four-layer contract token {needle:?}" + ); + } + for rejected in [ + "small inline llm", + "adopt a **five-layer", + "final bam, when built", + ] { + assert!( + !lowered.contains(rejected), + "{relative} contains active superseded claim {rejected:?}" + ); + } + } + + for relative in [ + "docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md", + "docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md", + ] { + let path = root.join(relative); + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("{} must exist: {err}", path.display())); + assert!( + body.contains("Status: SUPERSEDED IN FULL"), + "{relative} must be unmistakably historical" + ); + } + } + #[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")); + assert!(ENGINE_CONTRACT.whisper_window.contains("4s")); + assert!(ENGINE_CONTRACT.whisper_window.contains("1s_overlap")); } #[test] diff --git a/core/quality/overlay_quality.rs b/core/quality/overlay_quality.rs index 81f4152d..2127c9d4 100644 --- a/core/quality/overlay_quality.rs +++ b/core/quality/overlay_quality.rs @@ -4,9 +4,9 @@ //! custom lexicon (lexicon.custom.jsonl) that StreamPostProcessor / apply_lexicon already consumes. //! //! Privacy: purely local, no network, no secrets, no audio. -//! No new Settings knobs (defaults on; VoiceLab UI later). +//! No new Settings knobs (three identical human teaches by default; VoiceLab UI later). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; @@ -82,6 +82,13 @@ pub const LEXICON_SOURCE_IMPORT: &str = "import"; /// not a claim that a human wrote them. pub const LEXICON_SOURCE_LEGACY: &str = "legacy"; +/// Environment override for the number of identical human teaches required +/// before a correction pair becomes a custom-lexicon rule. +pub const LEXICON_MIN_CORRECTIONS_ENV: &str = "CODESCRIBE_LEXICON_MIN_CORRECTIONS"; +/// Product default: one correction is evidence; three identical corrections +/// are a learned rule. `1` is the explicit legacy compatibility escape. +pub const DEFAULT_LEXICON_MIN_CORRECTIONS: u64 = 3; + /// Read-only projection of one custom lexicon rule for product surfaces. /// The on-disk JSONL stores one canonical term with one or more variants; /// Voice Lab renders the flattened `variant -> canonical` truth. @@ -705,6 +712,24 @@ pub fn upsert_correction_in_custom_lexicon(variant: &str, canonical: &str) -> Re upsert_correction_in_custom_lexicon_unlocked(variant, canonical) } +/// Insert a promoted lexical pair once, even when detached overlay quality +/// tasks reach the third-confirmation boundary concurrently. +fn insert_promoted_correction_once(variant: &str, canonical: &str) -> Result { + let _write_guard = CUSTOM_LEXICON_WRITE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| anyhow::anyhow!("custom lexicon write lock was poisoned"))?; + let already_present = custom_lexicon_entries()?.iter().any(|entry| { + normalized_variant(&entry.variant) == normalized_variant(variant) + && entry.canonical.trim() == canonical.trim() + }); + if already_present { + return Ok(false); + } + upsert_correction_in_custom_lexicon_unlocked(variant, canonical)?; + Ok(true) +} + /// Single-pair upsert body. Caller must already hold [`CUSTOM_LEXICON_WRITE_LOCK`]. fn upsert_correction_in_custom_lexicon_unlocked(variant: &str, canonical: &str) -> Result<()> { upsert_corrections_unlocked(std::slice::from_ref(&(variant, canonical))) @@ -804,6 +829,131 @@ fn normalized_variant(value: &str) -> String { value.trim().to_lowercase() } +/// Read the human-teach threshold from exactly one place. +/// +/// An absent, empty, malformed, or zero value fails closed to the product law: +/// three identical corrections. This is deliberately read at each teach so the +/// registered hot environment override takes effect without restarting. +fn lexicon_min_corrections() -> u64 { + std::env::var(LEXICON_MIN_CORRECTIONS_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|count| *count > 0) + .unwrap_or(DEFAULT_LEXICON_MIN_CORRECTIONS) +} + +/// True only for persisted records created by a human lexicon-teach gesture. +/// Copy, close, send, speech-gap, formatter-only, and bulk/replay paths remain +/// evidence or explicit operator promotion respectively; none become history +/// for the automatic N-correction gate. +fn record_is_human_lexicon_teach(record: &QualityRecord) -> bool { + if record + .meta + .get("edit_provenance") + .and_then(serde_json::Value::as_str) + .map(str::trim) + == Some("manual_human") + { + return true; + } + let action = record + .meta + .get("action") + .and_then(serde_json::Value::as_str); + match action { + Some("teach-span") | Some("teach-dictionary") => true, + Some("edit") => { + record + .meta + .get("source") + .and_then(serde_json::Value::as_str) + .map(str::trim) + == Some("voice-lab") + } + _ => false, + } +} + +/// Count distinct quality records that teach exactly this pair. A record with +/// the same aligned pair twice is still one human teach, not two votes. +fn identical_human_teach_count(records: &[QualityRecord], variant: &str, canonical: &str) -> u64 { + let target_variant = normalized_variant(variant); + let target_canonical = canonical.trim(); + + records + .iter() + .filter(|record| record_is_human_lexicon_teach(record)) + .filter(|record| { + let learning_source = if record.raw_text.trim().is_empty() { + &record.delivered_text + } else { + &record.raw_text + }; + extract_lexicon_candidates(learning_source, &record.edited_text) + .into_iter() + .any(|(seen_variant, seen_canonical)| { + normalized_variant(&seen_variant) == target_variant + && seen_canonical.trim() == target_canonical + }) + }) + .map(QualityRecord::logical_id) + .collect::>() + .len() as u64 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PendingLexiconTeach { + seen: u64, + required: u64, +} + +#[derive(Debug, Default)] +struct LexiconTeachPromotion { + promoted: Vec<(String, String)>, + progress: Vec, +} + +/// Classify candidates after their quality record has been saved. The third +/// matching record is therefore included in the count and performs the first +/// upsert; later matching records refresh the existing row through the normal +/// write primitive. +fn classify_human_lexicon_teaches( + candidates: &[(String, String)], +) -> Result { + let records = all_quality_records()?; + let existing = custom_lexicon_entries()?; + let required = lexicon_min_corrections(); + let mut seen_pairs = HashSet::new(); + let mut result = LexiconTeachPromotion::default(); + + for (variant, canonical) in candidates { + if !is_sensible_lexicon_candidate(variant, canonical) { + continue; + } + let normalized_pair = (normalized_variant(variant), canonical.trim().to_string()); + if !seen_pairs.insert(normalized_pair) { + continue; + } + + let seen = identical_human_teach_count(&records, variant, canonical); + let already_promoted = existing.iter().any(|entry| { + normalized_variant(&entry.variant) == normalized_variant(variant) + && entry.canonical.trim() == canonical.trim() + }); + if seen >= required && !already_promoted { + result + .promoted + .push((variant.trim().to_string(), canonical.trim().to_string())); + } + result.progress.push(PendingLexiconTeach { + seen: seen.min(required), + required, + }); + } + + Ok(result) +} + /// Strip `target` from a row's variant lists, both the top-level /// `mispronunciations` and the legacy `extras` nest. /// @@ -955,13 +1105,49 @@ pub struct OverlayCorrectionCommit { pub quality_path: PathBuf, /// Lexicon pairs actually upserted from this commit (0 when evidence-only or filtered). pub pairs_learned: u32, - /// True when this commit did not teach the custom lexicon. + /// True when this commit left no custom-lexicon rule written. pub evidence_only: bool, + /// Manual confirmation progress, retained for the acknowledgement toast. + /// Each tuple is `(identical_teaches_seen, required_teaches)`. + lexicon_teach_progress: Vec, } impl OverlayCorrectionCommit { + /// Structured single-pair progress for bridge/UI consumers. + pub fn confirmation_progress(&self) -> Option<(u64, u64)> { + (self.lexicon_teach_progress.len() == 1).then(|| { + let progress = &self.lexicon_teach_progress[0]; + (progress.seen, progress.required) + }) + } + /// Honest post-edit acknowledgement for the overlay toast (operator UX, LL-E). pub fn acknowledgement_message(&self) -> String { + if !self.lexicon_teach_progress.is_empty() { + let learned = match self.pairs_learned { + 0 => "Saved as evidence".to_string(), + 1 => "Saved — 1 pair learned".to_string(), + count => format!("Saved — {count} pairs learned"), + }; + if self.lexicon_teach_progress.len() == 1 { + let progress = &self.lexicon_teach_progress[0]; + return format!( + "{learned} — {}/{} manual confirmations", + progress.seen, progress.required + ); + } + let progress = self + .lexicon_teach_progress + .iter() + .map(|pending| format!("{}/{}", pending.seen, pending.required)) + .collect::>() + .join(", "); + return format!( + "{learned} — {} pairs pending ({progress})", + self.lexicon_teach_progress.len() + ); + } + if self.evidence_only || self.pairs_learned == 0 { "Saved as evidence".to_string() } else if self.pairs_learned == 1 { @@ -979,6 +1165,10 @@ fn overlay_commit_teaches_lexicon(_mode: &str, action: Option<&str>) -> bool { matches!(action, Some("teach-span") | Some("teach-dictionary")) } +fn edit_provenance_is_manual(edit_provenance: Option<&str>) -> bool { + edit_provenance.map(str::trim) == Some("manual_human") +} + /// 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). @@ -1041,6 +1231,37 @@ pub fn commit_overlay_correction_with_confidence( avg_logprob: Option, speech_pct: Option, confidence_flags: Vec, +) -> Result { + commit_overlay_correction_with_provenance( + raw_text, + delivered_text, + edited_text, + mode, + model, + action, + formatting_level, + None, + avg_logprob, + speech_pct, + confidence_flags, + ) +} + +/// Persist one overlay receipt while keeping delivery action separate from the +/// explicit editor provenance that alone may vote in the three-confirmation gate. +#[allow(clippy::too_many_arguments)] +pub fn commit_overlay_correction_with_provenance( + raw_text: &str, + delivered_text: &str, + edited_text: &str, + mode: &str, + model: Option, + action: Option<&str>, + formatting_level: Option<&str>, + edit_provenance: Option<&str>, + avg_logprob: Option, + speech_pct: Option, + confidence_flags: Vec, ) -> Result { let formatting_level = formatting_level .map(FormattingPolicy::parse) @@ -1050,8 +1271,9 @@ pub fn commit_overlay_correction_with_confidence( // 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( + let teaches = + overlay_commit_teaches_lexicon(mode, action) || edit_provenance_is_manual(edit_provenance); + let mut record = QualityRecord::new_with_confidence( raw_text.to_string(), delivered_text.to_string(), edited_text.to_string(), @@ -1063,9 +1285,20 @@ pub fn commit_overlay_correction_with_confidence( speech_pct, confidence_flags, ); + if let Some(provenance) = edit_provenance + .map(str::trim) + .filter(|value| !value.is_empty()) + && let Some(meta) = record.meta.as_object_mut() + { + meta.insert( + "edit_provenance".to_string(), + serde_json::Value::String(provenance.to_string()), + ); + } let qpath = save_quality_record(&record)?; let mut pairs_learned = 0u32; + let mut lexicon_teach_progress = Vec::new(); if teaches { // Learn what the recognizer actually heard, not punctuation/casing or // rewrites introduced by the formatter/parser between STT and overlay. @@ -1074,34 +1307,46 @@ pub fn commit_overlay_correction_with_confidence( } else { raw_text }; - // Word-level extraction may yield several pairs; upsert each. - for (variant, canonical) in extract_lexicon_candidates(learning_source, edited_text) { - if is_sensible_lexicon_candidate(&variant, &canonical) { - match upsert_correction_in_custom_lexicon(&variant, &canonical) { - Ok(()) => { - pairs_learned = pairs_learned.saturating_add(1); - tracing::info!( - "quality: added lexicon candidate {} -> {}", - variant, - canonical - ); - } - Err(e) => { - tracing::warn!( - "quality: failed to append lexicon candidate {} -> {}: {}", - variant, - canonical, - e - ); + // Word-level extraction may yield several pairs. Only candidates taught + // by enough identical human records may reach the write primitive. + match classify_human_lexicon_teaches(&extract_lexicon_candidates( + learning_source, + edited_text, + )) { + Ok(promotion) => { + lexicon_teach_progress = promotion.progress; + for (variant, canonical) in promotion.promoted { + match insert_promoted_correction_once(&variant, &canonical) { + Ok(true) => { + pairs_learned = pairs_learned.saturating_add(1); + tracing::info!( + "quality: added lexicon candidate {} -> {}", + variant, + canonical + ); + } + Ok(false) => {} + Err(e) => { + tracing::warn!( + "quality: failed to append lexicon candidate {} -> {}: {}", + variant, + canonical, + e + ); + } } } } + Err(error) => tracing::warn!( + "quality: could not count prior human teaches after saving evidence: {error:#}" + ), } } Ok(OverlayCorrectionCommit { quality_path: qpath, pairs_learned, - evidence_only: !teaches, + evidence_only: !teaches || pairs_learned == 0, + lexicon_teach_progress, }) } @@ -1366,11 +1611,11 @@ pub struct VoiceLabSaveOutcome { /// ID shape + non-empty canonical only — saving a human edit is not a /// lexicon candidacy question). /// 2. Word-level pairs are derived from `raw_text -> canonical` (falling back -/// to delivered text only for legacy records that never captured raw STT) -/// (aligned replace runs), each individually gated by -/// [`is_sensible_lexicon_candidate`], and the survivors upserted in one -/// atomic lexicon rewrite. A failed rewrite leaves the previous lexicon -/// bytes intact and is reported via `lexicon_error`, never as `Err`. +/// to delivered text only for legacy records that never captured raw STT). +/// Each pair needs the configured number of identical saved human teaches; +/// only promoted pairs enter one atomic lexicon rewrite. A failed rewrite +/// leaves the previous lexicon bytes intact and is reported via +/// `lexicon_error`, never as `Err`. pub fn finalize_voice_lab_correction( correction_id: &str, canonical: &str, @@ -1431,20 +1676,30 @@ pub fn finalize_voice_lab_correction( let pairs = derive_lexicon_pairs(learning_source, canonical); let mut pairs_learned = 0u32; let mut lexicon_error = None; - if !pairs.is_empty() { - let borrowed: Vec<(&str, &str)> = pairs - .iter() - .map(|(variant, canonical)| (variant.as_str(), canonical.as_str())) - .collect(); - match upsert_corrections_in_custom_lexicon(&borrowed) { - Ok(()) => pairs_learned = borrowed.len() as u32, - Err(error) => { - tracing::error!( - "quality: voice lab lexicon learn failed after revision save: {error:#}" - ); - lexicon_error = Some(format!("{error:#}")); + match classify_human_lexicon_teaches(&pairs) { + Ok(promotion) if !promotion.promoted.is_empty() => { + let borrowed: Vec<(&str, &str)> = promotion + .promoted + .iter() + .map(|(variant, canonical)| (variant.as_str(), canonical.as_str())) + .collect(); + match upsert_corrections_in_custom_lexicon(&borrowed) { + Ok(()) => pairs_learned = borrowed.len() as u32, + Err(error) => { + tracing::error!( + "quality: voice lab lexicon learn failed after revision save: {error:#}" + ); + lexicon_error = Some(format!("{error:#}")); + } } } + Ok(_) => {} + Err(error) => { + tracing::error!( + "quality: voice lab could not count prior human teaches after revision save: {error:#}" + ); + lexicon_error = Some(format!("{error:#}")); + } } Ok(VoiceLabSaveOutcome { @@ -1671,20 +1926,211 @@ mod tests { #[test] #[serial] - fn teach_span_lexicon_learns_pair_and_gap_is_evidence_only() { + fn teach_span_requires_three_identical_corrections_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 first = super::teach_span("uni agentka", "Junie", "lexicon_corrected") + .expect("first teach lexicon span"); + assert_eq!(first.pairs_learned, 0); + assert!(first.evidence_only); + assert_eq!( + first.acknowledgement_message(), + "Saved as evidence — 1/3 manual confirmations" + ); + assert_eq!( + audit_line_count(), + 1, + "first teach persists quality evidence" + ); + let lexicon_path = Config::config_dir().join("lexicon.custom.jsonl"); + assert!( + !lexicon_path.exists(), + "one identical teach is evidence, not a rule" + ); + + let second = super::teach_span("UNI AGENTKA", "Junie", "lexicon_corrected") + .expect("second teach lexicon span"); + assert_eq!(second.pairs_learned, 0); + assert!(second.evidence_only); + assert_eq!( + second.acknowledgement_message(), + "Saved as evidence — 2/3 manual confirmations" + ); + assert!( + !lexicon_path.exists(), + "two identical teaches are still evidence" + ); + let learned = super::teach_span("uni agentka", "Junie", "lexicon_corrected") - .expect("teach lexicon span"); + .expect("third 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); + assert_eq!( + learned.acknowledgement_message(), + "Saved — 1 pair learned — 3/3 manual confirmations" + ); + let entries = custom_lexicon_entries().expect("learned custom lexicon"); + assert!(entries.iter().any(|entry| { + entry.variant == "uni agentka" + && entry.canonical == "Junie" + && entry.source == LEXICON_SOURCE_CORRECTION + })); + assert_eq!( + crate::pipeline::stream_postprocess::apply_lexicon("to uni agentka mówi"), + "to Junie mówi", + "the third teach must rewrite a later word-boundary transcript" + ); + let refreshed = super::teach_span("UNI AGENTKA", "Junie", "lexicon_corrected") + .expect("later identical teach leaves the promoted rule alone"); + assert_eq!(refreshed.pairs_learned, 0, "promotion occurs exactly once"); + assert_eq!( + refreshed.acknowledgement_message(), + "Saved as evidence — 3/3 manual confirmations" + ); + assert_eq!( + custom_lexicon_entries() + .unwrap() + .iter() + .filter(|entry| normalized_variant(&entry.variant) == "uni agentka") + .count(), + 1, + "re-teaching after promotion must not stack or rewrite duplicate rows" + ); + + for _ in 0..3 { + let gap = super::teach_span("brak", "uzupełnienie", "speech_gap") + .expect("speech-gap evidence"); + assert_eq!(gap.pairs_learned, 0); + assert!(gap.evidence_only); + } + assert!( + !custom_lexicon_entries() + .unwrap() + .iter() + .any(|entry| entry.variant == "brak"), + "speech-gap records never vote toward a lexicon rule" + ); + } + + #[test] + #[serial] + fn ordinary_manual_overlay_edits_vote_three_times_and_promote_once() { + 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 commit = |action: &str, provenance: Option<&str>| { + commit_overlay_correction_with_provenance( + "ajwo", + "ajwo", + "Iwo", + "overlay", + None, + Some(action), + Some("correction"), + provenance, + None, + None, + Vec::new(), + ) + .unwrap() + }; + + let first = commit("copy", Some("manual_human")); + assert_eq!(first.confirmation_progress(), Some((1, 3))); + assert_eq!(first.pairs_learned, 0); + let second = commit("paste", Some("manual_human")); + assert_eq!(second.confirmation_progress(), Some((2, 3))); + assert_eq!(second.pairs_learned, 0); + let third = commit("close", Some("manual_human")); + assert_eq!(third.confirmation_progress(), Some((3, 3))); + assert_eq!(third.pairs_learned, 1); + + let delivery_only = commit("copy", None); + assert_eq!(delivery_only.confirmation_progress(), None); + assert_eq!(delivery_only.pairs_learned, 0); + assert_eq!( + custom_lexicon_entries() + .unwrap() + .iter() + .filter(|entry| normalized_variant(&entry.variant) == "ajwo") + .count(), + 1, + "the third distinct manual act promotes once; delivery actions do not vote" + ); + + let records = all_quality_records().unwrap(); + assert_eq!( + records[0] + .meta + .get("action") + .and_then(serde_json::Value::as_str), + Some("copy") + ); + assert_eq!( + records[0] + .meta + .get("edit_provenance") + .and_then(serde_json::Value::as_str), + Some("manual_human") + ); + } + + #[test] + fn repeated_revision_of_one_correction_id_is_one_vote() { + let mut first = QualityRecord::new( + "ajwo".into(), + "ajwo".into(), + "Iwo".into(), + "overlay", + None, + Some("correction".into()), + Some("copy"), + ); + first + .meta + .as_object_mut() + .unwrap() + .insert("edit_provenance".into(), "manual_human".into()); + let mut revision = first.clone(); + revision.revision = 2; + assert_eq!( + identical_human_teach_count(&[first, revision], "ajwo", "Iwo"), + 1 + ); + } + + #[test] + #[serial] + fn concurrent_promotion_insert_reports_exactly_one_new_rule() { + 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 barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let handles = (0..2) + .map(|_| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + insert_promoted_correction_once("ajwo", "Iwo").unwrap() + }) + }) + .collect::>(); + let inserted = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|inserted| *inserted) + .count(); + assert_eq!(inserted, 1); + assert_eq!(custom_lexicon_entries().unwrap().len(), 1); } /// E2E: long-dictation commit learns one pair and stamps correction provenance. @@ -1715,10 +2161,19 @@ mod tests { ) .expect("commit long dictation evidence"); assert_eq!(evidence.pairs_learned, 0); + let first = teach_span("zaznaczenie", "selection", "lexicon_corrected") + .expect("first explicit teach of the one-word fix"); + assert_eq!(first.pairs_learned, 0); + let second = teach_span("ZAZNACZENIE", "selection", "lexicon_corrected") + .expect("second explicit teach of the one-word fix"); + assert_eq!(second.pairs_learned, 0); let commit = teach_span("zaznaczenie", "selection", "lexicon_corrected") - .expect("explicit teach of the one-word fix"); + .expect("third explicit teach of the one-word fix"); assert_eq!(commit.pairs_learned, 1); - assert_eq!(commit.acknowledgement_message(), "Saved — 1 pair learned"); + assert_eq!( + commit.acknowledgement_message(), + "Saved — 1 pair learned — 3/3 manual confirmations" + ); let entries = custom_lexicon_entries().expect("lexicon"); assert!( @@ -1740,6 +2195,96 @@ mod tests { assert_eq!(next, "tu selection jest"); } + /// A different canonical is a different vote, even when the STT variant is identical. + #[test] + #[serial] + fn same_variant_with_different_canonical_does_not_promote_the_first_pair() { + 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); + } + + teach_span("zazdroszczę", "życzliwość", "lexicon_corrected").unwrap(); + teach_span("ZAZDROSZCZĘ", "życzliwość", "lexicon_corrected").unwrap(); + let different = teach_span("zazdroszczę", "współczucie", "lexicon_corrected") + .expect("different canonical is its own pair"); + + assert_eq!(different.pairs_learned, 0); + assert!( + custom_lexicon_entries().unwrap().is_empty(), + "two X teaches plus one Y teach must not promote X" + ); + } + + /// The per-utterance Dictionary teach action uses the same three-correction + /// gate as a highlighted span; it is not the bulk proposed-file promotion. + #[test] + #[serial] + fn overlay_teach_dictionary_action_requires_three_identical_corrections() { + 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); + } + + for expected in [0, 0, 1] { + let outcome = commit_overlay_correction( + "kubernetis", + "kubernetis", + "Kubernetes", + "overlay", + None, + Some("teach-dictionary"), + ) + .expect("dictionary teach gesture"); + assert_eq!(outcome.pairs_learned, expected); + } + assert!( + custom_lexicon_entries() + .unwrap() + .iter() + .any(|entry| { entry.variant == "kubernetis" && entry.canonical == "Kubernetes" }) + ); + } + + /// Overlay copy and close remain evidence lines, never hidden votes toward teach N. + #[test] + #[serial] + fn overlay_copy_and_close_do_not_increment_the_human_teach_counter() { + 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); + } + + teach_span("pansiwe", "Pensieve", "lexicon_corrected").unwrap(); + for action in ["copy", "close"] { + let evidence = commit_overlay_correction( + "pansiwe", + "pansiwe", + "Pensieve", + "overlay", + None, + Some(action), + ) + .expect("copy/close evidence"); + assert_eq!(evidence.pairs_learned, 0); + assert!(evidence.evidence_only); + } + let second = + teach_span("pansiwe", "Pensieve", "lexicon_corrected").expect("second actual teach"); + + assert_eq!(second.pairs_learned, 0); + assert!( + custom_lexicon_entries().unwrap().is_empty(), + "copy and close must not turn the second explicit teach into a rule" + ); + } + /// Empty and emptied lexicon rows are dropped on the next rewrite (W11-B husks). #[test] #[serial] @@ -2117,6 +2662,7 @@ mod tests { fn test_voice_lab_read_surface_returns_live_records_and_lexicon_entries() { let temp_dir = tempfile::tempdir().expect("temp data dir for read surface"); let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _min_guard = EnvRestore::capture(LEXICON_MIN_CORRECTIONS_ENV); let temp_root = temp_dir .path() .canonicalize() @@ -2124,6 +2670,9 @@ mod tests { // SAFETY: this test is serial and EnvRestore restores process state. unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + // This read-projection fixture is intentionally a one-write + // custom-lexicon store fixture, not a product-threshold test. + std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, "1"); } assert!( @@ -2319,8 +2868,12 @@ mod tests { 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 _min_guard = EnvRestore::capture(LEXICON_MIN_CORRECTIONS_ENV); let temp_root = temp_dir.path().canonicalize().unwrap(); - unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root) }; + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, "1"); + }; let outcome = teach_span("rawvariant", "RawCanonical", "lexicon_corrected") .expect("explicit teach from raw STT"); @@ -2345,8 +2898,14 @@ mod tests { fn voice_lab_revision_keeps_raw_stt_as_dictionary_source() { let temp_dir = tempfile::tempdir().expect("temp quality root"); let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _min_guard = EnvRestore::capture(LEXICON_MIN_CORRECTIONS_ENV); let temp_root = temp_dir.path().canonicalize().unwrap(); - unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root) }; + unsafe { + std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + // This fixture isolates the raw-source writer behavior; the product + // threshold itself is covered by the three-save Voice Lab test. + std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, "1"); + }; commit_overlay_correction_with_level( "rawvariant", @@ -2380,9 +2939,13 @@ mod tests { fn finalizing_correction_appends_revision_and_leaves_one_active_mapping() { let temp_dir = tempfile::tempdir().expect("temp data dir for Voice Lab edit"); let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _min_guard = EnvRestore::capture(LEXICON_MIN_CORRECTIONS_ENV); let temp_root = temp_dir.path().canonicalize().unwrap(); unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + // This regression is the one-write supersession fixture, not the + // product threshold contract. + std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, "1"); } let quality_path = commit_overlay_correction( @@ -2440,6 +3003,49 @@ mod tests { assert_eq!(active[0].canonical, "Junie Prime"); } + /// Voice Lab revisions are human teaches too, but each save gets one vote. + #[test] + #[serial] + fn voice_lab_requires_three_identical_human_saves_before_learning() { + let temp_dir = tempfile::tempdir().expect("temp data dir for Voice Lab threshold"); + 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 ids = (0..3) + .map(|_| seed_voice_lab_record("uni agentka", "uni agentka")) + .collect::>(); + for (index, id) in ids.iter().enumerate() { + let outcome = + finalize_voice_lab_correction(id, "Junie").expect("Voice Lab human revision saves"); + assert_eq!(outcome.pairs_learned, if index == 2 { 1 } else { 0 }); + assert_eq!(outcome.lexicon_error, None); + } + assert!(custom_lexicon_entries().unwrap().iter().any(|entry| { + entry.variant == "uni agentka" + && entry.canonical == "Junie" + && entry.source == LEXICON_SOURCE_CORRECTION + })); + } + + /// Invalid values cannot silently relax the sealed product default. + #[test] + #[serial] + fn lexicon_min_corrections_fails_closed_to_three() { + let _guard = EnvRestore::capture(LEXICON_MIN_CORRECTIONS_ENV); + + unsafe { std::env::remove_var(LEXICON_MIN_CORRECTIONS_ENV) }; + assert_eq!(lexicon_min_corrections(), 3); + for invalid in ["", "0", "not-a-number"] { + unsafe { std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, invalid) }; + assert_eq!(lexicon_min_corrections(), 3, "{invalid:?} must fail closed"); + } + unsafe { std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, "1") }; + assert_eq!(lexicon_min_corrections(), 1); + } + /// One committed record inside an isolated data dir; returns its logical ID. fn seed_voice_lab_record(delivered: &str, edited: &str) -> String { commit_overlay_correction(delivered, delivered, edited, "overlay", None, Some("copy")) @@ -2490,10 +3096,7 @@ mod tests { assert_eq!(outcome.record.edited_text, canonical.trim()); assert_eq!(outcome.lexicon_error, None); - assert_eq!( - outcome.pairs_learned, 1, - "pansiwe -> Pensieve is the one sensible pair" - ); + assert_eq!(outcome.pairs_learned, 0, "one save is still evidence"); assert_eq!(audit_line_count(), 2, "revision appended, nothing replaced"); assert_eq!( recent_quality_records(1).unwrap()[0].edited_text, @@ -2507,9 +3110,13 @@ mod tests { fn pairs_are_gated_individually_not_as_one_edit() { let temp_dir = tempfile::tempdir().expect("temp data dir"); let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _min_guard = EnvRestore::capture(LEXICON_MIN_CORRECTIONS_ENV); let temp_root = temp_dir.path().canonicalize().unwrap(); unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + // This is an extractor/write-primitive fixture; the product + // threshold itself is covered separately below. + std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, "1"); } let insane = "a".repeat(90); // above MAX_CANDIDATE_CHARS — rejected per-pair @@ -2559,9 +3166,13 @@ mod tests { fn lexicon_write_failure_never_vetoes_the_human_save() { let temp_dir = tempfile::tempdir().expect("temp data dir"); let _guard = EnvRestore::capture("CODESCRIBE_DATA_DIR"); + let _min_guard = EnvRestore::capture(LEXICON_MIN_CORRECTIONS_ENV); let temp_root = temp_dir.path().canonicalize().unwrap(); unsafe { std::env::set_var("CODESCRIBE_DATA_DIR", &temp_root); + // Force the writer path: this fixture verifies that an I/O failure + // after an eligible promotion cannot veto the human revision. + std::env::set_var(LEXICON_MIN_CORRECTIONS_ENV, "1"); } let id = seed_voice_lab_record("uni agentka", "uni agentka"); diff --git a/core/stt/active_names.rs b/core/stt/active_names.rs new file mode 100644 index 00000000..a1237fa5 --- /dev/null +++ b/core/stt/active_names.rs @@ -0,0 +1,191 @@ +//! Read-only active Agent names from the W2-04 installed session bridge. +//! +//! The lease writer remains `scripts/bus-demux.py`. STT only consumes a bounded, +//! expiring snapshot: no helper process, lock, deletion, or heartbeat mutation. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use directories::BaseDirs; +use serde::Deserialize; + +const LEASE_SCHEMA: &str = "codescribe.agent-bridge.lease.v1"; +const BRIDGE_HOME_ENV: &str = "CODESCRIBE_AGENT_BRIDGE_HOME"; +const LEASE_TTL_SECONDS: f64 = 120.0; +const MAX_LEASE_FILES: usize = 64; +const MAX_LEASE_BYTES: u64 = 16 * 1024; +const MAX_ACTIVE_NAMES: usize = 16; +const CACHE_FOR: Duration = Duration::from_secs(1); + +#[derive(Debug, Deserialize)] +struct SessionLease { + schema: String, + name: Option, + active: bool, + heartbeat_unix: f64, +} + +#[derive(Default)] +struct NameCache { + root: PathBuf, + refreshed_at: Option, + names: Vec, +} + +static ACTIVE_NAMES: OnceLock> = OnceLock::new(); + +/// Current bounded active-name snapshot. Errors and stale leases fail open. +pub fn active_names() -> Vec { + let root = bridge_home(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs_f64()) + .unwrap_or(0.0); + let cache = ACTIVE_NAMES.get_or_init(|| Mutex::new(NameCache::default())); + let mut cache = cache.lock().unwrap_or_else(|error| error.into_inner()); + if cache.root == root + && cache + .refreshed_at + .is_some_and(|refreshed| refreshed.elapsed() < CACHE_FOR) + { + return cache.names.clone(); + } + cache.root = root.clone(); + cache.names = read_active_names_at(&root, now, LEASE_TTL_SECONDS); + cache.refreshed_at = Some(Instant::now()); + cache.names.clone() +} + +fn bridge_home() -> PathBuf { + if let Ok(value) = std::env::var(BRIDGE_HOME_ENV) { + let value = value.trim(); + if !value.is_empty() { + return expand_tilde(value); + } + } + BaseDirs::new() + .map(|dirs| dirs.home_dir().join(".codescribe/agent-bridge")) + .unwrap_or_else(|| PathBuf::from(".codescribe/agent-bridge")) +} + +fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + return BaseDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .unwrap_or_else(|| PathBuf::from(path)); + } + if let Some(relative) = path.strip_prefix("~/") { + return BaseDirs::new() + .map(|dirs| dirs.home_dir().join(relative)) + .unwrap_or_else(|| PathBuf::from(path)); + } + PathBuf::from(path) +} + +fn canonical_name(value: &str) -> Option { + let value = value.trim(); + let count = value.chars().count(); + if !(2..=32).contains(&count) || !value.chars().all(char::is_alphabetic) { + return None; + } + let mut chars = value.chars(); + let first = chars.next()?; + Some(first.to_uppercase().chain(chars).collect()) +} + +fn read_active_names_at(root: &Path, now: f64, ttl_seconds: f64) -> Vec { + let Ok(entries) = fs::read_dir(root.join("leases")) else { + return Vec::new(); + }; + let mut paths = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "json") + }) + .collect::>(); + paths.sort(); + paths.truncate(MAX_LEASE_FILES); + + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for path in paths { + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + if metadata.len() > MAX_LEASE_BYTES { + continue; + } + let Ok(bytes) = fs::read(&path) else { + continue; + }; + let Ok(lease) = serde_json::from_slice::(&bytes) else { + continue; + }; + let age = now - lease.heartbeat_unix; + if lease.schema != LEASE_SCHEMA + || !lease.active + || !age.is_finite() + || age < 0.0 + || age > ttl_seconds + { + continue; + } + let Some(name) = lease.name.as_deref().and_then(canonical_name) else { + continue; + }; + if seen.insert(name.to_lowercase()) { + names.push(name); + if names.len() == MAX_ACTIVE_NAMES { + break; + } + } + } + names +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_lease(root: &Path, file: &str, name: &str, active: bool, heartbeat: f64) { + let dir = root.join("leases"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(file), + serde_json::to_vec(&serde_json::json!({ + "schema": LEASE_SCHEMA, + "name": name, + "active": active, + "heartbeat_unix": heartbeat, + })) + .unwrap(), + ) + .unwrap(); + } + + #[test] + fn active_names_are_bounded_deduplicated_and_expire() { + let temp = tempfile::tempdir().unwrap(); + write_lease(temp.path(), "a.json", "iwo", true, 990.0); + write_lease(temp.path(), "b.json", "IWO", true, 995.0); + write_lease(temp.path(), "c.json", "stary", true, 800.0); + write_lease(temp.path(), "d.json", "zamkniety", false, 999.0); + write_lease(temp.path(), "e.json", "piwo trzy", true, 999.0); + + assert_eq!(read_active_names_at(temp.path(), 1_000.0, 120.0), ["Iwo"]); + } + + #[test] + fn malformed_or_future_lease_fails_open() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("leases")).unwrap(); + fs::write(temp.path().join("leases/bad.json"), b"not json").unwrap(); + write_lease(temp.path(), "future.json", "Iwo", true, 1_001.0); + assert!(read_active_names_at(temp.path(), 1_000.0, 120.0).is_empty()); + } +} diff --git a/core/stt/mod.rs b/core/stt/mod.rs index e54250dc..c7f44930 100644 --- a/core/stt/mod.rs +++ b/core/stt/mod.rs @@ -24,6 +24,8 @@ //! commit boundary must resolve to a skip rather than a whole-file re-pass //! appended onto text the user already sees. +/// Bounded read-only view of active W2-04 Agent session-name leases. +pub mod active_names; /// Candle Whisper singleton adapter implementing `TranscriptionAdapter`. pub mod adapter; /// Apple SpeechAnalyzer live STT bridge (letter-level canvas; live lane only). diff --git a/core/stt/tail_patcher/mod.rs b/core/stt/tail_patcher/mod.rs index 03c64170..83ebe245 100644 --- a/core/stt/tail_patcher/mod.rs +++ b/core/stt/tail_patcher/mod.rs @@ -8,39 +8,33 @@ //! //! # Relationship to Smart final-pass (`FINAL_PASS_MODE`) //! -//! **Orthogonal toggles — no silent coupling.** +//! **Orthogonal lifecycle controls — no silent coupling.** //! //! | Control | Env | Default | What it does | //! | --- | --- | --- | --- | -//! | Final pass | `FINAL_PASS_MODE` | `smart` | Stop-path only: whether to run a full WAV Whisper re-pass after release | -//! | Layered / Layer 1 | `CODESCRIBE_LAYERED_TRANSCRIPTION` | **phase1** | During-hold gap-fill: Whisper tail patches on sealed utterances. Unset → phase1; explicit `off`/`0`/`false` disarms. | +//! | Final pass | `FINAL_PASS_MODE` | legacy | Normal stop does not run a full WAV pass; Retranscribe is explicit. | +//! | Layered / Layer 1 | product mode + compatibility override | Local Power armed | During-hold, exact-span Whisper repair on the Apple progressive path. | //! //! - **Smart** = skip full stop re-pass when streaming completeness is //! adjudicated Complete. It does **not** enable layered transcription. //! - **Off** = never full stop re-pass. It does **not** force Whisper at stop. -//! - Layered phase ≥ 1 may run under any final-pass mode when the live session -//! path actually wires Layer 1 (see below). +//! - Local Power arms Layer 1 independently of final-pass state. `phase1` +//! remains a persisted compatibility token; explicit `off` is degraded. //! -//! Product intent: Smart *works with* layered (completeness skip + live -//! gap-fill). Phase 1 is the stock live default; W13 fusion / idempotence / -//! highlight flags remain the operator-flip surface, not this gate. +//! Product intent and runtime now agree on the Apple path: live patching is a +//! required part of Local Power, while unfenced routes fail closed. //! -//! # Where Layer 1 is wired today +//! # Where Layer 1 may mutate today //! -//! Both live paths are wired; gate is [`layered_phase`] ≥ 1 on each. +//! Only the Apple progressive path owns the required pending-span fence. Its +//! in-process, sidecar, and remote tail providers all return the same exact +//! request/range identity. Outcomes are applied to the byte-identical baseline +//! before `UtteranceFinal`; the event itself already contains the corrected +//! text. Replays and completions after seal are typed refusals. //! -//! - **VAD/scheduler:** `core/pipeline/streaming/session.rs` → -//! `vad_transcription_session` (Whisper engine, or Apple with -//! `CODESCRIBE_APPLE_STT_LIVE_MODE=wav`). Attaches FINAL audio per work item, -//! spawns Whisper re-transcribe + [`compute_tail_patch`], emits -//! `ReplaceRange { source: TailPatch }`, counts in `SessionFinalised.layer_summary`. -//! - **Apple progressive live:** `core/pipeline/streaming/apple_live_session.rs` -//! → `apple_stream_transcription_session` (W2-A). Each sealed `UtteranceFinal` -//! resolves to its retained PCM window and is handed to the async Layer 1 -//! lane, at most one job in flight so Whisper never sits on the event-drain -//! loop. A boundary that cannot address retained audio is never patched; a -//! full queue drops the request rather than stalling capture; the bounded -//! backlog left when capture stops is settled before `SessionFinalised`. +//! The legacy VAD/scheduler path has no pending-span owner. Explicit Phase 1 +//! therefore fails closed there with `tail_patch_route_unbound`; it may not +//! revive the old post-final `ReplaceRange` channel. //! //! # Invariants (from the ADR "Hard invariants") //! @@ -98,22 +92,16 @@ use tracing::info; use crate::pipeline::contracts::{EngineEvent, LayerSource}; -/// Env flag gating the layered transcription pipeline. +/// Compatibility override for the layered transcription pipeline. /// -/// `CODESCRIBE_LAYERED_TRANSCRIPTION=phase{1,2,3,4}` — **defaults to phase1**. -/// The live tail patch is a core element of the triangulation, not an opt-in -/// (operator directive 2026-08-09: "korekcje na żywo to live tail patch, który -/// MUSI być podstawowym elementem"). Explicit `off`/`0`/`false` disables; -/// explicit `phaseN` selects a phase. +/// `CODESCRIBE_LAYERED_TRANSCRIPTION=phase{1,2,3,4}` preserves older phase +/// selection. Product-mode bootstrap owns the default: Local Power + unset is +/// armed; explicit off or malformed input is a named degraded disposition. /// /// **Not** `FINAL_PASS_MODE`: Smart final-pass never writes this flag. Kept -/// here (not in the config hub) so this cut stays isolated; the orchestrator -/// can promote it to a typed config field when it lands. +/// here as the parser/constant owner; the key is promoted through settings.json. pub const LAYERED_TRANSCRIPTION_ENV: &str = "CODESCRIBE_LAYERED_TRANSCRIPTION"; -/// Phase served when the flag is unset — Layer 1 live tail patch on. -const LAYERED_DEFAULT_PHASE: u8 = 1; - /// Env override for [`TailPatchConfig::max_change_ratio`]. pub const TAIL_PATCH_MAX_CHANGE_RATIO_ENV: &str = "CODESCRIBE_TAIL_PATCH_MAX_CHANGE_RATIO"; @@ -221,9 +209,8 @@ pub fn parse_layered_phase_value(raw: &str) -> Option { } } -/// Active layered-transcription phase. Unset → the default phase (live tail -/// patch on); an explicit `off`/`0`/`false` — or unparseable garbage — is the -/// only way to `None`. +/// Active layered-transcription phase. Unset, explicit `off`/`0`/`false`, and +/// unparseable garbage all fail closed to `None`. /// /// Independent of `FINAL_PASS_MODE` / Smart completeness skip. pub fn layered_phase() -> Option { @@ -232,10 +219,10 @@ pub fn layered_phase() -> Option { } /// Resolve the layered phase from an optional raw override without touching -/// process-global environment state. `None` carries the production default. +/// process-global environment state. `None` means no compatibility override; +/// recording bootstrap resolves the product-mode default. pub fn layered_phase_from_raw(raw: Option<&str>) -> Option { - raw.map(parse_layered_phase_value) - .unwrap_or(Some(LAYERED_DEFAULT_PHASE)) + raw.and_then(parse_layered_phase_value) } /// Env override for [`TailPatchConfig::small_edit_token_floor`]. @@ -1861,7 +1848,7 @@ mod tests { #[test] fn layered_phase_parses_phase_prefix() { // Pure parse — no process env (suite stays deterministic under parallel exec). - assert_eq!(layered_phase_from_raw(None), Some(LAYERED_DEFAULT_PHASE)); + assert_eq!(layered_phase_from_raw(None), None); assert_eq!(layered_phase_from_raw(Some("off")), None); assert_eq!(parse_layered_phase_value("phase1"), Some(1)); assert_eq!(parse_layered_phase_value("phase2"), Some(2)); diff --git a/core/stt/tail_provider.rs b/core/stt/tail_provider.rs index c20010c3..7a735ea1 100644 --- a/core/stt/tail_provider.rs +++ b/core/stt/tail_provider.rs @@ -193,12 +193,27 @@ impl TailSampleRange { }) } - fn contains(&self, other: &Self) -> bool { + pub(crate) fn contains(&self, other: &Self) -> bool { self.session == other.session && self.capture_epoch == other.capture_epoch && self.sample_start <= other.sample_start && other.sample_end <= self.sample_end } + + /// Half-open ranges on the same capture epoch share samples. + pub fn overlaps(&self, other: &Self) -> bool { + self.session == other.session + && self.capture_epoch == other.capture_epoch + && self.sample_start < other.sample_end + && other.sample_start < self.sample_end + } + + /// Same epoch, no shared samples. Identical lexical text is still two observations. + pub fn is_disjoint(&self, other: &Self) -> bool { + self.session == other.session + && self.capture_epoch == other.capture_epoch + && !self.overlaps(other) + } } /// Idempotency key plus the exact audio range it names. @@ -302,13 +317,22 @@ impl TailProviderPayload { if self.segments.len() > MAX_TAIL_PROVIDER_SEGMENTS { bail!("tail provider returned too many segments"); } - self.identity.range.sample_len()?; + if self.identity.range.sample_len()? == 0 { + bail!("tail provider request range must contain samples"); + } let mut segment_text_bytes = 0usize; + let mut previous_segment_end = self.identity.range.sample_start; for segment in &self.segments { - segment.range.sample_len()?; + if segment.range.sample_len()? == 0 { + bail!("tail provider segment range must contain samples"); + } if !self.identity.range.contains(&segment.range) { bail!("tail provider segment range escapes request range"); } + if segment.range.sample_start < previous_segment_end { + bail!("tail provider segments must be ordered and non-overlapping"); + } + previous_segment_end = segment.range.sample_end; segment_text_bytes = segment_text_bytes .checked_add(segment.text.len()) .ok_or_else(|| anyhow!("tail provider segment text size overflow"))?; @@ -424,10 +448,11 @@ impl TailProvider for InProcessTailProvider { let raw = if speech.is_empty() { RawTranscript::default() } else { - super::candle_transcribe_long_with_segments( + super::candle_transcribe_long_with_segments_with_initial_prompt( &speech, request.sample_rate, request.language.as_deref(), + crate::pipeline::stream_postprocess::whisper_initial_prompt(), )? }; let request_range = &request.identity.range; @@ -1095,6 +1120,9 @@ impl TailProvider for RemoteTailProvider { .text("model", model.clone()) .text("language", language.to_string()) .text("response_format", "verbose_json"); + if let Some(prompt) = crate::pipeline::stream_postprocess::whisper_initial_prompt() { + form = form.text("prompt", prompt); + } if let Some((field, value)) = crate::stt::request_vocabulary::codescribe_stt_vocabulary_form_part(&self.endpoint) { @@ -1484,4 +1512,58 @@ mod tests { different_request.identity.request_id += 1; assert!(fake.transcribe(&different_request, &pcm).is_err()); } + + #[test] + fn provider_refuses_floating_or_overlapping_segment_clock() { + let range = TailSampleRange { + session: "ordered".into(), + capture_epoch: 2, + sample_start: 0, + sample_end: 320, + }; + let payload = TailProviderPayload { + identity: TailRequestIdentity { + request_id: 1, + range: range.clone(), + }, + text: "dwa jeden".into(), + segments: vec![ + TimedTailSegment { + text: "dwa".into(), + range: TailSampleRange { + sample_start: 160, + sample_end: 320, + ..range.clone() + }, + }, + TimedTailSegment { + text: "jeden".into(), + range: TailSampleRange { + sample_start: 0, + sample_end: 160, + ..range.clone() + }, + }, + ], + avg_logprob: None, + compression_ratio: None, + quality_gate_dropped: false, + provider_id: TailProviderId::Fake, + elapsed_ms: 0, + evidence: TailProviderEvidence { + source: TailEvidenceSource::Whisper, + revision: None, + stability: TailEvidenceStability::Final, + timing_quality: TailTimingQuality::Synthetic, + avg_logprob: None, + }, + }; + assert!( + payload + .validate() + .unwrap_err() + .to_string() + .contains("ordered and non-overlapping") + ); + } } diff --git a/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md b/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md index 33577328..36a587fc 100644 --- a/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md +++ b/docs/ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md @@ -1,12 +1,23 @@ # ADR 2026-05-26 — Layered Incremental Transcription Pipeline +> **Status: SUPERSEDED IN FULL (2026-08-22).** This file preserves the original +> five-layer proposal as historical inventory only. It has no current normative +> force. The active product contract has exactly four machine layers: +> **L0 Apple → L1 Whisper → L2 Lexicon + Light+ → L3 existing Responses +> formatter → human**. “Inline” is scheduling of that existing formatter. +> Silero is orthogonal VAD/time evidence, richer annotations are +> optional/provider-bound, Final BAM has no producer, and `SessionFinalised` is +> lifecycle-only. Use [`THE_ENGINE_CONTRACT.md`](../THE_ENGINE_CONTRACT.md), +> [`TRANSCRIPT_LANES.md`](../TRANSCRIPT_LANES.md), and +> [`OVERLAY_STREAMING.md`](../OVERLAY_STREAMING.md) for current law. + > **Historical model reference:** the original ADR named a Q8 Whisper artifact. > Current runtime policy is FP16-only; resolver, filesystem, direct-engine and > embedded paths reject quantized Whisper payloads before tensor load. -> **Status:** PROPOSED → ACCEPTED (operator-authored vision, 2026-05-26) +> **Historical status:** PROPOSED → ACCEPTED (operator-authored vision, 2026-05-26), then SUPERSEDED 2026-08-22 > **Replaces:** Whisper-as-primary live STT model (see `WHISPER_LIVE.md`, `OVERLAY_STREAMING.md`) -> **Owns invariant:** **NEVER REWRITE FROM ZERO.** All layers act incrementally on what was already shown to the user. +> **Owns invariant:** **NEVER REWRITE FROM ZERO.** A later layer may repair text only inside the same PCM-identified span and before its final event. > **Trigger:** operator's bench session 2026-05-26 — Apple Dictation latency/UX baseline vs Whisper recall depth. ## Context @@ -47,11 +58,12 @@ describing for weeks: What is missing is **orchestration glue** and one new contract event. -## Decision +## Historical decision (superseded) Adopt a **five-layer incremental transcription pipeline**, with Apple as the live primary engine and -Whisper + lexicon + LLM as background supplements that never overwrite what the user already saw — -they extend, patch in place, and annotate. +Whisper + lexicon + LLM as background supplements. They may extend or patch in +place only when span identity and the single rewrite fence prove authority; +they never rebuild the transcript from zero. ```mermaid flowchart TB @@ -273,12 +285,14 @@ they simply show Layer 0 output. ## Migration plan -Four phases. Each ships as an independent machete cut behind a feature flag -(`CODESCRIBE_LAYERED_TRANSCRIPTION=phase{1,2,3,4}`), defaulting to OFF until phase 4 lands. +Four phases were proposed as independent machete cuts behind compatibility +tokens (`CODESCRIBE_LAYERED_TRANSCRIPTION=phase{1,2,3,4}`). Runtime promotion +on 2026-08-21 made Phase 1 mandatory for Local Power + Apple/Auto; phases 2–4 +remain reserved rather than controlling the Phase-1 product default. > **Orthogonality (operator 2026-08-05):** `FINAL_PASS_MODE` / Smart is **stop-path -> full re-pass routing only**. It does not enable this flag. Live gap-fill is Layer 1 -> behind `CODESCRIBE_LAYERED_TRANSCRIPTION`. Smart + layered can compose; neither +> full re-pass routing only**. It does not enable or disable live Layer 1. +> Local Power owns live arming; the compatibility key may explicitly degrade it. Neither > silently rewrites the other. Off final-pass never forces Whisper at stop. **Phase 1 — Layer 0 + Layer 1 (Apple primary + Whisper tail patch).** @@ -286,16 +300,18 @@ Four phases. Each ships as an independent machete cut behind a feature flag - Wire Apple as default engine when available; Whisper-as-primary remains the fallback. - New `core/stt/tail_patcher/` module + `EngineEvent::ReplaceRange { source: TailPatch }`. - Overlay gains `ReplaceRange` render path (visible "cursor walks back, patch lands"). -- **Wiring status (2026-08-08, W2-A `a6b1233d`):** Layer 1 is live on **both** live paths - when phase ≥ 1 — the **VAD/scheduler** session (`vad_transcription_session`) and the - default Apple **progressive** live session (`apple_stream_transcription_session`). - On the Apple path every sealed `UtteranceFinal` resolves to its retained PCM window +- **Wiring status (revised 2026-08-21):** Layer 1 may mutate only on the default Apple + **progressive** live session (`apple_stream_transcription_session`). The VAD/scheduler + route has no pending-span owner and now fails closed with + `tail_patch_route_unbound` instead of emitting a post-final mutation. + On the Apple path every pending final resolves to its retained PCM window (W1-B bounded retention) and is handed to an async Layer 1 lane together with the exact committed string the `ReplaceRange` offsets are computed against. Bounded queue with counted drops (capture is never blocked), at most one job in flight, inference on - `spawn_blocking`, and every queued engine event flushed before the patch is emitted so a - `ReplaceRange` can never overtake the `UtteranceFinal` it patches. A boundary that cannot - address retained audio stays counted as unresolved and is never handed to Whisper. + `spawn_blocking`, exact request/payload identity, structural replay keys, and one + `ProgressiveSealMachine::try_rewrite` fence. The corrected text is carried by + `UtteranceFinal`; no TailPatch mutation is emitted after it. A boundary that cannot address + retained audio stays counted as unresolved and is never handed to Whisper. The `CODESCRIBE_APPLE_STT_LIVE_MODE=wav` escape hatch remains, but it is no longer needed to reach Layer 1. - Acceptance test: operator's bench audio reproduces — Layer 0 shows Polish live; Layer 1 fills @@ -326,25 +342,26 @@ Four phases. Each ships as an independent machete cut behind a feature flag What this ADR proposed vs. what the runtime actually executes today. -**Superseded on the Layer 1 default (2026-08-09 / polarized 2026-08-14).** -`CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1` (`LAYERED_DEFAULT_PHASE = 1`). -Explicit `off`/`0`/`false` is the only disarm. The 2026-08-08 sentence -"`phase1` is still off by default" is historical. Everything **above** -phase1 (Layers 2–4 in this table, plus W13 fusion / idempotence / -highlights) is still off by default. +**Default revised after field falsification (2026-08-21).** +`CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `off`. Explicit `phase1` remains an +experimental operator path. The Apple progressive implementation now carries +request/span identity through one rewrite fence, rejects structural replays, +preserves intentional repetition in disjoint PCM ranges, and reports abandoned +stop work. Promotion to stock remains gated on field/corpus validation, not on +the removed post-final patch architecture. The 2026-08-08 table below is inventory, not the default. Read the "Delivered?" column with that amendment. -| Phase | Proposed module | Delivered? | Where it actually lives | -| ------------------------------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **1 — Layer 1 tail patch** | `core/stt/tail_patcher/` | ✅ **delivered, default phase1** | `core/stt/tail_patcher/` exists as proposed; wired into `core/pipeline/streaming/session.rs` (VAD/scheduler) and `core/pipeline/streaming/apple_live_session.rs` (Apple progressive, W2-A `a6b1233d`). Unset env → phase1 since 2026-08-09. | -| **1 — overlay `ReplaceRange` render** | `app/ui/overlay/mod.rs` | ✅ delivered, moved | `OverlayState.applyReplaceRange` → `OverlayTranscriptSegment.replaceRange` (Swift) | -| **1 — orchestrator** | `app/controller/layered_orchestrator.rs` | ❌ **not built — and not needed so far** | Both live paths call the shared `tail_patch_enabled` / `compute_tail_patch_job` / `emit_tail_patch_result` primitives directly. One gate, one `LayerSummary` shape, no separate state machine. Revisit only when Layers 2–4 need a single audio cursor (see Consequences) | -| **2 — Lexicon** | `core/lexicon/` | ⚠️ **partial, different shape** | No `core/lexicon/` module. Lexicon substitution lives in `core/pipeline/stream_postprocess.rs::apply_lexicon` and runs **at seal time** on the Apple progressive path (W1-A `d180add9`) — as the doctrine's final automated layer, not as a debounced Layer 2 sub-pass | -| **2 — Inline LLM polish** | `core/llm/inline_polish.rs` | ❌ not built | No inline per-utterance LLM pass exists. Stop-path AI formatting (`core/llm/ai_formatting.rs`) is a different surface with a different contract | -| **3 — Paralingual monitor** | `core/vad/paralingual_classifier.rs` | ❌ not built | `EngineEvent::InsertAnnotation` travels end-to-end (contracts → IPC wire → `OverlayState.applyInsertAnnotation`), but the only site that _constructs_ one is a unit test in `app/presentation/emitter.rs`. The transport is ready; nothing produces paralingual annotations | -| **4 — Final BAM** | `core/pipeline/final_bam.rs` | ❌ not built | `SessionFinalised` is emitted (carrying `LayerSummary`) by the live paths, not by a session-end contextual pass. Stop-path re-pass routing is `FINAL_PASS_MODE`, which is a _different_ mechanism — it re-runs Whisper on the full WAV, it does not do bounded cross-utterance polish | +| Phase | Proposed module | Delivered? | Where it actually lives | +| --------------------------------------- | ------------------------------------------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **1 — Layer 1 tail patch** | `core/stt/tail_patcher/` | ⚠️ delivered, default **off** | Apple progressive only: exact PCM identity → byte-identical baseline → one pre-final rewrite fence. In-process, sidecar, and remote tail providers share the seam. VAD/scheduler and unbound full-session candidates fail closed. Explicit `phase1` awaits field promotion. | +| **1 — overlay `ReplaceRange` render** | `app/ui/overlay/mod.rs` | ✅ delivered, moved | `OverlayState.applyReplaceRange` → `OverlayTranscriptSegment.replaceRange` (Swift) | +| **1 — orchestrator** | `app/controller/layered_orchestrator.rs` | ❌ **not built — and not needed so far** | Both live paths call the shared `tail_patch_enabled` / `compute_tail_patch_job` / `emit_tail_patch_result` primitives directly. One gate, one `LayerSummary` shape, no separate state machine. Revisit only when Layers 2–4 need a single audio cursor (see Consequences) | +| **2 — Lexicon** | `core/lexicon/` | ⚠️ **partial, different shape** | No `core/lexicon/` module. Lexicon substitution lives in `core/pipeline/stream_postprocess.rs::apply_lexicon` and runs **at seal time** on the Apple progressive path (W1-A `d180add9`) — as the doctrine's final automated layer, not as a debounced Layer 2 sub-pass | +| **2 — Inline LLM polish** | `core/llm/inline_polish.rs` | ❌ not built | No inline per-utterance LLM pass exists. Stop-path AI formatting (`core/llm/ai_formatting.rs`) is a different surface with a different contract | +| **Orthogonal Silero sideband (not L3)** | `core/audio/chunker.rs` + `streaming/silero_fusion.rs` | ✅ timing evidence only | `EngineEvent::SidebandEvidence` carries exact PCM speech edges and pause=`unknown_non_speech`; reducer is a no-op and the existing Responses L3 may use pause duration only for punctuation/paragraphing. Named paralingual annotations remain unavailable without a measured provider. | +| **4 — Final BAM** | `core/pipeline/final_bam.rs` | ❌ not built | `SessionFinalised` is emitted (carrying `LayerSummary`) by the live paths, not by a session-end contextual pass. Stop-path re-pass routing is `FINAL_PASS_MODE`, which is a _different_ mechanism — it re-runs Whisper on the full WAV, it does not do bounded cross-utterance polish | **Reading rule for the phase specs above:** they describe intent, not inventory. A module path in Phases 2–4 is a proposal until this table marks it delivered. diff --git a/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md b/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md index b20c724f..d6333e18 100644 --- a/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md +++ b/docs/ADR/2026-05-28-Correction-Continuous-Hands-Off.md @@ -1,6 +1,12 @@ # ADR 2026-05-28 — Correction: Ciągły Hands-Off Dictation + Warstwowe Korekty (Sekwencyjnie) -> **Status:** PROPOSED +> **Status: SUPERSEDED IN FULL (2026-08-22).** Ten dokument zachowuje +> historyczną kolejność i pięciowarstwowy inwentarz, ale nie jest aktywnym +> kontraktem implementacyjnym. Obowiązują dokładnie cztery warstwy maszynowe: +> L0 Apple, L1 Whisper, L2 Lexicon + Light+, L3 istniejący formatter Responses. +> Silero jest ortogonalnym źródłem VAD/czasu, Final BAM nie ma producenta, a +> `SessionFinalised` wyłącznie zamyka cykl życia. Aktualna władza: +> [`THE_ENGINE_CONTRACT.md`](../THE_ENGINE_CONTRACT.md). > **Koryguje:** ADR 2026-05-26 — Warstwowy, inkrementalny pipeline transkrypcji > **Priorytet:** Najpierw przywrócić ciągłość transkryptu w hands-off, dopiero potem nakładać zaawansowane warstwy. @@ -125,7 +131,7 @@ Po zrobieniu tego porządku (najpierw ciągłość, potem korekty) dostajemy: **Data:** 2026-05-28 **Autor:** Operator -**Status:** PROPOSED — wymaga implementacji w ścisłej kolejności z gate'em po Fazie 1. +**Status historyczny:** PROPOSED — superseded 2026-08-22; nie uruchamia prac implementacyjnych. ## Revision 2026-06-11: Format is in-overlay diff --git a/docs/APP_RUNTIME.md b/docs/APP_RUNTIME.md new file mode 100644 index 00000000..4defe99e --- /dev/null +++ b/docs/APP_RUNTIME.md @@ -0,0 +1,29 @@ +# Codescribe application runtime + +Codescribe.app owns one process-wide, multi-thread Tokio runtime. The shipped +policy starts four workers named `codescribe-app-worker-1` through +`codescribe-app-worker-4`. `CODESCRIBE_APP_RUNTIME_WORKERS` may change that +count to `1..16`, is registered in `ENV_REGISTRY.toml`, and takes effect only +after restart. + +`AppDelegate` starts the runtime after configuration has been loaded and before +constructing async bridge surfaces. Every UniFFI async export immediately moves +its root future to this runtime; the foreign Swift executor only waits for the +join result. The bridge deliberately does not use UniFFI's +`async_runtime = "tokio"` compatibility fallback as application execution +authority. + +On termination the host first stops gesture intake, cancels pending account +login, drains an active controller recording, releases capture ownership, and +then shuts down the runtime with a bounded timeout. Shutdown is terminal for +the process: the same owner cannot be restarted. + +The Whisper engine remains the process-wide `Mutex` singleton. +The runtime adds scheduling capacity; it does not allow concurrent Whisper +decode or create a second transcript reducer. Recording callbacks still hand +off through channels and never wait for inference. + +Runtime evidence is available through `applicationRuntimeSnapshot()`: lifecycle +state, configured worker count, observed worker names, stopped worker names, +and active root bridge tasks. It contains no audio, transcript, model path, or +operator content. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 65308670..8da3006b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,35 +2,35 @@ > Created by Vetcoders (c)2026 > -> **2026-05-26:** transcription pipeline is now layered. See -> [ADR — Layered Incremental Transcription Pipeline](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md) -> for the authoritative model. Sections below describe the packaging and module layout that hosts it. +> **2026-08-22:** transcription follows the canonical +> [four-layer engine contract](./THE_ENGINE_CONTRACT.md). The 2026-05-26 +> [five-layer ADR](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md) +> is a superseded historical proposal. Sections below describe the packaging +> and module layout that hosts the current contract. ## Layered Incremental Transcription (since 2026-05-26) -Live transcription is no longer a single Whisper stream. The ADR specifies five cooperating -layers, with Apple Speech as the live primary and Whisper / lexicon / small LLM / Silero -paralingual classifier filling in behind it. The overlay renders the union of layer events and -never wipes and retypes — _NEVER REWRITE FROM ZERO_ is the operator-mandated invariant. Since +Live transcription is no longer a single Whisper stream. Exactly four machine +layers cooperate: Apple, Whisper, Lexicon + Light+, and the existing Responses +formatter. The overlay renders their accepted span events and never wipes and +retypes — _NEVER REWRITE FROM ZERO_ is the operator-mandated invariant. Since the UI moved to Swift, the enforcement point is `macos/Codescribe/Screens/Overlay/OverlayState.swift`: `applyReplaceRange` delegates to `OverlayTranscriptSegment.replaceRange`, which returns `false` (patch dropped) for any range that does not address the committed segment. -**Two of the five layers execute today, and only behind an opt-in flag.** The table below is -inventory, not intent — the ADR's -[Phase delivery status](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md#phase-delivery-status-2026-08-08) -carries the per-phase detail. - -| Layer | Engine | Status | Where it lives | -| --------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Live | Apple `SFSpeechRecognizer` (primary) · Whisper fallback | ✅ shipped, default | `core/stt/apple_stt/` + `core/stt/whisper/` | -| 1 — Tail Patch | Whisper background diff | ✅ delivered, **opt-in** (`CODESCRIBE_LAYERED_TRANSCRIPTION=phase1+`, default off) | `core/stt/tail_patcher/`, wired into `core/pipeline/streaming/session.rs` **and** `core/pipeline/streaming/apple_live_session.rs` | -| 2 — Lexicon | Dictionary substitution | ⚠️ partial, different shape | `core/pipeline/stream_postprocess.rs::apply_lexicon`, applied at seal time on the Apple path — not the ADR's debounced `core/lexicon/` module | -| 2 — LLM polish | Small inline LLM | ❌ not built | no `core/llm/inline_polish.rs`; stop-path `core/llm/ai_formatting.rs` is a different surface | -| 3 — Paralingual | Silero classifier head | ❌ not built | `InsertAnnotation` transport exists end-to-end; no producer | -| 4 — Final BAM | Session-end contextual pass | ❌ not built | no `core/pipeline/final_bam.rs`; `FINAL_PASS_MODE` is a different mechanism | -| Orchestrator | — | ❌ not built, not currently needed | both live paths share the `tail_patcher` gate directly; no `app/controller/layered_orchestrator.rs` | +| Layer | Engine | Status | Where it lives | +| ---------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **L0 — Apple** | `SFSpeechRecognizer` live observer | ✅ shipped first paint | `core/stt/apple_stt/` + Apple progressive session | +| **L1 — Whisper** | Contextual observation over proven PCM spans | ✅ exact-span Apple progressive; VAD/Whisper-first remains its own primary lane | `core/stt/tail_patcher/`, `core/stt/tail_provider.rs`, `core/stt/whisper/` | +| **L2 — Lexicon + Light+** | Deterministic vocabulary and sentence shaping | ✅ currently wired | `core/pipeline/stream_postprocess.rs::apply_lexicon`, `core/pipeline/light_plus.rs`, progressive seals and delivery floor | +| **L3 — Responses formatter** | Existing configured Formatting lane | ✅ implemented behind `CODESCRIBE_INLINE_FORMAT` | `core/llm/inline_format.rs` schedules stable spans through `core/llm/ai_formatting.rs`; inline is scheduling, not a separate small model | + +Silero sits beside these layers as VAD and PCM-time evidence. Speech boundaries, +silence duration, pause timing, and pre-roll are its truthful outputs. Named +laughter/noise classes require an optional measured provider; plain Silero does +not claim them. Final BAM is superseded and has no producer, while +`SessionFinalised` closes lifecycle only. Existing files (`core/stt/whisper/`, `core/audio/streaming_recorder.rs`, `core/vad/silero_ort.rs`) keep their public APIs — Layer 1 reuses them as its backend. @@ -49,11 +49,11 @@ by `FINAL_PASS_MODE` (`always|smart|off`, Smart default; Settings → Dictation "Final pass"). **Smart only** skips the full stop re-pass on a typed, adjudicator-backed completeness decision (`StreamingCompleteness`) — never on punctuation and never rewritten by live engine (Off stays Off; Off never forces -Whisper at stop). Live gap-fill (Layer 1 Whisper tail-patch) is a **separate** -opt-in via `CODESCRIBE_LAYERED_TRANSCRIPTION` (default off; phase ≥ 1 arms it on -**both** live paths — VAD/scheduler and the default Apple progressive live, wired -2026-08-08 in `a6b1233d`). Smart works _with_ layered when both are enabled; -Smart does not enable layered. Dictionary/lexicon always runs in postprocess. +Whisper at stop). Live repair is orthogonal: Local Power + Apple/Auto arms the +exact-span Apple progressive patcher by default. `phase1` remains compatible +explicit arming; explicit off/invalid is degraded. The VAD/scheduler route uses +Whisper directly and refuses a second unbound lane. Dictionary/lexicon always +runs in postprocess. Two INFO receipts prove the path in `codescribe.log`: diff --git a/docs/DELIVERY_ROUTE.md b/docs/DELIVERY_ROUTE.md index 3d688fee..c1cb2f9f 100644 --- a/docs/DELIVERY_ROUTE.md +++ b/docs/DELIVERY_ROUTE.md @@ -16,11 +16,12 @@ Notes, and every other caret **are** legal ambulances. Assistive still delivers as a first-class Agent message — that is a different intent, not a ban on pasting into the Agent window. -4. **Clipboard is borrowed, never stolen.** We may overwrite `NSPasteboard` - for a real Cmd+V. We must restore what the user had. If auto-paste cannot - land, we lose neither: restore the system clipboard, park the transcript - in our buffer (⌘⌥V). Explicit overlay **Copy** is the only verb that - writes the pasteboard on purpose and leaves it. +4. **Clipboard is borrowed, never stolen.** On release we snapshot the user's + pasteboard, Cmd+V into the latched caret, then restore. The overlay must + resign key first — if `NSWorkspace` still names Codescribe after activate, + that is not a veto. If Cmd+V cannot land, park ⌘⌥V and leave the user's + pasteboard alone. Explicit overlay **Copy** is the only verb that writes + the pasteboard on purpose and leaves it. ## Intent → route diff --git a/docs/ENV_REGISTRY.toml b/docs/ENV_REGISTRY.toml index 7b600786..9b04f676 100644 --- a/docs/ENV_REGISTRY.toml +++ b/docs/ENV_REGISTRY.toml @@ -16,7 +16,19 @@ [meta] version = "1.2.0" -updated = "2026-08-15" +updated = "2026-08-22" + +# ═══════════════════════════════════════════════════════════════════════════════ +# Application async runtime +# ═══════════════════════════════════════════════════════════════════════════════ + +[vars.CODESCRIBE_APP_RUNTIME_WORKERS] +default = "4" +type = "u64" +reload = "restart" +category = "runtime" +description = "Named Tokio async workers owned by Codescribe.app (clamped to 1-16; invalid values fall back to 4)" +range = "1-16" # ═══════════════════════════════════════════════════════════════════════════════ # VAD (Voice Activity Detection) - Silero Neural Network @@ -458,11 +470,11 @@ category = "stt" description = "Bounded-context A/B for W13-3B Whisper windows: utterance (default), left_pad, or stable_prompt. Left pad never crosses a Silero-sized silence fence." [vars.CODESCRIBE_SPAN_IDEMPOTENCE] -default = "0" +default = "1" type = "bool" reload = "restart" category = "stt" -description = "W13-4 sealed-span replay refusal + in-span loop fence. Default OFF. When 1/true/on: live seal path refuses a replayed PCM-range / request identity (SessionIngest RejectedSealedUtterance ported onto ledger identity) and fences non-progressing timestamps or decode failure. Content-similar offers on a new identity are WARN-preserved. No default flip." +description = "Sealed-span replay refusal + in-span loop fence. Default ON: the live seal path refuses a replayed PCM-range / request identity and fences non-progressing timestamps or decode failure. Content-similar offers on a new identity are WARN-preserved. Set 0/false/off only as a diagnostic escape hatch." [vars.CODESCRIBE_WHISPER_IDLE_UNLOAD_SECS] default = "1800" @@ -550,11 +562,11 @@ category = "stt" description = "Libraxis gateway session-mint endpoint (https; loopback http for dev). Endpoint only — URLs with user-info or query material are refused; vendor keys never live in config. Promoted: settings.json is the source of truth" [vars.CODESCRIBE_LAYERED_TRANSCRIPTION] -default = "phase1" +default = "" type = "string" reload = "hot" category = "stt" -description = "Layered incremental transcription phase gate: phase1 (DEFAULT since 2026-08-09 — operator directive: the live tail patch is a core element of the triangulation, not an opt-in; explicit off/0/false disables) or phase1..phase4 (bare 1..4 accepted). Orthogonal to FINAL_PASS_MODE/Smart — Smart never enables this. Phase ≥ 1 turns on Layer 1 Whisper tail-patch during hold on BOTH live paths: the VAD/scheduler session and the default Apple progressive live session (wired W2-A, a6b1233d). phase1 = Apple Layer 0 + Whisper ReplaceRange patches. Promoted 2026-08-10 to settings.json (single-brain; a stale ~/.codescribe/.env line must not lottery the live path)." +description = "Promoted compatibility override for live local Whisper refinement. Product mode owns the default: Local Power with Apple/Auto arms the exact-span Apple progressive patcher when unset or phase1; explicit off/0/false/no and malformed values produce a named degraded disposition. Apple-only does not arm it. Direct Whisper on the VAD/scheduler path is already the primary engine and refuses a second unbound mutation lane. Phases 2..4 remain reserved. Orthogonal to FINAL_PASS_MODE; normal stop performs no whole-file pass. Runtime truth is the per-session tail_patch_session_receipt, not UI state." [vars.CODESCRIBE_SEAL_ATLAS_DUMP] default = "" @@ -1112,6 +1124,13 @@ reload = "hot" category = "llm" description = "Max idle time between SSE chunks before attempt is failed (ms)" +[vars.CODESCRIBE_AGENT_BRIDGE_HOME] +default = "" +type = "string" +reload = "hot" +category = "storage" +description = "Optional absolute override for the agent bridge home used by active-name leases and bus demultiplexing; unset resolves the normal user data path" + [vars.CODESCRIBE_AGENT_USE_PREVIOUS_RESPONSE_ID] default = "1" type = "bool" @@ -1395,6 +1414,13 @@ description = "Override .env file path" # Quality / Daemon # ═══════════════════════════════════════════════════════════════════════════════ +[vars.CODESCRIBE_LEXICON_MIN_CORRECTIONS] +default = "3" +type = "u64" +reload = "hot" +category = "quality" +description = "Identical human teach gestures required before a correction pair is written to lexicon.custom.jsonl. Product default 3. 1 restores legacy first-teach upsert (tests may use this for storage-primitive fixtures only)." + [vars.QUBE_DAEMON_AUTOSTART] default = "0" type = "bool" diff --git a/docs/HOTKEYS_CONTRACT.md b/docs/HOTKEYS_CONTRACT.md index 3831ef7a..240a54b0 100644 --- a/docs/HOTKEYS_CONTRACT.md +++ b/docs/HOTKEYS_CONTRACT.md @@ -446,7 +446,7 @@ const DOUBLE_TAP_INTERVAL_MS: u64 = 200; When `HOLD_EXCLUSIVE=false` (default), modifier variants work out of the box: -- Shift or Command *during* an already-started Fn hold attaches `{selection_N}` +- Shift or Command _during_ an already-started Fn hold attaches `{selection_N}` (default arm modifier Shift; configurable to Cmd in Settings) - Fn+Shift from idle stays dictation — it is not Assistive and does not front Agent - The unconfigured arm modifier does not attach (W10-B detector truth) diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 9a4fd2f4..b8f0a64f 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -158,9 +158,44 @@ Codescribe.app/ ├── MacOS/ │ └── Codescribe # App executable └── Resources/ - └── AppIcon.icns # Application icon + ├── AppIcon.icns # Application icon + └── agent-bridge/ # Signed, checksumed external-agent payload + ├── manifest.json + ├── bin/bus-demux.py + └── skills/codescribe/ # Complete skill + references + examples ``` +## External Agent Bridge + +The existing 13-step Setup Wizard exposes the bridge inside **Agentic +Readiness**. It does not write to the home directory merely because the step is +shown. The operator must explicitly select Codex, Claude Code, or both and click +Install/Reinstall. + +The installed runtime is stable across checkout moves and deletions: + +```text +~/.codescribe/agent-bridge/ +├── receipt.json +├── runtime/ +│ ├── manifest.json +│ ├── bin/bus-demux.py +│ └── skills/codescribe/ +└── leases/ +``` + +Selected client skills live at `~/.codex/skills/codescribe/` and/or +`~/.claude/skills/codescribe/`. `receipt.json` records the bundle version, +selected clients, installed paths, payload hashes, and one ownership id. Each +managed client folder carries a matching `.codescribe-managed.json`. Updates +use staged directory renames and an atomic receipt write. Existing unowned +folders are visible conflicts and are never overwritten; deselection removes +only a folder whose marker still matches the receipt. + +Polish dictation selection shows the bridge explanation in Polish. All other +language selections use English fallback. Setup can be skipped and reopened +later from the existing **Setup Wizard…** tray action. + ### Info.plist Keys | Key | Value | Purpose | diff --git a/docs/KORA_CODESCRIBE_JOURNAL.md b/docs/KORA_CODESCRIBE_JOURNAL.md new file mode 100644 index 00000000..7b6303d9 --- /dev/null +++ b/docs/KORA_CODESCRIBE_JOURNAL.md @@ -0,0 +1,166 @@ +# Kora Codescribe source journal + +Status: append-only extraction journal. + +Primary source: `/Users/maciejgad/Downloads/Kora_codescribe.md` (2,789 lines, read sequentially in 400-line blocks on 2026-08-21). + +Purpose: preserve decisions, corrections, runtime evidence, superseded claims, and open risks before distilling them into `KORA_CODESCRIBE_CONTRACT.md`. This journal is evidence, not executable product authority. Later statements and explicit operator corrections supersede earlier interpretations. + +Extraction labels: + +- `OPERATOR` — explicit product intent or correction from Maciej. +- `FACT` — observation grounded in code, runtime, logs, or artifacts in the source conversation. +- `DECISION` — an adopted implementation or configuration choice. +- `SUPERSEDED` — a statement later corrected or invalidated. +- `RISK` — unresolved failure mode or uncertainty. +- `FOLLOW-UP` — work promised or required after the source block. + +## Source lines 1–400 + +- `FACT` The initial heat investigation was explicitly read-only. A 15.9-second sample did not include a running Codescribe process, so it could not attribute heat to Codescribe. +- `FACT` The dominant sampled process was `voc --view observe`, averaging about 41.8% of one CPU core; its stack repeatedly rebuilt control-plane state and spawned PID-liveness checks. +- `RISK` Physical heat was not measured directly; CPU samples do not establish chassis temperature, fan speed, GPU use, or causality. +- `SUPERSEDED` Kora first inferred that the user had returned to an ordinary application text field. A direct terminal command proved the session was a forked terminal Codex session. The corrected interpretation supersedes the UI inference. +- `OPERATOR` The CodeScribe priority was runtime truth around two Whisper cache entries: retired Q8 versus intended large-v3-turbo FP16/F32 runtime, with Q8 dequantization suspected as a large cold-start cost. +- `FACT` Cache presence is not runtime selection. The resolver, bundle completeness, tensor format, conversion path, and engine lifetime determine cost. +- `FACT` The intended FP16 snapshot contained `config.json` and `weights.safetensors` but lacked `tokenizer.json` and `mel_filters.npz`; the old Q8 repository still supplied companion assets and retained a complete orphaned Q8 snapshot. +- `FACT` The then-current resolver scanned snapshots and could select the complete Q8 snapshot when the FP16 snapshot was incomplete. +- `FACT` A runtime log proved this was not theoretical: a Q8 load took 9.53 seconds, of which 9.28 seconds was `dequantize_q8`. +- `FACT` The installed application was older than the repository commit intended to forbid local weights outside Local Power. +- `FACT` At that time, normal Apple/cloud Hold Fn runs did not load Whisper. The Q8 risk applied to local Layer 1 or explicit HQ/Retranscribe paths. +- `DECISION` The correct model bundle shape was identified as FP16 config/weights plus trustworthy tokenizer and mel assets in one complete composed directory. +- `DECISION` Resolver and runtime gates must never select Q8 weights once the product decision retires Q8. +- `FOLLOW-UP` Add a fixture for incomplete FP16 + companion assets + orphaned complete Q8, expecting FP16 composition/selection and zero Q8 fallback. +- `FOLLOW-UP` Runtime evidence must show the intended model identity and `dequantize_q8 = 0`. +- `RISK` The exact claimed 7.5x whole cold-start advantage was not found in the inspected repo evidence; the direction was supported, while exact ratios depended on which phase was compared. +- `FOLLOW-UP` For `voc`, measure refresh phases and helper spawn counts, eliminate subprocess-per-PID checks, cache terminal runs and unchanged derived views, add backoff/event-driven refresh, and target idle observe below 2% of one core. + +## Source lines 401–800 + +- `FACT` Three unrelated mechanisms had overlapping names: stop-path `FINAL_PASS_MODE=smart`, LLM Smart formatting, and planned Layer 4 Final BAM. They must never be treated as one feature. +- `FACT` Hated Full Final Pass (`always`) redecoded the whole saved WAV after release and could replace the whole transcript. Its product failures were latency, cold-load cost, authority without span provenance, and the ability to destroy a good live result. +- `FACT` Smart stop routing classified live output as complete, shape-deficient, or incomplete; it could skip, transplant punctuation/capitalization, or fill only a missing tail. It was not Layered live refinement. +- `DECISION` `off` means no Whisper inference on the normal stop path. It does not disable Apple live, lexicon, Layered live refinement, formatting, or explicit Retranscribe. +- `FACT` The source observed a macOS UI that forced final pass to `off` and retained old routing tokens for compatibility/tests. +- `CURRENT-CODE` On HEAD `361ece40`, `FinalPassRoutingMode` still contains `Always`, `Smart`, and `Off`; `Always` alone permits whole-file repass, `Smart` is documented as per-utterance tail gap-fill, and `Off` forbids Whisper on stop paths. `SettingsViewModel.finalPassModeId` still returns literal `off`. +- `DECISION` Normal capture must finish from the evolving live transcript. Whole-file inference belongs only to an explicit Retranscribe/HQ action. +- `OPERATOR` Layered is the core product advantage: several imperfect observers update one time-grounded canvas without granting any observer authority to rewrite the session from scratch. +- `DECISION` Intended layers: Apple fast hypotheses; Whisper/cloud recall and correction; lexicon/small LLM domain shaping; paralingual events; bounded Final BAM. +- `FACT` The source distinguished two different Layer 1 implementations: legacy local Whisper tail patch and a provider/cloud PCM fan-out lane. Naming them both Layer 1 hid incompatible semantics. +- `FACT` Legacy local tail patch was gated by `CODESCRIBE_LAYERED_TRANSCRIPTION=phase1` and Local Power. It transcribed an utterance PCM window, compared Apple and Whisper, and attempted bounded replacement or gap insertion. +- `FACT` The provider/cloud lane had bounded queues, no capture backpressure, volatile partials, ordering/idempotence tracking, typed degradation, and bounded stop drain, but its merge policy conservatively preserved Apple on ordinary disagreement. +- `SUPERSEDED` The source temporarily treated Apple-only as a complete product baseline and Layered `off` as the safe default. This was explicitly contingent on defects listed later, not the target product contract. +- `RISK` The old patcher sometimes compared transcripts produced from different PCM ranges because of VAD shifts, inaccurate clocks, mid-phrase cuts, or committed-text/audio mismatch. +- `RISK` Apple word timestamps could be lost before the reducer; seals lacked complete `[start,end)` word payloads; some mutation paths bypassed the intended rewrite fence. +- `RISK` Text-similarity safety gates rejected precisely the high-value cases where Whisper recovered more speech. Historical evidence included 116 skipped/0 applied and 295 change-ratio rejections, many with longer Whisper candidates. +- `DECISION` The remedy is stronger audio/span identity, not merely a looser textual change-ratio threshold. +- `RISK` Text-based gap dedup can either duplicate later Apple delivery or delete intentional repetitions. Content equality is not span identity. +- `RISK` Layer 1 work could be abandoned after the Apple seal worker closed. A completed product must bounded-drain admitted work or emit explicit degradation. +- `CURRENT-CODE` HEAD still counts `abandoned_tail_patch_jobs`, emits a warning, reports drain degradation, and abandons queued/in-flight work after the Apple worker closes. This remains an explicit product degradation, not a completed patch guarantee. +- `CURRENT-CODE` HEAD still defaults Layered to absent/off through `layered_phase_from_raw(None) -> None`; the local patch lane is therefore opt-in despite the operator's target architecture. + +## Source lines 801–1200 + +- `DECISION` Turning off hated whole-file Final Pass is a consequence of Layered, not a retreat from it. Final BAM must apply bounded ledger-aware corrections, never rebuild the transcript without provenance. +- `DECISION` The temporary `off` may be removed only when word/span identity, exact PCM range, one rewrite fence, structural idempotence, intentional repetition preservation, bounded stop drain, provider-semantic parity, and operator-visible receipts are real. +- `OPERATOR` Audio and its timeline are truth. Apple produces fast imperfect hypotheses pinned to time. Whisper observes overlapping windows of about four seconds with about one second of overlap. The canvas is a live projection, not immutable Apple text. +- `OPERATOR` Apple output such as `stwierdzić` is a temporal/phonetic pin, not a protected token. Whisper may replace it with `stwierdził` when the observation belongs to the same span identity. +- `DECISION` The safety law is: no canvas mutation without proof of the audio span to which the new observation belongs. It is not: never change an Apple token. +- `ANTI-MUTATION` Apple temporal pins must not become an immutable floor. +- `ANTI-MUTATION` Preserve speech content must not become preserve literal overlay characters. +- `ANTI-MUTATION` Correct the proper span must not become accept only a small textual diff. +- `ANTI-MUTATION` Audio identity must not be replaced by token similarity. +- `ANTI-MUTATION` Preserve intentional repetitions as separate spans must not become global no-delete or text deduplication. +- `ANTI-MUTATION` Overlapping Whisper windows must not become isolated whole-utterance string comparison. +- `ANTI-MUTATION` A living canvas must not become immutable after seal. +- `ANTI-MUTATION` Layer 0 as first observer must not become semantic authority. +- `OPERATOR` Agent-authored heuristics must never be relabeled as `operator law` or `operator decision`. Tests and comments need evidence provenance and an explicit supersession trail. +- `DECISION` True invariants are timeline continuity, audio-span identity, observation provenance, no uncovered audio gaps, no cross-span text mixing, replay identity distinct from intentional repetition, and reconstructable mutation receipts. +- `FACT` The source's final preflight identified a settings dual-brain: canonical Application Support settings lacked STT choices while `.env` and an older config carried them. +- `OPERATOR` Q8 was explicitly forbidden from runtime, including explicit custom paths. This was a product decision, not merely a resolver preference. +- `DECISION` FP16 must be a complete verified bundle. Tokenizer must come from official `openai/whisper-large-v3-turbo`; mel filters from a pinned `openai/whisper` revision; no runtime assets may depend on the retired Q8 repository. +- `CURRENT-CODE` HEAD contains `request_identity` and `span_map` throughout the Apple progressive tail-patch path, and no `dequantize_q8` or `LEGACY_WHISPER_REPO` implementation remains. +- `CURRENT-CODE` HEAD does not contain the literal phrase `immutable floor`, but textual conservatism must be audited semantically; deleting the phrase alone does not restore the product model. +- `CURRENT-CODE` The implementation and docs still admit incomplete closure: `core/pipeline/streaming/session.rs` reports that the VAD/scheduler path has no exact pending-span rewrite fence and preserves primary text; comments/docs describe exact PCM identity and one rewrite fence only for Apple progressive. +- `CURRENT-CODE` `docs/ENV_REGISTRY.toml` still says to keep Layered off until PCM/span identity, one rewrite fence, structural idempotence, and bounded-drain evidence cover every accepted patch. That condition is not satisfied across every live path on current HEAD. +- `CORRECTION` Therefore the later exact-identity cut materially advanced the Apple progressive path but did not, by itself, justify claiming the entire Layered product contract complete or universally safe-by-default. + +## Source lines 1201–1600 + +- `DECISION` Kora persisted the then-current Silver profile as Apple live + cloud ASR, Layered off, Final off, Polish, and explicit `whisper-large-v3-turbo`. This was a host-specific safe profile before the later Layered repair, not the timeless product target. +- `FACT` The composed FP16 bundle was exercised through a real loader and a real transcription, producing 2,432 characters; a real Q8 snapshot was rejected before tensor load/dequantization. +- `DECISION` Q8 refusal must exist at config validation, safetensors header validation, resolver selection, direct engine load, and embedded payload selection. Dead Q8 dequantization code must be removed to avoid future resurrection. +- `FACT` Installation verification distinguished signed files on disk from the actually running process. A relaunch was necessary to prove the new binary, not merely the replaced app bundle. +- `DECISION` A rescue push may preserve committed work even when a PR gate is red; mergeability and publication remain separately gated. +- `FACT` A many-month rescue branch produced 37 conflicts against current `develop`. The safe solution was a fresh branch from current `develop` plus the focused verified commit, not merging all of `develop` into the historical branch. +- `FACT` PR #81 eventually reached green CI, zero unresolved threads, and mergeability after focused fixes, while remaining a draft and unmerged. +- `FACT` Review correctly found residual bundle-integrity problems: platform-specific checksum tooling, corrupt mel retained as apparently complete, unconditional mel re-fetch, missing direct loader fixture, stale diagnostics, and ambiguous artifact errors. +- `RISK` Green discovery tests do not prove direct engine refusal; both discovery and direct loader paths require negative fixtures. +- `RISK` A checksum verifier that only returns an error can leave a corrupt destination. Download/repair must use temporary files and promote only after validation, or explicitly quarantine/remove invalid final files. +- `RISK` File-name presence is never model completeness. Config, tokenizer, pinned mel checksum, weights structure, dtype allowlist, offsets, payload length, and metadata schema belong to one validator. +- `CURRENT-CODE` HEAD now has a single `validate_whisper_model_bundle` that parses config and tokenizer, checks the pinned mel SHA-256, resolves a valid weights file, and validates safetensors. +- `CURRENT-CODE` `resolve_runtime_whisper_model_path` checks the complete validator for explicit path, configured model, default local bundle, and HF snapshots; its terminal diagnostic explicitly refuses quantized Q8. +- `CURRENT-CODE` `verify_sha256` still only reports mismatch; safety depends on callers using `.partial`/repair discipline. This must be checked at download call sites rather than inferred from the helper alone. +- `CURRENT-CODE` No runtime `dequantize_q8` implementation remains on HEAD. The earlier source note that dead dequantizer code remained is superseded by later PR work and current code. +- `RISK` A host build must not be installed merely to satisfy a ritual if its base would downgrade unrelated product work. Code/loader smoke and installed-app truth must be reported separately. + +## Source lines 1601–2000 + +- `DECISION` Before merge, model download needed cross-platform `shasum`/`sha256sum`, invalid-destination cleanup, reuse of valid mel, direct loader rejection tests, corrected FP16 terminology, and host evidence for real FP16 load/Q8 refusal. +- `DECISION` A self-attack must name falsifiers. Example: corrupt nonempty mel plus otherwise valid files must not make the next download return success without repair. +- `FACT` A second review broadened the corruption finding: invalid config, tokenizer, mel, or weights could persist behind filename-only completeness and skip-if-nonempty behavior. +- `DECISION` `fp16 only` must be a positive validator, not a blacklist of known Q8 signals. It requires at least one tensor, dtype allowlisting, metadata-schema validation, consistent shapes, byte sizes, offsets, no gaps/overlaps, and exact payload length. +- `DECISION` The official model's `alignment_heads:I64` is a narrow named exception; arbitrary integer tensors remain forbidden. +- `DECISION` All downloaded/copied artifacts use `.partial`, validate before promotion, repair/quarantine invalid destinations, and reuse valid existing files offline. +- `FACT` The focused implementation added one bundle validator shared by discovery/status/download, parsed tokenizer, pinned mel SHA, structural safetensors checks, loader-level U32/I32 refusal, corrupt-cache repair, and offline reuse tests. +- `FACT` Runtime verification included real official FP16 load and real Q8 refusal before tensor load; hermetic tests covered U32, I32, metadata-only, truncation, corrupt mel, retry, and reuse. +- `RISK` Structural validation cannot detect a same-length payload bit flip without a pinned full-weights hash. Supporting custom model paths makes a universal official-blob hash a separate product decision. +- `DECISION` A failure caused by a newer CI Clippy is still a real gate failure when introduced by the diff. Fix the source compatibility and rerun; never call it flaky merely because local Clippy is older. +- `FACT` Separate follow-up findings existed outside PR #81: auto-paste could send Cmd+V without positive target confirmation; bus demux diverged from the canonical bus-path resolver; loose greeting regex could assign accidental agent names; UTF-8 split across polls could produce a negative offset. +- `DECISION` These follow-up findings must not be mixed into model-bundle work merely because they share a branch. Scope ownership remains explicit. +- `CURRENT-CODE` HEAD's model validator matches the positive-validation shape: config/tokenizer/pinned mel plus structural weights validation. Current resolver uses it across explicit, configured, default, and HF candidates. +- `CURRENT-CODE` Current `verify_sha256` is only a validator; correct repair behavior must remain enforced and tested in downloader/copy callers. +- `RISK` Repo-wide `make check` can be red because its scope includes unchanged historical artifacts. Changed-file formatting and full hermetic tests must be reported distinctly; neither erases the repo-wide red gate. +- `DECISION` Loctree build/version drift must be reported and direct code checked when structural snapshot authority is stale; tool drift does not justify skipping structural mapping. + +## Source lines 2001–2400 + +- `FACT` After the repair, PR #81 reported one positive bundle validator, direct loader refusal, `.partial` promotion, invalid-cache repair, valid-mel reuse, cross-platform checksum tooling, corrected diagnostics, and green local/remote gates. +- `DECISION` A pull request is not done merely because CI is green. It must also be mergeable, conflict-free, and have every review thread addressed with code/evidence or a justified rejection. +- `DECISION` Do not merge merely because a PR was flipped from draft to Ready. Bot/reviewer waves are part of the expected lifecycle. +- `FACT` Later bot review found additional plausible defects even after green CI: invalid first weights file shadowing a valid alternate, newest invalid HF snapshot shadowing an older valid one, shell preflight using filename-only completeness, missing safetensors metadata validation, and documentation/runtime default drift. +- `RISK` Suppressed suggestions can contain real product failures and require falsification, not automatic dismissal: zero-element tensors, local paths mistaken for HF IDs, shallow test resolvers, and ambiguous downloader stdout. +- `DECISION` One review thread should normally map to one focused commit unless multiple threads share the same owner/root cause. Replies must cite the fixing SHA after verification. +- `DECISION` Cross-platform differences must be reasoned about before push whenever process, filesystem, signal, locking, or timing behavior changes. +- `DECISION` CI failure must be diagnosed from the failing step/log before retry. Platform-specific failure is evidence, not inconvenience. +- `CURRENT-CODE` Current resolver calls `is_complete_whisper_model_dir` for candidates and current bundle validation is deep. The later HF and dual-weights selection behavior still needs direct inspection before claiming every bot thread remains closed on this branch. +- `RISK` Documentation default drift is particularly dangerous here because `Final Pass off` and `Layered off` have different meanings, while old `smart` tokens can survive in persisted settings. UI, settings serialization, env registry, and runtime logs must agree. +- `DECISION` GitHub presentation is part of product truth: PR bodies must contain real Markdown newlines, not escaped `\\n`, and state changes must be re-read from the API. + +## Source lines 2401–2789 + +- `DECISION` Review replies are short, cite a verified SHA, explain the exact fix, and resolve the thread only after the code and gates support the claim. +- `FACT` Read-only audit confirmed three strong resolver defects: first-existing weights shadowing a valid alternate; newest invalid HF snapshot shadowing older valid snapshots; malformed safetensors `__metadata__` passing discovery but failing the upstream loader. +- `DECISION` Weights selection must choose the first validated candidate in deterministic name priority. Discovery and engine must share that resolver. +- `DECISION` HF resolution preserves cache-root priority, then checks snapshots newest-to-oldest until a full validator accepts one. Invalid candidates must not shadow valid candidates. +- `DECISION` Safetensors metadata must match the upstream schema: absent or accepted null, or an object with string values; numeric/array/non-string metadata is invalid. +- `FACT` Additional confirmed defects included zero-element tensors, shallow shell early exits, local paths mistaken for HF repository IDs, tokenizer-only E2E resolution, and dead resolver helpers. +- `DECISION` Shell/release/setup paths must invoke the same Rust validator as runtime; duplicating file lists in Bash recreates split-brain completeness. +- `RISK` Direct Cargo embedding could still bypass canonical Make/release preflight through a shallower `core/build.rs` filename-only check. Canonical targets being safe does not make the bypass correct. +- `DECISION` `final_pass_mode=smart` persisted as a migration/runtime token in the audited branch, while the normal product stop path remained hard-off. Documentation must state both facts without implying that Smart whole-file inference runs after every take. +- `FACT` The source reports focused commits for the resolver, HF predicate, metadata schema, zero-element rejection, shell validation, and E2E cleanup, followed by full gates and push. +- `CURRENT-CODE` Current HEAD does not contain those final resolver shapes: `resolve_weights_path` still chooses the first existing file without validating alternates. +- `CURRENT-CODE` Current HEAD has no `find_snapshot_with_any_matching` symbol, so the predicate-aware HF traversal described in the source is absent or named/implemented differently and requires direct follow-up. +- `CURRENT-CODE` Current HEAD still filters `__metadata__` out of tensor iteration without an evident schema check; the source's later metadata fix is not present in this checkout. +- `CURRENT-CODE` Therefore historical green PR state must not be projected onto the current branch. The current code is authoritative and presently re-exposes at least the alternate-weights and metadata risks. +- `FOLLOW-UP` Before claiming PR #81 follow-up is integrated, compare current HEAD against `7b85f718` (or the final remote PR head), then port/verify missing validated-weights, predicate-aware HF, metadata-schema, zero-element, and shell preflight fixes. +- `FOLLOW-UP` After any port, rerun targeted bundle/loader fixtures, `make verify`, Clippy, Semgrep, changed-doc formatting, real FP16 load, real Q8 refusal, and current-branch runtime smoke. + +## Journal synthesis + +- The source conversation contains intentional evolution: early safe-off statements were later narrowed by explicit operator correction. +- The stable product north star is time-grounded multi-observer transcription, not immutable Apple text. +- The stable model policy is verified FP16 and absolute Q8 refusal. +- The stable normal-stop policy is no hidden whole-file repass; explicit Retranscribe owns whole-file inference. +- The stable engineering policy is one validator/owner per truth, runtime receipts, real artifact smoke, and no historical-green projection onto a living branch. +- The largest current gap is not lack of intent. It is incomplete convergence between that intent, the current Layered runtime, current settings truth, and current branch integration of the final PR #81 follow-ups. diff --git a/docs/OVERLAY_STREAMING.md b/docs/OVERLAY_STREAMING.md index bb50d32c..8b00c0e9 100644 --- a/docs/OVERLAY_STREAMING.md +++ b/docs/OVERLAY_STREAMING.md @@ -2,27 +2,34 @@ > Complete data flow documentation for codescribe's real-time speech-to-text pipeline. > -> **Re-framed 2026-05-26** as the rendering surface for the -> [Layered Incremental Transcription Pipeline (ADR)](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md). -> The overlay is now a 5-layer incremental theatre — _NEVER rewrites from zero, always patches in place._ +> **Re-framed 2026-08-22** as the rendering surface for the canonical +> [four-layer engine contract](./THE_ENGINE_CONTRACT.md). The 2026-05-26 +> [five-layer ADR](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md) +> is a superseded historical inventory. > > Created by Vetcoders (c)2026 -## Layered rendering model (ADR 2026-05-26) +## Four-layer rendering model -The overlay no longer renders a single linear stream of one engine's output. It renders the -union of layer events, each mutating the same already-shown text buffer. The ADR specifies -five layers; **the render path accepts all five event families, but only three producers -exist today** — the `Status` column below is inventory, not intent: +The overlay renders one ordered document reduced from four machine layers. Each +accepted mutation addresses the same PCM/span ledger; no observer may wipe the +buffer and rebuild the session. -| Layer | Engine | Event types | When | Status | -| ------------------- | ------------------------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------- | -| **0 — Live** | Apple `SFSpeechRecognizer` (primary) · Whisper fallback | `Preview`, `Correction`, `UtteranceFinal` | While the user speaks — owns first commit | ✅ shipped, default | -| **1 — Tail Patch** | Whisper (Candle / mlx-audio / OpenAI / libraxis) | `ReplaceRange { source: TailPatch }` | at each sealed utterance boundary | ✅ delivered, **opt-in** (`CODESCRIBE_LAYERED_TRANSCRIPTION=phase1+`, default off) | -| **2 — Lexicon** | Dictionary substitution (`apply_lexicon`) | `ReplaceRange { source: Lexicon }` | at seal time, after Layer 1 | ⚠️ delivered in a different shape than the ADR's debounced module | -| **2 — LLM polish** | Small inline LLM (Bielik-11B proposed) | `ReplaceRange { source: InlineLlm }` | — | ❌ no producer | -| **3 — Paralingual** | Silero classifier head | `InsertAnnotation { HesitationPause \| Paralingual }` | — | ❌ no producer (transport exists end-to-end) | -| **4 — Final BAM** | Session-end contextual pass | `ReplaceRange` (cross-utterance, within bounds) + `SessionFinalised` | On `stop()` / hold-release | ❌ no producer; `SessionFinalised` _is_ emitted by the live paths | +| Layer | Owner | Reducer surface | Current status | +| ---------------------------- | --------------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| **L0 — Apple** | `SFSpeechRecognizer` live observer | `Preview`, `Correction`, `UtteranceFinal` | Shipped first paint on the Apple progressive route. | +| **L1 — Whisper** | Typed tail provider over proven PCM spans | bounded `ReplaceRange { source: TailPatch }` / corrected final | Wired on exact-span Apple progressive; VAD/Whisper-first uses Whisper as its primary observer and refuses a second unbound patcher. | +| **L2 — Lexicon + Light+** | Deterministic vocabulary and sentence shaping | lexicon rewrite followed by Light+ before stable commit | Currently wired on progressive seals and as the delivery floor; explicit `force_raw` skips Light+. | +| **L3 — Responses formatter** | Existing configured Formatting lane | span-keyed accepted formatting result through the same reducer authority | Implemented behind `CODESCRIBE_INLINE_FORMAT`; “inline” is scheduling over stable L2 spans, not a small model or second client. | + +The human receives the sealed document after L3; the human is not a fifth +machine layer. Silero is orthogonal VAD and PCM-time evidence. Plain Silero may +report speech probability, boundaries, silence duration, and pause timing; named +laughter/noise annotations require an optional measured paralingual provider. + +The historical `InlineLlm` and `FinalBam` enum values remain reserved wire +vocabulary, not active layer owners. Final BAM is superseded and has no +automatic producer. `SessionFinalised` is lifecycle-only. **Hard invariant:** every layer mutates the buffer only through bounded events (`Append`, `ReplaceRange`, `InsertAnnotation`, `Backspace`). No layer is allowed to wipe the @@ -34,24 +41,26 @@ contract and rationale. ```mermaid flowchart LR - L0[Layer 0
Apple live deltas] - L1[Layer 1
Whisper tail patch] - L2[Layer 2
Lexicon + LLM polish] - L3[Layer 3
Silero paralingual] - L4[Layer 4
Final BAM] + L0[L0
Apple live observer] + L1[L1
Whisper contextual observer] + L2[L2
Lexicon + Light+] + L3[L3
Existing Responses formatter] + SILERO[Silero
orthogonal VAD/time evidence] BUF[(Already-shown buffer
Overlay render target)] L0 -- Preview / UtteranceFinal --> BUF L1 -- ReplaceRange (bounded) --> BUF - L2 -- ReplaceRange (bounded) --> BUF - L3 -- InsertAnnotation --> BUF - L4 -- ReplaceRange (cross-utterance) + SessionFinalised --> BUF + L2 -- stable shaped span --> BUF + L3 -- accepted span format --> BUF + SILERO -. boundaries / pause evidence .-> L1 + SILERO -. timing evidence .-> L3 ``` -The legacy single-engine pipeline below describes **what powers Layer 0 + Layer 1's Whisper -backend today**. It still works exactly as documented when Apple is unavailable (fallback mode). -When Apple is the active Layer 0, the same chunker/VAD/Whisper machinery moves to background -duty and emits Layer 1 `ReplaceRange` events instead of primary `Preview` events. +The Whisper-first VAD/scheduler pipeline below is a separate runtime route. It +uses Whisper as its primary observer when Apple is unavailable or explicitly +unselected. Apple progressive instead submits exact PCM spans through the typed +tail-provider seam. Both routes converge on the same reducer; they do not claim +identical mutation-fence geometry. ## Pipeline Overview @@ -165,17 +174,21 @@ A 64ms circular buffer (~1024 samples at 16kHz) captures audio **before** speech | `Iter` | State machine with min_speech/min_silence/max_utterance | 16kHz (VAD rate) | | `Supervisor` | Same as Iter but preserves raw sample rate | Original (48kHz) | -### Two Silero instances - -The application runs **two independent Silero VAD paths**: - -1. **Inline in SpeechSession** (`SileroVad` struct) — synchronous, called directly in the audio processing loop. This is the gate that filters audio before Whisper. Zero latency, blocking. +### Single Silero ingress -2. **Singleton worker** (`vad::speech_probability()`) — async fire-and-forget via bounded channel (capacity=4). Used by the auto-stop monitor in `main.rs` to detect when the user stops speaking during toggle recording. Returns last computed probability (eventual consistency). +The Apple progressive session owns **one** `SileroIngress` / `SpeechSession`. +The same observation feeds both the utterance ledger and `EpochGate`; a second +VAD over the same PCM is forbidden because it could disagree on sample +boundaries. Exact threshold crossings are drained as +`EngineEvent::SidebandEvidence`. The existing fusion flag decides whether +Silero identity may reach the seal; it does not create another VAD. ### Flush fallback -When recording stops but VAD never fired `Start` (e.g. speech was too quiet or short for the threshold), `SpeechSession::flush()` checks `max_speech_prob`. If it exceeds `FALLBACK_PROB` (0.25) and at least 0.5s of audio is available, the raw buffer is emitted as a degraded fallback. The engine reports this as `EngineEvent::VadFallback`. +When Silero is unavailable on the Apple lane, sideband evidence is absent and +`EpochGate` is disarmed. PCM continues through one uninterrupted Apple stream; +sideband absence is not a gate. Buffered/VAD paths keep their existing flush +and `NoSpeech` behavior without inventing a sideband claim. --- @@ -194,8 +207,9 @@ Speech segments from the VAD gate arrive as `SpeechEvent::Utterance` (interim) o 1. Receives utterance audio from `SpeechSession`. 2. Transcribes with Whisper (Metal GPU acceleration). 3. Post-processes via `StreamPostProcessor` (lexicon correction, hallucination filter, semantic gate). -4. Emits `EngineEvent::Preview` with accumulated text for the current utterance. -5. Optionally runs Phase 2 correction (re-transcription of accumulated audio for better accuracy). +4. Applies the deterministic Light+ floor where the route promises L2 shaping. +5. Emits `EngineEvent::Preview` with accumulated text for the current utterance. +6. Optionally runs Phase 2 correction (re-transcription of accumulated audio for better accuracy). ### Anti-repetition @@ -216,17 +230,20 @@ Whisper uses `no_repeat_ngram_size = 5` to suppress the model's tendency to repe The engine emits **semantic events** — it communicates what happened, not how to display it: -| Event | Meaning | -| ---------------- | ----------------------------------------------------------- | -| `VadStart` | VAD detected speech start (with `speech_prob` and `ts_ms`) | -| `VadEnd` | VAD detected speech end | -| `VadFallback` | Flush path used (VAD never fired Start but speech detected) | -| `Preview` | Latest transcription of current utterance (full text) | -| `Correction` | Re-transcription improved previous output | -| `UtteranceFinal` | Complete utterance — VAD-bounded or flush | -| `Drop` | Content dropped (hallucination, semantic gate) | -| `Stats` | Session-level statistics (emitted on stop/flush) | -| `Warning` | Recoverable error — engine continues | +| Event | Meaning | +| ------------------ | ---------------------------------------------------------------------------- | +| `VadStart` | VAD detected speech start (with `speech_prob` and `ts_ms`) | +| `VadEnd` | VAD detected speech end | +| `SidebandEvidence` | Exact PCM edge or pause; typed `silero_vad` provenance; never text authority | +| `Preview` | Latest transcription of current utterance (full text) | +| `Correction` | Re-transcription improved previous output | +| `UtteranceFinal` | Complete utterance — VAD-bounded or flush | +| `Drop` | Content dropped (hallucination, semantic gate) | +| `Stats` | Session-level statistics (emitted on stop/flush) | +| `Warning` | Recoverable error — engine continues | +| `ReplaceRange` | Bounded mutation for a proven span | +| `InsertAnnotation` | Optional visible annotation; needs a measured content provider | +| `SessionFinalised` | Lifecycle closure only; never a text producer | ### Preview semantics (contract) @@ -236,7 +253,9 @@ The engine emits **semantic events** — it communicates what happened, not how - Presentation must keep **session structure**, not only a flat string: - committed utterances that are already safe to keep - one active preview/correction tail for the current utterance -- Corrections may rewrite only the active tail. Previously committed utterances must stay append-only. +- Span order and PCM identity stay append-only. An authorized downstream L1/L2/L3 + observation may still correct wording inside its proven span before + `transcript_sealed`; no layer may rebuild or reorder the session. - UI sinks still consume only backspace-encoded `TranscriptDelta` payloads; full preview snapshots must be diffed upstream before they reach overlay/chat APIs. ### Delta generation (backspace magic) @@ -348,9 +367,10 @@ SpeechEvent (speech segments, silence removed) │ transcription_session ▼ Whisper inference → raw transcript - │ StreamPostProcessor (lexicon + semantic gate) + │ StreamPostProcessor (lexicon + semantic gate) → Light+ ▼ -EngineEvent::Preview { text } (utterance-local) +stable L2 span / EngineEvent::Preview { text } + │ optional L3 scheduling through existing Responses formatter │ EventSink / DeltaSinkAdapter ▼ TranscriptDelta (backspace-encoded diff) @@ -376,6 +396,9 @@ Displayed text (String, visible in overlay/bubble) | `core/stt/tail_patcher/mod.rs` | Layer 1 gate, job computation, bounded-patch decision | | `core/pipeline/sinks.rs` | DeltaSinkAdapter, CallbackSink, CollectorEventSink | | `core/pipeline/stream_postprocess.rs` | Lexicon correction, semantic gate, hallucination filter | +| `core/pipeline/light_plus.rs` | Deterministic L2 sentence shaping | +| `core/llm/inline_format.rs` | L3 stable-span scheduling and fail-open ledger | +| `core/llm/ai_formatting.rs` | Existing Responses Formatting lane used by L3 | | `app/controller/mod.rs` | Recording state machine, Hold/Toggle orchestration | | `app/controller/helpers.rs` | ControllerEventRouter, session mode routing | | `app/presentation/emitter.rs` | PresentationEmitter (typing animation via BufferedEmitter) | diff --git a/docs/STT_CONTRACT.md b/docs/STT_CONTRACT.md index e1184092..207818a3 100644 --- a/docs/STT_CONTRACT.md +++ b/docs/STT_CONTRACT.md @@ -7,12 +7,15 @@ > Whisper transcribing **partials on the go** to fill canvas gaps — NOT final-pass-only. > Lexicon substitution is the FINAL automated layer, after Whisper. > -> **Status (2026-08-14):** on-the-go gap-fill **exists and is the stock live -> default** as Layer 1 tail-patch on both live paths (`a6b1233d`, default -> flip 2026-08-09). `CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1`; -> explicit `off`/`0`/`false` disarms. Legacy `FINAL_PASS_MODE` no longer owns -> any normal-stop inference. W13 fusion / -> idempotence / highlights stay OFF until an operator flip. +> **Status (2026-08-21):** the Apple progressive path now enforces exact +> request/span PCM identity, one pre-final rewrite fence, structural replay +> idempotence, and a bounded stop drain with typed degradation. In-process, +> sidecar, and remote tail providers share that seam. The legacy VAD/scheduler +> path and full-session WSS candidates have no equivalent per-word identity, so +> they fail closed instead of mutating emitted finals. Local Power + Apple/Auto +> now arms live local Whisper by default; `phase1` remains a compatibility +> token, while explicit `off` or invalid input is degraded. Legacy +> `FINAL_PASS_MODE` no longer owns any normal-stop inference. > Planning report: internal plan `stt-apple-must-have` (operator artifact store, 2026-07-24). --- @@ -163,24 +166,25 @@ Code: `core/config/loader.rs` · `core/stt/mod.rs::selected_engine()` · `reconc **Single brain (W2-A):** `CODESCRIBE_STT_ENGINE` and `FINAL_PASS_MODE` are **promoted** settings. UI write updates `settings.json`, process env, and `.env` together. No silent dual brain. -Still env-seedable when unset (not dual writers): `CODESCRIBE_LAYERED_TRANSCRIPTION`, `CODESCRIBE_STT_INITIAL_PROMPT_ENABLED`. +`CODESCRIBE_LAYERED_TRANSCRIPTION` is promoted single-brain configuration; +`CODESCRIBE_STT_INITIAL_PROMPT_ENABLED` remains env-seedable when unset. -> **Power-user hazard (measured 2026-08-08).** Because `CODESCRIBE_LAYERED_TRANSCRIPTION` is -> **not** promoted to `settings.json`, `Config::inject_file_env_for_runtime` copies it out of +> **Historical power-user hazard (measured 2026-08-08, now closed).** Before promotion, +> `Config::inject_file_env_for_runtime` copied `CODESCRIBE_LAYERED_TRANSCRIPTION` out of > `~/.codescribe/.env` into the process env on the first `Config::load()` — in _every_ process > that loads the core, tests and harnesses included. A stale `.env` line therefore arms Layer 1 > silently. This was observed live: the same `make test-engine-parity` binary scored 0.931 with > the lane off and 0.833 with the operator's dotenv arming `phase1`, and the low score was the > _more accurate_ transcript. The parity target now pins the lane explicitly (`Makefile`), but -> the general hazard stands for any tool that loads the core. Promoting the key the way -> `CODESCRIBE_STT_ENGINE` was promoted is an open operator decision. +> the general hazard affected any tool loading the core. The key is now +> promoted to settings.json; parity harnesses still pin their requested lane. **Final pass vs layered (orthogonal):** -| Setting | Env | Default | Role | -| ---------- | ---------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | -| Final pass | `FINAL_PASS_MODE` | legacy | No effect on normal stop; retained only for settings migration while explicit Retranscribe owns whole-file inference | -| Layered | `CODESCRIBE_LAYERED_TRANSCRIPTION` | `phase1` | During-hold Layer 1 tail-patch on **both** live paths — local Whisper or live cloud WSS, selected by product mode | +| Setting | Env | Default | Role | +| --------------------- | ---------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Final pass | `FINAL_PASS_MODE` | legacy | No effect on normal stop; retained only for settings migration while explicit Retranscribe owns whole-file inference | +| Layered compatibility | `CODESCRIBE_LAYERED_TRANSCRIPTION` | mode-owned | Local Power + Apple/Auto: unset or `phase1` arms; explicit off/invalid degrades. VAD/direct Whisper refuses a second unbound patcher | Normal capture ignores legacy final-pass routing and never decodes/uploads the completed WAV. Layered phase tokens (`phase1`…) select live refinement; @@ -217,23 +221,54 @@ file-pass belongs only to explicit retranscribe surfaces. ### 3.3 Dictation overlay / tray -| Front | UniFFI | Handler | -| ----------------------------- | -------------------------------------------------- | ------------------------------- | -| Live partials / final text | `CsTranscriptionListener` callbacks | streaming pipeline → listener | -| Recording service object | `CodescribeHotkeys` | shared controller recording API | -| Tray status glyphs | `CodescribeTrayStatus` + listener | controller tray payload | -| Auto-paste / auto-format tray | `set_auto_paste_enabled` / `set_auto_format_level` | `UserSettings` + live toggles | +| Front | UniFFI | Handler | +| ----------------------------- | -------------------------------------------------- | ------------------------------------------------------- | +| Live partials / final text | `CsTranscriptionListener` callbacks | streaming pipeline → listener | +| PCM sideband evidence | `EngineEventWire::SidebandEvidence` | Silero ingress → IPC → bridge diagnostic; reducer no-op | +| Recording service object | `CodescribeHotkeys` | shared controller recording API | +| Tray status glyphs | `CodescribeTrayStatus` + listener | controller tray payload | +| Auto-paste / auto-format tray | `set_auto_paste_enabled` / `set_auto_format_level` | `UserSettings` + live toggles | ### 3.4 STT engine dispatch (the nit) -| Call site | When | Function / transport | Engine rule | -| --------------------- | ---------------- | ---------------------------------------------- | -------------------------------------------------------- | -| Live Layer 0 | during recording | Apple progressive | committed canvas floor | -| Live Layer 1 local | during recording | Whisper on ~5 Apple segments | aligned sentence swap on the joined window | -| Live Layer 1 cloud | during recording | Voice Lab WSS | normalized gap/tail fill; same substitution rule | -| Explicit Retranscribe | operator action | local completed-file decode or cloud multipart | may replace the selected artifact, never the live canvas | - -**This split is the MacGyver fracture:** UI can show Whisper readiness while live is Apple-only and fails closed. +| Call site | When | Function / transport | Engine rule | +| --------------------- | ---------------- | ---------------------------------------------------------------- | -------------------------------------------------------- | +| Live Layer 0 | during recording | Apple progressive | committed canvas floor | +| Live Layer 1 typed | during recording | in-process / sidecar / remote tail provider on ~5 Apple segments | exact-PCM outcome rewrites pending baseline before final | +| Live Layer 1 unbound | after recording | full-session Voice Lab WSS candidate | evidence only; typed refusal if it proposes mutation | +| Legacy VAD tail patch | during recording | VAD/scheduler route | typed refusal; no pending-span fence exists | +| Explicit Retranscribe | operator action | local completed-file decode or cloud multipart | may replace the selected artifact, never the live canvas | + +Layer 1 mutation identity is `(session, capture_epoch, sample_start, sample_end, request_id, target_utterance_id, event_ordinal)`. The request range +must contain the target span and any provider payload must echo the admitted +identity. Patch offsets are applied only to the byte-identical baseline handed +to the patcher. The accepted text crosses +`ProgressiveSealMachine::try_rewrite_anchored` once, and only when the evidence +range overlaps the target on the same session/epoch clock; `UtteranceFinal` is the first and +last outward committed form. Replays, invalid ranges, missing identities, and +late completions emit content-free typed warnings and preserve Apple text. +Identical words in disjoint PCM ranges are distinct applications and survive. + +`UtteranceFinal.acoustic` carries the committed phrase identity through the +presentation reducer into the Transcript Bus. Phrase timing remains `phrase`; +the system never divides provider segment time evenly into invented word pins. +The L3 ledger orders `(capture_epoch, sample_start, sample_end)` +lexicographically and rejects loss, addition, or reorder before delivery. + +Active W2-04 Agent leases are read directly as a bounded 120-second snapshot. +Their names are placed first in the existing Whisper context budget and +canonicalized only by exact whole-word matching in Lexicon/Light+. Stale, +malformed, unknown, or colliding leases fail open. There is no phonetic/fuzzy +rewrite: active `Iwo` does not rewrite Polish `piwo`. + +Ordinary overlay TextEditor edits carry `edit_provenance=manual_human` +separately from delivery `action`. The latch is consumed by one quality commit; +three distinct correction IDs for the same normalized lexical pair expose +`1/3`, `2/3`, `3/3` and promote exactly once. Formatter, retranscribe, replay, +bulk, speech-gap, and delivery actions without that latch cast no vote. + +**Runtime proof:** Settings may show configured readiness, but a take counts as +exercised only when its typed receipt says `armed=true` and `submitted>0`. ```text selected_engine() diff --git a/docs/THE_ENGINE_CONTRACT.md b/docs/THE_ENGINE_CONTRACT.md index 0b7e00ec..72b330bf 100644 --- a/docs/THE_ENGINE_CONTRACT.md +++ b/docs/THE_ENGINE_CONTRACT.md @@ -1,14 +1,18 @@ # THE ENGINE — quality-report contract -| | | -| --------------- | ------------------------------------------------------------------------------------------------------ | -| id | `the-engine/v1` | -| corpus schema | `codescribe-corpus-parity/v3` | -| primary key | `pcm_time` | -| source of truth | `core/quality/engine_contract.rs` | -| surfaces | **Seal Atlas** in Voice Lab (`voice-lab` tab); gold HTML `docs/quality-reports/seal-atlas.take01.html` | - -Do not re-derive this. If a sentence here disagrees with `ENGINE_CONTRACT` in Rust, the Rust constant wins and this file is wrong. +| | | +| ----------------- | ------------------------------------------------------------------------------------------------------ | +| id | `the-engine/v1` | +| corpus schema | `codescribe-corpus-parity/v3` | +| primary key | `pcm_time` | +| executable mirror | `core/quality/engine_contract.rs` | +| surfaces | **Seal Atlas** in Voice Lab (`voice-lab` tab); gold HTML `docs/quality-reports/seal-atlas.take01.html` | + +Do not re-derive this from a convenient implementation detail. This file owns +the product invariant; `ENGINE_CONTRACT` in Rust is its executable mirror. If +they disagree, runtime truth must be reported as drift and both surfaces must +be reconciled in the same cut. A stale implementation does not silently repeal +the product contract. ## Product goal @@ -22,25 +26,104 @@ Preview, colours, successive hypotheses and seals are internal mechanics. The us ## Relay -Apple → Whisper → lexicon → formatter → human +Apple → Whisper → Lexicon + Light+ → Responses formatter → human This is a band, not a queue of correctors. Ban is **per layer, per span**. The layer that already passed this span is out. The next one may enrich the **same** time window. -- **Apple** draws now (thin, sharp pencil). Span commits → Apple out. -- **Whisper** enters the buffer on **3-5s utterance-bounded partials**. Never full audio in the automatic pipeline (`full_file_pass = button_only_proposal`). Must not hallucinate into silence. Excess recall is stuffed into holes Apple left (`ReplaceRange`, never full-replace). -- **Lexicon / Light+** tune after Whisper settles. -- **Formatter** (Responses, `previous_response_id`) has a trash bucket. It may throw away. It may not rearrange the plate. +- **Apple** draws now (thin, sharp pencil). Its text is a fast hypothesis pinned to PCM time, not a protected word floor. +- **Whisper** enters the buffer on **~4 s observations with ~1 s overlap**, bounded by available speech evidence. Never full audio in the automatic pipeline (`full_file_pass = button_only_proposal`). It may fill omissions or replace weaker Apple wording inside the same proven span. It must not hallucinate into silence or rebuild the session from zero. +- **Lexicon / Light+** are L2 and tune deterministically after Whisper settles. Light+ is currently wired on progressive seals and as the delivery floor. +- **Responses formatter** is L3 (`previous_response_id`). It has a trash bucket: it may throw away approved verbal debris, but it may not rearrange the plate. - **Human** is last, after seal. -`NEVER REWRITE FROM ZERO.` Append-only. Key = PCM sample counter, not token position. +### Exactly four machine layers + +L0 — Apple; L1 — Whisper; L2 — Lexicon + Light+; L3 — Responses formatter. + +| Layer | Owner | Contract | +| ------ | ---------------------------- | ------------------------------------------------------------------ | +| **L0** | Apple | Fast, PCM-pinned live hypothesis. | +| **L1** | Whisper | Deeper overlapping observation of the same proven spans. | +| **L2** | Lexicon + Light+ | Deterministic vocabulary and sentence shaping; currently wired. | +| **L3** | Existing Responses formatter | Session-context formatting through the configured Formatting lane. | + +Inline describes scheduling of the existing Responses formatter over stable +spans. It does not name a small model, a second client, or a second formatting +product. The human is the recipient after these four machine layers, not a +fifth machine layer. + +Silero is orthogonal VAD and PCM-time evidence. It may contribute speech +boundaries, silence duration, pause evidence, and pre-roll; richer paralingual +labels are optional and require a measured provider beyond plain Silero VAD. +Silero does not occupy a numbered text layer. + +### Sideband evidence contract + +`EngineEvent::SidebandEvidence` carries content-free observations on the same +PCM axis as the ordered span ledger: + +- identity is `(session, capture_epoch, sample_start, sample_end, sequence)` + plus `sample_rate_hz`; +- provenance is typed as `silero_vad`; +- supported claims are `speech_start`, `speech_end`, and a measured pause + duration whose only non-speech classification is `unknown_non_speech`; +- plain Silero does **not** support laughter, cough, music, speaker, language, + or named noise labels. Those require a separate measured provider; +- an edge is a zero-width range at the exact threshold-crossing sample; a pause + is the exact half-open gap from a measured speech end to the next measured + speech start; +- the event is never `InsertAnnotation`, never mutates committed text, and its + absence never blocks audio, sealing, delivery, or transcript assembly; +- L3 may consume only measured pause duration, and only as context for + punctuation or paragraph boundaries. It may not turn the evidence into words + or sound annotations. + +Example pause event (JSON field names match the serialized contract): + +```json +{ + "type": "sideband_evidence", + "evidence": { + "sequence": 3, + "range": { + "session": "session-abc", + "capture_epoch": 2, + "sample_start": 16000, + "sample_end": 24000 + }, + "sample_rate_hz": 16000, + "provenance": "silero_vad", + "evidence": { + "kind": "pause", + "duration_samples": 8000, + "non_speech": "unknown_non_speech" + } + } +} +``` + +The Apple lane emits this evidence whenever its existing single +`SileroIngress` is present (`CODESCRIBE_SILERO_FUSION=1` or the configured +hands-free epoch lifecycle needs speech edges). There is no second VAD and no +new sideband flag. If Silero cannot load, `EpochGate` disarms and Apple runs as +one continuous stream with no sideband events. + +Final BAM is superseded and has no automatic content producer. Normal stop +drains already admitted work and assembles the ordered span ledger; it does not +start a fifth rewrite. SessionFinalised is lifecycle-only and may not mutate +text. + +`NEVER REWRITE FROM ZERO.` The **PCM axis and ordered span ledger** are +append-only. Text hypotheses inside an authorized, not-yet-session-sealed span +remain correctable. Key = PCM sample counter, not token position. ## Three bars — not synonyms -| Bar | Means | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `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)` becomes append-only and may start inline formatting. Order on the PCM axis is frozen. | -| `transcript_sealed` | 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. | +| Bar | Means | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `utterance_final` / committed | This observer finished its current hypothesis for the fragment. **Not the document and not an immutable token floor.** A later authorized observer may correct the same proven span. | +| `utterance_sealed` | The span identity and `[sample_start, sample_end)` placement are frozen. Its text is stable for presentation but remains correctable by an admitted downstream observation before session seal. | +| `transcript_sealed` | 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. | `committed` does **not** mean "this is already the document". It means: **this layer finished its work here; the next layer takes the same time slice.** @@ -51,7 +134,7 @@ Before `transcript_sealed` the whole document is **not** mutable. - Closed spans stay on their places on the PCM axis. - Utterances may not be reordered. Text may not be built from zero. - The current tail may still evolve. -- Whisper may fill holes and replace weaker evidence inside a still-unsealed allowed span. +- Whisper may fill holes and replace weaker evidence inside the same authorized span before session seal. - The formatter works in parallel on closed fragments and keeps their order. - Stop closes only the tail and assembles ready fragments. @@ -62,7 +145,7 @@ A first-wins final string is not enough. The real document is the ordered span l | Column | Role | | ----------------------- | --------------------------------------------------------------------------------- | | `raw` / `live` | live hypothesis | -| `post` / `layer1` | Whisper hole-fill | +| `post` / `layer1` | Whisper span-bound correction or gap-fill | | `sealed` | sealed span | | `delivered` / `session` | session document | | `ai` / `cloud` / `hq` | `HumanTriggeredProposal` — WER against a proposal does not promote it to document | @@ -76,6 +159,566 @@ A first-wins final string is not enough. The real document is the ordered span l - `auto_replace_after_transcript_sealed` - `treat_committed_as_document` - `treat_whole_text_mutable_until_session_seal` +- `treat_apple_text_as_immutable_floor` +- `infer_span_identity_from_text_similarity` +- `infer_named_sound_from_silero` +- `deduplicate_intentional_repetition_by_content` +- `treat_mean_energy_db_as_identity` +- `claim_layered_on_when_no_windows_reach_the_provider` + +## Founding invariant — restored 2026-08-21 + +The sentence is not a string that one recognizer owns. + +The sentence is an ordered projection of observations over audio time. + +The stable object is: + +```text +session + → capture epoch + → PCM sample axis + → ordered spans + → observations with provenance + → current canvas projection + → transcript seal +``` + +The unstable object before session seal is: + +```text +the current wording attached to an authorized span +``` + +This distinction is the engine. + +Replay is re-delivery of the same **observation** (producer, request, +generation, occurrence). It is not "the same PCM range". Apple and Whisper on +one range are two observations of one occurrence; Whisper may correct Apple +there. Two disjoint ranges with the text "Iwo" are two occurrences. Overlap +may clip a phrase only when word pins prove which text belongs to the exclusive +tail; otherwise the text stays visible as read-only evidence and must not mint +a duplicate token. Unanchored text stays visible without mutation authority. + +### Audio truth + +- Mechanical speech energy exists before transcription. +- Capture PCM is the evidence retained by the product. +- Its monotonic sample counter is the shared clock. +- A provider timestamp is mapped onto that clock. +- A provider timestamp never replaces that clock. +- A token offset is not a clock. +- A character position is not a clock. +- Similar words are not identity. +- A model confidence score is not identity. +- A final callback is not identity. +- Identity is minted from capture/session/span evidence. +- Mean `energy_db` is quality evidence on the PCM axis, never a collision-proof ID. +- `dB × ms` names coordinates and hop evidence, not a scalar hash of average loudness. +- Two identical tokens on disjoint `[sample_start, sample_end)` ranges are two observations. +- Replaying one range must not mint another token. Text-suffix overlap must not collapse them. +- Executable admit path: `core/pipeline/acoustic_identity.rs` (`admit_acoustic_spans`). +- String `strip_suffix_overlap*` remains a legacy unanchored fallback and is forbidden once spans are anchored. + +### Apple truth + +- Apple optimizes time-to-first-useful-text. +- Apple can be excellent and still be wrong. +- Apple can lose the first token. +- Apple can collapse a technical phrase into common words. +- Apple can choose the wrong inflection. +- Apple can emit cumulative finals with awkward geometry. +- Apple can provide real per-word pins. +- Apple can provide only utterance-grain timing. +- Apple can exhibit clock-lie. +- Apple text is therefore evidence, never ownership. +- An Apple commit pins a hypothesis to a region of time. +- It does not make the literal hypothesis immutable. + +### Whisper truth + +- Whisper exists in the automatic pipeline to repair live hypotheses. +- Whisper does not exist merely for a manual rescue button. +- Whisper observes approximately four seconds at a time. +- Consecutive observations overlap approximately one second. +- The overlap carries linguistic context across boundaries. +- The overlap must not duplicate canvas content. +- Request/span identity resolves overlap replay. +- Whisper may correct a word family or inflection. +- Whisper may replace a malformed phrase. +- Whisper may restore code-switching or a technical name. +- Whisper may append speech absent from the canvas. +- Whisper may remove its own span's hallucinated text. +- Whisper may not use an unrelated window to alter a neighbor. +- Whisper may not write into verified silence. +- Whisper may not replace the complete session automatically. + +### Safety truth + +- Safety protects correspondence between canvas and audio. +- Safety does not protect the first textual guess. +- A small textual diff is not automatically safe. +- A large textual diff is not automatically unsafe. +- A correction is safe only when its audio authority is proven. +- Text alignment happens after authority is established. +- Change ratio is a heuristic, not a constitutional boundary. +- `never delete` is not a valid global safety law. +- `never replace committed words` is not a valid global safety law. +- `preserve intentional repetition` is a valid law. +- `reject replay of the same span identity` is a valid law. +- Those two laws are compatible only through structural identity. + +### Seal truth + +- A layer final closes that layer's turn, not the document. +- An utterance seal freezes span identity and time order. +- It does not canonize Apple's exact characters. +- A transcript seal closes automatic mutation. +- After transcript seal, automated providers may only propose. +- Explicit Retranscribe is a new user-authorized inference action. +- Retranscribe may produce a whole-file result. +- Its existence does not excuse a broken live Layer 1. +- If Retranscribe recovers meaning lost by Delivery, live refinement failed. + +## Product modes + +### Apple-first local power + +Target semantics: + +```text +microphone + → Apple immediate observations + → live canvas + → local FP16 Whisper overlapping observations + → span-bound corrections + → lexicon + → optional formatting + → delivery +``` + +- Apple owns first paint. +- Local Whisper owns no document. +- Local Whisper must be available before Layered reports ready. +- Model cold load must not block capture callbacks. +- Inference must not backpressure the microphone. +- One model instance may serve sequential jobs. +- Each request retains its own identity and evidence. +- Failure degrades explicitly, not silently. + +### Apple-first cloud + +Target semantics are identical. + +- Transport may be WebSocket or another authorized stream. +- Audio egress requires explicit consent. +- Cloud partials remain volatile until admitted. +- Cloud finals use the same span authority as local finals. +- Cloud may not receive broader mutation rights than local. +- Local may not receive broader mutation rights than cloud. +- Provider failure preserves the best grounded canvas. +- Provider failure emits a typed degradation receipt. + +### Apple-only + +- Apple-only is a deliberate privacy/availability mode. +- It is not the intended maximum-quality mode. +- It must be labeled as lacking Layer 1 refinement. +- It must not masquerade as Local Power. +- It must not display Layered ON. + +### Whisper-first + +- Whisper-first may use VAD/scheduler utterance decoding. +- It still uses the same PCM clock. +- It still uses the same reducer authority. +- It still forbids automatic whole-session rewrite. +- Current VAD/scheduler mutation parity is incomplete. +- Missing rewrite-fence parity must emit a named refusal. + +## Current HEAD truth — integrated runtime cut `ad1052d1` + +The contract above is the product destination. + +Current implementation evidence is narrower: + +- Apple progressive carries integer request identity. +- Apple progressive carries a span map. +- Apple progressive has one pre-final rewrite fence. +- Apple progressive tests structural replay rejection. +- Apple progressive counts submitted jobs into exactly one terminal bucket: + applied, skipped, timed out, or abandoned. +- VAD/scheduler lacks the same pending-span rewrite fence. +- VAD/scheduler preserves primary text instead of mutating blindly. +- Local Power + Apple/Auto arms the local tail patcher when the compatibility + setting is absent or `phase1`. +- Explicit `off` and malformed overrides are named degraded states. +- Settings exposes configured/not-ready/degraded truth, not an independent ON + boolean; only the ordered runtime receipt proves per-take exercise. +- Cloud still uses the generic consent-gated provider lane; local exact-span + patching is a distinct typed decision because its mutation fence is local. +- The receipt is emitted before `SessionFinalised` and reconciles every + submitted job. +- Normal product stop has no hidden whole-file pass. +- Historical `smart` may remain in persisted configuration. +- Explicit Retranscribe can load the verified local FP16 model. + +These are remaining contract gaps: + +- Cloud generic Layer 1 and local exact-span patching do not share one + mutation implementation; transport parity must not imply authority parity. +- VAD/scheduler intentionally refuses a second patcher until it owns an + equivalent pending-span fence; direct Whisper remains its primary engine. +- Settings can validate the FP16 bundle only as available/unavailable; it + cannot yet name the corrupt component through the bridge. +- Textual LCS/change-ratio logic still participates in acceptance. +- Full word-grain identity is not guaranteed for every Apple span. +- Clock-lie remains a real input class. +- The exact 4 s/1 s cadence is a target requiring runtime receipts. + +## Model contract + +- Runtime Whisper is large-v3-turbo FP16/F32 only. +- Q8 is retired absolutely. +- Q8 is not a fallback. +- Q8 is not an explicit-path exception. +- Q8 is not an embedded-build exception. +- Q8 dequantization code must not exist in the loader. +- Config, tokenizer, mel, and weights form one bundle. +- Tokenizer must parse. +- Mel must match the pinned SHA-256. +- Safetensors must contain at least one nonempty tensor. +- Model tensors use F16 or F32. +- `alignment_heads:I64` is the single named format exception. +- U32, I32, arbitrary integer tensors, scales, and biases are refused. +- Tensor shapes, byte sizes, offsets, gaps, overlaps, and payload length validate. +- Metadata must match upstream safetensors schema. +- A corrupt preferred weights filename may not shadow a valid alternate. +- A corrupt newer HF snapshot may not shadow a valid older snapshot. +- Runtime, downloader, release preflight, and embedded build must share validation. +- Filename presence alone never means installed. +- Invalid partial downloads never become final files. +- Invalid final destinations are repaired or quarantined. +- Valid existing artifacts are reused offline. + +## Settings contract + +- `settings.json` is the durable product source of truth. +- `.env` may seed absent promoted values. +- `.env` may not remain a second independent writer. +- UI readback uses the same effective value as recording start. +- UI writes become visible to the next recording without relaunch. +- `ASR mode`, `STT engine`, and `Layered` are distinct dimensions. +- Local Power means local Layer 1 capability is intended. +- Cloud means audio egress is consent-gated. +- Apple Only means no Layer 1 provider. +- `Final Pass off` concerns stop-path whole-file inference. +- `Layered off` concerns during-hold refinement. +- The two switches are orthogonal. +- A stale `final_pass_mode=smart` token must not reactivate hated Full Pass. +- A Layered toggle ON must be backed by an armed lane receipt. +- A missing model produces a visible not-ready/degraded state. +- Installed model status comes from full validation, not file names. + +## Acceptance recordings + +The following are contract fixtures, not anecdotes: + +### Meaning-loss fixture + +Spoken intent: + +```text +Whisper musi łatać partiale. +``` + +Failure observed: + +```text +mój model pt. Musi latać +``` + +Acceptance: + +- Apple may show the weak hypothesis initially. +- Whisper receives the corresponding PCM observation. +- The canvas is corrected before Delivery. +- Delivery retains the repaired meaning. +- Manual Retranscribe must not be the first place meaning returns. + +### Onset fixture + +Spoken first token: + +```text +IWO +``` + +Acceptance: + +- The first speech token survives capture and seal. +- Demux receives the same first token as the audio. +- Failure is classified as onset/pre-roll or ASR adjudication. +- Demux grammar is not blamed for a token absent from the bus. + +### Repetition fixture + +- Five intentional repetitions occupy five distinct span identities. +- All five survive projection and delivery. +- Replaying one identity does not create a sixth copy. +- Text-equality deduplication is forbidden. + +### Model fixture + +- Complete valid FP16 loads and decodes real audio. +- Real Q8 is refused before tensor load. +- Corrupt mel repairs on retry. +- Corrupt tokenizer repairs on retry. +- Invalid preferred weights falls through to valid alternate. +- Invalid newest HF snapshot falls through to valid older snapshot. +- Malformed metadata is refused during discovery. + +## Required receipts + +Every live session reports: + +- selected STT engine +- resolved ASR product mode +- effective Layered phase +- Layer 1 armed/disarmed reason +- provider kind +- model identity when local +- model validation result +- windows admitted +- windows coalesced +- windows unresolved +- provider jobs started +- provider jobs completed +- corrections applied +- corrections refused +- structural replays rejected +- gaps appended +- intentional repetitions preserved +- jobs abandoned +- drain timeouts +- first covered sample +- last covered sample +- transcript seal timestamp +- delivery timestamp + +The zero-work receipt is diagnostic: + +- ON + zero admitted windows is failure. +- OFF + zero admitted windows is expected. +- unavailable + zero admitted windows is explicit degradation. +- no log line is not an acceptable state. + +## Anti-drift rules + +- Never turn a heuristic into an operator law. +- Never cite Maciej or Monika for an agent inference. +- Never preserve a known-wrong rule for compatibility without labeling it. +- Never let a green test sanctify superseded behavior. +- Never let an old report outrank current runtime. +- Never let current broken runtime redefine the product goal. +- Product intent tells us what to build. +- Runtime tells us what currently works. +- Tests prove only the behavior they actually assert. +- Contracts must state target and current gap separately. +- Any change to authority updates this file and `TRANSCRIPT_LANES.md`. +- Any change to configuration updates `STT_CONTRACT.md` and `ENV_REGISTRY.toml`. +- Any change to receipts updates quality schemas and tests. +- Any accepted correction must be reproducible from its evidence. +- Any rejected correction must have a named reason. +- Any temporary OFF must name the missing falsifier/evidence required for ON. + +## Transcript Bus and observer contract + +- `PresentationEmitter` is the transcript reducer of record. +- The Transcript Bus observes committed reducer events. +- The Bus never opens a microphone. +- Diagnostic tools never open a competing recorder. +- One in-app `RecordingController` owns microphone capture. +- Dictation, Agent, and Assistive select consumers, not recorders. +- Bus path resolution is shared with the application. +- Resolver order is contractual. +- `CODESCRIBE_TRANSCRIPT_BUS_PATH` is the explicit override. +- XDG state participates only where documented. +- `CODESCRIBE_DATA_DIR` participates where documented. +- `~/.codescribe` is the final fallback. +- An observer may not invent an undocumented alternate key. +- A follower stores byte offsets, not decoded-character estimates. +- An incomplete UTF-8 sequence remains buffered as bytes. +- Poll boundaries may split any multibyte code point. +- A follower may act on side effects only after seal. +- Draft events may drive conversation preview only. +- Agent name assignment requires an unambiguous addressing phrase. +- A casual greeting may not permanently rename a follower. +- Failure to hear a name absent from sealed text is an ASR failure. +- Follower liveness and microphone ownership are separate facts. +- A live process writing to a dead terminal is not an effective observer. + +## Delivery contract + +- Delivery follows explicit operator intent. +- OS focus is not delivery authority. +- The capture-start Agent thread owns that take. +- Browsing another thread cannot steal in-flight speech. +- Clipboard, paste, canvas, and Agent are distinct routes. +- Auto-paste requires positive confirmation of the latched target. +- A successful activation request is not positive confirmation. +- A timeout with Codescribe still frontmost is not confirmation. +- Ambiguous activation fails closed. +- Fail-closed delivery preserves the user's clipboard. +- A failed paste presents a recoverable Paste Here/Copy route. +- Formatting may be vetoed without discarding raw text. +- Revert returns to the raw first version. +- Delivery text must equal the reducer's sealed projection plus authorized transforms. +- UI preview is not delivery truth. +- Raw engine text is not delivery truth. +- Manual Retranscribe is not delivery truth unless the user accepts it. +- Delivery receipts identify route, target, seal, and applied transforms. + +## Performance contract + +- Time-to-first-useful-text matters more than batch elegance. +- Capture callbacks never wait for Whisper inference. +- Capture callbacks never wait for network inference. +- Capture callbacks never wait for UI rendering. +- Layer 1 queues are bounded. +- Queue overflow degrades refinement, never capture. +- One in-flight local patch is an acceptable initial bound. +- Stop drain is bounded and measured. +- No hidden 8-second whole-file pass runs after ordinary Fn release. +- Model load happens once per residency epoch, not per audio packet. +- Local calls share model weights while retaining request state. +- FP16 preparation performs no Q8 dequantization. +- Cold-load, warm inference, stop drain, and delivery latency are separate metrics. +- Measurements name hardware and build. +- A single-machine benchmark is evidence, not a universal promise. +- RSS, Metal buffers, and TTL residency are reported separately. +- A hot observer process such as `voc` is not blamed on Codescribe without a joint sample. +- A sample without a running Codescribe process proves nothing about Codescribe heat. +- CPU percentage of one core is not total-machine percentage. +- Physical heat requires evidence beyond one process sample. + +## Verification contract + +Static gates: + +- Rust format +- Clippy with warnings denied +- Semgrep +- environment registry validation +- gate ledger validation +- shell syntax and ShellCheck for changed scripts +- Markdown/Prettier for changed documents + +Hermetic gates: + +- workspace tests +- doctests +- model bundle fixtures +- direct loader refusal fixtures +- reducer and presentation tests +- span identity and replay tests +- intentional repetition tests +- bounded-drain tests +- settings serialization/readback tests + +Host gates: + +- real official FP16 load +- real Q8 refusal before tensor load +- real Apple progressive take +- real Layered armed receipt +- real correction before Delivery +- real onset fixture +- real repetition fixture +- real app relaunch after install +- deep codesign verification when release-impacting + +Reporting rules: + +- A pre-change green test is not post-change verification. +- A process surviving is not proof it is the newly installed image. +- Files on disk and running-process identity are separate checks. +- A locked-desktop UI timeout is observation failure, not automatic regression. +- Host-only evidence is labeled host-only. +- Missing secrets are labeled unavailable, not failed code. +- A skipped gate is never summarized as pass. +- A known unrelated failure remains visible. +- Changed-file green does not erase repo-wide red. +- Repo-wide red does not erase a proven focused result. +- Every claim names the command, artifact, or receipt that supports it. + +## Completion contract + +The engine is not complete when it merely compiles. + +It is complete for a cut only when: + +- target behavior is explicit +- current behavior is mapped +- authority boundaries are preserved +- code implements the intended lane +- tests attack likely counterexamples +- settings expose actual runtime truth +- receipts make degradation visible +- normal-stop latency remains bounded +- model installation is valid and repairable +- the installed app runs the intended commit +- the user-visible failure case is improved +- documentation describes the same state +- unrelated Living Tree work is preserved +- the coherent cut is committed +- outward actions match operator authorization + +## Supersession ledger + +Superseded: + +- Apple text is the immutable live floor. +- Committed means document-final. +- Sealed span text is append-only. +- Whisper may only add missing suffixes. +- A small textual diff proves safe identity. +- Never delete is a universal safety law. +- Layered is merely an optional experiment. +- Manual Retranscribe is an adequate substitute for live repair. +- Layer 1 behaves identically on Apple and VAD paths. +- Settings ON proves runtime arming. +- Filename presence proves model installation. +- Green CI proves PR completion. + +Restored: + +- Audio time is truth. +- Apple creates fast temporal pins. +- Whisper continuously re-observes overlapping audio. +- Canvas text evolves within proven span authority. +- Span identity, ordering, and provenance are invariant. +- Automatic whole-session rewrite is forbidden. +- Final BAM is superseded; no automatic content producer owns a fifth layer. +- `SessionFinalised` is lifecycle-only and never edits the document. +- Q8 never enters runtime. +- FP16 is complete, validated, and exercised. +- Layered ON returns when every accepted mutation path is evidenced. + +## Immediate convergence order + +1. Make persisted Layered state and recorder arming one typed truth. +2. Make Local Power arm the local FP16 provider deterministically. +3. Emit an explicit arming receipt at recording start. +4. Submit overlapping ~4 s / ~1 s observations during speech. +5. Preserve exact PCM/request/span identity through completion. +6. Apply corrections through the single reducer fence. +7. Repair the canonical meaning-loss fixture before Delivery. +8. Preserve onset and intentional-repetition fixtures. +9. Bring VAD/scheduler to the same mutation authority or keep it explicitly read-only. +10. Port any missing validated-weights/HF/metadata fixes into the current Living Tree. +11. Align `STT_CONTRACT.md`, `ENV_REGISTRY.toml`, UI copy, and runtime logs. +12. Only then reconsider the deployment default. ## How a quality HTML must behave diff --git a/docs/THE_ENGINE_ROADMAP.md b/docs/THE_ENGINE_ROADMAP.md index 297a8cc0..9b645f31 100644 --- a/docs/THE_ENGINE_ROADMAP.md +++ b/docs/THE_ENGINE_ROADMAP.md @@ -69,14 +69,15 @@ after settlement — do not re-open those cuts as unstarted work. | W13-1 | Inline-format buffer: sealed chunks stream to the formatting LLM during dictation (`previous_response_id` chain); stop pays only the tail | `[~]` in progress — `CODESCRIBE_INLINE_FORMAT` default OFF | | W13-2 | Tail-patch behind a provider seam: local ws sidecar (default target), remote opt-in, in-process fallback | landed OFF — 2A contract + 2B sidecar slot | | W13-3 | **Keystone**: time-pinned canvas — Silero-bounded utterances, words pinned to seconds, sealed ledger | landed OFF — 3A provenance + 3B `CODESCRIBE_SILERO_FUSION=0` | -| W13-4 | Gap-append dedup by time-span (shrinks to a corollary of W13-3) + in-span hallucination fence | landed OFF — `CODESCRIBE_SPAN_IDEMPOTENCE=0` | +| W13-4 | Gap-append dedup by time-span (shrinks to a corollary of W13-3) + in-span hallucination fence | landed ON — PCM identity law; `=0` is diagnostic only | | W13-5 | Capture-level receipt + Audio menu truth (level, device, quality) | landed — WARN is non-terminal | | W13-6 | Lexicon gets a voice (Whisper `initial_prompt`, Apple `contextualStrings`) + word/gap highlighting feeding Teach | landed OFF — 6A voice + 6B `CODESCRIBE_OVERLAY_HIGHLIGHTS=0` | -**Current truth (2026-08-14, polarize):** the live product already runs -Apple canvas + Layer 1 Whisper tail-patch by default (`unset` → -`phase1`, `core/stt/tail_patcher/mod.rs::LAYERED_DEFAULT_PHASE`). W13 -fusion / idempotence / highlights stay OFF until an operator flip. +**Current truth (2026-08-21, field-falsified):** the stock live product runs +Apple canvas + lexicon. Layer 1 Whisper tail-patch is wired but fail-closed +(`unset` → `off`; explicit `phase1` is experimental) until the request/span +identity and single rewrite-fence closing bar is complete. W13 fusion / +idempotence / highlights stay OFF until an operator flip. `lbrx-stt-engine` file-mode is a **bench**, not a replacement engine. Single-writer emitter landed in `75c89f56`. Next field cut is take-614 fusion A/B. Closing bar: layered-ON ≥ lbrx file-mode on U-WER vs human, @@ -501,8 +502,8 @@ classes this roadmap names: vocabulary, code-switching, tail integrity. ### Diagnosis (holistic) -The gap is **not model capability** — we embed the same Whisper family, -and Layer 1 is already the stock live default (`phase1`). The gap is the +The gap is **not model capability** — we embed the same Whisper family. +Layer 1 is wired but no longer the stock live default. The gap is the live lane: window feeding, patch authority, and (until this morning) buffer integrity. Field evidence, same morning (Monika, 2026-08-14): 42% of Layer-1 tail patches rejected (80 applied / 59 rejected across 10 @@ -516,11 +517,11 @@ That dual-writer cut **landed** in `75c89f56`: loop animates the `DeltaSink` only. Do not re-open it as "field P0". The W13 close-the-gap machinery **already landed** in the settlement -(`13b1eed8`). "All defaults OFF" names the **W13 flip flags** +(`13b1eed8`). Its mutation lanes are fail-closed: Layer 1 itself and the +**W13 flip flags** (`CODESCRIBE_SILERO_FUSION`, `CODESCRIBE_SPAN_IDEMPOTENCE`, -`CODESCRIBE_OVERLAY_HIGHLIGHTS`, `CODESCRIBE_INLINE_FORMAT`) — it does -**not** describe Layer 1, which has been `phase1` since the 2026-08-09 -operator directive. Silero-boundary fusion (3B, synthetic starvation +`CODESCRIBE_OVERLAY_HIGHLIGHTS`, `CODESCRIBE_INLINE_FORMAT`) require explicit +arming. Silero-boundary fusion (3B, synthetic starvation −67%), span idempotence (4), typed tail providers (2A/2B), and lexicon voice (6A) sit behind those OFF flags. "Catching up" is therefore not new architecture — it is measurement and the operator's flip matrix, in diff --git a/docs/TRANSCRIPT_BUS.md b/docs/TRANSCRIPT_BUS.md index 46d8bc92..47a8c1e2 100644 --- a/docs/TRANSCRIPT_BUS.md +++ b/docs/TRANSCRIPT_BUS.md @@ -26,19 +26,26 @@ reader; no host, date, room, or control-plane path is embedded in Codescribe. Each line is one JSON object with: - `sequence`, `session_id`, `mode`, `utterance_id`, `emitted_at`, `status` -- `sample_rate_hz`, `sample_start`, `sample_end` +- `sample_rate_hz`, `capture_epoch`, `sample_start`, `sample_end` - `audio_start_seconds`, `audio_end_seconds` - clean draft or sealed `text`, structured `segments`, optional `pipeline_session_id` -- `words`: PCM-pinned spans (`text`, `sample_start`, `sample_end`, optional - `energy_db`, `grain`). `grain` is `word` when the engine supplied pins and - `utterance` when the span is the commit-to-commit window. Intensity is the - overlap-weighted capture RMS in dBFS for that sample range. Overlay live - text is not a word source. +- `words`: PCM-pinned spans (`text`, `session_id`, `capture_epoch`, + `sample_start`, `sample_end`, `energy_db`, `grain`). `grain` is `word`, + `phrase`, or `utterance` according to what the backend actually measured. + Segment seconds are never re-labelled as word timing. Intensity is the + overlap-weighted capture RMS in dBFS for that exact sample range. Overlay + live text is not a span source. +- `coverage`: a falsifiable pass/fail receipt. Missing PCM identity, absent + voiced-energy evidence, unordered/out-of-range spans, omitted anchored text, + or an unanchored insertion leaves reducer `text` visible but publishes no + misleading lexical spans and records the failure code. -`transcript_sealed` inherits the draft ledger's sample range, segments, and -words. A controller seal with no prior drafts stays honest: times and words -are omitted. +`transcript_sealed` inherits the draft ledger's PCM identities. Its lexical +signature must match the reducer bytes; L3 punctuation/casing may update the +text of a single phrase span without changing its range. An omission or added +tail clears the spans and fails coverage. A controller seal with no prior +drafts stays honest: times and words are omitted and coverage fails explicitly. Statuses are `session_started`, `utterance_draft`, `utterance_revised`, and `transcript_sealed`. A revision keeps the original utterance identity. @@ -59,3 +66,31 @@ tail -F "$HOME/.codescribe/transcript-events.jsonl" That command is the non-XDG default. With `XDG_STATE_HOME` set, follow `$XDG_STATE_HOME/codescribe/transcript-events.jsonl`; an explicit bus-path override wins over both. + +Named external agents (same file, not a second microphone): + +```bash +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py \ + --provider codex --session --become --drafts --follow +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py \ + --provider codex --session --name james --drafts --follow +``` + +The Setup Wizard installs that stable helper only after the operator selects +Codex, Claude Code, or both. The signed app payload is checksum-verified before +installation; runtime commands never depend on a source checkout. + +Unnamed agents do not pass. The first emitted line is an attach receipt with a +provider/session lease and cursor. Preserve and poll the follower handle. When a +provider session recovers after compaction, the same provider/session/name +resumes from that cursor, including events appended during recovery, without +replaying the old command. Duplicate names in different provider sessions own +different leases. Non-stale names are exposed without touching audio: + +```bash +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py --active-names +``` + +Draft/revised envelopes explicitly carry `state_change_allowed: false`; only a +`transcript_sealed` envelope carries `state_change_allowed: true`. The demux +never opens audio or changes transcript text. diff --git a/docs/TRANSCRIPT_LANES.md b/docs/TRANSCRIPT_LANES.md index 2672b6e9..fc7a348d 100644 --- a/docs/TRANSCRIPT_LANES.md +++ b/docs/TRANSCRIPT_LANES.md @@ -2,8 +2,14 @@ **One line per path: where it starts → what it passes through → what it crosses → what the user sees.** Anchors are `file :: symbol` (symbols survive -the Living Tree better than line numbers). Verified against -`fix/the-tail-patches` HEAD `16e0b9c3`, 2026-08-14. +the Living Tree better than line numbers). Originally mapped against +`fix/the-tail-patches` HEAD `16e0b9c3`; corrected against +`fix/p0-p2-before-release` integrated runtime cut `ad1052d1`, 2026-08-21. + +This file is the execution map. Product invariants live in +`THE_ENGINE_CONTRACT.md`. Where an older row below calls Apple text a floor, +immutable, append-only, or protected, the engine contract supersedes that +wording: **audio time and span identity are stable; text hypotheses are not**. How to read: @@ -13,6 +19,20 @@ How to read: default-OFF flag (an operator button). Drawn as a dashed line. - **since** — the commit/wave that put the station into service. +The live product contract has exactly four machine layers: + +| Layer | Lane | Current truth | +| ------ | ---------------------------- | --------------------------------------------------------------------------------------------------------- | +| **L0** | Apple live observer | First paint and PCM-pinned hypotheses. | +| **L1** | Whisper contextual observer | Exact-span correction/gap evidence; no automatic full-file pass. | +| **L2** | Lexicon + Light+ | Deterministic shaping, currently wired on progressive seals and delivery. | +| **L3** | Existing Responses formatter | Stable-span scheduling through the configured Formatting lane; “inline” is scheduling, not a small model. | + +Silero is an orthogonal VAD/time-evidence plane, not a numbered text layer. +Plain VAD may report speech probability, boundaries, and silence/pause timing; +richer paralingual labels remain optional and provider-bound. The human receives +the sealed document after L3 and is not a fifth machine layer. + --- ## 0. The map at a glance @@ -31,8 +51,11 @@ MIC ▶ recorder ▶ [J1 PCM ring+spill] └──▶ [J2 CANVAS: reducer+emitter] ◀────┘ ▲ │ LINE L1 ───┘ ▼ - (tail-patch, OVERLAY live text ▶ ...user watches letters land - rides A and B) + (local tail-patch OVERLAY live text ▶ ...user watches letters land + currently rides A; + provider L1 is separate) + │ stable seals: L2 lexicon + Light+ + └────────────▶ LINE F / L3 Responses formatting (when armed) │ stop ▼ LINE S: [J3 truth adjudication] ▶ [J4 postprocess+lexicon] @@ -69,7 +92,7 @@ mic ▶ recorder ▶ [J1] ▶ apple_stream_worker PCM ingress ▶ SFSpeech bridg | A6 | sealing | `progressive_seal.rs :: ProgressiveSealMachine` | finals seal utterances; the SFSpeech span clock maps onto the PCM clock (2 ms divergence measured); `may_rewrite`/`try_rewrite` is the future time-fence | wired W2-B (`8d65f610`/`d64c3876`) | | A7 | guards | `apple_live_session.rs` (`phrase final adjudicated`, `novel final suffix rescued`, `freeze open partial`) | cumulative finals are adjudicated against sealed state; novel suffixes are rescued with synthesized windows; restart-retained partials are frozen — the anti-duplication front line | W1-B/W2-A | | A8 | canvas | **[J2]** `emitter.rs :: TranscriptReducer` | committed utterances + one active preview; **single writer** `store_transcript_snapshot` (the tick loop only animates) | `75c89f56` | -| A9 | user | overlay live view | letters land as spoken; corrections arrive live as backspace magic (append + gap-fill only — full-replace is a doctrine violation) | pre-0.8 → doctrine | +| A9 | user | overlay live view | letters land as spoken; later observations may append, fill gaps, or replace weaker text inside the same proven PCM span; rewriting the session from zero remains forbidden | corrected doctrine 2026-08-21 | ## 2. LINE B — VAD/scheduler live (Whisper-first lane) @@ -80,16 +103,16 @@ mic ▶ recorder ▶ [J1] ▶ Silero VAD chunker ▶ utterance boundaries ↑ Refine lane: correction.rs partial passes (VAD-aligned windows) ``` -| # | station | code | what happens | since | -| --- | ------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| B1 | VAD filter | `core/audio/chunker.rs` (Silero, embedded, zero-I/O) | detects WORDS, not noise; silence edges close utterances — “fundament stabilności” | doctrine §3.5 | -| B2 | scheduling | `core/stt/scheduler.rs :: SttScheduler` | Fast lane = utterance decode; Refine lane = correction re-decodes; per-lane `initial_prompt_for_lane` (⚑ OFF, W13-6A) | — | -| B3 | decode | `core/stt/whisper/singleton.rs` | in-process Whisper (turbo fp16 only; official OpenAI tokenizer + pinned mel asset); TTL reaper unloads 30 min after last finished decode (`whisper_residency_reclaim`) | fp16 only | -| B4 | corrections | `streaming/correction.rs` | Phase-2 Refine: partial passes triggered by finals/speech-ms, **VAD-aligned windows** (`plan_vad_aligned_windows_with_config`) so windows never begin mid-phrase | W1-A | -| B5 | postprocess | `core/pipeline/stream_postprocess.rs` | lexicon rewrite table (compiled-in seed/programming/operator/protected), hallucination + SemanticGate + empty-drop gates | — | -| B6 | canvas + user | **[J2]** → overlay | same reducer/emitter contract as LINE A | — | +| # | station | code | what happens | since | +| --- | ----------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| B1 | VAD/time evidence | `core/audio/chunker.rs` + `streaming/silero_fusion.rs` | emits ordered `SidebandEvidence` with exact PCM edges and pause=`unknown_non_speech`; it does not identify words, laughter, or named noise classes | W2-02 | +| B2 | scheduling | `core/stt/scheduler.rs :: SttScheduler` | Fast lane = utterance decode; Refine lane = correction re-decodes; per-lane `initial_prompt_for_lane` (⚑ OFF, W13-6A) | — | +| B3 | decode | `core/stt/whisper/singleton.rs` | in-process Whisper (turbo fp16 only; official OpenAI tokenizer + pinned mel asset); TTL reaper unloads 30 min after last finished decode (`whisper_residency_reclaim`) | fp16 only | +| B4 | corrections | `streaming/correction.rs` | Phase-2 Refine: partial passes triggered by finals/speech-ms, **VAD-aligned windows** (`plan_vad_aligned_windows_with_config`) so windows never begin mid-phrase | W1-A | +| B5 | postprocess | `core/pipeline/stream_postprocess.rs` | lexicon rewrite table (compiled-in seed/programming/operator/protected), hallucination + SemanticGate + empty-drop gates | — | +| B6 | canvas + user | **[J2]** → overlay | same reducer/emitter contract as LINE A | — | -## 3. LINE L1 — Layer 1 tail-patch (rides on top of A **and** B) +## 3. LINE L1 — Layer 1 tail-patch ``` [J1 stopped PCM window] ▶ compute_tail_patch_job ▶ TailProvider @@ -97,13 +120,13 @@ mic ▶ recorder ▶ [J1] ▶ Silero VAD chunker ▶ utterance boundaries ▶ ReplaceRange on [J2 canvas] (gap-fill, never full-replace) ``` -| # | station | code | what happens | since | -| ---- | ------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| L1.1 | gate | `CODESCRIBE_LAYERED_TRANSCRIPTION` (default **phase1**) | orthogonal to engine choice; `off` disarms it | default `phase1` 2026-08-09; one-truth `afbb604a` | -| L1.2 | window | `session.rs :: compute_tail_patch_job` (called from BOTH live sessions) | takes settled PCM behind the live head; max one job in flight; unresolved windows never reach Whisper | W2-A | -| L1.3 | provider seam | `core/stt/tail_provider.rs :: TailProvider` | typed payload with **integer sample identity** `(session, capture_epoch, sample_start, sample_end)` + evidence + receipts; `STT_TAIL_PROVIDER=inprocess` is the default; `sidecar`/`remote` ⚑ built (W13-2B `4a9fc3fd`), falling back to inprocess with receipts | W13-2A `16ffe025` | -| L1.4 | diff + apply | `core/stt/tail_patcher/` | word-aligned LCS (`alignment_key`), `MAX_CHANGE_RATIO=0.5` safety (small-edit floor exempts short commits), applies as `ReplaceRange` events on the canvas | `f224effd` + `0e77a1e4` | -| L1.5 | receipts | `tail_patch_session_receipt applied=/skipped=` + per-request `tail_provider_receipt` | the starvation-alarm class (a month of 116/0 was invisible before it) | `c3933f42` | +| # | station | code | what happens | since | +| ---- | ------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| L1.1 | arming | `layer1_decision_for_recording` + `LocalTailPatchDisposition` | Local Power + Apple/Auto arms local Whisper by default; `phase1` is compatible explicit arming; explicit `off` or malformed override is named degradation; Apple-only does not arm | `6b9a6475` | +| L1.2 | window | Apple: `apple_live_session.rs`; VAD: `session.rs` | Apple progressive carries exact request/span identity through its mutation fence; VAD/scheduler already uses Whisper as its primary engine and explicitly refuses a second unbound mutation lane | `6b9a6475` | +| L1.3 | provider seam | `core/stt/tail_provider.rs :: TailProvider` | typed payload with **integer sample identity** `(session, capture_epoch, sample_start, sample_end)` + evidence + receipts; `STT_TAIL_PROVIDER=inprocess` is the default; `sidecar`/`remote` ⚑ built (W13-2B `4a9fc3fd`), falling back to inprocess with receipts | W13-2A `16ffe025` | +| L1.4 | diff + apply | `core/stt/tail_patcher/` | current implementation uses word-aligned LCS and a change-ratio guard; target authority comes from PCM/span identity, never textual similarity alone; accepted corrections apply through one pre-final `ReplaceRange` fence | corrected doctrine 2026-08-21 | +| L1.5 | receipts | `tail_patch_session_receipt` + per-request `tail_provider_receipt` | `armed/submitted/applied/skipped/timed_out/abandoned/drain` reconcile at job grain; receipt enters the ordered event stream before `SessionFinalised`; zero submitted while armed fails acceptance | `6b9a6475` + `ad1052d1` | Field truth, 2026-08-14: ~42% of patches were rejected in Monika's sessions — the number the ⚑ Silero-fusion flip (§8) exists to fix. @@ -116,25 +139,25 @@ stop ▶ recorder.stop (drain + WAV) ▶ [J3 truth adjudication] ▶ LINE F (format) or LINE G (agent) ▶ [J6 history] + [J7 delivery] ``` -| # | station | code | what happens | since | -| --- | ----------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -| S1 | drain | `stop_toggle_inner` PHASE 0–4 (`app/controller/mod.rs`) | serialized stop; `stop_path_budget` log line prices every phase — the budget is sacred (doctrine §3.4) | — | -| S2 | truth | **[J3]** `app/controller/truth.rs :: adjudicate_recording_truth` | `merge_live_whisper`: live canvas is the FLOOR, provider fills gaps, never full-replace; length-regression guard | doctrine | -| S3 | residual | `app/controller/final_pass.rs` + `final_pass_residual_from_partials` | `FINAL_PASS_MODE=smart` (default): full-file re-pass only when streaming is incomplete; residual composed from partials, `seal_source=live_session`, no file re-decode | live-first, stop-residual | -| S4 | postprocess | **[J4]** same `stream_postprocess` gates + lexicon | applied to the ADJUDICATED text (`Post-processed transcript … lexicon_rewrites=n`) | — | -| S5 | fork | mode decision (hotkey held) | raw → LINE F (formatting) and/or LINE G (assistive); AUTO format may fire on the overlay | — | +| # | station | code | what happens | since | +| --- | ----------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| S1 | drain | `stop_toggle_inner` PHASE 0–4 (`app/controller/mod.rs`) | serialized stop; `stop_path_budget` log line prices every phase — the budget is sacred (doctrine §3.4) | — | +| S2 | truth | **[J3]** `app/controller/truth.rs :: adjudicate_recording_truth` | ordered audio-span ledger is authority; Apple and provider observations are adjudicated per proven span; no whole-session rebuild | corrected doctrine | +| S3 | residual | `app/controller/final_pass.rs` + `final_pass_residual_from_partials` | normal product stop performs no hidden whole-file Whisper pass; historical `smart` remains a migration/runtime token; explicit Retranscribe alone owns whole-file inference | 2026-08-21 | +| S4 | postprocess | **[J4]** same `stream_postprocess` gates + lexicon | applied to the ADJUDICATED text (`Post-processed transcript … lexicon_rewrites=n`) | — | +| S5 | fork | mode decision (hotkey held) | raw → LINE F (formatting) and/or LINE G (assistive); AUTO format may fire on the overlay | — | -## 5. LINE F — Formatting LLM lane +## 5. LINE F — L3 Responses Formatting lane ``` -raw transcript ▶ ai_formatting (per-lane endpoint/model/key) - ▶ [J5 Responses chain: previous_response_id per mode] +stable L2 spans ▶ inline_format scheduling ▶ existing ai_formatting lane + ▶ [J5 Responses chain: previous_response_id per session] ▶ semantic guard ▶ [J7 overlay formatted] + [J6 history] ``` | # | station | code | what happens | since | | --- | ---------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -| F1 | lane truth | `core/llm/lane_truth.rs` | endpoint/model/key resolved per lane (Formatting ≠ Assistive ≠ Main — separate key slots, separate chains) | — | +| F1 | lane truth | `core/llm/lane_truth.rs` + `core/llm/ai_formatting.rs` | endpoint/model/key/prompt resolved from the existing Formatting lane (Formatting ≠ Assistive ≠ Main — separate key slots, separate chains); inline scheduling creates no second model or client | W2-01 | | F2 | request | `core/llm/ai_formatting.rs :: build_responses_input` | wire contract: `instructions` param on the FIRST turn only; chained turns re-carry the prompt as a leading `developer` item (the chain does NOT persist instructions server-side) | `26d0982d` + `5d62aacb` | | F3 | chain | **[J5]** `core/state/conversation.rs` | per-mode `previous_response_id`; the chain is REAL memory (2026-08-14: a stored id answered “what was this about” with a full recall of the take, hours later); ids are org/key-scoped — stale after key rotation ⇒ self-heal drops the id and retries unchained | self-heal in flight | | F4 | guard | `Action quality guardrail` + `semantic_cosine` (`app/controller/quality_delivery.rs`) | divergence (< 0.86) vetoes auto-paste; RAW is always preserved beside the draft | — | @@ -172,25 +195,28 @@ audio file ▶ `codescribe transcribe` CLI / cloud final pass by sample ranges, never by its reported seconds. - `streaming/offline.rs` is **tests/offline_eval only** — not a runtime lane. +PCM-range idempotence is a default-ON identity law. A repeated +`(session, capture_epoch, sample_start, sample_end)` cannot land twice; +`CODESCRIBE_SPAN_IDEMPOTENCE=0` exists only for diagnostic comparison. + ## 8. Dashed lines — built, verifier-green, ⚑ default-OFF (operator buttons) -| flag | line it arms | code | evidence | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- | -| `CODESCRIBE_INLINE_FORMAT` | W13-1 buforek: sealed spans stream to the formatter DURING dictation (`previous_response_id` chain per span), so stop pays only the tail | `core/llm/inline_format.rs` | seam stop 0.398 s vs 8.6–13.8 s; needs live ≥60 s take | -| `CODESCRIBE_SILERO_FUSION` | ⚠ **default ON since 2026-08-14** — this row is now the OFF switch. Silero boundary identity + conservative fusion feeding L1 windows | `streaming/silero_fusion.rs` | synthetic starvation skips 18→6 (−67%); take-614 A/B still owed | -| `CODESCRIBE_SPAN_IDEMPOTENCE` | ledger-keyed replay rejection — a sealed span cannot be delivered twice (kills gap-append “×4” dupes; NEVER content similarity) | `streaming/span_idempotence.rs` | named repetition tests green; real-session receipts pending | -| `CODESCRIBE_OVERLAY_HIGHLIGHTS` | lexicon-corrected words + VAD-speech-no-words gaps marked on canvas; highlighted span → Teach | W13-6B (bridge + Swift) | Rust+Swift tests green | -| `CODESCRIBE_STT_INITIAL_PROMPT_ENABLED` | lexicon VOICE: `Vocabulary:` prompt to Whisper per window | `stream_postprocess.rs :: build_whisper_initial_prompt` | A/B: U-WER −1.5 pp but false inserts 1→7 — flip not justified yet | -| `STT_TAIL_PROVIDER=sidecar\|remote` | tail decode out of process / off host | `tail_provider.rs` + `codescribe-stt-sidecar` | fake-provider receipt 22 ms; production supervision unmeasured | +| flag | line it arms | code | evidence | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- | +| `CODESCRIBE_INLINE_FORMAT` | L3 scheduling: stable L2 spans stream through the existing Formatting lane DURING dictation (`previous_response_id` chain per span), so stop pays only the tail | `core/llm/inline_format.rs` | W2-01 verifier-green; default OFF pending integrated live take | +| `CODESCRIBE_SILERO_FUSION` | Silero boundary identity + conservative fusion feeding L1 windows; default OFF | `streaming/silero_fusion.rs` | synthetic starvation skips 18→6 (−67%); take-614 A/B still owed | +| `CODESCRIBE_OVERLAY_HIGHLIGHTS` | lexicon-corrected words + VAD-speech-no-words gaps marked on canvas; highlighted span → Teach | W13-6B (bridge + Swift) | Rust+Swift tests green | +| `CODESCRIBE_STT_INITIAL_PROMPT_ENABLED` | lexicon VOICE: `Vocabulary:` prompt to Whisper per window | `stream_postprocess.rs :: build_whisper_initial_prompt` | A/B: U-WER −1.5 pp but false inserts 1→7 — flip not justified yet | +| `STT_TAIL_PROVIDER=sidecar\|remote` | tail decode out of process / off host | `tail_provider.rs` + `codescribe-stt-sidecar` | fake-provider receipt 22 ms; production supervision unmeasured | ## 9. Junctions — where lines cross | J | place | who meets whom | contract | | --- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | J1 | PCM ring + spill (`recorder`/`live_audio_buffer`) | mic capture × Apple ingress × L1 windows × stop WAV × crash recovery | the sample counter minted here is the ONE session clock | -| J2 | canvas (`TranscriptReducer` + `BufferedEmitter` + `app/presentation/emitter.rs`) | Apple finals × L1 `ReplaceRange` × gap-appends × preview | append + gap-fill only; ONE writer (`store_transcript_snapshot`); tick loop animates, never writes | -| J3 | truth adjudication (`truth.rs`) | live canvas × Whisper residual | live floor + gap fill, never full-replace | -| J4 | postprocess (`stream_postprocess`) | every text × lexicon × gates | lexicon is the FINAL automated layer; the human layer stays on top | +| J2 | canvas (`TranscriptReducer` + `BufferedEmitter` + `app/presentation/emitter.rs`) | Apple finals × L1 `ReplaceRange` × gap-appends × preview | ONE writer; order and PCM identity are stable, while text inside an authorized unsealed span may be corrected | +| J3 | truth adjudication (`truth.rs`) | live canvas × Whisper residual | preserve the ordered span ledger; accept only evidence bound to its PCM range; never rebuild the session from zero | +| J4 | L2 postprocess (`stream_postprocess` + `light_plus`) | every text × lexicon × Light+ × gates | deterministic L2 shaping is currently wired; accepted stable spans may then enter L3 Responses formatting | | J5 | Responses chain (`state/conversation.rs`) | formatting turns × assistive turns (separate streams) | per-mode ids; first-turn `instructions`, chained developer item; chain = recoverable session memory | | J6 | history (`core/state/history.rs`) | every take | `_raw.txt` + `_formatted.txt` (or `formatting-failed`) + `.m4a` + `.truth.json` — content is never destroyed (doctrine §3.7) | | J7 | overlay delivery (`overlay_paste.rs`, OverlayState.swift) | formatted draft × auto-paste × manual commit | semantic guard vetoes auto-paste only; Revert holds the raw first version | @@ -199,7 +225,7 @@ audio file ▶ `codescribe transcribe` CLI / cloud final pass | surface | fed by | truth it shows | | ------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| overlay LIVE | LINE A/B via J2 | letters as spoken; live backspace corrections (L1); never a rewrite of committed text | +| overlay LIVE | LINE A/B via J2 | letters as spoken; live backspace corrections may replace weaker text inside the same proven span; session-wide rewrite is forbidden | | overlay FINAL | LINE S→F via J7 | formatted draft + buttons (Copy / Insert / Revert / Format / To Agent); Auto Paste when guard allows | | paste target | J7 | formatted text into the latched foreign app; refuse (`CopyTargetUnavailable` / mismatch) ⇒ Paste Here slot, user clipboard untouched | | thread rail | LINE G | agent conversation, chained turn by turn | @@ -209,8 +235,150 @@ audio file ▶ `codescribe transcribe` CLI / cloud final pass --- -_Provenance: distilled from `docs/THE_ENGINE_ROADMAP.md` (§3 doctrine, §5 gap -map, §12 verdict, §13 bench), the W13 settlement ledger (`13b1eed8`), and -symbol-level verification on HEAD `16e0b9c3`, 2026-08-14._ +## 11. Superseding lane laws — 2026-08-21 + +These laws are normative. They replace stale `immutable floor`, `never delete`, +and `append-only text` interpretations wherever those appear in old comments, +tests, reports, or commits. + +### 11.1 Authority + +- PCM samples and their monotonic capture clock are primary evidence. +- A transcript token is an observation, not evidence by itself. +- Apple is the first low-latency observer. +- Whisper is a slower contextual observer. +- Lexicon is deterministic domain evidence. +- Formatting is a presentation transform, not speech evidence. +- Human edits have final authority after session seal. +- No model owns the document. +- No earlier model owns a word merely because it emitted first. +- No later model may mutate a span it cannot identify. + +### 11.2 Canvas geometry + +- Every accepted observation names a session. +- Every accepted observation names a capture epoch. +- Every accepted observation names `[sample_start, sample_end)`. +- Replayed delivery of the same identity is idempotent. +- Identical words on different spans are intentional until proven otherwise. +- Span order follows PCM time and cannot be reordered. +- Text inside an authorized span may evolve before `transcript_sealed`. +- A correction may replace inflection, morphology, spelling, or a whole phrase. +- A correction may remove a hallucinated word from its own span. +- A correction may add speech missing from the current canvas. +- A correction may not steal words from a neighboring span. +- A correction may not erase an uncovered region of speech. +- A correction may not build the entire session from zero. + +### 11.3 Window cadence + +- The target Whisper observation cadence is about four seconds. +- Adjacent windows overlap by about one second. +- Window edges must respect available speech-boundary evidence. +- Overlap exists to recover context, not to duplicate text. +- Overlap replay is resolved by request/span identity. +- Text similarity may help alignment after identity is established. +- Text similarity may never mint identity. +- VAD edges are evidence about speech activity, not transcript authority. +- Apple commit timing is evidence, not a perfect word boundary. +- Clock-lie or unresolved windows fail closed and emit receipts. + +### 11.4 Layer 1 deployment truth + +- The product destination is Apple-first with continuous Whisper refinement. +- Local Power arms live refinement by product default. +- `phase1` is not allowed to mean a decorative UI toggle. +- Settings ON requires an armed runtime lane. +- Settings ON with zero submitted windows is a product failure. +- Settings OFF may never be inferred from model unavailability silently. +- Missing FP16 produces named degradation. +- Q8 is never a fallback. +- Cloud and local providers must implement the same observation semantics. +- Provider differences may affect transport and latency only. +- Provider choice may not change canvas authority. + +### 11.5 Current implementation truth + +- Apple progressive has request identity and span maps on the integrated cut. +- Apple progressive has one pre-final rewrite fence. +- Apple progressive tracks structural replay identity. +- Apple progressive exposes reconciled job-grain arming and drain receipts. +- VAD/scheduler does not have the same pending-span rewrite fence. +- VAD/scheduler therefore preserves primary text and emits degradation. +- The old claim “L1 rides both paths identically” remains false: direct Whisper + is the primary engine on VAD/scheduler, not an Apple repair lane. +- Settings no longer exposes an independent optimistic Layered toggle. +- Model invalidity and explicit-off configuration are named degraded states. +- Runtime receipt, not Settings copy, proves that a take submitted patch jobs. + +### 11.6 Stop semantics + +- Releasing Fn closes capture. +- It does not authorize a whole-file rewrite. +- Admitted patch work receives a bounded drain opportunity. +- Work still outstanding after the bounded drain is counted as timed out. +- Non-timeout discard is counted separately as abandoned. +- Both outcomes emit named degradation evidence. +- The live result survives provider failure. +- Explicit Retranscribe reads the saved audio as a new user action. +- Retranscribe produces a proposal/result outside normal-stop authority. +- The historical Final BAM proposal is superseded; there is no automatic fifth + content producer. +- Stop drains admitted L1/L3 work and assembles accepted spans; it does not + authorize a renamed Full Final Pass. +- `SessionFinalised` is lifecycle-only and cannot mutate committed text. + +### 11.7 Receipt minimum + +- session id +- capture epoch +- sample range +- provider request identity +- provider kind +- source transcript hypothesis +- candidate transcript hypothesis +- alignment evidence +- accepted operation type +- accepted character/token range +- affected span identities +- rejection or degradation reason +- queue admission time +- inference completion time +- fence application time +- final disposition: applied, skipped, replayed, abandoned, or timed out + +### 11.8 Release falsifiers + +- A take where Apple loses a meaningful phrase must be repaired before Delivery. +- The canonical phrase `Whisper musi łatać partiale` must survive the live path. +- Manual Retranscribe recovering meaning that Delivery lost is a failed live run. +- Layered ON with `tail_patch_replacements=0`, `refusals=0`, and no submitted window is a failed arming test. +- A first token such as `IWO` lost before seal is an onset/pre-roll failure. +- Five intentional repetitions on distinct spans must remain five repetitions. +- Replaying the same span identity must not create a duplicate. +- An invalid/Q8 model must be refused before tensor load. +- A corrupt model artifact must not become permanently complete. +- A valid alternate weights file must not be shadowed by an invalid preferred name. +- A valid older HF snapshot must not be shadowed by an invalid newer snapshot. +- Malformed safetensors metadata must fail discovery before engine load. + +## 12. Documentation authority and drift control + +- `THE_ENGINE_CONTRACT.md` defines product invariants. +- This file defines the executable lane map. +- `STT_CONTRACT.md` defines engine/configuration behavior. +- `ENV_REGISTRY.toml` defines supported environment keys. +- Runtime logs and Transcript Bus receipts prove actual execution. +- Historical reports are evidence, never silent authority. +- A comment marked `operator law` requires a direct operator decision reference. +- Agent interpretations may not be attributed to Maciej or Monika. +- Superseded rules remain searchable but must be labeled superseded. +- Tests for superseded behavior must be rewritten or deleted. +- Green tests for the wrong contract are regressions, not reassurance. +- Every contract-changing patch updates code, tests, settings, UI, and docs together. + +_Provenance: distilled from `docs/THE_ENGINE_ROADMAP.md`, the W13 settlement +ledger, `/Users/maciejgad/Downloads/Kora_codescribe.md`, and structural/runtime +verification on `fix/p0-p2-before-release@361ece40`, 2026-08-21._ 𝚅𝚒𝚋𝚎𝚌𝚛𝚊𝚏𝚝𝚎𝚍. with AI Agents by VetCoders (c)2024-2026 LibraxisAI diff --git a/docs/WHISPER_LIVE.md b/docs/WHISPER_LIVE.md index af76d656..1ceea5d9 100644 --- a/docs/WHISPER_LIVE.md +++ b/docs/WHISPER_LIVE.md @@ -1,13 +1,13 @@ # WHISPER LIVE (Embedded Whisper + Streaming Transcription) -> **Status:** DONE ✅ (2026-01-16) · **Re-framed:** 2026-05-26 as Layer 1 + Layer 2 supplement. +> **Status:** DONE ✅ (2026-01-16) · **Re-framed:** 2026-08-22 as the L1 observer feeding deterministic L2 and the existing L3 formatter. > > **Tagline:** Whisper stays local, ships embedded by default, and patches the live overlay in the background — it is no longer the first thing the user sees. -## Role in the layered pipeline (ADR 2026-05-26) +## Role in the canonical four-layer pipeline -Whisper is now **Layer 1 — Tail Patch** and feeds **Layer 2 — Lexicon + LLM Polish** inside the -[Layered Incremental Transcription Pipeline](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md). +Whisper is **L1 — contextual observer** and feeds **L2 — Lexicon + Light+** +inside the canonical [four-layer engine contract](./THE_ENGINE_CONTRACT.md). Live first-pass text in the overlay comes from **Layer 0 — Apple Speech Recognizer** (`CODESCRIBE_STT_ENGINE=apple`); Whisper runs on the same audio tail in the background, diffs against Layer 0's committed buffer, and emits `EngineEvent::ReplaceRange { source: TailPatch }` @@ -15,20 +15,23 @@ events that visibly patch tokens Apple missed — mixed-language inserts, rare t nouns. The legacy "Whisper-as-primary" path stays as automatic fallback when Apple Speech is unavailable (no permission, no macOS Speech framework). -> **Delivery status (2026-08-14).** Layer 1 is wired on **both** live paths — VAD/scheduler and -> the default Apple progressive live (`a6b1233d`) — and is **on by default**: -> `CODESCRIBE_LAYERED_TRANSCRIPTION` unset → `phase1` (operator directive -> 2026-08-09). Explicit `off`/`0`/`false` disarms. Whisper also still earns -> its keep at **stop time** (`FINAL_PASS_MODE`, Smart by default) as the -> residual file pass, never as a live full-replace. W13 fusion / -> idempotence / highlights stay OFF. Layer 2's inline LLM, Layer 3 and -> Layer 4 have no producer at all — see the ADR's -> [Phase delivery status](./ADR/2026-05-26-LAYERED_INCREMENTAL_TRANSCRIPTION.md#phase-delivery-status-2026-08-08). - -**Hard invariant that gates every Whisper write:** _NEVER REWRITE FROM ZERO._ Tail Patch may -only `ReplaceRange` inside the utterance window Layer 0 already committed. If the diff distance -exceeds the safety threshold, the patch is dropped (annotation emitted) and Layer 0 output stands. -See the ADR for the full contract. +> **Delivery status (2026-08-22).** Local Power + Apple/Auto arms the exact-span +> Apple progressive L1 path; VAD/Whisper-first uses Whisper as its primary +> observer and refuses a second unbound patcher. Normal stop never performs a +> hidden Whisper file pass; full-file decoding belongs only to explicit +> Retranscribe/HQ. L2 Lexicon + Light+ is currently wired. L3 uses the existing +> Responses Formatting lane behind `CODESCRIBE_INLINE_FORMAT`; “inline” names +> scheduling, not another model or client. + +The contract has exactly four machine layers. Silero remains orthogonal VAD/time +evidence, with richer annotations optional and provider-bound. Final BAM is +superseded and has no automatic producer; `SessionFinalised` is lifecycle-only. + +**Hard invariant that gates every Whisper write:** _NEVER REWRITE FROM ZERO._ +Tail Patch may only `ReplaceRange` inside a proven PCM/span identity. Text +alignment and change ratios may judge a candidate after authority is +established, but they never mint authority. Unproven or cross-span work fails +closed. See the engine contract for the full rule. ## TL;DR @@ -45,8 +48,9 @@ Codescribe’s Whisper layer power-ups: deltas — the user sees Layer 0 first, then watches Whisper magically correct mixed-language / terminology tokens within ~1 s of utterance end - In fallback (no Apple): Whisper takes over the live preview path, behaving like pre-ADR builds -3. **Full WAV is always teed to disk** — Layer 1 reads from this persistent tail (no extra mic load), - Layer 4 (Final BAM) reuses the same WAV at session end +3. **Full WAV is always teed to disk** — L1 may read retained PCM without a + second microphone; the saved WAV remains available for explicit + Retranscribe/HQ and diagnostics, not an automatic fifth layer ## What we shipped @@ -94,21 +98,22 @@ Practical win: consumed and extended outside the tray flow. After the ADR, the IPC contract also carries `ReplaceRange` and `InsertAnnotation` events for clients that render the layered view. - **Quality loop/report** (`bin/codescribe_quality`, `bin/codescribe_loop`) — automated scoring and - batch diagnostics. The layered telemetry adds per-layer counters (utterances patched, LLM calls, - annotations inserted) so regression hunts can target the right layer. + batch diagnostics. Layer receipts identify Whisper proposals, L2 shaping, + L3 formatting outcomes, and orthogonal timing evidence so regression hunts + target the right owner. - **Cloud STT** — optional Layer 1 backend (libraxis cluster / OpenAI whisper-1 / `mlx-audio` + `openai/whisper-large-v3`). Latency vs. privacy trade-off lives in Settings; not live preview. ## Layer mapping for this file -| Section below | Layer it lights up | -| ----------------------------------------------- | ------------------------------------------------------------------- | -| Embedded Whisper (build + runtime lookup) | Layer 1 (Tail Patch) backend resolution | -| Streaming transcription, chunker, overlap dedup | Layer 1 background pass on utterance tail | -| Stream postprocess, semantic gate | Pre-diff cleanup feeding Layer 1's `ReplaceRange` decision | -| Cloud STT alternatives | Pluggable Layer 1 backend | -| Lexicon substitution (`apply_lexicon`) | Layer 2 — delivered at seal time, not as the ADR's debounced module | -| Small inline LLM pass (Phase 2, proposed) | ❌ not built — no `core/llm/inline_polish.rs` | +| Section below | Layer it lights up | +| ----------------------------------------------- | ---------------------------------------------------------------- | +| Embedded Whisper (build + runtime lookup) | Layer 1 (Tail Patch) backend resolution | +| Streaming transcription, chunker, overlap dedup | Layer 1 background pass on utterance tail | +| Stream postprocess, semantic gate | Pre-diff cleanup feeding Layer 1's `ReplaceRange` decision | +| Cloud STT alternatives | Pluggable Layer 1 backend | +| Lexicon substitution + Light+ | L2 — deterministic and currently wired at seal/delivery | +| Inline formatting scheduler | L3 — existing Responses Formatting lane; no separate small model | Everything below this point is the same Whisper-Live tech that existed before the ADR — it is **not removed**, just relocated in the architecture: Whisper became the silent partner that makes diff --git a/docs/env.md b/docs/env.md index 5047a12e..d6293fdb 100644 --- a/docs/env.md +++ b/docs/env.md @@ -152,6 +152,10 @@ i runtime nie może znaleźć Whispera przez cache / config: ## D) Pełny podział działowy (wszystkie zmienne) +### Runtime aplikacji + +- `CODESCRIBE_APP_RUNTIME_WORKERS` (RESTART NEEDED; default `4`; clamp `1..16`) — liczba nazwanych workerów jedynego runtime'u Tokio należącego do Codescribe.app. Eksporty async UniFFI są natychmiast przenoszone na ten executor; wartość jest odczytywana wyłącznie przy pierwszym starcie runtime'u. + ### Audio - `AUDIO_INPUT_DEVICE` – nazwa urządzenia wejściowego (RESTART NEEDED) @@ -167,7 +171,7 @@ i runtime nie może znaleźć Whispera przez cache / config: - `CODESCRIBE_WHISPER_INITIAL_PROMPT` (RESTART NEEDED; alias legacy: `WHISPER_INITIAL_PROMPT`; ignorowane przez ONNX) - `STT_ENDPOINT`, `STT_API_KEY` (RESTART NEEDED) - `FINAL_PASS_MODE` (legacy; `always|smart|off`; alias `CODESCRIBE_FINAL_PASS_MODE`) — zachowany do migracji ustawień, ale zwykły stop nie wykonuje już żadnego file-passu. Pełny WAV trafia do STT wyłącznie po jawnej akcji Retranscribe (Overlay/Dictionary/Teacher). Live refinement wybiera osobny `CODESCRIBE_LAYERED_TRANSCRIPTION`; słownik/lexicon zawsze działa w postprocess. -- `CODESCRIBE_LAYERED_TRANSCRIPTION` (HOT RELOADED; default `phase1`; `phase1`..`phase4` lub bare `1`..`4`; jawne `off`/`0`/`false` rozbraja) — ortogonalny gate warstwowej transkrypcji. Unset → `phase1` od 2026-08-09. `phase1+` = Layer 1 Whisper tail-patch (`ReplaceRange`) na **obu** ścieżkach live: VAD/scheduler oraz domyślnym Apple progressive live (W2-A — gap-fill z zatrzymanego PCM, max jeden job w locie, nierozwiązane okno nigdy nie trafia do Whispera). Promoted do `settings.json`. Smart final-pass **nie** ustawia tej flagi. +- `CODESCRIBE_LAYERED_TRANSCRIPTION` (HOT RELOADED; promoted compatibility override) — Local Power + Apple/Auto uzbraja dokładnie ogrodzony Whisper tail-patch przy braku wartości lub `phase1`; jawne `off`/`0`/`false`/`no` albo błędny token daje named degraded state. Apple-only nie uzbraja lane. Bezpośredni Whisper na VAD/scheduler jest primary engine i odmawia drugiej, unbound mutation lane. Per-take prawdą jest `tail_patch_session_receipt`, nie stan UI. Final pass pozostaje ortogonalny. - `STT_TAIL_PROVIDER` (HOT RELOADED; default `inprocess`) — wybiera implementację kontraktu tail-patch: `inprocess`, nadzorowany lokalny `sidecar` po WebSocket albo `remote` po multipart. Awaria sidecara/remote przechodzi do in-process z typed receiptem; zmiana defaultu pozostaje guzikiem operatora. - `CODESCRIBE_STT_SIDECAR_BIN` (RESTART NEEDED; dev only) — jawna ścieżka do helpera; aplikacja dystrybucyjna automatycznie znajduje `codescribe-stt-sidecar` obok własnego executable. - `CODESCRIBE_TAIL_PATCH_MAX_CHANGE_RATIO` (HOT RELOADED; default `0.5`) — próg bezpieczeństwa Layer 1: jeśli udział zmienionych znaków wobec zatwierdzonej wypowiedzi przekracza tę wartość, cała łatka jest **odrzucana** zamiast nałożona. Dzięki temu rozbieżna re-transkrypcja nigdy nie nadpisze żywego płótna. diff --git a/docs/loopback.html b/docs/loopback.html index 396e08e1..5d4841c3 100644 --- a/docs/loopback.html +++ b/docs/loopback.html @@ -1,16 +1,19 @@ - + - - - codescribe loopback - - -

codescribe loopback

-

Dev install pointers. Do not rewrite these URLs.

-
    -
  • Voice Lab — http://127.0.0.1:8765/lab
  • -
  • STT file HTTP — http://127.0.0.1:8444/v1/audio/transcriptions
  • -
  • STT live WebSocket — ws://127.0.0.1:8446
  • -
- + + + codescribe loopback + + +

codescribe loopback

+

Dev install pointers. Do not rewrite these URLs.

+
    +
  • Voice Lab — http://127.0.0.1:8765/lab
  • +
  • + STT file HTTP — + http://127.0.0.1:8444/v1/audio/transcriptions +
  • +
  • STT live WebSocket — ws://127.0.0.1:8446
  • +
+ diff --git a/macos/Codescribe/App.swift b/macos/Codescribe/App.swift index 1d9da126..b071b10f 100644 --- a/macos/Codescribe/App.swift +++ b/macos/Codescribe/App.swift @@ -181,7 +181,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Honour the persisted "Show Dock Icon" toggle at launch. LSUIElement // makes us an accessory by default; promote to .regular when enabled so // the launch state matches the tray toggle. - NSApp.setActivationPolicy(config.trayToggles().showDockIcon ? .regular : .accessory) + let trayToggles = config.trayToggles() + NSApp.setActivationPolicy(trayToggles.showDockIcon ? .regular : .accessory) + + do { + let runtime = try startApplicationRuntime() + appLogger.info( + "Application runtime started with \(runtime.workerCount, privacy: .public) workers: \(runtime.workerNames.joined(separator: ","), privacy: .public)" + ) + } catch { + appLogger.fault("Application runtime failed to start: \(error.localizedDescription, privacy: .public)") + NSApp.terminate(nil) + return + } DistributedNotificationCenter.default().addObserver( self, @@ -469,13 +481,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Mirrors the launch guards: the test host never started hotkeys, and // touching the lazy handle here would construct the bridge at teardown // purely to stop something that was never running. - guard !Self.isRunningTests else { return } + guard !shouldExitForDuplicate, !Self.isRunningTests else { return } VoiceLabRuntime.stopOwnedProcess() hotkeys.stop() sleepWakeObserver?.invalidate() sleepWakeObserver = nil if let textScaleMonitor { NSEvent.removeMonitor(textScaleMonitor) } DistributedNotificationCenter.default().removeObserver(self) + do { + let runtime = try shutdownApplicationRuntime() + appLogger.info( + "Application runtime stopped with \(runtime.activeTasks, privacy: .public) owned tasks and \(runtime.stoppedWorkerNames.count, privacy: .public) stopped workers" + ) + } catch { + appLogger.error( + "Application runtime shutdown failed: \(error.localizedDescription, privacy: .public)" + ) + } } /// Bind the active recorder to the real host power lifecycle. diff --git a/macos/Codescribe/Bridge/codescribe_ffi.swift b/macos/Codescribe/Bridge/codescribe_ffi.swift index cfc8ec1e..143461e5 100644 --- a/macos/Codescribe/Bridge/codescribe_ffi.swift +++ b/macos/Codescribe/Bridge/codescribe_ffi.swift @@ -6995,6 +6995,103 @@ public func FfiConverterTypeCsApiKeyProbeResult_lower(_ value: CsApiKeyProbeResu } +/** + * Observable, content-free runtime lifecycle evidence for Swift and probes. + */ +public struct CsApplicationRuntimeSnapshot: Equatable, Hashable { + /** + * `not_started`, `running`, or `stopped`. + */ + public var state: String + /** + * Configured async worker count for this process. + */ + public var workerCount: UInt32 + /** + * Named async workers observed entering the runtime. + */ + public var workerNames: [String] + /** + * Named async workers observed leaving the runtime. + */ + public var stoppedWorkerNames: [String] + /** + * Root bridge tasks currently owned by the runtime adapter. + */ + public var activeTasks: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * `not_started`, `running`, or `stopped`. + */state: String, + /** + * Configured async worker count for this process. + */workerCount: UInt32, + /** + * Named async workers observed entering the runtime. + */workerNames: [String], + /** + * Named async workers observed leaving the runtime. + */stoppedWorkerNames: [String], + /** + * Root bridge tasks currently owned by the runtime adapter. + */activeTasks: UInt64) { + self.state = state + self.workerCount = workerCount + self.workerNames = workerNames + self.stoppedWorkerNames = stoppedWorkerNames + self.activeTasks = activeTasks + } + + +} + +#if compiler(>=6) +extension CsApplicationRuntimeSnapshot: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeCsApplicationRuntimeSnapshot: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CsApplicationRuntimeSnapshot { + return + try CsApplicationRuntimeSnapshot( + state: FfiConverterString.read(from: &buf), + workerCount: FfiConverterUInt32.read(from: &buf), + workerNames: FfiConverterSequenceString.read(from: &buf), + stoppedWorkerNames: FfiConverterSequenceString.read(from: &buf), + activeTasks: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: CsApplicationRuntimeSnapshot, into buf: inout [UInt8]) { + FfiConverterString.write(value.state, into: &buf) + FfiConverterUInt32.write(value.workerCount, into: &buf) + FfiConverterSequenceString.write(value.workerNames, into: &buf) + FfiConverterSequenceString.write(value.stoppedWorkerNames, into: &buf) + FfiConverterUInt64.write(value.activeTasks, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsApplicationRuntimeSnapshot_lift(_ buf: RustBuffer) throws -> CsApplicationRuntimeSnapshot { + return try FfiConverterTypeCsApplicationRuntimeSnapshot.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeCsApplicationRuntimeSnapshot_lower(_ value: CsApplicationRuntimeSnapshot) -> RustBuffer { + return FfiConverterTypeCsApplicationRuntimeSnapshot.lower(value) +} + + /** * One outgoing composer attachment. Path-based on purpose: the bridge reads and * validates the file on the Rust side (via `load_image_for_vision`), which is @@ -9081,13 +9178,19 @@ public struct CsQualityCommitResult: Equatable, Hashable { */ public var pairsLearned: UInt32 /** - * True when the formatting level is not Correction. + * True when no custom-lexicon pair was learned: non-teach evidence, + * filtered edits, or an explicit teach still below its N-correction gate. */ public var evidenceOnly: Bool /** * Ready-to-show overlay toast text ("Saved — N pair(s) learned" / "Saved as evidence"). */ public var acknowledgement: String + /** + * Structured progress for one normalized lexical pair. + */ + public var teachSeen: UInt64? + public var teachRequired: UInt64? // Default memberwise initializers are never public by default, so we // declare one manually. @@ -9096,14 +9199,20 @@ public struct CsQualityCommitResult: Equatable, Hashable { * Lexicon pairs actually upserted (0 when evidence-only or filtered out). */pairsLearned: UInt32, /** - * True when the formatting level is not Correction. + * True when no custom-lexicon pair was learned: non-teach evidence, + * filtered edits, or an explicit teach still below its N-correction gate. */evidenceOnly: Bool, /** * Ready-to-show overlay toast text ("Saved — N pair(s) learned" / "Saved as evidence"). - */acknowledgement: String) { + */acknowledgement: String, + /** + * Structured progress for one normalized lexical pair. + */teachSeen: UInt64?, teachRequired: UInt64?) { self.pairsLearned = pairsLearned self.evidenceOnly = evidenceOnly self.acknowledgement = acknowledgement + self.teachSeen = teachSeen + self.teachRequired = teachRequired } @@ -9122,7 +9231,9 @@ public struct FfiConverterTypeCsQualityCommitResult: FfiConverterRustBuffer { try CsQualityCommitResult( pairsLearned: FfiConverterUInt32.read(from: &buf), evidenceOnly: FfiConverterBool.read(from: &buf), - acknowledgement: FfiConverterString.read(from: &buf) + acknowledgement: FfiConverterString.read(from: &buf), + teachSeen: FfiConverterOptionUInt64.read(from: &buf), + teachRequired: FfiConverterOptionUInt64.read(from: &buf) ) } @@ -9130,6 +9241,8 @@ public struct FfiConverterTypeCsQualityCommitResult: FfiConverterRustBuffer { FfiConverterUInt32.write(value.pairsLearned, into: &buf) FfiConverterBool.write(value.evidenceOnly, into: &buf) FfiConverterString.write(value.acknowledgement, into: &buf) + FfiConverterOptionUInt64.write(value.teachSeen, into: &buf) + FfiConverterOptionUInt64.write(value.teachRequired, into: &buf) } } @@ -9159,6 +9272,7 @@ public struct CsQualityRecord: Equatable, Hashable { public var variant: String public var editedText: String public var action: String + public var editProvenance: String? public var timestampMs: UInt64 public var avgLogprob: Float? public var speechPct: Float? @@ -9166,13 +9280,14 @@ public struct CsQualityRecord: Equatable, Hashable { // Default memberwise initializers are never public by default, so we // declare one manually. - public init(id: String, revision: UInt64, rawText: String, variant: String, editedText: String, action: String, timestampMs: UInt64, avgLogprob: Float?, speechPct: Float?, confidenceFlags: [String]) { + public init(id: String, revision: UInt64, rawText: String, variant: String, editedText: String, action: String, editProvenance: String?, timestampMs: UInt64, avgLogprob: Float?, speechPct: Float?, confidenceFlags: [String]) { self.id = id self.revision = revision self.rawText = rawText self.variant = variant self.editedText = editedText self.action = action + self.editProvenance = editProvenance self.timestampMs = timestampMs self.avgLogprob = avgLogprob self.speechPct = speechPct @@ -9199,6 +9314,7 @@ public struct FfiConverterTypeCsQualityRecord: FfiConverterRustBuffer { variant: FfiConverterString.read(from: &buf), editedText: FfiConverterString.read(from: &buf), action: FfiConverterString.read(from: &buf), + editProvenance: FfiConverterOptionString.read(from: &buf), timestampMs: FfiConverterUInt64.read(from: &buf), avgLogprob: FfiConverterOptionFloat.read(from: &buf), speechPct: FfiConverterOptionFloat.read(from: &buf), @@ -9213,6 +9329,7 @@ public struct FfiConverterTypeCsQualityRecord: FfiConverterRustBuffer { FfiConverterString.write(value.variant, into: &buf) FfiConverterString.write(value.editedText, into: &buf) FfiConverterString.write(value.action, into: &buf) + FfiConverterOptionString.write(value.editProvenance, into: &buf) FfiConverterUInt64.write(value.timestampMs, into: &buf) FfiConverterOptionFloat.write(value.avgLogprob, into: &buf) FfiConverterOptionFloat.write(value.speechPct, into: &buf) @@ -11044,6 +11161,8 @@ public enum CsError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError ) case Quality(msg: String ) + case Runtime(msg: String + ) @@ -11086,6 +11205,9 @@ public struct FfiConverterTypeCsError: FfiConverterRustBuffer { case 5: return .Quality( msg: try FfiConverterString.read(from: &buf) ) + case 6: return .Runtime( + msg: try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } @@ -11122,6 +11244,11 @@ public struct FfiConverterTypeCsError: FfiConverterRustBuffer { writeInt(&buf, Int32(5)) FfiConverterString.write(msg, into: &buf) + + case let .Runtime(msg): + writeInt(&buf, Int32(6)) + FfiConverterString.write(msg, into: &buf) + } } } @@ -13116,6 +13243,15 @@ fileprivate func uniffiFutureContinuationCallback(handle: UInt64, pollResult: In print("uniffiFutureContinuationCallback invalid handle") } } +/** + * Content-free lifecycle snapshot used by diagnostics and delivery probes. + */ +public func applicationRuntimeSnapshot()throws -> CsApplicationRuntimeSnapshot { + return try FfiConverterTypeCsApplicationRuntimeSnapshot_lift(try rustCallWithError(FfiConverterTypeCsError_lift) { + uniffi_codescribe_ffi_fn_func_application_runtime_snapshot($0 + ) +}) +} /** * Enumerate live input hardware and resolve the effective recorder device. * Failures cross the bridge as one `CsError::Recording` concern; no device @@ -13129,18 +13265,20 @@ public func audioInputSnapshot()throws -> CsAudioInputSnapshot { } /** * Persist one overlay correction: the quality record always lands, while lexicon - * learning is gated by `formatting_level`. + * learning is gated by explicit teach action plus the N-correction threshold. * - * Only the Correction level teaches word pairs; higher levels are recorded as - * evidence with `pairs_learned = 0`, because a creative rewrite is not a - * transcription fix and would poison the lexicon. An unrecognised - * `formatting_level` is rejected before anything is written. + * Only explicit Correction-level teach gestures can contribute word pairs; + * higher levels are recorded as evidence with `pairs_learned = 0`, because a + * creative rewrite is not a transcription fix and would poison the lexicon. + * A qualifying teach remains evidence until its identical pair reaches the + * configured threshold. An unrecognised `formatting_level` is rejected before + * anything is written. * * The confidence fields (`avg_logprob`, `speech_pct`, `confidence_flags`) are * stored alongside the text so later analysis can correlate corrections with how * unsure the engine was. */ -public func commitOverlayQualityRecord(rawText: String, deliveredText: String, editedText: String, action: String, formattingLevel: String, avgLogprob: Float?, speechPct: Float?, confidenceFlags: [String])throws -> CsQualityCommitResult { +public func commitOverlayQualityRecord(rawText: String, deliveredText: String, editedText: String, action: String, formattingLevel: String, editProvenance: String?, avgLogprob: Float?, speechPct: Float?, confidenceFlags: [String])throws -> CsQualityCommitResult { return try FfiConverterTypeCsQualityCommitResult_lift(try rustCallWithError(FfiConverterTypeCsError_lift) { uniffi_codescribe_ffi_fn_func_commit_overlay_quality_record( FfiConverterString.lower(rawText), @@ -13148,6 +13286,7 @@ public func commitOverlayQualityRecord(rawText: String, deliveredText: String, e FfiConverterString.lower(editedText), FfiConverterString.lower(action), FfiConverterString.lower(formattingLevel), + FfiConverterOptionString.lower(editProvenance), FfiConverterOptionFloat.lower(avgLogprob), FfiConverterOptionFloat.lower(speechPct), FfiConverterSequenceString.lower(confidenceFlags),$0 @@ -13301,6 +13440,25 @@ public func requestMicPermission() -> Bool { ) }) } +/** + * Stop controller/account activity first, then tear down every runtime worker. + */ +public func shutdownApplicationRuntime()throws -> CsApplicationRuntimeSnapshot { + return try FfiConverterTypeCsApplicationRuntimeSnapshot_lift(try rustCallWithError(FfiConverterTypeCsError_lift) { + uniffi_codescribe_ffi_fn_func_shutdown_application_runtime($0 + ) +}) +} +/** + * Start the one process-owned async runtime. Idempotent while running; once + * shut down it cannot be restarted in the same process. + */ +public func startApplicationRuntime()throws -> CsApplicationRuntimeSnapshot { + return try FfiConverterTypeCsApplicationRuntimeSnapshot_lift(try rustCallWithError(FfiConverterTypeCsError_lift) { + uniffi_codescribe_ffi_fn_func_start_application_runtime($0 + ) +}) +} /** * Snapshot Whisper availability without constructing a dictation session. */ @@ -13326,10 +13484,13 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } + if (uniffi_codescribe_ffi_checksum_func_application_runtime_snapshot() != 28624) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_func_audio_input_snapshot() != 64324) { return InitializationResult.apiChecksumMismatch } - if (uniffi_codescribe_ffi_checksum_func_commit_overlay_quality_record() != 61070) { + if (uniffi_codescribe_ffi_checksum_func_commit_overlay_quality_record() != 33612) { return InitializationResult.apiChecksumMismatch } if (uniffi_codescribe_ffi_checksum_func_current_serving_verdict() != 14135) { @@ -13371,6 +13532,12 @@ private let initializationResult: InitializationResult = { if (uniffi_codescribe_ffi_checksum_func_request_mic_permission() != 61967) { return InitializationResult.apiChecksumMismatch } + if (uniffi_codescribe_ffi_checksum_func_shutdown_application_runtime() != 56989) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_codescribe_ffi_checksum_func_start_application_runtime() != 55152) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_codescribe_ffi_checksum_func_whisper_model_status() != 33505) { return InitializationResult.apiChecksumMismatch } diff --git a/macos/Codescribe/Bridge/codescribe_ffiFFI.h b/macos/Codescribe/Bridge/codescribe_ffiFFI.h index 75d8e6d2..bd21c295 100644 --- a/macos/Codescribe/Bridge/codescribe_ffiFFI.h +++ b/macos/Codescribe/Bridge/codescribe_ffiFFI.h @@ -1563,6 +1563,12 @@ void uniffi_codescribe_ffi_fn_method_cswhisperdownloadlistener_on_progress(uint6 #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CSWHISPERDOWNLOADLISTENER_ON_COMPLETE #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_METHOD_CSWHISPERDOWNLOADLISTENER_ON_COMPLETE void uniffi_codescribe_ffi_fn_method_cswhisperdownloadlistener_on_complete(uint64_t ptr, RustBuffer path, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_APPLICATION_RUNTIME_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_APPLICATION_RUNTIME_SNAPSHOT +RustBuffer uniffi_codescribe_ffi_fn_func_application_runtime_snapshot(RustCallStatus *_Nonnull out_status + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_AUDIO_INPUT_SNAPSHOT @@ -1573,7 +1579,7 @@ RustBuffer uniffi_codescribe_ffi_fn_func_audio_input_snapshot(RustCallStatus *_N #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_COMMIT_OVERLAY_QUALITY_RECORD #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_COMMIT_OVERLAY_QUALITY_RECORD -RustBuffer uniffi_codescribe_ffi_fn_func_commit_overlay_quality_record(RustBuffer raw_text, RustBuffer delivered_text, RustBuffer edited_text, RustBuffer action, RustBuffer formatting_level, RustBuffer avg_logprob, RustBuffer speech_pct, RustBuffer confidence_flags, RustCallStatus *_Nonnull out_status +RustBuffer uniffi_codescribe_ffi_fn_func_commit_overlay_quality_record(RustBuffer raw_text, RustBuffer delivered_text, RustBuffer edited_text, RustBuffer action, RustBuffer formatting_level, RustBuffer edit_provenance, RustBuffer avg_logprob, RustBuffer speech_pct, RustBuffer confidence_flags, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_CURRENT_SERVING_VERDICT @@ -1645,6 +1651,18 @@ RustBuffer uniffi_codescribe_ffi_fn_func_quality_teach_span(RustBuffer variant, #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_REQUEST_MIC_PERMISSION int8_t uniffi_codescribe_ffi_fn_func_request_mic_permission(RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_SHUTDOWN_APPLICATION_RUNTIME +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_SHUTDOWN_APPLICATION_RUNTIME +RustBuffer uniffi_codescribe_ffi_fn_func_shutdown_application_runtime(RustCallStatus *_Nonnull out_status + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_START_APPLICATION_RUNTIME +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_START_APPLICATION_RUNTIME +RustBuffer uniffi_codescribe_ffi_fn_func_start_application_runtime(RustCallStatus *_Nonnull out_status + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_FN_FUNC_WHISPER_MODEL_STATUS @@ -1911,6 +1929,12 @@ void ffi_codescribe_ffi_rust_future_free_void(uint64_t handle #ifndef UNIFFI_FFIDEF_FFI_CODESCRIBE_FFI_RUST_FUTURE_COMPLETE_VOID #define UNIFFI_FFIDEF_FFI_CODESCRIBE_FFI_RUST_FUTURE_COMPLETE_VOID void ffi_codescribe_ffi_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_APPLICATION_RUNTIME_SNAPSHOT +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_APPLICATION_RUNTIME_SNAPSHOT +uint16_t uniffi_codescribe_ffi_checksum_func_application_runtime_snapshot(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_AUDIO_INPUT_SNAPSHOT @@ -2001,6 +2025,18 @@ uint16_t uniffi_codescribe_ffi_checksum_func_quality_teach_span(void #define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_REQUEST_MIC_PERMISSION uint16_t uniffi_codescribe_ffi_checksum_func_request_mic_permission(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_SHUTDOWN_APPLICATION_RUNTIME +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_SHUTDOWN_APPLICATION_RUNTIME +uint16_t uniffi_codescribe_ffi_checksum_func_shutdown_application_runtime(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_START_APPLICATION_RUNTIME +#define UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_START_APPLICATION_RUNTIME +uint16_t uniffi_codescribe_ffi_checksum_func_start_application_runtime(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CODESCRIBE_FFI_CHECKSUM_FUNC_WHISPER_MODEL_STATUS diff --git a/macos/Codescribe/Core/AppModel.swift b/macos/Codescribe/Core/AppModel.swift index 7b001bb5..bf552d2d 100644 --- a/macos/Codescribe/Core/AppModel.swift +++ b/macos/Codescribe/Core/AppModel.swift @@ -187,11 +187,7 @@ final class OverlayController: ObservableObject { return } guard overlayEnabledProvider() else { - if DictationOverlayGate.isLabModeOn() { - DictationOverlayGate.logger.info("overlay suppressed: lab_mode") - } else { - DictationOverlayGate.logger.info("overlay suppressed: tray toggle off") - } + DictationOverlayGate.logger.info("overlay suppressed: tray toggle off") if panel != nil { hide() } return } diff --git a/macos/Codescribe/Core/DeveloperSurface.swift b/macos/Codescribe/Core/DeveloperSurface.swift index 81e8aa40..ad4423fe 100644 --- a/macos/Codescribe/Core/DeveloperSurface.swift +++ b/macos/Codescribe/Core/DeveloperSurface.swift @@ -42,9 +42,9 @@ enum DictationOverlayGate { static func shouldShowOverlay( trayEnabled: Bool, - defaults: UserDefaults = .standard, - surfaceEnabled: Bool? = nil + defaults _: UserDefaults = .standard, + surfaceEnabled _: Bool? = nil ) -> Bool { - trayEnabled && !isLabModeOn(defaults: defaults, surfaceEnabled: surfaceEnabled) + trayEnabled } } diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift index b1dfcd6b..ddc7544e 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingSteps.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI // Individual step bodies for the first-run wizard. Welcome, Permission (reused @@ -237,7 +238,7 @@ struct HotkeyModeStepView: View { } } -// MARK: - Agentic readiness (agentic lane only — informational) +// MARK: - Agentic readiness (agentic lane only) struct AgenticReadinessStepView: View { @ObservedObject var model: OnboardingViewModel @@ -254,8 +255,8 @@ struct AgenticReadinessStepView: View { OnboardingStepHeader( eyebrow: "Agentic readiness", title: "Your agentic substrate.", - blurb: "A read-only check of what the agent lane needs: an AI " - + "provider + key, native tools, and any MCP servers you've wired.") + blurb: "Check what the agent lane needs, then optionally install the " + + "named live-session bridge for your agent client.") Spacer(minLength: 0) } @@ -264,6 +265,8 @@ struct AgenticReadinessStepView: View { statusCard(rows: readiness.rows) } + agentBridgeSetup + if let mcp = model.mcpStatus, mcp.configured { Text("MCP servers") .font(CSFont.mono(10, .semibold)) @@ -282,8 +285,85 @@ struct AgenticReadinessStepView: View { .padding(.top, 2) OnboardingStepNote( - text: "Informational — press Continue whether or not everything is green.") + text: + "Bridge install is explicit and optional — Continue whether or not everything is green.") + } + } + + /// Product install for the external named-session bridge. The checkboxes are + /// deliberately empty on first run; visiting this step performs no writes. + /// Reopening Setup seeds clients from the managed receipt for an explicit + /// reinstall/update or a safe deselection. + private var agentBridgeSetup: some View { + VStack(alignment: .leading, spacing: 10) { + Text("LIVE AGENT BRIDGE") + .font(CSFont.mono(10, .semibold)) + .tracking(0.4) + .foregroundStyle(CSColor.textFaint) + Text(model.agentBridgeTitle) + .font(CSFont.ui(15, .bold)) + .foregroundStyle(CSColor.textHigh) + Text(model.agentBridgeExplanation) + .font(CSFont.ui(12.5)) + .lineSpacing(3) + .foregroundStyle(CSColor.textMutedAlt) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 8) { + ForEach(AgentBridgeClient.allCases) { client in + OnboardingChoiceCard( + title: client.displayName, + subtitle: client == .codex + ? "~/.codex/skills/codescribe" + : "~/.claude/skills/codescribe", + isSelected: model.selectedAgentClients.contains(client) + ) { model.toggleAgentClient(client) } + } + } + + HStack(spacing: 10) { + OnboardingButton(title: model.agentBridgeButtonTitle, kind: .primary) { + model.installAgentBridge() + } + .disabled( + model.selectedAgentClients.isEmpty || !model.agentBridgeStatus.payloadAvailable + ) + Text(model.agentBridgeStatus.detail) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.textFaint) + .fixedSize(horizontal: false, vertical: true) + } + + if !model.agentBridgeStatus.installedPaths.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(model.agentBridgeStatus.installedPaths, id: \.self) { path in + Text(path.replacingOccurrences(of: NSHomeDirectory(), with: "~")) + .font(CSFont.mono(9.5, .medium)) + .foregroundStyle(CSColor.oliveLight) + .textSelection(.enabled) + } + } + } + if let error = model.agentBridgeError { + Text(error) + .font(CSFont.mono(10.5, .medium)) + .foregroundStyle(CSColor.terracottaLight) + .fixedSize(horizontal: false, vertical: true) + } + Text("Stable helper: ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py") + .font(CSFont.mono(9.5, .medium)) + .foregroundStyle(CSColor.textFaint) + .textSelection(.enabled) } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(CSColor.surfaceRaised(0.02)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(CSColor.hairline(0.07), lineWidth: 1)) } /// Shown on the readiness step when no MCP server is configured yet: a short, diff --git a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift index b0e289b8..6f49ba78 100644 --- a/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift +++ b/macos/Codescribe/Screens/Onboarding/OnboardingViewModel.swift @@ -115,6 +115,9 @@ final class OnboardingViewModel: ObservableObject { // Agentic-readiness step state (lazy — probed when the step appears). @Published private(set) var readiness: CsAgenticReadiness? @Published private(set) var mcpStatus: CsMcpStatusReport? + @Published private(set) var agentBridgeStatus: AgentBridgeInstallationStatus + @Published private(set) var selectedAgentClients: Set + @Published private(set) var agentBridgeError: String? /// Whether the user dismissed the "set up MCP" prompt shown when no MCP server /// is configured. Session-only: skipping keeps the readiness step moving without @@ -131,6 +134,7 @@ final class OnboardingViewModel: ObservableObject { private let engine: OnboardingEngine private let hotkeys: HotkeysEngine private let agentStatus: AgentStatusEngine + private let agentBridge: AgentBridgeInstalling private let probe: PermissionProbing /// Invoked when the wizard is finished (Done confirmed) so the host can close @@ -141,11 +145,14 @@ final class OnboardingViewModel: ObservableObject { engine: OnboardingEngine, hotkeys: HotkeysEngine = RealHotkeysEngine(), agentStatus: AgentStatusEngine = RealAgentStatusEngine(), + agentBridge: AgentBridgeInstalling = RealAgentBridgeInstaller(), probe: PermissionProbing = NativePermissionProbe() ) { + let bridgeStatus = agentBridge.status() self.engine = engine self.hotkeys = hotkeys self.agentStatus = agentStatus + self.agentBridge = agentBridge self.probe = probe // Resume from the persisted step; `onboardingProgress` is already clamped // to a valid index by the Rust side. @@ -155,6 +162,9 @@ final class OnboardingViewModel: ObservableObject { self.onboardingMode = OnboardingModeChoice.from(engine.onboardingMode()) self.selectedLanguage = engine.currentLanguage() self.hotkeyMode = HotkeyModeChoice.derive(from: hotkeys.modeBindings()) + self.agentBridgeStatus = bridgeStatus + self.selectedAgentClients = Set(bridgeStatus.installedClients) + self.agentBridgeError = nil self.selectedProviderId = engine.assistiveProvider() ?? engine.availableProviders().first?.id @@ -180,6 +190,30 @@ final class OnboardingViewModel: ObservableObject { providers.first { $0.id == selectedProviderId } ?? providers.first } + var agentBridgeUsesPolishCopy: Bool { selectedLanguage == .polish } + + var agentBridgeTitle: String { + agentBridgeUsesPolishCopy + ? "Połącz Codescribe z agentem." + : "Connect Codescribe to your agent." + } + + var agentBridgeExplanation: String { + if agentBridgeUsesPolishCopy { + return "Agent słyszy szkice na żywo tylko wtedy, gdy zwracasz się do niego po imieniu. " + + "Może odpowiedzieć w przerwie, ale instalację, commit, usuwanie i inne zmiany " + + "wykonuje dopiero po transcript_sealed." + } + return "The named agent can hear live drafts and reply during the pause. Installation, " + + "commits, deletion, and every other state-changing action wait for transcript_sealed." + } + + var agentBridgeButtonTitle: String { + agentBridgeStatus.installedClients.isEmpty + ? (agentBridgeUsesPolishCopy ? "Zainstaluj wybrane" : "Install selected") + : (agentBridgeUsesPolishCopy ? "Zainstaluj ponownie" : "Reinstall selected") + } + // MARK: - Lifecycle refresh /// Refresh live state when a step (re)appears. Called on view `onAppear` and @@ -215,6 +249,27 @@ final class OnboardingViewModel: ObservableObject { func refreshReadiness() { readiness = agentStatus.agenticReadiness() mcpStatus = agentStatus.mcpStatus() + agentBridgeStatus = agentBridge.status() + } + + func toggleAgentClient(_ client: AgentBridgeClient) { + if selectedAgentClients.contains(client) { + selectedAgentClients.remove(client) + } else { + selectedAgentClients.insert(client) + } + } + + /// The only home-directory write on the readiness step. Merely visiting, + /// refreshing, skipping, or continuing never installs a client skill. + func installAgentBridge() { + do { + agentBridgeStatus = try agentBridge.install(selectedClients: selectedAgentClients) + agentBridgeError = nil + } catch { + agentBridgeError = error.localizedDescription + agentBridgeStatus = agentBridge.status() + } } /// Arm the one-shot deep-link so the Settings window lands on the MCP surface diff --git a/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift b/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift index 52af482a..828709c8 100644 --- a/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift +++ b/macos/Codescribe/Screens/Overlay/DictationOverlayWindow.swift @@ -57,12 +57,40 @@ private final class OverlayContentContainer: NSView { override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) hosting.frame = bounds + window?.invalidateCursorRects(for: self) } override func layout() { super.layout() hosting.frame = bounds } + + /// AppKit's borderless resize strip is ~1–2 px. Claim the 12 pt band first + /// so SwiftUI / movable-background do not steal the edge. + override func hitTest(_ point: NSPoint) -> NSView? { + if OverlayResizeHit.edge(at: point, in: bounds) != nil { return self } + return super.hitTest(point) + } + + override func resetCursorRects() { + discardCursorRects() + for (rect, cursor) in OverlayResizeHit.cursorRects(in: bounds) { + addCursorRect(rect, cursor: cursor) + } + } + + override func mouseDown(with event: NSEvent) { + let local = convert(event.locationInWindow, from: nil) + guard let edge = OverlayResizeHit.edge(at: local, in: bounds), + let window + else { + super.mouseDown(with: event) + return + } + OverlayResizeHit.track(edge: edge, window: window, start: event) + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } } enum DictationOverlayWindow { @@ -321,6 +349,130 @@ final class OverlayPresence { } } +/// Geometry for a fat resize band on a borderless panel. AppKit's own strip +/// is one or two pixels; this is the operator-visible target (macOS 15+). +enum OverlayResizeHit: Sendable { + static let band: CGFloat = 12 + + enum Edge: Sendable, Equatable { + case left, right, top, bottom + case topLeft, topRight, bottomLeft, bottomRight + } + + static func edge(at point: NSPoint, in bounds: NSRect, band: CGFloat = band) -> Edge? { + guard bounds.width > band * 2, bounds.height > band * 2 else { return nil } + let left = point.x <= bounds.minX + band + let right = point.x >= bounds.maxX - band + let bottom = point.y <= bounds.minY + band + let top = point.y >= bounds.maxY - band + switch (left, right, bottom, top) { + case (true, false, true, false): return .bottomLeft + case (true, false, false, true): return .topLeft + case (false, true, true, false): return .bottomRight + case (false, true, false, true): return .topRight + case (true, false, false, false): return .left + case (false, true, false, false): return .right + case (false, false, true, false): return .bottom + case (false, false, false, true): return .top + default: return nil + } + } + + static func apply( + edge: Edge, + start: NSRect, + dx: CGFloat, + dy: CGFloat, + minSize: NSSize + ) -> NSRect { + var frame = start + switch edge { + case .right, .topRight, .bottomRight: + frame.size.width = max(minSize.width, start.width + dx) + case .left, .topLeft, .bottomLeft: + let width = max(minSize.width, start.width - dx) + frame.origin.x = start.maxX - width + frame.size.width = width + case .top, .bottom: + break + } + switch edge { + case .top, .topLeft, .topRight: + frame.size.height = max(minSize.height, start.height + dy) + case .bottom, .bottomLeft, .bottomRight: + let height = max(minSize.height, start.height - dy) + frame.origin.y = start.maxY - height + frame.size.height = height + case .left, .right: + break + } + return frame + } + + static func cursorRects(in bounds: NSRect, band: CGFloat = band) -> [(NSRect, NSCursor)] { + let b = band + let w = bounds.width + let h = bounds.height + return [ + (NSRect(x: 0, y: b, width: b, height: max(0, h - 2 * b)), cursor(for: .left)), + (NSRect(x: w - b, y: b, width: b, height: max(0, h - 2 * b)), cursor(for: .right)), + (NSRect(x: b, y: h - b, width: max(0, w - 2 * b), height: b), cursor(for: .top)), + (NSRect(x: b, y: 0, width: max(0, w - 2 * b), height: b), cursor(for: .bottom)), + (NSRect(x: 0, y: h - b, width: b, height: b), cursor(for: .topLeft)), + (NSRect(x: w - b, y: h - b, width: b, height: b), cursor(for: .topRight)), + (NSRect(x: 0, y: 0, width: b, height: b), cursor(for: .bottomLeft)), + (NSRect(x: w - b, y: 0, width: b, height: b), cursor(for: .bottomRight)), + ] + } + + static func cursor(for edge: Edge) -> NSCursor { + if #available(macOS 15.0, *) { + let position: NSCursor.FrameResizePosition + switch edge { + case .left: position = .left + case .right: position = .right + case .top: position = .top + case .bottom: position = .bottom + case .topLeft: + position = .topLeading(relativeTo: NSApp.userInterfaceLayoutDirection) + case .topRight: + position = .topTrailing(relativeTo: NSApp.userInterfaceLayoutDirection) + case .bottomLeft: + position = .bottomLeading(relativeTo: NSApp.userInterfaceLayoutDirection) + case .bottomRight: + position = .bottomTrailing(relativeTo: NSApp.userInterfaceLayoutDirection) + } + return NSCursor.frameResize(position: position, directions: [.inward, .outward]) + } + switch edge { + case .left, .right: return .resizeLeftRight + case .top, .bottom: return .resizeUpDown + default: return .crosshair + } + } + + @MainActor + static func track(edge: Edge, window: NSWindow, start: NSEvent) { + let startFrame = window.frame + let startMouse = start.locationInWindow + let startScreen = window.convertToScreen( + NSRect(origin: startMouse, size: .zero) + ).origin + while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { + if next.type == .leftMouseUp { break } + let now = NSEvent.mouseLocation + let frame = apply( + edge: edge, + start: startFrame, + dx: now.x - startScreen.x, + dy: now.y - startScreen.y, + minSize: window.minSize + ) + window.setFrame(frame, display: true) + } + } +} + /// Chrome hit target: the view itself moves the window. Interactive siblings /// (buttons, editor) sit above it and keep their clicks. struct OverlayDragHandle: NSViewRepresentable { diff --git a/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift b/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift index 89464867..69cf5ee4 100644 --- a/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift +++ b/macos/Codescribe/Screens/Overlay/LiveTranscriptTextView.swift @@ -173,4 +173,18 @@ struct LiveTranscriptTextView: NSViewRepresentable { /// explicit click in the transcript must immediately begin a drag selection. final class LiveTranscriptNativeTextView: NSTextView { override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + @discardableResult + func copySelection(to pasteboard: NSPasteboard) -> Bool { + let selection = selectedRange() + let source = string as NSString + guard selection.length > 0, NSMaxRange(selection) <= source.length else { return false } + + pasteboard.clearContents() + return pasteboard.setString(source.substring(with: selection), forType: .string) + } + + override func copy(_ sender: Any?) { + _ = copySelection(to: .general) + } } diff --git a/macos/Codescribe/Screens/Overlay/OverlayState.swift b/macos/Codescribe/Screens/Overlay/OverlayState.swift index 3d5f4797..2305e99b 100644 --- a/macos/Codescribe/Screens/Overlay/OverlayState.swift +++ b/macos/Codescribe/Screens/Overlay/OverlayState.swift @@ -355,6 +355,9 @@ final class OverlayState: ObservableObject { /// lexicon v2 and quality analytics get the real misheard text, not only /// the (possibly formatted) delivered. Cleared on reset like deliveredText. private var sttRawText: String = "" + /// Armed only by a genuine TextEditor write and consumed by the first + /// delivery action that records that edit. Delivery actions remain separate. + private var manualHumanEditPending = false /// Canonical provenance for the text currently shown in FINAL. Starts from /// persisted Auto Format truth and is replaced only by a successful manual /// format. Revert restores the previous level together with the exact bytes. @@ -848,7 +851,7 @@ final class OverlayState: ObservableObject { if isUsableChange { self.preFormatText = source self.preFormatLevel = sourceLevel - self.formattedText = formatted + self.replaceFormattedTranscriptProgrammatically(formatted) self.qualityFormattingLevel = level } self.formatFailureStatus = nil @@ -921,7 +924,7 @@ final class OverlayState: ObservableObject { return } if !source.isEmpty { self.preFormatText = source } - self.formattedText = result.text + self.replaceFormattedTranscriptProgrammatically(result.text) self.formatFailureStatus = nil self.mode = .formatted self.cancelAutoHide() @@ -941,7 +944,7 @@ final class OverlayState: ObservableObject { let sourceLevel = preFormatLevel ?? .off preFormatText = nil preFormatLevel = nil - formattedText = source + replaceFormattedTranscriptProgrammatically(source) qualityFormattingLevel = sourceLevel formatFailureStatus = nil mode = .formatted @@ -975,12 +978,11 @@ final class OverlayState: ObservableObject { // MARK: Action row - func copyToPasteboard() { + func copyToPasteboard(_ pasteboard: NSPasteboard = .general) { // P0-D: capture user correction on FINAL for quality loop + lexicon learning. captureQualityIfEdited(action: "copy") - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(activeText, forType: .string) + pasteboard.clearContents() + pasteboard.setString(activeText, forType: .string) restartAutoHideCountdown() } @@ -1127,10 +1129,31 @@ final class OverlayState: ObservableObject { if agentSessionArmed, agentFinalTranscriptAppeared, text != formattedText { agentAutoSendCancelled = true } + if text != formattedText { + manualHumanEditPending = true + } formattedText = text restartAutoHideCountdown() } + /// Formatter/retranscribe/revert bytes are machine or delivery workflow + /// output. They clear the manual latch instead of inheriting a prior edit. + func replaceFormattedTranscriptProgrammatically(_ text: String) { + manualHumanEditPending = false + formattedText = text + } + + /// One manual edit act may vote once. Copy followed by Close therefore keeps + /// the second quality receipt non-voting; a later TextEditor change re-arms it. + func consumeManualEditProvenanceForQuality(isEdited: Bool) -> String? { + guard isEdited, manualHumanEditPending else { + if !isEdited { manualHumanEditPending = false } + return nil + } + manualHumanEditPending = false + return "manual_human" + } + /// Consume the canonical Rust indicator mode. Agent arm is a one-shot /// session latch; the accepted orange processing phase must not disarm it. func applyIndicatorMode(_ mode: CsIndicatorMode) { @@ -1191,6 +1214,7 @@ final class OverlayState: ObservableObject { // queue without poisoning learning. guard isEdited || action == "close" else { return } let recordedAction = isEdited ? action : "close-unreviewed" + let editProvenance = consumeManualEditProvenanceForQuality(isEdited: isEdited) // Bridge FFI (generated by uniffi) appends the quality JSONL and feeds safe // candidates to lexicon.custom.jsonl. That is blocking disk I/O, so it runs // off the main actor — Copy/Send/Close must never wait on the disk. @@ -1215,6 +1239,7 @@ final class OverlayState: ObservableObject { editedText: edited, action: recordedAction, formattingLevel: formattingLevel, + editProvenance: editProvenance, avgLogprob: avgLogprob, speechPct: speechPct, confidenceFlags: confidenceFlags @@ -1937,6 +1962,7 @@ final class OverlayState: ObservableObject { authoritativeFinalText = nil deliveredText = "" sttRawText = "" + manualHumanEditPending = false qualityFormattingLevel = .off sessionAvgLogprob = nil sessionSpeechPct = nil diff --git a/macos/Codescribe/Screens/Settings/EnginePanel.swift b/macos/Codescribe/Screens/Settings/EnginePanel.swift index 96d9a72e..720c5ea8 100644 --- a/macos/Codescribe/Screens/Settings/EnginePanel.swift +++ b/macos/Codescribe/Screens/Settings/EnginePanel.swift @@ -157,7 +157,7 @@ struct EnginePanel: View { Rectangle().fill(CSColor.hairline(0.05)).frame(height: 1) } - // MARK: Engine controls (editable — F1 layered transcription) + // MARK: Engine controls /// Selectable engines. "onnx" is deliberately NOT exposed (experimental, /// frozen); "auto" defers to the core policy (Apple live when available). @@ -173,12 +173,6 @@ struct EnginePanel: View { ("cloud", "Cloud"), ] - private var layeredBinding: Binding { - Binding( - get: { model.layeredTranscriptionEnabled }, - set: { model.setLayeredTranscription($0) }) - } - private var engineControls: some View { VStack(spacing: 8) { if let note = model.sttEngineTruthNote { @@ -191,7 +185,7 @@ struct EnginePanel: View { SettingsControlRow( title: "ASR mode", subtitle: - "Apple only = live Apple. Local power = on-device Layer-1 weights. Cloud requires an explicit grant and stays Apple until consent is recorded." + "Apple only = live Apple without Layer 1. Local power = Apple-first with mandatory on-device Whisper refinement. Cloud uses its consent-gated provider, not local Whisper." ) { Menu { ForEach(Self.asrModeOptions, id: \.id) { option in @@ -214,7 +208,8 @@ struct EnginePanel: View { } SettingsControlRow( title: "STT engine", - subtitle: "Apple = live speech (product default). Whisper = Layer-1 / offline preference." + subtitle: + "Auto/Apple = immediate Apple text; Local power continuously repairs it with Whisper. Whisper = direct local engine." ) { Menu { ForEach(Self.sttEngineOptions, id: \.id) { option in @@ -235,18 +230,95 @@ struct EnginePanel: View { .menuIndicator(.hidden) .fixedSize() } + if model.asrModeId == "local_power" { + SettingsControlRow( + title: localWhisperRuntimeTitle, + subtitle: localWhisperRuntimeSubtitle + ) { + HStack(spacing: 8) { + Text(localWhisperRuntimeLabel) + .font(CSFont.mono(11, .medium)) + .foregroundStyle(localWhisperRuntimeColor) + if model.localWhisperRuntimeState == .livePatchingConfigurationMismatch { + Button("Repair") { + model.repairLocalWhisperLivePatching() + } + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Button("Recheck") { + model.recheckLocalWhisperRuntime() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + } SettingsControlRow( - title: "Layered transcription", - subtitle: "Live in-flight correction only. Stop does not run a file Whisper pass." + title: "Whole-session final pass", + subtitle: + "Off. This controls only a full-file decode after Stop; live Whisper refinement continues during the take." ) { - Toggle("", isOn: layeredBinding) - .toggleStyle(.switch) - .labelsHidden() - .tint(CSColor.chromeAccent) + Text("Off") + .font(CSFont.mono(11, .medium)) + .foregroundStyle(CSColor.textMutedAlt) } } } + private var localWhisperRuntimeTitle: String { + switch model.localWhisperRuntimeState { + case .directEngineReady, .directEngineNotReady: + return "Local Whisper engine" + default: + return "Live Whisper refinement" + } + } + + private var localWhisperRuntimeLabel: String { + switch model.localWhisperRuntimeState { + case .directEngineReady: return "Ready" + case .directEngineNotReady, .livePatchingNotReady: return "Not ready" + case .livePatchingConfigured: return "Required · configured" + case .livePatchingConfigurationMismatch: return "Degraded" + case .notSelected: return "Not selected" + } + } + + private var localWhisperRuntimeSubtitle: String { + switch model.localWhisperRuntimeState { + case .directEngineReady: + return + "The validated local FP16 bundle serves transcription directly; no parallel Apple patcher runs." + case .directEngineNotReady: + return + "The local FP16 bundle is missing or invalid. Direct Whisper cannot serve the next take." + case .livePatchingConfigured: + return + "Apple paints immediately; the validated local FP16 model repairs the same audio spans during the take." + case .livePatchingNotReady: + return + "The local FP16 bundle is missing or invalid. Local power is explicitly not ready until validation passes." + case .livePatchingConfigurationMismatch: + return + "Local power requires the runtime phase1 arming token, but persisted readback is disarmed. Repair before the next take." + case .notSelected: + return "Local Whisper is not the selected provider." + } + } + + private var localWhisperRuntimeColor: Color { + switch model.localWhisperRuntimeState { + case .directEngineReady, .livePatchingConfigured: + return CSColor.oliveLight + case .directEngineNotReady, .livePatchingNotReady, .livePatchingConfigurationMismatch: + return CSColor.amber + case .notSelected: + return CSColor.textMutedAlt + } + } + // MARK: Local Whisper download (public DMG is slim — model is opt-in) private var whisperDownloadSection: some View { @@ -279,7 +351,7 @@ struct EnginePanel: View { } else if !status.available { SettingsControlRow( title: "Download Whisper", - subtitle: "Optional local Candle model (\(status.sizeHint)). Apple STT works without it." + subtitle: whisperDownloadSubtitle(status) ) { Button("Download") { model.startWhisperDownload() @@ -321,7 +393,18 @@ struct EnginePanel: View { if status.available { return "Ready for Whisper engine · \(status.modelId)" } - return "Needed only when STT engine is Whisper or layered tail patches" + if model.asrModeId == "local_power" { + return "Missing or invalid FP16 bundle · Local power is not ready" + } + return "Required by the direct Whisper engine or Local power live refinement" + } + + private func whisperDownloadSubtitle(_ status: CsWhisperModelStatus) -> String { + if model.asrModeId == "local_power" { + return + "Required local FP16 model (\(status.sizeHint)). Missing or invalid weights keep Local power not ready." + } + return "Local FP16 model (\(status.sizeHint)) for direct Whisper or Local power refinement." } // MARK: Preview timing (overlay pacing — writes the existing promoted keys) diff --git a/macos/Codescribe/Screens/Settings/LabPanel.swift b/macos/Codescribe/Screens/Settings/LabPanel.swift index 73432083..05f8e6ca 100644 --- a/macos/Codescribe/Screens/Settings/LabPanel.swift +++ b/macos/Codescribe/Screens/Settings/LabPanel.swift @@ -13,20 +13,15 @@ struct LabPanel: View { .foregroundStyle(CSColor.textHigh) Text( labMode - ? "Lab mode is on. Daily overlay stays off so you test against the bus and the PWA tape." + ? "Lab mode is on. Overlay follows the tray toggle — Lab does not steal it." : "Open the loopback Voice Lab. Production builds never show this panel." ) .font(CSFont.ui(12.5)) .foregroundStyle(CSColor.textMutedAlt) - Toggle("Lab mode (overlay off)", isOn: $labMode) + Toggle("Lab mode", isOn: $labMode) .toggleStyle(.switch) .font(CSFont.ui(13, .medium)) - .onChange(of: labMode) { _, on in - if on { - AppModel.shared.overlay.hide() - } - } Button("Open Voice Lab") { VoiceLabRuntime.openConsole() diff --git a/macos/Codescribe/Screens/Settings/SettingsEngine.swift b/macos/Codescribe/Screens/Settings/SettingsEngine.swift index a471634d..3fe46e3a 100644 --- a/macos/Codescribe/Screens/Settings/SettingsEngine.swift +++ b/macos/Codescribe/Screens/Settings/SettingsEngine.swift @@ -262,6 +262,7 @@ struct MockSettingsEngine: SettingsEngine { variant: record.variant, editedText: canonical, action: "edit", + editProvenance: "manual_human", timestampMs: record.timestampMs, avgLogprob: nil, speechPct: nil, diff --git a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift index 04fda099..b57ac277 100644 --- a/macos/Codescribe/Screens/Settings/SettingsViewModel.swift +++ b/macos/Codescribe/Screens/Settings/SettingsViewModel.swift @@ -63,6 +63,46 @@ func formatActiveSTT(lastServing: LastServingVerdict?) -> String { } } +/// Product truth for the local Whisper lane shown in Dictation settings. +/// This is deliberately not a boolean: a persisted `phase1` token is only a +/// configured request, while model validation decides whether the next take +/// can actually use it. +enum LocalWhisperRuntimeState: Equatable { + case notSelected + case directEngineReady + case directEngineNotReady + case livePatchingConfigured + case livePatchingNotReady + case livePatchingConfigurationMismatch +} + +let localWhisperLivePatchingRuntimeValue = "phase1" + +func resolveLocalWhisperRuntimeState( + asrModeId: String, + sttEngineId: String, + layeredValue: String?, + modelAvailable: Bool +) -> LocalWhisperRuntimeState { + guard asrModeId == "local_power" else { return .notSelected } + + if sttEngineId == "whisper" || sttEngineId == "candle" { + return modelAvailable ? .directEngineReady : .directEngineNotReady + } + + // Runtime product policy treats an absent promoted key as ArmedDefault. + // Only an explicit phase1 token or absence can claim configured truth; + // explicit `off` and every unknown token are named configuration drift. + let normalizedLayeredValue = layeredValue?.lowercased() + guard + normalizedLayeredValue == nil || normalizedLayeredValue == localWhisperLivePatchingRuntimeValue + else { + return .livePatchingConfigurationMismatch + } + guard modelAvailable else { return .livePatchingNotReady } + return .livePatchingConfigured +} + enum SettingsSectionAvailability: Equatable { case available case hidden @@ -1993,9 +2033,26 @@ final class SettingsViewModel: ObservableObject { default: normalized = "apple" } settings.sttEngine = normalized - // Persist promotes to settings.json AND reconciles process env + .env - // (single brain — no CODESCRIBE_STT_ENGINE lottery). - persist("CODESCRIBE_STT_ENGINE", normalized) + // Local Power has one product shape. Apple/Auto requires live local + // patching; direct Whisper does not run a second patcher beside itself. + // Persist both values atomically, then `persistMany` reloads the bridge + // snapshot so Settings cannot keep an optimistic state the runtime did not + // accept. + if asrModeId == "local_power" { + let layered = + normalized == "auto" || normalized == "apple" + ? localWhisperLivePatchingRuntimeValue : "off" + settings.layeredTranscription = layered + persistMany([ + CsConfigEntry(key: "CODESCRIBE_STT_ENGINE", value: normalized), + CsConfigEntry(key: "CODESCRIBE_LAYERED_TRANSCRIPTION", value: layered), + ]) + refreshWhisperModelStatus() + } else { + // Persist promotes to settings.json AND reconciles process env + .env + // (single brain — no CODESCRIBE_STT_ENGINE lottery). + persist("CODESCRIBE_STT_ENGINE", normalized) + } } /// Legacy stop-file-pass token. Settings no longer exposes Always/Smart/Off. @@ -2038,17 +2095,31 @@ final class SettingsViewModel: ObservableObject { switch id.lowercased() { case "local_power": settings.asrMode = "local_power" - persist("CODESCRIBE_ASR_MODE", "local_power") + let layered = + sttEngineId == "auto" || sttEngineId == "apple" + ? localWhisperLivePatchingRuntimeValue : "off" + settings.layeredTranscription = layered + persistMany([ + CsConfigEntry(key: "CODESCRIBE_ASR_MODE", value: "local_power"), + CsConfigEntry(key: "CODESCRIBE_LAYERED_TRANSCRIPTION", value: layered), + ]) + refreshWhisperModelStatus() case "cloud": settings.asrMode = "cloud" settings.cloudConsent = "granted" + settings.layeredTranscription = "off" persistMany([ CsConfigEntry(key: "CODESCRIBE_CLOUD_CONSENT", value: "granted"), CsConfigEntry(key: "CODESCRIBE_ASR_MODE", value: "cloud"), + CsConfigEntry(key: "CODESCRIBE_LAYERED_TRANSCRIPTION", value: "off"), ]) default: settings.asrMode = "apple_only" - persist("CODESCRIBE_ASR_MODE", "apple_only") + settings.layeredTranscription = "off" + persistMany([ + CsConfigEntry(key: "CODESCRIBE_ASR_MODE", value: "apple_only"), + CsConfigEntry(key: "CODESCRIBE_LAYERED_TRANSCRIPTION", value: "off"), + ]) } } @@ -2058,19 +2129,33 @@ final class SettingsViewModel: ObservableObject { persist("CODESCRIBE_ASR_GATEWAY_URL", value.trimmingCharacters(in: .whitespaces)) } - /// ON for any phase value ("phase1".."phase4" or bare "1".."4"); explicit - /// "off" disarms. Absent matches the core default (`unset` → phase1). - var layeredTranscriptionEnabled: Bool { - let value = settings.layeredTranscription ?? "phase1" - return value.hasPrefix("phase") || Int(value) != nil + var localWhisperRuntimeState: LocalWhisperRuntimeState { + resolveLocalWhisperRuntimeState( + asrModeId: asrModeId, + sttEngineId: sttEngineId, + layeredValue: settings.layeredTranscription, + modelAvailable: localWhisperStatus.available + ) } - /// The GUI only exposes Phase 1 (Apple live layer + Whisper tail patch); - /// phases 2-4 do not exist as features yet. - func setLayeredTranscription(_ on: Bool) { - let value = on ? "phase1" : "off" - settings.layeredTranscription = value - persist("CODESCRIBE_LAYERED_TRANSCRIPTION", value) + /// Re-read both persisted arming truth and full model-bundle validation. + /// This is the repair/recheck action for external config drift; it never + /// paints an optimistic ON state. + func recheckLocalWhisperRuntime() { + guard let engine else { return } + applyLoadedSettings(engine.loadSettings()) + refreshWhisperModelStatus() + } + + /// Repair only the named configuration mismatch. Missing/invalid weights are + /// handled by the model download surface, never by pretending Phase 1 is on. + func repairLocalWhisperLivePatching() { + guard asrModeId == "local_power", sttEngineId == "auto" || sttEngineId == "apple" else { + return + } + settings.layeredTranscription = localWhisperLivePatchingRuntimeValue + persist("CODESCRIBE_LAYERED_TRANSCRIPTION", localWhisperLivePatchingRuntimeValue) + refreshWhisperModelStatus() } var holdBadgeOption: HoldBadgeOption { diff --git a/macos/Codescribe/Services/AgentBridgeInstaller.swift b/macos/Codescribe/Services/AgentBridgeInstaller.swift new file mode 100644 index 00000000..15679770 --- /dev/null +++ b/macos/Codescribe/Services/AgentBridgeInstaller.swift @@ -0,0 +1,520 @@ +import CryptoKit +import Foundation + +/// Agent clients that can consume the installed Codescribe foundation skill. +/// The raw values are receipt/API tokens and must remain stable. +enum AgentBridgeClient: String, CaseIterable, Codable, Hashable, Identifiable { + case codex + case claudeCode = "claude-code" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .codex: return "Codex" + case .claudeCode: return "Claude Code" + } + } + + fileprivate func skillDirectory(home: URL) -> URL { + switch self { + case .codex: + return home.appendingPathComponent(".codex/skills/codescribe", isDirectory: true) + case .claudeCode: + return home.appendingPathComponent(".claude/skills/codescribe", isDirectory: true) + } + } +} + +struct AgentBridgeInstallationStatus: Equatable { + let payloadAvailable: Bool + let bundleVersion: String? + let installedClients: [AgentBridgeClient] + let installedPaths: [String] + let detail: String + + static let unavailable = AgentBridgeInstallationStatus( + payloadAvailable: false, + bundleVersion: nil, + installedClients: [], + installedPaths: [], + detail: "The signed app does not contain the agent bridge payload." + ) +} + +protocol AgentBridgeInstalling { + func status() -> AgentBridgeInstallationStatus + func install(selectedClients: Set) throws -> AgentBridgeInstallationStatus +} + +enum AgentBridgeInstallationError: LocalizedError { + case selectionRequired + case payloadUnavailable + case invalidManifest(String) + case conflict(path: String, reason: String) + case transaction(String) + + var errorDescription: String? { + switch self { + case .selectionRequired: + return "Select Codex, Claude Code, or both before installing." + case .payloadUnavailable: + return "The app bundle does not contain the Codescribe agent bridge payload." + case .invalidManifest(let reason): + return "The bundled agent bridge failed checksum verification: \(reason)" + case .conflict(let path, let reason): + return "Codescribe will not overwrite \(path): \(reason)" + case .transaction(let reason): + return "Agent bridge installation could not be completed atomically: \(reason)" + } + } +} + +private struct AgentBridgeManifestFile: Codable, Equatable { + let path: String + let sha256: String + let bytes: UInt64 + let mode: String +} + +private struct AgentBridgeBundleManifest: Codable { + let schema: String + let bundleVersion: String + let helper: String + let skill: String + let files: [AgentBridgeManifestFile] + + enum CodingKeys: String, CodingKey { + case schema + case bundleVersion = "bundle_version" + case helper + case skill + case files + } +} + +private struct AgentBridgeReceipt: Codable { + let schema: String + let bundleVersion: String + let managedID: String + let selectedClients: [AgentBridgeClient] + let installedPaths: [String: String] + let runtimePath: String + let payloadFiles: [AgentBridgeManifestFile] + let installedAt: String + + enum CodingKeys: String, CodingKey { + case schema + case bundleVersion = "bundle_version" + case managedID = "managed_id" + case selectedClients = "selected_clients" + case installedPaths = "installed_paths" + case runtimePath = "runtime_path" + case payloadFiles = "payload_files" + case installedAt = "installed_at" + } +} + +private struct AgentBridgeManagedMarker: Codable { + let schema: String + let managedID: String + let client: AgentBridgeClient + let agentBridgeRoot: String + let bundleVersion: String + + enum CodingKeys: String, CodingKey { + case schema + case managedID = "managed_id" + case client + case agentBridgeRoot = "agent_bridge_root" + case bundleVersion = "bundle_version" + } +} + +/// Installs the signed bundle payload into a stable runtime root and copies the +/// skill tree into explicitly selected clients. All preflight conflicts are +/// detected before mutation. Directory renames form one rollback-capable +/// transaction; receipt replacement is the final commit point. +final class RealAgentBridgeInstaller: AgentBridgeInstalling { + static let bundleSchema = "codescribe.agent-bridge.bundle.v1" + static let receiptSchema = "codescribe.agent-bridge.receipt.v1" + static let markerSchema = "codescribe.agent-bridge.managed.v1" + + private let resourceRoot: URL? + private let homeDirectory: URL + private let fileManager: FileManager + private let bridgeRoot: URL + private let runtimeDirectory: URL + private let receiptURL: URL + + init( + resourceRoot: URL? = Bundle.main.resourceURL? + .appendingPathComponent("agent-bridge", isDirectory: true), + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default + ) { + self.resourceRoot = resourceRoot + self.homeDirectory = homeDirectory + self.fileManager = fileManager + self.bridgeRoot = + homeDirectory + .appendingPathComponent(".codescribe/agent-bridge", isDirectory: true) + self.runtimeDirectory = bridgeRoot.appendingPathComponent("runtime", isDirectory: true) + self.receiptURL = bridgeRoot.appendingPathComponent("receipt.json") + } + + func status() -> AgentBridgeInstallationStatus { + let manifest: AgentBridgeBundleManifest + do { + manifest = try verifiedManifest() + } catch { + return .unavailable + } + + guard let receipt = try? decode(AgentBridgeReceipt.self, from: receiptURL), + receipt.schema == Self.receiptSchema + else { + return AgentBridgeInstallationStatus( + payloadAvailable: true, + bundleVersion: manifest.bundleVersion, + installedClients: [], + installedPaths: [], + detail: "Ready to install after you select an agent client." + ) + } + + let clients = receipt.selectedClients.sorted { $0.rawValue < $1.rawValue } + let paths = clients.compactMap { receipt.installedPaths[$0.rawValue] } + return AgentBridgeInstallationStatus( + payloadAvailable: true, + bundleVersion: receipt.bundleVersion, + installedClients: clients, + installedPaths: paths, + detail: clients.isEmpty + ? "No agent client is currently managed by Codescribe." + : "Installed for \(clients.map(\.displayName).joined(separator: ", "))." + ) + } + + func install(selectedClients: Set) throws -> AgentBridgeInstallationStatus { + guard !selectedClients.isEmpty else { + throw AgentBridgeInstallationError.selectionRequired + } + let manifest = try verifiedManifest() + guard let resourceRoot else { + throw AgentBridgeInstallationError.payloadUnavailable + } + + try fileManager.createDirectory( + at: bridgeRoot, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: bridgeRoot.path) + + let previousReceipt = try? decode(AgentBridgeReceipt.self, from: receiptURL) + let managedID = previousReceipt?.managedID ?? UUID().uuidString.lowercased() + let selected = selectedClients.sorted { $0.rawValue < $1.rawValue } + let previouslySelected = Set(previousReceipt?.selectedClients ?? []) + let deselected = previouslySelected.subtracting(selectedClients) + + // Conflict discovery is deliberately complete before the first rename. + for client in selectedClients { + let destination = client.skillDirectory(home: homeDirectory) + if fileManager.fileExists(atPath: destination.path) { + try requireManaged( + destination: destination, + client: client, + managedID: managedID, + receipt: previousReceipt + ) + } + } + for client in deselected { + let destination = client.skillDirectory(home: homeDirectory) + if fileManager.fileExists(atPath: destination.path) { + try requireManaged( + destination: destination, + client: client, + managedID: managedID, + receipt: previousReceipt + ) + } + } + + let transactionID = UUID().uuidString.lowercased() + let runtimeStage = bridgeRoot.appendingPathComponent( + ".runtime-stage-\(transactionID)", + isDirectory: true + ) + var clientStages: [AgentBridgeClient: URL] = [:] + var records: [ReplacementRecord] = [] + + do { + try fileManager.copyItem(at: resourceRoot, to: runtimeStage) + let stagedSkill = runtimeStage.appendingPathComponent(manifest.skill, isDirectory: true) + for client in selected { + let destination = client.skillDirectory(home: homeDirectory) + let parent = destination.deletingLastPathComponent() + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + let stage = parent.appendingPathComponent( + ".codescribe-stage-\(transactionID)-\(client.rawValue)", + isDirectory: true + ) + try fileManager.copyItem(at: stagedSkill, to: stage) + let marker = AgentBridgeManagedMarker( + schema: Self.markerSchema, + managedID: managedID, + client: client, + agentBridgeRoot: bridgeRoot.standardizedFileURL.path, + bundleVersion: manifest.bundleVersion + ) + try writeJSON(marker, to: stage.appendingPathComponent(".codescribe-managed.json")) + clientStages[client] = stage + } + + try replace( + destination: runtimeDirectory, + with: runtimeStage, + transactionID: transactionID, + records: &records + ) + for client in selected { + guard let stage = clientStages[client] else { continue } + try replace( + destination: client.skillDirectory(home: homeDirectory), + with: stage, + transactionID: transactionID, + records: &records + ) + } + for client in deselected { + let destination = client.skillDirectory(home: homeDirectory) + guard fileManager.fileExists(atPath: destination.path) else { continue } + try replace( + destination: destination, + with: nil, + transactionID: transactionID, + records: &records + ) + } + + let installedPaths = Dictionary( + uniqueKeysWithValues: selected.map { + ($0.rawValue, $0.skillDirectory(home: homeDirectory).standardizedFileURL.path) + } + ) + let receipt = AgentBridgeReceipt( + schema: Self.receiptSchema, + bundleVersion: manifest.bundleVersion, + managedID: managedID, + selectedClients: selected, + installedPaths: installedPaths, + runtimePath: runtimeDirectory.standardizedFileURL.path, + payloadFiles: manifest.files, + installedAt: ISO8601DateFormatter().string(from: Date()) + ) + try writeJSON(receipt, to: receiptURL) + for record in records where record.backup != nil { + try? fileManager.removeItem(at: record.backup!) + } + } catch { + rollback(records: records) + try? fileManager.removeItem(at: runtimeStage) + for stage in clientStages.values { + try? fileManager.removeItem(at: stage) + } + if let typed = error as? AgentBridgeInstallationError { + throw typed + } + throw AgentBridgeInstallationError.transaction(error.localizedDescription) + } + + return status() + } + + private func verifiedManifest() throws -> AgentBridgeBundleManifest { + guard let resourceRoot else { + throw AgentBridgeInstallationError.payloadUnavailable + } + let manifestURL = resourceRoot.appendingPathComponent("manifest.json") + let manifest: AgentBridgeBundleManifest + do { + manifest = try decode(AgentBridgeBundleManifest.self, from: manifestURL) + } catch { + throw AgentBridgeInstallationError.invalidManifest("manifest.json is missing or unreadable") + } + guard manifest.schema == Self.bundleSchema, !manifest.bundleVersion.isEmpty else { + throw AgentBridgeInstallationError.invalidManifest("schema or bundle version is invalid") + } + guard !manifest.files.isEmpty else { + throw AgentBridgeInstallationError.invalidManifest("the file list is empty") + } + + var listed = Set() + for entry in manifest.files { + guard isSafeRelativePath(entry.path), listed.insert(entry.path).inserted else { + throw AgentBridgeInstallationError.invalidManifest("unsafe or duplicate path \(entry.path)") + } + let file = resourceRoot.appendingPathComponent(entry.path) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: file.path, isDirectory: &isDirectory), + !isDirectory.boolValue + else { + throw AgentBridgeInstallationError.invalidManifest("missing file \(entry.path)") + } + let values = try? file.resourceValues(forKeys: [.isSymbolicLinkKey]) + guard values?.isSymbolicLink != true else { + throw AgentBridgeInstallationError.invalidManifest("symlink refused at \(entry.path)") + } + let data = try Data(contentsOf: file) + guard UInt64(data.count) == entry.bytes, Self.sha256(data) == entry.sha256.lowercased() + else { + throw AgentBridgeInstallationError.invalidManifest("checksum mismatch for \(entry.path)") + } + } + + let actual = try payloadFiles(root: resourceRoot) + guard actual == listed else { + let difference = actual.symmetricDifference(listed).sorted().joined(separator: ", ") + throw AgentBridgeInstallationError.invalidManifest( + "manifest coverage mismatch: \(difference)") + } + guard listed.contains(manifest.helper), listed.contains("\(manifest.skill)/SKILL.md") else { + throw AgentBridgeInstallationError.invalidManifest("helper or skill entrypoint is missing") + } + return manifest + } + + private func payloadFiles(root: URL) throws -> Set { + guard + let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [] + ) + else { + throw AgentBridgeInstallationError.invalidManifest("payload cannot be enumerated") + } + var result = Set() + let rootManifest = root.appendingPathComponent("manifest.json").standardizedFileURL + for case let file as URL in enumerator { + let values = try file.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true, file.standardizedFileURL != rootManifest else { + continue + } + let prefix = root.standardizedFileURL.path + "/" + let absolute = file.standardizedFileURL.path + guard absolute.hasPrefix(prefix) else { + throw AgentBridgeInstallationError.invalidManifest("payload escaped its resource root") + } + result.insert(String(absolute.dropFirst(prefix.count))) + } + return result + } + + private func requireManaged( + destination: URL, + client: AgentBridgeClient, + managedID: String, + receipt: AgentBridgeReceipt? + ) throws { + guard let receipt, + receipt.schema == Self.receiptSchema, + receipt.managedID == managedID, + receipt.installedPaths[client.rawValue] == destination.standardizedFileURL.path + else { + throw AgentBridgeInstallationError.conflict( + path: destination.path, + reason: "the existing skill folder is not present in the Codescribe receipt" + ) + } + let markerURL = destination.appendingPathComponent(".codescribe-managed.json") + guard let marker = try? decode(AgentBridgeManagedMarker.self, from: markerURL), + marker.schema == Self.markerSchema, + marker.managedID == managedID, + marker.client == client, + marker.agentBridgeRoot == bridgeRoot.standardizedFileURL.path + else { + throw AgentBridgeInstallationError.conflict( + path: destination.path, + reason: "the Codescribe-managed marker is missing or does not match the receipt" + ) + } + } + + private struct ReplacementRecord { + let destination: URL + let backup: URL? + let installedReplacement: Bool + } + + private func replace( + destination: URL, + with staged: URL?, + transactionID: String, + records: inout [ReplacementRecord] + ) throws { + let parent = destination.deletingLastPathComponent() + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + var backup: URL? + if fileManager.fileExists(atPath: destination.path) { + let candidate = parent.appendingPathComponent( + ".\(destination.lastPathComponent).backup-\(transactionID)", + isDirectory: true + ) + try fileManager.moveItem(at: destination, to: candidate) + backup = candidate + } + do { + if let staged { + try fileManager.moveItem(at: staged, to: destination) + } + records.append( + ReplacementRecord( + destination: destination, + backup: backup, + installedReplacement: staged != nil + ) + ) + } catch { + if let backup { + try? fileManager.moveItem(at: backup, to: destination) + } + throw error + } + } + + private func rollback(records: [ReplacementRecord]) { + for record in records.reversed() { + if record.installedReplacement, fileManager.fileExists(atPath: record.destination.path) { + try? fileManager.removeItem(at: record.destination) + } + if let backup = record.backup, fileManager.fileExists(atPath: backup.path) { + try? fileManager.moveItem(at: backup, to: record.destination) + } + } + } + + private func writeJSON(_ value: T, to url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(value) + Data([0x0A]) + try data.write(to: url, options: .atomic) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + private func decode(_ type: T.Type, from url: URL) throws -> T { + try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } + + private func isSafeRelativePath(_ path: String) -> Bool { + guard !path.isEmpty, !path.hasPrefix("/") else { return false } + let components = path.split(separator: "/", omittingEmptySubsequences: false) + return !components.contains(where: { $0.isEmpty || $0 == "." || $0 == ".." }) + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/macos/CodescribeTests/AgentBridgeInstallerTests.swift b/macos/CodescribeTests/AgentBridgeInstallerTests.swift new file mode 100644 index 00000000..c157f40b --- /dev/null +++ b/macos/CodescribeTests/AgentBridgeInstallerTests.swift @@ -0,0 +1,259 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import Codescribe + +@MainActor +final class AgentBridgeInstallerTests: XCTestCase { + private var scratch: URL! + + override func setUpWithError() throws { + scratch = FileManager.default.temporaryDirectory + .appendingPathComponent("codescribe-agent-bridge-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + if let scratch { + try? FileManager.default.removeItem(at: scratch) + } + } + + func testInstallIsExplicitAtomicIdempotentAndSupportsIndependentClients() throws { + let payload = try makePayload() + let home = scratch.appendingPathComponent("home", isDirectory: true) + let installer = RealAgentBridgeInstaller(resourceRoot: payload, homeDirectory: home) + + XCTAssertThrowsError(try installer.install(selectedClients: [])) + XCTAssertFalse( + FileManager.default.fileExists( + atPath: home.appendingPathComponent(".codescribe/agent-bridge/receipt.json").path + ) + ) + + let codexOnly = try installer.install(selectedClients: [.codex]) + XCTAssertEqual(codexOnly.installedClients, [.codex]) + let runtimeHelper = home.appendingPathComponent( + ".codescribe/agent-bridge/runtime/bin/bus-demux.py" + ) + let codexSkill = home.appendingPathComponent(".codex/skills/codescribe") + let claudeSkill = home.appendingPathComponent(".claude/skills/codescribe") + XCTAssertTrue(FileManager.default.isExecutableFile(atPath: runtimeHelper.path)) + XCTAssertTrue( + FileManager.default.fileExists(atPath: codexSkill.appendingPathComponent("SKILL.md").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: claudeSkill.path)) + + let receiptURL = home.appendingPathComponent(".codescribe/agent-bridge/receipt.json") + let firstReceipt = try jsonObject(receiptURL) + let firstManagedID = try XCTUnwrap(firstReceipt["managed_id"] as? String) + XCTAssertEqual(firstReceipt["bundle_version"] as? String, "9.8.7") + + // Same selection is a content-idempotent reinstall and retains ownership. + _ = try installer.install(selectedClients: [.codex]) + let secondManagedID = try XCTUnwrap(try jsonObject(receiptURL)["managed_id"] as? String) + XCTAssertEqual(firstManagedID, secondManagedID) + + let both = try installer.install(selectedClients: [.codex, .claudeCode]) + XCTAssertEqual(Set(both.installedClients), [.codex, .claudeCode]) + XCTAssertTrue( + FileManager.default.fileExists(atPath: claudeSkill.appendingPathComponent("SKILL.md").path)) + + // Deselecting Codex removes only the matching managed folder. + let claudeOnly = try installer.install(selectedClients: [.claudeCode]) + XCTAssertEqual(claudeOnly.installedClients, [.claudeCode]) + XCTAssertFalse(FileManager.default.fileExists(atPath: codexSkill.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: claudeSkill.path)) + } + + func testUnownedClientSkillIsVisibleConflictAndNeverMutated() throws { + let payload = try makePayload() + let home = scratch.appendingPathComponent("foreign-home", isDirectory: true) + let destination = home.appendingPathComponent(".codex/skills/codescribe", isDirectory: true) + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + let foreign = destination.appendingPathComponent("FOREIGN.txt") + try Data("owned by operator\n".utf8).write(to: foreign) + let installer = RealAgentBridgeInstaller(resourceRoot: payload, homeDirectory: home) + + XCTAssertThrowsError(try installer.install(selectedClients: [.codex])) { error in + XCTAssertTrue( + error.localizedDescription.contains("will not overwrite"), error.localizedDescription) + XCTAssertTrue( + error.localizedDescription.contains(destination.path), error.localizedDescription) + } + XCTAssertEqual(try String(contentsOf: foreign, encoding: .utf8), "owned by operator\n") + XCTAssertFalse( + FileManager.default.fileExists( + atPath: home.appendingPathComponent(".codescribe/agent-bridge/receipt.json").path + ) + ) + } + + func testDeselectionRefusesFolderWhoseManagedMarkerWasReplaced() throws { + let payload = try makePayload() + let home = scratch.appendingPathComponent("marker-home", isDirectory: true) + let installer = RealAgentBridgeInstaller(resourceRoot: payload, homeDirectory: home) + _ = try installer.install(selectedClients: [.codex, .claudeCode]) + let codexSkill = home.appendingPathComponent(".codex/skills/codescribe", isDirectory: true) + let marker = codexSkill.appendingPathComponent(".codescribe-managed.json") + try FileManager.default.removeItem(at: marker) + let foreign = codexSkill.appendingPathComponent("FOREIGN.txt") + try Data("do not delete\n".utf8).write(to: foreign) + + XCTAssertThrowsError(try installer.install(selectedClients: [.claudeCode])) { error in + XCTAssertTrue(error.localizedDescription.contains("marker"), error.localizedDescription) + } + XCTAssertEqual(try String(contentsOf: foreign, encoding: .utf8), "do not delete\n") + XCTAssertTrue(FileManager.default.fileExists(atPath: codexSkill.path)) + } + + func testChecksumMismatchRefusesBeforeHomeMutation() throws { + let payload = try makePayload() + let helper = payload.appendingPathComponent("bin/bus-demux.py") + try Data("tamper\n".utf8).append(to: helper) + let home = scratch.appendingPathComponent("tamper-home", isDirectory: true) + let installer = RealAgentBridgeInstaller(resourceRoot: payload, homeDirectory: home) + + XCTAssertThrowsError(try installer.install(selectedClients: [.codex])) { error in + XCTAssertTrue(error.localizedDescription.contains("checksum"), error.localizedDescription) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: home.path)) + } + + func testOnboardingUsesPolishCopyAndNeverInstallsUntilSelectionAndClick() { + let engine = MockOnboardingEngine(progress: 11) + engine.mode = "agentic" + engine.language = .polish + let bridge = RecordingAgentBridgeInstaller() + let model = OnboardingViewModel( + engine: engine, + hotkeys: MockHotkeysEngine(), + agentStatus: MockAgentStatusEngine(), + agentBridge: bridge, + probe: MockPermissionProbe(.allGranted) + ) + + XCTAssertEqual(bridge.installCalls, []) + XCTAssertTrue(model.selectedAgentClients.isEmpty) + XCTAssertTrue(model.agentBridgeUsesPolishCopy) + XCTAssertTrue(model.agentBridgeExplanation.contains("szkice na żywo")) + XCTAssertTrue(model.agentBridgeExplanation.contains("transcript_sealed")) + + let fallbackEngine = MockOnboardingEngine(progress: 11) + fallbackEngine.mode = "agentic" + fallbackEngine.language = .auto + let fallbackModel = OnboardingViewModel( + engine: fallbackEngine, + hotkeys: MockHotkeysEngine(), + agentStatus: MockAgentStatusEngine(), + agentBridge: RecordingAgentBridgeInstaller(), + probe: MockPermissionProbe(.allGranted) + ) + XCTAssertFalse(fallbackModel.agentBridgeUsesPolishCopy) + XCTAssertTrue(fallbackModel.agentBridgeExplanation.contains("live drafts")) + XCTAssertTrue(fallbackModel.agentBridgeExplanation.contains("transcript_sealed")) + + model.refreshForCurrentStep() + XCTAssertEqual(bridge.installCalls, []) + + model.toggleAgentClient(.codex) + model.installAgentBridge() + XCTAssertEqual(bridge.installCalls, [[.codex]]) + XCTAssertEqual(model.agentBridgeStatus.installedClients, [.codex]) + } + + private func makePayload() throws -> URL { + let payload = scratch.appendingPathComponent("payload-\(UUID().uuidString)", isDirectory: true) + let helper = payload.appendingPathComponent("bin/bus-demux.py") + let skill = payload.appendingPathComponent("skills/codescribe", isDirectory: true) + try FileManager.default.createDirectory( + at: helper.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory(at: skill, withIntermediateDirectories: true) + try Data("#!/usr/bin/env python3\nprint('bridge')\n".utf8).write(to: helper) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + try Data("---\nname: codescribe\n---\n".utf8).write( + to: skill.appendingPathComponent("SKILL.md") + ) + try Data("reference\n".utf8).write(to: skill.appendingPathComponent("README.md")) + + let relativeFiles = [ + "bin/bus-demux.py", + "skills/codescribe/README.md", + "skills/codescribe/SKILL.md", + ] + let files: [[String: Any]] = try relativeFiles.map { relative in + let url = payload.appendingPathComponent(relative) + let data = try Data(contentsOf: url) + let permissions = + try FileManager.default.attributesOfItem(atPath: url.path)[.posixPermissions] + as? NSNumber + return [ + "path": relative, + "sha256": sha256(data), + "bytes": data.count, + "mode": String(format: "%04o", permissions?.intValue ?? 0o644), + ] + } + let manifest: [String: Any] = [ + "schema": "codescribe.agent-bridge.bundle.v1", + "bundle_version": "9.8.7", + "helper": "bin/bus-demux.py", + "skill": "skills/codescribe", + "files": files, + ] + let manifestData = + try JSONSerialization.data( + withJSONObject: manifest, + options: [.prettyPrinted, .sortedKeys] + ) + Data([0x0A]) + try manifestData.write(to: payload.appendingPathComponent("manifest.json")) + return payload + } + + private func jsonObject(_ url: URL) throws -> [String: Any] { + try XCTUnwrap( + try JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any] + ) + } + + private func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +private final class RecordingAgentBridgeInstaller: AgentBridgeInstalling { + private(set) var installCalls: [Set] = [] + private var current = AgentBridgeInstallationStatus( + payloadAvailable: true, + bundleVersion: "9.8.7", + installedClients: [], + installedPaths: [], + detail: "Ready" + ) + + func status() -> AgentBridgeInstallationStatus { current } + + func install(selectedClients: Set) throws -> AgentBridgeInstallationStatus { + installCalls.append(selectedClients) + let clients = selectedClients.sorted { $0.rawValue < $1.rawValue } + current = AgentBridgeInstallationStatus( + payloadAvailable: true, + bundleVersion: "9.8.7", + installedClients: clients, + installedPaths: clients.map { "/tmp/\($0.rawValue)" }, + detail: "Installed" + ) + return current + } +} + +extension Data { + fileprivate func append(to url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: self) + } +} diff --git a/macos/CodescribeTests/DeveloperSurfaceTests.swift b/macos/CodescribeTests/DeveloperSurfaceTests.swift index e180f3cd..f747893c 100644 --- a/macos/CodescribeTests/DeveloperSurfaceTests.swift +++ b/macos/CodescribeTests/DeveloperSurfaceTests.swift @@ -25,11 +25,11 @@ final class DeveloperSurfaceTests: XCTestCase { XCTAssertTrue(SettingsSection.matching(query: "").contains(.agent)) } - func testLabModeOnDeveloperSurfaceHidesOverlayWithoutTouchingTray() { + func testLabModeDoesNotHideOverlayWhenTrayWantsIt() { let defaults = UserDefaults(suiteName: UUID().uuidString)! defaults.set(true, forKey: DictationOverlayGate.labModeDefaultsKey) XCTAssertTrue(DictationOverlayGate.isLabModeOn(defaults: defaults, surfaceEnabled: true)) - XCTAssertFalse( + XCTAssertTrue( DictationOverlayGate.shouldShowOverlay( trayEnabled: true, defaults: defaults, diff --git a/macos/CodescribeTests/LiveTranscriptTextViewTests.swift b/macos/CodescribeTests/LiveTranscriptTextViewTests.swift index 7e3113b1..66e53eb2 100644 --- a/macos/CodescribeTests/LiveTranscriptTextViewTests.swift +++ b/macos/CodescribeTests/LiveTranscriptTextViewTests.swift @@ -48,25 +48,14 @@ final class LiveTranscriptTextViewTests: XCTestCase { } func testNativeCopyUsesOnlyTheCurrentSelection() throws { - let pasteboard = NSPasteboard.general - let oldItems: [NSPasteboardItem] = (pasteboard.pasteboardItems ?? []).map { item in - let copy = NSPasteboardItem() - for type in item.types { - if let data = item.data(forType: type) { - copy.setData(data, forType: type) - } - } - return copy - } - defer { - pasteboard.clearContents() - pasteboard.writeObjects(oldItems) - } + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("codescribe.tests.live-transcript.\(UUID().uuidString)") + ) let textView = LiveTranscriptTextView.makeTextView() textView.string = "alpha beta gamma" textView.setSelectedRange(NSRange(location: 6, length: 4)) - textView.copy(nil) + XCTAssertTrue(textView.copySelection(to: pasteboard)) XCTAssertEqual(pasteboard.string(forType: .string), "beta") } diff --git a/macos/CodescribeTests/OverlayResizeHitTests.swift b/macos/CodescribeTests/OverlayResizeHitTests.swift new file mode 100644 index 00000000..d49804a9 --- /dev/null +++ b/macos/CodescribeTests/OverlayResizeHitTests.swift @@ -0,0 +1,66 @@ +import XCTest + +@testable import Codescribe + +final class OverlayResizeHitTests: XCTestCase { + private let bounds = NSRect(x: 0, y: 0, width: 400, height: 300) + private let band = OverlayResizeHit.band + + func testInteriorIsNotAResizeHit() { + XCTAssertNil(OverlayResizeHit.edge(at: NSPoint(x: 200, y: 150), in: bounds)) + } + + func testEdgesAndCornersUseTheFatBand() { + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 2, y: 150), in: bounds), .left) + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 398, y: 150), in: bounds), .right) + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 200, y: 298), in: bounds), .top) + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 200, y: 2), in: bounds), .bottom) + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 2, y: 298), in: bounds), .topLeft) + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 398, y: 298), in: bounds), .topRight) + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 2, y: 2), in: bounds), .bottomLeft) + XCTAssertEqual(OverlayResizeHit.edge(at: NSPoint(x: 398, y: 2), in: bounds), .bottomRight) + } + + func testJustInsideTheBandIsStillInterior() { + let inset = band + 1 + XCTAssertNil(OverlayResizeHit.edge(at: NSPoint(x: inset, y: 150), in: bounds)) + XCTAssertNil(OverlayResizeHit.edge(at: NSPoint(x: 200, y: inset), in: bounds)) + } + + func testApplyKeepsMinSizeWhenDraggingInward() { + let start = NSRect(x: 100, y: 80, width: 400, height: 320) + let minSize = NSSize(width: 320, height: 300) + let crushed = OverlayResizeHit.apply( + edge: .right, + start: start, + dx: -200, + dy: 0, + minSize: minSize + ) + XCTAssertEqual(crushed.width, minSize.width) + XCTAssertEqual(crushed.origin.x, start.origin.x) + } + + func testLeftAndBottomKeepTheOppositeEdgePinned() { + let start = NSRect(x: 100, y: 80, width: 400, height: 320) + let minSize = NSSize(width: 320, height: 300) + let left = OverlayResizeHit.apply( + edge: .left, + start: start, + dx: 20, + dy: 0, + minSize: minSize + ) + XCTAssertEqual(left.maxX, start.maxX) + XCTAssertEqual(left.width, 380) + let bottom = OverlayResizeHit.apply( + edge: .bottom, + start: start, + dx: 0, + dy: 20, + minSize: minSize + ) + XCTAssertEqual(bottom.maxY, start.maxY) + XCTAssertEqual(bottom.height, 300) + } +} diff --git a/macos/CodescribeTests/OverlayStateTests.swift b/macos/CodescribeTests/OverlayStateTests.swift index f87b4971..fb251538 100644 --- a/macos/CodescribeTests/OverlayStateTests.swift +++ b/macos/CodescribeTests/OverlayStateTests.swift @@ -815,6 +815,31 @@ final class OverlayStateTests: XCTestCase { XCTAssertEqual(closeCount, 1) } + func testManualEditProvenanceIsConsumedOnceAndRearmsOnlyOnAnotherEdit() { + let state = makeFinalizedState(clock: OverlayStateTestClock()) + state.userEditedTranscript("first human correction") + + XCTAssertEqual( + state.consumeManualEditProvenanceForQuality(isEdited: true), + "manual_human" + ) + XCTAssertNil(state.consumeManualEditProvenanceForQuality(isEdited: true)) + + state.userEditedTranscript("second human correction") + XCTAssertEqual( + state.consumeManualEditProvenanceForQuality(isEdited: true), + "manual_human" + ) + XCTAssertNil(state.consumeManualEditProvenanceForQuality(isEdited: false)) + + state.userEditedTranscript("manual before formatter") + state.replaceFormattedTranscriptProgrammatically("formatter output") + XCTAssertNil( + state.consumeManualEditProvenanceForQuality(isEdited: true), + "formatter and retranscribe assignments must cast zero votes" + ) + } + func testWindowDragReanchorsAutoHide() { let clock = OverlayStateTestClock() let state = makeFinalizedState(clock: clock) @@ -873,13 +898,16 @@ final class OverlayStateTests: XCTestCase { func testCopyKeepsOverlayVisibleAndRearmsAutoHide() { let clock = OverlayStateTestClock() let state = makeFinalizedState(clock: clock) + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("codescribe.tests.overlay.\(UUID().uuidString)") + ) var closeCount = 0 state.onClose = { closeCount += 1 } clock.now = 4 - state.copyToPasteboard() + state.copyToPasteboard(pasteboard) XCTAssertEqual(closeCount, 0) - XCTAssertEqual(NSPasteboard.general.string(forType: .string), "ready transcript") + XCTAssertEqual(pasteboard.string(forType: .string), "ready transcript") clock.now = 5 state.fireAutoHideNowForTests() @@ -1420,7 +1448,10 @@ final class OverlayStateTests: XCTestCase { state.mode = .formatted // Copy triggers captureQualityIfEdited because texts differ; must return immediately. - state.copyToPasteboard() + let pasteboard = NSPasteboard( + name: NSPasteboard.Name("codescribe.tests.overlay-quality.\(UUID().uuidString)") + ) + state.copyToPasteboard(pasteboard) XCTAssertEqual(closeCount, 0, "quality capture must not change Copy's stay-visible contract") // The async commit to quality + lexicon happens off-main; test reaches here without wait. } diff --git a/macos/CodescribeTests/SettingsTruthTests.swift b/macos/CodescribeTests/SettingsTruthTests.swift index 52c2c35e..22d0577c 100644 --- a/macos/CodescribeTests/SettingsTruthTests.swift +++ b/macos/CodescribeTests/SettingsTruthTests.swift @@ -129,6 +129,7 @@ final class SettingsTruthTests: XCTestCase { (.engine, "engine", "Dictation", .dictation), (.audio, "audio", "Audio", .audio), (.voiceLab, "voiceLab", "Dictionary", .dictionary), + (.lab, "lab", "Lab", .lab), (.license, "license", "License", .license), (.user, "user", "User", .user), ] @@ -392,8 +393,6 @@ final class SettingsTruthTests: XCTestCase { }) model.setSttEngine("whisper") - model.setLayeredTranscription(true) - model.setLayeredTranscription(false) model.setToggleSilenceSeconds(3.5) model.setPreviewBufferDelayMs(1038) model.setPreviewTypingCps(10.6) @@ -404,8 +403,6 @@ final class SettingsTruthTests: XCTestCase { writes.map(\.key), [ "CODESCRIBE_STT_ENGINE", - "CODESCRIBE_LAYERED_TRANSCRIPTION", - "CODESCRIBE_LAYERED_TRANSCRIPTION", "TOGGLE_SILENCE_SEC", "CODESCRIBE_BUFFER_DELAY_MS", "CODESCRIBE_TYPING_CPS", @@ -415,10 +412,93 @@ final class SettingsTruthTests: XCTestCase { XCTAssertEqual( writes.map(\.value), [ - "whisper", "phase1", "off", "3.5", "1038", "10.6", "5", "8.0", + "whisper", "3.5", "1038", "10.6", "5", "8.0", ]) } + func testLocalWhisperRuntimeTruthRequiresModelAndExactReadback() { + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "local_power", + sttEngineId: "auto", + layeredValue: nil, + modelAvailable: true + ), + .livePatchingConfigured, + "unset is the runtime's armed product default" + ) + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "local_power", + sttEngineId: "apple", + layeredValue: "off", + modelAvailable: false + ), + .livePatchingConfigurationMismatch, + "explicit off is disarmed even when the model is also unavailable" + ) + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "local_power", + sttEngineId: "auto", + layeredValue: localWhisperLivePatchingRuntimeValue, + modelAvailable: true + ), + .livePatchingConfigured + ) + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "local_power", + sttEngineId: "auto", + layeredValue: "phase2", + modelAvailable: true + ), + .livePatchingConfigurationMismatch, + "unknown legacy phases cannot masquerade as the armed phase1 contract" + ) + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "local_power", + sttEngineId: "auto", + layeredValue: nil, + modelAvailable: false + ), + .livePatchingNotReady, + "armed-by-default still needs a validated FP16 bundle" + ) + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "cloud", + sttEngineId: "auto", + layeredValue: localWhisperLivePatchingRuntimeValue, + modelAvailable: true + ), + .notSelected, + "Cloud must not present local Whisper as its provider" + ) + } + + func testDirectWhisperIsLocalEngineNotAppleFirstPatching() { + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "local_power", + sttEngineId: "whisper", + layeredValue: "off", + modelAvailable: true + ), + .directEngineReady + ) + XCTAssertEqual( + resolveLocalWhisperRuntimeState( + asrModeId: "local_power", + sttEngineId: "whisper", + layeredValue: localWhisperLivePatchingRuntimeValue, + modelAvailable: false + ), + .directEngineNotReady + ) + } + func testSmoothPresetValuesMatchOperatorDefaultExactly() throws { let smooth = try XCTUnwrap(presetValues(.smooth)) @@ -1108,8 +1188,11 @@ final class SettingsTruthTests: XCTestCase { switch key { case "CODESCRIBE_ASR_MODE": persisted.asrMode = value case "CODESCRIBE_CLOUD_CONSENT": persisted.cloudConsent = value + case "CODESCRIBE_LAYERED_TRANSCRIPTION": persisted.layeredTranscription = value + case "CODESCRIBE_STT_ENGINE": persisted.sttEngine = value case "CODESCRIBE_ASR_GATEWAY_URL": persisted.asrGatewayUrl = value case "STT_ENDPOINT": persisted.sttEndpoint = value + case "FINAL_PASS_MODE": persisted.finalPassMode = value default: break } } @@ -1128,19 +1211,33 @@ final class SettingsTruthTests: XCTestCase { XCTAssertFalse(model.cloudConsentGranted) model.setAsrMode("local_power") - XCTAssertEqual(writes.last?.0, "CODESCRIBE_ASR_MODE") - XCTAssertEqual(writes.last?.1, "local_power") + XCTAssertEqual( + writes.suffix(2).map(\.0), + ["CODESCRIBE_ASR_MODE", "CODESCRIBE_LAYERED_TRANSCRIPTION"] + ) + XCTAssertEqual(writes.suffix(2).map(\.1), ["local_power", "phase1"]) XCTAssertEqual(model.asrModeId, "local_power") model.setAsrMode("cloud") XCTAssertEqual( - writes.suffix(2).map(\.0), - ["CODESCRIBE_CLOUD_CONSENT", "CODESCRIBE_ASR_MODE"] + writes.suffix(3).map(\.0), + [ + "CODESCRIBE_CLOUD_CONSENT", "CODESCRIBE_ASR_MODE", + "CODESCRIBE_LAYERED_TRANSCRIPTION", + ] ) - XCTAssertEqual(writes.suffix(2).map(\.1), ["granted", "cloud"]) + XCTAssertEqual(writes.suffix(3).map(\.1), ["granted", "cloud", "off"]) XCTAssertEqual(model.asrModeId, "cloud") XCTAssertTrue(model.cloudConsentGranted) + model.setAsrMode("apple_only") + XCTAssertEqual( + writes.suffix(2).map(\.0), + ["CODESCRIBE_ASR_MODE", "CODESCRIBE_LAYERED_TRANSCRIPTION"] + ) + XCTAssertEqual(writes.suffix(2).map(\.1), ["apple_only", "off"]) + XCTAssertEqual(model.asrModeId, "apple_only") + model.setSttEndpoint("wss://asr.example/v1/audio/transcribe") XCTAssertEqual(writes.last?.0, "STT_ENDPOINT") model.setAsrGatewayUrl("https://gateway.example/session") @@ -1150,4 +1247,41 @@ final class SettingsTruthTests: XCTestCase { XCTAssertEqual(writes.last?.0, "FINAL_PASS_MODE") XCTAssertEqual(writes.last?.1, "off") } + + func testLocalPowerEngineChangesArmAndDisarmExactRuntimeTokenAfterReadback() { + var persisted = CsSettings.sample + persisted.asrMode = "local_power" + persisted.sttEngine = "auto" + persisted.layeredTranscription = "off" + var batches: [[CsConfigEntry]] = [] + let engine = MockSettingsEngine( + settingsLoader: { persisted }, + updateConfigManyObserver: { entries in + batches.append(entries) + for entry in entries { + switch entry.key { + case "CODESCRIBE_STT_ENGINE": persisted.sttEngine = entry.value + case "CODESCRIBE_LAYERED_TRANSCRIPTION": persisted.layeredTranscription = entry.value + default: break + } + } + } + ) + let model = SettingsViewModel(engine: engine) + model.refresh() + + model.setSttEngine("apple") + XCTAssertEqual( + batches.last?.map { ($0.key, $0.value) }.map { "\($0.0)=\($0.1)" }, + ["CODESCRIBE_STT_ENGINE=apple", "CODESCRIBE_LAYERED_TRANSCRIPTION=phase1"] + ) + XCTAssertEqual(model.settings.layeredTranscription, "phase1") + + model.setSttEngine("whisper") + XCTAssertEqual( + batches.last?.map { ($0.key, $0.value) }.map { "\($0.0)=\($0.1)" }, + ["CODESCRIBE_STT_ENGINE=whisper", "CODESCRIBE_LAYERED_TRANSCRIPTION=off"] + ) + XCTAssertEqual(model.settings.layeredTranscription, "off") + } } diff --git a/macos/CodescribeTests/VoiceLabTests.swift b/macos/CodescribeTests/VoiceLabTests.swift index 892b69d0..87e4da20 100644 --- a/macos/CodescribeTests/VoiceLabTests.swift +++ b/macos/CodescribeTests/VoiceLabTests.swift @@ -18,6 +18,7 @@ final class VoiceLabTests: XCTestCase { variant: "uni agentka", editedText: "Junie", action: "copy", + editProvenance: nil, timestampMs: 42, avgLogprob: nil, speechPct: nil, @@ -59,6 +60,7 @@ final class VoiceLabTests: XCTestCase { variant: "before", editedText: "after", action: "send", + editProvenance: nil, timestampMs: 84, avgLogprob: nil, speechPct: nil, @@ -169,6 +171,7 @@ final class VoiceLabTests: XCTestCase { variant: "uni agentka", editedText: "Junie", action: "copy", + editProvenance: nil, timestampMs: 42, avgLogprob: nil, speechPct: nil, @@ -181,6 +184,7 @@ final class VoiceLabTests: XCTestCase { variant: "uni agentka", editedText: "Junie Prime", action: "edit", + editProvenance: "manual_human", timestampMs: 84, avgLogprob: nil, speechPct: nil, @@ -221,6 +225,7 @@ final class VoiceLabTests: XCTestCase { variant: "variant", editedText: "edited", action: "edit", + editProvenance: "manual_human", timestampMs: 1, avgLogprob: nil, speechPct: nil, @@ -254,6 +259,7 @@ final class VoiceLabTests: XCTestCase { variant: "uni agentka", editedText: "Junie", action: "copy", + editProvenance: nil, timestampMs: 42, avgLogprob: nil, speechPct: nil, diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 01895c21..066b75bd 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -18,6 +18,7 @@ # # Usage: # scripts/build-app.sh [debug|local-release|release] +# scripts/build-app.sh --stage-agent-bridge [bundle-version] # # Env toggles: # SKIP_XCODEBUILD=1 stop after xcodegen (verifies stages 1-4 without Xcode) @@ -28,6 +29,95 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" +stage_agent_bridge() { + local destination="$1" + local bundle_version="$2" + python3 - "$REPO_ROOT" "$destination" "$bundle_version" <<'PY' +import hashlib +import json +import os +import shutil +import stat +import sys +from pathlib import Path, PurePosixPath + +repo = Path(sys.argv[1]).resolve() +destination = Path(sys.argv[2]).resolve() +bundle_version = sys.argv[3] +skill_source = repo / "skills" / "codescribe" +helper_source = repo / "scripts" / "bus-demux.py" +if not (skill_source / "SKILL.md").is_file() or not helper_source.is_file(): + raise SystemExit("agent bridge source is incomplete") +if destination == destination.parent or destination == Path.home(): + raise SystemExit(f"refusing unsafe agent bridge destination: {destination}") + +stage = destination.parent / f".{destination.name}.stage-{os.getpid()}" +backup = destination.parent / f".{destination.name}.backup-{os.getpid()}" +for scratch in (stage, backup): + if scratch.exists(): + shutil.rmtree(scratch) +stage.mkdir(parents=True, mode=0o755) +shutil.copytree(skill_source, stage / "skills" / "codescribe") +(stage / "bin").mkdir(mode=0o755) +shutil.copy2(helper_source, stage / "bin" / "bus-demux.py") +(stage / "bin" / "bus-demux.py").chmod(0o755) + +files = [] +for path in sorted(candidate for candidate in stage.rglob("*") if candidate.is_file()): + if path.is_symlink(): + raise SystemExit(f"agent bridge payload may not contain symlinks: {path}") + relative = PurePosixPath(path.relative_to(stage).as_posix()) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + mode = stat.S_IMODE(path.stat().st_mode) + files.append({ + "path": str(relative), + "sha256": digest, + "bytes": path.stat().st_size, + "mode": f"{mode:04o}", + }) + +manifest = { + "schema": "codescribe.agent-bridge.bundle.v1", + "bundle_version": bundle_version, + "helper": "bin/bus-demux.py", + "skill": "skills/codescribe", + "files": files, +} +(stage / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", +) +(stage / "manifest.json").chmod(0o644) + +try: + if destination.exists(): + os.replace(destination, backup) + os.replace(stage, destination) + if backup.exists(): + shutil.rmtree(backup) +except BaseException: + if destination.exists() and backup.exists(): + shutil.rmtree(destination) + if backup.exists(): + os.replace(backup, destination) + raise +finally: + if stage.exists(): + shutil.rmtree(stage) +PY +} + +if [[ "${1:-}" == "--stage-agent-bridge" ]]; then + if [[ -z "${2:-}" ]]; then + echo "usage: $0 --stage-agent-bridge [bundle-version]" >&2 + exit 2 + fi + BRIDGE_STAGE_VERSION="${3:-$(sed -n 's/^version = "\(.*\)"/\1/p' "$REPO_ROOT/Cargo.toml" | head -1)}" + stage_agent_bridge "$2" "$BRIDGE_STAGE_VERSION" + echo "==> Agent bridge staged: $2 (v$BRIDGE_STAGE_VERSION)" + exit 0 +fi + PROFILE="${1:-debug}" case "$PROFILE" in debug) @@ -243,6 +333,9 @@ if [[ -n "$EMBEDDER_RUNTIME_SOURCE" ]]; then else echo " MiniLM is compiled into the binary by explicit CODESCRIBE_EMBED_EMBEDDER=1." fi +AGENT_BRIDGE_BUNDLE_DIR="$APP/Contents/Resources/agent-bridge" +stage_agent_bridge "$AGENT_BRIDGE_BUNDLE_DIR" "$STAMP_VERSION" +echo " Agent bridge skill tree + session helper bundled at Contents/Resources/agent-bridge." STT_BRIDGE_BUNDLED=0 # Same host-triple pin as Makefile ENGINE_BRIDGE_TARGET (W0-B / S-1): avoid # inheriting the builder's macosxN.0 so bundled bridges match CI/dev hosts. diff --git a/scripts/bus-demux.py b/scripts/bus-demux.py new file mode 100755 index 00000000..8ea0d798 --- /dev/null +++ b/scripts/bus-demux.py @@ -0,0 +1,648 @@ +#!/usr/bin/env python3 +"""Named, session-aware follower for the clean Codescribe Transcript Bus. + +The helper never opens audio. It reads ``codescribe.transcript.v1`` NDJSON and +emits small agent-bridge envelopes. Product installs run it from the stable +path below, not from a source checkout:: + + python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py \ + --provider codex --session --name james --drafts --follow + +``--provider`` plus ``--session`` enables a collision-safe lease, heartbeat, +and byte cursor. Re-running the same command resumes after the last consumed +bus line, including lines appended while the provider session was recovering. +Drafts are useful for live replies; only a ``transcript_sealed`` envelope sets +``state_change_allowed`` to true. +""" + +from __future__ import annotations + +import argparse +import fcntl +import hashlib +import json +import os +import re +import sys +import time +from pathlib import Path +from typing import Any, Iterator + +BUS_FILENAME = "transcript-events.jsonl" +SEALED = "transcript_sealed" +LIVE_STATUSES = ("utterance_draft", "utterance_revised") +LEASE_SCHEMA = "codescribe.agent-bridge.lease.v1" +ATTACH_SCHEMA = "codescribe.agent-bridge.attach.v1" +EVENT_SCHEMA = "codescribe.agent-bridge.event.v1" +ACTIVE_NAMES_SCHEMA = "codescribe.agent-bridge.active-names.v1" +DEFAULT_LEASE_TTL_SECONDS = 120.0 +ASSIGN_RE = re.compile( + r"(?i)(?:będziesz(?:\s+od)?\s+teraz|nazywam\s+cię|nazywasz\s+się|" + r"you(?:['’]re|\s+are)|cześć|hello)\s+([A-Za-zĄĆĘŁŃÓŚŹŻąćęłńóśźż]{2,32})" +) +SAFE_LEASE_RE = re.compile(r"^[a-zA-Z0-9_-]{8,80}$") + + +def bus_path() -> Path: + for key in ("CODESCRIBE_TRANSCRIPT_BUS_PATH", "CODESCRIBE_TRANSCRIPT_BUS"): + raw = os.environ.get(key, "").strip() + if raw: + return Path(os.path.expanduser(raw)) + xdg = os.environ.get("XDG_STATE_HOME", "").strip() + if xdg: + return Path(os.path.expanduser(xdg)) / "codescribe" / BUS_FILENAME + return Path.home() / ".codescribe" / BUS_FILENAME + + +def bridge_home() -> Path: + override = os.environ.get("CODESCRIBE_AGENT_BRIDGE_HOME", "").strip() + if override: + return Path(os.path.expanduser(override)) + return Path.home() / ".codescribe" / "agent-bridge" + + +def name_pat(name: str) -> re.Pattern[str]: + stem = re.escape(name.strip()) + return re.compile(rf"(?i)\b{stem}(?:ie|owi|a|em|u|ie|ieś|owi)?\b") + + +def assigned_name(text: str) -> str | None: + match = ASSIGN_RE.search(text or "") + if not match: + return None + return match.group(1).casefold() + + +def addressed_to(text: str, name: str) -> bool: + if not name: + return False + return name_pat(name).search(text or "") is not None + + +def event_kind(status: Any) -> str: + return { + "utterance_draft": "draft", + "utterance_revised": "revised", + SEALED: "seal", + }.get(str(status), "event") + + +def slim( + event: dict[str, Any], audience: str, kind: str | None = None +) -> dict[str, Any]: + status = event.get("status") + return { + "schema": EVENT_SCHEMA, + "audience": audience, + "kind": kind or event_kind(status), + "status": status, + "sequence": event.get("sequence"), + "session_id": event.get("session_id"), + "utterance_id": event.get("utterance_id"), + "emitted_at": event.get("emitted_at"), + "mode": event.get("mode"), + "text": event.get("text") or "", + "state_change_allowed": status == SEALED, + } + + +def parse_line(raw: str) -> dict[str, Any] | None: + raw = raw.strip() + if not raw: + return None + try: + event = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(event, dict): + return None + if event.get("schema") not in (None, "codescribe.transcript.v1"): + return None + return event + + +def emit(payload: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n") + sys.stdout.flush() + + +def consider( + event: dict[str, Any], + *, + name: str | None, + hear_all: bool, + drafts: bool, + debug: bool, +) -> dict[str, Any] | None: + status = event.get("status") + if status != SEALED and not (drafts and status in LIVE_STATUSES): + return None + text = event.get("text") or "" + claimed = assigned_name(text) + if claimed: + payload = slim(event, claimed, kind="name_assignment") + payload["name"] = claimed + if ( + hear_all + or (name and claimed == name.casefold()) + or addressed_to(text, name or "") + ): + return payload + if debug: + sys.stderr.write(f"bus-demux: drop assignment name={claimed}\n") + return None + if hear_all: + return slim(event, "*") + if name and addressed_to(text, name): + return slim(event, name.casefold()) + if debug and status == SEALED: + sys.stderr.write("bus-demux: drop unnamed-or-other seal\n") + return None + + +def iter_new_lines(path: Path, offset: int) -> tuple[list[tuple[str, int]], int]: + """Return complete UTF-8 lines paired with their exclusive byte cursors.""" + try: + size = path.stat().st_size + except FileNotFoundError: + return [], offset + if size < offset: + offset = 0 + entries: list[tuple[str, int]] = [] + with path.open("rb") as handle: + handle.seek(offset) + while True: + raw = handle.readline() + if not raw: + break + if not raw.endswith(b"\n"): + break + entries.append((raw.decode("utf-8", errors="replace"), handle.tell())) + return entries, entries[-1][1] if entries else offset + + +def replay(path: Path) -> Iterator[str]: + try: + raw = path.read_text(encoding="utf-8", errors="replace") + except FileNotFoundError: + return + yield from () # pragma: no cover - keeps the generator type + for line in raw.splitlines(): + yield line + + +def utc_now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def atomic_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + path.parent.chmod(0o700) + except OSError: + pass + temporary = path.with_name(f".{path.name}.{os.getpid()}.{time.time_ns()}.tmp") + encoded = (json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n").encode( + "utf-8" + ) + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + path.chmod(0o600) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def read_json(path: Path) -> dict[str, Any] | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + return value if isinstance(value, dict) else None + + +def lease_identifier(provider: str, provider_session_id: str) -> str: + # The provider session owns the cursor. Name is mutable during --become and + # therefore cannot participate in the key: binding a name must not fork the + # greeting follower onto a fresh cursor. + identity = "\0".join((provider.casefold(), provider_session_id)) + return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32] + + +def process_is_alive(pid: Any) -> bool: + if not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def active_leases( + root: Path, ttl_seconds: float, *, clean: bool = True +) -> list[dict[str, Any]]: + leases: list[dict[str, Any]] = [] + now = time.time() + lease_dir = root / "leases" + try: + candidates = list(lease_dir.glob("*.json")) + except OSError: + return [] + for path in candidates: + value = read_json(path) + heartbeat = value.get("heartbeat_unix") if value else None + fresh = ( + isinstance(heartbeat, (int, float)) + and now - float(heartbeat) <= ttl_seconds + ) + if not value or value.get("schema") != LEASE_SCHEMA or not fresh: + if clean: + try: + path.unlink() + except OSError: + pass + continue + if value.get("active") is True: + leases.append(value) + return leases + + +class SessionLease: + """One provider-session cursor and active-name heartbeat.""" + + def __init__( + self, + *, + root: Path, + provider: str, + provider_session_id: str, + name: str | None, + bus: Path, + requested_id: str | None, + ttl_seconds: float, + follow_from_end: bool, + ) -> None: + self.root = root + self.provider = provider.casefold() + self.provider_session_id = provider_session_id + self.name = name.casefold() if name else None + self.bus = str(bus.expanduser().resolve(strict=False)) + self.ttl_seconds = ttl_seconds + self.lease_id = requested_id or lease_identifier(provider, provider_session_id) + if not SAFE_LEASE_RE.fullmatch(self.lease_id): + raise ValueError("lease id must be 8-80 letters, digits, '_' or '-'") + self.path = root / "leases" / f"{self.lease_id}.json" + self.lock_path = root / "leases" / f"{self.lease_id}.lock" + self.lock_descriptor: int | None = None + self._acquire_lock() + try: + previous = read_json(self.path) + if previous and not self._matches(previous): + raise ValueError( + f"lease {self.lease_id} belongs to a different provider session or bus" + ) + self.resumed = False + self.cursor = 0 + self.last_sequence: Any = None + if previous and self._matches(previous): + heartbeat = previous.get("heartbeat_unix") + fresh = ( + isinstance(heartbeat, (int, float)) + and time.time() - float(heartbeat) <= ttl_seconds + ) + other_pid = previous.get("pid") + if ( + fresh + and previous.get("active") is True + and other_pid != os.getpid() + and process_is_alive(other_pid) + ): + raise RuntimeError( + f"lease {self.lease_id} is active in pid={other_pid}; " + "poll that follower handle" + ) + self.cursor = max(0, int(previous.get("cursor", 0))) + self.last_sequence = previous.get("last_sequence") + self.name = previous.get("name") or self.name + self.resumed = True + elif follow_from_end: + try: + self.cursor = bus.stat().st_size + except FileNotFoundError: + self.cursor = 0 + self.persist(active=True) + active_leases(root, ttl_seconds, clean=True) + except BaseException: + self._release_lock() + raise + + def _acquire_lock(self) -> None: + self.lock_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as error: + os.close(descriptor) + raise RuntimeError( + f"lease {self.lease_id} already has an active follower; poll that handle" + ) from error + self.lock_descriptor = descriptor + + def _release_lock(self) -> None: + if self.lock_descriptor is None: + return + try: + fcntl.flock(self.lock_descriptor, fcntl.LOCK_UN) + finally: + os.close(self.lock_descriptor) + self.lock_descriptor = None + + def _matches(self, value: dict[str, Any]) -> bool: + return ( + value.get("schema") == LEASE_SCHEMA + and value.get("lease_id") == self.lease_id + and value.get("provider") == self.provider + and value.get("provider_session_id") == self.provider_session_id + and value.get("bus") == self.bus + ) + + def persist( + self, + *, + active: bool, + cursor: int | None = None, + sequence: Any = None, + ) -> None: + if cursor is not None: + self.cursor = cursor + if sequence is not None: + self.last_sequence = sequence + atomic_json( + self.path, + { + "schema": LEASE_SCHEMA, + "lease_id": self.lease_id, + "provider": self.provider, + "provider_session_id": self.provider_session_id, + "name": self.name, + "bus": self.bus, + "cursor": self.cursor, + "last_sequence": self.last_sequence, + "active": active, + "pid": os.getpid(), + "heartbeat_unix": time.time(), + "updated_at": utc_now(), + }, + ) + + def bind_name(self, name: str) -> None: + self.name = name.casefold() + self.persist(active=True) + + def enrich(self, payload: dict[str, Any]) -> None: + payload["lease_id"] = self.lease_id + payload["provider"] = self.provider + payload["provider_session_id"] = self.provider_session_id + identity = "\0".join( + str(payload.get(key) or "") + for key in ("session_id", "utterance_id", "sequence", "status", "audience") + ) + payload["delivery_id"] = hashlib.sha256(identity.encode("utf-8")).hexdigest()[ + :24 + ] + + def attach_receipt(self) -> dict[str, Any]: + names = sorted( + { + str(item["name"]) + for item in active_leases(self.root, self.ttl_seconds) + if item.get("name") + } + ) + return { + "schema": ATTACH_SCHEMA, + "kind": "attach", + "lease_id": self.lease_id, + "provider": self.provider, + "provider_session_id": self.provider_session_id, + "name": self.name, + "bus": self.bus, + "cursor": self.cursor, + "resumed": self.resumed, + "active_names": names, + } + + def close(self) -> None: + try: + self.persist(active=False) + finally: + self._release_lock() + + +def run(args: argparse.Namespace) -> int: + path: Path = args.bus + name: str | None = args.name.casefold() if args.name else None + hear_all = bool(args.all or args.become) + if not name and not hear_all: + sys.stderr.write( + "bus-demux: unnamed agent does not pass; pass --name or --become/--all\n" + ) + return 2 + + lease: SessionLease | None = None + if args.provider: + try: + lease = SessionLease( + root=args.bridge_home, + provider=args.provider, + provider_session_id=args.session, + name=name, + bus=path, + requested_id=args.lease, + ttl_seconds=args.lease_ttl, + follow_from_end=bool(args.follow and not args.from_start), + ) + except (OSError, RuntimeError, ValueError) as error: + sys.stderr.write(f"bus-demux: session lease refused: {error}\n") + return 3 + if lease.name and not name: + name = lease.name + hear_all = False + emit(lease.attach_receipt()) + + def handle(raw: str, next_cursor: int | None = None) -> None: + nonlocal name, hear_all + event = parse_line(raw) + if lease and next_cursor is not None: + lease.persist( + active=True, + cursor=next_cursor, + sequence=event.get("sequence") if event else None, + ) + if event is None: + return + payload = consider( + event, + name=name, + hear_all=hear_all, + drafts=args.drafts, + debug=args.debug, + ) + if payload is None: + return + if args.become and payload.get("kind") == "name_assignment" and not name: + name = str(payload["name"]) + hear_all = False + if lease: + lease.bind_name(name) + sys.stderr.write(f"bus-demux: bound name={name}\n") + if lease: + lease.enrich(payload) + emit(payload) + + try: + if args.once: + last = None + for raw in replay(path): + event = parse_line(raw) + if event is None: + continue + payload = consider( + event, + name=name, + hear_all=hear_all, + drafts=args.drafts, + debug=False, + ) + if payload is not None: + last = payload + if last is None: + return 1 + if lease: + lease.enrich(last) + emit(last) + return 0 + + if lease: + offset = lease.cursor + elif args.follow and not args.from_start: + try: + offset = path.stat().st_size + except FileNotFoundError: + offset = 0 + else: + offset = 0 + + sys.stderr.write( + f"bus-demux: bus={path} name={name or '*'} follow={int(args.follow)}" + f" lease={lease.lease_id if lease else '-'}\n" + ) + last_heartbeat = time.monotonic() + while True: + entries, offset = iter_new_lines(path, offset) + for raw, next_cursor in entries: + handle(raw, next_cursor) + if not args.follow: + return 0 + if lease and time.monotonic() - last_heartbeat >= 1.0: + lease.persist(active=True, cursor=offset) + last_heartbeat = time.monotonic() + time.sleep(args.interval) + except KeyboardInterrupt: + return 130 + finally: + if lease: + lease.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bus", type=Path, default=None, help="override bus path") + parser.add_argument( + "--name", default=None, help="bound agent name (kielbasa filter)" + ) + parser.add_argument("--all", action="store_true", help="promiscuous: every seal") + parser.add_argument( + "--become", + action="store_true", + help="hear all until a name assignment, then filter", + ) + parser.add_argument("--follow", action="store_true", help="tail the bus") + parser.add_argument( + "--once", action="store_true", help="print last matching event and exit" + ) + parser.add_argument( + "--from-start", action="store_true", help="replay existing lines first" + ) + parser.add_argument( + "--drafts", action="store_true", help="also emit draft/revised envelopes" + ) + parser.add_argument( + "--provider", help="client id, for example codex or claude-code" + ) + parser.add_argument( + "--session", help="stable provider-session id used for cursor recovery" + ) + parser.add_argument("--lease", help="reattach to an explicit lease id") + parser.add_argument( + "--bridge-home", type=Path, default=None, help="override lease/receipt root" + ) + parser.add_argument("--lease-ttl", type=float, default=DEFAULT_LEASE_TTL_SECONDS) + parser.add_argument( + "--active-names", + action="store_true", + help="print non-stale active session names and exit", + ) + parser.add_argument("--debug", action="store_true") + parser.add_argument("--interval", type=float, default=0.15) + args = parser.parse_args() + if args.bus is None: + args.bus = bus_path() + if args.bridge_home is None: + args.bridge_home = bridge_home() + if bool(args.provider) != bool(args.session): + parser.error("--provider and --session must be supplied together") + if args.lease and not args.provider: + parser.error("--lease requires --provider and --session") + if args.lease_ttl <= 0: + parser.error("--lease-ttl must be positive") + if args.active_names: + leases = active_leases(args.bridge_home, args.lease_ttl, clean=True) + emit( + { + "schema": ACTIVE_NAMES_SCHEMA, + "kind": "active_names", + "names": sorted( + {str(item["name"]) for item in leases if item.get("name")} + ), + "leases": [ + { + "lease_id": item.get("lease_id"), + "provider": item.get("provider"), + "provider_session_id": item.get("provider_session_id"), + "name": item.get("name"), + } + for item in leases + ], + } + ) + return 0 + if args.once and args.follow: + parser.error("--once and --follow cannot combine") + if not args.once and not args.follow and not args.from_start: + args.follow = True + return run(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/e2e-blackhole-dictation.sh b/scripts/e2e-blackhole-dictation.sh index 5c43cde0..a5155122 100755 --- a/scripts/e2e-blackhole-dictation.sh +++ b/scripts/e2e-blackhole-dictation.sh @@ -354,12 +354,10 @@ if [ "${ENGINE:-apple}" = "apple" ]; then export CODESCRIBE_BRIDGE_DISCLAIM=1 fi -# Which lane is this run measuring? The core injects ~/.codescribe/.env into the -# process environment (CODESCRIBE_LAYERED_TRANSCRIPTION is a power-user key, not -# a promoted setting), so an unpinned run can silently score a different layer -# than the caller intended. Print it here and let the test assert it against the -# events it actually saw (`measured_lane_matches_request`). -info "layered lane: ${CODESCRIBE_LAYERED_TRANSCRIPTION:-}" +# Which compatibility override is this run measuring? The key is promoted to +# settings.json, while process env still wins for this harness. Print the +# explicit test input and let runtime receipts prove the lane actually armed. +info "layered override: ${CODESCRIBE_LAYERED_TRANSCRIPTION:-}" # Pre-build so compile time cannot eat into anything timing-sensitive. cargo test --test e2e_overlay_delivery_parity --no-run >"$WORK/build.log" 2>&1 || diff --git a/scripts/tests/agent-bridge-payload-test.sh b/scripts/tests/agent-bridge-payload-test.sh new file mode 100755 index 00000000..92c4ab10 --- /dev/null +++ b/scripts/tests/agent-bridge-payload-test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Hermetic staging proof for the signed-app agent bridge payload. No Xcode, app, +# home-directory write, microphone, or network access. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +PAYLOAD="$WORKDIR/Codescribe.app/Contents/Resources/agent-bridge" + +"$ROOT/scripts/build-app.sh" --stage-agent-bridge "$PAYLOAD" 9.8.7 +FIRST_MANIFEST="$(shasum -a 256 "$PAYLOAD/manifest.json" | awk '{print $1}')" + +python3 - "$ROOT" "$PAYLOAD" <<'PY' +import hashlib +import json +import os +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +payload = Path(sys.argv[2]) +manifest = json.loads((payload / "manifest.json").read_text(encoding="utf-8")) +assert manifest["schema"] == "codescribe.agent-bridge.bundle.v1", manifest +assert manifest["bundle_version"] == "9.8.7", manifest +assert manifest["helper"] == "bin/bus-demux.py", manifest +assert manifest["skill"] == "skills/codescribe", manifest + +source_skill = root / "skills" / "codescribe" +expected = { + f"skills/codescribe/{path.relative_to(source_skill).as_posix()}" + for path in source_skill.rglob("*") + if path.is_file() +} +expected.add("bin/bus-demux.py") +listed = {entry["path"] for entry in manifest["files"]} +actual = { + path.relative_to(payload).as_posix() + for path in payload.rglob("*") + if path.is_file() and path.name != "manifest.json" +} +assert listed == expected == actual, (listed ^ expected, actual ^ expected) +for entry in manifest["files"]: + path = payload / entry["path"] + assert hashlib.sha256(path.read_bytes()).hexdigest() == entry["sha256"], entry + assert path.stat().st_size == entry["bytes"], entry +assert os.access(payload / "bin" / "bus-demux.py", os.X_OK) +PY + +# Re-stage over a tampered payload. The atomic update restores canonical bytes +# and produces an identical deterministic manifest. +printf 'tampered\n' >>"$PAYLOAD/bin/bus-demux.py" +"$ROOT/scripts/build-app.sh" --stage-agent-bridge "$PAYLOAD" 9.8.7 +SECOND_MANIFEST="$(shasum -a 256 "$PAYLOAD/manifest.json" | awk '{print $1}')" +test "$FIRST_MANIFEST" = "$SECOND_MANIFEST" + +# The packaged helper runs after leaving the checkout: its runtime path has no +# dependency on repository-relative imports or files. +BUS="$WORKDIR/transcript-events.jsonl" +python3 - "$BUS" <<'PY' +import json, sys +with open(sys.argv[1], "w", encoding="utf-8") as handle: + handle.write(json.dumps({ + "schema": "codescribe.transcript.v1", + "sequence": 1, + "session_id": "payload-test", + "status": "transcript_sealed", + "text": "James, payload działa.", + }, ensure_ascii=False) + "\n") +PY +OUTPUT="$(cd "$WORKDIR" && python3 "$PAYLOAD/bin/bus-demux.py" --bus "$BUS" --name james --once)" +python3 - "$OUTPUT" <<'PY' +import json, sys +value = json.loads(sys.argv[1]) +assert value["kind"] == "seal", value +assert value["state_change_allowed"] is True, value +PY + +echo "agent-bridge-payload: ok" diff --git a/scripts/tests/bus-demux-test.sh b/scripts/tests/bus-demux-test.sh new file mode 100755 index 00000000..4629ca59 --- /dev/null +++ b/scripts/tests/bus-demux-test.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# Hermetic kielbasa checks for scripts/bus-demux.py. No microphone. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +DEMUX="$ROOT/scripts/bus-demux.py" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT +BUS="$WORKDIR/transcript-events.jsonl" +chmod +x "$DEMUX" + +seal() { + local text="$1" + local status="${2:-transcript_sealed}" + local sequence="${3:-1}" + python3 - "$BUS" "$text" "$status" "$sequence" <<'PY' +import json, sys +path, text, status, sequence = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]) +event = { + "schema": "codescribe.transcript.v1", + "sequence": sequence, + "session_id": "test-session", + "mode": "raw", + "utterance_id": "utterance-1", + "emitted_at": "2026-08-20T22:00:00Z", + "status": status, + "text": text, +} +with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps(event, ensure_ascii=False) + "\n") +PY +} + +run_once() { + python3 "$DEMUX" --bus "$BUS" --once "$@" +} + +: >"$BUS" +if python3 "$DEMUX" --bus "$BUS" --once >/dev/null 2>"$WORKDIR/err"; then + echo "expected unnamed refuse" >&2 + exit 1 +fi +grep -q "unnamed agent does not pass" "$WORKDIR/err" + +seal "zwykła dyktando do karetki bez imienia" +if run_once --name james >/dev/null 2>/dev/null; then + echo "expected drop of unnamed seal" >&2 + exit 1 +fi + +seal "James, wklejka nadal parkuje." +got="$(run_once --name james)" +python3 - "$got" <<'PY' +import json, sys +o = json.loads(sys.argv[1]) +assert o["audience"] == "james", o +assert "parkuje" in o["text"], o +assert o["kind"] == "seal", o +assert o["schema"] == "codescribe.agent-bridge.event.v1", o +assert o["state_change_allowed"] is True, o +PY + +got="$(run_once --all)" +python3 - "$got" <<'PY' +import json, sys +o = json.loads(sys.argv[1]) +assert o["audience"] == "*", o +PY + +: >"$BUS" +seal "Cześć James. Będziesz od teraz James." +got="$(run_once --become)" +python3 - "$got" <<'PY' +import json, sys +o = json.loads(sys.argv[1]) +assert o["kind"] == "name_assignment", o +assert o["name"] == "james", o +PY + +# A provider-scoped follower emits an attach receipt and all addressed live +# envelopes, then persists the byte cursor. Reattachment consumes only lines +# written after that cursor: no old command replay, no recovery gap. +BRIDGE_HOME="$WORKDIR/agent-bridge" +: >"$BUS" +seal "James, szkic pierwszy." utterance_draft 10 +seal "James, szkic poprawiony." utterance_revised 11 +seal "James, komenda zamknięta." transcript_sealed 12 +first="$WORKDIR/first.jsonl" +python3 "$DEMUX" \ + --bus "$BUS" --bridge-home "$BRIDGE_HOME" \ + --provider codex --session codex-session-a --name james \ + --drafts --from-start >"$first" +python3 - "$first" <<'PY' +import json, sys +rows = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +assert [row["kind"] for row in rows] == ["attach", "draft", "revised", "seal"], rows +attach = rows[0] +assert attach["resumed"] is False, attach +assert attach["provider"] == "codex", attach +lease_ids = {row["lease_id"] for row in rows} +assert lease_ids == {attach["lease_id"]}, rows +assert rows[1]["state_change_allowed"] is False, rows[1] +assert rows[2]["state_change_allowed"] is False, rows[2] +assert rows[3]["state_change_allowed"] is True, rows[3] +PY + +seal "James, komenda po recovery." transcript_sealed 13 +second="$WORKDIR/second.jsonl" +python3 "$DEMUX" \ + --bus "$BUS" --bridge-home "$BRIDGE_HOME" \ + --provider codex --session codex-session-a --name james \ + --drafts --from-start >"$second" +python3 - "$first" "$second" <<'PY' +import json, sys +first = [json.loads(line) for line in open(sys.argv[1], encoding="utf-8")] +second = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] +assert [row["kind"] for row in second] == ["attach", "seal"], second +assert second[0]["resumed"] is True, second[0] +assert second[0]["lease_id"] == first[0]["lease_id"], second[0] +assert second[1]["sequence"] == 13, second[1] +assert "komenda po recovery" in second[1]["text"], second[1] +PY + +# Same human name does not collapse provider sessions onto one cursor. +third="$WORKDIR/third.jsonl" +python3 "$DEMUX" \ + --bus "$BUS" --bridge-home "$BRIDGE_HOME" \ + --provider claude-code --session claude-session-a --name james \ + --drafts --from-start >"$third" +python3 - "$first" "$third" <<'PY' +import json, sys +first = json.loads(open(sys.argv[1], encoding="utf-8").readline()) +third = [json.loads(line) for line in open(sys.argv[2], encoding="utf-8")] +assert third[0]["lease_id"] != first["lease_id"], (first, third[0]) +assert third[0]["provider"] == "claude-code", third[0] +assert [row["kind"] for row in third[1:]] == ["draft", "revised", "seal", "seal"], third +PY + +# Active-name discovery is lease-derived and cleans stale leases without audio. +python3 - "$DEMUX" "$BUS" "$BRIDGE_HOME" <<'PY' +import importlib.util, json, sys, time +from pathlib import Path + +spec = importlib.util.spec_from_file_location("bus_demux", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(module) +lease = module.SessionLease( + root=Path(sys.argv[3]), provider="codex", provider_session_id="active-session", + name="iwo", bus=Path(sys.argv[2]), requested_id=None, ttl_seconds=120, + follow_from_end=True, +) +stale = Path(sys.argv[3]) / "leases" / "stale-lease.json" +module.atomic_json(stale, { + "schema": module.LEASE_SCHEMA, "lease_id": "stale-lease", "name": "old", + "active": True, "heartbeat_unix": time.time() - 999, +}) +active = module.active_leases(Path(sys.argv[3]), 120, clean=True) +assert {item["name"] for item in active} == {"iwo"}, active +assert not stale.exists(), stale +lease.close() + +# --become may bind a name after attach; recovery with that name must reuse the +# provider-session cursor rather than derive a second lease from the new name. +greeting = module.SessionLease( + root=Path(sys.argv[3]), provider="codex", provider_session_id="become-session", + name=None, bus=Path(sys.argv[2]), requested_id=None, ttl_seconds=120, + follow_from_end=True, +) +greeting_id = greeting.lease_id +greeting.bind_name("james") +greeting.close() +recovered = module.SessionLease( + root=Path(sys.argv[3]), provider="codex", provider_session_id="become-session", + name="james", bus=Path(sys.argv[2]), requested_id=None, ttl_seconds=120, + follow_from_end=True, +) +assert recovered.resumed is True, recovered.attach_receipt() +assert recovered.lease_id == greeting_id, recovered.attach_receipt() +assert recovered.name == "james", recovered.attach_receipt() +recovered.close() +PY + +# The provider/session identity is protected by a stable advisory lock. A +# second follower cannot win a simultaneous stale-read race or fork the cursor. +collision_out="$WORKDIR/collision-first.jsonl" +collision_err="$WORKDIR/collision-first.err" +python3 "$DEMUX" \ + --bus "$BUS" --bridge-home "$BRIDGE_HOME" \ + --provider codex --session collision-session --name james \ + --drafts --follow >"$collision_out" 2>"$collision_err" & +collision_pid=$! +for _ in {1..100}; do + if [[ -s "$collision_out" ]]; then + break + fi + sleep 0.01 +done +if [[ ! -s "$collision_out" ]]; then + echo "first collision follower did not attach" >&2 + kill "$collision_pid" 2>/dev/null || true + wait "$collision_pid" 2>/dev/null || true + exit 1 +fi +if python3 "$DEMUX" \ + --bus "$BUS" --bridge-home "$BRIDGE_HOME" \ + --provider codex --session collision-session --name james \ + --drafts --from-start >"$WORKDIR/collision-second.out" 2>"$WORKDIR/collision-second.err"; then + echo "duplicate collision follower unexpectedly attached" >&2 + kill "$collision_pid" 2>/dev/null || true + wait "$collision_pid" 2>/dev/null || true + exit 1 +fi +kill "$collision_pid" 2>/dev/null || true +wait "$collision_pid" 2>/dev/null || true +grep -q "active follower" "$WORKDIR/collision-second.err" +# Reattach after the provider process disappears, then close cleanly so active +# name discovery sees the durable cursor but not a phantom live agent. +python3 "$DEMUX" \ + --bus "$BUS" --bridge-home "$BRIDGE_HOME" \ + --provider codex --session collision-session --name james \ + --drafts --from-start >"$WORKDIR/collision-recovered.jsonl" + +names="$(python3 "$DEMUX" --bridge-home "$BRIDGE_HOME" --active-names)" +python3 - "$names" <<'PY' +import json, sys +o = json.loads(sys.argv[1]) +assert o["schema"] == "codescribe.agent-bridge.active-names.v1", o +assert o["names"] == [], o +PY + +echo "bus-demux: ok" diff --git a/scripts/verify-dmg-payload.sh b/scripts/verify-dmg-payload.sh index 6013dab7..fd78491f 100755 --- a/scripts/verify-dmg-payload.sh +++ b/scripts/verify-dmg-payload.sh @@ -319,7 +319,81 @@ if [[ -n "${APP_PATH:-}" ]]; then fail "codesign --verify --deep --strict failed on $(basename "$APP_PATH")" fi - # dylib payload + echo "" + echo "▶ agent bridge resource proof" + AGENT_BRIDGE_DIR="$APP_PATH/Contents/Resources/agent-bridge" + AGENT_BRIDGE_VERIFY="" + if AGENT_BRIDGE_VERIFY=$(python3 - "$AGENT_BRIDGE_DIR" <<'PY' +import hashlib +import json +import os +import sys +from pathlib import Path, PurePosixPath + +root = Path(sys.argv[1]) +manifest_path = root / "manifest.json" +if not manifest_path.is_file(): + raise SystemExit("manifest.json missing") +try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"manifest unreadable: {error}") +if manifest.get("schema") != "codescribe.agent-bridge.bundle.v1": + raise SystemExit("wrong manifest schema") +if not str(manifest.get("bundle_version") or "").strip(): + raise SystemExit("empty bundle version") +entries = manifest.get("files") +if not isinstance(entries, list) or not entries: + raise SystemExit("empty manifest file list") + +listed = set() +for entry in entries: + relative = PurePosixPath(str(entry.get("path") or "")) + if relative.is_absolute() or ".." in relative.parts or not relative.parts: + raise SystemExit(f"unsafe manifest path: {relative}") + name = relative.as_posix() + if name in listed: + raise SystemExit(f"duplicate manifest path: {name}") + listed.add(name) + path = root.joinpath(*relative.parts) + if not path.is_file() or path.is_symlink(): + raise SystemExit(f"payload file missing or symlinked: {name}") + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if digest != entry.get("sha256"): + raise SystemExit(f"checksum mismatch: {name}") + if path.stat().st_size != entry.get("bytes"): + raise SystemExit(f"size mismatch: {name}") + +actual = { + path.relative_to(root).as_posix() + for path in root.rglob("*") + if path.is_file() and path.relative_to(root).as_posix() != "manifest.json" +} +if actual != listed: + raise SystemExit(f"manifest coverage mismatch: {sorted(actual ^ listed)}") +required = { + "bin/bus-demux.py", + "skills/codescribe/SKILL.md", + "skills/codescribe/README.md", + "skills/codescribe/FLOW.md", + "skills/codescribe/references/attach.md", + "skills/codescribe/references/live-vs-seal.md", + "skills/codescribe/examples/example-prompt.md", +} +missing = sorted(required - listed) +if missing: + raise SystemExit(f"required bridge files missing: {missing}") +if not os.access(root / "bin" / "bus-demux.py", os.X_OK): + raise SystemExit("bus-demux.py is not executable") +print(f"v{manifest['bundle_version']} · {len(listed)} checksum-verified files") +PY + ); then + ok "agent bridge $AGENT_BRIDGE_VERIFY" + else + fail "agent bridge payload incomplete: $AGENT_BRIDGE_VERIFY" + fi + + # Model resource payload echo "" echo "▶ MiniLM runtime resource proof" EMBEDDER_DIR="$APP_PATH/Contents/Resources/models/embedder" diff --git a/site/src/styles/lab-shell.css b/site/src/styles/lab-shell.css index cd484011..26725780 100644 --- a/site/src/styles/lab-shell.css +++ b/site/src/styles/lab-shell.css @@ -119,7 +119,9 @@ .lab-frame { display: grid; - grid-template-columns: var(--lab-sidebar-w) minmax(0, 1fr) var(--lab-toolbox-w); + grid-template-columns: var(--lab-sidebar-w) minmax(0, 1fr) var( + --lab-toolbox-w + ); min-height: 0; } diff --git a/skills/codescribe/FLOW.md b/skills/codescribe/FLOW.md new file mode 100644 index 00000000..d3bb4468 --- /dev/null +++ b/skills/codescribe/FLOW.md @@ -0,0 +1,50 @@ +# `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["installed bus-demux --provider --session --become --drafts --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; state_change_allowed false] + J --> K{Addresses my name?} + K -->|yes| L[May reply in ~5s gap] + K -->|no| J + J --> M[Fn up: transcript_sealed; state_change_allowed true] + 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) +- Lease/cursor: `~/.codescribe/agent-bridge/leases/.json` +- Recovery: preserve/poll the follower handle; reattach with the same provider session + +### 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..17be7f3b --- /dev/null +++ b/skills/codescribe/README.md @@ -0,0 +1,35 @@ +# 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.2.0` | +| Operator command | **none** — not `vibecrafted codescribe ` | +| Interactive | `/codescribe` | +| Canonical doc | [`SKILL.md`](SKILL.md) | +| Follower | `~/.codescribe/agent-bridge/runtime/bin/bus-demux.py` | + +## Homes + +| Tree | Path | +| ------------------- | ---------------------------------------------------- | +| Codescribe checkout | `skills/codescribe/` | +| Signed app payload | `Contents/Resources/agent-bridge/skills/codescribe/` | +| Product install | Codex or Claude skill home + managed receipt/marker | +| Fleet | `vibecrafted_core/skills/codescribe/` | + +Keep authoring copies in lockstep. The signed app packages the complete tree and +the Setup Wizard installs it explicitly. Runtime commands use the stable product +helper under `~/.codescribe/agent-bridge/runtime/`, never the checkout. + +## 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 diff --git a/skills/codescribe/SKILL.md b/skills/codescribe/SKILL.md new file mode 100644 index 00000000..54844808 --- /dev/null +++ b/skills/codescribe/SKILL.md @@ -0,0 +1,197 @@ +--- +name: codescribe +version: 0.2.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. + +The Codescribe Setup Wizard is the product installer. This skill never writes +itself into a client and never invents another settings plane. It is not Voice +Lab and 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. + +## Dependencies + +- Stable installed helper: + `~/.codescribe/agent-bridge/runtime/bin/bus-demux.py` (kielbasa filter). + Do not depend on a Codescribe checkout, write a second parser, or 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. Choose the client token (`codex` or `claude-code`) and this provider's stable + session/thread id. Attach the installed follower with drafts enabled: + +```bash +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py \ + --provider codex --session --become --drafts --follow +``` + +Preserve the running tool handle. The first JSON line is an attach receipt; +retain its `lease_id` with the provider session. 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 the same provider session: + +```bash +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py \ + --provider codex --session --name --drafts --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. Their +envelopes say `state_change_allowed: false`. +Fn up → `transcript_sealed` with `state_change_allowed: true` → 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. Provider recovery + +Keep polling the original follower handle through ordinary provider turns. If +the provider compacts or recovers and the handle is gone, rerun the installed +helper with the same `--provider`, `--session`, name, and `--drafts`. Its lease +resumes from the persisted byte cursor rather than jumping to EOF or replaying +an old command. An attach receipt with `resumed: true` proves recovery. + +If the old follower is still alive, the helper refuses a duplicate process; +poll the original handle. Never create a second cursor for the same provider +session. Duplicate human names remain isolated because provider + session own +the lease. Inspect non-stale names for future acoustic routing with: + +```bash +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py --active-names +``` + +### 5. 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 the installed `~/.codescribe/agent-bridge/runtime/bin/bus-demux.py`, not a second mic +- [ ] You asked for a name in chat and bound `--name ` +- [ ] Attach receipt names provider/session/lease and the running handle is preserved +- [ ] Live command includes `--drafts`; state changes still wait for the seal +- [ ] 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 + +## 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..0a5fbded --- /dev/null +++ b/skills/codescribe/examples/example-prompt.md @@ -0,0 +1,29 @@ +# 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. Start the installed helper with the client token, stable provider session id, + `--become --drafts --follow`; preserve and poll its handle. +3. Answer the name question (e.g. James). Greet in **this** chat. +4. Bind the same provider session with `--name james --drafts --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 +- Attach receipt with provider/session/lease and `resumed: true` after a recovery probe +- 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..702d5964 --- /dev/null +++ b/skills/codescribe/references/attach.md @@ -0,0 +1,42 @@ +# 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 stable product install (no checkout dependency): + +```bash +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py \ + --provider codex --session --become --drafts --follow +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py \ + --provider codex --session --name james --drafts --follow +python3 ~/.codescribe/agent-bridge/runtime/bin/bus-demux.py --active-names +``` + +`--become` hears seals until a name assignment, then filters. Unnamed → exit 2. + +The first line is an attach receipt. Preserve its `lease_id` and the follower +handle. After provider recovery, poll the original handle; if it is gone, +reattach with the same provider/session/name. A resumed cursor catches events +written during recovery without replaying the old command. Different provider +sessions — even with the same human name — own different leases and cursors. + +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..9cd17609 --- /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 gap if named; `state_change_allowed=false` | +| `transcript_sealed` | `state_change_allowed=true`; only now install, kill, commit, or 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/e2e_overlay_delivery_parity.rs b/tests/e2e_overlay_delivery_parity.rs index be5c46f9..0f22f885 100644 --- a/tests/e2e_overlay_delivery_parity.rs +++ b/tests/e2e_overlay_delivery_parity.rs @@ -34,6 +34,7 @@ use codescribe_core::pipeline::contracts::{ final_pass_is_length_regression, }; use codescribe_core::pipeline::streaming::{ + TAIL_PATCH_SESSION_RECEIPT_WARNING_CODE, TailPatchDrainDisposition, TailPatchSessionReceipt, assemble_live_from_events, collect_buffered_engine_events, }; use codescribe_core::quality::{MergeMode, merge_live_whisper}; @@ -82,11 +83,10 @@ fn measured_tail_patch_count(events: &[EngineEvent]) -> usize { /// Fail-closed contract between the target that asked for a lane and the lane /// the run actually exercised. /// -/// `CODESCRIBE_LAYERED_TRANSCRIPTION` is a power-user key, NOT a promoted -/// settings.json key (`config::settings::is_promoted_key`), so -/// `Config::inject_file_env_for_runtime` copies it out of `~/.codescribe/.env` -/// into the process environment. An operator who switched their daily dictation -/// to `phase1` therefore armed Layer 1 inside a target whose entire purpose is +/// Before the key was promoted to settings.json, +/// `Config::inject_file_env_for_runtime` copied it out of `~/.codescribe/.env`. +/// An operator who switched daily dictation to `phase1` therefore armed Layer 1 +/// inside a target whose entire purpose is /// to score Layer 0 against an Apple-fidelity reference — and Layer 1 is /// *supposed* to diverge from Apple, so the bar went red for doing its job. /// @@ -109,9 +109,8 @@ fn measured_lane_matches_request( (None, leaked) => Err(format!( "this target scores Layer 0 against the Apple-fidelity reference, but the run \ measured Layer 1: {leaked} ReplaceRange{{TailPatch}} event(s) reached the \ - assembly. `CODESCRIBE_LAYERED_TRANSCRIPTION` is a power-user key, so \ - ~/.codescribe/.env is injected into the process environment and can arm the \ - layer underneath a Layer-0 bar. Pin the lane on the target \ + assembly. An unpinned compatibility override can arm the layer underneath a \ + Layer-0 bar. Pin the lane on the target \ (`CODESCRIBE_LAYERED_TRANSCRIPTION=off`) and re-run — the similarity number \ from this run says nothing about Layer 0." )), @@ -125,6 +124,31 @@ fn measured_lane_matches_request( } } +/// Product arming gate backed by the runtime receipt, not by settings/UI state +/// and not by the number of successful text mutations. A submitted window may +/// legitimately be skipped, but Layered ON with no submitted observation is a +/// failed run regardless of how clean the Apple transcript looked. +fn layered_arming_matches_request( + requested: bool, + receipt: Option<&TailPatchSessionReceipt>, +) -> Result<(), String> { + if !requested { + if receipt.is_some_and(|receipt| receipt.submitted > 0) { + return Err("Layered OFF submitted Layer 1 windows".to_string()); + } + return Ok(()); + } + + let receipt = receipt.ok_or_else(|| "Layered ON emitted no arming receipt".to_string())?; + if !receipt.armed { + return Err("Layered ON receipt says the lane was disarmed".to_string()); + } + if receipt.submitted == 0 { + return Err("Layered ON armed the lane but submitted zero windows".to_string()); + } + Ok(()) +} + fn sealed_final(utterance_id: u64, text: &str) -> EngineEvent { EngineEvent::UtteranceFinal { utterance_id, @@ -138,6 +162,7 @@ fn sealed_final(utterance_id: u64, text: &str) -> EngineEvent { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, } } @@ -151,6 +176,11 @@ fn tail_patch(utterance_id: u64, start: usize, end: usize, text: &str) -> Engine } } +fn layered_acceptance_fixture() -> serde_json::Value { + serde_json::from_str(include_str!("fixtures/layered_engine_acceptance.json")) + .expect("canonical layered-engine acceptance fixture must stay valid JSON") +} + /// The refuted run, in miniature: `make test-engine-parity` (Layer 0) with the /// operator's `.env` arming `phase1` underneath it. #[test] @@ -203,6 +233,52 @@ fn parity_lane_guard_passes_when_the_measured_lane_is_the_requested_lane() { assert!(measured_lane_matches_request(None, &lexicon_on_layer0).is_ok()); } +#[test] +fn layered_on_with_zero_submitted_windows_is_a_failed_arming_condition() { + let receipt = + TailPatchSessionReceipt::new(true, 0, 0, 0, 0, 0, TailPatchDrainDisposition::Completed); + let err = layered_arming_matches_request(true, Some(&receipt)) + .expect_err("settings ON plus zero submitted work must fail closed"); + assert!( + err.contains("zero windows"), + "unexpected receipt gate: {err}" + ); +} + +#[test] +fn layered_arming_uses_submitted_work_not_only_successful_mutations() { + let honestly_skipped = + TailPatchSessionReceipt::new(true, 1, 0, 1, 0, 0, TailPatchDrainDisposition::Completed); + assert!(layered_arming_matches_request(true, Some(&honestly_skipped)).is_ok()); + + let leaked_while_off = + TailPatchSessionReceipt::new(false, 1, 0, 1, 0, 0, TailPatchDrainDisposition::NotArmed); + assert!(layered_arming_matches_request(false, Some(&leaked_while_off)).is_err()); +} + +#[test] +fn stop_receipt_keeps_every_terminal_bucket_and_names_bounded_drain_timeout() { + let events = vec![EngineEvent::Warning { + code: TAIL_PATCH_SESSION_RECEIPT_WARNING_CODE.to_string(), + message: + "armed=true submitted=10 applied=4 skipped=3 timed_out=1 abandoned=2 drain=timed_out" + .to_string(), + }]; + let receipt = TailPatchSessionReceipt::from_events(&events) + .expect("runtime stop receipt must be recoverable from the event stream"); + + assert_eq!(receipt.applied, 4); + assert_eq!(receipt.skipped, 3); + assert_eq!(receipt.timed_out, 1); + assert_eq!(receipt.abandoned, 2); + assert_eq!( + receipt.applied + receipt.skipped + receipt.timed_out + receipt.abandoned, + receipt.submitted, + "stop evidence must account for every submitted window instead of silently passing" + ); + assert_eq!(receipt.drain, TailPatchDrainDisposition::TimedOut); +} + /// Engine bar: multi-pause dictation must seal ≥2 freezed finals and cover the /// spoken body (not a ~tens-of-chars tail on a 50s+ clip). /// @@ -272,6 +348,7 @@ fn single_final_short_tail_fails_engine_bar() { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }]; let assembly = assemble_live_from_events(&events); assert_eq!(assembly.sealed_count(), 1); @@ -580,6 +657,7 @@ fn overlay_assembly_freezes_finals_and_appends_preview_tail() { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }, EngineEvent::Preview { rev: 2, @@ -627,6 +705,7 @@ fn parity_assembly_reads_layer1_tail_patches() { compression_ratio: None, quality_gate_dropped: false, confidence_flags: vec![], + acoustic: None, }; // Layer 0 under-generated "Toolchain" as "Tulczajn"; Layer 1 re-transcribed @@ -659,6 +738,115 @@ fn parity_assembly_reads_layer1_tail_patches() { ); } +/// Canonical Kora acceptance: Apple may publish the weak hypothesis first, but +/// Layer 1 must repair the same committed span before the delivery boundary. +/// An empty `final_text` deliberately models `Final pass = off`; the live +/// tail-patch remains authoritative and does not require a whole-file repass. +#[test] +fn canonical_meaning_loss_is_repaired_live_before_delivery_with_final_pass_off() { + let fixture = layered_acceptance_fixture(); + let weak_apple = fixture["meaning_loss"]["apple"] + .as_str() + .expect("meaning-loss Apple hypothesis"); + let spoken = fixture["meaning_loss"]["spoken"] + .as_str() + .expect("meaning-loss spoken truth"); + + let events = vec![ + sealed_final(41, weak_apple), + tail_patch(41, 0, weak_apple.chars().count(), spoken), + ]; + + assert_eq!( + measured_tail_patch_count(&events), + 1, + "the acceptance vector must exercise Layer 1 rather than blessing Apple-only text" + ); + let repaired_live = streaming_floor_from_events(&events); + assert_eq!(repaired_live, spoken); + + let (source, delivered) = delivery_from_stream_and_final(&repaired_live, ""); + assert_eq!(source, "streaming_floor"); + assert_eq!( + delivered, spoken, + "manual Retranscribe/full-file final must not be the first place meaning returns" + ); + assert!(!delivered.contains("Musi latać")); +} + +/// The first audible token is capture truth. A later in-span correction may +/// improve the body, but it may not eat the onset before seal or Delivery. +#[test] +fn onset_token_iwo_survives_live_patch_seal_and_delivery() { + let fixture = layered_acceptance_fixture(); + let apple = fixture["onset"]["apple"] + .as_str() + .expect("onset Apple text"); + let repaired = fixture["onset"]["repaired"] + .as_str() + .expect("onset repaired text"); + let first_token = fixture["onset"]["required_first_token"] + .as_str() + .expect("required onset token"); + + let events = vec![ + sealed_final(7, apple), + tail_patch(7, 0, apple.chars().count(), repaired), + ]; + let live = streaming_floor_from_events(&events); + let (_, delivered) = delivery_from_stream_and_final(&live, ""); + + assert!( + live.starts_with(&format!("{first_token} ")), + "live onset was lost: {live}" + ); + assert!( + delivered.starts_with(&format!("{first_token} ")), + "delivery onset was lost after a valid live repair: {delivered}" + ); +} + +/// Idempotence is keyed by span identity, never by equal text. Replaying the +/// same `utterance_id` updates its one slot; five new identities carrying the +/// same intentional phrase must remain five committed spans. +#[test] +fn same_span_replay_is_idempotent_while_five_distinct_repetitions_survive() { + let fixture = layered_acceptance_fixture(); + let phrase = fixture["repetition"]["text"] + .as_str() + .expect("repetition text"); + let distinct_ids = fixture["repetition"]["distinct_span_ids"] + .as_array() + .expect("distinct span ids"); + let replayed_id = fixture["repetition"]["replayed_span_id"] + .as_u64() + .expect("replayed span id"); + let expected = fixture["repetition"]["expected_deliveries"] + .as_u64() + .expect("expected repetition count") as usize; + let mut events = Vec::new(); + for utterance_id in distinct_ids { + events.push(sealed_final( + utterance_id.as_u64().expect("numeric span id"), + phrase, + )); + } + // Provider/session replay of span 3: same identity, therefore one slot. + events.push(sealed_final(replayed_id, phrase)); + + let assembly = assemble_live_from_events(&events); + assert_eq!(assembly.sealed_count(), expected); + assert_eq!( + assembly + .streaming_floor() + .split_whitespace() + .filter(|token| *token == phrase) + .count(), + expected, + "text-equality dedup must not erase intentional repetitions, while an identity replay must not create a sixth" + ); +} + #[test] fn delivery_merges_live_floor_with_whisper_fill_not_full_replace() { // Same shape as teacher merge unit tests: live under-gen + whisper excess. @@ -1421,6 +1609,14 @@ async fn e2e_production_overlay_corpus_replay() { .filter(|event| matches!(event, EngineEvent::Preview { .. })) .count(); let tail_patches = measured_tail_patch_count(&replay.events); + let tail_patch_receipt = TailPatchSessionReceipt::from_events(&replay.events); + if replay.layer1_armed { + layered_arming_matches_request(true, tail_patch_receipt.as_ref()).unwrap_or_else( + |mismatch| { + panic!("production replay {opaque_id} Layer 1 arming mismatch: {mismatch}") + }, + ); + } let acceptance = if class == "long" { normalized_character_parity >= 0.85 @@ -1471,6 +1667,12 @@ async fn e2e_production_overlay_corpus_replay() { "final_count": replay.boundary_evidence.final_count, "unique_final_id_count": replay.boundary_evidence.unique_final_id_count, "tail_patches": tail_patches, + "tail_patch_submitted": tail_patch_receipt.as_ref().map(|receipt| receipt.submitted), + "tail_patch_applied": tail_patch_receipt.as_ref().map(|receipt| receipt.applied), + "tail_patch_skipped": tail_patch_receipt.as_ref().map(|receipt| receipt.skipped), + "tail_patch_timed_out": tail_patch_receipt.as_ref().map(|receipt| receipt.timed_out), + "tail_patch_abandoned": tail_patch_receipt.as_ref().map(|receipt| receipt.abandoned), + "tail_patch_drain": tail_patch_receipt.as_ref().map(|receipt| format!("{:?}", receipt.drain)), "live_chars": replay.live_text.chars().count(), "adjudicated_chars": replay.adjudicated_text.chars().count(), "delivered_chars": replay.delivered_text.chars().count(), @@ -1723,6 +1925,24 @@ async fn e2e_apple_live_parity() { if let Err(mismatch) = measured_lane_matches_request(requested_lane, &events) { panic!("parity lane mismatch — {mismatch}"); } + let tail_patch_receipt = TailPatchSessionReceipt::from_events(&events); + if let Err(mismatch) = + layered_arming_matches_request(requested_lane.is_some(), tail_patch_receipt.as_ref()) + { + panic!("parity Layer 1 arming mismatch — {mismatch}"); + } + if let Some(receipt) = tail_patch_receipt { + eprintln!( + "parity Layer 1 receipt: armed={} submitted={} applied={} skipped={} timed_out={} abandoned={} drain={:?}", + receipt.armed, + receipt.submitted, + receipt.applied, + receipt.skipped, + receipt.timed_out, + receipt.abandoned, + receipt.drain, + ); + } let normalize = |s: &str| s.split_whitespace().collect::>().join(" "); let ours = normalize(&transcript); diff --git a/tests/e2e_streaming_chunks.rs b/tests/e2e_streaming_chunks.rs index f3facbb6..86dae5b1 100644 --- a/tests/e2e_streaming_chunks.rs +++ b/tests/e2e_streaming_chunks.rs @@ -251,6 +251,26 @@ fn test_overlap_dedup_stable_across_chunkings() { ); } +#[test] +fn test_overlap_merge_never_drops_the_iwo_onset_token() { + let chunks = [ + "IWO został ucięte", + "został ucięte ale nagranie trwa dalej", + "nagranie trwa dalej i kończy się poprawnie", + ]; + let mut merged = String::new(); + for chunk in chunks { + append_with_overlap_dedup(&mut merged, chunk); + } + + assert!( + normalize_transcript(&merged) + .to_lowercase() + .starts_with("iwo "), + "overlap reconciliation dropped the first audible token: {merged}" + ); +} + #[test] fn test_streaming_matches_non_streaming_output() { if skip_unless_opt_in( diff --git a/tests/fixtures/layered_engine_acceptance.json b/tests/fixtures/layered_engine_acceptance.json new file mode 100644 index 00000000..68275362 --- /dev/null +++ b/tests/fixtures/layered_engine_acceptance.json @@ -0,0 +1,18 @@ +{ + "schema": "codescribe.layered-engine-acceptance.v1", + "meaning_loss": { + "apple": "mój model pt. Musi latać", + "spoken": "Whisper musi łatać partiale." + }, + "onset": { + "apple": "IWO został ucięte", + "repaired": "IWO zostało ucięte.", + "required_first_token": "IWO" + }, + "repetition": { + "text": "IWO", + "distinct_span_ids": [1, 2, 3, 4, 5], + "replayed_span_id": 3, + "expected_deliveries": 5 + } +}
BarMeans