//! Param-sweep (C12.1): enumerate a blueprint's param-space — either a cartesian //! `GridSpace` (a discrete per-slot lattice) or a seeded `RandomSpace` (`N` draws //! over typed continuous ranges) — and run each point disjointly (C1). Both //! enumerations implement the `Space` trait that `sweep` is generic over, so //! either runs through one execution path. This module owns enumeration //! (`GridSpace` / `RandomSpace` / the `Space` trait), execution (`sweep`), and //! collection (`SweepFamily`); the per-point run-to-metrics closure is the //! author's (harness-specific sink glue the engine cannot generically own — C8/C18). 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 /// value-list per param slot, in `param_space()` order. Enumerates a family of /// points (C12.1 grid axis). #[derive(Debug)] pub struct GridSpace { space: Vec, axes: Vec>, } impl GridSpace { /// Validate `axes` against `space` (the blueprint's `param_space()`): one /// axis per slot (`Arity`), every value the slot's declared kind /// (`KindMismatch`), no empty axis (`EmptyAxis`, which would yield zero /// points). On success the grid enumerates `∏ |axis_i|` points. pub fn new(space: &[ParamSpec], axes: Vec>) -> Result { if axes.len() != space.len() { return Err(SweepError::Arity { expected: space.len(), got: axes.len() }); } for (slot, (axis, ps)) in axes.iter().zip(space).enumerate() { if axis.is_empty() { return Err(SweepError::EmptyAxis { slot }); } for (value_index, v) in axis.iter().enumerate() { if v.kind() != ps.kind { return Err(SweepError::KindMismatch { slot, value_index, expected: ps.kind, got: v.kind(), }); } } } // author edge: the values were just kind-checked above; strip the tag and // store the enumerated grid as tag-free cells (the kind now lives once, in // `space`). let axes = axes.iter().map(|ax| ax.iter().map(|s| s.cell()).collect()).collect(); Ok(Self { space: space.to_vec(), axes }) } /// The number of grid points (`∏ |axis_i|`), always `>= 1` (a valid grid has /// no empty axis; a zero-param grid is the single empty point). pub fn len(&self) -> usize { self.axes.iter().map(|a| a.len()).product() } /// Always `false` — a valid `GridSpace` rejects empty axes, so it has at /// least one point. Present to satisfy clippy's `len_without_is_empty`. pub fn is_empty(&self) -> bool { false } /// Per-axis cardinalities in `param_space()` order (the odometer radixes, /// last-axis-fastest). `∏ axis_lens() == len()`. The lattice shape a plateau /// neighbourhood walks (cycle 0077). pub fn axis_lens(&self) -> Vec { self.axes.iter().map(Vec::len).collect() } /// The cartesian product, in odometer order: the **last** axis varies /// fastest. Deterministic — the same grid yields the same point sequence. pub fn points(&self) -> Vec> { let mut out = Vec::with_capacity(self.len()); let mut idx = vec![0usize; self.axes.len()]; loop { out.push(self.axes.iter().zip(&idx).map(|(a, &i)| a[i]).collect()); // odometer increment from the last axis let mut k = self.axes.len(); loop { if k == 0 { return out; } k -= 1; idx[k] += 1; if idx[k] < self.axes[k].len() { break; } idx[k] = 0; } } } /// The param-space (names + kinds) this grid was validated against, retained /// for the family to carry — the derived-name source (C23: names, not identity). pub fn param_specs(&self) -> &[ParamSpec] { &self.space } } /// 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` or a `RandomSpace` — the shared /// typed gate before any run (grid faults: `Arity` / `KindMismatch` / `EmptyAxis`; /// random faults: `NonNumericRange` / `RangeKindMismatch` / `EmptyRange`). #[derive(Clone, Debug, PartialEq, Eq)] pub enum SweepError { /// The number of axes does not equal the param-space slot count. Arity { expected: usize, got: usize }, /// A grid value's kind does not match its slot's declared kind. `slot` is the /// flat param-space index; `value_index` is the position within that axis. 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() } /// `true` iff the range admits no value: I64 `lo > hi`, F64 `lo >= hi`. The home /// of the non-empty invariant the named binder pre-checks. pub fn is_empty(&self) -> bool { match self.kind() { ScalarKind::I64 => self.lo.as_i64() > self.hi.as_i64(), _ => self.lo.as_f64() >= self.hi.as_f64(), } } } /// `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.) 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. /// Self-describing: `params` is the point's coordinate in `param_space()` order, /// and `report` carries the run's `(manifest, metrics)` — the unit the run /// registry indexes (C18). #[derive(Clone, Debug, PartialEq)] pub struct SweepPoint { pub params: Vec, pub report: RunReport, } /// The ordered result family of a sweep — one `SweepPoint` per grid point, in /// enumeration (odometer) order, independent of thread completion order. #[derive(Clone, Debug, PartialEq)] pub struct SweepFamily { pub space: Vec, pub points: Vec, } impl SweepFamily { /// The i-th point's params paired with their names — a derived view over the /// carried param-space (reuses [`zip_params`]); no new per-point state. pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> { zip_params(&self.space, &self.points[i].params) } } /// 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: &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); sweep_with_threads(space, nthreads, run_one) } /// Run `n` disjoint jobs in parallel and collect their results in **job-index /// order** — the shared disjoint-parallel core both `sweep` (over grid points) /// and `monte_carlo` (over seeds) drive (C1: order is the input order, not the /// completion order). Workers pull job indices from a shared atomic cursor /// (work-stealing load-balances uneven per-job cost); each tags its result with /// the index, and the results are sorted on that index after the scope joins. /// Only the cursor is shared; the results side is lock-free. `nthreads` is /// clamped to `[1, n.max(1)]` (a 0-job call yields an empty vec; a 0 thread /// count coerces to 1). pub(crate) fn run_indexed(n: usize, nthreads: usize, run_one: F) -> Vec where T: Send, F: Fn(usize) -> T + Sync, { let nthreads = nthreads.clamp(1, n.max(1)); let cursor = AtomicUsize::new(0); let mut results: Vec<(usize, T)> = std::thread::scope(|scope| { let handles: Vec<_> = (0..nthreads) .map(|_| { scope.spawn(|| { let mut local: Vec<(usize, T)> = Vec::new(); loop { let i = cursor.fetch_add(1, Ordering::Relaxed); if i >= n { break; } local.push((i, run_one(i))); } local }) }) .collect(); handles.into_iter().flat_map(|h| h.join().unwrap()).collect() }); results.sort_by_key(|&(i, _)| i); results.into_iter().map(|(_, t)| t).collect() } /// 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 `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(); let reports = run_indexed(points.len(), nthreads, |i| run_one(&points[i])); SweepFamily { space: space.param_specs().to_vec(), points: points .into_iter() .zip(reports) .map(|(params, report)| SweepPoint { params, report }) .collect(), } } #[cfg(test)] mod tests { use super::*; use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices}; use crate::{f64_field, summarize, RunManifest, VecSource}; use aura_core::{Cell, ParamSpec, Scalar, ScalarKind, Timestamp}; fn i64_space(n: usize) -> Vec { (0..n) .map(|i| ParamSpec { name: format!("p{i}"), kind: ScalarKind::I64 }) .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); let grid = GridSpace::new( &space, vec![ vec![Scalar::i64(2), Scalar::i64(3)], vec![Scalar::i64(4), Scalar::i64(5)], ], ) .expect("valid grid"); assert_eq!( grid.points(), vec![ vec![Cell::from_i64(2), Cell::from_i64(4)], vec![Cell::from_i64(2), Cell::from_i64(5)], vec![Cell::from_i64(3), Cell::from_i64(4)], vec![Cell::from_i64(3), Cell::from_i64(5)], ], ); } #[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![ ParamSpec { name: "a".into(), kind: ScalarKind::I64 }, ParamSpec { name: "b".into(), kind: ScalarKind::I64 }, ParamSpec { name: "c".into(), kind: ScalarKind::F64 }, ]; let grid = GridSpace::new( &space, vec![ vec![Scalar::i64(2), Scalar::i64(3)], vec![Scalar::i64(4), Scalar::i64(5)], vec![Scalar::f64(0.5)], ], ) .expect("valid grid"); assert_eq!(grid.len(), 4); assert!(!grid.is_empty()); } #[test] fn grid_axis_lens_are_the_per_axis_radixes() { let space = vec![ ParamSpec { name: "a".into(), kind: ScalarKind::I64 }, ParamSpec { name: "b".into(), kind: ScalarKind::I64 }, ]; let grid = GridSpace::new(&space, vec![ vec![Scalar::i64(10), Scalar::i64(20)], // axis 0: 2 values vec![Scalar::i64(1), Scalar::i64(2), Scalar::i64(3)], // axis 1: 3 values ]).expect("2x3 grid"); assert_eq!(grid.axis_lens(), vec![2, 3]); assert_eq!(grid.axis_lens().iter().product::(), grid.len()); } #[test] fn arity_mismatch_is_an_error() { let space = i64_space(2); let err = GridSpace::new(&space, vec![vec![Scalar::i64(2)]]).unwrap_err(); assert_eq!(err, SweepError::Arity { expected: 2, got: 1 }); } #[test] fn wrong_kind_is_a_kind_mismatch() { let space = i64_space(1); let err = GridSpace::new(&space, vec![vec![Scalar::f64(1.0)]]).unwrap_err(); assert_eq!( err, SweepError::KindMismatch { slot: 0, value_index: 0, expected: ScalarKind::I64, got: ScalarKind::F64, }, ); } #[test] fn empty_axis_is_an_error() { let space = i64_space(1); let err = GridSpace::new(&space, vec![vec![]]).unwrap_err(); assert_eq!(err, SweepError::EmptyAxis { slot: 0 }); } /// Build + bootstrap + run + drain + summarize one grid point into a /// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `sweep` closure /// AND a direct reference for the "sweep == N independent runs" comparison. /// The manifest is a minimal fixed fixture — the metrics are the run's, and /// determinism makes `run_point` reproduce a point's report exactly. fn run_point(point: &[Cell]) -> RunReport { let (bp, rx_eq, rx_ex) = composite_sma_cross_harness(); let mut h = bp .bootstrap_with_cells(point) .expect("enumerated grid points are pre-validated by GridSpace::new"); 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: "test".to_string(), params: Vec::new(), window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "test".to_string(), selection: None, instrument: None, topology_hash: None, }, metrics: summarize(&equity, &exposure), } } fn sma_cross_grid() -> GridSpace { let space = composite_sma_cross_harness().0.param_space(); GridSpace::new( &space, vec![ vec![Scalar::i64(2), Scalar::i64(3)], // fast ∈ {2, 3} vec![Scalar::i64(4), Scalar::i64(5)], // slow ∈ {4, 5} vec![Scalar::f64(0.5)], // scale ∈ {0.5} ], ) .expect("grid matches the sample param-space") } #[test] fn sweep_equals_n_independent_runs() { let grid = sma_cross_grid(); let family = sweep(&grid, run_point); assert_eq!(family.points.len(), 4); // params carried in enumeration (odometer) order, as tag-free cells (the // kind lives once, in `family.space`) assert_eq!( family.points[0].params, vec![Cell::from_i64(2), Cell::from_i64(4), Cell::from_f64(0.5)], ); assert_eq!( family.points[3].params, vec![Cell::from_i64(3), Cell::from_i64(5), Cell::from_f64(0.5)], ); // each point's metrics equal a direct, independent run of the same point: // the 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 sweep_family_carries_param_space() { let space = composite_sma_cross_harness().0.param_space(); let family = sweep(&sma_cross_grid(), run_point); assert_eq!(family.space, space); } #[test] fn family_named_params_round_trips() { let space = composite_sma_cross_harness().0.param_space(); let family = sweep(&sma_cross_grid(), run_point); // odometer-first point is [I64(2), I64(4), F64(0.5)] 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); assert_eq!(family.named_params(0)[0].1, Scalar::i64(2)); } #[test] fn family_is_deterministic_across_thread_counts() { let grid = sma_cross_grid(); let one = sweep_with_threads(&grid, 1, run_point); let many = sweep_with_threads(&grid, 8, run_point); // same family at 1 worker and at N (C1: order = enumeration, not completion) assert_eq!(one, many); // and identical to the public `sweep`, which derives the worker count assert_eq!(one, sweep(&grid, run_point)); } #[test] fn distinct_points_produce_distinct_metrics() { let family = sweep(&sma_cross_grid(), run_point); // not a constant family: differing SMA lengths produce differing equity let first = family.points[0].report.metrics.total_pips; assert!( family.points.iter().any(|p| p.report.metrics.total_pips != first), "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); } }