diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 178010c..2143530 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -1154,7 +1154,7 @@ mod tests { use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices}; use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource}; use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp}; - use aura_std::{Bias, Ema, Recorder, SimBroker, Sma, Sub}; + use aura_std::{Bias, Ema, LinComb, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc; /// One knob fanning into two sibling open params passes the gate; the @@ -1447,6 +1447,152 @@ mod tests { assert!(!g_trace.is_empty()); } + /// Interleaved-gang fixture (architect [low] finding, cycle close): one gang + /// member sits at a NON-zero pos among its own node's open params + /// (`LinComb`'s `weights[1]`, with `weights[0]` still open and EARLIER in + /// walk order), the other member is a sibling node's sole param + /// (`Bias.scale`), and a THIRD node's independent param (`Sma.length`) walks + /// AFTER the gang — so `expansion_map`'s "un-ganged slot consumes the next + /// public index" branch must correctly shift past the gang in the MIDDLE of + /// the walk, not only at an edge of it. + fn ganged_interleave() -> Composite { + Composite::new( + "sig3", + vec![ + LinComb::builder(2).into(), + Bias::builder().into(), + Sma::builder().named("c").into(), + ], + vec![], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 0, slot: 1 }, + Target { node: 1, slot: 0 }, + Target { node: 2, slot: 0 }, + ], + source: Some(ScalarKind::F64), + }], + vec![ + OutField { node: 0, field: 0, name: "lincomb_out".into() }, + OutField { node: 1, field: 0, name: "bias_out".into() }, + OutField { node: 2, field: 0, name: "sma_out".into() }, + ], + ) + .with_gangs(vec![Gang { + name: "shared".into(), + kind: ScalarKind::F64, + members: vec![ + GangMember { node: 0, pos: 1, name: "weights[1]".into() }, + GangMember { node: 1, pos: 0, name: "scale".into() }, + ], + }]) + .expect("weights[1] (pos 1, not 0) ganged with bias.scale, both F64") + } + + /// The un-ganged twin of [`ganged_interleave`]: identical topology, all four + /// params (`lincomb.weights[0]`, `lincomb.weights[1]`, `bias.scale`, + /// `c.length`) stay independently open. + fn unganged_interleave() -> Composite { + Composite::new( + "sig3", + vec![ + LinComb::builder(2).into(), + Bias::builder().into(), + Sma::builder().named("c").into(), + ], + vec![], + vec![Role { + name: "price".into(), + targets: vec![ + Target { node: 0, slot: 0 }, + Target { node: 0, slot: 1 }, + Target { node: 1, slot: 0 }, + Target { node: 2, slot: 0 }, + ], + source: Some(ScalarKind::F64), + }], + vec![ + OutField { node: 0, field: 0, name: "lincomb_out".into() }, + OutField { node: 1, field: 0, name: "bias_out".into() }, + OutField { node: 2, field: 0, name: "sma_out".into() }, + ], + ) + } + + /// Wrap [`ganged_interleave`]/[`unganged_interleave`] under a recording root + /// and run the synthetic price fixture, taping all THREE composite outputs + /// (one per node): topology here is param-value-invariant (`edges` equality + /// alone would not catch a mis-shifted index landing a value on the wrong + /// node), so the trace over every affected node is what actually detects it. + fn run_triplet(bp: Composite, params: &[Scalar]) -> Vec<(Timestamp, Vec)> { + let (tx, rx) = mpsc::channel(); + let root = Composite::new( + "h3", + vec![ + BlueprintNode::Composite(bp), + Recorder::builder( + vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], + Firing::Any, + tx, + ) + .into(), + ], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, + Edge { from: 0, to: 1, slot: 1, from_field: 1 }, + Edge { from: 0, to: 1, slot: 2, from_field: 2 }, + ], + 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 property under test: `collect_params` (public `param_space` order) and + /// `expansion_map` (raw-slot -> public-index) mirror each other even when a + /// gang's first member is NOT its node's first open param and an independent + /// param on a LATER node follows the gang in walk order — the "shift-through" + /// branch (an un-ganged slot consuming the next public index around a gang) + /// fires in the MIDDLE of the walk, not only at an edge. Values are pairwise + /// distinct (7 / 4 / 3) so a mis-mapped index changes which node receives + /// which value, diverging the run trace. `c.length` stays small (3) so the + /// SMA warms up within the 7-row synthetic price fixture. + #[test] + fn ganged_interleave_shift_through_matches_member_bound_twin() { + let space = ganged_interleave().param_space(); + let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); + let kinds: Vec = space.iter().map(|p| p.kind).collect(); + assert_eq!( + names, + ["lincomb.weights[0]", "shared", "c.length"], + "the gang publishes at its first member's walk position, flanked by \ + an earlier independent param on the SAME node and a later \ + independent param on a DIFFERENT node" + ); + assert_eq!(kinds, [ScalarKind::F64, ScalarKind::F64, ScalarKind::I64]); + + let ganged_point = [Scalar::f64(7.0), Scalar::f64(4.0), Scalar::i64(3)]; + let twin_point = [Scalar::f64(7.0), Scalar::f64(4.0), Scalar::f64(4.0), Scalar::i64(3)]; + + let g = ganged_interleave().compile_with_params(&ganged_point).expect("ganged compile"); + let t = unganged_interleave().compile_with_params(&twin_point).expect("twin compile"); + assert_eq!(g.edges, t.edges, "identical wiring"); + + let g_trace = run_triplet(ganged_interleave(), &ganged_point); + let t_trace = run_triplet(unganged_interleave(), &twin_point); + 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;