diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index 1c2d57a..6bb6aa1 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -128,15 +128,15 @@ pub fn window_of(sources: &[Box]) -> Option<(Timestamp, Timestamp)> /// completely determines the sequence; no external entropy, no global state. /// Bit-stable across toolchains and crate versions — the property C1 needs for /// seed-as-input reproducibility (C12). -struct SplitMix64 { +pub(crate) struct SplitMix64 { state: u64, } impl SplitMix64 { - fn new(seed: u64) -> Self { + pub(crate) fn new(seed: u64) -> Self { Self { state: seed } } - fn next_u64(&mut self) -> u64 { + pub(crate) fn next_u64(&mut self) -> u64 { self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); let mut z = self.state; z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); @@ -144,7 +144,7 @@ impl SplitMix64 { z ^ (z >> 31) } /// A uniform `f64` in `[0, 1)` from the top 53 bits. - fn next_f64(&mut self) -> f64 { + pub(crate) fn next_f64(&mut self) -> f64 { (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64) } } diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 9d8b199..e0a49eb 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -62,7 +62,9 @@ pub use harness::{ VecSource, }; pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport}; -pub use sweep::{sweep, GridSpace, SweepError, SweepFamily, SweepPoint}; +pub use sweep::{ + sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint, +}; pub use mc::{monte_carlo, McAggregate, McDraw, McFamily, MetricStats}; pub use walkforward::{ param_stability, walk_forward, RollMode, WalkForwardError, WalkForwardResult, diff --git a/crates/aura-engine/src/sweep.rs b/crates/aura-engine/src/sweep.rs index 5a4ea1a..f23c1ec 100644 --- a/crates/aura-engine/src/sweep.rs +++ b/crates/aura-engine/src/sweep.rs @@ -6,6 +6,7 @@ use aura_core::{zip_params, Cell, ParamSpec, Scalar, ScalarKind}; use crate::RunReport; +use crate::harness::SplitMix64; use std::sync::atomic::{AtomicUsize, Ordering}; /// A validated cartesian grid over a blueprint's param-space: one discrete @@ -90,6 +91,30 @@ impl GridSpace { } } +/// The enumeration interface `sweep` runs over: a producer of param-space points. +/// Both `GridSpace` (cartesian product) and `RandomSpace` (seeded draws) implement +/// it, so the disjoint execution core (`run_indexed`) carries either enumeration +/// through one path (C1). +pub trait Space { + /// The enumerated points, each a tag-free coordinate in `param_specs()` order + /// (the kind lives once, in `param_specs()`). + fn points(&self) -> Vec>; + /// The param-space (names + kinds) the points are coordinates in. + fn param_specs(&self) -> &[ParamSpec]; +} + +impl Space for GridSpace { + // `GridSpace::points(self)` is path syntax that selects the *inherent* method + // (inherent methods win method resolution), so this forward does not recurse; + // the grid path is behaviour-preserving (C1). + fn points(&self) -> Vec> { + GridSpace::points(self) + } + fn param_specs(&self) -> &[ParamSpec] { + GridSpace::param_specs(self) + } +} + /// A structural fault constructing a `GridSpace` — the typed gate before any run. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SweepError { @@ -100,6 +125,141 @@ pub enum SweepError { KindMismatch { slot: usize, value_index: usize, expected: ScalarKind, got: ScalarKind }, /// A slot was given no values (would collapse the product to zero points). EmptyAxis { slot: usize }, + /// A `RandomSpace` slot's declared kind is not range-sampleable (`I64`/`F64`): + /// a `Bool` is degenerate, a `Timestamp` is a structural axis (C20). + NonNumericRange { slot: usize, kind: ScalarKind }, + /// A `ParamRange`'s kind does not match its slot's declared kind. + RangeKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind }, + /// A `ParamRange` admits no sampleable value: `lo > hi` for an inclusive I64 + /// range, or `lo >= hi` for a half-open F64 range. + EmptyRange { slot: usize }, +} + +/// A typed, kind-tagged continuous range for one `RandomSpace` param slot, carried +/// positional-parallel to the param-space. `lo`/`hi` share a kind by construction; +/// this is the home for the non-empty-range invariant (validated in +/// [`RandomSpace::new`]) and, later, a distribution tag. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ParamRange { + pub lo: Scalar, + pub hi: Scalar, +} + +impl ParamRange { + /// An inclusive `[lo, hi]` I64 range. + pub fn i64(lo: i64, hi: i64) -> Self { + Self { lo: Scalar::i64(lo), hi: Scalar::i64(hi) } + } + /// A half-open `[lo, hi)` F64 range. + pub fn f64(lo: f64, hi: f64) -> Self { + Self { lo: Scalar::f64(lo), hi: Scalar::f64(hi) } + } + /// The kind of this range (`lo`/`hi` share it by construction). + pub fn kind(&self) -> ScalarKind { + self.lo.kind() + } +} + +/// `count` seeded uniform points over per-slot continuous `ranges`, validated +/// against a blueprint's param-space — the random sibling to `GridSpace` (C12.1). +/// The points are fully determined by `seed` before any run (C1). +#[derive(Debug)] +pub struct RandomSpace { + space: Vec, + ranges: Vec, + count: usize, + seed: u64, +} + +impl RandomSpace { + /// Validate `ranges` against `space` (the blueprint's `param_space()`): one + /// range per slot (`Arity`); each slot numeric, i.e. `I64`/`F64` + /// (`NonNumericRange` otherwise); each range's kind == the slot's declared kind + /// (`RangeKindMismatch`); a non-empty range (`EmptyRange`: I64 `lo > hi`, F64 + /// `lo >= hi`). A `count` of 0 is valid and yields an empty family. + pub fn new( + space: &[ParamSpec], + ranges: Vec, + count: usize, + seed: u64, + ) -> Result { + if ranges.len() != space.len() { + return Err(SweepError::Arity { expected: space.len(), got: ranges.len() }); + } + for (slot, (r, ps)) in ranges.iter().zip(space).enumerate() { + if !matches!(ps.kind, ScalarKind::I64 | ScalarKind::F64) { + return Err(SweepError::NonNumericRange { slot, kind: ps.kind }); + } + if r.kind() != ps.kind { + return Err(SweepError::RangeKindMismatch { slot, expected: ps.kind, got: r.kind() }); + } + // a range must admit at least one value: I64 [lo,hi] is non-empty iff + // lo <= hi (lo==hi is the valid single point); F64 [lo,hi) is non-empty + // iff lo < hi (at lo==hi the half-open interval is empty -> rejected). + let empty = match ps.kind { + ScalarKind::I64 => r.lo.as_i64() > r.hi.as_i64(), + _ => r.lo.as_f64() >= r.hi.as_f64(), + }; + if empty { + return Err(SweepError::EmptyRange { slot }); + } + } + Ok(Self { space: space.to_vec(), ranges, count, seed }) + } + + /// The number of points this space draws (`count`). + pub fn len(&self) -> usize { + self.count + } + + /// `true` iff `count == 0` (an explicit empty family). Present alongside + /// `len` to satisfy clippy's `len_without_is_empty`. + pub fn is_empty(&self) -> bool { + self.count == 0 + } +} + +impl Space for RandomSpace { + fn param_specs(&self) -> &[ParamSpec] { + &self.space + } + /// `count` points drawn from a single `SplitMix64` seeded with `self.seed`; + /// per point, each slot is sampled in declared `param_specs()` order (points in + /// sequence, slots within a point in order). Deterministic: same + /// `(ranges, count, seed)` => identical points, identical to a re-run (C1). + /// This RNG instance is code-path-disjoint from the data-edge seed RNG (the + /// #52/#71 World-II firewall): they share only the `u64` type, never a path. + fn points(&self) -> Vec> { + let mut rng = SplitMix64::new(self.seed); + (0..self.count) + .map(|_| { + self.ranges + .iter() + .map(|r| match r.kind() { + // inclusive [lo, hi]; span via i128 then u64 handles a negative lo. + // (Modulo bias for spans not dividing 2^64 is an accepted, + // documented simplification — param search needs no crypto + // uniformity, spec §"Error handling".) + ScalarKind::I64 => { + let (lo, hi) = (r.lo.as_i64(), r.hi.as_i64()); + // span is 1..=2^64; the full-width span [i64::MIN, i64::MAX] + // is exactly 2^64 and wraps the u64 counter to 0, so guard it: + // a 0 span means "any i64", a single raw draw (no modulo). + let span = (hi as i128 - lo as i128 + 1) as u64; + let draw = if span == 0 { rng.next_u64() } else { rng.next_u64() % span }; + Cell::from_i64(lo.wrapping_add(draw as i64)) + } + // half-open [lo, hi) + ScalarKind::F64 => { + let (lo, hi) = (r.lo.as_f64(), r.hi.as_f64()); + Cell::from_f64(lo + rng.next_f64() * (hi - lo)) + } + _ => unreachable!("RandomSpace::new rejects non-numeric ranges"), + }) + .collect() + }) + .collect() + } } /// One enumerated point and the full `RunReport` its run produced. @@ -128,13 +288,16 @@ impl SweepFamily { } } -/// Run `run_one` over every grid point, disjointly in parallel (C1), and collect -/// the family in enumeration order. `run_one` builds + bootstraps + runs + +/// Run `run_one` over every point the `space` produces (`Space::points` — +/// `GridSpace`'s cartesian product or `RandomSpace`'s seeded draws), disjointly +/// in parallel (C1), and collect the family in enumeration order. `run_one` +/// builds + bootstraps + runs + /// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs /// are lock-free. Parallelism is `available_parallelism()` workers — `std` only, /// via `std::thread::scope`. -pub fn sweep(space: &GridSpace, run_one: F) -> SweepFamily +pub fn sweep(space: &S, run_one: F) -> SweepFamily where + S: Space, F: Fn(&[Cell]) -> RunReport + Sync, { let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); @@ -184,10 +347,12 @@ where /// The thread-count-explicit core of [`sweep`]. Module-private: the public /// `sweep` derives the count, while the tests drive it at 1 and at N to pin /// determinism under concurrency (C1). A thin adapter over [`run_indexed`]: it -/// enumerates the grid points, runs each disjointly, and zips the reports back -/// onto their points in enumeration (odometer) order. -fn sweep_with_threads(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily +/// enumerates the `space`'s param-space points (`Space::points`), runs each +/// disjointly, and zips the reports back onto their points in enumeration +/// (odometer) order. +fn sweep_with_threads(space: &S, nthreads: usize, run_one: F) -> SweepFamily where + S: Space, F: Fn(&[Cell]) -> RunReport + Sync, { let points = space.points(); @@ -215,6 +380,154 @@ mod tests { .collect() } + fn one_i64_space() -> Vec { + vec![ParamSpec { name: "p0".into(), kind: ScalarKind::I64 }] + } + + #[test] + fn random_space_new_arity_mismatch() { + let space = i64_space(2); + let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0).unwrap_err(); + assert_eq!(err, SweepError::Arity { expected: 2, got: 1 }); + } + + #[test] + fn random_space_new_non_numeric_slot() { + let space = vec![ParamSpec { name: "b".into(), kind: ScalarKind::Bool }]; + let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0).unwrap_err(); + assert_eq!(err, SweepError::NonNumericRange { slot: 0, kind: ScalarKind::Bool }); + } + + #[test] + fn random_space_new_range_kind_mismatch() { + let space = one_i64_space(); + let err = RandomSpace::new(&space, vec![ParamRange::f64(0.0, 1.0)], 10, 0).unwrap_err(); + assert_eq!( + err, + SweepError::RangeKindMismatch { slot: 0, expected: ScalarKind::I64, got: ScalarKind::F64 }, + ); + } + + #[test] + fn random_space_new_empty_i64_range() { + // inclusive [lo, hi] is empty iff lo > hi + let space = one_i64_space(); + let err = RandomSpace::new(&space, vec![ParamRange::i64(5, 4)], 10, 0).unwrap_err(); + assert_eq!(err, SweepError::EmptyRange { slot: 0 }); + } + + #[test] + fn random_space_new_empty_f64_range() { + // half-open [lo, hi) is empty at lo == hi + let space = vec![ParamSpec { name: "f".into(), kind: ScalarKind::F64 }]; + let err = RandomSpace::new(&space, vec![ParamRange::f64(1.0, 1.0)], 10, 0).unwrap_err(); + assert_eq!(err, SweepError::EmptyRange { slot: 0 }); + } + + #[test] + fn random_space_new_accepts_i64_single_point() { + // inclusive [lo, hi] with lo == hi is the valid single point {lo} + let space = one_i64_space(); + let rs = RandomSpace::new(&space, vec![ParamRange::i64(7, 7)], 3, 0) + .expect("lo == hi is a valid single-point I64 range"); + assert_eq!(rs.len(), 3); + } + + #[test] + fn random_space_len_and_is_empty() { + let space = one_i64_space(); + let rs = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 5, 0).unwrap(); + assert_eq!(rs.len(), 5); + assert!(!rs.is_empty()); + let empty = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 0, 0).unwrap(); + assert_eq!(empty.len(), 0); + assert!(empty.is_empty()); + } + + #[test] + fn param_range_constructors_carry_kind() { + let ri = ParamRange::i64(2, 50); + assert_eq!(ri.kind(), ScalarKind::I64); + assert_eq!(ri.lo, Scalar::i64(2)); + assert_eq!(ri.hi, Scalar::i64(50)); + + let rf = ParamRange::f64(0.1, 2.0); + assert_eq!(rf.kind(), ScalarKind::F64); + assert_eq!(rf.lo, Scalar::f64(0.1)); + assert_eq!(rf.hi, Scalar::f64(2.0)); + } + + #[test] + fn random_points_respect_bounds() { + let space = vec![ + ParamSpec { name: "i".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "f".into(), kind: ScalarKind::F64 }, + ]; + let ranges = vec![ParamRange::i64(2, 50), ParamRange::f64(0.1, 2.0)]; + let rs = RandomSpace::new(&space, ranges, 500, 0xC0FFEE).unwrap(); + let pts = rs.points(); + assert_eq!(pts.len(), 500); + for p in &pts { + let i = p[0].i64(); + let f = p[1].f64(); + assert!((2..=50).contains(&i), "I64 inclusive [2,50], got {i}"); + assert!((0.1..2.0).contains(&f), "F64 half-open [0.1,2.0), got {f}"); + } + } + + #[test] + fn random_points_full_range_i64_does_not_panic() { + // Property: `points()` samples a full-width inclusive I64 range without a + // divide-by-zero panic. The span [i64::MIN, i64::MAX] is exactly 2^64, + // which overflows the u64 span counter to 0; the sampler must treat a + // full-width span as "any i64" rather than `% 0`. + let space = one_i64_space(); + let rs = RandomSpace::new( + &space, + vec![ParamRange::i64(i64::MIN, i64::MAX)], + 64, + 0xABCD, + ) + .expect("a full-width inclusive I64 range is non-empty"); + let pts = rs.points(); + assert_eq!(pts.len(), 64); + // every drawn value is a valid i64 (the whole range is admissible); the + // assertion that matters is that the call above did not panic. + for p in &pts { + let _ = p[0].i64(); + } + } + + #[test] + fn random_points_are_deterministic() { + let space = one_i64_space(); + let mk = || { + RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 42) + .unwrap() + .points() + }; + assert_eq!(mk(), mk(), "same (ranges, count, seed) => identical points"); + } + + #[test] + fn random_points_seed_sensitive() { + let space = one_i64_space(); + let a = RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 1) + .unwrap() + .points(); + let b = RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 2) + .unwrap() + .points(); + assert_ne!(a, b, "different seeds => different point sequences"); + } + + #[test] + fn random_points_zero_count_is_empty() { + let space = one_i64_space(); + let rs = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 0, 0).unwrap(); + assert!(rs.points().is_empty()); + } + #[test] fn points_enumerate_in_odometer_order() { let space = i64_space(2); @@ -237,6 +550,20 @@ mod tests { ); } + #[test] + fn grid_space_satisfies_space_trait() { + // A generic helper that can only call the trait surface — proves GridSpace + // is usable through `Space`, the property `sweep` now relies on. + fn via_trait(s: &S) -> (Vec>, Vec) { + (s.points(), s.param_specs().to_vec()) + } + let grid = sma_cross_grid(); + let (pts, specs) = via_trait(&grid); + // the trait surface forwards to the inherent methods — identical results + assert_eq!(pts, grid.points()); + assert_eq!(specs, grid.param_specs().to_vec()); + } + #[test] fn len_is_product_of_axis_lengths() { let space = vec![ @@ -389,4 +716,60 @@ mod tests { "a 4-point SMA-length grid must not collapse to one metric", ); } + + fn sma_cross_random() -> RandomSpace { + let space = composite_sma_cross_harness().0.param_space(); + RandomSpace::new( + &space, + vec![ + // integer windows drawn from exactly the grid test's proven domain + // ({2,3}×{4,5}): all <= the 7-tick fixture length, so the SMAs warm + // up and metrics are finite & non-degenerate. scale is continuous + // (a bounded multiplier over a clamped exposure), spanning the grid's + // 0.5. + ParamRange::i64(2, 3), // fast ∈ [2, 3] + ParamRange::i64(4, 5), // slow ∈ [4, 5] + ParamRange::f64(0.25, 1.5), // scale ∈ [0.25, 1.5) + ], + 8, + 0xABCDEF, + ) + .expect("ranges match the sample param-space kinds") + } + + #[test] + fn random_sweep_equals_n_independent_runs() { + let rs = sma_cross_random(); + let family = sweep(&rs, run_point); + assert_eq!(family.points.len(), 8); + // each point's metrics equal a direct, independent run of the same point: + // the random sweep adds enumeration + execution, never a metrics change (C1). + for pt in &family.points { + assert_eq!(pt.report, run_point(&pt.params)); + assert!(pt.report.metrics.total_pips.is_finite()); + } + } + + #[test] + fn random_sweep_is_deterministic_across_thread_counts() { + let rs = sma_cross_random(); + let one = sweep_with_threads(&rs, 1, run_point); + let many = sweep_with_threads(&rs, 8, run_point); + // same family at 1 worker and at N (C1: order = enumeration, not completion) + assert_eq!(one, many); + assert_eq!(one, sweep(&rs, run_point)); + } + + #[test] + fn random_sweep_family_named_view_round_trips() { + let rs = sma_cross_random(); + let family = sweep(&rs, run_point); + let space = composite_sma_cross_harness().0.param_space(); + let expected: Vec<(String, Scalar)> = space + .iter() + .zip(&family.points[0].params) + .map(|(ps, c)| (ps.name.clone(), Scalar::from_cell(ps.kind, *c))) + .collect(); + assert_eq!(family.named_params(0), expected); + } } diff --git a/crates/aura-engine/tests/random_sweep_e2e.rs b/crates/aura-engine/tests/random_sweep_e2e.rs new file mode 100644 index 0000000..4944824 --- /dev/null +++ b/crates/aura-engine/tests/random_sweep_e2e.rs @@ -0,0 +1,243 @@ +//! End-to-end coverage for the random param-sweep axis (spec 0049, C12.1): +//! `RandomSpace` + `ParamRange` driven through the **public** `sweep` surface a +//! downstream researcher actually writes (the spec's "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); +}