diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index c207c81..178010c 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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 { 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 = map.iter().map(|&j| point[j]).collect(); + let mut flat_nodes: Vec> = Vec::new(); let mut flat_signatures: Vec = Vec::new(); let mut flat_edges: Vec = 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) { - 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) { + 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) { + 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> = 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 = 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)> { + 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; diff --git a/crates/aura-engine/tests/gang_e2e.rs b/crates/aura-engine/tests/gang_e2e.rs new file mode 100644 index 0000000..4aa12d3 --- /dev/null +++ b/crates/aura-engine/tests/gang_e2e.rs @@ -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)> { + 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 = 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 = + 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:?}", + ); +}