# Random param-sweep: `RandomSpace` + a `Space` trait + typed `ParamRange` — Design Spec **Date:** 2026-06-17 **Status:** Draft — awaiting user spec review **Authors:** orchestrator + Claude > Seeding issue: `Brummel/Aura#52`. The three load-bearing forks (Space > abstraction, range type, sampling semantics) were resolved in an in-context > design discussion on 2026-06-17 and recorded as a provenance-bearing > reconciliation comment on #52 (the body still reads "Shape (none chosen)"). ## Goal Ship the **random** half of the C12.1 param-sweep axis. Cycle 0028 (#32) shipped the **grid** axis (`GridSpace`: cartesian enumeration over discrete per-slot value-lists). C12 names the axis as "param-sweep (**grid/random**)"; random was a deliberate scope cut. Random enumeration draws `N` seeded points from declared **continuous ranges** per slot, instead of enumerating a discrete lattice. This cycle adds three things, as one cut: 1. A `Space` **trait** (`points`, `param_specs`) implemented by both `GridSpace` and the new `RandomSpace`; `sweep` becomes generic over `impl Space`. This makes the already-"enumeration-agnostic" execution core (`run_indexed`) carry either enumeration through one path, and is **behaviour-preserving** for the existing grid path (C1). 2. `RandomSpace` — a sibling to `GridSpace` that, given a param-space, a typed range per slot, a sample count, and a seed, produces `N` deterministic points. 3. `ParamRange` — a typed `{ lo, hi }` range carried positional-parallel to the param-space, the home for the non-empty-range invariant and (future) a distribution tag. The RNG is the existing dependency-free, bit-stable `SplitMix64` (already in `harness.rs` for the seed→source path), promoted to crate scope and used by a **code-path-disjoint** instance here (the #52 World-II firewall: the param- sampling RNG and the data-edge seed RNG share only the `u64` type, never a code path). The per-point run closure is unchanged: `Fn(&[Cell]) -> RunReport`. ## Architecture `GridSpace` already shows the target shape (`sweep.rs`): a family is built by enumerating points (`Vec>`) and running each disjointly through the shared `run_indexed` core, in enumeration order (C1: order is input order, not completion order). `RandomSpace` is the second producer of that same point shape; the only thing it does differently is *how the points are produced* — seeded uniform draws over ranges instead of a cartesian product. The asymmetry today is that `sweep` is hard-typed to `&GridSpace`. The cut dissolves that into a trait both enumerations implement: ``` +-----------------------------------+ GridSpace ----| | (∏ axes) | trait Space { points; specs } | | |---> sweep(&impl Space, run_one) RandomSpace --| points() -> Vec> | -> run_indexed (disjoint, C1) (N seeded) | param_specs() -> &[ParamSpec] | -> SweepFamily (enumeration order) +-----------------------------------+ ``` `SweepFamily`, `SweepPoint`, `sweep`, `sweep_with_threads`, and `run_indexed` are unchanged in behaviour; only `sweep`/`sweep_with_threads` change *signature* (from `&GridSpace` to a generic `&S where S: Space`). The named per-point view stays `zip_params(&family.space, point)` (#57), identical for both enumerations. ## Concrete code shapes ### Worked author example (the new user-facing surface) A researcher tuning a 3-param strategy over **continuous ranges** (a grid would explode; cf. the curse of dimensionality) declares a `RandomSpace` and runs the **same** `sweep`: ```rust use aura_engine::{sweep, RandomSpace, ParamRange, SweepError}; // existing: the author's strategy blueprint; param_space() = [fast: I64, slow: I64, scale: F64] let bp = my_sma_cross_blueprint(); let space = bp.param_space(); // NEW: one declared continuous range per slot, positional-parallel to `space` let ranges = vec![ ParamRange::i64(2, 50), // fast ∈ [2, 50] (inclusive) ParamRange::i64(10, 200), // slow ∈ [10, 200] (inclusive) ParamRange::f64(0.1, 2.0), // scale ∈ [0.1, 2.0) (half-open) ]; // NEW: draw 200 seeded points; validated against the param-space in `new` let rand_space = RandomSpace::new(&space, ranges, 200, /* seed */ 0xC0FFEE)?; // SAME execution layer as the grid sweep — sweep is now generic over `impl Space` let family = sweep(&rand_space, |point: &[aura_core::Cell]| run_one(&bp, point)); assert_eq!(family.points.len(), 200); let (name, value) = family.named_params(0).remove(0); // readable view via zip_params (#57) // same seed + ranges + count => same 200 points, reproducibly (C1) ``` `run_one` is the author's existing per-point closure (build → bootstrap → run → summarize into a `RunReport`), unchanged from the grid sweep and from the data stream: `Fn(&[Cell]) -> RunReport`. Stream provision stays wholly inside the closure body (the #52 firewall): `RandomSpace`/`sweep` never name a `Source`. ### Before → after: the load-bearing changes **1. The `Space` trait (new) and `sweep`'s generalized signature.** ```rust // NEW in sweep.rs pub trait Space { /// The enumerated points, each a tag-free coordinate in `param_space()` order. fn points(&self) -> Vec>; /// The param-space (names + kinds) the points are coordinates in. fn param_specs(&self) -> &[ParamSpec]; } impl Space for GridSpace { // forward to the existing inherent methods (path syntax picks the inherent // method, not this trait method — no recursion); the grid path is unchanged fn points(&self) -> Vec> { GridSpace::points(self) } fn param_specs(&self) -> &[ParamSpec] { GridSpace::param_specs(self) } } impl Space for RandomSpace { /* as below */ } // BEFORE pub fn sweep(space: &GridSpace, run_one: F) -> SweepFamily where F: Fn(&[Cell]) -> RunReport + Sync { sweep_with_threads(space, nthreads, run_one) } // AFTER — generic over any Space; body unchanged pub fn sweep(space: &S, run_one: F) -> SweepFamily where S: Space, F: Fn(&[Cell]) -> RunReport + Sync { sweep_with_threads(space, nthreads, run_one) } ``` `sweep_with_threads` likewise takes `&S where S: Space`; it already calls only `space.points()` and `space.param_specs()`, so its body is unchanged. `GridSpace` keeps its inherent `points()`/`param_specs()` methods (the existing tests call them directly); the trait impl forwards to them, so the grid path is behaviour- preserving (C1). **2. `ParamRange` (new) — a typed, kind-tagged range.** ```rust // NEW in sweep.rs #[derive(Clone, Copy, Debug, PartialEq)] pub struct ParamRange { pub lo: Scalar, pub hi: Scalar, } impl ParamRange { pub fn i64(lo: i64, hi: i64) -> Self { Self { lo: Scalar::i64(lo), hi: Scalar::i64(hi) } } 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() } } ``` There are no `bool`/`timestamp` constructors: those kinds are not range-sampleable (Bool is degenerate; Timestamp is a structural axis, C20), so a `Bool`/`Timestamp` slot is rejected at validation (below). **3. `RandomSpace` (new) and its `Space::points` sampler.** ```rust // NEW in sweep.rs #[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 }) } pub fn len(&self) -> usize { self.count } 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 sampled in declared `param_space()` order (slot-major, /// points in sequence). Deterministic: same (ranges, count, seed) => same /// points, identical to a re-run (C1). fn points(&self) -> Vec> { let mut rng = SplitMix64::new(self.seed); // crate-visible; disjoint instance (0..self.count).map(|_| { self.ranges.iter().map(|r| match r.kind() { // inclusive [lo, hi]; span as u64 handles negative lo 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!("new() rejects non-numeric ranges"), }).collect() }).collect() } } ``` **4. `SweepError` — three new variants.** ```rust pub enum SweepError { Arity { expected: usize, got: usize }, // existing (reused) KindMismatch { slot: usize, value_index: usize, expected: ScalarKind, got: ScalarKind }, // existing EmptyAxis { slot: usize }, // existing (grid-only) NonNumericRange { slot: usize, kind: ScalarKind }, // NEW: slot kind not I64/F64 RangeKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind }, // NEW: range kind != slot kind EmptyRange { slot: usize }, // NEW: range admits no value (I64 lo>hi; F64 lo>=hi) } ``` **5. `SplitMix64` promoted to crate scope.** It is `struct SplitMix64` (module- private in `harness.rs`); change to `pub(crate)` (with `new`/`next_u64`/`next_f64` also `pub(crate)`) so `sweep.rs` reuses the **same bit-stable algorithm** without copying it. Two instances, two code paths, one type — exactly the firewall's "share the `u64`, not the code path". **6. Public exports.** `lib.rs` adds `RandomSpace`, `ParamRange`, `Space` to the `pub use sweep::{…}` line. ## Components - `crates/aura-engine/src/sweep.rs` — `Space` trait; `impl Space for GridSpace` (forwarding); `ParamRange`; `RandomSpace` + `impl Space for RandomSpace`; three new `SweepError` variants; `sweep`/`sweep_with_threads` generalized to `&S: Space`. - `crates/aura-engine/src/harness.rs` — `SplitMix64` (+ its methods) `pub(crate)`. - `crates/aura-engine/src/lib.rs` — export `RandomSpace`, `ParamRange`, `Space`. No change to `run_indexed`, `SweepFamily`, `SweepPoint`, `RunReport`, `zip_params`, `mc.rs`, or `walkforward.rs` (they consume `run_indexed`, not `sweep`). ## Data flow `RandomSpace::new(space, ranges, count, seed)` → (validate) → `RandomSpace`. `sweep(&rand_space, run_one)` → `sweep_with_threads` → `rand_space.points()` (seeded draws, `Vec>`) → `run_indexed(points.len(), nthreads, |i| run_one(&points[i]))` (disjoint, job-index order) → `SweepFamily { space: rand_space.param_specs().to_vec(), points: zip(points, reports) }`. The family's named view is `zip_params(&family.space, &family.points[i].params)`. ## Error handling All faults are structural and caught in `RandomSpace::new`, before any run — the typed gate, exactly as `GridSpace::new`: - `Arity { expected, got }` — `ranges.len() != space.len()`. - `NonNumericRange { slot, kind }` — the slot's declared kind is not `I64`/`F64` (a `Bool` or `Timestamp` slot cannot carry a continuous range). - `RangeKindMismatch { slot, expected, got }` — the range's kind ≠ the slot's declared kind (e.g. an `F64` range on an `I64` slot). - `EmptyRange { slot }` — the slot's range admits no sampleable value: `lo > hi` for an `I64` range (inclusive `[lo, hi]`), or `lo >= hi` for an `F64` range (the half-open `[lo, hi)` is empty when `lo == hi`). An `I64` range with `lo == hi` is valid — the single point `{lo}`. `count == 0` is **not** an error: it yields an empty family (no "accidental collapse" the way an empty grid axis is — the count is explicit). Integer sampling uses `next_u64() % span`, which carries a small modulo bias for spans that do not divide `2^64`; this is an accepted, documented simplification (param search does not need cryptographic uniformity), not rejection-sampled. ## Testing strategy - **Validation (RED-able):** arity mismatch → `Arity`; non-numeric slot (`Bool`) → `NonNumericRange`; mismatched range kind → `RangeKindMismatch`; an empty range → `EmptyRange` (I64 `lo > hi`; F64 `lo >= hi`, incl. the F64 `lo == hi` case), while an I64 `lo == hi` is accepted (single point). One test per variant, mirroring the existing `GridSpace::new` validation tests. - **Determinism (C1):** same `(ranges, count, seed)` → identical `points()`; `sweep_with_threads` at 1 and N workers → identical `SweepFamily` (mirrors `family_is_deterministic_across_thread_counts`). - **Bounds:** every sampled `I64` ∈ `[lo, hi]` inclusive; every `F64` ∈ `[lo, hi)` half-open; `count` points produced; `len()`/`is_empty()` correct; `count == 0` → empty family. - **Seed sensitivity:** two different seeds → different point sequences (not a constant family). - **Named view:** `family.named_params(i)` round-trips through `zip_params` (mirrors `family_named_params_round_trips`). - **Grid unchanged (C1):** the existing `GridSpace` sweep tests still pass under the generic `sweep` (behaviour-preserving), and `GridSpace` satisfies `Space`. - **`sweep == N independent runs`** for a `RandomSpace`, mirroring the grid test. ## Acceptance criteria Against aura's feature-acceptance criterion: - **Audience reaches for it.** A researcher tuning a many-param or continuous-range strategy cannot use a grid (the product explodes); random sampling over declared ranges is the standard tool, and the worked example is the code they write. This completes C12.1's named-but-unbuilt half. - **Improves correctness / removes redundancy.** The `Space` trait removes the `GridSpace`-only asymmetry in `sweep`, letting one execution/collection path serve both enumerations; the typed `ParamRange` localizes the non-empty-range invariant instead of leaving ranges as untyped pairs. - **Reintroduces no eliminated failure class.** C1 is preserved: `RandomSpace` points are fully determined by `seed` before any run, then executed disjointly through the unchanged `run_indexed`; `SplitMix64` is bit-stable, so a run reproduces exactly. The grid path is behaviour-preserving. No `Source`/stream type enters the sweep-layer signature (the #52 firewall holds). No look-ahead, no in-graph search policy (C12 forbids baking a search strategy into the unit). Definition of done: the new validation/determinism/bounds tests are green, the existing grid sweep tests are green under the generic `sweep`, and `cargo clippy --workspace --all-targets -- -D warnings` is clean.