diff --git a/.github/ISSUE_SPECS/1282-carry-per-node-effect-instances-across-a-plan-swap.md b/.github/ISSUE_SPECS/1282-carry-per-node-effect-instances-across-a-plan-swap.md index b217b280a..3e19997f4 100644 --- a/.github/ISSUE_SPECS/1282-carry-per-node-effect-instances-across-a-plan-swap.md +++ b/.github/ISSUE_SPECS/1282-carry-per-node-effect-instances-across-a-plan-swap.md @@ -137,3 +137,66 @@ Checked against `12d4a4f9d`. - **Edit order with #1621** (`runtime.rs`, the per-node window): whichever is scheduled first lands first; the other rebases. They change different lines. - **Dependencies.** #1281, #1280c (for D1's retarget). + +## Decision record: #1282 (batch 11) + +**Shipped.** A live-controlled per-node effect instance that survives a swap with its prepared +layout unchanged moves by pointer: D1-D3 as frozen. + +- `graph`: `LiveControlEffect::can_move_from` / `move_from` (`runtime.rs`, in a realtime-policy + region). The check compares the prepared layout, the control kind and cell shape (the successor + holds its carried slots, #1633) and the shunt shape. The move takes the control state through + `EffectControlLane::carry_from` (unread cells, bypass crossfade, symmetry terms, #1280a), then + exchanges the two processor boxes and the two `BypassShunt`s with `core::mem::swap`. The `spans` + window and the `observation` lane stay (#1621 sizes the window; the tap state is #1327b's), and + the pending `Observe` records of the predecessor are applied at the carry by the second marked + loop, as for every other move. `Runtime::can_carry_effect_lanes` and `carry_effect_lanes` gain the + node-to-node arm. `install_effect_carry` no longer refuses a node-to-node move and + `GraphCarryInstallError::EffectMoveBetweenNodes` is deleted. +- `host-core`: `resolve_effect_carry` drops the node-to-node exclusion, and a node-to-node move + adds nothing to the payload scratch (`carry.scratch_bytes` counts only moves that go through it), + so the carry's retained charge for a session of delays is the move table and the carried slots. + D3 needs no code: a per-node effect without live controls has no inventory row, so its owner + restarts its strip. +- `effect-contract` (outside the authorized list, one mechanical helper): `BypassShunt::same_shape`. +- `graph` test-support: `test_only_carry_payload_bytes` / `test_only_reset_carry_payload_bytes`, a + thread-local count of the layout payload each lane move passes through the scratch. +- Test harness: `effect_run_blocks` (a run of more than `BLOCKS` blocks), `Feed::with_blocks`, + `EffectRun::payload_bytes`; `LivePlan::submit` submits at the plan's own sample rate. + +**Departures.** None from the frozen decisions. D1's "prepared bypass" needs no new code: the +inventory layout already carries `bypass`, and `effect_instance_records` refuses a bypass change of +an effect that does not lower it. + +**Gates and tests** (`crates/host-core/tests/successor_swap.rs`): + +| Gate | Test | +|---|---| +| 1 | `a_delay_tail_rings_on_through_the_swap_at_every_width` (64 blocks; restart run loses the echo) | +| 2 | `unread_values_and_a_live_bypass_cross_the_swap_at_every_width` | +| 3 | `a_prepared_bypass_change_restarts_its_strip_at_every_width` (`restarted_strips() == ["eq0"]`) | +| 4 | `a_delay_moves_by_pointer_at_96_khz_at_every_width` (payload bytes 0, swap block clean); `the_payload_counter_counts_a_lane_move` (the counter reads nonzero for a bank move) | +| D3 | `an_owner_without_live_controls_on_one_side_restarts_at_every_width` | +| D1 live | `a_live_difference_on_a_moved_instance_is_retargeted_at_every_width` (delay time, compressor bypass) | + +Red under mutation (each run once, reverted): no processor exchange (gates 1, 2, 4 red), no +`carry_from` (gate 2 red). + +**Consequence outside the slice.** `capi`'s `a_prepared_bypass_change_rebuilds_and_renders_the_committed_model` +(#1266 gate 3) used a control that rebuilt by content edits, which restarted the per-node multiband +as a side effect. A content edit now moves the multiband, so the control's rebuilds also replace +the multiband by a fresh instance of the same declaration under a new ID (a prepared change of +that owner), which restarts it and its strip as the bypass rebuild does. The oracle is otherwise +unchanged. + +**Measured.** Browser boot high-water growth 20,447,232 bytes (before: 20,447,232; the pin is +20,971,520). No resource estimate or pin moved: `cargo test -p host-core --all-features` (428), +`effect-contract`, `rack`, `graph`, `graph-compiler`, `capi`, `engine`, `host-web --features +host-web/test-support` green; clippy `-D warnings` on `host-core` (no features, default, +all features) and `host-core graph effect-contract rack capi --all-targets --all-features`; +`realtime-policy`, `check-bench-policy.sh`, `check-builtins-fixtures.sh`, `check-cross-targets.sh`, +`check-web-audioworklet.sh`, `trace-graph-audit.sh` (1,000,000 blocks) and rustdoc `-D warnings` pass. + +### `audit capi` digest (batch 11) + +The C ABI render audit commits one structural transaction after its first call (it adds a muted track). Its session also holds a per-node live effect instance (the audit editor reaches a non-EQ effect lane). Before this slice that instance restarted at the swap; now it moves by pointer and keeps its state. So `audit capi`'s `pcm_digest` moves from `9bc5ec3d8a4c223c` to `bf7dcedd1d92621f`, measured by building the audit at `405221f8d` (#1633, the parent: `9bc5ec3d8a4c223c`) and at `067d145ec` (this slice: `bf7dcedd1d92621f`); the later batch 11 slices leave it at `bf7dcedd1d92621f`. Allocations, frees and render errors stay 0. diff --git a/.github/ISSUE_SPECS/1283-carry-compensation-lines-across-a-plan-swap.md b/.github/ISSUE_SPECS/1283-carry-compensation-lines-across-a-plan-swap.md index 9d5005513..7bb656cbd 100644 --- a/.github/ISSUE_SPECS/1283-carry-compensation-lines-across-a-plan-swap.md +++ b/.github/ISSUE_SPECS/1283-carry-compensation-lines-across-a-plan-swap.md @@ -137,3 +137,75 @@ Checked against `12d4a4f9d`. - **Dependency on #1282.** The lines hold no effect state; the dependency orders the shared files (`graph/src/{lib.rs,runtime.rs}`, `host-core/src/prepare.rs`, `successor_swap.rs`) only. This slice can land before #1327 or after it. + +## Decision record: #1283 (batch 11) + +**Shipped.** D1-D5 as frozen: a compensation line carries when its edge ID exists in both plans +with the same endpoints; equal lengths swap the rings and the cursor, other lengths take a +head-aligned copy. + +- `graph/program.rs`: `DelaySpec` gains `edge` (the delayed edge's position in `GraphSpec::edges`), + so the lowered program says which edge each line delays. +- `graph/runtime.rs` (policy regions): `CompensationDelay::adopt` (D2 swap, else D4 copy through + `carry_head_aligned`, one routine for both channels and both directions), and + `Runtime::can_carry_delay_lines` / `carry_delay_lines`, bounded by the move table. +- `graph/lib.rs`: `GraphDelayLine` (edge, source, destination, line, length), `GraphDelayMove`, + `take_compensation_lines`, `install_delay_carry`, `delay_carry_retained_bytes`, three install + errors, `GraphExecutor::carry_delays`, and the check and the carry in `adopt_predecessor` (the + check joins the "every section checks before any section moves" list). `carry_program_retained_bytes` + counts the move table. +- `host-core/prepare.rs`: `PlanStateInventory::delay_lines` (counted in `retained_bytes`), + `resolve_delay_carry` (the join), the table charged against the graph cap, and a program that + names the predecessor and moves no source when a line is the only thing that carries. + +**Decisions taken where the text left room.** + +- A route's two edges (`RouteSource`, `RouteDestination`) report the route's own endpoints (its + source strip and its destination node), not the edge's. The destination edge's own endpoints do + not change when only the route's source is re-pointed, so the text's "same source and destination + nodes" would have carried the old line there. +- The identity of the lines (`GraphDelayLine`, with ID strings) is not retained in the plan: the + executor holds it until `take_compensation_lines` is called, once, right after bind, and the + inventory (control side, counted) keeps it. The plan retains only the move table. +- The lookup is by successor edge to the predecessor edge of the same ID; #1363 D4 adds the alias. + +**Gates and tests.** + +| Gate | Test | +|---|---| +| 1 | `compensated_output_lines_keep_their_audio_at_every_rate_and_width` (44.1, 48, 88.2, 96 kHz; each asserts two compensated lines in the predecessor) | +| 2 | `compensated_submix_lines_keep_their_audio_at_every_rate_and_width` | +| 3 | `runtime::tests::a_successor_line_continues_the_predecessors_pending_samples_head_aligned` (equal, longer, shorter, one-word lines; a cursor at every position and wrapped) | +| 4 | `a_repointed_route_does_not_carry_its_line_at_every_rate_and_width` (`restarted_strips()` empty; muted re-pointed strip equals B, audible one differs); `prepare::tests::delay_lines_join_by_edge_and_endpoints`; `tests::a_route_edge_names_the_endpoints_of_its_route` | +| 5 | `assert_clean_swap` in every case above: allocator and render audit zero over the swap block | + +Red under mutation (each run once, reverted): no `install_delay_carry` (gates 1, 2, 4 red), join +without endpoints (gate 4 red), copy from index 0 instead of the cursor (gate 3 red). + +**Consequence outside the slice.** Two `capi` live tests compared a rebuild with another history +and relied on the lines restarting at rest: `a_live_mute_survives_a_later_rebuild` (live mute one +block before the swap against a mute in the swap transaction) and +`a_prepared_bypass_change_rebuilds_and_renders_the_committed_model` (carried source ring against a +fresh one in the control). With carried lines the audio a line holds at the swap plays out after +it, and in both pairs the held audio differs by one block. They now compare from the block after +the lines flush (the session's latency, in blocks); the oracle is otherwise unchanged. Found, not +from this slice: `host-web` `meter_delivery_restarts_on_the_successor_and_never_publishes_a_misshaped_frame` +fails at the base (lane W's file). + +**Measured.** Browser boot high-water growth 20,447,232 bytes (before: 20,447,232; the pin is +20,971,520). No pin moved. `host-core` (442 with all features), `graph` (105), `graph-compiler` +(178), `rack` (67), `capi` (153), `control-plane` (5) green; clippy `-D warnings` on `host-core` (no +features, default, all features) and `host-core graph capi` with all targets; `realtime-policy`, +`check-bench-policy.sh`, `check-workspace-policy.sh`, `check-builtins-fixtures.sh`, +`check-graph-determinism.sh`, `check-cross-targets.sh`, `check-web-audioworklet.sh`, +`trace-graph-audit.sh` (1,000,000 blocks) and rustdoc `-D warnings` pass. + +## Fold (batch 11 review) + +- The `#1283 D1` join doc sat on the test helper `strip_delay`; it now sits on + `delay_lines_join_by_edge_and_endpoints`, the test it describes. +- `a_live_mute_survives_a_later_rebuild` and `a_prepared_bypass_change_rebuilds_and_renders_the_committed_model` + compared two of six blocks after the lines flushed. Both rigs, the control and the rebuild run now render + `flushed + 6` blocks after the seek and compare `[flushed..]`, so six blocks are compared. The gate-3 comment + now says what the pre-flush window holds: the source ring's audio and audio rendered under the predecessor's + bypass in the carried lines, which no control built from the committed model reproduces. diff --git a/.github/ISSUE_SPECS/1284-carry-strip-delay-lines-and-live-send-ramps-across-a-plan-swap.md b/.github/ISSUE_SPECS/1284-carry-strip-delay-lines-and-live-send-ramps-across-a-plan-swap.md index df94ebc52..7689bdaca 100644 --- a/.github/ISSUE_SPECS/1284-carry-strip-delay-lines-and-live-send-ramps-across-a-plan-swap.md +++ b/.github/ISSUE_SPECS/1284-carry-strip-delay-lines-and-live-send-ramps-across-a-plan-swap.md @@ -152,3 +152,67 @@ Checked against `12d4a4f9d`. (#1277 D8), then copy the ramp, its position and its mute. No engine edit. - **Authorized paths** gain `crates/host-core/src/live_delta.rs` (D3's extraction only). - **Dependencies.** #1283 (shared files). #1225 is removed. + +## Decision record: #1284 (batch 8) + +**Shipped.** D1, D2 and the amended D3 (carry, then retarget; #1225 D8), as frozen. + +- `graph`: `TrackDelayLine::lanes_match` / `adopt` (equal rings and cursors swap, lane by lane), + `LiveRoute::adopt` (ramp, position, mute), `Runtime::{can_carry,carry}_{strip_delays,live_routes}`, + all in policy regions and bounded by the move table. The route carry first applies the + predecessor's pending cell value under `LiveSnapshot::ALL`, then copies. New API: + `GraphStripDelayLine` / `GraphStripDelayMove` (lane mask `STRIP_DELAY_LEFT|RIGHT`), + `GraphLiveRoute` / `GraphRouteMove` (by runtime unit), `take_strip_delay_lines`, `take_live_routes` + (read once after bind, as `take_compensation_lines`), `install_strip_delay_carry`, + `install_route_carry`, the two `*_carry_retained_bytes`, six install errors; both moves join the + "every section checks before any section moves" list. `build_sequential` now also returns the bind + identities. +- `host-core`: `PlanStateInventory::{strip_delays, live_routes}`; `join_strip_delays` and + `resolve_strip_delay_carry` (the key is strip and lane; a lane that carries has an equal + `delay_samples` and a line in both plans; a strip with a changed lane, or whose line one plan lacks, + goes into `restarted`); `join_routes` and `resolve_route_carry` (a route into a submix with the same + ID, source (strip and tap) and destination, and a live route in both plans). D3: the per-route + function is `route_record_between` (+ `RouteEditRamps`, moved from `live_delta.rs`); the classifier + and the join both call it, on effective values (`EffectiveDiff::route_gates`, `gate_length`, + `source_strips`, un-gated from `control-provider`). The retarget is written with + `stamp_held_gate(base.revision)` in the strips' block. `join_strips` takes the `EffectiveDiff`, built + once per preparation. The late carry program (names the predecessor, moves no source) now also + covers a plan whose only carry is a strip delay or a send. + +**Departures.** + +- `LiveRoute::drain` is renamed `apply_pending`. The call rule of the realtime policy refused + `from.drain(..)` in the move loop, because other functions named `drain` pop a queue. `apply_pending` + holds no pop (it reads a latest-target cell); its one other caller is the block drain in `execute_op`. +- A route carries only if its source and destination are equal. The text says "source, tap and + destination"; the tap is part of `RouteSource`. +- Test harness: `EffectValue::SendGain` and `CellRecord::Route` write a send record. The two + `a_followed_mute_change_*` oracles (the "written to A" run) now also write the send's gate record, as + a live commit does (#1558 D1): with sends carried, a strip mute written to A without the following + send's record is not the state the committed model describes. + +**Gates and tests** (`crates/host-core/tests/successor_swap.rs` unless noted): + +| Gate | Test | +|---|---| +| 1 | `strip_delays_and_a_send_ramp_cross_the_swap_at_{eight,four}_lanes` (track lanes 37/53, a lane with no delay, submix 41/29, a ramp from block 4 and a record pending at the swap) | +| 2 | `a_changed_lane_delay_restarts_only_its_lane_at_*` (restart set `["eq0"]`, left lane carried, right plane silent), `a_repointed_send_starts_at_its_prepared_value_at_*` (no restart, equals B without records), `only_the_delay_changed_strip_is_restarted_at_*` | +| 3 | `assert_clean_swap` in every case: zero allocations and frees on the swap block | +| 5 | `a_carried_send_is_retargeted_to_the_successors_gain_at_*` (run 1 retarget equals run 2 live write; not vacuous against a stale level) | +| unit | `graph::runtime::tests::a_strip_delay_line_hands_over_the_lanes_of_its_mask`, `prepare::tests::strip_delay_lanes_join_by_strip_and_lane`, `prepare::tests::live_routes_join_by_route_id` | + +Red under mutation (each run once, reverted): no strip delay move (gates 1, 2 red), no route move +(gates 1, 2b red), no predecessor drain (gates 1, 5 red), no retarget write (gate 5 red), route join +without the destination (gate 2b red), lanes keyed together (gate 2a red). + +**Measured.** Browser boot high-water growth 20,447,232 bytes (before: 20,447,232). No pin moved. +`realtime-policy`, `check-bench-policy.sh`, `check-workspace-policy.sh`, `check-builtins-fixtures.sh`, +`check-graph-determinism.sh`, `check-cross-targets.sh`, `check-web-audioworklet.sh`, rustdoc +`-D warnings` and clippy `-D warnings` (host-core no features, default, all features; graph) pass. +`trace-graph-audit.sh` was not run. + +## Fold (batch 11 review) + +- `LiveRoute::apply_pending` doc: "The drain runs in every block" is now "The read runs in every block" + (#1347 D3 reads a cell, it drains no queue). +- The two capi oracles that cover the carried lines now compare six blocks after the flush (see #1283). diff --git a/.github/ISSUE_SPECS/1306-size-each-effects-automation-span-window-from-the-producers-its-plan-has.md b/.github/ISSUE_SPECS/1306-size-each-effects-automation-span-window-from-the-producers-its-plan-has.md index 98e2eff9a..449135396 100644 --- a/.github/ISSUE_SPECS/1306-size-each-effects-automation-span-window-from-the-producers-its-plan-has.md +++ b/.github/ISSUE_SPECS/1306-size-each-effects-automation-span-window-from-the-producers-its-plan-has.md @@ -239,7 +239,7 @@ Root's ruling on finding F23 of the stored-automation check (`docs/handoffs/stor `EffectCompileCaps` keeps its shape and `Copy`; its field becomes a ceiling, a capacity above it or a ceiling of 0 is refused `effect.resource.limit` (`:358`), and host-core passes `u32::MAX`. - capi: `prepare_caps` (`crates/control-plane/src/compile.rs:601`) drops the field. Its other read, - the protocol's `per_block_automation_density` (`:202`), is deleted by #1629 in batch P1; draft 22 + the protocol's `per_block_automation_density` (`:202`), is deleted by #1629 in batch P1; #1668 of the #1058 program then reserves the C field. - Browser: the derivation from the stored segment count (`hosts/host-web/src/lib.rs:4447-4455`) and its two uses (`:4463`, `:4494`) go. @@ -258,5 +258,5 @@ Root's ruling on finding F23 of the stored-automation check (`docs/handoffs/stor `crates/capi/tests/resource_lifecycle.rs`, `docs/C_ABI_V1_QUALIFICATION.md` (the formula bullet, if #1611 and #1621 have not rewritten it), this spec. - **Dependencies.** #1611 (landed) and #1621 (the per-node window; this issue lands after it). #1629 - (batch P1) removes S's protocol read; this issue and draft 22 form batch P2, after P1. The #1058 + (batch P1) removes S's protocol read; this issue and #1668 form batch P2, after P1. The #1058 rendering slices no longer depend on this issue. diff --git a/.github/ISSUE_SPECS/1327-carry-meter-and-effect-observation-state-across-a-plan-swap.md b/.github/ISSUE_SPECS/1327-carry-meter-and-effect-observation-state-across-a-plan-swap.md index 1a7a57194..21270c23d 100644 --- a/.github/ISSUE_SPECS/1327-carry-meter-and-effect-observation-state-across-a-plan-swap.md +++ b/.github/ISSUE_SPECS/1327-carry-meter-and-effect-observation-state-across-a-plan-swap.md @@ -255,3 +255,171 @@ issue: an acknowledged subscribe can be dropped at a swap (an ack that precedes gates include that case: a subscribe admitted and acknowledged before a swap is armed on the successor's tap after the swap, and the successor publishes the window the predecessor would have published, with zero allocations and frees on the swap block. + +## Decision record: #1327a meters (batch 8, worktree b8k) + +**Shipped.** + +- **Engine, one `plan.rs` edit (root ruling).** + - `ObserverKind { Meter, Observation, Spectrum }` and `ObserverCarry { kind, successor, predecessor }` + (`usize` entry indices). + - `PreparedPlanExecutor::observer_carries()` with an empty default, and + `PreparedRenderPlan::observer_carries()`. + - `HostAttachment::adopt_predecessor(&mut self, predecessor: &mut dyn HostAttachment, carries: + &[ObserverCarry])` with an empty default. `PreparedRenderPlan::carry_from` calls it after the + executor hand-over when both plans have an attachment, with the successor executor's record. + Both the exchange's swap block and `adopt_predecessor_plan` reach it. + - The two new types are re-exported from `crates/engine/src/realtime/mod.rs` (one line). They + cannot be named outside `engine` without it. +- **`scripts/check-web-audioworklet-callgraph.py`.** `enter_block` pins 4 indirect sites, from 2: + the attachment hook and the `observer_carries()` read are new, with #1381 D3's reason. +- **builtins.** `MeterWindowPosition`, `MeterAccumulator::{config, window_position, join_window, + rebind_handle}`. +- **builtins-compiler.** `MeterObserver` answers the four new `GraphRuntimeObserver` methods. +- **graph.** + - `GraphRuntimeObserver::{carry_kind, adopt_binding_handle, window_position, join_window}` and + `ObserverWindow`. + - `GraphObserverLocation`, `GraphObserverRow`, `GraphObserverMove`, `GraphObserverJoin`, + `observer_rows`, `install_observer_carry`, `observer_carry_retained_bytes`. + - The executor's observer section is one boxed program (moves, joins, preallocated record), so + `GraphExecutor` grows by one pointer. + - The swap block checks every move first (all or nothing), then exchanges the boxed observers + between the two bindings, then runs the D6 joins. +- **host-core.** + - `MeterInventoryRow` and `MeterConfigKey` in `PlanStateInventory`, `resolve_meter_carry`, + `pair_carried_meters`, and the carry program now exists when a meter may carry. + - Test seam `test_only_prepare_host_runtime_with_selected_meters_on`. + - The meter request and its configuration are built by `requested_meter_owners` and + `requested_meter_config`, shared with the carry decision. + +**Departures from the text.** + +- **Only the reader's consumer is swapped.** `pair_carried_meters` exchanges the `consumer` field, + not the whole `MeterConsumer`. Handles are `index + 1`, so an added strip shifts every handle. + The swapped-in observer takes its binding's handle (`adopt_binding_handle`), so a snapshot + published after the swap names the reader that receives it. A snapshot published before the + swap keeps the predecessor's handle. +- **`pair_carried_observations` is not here.** It is #1327b. +- **Gate 2 is split.** A meter's window length is the preparation's one `meter_period_frames`, so no + caller can change one meter's configuration alone. + - Test A changes the length for every meter: nothing carries, the record is empty and each + meter's first window opens at the adoption block (sequence 0). + - Test B changes one meter's metric set: it alone is missing from the record. By D6 it joins the + carried grid, so its first snapshot after the swap is window 1, not a new sequence. +- **Gate 1's added muted track.** Its meters share the carried boundaries and sequence numbers + at every tap. They equal the reference field by field at the post-fader and post-matrix taps + only. A pre-fader tap sees the source in the reference from sample 0, while D6 holds the added + strip as silent before adoption, so that first window's energy cannot equal the reference's. + This is what D6 says (the strip did not exist before adoption). The gate text over-claimed. + +**Gates -> tests** (`crates/host-core/tests/successor_swap.rs`, module `meter_carry`): + +| Gate | Tests | +|---|---| +| 1 | `meters_carry_through_the_predecessors_readers_at_{eight,four}_lanes` | +| 2 | `a_changed_meter_starts_fresh_and_the_others_carry` | +| 3 | `paired_readers_follow_the_record_at_{eight,four}_lanes` | +| 5 | `the_meter_swap_block_allocates_and_frees_nothing` | + +- Gate 1 reads A's own consumers. +- Gate 3 pairs in a test attachment's hook, so the swap block includes the pairing. +- With the D6 join removed, gates 1 and 3 turn red at the added strip's first snapshot (checked). + +**Measured.** + +- Web AudioWorklet module: 3,924,130 B shipped, 4,491,023 B named twin. +- Boot high-water growth: 20,447,232 B, unchanged. +- Callgraph: the one pin row above. + +**Known interim state.** `host-web` test +`structural_apply::meter_delivery_restarts_on_the_successor_and_never_publishes_a_misshaped_frame` +is red. The browser's successor meter consumers now sit on fresh observers that the swap hands to +the retiring plan until #1381b's attachment hook pairs them, so no window folds after a swap. +#1381b and #1290d replace that test's "restart" expectation with "no restart". + +**Open item for root.** `HostAttachment::adopt_predecessor`'s doc in `plan.rs` links +`[PreparedRenderPlan::carry_from]`, which is `pub(crate)`. `RUSTDOCFLAGS='-D warnings' cargo doc -p +engine` fails on that link (`rustdoc::private_intra_doc_links`). It needs a one-line doc fix to +plain backticks in `crates/engine/src/realtime/plan.rs`. That is a second edit under the +one-attempt rule, so it was not made. + +### Known red test until #1381b (batch 11) + +`hosts/host-web` `tests::structural_apply::meter_delivery_restarts_on_the_successor_and_never_publishes_a_misshaped_frame` fails after this slice: the browser's successor meter readers sit on observers the swap hands to the retiring plan until #1381b's attachment hook pairs them (the browser rebuild path is test-support only until #1562b). #1381b, in this batch, turns the test from "restart" to "carry"; the batch lands only with it green (root, 2026-10-15). Root accepted the `crates/engine/src/realtime/mod.rs` re-export and a doc-only fix of `plan.rs` (a link to the crate-private `PreparedRenderPlan::carry_from` is plain code). + +## Decision record: #1327b observation taps (batch 8, worktree b8k) + +**Shipped.** No edit under `crates/engine/src/realtime`: #1327a's `ObserverKind::Observation`, `ObserverCarry`, +`observer_carries` and `HostAttachment::adopt_predecessor` are enough. + +- **effect-contract.** `ObservationLane::{same_taps, swap_state}`. `swap_state` exchanges the two lanes' tap + tables (arm state, open window, sequence, peaks, publisher) and armed count by pointer, and refuses unless both + lanes declare the same number of taps with the same fold at each index. Each lane keeps its own + `default_window_blocks`: a carried tap keeps the window length it was armed with. +- **rack.** `LiveControlEffectBankStage::observation_mut(lane)`. +- **graph.** + - `GraphTapMove` (the sites of one effect move, plus the two reader entries) and `install_observation_carry`. + A tap move binds to its effect move in the sorted effect table, so it is installed after + `install_effect_carry`, and refused if it names no effect move, if its successor holds no observation lane, or + if a site or entry repeats. + - `ObserverCarryProgram` gains the tap table. Its record holds one slot per meter move and per tap move, and + `observer_carry_retained_bytes` takes a third argument, the tap count. + - The effect section marks each tap move `taken` where its effect move actually carried (lane to lane, lane to + per-node, per-node to lane, per-node to per-node; a refused restore marks nothing). + - `Runtime::carry_taps` runs after the section's own pending-`Observe` loop (#1280 D3), so the predecessor's tap + already holds every acknowledged subscribe as its next block would have applied it. It then swaps the two + lanes' tap state and appends one `ObserverCarry { kind: Observation, successor, predecessor }` per swap after the + meter records. +- **host-core.** + - `ObservationInventoryRow` (reader entry, effect site, tap count) in `PlanStateInventory`, in the order of + `HostLiveControlHandles::effect_observations`. + - `resolve_observation_carry`: a reader set carries when its effect instance's move is in the effect carry and both + plans' sets have as many taps. A restarting effect, or one a rebuild removes, carries nothing. + - `pair_carried_observations(successor, predecessor, carries)`: swaps the `readers` of the two + `EffectObservationHandle`s the record names. Allocation-free. + +**The acknowledged subscribe.** A subscribe admitted before the swap sits in the predecessor's observe queue. The +carry pops it into the predecessor's tap at `predecessor_next_sample`, the sample the successor's first block +starts at, and the tap then moves to the successor with its window and publisher. The successor arms nothing +itself and publishes the window the predecessor would have. Today's red test `pending_observe_swap` asserted the +tap armed on A; it now asserts it armed on the successor, with the successor's reader (paired) equal to the +reference's window field by field, per-node and bank forms in both directions. + +**Departures from the text.** + +- **The tap is keyed by its effect owner, not by its own table.** D1 says the same, but the carried state moves in + the effect section's loop (a tap is a field of the effect instance, not a graph observer), so it has its own + small table beside the meters' instead of rows in `observer_rows`. +- **A restarted or removed owner's subscribe is dropped, as #143 D7 says.** The fresh tap starts unarmed. Arming the + successor's taps at preparation is #1584 D6 (open). The removed strip's tap stays armed on the retiring + predecessor. +- **Gate 4's "restart" case uses a prepared change (link mode).** With live controls attached, a threshold change + on an insert is a live difference and carries (#1280c), so it cannot start the tap fresh. + +**Gates -> tests.** + +| Gate | Tests | +|---|---| +| 4 and the acknowledged subscribe, per-node, bank, both form changes, eight lanes | `successor_swap`: `observation_carry::taps_follow_their_effect_through_every_form_at_eight_lanes`, `pending_observe_records_are_applied_at_the_carry_at_eight_lanes`, `full_observe_queues_are_applied_at_the_carry_at_eight_lanes`, `pending_observe_records_cross_the_form_change_at_eight_lanes` | +| 4, the same at four lanes | `..._at_four_lanes` twins: ignored where the build does not execute four lanes (#1017), as every console-effect test | +| 4, restart starts fresh | `observation_carry::a_restarted_effect_starts_its_tap_fresh_at_{eight,four}_lanes` | +| 5, swap block and the hook's pairing | `observation_carry::the_tap_swap_block_allocates_and_frees_nothing` (and the allocator/audit assertion inside the form tests) | +| lane exchange | `effect-contract/tests/observation_lane`: `a_lane_swap_carries_the_open_window_and_the_publisher`, `a_lane_swap_refuses_another_menu` | + +**Known state.** `host-web` `structural_apply::meter_delivery_restarts_on_the_successor_...` is still red, as the +#1327a record says (#1381b). No other `host-web` test changed: the browser's rebuild path is test-support only and +no host-web test swaps observation taps. #1381b pairs the browser's observation handles with +`pair_carried_observations` from the attachment hook, through its host-core-index-to-slot map. + +## Fold (batch 11 review) + +- `host_core::pair_carried_observations` and `pair_carried_meters` run on the render thread (host-web's + `adopt_predecessor` calls them) and now sit inside `REALTIME_POLICY_BEGIN`/`END` regions; `realtime-policy` + passes. +- Observe records left in the queue of a predecessor owner that did not move (a restarted or removed owner) are + never applied and never counted: `carry_effect_lanes` visits only the effect moves. No counter that a host + reports covers them (`unbound_observations` is read by tests only), and the rule is not to add an unread + counter. This goes to #1584 D6 (batch 12), which owns the reported observation counters. +- `cargo clippy -p host-core --no-default-features --all-targets` already passes at this head: the file the + review named, `tests/vca_live.rs`, no longer exists, and every host-core test that needs `control-provider` + carries a crate-level `cfg`. No change. diff --git a/.github/ISSUE_SPECS/1381-swap-and-retire-browser-plans-through-the-workers-service-loop.md b/.github/ISSUE_SPECS/1381-swap-and-retire-browser-plans-through-the-workers-service-loop.md index e75dc52b0..357e09db3 100644 --- a/.github/ISSUE_SPECS/1381-swap-and-retire-browser-plans-through-the-workers-service-loop.md +++ b/.github/ISSUE_SPECS/1381-swap-and-retire-browser-plans-through-the-workers-service-loop.md @@ -503,3 +503,59 @@ Checked against `12d4a4f9d`. authorized paths allow. - **Anchors in `hosts/host-web/src/lib.rs` moved** (the attachment is reached at `:1846` and `:2652`); lane W re-reads them at implementation. + +## Decision record: #1381b the pairing hook (batch 11, worktree b8w) + +**Shipped.** No engine edit: #1327a's `HostAttachment::adopt_predecessor(predecessor, carries)` is enough. All edits are in +`hosts/host-web/src/lib.rs` and `src/tests.rs`. + +- **`RenderCompanions::adopt_predecessor`** (render thread, swap block, inside a realtime-policy region). It downcasts the + predecessor through `as_any_mut`, calls host-core's `pair_carried_meters` and `pair_carried_observations` on the two + companions' vectors (the browser keeps both in host-core's order, so the record's indices address them directly), and notes each + paired reader in `carried: Box<[CarriedReader]>`, sized at preparation to one entry per reader and charged in + `RenderCompanions::retained_bytes`. Another attachment type, or an empty record, pairs and notes nothing. +- **Table hand-over.** `adopt_shape(epoch, carried)` runs in the same block and follows the pairs with the state the tables hold: + `meter_pending` entries swap per carried meter, and `PlanShape::carry_tap_state` moves the armed bits and arm samples of + each carried observation reader between the two shapes' slots. The slot of reader `r` is `PlanShape::reader_slots[r]`, the + inverse of `EffectInstance::reader`, built in `build_plan_shape` and charged in `arm_table_bytes`. A tap whose arm sample in + the successor shape is not zero was named by a command admitted after the commit and keeps that state; the others take the + predecessor's. +- **Delivery.** A swap whose record carries at least one meter no longer restarts the meter delivery: `meter_generation`, + the loss count, the master windows and the published header are the stream's and stay. A swap that carries no meter (the meter + window length changed, #1327a gate 2 test A) restarts as before. + +**Departures from the text.** + +- The spec's per-plan bookkeeping inside the companions (D3: generation, master windows, `observation_armed` in the attachment) + was not moved: #1381a/#1290a left them in `ReadyOwnership` and `PlanShape` (swapped at the adoption block), and the delivery + fields are continuous without a copy. The hook plus the table hand-over give the same result. +- `pair_carried_observations` is called (the spec said the browser would not): the browser's `effect_observations` is + already in host-core's order. The slot map is for the per-slot tap state only. +- Gate 3's published frame with a changed strip count (`track_count` 3, the shifted strip's word) needs the frame to grow with + the plan: that is #1290d. Here a strip-count change still publishes nothing and counts every window it leaves; the + strip-count-keeping swap and the shifted observation slot are tested. +- The spectrum pairing is not in the hook (batch 10 amendment). +- `meter_windows_a_strip_count_change_leaves_unpublished_are_counted`: the windows waiting at the swap are now counted by the poll that finds them + unpublishable, not at the swap; the count is not zeroed. + +**Gates -> tests** (`hosts/host-web/src/tests.rs`, `structural_apply`): + +| Gate | Test | +|---|---| +| 2 (meters carry, no epoch change, no loss) and the misshaped frame | `meter_delivery_carries_through_a_swap_and_never_publishes_a_misshaped_frame` (was `meter_delivery_restarts_...`) | +| 2/3 (acknowledged subscription, shifted slot) | `an_armed_tap_and_its_windows_follow_the_effect_through_a_swap` | +| loss counted | `meter_windows_a_strip_count_change_leaves_unpublished_are_counted` | +| allocation-free swap block | `tests/structural_apply_realtime.rs` (render-locked count 0 across the adoption block, hook included) | + +With the hook returning early, all three unit tests turn red (checked). + +## Fold (batch 11 review) + +- `PlanShape::carry_tap_state` no longer reads arm sample `0` as "no command". A command admitted against the + successor at sample zero (before the first block) was overwritten by the predecessor's armed bit. The shape now + holds `observation_commanded`, one bit per tap per effect slot, set where `push_observe` admits a tap command and + cleared once the hand-over has read it; the table is charged in `arm_table_bytes`. Gate: + `a_tap_command_admitted_at_sample_zero_survives_the_hand_over` (red with the sentinel, checked). +- The `adopt_shape` doc over-claimed that the poll finds the carried pending windows where it left them. That holds + only while the meter handles are unchanged: a swap that removes one strip and adds another shifts handles, the + carried pending snapshots fail the poll's handle check, and they are dropped and added to the meter loss count. diff --git a/.github/ISSUE_SPECS/1567-address-enqueued-automation-by-control-address.md b/.github/ISSUE_SPECS/1567-address-enqueued-automation-by-control-address.md index 956c9d88f..37a531cde 100644 --- a/.github/ISSUE_SPECS/1567-address-enqueued-automation-by-control-address.md +++ b/.github/ISSUE_SPECS/1567-address-enqueued-automation-by-control-address.md @@ -158,4 +158,4 @@ Checked against `main` at `7feb27800`. ## Parked (root, 2026-10-14) -Parked 2026-10-14: #1058 A9 retires AUTOMATION_ENQUEUE; close as superseded when #1058 slice 21a is filed and lands. +Parked 2026-10-14: #1058 A9 retires AUTOMATION_ENQUEUE; close as superseded when #1627 lands. diff --git a/.github/ISSUE_SPECS/1626-validate-automation-lanes-in-the-session.md b/.github/ISSUE_SPECS/1626-validate-automation-lanes-in-the-session.md index 6e852548e..f29816dc0 100644 --- a/.github/ISSUE_SPECS/1626-validate-automation-lanes-in-the-session.md +++ b/.github/ISSUE_SPECS/1626-validate-automation-lanes-in-the-session.md @@ -18,7 +18,8 @@ stage `typed-model`), refuses three shapes it accepts today: - two discontinuities of one entry closer than 64 samples. `docs/SESSION_SCHEMA_V1.md` states what the table means: the hold rule of A3, and these rules. -This slice reads no descriptor; unit, domain and shape rules are drafts 02 and 03a. Nothing renders +This slice reads no descriptor; unit, domain and shape rules are #1645 and +#1566's row rule (`controls::automation_diagnostics`). Nothing renders the table yet. ## Context @@ -109,8 +110,8 @@ the table yet. segment's `end_value`. A `step` segment holds `start_value` on `[start, end)`, and the lane takes `end_value` at `end`. - D1-D3 with their codes, and a pointer to the preparation rules (unit, domain, shape and filter - order against the target's row: drafts 02 and 03a). - - The sentence that the table renders nothing stays; slice 09b changes it. + order against the target's row: #1645 and #1566's row rule). + - The sentence that the table renders nothing stays; #1647 changes it. - The skill's automation paragraph gains the hold rule, and a new "Automation refusals, by code" table, in the form of the VCA table, lists D1-D3. - **D5. Fixture migration.** `builtins-automation.json` gains a second track, `keys`, that @@ -139,9 +140,10 @@ the table yet. ## Non-goals -- Any descriptor check: builtin rows are draft 02, effect parameters draft 03a and #1335. -- The SDK builder's mirror of these rules (draft 03b). -- Rendering any automation (slices 07 to 20). The live classifier. +- Any descriptor check: builtin rows are #1645, effect parameters + #1566's row rule (`controls::automation_diagnostics`) and #1335. +- The SDK builder's mirror of these rules (obsolete draft 03b, not filed). +- Rendering any automation (#1637-#1639 and #1646-#1667). The live classifier. - New shapes, fields or per-segment ramp lengths (A3 keeps the table as it is). ## Hazards @@ -215,7 +217,7 @@ the table yet. - None. - Batch: P1, first in it. #1627-#1629 (the hold rule's new home before #1629 deletes the old one), - 02, 03a and 03b and draft 07 (the evaluator crate) build on it. It is a product outcome alone: + #1645 and #1637 (the evaluator crate) build on it. It is a product outcome alone: it refuses automation lanes that no rendering could honour. ## Amendment (controls coordinator, 2026-10-14) diff --git a/.github/ISSUE_SPECS/1627-retire-the-automation-enqueue-command.md b/.github/ISSUE_SPECS/1627-retire-the-automation-enqueue-command.md index 565ab14b2..ee8ba9edc 100644 --- a/.github/ISSUE_SPECS/1627-retire-the-automation-enqueue-command.md +++ b/.github/ISSUE_SPECS/1627-retire-the-automation-enqueue-command.md @@ -70,7 +70,7 @@ refusal is permanent) and D6. | Fuzz | `fuzz/corpus/complete-schema-manifest.md:3-10` (frame counts and the hash); no seed file carries the command | **Never retired here:** `ParameterHandle`, the type `ParameterAutomationRate` -(`message_wire.rs:239-247`; draft 26 retires its value 1), `ControlProvider::current_sample`, +(`message_wire.rs:239-247`; #1636 retires its value 1), `ControlProvider::current_sample`, `reserve_reliable_events`, the stored automation edits `0x0600`-`0x0603`. **Tests that send or pin the command today:** @@ -156,15 +156,15 @@ the manifest `fuzz/corpus/complete-schema-manifest.md:10`, the record - The event `0x8002`, the cancellation path and `record_canceled_automation` (#1628). - The queue, its records, config, report, queue kind 2 and the counters (#1629). -- The C limit `maximum_automation_spans_per_block` (draft 22). The browser bypass lift (#1315 D4). -- The stored automation edits `0x0600`-`0x0603`. The transport position (draft 25). The span kinds - and the sample rate value (draft 26). +- The C limit `maximum_automation_spans_per_block` (#1668). The browser bypass lift (#1315 D4). +- The stored automation edits `0x0600`-`0x0603`. The transport position (#1635). The span kinds + and the sample rate value (#1636). ## Hazards - **Batch P1.** After this slice the cancellation path and the queue have no producer; #1628 and #1629 remove them in the same push, so `main` never holds them. -- **Corpus re-pin order.** #1313, #1365, #1369 and #1394, #1628, and drafts 25 and 26 also re-pin +- **Corpus re-pin order.** #1313, #1365, #1369 and #1394, #1628, and #1635 and #1636 also re-pin `COMPLETE_SCHEMA_HASH`. Whichever merges later recomputes from the earlier value and adds its own history line; never merge two hashes by hand. - **#1315's C ABI half** must not land before this slice: its feature switch would be code this @@ -339,7 +339,7 @@ stored `SetAutomationSegments` edit, not the command) and (the self-test's search text matched nothing and would have failed `--self-test`). Both now carry the new pin. -**Re-pins** (reason: draft 21a retires `AUTOMATION_ENQUEUE`, statuses 15 and 16 and capability +**Re-pins** (reason: #1627 retires `AUTOMATION_ENQUEUE`, statuses 15 and 16 and capability fields 9, 12, 19 and 20): `COMPLETE_SCHEMA_HASH` `0x4f6a_80fb_52c1_2aed` to `0xcfa5_8ab7_b7bb_b7c4` (corpus 50 to 46 frames: the command row, its success row and the two status rows are gone, and the capabilities response has 23 fields and lists no `0006`), the parity self-test strings, the fuzz diff --git a/.github/ISSUE_SPECS/1628-retire-the-automation-canceled-event.md b/.github/ISSUE_SPECS/1628-retire-the-automation-canceled-event.md index b3d368610..0a7065556 100644 --- a/.github/ISSUE_SPECS/1628-retire-the-automation-canceled-event.md +++ b/.github/ISSUE_SPECS/1628-retire-the-automation-canceled-event.md @@ -101,7 +101,7 @@ This is the second of three slices that retire the command, all in batch P1 (one ## Non-goals - The command (#1627). The queue, its records, config and counters (#1629). The transport - position (draft 25). + position (#1635). ## Hazards @@ -197,7 +197,7 @@ C ABI `all_five_event_families_cross_c_dequeue_with_exact_oracle_bytes` (doc com retired, README F24; the capabilities response vector now lists five events) and `a_live_edit_emits_the_commit_events_of_a_rebuild` (no `AUTOMATION_CANCELED` arm). -**Re-pins** (reason: draft 21b retires `AUTOMATION_CANCELED`): `COMPLETE_SCHEMA_HASH` +**Re-pins** (reason: #1628 retires `AUTOMATION_CANCELED`): `COMPLETE_SCHEMA_HASH` `0xcfa5_8ab7_b7bb_b7c4` to `0x4b21_d9e6_f520_afd9` (corpus 46 to 45 frames: the `event.automation_canceled` row is gone and the capabilities response lists no `8002`), the parity self-test strings (digest, and the length-assertion mutation 45 to 44), the fuzz manifest, the diff --git a/.github/ISSUE_SPECS/1629-delete-the-protocol-automation-queue.md b/.github/ISSUE_SPECS/1629-delete-the-protocol-automation-queue.md index 250b2228d..1d0a9577f 100644 --- a/.github/ISSUE_SPECS/1629-delete-the-protocol-automation-queue.md +++ b/.github/ISSUE_SPECS/1629-delete-the-protocol-automation-queue.md @@ -81,7 +81,7 @@ never holds a queue that nothing calls. - **D4. The C ABI** builds no automation queue and reads neither S nor the quantum for it; the test-support report loses its row. `capi_retained_bytes` falls by the removed rows; the exact-charge oracle stays exact, and the reference budgets are ceilings that still hold. The - compile limit S keeps its validation until draft 22 reserves it. + compile limit S keeps its validation until #1668 reserves it. - **D5. Tests.** The queue-only tests above are deleted; the wrap test keeps its other queues. - **D6. Docs.** The registry lists queue kind 2 and the five counters as retired with this slice as the reason, and removes the record line (`:45`); the semantics, sizing and realtime-memory @@ -111,7 +111,7 @@ never holds a queue that nothing calls. ## Non-goals - Wire identities, dispatch, capabilities and the corpus hash (#1627 and #1628). -- The C limit `maximum_automation_spans_per_block` (draft 22). +- The C limit `maximum_automation_spans_per_block` (#1668). - The other queues and their configuration. ## Hazards @@ -170,7 +170,7 @@ Batch P1. Direct dependency: - #1628 *Retire the AUTOMATION_CANCELED event and the cancellation path*, which brings #1627. *Extract the C ABI control plane into a portable crate both hosts call* (#1309) arrives through -#1627. Draft 22 *Reserve the C ABI's automation span limit* follows this draft. +#1627. #1668 *Reserve the C ABI's automation span limit* follows this draft. ## Amendment (controls coordinator, 2026-10-14) @@ -211,7 +211,7 @@ codes below changed. builds no automation row; the quantum is no longer read for the queue and `capi_resources` loses its `quantum_frames` argument; the test-support report loses its `automation` row. Both hosts build the queue here (the browser through `control_limits`), so D4 applies to both. The compile - limit S (`maximum_automation_spans_per_block`) keeps its validation, untouched (draft 22). + limit S (`maximum_automation_spans_per_block`) keeps its validation, untouched (#1668). - host-core: `SERVED_COUNTERS` is four IDs (`TELEMETRY_COALESCED`, `TELEMETRY_DROPPED`, `LIVE_VALUES_SUPERSEDED`, `TRANSITION_REPREPARE_REFUSALS`); `CANCELED_AUTOMATION` is no longer served. @@ -278,7 +278,7 @@ and the C ABI counters tests and the pinned counters response vector (request 8: conformance record's frame count is unchanged (45). - **The hold rule has no new home yet.** The spec deletes the registry's record line; its one rule not stated elsewhere, "a segment applies on `[start,end)` and holds its end value until replaced", - was waiting for draft 01 (#1626) to give it a home in `docs/SESSION_SCHEMA_V1.md`, which has not + was waiting for #1626 to give it a home in `docs/SESSION_SCHEMA_V1.md`, which has not landed on this base. The registry keeps that sentence alone, pointing at #1626; #1626 moves it. - Mechanical edits outside the authorized paths: `crates/host-core/src/control_provider.rs` (the served counter set and its test), `tools/realtime-policy/src/policy.rs` (the allowlist row), diff --git a/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md b/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md index 5acba7847..e1733a86f 100644 --- a/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md +++ b/.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md @@ -28,7 +28,7 @@ and a mode, not performance values. valid control row. `docs/CONTROL_PROTOCOL_REGISTRY.md:54` states the rule. - No checked-in fixture automates either row (`fixtures/session/v1/builtins-automation.json` holds builtins rows of `vocal`, `route-ride` on route control 1 and `vca-ride`). -- The stored-automation drafts assume this ruling: draft 13d (amended) renders no `follows_mute` +- The stored-automation drafts assume this ruling: #1654 (amended) renders no `follows_mute` entry, and a `matrix_mode` entry would make rows active and inactive over time, which no draft covers. @@ -70,7 +70,7 @@ and a mode, not performance values. ## Dependencies -- None. Draft 13d of the stored-automation program (README) assumes it (ordering only). +- None. #1654 of the stored-automation program (README) assumes it (ordering only). ## Size diff --git a/.github/ISSUE_SPECS/1633-allocate-an-effect-lanes-carried-slots-only-when-a-successor-carries.md b/.github/ISSUE_SPECS/1633-allocate-an-effect-lanes-carried-slots-only-when-a-successor-carries.md index 67c6fa9ff..dce351306 100644 --- a/.github/ISSUE_SPECS/1633-allocate-an-effect-lanes-carried-slots-only-when-a-successor-carries.md +++ b/.github/ISSUE_SPECS/1633-allocate-an-effect-lanes-carried-slots-only-when-a-successor-carries.md @@ -59,3 +59,58 @@ less. ## Size Half a day to a day. + +## Decision record: carried slots on demand (batch 11) + +Shipped D1-D3 as frozen. + +- `effect_control_lane` builds no carried slots. `EffectControlLane::install_carry_slots` (control + thread, idempotent, none for a lane without cells) allocates them; `carry_slot_bytes` is the one + spelling of their size; `carry_slots_installed` reports them. +- `graph::install_effect_carry` installs the slots of the successor sites in `moves` only (bank + lanes of a live stage, and per-node instances), after every move validated. `effect_carry_slot_bytes` + (new) reads the charge from the bound plan before the install; `effect_carry_retained_bytes` takes + it as a third argument; `carry_program_retained_bytes` sums the same function over the installed + successor sites, so admission and the retained row cannot disagree (the existing + `debug_assert_eq!` in `prepare.rs` ties them). +- D2: `can_carry_from` is the old shape check plus "this lane's slots are installed"; + `carry_from` reads a predecessor that holds no slots as empty (`.get(i)` with an empty default). + `Reads::read` takes `Option<&mut CarriedCell>`, so a lane without slots reads none in `stage`. + The shape check alone is now `same_cell_shape`. +- D3: the swap block allocates and frees nothing and reads no new state beyond the slot slices' + lengths; `stage` does one bounds-checked `get_mut` per cell, which `Box<[T]>` indexing did already. + +Departure (mechanical, outside the authorized list): `rack::LiveControlEffectBankStage::can_carry_instance` +was used in both directions of a bank-lane/per-node move, but the new rule needs the *successor's* +slots installed, so the into-instance direction got its own `can_carry_lane_into_instance` (the +instance is then the successor); `carry_slot_bytes(lane)` and `install_carry_slots(lane)` were added +on the stage; `crates/graph/src/runtime.rs` calls them; `crates/host-core/src/prepare.rs` passes the +slot charge into the admission. No edit under `crates/engine/src/realtime/`. + +Gates. + +1. Boot budget (`target/ci/b11k-artifacts`, scripts/check-web-boot-budget.mjs with the growth + printed): growth 20,971,520 -> 20,447,232 bytes; the pin is 20,971,520, so the margin is + 524,288 bytes (eight Wasm pages), the same growth as before #1280a. The multiplier is unchanged. + `check-browser-expected-resources.py` passes with no re-pin: no expected.json memory row moved + (a one-document first boot carries nothing). +2. `capi_retained_bytes` is 165,166 before and after (it charges capi's own allocations, not the + plan's lanes). Its eight-lane ceiling is tightened 216,832 -> 181,696 (165,166 + 10 %, rounded up + to 64). The four-lane value stays 216,832 until the next `aarch64-debug` print. The plan rows + the slots were in fell at eight lanes: `graph_session_plus_plan_bytes` 321,846 -> 315,798, + `graph_incremental_plan_bytes` 321,774 -> 315,726, `graph_metadata_bytes` 113,601 -> 107,553 + (-6,048 each); their ceilings (346,432, 346,368, 117,376 and the four-lane ones) still bound + them and are left for the same print. +3. All carry gates pass unchanged: `cargo test --locked -p host-core --all-features` (the whole + `successor_swap` suite, including zero allocations on the swap block and the charge tests), + `-p effect-contract -p graph -p graph-compiler -p rack -p capi`, `-p host-web --features + host-web/test-support`. +4. New tests, both in `crates/effect-contract/tests/live_control.rs`: + `carried_slots_are_installed_for_a_successor_only_and_charged_exactly` (a built lane holds no + slots and refuses a carry; the install grows `retained_resources` by exactly `carry_slot_bytes`; + a second install changes nothing) and + `a_predecessor_without_slots_hands_its_unread_write_to_an_installed_successor` (D2's + missing-predecessor-slot read). + Clippy `-D warnings`, fmt, rustdoc `-D warnings`, `check-builtins-fixtures.sh`, + `check-cross-targets.sh`, `check-web-audioworklet.sh` with the artifacts, `realtime-policy` and + `check-bench-policy.sh` pass. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/25-retire-the-protocol-transport-position.md b/.github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md similarity index 97% rename from docs/handoffs/stored-automation-1058/proposed-specs/25-retire-the-protocol-transport-position.md rename to .github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md index 845550acd..e223b174c 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/25-retire-the-protocol-transport-position.md +++ b/.github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md @@ -11,7 +11,7 @@ The control protocol no longer stores a playhead that nothing plays. `TRANSPORT_ transport state; its position field is retired and refused in either flag form. The transport snapshot and the `TRANSPORT_STATE` event carry the state and the effective sample, with no position. The engine has one playhead: the host moves it with source seeks today and with the session seek -of drafts 05 and 06a later. Every retired field is refused, never reallocated +of #1642 and #1643 later. Every retired field is refused, never reallocated and never renumbered. ## Context @@ -70,7 +70,7 @@ and never renumbered. (`crates/protocol/src/queue.rs:323-335`, `:395-417`), and the controller stops passing `snapshot.position`; the provider trait's `transport_get` and `transport_set` keep their shape without it; host-core's provider and `MockProvider` lose `transport_position`. -- **D3. Re-pins**, each with the reason "draft 25 retires the stored transport position": +- **D3. Re-pins**, each with the reason "#1635 retires the stored transport position": `COMPLETE_SCHEMA_HASH` and its history line, the wasm-parity self-test strings, the fuzz manifest, the conformance record and the C ABI vectors that carry a transport frame. `retired_code_rows` gains one row per retired field. @@ -102,7 +102,7 @@ and never renumbered. ## Non-goals - The transport state (stopped or playing), which stays endpoint-local and readable. -- The timeline and the session seek (drafts 04a-06b). A protocol command that seeks. +- The timeline and the session seek (#1640-#1644). A protocol command that seeks. ## Hazards diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/26-retire-the-span-kinds-and-sample-rate-no-producer-uses.md b/.github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md similarity index 99% rename from docs/handoffs/stored-automation-1058/proposed-specs/26-retire-the-span-kinds-and-sample-rate-no-producer-uses.md rename to .github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md index 1f426d699..4e3248c83 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/26-retire-the-span-kinds-and-sample-rate-no-producer-uses.md +++ b/.github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md @@ -69,7 +69,7 @@ retired: each raw value is refused, never reallocated and never renumbered. No r row leaves with the function. The independent metadata gate's rate table (`scripts/check-parameter-metadata-v1.py:30`, `RATES = {1: "sample", 2: "block", 3: "none"}`, read at `:477`) loses its `1: "sample"` entry, so that gate also refuses the retired rate. -- **D3. Re-pins**, with the reason "draft 26 retires the span kinds and the sample rate": any +- **D3. Re-pins**, with the reason "#1636 retires the span kinds and the sample rate": any conformance corpus row or digest that carries a retired value, and `COMPLETE_SCHEMA_HASH` only if a corpus frame carries `ParameterAutomationRate` value 1. `retired_code_rows` gains a row for value 1 in a parameter descriptor. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/07-compile-stored-automation-into-per-cell-events.md b/.github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md similarity index 93% rename from docs/handoffs/stored-automation-1058/proposed-specs/07-compile-stored-automation-into-per-cell-events.md rename to .github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md index f5a6c8cc9..1b55eaf20 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/07-compile-stored-automation-into-per-cell-events.md +++ b/.github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md @@ -13,7 +13,7 @@ offset order: grid retargets on a fixed node-time grid, jumps at discontinuities at a seek. Every value is computed in scalar `f64` through `crates/math` and rounded once to `f32`, so the same table and the same mapping give the same events, bit for bit, on x86-64, AArch64 and wasm, at any quantum, wherever a seek lands. It allocates nothing after its builder. No stage uses -it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first users. +it yet; #1645's order check (`value_at`) and #1646 and #1647 are its first users. ## Context @@ -34,10 +34,10 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first sample `τ` equals the target at `τ + L - 1`. The EQ and input-filter coefficient ramps reach the target at `A + 64` (`docs/EFFECT_CONTRACT_V1.md:165-166`), one sample later. - **Node time** (README A1.3): a node at render sample `r` reads source-read sample - `s = r + ΣP - a(n)`, and its node time is `τ(r) = timeline(s)`. Draft 04a's + `s = r + ΣP - a(n)`, and its node time is `τ(r) = timeline(s)`. #1640's `timeline_block` gives one node block's mapping: a start value and at most one step (a seek). This crate cannot depend on `source` (D1), so it takes the mapping as its own plain type, and - draft 09b fills it from draft 04a's reader. + #1647 fills it from #1640's reader. - **The cross-target corpus.** Gate G5 digests every case on x86-64, AArch64 and wasm and compares with one set of pins (`tools/wasm-gate-corpus/src/lib.rs:1-45`). Crates delegate scalar cases through their own `corpus` module and pins (`math::corpus`, `:55`, `:118`); new blocks are @@ -68,16 +68,16 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first time). `G` is 64, or the cell's ramp length `L` when that is larger (128 for the delay time); it is always a multiple of 64. - `JumpLength { Word(JumpWord), Fixed(u32) }`, the jump-length key, with - `JumpWord { Fader, Mute, Pan }`. A builtin strip row stores which word of draft 12's plan cell + `JumpWord { Fader, Mute, Pan }`. A builtin strip row stores which word of #1639's plan cell it reads (fader and trim: `Fader`; mute and polarity: `Mute`; pan and matrix: `Pan`). A filter target stores `Fixed(64)` and an effect parameter `Fixed(smoothing_samples)`. The program never stores a copied jump length `J` for a word key: `J` is read from the plan cell, so a - `control_smoothing` edit reaches the next jump with no rebuild (draft 12). + `control_smoothing` edit reaches the next jump with no rebuild (#1639). - `CellState`: cursor, current target bits, the node time at which the ramp in flight on the lane completes, a held flag, and the last seek boundary. At most 48 bytes (a `const` assertion). - **D3. `value_at(segments: &[Segment], t: i64) -> f32`.** A free function of the crate that takes only a segment table, never a `CellProgram`: render calls it with a program's segment slice, and - draft 02 calls it with a table it builds from one session entry (`Segment` per + #1645 calls it with a table it builds from one session entry (`Segment` per `AutomationSegment`, the same fields), so preparation's check and render read one law. The hold rule over the segment containing `t`. Inside a `linear` or `exponential` segment, `x = (t - t0) as f64 / (t1 - t0) as f64` (both differences are @@ -91,7 +91,7 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first a seek step if the block has one, and the block length. `mode` is `SeekMode::Exact` (builtin cells) or `SeekMode::Ramp` (effect cells). Rules, at most one event per offset, priority `Set > Jump > Grid`. `jump_lengths: [u32; 3]` is the block's `fader`, `mute` and `pan` words of - draft 12's plan cell; `J` below is the key resolved against it (a `Word` key) or the `Fixed` + #1639's plan cell; `J` below is the key resolved against it (a `Word` key) or the `Fixed` length: - **Grid.** At each offset whose node time `τ` is a multiple of `G`, a retarget over `L` to `value_at(completion(τ, L))`. @@ -103,7 +103,7 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first current target does not change. The caller reports ramps it starts itself (a mute ramp, a live record's ramp) through `CellState::hold_until(node_time)`. - **Set.** At a seek step, `Exact` emits `Set` (no ramp) to `value_at(τ)` and `Ramp` emits a - `Jump` over `J` to `value_at(completion(τ, J))`. The step may be at offset 0: draft 04a's reader + `Jump` over `J` to `value_at(completion(τ, J))`. The step may be at offset 0: #1640's reader reports `step = (0, t)` when the seek's source-read block starts the node block, and the cell then emits its `Set` (or ramped `Jump`) at offset 0. So every seek that reaches a block yields its event, whatever its offset. The cursor is repositioned first by binary @@ -119,8 +119,8 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first no session type) and reports the exact bytes it will allocate (`bytes(&cells) -> u64`) before it allocates them with fallible reservations. It refuses, typed, a description that breaks D2's invariants (segments out of order or overlapping; a `G` that is not a multiple of 64 or that - differs from the larger of 64 and `L`; a non-positive exponential value). Preparation (draft - 09a) charges the report. + differs from the larger of 64 and `L`; a non-positive exponential value). Preparation ( + #1646) charges the report. - **D6. G5 corpus.** `automation::corpus` holds a fixed curve set: a linear dB fade over 48,000 samples, an exponential 20 Hz to 20 kHz sweep, steps, gaps, one entry with a seek, and a cell with `no_restart`. Each case runs D4 over a fixed sequence of node-time mappings and digests every @@ -154,8 +154,8 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first ## Non-goals -- Reading a session, a descriptor or a plan; mapping targets to cells (drafts 09a, 09b and later). -- Any stage operation or render wiring (drafts 08, 09a and 09b). +- Reading a session, a descriptor or a plan; mapping targets to cells (#1646, #1647 and later). +- Any stage operation or render wiring (#1638, #1646 and #1647). - Vector evaluation. Events are sparse control arithmetic; vector lane math diverges on AArch64 release builds (LANE-3, `docs/TARGET_MATRIX.md:163-167`), so evaluation stays scalar `f64` (README A1.7). This is the reason the no-scalar rule asks for. @@ -164,8 +164,8 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first - **Corpus order is part of the pin.** The block goes on the end; no existing index moves. The G5 layout test checks it. -- **Exponential near the edges.** `v1/v0` is positive by validation (draft 03a for effects, draft 02 - for builtins); the builder still refuses a non-positive value so the evaluator has no NaN path. +- **Exponential near the edges.** `v1/v0` is positive by validation (#1645 and #1566's row rule, + `controls::automation_diagnostics`); the builder still refuses a non-positive value so the evaluator has no NaN path. - **The grid in node time, not render time.** A grid on render samples would make the event values depend on where a seek landed when `q` is not a multiple of 64 (the README's blocker B1). The quantum-100 gate defends it. @@ -224,10 +224,10 @@ it yet; draft 02's order check (`value_at`) and drafts 09a and 09b are its first ## Dependencies - #1626 *Validate stored automation lanes in the session crate and state the hold rule*. -- Batch: R1, with draft 02 *Validate builtin automation targets against their rows at - preparation* and drafts 09a *Prepare stored fader automation and render it flat* and 09b *Render +- Batch: R1, with #1645 *Validate builtin automation targets against their rows at + preparation* and #1646 *Prepare stored fader automation and render it flat* and #1647 *Render moving stored fader automation, seeks and latency*, its first users. The key names the words of - draft 12's plan cell, but this crate reads no cell: the caller passes the words, so draft 12 is + #1639's plan cell, but this crate reads no cell: the caller passes the words, so #1639 is not a dependency. ## Amendment (controls coordinator, 2026-10-14) @@ -237,7 +237,7 @@ Checked against `12d4a4f9d`. - **Jump lengths follow the ramp rows.** Decision 17 gives every row a `LiveRampRow` (`crates/effect-contract/src/control.rs:70-102`); polarity ramps over twice `mute_ms` (`PolarityInvert`), and sends and VCAs have rows of their own. Replace D2's `JumpWord { Fader, - Mute, Pan }` with the row's `LiveRampRow`, resolved per block against draft 12's three key + Mute, Pan }` with the row's `LiveRampRow`, resolved per block against #1639's three key lengths by `controls::LiveRamps::for_row` (`crates/controls/src/live_ramps.rs`), so no row's rule is written twice. `Fixed(n)` stays for filter, EQ and effect cells. - **Realtime policy.** D7 and gate 7: `scripts/check-realtime-policy.sh` is deleted; raise @@ -245,5 +245,5 @@ Checked against `12d4a4f9d`. `./target/release/realtime_policy`. Authorized paths change to that file. - **The table.** `Automation.target` is a `ControlAddress` (#1566); the crate reads no session type, so nothing else changes. -- **OQ1 = C** adds no evaluator work: the caller adds the offset after `value_at` (slice 11b D6, - draft 15). +- **OQ1 = C** adds no evaluator work: the caller adds the offset after `value_at` (#1650 D6, + #1658). diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/08-apply-timed-operations-inside-a-block-on-the-fader-stage.md b/.github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md similarity index 94% rename from docs/handoffs/stored-automation-1058/proposed-specs/08-apply-timed-operations-inside-a-block-on-the-fader-stage.md rename to .github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md index ec0f0a558..3eead45af 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/08-apply-timed-operations-inside-a-block-on-the-fader-stage.md +++ b/.github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md @@ -11,8 +11,8 @@ The strip fader and mute stage can apply a lane's fader and mute changes at any block, not only at block entry. A block with an operation at offset `o` renders, bit for bit, what the same block split at `o` renders with the operation applied at the second part's entry, at every width. A new "remember only" operation updates a lane's remembered fader gain without a -retarget while the lane is muted or a mute ramp is in flight. Nothing feeds operations yet; draft -09b drives them from stored fader automation. The live path is unchanged. +retarget while the lane is muted or a mute ramp is in flight. Nothing feeds operations yet; +#1647 drives them from stored fader automation. The live path is unchanged. ## Context @@ -59,7 +59,7 @@ retarget while the lane is muted or a mute ramp is in flight. Nothing feeds oper it. The stage tracks the second with one flag per lane and channel: `SetMute` with a nonzero window sets it; the lane's countdown reaching 0, or a later gain retarget on that lane through the live path, clears it. `gain_held(lane, channel)` and `remaining(lane, channel)` are readable, - so a caller (draft 07's `hold_until`) knows when the lane's ramp ends. The live path's + so a caller (#1637's `hold_until`) knows when the lane's ramp ends. The live path's `set_fader_gain` keeps its own rule, which *Session controlSmoothing: configurable ramp lengths for live mute, fader and pan changes* (#1054) D10 sets as amended by the #1058 note (finding F12): while the lane is muted (settled, or ramping toward 0) a live gain change only remembers the gain; @@ -75,7 +75,7 @@ retarget while the lane is muted or a mute ramp is in flight. Nothing feeds oper sample unmute toward the new gain, and a mute and a fader move at one sample end muted. - **D5. Exposure.** `BuiltinFaderBank::process_with` and `FaderMuteRampBuiltins::process_with` forward to D3. `try_process_settled_with_matrix` declines a block with any operation (it already - declines a ramping lane). The bank processors keep calling `process`; draft 09b switches the fader + declines a ramping lane). The bank processors keep calling `process`; #1647 switches the fader bank processor to `process_with`. - **D6. Class A.** No new arithmetic. The pieces run the existing kernels in the existing order, so a split moves no bit. @@ -97,11 +97,11 @@ retarget while the lane is muted or a mute ramp is in flight. Nothing feeds oper ## Non-goals -- Producing operations from stored automation (drafts 09a and 09b); the matrix, input and route - stages (drafts 13a-13c, 14a-14b, 15 and 16a-16b). +- Producing operations from stored automation (#1646 and #1647); the matrix, input and route + stages (#1651-#1653, #1656-#1657, #1658 and #1659-#1660). - Changing the live path's records, drains or `set_fader_gain` (#1054 D10 as amended, finding F12). -- The mute-cell composition with VCA and solo terms (drafts 13a-13c). +- The mute-cell composition with VCA and solo terms (#1651-#1653). ## Hazards @@ -157,8 +157,8 @@ retarget while the lane is muted or a mute ramp is in flight. Nothing feeds oper ## Dependencies - None. -- Batch: R1, with drafts 09a *Prepare stored fader automation and render it flat* (which uses - `SetGain`) and 09b *Render moving stored fader automation, seeks and latency*. +- Batch: R1, with #1646 *Prepare stored fader automation and render it flat* (which uses + `SetGain`) and #1647 *Render moving stored fader automation, seeks and latency*. ## Amendment (controls coordinator, 2026-10-14) @@ -172,4 +172,4 @@ Checked against `12d4a4f9d`. case. Authorized paths are unchanged (`crates/builtins/src/lib.rs`). - **Anchors moved.** `FaderRampStage`'s setters are now near `crates/builtins/src/lib.rs:3083-3140`, `BuiltinFaderBank` `:4314`, `FaderMuteRampBuiltins` `:4732`; re-read at filing. #1408 has landed. -- **Realtime policy** as in draft 07's amendment. +- **Realtime policy** as in #1637's amendment. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/12-hold-the-automation-jump-lengths-in-a-cell.md b/.github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md similarity index 92% rename from docs/handoffs/stored-automation-1058/proposed-specs/12-hold-the-automation-jump-lengths-in-a-cell.md rename to .github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md index 902c63a5e..777819c52 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/12-hold-the-automation-jump-lengths-in-a-cell.md +++ b/.github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md @@ -32,21 +32,21 @@ already in flight keeps the length it started with. and render never skips the newest completed write. - **The block entry.** The graph's render begins each block with the source set's `begin_block` (`crates/graph/src/lib.rs:3254-3256`). -- **Draft 07 D2** gives each cell program a jump-length key, never a copied `J`: a builtin row's key +- **#1637 D2** gives each cell program a jump-length key, never a copied `J`: a builtin row's key names a word of this slice's cell (`Fader`, `Mute`, `Pan`); filter and effect cells keep a fixed - length. This slice lands before draft 09a, the first program that reads the cell. + length. This slice lands before #1646, the first program that reads the cell. ## Decisions frozen for this slice - **D1. One plan-level cell.** Every host-core plan owns one #1312 latest-target cell of three words: `mute`, `fader`, `pan`, in samples, the value of `control_smoothing_samples()`. Preparation writes it on both hosts. A builtin cell program reads the word its row names (fader - and trim: `fader`; mute and polarity: `mute`; pan and matrix: `pan`; draft 07 D2's key). Filter, + and trim: `fader`; mute and polarity: `mute`; pan and matrix: `pan`; #1637 D2's key). Filter, EQ and effect cells keep their fixed length. The cell's bytes are fixed and do not depend on the session. - **D2. One reader.** The cell has one reader, so render reads it once per block: the graph reads it at block entry, beside the source set's `begin_block`, and holds the three values for that block. - Draft 09b passes them, with the node-time mapping, in the block context it gives every strip + #1647 passes them, with the node-time mapping, in the block context it gives every strip stage. A jump reads its row's value when it starts. A ramp in flight keeps its length. No stage reads the cell itself. - **D3. The edit path.** In `classify_live_delta`: @@ -67,7 +67,7 @@ already in flight keeps the length it started with. browser live edits in the Worker through the committed model* (#1382). So a browser transaction that edits `control_smoothing` meets the same rule as the C ABI's, from when the browser's apply export (*Export transaction apply and anchored seek from the browser engine module*, #1293, before - draft 06a in batch R1) can carry one. This slice adds no browser path and no browser gate. + #1643 in batch R1) can carry one. This slice adds no browser path and no browser gate. - **D6. #1365.** D3 is the `live` classification of #1365's `control_smoothing` edit. This slice depends on #1365, which lands first, and changes the classifier code #1365 adds; #1365 is not amended. @@ -80,7 +80,7 @@ already in flight keeps the length it started with. ## Deliverables 1. D1 and D2: the cell, preparation's seed and the graph's block-entry read. The strip stages' reads - come with their rendering slices (draft 09b for the fader; drafts 13a, 14b and 15 for their + come with their rendering slices (#1647 for the fader; #1651, #1657 and #1658 for their words). 2. D3 in the classifier and the commit path; D7. @@ -93,9 +93,9 @@ already in flight keeps the length it started with. - `crates/control-plane/src/` (the commit path), `crates/capi/src/runtime/live_tests.rs` - `docs/SESSION_SCHEMA_V1.md` (D7's sentence only) - Tests that pin a plan byte row the cell moves (re-pin each with the reason "the jump-length cell, - draft 12 D1", never in bulk) -- `crates/capi/Cargo.toml` (the `graph` dev-dependency's `test-support` feature, as draft 05 adds - it; only if draft 05 has not landed), `Cargo.lock` + #1639 D1", never in bulk) +- `crates/capi/Cargo.toml` (the `graph` dev-dependency's `test-support` feature, as #1642 adds + it; only if #1642 has not landed), `Cargo.lock` ## Non-goals @@ -109,7 +109,7 @@ already in flight keeps the length it started with. one owned value, moved into the graph at preparation and not `Clone`, so a second reader cannot be written. - **A transaction that changes `control_smoothing` and a rendered automated row together** is a - rebuild once draft 10 classifies that row (its D1); the successor is prepared with the new + rebuild once #1648 classifies that row (its D1); the successor is prepared with the new lengths (D4), so D3's write is not needed there. ## Objective gates @@ -117,7 +117,7 @@ already in flight keeps the length it started with. 1. **Block-entry read** (`crates/graph` test, new, through a `test-support` reader of the block's held values). A fresh plan holds the prepared lengths at its first block. A write of new lengths before a block entry is held from that block on, not before; two writes before one block give - the last. The audible effect on a jump is draft 09b's gate 2. + the last. The audible effect on a jump is #1647's gate 2. 2. **Classifier** (`crates/host-core/tests/live_delta.rs`, new). With a fader entry, and with a mute entry: a `control_smoothing` change gives a live delta with `jump_lengths` and no other record. Without stored automation, or with only an effect entry: an empty delta. A change of `fader_ms` @@ -162,7 +162,7 @@ already in flight keeps the length it started with. - *Edit control_smoothing by a session transaction, model-only* (#1365), for D3. It brings the edit's `model_only` path from *Report each transaction's edit path in its response* (#1313). - *Admit browser live edits in the Worker through the committed model* (#1382), for D5. -- Batch: R1. Draft 09a builds on it; no dependency on draft 10. +- Batch: R1. #1646 builds on it; no dependency on #1648. ## Amendment (controls coordinator, 2026-10-14) @@ -176,7 +176,7 @@ Checked against `12d4a4f9d`. rule of D3 stands. - **The cell holds the three key lengths**, the values of `controls::LiveRamps` (`crates/controls/src/live_ramps.rs:14-24`); a stage resolves a row's length with - `LiveRamps::for_row` (polarity is twice `mute_ms`; sends and VCAs have their own rows), as draft 07 + `LiveRamps::for_row` (polarity is twice `mute_ms`; sends and VCAs have their own rows), as #1637 (amended) says. D1's row-to-word list is replaced by that function. - **Context.** #1054 and #1365 have landed; `LiveRamps` moved from host-core to `controls`. - **The browser** is the same `SessionState` (#1562a); D5 holds by construction. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/04a-build-the-timeline-consumer-and-producer.md b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md similarity index 93% rename from docs/handoffs/stored-automation-1058/proposed-specs/04a-build-the-timeline-consumer-and-producer.md rename to .github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md index 6b9040be7..3a6fb0d9c 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/04a-build-the-timeline-consumer-and-producer.md +++ b/.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md @@ -14,8 +14,8 @@ through the same seek state machine a source uses, now factored so that no rule It keeps a short history, so a reader can ask "what was the timeline at this source-read sample?" for any sample a node of the plan can still be processing. -This slice is crate-local. Draft 04b *Give every plan a timeline that carries like a source* puts -the consumer in every host-core plan and carries it; draft 09b is its first render reader. Both are +This slice is crate-local. #1641 *Give every plan a timeline that carries like a source* puts +the consumer in every host-core plan and carries it; #1647 is its first render reader. Both are in batch R1, so `main` never holds the types with no caller. ## Context @@ -62,7 +62,7 @@ in batch R1, so `main` never holds the types with no caller. stops. Allocation-free, lock-free, bounded. - **D3. History.** A fixed ring of `K` entries of 16 bytes, `(source_read_sample: u64, timeline: u64)`, one per block, newest last. `K` is a constructor - argument (draft 04b sets `K = ⌈A_max / q⌉ + 1`). + argument (#1641 sets `K = ⌈A_max / q⌉ + 1`). - Reader `timeline_at(source_read_sample) -> i64`: search the entries newest first for the block that contains the sample (`entry.source_read_sample <= s < entry.source_read_sample + q`) and return `entry.timeline + (s - entry.source_read_sample)`. A sample older than the oldest @@ -77,21 +77,21 @@ in batch R1, so `main` never holds the types with no caller. block starts: the reader then reports `step = (0, t)`, `t` the seek's timeline value, and `start` equal to `t`. So a seek that reaches a node block always yields a step, at any offset from 0 to `q - 1`, and a reader never has to compare `start` with the previous block's end to - find a seek. Draft 09b copies it into draft 07's `NodeSpan`. + find a seek. #1647 copies it into #1637's `NodeSpan`. - `copy_history_from(&other)`: copies the newest `min(K_other, K_self)` entries, oldest first, a - bounded copy with no allocation (draft 04b's carry). + bounded copy with no allocation (#1641's carry). - Every value is an exact integer. No floating point. - **D4. The timeline producer.** `TimelineProducer`, the D1 seek producer plus nothing else. Plain and anchored seeks, generation strictly above the current one, anchor a quantum multiple, one slot: a full slot returns `SourceSeekError::Backpressure` and changes nothing. It exposes - `check_seek` (D1), so an all-or-nothing caller (draft 05) checks every consumer before it pushes + `check_seek` (D1), so an all-or-nothing caller (#1642) checks every consumer before it pushes any. A seek frame above `TIMELINE_SAMPLE_LIMIT = 2^53` is refused with a new `SourceSeekError::FrameOutOfRange`. The limit is above any session's length (2^53 samples is about 2,970 years at 96 kHz) and leaves `2^63 - 2^53` samples of headroom, so every timeline value and every node time derived from it stays an exact `i64` for any run a host can render. - **D5. Bytes.** `TimelineResourceReport` states the timeline's fixed bytes row by row: the one-slot command queue (the ring's `command_queue_bytes` formula), the history (`16·K`) and the consumer. - Draft 04b adds them to the source set's overhead. + #1641 adds them to the source set's overhead. - **D6. The acked-batch question.** The timeline's command queue is a source's one-slot queue: a seek that does not fit is refused with backpressure before anything changes, and a held seek is never dropped. No ack can precede a drop. @@ -112,8 +112,8 @@ in batch R1, so `main` never holds the types with no caller. ## Non-goals - Putting the consumer in a plan, its carry, its producer in `SourceControlSet` and the diagnostic - name (draft 04b). The C call (draft 05), the browser export and SDK `seek` (drafts 06a and 06b), - and any automation reader (drafts 07, 09a and 09b). + name (#1641). The C call (#1642), the browser export and SDK `seek` (#1643 and #1644), + and any automation reader (#1637, #1646 and #1647). ## Hazards @@ -167,7 +167,7 @@ in batch R1, so `main` never holds the types with no caller. clock* (#1316), *Report held source blocks apart from underruns* (#1318) and *Let a source consumer check and replay its next blocks for a prime* (#1320), merge first: D1 factors their seek code (Hazards). -- Batch: R1. Draft 04b depends on this draft. +- Batch: R1. #1641 depends on this draft. ## Amendment (controls coordinator, 2026-10-14) diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/04b-give-every-plan-a-timeline-that-carries-like-a-source.md b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md similarity index 92% rename from docs/handoffs/stored-automation-1058/proposed-specs/04b-give-every-plan-a-timeline-that-carries-like-a-source.md rename to .github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md index 1824919df..ae1388a5e 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/04b-give-every-plan-a-timeline-that-carries-like-a-source.md +++ b/.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md @@ -7,15 +7,15 @@ to file. Code anchors verified on `6ee64f484`. Batch R1. ## Product outcome -Every prepared plan, on both hosts, carries one timeline consumer (draft 04a): the session's +Every prepared plan, on both hosts, carries one timeline consumer (#1640): the session's playhead. The graph's source driver advances it at every block entry, before the sources, from the plan's source-read clock. A successor plan carries it, history included, exactly as it carries an unchanged source: across a rebuild (#1273), a declared discontinuity (#1323) and a prime adoption (#1320), and the seek report (#1316) has a row for it. Its producer sits in `SourceControlSet` and moves with the persisting producers. Its fixed bytes are charged to the source overhead cap. -Nothing renders from it yet. The C call that seeks it is draft 05, the browser export and the SDK -drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. +Nothing renders from it yet. The C call that seeks it is #1642, the browser export and the SDK +#1643 and #1644, and the first reader #1647. All are in batch R1. ## Context @@ -42,7 +42,7 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. - **Node arrival.** PDC computes each node's input arrival `max` in `timings` (`crates/graph-compiler/src/pdc.rs:53-104`). *Keep every node's latency from dropping during playback* (#1285) D2 records each node's floored `max` in the plan inventory. `A_max`, the - largest of them, sizes the history (draft 04a D3). + largest of them, sizes the history (#1640 D3). - **The mechanisms the timeline joins**, each filed and a dependency of this slice: - *Anchor every seek on the plan's source-read clock* (#1316): the per-source seek report; - *Give a plan a source-read clock that leads its render clock* (#1396): the sample the driver @@ -64,9 +64,9 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. block entry, before the sources, with the source-read sample it passes the sources (#1396). - **D2. The producer.** `SourceControlSet` owns one `TimelineProducer` beside the source producers, and `adopt_persisting` moves it like a persisting source's producer. `FrameOutOfRange` maps to - `source.seek.frame_out_of_range` in `SourceControlError::diagnostic`; draft 04a adds that arm, + `source.seek.frame_out_of_range` in `SourceControlError::diagnostic`; #1640 adds that arm, because host-core does not compile without it. Host-core gains no public - timeline seek here; draft 05 adds the session seek. + timeline seek here; #1642 adds the session seek. - **D3. Carry on a rebuild.** A successor carries the timeline exactly as an unchanged source, on every successor (it never changes): - preparation builds a vacant timeline entry that owns its own history of its own `K`, and no @@ -74,7 +74,7 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. - the carry program gains `timeline: bool`, set on every successor, so a program exists even when no source or input lane carries; - at the swap block the driver's `adopt_sources` swaps the seek state, the command consumer and - `position` into the vacant entry and calls `copy_history_from` (draft 04a D3); nothing is + `position` into the vacant entry and calls `copy_history_from` (#1640 D3); nothing is allocated or freed; - a vacant timeline that is never adopted (a carry mismatch, unreachable on both hosts by #1269 P11) advances from 0 with no command queue, so render stays defined. @@ -84,12 +84,12 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. carried node never reads further back than it did on the predecessor, and the predecessor's `K` covered that. Only a node that the successor adds, restarts or grows can read further back. Such a node starts at rest at adoption (#1324, #1397), so the samples it reads before its - first input arrives label rest state, and the extrapolation of draft 04a D3 gives them a + first input arrives label rest state, and the extrapolation of #1640 D3 gives them a defined, host-independent value that is exact unless a seek fell in the missing span. From the successor's `⌈A_max / q⌉`-th block on, its own entries fill its window. - **D4. Carry at a declared discontinuity** (#1323). A discontinuity successor carries the timeline as D3 does. Its source-read clock steps back to the render clock (#1396 D3), so the history can - hold two entries for one source-read sample; draft 04a's reader searches newest first, which is + hold two entries for one source-read sample; #1640's reader searches newest first, which is the entry render played last. The nodes that read the overlap start at rest (#1323 D2), so no continuity is owed. - **D5. Prime** (#1320). The timeline answers #1320's readiness check as always ready (it has no @@ -99,7 +99,7 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. - **D6. The seek report** (#1316). The report gains one row for the timeline, of the same form as a source's row (generation in effect, the source-read sample of the first block it played in, the timeline value that block started at); the underrun and held counts are 0. -- **D7. Bytes.** `prepare_graph_source_set` adds draft 04a's `TimelineResourceReport` to the source +- **D7. Bytes.** `prepare_graph_source_set` adds #1640's `TimelineResourceReport` to the source set's `overhead_bytes` and `largest_allocation_bytes`, so the source overhead cap bounds them on both hosts. A vacant (carried) timeline charges only its history; its carried command queue is counted with the carried source overhead, as a carried ring's is @@ -107,10 +107,10 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. - **D8. Test reader.** `graph` cannot name a `crates/source` type (`source` depends on `graph`), so the source-set driver trait (`crates/graph/src/lib.rs:2161-2232`) gains one defaulted method, `timeline_at(&self, source_read_sample: u64) -> Option` (`None` by default), which the source - driver implements with draft 04a's reader. `graph::test_only_timeline_at(plan: &mut + driver implements with #1640's reader. `graph::test_only_timeline_at(plan: &mut PreparedRenderPlan, source_read_sample: u64) -> Option` (`test-support`) downcasts as `carry_program_retained_bytes` does and calls it. The render-side block reader comes with - draft 09b. + #1647. - **D9. The acked-batch question.** A held seek moves with its consumer across every carry; no ack can precede a drop. @@ -143,9 +143,9 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. ## Non-goals -- The C call (draft 05), the browser export and SDK `seek` (drafts 06a and 06b), and any automation - reader (drafts 07, 09a and 09b). -- The protocol's transport state, which never reports the timeline (draft 25 retires its stored +- The C call (#1642), the browser export and SDK `seek` (#1643 and #1644), and any automation + reader (#1637, #1646 and #1647). +- The protocol's transport state, which never reports the timeline (#1635 retires its stored position). - A plan with no source set. Every host-core plan has one; the refusal of an empty source list (`crates/source/src/lib.rs:1901-1903`) is unchanged. @@ -156,7 +156,7 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. source given the same command. Both run in the one driver call at block entry; D1 puts the timeline first and applies no state to sources. - **Byte rows move.** Every source overhead row grows by the timeline's bytes. Each pinned number - that moves is re-pinned with the reason "the timeline consumer's fixed bytes (draft 04b D7)". + that moves is re-pinned with the reason "the timeline consumer's fixed bytes (#1641 D7)". - **Hot file.** `crates/host-core/src/prepare.rs` follows the merge order in STREAMS.md; this slice lands after every slice it depends on. @@ -206,7 +206,7 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. ## Dependencies -- Draft 04a *Build the timeline consumer and producer in the source crate*. +- #1640 *Build the timeline consumer and producer in the source crate*. - *Keep every node's latency from dropping during playback* (#1285): D2's recorded floored arrivals give `A_max` (D1). - *Anchor every seek on the plan's source-read clock* (#1316): the seek report (D6). @@ -216,8 +216,8 @@ drafts 06a and 06b, and the first reader draft 09b. All are in batch R1. (D1, D4). - *Let a source consumer check and replay its next blocks for a prime* (#1320): readiness and prime (D5). -- Batch: R1, with draft 09b *Render moving stored fader automation, seeks and latency*, its first - reader. Drafts 05, 06a and 06b build on it. +- Batch: R1, with #1647 *Render moving stored fader automation, seeks and latency*, its first + reader. #1642, #1643 and #1644 build on it. None of these specs is amended for the timeline: each lands before this slice, and this slice adds the timeline's line to the mechanism each one built. @@ -231,5 +231,5 @@ Checked against `12d4a4f9d`. - **The browser** runs the same `SessionState` (#1562a, single instance), so D1-D7 reach it by construction; its successor (carry) path is refused `web.apply.rebuild_pending` until #1562b, so the browser half of gate 2 waits for #1562b. -- **Realtime policy** as in draft 04a's amendment (`./target/release/realtime_policy`). +- **Realtime policy** as in #1640's amendment (`./target/release/realtime_policy`). - **Dependencies.** #1285, #1316, #1320, #1323 and #1396 are still open. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/05-seek-the-session-from-the-c-abi.md b/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md similarity index 94% rename from docs/handoffs/stored-automation-1058/proposed-specs/05-seek-the-session-from-the-c-abi.md rename to .github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md index ed76030a4..2f424c6c3 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/05-seek-the-session-from-the-c-abi.md +++ b/.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md @@ -8,7 +8,7 @@ to file. Code anchors verified on `6ee64f484`. ## Product outcome A C host moves the playhead with one call. `miso_engine_v1_session_seek` (and its anchored form -`miso_engine_v1_session_seek_at`) seeks the session's timeline (drafts 04a and 04b) and every source of the +`miso_engine_v1_session_seek_at`) seeks the session's timeline (#1640 and #1641) and every source of the newest committed session to one timeline sample, under one new generation, all or nothing. Either every consumer takes the seek, in the same render block, or nothing changes and the call returns a typed refusal. The host then refills each source from that frame under that generation. Per-source @@ -26,14 +26,14 @@ seeks stay, for a stem a transaction adds; they never move the timeline. `crates/capi/src/runtime/control.rs:1546-1557`). - **The producer table.** `SourceControlSet` (`crates/host-core/src/source.rs:127-130`) holds every source producer, its region `0..frames` (`ControlSource`, `:15-26`; filled from the model, - `crates/host-core/src/prepare.rs:1231-1253`) and, after draft 04b, the timeline producer. Its + `crates/host-core/src/prepare.rs:1231-1253`) and, after #1641, the timeline producer. Its `queue_seek` (`crates/host-core/src/source.rs:233-261`) checks the region, then calls the producer's `try_seek`. Its diagnostics are one table (`SourceControlError::diagnostic`, `:75-103`): `source.generation.stale`, `source.seek.backpressure`, `source.ring.vacated`, `source.seek.anchor_unaligned`. - **Why all or nothing is possible.** Each consumer's command queue has one slot. Only the control thread pushes and render only pops, so the room a check sees cannot shrink before the push. - Draft 04a D1 gives every producer `check_seek` (every rule except the push) and `try_seek`. + #1640 D1 gives every producer `check_seek` (every rule except the push) and `try_seek`. - **The shared control plane.** *Extract the C ABI control plane into a portable crate both hosts call* (#1309) moves capi's `SessionState` (today `crates/capi/src/runtime/control.rs`) into `crates/control-plane`, and lands before batch P1, so before this slice. After *Swap and retire @@ -52,7 +52,7 @@ seeks stay, for a stem a transaction adds; they never move the timeline. render scope (`tools/audit/src/capi.rs:460-540`) and prints a `pcm_digest` that no gate pins (`.github/workflows/qualification.yml:759-779` checks only its shape). *Test held seeks across swaps and supersession, and add a seek to audit capi* (#1319, in this slice's closure through - draft 04b and #1320) adds, right after the structural transaction, + #1641 and #1320) adds, right after the structural transaction, `miso_engine_v1_source_seek_at(session, "fixture-source", 2, A, A)` with `A = 4 * QUANTUM_FRAMES` and one generation-2 quantum from frame `A`, both asserted `RESULT_OK`; call 4's audited render applies that seek (#1319 D4). @@ -62,7 +62,7 @@ seeks stay, for a stem a transaction adds; they never move the timeline. - **D1. One function, in host-core.** `SourceControlSet::seek_session(&mut self, generation: u64, timeline_sample: u64, anchor_sample: Option) -> Result<(), SourceControlError>`: 1. `generation` 0 is `GenerationZero`. - 2. Check the timeline producer and every source producer with `check_seek` (draft 04a D1) for a + 2. Check the timeline producer and every source producer with `check_seek` (#1640 D1) for a command of generation `g`, frame `min(timeline_sample, region_end)` per source (`timeline_sample` for the timeline) and the anchor. The first failure is returned and nothing is pushed: a vacant entry is `Vacated`, a generation not above that producer's is @@ -76,7 +76,7 @@ seeks stay, for a stem a transaction adds; they never move the timeline. `debug_assert`. The C ABI's `SessionState::session_seek` synchronizes the epochs and calls it on `newest_providers_mut().sources`, exactly as `seek` does. `SessionState` is in - `crates/control-plane` (#1309). The browser export (draft 06a) calls the same wrapper. + `crates/control-plane` (#1309). The browser export (#1643) calls the same wrapper. - **D2. Which consumers.** The timeline and every source of the newest committed session: while a structural transaction's candidate waits for its swap, that is the candidate's set, whose persisting producers feed the rings the running plan renders (#1273 D3). A source the candidate @@ -149,12 +149,12 @@ seeks stay, for a stem a transaction adds; they never move the timeline. ## Non-goals -- The browser export and the SDK (drafts 06a and 06b). -- The seek report's timeline row (draft 04b D6). +- The browser export and the SDK (#1643 and #1644). +- The seek report's timeline row (#1641 D6). - The declaration itself (*Reset latency floors at a host-declared discontinuity*, #1323). This slice does not call it for the host. - A loop feature. A host loops with an anchored session seek at the end of each pass (README A1.2). -- The protocol's stored transport position (draft 25 retires it). +- The protocol's stored transport position (#1635 retires it). ## Hazards @@ -164,7 +164,7 @@ seeks stay, for a stem a transaction adds; they never move the timeline. - **Pending candidate.** A test that seeks while a candidate waits must see the seek in the candidate's new rings and in the running plan's carried rings at the same block, through D2. - **The timeline frame is not clamped.** Only sources clamp to their regions; the timeline takes - `timeline_sample` as given (up to draft 04a's limit), so automation past the end of every stem + `timeline_sample` as given (up to #1640's limit), so automation past the end of every stem still follows the playhead. ## Objective gates @@ -173,7 +173,7 @@ seeks stay, for a stem a transaction adds; they never move the timeline. two-source session (sources of 10,000 and 4,000 frames), 48 kHz, quantum 128, playing. After `miso_engine_v1_session_seek(session, 2, 6_000)` and one generation-2 quantum per source from its frame, the next rendered block reads frame 6,000 from the long source, the short source is at - its end of region (frame 4,000) and the timeline reads 6,000 (draft 04b's test reader). With + its end of region (frame 4,000) and the timeline reads 6,000 (#1641's test reader). With `session_seek_at(session, 3, 1_000, A)`, `A` two quanta ahead, the timeline and both sources change at the block whose sample is `A`, not before. 2. **Stale generation changes nothing.** After gate 1, `session_seek(session, 2, 0)` returns @@ -219,16 +219,16 @@ seeks stay, for a stem a transaction adds; they never move the timeline. ## Dependencies -- Draft 04b *Give every plan a timeline that carries like a source* (it brings draft 04a). +- #1641 *Give every plan a timeline that carries like a source* (it brings #1640). - *Document the seek contract and the C ABI growth rule in the header* (#1317): this slice adds the session seek to the text #1317 writes. - *Reset latency floors at a host-declared discontinuity* (#1323), which adds `miso_engine_v1_declare_discontinuity`, named by D5's sentence and called by D6. - *Test held seeks across swaps and supersession, and add a seek to audit capi* (#1319): D6 builds - on its audit seek (also in the closure through draft 04b). + on its audit seek (also in the closure through #1641). - *Extract the C ABI control plane into a portable crate both hosts call* (#1309): the file that holds `SessionState`. It lands before batch P1. -- Batch: R1. Draft 06a *Seek the timeline and every source from the browser module export and the +- Batch: R1. #1643 *Seek the timeline and every source from the browser module export and the headless SDK* calls the same wrapper. ## Amendment (controls coordinator, 2026-10-14) diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/06a-seek-the-session-from-the-headless-engine.md b/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md similarity index 89% rename from docs/handoffs/stored-automation-1058/proposed-specs/06a-seek-the-session-from-the-headless-engine.md rename to .github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md index 811fa3a9f..1c8c9ab8e 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/06a-seek-the-session-from-the-headless-engine.md +++ b/.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md @@ -7,13 +7,13 @@ to file. Code anchors verified on `6ee64f484`. ## Product outcome -The shipped engine module exports the session seek of draft 05: one function on both hosts, with no +The shipped engine module exports the session seek of #1642: one function on both hosts, with no browser-only seek logic in Rust. It seeks the timeline and every source to one frame under one new generation, all or nothing, and every consumer applies it at the next render block. The headless SDK moves the playhead with one call, `seek(frame)`, which resolves with the generation it used; the caller then submits each source's PCM from that frame under that generation. Until a source's new PCM arrives it underruns, as after any seek today. The browser SDK and the PCM feed use this -export in draft 06b. +export in #1644. ## Context @@ -26,7 +26,7 @@ export in draft 06b. seeks into the Worker* (#1387) moves the seek to the Worker on an isolated page, and *Swap and retire browser plans through the Worker's service loop* (#1381) moves the set into the Worker's `SessionState`. Both arrive through #1293, a dependency. -- **Draft 05's function.** `SourceControlSet::seek_session(generation, timeline_sample, anchor)` +- **#1642's function.** `SourceControlSet::seek_session(generation, timeline_sample, anchor)` in host-core checks the timeline and every source producer, then pushes to all of them. `SessionState::session_seek` in `crates/control-plane` wraps it, as `seek` wraps the source seek. The C ABI calls the wrapper; this slice makes the browser call the same wrapper, as #1381 D2 makes @@ -47,7 +47,7 @@ export in draft 06b. ## Decisions frozen for this slice - **D1. The export.** `miso_engine_web_v1_session_seek(handle, generation: u64, frame: u64) -> u32`. - It calls draft 05's `SessionState::session_seek` (draft 05 D1's wrapper, which synchronizes the + It calls #1642's `SessionState::session_seek` (#1642 D1's wrapper, which synchronizes the epochs and calls `SourceControlSet::seek_session(generation, frame, None)` on the newest providers), the source routing every browser source entry point uses after #1381 D2, on the control half #1387 D2 picks (the Worker in `worker` mode, the worklet's control handler in @@ -67,7 +67,7 @@ export in draft 06b. `min(frame, source frames)`, with that generation, as after `seekSource`. - **D3. Mirrors.** The two new exports join every list in the context; the generated SDK files are regenerated with the repository's generators; every checker's self-test passes. -- **D4. The acked-batch question.** The export is draft 05's all-or-nothing function: an OK means +- **D4. The acked-batch question.** The export is #1642's all-or-nothing function: an OK means every consumer holds the seek, and a refusal changes nothing. No ack can precede a drop. ## Deliverables @@ -80,19 +80,19 @@ export in draft 06b. - `hosts/host-web/src/ffi.rs`, `hosts/host-web/src/lib.rs`, `hosts/host-web/web/` and `crates/control-plane/src/` as #1387 and #1381 leave them (the session seek export, its routing - beside the source seek's, and its call into draft 05's function only), + beside the source seek's, and its call into #1642's function only), `hosts/host-web/src/tests.rs` - `sdk/src/headless/engine.ts`, `sdk/src/core/boundary.ts`, `sdk/src/generated/abi.ts`, `sdk/assets/miso-engine-v1-abi-layout.json`, `sdk/test/capability-evals.mjs` - `scripts/check-web-audioworklet.sh` (export list only), `scripts/check-abi-layout-v1.py`, `scripts/fixtures/abi-layout-v1-self-test.json`, `scripts/test-web-audioworklet.mjs` (the mock export only), `tools/parameter-metadata/src/abi_layout.rs` -- No new path for gate 1: `hosts/host-web/src/tests.rs` reads the timeline through draft 04b's +- No new path for gate 1: `hosts/host-web/src/tests.rs` reads the timeline through #1641's host-core re-export (host-web has no `graph` dependency). ## Non-goals -- The shipped host message, the browser SDK `seek` and the PCM feed's ring rule (draft 06b). +- The shipped host message, the browser SDK `seek` and the PCM feed's ring rule (#1644). - The anchored source seek's export (#1293), which lands first. - Any change to the routing of *Move browser source submission and seeks into the Worker* (#1387), which lands first (through #1293): the session seek takes the thread the source seek takes. @@ -104,7 +104,7 @@ export in draft 06b. the merge with #1293, #1332 and #1387. - **A browser app that keeps using ring seeks** after a session seek at generation `g` makes the feed call the per-source export with `g`, which is refused as stale and stalls that ring. No - browser path calls the new export until draft 06b adds its ring rule; both merge in batch R1. + browser path calls the new export until #1644 adds its ring rule; both merge in batch R1. - **The shipped artifact changes.** Report its new digest and size in the PR; the committed pin is the release fingerprint and is not re-pinned here (`scripts/build-web-audioworklet.sh:6-10`). @@ -113,21 +113,21 @@ export in draft 06b. 1. **One block for everything** (`hosts/host-web/src/tests.rs`, new). A two-source session at quantum 128, playing. Call the `extern "C"` export with generation 2 and frame 6,000, submit one generation-2 quantum per source from its clamped frame, render one block: both sources read - their seek frames and the timeline reads 6,000 (draft 04b's test reader) in that block. A stale + their seek frames and the timeline reads 6,000 (#1641's test reader) in that block. A stale generation returns `RESULT_INVALID_ARGUMENT` and the next block equals a run without the call. One full source slot returns `RESULT_BACKPRESSURE` and nothing changes. 2. **Headless** (`sdk/test/capability-evals.mjs`, new case beside the seek case at `:162`). After `seek(4096n)` and one quantum per source at the returned generation, the next render reads frame 4,096 from a source. The SDK has no timeline reader and no stored automation renders before - draft 09b, so this case does not observe the timeline; gate 1 does, through the same export - (draft 04b's reader, re-exported by host-core). A second `seek` without + #1647, so this case does not observe the timeline; gate 1 does, through the same export + (#1641's reader, re-exported by host-core). A second `seek` without options uses the next generation. A stale explicit generation returns the typed refusal. 3. **Mirrors.** Each mirror list contains the export, and the checkers fail if one omits it. 3a. **Role and pending candidate** (`hosts/host-web/src/tests.rs`, new). Each of the two exports, called on an instance without a control half, returns `RESULT_REFUSED_LIFECYCLE` and changes nothing (as #1293 gate 5). Commit a structural transaction that adds a source through `miso_engine_web_v1_apply` (#1293), then call the session seek before the swap: at the swap - block the carried source, the added source and the timeline all read the seek's frame (draft 05 + block the carried source, the added source and the timeline all read the seek's frame (#1642 gate 4, on the browser). 4. **No rendered bit moves** for a session that never calls the new export: the browser legs of the `browser` job in `.github/workflows/qualification.yml` pass with unchanged digests. @@ -158,11 +158,11 @@ export in draft 06b. ## Dependencies -- Draft 05 *Seek the timeline and every source in one C ABI call* (its D1 function; drafts 04a and - 04b come with it). +- #1642 *Seek the timeline and every source in one C ABI call* (its D1 function; #1640 and + #1641 come with it). - *Export transaction apply and anchored seek from the browser engine module* (#1293): the anchored export's rules and its mirrors. -- Batch: R1. Draft 06b *Seek the timeline and every source from the browser SDK and the PCM feed* +- Batch: R1. #1644 *Seek the timeline and every source from the browser SDK and the PCM feed* builds on it. ## Amendment (controls coordinator, 2026-10-14) @@ -175,4 +175,4 @@ Checked against `12d4a4f9d`. - **Anchors.** `miso_engine_web_v1_source_seek` is `hosts/host-web/src/ffi.rs:3251`; the control exports of #1562a are `:3308-3383`. - **Dependencies.** #1293 (open) for the anchored export's rules; #1381a has landed, #1381 and #1387 - are open. Decision 17 C13 applies as in draft 05's amendment. + are open. Decision 17 C13 applies as in #1642's amendment. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/06b-seek-the-session-from-the-browser-sdk.md b/.github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md similarity index 96% rename from docs/handoffs/stored-automation-1058/proposed-specs/06b-seek-the-session-from-the-browser-sdk.md rename to .github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md index 99c116f3f..2ebf8cc0c 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/06b-seek-the-session-from-the-browser-sdk.md +++ b/.github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md @@ -9,13 +9,13 @@ equals `6ee64f484`'s (`git diff --name-only 6ee64f484 c63f5f37d` lists only `doc This slice is written for the browser as *Move browser source submission and seeks into the Worker* (#1387) and *Swap and retire browser plans through the Worker's service loop* (#1381) leave it. Both -land before it (through draft 06a and #1293). +land before it (through #1643 and #1293). ## Product outcome A browser app moves the playhead with one call, `engine.seek(frame)`. The SDK sends one session seek to the control half: the Worker on a cross-origin-isolated page (`worker` mode), the worklet's -control handler otherwise (`single` mode). The control half calls the module export of draft 06a and +control handler otherwise (`single` mode). The control half calls the module export of #1643 and replies with the result and the generation it used. Only after an OK does the SDK reposition every PCM producer it was given to that generation and frame. The MSB1 drain, in the realm where #1387 runs it, accepts those ring seeks without asking the engine to seek the same sources again, so no @@ -48,7 +48,7 @@ ring stalls. No suspended-context requirement is added. returns before it submits any PCM (`:341`), on every later block too. - A producer writes a ring's seek words and bumps its epoch (`Msb1RingWriter.seek`, `sdk/src/browser/pcm-ring.ts:32`), in either mode. -- **Why the drain needs a rule.** After a session seek at generation `g` (draft 06a), every source +- **Why the drain needs a rule.** After a session seek at generation `g` (#1643), every source is already at `g`. A producer that then writes `g` into its ring makes the drain call the per-source export with `g`, which is refused as `source.generation.stale`. By the code above that ring never drains again, in both realms. @@ -66,7 +66,7 @@ ring stalls. No suspended-context requirement is added. `miso.session-seek.v1 { tag, requestId, generation, frame }` (both `bigint`) with an exact field set. It takes #1387's routing for control messages (#1387 D4, #1332 D7): - `worker` mode: the message travels on the page-to-control-plane port to the Worker. The - Worker's handler calls `miso_engine_web_v1_session_seek` (draft 06a D1). + Worker's handler calls `miso_engine_web_v1_session_seek` (#1643 D1). - `single` mode: the worklet's control handler calls the same export, outside the render-locked window, between blocks. - The handler replies `miso.session-seek.v1 { tag, requestId, result, generation }` with the @@ -109,7 +109,7 @@ ring stalls. No suspended-context requirement is added. `CanonicalPcmPump.seek(frame, generation?)` implements it: with a generation it uses that one (it must be above the pump's own), without one it keeps today's increment. - **D4. The acked-batch question: can an ack ever precede a drop? No.** The export is all or - nothing (draft 05 D1), and the reply carries its result. The SDK repositions producers only after + nothing (#1642 D1), and the reply carries its result. The SDK repositions producers only after an OK, so no producer feeds a generation the engine refused. D2 consumes an epoch only for a generation the engine already applied to every source. @@ -139,7 +139,7 @@ ring stalls. No suspended-context requirement is added. ## Non-goals -- The module export, its anchored form and the headless SDK (draft 06a). +- The module export, its anchored form and the headless SDK (#1643). - Any change to #1387's routing or to its drain's other rules: this slice adds one message on that routing and one rule in that drain's seek step. - Any change to `prepareSeek`, `waitForPcmRunway` or the per-source seek's semantics. @@ -203,8 +203,8 @@ ring stalls. No suspended-context requirement is added. ## Dependencies -- Draft 06a *Seek the timeline and every source from the browser module export and the headless - SDK* (the export, which calls draft 05's D1 function). +- #1643 *Seek the timeline and every source from the browser module export and the headless + SDK* (the export, which calls #1642's D1 function). - *Move browser source submission and seeks into the Worker* (#1387): the routing of D1 and the Worker drain of D2. - *Run the browser control plane in a Worker and keep the AudioWorklet render-only* (#1332): the @@ -224,4 +224,4 @@ Checked against `12d4a4f9d`. message with no other logic. Gates 1 and 2 keep their `single` legs. - **The browser's own command path is deleted** (#1565); the session seek message is one more control message beside #1562a's. -- **Dependencies.** 06a; #1294 and #1387 are open; #1332 only for the Worker half. +- **Dependencies.** #1643; #1294 and #1387 are open; #1332 only for the Worker half. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/02-validate-builtin-automation-targets-at-preparation.md b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md similarity index 94% rename from docs/handoffs/stored-automation-1058/proposed-specs/02-validate-builtin-automation-targets-at-preparation.md rename to .github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md index 584d49bb8..20d31390b 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/02-validate-builtin-automation-targets-at-preparation.md +++ b/.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md @@ -12,7 +12,7 @@ that prepares a session: browser boot, C ABI boot and every C ABI rebuild, and ` stage `prepare-builtins`. Refused today and accepted silently: a fader ride written in `linear`, a mute that ramps, a fader ride to +30 dB, an HPF that glides out of "off", and an HPF curve that crosses the LPF on the same lane. The order check is one function of a session model, so the slice -that classifies input-filter edits on an automated strip (slice 16b) can refuse a later static edit +that classifies input-filter edits on an automated strip (#1660) can refuse a later static edit that would cross a curve with the same code. Nothing renders the table yet. ## Context @@ -52,7 +52,7 @@ that would cross a curve with the same code. Nothing renders the table yet. `effect_automation_diagnostics` on `next` whenever the automation differs, and a non-empty result returns `LiveRebuild::AutomationTarget`, so the commit takes the rebuild path whose preparation refuses. The anchors in this draft stay those of `6ee64f484`. Note A10 removes the mask one row at - a time, from slice 10 to slice 20, so until slice 20 some automation edits still skip + a time, from #1648 to #1667, so until #1667 some automation edits still skip preparation. - **Checked-in builtin automation** (`fixtures/session/v1/builtins-automation.json`, after #1626's migration): fader `both` linear `db` 0 to -3, matrix `both` linear `linear` 1 to 0.5 on a matrix @@ -91,7 +91,7 @@ that would cross a curve with the same code. Nothing renders the table yet. a hold on both sides). A side that is a constant `0` ("off") on the sub-interval leaves it unchecked; D1 keeps every `linear` and `exponential` piece away from `0`. - **Order.** On each sub-interval where both sides are enabled, with values evaluated at `a` and - at `b` by draft 07's `automation::value_at(&segments, t)` over a `Segment` table built from + at `b` by #1637's `automation::value_at(&segments, t)` over a `Segment` table built from the entry's segments (D3 there, A1.4's law; a constant is its `f32` value): - both constant: `H < L` in `f32`, the static rule; - both `linear`, or both `exponential`: `L > H·(1 + 2^-22)` at `a` and at `b`; @@ -110,7 +110,7 @@ that would cross a curve with the same code. Nothing renders the table yet. last segment). - **For a later live edit.** The function reads only the model, so a classifier can run it on a candidate model to refuse a static HPF or LPF edit that would cross an automated curve. Wiring - it into `classify_live_delta` is slice 16b's. + it into `classify_live_delta` is #1660's. - **D3. One call site.** `prepare_session_builtins_with_live_controls_and_policy` appends both functions' results before its check at `:3515`, after the per-strip preflight, so no host and no validator stage changes its own code. Both functions allocate only off render, at preparation. @@ -124,7 +124,7 @@ that would cross a curve with the same code. Nothing renders the table yet. check. A non-empty result returns #1335's `LiveRebuild::AutomationTarget`, so the commit takes the rebuild path, and that path's preparation refuses with the D1 or D2 diagnostic. Nothing is pushed or committed first. The classifier only routes; preparation words the refusal. - Once slice 20 removes the last mask row, every automation edit is a rebuild and slice 20 deletes + Once #1667 removes the last mask row, every automation edit is a rebuild and #1667 deletes this route with #1335's. - **D6. The acked-batch question** does not arise: both refusals happen before preparation allocates or anything is committed. No queue changes. @@ -152,8 +152,8 @@ that would cross a curve with the same code. Nothing renders the table yet. ## Non-goals -- Effect-target rules: draft 03a and #1335. Session-level rules: #1626. -- The SDK builder's mirror (draft 03b). The classifier route for static filter edits (slice 16b). +- Effect-target rules: #1566's row rule (`controls::automation_diagnostics`) and #1335. Session-level rules: #1626. +- The SDK builder's mirror (obsolete draft 03b, not filed). The classifier route for static filter edits (#1660). - Rendering any automation. ## Hazards @@ -167,7 +167,7 @@ that would cross a curve with the same code. Nothing renders the table yet. - **`-0.0`.** `BooleanExact` refuses the bits of `-0.0` (`contains`, `crates/builtins/src/lib.rs:303-305`); a mute segment authored with `-0.0` is refused, as a static mute of `-0.0` already is. - **Evaluation code.** D2 needs A1.4's value at two samples. It calls the evaluator crate of - draft 07 (`automation::value_at`), which lands first, so one law exists from the start. The check + #1637 (`automation::value_at`), which lands first, so one law exists from the start. The check needs only ordering, and its `2^-22` margin dwarfs any `f64` evaluation error. ## Objective gates @@ -236,7 +236,7 @@ that would cross a curve with the same code. Nothing renders the table yet. - #1626 *Validate stored automation lanes in the session crate and state the hold rule* (the hold rule, the migrated fixture, the strip-kind rule). -- Draft 07 *Compile stored automation into per-cell events in node time*: `value_at`, the one +- #1637 *Compile stored automation into per-cell events in node time*: `value_at`, the one evaluation law D2 calls. - *Refuse automation on effect parameters that are not block-rate* (#1335): D5 uses its `LiveRebuild::AutomationTarget` route, which is on `main` at `0c19119d0`. @@ -252,12 +252,12 @@ Checked against `12d4a4f9d`. `builtin.automation.unit` and `.domain` codes are not added. - **What remains, in `controls`.** The shape rule (D1's third bullet) and the curve form of the filter order rule (D2) move into `controls::automation_diagnostics`, the one row rule (#1566 D6), - not into `builtins-compiler`. The shape rule is one rule for every row, so it also takes draft - 03a's: a `Toggle` or `Enumeration` row takes only `step`; `exponential` only on a continuous row + not into `builtins-compiler`. The shape rule is one rule for every row, so it also takes obsolete + draft 03a's (not filed): a `Toggle` or `Enumeration` row takes only `step`; `exponential` only on a continuous row whose minimum is strictly positive and whose unit is not dB; on a row with a `disabled_value` (HPF, LPF) a `linear` or `exponential` segment never touches it. New codes of the control set: `control.automation.shape` and `control.automation.order` (at the LPF entry's segment, as D2 - says). `controls` gains a normal dependency on draft 07's `automation` crate for `value_at`. + says). `controls` gains a normal dependency on #1637's `automation` crate for `value_at`. - **D3 and D5 are done.** Preparation (`crates/host-core/src/prepare.rs:1890`), the classifier (`crates/host-core/src/live_delta.rs:372-376`, `LiveRebuild::AutomationTarget`) and `session-validator` (`tools/session-validator/src/lib.rs:396`) already call the row rule, so the @@ -270,5 +270,5 @@ Checked against `12d4a4f9d`. - **Context anchors gone.** `validate_builtin_automation_target`, `BUILTIN_AUTOMATION_TARGETS`, `effect_automation_diagnostics` and `crates/capi/src/runtime/control.rs` (now `crates/control-plane/src/control.rs`). -- **Dependencies.** #1626, 07, #1566 (in place of #1335's route). Draft 03a is obsolete; this draft +- **Dependencies.** #1626, #1637, #1566 (in place of #1335's route). Obsolete draft 03a is not filed; this spec carries its shape rule. Batch R1. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/09a-prepare-stored-fader-automation.md b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md similarity index 86% rename from docs/handoffs/stored-automation-1058/proposed-specs/09a-prepare-stored-fader-automation.md rename to .github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md index e42ff9d2f..45e640174 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/09a-prepare-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md @@ -12,12 +12,12 @@ session's `automation` table into a cell program on the strip's fader bank, char the graph plan rows, and starts the lane at its prepared value: the curve at the fader's node time at render sample 0. A flat curve at `v` therefore renders the bits of a static fader at `v`, on both hosts and through any VCA composition. A session with no stored automation prepares and -renders exactly as before. Moving curves, seeks and latency in render are draft 09b, in the same +renders exactly as before. Moving curves, seeks and latency in render are #1647, in the same batch and push. This slice lands after *Admit browser live edits in the Worker through the committed model* (#1382). From then on every browser live edit goes through the shared commit, so the browser and -the C ABI meet one rule for a live edit of an automated lane (draft 10), and no host-only +the C ABI meet one rule for a live edit of an automated lane (#1648), and no host-only admission path remains that could write a live record onto an automated lane. ## Context @@ -51,8 +51,8 @@ admission path remains that could write a live record onto an automated lane. (`crates/lane/src/fpenv.rs:287-290`; the wasm form `:369-372` changes nothing), entered at `crates/host-core/src/render_session.rs:111`; preparation runs in the host's environment (README finding F11). -- **What this slice builds on.** #1626 and draft 02 (one entry per lane, the hold rule, the fader's - unit and domain), 07 (the builder, its byte report and the jump-length key), 08 (`SetGain`), 12 +- **What this slice builds on.** #1626 and #1645 (one entry per lane, the hold rule, the fader's + unit and domain), #1637 (the builder, its byte report and the jump-length key), #1638 (`SetGain`), #1639 (the plan cell that holds the jump lengths, seeded from #1054's `control_smoothing_samples()`). #1285 D2 records each node's floored input arrival `a(n)`. #1312 gives the latest-target cell (`crates/engine/src/realtime/latest_cell.rs`, #1312 D1); its cells hold two words (fader, @@ -63,52 +63,52 @@ admission path remains that could write a live record onto an automated lane. ## Decisions frozen for this slice - **D1. Cells.** Host-core preparation compiles one cell per automated fader lane (target rack - `builtins`, parameter 5; `both` gives two cells) with draft 07's builder: + `builtins`, parameter 5; `both` gives two cells) with #1637's builder: - `a(n)` is the floored input arrival of the strip's `PostFader` node (#1285 D2); - - grid period 64, grid ramp 64, completion `End`; jump-length key `Word(Fader)` (draft 07 D2): - each jump reads the `fader` word of draft 12's plan cell for its block, so the program stores + - grid period 64, grid ramp 64, completion `End`; jump-length key `Word(Fader)` (#1637 D2): + each jump reads the `fader` word of #1639's plan cell for its block, so the program stores no jump length; no `no_restart`; - **the offsets cell** (layout 4 of README finding F16). Preparation builds - draft 11's offsets cell for every automated fader lane, in every session, with or without + #1649's offsets cell for every automated fader lane, in every session, with or without VCAs: a #1312 latest-target cell of three words, the lane's offset sum `S` as one `f64` (two - `u32` words) and one `u32` ramp word that draft 11 writes (the length of the jump a VCA edit + `u32` words) and one `u32` ramp word that #1649 writes (the length of the jump a VCA edit asks for; seeded with the session's fader length, #1054). `S` is `vca_offsets_sum(reach offsets)` (D6) for the lane's channel, and `+0.0` when no VCA reaches the lane. A value `v` becomes the gain `checked_fader_gain(vca_compose_db(v, S))` (D6), with `S` from the cell's newest slot. No static copy of `S` is kept in the program, so one source holds - it; draft 11 adds only the live VCA edit that rewrites the cell. Because the cell exists on + it; #1649 adds only the live VCA edit that rewrites the cell. Because the cell exists on every automated fader lane, a VCA that a later edit adds or attaches only rewrites it. - **Why the cell keeps the bits.** With a reach, D6 makes the static path compute `vca_compose_db(member, S)` with the same `S`, so the bits are equal by construction. With no reach, the static path returns `v` unchanged, and render computes `clamp(f64(v) + (+0.0))`. In `f64`, `v + (+0.0) == v` for every `v` except `-0.0`, which becomes `+0.0`; `v` lies in - `[-144, 24]` (draft 02), so the clamp changes nothing. `db_gain(+0.0)` and `db_gain(-0.0)` are - both exactly 1 (`math::pow(10, ±0) = 1`), so the gain has the static bits in every case. Draft - 07's "change only" compares the curve value, before the offsets, and draft 11 D3 compares the + `[-144, 24]` (#1645), so the clamp changes nothing. `db_gain(+0.0)` and `db_gain(-0.0)` are + both exactly 1 (`math::pow(10, ±0) = 1`), so the gain has the static bits in every case. + #1637's "change only" compares the curve value, before the offsets, and #1649 D3 compares the composed gain, never the dB sum, so a sign of zero never causes a retarget. - - **Where the event state lives.** Each cell's event state (draft 07's `CellState`) lives in the + - **Where the event state lives.** Each cell's event state (#1637's `CellState`) lives in the fader bank stage beside the lane's ramp state, keyed by the cell's stable address (strip, row 5, channel). So the stage's carry program (*Carry fader, mute and pan ramps across a plan - swap*, #1277) moves it with the ramp state at a swap, and no second carry path exists. Draft 10 + swap*, #1277) moves it with the ramp state at a swap, and no second carry path exists. #1648 relies on that carry. The program is installed on the strip's fader bank processor (or the scalar track's), as immutable plan data. - **D2. Prepared value.** After the graph compile and before publication, each automated lane is - set exactly (draft 08 `SetGain`, off the render thread) to the curve at node time `-a(n)`, + set exactly (#1638 `SetGain`, off the render thread) to the curve at node time `-a(n)`, composed as in D1. By the hold rule the curve at any `t <= first.start_sample` is the first `start_value`, and every `start_sample >= 0`, so `curve(-a(n)) == curve(0)` always: the prepared value is the entry's first `start_value`, whatever `a(n)` is (README A1.4, "New lanes"). A lane whose curve is flat at `v` is therefore set to the bits a static `v` prepares. Every curve evaluation and conversion of preparation runs inside `CanonicalFpEnv`, as render's do (README - A1.1). A conversion that fails is unreachable (draft 02 + A1.1). A conversion that fails is unreachable (#1645 bounds the curve to the fader domain and `vca_compose_db` clamps, D6) and refuses preparation with `host.automation.fader`. - **D3. Mono collapse is kept.** The fader is seam-side (Context), so a fader automation that drives one lane, or both lanes with different curves, keeps the track's collapse. This refines the README's mono-collapse rule (A1.5), which holds for rows upstream of the seam: trim, polarity, - HPF, LPF and strip effects (drafts 15, 16a-16b, 18a-18b). Nothing is added to + HPF, LPF and strip effects (#1658, #1659-#1660, #1663-#1664). Nothing is added to `track_mono_source`. -- **D4. Bytes.** Each fader program's bytes (builder report, draft 07 D5) and each offsets cell's +- **D4. Bytes.** Each fader program's bytes (builder report, #1637 D5) and each offsets cell's bytes (three slots of three 4-byte words, 36 bytes, plus the cell's fixed words) are charged as builtin bank payload through `checked_add_builtin_banks`, so they reach `builtin_bank_bytes`, `graph_incremental_plan_bytes` and `graph_session_plus_plan_bytes`, the caller's @@ -171,29 +171,29 @@ admission path remains that could write a live record onto an automated lane. `docs/SESSION_SCHEMA_V1.md` (the composition sentence at `:80` only) - Tests that pin a builtin bank or graph plan row that D4 moves for a session with automation (none exists today; a session without automation moves no row) -- `crates/host-core/Cargo.toml` (a normal `automation` dependency: preparation runs draft 07's +- `crates/host-core/Cargo.toml` (a normal `automation` dependency: preparation runs #1637's builder), `Cargo.lock` ## Non-goals -- Events in render: the grid, jumps, held events, seeks, the timed chain call (draft 09b). -- The documents that say the fader row renders (draft 09b). +- Events in render: the grid, jumps, held events, seeks, the timed chain call (#1647). +- The documents that say the fader row renders (#1647). - Classifying fader automation edits, the rule for a live edit of an automated lane, and carrying - event state across a swap (draft 10). -- The live VCA move that rewrites the offsets cell (draft 11). The plan cell of jump lengths and - its edit path (draft 12). -- Mute, pan, matrix, input and effect rows (drafts 13a to 20). + event state across a swap (#1648). +- The live VCA move that rewrites the offsets cell (#1649). The plan cell of jump lengths and + its edit path (#1639). +- Mute, pan, matrix, input and effect rows (#1651 to #1667). ## Hazards -- **Batch R1, one push with drafts 09b, 10 and 11** (README "Must-land-together groups"). Alone, - this slice starts a moving curve at its prepared value and holds it there; draft 09b renders the - motion, draft 10 stops a live fader edit from fighting the curve, and draft 11 makes a VCA ride +- **Batch R1, one push with #1647, #1648 and #1649** (README "Must-land-together groups"). Alone, + this slice starts a moving curve at its prepared value and holds it there; #1647 renders the + motion, #1648 stops a live fader edit from fighting the curve, and #1649 makes a VCA ride heard on an automated member. The four merge in one push, so `main` never holds a partial state. - **Host-core is stream A's file** (`crates/host-core/src/prepare.rs`); root orders the merge after #1285 and #1312. - **Bit-identity with VCAs.** D6's one rule serves the static path and the cell, so `S` in the - cell is the static path's own sum; draft 11's live edit computes it with the same function, so + cell is the static path's own sum; #1649's live edit computes it with the same function, so it keeps the same property. - **A pinned order changes** (D6). The amendment to #1242's documented order, its pinned test and the three oracles of D6 land in this slice, together, so `main` never holds a test that pins or @@ -268,10 +268,10 @@ admission path remains that could write a live record onto an automated lane. ## Dependencies - #1626 *Validate stored automation lanes in the session crate and state the hold rule*. -- Draft 02 *Validate builtin automation targets against their rows at preparation*. -- Draft 07 *Compile stored automation into per-cell events in node time*. -- Draft 08 *Apply timed operations inside a block on the strip fader stage*. -- Draft 12 *Hold the automation jump lengths in a plan cell*: the `fader` word the jump-length key +- #1645 *Validate builtin automation targets against their rows at preparation*. +- #1637 *Compile stored automation into per-cell events in node time*. +- #1638 *Apply timed operations inside a block on the strip fader stage*. +- #1639 *Hold the automation jump lengths in a plan cell*: the `fader` word the jump-length key reads. - *Session controlSmoothing: configurable ramp lengths for live mute, fader and pan changes* (#1054): the fader jump length. @@ -284,8 +284,8 @@ admission path remains that could write a live record onto an automated lane. edit goes through the shared commit before any host renders stored automation. #1382 brings the shared classifier rows of #1225, #1226, #1247, #1261, #1262 and #1390 and the cells of #1345, #1346 and #1347, which the later drafts build on. -- Batch: R1, in one push with drafts 09b *Render moving stored fader automation, seeks and - latency*, 10 *Classify fader automation edits as carried rebuilds* and 11 *Compose VCA offsets +- Batch: R1, in one push with #1647 *Render moving stored fader automation, seeks and + latency*, #1648 *Classify fader automation edits as carried rebuilds* and #1649 *Compose VCA offsets with stored fader automation*. @@ -302,8 +302,8 @@ Checked against `12d4a4f9d`. `reference_db`, `:37`); the browser oracles of D6 were deleted with the browser's own path (#1565). The oracles to rewrite are `crates/controls/tests/composition.rs` and `crates/host-core/tests/vca.rs:271`. Authorized paths change to these files. -- **The hand offset joins `S`** (OQ1 = C, slice 11b D6): `S = offset + Σ reaching VCA offsets`. - This slice seeds `S` with the stored offset; 11b adds the field and the write. +- **The hand offset joins `S`** (OQ1 = C, #1650 D6): `S = offset + Σ reaching VCA offsets`. + This slice seeds `S` with the stored offset; #1650 adds the field and the write. - **Live lanes on every plan** (C6): every strip already has its `FaderCells` (`crates/builtins-compiler/src/lib.rs:298`); the offsets cell is an extra cell for automated lanes only. @@ -311,5 +311,5 @@ Checked against `12d4a4f9d`. #1312, #1054 and #1309 have landed; #1285 is open. - **The browser.** #1382 is closed; its role is #1562. #1562a (the browser commits through `SessionState`) has landed; #1562b (the browser's rebuild path) is a dependency of batch R1, - because every fader automation edit is a rebuild after draft 10. -- **Must-land group** is 09a, 09b, 10, 11 and 11b. + because every fader automation edit is a rebuild after #1648. +- **Must-land group** is #1646, #1647, #1648, #1649 and #1650. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/09b-render-moving-stored-fader-automation.md b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md similarity index 74% rename from docs/handoffs/stored-automation-1058/proposed-specs/09b-render-moving-stored-fader-automation.md rename to .github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md index c010e4f88..1dab9f576 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/09b-render-moving-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md @@ -8,12 +8,12 @@ to file. Code anchors verified on `6ee64f484`. ## Product outcome A producer's fader ride is heard on every platform. On the C ABI and in the browser, each automated -fader lane prepared by draft 09a follows its curve in node time: it reaches the curve's value at +fader lane prepared by #1646 follows its curve in node time: it reaches the curve's value at each ramp's completion sample on a fixed 64-sample grid, takes each discontinuity at its exact sample over the session's fader ramp length, lines up with the audio it acts on through every latent insert, and is set exactly where a session seek reaches the fader. The output does not depend on the quantum or on where a seek landed. A session with no stored automation renders -exactly as before. The fader row leaves the classifier's mask in draft 10, in the same batch. +exactly as before. The fader row leaves the classifier's mask in #1648, in the same batch. ## Context @@ -22,44 +22,44 @@ exactly as before. The fader row leaves the classifier's mask in draft 10, in th processor does the same and fuses only when nothing ramps (`:1198-1219`). Graph runs a strip's builtin chain with the block's first sample (`crates/graph/src/runtime.rs:3046-3048`) through `GraphPreparedBuiltinBankProcessor::process` (`crates/graph/src/lib.rs:1347-1353`). The graph - executor calls the source set at block entry (`crates/graph/src/lib.rs:3254-3256`), where draft 04b + executor calls the source set at block entry (`crates/graph/src/lib.rs:3254-3256`), where #1641 advances the timeline. -- **What draft 09a leaves.** Each automated lane has a cell program (draft 07) with its `a(n)`, its - grid and its jump-length key (the `fader` word of draft 12's plan cell), an offsets cell (the lane's - VCA offset sum `S`, `+0.0` when no VCA reaches it; draft 09a D1), and its event state in the fader bank stage. It starts at its prepared value, the +- **What #1646 leaves.** Each automated lane has a cell program (#1637) with its `a(n)`, its + grid and its jump-length key (the `fader` word of #1639's plan cell), an offsets cell (the lane's + VCA offset sum `S`, `+0.0` when no VCA reaches it; #1646 D1), and its event state in the fader bank stage. It starts at its prepared value, the curve at node time `-a(n)`, which is the entry's first `start_value`. -- **What draft 12 leaves.** The graph reads the plan's jump-length cell once at block entry and holds +- **What #1639 leaves.** The graph reads the plan's jump-length cell once at block entry and holds its three words for the block; no stage reads them yet. - **The documents.** `docs/SESSION_SCHEMA_V1.md:220-225` says the table renders nothing; the comments at `crates/session/src/validate.rs:812-822` and `:842-853` say the same; `docs/REALTIME_MEMORY.md:13` gives #1058 the rendering. -- **What this slice builds on.** Drafts 04a and 04b (the timeline and its reader), 05 and 06a (the session - seek on each host), 07 (events), 08 (operations inside a block, the held-gain rule). +- **What this slice builds on.** #1640 and #1641 (the timeline and its reader), #1642 and #1643 (the session + seek on each host), #1637 (events), #1638 (operations inside a block, the held-gain rule). ## Decisions frozen for this slice - **D1. Render.** Per block, after the live drain at block entry, the fader bank processor: 1. reads, for each automated lane, its node-time mapping: graph passes the block's source-read - sample and a timeline reader (draft 04a) to the strip chain through a new defaulted method on + sample and a timeline reader (#1640) to the strip chain through a new defaulted method on `GraphPreparedBuiltinBankProcessor` (`process_timed`, which forwards to `process` by default), - and the processor reads `timeline_block(s0 - a(n))` into draft 07's `NodeSpan`. The same call - passes the block's jump lengths, the three words draft 12's read gives the graph for that - block, which each cell's events resolve its `Word(Fader)` key against (draft 07 D4); + and the processor reads `timeline_block(s0 - a(n))` into #1637's `NodeSpan`. The same call + passes the block's jump lengths, the three words #1639's read gives the graph for that + block, which each cell's events resolve its `Word(Fader)` key against (#1637 D4); 2. tells each cell the end of a ramp in flight it did not start (a live mute ramp) through - `hold_until`, from the stage's `remaining` (draft 08 D2); - 3. pulls each lane's events (draft 07, `SeekMode::Exact`) and merges them by offset across lanes + `hold_until`, from the stage's `remaining` (#1638 D2); + 3. pulls each lane's events (#1637, `SeekMode::Exact`) and merges them by offset across lanes with a fixed per-lane cursor (no buffer); 4. converts each event value: `Grid` and `Jump` become `RetargetGain`, a held event - `RememberGain`, `Set` `SetGain` (draft 08 D1), each gain composed as draft 09a D1; - 5. runs `process_with` (draft 08 D3). The fused fader-and-matrix path is taken only when no + `RememberGain`, `Set` `SetGain` (#1638 D1), each gain composed as #1646 D1; + 5. runs `process_with` (#1638 D3). The fused fader-and-matrix path is taken only when no fader or matrix lane moves in the block. - A gain the conversion refuses is unreachable (draft 02 bounds the curve to the fader domain and - `vca_compose_db` clamps, draft 09a D6); it keeps the current target, adds one to a render counter and fails a + A gain the conversion refuses is unreachable (#1645 bounds the curve to the fader domain and + `vca_compose_db` clamps, #1646 D6); it keeps the current target, adds one to a render counter and fails a `debug_assert`. -- **D2. First blocks.** A fresh plan starts every automated lane at its prepared value (draft 09a +- **D2. First blocks.** A fresh plan starts every automated lane at its prepared value (#1646 D2), so its first block emits no `Set`. A lane a successor adds or restarts takes a `Set` at its first block when the curve at that block's node time differs from its prepared bits (README A1.4, - "New lanes"). Carrying a lane's event state across a swap is draft 10's. + "New lanes"). Carrying a lane's event state across a swap is #1648's. - **D3. Seeks.** A session seek reaches the fader where its node time steps; the cell emits `Set` there and the lane takes the curve's value exactly, with no ramp (README A1.4). - **D4. Docs.** `docs/SESSION_SCHEMA_V1.md:220-225`: the fader row (5) renders; every other row is @@ -86,26 +86,26 @@ exactly as before. The fader row leaves the classifier's mask in draft 10, in th (gate 4 on the C ABI) - `docs/SESSION_SCHEMA_V1.md`, `docs/REALTIME_MEMORY.md`, `crates/session/src/validate.rs` (comments only) -- `crates/source/src/lib.rs` (the source driver's `timeline_block` reader only: draft 04b D8 adds +- `crates/source/src/lib.rs` (the source driver's `timeline_block` reader only: #1641 D8 adds only `timeline_at` and leaves the render-side block reader to this slice) ## Non-goals -- Preparation, prepared values, bytes and the mono collapse (draft 09a). +- Preparation, prepared values, bytes and the mono collapse (#1646). - Classifying fader automation edits, the rule for a live edit of an automated lane, and carrying - event state across a swap (draft 10). -- The live VCA move that rewrites the offsets cell (draft 11). The `control_smoothing` edit path - that writes the jump-length cell (draft 12). -- Mute, pan, matrix, input and effect rows (drafts 13a to 20). The live path's held-gain rule (#1054 + event state across a swap (#1648). +- The live VCA move that rewrites the offsets cell (#1649). The `control_smoothing` edit path + that writes the jump-length cell (#1639). +- Mute, pan, matrix, input and effect rows (#1651 to #1667). The live path's held-gain rule (#1054 D10). -- A benchmark (drafts 24a and 24b). +- A benchmark (#1671 and #1672). ## Hazards -- **One push with drafts 09a, 10 and 11** (README "Must-land-together groups"). Until draft 10 is +- **One push with #1646, #1648 and #1649** (README "Must-land-together groups"). Until #1648 is in, a rebuild for another reason sets each automated lane exactly at adoption (D2), and a live - fader edit on an automated lane, on either host, retargets it until the next event. Until draft - 11 is in, an acknowledged VCA ride on an automated member is not heard. The four merge in one + fader edit on an automated lane, on either host, retargets it until the next event. Until + #1649 is in, an acknowledged VCA ride on an automated member is not heard. The four merge in one push, so `main` never holds these states. - **The chain call is shared.** `process_timed` defaults to `process`, so every other builtin bank is untouched; only the fader bank processor and the scalar track override it. @@ -123,15 +123,15 @@ exactly as before. The fader row leaves the classifier's mask in draft 10, in th with `control_smoothing` explicit 0 the output changes exactly at sample 1,000; with the default table the ramp starts at sample 1,000 and ends at `1,000 + fader length - 1`. **The next jump, not the one in flight:** steps at timeline 1,000 and 3,000, fader length 480; a test-support - write of 960 to draft 12's cell at a block entry while the first jump ramps: that jump completes - over 480 and the second ramps over 960, bit-identical to the stage driven directly with draft - 08's operations at those offsets and lengths. + write of 960 to #1639's cell at a block entry while the first jump ramps: that jump completes + over 480 and the second ramps over 960, bit-identical to the stage driven directly with + #1638's operations at those offsets and lengths. 3. **Latency.** Two tracks on one source, the first with a latent insert before the fader (a true-peak limiter, latency `λ`), each with a fader step at timeline 4,800: the step's event is at render sample `4,800 + a(n)` on each track's fader (a test-support event probe), so the two events are `λ` apart, and in the output both steps meet the source frame 4,800 they act on. 4. **Session seek.** After a session seek to `T` that reaches the fader at render sample `r`, the - lane equals `checked_fader_gain(curve(T))` exactly from `r`, with no ramp: through draft 04b's + lane equals `checked_fader_gain(curve(T))` exactly from `r`, with no ramp: through #1641's producer in host-core, and through `miso_engine_v1_session_seek` in `crates/capi/src/runtime/tests.rs`. 5. **Quantum invariance.** The same session at quantum 128 and at quantum 100 gives the same fader @@ -171,15 +171,15 @@ exactly as before. The fader row leaves the classifier's mask in draft 10, in th ## Dependencies -- Draft 09a *Prepare stored fader automation and render it flat*, in the same push. It brings - drafts 07, 08 and 12 and #1312 (the events, the timed operations, the jump-length cell and the +- #1646 *Prepare stored fader automation and render it flat*, in the same push. It brings + #1637, #1638 and #1639 and #1312 (the events, the timed operations, the jump-length cell and the live input D1 drains first). -- Draft 04b *Give every plan a timeline that carries like a source*. -- Draft 05 *Seek the timeline and every source in one C ABI call* and draft 06a *Seek the timeline +- #1641 *Give every plan a timeline that carries like a source*. +- #1642 *Seek the timeline and every source in one C ABI call* and #1643 *Seek the timeline and every source from the browser module export and the headless SDK* (the session seek on each host). -- Batch: R1, in one push with drafts 09a, 10 *Classify fader automation edits as carried - rebuilds* and 11 *Compose VCA offsets with stored fader automation*. +- Batch: R1, in one push with #1646, #1648 *Classify fader automation edits as carried + rebuilds* and #1649 *Compose VCA offsets with stored fader automation*. ## Amendment (controls coordinator, 2026-10-14) @@ -189,5 +189,5 @@ Checked against `12d4a4f9d`. `:842-853`) left with `BUILTIN_AUTOMATION_TARGETS` (#1566); the inert sentence is now in `docs/SESSION_SCHEMA_V1.md` near `:278-279` and in `validate_automation_target`'s rustdoc (`validate.rs:868-870`). D4 rewrites those. -- **Composition.** Each event's gain is draft 09a D1's with `S` that includes the hand offset (11b). -- **Must-land group** is 09a, 09b, 10, 11 and 11b. +- **Composition.** Each event's gain is #1646 D1's with `S` that includes the hand offset (#1650). +- **Must-land group** is #1646, #1647, #1648, #1649 and #1650. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/10-classify-and-carry-fader-automation-edits.md b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md similarity index 87% rename from docs/handoffs/stored-automation-1058/proposed-specs/10-classify-and-carry-fader-automation-edits.md rename to .github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md index 6582a3ee1..2f93e02f6 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/10-classify-and-carry-fader-automation-edits.md +++ b/.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md @@ -9,7 +9,7 @@ to file. Code anchors verified on `6ee64f484`. On both hosts, a transaction that adds, changes or removes stored fader automation on a playing engine takes the rebuild path. Both hosts run the shared commit of `crates/control-plane`: the C -ABI directly, the browser in its Worker (#1382, which lands before draft 09a). The successor plan holds the new automation program, every owner +ABI directly, the browser in its Worker (#1382, which lands before #1646). The successor plan holds the new automation program, every owner carries, the fader lane continues from the value it has, and the revision completes `exact` at adoption. The response reports `rebuild`. A transaction that changes only the static fader value of a lane the session automates commits as `model_only`: that value is the lane's fallback, and no @@ -20,7 +20,7 @@ both hosts (README A2). Every other automation row stays model-only and inert until its own rendering slice. Today every automation edit is "live with no records": it commits with no plan change, so after -drafts 09a and 09b render fader automation, an edit would be acked and never heard. +#1646 and #1647 render fader automation, an edit would be acked and never heard. ## Context @@ -56,10 +56,10 @@ drafts 09a and 09b render fader automation, an edit would be acked and never hea - **D1. The mask becomes per target row.** - One function in host-core, `automation_row_renders(target: &AutomationTarget) -> bool`, names the rows that render. In this slice it is true only for `rack = builtins`, `parameter_id = 5`. - Draft 09a's preparation compiles cells only for the targets it accepts, so the classifier and - preparation read one list. Each later rendering slice adds its row; draft 20 adds the last. + #1646's preparation compiles cells only for the targets it accepts, so the classifier and + preparation read one list. Each later rendering slice adds its row; #1667 adds the last. - The masked JSON comparison keeps copying `current.automation` (`:234-236`). A new step, after - step 4, #1335 D4's `AutomationTarget` check (which draft 02 D5 extends to the builtin rules), + step 4, #1335 D4's `AutomationTarget` check (which #1645 D5 extends to the builtin rules), and before the per-track step, compares the entries of rendered rows in `current` and `next`, in table order, by canonical bytes. Any difference returns a new `LiveRebuild::Automation`. The numbered rules and the model-only paragraph (`:181-185`) are rewritten to say so. @@ -67,13 +67,13 @@ drafts 09a and 09b render fader automation, an edit would be acked and never hea - **The stage.** This slice extends the carry predicate of #1277 D4 (which lands first): gaining, losing or changing stored automation is neither a prepared value nor a change of control kind, so the fader stage carries. #1277 itself is not amended. - - **The cell.** A cell's whole event state (draft 07 D2: cursor, current target bits, in-flight + - **The cell.** A cell's whole event state (#1637 D2: cursor, current target bits, in-flight ramp end, held grid, last seek boundary) carries by its stable address `(strip ID, parameter ID, - lane)`. Draft 09a D1 keeps that state in the fader bank stage beside the lane's ramp state, so + lane)`. #1646 D1 keeps that state in the fader bank stage beside the lane's ramp state, so the fader stage's carry program under #1277 moves it, with the ramp state, at the swap block in move mode (#1277 D8). The stage's inventory rows gain the cell addresses, and the join pairs them by address. No second carry program exists for cell state. - - **At adoption.** The successor repositions each carried cursor in its own table (draft 07 D4). + - **At adoption.** The successor repositions each carried cursor in its own table (#1637 D4). If the new curve's value at the adoption block's first sample differs in bits from the carried current target, the cell takes a jump there, over the fader jump length. - **A cell gained.** A lane that gains automation on a carried stage starts its cell with the @@ -86,11 +86,11 @@ drafts 09a and 09b render fader automation, an edit would be acked and never hea record is written: render alone knows the carried target. - **D3. A static fader edit on an automated lane gives no record** (A2). - One predicate, `automated_cell(model: &SessionModel, address: CellAddress) -> bool`, built on - the same enumeration draft 09a's preparation compiles cells from. The later slices reuse it. + the same enumeration #1646's preparation compiles cells from. The later slices reuse it. - The per-track step (step 5) emits no `FaderDb` for a lane that `next` automates. After D1, `current` automates the same lanes. Since *Deliver value-only VCA edits to the running C ABI plan* (#1247) D2 that step diffs the effective faders, so the rule covers a VCA move that - reaches an automated lane too; draft 11 makes that move reach the lane's offsets cell. Mute + reaches an automated lane too; #1649 makes that move reach the lane's offsets cell. Mute records are unchanged. A transaction that changes only such a value yields an empty delta and commits `model_only` (#1313 D2); D2's release cell reads the value when the automation goes. - **One rule, both hosts.** The rule lives in the shared classifier, which both hosts' commits @@ -121,26 +121,26 @@ drafts 09a and 09b render fader automation, an edit would be acked and never hea `crates/host-core/tests/successor_swap.rs` - `crates/graph/src/lib.rs`, `crates/graph/src/runtime.rs`, `crates/builtins-compiler/src/lib.rs` (the fader stage carry's move of cell event state only; stream A's files, sequenced by root) -- `crates/control-plane/src/` (the commit path; #1309 is on `main` through draft 09a's +- `crates/control-plane/src/` (the commit path; #1309 is on `main` through #1646's dependency), `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs` (gate 5) - `crates/capi/include/miso_engine_v1.h` (comment text), `docs/CONTROL_PROTOCOL_SEMANTICS.md`, `docs/C_ABI_V1_QUALIFICATION.md` ## Non-goals -- Rendering fader automation (drafts 09a and 09b). - VCA offsets on an automated member (draft 11). The jump length cell (draft 12). Mute, pan, matrix, - trim, polarity, filter and effect rows (drafts 13a to 20). -- Options B, C and D of OQ1. The browser's Worker commit itself (#1382, which lands before draft - 09a). +- Rendering fader automation (#1646 and #1647). + VCA offsets on an automated member (#1649). The jump length cell (#1639). Mute, pan, matrix, + trim, polarity, filter and effect rows (#1651 to #1667). +- Options B, C and D of OQ1. The browser's Worker commit itself (#1382, which lands before + #1646). ## Hazards - **Superseded cases.** The "automation upsert" and removal cases named in Context now rebuild. This slice moves them to a parametric EQ band-gain entry, a valid `Block` target whose row stays - masked until draft 20, the last row to leave the mask. So no slice between 10 and 20 touches - them, and draft 20, after which every automation edit is a rebuild, deletes them. -- **Order of the two automation checks.** Draft 02 D5 routes a refusable builtin entry to + masked until #1667, the last row to leave the mask. So no slice between #1648 and #1667 touches + them, and #1667, after which every automation edit is a rebuild, deletes them. +- **Order of the two automation checks.** #1645 D5 routes a refusable builtin entry to `LiveRebuild::AutomationTarget`. D1's check runs after it, so a refusable fader entry keeps that variant and its refusal; gate 1 holds the order. - **A redundant jump moves bits.** The adoption rule compares bits; a jump to the same target @@ -153,13 +153,13 @@ drafts 09a and 09b render fader automation, an edit would be acked and never hea `Ok` with no records. A static `left_db` change on a lane a `left` fader entry automates gives `Ok` with no records; the same change on the right lane gives one `FaderDb` for `Right`. A static change together with an automation change gives `Automation`. A fader entry in unit - `linear` gives `AutomationTarget` (draft 02 D5), not `Automation`. + `linear` gives `AutomationTarget` (#1645 D5), not `Automation`. 2. **A changed curve rebuilds and carries** (`crates/capi/src/runtime/live_tests.rs`, new). A playing `long_session` with a linear fader ride on track 0 takes an `UpsertAutomation` that changes the ride. The response path is `rebuild`; the watermark publishes `EXACT` at adoption (#1314); no source is reseeked. From the completion sample of the first grid ramp that starts after the adoption jump ends, every block is bit-identical to a fresh plan of the new session, - session-seeked (draft 05) to the adoption block's timeline sample and fed the same PCM. + session-seeked (#1642) to the adoption block's timeline sample and fed the same PCM. 3. **An unchanged curve gives no jump** (same file). A transaction that adds a muted track and keeps the ride: every block from the swap is bit-identical to the uninterrupted render of the old session. @@ -206,18 +206,18 @@ drafts 09a and 09b render fader automation, an edit would be acked and never hea ## Dependencies -- Draft 09b *Render moving stored fader automation, seeks and latency* (same batch). It brings - draft 09a's cells and event state, draft 07's reposition, draft 05's session seek (gate 2's - reference) and draft 02's D5 route (which runs first). +- #1647 *Render moving stored fader automation, seeks and latency* (same batch). It brings + #1646's cells and event state, #1637's reposition, #1642's session seek (gate 2's + reference) and #1645's D5 route (which runs first). - *Carry fader, mute and pan ramps across a plan swap* (#1277): the stage carry D2 extends. - *Report each transaction's edit path in its response* (#1313). - *Publish an applied-revision watermark and complete edits asynchronously* (#1314), for `EXACT`. - *Refuse automation on effect parameters that are not block-rate* (#1335): its step 4 and `LiveRebuild::AutomationTarget`. -- Batch: R1, in one push with drafts 09a, 09b and 11 (README "Must-land-together groups"): D3 drops - the `FaderDb` that a VCA ride gives an automated member, and draft 11 adds the offsets-cell write +- Batch: R1, in one push with #1646, #1647 and #1649 (README "Must-land-together groups"): D3 drops + the `FaderDb` that a VCA ride gives an automated member, and #1649 adds the offsets-cell write that makes the ride heard. *Admit browser live edits in the Worker through the committed model* - (#1382) arrives through draft 09a. + (#1382) arrives through #1646. ## Amendment (controls coordinator, 2026-10-14) @@ -230,13 +230,13 @@ Checked against `12d4a4f9d`. `controls::automation_row_renders(address: &ControlAddress) -> bool` (classification rules live in `controls`, C1), true only for builtins control 5 in this slice. D1's new step compares the entries whose rows render; `LiveRebuild` gains `Automation` beside `AutomationTarget`. -- **D3 is split by OQ1 = C.** For the fader row, the static edit is no longer `model_only`: slice - 11b makes `SET_CONTROL fader_db` on an automated lane write the lane's offset (live). D3's rule +- **D3 is split by OQ1 = C.** For the fader row, the static edit is no longer `model_only`: + #1650 makes `SET_CONTROL fader_db` on an automated lane write the lane's offset (live). D3's rule ("a static edit of an automated cell gives no record and commits `model_only`") stays the rule for the rows where automation wins, and its predicate `automated_cell` (in `controls`, over - `ControlAddress`) is the one later slices use (13d, 14b, 15 for polarity, 16b, 19, 20). Gate 1's + `ControlAddress`) is the one later slices use (#1654, #1657, #1658 for polarity, #1660, #1665, #1667). Gate 1's "static `left_db` change ... gives `Ok` with no records" and gate 5 move: the fader case becomes - 11b's gate 2; the first automation-wins row that renders (pan, draft 14b) takes gate 5's + #1650's gate 2; the first automation-wins row that renders (pan, #1657) takes gate 5's `model_only` case on both hosts. - **VCA rides.** #1247's step is now the effective diff (`EffectiveDiff`, C6 step 3); D3's note on VCA moves reads against it. @@ -247,6 +247,6 @@ Checked against `12d4a4f9d`. - **Moved files.** `crates/capi/src/runtime/{control.rs,compile.rs}` are in `crates/control-plane/src/`. The tests that pin today's model-only automation edit are `model_only_edits` (`crates/host-core/tests/live_delta.rs:1125-1154`) and its C ABI twin in - `crates/capi/src/runtime/live_tests.rs`. Realtime policy as in draft 07's amendment. -- **Dependencies.** #1277, #1313, #1314 and #1335 have landed; add #1562b. Must-land group: 09a, - 09b, 10, 11, 11b. + `crates/capi/src/runtime/live_tests.rs`. Realtime policy as in #1637's amendment. +- **Dependencies.** #1277, #1313, #1314 and #1335 have landed; add #1562b. Must-land group: #1646, + #1647, #1648, #1649, #1650. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/11-compose-vca-offsets-with-stored-fader-automation.md b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md similarity index 87% rename from docs/handoffs/stored-automation-1058/proposed-specs/11-compose-vca-offsets-with-stored-fader-automation.md rename to .github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md index 8aa45fd69..cfd9bc990 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/11-compose-vca-offsets-with-stored-fader-automation.md +++ b/.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md @@ -17,7 +17,7 @@ ABI plan*, #1247; the browser through #1382), which writes the lane's offsets ce fader record, and the path is `live`. Every other VCA edit that #1247 makes live stays `live` on an automated member too: a change of a VCA's members, nested VCAs included, and adding or removing a VCA. A member whose fader is not automated keeps today's records. This slice moves no rendered bit -of a session without stored automation (draft 09a D6 is the one order change). +of a session without stored automation (#1646 D6 is the one order change). ## Context @@ -25,12 +25,12 @@ of a session without stored automation (draft 09a D6 is the one order change). member's own value first, then each offset in the order given, clamps to `[-144, 24]` and rounds once to `f32`; with no offset it returns the member bit for bit (`crates/session/src/vca.rs:19-34`). `effective_strip_faders` passes each strip's reaching VCAs' offsets in ascending VCA-ID order - (`:96-120`), and `crates/session/tests/vca_composition.rs:79-82` pins the order. Draft 09a D6 + (`:96-120`), and `crates/session/tests/vca_composition.rs:79-82` pins the order. #1646 D6 (README F16, root's decision) changes it: `vca_offsets_sum` sums the offsets first and `vca_compose_db(member, S)` adds the member, on every path. -- **Draft 09a** creates and seeds the offsets cell (draft 09a D1): for each automated fader lane, +- **#1646** creates and seeds the offsets cell (#1646 D1): for each automated fader lane, in every session, a #1312 cell of three words, the lane's `S` as one `f64` and a ramp word; `S` - is `+0.0` when no VCA reaches the lane. Draft 09a D1 shows that the cell keeps the static gain + is `+0.0` when no VCA reaches the lane. #1646 D1 shows that the cell keeps the static gain bits. Render computes each event's gain as `checked_fader_gain(vca_compose_db(v, S))` from the cell's newest slot. Nothing writes the cell after preparation yet. - **The shared VCA row.** #1247 D2: the classifier's strip step compares `effective_strip_faders()` @@ -38,7 +38,7 @@ of a session without stored automation (draft 09a D6 is the one order change). change. #1247 makes VCA rides, mutes and membership live on the C ABI (D15-6), because the C ABI's render plane holds no VCA state. After #1382 a browser VCA edit reaches the same shared commit through the Worker's apply and takes the same row. -- **Draft 10 D3** makes that step emit no `FaderDb` for a lane that `next` automates, so until this +- **#1648 D3** makes that step emit no `FaderDb` for a lane that `next` automates, so until this slice a VCA move does not reach an automated member. - **The cell primitive.** #1312 D1: a cell has three slots of words and a sequence; the writer fills every word of its back slot, then publishes; render reads the newest completed write in one pass @@ -46,14 +46,14 @@ of a session without stored automation (draft 09a D6 is the one order change). ## Decisions frozen for this slice -- **D1. The composition is draft 09a D6's.** This slice calls `vca_offsets_sum` and +- **D1. The composition is #1646 D6's.** This slice calls `vca_offsets_sum` and `vca_compose_db` and changes neither. A flat curve at `v` renders the bits a static `v` prepares, however many VCAs reach the lane. -- **D2. The offsets cell is draft 09a's.** This slice adds only the write that rewrites it, a whole +- **D2. The offsets cell is #1646's.** This slice adds only the write that rewrites it, a whole slot at a time (D4), and the jump at the next block entry (D3). - **D3. Render.** - At each fader event, the gain is `checked_fader_gain(vca_compose_db(curve, S))`, with `S` from - the cell's front slot (the newest write render has read), as draft 09a D1 composes it. + the cell's front slot (the newest write render has read), as #1646 D1 composes it. - At block entry, when the cell is dirty, render reads the new slot. If the composed gain at the jump's completion sample differs in bits from the lane's current gain target, the lane takes a jump at the block's first sample over the slot's ramp word (D4): the length that #1247 D5 gives @@ -70,14 +70,14 @@ of a session without stored automation (draft 09a D6 is the one order change). live session edits*, #1394 D5-D6; #1247 D5), so one VCA edit ramps its automated and its plain members over one length. A ride, a membership change, a nested-VCA change, adding a VCA and removing one are all such writes: each changes only `S`, and the cell exists on every automated - fader lane (draft 09a D1). #1312's publication rule needs every word of the slot, so the writer + fader lane (#1646 D1). #1312's publication rule needs every word of the slot, so the writer never writes one word alone. The path is `live`; no VCA edit rebuilds. - For a lane that is not automated, #1247's `FaderDb` records are unchanged. - **D5. Memory and work.** Each offsets cell holds three 4-byte words in each of #1312's three slots: 36 bytes per automated fader lane, whatever the session's VCA count, plus the cell's fixed - sequence and index words (draft 09a D4 charges them). Render adds one `f64` (`S`) per event; + sequence and index words (#1646 D4 charges them). Render adds one `f64` (`S`) per event; nothing depends on song length or on the VCA count. -- **D6. Out of scope here:** a VCA mute on a member whose mute is automated (draft 13a). +- **D6. Out of scope here:** a VCA mute on a member whose mute is automated (#1651). - **D7. The acked-batch question: can an ack ever precede a drop? No.** The commit checks the whole transaction before the first cell write, and a cell write cannot fail; a replaced slot is in the committed state and counted (#1312 D2). @@ -99,18 +99,18 @@ of a session without stored automation (draft 09a D6 is the one order change). - `crates/control-plane/Cargo.toml` (a `bench-support` dev-dependency for that binary, only if an earlier slice has not added it), `Cargo.lock` -`crates/session/src/vca.rs` is called, never changed (draft 09a D6 changes it). +`crates/session/src/vca.rs` is called, never changed (#1646 D6 changes it). ## Non-goals - VCA automation (out of scope by decision 13, `docs/rulings/submix-strips-sends-and-vca-2026-10-02.md:129`). -- A VCA mute on an automated mute lane (draft 13a). -- Any change to the composition beyond draft 09a D6. +- A VCA mute on an automated mute lane (#1651). +- Any change to the composition beyond #1646 D6. ## Hazards - **A redundant jump moves bits.** D3 compares bits before it jumps; D4 writes only on a change. -- **Batch R1, one push with drafts 09a, 09b and 10** (README "Must-land-together groups"). Draft 10 +- **Batch R1, one push with #1646, #1647 and #1648** (README "Must-land-together groups"). #1648 D3 drops the `FaderDb` of an automated member; without this slice an acknowledged VCA ride on that member is not heard. @@ -151,7 +151,7 @@ of a session without stored automation (draft 09a D6 is the one order change). run. For gate 1's script on the render thread: `allocations == 0 && frees == 0` around every render call after warm-up. 7. **Commands:** - - `cargo test --locked -p session` (`vca_composition.rs` as draft 09a leaves it), + - `cargo test --locked -p session` (`vca_composition.rs` as #1646 leaves it), `cargo test --locked -p host-core --features host-core/test-support`, `cargo test --locked -p capi`, `cargo test --locked -p host-web --features host-web/test-support`, `cargo test --locked -p builtins-compiler --features test-support` @@ -179,40 +179,40 @@ of a session without stored automation (draft 09a D6 is the one order change). write when a VCA is added, or if a removed VCA leaves its offset in `S`. - Gate 5: red if the classifier emits a `FaderDb` that the next curve event would contradict. - Gate 6: red if the cell read or the composition allocates on render. -- The composition order and the no-VCA rule are draft 09a's gates 1 and 6. +- The composition order and the no-VCA rule are #1646's gates 1 and 6. ## Dependencies -- Draft 09a *Prepare stored fader automation and render it flat* (the offsets cell it creates and +- #1646 *Prepare stored fader automation and render it flat* (the offsets cell it creates and seeds). It brings *Admit browser live edits in the Worker through the committed model* (#1382), which brings #1247's shared VCA row. -- Draft 10 *Classify fader automation edits as carried rebuilds* (the carry and the strip step's +- #1648 *Classify fader automation edits as carried rebuilds* (the carry and the strip step's automated-lane rule). -- Draft 12 *Hold the automation jump lengths in a plan cell* (the `fader` jump length of the +- #1639 *Hold the automation jump lengths in a plan cell* (the `fader` jump length of the adoption jump). *Carry an optional per-edit ramp length on live session edits* (#1394), whose `resolve` gives the ramp word, arrives through #1382. -- Batch: R1, in one push with drafts 09a, 09b and 10. +- Batch: R1, in one push with #1646, #1647 and #1648. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. -- **Composition** is `controls::effective` over `session::VcaSum` (draft 09a's amendment); D4's +- **Composition** is `controls::effective` over `session::VcaSum` (#1646's amendment); D4's "#1247's strip step" is the classifier's effective diff (`EffectiveDiff`, `crates/host-core/src/live_delta.rs`, C6 step 3). -- **`S` includes the hand offset** (OQ1 = C, slice 11b D6): `S = offset + Σ reaching VCA offsets`, - the lane's own offset first. The cell, its write and the jump rule are unchanged; 11b reuses them. +- **`S` includes the hand offset** (OQ1 = C, #1650 D6): `S = offset + Σ reaching VCA offsets`, + the lane's own offset first. The cell, its write and the jump rule are unchanged; #1650 reuses them. - **VCA automation** is no longer out of scope by decision 13: decision 17 computes `automatable` from the row, and #1566 accepts a VCA `offset_db` or `mute` target (the fixture holds `vca-ride`). No slice renders it yet (README F20); if root files one, it composes into this slice's `S`. - **Moved files.** `crates/host-core/tests/vca_live.rs` is gone (use `crates/host-core/tests/vca.rs`); `hosts/host-web/src/tests.rs`'s VCA oracles were deleted (#1565): the browser leg goes through `hosts/host-web/tests/control_parity.rs`. -- **Must-land group** is 09a, 09b, 10, 11 and 11b. +- **Must-land group** is #1646, #1647, #1648, #1649 and #1650. ## Amendment (controls coordinator, batch 10, 2026-10-14) -- **VCA automation renders in draft 13e** (root's F20 ruling, README "Root's rulings +- **VCA automation renders in #1655** (root's F20 ruling, README "Root's rulings (2026-10-14)"). It extends this slice's offsets cell and write: automated VCA curves add to `S` at each event. This slice's non-goal stands for itself. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/11b-offset-an-automated-fader-or-trim-by-a-hand-move.md b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md similarity index 85% rename from docs/handoffs/stored-automation-1058/proposed-specs/11b-offset-an-automated-fader-or-trim-by-a-hand-move.md rename to .github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md index cab840d45..94085e019 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/11b-offset-an-automated-fader-or-trim-by-a-hand-move.md +++ b/.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md @@ -4,7 +4,7 @@ Proposed by *Research: render stored session automation in the engine, identical platform* (#1058), owner answer OQ1 = C (2026-10-14, README "Owner answers"), under decision 17 (`docs/rulings/one-control-abstraction-2026-10-13.md`, C2, C3, C5, C6 and C10). A draft for root to file. Written by the controls coordinator on 2026-10-14. Code anchors verified on `12d4a4f9d` -(batch 9 head). Batch R1, in one push with drafts 09a, 09b, 10 and 11. +(batch 9 head). Batch R1, in one push with #1646, #1647, #1648 and #1649. ## Product outcome @@ -15,9 +15,9 @@ stored dB offset, as the trim modes of Logic Pro, Pro Tools and Cubase do (READM control 5), it is live (one cell write, no rebuild), and the offset is saved in the session: in its canonical JSON, on the wire and in the control list. A later re-authoring of the curve (`SetAutomationSegments`) keeps the offset. The same rule holds for the input trim (`trim_db`, -builtins control 2) from the slice that renders trim automation (draft 15). The owner's other two -rules are not in this slice: a hand mute ORs with the automated mute (draft 13a, amended), and on -every other row automation wins (draft 10 D3). +builtins control 2) from the slice that renders trim automation (#1658). The owner's other two +rules are not in this slice: a hand mute ORs with the automated mute (#1651, amended), and on +every other row automation wins (#1648 D3). ## Context @@ -43,13 +43,13 @@ every other row automation wins (draft 10 D3). - **The control list.** A control row carries `automatable` (field 31), `active` (33) and `value` (34) (`crates/protocol/src/schema.rs:358-433`). No field says whether stored automation drives the row. -- **The fader composition.** Draft 09a D1 gives every automated fader lane an offsets cell that +- **The fader composition.** #1646 D1 gives every automated fader lane an offsets cell that holds `S`, the `f64` sum of the reaching VCA offsets, and composes each event as - `checked_fader_gain(vca_compose_db(v, S))`; draft 11 writes the cell on a VCA edit. A lane no VCA + `checked_fader_gain(vca_compose_db(v, S))`; #1649 writes the cell on a VCA edit. A lane no VCA reaches holds `S = +0.0`. -- **The fallback rule of A2.** Draft 10 D3 commits a static edit of an automated cell as +- **The fallback rule of A2.** #1648 D3 commits a static edit of an automated cell as `model_only`: the fallback value, stored and not heard. Under OQ1 = C that rule stays for every - row except the fader and trim dB rows (this slice) and the mute row (draft 13a). + row except the fader and trim dB rows (this slice) and the mute row (#1651). ## Decisions frozen for this slice @@ -77,8 +77,8 @@ every other row automation wins (draft 10 D3). - `control.domain`: an offset that is not finite or lies outside `[-144, 24]` dB, the VCA offset domain (`crates/session/src/validate.rs`, VCA offsets). The session crate checks nothing new: it cannot see rows. -- **D3. The write is the row.** On a lane whose fader (or, from draft 15, trim) automation renders - (draft 10 D1's `automation_row_renders`), the row's accessor reads and writes that lane's offset +- **D3. The write is the row.** On a lane whose fader (or, from #1658, trim) automation renders + (#1648 D1's `automation_row_renders`), the row's accessor reads and writes that lane's offset in the entry that drives the lane, not the static field. So: - `SET_CONTROL { address: fader_db of (strip, lane), value: v }` sets that lane's offset to `v`. `controls::check` applies the row's own domain, `[-144, 24]`, which is the offset domain of D2. @@ -89,7 +89,7 @@ every other row automation wins (draft 10 D3). while the automation exists. A host changes it in one transaction: `RemoveAutomation`, the `SET_CONTROL`, then `UpsertAutomation`; edits apply in order to the running model (decision 17 C5). A document replacement also sets it. - - On a lane whose automation does not render yet (trim before draft 15), the accessor keeps the + - On a lane whose automation does not render yet (trim before #1658), the accessor keeps the static field and the stored offset is inert, as every not-yet-rendered row is (A10). - **D4. The list says so.** The control row gains `automated BOOL` (field 35 of `control_entry`, optional on the wire, written on every control row): true when a rendered stored automation drives @@ -98,20 +98,20 @@ every other row automation wins (draft 10 D3). - **D5. The classifier.** Because the row's accessor reads the offset, `controls::mask_values` copies the offset with every other control value, and step 1 of `classify_live_delta` does not see an offset change as a change of the automation table. Step 3 sees a changed `fader_db` value - on an automated lane, and the strip-level lowering writes the lane's offsets cell (draft 11 D4) + on an automated lane, and the strip-level lowering writes the lane's offsets cell (#1649 D4) with the new `S` and the ramp `LiveRamps::resolve(Fader, the edit's ramp)`, instead of a `FaderDb` record. The path is `live`. An `UpsertAutomation` whose only change is the offset is classified the - same way. A change of segments or target stays a rebuild (draft 10 D1). -- **D6. Render.** The hand offset joins draft 09a's sum: `S = offset + Σ reaching VCA offsets`, in + same way. A change of segments or target stays a rebuild (#1648 D1). +- **D6. Render.** The hand offset joins #1646's sum: `S = offset + Σ reaching VCA offsets`, in `f64`, the lane's own offset first, then the VCA offsets in ascending VCA ID. Each fader event is `checked_fader_gain(vca_compose_db(curve, S))`, unchanged; a cell change jumps at the next block - entry over the cell's ramp word (draft 11 D3). An offset of `±0.0` and no VCA give the gain bits of + entry over the cell's ramp word (#1649 D3). An offset of `±0.0` and no VCA give the gain bits of no offset (`f64(v) + (±0.0)` differs from `v` only in the sign of a zero, and `db_gain(±0) = 1`). All per-sample work stays in the existing unfused fader kernels; the offset adds one `f64` addition per event. Trim's render half (its own offsets cell, the same composition with - `checked_trim_gain`) is draft 15's, amended. -- **D7. Mute and other rows.** Not in this slice. Draft 13a (amended) ORs the static mute into the - automated mute. On every other row draft 10 D3's rule stays: a static edit of an automated cell is + `checked_trim_gain`) is #1658's, amended. +- **D7. Mute and other rows.** Not in this slice. #1651 (amended) ORs the static mute into the + automated mute. On every other row #1648 D3's rule stays: a static edit of an automated cell is stored, not heard, and commits `model_only`. - **D8. The acked-batch question: can an ack ever precede a drop? No.** The write is checked whole before the first cell write (decision 17 C6), a cell write cannot fail, and the committed model @@ -122,7 +122,7 @@ every other row automation wins (draft 10 D3). 1. D1 in `crates/session`, `crates/protocol`, the SDK and every document that carries automation. 2. D2-D4 in `crates/controls` and the control-list codec. 3. D5 in `crates/host-core/src/live_delta.rs`; D6 in the fader bank's event composition and the - offsets-cell write of draft 11. + offsets-cell write of #1649. 4. `docs/SESSION_SCHEMA_V1.md` (the automation section: the offset, its rule and the write rule) and `docs/CONTROL_PROTOCOL_REGISTRY.md` (field 35). @@ -142,23 +142,23 @@ every other row automation wins (draft 10 D3). ## Non-goals -- The mute OR (draft 13a) and the trim render (draft 15). +- The mute OR (#1651) and the trim render (#1658). - An offset on any other row: owner answer C names only the fader and the input trim. Send gain is - "automation wins" (draft 13d). + "automation wins" (#1654). - Touch, latch or override modes (OQ1 options B and D, not chosen). - Reading the instantaneous automated value. A moving value is observation (decision 17 C9), not a control value; `effective_fader_db` keeps its static meaning. ## Hazards -- **One push with drafts 09a, 09b, 10 and 11** (README "Must-land-together groups", amended). Before +- **One push with #1646, #1647, #1648 and #1649** (README "Must-land-together groups", amended). Before this slice a hand fader move on an automated lane is heard as a static fader (nothing renders the - curve); after 09a-10 without this slice it would be `model_only`; with this slice it is an offset. + curve); after #1646-#1648 without this slice it would be `model_only`; with this slice it is an offset. `main` must never hold the middle state. - **Document bytes move.** Every document with automation gains the `offset` key. Each pinned digest - that moves is re-pinned with the reason "draft 11b adds the automation offset". + that moves is re-pinned with the reason "#1650 adds the automation offset". - **The binding changes with the table.** A transaction that adds or removes a lane's automation - also changes what the row's accessor reads. Draft 10 D1 makes every such transaction a rebuild, + also changes what the row's accessor reads. #1648 D1 makes every such transaction a rebuild, so the classifier never diffs a row whose binding changed between `current` and `next`; gate 4 holds the removal case. - **The `-0.0` offset.** An authored `-0.0` is a valid zero offset (D6 shows the gain bits stay); @@ -182,7 +182,7 @@ Commands: `cargo test --locked -p session -p protocol -p controls`, `cargo test fresh plan of a session whose ride is the same and whose left offset is `+3`; the right lane is unchanged. 3. **Offset 0 is no offset.** A flat ride at `-7.25` dB with offset `0` renders the bits of static - `-7.25` (draft 09a gate 1's twin), with and without a reaching VCA. + `-7.25` (#1646 gate 1's twin), with and without a reaching VCA. 4. **Re-authoring keeps it.** `SetAutomationSegments` on the ride keeps the offset (control list value unchanged); `RemoveAutomation` removes it and the lane returns to its static value. 5. **The list.** The row shows `automated = true` and `value = +3` after gate 2; a list fetched @@ -201,12 +201,12 @@ Commands: `cargo test --locked -p session -p protocol -p controls`, `cargo test ## Dependencies -- Drafts 09a, 09b, 10 and 11 (same push). Draft 10 D1's `automation_row_renders` is the predicate +- #1646, #1647, #1648 and #1649 (same push). #1648 D1's `automation_row_renders` is the predicate D3 reads. - #1566 (batch 9): the address target and the row rule. *Keep the session JSON keys and the session wire field IDs equal* (#1568) and *Generate the SDK's session key tables from the engine's walk* (#1569) are open; whichever lands first, the other covers the new keys (ordering only). -- Draft 15 (amended) gives the trim its render half and flips D3 for trim when it adds the trim row. +- #1658 (amended) gives the trim its render half and flips D3 for trim when it adds the trim row. - Batch R1. ## Amendment (controls coordinator, batch 10, 2026-10-14) @@ -222,13 +222,13 @@ owner's answer covers level controls, and the DAW trim modes include sends. also refuses an entry on a shared row whose two fields differ. The domain is the route gain's, `[-144, 24]` dB (`ROUTE_GAIN_DB_MINIMUM`, `ROUTE_GAIN_DB_MAXIMUM`), the same as the fader's. - **D3.** The route `gain_db` row's accessor reads and writes the offset from the slice that renders - send automation (draft 13d), as the trim's does from draft 15. Before 13d the stored offset is + send automation (#1654), as the trim's does from #1658. Before #1654 the stored offset is inert, as D3's last bullet says for every not-yet-rendered row. - **D5.** For an automated send gain, the classifier's route lowering writes the route's - seven-word cell (draft 13d, amended), with `LiveRamps::resolve(SendGain, the edit's ramp)`. That - lowering is draft 13d's; this slice adds only the field, the rule and the row binding switch. -- **D7 and Non-goals.** "Send gain is 'automation wins' (draft 13d)" is replaced: send gain takes - an offset; on a send's matrix rows automation wins (draft 13d D4). A VCA's `offset_db` is a level - control too; draft 13e adds it to `takes_offset` (a reading of root's ruling for root to confirm). + seven-word cell (#1654, amended), with `LiveRamps::resolve(SendGain, the edit's ramp)`. That + lowering is #1654's; this slice adds only the field, the rule and the row binding switch. +- **D7 and Non-goals.** "Send gain is 'automation wins' (#1654)" is replaced: send gain takes + an offset; on a send's matrix rows automation wins (#1654 D4). A VCA's `offset_db` is a level + control too; #1655 adds it to `takes_offset` (a reading of root's ruling for root to confirm). - **Gate 1** gains: an entry on a send `gain_db` whose two offset fields differ is refused `control.automation.offset`; a non-zero offset on a send matrix row is refused. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/13a-render-stored-mute-automation.md b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md similarity index 83% rename from docs/handoffs/stored-automation-1058/proposed-specs/13a-render-stored-mute-automation.md rename to .github/ISSUE_SPECS/1651-render-stored-mute-automation.md index 26500a583..4a4810618 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/13a-render-stored-mute-automation.md +++ b/.github/ISSUE_SPECS/1651-render-stored-mute-automation.md @@ -3,9 +3,9 @@ Proposed by *Research: render stored session automation in the engine, identically on every platform* (#1058), answers A1.5, A2 and A10, under decision 15 D15-16 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as drafts 13b -*Follow an automated mute on following sends in render* and 13c *Write a following send's route -cell from the shared commit*: 13a must not reach `main` without them**, or a send that follows an +to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as #1652 +*Follow an automated mute on following sends in render* and #1653 *Write a following send's route +cell from the shared commit*: #1651 must not reach `main` without them**, or a send that follows an automated mute leaks. ## Product outcome @@ -14,8 +14,8 @@ A producer's stored mute automation (row 6) plays on every host. A strip lane mu the exact samples the session names, over the session mute ramp. Solo (the browser's host overlay) and VCA mutes still compose with the automated mute. A mute automation edit is a carried rebuild; a static mute edit on an automated lane, and a browser live mute command on it, commit as -`model_only` on both hosts through the one shared rule of draft 10. Sends that follow the strip's -mute are drafts 13b and 13c. +`model_only` on both hosts through the one shared rule of #1648. Sends that follow the strip's +mute are #1652 and #1653. ## Context @@ -27,17 +27,17 @@ mute are drafts 13b and 13c. effective mute. - **The mute stage.** `FaderRampStage::set_mute` retargets the lane's gain to 0 or to the remembered `fader_gain` (`crates/builtins/src/lib.rs:2773-2794`); the kernel clears a muted lane - from the frame its ramp settles on (`crates/lane/src/kernels/builtins.rs:159-202`). Draft 08 D1 + from the frame its ramp settles on (`crates/lane/src/kernels/builtins.rs:159-202`). #1638 D1 adds `SetMute { muted, samples }` at an offset and `RememberGain`. - **The cell.** *Hold live values in latest-target cells on both hosts* (#1312) D3 gives each mute lane a cell of two words (value bits, ramp). - **Seam.** The fader and mute stage is seam-side (`crates/builtins-compiler/src/lib.rs:758-771`), so mute automation never declines a mono collapse. -- **The classifier row list and the predicate** are draft 10 D1 and D3. +- **The classifier row list and the predicate** are #1648 D1 and D3. ## Decisions frozen for this slice -- **D1. Mute cells (id 6).** Only `step` with values 0 or 1 (draft 02 D1). A step curve is constant +- **D1. Mute cells (id 6).** Only `step` with values 0 or 1 (#1645 D1). A step curve is constant between its discontinuities, so a mute cell has no grid events, only jumps. - The stage mute of a strip lane is `curve || terms`, where `terms` is `vca_mute || solo_mute`. - **The terms word.** For an automated mute lane, this slice gives #1312's mute cell value word @@ -45,18 +45,18 @@ mute are drafts 13b and 13c. word keeps #1312's meaning. Preparation seeds it. #1312 is not amended: this slice adds the meaning for a lane kind that exists only from this slice on. - At each discontinuity at render sample `r`, and at the first sample of the block after the mute - cell changes, render composes the stage mute. Only a change emits draft 08's `SetMute` at that - offset, over the session mute length (draft 12's `mute` word) for a curve event and over the + cell changes, render composes the stage mute. Only a change emits #1638's `SetMute` at that + offset, over the session mute length (#1639's `mute` word) for a curve event and over the cell's ramp word for a cell change. The caller reports the mute ramp to the cell's fader events - through draft 07's `hold_until`. + through #1637's `hold_until`. - At a session seek where the seek reaches the fader node, the stage mute is set exactly (`n = 0`). - While a lane's mute ramp is in flight or the lane is muted, fader events only remember the gain - (draft 08 D2). + (#1638 D2). - **D2. Edits, one rule on both hosts.** - - The classifier's row list (draft 10 D1) gains row 6: a mute automation edit is a carried - rebuild (draft 10 D2 carries mute cells by the same address scheme). A static mute edit on an - automated lane gives no record (draft 10 D3); a browser live mute edit on it reaches that rule - through the Worker's apply (#1382) and replies `model_only`. This turns red draft 10's gate 1 + - The classifier's row list (#1648 D1) gains row 6: a mute automation edit is a carried + rebuild (#1648 D2 carries mute cells by the same address scheme). A static mute edit on an + automated lane gives no record (#1648 D3); a browser live mute edit on it reaches that rule + through the Worker's apply (#1382) and replies `model_only`. This turns red #1648's gate 1 case "A mute (row 6) entry change gives `Ok` with no records" (`crates/host-core/tests/live_delta.rs`); this slice rewrites that case in place to expect `Err(LiveRebuild::Automation)` (AGENTS.md: a change that supersedes a test rewrites it in the @@ -78,7 +78,7 @@ mute are drafts 13b and 13c. ## Authorized paths - `crates/builtins-compiler/src/lib.rs` (the fader stage's mute events and the terms read), - `crates/builtins/src/lib.rs` (only if draft 08's mute operation needs a change) + `crates/builtins/src/lib.rs` (only if #1638's mute operation needs a change) - `crates/host-core/src/{prepare.rs,live_delta.rs,solo.rs}`, `crates/host-core/tests/live_delta.rs` - `crates/control-plane/src/` (the overlay composition's terms write) - `crates/capi/src/runtime/live_tests.rs`, `hosts/host-web/src/tests.rs`, @@ -88,12 +88,12 @@ mute are drafts 13b and 13c. ## Non-goals -- Sends that follow the mute, route lanes and the route op (drafts 13b and 13c). +- Sends that follow the mute, route lanes and the route op (#1652 and #1653). - Options B, C and D of OQ1. ## Hazards -- **One push with 13b and 13c.** Alone, this slice renders an automated mute while a following +- **One push with #1652 and #1653.** Alone, this slice renders an automated mute while a following send keeps its prepared gate; the send leaks. Root merges the three together. - **A redundant retarget moves bits** (README "Change only"; `crates/host-core/src/solo.rs:58-67`). D1 and D2 compare before they write or retarget. @@ -107,7 +107,7 @@ mute are drafts 13b and 13c. static mute, fed the same PCM. At quanta 128 and 100. 2. **Mute and fader compose** (`crates/builtins-compiler` test, new). A fader ride and a mute step on one lane: fader events inside the mute only remember the gain; after the unmute ramp the lane is - on the ride, bit-identical to the stage driven directly with draft 08's operations. + on the ride, bit-identical to the stage driven directly with #1638's operations. 3. **Composition** (browser, new). Solo on another track mutes `t` by solo while its curve is 0; `t` mutes; unsolo restores the curve's state. A VCA mute of `t` composes the same way, on both hosts. @@ -158,13 +158,13 @@ mute are drafts 13b and 13c. Batch R2. Direct dependencies: -- Draft 10 *Classify fader automation edits as carried rebuilds* (the row list, the predicate and - the carry; it brings draft 09a and, through it, #1382 and #1247). -- Draft 12 *Hold the automation jump lengths in a plan cell* (the mute ramp length is its `mute` +- #1648 *Classify fader automation edits as carried rebuilds* (the row list, the predicate and + the carry; it brings #1646 and, through it, #1382 and #1247). +- #1639 *Hold the automation jump lengths in a plan cell* (the mute ramp length is its `mute` word). -Draft 08's `SetMute` and the cells of *Hold live values in latest-target cells on both hosts* -(#1312) arrive through draft 10. Drafts 13b and 13c depend on this draft and land in the same push. +#1638's `SetMute` and the cells of *Hold live values in latest-target cells on both hosts* +(#1312) arrive through #1648. #1652 and #1653 depend on this draft and land in the same push. ## Amendment (controls coordinator, 2026-10-14) @@ -174,7 +174,7 @@ Checked against `12d4a4f9d`. `terms` now includes the lane's own static mute: `static_mute || any reaching VCA mute || solo term`, the effective mute of `controls::effective` (C7) without the curve. So a `SET_CONTROL mute` on an automated lane is a live `terms` write, path `live`, not `model_only`. D2's "A static mute - edit on an automated lane gives no record (draft 10 D3)" and the browser sentence are replaced by + edit on an automated lane gives no record (#1648 D3)" and the browser sentence are replaced by this rule; gate 4 tests a hand mute that closes an open curve, and a hand unmute that leaves a closed curve closed. When the entry is removed, the lane's mute is the static mute, as before. - **Solo is on both hosts** (C8, #1559): "On the C ABI the overlay is absent" is no longer true; the @@ -182,10 +182,10 @@ Checked against `12d4a4f9d`. - **Moved files.** `crates/host-core/src/solo.rs` is deleted; the composition is `controls::effective` (`crates/controls/src/effective.rs`). Authorized paths change to `crates/controls/src/effective.rs` (read) and `crates/host-core/src/live_delta.rs`. -- **The jump length** is `LiveRamps::for_row(Mute)` (draft 07, amended). +- **The jump length** is `LiveRamps::for_row(Mute)` (#1637, amended). ## Amendment (controls coordinator, batch 10, 2026-10-14) -- **Automated VCA mutes** (root's F20 ruling) compose with this slice's `curve || terms` in draft - 13e: the stage mute of a member lane gains `|| vca_curve(τ)` for each reaching VCA whose mute is +- **Automated VCA mutes** (root's F20 ruling) compose with this slice's `curve || terms` in + #1655: the stage mute of a member lane gains `|| vca_curve(τ)` for each reaching VCA whose mute is automated. This slice's `terms` word keeps the static VCA mutes. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/13b-follow-an-automated-mute-on-following-sends-in-render.md b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md similarity index 91% rename from docs/handoffs/stored-automation-1058/proposed-specs/13b-follow-an-automated-mute-on-following-sends-in-render.md rename to .github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md index 48905423e..72a89b5aa 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/13b-follow-an-automated-mute-on-following-sends-in-render.md +++ b/.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md @@ -3,8 +3,8 @@ Proposed by *Research: render stored session automation in the engine, identically on every platform* (#1058), answers A1.3 and A6, under decision 15 D15-16 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as drafts 13a -*Render stored mute automation on the strip* and 13c *Write a following send's route cell from the +to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as #1651 +*Render stored mute automation on the strip* and #1653 *Write a following send's route cell from the shared commit*.** ## Product outcome @@ -14,7 +14,7 @@ that automation in render, on every host and whatever the live-control options. changes at the render sample where the send's tap carries the timeline sample the curve names, so the send mutes exactly the audio the strip mutes, latency included, and a saved session with an automated mute and pre-fader sends leaks nothing. A route into the output never follows. The edits -that write the send's cell are draft 13c's. +that write the send's cell are #1653's. ## Context @@ -39,7 +39,7 @@ that write the send's cell are draft 13c's. `crates/graph-compiler/src/pdc.rs:53-104`). For an input, post-input or insert-send tap behind inserts of latency `L`, `a(tap) = a(fader) - L`; for a post-fader or post-pan tap, `a(tap) = a(fader)`. -- **The strip side** is draft 13a: mute cells, the stage mute `curve || terms`, events at exact +- **The strip side** is #1651: mute cells, the stage mute `curve || terms`, events at exact samples over the session mute length. - **Swaps.** *Carry strip delay lines and live send ramps across a plan swap* (#1284) D2 carries a live send's ramp by route ID. @@ -74,10 +74,10 @@ that write the send's cell are draft 13c's. target whose bits change retargets: a new `IndexedRamp` from `coefficients_at(position)` at offset `r` over the session mute length (the cell's ramp word for a cell change). A seek sets the target exactly where it reaches the tap. - - The session mute length of a route ramp is the `mute` word of draft 12's plan cell, read from + - The session mute length of a route ramp is the `mute` word of #1639's plan cell, read from the same block context as the strip's fader stage reads it, so the strip ramp and the send ramp have the same length. - - The route op gains an in-block retarget at an offset on draft 08's model: mix `[0, o)`, retarget, + - The route op gains an in-block retarget at an offset on #1638's model: mix `[0, o)`, retarget, mix `[o, q)`. The indexed law makes the split class A. - **D3. Swaps.** A route lane present in both plans carries by route ID under #1284 D2, whether or not its source mute is automated in either plan: a lane gained or lost for D1 is not a change of @@ -105,7 +105,7 @@ that write the send's cell are draft 13c's. `crates/control-plane/tests/follow_automation_realtime.rs` (new) - `crates/control-plane/Cargo.toml` (a `bench-support` dev-dependency for that binary, only if an earlier slice has not added it) -- `crates/graph/Cargo.toml` (a normal `automation` dependency: the route op runs draft 07's +- `crates/graph/Cargo.toml` (a normal `automation` dependency: the route op runs #1637's cursors in graph, and graph cannot reach them through builtins-compiler, `scripts/check-builtins-policy.sh:21`), `Cargo.lock`, `scripts/check-graph-policy.sh` (the pinned graph dependency list, `:19-22`) and `scripts/test-graph-policy.sh` (its fixture list, @@ -114,12 +114,12 @@ that write the send's cell are draft 13c's. ## Non-goals - The edits that write the route cell, a live `follows_mute` toggle and the solo and VCA terms - (draft 13c). The strip's mute events (draft 13a). Send automation (OQ2). Routes into the output, + (#1653). The strip's mute events (#1651). Send automation (OQ2). Routes into the output, which never follow (decision 13 P11). ## Hazards -- **One push with 13a and 13c.** Root merges the three together. +- **One push with #1651 and #1653.** Root merges the three together. - **A redundant retarget moves bits** (README "Change only"). D2 compares the whole target. - **Delayed sends.** A follow-muted delayed send stays active and mixes zeros (#1226 Hazards). Its comparison point includes its compensation delay. @@ -184,14 +184,14 @@ that write the send's cell are draft 13c's. Batch R2. Direct dependencies: -- Draft 13a *Render stored mute automation on the strip* (same push). -- Draft 12 *Hold the automation jump lengths in a plan cell* (the route ramp length is its `mute` +- #1651 *Render stored mute automation on the strip* (same push). +- #1639 *Hold the automation jump lengths in a plan cell* (the route ramp length is its `mute` word). - *Carry strip delay lines and live send ramps across a plan swap* (#1284): the join D3 extends. - *Hold route-lane values in latest-target cells* (#1347): the cell D1 lays out for these routes. -Draft 08's split model, draft 07's per-cell events and #1285's arrivals arrive through draft 13a. -Draft 13c depends on this draft and lands in the same push. +#1638's split model, #1637's per-cell events and #1285's arrivals arrive through #1651. +#1653 depends on this draft and lands in the same push. ## Amendment (controls coordinator, 2026-10-14) @@ -200,9 +200,9 @@ Checked against `12d4a4f9d`. - **Route lanes exist on every plan** (C6, #1557): every route into a submix has a live lane and a `ROUTE_CELL_WORDS = 6` cell (`crates/graph/src/lib.rs:1004-1047`). D1's "attach a route lane" is done; D1 keeps the seven-word layout for a route whose source mute is automated. -- **`terms` includes the source's static mute** (OQ1 = C, draft 13a amended): for an automated +- **`terms` includes the source's static mute** (OQ1 = C, #1651 amended): for an automated source lane, `terms = static_mute || VCA mute || solo term`. -- **Slice 13d** (OQ2 = yes) reuses this slice's seven-word cell and in-block route retarget for a +- **#1654** (OQ2 = yes) reuses this slice's seven-word cell and in-block route retarget for a route whose own rows are automated; build them so the transform words can come from curves too. - **Moved files.** The follow gate is computed by `controls::effective` (C7); route records come from `route_records` (`crates/host-core/src/live_delta.rs:1416`) and `RouteControlProducer` @@ -211,7 +211,7 @@ Checked against `12d4a4f9d`. ## Amendment (controls coordinator, batch 10, 2026-10-14) -- **Automated VCA mutes** (draft 13e): a route into a submix whose source lane is reached by a VCA +- **Automated VCA mutes** (#1655): a route into a submix whose source lane is reached by a VCA with an automated mute also uses this slice's seven-word cell, and its follow term ORs those VCA - curves, evaluated in the tap's node time. Draft 13e adds it on this slice's route op; this slice + curves, evaluated in the tap's node time. #1655 adds it on this slice's route op; this slice needs no change for it. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/13c-write-a-following-sends-route-cell-from-the-shared-commit.md b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md similarity index 91% rename from docs/handoffs/stored-automation-1058/proposed-specs/13c-write-a-following-sends-route-cell-from-the-shared-commit.md rename to .github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md index 4f1a247e7..d25c0ac2e 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/13c-write-a-following-sends-route-cell-from-the-shared-commit.md +++ b/.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md @@ -3,8 +3,8 @@ Proposed by *Research: render stored session automation in the engine, identically on every platform* (#1058), answers A6 and A10, under decision 15 D15-16 (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md`). A draft for root -to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as drafts 13a -*Render stored mute automation on the strip* and 13b *Follow an automated mute on following sends +to file. Code anchors verified on `6ee64f484`. Batch R2. **It lands in the same push as #1651 +*Render stored mute automation on the strip* and #1652 *Follow an automated mute on following sends in render*.** ## Product outcome @@ -12,7 +12,7 @@ in render*.** On a send that follows a strip whose mute the session automates, every live edit that changes the send's gate reaches it, on both hosts, through the shared commit: a route gain, matrix or mute edit, a live `follows_mute` toggle, and a change of the source strip's static mute, VCA mute or solo. Each -writes the route's seven-word cell (draft 13b D1) and gives no folded follow record, so render +writes the route's seven-word cell (#1652 D1) and gives no folded follow record, so render composes the gate with the curve at the next event, and nothing a live edit writes can contradict the curve. Solo and VCA mutes compose with the curve for the send as for the strip. @@ -29,13 +29,13 @@ the curve. Solo and VCA mutes compose with the curve for the send as for the str model* (#1382) D3 composes the solo overlay and the route mirror in the shared commit. - **The mirror.** `LiveRouteMuteFollow::delta` (`crates/host-core/src/live_route_state.rs:232-257`) yields only changed routes; the mirror holds `follows_mute` and `source_lane_muted` (`:46-53`). -- **The cell.** Draft 13b D1: for a route into a submix whose source strip has an automated mute +- **The cell.** #1652 D1: for a route into a submix whose source strip has an automated mute lane, the cell holds the open transform, a flags word (route `mute`, `follows_mute`, `terms[2]`, `automated[2]`) and the ramp. ## Decisions frozen for this slice -- **D1. Route edits write the cell.** For a route of draft 13b D1, the shared classifier emits one +- **D1. Route edits write the cell.** For a route of #1652 D1, the shared classifier emits one route-cell write instead of the route records that #1225 and #1226 emit for that route (#1226 adds no record type of its own, #1226 D3). It carries the open transform from `route_values` (`crates/graph-compiler/src/ids.rs:309-322`), the route @@ -66,11 +66,11 @@ the curve. Solo and VCA mutes compose with the curve for the send as for the str ## Non-goals -- The route lane, its layout and the route op (draft 13b). Send automation (OQ2). +- The route lane, its layout and the route op (#1652). Send automation (OQ2). ## Hazards -- **One push with 13a and 13b.** Root merges the three together. +- **One push with #1651 and #1652.** Root merges the three together. - **A redundant write moves bits** (README "Change only"). D1 writes only changed words. ## Objective gates @@ -81,7 +81,7 @@ the curve. Solo and VCA mutes compose with the curve for the send as for the str 2. **Composition** (browser, new). Solo on another track mutes `t` by solo while its curve is 0; the send follows; unsolo restores the curve's state. A VCA mute of `t` composes the same way on both hosts. A route into the output never follows. -3. **Edits** (`crates/host-core/tests/live_delta.rs`, new). A `follows_mute` toggle on a draft 13b +3. **Edits** (`crates/host-core/tests/live_delta.rs`, new). A `follows_mute` toggle on a #1652 route gives one route-cell write and no follow record; a static mute change on the source's non-automated lane gives that lane's `Mute` record and one route-cell write; a route gain edit gives one route-cell write with the new transform; an edit that leaves every word unchanged gives @@ -112,7 +112,7 @@ the curve. Solo and VCA mutes compose with the curve for the send as for the str Batch R2. Direct dependencies: -- Draft 13b *Follow an automated mute on following sends in render* (same push). +- #1652 *Follow an automated mute on following sends in render* (same push). - *Make a send's follows_mute live in the browser* (#1342): the browser's `follows_mute` edit. It brings #1225, #1226 and #1382. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/13d-render-stored-send-gain-mute-and-matrix-automation.md b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md similarity index 87% rename from docs/handoffs/stored-automation-1058/proposed-specs/13d-render-stored-send-gain-mute-and-matrix-automation.md rename to .github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md index b1fc5bf0d..a93f30772 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/13d-render-stored-send-gain-mute-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md @@ -4,7 +4,7 @@ Proposed by *Research: render stored session automation in the engine, identical platform* (#1058), owner answer OQ2 = yes (2026-10-14, README "Owner answers"), under decision 17 (`docs/rulings/one-control-abstraction-2026-10-13.md`, C3, C6 and C10). A draft for root to file. Written by the controls coordinator on 2026-10-14. Code anchors verified on `12d4a4f9d` (batch 9 -head). Batch R2, after drafts 13a-13c. +head). Batch R2, after #1651-#1653. ## Product outcome @@ -37,7 +37,7 @@ route into the output stays prepared: its rows are not automatable, as today. `route_values` (`crates/graph-compiler/src/ids.rs:322-338`); records come from `route_records` (`crates/host-core/src/live_delta.rs:1416`) and `RouteControlProducer` (`crates/host-core/src/route_controls.rs:201`). -- **Drafts 13b and 13c** give a route whose source mute is automated a seven-word cell (the open +- **#1652 and #1653** give a route whose source mute is automated a seven-word cell (the open transform `gain, ll, lr, rl, rr`, one flags word and the ramp), make the route op evaluate curves in the tap's node time `τ_tap(r) = timeline(r + ΣP - a(tap))` (README A1.3, A6), retarget inside a block on the indexed route ramp, and write the cell from the shared commit. @@ -47,31 +47,31 @@ route into the output stays prepared: its rows are not automatable, as today. ## Decisions frozen for this slice -- **D1. Cells.** Each automated route row is one cell (shared rows: one cell per entry), with draft - 07's builder, in the node time of the route's tap (`a(tap)`, draft 13b's rule): +- **D1. Cells.** Each automated route row is one cell (shared rows: one cell per entry), with + #1637's builder, in the node time of the route's tap (`a(tap)`, #1652's rule): - `gain_db` (row 1): grid period and ramp 64, completion `End`, jump key `SendGain`; the event value goes through the route's own dB conversion (`route_transform`, the code `route_values` calls), so a flat curve at `v` gives the bits of a static `v`; - - `mute` (row 2): `step` only (amended draft 02's shape rule), jumps over `SendMute`; + - `mute` (row 2): `step` only (amended #1645's shape rule), jumps over `SendMute`; - `matrix_*` (rows 3-6): grid period and ramp 64, completion `End`, jump key `SendMatrix`. -- **D2. The route is one target group.** A route with any automated row uses draft 13b's +- **D2. The route is one target group.** A route with any automated row uses #1652's seven-word cell, also when its source mute is not automated. At each event, at a seek step, and at the next block entry after the cell changes, the route op builds the open transform (each automated value from its curve at the completion sample, every other value from the cell), the - gate `RouteGate { mute: curve_mute || cell_mute, follow_zeroed }` with draft 13b D2's follow term, + gate `RouteGate { mute: curve_mute || cell_mute, follow_zeroed }` with #1652 D2's follow term, and the target `gated_route_coefficients(&transform, gate)`. Only a target whose bits change - retargets, on the indexed route ramp from `coefficients_at(position)` at the event offset (draft - 13b D2's in-block retarget). A seek sets the target exactly where it reaches the tap. -- **D3. Rendering rows.** Route rows 1-6 join draft 10 D1's `automation_row_renders`. A route - automation edit is a carried rebuild: the route lane carries by route ID (#1284 D2, as draft 13b D3 - extends it) and each cell's event state by `(route ID, row)`, as draft 10 D2 carries a fader cell. + retargets, on the indexed route ramp from `coefficients_at(position)` at the event offset ( + #1652 D2's in-block retarget). A seek sets the target exactly where it reaches the tap. +- **D3. Rendering rows.** Route rows 1-6 join #1648 D1's `automation_row_renders`. A route + automation edit is a carried rebuild: the route lane carries by route ID (#1284 D2, as #1652 D3 + extends it) and each cell's event state by `(route ID, row)`, as #1648 D2 carries a fader cell. - **D4. Hand edits where automation wins** (OQ1 = C, "other rows"). A `SET_CONTROL` on an automated - `gain_db` or matrix row stores the fallback value, gives no record and commits `model_only` (draft - 10 D3's rule); the control list shows `automated = true` (draft 11b D4). + `gain_db` or matrix row stores the fallback value, gives no record and commits `model_only` ( + #1648 D3's rule); the control list shows `automated = true` (#1650 D4). - **D5. A hand send mute ORs** with the automated send mute: the route's own static `mute` stays a live value, written into the cell's flags word, and the gate is `curve_mute || static_mute`. This is the reading of OQ1's "mute ORs with the automated mute" for a send mute; root confirms it at - filing. If root reads the answer as "strip mute only", D5 becomes draft 10 D3's `model_only` rule + filing. If root reads the answer as "strip mute only", D5 becomes #1648 D3's `model_only` rule and gate 4's mute case expects that. - **D6. Values the session does not automate.** On a route with an automated row, a live edit of another row (gain, a matrix coefficient, mute, or `follows_mute`) writes the seven-word cell @@ -112,7 +112,7 @@ route into the output stays prepared: its rows are not automatable, as today. ## Hazards -- **The route op is shared with draft 13b.** This slice extends the evaluation 13b builds; it adds +- **The route op is shared with #1652.** This slice extends the evaluation #1652 builds; it adds no second route evaluator. - **Latency.** The send changes at its tap's arrival, which for a pre-insert tap is earlier in render time than the strip's events; after the send's compensation delay both meet on one timeline @@ -151,10 +151,10 @@ Commands: `cargo test --locked -p host-core --features test-support`, `cargo tes ## Dependencies -- Drafts 13a, 13b and 13c (the route op's evaluation, the seven-word cell and its commit path). -- Draft 10 D1 (`automation_row_renders`), draft 11b D4 (`automated` in the list), amended draft 02 +- #1651, #1652 and #1653 (the route op's evaluation, the seven-word cell and its commit path). +- #1648 D1 (`automation_row_renders`), #1650 D4 (`automated` in the list), amended #1645 (the shape rule). -- Batch R2, after 13c. It is a complete outcome alone; it is not in a must-land group. +- Batch R2, after #1653. It is a complete outcome alone; it is not in a must-land group. ## Amendment (controls coordinator, batch 10, 2026-10-14) @@ -163,7 +163,7 @@ hand offset; a hand send mute ORs (D5 confirmed); on the send matrix automation `follows_mute` is not automatable (#1632). - **D1, gain cell.** For an automated `gain_db`, the seven-word cell's `gain` word holds the hand - offset in dB (`f32` bits; draft 11b's offset field, amended for the send gain), not the static + offset in dB (`f32` bits; #1650's offset field, amended for the send gain), not the static gain, which is the fallback and is not heard. At each gain event the route op computes `g = clamp(f64(curve) + f64(offset), -144, 24)`, rounded once to `f32`, and passes `g` to the route's own dB conversion (`route_transform`). An offset of `±0.0` gives the bits of no offset: @@ -171,10 +171,10 @@ hand offset; a hand send mute ORs (D5 confirmed); on the send matrix automation curve value in the route gain domain, and the conversion of `±0` is the same gain (gate 1 holds it). - **D4 is replaced for the gain.** A `SET_CONTROL` on an automated send `gain_db` writes the entry's - offset (draft 11b D3, amended) and the seven-word cell's `gain` word, with the ramp + offset (#1650 D3, amended) and the seven-word cell's `gain` word, with the ramp `LiveRamps::resolve(SendGain, the edit's ramp)`; the path is `live`. Render reads the new word at the next block entry and retargets on the indexed route ramp over the cell's ramp word, only if - the coefficients' bits change (draft 11 D3's rule on the route). On an automated matrix row D4 + the coefficients' bits change (#1649 D3's rule on the route). On an automated matrix row D4 stands: the edit stores the fallback and commits `model_only`. - **D5 is confirmed** by root: a hand send mute ORs with the automated send mute. D5's "a reading for root to confirm" and its alternative are void. @@ -190,6 +190,6 @@ hand offset; a hand send mute ORs (D5 confirmed); on the send matrix automation Gate 1 adds a flat gain ride with offset `0` and with offset `-0.0`. - **Test value** gains: gate 3 turns red if a hand send gain move writes the fallback (automation wins) instead of the offset, or if the offset reaches the matrix. -- **Non-goals.** "VCA automation (README F20)" is draft 13e. -- **Dependencies** gain draft 11b's amended field and rule (batch R1) and #1632 (ordering only: its +- **Non-goals.** "VCA automation (README F20)" is #1655. +- **Dependencies** gain #1650's amended field and rule (batch R1) and #1632 (ordering only: its row rule refuses the `follows_mute` entry this slice no longer renders). diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/13e-render-stored-vca-offset-and-mute-automation.md b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md similarity index 84% rename from docs/handoffs/stored-automation-1058/proposed-specs/13e-render-stored-vca-offset-and-mute-automation.md rename to .github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md index 60ff04cb0..5f3c02a0f 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/13e-render-stored-vca-offset-and-mute-automation.md +++ b/.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md @@ -4,7 +4,7 @@ Proposed by *Research: render stored session automation in the engine, identical 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 drafts 13a-13c. +`2e6343a99`. Batch R2, after #1651-#1653. ## Product outcome @@ -25,26 +25,26 @@ rebuild. A session with no VCA automation renders the bits it renders today. `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.** Draft 09a D6 (README F16, root's decision): a member's gain is +- **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. Draft 09a D1 gives each automated fader lane an offsets cell (`S` - as one `f64`, one ramp word); draft 11 writes it on every VCA edit; draft 11b puts the lane's hand + 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.** Draft 13a (amended): the stage mute of an automated mute lane is +- **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. - Draft 13b (amended) gives a following send of an automated source lane a seven-word route cell + #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 draft 02: a toggle row takes only `step`. +- **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 draft 07's builder: `offset_db` +- **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)` (draft 09b), because a VCA has no audio of its own. + `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: @@ -53,46 +53,46 @@ rebuild. A session with no VCA automation renders the bits it renders today. 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 draft - 09a's and draft 11's, and no bit moves. + - 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 draft 09a's offsets cell plus one word, `own_db` (the member's static fader dB, + 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 (draft 11 D4's write, ramp + 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 (draft 11 D3). -- **D4. Mute composition.** A member lane reached by an automated VCA mute uses draft 13a's + 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 draft 08's `SetMute`, over the - `VcaMute` jump row for a VCA curve event. A following send of such a member uses draft 13b's + 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 (draft 11b's + - `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 (draft 11 D4's write). This reads root's ruling "send gain is a + 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` (draft 10 D3), and gate 4's offset case expects that. + `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 (draft 13a's terms write). + 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 - draft 11's live `S` write. + #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 draft 10 D1's `automation_row_renders`. A VCA +- **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 draft 10 D2 carries a fader cell. + 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 @@ -117,21 +117,21 @@ rebuild. A session with no VCA automation renders the bits it renders today. - `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` (draft 11's binary, extended) + `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 (draft 13d). +- 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 drafts 13a-13d; root sequences the merge. + shared with #1651-#1654; root sequences the merge. ## Objective gates @@ -150,7 +150,7 @@ F19), clippy `-D warnings`, `cargo fmt --all -- --check`. 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 (draft 13b's rule); + 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 @@ -174,11 +174,11 @@ F19), clippy `-D warnings`, `cargo fmt --all -- --check`. ## Dependencies -- Drafts 13a, 13b and 13c (the terms word, the seven-word route cell, the follow evaluation). -- Drafts 11 and 11b (the offsets cell, its write and the offset field), draft 10 D1 - (`automation_row_renders`), draft 12 (jump rows), amended draft 02 (the shape rule), draft 09b +- #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 13c. A complete outcome alone; not in a must-land group. +- Batch R2, after #1653. A complete outcome alone; not in a must-land group. ## Ruling (root, 2026-10-14) diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/14a-retarget-the-matrix-stage-inside-a-block.md b/.github/ISSUE_SPECS/1656-retarget-the-matrix-stage-inside-a-block.md similarity index 89% rename from docs/handoffs/stored-automation-1058/proposed-specs/14a-retarget-the-matrix-stage-inside-a-block.md rename to .github/ISSUE_SPECS/1656-retarget-the-matrix-stage-inside-a-block.md index 96f951c5d..df25b4b26 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/14a-retarget-the-matrix-stage-inside-a-block.md +++ b/.github/ISSUE_SPECS/1656-retarget-the-matrix-stage-inside-a-block.md @@ -9,8 +9,8 @@ to file. Code anchors verified on `6ee64f484`. Batch R2. The strip's matrix and pan stage can apply a retarget or an exact set at any sample offset inside a block, per bank lane, with the same bits as the block split at that offset. The fused fader and -matrix path declines any block with such an operation. This is the in-block model of draft 08 for -the matrix stage; draft 14b *Render stored pan and matrix automation* is its first user, in the +matrix path declines any block with such an operation. This is the in-block model of #1638 for +the matrix stage; #1657 *Render stored pan and matrix automation* is its first user, in the same batch, so `main` never holds it with no caller. No rendered bit moves. ## Context @@ -23,16 +23,16 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. `:787`) drains its lanes at block entry (`drain_matrix_controls`, `:1108`). The fused `FaderMatrixBankProcessor` (`:831`, impl `:1172-1261`) runs fader and matrix in one traversal only through `try_process_settled_with_matrix`, which returns false while any ramp runs - (`crates/builtins/src/lib.rs:3926-3935`); draft 08 D5 makes it decline a block with any fader + (`crates/builtins/src/lib.rs:3926-3935`); #1638 D5 makes it decline a block with any fader operation. Test-support counters record which path ran. -- **Draft 08** gives the fader stage its timed operations and the split model this slice follows. +- **#1638** gives the fader stage its timed operations and the split model this slice follows. - *Keep every trim, fader and matrix ramp inside its endpoints* (#1408) changes the ramp kernels' last-step law; it is a per-sample rule inside the kernel, so it does not change where operations go. ## Decisions frozen for this slice -- **D1. In-block operations on the matrix stage**, on draft 08's model: per bank lane, at an offset, +- **D1. In-block operations on the matrix stage**, on #1638's model: per bank lane, at an offset, "retarget to a 2x2 over `n` samples" (`set_target_over`) and "set a 2x2 exactly". Between operations the existing kernels run; operations at one offset apply in lane order. The stage is class A: a block with an operation at offset `o` equals the same block split at `o` with the @@ -55,7 +55,7 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. ## Non-goals -- Stored pan and matrix automation, its events, group cell and edits (draft 14b). +- Stored pan and matrix automation, its events, group cell and edits (#1657). ## Hazards @@ -93,14 +93,14 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. Batch R2. Direct dependency: -- Draft 08 *Apply timed operations inside a block on the strip fader stage* (the model and the +- #1638 *Apply timed operations inside a block on the strip fader stage* (the model and the fused path's fader half). -Draft 14b depends on this draft. +#1657 depends on this draft. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. - No design change. Anchors in `crates/builtins/src/lib.rs` and `crates/builtins-compiler/src/lib.rs` - have moved; re-read them at filing. #1408 has landed. Realtime policy as in draft 07's amendment. + have moved; re-read them at filing. #1408 has landed. Realtime policy as in #1637's amendment. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/14b-render-stored-pan-and-matrix-automation.md b/.github/ISSUE_SPECS/1657-render-stored-pan-and-matrix-automation.md similarity index 94% rename from docs/handoffs/stored-automation-1058/proposed-specs/14b-render-stored-pan-and-matrix-automation.md rename to .github/ISSUE_SPECS/1657-render-stored-pan-and-matrix-automation.md index 6ebe4c934..a7800ce06 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/14b-render-stored-pan-and-matrix-automation.md +++ b/.github/ISSUE_SPECS/1657-render-stored-pan-and-matrix-automation.md @@ -22,7 +22,7 @@ group-cell write on both hosts: it moves that cell and never overwrites the auto input (`ll`, `rl`), `right` the right input (`lr`, `rr`). `lower_matrix_or_pan` is the one lowering (`crates/builtins-compiler/src/lib.rs:4925-4940`). The model is `MatrixOrPan` (`crates/session/src/model.rs:655-680`). -- **The in-block operations** on the matrix stage and the fused path's rule are draft 14a's. +- **The in-block operations** on the matrix stage and the fused path's rule are #1656's. - **The seam.** The matrix is seam-side: "the 2x2 matrix **is** the seam" (`crates/builtins-compiler/src/lib.rs:817-829`; `SEAM_SIDE_WITNESS`, `:391-398`). A collapsed track duplicates its plane into the matrix stage, whose two channels' words are free to differ. @@ -52,9 +52,9 @@ group-cell write on both hosts: it moves that cell and never overwrites the auto (the automated ones carry no meaning there), and the ramp. Every other strip keeps #1312's meaning; #1312 is not amended. Preparation seeds it with the session's static values. - Grid period and ramp 64, completion `End` (`τ + 63`): the kernel assigns the target exactly on - the ramp's last sample. Jumps ramp over the session pan length (draft 12's `pan` word). The + the ramp's last sample. Jumps ramp over the session pan length (#1639's `pan` word). The strip's `smoothing_samples` is a live-record rule (#1054 D5) and is not read here. - - A seek where it reaches the matrix node sets the target exactly (draft 14a's set operation). + - A seek where it reaches the matrix node sets the target exactly (#1656's set operation). - Events of the cells of one group at one offset make one target, so the group retargets at most once per offset. Only a target whose bits change retargets. - At the next block entry after the group cell changes, render builds the group's target from the @@ -62,9 +62,9 @@ group-cell write on both hosts: it moves that cell and never overwrites the auto sample, only if the target's bits change. Grid events inside that ramp are held (README "Held events"). - **D2. Edits, one rule on both hosts.** - - The classifier's row list (draft 10 D1) gains rows 7-10 and 12: an edit of their automation is - a carried rebuild; cells carry by draft 10's address. - - A static edit of an automated pan position or coefficient gives no record (draft 10 D3). + - The classifier's row list (#1648 D1) gains rows 7-10 and 12: an edit of their automation is + a carried rebuild; cells carry by #1648's address. + - A static edit of an automated pan position or coefficient gives no record (#1648 D3). - A static edit of a cell the session does not automate, in a group that has an automated cell, writes the group cell (the two pan positions or the four coefficients, with the ramp word `LiveRamps::resolve(Matrix, the edit's own ramp)`, *Carry an optional per-edit ramp length on @@ -78,7 +78,7 @@ group-cell write on both hosts: it moves that cell and never overwrites the auto as fallback values, and the other cells go to the group cell. - **D3. Mono collapse.** The matrix stage is seam-side (Context), so pan and matrix automation, symmetric or not, never declines a track's collapse. (README A1.5's collapse rule applies to the - input rows only; drafts 15 and 16b apply it.) + input rows only; #1658 and #1660 apply it.) - **D4. The acked-batch question: can an ack ever precede a drop? No.** Curve events come from the plan; an edit of an automated group gives no record, a group-cell write after every check, or a rebuild; a cell write cannot fail. @@ -102,7 +102,7 @@ group-cell write on both hosts: it moves that cell and never overwrites the auto ## Non-goals -- The in-block operations (draft 14a). +- The in-block operations (#1656). - Send matrix automation (OQ2). Options B, C and D of OQ1. A pan law other than `pan_matrix`. ## Hazards @@ -178,21 +178,21 @@ group-cell write on both hosts: it moves that cell and never overwrites the auto Batch R2. Direct dependencies: -- Draft 14a *Retarget the matrix stage inside a block*. -- Draft 10 *Classify fader automation edits as carried rebuilds* (the row list, the predicate and - the carry; it brings #1382 through draft 09a). -- Draft 12 *Hold the automation jump lengths in a plan cell* (the `pan` word). +- #1656 *Retarget the matrix stage inside a block*. +- #1648 *Classify fader automation edits as carried rebuilds* (the row list, the predicate and + the carry; it brings #1382 through #1646). +- #1639 *Hold the automation jump lengths in a plan cell* (the `pan` word). -Draft 07's events, the cells of *Hold live values in latest-target cells on both hosts* (#1312) and +#1637's events, the cells of *Hold live values in latest-target cells on both hosts* (#1312) and *Session `controlSmoothing`: configurable ramp lengths for live mute, fader and pan changes* (#1054) -arrive through drafts 10 and 12. +arrive through #1648 and #1639. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. - **Automation wins on pan and matrix** (OQ1 = C, "other rows"): D2's static-edit rule stands. This - is the first rendering row where it applies, so this slice takes draft 10's former gate 5 (a + is the first rendering row where it applies, so this slice takes #1648's former gate 5 (a static edit of an automated pan is `model_only` on both hosts, through `hosts/host-web/tests/control_parity.rs`). - **`matrix_mode` is live** (C12). A live switch on a strip with pan or matrix automation makes the diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/15-render-stored-input-trim-and-polarity-automation.md b/.github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md similarity index 93% rename from docs/handoffs/stored-automation-1058/proposed-specs/15-render-stored-input-trim-and-polarity-automation.md rename to .github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md index 6a48f0b24..3c4e121ac 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/15-render-stored-input-trim-and-polarity-automation.md +++ b/.github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md @@ -13,7 +13,7 @@ polarity step flips the sign at its exact sample and ramps through zero over the length. A track with a `both` entry keeps its mono collapse; a track whose input automation differs between its lanes declines it. A trim or polarity automation edit is a carried rebuild. A static edit of an automated row, and a browser live command on it, commit as `model_only` on both hosts -through the one shared rule of draft 10. +through the one shared rule of #1648. ## Context @@ -40,7 +40,7 @@ through the one shared rule of draft 10. - **The C ABI input path.** *Apply value-only input trim and polarity edits to the running C ABI plan* (#1261) emits `TrimDb` and `PolarityInvert` records, and *Hold strip input-lane values in latest-target cells* (#1346) holds them in cells. -- **Jump lengths** (#1054 D3): `fader_ms` for trim, `mute_ms` for polarity (draft 12's words). +- **Jump lengths** (#1054 D3): `fader_ms` for trim, `mute_ms` for polarity (#1639's words). - **Browser edits.** Today `COMMAND_TRIM_DB = 10` and `COMMAND_POLARITY_INVERT = 11` (`hosts/host-web/src/lib.rs:882`, `:891`). After *Admit browser live edits in the Worker through the committed model* (#1382) every browser live edit reaches the shared commit through the @@ -50,7 +50,7 @@ through the one shared rule of draft 10. ## Decisions frozen for this slice -- **D1. In-block operations on the input stage**, on draft 08's model: per bank lane and channel, at +- **D1. In-block operations on the input stage**, on #1638's model: per bank lane and channel, at an offset, "retarget the signed coefficient over `n`" and "set it exactly". Between operations the existing kernels run, filter plan included. Class A: a block with an operation at offset `o` equals the same block split at `o`, bit for bit, at `Scalar`, `Simd4` and `Simd8`, in the dual and @@ -63,7 +63,7 @@ through the one shared rule of draft 10. - **Trim** (row 2): grid period and ramp 64, completion `End` (`τ + 63`). The target is the signed coefficient: the trim gain of the curve value (`checked_trim_gain`'s conversion) with the lane's current sign. Jumps ramp over the fader length. - - **Polarity** (row 1, `step` with 0 or 1 only, draft 02 D1): jumps only. The target keeps the + - **Polarity** (row 1, `step` with 0 or 1 only, #1645 D1): jumps only. The target keeps the lane's remembered trim magnitude and takes the curve's sign, over the mute length. - **One target per offset.** A trim event and a polarity event at one offset make one signed target; its ramp is the polarity length when the sign changes there, else the trim's. @@ -79,9 +79,9 @@ through the one shared rule of draft 10. a `left` or `right` entry, unless both lanes' entries of that row have bit-identical segment tables, is asymmetric upstream of the seam and declines its collapse. A `both` entry keeps it, and its events apply identically to both channels, so the input stage's own witness stays symmetric. -- **D5. Edits.** The classifier's row list (draft 10 D1) gains rows 1 and 2: their automation edits +- **D5. Edits.** The classifier's row list (#1648 D1) gains rows 1 and 2: their automation edits are carried rebuilds. A static trim edit on an automated trim lane, or a static polarity edit on - an automated polarity lane, gives no record (draft 10 D3), on both hosts: a browser + an automated polarity lane, gives no record (#1648 D3), on both hosts: a browser live trim or polarity edit reaches that rule through the Worker's apply (#1382) and replies `model_only`. - **D6. The acked-batch question: can an ack ever precede a drop? No.** Curve events come from the @@ -108,7 +108,7 @@ through the one shared rule of draft 10. ## Non-goals -- HPF and LPF automation (drafts 16a and 16b), though the split runs the filter kernels unchanged. +- HPF and LPF automation (#1659 and #1660), though the split runs the filter kernels unchanged. - `delay_samples` (row 11), which is not a target. ## Hazards @@ -190,23 +190,23 @@ through the one shared rule of draft 10. Batch R2. Direct dependencies: -- Draft 10 *Classify fader automation edits as carried rebuilds*. -- Draft 12 *Hold the automation jump lengths in a plan cell* (the jump lengths). +- #1648 *Classify fader automation edits as carried rebuilds*. +- #1639 *Hold the automation jump lengths in a plan cell* (the jump lengths). - *Apply value-only input trim and polarity edits to the running C ABI plan* (#1261), for the C ABI's static-edit path. - *Hold strip input-lane values in latest-target cells* (#1346), for the C ABI's static-edit path. -Draft 07's events, draft 08's model (D1 follows it) and *Session `controlSmoothing`: configurable -ramp lengths for live mute, fader and pan changes* (#1054) arrive through drafts 10 and 12. +#1637's events, #1638's model (D1 follows it) and *Session `controlSmoothing`: configurable +ramp lengths for live mute, fader and pan changes* (#1054) arrive through #1648 and #1639. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. - **OQ1 = C: trim takes the hand offset.** When this slice adds rows 1 and 2 to - `automation_row_renders`, slice 11b's write rule applies to trim: `SET_CONTROL trim_db` on an + `automation_row_renders`, #1650's write rule applies to trim: `SET_CONTROL trim_db` on an automated trim lane writes that lane's stored offset (live). This slice adds the trim offsets - cell (one `f64` and a ramp word, 36 bytes per automated trim lane, charged as draft 09a D4 + cell (one `f64` and a ramp word, 36 bytes per automated trim lane, charged as #1646 D4 charges the fader's) and composes each trim event as `checked_trim_gain(clamp(f64(curve) + offset, -144, 24))` with the lane's current sign; a cell change jumps at the next block entry over `LiveRamps::resolve(InputTrim, edit ramp)`. A flat trim at `v` with offset `o` renders the bits of @@ -215,4 +215,4 @@ Checked against `12d4a4f9d`. `LiveRamps::for_row(PolarityInvert)`, twice `mute_ms` (decision 17's ramp row), not the mute length of D2. - **Browser commands** `COMMAND_TRIM_DB` and `COMMAND_POLARITY_INVERT` are deleted (#1565). - #1261 and #1346 have landed. Realtime policy as in draft 07's amendment. + #1261 and #1346 have landed. Realtime policy as in #1637's amendment. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/16a-design-an-input-filter-section-in-render.md b/.github/ISSUE_SPECS/1659-design-an-input-filter-section-in-render.md similarity index 90% rename from docs/handoffs/stored-automation-1058/proposed-specs/16a-design-an-input-filter-section-in-render.md rename to .github/ISSUE_SPECS/1659-design-an-input-filter-section-in-render.md index c5e673cd9..02ebc257a 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/16a-design-an-input-filter-section-in-render.md +++ b/.github/ISSUE_SPECS/1659-design-an-input-filter-section-in-render.md @@ -12,7 +12,7 @@ The strip's input stage can design one HPF or LPF section at a sample offset ins the same function and the same check the control plane uses, and start the existing 64-sample coefficient ramp there. A design that the check refuses keeps the current target and is counted; render never aborts a block for it. The function is one: preparation, the control plane and render -call it. Draft 16b *Render stored input HPF and LPF automation* is its first render user, in the +call it. #1660 *Render stored input HPF and LPF automation* is its first render user, in the same batch, so `main` never holds it with no caller. No rendered bit moves. ## Context @@ -32,7 +32,7 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. - **Off-render design today.** #808's amendment designs live targets off render (`docs/rulings/builtins-input-liveness-d2.md:6-14`) and leaves stored rendering to #1058 (`:163-180`). README A1.6 says why stored automation designs in render (finding F1). -- **The input stage split.** Draft 15 D1 gives the input stage in-block operations on draft 08's +- **The input stage split.** #1658 D1 gives the input stage in-block operations on #1638's model. - **The retarget law.** *Retarget a live input filter only through its designs and their mixtures* (#1407) sets the law every filter retarget follows (decision-15 record, `:589-593`). @@ -44,7 +44,7 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. `prepare_input_filter_pair`: `validate_input_filter_pair`, `SvfSection::design` of one section, `validate_prepared_input_filter_target`. `prepare_input_filter_pair` calls it twice. Render and the control plane call the same function. -- **D2. The in-block operation.** On draft 15's model, the input stage gains, per bank lane and +- **D2. The in-block operation.** On #1658's model, the input stage gains, per bank lane and channel, "design section `k` for `(hpf_hz, lpf_hz)` at offset `o`": it calls D1's function and, on success, applies the target through `apply_prepared_filter` at `o`, which ramps over the fixed 64 samples and refreshes the plan; and "set section `k` exactly at `o`", which writes the designed @@ -53,7 +53,7 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. - **D3. A refused design** keeps the current target and adds one to the input bank's `automation_designs_refused` count, which a readback reports. - **D4. Work.** One operation is one `math::tan`, the design arithmetic and two `math::sqrt`, scalar - `f64`, with no allocation (README A1.7). Draft 16b bounds the operations per block. For that + `f64`, with no allocation (README A1.7). #1660 bounds the operations per block. For that bound's gate, builtins exposes the design count behind its `test-support` feature (a public reader of the count `FILTER_DESIGN_CALLS` keeps, `crates/builtins/src/lib.rs:723-724`), so a builtins-compiler test can read it. @@ -70,13 +70,13 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. ## Non-goals -- Stored filter automation, its events, group cell, tail, collapse rule and edits (draft 16b). EQ - automation (draft 20). Variable filter slopes (#191). +- Stored filter automation, its events, group cell, tail, collapse rule and edits (#1660). EQ + automation (#1667). Variable filter slopes (#191). ## Hazards - **F1 is not confirmed yet.** D2 puts a filter design on the render thread. If root refuses F1, - this slice and draft 16b stop; the alternatives fail A4 or A5 (README A1.6). + this slice and #1660 stop; the alternatives fail A4 or A5 (README A1.6). - **The realtime policy region.** `SvfSection::design` counts calls through a `#[cfg(test)]` thread-local (`crates/builtins/src/lib.rs:724`, `:829`). Production builds have no such access; `check-realtime-policy.sh` and `audit capi` must accept the call path. @@ -94,7 +94,7 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. the identity, both integrators are `+0.0`, and the next block takes the elided path. 3. **Refused design** (builtins unit test, new). A value fed past preparation that the check refuses keeps the target and adds one to `automation_designs_refused`. -4. **Partition identity** (same builtins file, new). Draft 15 D1's identity with a design operation +4. **Partition identity** (same builtins file, new). #1658 D1's identity with a design operation at `o` in `{1, 63, 64, 127}` (`q = 128`) and in a block of 100, at `Scalar`, `Simd4` and `Simd8`. 5. **No rendered bit moves.** Every existing render test passes with unchanged digests; `cargo build --locked --release -p audit -p capi && ./target/release/audit capi` gives the same @@ -121,11 +121,11 @@ same batch, so `main` never holds it with no caller. No rendered bit moves. Batch R2. Direct dependencies: -- Draft 15 *Render stored input trim and polarity automation* (the input stage split, D1). +- #1658 *Render stored input trim and polarity automation* (the input stage split, D1). - *Retarget a live input filter only through its designs and their mixtures* (#1407). - Root's confirmation of README finding F1. -Draft 16b depends on this draft. +#1660 depends on this draft. ## Amendment (controls coordinator, 2026-10-14) @@ -137,14 +137,14 @@ Checked against `12d4a4f9d`. off render" stays true for live edits). It still waits for root's confirmation of F1. - **Anchors.** `SvfSection::design` is now `crates/builtins/src/lib.rs:837-875`; the test counter is `FILTER_DESIGN_CALLS` (`:839`). #1407 has landed; #1602 retired the input-design bound cache. -- Realtime policy as in draft 07's amendment. +- Realtime policy as in #1637's amendment. ## Amendment (controls coordinator, batch 10, 2026-10-14) Root's rulings (README "Root's rulings (2026-10-14)"). - **F1 is decided: option A.** Render designs a section at events with D1's function, the one the - control plane and preparation call; D4's count per block is bounded (draft 16b D4) and the render + control plane and preparation call; D4's count per block is bounded (#1660 D4) and the render call sits in a realtime-policy region; live edits keep designing off render. The hazard "F1 is not confirmed yet" and the dependency "Root's confirmation of README finding F1" are met. - **D5 (new). The contract amendment, for owner review.** This slice changes the texts that say @@ -161,6 +161,6 @@ Root's rulings (README "Root's rulings (2026-10-14)"). automation designs in render (F1 = A). Each changed text says "for owner review (root's application, 2026-10-14)". The amendment is root's application, subject to the owner's review, as decision 17's classifications are; root - tells the owner. An owner refusal reopens this slice, draft 16b and draft 20. The EQ's own - render-path comments (`crates/parametric-eq/src/lib.rs`) are draft 20's (its D8). + tells the owner. An owner refusal reopens this slice, #1660 and #1667. The EQ's own + render-path comments (`crates/parametric-eq/src/lib.rs`) are #1667's (its D8). - **Authorized paths** gain the four documents and the doc comments above. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/16b-render-stored-input-filter-automation.md b/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md similarity index 88% rename from docs/handoffs/stored-automation-1058/proposed-specs/16b-render-stored-input-filter-automation.md rename to .github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md index 5dc26560f..afa434079 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/16b-render-stored-input-filter-automation.md +++ b/.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md @@ -9,7 +9,7 @@ R2. ## Product outcome A producer's stored input HPF and LPF sweeps (rows 3 and 4) play on every host. At each event -render designs the filter section with draft 16a's operation, so the coefficients reach each +render designs the filter section with #1659's operation, so the coefficients reach each designed target exactly and the lane's HPF stays below its LPF at every sample. A filter turns off only by a step, and the section then settles to identity and elides again. A strip with filter automation reports the live input tail bound. A filter automation edit is a carried rebuild; a @@ -20,7 +20,7 @@ refused with `builtin.filter.order`, with nothing changed. ## Context -- **The operation.** Draft 16a: `prepare_input_filter_section` and the input stage's in-block design +- **The operation.** #1659: `prepare_input_filter_section` and the input stage's in-block design and set operations, with the refused-design count. - **The C ABI filter path.** *Apply value-only input HPF and LPF edits to the running C ABI plan through prepared targets* (#1262) designs targets in the classifier through @@ -30,24 +30,24 @@ refused with `builtin.filter.order`, with nothing changed. edits in the Worker through the committed model* (#1382), a browser live filter edit (today `COMMAND_INPUT_FILTERS`, `hosts/host-web/src/lib.rs:893`) reaches the shared commit through the Worker's apply and takes the same path. -- **The order check.** Draft 02 D2's `input_filter_order_diagnostics(model)` checks a lane's curves +- **The order check.** #1645 D2's `input_filter_order_diagnostics(model)` checks a lane's curves against each other or a static value, with the `2^-22` margin, and reads only the model, so it - can check a candidate model (draft 02 D2, "For a later live edit"). + can check a candidate model (#1645 D2, "For a later live edit"). - **Tails.** *State a bounded tail and an exact-rest bound for every node* (#1329) D4 gives a fixed design its own tail, and D5 gives a strip with a live input lane `input_section_live_bound(rate)`, the bound over any history of targets and 64-sample ramps. - **Docs.** `docs/BUILTINS_AND_METERING_V1.md:31-34` still calls the filters prepared-only. -- **Draft 15 D4** gives the input rows' collapse rule. +- **#1658 D4** gives the input rows' collapse rule. ## Decisions frozen for this slice - **D1. The pair is a target group, per strip lane (left, right).** At each event of the HPF cell, render designs section 0 from the HPF curve value and the LPF value; at each LPF event, section 1 from the LPF curve value and the HPF value. A value comes from its curve when automated, else from - the group cell. One draft 16a design operation per designed section. Grid period and ramp 64, + the group cell. One #1659 design operation per designed section. Grid period and ramp 64, completion `After`: an event at `τ` targets the curve value at `τ + 64`, the sample the ramp reaches its target. A jump ramps over the same fixed 64 samples. A seek where it reaches the - input node uses draft 16a's set operation. Render skips the operation when the curve value's bits + input node uses #1659's set operation. Render skips the operation when the curve value's bits did not change. - **D2. The group cell.** For a lane whose pair has an automated filter, this slice gives #1346's filter cell for the other section the meaning "group cell": the semantic pair and no @@ -55,7 +55,7 @@ refused with `builtin.filter.order`, with nothing changed. with the session's static value. At the next block entry after it changes, render designs that section by D1 from the new value and the curve and applies it over the fixed 64-sample ramp, if its words change. -- **D3. Off.** "Off" (`0`) is reached only by a `step` (draft 02 D1). Its event designs the identity +- **D3. Off.** "Off" (`0`) is reached only by a `step` (#1645 D1). Its event designs the identity section, and the existing disabled completion clears the integrators; the plan then elides it. - **D4. Bounded work.** Per section group and block, at most `(⌈q/64⌉ + 1) + Σ_cells(⌈q/64⌉ + 1) + 1 seek + 1 cell change` designs: the grid events of the @@ -65,15 +65,15 @@ refused with `builtin.filter.order`, with nothing changed. section group has one cell, and a strip lane designs at most `2·(2⌈q/64⌉ + 4)` times per block. - **D5. Tail.** A strip with filter automation reports `input_section_live_bound(rate)` (#1329 D5), as a strip with a live input lane does: its targets change on render. -- **D6. Mono collapse.** Rows 3 and 4 take draft 15 D4's rule: a `left` or `right` entry declines the +- **D6. Mono collapse.** Rows 3 and 4 take #1658 D4's rule: a `left` or `right` entry declines the collapse unless both lanes' entries are bit-identical; a `both` entry keeps it, and its designs apply to both channels. - **D7. Edits, one rule on both hosts.** - - The classifier's row list (draft 10 D1) gains rows 3 and 4: their automation edits are carried - rebuilds. A static edit of an automated filter gives no record (draft 10 D3). + - The classifier's row list (#1648 D1) gains rows 3 and 4: their automation edits are carried + rebuilds. A static edit of an automated filter gives no record (#1648 D3). - A static edit of the other filter of an automated pair first runs `input_filter_order_diagnostics` on `next`. A diagnostic gives `LiveRebuild::Domain`; the - rebuild's preparation refuses with the same `builtin.filter.order` (draft 02 D3), and nothing is + rebuild's preparation refuses with the same `builtin.filter.order` (#1645 D3), and nothing is committed. Otherwise the commit writes the group cell (D2), path `live`: the group cell is the live slot that decision 14 rule 2 asks for. An edit whose normalized value (`validate_input_filter_pair`) leaves the cell's bits unchanged gives nothing. @@ -83,7 +83,7 @@ refused with `builtin.filter.order`, with nothing changed. - #1262 is not amended: this slice adds the automated-pair branch to the classifier path #1262 builds. - **D8. Docs.** `docs/BUILTINS_AND_METERING_V1.md:31-34` states the filters' liveness and the - stored-automation rule: render designs stored-automation targets with draft 16a's function; live + stored-automation rule: render designs stored-automation targets with #1659's function; live edits of a pair with no automation stay designed off render (#808). - **D9. The acked-batch question: can an ack ever precede a drop? No.** Curve events come from the plan; the order check runs before any write; a group-cell write cannot fail; an edit of an @@ -110,14 +110,14 @@ refused with `builtin.filter.order`, with nothing changed. ## Non-goals -- The design function and the in-block operation (draft 16a). The preparer's design rules (#1262). - EQ automation (draft 20). Variable filter slopes (#191). +- The design function and the in-block operation (#1659). The preparer's design rules (#1262). + EQ automation (#1667). Variable filter slopes (#191). - A designs-per-second cap: D4's bound is per block and independent of song length. ## Hazards -- **F1 is not confirmed yet.** If root refuses F1, this slice stops (draft 16a Hazards). -- **Draft 02's margin refuses some edits that never cross** (mixed shapes, by design). The refusal is +- **F1 is not confirmed yet.** If root refuses F1, this slice stops (#1659 Hazards). +- **#1645's margin refuses some edits that never cross** (mixed shapes, by design). The refusal is the same at preparation and here, so a committed model never fails its own rebuild. ## Objective gates @@ -128,7 +128,7 @@ refused with `builtin.filter.order`, with nothing changed. under a static LPF: at every event the stage's target words equal `prepare_input_filter_section` of the curve value at `τ + 64`, bit for bit. 3. **Bounded designs** (builtins-compiler unit test, new). Over a block of 128 and one of 100, with a - moving HPF and a moving LPF, the design count is at most D4's bound. The count is the `test-support` counter draft 16a adds + moving HPF and a moving LPF, the design count is at most D4's bound. The count is the `test-support` counter #1659 adds (`FILTER_DESIGN_CALLS` itself is `#[cfg(test)]` and private to builtins, `crates/builtins/src/lib.rs:723-724`, so no other crate can read it). 4. **Tail.** A strip with HPF automation and no live lane reports `input_section_live_bound(rate)`; @@ -191,31 +191,31 @@ refused with `builtin.filter.order`, with nothing changed. Batch R2. Direct dependencies: -- Draft 16a *Design an input filter section in render* (it brings draft 15). +- #1659 *Design an input filter section in render* (it brings #1658). - *State a bounded tail and an exact-rest bound for every node* (#1329). - *Apply value-only input HPF and LPF edits to the running C ABI plan through prepared targets* (#1262): the classifier path D7 extends. - *Hold strip input-lane values in latest-target cells* (#1346): the cell D2 lays out. - Root's confirmation of README finding F1. -Drafts 02, 07, 10 and 12 arrive through drafts 15 and 16a. +#1645, #1637, #1648 and #1639 arrive through #1658 and #1659. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. - **Automation wins on HPF and LPF** (OQ1 = C): D7's static-edit rule stands. -- **The order check** is amended draft 02's `control.automation.order` rule in `controls`; the +- **The order check** is amended #1645's `control.automation.order` rule in `controls`; the live-edit check of D7 calls the same function on `next`, and its refusal stays `LiveRebuild::Domain` with preparation's `builtin.filter.order` for a static pair. - **The C ABI filter path** is #1556 (filters live on the C ABI) over `InputFilterPreparer` (`crates/host-core/src/control_preparation.rs`); `COMMAND_INPUT_FILTERS` is deleted (#1565). #1262, #1329 and #1346 have landed. -- **F1** as in draft 16a's amendment. +- **F1** as in #1659's amendment. ## Amendment (controls coordinator, batch 10, 2026-10-14) - **F1 is decided: option A** (README "Root's rulings (2026-10-14)"). The hazard "F1 is not confirmed yet" and the dependency on root's confirmation are met. D4's per-block bound is the bound F1's - condition 2 names; its gate stays. The contract text is draft 16a's (D5 of its batch-10 + condition 2 names; its gate stays. The contract text is #1659's (D5 of its batch-10 amendment), for owner review. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/17a-prove-effects-partition-invariant-with-point-spans.md b/.github/ISSUE_SPECS/1661-prove-effects-partition-invariant-with-point-spans.md similarity index 96% rename from docs/handoffs/stored-automation-1058/proposed-specs/17a-prove-effects-partition-invariant-with-point-spans.md rename to .github/ISSUE_SPECS/1661-prove-effects-partition-invariant-with-point-spans.md index 3406bcb54..58c817fad 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/17a-prove-effects-partition-invariant-with-point-spans.md +++ b/.github/ISSUE_SPECS/1661-prove-effects-partition-invariant-with-point-spans.md @@ -10,10 +10,10 @@ to file. Code anchors verified on `6ee64f484`. Batch R3. Every launch effect is proven to render the same bits when a `Point` span arrives at a sample in the middle of a quantum, in a block that starts at that sample, as when every sample is a block of its own: per node, and in banks at Simd4 and Simd8 against the scalar oracle. Stored effect -automation (drafts 17b and 18a) processes an effect node in pieces that start at event samples, +automation (#1662 and #1663) processes an effect node in pieces that start at event samples, and A1.6 relies on this property. Today no test stages a `Point` at a mid-quantum sample, so the property is unproven. This slice changes the conformance harness only. It lands in batch R3, -with its first user, draft 17b. +with its first user, #1662. ## Context @@ -59,7 +59,7 @@ with its first user, draft 17b. - The delay's time crossfade cannot restart. A time `Point` that lands while a crossfade runs takes effect at that crossfade's end, which is a chunk boundary in pieces and in 1-frame blocks alike. -- The EQ stages no span. Its stored events are prepared targets at piece starts (draft 20), so this +- The EQ stages no span. Its stored events are prepared targets at piece starts (#1667), so this slice proves it with targets. ## Decisions frozen for this slice @@ -101,7 +101,7 @@ with its first user, draft 17b. ## Non-goals -- The engine's piece loop (draft 17b). Compiling cells (draft 18a). +- The engine's piece loop (#1662). Compiling cells (#1663). - Any change to an effect crate's source (D3). - Sample-rate parameters: no launch descriptor has one. @@ -142,7 +142,7 @@ Batch R3. Direct dependencies: - *Multiband compressor: a ramp's cut moves a lane's bits in a bank* (#1069). -Draft 17b *Process an effect node in pieces at automation events* depends on this draft and lands +#1662 *Process an effect node in pieces at automation events* depends on this draft and lands in the same batch. ## Amendment (controls coordinator, 2026-10-14) diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/17b-process-an-effect-node-in-pieces-at-automation-events.md b/.github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md similarity index 86% rename from docs/handoffs/stored-automation-1058/proposed-specs/17b-process-an-effect-node-in-pieces-at-automation-events.md rename to .github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md index 303fd0036..e6ca3ef66 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/17b-process-an-effect-node-in-pieces-at-automation-events.md +++ b/.github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md @@ -11,9 +11,9 @@ An effect node, per node or in a bank, whose stored automation has an event insi that block in pieces. Each piece ends at the next event sample. At each piece start, every cell with an event there stages one `Point` at that sample, the only span shape the contract accepts. The pieces render the same bits as a render whose every event sample is a block start, which -draft 17a proves for every launch effect. A node with no event in the block renders exactly as -today: one call. This is the building block that lets draft 18a drive effect parameters at -sample-exact, quantum-independent times. It merges in the batch of its first user, draft 18a +#1661 proves for every launch effect. A node with no event in the block renders exactly as +today: one call. This is the building block that lets #1663 drive effect parameters at +sample-exact, quantum-independent times. It merges in the batch of its first user, #1663 (batch R3). ## Context @@ -25,7 +25,7 @@ sample-exact, quantum-independent times. It merges in the batch of its first use - A bank block holds exactly `frames x lanes` words per plane (`EffectBankProcessBlock::new`, `:1359-1397`). The planes are AoSoA, so frames `[a, b)` of every lane are the contiguous words `[a·W, b·W)`: a piece needs no copy. -- Draft 17a proves that a `Point` at a mid-quantum call start renders the bits of 1-frame blocks, +- #1661 proves that a `Point` at a mid-quantum call start renders the bits of 1-frame blocks, per node and in banks, for every launch effect. **The per-node stage** (`crates/graph/src/runtime.rs`). @@ -58,7 +58,7 @@ ramp in flight blocks the skip (`crates/compressor/src/lib.rs:568`; ## Decisions frozen for this slice - **D1. Pieces.** For a block `[r0, r0 + q)` of a node or bank slot with a stored program, the - stage asks each cell's event generator (draft 07) for the block's events. + stage asks each cell's event generator (#1637) for the block's events. - Piece starts are offset 0 and every event offset. A piece ends at the next start or at `q`. - A bank's piece starts are the union over its lanes: at most `1 + lanes·(2⌈q/64⌉ + 3)` pieces, and at most `q`. @@ -80,7 +80,7 @@ ramp in flight blocks the skip (`crates/compressor/src/lib.rs:568`; - **D4. No silent skip on an evented piece.** A piece with a staged `Point` takes the effect's normal path, by the rules in Context. This slice adds no skip and removes none. - **D5. One span per cell per piece.** An automated cell takes no live record (A10), so a piece - stages each cell at most once. With #1306's capacity (draft 18a) the window always holds + stages each cell at most once. With #1306's capacity (#1663) the window always holds `live + stored` spans, and `Staged.dropped` stays 0. A live span that addresses a cell the stage automates is an admission defect: a `debug_assert`; in release the stored `Point` is staged, the live span is skipped and a saturating `shadowed_live_spans` counter on the stage @@ -94,7 +94,7 @@ ramp in flight blocks the skip (`crates/compressor/src/lib.rs:568`; - A node or slot with no stored program keeps today's types, windows and calls byte for byte. - The rule "only a live channel has a window" (`crates/graph/src/runtime.rs:1206-1218`, `crates/rack/src/lib.rs:1014-1029`) becomes "a live channel or a stored program". The capacity - itself is draft 18a's. + itself is #1663's. - **D7. Symmetry.** - A `both` entry on a `PerLane` parameter is two cells with one curve and one `a(n)`. They emit equal events at equal offsets, and the stage stages them in one piece as a twin pair @@ -104,15 +104,15 @@ ramp in flight blocks the skip (`crates/compressor/src/lib.rs:568`; reports the `LIVE` term cleared in `lane_symmetry` (`crates/rack/src/lib.rs:1187-1193`, and the per-node witness) for the plan's life. A collapsed slot renders each piece with `process_bank_mono`. - - This is the render-side form of draft 18a D7's preparation rule: a live one-channel record or - an asymmetric program clears `LIVE` at render, and draft 18a D7 declines the collapse at + - This is the render-side form of #1663 D7's preparation rule: a live one-channel record or + an asymmetric program clears `LIVE` at render, and #1663 D7 declines the collapse at preparation for the same asymmetric program. -- **D8. Node time.** Node time reaches the stage as draft 07's `NodeSpan`. `graph` computes it for - each lane with draft 04a's reader, `timeline_block(s0 - a(n))`, from the block's source-read sample - `s0` and the lane's arrival `a(n)`, as draft 09b D1 does for the fader stage, and passes it into - the rack stage. The rack never reads draft 04b's timeline history itself. This slice adds no +- **D8. Node time.** Node time reaches the stage as #1637's `NodeSpan`. `graph` computes it for + each lane with #1640's reader, `timeline_block(s0 - a(n))`, from the block's source-read sample + `s0` and the lane's arrival `a(n)`, as #1647 D1 does for the fader stage, and passes it into + the rack stage. The rack never reads #1641's timeline history itself. This slice adds no second node-time path. -- **D9. The rack's dependency boundary.** `rack` gains the `automation` crate (draft 07), as A1.1 +- **D9. The rack's dependency boundary.** `rack` gains the `automation` crate (#1637), as A1.1 names. `scripts/check-rack-policy.sh:23` and its fixture in `scripts/test-rack-policy.sh:14` change to the three crates, with the reason in a comment. @@ -130,13 +130,13 @@ ramp in flight blocks the skip (`crates/compressor/src/lib.rs:568`; `execute_op` only), `crates/graph/Cargo.toml`, `crates/graph/tests/` (one new file). - `scripts/check-rack-policy.sh`, `scripts/test-rack-policy.sh` (D9 only). - `Cargo.lock` (the new rack and graph edges; `--locked` fails without it). Graph's `automation` - edge and its policy pins come from draft 13b, in batch R2 before this batch. + edge and its policy pins come from #1652, in batch R2 before this batch. ## Non-goals -- The effect-level proof (draft 17a). -- Compiling cells from the session, `a(n)`, window sizing (draft 18a); the seek step (draft 18b). -- The EQ's render-side design (draft 20). Classifier and carry (draft 19). +- The effect-level proof (#1661). +- Compiling cells from the session, `a(n)`, window sizing (#1663); the seek step (#1664). +- The EQ's render-side design (#1667). Classifier and carry (#1665). - Any change to an effect crate. - A silence skip that advances ramp state: finding F10's successor. @@ -155,7 +155,7 @@ ramp in flight blocks the skip (`crates/compressor/src/lib.rs:568`; 1. **The stages** (new tests in `crates/rack/tests/live_control_bank.rs` and in `crates/graph/src/runtime.rs`'s tests): a compressor slot at Simd4 and Simd8 and a per-node - delay, each with a stored program (built with draft 07's builder from neutral cell + delay, each with a stored program (built with #1637's builder from neutral cell descriptions) whose events fall at offsets 0, 1, 63, 64 and 127 of a 128-frame block, render the bits of the same instances driven by hand with the same `Point`s in 1-frame blocks. A slot with live spans and stored `Point`s in piece 0 stages them in canonical order. `Staged.dropped` and @@ -196,15 +196,15 @@ ramp in flight blocks the skip (`crates/compressor/src/lib.rs:568`; Batch R3. Direct dependencies: -- Draft 07 *Compile stored automation into per-cell events in node time*: the event generator and +- #1637 *Compile stored automation into per-cell events in node time*: the event generator and `NodeSpan`. -- Draft 17a *Prove every launch effect partition-invariant with Point spans*. -- Draft 04b *Give every plan a timeline that carries like a source*: the +- #1661 *Prove every launch effect partition-invariant with Point spans*. +- #1641 *Give every plan a timeline that carries like a source*: the `timeline_block` reader that `graph` calls for D8. - *Hold effect parameter, bypass and EQ-target values in latest-target cells* (#1345): this slice is written on its lane; D2's merge reads the window `stage` fills, whatever fills it. -It merges in batch R3 with its first user, draft 18a *Compile and bind stored effect parameter +It merges in batch R3 with its first user, #1663 *Compile and bind stored effect parameter automation*. ## Amendment (controls coordinator, 2026-10-14) @@ -216,6 +216,6 @@ Checked against `12d4a4f9d`. parameter cells (#1611 D3; #1621 D1 for the per-node window). D6 is obsolete: there is no "no live channel" case and no channel-less lane to add; the stage only gains the stored program. - **D5's bound** holds with the existing window: one slot per parameter cell, and an automated cell - takes no live record (A10, draft 19), so a piece stages each cell at most once. No #1306 term. + takes no live record (A10, #1665), so a piece stages each cell at most once. No #1306 term. - **D3 also keeps** the bypass crossfade step of #1341a per block. - **Dependencies.** #1345 has landed; #1611 and #1621 replace #1306 here. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/18a-compile-and-bind-stored-effect-automation.md b/.github/ISSUE_SPECS/1663-compile-and-bind-stored-effect-automation.md similarity index 86% rename from docs/handoffs/stored-automation-1058/proposed-specs/18a-compile-and-bind-stored-effect-automation.md rename to .github/ISSUE_SPECS/1663-compile-and-bind-stored-effect-automation.md index 7f673fde1..1e040b7d8 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/18a-compile-and-bind-stored-effect-automation.md +++ b/.github/ISSUE_SPECS/1663-compile-and-bind-stored-effect-automation.md @@ -15,7 +15,7 @@ the effect's stage with the node's arrival `a(n)`. The parameter follows the cur it equals the curve at each 64-sample grid ramp's completion (128 for the delay time) and jumps at the curve's discontinuities on the exact sample. A session with no stored effect automation renders the bits it renders today. The seek step, the browser host and quantum independence are -draft 18b; the parametric EQ is draft 20. This slice and draft 18b land in batch R3, in one push, +#1664; the parametric EQ is #1667. This slice and #1664 land in batch R3, in one push, with #1306, which sizes the span windows. ## Context @@ -24,8 +24,8 @@ with #1306, which sizes the span windows. - An effect target is `rack` `inserts` or `console`, an `effect_id`, a `parameter_id` and a `channel` (`crates/session/src/model.rs:874-955`). The session crate checks that it names a declared `(parameter_id, channel)` (`crates/session/src/validate.rs:890-967`). -- #1335 refuses a target that is not `automatable` with `AutomationRate::Block`. Draft 03a adds the - unit, domain and shape rules. #1626 adds one entry per lane and the hold rule. +- #1335 refuses a target that is not `automatable` with `AutomationRate::Block`. #1645 and + #1566's row rule (`controls::automation_diagnostics`) add the unit, domain and shape rules. #1626 adds one entry per lane and the hold rule. - Nothing renders the table (`docs/SESSION_SCHEMA_V1.md:220-225`). **The launch `Block` parameters** (`sdk/assets/miso-engine-v1-parameter-metadata.json`): 56 in @@ -68,8 +68,8 @@ only when none runs (`begin_transition`, `crates/delay/src/lib.rs:504-509`; `TRA #1306 D3 deletes that derivation in this batch. **Node arrivals.** PDC computes each node's arrival as the `max` over its inputs -(`crates/graph-compiler/src/pdc.rs:53-104`). #1285 D2 records each node's floored value; drafts 09a -and 09b give it to the fader stage. +(`crates/graph-compiler/src/pdc.rs:53-104`). #1285 D2 records each node's floored value; #1646 +and #1647 give it to the fader stage. ## Decisions frozen for this slice @@ -78,9 +78,9 @@ and 09b give it to the fader stage. one cell, its `both` entry two (`Left`, `Right`, one curve); a `Shared` parameter gives one `Both` cell. An `inserts` target resolves by insert ID and a `console` target by slot ID on that strip, as #1335 D2 resolves them. A target-owning effect (one with `target_preparation`, the EQ) gets - no span cell here (draft 20). A cell's stable address is `(strip ID, rack, effect ID, + no span cell here (#1667). A cell's stable address is `(strip ID, rack, effect ID, parameter_index, channel)`. -- **D2. The program.** Each cell takes draft 07's builder with: +- **D2. The program.** Each cell takes #1637's builder with: - grid period `G = max(64, L)` and grid ramp `L`, the descriptor's `smoothing_samples`: 64, and 128 for the delay time; - jump ramp `J = L` (`docs/EFFECT_CONTRACT_V1.md:133-134` makes the descriptor's length binding); @@ -91,7 +91,7 @@ and 09b give it to the fader stage. to the crossfade's last sample plus one (A1.4). Each program's node is the effect node; the binder gives it that node's floored arrival `a(n)` - (#1285 D2), as drafts 09a and 09b do for the fader node. + (#1285 D2), as #1646 and #1647 do for the fader node. - **D3. The prepared value.** An automated cell's prepared value is the curve at node time `-a(n)` (A1.4). Every segment starts at timeline 0 or later, and the hold rule holds the first `start_value` before the first segment; at timeline 0 every shape starts at `start_value`. So @@ -103,24 +103,24 @@ and 09b give it to the fader stage. the session does not automate (amendment to #1306 D1). #1306 owns the function; this slice passes it `C(i)`. - An instance with stored automation and no live lane gets a channel-less lane - (`EffectControlLane::without_channel` with its `initial_bypass`) and a window, so draft 17b's + (`EffectControlLane::without_channel` with its `initial_bypass`) and a window, so #1662's stage renders it. - - `Staged.dropped` stays 0: a piece stages at most one `Point` per cell (draft 17b D5). + - `Staged.dropped` stays 0: a piece stages at most one `Point` per cell (#1662 D5). - **D5. Plumbing.** `EffectPreparedEntry` gains `automation: Option>`. `into_effects` passes it as a `GraphEffectAutomationBinding` beside the control binding. The graph - binder hands it, with `a(n)`, to the stage draft 17b builds. Every plan that has no stored effect + binder hands it, with `a(n)`, to the stage #1662 builds. Every plan that has no stored effect automation carries `None` everywhere and binds today's node kinds. - **D6. Fresh cells.** A cell in a new plan, or in an instance a successor adds or restarts, starts with its prepared value as its current target. If the curve at its first block's node time differs, its first event is a jump at that block's first sample. -- **D7. Mono collapse.** This draft adds one preparation-time `DESIGNED` term, on the model of draft - 15 D4: an effect whose automation addresses one lane of a `PerLane` parameter, or both lanes with +- **D7. Mono collapse.** This draft adds one preparation-time `DESIGNED` term, on the model of + #1658 D4: an effect whose automation addresses one lane of a `PerLane` parameter, or both lanes with segment tables that are not bit-identical, has its prepared witness's `DESIGNED` term cleared (`ChannelSymmetryWitness::DESIGNED`, `crates/effect-contract/src/symmetry.rs:162`). `SessionPoolClasses` conjoins each prepared native effect's `DESIGNED` term into the track's class (`crates/builtins-compiler/src/lib.rs:4064-4067`), so the track declines its collapse. A `both` - entry keeps it. Draft 09a adds nothing to `track_mono_source` (`:3967`), which stays the source - rule. Draft 17b D7 is the render-side form of this rule. + entry keeps it. #1646 adds nothing to `track_mono_source` (`:3967`), which stays the source + rule. #1662 D7 is the render-side form of this rule. - **D8. Memory.** Effect programs are charged through `effect_control_resource` (`crates/graph-compiler/src/estimate.rs:188`): each program's bytes (`80 + 32·n` per cell, A4) and each window go to the graph plan rows there, and the exact-charge oracles stay exact. @@ -140,29 +140,29 @@ and 09b give it to the fader stage. - `crates/effect-compiler/src/prepare.rs` (the entry field and the window of automated instances). - `crates/graph-compiler/src/{ids.rs,estimate.rs}`, `crates/graph/src/lib.rs` (the binding type), `crates/graph/src/runtime.rs` (the binder only). -- `crates/rack/src/lib.rs` (only if draft 17b's constructor needs the binding's shape). +- `crates/rack/src/lib.rs` (only if #1662's constructor needs the binding's shape). - `crates/builtins-compiler/src/lib.rs` (D7's `DESIGNED` term only). - `crates/capi/src/runtime/` tests, `crates/capi/tests/resource_lifecycle.rs` (D8's oracle). - `crates/effect-compiler/Cargo.toml` (a normal `automation` dependency, for D5's `EffectAutomationProgram` field), `Cargo.lock`, and `scripts/check-effect-runtime-policy.sh` (`expected_compiler`, `:12`, which pins effect-compiler's exact dependency list; `automation` - joins it). host-core's `automation` edge comes from draft 09a; graph's from draft 13b. + joins it). host-core's `automation` edge comes from #1646; graph's from #1652. ## Non-goals -- The piece loop (draft 17b). The seek step, the browser host, quantum independence and the docs - (draft 18b). The EQ (draft 20). +- The piece loop (#1662). The seek step, the browser host, quantum independence and the docs + (#1664). The EQ (#1667). - The classifier, the one rule for a live edit of an automated cell on both hosts, and carry across a swap - (draft 19). Until draft 19, an effect automation edit stays model-only. + (#1665). Until #1665, an effect automation edit stays model-only. - The window sizing function itself (#1306). -- `AUTOMATION_ENQUEUE` (#1627, #1629, draft 22). Sample-rate parameters (none at launch, #1335 D1). +- `AUTOMATION_ENQUEUE` (#1627, #1629, #1668). Sample-rate parameters (none at launch, #1335 D1). ## Hazards -- **One push with draft 18b, draft 19 and #1306** (README "Must-land-together groups"). This - slice renders effect automation; draft 18b completes the seek and the browser; draft 19 makes an +- **One push with #1664, #1665 and #1306** (README "Must-land-together groups"). This + slice renders effect automation; #1664 completes the seek and the browser; #1665 makes an automation edit reach a running plan and stops a live record on an automated cell. Amended #1306 - sizes the window without an automated cell's live term (README A7), so before draft 19 a live + sizes the window without an automated cell's live term (README A7), so before #1665 a live record on such a cell can make a piece hold one span more than the window holds, and `Staged.dropped > 0` drops an acknowledged edit. The four merge in one push, so `main` never holds that state. @@ -233,18 +233,20 @@ and 09b give it to the fader stage. Batch R3. Direct dependencies: -- Draft 03a *Validate effect automation units, domains and shapes at preparation*. -- Draft 10 *Classify fader automation edits as carried rebuilds* (the per-row mask this batch +- #1645 *Validate builtin automation targets against their rows at preparation* and + #1566's row rule (`controls::automation_diagnostics`), which carry the unit, domain and shape rules of obsolete + draft 03a (not filed). +- #1648 *Classify fader automation edits as carried rebuilds* (the per-row mask this batch extends). -- Draft 17b *Process an effect node in pieces at automation events* (same batch). +- #1662 *Process an effect node in pieces at automation events* (same batch). - *Size each effect's automation span window from the producers its plan has* (#1306), in the same push. - *Hold effect parameter, bypass and EQ-target values in latest-target cells* (#1345). -Draft 07, drafts 09a and 09b (node time and `a(n)`), *Keep every node's latency from dropping +#1637, #1646 and #1647 (node time and `a(n)`), *Keep every node's latency from dropping during playback* (#1285) and *Refuse automation on effect parameters that are not block-rate* -(#1335) arrive through drafts 03a, 10 and 17b. Draft 18b *Render stored effect parameter -automation across seeks and on both hosts* and draft 19 *Classify and carry effect automation +(#1335) arrive through #1645, #1648 and #1662. #1664 *Render stored effect parameter +automation across seeks and on both hosts* and #1665 *Classify and carry effect automation edits* depend on this draft and land in the same push. ## Amendment (controls coordinator, 2026-10-14) @@ -254,8 +256,8 @@ Checked against `12d4a4f9d`. - **D4 is superseded by #1611 and #1621.** Every live effect window already holds the lane's parameter cells (#1611 D3 for banks, `crates/rack/src/lib.rs:1248-1264`; #1621 D1 per node), and every effect has its live lane on every plan (C6). So: no `capacity = live + stored`, no - channel-less lane, and no #1306 in this push (README F23). The must-land group is 18a, 18b and 19. -- **Validation** is #1566's row rule (`controls::automation_diagnostics`) plus amended draft 02's + channel-less lane, and no #1306 in this push (README F23). The must-land group is #1663, #1664 and #1665. +- **Validation** is #1566's row rule (`controls::automation_diagnostics`) plus amended #1645's shape rule; #1335's `effect_automation_diagnostics` is deleted. A cell is addressed by `ControlAddress` (owner strip, section `console(slot)` or `inserts(effect)`, control = parameter ID, lane). diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/18b-render-stored-effect-automation-across-seeks-and-hosts.md b/.github/ISSUE_SPECS/1664-render-stored-effect-automation-across-seeks-and-hosts.md similarity index 81% rename from docs/handoffs/stored-automation-1058/proposed-specs/18b-render-stored-effect-automation-across-seeks-and-hosts.md rename to .github/ISSUE_SPECS/1664-render-stored-effect-automation-across-seeks-and-hosts.md index 8246050e7..989ea5207 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/18b-render-stored-effect-automation-across-seeks-and-hosts.md +++ b/.github/ISSUE_SPECS/1664-render-stored-effect-automation-across-seeks-and-hosts.md @@ -7,25 +7,25 @@ to file. Code anchors verified on `6ee64f484`. Batch R3. ## Product outcome -Stored effect parameter automation, which draft 18a compiles and binds, follows a session seek +Stored effect parameter automation, which #1663 compiles and binds, follows a session seek and renders identically on both hosts and at any quantum. Where a session seek reaches an effect node, each automated cell stages one `Point` to the curve at the seek target, and the grid resumes from the new node time. The browser host renders the same automation as the C ABI. The same session renders the same bits at quantum 128 and at quantum 100. The contract and schema docs say -which spans render. This slice, draft 18a, draft 19 and #1306 land in batch R3, in one push +which spans render. This slice, #1663, #1665 and #1306 land in batch R3, in one push (README "Must-land-together groups"). ## Context -- **What draft 18a gives.** Each automated `Block` cell of a span-driven effect has a program - compiled by draft 07's builder (grid period and ramp `L` from the descriptor, 64 or 128 for the +- **What #1663 gives.** Each automated `Block` cell of a span-driven effect has a program + compiled by #1637's builder (grid period and ramp `L` from the descriptor, 64 or 128 for the delay time; completion `End`; "no restart" on the delay time), bound to the effect's stage with - the node's `a(n)`. The stage processes the node in pieces at events (draft 17b). + the node's `a(n)`. The stage processes the node in pieces at events (#1662). - **The ramp law.** A span-driven effect reaches a `Point`'s target at `τ + L - 1` (`LinearRamp::next_value`, `crates/effect-runtime/src/ramp.rs:140-155`). - **The seek rule** (A1.4): where a seek reaches a node, its node time steps; builtin lanes are set exactly there, and each effect cell stages a `Point` there, which the effect ramps over its - smoothing. Drafts 05 and 06a add the session seek on the C ABI and in the browser module; draft 07's + smoothing. #1642 and #1643 add the session seek on the C ABI and in the browser module; #1637's generator emits the step as an event of kind `Jump` for an effect cell. - **The browser host** boots through host-core preparation (`hosts/host-web/src/lib.rs:6661`), so it compiles the same programs; its own S derivation (`:6574-6583`) is deleted by #1306 D3 in this @@ -38,7 +38,7 @@ which spans render. This slice, draft 18a, draft 19 and #1306 land in batch R3, ## Decisions frozen for this slice - **D1. The seek step.** Where a session seek reaches the node (its node time steps), each cell - stages a `Point` at that sample: draft 07's `Jump` kind for effect cells, to the curve at the + stages a `Point` at that sample: #1637's `Jump` kind for effect cells, to the curve at the step sample plus `L - 1`. The grid resumes from the new node time (A1.4). - **D2. One path on both hosts.** The browser host takes the programs from the same host-core preparation as the C ABI. It adds no browser-only automation code; its tests prove the bits @@ -46,11 +46,11 @@ which spans render. This slice, draft 18a, draft 19 and #1306 land in batch R3, - **D3. Docs.** `docs/EFFECT_CONTRACT_V1.md:149-155` states that stored automation reaches an effect as `Point`s at the first sample of a piece, that a process call may cover part of a quantum, and that the delay time follows a curve as a sequence of 128-sample crossfades. - `docs/SESSION_SCHEMA_V1.md:220-225` lists the effect rows as rendered (the EQ after draft 20). + `docs/SESSION_SCHEMA_V1.md:220-225` lists the effect rows as rendered (the EQ after #1667). ## Deliverables -1. D1 through draft 07's generator in the stage (draft 17b), and the step's node-time mapping. +1. D1 through #1637's generator in the stage (#1662), and the step's node-time mapping. 2. D2's tests on both hosts. 3. D3's text. @@ -63,15 +63,15 @@ which spans render. This slice, draft 18a, draft 19 and #1306 land in batch R3, ## Non-goals -- Compiling and binding cells (draft 18a). The session seek itself (drafts 05, 06a, 06b). -- The EQ (draft 20). Classifier and carry (draft 19). -- Cross-target bit identity of a whole session (drafts 23a, 23b). +- Compiling and binding cells (#1663). The session seek itself (#1642, #1643, #1644). +- The EQ (#1667). Classifier and carry (#1665). +- Cross-target bit identity of a whole session (#1669, #1670). ## Hazards - **The delay time.** A time curve that moves faster than one crossfade per 128 samples renders as a sequence of crossfades, each to the curve at its completion. That is the delay's law, not a - defect; D3's paragraph states it. A seek inside a running crossfade waits for its end (draft 07's + defect; D3's paragraph states it. A seek inside a running crossfade waits for its end (#1637's "no restart" rule). - **Hot files.** `hosts/host-web/src/tests.rs` is stream H's; the stages are stream A's. Root sequences the merge. @@ -79,14 +79,14 @@ which spans render. This slice, draft 18a, draft 19 and #1306 land in batch R3, ## Objective gates 1. **Seek** (new cases in `crates/host-core/tests/effect_automation.rs`). After a session seek - (draft 05) to `T`, the first stored `Point` after the step is at the sample where the node's time + (#1642) to `T`, the first stored `Point` after the step is at the sample where the node's time steps, with the curve's value at `T + L - 1`, and the next ones are on the grid of the new node time (read through a test-support trace of staged stored `Point`s). On the delay time, a seek inside a running crossfade stages at the crossfade's last sample plus one. 2. **Browser equals C ABI.** For each of the seven effects, a session with a flat automation at `v` renders, through the browser host, the bits of the same session with static `v` (new test in `hosts/host-web/src/tests.rs`, 64 blocks), and a session with a moving curve renders the bits - the C ABI renders for it (draft 18a's gate-2 session, compared in the test). + the C ABI renders for it (#1663's gate-2 session, compared in the test). 3. **Quantum independence.** A one-track session whose only moving part is an automated compressor renders the same master output bits at quantum 128 and at quantum 100, with and without a session seek at the same timeline position. @@ -120,17 +120,17 @@ which spans render. This slice, draft 18a, draft 19 and #1306 land in batch R3, Batch R3. Direct dependencies: -- Draft 18a *Compile and bind stored effect parameter automation* (same push). +- #1663 *Compile and bind stored effect parameter automation* (same push). -Draft 17b's piece loop, draft 07's events, the seek of draft 05 *Seek the timeline and every source -in one C ABI call* and draft 06a *Seek the timeline and every source from the browser module export +#1662's piece loop, #1637's events, the seek of #1642 *Seek the timeline and every source +in one C ABI call* and #1643 *Seek the timeline and every source from the browser module export and the headless SDK*, and *Size each effect's automation span window from the producers its plan -has* (#1306) arrive through draft 18a. +has* (#1306) arrive through #1663. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. -- **No #1306 in the push** (README F23): the must-land group is 18a, 18b and 19. +- **No #1306 in the push** (README F23): the must-land group is #1663, #1664 and #1665. - **The browser** runs the same `SessionState` and preparation (#1562a); D2 holds by construction, and its gate goes through `hosts/host-web/tests/control_parity.rs`. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/19-classify-and-carry-effect-automation-edits.md b/.github/ISSUE_SPECS/1665-classify-and-carry-effect-automation-edits.md similarity index 92% rename from docs/handoffs/stored-automation-1058/proposed-specs/19-classify-and-carry-effect-automation-edits.md rename to .github/ISSUE_SPECS/1665-classify-and-carry-effect-automation-edits.md index 4b28ca12e..dc7625554 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/19-classify-and-carry-effect-automation-edits.md +++ b/.github/ISSUE_SPECS/1665-classify-and-carry-effect-automation-edits.md @@ -14,13 +14,13 @@ jump at the swap block, and a removed curve ramps to the static value. The revis `exact` at adoption. A static value edit on an automated effect parameter is stored and not heard, and commits as `model_only`, on both hosts: a browser live effect parameter edit on an automated cell reaches that rule through the Worker's apply (#1382) and replies `model_only`. The -EQ's rows stay masked until draft 20. +EQ's rows stay masked until #1667. ## Context **The classifier** (`crates/host-core/src/live_delta.rs`). - `classify_live_delta` (`:211`) masks the whole automation table (`:236`); its rustdoc says the - first slice that renders automation must remove that line (`:181-185`). Draft 10 makes the mask + first slice that renders automation must remove that line (`:181-185`). #1648 makes the mask per target row, adds `LiveRebuild::Automation` and one predicate `automated_cell(model, address)`, and removes the fader row. - The numbered rules are `:149-179`; the rebuild reasons are `LiveRebuild` (`:117-141`). @@ -45,7 +45,7 @@ EQ's rows stay masked until draft 20. `hosts/host-web/src/lib.rs:835`) reaches the shared commit through the Worker's apply and meets the shared classifier, as a C ABI transaction does. -**What drafts 18a and 18b give.** Each automated cell has a stable address `(strip ID, rack, effect ID, +**What #1663 and #1664 give.** Each automated cell has a stable address `(strip ID, rack, effect ID, parameter_index, channel)`, a compiled program and an event state (cursor, current target, ramp end, held grid, last seek boundary). An automated instance with no live lane has a channel-less lane and a window of `stored(i)` spans. @@ -53,12 +53,12 @@ lane and a window of `stored(i)` spans. ## Decisions frozen for this slice - **D1. The mask.** Effect automation rows leave the mask, except rows whose target is an - instance with target preparation (the EQ), which draft 20 removes. A delta whose effect + instance with target preparation (the EQ), which #1667 removes. A delta whose effect automation entries differ returns `LiveRebuild::Automation`. The numbered rules gain the step. An automation-only edit is then a rebuild on both hosts' shared commit (A10). - **D2. Static values on automated cells.** `parameter_records` emits no record for a `(parameter_index, channel)` of a span-driven effect that `automated_cell(next, address)` - reports. The EQ's static values still render until draft 20, so they keep today's + reports. The EQ's static values still render until #1667, so they keep today's records. A `both` entry covers both channels; a `left` entry covers only the left. A delta whose only change is such a value has no record and commits as `model_only` (#1313). A transaction that also changes automation is @@ -67,7 +67,7 @@ lane and a window of `stored(i)` spans. event state by stable address. Host-core joins the successor's cells with the predecessor's inventory by address, off render, and installs one move table in the carry program. In `adopt_predecessor`, the moves run with the other moves, all or nothing. Each carried cell then - repositions its cursor in the new table (draft 07). If the new curve's value at the adoption + repositions its cursor in the new table (#1637). If the new curve's value at the adoption block differs from the carried target, the cell's first event is a jump at that block's first sample (A1.8). - **D4. Released cells.** A cell present in the predecessor and absent from the successor is @@ -79,12 +79,12 @@ lane and a window of `stored(i)` spans. starts with the predecessor model's resolved value for that `(parameter_index, channel)` as its current target: that is the value the carried effect state holds, because every live edit is in the committed model. Its first event is a jump at the adoption block if the curve's value - differs from it, to the curve's value at `+ L - 1`. Draft 18a D6's rule (current target = the + differs from it, to the curve's value at `+ L - 1`. #1663 D6's rule (current target = the prepared value) applies only to an instance that does not carry. Host-core writes the seed into the move table. - **D6. The stage carries.** This slice extends the carry predicates that #1279 and #1280 build (both land first); neither spec is amended. - - #1279 D1's prepared-value test: the automation program, the prepared values it writes (draft 18a D3) and the window + - #1279 D1's prepared-value test: the automation program, the prepared values it writes (#1663 D3) and the window capacity it sets are not prepared values; nor is the static value of a cell the successor automates. An owner that gains, loses or changes stored automation carries. - #1279 D2's rule: an owner whose stored automation gives it a channel-less lane carries like one @@ -124,9 +124,9 @@ rebuild whose cells restart, and carry without classification is never reached. ## Non-goals -- The EQ's rows and its group cell (draft 20). +- The EQ's rows and its group cell (#1667). - OQ1: what a live move does to an automated control. Option A ships (A2). -- The browser's commit in the Worker (#1382, which lands before draft 09a). +- The browser's commit in the Worker (#1382, which lands before #1646). - Carrying the effect lane state itself (#1279, #1280). ## Hazards @@ -200,23 +200,23 @@ rebuild whose cells restart, and carry without classification is never reached. ## Dependencies -Batch R3, in one push with drafts 18a and 18b and #1306 (README "Must-land-together groups"): +Batch R3, in one push with #1663 and #1664 and #1306 (README "Must-land-together groups"): amended #1306 sizes each window without an automated cell's live term, and this slice's D2 stops the live record that would otherwise overflow it. Direct dependencies: -- Draft 18b *Render stored effect parameter automation across seeks and on both hosts*. +- #1664 *Render stored effect parameter automation across seeks and on both hosts*. - *Carry console effect lanes across a plan swap* (#1279): the carry D6 extends. - *Carry live-controlled effect lanes across a plan swap* (#1280): the carry D6 extends. -Draft 18a, draft 10 (the mask per row, `LiveRebuild::Automation`, `automated_cell`), draft 07 (the cursor reposition) and *Report each transaction's edit path in -its response* (#1313) arrive through draft 18b. +#1663, #1648 (the mask per row, `LiveRebuild::Automation`, `automated_cell`), #1637 (the cursor reposition) and *Report each transaction's edit path in +its response* (#1313) arrive through #1664. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. - **The classifier** lowers effect values by family (`parameter_records` in - `crates/host-core/src/live_delta.rs`); D1 and D2 use draft 10's `automation_row_renders` and + `crates/host-core/src/live_delta.rs`); D1 and D2 use #1648's `automation_row_renders` and `automated_cell` in `controls`. - **Automation wins on effect parameters** (OQ1 = C): D2's rule stands. - **No channel-less lane** (C6): D6's second and third bullets go; every owner has its live lane in diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/19b-render-stored-effect-bypass-automation.md b/.github/ISSUE_SPECS/1666-render-stored-effect-bypass-automation.md similarity index 89% rename from docs/handoffs/stored-automation-1058/proposed-specs/19b-render-stored-effect-bypass-automation.md rename to .github/ISSUE_SPECS/1666-render-stored-effect-bypass-automation.md index 8b71e51f6..6253fb2c3 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/19b-render-stored-effect-bypass-automation.md +++ b/.github/ISSUE_SPECS/1666-render-stored-effect-bypass-automation.md @@ -4,7 +4,7 @@ Proposed by *Research: render stored session automation in the engine, identical 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 and C6). A draft for root to file. Written by the controls coordinator on 2026-10-14. Code anchors verified on `2e6343a99`. -Batch R3, after draft 19. +Batch R3, after #1665. ## Product outcome @@ -26,17 +26,17 @@ session with no bypass automation renders the bits it renders today. output (`crates/effect-contract/src/live.rs:1621`); `crossfade` and `crossfade_bank` mix on the `IndexedRamp` law from a `position` (`:1764`, `:1850`). The bypass cell holds a `BypassSwitch { bypassed, ramp_samples }` (`:157`, #1341 D1); its ramp is `LiveRamps` row `BypassCrossfade`, key - `mute_ms` (`crates/effect-contract/src/control.rs`). The step runs once per block (draft 17b's + `mute_ms` (`crates/effect-contract/src/control.rs`). The step runs once per block (#1662's amendment: "D3 also keeps the bypass crossfade step of #1341a per block"). -- **Effect programs.** Draft 18a binds stored effect programs to the effect stage with the node's - arrival `a(n)`; draft 18b adds the seek step and the browser; draft 19 classifies and carries +- **Effect programs.** #1663 binds stored effect programs to the effect stage with the node's + arrival `a(n)`; #1664 adds the seek step and the browser; #1665 classifies and carries effect automation edits. #1622 carries the ramp in the bypass record. ## Decisions frozen for this slice -- **D1. The cell.** One bypass cell per automated instance (row 0, shared), with draft 07's - builder: `step` only (amended draft 02's toggle rule), so jumps only, no grid; jump row - `BypassCrossfade` (draft 12's row cell). +- **D1. The cell.** One bypass cell per automated instance (row 0, shared), with #1637's + builder: `step` only (amended #1645's toggle rule), so jumps only, no grid; jump row + `BypassCrossfade` (#1639's row cell). - **D2. Time at the output.** The switch acts on the effect's output, so its node time is the output's: `a(n) + latency(n)`. A step at timeline sample `t` starts the crossfade at the render sample where the node's output carries `t`; there the wet and the latency-matched dry words both @@ -51,11 +51,11 @@ session with no bypass automation renders the bits it renders today. the existing unfused crossfade arithmetic. - **D4. Seek.** Where a session seek reaches the node's output, the bypass is set exactly (settled, no ramp). -- **D5. Edits** (one rule on both hosts). Row 0 joins draft 10 D1's `automation_row_renders`. A +- **D5. Edits** (one rule on both hosts). Row 0 joins #1648 D1's `automation_row_renders`. A bypass automation edit is a carried rebuild; the cell's event state carries by `(strip, section, - effect, control 0)` with draft 19's carry. A `SET_CONTROL bypass` on an automated instance stores + effect, control 0)` with #1665's carry. A `SET_CONTROL bypass` on an automated instance stores the fallback, gives no record and commits `model_only`; the control list shows `automated = true` - (draft 11b D4). A bypass record never reaches an automated instance's cell (draft 19's rule). + (#1650 D4). A bypass record never reaches an automated instance's cell (#1665's rule). - **D6. Prepared bypasses.** The delay's and the multiband compressor's rows stay rebuild-only, so the row rule refuses their automation, unchanged. When #1339 or #1340 makes a row live, that effect renders through this slice's path with no change; that issue adds it to gate 1. @@ -92,7 +92,7 @@ session with no bypass automation renders the bits it renders today. ## Hazards - **Hot files.** `crates/graph/src/runtime.rs`, `crates/rack/src/lib.rs` and - `crates/host-core/src/prepare.rs` are shared with drafts 17b-19; root sequences the merge. + `crates/host-core/src/prepare.rs` are shared with #1662-#1665; root sequences the merge. - **The shunt identity.** `bypass_shunt_identity` and `bypass_cohorts` must stay green: the offset form at `o = 0` is today's call. @@ -133,7 +133,7 @@ test-support`, `cargo test --locked -p graph-compiler --test bypass_shunt_identi ## Dependencies -- Draft 19 *Classify and carry effect automation edits* (and drafts 18a and 18b, in its push): the +- #1665 *Classify and carry effect automation edits* (and #1663 and #1664, in its push): the effect programs, the seek step, both hosts, the carry. - #1341a1 and #1341a2 (landed): the crossfade. #1622 (ordering only: one bypass record spelling). -- Batch R3, after 19. A complete outcome alone; not in a must-land group. +- Batch R3, after #1665. A complete outcome alone; not in a must-land group. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/20-render-stored-parametric-eq-automation.md b/.github/ISSUE_SPECS/1667-render-stored-parametric-eq-automation.md similarity index 88% rename from docs/handoffs/stored-automation-1058/proposed-specs/20-render-stored-parametric-eq-automation.md rename to .github/ISSUE_SPECS/1667-render-stored-parametric-eq-automation.md index 725816e62..168323613 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/20-render-stored-parametric-eq-automation.md +++ b/.github/ISSUE_SPECS/1667-render-stored-parametric-eq-automation.md @@ -56,10 +56,10 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, ## Decisions frozen for this slice - **D1. The group.** One EQ section on one channel is a target group. If any of its cells is - automated, render owns that section's target on that channel. Its cells take draft 07's builder + automated, render owns that section's target on that channel. Its cells take #1637's builder with grid period 64, grid ramp 64, jump ramp 64 (A3) and completion `After`. - **D2. One design per event.** At each event of the group (grid, jump, seek step, adoption jump, - and the next block entry after its group cell changes), at a piece start (draft 17b): + and the next block entry after its group cell changes), at a piece start (#1662): - the stage builds the EQ's 60-value candidate in descriptor order on the stack: each automated cell takes its curve value at the completion sample `τ + 64`; every other value of the section comes from the group cell; other sections keep their current semantic values; @@ -75,8 +75,8 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, instance's factory (`EffectPreparedEntry::factory`, `crates/effect-compiler/src/prepare.rs:37-38`), cloned at bind; render only reads it. - **D3. A refused design** keeps the current target and increments a saturating - `automation_designs_refused` count on the stage, read after render. Draft 03a's domain and order - rules make it unreachable; the gates assert 0. + `automation_designs_refused` count on the stage, read after render. The domain and order rules of + #1645 and #1566's row rule make it unreachable; the gates assert 0. - **D4. The group cell.** For a section with an automated value, this slice gives #1345's section target cell the meaning "group cell": it holds the section's semantic values, not designed words. A live edit of a value of that section that is not automated writes the semantic value into the cell @@ -84,29 +84,29 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, #1345's designed target cell. #1345 is not amended: it lands first, and this slice adds the meaning for a section kind that exists only from this slice on. - **D5. The classifier.** - - The EQ's automation rows join `automation_row_renders` (draft 10 D1), which is then true for + - The EQ's automation rows join `automation_row_renders` (#1648 D1), which is then true for every row. The masked JSON comparison keeps copying `current.automation` - (`crates/host-core/src/live_delta.rs:236`, the line draft 10 D1 keeps), so that comparison never - sees an automation difference, and draft 10 D1's step after step 4 returns - `LiveRebuild::Automation` for every automation edit. The rustdoc (`:181-185`, as draft 10 D1 + (`crates/host-core/src/live_delta.rs:236`, the line #1648 D1 keeps), so that comparison never + sees an automation difference, and #1648 D1's step after step 4 returns + `LiveRebuild::Automation` for every automation edit. The rustdoc (`:181-185`, as #1648 D1 rewrote it) says that every row renders. - An EQ automation edit is `LiveRebuild::Automation`. Its cells carry by address and its group - cells by #1280's cell carry (draft 19 D3-D6). - - A static edit of an automated EQ value gives no record (draft 19 D2, now for the EQ too). + cells by #1280's cell carry (#1665 D3-D6). + - A static edit of an automated EQ value gives no record (#1665 D2, now for the EQ too). - A live edit of a non-automated value of an automated section is D4's group cell write, not a target design. - With the mask empty, every automation edit is a rebuild, so the separate - `LiveRebuild::AutomationTarget` routing that #1335 D4 adds and draft 02 D5 extends is deleted: an + `LiveRebuild::AutomationTarget` routing that #1335 D4 adds and #1645 D5 extends is deleted: an automation edit with a refusable entry returns `LiveRebuild::Automation`, and the rebuild's preparation refuses it with the same diagnostic. - **Earlier gates this slice turns red, rewritten here in place** (AGENTS.md: a change that supersedes a test rewrites it in the same PR): - - the cases that pin `AutomationTarget` expect `Automation`: draft 02's gate 7, draft 10's + - the cases that pin `AutomationTarget` expect `Automation`: #1645's gate 7, #1648's gate 1, and #1335's gate 4, whose test `an_automation_on_a_prepared_effect_parameter_needs_a_rebuild` (`crates/host-core/tests/live_delta.rs` on `main`) also has its second half, the block-rate `band-1-gain` that "stays live with no records", rewritten to `Err(LiveRebuild::Automation)`; - - draft 19's gate 1 case "an EQ automation change stays live with no records" expects + - #1665's gate 1 case "an EQ automation change stays live with no records" expects `Err(LiveRebuild::Automation)`; - #1335's gate 3, the band-gain half of `an_automation_on_a_prepared_effect_parameter_is_refused_before_any_ack` @@ -115,12 +115,12 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, rebuild path, so the test asserts one pending candidate, then completion `exact` and one revision after it, with the rendered blocks bit-identical to a twin prepared from the edited session. - Draft 03a's gate 3 asserts the refusal itself, which stays. + The refusal itself stays (obsolete draft 03a's gate 3 is not filed; see the amendment). - **D6. One rule, both hosts.** D4 and D5 live in the shared classifier, so a browser EQ edit meets - them through #1382's Worker commit, as drafts 14b and 16b do for the pan and matrix group and the + them through #1382's Worker commit, as #1657 and #1660 do for the pan and matrix group and the input filter pair. No host has its own admission for automated sections. - **D7. No span, no window.** The EQ stages no span: `stored(i) = 0` (A7). An automated EQ with - no live lane still uses the live-control stage with a channel-less lane (draft 17b D6), for its + no live lane still uses the live-control stage with a channel-less lane (#1662 D6), for its program and its group cells. - **D8. The render-design exception** (finding F1, which root confirms before filing). `docs/EFFECT_CONTRACT_V1.md:157-168`, the EQ comment (`crates/parametric-eq/src/lib.rs:3518-3520`) @@ -129,9 +129,9 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, owner's own designer and validation, at bounded events (A1.6). - **D9. #1337.** *Make a parametric EQ band's enabled and kind live* (#1337) lands before this draft (Dependencies). A band's `enabled` and `kind` are values of its group like the others (step - only, draft 03a), and #1337 D3-D4 govern the designed ramp. Draft 03a's step-only rule holds - whichever of draft 03a and #1337 lands first: until #1337 makes the two fields automatable, an - entry on them gets #1335's `effect.automation.rate` (draft 03a gate 1). + only, #1645's shape rule), and #1337 D3-D4 govern the designed ramp. #1645's step-only rule holds + whichever of #1645 and #1337 lands first: until #1337 makes the two fields automatable, an + entry on them gets #1335's `effect.automation.rate`. ## Deliverables @@ -139,9 +139,9 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, cell programs in `crates/host-core/src/prepare.rs`. 2. D4-D5 in `crates/host-core/src/live_delta.rs` and the cell writer. 3. D8's text. -4. Delete the `model_only_edits` cases that draft 10 moved to an EQ band-gain entry +4. Delete the `model_only_edits` cases that #1648 moved to an EQ band-gain entry (`crates/host-core/tests/live_delta.rs` and `crates/capi/src/runtime/live_tests.rs`): with the - mask empty, no automation edit is `model_only`, and the rebuild cases of drafts 10 and 19 and of this slice cover the path. + mask empty, no automation edit is `model_only`, and the rebuild cases of #1648 and #1665 and of this slice cover the path. 5. The tests below. ## Authorized paths @@ -159,7 +159,7 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, ## Non-goals -- The input HPF and LPF (drafts 16a, 16b), which use the same scheme on the strip. +- The input HPF and LPF (#1659, #1660), which use the same scheme on the strip. - A designer of its own: render calls the EQ's, unchanged in result. - A faster or vectorised design: events are sparse scalar control work (A1.7). - The EQ's #1306 live term (finding F5). @@ -171,7 +171,7 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, module's render closure. Any panic path in them (an `expect`, an unchecked index) is a trap the gate refuses. Fix the path in the EQ crate; never admit a new trap owner. - **Cost.** A moving section designs once per 64 samples per channel and lane (A4's table). Gate 6 - bounds the count; drafts 24a and 24b time it. + bounds the count; #1671 and #1672 time it. - **Hot files.** `crates/host-core/src/live_delta.rs` (stream B), the stages (stream A), `hosts/host-web/src/tests.rs` (stream H). Root sequences the merge. @@ -194,10 +194,10 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, `Err(LiveRebuild::Automation)`; a static edit of an automated EQ value gives no record; a delta that changes only the session ID is still live with no records (the mask is gone, nothing else moved); a fader entry in unit `linear` gives `Automation`, not `AutomationTarget`, and its - rebuild refuses it with draft 02's diagnostic. + rebuild refuses it with #1645's diagnostic. 6. **Bounded designs.** One section group designs at most `(⌈q/64⌉ + 1) + Σ_cells(⌈q/64⌉ + 1) + 1 seek + 1 cell change` times per channel and lane per - block, as draft 16b D4 states: the group's cells share one 64-sample grid, and each automated + block, as #1660 D4 states: the group's cells share one 64-sample grid, and each automated cell adds its jumps. At `q = 128`, band 2 with frequency, gain and Q automated, a seek and a group-cell change in one block designs at most `3 + 3·3 + 1 + 1 = 14` times (test-support counter); `automation_designs_refused` is 0 in every gate. @@ -246,35 +246,35 @@ counts scalar arithmetic in the listed kernels (`KERNEL_ROSTER`, Batch R4. Direct dependencies: -- Draft 19 *Classify and carry effect automation edits*. +- #1665 *Classify and carry effect automation edits*. - *Make a parametric EQ band's enabled and kind live* (#1337), an ordering dependency (D9). - Root's confirmation of finding F1. -Draft 17b (the pieces), draft 03a (the domain and shape rules that make a refused design +#1662 (the pieces), #1645 and #1566's row rule (the domain and shape rules that make a refused design unreachable), *Hold effect parameter, bypass and EQ-target values in latest-target cells* (#1345, the cell D4 lays out) and *Carry live-controlled effect lanes across a -plan swap* (#1280) arrive through draft 19. +plan swap* (#1280) arrive through #1665. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. -- **F1** as in draft 16a's amendment and the README's F1 proposal (option A recommended). +- **F1** as in #1659's amendment and the README's F1 proposal (option A recommended). - **Automation wins on EQ values** (OQ1 = C): D5's static-edit rule stands. - **D7 is simpler:** every EQ has its live stage on every plan (C6); no channel-less lane. With #1611 and #1621 an EQ lane has no parameter cells, so it holds no span window (`stored(i) = 0` is already the window). - **D5's anchor:** the masked line is now `crates/host-core/src/live_delta.rs:359-361`, and the routing variant is `LiveRebuild::AutomationTarget` over `controls::automation_diagnostics` - (#1566), not #1335's function. Draft 03a's gate 3 no longer exists (03a is obsolete); the - step-only rule for `enabled` and `kind` is amended draft 02's. + (#1566), not #1335's function. Obsolete draft 03a's gate 3 is not filed; the + step-only rule for `enabled` and `kind` is amended #1645's. - **Dependencies.** #1337 is open; #1345 has landed. ## Amendment (controls coordinator, batch 10, 2026-10-14) - **F1 is decided: option A** (README "Root's rulings (2026-10-14)"). The hazard "Finding F1" and the dependency on root's confirmation are met. -- **D8 is narrower.** Draft 16a (D5 of its batch-10 amendment) changes `docs/EFFECT_CONTRACT_V1.md` +- **D8 is narrower.** #1659 (D5 of its batch-10 amendment) changes `docs/EFFECT_CONTRACT_V1.md` and the trait's sentences in `crates/effect-contract/src/prepared_target.rs`, for owner review. This slice changes only the EQ's own render-path comments (`crates/parametric-eq/src/lib.rs`, the refusal comment and the render-path notes), with the same "for owner review" note. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/22-reserve-the-c-abi-automation-span-limit.md b/.github/ISSUE_SPECS/1668-reserve-the-c-abi-automation-span-limit.md similarity index 98% rename from docs/handoffs/stored-automation-1058/proposed-specs/22-reserve-the-c-abi-automation-span-limit.md rename to .github/ISSUE_SPECS/1668-reserve-the-c-abi-automation-span-limit.md index b28e3d184..b064b62c8 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/22-reserve-the-c-abi-automation-span-limit.md +++ b/.github/ISSUE_SPECS/1668-reserve-the-c-abi-automation-span-limit.md @@ -24,7 +24,7 @@ refused before the call reads anything else, so no session is created. (`crates/capi/src/runtime/compile.rs:482-508`, term at `:483`); `protocol_queue_config` makes it the protocol's `per_block_automation_density` (`:130-133`); `prepare_caps` passes it to effect preparation (`:528`). #1629 D4 (batch P1) removes the density read before #1306 lands, and - #1306 D3 (as amended: "capi keeps S until slice 22 reserves the field") removes the preparation + #1306 D3 (as amended: "capi keeps S until #1668 reserves the field") removes the preparation read. After both, S has no role and `all_limits_nonzero` is its only reader: a validated value nothing uses. - **The reserved-field rule.** `limits_are_valid` (`compile.rs:510-515`) requires `reserved0 == 0` diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/23a-compare-stored-automation-across-browser-and-native.md b/.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md similarity index 86% rename from docs/handoffs/stored-automation-1058/proposed-specs/23a-compare-stored-automation-across-browser-and-native.md rename to .github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md index ccbf968cf..bf35192f8 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/23a-compare-stored-automation-across-browser-and-native.md +++ b/.github/ISSUE_SPECS/1669-compare-stored-automation-across-browser-and-native.md @@ -11,7 +11,7 @@ One fixture session automates every row the engine renders, with every shape, an while it plays. The shipped browser artifact (wasm `simd128`) and the native browser host on x86-64 (AVX2) render it to the same bits, and the C ABI renders the same bits as the browser host. A second fixture does the same for matrix automation. This is the browser and x86-64 half of the -evidence that stored automation renders identically on every platform; draft 23b adds AArch64 and +evidence that stored automation renders identically on every platform; #1670 adds AArch64 and seek-time invariance on the same fixtures. It adds no new digest owner: it extends the direct oracle's existing pins. Batch Q. @@ -20,7 +20,7 @@ oracle's existing pins. Batch Q. **The existing owners.** - The G5 corpus is the single owner of the kernels' cross-target digests (`tools/wasm-gate-corpus/src/lib.rs:1-20`), run natively by `wasm-gates` and under wasmtime. - Draft 07 adds the evaluator's event values to it. + #1637 adds the evaluator's event values to it. - The browser direct oracle renders fixed sessions through the shipped `simd128` artifact (`hosts/host-web/tests/browser-v1/direct-oracle.mjs:547-679`). Each leg asserts its PCM digest equals a native pin before anything prints (the identity leg, `:571-583`; the command timeline, @@ -51,23 +51,23 @@ cross-host harness `hosts/host-web/tests/strip_cells_cross_host.rs`. limiter) and a parametric EQ, each with at least one automated `Block` parameter. Track 2 has the same chain with no automation, so a bank holds automated and plain lanes. - Across the entries: `step`, `linear` and `exponential` segments (exponential only where a rule - allows it: draft 02's for the input HPF and LPF, draft 03a's for an effect frequency, time or + allows it: #1645's for the input HPF and LPF, #1645's shape rule for an effect frequency, time or ratio row), a gap, a step on a sample that is not a grid sample, and a segment that starts before the seek target. - `automation-matrix-session.json`: one matrix strip whose four coefficients are automated. - **D2. One transcript.** Both fixtures render 96 blocks of PCM generated by an integer formula that is exact in `f32` in Rust and in JavaScript, for example `x[i] = ((i·40503) mod 65536 − 32768) / 65536`. At block 40 the host makes one session seek to - timeline 30,000 (draft 06a's export in the oracle, the same control-plane call natively). + timeline 30,000 (#1643's export in the oracle, the same control-plane call natively). - **D3. The browser leg.** `direct-oracle.mjs` gains `runAutomationTimeline` for each fixture. It asserts each digest equals `expected.directOracle.nativeAutomationPcmF32leSha256` and `nativeAutomationMatrixPcmF32leSha256` before any print, as the existing legs do. The pins are - written by two new host-web tests beside `:1632`, which `serde_pin` extends to read. Draft 23b + written by two new host-web tests beside `:1632`, which `serde_pin` extends to read. #1670 reuses these two tests and pins. - **D4. The C ABI equals the browser host.** In #1399's cross-host test, both fixtures and the - transcript render through the C ABI (draft 05's `miso_engine_v1_session_seek`) and through + transcript render through the C ABI (#1642's `miso_engine_v1_session_seek`) and through `AudioWorkletEngineHost`, and the two outputs are compared bit for bit in the test. No digest. -- **D5. One-ulp evidence (PR evidence, not committed).** Plant a one-ulp change in draft 07's +- **D5. One-ulp evidence (PR evidence, not committed).** Plant a one-ulp change in #1637's `value_at` result. Record that the two native pin tests, the oracle's `simd128` legs, the cross-host test (when the plant reaches only one host's path, as a check of the comparison) and the G5 corpus each turn red, then revert. @@ -89,10 +89,10 @@ cross-host harness `hosts/host-web/tests/strip_cells_cross_host.rs`. - Any engine change. A leg that disagrees is a defect for its own issue, recorded here with its first differing sample. -- AArch64 and seek-time invariance (draft 23b). +- AArch64 and seek-time invariance (#1670). - A new digest owner, or a digest of a rendered session outside `expected.json`. -- Timing (drafts 24a, 24b). Listening tests. -- Live edits during the fixture: drafts 10, 11, 13c, 14b, 16b, 19 and 20 gate them. +- Timing (#1671, #1672). Listening tests. +- Live edits during the fixture: #1648, #1649, #1653, #1657, #1660, #1665 and #1667 gate them. ## Hazards @@ -132,18 +132,18 @@ cross-host harness `hosts/host-web/tests/strip_cells_cross_host.rs`. Batch Q. Direct dependencies: -- Draft 13c *Write a following send's route cell from the shared commit* (it brings 13a and 13b). -- Draft 14b *Render stored pan and matrix automation*. -- Draft 15 *Render stored input trim and polarity automation*. -- Draft 16b *Render stored input HPF and LPF automation*. -- Draft 20 *Render stored parametric EQ automation*. +- #1653 *Write a following send's route cell from the shared commit* (it brings #1651 and #1652). +- #1657 *Render stored pan and matrix automation*. +- #1658 *Render stored input trim and polarity automation*. +- #1660 *Render stored input HPF and LPF automation*. +- #1667 *Render stored parametric EQ automation*. - *Report live_values_superseded in the browser status and prove both hosts drain strip cells alike* (#1399): the cross-host harness. -Draft 13a, draft 05 *Seek the timeline and every source in one C ABI call*, draft 06a *Seek the -timeline and every source from the browser module export and the headless SDK* and draft 07 +#1651, #1642 *Seek the timeline and every source in one C ABI call*, #1643 *Seek the +timeline and every source from the browser module export and the headless SDK* and #1637 *Compile stored automation into per-cell events in node time* (D5's plant, G5's case) arrive -through those drafts. Draft 23b *Compare stored automation bits on AArch64 and across seek times* +through those drafts. #1670 *Compare stored automation bits on AArch64 and across seek times* depends on this draft. ## Amendment (controls coordinator, 2026-10-14) @@ -152,6 +152,6 @@ Checked against `12d4a4f9d`. - **The cross-host harness** is `hosts/host-web/tests/control_parity.rs` (#1560); `strip_cells_cross_host.rs` is gone. #1399 has landed. -- **The fixtures add** a fader offset (11b), a hand mute over a mute curve (13a amended) and a send - ride (13d); depend on 11b and 13d. +- **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. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/23b-compare-stored-automation-on-aarch64-and-across-seeks.md b/.github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md similarity index 85% rename from docs/handoffs/stored-automation-1058/proposed-specs/23b-compare-stored-automation-on-aarch64-and-across-seeks.md rename to .github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md index e909eea95..db6870786 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/23b-compare-stored-automation-on-aarch64-and-across-seeks.md +++ b/.github/ISSUE_SPECS/1670-compare-stored-automation-on-aarch64-and-across-seeks.md @@ -7,16 +7,16 @@ to file. Code anchors verified on `6ee64f484`. Batch Q. ## Product outcome -The native host on AArch64 (NEON, FPCR) renders the stored-automation fixtures of draft 23a to the +The native host on AArch64 (NEON, FPCR) renders the stored-automation fixtures of #1669 to the same bits as the browser and x86-64, in the debug and the shipping release profile. And a session seek that lands at two different render samples, or at two quanta, gives the same output once the ramps it started have completed: stored automation after a seek depends only on the session and -the seek target. With draft 23a this completes the evidence for the owner's ruling that stored +the seek target. With #1669 this completes the evidence for the owner's ruling that stored automation renders identically on every platform. It adds no new digest owner. Batch Q. ## Context -- **The fixtures and pins.** Draft 23a adds `automation-session.json` and +- **The fixtures and pins.** #1669 adds `automation-session.json` and `automation-matrix-session.json` to `hosts/host-web/tests/browser-v1/`, their transcript (96 blocks of exact integer-formula PCM, one session seek at block 40), two native pin tests in `hosts/host-web/src/tests.rs` and their pins in `expected.json`. The three older native pin tests @@ -33,24 +33,24 @@ automation renders identically on every platform. It adds no new digest owner. B - **Seeks.** A1.4: where a seek reaches a node, builtin lanes are set exactly and each effect cell stages a `Point`; A5: effect parameters settle after their smoothing (64, or 128 for the delay time), and effect state after the pre-seek audio's tail reaches exact rest (D15-4). The delay - time's jump waits for a running crossfade (draft 07's "no restart" rule). + time's jump waits for a running crossfade (#1637's "no restart" rule). ## Decisions frozen for this slice -- **D1. The AArch64 leg.** `scripts/run-aarch64-tests.sh` runs draft 23a's two automation pin +- **D1. The AArch64 leg.** `scripts/run-aarch64-tests.sh` runs #1669's two automation pin tests and the three existing native pin tests in both modes: `cargo test --locked -p host-web --features test-support --lib -- --exact ` in debug, and the same with `--release` in release. They compare with the same pins in `expected.json`. A failure that is LANE-3's (#1019) is recorded as a row of `aarch64-known-defects.py` naming #1019 and its reason, by the script's own rule; any other failure fails the leg. - **D2. Seek-time invariance.** A new test renders `automation-session.json` on two C ABI engines - with silent sources before the seek and draft 23a's transcript after it. Engine A seeks to `T` at + with silent sources before the seek and #1669's transcript after it. Engine A seeks to `T` at render sample `r1`, engine B at `r2`, with `r2 - r1` not a multiple of 64; once at quantum 128 and once at quantum 100. Aligned at the seek, the two outputs are equal bit for bit from 256 samples after the seek reaches the output: the longest ramp a seek starts is the delay time's held jump plus its crossfade (2 x 128). Builtin lanes are set exactly at the seek, and silent inputs leave no pre-seek effect state to wait for. -- **D3. One-ulp evidence (PR evidence, not committed).** With draft 07's `value_at` changed by one +- **D3. One-ulp evidence (PR evidence, not committed).** With #1637's `value_at` changed by one ulp, both AArch64 legs turn red; revert. ## Deliverables @@ -69,7 +69,7 @@ automation renders identically on every platform. It adds no new digest owner. B - Any engine change. A leg that disagrees is a defect for its own issue, recorded here with its first differing sample. -- New fixtures or pins (draft 23a owns them). +- New fixtures or pins (#1669 owns them). - Seek invariance with audible pre-seek audio: its tail is bounded by #1329's rest bound, not by the automation, and is not this slice's claim. @@ -106,16 +106,16 @@ automation renders identically on every platform. It adds no new digest owner. B Batch Q. Direct dependencies: -- Draft 23a *Compare stored automation bits across the browser and native hosts*: fixtures, pins +- #1669 *Compare stored automation bits across the browser and native hosts*: fixtures, pins and the two pin tests. - *Make the compressor's AArch64 release build bit-identical to the browser (LANE-3)* (#1019), for a clean release leg (D1). -Draft 05 *Seek the timeline and every source in one C ABI call* and draft 07 *Compile stored -automation into per-cell events in node time* arrive through draft 23a. +#1642 *Seek the timeline and every source in one C ABI call* and #1637 *Compile stored +automation into per-cell events in node time* arrive through #1669. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. -- No design change. It inherits 23a's added rows. #1019 is still open. Anchors re-read at filing. +- No design change. It inherits #1669's added rows. #1019 is still open. Anchors re-read at filing. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/24a-build-the-stored-automation-console-benchmark-row.md b/.github/ISSUE_SPECS/1671-build-the-stored-automation-console-benchmark-row.md similarity index 95% rename from docs/handoffs/stored-automation-1058/proposed-specs/24a-build-the-stored-automation-console-benchmark-row.md rename to .github/ISSUE_SPECS/1671-build-the-stored-automation-console-benchmark-row.md index 82500a53e..14826547e 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/24a-build-the-stored-automation-console-benchmark-row.md +++ b/.github/ISSUE_SPECS/1671-build-the-stored-automation-console-benchmark-row.md @@ -13,7 +13,7 @@ ABI, with stored automation on every track's faders, pans, console EQ gain and c threshold. Its three arms (no automation, flat automation, moving automation) alternate per observation, and its two deltas are named for what they measure: holding automated lanes, and moving them. This slice builds and freezes the row, its preflight and its validators, and times -nothing; draft 24b *Record the stored-automation console benchmark baseline* runs it once. The row +nothing; #1672 *Record the stored-automation console benchmark baseline* runs it once. The row is descriptive: no threshold, no tuning. ## Context @@ -79,7 +79,7 @@ is descriptive: no threshold, no tuning. `[0, (PREROLL + OBSERVATIONS + 1) · 128)` from the lane's static value to the same value. **`moving`:** the same segments from the static value to a target: fader `-6` dB from it, pan `0.5`, EQ gain `-1.5` and `0.5` dB, threshold `-18` dB. Every value lies in its row's domain, - so #1626 and drafts 02 and 03a admit all three documents. + so #1626, #1645 and #1566's row rule admit all three documents. - The session's `control_smoothing` is `{ "kind": "default" }`, as in the fixture after #1054. - **D2. The C ABI driver.** In `tools/bench/src/console.rs`, `StoredAutomationMeasurement` compiles each arm with `miso_engine_v1_compile_session` and the reference limits, submits the @@ -112,7 +112,7 @@ is descriptive: no threshold, no tuning. backend and the statistical-method sentence. The record and aggregate validators, their library, and the runner's and preflight's record count (62 to 64) learn the row before any timing. - **D6. No timing here.** This slice runs no benchmark. The workload and the validators are frozen - when it merges; draft 24b runs them unchanged. + when it merges; #1672 runs them unchanged. ## Deliverables @@ -143,9 +143,9 @@ is descriptive: no threshold, no tuning. ## Non-goals -- A browser (V8) row for stored automation; the cross-target proof is slices 23a and 23b. +- A browser (V8) row for stored automation; the cross-target proof is #1669 and #1670. - Any change to the other rows, to the engine, or to `console-workload`'s plan path. -- The timed run and its record (draft 24b). +- The timed run and its record (#1672). - Any optimisation of stored automation (weekly performance pass, after a measured miss). ## Hazards @@ -203,19 +203,19 @@ is descriptive: no threshold, no tuning. Batch Q. Direct dependencies: -- Draft 20 *Render stored parametric EQ automation*. +- #1667 *Render stored parametric EQ automation*. -The rendering slices the row measures (09a-09b, 14a-14b, 18a-18b) land in batches R1 to R3, before -batch Q. #1626 and drafts 02 and 03a (the documents must validate) and *Session `controlSmoothing`: +The rendering slices the row measures (#1646-#1647, #1656-#1657, #1663-#1664) land in batches R1 to R3, before +batch Q. #1626, #1645 and #1566's row rule (the documents must validate) and *Session `controlSmoothing`: configurable ramp lengths for live mute, fader and pan changes* (#1054), for the document's -`control_smoothing` key, arrive through draft 20. Draft -24b *Record the stored-automation console benchmark baseline* follows it. +`control_smoothing` key, arrive through #1667. +#1672 *Record the stored-automation console benchmark baseline* follows it. ## Amendment (controls coordinator, 2026-10-14) Checked against `12d4a4f9d`. - **Realtime policy.** `scripts/check-realtime-policy.sh` and `scripts/test-realtime-policy.sh` are - deleted; use `./target/release/realtime_policy` (draft 07's amendment). + deleted; use `./target/release/realtime_policy` (#1637's amendment). - The native console rows stay under owner ruling R9 (`tools/bench/src/console.rs` holds `console_mixing_automation`, `:138`). No other change. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/24b-record-the-stored-automation-console-benchmark-baseline.md b/.github/ISSUE_SPECS/1672-record-the-stored-automation-console-benchmark-baseline.md similarity index 93% rename from docs/handoffs/stored-automation-1058/proposed-specs/24b-record-the-stored-automation-console-benchmark-baseline.md rename to .github/ISSUE_SPECS/1672-record-the-stored-automation-console-benchmark-baseline.md index 6e84673fe..2063ca4dd 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/24b-record-the-stored-automation-console-benchmark-baseline.md +++ b/.github/ISSUE_SPECS/1672-record-the-stored-automation-console-benchmark-baseline.md @@ -15,7 +15,7 @@ weekly performance pass. No code changes. ## Context -- **The row** is built and frozen by draft 24a *Build the stored-automation console benchmark row*: +- **The row** is built and frozen by #1671 *Build the stored-automation console benchmark row*: three arms (`none`, `flat`, `moving`) alternated per observation, in-run class-A statement `flat == none`, record fields `paired_hold_delta_median_ns` and `paired_motion_delta_median_ns`, validators and record counts (64) updated before any timing. @@ -41,7 +41,7 @@ weekly performance pass. No code changes. ## Decisions frozen for this slice - **D1. One run.** A person runs, on the reference host, with a clean tree at the merge commit of - draft 24a and every slice it depends on: + #1671 and every slice it depends on: `scripts/operator/preflight-console-benchmark.sh --step `, then `scripts/operator/run-console-benchmark.sh --step `, exactly once. `` is lowercase kebab-case and names the commit's batch (for example `stored-automation-base`). @@ -66,7 +66,7 @@ weekly performance pass. No code changes. ## Non-goals -- Any code, workload, validator or fixture change (draft 24a froze them). +- Any code, workload, validator or fixture change (#1671 froze them). - Any optimisation (weekly performance pass, after a measured miss). - A browser (V8) measurement. @@ -92,7 +92,7 @@ weekly performance pass. No code changes. ## Test value -- No test is added: the slice adds evidence, not behaviour. Gate 2 is the validator draft 24a +- No test is added: the slice adds evidence, not behaviour. Gate 2 is the validator #1671 froze and tested; it turns red if the row is missing, if a round is missing, or if the in-run class-A statement failed. @@ -100,9 +100,9 @@ weekly performance pass. No code changes. Batch Q. Direct dependencies: -- Draft 24a *Build the stored-automation console benchmark row*, merged. +- #1671 *Build the stored-automation console benchmark row*, merged. -Draft 20 *Render stored parametric EQ automation* and its predecessors arrive through draft 24a. +#1667 *Render stored parametric EQ automation* and its predecessors arrive through #1671. ## Amendment (controls coordinator, 2026-10-14) diff --git a/crates/builtins-compiler/src/lib.rs b/crates/builtins-compiler/src/lib.rs index 6129cbfc4..16934a6c7 100644 --- a/crates/builtins-compiler/src/lib.rs +++ b/crates/builtins-compiler/src/lib.rs @@ -5963,6 +5963,36 @@ impl GraphRuntimeObserver for MeterObserver { .banked_seed(first_sample, usize::try_from(frames).ok()?) } + /// A meter's window, cumulative counters and producer are state a swap carries (#1327 D1). + fn carry_kind(&self) -> Option { + Some(engine::realtime::ObserverKind::Meter) + } + + /// The state moved into this binding is named by this binding's handle, as the snapshots it + /// publishes show (#1327 D2). Binding handles of meters are meter handles, never zero. + fn adopt_binding_handle(&mut self, handle: u64) { + if let Some(handle) = core::num::NonZeroU64::new(handle) { + self.0.rebind_handle(MeterHandle(handle)); + } + } + + fn window_position(&self) -> Option { + let position = self.0.window_position(); + Some(graph::ObserverWindow { + start: position.start, + frames: position.frames, + sequence: position.sequence, + }) + } + + fn join_window(&mut self, position: graph::ObserverWindow) -> bool { + self.0.join_window(builtins::MeterWindowPosition { + start: position.start, + frames: position.frames, + sequence: position.sequence, + }) + } + fn observe(&mut self, block: GraphObservationBlock<'_>) -> Result<(), RenderError> { self.0 .observe(block.left, block.right, block.first_sample) diff --git a/crates/builtins/src/lib.rs b/crates/builtins/src/lib.rs index acff7f280..49a709dfd 100644 --- a/crates/builtins/src/lib.rs +++ b/crates/builtins/src/lib.rs @@ -5144,6 +5144,19 @@ pub struct MeterAccumulator { producer: Producer, } +/// Where a meter's open window sits on the sample grid (#1327 D6): the first sample of the open +/// window (`None` before the first observation), the frames it has already taken in, and the +/// sequence number its snapshot will carry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MeterWindowPosition { + /// First sample of the open window, or `None` for a meter that has observed nothing. + pub start: Option, + /// Frames already in the open window. + pub frames: u32, + /// `window_sequence` of the snapshot the open window will publish. + pub sequence: u64, +} + pub struct PreparedMeter { pub accumulator: MeterAccumulator, pub consumer: Consumer, @@ -5638,6 +5651,52 @@ impl MeterAccumulator { pub const fn dropped_snapshots(&self) -> u64 { self.dropped } + + /// The configuration this meter was prepared with. + #[must_use] + pub const fn config(&self) -> MeterConfig { + self.config + } + + // REALTIME_POLICY_BEGIN + /// Where this meter's open window sits (#1327 D6): scalar reads, allocation-free. + #[must_use] + pub const fn window_position(&self) -> MeterWindowPosition { + MeterWindowPosition { + start: self.start, + frames: self.frames, + sequence: self.sequence, + } + } + + /// Open this meter's first window at `position` (#1327 D6), as a meter that observed silence + /// since that window opened would hold it: the window's start, frame count and sequence are + /// the donor's, and the lanes and cumulative counters stay at zero. + /// + /// Only a meter that has observed nothing takes it: a meter with a window, a sequence or a + /// counter keeps its own, and this returns `false`. Scalar writes, allocation-free. + pub fn join_window(&mut self, position: MeterWindowPosition) -> bool { + if self.start.is_some() + || self.frames != 0 + || self.sequence != 0 + || self.discontinuities != 0 + || self.dropped != 0 + { + return false; + } + self.start = position.start; + self.frames = position.frames; + self.sequence = position.sequence; + true + } + + /// Name this meter by `handle` from now on (#1327 D2): its snapshots carry it. A swap moves a + /// meter's whole state, producer included, into the successor's binding, and the binding's + /// handle is the reader's; the state that moved takes it. A scalar write. + pub fn rebind_handle(&mut self, handle: MeterHandle) { + self.handle = handle; + } + // REALTIME_POLICY_END fn discontinuity(&mut self, first_sample: u64) { self.start = Some(first_sample); self.frames = 0; diff --git a/crates/capi/src/runtime/live_tests.rs b/crates/capi/src/runtime/live_tests.rs index a47b35e44..1c7962161 100644 --- a/crates/capi/src/runtime/live_tests.rs +++ b/crates/capi/src/runtime/live_tests.rs @@ -1029,10 +1029,10 @@ enum Mute { } /// Gate 2 run: three blocks, every other track muted per `mute`, one more block, then a content -/// edit rebuilds; the swap, a seek, and six blocks, which it returns. Every run feeds the same -/// source over the same blocks and swaps at the same block, so whatever the swap carries (the -/// strip input sections, #1276) is the same in every run, and the rebuilt blocks differ only by -/// what the successor was prepared from. +/// edit rebuilds; the swap, a seek, and the blocks until the carried lines flush plus six, which it +/// returns. Every run feeds the same source over the same blocks and swaps at the same block, so +/// whatever the swap carries (the strip input sections, #1276) is the same in every run, and the +/// rebuilt blocks differ only by what the successor was prepared from. fn persistence_run(mute: Mute) -> Vec> { // A hard switch (#1054 D6): the live mute steps one block before the rebuild and the baked // mute is the rebuild's own retarget, so with a ramp the two would start it a block apart. @@ -1078,7 +1078,10 @@ fn rebuild_run(document: &str, mute: Mute, ramp: Option) -> Vec> { // The render call swaps the candidate in; its fresh ring then waits for a seek. rig.render(); rig.seek_to_start(); - let rebuilt = (0..6).map(|_| rig.step()).collect(); + // The lines flush `flushed` blocks after the swap; six more are compared. + let flushed = + usize::try_from(u64::from(rig.latency()).div_ceil(rig.quantum as u64)).expect("blocks"); + let rebuilt = (0..flushed + 6).map(|_| rig.step()).collect(); assert_eq!( crate::ffi::test_plan_carry_counts(rig.plan), (1, 0), @@ -1092,10 +1095,26 @@ fn rebuild_run(document: &str, mute: Mute, ramp: Option) -> Vec> { /// run, or unlike a rebuild that bakes the mute into its own transaction from the same history). #[test] fn a_live_mute_survives_a_later_rebuild() { - let blocks = |run: Vec>| run.iter().map(|pcm| bits(pcm)).collect::>(); + // The rebuild carries the compensation lines (#1283), so the audio a line held at the swap + // plays out after it: the live mute silenced the last block of the muted strips' lines, and + // the baked mute takes effect at the swap, so the lines of those strips still hold that block + // unmuted, as a live mute at the swap would. The two runs agree once the lines are flushed, + // which is the session's latency after the swap block. + let (latency, quantum) = { + let rig = Rig::new(&long_session_with(10, 48_000, HARD_SWITCH)); + (rig.latency(), rig.quantum) + }; + let flushed = usize::try_from(u64::from(latency).div_ceil(quantum as u64)).expect("blocks"); + let blocks = |run: Vec>| { + run[flushed..] + .iter() + .map(|pcm| bits(pcm)) + .collect::>() + }; let live = blocks(persistence_run(Mute::Live)); let baked = blocks(persistence_run(Mute::Baked)); let unmuted = blocks(persistence_run(Mute::Unmuted)); + assert!(!live.is_empty(), "blocks remain after the lines flush"); assert_eq!( live, baked, "the rebuild after a live mute renders like a rebuild that bakes it" @@ -4280,27 +4299,67 @@ fn a_prepared_bypass_change_rebuilds_and_renders_the_committed_model() { }; let document = with_bypass(false); // The blocks after the swap of `rebuilds` successive rebuilds, each one block, the swap, a - // seek and six blocks, by content edits from a document holding the multiband at `bypass`. + // seek and `flushed + 6` blocks, by content edits from a document holding the multiband at + // `bypass`. + // A content edit alone moves a per-node multiband across the swap (#1282), as the bypass + // rebuild does not, so each control rebuild also replaces the multiband by a fresh instance + // of the same declaration under a new ID: a prepared change of that owner, which restarts it + // and its strip exactly as the bypass rebuild does. + let mut rig = Rig::new(&document); + // The lines flush after the session's latency: `flushed` blocks after the swap block. + let flushed = + usize::try_from(u64::from(rig.latency()).div_ceil(rig.quantum as u64)).expect("blocks"); let control = |bypass: bool, rebuilds: u8| { let document = with_bypass(bypass); let model = parse_session_json(&document).expect("control model"); + let multiband = model.tracks[0] + .inserts + .effects + .last() + .expect("multiband") + .clone(); + let position = u32::try_from(model.tracks[0].inserts.effects.len() - 1).expect("position"); let mut control = Rig::new(&document); control.step(); let mut rebuilt = Vec::new(); + let mut previous = multiband.id.clone(); for tag in 0..rebuilds { let mut edit = content_edit(&model); if let SessionEdit::SetSourceContent { content, .. } = &mut edit { *content = format!("blake3:{}", format!("{:02x}", 0xc0 + tag).repeat(32)); } - assert_eq!(control.apply(&[edit]), crate::RESULT_OK); + let mut renamed = multiband.clone(); + renamed.id = StableId::parse(&format!("mb{tag}")).expect("renamed multiband"); + let track_id = model.tracks[0].id.clone(); + let replace = [ + SessionEdit::RemoveTrackEffect { + track_id: track_id.clone(), + rack_name: session::RackName::Inserts, + effect_id: previous, + }, + SessionEdit::PutTrackEffect { + track_id, + rack_name: session::RackName::Inserts, + final_position: position, + effect: renamed.clone(), + }, + ]; + previous = renamed.id; + let [remove, put] = replace; + assert_eq!(control.apply(&[edit, remove, put]), crate::RESULT_OK); assert_eq!(control.summary().3, 1, "the control rebuilds"); control.render(); control.seek_to_start(); - rebuilt = (0..6).map(|_| bits(&control.step())).collect::>(); + rebuilt = (0..flushed + 6) + .map(|_| bits(&control.step())) + .collect::>(); } rebuilt }; - let mut rig = Rig::new(&document); + // The rebuild carries the compensation lines (#1283). Until they flush, the window holds the + // source ring's audio AND audio rendered under the predecessor's bypass in the carried lines, + // which no control built from the committed model reproduces. Only the blocks after the + // flush are compared: `flushed + 6` are rendered, so six are. rig.step(); for (rebuilds, bypass) in [(1, true), (2, false)] { let (revision, _, epoch, pending) = rig.summary(); @@ -4328,18 +4387,20 @@ fn a_prepared_bypass_change_rebuilds_and_renders_the_committed_model() { // The render call swaps the candidate in; the host then restarts the source. rig.render(); rig.seek_to_start(); - let rebuilt: Vec<_> = (0..6).map(|_| bits(&rig.step())).collect(); + let rebuilt: Vec<_> = (0..flushed + 6).map(|_| bits(&rig.step())).collect(); let (_, _, swapped, pending) = rig.summary(); assert_eq!(pending, 0, "bypass {bypass}: the candidate was taken"); assert_ne!(swapped, epoch, "bypass {bypass}: a new epoch renders"); let reference = control(bypass, rebuilds); + assert_eq!(rebuilt[flushed..].len(), 6, "six blocks are compared"); assert_eq!( - rebuilt, reference, + rebuilt[flushed..], + reference[flushed..], "bypass {bypass}: the rebuilt plan renders the committed model" ); assert_ne!( - reference, - control(!bypass, rebuilds), + reference[flushed..], + control(!bypass, rebuilds)[flushed..], "bypass {bypass}: the change is audible" ); } diff --git a/crates/capi/tests/resource_lifecycle.rs b/crates/capi/tests/resource_lifecycle.rs index 896e590b9..ade9200fc 100644 --- a/crates/capi/tests/resource_lifecycle.rs +++ b/crates/capi/tests/resource_lifecycle.rs @@ -1179,6 +1179,14 @@ impl Budget { /// high per row). #1629 removed the protocol automation queue: `capi_retained_bytes` measured /// 165,166 at both widths (that print, and x86-64), so its ceiling is 165,166 plus 10 %, rounded /// up to 64: 181,682.6 -> 181,696 (was 216,832). +/// +/// #1633 (batch 11) moved the effect lanes' carried slots out of the build: a lane holds them only +/// when a successor carries into it. The first-compile reference session carries nothing, so the +/// three graph rows fall by the slots the batch 10 carry added at eight lanes (`graph_session_plus_plan_bytes` +/// 321,846 -> 315,798, `graph_incremental_plan_bytes` 321,774 -> 315,726, `graph_metadata_bytes` +/// 113,601 -> 107,553: 6,048 each, x86-64). `capi_retained_bytes` charges capi's own allocations +/// and stays 165,166. The graph ceilings stand until the next `aarch64-debug` print tightens them +/// (AArch64 runs only in CI); the rows only shrank, so they still bound them. const REFERENCE_BUDGETS: [Budget; 19] = [ Budget { row: "graph_session_plus_plan_bytes", diff --git a/crates/effect-contract/src/live.rs b/crates/effect-contract/src/live.rs index 413a7e0aa..2dbc27753 100644 --- a/crates/effect-contract/src/live.rs +++ b/crates/effect-contract/src/live.rs @@ -704,8 +704,8 @@ pub fn effect_control_lane( mix_position: 0, symmetry, carried_bypass: CarriedCell::EMPTY, - carried_parameters: vec![CarriedCell::EMPTY; parameter_cells].into_boxed_slice(), - carried_targets: vec![CarriedCell::EMPTY; target_cells].into_boxed_slice(), + carried_parameters: Box::default(), + carried_targets: Box::default(), carried_pending: false, }; Some((writer, lane)) @@ -775,9 +775,12 @@ pub struct EffectControlLane { symmetry: ChannelSymmetryWitness, /// The predecessor's unread bypass write this lane took at a plan swap (#1280 D2). carried_bypass: CarriedCell<2>, - /// The predecessor's unread parameter writes, one per parameter cell. + /// The predecessor's unread parameter writes, one per parameter cell. Empty until + /// [`EffectControlLane::install_carry_slots`] runs: a lane no successor carries holds none + /// (#1633 D1). carried_parameters: Box<[CarriedCell<1>]>, - /// The predecessor's unread target writes, one per target cell. + /// The predecessor's unread target writes, one per target cell. Installed with + /// [`Self::carried_parameters`]. carried_targets: Box<[CarriedCell]>, /// Whether any carried slot holds a write: the next [`Self::stage`] resolves them before it /// decides it has nothing to read. @@ -943,6 +946,41 @@ impl EffectControlLane { resources } + /// The bytes [`Self::install_carry_slots`] allocates for this lane, and the lane holds once it + /// has: none for a lane without cells. The one place the slot charge is spelled, so + /// preparation's admission and the retained-bytes row cannot disagree. + #[must_use] + pub fn carry_slot_bytes(&self) -> u64 { + if self.cells.is_none() { + return 0; + } + let parameters = self + .parameter_readers + .len() + .saturating_mul(core::mem::size_of::>()); + let targets = self + .target_readers + .len() + .saturating_mul(core::mem::size_of::< + CarriedCell, + >()); + u64::try_from(parameters.saturating_add(targets)).unwrap_or(u64::MAX) + } + + /// Allocate the carried slots a plan swap fills, one per parameter and target cell (#1633 + /// D1). Off the render thread: preparation calls it for each successor lane a swap carries + /// into, and for no other, so a plan that is never a successor holds none. A lane without + /// cells has nothing to carry, and a lane whose slots are installed keeps them. + pub fn install_carry_slots(&mut self) { + if self.cells.is_none() || self.carry_slots_installed() { + return; + } + self.carried_parameters = + vec![CarriedCell::EMPTY; self.parameter_readers.len()].into_boxed_slice(); + self.carried_targets = + vec![CarriedCell::EMPTY; self.target_readers.len()].into_boxed_slice(); + } + // REALTIME_POLICY_BEGIN /// This lane's live channel-symmetry terms as of the last [`stage`](Self::stage). @@ -1045,18 +1083,36 @@ impl EffectControlLane { } } - /// Whether [`Self::carry_from`] would take `predecessor`: both lanes have no cells, or both - /// have cells of one shape (the same parameter groups and target slots), so every cell of the - /// predecessor has a carried slot here. Allocation-free and bounded by the group count. + /// Whether this lane and `other` have no cells, or cells of one shape (the same parameter + /// groups and target slots), so every cell of one has a cell of the other. Allocation-free and + /// bounded by the group count. #[must_use] - pub fn can_carry_from(&self, predecessor: &Self) -> bool { - match (self.cells.as_deref(), predecessor.cells.as_deref()) { + pub fn same_cell_shape(&self, other: &Self) -> bool { + match (self.cells.as_deref(), other.cells.as_deref()) { (None, None) => true, (Some(own), Some(before)) => own.shape == before.shape, _ => false, } } + /// Whether [`Self::carry_from`] would take `predecessor`: both lanes have no cells, or both + /// have cells of one shape, so every cell of the predecessor has a carried slot here, and + /// this lane's slots are installed ([`Self::install_carry_slots`], #1633 D2). The + /// predecessor's own slots may be missing: it reads them as empty. Allocation-free and + /// bounded by the group count. + #[must_use] + pub fn can_carry_from(&self, predecessor: &Self) -> bool { + self.same_cell_shape(predecessor) && self.carry_slots_installed() + } + + /// Whether this lane holds the carried slots a successor writes into: always true for a lane + /// without cells, which has nothing to carry. + #[must_use] + pub fn carry_slots_installed(&self) -> bool { + self.carried_parameters.len() == self.parameter_readers.len() + && self.carried_targets.len() == self.target_readers.len() + } + /// Take the live state of `predecessor`, the lane of the plan this lane's plan displaces, at /// the swap block (#1280 D2). The predecessor never renders again, so nothing here consumes /// from it and nothing writes a cell: control stays each cell's only writer. @@ -1090,24 +1146,35 @@ impl EffectControlLane { predecessor.carried_bypass, ); pending |= self.carried_bypass.unread != 0; - for (((slot, cell), reader), earlier) in self + // A predecessor that was never carried into holds no slots: it has no earlier writes. + for (index, ((slot, cell), reader)) in self .carried_parameters .iter_mut() .zip(&before.parameters) .zip(&predecessor.parameter_readers) - .zip(&predecessor.carried_parameters) + .enumerate() { - *slot = CarriedCell::of(reader, cell, *earlier); + let earlier = predecessor + .carried_parameters + .get(index) + .copied() + .unwrap_or(CarriedCell::EMPTY); + *slot = CarriedCell::of(reader, cell, earlier); pending |= slot.unread != 0; } - for (((slot, cell), reader), earlier) in self + for (index, ((slot, cell), reader)) in self .carried_targets .iter_mut() .zip(&before.targets) .zip(&predecessor.target_readers) - .zip(&predecessor.carried_targets) + .enumerate() { - *slot = CarriedCell::of(reader, cell, *earlier); + let earlier = predecessor + .carried_targets + .get(index) + .copied() + .unwrap_or(CarriedCell::EMPTY); + *slot = CarriedCell::of(reader, cell, earlier); pending |= slot.unread != 0; } self.carried_pending = pending; @@ -1191,7 +1258,7 @@ impl EffectControlLane { superseded, }; if let Some([word, ramp_samples]) = - reads.read(0, &cells.bypass, bypass_reader, carried_bypass) + reads.read(0, &cells.bypass, bypass_reader, Some(carried_bypass)) { let value = word != 0; // A record whose target is the lane's current target changes nothing, whether the @@ -1229,7 +1296,7 @@ impl EffectControlLane { 1 + first + offset, &cells.parameters[first + offset], &mut parameter_readers[first + offset], - &mut carried_parameters[first + offset], + carried_parameters.get_mut(first + offset), ) .map(|[bits]| bits); } @@ -1277,7 +1344,7 @@ impl EffectControlLane { cells.shape.target_bit(index), &cells.targets[index], &mut target_readers[index], - &mut carried_targets[index], + carried_targets.get_mut(index), ); } let slot = slot as u32; @@ -1362,7 +1429,7 @@ impl Reads<'_> { bit: usize, cell: &LatestCell, reader: &mut CellReader, - carried: &mut CarriedCell, + carried: Option<&mut CarriedCell>, ) -> Option<[u32; N]> { let own = if self.pending[bit / 64] & (1 << (bit % 64)) == 0 { None @@ -1378,9 +1445,10 @@ impl Reads<'_> { CellRead::Unchanged => None, } }; - if carried.unread == 0 { + // A lane whose slots are not installed was never carried into: nothing waits in them. + let Some(carried) = carried.filter(|carried| carried.unread != 0) else { return own; - } + }; let unread = core::mem::replace(&mut carried.unread, 0); if own.is_some() { self.superseded.add_carried(unread); @@ -1623,6 +1691,36 @@ impl ObservationLane { self.armed_count != 0 } + /// Whether [`Self::swap_state`] would exchange this lane's taps with `other`'s: both declare + /// the same number of taps, with the same fold at each index (the same effect's menu, #1327 + /// D1). One load and one compare per tap; allocation-free. + #[must_use] + pub fn same_taps(&self, other: &Self) -> bool { + self.taps.len() == other.taps.len() + && self + .taps + .iter() + .zip(other.taps.iter()) + .all(|(own, theirs)| own.fold == theirs.fold) + } + + /// Exchange every tap's whole state with `other`'s at a plan swap (#1327 D2): arm state, open + /// window, sequence, accumulated peaks and the publisher, so a reader that holds the carried + /// tap's slot stays connected to the tap that renders on. The two lanes' default window + /// lengths stay where they are: each plan sized its own, and a carried tap keeps the length + /// it was armed with. + /// + /// Returns `false` and exchanges nothing unless [`Self::same_taps`]. Two pointer exchanges and + /// a scalar one: no allocation, free, lock or syscall, and constant time. + pub fn swap_state(&mut self, other: &mut Self) -> bool { + if !self.same_taps(other) { + return false; + } + core::mem::swap(&mut self.taps, &mut other.taps); + core::mem::swap(&mut self.armed_count, &mut other.armed_count); + true + } + /// Whether tap `tap_index` should be read for this block at all. /// /// The whole of level-2 zero: one bounds check and one flag load. An unarmed tap's effect state @@ -1830,6 +1928,17 @@ impl BypassShunt { !self.line_left.is_empty() } + /// Whether `other` was allocated for the same block and the same latency, so a plan swap can + /// exchange the two shunts whole (#1282 D2): every buffer of one has the length of the + /// matching buffer of the other. Allocation-free and constant time. + #[must_use] + pub fn same_shape(&self, other: &Self) -> bool { + self.dry_left.len() == other.dry_left.len() + && self.dry_right.len() == other.dry_right.len() + && self.line_left.len() == other.line_left.len() + && self.line_right.len() == other.line_right.len() + } + /// Capture this block's input and advance the latency line, returning nothing. /// /// Called on every block whenever [`feeds_line`](Self::feeds_line) is true, bypassed or not: diff --git a/crates/effect-contract/tests/live_control.rs b/crates/effect-contract/tests/live_control.rs index 2152d4033..ad232f978 100644 --- a/crates/effect-contract/tests/live_control.rs +++ b/crates/effect-contract/tests/live_control.rs @@ -235,6 +235,63 @@ fn a_drained_cell_stages_nothing_on_the_next_block() { assert_eq!(rig.stage().staged, 0); } +/// A lane holds no carried slots until a successor swap installs them (#1633 D1), refuses to be +/// carried into before that (D2), and its retained bytes grow by exactly the charge the install +/// is admitted against. +/// +/// Test value: a lane that allocates its slots at build (the boot growth #1633 removes), a +/// `carry_from` that writes into slots that do not exist, or a charge that differs from what the +/// install allocates. +#[test] +fn carried_slots_are_installed_for_a_successor_only_and_charged_exactly() { + let mut successor = Rig::new(shape(&[0, 2], &[], 3), 0); + let predecessor = Rig::new(shape(&[0, 2], &[], 3), 0); + let before = successor.lane.retained_resources(); + assert!(!successor.lane.carry_slots_installed()); + assert!( + !successor.lane.can_carry_from(&predecessor.lane), + "a lane with no slots takes no carry" + ); + let charge = successor.lane.carry_slot_bytes(); + assert!(charge > 0, "a lane with cells has slots to charge"); + successor.lane.install_carry_slots(); + let after = successor.lane.retained_resources(); + assert_eq!(after.total_bytes - before.total_bytes, charge); + assert!(successor.lane.carry_slots_installed()); + assert!( + successor.lane.can_carry_from(&predecessor.lane), + "the predecessor never had slots: its earlier writes read as empty" + ); + successor.lane.install_carry_slots(); + assert_eq!( + successor.lane.retained_resources(), + after, + "a second install keeps the slots it has" + ); + let bare = effect_contract::EffectControlLane::without_channel(false); + assert_eq!(bare.carry_slot_bytes(), 0, "a lane without cells has none"); + assert!(bare.can_carry_from(&effect_contract::EffectControlLane::without_channel(true))); +} + +/// A successor with installed slots resolves a write its never-carried-into predecessor held +/// unread, in its first block, and counts nothing it did not replace. +/// +/// Test value: a carry that reads a missing predecessor slot as a write, or drops the unread +/// write when the predecessor holds no slots. +#[test] +fn a_predecessor_without_slots_hands_its_unread_write_to_an_installed_successor() { + let mut predecessor = Rig::new(shape(&[1], &[], 0), 0); + let mut successor = Rig::new(shape(&[1], &[], 0), 0); + predecessor.commit(&[left(1, 0.25)]).expect("a cell"); + successor.lane.install_carry_slots(); + assert!(successor.lane.carry_from(&predecessor.lane)); + let staged = successor.stage(); + assert_eq!(staged.staged, 1); + assert_eq!(successor.staging[0].start_value, 0.25); + assert_eq!(successor.superseded.get(), 0); + assert_eq!(successor.stage().staged, 0, "the slot resolves once"); +} + /// Bypass is not a span: it is retained state that survives blocks with no traffic. #[test] fn bypass_is_retained_state_and_produces_no_span() { diff --git a/crates/effect-contract/tests/observation_lane.rs b/crates/effect-contract/tests/observation_lane.rs index 7968046be..1645ccdb9 100644 --- a/crates/effect-contract/tests/observation_lane.rs +++ b/crates/effect-contract/tests/observation_lane.rs @@ -286,3 +286,67 @@ fn a_lane_refuses_a_publisher_count_that_does_not_match_the_menu() { assert!(ObservationLane::new(&MENU, vec![publisher], 4).is_none()); assert!(ObservationLane::new(&MENU, Vec::new(), 4).is_none()); } + +/// A plan swap exchanges two lanes' whole tap state (#1327 D2): the open window, the arm state +/// and the publisher, so the reader the host holds for the old lane receives the windows the +/// carried tap goes on to publish. Each lane keeps its own default window length. +/// +/// Test value: a swap that copies the window but keeps the successor's publisher leaves the host's +/// reader on a dead cell; one that restarts the window loses the blocks already folded. +#[test] +fn a_lane_swap_carries_the_open_window_and_the_publisher() { + let (mut old, old_readers) = lane(4); + let (mut fresh, fresh_readers) = lane(8); + assert!(old.arm(0, true, 2, 0)); + old.accumulate(0, sample(-3.0, 1.0), 0, 128); + assert!(fresh.swap_state(&mut old)); + assert!(fresh.is_armed(0) && fresh.any_armed()); + assert!(!old.is_armed(0) && !old.any_armed()); + assert_eq!( + (old.default_window_blocks(), fresh.default_window_blocks()), + (4, 8) + ); + + fresh.accumulate(0, sample(-5.0, 0.5), 128, 128); + let window = old_readers[0] + .read() + .expect("the window arrives on the reader of the lane it left"); + assert_eq!( + ( + window.first_sample, + window.end_sample, + window.sequence, + window.blocks, + window.left, + window.right + ), + (0, 256, 1, 2, 5.0, 1.0) + ); + assert!( + fresh_readers[0].read().is_none(), + "the other reader sees nothing" + ); +} + +/// Two lanes of different menus exchange nothing. +#[test] +fn a_lane_swap_refuses_another_menu() { + static ONE_TAP: [ObservationDescriptor; 1] = [tap(1, ObservationFold::PeakMagnitude)]; + static FOLDED: [ObservationDescriptor; 2] = [ + tap(1, ObservationFold::PeakMagnitude), + tap(2, ObservationFold::PeakMagnitude), + ]; + let (mut two, _readers) = lane(4); + let (publisher, _reader) = observation_slot(); + let mut one = ObservationLane::new(&ONE_TAP, vec![publisher], 4).expect("lane"); + let mut publishers = Vec::new(); + for _ in 0..FOLDED.len() { + publishers.push(observation_slot().0); + } + let mut folded = ObservationLane::new(&FOLDED, publishers, 4).expect("lane"); + assert!(two.arm(0, true, 1, 0)); + assert!(!two.swap_state(&mut one), "another tap count"); + assert!(!two.swap_state(&mut folded), "another fold at tap 1"); + assert!(two.is_armed(0), "nothing was exchanged"); + assert!(!one.is_armed(0) && !folded.is_armed(0)); +} diff --git a/crates/engine/src/realtime/mod.rs b/crates/engine/src/realtime/mod.rs index c80553c6a..2b48fc18f 100644 --- a/crates/engine/src/realtime/mod.rs +++ b/crates/engine/src/realtime/mod.rs @@ -28,11 +28,12 @@ pub use observe::{ observation_slot_retained_bytes, }; pub use plan::{ - CarryOutcome, HostAttachment, PlanRevisionGate, PlanUnitEligibility, PrepareRenderPlan, - PreparedPlanExecutor, PreparedProgram, PreparedRenderPlan, RenderEnvelope, RenderError, - RenderIo, RenderReport, RenderTime, ResponseSnapshotAvailability, ResponseSnapshotCapture, - ResponseSnapshotError, ResponseSnapshotOwnerInfo, ResponseSnapshotRequest, - ResponseSnapshotSection, ResponseSnapshotSink, plan_revision_gate_retained_bytes, + CarryOutcome, HostAttachment, ObserverCarry, ObserverKind, PlanRevisionGate, + PlanUnitEligibility, PrepareRenderPlan, PreparedPlanExecutor, PreparedProgram, + PreparedRenderPlan, RenderEnvelope, RenderError, RenderIo, RenderReport, RenderTime, + ResponseSnapshotAvailability, ResponseSnapshotCapture, ResponseSnapshotError, + ResponseSnapshotOwnerInfo, ResponseSnapshotRequest, ResponseSnapshotSection, + ResponseSnapshotSink, plan_revision_gate_retained_bytes, }; pub use plan_exchange::{ PlanEpoch, PlanExchangeConfig, PlanExchangeError, PlanExchangeResourceReport, PlanPublisher, diff --git a/crates/engine/src/realtime/plan.rs b/crates/engine/src/realtime/plan.rs index 9ffe232c9..d83fa9850 100644 --- a/crates/engine/src/realtime/plan.rs +++ b/crates/engine/src/realtime/plan.rs @@ -315,6 +315,22 @@ pub trait PreparedPlanExecutor: Send { fn adopt_predecessor(&mut self, _predecessor: &mut dyn PreparedPlanExecutor) -> CarryOutcome { CarryOutcome::NotRequested } + /// The observers this plan took from its predecessor in the block that applied its swap + /// (#1327 D3): one record per carried observer, written by + /// [`adopt_predecessor`](Self::adopt_predecessor) into preallocated storage and never changed + /// after, since a plan is adopted at most once. + /// + /// **Where it runs.** Called on the render thread inside the render scope, right after the + /// hand-over, by [`PreparedRenderPlan::adopt_predecessor_plan`] and by the plan exchange's swap + /// block. Control-side code may read it too. + /// + /// **Render-thread code.** A borrow of existing storage: no allocation, free, lock, syscall, + /// log or unbounded loop. + /// + /// The default is empty: this executor carried no observer. + fn observer_carries(&self) -> &[ObserverCarry] { + &[] + } /// Whether this plan, running, is ready for its successor to adopt with a prime of /// `lead_blocks` blocks at the block that starts at `block_start` (#1311 D3). /// @@ -524,6 +540,37 @@ pub enum CarryOutcome { PredecessorMismatch, } +/// Which host-visible reader vector an [`ObserverCarry`] indexes (#1327 D3). +/// +/// The kinds name vectors the host holds, in the order the plan's observer location table +/// lists the entries of that kind, so an entry's index is its reader's index. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ObserverKind { + /// A strip meter's reader. + Meter, + /// An effect observation tap's reader. + Observation, + /// A spectrum capture's reader (#1395). + Spectrum, +} + +/// One observer the successor took from its predecessor in the swap block (#1327 D3): the +/// successor's observer at entry `successor` of its `kind` now holds the state that sat at entry +/// `predecessor` of the predecessor's, producer included, so a reader the host already holds for +/// the predecessor's entry must follow it to the successor's. +/// +/// Plain data: the executor writes the record into preallocated storage in the swap block, and a +/// host pairs its readers from it with no allocation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ObserverCarry { + /// The reader vector both indices address. + pub kind: ObserverKind, + /// The entry index in the successor plan (this plan). + pub successor: usize, + /// The entry index in the predecessor plan. + pub predecessor: usize, +} + /// Absolute sample time supplied by the host; no wall clock is used. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct RenderTime { @@ -713,8 +760,9 @@ pub struct PreparedRenderPlan { gate: Arc, /// The pin of `gate` until [`Self::take_revision_gate`] hands the control handle out. gate_pin: Option, - /// Host data that travels with the plan (issue #1400 D4). Render never reads, calls or drops - /// it: it is set on the control thread before publication, and drops with the plan on the + /// Host data that travels with the plan (issue #1400 D4). Render never reads or drops it, and + /// calls it once per applied swap, through [`HostAttachment::adopt_predecessor`] in the swap + /// block. It is set on the control thread before publication, and drops with the plan on the /// retirement path. host_attachment: Option>, _not_sync: Cell<()>, @@ -732,6 +780,26 @@ pub struct PreparedRenderPlan { pub trait HostAttachment: Send + 'static { /// The attachment as `Any`, for [`PreparedRenderPlan::host_attachment_mut`]'s downcast. fn as_any_mut(&mut self) -> &mut dyn core::any::Any; + /// Take state from the attachment of the plan this one displaces, in the block that applies the + /// swap (#1381 D3). + /// + /// **Where it runs.** On the render thread, inside the render scope, from + /// `PreparedRenderPlan::carry_from`: after the executor hand-over, and only when both plans + /// carry an attachment. `carries` is the successor executor's + /// [`PreparedPlanExecutor::observer_carries`] (empty when it has none), so an attachment that + /// holds a host's readers pairs them with the observers the executors carried. + /// + /// **Render-thread code.** No allocation, free, lock, syscall, log or unbounded loop: it may + /// only move or swap state between preallocated storage. `predecessor` retires after this + /// returns and never renders again, so state may be moved out of it. + /// + /// The default takes nothing. + fn adopt_predecessor( + &mut self, + _predecessor: &mut dyn HostAttachment, + _carries: &[ObserverCarry], + ) { + } } #[cfg(test)] @@ -1154,15 +1222,37 @@ impl PreparedRenderPlan { .is_some_and(|executor| executor.prime_ready(block_start, lead_blocks)) } - /// Run the executor hand-over from `predecessor`, if both plans have executors. + /// The observers this plan took from its predecessor at its swap block + /// ([`PreparedPlanExecutor::observer_carries`]); empty without an executor or a carry. + #[must_use] + pub fn observer_carries(&self) -> &[ObserverCarry] { + self.executor + .as_deref() + .map_or(&[], |executor| executor.observer_carries()) + } + + /// Run the executor hand-over from `predecessor`, if both plans have executors, then the + /// attachment hand-over ([`HostAttachment::adopt_predecessor`]), if both plans have an + /// attachment, with the successor executor's observer carries. pub(crate) fn carry_from(&mut self, predecessor: &mut Self) -> CarryOutcome { - match ( + let outcome = match ( self.executor.as_deref_mut(), predecessor.executor.as_deref_mut(), ) { (Some(successor), Some(predecessor)) => successor.adopt_predecessor(predecessor), _ => CarryOutcome::NotRequested, + }; + if let (Some(successor), Some(predecessor)) = ( + self.host_attachment.as_deref_mut(), + predecessor.host_attachment.as_deref_mut(), + ) { + let carries = self + .executor + .as_deref() + .map_or(&[][..], |executor| executor.observer_carries()); + successor.adopt_predecessor(predecessor, carries); } + outcome } /// Render one block through the plan's own revision gate (#1502 D2). After the output-shape diff --git a/crates/graph/src/lib.rs b/crates/graph/src/lib.rs index 8bb324e30..818b6ec9c 100644 --- a/crates/graph/src/lib.rs +++ b/crates/graph/src/lib.rs @@ -20,9 +20,10 @@ pub use runtime::{ pub use runtime::{ TestOnlyFailedBufferCapture, TestOnlySelectedSplitFader, TestOnlySplitPairTableWitness, test_only_arm_failed_buffer_capture, test_only_bank_meter_passes, - test_only_bank_sample_peak_passes, test_only_completion_disabled, - test_only_failed_buffer_capture, test_only_meter_input_counts, test_only_meter_input_reset, - test_only_observation_dispatch_counts, test_only_observation_dispatch_reset, + test_only_bank_sample_peak_passes, test_only_carry_payload_bytes, + test_only_completion_disabled, test_only_failed_buffer_capture, test_only_meter_input_counts, + test_only_meter_input_reset, test_only_observation_dispatch_counts, + test_only_observation_dispatch_reset, test_only_reset_carry_payload_bytes, test_only_reset_selected_split_fader, test_only_reset_split_pair_table_witness, test_only_resident_input_counts, test_only_resident_input_reset, test_only_route_activity_built, test_only_route_drain_calls, test_only_route_drain_snapshots, @@ -217,9 +218,9 @@ use effect_contract::{ ResponseSnapshotSummary, SeamSide, TailSamples, }; use engine::realtime::{ - BufferArena, CarryOutcome, LiveSnapshot, PlanUnitEligibility, PlanarBufferMut, - PrepareRenderPlan, PreparedPlanExecutor, PreparedRenderPlan, RenderEnvelope, RenderError, - ResponseSnapshotError, ResponseSnapshotSink, + BufferArena, CarryOutcome, LiveSnapshot, ObserverCarry, ObserverKind, PlanUnitEligibility, + PlanarBufferMut, PrepareRenderPlan, PreparedPlanExecutor, PreparedRenderPlan, RenderEnvelope, + RenderError, ResponseSnapshotError, ResponseSnapshotSink, }; use lane::Backend; use rack::AoSoaScratch; @@ -2879,6 +2880,49 @@ pub trait GraphRuntimeObserver: Send { /// block calls [`Self::invalidate_after_failure`] and never this. It must be bounded and /// allocation-free. fn block_end(&mut self) {} + + /// The kind of host reader this observer's state belongs to, or `None` (the default) for an + /// observer whose state a plan swap never carries (#1327 D1). Read off the render thread, + /// when the plan's observer rows are recorded. + fn carry_kind(&self) -> Option { + None + } + + /// Name this observer's state by `handle`, the handle of the binding that now holds it + /// (#1327 D2). Called in the swap block on both observers of a carried pair after their + /// boxes were exchanged, since a snapshot carries the handle of the binding it came from. + /// The default ignores it. + /// + /// **Render-thread code.** A scalar write: no allocation, free, lock or syscall. + fn adopt_binding_handle(&mut self, _handle: u64) {} + + /// Where this observer's open window sits on the sample grid (#1327 D6), or `None` (the + /// default) for an observer with no window. + /// + /// **Render-thread code.** A scalar read: no allocation, free, lock or syscall. + fn window_position(&self) -> Option { + None + } + + /// Open this observer's first window at `position` (#1327 D6), as an observer that saw + /// silence since that window opened would hold it. `false` (the default) declines: the + /// observer has no window, or has already observed. + /// + /// **Render-thread code.** Scalar writes: no allocation, free, lock or syscall. + fn join_window(&mut self, _position: ObserverWindow) -> bool { + false + } +} + +/// Where an observer's open window sits on the sample grid (#1327 D6). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ObserverWindow { + /// First sample of the open window, or `None` before the first observation. + pub start: Option, + /// Frames already in the open window. + pub frames: u32, + /// Sequence number of the snapshot the open window will publish. + pub sequence: u64, } /// One immutable prepared observer binding, ordered by its stable meter handle. pub struct GraphNodeObserverBinding { @@ -2988,6 +3032,24 @@ struct GraphExecutor { /// Restores a swap's carry refused on a per-node successor instance, which no bank stage /// counts (#1281, #1279 D6). Saturating; read after render. node_carry_refused: u64, + /// The observer section of the hand-over (#1327 D2, D3): `None` when no observer carries. + observer_carry: Option>, + /// The compensation-line section of the hand-over (#1283 D1), sorted by predecessor line; + /// empty (and never allocated) when no line carries. + carry_delays: Box<[GraphDelayMove]>, + /// The identity of every compensation line, read once by [`take_compensation_lines`] right + /// after bind and empty afterwards: a plan retains none of it. + compensation_lines: Vec, + /// The strip-delay section of the hand-over (#1284 D1), sorted by predecessor line; empty + /// (and never allocated) when no strip delay line carries. + carry_strip_delays: Box<[GraphStripDelayMove]>, + /// The live-send section of the hand-over (#1284 D2), sorted by predecessor unit; empty (and + /// never allocated) when no live route carries. + carry_routes: Box<[GraphRouteMove]>, + /// The identity of every strip delay line and live route, read once by + /// [`take_strip_delay_lines`] and [`take_live_routes`] right after bind and empty afterwards: + /// a plan retains none of it. + bind_identities: runtime::BindIdentities, /// The live snapshot `S` of the block about to render (#1504 D1): stored by /// [`PreparedPlanExecutor::begin_live_block`] and taken by `render`, so a block that renders /// without its own snapshot is refused and never reads the previous block's. @@ -3058,8 +3120,30 @@ pub enum GraphCarryInstallError { EffectLaneOutOfRange, /// A successor or predecessor effect lane appears twice. DuplicateEffectLane, - /// An effect move names a per-node instance on both sides (#1282 moves those). - EffectMoveBetweenNodes, + /// An observer carry was installed before the plan's [`GraphCarryProgram`]. + NoObserverCarryProgram, + /// A successor location is not an observer of the kind the move or join names. + ObserverOutOfRange, + /// A successor or predecessor observer appears twice, or a join's fresh observer is carried. + DuplicateObserver, + /// A compensation-line carry was installed before the plan's [`GraphCarryProgram`]. + NoDelayCarryProgram, + /// A successor line is not a compensation line of the plan. + DelayLineOutOfRange, + /// A successor or predecessor compensation line appears twice. + DuplicateDelayLine, + /// A strip-delay carry was installed before the plan's [`GraphCarryProgram`]. + NoStripDelayCarryProgram, + /// A successor line is not a strip delay line of the plan, or names no lane. + StripDelayLineOutOfRange, + /// A successor or predecessor strip delay line appears twice. + DuplicateStripDelayLine, + /// A live-send carry was installed before the plan's [`GraphCarryProgram`]. + NoRouteCarryProgram, + /// A successor unit is not a live route of the plan. + RouteOutOfRange, + /// A successor or predecessor live route appears twice. + DuplicateRoute, } /// Where one builtin bank lane sits in a bound graph plan: its runtime unit, its bank chain slot @@ -3217,7 +3301,8 @@ pub enum GraphEffectSite { /// One effect lane a successor takes from its predecessor at the swap block: the whole state of /// the instance at `predecessor` in the displaced plan is moved into `successor` (issue #1279 D3). /// A lane may change representation in the move: a bank lane to a per-node instance or back -/// (#1281); a move between two per-node instances is not installed (#1282). +/// (#1281). A move between two per-node instances exchanges the processor and the dry shunt by +/// pointer and passes nothing through the payload scratch (#1282). #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct GraphEffectMove { /// The instance in the successor, the plan the move is installed in. @@ -3267,9 +3352,9 @@ pub fn bank_effect_lanes( /// /// Refused, leaving any earlier section in place, when the plan is not a graph plan, has no carry /// program, a successor site is not a populated lane of an effect bank or a live-controlled -/// per-node effect, a move is between two per-node instances, or a successor or predecessor site -/// repeats. Predecessor sites and layouts are checked against the predecessor at the swap block, -/// where one that does not resolve refuses the whole hand-over. The moves are kept sorted by +/// per-node effect, or a successor or predecessor site repeats. Predecessor sites and layouts are +/// checked against the predecessor at the swap block, where one that does not resolve refuses the +/// whole hand-over. The moves are kept sorted by /// predecessor site, so the swap block disengages each predecessor chain once. pub fn install_effect_carry( plan: &mut PreparedRenderPlan, @@ -3288,12 +3373,6 @@ pub fn install_effect_carry( if !executor.runtime.has_effect_site(step.successor) { return Err(GraphCarryInstallError::EffectLaneOutOfRange); } - if matches!( - (step.successor, step.predecessor), - (GraphEffectSite::Node(_), GraphEffectSite::Node(_)) - ) { - return Err(GraphCarryInstallError::EffectMoveBetweenNodes); - } if let GraphEffectSite::Lane(at) = step.successor { let Some(layout) = executor.runtime.effect_lane_layout(at) else { return Err(GraphCarryInstallError::EffectLaneOutOfRange); @@ -3307,21 +3386,49 @@ pub fn install_effect_carry( } } moves.sort_unstable_by_key(|step| step.predecessor); + // #1633 D1: the carried slots of the successor lanes `moves` names, and no others. + for step in &moves { + executor.runtime.install_effect_carry_slots(step.successor); + } executor.carry_scratch = vec![0_u8; usize::try_from(scratch).unwrap_or(usize::MAX)].into_boxed_slice(); executor.carry_effects = moves.into_boxed_slice(); Ok(()) } +/// The bytes [`install_effect_carry`] allocates for the carried slots of the successor lanes +/// `moves` names (#1633 D1): the part of a carry's charge that depends on the lanes' cell tables. +/// Read before the install, so preparation admits the carry before it allocates. `None` when the +/// plan is not a graph plan, a successor site does not resolve, or the sum overflows. +pub fn effect_carry_slot_bytes( + plan: &mut PreparedRenderPlan, + moves: &[GraphEffectMove], +) -> Option { + let executor = plan.executor_any_mut()?.downcast_mut::()?; + moves.iter().try_fold(0_u64, |total, step| { + total.checked_add( + executor + .runtime + .effect_site_carry_slot_bytes(step.successor)?, + ) + }) +} + /// The effect-section bytes a carry of `moves` lanes with a largest payload of `scratch_bytes` -/// retains in its plan: the move table and the payload scratch ([`install_effect_carry`]). -/// Checked arithmetic: `None` on overflow. The one place the charge is spelled, so preparation's -/// admission and [`carry_program_retained_bytes`] cannot disagree. +/// and `slot_bytes` of carried slots ([`effect_carry_slot_bytes`]) retains in its plan: the move +/// table, the payload scratch and the slots ([`install_effect_carry`]). Checked arithmetic: `None` +/// on overflow. The one place the charge is spelled, so preparation's admission and +/// [`carry_program_retained_bytes`] cannot disagree. #[must_use] -pub fn effect_carry_retained_bytes(moves: usize, scratch_bytes: u64) -> Option { +pub fn effect_carry_retained_bytes( + moves: usize, + scratch_bytes: u64, + slot_bytes: u64, +) -> Option { u64::try_from(moves.checked_mul(core::mem::size_of::())?) .ok()? - .checked_add(scratch_bytes) + .checked_add(scratch_bytes)? + .checked_add(slot_bytes) } /// Restores the console-effect carry refused in this plan's banks, summed over every effect bank @@ -3388,6 +3495,619 @@ pub fn install_carry_program( Ok(()) } +/// Where one observer sits in a bound graph plan: its runtime unit, its member op within a bank +/// (or [`Self::NO_MEMBER`] for a unit that is one op) and its index in that op's observer list +/// (#1327 D2). Valid only for the plan it was read from. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct GraphObserverLocation { + /// The runtime unit. + pub unit: u32, + /// The member op of a bank unit, or [`Self::NO_MEMBER`]. + pub member: u32, + /// The observer's index in the op's observer list. + pub index: u32, +} + +impl GraphObserverLocation { + /// `member` of an observer in a unit that is a single op. + pub const NO_MEMBER: u32 = u32::MAX; +} + +/// One observer of a bound graph plan that a swap can carry (#1327 D2): its kind, the handle of +/// its binding and where it sits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphObserverRow { + /// The reader vector the observer's state belongs to. + pub kind: ObserverKind, + /// The handle of the observer's binding (a meter's is its meter handle). + pub handle: u64, + /// Where the observer sits in this plan. + pub location: GraphObserverLocation, +} + +/// One observer a successor takes from its predecessor at the swap block (#1327 D2): the boxed +/// observer at `predecessor` in the displaced plan and the one at `successor` here exchange +/// places, so a reader that holds the carried observer's consumer stays connected to the +/// observer that renders on. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphObserverMove { + /// The reader vector both entries index. + pub kind: ObserverKind, + /// The observer in the successor, the plan the move is installed in. + pub successor: GraphObserverLocation, + /// The observer in the plan the successor displaces. + pub predecessor: GraphObserverLocation, + /// The successor reader's index in its kind's reader vector ([`ObserverCarry::successor`]). + pub successor_entry: usize, + /// The predecessor reader's index ([`ObserverCarry::predecessor`]). + pub predecessor_entry: usize, +} + +/// A fresh observer that joins a carried observer's window grid (#1327 D6): after the moves, the +/// observer at `fresh` opens its first window where the carried observer at `donor` has its open +/// window. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphObserverJoin { + /// The observer in the successor that has no carry record. + pub fresh: GraphObserverLocation, + /// The successor location of a carried observer of the same configuration. + pub donor: GraphObserverLocation, +} + +/// One effect instance's observation taps a successor takes from its predecessor at the swap +/// block (#1327 D1, D2): the taps follow the effect owner, so a move is named by the sites of the +/// two effect instances and applies only if that effect move carried. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphTapMove { + /// The effect instance in the successor, the plan the move is installed in. Must be the + /// successor of one of the plan's [`GraphEffectMove`]s and hold an observation lane. + pub successor: GraphEffectSite, + /// The effect instance in the plan the successor displaces: the predecessor of that same + /// effect move. + pub predecessor: GraphEffectSite, + /// The successor reader's index in the observation reader vector ([`ObserverCarry::successor`]). + pub successor_entry: usize, + /// The predecessor reader's index ([`ObserverCarry::predecessor`]). + pub predecessor_entry: usize, +} + +/// A [`GraphTapMove`] bound to the effect move it follows (#1327 D1). +#[derive(Clone, Copy)] +struct ObserverTapMove { + step: GraphTapMove, + /// The index of the effect move in the plan's sorted effect table. + effect_move: usize, + /// Whether that effect move carried at the swap block: written by the effect section, read by + /// the tap section that follows it. + taken: bool, +} + +/// The observer section of a hand-over: the meter moves, the grid joins, the effect tap moves and +/// the preallocated carry record (#1327 D2, D3, D6). +struct ObserverCarryProgram { + moves: Box<[GraphObserverMove]>, + joins: Box<[GraphObserverJoin]>, + /// Sorted by `effect_move`. + taps: Box<[ObserverTapMove]>, + /// One slot per move and per tap move, written at the swap block, `carried` of them. + record: Box<[ObserverCarry]>, + carried: usize, +} + +impl ObserverCarryProgram { + /// A program of these tables, or `None` for no section at all. + fn assemble( + moves: Box<[GraphObserverMove]>, + joins: Box<[GraphObserverJoin]>, + taps: Box<[ObserverTapMove]>, + ) -> Option> { + if moves.is_empty() && joins.is_empty() && taps.is_empty() { + return None; + } + let placeholder = ObserverCarry { + kind: ObserverKind::Meter, + successor: 0, + predecessor: 0, + }; + let record = vec![placeholder; moves.len() + taps.len()].into_boxed_slice(); + Some(Box::new(Self { + moves, + joins, + taps, + record, + carried: 0, + })) + } + + fn retained_bytes(&self) -> u64 { + observer_carry_retained_bytes(self.moves.len(), self.joins.len(), self.taps.len()) + .unwrap_or(u64::MAX) + } +} + +/// The bytes an observer section of `moves` meter moves, `joins` grid joins and `taps` effect tap +/// moves retains in its plan (#1327): the section, its three tables and the carry record. `None` +/// on overflow; zero for no section. +#[must_use] +pub fn observer_carry_retained_bytes(moves: usize, joins: usize, taps: usize) -> Option { + if moves == 0 && joins == 0 && taps == 0 { + return Some(0); + } + let bytes = core::mem::size_of::() + .checked_add(moves.checked_mul(core::mem::size_of::())?)? + .checked_add(joins.checked_mul(core::mem::size_of::())?)? + .checked_add(taps.checked_mul(core::mem::size_of::())?)? + .checked_add( + moves + .checked_add(taps)? + .checked_mul(core::mem::size_of::())?, + )?; + u64::try_from(bytes).ok() +} + +/// Every observer of a bound graph plan a swap can carry (those that answer +/// [`GraphRuntimeObserver::carry_kind`]), in program order (#1327 D2); `None` for any other plan. +/// Read off the render thread, before publication: a successor's preparation joins its own rows +/// with its predecessor's (recorded when that plan was prepared) by owner key, never by location. +pub fn observer_rows(plan: &mut PreparedRenderPlan) -> Option> { + let executor = plan.executor_any_mut()?.downcast_mut::()?; + Some(executor.runtime.observer_rows()) +} + +/// Install the observer section of the hand-over this graph plan runs when it displaces its +/// predecessor (#1327 D2, D6), after its [`GraphCarryProgram`], which names that predecessor. +/// +/// Refused, leaving any earlier section in place, when the plan is not a graph plan, has no carry +/// program, a successor location is not an observer of the move's kind, a successor or +/// predecessor location repeats, or a join's donor is not a moved successor observer of the +/// fresh observer's kind or its fresh observer is itself moved. Predecessor locations are checked +/// against the predecessor at the swap block, where one that does not resolve refuses the whole +/// hand-over. Allocates the carry record here, off the render thread. +pub fn install_observer_carry( + plan: &mut PreparedRenderPlan, + moves: Vec, + joins: Vec, +) -> Result<(), GraphCarryInstallError> { + let executor = plan + .executor_any_mut() + .and_then(|any| any.downcast_mut::()) + .ok_or(GraphCarryInstallError::NotAGraphPlan)?; + if executor.carry.is_none() { + return Err(GraphCarryInstallError::NoObserverCarryProgram); + } + let kinds: BTreeMap = executor + .runtime + .observer_rows() + .into_iter() + .map(|row| (row.location, row.kind)) + .collect(); + let mut moved_successors = BTreeSet::new(); + let mut moved_predecessors = BTreeSet::new(); + let mut entries = BTreeSet::new(); + for step in &moves { + if kinds.get(&step.successor) != Some(&step.kind) { + return Err(GraphCarryInstallError::ObserverOutOfRange); + } + if !moved_successors.insert(step.successor) + || !moved_predecessors.insert(step.predecessor) + || !entries.insert((step.kind as u8, true, step.successor_entry)) + || !entries.insert((step.kind as u8, false, step.predecessor_entry)) + { + return Err(GraphCarryInstallError::DuplicateObserver); + } + } + let mut joined = BTreeSet::new(); + for join in &joins { + let Some(kind) = kinds.get(&join.fresh) else { + return Err(GraphCarryInstallError::ObserverOutOfRange); + }; + if kinds.get(&join.donor) != Some(kind) || !moved_successors.contains(&join.donor) { + return Err(GraphCarryInstallError::ObserverOutOfRange); + } + if moved_successors.contains(&join.fresh) || !joined.insert(join.fresh) { + return Err(GraphCarryInstallError::DuplicateObserver); + } + } + // The tap moves of an earlier `install_observation_carry` stay. + let taps = executor + .observer_carry + .take() + .map_or_else(Box::default, |program| program.taps); + executor.observer_carry = + ObserverCarryProgram::assemble(moves.into_boxed_slice(), joins.into_boxed_slice(), taps); + Ok(()) +} + +/// Install the effect tap section of the hand-over this graph plan runs when it displaces its +/// predecessor (#1327 D1, D2), after its [`GraphCarryProgram`] and [`install_effect_carry`], whose +/// sorted move table the tap moves bind to: calling `install_effect_carry` again afterwards +/// invalidates them. +/// +/// Each tap move is the observation lane of one effect move. At the swap block it applies only if +/// that effect move carried, after the predecessor's pending `Observe` records were applied to its +/// tap, and exchanges the two lanes' whole tap state ([`effect_contract::ObservationLane::swap_state`]), +/// publisher included. A lane pair with different tap menus exchanges nothing. +/// +/// Refused, leaving any earlier section in place, when the plan is not a graph plan, has no carry +/// program, a tap move names no effect move of the plan (by both sites), its successor holds no +/// observation lane, or a successor or predecessor site or entry repeats. Allocates the record +/// here, off the render thread. +pub fn install_observation_carry( + plan: &mut PreparedRenderPlan, + taps: Vec, +) -> Result<(), GraphCarryInstallError> { + let executor = plan + .executor_any_mut() + .and_then(|any| any.downcast_mut::()) + .ok_or(GraphCarryInstallError::NotAGraphPlan)?; + if executor.carry.is_none() { + return Err(GraphCarryInstallError::NoObserverCarryProgram); + } + let mut bound = Vec::with_capacity(taps.len()); + let mut successors = BTreeSet::new(); + let mut predecessors = BTreeSet::new(); + let mut entries = BTreeSet::new(); + for step in taps { + let Ok(effect_move) = executor + .carry_effects + .binary_search_by_key(&step.predecessor, |effect| effect.predecessor) + else { + return Err(GraphCarryInstallError::ObserverOutOfRange); + }; + if executor.carry_effects[effect_move].successor != step.successor + || !executor.runtime.has_observation_lane(step.successor) + { + return Err(GraphCarryInstallError::ObserverOutOfRange); + } + if !successors.insert(step.successor) + || !predecessors.insert(step.predecessor) + || !entries.insert((true, step.successor_entry)) + || !entries.insert((false, step.predecessor_entry)) + { + return Err(GraphCarryInstallError::DuplicateObserver); + } + bound.push(ObserverTapMove { + step, + effect_move, + taken: false, + }); + } + bound.sort_unstable_by_key(|tap| tap.effect_move); + let (moves, joins) = executor.observer_carry.take().map_or_else( + || (Box::default(), Box::default()), + |program| (program.moves, program.joins), + ); + executor.observer_carry = + ObserverCarryProgram::assemble(moves, joins, bound.into_boxed_slice()); + Ok(()) +} + +/// One compensation line of a bound graph plan, named by the edge it delays (#1283 D1): what a +/// successor's preparation joins with its own lines, by edge ID and endpoints, never by `line`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GraphDelayLine { + /// The delayed edge. + pub edge: GraphEdgeId, + /// Where the edge's signal comes from. For a route's two edges (`RouteSource`, + /// `RouteDestination`) this is the route's own source, so a route whose source was + /// re-pointed does not match its predecessor on either edge. + pub source: GraphNodeId, + /// Where the edge's signal goes. For a route's two edges this is the route's own + /// destination. + pub destination: GraphNodeId, + /// The line's index in this plan. Meaningful only to this plan's successor's carry program, + /// which names it as the line to take. + pub line: u32, + /// The line's length in samples a channel. + pub samples: u64, +} + +impl GraphDelayLine { + /// Bytes of ID text the row owns on the heap: the strings of its edge and its two endpoints. + #[must_use] + pub fn heap_bytes(&self) -> usize { + fn edge(id: &GraphEdgeId) -> usize { + match id { + GraphEdgeId::TrackMain { target } => node(target), + GraphEdgeId::RouteSource { route_id } + | GraphEdgeId::RouteDestination { route_id } => route_id.as_str().len(), + GraphEdgeId::EffectSidechain { effect, port } => { + effect.track_id.as_str().len() + effect.effect_id.as_str().len() + port.len() + } + } + } + fn node(id: &GraphNodeId) -> usize { + match id { + GraphNodeId::TrackStage { track_id, .. } => track_id.as_str().len(), + GraphNodeId::Effect(effect) => { + effect.track_id.as_str().len() + effect.effect_id.as_str().len() + } + GraphNodeId::Route { route_id } => route_id.as_str().len(), + GraphNodeId::Submix { submix_id } => submix_id.as_str().len(), + GraphNodeId::Output { output_id } => output_id.as_str().len(), + GraphNodeId::CompensationDelay { edge_id } => { + core::mem::size_of::() + edge(edge_id) + } + } + } + edge(&self.edge) + node(&self.source) + node(&self.destination) + } +} + +/// One compensation line a successor takes from its predecessor at the swap block (#1283 D1, D2): +/// the pending samples of `predecessor` in the displaced plan continue in `successor`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphDelayMove { + /// The line in the successor, the plan the move is installed in. + pub successor: u32, + /// The line in the plan the successor displaces. + pub predecessor: u32, +} + +/// The identity of every compensation line `program` lowered from `spec` (#1283 D1), in line order. +fn compensation_lines_of( + spec: &GraphSpec, + program: &program::ExecutionProgram, +) -> Vec { + // A route's two edges share one identity: the route's own endpoints. + let mut routes: BTreeMap<&StableGraphId, (Option<&GraphNodeId>, Option<&GraphNodeId>)> = + BTreeMap::new(); + for edge in &spec.edges { + match &edge.id { + GraphEdgeId::RouteSource { route_id } => { + routes.entry(route_id).or_default().0 = Some(&edge.source.node); + } + GraphEdgeId::RouteDestination { route_id } => { + routes.entry(route_id).or_default().1 = Some(&edge.destination.node); + } + GraphEdgeId::TrackMain { .. } | GraphEdgeId::EffectSidechain { .. } => {} + } + } + program + .delays + .iter() + .enumerate() + .map(|(line, delay)| { + let edge = &spec.edges[delay.edge as usize]; + let ends = match &edge.id { + GraphEdgeId::RouteSource { route_id } + | GraphEdgeId::RouteDestination { route_id } => routes.get(route_id).copied(), + GraphEdgeId::TrackMain { .. } | GraphEdgeId::EffectSidechain { .. } => None, + }; + let (source, destination) = ends.unwrap_or_default(); + GraphDelayLine { + edge: edge.id.clone(), + source: source.unwrap_or(&edge.source.node).clone(), + destination: destination.unwrap_or(&edge.destination.node).clone(), + line: u32::try_from(line).expect("a line index fits the program's index width"), + samples: delay.samples, + } + }) + .collect() +} + +/// The compensation lines of a bound graph plan, in line order (#1283 D1); `None` for any other +/// plan, and empty on a second call. Read once, off the render thread, right after bind: a +/// successor's preparation joins its own lines with its predecessor's (recorded when that plan +/// was prepared), and a plan retains none of the identities it handed out. +pub fn take_compensation_lines(plan: &mut PreparedRenderPlan) -> Option> { + let executor = plan.executor_any_mut()?.downcast_mut::()?; + Some(core::mem::take(&mut executor.compensation_lines)) +} + +/// Install the compensation-line section of the hand-over this graph plan runs when it displaces +/// its predecessor (#1283 D1), after its [`GraphCarryProgram`], which names that predecessor. +/// +/// Refused, leaving any earlier section in place, when the plan is not a graph plan, has no carry +/// program, a successor line is not a compensation line of the plan, or a successor or +/// predecessor line repeats. Predecessor lines are checked against the predecessor at the swap +/// block, where one that does not resolve refuses the whole hand-over. +pub fn install_delay_carry( + plan: &mut PreparedRenderPlan, + mut moves: Vec, +) -> Result<(), GraphCarryInstallError> { + let executor = plan + .executor_any_mut() + .and_then(|any| any.downcast_mut::()) + .ok_or(GraphCarryInstallError::NotAGraphPlan)?; + if executor.carry.is_none() { + return Err(GraphCarryInstallError::NoDelayCarryProgram); + } + let mut successors = BTreeSet::new(); + let mut predecessors = BTreeSet::new(); + for step in &moves { + if step.successor as usize >= executor.runtime.delays.len() { + return Err(GraphCarryInstallError::DelayLineOutOfRange); + } + if !successors.insert(step.successor) || !predecessors.insert(step.predecessor) { + return Err(GraphCarryInstallError::DuplicateDelayLine); + } + } + moves.sort_unstable_by_key(|step| step.predecessor); + executor.carry_delays = moves.into_boxed_slice(); + Ok(()) +} + +/// The bytes a compensation-line section of `moves` lines retains in its plan (#1283): its move +/// table. `None` on overflow. The one place the charge is spelled, so preparation's admission and +/// [`carry_program_retained_bytes`] cannot disagree. +#[must_use] +pub fn delay_carry_retained_bytes(moves: usize) -> Option { + u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() +} + +/// Bit of [`GraphStripDelayMove::lanes`] for the left lane of a strip delay line (#1284 D1). +pub const STRIP_DELAY_LEFT: u8 = 1; +/// Bit of [`GraphStripDelayMove::lanes`] for the right lane of a strip delay line (#1284 D1). +pub const STRIP_DELAY_RIGHT: u8 = 2; + +/// One strip input alignment line of a bound graph plan (#1284 D1): the delay a track or submix +/// declares as `delay_samples`, named by the strip it belongs to. What a successor's preparation +/// joins with its own lines, by strip ID, never by `line`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GraphStripDelayLine { + /// The strip ID: a track's or a submix's. + pub strip: Box, + /// The line's index in this plan. Meaningful only to this plan's successor's carry program, + /// which names it as the line to take. + pub line: u32, + /// The length of the left and the right lane's ring, which is the lane's `delay_samples`. + pub samples: [u32; 2], +} + +impl GraphStripDelayLine { + /// Bytes of ID text the row owns on the heap. + #[must_use] + pub fn heap_bytes(&self) -> usize { + self.strip.len() + } +} + +/// The lanes of one strip delay line a successor takes from its predecessor at the swap block +/// (#1284 D1): the pending samples of `predecessor` in the displaced plan continue in +/// `successor`, lane by lane. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphStripDelayMove { + /// The line in the successor, the plan the move is installed in. + pub successor: u32, + /// The line in the plan the successor displaces. + pub predecessor: u32, + /// The lanes that move: [`STRIP_DELAY_LEFT`] and [`STRIP_DELAY_RIGHT`]. A moved lane's ring + /// has the same length in both plans. + pub lanes: u8, +} + +/// One live send of a bound graph plan (#1284 D2): a route into a submix with a live lane, named +/// by its route ID. What a successor's preparation joins with its own, by ID. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GraphLiveRoute { + /// The route's session ID. + pub route_id: Box, + /// The route's runtime unit in this plan. Meaningful only to this plan's successor's carry + /// program, which names it as the route to take. + pub unit: u32, +} + +impl GraphLiveRoute { + /// Bytes of ID text the row owns on the heap. + #[must_use] + pub fn heap_bytes(&self) -> usize { + self.route_id.len() + } +} + +/// One live send a successor takes from its predecessor at the swap block (#1284 D2): the ramp, its +/// position and its mute, after the predecessor's pending value was applied. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GraphRouteMove { + /// The route's unit in the successor, the plan the move is installed in. + pub successor: u32, + /// The route's unit in the plan the successor displaces. + pub predecessor: u32, +} + +/// The strip delay lines of a bound graph plan, in line order (#1284 D1); `None` for any other +/// plan, and empty on a second call. Read once, off the render thread, right after bind: a +/// successor's preparation joins its own lines with its predecessor's (recorded when that plan was +/// prepared), and a plan retains none of the identities it handed out. +pub fn take_strip_delay_lines(plan: &mut PreparedRenderPlan) -> Option> { + let executor = plan.executor_any_mut()?.downcast_mut::()?; + Some(core::mem::take(&mut executor.bind_identities.strip_delays)) +} + +/// The live sends of a bound graph plan, in unit order (#1284 D2); `None` for any other plan, and +/// empty on a second call. Read once, off the render thread, right after bind, as +/// [`take_strip_delay_lines`]. +pub fn take_live_routes(plan: &mut PreparedRenderPlan) -> Option> { + let executor = plan.executor_any_mut()?.downcast_mut::()?; + Some(core::mem::take(&mut executor.bind_identities.live_routes)) +} + +/// Install the strip-delay section of the hand-over this graph plan runs when it displaces its +/// predecessor (#1284 D1), after its [`GraphCarryProgram`], which names that predecessor. +/// +/// Refused, leaving any earlier section in place, when the plan is not a graph plan, has no carry +/// program, a successor line is not a strip delay line of the plan, a move names no lane or a lane +/// beyond the right one, or a successor or predecessor line repeats. Predecessor lines, and the +/// lengths of the lanes, are checked against the predecessor at the swap block, where one that does +/// not resolve refuses the whole hand-over. +pub fn install_strip_delay_carry( + plan: &mut PreparedRenderPlan, + mut moves: Vec, +) -> Result<(), GraphCarryInstallError> { + let executor = plan + .executor_any_mut() + .and_then(|any| any.downcast_mut::()) + .ok_or(GraphCarryInstallError::NotAGraphPlan)?; + if executor.carry.is_none() { + return Err(GraphCarryInstallError::NoStripDelayCarryProgram); + } + let mut successors = BTreeSet::new(); + let mut predecessors = BTreeSet::new(); + for step in &moves { + if step.successor as usize >= executor.runtime.track_delays.len() + || step.lanes == 0 + || step.lanes & !(STRIP_DELAY_LEFT | STRIP_DELAY_RIGHT) != 0 + { + return Err(GraphCarryInstallError::StripDelayLineOutOfRange); + } + if !successors.insert(step.successor) || !predecessors.insert(step.predecessor) { + return Err(GraphCarryInstallError::DuplicateStripDelayLine); + } + } + moves.sort_unstable_by_key(|step| step.predecessor); + executor.carry_strip_delays = moves.into_boxed_slice(); + Ok(()) +} + +/// Install the live-send section of the hand-over this graph plan runs when it displaces its +/// predecessor (#1284 D2), after its [`GraphCarryProgram`], which names that predecessor. +/// +/// Refused, leaving any earlier section in place, when the plan is not a graph plan, has no carry +/// program, a successor unit is not a live route of the plan, or a successor or predecessor unit +/// repeats. Predecessor units are checked against the predecessor at the swap block, where one that +/// does not resolve refuses the whole hand-over. +pub fn install_route_carry( + plan: &mut PreparedRenderPlan, + mut moves: Vec, +) -> Result<(), GraphCarryInstallError> { + let executor = plan + .executor_any_mut() + .and_then(|any| any.downcast_mut::()) + .ok_or(GraphCarryInstallError::NotAGraphPlan)?; + if executor.carry.is_none() { + return Err(GraphCarryInstallError::NoRouteCarryProgram); + } + let mut successors = BTreeSet::new(); + let mut predecessors = BTreeSet::new(); + for step in &moves { + if !executor.runtime.is_live_route(step.successor) { + return Err(GraphCarryInstallError::RouteOutOfRange); + } + if !successors.insert(step.successor) || !predecessors.insert(step.predecessor) { + return Err(GraphCarryInstallError::DuplicateRoute); + } + } + moves.sort_unstable_by_key(|step| step.predecessor); + executor.carry_routes = moves.into_boxed_slice(); + Ok(()) +} + +/// The bytes a strip-delay section of `moves` lines retains in its plan (#1284): its move table. +/// `None` on overflow. The one place the charge is spelled, so preparation's admission and +/// [`carry_program_retained_bytes`] cannot disagree. +#[must_use] +pub fn strip_delay_carry_retained_bytes(moves: usize) -> Option { + u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() +} + +/// The bytes a live-send section of `moves` routes retains in its plan (#1284): its move table. +/// `None` on overflow; spelled once, as [`strip_delay_carry_retained_bytes`]. +#[must_use] +pub fn route_carry_retained_bytes(moves: usize) -> Option { + u64::try_from(moves.checked_mul(core::mem::size_of::())?).ok() +} + /// Bytes the plan's installed [`GraphCarryProgram`] retains; zero for a plan without one or for /// any other plan. pub fn carry_program_retained_bytes(plan: &mut PreparedRenderPlan) -> u64 { @@ -3406,15 +4126,41 @@ pub fn carry_program_retained_bytes(plan: &mut PreparedRenderPlan) -> u64 { &executor.carry_strips, )) .unwrap_or(u64::MAX); + let slots = executor + .carry_effects + .iter() + .try_fold(0_u64, |total, step| { + total.checked_add( + executor + .runtime + .effect_site_carry_slot_bytes(step.successor)?, + ) + }) + .unwrap_or(u64::MAX); let effects = effect_carry_retained_bytes( executor.carry_effects.len(), u64::try_from(executor.carry_scratch.len()).unwrap_or(u64::MAX), + slots, ) .unwrap_or(u64::MAX); + let observers = executor + .observer_carry + .as_deref() + .map_or(0, ObserverCarryProgram::retained_bytes); + let delays = + delay_carry_retained_bytes(executor.carry_delays.len()).unwrap_or(u64::MAX); + let strip_delays = strip_delay_carry_retained_bytes(executor.carry_strip_delays.len()) + .unwrap_or(u64::MAX); + let routes = + route_carry_retained_bytes(executor.carry_routes.len()).unwrap_or(u64::MAX); sources + .saturating_add(delays) + .saturating_add(strip_delays) + .saturating_add(routes) .saturating_add(inputs) .saturating_add(strips) .saturating_add(effects) + .saturating_add(observers) }) } @@ -3504,6 +4250,12 @@ struct GraphExecutorWithoutSplitPairTable { carry_scratch: Box<[u8]>, next_block_sample: u64, node_carry_refused: u64, + observer_carry: Option>, + carry_delays: Box<[GraphDelayMove]>, + compensation_lines: Vec, + carry_strip_delays: Box<[GraphStripDelayMove]>, + carry_routes: Box<[GraphRouteMove]>, + bind_identities: runtime::BindIdentities, live_snapshot: Option, } @@ -3591,7 +4343,7 @@ impl GraphExecutor { } else { lent_claims }; - let runtime = + let (runtime, bind_identities) = runtime::build_sequential(program, &plan.spec, parts, frames, planning, &lent_claims); // The copy loop fills exactly the claims that are not read in place. let source_input_buffers: Box<[(usize, u32)]> = source_input_buffers @@ -3606,6 +4358,7 @@ impl GraphExecutor { .collect(); #[cfg(any(test, feature = "test-support"))] test_only_record_executor_tables(&active_units, &source_input_buffers); + let compensation_lines = compensation_lines_of(&plan.spec, program); Self { runtime, sample_rate_hz, @@ -3620,6 +4373,12 @@ impl GraphExecutor { carry_scratch: Box::default(), next_block_sample: 0, node_carry_refused: 0, + observer_carry: None, + carry_delays: Box::default(), + compensation_lines, + carry_strip_delays: Box::default(), + carry_routes: Box::default(), + bind_identities, live_snapshot: None, } } @@ -3681,6 +4440,31 @@ impl PreparedPlanExecutor for GraphExecutor { ) { return CarryOutcome::PredecessorMismatch; } + // Issue #1327: likewise every observer move. + if let Some(observers) = self.observer_carry.as_deref() + && !self + .runtime + .can_carry_observers(&mut predecessor.runtime, &observers.moves) + { + return CarryOutcome::PredecessorMismatch; + } + // Issue #1283: likewise every compensation-line move. + if !self + .runtime + .can_carry_delay_lines(&predecessor.runtime, &self.carry_delays) + { + return CarryOutcome::PredecessorMismatch; + } + // Issue #1284: likewise every strip delay line and live send move. + if !self + .runtime + .can_carry_strip_delays(&predecessor.runtime, &self.carry_strip_delays) + || !self + .runtime + .can_carry_live_routes(&predecessor.runtime, &self.carry_routes) + { + return CarryOutcome::PredecessorMismatch; + } if !program.sources.is_empty() { let (Some(successor_set), Some(predecessor_set)) = (self.source_set.as_mut(), predecessor.source_set.as_mut()) @@ -3698,15 +4482,48 @@ impl PreparedPlanExecutor for GraphExecutor { .carry_input_lanes(&mut predecessor.runtime, &self.carry_inputs); self.runtime .carry_strip_lanes(&mut predecessor.runtime, &self.carry_strips); + self.runtime + .carry_delay_lines(&mut predecessor.runtime, &self.carry_delays); + self.runtime + .carry_strip_delays(&mut predecessor.runtime, &self.carry_strip_delays); + self.runtime + .carry_live_routes(&mut predecessor.runtime, &self.carry_routes); let refused = self.runtime.carry_effect_lanes( &mut predecessor.runtime, &self.carry_effects, &mut self.carry_scratch, predecessor.next_block_sample, + self.observer_carry + .as_deref_mut() + .map_or(&mut [][..], |observers| &mut observers.taps[..]), ); self.node_carry_refused = self.node_carry_refused.saturating_add(refused); + if let Some(observers) = self.observer_carry.as_deref_mut() { + // The meters' observers first, then the effect taps, which the effect section above + // already brought up to date with the predecessor's pending subscriptions (#1280 D3). + let meters = self.runtime.carry_observers( + &mut predecessor.runtime, + &observers.moves, + &observers.joins, + &mut observers.record, + ); + let taps = self.runtime.carry_taps( + &mut predecessor.runtime, + &observers.taps, + observers.record.get_mut(meters..).unwrap_or(&mut []), + ); + observers.carried = meters + taps; + } CarryOutcome::Carried } + + /// The observers [`Self::adopt_predecessor`] carried (#1327 D3): empty until this plan has + /// taken its swap block. + fn observer_carries(&self) -> &[ObserverCarry] { + self.observer_carry + .as_deref() + .map_or(&[], |observers| &observers.record[..observers.carried]) + } // REALTIME_POLICY_END fn can_prepare_source_seek(&self, source_index: usize) -> bool { @@ -3789,6 +4606,12 @@ impl PreparedPlanExecutor for GraphExecutor { carry_scratch: _, next_block_sample, node_carry_refused: _, + observer_carry: _, + carry_delays: _, + compensation_lines: _, + carry_strip_delays: _, + carry_routes: _, + bind_identities: _, live_snapshot, } = self; // One block's snapshot: taken here, so the next block cannot reuse it. @@ -8281,4 +9104,70 @@ mod tests { "an index past the set's claims is refused" ); } + + /// #1283 D1: the endpoints of a delayed route edge are the route's own, so a route whose + /// source was re-pointed has other endpoints on its destination edge too, though that edge's + /// own source (the route node) and destination did not move. Red if a route edge reports its + /// own two nodes: the carry would join the old line to the re-pointed route. + #[test] + fn a_route_edge_names_the_endpoints_of_its_route() { + let id = |value: &str| StableGraphId::parse(value).expect("graph ID"); + let strip = |value: &str| GraphNodeId::TrackStage { + track_id: id(value), + stage: TrackStage::PostMatrix, + }; + let port = |node: GraphNodeId, kind| GraphPortId { + node, + kind, + effect_port: None, + }; + let route = GraphNodeId::Route { route_id: id("r") }; + let output = GraphNodeId::Output { + output_id: id("out"), + }; + let spec_of = |from: &str| GraphSpec { + nodes: Vec::new(), + ports: Vec::new(), + edges: vec![ + GraphEdge { + id: GraphEdgeId::RouteSource { route_id: id("r") }, + source: port(strip(from), GraphPortKind::MainOutput), + destination: port(route.clone(), GraphPortKind::MainInput), + path: String::new(), + }, + GraphEdge { + id: GraphEdgeId::RouteDestination { route_id: id("r") }, + source: port(route.clone(), GraphPortKind::MainOutput), + destination: port(output.clone(), GraphPortKind::MainInput), + path: String::new(), + }, + ], + }; + let program = program::ExecutionProgram { + ops: Box::new([]), + inputs: Box::new([]), + delays: Box::new([program::DelaySpec { + samples: 480, + edge: 1, + }]), + node_buffer: Box::new([]), + node_op: Box::new([]), + taps: Box::new([]), + buffers: 0, + output: program::BufferRef(0), + }; + let before = compensation_lines_of(&spec_of("a"), &program); + let after = compensation_lines_of(&spec_of("b"), &program); + assert_eq!(before.len(), 1); + assert_eq!( + (&before[0].edge, &before[0].source, &before[0].destination), + ( + &GraphEdgeId::RouteDestination { route_id: id("r") }, + &strip("a"), + &output + ) + ); + assert_eq!(after[0].source, strip("b")); + assert_eq!(after[0].destination, output); + } } diff --git a/crates/graph/src/program.rs b/crates/graph/src/program.rs index adeb063be..88456b3fa 100644 --- a/crates/graph/src/program.rs +++ b/crates/graph/src/program.rs @@ -68,6 +68,9 @@ pub struct InputRef { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DelaySpec { pub samples: u64, + /// The position in `GraphSpec::edges` of the edge this line delays (#1283): what names the + /// line to a successor plan's carry, which joins lines by edge ID and endpoints. + pub edge: u32, } /// One executable step. @@ -686,7 +689,10 @@ fn lower_with( .ok_or(ProgramError::UnknownNode)? as usize; let buffer = logical_of_node[source].ok_or(ProgramError::NotTopological)?; let delay = edge_delay(edge_index).map(|samples| { - delay_specs.push(DelaySpec { samples }); + delay_specs.push(DelaySpec { + samples, + edge: edge_index as u32, + }); (delay_specs.len() - 1) as u32 }); inputs.push(InputRef { @@ -707,7 +713,10 @@ fn lower_with( .ok_or(ProgramError::UnknownNode)? as usize; let buffer = logical_of_node[source].ok_or(ProgramError::NotTopological)?; let delay = edge_delay(edge_index).map(|samples| { - delay_specs.push(DelaySpec { samples }); + delay_specs.push(DelaySpec { + samples, + edge: edge_index as u32, + }); (delay_specs.len() - 1) as u32 }); lifetimes[buffer as usize].last_use = ops.len(); diff --git a/crates/graph/src/runtime.rs b/crates/graph/src/runtime.rs index c50b17168..c99a42d6f 100644 --- a/crates/graph/src/runtime.rs +++ b/crates/graph/src/runtime.rs @@ -57,6 +57,32 @@ thread_local! { static TEST_ONLY_BANK_METER_DECLINED: std::cell::Cell = const { std::cell::Cell::new(false) }; static TEST_ONLY_BANK_METER_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; + static TEST_ONLY_CARRY_PAYLOAD_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Zero the count of payload bytes a plan swap's effect carry has passed through the payload +/// scratch (#1282 D2, gate 4). +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +pub fn test_only_reset_carry_payload_bytes() { + TEST_ONLY_CARRY_PAYLOAD_BYTES.with(|value| value.set(0)); +} + +/// The bytes of effect state the carry has moved through the payload scratch since the last +/// [`test_only_reset_carry_payload_bytes`], on this thread: each lane move that snapshots into the +/// scratch and restores from it adds its layout's payload. An instance moved by pointer +/// ([`LiveControlEffect::move_from`]) adds nothing. +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +#[must_use] +pub fn test_only_carry_payload_bytes() -> u64 { + TEST_ONLY_CARRY_PAYLOAD_BYTES.with(std::cell::Cell::get) +} + +#[cfg(any(test, feature = "test-support"))] +#[inline] +fn test_only_note_carry_payload(bytes: u64) { + TEST_ONLY_CARRY_PAYLOAD_BYTES.with(|value| value.set(value.get().saturating_add(bytes))); } /// Decline (`true`) or restore (`false`) the banked sample-peak pass of issue #943, and reset its @@ -896,10 +922,10 @@ impl LiveRoute { /// /// The cell holds the newest value whose revision is `<= S`, never a newer or torn one, and /// never skips it (#1312 D1). When it applies sequence `s` after `p`, the `s - p - 1` writes - /// it replaced are added to the session's `live_values_superseded` (#1312 D2). The drain runs + /// it replaced are added to the session's `live_values_superseded` (#1312 D2). The read runs /// in every block, active route or not (#1504 D3). It allocates nothing and takes no lock. #[inline] - fn drain(&mut self, snapshot: LiveSnapshot) -> usize { + fn apply_pending(&mut self, snapshot: LiveSnapshot) -> usize { let control = &mut *self.control; let mut applied = 0; if control.dirty.pending(&control.lane.dirty) & 1 != 0 @@ -946,6 +972,17 @@ impl LiveRoute { let frames = u32::try_from(left.len()).unwrap_or(u32::MAX); self.position = core::cmp::min(self.position.saturating_add(frames), self.ramp.length); } + + /// Take the ramp state of `from`, the live route of the same ID in the plan this one displaces + /// (#1284 D2): the ramp, how far into it the route is, and its mute. The caller has applied the + /// predecessor's pending value ([`Self::apply_pending`] under every snapshot) first, so the successor + /// continues exactly where the predecessor's next block would have. The lane, the route index + /// and the compensation flag stay: they are this plan's own. A bounded copy of three fields. + fn adopt(&mut self, from: &Self) { + self.ramp = from.ramp; + self.position = from.position; + self.mute = from.mute; + } } // REALTIME_POLICY_END @@ -1011,6 +1048,41 @@ impl CompensationDelay { offset += take; } } + + /// Take the pending samples of `from`, the line of the same edge in the plan this one + /// displaces (#1283 D2, D4). + /// + /// Two lines of one length exchange their rings and cursors, so the successor emits exactly + /// what the predecessor would have and no word is copied. Lines of another length take a + /// head-aligned copy ([`carry_head_aligned`]) of each channel: the successor emits the + /// predecessor's pending samples first, oldest first, cut short or followed by `+0.0`. + /// Allocation-free; `from` is left in an unspecified state, for it retires. + pub(crate) fn adopt(&mut self, from: &mut Self) { + if self.left.len() == from.left.len() { + core::mem::swap(&mut self.left, &mut from.left); + core::mem::swap(&mut self.right, &mut from.right); + core::mem::swap(&mut self.cursor, &mut from.cursor); + return; + } + carry_head_aligned(&mut self.left, &from.left, from.cursor); + carry_head_aligned(&mut self.right, &from.right, from.cursor); + self.cursor = 0; + } +} + +/// One channel of [`CompensationDelay::adopt`] between rings of different lengths (#1283 D4, D5). +/// +/// `from` is a delay ring whose oldest pending word sits at `cursor` and whose age order wraps +/// there. The first `into.len()` words of that order land at the front of `into`, and any words +/// `into` has beyond that are `+0.0`. At most two slice copies and one fill, in either direction +/// of the length change. +fn carry_head_aligned(into: &mut [f32], from: &[f32], cursor: usize) { + let take = core::cmp::min(into.len(), from.len()); + let first = core::cmp::min(take, from.len() - cursor); + into[..first].copy_from_slice(&from[cursor..cursor + first]); + let wrapped = take - first; + into[first..take].copy_from_slice(&from[..wrapped]); + into[take..].fill(0.0); } // REALTIME_POLICY_END @@ -1083,6 +1155,29 @@ impl TrackDelayLine { delay_lane(&mut self.left, &mut self.left_cursor, left); delay_lane(&mut self.right, &mut self.right_cursor, right); } + + /// Whether each lane of `lanes` (bit 0 the left, bit 1 the right) is a ring of the length + /// `from`'s is (#1284 D1): the only case a ring and its cursor can change hands. + pub(crate) const fn lanes_match(&self, from: &Self, lanes: u8) -> bool { + (lanes & crate::STRIP_DELAY_LEFT == 0 || self.left.len() == from.left.len()) + && (lanes & crate::STRIP_DELAY_RIGHT == 0 || self.right.len() == from.right.len()) + } + + /// Take the pending samples of the lanes of `lanes` from `from`, the line of the same strip in + /// the plan this one displaces (#1284 D1). Two rings of one length exchange their words and + /// their cursors, so the successor emits exactly what the predecessor would have, and no word + /// is copied. The caller has checked [`Self::lanes_match`]. Allocation-free; `from` is left in + /// an unspecified state, for it retires. + pub(crate) fn adopt(&mut self, from: &mut Self, lanes: u8) { + if lanes & crate::STRIP_DELAY_LEFT != 0 { + core::mem::swap(&mut self.left, &mut from.left); + core::mem::swap(&mut self.left_cursor, &mut from.left_cursor); + } + if lanes & crate::STRIP_DELAY_RIGHT != 0 { + core::mem::swap(&mut self.right, &mut from.right); + core::mem::swap(&mut self.right_cursor, &mut from.right_cursor); + } + } } /// One lane of a track delay: the same two-segment swap `CompensationDelay::process` runs, over one @@ -1277,6 +1372,39 @@ impl LiveControlEffect { } } + // REALTIME_POLICY_BEGIN + /// Whether [`Self::move_from`] would take `predecessor` into this instance at a plan swap + /// (#1282 D2): one effect, one state layout, prepared bypass and latency; control lanes of one + /// kind and one cell shape, this one holding its carried slots (#1633); and shunts allocated + /// for the same block and latency. Allocation-free and constant time. + fn can_move_from(&self, predecessor: &Self) -> bool { + self.layout == predecessor.layout + && rack::EffectLaneKind::of_instance(&self.control) + == rack::EffectLaneKind::of_instance(&predecessor.control) + && self.control.can_carry_from(&predecessor.control) + && self.shunt.same_shape(&predecessor.shunt) + } + + /// Take `predecessor`'s whole instance at the swap block, by pointer (#1282 D2): the two + /// processor boxes and the two shunts are exchanged, so this plan's instance continues the + /// predecessor's state exactly (a ramp in flight, a delay's line) with no payload, no copy and + /// no allocation, and the predecessor's plan keeps this plan's at-rest ones to retire with it. + /// The control lane takes the predecessor's live state through + /// [`EffectControlLane::carry_from`]. The staging window and the observation lane stay: each + /// plan sized its own, and the tap state is the observer carry's (#1327). + /// + /// The caller has checked [`Self::can_move_from`], and `predecessor` never renders again. + fn move_from(&mut self, predecessor: &mut Self) { + let carried = self.control.carry_from(&predecessor.control); + debug_assert!(carried, "a checked control lane carries"); + core::mem::swap( + &mut self.effect.processor, + &mut predecessor.effect.processor, + ); + core::mem::swap(&mut self.shunt, &mut predecessor.shunt); + } + // REALTIME_POLICY_END + /// This instance's pending-`Observe` drain for a plan swap's carry (#1281, #1280 D3): its /// queue and its tap. Pops nothing; the carry pops in a loop of its own. `None` when the /// instance holds no queue. @@ -3546,7 +3674,7 @@ fn execute_op( // (an inactive op that skipped the drain would never see its unmute), then decides this // block's activity once and writes it before its destination, a later unit, reduces. if let NodeKind::LiveRoute(live) = &mut op.kind { - let _applied = live.drain(snapshot); + let _applied = live.apply_pending(snapshot); let active = live.active(); if let Some((activity, _)) = routes.as_mut() { activity.set_active(live.route as usize, active); @@ -4442,6 +4570,48 @@ impl<'a> EffectStageMut<'a> { Self::Live(stage) => stage.observe_target_mut(lane), } } + + /// The observation tap state of a live stage's lane (#1327 D2). A plain stage has no control + /// lane, so no tap. + fn into_observation(self, lane: usize) -> Option<&'a mut ObservationLane> { + match self { + Self::Plain(_) => None, + Self::Live(stage) => stage.observation_mut(lane), + } + } +} + +/// The observation tap state of the effect instance at `site` (#1327 D2): a live stage's lane, or +/// a per-node instance. Allocation-free. +fn observation_at_mut( + units: &mut [crate::runtime::RuntimeUnit], + site: crate::GraphEffectSite, +) -> Option<&mut ObservationLane> { + match site { + crate::GraphEffectSite::Lane(at) => { + let (chain, slot, lane) = effect_lane_mut(units, at)?; + effect_stage_mut(chain, slot)?.into_observation(lane) + } + crate::GraphEffectSite::Node(unit) => { + effect_node_mut(units, unit)?.observation.as_deref_mut() + } + } +} + +/// Note that effect move `index` carried, for the tap move bound to it (#1327 D1). `taps` is +/// sorted by effect move and `cursor` only advances, so a whole carry is one pass over it. The +/// carry marks moves in ascending index order. +fn mark_effect_move_taken(taps: &mut [crate::ObserverTapMove], cursor: &mut usize, index: usize) { + while let Some(tap) = taps.get_mut(*cursor) { + if tap.effect_move < index { + *cursor += 1; + } else { + if tap.effect_move == index { + tap.taken = true; + } + return; + } + } } /// The pending-`Observe` drain of the predecessor instance at `site`: its queue, tap and unbound @@ -4513,9 +4683,90 @@ fn effect_node_mut( crate::runtime::RuntimeUnit::Bank { .. } => None, } } + +/// The live route whose op sits in runtime unit `unit` (#1284 D2). Allocation-free: a +/// bounds-checked unit borrow and a match. +fn live_route_of(units: &[RuntimeUnit], unit: u32) -> Option<&LiveRoute> { + match units.get(unit as usize)? { + RuntimeUnit::Op(op) => match &op.kind { + NodeKind::LiveRoute(live) => Some(live), + _ => None, + }, + RuntimeUnit::Bank { .. } => None, + } +} + +/// [`live_route_of`], mutably. +fn live_route_mut(units: &mut [RuntimeUnit], unit: u32) -> Option<&mut LiveRoute> { + match units.get_mut(unit as usize)? { + RuntimeUnit::Op(op) => match &mut op.kind { + NodeKind::LiveRoute(live) => Some(live), + _ => None, + }, + RuntimeUnit::Bank { .. } => None, + } +} + +/// The observer binding at `at` (#1327 D2): a bounds-checked unit, member and index lookup. +/// Allocation-free. `None` when `at` names no observer, including a member of a unit that is one +/// op and no member of a bank. +fn observer_at_mut( + units: &mut [RuntimeUnit], + at: crate::GraphObserverLocation, +) -> Option<&mut GraphNodeObserverBinding> { + let op = match units.get_mut(at.unit as usize)? { + RuntimeUnit::Op(op) if at.member == crate::GraphObserverLocation::NO_MEMBER => op, + RuntimeUnit::Op(_) => return None, + RuntimeUnit::Bank { members, .. } => members.get_mut(at.member as usize)?, + }; + op.observers.get_mut(at.index as usize) +} // REALTIME_POLICY_END impl crate::runtime::Runtime { + /// The bytes of carried slots the effect instance at `site` holds once a carry is installed + /// into it (#1633 D1): the cell tables of its control lane. Zero for a stage without control + /// lanes and for a lane without cells; `None` when `site` resolves to no instance. + pub(crate) fn effect_site_carry_slot_bytes( + &mut self, + site: crate::GraphEffectSite, + ) -> Option { + match site { + crate::GraphEffectSite::Lane(at) => { + let (chain, slot, lane) = effect_lane_mut(&mut self.units, at)?; + Some(match effect_stage_mut(chain, slot)? { + EffectStageMut::Plain(_) => 0, + EffectStageMut::Live(stage) => stage.carry_slot_bytes(lane), + }) + } + crate::GraphEffectSite::Node(unit) => Some( + effect_node_mut(&mut self.units, unit)? + .control + .carry_slot_bytes(), + ), + } + } + + /// Allocate the carried slots of the successor instance at `site` (#1633 D1). Off the render + /// thread. A site that resolves to a stage without control lanes or a lane without cells + /// takes none. + pub(crate) fn install_effect_carry_slots(&mut self, site: crate::GraphEffectSite) { + match site { + crate::GraphEffectSite::Lane(at) => { + if let Some((chain, slot, lane)) = effect_lane_mut(&mut self.units, at) + && let Some(EffectStageMut::Live(stage)) = effect_stage_mut(chain, slot) + { + stage.install_carry_slots(lane); + } + } + crate::GraphEffectSite::Node(unit) => { + if let Some(node) = effect_node_mut(&mut self.units, unit) { + node.control.install_carry_slots(); + } + } + } + } + /// Every banked strip input section of this runtime: `(strip ID, location)`, in program order /// (issue #1276 D6). Read off the render thread, after bind. pub(crate) fn builtin_input_lanes(&mut self) -> Vec<(Box, crate::GraphLaneLocation)> { @@ -4881,11 +5132,128 @@ impl crate::runtime::Runtime { } } + /// Whether every move of `moves` names a compensation line of this (successor) runtime and a + /// compensation line of `predecessor` (#1283). Checked before anything moves, so the carry is + /// all or nothing. Allocation-free. + pub(crate) fn can_carry_delay_lines( + &self, + predecessor: &Self, + moves: &[crate::GraphDelayMove], + ) -> bool { + moves.iter().all(|step| { + (step.successor as usize) < self.delays.len() + && (step.predecessor as usize) < predecessor.delays.len() + }) + } + + /// Run the compensation-line section of a carry program at the swap block (#1283 D2, D4): + /// each successor line takes the pending samples of its predecessor line + /// ([`CompensationDelay::adopt`]). Bounded by `moves.len()`; allocation-free. The caller has + /// checked [`Self::can_carry_delay_lines`]. + pub(crate) fn carry_delay_lines( + &mut self, + predecessor: &mut Self, + moves: &[crate::GraphDelayMove], + ) { + for step in moves.iter() { + let (Some(into), Some(from)) = ( + self.delays.get_mut(step.successor as usize), + predecessor.delays.get_mut(step.predecessor as usize), + ) else { + continue; + }; + into.adopt(from); + } + } + + /// Whether the unit `unit` of this runtime is a live route's op (#1284 D2). + pub(crate) fn is_live_route(&self, unit: u32) -> bool { + live_route_of(&self.units, unit).is_some() + } + + /// Whether every move of `moves` names a strip delay line of this (successor) runtime and one + /// of `predecessor` whose carried lanes have the same length (#1284 D1). Checked before + /// anything moves, so the carry is all or nothing. Allocation-free. + pub(crate) fn can_carry_strip_delays( + &self, + predecessor: &Self, + moves: &[crate::GraphStripDelayMove], + ) -> bool { + moves.iter().all(|step| { + match ( + self.track_delays.get(step.successor as usize), + predecessor.track_delays.get(step.predecessor as usize), + ) { + (Some(into), Some(from)) => into.lanes_match(from, step.lanes), + _ => false, + } + }) + } + + /// Run the strip-delay section of a carry program at the swap block (#1284 D1): each successor + /// line takes the pending samples of the lanes of its predecessor line + /// ([`TrackDelayLine::adopt`]). Bounded by `moves.len()`; allocation-free. The caller has + /// checked [`Self::can_carry_strip_delays`]. + pub(crate) fn carry_strip_delays( + &mut self, + predecessor: &mut Self, + moves: &[crate::GraphStripDelayMove], + ) { + for step in moves.iter() { + let (Some(into), Some(from)) = ( + self.track_delays.get_mut(step.successor as usize), + predecessor.track_delays.get_mut(step.predecessor as usize), + ) else { + continue; + }; + into.adopt(from, step.lanes); + } + } + + /// Whether every move of `moves` names a live route unit of this (successor) runtime and one + /// of `predecessor` (#1284 D2). Checked before anything moves, so the carry is all or nothing. + /// Allocation-free. + pub(crate) fn can_carry_live_routes( + &self, + predecessor: &Self, + moves: &[crate::GraphRouteMove], + ) -> bool { + moves.iter().all(|step| { + live_route_of(&self.units, step.successor).is_some() + && live_route_of(&predecessor.units, step.predecessor).is_some() + }) + } + + /// Run the live-send section of a carry program at the swap block (#1284 D2): the + /// predecessor's pending value is applied under every snapshot, as its next block would apply + /// it ([`LiveRoute::apply_pending`]), and then the successor takes its ramp, position and mute + /// ([`LiveRoute::adopt`]). Bounded by `moves.len()`; allocation-free and lock-free. The + /// caller has checked [`Self::can_carry_live_routes`]. + pub(crate) fn carry_live_routes( + &mut self, + predecessor: &mut Self, + moves: &[crate::GraphRouteMove], + ) { + for step in moves.iter() { + let (Some(into), Some(from)) = ( + live_route_mut(&mut self.units, step.successor), + live_route_mut(&mut predecessor.units, step.predecessor), + ) else { + continue; + }; + // Admission validated the cell's words; the count of values this applies is the + // drain's own, added to the session's `live_values_superseded` as at a block. + from.apply_pending(LiveSnapshot::ALL); + into.adopt(from); + } + } + /// Whether every move of `moves` resolves, on this (successor) runtime and on `predecessor`, /// to two effect instances a carry would join, with a scratch of `scratch_bytes` (issues #1279 /// D3, #1281): two lanes of stages [`rack::EffectBankStage::can_carry_lane_from`] or its live - /// twin would join, or a lane of a live stage and a per-node instance it - /// [`rack::LiveControlEffectBankStage::can_carry_instance`] accepts. Checked before anything + /// twin would join, a lane of a live stage and a per-node instance it + /// [`rack::LiveControlEffectBankStage::can_carry_instance`] accepts, or two per-node instances + /// [`LiveControlEffect::can_move_from`] accepts (#1282). Checked before anything /// moves, so the carry is all or nothing. Allocation-free. pub(crate) fn can_carry_effect_lanes( &mut self, @@ -4940,10 +5308,16 @@ impl crate::runtime::Runtime { matches!( effect_stage_mut(chain, slot), Some(EffectStageMut::Live(from)) - if from.can_carry_instance(predecessor_lane, &node.control, scratch_bytes) + if from.can_carry_lane_into_instance(predecessor_lane, &node.control, scratch_bytes) ) } - (Node(_), Node(_)) => false, + (Node(successor), Node(before)) => { + let Some(from) = effect_node_mut(&mut predecessor.units, before) else { + return false; + }; + effect_node_mut(&mut self.units, successor) + .is_some_and(|into| into.can_move_from(from)) + } }) } @@ -4972,11 +5346,13 @@ impl crate::runtime::Runtime { moves: &[crate::GraphEffectMove], scratch: &mut [u8], predecessor_next_sample: u64, + taps: &mut [crate::ObserverTapMove], ) -> u64 { use crate::GraphEffectSite::{Lane, Node}; let mut disengaged: Option = None; let mut node_refused = 0_u64; - for step in moves { + let mut tap_cursor = 0_usize; + for (index, step) in moves.iter().enumerate() { match (step.successor, step.predecessor) { (Lane(successor), Lane(before)) => { let Some((chain, slot, predecessor_lane)) = @@ -4999,8 +5375,11 @@ impl crate::runtime::Runtime { let Some(mut into) = effect_stage_mut(chain, slot) else { continue; }; + #[cfg(any(test, feature = "test-support"))] + test_only_note_carry_payload(into.layout().payload_bytes()); if into.carry_lane_from(lane, &from, predecessor_lane, scratch) { chain.inherit_channel_agreement(agree); + mark_effect_move_taken(taps, &mut tap_cursor, index); } } (Lane(successor), Node(before)) => { @@ -5012,7 +5391,11 @@ impl crate::runtime::Runtime { continue; }; if let Some(EffectStageMut::Live(into)) = effect_stage_mut(chain, slot) { - into.carry_lane_from_instance(lane, node.instance(), scratch); + #[cfg(any(test, feature = "test-support"))] + test_only_note_carry_payload(into.layout().payload_bytes()); + if into.carry_lane_from_instance(lane, node.instance(), scratch) { + mark_effect_move_taken(taps, &mut tap_cursor, index); + } } } (Node(successor), Lane(before)) => { @@ -5031,17 +5414,30 @@ impl crate::runtime::Runtime { let Some(node) = effect_node_mut(&mut self.units, successor) else { continue; }; - if let EffectStageMut::Live(stage) = &from - && !stage.carry_lane_into_instance( + #[cfg(any(test, feature = "test-support"))] + test_only_note_carry_payload(node.layout.payload_bytes()); + if let EffectStageMut::Live(stage) = &from { + if stage.carry_lane_into_instance( predecessor_lane, node.instance_mut(), scratch, - ) - { - node_refused = node_refused.saturating_add(1); + ) { + mark_effect_move_taken(taps, &mut tap_cursor, index); + } else { + node_refused = node_refused.saturating_add(1); + } } } - (Node(_), Node(_)) => {} + (Node(successor), Node(before)) => { + let Some(from) = effect_node_mut(&mut predecessor.units, before) else { + continue; + }; + let Some(into) = effect_node_mut(&mut self.units, successor) else { + continue; + }; + into.move_from(from); + mark_effect_move_taken(taps, &mut tap_cursor, index); + } } } // The predecessor never renders again: an `Observe` record admitted after its last block @@ -5080,7 +5476,156 @@ impl crate::runtime::Runtime { } node_refused } + + /// Whether the effect instance at `site` holds an observation lane (#1327 D1), which a tap + /// move needs of its successor. Off the render thread. + pub(crate) fn has_observation_lane(&mut self, site: crate::GraphEffectSite) -> bool { + observation_at_mut(&mut self.units, site).is_some() + } + + /// Run the effect tap section of a carry program at the swap block (#1327 D1, D2): for each + /// tap move whose effect move carried (`taken`), the successor instance's observation lane and + /// the predecessor's exchange their whole tap state, publisher included. It runs after + /// [`Self::carry_effect_lanes`], which applied the predecessor's pending `Observe` records to + /// its taps as their next block would have, so the successor continues the window the + /// predecessor would have published. Each carried move is written to `record` in order, and + /// the count written is returned. A pair of lanes with different tap menus exchanges nothing + /// and is not recorded. Bounded by `taps.len()`; allocation-free, and never panics. + pub(crate) fn carry_taps( + &mut self, + predecessor: &mut Self, + taps: &[crate::ObserverTapMove], + record: &mut [engine::realtime::ObserverCarry], + ) -> usize { + let mut carried = 0; + for tap in taps.iter().filter(|tap| tap.taken) { + let (Some(into), Some(from)) = ( + observation_at_mut(&mut self.units, tap.step.successor), + observation_at_mut(&mut predecessor.units, tap.step.predecessor), + ) else { + continue; + }; + let Some(slot) = record.get_mut(carried) else { + continue; + }; + if into.swap_state(from) { + *slot = engine::realtime::ObserverCarry { + kind: engine::realtime::ObserverKind::Observation, + successor: tap.step.successor_entry, + predecessor: tap.step.predecessor_entry, + }; + carried += 1; + } + } + carried + } + + /// Whether every move of `moves` resolves, on this (successor) runtime and on `predecessor`, + /// to an observer that answers the move's kind (#1327 D2). Checked before anything moves, so + /// the carry is all or nothing. Allocation-free. + pub(crate) fn can_carry_observers( + &mut self, + predecessor: &mut Self, + moves: &[crate::GraphObserverMove], + ) -> bool { + moves.iter().all(|step| { + observer_at_mut(&mut self.units, step.successor) + .is_some_and(|into| into.observer.carry_kind() == Some(step.kind)) + && observer_at_mut(&mut predecessor.units, step.predecessor) + .is_some_and(|from| from.observer.carry_kind() == Some(step.kind)) + }) + } + + /// Run the observer section of a carry program at the swap block (#1327 D2, D3, D6). + /// + /// For each move the two boxed observers exchange places, so the successor's binding holds + /// the predecessor's whole state, producer included, and the predecessor's binding holds the + /// fresh observer; each then takes the handle of the binding that holds it. Each carried move + /// is written to `record` in order. Then every join opens its fresh observer's first window + /// where its donor's open window is. Returns how many moves carried, which is how many + /// entries of `record` are written. The caller has checked [`Self::can_carry_observers`]. + /// Bounded by `moves.len() + joins.len()`; allocation-free, and never panics. + pub(crate) fn carry_observers( + &mut self, + predecessor: &mut Self, + moves: &[crate::GraphObserverMove], + joins: &[crate::GraphObserverJoin], + record: &mut [engine::realtime::ObserverCarry], + ) -> usize { + let mut carried = 0; + for step in moves { + let (Some(into), Some(from)) = ( + observer_at_mut(&mut self.units, step.successor), + observer_at_mut(&mut predecessor.units, step.predecessor), + ) else { + continue; + }; + let Some(slot) = record.get_mut(carried) else { + continue; + }; + core::mem::swap(&mut into.observer, &mut from.observer); + into.observer.adopt_binding_handle(into.handle); + from.observer.adopt_binding_handle(from.handle); + *slot = engine::realtime::ObserverCarry { + kind: step.kind, + successor: step.successor_entry, + predecessor: step.predecessor_entry, + }; + carried += 1; + } + for join in joins { + let Some(position) = observer_at_mut(&mut self.units, join.donor) + .and_then(|donor| donor.observer.window_position()) + else { + continue; + }; + if let Some(fresh) = observer_at_mut(&mut self.units, join.fresh) { + fresh.observer.join_window(position); + } + } + carried + } // REALTIME_POLICY_END + + /// Every observer a swap can carry: those that answer + /// [`crate::GraphRuntimeObserver::carry_kind`], in program order (#1327 D2). Read off the + /// render thread, after bind. + pub(crate) fn observer_rows(&mut self) -> Vec { + let mut rows = Vec::new(); + for (unit, runtime_unit) in self.units.iter().enumerate() { + let Ok(unit) = u32::try_from(unit) else { + continue; + }; + let mut visit = |member: u32, op: &RuntimeOp| { + for (index, binding) in op.observers.iter().enumerate() { + if let (Some(kind), Ok(index)) = + (binding.observer.carry_kind(), u32::try_from(index)) + { + rows.push(crate::GraphObserverRow { + kind, + handle: binding.handle, + location: crate::GraphObserverLocation { + unit, + member, + index, + }, + }); + } + } + }; + match runtime_unit { + RuntimeUnit::Op(op) => visit(crate::GraphObserverLocation::NO_MEMBER, op), + RuntimeUnit::Bank { members, .. } => { + for (member, op) in members.iter().enumerate() { + if let Ok(member) = u32::try_from(member) { + visit(member, op); + } + } + } + } + } + rows + } } /// A padded group's active mask **is** its membership: the planner emits `Some` members before @@ -5356,6 +5901,9 @@ pub(crate) struct RuntimeParts { track_delays: BTreeMap, /// The lines `node_kind` allocated, in the order it allocated them. track_delay_lines: Vec, + /// The `Input` node each line of [`Self::track_delay_lines`] was allocated for, in the same + /// order (#1284 D1): the identity a successor's preparation joins its lines by. + track_delay_nodes: Vec, banks: Vec>, builtin_banks: Vec>, membership: BankMembership, @@ -5476,6 +6024,7 @@ impl RuntimeParts { .map(|delay| (delay.node, [delay.left_samples, delay.right_samples])) .collect(), track_delay_lines: Vec::new(), + track_delay_nodes: Vec::new(), banks: banks.into_iter().map(Some).collect(), builtin_banks: builtin_banks.into_iter().map(Some).collect(), membership, @@ -5515,6 +6064,7 @@ impl RuntimeParts { u32::try_from(self.track_delay_lines.len()).expect("delay line index"); self.track_delay_lines .push(TrackDelayLine::new(left as usize, right as usize)); + self.track_delay_nodes.push(node.clone()); NodeKind::TrackDelay { line, channels_agree: left == right, @@ -5558,6 +6108,7 @@ impl RuntimeParts { let line = u32::try_from(self.track_delay_lines.len()).expect("delay line index"); self.track_delay_lines .push(TrackDelayLine::new(left as usize, right as usize)); + self.track_delay_nodes.push(node.clone()); NodeKind::SumDelay { line, channels_agree: left == right, @@ -6614,7 +7165,7 @@ pub(crate) fn build_sequential( frames: usize, planning: SequentialPlan, source_claims: &[GraphNodeId], -) -> Runtime { +) -> (Runtime, BindIdentities) { #[cfg(any(test, feature = "test-support"))] test_only_reset_selected_split_fader(); let mut parts = parts; @@ -6836,6 +7387,7 @@ pub(crate) fn build_sequential( bind_live_routes(&mut units, &activity, &delayed); activity }); + let live_routes = live_route_rows(&units, &route_gates); #[cfg(any(test, feature = "test-support"))] test_only_record_route_activity(route_activity.is_some(), &route_gates); let arena = DisjointArena::try_new( @@ -6859,6 +7411,7 @@ pub(crate) fn build_sequential( source_claims, &mut identity, ); + let strip_delays = strip_delay_rows(&parts.track_delay_nodes, &parts.track_delay_lines); let mut runtime = Runtime::new_with_output_unit( arena, delays, @@ -6875,7 +7428,72 @@ pub(crate) fn build_sequential( ); runtime.source_plane_of_buffer = source_plane_of_buffer; runtime.route_activity = route_activity; - runtime + ( + runtime, + BindIdentities { + strip_delays, + live_routes, + }, + ) +} + +/// What bind knows about the state a successor can take from this plan and the runtime does not +/// keep (#1284): who owns each strip delay line and each live route. Read once, off the render +/// thread, right after bind ([`crate::take_strip_delay_lines`], [`crate::take_live_routes`]); +/// the plan retains none of it. +#[derive(Default)] +pub(crate) struct BindIdentities { + pub(crate) strip_delays: Vec, + pub(crate) live_routes: Vec, +} + +/// The strip delay lines of a bind, in line order (#1284 D1): `nodes[line]` is the `Input` node the +/// line was allocated for. +fn strip_delay_rows( + nodes: &[GraphNodeId], + lines: &[TrackDelayLine], +) -> Vec { + nodes + .iter() + .zip(lines) + .enumerate() + .map(|(line, (node, delay))| crate::GraphStripDelayLine { + strip: node_track(node), + line: u32::try_from(line).expect("a delay line index fits the program's index width"), + samples: [ + u32::try_from(delay.left.len()).expect("a lane's delay fits the schema's maximum"), + u32::try_from(delay.right.len()).expect("a lane's delay fits the schema's maximum"), + ], + }) + .collect() +} + +/// The live routes of a bind, in unit order (#1284 D2): the route's session ID and its runtime +/// unit. `route_gates` are the prepared routes in canonical route-ID order, which a live route's +/// index points into. +fn live_route_rows( + units: &[RuntimeUnit], + route_gates: &[(GraphNodeId, RouteGate)], +) -> Vec { + units + .iter() + .enumerate() + .filter_map(|(unit, owner)| { + let RuntimeUnit::Op(op) = owner else { + return None; + }; + let NodeKind::LiveRoute(live) = &op.kind else { + return None; + }; + let GraphNodeId::Route { route_id } = &route_gates.get(live.route as usize)?.0 else { + return None; + }; + Some(crate::GraphLiveRoute { + route_id: Box::from(route_id.as_str()), + unit: u32::try_from(unit).expect("a unit index fits the op index width"), + }) + }) + .collect() } /// Issue #1217 D5: the executor's [`RouteActivity`], or `None` when no route gate silences. @@ -11668,6 +12286,115 @@ mod tests { } } + /// #1283 gate 3: a successor line emits the predecessor's pending samples first, oldest + /// first, then (for a longer line) `+0.0`, then its own input; a shorter line cuts the + /// pending samples off. The predecessor has taken every number of frames from zero to two + /// lines and a block, so its cursor sits at every position and has wrapped, and its blocks + /// are not a multiple of its length. Red mutation: start the copy at the write position + /// (`cursor`) of the successor, or at index 0 of the predecessor's ring instead of its + /// cursor, and the pending order is wrong at every cursor but zero. + #[test] + fn a_successor_line_continues_the_predecessors_pending_samples_head_aligned() { + const BLOCK: usize = 5; + // The sample a channel carries at time `t`: distinct, never zero, and different on the + // two channels. + let sample = |t: usize, channel: usize| (t + 1) as f32 * 0.5 + channel as f32 * 1_000.0; + for (predecessor, successor) in + [(7, 7), (7, 10), (7, 4), (7, 1), (1, 7), (16, 40), (40, 16)] + { + for taken in 0..(2 * predecessor + BLOCK + 1) { + let mut before = CompensationDelay::new(predecessor); + let mut time = 0; + while time < taken { + let take = BLOCK.min(taken - time); + let mut left: Vec = (time..time + take).map(|t| sample(t, 0)).collect(); + let mut right: Vec = (time..time + take).map(|t| sample(t, 1)).collect(); + before.process(&mut left, &mut right); + time += take; + } + let mut after = CompensationDelay::new(successor); + after.adopt(&mut before); + // `successor` frames of silence in, so the output is the line's whole content in + // age order, and then the input. + let mut left = vec![0.0f32; successor + 3]; + let mut right = vec![0.0f32; successor + 3]; + after.process(&mut left, &mut right); + for (channel, plane) in [left, right].into_iter().enumerate() { + for (index, word) in plane.iter().enumerate() { + let expected = if index >= successor || index >= predecessor { + // Past the pending samples the line holds `+0.0`, then it has + // emitted the zeros it was fed. + 0.0 + } else { + // Pending sample `index` of the predecessor: the one it took + // `predecessor - index` frames ago, or rest if it took fewer. + let age = predecessor - index; + if taken >= age { + sample(taken - age, channel) + } else { + 0.0 + } + }; + assert_eq!( + word.to_bits(), + expected.to_bits(), + "line {predecessor} to {successor}, taken {taken}, channel \ + {channel}, word {index}" + ); + } + } + } + } + } + + /// #1284 D1. A strip delay line hands over its rings and cursors lane by lane: the lanes of + /// the mask continue exactly where the predecessor stood, at independent cursors (the two + /// lanes of a line have two lengths), and a lane outside the mask stays at rest. A mask over + /// lanes of unlike length is not a match, so the swap block refuses it. + /// + /// Red mutation: swap the rings without the cursors, or both lanes whatever the mask. + #[test] + fn a_strip_delay_line_hands_over_the_lanes_of_its_mask() { + let sample = |t: usize, channel: usize| (t + 1) as f32 * 0.25 + channel as f32 * 1_000.0; + let mut before = TrackDelayLine::new(5, 9); + let mut time = 0; + for take in [3, 4, 2, 7] { + let mut left: Vec = (time..time + take).map(|t| sample(t, 0)).collect(); + let mut right: Vec = (time..time + take).map(|t| sample(t, 1)).collect(); + before.process(&mut left, &mut right); + time += take; + } + let mut twin = TrackDelayLine::new(5, 9); + let mut twin_time = 0; + for take in [3, 4, 2, 7] { + let mut left: Vec = (twin_time..twin_time + take) + .map(|t| sample(t, 0)) + .collect(); + let mut right: Vec = (twin_time..twin_time + take) + .map(|t| sample(t, 1)) + .collect(); + twin.process(&mut left, &mut right); + twin_time += take; + } + let both = crate::STRIP_DELAY_LEFT | crate::STRIP_DELAY_RIGHT; + assert!(!TrackDelayLine::new(5, 8).lanes_match(&before, both)); + assert!(TrackDelayLine::new(5, 8).lanes_match(&before, crate::STRIP_DELAY_LEFT)); + let mut after = TrackDelayLine::new(5, 9); + assert!(after.lanes_match(&before, both)); + after.adopt(&mut before, crate::STRIP_DELAY_LEFT); + let mut left: Vec = (time..time + 11).map(|t| sample(t, 0)).collect(); + let mut right: Vec = (time..time + 11).map(|t| sample(t, 1)).collect(); + let (mut twin_left, mut twin_right) = (left.clone(), right.clone()); + after.process(&mut left, &mut right); + twin.process(&mut twin_left, &mut twin_right); + assert_eq!(left, twin_left, "the left lane continues the predecessor's"); + assert!( + right[..9].iter().all(|word| *word == 0.0), + "a lane outside the mask starts at rest" + ); + assert_ne!(right, twin_right); + } + /// E10. The route is two multiplies plus one add per output word, with the gain /// folded into the coefficients at bind (D3). The oracle is /// `softfma::unfused_multiply_add_via_f64`, which computes the same multiply-add through `f64` with diff --git a/crates/host-core/src/lib.rs b/crates/host-core/src/lib.rs index c583066fc..f91ac1aaf 100644 --- a/crates/host-core/src/lib.rs +++ b/crates/host-core/src/lib.rs @@ -131,8 +131,9 @@ pub use diagnostics::{ }; pub use engine::LAUNCH_SAMPLE_RATES; pub use engine::realtime::{ - ResponseSnapshotAvailability, ResponseSnapshotCapture, ResponseSnapshotError, - ResponseSnapshotOwnerInfo, ResponseSnapshotSection, ResponseSnapshotSink, + ObserverCarry, ObserverKind, ResponseSnapshotAvailability, ResponseSnapshotCapture, + ResponseSnapshotError, ResponseSnapshotOwnerInfo, ResponseSnapshotSection, + ResponseSnapshotSink, }; #[cfg(feature = "control-provider")] pub use live_delta::{ @@ -147,9 +148,9 @@ pub use prepare::{ MAXIMUM_VCA_REACH_PAIRS, MAXIMUM_VCAS_BOUNDED, MonitorBase, PlanStateInventory, PreparedHost, SOURCE_STALL_TOLERANCE_MS, SourceInventoryRow, SuccessorBase, check_counts, compile_host_model, compile_host_session, count_effects, default_source_ring_frames, effect_producer_position, - parse_host_session, prepare_host_runtime, prepare_host_runtime_between_render_calls, - prepare_host_runtime_successor, prepare_host_runtime_with_live_controls, - prepare_host_runtime_with_live_controls_and_spectrum, + pair_carried_meters, pair_carried_observations, parse_host_session, prepare_host_runtime, + prepare_host_runtime_between_render_calls, prepare_host_runtime_successor, + prepare_host_runtime_with_live_controls, prepare_host_runtime_with_live_controls_and_spectrum, prepare_host_runtime_with_live_controls_and_spectrum_collection, prepare_host_runtime_with_live_controls_and_spectrum_collection_successor, prepare_host_runtime_with_live_controls_and_spectrum_successor, @@ -221,6 +222,7 @@ pub use prepare::{ test_only_prepare_host_runtime_on, test_only_prepare_host_runtime_successor_on, test_only_prepare_host_runtime_with_live_controls_on, test_only_prepare_host_runtime_with_live_controls_successor_on, + test_only_prepare_host_runtime_with_selected_meters_on, }; #[cfg(feature = "test-support")] pub use spectrum::{ diff --git a/crates/host-core/src/live_delta.rs b/crates/host-core/src/live_delta.rs index 03ef574aa..490c0c1d4 100644 --- a/crates/host-core/src/live_delta.rs +++ b/crates/host-core/src/live_delta.rs @@ -26,7 +26,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::control_preparation::{ INPUT_FILTER_TARGET_CAPACITY, InputFilterEdit, InputFilterPreparer, }; -use crate::route_controls::{RouteControlError, RouteControlRecord, route_record}; +use crate::route_controls::RouteControlRecord; use builtins::{ BuiltinLaneSelector, PreparedInputFilterTarget, checked_trim_gain, validate_input_filter_pair, validate_prepared_input_filter_target, @@ -39,8 +39,8 @@ use controls::{ use effect_compiler::{LiveEffectAddress, launch_native_effect_registry, resolve_initial_values}; use effect_contract::{EffectControlRecord, ParameterChannel, PreparedEffectTarget}; use session::{ - EditRampRow, EditRampTarget, EditRamps, Effect, EffectIdentity, EffectParam, LoweredRacks, - Route, RouteDestination, SessionModel, StableId, StripRef, canonical_session_json, + EditRamps, Effect, EffectIdentity, EffectParam, LoweredRacks, Route, RouteDestination, + SessionModel, StableId, StripRef, canonical_session_json, }; #[cfg(feature = "test-support")] @@ -59,9 +59,9 @@ pub fn take_fast_classification_count() -> usize { // The ramp rule and the per-owner record derivation live with preparation, which needs them on // every build (a successor's carry-then-retarget, #1277 D5); the classifier calls them. use crate::prepare::{ - EffectiveDiff, LevelRules, OutOfDomain, StripChange, StripEditRamps, effect_instance_records, - effective_fader, fader_records, lane_records, level_differs, matrix_record, route_source_strip, - same_params, strip_at, + EffectiveDiff, LevelRules, OutOfDomain, RouteEditRamps, StripChange, StripEditRamps, + effect_instance_records, effective_fader, fader_records, lane_records, level_differs, + matrix_record, route_record_between, route_source_strip, same_params, strip_at, }; use crate::prepare::{LiveRampRow, LiveRamps, MonitorBase}; @@ -1238,30 +1238,6 @@ struct Context<'m> { diff: Option<&'m dyn LevelDiff>, } -/// The per-edit ramp lengths of one route's three rows, looked up once from the transaction's -/// [`EditRamps`] (#1557 D3): the `(Route, RouteGain)`, `(Route, RouteMute)` and -/// `(Route, RouteMatrix)` entries. `None` is "the edit gave no length": the session's applies. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct RouteEditRamps { - gain: Option, - mute: Option, - matrix: Option, -} - -impl RouteEditRamps { - fn of(edit_ramps: &EditRamps, id: &StableId) -> Self { - if edit_ramps.is_empty() { - return Self::default(); - } - let target = EditRampTarget::Route(id.clone()); - Self { - gain: edit_ramps.get(&target, EditRampRow::RouteGain), - mute: edit_ramps.get(&target, EditRampRow::RouteMute), - matrix: edit_ramps.get(&target, EditRampRow::RouteMatrix), - } - } -} - /// What a lowering is of: a strip (or one of its effect instances), or a route. enum Subject<'s, 'm, 'a> { Strip(Target<'s, 'm, 'a>), @@ -1369,17 +1345,8 @@ fn lower<'a>( } /// The record that brings a send from `before`'s live values to `after`'s (#1557 D2, D3), when the -/// target the render plane holds changes. -/// -/// The target is `graph_compiler::route_coefficients` of the route's gain, matrix and mute with -/// its effective gate ([`route_record`]), exactly what preparation folds into the route's -/// constants, so a settled send is bit-identical to a prepared one. It is compared as bits, with -/// the gate: a gain or matrix edit on a muted send leaves the silenced `+0.0` target as it was and -/// gives no record, and the later unmute carries the new values. -/// -/// The ramp is the gate's when the gate changes in either lane (`EffectiveDiff::gate_length`, #1558 -/// D7), else the longest of the changed rows' lengths (`SendGain`, `SendMatrix`), each through -/// [`LiveRamps::resolve`] (an explicit `0` is a step). +/// target the render plane holds changes: [`route_record_between`] of the lowering's inputs, the +/// one function of a send (#1284 D3), which a successor's preparation asks too. /// /// # Errors /// @@ -1389,41 +1356,16 @@ fn route_records<'a>( ctx: &Context<'_>, target: &RouteTarget<'_, 'a>, ) -> Result>, LiveRebuild> { - let (before, after) = (target.before, target.after); - let matrix = |route: &Route| { - let matrix = &route.channel_matrix; - [matrix.ll, matrix.lr, matrix.rl, matrix.rr] - }; - let build = |route: &Route, gate: [bool; 2], length: u32| { - route_record(route.gain_db, matrix(route), route.mute, gate, length).map_err(|error| { - match error { - RouteControlError::Domain | RouteControlError::Length => LiveRebuild::Domain, - } - }) - }; - let prior = build(before, target.gates[0], 0)?; - let current = build(after, target.gates[1], 0)?; - let bits = |record: &RouteControlRecord| record.target().map(f32::to_bits); - let gate_changed = target.gates[0] != target.gates[1]; - if prior.mute() == current.mute() && bits(&prior) == bits(¤t) { - return Ok(None); - } - let edit = target.edit; - let length = if gate_changed { - target.gate_length - } else { - let gain = (before.gain_db.to_bits() != after.gain_db.to_bits()) - .then(|| ctx.ramps.resolve(LiveRampRow::SendGain, edit.gain)); - let matrix = (matrix(before).map(f32::to_bits) != matrix(after).map(f32::to_bits)) - .then(|| ctx.ramps.resolve(LiveRampRow::SendMatrix, edit.matrix)); - gain.into_iter() - .chain(matrix) - .max() - .unwrap_or_else(|| ctx.ramps.resolve(LiveRampRow::SendMute, edit.mute)) - }; - let record = build(after, target.gates[1], length)?; - Ok(Some(LiveRouteRecord { - route_id: after.id.as_str(), + Ok(route_record_between( + ctx.ramps, + target.before, + target.after, + target.gates, + target.gate_length, + target.edit, + )? + .map(|record| LiveRouteRecord { + route_id: target.after.id.as_str(), record, })) } diff --git a/crates/host-core/src/prepare.rs b/crates/host-core/src/prepare.rs index 5508386f4..792f5b92f 100644 --- a/crates/host-core/src/prepare.rs +++ b/crates/host-core/src/prepare.rs @@ -20,7 +20,7 @@ use builtins_compiler::{ }; use effect_compiler::{ EffectCompileCaps, EffectControlProducer, EffectControlResources, EffectObservationHandle, - LiveEffectAddress, attach_effect_live_controls, attach_effect_observation, + LiveEffectAddress, LiveEffectRack, attach_effect_live_controls, attach_effect_observation, effect_control_resources, launch_native_effect_registry, lowers_session_bypass, prepare_native_session_effects, resolve_initial_values, }; @@ -55,7 +55,10 @@ use crate::control_preparation::{ EQ_TARGET_CAPACITY, EQ_VALUE_COUNT, EqTargetEdit, EqTargetPreparer, EqTargetPreparerError, }; use crate::diagnostics::{PrepareDiagnostics, PrepareRejection, diagnostic_lines}; -use crate::route_controls::{RouteControlProducer, RouteControlResources}; +use crate::route_controls::{ + RouteControlError, RouteControlProducer, RouteControlRecord, RouteControlResources, + route_producer_position, route_record, +}; use crate::source::{ControlSourceBuilder, SourceControlSet}; use crate::spectrum::{ SpectrumCapture, SpectrumCaptureCollection, SpectrumCaptureCollectionRequest, @@ -699,6 +702,19 @@ pub struct PlanStateInventory { strip_stages: Box<[StripStageInventoryRow]>, /// Sorted by strip ID, rack, then effect ID. effect_lanes: Box<[EffectLaneInventoryRow]>, + /// One row per meter, in the order of the plan's meter readers + /// ([`HostLiveControlHandles::meters`]), so a row's `entry` is its position (#1327 D1). + meters: Box<[MeterInventoryRow]>, + /// One row per effect instance that has observation taps, in the order of the plan's + /// observation readers ([`HostLiveControlHandles::effect_observations`]), so a row's `entry` + /// is its position (#1327 D1). + observations: Box<[ObservationInventoryRow]>, + /// One row per compensation line of the plan (#1283 D1), sorted by edge ID. + delay_lines: Box<[graph::GraphDelayLine]>, + /// One row per strip input alignment line of the plan (#1284 D1), sorted by strip ID. + strip_delays: Box<[graph::GraphStripDelayLine]>, + /// One row per live send of the plan (#1284 D2), sorted by route ID. + live_routes: Box<[graph::GraphLiveRoute]>, /// The strips this plan restarts relative to the plan it was prepared to displace (#1277 D6): /// present in both, with at least one owner that does not carry. Sorted by strip ID. Empty /// for a plan prepared without a predecessor. @@ -747,6 +763,63 @@ pub(crate) struct EffectLaneInventoryRow { pub(crate) kind: graph::EffectLaneKind, } +/// One effect instance of a prepared plan whose observation taps a swap can carry, owner key `(strip +/// ID, rack, effect ID)` (#1327 D1): the instance's taps follow the instance, so the key is the +/// effect lane's. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ObservationInventoryRow { + /// The reader set's index in the plan's observation readers. + pub(crate) entry: usize, + /// Where the effect instance sits in this plan: the key a successor's effect move names it by. + /// Meaningful only to this plan's successor's carry program. + pub(crate) location: graph::GraphEffectSite, + /// How many taps (readers) the instance declares. + pub(crate) taps: usize, +} + +/// A meter's [`MeterConfig`] as plain comparable data: equal exactly when the configurations are +/// equal word for word (#1327 D1), which `MeterConfig` itself cannot say because it holds a float. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) struct MeterConfigKey { + period_frames: u32, + peak_hold_frames: u32, + peak_decay_bits: u32, + queue_capacity: usize, + reset_generation: u64, +} + +impl MeterConfigKey { + pub(crate) fn of(config: &MeterConfig) -> Self { + Self { + period_frames: config.period_frames.get(), + peak_hold_frames: config.peak_hold_frames, + peak_decay_bits: config.peak_decay_db_per_second.to_bits(), + queue_capacity: config.queue_capacity.get(), + reset_generation: config.reset_generation, + } + } +} + +/// One meter of a prepared plan, owner key `(strip ID, tap, metric set)` with its configuration, +/// as its successor's preparation sees it (#1327 D1). Two meters of one key (a caller may select +/// the same tap twice) are told apart by their order, `entry`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MeterInventoryRow { + /// The strip ID: a track's or a submix's. + pub(crate) id: Box, + /// The chain boundary the meter observes. + pub(crate) tap: MeterTap, + /// The numeric groups the meter keeps. + pub(crate) metrics: MeterMetricSet, + /// The meter's configuration. + pub(crate) config: MeterConfigKey, + /// The meter's index in the plan's meter readers. + pub(crate) entry: usize, + /// Where the meter's observer sits in this plan. Meaningful only to this plan's successor's + /// carry program, which names it as the observer to take. + pub(crate) location: graph::GraphObserverLocation, +} + /// One banked strip input section of a prepared plan, owner key `(strip ID, /// PostInputBuiltins)`, as its successor's preparation sees it (issue #1276 D1). #[derive(Clone, Debug, Eq, PartialEq)] @@ -795,6 +868,36 @@ impl PlanStateInventory { input_section_row(&self.input_sections, id) } + /// Meter rows, one per meter reader (#1327). + #[must_use] + pub fn meter_count(&self) -> usize { + self.meters.len() + } + + /// Observation rows, one per effect observation reader set (#1327). + #[must_use] + pub fn observation_count(&self) -> usize { + self.observations.len() + } + + /// Compensation line rows, one per delayed edge of the plan (#1283). + #[must_use] + pub fn delay_line_count(&self) -> usize { + self.delay_lines.len() + } + + /// Strip input alignment line rows, one per strip that declares a `delay_samples` (#1284). + #[must_use] + pub fn strip_delay_count(&self) -> usize { + self.strip_delays.len() + } + + /// Live send rows, one per route into a submix with a live lane (#1284). + #[must_use] + pub fn live_route_count(&self) -> usize { + self.live_routes.len() + } + /// Strip stage rows, one per banked fader or matrix lane (issue #1277). #[must_use] pub fn strip_stage_count(&self) -> usize { @@ -847,12 +950,33 @@ impl PlanStateInventory { .iter() .map(|row| row.id.len() + row.effect_id.len()) .sum::() + + self.meters.iter().map(|row| row.id.len()).sum::() + + self + .delay_lines + .iter() + .map(graph::GraphDelayLine::heap_bytes) + .sum::() + + self + .strip_delays + .iter() + .map(graph::GraphStripDelayLine::heap_bytes) + .sum::() + + self + .live_routes + .iter() + .map(graph::GraphLiveRoute::heap_bytes) + .sum::() + self.restarted.iter().map(|id| id.len()).sum::(); u64::try_from( core::mem::size_of_val::<[SourceInventoryRow]>(&self.sources) + core::mem::size_of_val::<[InputSectionInventoryRow]>(&self.input_sections) + core::mem::size_of_val::<[StripStageInventoryRow]>(&self.strip_stages) + core::mem::size_of_val::<[EffectLaneInventoryRow]>(&self.effect_lanes) + + core::mem::size_of_val::<[MeterInventoryRow]>(&self.meters) + + core::mem::size_of_val::<[ObservationInventoryRow]>(&self.observations) + + core::mem::size_of_val::<[graph::GraphDelayLine]>(&self.delay_lines) + + core::mem::size_of_val::<[graph::GraphStripDelayLine]>(&self.strip_delays) + + core::mem::size_of_val::<[graph::GraphLiveRoute]>(&self.live_routes) + core::mem::size_of_val::<[Box]>(&self.restarted) + ids, ) @@ -1364,6 +1488,33 @@ pub fn test_only_prepare_host_runtime_with_live_controls_successor_on( ) } +/// Test-support width seam (#1327): [`prepare_host_runtime_with_selected_meters`], or its +/// successor form with `base`, on an explicit bank width. +#[cfg(feature = "test-support")] +#[doc(hidden)] +pub fn test_only_prepare_host_runtime_with_selected_meters_on( + compiled: &CompiledSession, + caps: &HostPrepareCaps, + live_controls: &HostLiveControlRequest, + meters: &[HostMeterRequest], + base: Option>, + backend: Backend, +) -> Result<(PreparedHost, HostLiveControlHandles), PrepareDiagnostics> { + let (prepared, handles, _) = prepare_host_runtime_with_live_controls_policy_and_spectrum( + compiled, + caps, + Some(live_controls), + Some(meters), + false, + backend, + None, + base, + HostLiveLanes::ALL, + None, + )?; + Ok((prepared, handles)) +} + /// Test-support width seam: [`prepare_host_runtime`] on an explicit bank width, with no live /// controls. #[cfg(feature = "test-support")] @@ -1824,15 +1975,33 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( // predecessor's base and this model: the join's model half, resolved against the plans' rows // once this plan is bound. let live_strips = attach_live; - let strip_joins: Vec> = match successor { - None => Vec::new(), - Some(base) => join_strips( + // The effective values of both models, computed once: the strip join and the send join read + // the same rules (#1558 D1). + let effective = successor.map(|base| { + EffectiveDiff::new( base.committed, model, - live_strips, + LiveRamps::for_session(model), base.edit_ramps, base.monitor, - ), + ) + }); + let strip_joins: Vec> = match (successor, &effective) { + (Some(base), Some(diff)) => { + join_strips(base.committed, model, live_strips, base.edit_ramps, diff) + } + _ => Vec::new(), + }; + // Issue #1284 D1, D2: how each strip's input alignment delay and each send differ between the + // predecessor's base and this model, resolved against the plans' lines and live routes once + // this plan is bound. + let delay_joins: Vec> = match successor { + None => Vec::new(), + Some(base) => join_strip_delays(base.committed, model), + }; + let route_joins: Vec> = match (successor, &effective) { + (Some(base), Some(diff)) => join_routes(base.committed, model, base.edit_ramps, diff), + _ => Vec::new(), }; // Issue #1279 D1: how each strip's console slots and effect chain differ between the // predecessor's base and this model, resolved against the plans' lanes once this plan is @@ -1843,10 +2012,21 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( }; // The hand-over the successor runs at its swap block, built here so the plan charge below // counts it; installed after bind. + // Issue #1327 D1: a meter carries when its key and configuration are the predecessor's; the + // joins that follow bind find the observers, this only decides that a program is needed. + let meters_may_carry = match (successor, requested_meter_config(live_controls)) { + (Some(base), Some(config)) => meters_may_carry( + &base.inventory.meters, + &requested_meter_owners(model, live_controls, selected_meters), + MeterConfigKey::of(&config), + ), + _ => false, + }; let carry_program = match successor { Some(base) if !carried_sources.is_empty() || !carried_inputs.is_empty() + || meters_may_carry || strip_joins.iter().any(StripJoin::may_carry) || chain_joins.iter().any(ChainJoin::may_carry) => { @@ -2056,7 +2236,6 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( }), "every effect producer is found by its sorted position" ); - let live_control_tracks = &live_control_strips[..live_control_track_count]; let control_requests: Vec = if attach_live { live_control_strips .iter() @@ -2075,19 +2254,10 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( return Err(shape("host.meter.period")); } // A selected meter may name any strip (issue #1208 D1); the default set stays one per track. - let meter_tracks: Vec<(&str, MeterTap, MeterMetricSet)> = match selected_meters { - Some(meters) => meters - .iter() - .map(|meter| (meter.strip_id.as_ref(), meter.tap, meter.metrics)) - .collect(), - None => live_control_tracks - .iter() - .map(|track| (track.as_ref(), live_controls.meter_tap, MeterMetricSet::ALL)) - .collect(), - }; - let meter_requests: Vec = match live_controls.meter_period_frames { + let meter_tracks = requested_meter_owners(model, live_controls, selected_meters); + let meter_requests: Vec = match requested_meter_config(live_controls) { None => Vec::new(), - Some(period) => meter_tracks + Some(config) => meter_tracks .iter() .enumerate() .map(|(index, (track, tap, metrics))| { @@ -2101,15 +2271,9 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( Ok(SelectedMeterRequest { request: MeterRequest { handle: MeterHandle(handle), - track_id: track.to_string(), + track_id: (*track).to_string(), tap: *tap, - config: MeterConfig { - period_frames: period, - peak_hold_frames: 0, - peak_decay_db_per_second: 0.0, - queue_capacity: live_controls.meter_queue_depth, - reset_generation: 0, - }, + config, }, metrics: *metrics, }) @@ -2234,6 +2398,11 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( Vec::new() }; let route_control_resources = graph::route_control_resources(&graph_route_controls); + // The producers in canonical route-ID order; a carried send's retarget is written through them. + let mut route_controls: Vec = graph_route_controls + .into_iter() + .map(RouteControlProducer::new) + .collect(); let output_latency = artifact.report().output_latency; let output_tail = artifact.report().output_tail; let mut graph_resources = artifact.graph_resource_estimate().clone(); @@ -2450,6 +2619,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( .ok_or_else(|| resource("host.resource.arithmetic"))?; let mut plan = bound.plan; // Issue #1272 D4: the join's program goes in before the plan leaves preparation. + let has_carry_program = carry_program.is_some(); if let Some(program) = carry_program { graph::install_carry_program(&mut plan, program) .map_err(|_| graph_failure("host.carry.install"))?; @@ -2482,6 +2652,46 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( graph::install_builtin_input_carry(&mut plan, moves) .map_err(|_| graph_failure("host.carry.install"))?; } + // Issue #1327 D1: this plan's own meters, in reader order: the inventory its successor joins + // with, and the rows the join below looks this plan's observers up in. + let meter_rows: Vec = { + let locations: BTreeMap = + graph::observer_rows(&mut plan) + .ok_or_else(|| graph_failure("host.inventory.identity"))? + .into_iter() + .filter(|row| row.kind == engine::realtime::ObserverKind::Meter) + .map(|row| (row.handle, row.location)) + .collect(); + let requested: BTreeMap = meter_requests + .iter() + .map(|item| (item.request.handle.0.get(), item)) + .collect(); + meters + .iter() + .enumerate() + .filter_map(|(entry, consumer)| { + let handle = consumer.handle.0.get(); + let item = requested.get(&handle)?; + Some(MeterInventoryRow { + id: consumer.track_id.clone(), + tap: consumer.tap, + metrics: item.metrics, + config: MeterConfigKey::of(&item.request.config), + entry, + location: *locations.get(&handle)?, + }) + }) + .collect() + }; + debug_assert_eq!( + meter_rows.len(), + meters.len(), + "every meter has an observer" + ); + let meter_carry = match successor { + Some(base) => resolve_meter_carry(&base.inventory.meters, &meter_rows), + None => MeterCarry::default(), + }; // Issue #1277 D1: this plan's own fader and matrix lanes, sorted by strip ID and owner: the // inventory its successor joins with, and the table the join below looks this plan's lanes up // in. @@ -2556,11 +2766,87 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( ), None => EffectCarry::default(), }; + // Issue #1327 D1: this plan's own effect observation readers, in reader order: the inventory + // its successor joins with. A tap follows its effect instance, so a row is keyed by the + // instance's lane, and its successor's carry follows the effect moves resolved above. + let observation_rows: Vec = effect_observations + .iter() + .enumerate() + .filter_map(|(entry, handle)| { + let racks: &[graph::RackId] = match handle.address.rack { + LiveEffectRack::Console => &[graph::RackId::Simd1, graph::RackId::Simd2], + LiveEffectRack::Inserts => &[graph::RackId::Dynamic], + }; + let lane = racks.iter().find_map(|&rack| { + effect_lane_row(&effect_lanes, &handle.track_id, rack, &handle.effect_id) + })?; + Some(ObservationInventoryRow { + entry, + location: lane.location, + taps: handle.readers.len(), + }) + }) + .collect(); + let observation_carry = match successor { + Some(base) => resolve_observation_carry( + &base.inventory.observations, + &observation_rows, + &effect_carry.moves, + ), + None => Vec::new(), + }; + let meter_carry_bytes = graph::observer_carry_retained_bytes( + meter_carry.moves.len(), + meter_carry.joins.len(), + observation_carry.len(), + ) + .ok_or_else(|| resource("host.resource.arithmetic"))?; + // Issue #1283 D1: this plan's own compensation lines, sorted by edge ID: the inventory its + // successor joins with, and the lines its own moves are resolved from. + let mut delay_lines = graph::take_compensation_lines(&mut plan) + .ok_or_else(|| graph_failure("host.inventory.identity"))?; + delay_lines.sort_unstable_by(|left, right| left.edge.cmp(&right.edge)); + let delay_carry = match successor { + Some(base) => resolve_delay_carry(&base.inventory.delay_lines, &delay_lines), + None => Vec::new(), + }; + let delay_carry_bytes = graph::delay_carry_retained_bytes(delay_carry.len()) + .ok_or_else(|| resource("host.resource.arithmetic"))?; + // Issue #1284 D1, D2: this plan's own strip input alignment lines and live sends, sorted by ID: + // the inventory its successor joins with, and the rows its own moves are resolved from. + let mut strip_delays = graph::take_strip_delay_lines(&mut plan) + .ok_or_else(|| graph_failure("host.inventory.identity"))?; + strip_delays.sort_unstable_by(|left, right| left.strip.cmp(&right.strip)); + let mut live_routes = graph::take_live_routes(&mut plan) + .ok_or_else(|| graph_failure("host.inventory.identity"))?; + live_routes.sort_unstable_by(|left, right| left.route_id.cmp(&right.route_id)); + let strip_delay_carry = match successor { + Some(base) => { + resolve_strip_delay_carry(&base.inventory.strip_delays, &strip_delays, &delay_joins) + } + None => StripDelayCarry::default(), + }; + let route_carry = match successor { + Some(base) => resolve_route_carry(&base.inventory.live_routes, &live_routes, &route_joins), + None => RouteCarry::default(), + }; + let strip_delay_carry_bytes = + graph::strip_delay_carry_retained_bytes(strip_delay_carry.moves.len()) + .ok_or_else(|| resource("host.resource.arithmetic"))?; + let route_carry_bytes = graph::route_carry_retained_bytes(route_carry.moves.len()) + .ok_or_else(|| resource("host.resource.arithmetic"))?; // The effect moves' table and payload scratch are charged at their exact size, against the // same cap as the rest of the plan, and the scratch is one allocation (#1279 D3). - let effect_carry_bytes = - graph::effect_carry_retained_bytes(effect_carry.moves.len(), effect_carry.scratch_bytes) - .ok_or_else(|| resource("host.resource.arithmetic"))?; + // The carried slots of the successor lanes the moves name are allocated with the table, and + // only for those lanes (#1633 D1). + let effect_carry_slot_bytes = graph::effect_carry_slot_bytes(&mut plan, &effect_carry.moves) + .ok_or_else(|| resource("host.resource.arithmetic"))?; + let effect_carry_bytes = graph::effect_carry_retained_bytes( + effect_carry.moves.len(), + effect_carry.scratch_bytes, + effect_carry_slot_bytes, + ) + .ok_or_else(|| resource("host.resource.arithmetic"))?; let effect_carry_scratch_bytes = effect_carry.scratch_bytes; if effect_carry_scratch_bytes > caps.maximum_named_allocation_bytes { return Err(resource("host.resource.limit")); @@ -2568,6 +2854,10 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( if admitted_graph_and_model .checked_add(strip_carry_bytes) .and_then(|bytes| bytes.checked_add(effect_carry_bytes)) + .and_then(|bytes| bytes.checked_add(meter_carry_bytes)) + .and_then(|bytes| bytes.checked_add(delay_carry_bytes)) + .and_then(|bytes| bytes.checked_add(strip_delay_carry_bytes)) + .and_then(|bytes| bytes.checked_add(route_carry_bytes)) .ok_or_else(|| resource("host.resource.arithmetic"))? > caps.maximum_graph_session_plus_plan_bytes { @@ -2581,14 +2871,60 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( graph::install_effect_carry(&mut plan, effect_carry.moves, effect_carry_scratch_bytes) .map_err(|_| graph_failure("host.carry.install"))?; } + if !meter_carry.moves.is_empty() || !meter_carry.joins.is_empty() { + graph::install_observer_carry(&mut plan, meter_carry.moves, meter_carry.joins) + .map_err(|_| graph_failure("host.carry.install"))?; + } + if !observation_carry.is_empty() { + graph::install_observation_carry(&mut plan, observation_carry) + .map_err(|_| graph_failure("host.carry.install"))?; + } + if let Some(base) = successor + && !(delay_carry.is_empty() + && strip_delay_carry.moves.is_empty() + && route_carry.moves.is_empty()) + { + // A line or a send carries between two plans whose strips all restart or differ in a + // prepared value, so the join above may have found nothing else to carry: the program then + // names the predecessor and moves no source (it retains no byte). + if !has_carry_program { + graph::install_carry_program( + &mut plan, + graph::GraphCarryProgram { + predecessor: base.inventory.plan_identity(), + sources: Box::default(), + }, + ) + .map_err(|_| graph_failure("host.carry.install"))?; + } + if !delay_carry.is_empty() { + graph::install_delay_carry(&mut plan, delay_carry) + .map_err(|_| graph_failure("host.carry.install"))?; + } + if !strip_delay_carry.moves.is_empty() { + graph::install_strip_delay_carry(&mut plan, strip_delay_carry.moves) + .map_err(|_| graph_failure("host.carry.install"))?; + } + if !route_carry.moves.is_empty() { + graph::install_route_carry(&mut plan, route_carry.moves) + .map_err(|_| graph_failure("host.carry.install"))?; + } + } let carry_program_retained_bytes = graph::carry_program_retained_bytes(&mut plan); debug_assert_eq!( carry_program_retained_bytes, - carry_program_bytes + strip_carry_bytes + effect_carry_bytes + carry_program_bytes + + strip_carry_bytes + + effect_carry_bytes + + meter_carry_bytes + + delay_carry_bytes + + strip_delay_carry_bytes + + route_carry_bytes ); inventory_sources.sort_unstable_by(|left, right| left.id.cmp(&right.id)); let mut restarted = strip_carry.restarted; restarted.extend(effect_carry.restarted); + restarted.extend(strip_delay_carry.restarted); restarted.sort_unstable(); restarted.dedup(); let inventory = PlanStateInventory { @@ -2598,6 +2934,11 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( input_sections: input_sections.into_boxed_slice(), strip_stages: strip_stages.into_boxed_slice(), effect_lanes: effect_lanes.into_boxed_slice(), + meters: meter_rows.into_boxed_slice(), + observations: observation_rows.into_boxed_slice(), + delay_lines: delay_lines.into_boxed_slice(), + strip_delays: strip_delays.into_boxed_slice(), + live_routes: live_routes.into_boxed_slice(), restarted: restarted.into_boxed_slice(), }; let report = HostPrepareReport { @@ -2670,7 +3011,9 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( // publishes it. A preparation that writes no retarget does not stamp. if let (Some(base), false) = ( successor, - strip_carry.retargets.is_empty() && effect_carry.retargets.is_empty(), + strip_carry.retargets.is_empty() + && effect_carry.retargets.is_empty() + && route_carry.retargets.is_empty(), ) { let Some(Ok(stamp)) = plan.stamp_held_gate(base.revision) else { return Err(successor_revision_refusal()); @@ -2693,6 +3036,15 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( producer.commit_owner().map_err(|_| fault())?; } } + // Issue #1284 D3: the carried sends' live differences, through the successor's own + // producers, in the same block as the strips', with the ramp the record carries. A write + // to a latest-target cell cannot fail (#1347), and every send of the table has a producer. + for &(route_id, record) in &route_carry.retargets { + let Some(position) = route_producer_position(&route_controls, route_id) else { + return Err(graph_failure("host.control.order")); + }; + route_controls[position].write(stamp, record); + } for retarget in &strip_carry.retargets { let Some(&index) = canonical_index.get(retarget.id) else { return Err(graph_failure("host.control.order")); @@ -2723,10 +3075,7 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( effect_controls, meters, effect_observations, - route_controls: graph_route_controls - .into_iter() - .map(RouteControlProducer::new) - .collect(), + route_controls, master_track, live_values_superseded: live_superseded, }, @@ -2734,6 +3083,232 @@ fn prepare_host_runtime_with_live_controls_policy_and_spectrum( )) } +/// The meters a preparation builds, as `(strip ID, tap, metric set)` in reader order (#1327): the +/// caller's selection, else one all-metric meter per track at the request's tap. +fn requested_meter_owners<'a>( + model: &'a SessionModel, + live_controls: &HostLiveControlRequest, + selected_meters: Option<&'a [HostMeterRequest]>, +) -> Vec<(&'a str, MeterTap, MeterMetricSet)> { + match selected_meters { + Some(meters) => meters + .iter() + .map(|meter| (meter.strip_id.as_ref(), meter.tap, meter.metrics)) + .collect(), + None => model + .tracks + .iter() + .map(|track| { + ( + track.id.as_str(), + live_controls.meter_tap, + MeterMetricSet::ALL, + ) + }) + .collect(), + } +} + +/// The configuration every meter of a preparation shares, or `None` when it builds no meter. +fn requested_meter_config(live_controls: &HostLiveControlRequest) -> Option { + live_controls + .meter_period_frames + .map(|period_frames| MeterConfig { + period_frames, + peak_hold_frames: 0, + peak_decay_db_per_second: 0.0, + queue_capacity: live_controls.meter_queue_depth, + reset_generation: 0, + }) +} + +/// Whether any of `owners` has a meter in `predecessor` with its key and `config` (#1327 D1). +fn meters_may_carry( + predecessor: &[MeterInventoryRow], + owners: &[(&str, MeterTap, MeterMetricSet)], + config: MeterConfigKey, +) -> bool { + let keys: BTreeSet<(&str, MeterTap, MeterMetricSet)> = predecessor + .iter() + .filter(|row| row.config == config) + .map(|row| (row.id.as_ref(), row.tap, row.metrics)) + .collect(); + owners.iter().any(|owner| keys.contains(owner)) +} + +/// What a successor's preparation takes from the meters of its predecessor (#1327). +#[derive(Default)] +struct MeterCarry { + moves: Vec, + joins: Vec, +} + +/// Join the successor's meter rows `own` with the predecessor's (#1327 D1, D6). +/// +/// A meter carries when its strip ID, tap, metric set and configuration equal a predecessor +/// meter's; two meters of one key pair in reader order. Every other meter is fresh, and a fresh +/// meter whose configuration equals a carried one's joins the window grid of the first such +/// carried meter, the one with the lowest successor entry (D6). +fn resolve_meter_carry(predecessor: &[MeterInventoryRow], own: &[MeterInventoryRow]) -> MeterCarry { + let mut available: BTreeMap<(&str, MeterTap, MeterMetricSet), Vec<&MeterInventoryRow>> = + BTreeMap::new(); + for row in predecessor { + available + .entry((row.id.as_ref(), row.tap, row.metrics)) + .or_default() + .push(row); + } + // Each key's candidates are taken in reader order: reverse once so `pop` yields the lowest. + for rows in available.values_mut() { + rows.sort_unstable_by_key(|row| core::cmp::Reverse(row.entry)); + } + let mut carry = MeterCarry::default(); + let mut donors: BTreeMap = BTreeMap::new(); + let mut fresh: Vec<&MeterInventoryRow> = Vec::new(); + let mut ordered: Vec<&MeterInventoryRow> = own.iter().collect(); + ordered.sort_unstable_by_key(|row| row.entry); + for row in ordered { + let taken = available + .get_mut(&(row.id.as_ref(), row.tap, row.metrics)) + .and_then(Vec::pop); + match taken { + Some(before) if before.config == row.config => { + carry.moves.push(graph::GraphObserverMove { + kind: engine::realtime::ObserverKind::Meter, + successor: row.location, + predecessor: before.location, + successor_entry: row.entry, + predecessor_entry: before.entry, + }); + donors.entry(row.config).or_insert(row.location); + } + _ => fresh.push(row), + } + } + for row in fresh { + if let Some(&donor) = donors.get(&row.config) { + carry.joins.push(graph::GraphObserverJoin { + fresh: row.location, + donor, + }); + } + } + carry +} + +/// Join the successor's effect observation rows `own` with the predecessor's (#1327 D1): a tap +/// set carries when its effect instance carried (`moves` names the successor instance's site) and +/// both instances declare as many taps, with a row on each side. Every other instance's taps start +/// fresh. +fn resolve_observation_carry( + predecessor: &[ObservationInventoryRow], + own: &[ObservationInventoryRow], + moves: &[graph::GraphEffectMove], +) -> Vec { + let from: BTreeMap = moves + .iter() + .map(|step| (step.successor, step.predecessor)) + .collect(); + let before: BTreeMap = + predecessor.iter().map(|row| (row.location, row)).collect(); + own.iter() + .filter_map(|row| { + let site = *from.get(&row.location)?; + let earlier = before.get(&site)?; + (earlier.taps == row.taps).then_some(graph::GraphTapMove { + successor: row.location, + predecessor: site, + successor_entry: row.entry, + predecessor_entry: earlier.entry, + }) + }) + .collect() +} + +/// The compensation-line moves of a successor (#1283 D1): a line carries when the predecessor +/// has a line on an edge of the same ID whose endpoints are the same. Both slices are sorted by +/// edge ID. A line has no live value, so there is no retarget; a re-pointed route has other +/// endpoints, so its line starts at rest and nothing about its strip changes. Lines of other +/// lengths carry too: the swap block aligns their heads (D4). +fn resolve_delay_carry( + predecessor: &[graph::GraphDelayLine], + own: &[graph::GraphDelayLine], +) -> Vec { + own.iter() + .filter_map(|line| { + let index = predecessor + .binary_search_by(|earlier| earlier.edge.cmp(&line.edge)) + .ok()?; + let earlier = &predecessor[index]; + (earlier.source == line.source && earlier.destination == line.destination).then_some( + graph::GraphDelayMove { + successor: line.line, + predecessor: earlier.line, + }, + ) + }) + .collect() +} + +// REALTIME_POLICY_BEGIN: #1327 D3, the pairing runs in the plan-swap block. +/// Swap each carried effect observation reader set between the plans' reader vectors, at the swap +/// block (#1327 D3): for each record of kind [`engine::realtime::ObserverKind::Observation`], the +/// successor handle at `successor` and the predecessor handle at `predecessor` exchange their +/// readers, so a reader the host already holds follows the tap state that renders on, and the +/// retiring predecessor holds the successor's fresh one. Each handle keeps its own track, address +/// and effect ID: they are equal for a carried pair. +/// +/// Allocation-free, drops nothing, and may run on the render thread. A record whose index is out of +/// range of its vector is skipped. +pub fn pair_carried_observations( + successor: &mut [EffectObservationHandle], + predecessor: &mut [EffectObservationHandle], + carries: &[engine::realtime::ObserverCarry], +) { + for carry in carries { + if carry.kind != engine::realtime::ObserverKind::Observation { + continue; + } + if let (Some(into), Some(from)) = ( + successor.get_mut(carry.successor), + predecessor.get_mut(carry.predecessor), + ) { + core::mem::swap(&mut into.readers, &mut from.readers); + } + } +} +// REALTIME_POLICY_END + +// REALTIME_POLICY_BEGIN: #1327 D3, the pairing runs in the plan-swap block. +/// Swap each carried meter's reader between the plans' reader vectors, at the swap block +/// (#1327 D3): for each record of kind [`engine::realtime::ObserverKind::Meter`], the successor +/// reader at `successor` and the predecessor reader at `predecessor` exchange their consumers, so +/// a reader the host already holds follows the observer that renders on, and the retiring +/// predecessor holds the successor's fresh one. Each reader keeps its own handle, track and tap: +/// the carried observer takes the successor's handle at the swap, so its snapshots name the +/// reader that now receives them. +/// +/// Allocation-free, drops nothing, and may run on the render thread. A record whose index is out +/// of range of its vector is skipped. +pub fn pair_carried_meters( + successor: &mut [MeterConsumer], + predecessor: &mut [MeterConsumer], + carries: &[engine::realtime::ObserverCarry], +) { + for carry in carries { + if carry.kind != engine::realtime::ObserverKind::Meter { + continue; + } + if let (Some(into), Some(from)) = ( + successor.get_mut(carry.successor), + predecessor.get_mut(carry.predecessor), + ) { + core::mem::swap(&mut into.consumer, &mut from.consumer); + } + } +} +// REALTIME_POLICY_END + /// Strip `id`'s row of `rows`, which are sorted by strip ID. fn input_section_row<'a>( rows: &'a [InputSectionInventoryRow], @@ -2822,6 +3397,91 @@ impl StripEditRamps { } } +/// The per-edit ramp lengths of one route's three rows, looked up once from the transaction's +/// [`EditRamps`] (#1557 D3): the `(Route, RouteGain)`, `(Route, RouteMute)` and +/// `(Route, RouteMatrix)` entries. `None` is "the edit gave no length": the session's applies. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct RouteEditRamps { + pub(crate) gain: Option, + pub(crate) mute: Option, + pub(crate) matrix: Option, +} + +impl RouteEditRamps { + pub(crate) fn of(edit_ramps: &EditRamps, id: &session::StableId) -> Self { + if edit_ramps.is_empty() { + return Self::default(); + } + let target = EditRampTarget::Route(id.clone()); + Self { + gain: edit_ramps.get(&target, EditRampRow::RouteGain), + mute: edit_ramps.get(&target, EditRampRow::RouteMute), + matrix: edit_ramps.get(&target, EditRampRow::RouteMatrix), + } + } +} + +/// The record that brings a send from `before`'s live values to `after`'s (#1557 D2, D3), when the +/// target the render plane holds changes: the per-route function of a send (#1284 D3). The +/// classifier derives a live delta's route records with it, and a successor's preparation derives +/// its carried sends' retargets with it, so the two cannot disagree. +/// +/// The target is `graph_compiler::route_coefficients` of the route's gain, matrix and mute with +/// its effective gate ([`route_record`]), exactly what preparation folds into the route's +/// constants, so a settled send is bit-identical to a prepared one. It is compared as bits, with +/// the gate: a gain or matrix edit on a muted send leaves the silenced `+0.0` target as it was and +/// gives no record, and the later unmute carries the new values. +/// +/// `gates` is the route's effective gate per source lane, in `[before, after]` +/// (`controls::effective`). The ramp is `gate_length` when the gate changes in either lane +/// (`EffectiveDiff::gate_length`, #1558 D7), else the longest of the changed rows' lengths +/// (`SendGain`, `SendMatrix`), each through [`LiveRamps::resolve`] (an explicit `0` is a step). +/// +/// # Errors +/// +/// [`LiveRebuild::Domain`] when either value set is outside the route domain, or a ramp is longer +/// than the cell's maximum. +pub(crate) fn route_record_between( + ramps: LiveRamps, + before: &session::Route, + after: &session::Route, + gates: [[bool; 2]; 2], + gate_length: u32, + edit: RouteEditRamps, +) -> Result, LiveRebuild> { + let matrix = |route: &session::Route| { + let matrix = &route.channel_matrix; + [matrix.ll, matrix.lr, matrix.rl, matrix.rr] + }; + let build = |route: &session::Route, gate: [bool; 2], length: u32| { + route_record(route.gain_db, matrix(route), route.mute, gate, length).map_err(|error| { + match error { + RouteControlError::Domain | RouteControlError::Length => LiveRebuild::Domain, + } + }) + }; + let prior = build(before, gates[0], 0)?; + let current = build(after, gates[1], 0)?; + let bits = |record: &RouteControlRecord| record.target().map(f32::to_bits); + let gate_changed = gates[0] != gates[1]; + if prior.mute() == current.mute() && bits(&prior) == bits(¤t) { + return Ok(None); + } + let length = if gate_changed { + gate_length + } else { + let gain = (before.gain_db.to_bits() != after.gain_db.to_bits()) + .then(|| ramps.resolve(LiveRampRow::SendGain, edit.gain)); + let matrix = (matrix(before).map(f32::to_bits) != matrix(after).map(f32::to_bits)) + .then(|| ramps.resolve(LiveRampRow::SendMatrix, edit.matrix)); + gain.into_iter() + .chain(matrix) + .max() + .unwrap_or_else(|| ramps.resolve(LiveRampRow::SendMute, edit.mute)) + }; + build(after, gates[1], length).map(Some) +} + /// A fader or pan value outside the domain its render-side setter accepts. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct OutOfDomain; @@ -3062,7 +3722,6 @@ impl LevelRules<'_> { } } - #[cfg(feature = "control-provider")] /// The ramp length of a route record whose gate changed (#1558 D7): the route's own `mute` or /// `follows_mute` edit when one of them changed (rows `SendMute`, `SendFollowsMute`, with /// `own_edit` the route's `RouteMute` length), else the length that its source strip's `Mute` @@ -3193,13 +3852,19 @@ impl<'a> EffectiveDiff<'a> { #[cfg(feature = "control-provider")] /// The effective gate of route `index`, per source lane, in `[current, next]`. pub(crate) fn gates(&self, index: usize) -> [[bool; 2]; 2] { + self.route_gates(index, index) + } + + /// The effective gate of the route at `before` in `current` and at `after` in `next`, per + /// source lane, in `[current, next]` (#1284 D3): a successor's routes need not sit at the same + /// index in the two models. + pub(crate) fn route_gates(&self, before: usize, after: usize) -> [[bool; 2]; 2] { [ - self.before.routes[index].gate, - self.after.routes[index].gate, + self.before.routes[before].gate, + self.after.routes[after].gate, ] } - #[cfg(feature = "control-provider")] /// The ramp length of a route record whose gate changed (#1558 D7); see /// [`LevelRules::gate_length`]. pub(crate) fn gate_length( @@ -3219,7 +3884,6 @@ impl<'a> EffectiveDiff<'a> { ) } - #[cfg(feature = "control-provider")] /// The strip index of a route's source in the two models, or `None` if it names no strip in /// one of them. pub(crate) fn source_strips( @@ -3235,8 +3899,7 @@ impl<'a> EffectiveDiff<'a> { } /// The index in [`SessionModel::strips`] of the strip `route` takes its source from, or `None` if -/// the model has no such strip. Only the live paths (`control-provider`) resolve route sources. -#[cfg(feature = "control-provider")] +/// the model has no such strip. pub(crate) fn route_source_strip(model: &SessionModel, route: &session::Route) -> Option { let id = match &route.source { session::RouteSource::Track { track_id, .. } => track_id, @@ -3614,10 +4277,9 @@ fn join_strips<'a>( model: &'a SessionModel, live: bool, edit_ramps: &'a EditRamps, - monitor: MonitorBase<'a>, + diff: &EffectiveDiff<'a>, ) -> Vec> { let ramps = LiveRamps::for_session(model); - let diff = EffectiveDiff::new(committed, model, ramps, edit_ramps, monitor); let mut joins = Vec::new(); for (index, strip) in model.strips().enumerate() { let id = strip.id.as_str(); @@ -3658,6 +4320,219 @@ fn join_strips<'a>( joins } +/// One strip present in both models, with the lanes whose `delay_samples` are equal (#1284 D1). +struct StripDelayJoin<'a> { + id: &'a str, + /// The lanes whose declared delay is the same in both models: [`graph::STRIP_DELAY_LEFT`] and + /// [`graph::STRIP_DELAY_RIGHT`] bits. Each carries its pending samples when both plans hold the + /// strip's line. + equal: u8, + /// The two lanes' declared delays in the successor's model. + delays: [u32; 2], +} + +/// The model half of the strip delay join (#1284 D1): for every strip of `model` that `committed` +/// also has as a strip of the same kind, which lanes declare the same `delay_samples`. A strip +/// whose kind changed has no counterpart and is not listed: it is a new strip. +fn join_strip_delays<'a>( + committed: &'a SessionModel, + model: &'a SessionModel, +) -> Vec> { + model + .strips() + .filter_map(|strip| { + let before = committed_input_section(committed, &strip)?; + let lanes = [ + (&before.left, &strip.builtins.left), + (&before.right, &strip.builtins.right), + ]; + let equal = lanes + .iter() + .enumerate() + .filter(|(_, (before, after))| before.delay_samples == after.delay_samples) + .fold(0, |mask, (lane, _)| mask | (1_u8 << lane)); + Some(StripDelayJoin { + id: strip.id.as_str(), + equal, + delays: [ + strip.builtins.left.delay_samples, + strip.builtins.right.delay_samples, + ], + }) + }) + .collect() +} + +/// What the strip delay join resolved against both plans' lines (#1284 D1). +#[derive(Default)] +struct StripDelayCarry { + moves: Vec, + restarted: Vec>, +} + +/// The line half of the strip delay join (#1284 D1): a lane carries when its `delay_samples` is the +/// same in both models and both plans hold the strip's line, whose lane rings are then equally long. +/// A strip with a lane whose delay changed starts that lane at rest and is listed in `restarted` +/// (decision 15, D15-9): the duck-swap removes the click of the alignment that moves. Both slices +/// are sorted by strip ID. +fn resolve_strip_delay_carry( + predecessor: &[graph::GraphStripDelayLine], + own: &[graph::GraphStripDelayLine], + joins: &[StripDelayJoin<'_>], +) -> StripDelayCarry { + let row = |rows: &[graph::GraphStripDelayLine], id: &str| { + rows.binary_search_by(|row| (*row.strip).cmp(id)) + .ok() + .map(|index| rows[index].clone()) + }; + let mut carry = StripDelayCarry::default(); + for join in joins { + let both = graph::STRIP_DELAY_LEFT | graph::STRIP_DELAY_RIGHT; + let mut restarts = join.equal != both; + // A lane with no delay in the successor has nothing to take; a lane of a line the + // predecessor never had starts at rest. + let needed = [graph::STRIP_DELAY_LEFT, graph::STRIP_DELAY_RIGHT] + .into_iter() + .zip(join.delays) + .filter(|&(bit, samples)| join.equal & bit != 0 && samples != 0) + .fold(0, |mask, (bit, _)| mask | bit); + if needed != 0 { + match (row(predecessor, join.id), row(own, join.id)) { + (Some(before), Some(after)) => { + let lanes = [graph::STRIP_DELAY_LEFT, graph::STRIP_DELAY_RIGHT] + .into_iter() + .enumerate() + .filter(|&(lane, bit)| { + needed & bit != 0 && before.samples[lane] == after.samples[lane] + }) + .fold(0, |mask, (_, bit)| mask | bit); + restarts |= lanes != needed; + if lanes != 0 { + carry.moves.push(graph::GraphStripDelayMove { + successor: after.line, + predecessor: before.line, + lanes, + }); + } + } + _ => restarts = true, + } + } + if restarts { + carry.restarted.push(join.id.into()); + } + } + carry +} + +/// One send present in both models whose source and destination are the same and whose values the +/// plans can continue from (#1284 D2, D3): the route into a submix, and the record that brings the +/// carried send from the predecessor's live values to the successor's, when they differ. +struct RouteJoin<'a> { + id: &'a str, + retarget: Option, +} + +/// The model half of the send join (#1284 D2, D3): for every route of `model` into a submix that +/// `committed` also has with the same source (strip and tap) and the same destination, the record +/// of the difference of their live values. A re-pointed route is not listed: it is a route removed +/// and a route added, which #1363 ramps at route level, and its source strip is not restarted. +/// +/// The difference is judged on the effective values of both models ([`EffectiveDiff`]; the route's +/// gate follows its source strip's effective mute, #1558 D1) by [`route_record_between`], the +/// function the classifier calls for the same send, so a carry cannot disagree with a live commit. +/// A send whose values the function refuses is prepared: it is not listed and starts at its prepared +/// value. +fn join_routes<'a>( + committed: &'a SessionModel, + model: &'a SessionModel, + edit_ramps: &'a EditRamps, + diff: &EffectiveDiff<'a>, +) -> Vec> { + let ramps = LiveRamps::for_session(model); + let before_at: BTreeMap<&str, usize> = committed + .routes + .iter() + .enumerate() + .map(|(index, route)| (route.id.as_str(), index)) + .collect(); + let mut joins = Vec::new(); + for (after_index, after) in model.routes.iter().enumerate() { + if !matches!( + after.destination, + session::RouteDestination::SubmixInput { .. } + ) { + continue; + } + let Some(&before_index) = before_at.get(after.id.as_str()) else { + continue; + }; + let before = &committed.routes[before_index]; + if before.source != after.source || before.destination != after.destination { + continue; + } + let gates = diff.route_gates(before_index, after_index); + let edit = RouteEditRamps::of(edit_ramps, &after.id); + let gate_length = if gates[0] == gates[1] { + 0 + } else { + let Some(length) = diff + .source_strips(before, after) + .map(|source| diff.gate_length(before, after, edit.mute, source, gates)) + else { + continue; + }; + length + }; + let Ok(retarget) = route_record_between(ramps, before, after, gates, gate_length, edit) + else { + continue; + }; + joins.push(RouteJoin { + id: after.id.as_str(), + retarget, + }); + } + joins +} + +/// What the send join resolved against both plans' live routes (#1284 D2, D3). +#[derive(Default)] +struct RouteCarry<'a> { + moves: Vec, + /// The carried sends whose live values differ, by route ID, with the record to write to the + /// successor's cell. + retargets: Vec<(&'a str, RouteControlRecord)>, +} + +/// The route half of the send join (#1284 D2): a send carries when both plans hold a live route of +/// its ID. Both slices are sorted by route ID. +fn resolve_route_carry<'a>( + predecessor: &[graph::GraphLiveRoute], + own: &[graph::GraphLiveRoute], + joins: &[RouteJoin<'a>], +) -> RouteCarry<'a> { + let row = |rows: &[graph::GraphLiveRoute], id: &str| { + rows.binary_search_by(|row| (*row.route_id).cmp(id)) + .ok() + .map(|index| rows[index].unit) + }; + let mut carry = RouteCarry::default(); + for join in joins { + let (Some(before), Some(after)) = (row(predecessor, join.id), row(own, join.id)) else { + continue; + }; + carry.moves.push(graph::GraphRouteMove { + successor: after, + predecessor: before, + }); + if let Some(record) = join.retarget { + carry.retargets.push((join.id, record)); + } + } + carry +} + /// One carried strip's retarget records, to write into the successor's cells (#1277 D5). struct StripRetarget<'a> { id: &'a str, @@ -4070,9 +4945,10 @@ fn prepare_effect_retarget( /// strip moves, and which strips restart. /// /// An owner carries when its difference is not a prepared one, and both plans hold it as a lane -/// of an effect bank stage, of one kind (a plain stage, or a live-control stage whose lane has -/// cells, only a lowered bypass or nothing), with equal layouts (prepared bypass, latency, state -/// sizes and state layout version). A live difference also needs `retarget` to accept it (the +/// of an effect bank stage or as a live-controlled per-node instance, of one kind (a plain stage, +/// or a live-control stage whose lane has cells, only a lowered bypass or nothing), with equal +/// layouts (prepared bypass, latency, state sizes and state layout version). An owner that one +/// plan holds with live controls and the other without has no row on one side, and restarts. A live difference also needs `retarget` to accept it (the /// successor's producer has a cell for every record); a lane it refuses restarts. A console slot's /// key is (strip, `Simd1` or `Simd2`, slot ID), a surviving insert's is (strip, /// [`graph::RackId::Dynamic`], insert ID). A strip with an owner that does not carry, or whose @@ -4106,15 +4982,7 @@ fn resolve_effect_carry( let rows = effect_lane_row(own, join.id, rack, effect_id) .zip(predecessor.effect_lane(join.id, rack, effect_id)); let Some((own, before)) = rows.filter(|(own, before)| { - !state.is_prepared() - && own.layout == before.layout - && own.kind == before.kind - // A move between two per-node instances is #1282's; until it lands, a strip - // that holds one restarts, as it always did. - && !matches!( - (own.location, before.location), - (graph::GraphEffectSite::Node(_), graph::GraphEffectSite::Node(_)) - ) + !state.is_prepared() && own.layout == before.layout && own.kind == before.kind }) else { restart = true; continue; @@ -4129,7 +4997,17 @@ fn resolve_effect_carry( } _ => None, }; - carry.scratch_bytes = carry.scratch_bytes.max(own.layout.payload_bytes()); + // Two per-node instances change places by pointer (#1282 D2): their state goes through + // no scratch, so they add nothing to it. + if !matches!( + (own.location, before.location), + ( + graph::GraphEffectSite::Node(_), + graph::GraphEffectSite::Node(_) + ) + ) { + carry.scratch_bytes = carry.scratch_bytes.max(own.layout.payload_bytes()); + } carry.moves.push(graph::GraphEffectMove { successor: own.location, predecessor: before.location, @@ -4267,6 +5145,204 @@ mod tests { } } + fn stable(id: &str) -> graph::StableGraphId { + graph::StableGraphId::parse(id).expect("graph ID") + } + + fn strip(id: &str) -> graph::GraphNodeId { + graph::GraphNodeId::TrackStage { + track_id: stable(id), + stage: graph::TrackStage::PostMatrix, + } + } + + fn submix(id: &str) -> graph::GraphNodeId { + graph::GraphNodeId::Submix { + submix_id: stable(id), + } + } + + /// A line of the route `route`, whose endpoints are `from` and `to`, at index `line`. + fn route_line( + route: &str, + from: &str, + to: &str, + line: u32, + samples: u64, + ) -> graph::GraphDelayLine { + graph::GraphDelayLine { + edge: graph::GraphEdgeId::RouteDestination { + route_id: stable(route), + }, + source: strip(from), + destination: submix(to), + line, + samples, + } + } + + fn strip_delay(strip: &str, line: u32, samples: [u32; 2]) -> graph::GraphStripDelayLine { + graph::GraphStripDelayLine { + strip: strip.into(), + line, + samples, + } + } + + /// #1284 D1: a lane carries when its delay is the same in both models and both plans hold the + /// strip's line; a strip with a lane that changed, or whose line one plan lacks, restarts. A + /// lane with no delay has nothing to take. + #[test] + fn strip_delay_lanes_join_by_strip_and_lane() { + use super::{StripDelayJoin, resolve_strip_delay_carry}; + let (left, right) = (graph::STRIP_DELAY_LEFT, graph::STRIP_DELAY_RIGHT); + let before = [ + strip_delay("eq0", 0, [37, 53]), + strip_delay("eq2", 1, [0, 19]), + ]; + let own = [ + strip_delay("eq0", 1, [37, 60]), + strip_delay("eq2", 0, [0, 19]), + strip_delay("eq5", 2, [4, 4]), + ]; + let joins = [ + // The right lane changed: the left carries and the strip restarts. + StripDelayJoin { + id: "eq0", + equal: left, + delays: [37, 60], + }, + // Unchanged, with no delay on the left: only the right takes its ring. + StripDelayJoin { + id: "eq2", + equal: left | right, + delays: [0, 19], + }, + // Unchanged and undelayed: no line on either side, nothing to do. + StripDelayJoin { + id: "eq3", + equal: left | right, + delays: [0, 0], + }, + // Unchanged, but the predecessor has no line: it restarts. + StripDelayJoin { + id: "eq5", + equal: left | right, + delays: [4, 4], + }, + ]; + let carry = resolve_strip_delay_carry(&before, &own, &joins); + assert_eq!( + carry.moves, + vec![ + graph::GraphStripDelayMove { + successor: 1, + predecessor: 0, + lanes: left, + }, + graph::GraphStripDelayMove { + successor: 0, + predecessor: 1, + lanes: right, + }, + ] + ); + assert_eq!( + carry.restarted, + vec![Box::::from("eq0"), Box::::from("eq5")] + ); + } + + /// #1284 D2: a send carries when both plans hold a live route of its ID, whatever unit it sits + /// in; its retarget travels with it, and a send one plan lacks is neither moved nor written. + #[test] + fn live_routes_join_by_route_id() { + use super::{RouteJoin, resolve_route_carry}; + let live = |route: &str, unit| graph::GraphLiveRoute { + route_id: route.into(), + unit, + }; + let before = [live("a-main", 3), live("b-main", 5)]; + let own = [live("a-main", 4), live("b-main", 2), live("c-main", 9)]; + let record = + crate::route_controls::route_record(-6.0, [1.0, 0.0, 0.0, 1.0], false, [false; 2], 64) + .expect("a record"); + let joins = [ + RouteJoin { + id: "a-main", + retarget: Some(record), + }, + RouteJoin { + id: "b-main", + retarget: None, + }, + // Present in the successor only. + RouteJoin { + id: "c-main", + retarget: Some(record), + }, + // Present in the predecessor's model but not a live route of either plan. + RouteJoin { + id: "d-main", + retarget: Some(record), + }, + ]; + let carry = resolve_route_carry(&before, &own, &joins); + assert_eq!( + carry.moves, + vec![ + graph::GraphRouteMove { + successor: 4, + predecessor: 3, + }, + graph::GraphRouteMove { + successor: 2, + predecessor: 5, + }, + ] + ); + assert_eq!(carry.retargets, vec![("a-main", record)]); + } + + /// #1283 D1: lines join by edge ID and endpoints, never by index. A line moves when its + /// edge exists on both sides with the same endpoints, whatever its length and whatever + /// index either side gave it; a route re-pointed to another destination or another source + /// keeps its route ID, and its line does not move; an edge with a line on one side only + /// moves nothing. Red if the join keys by index (the added track shifts every index) or by + /// route ID alone (a re-pointed route's line would continue into the wrong bus). + #[test] + fn delay_lines_join_by_edge_and_endpoints() { + let mut before = vec![ + route_line("a-main", "a", "bus", 0, 480), + route_line("b-main", "b", "bus", 1, 480), + route_line("c-main", "c", "bus", 2, 480), + route_line("d-main", "d", "bus", 3, 480), + route_line("e-main", "e", "bus", 4, 480), + ]; + let mut own = vec![ + // New route: no predecessor line. + route_line("new-main", "new", "bus", 0, 480), + // Unchanged, but at another index and another length. + route_line("a-main", "a", "bus", 1, 512), + // Re-pointed to another destination. + route_line("b-main", "b", "other", 2, 480), + // Re-pointed to another source. + route_line("c-main", "x", "bus", 3, 480), + // Unchanged. + route_line("d-main", "d", "bus", 4, 480), + // `e-main` has no line in this plan. + ]; + before.sort_by(|l, r| l.edge.cmp(&r.edge)); + own.sort_by(|l, r| l.edge.cmp(&r.edge)); + let moves = super::resolve_delay_carry(&before, &own); + let mut moves: Vec<(u32, u32)> = moves + .iter() + .map(|step| (step.successor, step.predecessor)) + .collect(); + moves.sort_unstable(); + assert_eq!(moves, vec![(1, 0), (4, 3)]); + } + fn inventory_with(row: super::EffectLaneInventoryRow) -> super::PlanStateInventory { super::PlanStateInventory { plan_identity: 1, @@ -4274,6 +5350,11 @@ mod tests { input_sections: Box::default(), strip_stages: Box::default(), effect_lanes: Box::new([row]), + meters: Box::default(), + observations: Box::default(), + delay_lines: Box::default(), + strip_delays: Box::default(), + live_routes: Box::default(), restarted: Box::default(), } } diff --git a/crates/host-core/tests/successor_swap.rs b/crates/host-core/tests/successor_swap.rs index 1c8b4dabe..e7ceed7eb 100644 --- a/crates/host-core/tests/successor_swap.rs +++ b/crates/host-core/tests/successor_swap.rs @@ -1066,7 +1066,7 @@ impl LivePlan { SourceSubmission { generation: 1, start_frame: range.start as u64, - sample_rate_hz: 48_000, + sample_rate_hz: self.prepared.report.sample_rate_hz, planes: &[&feed.planes[0][range.clone()], &feed.planes[1][range]], frames: quantum as u32, end_of_region: false, @@ -2520,6 +2520,9 @@ enum Form { enum CellRecord { Fader(TrackFaderRecord), Matrix(TrackControlRecord), + /// A live send's record, written to the cell of the route with this ID (#1284): the record a + /// live commit writes to a send that follows its source strip's mute. + Route(&'static str, host_core::RouteControlRecord), } /// A record written to `strip`'s cells before block `block` renders. @@ -2658,6 +2661,13 @@ impl CellPlan { let revision = self.next; let stamp = self.live.gate.stamp(revision).expect("a legal revision"); for (strip, record) in writes { + if let CellRecord::Route(route, record) = *record { + let position = + host_core::route_producer_position(&self.live.handles.route_controls, route) + .unwrap_or_else(|| panic!("route {route} is a live send")); + self.live.handles.route_controls[position].write(stamp, record); + continue; + } let index = self .live .handles @@ -2669,6 +2679,7 @@ impl CellPlan { match *record { CellRecord::Fader(record) => producer.fader.write(stamp, record), CellRecord::Matrix(record) => producer.producer.write(stamp, record), + CellRecord::Route(..) => unreachable!("written above"), } } self.live.gate.publish(revision); @@ -3475,6 +3486,37 @@ fn with_send_through_submix( model } +/// The writes of a live mute of [`MOVED`] on `lanes` (the lanes whose mute turns on) at the swap +/// block: the strip's mute record and, when the send through the submix follows the strip's mute, +/// the send's gate record over the same ramp (#1558 D1: a live commit writes both). +fn followed_mute_writes(follows_mute: bool, lanes: [bool; 2], mute_samples: u32) -> Vec { + let selector = match lanes { + [true, true] => BuiltinLaneSelector::Both, + [true, false] => BuiltinLaneSelector::Left, + [false, true] => BuiltinLaneSelector::Right, + [false, false] => panic!("a mute on no lane"), + }; + let mut writes = vec![CellWrite { + block: SWAP_BLOCK, + strip: MOVED, + record: CellRecord::Fader(TrackFaderRecord::Mute { + lanes: selector, + muted: true, + smoothing_samples: mute_samples, + }), + }]; + if follows_mute { + let record = host_core::route_record(0.0, [1.0, 0.0, 0.0, 1.0], false, lanes, mute_samples) + .expect("a send record"); + writes.push(CellWrite { + block: SWAP_BLOCK, + strip: MOVED, + record: CellRecord::Route("eq0-main", record), + }); + } + writes +} + /// A mute change on a track that a `follows_mute` route follows is a live difference like any other /// (#1558 D4): the strip's stage carries and is retargeted over the session's mute length, and the /// strip is not in the restart set, whether or not a route follows it. The following route is @@ -3530,11 +3572,7 @@ fn a_followed_mute_change_carries_and_retargets_the_stage(backend: Backend, form edit_ramps: &EditRamps::new(), }), &feed, - &[CellWrite { - block: SWAP_BLOCK, - strip: MOVED, - record: CellRecord::Fader(mute_both(true, mute_samples)), - }], + &followed_mute_writes(follows_mute, [true; 2], mute_samples), backend, ); assert_eq!( @@ -3679,22 +3717,15 @@ fn a_followed_mute_change_retargets_the_fused_stage_whole(backend: Backend) { edit_ramps: &EditRamps::new(), }), &feed, - &[ - CellWrite { - block: SWAP_BLOCK, - strip: MOVED, - record: CellRecord::Fader(TrackFaderRecord::Mute { - lanes: BuiltinLaneSelector::Left, - muted: true, - smoothing_samples: mute_samples, - }), - }, - CellWrite { + &{ + let mut writes = followed_mute_writes(true, [true, false], mute_samples); + writes.push(CellWrite { block: SWAP_BLOCK, strip: MOVED, record: CellRecord::Matrix(pan_record([0.8, 0.6], 300)), - }, - ], + }); + writes + }, backend, ); assert_eq!(run.carry, CarryOutcome::Carried); @@ -4937,6 +4968,14 @@ enum EffectValue { BandGainBoth(f32), /// The bypass cell with its crossfade ramp. Bypass(BypassSwitch), + /// A live send's gain (#1284), as one record over `length` samples: the route's other values + /// stay the identity matrix, unmuted, with no source lane muted. The write's `strip` and + /// `address` are not read. + SendGain { + route: &'static str, + gain_db: f32, + length: u32, + }, } /// A value written to one effect's cells before block `block` renders. @@ -4952,6 +4991,28 @@ struct EffectWrite { /// its revision is published. fn write_effect_value(plan: &mut LivePlan, write: &EffectWrite) { let LivePlan { handles, gate, .. } = plan; + if let EffectValue::SendGain { + route, + gain_db, + length, + } = write.value + { + let position = host_core::route_producer_position(&handles.route_controls, route) + .unwrap_or_else(|| panic!("route {route} is a live send")); + let producer = &mut handles.route_controls[position]; + gate.write_published(|stamp| { + producer.set( + stamp, + gain_db, + [1.0, 0.0, 0.0, 1.0], + false, + [false; 2], + length, + ) + }) + .expect("a send record the cell holds"); + return; + } let producer = handles .effect_control_mut(write.strip, write.address) .expect("a control channel for the addressed effect"); @@ -4987,6 +5048,7 @@ fn write_effect_value(plan: &mut LivePlan, write: &EffectWrite) { effect_contract::ParameterChannel::Both, value, ), + EffectValue::SendGain { .. } => unreachable!("written above"), EffectValue::Bypass(switch) => { producer .preflight(EffectControlRecord::Bypass(switch)) @@ -5018,6 +5080,9 @@ struct EffectRun { refusals: u64, /// Effect lanes the predecessor's inventory records. effect_lanes: usize, + /// Bytes of effect state the swap block's carry passed through the payload scratch (#1282 + /// gate 4); an instance moved by pointer adds none. + payload_bytes: u64, } /// Render `a` for [`SWAP_BLOCK`] blocks and write `writes` to the plan that renders their block @@ -5031,19 +5096,33 @@ fn effect_run( writes: &[EffectWrite], successor_writes: &[EffectWrite], backend: Backend, +) -> EffectRun { + effect_run_blocks(a, b, feed, writes, successor_writes, backend, BLOCKS) +} + +/// [`effect_run`] over `blocks` blocks; `feed` holds at least that many. +fn effect_run_blocks( + a: &Session, + b: Option<(&Session, &SessionModel)>, + feed: &Feed, + writes: &[EffectWrite], + successor_writes: &[EffectWrite], + backend: Backend, + blocks: usize, ) -> EffectRun { use bench_support::alloc as bench_alloc; let quantum = a.model.quantum_frames as usize; let mut plan = LivePlan::fresh(a, backend); let effect_lanes = plan.prepared.inventory.effect_lane_count(); - let mut blocks = Vec::with_capacity(BLOCKS); + let mut rendered = Vec::with_capacity(blocks); let mut carry = CarryOutcome::NotRequested; let mut restarted = Vec::new(); let mut swap_allocator = ((0, 0, 0), (0, 0)); let mut superseded = 0; let mut refusals = 0; + let mut payload_bytes = 0; plan.submit(feed, quantum, 0); - for block in 0..BLOCKS { + for block in 0..blocks { for write in writes.iter().filter(|write| write.block == block) { write_effect_value(&mut plan, write); } @@ -5061,11 +5140,12 @@ fn effect_run( .adopt_persisting(&mut plan.prepared.sources), 1 ); - if block + 1 < BLOCKS { + if block + 1 < blocks { successor.submit(feed, quantum, block + 1); } let mut output = vec![f32::NAN; quantum * 2]; engine::realtime::audit::reset(); + graph::test_only_reset_carry_payload_bytes(); let mark = bench_alloc::current_thread_counters(); carry = successor .prepared @@ -5078,10 +5158,11 @@ fn effect_run( (delta.allocations, delta.reallocations, delta.deallocations), (audit.allocations, audit.deallocations), ); + payload_bytes = graph::test_only_carry_payload_bytes(); superseded = successor.handles.live_values_superseded.get(); refusals = graph::carry_refusal_count(&mut successor.prepared.plan).expect("a graph plan"); - blocks.push(output.iter().map(|sample| sample.to_bits()).collect()); + rendered.push(output.iter().map(|sample| sample.to_bits()).collect()); plan = successor; continue; } @@ -5090,22 +5171,23 @@ fn effect_run( write_effect_value(&mut plan, write); } } - if block + 1 < BLOCKS { + if block + 1 < blocks { plan.submit(feed, quantum, block + 1); } - blocks.push(plan.render(quantum)); + rendered.push(plan.render(quantum)); if block == SWAP_BLOCK { superseded = plan.handles.live_values_superseded.get(); } } EffectRun { - blocks, + blocks: rendered, carry, restarted, swap_allocator, superseded, refusals, effect_lanes, + payload_bytes, } } @@ -5909,7 +5991,6 @@ fn pending_observe_swap( handle.track_id.as_ref() == strip && handle.address == LiveEffectAddress::insert(0) }) .and_then(|handle| handle.readers[0].read()) - .map(|window| (window.first_sample, window.end_sample, window.blocks)) }; // The swap: A renders its blocks, the records are admitted, B is prepared and adopts. @@ -5964,7 +6045,7 @@ fn pending_observe_swap( "{what}: no restore was refused" ); - // A's queues read empty and A's taps hold what its next block would have reached. + // A's queues read empty: the carry popped every record. for strip in &strips { let producer = plan .handles @@ -5976,14 +6057,38 @@ fn pending_observe_swap( "{what}: {strip}: the retired queue reads empty" ); } + // The acknowledged subscribe is not dropped at the swap (#1327b): the carry applied it to + // A's tap as A's next block would have, and the tap then moved to the successor with its + // window and its publisher, so A keeps the successor's fresh, unarmed taps. assert_eq!( - plan.prepared.plan.observation_binding_counts()[2], + successor.prepared.plan.observation_binding_counts()[2], armed_at_reference, - "{what}: every admitted subscribe is armed on A's tap" + "{what}: every admitted subscribe is armed on the successor's tap" ); - // One more block of A's own (its sources moved on, so it renders silence) closes the windows - // the records opened: they open at the sample A's next block starts at, as the reference's do. - plan.render(quantum); + assert_eq!( + plan.prepared.plan.observation_binding_counts()[2], + 0, + "{what}: the retiring predecessor holds the fresh taps" + ); + let carries = successor.prepared.plan.observer_carries().to_vec(); + assert_eq!( + carries.len(), + strips.len(), + "{what}: one record per carried instance" + ); + assert!( + carries + .iter() + .all(|carry| carry.kind == engine::realtime::ObserverKind::Observation), + "{what}" + ); + host_core::pair_carried_observations( + &mut successor.handles.effect_observations, + &mut plan.handles.effect_observations, + &carries, + ); + // The successor's swap block published the window the predecessor's next block would have: + // the reference's, field for field, from the reader the host now holds for the successor. for strip in &strips { let expected = window_of(&reference, strip); assert_eq!( @@ -5992,9 +6097,9 @@ fn pending_observe_swap( "{what}: {strip}: the reference publishes exactly the subscribed windows" ); assert_eq!( - window_of(&plan, strip), + window_of(&successor, strip), expected, - "{what}: {strip}: A's window opens at the sample its next block starts at" + "{what}: {strip}: the successor publishes the window the predecessor would have" ); } } @@ -6381,3 +6486,2121 @@ fn pending_observe_records_cross_the_form_change_at_eight_lanes() { ); } } + +// --------------------------------------------------------------------------------------------- +// #1282: per-node effect instances move by pointer across a plan swap. +// --------------------------------------------------------------------------------------------- + +/// Blocks of the tail runs: long enough for a 50 ms delay at high feedback to ring through +/// several repeats after the swap. +const TAIL_BLOCKS: usize = 64; + +/// A native insert `instance` of `effect` with `params`, dual-mono linked. +fn native_insert(instance: &str, effect: &str, params: Vec) -> Effect { + Effect { + id: id(instance), + identity: EffectIdentity::Native { + effect_id: id(effect), + }, + quality: EffectQuality::Normal, + bypass: false, + link_mode: LinkMode::DualMono, + params, + sidechain: SidechainDeclaration::None, + } +} + +/// Three tracks, each with one insert that no plan banks (a group of one never fills a bank, and +/// the delay has no bank kernel): `eq0` a 50 ms delay at high feedback, `eq1` a multiband +/// compressor, `eq2` a compressor. +fn tail_session() -> SessionModel { + use ParameterUnit::{Linear, Milliseconds}; + let mut model = filtered_session(false); + let cut: Vec = model + .tracks + .iter() + .skip(3) + .map(|track| track.id.as_str().to_owned()) + .collect(); + for track in cut { + remove_track(&mut model, &track); + } + let inserts = [ + native_insert( + "dly", + "miso.delay", + vec![ + param(1, Milliseconds, 50.0), + param(2, Linear, 0.85), + param(3, Linear, 0.2), + param(4, Linear, 0.5), + ], + ), + native_insert("mb", "miso.multiband-compressor", Vec::new()), + native_insert("comp", "miso.compressor", console_params("comp", 0, false)), + ]; + for (track, insert) in model.tracks.iter_mut().zip(inserts) { + track.inserts.effects.push(insert); + } + model +} + +/// [`tail_session`] and its successor: the same session plus the muted track [`MUTED_TRACK`], +/// which sorts first and holds no insert. +fn tail_pair() -> (Session, Session) { + let mut b = with_muted_track(tail_session()); + track_mut(&mut b, MUTED_TRACK).inserts.effects.clear(); + (Session::compile(tail_session()), Session::compile(b)) +} + +/// The source plays for four blocks, then drops to a deterministic noise near -80 dBFS that is +/// never an exact zero, so no stage skips its work for silence. +fn tail_feed() -> Feed { + let mut feed = Feed::with_blocks(SOURCE, 9, TAIL_BLOCKS); + for plane in &mut feed.planes { + for sample in &mut plane[4 * QUANTUM..] { + *sample *= 0.0002; + } + } + assert!(feed.planes.iter().flatten().all(|sample| *sample != 0.0)); + feed +} + +/// Every insert of `session` is a per-node instance. +fn assert_inserts_per_node(session: &Session, backend: Backend, count: usize, what: &str) { + assert_eq!( + insert_forms(session, backend), + vec![true; count], + "{what}: every insert renders per node" + ); +} + +fn loudest(block: &[u32]) -> f32 { + block + .iter() + .map(|bits| f32::from_bits(*bits).abs()) + .fold(0.0, f32::max) +} + +/// Gate 1: a delay's echoes and a multiband compressor's envelope ring on through a swap that +/// adds a muted track, at the sixth block, while the source is down at the noise floor. The +/// swapped run is bit-identical to B fed the same PCM from frame 0, nothing restarts, the swap +/// block makes no allocator call, and the carry passes nothing through the payload scratch. The +/// same swap with every strip restarted loses the echoes. +fn a_delay_tail_rings_on_through_the_swap(backend: Backend) { + let what = format!("{backend:?}"); + let (a, b) = tail_pair(); + assert_inserts_per_node(&a, backend, 3, &what); + assert_inserts_per_node(&b, backend, 3, &what); + let feed = tail_feed(); + let reference = effect_run_blocks(&b, None, &feed, &[], &[], backend, TAIL_BLOCKS); + let run = effect_run_blocks( + &a, + Some((&b, &a.model)), + &feed, + &[], + &[], + backend, + TAIL_BLOCKS, + ); + assert_clean_swap(&run, &what); + assert_eq!(run.effect_lanes, 3, "{what}: three per-node instances"); + assert_eq!(run.restarted, Vec::>::new(), "{what}"); + assert_eq!( + run.payload_bytes, 0, + "{what}: nothing passed through the scratch" + ); + assert_eq!( + first_effect_difference(&run, &reference), + None, + "{what}: every block equals B rendered from frame 0" + ); + // Not vacuous. The first echo of the source's loud blocks is audible long after the swap... + assert!( + loudest(&reference.blocks[20]) > 1e-3, + "{what}: the echo rings above the noise floor" + ); + // ...and a swap whose strips all restart cuts it. + let mut committed = a.model.clone(); + for track in &mut committed.tracks { + for entry in &mut track.inserts.effects { + entry.quality = session::EffectQuality::High; + } + } + let restarted = effect_run_blocks( + &a, + Some((&b, &committed)), + &feed, + &[], + &[], + backend, + TAIL_BLOCKS, + ); + assert_eq!(restarted.restarted.len(), 3, "{what}: the strips restart"); + let cut = first_effect_difference(&restarted, &reference).expect("the echoes are cut"); + assert!(cut <= 20, "{what}: the echo is lost at block {cut}"); +} + +#[test] +fn a_delay_tail_rings_on_through_the_swap_at_every_width() { + for backend in backends() { + a_delay_tail_rings_on_through_the_swap(backend); + } +} + +/// Gate 2: a delay-time value written to A's cell just before the swap (never rendered by A) and +/// a ramped live bypass of the per-node compressor a block earlier both reach the successor. The +/// reference is B fed the same writes at the same blocks. +fn unread_values_and_a_live_bypass_cross_the_swap(backend: Backend) { + let what = format!("{backend:?}"); + let (a, b) = tail_pair(); + let feed = tail_feed(); + let writes = [ + EffectWrite { + block: SWAP_BLOCK - 1, + strip: "eq2", + address: LiveEffectAddress::insert(0), + value: EffectValue::Bypass(BypassSwitch { + bypassed: true, + ramp_samples: 300, + }), + }, + EffectWrite { + block: SWAP_BLOCK, + strip: "eq0", + address: LiveEffectAddress::insert(0), + // The delay time, in milliseconds. + value: EffectValue::Parameter { + index: 0, + value: 80.0, + }, + }, + ]; + let reference = effect_run_blocks(&b, None, &feed, &writes, &[], backend, TAIL_BLOCKS); + let run = effect_run_blocks( + &a, + Some((&b, &a.model)), + &feed, + &writes, + &[], + backend, + TAIL_BLOCKS, + ); + assert_clean_swap(&run, &what); + assert_eq!(run.restarted, Vec::>::new(), "{what}"); + assert_eq!(run.payload_bytes, 0, "{what}"); + assert_eq!( + first_effect_difference(&run, &reference), + None, + "{what}: every block equals B fed the same values at the same blocks" + ); + // Not vacuous: each write is audible after the swap on its own. + for kept in 0..writes.len() { + let one = [writes[kept]]; + let alone = effect_run_blocks(&b, None, &feed, &one, &[], backend, TAIL_BLOCKS); + let other = [writes[1 - kept]]; + let without = effect_run_blocks(&b, None, &feed, &other, &[], backend, TAIL_BLOCKS); + assert_ne!( + first_effect_difference(&alone, &without), + None, + "{what}: write {kept} changes the output" + ); + } +} + +#[test] +fn unread_values_and_a_live_bypass_cross_the_swap_at_every_width() { + for backend in backends() { + unread_values_and_a_live_bypass_cross_the_swap(backend); + } +} + +/// Gate 3: a committed bypass change on the delay is a prepared one (the delay lowers no live +/// bypass), so its owner restarts and its strip, and only it, is in the restart set. The other +/// two per-node instances still move. +fn a_prepared_bypass_change_restarts_its_strip(backend: Backend) { + let what = format!("{backend:?}"); + let (a, _) = tail_pair(); + let mut b_model = tail_session(); + track_mut(&mut b_model, "eq0").inserts.effects[0].bypass = true; + let b = Session::compile(b_model); + let feed = tail_feed(); + let run = effect_run_blocks( + &a, + Some((&b, &a.model)), + &feed, + &[], + &[], + backend, + TAIL_BLOCKS, + ); + assert_clean_swap(&run, &what); + assert_eq!( + run.restarted, + vec![Box::::from("eq0")], + "{what}: exactly the delay's strip restarts" + ); + assert_eq!(run.payload_bytes, 0, "{what}"); + // The restarted strip starts at rest, so the swapped run is not B rendered from frame 0. + let reference = effect_run_blocks(&b, None, &feed, &[], &[], backend, TAIL_BLOCKS); + assert_ne!( + first_effect_difference(&run, &reference), + None, + "{what}: the restarted strip starts at rest" + ); +} + +#[test] +fn a_prepared_bypass_change_restarts_its_strip_at_every_width() { + for backend in backends() { + a_prepared_bypass_change_restarts_its_strip(backend); + } +} + +/// D3: a plan that holds the owners without live controls has no row for them, so a successor +/// built from live-controlled predecessor lanes restarts their strips. +fn an_owner_without_live_controls_on_one_side_restarts(backend: Backend) { + let what = format!("{backend:?}"); + let (a, b) = tail_pair(); + let mut plan = LivePlan::fresh(&a, backend); + let successor = b + .prepare_successor(&plan.prepared.inventory, &a.model, &caps(), backend) + .unwrap_or_else(|failure| panic!("{what}: {failure:?}")); + let mut restarted = successor.restarted_strips().to_vec(); + restarted.sort(); + assert_eq!( + restarted, + ["eq0", "eq1", "eq2"].map(Box::::from).to_vec(), + "{what}: every strip with a per-node insert restarts" + ); + drop(plan.prepared.plan.take_revision_gate()); +} + +#[test] +fn an_owner_without_live_controls_on_one_side_restarts_at_every_width() { + for backend in backends() { + an_owner_without_live_controls_on_one_side_restarts(backend); + } +} + +/// Gate 4: at 96 kHz the delay holds two seconds a channel, about 1.5 MiB. Its owner moves by +/// pointer, so the carry passes no byte of it through the payload scratch, and the swap block +/// makes no allocation and no free. The bits equal B's from frame 0. +fn a_delay_moves_by_pointer_at_96_khz(backend: Backend) { + let what = format!("{backend:?}"); + let at_96k = |mut model: SessionModel| { + model.sample_rate_hz = 96_000; + model + }; + let a = Session::compile(at_96k(tail_session())); + let mut b_model = with_muted_track(tail_session()); + track_mut(&mut b_model, MUTED_TRACK).inserts.effects.clear(); + let b = Session::compile(at_96k(b_model)); + let feed = tail_feed(); + let reference = effect_run_blocks(&b, None, &feed, &[], &[], backend, TAIL_BLOCKS); + let run = effect_run_blocks( + &a, + Some((&b, &a.model)), + &feed, + &[], + &[], + backend, + TAIL_BLOCKS, + ); + assert_clean_swap(&run, &what); + assert_eq!(run.restarted, Vec::>::new(), "{what}"); + assert_eq!( + run.payload_bytes, 0, + "{what}: the delay's state was not copied" + ); + assert_eq!(first_effect_difference(&run, &reference), None, "{what}"); +} + +#[test] +fn a_delay_moves_by_pointer_at_96_khz_at_every_width() { + for backend in backends() { + a_delay_moves_by_pointer_at_96_khz(backend); + } +} + +/// The payload counter reads what a lane move passes through the scratch: a per-node compressor +/// that joins a bank is copied, so the counter is nonzero there (gate 4's oracle can fail). +#[cfg(target_feature = "avx2")] +#[test] +fn the_payload_counter_counts_a_lane_move() { + let backend = Backend::Simd8; + let (a, b) = form_pair(Insert::Compressor, backend, true); + let feed = bursty_feed(SOURCE, 5); + let run = effect_run(&a, Some((&b, &a.model)), &feed, &[], &[], backend); + assert_clean_swap(&run, "per node to bank"); + assert!( + run.payload_bytes > 0, + "a state that changes form goes through the scratch" + ); +} + +/// A live difference on a moved per-node instance retargets it (#1280c D4): the delay's time and +/// a compressor's session bypass, each as one committed edit whose successor takes the instance +/// by pointer and then writes the new value. Run 1 is the structural swap with the retarget; +/// run 2 writes the same edit to A before the swap and prepares B from a base that holds it. +fn a_live_difference_on_a_moved_instance_is_retargeted(backend: Backend) { + let ramp = LiveRamps::for_session(&tail_session()).for_row(LiveRampRow::BypassCrossfade); + type Edit = fn(SessionModel) -> SessionModel; + let edits: [(&str, Edit, EffectWrite); 2] = [ + ( + "delay time", + |model| with_insert_param_all(model, "eq0", "dly", 1, 80.0), + EffectWrite { + block: SWAP_BLOCK, + strip: "eq0", + address: LiveEffectAddress::insert(0), + value: EffectValue::Parameter { + index: 0, + value: 80.0, + }, + }, + ), + ( + "compressor bypass", + |mut model| { + track_mut(&mut model, "eq2").inserts.effects[0].bypass = true; + model + }, + EffectWrite { + block: SWAP_BLOCK, + strip: "eq2", + address: LiveEffectAddress::insert(0), + value: EffectValue::Bypass(BypassSwitch { + bypassed: true, + ramp_samples: ramp, + }), + }, + ), + ]; + let feed = tail_feed(); + for (name, edit, write) in edits { + let what = format!("{backend:?} {name}"); + let (a, b) = tail_pair(); + let b_edited = Session::compile(edit(b.model.clone())); + let a_edited = edit(a.model.clone()); + let run = effect_run_blocks( + &a, + Some((&b_edited, &a.model)), + &feed, + &[], + &[], + backend, + TAIL_BLOCKS, + ); + assert_clean_swap(&run, &what); + assert_eq!(run.restarted, Vec::>::new(), "{what}"); + assert_eq!(run.payload_bytes, 0, "{what}"); + let reference = effect_run_blocks( + &a, + Some((&b_edited, &a_edited)), + &feed, + &[write], + &[], + backend, + TAIL_BLOCKS, + ); + assert_clean_swap(&reference, &format!("{what} reference")); + assert_eq!( + first_effect_difference(&run, &reference), + None, + "{what}: move, then retarget equals live edit, then structural edit" + ); + // Not vacuous: the moved instance keeps its old value without the retarget. + let stale = effect_run_blocks( + &a, + Some((&b, &a.model)), + &feed, + &[], + &[], + backend, + TAIL_BLOCKS, + ); + assert_ne!( + first_effect_difference(&stale, &run), + None, + "{what}: the edit is audible after the swap" + ); + } +} + +#[test] +fn a_live_difference_on_a_moved_instance_is_retargeted_at_every_width() { + for backend in backends() { + a_live_difference_on_a_moved_instance_is_retargeted(backend); + } +} + +// Issue #1327a (stream A of decision 15, D15-14): builtin meters carry across a plan swap. A +// meter whose strip, tap, metric set and configuration are the predecessor's keeps its open +// window, its counters and its sequence, and the host's reader keeps receiving. The oracle is the +// post-edit session prepared fresh and fed from frame 0, with the same meters. + +mod meter_carry { + use core::num::{NonZeroU32, NonZeroUsize}; + use std::collections::BTreeMap; + + use builtins::{MeterMetricSet, MeterSnapshot, MeterTap}; + use builtins_compiler::MeterConsumer; + use engine::realtime::{HostAttachment, ObserverCarry, ObserverKind, PlanAdoption}; + use host_core::{ + HostLiveControlRequest, HostMeterRequest, MonitorBase, PreparedHost, SuccessorBase, + pair_carried_meters, test_only_prepare_host_runtime_with_selected_meters_on, + }; + + use super::successor::{self, SUCCESSOR_REVISION}; + use super::*; + + const TAPS: [MeterTap; 7] = [ + MeterTap::Input, + MeterTap::PostInputBuiltins, + MeterTap::PostSimd1, + MeterTap::PostDynamic, + MeterTap::PostSimd2PreFader, + MeterTap::PostFader, + MeterTap::PostMatrix, + ]; + /// Not a multiple of the quantum, and longer than the swap block's start: the window opened at + /// sample 0 closes at 600 and the next one is open across the swap at sample 768. + const PERIOD: u32 = 600; + const THIRD_TRACK: &str = "eq2"; + + type Key = (String, MeterTap); + + fn live(period: u32) -> HostLiveControlRequest { + HostLiveControlRequest { + meter_period_frames: NonZeroU32::new(period), + meter_queue_depth: NonZeroUsize::new(16).expect("depth"), + ..HostLiveControlRequest::default() + } + } + + fn requests(strips: &[&str]) -> Vec { + strips + .iter() + .flat_map(|strip| { + TAPS.iter().map(|tap| HostMeterRequest { + strip_id: (*strip).into(), + tap: *tap, + metrics: MeterMetricSet::ALL, + }) + }) + .collect() + } + + fn key(request: &HostMeterRequest) -> Key { + (request.strip_id.to_string(), request.tap) + } + + /// Three audible tracks. + fn session_three() -> SessionModel { + let mut model = session_a(); + add_track(&mut model, "eq0", THIRD_TRACK, SOURCE, false); + model + } + + /// The test's host attachment, as the browser's is: it holds the plan's meter readers and + /// pairs them with the predecessor's in the swap block. + struct Rig { + meters: Vec, + carries: Vec, + } + + impl Rig { + fn new(meters: Vec) -> Box { + Box::new(Self { + meters, + carries: Vec::with_capacity(256), + }) + } + } + + impl HostAttachment for Rig { + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } + + fn adopt_predecessor( + &mut self, + predecessor: &mut dyn HostAttachment, + carries: &[ObserverCarry], + ) { + let Some(predecessor) = predecessor.as_any_mut().downcast_mut::() else { + return; + }; + pair_carried_meters(&mut self.meters, &mut predecessor.meters, carries); + // Within the capacity reserved at construction: no allocation. + self.carries.extend_from_slice(carries); + } + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum Reader { + /// The host keeps the predecessor's consumers and the successor's, and reads both + /// (gate 1): a carried meter's snapshots keep arriving on the predecessor's consumer. + Direct, + /// The readers live in the plans' attachments and pair in the swap block (gate 3). + Paired, + } + + struct Run { + blocks: Vec, + /// Every snapshot read, by the reader's strip and tap, in arrival order. + streams: BTreeMap>, + /// The successor's carry record after the swap block ([`Reader::Paired`] only). + carries: Vec, + } + + fn drain(consumers: &mut [MeterConsumer], streams: &mut BTreeMap>) { + for consumer in consumers { + while let Ok(snapshot) = consumer.consumer.try_pop() { + streams + .entry((consumer.track_id.to_string(), consumer.tap)) + .or_default() + .push(snapshot); + } + } + } + + fn prepare( + session: &Session, + live: &HostLiveControlRequest, + meters: &[HostMeterRequest], + base: Option>, + backend: Backend, + ) -> (PreparedHost, host_core::HostLiveControlHandles) { + test_only_prepare_host_runtime_with_selected_meters_on( + &session.compiled, + &caps(), + live, + meters, + base, + backend, + ) + .unwrap_or_else(|failure| panic!("prepare: {failure:?}")) + } + + /// `a` rendered for [`SWAP_BLOCK`] blocks, `b` prepared as its successor with its own meter + /// request and swapped in, rendered to [`BLOCKS`]. + #[allow(clippy::too_many_arguments)] + fn swapped( + backend: Backend, + a: &Session, + a_live: &HostLiveControlRequest, + a_meters: &[HostMeterRequest], + b: &Session, + b_live: &HostLiveControlRequest, + b_meters: &[HostMeterRequest], + reader: Reader, + ) -> Run { + let feed = Feed::new(SOURCE, 1); + let (mut a_prepared, mut a_handles) = prepare(a, a_live, a_meters, None, backend); + let mut a_direct = Vec::new(); + match reader { + Reader::Direct => a_direct = core::mem::take(&mut a_handles.meters), + Reader::Paired => a_prepared + .plan + .set_host_attachment(Rig::new(core::mem::take(&mut a_handles.meters))), + } + let rate = a_prepared.report.sample_rate_hz; + let PreparedHost { + plan, + sources: mut a_sources, + inventory, + .. + } = a_prepared; + let (mut publisher, mut owner, retirer) = successor::exchange(plan); + let mut streams = BTreeMap::new(); + let mut blocks = Vec::new(); + feed.submit(&mut a_sources, rate, 0); + for block in 0..SWAP_BLOCK { + feed.submit(&mut a_sources, rate, block + 1); + blocks.push(successor::render(&mut owner)); + match reader { + Reader::Direct => drain(&mut a_direct, &mut streams), + Reader::Paired => { + let rig = owner.active_attachment_mut::().expect("attachment"); + drain(&mut rig.meters, &mut streams); + } + } + } + let (mut b_prepared, mut b_handles) = prepare( + b, + b_live, + b_meters, + Some(SuccessorBase { + inventory: &inventory, + committed: &a.model, + revision: SUCCESSOR_REVISION, + edit_ramps: &session::EditRamps::new(), + monitor: MonitorBase::NONE, + }), + backend, + ); + b_prepared.sources.adopt_persisting(&mut a_sources); + let mut b_direct = Vec::new(); + match reader { + Reader::Direct => b_direct = core::mem::take(&mut b_handles.meters), + Reader::Paired => b_prepared + .plan + .set_host_attachment(Rig::new(core::mem::take(&mut b_handles.meters))), + } + let PreparedHost { + mut plan, + sources: mut b_sources, + .. + } = b_prepared; + plan.take_revision_gate() + .expect("the successor's handle") + .publish(SUCCESSOR_REVISION); + publisher + .reserve_replacement(plan, PlanAdoption::Next) + .unwrap_or_else(|_| panic!("reserve the successor")) + .commit(); + for block in SWAP_BLOCK..BLOCKS { + if block + 1 < BLOCKS { + feed.submit(&mut b_sources, rate, block + 1); + } + blocks.push(successor::render(&mut owner)); + match reader { + Reader::Direct => { + drain(&mut a_direct, &mut streams); + drain(&mut b_direct, &mut streams); + } + Reader::Paired => { + let rig = owner.active_attachment_mut::().expect("attachment"); + drain(&mut rig.meters, &mut streams); + } + } + } + let carries = match reader { + Reader::Direct => Vec::new(), + Reader::Paired => owner + .active_attachment_mut::() + .expect("attachment") + .carries + .clone(), + }; + drop((retirer, publisher, a_handles, b_handles)); + Run { + blocks, + streams, + carries, + } + } + + /// `b` prepared fresh with `meters` and fed from frame 0. + fn reference( + backend: Backend, + b: &Session, + live: &HostLiveControlRequest, + meters: &[HostMeterRequest], + ) -> BTreeMap> { + let feed = Feed::new(SOURCE, 1); + let (prepared, mut handles) = prepare(b, live, meters, None, backend); + let rate = prepared.report.sample_rate_hz; + let PreparedHost { + plan, mut sources, .. + } = prepared; + let (_publisher, mut owner, _retirer) = successor::exchange(plan); + let mut streams = BTreeMap::new(); + feed.submit(&mut sources, rate, 0); + for block in 0..BLOCKS { + if block + 1 < BLOCKS { + feed.submit(&mut sources, rate, block + 1); + } + successor::render(&mut owner); + drain(&mut handles.meters, &mut streams); + } + streams + } + + /// A snapshot with the fields a swap legitimately changes set to the reference's: a snapshot + /// the predecessor published before the swap names the predecessor's handle. + fn same_but_handle(snapshot: &MeterSnapshot, reference: &MeterSnapshot) -> MeterSnapshot { + MeterSnapshot { + handle: reference.handle, + ..*snapshot + } + } + + const SWAP_SAMPLE: u64 = (SWAP_BLOCK * QUANTUM) as u64; + + /// Gates 1 and 3 at one width and reader. Strips: A has three tracks, B adds a muted one that + /// sorts first, so every carried meter's reader index shifts by [`TAPS`].len(). + fn meters_carry(backend: Backend, reader: Reader) { + let a = Session::compile(session_three()); + let mut b_model = session_three(); + add_track(&mut b_model, "eq0", MUTED_TRACK, SOURCE, true); + let b = Session::compile(b_model); + let a_strips = ["eq0", "eq1", THIRD_TRACK]; + let b_strips = [MUTED_TRACK, "eq0", "eq1", THIRD_TRACK]; + let (a_meters, b_meters) = (requests(&a_strips), requests(&b_strips)); + let expected = reference(backend, &b, &live(PERIOD), &b_meters); + let run = swapped( + backend, + &a, + &live(PERIOD), + &a_meters, + &b, + &live(PERIOD), + &b_meters, + reader, + ); + assert_eq!(run.blocks[SWAP_BLOCK].carry, CarryOutcome::Carried); + assert_eq!(run.blocks[SWAP_BLOCK].swap, SwapOutcome::Applied); + assert!(!expected.is_empty()); + + let carried: Vec = a_meters.iter().map(key).collect(); + for k in &carried { + let got = &run.streams[k]; + let want = &expected[k]; + assert_eq!( + got.len(), + want.len(), + "{backend:?} {reader:?} {k:?}: every window arrives once, none lost or doubled" + ); + for (index, (got, want)) in got.iter().zip(want).enumerate() { + assert_eq!( + got.window_sequence, index as u64, + "{backend:?} {reader:?} {k:?}: no gap in the sequence" + ); + assert_eq!(got.cumulative_discontinuities, 0, "{k:?}: no discontinuity"); + assert_eq!(got.reset_generation, want.reset_generation, "{k:?}"); + assert_eq!( + same_but_handle(got, want), + *want, + "{backend:?} {reader:?} {k:?}: window {index} equals the reference field by field" + ); + if got.end_sample > SWAP_SAMPLE { + assert_eq!( + got.handle, want.handle, + "{k:?}: the carried state names the reader that receives it" + ); + } + } + assert!( + got.iter() + .any(|s| s.start_sample < SWAP_SAMPLE && s.end_sample > SWAP_SAMPLE), + "{k:?}: a window spans the swap, or the carry is not tested" + ); + } + + // The added muted track's meters join the carried grid (D6). Its pre-fader taps see the + // source, which the strip did not have before adoption, so only the grid and, where the + // strip is silent in the reference too, every field equal the reference's. + for tap in TAPS { + let k: Key = (MUTED_TRACK.to_string(), tap); + let got = &run.streams[&k]; + assert!(!got.is_empty(), "{k:?}: the added meter publishes"); + for snapshot in got { + let want = expected[&k] + .iter() + .find(|want| want.window_sequence == snapshot.window_sequence) + .unwrap_or_else(|| { + panic!( + "{k:?}: the reference has window {}", + snapshot.window_sequence + ) + }); + assert_eq!( + ( + snapshot.start_sample, + snapshot.end_sample, + snapshot.frames, + snapshot.handle + ), + (want.start_sample, want.end_sample, want.frames, want.handle), + "{backend:?} {reader:?} {k:?}: on the carried window grid from its first snapshot" + ); + assert_eq!(snapshot.cumulative_discontinuities, 0); + if matches!(tap, MeterTap::PostFader | MeterTap::PostMatrix) { + assert_eq!(snapshot, want, "{k:?}: a muted strip is silent in both"); + } + } + assert_eq!( + got[0].window_sequence, 1, + "{k:?}: it joined the open window" + ); + } + + if reader == Reader::Paired { + let index_in = |meters: &[HostMeterRequest], k: &Key| { + meters + .iter() + .position(|request| key(request) == *k) + .expect("key") + }; + assert_eq!( + run.carries.len(), + a_meters.len(), + "one record per carried meter" + ); + for carry in &run.carries { + assert_eq!(carry.kind, ObserverKind::Meter); + let k = key(&a_meters[carry.predecessor]); + assert_eq!( + index_in(&b_meters, &k), + carry.successor, + "{k:?}: the record names the successor's reader of the same strip and tap" + ); + } + } + } + + #[cfg(target_feature = "avx2")] + #[test] + fn meters_carry_through_the_predecessors_readers_at_eight_lanes() { + meters_carry(Backend::Simd8, Reader::Direct); + } + + #[test] + fn meters_carry_through_the_predecessors_readers_at_four_lanes() { + meters_carry(Backend::Simd4, Reader::Direct); + } + + #[cfg(target_feature = "avx2")] + #[test] + fn paired_readers_follow_the_record_at_eight_lanes() { + meters_carry(Backend::Simd8, Reader::Paired); + } + + #[test] + fn paired_readers_follow_the_record_at_four_lanes() { + meters_carry(Backend::Simd4, Reader::Paired); + } + + /// Gate 2: a changed window length starts every meter fresh, and a changed key starts that + /// meter fresh alone. + #[test] + fn a_changed_meter_starts_fresh_and_the_others_carry() { + let backend = Backend::Simd4; + let a = Session::compile(session_three()); + let strips = ["eq0", "eq1", THIRD_TRACK]; + let meters = requests(&strips); + + // A different window length: nothing carries, and the first window opens at adoption. + let run = swapped( + backend, + &a, + &live(PERIOD), + &meters, + &a, + &live(PERIOD / 2), + &meters, + Reader::Paired, + ); + assert!( + run.carries.is_empty(), + "no meter carries across a changed window length" + ); + for (k, stream) in &run.streams { + let fresh: Vec<_> = stream + .iter() + .filter(|s| s.start_sample >= SWAP_SAMPLE) + .collect(); + assert_eq!(fresh[0].window_sequence, 0, "{k:?}: a new sequence"); + assert_eq!( + fresh[0].start_sample, SWAP_SAMPLE, + "{k:?}: opened at the adoption block" + ); + assert_eq!(fresh[0].frames, PERIOD / 2, "{k:?}: the new window length"); + } + + // One meter keeps another metric set: that meter alone is not in the record. + let mut changed = requests(&strips); + let at = 9; + assert_eq!(key(&changed[at]), ("eq1".to_string(), TAPS[2])); + changed[at].metrics = MeterMetricSet::SAMPLE_PEAK; + let reference_streams = reference(backend, &a, &live(PERIOD), &changed); + let run = swapped( + backend, + &a, + &live(PERIOD), + &meters, + &a, + &live(PERIOD), + &changed, + Reader::Paired, + ); + assert_eq!(run.carries.len(), meters.len() - 1); + assert!( + run.carries + .iter() + .all(|carry| carry.successor != at && carry.predecessor != at) + ); + for (index, request) in changed.iter().enumerate() { + let k = key(request); + let got = &run.streams[&k]; + let want = &reference_streams[&k]; + if index == at { + // Fresh, on the carried grid (D6): the window it joined holds the swap block on. + // [the predecessor's window 0, the successor's fresh meter's window 1] + assert_eq!(got.len(), 2, "{k:?}"); + assert_eq!(got[1].window_sequence, 1); + assert_eq!(got[1].present_metrics, MeterMetricSet::SAMPLE_PEAK); + } else { + assert_eq!(got.len(), want.len(), "{k:?}"); + for (got, want) in got.iter().zip(want) { + assert_eq!(same_but_handle(got, want), *want, "{k:?}"); + } + } + } + } + + /// Gate 5: the swap block, with the hook's pairing in it, allocates and frees nothing. + #[test] + fn the_meter_swap_block_allocates_and_frees_nothing() { + use bench_support::alloc::{Mode, assert_installed, mode, set_mode}; + struct RestoreMode(Mode); + impl Drop for RestoreMode { + fn drop(&mut self) { + set_mode(self.0); + } + } + assert_installed(); + let _restore = RestoreMode(mode()); + set_mode(Mode::Count); + engine::realtime::audit::warm_up(); + let a = Session::compile(session_three()); + let mut b_model = session_three(); + add_track(&mut b_model, "eq0", MUTED_TRACK, SOURCE, true); + let b = Session::compile(b_model); + let run = swapped( + Backend::current(), + &a, + &live(PERIOD), + &requests(&["eq0", "eq1", THIRD_TRACK]), + &b, + &live(PERIOD), + &requests(&[MUTED_TRACK, "eq0", "eq1", THIRD_TRACK]), + Reader::Paired, + ); + assert_eq!(run.blocks[SWAP_BLOCK].carry, CarryOutcome::Carried); + assert!(!run.carries.is_empty()); + for (index, block) in run.blocks.iter().enumerate().skip(1) { + assert_eq!(block.allocator, (0, 0, 0), "block {index}: allocator calls"); + assert_eq!(block.audit, (0, 0), "block {index}: render audit"); + } + } +} + +// --------------------------------------------------------------------------------------------- +// #1327b: an effect's observation taps follow their effect instance across a swap. +// +// The tap's whole state moves with the instance: its arm state, its open window, its sequence and +// its publisher. The reader the host holds for the predecessor's tap is paired with the +// successor's in the swap block, so it keeps receiving, and a subscribe that was acknowledged +// before the swap is armed on the successor's tap after it. + +mod observation_carry { + use std::collections::BTreeMap; + + use engine::realtime::{HostAttachment, ObservationWindow, ObserverCarry, ObserverKind}; + use host_core::{EffectObservationHandle, pair_carried_observations}; + + use super::*; + + /// Blocks per window. The swap block (6) falls inside the window of blocks 4..8, so a window + /// is open across the swap and it publishes after it. + const WINDOW_BLOCKS: u32 = 4; + + /// The test's host attachment, as the browser's is: it holds the plan's observation readers + /// and pairs them with the predecessor's in the swap block. + struct Rig { + observations: Vec, + carries: Vec, + } + + impl Rig { + fn new(observations: Vec) -> Box { + let capacity = observations.len() + 8; + Box::new(Self { + observations, + carries: Vec::with_capacity(capacity), + }) + } + + /// The strip of each reader set, in reader order. + fn strips(&self) -> Vec { + self.observations + .iter() + .map(|handle| handle.track_id.to_string()) + .collect() + } + } + + impl HostAttachment for Rig { + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } + + fn adopt_predecessor( + &mut self, + predecessor: &mut dyn HostAttachment, + carries: &[ObserverCarry], + ) { + let Some(predecessor) = predecessor.as_any_mut().downcast_mut::() else { + return; + }; + pair_carried_observations( + &mut self.observations, + &mut predecessor.observations, + carries, + ); + // Within the capacity reserved at construction: no allocation. + self.carries.extend_from_slice(carries); + } + } + + type Streams = BTreeMap>; + + /// The windows the readers of `plan` hold that they have not shown before, by strip. + fn read_all(plan: &mut LivePlan, streams: &mut Streams) { + let rig = plan + .prepared + .plan + .host_attachment_mut::() + .expect("the plan's attachment"); + for handle in &rig.observations { + let Some(window) = handle.readers[0].read() else { + continue; + }; + let stream = streams.entry(handle.track_id.to_string()).or_default(); + if stream + .last() + .is_none_or(|last| last.sequence != window.sequence) + { + stream.push(window); + } + } + } + + /// Subscribe to the gain-reduction tap of each of `strips`' compressor insert, as an app + /// does: a record in the insert's observation queue, acknowledged when it is queued. + fn subscribe(plan: &mut LivePlan, strips: &[String]) { + for strip in strips { + plan.handles + .effect_control_mut(strip, LiveEffectAddress::insert(0)) + .expect("a control channel for the insert") + .try_push_observe(0, true, WINDOW_BLOCKS) + .expect("room in the queue"); + } + } + + /// Attach `plan`'s observation readers to it, as the browser does. + fn attach(plan: &mut LivePlan) { + let observations = core::mem::take(&mut plan.handles.effect_observations); + plan.prepared + .plan + .set_host_attachment(Rig::new(observations)); + } + + /// `session` prepared fresh, subscribed to `subscribed` at its first block, and fed from + /// frame 0: every window each strip's tap publishes. + fn reference(backend: Backend, session: &Session, subscribed: &[String]) -> Streams { + let feed = bursty_feed(SOURCE, 5); + let mut plan = LivePlan::fresh_with(session, &observed_request(), backend); + subscribe(&mut plan, subscribed); + attach(&mut plan); + let mut streams = Streams::new(); + for block in 0..BLOCKS { + plan.submit(&feed, QUANTUM, block); + plan.render(QUANTUM); + read_all(&mut plan, &mut streams); + } + streams + } + + struct Swapped { + streams: Streams, + carry: CarryOutcome, + /// The successor's carry record, as its attachment's hook saw it in the swap block. + carries: Vec, + /// The strip of each reader set of A and of B. + a_strips: Vec, + b_strips: Vec, + restarted: Vec>, + /// Armed taps of the successor and of the predecessor after the swap block. + armed: [u64; 2], + /// The allocator's `(allocations, reallocations, deallocations)` and the render audit's + /// `(allocations, deallocations)` over the swap block: hand-over, hook and render. + allocator: ((u64, u64, u64), (u64, u64)), + } + + /// A rendered for [`SWAP_BLOCK`] blocks with `subscribed` acknowledged before its first, B + /// prepared as its successor and swapped in at [`SWAP_BLOCK`], rendered to [`BLOCKS`]. B + /// subscribes to nothing: whatever its taps hold at the swap is what the carry brought. + fn swapped(backend: Backend, a: &Session, b: &Session, subscribed: &[String]) -> Swapped { + use bench_support::alloc as bench_alloc; + let feed = bursty_feed(SOURCE, 5); + let request = observed_request(); + let mut streams = Streams::new(); + let mut plan = LivePlan::fresh_with(a, &request, backend); + subscribe(&mut plan, subscribed); + attach(&mut plan); + for block in 0..SWAP_BLOCK { + plan.submit(&feed, QUANTUM, block); + plan.render(QUANTUM); + read_all(&mut plan, &mut streams); + } + let mut successor = + LivePlan::successor_with(b, &request, &plan.prepared.inventory, &a.model, backend); + attach(&mut successor); + assert_eq!( + successor + .prepared + .sources + .adopt_persisting(&mut plan.prepared.sources), + 1 + ); + successor.submit(&feed, QUANTUM, SWAP_BLOCK); + let mut output = vec![f32::NAN; QUANTUM * 2]; + engine::realtime::audit::reset(); + let mark = bench_alloc::current_thread_counters(); + let carry = successor + .prepared + .plan + .adopt_predecessor_plan(&mut plan.prepared.plan); + successor.render_into(&mut output, QUANTUM); + let delta = bench_alloc::current_thread_delta_since(mark); + let audit = engine::realtime::audit::snapshot(); + read_all(&mut successor, &mut streams); + for block in SWAP_BLOCK + 1..BLOCKS { + successor.submit(&feed, QUANTUM, block); + successor.render(QUANTUM); + read_all(&mut successor, &mut streams); + } + let strips = |plan: &mut LivePlan| { + plan.prepared + .plan + .host_attachment_mut::() + .expect("attachment") + .strips() + }; + Swapped { + streams, + carry, + carries: successor + .prepared + .plan + .host_attachment_mut::() + .expect("attachment") + .carries + .clone(), + a_strips: strips(&mut plan), + b_strips: strips(&mut successor), + restarted: successor.prepared.restarted_strips().to_vec(), + armed: [ + successor.prepared.plan.observation_binding_counts()[2], + plan.prepared.plan.observation_binding_counts()[2], + ], + allocator: ( + (delta.allocations, delta.reallocations, delta.deallocations), + (audit.allocations, audit.deallocations), + ), + } + } + + fn strips_of(session: &Session) -> Vec { + session + .model + .tracks + .iter() + .map(|track| track.id.as_str().to_owned()) + .collect() + } + + /// Which sessions the swap runs between. + #[derive(Clone, Copy, Debug)] + enum Form { + /// Per-node instances on both sides: A's inserts and B's, with one more. + NodeToNode, + /// A bank of lanes on both sides: B adds a track with no insert. + BankToBank, + /// Per-node instances become a bank of lanes. + NodeToBank, + /// A bank of lanes becomes per-node instances. + BankToNode, + } + + fn pair(backend: Backend, form: Form) -> (Session, Session) { + let width = bank_width(backend); + match form { + Form::NodeToNode => { + let short = compressor_insert_session(width - 2); + let (a, b) = ( + Session::compile(short.clone()), + Session::compile(with_muted_track(short)), + ); + assert_eq!(insert_forms(&a, backend), vec![true; width - 2]); + assert_eq!(insert_forms(&b, backend), vec![true; width - 1]); + (a, b) + } + Form::BankToBank => { + let full = compressor_insert_session(width); + let mut bare = with_muted_track(full.clone()); + track_mut(&mut bare, MUTED_TRACK).inserts.effects.clear(); + let (a, b) = (Session::compile(full), Session::compile(bare)); + assert_eq!(insert_forms(&a, backend), vec![false; width]); + assert_eq!(insert_forms(&b, backend), vec![false; width]); + (a, b) + } + Form::NodeToBank => form_pair(Insert::Compressor, backend, true), + Form::BankToNode => form_pair(Insert::Compressor, backend, false), + } + } + + /// Gate 4, and the acknowledged subscribe: a tap subscribed before the swap publishes the + /// windows a fresh B subscribed from frame 0 does, field for field, across the swap, with + /// no restart and no gap in its sequence; the host's reader was paired by the record, which + /// names each tap by its effect owner, not its position. + fn taps_carry(backend: Backend, form: Form) { + let what = format!("{backend:?} {form:?}"); + let (a, b) = pair(backend, form); + let subscribed = strips_of(&a); + // A strip B removes has no instance to carry, and its tap goes with it. + let kept: Vec = subscribed + .iter() + .filter(|strip| strips_of(&b).contains(strip)) + .cloned() + .collect(); + let expected = reference(backend, &b, &kept); + let run = swapped(backend, &a, &b, &subscribed); + assert_eq!(run.carry, CarryOutcome::Carried, "{what}"); + assert_eq!(run.restarted, Vec::>::new(), "{what}"); + assert_eq!( + run.armed, + [kept.len() as u64, (subscribed.len() - kept.len()) as u64], + "{what}: the subscribes are armed on the successor's taps; the retiring predecessor \ + holds fresh unarmed ones and the tap of a removed strip" + ); + assert_eq!( + run.allocator, + ((0, 0, 0), (0, 0)), + "{what}: allocator and render audit over the swap block" + ); + + assert_eq!( + run.carries.len(), + kept.len(), + "{what}: one record per tap set" + ); + let mut shifted = false; + for carry in &run.carries { + assert_eq!(carry.kind, ObserverKind::Observation, "{what}"); + assert_eq!( + run.a_strips[carry.predecessor], run.b_strips[carry.successor], + "{what}: the record joins the two reader sets of one strip" + ); + shifted |= carry.predecessor != carry.successor; + } + assert_eq!( + shifted, + matches!(form, Form::NodeToNode | Form::NodeToBank | Form::BankToNode), + "{what}: the added track shifts every reader index, or this swap shifts none" + ); + + for strip in &kept { + let got = &run.streams[strip]; + let want = &expected[strip]; + assert_eq!( + got, want, + "{what} {strip}: the windows equal a fresh B's, the one open at the swap included" + ); + assert_eq!( + got.iter().map(|window| window.sequence).collect::>(), + vec![1, 2, 3], + "{what} {strip}: no gap and no restart" + ); + assert!( + got.iter() + .any(|window| window.left > 0.0 || window.right > 0.0), + "{what} {strip}: the compressor reduces gain, or the readings are vacuous" + ); + } + assert!( + expected + .values() + .any(|windows| windows[1].first_sample < SWAP_SAMPLE + && windows[1].end_sample > SWAP_SAMPLE), + "{what}: a window spans the swap" + ); + // The added track's tap was not subscribed and is no carried one's: it publishes nothing. + for strip in run + .b_strips + .iter() + .filter(|strip| !subscribed.contains(strip)) + { + assert!(!run.streams.contains_key(strip), "{what}: {strip} is fresh"); + } + } + + const SWAP_SAMPLE: u64 = (SWAP_BLOCK * QUANTUM) as u64; + + #[cfg(target_feature = "avx2")] + #[test] + fn taps_follow_their_effect_through_every_form_at_eight_lanes() { + for form in [ + Form::NodeToNode, + Form::BankToBank, + Form::NodeToBank, + Form::BankToNode, + ] { + taps_carry(Backend::Simd8, form); + } + } + + #[test] + #[cfg_attr( + not(any(target_feature = "neon", target_feature = "simd128")), + ignore = "a console effect bank binds only at the width its build executes, and four \ + lanes is the NEON and simd128 width (#1017)" + )] + fn taps_follow_their_effect_through_every_form_at_four_lanes() { + for form in [ + Form::NodeToNode, + Form::BankToBank, + Form::NodeToBank, + Form::BankToNode, + ] { + taps_carry(Backend::Simd4, form); + } + } + + /// Gate 4, second case: a compressor whose prepared setup changed (its link mode) restarts, + /// and so does its tap: it is not in the record, and its successor reader receives nothing + /// while every other strip's tap carries. + fn a_restarted_effect_starts_its_tap_fresh(backend: Backend) { + let what = format!("{backend:?}"); + let width = bank_width(backend); + let base = compressor_insert_session(width - 2); + let mut changed = with_muted_track(base.clone()); + track_mut(&mut changed, "eq1").inserts.effects[0].link_mode = LinkMode::Average; + let (a, b) = (Session::compile(base), Session::compile(changed)); + let subscribed = strips_of(&a); + let run = swapped(backend, &a, &b, &subscribed); + assert_eq!(run.carry, CarryOutcome::Carried, "{what}"); + assert_eq!(run.restarted, vec![Box::::from("eq1")], "{what}"); + assert_eq!(run.carries.len(), subscribed.len() - 1, "{what}"); + assert!( + run.carries + .iter() + .all(|carry| run.a_strips[carry.predecessor] != "eq1" + && run.b_strips[carry.successor] != "eq1"), + "{what}: eq1's taps are not in the record" + ); + let expected = reference(backend, &b, &subscribed); + for strip in subscribed.iter().filter(|strip| *strip != "eq1") { + assert_eq!(run.streams[strip], expected[strip], "{what} {strip}"); + } + assert_eq!( + run.streams["eq1"] + .iter() + .map(|window| window.sequence) + .collect::>(), + vec![1], + "{what}: eq1 published its first window in A, and its fresh tap publishes none" + ); + assert_eq!( + run.armed[0], + subscribed.len() as u64 - 1, + "{what}: the restarted effect's tap starts unarmed" + ); + } + + #[cfg(target_feature = "avx2")] + #[test] + fn a_restarted_effect_starts_its_tap_fresh_at_eight_lanes() { + a_restarted_effect_starts_its_tap_fresh(Backend::Simd8); + } + + #[test] + #[cfg_attr( + not(any(target_feature = "neon", target_feature = "simd128")), + ignore = "a console effect bank binds only at the width its build executes, and four \ + lanes is the NEON and simd128 width (#1017)" + )] + fn a_restarted_effect_starts_its_tap_fresh_at_four_lanes() { + a_restarted_effect_starts_its_tap_fresh(Backend::Simd4); + } + + /// Gate 5 for the taps: the swap block -- carry, the attachment hook's pairing and the first + /// render -- allocates and frees nothing, counted rather than aborted. + #[test] + fn the_tap_swap_block_allocates_and_frees_nothing() { + use bench_support::alloc::{Mode, assert_installed, mode, set_mode}; + struct RestoreMode(Mode); + impl Drop for RestoreMode { + fn drop(&mut self) { + set_mode(self.0); + } + } + assert_installed(); + let _restore = RestoreMode(mode()); + set_mode(Mode::Count); + engine::realtime::audit::warm_up(); + let backend = Backend::current(); + let (a, b) = pair(backend, Form::NodeToNode); + let subscribed = strips_of(&a); + let run = swapped(backend, &a, &b, &subscribed); + assert_eq!(run.carry, CarryOutcome::Carried); + assert_eq!(run.carries.len(), subscribed.len()); + assert_eq!(run.allocator, ((0, 0, 0), (0, 0))); + } +} + +// Issue #1283 (slice 13 of #1269): the plug-in delay compensation lines. PDC delays each edge into +// a node with several inputs by that node's latest arrival minus the edge's own, so one strip with +// a latency insert gives every other route into the same node a line. A line is keyed by its edge +// ID and the edge's endpoints: it carries when both survive, at the output and inside a submix +// alike, and a re-pointed route carries nothing. + +/// The tracks `eq0` to `eq3` with `limited` strips behind a true-peak limiter insert. The +/// limiter reports `Fs / 100 + 6` samples of latency, so every route into a node the limited +/// strip also reaches has a compensation line. +fn pdc_session(rate: u32, limited: &[&str]) -> SessionModel { + let mut model = filtered_session(false); + model.sample_rate_hz = rate; + let cut: Vec = model + .tracks + .iter() + .skip(4) + .map(|track| track.id.as_str().to_owned()) + .collect(); + for track in cut { + remove_track(&mut model, &track); + } + for (index, track) in model.tracks.iter_mut().enumerate() { + if limited.contains(&track.id.as_str()) { + track.inserts.effects.push(Effect { + id: id("lim"), + identity: EffectIdentity::Native { + effect_id: id("miso.true-peak-limiter"), + }, + quality: EffectQuality::Normal, + bypass: false, + link_mode: LinkMode::Maximum, + params: console_params("limiter", index, false), + sidechain: SidechainDeclaration::None, + }); + } + } + model +} + +/// `model` with the route of `track` sent to submix `bus` instead of where it went. +fn send_route_to(model: &mut SessionModel, track: &str, bus: &str) { + let main = format!("{track}-main"); + let route = model + .routes + .iter_mut() + .find(|route| route.id.as_str() == main) + .expect("the track's main route"); + route.destination = session::RouteDestination::SubmixInput { submix_id: id(bus) }; +} + +/// A unity submix `bus` that forwards to the output. +fn add_bus(model: &mut SessionModel, bus: &str) { + let template = model + .routes + .iter() + .find(|route| route.id.as_str() == "eq0-main") + .expect("a track route") + .clone(); + let mut onward = template.clone(); + let RouteSource::Track { tap, .. } = template.source else { + panic!("a track route"); + }; + onward.id = id(&format!("{bus}-main")); + onward.source = RouteSource::Submix { + submix_id: id(bus), + tap, + }; + onward.follows_mute = false; + model.routes.push(onward); + model + .submixes + .push(session::Submix::unity(id(bus), &model.console)); +} + +/// The rates the launch supports. +const PDC_RATES: [u32; 4] = [44_100, 48_000, 88_200, 96_000]; + +/// A swapped run of `a` into `b` and the fresh reference of `b`, with live controls attached (the +/// form whose inserts carry), and the checks every case shares: the hand-over carried, the swap +/// block made no allocator call and no restore was refused. +fn pdc_runs( + a: &Session, + b: &Session, + feed: &Feed, + backend: Backend, + what: &str, +) -> (EffectRun, EffectRun) { + use bench_support::alloc::{Mode, assert_installed, mode, set_mode}; + struct RestoreMode(Mode); + impl Drop for RestoreMode { + fn drop(&mut self) { + set_mode(self.0); + } + } + assert_installed(); + let _restore = RestoreMode(mode()); + set_mode(Mode::Count); + engine::realtime::audit::warm_up(); + let reference = effect_run(b, None, feed, &[], &[], backend); + let run = effect_run(a, Some((b, &a.model)), feed, &[], &[], backend); + assert_clean_swap(&run, what); + (run, reference) +} + +/// Gate 1: three tracks, the first behind a limiter, so the other two routes into the output +/// carry lines. B adds a muted track with no latent effect, so no arrival moves. The swapped run +/// equals B rendered from frame 0 in every block, at every launch rate. Red if a line is keyed by +/// its index in the delay vector (the added track sorts first and shifts every index) or left at +/// rest: the two compensated tracks drop out for the length of their line. +fn compensated_output_lines_keep_their_audio(backend: Backend, rate: u32) { + let what = format!("{backend:?} at {rate} Hz"); + let mut a_model = pdc_session(rate, &["eq0"]); + remove_track(&mut a_model, "eq3"); + let mut b_model = a_model.clone(); + add_track(&mut b_model, "eq1", MUTED_TRACK, SOURCE, true); + let (a, b) = (Session::compile(a_model), Session::compile(b_model)); + assert_eq!( + a.prepare(backend).inventory.delay_line_count(), + 2, + "{what}: the two tracks without the limiter are compensated" + ); + let feed = Feed::new(SOURCE, 1); + let (run, reference) = pdc_runs(&a, &b, &feed, backend, &what); + assert_eq!(run.restarted, Vec::>::new(), "{what}"); + assert_eq!( + first_effect_difference(&run, &reference), + None, + "{what}: every block equals the post-edit session rendered from frame 0" + ); + // At 96 kHz the limiter's lookahead is longer than the blocks before the swap, so the first + // audio comes out after it; the lines hold what was fed before. + assert!( + reference.blocks[SWAP_BLOCK + 2..] + .iter() + .flatten() + .any(|bits| *bits != 0), + "{what}: the reference plays after the swap block" + ); +} + +#[test] +fn compensated_output_lines_keep_their_audio_at_every_rate_and_width() { + for backend in backends() { + for rate in PDC_RATES { + compensated_output_lines_keep_their_audio(backend, rate); + } + } +} + +/// The submix `bus1` takes `eq0` (behind a limiter) and `eq1`; the output takes `bus1` and `eq2`. +/// The edge of `eq1` into the bus and the edge of `eq2` into the output both carry a line. +fn bus_session(rate: u32) -> SessionModel { + let mut model = pdc_session(rate, &["eq0"]); + remove_track(&mut model, "eq3"); + add_bus(&mut model, "bus1"); + send_route_to(&mut model, "eq0", "bus1"); + send_route_to(&mut model, "eq1", "bus1"); + model +} + +/// Gate 2: B adds a muted zero-latency track to the submix. The submix's own line and the output's +/// line both carry. Red if the carry handles only the edges into the output: the submix input +/// line starts at rest and `eq1` drops out of the bus for the length of its line. +fn compensated_submix_lines_keep_their_audio(backend: Backend, rate: u32) { + let what = format!("{backend:?} at {rate} Hz"); + let a_model = bus_session(rate); + let mut b_model = a_model.clone(); + add_track(&mut b_model, "eq1", MUTED_TRACK, SOURCE, true); + let (a, b) = (Session::compile(a_model), Session::compile(b_model)); + assert_eq!( + a.prepare(backend).inventory.delay_line_count(), + 2, + "{what}: eq1 into the bus and eq2 into the output are compensated" + ); + let feed = Feed::new(SOURCE, 1); + let (run, reference) = pdc_runs(&a, &b, &feed, backend, &what); + assert_eq!(run.restarted, Vec::>::new(), "{what}"); + assert_eq!( + first_effect_difference(&run, &reference), + None, + "{what}: every block equals the post-edit session rendered from frame 0" + ); +} + +#[test] +fn compensated_submix_lines_keep_their_audio_at_every_rate_and_width() { + for backend in backends() { + for rate in PDC_RATES { + compensated_submix_lines_keep_their_audio(backend, rate); + } + } +} + +/// Two submixes, each with a limited and an unlimited track: `eq0` (limited) and `eq1` into +/// `bus1`, `eq3` (limited) and `eq2` into `bus2`. Both unlimited routes carry a line. +fn two_bus_session(rate: u32, silent: Option<&str>) -> SessionModel { + let mut model = pdc_session(rate, &["eq0", "eq3"]); + add_bus(&mut model, "bus1"); + add_bus(&mut model, "bus2"); + send_route_to(&mut model, "eq0", "bus1"); + send_route_to(&mut model, "eq1", "bus1"); + send_route_to(&mut model, "eq2", "bus2"); + send_route_to(&mut model, "eq3", "bus2"); + if let Some(track) = silent { + model = with_mute(model, track, true); + } + model +} + +/// Gate 4: B re-points `eq1`'s route from `bus1` to `bus2`. The route keeps its ID and gets +/// another destination, so its line does not carry and its strip is not restarted. +/// +/// With `eq1` muted in both sessions the re-pointed line holds only zeros, so the run equals B +/// from frame 0 exactly when every other line carried: `eq2`'s line into `bus2` is the one that +/// does. With `eq1` playing, the new route's line starts at rest and the swap block misses the +/// audio B's line holds: red if the line followed the route ID into the other bus, where it +/// would play the old pending audio and match B. +fn a_repointed_route_does_not_carry_its_line(backend: Backend, rate: u32) { + let what = format!("{backend:?} at {rate} Hz"); + let feed = Feed::new(SOURCE, 1); + for silent in [Some("eq1"), None] { + let a_model = two_bus_session(rate, silent); + let mut b_model = a_model.clone(); + send_route_to(&mut b_model, "eq1", "bus2"); + let (a, b) = (Session::compile(a_model), Session::compile(b_model)); + let (run, reference) = pdc_runs(&a, &b, &feed, backend, &what); + assert_eq!( + run.restarted, + Vec::>::new(), + "{what}: the re-pointed route's strip keeps its state" + ); + // Before the swap A renders, and A routes `eq1` through the other bus. + let difference = run.blocks[SWAP_BLOCK..] + .iter() + .zip(&reference.blocks[SWAP_BLOCK..]) + .position(|(swapped, fresh)| swapped != fresh); + if silent.is_some() { + assert_eq!( + difference, None, + "{what}: every other line carried, and the re-pointed one held silence" + ); + } else { + assert!( + difference.is_some(), + "{what}: the new route's line starts at rest, so B's audio is missing after the swap" + ); + } + } +} + +#[test] +fn a_repointed_route_does_not_carry_its_line_at_every_rate_and_width() { + for backend in backends() { + for rate in PDC_RATES { + a_repointed_route_does_not_carry_its_line(backend, rate); + } + } +} + +// Issue #1284 (slice 14 of #1269): the strip input alignment lines (`delay_samples` of a track and +// of a submix) and the live sends. A line is keyed by its strip and lane, a send by its route ID; +// a lane whose delay changed starts at rest and its strip goes into the restart set, and a +// re-pointed send is a route removed and a route added (#1363), which restarts nothing. + +/// The send whose level the cases move. +const SEND: &str = "eq3-main"; +/// A send's ramp, in samples: it spans six blocks, so it is in flight at the swap block. +const LONG_RAMP: u32 = 6 * QUANTUM as u32; + +/// Set the declared input delay of strip `strip` (a track or a submix). +fn with_strip_delay(mut model: SessionModel, strip: &str, delays: [u32; 2]) -> SessionModel { + let builtins = if let Some(track) = model.tracks.iter_mut().find(|t| t.id.as_str() == strip) { + &mut track.builtins + } else { + &mut model + .submixes + .iter_mut() + .find(|s| s.id.as_str() == strip) + .expect("a strip") + .builtins + }; + builtins.left.delay_samples = delays[0]; + builtins.right.delay_samples = delays[1]; + model +} + +/// Gate 1's session: the track `eq0` with two different lane delays straight to the output; `eq1` +/// and `eq2` (a delay on its right lane only) and `eq3` into the submix `bus1`, which has its own +/// input delay; `eq3`'s route [`SEND`] is the live send the cases move. `bus2` is a second submix +/// that nothing feeds. `bus_delays` gives the submixes their input delays (zero for a case that +/// needs a memoryless bus). +fn send_session(bus_delays: bool) -> SessionModel { + let mut model = pdc_session(48_000, &[]); + add_bus(&mut model, "bus1"); + add_bus(&mut model, "bus2"); + for track in ["eq1", "eq2", "eq3"] { + send_route_to(&mut model, track, "bus1"); + } + let model = with_strip_delay(model, "eq0", [37, 53]); + let model = with_strip_delay(model, "eq2", [0, 19]); + if bus_delays { + with_strip_delay(model, "bus1", [41, 29]) + } else { + model + } +} + +/// `model` plus [`MUTED_TRACK`], a muted copy of `eq0` that sorts first and goes to the output. +fn with_muted_output_track(mut model: SessionModel) -> SessionModel { + add_track(&mut model, "eq0", MUTED_TRACK, SOURCE, true); + model +} + +fn route_mut<'a>(model: &'a mut SessionModel, route: &str) -> &'a mut session::Route { + model + .routes + .iter_mut() + .find(|candidate| candidate.id.as_str() == route) + .expect("a route") +} + +fn send_writes() -> Vec { + // One ramp in flight at the swap block, and a second record admitted after the last block A + // renders, which is pending at the swap. + let write = |block, gain_db, length| EffectWrite { + block, + strip: "", + address: EQ_ADDRESS, + value: EffectValue::SendGain { + route: SEND, + gain_db, + length, + }, + }; + vec![ + write(SWAP_BLOCK - 2, -9.0, LONG_RAMP), + write(SWAP_BLOCK, -2.5, 3 * QUANTUM as u32 + 5), + ] +} + +/// Gate 1: every block of the swapped run equals the post-edit session rendered from frame 0 and +/// fed the same records at the same blocks. Red if any strip delay line (a track's two unequal +/// lanes, a lane with no delay, the submix's) is left at rest (the delayed strip drops out for the +/// length of its line), if the send's ramp restarts from its target (the level steps), or if the +/// record pending at the swap is lost (the copy ran before the drain). +fn strip_delays_and_a_send_ramp_cross_the_swap(backend: Backend) { + let a = Session::compile(send_session(true)); + let b = Session::compile(with_muted_output_track(send_session(true))); + let feed = Feed::new(SOURCE, 1); + let writes = send_writes(); + let probe = LivePlan::fresh(&a, backend).prepared; + assert_eq!( + probe.inventory.strip_delay_count(), + 3, + "eq0, eq2 and bus1 declare delays" + ); + assert_eq!( + probe.inventory.live_route_count(), + 3, + "the three sends into bus1; the routes into the output are not live" + ); + drop(probe); + let reference = effect_run(&b, None, &feed, &writes, &[], backend); + let run = effect_run(&a, Some((&b, &a.model)), &feed, &writes, &[], backend); + assert_clean_swap(&run, &format!("{backend:?}")); + assert_eq!(run.restarted, Vec::>::new(), "{backend:?}"); + assert_eq!( + first_effect_difference(&run, &reference), + None, + "{backend:?}: every block equals the post-edit session fed the same records" + ); + // Not vacuous: the records are audible in the blocks after the swap, and so are the lines. + let unmoved = effect_run(&b, None, &feed, &[], &[], backend); + assert_ne!( + unmoved.blocks[SWAP_BLOCK + 1], + reference.blocks[SWAP_BLOCK + 1], + "{backend:?}: the ramp is in flight after the swap block" + ); + let undelayed = Session::compile(with_muted_output_track(undelayed_send_session())); + let undelayed = effect_run(&undelayed, None, &feed, &writes, &[], backend); + assert_ne!( + undelayed.blocks[SWAP_BLOCK], reference.blocks[SWAP_BLOCK], + "{backend:?}: the delays are audible in the swap block" + ); +} + +/// [`send_session`] with no delay at all, for the case that shows the delays matter. +fn undelayed_send_session() -> SessionModel { + let model = with_strip_delay(send_session(false), "eq0", [0, 0]); + with_strip_delay(model, "eq2", [0, 0]) +} + +#[cfg(target_feature = "avx2")] +#[test] +fn strip_delays_and_a_send_ramp_cross_the_swap_at_eight_lanes() { + strip_delays_and_a_send_ramp_cross_the_swap(Backend::Simd8); +} + +#[test] +fn strip_delays_and_a_send_ramp_cross_the_swap_at_four_lanes() { + strip_delays_and_a_send_ramp_cross_the_swap(Backend::Simd4); +} + +/// The left and the right plane of one rendered block. +fn lane_planes(bits: &[u32]) -> (&[u32], &[u32]) { + bits.split_at(QUANTUM) +} + +/// Gate 2a: B moves `eq0`'s right lane from 53 to 60 samples of delay and leaves its left lane at +/// 37. The strip is in the restart set and nothing else is. The left lane's ring carries, so the +/// output's left plane equals the post-edit session rendered from frame 0 in every block; the right +/// lane starts at rest, so the swap block plays silence for the length of the new delay. Red if the +/// right ring is carried across a length change (the old alignment plays), if the lanes are keyed +/// together (the left lane restarts as well), or if the strip is not listed. +fn a_changed_lane_delay_restarts_only_its_lane(backend: Backend) { + let mut one = pdc_session(48_000, &[]); + for track in ["eq1", "eq2", "eq3"] { + remove_track(&mut one, track); + } + let builtins = &mut one.tracks[0].builtins; + for channel in [&mut builtins.left, &mut builtins.right] { + channel.hpf_hz = 0.0; + channel.lpf_hz = 0.0; + } + // The lanes reach the output independently, so a plane shows one lane's line. + one.tracks[0].matrix_or_pan = session::MatrixOrPan::Matrix { + ll: 1.0, + lr: 0.0, + rl: 0.0, + rr: 1.0, + smoothing_samples: 0, + }; + let a_model = with_strip_delay(one, "eq0", [37, 53]); + let b_model = with_strip_delay(a_model.clone(), "eq0", [37, 60]); + let (a, b) = (Session::compile(a_model), Session::compile(b_model)); + let feed = Feed::new(SOURCE, 1); + let reference = effect_run(&b, None, &feed, &[], &[], backend); + let run = effect_run(&a, Some((&b, &a.model)), &feed, &[], &[], backend); + assert_clean_swap(&run, &format!("{backend:?}")); + assert_eq!(run.restarted, vec![Box::::from("eq0")], "{backend:?}"); + for block in SWAP_BLOCK..BLOCKS { + let (swapped_left, swapped_right) = lane_planes(&run.blocks[block]); + let (fresh_left, fresh_right) = lane_planes(&reference.blocks[block]); + assert_eq!( + swapped_left + .iter() + .zip(fresh_left) + .position(|(a, b)| a != b), + None, + "{backend:?}: block {block}: the left lane's ring carried ({} samples)", + swapped_left.len() + ); + if block == SWAP_BLOCK { + assert!( + swapped_right[..60] + .iter() + .all(|bits| f32::from_bits(*bits) == 0.0), + "{backend:?}: the right lane's new line starts at rest" + ); + assert_ne!( + swapped_right, fresh_right, + "{backend:?}: the right lane differs" + ); + } + } +} + +#[cfg(target_feature = "avx2")] +#[test] +fn a_changed_lane_delay_restarts_only_its_lane_at_eight_lanes() { + a_changed_lane_delay_restarts_only_its_lane(Backend::Simd8); +} + +#[test] +fn a_changed_lane_delay_restarts_only_its_lane_at_four_lanes() { + a_changed_lane_delay_restarts_only_its_lane(Backend::Simd4); +} + +/// Gate 2b: B re-points the send [`SEND`] from `bus1` to `bus2` (no submix has a delay and the +/// buses hold no state, so the route's ramp is the only memory). `restarted_strips()` is empty: the +/// re-pointed send's source strip `eq3` is not in it. The send does not carry its ramp: every block +/// from the swap block on equals B rendered from frame 0 without any record, where B's send is at +/// its prepared value. The control keeps the send where it was, carries the ramp and differs from +/// that reference. Red if the ramp is carried into the other bus, or if the re-pointed send ducks +/// its source strip. +fn a_repointed_send_starts_at_its_prepared_value(backend: Backend) { + let feed = Feed::new(SOURCE, 1); + let writes = send_writes(); + let a_model = send_session(false); + let mut b_model = with_muted_output_track(a_model.clone()); + route_mut(&mut b_model, SEND).destination = session::RouteDestination::SubmixInput { + submix_id: id("bus2"), + }; + // The control keeps the send where it was. + let kept_model = with_muted_output_track(a_model.clone()); + let (a, b, kept) = ( + Session::compile(a_model), + Session::compile(b_model), + Session::compile(kept_model), + ); + let no_writes = effect_run(&b, None, &feed, &[], &[], backend); + let run = effect_run(&a, Some((&b, &a.model)), &feed, &writes, &[], backend); + assert_clean_swap(&run, &format!("{backend:?}")); + assert_eq!( + run.restarted, + Vec::>::new(), + "{backend:?}: the re-pointed send's source strip keeps its state" + ); + for block in SWAP_BLOCK..BLOCKS { + assert_eq!( + run.blocks[block], no_writes.blocks[block], + "{backend:?}: block {block}: the re-pointed send is at its prepared value" + ); + } + let carried = effect_run(&a, Some((&kept, &a.model)), &feed, &writes, &[], backend); + assert_clean_swap(&carried, &format!("{backend:?} control")); + assert_ne!( + carried.blocks[SWAP_BLOCK + 1], + no_writes.blocks[SWAP_BLOCK + 1], + "{backend:?}: a send that keeps its destination carries its ramp" + ); +} + +#[cfg(target_feature = "avx2")] +#[test] +fn a_repointed_send_starts_at_its_prepared_value_at_eight_lanes() { + a_repointed_send_starts_at_its_prepared_value(Backend::Simd8); +} + +#[test] +fn a_repointed_send_starts_at_its_prepared_value_at_four_lanes() { + a_repointed_send_starts_at_its_prepared_value(Backend::Simd4); +} + +/// Gate 2c: B changes `eq0`'s delay and re-points `eq3`'s send. The restart set is exactly the +/// delay-changed strip. +fn only_the_delay_changed_strip_is_restarted(backend: Backend) { + let feed = Feed::new(SOURCE, 1); + let a_model = send_session(true); + let mut b_model = with_strip_delay(a_model.clone(), "eq0", [37, 60]); + route_mut(&mut b_model, SEND).destination = session::RouteDestination::SubmixInput { + submix_id: id("bus2"), + }; + let (a, b) = (Session::compile(a_model), Session::compile(b_model)); + let run = effect_run( + &a, + Some((&b, &a.model)), + &feed, + &send_writes(), + &[], + backend, + ); + assert_clean_swap(&run, &format!("{backend:?}")); + assert_eq!(run.restarted, vec![Box::::from("eq0")], "{backend:?}"); +} + +#[cfg(target_feature = "avx2")] +#[test] +fn only_the_delay_changed_strip_is_restarted_at_eight_lanes() { + only_the_delay_changed_strip_is_restarted(Backend::Simd8); +} + +#[test] +fn only_the_delay_changed_strip_is_restarted_at_four_lanes() { + only_the_delay_changed_strip_is_restarted(Backend::Simd4); +} + +/// Gate 5 (#1225 D8): B is A plus a muted track that sorts first, and changes the carried send's +/// gain. Run 1 is the structural swap, whose successor retargets the carried send to B's gain over +/// the session's send ramp. Run 2 writes the same value to A just before the swap and prepares B +/// from a base that already holds it. Every block of the two runs is bit-identical. Red if a +/// carried send keeps the predecessor's level (a missing retarget), or if the retarget lands a +/// block early or late. +fn a_carried_send_is_retargeted_to_the_successors_gain(backend: Backend) { + const GAIN: f32 = -7.5; + let feed = Feed::new(SOURCE, 1); + let a_model = send_session(true); + let mut changed = a_model.clone(); + route_mut(&mut changed, SEND).gain_db = GAIN; + let b_model = with_muted_output_track(changed.clone()); + let (a, b) = (Session::compile(a_model), Session::compile(b_model)); + let holds = Session::compile(changed); + let length = LiveRamps::for_session(&b.model).resolve(LiveRampRow::SendGain, None); + assert!(length > 0, "the session's send ramp is not a step"); + // The record in flight at the swap, as in gate 1. + let mut run_one_writes = send_writes(); + run_one_writes.pop(); + let mut run_two_writes = run_one_writes.clone(); + run_two_writes.push(EffectWrite { + block: SWAP_BLOCK, + strip: "", + address: EQ_ADDRESS, + value: EffectValue::SendGain { + route: SEND, + gain_db: GAIN, + length, + }, + }); + let one = effect_run( + &a, + Some((&b, &a.model)), + &feed, + &run_one_writes, + &[], + backend, + ); + let two = effect_run( + &a, + Some((&b, &holds.model)), + &feed, + &run_two_writes, + &[], + backend, + ); + assert_clean_swap(&one, &format!("{backend:?} run 1")); + assert_clean_swap(&two, &format!("{backend:?} run 2")); + assert_eq!(one.restarted, Vec::>::new(), "{backend:?}"); + assert_eq!(two.restarted, Vec::>::new(), "{backend:?}"); + assert_eq!( + first_effect_difference(&one, &two), + None, + "{backend:?}: the retarget equals a live write before the swap" + ); + // Not vacuous: a swap that leaves the send at the predecessor's level differs. + let stale_b = Session::compile(with_muted_output_track(send_session(true))); + let stale = effect_run( + &a, + Some((&stale_b, &a.model)), + &feed, + &run_one_writes, + &[], + backend, + ); + assert_ne!( + stale.blocks[SWAP_BLOCK + 1], + one.blocks[SWAP_BLOCK + 1], + "{backend:?}: the new gain is audible after the swap block" + ); +} + +#[cfg(target_feature = "avx2")] +#[test] +fn a_carried_send_is_retargeted_to_the_successors_gain_at_eight_lanes() { + a_carried_send_is_retargeted_to_the_successors_gain(Backend::Simd8); +} + +#[test] +fn a_carried_send_is_retargeted_to_the_successors_gain_at_four_lanes() { + a_carried_send_is_retargeted_to_the_successors_gain(Backend::Simd4); +} diff --git a/crates/host-core/tests/support/successor.rs b/crates/host-core/tests/support/successor.rs index 47b5d206d..e215e53ee 100644 --- a/crates/host-core/tests/support/successor.rs +++ b/crates/host-core/tests/support/successor.rs @@ -116,8 +116,13 @@ pub(crate) struct Feed { impl Feed { pub(crate) fn new(id: &'static str, seed: u32) -> Self { + Self::with_blocks(id, seed, BLOCKS) + } + + /// [`Self::new`] over `blocks` blocks, for a run longer than [`BLOCKS`]. + pub(crate) fn with_blocks(id: &'static str, seed: u32, blocks: usize) -> Self { let plane = |channel: u32| { - (0..(QUANTUM * BLOCKS) as u32) + (0..(QUANTUM * blocks) as u32) .map(|frame| { let step = frame .wrapping_mul(7_919) diff --git a/crates/rack/src/lib.rs b/crates/rack/src/lib.rs index 3edaaaf8f..af3e27ff5 100644 --- a/crates/rack/src/lib.rs +++ b/crates/rack/src/lib.rs @@ -1615,11 +1615,12 @@ impl LiveControlEffectBankStage { true } - /// Whether a per-node instance with control lane `control` could exchange its state with this - /// stage's lane `lane` in a plan swap, in either direction (#1281): the lane exists and is of - /// the instance's kind, the two control lanes have one cell shape, and `scratch_bytes` holds - /// the payload. The two layouts agree by the join that named the move, which compares them - /// where both are known. Allocation-free and bounded. + /// Whether the per-node instance with control lane `control` could be moved into this stage's + /// lane `lane` in a plan swap (#1281): the lane exists and is of the instance's kind, the + /// lane's carried slots are installed ([`EffectControlLane::install_carry_slots`]) and the two + /// control lanes have one cell shape, and `scratch_bytes` holds the payload. The two layouts + /// agree by the join that named the move, which compares them where both are known. + /// Allocation-free and bounded. #[must_use] pub fn can_carry_instance( &self, @@ -1636,6 +1637,25 @@ impl LiveControlEffectBankStage { .is_ok_and(|payload| payload <= scratch_bytes) } + /// Whether this stage's lane `lane` could be moved into the per-node instance with control + /// lane `control` in a plan swap (#1281): [`Self::can_carry_instance`] with the direction + /// reversed, so the instance's carried slots are the ones that must be installed. + #[must_use] + pub fn can_carry_lane_into_instance( + &self, + lane: usize, + control: &EffectControlLane, + scratch_bytes: usize, + ) -> bool { + self.lanes + .get(lane) + .and_then(Option::as_ref) + .is_some_and(|own| control.can_carry_from(own)) + && self.lane_kind(lane) == Some(EffectLaneKind::of_instance(control)) + && usize::try_from(self.layout.payload_bytes()) + .is_ok_and(|payload| payload <= scratch_bytes) + } + /// Move the per-node instance `from` into this stage's lane `lane` at the swap block (#1281 /// D2): its whole state payload through `scratch` (the instance's snapshot into the bank's /// per-lane restore, a ramp in flight included), then its control lane @@ -1695,7 +1715,7 @@ impl LiveControlEffectBankStage { /// whether the instance took it; `false` leaves the instance as prepared, at rest, and the /// caller counts the refusal (D6). /// - /// The caller has checked [`Self::can_carry_instance`], and has disengaged this stage's chain + /// The caller has checked [`Self::can_carry_lane_into_instance`], and has disengaged this stage's chain /// ([`BankChain::disengage_for_carry`], D3), so a collapsed bank's right channel holds the /// state a dual run would and the instance continues it bit for bit. Reads this stage only: /// it never renders again. Allocation-free and bounded by the prepared state size and the @@ -1706,7 +1726,7 @@ impl LiveControlEffectBankStage { into: EffectInstanceMut<'_>, scratch: &mut [u8], ) -> bool { - if !self.can_carry_instance(lane, into.control, scratch.len()) { + if !self.can_carry_lane_into_instance(lane, into.control, scratch.len()) { return false; } if carry_payload_into_instance( @@ -1736,6 +1756,24 @@ impl LiveControlEffectBankStage { } // REALTIME_POLICY_END + /// The bytes [`Self::install_carry_slots`] allocates for lane `lane`, and the lane holds once + /// it has: none for a lane that is absent or has no cells (#1633 D1). + #[must_use] + pub fn carry_slot_bytes(&self, lane: usize) -> u64 { + self.lanes + .get(lane) + .and_then(Option::as_ref) + .map_or(0, EffectControlLane::carry_slot_bytes) + } + + /// Allocate the carried slots of lane `lane`, the successor lane of a plan swap's move. Off the + /// render thread; a lane that is absent or has no cells takes none (#1633 D1). + pub fn install_carry_slots(&mut self, lane: usize) { + if let Some(control) = self.lanes.get_mut(lane).and_then(Option::as_mut) { + control.install_carry_slots(); + } + } + /// `Observe` records refused because this slot has no observation capacity. Read off render. #[must_use] pub const fn unbound_observations(&self) -> u64 { @@ -2008,6 +2046,15 @@ impl LiveControlEffectBankStage { unbound: Some(&mut self.unbound), }) } + + /// Lane `lane`'s observation tap state, if the slot has observation capacity and the lane has + /// a tap (#1327 D2): the part of the lane a plan swap exchanges. Allocation-free. + pub fn observation_mut(&mut self, lane: usize) -> Option<&mut ObservationLane> { + self.observations + .as_deref_mut() + .and_then(|lanes| lanes.get_mut(lane)) + .and_then(Option::as_mut) + } // REALTIME_POLICY_END // REALTIME_POLICY_BEGIN diff --git a/docs/handoffs/stored-automation-1058/README.md b/docs/handoffs/stored-automation-1058/README.md index 5a6d603bd..032e6752e 100644 --- a/docs/handoffs/stored-automation-1058/README.md +++ b/docs/handoffs/stored-automation-1058/README.md @@ -22,12 +22,12 @@ Decision 15 binds every choice here to the no-shortcuts principle (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md:26-28`). A1 and A3-A11 are design decisions for the implementing issues. A2 is an owner question with a recommendation. A second owner question (send automation) is in "Owner questions". Points that -touch a decided item are in "Findings for the coordinator". F1 (filter designs in render; slices -16a, 16b and 20) is decided by root: option A (2026-10-14), its contract amendment for owner review +touch a decided item are in "Findings for the coordinator". F1 (filter designs in render; +#1659, #1660 and #1667) is decided by root: option A (2026-10-14), its contract amendment for owner review (section "Root's rulings (2026-10-14)"). F16 (the VCA offsets cell's layout) is decided by root: it sums the VCA offsets first, a class B change to #1242's order that root rules under the standing summation-order ruling, so every VCA edit stays live on a session -with stored fader automation (slices 09a and 11). No other point reopens a decided item. +with stored fader automation (#1646 and #1649). No other point reopens a decided item. **Review.** In attempt 1 two fresh internal verifiers reviewed the drafts (FAIL, then PASS-WITH-FIXES), and the attempt-1 adversarial verdict returned FAIL (one major, seven minors, @@ -58,20 +58,20 @@ What changes in the design: do (section "Root's rulings (2026-10-14)"). - **Fader dB and input trim dB.** A `SET_CONTROL` on the row of an automated lane writes that lane's stored offset, live, through the lane's offsets cell; render adds it to the curve at - each event. New slice **11b** (batch R1, in the 09a-09b-10-11 push) adds the field, the write - rule, the control list's `automated` flag and the fader render; draft 15 (amended) adds the trim - render. The send gain takes the same rule (root's ruling; draft 11b amended, draft 13d renders - it), and so does a VCA's `offset_db` (draft 13e, a reading of root's ruling for root to confirm). + each event. New slice **#1650** (batch R1, in the #1646-#1649 push) adds the field, the write + rule, the control list's `automated` flag and the fader render; #1658 (amended) adds the trim + render. The send gain takes the same rule (root's ruling; #1650 amended, #1654 renders + it), and so does a VCA's `offset_db` (#1655, a reading of root's ruling for root to confirm). - **Mute.** No new field: the strip's static mute stays a live value and ORs into the stage mute - with the curve, the VCA mutes and the solo term. Drafts 13a-13c are amended; a static mute edit + with the curve, the VCA mutes and the solo term. #1651-#1653 are amended; a static mute edit on an automated lane is a live cell write, not `model_only`. - The A2 analysis below ("No answer undoes anything shipped") assumed a new edit for option C. Under decision 17 the edit is `SET_CONTROL` on the same row, so option A must never ship for the - fader: 11b lands in the same push as 09a-11 (must-land group amended), and draft 15 flips trim + fader: #1650 lands in the same push as #1646-#1649 (must-land group amended), and #1658 flips trim in the same change that renders it. -- **Send rows render.** New slice **13d** (batch R2, after 13c) renders route rows 1-6 of a route - into a submix on draft 13b's route lane. *Amended by root's rulings (2026-10-14):* a hand send gain - move on an automated send writes the send's stored dB offset, a hand send mute ORs (13d D5 +- **Send rows render.** New slice **#1654** (batch R2, after #1653) renders route rows 1-6 of a route + into a submix on #1652's route lane. *Amended by root's rulings (2026-10-14):* a hand send gain + move on an automated send writes the send's stored dB offset, a hand send mute ORs (#1654 D5 confirmed), and a hand matrix edit commits `model_only` (automation wins). `follows_mute` (row 7) becomes not automatable (#1632, root's ruling on finding F20). - **AUTOMATION_ENQUEUE.** #1315 has landed with a feature switch (`ProviderFeatures::automation`, @@ -85,19 +85,19 @@ Recorded by the controls coordinator. Root ruled on the points this note left to - **F1 = A.** Stored automation designs an input-filter or EQ section in render, at events, with the same designer and the same check as preparation; the per-block design count is bounded and the render call is in a realtime-policy region; live edits keep designing off render. The contract - amendment that draft 16a carries is root's application **for owner review**: root tells the - owner. Drafts 16a, 16b and 20 no longer wait (finding F1). + amendment that #1659 carries is root's application **for owner review**: root tells the + owner. #1659, #1660 and #1667 no longer wait (finding F1). - **Sends under OQ1 = C.** A send's `gain_db` is a level control: a hand move of an automated send gain writes a stored dB offset, as on the fader and the trim (the owner's answer covers level controls, and the DAW trim modes include sends). A hand send mute ORs with the automated send - mute. On a send's `channel_matrix` automation wins. Draft 11b (amended) extends the offset field - and its write rule to the send gain; draft 13d (amended) renders the offset in the route cell. + mute. On a send's `channel_matrix` automation wins. #1650 (amended) extends the offset field + and its write rule to the send gain; #1654 (amended) renders the offset in the route cell. - **F20, per row.** - - A VCA's `offset_db` and `mute` are automatable and render: new draft **13e** *Render stored VCA - offset and mute automation* (batch R2, after 13c: the offsets cell of 09a and 11, the hand - offset of 11b, the mute composition of 13a and the follow term of 13b). - - An effect's `bypass` is automatable and renders: new draft **19b** *Render stored effect bypass - automation* (batch R3, after 19: it drives #1341's `BypassSwitch` crossfade at events). + - A VCA's `offset_db` and `mute` are automatable and render: new slice **#1655** *Render stored VCA + offset and mute automation* (batch R2, after #1653: the offsets cell of #1646 and #1649, the hand + offset of #1650, the mute composition of #1651 and the follow term of #1652). + - An effect's `bypass` is automatable and renders: new slice **#1666** *Render stored effect bypass + automation* (batch R3, after #1665: it drives #1341's `BypassSwitch` crossfade at events). - A send's `follows_mute` and a strip's `matrix_mode` are not automatable: a routing setting and a mode, not performance values. No draft; a placeholder issue spec, `.github/ISSUE_SPECS/1632-make-send-follows-mute-and-matrix-mode-non-automatable.md`, makes the @@ -116,16 +116,16 @@ Recorded by the controls coordinator. Root ruled on the points this note left to | Answer | Decision | |---|---| | A1 | The control plane compiles each automated cell into an immutable segment table in the plan. Render evaluates it in node time (the timeline delayed by the node's arrival), at a fixed 64-sample grid and at exact jump samples, and feeds the existing strip ramps, effect `Point` spans (in pieces) and filter targets. Hosts move the timeline with one session seek. | -| A2 | **Decided 2026-10-14: OQ1 = C.** A hand move of an automated fader, input trim or send gain dB (send gain: root's ruling, 2026-10-14) writes a stored dB offset that render adds to the curve (slice 11b, drafts 13d and 15; a VCA's `offset_db` in draft 13e, a reading for root to confirm); a hand mute (strip, send or VCA) ORs with the automated mute (drafts 13a, 13d, 13e); on every other row automation wins: a live edit of an automated cell commits as its fallback value, `model_only`. One rule set in the shared commit, the same on both hosts; no host refuses it, and no host has its own admission path. | +| A2 | **Decided 2026-10-14: OQ1 = C.** A hand move of an automated fader, input trim or send gain dB (send gain: root's ruling, 2026-10-14) writes a stored dB offset that render adds to the curve (#1650, #1654 and #1658; a VCA's `offset_db` in #1655, a reading for root to confirm); a hand mute (strip, send or VCA) ORs with the automated mute (#1651, #1654, #1655); on every other row automation wins: a live edit of an automated cell commits as its fallback value, `model_only`. One rule set in the shared commit, the same on both hosts; no host refuses it, and no host has its own admission path. | | A3 | The table is enough. It gains rules, not shapes: the hold rule, one entry per lane, the pan or matrix form, 64 samples between jumps, the target's unit and domain, shape limits, the filter order, and jump ramps from `control_smoothing`. | | A4 | Memory per automated cell (one lane of one parameter) is `80 + 32·n` bytes, `n` its segment count, owned by the plan and independent of song length. CPU per block is at most `2⌈q/64⌉ + 3` events per cell times a per-row operation count. | | A5 | The same session and the same host operations give the same bits on every target. Scalar `f64` evaluation through `crates/math` at events only, the existing unfused kernels and a grid in node time make it so; after a session seek the automation is independent of when the seek landed and of the quantum. | | A6 | Every route into a submix whose source mute is automated gets a route lane with `follows_mute` as a cell word. At each automated mute event render calls `graph::gated_route_coefficients`, at the render sample where the send's tap carries the curve's timeline sample (A1.3), over the strip's ramp. | | A7 | `stored(i)` is the number of distinct automated cells of instance `i` (two for `both` on a `PerLane` parameter); 0 for a target-owning effect (the EQ). At most the instance's `Block` cell count. | | A8 | No. `AUTOMATION_ENQUEUE` never stages spans. #1306 drops its third term. | -| A9 | The C ABI never serves `AUTOMATION_ENQUEUE`. The refusal is permanent, and the command, its event and its queue leave the protocol registry (#1627-#1629 in batch P1, which needs only #1626; slice 26 retires the span vocabulary no producer uses; slice 22 reserves the C limit). No ack can precede a drop. | -| A10 | The mask becomes per target row. Slice 10 removes the first row (fader) and slice 20 the last (EQ). An automation edit becomes a `rebuild` that carries every owner and completes `exact`; a static-value edit on an automated lane gives no record, on both hosts; a live edit of a group's other cell is a `live` group-cell write from the slice that renders the group. | -| A11 | Forty-one slices in twenty-six steps (drafts in `proposed-specs/`), in seven batches. Every slice that renders stored automation lands after #1382, so its browser half acts on the shared commit. Slices 18a and 18b render stored effect automation; #1306 lands in their batch. *2026-10-14: 43 drafts (11b and 13d added; 03a, 03b and 03c obsolete); #1306 leaves R3 (F23); #1382's role is #1562a and #1562b. Root's rulings, 2026-10-14: 45 drafts (13e and 19b added); the four P1 drafts are filed as #1626-#1629.* | +| A9 | The C ABI never serves `AUTOMATION_ENQUEUE`. The refusal is permanent, and the command, its event and its queue leave the protocol registry (#1627-#1629 in batch P1, which needs only #1626; #1636 retires the span vocabulary no producer uses; #1668 reserves the C limit). No ack can precede a drop. | +| A10 | The mask becomes per target row. #1648 removes the first row (fader) and #1667 the last (EQ). An automation edit becomes a `rebuild` that carries every owner and completes `exact`; a static-value edit on an automated lane gives no record, on both hosts; a live edit of a group's other cell is a `live` group-cell write from the slice that renders the group. | +| A11 | Forty-one slices in twenty-six steps (drafted first, now filed in `.github/ISSUE_SPECS/`), in seven batches. Every slice that renders stored automation lands after #1382, so its browser half acts on the shared commit. #1663 and #1664 render stored effect automation; #1306 lands in their batch. *2026-10-14: 43 drafts (#1650 and #1654 added; 03a, 03b and 03c obsolete); #1306 leaves R3 (F23); #1382's role is #1562a and #1562b. Root's rulings, 2026-10-14: 45 drafts (#1655 and #1666 added); the four P1 drafts are filed as #1626-#1629.* | ## What the design starts from @@ -203,7 +203,7 @@ Recorded by the controls coordinator. Root ruled on the points this note left to `K = ⌈A_max/q⌉ + 1` and `A_max` is the plan's largest node arrival (A1.3). - **It is carried like a source.** Across a swap (#1273), at a declared discontinuity (#1323), at a prime adoption (#1320: it advances by the prime like a source) and on the source-read clock - (#1396), its history included. Those specs land first and are not amended: slice 04b adds the + (#1396), its history included. Those specs land first and are not amended: #1641 adds the timeline to each mechanism they build, and depends on them. - **Session seek.** A host moves the playhead with one function both hosts call, in `host-core`'s `SourceControlSet` (#1309 moves only the C ABI wrapper): generation `g`, timeline sample `T`, and an optional anchor `A` on the source-read clock. @@ -215,7 +215,7 @@ Recorded by the controls coordinator. Root ruled on the points this note left to #1323 D1 still asks the host to declare a discontinuity before a seek of every source. - **Per-source seeks stay**, for a stem a transaction adds (`crates/capi/include/miso_engine_v1.h:96-108`). They never move the timeline. The timeline's seek lands in #1316's seek report as a row of its own - (slice 04b). + (#1641). - **Loop.** The engine adds no loop mechanism. A host loops by an anchored session seek at the end of each pass, as it would for sources. An anchor is a quantum multiple (`crates/source/src/lib.rs:772-777`), so a loop seam lands only on a block boundary: loop points @@ -224,7 +224,7 @@ Recorded by the controls coordinator. Root ruled on the points this note left to - **The protocol's transport position.** `TRANSPORT_SET` stores a position nothing reads (`crates/host-core/src/control_provider.rs:424-438`). A second, settable playhead beside the timeline would contradict it and cannot move it, since a seek needs the host to refill every - ring. Slice 25 retires that field; the transport keeps its state (finding F4). + ring. #1635 retires that field; the transport keeps its state (finding F4). ### A1.3 Node time @@ -254,7 +254,7 @@ Recorded by the controls coordinator. Root ruled on the points this note left to coefficient ramps, which reach the target at `A + 64`, `docs/EFFECT_CONTRACT_V1.md:165-166`). A ramp of length `L = 0` is a step: the lane outputs the target at `τ` itself, so its completion sample is `τ`, never `τ - 1`. That is the case of an effect parameter whose smoothing rule is - `None` (the gate's hold after #1336; draft 18a D2 uses `τ` for it) and of a jump under an + `None` (the gate's hold after #1336; #1663 D2 uses `τ` for it) and of a jump under an `explicit` 0 of `control_smoothing` (A3). `L` is 64 for strip rows and filter targets and the descriptor's `smoothing_samples` for an effect parameter. So the lane equals the curve at each ramp's completion sample and is linear between: @@ -303,8 +303,8 @@ Recorded by the controls coordinator. Root ruled on the points this note left to lane then has a cell of three words (`S` and a ramp word) that the control plane writes on any VCA edit; a lane no VCA reaches holds `S = +0.0`, which keeps the gain bits (`db_gain(±0) = 1`). A flat curve at `v` renders the bits of a plan prepared with static `v`, whatever the number of - VCAs, and every VCA edit, adding or removing a VCA included, is a live cell write (slices 09a and - 11). + VCAs, and every VCA edit, adding or removing a VCA included, is a live cell write (#1646 and + #1649). - **Mute** (id 6): the lane's stage mute is `curve || vca_mute || solo_mute`; the last two are control-plane terms in the strip's mute cell (`crates/host-core/src/solo.rs:255-263` composes them today; after #1382 the shared commit composes the browser's solo overlay, #1382 D3). A mute event ramps over the session mute length, as a live mute does @@ -347,7 +347,7 @@ Recorded by the controls coordinator. Root ruled on the points this note left to end at each event sample. At the start of each piece, each cell with an event there stages one `Point` at that piece's first sample, the only shape the contract accepts (`crates/effect-contract/src/lib.rs:1496-1499`). No effect changes if every launch effect is - partition-invariant with spans; conformance checks it only without spans, so slice 17a gates it at + partition-invariant with spans; conformance checks it only without spans, so #1661 gates it at widths 1, 4 and 8 against the scalar oracle, and depends on #1069, an open bank defect of exactly this kind. The delay's crossfade starts only at chunk starts (`crates/delay/src/lib.rs:1276-1285`), and a piece start is a chunk start. @@ -398,7 +398,7 @@ Recorded by the controls coordinator. Root ruled on the points this note left to retarget). The stage state itself carries under #1277 and #1279-#1282. - **The program is not a control kind.** Gaining, losing or changing stored automation, and the span window capacity it changes (A7), are neither a prepared value nor a change of live-control kind, so - the carry slices carry the stage. Slice 10 extends #1277 D4's carry predicate for it, and slice 19 + the carry slices carry the stage. #1648 extends #1277 D4's carry predicate for it, and #1665 extends #1279 D1-D2 and #1280 D1; those specs land first and are not amended. ## A2. Live changes against automation @@ -407,7 +407,7 @@ Recorded by the controls coordinator. Root ruled on the points this note left to an offset on level rows (fader dB, input trim dB), an OR on mute, and automation wins on every other row. The text below was written before the answer; it stays as the record of the analysis. Where it says "option A ships first", read: option A is the rule for the rows where automation wins, and -slice 11b and draft 13a (amended) give the fader, trim and mute rows answer C in the same pushes +#1650 and #1651 (amended) give the fader, trim and mute rows answer C in the same pushes that render them. What ships before the ruling, and stays under every option. It is one permanent rule, written once @@ -427,7 +427,7 @@ in the shared commit of `crates/control-plane` (#1309), so both hosts apply it b transaction edits by stable ID (the #1057 note, its finding F4); the drafts' browser gates name "a browser live edit through the Worker's apply", so they hold under either. So a browser fader, mute, pan, trim, filter or effect edit on an automated cell replies `model_only`, as the same - C ABI transaction does. The first slice that renders stored automation (09a) depends on + C ABI transaction does. The first slice that renders stored automation (#1646) depends on #1382, so no host ever renders a curve while a host-only path could write a live record onto it. No slice adds a browser refusal or a browser-only reason. - **Why not a typed refusal.** The alternative rule is one typed refusal in the shared commit, for @@ -437,14 +437,14 @@ in the shared commit of `crates/control-plane` (#1309), so both hosts apply it b on a stored value, so a refusal is the one rule that a B or C answer would have to undo. The `model_only` reply already tells the caller that nothing was heard. - **Terms that compose.** A VCA ride on an automated member writes the lane's offsets cell (A1.5, - slice 11). A VCA mute or a solo on an automated mute lane writes the mute cell's `terms` word - (slice 13a). A live edit of a group's other cell writes the group cell (A1.5). None of them is a + #1649). A VCA mute or a solo on an automated mute lane writes the mute cell's `terms` word + (#1651). A live edit of a group's other cell writes the group cell (A1.5). None of them is a live record on an automated cell. - This is option A of OQ1. **No answer undoes anything shipped:** - Under every option the existing static-value edit keeps its meaning: it sets the fallback value, gives no record and commits `model_only`. Options B, C and D add their behaviour only through a - new edit (B's override, C's offset, D's touch). So draft 10's gate 5 and its equivalents in - 13a, 14b, 15, 16b and 19 stay true under every answer, and nothing shipped changes meaning. + new edit (B's override, C's offset, D's touch). So #1648's gate 5 and its equivalents in + #1651, #1657, #1658, #1660 and #1665 stay true under every answer, and nothing shipped changes meaning. - Options B and C add a stored field per automated cell and an edit that sets it. Render reads the field at events, as it reads a group cell. The rules above stay true: the fallback value is still stored and not heard, a static edit still gives no record, and an automated cell still @@ -455,7 +455,7 @@ in the shared commit of `crates/control-plane` (#1309), so both hosts apply it b reads a group cell: while the flag is set, the cell's events use the touch value; on release the cell jumps back to the curve. It adds one cell per automated cell and one host overlay edit, and changes nothing shipped: an automated cell still takes no live record (A10), a piece still - holds at most one span per cell (A7, slice 17b D5), and #1306's live term still counts only + holds at most one span per cell (A7, #1662 D5), and #1306's live term still counts only cells the session does not automate. If D were built instead as a live record on an automated cell, it would undo all three; this note rules that form out. @@ -552,7 +552,7 @@ a session seek, stored automation is also independent of when the seek landed an - **Sample-time arithmetic.** `u64` timeline and render samples; `t - t0` and `t1 - t0` convert to `f64` exactly below 2^53; one `f64` division gives `x`. - **Transcendentals.** Exponential segments use `math::pow` in `f64`; conversions use `db_gain`, - `pan_matrix`, the filter designers and `vca_compose_db` (draft 09a D6). `crates/math` is vendored libm with every + `pan_matrix`, the filter designers and `vca_compose_db` (#1646 D6). `crates/math` is vendored libm with every target-conditional path and intrinsic removed (`crates/math/src/lib.rs:13-16`), and `clippy.toml` bans platform transcendentals (`clippy.toml:15-23`). The evaluator calls no lane math, so the AArch64 release divergence in `exp2_lane` (LANE-3, #1019; `docs/TARGET_MATRIX.md:163-167`) cannot @@ -565,14 +565,14 @@ a session seek, stored automation is also independent of when the seek landed an none. Finding F11 covers control-thread designs that exist today. - **Lanes and banks.** Ramps and kernels are the existing ones, identical at widths 1, 4 and 8 and across x86-64, AArch64 and wasm (`tools/wasm-gate-corpus/src/lib.rs:4-5`). Effect pieces rely on - partition invariance with spans, which slice 17a gates. Banking moves no bit (decision 12). + partition invariance with spans, which #1661 gates. Banking moves no bit (decision 12). - **Events, not samples.** Linear and exponential segments are both evaluated only at events, never per sample, so no per-sample transcendental exists to diverge. - **After a seek.** Builtin lanes are set exactly at the seek's sample, so they are a function of the session and the seek target from that sample on. Effect parameters are after their smoothing length (64 or 128 samples), and effect state after the pre-seek audio's tail reaches exact rest (D15-4). -- **Gates.** Slice 07 adds the evaluator's event values to the G5 corpus, the single owner of the - cross-target corpus (widths 1, 4 and 8; x86-64, wasm and AArch64). Slices 23a and 23b add a session with every +- **Gates.** #1637 adds the evaluator's event values to the G5 corpus, the single owner of the + cross-target corpus (widths 1, 4 and 8; x86-64, wasm and AArch64). #1669 and #1670 add a session with every row automated to the browser direct oracle (`hosts/host-web/tests/browser-v1/direct-oracle.mjs:571-583`) and an AArch64 equality leg, since no session digest is compared on AArch64 today, and a seek-time invariance case. @@ -595,7 +595,7 @@ ramp.** the source's other mute terms: VCA mute, solo mute, and the static mute of a lane whose mute is not automated. The shared commit writes them through the route's cell (#1347) whenever a route, VCA, solo, static mute or `follows_mute` edit changes them, on both hosts, so a live `follows_mute` - toggle (D15-6) is a cell write (slice 13c). + toggle (D15-6) is a cell write (#1653). - **When the send's gate changes.** The route op runs before the send's compensation delay (`crates/graph/src/runtime.rs:916-920`), so at render sample `r` it carries the tap's audio of timeline sample `τ_tap(r) = timeline(r + ΣP - a(tap))`, where `a(tap)` is the arrival of the @@ -615,9 +615,9 @@ ramp.** samples of the audio the strip mutes: the leak this answer exists to prevent. #1226 D5's render-time rule stays the rule for a live mute, which has no timeline sample. - **No leak.** A saved session with automated mute and pre-fader sends silences those sends exactly - when the mute engages. Slice 13b's gates compare the output, after the ramp, with a plan prepared + when the mute engages. #1652's gates compare the output, after the ramp, with a plan prepared with the same mute as a static value, and check the send's and the strip's ramps on the same - timeline sample with a latent insert; slice 13c's gate toggles `follows_mute` both ways on a + timeline sample with a latent insert; #1653's gate toggles `follows_mute` both ways on a playing engine. - **Out of scope by rule.** Every route into the output never follows (`follows_mute` is always false there, decision 13 P11). @@ -674,10 +674,10 @@ leaves the protocol registry.** replace #1315 D1-D3, so no feature switch is added only to be deleted, and they land no later than #1315's C ABI half. P1 needs only #1626 (the hold rule's new home, before #1629 deletes the old one) and #1309, not the rendering batches, so the acked-with-no-effect command leaves as early as - #1315 can. Slice 22 reserves the C field after #1306. + #1315 can. #1668 reserves the C field after #1306. - **The vocabulary that served it.** With the command gone, nothing produces a `Step`, `Linear` or `Exponential` span or a sample-rate parameter: live records and stored automation stage only - `Point` spans (A1.6). Under the same removal ruling, slice 26 retires those span kinds, + `Point` spans (A1.6). Under the same removal ruling, #1636 retires those span kinds, `automation_segment_value` and `AutomationRate::Sample` (finding F6). - **The acked-batch question: can an ack ever precede a drop?** No. The command is refused at decode, before the revision check and before any state changes, and no queue remains to hold a batch. @@ -688,9 +688,9 @@ leaves the protocol registry.** ## A10. The classifier mask, for #1260 **Decision: the mask becomes per target row** (`crates/host-core/src/live_delta.rs:181-185`, -`:234-236`). Slice 10, *Classify fader automation edits as carried rebuilds*, removes the first row -(fader) in the same batch as slices 09a and 09b, which render it. Each later rendering slice removes its own -row, and slice 20 (the EQ) removes the last. A row that does not render yet stays model-only and +`:234-236`). #1648, *Classify fader automation edits as carried rebuilds*, removes the first row +(fader) in the same batch as #1646 and #1647, which render it. Each later rendering slice removes its own +row, and #1667 (the EQ) removes the last. A row that does not render yet stays model-only and inert, as today. For a row that renders: - **An automation edit is a `rebuild`.** The successor needs a new compiled program: decision 14 @@ -700,24 +700,24 @@ inert, as today. For a row that renders: - **A static-value edit on an automated lane gives no record** (A2): alone it is `model_only`. A transaction that also edits automation is a rebuild. - **A group cell edit** (A1.5) and a live `follows_mute` toggle on a following route (A6) are `live`. - The slice that renders a group also adds its group-cell write (14b, 16b, 20), so no interim rule + The slice that renders a group also adds its group-cell write (#1657, #1660, #1667), so no interim rule classifies such an edit as a rebuild. - **One rule on both hosts.** The classifier is the shared one, which the browser calls in its Worker after #1382; no host has another admission path for an automated cell (A2). - **A `control_smoothing` edit** reaches the program's jump lengths through one plan-level cell - (slice 12), so on a session with stored automation it is `live` (slice 12 extends the classifier + (#1639), so on a session with stored automation it is `live` (#1639 extends the classifier path #1365 adds). - A refusable target never commits through a masked row: #1335 D4 routes an automation edit whose - effect diagnostics are non-empty to the rebuild path, whose preparation refuses it, and slice 02 - adds the builtin rules to the same route. #1335 lands before slice 09a (D15-13 E1). Once slice 20 - removes the last row, every automation edit is a rebuild and slice 20 deletes the route. + effect diagnostics are non-empty to the rebuild path, whose preparation refuses it, and #1645 + adds the builtin rules to the same route. #1335 lands before #1646 (D15-13 E1). Once #1667 + removes the last row, every automation edit is a rebuild and #1667 deletes the route. ## A11. The staged plan **Decision: forty-one slices in twenty-six numbered steps (steps that would not fit half a working -day are split into lettered parts), each a stateless draft in `proposed-specs/`, smallest first -inside each batch. Every slice that renders stored automation lands after #1382 (through slice -09a), so no slice adds browser-only code that #1382 deletes. Slices 18a and 18b, *Compile and bind stored effect parameter automation* and +day are split into lettered parts), each a stateless spec (drafted first, now filed in `.github/ISSUE_SPECS/`), smallest first +inside each batch. Every slice that renders stored automation lands after #1382 (through +#1646), so no slice adds browser-only code that #1382 deletes. #1663 and #1664, *Compile and bind stored effect parameter automation* and *Render stored effect parameter automation across seeks and on both hosts*, render stored effect automation; #1306 lands in their batch.** The table, batches and dependencies are in "Staged plan". @@ -785,91 +785,96 @@ Per block of `q` frames: Σ_pieces (call) + Σ_moving banks (ramping-path delta)`. Each sum is bounded by `q`, the bank widths and the automated cell count, and the seek term by `log2` of a cell's own segment count; none reads the song length. -- **Measurement.** This issue runs no benchmark. Slices 24a and 24b add and record one descriptive row on the real console +- **Measurement.** This issue runs no benchmark. #1671 and #1672 add and record one descriptive row on the real console path (frozen workload, one warmup, two measured rounds). ## Staged plan -Each row lists the slice's direct dependencies; a draft's own Dependencies section says the same. +Each row lists the slice's direct dependencies; a spec's own Dependencies section says the same. +Every slice that is not obsolete is filed: its spec is in `.github/ISSUE_SPECS/` under its issue +number, which the # column gives. `proposed-specs/` now holds only the three obsolete drafts 03a, +03b and 03c (not filed), kept as the record of why they are obsolete (section "Check against the +batch 9 head"). -| # | Draft | Title | Depends on | Batch | +| # | Spec | Title | Depends on | Batch | |---|---|---|---|---| -| #1626 | `.github/ISSUE_SPECS/1626-validate-automation-lanes-in-the-session.md` (filed) | Validate stored automation lanes in the session crate and state the hold rule | — | P1 | -| 02 | `proposed-specs/02-validate-builtin-automation-targets-at-preparation.md` | Validate builtin automation targets against their rows at preparation | #1626, 07, #1335 | R1 | -| 03a | `proposed-specs/03a-validate-effect-automation-units-domains-and-shapes.md` | **Obsolete 2026-10-14.** Validate effect automation units, domains and shapes at preparation | #1626, 02, #1335 | R3 | -| 03b | `proposed-specs/03b-mirror-the-automation-rules-in-the-sdk-builder.md` | **Obsolete 2026-10-14.** Mirror the stored automation rules in the SDK builder | #1626, 02, 03a | R3 | +| #1626 | `.github/ISSUE_SPECS/1626-validate-automation-lanes-in-the-session.md` | Validate stored automation lanes in the session crate and state the hold rule | — | P1 | +| #1645 | `.github/ISSUE_SPECS/1645-validate-builtin-automation-targets-at-preparation.md` | Validate builtin automation targets against their rows at preparation | #1626, #1637, #1335 | R1 | +| 03a | `proposed-specs/03a-validate-effect-automation-units-domains-and-shapes.md` | **Obsolete 2026-10-14.** Validate effect automation units, domains and shapes at preparation | #1626, #1645, #1335 | R3 | +| 03b | `proposed-specs/03b-mirror-the-automation-rules-in-the-sdk-builder.md` | **Obsolete 2026-10-14.** Mirror the stored automation rules in the SDK builder | #1626, #1645, 03a | R3 | | 03c | `proposed-specs/03c-author-submix-automation-targets-in-the-sdk.md` | **Obsolete 2026-10-14.** Author submix automation targets in the SDK and enginectl | — | R3 | -| 04a | `proposed-specs/04a-build-the-timeline-consumer-and-producer.md` | Build the timeline consumer and producer in the source crate | ordering: #1316, #1318, #1320 | R1 | -| 04b | `proposed-specs/04b-give-every-plan-a-timeline-that-carries-like-a-source.md` | Give every plan a timeline that carries like a source | 04a, #1285, #1316, #1320, #1323, #1396 | R1 | -| 05 | `proposed-specs/05-seek-the-session-from-the-c-abi.md` | Seek the timeline and every source in one C ABI call | 04b, #1309, #1317, #1319, #1323 | R1 | -| 06a | `proposed-specs/06a-seek-the-session-from-the-headless-engine.md` | Seek the timeline and every source from the browser module export and the headless SDK | 05, #1293 | R1 | -| 06b | `proposed-specs/06b-seek-the-session-from-the-browser-sdk.md` | Seek the timeline and every source from the browser SDK and the PCM feed | 06a, #1332, #1387, #1294 | R1 | -| 07 | `proposed-specs/07-compile-stored-automation-into-per-cell-events.md` | Compile stored automation into per-cell events in node time | #1626 | R1 | -| 08 | `proposed-specs/08-apply-timed-operations-inside-a-block-on-the-fader-stage.md` | Apply timed operations inside a block on the strip fader stage | — | R1 | -| 09a | `proposed-specs/09a-prepare-stored-fader-automation.md` | Prepare stored fader automation and render it flat | #1626, 02, 07, 08, 12, #1054, #1285, #1309, #1312, #1382, one push with 09b, 10, 11 | R1 | -| 09b | `proposed-specs/09b-render-moving-stored-fader-automation.md` | Render moving stored fader automation, seeks and latency | 09a, 04b, 05, 06a; one push with 09a, 10, 11 | R1 | -| 10 | `proposed-specs/10-classify-and-carry-fader-automation-edits.md` | Classify fader automation edits as carried rebuilds | 09b, #1277, #1313, #1314, #1335; one push with 09a, 09b, 11 | R1 | -| 11 | `proposed-specs/11-compose-vca-offsets-with-stored-fader-automation.md` | Compose VCA offsets with stored fader automation | 09a, 10, 12, one push with 09a, 09b, 10 | R1 | -| 11b | `proposed-specs/11b-offset-an-automated-fader-or-trim-by-a-hand-move.md` | Store a hand move on an automated fader or trim as a dB offset (added 2026-10-14, OQ1 = C) | 09a, 10, 11, #1566; one push with 09a, 09b, 10, 11 | R1 | -| 12 | `proposed-specs/12-hold-the-automation-jump-lengths-in-a-cell.md` | Hold the automation jump lengths in a plan cell | #1054, #1312, #1365, #1382 | R1 | -| 13a | `proposed-specs/13a-render-stored-mute-automation.md` | Render stored mute automation on the strip | 10, 12; one push with 13b and 13c | R2 | -| 13b | `proposed-specs/13b-follow-an-automated-mute-on-following-sends-in-render.md` | Follow an automated mute on following sends in render | 13a, 12, #1284, #1347 | R2 | -| 13c | `proposed-specs/13c-write-a-following-sends-route-cell-from-the-shared-commit.md` | Write a following send's route cell from the shared commit | 13b, #1342 | R2 | -| 13d | `proposed-specs/13d-render-stored-send-gain-mute-and-matrix-automation.md` | Render stored send gain, mute and matrix automation (added 2026-10-14, OQ2 = yes) | 13c, 10, 11b, 02 | R2 | -| 13e | `proposed-specs/13e-render-stored-vca-offset-and-mute-automation.md` | Render stored VCA offset and mute automation (added 2026-10-14, root's F20 ruling) | 13c, 11, 11b, 10, 12, 02 | R2 | -| 14a | `proposed-specs/14a-retarget-the-matrix-stage-inside-a-block.md` | Retarget the matrix stage inside a block | 08 | R2 | -| 14b | `proposed-specs/14b-render-stored-pan-and-matrix-automation.md` | Render stored pan and matrix automation | 14a, 10, 12 | R2 | -| 15 | `proposed-specs/15-render-stored-input-trim-and-polarity-automation.md` | Render stored input trim and polarity automation | 10, 12, #1261, #1346 | R2 | -| 16a | `proposed-specs/16a-design-an-input-filter-section-in-render.md` | Design an input filter section in render | 15, #1407, F1 = A (root, 2026-10-14) | R2 | -| 16b | `proposed-specs/16b-render-stored-input-filter-automation.md` | Render stored input HPF and LPF automation | 16a, #1262, #1329, #1346, F1 = A (root, 2026-10-14) | R2 | -| 17a | `proposed-specs/17a-prove-effects-partition-invariant-with-point-spans.md` | Prove every launch effect partition-invariant with Point spans | #1069 | R3 | -| 17b | `proposed-specs/17b-process-an-effect-node-in-pieces-at-automation-events.md` | Process an effect node in pieces at automation events | 04b, 07, 17a, #1345 | R3 | -| 18a | `proposed-specs/18a-compile-and-bind-stored-effect-automation.md` | Compile and bind stored effect parameter automation | 03a, 10, 17b, #1306, #1345; one push with #1306, 18b, 19 | R3 | -| 18b | `proposed-specs/18b-render-stored-effect-automation-across-seeks-and-hosts.md` | Render stored effect parameter automation across seeks and on both hosts | 18a; one push with #1306, 18a, 19 | R3 | -| 19 | `proposed-specs/19-classify-and-carry-effect-automation-edits.md` | Classify and carry effect automation edits | 18b, #1279, #1280; one push with #1306, 18a, 18b | R3 | -| 19b | `proposed-specs/19b-render-stored-effect-bypass-automation.md` | Render stored effect bypass automation (added 2026-10-14, root's F20 ruling) | 19, 18b; ordering: #1622 | R3 | -| 20 | `proposed-specs/20-render-stored-parametric-eq-automation.md` | Render stored parametric EQ automation | 19, #1337, F1 = A (root, 2026-10-14) | R4 | -| #1627 | `.github/ISSUE_SPECS/1627-retire-the-automation-enqueue-command.md` (filed) | Retire the AUTOMATION_ENQUEUE command from the protocol wire and dispatch | #1626, #1309; no later than #1315's C ABI half | P1 | -| #1628 | `.github/ISSUE_SPECS/1628-retire-the-automation-canceled-event.md` (filed) | Retire the AUTOMATION_CANCELED event and the cancellation path | #1627 | P1 | -| #1629 | `.github/ISSUE_SPECS/1629-delete-the-protocol-automation-queue.md` (filed) | Delete the protocol automation queue, its records and counters | #1628 | P1 | -| 22 | `proposed-specs/22-reserve-the-c-abi-automation-span-limit.md` | Reserve the C ABI's automation span limit | #1629, #1306 | P2 | -| 23a | `proposed-specs/23a-compare-stored-automation-across-browser-and-native.md` | Compare stored automation bits across the browser and native hosts | 13c, 14b, 15, 16b, 20, #1399 | Q | -| 23b | `proposed-specs/23b-compare-stored-automation-on-aarch64-and-across-seeks.md` | Compare stored automation bits on AArch64 and across seek times | 23a, #1019 | Q | -| 24a | `proposed-specs/24a-build-the-stored-automation-console-benchmark-row.md` | Build the stored-automation console benchmark row | 20 | Q | -| 24b | `proposed-specs/24b-record-the-stored-automation-console-benchmark-baseline.md` | Record the stored-automation console benchmark baseline | 24a | Q | -| 25 | `proposed-specs/25-retire-the-protocol-transport-position.md` | Retire the protocol's stored transport position | #1628 | P1 | -| 26 | `proposed-specs/26-retire-the-span-kinds-and-sample-rate-no-producer-uses.md` | Retire the automation span kinds and the sample rate that no producer uses | #1629 | P1 | +| #1640 | `.github/ISSUE_SPECS/1640-build-the-timeline-consumer-and-producer.md` | Build the timeline consumer and producer in the source crate | ordering: #1316, #1318, #1320 | R1 | +| #1641 | `.github/ISSUE_SPECS/1641-give-every-plan-a-timeline-that-carries-like-a-source.md` | Give every plan a timeline that carries like a source | #1640, #1285, #1316, #1320, #1323, #1396 | R1 | +| #1642 | `.github/ISSUE_SPECS/1642-seek-the-session-from-the-c-abi.md` | Seek the timeline and every source in one C ABI call | #1641, #1309, #1317, #1319, #1323 | R1 | +| #1643 | `.github/ISSUE_SPECS/1643-seek-the-session-from-the-headless-engine.md` | Seek the timeline and every source from the browser module export and the headless SDK | #1642, #1293 | R1 | +| #1644 | `.github/ISSUE_SPECS/1644-seek-the-session-from-the-browser-sdk.md` | Seek the timeline and every source from the browser SDK and the PCM feed | #1643, #1332, #1387, #1294 | R1 | +| #1637 | `.github/ISSUE_SPECS/1637-compile-stored-automation-into-per-cell-events.md` | Compile stored automation into per-cell events in node time | #1626 | R1 | +| #1638 | `.github/ISSUE_SPECS/1638-apply-timed-operations-inside-a-block-on-the-fader-stage.md` | Apply timed operations inside a block on the strip fader stage | — | R1 | +| #1646 | `.github/ISSUE_SPECS/1646-prepare-stored-fader-automation.md` | Prepare stored fader automation and render it flat | #1626, #1645, #1637, #1638, #1639, #1054, #1285, #1309, #1312, #1382, one push with #1647, #1648, #1649 | R1 | +| #1647 | `.github/ISSUE_SPECS/1647-render-moving-stored-fader-automation.md` | Render moving stored fader automation, seeks and latency | #1646, #1641, #1642, #1643; one push with #1646, #1648, #1649 | R1 | +| #1648 | `.github/ISSUE_SPECS/1648-classify-and-carry-fader-automation-edits.md` | Classify fader automation edits as carried rebuilds | #1647, #1277, #1313, #1314, #1335; one push with #1646, #1647, #1649 | R1 | +| #1649 | `.github/ISSUE_SPECS/1649-compose-vca-offsets-with-stored-fader-automation.md` | Compose VCA offsets with stored fader automation | #1646, #1648, #1639, one push with #1646, #1647, #1648 | R1 | +| #1650 | `.github/ISSUE_SPECS/1650-offset-an-automated-fader-or-trim-by-a-hand-move.md` | Store a hand move on an automated fader or trim as a dB offset (added 2026-10-14, OQ1 = C) | #1646, #1648, #1649, #1566; one push with #1646, #1647, #1648, #1649 | R1 | +| #1639 | `.github/ISSUE_SPECS/1639-hold-the-automation-jump-lengths-in-a-cell.md` | Hold the automation jump lengths in a plan cell | #1054, #1312, #1365, #1382 | R1 | +| #1651 | `.github/ISSUE_SPECS/1651-render-stored-mute-automation.md` | Render stored mute automation on the strip | #1648, #1639; one push with #1652 and #1653 | R2 | +| #1652 | `.github/ISSUE_SPECS/1652-follow-an-automated-mute-on-following-sends-in-render.md` | Follow an automated mute on following sends in render | #1651, #1639, #1284, #1347 | R2 | +| #1653 | `.github/ISSUE_SPECS/1653-write-a-following-sends-route-cell-from-the-shared-commit.md` | Write a following send's route cell from the shared commit | #1652, #1342 | R2 | +| #1654 | `.github/ISSUE_SPECS/1654-render-stored-send-gain-mute-and-matrix-automation.md` | Render stored send gain, mute and matrix automation (added 2026-10-14, OQ2 = yes) | #1653, #1648, #1650, #1645 | R2 | +| #1655 | `.github/ISSUE_SPECS/1655-render-stored-vca-offset-and-mute-automation.md` | Render stored VCA offset and mute automation (added 2026-10-14, root's F20 ruling) | #1653, #1649, #1650, #1648, #1639, #1645 | R2 | +| #1656 | `.github/ISSUE_SPECS/1656-retarget-the-matrix-stage-inside-a-block.md` | Retarget the matrix stage inside a block | #1638 | R2 | +| #1657 | `.github/ISSUE_SPECS/1657-render-stored-pan-and-matrix-automation.md` | Render stored pan and matrix automation | #1656, #1648, #1639 | R2 | +| #1658 | `.github/ISSUE_SPECS/1658-render-stored-input-trim-and-polarity-automation.md` | Render stored input trim and polarity automation | #1648, #1639, #1261, #1346 | R2 | +| #1659 | `.github/ISSUE_SPECS/1659-design-an-input-filter-section-in-render.md` | Design an input filter section in render | #1658, #1407, F1 = A (root, 2026-10-14) | R2 | +| #1660 | `.github/ISSUE_SPECS/1660-render-stored-input-filter-automation.md` | Render stored input HPF and LPF automation | #1659, #1262, #1329, #1346, F1 = A (root, 2026-10-14) | R2 | +| #1661 | `.github/ISSUE_SPECS/1661-prove-effects-partition-invariant-with-point-spans.md` | Prove every launch effect partition-invariant with Point spans | #1069 | R3 | +| #1662 | `.github/ISSUE_SPECS/1662-process-an-effect-node-in-pieces-at-automation-events.md` | Process an effect node in pieces at automation events | #1641, #1637, #1661, #1345 | R3 | +| #1663 | `.github/ISSUE_SPECS/1663-compile-and-bind-stored-effect-automation.md` | Compile and bind stored effect parameter automation | #1645, #1566, #1648, #1662, #1306, #1345; one push with #1306, #1664, #1665 | R3 | +| #1664 | `.github/ISSUE_SPECS/1664-render-stored-effect-automation-across-seeks-and-hosts.md` | Render stored effect parameter automation across seeks and on both hosts | #1663; one push with #1306, #1663, #1665 | R3 | +| #1665 | `.github/ISSUE_SPECS/1665-classify-and-carry-effect-automation-edits.md` | Classify and carry effect automation edits | #1664, #1279, #1280; one push with #1306, #1663, #1664 | R3 | +| #1666 | `.github/ISSUE_SPECS/1666-render-stored-effect-bypass-automation.md` | Render stored effect bypass automation (added 2026-10-14, root's F20 ruling) | #1665, #1664; ordering: #1622 | R3 | +| #1667 | `.github/ISSUE_SPECS/1667-render-stored-parametric-eq-automation.md` | Render stored parametric EQ automation | #1665, #1337, F1 = A (root, 2026-10-14) | R4 | +| #1627 | `.github/ISSUE_SPECS/1627-retire-the-automation-enqueue-command.md` | Retire the AUTOMATION_ENQUEUE command from the protocol wire and dispatch | #1626, #1309; no later than #1315's C ABI half | P1 | +| #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 | +| #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 | +| #1635 | `.github/ISSUE_SPECS/1635-retire-the-protocol-transport-position.md` | Retire the protocol's stored transport position | #1628 | P1 | +| #1636 | `.github/ISSUE_SPECS/1636-retire-the-span-kinds-and-sample-rate-no-producer-uses.md` | Retire the automation span kinds and the sample rate that no producer uses | #1629 | P1 | **Batches** (CI-conscious, one push each; a building block merges in the batch of its first user, so `main` never holds code that nothing calls): -1. **P1, the protocol:** #1626, #1627, #1628, #1629, 25, 26. It needs only #1309 and lands no later than +1. **P1, the protocol:** #1626, #1627, #1628, #1629, #1635, #1636. It needs only #1309 and lands no later than #1315's C ABI half, so the acked-with-no-effect command leaves as early as #1315 can. #1626 is first in it: the hold rule's new home exists before #1629 deletes the old one. -2. **R1, the first rendering:** 02, 04a, 04b, 05, 06a, 06b, 07, 08, 09a, 09b, 10, 11, 12, after P1 +2. **R1, the first rendering:** #1645, #1640, #1641, #1642, #1643, #1644, #1637, #1638, #1646, #1647, #1648, #1649, #1639, after P1 and after #1382. After it, stored fader automation renders on both hosts, both hosts can seek the timeline, the fader row is out of the mask, and a live edit of an automated fader meets one rule on both hosts. -3. **R2, the other strip rows:** 13a, 13b, 13c, 14a, 14b, 15, 16a, 16b. Each brings the in-block - operations of its own stage, on slice 08's model. -4. **R3, effects:** 03a, 03b, 03c, 17a, 17b, 18a, 18b, 19 and #1306. This is the batch D15-5 names. -5. **R4:** 20. -6. **P2:** 22, after R3 (#1306). -7. **Q:** 23a, 23b, 24a and 24b, after R4. +3. **R2, the other strip rows:** #1651, #1652, #1653, #1656, #1657, #1658, #1659, #1660. Each brings the in-block + operations of its own stage, on #1638's model. +4. **R3, effects:** #1661, #1662, #1663, #1664, #1665 and #1306 (and the obsolete drafts 03a, 03b and + 03c, not filed). This is the batch D15-5 names. +5. **R4:** #1667. +6. **P2:** #1668, after R3 (#1306). +7. **Q:** #1669, #1670, #1671 and #1672, after R4. **Amended 2026-10-14** (controls coordinator; details in section "Check against the batch 9 -head"): R1 gains 11b, in the 09a-09b-10-11 push, and needs #1562b (the browser's rebuild path) as -well as #1562a; R2 gains 13d after 13c; P1 is #1626, #1627-#1629, 25 and 26 as before, with #1627 smaller +head"): R1 gains #1650, in the #1646-#1649 push, and needs #1562b (the browser's rebuild path) as +well as #1562a; R2 gains #1654 after #1653; P1 is #1626, #1627-#1629, #1635 and #1636 as before, with #1627 smaller (#1315 landed); 03a, 03b and 03c are obsolete (decision 17, #1566), and 03a's shape rule moves into -02; #1306 leaves R3 (its window term is done by #1611 and #1621), so P2 (#1306's S removal, then 22) -can follow P1 directly; the R3 must-land group is 18a, 18b and 19. +#1645; #1306 leaves R3 (its window term is done by #1611 and #1621), so P2 (#1306's S removal, then #1668) +can follow P1 directly; the R3 must-land group is #1663, #1664 and #1665. **Amended 2026-10-14, root's rulings** (section "Root's rulings (2026-10-14)"): P1 is filed as -#1626-#1629 (with 25 and 26 still drafts); R2 gains 13e after 13c; R3 gains 19b after 19; #1306 is -D3 only. Neither 13e nor 19b is in a must-land group: each is a complete outcome once its +#1626-#1629 (with #1635 and #1636 then still drafts); R2 gains #1655 after #1653; R3 gains #1666 after #1665; #1306 is +D3 only. Neither #1655 nor #1666 is in a must-land group: each is a complete outcome once its dependencies have landed, and before it lands its rows stay masked and inert, as every row no slice renders (A10). #1631 (defect) and #1632 (row rule) are placeholder issue specs outside the -staged plan; neither depends on a slice. Draft 13d (amended) assumes #1632: it renders no +staged plan; neither depends on a slice. #1654 (amended) assumes #1632: it renders no `follows_mute` entry because the row rule refuses one. **No placeholder and no cycle.** @@ -880,84 +885,84 @@ staged plan; neither depends on a slice. Draft 13d (amended) assumes #1632: it r plus the batch order: #1309 before P1, #1382 before R1), and every deletion or move in each spec of that closure, against the draft's Context, Decisions, Deliverables, paths and gates. The closure reaches 70 specs. Each definite finding is fixed in the draft: - - 06b follows #1387's routing (worker and single mode) and drain realms, and depends on #1387, + - #1644 follows #1387's routing (worker and single mode) and drain realms, and depends on #1387, #1332 and #1294. - - 06a refuses with #1293 D1's `RESULT_REFUSED_LIFECYCLE` on an instance with no control half, + - #1643 refuses with #1293 D1's `RESULT_REFUSED_LIFECYCLE` on an instance with no control half, calls `SessionState`'s wrapper (#1381 D2), and tests a seek while a browser candidate waits (#1290 is in its closure). - - 05 puts its wrapper in `crates/control-plane` (#1309 lands before P1). - - 14b and 11 take their ramp words through `LiveRamps::resolve` (#1394 D5-D6, #1247 D5). - - 02 calls draft 07's evaluator instead of an interim copy, and depends on 07. - - 10 moves the superseded `model_only_edits` cases to an EQ row, which no slice unmasks before - 20, and 20 deletes them. - - 20 names the gates that pin `AutomationTarget` (02 gate 7, 10 gate 1, #1335 gate 4). + - #1642 puts its wrapper in `crates/control-plane` (#1309 lands before P1). + - #1657 and #1649 take their ramp words through `LiveRamps::resolve` (#1394 D5-D6, #1247 D5). + - #1645 calls #1637's evaluator instead of an interim copy, and depends on #1637. + - #1648 moves the superseded `model_only_edits` cases to an EQ row, which no slice unmasks before + #1667, and #1667 deletes them. + - #1667 names the gates that pin `AutomationTarget` (#1645 gate 7, #1648 gate 1, #1335 gate 4). - #1306's text that keeps a role for S gets amendment rows (#1627 and #1629 delete it in P1). - - 10 D4, 12 D5, 25, #1627-#1629, 22 and 13c no longer name a file, line or record that their + - #1648 D4, #1639 D5, #1635, #1627-#1629, #1668 and #1653 no longer name a file, line or record that their closure moves. Context anchors that describe today's code (the capi files #1309 moves, the record rings #1312 and #1346 replace) stay as anchors of `6ee64f484`; F15 asks root to re-check them at filing. The attempt-3 verdict found two conflicts that check missed, and attempt 4 fixed both and re-checked each against the text it touches: - - 05 D6 now builds on #1319 D4's `audit capi` seek (in 05's closure through 04b and #1320, and + - #1642 D6 now builds on #1319 D4's `audit capi` seek (in #1642's closure through #1641 and #1320, and now a direct dependency): #1319's anchored generation-2 source seek and its `RESULT_OK` - assertions stay; 05's session seek runs at generation 3 after call 4's render has applied - #1319's seek, so no slot holds a pending seek. Checked against #1319 D4 and against 05 D1, D3 + assertions stay; #1642's session seek runs at generation 3 after call 4's render has applied + #1319's seek, so no slot holds a pending seek. Checked against #1319 D4 and against #1642 D1, D3 and D4 (generation rule, results, the unanchored entry point). - - 20 D5 keeps the copy line of the masked comparison (`live_delta.rs:236`) that 10 D1 keeps, and - adds the EQ rows to 10 D1's `automation_row_renders`, so 10 D1's step still returns - `LiveRebuild::Automation` for every automation edit. Checked against 10 D1 and against 20's own + - #1667 D5 keeps the copy line of the masked comparison (`live_delta.rs:236`) that #1648 D1 keeps, and + adds the EQ rows to #1648 D1's `automation_row_renders`, so #1648 D1's step still returns + `LiveRebuild::Automation` for every automation edit. Checked against #1648 D1 and against #1667's own D5 and gates. Attempt 4 did not re-run the mechanical check over all 41 drafts; it checked these two pairs and the superseded gates below. - **Earlier gates that a later slice turns red.** Each later slice names the gate and rewrites or deletes it in its own change, inside its own paths (AGENTS.md: a change that supersedes a test deletes it in the same PR): - - 13a D2 rewrites draft 10 gate 1's mute (row 6) case to `Err(LiveRebuild::Automation)`; - - 20 D5 rewrites draft 02 gate 7, draft 10 gate 1, both halves of #1335 gate 4, draft 19 gate 1's + - #1651 D2 rewrites #1648 gate 1's mute (row 6) case to `Err(LiveRebuild::Automation)`; + - #1667 D5 rewrites #1645 gate 7, #1648 gate 1, both halves of #1335 gate 4, #1665 gate 1's EQ case and the band-gain half of #1335 gate 3 (now a rebuild with one pending candidate); - - 22 D5a deletes #1306 gate 2's `resource_lifecycle.rs` test at S = 128 and S = 4,096, which a + - #1668 D5a deletes #1306 gate 2's `resource_lifecycle.rs` test at S = 128 and S = 4,096, which a reserved word makes `INVALID_ARGUMENT`; - - 09a D6 rewrites #1242's pinned order test (`crates/session/tests/vca_composition.rs:79-82`). - Attempt 4 found these from the attempt-3 verdict and from 09a's own change; it did not search + - #1646 D6 rewrites #1242's pinned order test (`crates/session/tests/vca_composition.rs:79-82`). + Attempt 4 found these from the attempt-3 verdict and from #1646's own change; it did not search every later slice for others. - **Other deletions.** #1382 deletes the browser's own admission (#1382 D5), and no slice edits `admit_commands` or adds a browser reason. #1627-#1629 delete the protocol queue; no slice reads it. A building block - (04a, 14a, 16a) lands in the batch of its first user (07 lands with 02, its first user). + (#1640, #1656, #1659) lands in the batch of its first user (#1637 lands with #1645, its first user). - **No interim rule on `main`.** The group-cell write lands with the slice that renders the group - (14b, 16b, 20). One same-batch change of a rule exists: alone, draft 10 D3 makes a VCA ride on an - automated member an empty delta (`model_only`), and draft 11 makes it a live offsets-cell write. - The two are in one must-land-together group, so `main` never holds draft 10's interim result. + (#1657, #1660, #1667). One same-batch change of a rule exists: alone, #1648 D3 makes a VCA ride on an + automated member an empty delta (`model_only`), and #1649 makes it a live offsets-cell write. + The two are in one must-land-together group, so `main` never holds #1648's interim result. - **Must-land-together groups.** Each group below must reach `main` in one push: any proper subset on `main` leaves a state that is wrong for a user, so root files each group knowingly and never splits it across pushes. Each group is inside one batch, and each batch is one push. - - **09a, 09b, 10, 11, 11b** (R1; 11b added 2026-10-14: without it a hand fader move on an - automated lane commits `model_only`, option A, which OQ1 = C replaces). Without 09b, a moving - curve holds its first value. Without 10, a live + - **#1646, #1647, #1648, #1649, #1650** (R1; #1650 added 2026-10-14: without it a hand fader move on an + automated lane commits `model_only`, option A, which OQ1 = C replaces). Without #1647, a moving + curve holds its first value. Without #1648, a live fader edit on an automated lane retargets it until the next event, so the edit fights the curve - (09b Hazards). Without 11, 10 D3 drops the `FaderDb` that a VCA ride gives an automated member, + (#1647 Hazards). Without #1649, #1648 D3 drops the `FaderDb` that a VCA ride gives an automated member, so an acknowledged ride is not heard. - - **13a, 13b, 13c** (R2). Without 13b and 13c, an automated mute renders while a send that follows + - **#1651, #1652, #1653** (R2). Without #1652 and #1653, an automated mute renders while a send that follows it keeps passing audio: the leak A6 exists to prevent. - - **#1306, 18a, 18b, 19** (R3). *Amended 2026-10-14: the group is 18a, 18b and 19.* #1611 D3 and + - **#1306, #1663, #1664, #1665** (R3). *Amended 2026-10-14: the group is #1663, #1664 and #1665.* #1611 D3 and #1621 D1 size every live window by the lane's parameter cells, so a window already holds one span per cell and #1306 adds nothing that stored automation needs (finding F23). The reason for - 19 stays: a live record and a stored event on one cell in one piece would need two slots. - Without 18b, effect automation renders but a seek and the browser - do not follow it. Without 19, a live effect record on an automated cell still reaches the lane, - while amended #1306 sizes the window without that cell's live term (A7), and 17b D5 and 18a D4 + #1665 stays: a live record and a stored event on one cell in one piece would need two slots. + Without #1664, effect automation renders but a seek and the browser + do not follow it. Without #1665, a live effect record on an automated cell still reaches the lane, + while amended #1306 sizes the window without that cell's live term (A7), and #1662 D5 and #1663 D4 assume that no such record exists. A piece can then hold one span more than the window holds, and `Staged.dropped > 0` drops an acknowledged edit. Amended #1306 alone has the same defect, - because before 19 nothing stops that record. + because before #1665 nothing stops that record. - **#1627, #1628, #1629** (P1). Without all three, `main` holds a queue, event or command that nothing serves (a dead queue). - - No other subset is unsafe: a building block (04a, 14a, 16a) alone is code nothing calls, which + - No other subset is unsafe: a building block (#1640, #1656, #1659) alone is code nothing calls, which the batch rule above already keeps off `main`, and every other slice is a complete outcome on its own once its dependencies have landed. - **Acyclic.** Every spec a slice depends on lands before it, and no amendment makes a spec depend on a slice. Each amendment below comes from the research of this note (A7, A8, A9, A10, F12, F13), not from a slice. Two amended specs are dependencies of slices, and both land before - them: #1054 (09a, 12, 14b, 15, 24a), whose D10 row names no slice, and #1306 (18a, 18b, 22), which - lands in the same push as 18a and whose amended Dependencies name 18a and 18b only as same-push + them: #1054 (#1646, #1639, #1657, #1658, #1671), whose D10 row names no slice, and #1306 (#1663, #1664, #1668), which + lands in the same push as #1663 and whose amended Dependencies name #1663 and #1664 only as same-push partners, not as dependencies. Specs that land first and whose code a slice extends are listed as not amended. @@ -966,35 +971,35 @@ its edit (B: an override value and flag per automated lane; C: an offset per aut and a stored mute term). If the answer is D, one slice adds the touch cell and its overlay edit, as A2 requires. None of them is drafted here, because its fields depend on the answer. If the answer is A, nothing is added. If OQ2 is answered yes, one slice adds route targets after R2. -**Ruled 2026-10-14:** OQ1 = C is slice 11b (R1) with drafts 13a and 15 amended; OQ2 = yes is slice -13d (R2). +**Ruled 2026-10-14:** OQ1 = C is #1650 (R1) with #1651 and #1658 amended; OQ2 = yes is +#1654 (R2). ### Amendments to existing specs Each row comes from this note's research. No row makes a spec depend on a slice: #1306's rows -name slices 18a and 18b only as its same-push partners, #1054's row names no slice, and the other +name #1663 and #1664 only as its same-push partners, #1054's row names no slice, and the other rows change specs that no slice depends on. | Spec | Section | Change | Why | |---|---|---|---| | #1306 | D1 | `stored` is A7: the number of cells in `C(i)`, 0 for a target-owning effect. Delete the `AUTOMATION_ENQUEUE` term. The live term counts only `Block` cells the session does not automate. | A7, A8; an automated cell takes no live span. | -| #1306 | D3 | "capi keeps S only for `per_block_automation_density`" becomes "capi keeps S until slice 22 reserves the field". | A9. | -| #1306 | Introduction, "Not ready" paragraph, Context | "The caller's S then bounds only `AUTOMATION_ENQUEUE` density" becomes "The caller's S has no role after slice 22". The "Not ready" paragraph says #1058 recorded A7 and A8: `stored` is A7, and there is no `AUTOMATION_ENQUEUE` term (A8). "S is also the protocol's `per_block_automation_density` … That role stays" becomes "#1629 (batch P1, before this spec) deletes `per_block_automation_density` and its read; S keeps no role". | A8, A9; #1627 and #1629 remove capability field 19 and the density field before R3. | -| #1306 | Dependencies | "the slice that renders stored effect automation" is slices 18a and 18b, which land in the same push with draft 19 (must-land-together groups); #1306 does not depend on them. | A11. | +| #1306 | D3 | "capi keeps S only for `per_block_automation_density`" becomes "capi keeps S until #1668 reserves the field". | A9. | +| #1306 | Introduction, "Not ready" paragraph, Context | "The caller's S then bounds only `AUTOMATION_ENQUEUE` density" becomes "The caller's S has no role after #1668". The "Not ready" paragraph says #1058 recorded A7 and A8: `stored` is A7, and there is no `AUTOMATION_ENQUEUE` term (A8). "S is also the protocol's `per_block_automation_density` … That role stays" becomes "#1629 (batch P1, before this spec) deletes `per_block_automation_density` and its read; S keeps no role". | A8, A9; #1627 and #1629 remove capability field 19 and the density field before R3. | +| #1306 | Dependencies | "the slice that renders stored effect automation" is #1663 and #1664, which land in the same push with #1665 (must-land-together groups); #1306 does not depend on them. | A11. | | #1315 | D1-D3, gates 1, 2, 4 | Move to #1627-#1629, which retire the command instead of adding `ProviderFeatures::automation`. D4 (browser bypass), D5 and D6 stay. | A9; a switch that is never set true is code a later slice deletes. | | #1315 | D5 | The docs say the refusal is permanent and the command is retired (#1627). | A9. | -| #1053 | D12, D15 | D12: the mask becomes per row, from slice 10 to slice 20. D15's "S bounds only `AUTOMATION_ENQUEUE` density" becomes "S has no role after slice 22". | A9, A10; #1053 is an umbrella root keeps current. | +| #1053 | D12, D15 | D12: the mask becomes per row, from #1648 to #1667. D15's "S bounds only `AUTOMATION_ENQUEUE` density" becomes "S has no role after #1668". | A9, A10; #1053 is an umbrella root keeps current. | | #1054 | D10 | While a lane is muted (settled, or ramping toward 0), a live fader change only remembers the gain; during an unmute ramp it retargets as today. This replaces "keep the ramp": it never cuts a mute ramp and removes the `-0.0` artefact. | Finding F12, decided. | | #1351 | Context, D-section on counters | `CANCELED_AUTOMATION` is never served: #1629 retires it. The snapshot serves the remaining counters. | A9, finding F13. | **Not amended.** These specs land before the slice that needs a change, so the slice owns the change -in their code, and depends on them: #1054 D3 (slice 12 D7 documents the jump ramps), #1365 (slice -12 D3), #1312 D1 and D3 (slices 09a, 13a, 14b), #1345 D1 (slice 20 D4), #1262 and #1346 (slice -16b), #1382 (no change: the slices add their rules to the shared commit it calls), #1225, #1226, -#1342 and #1347 (slices 13b and 13c), #1247 (slice 11), #1284 (slice 13b), #1277 (slice 10), #1279 -and #1280 (slice 19), #1316, #1320, #1323 and #1396 (slice 04b), #1317 (slice 05), #1293 (slice -06a). #1297 needs no change: its `alignTo` start reads a stem that plays in step with the timeline, -so it gives the timeline's frame. #1335 has landed (finding F15); slice 02 extends its route. +in their code, and depends on them: #1054 D3 (#1639 D7 documents the jump ramps), #1365 ( +#1639 D3), #1312 D1 and D3 (#1646, #1651, #1657), #1345 D1 (#1667 D4), #1262 and #1346 ( +#1660), #1382 (no change: the slices add their rules to the shared commit it calls), #1225, #1226, +#1342 and #1347 (#1652 and #1653), #1247 (#1649), #1284 (#1652), #1277 (#1648), #1279 +and #1280 (#1665), #1316, #1320, #1323 and #1396 (#1641), #1317 (#1642), #1293 ( +#1643). #1297 needs no change: its `alignTo` start reads a stem that plays in step with the timeline, +so it gives the timeline's frame. #1335 has landed (finding F15); #1645 extends its route. ## Owner questions @@ -1025,7 +1030,7 @@ of that question stay open and this answer does not decide them). recommended personal-mix overlay of #1057, matches the DAW trim modes, and costs one addition per event. A ships first; B, C or D each add one slice and change nothing already shipped (A2). -**Owner answer (2026-10-14): C.** Slice 11b; drafts 13a and 15 amended. +**Owner answer (2026-10-14): C.** #1650; #1651 and #1658 amended. ### OQ2. Should send levels be automatable? @@ -1043,14 +1048,14 @@ include sends. Decision 13 made sends live; nothing makes them automatable. **Recommendation: 2,** after the strip rows ship (batch R2). It completes the mix vocabulary at a small cost and keeps the output fold. -**Owner answer (2026-10-14): yes (option 2).** Slice 13d. Decision 17 already made the rows +**Owner answer (2026-10-14): yes (option 2).** #1654. Decision 17 already made the rows targets: #1566 stores a send target as a control address, and the computed `automatable` flag is -true on a route into a submix and false on a route into the output. 13d adds the render and the +true on a route into a submix and false on a route into the output. #1654 adds the render and the edit rules. ## Findings for the coordinator -- **F1. Filter designs in render.** A1.6 and slices 16a, 16b and 20 design EQ and input-filter targets on +- **F1. Filter designs in render.** A1.6 and #1659, #1660 and #1667 design EQ and input-filter targets on the render thread for stored automation. That amends `docs/EFFECT_CONTRACT_V1.md:157-163` ("prepares fixed targets off render"), the prepared-target contract ("Render-side code never calls this method", `crates/effect-contract/src/prepared_target.rs:6-8`, `:73`) and the EQ's code rule @@ -1094,32 +1099,32 @@ edit rules. - **F. Make these values not automatable.** Contradicts decision 14 rule 4 and decision 17's computed `automatable` (the rows are live). Not available without an owner ruling. - **Recommendation: A**, with four conditions written into slices 16a and 20: (1) one designer + **Recommendation: A**, with four conditions written into #1659 and #1667: (1) one designer and one check per filter kind, called by preparation, the control plane and render, so no - second design code exists (16a gate 1, 20 D2); (2) the render call is in a realtime policy - region, allocates nothing, and its count per block is bounded and tested (16b D4, 20 D2); + second design code exists (#1659 gate 1, #1667 D2); (2) the render call is in a realtime policy + region, allocates nothing, and its count per block is bounded and tested (#1660 D4, #1667 D2); (3) live edits keep designing off render, so C6 is unchanged for them; (4) the slice that first - designs in render (16a) carries the contract amendments above and a one-line note against + designs in render (#1659) carries the contract amendments above and a one-line note against decision 17 C6, which the owner sees on review because C6 is root's application subject to owner review. DSP grounds: A keeps the documented designs (RBJ cookbook responses, Butterworth input sections) exact at every completion sample; the 64-sample linear coefficient ramp between two successive designs is #1407's mixture of close designs, held stable by the existing spectral-norm check; and the state-variable structure keeps its state meaningful when its coefficients change, which is why both filters use it. No shortcut is involved: A is the only - option that keeps the law, the bits and the memory bound together. Slices 24a and 24b measure + option that keeps the law, the bits and the memory bound together. #1671 and #1672 measure its cost on the console row. - **Root's decision (2026-10-14): A.** Render designs at events with the same designer and the same check as preparation and the control plane; the per-block count is bounded and the render call sits in a realtime-policy region; live edits keep designing off render, so decision 17 C6 - stands for them. The four conditions above bind drafts 16a, 16b and 20. The contract amendment + stands for them. The four conditions above bind #1659, #1660 and #1667. The contract amendment of condition 4 (`docs/EFFECT_CONTRACT_V1.md`, `crates/effect-contract/src/prepared_target.rs`, the EQ's render-path comments, `docs/rulings/builtins-input-liveness-d2.md`, and a one-line note - against C6) is root's application **for owner review**; root tells the owner. Draft 16a carries - it and marks it so; 16b and 20 no longer wait for F1. + against C6) is root's application **for owner review**; root tells the owner. #1659 carries + it and marks it so; #1660 and #1667 no longer wait for F1. - **F2. Ruling text that presumes `AUTOMATION_ENQUEUE` is wired.** D15-13 E4 says "until #1058 wires it", D15-5 says S "bounds only `AUTOMATION_ENQUEUE` density", and D15-2 keeps "Automation" records FIFO (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md:82`, `:139-143`, - `:407-410`). Under A9 the command is retired, S has no role after slice 22, and E4's item for the + `:407-410`). Under A9 the command is retired, S has no role after #1668, and E4's item for the command moves from #1315 to #1627. Root updates the record and sequences #1627 no later than #1315's C ABI half. - **F3. `AGENTS.md` "Interfaces and transports"** (`AGENTS.md:49`) says the protocol uses absolute @@ -1129,11 +1134,11 @@ edit rules. that names the stored edits is optional. - **F4. Two positions, decided.** `TRANSPORT_SET` stores an endpoint-local position nothing reads (`crates/host-core/src/control_provider.rs:424-438`); A1.2 adds the timeline. The note decides to - retire the stored position (slice 25, batch P1): a second settable playhead would contradict the + retire the stored position (#1635, batch P1): a second settable playhead would contradict the timeline, and it cannot move it, because a seek needs the host to refill every ring. Reporting the timeline through the transport snapshot was rejected: the control thread would need a - render-published playhead that the seek report (#1316, slice 04b D6) already gives the host. The - transport state stays. #1315 kept `TRANSPORT_SET` for its readback, which slice 25 keeps for the + render-published playhead that the seek report (#1316, #1641 D6) already gives the host. The + transport state stays. #1315 kept `TRANSPORT_SET` for its readback, which #1635 keeps for the state; this follows the owner's removal ruling (`docs/rulings/engine-footprint-2026-09-28.md:20-21`), so root only files the slice. - **F5. #1306's EQ live term.** #1306 D1 gives the EQ 44 live cells, but the EQ stages no span: it @@ -1144,14 +1149,14 @@ edit rules. `AutomationRate::Sample` (`crates/effect-contract/src/lib.rs:139`, `:145`, `:1632-1656`; `docs/EFFECT_CONTRACT_V1.md:151-155`) have no production producer; the contract doc keeps them for "a later protocol capability", which A9 retires. The owner's removal ruling - (`docs/rulings/engine-footprint-2026-09-28.md:20-21`) settles it: slice 26 (batch P1) retires them + (`docs/rulings/engine-footprint-2026-09-28.md:20-21`) settles it: #1636 (batch P1) retires them and the protocol's sample rate value, with their raw values refused and never reallocated. - **F7. Stale numeric documents.** `dsp-research/simd-numerics.md:9`, `:17`, `.cargo/config.toml:2` and `docs/REALTIME_DEPENDENCY_POLICY.md:148-150` describe a fused or runtime-dispatched FMA and a tolerance, against the unfused, bit-exact contract A5 cites. Not part of #1058. -- **F8. This issue's gates 3 and 4.** By root's instruction the slices are drafts under - `proposed-specs/`, not files under `.github/ISSUE_SPECS/` with GitHub issues; root files them, and - gate 3 is met at filing. Gate 4's `origin/main...HEAD` also lists the #1057 files this branch +- **F8. This issue's gates 3 and 4.** By root's instruction the slices were drafts, not files under + `.github/ISSUE_SPECS/` with GitHub issues; root files them, and gate 3 is met at filing (now + filed as #1626-#1629 and #1635-#1672). Gate 4's `origin/main...HEAD` also lists the #1057 files this branch carries. - **F9. Static edits on automated lanes, decided.** A2 commits them as `model_only` by one rule in the shared commit for both hosts. #1315's definition is "the state render reaches differs from @@ -1176,8 +1181,8 @@ edit rules. automation, or each grid event would restart a mute ramp. The note decides that the live path takes the same rule while the lane is muted (settled, or ramping toward 0): it never cuts a mute ramp, it removes the artefact, and one rule serves both paths. During an unmute ramp a live change - still retargets, so the lane ends on the new value. #1054 lands before slice 08 and needs nothing - from #1058, so the change is an amendment to #1054 D10 (amendment table); draft 08 D2 reads it. + still retargets, so the lane ends on the new value. #1054 lands before #1638 and needs nothing + from #1058, so the change is an amendment to #1054 D10 (amendment table); #1638 D2 reads it. - **F13. #1351 serves a counter #1629 retires.** *Report each configured counter's own value in the C ABI counter snapshot* (#1351) serves `CANCELED_AUTOMATION` @@ -1216,7 +1221,7 @@ edit rules. session, has a cell of three words (`S` as one `f64`, one ramp word): 36 bytes in #1312's three slots, whatever the VCA count, plus the cell's fixed words. A lane no VCA reaches holds `S = +0.0`: in `f64`, `v + (+0.0) == v` except that `-0.0` becomes `+0.0`, the clamp changes - nothing for a curve value in the fader domain (draft 02), and `db_gain(±0) = 1` exactly, so the + nothing for a curve value in the fader domain (#1645), and `db_gain(±0) = 1` exactly, so the gain has the static bits. Every VCA edit, adding and removing a VCA included, is one live cell write; nothing rebuilds and no cap sizes the cell. Render adds one `f64` per event. - **Authority.** Root ruled it under the owner's standing summation-order ruling: a class B @@ -1227,7 +1232,7 @@ edit rules. D15-4(a) only (`docs/rulings/live-updates-seamless-swaps-and-one-control-plane-2026-10-05.md:119-121`, `:609-610`). The ruling as the owner gave it names a measured speed-up as the ground; this change is for liveness and fixed memory. Root must record the standing ruling, and this - reading of it, in the decision-15 record when it files drafts 09a and 11, for owner review. + reading of it, in the decision-15 record when it files #1646 and #1649, for owner review. - **The amendment to #1242's order.** It is a class B change to shipped, pinned bits: - the order is documented at `docs/SESSION_SCHEMA_V1.md:80` ("its own value first, then the reaching VCAs' offsets in ascending VCA ID"), implemented at `crates/session/src/vca.rs:19-34` @@ -1236,14 +1241,14 @@ edit rules. - decision 13 (a) does not fix the order: the offset "adds to each member's own fader" (`docs/rulings/submix-strips-sends-and-vca-2026-10-02.md:114-115`), and the module doc already spells the rule `clamp(own_db + sum(offsets), -144, 24)` (`crates/session/src/vca.rs:5`); - - **slice 09a makes the change** (draft 09a D6): the two functions in `vca.rs`, the sentence at - `SESSION_SCHEMA_V1.md:80`, and the pinned test rewritten in the same change (draft 09a + - **#1646 makes the change** (#1646 D6): the two functions in `vca.rs`, the sentence at + `SESSION_SCHEMA_V1.md:80`, and the pinned test rewritten in the same change (#1646 gate 6). Three more test oracles compute the member-first order today: `reference_db` (`crates/host-core/tests/vca.rs:268-279`), the `lane` closure of `a_browser_vca_renders_as_its_effective_faders_under_solo_and_mute` (`hosts/host-web/src/tests.rs:13358-13371`) and `vca_reference_effective` (`:13697-13715`). They stay green, because they compare rendered PCM and the new order moves a dB value only in - degenerate cases, but each documents a retired rule. Draft 09a D6 rewrites the three in the + degenerate cases, but each documents a retired rule. #1646 D6 rewrites the three in the same change, so no test computes or pins the old order after it lands; - **which bits move.** Each term is in `[-144, 24]` (`crates/session/src/validate.rs:584` for a VCA offset; a member's fader likewise). With `k` terms, every partial sum is exact in `f64` @@ -1270,7 +1275,7 @@ edit rules. `MAXIMUM_BROWSER_VCAS = 256` (`:89`), which boot enforces (`browser_vca_shape`, `:6398-6400`), so the figure is `12·256 + 24 = 3,096` bytes per automated lane in every session. - - **The drafts.** Slices 09a and 11 are written for layout 4 and wait for no ruling. Root records + - **The drafts.** #1646 and #1649 are written for layout 4 and wait for no ruling. Root records the decision, with its authority, in the decision-15 record; it narrows nothing in #1247's live set. @@ -1281,13 +1286,13 @@ edit rules. is still open, and this plan lands it in batch R3 (with the amendments above). Root reopens GitHub #1306. *2026-10-14: GitHub #1306 is open again; its scope changes (finding F23).* -- **F18. Two dependency specs are not on this branch.** Draft 16a depends on *Retarget a live input - filter only through its designs and their mixtures* (#1407), and drafts 08, 14a and 15 cite *Keep - every trim, fader and matrix ramp inside its endpoints* (#1408; draft 08 for its site 1, the fader +- **F18. Two dependency specs are not on this branch.** #1659 depends on *Retarget a live input + filter only through its designs and their mixtures* (#1407), and #1638, #1656 and #1658 cite *Keep + every trim, fader and matrix ramp inside its endpoints* (#1408; #1638 for its site 1, the fader and mute ramp kernel `gain_mute_ramp_block`). Their specs exist only on `codex/d15-stream-g` (`5cfc1fb6d`), not on `c63f5f37d`. Attempt 3 read #1407 there: its D5 keeps `InputStage::apply_prepared_filter` as the one retarget entry and changes only its rules, - so 16a's call through it stays valid. Root merges stream G's specs before it files 16a. + so #1659's call through it stays valid. Root merges stream G's specs before it files #1659. - **F19. The iOS memset rule. The worklet trap owners do not apply.** - **The rule.** The required `cross-target` job (`.github/workflows/qualification.yml:889-915`) @@ -1297,12 +1302,12 @@ edit rules. crate's ceiling, and on any call in a crate that has no row (`scripts/lib/aarch64-known-defects.py:62-73`, `:131-160`). `builtins-compiler`, `rack`, `source` and `effect-compiler` are in the closure today with no row; `control-plane` (#1309) - and `automation` (draft 07) join it with no row. A new call site of an inlined four-lane kernel, + and `automation` (#1637) join it with no row. A new call site of an inlined four-lane kernel, for example a split piece path beside a fused path, can add calls. - **What every draft does.** Each draft that changes product code in a crate of that closure runs `bash scripts/check-cross-targets.sh` as a gate and adds no `memset_pattern16` call. The fix for a new call is in the slice's own code, never a ceiling: a raised ceiling adds a libc - call in render. No draft authorizes an `IOS_MEMSET_CEILINGS` row (draft 23b may add only a + call in render. No draft authorizes an `IOS_MEMSET_CEILINGS` row (#1670 may add only a #1019 expected test failure to `scripts/lib/aarch64-known-defects.py`). The drafts cite this finding as "the iOS memset rule". - **The trap owners do not apply.** `scripts/check-web-audioworklet-callgraph.py` follows only @@ -1311,8 +1316,8 @@ edit rules. `Option>`), every bank stage (`crates/rack/src/lib.rs:1470`, `Box`) and every effect (`crates/graph/src/lib.rs:884`, `Box`). No draft adds Rust on the direct path from - `miso_engine_web_v1_render`, and draft 06a's export is a control export with its own list - entry. So no slice can add a trap owner to the closure that the gate walks. Draft 20's hazard + `miso_engine_web_v1_render`, and #1643's export is a control export with its own list + entry. So no slice can add a trap owner to the closure that the gate walks. #1667's hazard on trap owners is conservative and stays. ## Check against the batch 9 head (controls coordinator, 2026-10-14) @@ -1329,16 +1334,16 @@ since changed: (`crates/controls/src/automation.rs:72`), refuses an unknown row, a row that is not `automatable`, an inactive row (`control.inactive`: pan on a matrix strip and the reverse), a lane the row lacks, and a segment whose unit or value the row refuses. Preparation, the classifier and - `session-validator` call it. So #1626's strip-kind rule, draft 02's unit and domain rules and - draft 03a are done, and the SDK checks only structure (#1566 D8). + `session-validator` call it. So #1626's strip-kind rule, #1645's unit and domain rules and + obsolete draft 03a's rules are done, and the SDK checks only structure (#1566 D8). - **One classifier.** `classify_live_delta` masks control values through `controls::mask_values` and diffs rows through `controls::changed_controls`; the stored automation is masked whole (`crates/host-core/src/live_delta.rs:359-361`), and an automation change is re-checked by the row rule (`:372-376`). A10's per-row mask is now "per rendered row" over this structure, and its predicate belongs in `controls` (C1). - **Live lanes on every plan** (C6). Every strip, route and effect has its live cells on every plan, - on both hosts. Drafts that attach a lane "only for stored automation" (13b D1, 17b D6, 18a D4, - 19, 20 D7) no longer need to. #1611 D3 and #1621 D1 size every effect window by the lane's + on both hosts. Drafts that attach a lane "only for stored automation" (#1652 D1, #1662 D6, #1663 D4, + #1665, #1667 D7) no longer need to. #1611 D3 and #1621 D1 size every effect window by the lane's parameter cells. - **The browser is a thin client** over the same `SessionState` (#1562a, single instance; the Worker is #1332, later). Its own command kinds and admission are deleted (#1565). Until #1562b @@ -1352,7 +1357,7 @@ since changed: delete it. #1567 is obsolete (A9 confirmed). - **Ramp rows.** Jump lengths follow decision 17's `LiveRampRow` (`crates/effect-contract/src/control.rs:70-102`): polarity ramps over twice `mute_ms`, sends and VCAs have rows of their own. The three-word key of - drafts 07 and 12 becomes the row. + #1637 and #1639 becomes the row. - **Effect parameters** are live only at `Block` rate; `Sample`-rate rows are rebuild-only (`crates/controls/src/effect_rows.rs:146-160`). The bypass crossfade (#1341a1, #1341a2) is a per-block effect stage step. @@ -1362,15 +1367,15 @@ New findings: - **F20. Automatable rows that no slice renders.** Decision 17 computes `automatable` from the row, so the row rule accepts stored automation on: a VCA's `offset_db` and `mute` (`fixtures/session/v1/builtins-automation.json` holds `vca-ride`), a strip's `matrix_mode`, an effect's live `bypass` (#1341a), and a send's - `follows_mute`. A3 and draft 11 said VCA automation was out of scope by decision 13; decision 17 + `follows_mute`. A3 and #1649 said VCA automation was out of scope by decision 13; decision 17 supersedes that. By A10 these entries stay inert after the program ends, which is the "accepted, never heard" class #1315 refuses. Root decides per row: a rendering slice (VCA offset - and mute compose into draft 11's `S` and draft 13a's terms; bypass and `follows_mute` are + and mute compose into #1649's `S` and #1651's terms; bypass and `follows_mute` are step-only rows on stages that already exist), or an owner change to the computed rule. `matrix_mode` needs the owner: an automated switch makes the pan or matrix rows active and inactive over time, which A3's "one authority per strip" does not cover. - **Root's ruling (2026-10-14):** VCA `offset_db` and `mute` render (draft 13e); effect `bypass` - renders (draft 19b); a send's `follows_mute` and a strip's `matrix_mode` are not automatable + **Root's ruling (2026-10-14):** VCA `offset_db` and `mute` render (#1655); effect `bypass` + renders (#1666); a send's `follows_mute` and a strip's `matrix_mode` are not automatable (placeholder #1632; root reports it to the owner as revisitable). - **F21. A live `matrix_mode` switch can commit a model its own preparation refuses.** The switch is live (C12) and flips which rows are active, but the classifier runs the row rule only when the @@ -1383,13 +1388,13 @@ New findings: (no `matrix_mode` automation) narrows the fault class but does not remove it, because a live or document switch still flips which rows are active under a pan or matrix entry. - **F22. F12's amendment to #1054 D10 never landed.** #1054 is closed, and `set_fader_gain` still - retargets toward 0 on a muted lane (`crates/builtins/src/lib.rs:3083-3098`). Draft 08 (amended) + retargets toward 0 on a muted lane (`crates/builtins/src/lib.rs:3083-3098`). #1638 (amended) takes the live rule, so one rule serves both paths. - **F23. #1306 changes scope.** With every window sized by the lane's parameter cells (#1611 D3, #1621 D1) and every effect carrying its live lane, a window holds one span per cell, automated or not; A7's term adds nothing. #1306 keeps D3 (S leaves the preparation caps, the browser's S derivation at `hosts/host-web/src/lib.rs:4447-4455` goes) and D6's docs; its D1 and the A7 rows of - the amendment table are moot. It leaves R3, and P2 (#1306, then 22) can follow P1. + the amendment table are moot. It leaves R3, and P2 (#1306, then #1668) can follow P1. **Root's ruling (2026-10-14):** #1306 is amended to its D3 only, in a section "Amendment (controls coordinator, batch 10, 2026-10-14)" of its spec; the #1306 rows of the amendment table are superseded by it. @@ -1403,11 +1408,11 @@ New findings: | Status | Drafts | |---|---| -| New | 11b (OQ1 = C), 13d (OQ2 = yes), 13e and 19b (root's F20 ruling, 2026-10-14) | +| New | #1650 (OQ1 = C), #1654 (OQ2 = yes), #1655 and #1666 (root's F20 ruling, 2026-10-14) | | Filed (moved to `.github/ISSUE_SPECS/`, commit `2e6343a99`) | #1626, #1627, #1628, #1629 | -| Current (no design change; anchors re-read at filing) | 14a, 23b, 24b | -| Amended | #1626, 02, 04a, 04b, 05, 06a, 06b, 07, 08, 09a, 09b, 10, 11, 12, 13a, 13b, 13c, 14b, 15, 16a, 16b, 17a, 17b, 18a, 18b, 19, 20, #1627, #1628, #1629, 22, 23a, 24a, 25, 26 | -| Obsolete (kept, marked) | 03a (#1566's row rule; its shape rule moves to 02), 03b (decision 17 C1 and C13, #1566 D8: the SDK is a thin client), 03c (#1566 D8: SDK targets are control addresses) | +| Current (no design change; anchors re-read at filing) | #1656, #1670, #1672 | +| Amended | #1626, #1645, #1640, #1641, #1642, #1643, #1644, #1637, #1638, #1646, #1647, #1648, #1649, #1639, #1651, #1652, #1653, #1657, #1658, #1659, #1660, #1661, #1662, #1663, #1664, #1665, #1667, #1627, #1628, #1629, #1668, #1669, #1671, #1635, #1636 | +| Obsolete (kept, marked) | 03a (#1566's row rule; its shape rule moves to #1645), 03b (decision 17 C1 and C13, #1566 D8: the SDK is a thin client), 03c (#1566 D8: SDK targets are control addresses) | ### Batch P1 readiness @@ -1432,7 +1437,7 @@ open: #1309 and #1315 have landed. The amended scope: (`crates/control-plane/src/compile.rs:185-205`), which both hosts build (the browser passes S through `control_limits`, `hosts/host-web/src/lib.rs:4494`), and the report row is `crates/control-plane/src/control.rs:252-253`, `:929`. #1351 landed, so counter 12 is retired here. -- **25** and **26** stay in P1 (26 is smaller: the protocol's `ParameterAutomationRate` left with +- **#1635** and **#1636** stay in P1 (#1636 is smaller: the protocol's `ParameterAutomationRate` left with `PARAMETER_METADATA_GET`). No blocker. Two ordering points: root closes #1567 before P1 merges (it would edit the command P1 @@ -1453,10 +1458,10 @@ returned **FAIL**. Every finding is folded in: - **M3** (`follows_mute` toggles were not followed): every route into a submix whose source mute is automated gets a lane with `follows_mute` in its cell (A6). - **M4** (static edits as `model_only` against #1315's definition): argued in A2 and raised as F9. -- **M5** (partition invariance not shown with spans): slice 17a gates it and depends on #1069. +- **M5** (partition invariance not shown with spans): #1661 gates it and depends on #1069. - **M6** (carry not proven): the program is not a control kind, the whole event state carries, and #1277, #1279 and #1280 are extended (A1.8; attempt 2 moved the change from amendments into - slices 10 and 19). + #1648 and #1665). - **Minors**: silence (F10), one seek function on both hosts (A1.2), the FP environment (A1.1, F11), the jump spacing rule (A3), the mute-ramp rule (A1.5, F12), the pan or matrix rule (A3), the split and per-row mask (staged plan, A10), F2's wording, the scalar exception (A1.7), the cost table, and @@ -1468,38 +1473,38 @@ record and the 37 drafts, and returned **PASS-WITH-FIXES** (no blocker; eight ma consistency, a vacuous gate, two unowned changes, interim code across same-batch slices, a missing no-bit-moves gate and the moved `main`). Every finding is folded in: the plan table is the union of the drafts' direct dependencies, the cross-references use split names, the interim code is removed -(09a builds the VCA offsets cell, 12 owns the jump lengths from the start, browser refusals sit in +(#1646 builds the VCA offsets cell, #1639 owns the jump lengths from the start, browser refusals sit in the rendering halves), and F15 records the moved `main`. Attempt 2 removed those browser refusals (M1 below). **Attempt 2.** The attempt-1 adversarial verdict (`/home/bl/misofm/submix-verdicts/1058-attempt1.md`) returned **FAIL**: one major, seven minors, seven nits. Each is folded in: -- **M1** (browser refusals that #1382 deletes, and R2 slices that edit code #1382 deletes): slice - 09a depends on #1382, so every rendering slice acts on the shared commit on both hosts. Draft 10b - and `COMMAND_REASON_AUTOMATED` are deleted; the browser refusals of 11, 13a, 14a, 15, 16a, 19 and - 20 are removed; 13b is rewritten onto the shared commit and split (13b render, 13c commit). The +- **M1** (browser refusals that #1382 deletes, and R2 slices that edit code #1382 deletes): + #1646 depends on #1382, so every rendering slice acts on the shared commit on both hosts. Draft 10b + and `COMMAND_REASON_AUTOMATED` are deleted; the browser refusals of #1649, #1651, #1656, #1658, #1659, #1665 and + #1667 are removed; #1652 is rewritten onto the shared commit and split (#1652 render, #1653 commit). The permanent rule is `model_only` in the shared commit, decided against a typed refusal (A2, F9). Amendments that made an earlier-landing spec refer to a later slice are removed; the slice owns the change (amendment table, "Not amended"). That also removed a real cycle: slice 04 depended on #1316, which an amendment asked to report the timeline. - **m1** (P1 tied to R1): P1 depends on #1626 alone, which moves into P1. -- **m2** (send ramp timing): the send's gate changes at the tap's arrival (A6, slice 13b D2), the +- **m2** (send ramp timing): the send's gate changes at the tap's arrival (A6, #1652 D2), the timing A1.3 requires; the fader's arrival would leak `L` samples behind a latent insert. - **m3** ("no answer undoes anything" covered only B and C): A2 states what D changes and requires - it to be a cell word read at events, so A10, slice 17b D5 and the #1306 live term stay true. -- **m4** (sizes and interim rules): 04 split into 04a and 04b, 13b into 13b and 13c, the first protocol slice into #1627 and - #1628 (the queue is #1629). 14 and 16 are re-split by layer (14a and 16a the in-block operations, - 14b and 16b the product with its group-cell write), so no same-batch slice classifies an edit as a + it to be a cell word read at events, so A10, #1662 D5 and the #1306 live term stay true. +- **m4** (sizes and interim rules): 04 split into #1640 and #1641, 13b into #1652 and #1653, the first protocol slice into #1627 and + #1628 (the queue is #1629). 14 and 16 are re-split by layer (#1656 and #1659 the in-block operations, + #1657 and #1660 the product with its group-cell write), so no same-batch slice classifies an edit as a rebuild for a later slice to replace. Must-land-together groups fell from five to four. -- **m5** (F4, F6 handed to root): both decided; slices 25 and 26. -- **m6** (inconsistencies): 18a D8 reads `80 + 32·n`; F12 is decided and the #1054 D10 row states - the decision, which draft 08 D2 reads; the #1225 row is gone and 13c D1 writes the cell instead of +- **m5** (F4, F6 handed to root): both decided; #1635 and #1636. +- **m6** (inconsistencies): #1663 D8 reads `80 + 32·n`; F12 is decided and the #1054 D10 row states + the decision, which #1638 D2 reads; the #1225 row is gone and #1653 D1 writes the cell instead of #1225's record, consistently. - **m7** (citations): [S6] cites the deep pages, [S7] adds the Delay-Line Interpolation page, [S8] uses the real section titles; each was fetched again (section "Sources"). - **Nits:** loop points are quantum-granular (A1.2); the cursor bound is `q` per cell and block and - joins the CPU bound (A1.7, "Costs"); 17b lists #1345; the spec's attempt record names both + joins the CPU bound (A1.7, "Costs"); #1662 lists #1345; the spec's attempt record names both internal reviews; the three short anchor ranges are fixed (`crates/builtins/src/lib.rs:1352-1426`, gate-expander `:534-595`, multiband-compressor `:1228-1282`); F3 says no change is needed; F10 asks root to file promptly. @@ -1507,7 +1512,7 @@ returned **FAIL**: one major, seven minors, seven nits. Each is folded in: **Attempt 3.** The attempt-2 adversarial verdict (`/home/bl/misofm/submix-verdicts/1058-attempt2.md`) returned **FAIL**: one major, three minors, five nits. Each is folded in: -- **MA1** (06b written for the worklet routing that #1387 removes): 06b is rewritten for #1387. The +- **MA1** (#1644 written for the worklet routing that #1387 removes): #1644 is rewritten for #1387. The session seek message goes to the control half (the Worker in `worker` mode, the worklet's control handler in `single` mode); the handler that calls the export records the accepted generation, and the MSB1 drain reads it in its own realm (#1387's Worker drain module, or the worklet prelude). @@ -1515,19 +1520,19 @@ returned **FAIL**: one major, three minors, five nits. Each is folded in: checked "No slice edits code that an earlier slice deletes" mechanically for every draft against its whole dependency closure; every definite finding is fixed ("No placeholder and no cycle"). - **m1** (F16): the middle layout, one word per session VCA with `+0.0` where the VCA does not - reach, is bit-exact (draft 09a D1) and keeps every ride and membership change live. It does not + reach, is bit-exact (#1646 D1) and keeps every ride and membership change live. It does not keep #1247's whole live set: adding or removing a VCA changes the word count, so on a session with stored fader automation it rebuilds. So F16 is a root decision with three layouts and their costs, - recommending the middle one; slices 09a and 11 are written for it and wait for the ruling; the + recommending the middle one; #1646 and #1649 are written for it and wait for the ruling; the Authority line is corrected. -- **m2**: the must-land-together groups are 09a-09b-10-11, 13a-13b-13c, #1306-18a-18b-19 and +- **m2**: the must-land-together groups are #1646-#1649, #1651-#1653, #1306 with #1663, #1664 and #1665, and #1627-#1628-#1629, each with its reason, in the README and in each draft. -- **m3**: draft 25 adds `tools/audit/src/protocol.rs:206` and `crates/capi/src/runtime/tests.rs:2557`; - draft 26 adds the four test fixtures and the policy pin. Both list every user a `git grep` finds. +- **m3**: #1635 adds `tools/audit/src/protocol.rs:206` and `crates/capi/src/runtime/tests.rs:2557`; + #1636 adds the four test fixtures and the policy pin. Both list every user a `git grep` finds. - **Nits**: browser gates say "a browser live edit through the Worker's apply", which holds whether #1382 lowers records or takes stable-ID edits (the #1057 note's F4); A2 states that the static edit keeps its meaning under every option; A1.4 states the completion sample `τ` for `L = 0`; the - "Acyclic" wording is corrected; the hot-file hazards of 19 and 20 name `hosts/host-web/src/tests.rs`; + "Acyclic" wording is corrected; the hot-file hazards of #1665 and #1667 name `hosts/host-web/src/tests.rs`; F17 asks root to reopen GitHub #1306. **Attempt 4.** The attempt-3 adversarial verdict (`/home/bl/misofm/submix-verdicts/1058-attempt3.md`) @@ -1539,57 +1544,57 @@ returned **FAIL**: one major, three minors, nine nits. Each is folded in: under the standing summation-order ruling (F16 records the authority and that the memory's text names a measured speed-up). Layouts 1-3 are rejected alternatives with one line each. Layout 3's sizing is corrected on both hosts (`maximum_vcas == 0` means `maximum_tracks` on the C ABI; the - browser bound is the compiled `MAXIMUM_BROWSER_VCAS`, enforced at boot; 3,096 bytes). Drafts 09a - and 11 are written for layout 4 and wait for no ruling; 09a D6 makes the order change and + browser bound is the compiled `MAXIMUM_BROWSER_VCAS`, enforced at boot; 3,096 bytes). #1646 + and #1649 are written for layout 4 and wait for no ruling; #1646 D6 makes the order change and rewrites the pinned test in the same change. The memory formula charges `36·F`, which is right for a lane with no VCA, since its cell exists. -- **m1**: draft 25 authorizes the `TransportState` reliable payload and constructor in `queue.rs` - and widens its grep; draft 02 authorizes the builtins policy script's dependency list. A fresh +- **m1**: #1635 authorizes the `TransportState` reliable payload and constructor in `queue.rs` + and widens its grep; #1645 authorizes the builtins policy script's dependency list. A fresh sub-agent (Opus 5.5, extra-high effort; read-only, no build) then checked every other draft's gates against the policy scripts and its authorized paths, and each finding is fixed in the - draft: 04a (the host-core diagnostic arm and its table, since `SourceSeekError` is exhaustive), - 04b (host-core's test re-export of the timeline reader, the browser ceilings), 05 and 12 (capi's - `graph` dev-dependency with `test-support`; 05's symbol-list anchor), 06a (gate 2 no longer - claims a timeline read the SDK cannot make), 07 (`g6_full_corpus_ftz.rs`'s row count), 09a - (host-core's `automation` edge), 09b (the source driver's block reader), 11, 13a, 13b, 14b, 15 - and 16b (the allocation gates move to `crates/host-core/tests/`, since - `scripts/check-bench-policy.sh:257-280` bans `bench-support` in any `hosts/` manifest), 13b - (graph's `automation` edge and the graph policy pins), 16a and 16b (a `test-support` reader of - the filter design count), 17b (`Cargo.lock`), 18a (effect-compiler's `automation` edge and its - policy pin), 20 (the EQ target buffer is sized at bind from `maximum_targets()`, since + draft: #1640 (the host-core diagnostic arm and its table, since `SourceSeekError` is exhaustive), + #1641 (host-core's test re-export of the timeline reader, the browser ceilings), #1642 and #1639 (capi's + `graph` dev-dependency with `test-support`; #1642's symbol-list anchor), #1643 (gate 2 no longer + claims a timeline read the SDK cannot make), #1637 (`g6_full_corpus_ftz.rs`'s row count), #1646 + (host-core's `automation` edge), #1647 (the source driver's block reader), #1649, #1651, #1652, #1657, #1658 + and #1660 (the allocation gates move to `crates/host-core/tests/`, since + `scripts/check-bench-policy.sh:257-280` bans `bench-support` in any `hosts/` manifest), #1652 + (graph's `automation` edge and the graph policy pins), #1659 and #1660 (a `test-support` reader of + the filter design count), #1662 (`Cargo.lock`), #1663 (effect-compiler's `automation` edge and its + policy pin), #1667 (the EQ target buffer is sized at bind from `maximum_targets()`, since `MAXIMUM_TARGETS` is private and rack may not depend on the EQ), #1627 and #1629 (capi tests that - name the deleted protocol types), and 24a (gate 1 moves to the bench test; `bench` gains `capi` + name the deleted protocol types), and #1671 (gate 1 moves to the bench test; `bench` gains `capi` and `session-validator`, and `tools/bench/src/console.rs` joins the approved unsafe owners, with the dependency-union and unsafe-owner pins and their self-tests authorized). Not checked without a build: new iOS `memset_pattern16` call sites (`scripts/lib/aarch64-known-defects.py`) and new trap owners in the worklet call graph. -- **m2**: draft 05 D6 builds on #1319 D4 (a generation-3 session seek after #1319's seek has - applied); draft 20 D5 keeps 10 D1's copy line and adds the EQ rows to `automation_row_renders`. +- **m2**: #1642 D6 builds on #1319 D4 (a generation-3 session seek after #1319's seek has + applied); #1667 D5 keeps #1648 D1's copy line and adds the EQ rows to `automation_row_renders`. The "No placeholder and no cycle" claim now states what each attempt checked. -- **m3**: 13a, 20 and 22 name and rewrite or delete the earlier gates they turn red (list in "No +- **m3**: #1651, #1667 and #1668 name and rewrite or delete the earlier gates they turn red (list in "No placeholder and no cycle"). -- **Nits**: row 05 lists #1309 and #1319; #1628's stray fragment is gone; 06b's producer takes a - `number` frame and the SDK converts; 05 D5 places its paragraph in #1317 D1's sequence; 07 D3's - `value_at` takes a segment table; 26 removes the retired rate from - `scripts/check-parameter-metadata-v1.py`; 22's Context matches the #1306 rows; draft 08 cites +- **Nits**: row #1642 lists #1309 and #1319; #1628's stray fragment is gone; #1644's producer takes a + `number` frame and the SDK converts; #1642 D5 places its paragraph in #1317 D1's sequence; #1637 D3's + `value_at` takes a segment table; #1636 removes the retired rate from + `scripts/check-parameter-metadata-v1.py`; #1668's Context matches the #1306 rows; #1638 cites #1408 and F18 says so; the memory formula is fixed (MA1). ### Follow-ups after PASS The attempt-4 verdict returned **PASS** with four minors and five nits. Each is folded in: -- **m1**: the realtime gates of drafts 11, 13a, 13b, 14b, 15 and 16b move to +- **m1**: the realtime gates of #1649, #1651, #1652, #1657, #1658 and #1660 move to `crates/control-plane/tests/`, the crate that holds the shared commit. A host-core binary cannot reach the commit without a dev-dependency cycle. Each draft authorizes control-plane's `bench-support` dev-dependency, which `scripts/check-bench-policy.sh:257-280` allows in a `crates/` manifest. -- **m2**: draft 09a D6 rewrites the three member-first oracles in the same change; F16 names them. +- **m2**: #1646 D6 rewrites the three member-first oracles in the same change; F16 names them. - **m3**: finding F19 states the iOS memset rule and why the trap-owner risk does not apply. Every draft that changes product code in `capi`'s closure runs `bash scripts/check-cross-targets.sh`. -- **m4**: draft 24a's only new unsafe owner is `tools/bench/src/console_capi.rs`, which holds only +- **m4**: #1671's only new unsafe owner is `tools/bench/src/console_capi.rs`, which holds only the C ABI calls, and its gates run the six policy scripts whose pins it edits. -- **Nits**: 09a gate 1 asks for different gain bits, with an example; 11 gate 2 uses three VCAs; - 05 D6 says why `replacements == 1` stays true; A5 and 09a D2 name `vca_compose_db`; F16's +- **Nits**: #1646 gate 1 asks for different gain bits, with an example; #1649 gate 2 uses three VCAs; + #1642 D6 says why `replacements == 1` stays true; A5 and #1646 D2 name `vca_compose_db`; F16's authority cites no private file and says that no repository ruling records the standing summation-order ruling. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/03a-validate-effect-automation-units-domains-and-shapes.md b/docs/handoffs/stored-automation-1058/proposed-specs/03a-validate-effect-automation-units-domains-and-shapes.md index 4673ebcf1..5a1f4c36e 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/03a-validate-effect-automation-units-domains-and-shapes.md +++ b/docs/handoffs/stored-automation-1058/proposed-specs/03a-validate-effect-automation-units-domains-and-shapes.md @@ -1,6 +1,6 @@ # Validate effect automation units, domains and shapes at preparation -> **Obsolete (controls coordinator, 2026-10-14):** its unit and domain rules are #1566's row rule and its shape rule moves into draft 02. Kept as a record; do not file. See the amendment at the end. +> **Obsolete (controls coordinator, 2026-10-14):** its unit and domain rules are #1566's row rule and its shape rule moves into #1645. Kept as a record; do not file. See the amendment at the end. Proposed by *Research: render stored session automation in the engine, identically on every platform* (#1058), answer A3, under decision 15 D15-16 @@ -39,7 +39,7 @@ ABI automation-only transaction (through #1335's classifier route) and `session- `ratio` (minimum 0.1 or 1). - **Frozen effect codes.** `docs/EFFECT_CONTRACT_V1.md:206-229` ("Stable diagnostics"); #1335 D6 adds `effect.automation.rate`. -- **Session and builtin rules** are #1626 and draft 02: one entry per lane, pan or matrix by strip +- **Session and builtin rules** are #1626 and #1645: one entry per lane, pan or matrix by strip kind, discontinuity spacing, and builtin unit, domain, shape and filter order. - **Checked-in documents.** `fixtures/session/v1/canonical.json` automates an insert of the unregistered identity `parametric-eq` (#1335's skip applies); the skill's @@ -84,9 +84,9 @@ ABI automation-only transaction (through #1335's classifier route) and `session- ## Non-goals -- The rate rule (#1335). Session-level and builtin rules (#1626, draft 02). +- The rate rule (#1335). Session-level and builtin rules (#1626, #1645). - The SDK mirror (draft 03b) and submix authoring (draft 03c). -- Rendering effect automation (slices 17a to 20). +- Rendering effect automation (#1661 to #1667). ## Hazards @@ -139,7 +139,7 @@ ABI automation-only transaction (through #1335's classifier route) and `session- - #1626 *Validate stored automation lanes in the session crate and state the hold rule* (the skill's automation refusal table). -- Draft 02 *Validate builtin automation targets against their rows at preparation* (the builtin +- #1645 *Validate builtin automation targets against their rows at preparation* (the builtin rules these effect rules complete, the shared path form and the classifier call beside which #1335's function runs). - *Refuse automation on effect parameters that are not block-rate* (#1335): D1 extends its @@ -153,5 +153,5 @@ Checked against `12d4a4f9d`. - **Status: obsolete (decision 17, #1566).** Its unit and domain rules are #1566's row rule (`controls::automation_diagnostics`, `control.domain`), which covers effect rows through their descriptors (`DomainRule::Contract`); `effect_automation_diagnostics`, the function it extends, is - deleted (#1566 D7). Its one remaining rule, the shape rule, moves into amended draft 02 as one + deleted (#1566 D7). Its one remaining rule, the shape rule, moves into amended #1645 as one rule for every row. Do not file this draft. diff --git a/docs/handoffs/stored-automation-1058/proposed-specs/03b-mirror-the-automation-rules-in-the-sdk-builder.md b/docs/handoffs/stored-automation-1058/proposed-specs/03b-mirror-the-automation-rules-in-the-sdk-builder.md index 95e4c3afd..5df63dac6 100644 --- a/docs/handoffs/stored-automation-1058/proposed-specs/03b-mirror-the-automation-rules-in-the-sdk-builder.md +++ b/docs/handoffs/stored-automation-1058/proposed-specs/03b-mirror-the-automation-rules-in-the-sdk-builder.md @@ -31,7 +31,7 @@ accepts. rows), and each effect row its `domainName`, `minimum`, `maximum` and `unitName`. - **The engine rules mirrored here.** #1626 D1-D3: `automation.duplicate_lane`, pan or matrix by strip kind (`reference.missing_entity` at the parameter), `automation.discontinuity_spacing`. - Draft 02 D1-D2: `builtin.automation.{unit,domain,shape}` and `builtin.filter.order` over curves. + #1645 D1-D2: `builtin.automation.{unit,domain,shape}` and `builtin.filter.order` over curves. Draft 03a D1: `effect.automation.{unit,domain,shape}`. - **SDK precedent tests.** `sdk/test/builder-evals.mjs:691-728` (builtin target rows) and the engine-parity helper `engineFirstCode` (`:568-574`, `:645-651`), which boots a hand-edited model @@ -50,9 +50,9 @@ accepts. `.segments[].startSample` or `.endSample`; - pan on a matrix strip, matrix on a pan strip (#1626 D2): `reference.missing_entity` at `.target.parameter`; - - builtin domain and shape (draft 02 D1): `builtin.automation.domain` through the same tests as + - builtin domain and shape (#1645 D1): `builtin.automation.domain` through the same tests as `builtinNumber` and `builtinFilter` at the session rate, and `builtin.automation.shape`; - - filter order (draft 02 D2): the same split, margin and code `builtin.filter.order`; + - filter order (#1645 D2): the same split, margin and code `builtin.filter.order`; - effect shape (draft 03a D1): `effect.automation.shape`; effect domain stays `automationScalar`'s check, which now carries `effect.automation.domain`. - **D2. Units need no SDK rule:** the builder writes the row's unit. @@ -116,7 +116,7 @@ accepts. ## Dependencies - #1626 *Validate stored automation lanes in the session crate and state the hold rule*, - draft 02 *Validate builtin automation targets against their rows at preparation* and draft 03a + #1645 *Validate builtin automation targets against their rows at preparation* and draft 03a *Validate effect automation units, domains and shapes at preparation*: D1 mirrors their rules, so each must be on `main` first. - Batch: R3. diff --git a/hosts/host-web/src/lib.rs b/hosts/host-web/src/lib.rs index be0963bac..c50817d0f 100644 --- a/hosts/host-web/src/lib.rs +++ b/hosts/host-web/src/lib.rs @@ -39,7 +39,9 @@ use control_plane::{ SessionState, SourceFailure, }; use effect_contract::{ObservationCost, ObservationDescriptor}; -use engine::realtime::{HostAttachment, ObservationWindow, PlanarBufferMut}; +use engine::realtime::{ + HostAttachment, ObservationWindow, ObserverCarry, ObserverKind, PlanarBufferMut, +}; use host_core::{ CompiledSession, EffectObservationHandle, HostLiveControlRequest, HostLiveLanes, HostMeterRequest, HostPrepareCaps, HostPrepareReport, HostShapePolicy, MAXIMUM_VCA_REACH_PAIRS, @@ -1312,6 +1314,11 @@ struct PlanShape { /// for a slot with no taps. Built once at compilation, so the poll's fold is arithmetic rather /// than a search. observation_tracks: Box<[u32]>, + /// The dense effect slot of each observation reader, in the companions' reader order (host-core's + /// `effect_observations` order): entry `r` is the slot whose [`EffectInstance::reader`] is `r`. + /// It is the inverse of that field, built in the same preparation loop, so the swap block's + /// carry record, which names readers (#1327 D3), reaches the per-slot tap state below (#1381b). + reader_slots: Box<[u32]>, /// Armed-tap bitmask per effect slot, maintained at **admission**. /// /// A conflating cell holds its last window forever -- that is what makes it wait-free -- so @@ -1324,8 +1331,15 @@ struct PlanShape { /// Admission sample at which each tap's current arm was acknowledged. A resident cell keeps /// its previous window forever, so a read is fresh for a re-arm only when the whole returned /// window starts at or after this sample. The nested index is the dense effect slot followed - /// by that descriptor's tap index; zero means no command has changed the tap yet. + /// by that descriptor's tap index; zero is a valid sample (a command admitted before the first + /// block), so `observation_commanded` says whether a command named the tap. observation_arm_samples: Box<[Box<[u64]>]>, + /// Commanded-taps bitmask per effect slot: bit `t` is set when a tap command (arm or disarm) + /// was admitted against this shape's plan for tap `t`, whatever sample it was admitted at, so + /// sample zero is not "no command". The swap's hand-over reads it to keep a command admitted + /// against the successor from being overwritten by the predecessor's state, then clears it + /// at the commit of that hand-over (#1381b). + observation_commanded: Box<[u32]>, /// The designated master strip index (tracks first, then submixes), or `None` (issue #143 /// D6, issue #1213 D5). master_track: Option, @@ -1530,13 +1544,27 @@ impl ReadyOwnership { self.pending_shapes.retain(|entry| !entry.adopted); } + // REALTIME_POLICY_BEGIN /// Take the tables of the plan whose epoch is `epoch` (#1290a), at the block that adopted it. /// The tables it retires stay in the entry for [`Self::reclaim_shapes`]; an entry of an /// earlier epoch was superseded and is marked the same way. A pointer exchange: it allocates /// and frees nothing, and indexes nothing, so it adds no trap to the render export. - fn adopt_shape(&mut self, epoch: u64) { + /// + /// `carried` is the pairing the swap block's attachment hook made (#1381b): the readers the + /// executors carried, which this hand-over follows with the per-reader state the tables hold. + /// A swap that carried a meter keeps the meter delivery running, because the carried meters + /// keep their window grid and the host's master windows are continuous; one that carried none + /// restarts it, because its meters open their first window at the adoption block. The poll + /// finds the carried pending windows where it left them only while the meter handles are + /// unchanged: a swap that removes one strip and adds another shifts handles, so a carried + /// pending snapshot fails the poll's handle check, is dropped, and is added to the meter loss + /// count. + fn adopt_shape(&mut self, epoch: u64, carried: &[CarriedReader]) { self.shape_epoch = epoch; - // The windows a swap discards unpublished (the master intervals waiting for a poll and + let meters_carried = carried + .iter() + .any(|reader| reader.kind == ObserverKind::Meter); + // The windows a restart discards unpublished (the master intervals waiting for a poll and // the group of track snapshots a poll had begun to assemble) are lost telemetry, counted // with the others (#1290a, batch 10 review). let discarded = u64::try_from(self.master_count) @@ -1553,17 +1581,45 @@ impl ReadyOwnership { core::mem::swap(&mut self.shape, &mut entry.shape); entry.adopted = true; taken = true; + // `self.shape` is the adopted plan's and `entry.shape` the retired one's. The + // pending track snapshots and the tap state follow their readers. + for reader in carried { + let (Ok(successor), Ok(predecessor)) = ( + usize::try_from(reader.successor), + usize::try_from(reader.predecessor), + ) else { + continue; + }; + match reader.kind { + ObserverKind::Meter => { + if let (Some(into), Some(from)) = ( + self.shape.meter_pending.get_mut(successor), + entry.shape.meter_pending.get_mut(predecessor), + ) { + core::mem::swap(into, from); + } + } + ObserverKind::Observation => { + self.shape + .carry_tap_state(&mut entry.shape, successor, predecessor); + } + ObserverKind::Spectrum => {} + } + } } else if entry.epoch < epoch { entry.adopted = true; } } if taken { - // A swap restarts the meter delivery state, as a producer reset does (#1290d carries - // it). - let lost = self.meter_loss_count.saturating_add(discarded); - self.reset_meter_delivery(false); - self.meter_loss_count = lost; - self.meter_generation = self.meter_generation.saturating_add(1); + if meters_carried { + // The delivery epoch is the stream's, not the plan's: the page sees the same + // generation, and the poll finds the carried windows where it left them. + } else { + let lost = self.meter_loss_count.saturating_add(discarded); + self.reset_meter_delivery(false); + self.meter_loss_count = lost; + self.meter_generation = self.meter_generation.saturating_add(1); + } // The tables hold the admissions made against the plan before it rendered; the block // that adopted it drained its observation queues at its top, so the pass that follows // the swap clears the counts (a block that did not adopt cleared only the tables of @@ -1572,6 +1628,8 @@ impl ReadyOwnership { } } + // REALTIME_POLICY_END + /// Run `admit` with the tables of the newest committed plan in place of the rendering plan's /// (#1290a, batch 10 review): the rendering plan's while no replacement is waiting for its /// block, else the waiting replacement's, the plan whose producers the control plane lists @@ -1726,6 +1784,9 @@ impl ReadyOwnership { { *sample = applied_at_sample; } + if let Some(commanded) = self.shape.observation_commanded.get_mut(effect) { + *commanded |= bit; + } } Ok(()) } @@ -3142,7 +3203,11 @@ impl AudioWorkletEngineHost { // drops the retired ones at its next step. let epoch = plan.owner().active_epoch().0; if epoch != ready.shape_epoch { - ready.adopt_shape(epoch); + // #1381b: what the swap block's hook paired travels with the tables. + let carried = plan + .active_attachment_mut::() + .map_or(&[][..], |companions| companions.carried()); + ready.adopt_shape(epoch, carried); } self.status.next_absolute_sample = next_absolute_sample; self.status.rendered_quanta = self.status.rendered_quanta.saturating_add(1); @@ -4992,6 +5057,32 @@ pub struct RenderCompanions { meters: Vec, /// One reader set per observed effect instance, in `HostLiveControlHandles` order. effect_observations: Vec, + /// What the swap block that adopted this plan carried (#1381b): one move per paired meter or + /// observation reader, written by [`HostAttachment::adopt_predecessor`] and read once by the + /// block's table hand-over ([`ReadyOwnership::adopt_shape`]). Its length is fixed at + /// preparation, one entry per reader, so the hook never grows it. + carried: Box<[CarriedReader]>, + /// How many entries of `carried` the last swap block wrote. + carried_len: u32, +} + +/// One reader the swap block paired with the predecessor's (#1381b): `successor` and `predecessor` +/// are positions in the two plans' own reader vectors (host-core's order), the indices of the +/// engine's carry record. +#[derive(Clone, Copy)] +struct CarriedReader { + kind: ObserverKind, + successor: u32, + predecessor: u32, +} + +impl CarriedReader { + /// The value of an unused entry. + const UNUSED: Self = Self { + kind: ObserverKind::Meter, + successor: 0, + predecessor: 0, + }; } impl RenderCompanions { @@ -5017,16 +5108,24 @@ impl RenderCompanions { spectrum_capture: _, meters, effect_observations, + carried, + carried_len: _, } = self; let shell = u64::try_from(size_of::()).ok()?; + let carried = u64::try_from(carried.len()) + .ok()? + .checked_mul(u64::try_from(size_of::()).ok()?)?; let meters = u64::try_from(meters.capacity()) .ok()? .checked_mul(u64::try_from(size_of::()).ok()?)?; let observations = u64::try_from(effect_observations.capacity()) .ok()? .checked_mul(u64::try_from(size_of::()).ok()?)?; - let total = shell.checked_add(meters)?.checked_add(observations)?; - Some((total, shell.max(meters).max(observations))) + let total = shell + .checked_add(meters)? + .checked_add(observations)? + .checked_add(carried)?; + Some((total, shell.max(meters).max(observations).max(carried))) } /// Whether the plan was prepared with a spectrum capture (one, or a collection). @@ -5081,10 +5180,75 @@ struct BootRecord { shape: Option, } +impl RenderCompanions { + /// What the swap block that adopted this plan paired (#1381b); empty when nothing was. + fn carried(&self) -> &[CarriedReader] { + self.carried.get(..self.carried_len as usize).unwrap_or(&[]) + } +} + impl HostAttachment for RenderCompanions { fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } + + // REALTIME_POLICY_BEGIN + /// Pair the readers the executors carried (#1381 D3; #1327 D3): the successor's meter + /// consumers and observation reader sets swap with the predecessor's at the records the + /// engine's carry names, through host-core's [`host_core::pair_carried_meters`] and + /// [`host_core::pair_carried_observations`]. The browser keeps both vectors in host-core's + /// order, so the record's indices address them directly. The pairs are noted for the block's + /// table hand-over, which moves the per-slot tap state with them. + /// + /// Render thread, in the swap block: swaps and scalar writes into storage sized at + /// preparation. A predecessor of another type, or a record with no reader, pairs nothing. + fn adopt_predecessor( + &mut self, + predecessor: &mut dyn HostAttachment, + carries: &[ObserverCarry], + ) { + self.carried_len = 0; + let Some(previous) = predecessor.as_any_mut().downcast_mut::() else { + return; + }; + host_core::pair_carried_meters(&mut self.meters, &mut previous.meters, carries); + host_core::pair_carried_observations( + &mut self.effect_observations, + &mut previous.effect_observations, + carries, + ); + let mut written = 0; + for carry in carries { + let (successor_len, predecessor_len) = match carry.kind { + ObserverKind::Meter => (self.meters.len(), previous.meters.len()), + ObserverKind::Observation => ( + self.effect_observations.len(), + previous.effect_observations.len(), + ), + ObserverKind::Spectrum => continue, + }; + if carry.successor >= successor_len || carry.predecessor >= predecessor_len { + continue; + } + let (Ok(successor), Ok(predecessor)) = ( + u32::try_from(carry.successor), + u32::try_from(carry.predecessor), + ) else { + continue; + }; + let Some(entry) = self.carried.get_mut(written) else { + break; + }; + *entry = CarriedReader { + kind: carry.kind, + successor, + predecessor, + }; + written += 1; + } + self.carried_len = u32::try_from(written).unwrap_or(0); + } + // REALTIME_POLICY_END } /// The diagnostic code of a preparation that could not leave the plan's tables for the host (a @@ -5208,10 +5372,24 @@ impl WebRuntimePreparer { }; PrepareDiagnostics::fixed(rejection, code) })?; + let meters = core::mem::take(&mut handles.meters); + let effect_observations = core::mem::take(&mut handles.effect_observations); + let mut carried: Vec = Vec::new(); + carried + .try_reserve_exact(meters.len() + effect_observations.len()) + .map_err(|_| { + PrepareDiagnostics::fixed(PrepareRejection::Platform, "web.resource.allocation") + })?; + carried.resize( + meters.len() + effect_observations.len(), + CarriedReader::UNUSED, + ); let companions = RenderCompanions { spectrum_capture, - meters: core::mem::take(&mut handles.meters), - effect_observations: core::mem::take(&mut handles.effect_observations), + meters, + effect_observations, + carried: carried.into_boxed_slice(), + carried_len: 0, }; // The attachment is the plan's own allocation, so the plan's rows charge it: the control // plane builds its plan resource row from this report, and nothing else would count the @@ -5371,6 +5549,10 @@ fn build_plan_shape( // `observation_tracks[slot]` is the strip index of that effect slot's observed instance, or // `u32::MAX` for a slot with no taps. Built once, here, so the poll's fold is arithmetic. let mut observation_tracks = vec![u32::MAX; total_effects as usize]; + let mut reader_slots: Vec = Vec::new(); + reader_slots + .try_reserve_exact(observations.len()) + .map_err(|_| "web.resource.allocation")?; let observation_present = vec![false; strip_count]; for (reader, handle) in observations.iter().enumerate() { let Some(strip) = strip_index(&tracks, &submixes, &handle.track_id) else { @@ -5400,6 +5582,7 @@ fn build_plan_shape( arm_samples.resize(tap_count, 0); observation_arm_samples[slot] = arm_samples.into_boxed_slice(); instance.reader = Some(u32::try_from(reader).map_err(|_| "web.live_controls.observation")?); + reader_slots.push(u32::try_from(slot).map_err(|_| "web.live_controls.observation")?); } Ok(PlanShape { tracks, @@ -5408,8 +5591,10 @@ fn build_plan_shape( effect_base: effect_base.into_boxed_slice(), effect_instances: effect_instances.into_boxed_slice(), observation_tracks: observation_tracks.into_boxed_slice(), + reader_slots: reader_slots.into_boxed_slice(), observation_armed: zero_u32(total_effects as usize)?, observation_arm_samples: observation_arm_samples.into_boxed_slice(), + observation_commanded: zero_u32(total_effects as usize)?, master_track, command_wanted: zero_u32(total_effects as usize)?, observe_named: zero_u32(total_effects as usize)?, @@ -5452,6 +5637,64 @@ struct ShapeRows { } impl PlanShape { + // REALTIME_POLICY_BEGIN + /// Take the tap state of the predecessor's reader `predecessor` into this shape's reader + /// `successor` (#1381b), for a pair the swap block carried: the armed bits and the arm + /// samples of the effect slot each reader sits at (`reader_slots`). + /// + /// A tap a command named after this plan was committed already holds that command's state + /// here (its `observation_commanded` bit is set, whatever sample the command was admitted at) + /// and keeps it, so a subscription admitted against the successor is not overwritten by the + /// predecessor's older one. Every other tap takes the predecessor's. The bits are cleared once + /// the hand-over has read them. Slot tables of different tap counts are left alone: a carried + /// reader never has them. Swaps and scalar writes only. + fn carry_tap_state(&mut self, predecessor: &mut Self, successor_reader: usize, reader: usize) { + let (Some(into), Some(from)) = ( + self.reader_slots.get(successor_reader).copied(), + predecessor.reader_slots.get(reader).copied(), + ) else { + return; + }; + let (into, from) = (into as usize, from as usize); + let (Some(new_samples), Some(old_samples)) = ( + self.observation_arm_samples.get_mut(into), + predecessor.observation_arm_samples.get(from), + ) else { + return; + }; + if new_samples.len() != old_samples.len() { + return; + } + let old_armed = predecessor + .observation_armed + .get(from) + .copied() + .unwrap_or(0); + let mut armed = self.observation_armed.get(into).copied().unwrap_or(0); + let commanded = self.observation_commanded.get(into).copied().unwrap_or(0); + for (tap, (new, old)) in new_samples.iter_mut().zip(old_samples.iter()).enumerate() { + let Some(bit) = u32::try_from(tap) + .ok() + .and_then(|tap| 1_u32.checked_shl(tap)) + else { + continue; + }; + if commanded & bit != 0 { + continue; + } + *new = *old; + armed = (armed & !bit) | (old_armed & bit); + } + if let Some(slot) = self.observation_armed.get_mut(into) { + *slot = armed; + } + if let Some(slot) = self.observation_commanded.get_mut(into) { + *slot = 0; + } + } + + // REALTIME_POLICY_END + /// The rows this shape is charged, or `None` on arithmetic overflow. fn bridge_rows(&self) -> Option { // The IDs the dense instance table copies out of the producers are retained beside the arm @@ -5486,7 +5729,15 @@ impl PlanShape { })?; let arm_table_bytes = u64::try_from(self.effect_instances.len()) .ok()? - .checked_mul((size_of::>() + size_of::>()) as u64)?; + .checked_mul( + (size_of::>() + size_of::>() + size_of::()) + as u64, + )? + .checked_add( + u64::try_from(self.reader_slots.len()) + .ok()? + .checked_mul(size_of::() as u64)?, + )?; let pending_bytes = u64::try_from(self.meter_pending.len()) .ok()? .checked_mul(size_of::>() as u64)?; diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 8c8b39bda..08b22c52f 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -6645,16 +6645,19 @@ mod structural_apply { windows } - /// The meter delivery across a swap, until #1290d carries it: a swap that keeps the strip - /// count restarts delivery on the successor's meters (a new epoch the page can see), and a - /// swap that changes the count publishes nothing rather than a frame whose words name other - /// strips (the worklet's views over the frame keep the boot's size). + /// The meter delivery across a swap (#1381b): a swap that keeps the strip count carries the + /// predecessor's meters, so the delivery epoch the page sees does not change and the windows + /// follow each other without a gap, and a swap that changes the count publishes nothing rather + /// than a frame whose words name other strips (the worklet's views over the frame keep the + /// boot's size, #1290d). /// - /// Test value: red if the meter poll reads past the boot-sized frame or the successor's - /// meters after a swap that adds a strip, or if a swap that keeps the strip count leaves the - /// delivery on the retired plan's state (no window would ever fold again). + /// Test value: red if the swap block's hook does not pair the meter consumers (the successor's + /// meters would sit on observers the retiring plan owns and no window would ever fold again), + /// if the swap restarts the delivery epoch or discards the windows waiting for a poll, or if + /// the poll reads past the boot-sized frame or the successor's meters after a swap that adds a + /// strip. #[test] - fn meter_delivery_restarts_on_the_successor_and_never_publishes_a_misshaped_frame() { + fn meter_delivery_carries_through_a_swap_and_never_publishes_a_misshaped_frame() { let model = gate_model(); // A swap that keeps the strips: an unused source is added. @@ -6664,6 +6667,7 @@ mod structural_apply { assert_eq!(rig.host.set_meter_lease(true), RESULT_OK); assert!(poll_blocks(&mut rig, 6) > 0, "the boot plan's windows fold"); let generation = rig.host.meter_header().reserved[0]; + let published = *rig.host.meter_header(); let strips = rig.host.live_control_tracks().len(); let base = rig.host.committed_revision(); assert_eq!( @@ -6679,8 +6683,17 @@ mod structural_apply { assert!(windows > 0, "the successor's windows fold"); assert_eq!( rig.host.meter_header().reserved[0], - generation + 1, - "the swap is a new delivery epoch" + generation, + "the swap is not a new delivery epoch: the meters carry" + ); + assert!( + rig.host.meter_header().sequence >= published.sequence + u64::from(windows), + "every window the successor closed was published" + ); + assert_eq!( + rig.host.meter_header().reserved[1], + published.reserved[1], + "no window was lost to the swap" ); // A swap that adds a strip. @@ -6703,16 +6716,126 @@ mod structural_apply { assert_eq!(rig.host.meter_header().sequence, header.sequence); } + /// An acknowledged subscription survives a swap (#1381b): the tap's armed bit and arm sample + /// move with its effect to the successor's slot, which a strip added in front shifts, and the + /// successor's reader is the carried tap's, so its windows continue. + /// + /// Test value: red if the swap block's hook does not pair the observation readers or the + /// table hand-over does not carry the armed bits through the reader-to-slot map (the tap would + /// read as unarmed and the page's subscription would silently stop after every swap), or if + /// it maps the readers to the wrong slot after the added strip shifts them. + #[test] + fn an_armed_tap_and_its_windows_follow_the_effect_through_a_swap() { + let mut model = + parse_session_json(include_str!("../tests/browser-v1/command-session.json")) + .expect("accepted command fixture"); + model.tracks[0].inserts.effects[0] = observed_compressor_insert(); + let mut rig = Rig::boot(&model, options(), None); + rig.step(); + assert_eq!( + observe(&mut rig.host, 0, RACK_INSERTS, 0, 1, 2, true), + RESULT_OK + ); + for _ in 0..8 { + rig.step(); + } + assert_eq!(rig.host.observation_armed_taps(), 1); + let address = |track_index| ObservationAddress { + track_index, + rack: LiveEffectRack::Inserts, + effect_index: 0, + tap_id: 1, + channels: ObservationReadChannels::Both, + }; + let before = rig + .host + .read_observation_addresses(&[address(0)]) + .expect("a read")[0] + .window + .expect("the armed tap published a window") + .sequence; + + let base = rig.host.committed_revision(); + assert!(matches!( + rig.apply(&add_muted_track(&model, ADDED), 1), + Outcome::Applied { + path: EditPath::Rebuild, + revision + } if revision == base + 1 + )); + rig.step(); + assert_eq!(rig.epoch(), 1); + assert_eq!( + rig.host.observation_armed_taps(), + 1, + "the subscription the plan had acknowledged is armed on the successor" + ); + assert_eq!(rig.host.service(), RESULT_OK); + for _ in 0..4 { + rig.step(); + } + let after = rig + .host + .read_observation_addresses(&[address(1)]) + .expect("a read at the shifted strip")[0] + .window + .expect("the carried tap kept publishing") + .sequence; + assert!(after > before, "the windows continue ({before} to {after})"); + } + + /// A tap command admitted against the successor at sample zero is not overwritten by the + /// predecessor's state (#1381b): the hand-over tells "commanded" from the arm sample's zero, + /// which is also the sample of a command admitted before the first block. + /// + /// Test value: red if the hand-over reads arm sample zero as "no command" (the predecessor's + /// armed bit would come back over a disarm admitted against the successor before any block + /// rendered, and the page's unsubscribe would silently be undone by the swap). + #[test] + fn a_tap_command_admitted_at_sample_zero_survives_the_hand_over() { + let mut model = + parse_session_json(include_str!("../tests/browser-v1/command-session.json")) + .expect("accepted command fixture"); + model.tracks[0].inserts.effects[0] = observed_compressor_insert(); + let mut rig = Rig::boot(&model, options(), None); + assert_eq!( + observe(&mut rig.host, 0, RACK_INSERTS, 0, 1, 2, true), + RESULT_OK, + "the subscription is admitted before the first block, at sample zero" + ); + assert_eq!(rig.host.observation_armed_taps(), 1); + assert!(matches!( + rig.apply(&add_muted_track(&model, ADDED), 1), + Outcome::Applied { + path: EditPath::Rebuild, + .. + } + )); + assert_eq!( + observe(&mut rig.host, 1, RACK_INSERTS, 0, 1, 2, false), + RESULT_OK, + "the disarm is admitted against the successor, still at sample zero" + ); + rig.step(); + assert_eq!(rig.epoch(), 1); + assert_eq!( + rig.host.observation_armed_taps(), + 0, + "the successor keeps the disarm it was commanded; the predecessor's arm is not carried" + ); + } + fn meter_loss(rig: &Rig) -> u64 { rig.host.ready.as_ref().expect("ready").meter_loss_count } /// Telemetry is dropped only with a count. A swap that changes the strip count publishes no - /// frame, so every window it leaves unpublished is added to the loss count the next published - /// frame reports, and the swap does not zero what was counted before it. + /// frame (the carried meters keep the delivery state, #1381b), so every window it leaves + /// unpublished is counted when the poll finds it unpublishable and added to the loss count + /// the next published frame reports, and the swap does not zero what was counted before it. /// /// Test value: red if the unpublished windows of a strip-count change vanish without a count - /// (a page would see a silent gap), or if the restart at the swap zeroes the loss count. + /// (a page would see a silent gap), or if the swap zeroes the loss count. #[test] fn meter_windows_a_strip_count_change_leaves_unpublished_are_counted() { let model = gate_model(); @@ -6738,9 +6861,9 @@ mod structural_apply { rig.step(); assert_eq!(rig.epoch(), 1); let after_swap = meter_loss(&rig); - assert!( - after_swap >= 5 + waiting, - "the swap keeps the count ({after_swap}) and adds the {waiting} windows it discarded" + assert_eq!( + after_swap, 5, + "the swap keeps the count; the {waiting} windows waiting are counted by the poll" ); // Every window closed afterwards is polled, found unpublishable and counted. diff --git a/scripts/check-web-audioworklet-callgraph.py b/scripts/check-web-audioworklet-callgraph.py index 4ae483c7a..40c272804 100644 --- a/scripts/check-web-audioworklet-callgraph.py +++ b/scripts/check-web-audioworklet-callgraph.py @@ -176,15 +176,29 @@ # review of new dynamic dispatch on the render thread, never a re-pin without its reason. INDIRECT_SITES: dict[str, dict[str, tuple[int, str]]] = { "miso_engine_web_v1_render": { + # Issue #1381b: the block that adopted a plan hands the plan's tables over together with + # what the swap block's attachment hook paired, which it reads from the active plan's + # companions. + "_RNvMs0_Cs_8host_webNtB5_22AudioWorkletEngineHost11render_next": ( + 2, + "`PlanState::active_attachment_mut::()` at the adoption block " + "(#1381b): the active plan's `Box` answers `as_any_mut` and the " + "downcast asks its `type_id`, one `call_indirect` each, neither of which allocates or " + "drops", + ), # Issue #1381a D4: the browser renders through the plan owner, so the block boundary's # swap decision is in the closure. The plan's own `render` entry is not: the owner calls # `render_inner` and counts a failed block itself. "_RNvMs6_NtNtCs_6engine8realtime13plan_exchangeNtB5_17RealtimePlanOwner11enter_block": ( - 2, + 4, "`running.prime_ready(...)`, the running plan's readiness hook, asked for a `Primed` " "candidate only (#1311), and `carry_from`'s executor hand-over at an adopted swap " - "(#1273), both through the plan's `Box`; the runtime " - "render-locked count covers them", + "(#1273), both through the plan's `Box`; and, at the same " + "adopted swap, `carry_from`'s attachment hand-over (#1381 D3: " + "`HostAttachment::adopt_predecessor(...)` through the plan's `Box`, " + "which pairs the host's readers with the observers the executors carried, #1327) and " + "its read of the successor executor's `observer_carries()` record (#1327 D3); the " + "runtime render-locked count covers them", ), "_RNvMs4_NtNtCs_6engine8realtime4planNtB5_18PreparedRenderPlan12render_inner": ( 2,