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 deleted file mode 100644 index 74ce46492..000000000 --- a/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md +++ /dev/null @@ -1,796 +0,0 @@ -# Render stored VCA offset and mute automation - -Proposed by *Research: render stored session automation in the engine, identically on every -platform* (#1058), root's ruling on finding F20 (2026-10-14, README "Root's rulings (2026-10-14)"), -under decision 17 (`docs/rulings/one-control-abstraction-2026-10-13.md`, C3, C6 and C7). A draft for -root to file. Written by the controls coordinator on 2026-10-14. Code anchors verified on -`2e6343a99`. Batch R2, after #1651-#1653. - -## Product outcome - -A producer automates the "vocals" VCA: an `offset_db` ride and `mute` steps. Every member lane -plays the ride and the steps on every host, in the member's own node time, so a member behind a -latent insert hears the VCA move with its own audio. Sends that follow a member's mute follow the -automated VCA mute at their tap. A hand move of an automated VCA offset writes a stored dB offset, -and a hand VCA mute ORs with the automated VCA mute (D5). A VCA automation edit is a carried -rebuild. A session with no VCA automation renders the bits it renders today. - -## Context - -- **The rows.** A VCA has two rows, `offset_db` (1) and `mute` (2), both per lane, family - `StripLevel`; `offset_db` has the fader domain and the ramp row `VcaOffset`, `mute` is a toggle - with the ramp row `VcaMute` (`crates/controls/src/vca.rs:13-72`). A VCA has no render cell: a - change reaches render through its members' effective fader and mute (#1558). `automatable` is - computed (`crates/controls/src/descriptor.rs:124-132`), so both rows are targets today, and - `fixtures/session/v1/builtins-automation.json` holds the entry `vca-ride` (target owner `vca`, - `riders`). Nothing renders it: the classifier masks the whole table - (`crates/host-core/src/live_delta.rs:359-361`). -- **The fader composition.** #1646 D6 (README F16, root's decision): a member's gain is - `checked_fader_gain(vca_compose_db(member, S))`, `S` the `f64` sum of the reaching offsets in - ascending VCA ID, offsets first. #1646 D1 gives each automated fader lane an offsets cell (`S` - as one `f64`, one ramp word); #1649 writes it on every VCA edit; #1650 puts the lane's hand - offset first in `S`. -- **The mute composition.** #1651 (amended): the stage mute of an automated mute lane is - `curve || terms`, `terms = static_mute || reaching VCA mutes || solo term` in one cell word. - #1652 (amended) gives a following send of an automated source lane a seven-word route cell - and evaluates the source curve in the tap's node time. -- **The reach.** Nested VCAs reach members through `session::vca`; the host caps the reach pairs - (`maximum_vca_reach_pairs`; the browser's `MAXIMUM_VCA_REACH_PAIRS`). -- **The shape rule.** Amended #1645: a toggle row takes only `step`. - -## Decisions frozen for this slice - -- **D1. Programs.** Each automated VCA lane compiles once, with #1637's builder: `offset_db` - with grid period and ramp 64, completion `End`, jump row `VcaOffset`; `mute` with jumps only, - row `VcaMute`. The segment table is plan-owned once per VCA lane. Each member lane the VCA reaches - holds its own cursor and event state for it and evaluates it in the member's fader node time - `a(fader)` (#1647), because a VCA has no audio of its own. -- **D2. Offset composition.** A member lane's fader stage runs events when its own fader is - automated or when an automated VCA offset reaches it ("event lanes"). Events are the union of its - own curve's events and each reaching automated VCA's events. At each event: - `S_t = S + c_1(τ) + … + c_m(τ)` in `f64`, the `c_k` the reaching automated VCAs' curve values in - ascending VCA ID, then `gain = checked_fader_gain(vca_compose_db(own, S_t))`. `S` (the cell) holds - the lane's own hand offset, then the static offsets of the reaching VCAs that are not automated, - then the hand offsets of the reaching automated VCAs, each group in ascending VCA ID. `own` is - the member's curve value when its fader is automated, else the cell's `own_db` word (D3). - - A session with no VCA automation has no `c_k` and no `own_db` word: the composition is - #1646's and #1649's, and no bit moves. - - A flat VCA curve at `v` gives the bits of a static VCA offset `v` whenever F16's exactness - condition holds (each nonzero term at least `k·2^-20` dB, so every partial sum is exact); the - order "static terms, then automated terms" can differ from ascending-ID order only in the - degenerate cases F16 lists. Root records it as a class B order under the standing - summation-order ruling, with F16. -- **D3. The cell of a member that is not automated.** An event lane whose own fader is not - automated gets #1646's offsets cell plus one word, `own_db` (the member's static fader dB, - `f32`): four words, 48 bytes in #1312's three slots. A hand fader move on that lane is a live - value, as today, but the classifier writes `own_db` through the cell (#1649 D4's write, ramp - `LiveRamps::resolve(Fader, the edit's ramp)`) instead of a `FaderDb` record, and render jumps at - the next block entry only if the gain bits change (#1649 D3). -- **D4. Mute composition.** A member lane reached by an automated VCA mute uses #1651's - `terms` word whether or not its own mute is automated. Its stage mute is - `own_curve || terms || v_1(τ) || … || v_m(τ)`, the `v_k` the reaching automated VCA mute curves - in the member's node time, with `own_curve` false when the own mute is not automated. `terms` - holds the static mute, the static mutes of every reaching VCA (automated or not: D5) and the solo - term. Events are the curves' discontinuities; only a change emits #1638's `SetMute`, over the - `VcaMute` jump row for a VCA curve event. A following send of such a member uses #1652's - seven-word cell, and its follow term ORs the same `v_k` evaluated in the tap's node time - `τ_tap`, so the send closes on the timeline sample where the member mutes (README A6). -- **D5. Hand edits** (OQ1 = C with root's ruling on level controls). - - `SET_CONTROL` on an automated VCA `offset_db` writes the entry's stored offset (#1650's - field; `takes_offset` gains VCA `offset_db`, per lane), and the classifier rewrites `S` of every - member lane the VCA reaches (#1649 D4's write). This reads root's ruling "send gain is a - level control, so it takes an offset" as covering the VCA offset, a level control too; root - confirms at filing. If root reads it otherwise, the edit stores the fallback and commits - `model_only` (#1648 D3), and gate 4's offset case expects that. - - `SET_CONTROL` on an automated VCA `mute` ORs: the VCA's static mute stays live and is written - into `terms` of each reached member lane (#1651's terms write). - - Edits of a VCA that is not automated, and reach changes that involve only such VCAs, stay - #1649's live `S` write. - - A change of which member lanes an automated VCA reaches (a membership or nesting edit, adding or - removing an automated VCA) needs new event state for the new pairs: a carried rebuild, for the - new-memory reason (decision 14 rule 1). It narrows D15-6's live set for automated VCAs only; - root sees it at filing. -- **D6. Rendering rows.** VCA rows 1 and 2 join #1648 D1's `automation_row_renders`. A VCA - automation edit is a carried rebuild; each member pair's event state carries by `(VCA ID, row, - member strip, lane)`, as #1648 D2 carries a fader cell. -- **D7. Memory.** Per automated VCA lane one program (`32 + 32·n` bytes, A4's header and - segments); per (member lane, automated VCA lane) pair one event state (48 bytes); per event lane - whose own fader is not automated 12 bytes more (D3's word in three slots). Every term is bounded - by the session and the host's reach-pair cap; none reads the song length. -- **D8. Work.** A member lane takes at most `(1 + m)(2⌈q/64⌉ + 3)` fader events per block, `m` the - automated VCA offset lanes that reach it, and `m` more `f64` additions per event. -- **D9. The acked-batch question: can an ack ever precede a drop? No.** Curve events come from the - plan; every check of a transaction precedes the first cell write; a cell write cannot fail. - -## Deliverables - -1. D1-D4 in host-core preparation (programs, member pairs, cells and seeds), the fader stage's - events and mute terms, and the route op's follow term. -2. D5-D6 in `crates/controls` (`takes_offset`, the rendering rows) and the classifier. -3. `docs/SESSION_SCHEMA_V1.md`: VCA rows render; the composition order of D2. - -## Authorized paths - -- `crates/host-core/src/{prepare.rs,live_delta.rs}`, `crates/host-core/tests/{vca.rs,live_delta.rs}` -- `crates/builtins-compiler/src/lib.rs` (the fader stage's VCA events, the `own_db` word and the - mute terms only) -- `crates/graph/src/{lib.rs,runtime.rs}` (the route op's follow term only) -- `crates/controls/src/{automation.rs,vca.rs}` and their tests -- `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/tests/control_parity.rs`, - `crates/control-plane/tests/vca_automation_realtime.rs` (#1649's binary, extended) -- `docs/SESSION_SCHEMA_V1.md` - -## Non-goals - -- A VCA with an audio path; VCA solo. -- Any change to a session without VCA automation (D2). -- Sends' own rows (#1654). - -## Hazards - -- **Class B order.** D2's order equals ascending-ID order except in F16's degenerate cases; root - records it with F16. Gates use values on the 0.1 dB lattice. -- **Hot files.** `crates/host-core/src/prepare.rs` and `crates/builtins-compiler/src/lib.rs` are - shared with #1651-#1654; root sequences the merge. - -## Objective gates - -Commands: `cargo test --locked -p host-core --features test-support`, `cargo test --locked -p -builtins-compiler --features test-support`, `cargo test --locked -p controls`, `cargo test --locked --p graph --features test-support`, `cargo test --locked -p capi`, `cargo test --locked -p host-web ---features test-support --test control_parity`, `cargo test --locked -p control-plane --features -test-support`, `./target/release/realtime_policy`, `bash scripts/check-cross-targets.sh` (README -F19), clippy `-D warnings`, `cargo fmt --all -- --check`. - -1. **Flat equals static** (C ABI and browser). A flat VCA offset ride at `-6` dB and a flat VCA mute - at 0 render, bit for bit, the session with those static values: for a member with its own fader - ride and for a member with none. -2. **Member node time.** A linear VCA ride reaches two members, one behind the true-peak limiter. - Each member's gain at each grid completion equals `checked_fader_gain(vca_compose_db(own, S + - c(τ)))` computed on the test thread, and the latent member's events fall `L` samples later in - render time. -3. **Mute.** A VCA mute step at a sample that is not a grid sample mutes each member at its node - time over the `VcaMute` ramp; a following send of a member closes at its tap (#1652's rule); - after the ramps the output equals a static-mute plan. -4. **Hand edits.** On an automated VCA: `SET_CONTROL offset_db = +3` gives `live`, stores the - offset and, after the jump, equals a fresh plan with that offset; a hand mute ORs (closes an - open curve, leaves a closed curve closed). A hand fader move on a member with no own automation - gives `live` and one `own_db` write, no `FaderDb`. A membership edit of the automated VCA gives - `rebuild` and completes `exact`; the same edit of a VCA that is not automated gives `live`. -5. **Realtime.** The events, cell reads and writes allocate and free nothing on the render thread - (`bench_support::alloc` thread counters). -6. **No rendered bit moves** for a session without VCA 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 turns red if the VCA curve composes with other arithmetic than the static path. -- Gate 2 turns red if VCA events use the render clock or the VCA's own (non-existent) arrival - instead of each member's. -- Gate 3 turns red if a following send of a member keeps passing audio under an automated VCA mute. -- Gate 4 turns red if a hand VCA move writes the fallback, if a plain member's hand move fights the - VCA curve with a `FaderDb` record, or if an automated VCA's reach change commits live with no - event state for the new pair. - -## Dependencies - -- #1651, #1652 and #1653 (the terms word, the seven-word route cell, the follow evaluation). -- #1649 and #1650 (the offsets cell, its write and the offset field), #1648 D1 - (`automation_row_renders`), #1639 (jump rows), amended #1645 (the shape rule), #1647 - (member node time). -- Batch R2, after #1653. A complete outcome alone; not in a must-land group. - -## Ruling (root, 2026-10-14) - -- **Hand move.** A hand move of an automated VCA `offset_db` writes an offset, the same rule as - fader, trim and send gain (a level control). -- **Summation order.** Static terms are summed first, then the automated VCA curves: a class B - change under the standing summation-order ruling, recorded with F16. -- **Membership of an automated VCA.** A change to which members an automated VCA reaches is a - 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. - -## Decision record: V0 (batch 19) - -`crates/controls` only. No render, host-core, graph or builtins-compiler code; no row class or -table change, so no SDK regeneration. - -- **Rows (D6).** `automation_row_renders` admits VCA row 1 (`vca_offset_row`) and row 2 - (`vca_mute_row`). `fader_row` is unchanged, so a VCA entry reaches no fader-only reader. New - accessors: `vca_offset_automation`, `vca_mute_automation` (per lane, first entry in table order; - the strip readers now share one `owner_row_automation` walk). -- **Offset (D5).** `takes_offset` = `fader_row || route_gain_row || vca_offset_row`. The VCA row is - per lane, so `offset_faults` judges each driven lane against the row's own domain - (`[-144, 24]` dB, the fader domain); a lane the entry does not drive holds `+0.0`; the mute row - takes no offset. -- **Owner rule (R17-1).** `registry::write` on an automated VCA `offset_db` lane writes that lane's - entry offset (`vca::write_offset`) and the static offset only on lanes no entry drives; - `vca::resolve` and the list read the offset as the row's value; both VCA rows list - `automated = true` on a driven lane (the `mute` row keeps its static hand mute as its value). - `OwnerEdits::set` on VCA row 1 clones the driving entries; `ModelView::vca_automation` reads the - edited entry. A member's own rows are not marked by a VCA ride. -- **Mask.** `mask_fader_offsets` copies VCA-offset entry offsets (through `takes_offset`); - `mask_values` keeps `into`'s static offset on each automated VCA lane (fallback = structure), - and copies the rest of the VCA list (other lanes, mutes, members) as before. -- **Event lanes.** `fader_event_lanes(model, strip, reach)` and `mute_event_lanes(model, strip, - reach)`: own entry on the row, or a reaching VCA with an entry on the matching VCA row and lane. - `reach` is the caller's VCA reach of the strip (`vca_reach` / `vca_reach_of`), so preparation and - the classifier, which already hold it, build no second table. The own-entry readers - (`fader_automation`, `model_fader_offsets`, `mask_fader_offsets`, the listing flags) stay - own-entry only. -- **Reach rule.** `rendered_automation_changed` also returns `true` when, with equal rendered - entries, the strip IDs reached by some VCA that a rendered entry drives differ (inverse of - `SessionModel::vca_reach`, which reads a model in any strip order). No table is built when no - rendered entry is on a VCA row. -- **`S` (D2).** `EffectiveInputs` gains `vca_offset(vca)` (the driving entries' hand offsets per - lane; `ModelInputs` precomputes it, `LocalInputs` reads the view). `effective_strip` sums: own - hand offset, then static offsets of reaching VCAs whose lane is not automated, then hand offsets - of those whose lane is, each in ascending VCA ID. With no VCA offset entry the second pass does - not run and `S` is today's sum. `fader_db` stays the static composition (a lane an automated VCA - reaches is a fader event lane, which takes no `FaderDb` record). Departure: none from the text; - `EffectiveInputs` is a public trait with no implementor outside the crate. -- **Not in V0** (render tranche): `continued_fader_lanes` / `continued_mute_lanes` are not widened - to the reaching VCA tables (D6 carry). - -Gates (`crates/controls/tests/automation.rs`): `vca_rows_render_and_take_a_per_lane_offset`, -`a_hand_move_on_an_automated_vca_offset_writes_its_offset` (whole and in-place paths, list flags, -`S` on both effective paths), `the_mask_copies_a_vca_offset_and_keeps_its_fallback`, -`event_lanes_take_the_own_entry_and_every_reaching_vca_ride` (lanes, rows, nesting, own-entry -readers), `a_reach_change_of_an_automated_vca_rebuilds` (members added or removed, nesting, a -non-automated VCA, a hand offset, the mute row), `the_offset_sum_puts_the_static_vca_offsets_first` -(values `1, 2^-60, -1` that tell the two orders apart; no VCA offset automation and a VCA mute -entry alone keep every bit of `S` and `fader_db`). - -Commands: `cargo test --locked -p controls` (all green), `cargo clippy --locked -p controls ---all-targets --all-features -- -D warnings`, `cargo check --locked -p controls --all-targets`, -`cargo test --locked -p host-core --features test-support` (467 passed), -`--features test-support,control-provider` (587 passed), `cargo test --locked -p control-plane ---features test-support` (28 passed). No red: no host-core or control-plane test edits a VCA -automation entry or an automated VCA's reach today. - -For V1: a VCA automation edit and an automated VCA's reach change are now step 3 `Automation` -(rebuild) in the classifier; a session whose only rendered entries are VCA entries now enters -`prepare_fader_automation` (it builds no VCA cell yet; tests green); a hand move on an automated -VCA offset diffs as a VCA row 1 change whose value is the offset and moves `S` of each reached -lane, which host-core lowers only on lanes that have an offsets cell until V1 gives every event -lane one. - -## Decision record: V2 (batch 19) - -The fader stage (`crates/builtins-compiler/src/lib.rs`), one additive rule in K1 -(`crates/automation/src/compose.rs`), the carry predicates (`crates/controls/src/automation.rs`). -No edit under `crates/engine/src/realtime/`; no row class or table change (no SDK regeneration). - -- **Program (D1).** The VCA cells follow every route cell, in ascending VCA ID, then row - (`offset_db` with `FADER_CELL_RULE`, `mute` with `MUTE_CELL_RULE`), then lane; one cell per - automated VCA lane, shared by its members (refusal code `host.automation.vca`). The reach is read - only when an entry drives a VCA row, so a session without VCA automation builds its program cell - for cell. Export for V3/V4: `PreparedBuiltinsSession::vca_cells(vca_id) -> Option<(Arc, - VcaCells)>` (`[row][lane]`). `strip_mute_cells` stays own-entry cells (a lane only a VCA mute - reaches reads `None` there). -- **Event lanes (D2, D4, R19-2 = (b)).** Preparation reads `controls::fader_event_lanes` and - `mute_event_lanes`. A lane no automated VCA curve reaches keeps `FaderCurve::Cell` / - `MuteCurve::Cell`: the same `CellEvents` path and bits as before. A reached lane is - `FaderCurve::Composed` (one `automation::Composed` over its own cell, when automated, then - the VCA cells in ascending VCA ID) or `MuteCurve::Composed` (`Composed`, gate cursors, value - `own || v_1 || ... || v_m`; the stage mute is that `|| terms`). The composed value is the fader dB - `vca_compose_db(own, S + c_1 + ... + c_m)` (`composed_db`: `f64` fold, `S` first, inside the - render entry's `CanonicalFpEnv`; the prepared value inside preparation's), `own` the own curve or - `own_db`; the stage gain is `checked_fader_gain` of it. -- **One driver (R17-4).** `FaderEventDriver` (bank, fused pair fallback, scalar oracle) holds a - `ChannelEvents::{Cell, Composed}` per channel: each piece begins the walk (`Composed::start`), the - cut at `E` is a one-sample `Ramp` landing on every cursor (longest jump, R19-4), and the held-ramp - obligation, the R17-10 release, the T3b re-hold release, the held-stage offsets jump and the free - offsets jump act on every cursor (`FaderComposite::each_state`). `settle_adoption` is generic over - `AdoptedCurve` (`CellAdoption`, `ComposedAdoption`): one adoption rule (F0 ramp target, the - deferred jump at the ramp's first free sample, grid hold, clears) for both shapes. - `block_jumps` resolves `LiveRamps::for_row(VcaOffset)` and `for_row(VcaMute)` (slot field - `vca_jumps`); a VCA cursor takes its row's length, the own cursor the fader or mute length. -- **D3.** `OFFSETS_CELL_WORDS = 4`. `FaderOffsetsRecord` gains `own_db: Option` (word 3, a - quiet NaN for `None`); a composed lane whose own fader is not automated takes `Some(own_db)` and - jumps at the block entry only when the gain bits change; `None` keeps it. -- **Carry (D6).** `continued_fader_lanes` / `continued_mute_lanes` now continue a lane only when its - own table (absent in both, or the same) and each reaching automated VCA table (same VCAs, ascending - ID, same segments) continue; with no VCA row automated the rule is the old one. `FaderCurve::carry` - / `MuteCurve::carry`: same shape copies the cell state, or the composed output's state and target - and, with one layout, the cursors; a changed shape takes the cell's ramp or starts fresh (always a - reposition). A composed reposition places every cursor afresh (no target, hears its curve at the - block start), so a deferred jump or a landing reaches every curve. `own_db` is carried like `S` - (`adopted_own_db`, the `Compare` backstop). -- **Memory (R19-6).** Each composed channel charges its boxed curve (`fader_composite_bytes` 128, - `mute_composite_bytes` 112) and its cursor table (`cursor_bytes` 104 per pair) as bank payload and - in the scalar owner row (`FaderAutomationPlan::composed_bytes`), under the existing caps; the - offsets-writer charge widens to fader event lanes. The cap gate is V5's. - -**Departures.** -1. K1 additive rule `Composed::with_held_grid` (opt-in; the route keeps R19-3). Without it a grid - event of one curve retargets the composed lane inside another curve's jump (the R19-V1 (a) - defect: the step ends at the next grid point, not at `J`). With it each retarget reports its ramp - to every value cursor whose own ramp ends earlier, and the cell's own grid rule holds or restarts - it. Also `clear_target`, `output_state`, `set_output` (carry). Test - `compose::tests::a_step_keeps_its_jump_length_while_another_curve_rides_on_a_held_grid_output`. -2. An offsets record on a composed lane takes the single cell's rule (seed, clear the deferral, - every cursor holds to the jump's completion), not `Composed::retarget` (deferral then each - cursor's own `J`), so a composed lane moves as one cell does in holds and deferrals. -3. `own_db` is `Option`: today's host-core record sites write `None` (mechanical, correct for - every lane that has a cell today); V3 writes `Some` on static-own event lanes. -4. `LaneFaderAutomation::new` returns `Result` (the cursor table allocates); bind - (`bound_lane_automation`) panics only on an allocation failure, as a `Box::new` there does. - -**Gates** (`cargo test --locked -p builtins-compiler --all-features`, Simd8 under AVX2 and Simd4): -`a_flat_vca_ride_renders_the_static_vca_offset_bits` (flat ride `-6` dB and flat mute `0` equal the -static VCA bits over 40 blocks, a member with and without its own ride, fallback `+3` unused), -`a_vca_ride_composes_with_each_members_own_value_at_every_grid_event`, -`a_step_inside_another_curves_ride_keeps_its_jump_length` (both ways; red without `with_held_grid`: -`(1024, Grid)` after the step), `coincident_member_and_vca_events_take_the_longest_jump` (fader and -mute, 480/960 both ways), `a_session_without_vca_offset_automation_keeps_its_lone_fader_cells`, -`a_carried_composed_lane_renders_the_uninterrupted_bits` (continued and repositioned, swap inside a -grid ramp and inside a VCA step's jump; red without the cursor carry). `controls`: -`a_member_lane_continues_only_while_its_reaching_vca_tables_do`. - -Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature -`cargo check --locked --all-targets` on automation, builtins, builtins-compiler, host-core, controls; -tests automation (all features), builtins (all features), builtins-compiler (all features, 76 lib), -controls, host-core `--features test-support` `stored_fader_automation` (36), `stored_mute_automation` -(13), `successor_swap` (140): all green. Realtime policy: 172 regions in 35 files (171 before: the -composed-curve impls). Sizes (x86-64, measured): `Option` 392 -> 416 B -(`vca_jumps`), `offsets_cell_bytes` 120 (unchanged), `ComposeCursor` 104. - -For V3: write `own_db = Some(static fader dB)` on fader event lanes with no own automation; take the -fader event lanes as `automated` in `level_records_between` / `fader_records` (no `FaderDb` must reach -a composed lane: the drain still applies a `FaderDb` to the stage); `has_offsets` is true on every -fader event lane. For V4: a source lane's VCA mute cells are its reach (ascending VCA ID) mapped -through `vca_cells(..)[1][lane]`, jump row `VcaMute`. - -## Decision record: V3 (batch 19) - -host-core preparation and the classifier (`crates/host-core/src/{prepare.rs,live_delta.rs}`). No -render code, no edit under `crates/engine/src/realtime/`, no `control.rs` edit (#1711 landed in -19a, so `automates` stays), no row class or table change (no SDK regeneration). - -- **Records (D3).** `level_records_between` takes the strip's own faders before and after and a - `FaderEventLanes` (`controls::fader_event_lanes` as `events`, `automated_fader_lanes` as `own`). - `fader_records` reads `events` as its automated lanes, so no `FaderDb` reaches an event lane. An - event lane gets one offsets write when its `S` moves or, on a lane whose own fader is not - automated, when its static own dB moves; that write carries `own_db = Some(own after)` (else - `None`) and the lane's fader length (`LevelRules::lengths`: `resolve(Fader, edit)` for a hand - move). Dense diff, local (fast-path) diff and the strip join all call it with the reach they hold. -- **Clamp.** `level_differs` also compares the own static fader dB: at the effective clamp a hand - move leaves `fader_db` and `S` as they were but a composed lane hears it. On any other lane the - extra "moved" yields no record, so no non-VCA-automation answer changes. -- **VCA hand offset (D5).** `LevelRules::vca_changed` (offset) also compares the driving entry's - hand offset (`vca_offset_moved`: `vca_offset_automation(current)` against - `ModelView::vca_automation(next)`), so members' writes take the VCA edit's length, not the - session's. `S` itself is V0's (`effective_strip`). -- **VCA mute (D4, D5).** No new record: the effective mute (`terms`) change is the `Mute` record, - which V2's composed mute lane takes as `terms`. -- **Reach rule.** No classifier change: V0's `rendered_automation_changed` gives step 3 `Automation`; - step 3's doc now names the VCA rows and the reach rule. -- **Join (D6).** `join_level_records` releases lanes that stop being fader / mute event lanes - (`fader_event_lanes` / `mute_event_lanes` over `reach_before` and `reach`). The carry byte is V2's - widened `continued_*_lanes`, unchanged here. -- **Slot.** V2 already gives every fader event lane its `PreparedFaderCell` and offsets cell - (`attach_offsets` over `cells.fader`); V3 only gates it. -- **Route (D4).** `following_route_lanes` widens `lanes` to `mute_event_lanes` (reach read through - `vca_reach_of` only when an entry drives a VCA `mute` row and an own lane is not automated), so the - attach, #1653's records, the local route scan and `join_routes` read one predicate. The attach - checks `own cell || VCA cells` per lane against it, with the new - `follow_vca_mute_cells(vca_cells, model, source, program) -> Option<[Vec; 2]>`: per source - lane, `PreparedBuiltinsSession::vca_cells(vca)[1][lane]` of each reaching automated VCA mute, in - ascending VCA ID, `None` on a plan/model mismatch. - -**Departure / interim.** Until V4, a following send whose source lane only a VCA mute reaches has -`automated[lane] = true` and no own cell, so `GraphFollowRouteSource::new` refuses it and preparation -fails with `host.route_control.attach` (typed, no panic). V4 passes `follow_vca_mute_cells`' lists -to the source (graph) and relaxes `new`'s `flags.automated == cells` check to `own || VCA`. A lane -with its own mute cell and a VCA mute prepares, and until V4 its send follows only the own curve. - -**Gates** (`crates/host-core/tests/stored_vca_automation.rs`, renders on Simd8 under AVX2, Simd4 and -the scalar oracle, live and between render calls, tail bits equal to a fresh plan, the edit heard, -render allocations and frees 0): `every_fader_event_lane_has_an_offsets_cell` (test-support); -under `control-provider`, full and fast path equal: -`a_hand_move_on_a_member_only_a_vca_ride_reaches_writes_its_own_db`, -`a_hand_move_on_an_automated_vca_offset_rewrites_every_reached_lanes_s`, -`a_hand_mute_of_an_automated_vca_writes_each_reached_lanes_terms`, -`a_reach_change_of_an_automated_vca_rebuilds_and_of_a_plain_vca_is_live`, -`a_hand_move_hidden_by_the_static_clamp_still_writes_own_db`. - -Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature -`cargo check --locked --all-targets` on host-core, control-plane, builtins-compiler; tests host-core -`--features test-support` (477 passed) and `test-support,control-provider` (605), control-plane -`--features test-support`, builtins-compiler, capi lib (218): all green. Realtime policy: 172 -regions in 35 files (unchanged). - -For V4: read `follow_vca_mute_cells` in the attach loop (`prepare.rs`, the `follow_cells` push) and -pass its lists into `GraphFollowRouteSource`; jump row `VcaMute`. For V5: host parity, the cap -gate (R19-6), gates 1-3 and 7-8 on both hosts. - -## Decision record: V4 (batch 19) - -The route op's VCA mute follow term (`crates/graph/src/{lib.rs,runtime.rs}`) and the attach in -`crates/host-core/src/prepare.rs` (the `follow_cells` loop and `follow_route_jumps`). No edit under -`crates/engine/src/realtime/`, in `crates/automation` or in any row class or table (no SDK -regeneration). - -- **Source (D4).** `GraphFollowRouteSource::new` takes `vca_mutes: [Vec; 2]` (per source lane, - V3's `follow_vca_mute_cells`, ascending VCA ID; stored boxed) and checks `flags.automated[lane] == - own cell || VCA cells`, every cell in the program. This closes V3's interim refusal: a send whose - lane only a VCA mute reaches now prepares. `with_continued_vca_mutes([bool; 2])` (host-core: - `controls::continued_mute_lanes` of the successor) marks lanes whose VCA mute tables continue. - When the source strip has no own mute cell and the route no row cell, the attach takes the VCA - cells' program (`follow_vca_mute_cells` now takes `&mut Option>`). -- **Jump row (data).** `GraphRouteCursorRow::VcaMute` and `GraphRouteJumpLengths::vca_mute`; - host-core's rule reads `LiveRamps::for_row(VcaMute)`. -- **Walk.** The VCA mute cursors are `Gate` cursors appended after the five role cursors in each - column's one K1 table (`ColumnRoles { fixed, vca_start, vca_count }`); no second walk. The follow - term is `follows_mute && (own curve || v_1 || ... || v_m || terms)` in `column_target`, in the - tap's node time. Because they are gates, a VCA mute fade holds the column's value events and - lands them at its end (R19-3, K1's deferral). The table (and its probe twin) is boxed, allocated - at attach, charged by `Composed::bytes(K)`; the source's lists are added to the producer's - `cursor_bytes`. -- **Carry (D6, U2 folds).** A lane's VCA mute cursors carry verbatim by position (`Continued`) when - the lane continues and `a(tap)` did not move; else they start fresh and are placed at the first - block like the source mute. At the first block the follow curves are one gate: its OR is - compared with the OR the predecessor heard (with the own mute alone this is the old per-cursor - comparison, so no bit moves). A gate move takes the longest own length among the curves that - close the column (for an open, among all follow curves; which were closed is not kept across a - carry), R19-4. A follow curve is "dropped" (#1654 U2 fold 1 MAJOR-3) only when no own or VCA mute - curve is left; a dropped VCA mute lands at once over `VcaMute` at the carry (B19-R7). A seven -> six carry of - a predecessor that heard VCA curves reopens over `max(mute, VcaMute)`. - -**Departure.** In `place_carried` a moved gate now retargets over its own row's length (was the -mute length for every gate; equal for the source and route mutes, so no existing bit moves). - -Gates (`cargo test --locked -p graph --features test-support`, 149 lib tests green), module -`runtime::send_automation_tests`, `VcaMute` = 320 against the mute rule's 200: -`a_vca_mute_step_closes_a_following_send_at_its_tap_time` (a lane only a VCA mute reaches, `a(tap)` -0 and 100: the close lands at render sample `1,000 + a(tap)` over 320), -`a_vca_mute_ors_with_the_source_lanes_own_mute`, `a_vca_unmute_fade_holds_the_columns_value_events_to_its_end` -(R19-3), `a_carried_vca_mute_renders_the_twins_bits_continued_or_repositioned` (red with the -continued cursor's state dropped: block 21), `a_moved_vca_mute_table_lands_over_its_own_length_at_the_first_sample` -(both ways, the OR with a closed own mute, a removed VCA mute); `follow_route_tests`: the source -accepts VCA-only lanes and refuses a VCA cell outside the program and disagreeing flags. - -Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on graph, host-core, -automation; `cargo check --locked --all-targets` (workspace, no features); tests automation, graph -`--features test-support`, host-core `--features test-support` (all, incl. `route_mute` 26, -`stored_vca_automation`; with `control-provider` 6), capi lib (220), host-web `--features -test-support --test control_parity` (44): all green. Realtime policy: 172 regions in 35 files -(unchanged). `scripts/check-graph-policy.sh`: PASS. Sizes (x86-64, measured): -`LIVE_FOLLOW_ROUTE_OWNER_BYTES` 1,072 -> 1,120, `GraphFollowRouteSource` 120 -> 152, -`GraphFollowRouteControlBinding` 808 -> 872, `GraphFollowRouteControlProducer` 104; charges read -`size_of`. - -For V5: host gates for gate 3 (a following send of a member closes at its tap under a VCA mute, -both hosts, latent tap), a member with no own mute entry, a nested VCA, the carry (continued and -moved VCA mute tables) and the reach-pair cap gate (R19-6). - -## Decision record: V5 (batch 19) - -The objective gates and the doc. Tests and `docs/SESSION_SCHEMA_V1.md` only: no product code, no -edit under `crates/engine/src/realtime/`, no row class or table change (no SDK regeneration). The -gates found no defect. - -- **Hosts.** Every both-host gate is in `hosts/host-web/tests/control_parity.rs`, which drives the - C ABI's exported `miso_engine_v1_*` entries and the browser host in one binary (the amendment's - "both hosts in one binary"); `crates/capi/src/runtime/live_tests.rs` gains nothing (departure - from the brief's file list, not from the gates). Parity runs at quantum 128 and the native width; - widths and quanta are host-core's. -- **Widths.** `crates/host-core/tests/stored_vca_automation.rs` runs Simd8 (when `avx2`), Simd4 and - the scalar oracle (`backends()`), live and between render calls; the carry gates (8) run the bank - widths only (the oracle restarts at a swap, #1651 T4). - -Gates (test names; all green): -1. Flat equals static: host-core `a_flat_vca_ride_and_mute_render_the_static_vca_bits` (quanta 128, - 100, 37; a member with its own ride, one with none and in a second static VCA; fallback +3 - unused); both hosts `a_flat_vca_ride_and_mute_render_the_static_vca_bits_on_both_hosts`. -2. Member node time: host-core `a_vca_ride_reaches_each_member_in_its_own_node_time` (each grid - event's gain equals `checked_fader_gain(vca_compose_db(own, S + c(τ)))` computed on the test - thread; the latent member's events `L` later); both hosts - `a_linear_vca_ride_renders_alike_on_both_hosts` (alike and heard). -3. Mute: host-core `a_vca_mute_step_mutes_each_member_at_its_node_time` (off-grid step at 1,000; - `SetMute` at `1,000` and `1,000 + L` over `VcaMute`; a member with no own mute entry, one with, - a nested VCA; static-mute and open plans after the ramps); both hosts - `a_vca_mute_curve_closes_a_following_send_at_its_latent_tap_on_both_hosts` (also the V4 note: the - send follows a lane only a VCA mute reaches, through a nested VCA, behind a latent tap). -4. Hand edits: both hosts `hand_edits_of_an_automated_vca_commit_as_d5_says_on_both_hosts` (offset - +3 live and heard; hand mute over an open curve heard, over a closed one no bit moves; a hand - move of a member only the ride reaches live and heard; membership edit of the automated VCA - `rebuild` and `EXACT`, no fallback; of a plain VCA `live`) and - `a_hand_move_on_an_automated_vca_offset_is_in_place_as_the_full_path_on_both_hosts` (R17-1; the - document stores the offset). The `own_db` record and "no `FaderDb`" stay V3's classifier gates. -5. Realtime: `crates/control-plane/tests/vca_automation_realtime.rs` - `a_vca_curve_and_hand_edits_over_it_render_allocation_free` (curve, hand VCA move, hand member - move, hand VCA mute and unmute; every block `allocations == 0 && frees == 0`). -6. No bit moves: `./target/release/audit capi` `pcm_digest` = `bffc21e857b5e134`. -7. Reposition: both hosts - `a_vca_ride_table_edit_while_a_level_move_ramps_completes_exactly_on_both_hosts` (a member's - `own_db` move and a VCA hand move of 4,000 samples, heard 1, 12, 26 blocks; a table change after - 1,000,000 repositions every member; each host equals its own twin without the rebuild; `EXACT`). -8. Carry: host-core `a_hand_vca_mute_in_a_vca_mute_curve_rebuild_lands_over_its_own_ramp` (ramp - `M`, 0, 2,000) and `added_removed_and_changed_vca_mute_entries_carry_the_member_stage` (swap - block allocation-free, strip carried); both hosts - `a_hand_vca_mute_in_a_vca_mute_curve_rebuild_lands_over_its_own_ramp_on_both_hosts`. -- R19-4: host-core `coincident_member_and_vca_events_take_one_longest_jump` (one fader jump over - `J`, one `SetMute` over `M`; bits of one curve making the sum). At host level the `VcaOffset` and - `VcaMute` lengths equal the fader and mute lengths, so "longest" is V2's - `coincident_member_and_vca_events_take_the_longest_jump` (480/960) at the stage. -- R19-6: both hosts `the_reach_pair_cap_bounds_vca_automation_alike_on_both_hosts` (256 nested VCAs, - the outermost automated: 64 tracks, 16,384 pairs, boot; 65 tracks refused - `host.resource.vca_reach_pairs` alike, no panic). - -Red without the feature (once each, reverted): gates 1-3 with the VCA reach dropped from -`controls::event_lanes` (VCA curves reach no member): host-core gates 1, 2, 3 red (and gate 8's -carry case); gate 1 and 3 parity red; gate 4 red with the same knockout (a hand VCA move is not -heard: `automated_vca_alike`'s heard assert); gate 7 red with `settle_adoption`'s stage hold -ignored (the move is cut at the swap: the browser twin comparison). Knocking out -`automation_row_renders` for VCA rows alone leaves gates 1-3 green: render reads the event-lane -predicates, the classifier the rows. - -Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` on host-core, -control-plane, host-web; `cargo check --locked --all-targets` (workspace, no features); tests -automation (44), builtins-compiler `--features test-support` (94), graph `--features test-support` -(159), host-core `--features test-support` (485) and `test-support,control-provider` (613), -control-plane `--features test-support` (30), capi lib (220), host-web `--features test-support ---test control_parity` (52): all green. Realtime policy: 172 regions in 35 files (unchanged). - -## Decision record: U3 fold 1 (batch 19) - -U3 MAJOR-1 and NIT-1. No edit under `crates/engine/src/realtime/`, no row class or table change (no -SDK regeneration). - -- **Fix (K1, opt-in).** `automation::Composed::with_jump_release` (new flag, copied by `copy_from`): - every jump the output emits (not a seek landing, not a held event) shortens each restartable value - cursor's hold to the jump's completion (`CellState::release_hold_by`) and drops its deferral - (`clear_deferred_jump`), before the `with_held_grid` hold and the column-jump deferral; a cursor - that pulled ahead is replayed first, as for a hold. A jump that emits nothing changes nothing - (#1711 U1 fold 2). It reaches every hold and deferral the lane put on every cursor: the record - hold in `offsets_jump`, the adoption hold and deferral of `settle_adoption`, and the holds - `with_held_grid` propagates, so a composed lane moves as one cell does in holds and deferrals - (V2 departure 2). -- **Who uses it.** Only the fader composed lanes (`FaderCurve::prepared`: `.with_held_grid() - .with_jump_release()`). The composed mute lanes have gate cursors only. The route column - (`crates/graph`, #1654) does not call it and keeps its T3 rule: a held value follows the column's - current free time and never moves earlier. No graph file changed; graph tests and #1654 gates - unchanged. -- **NIT-1.** `follow_route_flags`' doc lines moved back above it; `follow_vca_mute_cells` keeps its - own doc. - -**Departures.** -1. The host-core live gate compares from the step's jump's end (3,240), not from 0. Before the step - the composed lane and one cell over the sum differ: a record's ramp target composes each curve - toward the ramp's completion clipped at that curve's own next discontinuity, where one cell clips - the sum at the lane's next discontinuity (`FaderComposite::toward` against `curve_toward`). The - jump's end equalizes the gains, and the defect window (3,328 to 9,024) lies after it. - Defined behaviour: on a composed fader lane a record's or an adoption's ramp target composes each - curve toward the ramp's completion, each clipped at its own next discontinuity (K1 - `compose_toward`, #1649 D3), so between the record and another curve's next discontinuity the - lane differs from one cell over the summed curve, and that curve's jump makes them equal; a - successor plan uses the same rule. -2. The both-host gate runs at the native width only (control_parity's rule, V5); the two bank widths - are the host-core twin's. - -**Gates** (red before: the gate with `.with_jump_release()` removed from `FaderCurve::prepared`, or -from the K1 probe; green after): -- K1: `compose::tests::a_step_replaces_a_hold_the_lane_put_on_every_cursor_as_one_cell_does` (every - cursor held to 2,999, then also deferred to 3,000; the composed retargets equal one cell over the - summed curve with the same hold and deferral). Red: `(2944, Grid)` after the step instead of - `(1472, Grid)`. -- host-core live: `stored_vca_automation::classifier:: - a_vca_step_inside_a_hand_moves_ramp_lets_the_own_ride_move_as_one_cell` (own ride on a 2^-12 dB - lattice, VCA step at 3,000, hand VCA move to +3 over 8,000 at 1,024; Simd8, Simd4, scalar, live and - between render calls; same classifier records as the summed single-cell session). Red: first - differing sample 3,328 (Simd8). -- host-core rebuild twin: `stored_fader_automation:: - a_table_step_inside_a_deferred_moves_ramp_releases_every_cursor_of_a_composed_lane` (Simd8 and - Simd4; track 0 also in an automated VCA riding 0 to -6 dB). Red: first differing sample 4,544. -- both hosts: `control_parity:: - a_table_step_inside_a_deferred_moves_ramp_releases_a_composed_lane_alike_on_both_hosts` (the - #1711 body over a session with the VCA ride; four rates). Red: block 39 against the reference at - 44.1 kHz. The single-cell test now calls the shared body `table_step_inside_a_deferred_move`. - -Commands: `cargo fmt --all`; clippy `-D warnings --all-targets --all-features` and no-feature -`cargo check --locked --all-targets` on automation, builtins-compiler, host-core, graph, host-web; -tests automation (all features), builtins-compiler `--features test-support` (76 lib), graph -`--features test-support` (149 lib, unchanged), host-core `--features test-support` -`stored_vca_automation` (7; 13 with `control-provider`), `stored_fader_automation` (37), -`stored_mute_automation` (13), `route_mute` (26), `successor_swap` (140), control-plane -`--features test-support` (30), capi lib (220), host-web `--features test-support --test -control_parity` (54): all green. Realtime policy: 172 regions in 35 files (unchanged). diff --git a/.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md b/.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md index bf35192f8..b793816a8 100644 --- a/.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md +++ b/.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md @@ -155,3 +155,283 @@ Checked against `12d4a4f9d`. - **The fixtures add** a fader offset (#1650), a hand mute over a mute curve (#1651 amended) and a send ride (#1654); depend on #1650 and #1654. - Native pin tests in `hosts/host-web/src/tests.rs` have moved; re-read at filing. + +## Amendment (controls coordinator, batch 20, 2026-10-17): narrowed to the delivered rows + +Checked against `e7722bd19`. The owner narrowed this issue on 2026-10-17. This amendment replaces +D1-D5, the authorized paths, the gates, the test value and the dependencies above, and the +amendment of 2026-10-14. + +### Product outcome (narrowed) + +One fixture session automates the rows that the engine renders today: the strip fader, the strip +mute, the VCA offset, the VCA mute, and a following send that tracks its source's mute and its +VCA's mute. It uses every shape that each row accepts, and it is seeked once while it plays. The +shipped `simd128` artifact (the direct oracle), the native browser host on x86-64 +(`AudioWorkletEngineHost`) and the C ABI render the same bits. There is no new digest owner. The +oracle leg adds one pin beside the existing pins in `expected.json`. + +### What the code accepts (anchors at `e7722bd19`) + +- **Grammar.** An entry is `{id, target, segments, offset}` (`crates/session/src/parse.rs:1569-1604`). + The target is `{owner, owner_id, section, control, lane}` (`:1606-1646`). A segment is + `{shape, start_sample, end_sample, start_value, end_value, unit}` (`:1690`). Sample times are + JSON strings. +- **Rows.** Strip fader: `strip`, `builtins`, control 5. Strip mute: control 6 + (`crates/controls/src/builtin_rows.rs:29-30`). VCA offset: `vca`, `own`, control 1. VCA mute: + control 2 (`crates/controls/src/vca.rs:14-15`). Only these four rows compile to cells + (`crates/controls/src/automation.rs:394-408`). A route-row entry is kept but inert (#1654). +- **Shapes.** `step` is always valid. `linear` needs a continuous row. `exponential` needs a + continuous row above 0 that is not in dB (`crates/controls/src/automation.rs:255-266`). So the + fader and the VCA offset take `step` and `linear`, the two mutes take `step` only (unit + `linear`, values 0 and 1), and `exponential` is refused on all five rows with + `control.automation.shape`. +- **Structure** (`crates/session/src/validate.rs`). Segments are ordered and do not overlap + (`:1023`, `:1031`). Gaps are valid. The jumps of one entry are at least 64 samples apart + (`:1070-1134`). One entry per lane, and `both` claims the two lanes (`:1141-1185`). The hold + rule is in `docs/SESSION_SCHEMA_V1.md:339-345`: a `step` holds `start_value` and takes + `end_value` at `end_sample`. +- **Offsets.** The stored hand-move offset (#1650) is `offset.{left_db,right_db}`. A lane that the + entry drives on the fader or VCA offset row takes a value in `[-144, 24]` dB. Every other lane + holds exactly `+0.0` (`docs/SESSION_SCHEMA_V1.md:363-371`). +- **Known-good shapes.** `fixtures/session/v1/builtins-automation.json` (fader `:174-197`, VCA + offset `:294-318`, VCA members `:106-118`, a following send `:129-148`). Mute entries: + `hosts/host-web/tests/stored_mute_automation.rs:184-207`. VCA mute: + `crates/host-core/tests/stored_vca_automation.rs:161-184`. + +### D1 (replaced). One fixture: `hosts/host-web/tests/browser-v1/automation-session.json` + +Copy the frame of `command-session.json` (48 kHz, quantum 128, profiles, `main-out`, default +smoothing). Then: + +- **Sources.** `src-t1` and `src-t2`, 2 channels, `32f`, `frames` `"48000"`. A seek clamps a + source to `[0, frames]` (`crates/host-core/src/source.rs:37-39`), so the frames must be more + than 37,168. +- **Tracks.** `t1` on `src-t1` and `t2` on `src-t2`. No console slot, no insert, no input + filter. Static faders at 0 dB, all hand mutes `false`. +- **Submix.** `bus`, with the plain builtins of `builtins-automation.json:68-118`. +- **VCA.** `vca`, members `["t1", "t2"]`, static `fader.left_db` `-2.0` (the static offset on the + left lanes), all other static values zero or `false`. +- **Routes.** `t1-main` and `t2-main` (`post_pan` to `main-out`). `t1-bus` (`pre_fader` to `bus`, + `follows_mute: true`). `t2-bus` (`pre_fader` to `bus`, `follows_mute: false`, the + non-following contrast). `bus-main` (`post_pan` to `main-out`). +- **Entries** (values in dB for the dB rows, 0 or 1 for the mutes): + - `t1-fader`, `t1` control 5, lane `both`, offset `{left_db: -1.5, right_db: 0.0}`: + linear 256-2304, 0 to -12; a gap; step 3001-3601, -6 then -9 (3001 and 3601 are not grid + samples); linear 29000-33000, -9 to +3; step 34000-35000, 0 then -3. + - `t1-mute`, `t1` control 6, lane `left`: step 0-1000, 0 then 1; step 1600-4200, 0 then 1; + step 4800-29800, 0 then 1; step 30600-33500, 0 then 1; step 34000-36000, 0 then 0. Muted over + 1000-1600, 4200-4800, 29800-30600 and 33500-34000. + - `t2-fader`, `t2` control 5, lane `right`, offset `{left_db: 0.0, right_db: 0.0}`: linear + 1024-4096, -3 to 0; linear 29900-32000, 0 to -18. + - `vca-offset`, `vca` control 1, lane `right`, offset `{left_db: 0.0, right_db: -0.5}`: linear + 512-3584, 0 to -6; step 4096-4352, +2 then 0; linear 29500-31500, -4 to +2; step 33000-35500, + -3 then -3. + - `vca-mute`, `vca` control 2, lane `both`: step 0-2000, 0 then 1; step 2400-30200, 0 then 1; + step 30400-35000, 0 then 1; step 35500-36000, 0 then 0. Muted over 2000-2400, 30200-30400 + and 35000-35500 (review fold 1, M1: the seek target 30,000 and the step at 3001 are outside + every VCA mute hold, so their landings are heard). +- **What this covers.** A `both` entry and single-lane entries. Lanes driven by their own curve, + by a VCA curve, by both, and plain lanes. Linear segments, steps on and off the grid, gaps, a + hold before the first segment and after the last one, and non-zero stored offsets. Each entry + has a segment that starts before the seek target 30,000 and ends after it, and events after the + seek. The following send closes for the strip mute (left lane only) and for the VCA mute. The + non-following send stays open. +- **Dropped.** The matrix fixture, effect inserts, and every other row (phase 2, below). + +### D2 (replaced). One transcript + +- 96 blocks of 128 frames. Blocks 0-39 render timeline 0-5,119. Before block 40 renders, the host + makes one plain session seek: generation 2, timeline 30,000. Blocks 40-95 render timeline + 30,000-37,167. +- Before each render, submit exactly one 128-frame chunk per source (the oracle's ring holds one + quantum, `direct-oracle.mjs:96`). Block `k < 40`: generation 1, start frame `k*128`. Block + `k >= 40`: generation 2, start frame `30000 + (k-40)*128`. `final` is always false. Do not + submit a generation-1 chunk for block 40. A seek discards stale chunks and needs contiguous new + chunks from the seek frame (`crates/source/src/lib.rs:1162-1168`, `:1355-1381`). +- PCM at source frame `f`: `((f*m) mod 65536 - 32768) / 65536`, with `m` = 40503 (`src-t1` left), + 30011 (`src-t1` right), 52153 (`src-t2` left), 18749 (`src-t2` right). The value is exact in + `f32`. In Rust, compute `(f as u32).wrapping_mul(m) & 0xffff` in integers, then subtract 32768 + and divide by 65536.0 (plain `i32` multiplication overflows). In JavaScript, + `(Math.imul(f, m) >>> 0) & 0xffff` gives the same integer. +- The seek: `miso_engine_web_v1_session_seek(handle, 2, 30000n)` in the oracle + (`hosts/host-web/src/ffi.rs:3351`), `host.session_seek(None, 2, 30000, None)` natively + (`hosts/host-web/src/lib.rs:3756`), and `miso_engine_v1_session_seek(session, 2, 30000)` on the + C ABI (`crates/capi/src/ffi.rs:660`). All three reach `SessionState::session_seek` + (`crates/control-plane/src/control.rs:4327`). A plain seek declares a discontinuity and + publishes a successor plan, on every host alike. Every seek and every submit must return 0. + +### D3 (replaced). One oracle leg and one pin + +- `direct-oracle.mjs` gains `runAutomationTimeline`. It boots `automation-session.json`, runs D2 + and digests the PCM as the other legs do (`pcmSha256`, every left block then every right block). + It records `actual.automationTimeline = {blocks: 96, seekBlock: 40, seekTimelineSample: 30000, + beforeDisposeStatus, pcmF32leSha256}`. Before the print branch, it asserts that the digest + equals `expected.directOracle.nativeAutomationPcmF32leSha256`, as the other legs do + (`:493-576`). +- The pin key is **`directOracle.nativeAutomationPcmF32leSha256`**. Put `automationTimeline` and + the pin after `observationTimeline` in `expected.json`. +- The native pin test is `native_automation_timeline_digest_pins_the_wasm_parity` in + `hosts/host-web/src/tests.rs`, beside its twin at `:2016`. It copies that twin: `include_str!` + of the fixture, `AudioWorkletEngineHost::boot` with `source_ring_frames: QUANTUM`, + `submit_source`, `render_next`, the same digest. It asserts against both pins. +- `serde_pin` (`:2120-2164`) gains two fields: the text after `"nativeAutomationPcmF32leSha256":`, + and the `"pcmF32leSha256":` after the anchor `"automationTimeline"`. +- `scripts/check-browser-expected-resources.py` adds `("automationTimeline", "pcmF32leSha256")` + to `DIGEST_PATHS` (`:121-125`) and says four PCM digests in its docstring (`:33-35`). The two-class + rule covers resource rows only, so the new key needs no class. The oracle (`:599`) and the + script (`:274-283`) compare the whole document, so `expected.json`, the oracle and the script + change in one commit. No other reader breaks: `qualification/qualification.js:1131-1132` and + `run.mjs:204-207` read only the identity pins. + +### D4 (replaced). One cross-host case in `hosts/host-web/tests/control_parity.rs` + +- `the_automation_fixture_renders_alike_on_both_hosts_across_one_session_seek`. The browser side + boots the fixture bytes through `Rig::boot` (`tests/common/mod.rs:363-385`). The C ABI side + parses the same bytes with `parse_session_json` and builds `CAbi::new` + (`control_parity.rs:922-975`). +- It runs D2 with `CAbi::session_seek` (`:4949-4972`) and direct submits, as + `a_composed_stored_mute_renders_alike_on_both_hosts` does (`:3694-3741`). Do not use `step()`: + it hard-codes generation 1 (`common/mod.rs:454-475`). +- It compares every block's two planes with `bits()` (`common/mod.rs:287-289`) and `assert_eq!`. + No digest. The case is under `test-support`. + +### D5 (replaced). The plants (PR evidence, not committed) + +A plain one-ulp change to the result of `value_at` alone is not valid. The program builder +compares `value_before` with `value_at` to find jumps (`crates/automation/src/program.rs:330`), so +the change adds jumps and can make preparation refuse the session. Use these three plants, one at +a time, and record each leg's result: + +- **P1, the evaluator (every row).** Put `f32::from_bits(v.to_bits() + 1)` on every return of + `value_at` (`crates/automation/src/evaluate.rs:18-44`) and of `value_before` + (`crates/automation/src/program.rs:338-358`). The jumps stay the same. A mute value of 0.0 + becomes the least subnormal, so every open mute span, VCA mute span and follow gate closes + (`value != 0.0`: `crates/builtins-compiler/src/lib.rs:3252`; + `crates/graph/src/runtime.rs:1480-1481`, `:2196`). The FP environment keeps subnormals on every + target (`crates/lane/src/fpenv.rs:518-530`). Expected red: the native pin test, the oracle leg, + and G5 (`g5_native_digests_match_pins` and the wasm leg; `crates/automation/src/corpus.rs:245`). + Expected green: D4 (the path is shared). +- **P2, the dB rows.** On a dB row a P1 ulp can disappear (in `db_gain`, or in + `vca_compose_db`). Put `next_up` on the output of `db_gain` + (`crates/builtins/src/lib.rs:6472-6479`). It reaches every fader and VCA offset lane. Expected + red: the native pin test and the oracle leg. +- **P3, one host only (does D4 compare?).** After `state.render` returns `Ok` in + `miso_engine_v1_render_f32_planar` (`crates/capi/src/ffi.rs:1133`), flip the low bit of the + first left sample when `absolute_sample >= 5120`. Expected red: D4 at block 40. Expected green: + the pin test and the oracle leg. + +Hand-written tests that compute their expected values with `value_at` stay green under P1 (for +example `crates/host-core/tests/stored_fader_automation.rs:578`). Do not count them. + +### Authorized paths (replaced) + +- `hosts/host-web/tests/browser-v1/automation-session.json` (new), `direct-oracle.mjs`, + `expected.json`. +- `hosts/host-web/src/tests.rs` (one pin test, `serde_pin`). +- `hosts/host-web/tests/control_parity.rs` (D4). +- `scripts/check-browser-expected-resources.py` (`DIGEST_PATHS` and the docstring only). + +### Objective gates (replaced) + +1. **Browser equals native.** As on main (`.github/workflows/qualification.yml:132`, `:345-348`): + `mkdir -p target/ci/qualification-artifacts target/ci/qualification-named-twin && bash scripts/build-web-audioworklet.sh --named-twin target/ci/qualification-named-twin target/ci/qualification-artifacts`, + then + `bash scripts/check-web-audioworklet.sh --without-metadata-regeneration target/ci/qualification-artifacts target/ci/qualification-named-twin/miso-engine-v1-audio-worklet.simd128.named.wasm` + and `python3 -B scripts/check-browser-expected-resources.py --artifacts target/ci/qualification-artifacts` + pass with the automation leg. `python3 -B scripts/check-browser-expected-resources.py --self-test` + passes. +2. **Native pin and cross-host.** `cargo test --locked -p host-web --features test-support` passes, + with the new pin test and the D4 case. +3. **Plants** (D5, PR evidence): P1, P2 and P3 each give the expected red legs. +4. **Workspace.** The `test-debug-a` step (`qualification.yml:631-638`); G5, + `cargo test --locked --release -p lane -p math -p wasm-gates --features math/lane` (`:705-706`); + `cargo fmt --all -- --check`; + `cargo clippy --locked --workspace --all-targets --all-features -- -D warnings`. + +### Test value (replaced) + +- **The oracle leg and its pin** turn red if the `simd128` artifact renders an event, a + composition (fader with VCA offsets, mute OR, the follow gate) or the landing of a plain session + seek differently from x86-64 native. Today the only artifact-versus-native renders have empty + automation (`session.json:62`, `command-session.json:141`, `observation-session.json:159`). +- **The D4 case** turns red if the C ABI and the browser host prepare the whole fixture or land + its seek differently. Today the only cross-host case with a seek automates the mute row only + (`control_parity.rs:3571`), and the VCA cases do not seek (`:6916-7395`). + +### Dependencies (replaced): all on main + +- #1637 and #1639 (PR #1687), #1645 (PR #1692), #1642 and #1643 (PR #1708), #1646-#1650 (PR + #1712), #1651-#1653 (PR #1715), #1655 (PR #1719). The harness work #1399 and #1560 is closed. + All of these issues are closed on GitHub. +- The fixture has no live edit. A stored offset (#1650) is a field of the entry. A hand mute + (#1651) is the static `fader.*_mute` and would OR to a constant mute, so the fixture keeps it + `false`. The live hand mute over a curve is already compared across hosts at `:3571`. +- #1670 depends on this issue. Its spec names two fixtures and two pin tests; it needs a matching + amendment (one fixture, one pin test). + +### Phase-2 extensions (not in this issue) + +- The matrix fixture `automation-matrix-session.json` and pan and matrix automation (#1657). +- Input trim and polarity (#1658), input HPF and LPF (#1660), parametric EQ (#1667), and every + other effect-parameter row, with effect inserts in the fixture. +- Send-row (route-row) automation (#1654, behind #1718). +- A following send at a latent tap (an insert with latency) in the oracle fixture. +- AArch64 and seek-time invariance stay in #1670. + +## Decision record: T1+T2 (batch 20) + +- **D1 shipped as written.** `hosts/host-web/tests/browser-v1/automation-session.json` has the + amendment's sources, tracks, submix, VCA, five routes and five entries with no departure. Every + value was accepted: the "0 then 0" and "-3 then -3" steps, the 64-sample jump spacing and the + VCA `both` mute lane all parse, validate and prepare (the native pin test boots the file + verbatim). Track pan is `left -1.0, right 1.0, smoothing_samples 0` and the bus matrix is the + identity with `smoothing_samples 0`, as in `command-session.json` and `builtins-automation.json`. + The two `content` digests are placeholders of the valid form (the engine streams the PCM). +- **D2.** One transcript helper, `automation_transcript` in `hosts/host-web/src/tests.rs`, and its + JS twin in `runAutomationTimeline`: the same integer formula and chunk schedule. Every seek, + submit and render returns 0 and is asserted on both sides. +- **D3.** Pin test `native_automation_timeline_digest_pins_the_wasm_parity`; `serde_pin` reads + `nativeAutomationPcmF32leSha256` and the `pcmF32leSha256` after `"automationTimeline"`. The test + also asserts the automation is heard: the same session with `automation` cleared renders other + bits before the seek (blocks 0-39) and after it (blocks 40-95). +- **Pin discipline.** The native digest and the simd128 oracle digest were read independently + before any pin was written (the oracle run against a scratch copy of `expected.json` with a + probe pin). Both are + `6f82e4668836ea2c7b977f2c322882aea98d73c0dd285caf41e13ccd18186cb6`. The leg's + `beforeDisposeStatus.memoryBytes` is 2,949,120 (one 64 KiB page above the other legs; the + seek's successor plan is control work). +- **Script.** `DIGEST_PATHS` gains `("automationTimeline", "pcmF32leSha256")`; the docstring and the + comment above it say four digests. +- **Gates (all green).** `cargo fmt --all`; `cargo clippy --locked -p host-web --all-targets + --all-features -- -D warnings`; `cargo check --locked -p host-web --all-targets`; + `cargo test --locked -p host-web --features test-support native_` (the four pin tests); + `bash scripts/check-web-audioworklet.sh --without-metadata-regeneration ...`; + `python3 -B scripts/check-browser-expected-resources.py --artifacts target/ci/qualification-artifacts` + and `--self-test` (32 red mutations); `node direct-oracle.mjs` in compare mode. + +### T3 + +- **D4 shipped.** `the_automation_fixture_renders_alike_on_both_hosts_across_one_session_seek` in + `hosts/host-web/tests/control_parity.rs` (under `test-support`). The browser side boots the + fixture bytes with `Rig::boot`; the C ABI side builds `CAbi::new` from `parse_session_json` of + the same bytes. Both run the D2 schedule with direct submits and `session_seek`; every seek, + submit and render returns OK, and each block's left and right planes are compared with `bits()`. + Both hosts agree on all 96 blocks. +- **Ring.** `Rig::boot` and `limits()` both use a ring of two quanta, not one; the transcript + submits one quantum per block, so the ring size does not change what renders. +- **Probe (not committed).** Flipping the low bit of the first C ABI sample at blocks >= 40 in the + test's read path turns the case red at block 40, left plane. + +### Review fold 1 + +- **M1.** At timeline 30,000 every member lane was inside a closed mute (VCA mute 29900-30100; + t1 left 29800-30600). A mute hold only remembers a seek's value (`docs/SESSION_SCHEMA_V1.md`, + the fader-row paragraph), so the fader and VCA-offset landings were never heard, and the step + at 3001 sat inside the VCA unmute hold. The `vca-mute` segments are now step 0-2000, 2400-30200, + 30400-35000 and 35500-36000 (D1 above is edited in place); t1 left still lands inside a closed + mute. Re-minted pin, measured on both sides independently and equal: + `a81d883b3f83eaf629bd78db149d1a39825c821c6cb2e9a53506341a97ed1363` (native pin test, and the + oracle against the unchanged artifact). The pin test now also asserts the landing is heard: + two copies whose `t1-fader` holds -6 dB or +20 dB over 30000-30064 only render other bits in + block 40. It is red on the fixture before this fold and green after it. The expected-resources self-test gains the `automationTimeline` digest mutation. diff --git a/.github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md b/.github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md index db6870786..570c9fcac 100644 --- a/.github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md +++ b/.github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md @@ -119,3 +119,25 @@ automation into per-cell events in node time* arrive through #1669. Checked against `12d4a4f9d`. - No design change. It inherits #1669's added rows. #1019 is still open. Anchors re-read at filing. + +## Amendment (controls coordinator, batch 20, 2026-10-17) + +Checked against `e7722bd19`. The owner narrowed #1669 to the rows that render today (see #1669's +batch 20 amendment). + +- #1669 adds one fixture, `automation-session.json`, one pin test, + `native_automation_timeline_digest_pins_the_wasm_parity`, and one pin, + `directOracle.nativeAutomationPcmF32leSha256`. There is no `automation-matrix-session.json`. + Read "the two automation pin tests", "both fixtures" and "the two pins" in this issue as these + single items. +- D1 and D2 use #1669's narrowed transcript: 96 blocks of 128 frames, a plain session seek to + timeline 30,000 at block 40, generation 2. +- The matrix fixture and the rows that #1657, #1658, #1660 and #1667 add are phase-2 extensions, + as in #1669. +- The narrowed fixture has no insert, so D2's "equal from 256 samples after" bound (a delay's + held jump plus its crossfade) does not apply. The longest ramp a seek leaves is a mute hold + (`mute_ms`, 480 samples) followed by a fader ramp to the curve (`fader_ms`, 960 samples), so + the bound is 1,440 samples after the seek at 48 kHz (`docs/SESSION_SCHEMA_V1.md`, the + fader-row paragraph). A pre-seek unmute ramp still running at the seek carries across it, as + documented. Read "five pin tests" in gate 1 and the hazards as four. The LANE-3 compressor and + limiter hazard does not apply: the narrowed fixture has no insert. 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 deleted file mode 100644 index 691680d17..000000000 --- a/.github/ISSUE_SPECS/1711-keep-a-live-level-moves-own-length-across-a-reposition-swap.md +++ /dev/null @@ -1,577 +0,0 @@ -# Keep a live level move's own length across a Reposition swap - -Filed by root from batch 17 (#1648's split, root ruling B17-R13; controls coordinator, 2026-10-16). -It follows the "split a blocking gate early" practice: #1648 closes on its passing gates, and the -in-flight length case moves here. - -## Product outcome - -A live level move (a VCA move over an automated fader, or a hand move stored as the ride's offset) -that is still ramping when a `Reposition` swap adopts (a rebuild whose only change on that lane is -the ride's table) ends when it was due, with its own length, for every `remaining` (the frames -left in its ramp at the swap), including `remaining < J` (`J`, the fader jump length, 960 in the -gate's session). The revision's watermark then reports `OUTCOME_EXACT`. This issue replaces the -conservative fallback flag of B17-R14 with the exact behaviour and deletes that flag path. - -## Context - -- Since #1648 attempt 3 (U3 fold 2), the adoption rule (`settle_adoption`, - `crates/builtins-compiler/src/lib.rs`) holds a free stage's running ramp again, compares the - curve where that ramp ends with the stage's target, and lands a moved target over - `max(J, remaining)` (a one-sample landing piece, then the rest of the block over `J`). -- The defect (verifier, batch 17, at 0fac00ee9): when `remaining < J` the landing is stretched to - `J`. In the gate's setup (a 4,000-sample move from block 4, `a_level_move_across_a_table_change_ - keeps_its_length` in `crates/capi/src/runtime/live_tests.rs`) a move heard 24, 26 and 30 blocks - has 928, 672 and 160 samples left; the rebuilt rig differs from the uninterrupted engine by - 4.8e-4, 3.1e-3 and 2.1e-3 (both table changes, VCA and hand moves). -- Current behaviour (B17-R13 and B17-R14): the move is stretched to `J`. The control plane marks a - rebuild candidate `CandidateOutcome::TransitionFallback` before it publishes it when the - rebuild is a transaction (not a declared discontinuity), the successor's model has at least one - stored fader automation entry (`controls::automation_row_renders`), and the session's level - move window may still ramp. The window (`LevelMoveWindow` in - `crates/control-plane/src/control.rs`) folds every live commit's fader `FaderDb` and offsets - records with a nonzero ramp, and every rebuild commit that changes some kept strip's effective - fader dB or `S` (`controls::effective`, old model against new) with the ramp - `max(fader_samples, the longest explicit ramp of the transaction)`, into one bound: the newest - revision and the longest ramp. "May still ramp" is `watermark.revision < revision || - render_clock < watermark.first_sample + ramp` (a watermark read that gives up counts as "may"). - The watermark and the render clock are read before the reservation. - So the watermark reports `OUTCOME_TRANSITION_FALLBACK`. This is conservative: it is also set - when no move is stretched (any transaction rebuild of a session with fader automation while any - level move may still ramp; see the #1648 decision record "the conservative fallback flag"). -- First thing to find out, before the design work (root's note): why the stage target moves - during a heard move (0.398 at 12 blocks, 0.335 at 24, against 0.3018). This may be a defect of - its own. Measured while landing B17-R13 with a temporary probe: for every heard move on the - moving ride, the stage's `fader_gain` at the swap is not the composed curve at the ramp's end. - So the adoption rule's comparison always differs, and every heard move lands at the swap (over - `remaining`, or `J`). The 4.6e-6 at 12 blocks heard comes from that landing recomputing the - stage's ramp step. -- A second Reposition inside a stretched landing (B17-R13 tranche 5): a flagged rebuild also folds - `(its revision, J)` into the window, so a second table-only rebuild inside the first one's - landing over `J` is flagged too (gate - `a_second_reposition_inside_a_stretched_landing_reports_a_fallback`). D2 deletes this with the - rest of the flag. -- Further over-report: `may_ramp` reads the watermark's newest `first_sample`, so any later - revision restarts the window (a non-level edit, or the flagged rebuild itself). A stream of live - edits keeps it open after one level move. -- A cell's own jump of `J` that is still running at a Reposition swap and is landed over `J` again - is specified D2 behaviour, not a defect: it has no user length, and a fresh plan with the new - table also jumps over `J`. The exception is a stretched live move's landing, which is covered by - the tranche-5 fold today and by D1 after this issue. -- MINOR 4 (from B17-R13, not gated): the comparison point `at = end` in `settle_adoption` has no - gate. It is observable with a heard move on the flat ride (`S` moves, the curve does not), then - a table whose step falls inside the remaining ramp. G2 can use that setup. - -## Decision to make at filing - -- D1. The deferred jump. At a `Reposition` adoption, a free stage's live ramp is not retargeted: - it runs to its end as the uninterrupted engine runs it. The cell makes the D2 comparison and, if - the target moved, the `J` jump at the first free sample after the ramp (the ramp's end), not at - the block's first sample. A small API in `crates/automation` gives the cell's deferred jump - (the node time at which it is due and the comparison it makes there), so the driver needs no - second landing path; the one-sample landing piece and `max(J, remaining)` are deleted. -- D2. The conservative fallback flag of B17-R14 is deleted: `LevelMoveWindow`, `fold_level_move`, - `rebuild_level_ramp`, `rebuild_moves_a_level` and the `set_outcome` call in - `crates/control-plane/src/control.rs`, with `ObservedReservation::set_outcome` if nothing else - uses it. The text that B17-R14 appended is removed from `crates/capi/src/abi.rs`, the C header - comment, `docs/C_ABI_V1_QUALIFICATION.md` and the two d.ts files. Its gates - (`a_reposition_swap_while_a_level_move_ramps_reports_a_fallback` and its host-web parity test) - are replaced by G1. -- D3. A step of the cell's own at the swap block's first sample keeps the B17-R13 rule: the curve - is compared there and the landing is over `J` (gate - `a_step_at_a_reposition_swap_jumps_over_the_fader_length`). - -## Objective gates - -- G1. VCA and hand moves of 4,000 samples, heard 1, 12, 24, 26 and 30 blocks before a table-only - rebuild (the ride split at 10,000, and a change in its last 2,000 samples): every block after - the swap is bit-equal to the uninterrupted engine's (or, where the table's own values differ, - to a fresh plan prepared with the new table and given the same move), allocation-free, adopted, - and the rebuild's watermark advance reports `OUTCOME_EXACT`. -- G2. The comparison point (setup: a heard move on the flat ride, then a table whose step falls - inside the remaining ramp): a heard move whose stage target equals the curve at its ramp's end - keeps its own length with less than `J` left (red if the curve is compared at the block's first - sample). -- G3. The B17-R13 step gate and the #1648 gates stay green; realtime policy count unchanged or - explained. - -## Dependencies - -- #1648 (closed on its gates), B17-R13 and B17-R14 (the conservative flag this issue removes). - -## Amendment (controls coordinator, batch 18, root ruling 2026-10-17) - -- **Added item: gate or delete the owed discharge on a free fader write.** In - `crates/builtins-compiler/src/lib.rs` (about `:1689` on `ad03780ee`), a free live write releases the - hold and sets `self.owed = false`. Deleting that line leaves every test green (batch 18 T1 gate - 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/1720-ceiling-the-direct-oracle-status-memory-bytes.md b/.github/ISSUE_SPECS/1720-ceiling-the-direct-oracle-status-memory-bytes.md new file mode 100644 index 000000000..a7903f7cb --- /dev/null +++ b/.github/ISSUE_SPECS/1720-ceiling-the-direct-oracle-status-memory-bytes.md @@ -0,0 +1,58 @@ +# Ceiling the direct oracle's status memory bytes + +Proposed by the controls coordinator from batch 20 (#1669 review, NIT/placeholder; 2026-10-17). +Filed by root. Not phase-2 automation work: test hygiene for the browser identity +gate. Code anchors at `589403170`. + +## Product outcome + +The direct oracle's printed `beforeDisposeStatus.memoryBytes` (and any other status field that +is a resource size, not a contract fact) is a ceiling, not an exact pin. A layout change that grows +the Wasm linear memory by one page inside its budget does not turn the gate red; growth past the +budget does. + +## Context + +- `scripts/check-browser-expected-resources.py` splits resource rows into `EXACT_ROWS` and + `CEILING_ROWS` (#1060, owner decision 4: budgets, not exact counts). Everything else in the + printed document must equal `expected.json` exactly (the docstring, `:33-36`). +- The status snapshots carry `memoryBytes`, the Wasm memory size. It falls in "everything else", + so it is pinned to the byte: 2,883,584 in the identity, SET_CONTROL and observation legs + (`hosts/host-web/tests/browser-v1/expected.json:39`, `:49`, `:117`) and 2,949,120 in the batch-20 + automation leg (`:146`). +- AGENTS.md (test value): an exact resource byte count is used only for a wire, ABI or on-disk + format. `memoryBytes` is none of these. The batch-20 reviewer flagged it; the older legs had the + same pin before batch 20. + +## Decisions to make at filing + +- D1. Which status fields are resource sizes (at least `memoryBytes`) and which are contract facts. +- D2. The ceiling rule: reuse the `CEILING_ROWS` rule (value plus 10 %, rounded up to a 64 KiB page + for memory), or a separate status-ceiling table. + +## Deliverables + +- The script classifies the status resource fields as ceilings, with a `--self-test` mutation for + one page over the ceiling. +- `expected.json` carries their ceilings; the oracle prints the measured values. + +## Authorized paths + +- `scripts/check-browser-expected-resources.py`, `hosts/host-web/tests/browser-v1/expected.json`, + and, if the oracle compares the whole document, `hosts/host-web/tests/browser-v1/direct-oracle.mjs`. + +## Objective gates + +1. `python3 -B scripts/check-browser-expected-resources.py --self-test` catches one page over a + status ceiling. +2. `python3 -B scripts/check-browser-expected-resources.py --artifacts target/ci/qualification-artifacts` + passes on the shipped artifact. + +## Test value + +The new self-test mutation turns red if a status resource field stops being ceilinged or grows past +its budget, which the exact pin catches today only as noise on every layout change. + +## Dependencies + +None. #1669 (batch 20) adds the fourth leg it covers. diff --git a/docs/handoffs/stored-automation-1058/README.md b/docs/handoffs/stored-automation-1058/README.md index 032e6752e..c979fc8aa 100644 --- a/docs/handoffs/stored-automation-1058/README.md +++ b/docs/handoffs/stored-automation-1058/README.md @@ -837,7 +837,7 @@ batch 9 head"). | #1628 | `.github/ISSUE_SPECS/1628-retire-the-automation-canceled-event.md` | Retire the AUTOMATION_CANCELED event and the cancellation path | #1627 | P1 | | #1629 | `.github/ISSUE_SPECS/1629-delete-the-protocol-automation-queue.md` | Delete the protocol automation queue, its records and counters | #1628 | P1 | | #1668 | `.github/ISSUE_SPECS/1668-reserve-the-c-abi-automation-span-limit.md` | Reserve the C ABI's automation span limit | #1629, #1306 | P2 | -| #1669 | `.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md` | Compare stored automation bits across the browser and native hosts | #1653, #1657, #1658, #1660, #1667, #1399 | Q | +| #1669 | `.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md` | Compare stored automation bits across the browser and native hosts (narrowed 2026-10-17 to the delivered rows; pan, trim, filters, EQ and the matrix fixture are phase 2) | #1653, #1655, #1399 | Q | | #1670 | `.github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md` | Compare stored automation bits on AArch64 and across seek times | #1669, #1019 | Q | | #1671 | `.github/ISSUE_SPECS/1671-build-the-stored-automation-console-benchmark-row.md` | Build the stored-automation console benchmark row | #1667 | Q | | #1672 | `.github/ISSUE_SPECS/1672-record-the-stored-automation-console-benchmark-baseline.md` | Record the stored-automation console benchmark baseline | #1671 | Q | diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 31bd40ea2..4f235b294 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -2113,6 +2113,164 @@ fn native_set_control_timeline_digest_pins_the_wasm_parity() { ); } +/// The #1669 automation transcript (D2): 96 blocks of 128 frames, one plain session seek to +/// timeline 30,000 under generation 2 before block 40, one chunk per source before every render. +/// It returns each block's output (left plane, then right plane). `direct-oracle.mjs`'s +/// `runAutomationTimeline` replays the same integer PCM formula and chunk schedule. +fn automation_transcript(document: &[u8]) -> Vec> { + const QUANTUM: u32 = 128; + const RATE: u32 = 48_000; + const BLOCKS: u64 = 96; + const SEEK_BLOCK: u64 = 40; + const SEEK_TIMELINE: u64 = 30_000; + // `((f*m) mod 65536 - 32768) / 65536`, exact in `f32`, per source and lane. + const SOURCES: [(&[u8], [u32; 2]); 2] = + [(b"src-t1", [40_503, 30_011]), (b"src-t2", [52_153, 18_749])]; + let pcm = |frame: u64, multiplier: u32| -> f32 { + let frame = u32::try_from(frame).expect("frame fits u32"); + let residue = (frame.wrapping_mul(multiplier) & 0xffff) as i32 - 32_768; + residue as f32 / 65_536.0 + }; + + let options = WebBootOptions { + source_ring_frames: QUANTUM, + ..boot_options(QUANTUM) + }; + let mut host = AudioWorkletEngineHost::boot(document, options).unwrap_or_else(|failure| { + panic!("boot: {}", String::from_utf8_lossy(failure.diagnostic())) + }); + let mut blocks = Vec::new(); + for block in 0..BLOCKS { + if block == SEEK_BLOCK { + assert_eq!( + host.session_seek(None, 2, SEEK_TIMELINE, None), + RESULT_OK, + "the session seek" + ); + } + let (generation, start) = if block < SEEK_BLOCK { + (1, block * u64::from(QUANTUM)) + } else { + (2, SEEK_TIMELINE + (block - SEEK_BLOCK) * u64::from(QUANTUM)) + }; + for (source, multipliers) in SOURCES { + let [left, right] = multipliers.map(|multiplier| { + (start..start + u64::from(QUANTUM)) + .map(|frame| pcm(frame, multiplier)) + .collect::>() + }); + let planes: [&[f32]; 2] = [&left, &right]; + assert_eq!( + host.submit_source(source, generation, start, RATE, &planes, QUANTUM, false), + RESULT_OK, + "block {block} submit" + ); + } + assert_eq!(host.render_next(), RESULT_OK, "block {block} render"); + blocks.push(host.output_pcm().expect("prepared output").to_vec()); + } + assert_eq!(host.status().rendered_quanta, BLOCKS); + blocks +} + +/// #1669 D3: the stored-automation timeline's native digest. `tests/browser-v1/automation-session.json` +/// automates every row the engine renders today -- the strip fader (`both` and one lane, with +/// stored offsets), the strip mute (one lane), the VCA offset and the VCA mute -- with a following +/// and a non-following send, and is seeked once while it plays (D2). `direct-oracle.mjs` renders +/// the same session and transcript through the shipped simd128 artifact and asserts its digest +/// equals the pin this test checks. The digest is over little-endian `f32` words (every left +/// block, then every right block), so it is a bit comparison. +/// +/// Test value: red if the simd128 artifact renders an automation event, a composition (fader with +/// VCA offset, mute OR, the follow gate) or the landing of a plain session seek differently from +/// x86-64 native. The other artifact-versus-native renders have empty automation. +#[test] +fn native_automation_timeline_digest_pins_the_wasm_parity() { + use sha2::{Digest, Sha256}; + + const QUANTUM: usize = 128; + // The fixture file is read verbatim, exactly as `direct-oracle.mjs` reads it. + let document = include_str!("../tests/browser-v1/automation-session.json"); + let blocks = automation_transcript(document.as_bytes()); + + // The automation is heard on both sides of the seek: the same session with no entry renders + // other bits before the seek and after it. + let mut plain = parse_session_json(document).expect("the automation fixture"); + plain.automation.clear(); + let plain = automation_transcript( + canonical_session_json(&plain) + .expect("canonical plain session") + .as_bytes(), + ); + assert_ne!( + blocks[..40], + plain[..40], + "automation heard before the seek" + ); + assert_ne!(blocks[40..], plain[40..], "automation heard after the seek"); + // The seek's landing is heard (review fold 1, M1): `t1-fader` holds `value` dB over + // 30000-30064 only, so two values differ only where the seek lands, and block 40 differs. + let landing = |value: f32| { + let mut model = parse_session_json(document).expect("the automation fixture"); + let ride = model + .automation + .iter_mut() + .find(|entry| entry.id.as_str() == "t1-fader") + .expect("t1-fader"); + let segment = |shape, span: (u64, u64), values: (f32, f32)| session::AutomationSegment { + shape, + start_sample: span.0, + end_sample: span.1, + start_value: values.0, + end_value: values.1, + unit: session::ParameterUnit::Db, + }; + let (step, linear) = ( + session::AutomationShape::Step, + session::AutomationShape::Linear, + ); + ride.segments.splice( + 2..3, + [ + segment(step, (29_000, 30_000), (-9.0, value)), + segment(step, (30_000, 30_064), (value, -9.0)), + segment(linear, (30_064, 33_000), (-9.0, 3.0)), + ], + ); + automation_transcript( + canonical_session_json(&model) + .expect("canonical") + .as_bytes(), + ) + }; + assert_ne!( + landing(-6.0)[40], + landing(20.0)[40], + "the seek's fader landing is heard" + ); + + let mut digest = Sha256::new(); + for channel in 0..2_usize { + for output in &blocks { + for sample in &output[channel * QUANTUM..(channel + 1) * QUANTUM] { + digest.update(sample.to_bits().to_le_bytes()); + } + } + } + let native = engine::hex_lower(&digest.finalize()); + + let expected: serde_pin::Pin = + serde_pin::read(include_str!("../tests/browser-v1/expected.json")); + assert_eq!( + native, expected.native_automation, + "native and the pinned wasm automation-timeline digest must agree bit for bit" + ); + assert_eq!( + native, expected.simd128_automation, + "the shipped simd128 artifact renders this automation timeline to the same bits as native" + ); +} + /// A three-field reader for `expected.json`, so the test needs no JSON dependency. /// /// The file is generated by `direct-oracle.mjs` and is machine-formatted, so the two hashes are @@ -2125,6 +2283,8 @@ mod serde_pin { pub(super) simd128_set_control_timeline: String, pub(super) native_observation: String, pub(super) simd128_observation: String, + pub(super) native_automation: String, + pub(super) simd128_automation: String, } fn hex_after(text: &str, key: &str) -> String { @@ -2159,6 +2319,13 @@ mod serde_pin { .expect("expected.json has no observationTimeline")..], "\"pcmF32leSha256\":", ), + native_automation: hex_after(text, "\"nativeAutomationPcmF32leSha256\":"), + simd128_automation: hex_after( + &text[text + .find("\"automationTimeline\"") + .expect("expected.json has no automationTimeline")..], + "\"pcmF32leSha256\":", + ), } } } diff --git a/hosts/host-web/tests/browser-v1/automation-session.json b/hosts/host-web/tests/browser-v1/automation-session.json new file mode 100644 index 000000000..1b1f7ad55 --- /dev/null +++ b/hosts/host-web/tests/browser-v1/automation-session.json @@ -0,0 +1,514 @@ +{ + "schema_version": 1, + "session_id": "web-browser-automation", + "revision": "1", + "sample_rate_hz": 48000, + "quantum_frames": 128, + "render_profile": { + "id": "native", + "mode": "single_thread" + }, + "output_profile": { + "id": "main", + "channels": 2, + "sample_format": "f32_planar" + }, + "sources": [ + { + "id": "src-t1", + "content": "blake3:08b2490bf336bde77a8e99c5099cf33f98f310f48f015bda3ffe18412427ffc9", + "channels": 2, + "bit_depth": "32f", + "frames": "48000" + }, + { + "id": "src-t2", + "content": "blake3:83a27b09adbc4ad674939525a95bbe671b15423660508249fc86ee9fea30a1be", + "channels": 2, + "bit_depth": "32f", + "frames": "48000" + } + ], + "console": { + "pre_insert": [], + "post_insert": [] + }, + "tracks": [ + { + "id": "t1", + "source_id": "src-t1", + "left_source_channel": 0, + "right_source_channel": 1, + "builtins": { + "left": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 0.0, + "lpf_hz": 0.0, + "delay_samples": 0 + }, + "right": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 0.0, + "lpf_hz": 0.0, + "delay_samples": 0 + } + }, + "console": [], + "inserts": { + "effects": [] + }, + "fader": { + "left_db": 0.0, + "right_db": 0.0, + "left_mute": false, + "right_mute": false + }, + "pan": { + "left": -1.0, + "right": 1.0, + "smoothing_samples": 0 + } + }, + { + "id": "t2", + "source_id": "src-t2", + "left_source_channel": 0, + "right_source_channel": 1, + "builtins": { + "left": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 0.0, + "lpf_hz": 0.0, + "delay_samples": 0 + }, + "right": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 0.0, + "lpf_hz": 0.0, + "delay_samples": 0 + } + }, + "console": [], + "inserts": { + "effects": [] + }, + "fader": { + "left_db": 0.0, + "right_db": 0.0, + "left_mute": false, + "right_mute": false + }, + "pan": { + "left": -1.0, + "right": 1.0, + "smoothing_samples": 0 + } + } + ], + "submixes": [ + { + "id": "bus", + "builtins": { + "left": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 0.0, + "lpf_hz": 0.0, + "delay_samples": 0 + }, + "right": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 0.0, + "lpf_hz": 0.0, + "delay_samples": 0 + } + }, + "console": [], + "inserts": { + "effects": [] + }, + "fader": { + "left_db": 0.0, + "right_db": 0.0, + "left_mute": false, + "right_mute": false + }, + "matrix": { + "ll": 1.0, + "lr": 0.0, + "rl": 0.0, + "rr": 1.0, + "smoothing_samples": 0 + } + } + ], + "vcas": [ + { + "id": "vca", + "fader": { + "left_db": -2.0, + "right_db": 0.0, + "left_mute": false, + "right_mute": false + }, + "members": [ + "t1", + "t2" + ] + } + ], + "control_smoothing": { + "kind": "default" + }, + "outputs": [ + { + "id": "main-out" + } + ], + "routes": [ + { + "id": "t1-main", + "source": { + "kind": "track", + "track_id": "t1", + "tap": "post_pan" + }, + "destination": { + "kind": "output_input", + "output_id": "main-out" + }, + "channel_matrix": { + "ll": 1.0, + "lr": 0.0, + "rl": 0.0, + "rr": 1.0 + }, + "gain_db": 0.0, + "mute": false, + "follows_mute": false + }, + { + "id": "t2-main", + "source": { + "kind": "track", + "track_id": "t2", + "tap": "post_pan" + }, + "destination": { + "kind": "output_input", + "output_id": "main-out" + }, + "channel_matrix": { + "ll": 1.0, + "lr": 0.0, + "rl": 0.0, + "rr": 1.0 + }, + "gain_db": 0.0, + "mute": false, + "follows_mute": false + }, + { + "id": "t1-bus", + "source": { + "kind": "track", + "track_id": "t1", + "tap": "pre_fader" + }, + "destination": { + "kind": "submix_input", + "submix_id": "bus" + }, + "channel_matrix": { + "ll": 1.0, + "lr": 0.0, + "rl": 0.0, + "rr": 1.0 + }, + "gain_db": 0.0, + "mute": false, + "follows_mute": true + }, + { + "id": "t2-bus", + "source": { + "kind": "track", + "track_id": "t2", + "tap": "pre_fader" + }, + "destination": { + "kind": "submix_input", + "submix_id": "bus" + }, + "channel_matrix": { + "ll": 1.0, + "lr": 0.0, + "rl": 0.0, + "rr": 1.0 + }, + "gain_db": 0.0, + "mute": false, + "follows_mute": false + }, + { + "id": "bus-main", + "source": { + "kind": "submix", + "submix_id": "bus", + "tap": "post_pan" + }, + "destination": { + "kind": "output_input", + "output_id": "main-out" + }, + "channel_matrix": { + "ll": 1.0, + "lr": 0.0, + "rl": 0.0, + "rr": 1.0 + }, + "gain_db": 0.0, + "mute": false, + "follows_mute": false + } + ], + "automation": [ + { + "id": "t1-fader", + "target": { + "owner": "strip", + "owner_id": "t1", + "section": "builtins", + "control": 5, + "lane": "both" + }, + "segments": [ + { + "shape": "linear", + "start_sample": "256", + "end_sample": "2304", + "start_value": 0.0, + "end_value": -12.0, + "unit": "db" + }, + { + "shape": "step", + "start_sample": "3001", + "end_sample": "3601", + "start_value": -6.0, + "end_value": -9.0, + "unit": "db" + }, + { + "shape": "linear", + "start_sample": "29000", + "end_sample": "33000", + "start_value": -9.0, + "end_value": 3.0, + "unit": "db" + }, + { + "shape": "step", + "start_sample": "34000", + "end_sample": "35000", + "start_value": 0.0, + "end_value": -3.0, + "unit": "db" + } + ], + "offset": { + "left_db": -1.5, + "right_db": 0.0 + } + }, + { + "id": "t1-mute", + "target": { + "owner": "strip", + "owner_id": "t1", + "section": "builtins", + "control": 6, + "lane": "left" + }, + "segments": [ + { + "shape": "step", + "start_sample": "0", + "end_sample": "1000", + "start_value": 0.0, + "end_value": 1.0, + "unit": "linear" + }, + { + "shape": "step", + "start_sample": "1600", + "end_sample": "4200", + "start_value": 0.0, + "end_value": 1.0, + "unit": "linear" + }, + { + "shape": "step", + "start_sample": "4800", + "end_sample": "29800", + "start_value": 0.0, + "end_value": 1.0, + "unit": "linear" + }, + { + "shape": "step", + "start_sample": "30600", + "end_sample": "33500", + "start_value": 0.0, + "end_value": 1.0, + "unit": "linear" + }, + { + "shape": "step", + "start_sample": "34000", + "end_sample": "36000", + "start_value": 0.0, + "end_value": 0.0, + "unit": "linear" + } + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } + }, + { + "id": "t2-fader", + "target": { + "owner": "strip", + "owner_id": "t2", + "section": "builtins", + "control": 5, + "lane": "right" + }, + "segments": [ + { + "shape": "linear", + "start_sample": "1024", + "end_sample": "4096", + "start_value": -3.0, + "end_value": 0.0, + "unit": "db" + }, + { + "shape": "linear", + "start_sample": "29900", + "end_sample": "32000", + "start_value": 0.0, + "end_value": -18.0, + "unit": "db" + } + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } + }, + { + "id": "vca-offset", + "target": { + "owner": "vca", + "owner_id": "vca", + "section": "own", + "control": 1, + "lane": "right" + }, + "segments": [ + { + "shape": "linear", + "start_sample": "512", + "end_sample": "3584", + "start_value": 0.0, + "end_value": -6.0, + "unit": "db" + }, + { + "shape": "step", + "start_sample": "4096", + "end_sample": "4352", + "start_value": 2.0, + "end_value": 0.0, + "unit": "db" + }, + { + "shape": "linear", + "start_sample": "29500", + "end_sample": "31500", + "start_value": -4.0, + "end_value": 2.0, + "unit": "db" + }, + { + "shape": "step", + "start_sample": "33000", + "end_sample": "35500", + "start_value": -3.0, + "end_value": -3.0, + "unit": "db" + } + ], + "offset": { + "left_db": 0.0, + "right_db": -0.5 + } + }, + { + "id": "vca-mute", + "target": { + "owner": "vca", + "owner_id": "vca", + "section": "own", + "control": 2, + "lane": "both" + }, + "segments": [ + { + "shape": "step", + "start_sample": "0", + "end_sample": "2000", + "start_value": 0.0, + "end_value": 1.0, + "unit": "linear" + }, + { + "shape": "step", + "start_sample": "2400", + "end_sample": "30200", + "start_value": 0.0, + "end_value": 1.0, + "unit": "linear" + }, + { + "shape": "step", + "start_sample": "30400", + "end_sample": "35000", + "start_value": 0.0, + "end_value": 1.0, + "unit": "linear" + }, + { + "shape": "step", + "start_sample": "35500", + "end_sample": "36000", + "start_value": 0.0, + "end_value": 0.0, + "unit": "linear" + } + ], + "offset": { + "left_db": 0.0, + "right_db": 0.0 + } + } + ] +} diff --git a/hosts/host-web/tests/browser-v1/direct-oracle.mjs b/hosts/host-web/tests/browser-v1/direct-oracle.mjs index 05ab941f4..d59e457fc 100644 --- a/hosts/host-web/tests/browser-v1/direct-oracle.mjs +++ b/hosts/host-web/tests/browser-v1/direct-oracle.mjs @@ -461,6 +461,71 @@ async function runObservationTimeline(modulePath, sessionDocument, sourceId, tap }; } +/// Issue #1669 D2/D3: the stored-automation timeline, through the shipped artifact. The same +/// session, source feed and seek the native twin +/// (`tests::native_automation_timeline_digest_pins_the_wasm_parity`) runs. +/// +/// `automation-session.json` automates the strip fader, the strip mute, the VCA offset and the VCA +/// mute, with a following and a non-following send. 96 blocks; before block 40 one plain session +/// seek to timeline 30,000 under generation 2. Before every render each source gets exactly one +/// 128-frame chunk (the ring holds one quantum): generation 1 from `k*128` before the seek, +/// generation 2 from `30000 + (k-40)*128` after it. The PCM at source frame `f` is +/// `((f*m) mod 65536 - 32768) / 65536`, exact in `f32`. +const AUTOMATION_BLOCKS = 96; +const AUTOMATION_SEEK_BLOCK = 40; +const AUTOMATION_SEEK_TIMELINE = 30000; +const AUTOMATION_SOURCES = [["src-t1", [40503, 30011]], ["src-t2", [52153, 18749]]]; +async function runAutomationTimeline(modulePath, sessionDocument) { + const { instance } = await WebAssembly.instantiate(await readFile(modulePath), {}); + const exports = instance.exports; + const handle = boot(exports, sessionDocument); + + // The seek is control work and may grow the memory, so the buffers are re-read at every use. + const feed = (sourceId, multipliers, generation, start) => { + const id = new TextEncoder().encode(sourceId); + const idPointer = exports.miso_engine_web_v1_buffer_ptr(handle, BUFFER_SOURCE_ID); + new Uint8Array(exports.memory.buffer, idPointer, id.byteLength).set(id); + const pcmPointer = exports.miso_engine_web_v1_buffer_ptr(handle, BUFFER_SOURCE_PCM); + const pcm = new Float32Array(exports.memory.buffer, pcmPointer, QUANTUM * 2); + multipliers.forEach((multiplier, lane) => { + for (let index = 0; index < QUANTUM; index += 1) { + const residue = (Math.imul(start + index, multiplier) >>> 0) & 0xffff; + pcm[lane * QUANTUM + index] = (residue - 32768) / 65536; + } + }); + return exports.miso_engine_web_v1_source_submit( + handle, id.byteLength, BigInt(generation), BigInt(start), 2, QUANTUM, 0, + ); + }; + const blocks = []; + for (let block = 0; block < AUTOMATION_BLOCKS; block += 1) { + if (block === AUTOMATION_SEEK_BLOCK) { + assert.equal( + exports.miso_engine_web_v1_session_seek(handle, 2n, BigInt(AUTOMATION_SEEK_TIMELINE)), + 0, + "the session seek", + ); + } + const [generation, start] = block < AUTOMATION_SEEK_BLOCK + ? [1, block * QUANTUM] + : [2, AUTOMATION_SEEK_TIMELINE + (block - AUTOMATION_SEEK_BLOCK) * QUANTUM]; + for (const [sourceId, multipliers] of AUTOMATION_SOURCES) { + assert.equal(feed(sourceId, multipliers, generation, start), 0, `block ${block} submit`); + } + blocks.push(render(exports, handle)); + } + const beforeDisposeStatus = status(exports, handle); + const pcm = [blocks.flatMap((block) => block[0]), blocks.flatMap((block) => block[1])]; + assert.equal(exports.miso_engine_web_v1_dispose(handle), 0); + return { + blocks: AUTOMATION_BLOCKS, + seekBlock: AUTOMATION_SEEK_BLOCK, + seekTimelineSample: AUTOMATION_SEEK_TIMELINE, + beforeDisposeStatus, + pcmF32leSha256: pcmSha256(pcm), + }; +} + async function main() { if (process.argv.length !== 4) { throw new Error("usage: direct-oracle.mjs ARTIFACT_DIRECTORY EXPECTED_JSON"); @@ -574,6 +639,23 @@ async function main() { nativeObservation, "native and simd128 must render this observation timeline to identical bits", ); + // Issue #1669 D3: the stored-automation timeline, pinned the same way and asserted before any + // print. + actual.automationTimeline = await runAutomationTimeline( + artifact, await readFile(path.join(fixtureDirectory, "automation-session.json")), + ); + const nativeAutomation = expected.directOracle?.nativeAutomationPcmF32leSha256; + assert.equal( + typeof nativeAutomation, + "string", + "expected.json must carry the native automation-timeline digest pin", + ); + actual.nativeAutomationPcmF32leSha256 = nativeAutomation; + assert.equal( + actual.automationTimeline.pcmF32leSha256, + nativeAutomation, + "native and simd128 must render this automation timeline to identical bits", + ); if (process.env.MISO_ENGINE_WEB_ORACLE_PRINT === "1") { process.stdout.write(`${JSON.stringify(actual, null, 2)}\n`); return; diff --git a/hosts/host-web/tests/browser-v1/expected.json b/hosts/host-web/tests/browser-v1/expected.json index 409b59f71..5e2a63333 100644 --- a/hosts/host-web/tests/browser-v1/expected.json +++ b/hosts/host-web/tests/browser-v1/expected.json @@ -130,6 +130,23 @@ "unboundReason": 7, "pcmF32leSha256": "4d1412f875886235dd9295ebfb109dfd54a63004eeea6615b7c8711dbc2203e5" }, - "nativeObservationPcmF32leSha256": "4d1412f875886235dd9295ebfb109dfd54a63004eeea6615b7c8711dbc2203e5" + "nativeObservationPcmF32leSha256": "4d1412f875886235dd9295ebfb109dfd54a63004eeea6615b7c8711dbc2203e5", + "automationTimeline": { + "blocks": 96, + "seekBlock": 40, + "seekTimelineSample": 30000, + "beforeDisposeStatus": { + "state": 2, + "lastResult": 0, + "backend": 1, + "sampleRateHz": 48000, + "quantumFrames": 128, + "nextAbsoluteSample": "12288", + "renderedQuanta": "96", + "memoryBytes": 2949120 + }, + "pcmF32leSha256": "a81d883b3f83eaf629bd78db149d1a39825c821c6cb2e9a53506341a97ed1363" + }, + "nativeAutomationPcmF32leSha256": "a81d883b3f83eaf629bd78db149d1a39825c821c6cb2e9a53506341a97ed1363" } } diff --git a/hosts/host-web/tests/control_parity.rs b/hosts/host-web/tests/control_parity.rs index 472835bd2..59bb54980 100644 --- a/hosts/host-web/tests/control_parity.rs +++ b/hosts/host-web/tests/control_parity.rs @@ -7501,3 +7501,83 @@ fn the_reach_pair_cap_bounds_vca_automation_alike_on_both_hosts() { b"host.resource.vca_reach_pairs\t$\n".to_vec() ); } + +// --------------------------------------------------------------------------------------------- +// The #1669 automation fixture across one session seek (D4) +// --------------------------------------------------------------------------------------------- + +/// `tests/browser-v1/automation-session.json` (the strip fader, strip mute, VCA offset and VCA +/// mute, with a following and a non-following send) runs the #1669 D2 transcript on the browser +/// host and the C ABI: 96 blocks, one plain session seek to timeline 30,000 under generation 2 +/// before block 40, one chunk per source before every render. Every block's two planes have the +/// same bits. +/// +/// Test value: red if the two hosts prepare the whole automation fixture differently or land its +/// session seek differently; the only other cross-host seek case automates the mute row alone, and +/// the VCA cases do not seek. +#[cfg(feature = "test-support")] +#[test] +fn the_automation_fixture_renders_alike_on_both_hosts_across_one_session_seek() { + const BLOCKS: u64 = 96; + const SEEK_BLOCK: u64 = 40; + const SEEK_TIMELINE: u64 = 30_000; + // The D2 PCM, `((f*m) mod 65536 - 32768) / 65536`, exact in `f32`, per source and lane. + const SOURCES: [(&str, [u32; 2]); 2] = + [("src-t1", [40_503, 30_011]), ("src-t2", [52_153, 18_749])]; + let pcm = |frame: u64, multiplier: u32| -> f32 { + let frame = u32::try_from(frame).expect("frame fits u32"); + let residue = (frame.wrapping_mul(multiplier) & 0xffff) as i32 - 32_768; + residue as f32 / 65_536.0 + }; + + let document = include_str!("browser-v1/automation-session.json"); + let model = session::parse_session_json(document).expect("the automation fixture"); + let mut browser = Rig::boot(document.as_bytes(), model.sample_rate_hz); + let mut c_abi = CAbi::new(&model); + let plane = QUANTUM as usize; + for block in 0..BLOCKS { + if block == SEEK_BLOCK { + assert_eq!( + browser.host.session_seek(None, 2, SEEK_TIMELINE, None), + host_web::RESULT_OK, + "browser session seek" + ); + c_abi.session_seek(2, SEEK_TIMELINE, None); + } + let (generation, start) = if block < SEEK_BLOCK { + (1, block * u64::from(QUANTUM)) + } else { + (2, SEEK_TIMELINE + (block - SEEK_BLOCK) * u64::from(QUANTUM)) + }; + for (source, multipliers) in SOURCES { + let [left, right] = multipliers.map(|multiplier| { + (start..start + u64::from(QUANTUM)) + .map(|frame| pcm(frame, multiplier)) + .collect::>() + }); + assert_eq!( + browser.host.submit_source( + source.as_bytes(), + generation, + start, + model.sample_rate_hz, + &[&left, &right], + QUANTUM, + false, + ), + host_web::RESULT_OK, + "block {block}: browser source {source}" + ); + c_abi.submit_planes(source, generation, start, [&left, &right]); + } + assert_eq!( + browser.host.render_next(), + host_web::RESULT_OK, + "block {block}: browser render" + ); + let b = bits(browser.host.output_pcm().expect("browser output")); + let c = c_abi.render(block); + assert_eq!(b[..plane], c[..plane], "block {block}: left plane bits"); + assert_eq!(b[plane..], c[plane..], "block {block}: right plane bits"); + } +} diff --git a/scripts/check-browser-expected-resources.py b/scripts/check-browser-expected-resources.py index 9c9717c6c..18093b53a 100755 --- a/scripts/check-browser-expected-resources.py +++ b/scripts/check-browser-expected-resources.py @@ -31,8 +31,9 @@ `hosts/host-web/tests/retained_ceilings.rs`. Everything else in the printed document -- the status snapshots, the result transcripts, the -SET_CONTROL and observation timelines and the three PCM digests, which `direct-oracle.mjs` has already -asserted equal to the native digests before printing -- must equal `expected.json` exactly. +SET_CONTROL, observation and automation timelines and the four PCM digests, which +`direct-oracle.mjs` has already asserted equal to the native digests before printing -- must equal +`expected.json` exactly. `--self-test` proves the comparator discriminates before it is trusted: it fabricates a green document at the ceilings, and then requires every red mutation below -- one byte over a ceiling, @@ -116,12 +117,13 @@ CEILINGS_KEY = "resourceCeilings" -# The three digests `expected.json` pins. Named here so a report that stops carrying one is a +# The four digests `expected.json` pins. Named here so a report that stops carrying one is a # failure rather than a silently skipped comparison. DIGEST_PATHS = ( ("simd128", "pcmF32leSha256"), ("setControlTimeline", "pcmF32leSha256"), ("observationTimeline", "pcmF32leSha256"), + ("automationTimeline", "pcmF32leSha256"), ) @@ -584,6 +586,7 @@ def apply(pair: tuple) -> tuple: ("the identity-session digest moves", move_digest("simd128"), None), ("the SET_CONTROL-timeline digest moves", move_digest("setControlTimeline"), None), ("the observation-timeline digest moves", move_digest("observationTimeline"), None), + ("the automation-timeline digest moves", move_digest("automationTimeline"), None), ("the render transcript moves", move_status("nextAbsoluteSample"), None), ("the oracle prints a different schema", with_schema("miso.web.browser.v1"), None), # The boot-options vocabulary, and the exact #143 regression this fixture shipped with.