Files
Aura/docs/plans/0026-graph-render-viewer.md
T
Brummel c3109bdfdd plan: 0026 graph render viewer (iteration 2)
Iteration-2 plan for cycle 0026 (graph render redesign): port the prototype
viewer, vendor Graphviz-WASM (checked in, for hermetic offline builds), add a
read-only render_html, wire `aura graph` to emit the self-contained HTML, and
retire ascii-dag (adapter, dep, --compiled/--macd flags, 12 ascii-dag tests).
Plus a stale-prose tidy in aura-core and the cycle-0026 ledger note.

refs #51
2026-06-10 16:49:19 +02:00

848 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Graph Render Viewer (cycle 0026, iteration 2) — Implementation Plan
> **Parent spec:** `docs/specs/0026-graph-render-redesign.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Replace `aura graph`'s ascii-dag output with a single self-contained
HTML page — the ported prototype viewer (pin-graph via Graphviz-WASM) driven by
the deterministic model serializer that shipped in iteration 1 — and retire
`ascii-dag` from the workspace. Closes Gitea #51.
**Architecture:** `aura graph` calls a new read-only `render_html(&Composite)`
(crate `aura-cli`, module `render`) that concatenates the prototype's `<head>`
shell, the inlined vendored Graphviz-WASM + pan-zoom blobs, the inlined model
JSON (`aura_engine::model_to_json`), and the inlined ported viewer JS into one
`.html` on stdout. The viewer JS is the prototype's `genDot`/chrome **verbatim**,
prefixed with a `normalizeModel` adapter that maps aura's real model tuple shape
(post-0027 named inputs: `ins = [kind, firing, name]`, index node keys, `comp`
references, `src_<role>` sources) into the viewer's native shape. The old
ascii-dag adapter (`graph.rs`), its dependency, the `--compiled`/`--macd` flag
plumbing, and all 12 ascii-dag-asserting tests are deleted in one
compile-coherent task. No `eval`/build on the render path (C9); no `serde` (C14);
the four-colour palette stays the four scalar base types (C4); the viewer carries
no authoring logic (C10).
**Tech Stack:** `aura-cli` (`render.rs`, `main.rs`, `Cargo.toml`, `assets/`,
`tests/cli_run.rs`), `aura-engine::model_to_json` (consumed, unchanged),
`aura-core::node.rs` (stale-prose tidy), `docs/design/INDEX.md` (ledger note).
Vendored JS/WASM assets are **checked in** (see Task 1 rationale).
---
## Files this plan creates or modifies
- Create: `crates/aura-cli/assets/graph-viewer.js` — ported viewer (normalizeModel + verbatim genDot/chrome)
- Create: `crates/aura-cli/assets/viz-standalone.js` — vendored `@viz-js/viz@3.7.0` (Graphviz-WASM, ~1.38 MB, checked in)
- Create: `crates/aura-cli/assets/svg-pan-zoom.min.js` — vendored `svg-pan-zoom@3.6.1` (~30 KB, checked in)
- Create: `crates/aura-cli/assets/refresh-assets.sh` — pinned-version refresh script (provenance; not run at build time)
- Create: `crates/aura-cli/src/render.rs``render_html(&Composite) -> String` + smoke test
- Modify: `crates/aura-cli/src/main.rs``mod render;`; flip `["graph"]` arm to `render_html`; delete `--compiled`/`--macd` graph arms, `render_compiled`, the `color` plumbing, `USAGE`; delete 12 ascii-dag tests + `swapped_point`/`sample_point` helpers; drop `mod graph;`
- Modify: `crates/aura-cli/Cargo.toml:16` — remove `ascii-dag = "0.9.1"`
- Delete: `crates/aura-cli/src/graph.rs` — the 504-line ascii-dag adapter (incl. the stale #43 comment at :324)
- Modify: `crates/aura-cli/tests/cli_run.rs:164-185` — replace the ascii-dag fan-in test with the HTML-contract test
- Modify: `crates/aura-core/src/node.rs:150-157,196-199` — re-justify two stale ascii-dag prose references
- Modify: `docs/design/INDEX.md:694` — add the cycle-0026 render-redesign realization note
---
## Task 1: Vendor the assets + port the viewer JS
**Vendoring decision (orchestrator call, deferred by the spec):** the blobs are
**checked into the repo**, not git-ignored + fetched at build time. Substance:
(a) `render_html` inlines the assets via `include_str!`, which requires them
present at **compile time** — a build-time `unpkg` fetch makes the build
non-hermetic and non-offline; (b) C8 freezes deploy artifacts and C1 demands
determinism — a build that pulls a remote URL drifts with the registry and the
network; (c) the shipped `.html` must be byte-deterministic for a given commit.
A checked-in blob is the only shape satisfying all three. `refresh-assets.sh`
records the pinned provenance for a future deliberate refresh; it is **not** part
of the build. The ~1.4 MB cost is the price of a hermetic, frozen render asset.
The validated blobs already exist on disk in the prototype dir — copy them (no
network fetch in this task).
**Files:**
- Create: `crates/aura-cli/assets/viz-standalone.js`
- Create: `crates/aura-cli/assets/svg-pan-zoom.min.js`
- Create: `crates/aura-cli/assets/refresh-assets.sh`
- Create: `crates/aura-cli/assets/graph-viewer.js`
- [ ] **Step 1: Create the assets dir and copy the two vendored blobs from the prototype**
Run:
```sh
mkdir -p crates/aura-cli/assets
cp docs/design/prototypes/graph-render/viz-standalone.js crates/aura-cli/assets/viz-standalone.js
cp docs/design/prototypes/graph-render/svg-pan-zoom.min.js crates/aura-cli/assets/svg-pan-zoom.min.js
wc -c crates/aura-cli/assets/viz-standalone.js crates/aura-cli/assets/svg-pan-zoom.min.js
```
Expected: `viz-standalone.js``1379750` bytes, `svg-pan-zoom.min.js``29768` bytes (both non-empty, copied).
If the prototype blobs are absent (a clean checkout that never ran the
prototype's `fetch-deps.sh`), fetch them instead with the pinned URLs from Step 3
below, then re-run the `wc -c` check.
- [ ] **Step 2: Verify the assets are not git-ignored (they are checked in)**
Run:
```sh
git check-ignore crates/aura-cli/assets/viz-standalone.js crates/aura-cli/assets/svg-pan-zoom.min.js; echo "exit=$?"
```
Expected: `exit=1` (no path printed) — neither file is ignored, so both will be
committed. If any path is printed, a parent `.gitignore` rule is catching them;
do **not** add a crate-level `.gitignore` for these (unlike the prototype, the
engine checks them in).
- [ ] **Step 3: Create the provenance refresh script**
Create `crates/aura-cli/assets/refresh-assets.sh`:
```sh
#!/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)"
```
Run:
```sh
chmod +x crates/aura-cli/assets/refresh-assets.sh
head -1 crates/aura-cli/assets/refresh-assets.sh
```
Expected: `#!/usr/bin/env bash`.
- [ ] **Step 4: Create the ported viewer `crates/aura-cli/assets/graph-viewer.js`**
This is the prototype's `index.html` script (lines 43-256) **verbatim**, with the
hardcoded model (prototype lines 52-100, the `F`/`I`/`P`/`C`/`COMP`/`ROOT`
constants) replaced by `normalizeModel(window.AURA_MODEL)`. `genDot`, `cellLabel`,
`addInfo`, the tooltip/interaction chrome, and `chrome()`/`show()` are unchanged.
Create `crates/aura-cli/assets/graph-viewer.js`:
```js
// 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); });
```
- [ ] **Step 5: Sanity-check the viewer asset for the load-bearing markers**
Run:
```sh
grep -c "Viz.instance" crates/aura-cli/assets/graph-viewer.js
grep -c "normalizeModel(window.AURA_MODEL)" crates/aura-cli/assets/graph-viewer.js
grep -F 'i64: "#f9e2af"' crates/aura-cli/assets/graph-viewer.js | head -1
```
Expected: first two print `1`; the third prints the palette line (C4 four-colour
table present). These substrings are asserted by the Task 2 / Task 3 tests, so
they must be present contiguously.
---
## Task 2: `render_html` module + smoke test (coexists with graph.rs)
`render.rs` is a new module that compiles **alongside** the still-present
`graph.rs` — no retirement yet, so the workspace stays green. `render_html` is
read-only (C9): it calls `aura_engine::model_to_json` (a pure `&Composite ->
String` walk) and string-concatenates the inlined assets. No `eval`, no build, no
`serde`.
**Files:**
- Create: `crates/aura-cli/src/render.rs`
- Modify: `crates/aura-cli/src/main.rs:9` (add `mod render;`)
- Test: `crates/aura-cli/src/render.rs` (the `#[cfg(test)]` smoke test)
- [ ] **Step 1: Create `crates/aura-cli/src/render.rs`**
Create `crates/aura-cli/src/render.rs`:
```rust
//! 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_core::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"
);
}
}
```
- [ ] **Step 2: Declare the module**
In `crates/aura-cli/src/main.rs`, add `mod render;` immediately after the existing
`mod graph;` at line 9:
```rust
mod graph;
mod render;
```
(Both modules coexist this task; `graph` is retired in Task 3.)
- [ ] **Step 3: Build and run the smoke test**
Run:
```sh
cargo test -p aura-cli render_html_is_self_contained_and_embeds_the_model
```
Expected: PASS (`test result: ok. 1 passed`). The model golden contract is
unchanged, so `cargo build --workspace` stays green with `graph.rs` still present.
---
## Task 3: Retire ascii-dag (atomic — flip `aura graph`, delete graph.rs + dep + dead tests/helpers)
This is one compile-coherent unit: removing `graph.rs` and the `ascii-dag` dep
breaks every `graph::` reference (the production arms + 12 tests) at once, so the
"0 errors" build gate is only satisfiable when **all** of them are removed in the
same task (planner self-review item 7 — signature/removal-breaks-all-callers).
**Files:**
- Modify: `crates/aura-cli/src/main.rs` (graph arm, flags, `render_compiled`, `color`, `USAGE`, `mod graph;`, 12 tests, `swapped_point`/`sample_point` helpers)
- Modify: `crates/aura-cli/Cargo.toml:16` (remove `ascii-dag`)
- Delete: `crates/aura-cli/src/graph.rs`
- Modify: `crates/aura-cli/tests/cli_run.rs:164-185` (replace the ascii-dag fan-in test)
- [ ] **Step 1: Flip the `["graph"]` arm and delete the `--compiled`/`--macd` graph arms**
In `crates/aura-cli/src/main.rs`, the `match` block (lines 379-395) currently reads:
```rust
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint(), color)),
["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}"),
_ => {
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
```
Replace it with (only `run`/`run --macd`/`graph`/help survive; `graph` emits the
HTML viewer via `print!` since `render_html` ends with a newline):
```rust
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
["--help"] | ["-h"] => println!("{USAGE}"),
_ => {
eprintln!("aura: {USAGE}");
std::process::exit(2);
}
}
```
- [ ] **Step 2: Delete the `color` plumbing and the `render_compiled` helper**
In `crates/aura-cli/src/main.rs`, delete the `color` binding (lines 372-378):
```rust
// 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
};
```
And delete the `render_compiled` helper (lines 358-363):
```rust
/// Compile a blueprint under its point vector and render the flat post-inline
/// (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)
}
```
If `is_terminal`/`IsTerminal` was imported only for the `color` binding, its `use`
becomes unused — remove it (clippy will name it in Step 7; the import is near the
top of `main.rs`).
- [ ] **Step 3: Shrink the `USAGE` string (drop the retired graph flags)**
In `crates/aura-cli/src/main.rs:365`, replace:
```rust
const USAGE: &str = "usage: aura run [--macd] | aura graph [--compiled | --macd [--compiled]]";
```
with:
```rust
const USAGE: &str = "usage: aura run [--macd] | aura graph";
```
- [ ] **Step 4: Drop `mod graph;` and delete the file**
In `crates/aura-cli/src/main.rs:9`, remove the `mod graph;` line (leaving
`mod render;`). Then (plain `rm`, not `git rm` — the deletion stays unstaged with
the rest of the iter's work for the orchestrator to commit):
```sh
rm crates/aura-cli/src/graph.rs
```
Expected: `graph.rs` removed (this also retires the stale #43 comment at :324,
which dies with the file).
- [ ] **Step 5: Delete the 12 ascii-dag-asserting tests + the orphaned `swapped_point`/`sample_point` helpers**
In `crates/aura-cli/src/main.rs`'s `#[cfg(test)] mod tests`, delete these test
functions in full (each asserts retired ascii-dag output and references
`graph::render_blueprint` / `graph::render_compilat` / `graph::Color`):
```text
blueprint_view_main_graph_shows_composite_as_opaque_node (~409-425)
blueprint_view_defines_each_composite_once (~427-435)
nested_composite_renders_without_panic (~437-473)
reused_composite_defined_once (~475-501)
compiled_view_dissolves_the_composite_boundary (~503-512)
swapped_param_vector_changes_the_compiled_render (~514-527)
blueprint_view_golden (~529-566)
compiled_view_golden (~568-593)
macd_blueprint_renders_a_nested_composite_definition (~619-646)
fan_in_identifiers_are_source_derived_and_scoped_per_node_call (~648-670)
fan_in_identifiers_descend_into_bare_combinators (~672-723)
ansi_colour_emits_escapes_only_when_requested (~725-735)
```
**Keep** every test that does not reference `graph::`:
`run_macd_compiles_from_nested_composite_and_is_deterministic` (~595-617),
`macd_param_space_surfaces_the_three_named_aliases` (~743-759),
`run_sample_is_deterministic_and_non_trivial` (~761+).
Then delete the now-orphaned test helper `swapped_point` (~405-407, used only by
the deleted `swapped_param_vector_changes_the_compiled_render`).
Completeness check — no `graph::` reference may survive in the file:
```sh
grep -n "graph::" crates/aura-cli/src/main.rs; echo "exit=$?"
```
Expected: `exit=1` (no matches). Any surviving match is a test the deletion list
missed — remove it.
- [ ] **Step 6: Remove the `ascii-dag` dependency**
In `crates/aura-cli/Cargo.toml`, delete line 16:
```toml
ascii-dag = "0.9.1"
```
- [ ] **Step 7: Build + clippy; remove whatever dead-code/unused-import the gate names**
`sample_point()` (main.rs ~199) loses its last caller when the compiled-view
tests go (its only uses were the `--compiled` arm + the deleted tests). The
`dead_code` lint under `-D warnings` will name it and any import (e.g.
`graph`-only or `IsTerminal`-only `use`s, the fixture imports `Role`/`Target`/
`Edge`/`OutField`/`BlueprintNode`/`ParamAlias`/`Sub`/`Ema`/`Sma`/`Exposure`/
`sma_cross` used only by the deleted tests) that is now unused.
Run, then delete each item the lint names, and re-run until clean:
```sh
cargo build --workspace
cargo clippy --workspace --all-targets -- -D warnings
```
Expected (final): `cargo build` 0 errors; `clippy` 0 warnings. Remove `sample_point`
and every unused `use` clippy reports; do **not** remove `macd_blueprint`,
`macd_point`, `run_macd` (still used by the kept tests / `run --macd` arm) or
`sample_blueprint`/`build_sample` (used by the `graph` arm + `render` test).
- [ ] **Step 8: Replace the integration test that pinned the old ascii-dag output**
In `crates/aura-cli/tests/cli_run.rs`, the test
`graph_renders_source_derived_fan_in_identifiers` (lines 164-185) asserts the
retired ascii-dag notation (`[cross := Sub(#Sf,#Ss)]`) and now fails. Replace the
entire test (its doc-comment + function) with the HTML-contract test:
```rust
/// Property: `aura graph` emits a single self-contained HTML page — the
/// interactive pin-graph viewer with the deterministic model inlined and the
/// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through
/// the built binary so it pins the observable CLI contract. The DOT/SVG are
/// Graphviz-version-dependent and not asserted (the prototype is the by-hand
/// visual reference); this pins the page envelope + the retirement of the old
/// ascii-dag notation.
#[test]
fn graph_emits_self_contained_html_viewer() {
let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph");
assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
// a self-contained HTML document...
assert!(
stdout.trim_start().starts_with("<!doctype html>"),
"not an HTML doc: {:?}",
&stdout[..stdout.len().min(80)]
);
// ...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}");
}
```
- [ ] **Step 9: Full gate — build, test, lint, dep-tree, file-gone**
Run:
```sh
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo tree -p aura-cli 2>/dev/null | grep -i ascii-dag; echo "ascii-dag-grep-exit=$?"
test ! -e crates/aura-cli/src/graph.rs && echo "graph.rs gone"
```
Expected: build 0 errors; `cargo test --workspace` all green (every test that ran
passed, and the new `graph_emits_self_contained_html_viewer` +
`render_html_is_self_contained_and_embeds_the_model` are among them — confirm both
names appear in the test output, not "0 ran"); clippy 0 warnings;
`ascii-dag-grep-exit=1` (ascii-dag absent from the dep tree); `graph.rs gone`.
---
## Task 4: Tidy the two stale ascii-dag prose references in aura-core
The cycle retires ascii-dag; these two comments in `crates/aura-core/src/node.rs`
are its last prose footprint and carry now-false justifications (the second
already false since cycle 0017 dropped cluster boxes). The `label()` single-line
rule stays true (an HTML-table cell label is still one line) but is re-justified
generically; the param-generic rule is re-justified on C23, not on an ascii-dag
limitation. Methods and assertions are unchanged — prose only.
**Files:**
- Modify: `crates/aura-core/src/node.rs:154` and `:198-199`
- [ ] **Step 1: Re-justify the `Node::label()` single-line constraint (~line 150-157)**
Replace:
```rust
/// A one-line, **non-load-bearing** render label (C23): a debug symbol for
/// 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
/// identifying params so identical node types disambiguate (`SMA(2)` vs
/// `SMA(4)`). MUST be single-line (no `\n`): ascii-dag breaks box drawing on
/// a multiline label. The default is a placeholder; every shipped node
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
```
with:
```rust
/// A one-line, **non-load-bearing** render label (C23): a debug symbol for
/// 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
/// identifying params so identical node types disambiguate (`SMA(2)` vs
/// `SMA(4)`). MUST be single-line (no `\n`): the graph render draws each
/// label in one cell. The default is a placeholder; every shipped node
/// overrides it. Returns an owned `String` and takes `&self`, so `Node`
/// stays object-safe and `Box<dyn Node>::label()` dispatches.
```
- [ ] **Step 2: Re-justify the param-generic builder-label test comment (~line 196-199)**
Replace:
```rust
fn primitive_builder_label_is_the_bare_type() {
// param-generic: the label is just the node type, no knob suffix (the
// ascii-dag blueprint view cannot render wide cluster-sibling labels).
```
with:
```rust
fn primitive_builder_label_is_the_bare_type() {
// param-generic: the label is just the node type, no knob suffix — the
// blueprint view is param-generic (C23), values appear only post-compile.
```
- [ ] **Step 3: Verify no ascii-dag prose remains in the workspace source**
Run:
```sh
grep -rni "ascii-dag\|ascii_dag" crates/ ; echo "exit=$?"
```
Expected: `exit=1` (no matches) — the dep, the adapter, and the prose are all
gone. (`Cargo.lock` may still list it transitively until regenerated; the build
in Task 3 Step 9 already regenerated it — if it appears there only, that is the
lockfile and not a source reference.)
- [ ] **Step 4: Build + test aura-core to confirm the prose edits compile**
Run:
```sh
cargo test -p aura-core
```
Expected: PASS (prose-only change; all aura-core tests green).
---
## Task 5: Ledger realization note (cycle 0026)
Add one consolidated realization note for cycle 0026 (both iterations: the model
serializer + the WASM-Graphviz viewer) to the C9 section of the design ledger,
right after the cycle-0024 render-lineage note.
**Files:**
- Modify: `docs/design/INDEX.md:694` (insert after the cycle-0024 realization note, before `### C20`)
- [ ] **Step 1: Insert the realization note**
In `docs/design/INDEX.md`, after the paragraph ending at line 694 (`…the C8 0024
realization).`) and before the blank line preceding `### C20 — Strategy ↔
harness` (line 696), insert:
```markdown
**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.
```
- [ ] **Step 2: Confirm the note placed cleanly**
Run:
```sh
grep -n "Realization (cycle 0026" docs/design/INDEX.md
grep -n "### C20 — Strategy" docs/design/INDEX.md
```
Expected: the 0026 note line number is immediately above (a few lines before) the
`### C20` line — the insertion sits at the end of the C9 section, not inside C20.
---
## Iteration done-state
After Task 5, the working tree carries: the new assets + `render.rs`, the
retired `graph.rs` + dep + flags + tests, the prose tidy, and the ledger note —
all unstaged. The full gate (Task 3 Step 9) is green: `cargo build --workspace`
0 errors, `cargo test --workspace` all green (model golden unchanged; the two new
HTML tests present), `cargo clippy --workspace --all-targets -- -D warnings`
clean, `ascii-dag` absent from the dep tree. The orchestrator inspects the diff
and commits (suggested subject: `feat(aura-cli): render the graph as a
self-contained WASM-Graphviz viewer; retire ascii-dag`, body `closes #51`).