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 —