Files
Aura/docs/plans/0049-random-param-sweep.md
T
Brummel b99f52443d plan: 0049 random param-sweep
Five bite-sized tasks for the RandomSpace cut: (1) the Space trait +
behaviour-preserving generalization of sweep/sweep_with_threads, (2) typed
ParamRange, (3) RandomSpace::new validation + three SweepError variants,
(4) SplitMix64 -> pub(crate) + the seeded sampler, (5) public exports +
random-sweep integration. Each task is RED-first with single-substring test
filters; the grid suite is the C1 behaviour-preservation regression guard.

refs #52
2026-06-17 12:46:45 +02:00

701 lines
26 KiB
Markdown
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.
# Random param-sweep: `RandomSpace` + a `Space` trait + typed `ParamRange` — Implementation Plan
> **Parent spec:** `docs/specs/0049-random-param-sweep.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to
> run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship the random half of the C12.1 param-sweep axis — a `RandomSpace`
that draws `N` seeded uniform points over declared continuous ranges, fed to the
same enumeration-agnostic `sweep` core the grid already drives, via a new `Space`
trait both enumerations implement.
**Architecture:** All work lands in `crates/aura-engine`. A new `Space` trait
(`points`/`param_specs`) generalizes `sweep`/`sweep_with_threads` from `&GridSpace`
to `&S: Space` (behaviour-preserving — `GridSpace`'s trait impl forwards to its
existing inherent methods, C1). `RandomSpace` is the second `Space` producer: a
typed `ParamRange { lo, hi }` per slot, validated in `new` against the param-space,
sampled by a code-path-disjoint `SplitMix64` instance (the #52/#71 World-II
source-seam firewall — the param-sampling RNG and the data-edge seed RNG share
only the `u64` type, never a code path).
**Tech Stack:** Rust; `crates/aura-engine/src/sweep.rs` (trait + `ParamRange` +
`RandomSpace` + signature generalization + three new `SweepError` variants),
`crates/aura-engine/src/harness.rs` (`SplitMix64``pub(crate)`),
`crates/aura-engine/src/lib.rs` (exports). Tests live in the existing
`sweep.rs` `mod tests`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/sweep.rs``Space` trait + `impl Space for GridSpace`
(forwarding); `ParamRange`; three new `SweepError` variants; `RandomSpace` +
`RandomSpace::new` + `impl Space for RandomSpace`; `sweep`/`sweep_with_threads`
generalized to `&S: Space`. New tests in `mod tests`.
- Modify: `crates/aura-engine/src/harness.rs:131-150``SplitMix64` struct + its
`new`/`next_u64`/`next_f64` methods from module-private to `pub(crate)`.
- Modify: `crates/aura-engine/src/lib.rs:65` — add `RandomSpace`, `ParamRange`,
`Space` to the `pub use sweep::{…}` line.
- Test: `crates/aura-engine/src/sweep.rs` `mod tests``Space`-trait satisfaction,
`ParamRange` constructors, `RandomSpace::new` validation (one per error variant +
accepted I64 `lo==hi`), sampler bounds/determinism/seed-sensitivity/zero-count,
and the random-`sweep` integration (== N independent runs, determinism across
thread counts, named-view round-trip).
---
### Task 1: `Space` trait + `impl Space for GridSpace` + generalize `sweep`/`sweep_with_threads`
The behaviour-preserving refactor (C1): introduce the trait, have `GridSpace`
implement it by forwarding to its inherent methods, and make the execution core
generic. The existing grid sweep tests are the regression guard — they must stay
green; the new test pins that `GridSpace` satisfies `Space`.
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs` (trait + impl after line 91; signatures at `:136-142` and `:189-203`)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing test**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs` (after `points_enumerate_in_odometer_order`, around line 238):
```rust
#[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());
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine grid_space_satisfies_space_trait`
Expected: FAIL — compilation error: `cannot find trait Space in this scope` (the
trait does not exist yet, so the `via_trait<S: Space>` bound is unresolved).
- [ ] **Step 3: Add the `Space` trait, the `GridSpace` impl, and generalize the signatures**
In `crates/aura-engine/src/sweep.rs`, insert the trait and impl immediately after
the `impl GridSpace { … }` block (after line 91, before the `SweepError` enum):
```rust
/// 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)
}
}
```
Then change `sweep`'s signature (currently `sweep.rs:136-139`) from:
```rust
pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
where
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
to:
```rust
pub fn sweep<S, F>(space: &S, run_one: F) -> SweepFamily
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
(the body — `available_parallelism` + `sweep_with_threads(space, nthreads, run_one)` — is unchanged.)
And change `sweep_with_threads`'s signature (currently `sweep.rs:189-192`) from:
```rust
fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
where
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
to:
```rust
fn sweep_with_threads<S, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
{
```
(the body — `space.points()`, `run_indexed`, `space.param_specs().to_vec()`, the
zip — is unchanged; it already calls only the two `Space` methods.)
- [ ] **Step 4: Run the new test and the existing grid suite to verify green**
Run: `cargo test -p aura-engine grid_space_satisfies_space_trait`
Expected: PASS.
Run: `cargo test -p aura-engine sweep`
Expected: PASS — all existing grid tests (`sweep_equals_n_independent_runs`,
`family_is_deterministic_across_thread_counts`, `sweep_family_carries_param_space`,
`family_named_params_round_trips`, `points_enumerate_in_odometer_order`, …) stay
green: the `&GridSpace``&S: Space` swap is behaviour-preserving, and the
cross-module caller `blueprint.rs:392` compiles unchanged because `GridSpace: Space`.
---
### Task 2: `ParamRange` — a typed, kind-tagged range
A `{ lo, hi }` pair carrying a shared kind by construction; the home for the
non-empty-range invariant (validated in Task 3). No `bool`/`timestamp`
constructors — those kinds are not range-sampleable.
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs` (insert after the `SweepError` enum, around line 103)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing test**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs`:
```rust
#[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));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test -p aura-engine param_range_constructors_carry_kind`
Expected: FAIL — compilation error: `cannot find ... ParamRange in this scope`
(the type does not exist yet).
- [ ] **Step 3: Add `ParamRange`**
In `crates/aura-engine/src/sweep.rs`, insert after the `SweepError` enum (after
the closing `}` at line 103):
```rust
/// 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()
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cargo test -p aura-engine param_range_constructors_carry_kind`
Expected: PASS.
---
### Task 3: `RandomSpace` struct + `RandomSpace::new` validation + three new `SweepError` variants
The typed gate: every structural fault is caught in `new`, before any run — exactly
as `GridSpace::new`. Adds the three error variants `new` produces.
**Files:**
- Modify: `crates/aura-engine/src/sweep.rs` (three `SweepError` variants append into the enum at `:95-103`; `RandomSpace` + `impl RandomSpace` after `ParamRange`)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing tests**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs`. (The helper `i64_space` already
exists at `sweep.rs:212`; add `one_i64_space` once, alongside the tests.)
```rust
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());
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p aura-engine random_space_`
Expected: FAIL — compilation error: `cannot find ... RandomSpace` and
`no variant ... NonNumericRange` / `RangeKindMismatch` / `EmptyRange` on
`SweepError` (the type and variants do not exist yet).
- [ ] **Step 3: Append the three `SweepError` variants and add `RandomSpace`**
In `crates/aura-engine/src/sweep.rs`, append the three variants inside the
`SweepError` enum, immediately after the `EmptyAxis { slot: usize },` line (`:102`):
```rust
/// 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 },
```
Then add `RandomSpace` after the `ParamRange` impl (from Task 2):
```rust
/// `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
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cargo test -p aura-engine random_space_`
Expected: PASS — all seven `random_space_*` tests green.
---
### Task 4: `SplitMix64` → `pub(crate)` + `impl Space for RandomSpace` (the seeded sampler)
Promote the existing bit-stable PRNG to crate scope (so `sweep.rs` reuses the same
algorithm, not a copy) and implement the sampler. A code-path-disjoint instance from
the data-edge seed RNG (#52/#71 firewall).
**Files:**
- Modify: `crates/aura-engine/src/harness.rs:131-150` (`SplitMix64` + methods → `pub(crate)`)
- Modify: `crates/aura-engine/src/sweep.rs` (import `SplitMix64`; `impl Space for RandomSpace`)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing tests**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs`:
```rust
#[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_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());
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cargo test -p aura-engine random_points`
Expected: FAIL — compilation error: `no method named points found for struct
RandomSpace` (the `Space` impl for `RandomSpace` does not exist yet).
- [ ] **Step 3a: Promote `SplitMix64` to `pub(crate)` in `harness.rs`**
In `crates/aura-engine/src/harness.rs`, change the four visibility keywords only
(bodies unchanged). Line `:131`:
```rust
struct SplitMix64 {
```
becomes:
```rust
pub(crate) struct SplitMix64 {
```
and the three methods (`:136`, `:139`, `:147`):
```rust
fn new(seed: u64) -> Self {
```
```rust
fn next_u64(&mut self) -> u64 {
```
```rust
fn next_f64(&mut self) -> f64 {
```
become:
```rust
pub(crate) fn new(seed: u64) -> Self {
```
```rust
pub(crate) fn next_u64(&mut self) -> u64 {
```
```rust
pub(crate) fn next_f64(&mut self) -> f64 {
```
(The existing in-crate user `SplitMix64::new(seed)` at `harness.rs:180` keeps
compiling under the wider visibility.)
- [ ] **Step 3b: Import `SplitMix64` and add `impl Space for RandomSpace` in `sweep.rs`**
In `crates/aura-engine/src/sweep.rs`, add the import after line 8
(`use crate::RunReport;`):
```rust
use crate::harness::SplitMix64;
```
Then add the sampler impl after the `impl RandomSpace { … }` block (from Task 3):
```rust
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());
let span = (hi as i128 - lo as i128 + 1) as u64;
Cell::from_i64(lo + (rng.next_u64() % span) 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()
}
}
```
- [ ] **Step 4: Run the sampler tests and a crate build to verify green**
Run: `cargo test -p aura-engine random_points`
Expected: PASS — all four `random_points*` tests green.
Run: `cargo build -p aura-engine`
Expected: clean build (0 errors) — the data-edge `SplitMix64::new` user at
`harness.rs:180` still compiles under `pub(crate)`.
---
### Task 5: `lib.rs` exports + the random-`sweep` integration tests
Make `RandomSpace`/`ParamRange`/`Space` public, and pin that a `RandomSpace` flows
through the *same* `sweep` as the grid: equals N independent runs, deterministic
across thread counts, named view round-trips. Final regression + lint gate.
**Files:**
- Modify: `crates/aura-engine/src/lib.rs:65` (export line)
- Test: `crates/aura-engine/src/sweep.rs` `mod tests`
- [ ] **Step 1: Write the failing tests**
Add to `mod tests` in `crates/aura-engine/src/sweep.rs` (reuses the existing
`run_point` fixture and `composite_sma_cross_harness`, whose param-space is
`[fast: I64, slow: I64, scale: F64]`):
```rust
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);
}
```
- [ ] **Step 2: Run tests to verify they pass (behaviour already shipped in Tasks 14)**
Run: `cargo test -p aura-engine random_sweep`
Expected: PASS — the three `random_sweep_*` tests green. (`RandomSpace: Space` and
`sweep`'s generic signature already exist; these tests assert the integration.)
- [ ] **Step 3: Add the public exports in `lib.rs`**
In `crates/aura-engine/src/lib.rs`, change line 65 from:
```rust
pub use sweep::{sweep, GridSpace, SweepError, SweepFamily, SweepPoint};
```
to:
```rust
pub use sweep::{sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint};
```
- [ ] **Step 4: Final workspace build, full suite, and lint gate**
Run: `cargo build --workspace`
Expected: clean build (0 errors) — the new exports resolve; no consumer breaks.
Run: `cargo test -p aura-engine`
Expected: PASS — the full `aura-engine` suite is green, including every pre-existing
grid sweep test (C1 behaviour-preservation) alongside the new `RandomSpace` tests.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean — no warnings.