feat(aura): surface explicit node instance name in the graph viewer
A leaf primitive built with `.named("fast")` now renders its `aura graph` viewer
box head as `fast: SMA[length]` — the instance name as a `:` declaration prefix.
An unnamed leaf keeps the bare `SMA[length]`.
Engine half: a new raw `PrimitiveBuilder::instance_name() -> Option<&str>` (the
explicit name, default not resolved) feeds a conditional leading `"name"` field
in prim_record, present only for an explicitly-named node; both golden twins
(inline model_golden + sample-model.json) re-captured. Viewer half: adaptNodes
carries `name`, the genDot leaf-emit forwards it, and cellLabel prepends the
`name: ` prefix when present (composites stay bare). `named()`'s non-empty
debug_assert is unchanged, its doc re-grounded to the now load-bearing Some/None
invariant (knob-address segment + prefix switch).
The name is a render/model-only debug symbol — dropped at lowering (C23), and the
model stays deterministic (C14). Parameter ganging (one knob, several nodes) was
considered and spun off as an explicit composite-shared-param idea (#61), not via
name collision (param_space is injective, C12/C19).
closes #58
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: { 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, ins: adaptIns(p.ins), outs: p.outs } };
|
||||
} else {
|
||||
out[key] = n; // composite reference { comp }
|
||||
}
|
||||
@@ -71,7 +71,10 @@ function cellLabel(id, o, showTypes) {
|
||||
`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>` : "";
|
||||
const name = `<font color="#f5f5f5"><b>${o.type}</b></font>${sig}`;
|
||||
const prefix = o.name
|
||||
? `<font color="#cdd6f4">${o.name}</font><font color="#9399b2">: </font>`
|
||||
: "";
|
||||
const name = `${prefix}<font color="#f5f5f5"><b>${o.type}</b></font>${sig}`;
|
||||
let bodyRow;
|
||||
if (o.composite) {
|
||||
bodyRow = `<tr><td colspan="${cols}" bgcolor="${o.bodyBg}"><table border="0" cellborder="0" cellspacing="3"><tr>` +
|
||||
@@ -105,7 +108,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, { 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, 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":"sma_cross"},"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":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["cross","f64"]],"nodes":{"0":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"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"]]}}}
|
||||
{"root":{"nodes":{"0":{"comp":"sma_cross"},"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":{"sma_cross":{"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"]]}}}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Headless render guard for the `aura graph` viewer (graph-viewer.js).
|
||||
//
|
||||
// Property protected: a leaf primitive built with an explicit instance name
|
||||
// renders a `name: ` declaration prefix immediately ahead of the bold type head
|
||||
// (`fast: SMA…`); an UNNAMED leaf renders the bare type head, no prefix.
|
||||
//
|
||||
// It loads the *real* viewer module (so a fix or a regression there is observed
|
||||
// here) and drives the exported pure `genDot` over a minimal root model with one
|
||||
// named leaf (SMA, name "fast") and one unnamed leaf (Sub) — exercising the
|
||||
// genDot leaf-emit + cellLabel prefix path directly, no composite expand needed.
|
||||
|
||||
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));
|
||||
|
||||
const model = {
|
||||
root: {
|
||||
nodes: {
|
||||
"0": { prim: { name: "fast", type: "SMA", role: "node", params: [["length", "i64"]], ins: [["f64", "any", "series"]], outs: [["value", "f64"]] } },
|
||||
"1": { prim: { type: "Sub", role: "node", params: [], ins: [["f64", "any", "lhs"], ["f64", "any", "rhs"]], outs: [["value", "f64"]] } },
|
||||
},
|
||||
edges: [["0.o0", "1.i0"]],
|
||||
},
|
||||
composites: {},
|
||||
};
|
||||
|
||||
// genDot closes over module-scoped ROOT/COMP derived from window.AURA_MODEL at
|
||||
// load. Stub the global BEFORE requiring the viewer (mirrors the browser).
|
||||
global.window = { AURA_MODEL: model };
|
||||
const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
|
||||
const ROOT = normalizeModel(model).root;
|
||||
const { dot } = genDot(ROOT, "root", new Set(), false);
|
||||
|
||||
// The named leaf renders `fast: ` (colon + one trailing space, no leading space)
|
||||
// contiguously ahead of the bold SMA type head.
|
||||
const NAMED = '<font color="#cdd6f4">fast</font><font color="#9399b2">: </font><font color="#f5f5f5"><b>SMA</b></font>';
|
||||
if (!dot.includes(NAMED)) {
|
||||
console.error(
|
||||
"named leaf missing the `fast: ` prefix before the SMA head.\n--- dot ---\n" + dot
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Exactly ONE name prefix in the whole graph: the named SMA leg. The `: `
|
||||
// separator font (#9399b2 colon-space) is unique to the prefix at showTypes=false
|
||||
// (sig uses `[`/`]`/`, `, never `: `), so it appears once — the unnamed Sub has
|
||||
// no prefix.
|
||||
const SEP = '<font color="#9399b2">: </font>';
|
||||
const sepCount = dot.split(SEP).length - 1;
|
||||
if (sepCount !== 1) {
|
||||
console.error(
|
||||
`expected exactly 1 name prefix (the named leaf), found ${sepCount} — ` +
|
||||
"an unnamed leaf must render no prefix.\n--- dot ---\n" + dot
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("OK — named leaf renders `fast: ` prefix; unnamed leaf stays bare.");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Integration guard: the `aura graph` viewer renders an explicit instance name
|
||||
//! as a `name: ` prefix ahead of the type head, and leaves an unnamed leaf bare.
|
||||
//!
|
||||
//! Like `viewer_dot.rs`, the property lives in JavaScript (`cellLabel` in
|
||||
//! assets/graph-viewer.js), so this shells out to `node` running the headless
|
||||
//! guard `tests/viewer_name_prefix.mjs`, which loads the real viewer module and
|
||||
//! drives the exported `genDot` over a minimal model (a named SMA + an unnamed
|
||||
//! Sub).
|
||||
//!
|
||||
//! `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_name_prefix_for_named_leaf_only() {
|
||||
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("viewer_name_prefix.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 name-prefix 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 name-prefix guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
|
||||
out.status.code()
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,13 @@ impl PrimitiveBuilder {
|
||||
) -> Self {
|
||||
Self { name, instance_name: None, schema, build: Box::new(build) }
|
||||
}
|
||||
/// Set this node instance's name. Must be non-empty.
|
||||
/// Set this node instance's explicit name. Must be non-empty: the name forms
|
||||
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
|
||||
/// `collect_params`) and is the `Some`/`None` switch for the graph-model
|
||||
/// `"name"` field and the viewer prefix. An empty name would yield a broken
|
||||
/// address segment (`sma_cross..length`) and a degenerate `Some("")` —
|
||||
/// serialised as `"name":""` yet rendering no prefix, i.e. two encodings for
|
||||
/// "no visible name". The `debug_assert` guards that at source.
|
||||
pub fn named(mut self, name: &str) -> Self {
|
||||
debug_assert!(!name.is_empty(), "node name must be non-empty");
|
||||
self.instance_name = Some(name.to_string());
|
||||
@@ -108,6 +114,12 @@ impl PrimitiveBuilder {
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.name.to_ascii_lowercase())
|
||||
}
|
||||
/// The explicit instance name if one was set via `named()`, else `None`.
|
||||
/// Unlike `node_name()`, this does not resolve the default — callers that must
|
||||
/// distinguish "explicitly named" from "defaulted" (the graph model) read this.
|
||||
pub fn instance_name(&self) -> Option<&str> {
|
||||
self.instance_name.as_deref()
|
||||
}
|
||||
/// The full declared signature (read pre-build by `Composite::param_space`,
|
||||
/// `BlueprintNode::signature`, and the renderer).
|
||||
pub fn schema(&self) -> &NodeSchema {
|
||||
@@ -289,6 +301,28 @@ mod tests {
|
||||
assert_eq!(named.node_name(), "fast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_name_is_some_only_when_explicitly_named() {
|
||||
// unnamed → None (instance_name() does NOT resolve node_name()'s
|
||||
// lowercased-type default — that is exactly the value the graph model
|
||||
// must not surface)
|
||||
let unnamed = PrimitiveBuilder::new(
|
||||
"SimBroker",
|
||||
NodeSchema::default(),
|
||||
|_| panic!("not built in this test"),
|
||||
);
|
||||
assert_eq!(unnamed.instance_name(), None);
|
||||
|
||||
// explicitly named → Some(the raw name)
|
||||
let named = PrimitiveBuilder::new(
|
||||
"SMA",
|
||||
NodeSchema::default(),
|
||||
|_| panic!("not built in this test"),
|
||||
)
|
||||
.named("fast");
|
||||
assert_eq!(named.instance_name(), Some("fast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_builder_build_runs_the_closure() {
|
||||
let f = PrimitiveBuilder::new(
|
||||
|
||||
@@ -75,8 +75,14 @@ fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
|
||||
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)));
|
||||
// explicit instance name only — an unnamed node carries no "name" field
|
||||
// (node_name()'s lowercased-type default would be a redundant type-duplicate)
|
||||
let name = match b.instance_name() {
|
||||
Some(n) => format!(r#""name":{},"#, json_str(n)),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
r#"{{"prim":{{"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
|
||||
r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
|
||||
json_str(&b.label()),
|
||||
json_str(role),
|
||||
)
|
||||
@@ -372,6 +378,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prim_record_emits_name_for_explicit_only() {
|
||||
// explicitly named → a leading "name" field, ahead of "type"
|
||||
let named = prim_record(&sub_builder().named("fast"));
|
||||
assert!(
|
||||
named.starts_with(r#"{"prim":{"name":"fast","type":"Sub""#),
|
||||
"{named}"
|
||||
);
|
||||
// unnamed → no "name" field at all
|
||||
let unnamed = prim_record(&sub_builder());
|
||||
assert!(!unnamed.contains(r#""name""#), "{unnamed}");
|
||||
}
|
||||
|
||||
// -- Task 3: scope (index keys + synthetic sources + edges) ------------------
|
||||
|
||||
#[test]
|
||||
@@ -426,7 +445,7 @@ mod tests {
|
||||
// Re-capture if an intended model-shape change lands: temporarily add a
|
||||
// `println!("{}", model_to_json(&sample_root()))` test, run with
|
||||
// `-- --nocapture`, and paste the fresh bytes below.
|
||||
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"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"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"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"]]}}}"##;
|
||||
let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"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"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","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"]]}}}"##;
|
||||
assert_eq!(model_to_json(&sample_root()), expected);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user