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
+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