diff --git a/.github/ISSUE_SPECS/1724-close-the-gate-bypass-and-browser-plain-seek-test-gaps.md b/.github/ISSUE_SPECS/1724-close-the-gate-bypass-and-browser-plain-seek-test-gaps.md new file mode 100644 index 000000000..362e9212c --- /dev/null +++ b/.github/ISSUE_SPECS/1724-close-the-gate-bypass-and-browser-plain-seek-test-gaps.md @@ -0,0 +1,93 @@ +# Close the gate bypass and browser plain-seek test gaps + +Owner-approved, relayed by root on 2026-10-18, from the test audit (mutation pilot, stage 1). +Filed by agent G. Test-only: no product code changes. Code anchors at `81a3074fc`. + +## Product outcome + +Two tests that the mutation pilot found weak now assert what their names claim: + +1. The gate/expander bypass test renders an input the gate acts on, so a bypass that does not + reach the kernel turns it red. +2. A host-web test checks the host's bookkeeping of the successor a plain session seek publishes + (root ruling B16-R9), which audio alone cannot show. + +## Context + +- Gap 1 (pilot rows s1o-m1, s1o-m2). `contract::bypass_is_exact_identity_at_all_rates` + (`crates/gate-expander/tests/contract.rs`) rendered 3 samples at default values. Preparation + seeds every lane open with the full hold (100 ms at defaults), so the gain stays 0 dB and the + output is dry through the `gain_db == 0` branch whatever the bypass word says. Mutants: the + kernel ignores the bypass word (`kernel.rs:255`, s1o-m1) and the prepared gate drops the bypass + flag (`lib.rs:473`, s1o-m2) left it green. s1o-m3 (bypass mutes) was already red. +- Gap 2 (pilot row s1e-m2). `hosts/host-web/src/lib.rs:1721`: the plain form of + `ReadyHost::session_seek` runs through `run_control`. A mutant that sends it straight to the + control plane leaves the successor with no `PendingShape`, does not count it in + `blocking_rebuilds`, skips the table-room check and does not name the successor's meter header. + The first command submission before the swap then fails the host + (`web.internal.pending_shape`). Audio and latency are unchanged, so no host-web test saw it + (`control_parity::a_plain_session_seek_declares_and_an_anchored_one_does_not_alike_on_both_hosts` + checks result verdicts, bits and latency, not the host's table bookkeeping). + +## Decisions + +- D1. Gap 1 rewrites the old test in place (it supersedes it): the active values (`T = -20`, + ratio 20, range 48 dB, hold 0 ms) over a 4,800-frame -60 dBFS plateau with a signed zero at + frame 0, both channels. It asserts the unbypassed render is not dry (no vacuous pass) and the + bypassed render is bit-exact dry, at 44.1, 48, 88.2 and 96 kHz. +- D2. Gap 2 adds `tests::structural_apply::a_plain_session_seek_keeps_and_counts_its_successor_on_the_browser` + beside `a_declared_discontinuity_resets_the_floors_on_the_browser`, from the same #1285 floors. + It mirrors the C ABI's `runtime::session_seek_declares::an_accepted_plain_session_seek_publishes_one_successor_and_applies` + (`crates/capi/src/runtime/tests.rs`): both the swapped and the still-waiting removal. + Assertions: one successor epoch; `blocking_rebuilds` up by one; exactly one unadopted + `PendingShape`, at the successor's epoch (no superseded candidate's left); the rendering plan + names that entry's meter header; `submit_commands(0)` is `RESULT_OK` and the host stays ready; + the next block adopts the successor. +- D3. The table-room backpressure branch is not tested. `PENDING_SHAPES` is 4, `run_control` + reclaims adopted entries first and drops superseded ones, and the core holds at most one + waiting candidate, so no public call sequence fills the table before a plain seek. The branch + is defensive; no test is added for it. +- D4. host-core has no host-side table bookkeeping to mirror. The C ABI has the equivalent + publication test (D2); it keeps no tables, so it needs nothing more. + +## Authorized paths + +- `crates/gate-expander/tests/contract.rs` +- `hosts/host-web/src/tests.rs` +- this spec + +## Objective gates + +- `cargo fmt --all --check` +- `cargo clippy --locked -p gate-expander --all-targets --all-features -- -D warnings`, and the same + for `-p host-web` +- `cargo test --locked --all-features -p gate-expander`, and the same for `-p host-web` +- `scripts/check-workspace-policy.sh` + +## Evidence (agent G, 2026-10-18) + +Mutants are the pilot's exact old/new texts, applied one at a time with a revert-on-exit driver; +the tree was clean after each. + +| Test | Branch | s1o-m1 | s1o-m2 | s1o-m3 | s1e-m2 | +|---|---|---|---|---|---| +| gate-expander `contract::bypass_is_exact_identity_at_all_rates` | green | red | red | red | - | +| host-web `tests::structural_apply::a_plain_session_seek_keeps_and_counts_its_successor_on_the_browser` | green | - | - | - | red | + +- Gap 1: all three mutants fail at the bypassed-left bit comparison. +- Gap 2: the mutant fails first at the `blocking_rebuilds` assertion. With that assertion and the + table checks removed in a scratch probe, the mutant fails at the `submit_commands(0)` + assertion, so the submission check is red on its own. + +## Test value + +- Gap 1: red if bypass does not reach the gate kernel (the kernel ignores the bypass word, or + preparation drops the flag) while the gate is acting, which no other gate-expander test caught + in the pilot (only rack's `bypass_shunt_identity` tests caught the dropped flag). +- Gap 2: red if the browser's plain session seek publishes its successor without the host's plan + bookkeeping, so the successor is uncounted, unnamed and has no tables and the next submission + before its swap fails the host, which no host-web test catches because the audio is unchanged. + +## Dependencies + +None. diff --git a/crates/gate-expander/tests/contract.rs b/crates/gate-expander/tests/contract.rs index 4b7873919..eb5d17ca8 100644 --- a/crates/gate-expander/tests/contract.rs +++ b/crates/gate-expander/tests/contract.rs @@ -217,19 +217,63 @@ fn missing_and_misordered_initial_records_return_exact_errors() { assert_eq!(error.code, "effect.parameter.initial"); } +/// Bypass is exact dry identity on an input that the gate acts on. +/// +/// At the active values (`T = -20`, `rho = 20`, range 48 dB, hold 0 ms) a -60 dBFS plateau that +/// lasts 4,800 frames closes the gate onto the range floor, so the unbypassed output is not dry. +/// The same input, bypassed, must come back bit-exact dry (signed zero included) at every launch +/// rate. The unbypassed render is asserted not dry, so the bypass check cannot pass vacuously: +/// at default values the prepared lane is open with the full hold and every sample is dry +/// whatever the bypass word says. +/// +/// Red mutations: the kernel ignores the bypass word (`kernel.rs`, the identity mask), or the +/// prepared gate drops the bypass flag (`lib.rs`, `bypass: L::splat(0.0)`). #[test] fn bypass_is_exact_identity_at_all_rates() { + const FRAMES: usize = 4_800; + const QUIET: f32 = 1.0 / 1_024.0; + let mut left_input: Vec = (0..FRAMES) + .map(|frame| if (frame / 8) % 2 == 0 { QUIET } else { -QUIET }) + .collect(); + left_input[0] = f32::from_bits(0x8000_0000); + let mut right_input: Vec = left_input.iter().map(|value| -value).collect(); + right_input[0] = f32::from_bits(0x8000_0000); + let bits = |samples: &[f32]| { + samples + .iter() + .map(|value| value.to_bits()) + .collect::>() + }; for rate in [44_100, 48_000, 88_200, 96_000] { - let values = initial_values(); + let values = support::active_values(); + + let mut effect = prepare(request_at_rate(&values, rate)); + let mut left = left_input.clone(); + let mut right = right_input.clone(); + render_scalar(&mut effect, &mut left, &mut right, 128); + assert_ne!( + bits(&left), + bits(&left_input), + "unbypassed left must act at {rate} Hz" + ); + assert_ne!( + bits(&right), + bits(&right_input), + "unbypassed right must act at {rate} Hz" + ); + let mut request = request_at_rate(&values, rate); request.bypass = true; let mut effect = GateExpanderFactory.prepare(request).expect("prepare"); - let mut left = [f32::from_bits(0x8000_0000), 0.25, -0.5]; - let mut right = [-0.0, -0.25, 0.5]; - let expected = (left, right); - render_scalar(&mut effect, &mut left, &mut right, 3); - assert_eq!(left, expected.0); - assert_eq!(right, expected.1); + let mut left = left_input.clone(); + let mut right = right_input.clone(); + render_scalar(&mut effect, &mut left, &mut right, 128); + assert_eq!(bits(&left), bits(&left_input), "bypassed left at {rate} Hz"); + assert_eq!( + bits(&right), + bits(&right_input), + "bypassed right at {rate} Hz" + ); assert_eq!(effect.metadata.latency, LatencySamples(0)); } } diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 4f235b294..f721fbf1a 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -7602,6 +7602,137 @@ mod structural_apply { assert_eq!(rig.host.service(None), RESULT_OK); } + /// Root ruling B16-R9's bookkeeping on the browser: a plain session seek declares the + /// discontinuity in the same call, so the host keeps and counts the successor it publishes as + /// it keeps a declaration's. From #1285's floors (a muted track behind a true-peak limiter + /// removed), swapped or still waiting for its block, the plain seek publishes one successor; + /// the host holds that plan's tables for its adoption block, counts one blocking rebuild, + /// names the successor's meter header from the rendering plan, and admits a command + /// submission against it before the swap. The next block adopts it and the host stays ready. + /// Mirrors the C ABI's `an_accepted_plain_session_seek_publishes_one_successor_and_applies`. + /// + /// Test value: red if the browser's plain seek reaches the control plane without the host's + /// plan bookkeeping (`run_control`): the audio and the latency are the same, but the + /// successor has no tables, is not counted and is not named, and the first submission before + /// its swap fails the host (`web.internal.pending_shape`). + #[test] + fn a_plain_session_seek_keeps_and_counts_its_successor_on_the_browser() { + for swap in [true, false] { + let mut model = gate_model(); + let limited = &mut model.tracks[0]; + limited.fader.left_mute = true; + limited.fader.right_mute = true; + limited.inserts.effects.push(session::Effect { + id: StableId::parse("lim").expect("insert ID"), + identity: session::EffectIdentity::Native { + effect_id: StableId::parse("miso.true-peak-limiter").expect("limiter id"), + }, + quality: session::EffectQuality::Normal, + bypass: false, + link_mode: session::LinkMode::Maximum, + params: Vec::new(), + sidechain: session::SidechainDeclaration::None, + }); + let edits = [ + SessionEdit::RemoveRoute { + route_id: model.routes[0].id.clone(), + }, + SessionEdit::RemoveTrack { + track_id: model.tracks[0].id.clone(), + }, + ]; + let mut rig = Rig::boot(&model, options(), None); + for _ in 0..APPLY_BEFORE { + rig.step(); + } + assert!(matches!( + rig.apply(&edits, 1), + Outcome::Applied { + path: EditPath::Rebuild, + .. + } + )); + if swap { + for _ in 0..4 { + rig.step(); + } + assert!(!rig.pending(), "swap {swap}: the removal was adopted"); + } else { + assert!( + rig.pending(), + "swap {swap}: the removal waits for its block" + ); + } + let counted = rig.host.blocking_rebuilds(); + let newest = |rig: &Rig| { + rig.host + .ready + .as_ref() + .expect("ready") + .control + .newest_plan_epoch() + }; + let before = newest(&rig); + + const FRAME: u64 = 1_280; + assert_eq!(rig.host.session_seek(None, 2, FRAME, None), RESULT_OK); + let successor = newest(&rig); + assert_eq!(successor, before + 1, "swap {swap}: one successor"); + assert_eq!( + rig.host.blocking_rebuilds(), + counted + 1, + "swap {swap}: the successor is counted" + ); + { + let ready = rig.host.ready.as_ref().expect("ready"); + let waiting: Vec<_> = ready + .pending_shapes + .iter() + .filter(|entry| !entry.adopted) + .collect(); + assert_eq!( + waiting.len(), + 1, + "swap {swap}: the successor's tables, and no superseded candidate's" + ); + assert_eq!(waiting[0].epoch, successor, "swap {swap}"); + assert_eq!( + rig.host.meter_successor_address(), + core::ptr::from_ref(&waiting[0].shape.meter_header).addr(), + "swap {swap}: the rendering plan names the successor's header" + ); + } + assert_eq!( + rig.host.submit_commands(0), + RESULT_OK, + "swap {swap}: a submission before the swap is admitted on the successor" + ); + assert_eq!(rig.host.status().state, STATE_READY, "swap {swap}"); + + let quantum = u64::from(QUANTUM); + let lane = |lane: u64| { + (FRAME..FRAME + quantum) + .map(|frame| strip_sample(0, lane, frame)) + .collect::>() + }; + let planes = [lane(0), lane(1)]; + let views: [&[f32]; 2] = [&planes[0], &planes[1]]; + assert_eq!( + rig.host + .submit_source(SOURCE, 2, FRAME, 48_000, &views, QUANTUM, false), + RESULT_OK + ); + assert_eq!(rig.host.render_next(), RESULT_OK, "swap {swap}"); + assert_eq!( + rig.epoch(), + successor, + "swap {swap}: the next block adopts it" + ); + assert_eq!(rig.host.status().state, STATE_READY, "swap {swap}"); + assert_eq!(rig.host.service(None), RESULT_OK); + } + } + /// #1310 on the browser path, which runs the same control plane: a structural edit while a /// candidate waits unadopted commits and supersedes it. The first candidate never renders; the /// replacement carries both transactions and is adopted at the next block.