Files
rutster/LEARNING.md
Aaron D. Lee 5c24c64140 docs(sim): scenario pack + LEARNING.md pointers (slice-4½ S8)
Three shipped scenarios assert distinct FOB reflex properties per spec section 5.3:
- loud-barge.toml: PRIMARY barge-in path (local VAD, 20 loud frames @ 20ms = 400ms)
- quiet-advisory.toml: SECONDARY path (sub-VAD-threshold quiet frames; in the
  slice-4-half standalone-wiring mode this exercises the harness's own latencies
  rather than real brain ASR-VAD -- the latter deferred to post-spearhead
  MockRealtimeBrain composition per spec section 8.6)
- sustained-call.toml: multi-barge / anti-fatigue check (3 loud bursts across 5 speak
  cycles; the second + third bar's kill-time should drift <= 1.5x the first's).

LEARNING.md gains 5 new slice-4-half concept pointers covering: measurement discipline
(the callers clock), post-hoc dedup of noise captures, in-process concurrency sweep,
atomic accumulators with compare_exchange_weak, and pub(crate) visibility for the
percentile_ms helper used by ConcurrencyRunner's sample-level aggregation (avoiding
the interleaved-captures-corrupt-LatencyProbe-pairing problem).

Signed-off-by: Aaron D. Lee <himself@adlee.work>
2026-07-05 03:28:02 -04:00

155 lines
8.9 KiB
Markdown

