Skip to main content

launchbound_bench/
stats.rs

1//! Interval statistics (docs/BENCHMARKING.md): a benchmark that reports a mean
2//! and no interval is not evidence. Median with a distribution-free 95% CI
3//! (order statistics), Tukey-fence outlier rejection, and an overlap test —
4//! configurations whose intervals overlap are indistinguishable, never
5//! ranked.
6
7use serde::{Deserialize, Serialize};
8
9/// A candidate's timings, reduced to what a decision needs.
10///
11/// The interval is the point. Two configurations whose 95% CIs overlap are
12/// reported indistinguishable and never ranked against each other
13/// (`docs/BENCHMARKING.md`), because a tool that puts a winner's name on
14/// measurement noise is worse than one that says it cannot tell.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct Summary {
17    /// Samples kept after outlier rejection.
18    pub n: usize,
19    /// Samples dropped by the Tukey fences, NaNs among them.
20    pub outliers_rejected: usize,
21    /// Median of the kept samples — the statistic everything ranks on,
22    /// chosen over the mean because a single descheduled launch should not
23    /// move it.
24    pub median_ms: f64,
25    /// Distribution-free 95% CI on the median (order statistics).
26    pub ci95_lo_ms: f64,
27    /// Upper bound of that interval.
28    pub ci95_hi_ms: f64,
29    /// Fastest kept sample.
30    pub min_ms: f64,
31    /// Slowest kept sample.
32    pub max_ms: f64,
33    /// Mean of the kept samples. Reported for context; nothing ranks on it.
34    pub mean_ms: f64,
35}
36
37/// Summarize raw timings. The Tukey fences (1.5 IQR) run first; the median
38/// CI uses the normal approximation to the binomial order-statistic
39/// interval, clamped to the sample range.
40///
41/// # NaN
42///
43/// A NaN timing cannot be ordered against anything, so the sort uses
44/// [`f64::total_cmp`], which is total: `-NaN` sorts below `-inf` and `+NaN`
45/// above `+inf`. A NaN then fails both fence comparisons and is *rejected as
46/// an outlier*, counted in `outliers_rejected`. If enough of them poison the
47/// quantiles that nothing survives the fences, the result is `None` — which
48/// is the honest summary of a sample that has none. Nothing here panics on a
49/// NaN, and no NaN reaches `median_ms`.
50pub fn summarize(samples_ms: &[f64]) -> Option<Summary> {
51    if samples_ms.is_empty() {
52        return None;
53    }
54    let mut sorted: Vec<f64> = samples_ms.to_vec();
55    sorted.sort_by(f64::total_cmp);
56
57    let q1 = quantile(&sorted, 0.25);
58    let q3 = quantile(&sorted, 0.75);
59    let iqr = q3 - q1;
60    let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
61    let kept: Vec<f64> = sorted
62        .iter()
63        .copied()
64        .filter(|&x| x >= lo_fence && x <= hi_fence)
65        .collect();
66    let outliers_rejected = sorted.len() - kept.len();
67    let n = kept.len();
68    if n == 0 {
69        return None;
70    }
71
72    let median = quantile(&kept, 0.5);
73    // Order-statistic 95% CI for the median: ranks n/2 ± 1.96*sqrt(n)/2.
74    let half_width = 1.96 * (n as f64).sqrt() / 2.0;
75    let lo_rank = ((n as f64) / 2.0 - half_width).floor().max(0.0) as usize;
76    let hi_rank = (((n as f64) / 2.0 + half_width).ceil() as usize).min(n - 1);
77    let mean = kept.iter().sum::<f64>() / n as f64;
78
79    Some(Summary {
80        n,
81        outliers_rejected,
82        median_ms: median,
83        ci95_lo_ms: kept[lo_rank],
84        ci95_hi_ms: kept[hi_rank],
85        min_ms: kept[0],
86        max_ms: kept[n - 1],
87        mean_ms: mean,
88    })
89}
90
91/// Linear-interpolated quantile of a sorted slice.
92fn quantile(sorted: &[f64], q: f64) -> f64 {
93    if sorted.len() == 1 {
94        return sorted[0];
95    }
96    let pos = q * (sorted.len() - 1) as f64;
97    let base = pos.floor() as usize;
98    let frac = pos - base as f64;
99    if base + 1 < sorted.len() {
100        sorted[base] * (1.0 - frac) + sorted[base + 1] * frac
101    } else {
102        sorted[base]
103    }
104}
105
106/// Two summaries whose 95% CIs overlap are indistinguishable (docs/BENCHMARKING.md).
107pub fn indistinguishable(a: &Summary, b: &Summary) -> bool {
108    a.ci95_lo_ms <= b.ci95_hi_ms && b.ci95_lo_ms <= a.ci95_hi_ms
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn summarizes_and_rejects_outliers() {
117        let mut samples: Vec<f64> = (0..100).map(|i| 1.0 + (i % 7) as f64 * 0.001).collect();
118        samples.push(50.0); // gross outlier
119        let s = summarize(&samples).unwrap();
120        assert_eq!(s.outliers_rejected, 1);
121        assert!(s.median_ms > 0.99 && s.median_ms < 1.01);
122        assert!(s.ci95_lo_ms <= s.median_ms && s.median_ms <= s.ci95_hi_ms);
123    }
124
125    // A NaN timing used to be an `expect("no NaN timings")` away from taking
126    // the process down. `total_cmp` orders it, the Tukey fences reject it, and
127    // `summarize` keeps its contract: a value or `None`, never a panic.
128    #[test]
129    fn a_nan_timing_is_rejected_as_an_outlier_and_never_panics() {
130        let mut samples: Vec<f64> = (0..50).map(|i| 1.0 + (i % 7) as f64 * 0.001).collect();
131        samples.push(f64::NAN);
132        let s = summarize(&samples).expect("a summary, not a panic");
133        assert!(
134            s.outliers_rejected >= 1,
135            "the NaN must not survive the fences"
136        );
137        assert!(
138            s.median_ms.is_finite(),
139            "median {} is not finite",
140            s.median_ms
141        );
142        assert!(s.ci95_lo_ms.is_finite() && s.ci95_hi_ms.is_finite());
143        assert!(s.ci95_lo_ms <= s.median_ms && s.median_ms <= s.ci95_hi_ms);
144    }
145
146    // All-NaN is the honest `None`, not a crash and not a fabricated number.
147    #[test]
148    fn an_all_nan_sample_summarizes_to_none() {
149        assert!(summarize(&[f64::NAN; 8]).is_none());
150    }
151
152    // Both signs, and the infinities, since `total_cmp` treats them as
153    // distinct ends of the order.
154    #[test]
155    fn every_non_finite_shape_is_survivable() {
156        for probe in [f64::NAN, -f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
157            let mut samples: Vec<f64> = (0..30).map(|i| 1.0 + (i % 5) as f64 * 0.001).collect();
158            samples.push(probe);
159            // The only requirement is that it returns.
160            let _ = summarize(&samples);
161        }
162    }
163
164    #[test]
165    fn overlap_means_indistinguishable() {
166        let a = summarize(&[1.0, 1.01, 1.02, 0.99, 1.0]).unwrap();
167        let b = summarize(&[1.01, 1.02, 1.03, 1.0, 1.01]).unwrap();
168        assert!(indistinguishable(&a, &b));
169        let c = summarize(&[2.0, 2.01, 2.02, 1.99, 2.0]).unwrap();
170        assert!(!indistinguishable(&a, &c));
171    }
172
173    #[test]
174    fn empty_input_is_none_not_zero() {
175        assert!(summarize(&[]).is_none());
176    }
177}