feat(engine): gang data model + validation gate (#61 task 1)

One public knob fanning into >=2 sibling params: Gang/GangMember types
(the Role name/targets split + BoundParam pos/name dual), Composite.gangs
behind the single fallible Composite::with_gangs gate, check_gangs as the
shared $A predicate (all three future minting boundaries route here), and
CompileError::BadGang(GangFault) as the typed refusal.

PrimitiveBuilder::original_pos (aura-core) extracts bind's pos-reconstruction
loop verbatim so gang members and BoundParam share one coordinate space.

Deviation ratified vs the plan: GangFault::KindMismatch carries structural
{node, name} coordinates instead of a stringly member label, consistent with
the sibling arms (plan amended). A redundant test-local fixture wrapper was
dropped after a fresh quality re-review of the blocked task diff.

Verified: cargo test -p aura-engine --lib 249/0, -p aura-core 53/0, clippy
-D warnings clean on both crates.

refs #61
This commit is contained in:
2026-07-10 10:12:18 +02:00
parent 962b249814
commit 5eedf060dd
3 changed files with 279 additions and 20 deletions
+247 -2
View File
@@ -134,6 +134,34 @@ pub struct Role {
pub source: Option<ScalarKind>,
}
/// 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<GangMember>,
}
/// 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<Edge>,
input_roles: Vec<Role>,
output: Vec<OutField>,
gangs: Vec<Gang>,
}
impl Composite {
@@ -159,7 +188,7 @@ impl Composite {
input_roles: Vec<Role>,
output: Vec<OutField>,
) -> 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<Gang>) -> Result<Composite, CompileError> {
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 `<node>.<param>` (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<usize> = 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<GangMember>| 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;
+2 -2
View File
@@ -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,