diff --git a/crates/aura-cli/assets/graph-viewer.js b/crates/aura-cli/assets/graph-viewer.js index de400f1..39185fe 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: { 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"}">${txt(n, k)}`; const sp = o.params.map(([n, k]) => `${showTypes && k ? `${n}:${k}` : n}`); const sig = o.params.length ? `[${sp.join(', ')}]` : ""; - const name = `${o.type}${sig}`; + const prefix = o.name + ? `${o.name}: ` + : ""; + const name = `${prefix}${o.type}${sig}`; let bodyRow; if (o.composite) { bodyRow = `` + @@ -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 }; diff --git a/crates/aura-cli/tests/fixtures/sample-model.json b/crates/aura-cli/tests/fixtures/sample-model.json index 2f0b8fb..bf52abc 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":"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"]]}}} diff --git a/crates/aura-cli/tests/viewer_name_prefix.mjs b/crates/aura-cli/tests/viewer_name_prefix.mjs new file mode 100644 index 0000000..dd202db --- /dev/null +++ b/crates/aura-cli/tests/viewer_name_prefix.mjs @@ -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 = 'fast: SMA'; +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 = ': '; +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); diff --git a/crates/aura-cli/tests/viewer_name_prefix.rs b/crates/aura-cli/tests/viewer_name_prefix.rs new file mode 100644 index 0000000..87a8bb3 --- /dev/null +++ b/crates/aura-cli/tests/viewer_name_prefix.rs @@ -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() + ); +} diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index f9eaded..3ba5792 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -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 (`..`, 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( diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs index 5adc4f2..7edd8b3 100644 --- a/crates/aura-engine/src/graph_model.rs +++ b/crates/aura-engine/src/graph_model.rs @@ -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); }