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
+30 -16
View File
@@ -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<usize> = 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<usize> = 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<usize> = 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).
+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,