Files
Aura/crates/aura-engine/src/sweep.rs
T
Brummel ff4a1b3d4a feat(0092): run-from-blueprint infrastructure — restructure + topology_hash + shared seam (Tasks 1-3)
Cycle-1 infrastructure for #165 (World/C21). Behaviour-preserving + workspace
green; the CLI arm + tests (Tasks 4-5) follow.

Task 1 — restructured stage1_r_graph into stage1_signal() (the serializable
signal leg: SMA-cross -> Bias, a `price` input-role + a `bias` output) +
wrap_stage1r(signal, ...) (broker/sinks/exec/cost around a nested signal), with
stage1_r_graph() = wrap_stage1r(stage1_signal(...)). DEVIATION from the plan's
"callers untouched" claim, verified behaviour-preserving: nesting the signal
prefixes its param-space names (stage1_signal.fast.length), which broke the
sweep's bare .axis("fast.length"); fixed exactly as the #137 stop-knob machinery
does — FAST_LENGTH_SUFFIX/SLOW_LENGTH_SUFFIX suffix-resolution + stage1_r_friendly_name
arms mapping the prefixed names back to the bare manifest names. All observable
behaviour (manifest names, member keys, metrics, traces) byte-identical; the flat
graph and every metric unchanged (full suite green, incl. sweep/walkforward/MC).

Task 2 — RunManifest.topology_hash: Option<String> (Tier-1 optional, serde
default/skip — old manifests byte-identical, #156), threaded None into all 24
literal sites across 7 crates. Also fixed the aura-registry RunManifestRead
read-mirror to carry topology_hash (it was hardcoding None on load) — else a
stored hash would silently drop on the registry round-trip once Task 3 stores a
real one.

Task 3 — the shared run_signal_stage1r seam (hash the signal, wrap, compile with
params, bootstrap, run, manifest with params from param_space + topology_hash) +
the sha256_hex topology_hash helper (sha2 in aura-cli, off the frozen engine,
invariant 8). run_stage1_r now carries its topology_hash too (every run becomes
self-identifying, #158). The seam is unwired until Task 4 (transient
#[allow(dead_code)], retired there); RED-first seam tests added.

Verified: cargo test --workspace green (51 suites, 0 failures); cargo clippy
--workspace --all-targets -D warnings clean.

refs #165
2026-06-30 20:23:42 +02:00

813 lines
32 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 — 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<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` 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<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()
}
}
/// 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(),
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);
}
}