feat(aura): surface bind-bound params in the graph model + viewer signature
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
This commit is contained in:
@@ -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) =>
|
||||
`<td port="${io}${i}" id="P_${id}_${io}${i}" href="#" colspan="${span(arr.length)}" ` +
|
||||
`bgcolor="${io === "i" ? "#222b33" : "#332b22"}"><font color="${col(k)}">${txt(n, k)}</font></td>`;
|
||||
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>` : "";
|
||||
// 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>` : "";
|
||||
const prefix = o.name
|
||||
? `<font color="#cdd6f4">${o.name}</font><font color="#9399b2">: </font>`
|
||||
: "";
|
||||
@@ -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 };
|
||||
|
||||
+1
-1
@@ -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"]]}}}
|
||||
|
||||
@@ -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 = '<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);
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user