diff --git a/docs/plans/0049-random-param-sweep.md b/docs/plans/0049-random-param-sweep.md new file mode 100644 index 0000000..9f90c85 --- /dev/null +++ b/docs/plans/0049-random-param-sweep.md @@ -0,0 +1,700 @@ +# 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: &S) -> (Vec>, Vec) { + (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` 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>; + /// 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> { + 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(space: &GridSpace, run_one: F) -> SweepFamily +where + F: Fn(&[Cell]) -> RunReport + Sync, +{ +``` + +to: + +```rust +pub fn sweep(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(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily +where + F: Fn(&[Cell]) -> RunReport + Sync, +{ +``` + +to: + +```rust +fn sweep_with_threads(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 { + 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, + ranges: Vec, + 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, + count: usize, + seed: u64, + ) -> Result { + 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> { + 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 1–4)** + +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.