Files
Aura/docs/plans/0049-random-param-sweep.md
T
Brummel b99f52443d plan: 0049 random param-sweep
Five bite-sized tasks for the RandomSpace cut: (1) the Space trait +
behaviour-preserving generalization of sweep/sweep_with_threads, (2) typed
ParamRange, (3) RandomSpace::new validation + three SweepError variants,
(4) SplitMix64 -> pub(crate) + the seeded sampler, (5) public exports +
random-sweep integration. Each task is RED-first with single-substring test
filters; the grid suite is the C1 behaviour-preservation regression guard.

refs #52
2026-06-17 12:46:45 +02:00

26 KiB
Raw Blame History

Random param-sweep: RandomSpace + a Space trait + typed ParamRange — Implementation Plan

Parent spec: docs/specs/0049-random-param-sweep.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Ship the random half of the C12.1 param-sweep axis — a RandomSpace that draws N seeded uniform points over declared continuous ranges, fed to the same enumeration-agnostic sweep core the grid already drives, via a new Space trait both enumerations implement.

Architecture: All work lands in crates/aura-engine. A new Space trait (points/param_specs) generalizes sweep/sweep_with_threads from &GridSpace to &S: Space (behaviour-preserving — GridSpace's trait impl forwards to its existing inherent methods, C1). RandomSpace is the second Space producer: a typed ParamRange { lo, hi } per slot, validated in new against the param-space, sampled by a code-path-disjoint SplitMix64 instance (the #52/#71 World-II source-seam firewall — the param-sampling RNG and the data-edge seed RNG share only the u64 type, never a code path).

Tech Stack: Rust; crates/aura-engine/src/sweep.rs (trait + ParamRange + RandomSpace + signature generalization + three new SweepError variants), crates/aura-engine/src/harness.rs (SplitMix64pub(crate)), crates/aura-engine/src/lib.rs (exports). Tests live in the existing sweep.rs mod tests.


Files this plan creates or modifies:

  • Modify: crates/aura-engine/src/sweep.rsSpace trait + impl Space for GridSpace (forwarding); ParamRange; three new SweepError variants; RandomSpace + RandomSpace::new + impl Space for RandomSpace; sweep/sweep_with_threads generalized to &S: Space. New tests in mod tests.
  • Modify: crates/aura-engine/src/harness.rs:131-150SplitMix64 struct + its new/next_u64/next_f64 methods from module-private to pub(crate).
  • Modify: crates/aura-engine/src/lib.rs:65 — add RandomSpace, ParamRange, Space to the pub use sweep::{…} line.
  • Test: crates/aura-engine/src/sweep.rs mod testsSpace-trait satisfaction, ParamRange constructors, RandomSpace::new validation (one per error variant + accepted I64 lo==hi), sampler bounds/determinism/seed-sensitivity/zero-count, and the random-sweep integration (== N independent runs, determinism across thread counts, named-view round-trip).

Task 1: Space trait + impl Space for GridSpace + generalize sweep/sweep_with_threads

The behaviour-preserving refactor (C1): introduce the trait, have GridSpace implement it by forwarding to its inherent methods, and make the execution core generic. The existing grid sweep tests are the regression guard — they must stay green; the new test pins that GridSpace satisfies Space.

Files:

  • Modify: crates/aura-engine/src/sweep.rs (trait + impl after line 91; signatures at :136-142 and :189-203)

  • Test: crates/aura-engine/src/sweep.rs mod tests

  • Step 1: Write the failing test

Add to mod tests in crates/aura-engine/src/sweep.rs (after points_enumerate_in_odometer_order, around line 238):

    #[test]
    fn grid_space_satisfies_space_trait() {
        // A generic helper that can only call the trait surface — proves GridSpace
        // is usable through `Space`, the property `sweep` now relies on.
        fn via_trait<S: Space>(s: &S) -> (Vec<Vec<Cell>>, Vec<ParamSpec>) {
            (s.points(), s.param_specs().to_vec())
        }
        let grid = sma_cross_grid();
        let (pts, specs) = via_trait(&grid);
        // the trait surface forwards to the inherent methods — identical results
        assert_eq!(pts, grid.points());
        assert_eq!(specs, grid.param_specs().to_vec());
    }
  • Step 2: Run test to verify it fails

Run: cargo test -p aura-engine grid_space_satisfies_space_trait Expected: FAIL — compilation error: cannot find trait Space in this scope (the trait does not exist yet, so the via_trait<S: Space> bound is unresolved).

  • Step 3: Add the Space trait, the GridSpace impl, and generalize the signatures

In crates/aura-engine/src/sweep.rs, insert the trait and impl immediately after the impl GridSpace { … } block (after line 91, before the SweepError enum):

/// The enumeration interface `sweep` runs over: a producer of param-space points.
/// Both `GridSpace` (cartesian product) and `RandomSpace` (seeded draws) implement
/// it, so the disjoint execution core (`run_indexed`) carries either enumeration
/// through one path (C1).
pub trait Space {
    /// The enumerated points, each a tag-free coordinate in `param_specs()` order
    /// (the kind lives once, in `param_specs()`).
    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 {
    // `GridSpace::points(self)` is path syntax that selects the *inherent* method
    // (inherent methods win method resolution), so this forward does not recurse;
    // the grid path is behaviour-preserving (C1).
    fn points(&self) -> Vec<Vec<Cell>> {
        GridSpace::points(self)
    }
    fn param_specs(&self) -> &[ParamSpec] {
        GridSpace::param_specs(self)
    }
}

Then change sweep's signature (currently sweep.rs:136-139) from:

pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
where
    F: Fn(&[Cell]) -> RunReport + Sync,
{

to:

pub fn sweep<S, F>(space: &S, run_one: F) -> SweepFamily
where
    S: Space,
    F: Fn(&[Cell]) -> RunReport + Sync,
{

(the body — available_parallelism + sweep_with_threads(space, nthreads, run_one) — is unchanged.)

And change sweep_with_threads's signature (currently sweep.rs:189-192) from:

fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
where
    F: Fn(&[Cell]) -> RunReport + Sync,
{

to:

fn sweep_with_threads<S, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily
where
    S: Space,
    F: Fn(&[Cell]) -> RunReport + Sync,
{

(the body — space.points(), run_indexed, space.param_specs().to_vec(), the zip — is unchanged; it already calls only the two Space methods.)

  • Step 4: Run the new test and the existing grid suite to verify green

Run: cargo test -p aura-engine grid_space_satisfies_space_trait Expected: PASS.

Run: cargo test -p aura-engine sweep Expected: PASS — all existing grid tests (sweep_equals_n_independent_runs, family_is_deterministic_across_thread_counts, sweep_family_carries_param_space, family_named_params_round_trips, points_enumerate_in_odometer_order, …) stay green: the &GridSpace&S: Space swap is behaviour-preserving, and the cross-module caller blueprint.rs:392 compiles unchanged because GridSpace: Space.


Task 2: ParamRange — a typed, kind-tagged range

A { lo, hi } pair carrying a shared kind by construction; the home for the non-empty-range invariant (validated in Task 3). No bool/timestamp constructors — those kinds are not range-sampleable.

Files:

  • Modify: crates/aura-engine/src/sweep.rs (insert after the SweepError enum, around line 103)

  • Test: crates/aura-engine/src/sweep.rs mod tests

  • Step 1: Write the failing test

Add to mod tests in crates/aura-engine/src/sweep.rs:

    #[test]
    fn param_range_constructors_carry_kind() {
        let ri = ParamRange::i64(2, 50);
        assert_eq!(ri.kind(), ScalarKind::I64);
        assert_eq!(ri.lo, Scalar::i64(2));
        assert_eq!(ri.hi, Scalar::i64(50));

        let rf = ParamRange::f64(0.1, 2.0);
        assert_eq!(rf.kind(), ScalarKind::F64);
        assert_eq!(rf.lo, Scalar::f64(0.1));
        assert_eq!(rf.hi, Scalar::f64(2.0));
    }
  • Step 2: Run test to verify it fails

Run: cargo test -p aura-engine param_range_constructors_carry_kind Expected: FAIL — compilation error: cannot find ... ParamRange in this scope (the type does not exist yet).

  • Step 3: Add ParamRange

In crates/aura-engine/src/sweep.rs, insert after the SweepError enum (after the closing } at line 103):

/// A typed, kind-tagged continuous range for one `RandomSpace` param slot, carried
/// positional-parallel to the param-space. `lo`/`hi` share a kind by construction;
/// this is the home for the non-empty-range invariant (validated in
/// [`RandomSpace::new`]) and, later, a distribution tag.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ParamRange {
    pub lo: Scalar,
    pub hi: Scalar,
}

impl ParamRange {
    /// An inclusive `[lo, hi]` I64 range.
    pub fn i64(lo: i64, hi: i64) -> Self {
        Self { lo: Scalar::i64(lo), hi: Scalar::i64(hi) }
    }
    /// A half-open `[lo, hi)` F64 range.
    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()
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test -p aura-engine param_range_constructors_carry_kind Expected: PASS.


Task 3: RandomSpace struct + RandomSpace::new validation + three new SweepError variants

The typed gate: every structural fault is caught in new, before any run — exactly as GridSpace::new. Adds the three error variants new produces.

Files:

  • Modify: crates/aura-engine/src/sweep.rs (three SweepError variants append into the enum at :95-103; RandomSpace + impl RandomSpace after ParamRange)

  • Test: crates/aura-engine/src/sweep.rs mod tests

  • Step 1: Write the failing tests

Add to mod tests in crates/aura-engine/src/sweep.rs. (The helper i64_space already exists at sweep.rs:212; add one_i64_space once, alongside the tests.)

    fn one_i64_space() -> Vec<ParamSpec> {
        vec![ParamSpec { name: "p0".into(), kind: ScalarKind::I64 }]
    }

    #[test]
    fn random_space_new_arity_mismatch() {
        let space = i64_space(2);
        let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0).unwrap_err();
        assert_eq!(err, SweepError::Arity { expected: 2, got: 1 });
    }

    #[test]
    fn random_space_new_non_numeric_slot() {
        let space = vec![ParamSpec { name: "b".into(), kind: ScalarKind::Bool }];
        let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0).unwrap_err();
        assert_eq!(err, SweepError::NonNumericRange { slot: 0, kind: ScalarKind::Bool });
    }

    #[test]
    fn random_space_new_range_kind_mismatch() {
        let space = one_i64_space();
        let err = RandomSpace::new(&space, vec![ParamRange::f64(0.0, 1.0)], 10, 0).unwrap_err();
        assert_eq!(
            err,
            SweepError::RangeKindMismatch { slot: 0, expected: ScalarKind::I64, got: ScalarKind::F64 },
        );
    }

    #[test]
    fn random_space_new_empty_i64_range() {
        // inclusive [lo, hi] is empty iff lo > hi
        let space = one_i64_space();
        let err = RandomSpace::new(&space, vec![ParamRange::i64(5, 4)], 10, 0).unwrap_err();
        assert_eq!(err, SweepError::EmptyRange { slot: 0 });
    }

    #[test]
    fn random_space_new_empty_f64_range() {
        // half-open [lo, hi) is empty at lo == hi
        let space = vec![ParamSpec { name: "f".into(), kind: ScalarKind::F64 }];
        let err = RandomSpace::new(&space, vec![ParamRange::f64(1.0, 1.0)], 10, 0).unwrap_err();
        assert_eq!(err, SweepError::EmptyRange { slot: 0 });
    }

    #[test]
    fn random_space_new_accepts_i64_single_point() {
        // inclusive [lo, hi] with lo == hi is the valid single point {lo}
        let space = one_i64_space();
        let rs = RandomSpace::new(&space, vec![ParamRange::i64(7, 7)], 3, 0)
            .expect("lo == hi is a valid single-point I64 range");
        assert_eq!(rs.len(), 3);
    }

    #[test]
    fn random_space_len_and_is_empty() {
        let space = one_i64_space();
        let rs = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 5, 0).unwrap();
        assert_eq!(rs.len(), 5);
        assert!(!rs.is_empty());
        let empty = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 0, 0).unwrap();
        assert_eq!(empty.len(), 0);
        assert!(empty.is_empty());
    }
  • Step 2: Run tests to verify they fail

Run: cargo test -p aura-engine random_space_ Expected: FAIL — compilation error: cannot find ... RandomSpace and no variant ... NonNumericRange / RangeKindMismatch / EmptyRange on SweepError (the type and variants do not exist yet).

  • Step 3: Append the three SweepError variants and add RandomSpace

In crates/aura-engine/src/sweep.rs, append the three variants inside the SweepError enum, immediately after the EmptyAxis { slot: usize }, line (:102):

    /// A `RandomSpace` slot's declared kind is not range-sampleable (`I64`/`F64`):
    /// a `Bool` is degenerate, a `Timestamp` is a structural axis (C20).
    NonNumericRange { slot: usize, kind: ScalarKind },
    /// A `ParamRange`'s kind does not match its slot's declared kind.
    RangeKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
    /// A `ParamRange` admits no sampleable value: `lo > hi` for an inclusive I64
    /// range, or `lo >= hi` for a half-open F64 range.
    EmptyRange { slot: usize },

Then add RandomSpace after the ParamRange impl (from Task 2):

/// `count` seeded uniform points over per-slot continuous `ranges`, validated
/// against a blueprint's param-space — the random sibling to `GridSpace` (C12.1).
/// The points are fully determined by `seed` before any run (C1).
#[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 })
    }

    /// The number of points this space draws (`count`).
    pub fn len(&self) -> usize {
        self.count
    }

    /// `true` iff `count == 0` (an explicit empty family). Present alongside
    /// `len` to satisfy clippy's `len_without_is_empty`.
    pub fn is_empty(&self) -> bool {
        self.count == 0
    }
}
  • Step 4: Run tests to verify they pass

