4227073fce
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
650 lines
24 KiB
Markdown
650 lines
24 KiB
Markdown
# 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 1–6 (render: 3+5; model: 2+4; tunable surface untouched: 6; tests: 1,2,5) |
|