1use serde::{Deserialize, Serialize};
10
11#[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
28pub type PercentileStats = ScalarStats;
30
31impl ScalarStats {
32 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 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 pub fn differs_from(&self, other: &Self) -> bool {
69 (self.mean - other.mean).abs() > (self.ci95_hw + other.ci95_hw)
70 }
71}
72
73pub 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
84pub 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
105pub 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 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 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 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 assert!((s.stddev - 12.5_f64.sqrt()).abs() < 1e-9);
177 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 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 assert!((student_t_975(100) - 1.96).abs() < 1e-2);
190 }
191
192 #[test]
193 fn differs_from_overlap() {
194 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 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}