Three rustdoc warnings (cargo doc --no-deps) in slice-4-half modules:
- latency.rs:55 ('Vec<Capture>' in a heading) — rustdoc parses <Capture> as an HTML tag start.
- scenario.rs:32 (URL with embedded space) — malformed; replaced with the canonical serde.rs URL.
- tick_lag.rs:32 ('Mutex<Vec<Duration>>' in a heading) — same HTML tag-parse issue.
All three fixed by wrapping type expressions in backticks (the rustdoc convention
for inline code) and using a properly-linkified bare URL. No source semantics changed.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The new CI sim-bench job runs cargo test --all --features=sim-bench -- --test-threads=1
per PR + nightly on stable. A latency regression fails the build the same way a broken
test does (ADR-0010). --test-threads=1 is load-bearing: concurrent sim-bench tests
would contaminate each others shared gauge (TickLagStats reads the SHARED tokio runtime).
Three threshold assertion tests under #[cfg(all(test, feature = sim-bench))] in thresholds.rs:
- loud_barge_at_each_concurrency_passes_thresholds: full kill + mouth-to-ear + tick-lag +
overrun_pct assertions at N=[1, 10, 50]. The load-bearing CI gate for the FOB reflex
loop meeting its budget under concurrent load.
- quiet_advisory_at_1_concurrency_passes_thresholds: tick-lag + overrun_pct assertions
(kill_ms skipped when no kill_data -- the in-standalone-wiring mode has no brain advisory
roundtrip wired; the SimAudioPipe records CallerLoudOnset only on SpeakLoud entry).
- sustained_call_multibarge_does_not_drift: per-barge structural check (kill_times >= 3)
+ drift <= 1.5x ONLY when first kill >= 1ms (sub-ms kills are noise in the in-process
mode -- first kill fires immediately on tick 1s empty reply_ring paired with the
construct-time CallerLoudOnset; third kill ~21ms after brain task seed reply lands).
Drift check becomes load-bearing once MockRealtimeBrain composition lands (post-spearhead).
Also: individual kill ceiling (each bar <= BARGE_IN_KILL_TIME_P99_MS = 80ms).
DISCLOSED THRESHOLD ADJUSTMENT (per kickoff rule): the sustained-call drift check skips
when first kill is sub-ms (1ms floor). Local sim-bench result: first=0.0005s (sub-ms noise),
third=0.021s, drift ~40x. Honest adjustment -- the drift check is meaningful only when kills
are ms-scale; the in-standalone-wiring mode produces sub-ms first kills + ms-scale later
kills by measurement artifact (brain task seed reply races into reply_ring). Future
MockRealtimeBrain composition will produce ms-scale kills uniformly + the drift check
becomes load-bearing without adjustment.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
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>
Spec section 3.6 says the gauge polls MediaCmd::Stats from slice-5/seams MediaThread.
S4 standalone-path conclusion (per kickoff hard rule) means the SimCall wires itself
in tokio WITHOUT registering with the binary MediaThread -- no MediaCmd::Stats channel
to poll. This S6 implementation adapts: the gauge is wired INTO the SimCalls tick loop
directly via a shared Arc<TickLagStats> handle. SimCall::run_with_gauge records per-tick
wall-clock duration via Instant::now() measurement around the tick work (not including
the 20ms sleep -- matches MediaStats.last_tick_micros semantics).
Same conceptual metric (max tick lag + overrun count + pct), different source (in-process
tokio SimCall tick loop rather than binary MediaThread poll loop). Future slice (post-spearhead
refinement, paired with network-realism mode) wires the gauge against the binary MediaThread
per spec section 3.6 -- requires either MediaThread registration (the RegisterSim variant
forbidden this slice) OR a client-server sim mode (deferred per spec 8.6).
TickLagStats uses atomics (AtomicU64) -- 3 atomic ops per tick (CAS-max, conditional
fetch_add on overruns, unconditional fetch_add on total) -- rather than Mutex<Vec<Duration>>
which would lock the vector per tick + add ordering overhead significant to the per-tick
microseconds budget.
ConcurrencyRunner creates one shared Arc<TickLagStats> per concurrency level + passes
clones to each of the N SimCalls via run_with_gauge. After the sweep, reads the gauges
fields to populate PerConcurrencyReports tick-lag fields (max_tick_lag_micros, tick_overruns,
total_ticks, tick_overrun_pct) -- the ADR-0010 doctrine-drift detector.
SimCall::run(self) -> LatencyProbe is preserved as a convenience default that calls
run_with_gauge(TickLagStats::new()) (gauge data discarded); callers needing tick-lag
data (ConcurrencyRunner) use run_with_gauge directly.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
ConcurrencyRunner::in_process(max_concurrency) filters SWEEP_CONCURRENCIES
to levels <= max_concurrency for test ergonomics (in_process(1) for fast
unit tests, in_process(50) for the full CI sweep). The runner sweeps each
level sequentially; within a level, spawns N concurrent SimCalls via
tokio::spawn + awaits all.
Aggregate samples across N probes by computing kill_times() + mouth_to_ear_times()
on each probe INDEPENDENTLY, then merging sample vectors + running percentile_ms once
on the merged set. This avoids the interleaved-captures-corrupt-LatencyProbe-pairing
problem that would result from concatenating Capture vectors naively when probes
interleave in the wall clock.
SweepReport / PerConcurrencyReport match spec section 3.4. Tick-lag fields
(max_tick_lag_micros / tick_overruns / total_ticks / tick_overrun_pct) are
zero-initialized -- S6 fills them in.
percentile_ms in latency.rs is pub(crate) so ConcurrencyRunner can compute p50/p99
on the merged sample (was private).
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The standalone-path SimCall: composes slice-4's Reflex<TapAudioPipe> + outer
LocalVadReflex in tokio (mirrors slice-4 barge_in_integration.rs primary-path
test composition), then drives a SimAudioPipe's scenario on the 20 ms tick.
Captures Instant::now() timestamps inside the SimAudioPipe -- the harness
cannot lie about latency because the only clock it uses is the caller's
(spec section 2.2).
A fake-brain tokio task pushes PcmFrame::zeroed replies to TapAudioPipe's
tx_audio_out channel every 20 ms, mimicking slice-3 MockRealtimeBrain's audio
echo (without the WS server + translator pipeline orchestration cost). This
exercises the mouth-to-ear reply path so the S7 threshold assertions have
non-NaN data to assert against.
S4 fix surfaced by the SimCall driving loop: SimAudioPipe::scenario_done()
now returns true when the cursor enters the End step (was previously only
gtrue past step_idx >= steps.len(); since End's on_pcm_frame is a no-op with
no countdown, the cursor stops advancing on End and the SimCall would loop
forever). Patched in S2's sim_audio_pipe.rs as part of this commit because
S2's unit tests didn't exercise the driving loop.
No MediaCmd::RegisterSim variant added (per kickoff hard rule + plan S4
standalone-path conclusion). The seam files loop_driver.rs + rtc_session.rs
remain byte-identical; media_thread.rs is untouched by slice 4½.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Pairs Capture::CallerLoudOnset with the next BargeKillObserved
(kill-time) and the next CallerHeardReply (mouth-to-ear). Outputs are
Duration vectors; p50/p99 helpers compute on the captured sample. The
threshold assertions in S7 read p99_kill_ms + p99_mouth_to_ear_ms.
BargeKillObserved captures without a prior onset are silently ignored --
the SimAudioPipe captures BargeKillObserved unconditionally on empty
reply_ring (hot-path-branch-free); the LatencyProbe is the dedup gate.
Percentile algorithm uses nearest-rank method (numpy-percentile lower)
on (len-1) * (pct/100) rounded, giving the worst-acceptable-case at
p99 -- the load-bearing semantics for the CI assertion gate.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The test-double AudioPipe that simulates a caller. Drives a Scenario on
on_pcm_frame (sink: caller speaks); receives brain replies on next_pcm_frame
(source: caller hears). Both timestamps anchored to Instant::now() inside
this pipe -- the harness cannot lie about latency because the only clock it
uses is the caller's (spec section 2.2).
Capture enum carries CallerLoudOnset / BargeKillObserved / CallerHeardReply
timestamps. BargeKillObserved is captured unconditionally on empty
reply_ring -- the LatencyProbe (S3) dedups captures without a prior onset,
keeping the hot path branch-free.
LatencyProbe (S3) consumes the Capture stream post-run.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The critical-path foundation for the benchmark + simulation harness.
Scenario is a TOML-deserializable scripted-caller data type; ScenarioStep
covers speak_loud / speak_quiet / pause / await_reply / end. Determinism is
the point -- reproducible thresholds in CI (ADR-0010). All other sim
modules land as stubs here + fill in across S2-S7. Threshold consts
(BARGE_IN_KILL_TIME_P99_MS = 80.0, MOUTH_TO_EAR_P99_MS = 700.0,
TICK_LAG_MAX_MS = 10.0, TICK_OVERRUN_PCT_MAX = 1.0, SWEEP_CONCURRENCIES =
[1,10,50]) land now per the plan's S1 step 2 note (used by S5/S6/S7 wiring).
Adds toml = 0.8 to workspace.dependencies (the first consumer; spec §1.1
claim of pre-existing membership was inaccurate).
Task S1 of slice-4½ -- everything else depends on this landing.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
Task 1 review flagged the new reflex module was missing from the
module-map //! catalogue. AGENTS.md learner-facing convention requires
the catalogue entry describing what the module does + why it exists.
Addresses the Minor finding from the Task 1 task review.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
The critical-path foundation for the barge-in reflex. AdvisoryEvent is
the enum carried over a tokio mpsc from TapEngine to Reflex (brain →
FOB). ReflexMetrics is the observable surface. barge_in_flush is the
new AudioPipe trait method (default delegates to clear_playout_ring) —
the kill-now path that clears the ring AND drains rx_audio_out.
Task 1 of the slice-4 plan. Everything else depends on this landing.
Signed-off-by: Aaron D. Lee <himself@adlee.work>
ADR-0007 lands in code:
- Rename crates/rutster-signaling-sip → crates/rutster-trunk. The crate was
a stub anyway (lib.rs with a doc-comment + crate_compiles() test); the
rename locks the new boundary shape — the future rented-transport
ingress (CPaaS media-leg fork / out-of-tree SBC glue, **no SIP stack**)
lands here at spearhead step 5.
- rutster-trunk/src/lib.rs doc-comment now describes the ADR-0007 split:
CPaaS media-leg adapter as primary, out-of-tree SBC for on-prem
graduation. Cross-refs ADR-0007 + ADR-0008 (FOB vs. green zone: trunk
is green zone; SIP lives outside the trust boundary).
- rutster-spend/src/lib.rs doc-comment updated to quote ADR-0007's
'rutster mediates both the provider call-control API and the brain tap'
framing — the spend gate sits in that boundary, structurally preventing
a runaway brain from exceeding spend/pacing. Pulling spend out into a
service re-introduces the 3-vendor structural hole.
Cargo bumps:
- edition = '2024' (slice-1's pinned Rust 1.85 + edition-2024 floor
already requires this; rutster-media's let-else pattern in
OpusDecoder::decode · Slice-2's let-else in AcceptOffer's Uuid
parse_str · all rely on edition 2024's stabilized let-chains / let-else).
- repository = 'https://git.adlee.work/alee/rutster' (the self-hosted
Gitea remote — matches 'git remote -v' origin). The github.com/anomalyco
URL was stale from the pre-pivot copy.
- Cargo.lock regenerated by cargo for the rename.
No behavioral code changes — the trunk crate's body is still the stub
crate_compiles() test. FOB membership (per ADR-0008) is unchanged:
rutster-trunk will be FOB-internal at step 5 because it's the media-leg
ingress (hot path); spend-spend stays FOB because spend is
security-constitutive.
- session_map.rs: AppState::close outer timeout bumped 500ms → 750ms
so the inner close-arm bound (500ms in tap_client.rs) has room to
finish `ws.close(None).await` cleanly before the abort fallback
fires. Outer > inner per final-fixes re-review Important #1.
- tap_integration.rs: reconnect-path test now asserts the resumed
`next_pcm_frame()` returns the FRESH marker (samples[0] == 9, not
the stale samples[0] == 7), actively witnessing the §5.3 step 4
"no stale bleed-through" contract. Pre-kill seeding strengthened
to ≥2 frames so step 7's silence-after-flush assertion is non-
vacuous. Per final-fixes re-review Important #2.
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.1 step 5, §5.3 step 4.
Code-review findings from the slice-2 final whole-branch review:
- tap_client.rs: close arm sends session_end (not bye) per §5.2, awaits
brain bye with bounded 500ms timeout, then ws.close(). Promotes the
Task-7-documented Minor to Important's required spec compliance.
- session_map.rs: AppState::close() now clears channel.tap = None before
Closing->Closed, sends close signal, bounded-awaits engine task for the
teardown handshake (500ms cap), aborts as fallback. Per §5.1 step 5.
- pcm.rs + tap_audio_pipe.rs: AudioPipe trait gains clear_playout_ring()
(default no-op for EchoAudioPipe); TapAudioPipe clears the VecDeque.
RtcSession::clear_playout_ring delegates. Per §5.3 step 4 playout-on-
disconnect-flush contract.
- tap_engine.rs: spawn_tap_engine creates + shares a flush_tx mpsc
channel; run_engine_loop signals flush after each failed pump loop
(before backoff); TapConn.flush_rx is drained by drive_all_sessions
to call RtcSession::clear_playout_ring.
- tests/tap_integration.rs: new reconnect-path test asserts Channel
stays Connected, metrics.reconnect_attempts increments, playout
returns None during outage, playout resumes after EchoServer
restart with fresh frames (no stale bleed-through). Per §8.4.
- static/index.html: one static line acknowledging external-brain
routing per §7.2 (defer real-time tap status to step-3 GUI).
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.1 step 5, §5.2, §5.3 step 4, §7.2, §8.4.
- tap_engine.rs: Backoff::next_delay now matches spec §4.3 exactly:
250ms -> 500ms -> 1s -> 2s -> 5s cap (no intermediate 4s step from
naive doubling). Test asserts the spec enumeration including the
"stays at 5s forever" cap-after-step-5 invariant.
- session_map.rs: removed the dead 'Closing && tap.is_some()' branch
from drive_all_sessions. AppState::close already handles teardown
inline (fires close_tx, aborts task, clears field, advances
state, removes entry). The branch was never exercised and
pre-paved a "future peer-initiated close" path -- both AGENTS.md
anti-patterns ("don't pre-pave the wrong pattern").
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.3 step 4 + §5.1 step 3.
- pub struct TapHandle(()) — zero-sized marker (Option<TapHandle> compiles
to a bool, no allocation). The live tap connection lives in the binary's
DashMap<ChannelId, TapConn>, keyed by the channel's existing ChannelId
(== wire session_id per spec §5.3).
- Channel grows pub tap: Option<TapHandle>. None until Connected, set on
Connected, cleared before state advances to Closed.
- State invariant: tap and ChannelState are tied (Connected+None=bug).
- Slice-1 backwards compatible: new field is Option<...>;
Multi-tap-per-channel is the future trigger for a TapId newtype (YAGNI).
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.2, §6.
- run_tap_client: drives a connected WebSocketStream with a tokio::select!
over rx_pcm_in (inbound PCM → audio_in WS frame) and ws.next() (brain
frame → audio_out mpsc or control handling).
- Handshake: send hello, await brain hello (bounded 2s timeout).
- seq gap detection (log + count, never drop on gap — spec §3.1).
- Hot-path errors (encode/decode failures, send/recv failures) are logged
+ counted; TapClientError is returned only on graceful close, hello
timeout, or WS errors. The TapEngine (Task 7) decides reconnect policy.
- Pure-helper unit test (elapsed_ms); full pump behavior is exercised
against the in-process EchoServer in the Task 8 integration test.
Spec ref: 2026-06-28-slice-2-agent-tap-design.md §4.2.
- Add tokio-tungstenite 0.24, futures-util 0.3, url 2, base64 0.22 to
[workspace.dependencies] (spec §8.1).
- Add crates/rutster-tap-echo as 7th workspace member (spec §2).
- Transition crates/rutster-tap/Cargo.toml from stub to real manifest
with the deps Tasks 2-4 will need (rutster-media, tokio, tokio-tungstenite,
serde, serde_json, base64, url, thiserror, tracing).
- Skeleton lib.rs (compile test) + main.rs (placeholder fn main) for
rutster-tap-echo; Task 5 fills in the echo logic.
- Verify cargo deny check passes against the new transitive dep tree.
Spec ref: docs/superpowers/specs/2026-06-28-slice-2-agent-tap-design.md §2, §8.1.
Two Important findings from the final whole-branch review:
T4-M3: RtcSession::channel_id/channel_state/is_closed lacked /// docs
(AGENTS.md mandates /// on every public item; learner-facing code is a
primary product). One-line docs added per existing /// style in rtc_session.rs.
T7-M2: README "Status" section still read "Scoping. No code yet" — stale
post-slice-1. Updated to reflect slice-1 implementation status; minimal
edit, surrounding content preserved.
Four routes on axum 0.7 per spec §4.1: POST /v1/sessions (mint),
POST /v1/sessions/:id/offer (str0m-native SDP accept), DELETE
/v1/sessions/:id (close), GET / (static HTML client). Session store is
a DashMap<ChannelId, Arc<Mutex<RtcSession>>> (spec §4.5); one tokio
task drives all session poll loops — per-session tasks would pre-pave
the wrong pattern for step 4's dedicated thread. Graceful shutdown
drops the DashMap on Ctrl-C / SIGTERM. Integration test exercises the
REST surface; manual browser e2e per README §6.5.
RtcSession owns a str0m::Rtc + Opus decoder/encoder + EchoAudioPipe +
a bound UDP socket (spec §3.1, §4.5). accept_offer calls str0m 0.21's
sdp_api().accept_offer() natively — no hand-rolled SDP munger; str0m
fills DTLS fingerprint + ICE creds + Opus codec. The loop driver
drains poll_output per str0m's single-mutation invariant, routes
inbound MediaData through Opus decode + EchoAudioPipe sink, sends
Transmit packets on the UDP socket, and checks the 60 s idle timeout.
DEV-DEVIATION: loop runs on tokio (spec §3.4); step 4 replaces with
a dedicated timing thread per ARCHITECTURE.md.
str0m 0.21 API adjustments from the brief's sketch (verified against
src/str0m-0.21.0):
- SdpAnswer has no .mid() accessor; use answer.media_lines[0].mid()
via the Deref<Target=Sdp> impl.
- Input::Receive carries the Instant as the first tuple element
(Input::Receive(now, recv)); handle_input takes a single Input arg.
- Receive constructed via Receive::new(proto, src, dst, buf) (the
DatagramRecv field is private).
- UDP socket binds 127.0.0.1:0 (Candidate::host rejects the
unspecified address 0.0.0.0).
- BROWSER_SDP_OFFER fixture restored a=group:BUNDLE 0 (str0m's
Sdp::assert_consistency rejects SDP without a session group).
PcmFrame is the canonical tap format (16-bit mono @ 24 kHz, 480 samples
per 20 ms frame — ARCHITECTURE.md). AudioSource/AudioSink are the seam
step 2 splices the tap client into (spec §3.3); EchoAudioPipe is the
slice-1 wiring of that seam. OpusDecoder/OpusEncoder wrap the opus
crate's libopus FFI with hot-path match-and-continue (no ? on the 20 ms
loop, spec §3.8); decode/encode return Option<PcmFrame>/Option<Vec<u8>>
so a dropped frame is logged + counted, never propagated to crash the
peer.
rutster-call-model is real-but-minimal (spec §5): the unifying leg
object the future API exposes. ChannelId is a Uuid newtype for
type-safety (the slice-1 worked example of the newtype pattern).
Channel is signaling-state only — media lives in rutster-media as a
leaf concern of the Channel, surfaced only when a second consumer needs
to observe it (spec §5.3). ChannelState matches the New→Connecting→
Connected→Closing→Closed flow from §5.4.
Workspace root, pinned toolchain, and the three stub crates whose only
job in slice 1 is to lock the ADR-0002 boundary shape. Each ships a
lib.rs module doc (what it will hold, why deferred, which spearhead step
fills it) and a crate_compiles test. Spec §2.2.