Files
Aura/docs/plans/0037-bound-param-in-graph-model.md
T
Brummel 4227073fce plan: 0037 bound-param-in-graph-model
Six bite-sized tasks for spec 0037: aura-core records a BoundParam (original slot
position) in bind + a bound_params() accessor; aura-engine emits a conditional
"bound" field via scalar_str; graph-viewer.js merges free+bound params into slot
order and renders the bound slot dimmed as name=value; re-capture the sample-model
fixture; a new headless render guard (trailing + middle bind); full-workspace gate.

refs #63
2026-06-13 23:14:49 +02:00

650 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String>,
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<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
```
with:
```rust
pub struct PrimitiveBuilder {
name: &'static str,
instance_name: Option<String>,
schema: NodeSchema,
bound: Vec<BoundParam>,
// 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<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}
```
(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<usize> = 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]) => `<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
const sig = o.params.length ? `<font color="#9399b2">[</font>${sp.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";
```
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(`<font color="#9399b2">${n}=${v}</font>`);
} else {
const [n, k] = o.params[fi++];
slots.push(`<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
}
}
const sig = total ? `<font color="#9399b2">[</font>${slots.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";
```
(`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 = '<font color="#9399b2">weights[2]=0.5</font>';
const FREE0 = '<font color="#89b4fa">weights[0]</font>';
const FREE1 = '<font color="#89b4fa">weights[1]</font>';
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 = '<font color="#89b4fa">a</font>';
const MB = '<font color="#9399b2">b=9.0</font>';
const MC = '<font color="#89b4fa">c</font>';
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 16 (render: 3+5; model: 2+4; tunable surface untouched: 6; tests: 1,2,5) |