diff --git a/crates/aura-cli/assets/graph-viewer.js b/crates/aura-cli/assets/graph-viewer.js
index 7cbdce9..7409f59 100644
--- a/crates/aura-cli/assets/graph-viewer.js
+++ b/crates/aura-cli/assets/graph-viewer.js
@@ -46,6 +46,7 @@ function normalizeModel(model) {
nodes: adaptNodes(c.nodes),
edges: c.edges,
gangs: c.gangs || [], // #61: the scope's gang table, empty for a gang-free scope
+ doc: c.doc, // #125: authored rationale (undefined when absent)
};
}
return {
@@ -53,6 +54,7 @@ function normalizeModel(model) {
inputs: [], outputs: [],
nodes: adaptNodes(model.root.nodes), edges: model.root.edges,
gangs: model.root.gangs || [],
+ doc: model.root.doc, // #125: the blueprint-level rationale
},
composites,
};
@@ -117,7 +119,8 @@ function cellLabel(id, o, showTypes) {
}
function addInfo(INFO, id, o) {
const ps = o.params && o.params.length ? o.params.map(([n, k]) => `\`${n}:${k}\``).join(", ") : "none";
- INFO.B[id] = `**${o.type}** · ${o.role || "node"}\nparams: ${ps}`;
+ INFO.B[id] = `**${o.type}** · ${o.role || "node"}\nparams: ${ps}`
+ + (o.doc ? `\n${o.doc}` : "");
(o.ins || []).forEach(([n, k, m], i) => INFO.P[`P_${id}_i${i}`] = `**${n}** · \`${k}\`` + (m && m.firing ? `\nfiring: \`${m.firing}\`` : ""));
(o.outs || []).forEach(([n, k], i) => INFO.P[`P_${id}_o${i}`] = `**${n}** · \`${k}\``);
}
@@ -142,12 +145,13 @@ function genDot(def, role, expandedSet, showTypes) {
const cname = inst.comp, cdef = COMP[cname];
if (expandedSet.has(cid)) {
const sub = build(cpath, cdef, "cluster"); clusters.push(cid);
- INFO.C["clust_" + cid] = `**${cname}** · composite · click frame to collapse`;
+ INFO.C["clust_" + cid] = `**${cname}** · composite · click frame to collapse`
+ + (cdef.doc ? `\n${cdef.doc}` : "");
block += `subgraph cluster_${cid} { id="clust_${cid}"; style=filled; color="#5b4b8a"; fillcolor="#211c30"; penwidth=2;\n${sub.block}}\n`;
childRes[key] = { type: "exp", resolveIn: sub.resolveIn, resolveOut: sub.resolveOut };
} else {
block += `${cid} [label=${cellLabel(cid, { type: cname, params: [], ins: cdef.inputs, outs: cdef.outputs, bodyBg: PURPLE, composite: true }, showTypes)}];\n`;
- addInfo(INFO, cid, { type: cname, role: "composite", params: [], ins: cdef.inputs, outs: cdef.outputs });
+ addInfo(INFO, cid, { type: cname, role: "composite", params: [], ins: cdef.inputs, outs: cdef.outputs, doc: cdef.doc });
drill[cid] = cname; expandable.push(cid);
childRes[key] = { type: "opaque", id: cid, cdef };
}
@@ -207,6 +211,10 @@ function showTip(text, x, y) {
const hideTip = () => { tip.style.display = "none"; };
const stage = document.getElementById("stage"), crumbEl = document.getElementById("crumb");
+// #125: the root composite's authored rationale as a muted header line;
+// stays empty when the blueprint carries no doc.
+const rootdocEl = document.getElementById("rootdoc");
+if (rootdocEl && ROOT.doc) rootdocEl.textContent = ROOT.doc;
let viz, viewStack = [{ name: "root", def: ROOT }], expanded = new Set(), SHOW_TYPES = false, INFO = { B: {}, P: {}, C: {} };
function chrome() {
diff --git a/crates/aura-cli/src/render.rs b/crates/aura-cli/src/render.rs
index dce2ac6..bad05e1 100644
--- a/crates/aura-cli/src/render.rs
+++ b/crates/aura-cli/src/render.rs
@@ -37,6 +37,7 @@ const GRAPH_HEAD: &str = r#"
aura graph
hover · [+] expand · body drill
+
@@ -261,6 +262,10 @@ mod tests {
);
// the chart-only toggle control must not leak onto the graph page
assert!(!html.contains("xmode-toggle"), "chart toggle leaked onto the graph page");
+ assert!(
+ html.contains(r#""#),
+ "the graph header carries the (empty) root-doc slot"
+ );
}
fn sample_chart_data() -> ChartData {
diff --git a/crates/aura-cli/tests/fixtures/nested_doc_blueprint.json b/crates/aura-cli/tests/fixtures/nested_doc_blueprint.json
new file mode 100644
index 0000000..0055d70
--- /dev/null
+++ b/crates/aura-cli/tests/fixtures/nested_doc_blueprint.json
@@ -0,0 +1 @@
+{"format_version":1,"blueprint":{"name":"root","nodes":[{"composite":{"name":"sma_cross","doc":"momentum leg: change over a lookback, gated by the trend sign","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}]}],"output":[{"node":0,"field":0,"name":"bias"}]}}
diff --git a/crates/aura-cli/tests/fixtures/sample-model.json b/crates/aura-cli/tests/fixtures/sample-model.json
index e16452c..8092e89 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":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","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"]]}}}
+{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","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"]],"doc":"momentum leg: change over a lookback, gated by the trend sign"}}}
diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs
index 61f43f2..d879762 100644
--- a/crates/aura-cli/tests/graph_construct.rs
+++ b/crates/aura-cli/tests/graph_construct.rs
@@ -1040,6 +1040,37 @@ fn graph_doc_field_moves_content_id_but_not_identity_id() {
);
}
+/// Property (#125 viewer/#3, nested-composite doc through the real render
+/// pipeline): the sibling test above only proves a ROOT-level `doc` reaches
+/// the rendered page — the viewer threads a composite's doc onto its own
+/// tooltip (INFO.B/INFO.C in graph-viewer.js), which needs the doc present on
+/// a NESTED composite def, not just the root. `aura graph ` over a
+/// blueprint whose one node is a nested composite carrying `doc` must embed
+/// that exact string in the emitted page's `composites` section of
+/// `window.AURA_MODEL` — the composite-def half of `graph_model.rs`'s
+/// `doc_fragment`, exercised through the actual file-load -> render CLI
+/// binary, not just the library function called directly.
+#[test]
+fn graph_render_embeds_a_nested_composites_doc() {
+ let out = std::process::Command::new(BIN)
+ .arg("graph")
+ .arg(fixture("nested_doc_blueprint.json"))
+ .output()
+ .expect("spawn aura graph ");
+ assert_eq!(
+ out.status.code(),
+ Some(0),
+ "render exit: {:?}\nstderr: {}",
+ out.status,
+ String::from_utf8_lossy(&out.stderr)
+ );
+ let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
+ assert!(
+ html.contains(r#","doc":"momentum leg: change over a lookback, gated by the trend sign""#),
+ "rendered page does not embed the nested composite's doc: {html}"
+ );
+}
+
/// The `gang` op's arity refusal (fewer than two members) reads as prose at the
/// binary seam, attributed to its op index, never the raw `GangArity` Debug name.
#[test]
diff --git a/crates/aura-cli/tests/viewer_tooltip.mjs b/crates/aura-cli/tests/viewer_tooltip.mjs
index e924709..df6b052 100644
--- a/crates/aura-cli/tests/viewer_tooltip.mjs
+++ b/crates/aura-cli/tests/viewer_tooltip.mjs
@@ -117,5 +117,59 @@ const expect = (actual, expected, msg) => {
);
}
+// --- #125: doc'd composite + root -------------------------------------------
+// A minimal model whose composite def and root both carry a "doc"; pins that
+// the doc reaches INFO.B (collapsed), INFO.C (expanded), and root.doc.
+//
+// genDot resolves a nested composite reference via the module-scope COMP
+// captured once at require time from window.AURA_MODEL (see the file-top
+// comment above the `model`/`ROOT` constants and the sibling
+// viewer_nested_depth.mjs guard's identical note) — reusing the
+// already-required module's genDot would resolve "sub" back to THIS file's
+// original doc-less "sub" composite of the same name, not docModel's. Fresh-
+// require the module against docModel so its own COMP reflects the doc'd
+// composite.
+const docModel = {
+ root: {
+ nodes: { "0": { comp: "sub" } },
+ edges: [],
+ doc: "root rationale line",
+ },
+ composites: {
+ sub: {
+ inputs: [["f64", "any", "a"]],
+ outputs: [["y", "f64"]],
+ nodes: {},
+ edges: [],
+ doc: "why this sub-graph is wired this way",
+ },
+ },
+};
+const viewerPath = join(here, "..", "assets", "graph-viewer.js");
+delete require.cache[require.resolve(viewerPath)];
+global.window = { AURA_MODEL: docModel };
+const { normalizeModel: normalizeModelDoc, genDot: genDotDoc } = require(viewerPath);
+const docNorm = normalizeModelDoc(docModel);
+expect(docNorm.root.doc, "root rationale line", "root doc carried");
+expect(docNorm.composites.sub.doc, "why this sub-graph is wired this way", "composite doc carried");
+
+{
+ const { info } = genDotDoc(docNorm.root, "root", new Set(), false);
+ expect(
+ info.B["n0"],
+ "**sub** · composite\nparams: none\nwhy this sub-graph is wired this way",
+ "collapsed tooltip carries the doc paragraph"
+ );
+}
+
+{
+ const { info } = genDotDoc(docNorm.root, "root", new Set(["n0"]), false);
+ expect(
+ info.C["clust_n0"],
+ "**sub** · composite · click frame to collapse\nwhy this sub-graph is wired this way",
+ "expanded cluster tooltip carries the doc paragraph"
+ );
+}
+
console.log("OK — INFO.B/INFO.C/INFO.P tooltip shapes and md() conversion pinned.");
process.exit(0);