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