diff --git a/docs/plans/0037-bound-param-in-graph-model.md b/docs/plans/0037-bound-param-in-graph-model.md new file mode 100644 index 0000000..823fc0b --- /dev/null +++ b/docs/plans/0037-bound-param-in-graph-model.md @@ -0,0 +1,649 @@ +# Bound params in the graph model + viewer signature — Implementation Plan + +> **Parent spec:** `docs/specs/0037-bound-param-in-graph-model.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Surface a `bind`-bound param in the graph model and the `aura graph` +viewer signature — all slots shown, the bound one as `name=value`, dimmed — while +leaving the tunable surface (`param_space` / sweep axes) untouched. + +**Architecture:** A conditional field threaded `engine → model → viewer`, the +twin of cycle 0035's instance-name thread. `PrimitiveBuilder::bind` records a +`BoundParam` (original slot position + name + kind + value); `prim_record` emits a +conditional `"bound"` field via a new `scalar_str` helper; `graph-viewer.js` +merges free + bound params into slot order by position and renders the bound slot +dimmed. Render/debug surface only — dropped at lowering (C23), model stays +deterministic (C14). Purely additive: a new struct, a `Vec::new()`-default field, +a new accessor, a conditional model field defaulting to empty, a viewer field +defaulting to `[]`. No call site of `bind`/`prim_record`/`adaptNodes`/`genDot`/ +`cellLabel` changes arity. + +**Tech Stack:** `crates/aura-core/src/node.rs` (Rust), `crates/aura-engine/src/ +graph_model.rs` (Rust), `crates/aura-cli/assets/graph-viewer.js` (JS), +`crates/aura-cli/tests/` (Rust + headless `node` guards), the +`crates/aura-cli/tests/fixtures/sample-model.json` golden. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-core/src/node.rs` — `BoundParam` struct (after `ParamSpec`, + ~:67), `PrimitiveBuilder.bound` field (struct :77-85), init in `new` (:96), + record in `bind` (between the kind-assert :172 and `remove(pos)` :173), + `bound_params()` accessor (after `instance_name()` :120-122), unit test (tests + mod :229+, beside `bind_removes_slot_and_reconstructs_positionally` :386). +- Modify: `crates/aura-engine/src/graph_model.rs` — `scalar_str` helper (beside + `kind_str` :14-21), conditional `"bound"` field in `prim_record` (:72-89; field + block after the `name` conditional :80-83, `{bound}` in the format string :85), + unit test (tests mod, beside `prim_record_emits_name_for_explicit_only` :383). +- Modify: `crates/aura-cli/assets/graph-viewer.js` — `adaptNodes` (:32), `genDot` + leaf-emit cellLabel call (:111), `cellLabel` `sp`/`sig` (:72-73). +- Modify: `crates/aura-cli/tests/fixtures/sample-model.json` — re-capture; the + `signals` composite's node `"2"` (`blend`) gains `"bound":[[2,"weights[2]","f64","0.5"]]`. +- Create: `crates/aura-cli/tests/viewer_bound_param.mjs` — headless render guard + (trailing + middle bind). +- Create: `crates/aura-cli/tests/viewer_bound_param.rs` — Rust shell that runs the + `.mjs` via `node`. Mirror of `viewer_name_prefix.rs`. + +**Note on verification commands (project memory):** use ONE positional +`cargo test` filter per invocation (a multi-filter `cargo test X Y` silently +prints nothing here); for "everything still green" use an unfiltered per-crate run +and read the summary count. The viewer guards REQUIRE `node` on PATH +(`/usr/bin/node`, v26 confirmed) — a skipped guard is not a guard. + +**Parse-the-bytes gate:** the project declares no `spec_validation` parser (see +CLAUDE.md project facts), so the inline-body parse gate is a documented no-op; the +Rust bodies are caught by the `implement` compile gate and the JS by the `node` +guard run. + +--- + +### Task 1: aura-core — `BoundParam`, `bind` records original position, `bound_params()` + +**Files:** +- Modify: `crates/aura-core/src/node.rs` +- Test: `crates/aura-core/src/node.rs` (tests mod) + +- [ ] **Step 1: Write the failing test** + +In `crates/aura-core/src/node.rs`, inside `#[cfg(test)] mod tests` (after +`bind_removes_slot_and_reconstructs_positionally`, ~:408), add: + +```rust + #[test] + fn bind_records_bound_param_at_original_position() { + // middle-of-three: binding `b` records its ORIGINAL slot position (1). + let mid = probe3().bind("b", Scalar::I64(8)); + assert_eq!( + mid.bound_params(), + [BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) }] + .as_slice(), + ); + + // chained reverse-order bind (b then a): each entry carries its ORIGINAL + // position regardless of the shrinking array — positions {1, 0} in call order. + let pair = probe3().bind("b", Scalar::I64(8)).bind("a", Scalar::I64(7)); + assert_eq!( + pair.bound_params(), + [ + BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) }, + BoundParam { pos: 0, name: "a".into(), kind: ScalarKind::I64, value: Scalar::I64(7) }, + ] + .as_slice(), + ); + + // an unbound builder records nothing. + assert!(probe3().bound_params().is_empty()); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-core bind_records_bound_param_at_original_position` +Expected: FAIL — does not compile: `cannot find type BoundParam in this scope` / +`no method named bound_params found for struct PrimitiveBuilder`. + +- [ ] **Step 3: Write the implementation** + +(3a) Add the `BoundParam` struct immediately after `ParamSpec` (after its closing +`}` at ~:67, before the `PrimitiveBuilder` doc-comment at :69). `Scalar` / +`ScalarKind` are already imported at `node.rs:13` — no new import: + +```rust +/// One param bound to a structural constant by [`PrimitiveBuilder::bind`] — a +/// render/debug surface only (C23), dropped at lowering. `pos` is the slot's +/// position in the node's ORIGINAL (pre-bind) param list, so the model serializer +/// and viewer can place it back into slot order regardless of bind sequence. +#[derive(Clone, Debug, PartialEq)] +pub struct BoundParam { + pub pos: usize, + pub name: String, + pub kind: ScalarKind, + pub value: Scalar, +} +``` + +(3b) Add the `bound` field to `PrimitiveBuilder` (struct at :77-85). Replace: + +```rust +pub struct PrimitiveBuilder { + name: &'static str, + instance_name: Option, + schema: NodeSchema, + // The build closure's type is exactly the recipe contract (a param slice in, a + // boxed node out); a type alias would not clarify it. + #[allow(clippy::type_complexity)] + build: Box Box>, +} +``` + +with: + +```rust +pub struct PrimitiveBuilder { + name: &'static str, + instance_name: Option, + schema: NodeSchema, + bound: Vec, + // The build closure's type is exactly the recipe contract (a param slice in, a + // boxed node out); a type alias would not clarify it. + #[allow(clippy::type_complexity)] + build: Box Box>, +} +``` + +(3c) Initialize `bound` in `new` (:96). Replace: + +```rust + Self { name, instance_name: None, schema, build: Box::new(build) } +``` + +with: + +```rust + Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) } +``` + +(3d) Add the `bound_params()` accessor immediately after `instance_name()` +(after its closing `}` at :122): + +```rust + /// The params bound to structural constants by [`bind`](Self::bind), in + /// bind-call order, each carrying its ORIGINAL slot position. The render- + /// surface twin of [`instance_name`](Self::instance_name): the model + /// serializer reads it to show a bound slot's fixed value (the value captured + /// in the build closure is otherwise unreachable to it). Empty when no binds. + pub fn bound_params(&self) -> &[BoundParam] { + &self.bound + } +``` + +(3e) In `bind`, record the `BoundParam` at its original position. Insert between +the kind-assert (ends :172) and `self.schema.params.remove(pos);` (:173): + +```rust + // [render side] record the bound slot for the model/viewer at its ORIGINAL + // pre-bind position. `pos` indexes the already-shrunk array (earlier binds + // removed), so map it back to the full-arity index by adding one for each + // earlier-bound slot at an original position <= it. Render/debug only (C23); + // the closure capture below is what bootstrap actually reads. + let name = self.schema.params[pos].name.clone(); + let kind = self.schema.params[pos].kind; + let mut orig = pos; + let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect(); + prior.sort_unstable(); + for p in prior { + if p <= orig { + orig += 1; + } + } + self.bound.push(BoundParam { pos: orig, name, kind, value }); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p aura-core bind_records_bound_param_at_original_position` +Expected: PASS (`test ... ok`, 1 passed). + +- [ ] **Step 5: Verify aura-core suite stays green** + +Run: `cargo test -p aura-core` +Expected: PASS — all tests ok, 0 failed (existing `bind_*` tests included). + +--- + +### Task 2: aura-engine — `scalar_str` + conditional `"bound"` in `prim_record` + +**Files:** +- Modify: `crates/aura-engine/src/graph_model.rs` +- Test: `crates/aura-engine/src/graph_model.rs` (tests mod) + +- [ ] **Step 1: Write the failing test** + +In `crates/aura-engine/src/graph_model.rs`, inside `#[cfg(test)] mod tests` (after +`prim_record_emits_name_for_explicit_only`, ~:394), add. The tests mod already +imports `Scalar`, `PrimitiveBuilder` (:244-245) and `aura_std::{Exposure, …, Sma}` +(:247): + +```rust + #[test] + fn prim_record_emits_bound_only_when_bound() { + // a bound i64 param → a "bound" field carrying [pos,"name","kind","value"] + let bound = prim_record(&Sma::builder().bind("length", Scalar::I64(5))); + assert!(bound.contains(r#""bound":[[0,"length","i64","5"]]"#), "{bound}"); + // the bound slot also leaves the tunable "params" surface (bind's tuning side) + assert!(bound.contains(r#""params":[]"#), "{bound}"); + + // a bound f64 value renders canonically (shortest round-trip), e.g. 0.5 + let f = prim_record(&Exposure::builder().bind("scale", Scalar::F64(0.5))); + assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}"); + + // an unbound builder → no "bound" field at all + let unbound = prim_record(&Sma::builder()); + assert!(!unbound.contains(r#""bound""#), "{unbound}"); + } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p aura-engine prim_record_emits_bound_only_when_bound` +Expected: FAIL — does not compile: `no method named bound_params` (until Task 1 +lands) OR, with Task 1 present, assertion failure (`"bound"` substring absent). + +- [ ] **Step 3: Write the implementation** + +(3a) Add the `scalar_str` helper immediately after `kind_str` (after its closing +`}` at :21): + +```rust +/// A scalar VALUE as a canonical, deterministic string for the model (C14). `f64` +/// uses the shortest round-trippable debug form, which keeps a decimal point on +/// whole values (`1.0`, not `1`) so a bound f64 is never mistaken for an i64 in +/// the render. The value side of `kind_str`. +fn scalar_str(s: aura_core::Scalar) -> String { + use aura_core::Scalar; + match s { + Scalar::I64(n) => n.to_string(), + Scalar::F64(f) => format!("{f:?}"), + Scalar::Bool(b) => b.to_string(), + Scalar::Ts(t) => t.0.to_string(), + } +} +``` + +(3b) In `prim_record` (:72-89), add the `bound` field block after the `name` +conditional (after its closing line :83, before the `format!` at :84): + +```rust + // bound params: present only when the node has binds (mirrors the conditional + // "name" field). each entry: [pos,"name","kind","value"]. + let bound = if b.bound_params().is_empty() { + String::new() + } else { + let items = join(b.bound_params().iter().map(|bp| { + format!( + "[{},{},{},{}]", + bp.pos, + json_str(&bp.name), + json_str(kind_str(bp.kind)), + json_str(&scalar_str(bp.value)), + ) + })); + format!(r#","bound":[{items}]"#) + }; +``` + +(3c) Insert `{bound}` into the `format!` template (:85), after `"params":[{params}]` +and before `,"ins"`. Replace: + +```rust + r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#, +``` + +with: + +```rust + r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p aura-engine prim_record_emits_bound_only_when_bound` +Expected: PASS (1 passed). + +- [ ] **Step 5: Verify the no-bind golden stays byte-stable** + +Run: `cargo test -p aura-engine model_golden` +Expected: PASS — the inline `model_golden` (`sample_root`, no binds) is unchanged: +the conditional field is inert for unbound nodes. + +- [ ] **Step 6: Verify aura-engine suite stays green** + +Run: `cargo test -p aura-engine` +Expected: PASS — all tests ok, 0 failed (`model_is_deterministic` included). + +--- + +### Task 3: graph-viewer.js — carry `bound`, render the merged signature + +**Files:** +- Modify: `crates/aura-cli/assets/graph-viewer.js` + +- [ ] **Step 1: `adaptNodes` carries `bound`** + +In `crates/aura-cli/assets/graph-viewer.js` (:32), replace: + +```js + out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } }; +``` + +with: + +```js + out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, bound: p.bound || [], ins: adaptIns(p.ins), outs: p.outs } }; +``` + +- [ ] **Step 2: `genDot` leaf-emit forwards `bound`** + +In `genDot` (:111), replace: + +```js + block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`; +``` + +with: + +```js + block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, bound: s.bound, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`; +``` + +- [ ] **Step 3: `cellLabel` merges free + bound params into slot order** + +In `cellLabel` (:72-73), replace: + +```js + const sp = o.params.map(([n, k]) => `${showTypes && k ? `${n}:${k}` : n}`); + const sig = o.params.length ? `[${sp.join(', ')}]` : ""; +``` + +with: + +```js + // merge free params and bound params back into slot order by original position; + // a bound slot renders as a dimmed name=value (reuses the 0035 separator grey). + const bnd = o.bound || []; + const total = o.params.length + bnd.length; + const byPos = new Map(bnd.map(([pos, n, k, v]) => [pos, [n, k, v]])); + const slots = []; + let fi = 0; + for (let pos = 0; pos < total; pos++) { + if (byPos.has(pos)) { + const [n, , v] = byPos.get(pos); + slots.push(`${n}=${v}`); + } else { + const [n, k] = o.params[fi++]; + slots.push(`${showTypes && k ? `${n}:${k}` : n}`); + } + } + const sig = total ? `[${slots.join(', ')}]` : ""; +``` + +(`o.bound || []` keeps the composite / boundary `cellLabel` calls — which pass no +`bound` — rendering exactly as before: `total` collapses to `params.length`.) + +- [ ] **Step 4: Verify the existing viewer guards stay green** + +The existing guards exercise `genDot`/`cellLabel` over models with no `bound` +field; the merge must render them identically. They read the OLD fixture (Task 4 +re-captures it) — both states are backward-compatible. + +Run: `cargo test -p aura-cli --test viewer_name_prefix` +Expected: PASS. + +Run: `cargo test -p aura-cli --test viewer_dot` +Expected: PASS. + +Run: `cargo test -p aura-cli --test viewer_nested_depth` +Expected: PASS. + +--- + +### Task 4: Re-capture the `sample-model.json` fixture + +**Files:** +- Modify: `crates/aura-cli/tests/fixtures/sample-model.json` +- Modify (temporary): `crates/aura-cli/src/main.rs` (a throwaway capture test, removed in Step 4) + +- [ ] **Step 1: Add a temporary capture test** + +In `crates/aura-cli/src/main.rs`, inside `#[cfg(test)] mod tests` (:560, +`use super::*` already present), add: + +```rust + #[test] + fn capture_sample_model_tmp() { + println!("AURA_MODEL_CAPTURE:{}", aura_engine::model_to_json(&sample_blueprint())); + } +``` + +- [ ] **Step 2: Capture the model into the fixture** + +Run (one positional filter, per project note): + +```bash +cargo test -p aura-cli capture_sample_model_tmp -- --nocapture 2>/dev/null \ + | grep '^AURA_MODEL_CAPTURE:' | sed 's/^AURA_MODEL_CAPTURE://' \ + > crates/aura-cli/tests/fixtures/sample-model.json +``` + +Expected: the fixture is rewritten as a single JSON line. Verify the `blend` +node gained the bound field: + +```bash +grep -c '"bound":\[\[2,"weights\[2\]","f64","0.5"\]\]' crates/aura-cli/tests/fixtures/sample-model.json +``` + +Expected: `1`. + +- [ ] **Step 3: Validate the fixture is well-formed JSON** + +Run: + +```bash +node -e "JSON.parse(require('fs').readFileSync('crates/aura-cli/tests/fixtures/sample-model.json','utf8')); console.log('OK valid JSON')" +``` + +Expected: `OK valid JSON`. + +- [ ] **Step 4: Remove the temporary capture test** + +Delete the `capture_sample_model_tmp` test added in Step 1 from +`crates/aura-cli/src/main.rs`. + +- [ ] **Step 5: Verify the fixture consumers stay green** + +The two consumers assert DOT-id grammar / nesting depth, not params, and read the +re-captured fixture (now carrying `bound`, handled by Task 3's `p.bound || []`). + +Run: `cargo test -p aura-cli --test viewer_dot` +Expected: PASS. + +Run: `cargo test -p aura-cli --test viewer_nested_depth` +Expected: PASS. + +--- + +### Task 5: New headless render guard — bound slot in true slot order + +**Files:** +- Create: `crates/aura-cli/tests/viewer_bound_param.mjs` +- Create: `crates/aura-cli/tests/viewer_bound_param.rs` + +- [ ] **Step 1: Write the headless guard script** + +Create `crates/aura-cli/tests/viewer_bound_param.mjs`: + +```js +// Headless render guard for the `aura graph` viewer (graph-viewer.js). +// +// Property protected: a bind-bound param slot renders in the node signature as a +// DIMMED `name=value`, in TRUE slot order (by original position), with the free +// slots in their type colour — never dropped. Covers a trailing bind (the +// cycle-0036 showcase shape) AND a non-trailing (middle) bind, which an +// append-after-free design would render in the wrong order. +// +// It loads the real viewer module and drives the exported pure `genDot`. `node` +// is REQUIRED: a skipped guard is not a guard. + +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); + +// Load the viewer once with an empty model; normalizeModel/genDot are pure and +// take the model/def explicitly, so each case is rendered by passing its own. +global.window = { AURA_MODEL: { root: { nodes: {}, edges: [] }, composites: {} } }; +const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js")); +const dotFor = (model) => genDot(normalizeModel(model).root, "root", new Set(), false).dot; + +const fail = (msg, dot) => { + console.error(msg + "\n--- dot ---\n" + dot); + process.exit(1); +}; + +// --- Model A: trailing bind (the cycle-0036 showcase shape) --- +const trailing = { + root: { nodes: { "0": { prim: { + name: "blend", type: "LinComb", role: "node", + params: [["weights[0]", "f64"], ["weights[1]", "f64"]], + bound: [[2, "weights[2]", "f64", "0.5"]], + ins: [["f64", "any", "term[0]"], ["f64", "any", "term[1]"], ["f64", "any", "term[2]"]], + outs: [["value", "f64"]], + } } }, edges: [] }, + composites: {}, +}; +const dotA = dotFor(trailing); + +const BOUND = 'weights[2]=0.5'; +const FREE0 = 'weights[0]'; +const FREE1 = 'weights[1]'; +if (!dotA.includes(BOUND)) fail("trailing: bound slot weights[2]=0.5 not rendered dimmed", dotA); +if (!dotA.includes(FREE0) || !dotA.includes(FREE1)) fail("trailing: a free weight slot is missing or not type-coloured", dotA); +const a0 = dotA.indexOf(FREE0), a1 = dotA.indexOf(FREE1), ab = dotA.indexOf(BOUND); +if (!(a0 < a1 && a1 < ab)) fail("trailing: slot order is not weights[0] < weights[1] < weights[2]=0.5", dotA); + +// --- Model B: NON-TRAILING (middle) bind — pins slot-order faithfulness --- +const middle = { + root: { nodes: { "0": { prim: { + name: "mid", type: "Probe", role: "node", + params: [["a", "f64"], ["c", "f64"]], + bound: [[1, "b", "f64", "9.0"]], + ins: [["f64", "any", "t0"], ["f64", "any", "t1"], ["f64", "any", "t2"]], + outs: [["value", "f64"]], + } } }, edges: [] }, + composites: {}, +}; +const dotB = dotFor(middle); + +const MA = 'a'; +const MB = 'b=9.0'; +const MC = 'c'; +if (!dotB.includes(MA) || !dotB.includes(MB) || !dotB.includes(MC)) fail("middle: a free or bound slot is missing", dotB); +const ba = dotB.indexOf(MA), bb = dotB.indexOf(MB), bc = dotB.indexOf(MC); +if (!(ba < bb && bb < bc)) fail("middle: bound slot b=9.0 not placed between free a and c (append-only bug)", dotB); + +console.log("OK — bound slot renders dimmed name=value in true slot order (trailing + middle bind)."); +process.exit(0); +``` + +- [ ] **Step 2: Write the Rust shell** + +Create `crates/aura-cli/tests/viewer_bound_param.rs`: + +```rust +//! Integration guard: the `aura graph` viewer renders a bind-bound param slot as +//! a dimmed `name=value` in true slot order (trailing + non-trailing bind). +//! +//! The property lives in JavaScript (`cellLabel` in assets/graph-viewer.js), so +//! this shells out to `node` running the headless guard +//! `tests/viewer_bound_param.mjs`, which loads the real viewer module and drives +//! the exported `genDot` over two minimal models. +//! +//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is +//! not a guard). + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn viewer_renders_bound_param_in_slot_order() { + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("viewer_bound_param.mjs"); + assert!(script.exists(), "guard script missing at {}", script.display()); + + let out = match Command::new("node").arg(&script).output() { + Ok(out) => out, + Err(e) => panic!( + "node is required for the viewer bound-param guard but could not be run \ + ({e}); install Node.js or ensure `node` is on PATH" + ), + }; + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success(), + "viewer bound-param guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}", + out.status.code() + ); +} +``` + +- [ ] **Step 3: Run the new guard to verify it passes** + +Run: `cargo test -p aura-cli --test viewer_bound_param` +Expected: PASS — `viewer_renders_bound_param_in_slot_order ... ok`; the `.mjs` +prints `OK — bound slot renders dimmed name=value in true slot order …`. + +--- + +### Task 6: Full-workspace gate + +**Files:** none (verification only) + +- [ ] **Step 1: Build** + +Run: `cargo build --workspace` +Expected: finishes, 0 errors. + +- [ ] **Step 2: Test** + +Run: `cargo test --workspace` +Expected: all tests pass, 0 failed (the new aura-core + aura-engine unit tests, +the new `viewer_bound_param` guard, and every existing test — the sweep param +pins at `main.rs:595-598` and `cli_run.rs:214` stay byte-identical, since the +tunable surface is unchanged). + +- [ ] **Step 3: Lint** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: finishes, 0 warnings. + +--- + +## Spec coverage map + +| Spec section | Task | +|---|---| +| `BoundParam` + `bind` records original position + `bound_params()` | Task 1 | +| `scalar_str` + conditional `"bound"` in `prim_record` | Task 2 | +| `adaptNodes`/`genDot`/`cellLabel` merge + dimmed render | Task 3 | +| `sample-model.json` re-capture (blend gains `"bound"`) | Task 4 | +| Headless render guard (trailing + middle bind, slot order) | Task 5 | +| Goldens / sweep pins stay green; build/test/clippy clean | Tasks 2, 4, 6 | +| Acceptance criteria #1–#4 | Tasks 1–6 (render: 3+5; model: 2+4; tunable surface untouched: 6; tests: 1,2,5) |