Files
Aura/crates/aura-engine/src/sweep.rs
T
claude 1ebb94c1b8 feat(engine,cli): run manifests stamp untouched bound defaults (closes #249)
RunManifest gains defaults: Vec<(String, Scalar)> — the wrap-prefixed
bound_param_space() of the signal, read after axis reopening, so a
bound param an axis overrode has already left the space and flows
through params instead (disjoint by construction; verified end to end:
a sweep member's overridden fast.length sits in params while
slow.length/bias.scale sit in defaults). params keeps its "what varied"
semantics and stays the reproduce input. One-directional serde
widening (#[serde(default)]) per the selection/instrument/topology_hash
idiom — old records deserialize with an empty defaults; unlike the
Option fields it always serializes, mirroring params. ~20
struct-literal sites across five crates gained the field
(compile-mandated breadth, no behaviour change at those sites).

The C14 ledger records the underlying decision (2026-07-13): generated
outcome records spend redundancy on direct readability (single writer,
cannot drift); authored intent artifacts admit none (every redundancy
is a drift site) — so the fix lands in the manifest, never the
blueprint.

Verification: RED test run_manifest_stamps_untouched_bound_defaults
green; cargo build --workspace; cargo test --workspace green; clippy
-D warnings on the touched crates; binary-level sweep exclusivity
check.
2026-07-13 14:20:23 +02:00

965 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Param-sweep (C12.1): enumerate a blueprint's param-space — a cartesian
//! `GridSpace` (a discrete per-slot lattice), a seeded `RandomSpace` (`N` draws
//! over typed continuous ranges), or an explicit `ListSpace` (an arbitrary
//! validated point set, e.g. a gate's survivor subset) — and run each point
//! disjointly (C1). All three enumerations implement the `Space` trait that
//! `sweep` is generic over, so each runs through one execution path. This module
//! owns enumeration (`GridSpace` / `RandomSpace` / `ListSpace` / 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<ParamSpec>,
axes: Vec<Vec<Cell>>,
}
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<Vec<Scalar>>) -> Result<Self, SweepError> {
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<usize> {
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<Vec<Cell>> {
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<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`, a `RandomSpace`, or a
/// `ListSpace` — the shared typed gate before any run (grid faults: `Arity` /
/// `KindMismatch` / `EmptyAxis`; random faults: `NonNumericRange` /
/// `RangeKindMismatch` / `EmptyRange`; list faults reuse `Arity` /
/// `KindMismatch`, with `value_index` carrying the point ordinal).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SweepError {
/// The number of axes (grid/random) — or of a single point's values (list)
/// — does not equal the param-space slot count.
Arity { expected: usize, got: usize },
/// A grid or list 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 (grid) or the point ordinal (list).
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<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.)
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()
}
}
/// An explicit, validated point set over a blueprint's param-space — the
/// enumeration for arbitrary member subsets (e.g. a gate's survivor set) that
/// no cartesian `GridSpace` can represent. Points are validated against
/// `space` at construction (the `GridSpace::new` contract: arity per point,
/// kind per slot); an empty point list is valid and yields an empty family.
#[derive(Debug)]
pub struct ListSpace {
space: Vec<ParamSpec>,
points: Vec<Vec<Scalar>>,
}
impl ListSpace {
/// Validate `points` against `space` (the blueprint's `param_space()`):
/// every point carries one value per slot (`Arity`), every value the
/// slot's declared kind (`KindMismatch`, whose `value_index` is the point
/// ordinal here). An empty point list is allowed — a legitimately-empty
/// survivor set is representable, it just enumerates zero points.
pub fn new(space: &[ParamSpec], points: Vec<Vec<Scalar>>) -> Result<Self, SweepError> {
for (point_index, point) in points.iter().enumerate() {
if point.len() != space.len() {
return Err(SweepError::Arity { expected: space.len(), got: point.len() });
}
for (slot, (v, ps)) in point.iter().zip(space).enumerate() {
if v.kind() != ps.kind {
return Err(SweepError::KindMismatch {
slot,
value_index: point_index,
expected: ps.kind,
got: v.kind(),
});
}
}
}
Ok(Self { space: space.to_vec(), points })
}
}
impl Space for ListSpace {
/// The points exactly as given, in input order (no enumeration structure —
/// determinism is the caller's declared order, C1), lowered to tag-free
/// cells in the declared kinds (the kind lives once, in `param_specs()`).
fn points(&self) -> Vec<Vec<Cell>> {
self.points
.iter()
.map(|p| p.iter().map(|s| s.cell()).collect())
.collect()
}
fn param_specs(&self) -> &[ParamSpec] {
&self.space
}
}
/// 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<Cell>,
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<ParamSpec>,
pub points: Vec<SweepPoint>,
}
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<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);
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<T, F>(n: usize, nthreads: usize, run_one: F) -> Vec<T>
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<S, F>(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<ParamSpec> {
(0..n)
.map(|i| ParamSpec { name: format!("p{i}"), kind: ScalarKind::I64 })
.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);
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: 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![
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::<usize>(), 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::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: RunManifest {
commit: "test".to_string(),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: 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);
}
/// A fake report whose manifest `commit` encodes the input cells — enough to
/// prove the sweep closure ran on exactly the given point, without a harness
/// run. A free `fn` (Sync) so it serves directly as the `sweep` closure.
fn tagged_report(point: &[Cell]) -> RunReport {
RunReport {
manifest: RunManifest {
commit: point.iter().map(|c| c.i64().to_string()).collect::<Vec<_>>().join(","),
params: Vec::new(),
defaults: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&[], &[]),
}
}
#[test]
fn list_space_runs_exactly_the_given_points_in_order() {
let space = i64_space(2);
// deliberately non-odometer order: the list enumerates the input order,
// not any canonical order
let ls = ListSpace::new(
&space,
vec![
vec![Scalar::i64(3), Scalar::i64(5)],
vec![Scalar::i64(2), Scalar::i64(4)],
vec![Scalar::i64(3), Scalar::i64(4)],
],
)
.expect("valid explicit point set");
let expected = vec![
vec![Cell::from_i64(3), Cell::from_i64(5)],
vec![Cell::from_i64(2), Cell::from_i64(4)],
vec![Cell::from_i64(3), Cell::from_i64(4)],
];
assert_eq!(ls.points(), expected);
let family = sweep(&ls, tagged_report);
assert_eq!(family.points.len(), 3);
assert_eq!(family.space, space, "family carries the validated param-space");
for (pt, cells) in family.points.iter().zip(&expected) {
assert_eq!(&pt.params, cells, "points carried tag-free, in input order");
let tag: String =
cells.iter().map(|c| c.i64().to_string()).collect::<Vec<_>>().join(",");
assert_eq!(pt.report.manifest.commit, tag, "the closure ran on exactly this point");
}
}
#[test]
fn list_space_refuses_arity_and_kind_mismatch() {
let space = i64_space(2);
// arity: the second point carries 1 value for a 2-slot space
let err = ListSpace::new(
&space,
vec![vec![Scalar::i64(1), Scalar::i64(2)], vec![Scalar::i64(3)]],
)
.unwrap_err();
assert_eq!(err, SweepError::Arity { expected: 2, got: 1 });
// kind: point 1, slot 1 carries an F64 in an I64 slot
let err = ListSpace::new(
&space,
vec![
vec![Scalar::i64(1), Scalar::i64(2)],
vec![Scalar::i64(3), Scalar::f64(0.5)],
],
)
.unwrap_err();
assert_eq!(
err,
SweepError::KindMismatch {
slot: 1,
value_index: 1,
expected: ScalarKind::I64,
got: ScalarKind::F64,
},
);
}
#[test]
fn list_space_allows_empty_point_list() {
let space = i64_space(2);
let ls = ListSpace::new(&space, vec![]).expect("an empty point list is valid");
assert!(ls.points().is_empty(), "zero points enumerated");
let family = sweep(&ls, tagged_report);
assert!(family.points.is_empty(), "an empty ListSpace sweeps to an empty family");
assert_eq!(family.space, space, "the empty family still carries the param-space");
}
}