Files
Aura/docs/specs/0049-random-param-sweep.md
T
Brummel 5b3d133529 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
2026-06-17 12:36:39 +02:00

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:

  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:

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.rsSpace 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.rsSplitMix64 (+ 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_threadsrand_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.