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:
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user