# 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<Vec<Cell>>`) 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<Vec<Cell>>      |        -> 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<Vec<Cell>>;
    /// 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<Vec<Cell>> { GridSpace::points(self) }
    fn param_specs(&self) -> &[ParamSpec] { GridSpace::param_specs(self) }
}
impl Space for RandomSpace { /* as below */ }

// BEFORE
pub fn sweep<F>(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<S, F>(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<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 })
    }

    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<Vec<Cell>> {
        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<Vec<Cell>>`) → `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.
