diff --git a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md deleted file mode 100644 index 8b13821b4..000000000 --- a/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md +++ /dev/null @@ -1,835 +0,0 @@ -# Render stored mute automation on the strip - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A1.5, A2 and A10, under decision 15 D15-16 -(`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as #1652 -*Follow an automated mute on following sends in render* and #1653 *Write a following send's route -cell from the shared commit*: #1651 must not reach `main` without them**, or a send that follows an -automated mute leaks. - -## Product outcome - -A producer's stored mute automation (row 6) plays on every host. A strip lane mutes and unmutes at -the exact samples the session names, over the session mute ramp. Solo (the browser's host -overlay) and VCA mutes still compose with the automated mute. A mute automation edit is a carried -rebuild; a static mute edit on an automated lane, and a browser live mute command on it, commit as -`model_only` on both hosts through the one shared rule of #1648. Sends that follow the strip's -mute are #1652 and #1653. - -## Context - -- **The mute composition.** `user_mute || vca_mute || (any_solo && !solo_safe && !soloed)` is - written once, `LiveControlSoloState::effective_mute` (`crates/host-core/src/solo.rs:255-263`). - *Admit browser live edits in the Worker through the committed model* (#1382) D3 composes the solo - term in the shared commit from the browser's host overlay; the C ABI passes no overlay. *Deliver - value-only VCA edits to the running C ABI plan* (#1247) D2 emits strip `Mute` records from the - effective mute. -- **The mute stage.** `FaderRampStage::set_mute` retargets the lane's gain to 0 or to the - remembered `fader_gain` (`crates/builtins/src/lib.rs:2773-2794`); the kernel clears a muted lane - from the frame its ramp settles on (`crates/lane/src/kernels/builtins.rs:159-202`). #1638 D1 - adds `SetMute { muted, samples }` at an offset and `RememberGain`. -- **The cell.** *Hold live values in latest-target cells on both hosts* (#1312) D3 gives each mute - lane a cell of two words (value bits, ramp). -- **Seam.** The fader and mute stage is seam-side (`crates/builtins-compiler/src/lib.rs:758-771`), so - mute automation never declines a mono collapse. -- **The classifier row list and the predicate** are #1648 D1 and D3. - -## Decisions frozen for this slice - -- **D1. Mute cells (id 6).** Only `step` with values 0 or 1 (#1645 D1). A step curve is constant - between its discontinuities, so a mute cell has no grid events, only jumps. - - The stage mute of a strip lane is `curve || terms`, where `terms` is `vca_mute || solo_mute`. - - **The terms word.** For an automated mute lane, this slice gives #1312's mute cell value word - the meaning `terms` (the mute terms the session does not automate); for every other lane the - word keeps #1312's meaning. Preparation seeds it. #1312 is not amended: this slice adds the - meaning for a lane kind that exists only from this slice on. - - At each discontinuity at render sample `r`, and at the first sample of the block after the mute - cell changes, render composes the stage mute. Only a change emits #1638's `SetMute` at that - offset, over the session mute length (#1639's `mute` word) for a curve event and over the - cell's ramp word for a cell change. The caller reports the mute ramp to the cell's fader events - through #1637's `hold_until`. - - At a session seek where the seek reaches the fader node, the stage mute is set exactly (`n = 0`). - - While a lane's mute ramp is in flight or the lane is muted, fader events only remember the gain - (#1638 D2). -- **D2. Edits, one rule on both hosts.** - - The classifier's row list (#1648 D1) gains row 6: a mute automation edit is a carried - rebuild (#1648 D2 carries mute cells by the same address scheme). A static mute edit on an - automated lane gives no record (#1648 D3); a browser live mute edit on it reaches that rule - through the Worker's apply (#1382) and replies `model_only`. This turns red #1648's gate 1 - case "A mute (row 6) entry change gives `Ok` with no records" - (`crates/host-core/tests/live_delta.rs`); this slice rewrites that case in place to expect - `Err(LiveRebuild::Automation)` (AGENTS.md: a change that supersedes a test rewrites it in the - same PR), and gate 4 below holds the new rule. - - For an automated mute lane, the shared commit's mute composition (#1247's VCA mute, #1382 D3's - solo overlay) writes the strip mute cell's `terms` instead of emitting a `Mute` record, and only - when the terms' bits change. On the C ABI the overlay is absent, so `terms` is the VCA mute; - the code is the same on both hosts. -- **D3. The acked-batch question: can an ack ever precede a drop? No.** Every check of a - transaction (and of #1382's overlay) precedes the first cell write; writes cannot fail; render - generates curve events from the plan. - -## Deliverables - -1. D1: mute events on the fader stage (both bank forms and the fused form), and the terms word's - seed. -2. D2: the classifier row and the shared commit's terms write. - -## Authorized paths - -- `crates/builtins-compiler/src/lib.rs` (the fader stage's mute events and the terms read), - `crates/builtins/src/lib.rs` (only if #1638's mute operation needs a change) -- `crates/host-core/src/{prepare.rs,live_delta.rs,solo.rs}`, `crates/host-core/tests/live_delta.rs` -- `crates/control-plane/src/` (the overlay composition's terms write) -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs`, - `crates/control-plane/tests/mute_automation_realtime.rs` (new) -- `crates/control-plane/Cargo.toml` (a `bench-support` dev-dependency for that binary, only if an - earlier slice has not added it), `Cargo.lock` - -## Non-goals - -- Sends that follow the mute, route lanes and the route op (#1652 and #1653). -- Options B, C and D of OQ1. - -## Hazards - -- **One push with #1652 and #1653.** Alone, this slice renders an automated mute while a following - send keeps its prepared gate; the send leaks. Root merges the three together. -- **A redundant retarget moves bits** (README "Change only"; `crates/host-core/src/solo.rs:58-67`). - D1 and D2 compare before they write or retarget. - -## Objective gates - -1. **Exact samples** (both hosts: `crates/capi/src/runtime/live_tests.rs` and - `hosts/host-web/src/tests.rs`, new). Track `t`, with no following send, has a mute entry on - `both` with steps 0, 1, 0, at samples that are not grid samples. Each mute ramp starts at its step - sample; from the end of each ramp the output is bit-identical to a plan prepared with the same - static mute, fed the same PCM. At quanta 128 and 100. -2. **Mute and fader compose** (`crates/builtins-compiler` test, new). A fader ride and a mute step on - one lane: fader events inside the mute only remember the gain; after the unmute ramp the lane is - on the ride, bit-identical to the stage driven directly with #1638's operations. -3. **Composition** (browser, new). Solo on another track mutes `t` by solo while its curve is 0; - `t` mutes; unsolo restores the curve's state. A VCA mute of `t` composes the same way, on both - hosts. -4. **Edits** (`crates/host-core/tests/live_delta.rs`, new). A mute entry change gives - `Err(LiveRebuild::Automation)`; a static mute change on an automated lane gives no record; on the - other, non-automated lane it gives its `Mute` record; a VCA mute of `t` gives one terms write and - no `Mute` record for the automated lane. Browser: a live mute edit through the Worker's apply on the automated lane replies - `model_only` and moves no bit. -5. **Realtime.** `crates/control-plane/tests/mute_automation_realtime.rs` (new integration binary in control-plane, the crate that - holds the shared commit, `control_plane::SessionState`, #1309 D1-D9. A host-core binary cannot - reach the commit without a dev-dependency cycle, since control-plane depends on host-core. - `scripts/check-bench-policy.sh:257-280` allows a `bench-support` dev-dependency only in a - `crates/` manifest, so no host-web binary can link it. The binary links `bench_support::alloc` - and calls `assert_installed()` first). It drives the script through control-plane's shared - commit and host-core's render session, the code the browser Worker (#1382) and the C ABI both - run. For gate 1's script: - `allocations == 0 && frees == 0` around every render call after warm-up. - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` reports all - violation counts 0. -6. **Commands:** - - `cargo test --locked -p host-core --features host-core/test-support`, - `cargo test --locked -p builtins-compiler --features test-support`, `cargo test --locked -p capi`, - `cargo test --locked -p host-web --features host-web/test-support`, - `cargo test --locked -p control-plane --features test-support` - - the workspace debug leg (`test-debug-a`) and the DSP leg (`test-debug-b`) in - `.github/workflows/qualification.yml` - - `cargo build --locked --release -p audit && bash scripts/trace-builtins-audit.sh target/release/audit && bash scripts/trace-builtins-graph-audit.sh target/release/audit` - - `bash scripts/check-web-audioworklet.sh`, `bash scripts/test-web-audioworklet.sh` - - `bash scripts/check-realtime-policy.sh`, `bash scripts/check-host-core-policy.sh`, - `bash scripts/check-workspace-policy.sh`, `bash scripts/check-cross-targets.sh` (README F19, the iOS memset rule), - `bash scripts/run-aarch64-tests.sh debug` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` -7. **No rendered bit moves** for a session with no stored automation: `audit capi` gives the same - `pcm_digest` at base and head (PR evidence); the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if a mute step snaps to the grid, uses another row's length, or ramps where the static - plan does not. -- Gate 2: red if a fader event restarts the mute ramp, which `set_fader_gain` does today. -- Gate 3: red if solo or a VCA mute overwrites the curve instead of composing with it. -- Gate 4: red if the mute row stays masked, a static edit or a live command fights the curve, or a - VCA mute emits a record the next curve event would contradict. -- Gate 5: red if the mute events or the cell read allocate on render. - -## Dependencies - -Batch R2. Direct dependencies: - -- #1648 *Classify fader automation edits as carried rebuilds* (the row list, the predicate and - the carry; it brings #1646 and, through it, #1382 and #1247). -- #1639 *Hold the automation jump lengths in a plan cell* (the mute ramp length is its `mute` - word). - -#1638's `SetMute` and the cells of *Hold live values in latest-target cells on both hosts* -(#1312) arrive through #1648. #1652 and #1653 depend on this draft and land in the same push. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **OQ1 = C: a hand mute ORs.** The stage mute of an automated lane is `curve || terms`, and - `terms` now includes the lane's own static mute: `static_mute || any reaching VCA mute || solo - term`, the effective mute of `controls::effective` (C7) without the curve. So a `SET_CONTROL mute` - on an automated lane is a live `terms` write, path `live`, not `model_only`. D2's "A static mute - edit on an automated lane gives no record (#1648 D3)" and the browser sentence are replaced by - this rule; gate 4 tests a hand mute that closes an open curve, and a hand unmute that leaves a - closed curve closed. When the entry is removed, the lane's mute is the static mute, as before. -- **Solo is on both hosts** (C8, #1559): "On the C ABI the overlay is absent" is no longer true; the - code is the same on both hosts. -- **Moved files.** `crates/host-core/src/solo.rs` is deleted; the composition is - `controls::effective` (`crates/controls/src/effective.rs`). Authorized paths change to - `crates/controls/src/effective.rs` (read) and `crates/host-core/src/live_delta.rs`. -- **The jump length** is `LiveRamps::for_row(Mute)` (#1637, amended). - -## Amendment (controls coordinator, batch 10, 2026-10-14) - -- **Automated VCA mutes** (root's F20 ruling) compose with this slice's `curve || terms` in - #1655: the stage mute of a member lane gains `|| vca_curve(τ)` for each reaching VCA whose mute is - automated. This slice's `terms` word keeps the static VCA mutes. - -## Amendment (controls coordinator, batch 13b, root ruling) - -- **A `SetMute` that changes nothing does nothing** (constraint). Today #1638's `SetMute { muted, - samples }` reaches `mute_channel` (`crates/builtins/src/lib.rs`, through `apply_operation`; the - live `set_mute` calls the same function), which always writes `muted`, retargets the lane to `0.0` - (muted) or to the remembered `fader_gain` (unmuted) over `samples`, and sets `mute_ramp` when - `samples` is not 0, whether or not the lane's mute state changed. So a `SetMute` that repeats the - lane's state restarts a fader ramp in flight over the mute length (its shape changes), and its - `mute_ramp` makes `gain_held` true for that window, so the fader events inside it are only - remembered. The rule: a `SetMute` whose `muted` equals the lane's and channel's current mute state - leaves the stage as it was (no retarget, `mute_ramp` and the ramp's remaining frames unchanged). - D1's "only a change emits" still holds; this rule makes the stage itself safe against a repeated - `SetMute`, whatever composed it. The change - is in `crates/builtins/src/lib.rs`, which this slice's authorized paths already allow; its test - goes in `crates/builtins/tests/fader_ramp.rs` (authorized). -- **Gate 2, new case.** On the stage driven directly with #1638's operations: (a) an unmuted lane - with a fader `RetargetGain` in flight takes `SetMute { muted: false, samples: 240 }` at an offset - inside the ramp, then a `RetargetGain` 10 samples later; (b) a lane in a mute ramp toward `0.0` - takes `SetMute { muted: true, samples: 240 }` inside it. Each block is bit-identical to a twin - that never received the repeated `SetMute`, and in (a) `gain_held` stays false. Red if a repeated - `SetMute` retargets the lane or starts a mute ramp. Gate 7 holds that the change moves no bit of a - session with no stored automation. - -## Amendment (controls coordinator, batch 18 spec check, 2026-10-17) - -Checked against `ad03780ee`. #1637, #1638, #1639, #1312, #1648 and #1650 have landed (batch 17, -PR #1712). - -- **Anchors.** The mute composition is `controls::effective_strip` - (`crates/controls/src/effective.rs:326-366`); `EffectiveStrip::mute` (`:198`) is - `own || vca_mute || solo_term`. The stage: `set_mute` and `mute_channel` - (`crates/builtins/src/lib.rs:3129-3155`), `apply_operation` (`:3167-3178`), `FaderOperation` - (`:4947-4958`); kernels `crates/lane/src/kernels/builtins.rs:187` and `:273`. The cells: - `FaderLane` and `FaderCells` (`crates/builtins-compiler/src/lib.rs:255-427`); the drain - `drain_fader_controls` (`:3940`). Seam: `FaderBankProcessor::seam_side` (`:3433`). Classifier: - `controls::automation_row_renders` (`crates/controls/src/automation.rs:383-387`), - `rendered_automation_changed` (`:472-489`), step 3 (`crates/host-core/src/live_delta.rs:415-418`). -- **Not landed.** The batch 13b rule has not landed: `mute_channel` still writes and retargets on - every call. It stays this slice's deliverable. -- **D1 on batch 17's code.** - - Mute cells join the plan's one program (`prepare_fader_automation`, `FaderAutomationPlan`), - after the fader cells, so a fader-only session builds the same program. A new - `controls::MUTE_CELL_RULE` sits beside `FADER_CELL_RULE`: completion `End`, - `JumpLength::Block` (the caller resolves `LiveRamps::for_row(Mute)` from the block's jump - lengths, as `block_fader_jump` does), seek `Exact`. - - The lane's slot (`LaneFaderAutomation`) gains the mute channels: cell index, `CellState`, - `terms`, ramp word, adoption. Same `a(n)` (R17-2), same span from `set_block_time` (R17-3). - - The prepared stage mute of an automated lane is `first start_value != 0 || terms` - (`prepared_fader`, `:6777`). Preparation seeds `terms` with `EffectiveStrip::mute`. - - **The terms word.** Under OQ1 = C, `terms` equals `EffectiveStrip::mute`. So the `Mute` record - the shared commit already emits (only on change, with its ramp) is the terms write, and the - cell's value word carries it unchanged. No new record, no new cell, no edit under - `crates/engine/src/realtime/`. The drain (the bank's and the scalar oracle's `drain_controls`) - gives a mute value of an automated channel to the slot: the slot stores `terms` and the ramp - word, composes `target || terms` with the cell's current target (the curve state the stage - has heard), and applies `SetMute` at the block boundary over the ramp word only when it - differs (the `read_offsets` pattern). The block's curve events then compose with the new - `terms`. - - Curve events go through the one `FaderEventDriver` (one `FaderOperationSource` per block; the - sink takes one gain and one mute operation per lane, channel and offset, gain first). A mute - cursor pushes `SetMute` over the block's mute jump length only when `curve || terms` changes. - - **Fix.** "The caller reports the mute ramp ... through #1637's `hold_until`" cannot hold: - `CellEvents` borrows the state (#1647 constraint 2) and D1.2 is deleted (B17-R11). Instead, at - each mute event at offset `o`, the driver ends that channel's fader piece at `o` and starts the - next piece at `o + 1` with the hold the `SetMute` gives (muted: held to the block's end; an - unmute over `M`: `E = o + M`), through the shipped D1.6 obligation and cut at `E`. A gain - operation at `o` keeps the old hold (gain before mute). Bounded by the block's mute events; no - buffer. - - **Seeks.** A seek `Set` composes like any event. With the 13b rule, a `Set` that does not - change the state leaves the stage as it is, so a ramp toward that state finishes (as #1647 - constraint 5 for a held fader). Root confirms this reading (report ruling 1). - - **Scalar oracle (R17-4).** Same slot and driver. A strip with mute automation and no fader - automation binds `AutomatedFaderProcessor` (`:7497`) too. - - **CanonicalFpEnv (R17-7).** The mute path has no float arithmetic (values 0 and 1, then - booleans), and preparation already runs inside the guard. No new guard. - - **For #1652.** This slice makes the program `Arc` and each strip's mute cell indices readable - by host-core preparation, so #1652's route lanes read the same table. -- **D2 on batch 17's code.** - - Row 6 joins `automation_row_renders`; `rendered_automation_changed` then makes a mute entry - change `Err(LiveRebuild::Automation)`. - - **Fix.** The fader-only readers of that predicate test the fader row: `fader_automation` and - `automated_cell` (`automation.rs:393-448`), `model_fader_offsets` (`effective.rs:488`), - `mask_fader_offsets` (`mask.rs:137`) and B17-R14's `automates` - (`crates/control-plane/src/control.rs:2613-2616`). Without it, a mute entry gives its lane a - hand offset (`S`, `fader_offset`), a fader edit becomes an offsets write, and a mute-only - rebuild is flagged `TransitionFallback`. - - **The second bullet** is replaced by the terms word above: a hand mute (OQ1 = C), a VCA mute - and solo give the lane its `Mute` record, as today; render ORs it with the curve. The commit - code does not change. - - **Carry (#1648 D2).** `continued_cells` bits 2 and 3 are the mute lanes (a new - `controls::continued_mute_lanes`, set in host-core's `StripJoin`, `prepare.rs:5338`); graph - passes the `u8` unchanged (a doc edit only). `LaneFaderAutomation::adopt` copies a mute - channel's state, `terms` and ramp by address; a changed table or a moved `a(n)` repositions; - the first block emits `SetMute` over the mute jump length only on a change. A lost mute cell: - the join emits a `Mute` record of `next`'s effective mute (as `join_level_records`, `:4660`, - releases a fader lane); the 13b rule makes it a no-op when the stage agrees. - - **TransitionFallback and watermark.** Nothing new: a mute adoption never stretches a live - move, so `automates` stays fader-only. A terms write is a live record; a mute automation edit - is a rebuild whose jump lands at the adoption block. - - **Superseded tests, rewritten in place:** `crates/host-core/tests/live_delta.rs:1411-1451` - (#1648 gate 1's three mute asserts now expect `Err(LiveRebuild::Automation)`) and the mute - branch of `a_fader_automation_edit_rebuilds_alike_on_both_hosts` - (`hosts/host-web/tests/control_parity.rs:2846-2857`, `ModelOnly` becomes `Rebuild`). - - Root rules whether this slice sets the control listing's `automated` flag on an automated - mute row (#1650 D4; report ruling 2). Until then the flag stays fader-only. -- **Gates.** - - Gate 1: both hosts in one binary, `hosts/host-web/tests/control_parity.rs` (batch 17's form), - at quanta 128, 100 and 37, plus a session seek into a muted span (muted exactly from the seek - sample). Widths: the same script in `crates/host-core/tests/stored_mute_automation.rs` (new) - at `Simd8` (with `avx2`) and `Simd4`, split and fused, live and between render calls, and the - scalar oracle: the same bits. - - Gate 2 runs in `crates/host-core/tests/stored_mute_automation.rs`, against the stage driven - directly with #1638's operations, with a new case: fader events inside a curve mute, and the - move at `E` after a curve unmute, at quanta 128, 100, 64 and 37, the same bits. Red if the - fader piece is not cut at the mute event. The 13b case stays in - `crates/builtins/tests/fader_ramp.rs`. - - Gate 3 runs in `control_parity.rs`, both hosts. - - Gate 4: "one terms write and no `Mute` record" now reads "one `Mute` record (the terms - write)". New cases: a mute entry on `t` gives `t` no hand offset and keeps `S`; a `SET_CONTROL - fader_db` on `t`'s unautomated fader gives a `FaderDb` record, not an offsets write. Red if a - fader-only reader sees the mute entry. - - Gate 8 (new, carry; `stored_mute_automation.rs`): a changed curve, an added entry and a removed - entry each rebuild and carry `t`'s stage; the lane jumps at the adoption block only when - `curve || terms` changes; after removal it holds the static mute; the swap block allocates and - frees nothing. A mute-only session's rebuild with a live fader move in flight completes - `Exact`. Red if a mute cell restarts, misses or repeats the adoption jump, or a lost cell keeps - the curve's state. - - Gate 6: `scripts/check-realtime-policy.sh` is deleted; run `cargo build --locked --release -p - realtime-policy && ./target/release/realtime_policy`. Add `cargo test --locked -p controls` - and `cargo test --locked -p builtins`. `crates/control-plane/Cargo.toml` already has - `bench-support` (#1649). -- **Authorized paths (this list replaces the earlier ones).** Render paths are marked (R). None is - under `crates/engine/src/realtime/`. - - (R) `crates/builtins/src/lib.rs` (the 13b rule in `mute_channel` only), - `crates/builtins/tests/fader_ramp.rs` - - (R) `crates/builtins-compiler/src/lib.rs` (mute cells, slot, seed, drain branch, driver, scalar - oracle, adopt, export for #1652) - - `crates/controls/src/{automation.rs,effective.rs,mask.rs,lib.rs}`, - `crates/controls/tests/automation.rs` - - `crates/control-plane/src/control.rs` (B17-R14's `automates` only) - - `crates/host-core/src/prepare.rs` (the strip join only), `crates/host-core/src/live_delta.rs` - (docs only), `crates/graph/src/lib.rs` (`GraphStripMove::continued_cells` doc only) - - `crates/host-core/tests/{live_delta.rs,stored_mute_automation.rs}`, - `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, - `hosts/host-web/src/tests.rs`, `crates/control-plane/tests/mute_automation_realtime.rs` (new) - - `docs/SESSION_SCHEMA_V1.md` (`:290-305`, the mute row renders), `docs/REALTIME_MEMORY.md` - (`:13`) - - Removed: `crates/host-core/src/solo.rs` (deleted), `crates/control-plane/Cargo.toml`, - `Cargo.lock` (no new dependency). - -## Decision record: T1 (batch 18) - -- **Shipped.** The batch 13b rule: `FaderRampStage::mute_channel` (`crates/builtins/src/lib.rs`) - returns at once when `muted` equals the channel's current mute state. No retarget, and - `mute_ramp` and the ramp's remaining frames stay as they were. One compare on a `bool` array - element; no allocation, no new panic path. Every route reaches it: `apply_operation`'s `SetMute` - and the live `set_mute`, on the Simd8 and Simd4 banks and the scalar track. -- **Gate (13b Gate 2, new case).** `crates/builtins/tests/fader_ramp.rs`, - `a_repeated_mute_leaves_the_stage_as_it_was`, on every subject (Simd4, Simd8 with `avx2`, - scalar), at quanta 128 and 100: (a) an unmuted lane with `RetargetGain` in flight takes - `SetMute { false, 240 }` at offset 20 and a `RetargetGain` at offset 30; (b) a lane in a 300-sample - mute ramp takes `SetMute { true, 240 }` at offset 20; (c) the live `set_mute` repeats the state - between blocks. Each block, both planes, is bit-identical to a twin without the repeat; the held - flag agrees, and in (a) `gain_held` stays false. Red on the base `mute_channel` (case (a), Simd4, - q=128, block 0: the repeat restarts the fader ramp over 240 samples); green with the change. -- **Superseded tests, rewritten in place** (each used a repeated zero-window unmute to end an - unmute ramp, the behaviour this rule removes): - - `crates/builtins/tests/fader_ramp.rs::the_held_flag_follows_the_mute_ramp`: mutes the lane - first, then unmutes over 40, so the unmute is a real change. - - `crates/host-core/tests/stored_fader_automation.rs`: - `a_live_write_that_frees_a_held_lane_releases_its_hold`, - `a_live_write_that_frees_a_lane_with_nothing_owed_releases_its_hold` and - `an_offsets_jump_at_the_entry_that_frees_the_lane_keeps_its_ramp` (ramping form) free the lane - with a live `FaderDb` write (`live_fader`, `Act::fader`) instead. The R17-10 release depends on - the stage's held state at the block entry, not on the record kind, so the gates keep their - claims. This edit is outside the spec's authorized list (a test file of batch 17's slice); it is - mechanical to the new rule. -- **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (base: the same), `allocations` 0. -- **Departures.** None from the rule's text. - -## Decision record: T2 (batch 18) - -- **Shipped (no rendered bit moves).** A new `controls::fader_row(target)` (strip, builtins, row - 5) is the fader-only predicate; `automation_row_renders` is today `fader_row(target)` and T3a - adds row 6 to it alone. The fader-only readers now test `fader_row`. -- **Reader classification** (every reader of `automation_row_renders` at `0e078aad8`): - - Fader-only, now `fader_row`: `fader_automation` (`automation.rs`; also feeds - `continued_fader_lanes`, `ModelView::fader_automation`, `OwnerEdits::take_fader_automation` - and builtins-compiler's per-strip cells), `model_fader_offsets` (`effective.rs`; feeds - `EffectiveStrip::fader_offset`/`offsets_sum` and `mask_values`' automated lanes), - `mask_fader_offsets` (`mask.rs`), and B17-R14's `automates` (`control-plane/src/control.rs`). - - Any rendered row, unchanged: `rendered_automation_changed` (a mute entry change must rebuild, - D2), and `automated_cell` (keyed by the address's own row: it already compares - `target.control == address.control`, so `automated_fader_lanes`, its one caller, asks at - `fader_db` and a mute entry never matches; at a mute address it will say where T3a's mute cell - exists, which is the doc's claim). - - Any rendered row, outside T2's files: `prepare_fader_automation`'s early return - (`builtins-compiler/src/lib.rs:2095`). It is right for T3a (the program then also builds mute - cells); until T3a it is the same predicate. -- **R18-4 (withdrawn by root at U1 fold 1).** There is no general claim that a mute adoption never - stretches a move: the U1 review found a hand or VCA mute committed in the same transaction as a - mute curve edit landing over the mute length instead of its own ramp. B17-R14's window stays - fader-only because a mute cell's adoption lands only a `SetMute`, never a level move over `J`, - and the U1 fold 1 fix makes that adoption land as committed: a terms record at the swap block - lands over its own ramp word, and the adoption landing adds only the curve's own change over the - mute length. So a successor that automates only mute rows completes `Exact`. -- **Gate.** `crates/controls/tests/automation.rs::a_mute_entry_reaches_no_fader_only_reader`: a - `both` mute entry before a `left` fader entry on `vocal`; the fader cells are the fader entry's - left lane only, `automated_fader_lanes == [true, false]`, the right lane has no hand offset and - the same `S` bits as the session without the mute entry, and `mask_values` masks the right - lane's static dB. Row 6 cannot render before T3a, so the test is green on the base too; its - predicate asserts (`fader_row` refuses a mute target, every fader target renders) hold the split - now. Probe (not committed): with row 6 added to `automation_row_renders`, the test stays green - with the split and turns red with `fader_automation` (the mute entry takes the left cell) or - `model_fader_offsets` (`[Some(0.0), Some(0.0)]`) reverted to the old predicate. - `mask_fader_offsets` reverted is not observable: a validated mute entry holds `+0.0` offsets, - so copying one moves nothing. The control-plane `automates` change has no T2 test (no mute row - renders yet); T3a's gate 8 (a mute-only rebuild with a fader move in flight completes `Exact`) - holds it. -- **Ran.** `cargo test --locked -p controls --all-features`, `-p control-plane --features - test-support`, `-p host-core --features host-core/test-support`, `-p capi`: all pass. fmt, - clippy (`controls`, `control-plane`, all targets and features) and the no-feature check pass. -- **Departures.** `automated_cell` is not changed (above). None else. - -## Decision record: T3a (batch 18) - -- **Shipped (row 6 renders).** `controls::automation_row_renders` is `fader_row || mute_row` (the - only reader that gains row 6; T2's fader-only readers are unchanged). New in `controls`: - `MUTE_CELL_RULE` (grid 64, `JumpLength::Block`, completion `End`, restartable, seek `Exact`; the - caller resolves `LiveRamps::for_row(Mute)`), `mute_row` and `mute_automation` (the - `fader_automation` rule on row 6). -- **Program.** `prepare_fader_automation` pushes every fader cell first (unchanged order), then - one mute cell per automated mute lane in strip and channel order. A fader-only session builds - the same program; a mute-only session builds mute cells only (the early return already reads - `automation_row_renders`). The prepared stage mute of an automated mute lane is - `first start_value != 0 || terms`, `terms` seeded with `EffectiveStrip::mute` - (`FaderAutomationPlan::mute`, read by `prepared_fader`). -- **Slot.** `LaneFaderAutomation` gains `mute: [Option; 2]` (cell index, - `CellState` seeded with the prepared curve value, `terms`) and `mute_jump`, both from - `set_block_time` (`block_jumps` replaces `block_fader_jump` and resolves both rows through - `LiveRamps`). A strip with mute automation and no fader automation gets a bank slot and, on the - scalar oracle, binds `AutomatedFaderProcessor` (R17-4); the bank resource charges its slots. -- **Drain branch.** `drain_fader_controls` (bank, block and carry drains) and the oracle's - `LiveControlFaderProcessor::drain_controls` give a mute value of an automated channel to - `LaneFaderAutomation::mute_terms` (shared helper `apply_mute_value`): it stores `terms`, composes - `target != 0 || terms` with the cell's current target and applies `SetMute` over the record's - ramp word only when that differs from the stage's mute. A non-automated channel of the same - record goes to the stage as before. The commit code does not change. -- **Driver.** `FaderEventDriver` holds one `MuteCellCursor` per automated mute channel beside the - fader cursors (same `a(n)`, same span, same first-block `Set` rule). At each offset the fader - cursors push first; then each mute event composes `curve || terms` and pushes `SetMute` only on - a change (jump: over the block's mute length; seek `Set`: `0`), into the sink's mute slot, so - the stage applies gain before mute. `finish` drains the mute cursors. A mute event in the block - makes the fused fader-matrix pair decline that block (its `next_offset` is in the block). -- **R18-1** holds through the 13b rule and the change-only compare: a seek `Set` that leaves - `curve || terms` equal to the stage's mute pushes nothing, so a ramp toward that state finishes. -- **Export for #1652.** `PreparedBuiltinsSession::strip_mute_cells(&self, track_id: &str) -> - Option<(Arc, [Option; 2])>`: the plan's one program (`Arc::ptr_eq` - with the fader owners') and each lane's mute cell index, `[left, right]`; `None` when the strip - automates no mute lane. Read it before lowering (`into_graph_artifact` takes the plan). -- **Docs.** `host-core/src/live_delta.rs` (step 3 and the `Mute` record paragraph), - `host-core/src/prepare.rs` (`LiveRebuild::Automation`), `docs/SESSION_SCHEMA_V1.md` (the mute - row renders), `docs/REALTIME_MEMORY.md`. -- **Superseded tests, rewritten in place.** `crates/host-core/tests/live_delta.rs` - (`a_fader_automation_edit_is_a_rebuild`: the three mute asserts expect - `Err(LiveRebuild::Automation)`) and `hosts/host-web/tests/control_parity.rs` - (`a_fader_automation_edit_rebuilds_alike_on_both_hosts`: the mute entry is `EditPath::Rebuild`). - `live_delta.rs` is gated on host-core's `control-provider` feature: run with - `--features host-core/test-support,host-core/control-provider` (70 pass). -- **Gates (all pass).** - - `crates/host-core/tests/stored_mute_automation.rs` (new; every form: Simd8 on AVX2, Simd4, - scalar oracle; each with no live control, live controls, and between render calls): - `a_mute_step_mutes_the_lane_at_its_exact_sample_over_the_mute_length` (steps 0, 1, 0 at 1,000 - and 2,500, quanta 128, 100 and 37: `SetMute` at exactly those samples over `M = 480`, output - bit for bit the stage driven directly; render allocates nothing); - `a_curve_that_starts_muted_prepares_the_lane_muted`; - `a_seek_that_leaves_the_mute_unchanged_lets_the_unmute_ramp_finish` (R18-1: bit-identical to - the no-seek twin; and a seek into the muted span sets the lane exactly, `n = 0`); - `a_hand_mute_on_an_automated_lane_composes_with_the_curve` (the drain branch, OQ1 = C); - `a_mute_entry_leaves_a_fader_ride_as_it_was`. - - `crates/builtins-compiler/src/lib.rs` - `tests::the_mute_cells_follow_the_fader_cells_in_the_one_program` (cell order, program bytes, - the export, a mute-only program). -- **Measured.** One-time PR evidence (not committed): a fader-only session (three tracks: a fade, - a step and fade, a step; live mutes, a seek; every form; quanta 128 and 100) renders the same - bits at base `e7848d05a` and head (FNV digest `a58534af7e8b0509` both). `audit capi` - `pcm_digest` `bffc21e857b5e134`, all violation counts 0 (`audit capi` reports no - `capi_retained` field). `fader_automation_sizes().lane_slot_bytes` 304 -> 392 (two mute - channels and the mute jump word); `program_overhead_bytes` 64 and `offsets_cell_bytes` 120 - unchanged. `realtime_policy`: ok, 161 marked regions (base 160: `apply_mute_value`). -- **Departures.** - - The slot stores no ramp word and no adoption for a mute channel: the drain uses the ramp word - at once and curve events use the block's mute length, so nothing reads them before T4's - carry; T4 adds what `adopt` needs. - - The defensive refusals of a mute entry use the code `host.automation.mute` (unreachable for a - validated session, as the fader's). - - `TestOnlyFaderEvent` gains `mute: bool` (the probe records each pushed `SetMute`). -- **What T3b must add.** A mute event inside a block renders correctly on a lane with no fader - automation (all gates above). On a lane with both, the in-block fader cut is missing: after an - in-block mute at `o` the fader cursor still pushes its later events as free (the stage only - remembers them, and no obligation is set), and after an in-block unmute over `M` the fader - events inside `[o, o + M)` are only remembered with no move owed at `E = o + M`, so the lane - can end the unmute ramp at a stale gain and the cell's committed target suppresses the - correction. T3b: at each pushed `SetMute` at `o` on a channel with a fader cursor, end that - channel's fader piece at `o`, start the next at `o + 1` with the hold the `SetMute` gives - (muted: held to the block's end; unmute over `M`: `E = o + M`), mark owed events below `E` - through the D1.6 obligation and cut at `E`; a gain operation at `o` keeps the old hold (gain - before mute). Gate 2 holds it. -- **For T4.** `LaneFaderAutomation::adopt` does not touch the mute channels yet (a carried lane - keeps its prepared mute seed and `terms`, is not fresh, and lands no `Set`); the carry drain - already routes an automated channel's mute value to the predecessor slot's `terms`, which - composes onto the predecessor's stage before export. - -## Decision record: T1 gate power check (root, batch 18) - -- Each batch 17 mutation was applied again at `0e078aad8`. The nothing-owed gate (P1b at q37) is red - under release-only-when-owed and under no-release. The F1 gate is red when the `held_through` reset is - deleted, and its rewritten ramping form is red alone. -- `a_live_write_that_frees_a_held_lane_releases_its_hold` was green under no-release in its T1 form and - in its pre-T1 form. It went void at the D1.2 removal (the drain gives the cell no hold), not at T1. - The fix puts an offsets write at 3,200 under an 8,000-sample unmute, so the cell holds and owes a move; - the lane is then freed at 6,400. The gate is now red under no-release and under - release-only-when-nothing-owed, and green on the code. -- No test catches a free write that keeps the owed flag (deleting `self.owed = false` stays green). - Root moved that gap to #1711 (batch 19). - -## Decision record: T3b (batch 18) - -- **Shipped (the in-block fader cut at mute events).** In the one `FaderEventDriver` - (`crates/builtins-compiler/src/lib.rs`), so the Simd8 and Simd4 banks, the fused fader-matrix - pair's fallback and the scalar oracle share it (R17-4). A fader channel's block is rendered in - pieces: each piece ends at the D1.6 cut `E`, at the offset of the same lane and channel's next - mute event, or at the block's last sample (`FaderCellCursor::piece_last`), and the next piece - starts at `+ 1` (`open`, `piece_span`). A piece is consumed to its end before the next starts, - so the cell's state advances only through events the stage was given (#1647 constraint 2). At - each offset: the fader cursors push first (a gain operation at `o` keeps the old hold, gain - before mute), then the mute cursors, then each fader channel whose piece ended at its mute - event takes the hold the pushed `SetMute` gives (`rehold`) and opens its next piece: muted -> - held to the block's end (`held_through`); unmuted over `M` -> `E = o + M`: inside the block a - cut (owed events below `E` move there through D1.6), at the block's end `due_at_entry`, past it - the next block finds the hold from the stage; unmuted exactly (`M = 0`) -> free, nothing owed - (the stage takes the remembered gain, the cell's committed target). A mute event that changes - nothing (not pushed) keeps the old hold. An older cut ahead is dropped by any pushed `SetMute`. - Mute cursors are now built before a lane's fader cursors, so each fader channel's first piece is - cut at its first mute event. `HoldEnd` loses its `rest` span (the next piece is built at the cut). -- **Departure from the handoff's text.** "Start the next piece at `o + 1`" is applied at every - mute event of the channel, pushed or not: the piece must end there because the driver pulls one - event ahead, and the hold is known only after the mute cursor composes `curve || terms`. A - non-pushed event splits the piece with the hold unchanged, which moves no event (the same split - the D1.6 cut makes). -- **Gates (all pass).** `crates/host-core/tests/stored_mute_automation.rs`, every form (Simd8 on - AVX2, Simd4, scalar oracle; no live control, live controls, between render calls), quanta 128, - 100, 64 and 37; the driver's pushed fader events and both planes are compared with a model of - the rule written apart from the driver (`compose_model`: the fader-only plan's events, each - remembered by the stage while held, the owed move at `E` over `J` to the curve at `E + J - 1`), - driven into the stage directly with #1638's operations. - - Gate 2: `a_fader_ride_under_a_curve_mute_is_remembered_and_moves_at_the_unmute_end` (grid - events inside a curve mute and inside its unmute ramp, the move at `E = 2,480`). Red if the - move at `E` is missing (mutation `moving = false`: first difference at 2,480). Green on the base driver: its `E` lies in a later block, which batch 17's block-entry rule already handles. - - Block boundaries, `M = 48` (and 96): `a_hold_that_starts_and_ends_inside_a_block_moves_at_its_end`: - `E` inside the unmute's block, `E` exactly at a block end, `E` in the next block, an unmute and - a re-mute in the same block (after `E`: two moves; inside the unmute ramp: the first `E` - dropped, the owed move kept through the mute), a mute at offset 0 with an unmute at `q - 1`, - a mute at `q - 1` with an unmute at 0, and fader steps exactly at the mute and the unmute - samples. Red checks (each a temporary mutation, then reverted): no rehold at the mute event - (base behaviour, first difference at 1,348); `E` off by one either way (bits differ at 1,348; - events at 1,349 / 1,347); owed events below `E` dropped (`free_from = o`: bits at 1,348); - `due_at_entry` not set at an `E` on the block's end (bits at 1,408); a re-mute that keeps the - old cut (the pushed events: a move at 1,396 under the mute). The base driver (`ce32c1b1f`) is - red at 1,348. - - Batch 17's gates stay green: `stored_fader_automation.rs` (30), `stored_mute_automation.rs`'s - T3a tests, capi `live_tests`, host-web parity. -- **Measured.** One-time PR evidence (not committed): a fader-only probe (three tracks: a fade, a - step and fade, two steps; live mutes with ramps and with `0`; a seek; every form; quanta 128, 100 - and 37) renders the same bits at base `ce32c1b1f` and head (FNV `7caf69e2bae5afcd` both). - `audit capi` `pcm_digest` `bffc21e857b5e134`, allocations 0. `realtime_policy`: ok, 161 marked regions (unchanged). `lane_slot_bytes` 392 unchanged (the slot is not - touched; the new fields live in the driver on the render stack). -- **Phase-2 placeholder.** A cell hold set by an offsets jump on a lane held by a mute ramp - (`AutomatedFaderCell::offsets_jump`: until the stage's ramp end at the block entry) is not - shortened when a curve unmute inside that block ends the hold earlier; grid events between the - new `E` and the old ramp end are pushed as held (`RememberGain` on a free stage) until the cell's - next free event. Needs a VCA write during a mute ramp and a curve unmute inside it. - -## Decision record: T5 (batch 18) - -- **Shipped (root ruling R18-2).** The control list marks a strip's builtin `mute` row - `automated` (`ControlEntry` field 35) on each lane a mute entry drives, on both hosts; the row's - value stays the lane's static (hand) mute. `controls::owner_rows_in` reads the driving entries - from the committed table (`controls::mute_automation`, as the fader reads its own) and passes - them with the fader offsets to `strip::push_rows` as one `strip::Driven`. No row class or table - changes, so the SDK is not regenerated. Docs: `docs/SESSION_SCHEMA_V1.md` (the mute row - paragraph) and `docs/CONTROL_PROTOCOL_REGISTRY.md` (field 35, outside the listed paths: one - clause, mechanical to the ruling). `docs/REALTIME_MEMORY.md` and `host-core/src/live_delta.rs` - were not stale (T3a). One stale doc line in `crates/host-core/tests/live_delta.rs` - (`fader_ride`: "which nothing renders") is fixed. -- **Gates (all pass).** - - Gates 1 and 3 and R18-2, both hosts: one script in two copies (kept equal; a diff of the two - script halves is empty), `crates/capi/src/runtime/mute_automation_tests.rs` (new, one `mod` - line in `runtime/mod.rs`) and `hosts/host-web/tests/stored_mute_automation.rs` (new). Each - host compares its own output with plans of the same host prepared with `t` statically - unmuted and muted, fed the same PCM (both of `t`'s lanes negative in every fourth stretch of - 300 frames, `u` at `-0.0` in others). - - Gate 1: `a_mute_curve_mutes_at_its_exact_samples` (steps at 1,000 and 2,500; every sample - before a step equals the static plan it leaves, the step sample and `step + M - 2` differ, - every sample from the first block boundary at or after `step + M` to the next step equals - the new state's static plan; quanta 128, 100, 37) and - `a_seek_into_a_muted_span_mutes_exactly_from_the_seek` (a plain session seek to 1,600 at the - fifth block: muted exactly from that block, the opening ramps 900 samples later; quanta 128, - 100, 37). - - Gate 3 (and gate 4's browser case): `solo_vca_and_hand_mutes_compose_with_the_curve` (solo - on `u`, a VCA mute and a hand mute of `t`, each set while the curve is open and released - while closed, released while open, or kept; solo replies `monitor`, the VCA and the hand - mute `live`, so a hand mute through the Worker's apply is `live` and the output follows - `curve || terms`). - - R18-2: `the_list_marks_a_mute_row_an_entry_drives_automated` (a `left` and a `both` entry; - `t`'s fader rows and `u`'s mute rows unmarked; a hand mute moves the row's value only). - - Gate 4: `crates/host-core/tests/live_delta.rs::a_mute_curve_takes_its_terms_as_mute_records` - (a `both` and a `left` curve: an entry change is `Err(LiveRebuild::Automation)`; a hand mute - on either lane, a VCA mute of one lane and of both are each one `Mute` record; a VCA fader - move and a static `fader_db` change give `FaderDb` records and no offsets write). - - Gate 5: `crates/control-plane/tests/mute_automation_realtime.rs::stored_mute_automation_renders_allocation_free` - (`eq0` of the nine-track fixture with the curve, through `SessionState::command` and - `PlanState::render`: both steps, a hand mute and a VCA mute set and released live, a plain - session seek to 1,600 (a declared successor) and the opening after it; 44 blocks, each - `allocations == 0 && frees == 0`; a twin without the curve differs inside each mute and equals - at the end). -- **Red checks (temporary mutations, each reverted; red on both hosts).** Terms overwrite the - curve in `LaneFaderAutomation::mute_terms` (gate 3, "released at 2560"); a curve event - overwrites the terms in the driver (gate 3, "kept", at 3,500); a seek `Set` ramps over 480 - (seek gate, at the seek block); the jump ramps over half the mute length (gate 1 and the seek - gate, at `step + M - 2`); the listing flag fader-only (R18-2 gate). An allocation in - `mute_terms` aborts gate 5's binary (the render-locked allocator). Gate 4 was not mutated: its - mutation lives in `crates/host-core/src/prepare.rs` (`fader_records`), which this tranche must - not touch. -- **Departures.** - - Gates 1 and 3 are not one binary that compares the two hosts bit for bit (the batch 18 - amendment's `control_parity.rs` form): a new host-web test file may not call the C ABI's - `unsafe` exports (`realtime_policy`'s unsafe allowlist), and the shared files were kept free - of this tranche. Each host instead holds the same claims against its own static plans, as - the filed gate text has it. - - Gate 1's widths are not repeated here: the host tests run the native backend; Simd8, Simd4 - and the scalar oracle are T3a's `crates/host-core/tests/stored_mute_automation.rs`. -- **Ran.** `cargo test --locked` on `-p controls --all-features` (72), `-p host-core --features - host-core/test-support,host-core/control-provider` (569), `-p capi` (229), `-p host-web - --features host-web/test-support` (211), `-p control-plane --features test-support` (26): all - pass. `check-host-core-policy.sh`, `check-workspace-policy.sh`, `realtime_policy` (161 marked - regions, unchanged), fmt, clippy (the five crates, all targets and features, `-D warnings`) - and the no-feature `cargo check --all-targets`: ok. - -## Decision record: T4 (batch 18) - -- **Shipped (the strip carry of mute automation).** - - `LaneFaderAutomation::adopt` (`crates/builtins-compiler/src/lib.rs`) adopts the mute - channels by address (strip, row 6, channel): a carried channel takes the predecessor cell's - `CellState` and `terms` (the terms its stage heard; the successor's own `Mute` records, the - join's and later live writes, move them, as they move a fader channel's `S`). It owes a - reposition (`AutomatedMuteCell::reposition`, new) when `continued_cells` bit `2 + channel` is - clear, `a(n)` moved, or the carried cell still owed one; a gained channel keeps its prepared - seed and `terms` and repositions. - - At the lane's next block with node time, `FaderEventDriver::new` repositions the cell at the - block's node time and opens the block with a ramped landing (`SeekMode::Ramp`, a step at - offset 0) over the block's mute jump length. The mute cursor's one compose rule pushes - `SetMute { curve || terms, M }` only when that differs from the stage's mute, so a continued - or an agreeing cell lands nothing (and the 13b rule makes any repeat a no-op). A session - seek in that block lands instead, exactly. T3b's fader cut takes the landing like any mute - event. - - `LaneFaderAutomation::mute_terms`: on a cell that owes a reposition (and has node time this - block) the drain only stores `terms`; the driver composes them with the curve at the swap - block, so the swap lands the one `SetMute` the two changes owe, not one from the prepared - seed and a second that reverses it. (Superseded by U1 fold 1: the drain composes the record - with the curve at the block's node time and applies it over its own ramp word.) - - `controls::continued_mute_lanes` (new, `crates/controls/src/automation.rs`; the fader and - the mute rule share `continued_lanes`). host-core's strip join sets bits 2 and 3 of - `continued_cells`; graph passes the byte unchanged (doc only). - - A lost mute cell: the join emits a `Mute` record of the lane's effective mute in `next` - (`EffectiveStrip::mute`: static, VCA and solo terms) over the lane's mute length, unless the - lane already has a `Mute` record. The 13b rule makes it a no-op where the stage agrees. -- **host-core `prepare.rs` functions touched (strip join only):** the `StripJoin` struct's - `continued_cells` doc, `join_strips` (bits 2 and 3), and `EffectiveDiff::join_level_records` - (the mute release beside the fader release). `resolve_route_carry` is not touched. -- **R18-4 for the strip (withdrawn by root at U1 fold 1; corrected text).** The landing is a - `SetMute`, which retargets only the lane's gain ramp toward `0` (or the remembered gain) of the - one lane and channel it addresses (dual-mono lanes have separate stages), and the stage keeps - `fader_gain` as the target the live move aimed for (#1638 D2, D1.6), so after the unmute the - lane is back at that gain. No level ramp is carried over `J`, so B17-R14's window stays - fader-only. The adoption lands as committed only with the U1 fold 1 fix: before it, the drain of - a repositioned cell dropped a same-transaction terms record's ramp word and the landing always - ramped over the mute length (see "Decision record: U1 fold 1"). Gate: below. -- **Gates (all pass)**, `crates/host-core/tests/stored_mute_automation.rs`, every bank width - (Simd8 on AVX2, Simd4) and the scalar oracle, each with and without live controls, quanta 128 - and 100, a swap at block 8, a signed never-zero source; each case compares, plane by plane, - the swapped run with a fresh plan of the new model fed the same PCM from the first block - boundary at or after `swap + M`, with the old model's plan before the swap, and holds the swap - block at 0 allocations, reallocations and frees: - - `a_rebuild_that_adds_a_mute_entry_mutes_from_the_swap_over_the_mute_length` (a gained cell - muted now: one `SetMute(true, 480)` per channel at the swap, the bank lane ramps; then a - gained cell whose curve agrees with a static mute the same rebuild opens: no jump). Red: - adopt without the mute channels (the lane stays open, first difference at 1,536); the - landing exact instead of ramped ("ramps at the swap"); the drain's `terms` composed with the - prepared seed (two jumps at the swap). - - `a_rebuild_that_removes_a_mute_entry_holds_the_static_mute`. Red: the join without the mute - release (the lane stays muted, first difference at 1,536). - - `a_rebuild_that_changes_a_mute_curve_jumps_only_when_the_composed_mute_changes` (muted now: - one jump; changed only after the swap: none). Red: a changed table that continues its - carried cursor (the jump becomes a 64-sample grid event), adopt without the mute channels. - - `a_mute_adoption_keeps_the_fader_move_in_flight_for_the_unmute` (R18-4: a live -12 dB - `FaderDb` over 2,000 in flight across a rebuild that adds a curve mute on at the swap; muted - to `+0.0` after the ramp, and after the curve's unmute equal to the never-swapped plan; live - forms). Red: a stage that remembers the in-flight gain at the mute (temporary mutation in - `builtins::mute_channel`, first difference at 3,200). - - `crates/host-core/tests/route_mute.rs::a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length` - un-ignored and green: the strip and the send reach `+0.0` at 1,247 (`swap + M - 1`) on Simd8 - and Simd4. Red at the base and with adopt without the mute channels ("the strip mutes"). -- **Departures.** - - The scalar oracle binds the fader as a graph node, which no plan swap carries: its strip - restarts at the swap (prepared muted exactly, as a fresh plan). Gate 8's scalar leg holds - the post-ramp claim and the swap block's zero allocations only; the route_mute test runs the - bank widths only (its scalar leg failed on "one length for the strip and the send": the strip - is set at 768). Carrying a bound fader node is outside this slice. - - The adoption landing ramps over the block's mute jump length even when a `terms` record of - another length arrives at the same block (the two changes land as one `SetMute`). (Defect, - U1 review MAJOR-2; fixed by U1 fold 1.) - - A cell that owes a reposition on a lane with no node time keeps it owed; a `terms` record - there applies at once, composed with the carried target (no curve renders on that lane). -- **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), allocations 0, - `total_violations` 0. `realtime_policy`: ok, 168 marked regions (unchanged: the new code sits - in existing regions). `lane_slot_bytes` 392 (unchanged: the new `bool` fits the mute cell's - padding). -- **Ran (all pass).** `cargo test --locked` on `-p builtins-compiler --features test-support`, - `-p host-core --features host-core/test-support,host-core/control-provider` (579), `-p capi`, - `-p host-web --features host-web/test-support`, `-p control-plane --features test-support`, - `-p graph --features test-support`, `-p controls --all-features`; fmt, clippy (builtins-compiler, - controls, host-core, graph; all targets and features, `-D warnings`) and the no-feature - `cargo check --all-targets`. - -## Decision record: U1 fold 1 (batch 18) - -Fold round 1 of the U1 review (FAIL at `1d2e2bcc2`). Root rulings with it: MAJOR-2 takes the exact -fix (no fallback flag); R18-4 is withdrawn for the strip too (T2 and T4 text corrected above); -MAJOR-1 may extend the authorized paths to `crates/automation/src/events.rs`. - -- **MAJOR-2 (a terms record in a curve rebuild lands as committed).** - `LaneFaderAutomation::mute_terms` (`crates/builtins-compiler/src/lib.rs`): the drain stores - `terms`; when the record changes the stage mute the curve the stage heard gives (the old rule: - `target || terms` differs from the stage's mute), a cell that owes a reposition and has node time - this block composes the record with the curve at the block's first node time - (`value_at(cell, node_time_at(span, 0))`, the value the driver's ramped landing aims at, since a - step curve is constant between discontinuities) and applies `SetMute` at the block boundary over - the record's ramp word. The driver's adoption landing then composes the same value with the stage's - updated mute and pushes only a change the curve itself makes, over the mute length. At most one - `SetMute` per channel at the swap block. A record that changes nothing against the heard curve is - left to the landing (the change, if any, is the curve's). No new field, branch-only. Also fixes - NIT-6 (a session seek in the swap block no longer delays the terms change: the record applies at - the block boundary). Not separately gated. -- **MINOR-3.** `hosts/host-web/tests/control_parity.rs::a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts` - (gate 8's sentence): a mute-only session (a mute curve on `t1`), a hand fader move over 4,000 in - flight, then a mute-curve rebuild: `EXACT` and `transition_fallback_count == 0` on both hosts, - the same watermark and bits, at every launch rate. New helper `CAbi::transition_fallback_count`. -- **MINOR-4.** `control_parity.rs::a_composed_stored_mute_renders_alike_on_both_hosts` - (`test-support`): gate 3's script (solo on `t2`, a VCA mute of `t1` through a new VCA `m`, a hand - mute of `t1`; set while the curve is open, released while closed, while open, or kept) and a plain - session seek to 2,600 (inside the muted span) after the opening; the same verdicts and reply - bytes and the same bits per block on the browser and the C ABI, at quanta 128 and 37; the muted - span is heard (differs from a twin with no curve). The send does not follow `t1`'s mute here: see - the phase-2 line. Helpers: `CAbi::with_limits`, `submit_planes`, `render_frames` (the existing - `new`, `submit_from`, `render` call them) and `Rig::boot_at` in `hosts/host-web/tests/common/mod.rs` - (outside the listed paths: a mechanical test-helper extension; `Rig::boot` calls it). -- **MINOR-5.** `crates/host-core/tests/stored_fader_automation.rs::a_live_write_that_frees_a_held_lane_releases_its_hold` - now frees the lane with the production trigger: `Mute { true, 0 }` at the block entry before - 6,400 and `Mute { false, 0 }` at 6,400 (an exact hand unmute of a muted lane, `held_through` set), - at quanta 128, 100 and 64 and on the oracle (per quantum: the muted block differs between - quanta). `live_fader` is now unused and deleted. The other two R17-10 gates keep their - `FaderDb` form. -- **MAJOR-1 (a curve unmute ends an older cell hold).** Path extension approved by root: - `automation::CellState::release_hold_by(t)` (`crates/automation/src/events.rs`, shorten-only: - `ramp_done = t` only when a ramp is in flight and completes after `t`). In the driver, - `FaderCellCursor::rehold`'s unmuted branches record a release node (`node(at)` for `M = 0`, - `node(at + M) - 1` otherwise) and `FaderCellCursor::open` applies it to the cell's state before - it builds the next piece's events, also when no piece is left in the block (an unmute on its last - sample). Not an unconditional `hold_until`. The T3b phase-2 placeholder (an offsets-jump hold not - shortened by an in-block curve unmute) is this defect and is closed by it. -- **Gates (all pass).** - - MAJOR-2: `crates/control-plane/tests/mute_automation_realtime.rs::a_hand_mute_committed_with_a_mute_curve_edit_lands_as_committed` - (the review's repro through the shared commit: one transaction = `UpsertAutomation` moving - `eq0`'s closing step 1,000 -> 1,100 + `SetControl` mute = 1 with `ramp_samples` none / `0` / - `2,000`; path `Rebuild`; every block equals the live twin, no adoption offset) and, at the bank - widths, `crates/host-core/tests/stored_mute_automation.rs::a_hand_mute_in_a_curve_rebuild_lands_over_its_own_ramp` - (Simd8 on AVX2 and Simd4, live forms, quanta 128 and 100, ramps `M` / `0` / `2,000`: the - successor's first block takes the record; every sample equals the never-swapped twin; the swap - block allocates nothing; `carry_run` now passes the successor's live handles and gate to `edit` - from the swap block on). The scalar oracle restarts its strip at a swap (T4 departure), so it - has no leg. - - MAJOR-1: `stored_mute_automation.rs::a_curve_unmute_ends_an_older_hold_of_the_fader_cell` (the - review's probe: a ride `-30 -> 0` dB, a hand mute at 512, a hand unmute over 4,000 at 1,024, an - offsets write under it at 1,536, a curve mute 2,001 to 2,570; `M = 0` and `M = 48`; every live - form incl. the scalar oracle; quanta 128, 100, 37: each completion sample after `E + J` equals - the composed curve) and `crates/automation/tests/events.rs::a_released_hold_ends_early_and_is_never_lengthened`. - - Every mute gate (13 in `stored_mute_automation.rs`) and fader gate (30 in - `stored_fader_automation.rs`) green. -- **Red checks (temporary, reverted).** - - MAJOR-2 control-plane gate on the base `lib.rs`: red at `Some(0)`, block 4, and at `Some(2000)`, - block 4 (`None` is green, as the review found). Bank-width gate on the base: red at ramps `0` - and `2,000`, Simd8 and Simd4, both planes, quanta 128 (first difference 1,024) and 100 (800). - - MINOR-3: `automates` mutated to `automation_row_renders` -> outcome flags 2 (transition - fallback) instead of 1 at 44.1 kHz. - - MINOR-4: both hosts run one shared core, so a shared-code mutation moves both legs alike; a - browser-only mutation (the adapter's plain session seek anchored one block late) is red at the - seek ("block 36 bits", quantum 128). The gate is green on the pre-fold code. - - MINOR-5: no release (`if false && release` in the driver) -> red at 6,463 (quantum 128, bank). - - MAJOR-1 driver gate: the release removed (`self.release = None`) and the base `lib.rs`: red at - `M = 0` on every form and quantum (first completion sample 2,879: the ride frozen at -23.7 dB). - Unit test: an empty `release_hold_by` -> red (hold to 1,000 kept); `hold_until` in its place - -> red (hold to 200 lengthened to 500). -- **Departures.** - - MAJOR-1 at `M = 48` is not red on the base, and cannot be made red: the review's probe itself - shows no freeze at `M = 48` on the base (the grid events of the held span leave a move owed at - `E`, whose landing re-commits the cell and replaces the old hold). A freeze at `M > 0` needs - nothing owed at an in-block `E` with the old hold set after the last remembered event, that is - no grid point in `[X, E)` where `X` is the offsets write's entry at or before the curve mute; - automation discontinuities are at least 64 samples apart, so `E - X > 64` and a grid event - always lies there (the held branch clears the target, so it emits and owes). The `M = 48` leg - stays in the gate as a claim (green on both). - - MAJOR-2's gate in host-core runs the bank widths only (no scalar leg: T4 departure). -- **Measured.** `audit capi` `pcm_digest` `bffc21e857b5e134` (unchanged), allocations 0, - `total_violations` 0. `realtime_policy`: ok, 168 marked regions (unchanged). -- **Ran (all pass).** `cargo test --locked` on `-p builtins-compiler --features test-support` - (87), `-p automation --all-features` (32), `-p host-core --features - host-core/test-support,host-core/control-provider` (581), `-p capi` (231), `-p host-web --features - host-web/test-support` (214), `-p control-plane --features test-support` (28). fmt, clippy - (automation, builtins-compiler, control-plane, host-core, host-web; all targets and features, - `-D warnings`) and the no-feature `cargo check --all-targets` of the same crates and `capi`. -- **Resolved by #1653 (verified on the merge `a63588680`).** A solo on `t2` in a session where `t1` - has a mute curve and a send that follows `t1`'s mute was refused with `control.internal` on lane M - before #1653 landed. On the merged tree it is accepted on both hosts and composes; the MINOR-4 gate - now keeps the following send (its `follows_mute = false` override is removed). -- **U1 fold verification notes (batch 18).** The M > 0 release branch of `rehold` is defensive: a - muted span is at least 64 samples, so a grid event before E always owes a move that re-commits the - cell, and the M = 48 leg cannot turn red (the verifier confirmed the argument). MINOR-4's unique - catch is a divergence in a host adapter's seek or term delivery (both hosts run the one core). When - a hand record and a curve edit are both needed for a change (P3), the landing ramps over M and the - hand ramp is not used, by the rule "a record that changes nothing against the heard curve is left - to the landing". - diff --git a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md deleted file mode 100644 index 177b444fe..000000000 --- a/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md +++ /dev/null @@ -1,808 +0,0 @@ -# Follow an automated mute on following sends in render - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A1.3 and A6, under decision 15 D15-16 -(`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as #1651 -*Render stored mute automation on the strip* and #1653 *Write a following send's route cell from the -shared commit*.** - -## Product outcome - -Every send into a submix with `follows_mute` from a strip whose mute the session automates follows -that automation in render, on every host and whatever the live-control options. The send's gate -changes at the render sample where the send's tap carries the timeline sample the curve names, so -the send mutes exactly the audio the strip mutes, latency included, and a saved session with an -automated mute and pre-fader sends leaks nothing. A route into the output never follows. The edits -that write the send's cell are #1653's. - -## Context - -- **Route lanes.** Preparation attaches one lane per route into a submix only with a control channel - and `lanes.routes` (`crates/host-core/src/prepare.rs:1574-1592`). At block entry the route op - applies its lane's value, ramping from the coefficients it is at - (`crates/graph/src/runtime.rs:892-914`). The route ramp is the indexed law, a pure function of the - frame index (`crates/lane/src/kernels.rs:1073-1095`, `IndexedRamp` `:1097`, - `coefficients_at` `:1153`). A delayed route keeps feeding its compensation line - (`crates/graph/src/runtime.rs:916-920`): the route op runs before the send's compensation delay. -- **The one route derivation.** `graph::gated_route_coefficients(transform, RouteGate { mute, - follow_zeroed })` (`crates/graph/src/lib.rs:777-812`) is a `const fn` that allocates nothing; - `RouteGate` is `:753-775`. `graph_compiler::route_values` gives the open transform - (`crates/graph-compiler/src/ids.rs:309-322`), and `route_coefficients` composes it with the gate - (`:341-355`). -- **Route cells.** *Hold route-lane values in latest-target cells* (#1347) D1: four coefficients, - mute, ramp. -- **Node time** (README A1.3). A point whose signal arrives at `a` on the compensated graph - processes, at render sample `r`, the audio of timeline sample `timeline(r + ΣP - a)`. For a send - tap, `a(tap)` is the arrival of the tap's signal: the floored input arrival of the stage the tap - reads after (#1285 D2), plus that stage's latency (PDC's output time, - `crates/graph-compiler/src/pdc.rs:53-104`). For an input, post-input or insert-send tap behind - inserts of latency `L`, `a(tap) = a(fader) - L`; for a post-fader or post-pan tap, - `a(tap) = a(fader)`. -- **The strip side** is #1651: mute cells, the stage mute `curve || terms`, events at exact - samples over the session mute length. -- **Swaps.** *Carry strip delay lines and live send ramps across a plan swap* (#1284) D2 carries a - live send's ramp by route ID. -- **#1226 D6** leaves the follow of an automated mute to #1058, through the same composition. - -## Decisions frozen for this slice - -- **D1. A route lane for every following route of an automated mute, on every plan.** Preparation - attaches a route lane, on both hosts and whatever the live options, to every route into a submix - whose source strip has an automated mute lane. For such a route, this slice gives the route cell - seven words instead of #1347's folded coefficients: the open transform (`gain`, `ll`, `lr`, `rl`, - `rr`), one flags word (route `mute`, `follows_mute`, `terms[2]`, `automated[2]`), and the ramp. - `terms[lane]` is the source lane's other mute terms: VCA mute and solo mute for an automated - lane; the whole effective mute for a lane that is not automated. `automated` is fixed by the - plan. Preparation seeds the cell. Every other route keeps #1347's layout; #1347 is not amended. -- **D2. The route op follows the curve at the tap's arrival.** - - The route op evaluates its source strip's mute cells itself, with its own cursors over the same - plan-immutable tables, in the node time of its tap, `τ_tap(r) = timeline(r + ΣP - a(tap))`. A - curve event at timeline sample `t` therefore falls at the render sample where the tap carries - `t`. For a post-fader tap that is the strip's own event sample; for a tap behind inserts of - latency `L` it is `L` samples earlier in render time, and after the send's compensation delay - the send's gate change meets the strip's on the same timeline sample at the submix input. - - **Why the tap's arrival, not the fader's.** A1.3 says automation lines up with the audio it acts - on at every node, latency included. With the fader's arrival, a pre-insert send would gate `L` - samples of timeline late, and pass `L` samples of audio that the strip mutes: the leak A6 exists - to prevent. *Let C ABI sends follow their source strip's mute live* (#1226) D5 ramps a live - follow together with the strip in render time, because a live mute has no timeline sample; a - stored curve has one, and the timeline rule governs it. - - At each source mute event at render sample `r`, and at the block entry after its cell changes: - `follow_zeroed[lane] = follows_mute && ((automated[lane] && curve[lane](τ_tap(r))) || terms[lane])`; - the target is `gated_route_coefficients(&transform, RouteGate { mute, follow_zeroed })`. Only a - target whose bits change retargets: a new `IndexedRamp` from `coefficients_at(position)` at - offset `r` over the session mute length (the cell's ramp word for a cell change). A seek sets - the target exactly where it reaches the tap. - - The session mute length of a route ramp is the `mute` word of #1639's plan cell, read from - the same block context as the strip's fader stage reads it, so the strip ramp and the send ramp - have the same length. - - The route op gains an in-block retarget at an offset on #1638's model: mix `[0, o)`, retarget, - mix `[o, q)`. The indexed law makes the split class A. -- **D3. Swaps.** A route lane present in both plans carries by route ID under #1284 D2, whether or - not its source mute is automated in either plan: a lane gained or lost for D1 is not a change of - the route's structure. This slice extends #1284's join for it; #1284 is not amended. -- **D4. The acked-batch question: can an ack ever precede a drop? No.** Render generates curve - events from the plan; nothing is queued. - -## Deliverables - -1. D1: route lanes for following routes, the seven-word cell and its seed. -2. D2: the route op's evaluation at the tap's arrival and the in-block retarget. -3. D3: the carry join. - -## Authorized paths - -- `crates/host-core/src/prepare.rs` (the route lanes, the cell seed and the carry join), - `crates/host-core/tests/route_mute.rs` (exists; new cases) -- `crates/graph-compiler/src/ids.rs` (only to make `route_values`, `:309-322`, public or to expose - a public wrapper for it), `crates/graph-compiler/src/pdc.rs` (only to export `a(tap)`, if #1285's - inventory does not already give it) -- `crates/graph/src/lib.rs`, `crates/graph/src/runtime.rs` (the route lane, its cell and the route - op only; stream A's files, sequenced by root), `crates/lane/src/kernels.rs` (a range entry only, - if the split needs one) -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs`, - `crates/control-plane/tests/follow_automation_realtime.rs` (new) -- `crates/control-plane/Cargo.toml` (a `bench-support` dev-dependency for that binary, only if an - earlier slice has not added it) -- `crates/graph/Cargo.toml` (a normal `automation` dependency: the route op runs #1637's - cursors in graph, and graph cannot reach them through builtins-compiler, - `scripts/check-builtins-policy.sh:21`), `Cargo.lock`, `scripts/check-graph-policy.sh` (the - pinned graph dependency list, `:19-22`) and `scripts/test-graph-policy.sh` (its fixture list, - `:7-13`), each gaining `automation` only. This slice is the first to add the edge. - -## Non-goals - -- The edits that write the route cell, a live `follows_mute` toggle and the solo and VCA terms - (#1653). The strip's mute events (#1651). Send automation (OQ2). Routes into the output, - which never follow (decision 13 P11). - -## Hazards - -- **One push with #1651 and #1653.** Root merges the three together. -- **A redundant retarget moves bits** (README "Change only"). D2 compares the whole target. -- **Delayed sends.** A follow-muted delayed send stays active and mixes zeros (#1226 Hazards). Its - comparison point includes its compensation delay. -- **Two readers of one table** are safe only because the tables are immutable after preparation and - each op owns its cursor; gate 2 proves the samples agree. - -## Objective gates - -1. **No leak** (both hosts: `crates/capi/src/runtime/live_tests.rs` and `hosts/host-web/src/tests.rs`, - new). Track `t` sends `pre_fader` with `follows_mute: true` into bus `b`; `t` has a mute entry on - `both` with steps 0, 1, 0, at samples that are not grid samples. From the end of each mute ramp - (plus the send's compensation delay), the output is bit-identical to a plan prepared with the same - static mute, fed the same PCM. With live controls off in the browser too. -2. **Same timeline sample, same length** (`crates/host-core/tests/route_mute.rs`, new cases). With a - latent insert of `L = 64` before the fader and an `input` tap: the send's route ramp starts `L` - render samples before the strip's mute ramp, with the same length, so both act on the same - timeline sample; with a `post_fader` tap both start on the same render sample. At quanta 128 and - 100. -3. **Route split** (`crates/graph` test, new). A route retargeted at offset `o` in `{1, 63, 64, 127}` - (`q = 128`) and in a block of 100 equals the same block split at `o` with the retarget at the - second part's entry, bit for bit. -4. **Carry** (`crates/host-core/tests/route_mute.rs`). A rebuild that adds an automated mute to `t` - keeps the send's ramp state by route ID; the swap block allocates and frees nothing. -5. **Realtime.** `crates/control-plane/tests/follow_automation_realtime.rs` (new integration binary in control-plane, the crate that - holds the shared commit, `control_plane::SessionState`, #1309 D1-D9. A host-core binary cannot - reach the commit without a dev-dependency cycle, since control-plane depends on host-core. - `scripts/check-bench-policy.sh:257-280` allows a `bench-support` dev-dependency only in a - `crates/` manifest, so no host-web binary can link it. The binary links `bench_support::alloc` - and calls `assert_installed()` first). It drives the script through control-plane's shared - commit and host-core's render session, the code the browser Worker (#1382) and the C ABI both - run. For gate 1's script: - `allocations == 0 && frees == 0` around every render call after warm-up. - `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` reports all - violation counts 0. -6. **Commands:** - - `cargo test --locked -p host-core --features host-core/test-support`, - `cargo test --locked -p graph --features test-support`, `cargo test --locked -p capi`, - `cargo test --locked -p host-web --features host-web/test-support` - - `cargo test --locked -p control-plane --features test-support` - - the workspace debug leg (`test-debug-a`) in `.github/workflows/qualification.yml` - - `cargo build --locked --release -p audit && bash scripts/trace-builtins-graph-audit.sh target/release/audit` - - `bash scripts/check-web-audioworklet.sh`, `bash scripts/test-web-audioworklet.sh` - - `bash scripts/check-realtime-policy.sh`, `bash scripts/check-graph-policy.sh`, - `bash scripts/check-host-core-policy.sh`, `bash scripts/check-workspace-policy.sh`, - `bash scripts/check-cross-targets.sh` (README F19, the iOS memset rule), `bash scripts/run-aarch64-tests.sh debug` - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` -7. **No rendered bit moves** for a session with no stored automation: `audit capi` gives the same - `pcm_digest` at base and head (PR evidence); the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if a following send ignores the curve (the leak A6 exists to prevent), or if a plan - without live controls attaches no lane for it. -- Gate 2: red if the route op times its events at the fader's arrival (a pre-insert send would gate - `L` samples late and leak) or at the route's own arrival, or if the two ramps differ in length. -- Gate 3: red if the in-block retarget restarts the indexed ramp from the target or from rest. -- Gate 4: red if gaining automation on the source re-points the send and drops its ramp. -- Gate 5: red if the route op's evaluation or the cell read allocates on render. - -## Dependencies - -Batch R2. Direct dependencies: - -- #1651 *Render stored mute automation on the strip* (same push). -- #1639 *Hold the automation jump lengths in a plan cell* (the route ramp length is its `mute` - word). -- *Carry strip delay lines and live send ramps across a plan swap* (#1284): the join D3 extends. -- *Hold route-lane values in latest-target cells* (#1347): the cell D1 lays out for these routes. - -#1638's split model, #1637's per-cell events and #1285's arrivals arrive through #1651. -#1653 depends on this draft and lands in the same push. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **Route lanes exist on every plan** (C6, #1557): every route into a submix has a live lane and a - `ROUTE_CELL_WORDS = 6` cell (`crates/graph/src/lib.rs:1004-1047`). D1's "attach a route lane" is - done; D1 keeps the seven-word layout for a route whose source mute is automated. -- **`terms` includes the source's static mute** (OQ1 = C, #1651 amended): for an automated - source lane, `terms = static_mute || VCA mute || solo term`. -- **#1654** (OQ2 = yes) reuses this slice's seven-word cell and in-block route retarget for a - route whose own rows are automated; build them so the transform words can come from curves too. -- **Moved files.** The follow gate is computed by `controls::effective` (C7); route records come - from `route_records` (`crates/host-core/src/live_delta.rs:1416`) and `RouteControlProducer` - (`crates/host-core/src/route_controls.rs:201`); `live_route_state.rs` is gone. -- **Dependencies.** #1284 is open; #1347 has landed. - -## Amendment (controls coordinator, batch 10, 2026-10-14) - -- **Automated VCA mutes** (#1655): a route into a submix whose source lane is reached by a VCA - with an automated mute also uses this slice's seven-word cell, and its follow term ORs those VCA - curves, evaluated in the tap's node time. #1655 adds it on this slice's route op; this slice - needs no change for it. - -## Amendment (controls coordinator, batch 18 spec check, 2026-10-17) - -Checked against `ad03780ee`. The filed text above stays; where it disagrees, this section wins. - -**Anchors now.** -- Route cell: `RouteControlRecord` `crates/graph/src/lib.rs:963`, `ROUTE_CELL_WORDS = 6` `:1007`, - `RouteLane` `:1047`, `GraphRouteControlProducer` `:1085`. `RouteGate` `:770`, - `gated_route_coefficients` `:801`. `LatestCell` is const-generic - (`crates/engine/src/realtime/latest_cell.rs:590`), so a seven-word cell needs no edit under - `crates/engine/src/realtime/`. -- Route op: `LiveRoute` `crates/graph/src/runtime.rs:887` (`apply_pending` `:928`, `active` - `:964`, `mix` `:970`, `adopt` `:981`); the drain and activity in `execute_op` `:3676-3688`; the - mix arm `:3791`. `execute_op` already takes the block's `GraphBlockTime` (`:3660`). -- Ramp: `IndexedRamp` `crates/lane/src/kernels.rs:1589`, `coefficients_at` `:1645`, - `route_mix_ramp_block` `:1698`. The kernel takes sub-slices and a start position, so the split - needs no kernel change. `crates/lane/src/kernels.rs` leaves the paths. -- `route_values` `crates/graph-compiler/src/ids.rs:322-338` (`pub(crate)`), `route_coefficients` - `:362-376`, re-exported at `crates/graph-compiler/src/lib.rs:323`. -- Node time: `GraphBlockTime` `crates/graph/src/lib.rs:1298` (`node_span` `:1350`, - `jump_lengths` `:1341`), `GraphJumpLengths` `:1244`, `floored_input_arrival` `:1362`. - `GraphCompileReport::input_arrivals` (`crates/graph-compiler/src/lib.rs:194`) holds every node's - floored input arrival, route nodes included. A route op reads its one undelayed input before the - consumer's compensation delay, so the route node's floored input arrival is `a(tap)`. - `crates/graph-compiler/src/pdc.rs` leaves the paths. -- Route lanes attach at `crates/host-core/src/prepare.rs:2867-2888`. Since C6 (#1557) every route - into a submix is a live row, so every plan on both hosts has its lane (D1's attach is done). -- Carry: `join_routes` `prepare.rs:5473`, `resolve_route_carry` `:5537`, the join's retarget - writes `:3654`; render side `carry_live_routes` `runtime.rs:5261`. -- The one record function of a send is `route_record_between` (`prepare.rs:4154-4193`). The - classifier (`route_records`, `crates/host-core/src/live_delta.rs:1467`) and the join call it. -- Graph's dependency pin is `scripts/check-graph-policy.sh:21`; the fixture is - `scripts/test-graph-policy.sh:7-13`. Batch 17 did not add `automation` to graph (it put the - fader program in builtins-compiler), so this slice is still the first to add the edge. -- `scripts/check-realtime-policy.sh` is deleted (c1aadc234). The gate is - `cargo build --locked --release -p realtime-policy && ./target/release/realtime_policy`. -- `crates/control-plane/Cargo.toml` already has `bench-support` (`vca_automation_realtime.rs`). - It leaves the paths. - -**How the slice maps onto the landed code.** -- **Program hand-off.** The plan's `Arc` is built in builtins-compiler - (`prepare_fader_automation`, `crates/builtins-compiler/src/lib.rs:2087`; #1651 adds the mute - cells). #1651 exports, per strip, the program and the two mute cell indices to host-core - preparation, as a handle type that graph defines. Host-core passes it, with `a(tap)` from - `floored_input_arrival(report.input_arrivals, route node)`, to the route attach. Host-core needs - no `automation` dependency. The "automated" test is #1651's mute-lane predicate in `controls` - (as `automated_fader_lanes`, `crates/controls/src/automation.rs:447`). -- **Layout.** A seven-word route has its own lane type (`LatestCell<7>`) and its own boxed render - state beside the route op. Every six-word route keeps its bytes (`LIVE_ROUTE_OWNER_BYTES`, - `route_control_resources`) and its bits. The seven-word lane and its render state are charged in - `route_control_resources`; the program is charged once, by builtins-compiler. -- **Flags word.** Bit 0 route `mute`, bit 1 `follows_mute`, bits 2-3 `terms[L, R]`, bits 4-5 - `automated[L, R]`, other bits 0 (decode refuses them, as `mute > 1` today). After OQ1 = C, - `terms[lane]` is always `controls::effective`'s source-lane mute (it holds no curve), for an - automated lane and for one that is not. `automated` is the plan's: render takes it from its own - cursors, and the commit refuses, before the first write, a record whose bits differ (#1653). -- **Node time (R17-3).** The route op takes the same per-block context the builtin banks get - (`GraphBlockTime`, set once per block), calls `node_span(a(tap))` (signed, R17-5) and builds its - `automation::NodeSpan`. It uses `CellEvents` with the mute cell's rule (#1651; `SeekMode::Exact`, - so a seek gives a `Set`, length 0). -- **Ramp length.** `GraphJumpLengths` "knows no row", so graph must not spell the mute row. The - attach passes a resolver from host-core: - `LiveRamps::from_lengths(fader, mute, pan).for_row(LiveRampRow::Mute)`, the rule the strip's - stage uses. Gate 2 holds that the two lengths agree. -- **Order in one block.** First the cell at block entry: compose with the curve's value at the - block's first sample and retarget over the cell's ramp word on a bits change. Then the curve - events in offset order: each composes and retargets over the session mute length (0 for a `Set`) - on a bits change. #1651 uses the same order on the strip, so gate 2 holds. -- **Seed.** The bound target composes the curve's first `start_value` (as #1651's prepared stage - mute) with the seeded words. A route of a fresh plan whose curve at its first block's `τ_tap` - differs sets exactly there (length 0), as the strip's fresh lane does (#1647 D2, #1651). -- **Activity (fix).** Today the op decides `active` at block entry, before it mixes. A seven-word - route is active in every block where it retargets, so an unmute inside a block is mixed. It finds - its changes first (for example on a copy of its `CellState`, which is `Copy`, 32 bytes). A - muted, undelayed route with no change in the block stays inactive and moves no bit. -- **FP (R17-7).** Render composes inside the render entry's `CanonicalFpEnv`; the seed and the - commit compose inside R17-7's guards. One `const fn`, `gated_route_coefficients`, gives both. -- **Scalar oracle (R17-4).** The route op is not banked and has one form. The strip's bank and - its oracle are #1651's. -- **No queue.** D4 holds: render generates the events from the plan. - -**D3, made exact (fix).** -- The lane exists in both plans, so the move by route ID always applies. Ramp, position and mute - carry as today (`LiveRoute::adopt`). -- The follow cursors do not carry. A mute cell has no grid and no hold, so the successor places - each cursor fresh at its own `τ_tap` of the first block and seeds it with `value_at` there. -- At the successor's first block: the join's record (if any) applies first, through the cell. Then - the route compares its composed target with the adopted ramp's target. On a bits change it - retargets from `coefficients_at(position)` over the session mute length. -- A six-word successor of a seven-word predecessor (the curve removed) makes the same comparison - with its own prepared target. The model diff cannot see the curve, so only render knows the - carried state. A six-word pair keeps today's code exactly. -- The join's record for a seven-word successor route is the seven-word record from - `route_record_between` (#1653's branch). -- No `TransitionFallback` and no new watermark rule. A carried ramp whose target does not change - keeps its own length. A changed target is the new curve's own jump, which an uninterrupted plan - makes the same way at a curve event (as #1648's "Note 1" for a cell's own jump). B17-R14's - window does not read routes. - -**Gates (fixes).** -- Gate 1: drop "With live controls off in the browser too". There is no such option now (C6). - Both hosts run it at the native width: `crates/capi/src/runtime/live_tests.rs` and - `hosts/host-web/tests/control_parity.rs`. Host-core runs the same script in - `crates/host-core/tests/route_mute.rs` at `Backend::Simd8` (`cfg(target_feature = "avx2")`) and - `Backend::Simd4`, with the `Scalar` oracle (`test_only_prepare_host_runtime_*_on`). -- Gates 2 and 4 also run at `Simd8` (avx2) and `Simd4`. -- Gate 3 is a unit test in `crates/graph/src/runtime.rs`'s test module (the route op is - `pub(crate)`). Add one case: a route that unmutes at offset `o` while inactive is mixed on - `[o, q)`. -- Gate 4, new case (b): a rebuild that removes `t`'s mute entry while the curve holds the send - muted unmutes the send over the session mute length from the swap block; it allocates nothing. - Red if the six-word successor keeps the adopted mute. -- Gate 6: replace `bash scripts/check-realtime-policy.sh` with the `realtime-policy` binary above; - add `bash scripts/test-graph-policy.sh` and `bash scripts/check-builtins-policy.sh`. - -**Authorized paths after the check.** -- `crates/graph/src/lib.rs`, `crates/graph/src/runtime.rs` (route lane, cell, record, producer - write, the route op, its activity, its carry). Render code, but not under - `crates/engine/src/realtime/`: no root notice is needed. -- `crates/graph/Cargo.toml`, `Cargo.lock`, `scripts/check-graph-policy.sh`, - `scripts/test-graph-policy.sh` (each gains `automation` only). -- `crates/graph-compiler/src/ids.rs` (`route_values` public), `crates/graph-compiler/src/lib.rs` - (its re-export only). -- `crates/builtins-compiler/src/lib.rs` (only if #1651's export of the program and a strip's mute - cells needs a change; after #1651). -- `crates/host-core/src/prepare.rs` (attach, seed, resolver, join), `crates/host-core/src/lib.rs` - (re-exports only), `crates/host-core/tests/route_mute.rs`. -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, - `hosts/host-web/src/tests.rs`, `crates/control-plane/tests/follow_automation_realtime.rs` (new). -- No path under `crates/engine/src/realtime/`. - -**For #1654 (phase 2, no scope here).** It reuses the open transform words, the flags word's spare -bits (6 and up), the per-event composition and the in-block retarget. It needs the seven-word -predicate widened to routes with automated rows, and carried cursor state by `(route, row)` for -curves that move on the grid; this slice's mute cursors are not carried. - -## Decision record: #1652a (batch 18) - -Tranche R1: the seven-word cell types, the graph -> automation edge and a public `route_values`. -No render behaviour: no route op evaluation, no attach, no carry. - -- **Shipped (`crates/graph/src/lib.rs`).** `FOLLOW_ROUTE_CELL_WORDS = 7`; `RouteFollowFlags` (bit 0 - `mute`, 1 `follows_mute`, 2-3 `terms[L, R]`, 4-5 `automated[L, R]`; `from_word` refuses any bit - from 6 up) with `gate(curve)`, D2's composition - `follow_zeroed[lane] = follows_mute && ((automated[lane] && curve[lane]) || terms[lane])`; - `FollowRouteControlRecord` (open `RouteTransform`, flags, length; refuses a length over - `ROUTE_RAMP_LENGTH_MAXIMUM` and a non-finite transform word) with `to_words`/`from_words`; - `FollowRouteLane` (`LatestCell<7>` + dirty word), `FollowRouteControlLane` (render reader half, - with `read(snapshot)`), `GraphFollowRouteControlBinding`, `GraphFollowRouteControlProducer` - (`write`, `test_only_write_count`), and `follow_route_control_pair`, the one builder of a - binding/producer pair, for #1652c's attach. -- **Render state (`crates/graph/src/runtime.rs`).** `LiveFollowRoute`, its own boxed type beside - `LiveRoute` (ramp, position, mute, delayed, route index, transform, flags, control lane); - `LiveRoute` is unchanged. #1652b adds its cursors and its constructor. -- **Charge.** `route_control_resources(producers, following)` charges each kind its own cell, - owner and producer bytes and the activity table once; with `following` empty it is the old sum - term for term. New `LIVE_FOLLOW_ROUTE_OWNER_BYTES`. Measured (x86-64): six-word cell 144, - seven-word cell 168; `LIVE_ROUTE_OWNER_BYTES` 72 (unchanged), `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 96 - (grows with #1652b's cursors, charged by `size_of`); both lanes 32, both bindings 64, both - producers 72. -- **Departure.** The signature change of `route_control_resources` is a mechanical caller update - at `crates/host-core/src/prepare.rs` (one call), `crates/host-core/tests/live_routes.rs` and - `crates/graph-compiler/tests/live_routes.rs` (pass `&[]`); the last two are outside this slice's - listed paths. `RouteFollowFlags::gate` is the pure composition #1652b and #1652c both call; it - lands here with its test. -- **Edge.** `crates/graph/Cargo.toml`, `Cargo.lock`, `scripts/check-graph-policy.sh` (the pin) and - `scripts/test-graph-policy.sh` (the fixture manifest and the partial-output awk shim) gain - `automation` only. No source in graph uses it yet; #1652b's cursors do. -- **`route_values`** is `pub` and re-exported from `graph_compiler`. -- **Dead-code expects (gone by the PR boundary):** `LiveFollowRoute` (struct, "bound and read by - #1652b route op"); `FollowRouteControlLane::read` (non-test, "read by #1652b route op"); - `follow_route_control_pair` (non-test, "called by #1652c route attach"). -- **Gates.** `crates/graph/src/lib.rs` `follow_route_tests`: - `the_flags_word_round_trips_its_six_bits_and_refuses_every_other_bit`, - `the_gate_follows_the_curve_only_on_an_automated_lane`, - `the_record_round_trips_its_seven_words_and_refuses_out_of_bounds_words`, - `the_producer_write_reaches_the_binding_under_the_published_revision`, - `the_charge_counts_each_kind_its_own_bytes_and_the_activity_table_once`; all pass. The six-word - charge stays held by graph-compiler's `route_control_resources_cover_the_allocation`. The - realtime policy reports 164 marked regions (main: 160; four new). - -## Decision record: #1652b (batch 18) - -Tranche R2: the following route op in render. All in `crates/graph`; no attach, no carry. - -- **Shipped (`crates/graph/src/lib.rs`).** `GraphMuteJumpLength = fn(GraphJumpLengths) -> u32`, the - mute row's rule host-core hands in, so graph never spells the row. `GraphFollowRouteSource` (the - plan's `Arc`, `cells[L, R]`, `a(tap)`, the jump rule, the seeded flags); - `new` refuses no automated lane, a cell outside the program and `flags.automated` that disagree - with `cells`. `GraphFollowRouteControlBinding` gains `source`, and `follow_route_control_pair` - takes it. A plan field `follow_route_controls` (empty until #1652c's attach) goes to bind. -- **Render (`crates/graph/src/runtime.rs`).** `LiveFollowRoute::new` seeds each automated lane's - `CellState` with its curve's first `start_value` and settles the ramp on - `gated_route_coefficients(transform, flags.gate(curve))`, with the plan's `automated`. - `NodeKind::LiveFollowRoute`. `node_kind` takes a following lane before a six-word lane; both - planning-metadata forms count it as a live route (it never folds); bind gives it its activity - index and `delayed` flag. `begin_block`: read the cell; compose with the curve the route has - heard; retarget over the record's ramp only on a bits change; take the block's spans from - `node_span(a(tap))` (a fresh route's first block lands a `Set` at 0 where the curve differs from - the seed, as the strip's fresh lane); then decide activity. `mix`: walk both lanes' - `CellEvents` (`SeekMode::Exact`) in offset order, compose at each offset, and retarget only on a - bits change: mix `[from, o)`, `IndexedRamp::new(coefficients_at(position), target, J or 0)`, - continue. The six-word `LiveRoute` and its arm are unchanged. -- **Activity (exact).** Active when the route is not muted-settled-undelayed at entry. Otherwise - a dry walk over copies of the cursors (`CellState` is `Copy`) finds out whether some change leaves - it not muted-settled. An inactive block commits the copies. No change in such a block retargets, - because every silencing target is `[+0.0; 4]`. A route that unmutes at `o` mixes the whole - block; `[0, o)` mixes the silent coefficients, which can contribute only a signed zero. - *Corrected by U2a fold 1 (MAJOR-1):* that signed zero reached the destination (`-0.0` where the - block split at `o` gives `+0.0`). The route op now reports its activation offset `o`, and the - destination leaves the route out of `[0, o)`; see the fold record below. -- **Choices.** (1) Events of both lanes at one offset compose once; the length is 0 when one of - them is a `Set` (a seek is exact). (2) The `mute` flag follows every composition; only the - coefficients' bits decide a retarget. (3) The jump length is clamped to - `ROUTE_RAMP_LENGTH_MAXIMUM`. (4) With no timeline, no jump-length cell, or a span at the `i64` - edge, the block has no events and the curve holds (the fader driver's rule). (5) A held grid - event changes no curve. -- **R18-4 (root).** No `TransitionFallback` for a route edit. A route retargets only on its own - curve's jump or on its own cell's record, so no carried live move is stretched or cut by a - rebuild, and B17-R14's window reads no route. *Withdrawn for routes by R18-5 (U2a fold 1):* with - one shared ramp, a gate change of one source column restarted the whole ramp, so an open - column's move was re-timed (MAJOR-2) and per-lane events closer than `J` stretched the earlier - column (MINOR-1). Per-column ramps make the reason true: a gate change retargets its own column - only, so no other column's move is stretched or cut. -- **Owner bytes (x86-64).** `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 96 -> 296 (cursors, spans, program - `Arc`, arrival, rule); `GraphFollowRouteControlBinding` 64 -> 112 (`GraphFollowRouteSource` 48). - Both are charged by `size_of`. `LIVE_ROUTE_OWNER_BYTES` stays 72. -- **Gates (`crates/graph/src/runtime.rs` `follow_route_op_tests`, all pass).** Gate 3: - `a_retarget_inside_the_block_equals_the_block_split_at_its_offset` (o in {1, 63, 64, 127} at - q = 128, {1, 63, 64, 99} at q = 100, against a kernel-level split reference and against the op - run as two blocks); `a_route_inactive_at_block_entry_that_unmutes_inside_the_block_mixes_the_rest`; - `the_event_lands_where_the_tap_carries_the_curve_sample` (`a(tap) = 64`); - `an_event_that_changes_no_target_bit_retargets_nothing`; - `a_seek_sets_the_target_exactly_where_it_reaches_the_tap`; - `the_bound_target_composes_the_curves_first_values`. In `lib.rs` `follow_route_tests`: - `the_source_refuses_no_lane_a_missing_cell_and_disagreeing_flags`. Red checks (temporary - mutations, reverted): retarget from the target or from the ramp's start -> gate 3 red; events - timed at arrival 0 -> the tap case red; activity decided at entry -> the unmute case red; always - active -> the same case red; no bits guard -> the guard case red; a seek ramping over `J` -> the - seek case red. None depends on a bank width (the route op mixes at `FrameLane`). -- **Realtime policy.** 165 marked regions (164 before; one new: the cursors, the walk, the block - entry and the mix). `follow_route_control_pair` keeps its dead-code expect until #1652c. -- **For #1652c (attach).** Pass `GraphFollowRouteSource::new(program, cells, a(tap), rule, - flags)`: the program and the two mute cell indices from #1651's export; `a(tap) = - floored_input_arrival(report.input_arrivals, route node)`; rule = a non-capturing - `|l| LiveRamps::from_lengths(l.fader_samples, l.mute_samples, l.pan_samples).for_row(LiveRampRow::Mute)`; - the seeded flags (route mute, `follows_mute`, `terms` from `controls::effective`, `automated == - cells`). Fill `follow_route_controls`, leave those routes out of `route_controls`, and charge - them. The carry (`live_route_rows`, `carry_live_routes`, D3) does not list following routes yet. - -## Decision record: #1652d graph carry (batch 18) - -Tranche R5a: the graph side of D3 (carry). All in `crates/graph`; host-core's join records are a -later tranche (after #1653). - -- **Shipped (`crates/graph/src/runtime.rs`).** `live_route_rows`, `is_live_route`, - `can_carry_live_routes` and `carry_live_routes` take a live route of either kind, so a route - present in both plans carries by route ID whatever its kind in either plan (six -> six, six -> - seven, seven -> seven, seven -> six). One per-move function, `carry_route`, applies the - predecessor's pending value under every snapshot (six-word `apply_pending`, seven-word - `apply_cell`, the cell half of the block entry factored out), then the successor takes ramp, - position and mute. Six -> six still runs `LiveRoute::adopt`, unchanged, so its bits hold. -- **Seven-word successor.** `LiveFollowRoute::adopt` marks the route `FollowStart::Carried` (the old - `fresh: bool` is now `Fresh | Carried | Running`). The follow cursors do not carry: the - successor's own unplaced cursors are placed at its first block's `τ_tap`, and each automated - lane is seeded with `value_at(span.start)` there (no fresh `Set`). The block entry now takes the - spans first, then the cell (the join's record, if any, applies first through the cell and - composes with the curve at `τ_tap`), then compares the composed target with the adopted ramp's - target and retargets from `coefficients_at(position)` over the block's mute jump length only on a - bits change. For a fresh or running route the new order changes nothing: the spans step reads no - state the cell writes. The block's jump length is now resolved whenever the block has jump - lengths, even with no node span; with no span the block has no events, so only the carried - comparison reads it. -- **Six-word successor of a seven-word predecessor (the curve removed).** The swap block has no - jump lengths, so the comparison waits for the first block, and a six-word route holds no mute - rule. At the swap, `carry_route` reads the successor's prepared target and mute (its bound, - settled ramp: it has rendered no block), adopts the state with `LiveRoute::adopt_state`, and when - the bits differ leaves a `SixWordCarryCheck { prepared, mute, mute_jump }` (the predecessor's - host-core mute rule) in that move's `RouteCarryStep`. With equal bits it takes the prepared mute - and leaves nothing. Render's first block after the swap (`GraphExecutor::render`, before the - units, under `route_carry_pending`) runs `finish_route_carry`: per check, the route's cell first - (`apply_pending` under the block's snapshot); with a record applied, the record governs (it is - this plan's own value); without one, `retarget_carried` to the prepared target over - `mute_jump(block lengths)` (clamped to `ROUTE_RAMP_LENGTH_MAXIMUM`, `0` with no lengths) on a bits - change. A send the curve held muted therefore reopens over the session mute length. -- **Bytes.** `LIVE_ROUTE_OWNER_BYTES` and `LIVE_FOLLOW_ROUTE_OWNER_BYTES` are unchanged (the - amendment's "every six-word route keeps its bytes"): the check lives in the carry section's move - table, `carry_routes: Box<[RouteCarryStep]>` (move + `Option`, about 40 bytes - on x86-64 against 8 before), allocated by `install_route_carry` on the control thread and charged - by `route_carry_retained_bytes`, the one place the charge is spelled. *Corrected by U2a fold 1 - (MINOR-3):* `RouteCarryStep` is back to 8 bytes; the checks live in a side table. The executor and its layout - twin gain one `route_carry_pending: bool`. -- **No `TransitionFallback` (R18-4; reason corrected by R18-5, U2a fold 1).** With per-column - ramps the reason below holds column by column: a carried column whose target bits do not change - keeps its own ramp, length and position, whatever the other column does. The carry never - stretches or cuts a move. A carried ramp - whose target bits do not change keeps its own ramp and length. A changed target is either the - join's record (its own rule and length, as at any block entry) or the curve's own jump at the - successor's node time over the session mute length, which an uninterrupted plan would make at a - curve event (for seven -> six: the jump the removed curve's absence makes, the same length). -- **Realtime.** The swap and the first block copy bounded fields and compare bits: no allocation, - free, lock, syscall or new panic path. Every new render function sits in an existing marked - region; the realtime policy reports 165 marked regions (unchanged). -- **Hook for the host-core join (#1652 D3, after #1653).** None new: the join feeds the successor's - own cell before the swap. For a seven-word successor it writes the seven-word record from - `route_record_between` through the successor's `GraphFollowRouteControlProducer::write` under the - carry's stamp (today's loop at `prepare.rs` writes only `route_controls`; a seven-word successor - with a retarget would fail there with `host.control.order` and must go to the follow producer). - For a six-word successor it writes the six-word record as today. Render applies the record first - at the successor's first block and then compares. `resolve_route_carry` needs no change: the - rows now list following routes. -- **Departure.** The alloc half of the gate is not in graph: linking `bench_support::alloc` into the - graph lib test binary installs the audited allocator for every unit test, and - `a_folded_metered_plan_is_the_unfolded_plans_master_and_meters_bit_for_bit` then aborts. The - route op is `pub(crate)` and following routes cannot be attached from outside graph before - #1652c, so the swap-block `allocations == 0 && frees == 0` gate stays with host-core gate 4. A - seven -> six successor's first block calls `apply_pending` twice (once in `finish_route_carry`, - once in its op), so the test-only drain-call counter counts two calls for that route in that - block; the applied count is exact. -- **Gates (`crates/graph/src/runtime.rs` `follow_route_op_tests::carry`, all pass).** - `six_to_seven_keeps_the_ramp_state_by_route_id` ((a) a pure re-adoption mid-ramp equals the - never-swapped twin bit for bit and keeps ramp and position; (b) a curve on at the swap ramps from - where the route is over the mute length); `seven_to_seven_mid_ramp_keeps_its_position`; - `seven_to_six_reopens_a_send_the_removed_curve_held_muted`. Red checks (temporary mutations, - reverted): no adopt into a seven-word successor -> six->seven and seven->seven red; no bits - guard on the carried comparison -> the same two red; cursor not seeded at `τ_tap` -> six->seven - (b) red; carried comparison as a step -> six->seven (b) red; six-word comparison missing -> - seven->six red. Six -> six carry keeps today's code path; `host-core` `route_mute` (9) and - `live_routes` (13) pass. - -## Decision record: #1652c (batch 18) - -Tranche R3: the host-core attach, the seven-word record builder and the swap join. No render code -(`crates/graph/src/runtime.rs` untouched). - -- **Attach (`crates/host-core/src/prepare.rs`).** `follows_automated_mute(model, route)` is the - one predicate: a route into a submix whose source strip has a mute entry - (`controls::mute_automation`, the entries the builtins plan compiles mute cells from). Before - lowering, preparation reads `PreparedBuiltinsSession::strip_mute_cells` for each such route and - seeds its flags (route `mute`, `follows_mute`, `terms = controls::effective`'s source-lane mute - under the plan's overlay, `automated == cells`). After compile it builds - `GraphFollowRouteSource::new(program, cells, floored_input_arrival(input_arrivals, route node), - follow_mute_jump, flags)`. `follow_mute_jump` is a plain fn: - `LiveRamps::from_lengths(f, m, p).for_row(LiveRampRow::Mute)`, in its own realtime-policy - region. The attach gives a following lane on every plan, whatever the live options (six-word - lanes still only with live controls and `lanes.routes`), and leaves those routes out of - `route_controls`. A plan with no following route and no live lane calls no attach and binds what - it bound before. Every preparation runs inside #1709's `CanonicalFpEnv` (R17-7); no new guard. - A count mismatch between the predicate and the attached lanes is `host.route_control.attach` - (unreachable for a validated session). -- **Graph (`crates/graph/src/lib.rs`, small).** `PreparedGraphPlan::attach_route_controls_following( - superseded, six_word, following)` returns both producer lists; `attach_route_controls` calls it - with `(true, |_| None)`, so six-word bits and bytes are unchanged. The dead-code expect on - `follow_route_control_pair` is gone. Mechanical wrapper in - `crates/builtins-compiler/src/lib.rs`: `attach_route_live_controls_following`. -- **Handles.** `HostLiveControlHandles::follow_route_controls: - Vec` (canonical route-ID order) and - `follow_route_producer_position` (re-exported). Charge: `route_control_resources(&six, - &following)`. Mechanical: two struct literals in `crates/host-core/tests/{live_routes, - effect_observation}.rs` gain the field. control-plane still drops the follow producers (`..`); - #1653 keeps them. -- **Record builder.** `follow_route_record(route, terms, automated, length)` (open transform from - `graph_compiler::route_values`) and `follow_route_record_between(ramps, before, after, terms, - automated, gates, gate_length, edit)`, next to `route_record_between`. A record only when the - transform bits, `mute`, `follows_mute` or `terms` change; `automated` is the next plan's and is - never compared, so a change of kind alone writes nothing (coordinator obligation 1). Length: - `gate_length` when the effective gate changes, else `SendFollowsMute` when only `follows_mute` - changes, else the longest changed `SendGain`/`SendMatrix`, else `SendMute`. `SendRecord { - Folded, Following }` is the join's record kind. -- **Join.** `join_routes` gives a successor following route the seven-word record (terms from - `EffectiveDiff::strip_mutes`), any other route today's. The carry loop writes `Folded` through - `route_controls` and `Following` through `follow_route_controls[...].write` under the carry's - one stamp (the revision the host publishes before the successor's first block). -- **No `host.control.order` / internal fault for a following route.** Until #1653, the classifier - (`live_delta.rs`, `route_records`) answers `RebuildOnly(RuledLiveNotBuilt { issue: 1653 })` for - any route lowering of a following route (a send edit, or a strip mute that moves its gate), so no - commit writes a six-word record to a route with no six-word cell; the rebuild's join writes the - seven-word record. #1653 replaces this branch with its live record. -- **Departure.** Gate 2 uses `L = 486` (the true-peak limiter at 48 kHz): no native effect declares - 64. Gate 2 measures at the output (after PDC); "the send ramp starts `L` render samples before - the strip's" follows from the bus path's `L`-sample compensation, which the test does not probe - at the op. -- **Gates (all pass unless marked).** - - Gate 1: `crates/host-core/tests/route_mute.rs` - `a_following_send_leaks_nothing_of_a_stored_mute` (Simd8 on AVX2, Simd4, Scalar; no live - control, live controls, between render calls; quanta 128 and 100; signed feed; windows from the - next block boundary after each ramp's end); `crates/capi/src/runtime/live_tests.rs` - `a_following_send_leaks_nothing_of_a_stored_mute`; `hosts/host-web/tests/control_parity.rs` - `a_following_send_leaks_nothing_of_a_stored_mute_on_both_hosts` (both hosts' bits equal per - block, and each against its static twins). - - Gate 2: `the_send_gates_on_the_timeline_sample_the_strip_mutes` (input and post_fader taps, - quanta 128 and 100, every width): mute and unmute start on the strip's output sample - (`step + L`), same length (480) for the input tap. - - Gate 4(a): `a_rebuild_that_adds_a_mute_entry_keeps_the_sends_ramp` (live predecessor - mid-ramp, six -> seven: bit for bit the never-swapped twin until the step, no join write, every - block after 0 allocation- and free-free by thread counters and the render audit). - - Gate 4(b): `a_rebuild_that_removes_the_mute_entry_reopens_the_send` (seven -> six: silent - before the swap, ramps in `[768, 768 + M)`, fresh-plan bits from the next boundary, quiet). - - Combined (obligation 3): `a_send_edit_and_a_mute_entry_in_one_rebuild_adopt_over_one_length` - is **red and `#[ignore]`d**: the send reaches `+0.0` at 1007 (768 + 239, the join record's - `SendGain` length, not `M`), which the graph fold (U2a) addresses; and the strip lane does not - mute at all at the swap (the carried strip lane does not adopt a new mute curve: #1651 T4). - - Charge (obligation 4): `the_following_route_charge_covers_its_attach_and_bind`: charged 922, - retained 683 bytes (x86-64), one route, a seven-word cell. - - Rebuild gate: `crates/capi/src/runtime/live_tests.rs` `an_edit_of_a_following_route_rebuilds`. - - Gate 5: `crates/control-plane/tests/follow_automation_realtime.rs` - `a_following_send_of_a_stored_mute_renders_allocation_free`. -- **Red checks (temporary mutations, reverted).** No lane attached (predicate false): gates 1 (all - three hosts), 2, 4(a), 4(b), 5, the charge and the rebuild gate red. Events at the fader's - arrival (`a(tap).max(486)`): gate 2 red (send at 1972). Carry drops the ramp (no join for a - following route): 4(a) red. A record on a change of kind (prior with `automated = [false; 2]`): - 4(a) red. Classifier gate removed: the rebuild gate red (result 255, internal fault). -- **Realtime.** `realtime_policy`: 167 marked regions (166 before; `follow_mute_jump`). - -## Decision record: U2a fold 1 (batch 18) - -Fold of the U2a review (FAIL at `3d063f0ed`) under root ruling R18-5 (option (b), per-column -ramps). Files: `crates/graph/src/{lib.rs,runtime.rs}`, `crates/lane/src/kernels.rs` (one entry), -`crates/lane/tests/route_ramp.rs`; mechanical caller update in `crates/host-core/src/prepare.rs`. - -- **MAJOR-1 (activation offset).** `RouteActivity` gains `from: Box<[u32]>`, one offset per route, - allocated at bind only for a plan that binds a following route (empty otherwise). - `LiveFollowRoute::begin_block` returns `Option`: `None` inactive, `Some(o)` mixes from `o` - (`Some(0)` when active at entry). `execute_op` writes the bit and the offset each block. - `reduce_gated` / `reduce_gated_into` reduce the destination over frame ranges cut at the active - routes' offsets: a route counts on a range only from its offset on, so `[0, o)` leaves it out and - `[o, q)` sums it. With no offset inside the block the whole block is one range (same segments, - same kernels). The reduction kernels take a frame range (`get`/`get_mut`, no new trap). The - offsets are charged in `route_control_resources` through the following producers - (`activity_offset_bytes`, one `u32` per prepared route, once per plan). -- **MAJOR-2 / MINOR-1 (R18-5).** `LiveFollowRoute` holds `ColumnRamps`: one `IndexedRamp` and - position per source column (left: `ll`, `rl`; right: `lr`, `rr`). A retarget changes only a - column whose target bits change, from the coefficients it is at. A column's new ramp holds the - target in the other column's entries, so only its own entries decide ramp or step. New kernel - `lane::kernels::route_mix_column_ramps_block`: three parts (both columns ramp, one ramps, both - settled = `mix2x2_block` of the two targets), vector body at `L` (simd128 / NEON / AVX2) and one - outlined non-generic `f32` tail, as `route_mix_ramp_block`. With one ramp in both columns at one - position it is `route_mix_ramp_block` bit for bit. A settled following route therefore mixes the - static six-word plan's bits. Six-word routes are untouched (code, bytes, bits). -- **MAJOR-3 (carried record order).** `LiveFollowRoute::adopt` takes the predecessor's column - ramps, mute and heard curve (`[false; 2]` from a six-word predecessor). On the carried first - block, `FollowCursors::block` seeds the cursors at `τ_tap` but returns the placed curve. The cell - applies first, composed with the heard curve, over its own length; then the placed curve composes - and each changed column retargets over `J`. Seven -> six: the side-table check carries the - columns the heard curve zeroed. `finish_six_word_carry` applies a record composed with them - (`LiveRoute::apply_pending_heard`), then reopens to the record's own target over `J` on a bits - change; with no record, to the prepared target as before. The check is left whenever the bits - differ or the heard curve zeroes a column. -- **MINOR-2.** `adopt_predecessor` first runs the predecessor's pending comparisons - (`finish_route_carry` under `LiveSnapshot::ALL` and its held jump lengths) when that - predecessor was displaced before its first block. Not gated in graph (two swaps with a following - route need #1652c's attach). Proof comment at the call; the host-core carry tranche gates it. -- **MINOR-3.** `RouteCarryStep` is `{ successor, predecessor }` (8 B, as before #1652d). The checks - live in `GraphExecutor::route_checks: Option>`: one `RouteCarryCheck` - per move whose predecessor row is a following route (`GraphLiveRoute::following` -> - `GraphRouteMove::predecessor_follows`). `None` when no such move exists. - `can_carry_live_routes` refuses a seven -> six move without its slot. - `route_carry_retained_bytes(moves, following)` charges both. -- **NIT-1.** Recorded at `FollowCursors::block`: with no jump lengths or no node span the block has - no events, nothing is placed and the curve holds. A fresh route keeps its bind seed. A carried - route keeps the predecessor's heard curve, and with no lengths its compare is a step (`J = 0`). - Production plans always attach both. -- **Departures / limits.** (1) A six-word successor holds one ramp, so the seven -> six cases are - not per column. A record whose heard-curve composition differs from it reopens the whole route - over `J`, and the record's open columns take `J`, not `R`. Predecessor columns in different moves - become one ramp from where they are to the composite target over the longer remaining length - (`ColumnRamps::one_ramp`), which re-times the shorter move. (2) A route that opens inside a block - and is muted again by a seek's `Set` in the same block still sums `[set, q)` (no end offset). - (3) A column's ramp steps on a non-finite difference of its own entries only; the shared law - stepped all four. (4) `GraphRouteMove` and `GraphLiveRoute` gain one public field each. - host-core's join, its charge call and one unit test are updated mechanically - (`crates/host-core/src/prepare.rs`). -- **Measured (x86-64).** `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 296 -> 352 (`ColumnRamps` 112). `RouteCarryStep` - 40 -> 8. `RouteCarryCheck` 40 and `RouteCarryChecks` 24 (side table, automation sessions only). - `SixWordCarryCheck` 32. `RouteActivity` 64 -> 80: the `from` box header, in every plan with a - live route, charged by `size_of` in `route_activity_bound_bytes`. `LIVE_ROUTE_OWNER_BYTES` 72 - (unchanged). Realtime policy: 167 marked regions (166 at base; one new region for `ColumnRamps`). -- **Gates (all pass).** `crates/graph/src/runtime.rs` `follow_route_op_tests::carry::fold`: - `an_opening_route_is_left_out_of_its_destination_before_its_offset` (MAJOR-1, through - `execute_op`, negative feeds, `o` in {1, 63, 64, 127}, sole / first / second input); - `an_open_columns_move_keeps_its_length_across_a_gate_changing_carry` (MAJOR-2); - `per_lane_steps_thirty_samples_apart_each_end_with_the_mute_length` (MINOR-1); - `a_carried_record_composes_with_the_heard_curve_then_the_curve_jumps` (MAJOR-3, seven-word); - `a_six_word_successors_record_composes_with_the_heard_curve_then_reopens` (MAJOR-3, six-word). - `crates/lane/tests/route_ramp.rs`: - `equal_column_ramps_are_the_six_word_kernel_and_settled_ones_the_static_mix` and - `each_column_follows_its_own_ramp` (`each_lane!`: `f32`, `Simd4`, `Simd8` under avx2). Existing - #1652b/#1652d cases now expect per-column retargets (gate 3's reference keeps an unchanged - column's ramp; six -> seven (b) keeps the right column's move). -- **Red checks (temporary mutations, reverted).** No offset written -> MAJOR-1 red. M2 (drop - `set_active` for `LiveFollowRoute`) -> MAJOR-1 red. One shared ramp (any change restarts both - columns) -> MAJOR-2, MINOR-1, MAJOR-3 seven-word, gate 3 and six -> seven red. Curve placed - before the record -> MAJOR-3 seven-word red. Record governs alone in `finish_six_word_carry` - (the pre-fold code) -> MAJOR-3 six-word red. M1 analogue (drop the `return` after the record) - -> MAJOR-3 six-word red. Kernel: right column read from the left ramp -> `each_column...` red. - The one-ramping part assigning the ramping column's target -> `each_column...` red. - -## Decision record: U2a fold 2 (batch 18) - -Fold of the U2a fold-1 verification (FAIL narrow at `977d6969c`), the last fold round for #1652. -Root (2026-10-17) accepted the plan as written: MAJOR-A is a conservative flag, gated both ways, -the exact form is #1713 (phase 2); MINOR-B must land. - -- **MAJOR-A (seven -> six re-times a live move and reported EXACT).** Two render forms, both - only on a carry of a following route into a six-word route: (a) `finish_six_word_carry` reopens a - record composed with the heard curve over `J`, not the record's `R`; (b) `ColumnRamps::one_ramp` - folds two column moves into one ramp over the longer remaining length. Shipped the B17-R14 - pattern (control-time `CandidateOutcome::TransitionFallback`, no render edit): host-core's send - join `resolve_route_carry` now reads the successor row's `following` too and sets - `RouteCarry::follow_into_six_word` when a move's predecessor follows and its successor does not; - the plan's inventory keeps it (`PlanStateInventory::carries_a_following_route_into_a_six_word_route`, - one `bool`, no heap byte), and the control plane's rebuild (`crates/control-plane/src/control.rs`) - marks the candidate `TransitionFallback` when it or B17-R14's level condition holds. Both hosts - run that one commit. The flag does not open B17-R14's level-move window (it is not a level move: - the `landing` fold now reads the level condition alone, which keeps B17-R13 tranche 5's rule - as it was). **Conservative:** it over-reports on every rebuild that removes a strip's last mute - automation while a following send of it exists, moving or not; #1713 - (`.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md`) - gives the exact form and deletes the flag. Outcome doc text updated in - `crates/capi/src/abi.rs`, `crates/capi/include/miso_engine_v1.h`, - `docs/C_ABI_V1_QUALIFICATION.md` and the two shipped-host `.d.ts` copies. -- **MINOR-B (kernel panic path).** `route_mix_column_ramps_block` splits each rest at - `one = min(either - both, min(left_rest.len(), right_rest.len()))`, so the wasm `f32x4` - instantiation keeps no `mid > len` path. No bit moves (`either - both` is never above the - rests' length; `route_ramp` passes). -- **MINOR-C.** `an_opening_route_is_left_out_of_its_destination_before_its_offset` runs every case - twice: into a submix (`reduce_gated`) and into the session Output (`Some(HostMaster)`, - `reduce_gated_into`, host planes pre-filled with stale words). -- **NIT-D.** New gate for the carried first block's `else if !automated { *curve = false }`: a - three-plan chain (P1 automates both lanes and hears the left curve on; P2 automates only the - right; P3 automates the left again and gets a join record). The record composes with P2's heard - curve, so both columns move over `R`. -- **NIT-E (fold 1's MINOR-2) is reachable and gated in host-core.** A render call that adopts a - plan at the boundary and is then refused (a time discontinuity) displaces nothing yet, but the - plan has rendered no block; the next swap adopts from it. The proof comment at - `adopt_predecessor` names the gate. -- **Record (c), precedent.** A route that opens inside a block and is muted again by a seek's - `Set` in the same block sums `[set, q)` (no end offset), as the #1217/#1220 rule sums a six-word - route that a block mutes: a precedent, not a new limit. -- **Record (d), departure for root to accept (accepted 2026-10-17).** `RouteActivity` grows by - 16 B (the `from: Box<[u32]>` header) once per plan that has live or gated routes, automation or - not: measured `size_of::()` 80 (64 before, x86-64), charged by `size_of` in - `route_activity_bound_bytes`. This departs from R18-5's literal "no new bytes". The offsets - themselves (one `u32` per prepared route) are allocated and charged only in a plan that binds a - following route. `LIVE_FOLLOW_ROUTE_OWNER_BYTES` 352 and `LIVE_ROUTE_OWNER_BYTES` 72 unchanged. -- **Paths.** `crates/host-core/src/prepare.rs` functions touched: `resolve_route_carry` (the flag), - `prepare_host_runtime_with_live_controls_policy_and_spectrum` (one inventory field line), - `struct RouteCarry` (the field), `PlanStateInventory` (the field, a new accessor - `carries_a_following_route_into_a_six_word_route`, and `false` in `at_discontinuity` and - `test_only_floors_only`), and the test module (`live_routes_join_by_route_id`, one inventory - literal, new `the_join_flags_a_following_route_carried_into_a_six_word_one`). No strip-join - code. Mechanical, - outside the listed paths: `crates/control-plane/src/control.rs` (the outcome term), the outcome - doc text above, `crates/graph/src/lib.rs` (a proof comment only). -- **Gates (all pass).** - - MAJOR-A: `crates/capi/src/runtime/live_tests.rs` - `a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback` (seven -> six sent at - block 8 while the send's columns ramp toward the curve's mute -> `TRANSITION_FALLBACK`, count 1; - seven -> seven and no following route -> `EXACT`, count 0); - `hosts/host-web/tests/control_parity.rs` - `a_rebuild_that_drops_a_following_route_to_six_words_reports_the_fallback_alike` (the same three - cases on both hosts, the browser status carries the C ABI's watermark, same bits); - `crates/host-core/src/prepare.rs` `the_join_flags_a_following_route_carried_into_a_six_word_one` - (the four kind pairs). - - MINOR-C: `an_opening_route_is_left_out_of_its_destination_before_its_offset` (graph). - - NIT-D: `a_heard_curve_on_a_lane_the_successor_no_longer_automates_is_forgotten` (graph). - - NIT-E: `crates/host-core/tests/route_mute.rs` - `a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison` (Simd8 on AVX2, Simd4, - Scalar; every render after block 0 allocation- and free-free). -- **Red checks (temporary mutations, reverted).** Control plane ignores the follow flag -> both - host gates red (`(flags, count)` `(1, 0)` against `(2, 1)`). The join flags on the predecessor's - kind alone -> the host-core unit gate red ("seven -> seven") and the capi gate red (seven -> - seven reports the fallback). `reduce_gated_into` sums every active route over the whole block - (the verifier's `MAJ1_into_only`) -> MINOR-C red. The `*curve = false` line removed (the - verifier's `unautomated_curve_kept`) -> NIT-D red. `adopt_predecessor` skips the predecessor's - pending comparisons -> NIT-E red ("the send reopens from the block C first renders"). - -## Decision record: batch 18 cleanup (fold 2 NITs) - -Test and doc only. NIT-1: `crates/capi/src/runtime/live_tests.rs` -`a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback` gains a second rebuild -(`UpsertAutomation` of a fader ride on `eq0`) sent in the block after the flagged seven -> six -rebuild's adoption, inside `J`; it completes `OUTCOME_EXACT` with the fallback count still 1. Red -when `landing` in `control.rs` is opened by `follow_into_six_word` too (the watermark reads -`(2, 2)`). NIT-2: `route_mute.rs` `a_successor_of_a_plan_that_never_rendered_takes_its_pending_comparison` -measures the refused, adopting `render_contiguous` call with the thread allocator counters and the -render audit (zero); red when a throwaway `Vec::with_capacity` is added inside the window. NIT-3: -the docs of `PlanStateInventory::carries_a_following_route_into_a_six_word_route` (`prepare.rs`) -and the B17-R14/MAJOR-A comment in `control.rs` state the rule (a route whose source strip automates a -mute lane in the committed plan and none in the successor, so the successor gives it the -six-word cell), not the one example; `follows_mute` does not decide the cell. diff --git a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md deleted file mode 100644 index 322f2e202..000000000 --- a/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md +++ /dev/null @@ -1,297 +0,0 @@ -# Write a following send's route cell from the shared commit - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), answers A6 and A10, under decision 15 D15-16 -(`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as #1651 -*Render stored mute automation on the strip* and #1652 *Follow an automated mute on following sends -in render*.** - -## Product outcome - -On a send that follows a strip whose mute the session automates, every live edit that changes the -send's gate reaches it, on both hosts, through the shared commit: a route gain, matrix or mute edit, -a live `follows_mute` toggle, and a change of the source strip's static mute, VCA mute or solo. Each -writes the route's seven-word cell (#1652 D1) and gives no folded follow record, so render -composes the gate with the curve at the next event, and nothing a live edit writes can contradict -the curve. Solo and VCA mutes compose with the curve for the send as for the strip. - -## Context - -- **The C ABI's route records.** *Deliver value-only send edits to the running C ABI plan* (#1225) - D3 emits route records from `route_target(model, route)`; *Let C ABI sends follow their source - strip's mute live* (#1226) D2-D3 makes `follows_mute` live and builds follow records from each - strip's effective mute; #1226 D7 adds `LiveRouteState::set_follows_mute`. #1226 D6 leaves the - follow of an automated mute to #1058. -- **The browser.** *Make a send's follows_mute live in the browser* (#1342) D1-D2: the browser - receives the edit only as a transaction through the Worker's committed model, and its follow - records come from the shared commit. *Admit browser live edits in the Worker through the committed - model* (#1382) D3 composes the solo overlay and the route mirror in the shared commit. -- **The mirror.** `LiveRouteMuteFollow::delta` (`crates/host-core/src/live_route_state.rs:232-257`) - yields only changed routes; the mirror holds `follows_mute` and `source_lane_muted` (`:46-53`). -- **The cell.** #1652 D1: for a route into a submix whose source strip has an automated mute - lane, the cell holds the open transform, a flags word (route `mute`, `follows_mute`, `terms[2]`, - `automated[2]`) and the ramp. - -## Decisions frozen for this slice - -- **D1. Route edits write the cell.** For a route of #1652 D1, the shared classifier emits one - route-cell write instead of the route records that #1225 and #1226 emit for that route (#1226 adds no record type - of its own, #1226 D3). It carries the - open transform from `route_values` (`crates/graph-compiler/src/ids.rs:309-322`), the route - `mute`, `follows_mute` and `terms`, and the ramp from #1226 D5's rule. It writes only when a word's - bits change. The path is `live`. -- **D2. Terms.** `terms[lane]` is, for an automated source lane, `vca_mute || solo_mute`; for a lane - that is not automated, its whole effective mute (#1247 D2's effective mute, with #1382 D3's solo - term). A change of any of them writes the cell. On the C ABI the overlay is absent, so the solo - term is false; the code is the same on both hosts. -- **D3. `follows_mute`.** A toggle writes the cell and, through #1226 D7's setter, the mirror, in the - same shadow as #1382 D3 requires, so a later solo change composes with the new flag. -- **D4. No second path.** #1225, #1226, #1342 and #1347 are not amended: they land before this slice, - and this slice adds the branch for routes whose source mute is automated to the code they built. -- **D5. The acked-batch question: can an ack ever precede a drop? No.** Every check of a - transaction and of the overlay precedes the first cell write; writes cannot fail; render - generates curve events from the plan. - -## Deliverables - -1. D1-D3 in the shared classifier and the commit path, with tests. - -## Authorized paths - -- `crates/host-core/src/{live_delta.rs,live_route_state.rs}`, - `crates/host-core/tests/{live_delta.rs,live_routes.rs}` -- `crates/control-plane/src/` (the route-cell write and the overlay composition) -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs` - -## Non-goals - -- The route lane, its layout and the route op (#1652). Send automation (OQ2). - -## Hazards - -- **One push with #1651 and #1652.** Root merges the three together. -- **A redundant write moves bits** (README "Change only"). D1 writes only changed words. - -## Objective gates - -1. **`follows_mute` toggle** (both hosts, new). On a playing engine with `t` automated-muted, a - transaction (C ABI) and a browser edit turn `follows_mute` off, then on: each is `live`; after - the route ramp the output equals a fresh plan of the committed model. -2. **Composition** (browser, new). Solo on another track mutes `t` by solo while its curve is 0; the - send follows; unsolo restores the curve's state. A VCA mute of `t` composes the same way on both - hosts. A route into the output never follows. -3. **Edits** (`crates/host-core/tests/live_delta.rs`, new). A `follows_mute` toggle on a #1652 - route gives one route-cell write and no follow record; a static mute change on the source's - non-automated lane gives that lane's `Mute` record and one route-cell write; a route gain edit - gives one route-cell write with the new transform; an edit that leaves every word unchanged gives - nothing. -4. **Commands:** - - `cargo test --locked -p host-core --features host-core/test-support`, - `cargo test --locked -p control-plane --features test-support`, `cargo test --locked -p capi`, - `cargo test --locked -p host-web --features host-web/test-support` - - the workspace debug leg (`test-debug-a`) in `.github/workflows/qualification.yml` - - `bash scripts/check-host-core-policy.sh`, `bash scripts/check-workspace-policy.sh` - - `bash scripts/check-cross-targets.sh` (README F19, the iOS memset rule: no new - `memset_pattern16` call; fix one in code, never by a ceiling) - - `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`, - `cargo fmt --all -- --check` -5. **No rendered bit moves** for a session with no stored automation: `cargo build --locked - --release -p audit -p capi && ./target/release/audit capi` gives the same `pcm_digest` at base - and head (PR evidence); the browser legs pass with unchanged digests. - -## Test value - -- Gate 1: red if `follows_mute` is still read from the prepared route only, so a live toggle is lost - on an automated route. -- Gate 2: red if solo or a VCA mute overwrites the curve instead of composing with it for the send. -- Gate 3: red if an edit still emits a folded record that the curve's next event would contradict, - or writes an unchanged cell. - -## Dependencies - -Batch R2. Direct dependencies: - -- #1652 *Follow an automated mute on following sends in render* (same push). -- *Make a send's follows_mute live in the browser* (#1342): the browser's `follows_mute` edit. It - brings #1225, #1226 and #1382. - -## Amendment (controls coordinator, 2026-10-14) - -Checked against `12d4a4f9d`. - -- **The mirror is gone.** `LiveRouteMuteFollow` and `crates/host-core/src/live_route_state.rs` are - deleted; the send gate is `controls::effective`'s (C7), and route records come from - `route_records` (`crates/host-core/src/live_delta.rs:1416`) and `RouteControlProducer` - (`crates/host-core/src/route_controls.rs:201`). D1-D3 apply to that code. -- **`terms`** for an automated source lane is `static_mute || VCA mute || solo term` (OQ1 = C); - solo exists on both hosts (C8). -- **#1342** is superseded by #1562 (its gate 1). #1225, #1226, #1347 have landed. -- Authorized paths: `crates/host-core/src/{live_delta.rs,route_controls.rs}`, - `crates/host-core/tests/live_delta.rs`, `crates/control-plane/src/`, - `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`. - -## Amendment (controls coordinator, batch 18 spec check, 2026-10-17) - -Checked against `ad03780ee`. The filed text above stays; where it disagrees, this section wins. - -**Anchors now.** -- The one record function of a send is `route_record_between`, - `crates/host-core/src/prepare.rs:4154-4193`. The classifier (`route_records`, - `crates/host-core/src/live_delta.rs:1467`) and the swap join (`join_routes`, `prepare.rs:5473`) - both call it. So the branch is in `prepare.rs`, which this slice now needs. -- The classifier lowers a route only when its rows changed or its effective gate changed - (`live_delta.rs:1019`). The gate is `controls::effective_gate` - (`crates/controls/src/effective.rs:381`). -- `route_record` and `RouteControlProducer`: `crates/host-core/src/route_controls.rs:146`, `:201`. -- The commit resolves route producers at `crates/control-plane/src/control.rs:3226-3242` and - writes them at `:3419-3421`, in the same stamp as the strip and jump-length writes. -- The in-place classifier (`classify_live_staged`, `live_delta.rs:565`) must equal the dense one - (#1613; `crates/host-core/tests/in_place_commit.rs`, R17-1). - -**How the slice maps onto the landed code.** -- **D1.** For a route that the next plan gives a seven-word cell (#1652 D1, from the `after` - model), `route_record_between` returns a seven-word record: the open transform from - `route_values` (#1652 makes it public), the flags word of #1652's amendment, and the ramp from - the existing length rule. `LiveRouteRecord` carries either a folded record or a seven-word one. - The join gets the same record with no second code. -- **Trigger (fix).** For a seven-word route, the classifier also lowers it when its source lane's - effective mute (`terms`) changes and the gate does not (for example while `follows_mute` is - off). So the cell always holds the committed words, and "writes only when a word's bits change" - is exact. -- **D2.** After OQ1 = C, `terms[lane]` is `controls::effective`'s source-lane mute, with the - overlay, for every lane (it holds no curve). Solo is on both hosts (C8). -- **D3.** The mirror and its setter are gone. `follows_mute` is an input of `controls::effective` - and the committed model is the shadow. A toggle writes the cell only. -- **D5 (the acked-batch question), made exact.** Before the first write, the commit checks that - each seven-word record goes to a seven-word producer, and that its `automated` bits equal the - producer's plan. Any mismatch is `CommandError::Internal`, before any write, so no ack precedes a - drop. -- **Watermark.** The path is `live` (`monitor` for solo), with the existing rule. The strip's - terms write (#1651) and the route's cell write of one transaction share one stamp and one block - snapshot, so they apply in the same block. No `TransitionFallback` is set. -- **FP (R17-7).** `route_values` runs inside the guards that R17-7 put at the commit entry and the - preparation entry. No new guard. - -**Gates (fixes).** -- Gate 2 runs on both hosts (solo is on the C ABI too): `hosts/host-web/tests/control_parity.rs`. -- Gate 1 also runs in host-core at `Backend::Simd8` (`cfg(target_feature = "avx2")`) and - `Backend::Simd4`, with the `Scalar` oracle: the classifier's records written through the - producers (`crates/host-core/tests/live_routes.rs`). -- Gate 3, rewritten for OQ1 = C: a `follows_mute` toggle gives one seven-word record and no folded - record; a hand mute on the automated lane gives #1651's terms write and one seven-word record; a - hand mute on the other lane gives that lane's `Mute` record and one seven-word record; a route - gain edit gives one seven-word record with the new transform; a terms change while - `follows_mute` is off gives one seven-word record; an edit that leaves every word unchanged - gives nothing. The same cases pass through the in-place path: add a session with a mute entry - and a following send to `crates/host-core/tests/support/edit_sessions.rs`. - -**Authorized paths after the check.** -- `crates/host-core/src/prepare.rs` (`route_record_between`'s branch only; after #1652), - `crates/host-core/src/live_delta.rs`, `crates/host-core/src/route_controls.rs`, - `crates/host-core/src/lib.rs` (re-exports only). -- `crates/host-core/tests/live_delta.rs`, `crates/host-core/tests/live_routes.rs`, - `crates/host-core/tests/support/edit_sessions.rs`. -- `crates/control-plane/src/control.rs` (the route write and its pre-write check). -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, - `hosts/host-web/src/tests.rs`. -- No render code and no path under `crates/engine/src/realtime/`. - -## Decision record: #1653 (batch 18) - -Replaces #1652c's stopgap (`RebuildOnly(RuledLiveNotBuilt { issue: 1653 })` in `route_records`); -no placeholder remains. No render code, nothing under `crates/engine/src/realtime/`. - -- **D1 (classifier, `crates/host-core/src/live_delta.rs`).** `route_records` lowers a route the - next plan gives the seven-word cell through `follow_route_record_between` (terms from the diff, - `automated` from the structure's `controls::mute_automation`), any other route through - `route_record_between`. `LiveRouteRecord::record` is now `SendRecord` (`Folded` | `Following`), - public and re-exported (`host_core::SendRecord`, with `folded()` / `following()`); the swap - join's `SendRecord` is the same type. The path is `live`. -- **Trigger (fix).** `LevelDiff` gains `terms(before, after)`: `EffectiveDiff::strip_mutes` (dense) - and the held source strip's `EffectiveStrip::mute` (`LocalDiff`, fast path). `lower_routes` - lowers a following route also when its terms change with the gate unchanged - (`follows_mute` off); `LocalDiff::new` holds such a route when its source's mute moved, whatever - its `follows_mute`. A record is written only when a word's bits change - (`follow_route_record_between`'s rule, #1652c). -- **D3.** A `follows_mute` toggle is a route row change and writes the cell only (no mirror). -- **D5 (control plane, `crates/control-plane/src/{compile.rs,control.rs}`).** `StripLanes` keeps - `follow_routes` (the handles' `follow_route_controls`, no longer dropped by `..`). Step 2a - resolves a `Folded` record among the six-word producers and a `Following` one with - `host_core::follow_route_producer_position`, and refuses with `CommandError::Internal`, before - any write, a record with no producer of its kind or whose `automated` bits differ from the - producer's plan. The writes go under the transaction's one stamp, with the strip's terms - (`Mute`) records, so they apply in one block. No `TransitionFallback`. -- **Departure (mechanical, outside the listed paths).** `crates/graph/src/lib.rs`: - `GraphFollowRouteControlProducer` keeps the bound source's `flags.automated` (one - `[bool; 2]` field set in `follow_route_control_pair`) behind `automated()`, which D5's check - reads; the producer is charged by `size_of`, so its charge follows. `TestTransactionSnapshot` - (test observer) gains `follow_route_cells`. `crates/host-core/tests/in_place_commit.rs` gains the - differential case (the `Pair` harness lives there). -- **`crates/host-core/src/prepare.rs` functions touched.** `SendRecord` (public, accessors, doc), - `follows_automated_mute` (now `following_route_lanes(..).is_some()`), new - `following_route_lanes` (the predicate and the `automated` bits in one place). The join and - `route_record_between` are unchanged. -- **Gates (all pass).** - - Gate 1: `crates/capi/src/runtime/live_tests.rs` - `a_follows_mute_toggle_on_a_following_route_is_live`; `hosts/host-web/tests/control_parity.rs` - `a_follows_mute_toggle_on_a_following_route_is_live_on_both_hosts`; - `crates/host-core/tests/live_routes.rs` - `a_follows_mute_toggle_on_a_following_route_settles_on_a_fresh_plan` (Simd8 on AVX2, Simd4, - Scalar oracle; the classifier's records written through the seven-word producers; every width - equals the oracle bit for bit). - - Gate 2: `hosts/host-web/tests/control_parity.rs` - `solo_and_a_vca_mute_compose_with_the_curve_for_a_following_send_on_both_hosts` (solo path - `monitor`, VCA mute `live`; a pre-fader route into the output still plays under the mute). - - Gate 3: `crates/host-core/tests/live_delta.rs` - `a_following_routes_edits_write_its_seven_word_cell_only` (every case also through - `classify_live_staged`, equal to the dense delta); in-place path: - `crates/host-core/tests/in_place_commit.rs` - `a_following_routes_edits_commit_as_the_full_path_does` over - `edit_sessions::following_session` / `following_script` (48 and 96 kHz). - - Superseded rebuild gate `an_edit_of_a_following_route_rebuilds` replaced in place by - `an_edit_of_a_following_route_is_live` (send gain and hand mute: `live`, the committed - document's fresh bits after the ramps). -- **Red checks (temporary mutations, reverted).** Folded record still emitted for a following - route -> gate 3, host-core gate 1, both capi gates and both parity gates red. Terms trigger - missing in both classifiers -> gate 3 red ("one seven-word record", 0). Terms trigger missing in - the fast path only -> the in-place differential red. The commit skips the seven-word write -> - both capi gates and both parity gates red. The pre-write `automated` check removed -> no test - turns red: every record and every producer of a live commit derive `automated` from the same - committed structure (automation is structure, so a value commit cannot change it, and the - newest epoch is prepared from that structure), so the refusal is unreachable through the public - paths; it stays as D5's defence. - -## Decision record: batch 18 cleanup - -Test and doc only (U2b review PASS; U2a fold 2 verification PASS). No render, control or -preparation logic changed. - -- **MINOR-1 (the seeded `mute` bit).** `crates/host-core/tests/route_mute.rs` - `a_following_send_leaks_nothing_of_a_stored_mute` gains the case `model(128, PreFader, false, - true)` with `routes[1].mute = true` and a `mute_entry(0.0, &[(3_000, 1.0)])`, `Live::None`, at - Simd8, Simd4 and the scalar oracle: the send plane is exact `+0.0` while the strip is heard. - Red with `mute: false` in the seeded `RouteFollowFlags` (`prepare.rs`): "muted route Simd8: a - muted following send leaks". -- **MINOR-2 (the join's `before` terms).** New `route_mute.rs` - `a_rebuild_that_hand_mutes_the_source_ramps_the_send_over_the_edits_length` (all three widths; - the strip's reach of `+0.0` asserted on the bank widths only, as the scalar oracle restarts its - strip). A rebuild from an open `t` with a held-open mute entry to a hand-muted `t`, with the - transaction's `(Strip t, Mute)` edit ramp at 200 (not `M` = 480): the send reaches exact `+0.0` - at the swap block plus 199 or 200. Red with `[m[1], m[1]]` for the join's `terms` - (`join_routes`): the send reaches `+0.0` at 479. The helper `follow::live_swap_ramped` takes the - `EditRamps`; `live_swap` forwards with none. -- **NIT-1 (capi_retained +96 B).** Read from the code, not re-measured: `StripLanes` gained - `follow_routes: Box<[GraphFollowRouteControlProducer]>`, a 16-byte pointer and length held - inline in every `ProviderEpoch`. The control row charges the layout of `PENDING_PROVIDERS` (2) + - `RETIRED_PROVIDERS` (3) parked epochs, and the running epoch is inline in the charged - `SessionState`: six epochs x 16 B = 96 B (168,650 on `main`, 168,746 at head). An empty box - allocates nothing. `crates/capi/tests/resource_lifecycle.rs` explains no row by number, so its - doc is unchanged. -- **NIT-2 (realtime gate).** `crates/control-plane/tests/follow_automation_realtime.rs` - `a_following_send_of_a_stored_mute_renders_allocation_free` now commits the send's - `follows_mute` off at block 9 and on at block 10 (`SetControl` on the route's row 7, each - asserted `EditPath::Live`), inside the muted span, with every render block still allocation- - and free-free; the silence check moved from block 13 to block 18 so the toggles' ramps end - first. Red with the "on" write dropped (the send stays open: "the send follows the curve's - mute"). diff --git a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index 460aaf48b..2b6065987 100644 --- a/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -237,3 +237,161 @@ The rule is the one of #1650, for route row 1 `gain_db` on a route into a submix route row 1 binding and its owner), `crates/controls/tests/**`, and the fields #1650 introduces. `docs/SESSION_SCHEMA_V1.md` names the send gain as taking the offset. The batch R2 order stands: this issue follows #1650 and #1651 to #1653. + +## Amendment (controls coordinator, batch 19 spec check, 2026-10-17) + +Checked against `323433f6a`. The filed text and the amendments above stay; where they disagree, +this section wins. #1651, #1652 and #1653 are closed; read their decision records by number. + +**Anchors now.** +- Rows: `crates/controls/src/route.rs:22-28` (IDs), `:31` (`OUTPUT_FOLD`), `:101-218` (`ROWS`). + Row 7 `follows_mute` is `AutomationRule::Excluded` (#1632 landed), so D7 holds as amended. + `automatable`: `crates/controls/src/descriptor.rs:154`. +- Rendered rows and offsets: `takes_offset` `crates/controls/src/automation.rs:51`, + `offset_faults` `:62`, `automation_row_renders` `:400`, `mute_row` `:407`, `fader_row` `:419`, + `automated_cell` `:484`, `rendered_automation_changed` `:527`, `continued_fader_lanes` / + `continued_mute_lanes` `:573`/`:586`. Code `control.automation.offset`: + `crates/controls/src/error.rs:37,74`. Docs: `docs/SESSION_SCHEMA_V1.md:337-341`. +- Owner edits (R17-1): `OwnerEdits::set` `crates/controls/src/owner_edits.rs:84` (fader branch + `:92-97`), `take_fader_automation` `:105`; row read `crates/controls/src/registry.rs:224`, write + `:565-567`; `ModelView::fader_automation` `crates/controls/src/view.rs:111`; + `write_fader_offset` `crates/controls/src/builtin_rows.rs:155`; `mask_fader_offsets` + `crates/controls/src/mask.rs:138`. +- The classifier no longer masks at `live_delta.rs:359`. Step 3 (`rendered_automation_changed`) is + `crates/host-core/src/live_delta.rs:423`, `lower_routes` `:995`, `route_records` `:1517`. +- Records: `route_record_between` `crates/host-core/src/prepare.rs:4288`, `follow_route_record` + `:4371`, `follow_route_record_between` `:4410`; predicate `following_route_lanes` `:5081`; + attach `:2873-2897`; join `join_routes` `:5829`, `resolve_route_carry` `:5919`. +- One program: `prepare_fader_automation`, `crates/builtins-compiler/src/lib.rs:2618-2752` + (fader cells, then mute cells); `strip_mute_cells` `:5808`. +- Graph: `ROUTE_CELL_WORDS` `crates/graph/src/lib.rs:1010`; `FOLLOW_ROUTE_CELL_WORDS` `:1136`; + `RouteFollowFlags` `:1146` (bits 0-5 used, bits 6-31 spare and refused by `from_word`); + `FollowRouteControlRecord` `:1221`; `GraphFollowRouteSource::new` `:1390` (refuses a route with + no automated source lane). Render: `crates/graph/src/runtime.rs` `LiveFollowRoute` `:1287`, + `ColumnRamps` `:1318` (`retarget` `:1374`, `one_ramp` `:1413`), `FollowCursors` `:1442` + (`block` `:1568`), `follow_target`, `follow_walk`, `apply_cell` `:1698`, `begin_block` `:1745`, + `mix` `:1815`, `finish_six_word_carry` `:5733`. Kernel `route_mix_column_ramps_block` + `crates/lane/src/kernels.rs:1820`. +- dB law: `route_transform` `crates/graph-compiler/src/ids.rs:293` (`math::db_to_gain_f32`), + `route_values` `:330`. Ramp rows: `crates/effect-contract/src/control.rs:76,89,101`. +- Commit: pre-write check `crates/control-plane/src/control.rs:3260-3290` (compares the record's + `automated` with `GraphFollowRouteControlProducer::automated()`); #1713 flag `:2540`, `:2659`; + B17-R14 term `automates = fader_row` `:2648-2651`. + +**How the slice maps onto the landed code.** +- **Rows (D3, R17-6).** `automation_row_renders` gains route rows 1-6 on an `Owner::Route`, + `Section::Own` target (an output route's entry is refused at `.target` first, D8). `fader_row` + stays the fader's only predicate, so no route entry reaches the fader cell, the fader offset or + B17-R14's window. `takes_offset` gains route row 1. `offset_faults` also refuses a route row 1 + entry whose `left_db` and `right_db` bits differ (`control.automation.offset`, at + `.offset.right_db`). New `route_automation(model, route) -> [Option<&Automation>; 6]` (rows 1-6) + is the one enumeration that preparation, the classifier and owner edits read. +- **Offset (R17-6).** Mirror the fader: `OwnerEdits::set` takes the route's row 1 entry with the + route; `registry` reads and writes the offset of an automated row 1 (both fields, one value); + `mask_values` copies route row 1 offsets as it copies fader offsets; the list sets `automated` + on rows 1-6 that an entry drives. +- **Program.** The route cells join the plan's one program in `prepare_fader_automation`, after + the mute cells, in `model.routes` order: rows 1 and 3-6 with `FADER_CELL_RULE` (grid 64, `End`), + row 2 with `MUTE_CELL_RULE` (step). New accessor `route_cells(route_id)`. The function now also + runs for a session whose only rendered entries are route entries. +- **Seven-word predicate (D2).** `following_route_lanes` becomes the predicate of "the plan gives + this route the seven-word cell": source mute automated, or any route row 1-6 automated. It returns + the plan's full automated set. `GraphFollowRouteSource::new` accepts a route with no source mute + cell when it has a route cell. +- **Flags word.** Bits 0-5 keep their meaning. New bits, fixed by the plan like bits 4-5: + bit 6 `automated_gain`, bit 7 `automated_mute` (the route's own row 2), bits 8-11 + `automated_matrix[ll, lr, rl, rr]`. `DEFINED` becomes `(1 << 12) - 1`. When bit 6 is set, word 0 + holds the hand offset in dB (`f32` bits), not a linear gain. `FollowRouteControlRecord::new` + still checks only finiteness, so the offset word fits. #1653's pre-write check compares the whole + automated set (bits 4-11) with the producer's, before any write; it stays D9's defence. +- **Records.** `follow_route_record` takes the route's row 1 offset when bit 6 is set. A + word of an automated matrix row is the static fallback and render ignores it, so a change of + only such words gives no record and commits `model_only` (D4). A change of the offset is a + record over `SendGain` (`live`). +- **The dB law.** Graph does not link `math` (pinned by `scripts/check-graph-policy.sh:21`). + Host-core hands the route op `GraphRouteGainLaw = fn(curve_db: f32, offset_db: f32) -> f32` + (`db_to_gain_f32(clamp(f64(curve) + f64(offset), -144, 24) as f32)`), defined in graph-compiler + next to `route_transform`, which calls it for the static gain. One derivation, no policy change, + in its own realtime-policy region (the `follow_mute_jump` pattern). +- **Route op (D2).** `FollowCursors` becomes one table of up to eight cursors: source mute L/R, + route gain, route mute, matrix ll/lr/rl/rr, each with its row's jump length (mute rule for the + mute cells, `SendGain` for gain, `SendMatrix` for the matrix; the jump-length rule returns all + three). `follow_walk` walks the table in offset order; the values it carries are `f32` (a mute + value is `!= 0`). The composition at each offset: open transform with + `gain = law(curve_gain, offset)` if bit 6, else word 0, each automated matrix entry from its + curve, others from the cell; gate `mute = static_mute || curve_mute` (D5), follow terms as + #1652. One `follow_target`; no second evaluator. A held event changes no value. The + retarget length of a column is the event's own `ramp` (`0` for a `Set`), see ruling R19-A for a + column with a move in flight. `ColumnRamps::retarget` takes one length per column. +- **Record jump (batch 10 amendment, #1649 D3 on the route).** At a record that changes the + offset or a value word, the target takes each automated value from its curve at + `completion_of(End, start, R)` (the `curve_toward` rule), and the route's value cells + `hold_until` that completion, so grid events inside the jump are held. A record that changes + only gate words keeps today's #1652 rule. +- **Carry (D3).** Today the follow cursors do not carry: a carried route places them at + `τ_tap` and compares over the mute `J`. For a gain or matrix cell that re-times a running + ride. Fix, as #1648 D2 / #1651 T4: a route cell whose table both plans hold with the same + segments (`continued_route_rows`) carries its `CellState` and heard value verbatim, by + `(route ID, row)`; another one is repositioned and jumps over its own row's `J` (specified + behaviour, B17-R14 note 1). Source mute cells keep today's placement (a step: exact). A live move + in flight at a Reposition is ruling R19-B. +- **#1713 flag.** `resolve_route_carry` reads the plan's `GraphLiveRoute::following`, so the + widened predicate flags every seven -> six carry, including the removal of a send's last route + entry. Conservative, no new code; #1713's exact form must cover route-row-only routes too. +- **Node time (R17-3), oracle (R17-4), FP (R17-7).** The route op takes `node_span(a(tap))` as + today; it mixes at `FrameLane` on every backend, so the scalar oracle and both bank widths run + the same op. Seeds and records compose on the control thread inside the existing + `CanonicalFpEnv` guards (prepare entry, commit entry); no new guard. + +**Gates (additions).** Gate 1 adds a flat route mute curve at `0` and a flat matrix entry, and +runs in host-core at `Simd8` (avx2), `Simd4` and the scalar oracle, and on both hosts. New gate 7: +a running gain ride across an unrelated rebuild (continued table) is bit for bit the +never-swapped twin (host-core, three widths). New gate 8: an entry on route row 1 with +`left_db != right_db` is refused `control.automation.offset`; a non-zero offset on rows 2-6 is +refused. Gate 3's `+3` move runs through the in-place and the full path with equal reply and +bits on both hosts (R17-6). Gates whose result depends on R19-A or R19-B are written after root +rules. + +**Rulings needed.** R19-A (a value move and a gate move on one folded column ramp) and R19-B (a +live send move in flight at a Reposition, once #1711 deletes B17-R14's window). See the batch 19 +report. + +**Authorized paths after the check.** +- `crates/controls/src/{automation.rs,route.rs,registry.rs,view.rs,owner_edits.rs,mask.rs, + builtin_rows.rs,lib.rs}`, `crates/controls/tests/**`. +- `crates/builtins-compiler/src/lib.rs` (route cells in the one program, `route_cells`; shared + with #1711 and #1655). +- `crates/graph-compiler/src/{ids.rs,lib.rs}` (the gain law and its re-export). +- `crates/graph/src/lib.rs` (flags, source, record, law and jump-rule types), + `crates/graph/src/runtime.rs` (the route op, its cursors and the route carry only). Render code. +- `crates/host-core/src/{prepare.rs,live_delta.rs,route_controls.rs,lib.rs}`, + `crates/host-core/tests/{live_delta.rs,live_routes.rs,route_mute.rs,in_place_commit.rs, + support/edit_sessions.rs}`. +- `crates/control-plane/src/control.rs` (the pre-write check width; the R19-B term if ruled), + `crates/control-plane/tests/send_automation_realtime.rs` (new). +- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `hosts/host-web/src/tests.rs`. +- `docs/SESSION_SCHEMA_V1.md`. +- Nothing under `crates/engine/src/realtime/`. No `crates/lane` kernel change unless R19-A picks + option (b). + +## Rulings (root, batch 19, 2026-10-17) + +These override the batch 19 spec-check amendment where they differ. + +- **R19-3 = (d).** A route column uses the fader's deferred-jump rule. While a column jump runs (a gate + change over the mute `J`, a record ramp `R`, or `0` for a Set), a gain or matrix grid event updates + the heard values but does not retarget that column; it sets the column's owed bit. At the jump's end + the column composes again; if the bits differ it lands over the value row's own `J` (`SendGain` or + `SendMatrix`, the longer one if both are owed), as #1711's deferred jump. A free column retargets at + once over the event's own ramp. A gate event always retargets at once. The hold is per column, never + per cell. No `max(own, remaining)` anywhere. Gates: a gain ride across an unmute fade ends the fade at + exactly `J` and lands at its end; a ride on the left column keeps moving while the right column fades. +- **R19-4.** Events of several curves at one offset take the longest jump among them. Gate it. +- **R19-5.** A live send move (hand offset or a D6 matrix edit) in flight at a Reposition is exact under + R19-3 and #1711's deferred jump, with no flag. If its gate fails, a send-only conservative + `TransitionFallback` term (the B17-R14 pattern) is added, and root is told. +- **K1, the shared walk.** The route table uses the merged walk of `crates/automation/src/compose.rs` + (built after #1711, before T3). The composition (law, matrix, gate) stays in the route op. The cursor + table is a boxed slice sized at bind and charged by `size_of`, not a fixed array. +- **Two PRs.** This issue ships in batch 19a with #1711 and K1. diff --git a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 5f3c02a0f..dc344e299 100644 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -190,3 +190,199 @@ F19), clippy `-D warnings`, `cargo fmt --all -- --check`. carried rebuild. Decision 14, rule 3 entry, for automated VCAs only: the reach change needs new memory (an `own_db` cell word per newly reached lane and the member's curve tables), so it cannot be a live update. Non-automated VCAs keep D15-6's live set. + +## Amendment (controls coordinator, batch 19 spec check, 2026-10-17) + +Checked against `323433f6a`. #1646-#1653 have landed (batches 17 and 18). The filed text stays; +where it disagrees, this section wins. Rulings R19-V1 and R19-V2 below are open; the rest are fixes. + +**Anchors now.** +- VCA rows: `crates/controls/src/vca.rs:14` (`OFFSET_DB`), `ROWS` `:37-73`, `write` `:111`. + `automatable`: `crates/controls/src/descriptor.rs:154`. The classifier masks the table at + `crates/host-core/src/live_delta.rs:404`; step 3 is `:422-425`. +- `controls` (`crates/controls/src/automation.rs`): `takes_offset` `:51`, `automation_row_renders` + `:400`, `mute_row` `:407`, `fader_row` `:419`, `automated_fader_lanes` `:502`, + `rendered_automation_changed` `:527`, `continued_fader_lanes` `:573`, `continued_mute_lanes` + `:586`. `effective_strip` `crates/controls/src/effective.rs:326-366` (`offsets_sum` `:207`). The + hand-offset write: `crates/controls/src/registry.rs:565` (`builtin_rows::write_fader_offset`), + `ModelView::fader_automation` `crates/controls/src/view.rs:111`, + `OwnerEdits::take_fader_automation` `crates/controls/src/owner_edits.rs:105`, + `mask_fader_offsets` `crates/controls/src/mask.rs:138`. +- builtins-compiler (`crates/builtins-compiler/src/lib.rs`): `OFFSETS_CELL_WORDS` `:719`, + `OffsetsLane` `:727`, `AutomatedFaderCell` `:753`, `AutomatedMuteCell` `:839`, + `LaneFaderAutomation` `:858` (`adopt` `:956`, `mute_terms` `:1062`, `read_offsets` `:1128`), + `offsets_jump` `:1285`, `block_jumps` `:1404`, `FaderCellCursor` `:1458`, `FaderEventDriver` + `:1709` (`new` `:1755`), `settle_adoption` `:2146`, `prepare_fader_automation` `:2618`, + `fader_automation_sizes` `:2582`, `strip_mute_cells` `:5808`. +- `automation::CellState` and `CellEvents`: `crates/automation/src/events.rs:91`, `:217`. +- host-core (`crates/host-core/src/prepare.rs`): `MAXIMUM_VCA_REACH_PAIRS` `:144`, the following + route attach `:2873-2897`, `fader_records` `:4493`, `level_records_between` `:4567`, + `StripChange` `:4637`, `join_level_records` `:4928`, `following_route_lanes` `:5081`, + `join_strips` `:5638`, `join_routes` `:5829`. +- graph: `RouteFollowFlags` `crates/graph/src/lib.rs:1146`; `LiveFollowRoute` + `crates/graph/src/runtime.rs:1287`, `FollowCursors` `:1442`, `follow_walk` `:1649`. +- B17-R14's `automates`: `crates/control-plane/src/control.rs:2651`. + +**How the slice maps onto the landed code.** +- **Event lanes.** A member lane is a fader event lane when its fader is automated or an automated + VCA `offset_db` lane reaches it, and a mute event lane when its mute is automated or an automated + VCA `mute` lane reaches it. Two new `controls` predicates give them (one rule for preparation, + the classifier, the join and the route attach). The own-entry readers of #1651 T2 + (`fader_automation`, `model_fader_offsets`, `mask_fader_offsets`, the listing flags) stay + own-entry only: a member's own fader is not automated by a VCA ride. +- **Program.** The VCA cells join the plan's one program after the mute cells, so a session + without VCA automation builds the same program. One cell per automated VCA lane, shared by + every member. `prepare_fader_automation`'s early return already reads `automation_row_renders`. +- **Slot.** An event lane gets a `LaneFaderAutomation` slot and an offsets cell, also when its own + fader is not automated. Its channel's own source is the cell (own automation) or `own_db`. +- **Driver.** The one `FaderEventDriver` (bank widths, fused pair fallback, scalar oracle: R17-4). + Same `a(n)` and span (R17-2, R17-3); no new node-time context. `block_jumps` also resolves + `LiveRamps::for_row(VcaOffset)` and `for_row(VcaMute)` (equal to the fader and mute lengths + today; resolved by row, not assumed). +- **Mute.** `terms` is already `EffectiveStrip::mute` (static mute, every reaching VCA's static + mute, solo term). So D4 needs no new record and no classifier change: the `Mute` record is the + terms write, and `mute_terms` (the drain branch) composes it with the lane's composed curve. +- **Route.** `following_route_lanes` (the one predicate of the attach and of #1653's record) widens + to mute event lanes. `RouteFollowFlags` keeps its six bits; `automated[lane]` now means "the + lane has a mute curve, own or VCA". No spare bit is used (#1654 keeps bits 6 and up). The route + op evaluates the reaching VCA mute cells at `τ_tap` with its own cursors (`FollowCursors`, + `follow_walk`). `route_mix_column_ramps_block` and the per-column ramps (R18-5) do not change. + #1653's pre-write checks hold: plan and record read the same predicate. +- **FP (R17-7).** The `f64` sums of D2 run on render inside the render entry's `CanonicalFpEnv`; + the seed runs inside preparation's guard; the classifier's `S` inside the commit guard. +- **Classifier.** VCA rows 1 and 2 join `automation_row_renders`, so a VCA entry change is + `Err(LiveRebuild::Automation)` at step 3. The reach rule (D5 last bullet, root's ruling) goes in + `controls::rendered_automation_changed`: it also returns `true` when the `(member strip, lane)` + set that an automated VCA lane reaches differs (membership, nesting, an added or removed VCA). + Step 3's call in `live_delta.rs` does not change (docs only). The fast path (`classify_rows`) + takes `SetControl` only, which cannot change a reach, so it needs no change. +- **Hand edits (D5).** `takes_offset` gains VCA `offset_db`. As for the fader (#1650 D3, D4, + R18-2): `registry::write` sends a hand move on an automated VCA lane to the entry's offset; + `ModelView` and `OwnerEdits` read and take the VCA entry for the in-place edit (R17-1); + the VCA row's value is the offset and the list marks it `automated` (field 35); `mask_values` + copies the entry's offset and leaves the automated lane's static offset (its fallback) as + structure. Without these a write and a read of the same row disagree. +- **`S` (D2).** `effective_strip` puts, on a fader event lane: the own hand offset, then the static + offsets of the reaching VCA lanes that are not automated, then the hand offsets of the automated + ones, each group in ascending VCA ID. With no automated VCA lane this is today's sum, bit for bit. +- **Records (D3).** `level_records_between` and `fader_records` take the fader event lanes as + `automated` (no `FaderDb` there). The offsets record of an event lane carries `own_db` from + `StripChange::own_after` (no new `EffectiveStrip` field). + +**Fixes.** +- **D3 and D7 bytes.** `LatestCell` is const-generic. A slot of three words and a slot of four + words are both 32 bytes (two `AtomicU64` set the alignment), so a four-word cell costs nothing + more. Every offsets cell becomes four words (`OFFSETS_CELL_WORDS = 4`; the `own_db` word is + unread on a lane whose fader is automated). One cell shape, one reader. No edit under + `crates/engine/src/realtime/`. `offsets_cell_bytes` stays 120 (x86-64); the gate measures it. +- **D7 terms.** D7 leaves out that an event lane with no own automation also takes a slot + (`lane_slot_bytes`) and an offsets cell. The per-pair term depends on R19-V1. All terms are + bank payload, charged by `fader_automation_sizes()` and checked as ceilings, never literals. +- **D6 carry.** A carried channel continues verbatim when its own table and each reaching automated + VCA table (in ascending VCA ID) are the same set, bit for bit, and `a(n)` did not move; else it + repositions. This is `continued_cells` (bits 0-3) from the widened `continued_fader_lanes` and + `continued_mute_lanes`; graph passes the byte unchanged. No per-pair carry key is needed, because + a repositioned channel places each cursor again. A lost event lane is released by the join, as + today (`join_level_records`, widened to event lanes). +- **Reposition and #1711 (R18-4 withdrawn).** A VCA offset table edit repositions every reached + member channel. Its landing can cut a live move in flight (a member's `own_db` move, a VCA hand + move), as a fader table edit does. So: land #1711 first and use its deferred jump for every + repositioned channel; a VCA case joins its G1 here (gate 7 below). If #1711 does not land, the + B17-R14 flag stays and `automates` (`control.rs:2651`) widens to VCA `offset_db` entries, so + a VCA-only session's table edit is flagged, never `EXACT` when wrong. A VCA mute table edit + lands only a `SetMute` (the stage keeps `fader_gain`, #1651 T4); U1 fold 1's rule must hold + for the composed mute: a terms record at the swap lands over its own ramp word (gate 8). + +**R19-V1 (ruling needed): how a member channel holds and walks its curves.** D1's per-pair state +(one `CellState` per member and VCA lane) cannot hold a correct composition. Each `CellState` keeps +its own ramp in flight and its own `held` rule, but the stage has one ramp. A member grid event +inside a VCA jump retargets the stage over 64 samples, so the VCA step ends in 64 samples, not in +`J` (and the reverse). Also the driver's arrays are fixed (`MAXIMUM_FADER_CELLS` on the render +stack), and `m` has no compiled bound. +- (a) D1 as filed: per-pair `CellState`, events merged by offset. Small. A VCA step is shortened + whenever the member's own ride moves. Not correct. +- (b) One composite cell per member channel: one `CellState` over the composed curve + `vca_compose_db(own(t), S + c_1(t) + … + c_m(t))` (for mute: `own || v_1 || … || v_m`). + Events at the union of the curves' discontinuities and the shared node-time grid; target, held + and lagging rules on the composed value. A new K-curve event source in `crates/automation` + (`events.rs`, render code, not under `crates/engine/src/realtime/`): per pair one discontinuity + cursor and a cell index (8 bytes) in a boxed slice of the slot, allocated at preparation. With + `m = 0` the driver keeps today's `CellEvents` path, so no fader-only or mute-only bit moves. The + route op uses the same source per lane (OR). D8 holds (events at most the sum of the curves'). + About 8 h more than (a). +- (c) Split: this slice renders VCA `mute` (D4, the reach rule, D5's mute bullet, D6 for row 2); + VCA `offset_db` (D2, D3, D5's offset bullet) moves to a phase-2 successor with (b). +- Recommendation: (b). Fall back to (c) if the offset tranche fails its second fix round. + +**R19-V2 (ruling needed): the reach-pair memory.** The host cap is 16,384 (strip, VCA) pairs +(`MAXIMUM_VCA_REACH_PAIRS`), per lane and per row. Under (b) that is at most about 512 KiB of pair +state per plan; under (a) about 3 MiB. Options: (i) charge it as bank payload under the existing +caps (no new cap); (ii) add a host cap on automated pairs. Recommendation: (i) with (b). + +**Gates (fixes; both hosts and both bank widths).** +- Gates 1-3: both hosts in one binary, `hosts/host-web/tests/control_parity.rs`, at quanta 128, + 100 and 37. The same scripts in `crates/host-core/tests/stored_vca_automation.rs` (new) at + `Backend::Simd8` (`cfg(target_feature = "avx2")`) and `Backend::Simd4`, split and fused, live and + between render calls, and on the scalar oracle; bits equal. Gate 1 adds two VCAs on one member + (one automated, one static) and a member with its own ride. Gate 3 adds a member with no own + mute entry (its send becomes seven-word) and a nested VCA. +- Gate 4 reads: a hand fader move on a member with no own automation gives `live`, one offsets + write carrying `own_db` and no `FaderDb`. Add: the VCA row lists the offset as its value and is + marked `automated`; a replace that changes only an automated VCA lane's static offset is + `rebuild`. In `crates/host-core/tests/live_delta.rs` (run with + `--features host-core/test-support,host-core/control-provider`) and both hosts. +- Gate 5: `crates/control-plane/tests/vca_automation_realtime.rs` gains the curve, a hand VCA move, + a hand member move and a VCA mute, each block `allocations == 0 && frees == 0`. +- Gate 7 (new, Reposition): a VCA ride table edit while a member's hand move (and a VCA hand move) + ramps, heard 1, 12 and 26 blocks: with #1711, every block after the swap equals the + uninterrupted engine and the watermark reports `EXACT`; without #1711, the flag. Both hosts, + both widths. +- Gate 8 (new, carry): a VCA mute table edit with a hand VCA mute in the same transaction lands the + hand mute over its own ramp; added, removed and changed VCA entries each rebuild and carry the + member stage; the swap block allocates nothing. Bank widths (the oracle restarts at a swap, + #1651 T4 departure). +- Gate 6 command: `audit capi` `pcm_digest` stays `bffc21e857b5e134`. Add `cargo test --locked -p + automation --all-features`; `cargo build --locked --release -p realtime-policy && + ./target/release/realtime_policy` (the script is deleted). + +**Authorized paths after the check (this list replaces the filed one).** (R) marks render code. +None is under `crates/engine/src/realtime/`, so no root notice is needed. +- (R) `crates/automation/src/{events.rs,lib.rs}`, `crates/automation/tests/events.rs` (only under + R19-V1 (b); a path extension root approves with the ruling) +- (R) `crates/builtins-compiler/src/lib.rs` (VCA cells, event lanes, slot, offsets cell shape, + driver, drain, adopt, export to host-core) +- (R) `crates/graph/src/runtime.rs` (`FollowCursors`, `follow_walk`, the follow route's carry + only), `crates/graph/src/lib.rs` (`GraphFollowRouteSource`'s cells, docs) +- `crates/controls/src/{automation.rs,vca.rs,effective.rs,registry.rs,view.rs,owner_edits.rs,mask.rs,lib.rs}`, + `crates/controls/tests/{automation.rs,mask_and_diff.rs}` +- `crates/host-core/src/prepare.rs` (event-lane predicates, follow attach, level records, strip + and route joins), `crates/host-core/src/live_delta.rs` (docs only) +- `crates/host-core/tests/{vca.rs,live_delta.rs,stored_vca_automation.rs,route_mute.rs}` +- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `crates/control-plane/tests/vca_automation_realtime.rs` +- `crates/control-plane/src/control.rs` (only if #1711 does not land: `automates`) +- `docs/SESSION_SCHEMA_V1.md` (`:339-340` the offset rows; the VCA rows render; D2's order), + `docs/REALTIME_MEMORY.md` (the pair term) +- No SDK regeneration: no row class or table changes. + +**Edit order with the batch.** #1711 first (driver adoption, `events.rs`, `control.rs`), then #1654 +(`following_route_lanes`, the route op, `takes_offset` and its test), then this slice. +Program cell order: fader cells, mute cells, #1654's route cells, then the VCA cells (VCA ID, +row, lane), so each earlier session builds its program cell for cell. If #1654 generalizes +`follow_walk` to a table of cursors, this slice adds the VCA mute cursors to that table and +writes no second walk. + +## Rulings (root, batch 19, 2026-10-17) + +These override the batch 19 spec-check amendment where they differ. + +- **R19-2 = (b).** One composed `CellState` per output (a member fader channel, a member mute channel) + over all its curves: the merged walk of `crates/automation/src/compose.rs` (K1, built in batch 19a), + sum for offsets, OR for mutes. The route op adds the VCA mute curves as more cursors in #1654's route + table, ORed into the follow term; no second walk. Fallback (c) if U3 fails its second fix round: VCA + mute ships, VCA offset automation goes to phase 2; root is told. +- **R19-4.** Events of several curves at one offset take the longest jump among them. Gate it. +- **R19-6.** Reach-pair memory is charged as bank payload under the existing caps; no new host cap. A + gate at the cap: one pair over is refused with a typed diagnostic, not a panic. +- **Two PRs.** This issue ships alone in batch 19b, on top of 19a (#1711, K1, #1654). Edit order: every + shared file after 19a. diff --git a/.github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md b/.github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md index d5147a73a..691680d17 100644 --- a/.github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md +++ b/.github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md @@ -107,3 +107,471 @@ conservative fallback flag of B17-R14 with the exact behaviour and deletes that power check, `scratchpad/c18t1/`). Prove the discharge observable and gate it, or prove it unobservable and delete it. This slice reworks the same owed and hold machinery at a Reposition, so the item belongs here, not in a phase-2 placeholder. + +## Amendment (controls coordinator, batch 19 spec check, 2026-10-17) + +Anchors are on `323433f6a`. #1648 is closed; batches 17 and 18 are merged. Every number below was +measured with temporary probes in a detached worktree at `323433f6a` (removed after use). + +### A0. Diagnosis: the stage target does not move; the comparison reads the wrong field (a defect) + +- The stage's ramp target stays at the composed curve at the ramp's end: 0.301812 at every + `remaining` (3872, 2464, 928, 672, 160), VCA and hand moves, both tables. +- `fader_gain` is not the ramp target. Inside a live move's ramp the cell's grid events are held + (`held: true`, `crates/automation/src/events.rs:391-409`). The driver gives them to the stage as + `RememberGain` (`crates/builtins-compiler/src/lib.rs:2026-2043`), and `RememberGain` writes + `fader_gain` with no retarget (`crates/builtins/src/lib.rs:3184`). So `fader_gain` follows the + ride (0.466 heard 1, 0.398 heard 12, 0.335 heard 24, 0.325 heard 26, 0.307 heard 30). +- `bank_hold` puts `fader_gain` in `ChannelHold` (`lib.rs:4032-4041`, field `:1438`), and + `settle_adoption` compares the curve with it (`lib.rs:2196-2198`). On a moving ride it always + differs, so every heard move lands at the swap. +- Fix F0 (smallest): for a free stage (`!held`), `ChannelHold` carries the stage's ramp target + (a new `BuiltinFaderBank` accessor over the kernel's `target_gain`, `builtins/src/lib.rs:3196`, + beside `fader_gain` at `:4639-4649`). A held stage keeps `fader_gain` (the gain an unmute lands + on; correct there). Measured with F0: all 40 cases of the gate setup (VCA and hand, both tables, + heard 1, 12, 24, 26, 30) are bit-equal to the uninterrupted engine over 44 blocks, adopted and + allocation-free. At HEAD: 4.6e-6 (heard 12), 4.8e-4, 3.1e-3, 2.1e-3 (heard 24, 26, 30). +- Rows: the fader stage only. VCA and hand moves both (one offsets-jump path, R17-1), and any + free ramp the cell holds (its own `J` jump or grid ramp). Mute cells are not affected (no gain + comparison). #1655's VCA cells drive the same stage and inherit F0. +- MINOR 4 (`at = end`): with F0, G1 is red if the curve is compared at the block's first sample + (measured: the same 4.8e-4, 3.1e-3, 2.1e-3). G2 as filed cannot be red: on the flat ride + `curve_toward` stops before the step, so `at = start` and `at = end` read the same value. G2 is + replaced (A3). + +### A1. The owed discharge: observable, so keep it and gate it (G4) + +- The line is now `crates/builtins-compiler/src/lib.rs:2104` (`HeldRampObligation::block`, + `:2080-2111`): a free stage at a block entry with no move due. +- `owed` can be true there only when the stage held the gain through the previous block and a + live write freed it at this entry (an exact live unmute). Without the line, the stale `owed` + survives the free blocks and makes a later hold's end `E` land a move over `J` + (`lib.rs:2243`, `moving`) when nothing was remembered in that hold. +- Measured on the C ABI (quantum 128): ride linear 0 to -6 dB over `[0, 1280)`, flat -6 dB to + 2320, then linear. Hand mute (ramp 256) at block 4, exact hand unmute (ramp 0) at block 8, hand + mute (256) at block 12, hand unmute (200) at block 16, so `E` = 2248 with no grid event in the + second hold. Reference: the same rig with only the second hold. With the line, bit-equal from + block 9. Without it, the first difference is block 17 (2.7e-4). + +### A2. D1 against the code (amended) + +- F0 first. An unmoved target then takes no landing: the cell seeds its target and holds the ramp + again (`settle_adoption`, `lib.rs:2174-2202`, already does this when `!differs`). +- A moved target on a free stage with a ramp in flight (and no step at the block's first sample): + no landing at the swap. The cell calls `CellState::defer_jump(at)` with `at` the ramp's first + free sample (`node_time_at(span, remaining)`). The existing deferred path + (`events.rs:456-490`) gives a `Jump` over the block's `J` there, change only. +- One rule change in `crates/automation` (measured as needed): on a restartable cell + (`no_restart` false) grid events before a pending deferral are held. Without it the grid point + 32 samples before the move's end retargets to the new curve (a grid ramp that completes after + the ramp in flight restarts, `events.rs:395-396`), the move is cut short, and change only then + drops the jump. A `no_restart` cell is unchanged. +- The deferral is cleared by `reposition` and a seek landing (both already), and by a new + `CellState::clear_deferred_jump()` that the fader cell calls where the ramp it waits for is + replaced or ends early: the free path of `offsets_jump` (`lib.rs:1346-1352`, a newer live move + keeps its own length), the R17-10 release (`:1920-1926`) and the `release_hold_by` sites + (`:1576`, `:1592`). Reason for the last two: a held grid would freeze the lane after an early + unmute (the #1651 U1 MAJOR-1 defect). +- Kept: the ramped landing at the block's first sample over `J` when no ramp is in flight, when + the stage is held, for D3 (a step at the block's first sample), and for the `Compare` backstop. +- Deleted: `long_landing` and its one-sample landing arm (`lib.rs:1898-1919`, `:1966-2009`) and + `length = length.max(remaining)` (`:2191`). `settle_adoption` no longer returns a length. +- Prototype measured (F0 + `defer_jump` + the held grid): G2's setup (A3) is bit-equal to its + reference at heard 12 (2464 left) and heard 30 (160 left), VCA and hand. At HEAD: 9.4e-3 and + 1.5e-3 from the swap block. With the prototype on, the capi `automated` tests (24), host-core + `stored_fader_automation`, `stored_mute_automation`, `successor_swap`, `session_seek` and the + `automation` and `builtins-compiler` tests stay green. +- No new state: `CellState::deferred` exists (`events.rs:102`, 32 B unchanged). + +### A3. Gates (amended) + +- G1 (unmoved target). The existing gate (`live_tests.rs:9059-9147`) becomes bit-equal (not 1e-3) + for Pending, SameTransaction and heard 1, 12, 24, 26, 30; both tables; VCA and hand. Add: two + table-only rebuilds 5 blocks apart while a 500-sample move ramps (the tranche-5 setup). Every + rebuild's watermark advance reports `OUTCOME_EXACT` and no transition fallback. Simd8 on the + C ABI. Simd4 and Simd8 in `crates/host-core/tests/stored_fader_automation.rs` through a carry + harness with live offsets writes (the pattern of `carry_run`, + `crates/host-core/tests/stored_mute_automation.rs:1129`). The scalar oracle has no plan swap + (`ChannelHold` there, `lib.rs:8234`). Both hosts: the parity test at + `hosts/host-web/tests/control_parity.rs:2933-3012` becomes "a hand move in flight reports + `EXACT` alike on both hosts, same bits". +- G2 (moved target, replaces the flat-ride G2). Base ride flat at -3 dB (`flat()`); move -6 dB + over 4,000 samples from block 4; rebuild to a ride flat at -5 dB to 6,000, then -3 dB; heard 12 + and 30; VCA and hand. Reference: a fresh plan whose table is -3 dB before the move's first free + sample (4,512 here; compute it from the rig), -5 dB to 6,000, then -3 dB, given the same move. + Every block is bit-equal to the reference, allocation-free, adopted, `OUTCOME_EXACT`. Red at + HEAD (above). Case (c): a second live move committed after the swap, before 4,512: bit-equal to + a fresh plan with the new table given both moves, from the second move's last sample on (red if + the deferral is not cleared). Simd8 and Simd4 in host-core as for G1; the C ABI leg; one + both-host parity case. +- G3 as filed. Named: `a_step_at_a_reposition_swap_jumps_over_the_fader_length` + (`live_tests.rs:9186-9289`) and the #1648 carry gates in `automated_carry`. Also the G5 + automation corpus digests (`crates/automation/src/corpus.rs:245`, + `tools/wasm-gates/tests/g5_native_corpus.rs`) do not move. Realtime policy count unchanged or + explained. +- G4 (owed discharge, A1). The A1 setup in `stored_fader_automation.rs` with `acted` on every form + (`FORMS`, `:2092-2100`: Simd8, Simd4, the between-render forms and the scalar oracle), and on + both hosts in `control_parity.rs` (same bits). Test value: red if the discharge line is + deleted. + +### A4. D2 exact deletions + +- `crates/control-plane/src/control.rs`: the `level_moves` field (`:649-651`); + `LevelMoveWindow`, its `may_ramp`, `fold_level_move`, `rebuild_level_ramp`, + `rebuild_moves_a_level` (`:654-735`); the B17-R14 comment paragraph (`:2621-2629`, keep the + #1652 U2a paragraph); `automates`, `in_flight`, `level_fallback`, `moved`, `landing` and the + `rebuild_level` value (`:2640-2672`; the outcome becomes `follow_into_six_word` alone); the fold + after commit (`:2866-2870`); the live commit's `level_ramp` and its fold (`:3519-3539`, + `:3555-3557`). `crates/control-plane/src/compile.rs:1483` (`level_moves: None`). +- Keep `reservation.set_outcome` (`control.rs:2840`) and `ObservedReservation::set_outcome` + (`crates/control-plane/src/plan.rs:199-206`): #1713's six-word fallback uses them. Fix the doc + at `plan.rs:199-200` to name #1713 only. +- The B17-R14 clause: `crates/capi/src/abi.rs:96-97`, `crates/capi/include/miso_engine_v1.h:183-184`, + `docs/C_ABI_V1_QUALIFICATION.md:153-154`, + `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts:782-784` and its generated copy + `sdk/src/browser/shipped-host.d.ts:782-784` (`scripts/check-sdk-generated.sh`). Keep the #1713 + clause. Doc mentions of the window: `crates/controls/src/automation.rs:397-398`, `:414-415`. +- Tests deleted: `a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` + (`live_tests.rs:9297-9368`), `a_second_reposition_inside_a_stretched_landing_reports_a_fallback` + (`:9370-9414`), and + `a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts` + (`control_parity.rs:3014-3114`, its window is gone). The `outcome` helper + (`live_tests.rs:9290-9295`) stays for G1. + +### A5. #1655 (same batch, same fader stage) + +- #1711 lands first. It removes the window that #1655 would otherwise have to extend for VCA + level moves. +- #1711 assumes: (1) the adoption compares the stage's ramp target (F0), which does not depend + on how the gain is composed; (2) the curve side of the comparison is the lane's composed gain at + `at`. Today that is `FaderEventDriver::gain(value, S)` (`lib.rs:2196`). #1655 replaces it with + its one composition `checked_fader_gain(vca_compose_db(own(at), S + Σ c_k(at)))`; (3) one + deferral per lane channel, so one `Jump` fires at the free sample, composed with every cell at + that node time. #1655 names which cell holds it on an event lane with no own fader cell, and + gives its VCA cells the same grid-hold and clear rules. + +### A6. Mapping onto the batch 17-18 code + +- Fader cells and the one event driver: `FaderEventDriver::new` (`lib.rs:1755`), adoption at + `:1898-1919`. Offsets cells and hand moves (`takes_offset`, R17-1): one `offsets_jump` path + (`:1285`). Jump lengths: `BlockJumps` (`:1404`). Node time (R17-3): `take_block_time` + (`:1044`). +- The scalar oracle (R17-4) shares the driver; no swap reaches it. It is in G4 only. +- R17-7, the classifier rows and carry, `LatestCell<7>`, `route_mix_column_ramps_block`, the + #1653 commit write and mute `curve || terms`: not touched. Mute adoption stays + `AutomatedMuteCell::reposition`. +- `controls::fader_row` and `automation_row_renders`: D2 deletes the only control-plane reader of + `fader_row` for the window (`control.rs:2651`). +- The conservative TransitionFallback: B17-R14's is deleted (D2). #1713's stays. + +### A7. Authorized paths + +- Render (realtime policy regions): `crates/builtins-compiler/src/lib.rs`, + `crates/automation/src/events.rs`, `crates/builtins/src/lib.rs` (one accessor). +- Control and docs: `crates/control-plane/src/control.rs`, `crates/control-plane/src/plan.rs`, + `crates/control-plane/src/compile.rs`, `crates/controls/src/automation.rs` (doc only), + `crates/capi/src/abi.rs`, `crates/capi/include/miso_engine_v1.h`, + `docs/C_ABI_V1_QUALIFICATION.md`, `hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts`, + `sdk/src/browser/shipped-host.d.ts`. +- Tests: `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, + `crates/host-core/tests/stored_fader_automation.rs`, + `crates/host-core/tests/support/successor.rs` (only if the carry harness needs a live-write + hook). +- This spec. +- Nothing under `crates/engine/src/realtime/`. + +## Rulings (root, batch 19, 2026-10-17) + +- **R19-1.** Confirmed: for a moved target with `remaining >= J`, the old ramp runs to its end, then + the cell jumps over `J`. Today the ramp is retargeted over `remaining`; D1 changes that. A gate holds + it (red if the ramp is retargeted at the swap). +- **R19-2 (shared with #1655).** The deferred jump acts on one composed `CellState` per output. K1 + (`crates/automation/src/compose.rs`, after this issue) reuses the decisions this issue adds; events.rs + exposes them `pub(crate)`, no copy. +- **Two PRs.** This issue ships in batch 19a with K1 and #1654. + +## Decision record: F1 (batch 19) + +- **F0 shipped.** `BuiltinFaderBank::ramp_target(lane, channel)` (`crates/builtins/src/lib.rs`, over + the kernel's `target_gain`) is new. `ChannelHold::fader_gain` is renamed `stage_gain`. `bank_hold` + fills it with the ramp target on a free stage and with `fader_gain` on a held stage. The + scalar oracle still gives `None`. `settle_adoption` compares the curve with `stage_gain`. No + other behaviour changes: `long_landing`, `length.max(remaining)` and the B17-R14 flag stay + (D1 and D2 are later tranches). +- **G1, C ABI (Simd8).** `a_level_move_across_a_table_change_keeps_its_length` + (`crates/capi/src/runtime/live_tests.rs`) now asserts bit-equality with the uninterrupted engine + over 44 blocks after the swap, not 1e-3. Timings: Pending, SameTransaction, heard 1, 12, 24, 26 + and 30. VCA and hand moves, both tables: 28 cases, all bit-equal, allocation-free, adopted. +- **G1, host-core (Simd8 and Simd4).** New + `a_level_move_in_flight_across_a_table_only_rebuild_keeps_its_bits` + (`crates/host-core/tests/stored_fader_automation.rs`, `test-support`), with its own carry + harness `level_move_run` (the `carry_run` pattern: a plan exchange, a live offsets write at + block 4, a successor prepared on the committed model with the move). Heard 1, 12, 24, 26 and + 30, VCA and hand moves, both tables: every block bit-equal to the uninterrupted engine, + allocation-free, adopted at the swap block, the strip carries. `successor.rs` is not touched. +- **Red without F0** (`bank_hold` back to `fader_gain`, run once, restored): the C ABI gate fails + at "a VCA move, the ride split at 10,000, Heard(12)" (first difference block 25, sample 79); the + host-core gate fails (first difference block 28). +- **Not asserted in F1: `OUTCOME_EXACT`.** The B17-R14 window still flags the heard 24, 26 and 30 + cases (`a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` is unchanged and green). + The outcome assertion of G1 belongs to the tranche that deletes the flag (D2). +- **Departure: the two-rebuild case of A3 is not in F1.** Two table-only rebuilds 5 blocks apart + around a 500-sample move (heard 2) cannot be bit-equal to the uninterrupted engine: the second + rebuild comes after the move's ramp ends, the stage is then free and settled on a grid ramp, and + the split tables' values differ from the original ride in `f32` rounding, so the cell jumps over + `J` (probe: 1.4e-4 to 1.7e-4 with no move at all; 7.7e-5 with only the later rebuild). Its + reference must be a fresh plan with the new table (G1's "where the table's own values differ" + clause), or its claim is the outcome alone. Moved to the D2 tranche, which owns the outcome + assertion and deletes the tranche-5 fallback gate it replaces. +- Gates run: `cargo fmt --all`; clippy `-D warnings` `--all-targets --all-features` on builtins, + builtins-compiler, host-core, capi; `cargo check --locked --all-targets` (no features) on the + same; tests of builtins, builtins-compiler, automation (all features), host-core + `stored_fader_automation` (31), `stored_mute_automation` (13), `successor_swap` (140), + `session_seek`, `browser_successor`, capi lib (209), host-web `control_parity` (30): all green. + +## Decision record: F2 (batch 19) + +- **D1 shipped (A2).** `crates/automation/src/events.rs`: new `CellState::defer_jump(at)`, + `clear_deferred_jump()` and `deferred_jump()` (small `pub` methods on the existing `deferred` + field; no new state, 32 B). One rule change: a restartable cell holds its grid events while a + deferral is pending (`time < deferred`); a `no_restart` cell is unchanged. The existing deferred + path in `next_in_run` fires the jump (a `Jump` over the block's `J`, change only, or always with + a held event outstanding). +- `crates/builtins-compiler/src/lib.rs`: `settle_adoption` returns `bool` (land at the block's + first sample over `J`), not a length. On a `Reposition` of a free stage with a ramp in flight + and no step of the cell's own at the block's first sample, it holds the ramp again, compares the + curve at the ramp's end with the stage's ramp target, and, if it differs, calls + `defer_jump(node_time_at(span, remaining))` (no landing at the swap; R19-1). The landing at the + block's first sample over `J` stays for no ramp in flight, a held stage, D3 and `Compare`. + Deleted: `long_landing`, its one-sample landing arm, `length.max(remaining)` and the `jump` field + of `AdoptionBlock`. `clear_deferred_jump()` is called on `offsets_jump`'s free path, at the + R17-10 release, and at the two `release_hold_by` sites (through a new `release_lane_hold`). + R19-2: the decisions are the `CellState` methods above, for K1 to reuse; `compose.rs` is not + written here. +- **The B17-R14 flag stays** (F4 deletes it), so F2 asserts no `OUTCOME_EXACT`: the G2 and R19-1 + cases are inside the level-move window and still report the transition fallback. +- **G2 (A3).** C ABI (Simd8): `a_moved_target_jumps_at_a_live_moves_first_free_sample` + (`crates/capi/src/runtime/live_tests.rs`). Host-core (Simd8 and Simd4): + `a_moved_target_jumps_over_the_fader_length_at_the_moves_first_free_sample` + (`crates/host-core/tests/stored_fader_automation.rs`; `level_move_run` now takes a list of + `(block, record)` moves and writes a later one to the successor's cells). Flat ride -3 dB, move + -6 dB over 4,000 from block 4, heard 12 and 30, VCA and hand, rebuild to -5 dB to 6,000 then + -3 dB; reference: a fresh plan with -3 dB to 4,512, -5 dB to 6,000, then -3 dB, same move. Every + sample bit-equal, allocation-free, adopted, the strip carries. Case (c): a second move -9 dB over + 1,200 from block 35 (ends 5,679, after 4,512 + `J`): bit-equal to a fresh plan with the + rebuild's table given both moves from 5,679 on; host-core also asserts the lane still moves on + the move's last sample (the length). +- **R19-1 gate.** `a_moved_target_lets_a_live_move_longer_than_the_jump_end_when_it_was_due` (C + ABI and host-core, same names): heard 1 and 12 (3,872 and 2,464 left, both >= `J`), bit-equal + to the fresh plan with the jump at 4,512. +- **Both hosts.** `a_moved_target_defers_its_jump_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`): hand move heard 3 blocks, rebuild to -5 dB; same + verdicts, replies, watermark and bits over 60 blocks at the four rates. +- **Unit tests** (`events.rs`): `a_deferred_jump_fires_at_its_time_and_holds_the_grid_before_it`, + `a_cleared_deferral_fires_nothing_and_holds_no_grid`. +- **Mutations (each run once, restored).** Land at the swap instead of deferring (the old + behaviour): R19-1 red (C ABI and host-core, VCA move, heard 1) and G2 red (heard 12). No grid + hold while deferring: G2 and R19-1 red (host-core, heard 12 and heard 1). No clear in + `offsets_jump`: G2 case (c) red (host-core length assertion, VCA move, heard 12). Compare at + the block's first sample (`at = end` removed): G2 stays green (on a flat table both points read + -5 dB, as A0 found for the filed G2); G1 host-core is red (heard 1, the ride split at 10,000). + So `at = end` is held by G1, not by G2. +- **F1 note checked: no.** Two table-only rebuilds 5 blocks apart (riding ride, R1 split at + 10,000 to -6 dB, R2 to -9 dB) against a fresh plan prepared with R2's table: with the 500-sample + move (heard 2), bit-equal up to R2, then max 7.7e-5 (block 15, 4 blocks after R2); with no move, + max 1.7e-4 after R1 (block 9) and 1.4e-4 after R2. Cause: at the adoption the stage's grid-ramp + target (taken from the old table's values) and the new table's curve differ in `f32` rounding, + so the cell jumps over `J` (deferred to the grid ramp's end, or at the block's first sample), + where a fresh plan never jumps. Not fixed (outside D1); the D2 tranche's two-rebuild case needs + an outcome-only claim or a phase-2 decision on rounding-only differences. +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on automation, + builtins, builtins-compiler, host-core, capi (and host-web `--all-targets`); `cargo check --locked + --all-targets` (no features) on the five; tests: automation, builtins-compiler (all features), + host-core `stored_fader_automation` (33), `stored_mute_automation` (13), `successor_swap` (140), + `session_seek`, `browser_successor`, capi lib (211), host-web `control_parity` (31): all green. + Realtime policy: 168 regions (unchanged). + +## Decision record: F3 and F4 (batch 19) + +- **G4 shipped (A1): the owed discharge is kept and gated.** The line `self.owed = false` in + `HeldRampObligation::block` (`crates/builtins-compiler/src/lib.rs`) now has a doc comment that + names the gate. Gates: `an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move` on the + C ABI (`crates/capi/src/runtime/live_tests.rs`, quantum 128, hand mutes as in A1) and in + `crates/host-core/tests/stored_fader_automation.rs` (`acted`, every one of `FORMS`: Simd8 on + AVX2, Simd4, the between-render forms and the scalar oracle; fader length 20 ms = 960). Both + compare with a rig that takes only the second hold, from block 9 (sample 1,152) on. Red with the + line deleted (run once, restored): C ABI first difference block 17; host-core first difference + at sample 2,248 (`E`). +- **D2 shipped (A4).** `crates/control-plane/src/control.rs`: deleted the `level_moves` field, + `LevelMoveWindow`, `may_ramp`, `fold_level_move`, `rebuild_level_ramp`, `rebuild_moves_a_level`, + the B17-R14 comment, `automates`, `in_flight`, `level_fallback`, `moved`, `landing`, + `rebuild_level`, both folds and the live commit's `level_ramp`. The outcome is now the #1713 + six-word flag alone (a transaction rebuild with `follow_into_six_word`; a discontinuity is + `Exact`). `reservation.set_outcome` and `ObservedReservation::set_outcome` stay (#1713); the doc + of the latter names #1713 only. `compile.rs`: `level_moves: None` deleted. The B17-R14 clause + is removed from `abi.rs`, `miso_engine_v1.h`, `docs/C_ABI_V1_QUALIFICATION.md` and the two d.ts + files (the #1713 clause kept); `crates/controls/src/automation.rs` no longer names the window. +- **Tests deleted:** `a_reposition_swap_while_a_level_move_ramps_reports_a_fallback`, + `a_second_reposition_inside_a_stretched_landing_reports_a_fallback` (C ABI) and + `a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts` (host-web). +- **Outcome checks (G1, G2, R19-1).** A new helper `assert_exact` (the watermark covers the newest + revision, its last advance reads `OUTCOME_EXACT` alone, transition-fallback count 0) is called by + every case of `a_level_move_across_a_table_change_keeps_its_length` (28 cases) and of + `moved_target_case` (G2, its case (c), and R19-1). Host-web: the B17-R14 parity test is now + `a_hand_move_in_flight_across_a_table_only_rebuild_completes_exactly_alike_on_both_hosts` (no + move and a hand move in flight: `EXACT`, no fallback on either host, same bits and watermark at + the four rates), and `a_moved_target_defers_its_jump_alike_on_both_hosts` asserts the same. +- **Two rebuilds 5 blocks apart (A3): outcome only.** + `table_only_rebuilds_around_a_level_move_complete_exactly` (C ABI): with no move (R1, R2) and + with a 500-sample VCA move heard 2 blocks, each rebuild reports `OUTCOME_EXACT` with no + fallback, every block is allocation-free, every successor is adopted. Bits are not compared: + the split tables' values differ from the ride in `f32` rounding, so the adopting cell jumps + over `J` where a fresh plan does not (F2 record: 7.7e-5 to 1.7e-4). A bit claim needs a + phase-2 decision on rounding-only differences at adoption. The no-move case is also the + "a no-live-move rebuild stays EXACT" check. +- **F2 note fixed.** The B17-R13 step gate's doc no longer names "a long landing"; it says the + step one sample after the swap must jump at its own sample (B17-R13's 8.6e-2 measurement kept + as history). The #1713 test's doc and comment no longer name the level-move window ("the flag + marks its own candidate only"). +- **G4 on both hosts.** `an_exact_unmute_discharges_the_owed_move_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`): the A1 setup on `t1` at the four rates; the + browser and the C ABI give the same verdicts, replies, watermark (`EXACT`, no transition + fallback) and bits in every block, and from block 24 on both equal a pair of rigs that took + only the second hold. Block 24: the first hold's own difference ends by block 21 at every rate + (the session's latency), and the stale obligation's difference spans blocks 21 to 28 at + 44.1 kHz and 25 to 40 at 96 kHz. Red with the line deleted (run once, restored): block 24 at + 44.1 kHz. +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on control-plane, + capi, host-core, builtins-compiler, host-web, controls; `cargo check --locked --all-targets` (no + features) on the first five; tests: control-plane (`test-support`), capi lib (211 passed, 1 + ignored), host-core `stored_fader_automation` (34), `stored_mute_automation` (13), + `successor_swap` (140), host-web `control_parity` (31 with the G4 parity case): all green. `scripts/check-capi-abi.sh`, + `check-sdk-generated.sh`, `check-sdk-types.sh`: ok. Realtime policy: 168 regions (unchanged). + +## Decision record: U1 fold 1 (batch 19) + +- **MAJOR-1 fixed (A2 clear list).** `crates/automation/src/events.rs`, `next_in_run`: a + discontinuity jump on a restartable cell drops a pending deferral (the jump compares the curve + with the target and replaces the ramp the deferral waits for; the stale deferral's grid hold + froze the lane on the step's target until the deferral's time). A `no_restart` cell is + unchanged. The A2 clear list is now: `reposition`, a seek landing, `clear_deferred_jump()` (the + free path of `offsets_jump`, the R17-10 release, the `release_hold_by` sites) and a + discontinuity jump of a restartable cell. `defer_jump`'s doc says so. +- **Effect on K1** (corrected in U1 fold 2). A value cursor's own discontinuity jump inside a + column-jump deferral drops that cursor's deferral only when the jump emits (it then replaces the + ramp: its grid moves again after its own jump, held by its own ramp by the cell rule); a jump + that emits nothing keeps the deferral, which holds the grid to the column ramp's end and lands + there. The other cursors keep their deferrals. K = 1 still equals `CellEvents` (one rule); the + K1 tests and the allocation test are + green unchanged. +- **MAJOR-1 gate (host-core, Simd8 and Simd4).** + `a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral` + (`crates/host-core/tests/stored_fader_automation.rs`): VCA and hand moves, heard 1 and 4, the + rebuild `[-5 dB step 0..2,000; linear -8 to -14 dB 2,000..8,000; -14 dB]` against a fresh plan + with -3 dB before 2,000: every sample bit-equal, allocation-free, adopted, carried. Red without + the fix: first difference sample 2,944, max 2.9e-2 (all four cases). +- **MAJOR-1, C ABI (Simd8).** `a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral` + (`crates/capi/src/runtime/live_tests.rs`, through `rebuilt_table_case`, which + `moved_target_case` now calls): VCA and hand moves, heard 1 and 4, every sample bit-equal to the + fresh plan, allocation-free, adopted, `OUTCOME_EXACT` with no transition fallback. Red without + the fix (VCA move, heard 1). +- **MAJOR-1, both hosts.** `a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`): hand move heard 3 blocks, the same rebuild; one + verdict, reply and committed document per edit, the same bits and watermark (`EXACT`, no + fallback), and every block equal to a reference pair prepared with -3 dB before 2,000 that takes + the same move and an unmoved table-only rebuild at the same block (both rebuilds keep the hand + move's stored offset; a rebuild with the default offset cancels the move). Red without the fix + (44.1 kHz, block 26). +- **G2 parity, fixed.** `a_moved_target_defers_its_jump_alike_on_both_hosts` sent its rebuild + with the default offset, which cancelled the hand move (parity held, the deferral was not + exercised). Its rebuild now carries the committed offset (read from both hosts' snapshots, one + document), and every block before the one holding 4,512 equals a pair given the same move and + no rebuild; a later block differs (the jump is heard). Red once with the adoption landing at the + swap instead of deferring (`settle_adoption`, 44.1 kHz, block 10). A fresh-plan reference with a + real step at 4,512 is not bit-equal in this session (first difference about `J / 2` after + 4,512 at every rate: 4,959, 4,998, 5,400 and 5,478). Corrected in U1 fold 2: 4,512 is the + render time; the post-insert console slot `miso.soft-clip` has 31 samples latency, so the + fader's node time is render time less 31 and the deferred jump fires at node 4,481 (correct by + D1). A reference stepped at node 4,481 is bit-equal over every block (U1 fold 2). +- **MINOR-1.** `table_only_rebuilds_around_a_level_move_complete_exactly` is deleted. Its + no-live-move case moved into G1, `a_level_move_across_a_table_change_keeps_its_length`: a + table-only rebuild with no move is adopted and reports `OUTCOME_EXACT` (outcome only: the split + table's rounding differs). The host-web renamed G1 parity test keeps its own no-move check. +- **NIT.** `a_mute_curve_rebuild_while_a_level_move_ramps_renders_alike_on_both_hosts` + (`control_parity.rs`) restores the deleted test's both-host part: verdicts, replies, the same + bits over 40 blocks and the same watermark; no B17-R14 window claim. +- **MINOR-2 gate (host-core, Simd8 and Simd4).** + `a_hand_mute_inside_a_pending_deferral_lands_the_lane_on_the_new_table_at_the_unmute`: G2 + heard 12 (swap 2,048, F 4,512), hand mute at 2,560, unmute at 3,072 or 5,120, ramps 200 and 0. + `level_move_run_muted` adds live mutes to the carry harness. Inside: the rebuild's table is a + linear -5 to -8 dB to 6,000 (a flat table hides a held grid), and from the mute's end on every + sample is bit-equal to a fresh plan with that table. After F (G2's flat table): bit-equal to the + deferred-jump reference everywhere. Mutation (once each): the R17-10 release without its clear + is red (exact unmute); with all three builtins-compiler clears removed the ramped unmute stays + green, because its owed landing at `E` is a seek landing that clears the deferral. The + `release_lane_hold` sites are not reached by this setup. +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature + `cargo check --all-targets` on automation, builtins-compiler, host-core, capi and host-web; + tests: automation (43, compose included), builtins-compiler (all features), host-core + `stored_fader_automation` (36), `stored_mute_automation` (13), `successor_swap` (140), capi + lib (211), host-web `control_parity` (33): green. Realtime policy: 171 regions (unchanged). + +## Decision record: U1 fold 2 (batch 19) + +- **MAJOR-A fixed.** `crates/automation/src/events.rs`, `next_in_run`: fold 1 dropped a pending + deferral at every discontinuity of a restartable cell before `jump_event` ran, and + `jump_event` can emit nothing (the curve already has the target's bits, no held event, the ramp + in flight not lagging): then no ramp replaced the one the deferral waits for. A fired deferral + is still spent before the event; a discontinuity of a restartable cell now drops the deferral + only when its jump emits. `defer_jump`'s doc says so. `no_restart` cells are unchanged. +- **Gate (K1).** `an_unchanged_value_jump_keeps_the_gate_deferral` + (`crates/automation/src/compose/tests.rs`): `gate_and` with the unmute at 1,000 over 480; the + gain flat at -5.9921875 to 1,200, then linear -4 to -8 dB over 1,200..1,712, then -8; its + jump at 1,200 targets the curve at 1,455 (-5.9921875, unchanged). The retargets are exactly + `[(1,000, Jump, 480), (1,480, Jump, 256)]`. Red at the fold's base (4533703d3): `[(1,000, Jump, + 480), (1,216, Grid, 64), (1,280, Grid, 64), ..., (1,664, Grid, 64)]` (the grid cuts the gate + jump, R19-3, and the landing at 1,480 is skipped). +- **Builtins single cell: no host-core gate.** In a single builtins cell the deferral comes from + `settle_adoption`, set only when the curve before the next discontinuity `d` (it is compared at + the move's ramp end, clamped before `d`) differs from the stage's target; the cell's target is + the live move's (`seed_target`), and `reposition` cleared `held`. Every grid event in + `[swap, d)` is held (the deferral holds the grid) and the last one, at or after `d - 64`, aims at + the curve at `d - 1`, which differs, so `held` is set and the jump at `d` emits. That holds + whenever a grid point lies in `[swap, d)`: always when the swap block's first node time is on + the 64-sample grid, as in every host-core harness (quantum 128, no latency). The case is + reachable at a swap block whose first node time is off the grid (a quantum + not a multiple of 64, or upstream latency, such as the 31 samples above) with `d` before the + first grid point; there the fold-2 rule is the D1 behaviour (the deferral is kept, so the grid + does not cut the live move short). The U1 fold 2 verifier reached it in the host-web parity session + (quantum 128 with the 31-sample soft-clip latency): bit-equal to a fresh plan at the four rates, + `OUTCOME_EXACT`. No separate gate: the K1 gate goes red on any regression of the shared rule. +- **MINOR-B fixed.** `a_moved_target_defers_its_jump_alike_on_both_hosts` + (`hosts/host-web/tests/control_parity.rs`) now compares every block with a fresh pair whose + table steps at `FREE` in the fader's node time (4 * 128 + 4,000 - `T1_FADER_LATENCY`), given the + same move and, at the rebuild's block, a table-only rebuild that moves nothing (split at + 950,000), both keeping the stored offset: bit-equal over all 60 blocks at the four rates. + `T1_FADER_LATENCY` = 31 is a named constant (the soft-clip slot's descriptor latency; no host + reader reports one strip's upstream latency, the plan reports only the output's, which `t3`'s + limiter raises). Red with it set to 0 (44.1 kHz, block 38). The unrebuilt pair is removed (the + fresh reference subsumes it). +- Gates run: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature + `cargo check --all-targets` on automation and host-web; tests: automation (all), builtins-compiler + (all features), host-core `stored_fader_automation` (36), `stored_mute_automation` (13), + `successor_swap` (140), capi lib (211, 1 ignored), host-web `control_parity` (37): green. + Realtime policy: 171 regions (unchanged). + +## Note (batch 19a, option C) + +- Batch 19a ships #1711 alone (root ruling, option C after the #1654 B19-R8 check). K1, #1654 and + #1655 are not in 19a. +- The U1 fold 2 rule's K1 gate (`an_unchanged_value_jump_keeps_the_gate_deferral`, + `crates/automation/src/compose/tests.rs`) ships with K1 in batch 19b. +- In 19a the rule ("a discontinuity jump on a restartable cell drops a pending deferral only when + the jump emits") is held by the events.rs unit tests and the fader gates. No earlier events.rs + unit test covered the rule, so 19a adds one: + `a_discontinuity_whose_jump_emits_nothing_keeps_a_pending_deferral` + (`crates/automation/src/events.rs`): a restartable cell flat at -6 to 50, then linear -4 to -8 + over 50..562, fixed jump length 257, seeded at -6 and deferred to 201. The jump at 50 aims at + -6 and emits nothing; the grid at 64, 128 and 192 is held and the deferred jump fires at 201. + Red with the fold-1 rule (the clear without `event.is_some()`, run once, restored): the grid + retargets unheld and nothing fires at 201. diff --git a/.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md b/.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md index d455a5865..d50d5102d 100644 --- a/.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md +++ b/.github/ISSUE_SPECS/1713-carry-a-following-routes-column-moves-exactly-into-a-six-word-successor.md @@ -57,3 +57,10 @@ field and its join line, the control plane's `follow_into_six_word` term, and th `a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback` (capi), `a_rebuild_that_drops_a_following_route_to_six_words_reports_the_fallback_alike` (host-web) and `the_join_flags_a_following_route_carried_into_a_six_word_one` (host-core), replaced by gate 3. + +## Note (controls coordinator, batch 19, 2026-10-17) + +With #1654, a route with only route-row automation (rows 1-6, no source mute entry) also uses the +seven-word cell. Its seven-to-six carry (its last route entry removed) is flagged by the existing +conservative flag (`resolve_route_carry` reads `GraphLiveRoute::following`); no new code. This issue's +exact form must also cover that case. diff --git a/.github/ISSUE_SPECS/1716-decide-how-adoption-treats-a-rounding-only-table-difference.md b/.github/ISSUE_SPECS/1716-decide-how-adoption-treats-a-rounding-only-table-difference.md new file mode 100644 index 000000000..001f00007 --- /dev/null +++ b/.github/ISSUE_SPECS/1716-decide-how-adoption-treats-a-rounding-only-table-difference.md @@ -0,0 +1,41 @@ +# Decide how adoption treats a rounding-only table difference + +Filed by root from batch 19 (#1711 F2 and F3/F4 decision records; controls coordinator, 2026-10-17). +Phase 2. + +## Product outcome + +A table-only rebuild that changes no value a user can hear (for example a ride split at a sample, so +the same line is stored as two segments) leaves the rendered bits as they were, or the difference is a +documented, measured choice. + +## Context + +- At a Reposition adoption the fader cell compares the stage's target with the new curve (#1711 F0) + and, if they differ, jumps over the fader length `J` (#1648 D2; after #1711, at the move's first free + sample). +- A split table evaluates the same line in `f32` from different segment ends, so its values can differ + from the old table's in the last bits. The comparison then sees a moved target and the cell jumps over + `J`, where a fresh plan with the new table never jumps. +- Measured on batch 19 (`a_level_move_across_a_table_change_keeps_its_length` setup, C ABI): a + table-only rebuild with no live move differs from the uninterrupted engine by about 1.7e-4 three + blocks after the swap (1.4e-4 after a second rebuild); with a 500-sample move, 7.7e-5. The watermark + reports `OUTCOME_EXACT`, which is honest under D2 (the jump is the specified adoption behaviour). +- Because of this, the G1 case "two table-only rebuilds 5 blocks apart" is gated on its outcome only. + +## Decision to make at filing + +- Option A: keep D2 as it is and document that a rounding-only difference is landed over `J`. +- Option B: compare with a tolerance tied to the curve's own `f32` evaluation error, or compare the + segment parameters, so a rounding-only difference does not jump. +- Option C: make the table builder canonical (no split of a straight line), so the values are equal. + +## Objective gates + +- G1. The two-rebuild case of #1711 G1 is bit-equal to the uninterrupted engine (B or C), or a gate + pins the documented jump (A). +- G2. A real table change still lands over `J` (the #1711 gates stay green). + +## Dependencies + +- #1711, #1648 D2. diff --git a/crates/automation/src/events.rs b/crates/automation/src/events.rs index 5bed33c9e..3004a7e2b 100644 --- a/crates/automation/src/events.rs +++ b/crates/automation/src/events.rs @@ -98,7 +98,8 @@ pub struct CellState { held: bool, /// The node time at which the ramp in flight completes, or [`IDLE`]. ramp_done: i64, - /// The node time at which a deferred jump fires (a `no_restart` cell), or [`IDLE`]. + /// The node time at which a deferred jump fires, or [`IDLE`]: a `no_restart` cell's jump that + /// waits for its lane, or a jump the caller deferred ([`Self::defer_jump`]). deferred: i64, } @@ -156,6 +157,43 @@ impl CellState { } } + /// Defers the cell's jump to node time `at` (#1711 D1): at the first sample at or after `at`, + /// the cell compares its curve with its current target, as at a discontinuity (a + /// [`EventKind::Jump`] over the jump length to the curve at the jump's completion, change + /// only, or always when a held event is outstanding). It replaces a deferral already pending. + /// + /// The caller defers the jump of a lane whose table changed while a ramp of its own runs (a + /// live move adopted at a plan swap) to that ramp's first free sample, so the ramp keeps its + /// own length. Until the deferral fires, every grid event of a restartable cell is held (the + /// stage remembers it and does not retarget), so no grid ramp cuts the running ramp short; a + /// `no_restart` cell holds them inside the ramp in flight as it always does. Cleared by + /// [`Self::clear_deferred_jump`], [`Self::reposition`], a seek landing and, on a restartable + /// cell, a discontinuity jump that emits (it replaces the ramp the deferral waits for; one + /// that emits nothing replaces no ramp and keeps the deferral). Control-free and + /// allocation-free. + pub const fn defer_jump(&mut self, at: i64) { + self.deferred = at; + } + + /// Drops a pending deferred jump ([`Self::defer_jump`]), and with it the grid hold it keeps; + /// with none pending it does nothing. The caller clears it where the ramp the deferral waits + /// for is replaced (a newer live move keeps its own length) or ends early (a release of the + /// lane's hold), so the cell's grid moves the lane again from its next grid point. + pub const fn clear_deferred_jump(&mut self) { + self.deferred = IDLE; + } + + /// The node time of the pending deferred jump, if any ([`Self::defer_jump`], or a + /// `no_restart` cell's jump that waits for its lane). + #[must_use] + pub const fn deferred_jump(&self) -> Option { + if self.deferred == IDLE { + None + } else { + Some(self.deferred) + } + } + /// Sets the current target to `value`, as if a retarget to it had completed: the next grid or /// jump event to the same bits emits nothing (change only). It clears an outstanding held /// event and keeps the cursor, the ramp in flight and a deferred jump. @@ -393,7 +431,13 @@ impl<'a, 's> CellEvents<'a, 's> { let value = self.target_at(time, ramp); let done = self.completion(time, ramp); let in_flight = time < self.free_at(); - if in_flight && (self.program.no_restart || done < self.state.ramp_done) { + // A restartable cell holds its grid until a pending deferral fires (#1711 D1): a grid + // ramp that completes after the ramp in flight would otherwise restart it with the new + // curve and cut it short. A `no_restart` cell's deferral always lies at its lane's free + // time, so its rule is unchanged. + let deferring = + !self.program.no_restart && self.state.deferred != IDLE && time < self.state.deferred; + if (in_flight && (self.program.no_restart || done < self.state.ramp_done)) || deferring { let differs = !self.state.has_target || self.state.target != value.to_bits(); if !differs && !self.state.held { return None; @@ -491,6 +535,14 @@ impl<'a, 's> CellEvents<'a, 's> { } else { self.grid_event(offset, next) }; + // A discontinuity jump on a restartable cell that emits drops a pending deferral + // (#1711 U1 fold 1): it replaces the ramp the deferral waits for, so the deferral and + // its grid hold are stale (they would freeze the lane on the jump's target until the + // deferral's time). A jump that emits nothing (#1711 U1 fold 2: the curve already has + // its value and no ramp lags) replaces no ramp, and the deferral stays owed. + if is_boundary && !self.program.no_restart && event.is_some() { + self.state.deferred = IDLE; + } if event.is_some() { return event; } @@ -558,6 +610,174 @@ mod tests { .expect("valid cell") } + /// A restartable cell riding linearly from 0 to -12 over 4,096 samples (a grid event at + /// every 64th sample moves it). + fn riding() -> crate::Program { + let segments = [Segment { + start: 0, + end: 4_096, + start_value: 0.0, + end_value: -12.0, + shape: Shape::Linear, + }]; + ProgramBuilder::build(&[CellDescription { + segments: &segments, + grid_ramp: 64, + grid: 64, + jump: JumpLength::Block, + completion: Completion::End, + no_restart: false, + }]) + .expect("valid cell") + } + + /// The `(offset, kind, held)` of every event of the block of 256 samples from node time 0. + fn block_events( + program: &crate::Program, + state: &mut CellState, + ) -> Vec<(u32, EventKind, bool)> { + let span = NodeSpan::new(0, None, 256).expect("span"); + CellEvents::new( + program.cell(0).expect("cell"), + state, + span, + SeekMode::Exact, + 480, + ) + .map(|event| (event.offset, event.kind, event.held)) + .collect() + } + + /// A cell seeded at its curve's start whose caller reported a ramp completing at node time + /// 200 (a live move), so its first free sample is 201. + fn in_a_move() -> CellState { + let mut state = CellState::new(); + state.seed_target(0.0); + state.hold_until(200); + state + } + + /// #1711 D1: a jump deferred to the first free sample of a ramp in flight fires there over the + /// jump length, and every grid event before it is held, also the one whose grid ramp would + /// complete after the ramp in flight (which restarts the ramp with no deferral). + /// + /// Red if the deferral is not fired on a restartable cell, or if the grid event at 192 (its + /// ramp completes at 255, after 200) is not held while the deferral is pending. + #[test] + fn a_deferred_jump_fires_at_its_time_and_holds_the_grid_before_it() { + let program = riding(); + let mut plain = in_a_move(); + let held = |offset| (offset, EventKind::Grid, true); + assert_eq!( + block_events(&program, &mut plain), + [held(0), held(64), held(128), (192, EventKind::Grid, false)], + "no deferral: the grid restarts the ramp at 192" + ); + let mut deferred = in_a_move(); + deferred.defer_jump(201); + assert_eq!(deferred.deferred_jump(), Some(201)); + assert_eq!( + block_events(&program, &mut deferred), + [ + held(0), + held(64), + held(128), + held(192), + (201, EventKind::Jump, false) + ] + ); + assert_eq!(deferred.deferred_jump(), None, "a fired deferral is spent"); + } + + /// #1711 D1: a cleared deferral fires nothing and holds no grid event: the cell's events are + /// those of a cell that never deferred; a deferral whose target the curve already has emits + /// nothing (change only), and `reposition` drops it. + /// + /// Red if `clear_deferred_jump` leaves the deferral or its grid hold in place (a held grid + /// would freeze the lane after an early release, #1651 U1 MAJOR-1). + #[test] + fn a_cleared_deferral_fires_nothing_and_holds_no_grid() { + let program = riding(); + let mut cleared = in_a_move(); + cleared.defer_jump(201); + cleared.clear_deferred_jump(); + assert_eq!(cleared.deferred_jump(), None); + assert_eq!( + block_events(&program, &mut cleared), + block_events(&program, &mut in_a_move()) + ); + + let flat = flat(-7.25); + let mut settled = CellState::new(); + settled.seed_target(-7.25); + settled.defer_jump(100); + assert_eq!(first_block(&flat, &mut settled), [], "change only"); + + let mut repositioned = in_a_move(); + repositioned.defer_jump(201); + repositioned.reposition(&program.cell(0).expect("cell"), 0); + assert_eq!(repositioned.deferred_jump(), None); + } + + /// #1711 U1 fold 2: a discontinuity jump on a restartable cell that emits nothing keeps a + /// pending deferral. The curve is flat at -6 to 50, then linear from -4 to -8 over 50..562, + /// with a fixed jump length of 257 samples: the jump at 50 aims at the curve at 306 (-6, the + /// seeded target), so it emits nothing and replaces no ramp. The deferral to 201 stays owed: + /// the grid is held until 201, where the deferred jump fires. + /// + /// Red if a discontinuity whose jump emits nothing drops the deferral (the fold-1 rule): the + /// grid at 64, 128 and 192 then retargets unheld and nothing fires at 201. + #[test] + fn a_discontinuity_whose_jump_emits_nothing_keeps_a_pending_deferral() { + let segments = [ + Segment { + start: 0, + end: 50, + start_value: -6.0, + end_value: -6.0, + shape: Shape::Linear, + }, + Segment { + start: 50, + end: 562, + start_value: -4.0, + end_value: -8.0, + shape: Shape::Linear, + }, + Segment { + start: 562, + end: 4_096, + start_value: -8.0, + end_value: -8.0, + shape: Shape::Linear, + }, + ]; + let program = ProgramBuilder::build(&[CellDescription { + segments: &segments, + grid_ramp: 64, + grid: 64, + jump: JumpLength::Fixed(257), + completion: Completion::End, + no_restart: false, + }]) + .expect("valid cell"); + assert_eq!(program.cell(0).expect("cell").discontinuities(), [50]); + let mut state = CellState::new(); + state.seed_target(-6.0); + state.defer_jump(201); + let held = |offset| (offset, EventKind::Grid, true); + assert_eq!( + block_events(&program, &mut state), + [ + held(64), + held(128), + held(192), + (201, EventKind::Jump, false) + ] + ); + assert_eq!(state.deferred_jump(), None, "a fired deferral is spent"); + } + fn first_block(program: &crate::Program, state: &mut CellState) -> Vec<(u32, EventKind)> { let span = NodeSpan::new(0, None, 256).expect("span"); CellEvents::new( diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 3a7859b34..ed3e6af7e 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -1346,6 +1346,9 @@ impl AutomatedFaderCell { // record takes this same path at its first block, with the carried obligation. self.obligation.held_through = false; self.state.seed_target(value); + // A newer live move keeps its own length: a jump an adoption deferred to the end of the + // ramp this one replaces is dropped (#1711 D1). + self.state.clear_deferred_jump(); if let Some(start) = start { self.state .hold_until(automation::completion_of(completion, start, ramp)); @@ -1432,10 +1435,13 @@ struct ChannelHold { muted: bool, /// Frames left in the channel's ramp. remaining: u32, - /// The fader gain the stage aims for, whatever its mute (#1648 D2: an adopted cell compares - /// its curve with it). `None` on the scalar oracle, which no plan swap carries (its fader - /// owners are bound nodes, not bank lanes). - fader_gain: Option, + /// The gain an adopted cell compares its curve with (#1648 D2): on a held stage, the fader + /// gain it aims for (the one an unmute lands on); on a free stage, its ramp's target (#1711 + /// F0: the cell's held grid events only remember a gain inside a live move's ramp, so the + /// fader gain follows the ride while the target stays where the move ends). `None` on the + /// scalar oracle, which no plan swap carries (its fader owners are bound nodes, not bank + /// lanes). + stage_gain: Option, } /// One channel's block cut at the end `E` of a held ramp (#1647 D1.6, constraint 2): the piece @@ -1573,7 +1579,7 @@ impl FaderCellCursor<'_> { if let Some(release) = self.release.take() && let Some(events) = self.events.take() { - events.into_state().release_hold_by(release); + release_lane_hold(events.into_state(), release); } return false; } @@ -1589,7 +1595,7 @@ impl FaderCellCursor<'_> { self.last = last; let state = events.into_state(); if let Some(release) = self.release.take() { - state.release_hold_by(release); + release_lane_hold(state, release); } self.events = Some(automation::CellEvents::new( self.program, @@ -1895,18 +1901,13 @@ impl<'a> FaderEventDriver<'a> { }); let hold_now = hold(lane, channel); let mut mode = controls::FADER_CELL_RULE.seek; - // A landing longer than `J`: the remainder of the free stage's own ramp (U3 fold - // 2), landed alone at the block's first sample so the block's discontinuities - // still jump over `J`. - let mut long_landing = None; - if let Some(length) = settle_adoption( + if settle_adoption( core::mem::replace(adoption, Adoption::None), AdoptionBlock { program: &cell_program, span, offsets_sum: *offsets_sum, hold: hold_now, - jump, }, state, ) && step.is_none() @@ -1915,14 +1916,15 @@ impl<'a> FaderEventDriver<'a> { // over the block's jump length, to the curve at the jump's completion. step = Some((0, span.start)); mode = automation::SeekMode::Ramp; - long_landing = (length > jump).then_some(length); } let (free_from, at, release) = obligation.block(hold_now, frames); if release && let Some(first) = span_time(span.start, step, 0) { // R17-10: a live write freed the gain the stage held through the previous // block, so the cell's hold of the ramp it ended is released here, owed or - // not: its grid moves the lane from this block's first sample on. + // not: its grid moves the lane from this block's first sample on (and a jump + // deferred to that ramp's end is dropped with it, #1711 D1). state.hold_until(first.saturating_sub(1)); + state.clear_deferred_jump(); } let end = at.map(|at| hold_end(span.start, step, at)); // The channel's first mute event (#1651 T3b): the first piece ends there. @@ -1963,50 +1965,22 @@ impl<'a> FaderEventDriver<'a> { step, frames, }; - match long_landing { - // U3 fold 2: the landing alone, a one-sample piece over its own length (it - // commits the target and holds the grid until its end, as the cell's own - // `Jump` does), then the rest of the block from its second sample. A free - // stage has no cut (`end` is `None`); a cut at the first sample (a move due - // there) takes the landing over `J` below instead. - Some(length) if cursor.end.is_none() => { - // Unreachable: a step at 0 lies inside one sample. In release the - // channel is skipped: it emits nothing and the lane holds its gain. - let Ok(one) = - automation::NodeSpan::new(span.start, Some((0, span.start)), 1) - else { - refused(); - continue; - }; - let mut landing_events = automation::CellEvents::new( - cell_program, - state, - one, - automation::SeekMode::Ramp, - length, - ); - cursor.next = landing_events.next(); - cursor.events = Some(landing_events); - } - _ => { - let last = cursor.piece_last(0); - // Unreachable: a step kept inside the piece lies inside it. In release - // the channel is skipped: it emits nothing and the lane holds its gain. - let Some(node) = piece_span(span.start, step, 0, last) else { - refused(); - continue; - }; - cursor.last = last; - cursor.events = Some(automation::CellEvents::new( - cell_program, - state, - node, - mode, - jump, - )); - cursor.pull(); - } - } + let last = cursor.piece_last(0); + // Unreachable: a step kept inside the piece lies inside it. In release + // the channel is skipped: it emits nothing and the lane holds its gain. + let Some(node) = piece_span(span.start, step, 0, last) else { + refused(); + continue; + }; + cursor.last = last; + cursor.events = Some(automation::CellEvents::new( + cell_program, + state, + node, + mode, + jump, + )); + cursor.pull(); *slot = Some(cursor); used += 1; } @@ -2101,6 +2075,10 @@ impl HeldRampObligation { } Some(_) if due && self.owed => (0, Some(0), false), Some(_) => { + // A live write that freed a held stage at this entry (an exact unmute) discharges + // the move the hold owed; a stale `owed` would land a move over `J` at a later + // hold's end with nothing remembered there (#1711 G4, + // `an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move`). self.owed = false; (0, None, held_through && !due) } @@ -2115,54 +2093,52 @@ struct AdoptionBlock<'p, 'a> { span: TimelineSpan, offsets_sum: f64, hold: Option, - /// The block's jump length `J`. - jump: u32, } /// #1648 D2, at an adopted channel's first block: settle what [`LaneFaderAutomation::adopt`] left -/// owed, and whether the block must open with the adoption jump, and over how many samples. +/// owed; `true` when the block must open with the adoption jump (a ramped landing at its first +/// sample over the block's jump length `J`). /// /// A [`Adoption::Reposition`] places the cursor at the block's node time in the cell's own table -/// (#1637 D4) and reports the stage's held ramp again (batch 13b: `reposition` drops the one the -/// carried state held). That report changes no sample: a held stage only remembers a gain -/// operation of either kind, and the obligation is decided by where the gain is free, not by the -/// event's flag. It keeps the repositioned cell's events what the cell gives under a hold (held -/// events as `RememberGain`, as the test-only event probe reports them); batch 17 U3 fold, -/// MINOR 8: kept as bookkeeping, with no audible gate. On a free stage still in a ramp (a live -/// level edit over its own length, applied at this block's entry or heard before the swap, or a -/// jump) the repositioned cell holds that ramp again, and the adoption rule compares the curve -/// where the ramp ends, where the stage's target was taken (U3 fold 2, D15-7): an unmoved target -/// keeps the edit's own length, and only a moved one lands over the jump length. -/// Then, for a reposition or [`Adoption::Compare`], the adoption rule: when -/// the curve at the block's first sample (or that ramp's end), composed with this plan's `S` -/// (`checked_fader_gain(vca_compose_db(curve, S))`, #1648 departure 4), differs in bits from the -/// gain the stage aims for, the block opens with a jump (returns its length: `J`, or the frames -/// the free stage's own ramp has left when that is longer, so a running edit still ends when it -/// was due, on this table's curve; never shorter than `J`, so a moved target never lands faster -/// than a table change does). Otherwise a repositioned -/// cell takes the curve's value as its current target (`seed_target`: the stage already holds its -/// gain), and a continued one keeps its carried state. A block with a seek step lands the seek -/// instead (the caller's step wins): the seek sets the lane exactly where it applies. +/// (#1637 D4) and reports the stage's ramp again (batch 13b: `reposition` drops the one the +/// carried state held). On a held stage that report changes no sample: a held stage only +/// remembers a gain operation of either kind, and the obligation is decided by where the gain is +/// free, not by the event's flag (batch 17 U3 fold, MINOR 8: kept as bookkeeping, with no audible +/// gate). On a free stage still in a ramp of its own (a live level move over its own length, +/// applied at this block's entry or heard before the swap, or a jump), the ramp is not retargeted +/// (#1711 D1, R19-1): the cell compares the curve where the ramp ends, where the stage's target +/// was taken (U3 fold 2, D15-7). An unmoved target keeps the ramp; a moved one defers the jump to +/// the ramp's first free sample ([`automation::CellState::defer_jump`]), where the cell jumps over +/// `J` as at a discontinuity of its table, so the ramp ends when it was due. +/// +/// Then, for a reposition with no such ramp, a held stage, a step of the cell's own at the +/// block's first sample (B17-R13), or [`Adoption::Compare`], the curve at the block's first +/// sample is compared: composed with this plan's `S` (`checked_fader_gain(vca_compose_db(curve, +/// S))`, #1648 departure 4), when it differs in bits from the gain the stage aims for (a free +/// stage's ramp target, #1711 F0; a held stage's fader gain) the block opens with the adoption +/// jump. A repositioned cell whose comparison does not differ takes the curve's value as its +/// current target (`seed_target`: the stage already holds its gain), and a continued one keeps +/// its carried state. A block with a seek step lands the seek instead of the adoption jump (the +/// caller's step wins), and a seek landing drops a deferred jump. fn settle_adoption( adoption: Adoption, block: AdoptionBlock<'_, '_>, state: &mut automation::CellState, -) -> Option { +) -> bool { let AdoptionBlock { program, span, offsets_sum, hold, - jump, } = block; if adoption == Adoption::None { - return None; + return false; } - // The landing's length: `J`, or the frames the free stage's own ramp has left when longer. - let mut length = jump; // The node time at which the curve is compared: the block's first sample, or the end of a // ramp the free stage still runs (U3 fold 2). let mut at = span.start; + // The free stage's ramp's first free sample, where a moved target's jump is deferred to. + let mut free = None; if adoption == Adoption::Reposition { state.reposition(program, span.start); if let Some(ChannelHold { @@ -2172,15 +2148,12 @@ fn settle_adoption( && let Some(end) = node_time_at(span, u64::from(last)) { // A held stage: the held ramp again (batch 13b). A free stage: the ramp it runs is the - // cell's own (a live level edit over its own length, or a jump) and `reposition` - // dropped its hold, so the cell holds it again and compares the curve where it ends - // (U3 fold 2, D15-7): the edit keeps its own length unless the new table moved its - // target. + // cell's own and `reposition` dropped its hold, so the cell holds it again. state.hold_until(end); // A step of the cell's own at the block's first sample jumps over `J` there, as the // cell does at any discontinuity (and as a plan prepared with this table does), so - // the curve is compared at the block's first sample and the landing is `J` - // (B17-R13): `curve_toward` looks only past `start` and would skip it. + // the curve is compared at the block's first sample (B17-R13): `curve_toward` looks + // only past `start` and would skip it. if !held && program .discontinuities() @@ -2188,18 +2161,35 @@ fn settle_adoption( .is_err() { at = end; - length = length.max(remaining); + free = node_time_at(span, u64::from(remaining)); } } } let value = curve_toward(program, span.start, at); let differs = FaderEventDriver::gain(value, offsets_sum) - .zip(hold.and_then(|hold| hold.fader_gain)) + .zip(hold.and_then(|hold| hold.stage_gain)) .is_some_and(|(gain, stage)| gain.to_bits() != stage.to_bits()); - if !differs && adoption == Adoption::Reposition { - state.seed_target(value); + if !differs { + if adoption == Adoption::Reposition { + state.seed_target(value); + } + return false; } - differs.then_some(length) + match free { + Some(free) => { + state.defer_jump(free); + false + } + None => true, + } +} + +/// Release the hold of a cell whose stage stopped holding the lane at node time `release` (an +/// early unmute, #1651 T3b): the ramp a deferred jump waits for ended with it, so the deferral is +/// dropped too (#1711 D1; a held grid would otherwise freeze the lane, #1651 U1 MAJOR-1). +fn release_lane_hold(state: &mut automation::CellState, release: i64) { + state.release_hold_by(release); + state.clear_deferred_jump(); } /// The cut at offset `at` of a block whose offset 0 is node time `start`, with seek step `step`; @@ -4031,11 +4021,16 @@ fn automated_lanes( /// [`FaderEventDriver::new`] (#1647 D1.6). fn bank_hold(bank: &BuiltinFaderBank) -> impl Fn(usize, usize) -> Option + '_ { |lane, channel| { + let held = bank.gain_held(lane, channel)?; Some(ChannelHold { - held: bank.gain_held(lane, channel)?, + held, muted: bank.is_muted(lane, channel)?, remaining: bank.ramp_remaining(lane, channel)?, - fader_gain: bank.fader_gain(lane, channel), + stage_gain: if held { + bank.fader_gain(lane, channel) + } else { + bank.ramp_target(lane, channel) + }, }) } } @@ -8231,7 +8226,7 @@ fn render_oracle_fader( held: fader.gain_held(channel)?, muted: fader.is_muted(channel), remaining: fader.ramp_remaining(channel)?, - fader_gain: None, + stage_gain: None, }) }, ); diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index 013951955..203e219c4 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -4648,6 +4648,18 @@ impl BuiltinFaderBank { Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.fader_gain[channel][lane])) } + /// The gain a member lane's ramp on `channel` (`0` left, `1` right) runs toward: its settled + /// gain once the ramp ends. Unlike [`Self::fader_gain`], a gain the lane only remembered does + /// not move it (#1711 F0: a free carried lane's adoption compares its curve with this). + /// `None` for a padding lane or a channel above `1`. + #[must_use] + pub fn ramp_target(&self, lane: usize, channel: usize) -> Option { + if lane >= self.members || channel > 1 { + return None; + } + Some(per_width!(FaderStageKernel(stage) in &self.stage => stage.target_gain(lane, channel))) + } + /// Frames left in a member lane's ramp on `channel`; `0` when settled. `None` for a padding /// lane or a channel above `1`. #[must_use] diff --git a/crates/capi/include/miso_engine_v1.h b/crates/capi/include/miso_engine_v1.h index 4e3043ee5..4fc865294 100644 --- a/crates/capi/include/miso_engine_v1.h +++ b/crates/capi/include/miso_engine_v1.h @@ -180,10 +180,8 @@ extern "C" { * over the revisions the last advance covered of MISO_ENGINE_V1_OUTCOME_EXACT, * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK and MISO_ENGINE_V1_OUTCOME_SUPERSEDED. * MISO_ENGINE_V1_OUTCOME_TRANSITION_FALLBACK reports a warm successor's fallback to its - * transition, or a Reposition swap while a live level move may still ramp (conservative: it can be - * set when the move was not stretched; #1711 removes it), or a rebuild that carries a send - * following a stored mute into one that follows none (conservative: it can be set when no send - * moves at the swap; #1713 removes it). The host + * transition, or a rebuild that carries a send following a stored mute into one that follows + * none (conservative: it can be set when no send moves at the swap; #1713 removes it). The host * polls the watermark to learn that an edit is audible, never the event lane. The caller zeroes * reserved0 and reserved[] and sets struct_size, or the call returns * MISO_ENGINE_V1_INVALID_ARGUMENT; a copy that keeps landing inside render's publication diff --git a/crates/capi/src/abi.rs b/crates/capi/src/abi.rs index 8f0087200..202d2a5b5 100644 --- a/crates/capi/src/abi.rs +++ b/crates/capi/src/abi.rs @@ -93,10 +93,9 @@ pub const FEATURE_MASK: u64 = FEATURE_IMMUTABLE_SESSION /// Watermark outcome flag: the covered revisions completed exactly as committed. pub const OUTCOME_EXACT: u64 = 1; /// Watermark outcome flag: a warm successor could not adopt, and the covered revisions completed -/// through the transition it fell back to, or a Reposition swap while a live level move may still -/// ramp (conservative: it can be set when the move was not stretched; #1711 removes it), or a -/// rebuild that carries a send following a stored mute into one that follows none (conservative: -/// it can be set when no send moves at the swap; #1713 removes it). +/// through the transition it fell back to, or a rebuild that carries a send following a stored +/// mute into one that follows none (conservative: it can be set when no send moves at the swap; +/// #1713 removes it). pub const OUTCOME_TRANSITION_FALLBACK: u64 = 2; /// Watermark outcome flag: the covered revisions include ones a newer candidate folded in when it /// replaced the candidate that carried them. diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index b4df19aa9..16dc2c576 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -9056,20 +9056,23 @@ mod set_control { SessionEdit::UpsertAutomation { automation } } - /// U3 fold 2 (#1648 D2, D15-7). A live level move with its own 4,000 samples on the - /// riding fader (a VCA move and a hand move), and a rebuild whose only change is the - /// ride's table: the same ride split in two at sample 10,000, or a change only in its + /// U3 fold 2 (#1648 D2, D15-7) and #1711 G1. A live level move with its own 4,000 samples + /// on the riding fader (a VCA move and a hand move), and a rebuild whose only change is + /// the ride's table: the same ride split in two at sample 10,000, or a change only in its /// last 2,000 samples. The move is pending at the swap, in the rebuild's transaction, or - /// heard 1 and 12 blocks before it. Every block of the rebuilt rig stays within 1e-3 of - /// the uninterrupted engine's over 44 blocks, allocation-free, and the successor is - /// adopted. Measured: 0 pending, in the transaction and heard 1; 4.6e-6 heard 12, where - /// the landing over the rest of the move's ramp recomputes the stage's ramp step at the - /// swap (B17-R13, NIT 5). + /// heard 1, 12, 24, 26 and 30 blocks before it (3,872, 2,464, 928, 672 and 160 samples + /// left, the last three shorter than the fader length `J` = 960). Every block of the + /// rebuilt rig is bit-equal to the uninterrupted engine's over 44 blocks, allocation-free, + /// the successor is adopted, and the rebuild's watermark advance reports `OUTCOME_EXACT` + /// with no transition fallback (#1711 D2: the B17-R14 flag is deleted). A table-only + /// rebuild with no live move reports `OUTCOME_EXACT` too (outcome only). /// - /// Test value: red if the repositioned cell drops the hold of the ramp the free stage - /// still runs, or compares the curve at the block's first sample rather than at that - /// ramp's end with the stage's target: on a moving ride they differ, and the adoption - /// jump replaces the edit's 4,000 samples with the fader length (960; 0.047 at HEAD). + /// Test value: red if the adoption rule compares the curve with the stage's fader gain + /// rather than its ramp's target (#1711 F0: the held grid events only remember a gain, so + /// the fader gain follows the ride and every heard move lands at the swap; 4.6e-6 heard + /// 12, up to 3.1e-3 heard 24 to 30), if the repositioned cell drops the hold of the ramp + /// the free stage still runs, or if it compares the curve at the block's first sample + /// rather than at that ramp's end. #[test] fn a_level_move_across_a_table_change_keeps_its_length() { bench_support::alloc::assert_installed(); @@ -9086,11 +9089,32 @@ mod set_control { (SOURCE_FRAMES - 2_000, -6.0), ), ]; + // A table-only rebuild with no live move completes exactly too (bits are not + // compared: the split table's values differ from the ride's in `f32` rounding). + let mut still = Rig::new(&document); + for _ in 0..4 { + still.step(); + } + let offset = still.model().automation[0].offset; + send( + &mut still, + &[split_ride(&track, changes[0].1, offset)], + protocol::EditPath::Rebuild, + "no move", + ); + for _ in 0..4 { + still.step(); + } + assert_eq!(still.summary().3, 0, "no move: the successor is adopted"); + assert_exact(&still, "no move"); let timings = [ MoveTiming::Pending, MoveTiming::SameTransaction, MoveTiming::Heard(1), MoveTiming::Heard(12), + MoveTiming::Heard(24), + MoveTiming::Heard(26), + MoveTiming::Heard(30), ]; for (what, edit) in &moves { for &(change, table) in &changes { @@ -9131,29 +9155,10 @@ mod set_control { label, ); } - let [a, u, _] = &mut rigs; - let mut worst = (0.0_f32, 0_u64); - for _ in 0..44 { - let block = a.block; - let (x, used) = a.step_counted(); - assert_eq!( - (used.allocations, used.deallocations), - (0, 0), - "{label}: block {block} allocated or freed" - ); - for (p, q) in x.iter().zip(&u.step()) { - if (p - q).abs() > worst.0 { - worst = ((p - q).abs(), block); - } - } - } - assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); - assert!( - worst.0 < 1e-3, - "{label}: max |a - u| {} at block {}", - worst.0, - worst.1 - ); + let (first, _) = lockstep(&mut rigs, 44); + assert_eq!(rigs[0].summary().3, 0, "{label}: the successor is adopted"); + assert_eq!(first, None, "{label}: first difference (block, sample)"); + assert_exact(&rigs[0], label); } } } @@ -9194,8 +9199,9 @@ mod set_control { /// /// Test value: red if the adoption skips a step at the block's first sample (it compares /// the curve where the move's ramp ends and lands over the ramp's remaining length, not - /// over `J` at the step; 8.6e-2, checked once), or if a long landing runs over the whole - /// block, step included (the step one sample after the swap; 8.6e-2, checked once). + /// over `J` at the step; 8.6e-2, checked once), or if the step one sample after the swap + /// does not jump at its own sample (B17-R13 measured 8.6e-2 when a landing ran over the + /// whole block, step included; #1711 D1 removed that landing). #[test] fn a_step_at_a_reposition_swap_jumps_over_the_fader_length() { /// The session's fader jump length `J`. @@ -9287,132 +9293,233 @@ mod set_control { } } - /// The outcome flags and the transition-fallback count of `rig`'s watermark. - fn outcome(rig: &Rig) -> (u64, u64) { - let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); - assert_eq!(result, crate::RESULT_OK); - (watermark.outcome_flags, watermark.transition_fallback_count) + /// One moved-target case of #1711 G2 and R19-1 on the C ABI: on [`flat`]'s ride (-3 dB), + /// a live level move to -6 dB over its own 4,000 samples from block 4 (a VCA move or a + /// hand move), heard `heard` blocks, then a table-only rebuild to -5 dB up to 6,000, then + /// -3 dB (the move's target moves), and the `later` live moves `(block, dB, ramp)`. The + /// rebuilt rig is compared with a fresh plan prepared with the reference table (-3 dB to + /// the move's first free sample, -5 dB to 6,000, then -3 dB; with later moves, the + /// rebuild's own table) and given the same moves at the same blocks: from sample `from` + /// on every sample is bit-equal over 44 blocks after the swap, every block is + /// allocation-free, the successor is adopted, and every revision completed exactly (the + /// rebuild's and the later moves': `OUTCOME_EXACT`, no transition fallback). + fn moved_target_case(hand: bool, heard: usize, later: &[(u64, f32, u32)], from: u64) { + let free = 4 * 128 + 4_000; + let pieces = |pieces: &[(u64, u64, f32)]| { + let step = session::AutomationShape::Step; + pieces + .iter() + .map(|&(start, end, db)| segment(step, (start, end), (db, db))) + .collect::>() + }; + let table = pieces(&[(0, 6_000, -5.0), (6_000, SOURCE_FRAMES, -3.0)]); + let fresh_table = pieces(&[ + (0, free, -3.0), + (free, 6_000, -5.0), + (6_000, SOURCE_FRAMES, -3.0), + ]); + let reference = if later.is_empty() { + fresh_table + } else { + table.clone() + }; + rebuilt_table_case(hand, heard, (&table, &reference), later, from); } - /// B17-R14 (#1648 split, #1711). A table-only rebuild of the riding fader while a live - /// level move may still ramp completes as the transition fallback: a VCA move with its - /// own 4,000 samples heard 24, 26 and 30 blocks (928, 672 and 160 samples left, landed - /// over `J` = 960), and a VCA move committed inside a rebuild, heard 12 blocks, then a - /// table-only rebuild (the rebuild path's moves). The watermark advance that covers the - /// rebuild reports `OUTCOME_TRANSITION_FALLBACK`, not `OUTCOME_EXACT`. A table-only - /// rebuild with no move, and one after a move whose ramp has ended (heard 40 blocks), - /// report `OUTCOME_EXACT`. - /// - /// Test value: red if the control plane does not mark the candidate (the stretched move - /// then completes as exact, checked once with the `set_outcome` line removed), if a live - /// move or a rebuild's level change is not tracked, or if a rebuild with no move in - /// flight is marked (the flag must stay narrow). - #[test] - fn a_reposition_swap_while_a_level_move_ramps_reports_a_fallback() { - let fallback = crate::abi::OUTCOME_TRANSITION_FALLBACK; - let exact = crate::abi::OUTCOME_EXACT; - let document = riding(); - let model = parse_session_json(&document).expect("model"); + /// [`moved_target_case`] with the rebuild's `table` and the fresh plan's `reference` + /// table (both track 0's ride segments) on [`flat`]'s ride. + fn rebuilt_table_case( + hand: bool, + heard: usize, + (table, reference): (&[session::AutomationSegment], &[session::AutomationSegment]), + later: &[(u64, f32, u32)], + from: u64, + ) { + const MOVE: u32 = 4_000; + bench_support::alloc::assert_installed(); + let document = flat(); let track = first_track(&document); - let rebuild = split_ride(&track, (10_000, -6.0), session::AutomationOffset::default()); - let edit = vca_move(-6.0, Some(4_000)); - // (label, the move: none, live or inside a rebuild, blocks heard, expected flags) - let cases = [ - ("no move", None, 0, exact), - ("a live move heard 24", Some(false), 24, fallback), - ("a live move heard 26", Some(false), 26, fallback), - ("a live move heard 30", Some(false), 30, fallback), - ("a live move heard 40 (ended)", Some(false), 40, exact), - ("a move inside a rebuild heard 12", Some(true), 12, fallback), - ]; - for (label, inside, heard, expected) in cases { - let mut a = Rig::new(&document); + let label = &format!("hand {hand}, heard {heard}, {} later moves", later.len()); + let ride = |segments: &[session::AutomationSegment], offset| { + let mut automation = entry(&track, segments.to_vec()); + automation.offset = offset; + automation + }; + let move_of = |document: &str, db: f32, ramp: u32| { + if hand { + hand_move(document, db, Some(ramp)) + } else { + vca_move(db, Some(ramp)) + } + }; + let mut a = Rig::new(&document); + let mut model = parse_session_json(&document).expect("model"); + model.automation[0] = ride(reference, session::AutomationOffset::default()); + let fresh = session::canonical_session_json(&model).expect("canonical"); + let mut r = Rig::new(&fresh); + for rig in [&mut a, &mut r] { for _ in 0..4 { - a.step(); + rig.step(); } - match inside { - None => {} - Some(false) => send( - &mut a, - core::slice::from_ref(&edit), - protocol::EditPath::Live, - label, - ), - Some(true) => { - let mut edits = vec![edit.clone()]; - edits.extend(add_muted_track(&model, "added")); - send(&mut a, &edits, protocol::EditPath::Rebuild, label); + } + send( + &mut a, + &[move_of(&document, -6.0, MOVE)], + protocol::EditPath::Live, + label, + ); + send( + &mut r, + &[move_of(&fresh, -6.0, MOVE)], + protocol::EditPath::Live, + label, + ); + let (mut x, mut y) = (Vec::new(), Vec::new()); + for _ in 0..heard { + x.push(a.step()); + y.push(r.step()); + } + let offset = a.model().automation[0].offset; + send( + &mut a, + &[SessionEdit::UpsertAutomation { + automation: ride(table, offset), + }], + protocol::EditPath::Rebuild, + label, + ); + for _ in 0..44 { + for &(block, db, ramp) in later { + if a.block == block { + send( + &mut a, + &[move_of(&document, db, ramp)], + protocol::EditPath::Live, + label, + ); + send( + &mut r, + &[move_of(&fresh, db, ramp)], + protocol::EditPath::Live, + label, + ); } } - for _ in 0..heard { - a.step(); - } - send( - &mut a, - core::slice::from_ref(&rebuild), - protocol::EditPath::Rebuild, - label, - ); - for _ in 0..4 { - a.step(); - } - assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); - let (flags, fallbacks) = outcome(&a); - assert_eq!(flags, expected, "{label}: the rebuild's outcome flags"); + let block = a.block; + let (out, used) = a.step_counted(); assert_eq!( - fallbacks, - u64::from(expected == fallback), - "{label}: transition-fallback count" + (used.allocations, used.deallocations), + (0, 0), + "{label}: block {block} allocated or freed" ); + x.push(out); + y.push(r.step()); } + assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); + assert_exact(&a, label); + let quantum = a.quantum as u64; + let first = x.iter().zip(&y).enumerate().find_map(|(index, (x, y))| { + let start = (4 + index as u64) * quantum; + x.iter().zip(y).enumerate().find_map(|(i, (p, q))| { + let sample = start + i as u64 % quantum; + (sample >= from && p.to_bits() != q.to_bits()).then_some((sample, i)) + }) + }); + assert_eq!(first, None, "{label}: first (sample, index) that differs"); } - /// B17-R13 tranche 5 (verifier MAJOR 1). A live VCA move with a 500-sample ramp, heard 2 - /// blocks, is stretched to `J` (960) by a table-only rebuild R1, which reports the - /// transition fallback; a second table-only rebuild R2 sent 5 blocks after R1's adoption, - /// inside that landing over `J` but past the move's own 500 samples, stretches it again - /// and reports the fallback too. + /// #1711 G2 on the C ABI (A3). A VCA or hand move heard 12 and 30 blocks (2,464 and 160 + /// samples left; `J` = 960) whose target a table-only rebuild moves: the cell jumps over + /// `J` at the move's first free sample (4,512), so every sample is bit-equal to a fresh + /// plan whose table has that jump as a real discontinuity there. Case (c): a second live + /// move to -9 dB over 1,200 samples from block 35 (before 4,512, ending after + /// `4,512 + J`) clears the deferral; from its last sample on every sample is bit-equal to + /// a fresh plan with the rebuild's table given both moves. /// - /// Test value: red if a flagged rebuild does not keep the level-move window open for `J` - /// from its adoption (R2 then reports `OUTCOME_EXACT`, checked once). + /// Test value: red if the adoption lands a moved target at the swap instead of deferring + /// its jump (the previous landing over `max(J, remaining)`), or if the grid is not held + /// until the deferral fires. #[test] - fn a_second_reposition_inside_a_stretched_landing_reports_a_fallback() { - let fallback = crate::abi::OUTCOME_TRANSITION_FALLBACK; - let document = riding(); - let track = first_track(&document); - let offset = session::AutomationOffset::default(); - let mut a = Rig::new(&document); - for _ in 0..4 { - a.step(); + fn a_moved_target_jumps_at_a_live_moves_first_free_sample() { + for hand in [false, true] { + for heard in [12, 30] { + moved_target_case(hand, heard, &[], 0); + moved_target_case(hand, heard, &[(35, -9.0, 1_200)], 35 * 128 + 1_199); + } } - send( - &mut a, - &[vca_move(-6.0, Some(500))], - protocol::EditPath::Live, - "the move", - ); - for _ in 0..2 { - a.step(); + } + + /// #1711 R19-1 on the C ABI. A VCA or hand move heard 1 and 12 blocks (3,872 and 2,464 + /// samples left, both at least `J` = 960) whose target a table-only rebuild moves: the + /// move's ramp runs to its end as the uninterrupted engine runs it, then the cell jumps + /// over `J`; every sample is bit-equal to the fresh plan with that jump at the move's + /// first free sample. + /// + /// Test value: red if the adoption retargets the move at the swap rather than deferring + /// the jump to the ramp's end. + #[test] + fn a_moved_target_lets_a_live_move_longer_than_the_jump_end_when_it_was_due() { + for hand in [false, true] { + for heard in [1, 12] { + moved_target_case(hand, heard, &[], 0); + } } - for (label, end_db, fallbacks) in [("R1", -6.0, 1), ("R2", -9.0, 2)] { - send( - &mut a, - &[split_ride(&track, (10_000, end_db), offset)], - protocol::EditPath::Rebuild, - label, + } + + /// #1711 U1 fold 1 (MAJOR-1) on the C ABI. A VCA or hand move heard 1 and 4 blocks on + /// the ride flat at -3 dB, then a table-only rebuild to -5 dB up to 2,000, a linear -8 to + /// -14 dB over 2,000 to 8,000, then -14 dB: the move's target moves, so its jump is + /// deferred to the move's first free sample (4,512), and the table's step at 2,000, inside + /// the move's remaining ramp, replaces the ramp the deferral waits for. Every sample is + /// bit-equal to a fresh plan whose table is -3 dB before 2,000 (the same after it), given + /// the same move; every revision completes as `OUTCOME_EXACT`. + /// + /// Test value: red if a discontinuity jump on a restartable cell leaves the pending + /// deferral in place (its grid hold freezes the lane on the step's target until 4,512). + #[test] + fn a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral() { + let stepped = |first: f32| { + let (step, lin) = ( + session::AutomationShape::Step, + session::AutomationShape::Linear, ); - for _ in 0..5 { - a.step(); + vec![ + segment(step, (0, 2_000), (first, first)), + segment(lin, (2_000, 8_000), (-8.0, -14.0)), + segment(step, (8_000, SOURCE_FRAMES), (-14.0, -14.0)), + ] + }; + for hand in [false, true] { + for heard in [1, 4] { + rebuilt_table_case(hand, heard, (&stepped(-5.0), &stepped(-3.0)), &[], 0); } - assert_eq!(a.summary().3, 0, "{label}: the successor is adopted"); - assert_eq!( - outcome(&a), - (fallback, fallbacks), - "{label}: the rebuild's outcome flags and the transition-fallback count" - ); } } + /// The outcome flags and the transition-fallback count of `rig`'s watermark. + fn outcome(rig: &Rig) -> (u64, u64) { + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + (watermark.outcome_flags, watermark.transition_fallback_count) + } + + /// The watermark covers `rig`'s newest committed revision, its last advance reports + /// `OUTCOME_EXACT` alone, and no revision of the rig completed as the transition fallback. + fn assert_exact(rig: &Rig, label: &str) { + let (result, watermark) = crate::ffi::test_plan_watermark(rig.plan); + assert_eq!(result, crate::RESULT_OK); + assert_eq!( + watermark.revision, + rig.summary().0, + "{label}: the watermark covers the newest revision" + ); + assert_eq!( + outcome(rig), + (crate::abi::OUTCOME_EXACT, 0), + "{label}: the outcome flags and the transition-fallback count" + ); + } + /// U3 fold 2, MINOR (#1648 D2, D15-2). Two live VCA moves around a rebuild, with no block /// between any of them: a move pending at a rebuild whose own transaction moves `v` /// again, and a move, a rebuild, then a second live move. The first move is replaced @@ -9595,6 +9702,70 @@ mod set_control { } } + /// #1711 G4 (A1): an exact hand unmute that frees a lane held over a moving ride + /// discharges the move the hold owed. The first track rides linearly from 0 to -6 dB over + /// `[0, 1,280)`, holds -6 dB to 2,320, then rides on to -12 dB. A hand mute (ramp 256) at + /// block 4 and an exact hand unmute (ramp 0) at block 8 hold the lane over moving grid + /// points (a move is owed, then the unmute frees the lane at its block's entry); a hand + /// mute (256) at block 12 and a hand unmute (200) at block 16 hold it again where the ride + /// is flat, so nothing is remembered in that hold, whose end `E` is 2,248. From block 9 on + /// every sample equals a rig that took only the second hold, and every block is + /// allocation-free. + /// + /// Test value: red if the free write at the unmute's entry does not discharge the owed + /// move (the `owed = false` of `HeldRampObligation::block`): the stale obligation then + /// lands a move over `J` at `E` with nothing remembered (first difference block 17). + #[test] + fn an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move() { + bench_support::alloc::assert_installed(); + let document = session_with(|model, first, _| { + let lin = session::AutomationShape::Linear; + model.automation.push(entry( + first, + vec![ + segment(lin, (0, 1_280), (0.0, -6.0)), + segment(lin, (1_280, 2_320), (-6.0, -6.0)), + segment(lin, (2_320, SOURCE_FRAMES), (-6.0, -12.0)), + ], + )); + }); + let track = first_track(&document); + let mute = |muted: bool, ramp: u32| { + [set( + builtin(&track, MUTE, Lane::Both), + f32::from(u8::from(muted)), + Some(ramp), + )] + }; + let first_hold = [(4, mute(true, 256)), (8, mute(false, 0))]; + let second_hold = [(12, mute(true, 256)), (16, mute(false, 200))]; + let mut a = Rig::new(&document); + let mut r = Rig::new(&document); + for _ in 0..40 { + let block = a.block; + for (at, edit) in first_hold.iter().chain(&second_hold) { + if *at == block { + send(&mut a, edit, protocol::EditPath::Live, "both holds"); + } + } + for (at, edit) in &second_hold { + if *at == block { + send(&mut r, edit, protocol::EditPath::Live, "the second hold"); + } + } + let (x, used) = a.step_counted(); + assert_eq!( + (used.allocations, used.deallocations), + (0, 0), + "block {block} allocated or freed" + ); + let y = r.step(); + if block >= 9 { + assert_eq!(bits(&x), bits(&y), "block {block}"); + } + } + } + /// U3 fold, MINOR 8 (#1648 D2, the reposition's seeding). A live fader move to -6 dB /// (the session's 960 samples) and a rebuild that adds a ride on the same track commit in /// one gap between blocks. The ride holds 0 dB until sample 100 and -6 dB after, so the @@ -11080,13 +11251,13 @@ fn a_following_send_leaks_nothing_of_a_stored_mute() { /// keeps the entry (the following route stays following, seven -> seven) and a rebuild of a /// session with no following route (no mute entry) complete as `OUTCOME_EXACT`, count 0. A second /// rebuild that automates `eq0`'s fader in the block after the first one's adoption (inside `J`) -/// completes `OUTCOME_EXACT` too, count still 1: only a level fallback opens the level-move window. +/// completes `OUTCOME_EXACT` too, count still 1: the flag marks its own candidate only. /// /// Test value: red if the control plane does not mark a seven -> six rebuild (the six-word /// successor's re-timed column move then completes exact), or if it marks a rebuild whose /// following routes stay following, or one with no following route (the flag must stay narrow), -/// or if the flagged rebuild opens the level-move window as a level fallback does (the fader -/// rebuild inside its landing then reports the fallback: `landing` read the whole outcome). +/// or if the flag outlives its own candidate (the fader rebuild after it then reports the +/// fallback). #[test] fn a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback() { let remove = SessionEdit::RemoveAutomation { @@ -11154,10 +11325,9 @@ fn a_rebuild_that_drops_a_following_route_to_six_words_reports_a_fallback() { "{label}: the rebuild's revision, outcome flags and transition-fallback count" ); } - // Fold-2 NIT-1: the level-move window opens for a level fallback only. The seven -> six - // rebuild above is flagged but moves no level, so a second rebuild that automates a fader - // in the block after its adoption (inside `J` = 240 samples of it) finds no level move in - // flight and completes exact; the count stays 1. + // Fold-2 NIT-1: the flag marks its own candidate only. The seven -> six rebuild above is + // flagged, and a second rebuild that automates a fader in the block after its adoption + // (inside `J` = 240 samples of it) completes exact; the count stays 1. let document = follow_document(true, false); let mut rig = Rig::new(&document); for _ in 0..8 { diff --git a/crates/control-plane/src/compile.rs b/crates/control-plane/src/compile.rs index 18a854725..722185aba 100644 --- a/crates/control-plane/src/compile.rs +++ b/crates/control-plane/src/compile.rs @@ -1480,7 +1480,6 @@ where observation_armed_bytes: 0, observation_slots_high_water: 0, observation_forwarded: Vec::new(), - level_moves: None, }; let (offsets, pending, counts, armed, armed_bytes) = crate::control::observation_slots( &session.providers.effects, diff --git a/crates/control-plane/src/control.rs b/crates/control-plane/src/control.rs index f7cd40464..5923812d0 100644 --- a/crates/control-plane/src/control.rs +++ b/crates/control-plane/src/control.rs @@ -646,89 +646,6 @@ pub struct SessionState { /// from the candidate it superseded (#1310, root ruling B15-Q4); empty after a rebuild that /// superseded nothing. pub(crate) observation_forwarded: Vec, - /// The level moves that may still ramp, as one conservative window (root ruling B17-R14, - /// #1648 split; #1711 deletes it): `None` when none may. - pub(crate) level_moves: Option, -} - -/// Every live level move (a fader or offsets record with a ramp) and every level change a rebuild -/// commits, folded into one bound (B17-R14): the newest revision among them and the longest ramp -/// any of them takes. A move is in effect from at most the watermark's `first_sample` once the -/// watermark covers its revision (the watermark is monotone), so every move has ended once the -/// watermark covers `revision` and the render clock is at or past `first_sample + ramp`. The bound -/// only errs toward "may still ramp", and it is one word pair: nothing grows with the moves. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct LevelMoveWindow { - revision: u64, - ramp: u32, -} - -impl LevelMoveWindow { - /// Whether some move of the window may still ramp, given the watermark (`None` when a read - /// gave up: then it may) and the render clock read after it. - fn may_ramp(self, watermark: Option, render_clock: u64) -> bool { - watermark.is_none_or(|watermark| { - watermark.revision < self.revision - || render_clock < watermark.first_sample.saturating_add(u64::from(self.ramp)) - }) - } -} - -/// A transaction's level moves: the newest revision and the longest ramp (B17-R14). -fn fold_level_move(window: &mut Option, revision: u64, ramp: u32) { - *window = Some(match *window { - Some(window) => LevelMoveWindow { - revision: window.revision.max(revision), - ramp: window.ramp.max(ramp), - }, - None => LevelMoveWindow { revision, ramp }, - }); -} - -/// The longest ramp a level change a rebuild commits can take at its successor's first block -/// (B17-R14): the session's fader length (the jump length `J` and the default record ramp), or a -/// longer one the transaction gave a row. -fn rebuild_level_ramp(model: &session::SessionModel, edit_ramps: &session::EditRamps) -> u32 { - edit_ramps - .entries() - .into_iter() - .map(|(_, _, samples)| samples) - .fold( - controls::LiveRamps::for_session(model).fader_samples, - u32::max, - ) -} - -/// Whether some strip both models hold has a different effective fader or `S` on a lane in -/// `next` (B17-R14): a level change the rebuild's successor applies at its first block, and -/// which ramps from there. -fn rebuild_moves_a_level( - committed: &session::SessionModel, - next: &session::SessionModel, - monitor: &controls::MonitorOverlay, - next_monitor: &controls::MonitorOverlay, -) -> bool { - let (before, after) = ( - controls::effective(committed, monitor), - controls::effective(next, next_monitor), - ); - let track_count = committed.tracks.len(); - let ids: Vec<&str> = committed.strips().map(|strip| strip.id.as_str()).collect(); - next.strips().zip(&after.strips).any(|(strip, value)| { - host_core::strip_position( - ids.len(), - track_count, - |index| ids[index], - strip.id.as_str(), - ) - .and_then(|index| before.strips.get(index)) - .is_some_and(|old| { - (0..2).any(|lane| { - old.fader_db[lane].to_bits() != value.fader_db[lane].to_bits() - || old.offsets_sum[lane].to_bits() != value.offsets_sum[lane].to_bits() - }) - }) - }) } /// The stable key of one observation tap, borrowed from the bytes that name it (#1677): the strip's @@ -2618,17 +2535,6 @@ where }; #[cfg(not(feature = "test-support"))] let adoption = PlanAdoption::Next; - // B17-R14 (#1648 split; #1711 deletes this): a successor that automates a fader lane may - // Reposition it (a changed table, or a moved input arrival), and a level move still - // ramping there is landed over `J` when less than `J` is left. Control cannot tell which - // lanes reposition or how much is left, so the candidate completes as the transition - // fallback whenever any level move may still ramp. The rebuild's own level changes join - // the window with the commit. A declared discontinuity carries nothing. The test is the - // fader row alone (#1651 T2; U1 fold 1, R18-4 withdrawn): a mute cell's adoption lands - // only a `SetMute` (the curve's change over the mute length, a terms record committed in - // the same transaction over its own ramp), never a level move over `J`, so a successor - // that automates only a mute row completes `Exact`. - // // #1652 U2a fold 2 (MAJOR-A; #1713 deletes this): a successor whose send join carries a // following route into a six-word route (a route whose source strip automates a mute // lane in the committed plan and automates none in the successor: the rebuild removes @@ -2636,42 +2542,11 @@ where // render cannot report: one ramp of the six-word route takes the session mute length for // a join record, and folds two columns' moves into one. Such a rebuild completes as the // transition fallback, whether or not a column moves at the swap: conservative, it - // over-reports. It is not a level move, so it does not open the level-move window. - let (outcome, rebuild_level) = match &kind { - Rebuild::Transaction(prepared) => { - let session = prepared.get().prospective_session(); - let next = session - .compiled() - .ok_or(CommandError::Internal)? - .normalized_model(); - let committed = self.controller.session().compiled().normalized_model(); - let automates = next - .automation - .iter() - .any(|entry| controls::fader_row(&entry.target)); - let in_flight = self.level_moves.is_some_and(|window| { - window.may_ramp( - self.shared.watermark.read().ok(), - self.publisher.render_clock(), - ) - }); - let level_fallback = automates && in_flight; - let outcome = if level_fallback || follow_into_six_word { - engine::realtime::CandidateOutcome::TransitionFallback - } else { - engine::realtime::CandidateOutcome::Exact - }; - let moved = - rebuild_moves_a_level(committed, next, &self.monitor, &monitor_change.overlay) - .then(|| rebuild_level_ramp(next, session.edit_ramps())); - // A rebuild flagged for a level move may land a stretched move over `J` from its - // first block, so the window stays open for `J` from there: a second Reposition - // inside that landing stretches it again (verifier MAJOR 1, B17-R13 tranche 5). - let landing = - level_fallback.then(|| controls::LiveRamps::for_session(next).fader_samples); - (outcome, moved.max(landing)) - } - Rebuild::Discontinuity(_) => (engine::realtime::CandidateOutcome::Exact, None), + // over-reports. + let outcome = if matches!(kind, Rebuild::Transaction(_)) && follow_into_six_word { + engine::realtime::CandidateOutcome::TransitionFallback + } else { + engine::realtime::CandidateOutcome::Exact }; let reservation = match self .publisher @@ -2863,11 +2738,6 @@ where // withdrawn candidate, drops below as a commit's displaced model does. Rebuild::Discontinuity(model) => (None, Some(*model)), }; - // B17-R14: the rebuild's own level changes ramp from its successor's first block, and a - // flagged rebuild's landing over `J` does too. - if let Some(ramp) = rebuild_level { - fold_level_move(&mut self.level_moves, revision, ramp); - } // #1559 D8: the overlay is written after the last fallible step, with the commit. let monitor_ramp = writes_monitor.then_some(monitor_change.ramp); let previous_monitor = core::mem::replace(&mut self.monitor, monitor_change.overlay); @@ -3516,27 +3386,6 @@ where } else { protocol::EditPath::Live }; - // B17-R14: the longest ramp of the level moves this commit writes (fader and offsets - // records with a ramp), folded into the window once committed. - let level_ramp = delta - .strips - .iter() - .flat_map(|strip| { - let fader = strip.fader_records().filter_map(|record| match record { - host_core::TrackFaderRecord::FaderDb { - smoothing_samples, .. - } => Some(smoothing_samples), - host_core::TrackFaderRecord::Mute { .. } => None, - }); - let offsets = strip - .offsets - .iter() - .flatten() - .map(|record| record.smoothing_samples); - fader.chain(offsets) - }) - .filter(|&samples| samples != 0) - .max(); drop(delta); let published = core::mem::take(&mut owners.begun); @@ -3552,9 +3401,6 @@ where ); // #1559 D8: the overlay is written after the last fallible step, with the commit. self.monitor = monitor_change.overlay; - if let Some(ramp) = level_ramp { - fold_level_move(&mut self.level_moves, revision, ramp); - } for index in published { owners.effects[index] .commit_owner() diff --git a/crates/control-plane/src/plan.rs b/crates/control-plane/src/plan.rs index 292b89fae..376f488bc 100644 --- a/crates/control-plane/src/plan.rs +++ b/crates/control-plane/src/plan.rs @@ -197,7 +197,7 @@ impl<'a> ObservedReservation<'a> { } /// The outcome the candidate's own revisions complete with (#1314 D1): the transition - /// fallback for a rebuild that may stretch a level move in flight (B17-R14). + /// fallback for a rebuild that carries a following route into a six-word route (#1713). pub(crate) fn set_outcome(&mut self, outcome: engine::realtime::CandidateOutcome) { self.inner .as_mut() diff --git a/crates/controls/src/automation.rs b/crates/controls/src/automation.rs index 2285075d2..4daf68a25 100644 --- a/crates/controls/src/automation.rs +++ b/crates/controls/src/automation.rs @@ -395,7 +395,7 @@ pub const MUTE_CELL_RULE: CellRule = CellRule { /// slice adds its row here, so preparation and the live classifier (#1648) read one list. /// /// A reader that means "the fader" tests [`fader_row`] instead (#1651 T2): the mute row gives no -/// lane a fader cell, a hand-move offset or B17-R14's window. +/// lane a fader cell or a hand-move offset. #[must_use] pub fn automation_row_renders(target: &ControlAddress) -> bool { fader_row(target) || mute_row(target) @@ -411,10 +411,9 @@ pub fn mute_row(target: &ControlAddress) -> bool { } /// Whether `target` is a strip's builtin `fader_db` row (row 5): the one rendered row whose entry -/// gives a lane its fader cell ([`fader_automation`]), its hand-move offset (#1650) and the -/// B17-R14 transition window. The readers of those test this predicate, not -/// [`automation_row_renders`], so an entry on another rendered row reaches none of them (#1651 -/// T2). +/// gives a lane its fader cell ([`fader_automation`]) and its hand-move offset (#1650). The +/// readers of those test this predicate, not [`automation_row_renders`], so an entry on another +/// rendered row reaches neither of them (#1651 T2). #[must_use] pub fn fader_row(target: &ControlAddress) -> bool { matches!(target.owner, Owner::Strip(_)) diff --git a/crates/host-core/tests/stored_fader_automation.rs b/crates/host-core/tests/stored_fader_automation.rs index fdb1333e6..a393cd8e4 100644 --- a/crates/host-core/tests/stored_fader_automation.rs +++ b/crates/host-core/tests/stored_fader_automation.rs @@ -2239,6 +2239,58 @@ fn acted( (outs.swap_remove(0), entries) } +/// #1711 G4 (A1): an exact live unmute that frees a lane held over a moving ride discharges the +/// move the hold owed. Track 0 (prepared unmuted, fader length 20 ms = 960 samples) rides +/// linearly from `0` to `-6` dB over `[0, 1_280)`, holds `-6` dB to 2,320, then rides on to +/// `-12` dB. A live mute (window 256) at 512 and an exact live unmute (window 0) at 1,024 hold +/// the lane over moving grid points (a move is owed, then the unmute frees the lane at that +/// entry); a mute (256) at 1,536 and an unmute (200) at 2,048 hold it again where the ride is +/// flat, so nothing is remembered in that hold, whose end `E` is 2,248. From 1,152 on both lanes +/// equal the rig that takes only the second hold, at quantum 128 on every render form (Simd8 on +/// AVX2, Simd4, the between-render forms and the scalar oracle). +/// +/// Test value: red if the free write at the unmute's entry does not discharge the owed move (the +/// `owed = false` of `HeldRampObligation::block`): the stale obligation then lands a move over +/// `J` at `E` with nothing remembered. +#[cfg(feature = "test-support")] +#[test] +fn an_exact_unmute_that_frees_a_held_lane_discharges_the_owed_move() { + use builtins::BuiltinLaneSelector::Both; + const FRAMES: u64 = 5_120; + let (model, _) = muted_ride( + vec![ + segment(AutomationShape::Linear, 0, 1_280, 0.0, -6.0), + segment(AutomationShape::Linear, 1_280, 2_320, -6.0, -6.0), + segment(AutomationShape::Linear, 2_320, 1 << 20, -6.0, -12.0), + ], + 20.0, + ); + let model = never_muted(&model); + let mute = |at: u64, muted: bool, window: u32| Act { + mute: Some((Both, muted, window)), + ..act(at) + }; + let second = [mute(1_536, true, 256), mute(2_048, false, 200)]; + let both: Vec = [mute(512, true, 256), mute(1_024, false, 0)] + .into_iter() + .chain(second) + .collect(); + let (out, entries) = acted(&model, 128, FRAMES, &both); + assert_eq!( + entries, + [512, 1_024, 1_536, 2_048], + "each act lands at its own entry" + ); + let (reference, _) = acted(&model, 128, FRAMES, &second); + for plane in 0..2 { + assert_eq!( + first_difference(&out[plane][1_152..], &reference[plane][1_152..]), + None, + "plane {plane}: first difference after 1,152" + ); + } +} + /// `model` with track 0 prepared unmuted: the never-muted twin. #[cfg(feature = "test-support")] fn never_muted(model: &SessionModel) -> SessionModel { @@ -2856,6 +2908,610 @@ fn a_ride_gained_on_a_carried_static_fader_jumps_from_the_stages_gain() { } } +// #1711 G1: a live level move in flight across a table-only rebuild. + +/// The block a [`level_move_run`] commits its live move at. +#[cfg(feature = "test-support")] +const MOVE_BLOCK: usize = 4; + +/// The length of the ride of [`level_move_model`], from `0` to `-12` dB. +#[cfg(feature = "test-support")] +const RIDE_FRAMES: u64 = 48_000; + +/// The live move of [`level_move_model`] at [`MOVE_BLOCK`]: `S` to `-6` dB over 4,000 samples. +#[cfg(feature = "test-support")] +const MOVE: (usize, (f64, u32)) = (MOVE_BLOCK, (-6.0, 4_000)); + +/// Two tracks (two bank lanes) at quantum 128 with fader length 20 ms (`J` = 960), track 0 +/// riding linearly from `0` to `-12` dB over [`RIDE_FRAMES`] (split in two at `split.0` with the +/// second segment ending at `split.1` dB when given: `-12` keeps the ride), with its entry's +/// stored offset `hand` dB and, for a VCA move, a VCA of track 0 at `vca` dB. +#[cfg(feature = "test-support")] +fn level_move_model(split: Option<(u64, f32)>, hand: f32, vca: Option) -> SessionModel { + let lin = AutomationShape::Linear; + let segments = match split { + None => vec![segment(lin, 0, RIDE_FRAMES, 0.0, -12.0)], + Some((at, end_db)) => { + let k = -12.0 * at as f32 / RIDE_FRAMES as f32; + vec![ + segment(lin, 0, at, 0.0, k), + segment(lin, at, RIDE_FRAMES, k, end_db), + ] + } + }; + move_model(segments, hand, vca) +} + +/// [`level_move_model`]'s session with track 0's ride `segments`. +#[cfg(feature = "test-support")] +fn move_model(segments: Vec, hand: f32, vca: Option) -> SessionModel { + let mut model = plain(2, 128); + model.control_smoothing = smoothing(20.0); + let mut entry = fader_entry(&model, 0, ParameterChannel::Both, segments); + (entry.offset.left_db, entry.offset.right_db) = (hand, hand); + model.automation = vec![entry]; + if let Some(db) = vca { + model.vcas = vec![session::Vca { + id: id("vca-move"), + fader: session::DualMonoFader { + left_db: db, + right_db: db, + left_mute: false, + right_mute: false, + }, + members: vec![model.tracks[0].id.clone()], + }]; + } + model +} + +/// What a [`level_move_run`] rendered. +#[cfg(feature = "test-support")] +struct LevelMoveRun { + /// Each block's output bits, both planes. + blocks: Vec>, + /// The render calls' `(allocations, reallocations, frees)`, summed. + allocations: (u64, u64, u64), + /// The block whose render adopted the successor, if one did. + adopted: Option, + /// The successor's `restarted_strips()`. + restarted: Vec>, +} + +/// `a` prepared with live controls on `backend` and rendered for `blocks` blocks of DC `1.0`; +/// at each `(block, record)` of `moves`, that block's entry writes the live offsets record +/// `record` (`S` and its ramp) on both channels of track 0, to the plan that renders it. With `b = Some((next, committed, swap))`, `next` is prepared as the successor of +/// the plan on the committed model `committed` and swapped in at block `swap` (the C ABI's +/// carry: `adopt_persisting`, the successor's gate published at the next revision). +#[cfg(feature = "test-support")] +fn level_move_run( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel, usize)>, + moves: &[(usize, (f64, u32))], + backend: graph_compiler::Backend, + blocks: usize, +) -> LevelMoveRun { + level_move_run_muted(a, b, moves, &[], backend, blocks) +} + +/// [`level_move_run`] with live hand mutes: at each `(block, (muted, window))` of `mutes`, that +/// block's entry also writes the live mute record `muted` (ramp `window`) on both lanes of track +/// 0, after the block's live moves, to the plan that renders it. +#[cfg(feature = "test-support")] +fn level_move_run_muted( + a: &SessionModel, + b: Option<(&SessionModel, &SessionModel, usize)>, + moves: &[(usize, (f64, u32))], + mutes: &[LiveMute], + backend: graph_compiler::Backend, + blocks: usize, +) -> LevelMoveRun { + use builtins_compiler::TestOnlyWriteGate; + use engine::realtime::{PlanExchangeConfig, SwapOutcome, plan_exchange}; + let quantum = a.quantum_frames as usize; + let caps = rig_caps(a); + let request = host_core::HostLiveControlRequest::default(); + let fail = |failure: host_core::PrepareDiagnostics| -> ! { + panic!("prepare: {}", String::from_utf8_lossy(failure.as_bytes())) + }; + let compiled = |model: &SessionModel| { + let document = canonical_session_json(model).expect("canonical"); + host_core::compile_host_session(&document, &caps) + .unwrap_or_else(|failure| panic!("{}", String::from_utf8_lossy(failure.as_bytes()))) + }; + let (prepared, mut handles) = host_core::test_only_prepare_host_runtime_with_live_controls_on( + &compiled(a), + &caps, + &request, + backend, + ) + .unwrap_or_else(|failure| fail(failure)); + let PreparedHost { + mut plan, + mut sources, + inventory, + .. + } = prepared; + let mut gate = plan.take_revision_gate().expect("the plan's gate"); + let (mut publisher, mut owner, _retirer) = plan_exchange( + plan, + PlanExchangeConfig { + retirement_capacity: core::num::NonZeroUsize::MIN, + }, + ) + .unwrap_or_else(|_| panic!("plan exchange")); + bench_support::alloc::assert_installed(); + let plane = vec![1.0_f32; quantum]; + let mut run = LevelMoveRun { + blocks: Vec::with_capacity(blocks), + allocations: (0, 0, 0), + adopted: None, + restarted: Vec::new(), + }; + let mut successor_sources = None; + let mut successor_gate = None; + for block in 0..blocks { + if let Some((next, committed, swap)) = b + && block == swap + { + let committed = compiled(committed).normalized_model().clone(); + let revision = gate.last_published() + 1; + let base = host_core::SuccessorBase::new(&inventory, &committed, revision); + let (mut prepared, next_handles) = + host_core::test_only_prepare_host_runtime_with_live_controls_successor_on( + &compiled(next), + &caps, + &request, + base, + backend, + ) + .unwrap_or_else(|failure| fail(failure)); + prepared.sources.adopt_persisting(&mut sources); + run.restarted = prepared.restarted_strips().to_vec(); + let PreparedHost { + mut plan, + sources: next_sources, + .. + } = prepared; + let mut next_gate = plan.take_revision_gate().expect("the successor's gate"); + next_gate.publish(revision); + successor_gate = Some((next_handles, next_gate)); + publisher + .reserve_replacement(plan, engine::realtime::PlanAdoption::Next) + .unwrap_or_else(|_| panic!("reserve the successor")) + .commit(); + successor_sources = Some(next_sources); + } + for &(_, (offsets_sum, smoothing_samples)) in moves.iter().filter(|(at, _)| *at == block) { + let (handles, gate) = match successor_gate.as_mut() { + Some((handles, gate)) => (handles, gate), + None => (&mut handles, &mut gate), + }; + gate.write_published(|stamp| { + for channel in 0..2 { + handles.strip_controls[0].fader.write_offsets( + stamp, + channel, + builtins_compiler::FaderOffsetsRecord { + offsets_sum, + smoothing_samples, + }, + ); + } + }); + } + for &(_, (muted, smoothing_samples)) in mutes.iter().filter(|(at, _)| *at == block) { + let (handles, gate) = match successor_gate.as_mut() { + Some((handles, gate)) => (handles, gate), + None => (&mut handles, &mut gate), + }; + gate.write_published(|stamp| { + handles.strip_controls[0].fader.write( + stamp, + builtins_compiler::TrackFaderRecord::Mute { + lanes: builtins::BuiltinLaneSelector::Both, + muted, + smoothing_samples, + }, + ); + }); + } + let at = block * quantum; + successor_sources + .as_mut() + .unwrap_or(&mut sources) + .submit( + NINE_SOURCE, + SourceSubmission { + generation: 1, + start_frame: at as u64, + sample_rate_hz: RATE, + planes: &[&plane, &plane], + frames: quantum as u32, + end_of_region: false, + }, + ) + .expect("source block"); + let mut samples = vec![0.0_f32; quantum * 2]; + let output = + PlanarBufferMut::try_new(&mut samples, 2, quantum, quantum).expect("output planes"); + let mark = bench_support::alloc::current_thread_counters(); + let report = owner + .render_contiguous(RenderIo { output }, at as u64) + .unwrap_or_else(|error| panic!("render: {error:?}")); + let delta = bench_support::alloc::current_thread_delta_since(mark); + run.allocations.0 += delta.allocations; + run.allocations.1 += delta.reallocations; + run.allocations.2 += delta.deallocations; + if report.swap == SwapOutcome::Applied { + run.adopted = Some(block); + } + run.blocks + .push(samples.iter().map(|sample| sample.to_bits()).collect()); + } + drop(handles); + drop(successor_gate); + run +} + +/// #1711 G1 on host-core, at both bank widths. A live level move (`S` to `-6` dB over its own +/// 4,000 samples, from block 4) on the riding fader, heard 1, 12, 24, 26 and 30 blocks (3,872, +/// 2,464, 928, 672 and 160 samples left; `J` = 960), then a table-only rebuild: the same ride +/// split in two at 10,000, or a change only in its last 2,000 samples. The move is a VCA move +/// (the committed model's VCA moves) or a hand move (the ride's stored offset moves, R17-1). +/// Every block, before and after the swap, is bit-equal to the uninterrupted engine's, the +/// render is allocation-free, the successor is adopted at the swap block and the strip carries. +/// +/// Test value: red if the adoption rule compares the curve with the stage's fader gain rather +/// than its ramp's target (#1711 F0: the held grid events only remember a gain inside the move's +/// ramp, so the fader gain follows the ride and every heard move lands at the swap), or if it +/// compares at the block's first sample rather than at the ramp's end. +#[cfg(feature = "test-support")] +#[test] +fn a_level_move_in_flight_across_a_table_only_rebuild_keeps_its_bits() { + const AFTER: usize = 44; + let changes = [ + ("the ride split at 10,000", (10_000, -12.0)), + ( + "a change in the last 2,000 samples", + (RIDE_FRAMES - 2_000, -6.0), + ), + ]; + // (label, the stored hand offset after the move, the VCA before and after it) + let moves = [ + ("a VCA move", 0.0, Some((0.0_f32, -6.0_f32))), + ("a hand move", -6.0, None), + ]; + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for (what, hand, vca) in moves { + let a = level_move_model(None, 0.0, vca.map(|vca| vca.0)); + let committed = level_move_model(None, hand, vca.map(|vca| vca.1)); + for heard in [1, 12, 24, 26, 30] { + let swap = MOVE_BLOCK + heard; + let blocks = swap + AFTER; + let reference = level_move_run(&a, None, &[MOVE], backend, blocks); + assert_eq!(reference.allocations, (0, 0, 0)); + for (change, split) in changes { + let label = format!("{backend:?}, {what}, heard {heard}, {change}"); + let next = level_move_model(Some(split), hand, vca.map(|vca| vca.1)); + let run = level_move_run( + &a, + Some((&next, &committed, swap)), + &[MOVE], + backend, + blocks, + ); + assert_eq!(run.adopted, Some(swap), "{label}: adopted at the swap"); + assert!(run.restarted.is_empty(), "{label}: the strip carries"); + assert_eq!(run.allocations, (0, 0, 0), "{label}: allocation-free"); + assert_ne!( + reference.blocks[swap - 1], + level_move_run(&committed, None, &[], backend, swap).blocks[swap - 1], + "{label}: the move is still heard before the swap" + ); + let first = run + .blocks + .iter() + .zip(&reference.blocks) + .position(|(run, reference)| run != reference); + assert_eq!(first, None, "{label}: first block that differs"); + } + } + } + } +} + +// #1711 G2 and R19-1: a live level move whose target a table-only rebuild moves. + +/// The first free sample of [`MOVE`]'s ramp: the block-4 entry plus its 4,000 samples. +#[cfg(feature = "test-support")] +const MOVE_FREE: u64 = MOVE_BLOCK as u64 * 128 + MOVE.1.1 as u64; + +/// A step ride of track 0 through `(start, end, dB)` pieces. +#[cfg(feature = "test-support")] +fn steps_of(pieces: &[(u64, u64, f32)]) -> Vec { + pieces + .iter() + .map(|&(start, end, db)| segment(AutomationShape::Step, start, end, db, db)) + .collect() +} + +/// The moved-target setup (#1711 A3 G2): the ride flat at -3 dB; the rebuild's table -5 dB to +/// 6,000, then -3 dB (the move's target moves from -3 to -5 dB); the reference table, -3 dB to the +/// move's first free sample [`MOVE_FREE`], -5 dB to 6,000, then -3 dB. +#[cfg(feature = "test-support")] +fn moved_target_tables() -> [Vec; 3] { + [ + steps_of(&[(0, RIDE_FRAMES, -3.0)]), + steps_of(&[(0, 6_000, -5.0), (6_000, RIDE_FRAMES, -3.0)]), + steps_of(&[ + (0, MOVE_FREE, -3.0), + (MOVE_FREE, 6_000, -5.0), + (6_000, RIDE_FRAMES, -3.0), + ]), + ] +} + +/// One moved-target case at `backend`: [`MOVE`] heard `heard` blocks on the flat ride, then the +/// table-only rebuild of [`moved_target_tables`], and the `later` live moves after the swap. The +/// rebuilt run is compared with a fresh plan prepared with `reference` (the reference table, +/// or the rebuild's own with later moves) and given the same moves, from sample `from` on: every +/// sample bit-equal, allocation-free, adopted at the swap block, the strip carries. Returns the +/// rebuilt run's left plane. +#[cfg(feature = "test-support")] +fn moved_target_case( + backend: graph_compiler::Backend, + case: MovedTargetMove, + heard: usize, + later: &[(usize, (f64, u32))], + from: u64, +) -> Vec { + let [flat, table, fresh] = moved_target_tables(); + let reference = if later.is_empty() { + fresh + } else { + table.clone() + }; + rebuilt_case( + backend, + case, + heard, + [flat, table, reference], + (later, &[]), + from, + ) +} + +/// A live move of [`level_move_run`]: `(block, (S, window))`. +#[cfg(feature = "test-support")] +type LiveMove = (usize, (f64, u32)); + +/// A live hand mute of [`level_move_run_muted`]: `(block, (muted, window))`. +#[cfg(feature = "test-support")] +type LiveMute = (usize, (bool, u32)); + +/// [`moved_target_case`] over `[base, rebuild, reference]` tables with `later` live moves and +/// live hand `mutes` (both given to the rebuilt run and to the fresh reference plan). +#[cfg(feature = "test-support")] +fn rebuilt_case( + backend: graph_compiler::Backend, + (what, hand, vca): MovedTargetMove, + heard: usize, + [flat, table, fresh]: [Vec; 3], + (later, mutes): (&[LiveMove], &[LiveMute]), + from: u64, +) -> Vec { + const AFTER: usize = 44; + let swap = MOVE_BLOCK + heard; + let blocks = (swap + AFTER).max(later.iter().map(|(at, _)| at + 20).max().unwrap_or(0)); + let a = move_model(flat.clone(), 0.0, vca.map(|vca| vca.0)); + let committed = move_model(flat, hand, vca.map(|vca| vca.1)); + let next = move_model(table.clone(), hand, vca.map(|vca| vca.1)); + let reference = move_model(fresh, 0.0, vca.map(|vca| vca.0)); + let moves: Vec<_> = core::iter::once(MOVE) + .chain(later.iter().copied()) + .collect(); + let blocks = blocks.max(mutes.iter().map(|(at, _)| at + 20).max().unwrap_or(0)); + let label = format!( + "{backend:?}, {what}, heard {heard}, {} later moves, mutes {mutes:?}", + later.len() + ); + let run = level_move_run_muted( + &a, + Some((&next, &committed, swap)), + &moves, + mutes, + backend, + blocks, + ); + let expected = level_move_run_muted(&reference, None, &moves, mutes, backend, blocks); + assert_eq!(run.adopted, Some(swap), "{label}: adopted at the swap"); + assert!(run.restarted.is_empty(), "{label}: the strip carries"); + assert_eq!(run.allocations, (0, 0, 0), "{label}: allocation-free"); + let planes = |run: &LevelMoveRun, plane: usize| -> Vec { + run.blocks + .iter() + .flat_map(|block| block[plane * 128..(plane + 1) * 128].iter().copied()) + .collect() + }; + for plane in 0..2 { + let (x, y) = (planes(&run, plane), planes(&expected, plane)); + let first = (from as usize..x.len()).find(|&sample| x[sample] != y[sample]); + assert_eq!( + first, None, + "{label}: plane {plane}, first sample that differs" + ); + } + planes(&run, 0).into_iter().map(f32::from_bits).collect() +} + +/// One move of the moved-target cases: (label, the stored hand offset after the move, the VCA +/// before and after it). +#[cfg(feature = "test-support")] +type MovedTargetMove = (&'static str, f32, Option<(f32, f32)>); + +/// The two moves of the moved-target cases. +#[cfg(feature = "test-support")] +const MOVED_TARGET_MOVES: [MovedTargetMove; 2] = [ + ("a VCA move", 0.0, Some((0.0, -6.0))), + ("a hand move", -6.0, None), +]; + +/// #1711 G2 on host-core, at both bank widths. [`MOVE`] heard 12 and 30 blocks (2,464 and 160 +/// samples left; `J` = 960) on a ride flat at -3 dB, then a table-only rebuild to -5 dB up to +/// 6,000: the move's target moves, so the cell jumps over `J` at the move's first free sample +/// (4,512), not at the swap. Every sample is bit-equal to a fresh plan whose table has that jump +/// as a real discontinuity at 4,512 and that is given the same move. Case (c): a second live move +/// (`S` to -9 dB over 1,200 samples from block 35, before 4,512, ending after `4,512 + J`) clears +/// the deferral: from its last sample on, every sample is bit-equal to a fresh plan with the new +/// table given both moves, and the lane still moves on that move's last samples. +/// +/// Test value: red if the adoption lands a moved target at the swap instead of deferring its jump +/// (the old landing; checked once), if the grid is not held until the deferral fires (the grid +/// point 32 samples before the move's end restarts the ramp toward -5 dB; checked once), or, case +/// (c), if the newer move does not clear the deferral (the deferred jump then cuts the move short +/// at `4,512 + J`; checked once). The comparison point `at = end` is not observable here (on a +/// flat table both points read -5 dB); G1 (`a_level_move_in_flight_across_a_table_only_rebuild_ +/// keeps_its_bits`) holds it. +#[cfg(feature = "test-support")] +#[test] +fn a_moved_target_jumps_over_the_fader_length_at_the_moves_first_free_sample() { + const SECOND: (usize, (f64, u32)) = (35, (-9.0, 1_200)); + let second_last = SECOND.0 as u64 * 128 + u64::from(SECOND.1.1) - 1; + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for heard in [12, 30] { + moved_target_case(backend, case, heard, &[], 0); + let left = moved_target_case(backend, case, heard, &[SECOND], second_last); + let last = second_last as usize; + assert_ne!( + left[last - 1].to_bits(), + left[last].to_bits(), + "{backend:?}, {}, heard {heard}: the second move keeps its own length", + case.0 + ); + } + } + } +} + +/// #1711 R19-1 on host-core, at both bank widths. [`MOVE`] heard 1 and 12 blocks (3,872 and +/// 2,464 samples left, both at least `J` = 960) on the flat ride, then the table-only rebuild that +/// moves its target: the move's ramp runs to its end as the uninterrupted engine runs it, then +/// the cell jumps over `J`. Every sample is bit-equal to the fresh plan with that jump at the +/// move's first free sample. +/// +/// Test value: red if the adoption retargets the move at the swap (over its remaining frames, or +/// over `J`) instead of deferring the jump to the ramp's end. +#[cfg(feature = "test-support")] +#[test] +fn a_moved_target_lets_a_live_move_longer_than_the_jump_end_when_it_was_due() { + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for heard in [1, 12] { + moved_target_case(backend, case, heard, &[], 0); + } + } + } +} + +/// #1711 U1 fold 1 (MAJOR-1) on host-core, at both bank widths. [`MOVE`] heard 1 and 4 blocks +/// on the ride flat at -3 dB, then a table-only rebuild to -5 dB up to 2,000, a linear -8 to +/// -14 dB over 2,000 to 8,000, then -14 dB: the move's target moves, so the cell defers its jump +/// to the move's first free sample (4,512); the table's step at 2,000 lies inside the move's +/// remaining ramp, and its jump replaces the ramp the deferral waits for. Every sample is +/// bit-equal to a fresh plan whose table is -3 dB before 2,000 (the same table after it), given +/// the same move. +/// +/// Test value: red if a discontinuity jump on a restartable cell leaves a pending deferral in +/// place (its grid hold then freezes the lane on the step's target until 4,512; at the fold's +/// base the first difference was sample 2,944, max 2.9e-2). +#[cfg(feature = "test-support")] +#[test] +fn a_table_step_inside_a_deferred_moves_ramp_drops_the_deferral() { + let stepped = |first: f32| { + let mut segments = steps_of(&[(0, 2_000, first)]); + segments.push(segment(AutomationShape::Linear, 2_000, 8_000, -8.0, -14.0)); + segments.extend(steps_of(&[(8_000, RIDE_FRAMES, -14.0)])); + segments + }; + let [flat, ..] = moved_target_tables(); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for heard in [1, 4] { + rebuilt_case( + backend, + case, + heard, + [flat.clone(), stepped(-5.0), stepped(-3.0)], + (&[], &[]), + 0, + ); + } + } + } +} + +/// #1711 U1 fold 1 (MINOR-2) on host-core, at both bank widths. The G2 setup heard 12 blocks +/// (swap at 2,048, the deferred jump due at 4,512), with a live hand mute at 2,560 and an unmute +/// at 3,072 (inside the deferral) or at 5,120 (after it), each ramp 200 samples or exact. The +/// mute holds the lane and its release clears the deferral, so the lane lands on the new table at +/// the unmute: from the mute's end on, every sample is bit-equal to a fresh plan with the +/// rebuild's table given the same move and mutes. An unmute after 4,512 leaves the deferred jump +/// as it was: every sample is bit-equal to the fresh plan with that jump at 4,512. +/// +/// Test value: red if the release of a held lane leaves a pending deferral in place (its grid +/// hold then freezes the lane after the unmute until 4,512). +#[cfg(feature = "test-support")] +#[test] +fn a_hand_mute_inside_a_pending_deferral_lands_the_lane_on_the_new_table_at_the_unmute() { + const MUTE: usize = 20; + let [flat, _, fresh] = moved_target_tables(); + let mut table = vec![segment(AutomationShape::Linear, 0, 6_000, -5.0, -8.0)]; + table.extend(steps_of(&[(6_000, RIDE_FRAMES, -3.0)])); + let [_, deferred_table, _] = moved_target_tables(); + for backend in [ + #[cfg(target_feature = "avx2")] + graph_compiler::Backend::Simd8, + graph_compiler::Backend::Simd4, + ] { + for case in MOVED_TARGET_MOVES { + for window in [200, 0] { + let muted_from = MUTE as u64 * 128 + u64::from(window); + for (unmute, rebuild, reference, from) in [ + (24, &table, &table, muted_from), + (40, &deferred_table, &fresh, 0), + ] { + rebuilt_case( + backend, + case, + 12, + [flat.clone(), rebuild.clone(), reference.clone()], + (&[], &[(MUTE, (true, window)), (unmute, (false, window))]), + from, + ); + } + } + } + } +} + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] mod canonical_preparation { use super::*; diff --git a/docs/C_ABI_V1_QUALIFICATION.md b/docs/C_ABI_V1_QUALIFICATION.md index e0001c6ef..5be40e410 100644 --- a/docs/C_ABI_V1_QUALIFICATION.md +++ b/docs/C_ABI_V1_QUALIFICATION.md @@ -150,10 +150,8 @@ before it pushes a record, so the watermark never reads a revision the gate cann inside render's publication (bounded at 64 attempts) is `RESULT_BACKPRESSURE` with `out` untouched, and the host retries. In this slice every revision completes as `exact`; the `superseded` and `transition_fallback` producers are #1310, #1397 and #1358. `transition_fallback` -also reports a Reposition swap while a live level move may still ramp (conservative: it can be set -when the move was not stretched; #1711 removes it; root ruling B17-R14), and a rebuild that carries -a send following a stored mute into one that follows none (conservative: it can be set when no -send moves at the swap; #1713 removes it; #1652 U2a fold 2). The frozen exported +also reports a rebuild that carries a send following a stored mute into one that follows none +(conservative: it can be set when no send moves at the swap; #1713 removes it; #1652 U2a fold 2). The frozen exported set is now 16 `miso_engine_v1_*` definitions; `abi_smoke.c` checks the bit against the queried mask and queries a compiled plan with a valid and a wrong-size struct, `header_smoke.cpp` pins the size and every field offset, and diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 856f1dac1..9763e128b 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -2930,16 +2930,17 @@ fn a_fader_automation_edit_rebuilds_alike_on_both_hosts() { } } -/// B17-R14 (#1648 split, #1711): a table-only rebuild of a riding fader while a hand move with -/// its own 4,000 samples may still ramp completes as the transition fallback on both hosts, and -/// the same rebuild with no move in flight completes exactly. Both hosts take the one decision in -/// the shared control plane: the browser's status words carry the C ABI's watermark, flags -/// included, and both hosts render the same bits. +/// #1711 G1 across hosts (D2): a table-only rebuild of a riding fader while a hand move with its +/// own 4,000 samples still ramps completes exactly on both hosts, as the same rebuild with no move +/// in flight does: the adoption keeps the move's own length, so the B17-R14 transition fallback is +/// deleted. Both hosts take the one decision in the shared control plane: the browser's status +/// words carry the C ABI's watermark, flags included, neither host counts a transition fallback, +/// and both hosts render the same bits. /// -/// Test value: red if either host marks the rebuild differently (an adapter-local rule), or if -/// the shared decision is not reached through the browser's control path. +/// Test value: red if either host reports the rebuild differently (an adapter-local rule), or if +/// a rebuild with a level move in flight completes as the transition fallback. #[test] -fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both_hosts() { +fn a_hand_move_in_flight_across_a_table_only_rebuild_completes_exactly_alike_on_both_hosts() { let ride = |end_db: f32| SessionEdit::UpsertAutomation { automation: session::Automation { id: id("t1-fader-ride"), @@ -2969,14 +2970,7 @@ fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both Some(4_000), ); for rate in RATES { - for (label, moved, expected) in [ - ("no move", false, capi::OUTCOME_EXACT), - ( - "a hand move in flight", - true, - capi::OUTCOME_TRANSITION_FALLBACK, - ), - ] { + for (label, moved) in [("no move", false), ("a hand move in flight", true)] { let model = model_at(rate); let mut browser = Rig::new(&model); let mut c_abi = CAbi::new(&model); @@ -3004,25 +2998,337 @@ fn a_reposition_swap_while_a_level_move_ramps_reports_the_fallback_alike_on_both "{rate} Hz, {label}: the browser status carries the C ABI's watermark" ); assert_eq!( - watermark.2, expected, + watermark.2, + capi::OUTCOME_EXACT, "{rate} Hz, {label}: the rebuild's outcome flags" ); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + (0, 0), + "{rate} Hz, {label}: no transition fallback on either host" + ); } } } -/// #1651 gate 8 (U1 fold 1, MINOR-3): in a mute-only session (a stored mute curve on `t1`, no -/// fader entry), a rebuild that changes the mute curve while a hand fader move with its own 4,000 -/// samples still ramps completes exactly on both hosts: B17-R14's transition-fallback window is -/// the fader row's alone, because a mute cell's adoption lands a `SetMute`, never a level move -/// over `J`. Both hosts report the same watermark, `EXACT`, and no transition fallback, and render -/// the same bits. +/// The latency upstream of `t1`'s fader in [`model_at`]'s session, in samples: the console +/// post-insert slot `miso.soft-clip` declares 31 (its descriptor's quality latency); the +/// pre-insert compressor and `t1`'s EQ and compressor inserts declare 0. The fader's node time is +/// render time less it. No host reader reports one strip's upstream latency (the plan reports +/// only the output's, which `t3`'s limiter raises), so it is named here; the bit-equality of +/// [`a_moved_target_defers_its_jump_alike_on_both_hosts`] fails if it is wrong. +const T1_FADER_LATENCY: u64 = 31; + +/// #1711 G2 across hosts: on a fader ride flat at -3 dB, a hand move with its own 4,000 samples +/// heard 3 blocks, then a table-only rebuild to -5 dB up to 20,000, then -3 dB (the move's target +/// moves; the rebuild keeps the hand move's stored offset, R17-1): the adoption defers the cell's +/// jump to the move's first free sample on both hosts alike, through the one shared driver. Both +/// hosts give one verdict, reply and committed document per edit, render the same bits in every +/// block, and the rebuild completes exactly on both (`OUTCOME_EXACT`, no transition fallback; +/// #1711 D2). Every block, at the four rates, is bit-equal to a fresh pair whose table has the +/// jump as a real step at the move's first free sample in the fader's node time (render time +/// 4,512 less [`T1_FADER_LATENCY`]), given the same move and, at the same block, a table-only +/// rebuild that moves nothing (a split at 950,000), both keeping the stored offset. /// -/// Test value: red if the shared control plane opens the fallback window for a successor that -/// automates only a mute row (the candidate is flagged `TRANSITION_FALLBACK`), or if either host -/// marks the rebuild differently. +/// Test value: red if either host adopts the moved target differently (an adapter-local rule, or +/// a browser successor that does not carry the stage's ramp the deferral waits for), or if the +/// shared adoption does not defer the jump to the move's first free sample (it lands at the swap, +/// fires at another time, or the grid cuts the move short). #[test] -fn a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts() { +fn a_moved_target_defers_its_jump_alike_on_both_hosts() { + // The move's first free sample in the fader's node time: its block-4 entry in render time plus + // its 4,000 samples, less the latency upstream of `t1`'s fader. + const FREE: u64 = 4 * QUANTUM as u64 + 4_000 - T1_FADER_LATENCY; + let ride = |pieces: &[(u64, u64, f32)], offset| SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-fader-ride"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + 5, + ParameterChannel::Both, + ), + segments: pieces + .iter() + .map(|&(start, end, db)| session::AutomationSegment { + shape: session::AutomationShape::Step, + start_sample: start, + end_sample: end, + start_value: db, + end_value: db, + unit: session::ParameterUnit::Db, + }) + .collect(), + offset, + }, + }; + let none = session::AutomationOffset::default(); + let hand = CAbi::edits( + Edit::Fader { + strip: T1, + lanes: Lanes::Both, + db: -6.0, + }, + Some(4_000), + ); + for rate in RATES { + let model = model_at(rate); + let (mut browser, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); + let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); + let reference = |split: u64, offset| { + ride( + &[ + (0, FREE, -3.0), + (FREE, 20_000, -5.0), + (20_000, split, -3.0), + (split, 1_000_000, -3.0), + ], + offset, + ) + }; + let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { + let (b, b_reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{rate} Hz, {label}: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz, {label}: one reply"); + }; + both( + &[ride(&[(0, 1_000_000, -3.0)], none)], + &mut browser, + &mut c_abi, + "the flat ride", + ); + both( + &[reference(900_000, none)], + &mut browser_r, + &mut c_abi_r, + "the reference ride", + ); + for block in 0..60 { + if block == 4 { + both(&hand, &mut browser, &mut c_abi, "the hand move"); + both(&hand, &mut browser_r, &mut c_abi_r, "the reference move"); + } + if block == 7 { + let committed = + snapshot_bytes(|payload, expected| browser.command(payload, expected)); + assert_eq!( + committed, + snapshot_bytes(|payload, expected| c_abi.send(payload, expected)), + "{rate} Hz: one committed document" + ); + let committed = + session::parse_session_json(core::str::from_utf8(&committed).expect("utf-8")) + .expect("the committed document"); + let offset = committed + .automation + .iter() + .find(|entry| entry.id == id("t1-fader-ride")) + .expect("the ride") + .offset; + assert_ne!(offset, none, "{rate} Hz: the hand move is stored"); + both( + &[ride( + &[(0, 20_000, -5.0), (20_000, 1_000_000, -3.0)], + offset, + )], + &mut browser, + &mut c_abi, + "the rebuild", + ); + both( + &[reference(950_000, offset)], + &mut browser_r, + &mut c_abi_r, + "the reference rebuild", + ); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{rate} Hz: block {block}"); + let (b_r, c_r) = (browser_r.step(block), c_abi_r.step(block)); + assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the reference"); + assert_eq!(b, b_r, "{rate} Hz: block {block} against the reference"); + } + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(&browser), + watermark, + "{rate} Hz: the browser status carries the C ABI's watermark" + ); + assert_eq!( + watermark.2, + capi::OUTCOME_EXACT, + "{rate} Hz: the rebuild's outcome flags" + ); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + (0, 0), + "{rate} Hz: no transition fallback on either host" + ); + } +} + +/// #1711 U1 fold 1 (MAJOR-1) across hosts: on a fader ride flat at -3 dB, a hand move with its +/// own 4,000 samples heard 3 blocks, then a table-only rebuild to -5 dB up to 2,000, a linear -8 +/// to -14 dB over 2,000 to 8,000, then -14 dB. The moved target defers the cell's jump to the +/// move's first free sample (4,512), and the table's step at 2,000, inside the move's ramp, +/// replaces the ramp the deferral waits for. Both hosts give one verdict and reply per edit, the +/// same bits in every block and the same watermark (`EXACT`, no transition fallback), and every +/// block equals the pair of rigs prepared with -3 dB before 2,000 (the same table after it), +/// given the same move and, at the same block, a table-only rebuild that moves nothing (a split +/// at 950,000), at the four rates. Both rebuilds keep the hand move's stored offset. +/// +/// Test value: red if a discontinuity jump on a restartable cell leaves the pending deferral in +/// place (its grid hold freezes the lane on the step's target until 4,512), on either host. +#[test] +fn a_table_step_inside_a_deferred_moves_ramp_lands_alike_on_both_hosts() { + let segment = |shape, start, end, from, to| session::AutomationSegment { + shape, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: session::ParameterUnit::Db, + }; + let ride = |segments: Vec, offset| SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-fader-ride"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + 5, + ParameterChannel::Both, + ), + segments, + offset, + }, + }; + let (step, linear) = ( + session::AutomationShape::Step, + session::AutomationShape::Linear, + ); + let stepped = |first: f32, split: u64, offset| { + ride( + vec![ + segment(step, 0, 2_000, first, first), + segment(linear, 2_000, 8_000, -8.0, -14.0), + segment(step, 8_000, split, -14.0, -14.0), + segment(step, split, 1_000_000, -14.0, -14.0), + ], + offset, + ) + }; + let none = session::AutomationOffset::default(); + let hand = CAbi::edits( + Edit::Fader { + strip: T1, + lanes: Lanes::Both, + db: -6.0, + }, + Some(4_000), + ); + for rate in RATES { + let model = model_at(rate); + let (mut browser, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); + let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); + let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { + let (b, b_reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{rate} Hz, {label}: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz, {label}: one reply"); + }; + both( + &[ride(vec![segment(step, 0, 1_000_000, -3.0, -3.0)], none)], + &mut browser, + &mut c_abi, + "the flat ride", + ); + both( + &[stepped(-3.0, 900_000, none)], + &mut browser_r, + &mut c_abi_r, + "the reference ride", + ); + for block in 0..60 { + if block == 4 { + both(&hand, &mut browser, &mut c_abi, "the hand move"); + both(&hand, &mut browser_r, &mut c_abi_r, "the reference move"); + } + if block == 7 { + // A table-only rebuild keeps the hand move's stored offset (R17-1). + let committed = + snapshot_bytes(|payload, expected| browser.command(payload, expected)); + assert_eq!( + committed, + snapshot_bytes(|payload, expected| c_abi.send(payload, expected)), + "{rate} Hz: one committed document" + ); + let committed = + session::parse_session_json(core::str::from_utf8(&committed).expect("utf-8")) + .expect("the committed document"); + let offset = committed + .automation + .iter() + .find(|entry| entry.id == id("t1-fader-ride")) + .expect("the ride") + .offset; + both( + &[stepped(-5.0, 900_000, offset)], + &mut browser, + &mut c_abi, + "the rebuild", + ); + both( + &[stepped(-3.0, 950_000, offset)], + &mut browser_r, + &mut c_abi_r, + "the reference rebuild", + ); + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{rate} Hz: block {block}"); + let (b_r, c_r) = (browser_r.step(block), c_abi_r.step(block)); + assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the reference"); + assert_eq!(b, b_r, "{rate} Hz: block {block} against the reference"); + } + let watermark = c_abi.watermark(); + assert_eq!( + status_watermark(&browser), + watermark, + "{rate} Hz: the browser status carries the C ABI's watermark" + ); + assert_eq!(watermark.2, capi::OUTCOME_EXACT, "{rate} Hz: outcome flags"); + assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); + assert_eq!( + ( + browser.host.watermark().transition_fallback_count, + c_abi.transition_fallback_count() + ), + (0, 0), + "{rate} Hz: no transition fallback on either host" + ); + } +} + +/// #1651 gate 8 across hosts (U1 fold 1 NIT of #1711, restored without its B17-R14 window +/// claim): in a mute-only session (a stored mute curve on `t1`, no fader entry), a rebuild that +/// changes the mute curve while a hand fader move with its own 4,000 samples still ramps. Both +/// hosts give one verdict and reply per edit, render the same bits in every block and report the +/// same watermark. +/// +/// Test value: red if either host adopts a mute-curve rebuild under a ramping level move +/// differently (an adapter-local adoption, or a browser successor that does not carry the +/// fader stage's ramp or the mute cell's state as the C ABI's does). +#[test] +fn a_mute_curve_rebuild_while_a_level_move_ramps_renders_alike_on_both_hosts() { let curve = |close: u64| SessionEdit::UpsertAutomation { automation: session::Automation { id: id("t1-mute-curve"), @@ -3032,24 +3338,17 @@ fn a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts 6, ParameterChannel::Both, ), - segments: vec![ - session::AutomationSegment { - shape: session::AutomationShape::Step, - start_sample: 0, - end_sample: close, - start_value: 0.0, - end_value: 0.0, - unit: session::ParameterUnit::Linear, - }, - session::AutomationSegment { + segments: [(0, close, 0.0), (close, 1_000_000, 1.0)] + .iter() + .map(|&(start, end, value)| session::AutomationSegment { shape: session::AutomationShape::Step, - start_sample: close, - end_sample: 1_000_000, - start_value: 1.0, - end_value: 1.0, + start_sample: start, + end_sample: end, + start_value: value, + end_value: value, unit: session::ParameterUnit::Linear, - }, - ], + }) + .collect(), offset: session::AutomationOffset::default(), }, }; @@ -3089,18 +3388,121 @@ fn a_mute_curve_rebuild_while_a_level_move_ramps_completes_exactly_on_both_hosts both(&hand, &mut browser, &mut c_abi); same_blocks(&mut browser, &mut c_abi, 4..7, "the hand move"); rebuilt(both(&[curve(30_000)], &mut browser, &mut c_abi)); - same_blocks(&mut browser, &mut c_abi, 7..10, "the mute curve rebuild"); - let watermark = c_abi.watermark(); + same_blocks(&mut browser, &mut c_abi, 7..40, "the mute curve rebuild"); assert_eq!( status_watermark(&browser), - watermark, + c_abi.watermark(), "{rate} Hz: the browser status carries the C ABI's watermark" ); + } +} + +/// #1711 G4 across hosts (A1): an exact hand unmute that frees a lane held over a moving ride +/// discharges the move the hold owed, alike on both hosts. `t1` rides linearly from 0 to -6 dB +/// over `[0, 1,280)`, holds -6 dB to 2,320, then rides on to -12 dB. A hand mute (ramp 256) at +/// block 4 and an exact hand unmute (ramp 0) at block 8 hold the lane over moving grid points; a +/// hand mute (256) at block 12 and a hand unmute (200) at block 16 hold it again where the ride is +/// flat (nothing is remembered there; the hold ends at 2,248). Both hosts give one verdict and +/// reply per edit, the same watermark (`EXACT`, no transition fallback) and the same bits in +/// every block, and from block 24 on every block equals the pair of rigs that took only the +/// second hold, at the four rates. Block 24 is past the first hold's own difference at every +/// rate (measured: equal from block 12, 13, 21 and 22 at 44.1, 48, 88.2 and 96 kHz), and inside +/// the difference a stale obligation makes (measured once with the line deleted: blocks 21 to 28 +/// at 44.1 kHz up to 25 to 40 at 96 kHz). +/// +/// Test value: red if the free write at the unmute's entry does not discharge the owed move (the +/// `owed = false` of `HeldRampObligation::block`, through the browser's control path as through +/// the C ABI's): the stale obligation lands a move over `J` at the second hold's end. +#[test] +fn an_exact_unmute_discharges_the_owed_move_alike_on_both_hosts() { + let linear = |start: u64, end: u64, from: f32, to: f32| session::AutomationSegment { + shape: session::AutomationShape::Linear, + start_sample: start, + end_sample: end, + start_value: from, + end_value: to, + unit: session::ParameterUnit::Db, + }; + let ride = SessionEdit::UpsertAutomation { + automation: session::Automation { + id: id("t1-fader-ride"), + target: address( + Owner::Strip(id(T1.id)), + Section::Builtins, + 5, + ParameterChannel::Both, + ), + segments: vec![ + linear(0, 1_280, 0.0, -6.0), + linear(1_280, 2_320, -6.0, -6.0), + linear(2_320, 1_000_000, -6.0, -12.0), + ], + offset: session::AutomationOffset::default(), + }, + }; + let mute = |muted: bool, ramp: u32| { + CAbi::edits( + Edit::Mute { + strip: T1, + lanes: Lanes::Both, + muted, + }, + Some(ramp), + ) + }; + let first_hold = [(4, mute(true, 256)), (8, mute(false, 0))]; + let second_hold = [(12, mute(true, 256)), (16, mute(false, 200))]; + for rate in RATES { + let model = model_at(rate); + let (mut browser, mut c_abi) = (Rig::new(&model), CAbi::new(&model)); + let (mut browser_r, mut c_abi_r) = (Rig::new(&model), CAbi::new(&model)); + let both = |edits: &[SessionEdit], browser: &mut Rig, c_abi: &mut CAbi, label: &str| { + let (b, b_reply) = browser.transaction_reply(edits); + let (c, c_reply) = c_abi.transaction(edits); + assert_eq!(b, c, "{rate} Hz, {label}: one verdict"); + assert_eq!(b_reply, c_reply, "{rate} Hz, {label}: one reply"); + }; + both( + std::slice::from_ref(&ride), + &mut browser, + &mut c_abi, + "the ride", + ); + both( + std::slice::from_ref(&ride), + &mut browser_r, + &mut c_abi_r, + "the ride, reference", + ); + for block in 0..48 { + for (at, edits) in first_hold.iter().chain(&second_hold) { + if *at == block { + both(edits, &mut browser, &mut c_abi, "both holds"); + } + } + for (at, edits) in &second_hold { + if *at == block { + both(edits, &mut browser_r, &mut c_abi_r, "the second hold"); + } + } + let (b, c) = (browser.step(block), c_abi.step(block)); + assert_eq!(b, c, "{rate} Hz: block {block}, both holds"); + let (b_r, c_r) = (browser_r.step(block), c_abi_r.step(block)); + assert_eq!(b_r, c_r, "{rate} Hz: block {block}, the second hold"); + if block >= 24 { + assert_eq!( + b, b_r, + "{rate} Hz: block {block} against the second hold alone" + ); + } + } + let watermark = c_abi.watermark(); assert_eq!( - watermark.2, - capi::OUTCOME_EXACT, - "{rate} Hz: the mute-only rebuild completes exactly" + status_watermark(&browser), + watermark, + "{rate} Hz: the browser status carries the C ABI's watermark" ); + assert_eq!(watermark.2, capi::OUTCOME_EXACT, "{rate} Hz: outcome flags"); assert_eq!(browser.host.read_watermark(), host_web::RESULT_OK); assert_eq!( ( diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts index cf23f3e5c..bdce6d935 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-host.d.ts @@ -779,10 +779,9 @@ export interface MisoStatus { /// @internal readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, - /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a Reposition swap while a live level move - /// may still ramp (conservative: it can be set when the move was not stretched; #1711 removes - /// it), and a rebuild that carries a send following a stored mute into one that follows none - /// (conservative: it can be set when no send moves at the swap; #1713 removes it). + /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a rebuild that carries a send following a + /// stored mute into one that follows none (conservative: it can be set when no send moves at the + /// swap; #1713 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`), diff --git a/sdk/src/browser/shipped-host.d.ts b/sdk/src/browser/shipped-host.d.ts index cf23f3e5c..bdce6d935 100644 --- a/sdk/src/browser/shipped-host.d.ts +++ b/sdk/src/browser/shipped-host.d.ts @@ -779,10 +779,9 @@ export interface MisoStatus { /// @internal readonly appliedSample: bigint; /// The watermark's outcome flags, the C ABI's bits: `EXACT` 1, `TRANSITION_FALLBACK` 2, - /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a Reposition swap while a live level move - /// may still ramp (conservative: it can be set when the move was not stretched; #1711 removes - /// it), and a rebuild that carries a send following a stored mute into one that follows none - /// (conservative: it can be set when no send moves at the swap; #1713 removes it). + /// `SUPERSEDED` 4. `TRANSITION_FALLBACK` also reports a rebuild that carries a send following a + /// stored mute into one that follows none (conservative: it can be set when no send moves at the + /// swap; #1713 removes it). /// @internal readonly appliedOutcome: bigint; /// The module's memory size now: at least the boot size (`MisoAudioWorkletHost.memoryBytes`),