feat(aura-engine): random param-sweep — RandomSpace + a Space trait + typed ParamRange
Ship the random half of the C12.1 param-sweep axis (grid landed in 0028).
A new `Space` trait (`points`/`param_specs`) generalizes `sweep`/`sweep_with_threads`
from `&GridSpace` to `&S: Space`; `GridSpace`'s trait impl forwards to its existing
inherent methods, so the grid path is behaviour-preserving (C1, every pre-existing
grid sweep test stays green). `RandomSpace` is the second `Space` producer: N seeded
uniform points over per-slot typed `ParamRange { lo, hi }` ranges (I64 inclusive
[lo,hi], F64 half-open [lo,hi)), validated in `new` against the param-space — a
non-numeric slot, a range-kind mismatch, and an empty range are the three new typed
`SweepError` variants, all caught before any run. Sampling reuses the existing
bit-stable `SplitMix64` (promoted to `pub(crate)`) as a code-path-disjoint instance
from the data-edge seed RNG — the #52/#71 World-II source-seam firewall: they share
only the `u64` type, never a path. The sweep-layer signature stays param-only
(`Fn(&[Cell]) -> RunReport`); no `Source`/stream type enters it.
Deviation from the spec's verbatim sampler, accepted as a correctness hardening:
the I64 draw guards the full-width span ([i64::MIN, i64::MAX] computes a span of
2^64 that wraps a u64 to 0) and uses `lo.wrapping_add(draw as i64)` instead of a
plain `+`. This avoids both the `% 0` divide-by-zero at the full domain and the
signed-overflow panic the literal form hits on wide ranges in debug builds, while
preserving uniform-over-[lo,hi] draws and determinism (an extra RED test,
random_points_full_range_i64_does_not_panic, pins it). Modulo bias for spans not
dividing 2^64 remains the spec's documented, accepted simplification (param search
needs no cryptographic uniformity).
Includes a public-API E2E suite (tests/random_sweep_e2e.rs) exercising the feature
exactly as a downstream researcher would — reproducibility at the report boundary,
seed-sensitivity, in-range draws, the typed NonNumericRange gate, a well-formed
empty (count==0) family, and Grid/Random interchangeability through `Space`.
Verified: cargo test --workspace green (incl. 17 new engine tests + 6 E2E);
cargo clippy --workspace --all-targets -- -D warnings clean.
closes #52
This commit is contained in:
@@ -128,15 +128,15 @@ pub fn window_of(sources: &[Box<dyn Source>]) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Vec<Cell>>;
|
||||
/// 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<Vec<Cell>> {
|
||||
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<ParamSpec>,
|
||||
ranges: Vec<ParamRange>,
|
||||
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<ParamRange>,
|
||||
count: usize,
|
||||
seed: u64,
|
||||
) -> Result<Self, SweepError> {
|
||||
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<Vec<Cell>> {
|
||||
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<F>(space: &GridSpace, run_one: F) -> SweepFamily
|
||||
pub fn sweep<S, F>(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<F>(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<S, F>(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<ParamSpec> {
|
||||
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: Space>(s: &S) -> (Vec<Vec<Cell>>, Vec<ParamSpec>) {
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user