launchbound_bench/
stats.rs1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct Summary {
17 pub n: usize,
19 pub outliers_rejected: usize,
21 pub median_ms: f64,
25 pub ci95_lo_ms: f64,
27 pub ci95_hi_ms: f64,
29 pub min_ms: f64,
31 pub max_ms: f64,
33 pub mean_ms: f64,
35}
36
37pub 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 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
91fn 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
106pub 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); 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 #[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 #[test]
148 fn an_all_nan_sample_summarizes_to_none() {
149 assert!(summarize(&[f64::NAN; 8]).is_none());
150 }
151
152 #[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 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}