//! 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:?}", ); }