From 47e0e605e1f6645d8f4dbe65fd6e4f5a4c613dd9 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 13 Jun 2026 23:23:29 +0200 Subject: [PATCH] feat(aura): surface bind-bound params in the graph model + viewer signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node built with `.bind(slot, value)` fixes a declared param to a structural constant: the slot correctly leaves the tunable surface (param_space / sweep axes), but it also vanished from the RENDERED signature, because prim_record emitted only schema.params. The cycle-0036 `blend` (a LinComb(3) with weights[2] bound to 0.5) rendered `LinComb[weights[0], weights[1]]` — a 2-arity signature over a 3-port box, with the fixed 0.5 invisible. The signature misrepresented the node. bind now ANNOTATES instead of erasing. All slots are shown; the bound one renders `name=value`, dimmed. The blend renders: blend: LinComb[weights[0], weights[1], weights[2]=0.5] Structurally a twin of cycle 0035's instance-name thread (commit 0ae8320): a conditional field threaded engine -> model -> viewer. - aura-core: a `BoundParam { pos, name, kind, value }` recorded by bind alongside the unchanged closure capture, plus a `bound_params()` accessor (twin of instance_name()). `pos` is the slot's ORIGINAL pre-bind position (the compressed index is mapped back over earlier-bound positions), so the render is faithful for any bind pattern, not just a trailing one. - aura-engine: a conditional `"bound"` field in prim_record (present only when the node has binds, mirroring the conditional "name"), each entry [pos,"name","kind", "value"] via a new scalar_str helper (f64 via {:?} — keeps a decimal point so a bound f64 never reads as an i64). - graph-viewer.js: adaptNodes/genDot carry `bound`; cellLabel merges free + bound params back into slot order by position and renders the bound slot dimmed (reusing the 0035 separator grey #9399b2). Render notation settled with the user (issue #63, reconciliation comment): keep the [...] convention and name=value (not parens, not positional value-only). Render/debug surface only: dropped at lowering (C23 — bind still resolves the name to a fixed position and the compilat is unchanged), model stays deterministic (C14 — the inline no-bind golden is byte-unchanged; only the sample fixture's blend node gains "bound"). The tunable surface is untouched: the eight-param sweep pins stay byte-identical (C12/C19). New unit tests pin the recorded original position and the conditional model field; a new headless render guard pins the dimmed name=value render and slot-order faithfulness for both a trailing and a non-trailing bind. Verified: cargo build/test (0 failed) + clippy -D warnings (0) all green. closes #63 --- crates/aura-cli/assets/graph-viewer.js | 23 ++++-- .../aura-cli/tests/fixtures/sample-model.json | 2 +- crates/aura-cli/tests/viewer_bound_param.mjs | 72 +++++++++++++++++++ crates/aura-cli/tests/viewer_bound_param.rs | 37 ++++++++++ crates/aura-core/src/node.rs | 65 ++++++++++++++++- crates/aura-engine/src/graph_model.rs | 49 ++++++++++++- 6 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 crates/aura-cli/tests/viewer_bound_param.mjs create mode 100644 crates/aura-cli/tests/viewer_bound_param.rs diff --git a/crates/aura-cli/assets/graph-viewer.js b/crates/aura-cli/assets/graph-viewer.js index 39185fe..f5f93fe 100644 --- a/crates/aura-cli/assets/graph-viewer.js +++ b/crates/aura-cli/assets/graph-viewer.js @@ -29,7 +29,7 @@ function adaptNodes(nodes) { const n = nodes[key]; if (n.prim) { const p = n.prim; - out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } }; + 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 } }; } else { out[key] = n; // composite reference { comp } } @@ -69,8 +69,23 @@ function cellLabel(id, o, showTypes) { const cell = (io, arr) => ([n, k], i) => `${txt(n, k)}`; - const sp = o.params.map(([n, k]) => `${showTypes && k ? `${n}:${k}` : n}`); - const sig = o.params.length ? `[${sp.join(', ')}]` : ""; + // 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(', ')}]` : ""; const prefix = o.name ? `${o.name}: ` : ""; @@ -108,7 +123,7 @@ function genDot(def, role, expandedSet, showTypes) { const inst = def.nodes[key], cpath = [...path, key], cid = "n" + cpath.join("__"); if (inst.prim) { const s = inst.prim; - 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`; + 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`; addInfo(INFO, cid, s); if (s.role === "source") topRank.push(cid); // sinks are placed freely (not rank-pinned) childRes[key] = { type: "leaf", id: cid, spec: s }; diff --git a/crates/aura-cli/tests/fixtures/sample-model.json b/crates/aura-cli/tests/fixtures/sample-model.json index 6eb7a19..f2bde42 100644 --- a/crates/aura-cli/tests/fixtures/sample-model.json +++ b/crates/aura-cli/tests/fixtures/sample-model.json @@ -1 +1 @@ -{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"signals":{"inputs":[["f64","any","price"]],"outputs":[["signal","f64"]],"nodes":{"0":{"comp":"trend"},"1":{"comp":"momentum"},"2":{"prim":{"name":"blend","type":"LinComb","role":"node","params":[["weights[0]","f64"],["weights[1]","f64"]],"ins":[["f64","any","term[0]"],["f64","any","term[1]"],["f64","any","term[2]"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o2","2.i1"],["1.o1","2.i2"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"trend":{"inputs":[["f64","any","price"]],"outputs":[["cross","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"momentum":{"inputs":[["f64","any","price"]],"outputs":[["macd","f64"],["signal","f64"],["histogram","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}},"3":{"prim":{"name":"signal","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"4":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["2.o0","3.i0"],["2.o0","4.i0"],["3.o0","4.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"],["3.o0","#1"],["4.o0","#2"]]}}} +{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"signals":{"inputs":[["f64","any","price"]],"outputs":[["signal","f64"]],"nodes":{"0":{"comp":"trend"},"1":{"comp":"momentum"},"2":{"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":[["0.o0","2.i0"],["1.o2","2.i1"],["1.o1","2.i2"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"trend":{"inputs":[["f64","any","price"]],"outputs":[["cross","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"momentum":{"inputs":[["f64","any","price"]],"outputs":[["macd","f64"],["signal","f64"],["histogram","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}},"3":{"prim":{"name":"signal","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"4":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["2.o0","3.i0"],["2.o0","4.i0"],["3.o0","4.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"],["3.o0","#1"],["4.o0","#2"]]}}} diff --git a/crates/aura-cli/tests/viewer_bound_param.mjs b/crates/aura-cli/tests/viewer_bound_param.mjs new file mode 100644 index 0000000..f5a90a8 --- /dev/null +++ b/crates/aura-cli/tests/viewer_bound_param.mjs @@ -0,0 +1,72 @@ +// 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); diff --git a/crates/aura-cli/tests/viewer_bound_param.rs b/crates/aura-cli/tests/viewer_bound_param.rs new file mode 100644 index 0000000..a73c487 --- /dev/null +++ b/crates/aura-cli/tests/viewer_bound_param.rs @@ -0,0 +1,37 @@ +//! 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() + ); +} diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 3ba5792..5da83c8 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -66,6 +66,18 @@ pub struct ParamSpec { pub kind: ScalarKind, } +/// 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, +} + /// A param-generic blueprint **primitive** recipe (C19): a node's full declared /// signature (`NodeSchema` — inputs/output/params) plus a closure that builds a /// sized instance through the node's own constructor (the single sizing/validation @@ -78,6 +90,7 @@ 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)] @@ -93,7 +106,7 @@ impl PrimitiveBuilder { schema: NodeSchema, build: impl Fn(&[Scalar]) -> Box + 'static, ) -> Self { - Self { name, instance_name: None, schema, build: Box::new(build) } + Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) } } /// Set this node instance's explicit name. Must be non-empty: the name forms /// a knob-address segment (`..`, via `node_name()` in @@ -120,6 +133,14 @@ impl PrimitiveBuilder { pub fn instance_name(&self) -> Option<&str> { self.instance_name.as_deref() } + /// 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 + } /// The full declared signature (read pre-build by `Composite::param_space`, /// `BlueprintNode::signature`, and the renderer). pub fn schema(&self) -> &NodeSchema { @@ -170,6 +191,22 @@ impl PrimitiveBuilder { self.schema.params[pos].kind, "bind: kind mismatch for param `{slot}`", ); + // [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 }); self.schema.params.remove(pos); // [param_space side] shrink the declared surface let inner = self.build; // [value side] wrap the build closure self.build = Box::new(move |open: &[Scalar]| { @@ -407,6 +444,32 @@ mod tests { ); } + #[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()); + } + #[test] #[should_panic(expected = "no open param named")] fn bind_unknown_slot_panics() { diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs index 8ba8460..f31fd46 100644 --- a/crates/aura-engine/src/graph_model.rs +++ b/crates/aura-engine/src/graph_model.rs @@ -20,6 +20,20 @@ fn kind_str(k: ScalarKind) -> &'static str { } } +/// 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(), + } +} + /// One input port's firing policy as a model string: `"any"` or `"barrier "`. fn firing_str(f: Firing) -> String { match f { @@ -81,8 +95,24 @@ fn prim_record(b: &aura_core::PrimitiveBuilder) -> String { Some(n) => format!(r#""name":{},"#, json_str(n)), None => String::new(), }; + // 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}]"#) + }; format!( - r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#, + r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#, json_str(&b.label()), json_str(role), ) @@ -393,6 +423,23 @@ mod tests { assert!(!unnamed.contains(r#""name""#), "{unnamed}"); } + #[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}"); + } + // -- Task 3: scope (index keys + synthetic sources + edges) ------------------ #[test]