Run: cargo test -p aura-engine random_space_ Expected: PASS — all seven random_space_* tests green.


Task 4: SplitMix64pub(crate) + impl Space for RandomSpace (the seeded sampler)

Promote the existing bit-stable PRNG to crate scope (so sweep.rs reuses the same algorithm, not a copy) and implement the sampler. A code-path-disjoint instance from the data-edge seed RNG (#52/#71 firewall).

Files:

  • Modify: crates/aura-engine/src/harness.rs:131-150 (SplitMix64 + methods → pub(crate))

  • Modify: crates/aura-engine/src/sweep.rs (import SplitMix64; impl Space for RandomSpace)

  • Test: crates/aura-engine/src/sweep.rs mod tests

  • Step 1: Write the failing tests

Add to mod tests in crates/aura-engine/src/sweep.rs:

    #[test]
    fn random_points_respect_bounds() {
        let space = vec![
            ParamSpec { name: "i".into(), kind: ScalarKind::I64 },
            ParamSpec { name: "f".into(), kind: ScalarKind::F64 },
        ];
        let ranges = vec![ParamRange::i64(2, 50), ParamRange::f64(0.1, 2.0)];
        let rs = RandomSpace::new(&space, ranges, 500, 0xC0FFEE).unwrap();
        let pts = rs.points();
        assert_eq!(pts.len(), 500);
        for p in &pts {
            let i = p[0].i64();
            let f = p[1].f64();
            assert!((2..=50).contains(&i), "I64 inclusive [2,50], got {i}");
            assert!((0.1..2.0).contains(&f), "F64 half-open [0.1,2.0), got {f}");
        }
    }

    #[test]
    fn random_points_are_deterministic() {
        let space = one_i64_space();
        let mk = || {
            RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 42)
                .unwrap()
                .points()
        };
        assert_eq!(mk(), mk(), "same (ranges, count, seed) => identical points");
    }

    #[test]
    fn random_points_seed_sensitive() {
        let space = one_i64_space();
        let a = RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 1)
            .unwrap()
            .points();
        let b = RandomSpace::new(&space, vec![ParamRange::i64(0, 1_000_000)], 100, 2)
            .unwrap()
            .points();
        assert_ne!(a, b, "different seeds => different point sequences");
    }

    #[test]
    fn random_points_zero_count_is_empty() {
        let space = one_i64_space();
        let rs = RandomSpace::new(&space, vec![ParamRange::i64(0, 10)], 0, 0).unwrap();
        assert!(rs.points().is_empty());
    }
  • Step 2: Run tests to verify they fail

