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
16 KiB
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:
- A
Spacetrait (points,param_specs) implemented by bothGridSpaceand the newRandomSpace;sweepbecomes generic overimpl 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). RandomSpace— a sibling toGridSpacethat, given a param-space, a typed range per slot, a sample count, and a seed, producesNdeterministic points.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:
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.
// 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.
// 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.
// 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.
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—Spacetrait;impl Space for GridSpace(forwarding);ParamRange;RandomSpace+impl Space for RandomSpace; three newSweepErrorvariants;sweep/sweep_with_threadsgeneralized to&S: Space.crates/aura-engine/src/harness.rs—SplitMix64(+ its methods)pub(crate).crates/aura-engine/src/lib.rs— exportRandomSpace,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 notI64/F64(aBoolorTimestampslot cannot carry a continuous range).RangeKindMismatch { slot, expected, got }— the range's kind ≠ the slot's declared kind (e.g. anF64range on anI64slot).EmptyRange { slot }— the slot's range admits no sampleable value:lo > hifor anI64range (inclusive[lo, hi]), orlo >= hifor anF64range (the half-open[lo, hi)is empty whenlo == hi). AnI64range withlo == hiis 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(I64lo > hi; F64lo >= hi, incl. the F64lo == hicase), while an I64lo == hiis accepted (single point). One test per variant, mirroring the existingGridSpace::newvalidation tests. - Determinism (C1): same
(ranges, count, seed)→ identicalpoints();sweep_with_threadsat 1 and N workers → identicalSweepFamily(mirrorsfamily_is_deterministic_across_thread_counts). - Bounds: every sampled
I64∈[lo, hi]inclusive; everyF64∈[lo, hi)half-open;countpoints 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 throughzip_params(mirrorsfamily_named_params_round_trips). - Grid unchanged (C1): the existing
GridSpacesweep tests still pass under the genericsweep(behaviour-preserving), andGridSpacesatisfiesSpace. sweep == N independent runsfor aRandomSpace, 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
Spacetrait removes theGridSpace-only asymmetry insweep, letting one execution/collection path serve both enumerations; the typedParamRangelocalizes the non-empty-range invariant instead of leaving ranges as untyped pairs. - Reintroduces no eliminated failure class. C1 is preserved:
RandomSpacepoints are fully determined byseedbefore any run, then executed disjointly through the unchangedrun_indexed;SplitMix64is bit-stable, so a run reproduces exactly. The grid path is behaviour-preserving. NoSource/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.