spec: 0049 random param-sweep (boss-signed)

RandomSpace + a Space trait + typed ParamRange: the random half of the
C12.1 param-sweep axis (grid shipped in 0028). N seeded uniform draws over
declared continuous ranges, fed to the same enumeration-agnostic sweep core.
Param-only sweep signature and a code-path-disjoint SplitMix64 instance keep
the World-II source-seam firewall (refs #71) intact.

Auto-signed under /boss spec auto-sign: fresh grounding-check PASS and a
unanimous five-lens spec-skeptic panel (criterion, grounding, scope-fork,
ambiguity, plan-readiness all SOUND).

refs #52
This commit is contained in:
2026-06-17 12:36:39 +02:00
parent 67c1f51cfe
commit 5b3d133529
+343
View File
@@ -0,0 +1,343 @@
# 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.