From bc88e182474d3c1f1396c7386be2f45f908e91d2 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 10 Jul 2026 13:03:10 +0200 Subject: [PATCH] feat(engine,cli): GraphBuilder gang cadence + render carrier (#61 tasks 5-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/assets/graph-viewer.js | 17 ++- crates/aura-cli/tests/graph_construct.rs | 48 +++++++ crates/aura-cli/tests/viewer_gang_param.mjs | 63 +++++++++ crates/aura-cli/tests/viewer_gang_param.rs | 37 +++++ crates/aura-engine/src/builder.rs | 144 +++++++++++++++++++- crates/aura-engine/src/construction.rs | 51 +++++++ crates/aura-engine/src/graph_model.rs | 79 ++++++++++- 7 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 crates/aura-cli/tests/viewer_gang_param.mjs create mode 100644 crates/aura-cli/tests/viewer_gang_param.rs diff --git a/crates/aura-cli/assets/graph-viewer.js b/crates/aura-cli/assets/graph-viewer.js index f5f93fe..a812f9d 100644 --- a/crates/aura-cli/assets/graph-viewer.js +++ b/crates/aura-cli/assets/graph-viewer.js @@ -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(`${n}=${v}`); } else { const [n, k] = o.params[fi++]; - slots.push(`${showTypes && k ? `${n}:${k}` : n}`); + const gang = gangFor(pos); + const label = txt(n, k) + (gang ? ` (gang: ${gang.name})` : ""); + slots.push(`${label}`); } } const sig = total ? `[${slots.join(', ')}]` : ""; @@ -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 }; diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 1fc50b5..7930847 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -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 ` — 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 "); + assert_eq!( + out.status.code(), + Some(0), + "`aura graph ` 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] diff --git a/crates/aura-cli/tests/viewer_gang_param.mjs b/crates/aura-cli/tests/viewer_gang_param.mjs new file mode 100644 index 0000000..0451aa8 --- /dev/null +++ b/crates/aura-cli/tests/viewer_gang_param.mjs @@ -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 = ' (gang: channel_length)'; +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); diff --git a/crates/aura-cli/tests/viewer_gang_param.rs b/crates/aura-cli/tests/viewer_gang_param.rs new file mode 100644 index 0000000..d6b057d --- /dev/null +++ b/crates/aura-cli/tests/viewer_gang_param.rs @@ -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() + ); +} diff --git a/crates/aura-engine/src/builder.rs b/crates/aura-engine/src/builder.rs index 1a76b4a..01ccea3 100644 --- a/crates/aura-engine/src/builder.rs +++ b/crates/aura-engine/src/builder.rs @@ -10,7 +10,7 @@ use aura_core::{NodeSchema, ScalarKind}; -use crate::blueprint::{BlueprintNode, Composite, OutField, Role}; +use crate::blueprint::{BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role}; use crate::harness::{Edge, Target}; /// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the @@ -36,6 +36,14 @@ pub struct OutPort { name: &'static str, } +/// An unresolved param endpoint: a node handle's index plus a param name +/// (resolved to a structural `GangMember` at [`GraphBuilder::build`]). +#[derive(Clone, Copy, Debug)] +pub struct ParamRef { + node: usize, + name: &'static str, +} + impl NodeHandle { /// Address an input port of this node by name (resolved at [`GraphBuilder::build`]). pub fn input(self, name: &'static str) -> InPort { @@ -45,6 +53,10 @@ impl NodeHandle { pub fn output(self, name: &'static str) -> OutPort { OutPort { node: self.0, name } } + /// Address an open param of this node by name (resolved at [`GraphBuilder::build`]). + pub fn param(self, name: &'static str) -> ParamRef { + ParamRef { node: self.0, name } + } } /// An authoring-layer fault surfaced at [`GraphBuilder::build`]. Resolution by @@ -62,6 +74,12 @@ pub enum BuildError { UnknownOutPort { node: usize, name: String }, /// More than one output field of `node` is named `name`. AmbiguousOutPort { node: usize, name: String }, + /// No open param of `node` is named `name` (gang member resolution). + UnknownParam { node: usize, name: String }, + /// More than one open param of `node` is named `name`. + AmbiguousParam { node: usize, name: String }, + /// The assembled gang table failed the structural gate. + BadGang(GangFault), } /// A fluent, additive accumulator that authors a [`Composite`] by typed handles @@ -73,6 +91,7 @@ pub struct GraphBuilder { edges: Vec<(OutPort, InPort)>, roles: Vec<(String, Option, Vec)>, out: Vec<(String, OutPort)>, + gangs: Vec<(String, Vec)>, } impl GraphBuilder { @@ -85,6 +104,7 @@ impl GraphBuilder { edges: Vec::new(), roles: Vec::new(), out: Vec::new(), + gangs: Vec::new(), } } @@ -127,6 +147,11 @@ impl GraphBuilder { self.out.push((name.to_string(), from)); } + /// Fuse two or more sibling params into one public knob (resolved at build). + pub fn gang(&mut self, name: &str, members: impl IntoIterator) { + self.gangs.push((name.to_string(), members.into_iter().collect())); + } + /// Resolve every accumulated name to an index and assemble the [`Composite`]. pub fn build(self) -> Result { let mut edges = Vec::with_capacity(self.edges.len()); @@ -152,7 +177,57 @@ impl GraphBuilder { output.push(OutField { node: from.node, field, name: name.clone() }); } - Ok(Composite::new(self.name, self.nodes, edges, roles, output)) + let mut gangs = Vec::with_capacity(self.gangs.len()); + for (name, refs) in &self.gangs { + let mut members = Vec::with_capacity(refs.len()); + let mut kind = None; + for r in refs { + let node = self.nodes.get(r.node).ok_or(BuildError::BadHandle { node: r.node })?; + let BlueprintNode::Primitive(b) = node else { + // A composite node is a legitimate handle (composites are + // addable, `impl From for BlueprintNode`) — not a + // bad handle. Push a placeholder member and let the shared + // structural gate (`check_gangs`, below) report the true + // fault (`GangFault::NotAPrimitive`) instead of this loop + // pre-empting it with a misleading `BadHandle`. + members.push(GangMember { node: r.node, pos: 0, name: r.name.to_string() }); + continue; + }; + let hits: Vec = b + .params() + .iter() + .enumerate() + .filter(|(_, p)| p.name == r.name) + .map(|(i, _)| i) + .collect(); + let idx = match hits.as_slice() { + [i] => *i, + [] => { + return Err(BuildError::UnknownParam { node: r.node, name: r.name.to_string() }) + } + _ => { + return Err(BuildError::AmbiguousParam { node: r.node, name: r.name.to_string() }) + } + }; + kind.get_or_insert(b.params()[idx].kind); + members.push(GangMember { node: r.node, pos: b.original_pos(idx), name: r.name.to_string() }); + } + // `kind` is `None` only when every member above was a composite + // handle (each such member `continue`s without ever reaching + // `kind.get_or_insert`) or `refs` was empty — and in both cases + // `check_gangs` below rejects the gang (`NotAPrimitive` on the + // first non-primitive member, `TooFewMembers` on <2 members) + // before `Gang::kind` is ever read. The fallback value is + // therefore unobservable; it exists only to satisfy the type. + let kind = kind.unwrap_or(ScalarKind::F64); + gangs.push(Gang { name: name.clone(), kind, members }); + } + let composite = Composite::new(self.name, self.nodes, edges, roles, output); + match composite.with_gangs(gangs) { + Ok(c) => Ok(c), + Err(CompileError::BadGang(f)) => Err(BuildError::BadGang(f)), + Err(_) => unreachable!("with_gangs emits only BadGang"), + } } /// Resolve an `InPort` to `(node-index, slot)` by exactly-one-match on the @@ -430,6 +505,71 @@ mod tests { ); } + /// A gang member whose node is a composite (not a primitive) is reported as + /// the true structural fault `GangFault::NotAPrimitive`, not the unrelated + /// `BadHandle` (composites are addable via `add`, so this path is reachable, + /// not merely a defensive stub). + #[test] + fn gang_member_on_composite_node_reports_not_a_primitive() { + use crate::GangFault; + let mut g = GraphBuilder::new("g"); + let comp = g.add(built_sma_cross()); + let a = g.add(Sma::builder().named("a")); + g.gang("length", [comp.param("length"), a.param("length")]); + assert!( + matches!( + g.build().err(), + Some(BuildError::BadGang(GangFault::NotAPrimitive { node: 0, .. })) + ), + "expected NotAPrimitive for the composite member" + ); + } + + /// An unknown param name in a gang member surfaces as `UnknownParam` (the + /// same collect-then-reject posture as port resolution), not silently + /// ignored or mis-attributed to the wrong node. + #[test] + fn unknown_param_in_gang_is_reported_at_build() { + let mut g = GraphBuilder::new("g"); + let a = g.add(Sma::builder().named("a")); + let b = g.add(Sma::builder().named("b")); + g.gang("length", [a.param("length"), b.param("nope")]); + assert_eq!( + g.build().err(), + Some(BuildError::UnknownParam { node: 1, name: "nope".into() }) + ); + } + + /// A structurally invalid gang table (here: a single-member gang) surfaces + /// through the `CompileError` -> `BuildError` translation as `BadGang`, + /// proving the `with_gangs` structural gate is actually wired into the + /// builder's `build()`, not just the happy path. + #[test] + fn single_member_gang_reports_bad_gang() { + use crate::GangFault; + let mut g = GraphBuilder::new("g"); + let a = g.add(Sma::builder().named("a")); + g.gang("length", [a.param("length")]); + assert!(matches!( + g.build().err(), + Some(BuildError::BadGang(GangFault::TooFewMembers { .. })) + )); + } + + /// The `gang` method fuses two handles' params into one public knob — the + /// projected param space carries the gang's name once, not two node-qualified + /// entries. + #[test] + fn builder_gang_projects_the_param_space() { + let mut g = GraphBuilder::new("g"); + let a = g.add(Sma::builder().named("a")); + let b = g.add(Sma::builder().named("b")); + g.gang("length", [a.param("length"), b.param("length")]); + let built = g.build().expect("gang resolves"); + let names: Vec = built.param_space().into_iter().map(|p| p.name).collect(); + assert_eq!(names, ["length"]); + } + #[test] fn simbroker_legs_resolve_by_name_and_a_typo_is_caught() { // exposure/price (both f64, slot 0/1) addressed by NAME — the #21 legibility win diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index fd65bce..44a07db 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -893,4 +893,55 @@ mod tests { assert_eq!(replay_flat.edges, builder_flat.edges); assert_eq!(replay_flat.sources, builder_flat.sources); } + + /// C24 lockstep extended to gangs: the op-script's `Op::Gang` replay and the + /// `GraphBuilder::gang` twin serialize to byte-identical blueprint JSON and + /// compile to the byte-identical `FlatGraph` topology — the gang op is not a + /// second construction semantics, just the by-identifier face of the same + /// gate `GraphBuilder::build` runs (builder.rs). + #[test] + fn gang_replay_serializes_identically_to_builder() { + use crate::blueprint_serde::blueprint_to_json; + use crate::GraphBuilder; + use aura_core::Scalar; + use aura_std::{Sma, Sub}; + + // (a) op-script: the Task-4 fixture (two SMA + gang + Sub + expose). + let ops = vec![ + Op::Source { role: "price".into(), kind: ScalarKind::F64 }, + Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }, + Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] }, + Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] }, + Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] }, + Op::Feed { role: "price".into(), into: vec!["a.series".into(), "b.series".into()] }, + Op::Connect { from: "a.value".into(), to: "sub.lhs".into() }, + Op::Connect { from: "b.value".into(), to: "sub.rhs".into() }, + Op::Expose { from: "sub.value".into(), as_name: "bias".into() }, + ]; + let replayed = super::replay("g", ops, &std_vocabulary).expect("replays"); + + // (b) the GraphBuilder twin — same adds/feeds/connects/expose + g.gang(...). + let mut g = GraphBuilder::new("g"); + let a = g.add(Sma::builder().named("a")); + let b = g.add(Sma::builder().named("b")); + g.gang("length", [a.param("length"), b.param("length")]); + let sub = g.add(Sub::builder().named("sub")); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [a.input("series"), b.input("series")]); + g.connect(a.output("value"), sub.input("lhs")); + g.connect(b.output("value"), sub.input("rhs")); + g.expose(sub.output("value"), "bias"); + let built = g.build().expect("root resolves"); + + assert_eq!( + blueprint_to_json(&replayed).expect("replay serializes"), + blueprint_to_json(&built).expect("builder serializes"), + ); + + let params = [Scalar::i64(2)]; + let replay_flat = replayed.compile_with_params(¶ms).expect("replay compiles"); + let built_flat = built.compile_with_params(¶ms).expect("builder compiles"); + assert_eq!(replay_flat.edges, built_flat.edges); + assert_eq!(replay_flat.sources, built_flat.sources); + } } diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs index 2fc6547..c128e76 100644 --- a/crates/aura-engine/src/graph_model.rs +++ b/crates/aura-engine/src/graph_model.rs @@ -164,10 +164,41 @@ fn scope_parts(c: &Composite, is_root: bool) -> (Vec, Vec) { (nodes, edges) } +/// `"gangs":[…]` fragment for a scope, or empty for a gang-free scope (the +/// model stays byte-identical for existing graphs). Members are +/// [node,pos,"param"] triples; the name/kind are display strings — the model +/// is the C23 debug surface, so names are welcome here. `kind` renders in its +/// serde/Debug form (`"I64"`, matching `Gang`'s own derive) rather than +/// `kind_str`'s lowercase render — the gang table is a structural record, not +/// a port/palette entry. +fn gangs_fragment(c: &Composite) -> String { + if c.gangs().is_empty() { + return String::new(); + } + let items = join(c.gangs().iter().map(|g| { + let members = join( + g.members + .iter() + .map(|m| format!("[{},{},{}]", m.node, m.pos, json_str(&m.name))), + ); + format!( + r#"{{"name":{},"kind":{},"members":[{members}]}}"#, + json_str(&g.name), + json_str(&format!("{:?}", g.kind)), + ) + })); + format!(r#","gangs":[{items}]"#) +} + /// One scope as `{ "nodes": {…}, "edges": [...] }`. fn scope_json(c: &Composite, is_root: bool) -> String { let (nodes, edges) = scope_parts(c, is_root); - format!(r#"{{"nodes":{{{}}},"edges":[{}]}}"#, nodes.join(","), edges.join(",")) + format!( + r#"{{"nodes":{{{}}},"edges":[{}]{}}}"#, + nodes.join(","), + edges.join(","), + gangs_fragment(c), + ) } /// A composite's interior scope plus its boundary: `inputs` (each role's declared @@ -209,9 +240,10 @@ fn composite_def_json(c: &Composite) -> String { )); } format!( - r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]}}"#, + r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]{}}}"#, nodes.join(","), edges.join(","), + gangs_fragment(c), ) } @@ -270,7 +302,7 @@ pub fn model_to_json(root: &Composite) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{Edge, OutField, Role, Target}; + use crate::{Edge, Gang, GangMember, OutField, Role, Target}; use aura_core::{ Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, }; @@ -441,6 +473,31 @@ mod tests { ) } + /// A composite with two SMA leaves whose `length` params are fused into one + /// public knob (mirrors `blueprint::tests::with_gangs_accepts_two_open_siblings`) + /// — the smallest fixture exercising the model's `gangs` render. + fn ganged_fixture() -> Composite { + Composite::new( + "ganged", + vec![ + BlueprintNode::Primitive(Sma::builder().named("a")), + BlueprintNode::Primitive(Sma::builder().named("b")), + ], + vec![], + vec![], + vec![], + ) + .with_gangs(vec![Gang { + name: "length".into(), + kind: ScalarKind::I64, + members: vec![ + GangMember { node: 0, pos: 0, name: "length".into() }, + GangMember { node: 1, pos: 0, name: "length".into() }, + ], + }]) + .expect("two open siblings gang") + } + // -- Task 2: primitive record ------------------------------------------------ #[test] @@ -585,6 +642,22 @@ mod tests { assert_ne!(ok, bad); } + // -- Task 6 (#61): gang render ----------------------------------------------- + + /// A ganged scope's model record carries the gang relation (name, kind, + /// [node,pos,"param"] members); a gang-free scope carries NO gangs key + /// (the model_golden byte-pin stays untouched). + #[test] + fn model_carries_gangs_only_for_ganged_scopes() { + let ganged = ganged_fixture(); // Task-2-style helper, built via with_gangs + let json = model_to_json(&ganged); + assert!( + json.contains(r#""gangs":[{"name":"length","kind":"I64","members":[[0,0,"length"],[1,0,"length"]]}]"#), + "{json}" + ); + assert!(!model_to_json(&sample_root()).contains(r#""gangs""#)); + } + #[test] fn serializer_is_read_only() { // Compile-time witness: model_to_json takes &Composite and returns String —