# Rutster slice 4½ — Benchmark + simulation harness (the `rutster-sim` seed) - **Status:** Draft (pending review) - **Date:** 2026-07-05 - **Spearhead step:** 4½ of 6 (inserted by [ADR-0010](../../adr/0010-spearhead-benchmark-sim-harness.md) — "after barge-in (4), before rented transport (5)") - **Origin:** [2026-07-03 adversarial review](../../reviews/2026-07-03-adversarial-review.md) (recommendations R1+R2) + [2026-07-03 market feature scan](../../reviews/2026-07-03-market-feature-scan.md) (F1 — simulation as the standout engine-fit feature) - **Depends on (already merged):** - [slice 1 — WebRTC media loopback](2026-06-28-slice-1-webrtc-loopback-design.md) — the media core + the `AudioPipe` trait slice 4½ simulates against - [slice 2 — The agent tap](2026-06-28-slice-2-agent-tap-design.md) — the `TapAudioPipe` slice 4½ measures end-to-end - 2026-06-30 slice-3 realtime brain (merged `c30a452`) — `MockRealtimeBrain` slice 4½ drives the harness against - [slice 4 — Barge-in / VAD-driven playout kill](2026-07-01-slice-4-barge-in-design.md) — the ≤60 ms kill budget this slice MEASURES (slice-4 §5.1) - 2026-07-04 slice-5/seams — `MediaCmd::Stats` exposes `MediaStats { tick_overruns, last_tick_micros }` (the "tick-lag gauge" readout this harness surfaces as the primary concurrency-sweep result) - **Related:** [ADR-0002](../../adr/0002-north-star-and-fused-core.md) (the wedge: "local real-time reflexes that don't need the brain" — currently an arithmetic claim, this slice proves it), [ADR-0008](../../adr/0008-fob-and-green-zone.md) (the harness is FOB: hot-path-adjacent + differentiating), [ARCHITECTURE.md §"Biggest technical risk"](../../ARCHITECTURE.md) (the reflex loop *is* the long pole; the harness is its measurement surface) --- ## TL;DR Stand up spearhead step 4½: a self-hostable **benchmark + simulation harness** in a new `crates/rutster-sim/` crate. It drives synthetic callers through the SAME media-leg path real callers use, measures **p50/p99 mouth-to-ear latency** and **barge-in kill-time** against slice-4's ≤60 ms kill budget, and runs the same measurements at **1 / 10 / 50 concurrent synthetic calls**. A separate CI job (opt-in via `--features=sim-bench`) asserts the per-budget thresholds every commit. **A latency regression fails the build the same way a broken test does** — ADR-0010's central demand. The harness is also the **doctrine-drift detector** for the timing-thread debt (slice-4 §1.2 deferred per-session threads; slice-5/seams landed a single shared std::thread + a tick-lag gauge without data). 4½'s concurrency sweep turns `MediaStats.tick_overruns` 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. Three things this slice deliberately is NOT: - **Not an LLM-driven caller** (the scenario pack is scripted PCM segments; LLM-driven callers are post-spearhead). The harness ships with three deterministic scenarios that exercise the wedge. - **Not a fuzz target** (the harness exercises real latency under load; it is not designed to find panics in the PCM path — that's the deferred `cargo-fuzz` harness). - **Not a load test for production capacity** (p99 under 50 synthetic calls is the sperhead-scale limit; load testing for horizontally-scaled fleet deployments lands with the rung-2 escalation / step-6 spend-cap work). --- ## 1. Scope ### 1.1 In scope - A new `crates/rutster-sim/` crate (currently non-existent; this slice creates it from `Cargo.toml` + `lib.rs`). - A **`Scenario` data type** + a TOML scenario file format (`crates/rutster-sim/scenarios/*.toml`) describing scripted caller PCM playback with timing directives ("speak N frames, pause M frames, interrupt at T"). At least three shipped scenarios: `loud-barge.toml`, `quiet-advisory.toml`, `sustained-call.toml`. - A **`SimAudioPipe: AudioPipe`** implementation that drives a `Scenario` on `next_pcm_frame` (plays back scripted PCM frames) and captures received frames on `on_pcm_frame` (timestamps each — the latency-measurement boundary). - A **`LatencyProbe`** that computes the two metrics slice-4's design sets targets for: - **Mouth-to-ear round-trip** — caller-speech-onset → first frame returned at the caller's "ear" (the `SimAudioPipe`'s source path). - **Barge-in kill-time** — caller-speech-onset → reflex-barge-fire (the moment `next_pcm_frame` first returns `None` post-barge). - A **`ConcurrencyRunner`** that spawns N concurrent `SimCall`s against a target binary URL (default: in-process via `MediaThread` for determinism) and computes per-call latencies + the p50/p99 aggregates across the sample. - **Tick-lag gauge integration**: the runner reads `MediaStats { tick_overruns, last_tick_micros }` via `MediaCmd::Stats` (already exposed by slice-5/seams) and surfaces both as primary readouts in the sweep report — both the per-call `last_tick_micros` distribution and the cumulative `tick_overruns` count. - **CI-regressed thresholds**: a separate CI job runs `cargo test --all --features=sim-bench` per PR + nightly; thresholds asserted; a regression fails the build. - New learner-facing comments explaining the measurement discipline (the test corpus *is* the code-reading curriculum for "how do you measure a real-time system without lying to yourself?" — the slice-1 §7 verbosity standard carries over). ### 1.2 Out of scope (with scheduled return) | Deferred item | Returns in | Why deferred | |---|---|---| | LLM-driven synthetic callers | post-spearhead refinement | ADR-0010 says scripted scenarios are 4½; LLM callers are an extension. Scripted scenarios are deterministic (reproducibility is the point); LLM callers add variance that complicates threshold-assertion CI gates. | | Per-environment calibration of threshold values | post-spearhead | The MVP ships thresholds tuned for the dev loopback (smoke) + CI runner (deterministic box). Real-world noise calibration (per-CPU, per-RAM) is a tuning-framework problem — paired with slice-4's VAD-threshold tuning deferral. | | Multi-perspective scenario recording (the caller's audio + the operator's audio + the brain's audio saved per-run as WAV for review) | later rung (rung-2 escalation) | Recording per-call audio for supervisor review is a rung-2 capability (warm-handoff artifact). The harness measures; recording is a separate concern. | | Distributed / multi-binary fleet latency sweep | rung 3 (post-escalation) | ADR-0010's sweep targets a single binary at low concurrency. Fleet-scale (N binaries, NAT, autoscaling) lands after the trunk is real (step 5) + escalation is real (rung 2). | | Adaptive `VAD_RMS_THRESHOLD` tuning | post-spearhead | Slice-4 §1.2 defers; 4½ inherits the const threshold (500.0). Real-world noise-floor learning is a later rung. | | Concurrency > 50 calls | later rung | The spearhead's scale envelope caps at "dozens of PSTN calls on one box" (slice-4 §6.3); 50 is the upper claim. Beyond 50 ⇒ fleet (above), not single-binary. | | Latency under degraded brain conditions (mock brain stalls) | later refinement | The MVP measures against `MockRealtimeBrain` (deterministic). The real latency risk is "what if the brain takes 800 ms instead of 300 ms?" — addressed by a "brain slow" scenario ADDED LATER, not in the MVP. | | Hooking `cargo bench` / criterion into the CI artifact publishing | later | False precision: the harness is `cargo test --features=sim-bench`, not `cargo bench`. We are asserting threshold-shape gates, not micro-bench-diff regressions (which are noisy in CI). `criterion` is a different epistemology. | | Browser-based e2e (Playwright/Selenium) | post-spearhead | Unchanged from prior slices' deferral. The synthetic-peer harness is the test vehicle. | --- ## 2. Architecture delta ### 2.1 What changes vs slice-4 + slice-5/seams Slice 4½ adds ONE crate + one CI matrix entry. The fused vertical's existing hot path (`loop_driver.rs` + `rtc_session.rs` + `MediaThread` + `Reflex
` + `TapAudioPipe`) is **untouched**.
The harness drives the same media-leg ingress path a real caller uses — so what it measures is what
the customer experiences, by construction.
```
┌─ crates/rutster-sim/ (NEW, this slice) ──────────┐
│ │
│ Scenario(.toml) ──► SimAudioPipe: AudioPipe │
│ │ │
│ LatencyProbe ◄── timestamps on frames passing │
│ through the pipe │
│ │
│ ConcurrencyRunner (1/10/50 SimCalls) │
│ │ │
│ └─► MediaCmd::Stats (slice-5/seams already) │
│ reads tick_overruns / last_tick_micros│
└──────────────────────┬────────────────────────────┘
│ drives via the existing
│ media-leg ingress path
▼
┌────────────────────────────────────────────── Rutster trust boundary (FOB) ────────────┐
│ WebRTC ingress / Trunk ingress (slice-5) → RtcSession → Reflex ::muted == true).
// Capture: this is the "barge kill observed" wall-clock.
// (Only capture if we are mid-AwaitReply post-barge; see the
// LatencyProbe's classification §3.3 for the dedup logic.)
self.captures.push(Capture::BargeKillObserved { at: Instant::now() });
None
}
}
}
}
impl AudioSink for SimAudioPipe {
fn on_pcm_frame(&mut self, _frame: PcmFrame) {
// The sink path: the caller "speaks." The scenario drives here.
// Decode the current step + emit the appropriate PCM signal.
self.dispatch_step_action();
// (We discard the inbound frame — the caller doesn't hear itself;
// the SimCall's wiring pushes the caller-side frame into the
// `tx_pcm_in` channel for the tap to forward to the brain.)
}
}
```
### 3.3 `LatencyProbe`
```rust
// crates/rutster-sim/src/latency.rs
/// Computes the two metrics slice-4's design sets budgets for, from a
/// captured-stream of `Capture` events produced by a `SimAudioPipe`.
///
/// # Verification discipline
///
/// The probe is the single source of truth for "did latency regress?"
/// Assertions are made against `LatencyProbe::p99_kill_ms()` and
/// `LatencyProbe::p99_mouth_to_ear_ms()`. A failure here is the build-red
/// signal ADR-0010 demands.
pub struct LatencyProbe {
captures: Vec (which wraps the pipe
in the session_map/MediaThread composition site, slice-4 Task 6) trips the SpeechStarted
advisory → Reflex ::muted = true → next_pcm_frame returns None → capture
BargeKillObserved timestamp.
7. On End step: harness stops driving, returns LatencyProbe.
```
### 4.2 Concurrency sweep (the doctrine detector)
```
1. ConcurrencyRunner::run(scenario) launches N Tokio tasks, each running SimCall::run
against the SAME media_cmd_tx (shared media thread).
2. The MediaThread drives ALL N sessions per 10 ms meta-tick (slice-4 §2.2 unchanged shape).
3. Per second during the sweep: ConcurrencyRunner fires MediaCmd::Stats; accumulates
tick_overruns + last_tick_micros samples.
4. On all SimCalls completing: aggregate per-call LatencyProbes → p50/p99 vector.
5. Build SweepReport with per-concurrency rows, asserting thresholds.rs constants.
```
### 4.3 Why in-process (not client-server)
The harness DOES NOT stand up the binary as a server + a separate sim-client process. Why:
1. **Determinism.** Loopback within the same process eliminates websocket / socket / TCP jitter as
a confounder. The thresholds are assertions about the FOB reflex loop itself — not assertions
about NTPD variance on the CI runner.
2. **CI simplicity.** `cargo test --features=sim-bench` runs in-process; no port binding, no
test-orchestration container, no race against `epoll` initialization.
3. **Direct seam access.** The harness can construct a `SimAudioPipe` and ship it via
`MediaCmd::RegisterSim` directly — same path the production binary would use if it had a
"synthetic caller" feature, no client-server glue needed.
A separate client-server mode (true loopback against the binary's HTTP/WebSocket surface) IS
deferred — it's needed when the harness gains LLM-driven callers (post-spearhead refinement) and
needs network realism.
---
## 5. Measurement plan + thresholds
### 5.1 The budgets (concrete numbers)
| Metric | Budget (slice-4 design) | CI assertion (slice 4½) | Rationale for slack |
|---|---|---|---|
| Barge-in kill-time, p99 | ≤60 ms (3 debounce × 20 ms + 1 drain tick) | ≤80 ms | CI runner has known variance against dev; the budget is 60 ms; the assertion is 80 to avoid flakiness. |
| Mouth-to-ear round-trip, p99 | ≤200 ms slice-1 + ≤300 ms mock brain + ≤100 ms playout = ~600 ms | ≤700 ms | Same logic; the mock brain is deterministic but the harness adds observer cost. |
| Tick-lag (max prev-poll duration) | unspecified | ≤10 ms | The slice-5/seams META_TICK const; the invariant the assertion makes explicit. |
| Tick overruns (fraction of ticks > 10 ms) | unspecified | ≤1% | At 50 calls × 1000 ticks each = ≥99% need to be ≤10 ms. Allows for one scheduling hiccup per ~99 well-behaved ticks. |
### 5.2 Per-concurrency swept assertions
```
For each N ∈ [1, 10, 50]:
run scenario loud-barge.toml against N concurrent SimCalls.
assert p99_kill_ms <= 80 ms;
assert p99_mouth_to_ear_ms <= 700 ms;
assert max_tick_lag_micros <= 10_000; // 10 ms
assert tick_overrun_pct <= 1.0;
```
### 5.3 The scenarios (3 shipped)
| Scenario | Path | What it asserts |
|---|---|---|
| `loud-barge.toml` | Caller speaks 20 loud frames → awaits reply → end. | The PRIMARY barge-in path (slice-4 §5.1): local VAD fires, kill within ≤80 ms at p99, NO brain advisory required. |
| `quiet-advisory.toml` | Caller speaks 20 quiet frames (sub-VAD-threshold) → awaits reply → end. | The SECONDARY barge-in path (slice-4 §5.2): brain advisory fires from `MockRealtimeBrain`, kill flows through slice-3 plumbing + slice-4 `advisory_tx` → `Reflex`. |
| `sustained-call.toml` | Caller speaks 10 loud → 10 quiet → 10 loud → 10 quiet → 10 loud → end (5 minutes of talk). The fatigue / sustained-load check. | Multi-barge: 3 `SpeechStarted` advisories should fire in sequence; the `Reflex::barge_epoch` increments 3×; latency is asserted across all three bars (the second + third bar shouldn't drift > 1.5× the first). |
### 5.4 CI integration
The CI workflow gains a new job:
```yaml
# .github/workflows/ci.yml (additive)
sim-bench:
name: sim-bench (stable)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install libopus
run: sudo apt-get update && sudo apt-get install -y libopus-dev
- name: Run sim-bench threshold sweep
run: cargo test --all --features=sim-bench -- --test-threads=1
```
Notes:
- `--test-threads=1` — concurrent sim-bench tests would pollute each other's `MediaStats` polling
(the tick-lag gauge measures the SHARED media thread; concurrent runs of the sweep would
contaminate each other).
- Run on `stable` only — the matrix already runs `cargo test --all` on stable + 1.85; the bench
feature lives on stable.
### 5.5 The thresholds-as-test contract
```rust
// crates/rutster-sim/src/thresholds.rs (continuation — the CI test entry)
#[cfg(test)]
mod threshold_assertions {
use super::*;
#[tokio::test]
#[cfg(feature = "sim-bench")]
async fn loud_barge_at_each_concurrency_passes_thresholds() {
let scenario = Scenario::load("scenarios/loud-barge.toml").unwrap();
for &n in SWEEP_CONCURRENCIES {
let report = ConcurrencyRunner::in_process(n).run(scenario.clone()).await;
let row = report.per_concurrency.iter()
.find(|r| r.concurrency == n).expect("concurrency row");
assert!(
row.p99_kill_ms <= BARGE_IN_KILL_TIME_P99_MS,
"p99 kill-time at N={}: {}ms > {}ms (budget overflow)",
n, row.p99_kill_ms, BARGE_IN_KILL_TIME_P99_MS,
);
assert!(
row.p99_mouth_to_ear_ms <= MOUTH_TO_EAR_P99_MS,
"p99 mouth-to-ear at N={}: {}ms > {}ms",
n, row.p99_mouth_to_ear_ms, MOUTH_TO_EAR_P99_MS,
);
assert!(
(row.max_tick_lag_micros as f64) / 1000.0 <= TICK_LAG_MAX_MS,
"max tick-lag at N={}: {}us > {}ms",
n, row.max_tick_lag_micros, TICK_LAG_MAX_MS,
);
assert!(
row.tick_overrun_pct <= TICK_OVERRUN_PCT_MAX,
"tick overrun % at N={}: {}% > {}%",
n, row.tick_overrun_pct, TICK_OVERRUN_PCT_MAX,
);
}
}
#[tokio::test]
#[cfg(feature = "sim-bench")]
async fn quiet_advisory_at_1_concurrency_passes_thresholds() { /* ... */ }
#[tokio::test]
#[cfg(feature = "sim-bench")]
async fn sustained_call_multibarge_does_not_drift() { /* ... */ }
}
```
---
## 6. Why these decisions
### 6.1 Why a new crate (`crates/rutster-sim/`) instead of in-tree tests
A test under `crates/rutster/tests/sim_*.rs` would absorb the harness into the binary crate —
encouraging the harness to depend on binary internals. The separate crate keeps the dependency
direction: `rutster-sim` → `rutster-media` + `rutster-call-model` + `rutster` (for `MediaCmd`).
The sim crate is a **FOB member** per ADR-0008: hot-path-adjacent (drives the loop), differentiating
(the proof artifact). It earns cratehood the same way `rutster-tap` did.
### 6.2 Why scripted scenarios, not LLM-driven callers (deferred)
- **Reproducibility** is the entire point. A CI gate that asserts "p99 ≤ 80 ms" can't be flaky on
LLM variance — the LLM might take 2 s to respond, the threshold fails, the build is red, the dev
attributes it to the LLM provider. The signal is corrupted.
- **Isolation** of what's being measured: scripted scenarios measure the FOB + the loop + the
reflex; LLM callers introduce brain-side variance that isn't a FOB property.
- LLM-driven callers land in a post-spearhead refinement tier (ADR-0010 explicit deferral). They
measure a different question ("how does it feel to talk to my brain?") than the MVP harness asks
("does the FOB reflex loop meet its budget under load?").
### 6.3 Why in-process, not client-server (the false-precision risk)
A client-server test harness gives Network-Realism™ but takes away the **measurement discipline**: a
TCP retransmit on the CI runner would inflate p99 unattributably, and a "drift over threshold"
failure becomes a triage time-sink ("is the FOB regressing, or did the CI runner's network blip?").
The in-process mode gives **single-process determinism** at the cost of network realism — which the
spearhead explicitly doesn't need (it's measuring the FOB loop, not the deployed system). A
future post-spearhead tier adds a client-server mode for integration realism + a different set of
thresholds.
### 6.4 Why tick-lag as a primary readout (the doctrine-drift contract)
The single-thread media loop (slice-4 §6.3) is a spearhead-scale decision explicitly deferred to a
"load demands it" trigger. Without the harness measuring it under load, the deferral has no
data-driven graduation criterion — it'd be doctrine ("faster per-session threads later") instead of
evidence ("measured `tick_overrun_pct = 12%` at 50 calls, the threadpool shard lands now"). ADR-0010
explicitly pairs the debt — `MediaStats.tick_overruns` exists because of slice-5/seams' seam
work; slice 4½ makes the gauge OBSERVED. Both pass and the single-thread design is validated; both
breach and the graduation is scheduled with data.
### 6.5 Why `--features=sim-bench` (default off)
`cargo test --all` runs in 12 s on the CI runner today (fmt+clippy+test sweep, no bench).
Turning the threshold sweep on by default would either (a) slow down every PR's turnaround to the
sim sweep duration (estimated 30–60 s, dominated by the 50-concurrency sweep), or (b) make the
threshold sweep's failure mode just-another-failing-test that gets ignored. The opt-in feature
makes the threshold sweep a **separate concern with its own CI job**, surfacing its results
prominently in the PR status checks, and small enough to not block the routine `cargo test --all`
gate. This mirrors how performance-sensitive open-source projects gate `cargo bench` PRs.
### 6.6 Why p99 (not p50) as the load-bearing assertion
p50 = "the typical experience." p99 = "the worst acceptable case." The contact-center wedge
(README §"Why it exists") rests on tight-tail-latency: a p50 of 60 ms with a p99 of 1.5 s is
INDISTINGUISHABLE from a cloud CCaaS provider that averaged down to 60 ms but had bad tails. The
assertion HAS to fire on the tail to be meaningful.
p999 (or max) IS deferred — too noisy for CI gate assertions (one scheduler hiccup blowing past the
ceiling would block every PR for unrelated reasons). p99 is the empirical sweet spot: sensitive
enough to catch real regressions, lenient enough to survive CI runner variance.
---
## 7. Done-criteria
1. `cargo test --all` passes (stable + 1.85) — the routine gate, UNCHANGED. The sim-bench feature
is opt-in; default `cargo test --all` does NOT run the threshold sweep.
2. `cargo fmt --check` + `cargo clippy -- -D warnings` clean on the new crate.
3. `cargo test --all --features=sim-bench` passes — the new gate, on stable. CI runs this in a
separate `sim-bench` job per PR + nightly.
4. `cargo deny check` passes — no new dep conflicts (`toml` is already a workspace member;
`serde` already workspace member).
5. `cargo doc --no-deps` renders the new `crates/rutster-sim/` cleanly with learner-facing
comments per AGENTS.md code style.
6. Loop driver + rtc_session seam STILL holds: `loop_driver.rs` + `rtc_session.rs` byte-identical to
slice-3 (CI pinned-blob gate from slice-4 Task 10 unchanged). The new `MediaCmd::RegisterSim`
variant lives in `media_thread.rs`, NOT in the seam files.
7. The lance `loud-barge.toml` scenario passes the threshold sweep at all of [1, 10, 50]
concurrency.
8. The `quiet-advisory.toml` scenario passes at 1 concurrency (the secondary-path focus).
9. The `sustained-call.toml` scenario's 3-barge sequence shows performance drift ≤ 1.5× across
barges (anti-fatigue assertion).
10. Tick-lag gauge reads a `MediaStats.{tick_overruns, last_tick_micros}` value during the sweep
and surfaces it in the SweepReport.
11. SweepReport's per-concurrency rows are logged to stderr in a structured format (CI failure
messages are readable; "p99 kill-time at N=50: 84ms > 80ms" not "test_sim_thresholds
failed").
12. The single-thread-vs-threadpool question has a data-point answer documented ("slice 4½ found p99
tick-lag = Xms at 50 calls; the threadpool shard remains deferred / the threadpool shard
should land now"). Even if the answer is "data confirms the deferral is fine for now," the
decision is no longer vibe-based.
---
## 8. Open decisions
### 8.1 Should the harness also assert against the HTTP/WebSocket out-of-process surface?
**Decision (slice 4½):** no. In-process measurement only. The out-of-process mode is a future
post-spearhead refinement (paired with LLM-driven callers). Reasoning in §6.3.
### 8.2 Should the sim-bench CI job also run on the 1.85 toolchain?
**Decision (slice 4½):** no. The matrix already runs `cargo test --all` on both. The sim-bench
job is a stable-only opt-in feature; 1.85 doesn't get a second tier of benches. Rationale:
the sim-bench feature is `cfg(feature)` — feature-gated code paths need their own gates, not a
toolchain proliferation.
### 8.3 Threshold values: hardcodedconsts vs env-overridable for the CI runner operator?
**Decision (slice 4½):** hardcoded consts in `thresholds.rs`. Post-spearhead, with the
per-environment tuning framework (paired with slice-4 §1.2 VAD-threshold tuning deferral), they
become env-driven. Hardcoded now makes the budget-vs-assertion-slack reasoning (§5.1) explicit in
source — not subject to runner-env drift.
### 8.4 Should `MediaCmd::RegisterSim` carry a `pipe: Box