# LEARNING.md — to learn concept X, read file Y
This index maps a Rust concept you might be learning to the file where
slice 1 makes the concept concrete. Each entry is a worked example you
can read in `cargo doc --open` plus the source file itself.
## Concepts + pointers
- **Newtype pattern (type-safety via single-field wrappers)** →
`crates/rutster-call-model/src/lib.rs``ChannelId(Uuid)`. The newtype
stops us from mixing up a `ChannelId` with some future `SessionId` at
the type-system level. Compile-enforced where a comment could only ask.
- **`enum` for closed state sets + exhaustive `match`** →
`crates/rutster-call-model/src/lib.rs``ChannelState` (New →
Connecting → Connected → Closing → Closed). Exhaustiveness checking
forces every `match` to consider each state; adding a state later
surfaces every site that needs handling.
- **Sans-IO pattern (no I/O inside the library; input via method calls,
output via return values)** → `crates/rutster-media/src/loop_driver.rs`
— the str0m poll loop. `Rtc::handle_input` takes a network packet as a
struct argument, not from a socket the library owns; `poll_output`
returns `Transmit` packets the caller sends. Fully testable without a
network — str0m integration tests use this property to drive faster
than realtime.
- **Trait design for extension points (a futures-compatible seam)** →
`crates/rutster-media/src/pcm.rs` — the `AudioSource` / `AudioSink`
traits. Slice 1 wires an `EchoAudioPipe` between them; step 2 swaps
that for a real WSS tap client without touching `RtcSession`. The
traits describe *what* the splice point does, not *how* it's filled.
- **Error enums with `thiserror` + hot-path match-and-continue** →
`crates/rutster-media/src/lib.rs` (`MediaError`) and
`crates/rutster-media/src/opus_codec.rs` (`OpusDecoder::decode` returns
`Option<PcmFrame>`). Cold path: `thiserror`-derived enum + `?`. Hot
path: match-and-continue, never `?`, never panic — "drop + observe,
don't crash" (spec §3.8).
- **`Arc<Mutex<T>>` vs `Arc<RwLock<T>>` — when each is right** →
`crates/rutster/src/session_map.rs`. The `RtcSession` lives behind
`Arc<Mutex<...>>` because every access mutates it (str0m's `&mut self`
contract) — `RwLock`'s read-mode would be useless. Comment on the
struct explains the trade-off.
- **`DashMap` for sharded concurrent maps** →
`crates/rutster/src/session_map.rs`. `DashMap` shards its inner map so
two handlers operating on different `ChannelId`s don't contend;
`HashMap` wrapped in a single `Mutex` would serialize every access.
- **str0m 0.21's single-mutation invariant** →
`crates/rutster-media/src/loop_driver.rs`. Mutate (handle_input /
Writer::write) → drain `poll_output` to `Output::Timeout` → next
mutate. Violating this leaves str0m in an inconsistent state.
- **tokio graceful shutdown via signal handlers** →
`crates/rutster/src/main.rs` (`shutdown_signal`). Ctrl-C / SIGTERM
drops the AppState; the AppState drops the DashMap; the DashMap drops
every RtcSession. No in-flight call preservation in slice 1.
- **`include_str!` for embedding static assets** →
`crates/rutster/src/routes.rs` (`include_str!("../static/index.html")`).
The HTML test client is compiled into the binary at build time — no
separate file to ship, no disk IO to serve it.
- **`mpsc` + `oneshot` for cold-path task supervision** → `crates/rutster/src/tap_engine.rs`
— how a spawned tokio task is supervised + cancelled via `oneshot::Receiver`.
- **`VecDeque` as a bounded playout ring with drop-oldest policy** → `crates/rutster-tap/src/tap_audio_pipe.rs`
— why a manual ring (not `mpsc`) when the overflow policy is drop-oldest, not drop-newest.
- **Async WS connect + `Sink`/`Stream` traits** → `crates/rutster-tap/src/tap_client.rs`
`tokio_tungstenite::connect_async`, `WebSocketStream`, the `SinkExt`/`StreamExt`
extension traits, `tokio::select!` over inbound + outbound + close.
- **`Box<dyn Trait + Send>` field widening (the seam test)** → `crates/rutster-media/src/rtc_session.rs`
— why the `pipe` field type changed from `EchoAudioPipe` to
`Box<dyn AudioSource + AudioSink + Send>` so `loop_driver`'s call sites
are byte-identical (slice-2 §8.5 #6).
- **Zero-sized marker newtype for state flags** → `crates/rutster-call-model/src/lib.rs`
`TapHandle(())` compiles `Option<TapHandle>` to a single `bool`; no
runtime cost for the type-system marker.
### Slice 3 — OpenAI Realtime brain
- **`async-trait` patterns / async fns in trait objects** →
`crates/rutster/src/tool_registry.rs` — the `Tool` trait's `async fn call`
and how `async-trait` lowers it to `Pin<Box<dyn Future + Send>>` (the same
boxing the slice-1 `pipe` widening used, now applied to trait methods).
- **OpenAI Realtime adapter + event translation (pure-function layer)** →
`crates/rutster-brain-realtime/src/translator.rs` — the §4.2 event mapping
table as pure `fn`s: tap `audio_in` ⇄ OpenAI `input_audio_buffer.append`,
`response.audio.delta``audio_out`, `function_call_arguments.done`
`function_call`. Testable without a network (the pump is in a separate file).
- **Tap protocol additive extension + forward-compat via `#[serde(other)]`** →
`crates/rutster-tap/src/protocol.rs` — how slice-3 adds `speech_started`,
`speech_stopped`, `function_call`, `function_call_output`, `tools.update`
without breaking slice-2 brains: unknown variants deserialize to a catch-all
arm instead of erroring (forward-compat by construction).
- **Side-channel mpsc for FOB-boundary dispatch** →
`crates/rutster/src/session_map.rs` — how `drive_all_sessions` drains a
function-call mpsc side channel into the tap WS writer without blocking the
20 ms media loop. The pattern: hot loop polls `try_recv`; cold path spawns.
- **HTTP request builder for WS subprotocol handshake** →
`crates/rutster-brain-realtime/src/openai_client.rs` — why we hand-roll the
WS upgrade `Request` instead of letting `connect_async` build it: to set
`Authorization` + `OpenAI-Beta` headers on the WS handshake (tungstenite
doesn't expose subprotocol/auth headers via the simple `connect_async(url)`
entry point — see `openai_headers` / `openai_realtime_url`).
## Slice 4½ — benchmark + simulation harness
- **Measurement discipline: the caller's clock** →
`crates/rutster-sim/src/sim_audio_pipe.rs` — the `AudioPipe` test-double
that IS the caller. Both onset + receipt timestamps are captured inside
the SimAudioPipe (the wall-clock the *caller* started speaking + the
wall-clock the *caller* heard the reply). The harness cannot lie about
latency because the only clock it uses is the caller's (spec §2.2 — the
load-bearing design choice).
- **Post-hoc p50/p99 computation + dedup of noise captures** →
`crates/rutster-sim/src/latency.rs` — the `LatencyProbe` consumes
`Capture` events + pairs `CallerLoudOnset` with the next
`BargeKillObserved` (kill-time) and the next `CallerHeardReply`
(mouth-to-ear). Captures without a prior onset are silently dropped
(the SimAudioPipe captures BargeKillObserved unconditionally on empty
ring; the probe is the dedup gate).
- **In-process concurrency sweep + the doctrine-drift detector** →
`crates/rutster-sim/src/concurrency.rs` — the `ConcurrencyRunner` spawns
N `SimCall`s at levels [1, 10, 50] (the spearhead-scale envelope) +
aggregates per-call latencies. Per spec §2.4: 1 isolates the baseline,
10 is the warm working set, 50 is the saturation point.
- **Atomic accumulators on the hot path: `compare_exchange_weak`** →
`crates/rutster-sim/src/tick_lag.rs``TickLagStats` keeps a per-tick
max + overrun counts via `AtomicU64`. The CAS loop on `max` is the
idiomatic pattern for "atomic max update" in Rust (a single
load-then-CAS-on-fail loop, ordering::Relaxed because stat counters
don't need cross-thread synchronization ordering). 3 atomic ops per tick
(max CAS + conditional fetch_add + unconditional fetch_add) — no Mutex,
lock-free hot path.
- **`pub(crate)` visibility for cross-module helper** →
`crates/rutster-sim/src/latency.rs``percentile_ms` is `pub(crate)`
(not `pub` nor private). The `ConcurrencyRunner` (sibling module) needs
it to compute p50/p99 over the *merged sample across N probes* — merging
`kill_times()` samples per-probe (not concatenating `Capture` vectors)
avoids the interleaved-captures-corrupt-LatencyProbe-pairing problem
(each probe has its own `Option<Instant>` pairing cursor; merging
captures across probes would interleave their cursors).
## How to read
1. `cargo doc --open` — every module has a `//!` doc comment; the doc
tree is the high-level map.
2. Pick a concept above; open the named file. The first occurrence of
each non-obvious pattern has a `//` comment explaining *why*.
3. Cross-ref back to the spec sections cited inline (`spec §3.8`,
`ADR-0002`, etc.) for the architecture-level rationale.