//! End-to-end coverage for the random param-sweep axis (C12.1): //! `RandomSpace` + `ParamRange` driven through the **public** `sweep` surface a //! downstream researcher actually writes (the worked author example). //! //! The in-module unit tests in `sweep.rs` reach into crate internals //! (`bootstrap_with_cells`, `sweep_with_threads`, `SplitMix64`); these tests use //! only the exported API, so they pin the properties a real consumer observes: //! a `SweepFamily` of `RunReport`s, the typed `SweepError` gate, and the //! `named_params` view. The blueprint is reconstructed here (the crate-private //! `test_fixtures` harness is unreachable from an integration test) through the //! public `Composite` builder + `aura-std` nodes, so the test exercises the same //! published surface the worked example does. use std::sync::mpsc; use aura_core::{Cell, Firing, ScalarKind, Timestamp}; use aura_engine::{ f64_field, summarize, sweep, BlueprintNode, Composite, Edge, OutField, ParamRange, ParamSpec, RandomSpace, Role, RunManifest, RunReport, Scalar, Space, SweepError, Target, VecSource, }; use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; /// Seven synthetic F64 ticks (mirrors the crate-private `synthetic_prices`): /// short enough that small SMA windows warm up, so every run yields finite, /// non-degenerate metrics. Deterministic input fixture. fn synthetic_prices() -> Vec<(Timestamp, Scalar)> { [ (1_i64, 1.0000_f64), (2, 1.0010), (3, 1.0030), (4, 1.0060), (5, 1.0040), (6, 1.0010), (7, 0.9990), ] .iter() .map(|&(t, p)| (Timestamp(t), Scalar::f64(p))) .collect() } /// The SMA-cross signal-quality harness built through the PUBLIC builder API: /// `[fast: I64, slow: I64, scale: F64]` param-space, ending in an equity sink and /// an exposure sink. Returns the blueprint plus its two recording receivers (a /// fresh channel pair per build, so each swept point runs disjointly — C1). #[allow(clippy::type_complexity)] fn sma_cross_harness() -> ( Composite, mpsc::Receiver<(Timestamp, Vec)>, mpsc::Receiver<(Timestamp, Vec)>, ) { let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let sma_cross = Composite::new( "sma_cross", vec![ Sma::builder().named("fast").into(), Sma::builder().named("slow").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: None, }], vec![OutField { node: 2, field: 0, name: "out".into() }], ); let bp = Composite::new( "root", vec![ BlueprintNode::Composite(sma_cross), Exposure::builder().into(), SimBroker::builder(0.0001).into(), Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(), Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(), ], vec![ Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Exposure Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0 Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink ], vec![Role { name: "src".into(), targets: vec![ Target { node: 0, slot: 0 }, // price -> sma_cross role 0 Target { node: 2, slot: 1 }, // price -> SimBroker price slot ], source: Some(ScalarKind::F64), }], vec![], // root ends in sinks ); (bp, rx_eq, rx_ex) } /// Build + bootstrap + run + summarize one swept point into a `RunReport`, using /// only the public surface. A fresh harness (fresh sink channels) per point keeps /// the runs disjoint (C1). The manifest is a fixed minimal fixture — only the /// metrics carry the run, so determinism makes this reproduce a point exactly. fn run_point(point: &[Cell]) -> RunReport { let (bp, rx_eq, rx_ex) = sma_cross_harness(); let mut h = bp .bootstrap_with_cells(point) .expect("RandomSpace-drawn points are pre-validated against the param-space"); h.run(vec![Box::new(VecSource::new(synthetic_prices()))]); let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); RunReport { manifest: RunManifest { commit: "random-sweep-e2e".to_string(), params: Vec::new(), window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "test".to_string(), }, metrics: summarize(&equity, &exposure), } } /// A `RandomSpace` over the harness's `[fast, slow, scale]` param-space: integer /// windows drawn from the 7-tick fixture's proven domain (so SMAs warm up) and a /// continuous scale. The integer ranges are exact single points so the family /// stays small but every kind/arm is exercised. fn sma_cross_random(count: usize, seed: u64) -> RandomSpace { let space = sma_cross_harness().0.param_space(); RandomSpace::new( &space, vec![ ParamRange::i64(2, 3), // fast in [2, 3] ParamRange::i64(4, 5), // slow in [4, 5] ParamRange::f64(0.25, 1.5), // scale in [0.25, 1.5) ], count, seed, ) .expect("ranges match the sample param-space kinds") } /// Property: a `RandomSpace` sweep is fully seed-determined end-to-end — the same /// `(ranges, count, seed)` driven through the public `sweep` produces a /// bit-identical `SweepFamily` of `RunReport`s, run after run (C1). The whole /// pipeline (seeded draw -> bootstrap -> run -> summarize) reproduces, observed at /// the published JSON boundary, not the internal `points()`. #[test] fn random_sweep_is_reproducible_at_the_report_boundary() { let render = |seed: u64| -> Vec { sweep(&sma_cross_random(8, seed), run_point) .points .iter() .map(|p| p.report.to_json()) .collect() }; let a = render(0xC0FFEE); let b = render(0xC0FFEE); assert_eq!(a.len(), 8, "count points were swept"); assert_eq!(a, b, "same (ranges, count, seed) => bit-identical family (C1)"); } /// Property: a different seed produces a different family — the sweep genuinely /// samples the seed, it does not collapse to a constant set of points. Observed /// via the public `named_params` coordinate view, never an internal field. #[test] fn random_sweep_seed_changes_the_family() { let coords = |seed: u64| -> Vec> { let family = sweep(&sma_cross_random(8, seed), run_point); (0..family.points.len()) .map(|i| family.named_params(i).into_iter().map(|(_, v)| v).collect()) .collect() }; assert_ne!( coords(1), coords(2), "different seeds => different swept coordinate sets", ); } /// Property: every coordinate the sweep actually ran on lies inside its declared /// `ParamRange` — the I64 slots inclusive `[lo, hi]`, the F64 slot half-open /// `[lo, hi)`. A regression that let a draw escape its range would silently run /// the strategy out of its declared domain; this pins the bound at the observable /// `named_params` view of the family that was run. #[test] fn swept_points_stay_inside_their_declared_ranges() { let family = sweep(&sma_cross_random(200, 0xABCDEF), run_point); assert_eq!(family.points.len(), 200); for i in 0..family.points.len() { let named = family.named_params(i); let fast = named[0].1.as_i64(); let slow = named[1].1.as_i64(); let scale = named[2].1.as_f64(); assert!((2..=3).contains(&fast), "fast in [2,3] inclusive, got {fast}"); assert!((4..=5).contains(&slow), "slow in [4,5] inclusive, got {slow}"); assert!((0.25..1.5).contains(&scale), "scale in [0.25,1.5), got {scale}"); // and the run that consumed this in-range point produced a finite metric assert!(family.points[i].report.metrics.total_pips.is_finite()); } } /// Property: the typed validation gate rejects a non-numeric param slot BEFORE /// any run. A `Bool` slot cannot carry a continuous range (it is degenerate), so /// `RandomSpace::new` returns the public `SweepError::NonNumericRange` value — an /// observable typed error at the published API, not a panic and not a swept run. #[test] fn bool_slot_is_rejected_as_non_numeric_before_any_run() { let space = vec![ParamSpec { name: "flag".into(), kind: ScalarKind::Bool }]; let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0) .expect_err("a Bool slot is not range-sampleable"); assert_eq!(err, SweepError::NonNumericRange { slot: 0, kind: ScalarKind::Bool }); } /// Property: a `count == 0` `RandomSpace` is a valid, explicit empty family (not /// the "accidental collapse" an empty grid axis would be) — `sweep` over it /// returns an empty `SweepFamily` while still carrying the param-space schema, so /// a downstream `named_params` consumer sees a well-formed empty result. #[test] fn zero_count_sweep_is_a_well_formed_empty_family() { let space = sma_cross_harness().0.param_space(); let rs = sma_cross_random(0, 0); assert!(rs.is_empty(), "count == 0 is the explicit empty space"); let family = sweep(&rs, run_point); assert!(family.points.is_empty(), "no points swept"); assert_eq!(family.space, space, "the empty family still carries the schema"); } /// Property: `GridSpace` and `RandomSpace` are interchangeable through the `Space` /// trait `sweep` is generic over — the same generic helper drives either /// enumeration. This is the trait abstraction the cut introduced, observed via the /// public `Space::param_specs`, kept from regressing back to a `GridSpace`-only /// `sweep` signature. #[test] fn random_space_is_driven_through_the_space_trait() { fn schema_len(s: &S) -> usize { s.param_specs().len() } let rs = sma_cross_random(4, 7); assert_eq!(schema_len(&rs), 3, "the [fast, slow, scale] schema reaches the trait surface"); // and the generic `sweep` accepts it by value of the same bound let family = sweep(&rs, run_point); assert_eq!(family.points.len(), 4); }