spec: 0037 bound-param-in-graph-model (boss-signed)
Surface a bind-bound param in the graph model and the `aura graph` viewer signature instead of dropping it. A node built with `.bind(slot, value)` keeps the slot off the tunable surface (param_space / sweep axes — unchanged, correct) but now ANNOTATES it in the render: all slots shown, the bound one as `name=value`, dimmed. The cycle-0036 `blend` renders `LinComb[weights[0], weights[1], weights[2]=0.5]`. Structurally a twin of cycle 0035's instance-name thread: a conditional field (here `"bound"`) threaded engine -> model -> viewer, plus goldens and a headless render guard. Render/debug surface only — dropped at lowering (C23), model stays deterministic (C14). Auto-signed under the /boss spec auto-sign gate: precondition clean (the one open notation decision was settled with the user in-session and recorded as a provenance-bearing reconciliation comment on the issue), self-review + parse-trace clean, grounding-check PASS, and a unanimous five-lens spec-skeptic panel. refs #63
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
# Bound params in the graph model + viewer signature — Design Spec
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** orchestrator + Claude
|
||||
|
||||
## Goal
|
||||
|
||||
A node built with `.bind(slot, value)` fixes one declared param to a structural
|
||||
constant: the slot leaves the *tunable* surface (`params()` / the aggregated
|
||||
`param_space` / the sweep axes) — correct, and the point of `bind`. But it also
|
||||
vanishes from the *rendered node signature*, because the graph-model serializer
|
||||
(`prim_record`) emits only `schema.params`, which no longer holds the bound slot.
|
||||
|
||||
The observable defect (cycle 0036 showcase): the `signals` composite's `blend`
|
||||
node is a `LinComb(3)` with three input ports (`term[0..2]`) whose third weight
|
||||
is `bind`-bound to `0.5`. The viewer renders
|
||||
|
||||
```
|
||||
blend: LinComb[weights[0], weights[1]]
|
||||
```
|
||||
|
||||
— a 2-arity signature over a box that shows 3 input ports, with the fixed `0.5`
|
||||
**invisible**. The signature misrepresents the node.
|
||||
|
||||
This cycle separates the two concerns `bind` conflates:
|
||||
|
||||
- **Tunable surface** (`param_space`, sweep axes, C12/C19): `bind` *removes* the
|
||||
slot. Unchanged — correct.
|
||||
- **Node signature** (the render): `bind` should *annotate*, not erase. Show all
|
||||
slots; mark the bound one with its fixed value.
|
||||
|
||||
Target render (the settled notation — see *Settled design decisions*):
|
||||
|
||||
```
|
||||
blend: LinComb[weights[0], weights[1], weights[2]=0.5]
|
||||
```
|
||||
|
||||
all three slots, the bound `weights[2]=0.5` visually dimmed. This is consistent
|
||||
with the value-empty blueprint convention: a *free* param renders as a name
|
||||
(value deferred to sweep/bootstrap, e.g. `SMA[length]`); a *bound* param has its
|
||||
value fixed at blueprint time, so it renders `name=value`. Showing the bound
|
||||
value is showing a **structural fact**, not a tuning value.
|
||||
|
||||
The change is structurally identical to cycle 0035 (commit `0ae8320`, the node
|
||||
instance-name surface): a conditional field threaded `engine → model → viewer`
|
||||
plus goldens and a headless render guard. Like the instance name, the bound
|
||||
annotation is a **render/model-only debug symbol**, dropped at lowering (C23),
|
||||
and the model stays deterministic (C14).
|
||||
|
||||
## Settled design decisions
|
||||
|
||||
The issue (#63) flagged **one** open design decision — the render notation —
|
||||
"settle BEFORE implementing (with the user)". It was settled with the user in
|
||||
this `/boss` session (reconciliation comment posted on #63, 2026-06-13):
|
||||
|
||||
- **Notation = Option A.** Keep the square-bracket `TYPE[...]` convention (as
|
||||
`SMA[length]`); render the bound slot as `name=value` (`weights[2]=0.5`),
|
||||
visually dimmed (reuse the 0035 `:` separator's muted grey `#9399b2`).
|
||||
Rejected alternatives: positional value-only (`...,0.5`, drops the slot name);
|
||||
parens (`LinComb(...)`, a global notation change beyond this issue's scope).
|
||||
|
||||
Two further decisions are realizations of the settled notation, not open forks
|
||||
(they do not change the cycle's observable acceptance — the showcase bind is of
|
||||
the *trailing* slot, where every option renders identically):
|
||||
|
||||
- **Slot-order faithfulness — record the original slot position.** The issue's
|
||||
stated goal is "merge the free params and the bound params back into **slot
|
||||
order**". For a *trailing* bind (the showcase) "append bound after free" and
|
||||
"true slot order" coincide; for a *non-trailing* bind they diverge. Honouring
|
||||
the issue's own "slot order" goal for **any** bind pattern requires the bound
|
||||
record to carry the slot's original position. This is the value-empty-blueprint
|
||||
convention applied faithfully (a slot's *position* is a structural fact, like
|
||||
its name). The issue's illustrative triple `["weights[2]","f64","0.5"]` (an
|
||||
`e.g.`) therefore gains a leading position: `[2,"weights[2]","f64","0.5"]`.
|
||||
Chosen on substance (what the design permits — correct rendering of mid-slot
|
||||
binds — and compositional clarity), not effort.
|
||||
|
||||
- **Deterministic value rendering (C14).** The bound `Scalar` value formats to a
|
||||
canonical string via a new `scalar_str` helper, mirroring the existing
|
||||
`kind_str`: `i64`/`bool`/`timestamp` by their integer/`true`/`false`/epoch-ns
|
||||
form, `f64` by Rust's shortest round-trippable debug form (`{:?}`), which keeps
|
||||
a decimal point for whole values (`1.0`, not `1`) so a bound `f64` never reads
|
||||
as an `i64` in the head. `0.5` renders `0.5` under either Display or Debug; the
|
||||
Debug choice only disambiguates whole-number floats.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers, mirroring the 0035 thread, plus goldens and a guard:
|
||||
|
||||
1. **aura-core** (`node.rs`, `PrimitiveBuilder`): `bind` additionally records a
|
||||
`bound: Vec<BoundParam>` entry — the bound slot's original position, name,
|
||||
kind, and value — alongside the existing closure capture (which is unchanged).
|
||||
A `bound_params()` accessor exposes it, the twin of `instance_name()`.
|
||||
|
||||
2. **aura-engine** (`graph_model.rs`, `prim_record`): emit a conditional
|
||||
`"bound"` field — present only when non-empty (mirrors the conditional
|
||||
`"name"` field) — each entry serialized `[pos,"name","kind","value"]` via a
|
||||
new `scalar_str` value helper. Deterministic (C14).
|
||||
|
||||
3. **graph-viewer.js** (`adaptNodes`, `genDot` leaf-emit, `cellLabel`): carry
|
||||
`bound`; `cellLabel` merges the free params and the bound params back into
|
||||
slot order by position and renders a bound slot as `weights[2]=0.5`, dimmed.
|
||||
|
||||
4. **Goldens + tests**: re-capture the `sample-model.json` fixture (the `blend`
|
||||
node gains a `"bound"` field); the engine's own inline `model_golden`
|
||||
(`sample_root`, which uses no `bind`) stays byte-unchanged. Add a
|
||||
`prim_record` unit test (bound field present only when bound) and a headless
|
||||
viewer render guard (bound slot renders `name=value`, dimmed; free slots
|
||||
unchanged; slot order correct for a non-trailing bind).
|
||||
|
||||
## Concrete code shapes
|
||||
|
||||
### User-facing program (the acceptance evidence)
|
||||
|
||||
The authoring is **already shipped** (cycle 0036, `aura-cli/src/main.rs`
|
||||
`signals()`); this cycle fixes only what it *renders*:
|
||||
|
||||
```rust
|
||||
// crates/aura-cli/src/main.rs — signals(), UNCHANGED authoring
|
||||
LinComb::builder(3)
|
||||
.named("blend")
|
||||
.bind("weights[2]", Scalar::F64(0.5)) // a fixed signal-line weight
|
||||
.into()
|
||||
```
|
||||
|
||||
`aura graph` viewer box head, **before** this cycle (the defect):
|
||||
|
||||
```
|
||||
blend: LinComb[weights[0], weights[1]] # 2 slots, but the box shows 3 ports
|
||||
```
|
||||
|
||||
**after** this cycle (the fix — `weights[2]=0.5` dimmed):
|
||||
|
||||
```
|
||||
blend: LinComb[weights[0], weights[1], weights[2]=0.5]
|
||||
```
|
||||
|
||||
A trader/researcher who fixes a weight with `.bind()` and opens the graph now
|
||||
reads the true signature: all three slots, the constant `0.5` shown as a fixed
|
||||
value rather than silently dropped. This is the criterion's evidence — the
|
||||
feature measurably removes a misrepresentation (signature arity now matches the
|
||||
port count) and reintroduces no failure class (render-only; C23/C14 intact).
|
||||
|
||||
### aura-core — `node.rs` (`PrimitiveBuilder`)
|
||||
|
||||
A small record type and a field on the builder:
|
||||
|
||||
```rust
|
||||
/// One param bound to a structural constant by `bind` (render/debug only, C23).
|
||||
/// `pos` is the slot's position in the node's ORIGINAL (pre-bind) param list, so
|
||||
/// the renderer 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,
|
||||
}
|
||||
```
|
||||
|
||||
before — the builder fields:
|
||||
|
||||
```rust
|
||||
pub struct PrimitiveBuilder {
|
||||
name: &'static str,
|
||||
instance_name: Option<String>,
|
||||
schema: NodeSchema,
|
||||
#[allow(clippy::type_complexity)]
|
||||
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
|
||||
}
|
||||
```
|
||||
|
||||
after — add `bound` (and initialize it `Vec::new()` in `new`):
|
||||
|
||||
```rust
|
||||
pub struct PrimitiveBuilder {
|
||||
name: &'static str,
|
||||
instance_name: Option<String>,
|
||||
schema: NodeSchema,
|
||||
bound: Vec<BoundParam>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
|
||||
}
|
||||
```
|
||||
|
||||
before — `bind` (the closure mechanics, unchanged) records nothing visible:
|
||||
|
||||
```rust
|
||||
pub fn bind(mut self, slot: &str, value: Scalar) -> Self {
|
||||
// ... matches/pos resolution + kind assert (unchanged) ...
|
||||
self.schema.params.remove(pos); // [param_space side]
|
||||
let inner = self.build; // [value side]
|
||||
self.build = Box::new(move |open: &[Scalar]| {
|
||||
let mut full = open.to_vec();
|
||||
full.insert(pos, value);
|
||||
inner(&full)
|
||||
});
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
after — record a `BoundParam` with the slot's ORIGINAL position before removal.
|
||||
`pos` is the index in the already-shrunk array; map it to the original index by
|
||||
adding back every earlier-bound slot at an original position `<= ` it:
|
||||
|
||||
```rust
|
||||
pub fn bind(mut self, slot: &str, value: Scalar) -> Self {
|
||||
// ... matches/pos resolution + kind assert (unchanged) ...
|
||||
let removed = &self.schema.params[pos]; // capture name+kind before removal
|
||||
// map the compressed index `pos` to the original full-arity position:
|
||||
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: removed.name.clone(),
|
||||
kind: removed.kind,
|
||||
value,
|
||||
});
|
||||
self.schema.params.remove(pos); // [param_space side] — unchanged
|
||||
let inner = self.build; // [value side] — unchanged
|
||||
self.build = Box::new(move |open: &[Scalar]| {
|
||||
let mut full = open.to_vec();
|
||||
full.insert(pos, value);
|
||||
inner(&full)
|
||||
});
|
||||
self
|
||||
}
|
||||
```
|
||||
|
||||
new accessor — the twin of `instance_name()`:
|
||||
|
||||
```rust
|
||||
/// The params bound to structural constants by `bind`, in bind-call order, each
|
||||
/// carrying its ORIGINAL slot position. The render-surface twin of the value
|
||||
/// captured in the build closure (unreachable to the model serializer, which
|
||||
/// never builds). Empty for a node with no binds.
|
||||
pub fn bound_params(&self) -> &[BoundParam] {
|
||||
&self.bound
|
||||
}
|
||||
```
|
||||
|
||||
### aura-engine — `graph_model.rs` (`prim_record` + a value helper)
|
||||
|
||||
new value helper, mirroring `kind_str`:
|
||||
|
||||
```rust
|
||||
/// A scalar VALUE as a canonical, deterministic string for the model (C14).
|
||||
/// `f64` uses the shortest round-trippable debug form (keeps a decimal point on
|
||||
/// whole values, so a bound f64 is not mistaken for an i64 in the render).
|
||||
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(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
before — `prim_record` emits `{name?}type/role/params/ins/outs`:
|
||||
|
||||
```rust
|
||||
fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
|
||||
let s = b.schema();
|
||||
let role = if s.output.is_empty() { "sink" } else { "node" };
|
||||
let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
|
||||
let ins = join(s.inputs.iter().map(port_json));
|
||||
let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind)));
|
||||
let name = match b.instance_name() {
|
||||
Some(n) => format!(r#""name":{},"#, json_str(n)),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
|
||||
json_str(&b.label()),
|
||||
json_str(role),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
after — a conditional `"bound"` field, placed right after `"params"` (the
|
||||
tunable surface and its bound annotation grouped). Present only when non-empty,
|
||||
exactly like `"name"`, so an unbound node's bytes are unchanged:
|
||||
|
||||
```rust
|
||||
fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
|
||||
let s = b.schema();
|
||||
let role = if s.output.is_empty() { "sink" } else { "node" };
|
||||
let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
|
||||
let ins = join(s.inputs.iter().map(port_json));
|
||||
let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind)));
|
||||
let name = match b.instance_name() {
|
||||
Some(n) => format!(r#""name":{},"#, json_str(n)),
|
||||
None => String::new(),
|
||||
};
|
||||
// bound params: present only when the node has binds (mirrors "name").
|
||||
// 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}]"#)
|
||||
};
|
||||
format!(
|
||||
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#,
|
||||
json_str(&b.label()),
|
||||
json_str(role),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `blend` node in the re-captured `sample-model.json` then reads (abridged):
|
||||
|
||||
```json
|
||||
{"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"]]}}
|
||||
```
|
||||
|
||||
### graph-viewer.js (`adaptNodes`, `genDot` leaf-emit, `cellLabel`)
|
||||
|
||||
before — `adaptNodes` carries `name` but not `bound`:
|
||||
|
||||
```js
|
||||
out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };
|
||||
```
|
||||
|
||||
after — carry `bound` (default `[]` when absent):
|
||||
|
||||
```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 } };
|
||||
```
|
||||
|
||||
before — `genDot` leaf-emit forwards the prim fields to `cellLabel`:
|
||||
|
||||
```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`;
|
||||
```
|
||||
|
||||
after — forward `bound`:
|
||||
|
||||
```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`;
|
||||
```
|
||||
|
||||
before — `cellLabel` builds the signature from free params only:
|
||||
|
||||
```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>` : "";
|
||||
```
|
||||
|
||||
after — merge free params and bound params into slot order by position; render a
|
||||
bound slot as `name=value` in the dimmed separator grey (`#9399b2`); the bracket
|
||||
test keys on the *total* slot count so an all-bound node still shows `[...]`:
|
||||
|
||||
```js
|
||||
const bound = o.bound || [];
|
||||
const total = o.params.length + bound.length;
|
||||
const byPos = new Map(bound.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); // bound: dimmed name=value
|
||||
slots.push(`<font color="#9399b2">${n}=${v}</font>`);
|
||||
} else {
|
||||
const [n, k] = o.params[fi++]; // free: type-coloured name
|
||||
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>` : "";
|
||||
```
|
||||
|
||||
`addInfo` (the tooltip) is left unchanged: it shows the *tunable* params (the
|
||||
knob view); the bound constant is a signature fact shown in the head, not a knob.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | File | Change |
|
||||
|-----------|------|--------|
|
||||
| `BoundParam` + `PrimitiveBuilder.bound` + `bind` record + `bound_params()` | `crates/aura-core/src/node.rs` | new struct, field, ~6 lines in `bind`, accessor |
|
||||
| `scalar_str` + conditional `"bound"` in `prim_record` | `crates/aura-engine/src/graph_model.rs` | new helper, ~8 lines in `prim_record` |
|
||||
| `bound` carried + merged-slot render | `crates/aura-cli/assets/graph-viewer.js` | `adaptNodes`, `genDot`, `cellLabel` |
|
||||
| sample fixture re-capture | `crates/aura-cli/tests/fixtures/sample-model.json` | `blend` gains `"bound"` |
|
||||
| `prim_record` bound unit test | `crates/aura-engine/src/graph_model.rs` (tests) | new test |
|
||||
| `bind` records position unit test | `crates/aura-core/src/node.rs` (tests) | new test |
|
||||
| headless render guard (`.mjs` + `.rs`) | `crates/aura-cli/tests/viewer_bound_param.{mjs,rs}` | new guard pair |
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
author: LinComb::builder(3).bind("weights[2]", F64(0.5))
|
||||
→ PrimitiveBuilder { schema.params: [w0,w1], bound: [{pos:2,"weights[2]",F64,0.5}] }
|
||||
→ prim_record: "params":[[w0],[w1]], "bound":[[2,"weights[2]","f64","0.5"]]
|
||||
→ render_html: window.AURA_MODEL = { ... blend ... }
|
||||
→ adaptNodes: { ..., bound: [[2,"weights[2]","f64","0.5"]] }
|
||||
→ cellLabel merge by pos: [ w0(free), w1(free), weights[2]=0.5(bound, dimmed) ]
|
||||
→ "blend: LinComb[weights[0], weights[1], weights[2]=0.5]"
|
||||
```
|
||||
|
||||
At lowering (bootstrap/compile) nothing changes: `bind` still resolves the name
|
||||
to a fixed position and re-splices the value in the build closure; `bound` is a
|
||||
render/debug surface only, never read by bootstrap or the run loop (C23).
|
||||
|
||||
## Error handling
|
||||
|
||||
- `bind`'s existing preconditions are unchanged: panic on zero / ambiguous slot
|
||||
matches and on a value-kind mismatch (its should-panic tests stay green).
|
||||
- The original-position computation is total and panic-free (a sort + a counted
|
||||
increment over already-recorded positions); no new failure mode.
|
||||
- `scalar_str` is total over all four `Scalar` variants — no `unreachable!`.
|
||||
- The model JSON stays well-formed for any bind count (zero → no `"bound"` key;
|
||||
N → an N-element array). `json_str` escaping is reused for name/kind/value.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **`bind` records the original position (aura-core unit).** Bind a non-trailing
|
||||
slot and a chained reverse-order pair (mirroring `chained_bind_…`); assert
|
||||
`bound_params()` carries the correct ORIGINAL positions (e.g. binding the
|
||||
middle of three → `pos == 1`; reverse-binding `weights[1]` then `weights[0]`
|
||||
on `LinComb(2)` → positions `{0,1}`). This is the test that justifies carrying
|
||||
the position rather than appending.
|
||||
- **`prim_record` emits `"bound"` only when bound (aura-engine unit).** A bound
|
||||
builder's record contains `"bound":[[…]]` with the canonical value string; an
|
||||
unbound builder's record contains no `"bound"` substring (twin of
|
||||
`prim_record_emits_name_for_explicit_only`).
|
||||
- **Model determinism + goldens.** `model_is_deterministic` and the inline
|
||||
`model_golden` (`sample_root`, no binds) stay byte-unchanged — proves the
|
||||
conditional field does not perturb unbound nodes. The `sample-model.json`
|
||||
fixture is re-captured; the two fixture consumers (`viewer_dot_ids.mjs`,
|
||||
`viewer_nested_depth.mjs`) keep passing (they assert DOT-id grammar / nesting
|
||||
depth, not params), confirming the new field flows through `adaptNodes`/`genDot`
|
||||
without breaking id generation.
|
||||
- **Headless render guard (`viewer_bound_param.mjs` + `.rs`).** Mirror
|
||||
`viewer_name_prefix`. Drive the real `genDot` over two minimal models:
|
||||
(a) a LinComb-like leaf with free `weights[0]`/`weights[1]` and
|
||||
`bound:[[2,"weights[2]","f64","0.5"]]` → assert the head contains the dimmed
|
||||
`<font color="#9399b2">weights[2]=0.5</font>` and the two free slots in their
|
||||
type colour, in order `weights[0], weights[1], weights[2]=0.5`;
|
||||
(b) a 3-param leaf binding the MIDDLE slot (`pos 1`) → assert the rendered
|
||||
order is `a, b=<v>, c` (the bound slot between the free ones), which fails
|
||||
under an append-only design and so pins slot-order faithfulness. `node` is
|
||||
REQUIRED (a skipped guard is not a guard).
|
||||
- **Sweep / `param_space` unaffected.** The `cli_run` E2E params pin (eight free
|
||||
params, `weights[2]` correctly absent) and the in-crate sweep pins stay green —
|
||||
`bind` still removes the slot from the tunable surface.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. A node built with `.bind(slot, value)` renders all its declared slots in the
|
||||
`aura graph` viewer; the bound slot shows `name=value`, visually dimmed, in
|
||||
true slot order. The cycle-0036 `blend` renders
|
||||
`blend: LinComb[weights[0], weights[1], weights[2]=0.5]`.
|
||||
2. The graph model carries a conditional `"bound"` field
|
||||
(`[pos,"name","kind","value"]`), present only for nodes with binds;
|
||||
deterministic (C14). Unbound nodes' model bytes are unchanged.
|
||||
3. The bound slot remains absent from the tunable surface (`params()` /
|
||||
`param_space` / sweep axes) — `bind`'s tuning-side behaviour is unchanged
|
||||
(C12/C19), and the compilat is unchanged (C23: render/debug symbol only).
|
||||
4. New unit tests pin the recorded original position and the conditional model
|
||||
field; a headless guard pins the dimmed `name=value` render and slot-order
|
||||
faithfulness (including a non-trailing bind). All existing goldens, viewer
|
||||
guards, and sweep pins stay green; `cargo build`/`test`/`clippy` clean.
|
||||
Reference in New Issue
Block a user