# 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 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.
