diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index e252905..842a741 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -162,6 +162,22 @@ impl PrimitiveBuilder { pub fn bound_params(&self) -> &[BoundParam] { &self.bound } + /// Map an index into the CURRENT (shrunk) open-param list back to the + /// slot's position in the ORIGINAL pre-bind param list — the coordinate + /// space `BoundParam.pos` uses: add one for each already-bound slot at an + /// original position <= it (the same reconstruction `bind` performs when + /// recording a `BoundParam`). + pub fn original_pos(&self, open_idx: usize) -> usize { + let mut orig = open_idx; + let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect(); + prior.sort_unstable(); + for p in prior { + if p <= orig { + orig += 1; + } + } + orig + } /// The full declared signature (read pre-build by `Composite::param_space`, /// `BlueprintNode::signature`, and the renderer). pub fn schema(&self) -> &NodeSchema { @@ -220,14 +236,7 @@ impl PrimitiveBuilder { // the closure capture below is what bootstrap actually reads. let name = self.schema.params[pos].name.clone(); let kind = self.schema.params[pos].kind; - let mut orig = pos; - let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect(); - prior.sort_unstable(); - for p in prior { - if p <= orig { - orig += 1; - } - } + let orig = self.original_pos(pos); self.bound.push(BoundParam { pos: orig, name, kind, value }); self.schema.params.remove(pos); // [param_space side] shrink the declared surface let inner = self.build; // [value side] wrap the build closure @@ -265,14 +274,7 @@ impl PrimitiveBuilder { } let name = self.schema.params[pos].name.clone(); let kind = self.schema.params[pos].kind; - let mut orig = pos; - let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect(); - prior.sort_unstable(); - for p in prior { - if p <= orig { - orig += 1; - } - } + let orig = self.original_pos(pos); self.bound.push(BoundParam { pos: orig, name, kind, value }); self.schema.params.remove(pos); let inner = self.build; @@ -549,6 +551,18 @@ mod tests { ); } + /// original_pos maps a shrunk-schema index back to the pre-bind slot, + /// mirroring bind's own BoundParam.pos recording. + #[test] + fn original_pos_reconstructs_prebind_slots() { + let b = probe3(); // params a,b,c at 0,1,2 + assert_eq!(b.original_pos(0), 0); + assert_eq!(b.original_pos(2), 2); + let b = b.bind("b", Scalar::i64(1)); // shrunk: [a, c] + assert_eq!(b.original_pos(0), 0); // a + assert_eq!(b.original_pos(1), 2); // c sat at 2 originally + } + #[test] fn bind_records_bound_param_at_original_position() { // middle-of-three: binding `b` records its ORIGINAL slot position (1). diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index c110298..c207c81 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -134,6 +134,34 @@ pub struct Role { pub source: Option, } +/// One public knob fanning into >=2 sibling params of one composite frame +/// (#61). Binding is structural, not advisory: the member addresses leave +/// `param_space()` and this gang's own address replaces them, so no consumer +/// can bind a member independently. Mirrors `Role`'s name/targets split (the +/// name is a C23 debug/axis symbol; the members are structure) and +/// `BoundParam`'s pos/name dual (pos identity-bearing, name the re-application +/// key). +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Gang { + /// The public knob's name — a single path segment (no `.`). + pub name: String, + /// The shared scalar kind every member must declare. + pub kind: ScalarKind, + /// The fused params, canonical order ascending by `(node, pos)`. + pub members: Vec, +} + +/// One ganged param slot: the declaring composite's node index plus the +/// slot's position in that node's ORIGINAL (pre-bind) param list, plus the +/// param's name (used to resolve against the shrunk open schema; blanked in +/// the identity projection like `BoundParam.name`). +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct GangMember { + pub node: usize, + pub pos: usize, + pub name: String, +} + /// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not** /// a [`Node`]: it is never `eval`'d. It holds interior items (local indices), /// interior edges (local indices), input roles (role `r` fans into the interior @@ -145,6 +173,7 @@ pub struct Composite { edges: Vec, input_roles: Vec, output: Vec, + gangs: Vec, } impl Composite { @@ -159,7 +188,7 @@ impl Composite { input_roles: Vec, output: Vec, ) -> Self { - Self { name: name.into(), nodes, edges, input_roles, output } + Self { name: name.into(), nodes, edges, input_roles, output, gangs: Vec::new() } } /// The authored render name (cluster title, #13). Non-load-bearing. @@ -185,6 +214,21 @@ impl Composite { pub fn output(&self) -> &[OutField] { &self.output } + /// The gang table (empty for an un-ganged blueprint). + pub fn gangs(&self) -> &[Gang] { + &self.gangs + } + /// Install a gang table — the ONLY way a non-empty one enters a + /// `Composite`. Validates via `check_gangs`, so a constructed value's + /// gangs may be trusted downstream (the same authoring-edge trust + /// `lower_items` extends to param cells). All three minting boundaries + /// (GraphBuilder::build, GraphSession::finish, blueprint_from_json) + /// route through here — one predicate, three cadences (C24). + pub fn with_gangs(mut self, gangs: Vec) -> Result { + check_gangs(&self.nodes, &gangs)?; + self.gangs = gangs; + Ok(self) + } /// The aggregated, flat, path-qualified param-space (C12): every node's declared /// params, concatenated in lowering order. Each param is `.` (the @@ -569,6 +613,74 @@ pub(crate) fn check_param_namespace_injective(space: &[ParamSpec]) -> Result<(), Ok(()) } +/// §A predicate: structural validity of a gang table against its composite +/// frame's nodes. Shared by every minting boundary; gang-name collisions with +/// live addresses are NOT re-checked here — the projected `param_space()` +/// flows into `check_param_namespace_injective` on every compile/finish path. +pub(crate) fn check_gangs(nodes: &[BlueprintNode], gangs: &[Gang]) -> Result<(), CompileError> { + let mut claimed = std::collections::HashSet::new(); + for g in gangs { + if g.name.is_empty() || g.name.contains('.') { + return Err(CompileError::BadGang(GangFault::BadName { gang: g.name.clone() })); + } + if g.members.len() < 2 { + return Err(CompileError::BadGang(GangFault::TooFewMembers { gang: g.name.clone() })); + } + for m in &g.members { + let item = nodes.get(m.node).ok_or_else(|| { + CompileError::BadGang(GangFault::NodeOutOfRange { gang: g.name.clone(), node: m.node }) + })?; + let BlueprintNode::Primitive(b) = item else { + return Err(CompileError::BadGang(GangFault::NotAPrimitive { + gang: g.name.clone(), + node: m.node, + })); + }; + let hits: Vec = b + .params() + .iter() + .enumerate() + .filter(|(_, p)| p.name == m.name) + .map(|(i, _)| i) + .collect(); + let [idx] = hits.as_slice() else { + return Err(CompileError::BadGang(GangFault::NoOpenParam { + gang: g.name.clone(), + node: m.node, + name: m.name.clone(), + })); + }; + let expected_pos = b.original_pos(*idx); + if expected_pos != m.pos { + return Err(CompileError::BadGang(GangFault::PosMismatch { + gang: g.name.clone(), + node: m.node, + expected: expected_pos, + got: m.pos, + })); + } + let member_kind = b.params()[*idx].kind; + if member_kind != g.kind { + return Err(CompileError::BadGang(GangFault::KindMismatch { + gang: g.name.clone(), + node: m.node, + name: m.name.clone(), + expected: g.kind, + got: member_kind, + })); + } + if !claimed.insert((m.node, m.pos)) { + return Err(CompileError::BadGang(GangFault::MemberInTwoGangs { + gang: g.name.clone(), + node: m.node, + pos: m.pos, + })); + } + } + } + Ok(()) +} + /// Every root input role must be source-bound (`source: Some`): an open role /// (`None`) at the root has no enclosing graph to wire it, so only a fully /// source-bound composite is runnable. Shared by `compile_with_cells` (the @@ -629,6 +741,22 @@ pub enum CompileError { /// An interior node's input `slot` is covered by more than one edge/role target /// combined — a slot holds exactly one column, so >1 producer is ill-formed. DoubleWiredPort { node: usize, slot: usize }, + /// A gang table failed structural validation at a minting boundary. + BadGang(GangFault), +} + +/// The typed detail of a `CompileError::BadGang` (one arm per `check_gangs` +/// refusal). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GangFault { + TooFewMembers { gang: String }, + BadName { gang: String }, + NodeOutOfRange { gang: String, node: usize }, + NotAPrimitive { gang: String, node: usize }, + NoOpenParam { gang: String, node: usize, name: String }, + PosMismatch { gang: String, node: usize, expected: usize, got: usize }, + KindMismatch { gang: String, node: usize, name: String, expected: ScalarKind, got: ScalarKind }, + MemberInTwoGangs { gang: String, node: usize, pos: usize }, } /// The per-edge kind predicate, shared by `validate_wiring` (holistic) and the @@ -836,7 +964,9 @@ fn inline_composite( // param-space path at construction but are dropped at lowering — the injected // cell `point: &[Cell]` arg drives `lower_items` below; the flat graph stays // wired by raw index. - let Composite { name: _, nodes, edges, input_roles, output } = c; + // `gangs` is an authoring-time gate only (checked at `with_gangs`); it has + // no runtime representation in the flat graph, same as `name`. + let Composite { name: _, nodes, edges, input_roles, output, gangs: _ } = c; let item_count = nodes.len(); // recursively lower interior items, then rewrite interior edges through them @@ -952,6 +1082,121 @@ mod tests { use aura_std::{Bias, Ema, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc; + /// One knob fanning into two sibling open params passes the gate; the + /// value carries the gang table. + #[test] + fn with_gangs_accepts_two_open_siblings() { + let c = Composite::new( + "sig", + vec![ + BlueprintNode::Primitive(Sma::builder().named("a")), + BlueprintNode::Primitive(Sma::builder().named("b")), + ], + vec![], + vec![], + vec![], + ); + let c = c + .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"); + assert_eq!(c.gangs().len(), 1); + } + + /// Every check_gangs refusal arm fires as a typed BadGang fault. + #[test] + fn with_gangs_refuses_each_malformed_table() { + let mk = || { + Composite::new( + "sig", + vec![ + BlueprintNode::Primitive(Sma::builder().named("a")), + BlueprintNode::Primitive(Sma::builder().named("b")), + ], + vec![], + vec![], + vec![], + ) + }; + let gang = |name: &str, members: Vec| Gang { + name: name.into(), + kind: ScalarKind::I64, + members, + }; + let m = |node, pos, name: &str| GangMember { node, pos, name: name.into() }; + // < 2 members + let e = mk().with_gangs(vec![gang("g", vec![m(0, 0, "length")])]).err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::TooFewMembers { .. })), "{e:?}"); + // dotted name + let e = mk() + .with_gangs(vec![gang("a.b", vec![m(0, 0, "length"), m(1, 0, "length")])]) + .err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::BadName { .. })), "{e:?}"); + // node out of range + let e = mk() + .with_gangs(vec![gang("g", vec![m(0, 0, "length"), m(9, 0, "length")])]) + .err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::NodeOutOfRange { .. })), "{e:?}"); + // member node is a composite, not a primitive + let nested = Composite::new( + "sig", + vec![ + BlueprintNode::Composite(Composite::new("inner", vec![], vec![], vec![], vec![])), + BlueprintNode::Primitive(Sma::builder().named("b")), + ], + vec![], + vec![], + vec![], + ); + let e = nested + .with_gangs(vec![gang("g", vec![m(0, 0, "length"), m(1, 0, "length")])]) + .err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::NotAPrimitive { .. })), "{e:?}"); + // no open param of that name (bound at authoring) + let bound = Composite::new( + "sig", + vec![ + BlueprintNode::Primitive(Sma::builder().named("a").bind("length", Scalar::i64(2))), + BlueprintNode::Primitive(Sma::builder().named("b")), + ], + vec![], + vec![], + vec![], + ); + let e = bound + .with_gangs(vec![gang("g", vec![m(0, 0, "length"), m(1, 0, "length")])]) + .err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::NoOpenParam { .. })), "{e:?}"); + // kind mismatch (declared F64 over I64 members) + let e = mk() + .with_gangs(vec![Gang { + name: "g".into(), + kind: ScalarKind::F64, + members: vec![m(0, 0, "length"), m(1, 0, "length")], + }]) + .err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::KindMismatch { .. })), "{e:?}"); + // one param claimed twice + let e = mk() + .with_gangs(vec![ + gang("g", vec![m(0, 0, "length"), m(1, 0, "length")]), + gang("h", vec![m(1, 0, "length"), m(0, 0, "length")]), + ]) + .err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::MemberInTwoGangs { .. })), "{e:?}"); + // pos out of step with the schema + let e = mk() + .with_gangs(vec![gang("g", vec![m(0, 3, "length"), m(1, 0, "length")])]) + .err().unwrap(); + assert!(matches!(e, CompileError::BadGang(GangFault::PosMismatch { .. })), "{e:?}"); + } + #[test] fn edge_kind_check_accepts_match_and_rejects_mismatch() { use super::edge_kind_check; diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index bd7b1a2..4741cc9 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -54,8 +54,8 @@ mod sweep; mod walkforward; pub use blueprint::{ - BindError, Binder, BlueprintNode, CompileError, Composite, OutField, RandomBinder, - Role, SweepBinder, + BindError, Binder, BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, + OutField, RandomBinder, Role, SweepBinder, }; pub use blueprint_serde::{ blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,