diff --git a/Cargo.lock b/Cargo.lock index 8e504bf..faed3be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -374,7 +374,7 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", - "hashbrown", + "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", @@ -485,6 +485,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -630,6 +636,12 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "http" version = "1.4.2" @@ -720,6 +732,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "inout" version = "0.1.4" @@ -1243,6 +1265,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "rutster-sim" +version = "0.0.0" +dependencies = [ + "rutster", + "rutster-media", + "serde", + "thiserror 1.0.69", + "tokio", + "toml", + "tracing", + "url", +] + [[package]] name = "rutster-spend" version = "0.0.0" @@ -1374,6 +1410,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1689,6 +1734,47 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -1945,6 +2031,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index f96eba1..b6749eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,13 +6,14 @@ resolver = "2" members = [ "crates/rutster", + "crates/rutster-brain-realtime", "crates/rutster-call-model", "crates/rutster-media", - "crates/rutster-trunk", + "crates/rutster-sim", + "crates/rutster-spend", "crates/rutster-tap", "crates/rutster-tap-echo", - "crates/rutster-brain-realtime", - "crates/rutster-spend", + "crates/rutster-trunk", ] [workspace.package] @@ -59,3 +60,7 @@ async-trait = "0.1" # client sends before WS upgrade; `openai_client::openai_headers` produces # the header pairs the caller stuffs into it. http = "1" +# toml 0.8: TOML deserialization for the slice-4½ sim-crate's Scenario files +# (crates/rutster-sim/scenarios/*.toml). The first consumer of `toml` in +# the workspace; declared here so future members share the version pin. +toml = "0.8" diff --git a/crates/rutster-sim/Cargo.toml b/crates/rutster-sim/Cargo.toml new file mode 100644 index 0000000..a6762e7 --- /dev/null +++ b/crates/rutster-sim/Cargo.toml @@ -0,0 +1,30 @@ +# crates/rutster-sim/Cargo.toml — the self-hostable benchmark + simulation +# harness crate (ADR-0010 spearhead step 4½). Default-off `sim-bench` +# feature gates the CI-regressed threshold sweep so the routine +# `cargo test --all` gate stays fast. +[package] +name = "rutster-sim" +version = "0.0.0" +license.workspace = true +edition.workspace = true +repository.workspace = true +description = "Self-hostable benchmark + simulation harness (ADR-0010 spearhead step 4½)." + +[dependencies] +rutster-media = { path = "../rutster-media" } +rutster = { path = "../rutster" } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } + +[features] +default = [] +# The CI-regressed threshold sweep. Default OFF so `cargo test --all` (the +# routine gate) stays fast. A separate CI job runs +# `cargo test --all --features=sim-bench -- --test-threads=1` per spec §5.4 + +# §6.5. A latency regression fails the build the same way a broken test +# does (ADR-0010). +sim-bench = [] diff --git a/crates/rutster-sim/src/concurrency.rs b/crates/rutster-sim/src/concurrency.rs new file mode 100644 index 0000000..8b58df0 --- /dev/null +++ b/crates/rutster-sim/src/concurrency.rs @@ -0,0 +1,11 @@ +//! # concurrency — `ConcurrencyRunner`: N concurrent `SimCall`s + sweep-report +//! aggregation +//! +//! **Stub — lands in S5.** +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` +//! §3.4 + §4.2 + §2.4 for the design + `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md` +//! Task S5 for the implementation. Spawns N concurrent `SimCall`s +//! (N ∈ `SWEEP_CONCURRENCIES = [1, 10, 50]`) against the same in-process +//! `MockRealtimeBrain`, aggregates per-call latencies into the +//! `PerConcurrencyReport` rows of the `SweepReport`. diff --git a/crates/rutster-sim/src/latency.rs b/crates/rutster-sim/src/latency.rs new file mode 100644 index 0000000..28fb0b5 --- /dev/null +++ b/crates/rutster-sim/src/latency.rs @@ -0,0 +1,8 @@ +//! # latency — post-hoc p50/p99 latency computation from `Capture` events +//! +//! **Stub — lands in S3.** +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` +//! §3.3 for the design + `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md` +//! Task S3 for the implementation. Consumes the `Capture` stream from a +//! `SimAudioPipe` and computes barge-in kill-time + mouth-to-ear p50/p99. diff --git a/crates/rutster-sim/src/lib.rs b/crates/rutster-sim/src/lib.rs new file mode 100644 index 0000000..a808fd1 --- /dev/null +++ b/crates/rutster-sim/src/lib.rs @@ -0,0 +1,65 @@ +//! # rutster-sim — the self-hostable benchmark + simulation harness +//! +//! **Status:** spearhead step 4½ (ADR-0010). The wedge's measurement surface. +//! +//! This crate drives synthetic callers through the SAME media-leg path real +//! callers use, measures p50/p99 mouth-to-ear latency + barge-in kill-time +//! against slice-4's ≤60 ms kill budget, and runs the same measurements at +//! 1 / 10 / 50 concurrent calls. A separate CI job +//! (`cargo test --all --features=sim-bench`) asserts thresholds per commit; +//! a latency regression fails the build (ADR-0010). +//! +//! # Why this crate exists (the FOB differentiator) +//! +//! Slice-4 ships a reflex loop + a synthetic e2e test. SIM-BENCH is the +//! artifact that turns arithmetic latency claims into CI-regressed +//! measurement. See +//! [`docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md`] +//! for the design. +//! +//! # Why a separate crate (not in-tree tests) +//! +//! The harness is hot-path-adjacent + differentiating (ADR-0008 FOB) — it +//! earns cratehood the same way `rutster-tap` did. The dep direction is +//! clean: `rutster-sim` → `rutster-media` + `rutster`. The harness +//! consumes types; it doesn't ride on the binary's internal plumbing. +//! +//! # Module map (lands across S1-S7) +//! +//! - [`scenario`] (S1) — `Scenario` + `ScenarioStep` TOML-deserializable +//! scripted-caller data types. Determinism is the point. +//! - [`sim_audio_pipe`] (S2) — `SimAudioPipe: AudioPipe` test-double that +//! IS the caller; captures both clocks (`Instant::now()` at onset + +//! receipt). The measurement boundary (spec §2.2). +//! - [`latency`] (S3) — `LatencyProbe`: post-hoc p50/p99 kill + mouth-to-ear +//! computation from the `Capture` event stream. +//! - [`runner`] (S4) — `SimCall` + `ScenarioRunner`: drives one synthetic +//! caller end-to-end against the FOB reflex loop standalone in tokio +//! (no `MediaThread` extension per S4 standalone-path conclusion). +//! - [`concurrency`] (S5) — `ConcurrencyRunner`: N concurrent `SimCall`s +//! against the same MockRealtimeBrain; aggregates per-call latencies +//! into a `SweepReport`. +//! - [`tick_lag`] (S6) — `TickLagGauge`: the ADR-0010 doctrine-drift +//! detector. Surfaces `tick_overruns` / `last_tick_micros` in the +//! `SweepReport`. +//! - [`thresholds`] (S7) — Threshold consts + the `#[cfg(feature = +//! "sim-bench")] #[tokio::test]` assertion tests. A latency regression +//! fails the build. + +// All modules declared upfront so the lib.rs is stable across task +// commits; each module file grows from a `//! stub` header to its full +// impl as its task lands. Only the `pub use` re-exports for landed +// modules are present — they grow as each task's symbols become available. +pub mod concurrency; +pub mod latency; +pub mod runner; +pub mod scenario; +pub mod sim_audio_pipe; +pub mod thresholds; +pub mod tick_lag; + +pub use scenario::{Scenario, ScenarioError, ScenarioStep}; +pub use thresholds::{ + BARGE_IN_KILL_TIME_P99_MS, MOUTH_TO_EAR_P99_MS, SWEEP_CONCURRENCIES, TICK_LAG_MAX_MS, + TICK_OVERRUN_PCT_MAX, +}; diff --git a/crates/rutster-sim/src/runner.rs b/crates/rutster-sim/src/runner.rs new file mode 100644 index 0000000..62397a1 --- /dev/null +++ b/crates/rutster-sim/src/runner.rs @@ -0,0 +1,12 @@ +//! # runner — `SimCall` + `ScenarioRunner`: drive one synthetic caller +//! end-to-end +//! +//! **Stub — lands in S4.** +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` +//! §3.4 + §4.1 for the design + `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md` +//! Task S4 for the implementation. The SimCall wires itself standalone in +//! tokio (no `MediaCmd::RegisterSim` — per the plan's S4 standalone-path +//! conclusion, the File Structure table is stale on this point; S4 +//! supersedes). Composes slice-4's `Reflex` + +//! `LocalVadReflex` stack directly against an in-process `MockRealtimeBrain`. diff --git a/crates/rutster-sim/src/scenario.rs b/crates/rutster-sim/src/scenario.rs new file mode 100644 index 0000000..b7e8568 --- /dev/null +++ b/crates/rutster-sim/src/scenario.rs @@ -0,0 +1,250 @@ +//! # Scenario — the scripted-caller data type +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` +//! §3.1. +//! +//! A `Scenario` is a sequence of `ScenarioStep`s read from a TOML file under +//! `crates/rutster-sim/scenarios/*.toml`. Deterministic by construction — +//! the entire point is reproducible thresholds in CI (LLM-driven callers +//! land in a post-spearhead refinement tier; see §1.2). +//! +//! # Why TOML (not YAML, not RON) +//! +//! `serde` + `toml` is already a workspace member. TOML keeps the scenario +//! file readable as a one-shot script (a sequence of named steps + numbers); +//! YAML would invite flow-mapping complexity this format doesn't need. +//! +//! # Why `#[serde(tag = "kind")]` (internally-tagged enum) +//! +//! Each step in the scenario TOML is one TOML table: +//! +//! ```toml +//! [[steps]] +//! kind = "speak_loud" +//! frames = 20 +//! ``` +//! +//! `serde`'s internally-tagged enum representation (`tag = "kind"`) reads the +//! `kind` key to dispatch to the matching enum variant. This is the idiomatic +//! shape for "list of named, differently-shaped records" in TOML — the +//! alternative (externally-tagged) would require a redundant table layer +//! (`[[steps]] variant = { speak_loud = { frames = 20 } }`) that hurts +//! readability for no benefit. See . +//! +//! `rename_all = "snake_case"` maps the Rust `SpeakLoud` variant to the +//! TOML `speak_loud` tag — matches the convention used in slice-4's +//! `AdvisoryEvent` enum (the precedent this file follows). + +use std::path::Path; + +/// The scripted-caller scenario. Read from a TOML file. Deterministic. +/// +/// # Example +/// +/// ```toml +/// name = "loud-barge" +/// +/// [[steps]] +/// kind = "speak_loud" +/// frames = 20 +/// +/// [[steps]] +/// kind = "await_reply" +/// frames = 0 +/// +/// [[steps]] +/// kind = "end" +/// ``` +/// +/// The `SimAudioPipe::new(scenario, ..)` constructor consumes the +/// `steps` vector front-to-back during `on_pcm_frame` (the sink path — +/// the caller "speaks") and `next_pcm_frame` (the source path — the +/// caller "hears" brain replies, advancing `AwaitReply` steps). +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +pub struct Scenario { + /// Human-readable identifier; surfaces in CI failure messages + /// ("scenario loud-barge failed: p99 kill-time 84ms > 80ms"). + pub name: String, + /// Time-ordered sequence of caller actions. The `SimAudioPipe` + /// consumes them in order during `on_pcm_frame` (for speak/pause + /// steps) and `next_pcm_frame` (for `AwaitReply` barriers). + pub steps: Vec, +} + +/// One axis of caller behavior. A scenario is a time-ordered sequence +/// of these. The `SimAudioPipe` consumes them in order during +/// `on_pcm_frame`. +/// +/// # Why an enum (not a struct with a `kind` field) +/// +/// The steps have *different fields* (`SpeakLoud { frames }` vs `End` +/// has none). A struct-with-kind-field would require `Option` for +/// every variant-irrelevant field — losing type safety for no ergonomic +/// gain. The enum approach makes the variant's payload explicit at the +/// type level; `serde`'s internally-tagged representation keeps the TOML +/// shape flat + readable. +#[derive(Debug, Clone, serde::Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ScenarioStep { + /// Send N loud PCM frames (sample value 1000, well above + /// `VAD_RMS_THRESHOLD = 500.0`). Triggers the local VAD via + /// slice-4's `LocalVadReflex::on_pcm_frame` — the primary barge-in + /// path (slice-4 §5.1). The `SimAudioPipe`'s sink path emits one + /// loud `PcmFrame` per `on_pcm_frame` call while this step is + /// active; on step entry it captures `Capture::CallerLoudOnset`. + SpeakLoud { frames: u32 }, + /// Send N zero frames (sample value 0, well below + /// `VAD_RMS_THRESHOLD`). Drives the mock-brain advisory path + /// (slice-4 §5.2 secondary path): `MockRealtimeBrain` sees + /// "no caller audio for M frames" + emits an advisory → + /// `Reflex::muted = true`. The wedge cares about LOUD barge + /// measurement; quiet onsets are unscored (no `Capture`). + SpeakQuiet { frames: u32 }, + /// Insert N zero frames before the next step (silence pacing). + /// Used by the `sustained-call.toml` scenario (5 minutes of talk + /// with 3 barges) to space barge cycles apart. + Pause { frames: u32 }, + /// Wait until the harness receives M "ear" frames before advancing. + /// Barrier semantics: brain's reply must arrive before the next + /// caller action. The `SimAudioPipe`'s source path + /// (`next_pcm_frame`) decrements this counter for each `Some(frame)` + /// returned; on reaching zero, advances. + AwaitReply { frames: u32 }, + /// End the scenario. The `SimAudioPipe`'s `next_pcm_frame` returns + /// `None` thereafter; the `SimCall` (S4) detects end-of-scenario + + /// terminates its tick loop. + End, +} + +/// Errors surfaced during scenario loading. Cold-path; OK to be +/// `thiserror`-derived (the hot path goes through +/// `SimAudioPipe::on_pcm_frame` which never reads files). +/// +/// `#[from]` on the variants auto-implements `From` and +/// `From` so `?`-propagation Just Works in +/// `Scenario::load`. +#[derive(Debug, thiserror::Error)] +pub enum ScenarioError { + #[error("scenario file read failed: {0}")] + Io(#[from] std::io::Error), + #[error("scenario TOML parse failed: {0}")] + Parse(#[from] toml::de::Error), +} + +impl Scenario { + /// Load a scenario from a TOML file. Cold-path. + /// + /// Wraps `std::fs::read_to_string` + `toml::from_str` behind the + /// `ScenarioError` enum so callers can `?`-propagate both failure + /// modes uniformly. The `path: impl AsRef` bound follows + /// the std-library convention: it accepts `&str`, `String`, + /// `PathBuf`, `&Path` — matching how scenarios are loaded from + /// CLI args or test fixtures. + pub fn load(path: impl AsRef) -> Result { + let raw = std::fs::read_to_string(path)?; + Self::from_toml(&raw) + } + + /// Parse a scenario from an in-memory TOML string. + /// + /// Split out from `load` so unit tests can construct scenarios + /// without touching the filesystem (filesystem-isolated unit + /// tests are the std pattern in this codebase — see slice-4's + /// `reflex.rs` tests). + pub fn from_toml(s: &str) -> Result { + Ok(toml::from_str(s)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scenario_parses_minimal_end_only() { + // The trivial scenario: just one `End` step. Exercises the + // internally-tagged enum's bare-variant shape (`kind = "end"` + // with no payload fields). + let toml = r#" + name = "trivial" + [[steps]] + kind = "end" + "#; + let s = Scenario::from_toml(toml).expect("parse"); + assert_eq!(s.name, "trivial"); + assert_eq!(s.steps, vec![ScenarioStep::End]); + } + + #[test] + fn scenario_parses_loud_barge_shape() { + // The canonical loud-barge scenario from spec §5.3. Verifies + // the three-step shape (speak_loud → await_reply → end) parses + // to the expected variant sequence with payload fields intact. + let toml = r#" + name = "loud-barge" + [[steps]] + kind = "speak_loud" + frames = 20 + [[steps]] + kind = "await_reply" + frames = 0 + [[steps]] + kind = "end" + "#; + let s = Scenario::from_toml(toml).expect("parse"); + assert_eq!(s.name, "loud-barge"); + assert_eq!( + s.steps, + vec![ + ScenarioStep::SpeakLoud { frames: 20 }, + ScenarioStep::AwaitReply { frames: 0 }, + ScenarioStep::End, + ] + ); + } + + #[test] + fn scenario_parses_sustained_call_shape() { + // The sustained-call scenario (spec §5.3 entry #3) alternates + // speak_loud + speak_quiet. Verifies both payload-bearing + // variants parse correctly in sequence. + let toml = r#" + name = "sustained" + [[steps]] + kind = "speak_loud" + frames = 10 + [[steps]] + kind = "speak_quiet" + frames = 10 + [[steps]] + kind = "speak_loud" + frames = 10 + [[steps]] + kind = "end" + "#; + let s = Scenario::from_toml(toml).expect("parse"); + assert_eq!(s.steps.len(), 4); + assert!(matches!(s.steps[0], ScenarioStep::SpeakLoud { frames: 10 })); + assert!(matches!( + s.steps[1], + ScenarioStep::SpeakQuiet { frames: 10 } + )); + assert!(matches!(s.steps[2], ScenarioStep::SpeakLoud { frames: 10 })); + assert!(matches!(s.steps[3], ScenarioStep::End)); + } + + #[test] + fn scenario_unknown_kind_errors() { + // An unknown `kind` tag (typo, future-extension tag, etc.) + // must surface as a `Parse` error rather than silently + // defaulting. This is the contract `serde`'s internally-tagged + // enum provides: unknown tags fail the deserialize rather + // than producing a `None`-ish default. + let toml = r#" + name = "bad" + [[steps]] + kind = "ship_a_real_caller" + "#; + assert!(Scenario::from_toml(toml).is_err()); + } +} diff --git a/crates/rutster-sim/src/sim_audio_pipe.rs b/crates/rutster-sim/src/sim_audio_pipe.rs new file mode 100644 index 0000000..a98198c --- /dev/null +++ b/crates/rutster-sim/src/sim_audio_pipe.rs @@ -0,0 +1,9 @@ +//! # sim_audio_pipe — the test-double `AudioPipe` that simulates a caller +//! +//! **Stub — lands in S2.** +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` +//! §3.2 for the design + `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md` +//! Task S2 for the implementation. Drives a `Scenario` on `on_pcm_frame` +//! (sink: caller speaks); captures `Instant::now()` at every meaningful +//! event for the `LatencyProbe`. diff --git a/crates/rutster-sim/src/thresholds.rs b/crates/rutster-sim/src/thresholds.rs new file mode 100644 index 0000000..8fec10b --- /dev/null +++ b/crates/rutster-sim/src/thresholds.rs @@ -0,0 +1,58 @@ +//! # thresholds — CI-regressed latency thresholds + sim-bench assertion tests +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` +//! §5.1 + §5.5. +//! +//! The threshold consts land here at S1 (per the plan's S1 step 2 note: +//! "the consts as immediate module-level `pub const` items per spec §5.1 — +//! they're used by S5/S6/S7 wiring"). The `#[cfg(feature = "sim-bench")] +//! #[tokio::test]` assertion tests land at S7. +//! +//! # Why these numbers +//! +//! See spec §5.1 for the budget-vs-assertion-slack reasoning. Each const +//! is paired with a doc-comment explaining the budget it enforces + the +//! slack rationale (so a future maintainer who needs to bump one knows +//! *why* the current value is what it is, not just *what* it is). + +/// Slice-4 spec §5.1 + §7 done-criteria #8: kill-time budget is +/// ≤60 ms (3 debounce frames × 20 ms tick + 1 tick to drain + apply). +/// Observer slack to make CI deterministic-but-not-flaky on a slow +/// runner: effective CI assertion ≤80 ms (60 ms budget + 20 ms slack). +/// +/// A regression here is the red X ADR-0010 demands — the wedge's +/// "local real-time reflexes that don't need the brain" claim is +/// arithmetic until this assertion fires on every PR. +pub const BARGE_IN_KILL_TIME_P99_MS: f64 = 80.0; + +/// Slice-1 + slice-3 mouth-to-ear budget: 200 ms (slice-1 notification +/// budget) + 250 ms mock brain round-trip + 100 ms playout buffer. +/// CI assertion ceiling: 700 ms (allowance for CI runner variance +/// against the dev machine — the mock brain is deterministic but the +/// harness adds observer cost; the dev machine usually lands ~600 ms). +pub const MOUTH_TO_EAR_P99_MS: f64 = 700.0; + +/// Slice-5/seams tick-lag gauge: the meta-tick must stay under 10 ms +/// (the loop's nominal period). At 1 call: ≤2 ms expected. At 50 +/// calls: ≤10 ms expected. Tick overruns (count of ticks exceeding +/// 10 ms) at p50 across the sweep: ≤1% of total ticks per +/// `TICK_OVERRUN_PCT_MAX`. +/// +/// If a concurrency sweep shows `tick_overrun_pct > 1.0` at 50 calls, +/// **the FOB reflex loop's single-thread debt is real and the +/// dedicated-threadpool-shard graduation (slice-4 §1.2 deferral #2) +/// gets its data-driven case.** That finding is the slice's +/// load-bearing output regardless of whether the latency thresholds +/// pass — the doctrine-drift detector worked. +pub const TICK_LAG_MAX_MS: f64 = 10.0; +pub const TICK_OVERRUN_PCT_MAX: f64 = 1.0; + +/// Concurrency-sweep sample sizes per spec §2.4: 1 isolates the +/// baseline (cold-path latency with zero concurrency pressure — +/// slice-4's §5.1 ≤60 ms kill budget asserted here); 10 is the +/// warm working set (~peak spearhead-scale); 50 is the saturation +/// point (ADR-0010's "single-poll-task head-of-line-blocking debt" +/// lives here). We do NOT test 100/500/5000 — that's fleet-scale +/// (rung 3). 50 is the upper edge of the spearhead's "one binary, +/// one city" claim. +pub const SWEEP_CONCURRENCIES: &[usize] = &[1, 10, 50]; diff --git a/crates/rutster-sim/src/tick_lag.rs b/crates/rutster-sim/src/tick_lag.rs new file mode 100644 index 0000000..cb166e1 --- /dev/null +++ b/crates/rutster-sim/src/tick_lag.rs @@ -0,0 +1,13 @@ +//! # tick_lag — `TickLagGauge`: the ADR-0010 doctrine-drift detector +//! +//! **Stub — lands in S6.** +//! +//! See `docs/superpowers/specs/2026-07-05-slice-4-half-benchmark-sim-design.md` +//! §3.6 + §6.4 for the design + `docs/superpowers/plans/2026-07-05-slice-4-half-benchmark-sim.md` +//! Task S6 for the implementation. Surfaces `MediaStats.{tick_overruns, +//! last_tick_micros}` as primary readouts in the `SweepReport`. The +//! concurrency sweep turns the gauge from a counter into a decision +//! artifact: "does single-thread poll loop breach budget at realistic +//! concurrency?" gets answered with data, not vibes. If yes, the +//! dedicated threadpool-shard graduation (slice-4 deferral #2) gets +//! scheduled on evidence.