feat(engine): gang param-space projection + compile-front point expansion (#61 task 2)
param_space()/derive_signature project each frame's gang table: member addresses are skipped, the gang's single public address (prefixed like any knob at that frame) is emitted once at its FIRST member's raw position — so every consumer of param_space (binders, grids, campaign axes, --list-axes, registry coverage) inherits the gang with zero changes. compile_with_cells expands the public point back onto the raw layout via expansion_map (the collect_params mirror walk): a gang's cell is duplicated into every member slot, un-ganged slots pass through. For a gang-free blueprint the map is the identity and behaviour is byte-identical; the lower_items cursor walk and all build closures are untouched. The load-bearing equivalence is pinned in-module (ganged compile == the member-bound un-ganged twin, bit-identical wiring and run output) and again from OUTSIDE the crate in tests/gang_e2e.rs — the C24 consumer seam a project crate or the World actually uses. Verified: cargo test -p aura-engine 253/0 lib + 5/0 gang_e2e (all groups green), workspace clippy -D warnings clean. RED-first evidenced by the loop via temporary call-site reversion (all 4 new tests failed for the expected reason, then the implementation was restored). refs #61
This commit is contained in:
@@ -104,7 +104,7 @@ fn derive_signature(c: &Composite) -> NodeSchema {
|
||||
})
|
||||
.collect();
|
||||
let mut params = Vec::new();
|
||||
collect_params(c.nodes(), "", &mut params);
|
||||
collect_params(c.nodes(), c.gangs(), "", &mut params);
|
||||
NodeSchema { inputs, output, params }
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ impl Composite {
|
||||
/// names prefix via the recursion in `collect_params`.
|
||||
pub fn param_space(&self) -> Vec<ParamSpec> {
|
||||
let mut out = Vec::new();
|
||||
collect_params(&self.nodes, "", &mut out);
|
||||
collect_params(&self.nodes, &self.gangs, "", &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -259,6 +259,15 @@ impl Composite {
|
||||
return Err(CompileError::ParamArity { expected: space.len(), got: point.len() });
|
||||
}
|
||||
|
||||
// gang expansion (#61 task 2): project the public point back onto the raw,
|
||||
// per-slot cursor order `lower_items` walks — a gang-free blueprint's map is
|
||||
// the identity `[0, 1, 2, ...]`, so `raw == point` and behaviour is
|
||||
// byte-identical to before gangs existed.
|
||||
let mut map = Vec::new();
|
||||
let mut next_public = 0usize;
|
||||
expansion_map(&self.nodes, &self.gangs, &mut next_public, &mut map);
|
||||
let raw: Vec<Cell> = map.iter().map(|&j| point[j]).collect();
|
||||
|
||||
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
|
||||
let mut flat_signatures: Vec<NodeSchema> = Vec::new();
|
||||
let mut flat_edges: Vec<Edge> = Vec::new();
|
||||
@@ -266,7 +275,7 @@ impl Composite {
|
||||
|
||||
let lowerings = lower_items(
|
||||
self.nodes,
|
||||
point,
|
||||
&raw,
|
||||
&mut cursor,
|
||||
&mut flat_nodes,
|
||||
&mut flat_signatures,
|
||||
@@ -872,8 +881,21 @@ fn check_ports_connected(
|
||||
/// `name()` onto the path and recurses. Order mirrors `lower_items` (items in
|
||||
/// declared order, composites depth-first) so a param's slot matches the later
|
||||
/// flat-node order.
|
||||
fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
|
||||
for item in items {
|
||||
///
|
||||
/// Gang-aware (#61 task 2): `gangs` is the CURRENT frame's gang table (keyed by
|
||||
/// `(item index within `items`, original pre-bind pos)`). A ganged member is
|
||||
/// skipped at its own address; the gang's single public address is emitted once,
|
||||
/// at its FIRST member's raw position, prefixed like any other address at this
|
||||
/// frame. Un-ganged params are untouched.
|
||||
fn collect_params(items: &[BlueprintNode], gangs: &[Gang], prefix: &str, out: &mut Vec<ParamSpec>) {
|
||||
let mut ganged = std::collections::HashMap::new();
|
||||
for (gi, g) in gangs.iter().enumerate() {
|
||||
for m in &g.members {
|
||||
ganged.insert((m.node, m.pos), gi);
|
||||
}
|
||||
}
|
||||
let mut emitted = vec![false; gangs.len()];
|
||||
for (ni, item) in items.iter().enumerate() {
|
||||
match item {
|
||||
BlueprintNode::Primitive(b) => {
|
||||
let node = if prefix.is_empty() {
|
||||
@@ -881,7 +903,20 @@ fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec
|
||||
} else {
|
||||
format!("{prefix}.{}", b.node_name())
|
||||
};
|
||||
for p in b.params() {
|
||||
for (i, p) in b.params().iter().enumerate() {
|
||||
if let Some(&gi) = ganged.get(&(ni, b.original_pos(i))) {
|
||||
if !emitted[gi] {
|
||||
emitted[gi] = true;
|
||||
let g = &gangs[gi];
|
||||
let name = if prefix.is_empty() {
|
||||
g.name.clone()
|
||||
} else {
|
||||
format!("{prefix}.{}", g.name)
|
||||
};
|
||||
out.push(ParamSpec { name, kind: g.kind });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(ParamSpec { name: format!("{node}.{}", p.name), kind: p.kind });
|
||||
}
|
||||
}
|
||||
@@ -891,7 +926,47 @@ fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec
|
||||
} else {
|
||||
format!("{prefix}.{}", c.name())
|
||||
};
|
||||
collect_params(c.nodes(), &child, out);
|
||||
collect_params(c.nodes(), c.gangs(), &child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// For each raw open param slot (the `lower_items` cursor order), the index of
|
||||
/// the public-space entry (`param_space()`/`collect_params` order) that supplies
|
||||
/// its cell (#61 task 2). Un-ganged slots consume the next public index in
|
||||
/// order; a gang's first member claims the gang's public index and every later
|
||||
/// member repeats it. Mirrors `collect_params`'s walk exactly (same recursion
|
||||
/// shape, same `ganged`/`emitted` bookkeeping) so the two walks share their
|
||||
/// order by construction — a gang-free blueprint yields the identity map
|
||||
/// `[0, 1, 2, ...]`.
|
||||
fn expansion_map(items: &[BlueprintNode], gangs: &[Gang], next_public: &mut usize, map: &mut Vec<usize>) {
|
||||
let mut ganged = std::collections::HashMap::new();
|
||||
for (gi, g) in gangs.iter().enumerate() {
|
||||
for m in &g.members {
|
||||
ganged.insert((m.node, m.pos), gi);
|
||||
}
|
||||
}
|
||||
let mut gang_public: Vec<Option<usize>> = vec![None; gangs.len()];
|
||||
for (ni, item) in items.iter().enumerate() {
|
||||
match item {
|
||||
BlueprintNode::Primitive(b) => {
|
||||
for i in 0..b.params().len() {
|
||||
if let Some(&gi) = ganged.get(&(ni, b.original_pos(i))) {
|
||||
let idx = *gang_public[gi].get_or_insert_with(|| {
|
||||
let idx = *next_public;
|
||||
*next_public += 1;
|
||||
idx
|
||||
});
|
||||
map.push(idx);
|
||||
} else {
|
||||
map.push(*next_public);
|
||||
*next_public += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
BlueprintNode::Composite(c) => {
|
||||
expansion_map(c.nodes(), c.gangs(), next_public, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1197,6 +1272,181 @@ mod tests {
|
||||
assert!(matches!(e, CompileError::BadGang(GangFault::PosMismatch { .. })), "{e:?}");
|
||||
}
|
||||
|
||||
/// Two length-open SMAs fed from one bound price role, spread via `Sub`
|
||||
/// (Task 1's fixture shape, root-bound so it compiles standalone), with
|
||||
/// their `length` params fused into one gang.
|
||||
fn ganged_pair() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![
|
||||
Sma::builder().named("a").into(),
|
||||
Sma::builder().named("b").into(),
|
||||
Sub::builder().into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
.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")
|
||||
}
|
||||
|
||||
/// The un-ganged twin of [`ganged_pair`]: identical topology, both
|
||||
/// `length`s stay independently open in `param_space()`.
|
||||
fn unganged_pair() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![
|
||||
Sma::builder().named("a").into(),
|
||||
Sma::builder().named("b").into(),
|
||||
Sub::builder().into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// Four open SMAs, two disjoint gangs both named "length" — a gang-name
|
||||
/// collision cannot arise from a bare node-path address (1 segment vs. 2),
|
||||
/// so this is the only shape that can trip the projected space's
|
||||
/// injectivity gate.
|
||||
fn two_gangs_same_name() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![
|
||||
Sma::builder().named("a").into(),
|
||||
Sma::builder().named("b").into(),
|
||||
Sma::builder().named("c").into(),
|
||||
Sma::builder().named("d").into(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.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() },
|
||||
],
|
||||
},
|
||||
Gang {
|
||||
name: "length".into(),
|
||||
kind: ScalarKind::I64,
|
||||
members: vec![
|
||||
GangMember { node: 2, pos: 0, name: "length".into() },
|
||||
GangMember { node: 3, pos: 0, name: "length".into() },
|
||||
],
|
||||
},
|
||||
])
|
||||
.expect("two structurally-valid gangs, same name, disjoint members")
|
||||
}
|
||||
|
||||
/// The projection: two ganged member addresses collapse to ONE gang
|
||||
/// address, emitted at the FIRST member's raw position; un-ganged params
|
||||
/// are untouched.
|
||||
#[test]
|
||||
fn param_space_projects_ganged_members_to_one_address() {
|
||||
let c = ganged_pair();
|
||||
let space = c.param_space();
|
||||
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["length"], "one public knob replaces a.length/b.length");
|
||||
assert_eq!(space[0].kind, ScalarKind::I64);
|
||||
}
|
||||
|
||||
/// A gang nested inside a composite child wraps with the child's prefix,
|
||||
/// one segment SHORTER than a member address would be.
|
||||
#[test]
|
||||
fn nested_composite_gang_wraps_with_the_frame_prefix() {
|
||||
let inner = ganged_pair(); // composite named "sig"
|
||||
let outer = Composite::new(
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
let names: Vec<String> = outer.param_space().into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(names, ["sig.length"]);
|
||||
}
|
||||
|
||||
/// A gang named like a surviving address is caught by the EXISTING
|
||||
/// injectivity gate on the projected space.
|
||||
#[test]
|
||||
fn gang_name_collision_trips_param_namespace_injectivity() {
|
||||
let c = two_gangs_same_name();
|
||||
let err = check_param_namespace_injective(&c.param_space()).unwrap_err();
|
||||
assert!(matches!(err, CompileError::DuplicateParamPath(p) if p == "length"));
|
||||
}
|
||||
|
||||
/// Wrap one signal composite under a recording root and run the synthetic
|
||||
/// price fixture, mirroring `blueprint_serde`'s
|
||||
/// `serialized_blueprint_runs_bit_identical_to_rust_built` run half.
|
||||
fn run_pair(bp: Composite, params: &[Scalar]) -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let root = Composite::new(
|
||||
"h",
|
||||
vec![
|
||||
BlueprintNode::Composite(bp),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
|
||||
],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
||||
vec![Role {
|
||||
name: "src".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
let prices = crate::test_fixtures::synthetic_prices();
|
||||
let mut h = root.bootstrap_with_params(params.to_vec()).expect("bootstraps");
|
||||
h.run(vec![Box::new(VecSource::new(prices))]);
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// THE load-bearing equivalence: binding the gang to v grades exactly like
|
||||
/// binding every member to v on the un-ganged twin — both the compiled
|
||||
/// wiring and the run trace are identical.
|
||||
#[test]
|
||||
fn ganged_compile_equals_member_bound_twin() {
|
||||
let ganged = ganged_pair();
|
||||
let twin = unganged_pair();
|
||||
let g = ganged.compile_with_params(&[Scalar::i64(4)]).expect("ganged compile");
|
||||
let t = twin
|
||||
.compile_with_params(&[Scalar::i64(4), Scalar::i64(4)])
|
||||
.expect("twin compile");
|
||||
assert_eq!(g.edges, t.edges, "identical wiring");
|
||||
|
||||
let g_trace = run_pair(ganged_pair(), &[Scalar::i64(4)]);
|
||||
let t_trace = run_pair(unganged_pair(), &[Scalar::i64(4), Scalar::i64(4)]);
|
||||
assert_eq!(g_trace, t_trace, "ganged run diverged from the member-bound twin");
|
||||
assert!(!g_trace.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_kind_check_accepts_match_and_rejects_mismatch() {
|
||||
use super::edge_kind_check;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! End-to-end coverage for parameter ganging's compile-front (#61 task 2): a
|
||||
//! composite's `Gang` table (installed via `Composite::with_gangs`, task 1) is
|
||||
//! projected by `param_space()` into ONE public address per gang, and expanded
|
||||
//! back onto every member's raw slot at `compile_with_params` time.
|
||||
//!
|
||||
//! The in-module tests in `blueprint.rs` cover this through the same public
|
||||
//! `Composite`/`aura_std` surface but stay inside the crate. This file proves
|
||||
//! the identical properties reachable from OUTSIDE `aura-engine` — the exact
|
||||
//! seam a project crate or the World consumes (C24) — so a refactor that made
|
||||
//! `Gang`, `GangMember`, or `Composite::with_gangs` unreachable across the
|
||||
//! crate boundary would break a real consumer while every in-crate test stayed
|
||||
//! green.
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
BlueprintNode, CompileError, Composite, Edge, Gang, GangMember, OutField, Role, Target,
|
||||
VecSource,
|
||||
};
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
|
||||
/// Five synthetic F64 ticks: short, but long enough that a length-2 or length-3
|
||||
/// SMA warms up and the spread fires at least once. Deterministic — no time, no
|
||||
/// randomness.
|
||||
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
[(1_i64, 1.0000_f64), (2, 1.0010), (3, 1.0030), (4, 1.0060), (5, 1.0040)]
|
||||
.iter()
|
||||
.map(|&(t, p)| (Timestamp(t), Scalar::f64(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Two open-`length` SMAs fed from one bound `price` role, spread via `Sub`,
|
||||
/// with both `length` params fused into one gang named `"length"`.
|
||||
fn ganged_pair() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![
|
||||
Sma::builder().named("a").into(),
|
||||
Sma::builder().named("b").into(),
|
||||
Sub::builder().into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
.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")
|
||||
}
|
||||
|
||||
/// The un-ganged twin of [`ganged_pair`]: identical topology, both `length`s
|
||||
/// stay independently open in `param_space()`.
|
||||
fn unganged_pair() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![
|
||||
Sma::builder().named("a").into(),
|
||||
Sma::builder().named("b").into(),
|
||||
Sub::builder().into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// Wrap one signal composite under a recording root and run the synthetic price
|
||||
/// fixture, collecting the recorded `(ts, out)` trace — the only observable
|
||||
/// behaviour these tests assert on.
|
||||
fn run_recording(signal: Composite, params: &[Scalar]) -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let root = Composite::new(
|
||||
"h",
|
||||
vec![
|
||||
BlueprintNode::Composite(signal),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
|
||||
],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
||||
vec![Role {
|
||||
name: "src".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
let mut h = root.bootstrap_with_params(params.to_vec()).expect("bootstraps");
|
||||
h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// Property (projection, at the public boundary): `param_space()` collapses two
|
||||
/// ganged sibling addresses (`a.length`, `b.length`) into ONE public address
|
||||
/// (`length`), reachable using only `Composite`/`Gang`/`GangMember` exported
|
||||
/// from `aura_engine`. A regression that made the projection leak both member
|
||||
/// addresses (or drop the gang address entirely) would desync every downstream
|
||||
/// consumer of `param_space()` — sweep grids, campaign axes, the CLI's
|
||||
/// introspect output — from the point vector `compile_with_params` actually
|
||||
/// expects.
|
||||
#[test]
|
||||
fn ganged_param_space_projects_to_one_public_address() {
|
||||
let names: Vec<String> = ganged_pair().param_space().into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(names, ["length"], "one public knob must replace a.length/b.length");
|
||||
|
||||
let twin_names: Vec<String> =
|
||||
unganged_pair().param_space().into_iter().map(|p| p.name).collect();
|
||||
assert_eq!(twin_names, ["a.length", "b.length"], "twin keeps both addresses independently open");
|
||||
}
|
||||
|
||||
/// THE load-bearing property (compile-front point expansion, at the public
|
||||
/// boundary): binding the gang's single public value via `compile_with_params`
|
||||
/// expands to the identical raw per-member value the un-ganged twin gets when
|
||||
/// EVERY member is bound explicitly to that same value — both the flat wiring
|
||||
/// (`FlatGraph::edges`) and the recorded run trace match, byte-for-byte,
|
||||
/// through only the exported `aura_engine`/`aura_std` surface. A regression in
|
||||
/// `expansion_map`'s raw-slot projection would either desync the compiled
|
||||
/// wiring from the un-ganged twin or silently apply the gang's value to only
|
||||
/// one member — this pins both away.
|
||||
#[test]
|
||||
fn ganged_compile_expands_to_member_bound_twin() {
|
||||
let ganged = ganged_pair().compile_with_params(&[Scalar::i64(3)]).expect("ganged compiles");
|
||||
let twin = unganged_pair()
|
||||
.compile_with_params(&[Scalar::i64(3), Scalar::i64(3)])
|
||||
.expect("twin compiles");
|
||||
assert_eq!(ganged.edges, twin.edges, "ganged wiring diverged from the member-bound twin");
|
||||
|
||||
let ganged_trace = run_recording(ganged_pair(), &[Scalar::i64(3)]);
|
||||
let twin_trace = run_recording(unganged_pair(), &[Scalar::i64(3), Scalar::i64(3)]);
|
||||
assert!(!ganged_trace.is_empty(), "the price fixture must warm up both SMAs and record a spread");
|
||||
assert_eq!(ganged_trace, twin_trace, "ganged run diverged from the member-bound twin");
|
||||
}
|
||||
|
||||
/// Property (injectivity extends to gang addresses, at the public boundary):
|
||||
/// two structurally-valid, disjoint gangs that happen to share a public name
|
||||
/// are refused by `compile_with_params` with the named
|
||||
/// `CompileError::DuplicateParamPath`, exactly like two colliding non-ganged
|
||||
/// addresses — never silently merged into one knob driving two unrelated
|
||||
/// gangs. Reachable using only the exported `Composite`/`Gang`/`GangMember`/
|
||||
/// `CompileError` surface.
|
||||
#[test]
|
||||
fn gang_name_collision_refused_at_compile() {
|
||||
let c = Composite::new(
|
||||
"sig",
|
||||
vec![
|
||||
Sma::builder().named("a").into(),
|
||||
Sma::builder().named("b").into(),
|
||||
Sma::builder().named("c").into(),
|
||||
Sma::builder().named("d").into(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.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() },
|
||||
],
|
||||
},
|
||||
Gang {
|
||||
name: "length".into(),
|
||||
kind: ScalarKind::I64,
|
||||
members: vec![
|
||||
GangMember { node: 2, pos: 0, name: "length".into() },
|
||||
GangMember { node: 3, pos: 0, name: "length".into() },
|
||||
],
|
||||
},
|
||||
])
|
||||
.expect("two structurally-valid gangs, same name, disjoint members");
|
||||
|
||||
let err = c.compile_with_params(&[Scalar::i64(2), Scalar::i64(2)]).err().unwrap();
|
||||
assert!(
|
||||
matches!(err, CompileError::DuplicateParamPath(ref p) if p == "length"),
|
||||
"expected a named DuplicateParamPath collision, got {err:?}",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user