Skip to main content

aatxe_core/
stats.rs

1//! Pure statistical helpers.
2//!
3//! Two significance tests live here:
4//! * [`welch_t`] — parametric, kept as a diagnostic. Bench distributions are
5//!   typically heavy-tailed, so we don't rely on it as the primary signal.
6//! * [`mann_whitney_u`] — non-parametric Wilcoxon rank-sum. This is what
7//!   [`crate::compare`] consults by default: it makes no normality assumption
8//!   and is robust against the GC-pause / scheduler-pre-emption outliers that
9//!   plague microbenchmarks.
10//!
11//! All functions are pure: no global state, no IO, no panics on empty input
12//! (they return `0` or a benign default).
13
14/// Per-sample summary returned by [`summarize_samples`]. All values are in the
15/// same units as the input samples (nanoseconds, in aatxe's case).
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct Summary {
18    pub mean: f64,
19    pub median: f64,
20    pub trimmed_mean: f64,
21    pub stddev: f64,
22    /// Coefficient of variation: `stddev / mean`.
23    pub cv: f64,
24    /// Median absolute deviation: `median(|x_i - median(x)|)`.
25    pub mad: f64,
26    /// Interquartile range: P75 - P25.
27    pub iqr: f64,
28    pub min: f64,
29    pub max: f64,
30    pub p50: f64,
31    pub p95: f64,
32    pub p99: f64,
33}
34
35impl Default for Summary {
36    fn default() -> Self {
37        Self {
38            mean: 0.0,
39            median: 0.0,
40            trimmed_mean: 0.0,
41            stddev: 0.0,
42            cv: 0.0,
43            mad: 0.0,
44            iqr: 0.0,
45            min: 0.0,
46            max: 0.0,
47            p50: 0.0,
48            p95: 0.0,
49            p99: 0.0,
50        }
51    }
52}
53
54/// Result of the Mann–Whitney U test.
55#[derive(Debug, Clone, Copy)]
56pub struct MwResult {
57    pub u: f64,
58    pub z: f64,
59    pub p: f64,
60}
61
62/// Result of Welch's t-test.
63#[derive(Debug, Clone, Copy)]
64pub struct WelchResult {
65    pub t: f64,
66    pub df: f64,
67    pub p: f64,
68}
69
70/// Arithmetic mean. Returns `0.0` for an empty slice.
71pub fn mean(xs: &[f64]) -> f64 {
72    if xs.is_empty() {
73        return 0.0;
74    }
75    let sum: f64 = xs.iter().sum();
76    sum / xs.len() as f64
77}
78
79/// Sample variance (Bessel-corrected). Returns 0 for arrays with < 2 elements.
80pub fn variance(xs: &[f64], sample_mean: f64) -> f64 {
81    if xs.len() < 2 {
82        return 0.0;
83    }
84    let mut sum_sq = 0.0;
85    for &x in xs {
86        let d = x - sample_mean;
87        sum_sq += d * d;
88    }
89    sum_sq / (xs.len() - 1) as f64
90}
91
92/// Sample standard deviation.
93pub fn stddev(xs: &[f64]) -> f64 {
94    variance(xs, mean(xs)).sqrt()
95}
96
97/// Coefficient of variation (`stddev / mean`). Returns 0 if mean is 0.
98pub fn coefficient_of_variation(xs: &[f64]) -> f64 {
99    let m = mean(xs);
100    if m == 0.0 {
101        0.0
102    } else {
103        variance(xs, m).sqrt() / m
104    }
105}
106
107/// Sort a copy of `xs` ascending. Caller-owns. Uses partial_cmp; NaN sorts last.
108fn sorted_copy(xs: &[f64]) -> Vec<f64> {
109    let mut v: Vec<f64> = xs.to_vec();
110    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
111    v
112}
113
114/// Linear-interpolated percentile from an already-sorted slice.
115fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
116    if sorted.is_empty() {
117        return 0.0;
118    }
119    let rank = (p / 100.0) * (sorted.len() - 1) as f64;
120    let lo = rank.floor() as usize;
121    let hi = rank.ceil() as usize;
122    if lo == hi {
123        return sorted[lo];
124    }
125    let frac = rank - lo as f64;
126    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
127}
128
129/// Linear-interpolated percentile. `p` is in [0, 100]. Input is not mutated.
130pub fn percentile(xs: &[f64], p: f64) -> f64 {
131    if xs.is_empty() {
132        return 0.0;
133    }
134    percentile_sorted(&sorted_copy(xs), p)
135}
136
137/// Median (50th percentile). Outlier-robust point estimate.
138pub fn median(xs: &[f64]) -> f64 {
139    percentile(xs, 50.0)
140}
141
142/// Trimmed mean: drop a `trim` fraction from each end, then average.
143pub fn trimmed_mean(xs: &[f64], trim: f64) -> f64 {
144    if xs.is_empty() {
145        return 0.0;
146    }
147    let sorted = sorted_copy(xs);
148    let cut = (sorted.len() as f64 * trim).floor() as usize;
149    let slice = if cut > 0 {
150        &sorted[cut..sorted.len() - cut]
151    } else {
152        &sorted[..]
153    };
154    mean(slice)
155}
156
157/// Median Absolute Deviation: `median(|x_i - median(x)|)`.
158pub fn median_absolute_deviation(xs: &[f64]) -> f64 {
159    if xs.is_empty() {
160        return 0.0;
161    }
162    let sorted = sorted_copy(xs);
163    let med = percentile_sorted(&sorted, 50.0);
164    mad_from_sorted(&sorted, med)
165}
166
167/// Compute MAD from a sorted slice + its median without re-sorting the
168/// absolute deviations. The deviation array is built as a merge of the two
169/// already-sorted halves around the median.
170fn mad_from_sorted(sorted: &[f64], med: f64) -> f64 {
171    let n = sorted.len();
172    if n == 0 {
173        return 0.0;
174    }
175
176    // Boundary of elements equal to median.
177    let mut left = 0usize;
178    while left < n && sorted[left] < med {
179        left += 1;
180    }
181    let mut right: isize = n as isize - 1;
182    while right >= 0 && sorted[right as usize] > med {
183        right -= 1;
184    }
185    let eq_count = (right as i64 - left as i64 + 1).max(0) as usize;
186
187    let mut merged: Vec<f64> = vec![0.0; eq_count];
188    merged.reserve(n - eq_count);
189
190    // Walk outward: left side gives `med - x` (increasing as we move down),
191    // right side gives `x - med` (increasing as we move up).
192    let mut li: isize = left as isize - 1;
193    let mut ri: usize = (right + 1).max(0) as usize;
194    while li >= 0 && ri < n {
195        let lv = med - sorted[li as usize];
196        let rv = sorted[ri] - med;
197        if lv <= rv {
198            merged.push(lv);
199            li -= 1;
200        } else {
201            merged.push(rv);
202            ri += 1;
203        }
204    }
205    while li >= 0 {
206        merged.push(med - sorted[li as usize]);
207        li -= 1;
208    }
209    while ri < n {
210        merged.push(sorted[ri] - med);
211        ri += 1;
212    }
213
214    percentile_sorted(&merged, 50.0)
215}
216
217/// Interquartile range: P75 - P25.
218pub fn interquartile_range(xs: &[f64]) -> f64 {
219    if xs.is_empty() {
220        return 0.0;
221    }
222    let sorted = sorted_copy(xs);
223    percentile_sorted(&sorted, 75.0) - percentile_sorted(&sorted, 25.0)
224}
225
226/// Compute all common statistics for a sample in a single pass + one sort.
227/// Avoids the redundant sorting that occurs when calling `median`, `percentile`,
228/// `trimmed_mean`, and `iqr` independently.
229pub fn summarize_samples(samples: &[f64]) -> Summary {
230    let n = samples.len();
231    if n == 0 {
232        return Summary::default();
233    }
234
235    // Single-pass mean (Welford), variance, min, max.
236    let mut mean_ = 0.0_f64;
237    let mut m2 = 0.0_f64;
238    let mut min = samples[0];
239    let mut max = samples[0];
240    for (i, &x) in samples.iter().enumerate() {
241        if x < min {
242            min = x;
243        }
244        if x > max {
245            max = x;
246        }
247        let delta = x - mean_;
248        mean_ += delta / (i + 1) as f64;
249        let delta2 = x - mean_;
250        m2 += delta * delta2;
251    }
252    let variance_ = if n < 2 { 0.0 } else { m2 / (n - 1) as f64 };
253    let stddev_ = variance_.sqrt();
254    let cv_ = if mean_ == 0.0 { 0.0 } else { stddev_ / mean_ };
255
256    let sorted = sorted_copy(samples);
257    let median_ = percentile_sorted(&sorted, 50.0);
258    let p95_ = percentile_sorted(&sorted, 95.0);
259    let p99_ = percentile_sorted(&sorted, 99.0);
260    let p25_ = percentile_sorted(&sorted, 25.0);
261    let p75_ = percentile_sorted(&sorted, 75.0);
262    let iqr_ = p75_ - p25_;
263
264    // 5% trimmed mean.
265    let cut = (n as f64 * 0.05).floor() as usize;
266    let trimmed_slice = if cut > 0 && 2 * cut < n {
267        &sorted[cut..n - cut]
268    } else {
269        &sorted[..]
270    };
271    let trimmed_mean_ = mean(trimmed_slice);
272
273    let mad_ = mad_from_sorted(&sorted, median_);
274
275    Summary {
276        mean: mean_,
277        median: median_,
278        trimmed_mean: trimmed_mean_,
279        stddev: stddev_,
280        cv: cv_,
281        mad: mad_,
282        iqr: iqr_,
283        min,
284        max,
285        p50: median_,
286        p95: p95_,
287        p99: p99_,
288    }
289}
290
291/// Welch's t-test (unequal variances). Two-tailed p-value via the Student-t
292/// CDF with Welch–Satterthwaite degrees of freedom.
293///
294/// Kept as a diagnostic — [`mann_whitney_u`] is the primary signal because
295/// it makes no normality assumption.
296pub fn welch_t(a: &[f64], b: &[f64]) -> WelchResult {
297    if a.len() < 2 || b.len() < 2 {
298        return WelchResult {
299            t: 0.0,
300            df: 0.0,
301            p: 1.0,
302        };
303    }
304    let ma = mean(a);
305    let mb = mean(b);
306    let va = variance(a, ma);
307    let vb = variance(b, mb);
308    let na = a.len() as f64;
309    let nb = b.len() as f64;
310    let se_sq = va / na + vb / nb;
311    if se_sq == 0.0 {
312        return WelchResult {
313            t: 0.0,
314            df: na + nb - 2.0,
315            p: 1.0,
316        };
317    }
318    let t = (ma - mb) / se_sq.sqrt();
319    let df =
320        (se_sq * se_sq) / ((va * va) / (na * na * (na - 1.0)) + (vb * vb) / (nb * nb * (nb - 1.0)));
321    let p = 2.0 * (1.0 - student_t_cdf(t.abs(), df));
322    WelchResult {
323        t,
324        df,
325        p: p.clamp(0.0, 1.0),
326    }
327}
328
329/// Mann–Whitney U test (Wilcoxon rank-sum). Non-parametric: tests whether `a`
330/// and `b` are stochastically shifted, without any distributional assumption.
331///
332/// Returns the smaller U statistic, the standardised z-score, and a two-tailed
333/// p-value via the normal approximation with continuity + tie corrections. The
334/// normal approximation is accurate once both samples have ~8+ observations;
335/// below that the value should be treated as a weak signal.
336pub fn mann_whitney_u(a: &[f64], b: &[f64]) -> MwResult {
337    let n_a = a.len();
338    let n_b = b.len();
339    if n_a == 0 || n_b == 0 {
340        return MwResult {
341            u: 0.0,
342            z: 0.0,
343            p: 1.0,
344        };
345    }
346
347    let sorted_a = sorted_copy(a);
348    let sorted_b = sorted_copy(b);
349
350    let mut i = 0usize;
351    let mut j = 0usize;
352    let mut rank = 1u64;
353    let mut rank_sum_a = 0.0_f64;
354    let mut tie_correction = 0.0_f64;
355
356    while i < n_a || j < n_b {
357        // Choose the smaller of the two current heads.
358        let val = if j >= n_b || (i < n_a && sorted_a[i] <= sorted_b[j]) {
359            sorted_a[i]
360        } else {
361            sorted_b[j]
362        };
363        let start_a = i;
364        let start_b = j;
365        while i < n_a && sorted_a[i] == val {
366            i += 1;
367        }
368        while j < n_b && sorted_b[j] == val {
369            j += 1;
370        }
371        let count_a = (i - start_a) as u64;
372        let count_b = (j - start_b) as u64;
373        let total = count_a + count_b;
374        let end_rank = rank + total - 1;
375        let avg_rank = (rank + end_rank) as f64 / 2.0;
376        rank_sum_a += count_a as f64 * avg_rank;
377        if total > 1 {
378            let t = total as f64;
379            tie_correction += t * t * t - t;
380        }
381        rank = end_rank + 1;
382    }
383
384    let n_a_f = n_a as f64;
385    let n_b_f = n_b as f64;
386    let u_a = rank_sum_a - (n_a_f * (n_a_f + 1.0)) / 2.0;
387    let u_b = n_a_f * n_b_f - u_a;
388    let u = u_a.min(u_b);
389
390    let n_total = n_a_f + n_b_f;
391    let mean_u = (n_a_f * n_b_f) / 2.0;
392    let var_u =
393        ((n_a_f * n_b_f) / 12.0) * (n_total + 1.0 - tie_correction / (n_total * (n_total - 1.0)));
394    if var_u <= 0.0 {
395        return MwResult { u, z: 0.0, p: 1.0 };
396    }
397
398    // Continuity correction: shift |U - μ| by 0.5 toward μ.
399    let diff = ((u - mean_u).abs() - 0.5).max(0.0);
400    let z = diff / var_u.sqrt();
401    let p = 2.0 * standard_normal_cdf(-z);
402    MwResult {
403        u,
404        z,
405        p: p.clamp(0.0, 1.0),
406    }
407}
408
409// --- distribution helpers ---
410
411fn standard_normal_cdf(x: f64) -> f64 {
412    0.5 * (1.0 + erf(x / std::f64::consts::SQRT_2))
413}
414
415/// Abramowitz & Stegun 7.1.26 approximation. Max abs error ~1.5e-7 — plenty
416/// for benchmark p-values where we only care about the threshold crossing.
417fn erf(x: f64) -> f64 {
418    let sign = if x < 0.0 { -1.0 } else { 1.0 };
419    let a = x.abs();
420    let t = 1.0 / (1.0 + 0.3275911 * a);
421    let poly = ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t
422        + 0.254829592)
423        * t;
424    sign * (1.0 - poly * (-a * a).exp())
425}
426
427fn student_t_cdf(x: f64, df: f64) -> f64 {
428    if df <= 0.0 {
429        return 0.5;
430    }
431    let xt = df / (df + x * x);
432    let ib = regularized_incomplete_beta(xt, df / 2.0, 0.5);
433    if x >= 0.0 {
434        1.0 - 0.5 * ib
435    } else {
436        0.5 * ib
437    }
438}
439
440fn regularized_incomplete_beta(x: f64, a: f64, b: f64) -> f64 {
441    if x <= 0.0 {
442        return 0.0;
443    }
444    if x >= 1.0 {
445        return 1.0;
446    }
447    let ln_beta = ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b);
448    let front = (x.ln() * a + (1.0 - x).ln() * b - ln_beta).exp() / a;
449    front * beta_continued_fraction(x, a, b)
450}
451
452fn beta_continued_fraction(x: f64, a: f64, b: f64) -> f64 {
453    let max_iter = 200;
454    let epsilon = 1e-15;
455    let fpmin = 1e-300;
456    let qab = a + b;
457    let qap = a + 1.0;
458    let qam = a - 1.0;
459    let mut c = 1.0;
460    let mut d = 1.0 - (qab * x) / qap;
461    if d.abs() < fpmin {
462        d = fpmin;
463    }
464    d = 1.0 / d;
465    let mut h = d;
466    for m in 1..=max_iter {
467        let m_f = m as f64;
468        let m2 = 2.0 * m_f;
469        let mut aa = (m_f * (b - m_f) * x) / ((qam + m2) * (a + m2));
470        d = 1.0 + aa * d;
471        if d.abs() < fpmin {
472            d = fpmin;
473        }
474        c = 1.0 + aa / c;
475        if c.abs() < fpmin {
476            c = fpmin;
477        }
478        d = 1.0 / d;
479        h *= d * c;
480        aa = (-(a + m_f) * (qab + m_f) * x) / ((a + m2) * (qap + m2));
481        d = 1.0 + aa * d;
482        if d.abs() < fpmin {
483            d = fpmin;
484        }
485        c = 1.0 + aa / c;
486        if c.abs() < fpmin {
487            c = fpmin;
488        }
489        d = 1.0 / d;
490        let del = d * c;
491        h *= del;
492        if (del - 1.0).abs() < epsilon {
493            break;
494        }
495    }
496    h
497}
498
499/// Lanczos approximation for `ln Γ(z)`. Accurate to ~15 digits for z > 0.
500fn ln_gamma(z: f64) -> f64 {
501    const G: usize = 7;
502    const C: [f64; 9] = [
503        0.999_999_999_999_809_9,
504        676.520_368_121_885_1,
505        -1_259.139_216_722_402_8,
506        771.323_428_777_653_1,
507        -176.615_029_162_140_6,
508        12.507_343_278_686_905,
509        -0.138_571_095_265_720_1,
510        9.984_369_578_019_572e-6,
511        1.505_632_735_149_311_6e-7,
512    ];
513    if z < 0.5 {
514        return (std::f64::consts::PI / (std::f64::consts::PI * z).sin()).ln() - ln_gamma(1.0 - z);
515    }
516    let z = z - 1.0;
517    let mut x = C[0];
518    for (i, coef) in C.iter().enumerate().skip(1) {
519        x += coef / (z + i as f64);
520    }
521    let t = z + G as f64 + 0.5;
522    0.5 * (2.0 * std::f64::consts::PI).ln() + (z + 0.5) * t.ln() - t + x.ln()
523}