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).