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:
2026-06-17 13:24:06 +02:00
parent b99f52443d
commit e17d78f24f
4 changed files with 639 additions and 11 deletions
+4 -4
View File
@@ -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)
}
}
+3 -1
View File
@@ -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,
+389 -6
View File
@@ -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);
}
}
@@ -0,0 +1,243 @@
//! End-to-end coverage for the random param-sweep axis (spec 0049, C12.1):
//! `RandomSpace` + `ParamRange` driven through the **public** `sweep` surface a
//! downstream researcher actually writes (the spec's "Worked author example").
//!
//! The in-module unit tests in `sweep.rs` reach into crate internals
//! (`bootstrap_with_cells`, `sweep_with_threads`, `SplitMix64`); these tests use
//! only the exported API, so they pin the properties a real consumer observes:
//! a `SweepFamily` of `RunReport`s, the typed `SweepError` gate, and the
//! `named_params` view. The blueprint is reconstructed here (the crate-private
//! `test_fixtures` harness is unreachable from an integration test) through the
//! public `Composite` builder + `aura-std` nodes, so the test exercises the same
//! published surface the worked example does.
use std::sync::mpsc;
use aura_core::{Cell, Firing, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, OutField, ParamRange, ParamSpec,
RandomSpace, Role, RunManifest, RunReport, Scalar, Space, SweepError, Target, VecSource,
};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
/// Seven synthetic F64 ticks (mirrors the crate-private `synthetic_prices`):
/// short enough that small SMA windows warm up, so every run yields finite,
/// non-degenerate metrics. Deterministic input fixture.
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::f64(p)))
.collect()
}
/// The SMA-cross signal-quality harness built through the PUBLIC builder API:
/// `[fast: I64, slow: I64, scale: F64]` param-space, ending in an equity sink and
/// an exposure sink. Returns the blueprint plus its two recording receivers (a
/// fresh channel pair per build, so each swept point runs disjointly — C1).
#[allow(clippy::type_complexity)]
fn sma_cross_harness() -> (
Composite,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let sma_cross = Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(sma_cross),
Exposure::builder().into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
],
vec![Role {
name: "src".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
],
source: Some(ScalarKind::F64),
}],
vec![], // root ends in sinks
);
(bp, rx_eq, rx_ex)
}
/// Build + bootstrap + run + summarize one swept point into a `RunReport`, using
/// only the public surface. A fresh harness (fresh sink channels) per point keeps
/// the runs disjoint (C1). The manifest is a fixed minimal fixture — only the
/// metrics carry the run, so determinism makes this reproduce a point exactly.
fn run_point(point: &[Cell]) -> RunReport {
let (bp, rx_eq, rx_ex) = sma_cross_harness();
let mut h = bp
.bootstrap_with_cells(point)
.expect("RandomSpace-drawn points are pre-validated against the param-space");
h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: RunManifest {
commit: "random-sweep-e2e".to_string(),
params: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
},
metrics: summarize(&equity, &exposure),
}
}
/// A `RandomSpace` over the harness's `[fast, slow, scale]` param-space: integer
/// windows drawn from the 7-tick fixture's proven domain (so SMAs warm up) and a
/// continuous scale. The integer ranges are exact single points so the family
/// stays small but every kind/arm is exercised.
fn sma_cross_random(count: usize, seed: u64) -> RandomSpace {
let space = sma_cross_harness().0.param_space();
RandomSpace::new(
&space,
vec![
ParamRange::i64(2, 3), // fast in [2, 3]
ParamRange::i64(4, 5), // slow in [4, 5]
ParamRange::f64(0.25, 1.5), // scale in [0.25, 1.5)
],
count,
seed,
)
.expect("ranges match the sample param-space kinds")
}
/// Property: a `RandomSpace` sweep is fully seed-determined end-to-end — the same
/// `(ranges, count, seed)` driven through the public `sweep` produces a
/// bit-identical `SweepFamily` of `RunReport`s, run after run (C1). The whole
/// pipeline (seeded draw -> bootstrap -> run -> summarize) reproduces, observed at
/// the published JSON boundary, not the internal `points()`.
#[test]
fn random_sweep_is_reproducible_at_the_report_boundary() {
let render = |seed: u64| -> Vec<String> {
sweep(&sma_cross_random(8, seed), run_point)
.points
.iter()
.map(|p| p.report.to_json())
.collect()
};
let a = render(0xC0FFEE);
let b = render(0xC0FFEE);
assert_eq!(a.len(), 8, "count points were swept");
assert_eq!(a, b, "same (ranges, count, seed) => bit-identical family (C1)");
}
/// Property: a different seed produces a different family — the sweep genuinely
/// samples the seed, it does not collapse to a constant set of points. Observed
/// via the public `named_params` coordinate view, never an internal field.
#[test]
fn random_sweep_seed_changes_the_family() {
let coords = |seed: u64| -> Vec<Vec<Scalar>> {
let family = sweep(&sma_cross_random(8, seed), run_point);
(0..family.points.len())
.map(|i| family.named_params(i).into_iter().map(|(_, v)| v).collect())
.collect()
};
assert_ne!(
coords(1),
coords(2),
"different seeds => different swept coordinate sets",
);
}
/// Property: every coordinate the sweep actually ran on lies inside its declared
/// `ParamRange` — the I64 slots inclusive `[lo, hi]`, the F64 slot half-open
/// `[lo, hi)`. A regression that let a draw escape its range would silently run
/// the strategy out of its declared domain; this pins the bound at the observable
/// `named_params` view of the family that was run.
#[test]
fn swept_points_stay_inside_their_declared_ranges() {
let family = sweep(&sma_cross_random(200, 0xABCDEF), run_point);
assert_eq!(family.points.len(), 200);
for i in 0..family.points.len() {
let named = family.named_params(i);
let fast = named[0].1.as_i64();
let slow = named[1].1.as_i64();
let scale = named[2].1.as_f64();
assert!((2..=3).contains(&fast), "fast in [2,3] inclusive, got {fast}");
assert!((4..=5).contains(&slow), "slow in [4,5] inclusive, got {slow}");
assert!((0.25..1.5).contains(&scale), "scale in [0.25,1.5), got {scale}");
// and the run that consumed this in-range point produced a finite metric
assert!(family.points[i].report.metrics.total_pips.is_finite());
}
}
/// Property: the typed validation gate rejects a non-numeric param slot BEFORE
/// any run. A `Bool` slot cannot carry a continuous range (it is degenerate), so
/// `RandomSpace::new` returns the public `SweepError::NonNumericRange` value — an
/// observable typed error at the published API, not a panic and not a swept run.
#[test]
fn bool_slot_is_rejected_as_non_numeric_before_any_run() {
let space = vec![ParamSpec { name: "flag".into(), kind: ScalarKind::Bool }];
let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0)
.expect_err("a Bool slot is not range-sampleable");
assert_eq!(err, SweepError::NonNumericRange { slot: 0, kind: ScalarKind::Bool });
}
/// Property: a `count == 0` `RandomSpace` is a valid, explicit empty family (not
/// the "accidental collapse" an empty grid axis would be) — `sweep` over it
/// returns an empty `SweepFamily` while still carrying the param-space schema, so
/// a downstream `named_params` consumer sees a well-formed empty result.
#[test]
fn zero_count_sweep_is_a_well_formed_empty_family() {
let space = sma_cross_harness().0.param_space();
let rs = sma_cross_random(0, 0);
assert!(rs.is_empty(), "count == 0 is the explicit empty space");
let family = sweep(&rs, run_point);
assert!(family.points.is_empty(), "no points swept");
assert_eq!(family.space, space, "the empty family still carries the schema");
}
/// Property: `GridSpace` and `RandomSpace` are interchangeable through the `Space`
/// trait `sweep` is generic over — the same generic helper drives either
/// enumeration. This is the trait abstraction the cut introduced, observed via the
/// public `Space::param_specs`, kept from regressing back to a `GridSpace`-only
/// `sweep` signature.
#[test]
fn random_space_is_driven_through_the_space_trait() {
fn schema_len<S: Space>(s: &S) -> usize {
s.param_specs().len()
}
let rs = sma_cross_random(4, 7);
assert_eq!(schema_len(&rs), 3, "the [fast, slow, scale] schema reaches the trait surface");
// and the generic `sweep` accepts it by value of the same bound
let family = sweep(&rs, run_point);
assert_eq!(family.points.len(), 4);
}