feat(engine,cli): gang serde + identity + the gang construction op (#61 tasks 3-4)

Serde: CompositeData carries an additive-optional gangs section (omitted when
empty — every existing v1 document round-trips byte-identically, NO
format-version bump per the #61 decision; BLUEPRINT_FORMAT_VERSION doc now
records the pre-ship Tier-2 dormancy). project() emits gangs canonically
(members by (node,pos), gangs by first member) so declaration order never
reaches the content id; reconstruct() re-mints through Composite::with_gangs
and a corrupted section refuses as the new typed LoadError::Gang.
strip_debug_symbols blanks gang + member names, keeps (node,pos,kind) — the
identity id stays gang-name-blind and gang-structure-sensitive.

Construction: Op::Gang { as_name, into } with the feed-style two-phase eager
arm (member resolution against the post-bind schemas, kind uniformity,
single-gang membership; OpError::{GangKindMismatch,AlreadyGanged,GangArity});
finish() routes the assembled composite through with_gangs, and gang-name
collisions surface via the existing injectivity gate on the projected space.
CLI: OpDoc::Gang ({"op":"gang","as":…,"into":[…]}), kind_label/From/
format_op_error arms, and format_load_error phrases gang faults as prose
(gang_fault_prose) instead of leaking Debug — a ratified improvement over the
plan bytes.

Held quality nit (principled plan-hold): gang()'s inline collect-then-reject
param resolution is the module's established convention (bind/try_bind); a
shared cross-crate helper is out of this task's scope.

Verified: workspace build clean, aura-engine lib 258/0, aura-cli
graph_construct 48/0 (incl. the new gang-op e2e), clippy -D warnings clean.

refs #61
This commit is contained in:
2026-07-10 11:56:43 +02:00
parent 123620442f
commit 521cfc8b5b
5 changed files with 567 additions and 13 deletions
+49 -1
View File
@@ -9,7 +9,7 @@ use std::path::Path;
use aura_engine::{
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
};
use serde::Deserialize;
@@ -44,6 +44,11 @@ enum OpDoc {
#[serde(rename = "as")]
as_name: String,
},
Gang {
#[serde(rename = "as")]
as_name: String,
into: Vec<String>,
},
}
impl OpDoc {
@@ -56,6 +61,7 @@ impl OpDoc {
OpDoc::Feed { .. } => "feed",
OpDoc::Connect { .. } => "connect",
OpDoc::Expose { .. } => "expose",
OpDoc::Gang { .. } => "gang",
}
}
}
@@ -71,6 +77,7 @@ impl From<OpDoc> for Op {
OpDoc::Feed { role, into } => Op::Feed { role, into },
OpDoc::Connect { from, to } => Op::Connect { from, to },
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
}
}
}
@@ -106,6 +113,13 @@ fn format_op_error(e: &OpError) -> String {
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"),
OpError::GangKindMismatch { member, expected, got } => {
format!("gang: member `{member}` is {got:?}, expected {expected:?}")
}
OpError::AlreadyGanged { node, param } => {
format!("gang: `{node}.{param}` is already ganged")
}
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::Incomplete(ce) => format!("{ce:?}"),
}
}
@@ -325,6 +339,40 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
format!("blueprint format_version {found} is unsupported (this build reads {supported})")
}
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
}
}
/// Phrase a gang-table `CompileError` as prose (Debug-leak-free, mirroring
/// `blueprint_load_prose`'s convention). `with_gangs` — the only producer of
/// `LoadError::Gang` — always yields `CompileError::BadGang`; a bare fallback
/// covers the type's other variants without ever printing a raw `{e:?}`.
fn gang_fault_prose(e: &CompileError) -> String {
use aura_engine::GangFault as F;
let CompileError::BadGang(fault) = e else {
return "malformed gang table".to_string();
};
match fault {
F::BadName { gang } => format!("gang {gang:?} has an empty or dotted name"),
F::TooFewMembers { gang } => format!("gang {gang:?} has fewer than two members"),
F::NodeOutOfRange { gang, node } => {
format!("gang {gang:?} references out-of-range node {node}")
}
F::NotAPrimitive { gang, node } => {
format!("gang {gang:?} member node {node} is not a primitive")
}
F::NoOpenParam { gang, node, name } => {
format!("gang {gang:?} member node {node} has no open param {name:?}")
}
F::PosMismatch { gang, node, expected, got } => format!(
"gang {gang:?} member node {node} declares position {got} but the param sits at {expected}"
),
F::KindMismatch { gang, node, name, expected, got } => format!(
"gang {gang:?} member node {node} param {name:?} is {got:?} but the gang expects {expected:?}"
),
F::MemberInTwoGangs { gang, node, pos } => format!(
"gang {gang:?} member (node {node}, pos {pos}) already belongs to another gang"
),
}
}
+1
View File
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"sig","nodes":[{"primitive":{"type":"SMA","name":"fast"}}],"gangs":[{"name":"solo","kind":"I64","members":[{"node":0,"pos":0,"name":"length"}]}]}}
+172
View File
@@ -781,3 +781,175 @@ fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
"the raw open params, in lowering order"
);
}
/// The op-script surface accepts a gang op and the built blueprint exposes
/// ONE public knob for the fused pair (#61).
#[test]
fn graph_build_accepts_a_gang_op() {
let dir = temp_cwd("graph-build-gang-op");
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}");
// Write the built blueprint to a file, then introspect its raw param space
// (mirrors `graph_content_id_accepts_an_op_list_file`'s file-write plumbing).
let bp_path = dir.join("bp.json");
std::fs::write(&bp_path, &bp).expect("write built blueprint");
let (params, stderr2, code2) =
run_in(&dir, &["graph", "introspect", "--params", bp_path.to_str().unwrap()]);
assert_eq!(code2, Some(0), "stderr: {stderr2}");
assert_eq!(params, "length:I64\n", "one fused public knob, not two members");
}
/// 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]
fn graph_build_reports_gang_arity_fault_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"gang","as":"solo","into":["a.length"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a gang arity fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 2 (gang): gang `solo`: needs at least two members"),
"names the op + cause as prose: {stderr}"
);
assert!(!stderr.contains("GangArity"), "does not leak the Debug variant name: {stderr}");
}
/// The `gang` op's double-membership refusal (a param already claimed by an
/// earlier gang) reads as prose, naming the offending `node.param`.
#[test]
fn graph_build_reports_already_ganged_fault_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"SMA","name":"b"},
{"op":"add","type":"SMA","name":"c"},
{"op":"gang","as":"lo","into":["a.length","b.length"]},
{"op":"gang","as":"hi","into":["a.length","c.length"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on an already-ganged fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 5 (gang): gang: `a.length` is already ganged"),
"names the op + doubly-ganged member as prose: {stderr}"
);
assert!(!stderr.contains("AlreadyGanged"), "does not leak the Debug variant name: {stderr}");
}
/// The `gang` op's kind-mismatch refusal (members of differing scalar kind)
/// reads as prose, naming the offending member and both kinds.
#[test]
fn graph_build_reports_gang_kind_mismatch_as_prose() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"Bias","name":"b"},
{"op":"gang","as":"g","into":["a.length","b.scale"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a gang kind mismatch");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 3 (gang): gang: member `b.scale` is F64, expected I64"),
"names the op + kind mismatch as prose: {stderr}"
);
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
}
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
/// register` on a hand-corrupted blueprint whose gangs section fails structural
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
/// `gang_fault_prose` wording — never the raw `CompileError`/`GangFault` Debug
/// dump the pre-#61 fallback would otherwise leak. Mirrors
/// `graph_register_rejects_an_unknown_node_type_with_prose`'s pattern, one layer
/// further into the load path.
#[test]
fn graph_register_rejects_a_corrupt_gang_table_with_prose() {
let dir = temp_cwd("register-bad-gang");
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("bad_gang.json")]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
assert!(
stderr.contains("gang section invalid: gang \"solo\" has fewer than two members"),
"phrases the corrupt gang table as prose: {stderr}"
);
assert!(!stderr.contains("GangFault"), "does not leak the Debug type name: {stderr}");
assert!(!stderr.contains("BadGang"), "does not leak the Debug variant name: {stderr}");
}
/// Property (#61, the `blueprint_serde` canonicalization the CLI content-id
/// path relies on): declaring a gang's members in a different order in the
/// op-script's `into` list must NOT change the built blueprint's content id.
/// `project()`'s member/gang sort (task 3) exists precisely so that the
/// write-once content-addressed store (`graph register`'s idempotency
/// contract) sees two authoring-order variants of the identical graph as one
/// blueprint; were the sort ever dropped, this is the CLI-boundary regression
/// that would silently start minting a second id for the same topology.
#[test]
fn graph_build_gang_member_order_does_not_affect_content_id() {
let forward = 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 reversed = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a"},
{"op":"add","type":"SMA","name":"b"},
{"op":"gang","as":"length","into":["b.length","a.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 (id_forward, e1, ok1) = run(&["graph", "introspect", "--content-id"], forward);
let (id_reversed, e2, ok2) = run(&["graph", "introspect", "--content-id"], reversed);
assert!(ok1, "forward: {e1}");
assert!(ok2, "reversed: {e2}");
assert_eq!(id_forward, id_reversed, "gang member declaration order is content-id-invisible");
}
/// Property (#61, the eager gang gate's bind-then-gang ordering): a member
/// already bound at `add` has left the node's open param schema exactly like
/// any other consumed param, so ganging it reports the generic `UnknownParam`
/// prose — not a gang-specific "already bound" message — proving the gang op
/// resolves each member against the CURRENT open schema (mirroring `try_bind`)
/// rather than against the node's original, pre-bind schema.
#[test]
fn graph_build_gang_on_a_bound_member_reports_unknown_param() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"a","bind":{"length":{"I64":5}}},
{"op":"add","type":"SMA","name":"b"},
{"op":"gang","as":"length","into":["a.length","b.length"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit: a bound member cannot be ganged");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(
stderr.contains("op 3 (gang): node a has no param \"length\""),
"the bound member reads as an ordinary unknown param, not a gang-specific fault: {stderr}"
);
}
+191 -10
View File
@@ -8,12 +8,16 @@
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
//! carries no node logic (C17) and references a closed vocabulary (C24).
use crate::blueprint::{BlueprintNode, Composite, OutField, Role};
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
use crate::harness::Edge;
use aura_core::{BoundParam, PrimitiveBuilder};
/// The format version the loader understands. Bumped only by a load-bearing
/// (Tier-2) change; additive optional fields do not bump it (#156).
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
/// discipline activates at the first external ship — which consciously
/// freezes v1 (gangs included).
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
/// Top-level envelope: the version is read before the payload is interpreted.
@@ -35,6 +39,8 @@ pub struct CompositeData {
pub input_roles: Vec<Role>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub output: Vec<OutField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gangs: Vec<Gang>,
}
/// A blueprint item: a primitive (referenced by type identity) or a nested
@@ -65,12 +71,23 @@ pub enum SerializeError {
}
fn project(c: &Composite) -> CompositeData {
// Canonical gang order (bind-order-independent, mirroring `project_node`'s
// bound-param canonicalization below): each gang's members ascending
// `(node, pos)`, then the gangs themselves ordered by their (now-sorted)
// first member.
let mut gangs = c.gangs().to_vec();
for g in &mut gangs {
g.members.sort_by_key(|m| (m.node, m.pos));
}
gangs.sort_by_key(|g| (g.members[0].node, g.members[0].pos));
CompositeData {
name: c.name().to_string(),
nodes: c.nodes().iter().map(project_node).collect(),
edges: c.edges().to_vec(),
input_roles: c.input_roles().to_vec(),
output: c.output().to_vec(),
gangs,
}
}
@@ -142,6 +159,12 @@ fn strip_debug_symbols(b: &mut CompositeData) {
for out in &mut b.output {
out.name = String::new(); // (node, field) + order survive
}
for gang in &mut b.gangs {
gang.name = String::new();
for m in &mut gang.members {
m.name = String::new(); // node/pos/kind survive
}
}
}
/// Loader failure (typed, named — never a panic, never a silent wrong graph).
@@ -164,6 +187,9 @@ pub enum LoadError {
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
UnknownNodeType(String),
/// The document's gangs section fails structural validation against its
/// own nodes (a hand-edited or corrupted document).
Gang(crate::blueprint::CompileError),
}
fn reconstruct(
@@ -192,13 +218,9 @@ fn reconstruct(
NodeData::Composite(c) => BlueprintNode::Composite(reconstruct(c, resolve)?),
});
}
Ok(Composite::new(
d.name.clone(),
nodes,
d.edges.clone(),
d.input_roles.clone(),
d.output.clone(),
))
Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
.with_gangs(d.gangs.clone())
.map_err(LoadError::Gang)
}
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
@@ -221,7 +243,7 @@ pub fn blueprint_from_json(
#[cfg(test)]
mod tests {
use super::*;
use crate::blueprint::{Composite, OutField, Role};
use crate::blueprint::{Composite, GangMember, OutField, Role};
use crate::harness::{Edge, Target};
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
use aura_core::{Scalar, ScalarKind, Timestamp};
@@ -542,4 +564,163 @@ mod tests {
"identity form is role-name- and output-name-blind"
);
}
// The resolver used by the gang-serde tests below (mirrors the inline
// `&|t| aura_std::std_vocabulary(t)` closure the rest of this module uses).
fn fixture_resolver() -> impl Fn(&str) -> Option<PrimitiveBuilder> {
|t: &str| aura_std::std_vocabulary(t)
}
// Shared topology: two open SMAs feeding a `Sub`. `ganged_fixture_named`
// and `unganged_twin` both build on this single definition so the gang
// and gang-free fixtures cannot drift apart independently.
fn two_sma_sub() -> Composite {
Composite::new(
"sig",
vec![Sma::builder().named("a").into(), Sma::builder().named("b").into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
// `two_sma_sub`, one gang fusing the two SMAs' lengths — Task 2's
// `ganged_pair` shape, reproduced here for the serde-layer tests.
// `gang_name` is the ONLY thing that varies across callers.
fn ganged_fixture_named(gang_name: &str) -> Composite {
two_sma_sub()
.with_gangs(vec![Gang {
name: gang_name.into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 1, pos: 0, name: "length".into() }, // declared backwards
GangMember { node: 0, pos: 0, name: "length".into() },
],
}])
.expect("two open siblings gang")
}
fn ganged_fixture() -> Composite {
ganged_fixture_named("length")
}
// The un-ganged twin of `ganged_fixture`: identical topology, no gang table.
fn unganged_twin() -> Composite {
two_sma_sub()
}
// Four open SMAs, two gangs, BOTH declared out of canonical order: the
// gangs vec itself is swapped (the gang whose sorted first member is
// (0,0) comes SECOND) and each gang's own members are swapped too — probes
// the round-trip's re-canonicalization independently from `with_gangs`'s
// own (order-blind) structural gate.
fn ganged_fixture_declared_backwards() -> Composite {
Composite::new(
"sig",
vec![
Sma::builder().named("a").into(),
Sma::builder().named("b").into(),
Sma::builder().named("c").into(),
Sma::builder().named("d").into(),
],
vec![],
vec![],
vec![],
)
.with_gangs(vec![
Gang {
name: "hi".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 3, pos: 0, name: "length".into() },
GangMember { node: 2, pos: 0, name: "length".into() },
],
},
Gang {
name: "lo".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 1, pos: 0, name: "length".into() },
GangMember { node: 0, pos: 0, name: "length".into() },
],
},
])
.expect("two structurally-valid gangs, declared backwards")
}
// The canonical form `ganged_fixture_declared_backwards` must settle into:
// members ascending (node,pos) within each gang, gangs ordered by their
// (sorted) first member — "lo" (first member (0,0)) precedes "hi" ((2,0)).
fn c_sorted_expectation() -> Vec<Gang> {
vec![
Gang {
name: "lo".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 0, pos: 0, name: "length".into() },
GangMember { node: 1, pos: 0, name: "length".into() },
],
},
Gang {
name: "hi".into(),
kind: ScalarKind::I64,
members: vec![
GangMember { node: 2, pos: 0, name: "length".into() },
GangMember { node: 3, pos: 0, name: "length".into() },
],
},
]
}
/// Gangs round-trip through the canonical form, in canonical order
/// (members ascending (node,pos); gangs by first member) regardless of
/// declaration order.
#[test]
fn gangs_round_trip_canonically() {
let c = ganged_fixture_declared_backwards(); // members/gangs deliberately unsorted
let json = blueprint_to_json(&c).expect("serialize");
let back = blueprint_from_json(&json, &fixture_resolver()).expect("load");
assert_eq!(blueprint_to_json(&back).expect("re-serialize"), json, "canonical fixpoint");
assert_eq!(back.gangs().to_vec(), c_sorted_expectation(), "sorted members/gangs");
assert_eq!(back.param_space().len(), c.param_space().len());
}
/// A hand-corrupted gangs section (node out of range) is refused as a
/// typed load fault, never a silent wrong graph.
#[test]
fn corrupt_gang_section_is_refused_on_load() {
let mut doc: serde_json::Value =
serde_json::from_str(&blueprint_to_json(&ganged_fixture()).unwrap()).unwrap();
doc["blueprint"]["gangs"][0]["members"][0]["node"] = 99.into();
// `.err().unwrap()` (not `.unwrap_err()`): the Ok type `Composite` holds a
// build closure and is not `Debug`.
let err = blueprint_from_json(&doc.to_string(), &fixture_resolver()).err().unwrap();
assert!(matches!(err, LoadError::Gang(_)), "{err:?}");
}
/// Identity is gang-name-blind and gang-structure-sensitive.
#[test]
fn gang_identity_blanks_names_keeps_structure() {
let a = ganged_fixture_named("length");
let b = ganged_fixture_named("window"); // same structure, different gang name
assert_ne!(blueprint_to_json(&a).unwrap(), blueprint_to_json(&b).unwrap());
assert_eq!(
blueprint_identity_json(&a).unwrap(),
blueprint_identity_json(&b).unwrap(),
"gang names are debug symbols"
);
let unganged = unganged_twin();
assert_ne!(
blueprint_identity_json(&a).unwrap(),
blueprint_identity_json(&unganged).unwrap(),
"the gang itself is identity-bearing"
);
}
}
+154 -2
View File
@@ -14,7 +14,7 @@ use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
use crate::blueprint::{
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring,
BlueprintNode, Composite, OutField, Role,
BlueprintNode, Composite, Gang, GangMember, OutField, Role,
};
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
use crate::harness::{Edge, Target};
@@ -37,6 +37,8 @@ pub enum Op {
Connect { from: String, to: String },
/// Re-export `"node.field"` at the boundary under `as_name`.
Expose { from: String, as_name: String },
/// Fuse two or more sibling params into one public knob (#61).
Gang { as_name: String, into: Vec<String> },
}
/// A per-op construction fault, by-identifier so the cause names the op.
@@ -73,6 +75,12 @@ pub enum OpError {
RoleKindMismatch { role: String },
/// Holistic root fault, by-identifier: a root input role has no bound source.
UnboundRootRole { role: String },
/// Gang members must share one scalar kind.
GangKindMismatch { member: String, expected: ScalarKind, got: ScalarKind },
/// The param is already claimed by an earlier gang.
AlreadyGanged { node: String, param: String },
/// A gang needs at least two members.
GangArity { gang: String },
/// A holistic finalize fault (totality / injectivity / unbound root role),
/// wrapping the unchanged engine gate's `CompileError`.
Incomplete(CompileError),
@@ -94,6 +102,7 @@ pub struct GraphSession<'v> {
out: Vec<OutField>,
out_names: HashSet<String>,
coverage: HashMap<(usize, usize), usize>,
gangs: Vec<Gang>,
}
impl<'v> GraphSession<'v> {
@@ -113,6 +122,7 @@ impl<'v> GraphSession<'v> {
out: Vec::new(),
out_names: HashSet::new(),
coverage: HashMap::new(),
gangs: Vec::new(),
}
}
@@ -126,6 +136,7 @@ impl<'v> GraphSession<'v> {
Op::Feed { role, into } => self.feed(role, into),
Op::Connect { from, to } => self.connect(from, to),
Op::Expose { from, as_name } => self.expose(from, as_name),
Op::Gang { as_name, into } => self.gang(as_name, into),
}
}
@@ -293,6 +304,67 @@ impl<'v> GraphSession<'v> {
Ok(())
}
/// Eager arm of the gang gate: resolve every member against the CURRENT
/// (post-bind) open param lists — a member bound at `Add` has left the open
/// schema and correctly fails as `UnknownParam`, mirroring `try_bind` — enforce
/// kind uniformity and single-gang membership, then commit. Structural validity
/// is re-run holistically at `finish` through `Composite::with_gangs` (one
/// predicate, two cadences, like `connect`'s eager `edge_kind_check` + the
/// holistic `validate_wiring`).
fn gang(&mut self, as_name: String, into: Vec<String>) -> Result<(), OpError> {
if into.len() < 2 {
return Err(OpError::GangArity { gang: as_name });
}
let mut members: Vec<GangMember> = Vec::with_capacity(into.len());
let mut kind: Option<ScalarKind> = None;
for port in &into {
let (node_name, param_name) = Self::split_port(port)?;
let ti = self.node_index(&node_name)?;
let BlueprintNode::Primitive(b) = &self.nodes[ti] else {
unreachable!("sessions only ever add primitives")
};
let hits: Vec<usize> = b
.params()
.iter()
.enumerate()
.filter(|(_, p)| p.name == param_name)
.map(|(i, _)| i)
.collect();
let idx = match hits.as_slice() {
[i] => *i,
[] => {
return Err(OpError::BadParam {
node: node_name,
err: BindOpError::UnknownParam(param_name),
})
}
_ => {
return Err(OpError::BadParam {
node: node_name,
err: BindOpError::AmbiguousParam(param_name),
})
}
};
let member_kind = b.params()[idx].kind;
match kind {
None => kind = Some(member_kind),
Some(expected) if expected != member_kind => {
return Err(OpError::GangKindMismatch { member: port.clone(), expected, got: member_kind })
}
_ => {}
}
let pos = b.original_pos(idx);
let already_claimed = self.gangs.iter().flat_map(|g| g.members.iter()).any(|m| m.node == ti && m.pos == pos)
|| members.iter().any(|m| m.node == ti && m.pos == pos);
if already_claimed {
return Err(OpError::AlreadyGanged { node: node_name, param: param_name });
}
members.push(GangMember { node: ti, pos, name: param_name });
}
self.gangs.push(Gang { name: as_name, kind: kind.expect(">=2 members checked above"), members });
Ok(())
}
/// The still-unwired interior input slots (build-free introspection): every
/// added node's declared input slots minus the covered ones, by-identifier.
pub fn unwired(&self) -> Vec<(String, ScalarKind)> {
@@ -320,7 +392,9 @@ impl<'v> GraphSession<'v> {
.into_iter()
.map(|(name, source, targets)| Role { name, targets, source })
.collect();
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out);
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
.with_gangs(self.gangs)
.map_err(OpError::Incomplete)?;
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(|e| match e {
CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort {
@@ -562,6 +636,84 @@ mod tests {
);
}
/// The gang op fuses two sibling params into one public knob; the replayed
/// composite's `param_space()` carries exactly the gang address.
#[test]
fn gang_op_projects_the_param_space() {
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 c = super::replay("sig", ops, &std_vocabulary).expect("replays");
let names: Vec<String> = c.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, ["length"]);
}
/// Eager refusals: unknown member param (a member bound at `Add` has left the
/// open schema), kind mismatch across members, a doubly-ganged member, a
/// single-member gang, and an unknown node identifier.
#[test]
fn gang_op_eager_refusals() {
// bound member: `b.length` was bound at Add, so it left the open param
// list and is unknown to the gang gate, exactly as `try_bind` would see it.
let mut s = session("g");
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
s.apply(Op::Add {
type_id: "SMA".into(),
as_name: Some("b".into()),
bind: vec![("length".into(), Scalar::i64(5))],
})
.unwrap();
assert_eq!(
s.apply(Op::Gang { as_name: "length".into(), into: vec!["a.length".into(), "b.length".into()] }),
Err(OpError::BadParam { node: "b".into(), err: BindOpError::UnknownParam("length".into()) })
);
// kind mismatch: SMA.length is i64, Bias.scale is f64.
let mut s = session("g");
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "Bias".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
let r = s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "b.scale".into()] });
assert!(
matches!(r, Err(OpError::GangKindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64, .. })),
"{r:?}"
);
// doubly-ganged member: a.length is already claimed by an earlier gang.
let mut s = session("g");
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("b".into()), bind: vec![] }).unwrap();
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("c".into()), bind: vec![] }).unwrap();
s.apply(Op::Gang { as_name: "lo".into(), into: vec!["a.length".into(), "b.length".into()] }).unwrap();
assert_eq!(
s.apply(Op::Gang { as_name: "hi".into(), into: vec!["a.length".into(), "c.length".into()] }),
Err(OpError::AlreadyGanged { node: "a".into(), param: "length".into() })
);
// single member: a gang needs at least two.
let mut s = session("g");
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
assert_eq!(
s.apply(Op::Gang { as_name: "solo".into(), into: vec!["a.length".into()] }),
Err(OpError::GangArity { gang: "solo".into() })
);
// unknown node: a member names a node that was never added.
let mut s = session("g");
s.apply(Op::Add { type_id: "SMA".into(), as_name: Some("a".into()), bind: vec![] }).unwrap();
assert_eq!(
s.apply(Op::Gang { as_name: "g".into(), into: vec!["a.length".into(), "ghost.length".into()] }),
Err(OpError::UnknownIdentifier("ghost".into()))
);
}
#[test]
fn finish_reports_incomplete_on_unwired_slot() {
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm