feat(aura-cli): render the graph as a self-contained WASM-Graphviz viewer; retire ascii-dag
Iteration 2 of cycle 0026 (graph render redesign). `aura graph` no longer emits ASCII: it now prints one self-contained `.html` to stdout — the ported prototype pin-graph viewer (drill / inline-expand / styled tooltips / breadcrumb) driven by the deterministic model serializer that shipped in iteration 1, with layout and SVG done in-browser by Graphviz compiled to WebAssembly. Closes the redesign opened by spec 0026; unblocked by cycle 0027 (input ports are now named, so the viewer labels every input pin from the model's real names instead of inventing "a"/"b"). What ships: - crates/aura-cli/assets/: the ported graph-viewer.js (prototype genDot/chrome verbatim + a normalizeModel adapter mapping aura's real model tuple shape — ins = [kind, firing, name], index node keys, `comp` refs, `src_<role>` — into the viewer's native shape), plus the vendored Graphviz-WASM (@viz-js/viz@3.7.0) and svg-pan-zoom@3.6.1 blobs and a refresh-assets.sh provenance script. - crates/aura-cli/src/render.rs: render_html(&Composite) -> String, a read-only (C9) assembly — model_to_json + include_str! of the inlined assets, no eval, no build, no serde (C14). The `["graph"]` arm calls it. - Retired: the ascii-dag adapter (graph.rs), the `ascii-dag` dependency, the `--compiled`/`--macd` graph flag plumbing, render_compiled, the color/terminal plumbing, and the 12 ascii-dag-asserting tests + the now-orphaned helpers. The cli_run integration test now pins the HTML page envelope. - crates/aura-core/src/node.rs: the two last ascii-dag prose justifications re-grounded (single-line label rule kept, re-justified generically; the param-generic rule re-justified on C23, not on a renderer limitation). - docs/design/INDEX.md: the cycle-0026 C9 realization note (both iterations). Vendoring decision (spec deferred it to the plan): the WASM/JS blobs are checked in, not fetched at build time. include_str! needs them at compile time, and a build-time unpkg fetch would make the build non-hermetic/non-offline and let the shipped artifact drift with the registry — against C1 (determinism) and C8 (frozen artifacts). ~1.4 MB is the price of a hermetic, frozen render asset. Three adaptations beyond the plan, each verified: (1) render.rs imports `aura_engine::Composite` (the path model_to_json takes), not aura_core; (2) macd_blueprint is now test-only — removing the `--macd` arm left it used solely by the param-space test, so it took `#[cfg(test)]` rather than removal or an `#[allow]` suppression (the production `aura run --macd` path is intact, verified emitting JSON); (3) the `grep ascii-dag` over crates/ matches only the new contract test, which names the term deliberately to document the retirement — no stale justification prose remains. Invariants held (verified independently, not on agent report): C9 (render path read-only), C10 (viewer is a render asset, no logic/DSL), C4 (four-colour palette = the four scalar base types), C14 (no serde). The iteration-1 model byte golden is unchanged and green. cargo build --workspace: 0 errors. cargo test --workspace: 157 passed, 0 failed (incl. the two new HTML tests + the unchanged model_golden). cargo clippy --workspace --all-targets -- -D warnings: clean. ascii-dag absent from `cargo tree -p aura-cli`. closes #51
This commit is contained in:
Generated
-7
@@ -46,17 +46,10 @@ dependencies = [
|
|||||||
"derive_arbitrary",
|
"derive_arbitrary",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ascii-dag"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cd898d2dcffeeb51015c511a2849a8637c894b833afc946936d48b1e5c8aa6ec"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aura-cli"
|
name = "aura-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ascii-dag",
|
|
||||||
"aura-core",
|
"aura-core",
|
||||||
"aura-engine",
|
"aura-engine",
|
||||||
"aura-std",
|
"aura-std",
|
||||||
|
|||||||
@@ -13,4 +13,3 @@ path = "src/main.rs"
|
|||||||
aura-core = { path = "../aura-core" }
|
aura-core = { path = "../aura-core" }
|
||||||
aura-engine = { path = "../aura-engine" }
|
aura-engine = { path = "../aura-engine" }
|
||||||
aura-std = { path = "../aura-std" }
|
aura-std = { path = "../aura-std" }
|
||||||
ascii-dag = "0.9.1"
|
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// aura graph viewer — ported from docs/design/prototypes/graph-render/index.html.
|
||||||
|
// A render asset (C10): no node/strategy/experiment logic, no DSL. Reads aura's
|
||||||
|
// emitted model (window.AURA_MODEL, injected by render_html) and draws a
|
||||||
|
// homogeneous pin-graph via Graphviz-WASM. genDot/cellLabel/chrome are the
|
||||||
|
// prototype verbatim; normalizeModel adapts aura's real tuple shape (post-0027
|
||||||
|
// named inputs) to the viewer's native shape.
|
||||||
|
const TYPE_COLOR = { i64: "#f9e2af", f64: "#89b4fa", bool: "#a6e3a1", timestamp: "#cba6f7" };
|
||||||
|
const COLOR_TYPE = Object.fromEntries(Object.entries(TYPE_COLOR).map(([k, v]) => [v.toLowerCase(), k]));
|
||||||
|
const PURPLE = "#5b4b8a", TEAL = "#2c5a5a", PLUS = "#7c6ba8";
|
||||||
|
const LEAF = "#45475a", SOURCE = "#2e5a3a", SINK = "#5a2e3e";
|
||||||
|
const bodyBgFor = (role) => role === "source" ? SOURCE : role === "sink" ? SINK : LEAF;
|
||||||
|
const HEAD = `bgcolor="#16161a"; rankdir=TB; nodesep=0.5; ranksep=0.65;
|
||||||
|
node [shape=plaintext, fontname="Courier", fontsize=12];
|
||||||
|
edge [penwidth=1.7, arrowsize=0.8];`;
|
||||||
|
const col = (k) => TYPE_COLOR[k] || "#cdd6f4";
|
||||||
|
|
||||||
|
// === adapt aura's emitted model to the viewer's native shape =================
|
||||||
|
// aura input tuples are [kind, firing, name]; the viewer wants [name, kind, meta?]
|
||||||
|
// (non-"any" firing surfaces in the tooltip). outs/params are already [name, kind].
|
||||||
|
// aura composite inputs are [kind, firing, name]; the viewer wants [name, kind].
|
||||||
|
// node keys (indices / "src_<role>") and edge endpoints pass through unchanged.
|
||||||
|
function adaptIns(ins) {
|
||||||
|
return ins.map(([kind, firing, name]) =>
|
||||||
|
firing && firing !== "any" ? [name, kind, { firing }] : [name, kind]);
|
||||||
|
}
|
||||||
|
function adaptNodes(nodes) {
|
||||||
|
const out = {};
|
||||||
|
for (const key in 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 } };
|
||||||
|
} else {
|
||||||
|
out[key] = n; // composite reference { comp }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function normalizeModel(model) {
|
||||||
|
const composites = {};
|
||||||
|
for (const cname in (model.composites || {})) {
|
||||||
|
const c = model.composites[cname];
|
||||||
|
composites[cname] = {
|
||||||
|
inputs: c.inputs.map(([kind, firing, name]) => [name, kind]),
|
||||||
|
outputs: c.outputs,
|
||||||
|
nodes: adaptNodes(c.nodes),
|
||||||
|
edges: c.edges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
root: { inputs: [], outputs: [], nodes: adaptNodes(model.root.nodes), edges: model.root.edges },
|
||||||
|
composites,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const MODEL = normalizeModel(window.AURA_MODEL);
|
||||||
|
const COMP = MODEL.composites;
|
||||||
|
const ROOT = MODEL.root;
|
||||||
|
|
||||||
|
// === ONE homogeneous, per-cell-addressable node template =====================
|
||||||
|
function cellLabel(id, o, showTypes) {
|
||||||
|
const cols = Math.max(o.ins.length, o.outs.length, 1);
|
||||||
|
const span = (n) => Math.max(1, Math.floor(cols / n));
|
||||||
|
const txt = (n, k) => (showTypes && k) ? `${n} : ${k}` : n;
|
||||||
|
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>` : "";
|
||||||
|
const name = `<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>` +
|
||||||
|
`<td id="plus_${id}" href="#" align="center" valign="middle" fixedsize="true" width="24" height="24" border="1" color="#a594c8" bgcolor="${PLUS}"><font color="#f5f5f5"><b>+</b></font></td>` +
|
||||||
|
`<td id="B_${id}" href="#">${name}</td></tr></table></td></tr>`;
|
||||||
|
} else {
|
||||||
|
bodyRow = `<tr><td id="B_${id}" href="#" colspan="${cols}" bgcolor="${o.bodyBg}">${name}</td></tr>`;
|
||||||
|
}
|
||||||
|
let rows = "";
|
||||||
|
if (o.ins.length) rows += `<tr>${o.ins.map(cell("i", o.ins)).join("")}</tr>`;
|
||||||
|
rows += bodyRow;
|
||||||
|
if (o.outs.length) rows += `<tr>${o.outs.map(cell("o", o.outs)).join("")}</tr>`;
|
||||||
|
return `<<table border="0" cellborder="1" cellspacing="0" cellpadding="6">${rows}</table>>`;
|
||||||
|
}
|
||||||
|
function addInfo(INFO, id, o) {
|
||||||
|
const ps = o.params && o.params.length ? o.params.map(([n, k]) => `\`${n}:${k}\``).join(", ") : "<span class='dim'>none</span>";
|
||||||
|
INFO.B[id] = `**${o.type}** <span class='dim'>· ${o.role || "node"}</span>\nparams: ${ps}`;
|
||||||
|
(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}\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === recursive DOT generator (the mini-inliner) ==============================
|
||||||
|
function genDot(def, role, expandedSet, showTypes) {
|
||||||
|
const INFO = { B: {}, P: {}, C: {} }, drill = {}, expandable = [], clusters = [], edges = [], topRank = [], bottomRank = [];
|
||||||
|
function build(path, def, brole) {
|
||||||
|
const childRes = {}; let block = "";
|
||||||
|
for (const key in def.nodes) {
|
||||||
|
const inst = def.nodes[key], cpath = [...path, key], cid = 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`;
|
||||||
|
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 };
|
||||||
|
} else {
|
||||||
|
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}** <span class='dim'>· composite · click frame to collapse</span>`;
|
||||||
|
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 });
|
||||||
|
drill[cid] = cname; expandable.push(cid);
|
||||||
|
childRes[key] = { type: "opaque", id: cid, cdef };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pk = (r, io, idx) => r.type === "leaf" ? (io === "i" ? r.spec.ins : r.spec.outs)[idx][1] : (io === "i" ? r.cdef.inputs : r.cdef.outputs)[idx][1];
|
||||||
|
const resTarget = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveIn(idx) : [{ id: r.id, port: "i" + idx, kind: pk(r, "i", idx) }]; };
|
||||||
|
const resSource = (ep) => { const [k, p] = ep.split("."); const idx = +p.slice(1), r = childRes[k]; return r.type === "exp" ? r.resolveOut(idx) : { id: r.id, port: "o" + idx, kind: pk(r, "o", idx) }; };
|
||||||
|
const resolveIn = (i) => { const rn = "@" + def.inputs[i][0]; return def.edges.filter(e => e[0] === rn).flatMap(e => resTarget(e[1])); };
|
||||||
|
const resolveOut = (i) => { const e = def.edges.find(e => e[1] === "#" + i); return resSource(e[0]); };
|
||||||
|
for (const [f, t] of def.edges) { if (f[0] === "@" || t[0] === "#") continue; const s = resSource(f); for (const d of resTarget(t)) edges.push([s, d]); }
|
||||||
|
if (brole === "drill") {
|
||||||
|
def.inputs.forEach(([rn, rk], i) => { const bid = `bin_${i}`;
|
||||||
|
block += `${bid} [label=${cellLabel(bid, { type: rn, params: [], ins: [], outs: [[rn, rk]], bodyBg: SOURCE, composite: false }, showTypes)}];\n`;
|
||||||
|
addInfo(INFO, bid, { type: rn, role: "input", params: [], ins: [], outs: [[rn, rk]] });
|
||||||
|
topRank.push(bid);
|
||||||
|
resolveIn(i).forEach(d => edges.push([{ id: bid, port: "o0", kind: rk }, d])); });
|
||||||
|
def.outputs.forEach(([on, ok], i) => { const bid = `bout_${i}`;
|
||||||
|
block += `${bid} [label=${cellLabel(bid, { type: on, params: [], ins: [[on, ok]], outs: [], bodyBg: SINK, composite: false }, showTypes)}];\n`;
|
||||||
|
addInfo(INFO, bid, { type: on, role: "output", params: [], ins: [[on, ok]], outs: [] });
|
||||||
|
bottomRank.push(bid);
|
||||||
|
edges.push([resolveOut(i), { id: bid, port: "i0", kind: ok }]); });
|
||||||
|
}
|
||||||
|
return { block, resolveIn, resolveOut };
|
||||||
|
}
|
||||||
|
const top = build([], def, role);
|
||||||
|
let dot = `digraph g { ${HEAD}\n${top.block}`;
|
||||||
|
// annotation by position: sources/inputs share the top rank, outputs the bottom rank.
|
||||||
|
const rankRow = (ids, r, chain) => ids.length
|
||||||
|
? `{rank=${r};${ids.map(id => " " + id + ";").join("")}${chain && ids.length > 1 ? " " + ids.join(" -> ") + " [style=invis];" : ""}}\n`
|
||||||
|
: "";
|
||||||
|
dot += rankRow(topRank, "min", true);
|
||||||
|
dot += rankRow(bottomRank, "max", false);
|
||||||
|
for (const [s, d] of edges) dot += `${s.id}:${s.port}:s -> ${d.id}:${d.port}:n [color="${TYPE_COLOR[s.kind] || "#7f849c"}"];\n`;
|
||||||
|
return { dot: dot + "}", info: INFO, drill, expandable, clusters };
|
||||||
|
}
|
||||||
|
|
||||||
|
// === styled tooltip + interaction ============================================
|
||||||
|
const tip = document.getElementById("tip");
|
||||||
|
const md = (s) => s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/`(.+?)`/g, "<code>$1</code>").replace(/\n/g, "<br>");
|
||||||
|
function showTip(text, x, y) {
|
||||||
|
tip.innerHTML = md(text); tip.style.display = "block";
|
||||||
|
const w = tip.offsetWidth, h = tip.offsetHeight;
|
||||||
|
let l = x + 14, t = y + 16;
|
||||||
|
if (l + w > innerWidth - 8) l = x - w - 14;
|
||||||
|
if (t + h > innerHeight - 8) t = y - h - 16;
|
||||||
|
tip.style.left = l + "px"; tip.style.top = t + "px";
|
||||||
|
}
|
||||||
|
const hideTip = () => { tip.style.display = "none"; };
|
||||||
|
|
||||||
|
const stage = document.getElementById("stage"), crumbEl = document.getElementById("crumb");
|
||||||
|
let viz, viewStack = [{ name: "root", def: ROOT }], expanded = new Set(), SHOW_TYPES = false, INFO = { B: {}, P: {}, C: {} };
|
||||||
|
|
||||||
|
function chrome() {
|
||||||
|
crumbEl.innerHTML = "";
|
||||||
|
viewStack.forEach((v, i) => {
|
||||||
|
if (i) { const s = document.createElement("span"); s.className = "sep"; s.textContent = " › "; crumbEl.appendChild(s); }
|
||||||
|
const a = document.createElement("a"); a.textContent = v.name;
|
||||||
|
a.onclick = () => { viewStack = viewStack.slice(0, i + 1); expanded = new Set(); show(); };
|
||||||
|
crumbEl.appendChild(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
const v = viewStack[viewStack.length - 1];
|
||||||
|
const G = genDot(v.def, v.name === "root" ? "root" : "drill", expanded, SHOW_TYPES);
|
||||||
|
INFO = G.info;
|
||||||
|
stage.innerHTML = ""; hideTip();
|
||||||
|
const svg = viz.renderSVGElement(G.dot);
|
||||||
|
stage.appendChild(svg);
|
||||||
|
const pz = svgPanZoom(svg, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true, minZoom: 0.1, maxZoom: 12, dblClickZoomEnabled: false });
|
||||||
|
addEventListener("resize", () => { pz.resize(); pz.fit(); pz.center(); });
|
||||||
|
svg.querySelectorAll("title").forEach(t => t.remove());
|
||||||
|
svg.querySelectorAll("a").forEach(a => a.removeAttributeNS("http://www.w3.org/1999/xlink", "title"));
|
||||||
|
|
||||||
|
const idOf = (el) => { const a = el.closest && el.closest("a[id], g[id]"); return a ? a.id.replace(/^a_/, "") : ""; };
|
||||||
|
svg.addEventListener("mousemove", (e) => {
|
||||||
|
const id = idOf(e.target);
|
||||||
|
if (id.startsWith("P_") && INFO.P[id]) return showTip(INFO.P[id], e.clientX, e.clientY);
|
||||||
|
if (id.startsWith("B_")) { const k = id.slice(2); if (INFO.B[k]) return showTip(INFO.B[k], e.clientX, e.clientY); }
|
||||||
|
if (id.startsWith("plus_")) return showTip("**expand** inline", e.clientX, e.clientY);
|
||||||
|
if (id.startsWith("clust_") && INFO.C[id]) return showTip(INFO.C[id], e.clientX, e.clientY);
|
||||||
|
const eg = e.target.closest && e.target.closest("g.edge");
|
||||||
|
if (eg) { const p = eg.querySelector("path"); const c = p && p.getAttribute("stroke") ? p.getAttribute("stroke").toLowerCase() : ""; return showTip("`" + (COLOR_TYPE[c] || "?") + "`", e.clientX, e.clientY); }
|
||||||
|
hideTip();
|
||||||
|
});
|
||||||
|
svg.addEventListener("mouseleave", hideTip);
|
||||||
|
svg.addEventListener("click", (e) => { const a = e.target.closest && e.target.closest("a"); if (a) e.preventDefault(); });
|
||||||
|
|
||||||
|
const click = (id, fn) => { const el = svg.getElementById(id) || svg.getElementById("a_" + id); if (el) { el.style.cursor = "pointer"; el.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); fn(); }); } };
|
||||||
|
for (const id in G.drill) click("B_" + id, () => { viewStack.push({ name: G.drill[id], def: COMP[G.drill[id]] }); expanded = new Set(); show(); });
|
||||||
|
for (const id of G.expandable) click("plus_" + id, () => { expanded.add(id); show(); });
|
||||||
|
for (const id of G.clusters) click("clust_" + id, () => { expanded.delete(id); show(); });
|
||||||
|
chrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
Viz.instance().then(v => { viz = v; show(); }).catch(e => { document.getElementById("err").textContent = String(e && e.stack || e); });
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the vendored graph-viewer assets. NOT run at build time — the blobs are
|
||||||
|
# checked into the repo so `include_str!` and the build stay hermetic/offline
|
||||||
|
# (C1/C8). Run this only to deliberately bump a pinned version, then commit the
|
||||||
|
# updated blobs. Mirrors the prototype's fetch-deps.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
curl -fsSL "https://unpkg.com/@viz-js/viz@3.7.0/lib/viz-standalone.js" -o viz-standalone.js
|
||||||
|
curl -fsSL "https://unpkg.com/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js" -o svg-pan-zoom.min.js
|
||||||
|
echo "refreshed: viz-standalone.js (@viz-js/viz@3.7.0), svg-pan-zoom.min.js (svg-pan-zoom@3.6.1)"
|
||||||
+3
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,504 +0,0 @@
|
|||||||
//! The `aura graph` ASCII-DAG adapter (#13, #38): turns the engine's
|
|
||||||
//! graph-as-data (C9) into an `ascii_dag::Graph` rendered to a `String`. Two
|
|
||||||
//! views. `render_blueprint` shows the authored structure — a flat main graph
|
|
||||||
//! wiring the harness with each composite as a single opaque node, plus a
|
|
||||||
//! `where:` section that defines each distinct composite type once (its interior
|
|
||||||
//! with `[<name>]` input/output port markers). `render_compilat` shows the flat
|
|
||||||
//! post-inline graph (boundaries dissolved, C23). Rendering reads structure +
|
|
||||||
//! node `label()`s only — never `eval`.
|
|
||||||
//!
|
|
||||||
//! ascii-dag borrows its node labels as `&'a str`, so each function first
|
|
||||||
//! materializes the owned label `String`s (which outlive the `Graph`), then
|
|
||||||
//! borrows into them. `RenderMode::Vertical` is mandatory: Horizontal collapses a
|
|
||||||
//! fan-out onto one path. Both views build flat graphs (no subgraphs): the
|
|
||||||
//! subgraph layout mis-centres wide sibling labels, the flat layout does not.
|
|
||||||
|
|
||||||
use ascii_dag::graph::{Graph, RenderMode};
|
|
||||||
use ascii_dag::render::colors::Palette;
|
|
||||||
use aura_core::{Node, PrimitiveBuilder, ScalarKind};
|
|
||||||
use aura_engine::{
|
|
||||||
aliases_on, signature_of, BlueprintNode, Composite, Edge, OutField, ParamAlias, SourceSpec,
|
|
||||||
Target,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Where a leaf's param **names** come from in the shared leaf-label path — the one
|
|
||||||
/// border that differs between the two views (#48). A composite interior leaf draws
|
|
||||||
/// its names from the composite's `ParamAlias` overlay (filtered to this node,
|
|
||||||
/// keeping the existing `where:` form byte-for-byte); a top-level blueprint leaf has
|
|
||||||
/// no alias overlay, so its names come straight from the factory's declared params.
|
|
||||||
enum ParamNames<'a> {
|
|
||||||
/// Composite interior: the alias list, filtered by `node == index` (existing
|
|
||||||
/// `where:` behaviour — only aliased slots show, never factory defaults).
|
|
||||||
Aliases(&'a [ParamAlias]),
|
|
||||||
/// Top-level blueprint leaf: every factory-declared param name, in order.
|
|
||||||
Factory,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A CLI-local, *borrowed* render notion unifying a composite **input role** and a
|
|
||||||
/// blueprint **bound source role** for the shared graph core (#48): both are an
|
|
||||||
/// external entry drawn as a marker node wired into its interior `targets`. Both
|
|
||||||
/// views build these from `Role` (name = role name); the blueprint root filters to
|
|
||||||
/// bound roles (`source.is_some()`), the only remaining root-vs-interior
|
|
||||||
/// distinction (C3) — no engine change, the list is assembled CLI-side and borrows
|
|
||||||
/// the engine's `Target` slices.
|
|
||||||
struct Entry<'a> {
|
|
||||||
name: String,
|
|
||||||
targets: &'a [Target],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Edge colouring for a rendered graph. `Plain` is monochrome — golden-stable and
|
|
||||||
/// pipe-safe (no escape codes when redirected to a file). `Ansi` emits per-edge
|
|
||||||
/// ANSI colours so crossing edges stay traceable on an interactive terminal. The
|
|
||||||
/// CLI selects `Ansi` only when stdout is a TTY; tests always render `Plain`.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub enum Color {
|
|
||||||
Plain,
|
|
||||||
Ansi,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blueprint view: the authored structure (#38). A flat main graph wires the
|
|
||||||
/// harness with each composite shown as a single opaque node; a `where:` section
|
|
||||||
/// defines each distinct composite type once. Flat layout only (no subgraphs).
|
|
||||||
pub fn render_blueprint(bp: &Composite, color: Color) -> String {
|
|
||||||
// the main graph is the shared graph core (`render_graph`) over the root
|
|
||||||
// composite's top-level (nodes, edges), rendered exactly as a `where:` interior:
|
|
||||||
// leaves enriched (param names, a `<field> →` prefix for a multi-output producer
|
|
||||||
// — macd's `histogram` driving Exposure — and, now threaded at the root too,
|
|
||||||
// fan-in slot stubs), composites opaque, entries named by their role. The only
|
|
||||||
// deltas vs a composite definition: param names come from the builder (no alias
|
|
||||||
// overlay at the root); entries are filtered to bound source roles (C3); there
|
|
||||||
// is no output record (terminals are sinks) and no title.
|
|
||||||
let entries: Vec<Entry> = bp
|
|
||||||
.input_roles()
|
|
||||||
.iter()
|
|
||||||
.filter(|role| role.source.is_some())
|
|
||||||
.map(|role| Entry { name: role.name.clone(), targets: &role.targets })
|
|
||||||
.collect();
|
|
||||||
let main = render_graph(
|
|
||||||
bp.nodes(),
|
|
||||||
bp.edges(),
|
|
||||||
&entries,
|
|
||||||
&[], // no output bindings: a blueprint's terminals are sinks
|
|
||||||
ParamNames::Factory,
|
|
||||||
bp, // the root IS the fan-in stub context (it carries roles + edges)
|
|
||||||
None, // no title
|
|
||||||
color,
|
|
||||||
);
|
|
||||||
|
|
||||||
// definitions: each distinct composite type, once, recursively.
|
|
||||||
let defs = collect_distinct_composites(bp);
|
|
||||||
if defs.is_empty() {
|
|
||||||
return main;
|
|
||||||
}
|
|
||||||
let body = defs.iter().map(|c| render_definition(c, color)).collect::<Vec<_>>().join("\n");
|
|
||||||
format!("{main}\nwhere:\n\n{body}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shared graph core both views render through (#48): a flat ascii-dag of the
|
|
||||||
/// computation DAG plus external-entry markers. `nodes`/`edges` are a borrowed
|
|
||||||
/// (blueprint-root or composite-interior) graph slice — never an owned
|
|
||||||
/// `Composite` (`PrimitiveBuilder` is not `Clone`). Each leaf gets the unified
|
|
||||||
/// [`leaf_label`]; each composite is opaque (`[name]`); a producing node carrying an
|
|
||||||
/// `OutField` re-export gets the `name := ` binding prefix folded on (empty list →
|
|
||||||
/// no binding, the blueprint case); every [`Entry`] is drawn as a marker wired to its
|
|
||||||
/// interior targets. A `title` (the composite's typed signature) is prefixed when
|
|
||||||
/// `Some`. The render-border that differs — the param-name source — is passed as
|
|
||||||
/// `param_names`; `stub_ctx` is the borrowed composite (root or interior) both
|
|
||||||
/// views thread for fan-in slot resolution.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn render_graph(
|
|
||||||
nodes: &[BlueprintNode],
|
|
||||||
edges: &[Edge],
|
|
||||||
entries: &[Entry],
|
|
||||||
output: &[OutField],
|
|
||||||
param_names: ParamNames,
|
|
||||||
stub_ctx: &Composite,
|
|
||||||
title: Option<&str>,
|
|
||||||
color: Color,
|
|
||||||
) -> String {
|
|
||||||
let mut labels: Vec<String> = Vec::with_capacity(nodes.len());
|
|
||||||
for (i, item) in nodes.iter().enumerate() {
|
|
||||||
let base = match item {
|
|
||||||
BlueprintNode::Primitive(factory) => {
|
|
||||||
leaf_label(nodes, edges, entries, i, factory, ¶m_names, stub_ctx)
|
|
||||||
}
|
|
||||||
BlueprintNode::Composite(c) => c.name().to_string(),
|
|
||||||
};
|
|
||||||
labels.push(match output_binding(output, i) {
|
|
||||||
Some(prefix) => format!("{prefix}{base}"),
|
|
||||||
None => base,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut edge_pairs: Vec<(usize, usize)> = edges.iter().map(|e| (e.from, e.to)).collect();
|
|
||||||
for entry in entries {
|
|
||||||
let entry_id = labels.len();
|
|
||||||
labels.push(entry.name.clone());
|
|
||||||
for t in entry.targets {
|
|
||||||
edge_pairs.push((entry_id, t.node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// outputs are folded onto their producers by output_binding above — no
|
|
||||||
// standalone output node and no producer→output edge (C23).
|
|
||||||
|
|
||||||
let flat = render_flat(&labels, &edge_pairs, color);
|
|
||||||
match title {
|
|
||||||
Some(t) => format!("{t}:\n\n{flat}"),
|
|
||||||
None => flat,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build and render a flat (no-subgraph) ascii-dag graph from owned labels and
|
|
||||||
/// edge pairs — the same idiom `render_compilat` uses.
|
|
||||||
fn render_flat(labels: &[String], edges: &[(usize, usize)], color: Color) -> String {
|
|
||||||
let mut g = Graph::with_mode(RenderMode::Vertical);
|
|
||||||
for (id, l) in labels.iter().enumerate() {
|
|
||||||
g.add_node(id, l);
|
|
||||||
}
|
|
||||||
for &(from, to) in edges {
|
|
||||||
g.add_edge(from, to, None);
|
|
||||||
}
|
|
||||||
match color {
|
|
||||||
Color::Plain => g.render(),
|
|
||||||
// colour lives in the layout IR's scanline pass; node labels stay default
|
|
||||||
// colour (ascii-dag only colours edges), so the box text is unaffected.
|
|
||||||
Color::Ansi => g.compute_layout().render_scanline_colored(Palette::Ansi),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every distinct composite type in the blueprint, in first-seen order, keyed by
|
|
||||||
/// `name()` (the authoring type identity — same name implies same structure).
|
|
||||||
/// Recurses into a composite's interior on first sight so nested composites get
|
|
||||||
/// their own definition; a later same-name occurrence is skipped (deduped).
|
|
||||||
fn collect_distinct_composites(bp: &Composite) -> Vec<&Composite> {
|
|
||||||
fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) {
|
|
||||||
for item in items {
|
|
||||||
if let BlueprintNode::Composite(c) = item
|
|
||||||
&& !seen.contains(&c.name())
|
|
||||||
{
|
|
||||||
seen.push(c.name());
|
|
||||||
out.push(c);
|
|
||||||
walk(c.nodes(), seen, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut seen: Vec<&str> = Vec::new();
|
|
||||||
let mut out: Vec<&Composite> = Vec::new();
|
|
||||||
walk(bp.nodes(), &mut seen, &mut out);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `ScalarKind` as a lowercase type string for a signature (`i64`/`f64`/`bool`/
|
|
||||||
/// `timestamp`). The derived `Debug` gives PascalCase (`I64`), so this is explicit.
|
|
||||||
fn kind_str(kind: ScalarKind) -> &'static str {
|
|
||||||
match kind {
|
|
||||||
ScalarKind::I64 => "i64",
|
|
||||||
ScalarKind::F64 => "f64",
|
|
||||||
ScalarKind::Bool => "bool",
|
|
||||||
ScalarKind::Timestamp => "timestamp",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The composite's typed signature for the definition title:
|
|
||||||
/// `name(p1:kind, …) -> (o1, …)`. Param kinds come from the aliased interior leaf's
|
|
||||||
/// declared params; output **names only** (kinds need a pre-build factory interface,
|
|
||||||
/// #43). An empty alias list renders `name()`. Total: a malformed alias falls back
|
|
||||||
/// to `?` rather than panicking (compile is the validator, #41).
|
|
||||||
fn signature(c: &Composite) -> String {
|
|
||||||
let params: Vec<String> = c
|
|
||||||
.params()
|
|
||||||
.iter()
|
|
||||||
.map(|a| {
|
|
||||||
let kind = c
|
|
||||||
.nodes()
|
|
||||||
.get(a.node)
|
|
||||||
.and_then(|n| match n {
|
|
||||||
BlueprintNode::Primitive(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)),
|
|
||||||
BlueprintNode::Composite(_) => None,
|
|
||||||
})
|
|
||||||
.unwrap_or("?");
|
|
||||||
format!("{}:{}", a.name, kind)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let outs: Vec<String> = c.output().iter().map(|of| of.name.clone()).collect();
|
|
||||||
format!("{}({}) -> ({})", c.name(), params.join(", "), outs.join(", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The unified leaf-label both views render through (#48): `factory.label()`
|
|
||||||
/// enriched in three value-empty ways (names, never values — C22 "structure
|
|
||||||
/// before"), folded into one `factory.label(...)` form:
|
|
||||||
///
|
|
||||||
/// 1. A `<field> → ` consumer-side prefix per input slot fed by a **multi-output
|
|
||||||
/// producer** (the headline — macd's `histogram` driving `Exposure`). Single-output
|
|
||||||
/// producers contribute nothing, so single-out graphs (sample sma_cross interior
|
|
||||||
/// and main graph) stay prefix-free. Drawn outside the `(...)`, like the
|
|
||||||
/// `output_binding` consumer-prefix form, because ascii-dag 0.9.1 silently drops
|
|
||||||
/// an edge label it cannot place.
|
|
||||||
/// 2. The leaf's param **names** in `(...)`. The source is [`ParamNames`]: the
|
|
||||||
/// composite path keeps using the alias overlay filtered to this node (so `where:`
|
|
||||||
/// stays byte-identical — only aliased slots, never factory defaults); the
|
|
||||||
/// blueprint-root path uses the factory's declared names.
|
|
||||||
/// 3. The leaf's input-slot stubs (`#Sf`, …) when it is a multi-input fan-in. The
|
|
||||||
/// `stub_ctx` is the borrowed composite (root or interior) the wired slots
|
|
||||||
/// resolve against; both views thread it, so a top-level fan-in stubs exactly as
|
|
||||||
/// an interior one does — one shared path, no root carve-out (#49).
|
|
||||||
///
|
|
||||||
/// Params and stubs are `; `-separated inside the `(...)` when both present; the
|
|
||||||
/// field prefix always sits outside the parens. The bare label results when none of
|
|
||||||
/// the three enrichments apply.
|
|
||||||
fn leaf_label(
|
|
||||||
nodes: &[BlueprintNode],
|
|
||||||
edges: &[Edge],
|
|
||||||
entries: &[Entry],
|
|
||||||
index: usize,
|
|
||||||
factory: &PrimitiveBuilder,
|
|
||||||
param_names: &ParamNames,
|
|
||||||
stub_ctx: &Composite,
|
|
||||||
) -> String {
|
|
||||||
let params: Vec<&str> = match param_names {
|
|
||||||
ParamNames::Aliases(aliases) => {
|
|
||||||
aliases.iter().filter(|a| a.node == index).map(|a| a.name.as_str()).collect()
|
|
||||||
}
|
|
||||||
ParamNames::Factory => factory.params().iter().map(|p| p.name.as_str()).collect(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// wired input slots: the distinct `.slot` values targeting this node across
|
|
||||||
// interior/root edges and external entries (roles or sources).
|
|
||||||
let mut slots: Vec<usize> = Vec::new();
|
|
||||||
for e in edges {
|
|
||||||
if e.to == index && !slots.contains(&e.slot) {
|
|
||||||
slots.push(e.slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for entry in entries {
|
|
||||||
for t in entry.targets {
|
|
||||||
if t.node == index && !slots.contains(&t.slot) {
|
|
||||||
slots.push(t.slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slots.sort_unstable();
|
|
||||||
let stubs: Vec<String> = if slots.len() > 1 {
|
|
||||||
fan_in_identifiers(stub_ctx, index, &slots)
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let inner: String = match (params.is_empty(), stubs.is_empty()) {
|
|
||||||
(true, true) => factory.label(),
|
|
||||||
(false, true) => format!("{}({})", factory.label(), params.join(", ")),
|
|
||||||
(true, false) => format!("{}({})", factory.label(), stubs.join(",")),
|
|
||||||
(false, false) => {
|
|
||||||
format!("{}({}; {})", factory.label(), params.join(", "), stubs.join(","))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// field prefixes: for each edge feeding this leaf from a multi-output producer,
|
|
||||||
// a `<field> → ` consumer-side prefix, in slot order for determinism. A no-op for
|
|
||||||
// both current corpora's single-output interiors (so `where:` is byte-stable).
|
|
||||||
let mut fed: Vec<(usize, String)> = Vec::new();
|
|
||||||
for e in edges {
|
|
||||||
if e.to != index {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(field) = multi_output_field_name(nodes, e.from, e.from_field) {
|
|
||||||
fed.push((e.slot, field));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fed.sort_by_key(|(slot, _)| *slot);
|
|
||||||
if fed.is_empty() {
|
|
||||||
return inner;
|
|
||||||
}
|
|
||||||
// a per-field `<field> → ` stack, then the (possibly enriched) label.
|
|
||||||
let prefix = fed.iter().map(|(_, f)| format!("{f} \u{2192} ")).collect::<String>();
|
|
||||||
format!("{prefix}{inner}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The driving field's *name* when producer `from` (in `nodes`) is a **multi-output**
|
|
||||||
/// producer feeding its field `from_field` into a consumer; `None` when single-output
|
|
||||||
/// (no prefix — keeps single-out graphs clean). Operates on a borrowed node slice, so
|
|
||||||
/// it serves both views uniformly (blueprint root and composite interior).
|
|
||||||
///
|
|
||||||
/// - Composite producer: multi-output iff `output().len() > 1`; the name is
|
|
||||||
/// `output()[from_field].name` (the headline — macd's `output()[2] == "histogram"`).
|
|
||||||
/// - Leaf producer: pre-build field names are unavailable (#43), so a leaf's output
|
|
||||||
/// arity is unknown here. Treat `from_field > 0` as the only structurally-certain
|
|
||||||
/// multi-output signal and fall back to the field **index** as a string; this case
|
|
||||||
/// is absent in both current corpora and must never panic.
|
|
||||||
fn multi_output_field_name(nodes: &[BlueprintNode], from: usize, from_field: usize) -> Option<String> {
|
|
||||||
match nodes.get(from)? {
|
|
||||||
BlueprintNode::Composite(c) => {
|
|
||||||
if c.output().len() > 1 {
|
|
||||||
Some(
|
|
||||||
c.output()
|
|
||||||
.get(from_field)
|
|
||||||
.map(|of| of.name.clone())
|
|
||||||
.unwrap_or_else(|| from_field.to_string()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BlueprintNode::Primitive(_) => (from_field > 0).then(|| from_field.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One `#…` identifier per wired slot of a fan-in leaf, in slot order.
|
|
||||||
/// - A role-fed slot uses the role name verbatim (`#price`), never shortened.
|
|
||||||
/// - An interior-fed slot uses its source signature, never shorter than the
|
|
||||||
/// source's **base** (type initial + alias initials), extended into the
|
|
||||||
/// recursive tail only as far as needed to be unique among the siblings.
|
|
||||||
/// - Two siblings with equal full signatures (interchangeable inputs) cannot be
|
|
||||||
/// separated — those slots fall back to the positional letter `#A`. The engine
|
|
||||||
/// constraint guarantees no configuration-distinct pair fully collides, so a
|
|
||||||
/// valid blueprint reaches the fallback only for genuinely-interchangeable
|
|
||||||
/// inputs.
|
|
||||||
fn fan_in_identifiers(c: &Composite, index: usize, slots: &[usize]) -> Vec<String> {
|
|
||||||
// per slot: (slot, signature, base_len, is_role)
|
|
||||||
let srcs: Vec<(usize, String, usize, bool)> = slots
|
|
||||||
.iter()
|
|
||||||
.map(|&slot| {
|
|
||||||
let (sig, base, is_role) = slot_source(c, index, slot);
|
|
||||||
(slot, sig, base, is_role)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
srcs.iter()
|
|
||||||
.map(|(slot, sig, base, is_role)| {
|
|
||||||
if *is_role {
|
|
||||||
return format!("#{sig}"); // role name verbatim
|
|
||||||
}
|
|
||||||
let others: Vec<&String> =
|
|
||||||
srcs.iter().filter(|(s, _, _, _)| s != slot).map(|(_, x, _, _)| x).collect();
|
|
||||||
match unique_prefix_from(sig, *base, &others) {
|
|
||||||
Some(p) => format!("#{p}"),
|
|
||||||
None => format!("#{}", (b'A' + *slot as u8) as char), // interchangeable fallback
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The source feeding `(index, slot)`: `(signature, base_len, is_role)`. A role
|
|
||||||
/// returns its name verbatim with `is_role = true` (base_len unused); an interior
|
|
||||||
/// producer returns its `signature_of` and its base length (type initial + alias
|
|
||||||
/// initials).
|
|
||||||
fn slot_source(c: &Composite, index: usize, slot: usize) -> (String, usize, bool) {
|
|
||||||
for e in c.edges() {
|
|
||||||
if e.to == index && e.slot == slot {
|
|
||||||
let sig = signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), e.from);
|
|
||||||
return (sig, signature_base_len(c, e.from), false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for r in c.input_roles() {
|
|
||||||
if r.targets.iter().any(|t| t.node == index && t.slot == slot) {
|
|
||||||
return (r.name.clone(), 0, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(String::new(), 0, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The base length of an interior node's signature: 1 (type / composite-name
|
|
||||||
/// initial) plus one per declared param alias on that node — the minimum the
|
|
||||||
/// rendered identifier never goes below.
|
|
||||||
fn signature_base_len(c: &Composite, node: usize) -> usize {
|
|
||||||
match &c.nodes()[node] {
|
|
||||||
BlueprintNode::Primitive(_) => 1 + aliases_on(c.params(), node).count(),
|
|
||||||
BlueprintNode::Composite(_) => 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shortest prefix of `sig` of length ≥ `base` that no `other` starts with —
|
|
||||||
/// i.e. distinguishes `sig` from all siblings while never dropping below the
|
|
||||||
/// base. `None` when some `other` equals `sig` in full (inseparable —
|
|
||||||
/// interchangeable, caller uses the positional fallback).
|
|
||||||
fn unique_prefix_from(sig: &str, base: usize, others: &[&String]) -> Option<String> {
|
|
||||||
if others.iter().any(|o| o.as_str() == sig) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let chars: Vec<char> = sig.chars().collect();
|
|
||||||
if chars.is_empty() {
|
|
||||||
return Some(String::new()); // degenerate (no producer); not reached for a wired slot
|
|
||||||
}
|
|
||||||
let start = base.max(1).min(chars.len());
|
|
||||||
for len in start..=chars.len() {
|
|
||||||
let prefix: String = chars[..len].iter().collect();
|
|
||||||
if others.iter().all(|o| !o.starts_with(&prefix)) {
|
|
||||||
return Some(prefix);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(sig.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render one composite's interior as a flat graph: interior leaves as
|
|
||||||
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
|
|
||||||
/// in via `leaf_label`), nested composites as opaque `[name]`, and an `[<name>]`
|
|
||||||
/// entry marker per input role (wired to its interior targets). A re-exported
|
|
||||||
/// output is **not** a standalone node: its name is folded onto its producing
|
|
||||||
/// node's label as a binding (`[macd := Sub(…)]`, tuple `(a, b) := …` for several
|
|
||||||
/// outputs on one node) via [`output_binding`] — so the drawn graph is exactly the
|
|
||||||
/// computation DAG, every node a real step (C23: the name is a render symbol on the
|
|
||||||
/// producer, never a wired terminal). The title line is the composite's typed
|
|
||||||
/// `signature` (`name(p:kind, …) -> (out, …)`); params live in the signature, not
|
|
||||||
/// as marker nodes.
|
|
||||||
fn render_definition(c: &Composite, color: Color) -> String {
|
|
||||||
// the same shared graph core the main graph uses (#48); the composite-specific
|
|
||||||
// borders: param names from the alias overlay, entries from input roles, the
|
|
||||||
// output record folded as bindings, fan-in stub context available, and the typed
|
|
||||||
// signature as title.
|
|
||||||
let entries: Vec<Entry> = c
|
|
||||||
.input_roles()
|
|
||||||
.iter()
|
|
||||||
.map(|role| Entry { name: role.name.clone(), targets: &role.targets })
|
|
||||||
.collect();
|
|
||||||
let title = signature(c);
|
|
||||||
render_graph(
|
|
||||||
c.nodes(),
|
|
||||||
c.edges(),
|
|
||||||
&entries,
|
|
||||||
c.output(),
|
|
||||||
ParamNames::Aliases(c.params()),
|
|
||||||
c,
|
|
||||||
Some(&title),
|
|
||||||
color,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `name := ` (single) or `(n1, n2) := ` (tuple) binding prefix for interior
|
|
||||||
/// node `i`, if any `OutField` re-exports it; `None` for a non-output node. Names
|
|
||||||
/// are ordered by re-exported `field` index (ties keep author order). The producer
|
|
||||||
/// label follows the prefix; no standalone output node or producer→output edge is
|
|
||||||
/// emitted (the output *is* the producer, surfaced by name — C23).
|
|
||||||
fn output_binding(output: &[OutField], i: usize) -> Option<String> {
|
|
||||||
let mut outs: Vec<&OutField> = output.iter().filter(|of| of.node == i).collect();
|
|
||||||
if outs.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
outs.sort_by_key(|of| of.field);
|
|
||||||
let names: Vec<&str> = outs.iter().map(|of| of.name.as_str()).collect();
|
|
||||||
Some(match names.as_slice() {
|
|
||||||
[one] => format!("{one} := "),
|
|
||||||
many => format!("({}) := ", many.join(", ")),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved,
|
|
||||||
/// C23). Each `Box<dyn Node>` labels itself; node display id = node index.
|
|
||||||
pub fn render_compilat(
|
|
||||||
nodes: &[Box<dyn Node>],
|
|
||||||
sources: &[SourceSpec],
|
|
||||||
edges: &[Edge],
|
|
||||||
color: Color,
|
|
||||||
) -> String {
|
|
||||||
let mut labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
|
|
||||||
let source_base = labels.len();
|
|
||||||
for src in sources {
|
|
||||||
labels.push(format!("source:{:?}", src.kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut edge_pairs: Vec<(usize, usize)> = edges.iter().map(|e| (e.from, e.to)).collect();
|
|
||||||
for (i, src) in sources.iter().enumerate() {
|
|
||||||
for t in &src.targets {
|
|
||||||
edge_pairs.push((source_base + i, t.node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
render_flat(&labels, &edge_pairs, color)
|
|
||||||
}
|
|
||||||
+9
-347
@@ -6,7 +6,7 @@
|
|||||||
//! recording sinks), runs it deterministically (C1), and prints the run's
|
//! recording sinks), runs it deterministically (C1), and prints the run's
|
||||||
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
|
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
|
||||||
|
|
||||||
mod graph;
|
mod render;
|
||||||
|
|
||||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||||
use aura_engine::{
|
use aura_engine::{
|
||||||
@@ -14,7 +14,6 @@ use aura_engine::{
|
|||||||
Role, RunManifest, RunReport, SourceSpec, Target,
|
Role, RunManifest, RunReport, SourceSpec, Target,
|
||||||
};
|
};
|
||||||
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
|
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
|
||||||
use std::io::IsTerminal;
|
|
||||||
use std::sync::mpsc::{self, Receiver};
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
|
||||||
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
|
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
|
||||||
@@ -155,9 +154,9 @@ fn sma_cross(name: &str) -> Composite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The sample signal-quality blueprint (value-empty): a recipe whose SMA lengths +
|
/// The sample signal-quality blueprint (value-empty): a recipe whose SMA lengths +
|
||||||
/// exposure scale are injected at compile via the point vector (see
|
/// exposure scale are injected at compile via the point vector. Recorders need a
|
||||||
/// `sample_point`). Recorders need a channel to construct; the receivers are
|
/// channel to construct; the receivers are dropped because the render never runs
|
||||||
/// dropped because the render never runs the graph.
|
/// the graph.
|
||||||
fn build_sample() -> Composite {
|
fn build_sample() -> Composite {
|
||||||
let (tx_eq, _rx_eq) = mpsc::channel();
|
let (tx_eq, _rx_eq) = mpsc::channel();
|
||||||
let (tx_ex, _rx_ex) = mpsc::channel();
|
let (tx_ex, _rx_ex) = mpsc::channel();
|
||||||
@@ -194,12 +193,6 @@ fn sample_blueprint() -> Composite {
|
|||||||
build_sample()
|
build_sample()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The point vector injected into the sample blueprint, in `param_space()` slot
|
|
||||||
/// order: `[fast SMA length, slow SMA length, exposure scale]`.
|
|
||||||
fn sample_point() -> Vec<Scalar> {
|
|
||||||
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MACD proof-of-concept (a richer, nested indicator + strategy) -----------
|
// --- MACD proof-of-concept (a richer, nested indicator + strategy) -----------
|
||||||
|
|
||||||
/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line
|
/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line
|
||||||
@@ -283,8 +276,9 @@ fn macd_strategy_blueprint(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The MACD strategy blueprint for the structural render (receivers dropped, as
|
/// The MACD strategy blueprint as a param-space fixture (receivers dropped, since
|
||||||
/// the render never runs the graph).
|
/// `param_space()` reads structure only, never runs the graph).
|
||||||
|
#[cfg(test)]
|
||||||
fn macd_blueprint() -> Composite {
|
fn macd_blueprint() -> Composite {
|
||||||
let (tx_eq, _rx_eq) = mpsc::channel();
|
let (tx_eq, _rx_eq) = mpsc::channel();
|
||||||
let (tx_ex, _rx_ex) = mpsc::channel();
|
let (tx_ex, _rx_ex) = mpsc::channel();
|
||||||
@@ -355,38 +349,17 @@ fn run_macd() -> RunReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compile a blueprint under its point vector and render the flat post-inline
|
const USAGE: &str = "usage: aura run [--macd] | aura graph";
|
||||||
/// (C23) view — the shared body of the `--compiled` paths.
|
|
||||||
fn render_compiled(bp: Composite, point: &[Scalar], color: graph::Color) -> String {
|
|
||||||
let flat = bp.compile_with_params(point).expect("valid blueprint");
|
|
||||||
graph::render_compilat(&flat.nodes, &flat.sources, &flat.edges, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
const USAGE: &str = "usage: aura run [--macd] | aura graph [--compiled | --macd [--compiled]]";
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||||
// so an unexpected trailing token falls through to the usage-error path rather
|
// so an unexpected trailing token falls through to the usage-error path rather
|
||||||
// than masquerading as a successful run (#16 strict reading).
|
// than masquerading as a successful run (#16 strict reading).
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
// Edge colouring only when stdout is an interactive terminal; a redirect to a
|
|
||||||
// file or pipe stays plain (no escape codes). `run` output is JSON, never coloured.
|
|
||||||
let color = if std::io::stdout().is_terminal() {
|
|
||||||
graph::Color::Ansi
|
|
||||||
} else {
|
|
||||||
graph::Color::Plain
|
|
||||||
};
|
|
||||||
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
|
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
|
||||||
["run"] => println!("{}", run_sample().to_json()),
|
["run"] => println!("{}", run_sample().to_json()),
|
||||||
["run", "--macd"] => println!("{}", run_macd().to_json()),
|
["run", "--macd"] => println!("{}", run_macd().to_json()),
|
||||||
["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint(), color)),
|
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||||
["graph", "--compiled"] => {
|
|
||||||
println!("{}", render_compiled(sample_blueprint(), &sample_point(), color));
|
|
||||||
}
|
|
||||||
["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint(), color)),
|
|
||||||
["graph", "--macd", "--compiled"] => {
|
|
||||||
println!("{}", render_compiled(macd_blueprint(), &macd_point(), color));
|
|
||||||
}
|
|
||||||
["--help"] | ["-h"] => println!("{USAGE}"),
|
["--help"] | ["-h"] => println!("{USAGE}"),
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!("aura: {USAGE}");
|
eprintln!("aura: {USAGE}");
|
||||||
@@ -399,199 +372,6 @@ fn main() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The sample's point vector with the fast/slow SMA lengths swapped — the
|
|
||||||
/// mis-wiring the compiled render must surface. The blueprint is param-generic
|
|
||||||
/// and identical for both orderings; only the injected vector differs.
|
|
||||||
fn swapped_point() -> Vec<Scalar> {
|
|
||||||
vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blueprint_view_main_graph_shows_composite_as_opaque_node() {
|
|
||||||
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
|
|
||||||
// the composite is a single opaque main-graph node, not an expanded cluster
|
|
||||||
assert!(out.contains("[sma_cross]"), "missing opaque composite node:\n{out}");
|
|
||||||
// top-level leaves render enriched exactly as the `where:` interior leaves:
|
|
||||||
// `Exposure` folds its `scale` param; a paramless SINGLE-input leaf
|
|
||||||
// (`Recorder`) stays bare, but a paramless MULTI-input fan-in (`SimBroker`)
|
|
||||||
// now shows its slot stubs (#49).
|
|
||||||
for needle in ["[Exposure(scale)]", "[SimBroker(#E,#price)]", "[Recorder]"] {
|
|
||||||
assert!(out.contains(needle), "missing {needle}:\n{out}");
|
|
||||||
}
|
|
||||||
// a definitions section is present
|
|
||||||
assert!(out.contains("where:"), "missing where: section:\n{out}");
|
|
||||||
// the flat layout draws no subgraph cluster box
|
|
||||||
assert!(!out.contains('╔'), "blueprint view must not draw a cluster box:\n{out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blueprint_view_defines_each_composite_once() {
|
|
||||||
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
|
|
||||||
// the sma_cross body is defined exactly once, with its interior + ports
|
|
||||||
assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}");
|
|
||||||
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[cross := Sub(#Sf,#Ss)]", "[price]"] {
|
|
||||||
assert!(out.contains(needle), "missing {needle} in definition:\n{out}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nested_composite_renders_without_panic() {
|
|
||||||
// a composite whose interior contains another composite — render reads
|
|
||||||
// structure only (no compile/validate), so a minimal fixture suffices.
|
|
||||||
let inner = Composite::new(
|
|
||||||
"inner",
|
|
||||||
vec![Sma::builder().into()],
|
|
||||||
vec![],
|
|
||||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
||||||
vec![],
|
|
||||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
|
||||||
);
|
|
||||||
let outer = Composite::new(
|
|
||||||
"outer",
|
|
||||||
vec![BlueprintNode::Composite(inner), Sub::builder().into()],
|
|
||||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
||||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
|
||||||
vec![],
|
|
||||||
vec![OutField { node: 1, field: 0, name: "out".into() }],
|
|
||||||
);
|
|
||||||
let bp = Composite::new(
|
|
||||||
"root",
|
|
||||||
vec![BlueprintNode::Composite(outer)],
|
|
||||||
vec![],
|
|
||||||
vec![
|
|
||||||
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
||||||
],
|
|
||||||
vec![], // params
|
|
||||||
vec![], // output
|
|
||||||
);
|
|
||||||
let out = graph::render_blueprint(&bp, graph::Color::Plain); // must not panic (no unimplemented!)
|
|
||||||
// outer shows the inner composite as an opaque node, and both get a definition
|
|
||||||
assert!(out.contains("[outer]"), "missing opaque outer node:\n{out}");
|
|
||||||
assert!(out.contains("[inner]"), "inner must be opaque inside outer's definition:\n{out}");
|
|
||||||
assert_eq!(out.matches("outer(").count(), 1, "outer defined once:\n{out}");
|
|
||||||
assert_eq!(out.matches("inner(").count(), 1, "inner defined once:\n{out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reused_composite_defined_once() {
|
|
||||||
// the same composite type used twice: two opaque nodes, one definition.
|
|
||||||
let bp = Composite::new(
|
|
||||||
"root",
|
|
||||||
vec![
|
|
||||||
BlueprintNode::Composite(sma_cross("dup")),
|
|
||||||
BlueprintNode::Composite(sma_cross("dup")),
|
|
||||||
Exposure::builder().into(),
|
|
||||||
],
|
|
||||||
vec![
|
|
||||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
||||||
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
|
|
||||||
],
|
|
||||||
vec![
|
|
||||||
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: Some(ScalarKind::F64) },
|
|
||||||
],
|
|
||||||
vec![], // params
|
|
||||||
vec![], // output
|
|
||||||
);
|
|
||||||
let out = graph::render_blueprint(&bp, graph::Color::Plain);
|
|
||||||
assert_eq!(out.matches("[dup]").count(), 2, "two opaque uses expected:\n{out}");
|
|
||||||
assert_eq!(out.matches("dup(").count(), 1, "body defined once:\n{out}");
|
|
||||||
// the bound root role named "src" carries its name into the entry marker
|
|
||||||
// (general non-"price" role-name passthrough, not just the sample's price).
|
|
||||||
assert!(out.contains("[src]"), "root role name renders as the entry marker: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compiled_view_dissolves_the_composite_boundary() {
|
|
||||||
let bp = sample_blueprint();
|
|
||||||
let flat = bp.compile_with_params(&sample_point()).expect("valid sample");
|
|
||||||
let out = graph::render_compilat(&flat.nodes, &flat.sources, &flat.edges, graph::Color::Plain);
|
|
||||||
// node labels survive inlining...
|
|
||||||
assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
|
|
||||||
// ...but the composite cluster name does NOT (boundary dissolved, C23)
|
|
||||||
assert!(!out.contains("sma_cross"), "compiled view must not show the cluster:\n{out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn swapped_param_vector_changes_the_compiled_render() {
|
|
||||||
// the property the cycle exists to buy: a mis-wiring is no longer invisible.
|
|
||||||
// The blueprint is now param-generic (identical for both orderings), so the
|
|
||||||
// swap is observable only after the vector is injected — in the COMPILED
|
|
||||||
// view, where the flat nodes carry their valued labels (SMA(2)/SMA(4)).
|
|
||||||
let cflat =
|
|
||||||
sample_blueprint().compile_with_params(&sample_point()).expect("valid sample");
|
|
||||||
let correct = graph::render_compilat(&cflat.nodes, &cflat.sources, &cflat.edges, graph::Color::Plain);
|
|
||||||
let sflat =
|
|
||||||
sample_blueprint().compile_with_params(&swapped_point()).expect("valid sample");
|
|
||||||
let swapped = graph::render_compilat(&sflat.nodes, &sflat.sources, &sflat.edges, graph::Color::Plain);
|
|
||||||
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the compiled render");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blueprint_view_golden() {
|
|
||||||
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
|
|
||||||
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
|
|
||||||
// exact bytes `aura graph` emits — main graph (composites opaque) + the
|
|
||||||
// `where:` definitions section. Re-capture via `aura graph` if intended.
|
|
||||||
let expected = r#" [price]
|
|
||||||
┌──└──────┐
|
|
||||||
↓ │
|
|
||||||
[sma_cross] │
|
|
||||||
│ │
|
|
||||||
↓ └──┐
|
|
||||||
[Exposure(scale)] │
|
|
||||||
┌────────┘─────────┐ │
|
|
||||||
↓ ↓──┘
|
|
||||||
[Recorder] [SimBroker(#E,#price)]
|
|
||||||
┌──────┘
|
|
||||||
↓
|
|
||||||
[Recorder]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
where:
|
|
||||||
|
|
||||||
sma_cross(fast:i64, slow:i64) -> (cross):
|
|
||||||
|
|
||||||
[price]
|
|
||||||
┌──────└──────┐
|
|
||||||
↓ ↓
|
|
||||||
[SMA(fast)] [SMA(slow)]
|
|
||||||
└──────┌──────┘
|
|
||||||
↓
|
|
||||||
[cross := Sub(#Sf,#Ss)]
|
|
||||||
|
|
||||||
|
|
||||||
"#;
|
|
||||||
assert_eq!(out, expected, "blueprint render drifted; re-capture if intended");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compiled_view_golden() {
|
|
||||||
let bp = sample_blueprint();
|
|
||||||
let flat = bp.compile_with_params(&sample_point()).expect("valid sample");
|
|
||||||
let out = graph::render_compilat(&flat.nodes, &flat.sources, &flat.edges, graph::Color::Plain);
|
|
||||||
let expected = r#" [source:F64]
|
|
||||||
┌────────└─┐──────┐
|
|
||||||
↓ ↓ │
|
|
||||||
[SMA(2)] [SMA(4)] │
|
|
||||||
└────┌─────┘ │
|
|
||||||
↓ ┌──────┘
|
|
||||||
[Sub] │
|
|
||||||
│ │
|
|
||||||
↓ └────┐
|
|
||||||
[Exposure(0.5)] │
|
|
||||||
┌──────┘─────────┐│
|
|
||||||
↓ ↓┘
|
|
||||||
[Recorder] [SimBroker(0.0001)]
|
|
||||||
┌─────┘
|
|
||||||
↓
|
|
||||||
[Recorder]
|
|
||||||
|
|
||||||
|
|
||||||
"#;
|
|
||||||
assert_eq!(out, expected, "compiled render drifted; re-capture if intended");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_macd_compiles_from_nested_composite_and_is_deterministic() {
|
fn run_macd_compiles_from_nested_composite_and_is_deterministic() {
|
||||||
// the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a
|
// the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a
|
||||||
@@ -616,124 +396,6 @@ sma_cross(fast:i64, slow:i64) -> (cross):
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn macd_blueprint_renders_a_nested_composite_definition() {
|
|
||||||
// the MACD blueprint view shows the composite opaque in the main graph and
|
|
||||||
// defines its EMA-of-EMA interior once under `where:`.
|
|
||||||
let out = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain);
|
|
||||||
assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
|
|
||||||
assert_eq!(out.matches("macd(").count(), 1, "macd defined once:\n{out}");
|
|
||||||
assert!(
|
|
||||||
out.contains("macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)"),
|
|
||||||
"typed signature line: {out}"
|
|
||||||
);
|
|
||||||
assert!(out.contains("[EMA(fast)]"), "fast EMA folds its param: {out}");
|
|
||||||
assert!(out.contains("[EMA(slow)]"), "slow EMA folds its param: {out}");
|
|
||||||
assert!(out.contains("[macd := Sub(#Ef,#Es)]"), "macd line Sub bound as output `macd`: {out}");
|
|
||||||
assert!(out.contains("[signal := EMA(signal)]"), "signal EMA bound as output `signal` (name/param pun is intended): {out}");
|
|
||||||
assert!(out.contains("[histogram := Sub(#S,#Es)]"), "histogram Sub bound as output `histogram`: {out}");
|
|
||||||
// the root SimBroker is a top-level multi-input fan-in: its two slots now
|
|
||||||
// render as #… stubs, mirroring the where: interior (#49).
|
|
||||||
assert!(out.contains("[SimBroker(#E,#price)]"), "root SimBroker shows its two slot stubs: {out}");
|
|
||||||
// output re-exports are folded onto their producers — no standalone stubs.
|
|
||||||
// `[macd]` is NOT a valid negative here: it still appears as the opaque
|
|
||||||
// composite node in the MAIN graph. Discriminate on signal/histogram.
|
|
||||||
assert!(!out.contains("[signal]"), "no standalone signal output stub: {out}");
|
|
||||||
assert!(!out.contains("[histogram]"), "no standalone histogram output stub: {out}");
|
|
||||||
assert!(out.contains("[price]"), "named MACD input role: {out}");
|
|
||||||
assert!(!out.contains("[param:"), "param marker nodes removed: {out}");
|
|
||||||
assert!(!out.contains("[out:"), "output prefix dropped: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fan_in_identifiers_are_source_derived_and_scoped_per_node_call() {
|
|
||||||
// role passthrough: a Sub fed by role `price` + an EMA(slow) ->
|
|
||||||
// #price (role name) and #Es
|
|
||||||
let c = Composite::new(
|
|
||||||
"roles",
|
|
||||||
vec![Ema::builder().into(), Sub::builder().into()],
|
|
||||||
vec![Edge { from: 0, to: 1, slot: 1, from_field: 0 }],
|
|
||||||
vec![Role { name: "price".into(), targets: vec![Target { node: 1, slot: 0 }], source: None }],
|
|
||||||
vec![ParamAlias { name: "slow".into(), node: 0, slot: 0 }],
|
|
||||||
vec![OutField { node: 1, field: 0, name: "o".into() }],
|
|
||||||
);
|
|
||||||
let bp = Composite::new(
|
|
||||||
"root",
|
|
||||||
vec![BlueprintNode::Composite(c)],
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
vec![], // params
|
|
||||||
vec![], // output
|
|
||||||
);
|
|
||||||
let out = graph::render_blueprint(&bp, graph::Color::Plain);
|
|
||||||
assert!(out.contains("[o := Sub(#price,#Es)]"), "role name verbatim + source-derived, bound as output `o`: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fan_in_identifiers_descend_into_bare_combinators() {
|
|
||||||
// Sub( Sub(EMA fast, EMA slow), Sub(EMA up, EMA down) ): the two inner Subs
|
|
||||||
// are param-less but have distinct recursive signatures (SEf… vs SEu…), so
|
|
||||||
// the outer Sub descends just far enough -> [Sub(#SEf,#SEu)].
|
|
||||||
let c = Composite::new(
|
|
||||||
"nest",
|
|
||||||
vec![
|
|
||||||
Ema::builder().into(), // 0 fast
|
|
||||||
Ema::builder().into(), // 1 slow
|
|
||||||
Ema::builder().into(), // 2 up
|
|
||||||
Ema::builder().into(), // 3 down
|
|
||||||
Sub::builder().into(), // 4 = Sub(0,1)
|
|
||||||
Sub::builder().into(), // 5 = Sub(2,3)
|
|
||||||
Sub::builder().into(), // 6 = Sub(4,5) (the outer fan-in)
|
|
||||||
],
|
|
||||||
vec![
|
|
||||||
Edge { from: 0, to: 4, slot: 0, from_field: 0 },
|
|
||||||
Edge { from: 1, to: 4, slot: 1, from_field: 0 },
|
|
||||||
Edge { from: 2, to: 5, slot: 0, from_field: 0 },
|
|
||||||
Edge { from: 3, to: 5, slot: 1, from_field: 0 },
|
|
||||||
Edge { from: 4, to: 6, slot: 0, from_field: 0 },
|
|
||||||
Edge { from: 5, to: 6, slot: 1, from_field: 0 },
|
|
||||||
],
|
|
||||||
vec![Role {
|
|
||||||
name: "price".into(),
|
|
||||||
targets: vec![
|
|
||||||
Target { node: 0, slot: 0 },
|
|
||||||
Target { node: 1, slot: 0 },
|
|
||||||
Target { node: 2, slot: 0 },
|
|
||||||
Target { node: 3, slot: 0 },
|
|
||||||
], source: None, }],
|
|
||||||
vec![
|
|
||||||
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
|
|
||||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
|
|
||||||
ParamAlias { name: "up".into(), node: 2, slot: 0 },
|
|
||||||
ParamAlias { name: "down".into(), node: 3, slot: 0 },
|
|
||||||
],
|
|
||||||
vec![OutField { node: 6, field: 0, name: "o".into() }],
|
|
||||||
);
|
|
||||||
let bp = Composite::new(
|
|
||||||
"root",
|
|
||||||
vec![BlueprintNode::Composite(c)],
|
|
||||||
vec![],
|
|
||||||
vec![],
|
|
||||||
vec![], // params
|
|
||||||
vec![], // output
|
|
||||||
);
|
|
||||||
let out = graph::render_blueprint(&bp, graph::Color::Plain);
|
|
||||||
assert!(out.contains("[o := Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs, bound as output `o`: {out}");
|
|
||||||
assert!(out.contains("[Sub(#Ef,#Es)]"), "inner Sub uses EMA aliases: {out}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ansi_colour_emits_escapes_only_when_requested() {
|
|
||||||
// `Color::Ansi` adds per-edge ANSI escapes for an interactive terminal;
|
|
||||||
// `Color::Plain` is byte-clean (the golden / redirected-to-file path). The
|
|
||||||
// colour is orthogonal to content — node-label text is identical either way.
|
|
||||||
let plain = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain);
|
|
||||||
let coloured = graph::render_blueprint(&macd_blueprint(), graph::Color::Ansi);
|
|
||||||
assert!(!plain.contains('\x1b'), "plain render must carry no escape codes:\n{plain}");
|
|
||||||
assert!(coloured.contains('\x1b'), "ansi render must carry escape codes");
|
|
||||||
assert!(coloured.contains("[macd]"), "labels survive colouring: {coloured}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy
|
/// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy
|
||||||
/// blueprint's swept param surface relabels the three otherwise-indistinguishable
|
/// blueprint's swept param surface relabels the three otherwise-indistinguishable
|
||||||
/// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named
|
/// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! Self-contained HTML graph viewer assembly (C9: read-only render path).
|
||||||
|
//!
|
||||||
|
//! `render_html` walks the blueprint into the deterministic JSON model
|
||||||
|
//! (`aura_engine::model_to_json`) and inlines it, the vendored Graphviz-WASM, the
|
||||||
|
//! pan-zoom helper, and the ported viewer JS into one standalone `.html`. The
|
||||||
|
//! emitted page fetches nothing at view time. No `eval`/build is on this path
|
||||||
|
//! (C9); the model is hand-rolled JSON (C14, owned by `model_to_json`).
|
||||||
|
|
||||||
|
use aura_engine::Composite;
|
||||||
|
|
||||||
|
/// The prototype's `<head>` shell + body skeleton (header, stage, tooltip, error
|
||||||
|
/// pane), verbatim from `docs/design/prototypes/graph-render/index.html` lines
|
||||||
|
/// 1-38 — minus the two `<script src="./…">` tags, since `render_html` inlines
|
||||||
|
/// the assets instead.
|
||||||
|
const SHELL_HEAD: &str = r#"<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>aura graph</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #16161a; color: #cdd6f4;
|
||||||
|
font-family: ui-monospace, monospace; }
|
||||||
|
header { padding: 8px 14px; border-bottom: 1px solid #313244; font-size: 13px;
|
||||||
|
display: flex; gap: 16px; align-items: baseline; flex-wrap: nowrap;
|
||||||
|
white-space: nowrap; overflow: hidden; }
|
||||||
|
header b { color: #f5e0dc; } .sub { color: #6c7086; }
|
||||||
|
#crumb a { color: #89b4fa; cursor: pointer; text-decoration: none; }
|
||||||
|
#crumb a:hover { text-decoration: underline; }
|
||||||
|
#crumb .sep { color: #6c7086; }
|
||||||
|
#status { color: #6c7086; margin-left: auto; }
|
||||||
|
#stage { width: 100%; height: calc(100% - 44px); }
|
||||||
|
#stage svg { width: 100%; height: 100%; cursor: default; }
|
||||||
|
#stage svg text { cursor: inherit; user-select: none; -webkit-user-select: none; }
|
||||||
|
#stage svg a { cursor: inherit; }
|
||||||
|
#err { padding: 14px; color: #f38ba8; white-space: pre-wrap; }
|
||||||
|
#tip { position: fixed; display: none; pointer-events: none; z-index: 10;
|
||||||
|
background: #1e1e2e; border: 1px solid #585b70; border-radius: 6px; padding: 6px 9px;
|
||||||
|
font-family: ui-monospace, monospace; font-size: 12px; color: #cdd6f4;
|
||||||
|
max-width: 420px; box-shadow: 0 6px 18px rgba(0,0,0,.55); line-height: 1.5; }
|
||||||
|
#tip b { color: #f5e0dc; } #tip code { color: #89b4fa; } #tip .dim { color: #7f849c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<b>aura graph</b>
|
||||||
|
<span id="crumb"></span>
|
||||||
|
<span class="sub">hover · [+] expand · body drill</span>
|
||||||
|
</header>
|
||||||
|
<div id="stage"></div>
|
||||||
|
<div id="tip"></div>
|
||||||
|
<pre id="err"></pre>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const VIZ_JS: &str = include_str!("../assets/viz-standalone.js");
|
||||||
|
const PANZOOM_JS: &str = include_str!("../assets/svg-pan-zoom.min.js");
|
||||||
|
const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");
|
||||||
|
|
||||||
|
/// Assemble the self-contained interactive graph viewer page for `root`.
|
||||||
|
///
|
||||||
|
/// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`,
|
||||||
|
/// `svgPanZoom`) and the model (`window.AURA_MODEL`) must be defined before the
|
||||||
|
/// viewer script runs.
|
||||||
|
pub fn render_html(root: &Composite) -> String {
|
||||||
|
let model = aura_engine::model_to_json(root);
|
||||||
|
let mut html = String::with_capacity(
|
||||||
|
SHELL_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
|
||||||
|
);
|
||||||
|
html.push_str(SHELL_HEAD);
|
||||||
|
html.push_str("<script>");
|
||||||
|
html.push_str(VIZ_JS);
|
||||||
|
html.push_str("</script>\n<script>");
|
||||||
|
html.push_str(PANZOOM_JS);
|
||||||
|
html.push_str("</script>\n<script>window.AURA_MODEL = ");
|
||||||
|
html.push_str(&model);
|
||||||
|
html.push_str(";</script>\n<script>");
|
||||||
|
html.push_str(VIEWER_JS);
|
||||||
|
html.push_str("</script>\n</body>\n</html>\n");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::sample_blueprint;
|
||||||
|
|
||||||
|
/// `aura graph`'s page is self-contained (no remote fetch), embeds the
|
||||||
|
/// deterministic model as the viewer's data source, carries the Graphviz-WASM
|
||||||
|
/// bootstrap, and keeps the C4 four-colour palette. Structural, not pixel:
|
||||||
|
/// the DOT/SVG are Graphviz-version-dependent and are exercised by hand
|
||||||
|
/// against the prototype (spec Testing strategy).
|
||||||
|
#[test]
|
||||||
|
fn render_html_is_self_contained_and_embeds_the_model() {
|
||||||
|
let html = render_html(&sample_blueprint());
|
||||||
|
// a complete HTML document
|
||||||
|
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
|
||||||
|
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
|
||||||
|
// self-contained: every asset is inlined, no remote <script src>
|
||||||
|
assert!(!html.contains("<script src"), "assets must be inlined, found a remote script tag");
|
||||||
|
// the deterministic model is injected as the viewer's data source
|
||||||
|
assert!(html.contains("window.AURA_MODEL = {\"root\":"), "model JSON not injected");
|
||||||
|
// a known node from the sample harness round-trips via the model
|
||||||
|
assert!(html.contains("\"type\":\"Exposure\""), "sample model content missing");
|
||||||
|
// the vendored Graphviz-WASM is present (its bootstrap global)
|
||||||
|
assert!(html.contains("Viz.instance"), "viewer bootstrap (Viz.instance) missing");
|
||||||
|
// C4: the four-scalar palette, no fifth colour
|
||||||
|
assert!(
|
||||||
|
html.contains("i64: \"#f9e2af\"") && html.contains("timestamp: \"#cba6f7\""),
|
||||||
|
"C4 four-colour palette missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,25 +161,29 @@ fn help_flag_prints_usage_to_stdout_and_exits_zero() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Property: the `aura graph` blueprint view renders a fan-in node's inputs as
|
/// Property: `aura graph` emits a single self-contained HTML page — the
|
||||||
/// source-derived signature identifiers, not positional `#A`/`#B`. The sample
|
/// interactive pin-graph viewer with the deterministic model inlined and the
|
||||||
/// `sma_cross` Sub combines a fast and a slow SMA; the two inputs must therefore
|
/// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through
|
||||||
/// render as the distinct prefixes `#Sf` / `#Ss` (SMA + alias initial), so a
|
/// the built binary so it pins the observable CLI contract. The DOT/SVG are
|
||||||
/// non-commutative Sub's argument order is legible at the boundary. Driven
|
/// Graphviz-version-dependent and not asserted (the prototype is the by-hand
|
||||||
/// through the built binary (output piped → Plain, byte-clean) so it pins the
|
/// visual reference); this pins the page envelope + the retirement of the old
|
||||||
/// observable CLI contract, not just the in-process renderer.
|
/// ascii-dag notation.
|
||||||
#[test]
|
#[test]
|
||||||
fn graph_renders_source_derived_fan_in_identifiers() {
|
fn graph_emits_self_contained_html_viewer() {
|
||||||
let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph");
|
let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph");
|
||||||
assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status);
|
assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status);
|
||||||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||||
|
// a self-contained HTML document...
|
||||||
assert!(
|
assert!(
|
||||||
stdout.contains("[cross := Sub(#Sf,#Ss)]"),
|
stdout.trim_start().starts_with("<!doctype html>"),
|
||||||
"fan-in inputs source-derived (#Sf/#Ss), bound as output `cross`: {stdout}"
|
"not an HTML doc: {:?}",
|
||||||
);
|
&stdout[..stdout.len().min(80)]
|
||||||
// the retired positional form must not reappear.
|
|
||||||
assert!(
|
|
||||||
!stdout.contains("[Sub(#A,#B)]"),
|
|
||||||
"positional #A/#B fan-in stubs must be gone: {stdout}"
|
|
||||||
);
|
);
|
||||||
|
// ...with the deterministic model inlined as the viewer's data source...
|
||||||
|
assert!(stdout.contains("window.AURA_MODEL = {\"root\":"), "model not inlined: {stdout:?}");
|
||||||
|
// ...the Graphviz-WASM bootstrap present, and no remote asset fetch.
|
||||||
|
assert!(stdout.contains("Viz.instance"), "viewer bootstrap missing");
|
||||||
|
assert!(!stdout.contains("<script src"), "assets must be inlined, found remote <script src>");
|
||||||
|
// the retired ascii-dag invented notation is gone.
|
||||||
|
assert!(!stdout.contains("Sub(#Sf,#Ss)"), "retired ascii-dag notation must be gone: {stdout}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,8 +151,8 @@ pub trait Node {
|
|||||||
/// tracing / graph rendering (#13), never read by the run loop and never
|
/// tracing / graph rendering (#13), never read by the run loop and never
|
||||||
/// part of wiring (which is by index). Overrides SHOULD carry the node's
|
/// part of wiring (which is by index). Overrides SHOULD carry the node's
|
||||||
/// identifying params so identical node types disambiguate (`SMA(2)` vs
|
/// identifying params so identical node types disambiguate (`SMA(2)` vs
|
||||||
/// `SMA(4)`). MUST be single-line (no `\n`): ascii-dag breaks box drawing on
|
/// `SMA(4)`). MUST be single-line (no `\n`): the graph render draws each
|
||||||
/// a multiline label. The default is a placeholder; every shipped node
|
/// label in one cell. The default is a placeholder; every shipped node
|
||||||
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
|
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
|
||||||
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
|
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
|
||||||
fn label(&self) -> String {
|
fn label(&self) -> String {
|
||||||
@@ -195,8 +195,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn primitive_builder_label_is_the_bare_type() {
|
fn primitive_builder_label_is_the_bare_type() {
|
||||||
// param-generic: the label is just the node type, no knob suffix (the
|
// param-generic: the label is just the node type, no knob suffix — the
|
||||||
// ascii-dag blueprint view cannot render wide cluster-sibling labels).
|
// blueprint view is param-generic (C23), values appear only post-compile.
|
||||||
let with = PrimitiveBuilder::new(
|
let with = PrimitiveBuilder::new(
|
||||||
"SMA",
|
"SMA",
|
||||||
NodeSchema {
|
NodeSchema {
|
||||||
|
|||||||
@@ -693,6 +693,25 @@ from the carried signatures, buffer depth from `lookbacks()`). The per-flat-node
|
|||||||
signature travels beside the node, so `bootstrap` reads it without a built-node
|
signature travels beside the node, so `bootstrap` reads it without a built-node
|
||||||
`schema()` call (which no longer exists, see the C8 0024 realization).
|
`schema()` call (which no longer exists, see the C8 0024 realization).
|
||||||
|
|
||||||
|
**Realization (cycle 0026 — graph render redesign: model + WASM-Graphviz viewer,
|
||||||
|
#51).** `aura graph` no longer renders ASCII. The render path is now two pieces:
|
||||||
|
a read-only **model serializer** (`aura_engine::model_to_json`, iteration 1) that
|
||||||
|
walks the root composite + every distinct composite type into a deterministic,
|
||||||
|
hand-rolled JSON model (C14, golden-tested like `RunReport::to_json`; the
|
||||||
|
swapped-param mis-wire property moved here from the old compiled-view test), and a
|
||||||
|
**self-contained HTML viewer** (`aura-cli::render::render_html`, iteration 2) that
|
||||||
|
inlines that model, the ported prototype viewer JS, and a vendored Graphviz-WASM
|
||||||
|
blob into one page emitted to stdout. Layout/SVG happen in the browser via
|
||||||
|
WebAssembly — aura ships no layout engine and stays a serializer (C9: graph-as-data,
|
||||||
|
no `eval`/build on the path). The viewer is a render asset (C10 — no node/strategy
|
||||||
|
logic, no DSL); it labels every input pin from the model's now-real names (C23
|
||||||
|
debug symbols, named in cycle 0027) and colours wires by the four scalar base
|
||||||
|
types (C4). This **retires `ascii-dag`** and its adapter (`graph.rs`), the
|
||||||
|
`--compiled`/`--macd` flag plumbing, and the invented `#Sf`/`:=`/`histogram →`
|
||||||
|
notation — superseding the cycle-0017 flat-ascii model and its renderer-defect
|
||||||
|
workaround. The DOT/SVG are Graphviz-version-dependent and not golden-tested; the
|
||||||
|
deterministic JSON model is the asserted contract.
|
||||||
|
|
||||||
### C20 — Strategy ↔ harness; the harness is the root sim graph
|
### C20 — Strategy ↔ harness; the harness is the root sim graph
|
||||||
**Guarantee.** A **strategy** is a reusable composite-node blueprint (C9):
|
**Guarantee.** A **strategy** is a reusable composite-node blueprint (C9):
|
||||||
broker-, data-, and viz-independent, with inputs declared as named **roles**
|
broker-, data-, and viz-independent, with inputs declared as named **roles**
|
||||||
|
|||||||
Reference in New Issue
Block a user