Skip to main content

ferrum_bench_core/
stats.rs

1//! Statistical aggregates used in `BenchReport`: percentile (linear
2//! interpolation), `ScalarStats` (mean / stddev / CI95), and Student-t
3//! critical values for small-sample CI.
4//!
5//! See `docs/bench/PLAYBOOK.md` § 0.4 for the contract: `n_repeats < 3`
6//! must omit dispersion fields rather than emit zeros that look like
7//! "perfectly consistent" runs.
8
9use serde::{Deserialize, Serialize};
10
11/// Aggregated statistics across N independent samples of one quantity.
12///
13/// For `n < 3` only `mean` is meaningful; `stddev` and `ci95_hw` are
14/// reported as 0 and the serializer omits them via `skip_serializing_if`.
15#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
16pub struct ScalarStats {
17    pub mean: f64,
18    #[serde(default, skip_serializing_if = "is_zero")]
19    pub stddev: f64,
20    #[serde(default, skip_serializing_if = "is_zero")]
21    pub ci95_hw: f64,
22}
23
24fn is_zero(x: &f64) -> bool {
25    *x == 0.0
26}
27
28/// Latency-percentile statistics use the same shape as `ScalarStats`.
29pub type PercentileStats = ScalarStats;
30
31impl ScalarStats {
32    /// Aggregate per-run scalars into mean/stddev/CI95 across runs.
33    ///
34    /// `xs.len()` is the number of repeats. Uses sample variance
35    /// (divide by `n − 1`). CI95 half-width via Student's t (two-sided
36    /// 95%, df = n − 1).
37    pub fn from_samples(xs: &[f64]) -> Self {
38        let n = xs.len();
39        if n == 0 {
40            return Self {
41                mean: 0.0,
42                stddev: 0.0,
43                ci95_hw: 0.0,
44            };
45        }
46        let mean = xs.iter().sum::<f64>() / n as f64;
47        if n < 3 {
48            // Schema rule: CI with n < 3 is degenerate; emit mean only.
49            return Self {
50                mean,
51                stddev: 0.0,
52                ci95_hw: 0.0,
53            };
54        }
55        let var = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
56        let stddev = var.sqrt();
57        let ci95_hw = ci95_half_width(stddev, n);
58        Self {
59            mean,
60            stddev,
61            ci95_hw,
62        }
63    }
64
65    /// True if the absolute mean difference exceeds the sum of the two
66    /// half-widths — i.e. the difference is statistically significant
67    /// at the 95% level (CIs do not overlap).
68    pub fn differs_from(&self, other: &Self) -> bool {
69        (self.mean - other.mean).abs() > (self.ci95_hw + other.ci95_hw)
70    }
71}
72
73/// 95% confidence interval half-width via Student's t (two-sided).
74/// Returns 0 for n < 3.
75pub fn ci95_half_width(stddev: f64, n: usize) -> f64 {
76    if n < 3 {
77        return 0.0;
78    }
79    let df = n - 1;
80    let t = student_t_975(df);
81    t * stddev / (n as f64).sqrt()
82}
83
84/// Critical value of Student's t at α = 0.025 (one-sided), i.e. the
85/// multiplier for a two-sided 95% confidence interval at the given df.
86///
87/// Table for df ≤ 29 (covers n_repeats ≤ 30); for larger df we use the
88/// N(0,1) approximation `1.96`, which is within 0.5% of the true value
89/// for df ≥ 30.
90pub fn student_t_975(df: usize) -> f64 {
91    const T_TABLE: &[f64] = &[
92        12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, 2.201, 2.179, 2.160,
93        2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, 2.080, 2.074, 2.069, 2.064, 2.060, 2.056,
94        2.052, 2.048, 2.045,
95    ];
96    if df == 0 {
97        return f64::INFINITY;
98    }
99    if df <= T_TABLE.len() {
100        return T_TABLE[df - 1];
101    }
102    1.960
103}
104
105/// Continuous-position percentile using linear interpolation between
106/// adjacent order statistics — equivalent to numpy.percentile's default
107/// linear method, which is what vLLM / SGLang / lmdeploy all use.
108///
109/// `q ∈ [0, 1]`. Empty input returns 0.
110pub fn percentile(xs: &[f64], q: f64) -> f64 {
111    if xs.is_empty() {
112        return 0.0;
113    }
114    let mut sorted: Vec<f64> = xs.to_vec();
115    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
116    let n = sorted.len();
117    if n == 1 {
118        return sorted[0];
119    }
120    let pos = q * (n - 1) as f64;
121    let lo = pos.floor() as usize;
122    let hi = pos.ceil() as usize;
123    let frac = pos - lo as f64;
124    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn percentile_known_set() {
133        let xs = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
134        assert!((percentile(&xs, 0.50) - 5.5).abs() < 1e-9);
135        assert!((percentile(&xs, 0.00) - 1.0).abs() < 1e-9);
136        assert!((percentile(&xs, 1.00) - 10.0).abs() < 1e-9);
137        // numpy.percentile(xs, 99) = 9.91
138        assert!((percentile(&xs, 0.99) - 9.91).abs() < 1e-9);
139    }
140
141    #[test]
142    fn percentile_single_element() {
143        assert_eq!(percentile(&[42.0], 0.5), 42.0);
144        assert_eq!(percentile(&[42.0], 0.99), 42.0);
145    }
146
147    #[test]
148    fn percentile_empty() {
149        assert_eq!(percentile(&[], 0.5), 0.0);
150    }
151
152    #[test]
153    fn scalar_stats_zero_or_one_sample() {
154        assert_eq!(ScalarStats::from_samples(&[]).mean, 0.0);
155        let s = ScalarStats::from_samples(&[10.0]);
156        assert_eq!(s.mean, 10.0);
157        assert_eq!(s.stddev, 0.0);
158        assert_eq!(s.ci95_hw, 0.0);
159    }
160
161    #[test]
162    fn scalar_stats_two_samples_no_ci() {
163        // n=2 is below the threshold — emit mean only.
164        let s = ScalarStats::from_samples(&[10.0, 12.0]);
165        assert_eq!(s.mean, 11.0);
166        assert_eq!(s.stddev, 0.0);
167        assert_eq!(s.ci95_hw, 0.0);
168    }
169
170    #[test]
171    fn scalar_stats_five_samples_with_ci() {
172        // mean = 100, sample stddev = 5.0
173        let s = ScalarStats::from_samples(&[95.0, 100.0, 100.0, 100.0, 105.0]);
174        assert!((s.mean - 100.0).abs() < 1e-9);
175        // Sample variance = ((25 + 0 + 0 + 0 + 25) / 4) = 12.5 → stddev ≈ 3.5355
176        assert!((s.stddev - 12.5_f64.sqrt()).abs() < 1e-9);
177        // CI95 = t_4 * stddev / sqrt(5) = 2.776 * 3.5355 / 2.2361
178        let expected = 2.776 * 12.5_f64.sqrt() / 5.0_f64.sqrt();
179        assert!((s.ci95_hw - expected).abs() < 1e-6);
180    }
181
182    #[test]
183    fn student_t_table_anchor_points() {
184        // Standard reference values.
185        assert!((student_t_975(1) - 12.706).abs() < 1e-3);
186        assert!((student_t_975(4) - 2.776).abs() < 1e-3);
187        assert!((student_t_975(29) - 2.045).abs() < 1e-3);
188        // Large df → ~1.96 (Z critical).
189        assert!((student_t_975(100) - 1.96).abs() < 1e-2);
190    }
191
192    #[test]
193    fn differs_from_overlap() {
194        // Two non-overlapping CIs → differ.
195        let a = ScalarStats {
196            mean: 100.0,
197            stddev: 5.0,
198            ci95_hw: 4.0,
199        };
200        let b = ScalarStats {
201            mean: 110.0,
202            stddev: 5.0,
203            ci95_hw: 4.0,
204        };
205        assert!(a.differs_from(&b));
206        // Overlap by 1 → not significant.
207        let c = ScalarStats {
208            mean: 107.0,
209            stddev: 5.0,
210            ci95_hw: 4.0,
211        };
212        assert!(!a.differs_from(&c));
213    }
214
215    #[test]
216    fn json_omits_dispersion_when_zero() {
217        let s = ScalarStats {
218            mean: 42.0,
219            stddev: 0.0,
220            ci95_hw: 0.0,
221        };
222        let j = serde_json::to_string(&s).unwrap();
223        assert_eq!(j, r#"{"mean":42.0}"#);
224    }
225
226    #[test]
227    fn json_includes_dispersion_when_set() {
228        let s = ScalarStats {
229            mean: 42.0,
230            stddev: 1.5,
231            ci95_hw: 0.8,
232        };
233        let j = serde_json::to_string(&s).unwrap();
234        assert!(j.contains("\"stddev\":1.5"));
235        assert!(j.contains("\"ci95_hw\":0.8"));
236    }
237}