1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum PoolStrategy {
15 Mean,
17 HarmonicMean,
19 Min,
21 Max,
23 P1,
25 P5,
27 P10,
29 Median,
31}
32
33impl PoolStrategy {
34 pub fn apply(self, values: &[f64]) -> f64 {
36 if values.is_empty() {
37 return 0.0;
38 }
39 match self {
40 PoolStrategy::Mean => mean(values),
41 PoolStrategy::HarmonicMean => harmonic_mean(values),
42 PoolStrategy::Min
43 | PoolStrategy::Max
44 | PoolStrategy::P1
45 | PoolStrategy::P5
46 | PoolStrategy::P10
47 | PoolStrategy::Median => {
48 let mut sorted = values.to_vec();
49 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
50 match self {
51 PoolStrategy::Min => sorted[0],
52 PoolStrategy::Max => sorted[sorted.len() - 1],
53 PoolStrategy::P1 => percentile_sorted(&sorted, 1.0),
54 PoolStrategy::P5 => percentile_sorted(&sorted, 5.0),
55 PoolStrategy::P10 => percentile_sorted(&sorted, 10.0),
56 PoolStrategy::Median => percentile_sorted(&sorted, 50.0),
57 PoolStrategy::Mean | PoolStrategy::HarmonicMean => unreachable!(),
58 }
59 }
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
69#[serde(default)]
70pub struct PooledStats {
71 pub mean: f64,
73 pub harmonic_mean: f64,
75 pub min: f64,
77 pub max: f64,
79 pub p1: f64,
81 pub p5: f64,
83 pub p10: f64,
85 pub median: f64,
87 pub std_dev: f64,
89 pub count: usize,
91}
92
93impl PooledStats {
94 pub fn from_values(values: &[f64]) -> Self {
96 if values.is_empty() {
97 return Self::default();
98 }
99 let mut sorted = values.to_vec();
100 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
101 Self {
102 mean: mean(values),
103 harmonic_mean: harmonic_mean(values),
104 min: sorted[0],
105 max: sorted[sorted.len() - 1],
106 p1: percentile_sorted(&sorted, 1.0),
107 p5: percentile_sorted(&sorted, 5.0),
108 p10: percentile_sorted(&sorted, 10.0),
109 median: percentile_sorted(&sorted, 50.0),
110 std_dev: std_dev(values),
111 count: values.len(),
112 }
113 }
114
115 pub fn get(&self, strategy: PoolStrategy) -> f64 {
117 match strategy {
118 PoolStrategy::Mean => self.mean,
119 PoolStrategy::HarmonicMean => self.harmonic_mean,
120 PoolStrategy::Min => self.min,
121 PoolStrategy::Max => self.max,
122 PoolStrategy::P1 => self.p1,
123 PoolStrategy::P5 => self.p5,
124 PoolStrategy::P10 => self.p10,
125 PoolStrategy::Median => self.median,
126 }
127 }
128}
129
130fn mean(values: &[f64]) -> f64 {
131 if values.is_empty() {
132 return 0.0;
133 }
134 values.iter().sum::<f64>() / values.len() as f64
135}
136
137fn harmonic_mean(values: &[f64]) -> f64 {
138 let positive: Vec<f64> = values.iter().copied().filter(|x| *x > 0.0).collect();
139 if positive.is_empty() {
140 return 0.0;
141 }
142 let denom: f64 = positive.iter().map(|x| 1.0 / x).sum();
143 positive.len() as f64 / denom
144}
145
146fn std_dev(values: &[f64]) -> f64 {
147 if values.len() < 2 {
148 return 0.0;
149 }
150 let m = mean(values);
151 let var = values
152 .iter()
153 .map(|x| {
154 let d = x - m;
155 d * d
156 })
157 .sum::<f64>()
158 / values.len() as f64;
159 var.sqrt()
160}
161
162fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
164 match sorted.len() {
165 0 => 0.0,
166 1 => sorted[0],
167 n => {
168 let rank = (p / 100.0) * (n - 1) as f64;
169 let lo = rank.floor() as usize;
170 let hi = rank.ceil() as usize;
171 let frac = rank - lo as f64;
172 sorted[lo] + (sorted[hi] - sorted[lo]) * frac
173 }
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn empty_is_zero() {
183 let s = PooledStats::from_values(&[]);
184 assert_eq!(s, PooledStats::default());
185 assert_eq!(PoolStrategy::Mean.apply(&[]), 0.0);
186 assert_eq!(PoolStrategy::P1.apply(&[]), 0.0);
187 }
188
189 #[test]
190 fn basic_stats() {
191 let v = [10.0, 20.0, 30.0, 40.0, 50.0];
192 let s = PooledStats::from_values(&v);
193 assert!((s.mean - 30.0).abs() < 1e-9);
194 assert!((s.min - 10.0).abs() < 1e-9);
195 assert!((s.max - 50.0).abs() < 1e-9);
196 assert!((s.median - 30.0).abs() < 1e-9);
197 assert_eq!(s.count, 5);
198 }
199
200 #[test]
201 fn harmonic_mean_penalises_low_outliers() {
202 let v = [100.0, 100.0, 1.0];
203 let s = PooledStats::from_values(&v);
204 assert!(s.harmonic_mean < s.mean);
206 assert!(s.harmonic_mean < 5.0);
207 }
208
209 #[test]
210 fn harmonic_mean_ignores_nonpositive() {
211 assert_eq!(harmonic_mean(&[0.0, 0.0]), 0.0);
213 let s = PooledStats::from_values(&[0.0, 2.0]);
214 assert!((s.harmonic_mean - 2.0).abs() < 1e-9);
215 }
216
217 #[test]
218 fn percentiles_interpolate() {
219 let v: Vec<f64> = (1..=100).map(|x| x as f64).collect();
220 let s = PooledStats::from_values(&v);
221 assert!((s.p1 - 1.99).abs() < 1e-6);
223 assert!((s.p10 - 10.9).abs() < 1e-6);
224 assert!((s.median - 50.5).abs() < 1e-6);
225 }
226
227 #[test]
228 fn strategy_get_matches_apply() {
229 let v = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0];
230 let s = PooledStats::from_values(&v);
231 for strat in [
232 PoolStrategy::Mean,
233 PoolStrategy::HarmonicMean,
234 PoolStrategy::Min,
235 PoolStrategy::Max,
236 PoolStrategy::P1,
237 PoolStrategy::P5,
238 PoolStrategy::P10,
239 PoolStrategy::Median,
240 ] {
241 assert!((s.get(strat) - strat.apply(&v)).abs() < 1e-9, "mismatch for {strat:?}");
242 }
243 }
244
245 #[test]
246 fn single_value() {
247 let s = PooledStats::from_values(&[42.0]);
248 assert_eq!(s.mean, 42.0);
249 assert_eq!(s.p1, 42.0);
250 assert_eq!(s.std_dev, 0.0);
251 assert_eq!(s.count, 1);
252 }
253}