//! Param-sweep (C12.1): enumerate a blueprint's param-space — either a cartesian
//! `GridSpace` (a discrete per-slot lattice) or a seeded `RandomSpace` (`N` draws
//! over typed continuous ranges) — and run each point disjointly (C1). Both
//! enumerations implement the `Space` trait that `sweep` is generic over, so
//! either runs through one execution path. This module owns enumeration
//! (`GridSpace` / `RandomSpace` / the `Space` trait), execution (`sweep`), and
//! collection (`SweepFamily`); the per-point run-to-metrics closure is the
//! author's (harness-specific sink glue the engine cannot generically own — C8/C18).

use aura_core::{zip_params, Cell, ParamSpec, Scalar, ScalarKind};
use crate::RunReport;
use crate::harness::SplitMix64;
use std::sync::atomic::{AtomicUsize, Ordering};

/// A validated cartesian grid over a blueprint's param-space: one discrete
/// value-list per param slot, in `param_space()` order. Enumerates a family of
/// points (C12.1 grid axis).
#[derive(Debug)]
pub struct GridSpace {
    space: Vec<ParamSpec>,
    axes: Vec<Vec<Cell>>,
}

impl GridSpace {
    /// Validate `axes` against `space` (the blueprint's `param_space()`): one
    /// axis per slot (`Arity`), every value the slot's declared kind
    /// (`KindMismatch`), no empty axis (`EmptyAxis`, which would yield zero
    /// points). On success the grid enumerates `∏ |axis_i|` points.
    pub fn new(space: &[ParamSpec], axes: Vec<Vec<Scalar>>) -> Result<Self, SweepError> {
        if axes.len() != space.len() {
            return Err(SweepError::Arity { expected: space.len(), got: axes.len() });
        }
        for (slot, (axis, ps)) in axes.iter().zip(space).enumerate() {
            if axis.is_empty() {
                return Err(SweepError::EmptyAxis { slot });
            }
            for (value_index, v) in axis.iter().enumerate() {
                if v.kind() != ps.kind {
                    return Err(SweepError::KindMismatch {
                        slot,
                        value_index,
                        expected: ps.kind,
                        got: v.kind(),
                    });
                }
            }
        }
        // author edge: the values were just kind-checked above; strip the tag and
        // store the enumerated grid as tag-free cells (the kind now lives once, in
        // `space`).
        let axes = axes.iter().map(|ax| ax.iter().map(|s| s.cell()).collect()).collect();
        Ok(Self { space: space.to_vec(), axes })
    }

    /// The number of grid points (`∏ |axis_i|`), always `>= 1` (a valid grid has
    /// no empty axis; a zero-param grid is the single empty point).
    pub fn len(&self) -> usize {
        self.axes.iter().map(|a| a.len()).product()
    }

    /// Always `false` — a valid `GridSpace` rejects empty axes, so it has at
    /// least one point. Present to satisfy clippy's `len_without_is_empty`.
    pub fn is_empty(&self) -> bool {
        false
    }

    /// The cartesian product, in odometer order: the **last** axis varies
    /// fastest. Deterministic — the same grid yields the same point sequence.
    pub fn points(&self) -> Vec<Vec<Cell>> {
        let mut out = Vec::with_capacity(self.len());
        let mut idx = vec![0usize; self.axes.len()];
        loop {
            out.push(self.axes.iter().zip(&idx).map(|(a, &i)| a[i]).collect());
            // odometer increment from the last axis
            let mut k = self.axes.len();
            loop {
                if k == 0 {
                    return out;
                }
                k -= 1;
                idx[k] += 1;
                if idx[k] < self.axes[k].len() {
                    break;
                }
                idx[k] = 0;
            }
        }
    }

    /// The param-space (names + kinds) this grid was validated against, retained
    /// for the family to carry — the derived-name source (C23: names, not identity).
    pub fn param_specs(&self) -> &[ParamSpec] {
        &self.space
    }
}

/// 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)
    }
}

