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:
@@ -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.
|
||||
Reference in New Issue
Block a user