feat(engine,cli): GraphBuilder gang cadence + render carrier (#61 tasks 5-6)
Builder: NodeHandle::param -> ParamRef, GraphBuilder::gang, build() resolves
the gang table (collect-then-reject, BuildError::{UnknownParam,AmbiguousParam,
BadGang}) and terminates in Composite::with_gangs — the same predicate the
op-script and serde boundaries use. C24 lockstep extended: an op-script gang
replay serializes byte-identically to the GraphBuilder twin (FlatGraph edges +
sources + canonical JSON).
Render: model_to_json emits a per-scope gangs fragment (name, kind, [node,pos,
"param"] members) for ganged scopes only — the gang-free model stays
byte-identical (model_golden untouched). graph-viewer.js annotates a ganged
open param with its public knob name; guarded by a viewer_gang_param.{mjs,rs}
pair RED-first per the established viewer_bound_param convention (a welcome
extra beyond the plan's file list).
Ratified deviation: the model's kind string uses the serde form ("I64") — the
plan's implementation snippet (kind_str -> "i64") contradicted its own test;
resolved toward the test + the blueprint_serde form. Two quality nits held as
principled plan-holds (inline resolution block; the unreachable unwrap_or
placeholder check_gangs refuses first).
Verified: aura-engine lib 264/0 (model_golden, lockstep, builder, carrier all
green), viewer_gang_param 1/0, workspace clippy -D warnings clean. Slice
resumed across a transient API failure via workflow resume (cached prefix).
refs #61
This commit is contained in:
@@ -45,10 +45,15 @@ function normalizeModel(model) {
|
||||
outputs: c.outputs,
|
||||
nodes: adaptNodes(c.nodes),
|
||||
edges: c.edges,
|
||||
gangs: c.gangs || [], // #61: the scope's gang table, empty for a gang-free scope
|
||||
};
|
||||
}
|
||||
return {
|
||||
root: { inputs: [], outputs: [], nodes: adaptNodes(model.root.nodes), edges: model.root.edges },
|
||||
root: {
|
||||
inputs: [], outputs: [],
|
||||
nodes: adaptNodes(model.root.nodes), edges: model.root.edges,
|
||||
gangs: model.root.gangs || [],
|
||||
},
|
||||
composites,
|
||||
};
|
||||
}
|
||||
@@ -74,6 +79,10 @@ function cellLabel(id, o, showTypes) {
|
||||
const bnd = o.bound || [];
|
||||
const total = o.params.length + bnd.length;
|
||||
const byPos = new Map(bnd.map(([pos, n, k, v]) => [pos, [n, k, v]]));
|
||||
// a ganged open param renders with its public knob name so the fusion is
|
||||
// visible on the node card: "length (gang: channel_length)"
|
||||
const gangFor = (pos) =>
|
||||
(o.gangs || []).find((g) => g.members.some(([n, p]) => n === o.nodeIndex && p === pos));
|
||||
const slots = [];
|
||||
let fi = 0;
|
||||
for (let pos = 0; pos < total; pos++) {
|
||||
@@ -82,7 +91,9 @@ function cellLabel(id, o, showTypes) {
|
||||
slots.push(`<font color="#9399b2">${n}=${v}</font>`);
|
||||
} else {
|
||||
const [n, k] = o.params[fi++];
|
||||
slots.push(`<font color="${col(k)}">${showTypes && k ? `${n}:${k}` : n}</font>`);
|
||||
const gang = gangFor(pos);
|
||||
const label = txt(n, k) + (gang ? `<font color="#9399b2"> (gang: ${gang.name})</font>` : "");
|
||||
slots.push(`<font color="${col(k)}">${label}</font>`);
|
||||
}
|
||||
}
|
||||
const sig = total ? `<font color="#9399b2">[</font>${slots.join('<font color="#9399b2">, </font>')}<font color="#9399b2">]</font>` : "";
|
||||
@@ -123,7 +134,7 @@ function genDot(def, role, expandedSet, showTypes) {
|
||||
const inst = def.nodes[key], cpath = [...path, key], cid = "n" + cpath.join("__");
|
||||
if (inst.prim) {
|
||||
const s = inst.prim;
|
||||
block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, bound: s.bound, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
|
||||
block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, bound: s.bound, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false, nodeIndex: Number(key), gangs: def.gangs || [] }, 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 };
|
||||
|
||||
@@ -810,6 +810,54 @@ fn graph_build_accepts_a_gang_op() {
|
||||
assert_eq!(params, "length:I64\n", "one fused public knob, not two members");
|
||||
}
|
||||
|
||||
/// Property (#61, Task 6 wiring): a blueprint built through the op-script `gang`
|
||||
/// op and then RENDERED via `aura graph <file>` — the real CLI round trip a
|
||||
/// consumer drives, not a hand-built `Composite` fed straight to `model_to_json`
|
||||
/// — embeds the fused gang table in the page's inlined `window.AURA_MODEL`. This
|
||||
/// is the seam that actually connects the construction-layer gang op to the
|
||||
/// viewer surface: `graph_model.rs`'s `model_carries_gangs_only_for_ganged_scopes`
|
||||
/// pins the JSON fragment in isolation, and `viewer_gang_param` pins the JS
|
||||
/// annotation against a hand-written model literal, but neither proves the gang
|
||||
/// table SURVIVES the build -> file -> render pipeline a consumer actually runs.
|
||||
#[test]
|
||||
fn graph_build_gang_op_survives_render_round_trip() {
|
||||
let dir = temp_cwd("graph-render-gang-round-trip");
|
||||
let ops = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"a"},
|
||||
{"op":"add","type":"SMA","name":"b"},
|
||||
{"op":"gang","as":"length","into":["a.length","b.length"]},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["a.series","b.series"]},
|
||||
{"op":"connect","from":"a.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"b.value","to":"sub.rhs"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
let (bp, stderr, ok) = run(&["graph", "build"], ops);
|
||||
assert!(ok, "graph build: stderr: {stderr}");
|
||||
let bp_path = dir.join("bp.json");
|
||||
std::fs::write(&bp_path, &bp).expect("write built blueprint");
|
||||
|
||||
let out = std::process::Command::new(BIN)
|
||||
.arg("graph")
|
||||
.arg(&bp_path)
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura graph <blueprint>");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(0),
|
||||
"`aura graph <blueprint.json>` exit: {:?}\nstderr: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let html = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
assert!(
|
||||
html.contains(r#""gangs":[{"name":"length","kind":"I64","members":[["#),
|
||||
"rendered page does not embed the gang table: {html}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `gang` op's arity refusal (fewer than two members) reads as prose at the
|
||||
/// binary seam, attributed to its op index, never the raw `GangArity` Debug name.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Headless render guard for the `aura graph` viewer (graph-viewer.js).
|
||||
//
|
||||
// Property protected: a free param slot whose [nodeIndex, originalPos] is a
|
||||
// member of the scope's gang table renders with its public gang name appended
|
||||
// ("length (gang: channel_length)"), while an identical param slot in a
|
||||
// gang-free scope renders bare — the gang table wired into normalizeModel/
|
||||
// cellLabel (#61) actually reaches the rendered node card, not just the JSON
|
||||
// model (which the Rust-side model_carries_gangs test already pins).
|
||||
//
|
||||
// It loads the real viewer module and drives the exported pure `genDot`. `node`
|
||||
// is REQUIRED: a skipped guard is not a guard.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
global.window = { AURA_MODEL: { root: { nodes: {}, edges: [] }, composites: {} } };
|
||||
const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
|
||||
const dotFor = (model) => genDot(normalizeModel(model).root, "root", new Set(), false).dot;
|
||||
|
||||
const fail = (msg, dot) => {
|
||||
console.error(msg + "\n--- dot ---\n" + dot);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
// --- Model A: two sibling nodes, their "length" params ganged as "channel_length" ---
|
||||
const ganged = {
|
||||
root: {
|
||||
nodes: {
|
||||
"0": { prim: { name: "hi", type: "Sma", role: "node", params: [["length", "i64"]], ins: [], outs: [["value", "f64"]] } },
|
||||
"1": { prim: { name: "lo", type: "Sma", role: "node", params: [["length", "i64"]], ins: [], outs: [["value", "f64"]] } },
|
||||
},
|
||||
edges: [],
|
||||
gangs: [{ name: "channel_length", kind: "I64", members: [[0, 0, "length"], [1, 0, "length"]] }],
|
||||
},
|
||||
composites: {},
|
||||
};
|
||||
const dotGanged = dotFor(ganged);
|
||||
|
||||
const GANG_TAG = '<font color="#9399b2"> (gang: channel_length)</font>';
|
||||
if (!dotGanged.includes(GANG_TAG)) fail("ganged param slot missing the gang-name annotation", dotGanged);
|
||||
const occurrences = dotGanged.split(GANG_TAG).length - 1;
|
||||
if (occurrences !== 2) fail(`expected the gang tag on BOTH member nodes (2 occurrences), got ${occurrences}`, dotGanged);
|
||||
|
||||
// --- Model B: identical shape, no gangs — must render bare, no annotation ---
|
||||
const bare = {
|
||||
root: {
|
||||
nodes: {
|
||||
"0": { prim: { name: "hi", type: "Sma", role: "node", params: [["length", "i64"]], ins: [], outs: [["value", "f64"]] } },
|
||||
"1": { prim: { name: "lo", type: "Sma", role: "node", params: [["length", "i64"]], ins: [], outs: [["value", "f64"]] } },
|
||||
},
|
||||
edges: [],
|
||||
},
|
||||
composites: {},
|
||||
};
|
||||
const dotBare = dotFor(bare);
|
||||
if (dotBare.includes("(gang:")) fail("gang-free scope must not render any gang annotation", dotBare);
|
||||
|
||||
console.log("OK — a ganged param slot renders its public gang name; a gang-free scope renders bare.");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Integration guard: the `aura graph` viewer decorates a ganged param slot
|
||||
//! with its public gang name, and leaves a gang-free scope's slots bare.
|
||||
//!
|
||||
//! The property lives in JavaScript (`cellLabel`'s `gangFor` lookup in
|
||||
//! assets/graph-viewer.js), so this shells out to `node` running the headless
|
||||
//! guard `tests/viewer_gang_param.mjs`, which loads the real viewer module and
|
||||
//! drives the exported `genDot` over two minimal models (one ganged, one not).
|
||||
//!
|
||||
//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is
|
||||
//! not a guard).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn viewer_annotates_a_ganged_param_slot() {
|
||||
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("viewer_gang_param.mjs");
|
||||
assert!(script.exists(), "guard script missing at {}", script.display());
|
||||
|
||||
let out = match Command::new("node").arg(&script).output() {
|
||||
Ok(out) => out,
|
||||
Err(e) => panic!(
|
||||
"node is required for the viewer gang-param guard but could not be run \
|
||||
({e}); install Node.js or ensure `node` is on PATH"
|
||||
),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"viewer gang-param guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
|
||||
out.status.code()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user