Run: cargo test -p aura-engine random_points Expected: FAIL — compilation error: no method named points found for struct RandomSpace (the Space impl for RandomSpace does not exist yet).

  • Step 3a: Promote SplitMix64 to pub(crate) in harness.rs

In crates/aura-engine/src/harness.rs, change the four visibility keywords only (bodies unchanged). Line :131:

struct SplitMix64 {

becomes:

pub(crate) struct SplitMix64 {

and the three methods (:136, :139, :147):

    fn new(seed: u64) -> Self {
    fn next_u64(&mut self) -> u64 {
    fn next_f64(&mut self) -> f64 {

become:

    pub(crate) fn new(seed: u64) -> Self {
    pub(crate) fn next_u64(&mut self) -> u64 {
    pub(crate) fn next_f64(&mut self) -> f64 {

(The existing in-crate user SplitMix64::new(seed) at harness.rs:180 keeps compiling under the wider visibility.)

  • Step 3b: Import SplitMix64 and add impl Space for RandomSpace in sweep.rs

In crates/aura-engine/src/sweep.rs, add the import after line 8 (use crate::RunReport;):

use crate::harness::SplitMix64;

Then add the sampler impl after the impl RandomSpace { … } block (from Task 3):

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 is sampled in declared `param_specs()` order (points in
    /// sequence, slots within a point in order). Deterministic: same
    /// `(ranges, count, seed)` => identical points, identical to a re-run (C1).
    /// This RNG instance is code-path-disjoint from the data-edge seed RNG (the
    /// #52/#71 World-II firewall): they share only the `u64` type, never a path.
    fn points(&self) -> Vec<Vec<Cell>> {
        let mut rng = SplitMix64::new(self.seed);
        (0..self.count)
            .map(|_| {
                self.ranges
                    .iter()
                    .map(|r| match r.kind() {
                        // inclusive [lo, hi]; span via i128 then u64 handles a negative lo.
                        // (Modulo bias for spans not dividing 2^64 is an accepted,
                        // documented simplification — param search needs no crypto
                        // uniformity, spec §"Error handling".)
                        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!("RandomSpace::new rejects non-numeric ranges"),
                    })
                    .collect()
            })
            .collect()
    }
}
  • Step 4: Run the sampler tests and a crate build to verify green

Run: cargo test -p aura-engine random_points Expected: PASS — all four random_points* tests green.

Run: cargo build -p aura-engine Expected: clean build (0 errors) — the data-edge SplitMix64::new user at harness.rs:180 still compiles under pub(crate).


Task 5: lib.rs exports + the random-sweep integration tests

Make RandomSpace/ParamRange/Space public, and pin that a RandomSpace flows through the same sweep as the grid: equals N independent runs, deterministic across thread counts, named view round-trips. Final regression + lint gate.

Files:

  • Modify: crates/aura-engine/src/lib.rs:65 (export line)

  • Test: crates/aura-engine/src/sweep.rs mod tests

  • Step 1: Write the failing tests

Add to mod tests in crates/aura-engine/src/sweep.rs (reuses the existing run_point fixture and composite_sma_cross_harness, whose param-space is [fast: I64, slow: I64, scale: F64]):

    fn sma_cross_random() -> RandomSpace {
        let space = composite_sma_cross_harness().0.param_space();
        RandomSpace::new(
            &space,
            vec![
                // integer windows drawn from exactly the grid test's proven domain
                // ({2,3}×{4,5}): all <= the 7-tick fixture length, so the SMAs warm
                // up and metrics are finite & non-degenerate. scale is continuous
                // (a bounded multiplier over a clamped exposure), spanning the grid's
                // 0.5.
                ParamRange::i64(2, 3),      // fast  ∈ [2, 3]
                ParamRange::i64(4, 5),      // slow  ∈ [4, 5]
                ParamRange::f64(0.25, 1.5), // scale ∈ [0.25, 1.5)
            ],
            8,
            0xABCDEF,
        )
        .expect("ranges match the sample param-space kinds")
    }

    #[test]
    fn random_sweep_equals_n_independent_runs() {
        let rs = sma_cross_random();
        let family = sweep(&rs, run_point);
        assert_eq!(family.points.len(), 8);
        // each point's metrics equal a direct, independent run of the same point:
        // the random sweep adds enumeration + execution, never a metrics change (C1).
        for pt in &family.points {
            assert_eq!(pt.report, run_point(&pt.params));
            assert!(pt.report.metrics.total_pips.is_finite());
        }
    }

    #[test]
    fn random_sweep_is_deterministic_across_thread_counts() {
        let rs = sma_cross_random();
        let one = sweep_with_threads(&rs, 1, run_point);
        let many = sweep_with_threads(&rs, 8, run_point);
        // same family at 1 worker and at N (C1: order = enumeration, not completion)
        assert_eq!(one, many);
        assert_eq!(one, sweep(&rs, run_point));
    }

    #[test]
    fn random_sweep_family_named_view_round_trips() {
        let rs = sma_cross_random();
        let family = sweep(&rs, run_point);
        let space = composite_sma_cross_harness().0.param_space();
        let expected: Vec<(String, Scalar)> = space
            .iter()
            .zip(&family.points[0].params)
            .map(|(ps, c)| (ps.name.clone(), Scalar::from_cell(ps.kind, *c)))
            .collect();
        assert_eq!(family.named_params(0), expected);
    }
  • Step 2: Run tests to verify they pass (behaviour already shipped in Tasks 14)

Run: cargo test -p aura-engine random_sweep Expected: PASS — the three random_sweep_* tests green. (RandomSpace: Space and sweep's generic signature already exist; these tests assert the integration.)

  • Step 3: Add the public exports in lib.rs

In crates/aura-engine/src/lib.rs, change line 65 from:

pub use sweep::{sweep, GridSpace, SweepError, SweepFamily, SweepPoint};

to:

pub use sweep::{sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint};
  • Step 4: Final workspace build, full suite, and lint gate

Run: cargo build --workspace Expected: clean build (0 errors) — the new exports resolve; no consumer breaks.

Run: cargo test -p aura-engine Expected: PASS — the full aura-engine suite is green, including every pre-existing grid sweep test (C1 behaviour-preservation) alongside the new RandomSpace tests.

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean — no warnings.