/// A structural fault constructing a `GridSpace` or a `RandomSpace` — the shared
/// typed gate before any run (grid faults: `Arity` / `KindMismatch` / `EmptyAxis`;
/// random faults: `NonNumericRange` / `RangeKindMismatch` / `EmptyRange`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SweepError {
    /// The number of axes does not equal the param-space slot count.
    Arity { expected: usize, got: usize },
    /// A grid value's kind does not match its slot's declared kind. `slot` is the
    /// flat param-space index; `value_index` is the position within that axis.
    KindMismatch { slot: usize, value_index: usize, expected: ScalarKind, got: ScalarKind },
    /// A slot was given no values (would collapse the product to zero points).
    EmptyAxis { slot: usize },
    /// 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 },
}

/// 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()
    }
}

/// `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
    }
}

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());
                            // span is 1..=2^64; the full-width span [i64::MIN, i64::MAX]
                            // is exactly 2^64 and wraps the u64 counter to 0, so guard it:
                            // a 0 span means "any i64", a single raw draw (no modulo).
                            let span = (hi as i128 - lo as i128 + 1) as u64;
                            let draw = if span == 0 { rng.next_u64() } else { rng.next_u64() % span };
                            Cell::from_i64(lo.wrapping_add(draw 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()
    }
}

/// One enumerated point and the full `RunReport` its run produced.
/// Self-describing: `params` is the point's coordinate in `param_space()` order,
/// and `report` carries the run's `(manifest, metrics)` — the unit the run
/// registry indexes (C18).
#[derive(Clone, Debug, PartialEq)]
pub struct SweepPoint {
    pub params: Vec<Cell>,
    pub report: RunReport,
}

/// The ordered result family of a sweep — one `SweepPoint` per grid point, in
/// enumeration (odometer) order, independent of thread completion order.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily {
    pub space: Vec<ParamSpec>,
    pub points: Vec<SweepPoint>,
}

impl SweepFamily {
    /// The i-th point's params paired with their names — a derived view over the
    /// carried param-space (reuses [`zip_params`]); no new per-point state.
    pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> {
        zip_params(&self.space, &self.points[i].params)
    }
}

/// Run `run_one` over every point the `space` produces (`Space::points` —
/// `GridSpace`'s cartesian product or `RandomSpace`'s seeded draws), disjointly
/// in parallel (C1), and collect the family in enumeration order. `run_one`
/// builds + bootstraps + runs +
/// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs
/// are lock-free. Parallelism is `available_parallelism()` workers — `std` only,
/// via `std::thread::scope`.
pub fn sweep<S, F>(space: &S, run_one: F) -> SweepFamily
where
    S: Space,
    F: Fn(&[Cell]) -> RunReport + Sync,
{
    let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
    sweep_with_threads(space, nthreads, run_one)
}

/// Run `n` disjoint jobs in parallel and collect their results in **job-index
/// order** — the shared disjoint-parallel core both `sweep` (over grid points)
/// and `monte_carlo` (over seeds) drive (C1: order is the input order, not the
/// completion order). Workers pull job indices from a shared atomic cursor
/// (work-stealing load-balances uneven per-job cost); each tags its result with
/// the index, and the results are sorted on that index after the scope joins.
/// Only the cursor is shared; the results side is lock-free. `nthreads` is
/// clamped to `[1, n.max(1)]` (a 0-job call yields an empty vec; a 0 thread
/// count coerces to 1).
pub(crate) fn run_indexed<T, F>(n: usize, nthreads: usize, run_one: F) -> Vec<T>
where
    T: Send,
    F: Fn(usize) -> T + Sync,
{
    let nthreads = nthreads.clamp(1, n.max(1));
    let cursor = AtomicUsize::new(0);

    let mut results: Vec<(usize, T)> = std::thread::scope(|scope| {
        let handles: Vec<_> = (0..nthreads)
            .map(|_| {
                scope.spawn(|| {
                    let mut local: Vec<(usize, T)> = Vec::new();
                    loop {
                        let i = cursor.fetch_add(1, Ordering::Relaxed);
                        if i >= n {
                            break;
                        }
                        local.push((i, run_one(i)));
                    }
                    local
                })
            })
            .collect();
        handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
    });

    results.sort_by_key(|&(i, _)| i);
    results.into_iter().map(|(_, t)| t).collect()
}

