Skip to main content

sim_lib_numbers_stats/
robust.rs

1//! Robust dispersion and deterministic uncertainty intervals for comparisons.
2
3use super::{StatsError, StatsResult, mean, validate_values};
4use crate::SeededSampler;
5use crate::exact_quantile;
6
7/// Controls a deterministic bootstrap of the candidate-minus-baseline mean.
8///
9/// One work unit is one sampled observation. `max_work` therefore bounds the
10/// exact resampling cost before allocation or sampling begins.
11#[derive(Clone, Copy, Debug, PartialEq)]
12pub struct BootstrapControl {
13    /// Seed for the platform-independent SplitMix64 resampler.
14    pub seed: u64,
15    /// Number of bootstrap replicates to retain.
16    pub resamples: usize,
17    /// Central interval mass, strictly between zero and one.
18    pub confidence_level: f64,
19    /// Maximum admitted sampled observations across all replicates.
20    pub max_work: u64,
21}
22
23impl BootstrapControl {
24    /// Builds and validates a bootstrap control.
25    pub fn new(
26        seed: u64,
27        resamples: usize,
28        confidence_level: f64,
29        max_work: u64,
30    ) -> StatsResult<Self> {
31        let control = Self {
32            seed,
33            resamples,
34            confidence_level,
35            max_work,
36        };
37        control.validate()?;
38        Ok(control)
39    }
40
41    fn validate(self) -> StatsResult<()> {
42        if self.resamples < 2 {
43            return Err(StatsError::InvalidControl {
44                field: "resamples",
45                reason: "must be at least two",
46            });
47        }
48        if !self.confidence_level.is_finite() || !(0.0..1.0).contains(&self.confidence_level) {
49            return Err(StatsError::InvalidControl {
50                field: "confidence_level",
51                reason: "must be finite and strictly between zero and one",
52            });
53        }
54        Ok(())
55    }
56}
57
58/// A percentile bootstrap interval for the candidate-minus-baseline mean.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub struct BootstrapEffectInterval {
61    /// Mean candidate effect minus mean baseline effect in source units.
62    pub point_effect: f64,
63    /// Lower endpoint of the central percentile interval.
64    pub lower: f64,
65    /// Upper endpoint of the central percentile interval.
66    pub upper: f64,
67    /// Central interval mass requested by the caller.
68    pub confidence_level: f64,
69    /// Seed used by the resampler.
70    pub seed: u64,
71    /// Number of retained bootstrap replicates.
72    pub resamples: usize,
73    /// Number of source baseline observations.
74    pub baseline_samples: usize,
75    /// Number of source candidate observations.
76    pub candidate_samples: usize,
77    /// Rows excluded before resampling; current strict APIs reject rather than exclude.
78    pub exclusions: usize,
79    /// Independent clusters resampled, or zero for non-clustered intervals.
80    pub cluster_count: usize,
81    /// Sampled observations or clusters admitted under `max_work`.
82    pub admitted_work: u64,
83}
84
85/// Computes the raw median absolute deviation from the sample median.
86///
87/// This returns MAD in source units without a normal-distribution scaling
88/// factor, leaving benchmark comparison policy explicit about its threshold.
89pub fn median_absolute_deviation(values: &[f64]) -> StatsResult<f64> {
90    validate_values("median_absolute_deviation", values)?;
91    let median = exact_quantile(values, 0.5).map_err(|_| StatsError::InvalidControl {
92        field: "quantile",
93        reason: "internal median quantile must remain valid",
94    })?;
95    let deviations = values
96        .iter()
97        .map(|value| (value - median).abs())
98        .collect::<Vec<_>>();
99    exact_quantile(&deviations, 0.5).map_err(|_| StatsError::InvalidControl {
100        field: "quantile",
101        reason: "internal deviation quantile must remain valid",
102    })
103}
104
105/// Bootstraps the difference between candidate and baseline arithmetic means.
106///
107/// The two samples are resampled independently with replacement. This matches
108/// the benchmark comparison policy's interleaved but not necessarily paired
109/// observations. The same inputs and control produce bit-identical results on
110/// every target with IEEE-754 `f64` arithmetic.
111pub fn bootstrap_mean_difference_interval(
112    baseline: &[f64],
113    candidate: &[f64],
114    control: BootstrapControl,
115) -> StatsResult<BootstrapEffectInterval> {
116    validate_values("bootstrap baseline", baseline)?;
117    validate_values("bootstrap candidate", candidate)?;
118    control.validate()?;
119
120    let observations = baseline
121        .len()
122        .checked_add(candidate.len())
123        .and_then(|count| u64::try_from(count).ok())
124        .ok_or(StatsError::WorkLimitExceeded {
125            required: u64::MAX,
126            limit: control.max_work,
127        })?;
128    let required = observations.checked_mul(control.resamples as u64).ok_or(
129        StatsError::WorkLimitExceeded {
130            required: u64::MAX,
131            limit: control.max_work,
132        },
133    )?;
134    if required > control.max_work {
135        return Err(StatsError::WorkLimitExceeded {
136            required,
137            limit: control.max_work,
138        });
139    }
140
141    let mut rng = SeededSampler::new(control.seed);
142    let mut effects = Vec::with_capacity(control.resamples);
143    for _ in 0..control.resamples {
144        let baseline_mean = resampled_mean(baseline, &mut rng);
145        let candidate_mean = resampled_mean(candidate, &mut rng);
146        effects.push(candidate_mean - baseline_mean);
147    }
148    let tail = (1.0 - control.confidence_level) / 2.0;
149    let lower = exact_quantile(&effects, tail).expect("validated non-empty bootstrap quantile");
150    let upper =
151        exact_quantile(&effects, 1.0 - tail).expect("validated non-empty bootstrap quantile");
152
153    Ok(BootstrapEffectInterval {
154        point_effect: mean(candidate)? - mean(baseline)?,
155        lower,
156        upper,
157        confidence_level: control.confidence_level,
158        seed: control.seed,
159        resamples: control.resamples,
160        baseline_samples: baseline.len(),
161        candidate_samples: candidate.len(),
162        exclusions: 0,
163        cluster_count: 0,
164        admitted_work: required,
165    })
166}
167
168fn resampled_mean(values: &[f64], rng: &mut SeededSampler) -> f64 {
169    let sum = (0..values.len())
170        .map(|_| values[rng.index_multiply_high(values.len())])
171        .sum::<f64>();
172    sum / values.len() as f64
173}