From 5b3d13352941c0983061c220029b17fced35794e Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 17 Jun 2026 12:36:39 +0200 Subject: [PATCH] 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 --- docs/specs/0049-random-param-sweep.md | 343 ++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/specs/0049-random-param-sweep.md diff --git a/docs/specs/0049-random-param-sweep.md b/docs/specs/0049-random-param-sweep.md new file mode 100644 index 0000000..ced4f64 --- /dev/null +++ b/docs/specs/0049-random-param-sweep.md @@ -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>`) 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> | -> 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>; + /// 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> { GridSpace::points(self) } + fn param_specs(&self) -> &[ParamSpec] { GridSpace::param_specs(self) } +} +impl Space for RandomSpace { /* as below */ } + +// BEFORE +pub fn sweep(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(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, + ranges: Vec, + 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, count: usize, seed: u64) + -> Result + { + 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> { + 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>`) → `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.