/// The thread-count-explicit core of [`sweep`]. Module-private: the public
/// `sweep` derives the count, while the tests drive it at 1 and at N to pin
/// determinism under concurrency (C1). A thin adapter over [`run_indexed`]: it
/// enumerates the `space`'s param-space points (`Space::points`), runs each
/// disjointly, and zips the reports back onto their points in enumeration
/// (odometer) order.
fn sweep_with_threads<S, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily
where
    S: Space,
    F: Fn(&[Cell]) -> RunReport + Sync,
{
    let points = space.points();
    let reports = run_indexed(points.len(), nthreads, |i| run_one(&points[i]));
    SweepFamily {
        space: space.param_specs().to_vec(),
        points: points
            .into_iter()
            .zip(reports)
            .map(|(params, report)| SweepPoint { params, report })
            .collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
    use crate::{f64_field, summarize, RunManifest, VecSource};
    use aura_core::{Cell, ParamSpec, Scalar, ScalarKind, Timestamp};

    fn i64_space(n: usize) -> Vec<ParamSpec> {
        (0..n)
            .map(|i| ParamSpec { name: format!("p{i}"), kind: ScalarKind::I64 })
            .collect()
    }

    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());
    }

    #[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));
    }

    #[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_full_range_i64_does_not_panic() {
        // Property: `points()` samples a full-width inclusive I64 range without a
        // divide-by-zero panic. The span [i64::MIN, i64::MAX] is exactly 2^64,
        // which overflows the u64 span counter to 0; the sampler must treat a
        // full-width span as "any i64" rather than `% 0`.
        let space = one_i64_space();
        let rs = RandomSpace::new(
            &space,
            vec![ParamRange::i64(i64::MIN, i64::MAX)],
            64,
            0xABCD,
        )
        .expect("a full-width inclusive I64 range is non-empty");
        let pts = rs.points();
        assert_eq!(pts.len(), 64);
        // every drawn value is a valid i64 (the whole range is admissible); the
        // assertion that matters is that the call above did not panic.
        for p in &pts {
            let _ = p[0].i64();
        }
    }

    #[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());
    }

    #[test]
    fn points_enumerate_in_odometer_order() {
        let space = i64_space(2);
        let grid = GridSpace::new(
            &space,
            vec![
                vec![Scalar::i64(2), Scalar::i64(3)],
                vec![Scalar::i64(4), Scalar::i64(5)],
            ],
        )
        .expect("valid grid");
        assert_eq!(
            grid.points(),
            vec![
                vec![Cell::from_i64(2), Cell::from_i64(4)],
                vec![Cell::from_i64(2), Cell::from_i64(5)],
                vec![Cell::from_i64(3), Cell::from_i64(4)],
                vec![Cell::from_i64(3), Cell::from_i64(5)],
            ],
        );
    }

    #[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());
    }

    #[test]
    fn len_is_product_of_axis_lengths() {
        let space = vec![
            ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
            ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
            ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
        ];
        let grid = GridSpace::new(
            &space,
            vec![
                vec![Scalar::i64(2), Scalar::i64(3)],
                vec![Scalar::i64(4), Scalar::i64(5)],
                vec![Scalar::f64(0.5)],
            ],
        )
        .expect("valid grid");
        assert_eq!(grid.len(), 4);
        assert!(!grid.is_empty());
    }

    #[test]
    fn arity_mismatch_is_an_error() {
        let space = i64_space(2);
        let err = GridSpace::new(&space, vec![vec![Scalar::i64(2)]]).unwrap_err();
        assert_eq!(err, SweepError::Arity { expected: 2, got: 1 });
    }

    #[test]
    fn wrong_kind_is_a_kind_mismatch() {
        let space = i64_space(1);
        let err = GridSpace::new(&space, vec![vec![Scalar::f64(1.0)]]).unwrap_err();
        assert_eq!(
            err,
            SweepError::KindMismatch {
                slot: 0,
                value_index: 0,
                expected: ScalarKind::I64,
                got: ScalarKind::F64,
            },
        );
    }

    #[test]
    fn empty_axis_is_an_error() {
        let space = i64_space(1);
        let err = GridSpace::new(&space, vec![vec![]]).unwrap_err();
        assert_eq!(err, SweepError::EmptyAxis { slot: 0 });
    }

    /// Build + bootstrap + run + drain + summarize one grid point into a
    /// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `sweep` closure
    /// AND a direct reference for the "sweep == N independent runs" comparison.
    /// The manifest is a minimal fixed fixture — the metrics are the run's, and
    /// determinism makes `run_point` reproduce a point's report exactly.
    fn run_point(point: &[Cell]) -> RunReport {
        let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
        let mut h = bp
            .bootstrap_with_cells(point)
            .expect("enumerated grid points are pre-validated by GridSpace::new");
        h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
        let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
        let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
        RunReport {
            manifest: RunManifest {
                commit: "test".to_string(),
                params: Vec::new(),
                window: (Timestamp(0), Timestamp(0)),
                seed: 0,
                broker: "test".to_string(),
            },
            metrics: summarize(&equity, &exposure),
        }
    }

    fn sma_cross_grid() -> GridSpace {
        let space = composite_sma_cross_harness().0.param_space();
        GridSpace::new(
            &space,
            vec![
                vec![Scalar::i64(2), Scalar::i64(3)], // fast  ∈ {2, 3}
                vec![Scalar::i64(4), Scalar::i64(5)], // slow  ∈ {4, 5}
                vec![Scalar::f64(0.5)],               // scale ∈ {0.5}
            ],
        )
        .expect("grid matches the sample param-space")
    }

    #[test]
    fn sweep_equals_n_independent_runs() {
        let grid = sma_cross_grid();
        let family = sweep(&grid, run_point);
        assert_eq!(family.points.len(), 4);
        // params carried in enumeration (odometer) order, as tag-free cells (the
        // kind lives once, in `family.space`)
        assert_eq!(
            family.points[0].params,
            vec![Cell::from_i64(2), Cell::from_i64(4), Cell::from_f64(0.5)],
        );
        assert_eq!(
            family.points[3].params,
            vec![Cell::from_i64(3), Cell::from_i64(5), Cell::from_f64(0.5)],
        );
        // each point's metrics equal a direct, independent run of the same point:
        // the 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 sweep_family_carries_param_space() {
        let space = composite_sma_cross_harness().0.param_space();
        let family = sweep(&sma_cross_grid(), run_point);
        assert_eq!(family.space, space);
    }

    #[test]
    fn family_named_params_round_trips() {
        let space = composite_sma_cross_harness().0.param_space();
        let family = sweep(&sma_cross_grid(), run_point);
        // odometer-first point is [I64(2), I64(4), F64(0.5)]
        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);
        assert_eq!(family.named_params(0)[0].1, Scalar::i64(2));
    }

    #[test]
    fn family_is_deterministic_across_thread_counts() {
        let grid = sma_cross_grid();
        let one = sweep_with_threads(&grid, 1, run_point);
        let many = sweep_with_threads(&grid, 8, run_point);
        // same family at 1 worker and at N (C1: order = enumeration, not completion)
        assert_eq!(one, many);
        // and identical to the public `sweep`, which derives the worker count
        assert_eq!(one, sweep(&grid, run_point));
    }

    #[test]
    fn distinct_points_produce_distinct_metrics() {
        let family = sweep(&sma_cross_grid(), run_point);
        // not a constant family: differing SMA lengths produce differing equity
        let first = family.points[0].report.metrics.total_pips;
        assert!(
            family.points.iter().any(|p| p.report.metrics.total_pips != first),
            "a 4-point SMA-length grid must not collapse to one metric",
        );
    }

    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);
    }
}
