1use std::collections::BTreeMap;
7
8use crate::explore::outcome::RunStatus;
9
10const T_975: [f64; 30] = [
12 12.706_204_736,
13 4.302_652_730,
14 3.182_446_305,
15 2.776_445_105,
16 2.570_581_836,
17 2.446_911_851,
18 2.364_624_252,
19 2.306_004_135,
20 2.262_157_163,
21 2.228_138_852,
22 2.200_985_160,
23 2.178_812_830,
24 2.160_368_656,
25 2.144_786_688,
26 2.131_449_546,
27 2.119_905_299,
28 2.109_815_578,
29 2.100_922_040,
30 2.093_024_054,
31 2.085_963_447,
32 2.079_613_845,
33 2.073_873_068,
34 2.068_657_610,
35 2.063_898_562,
36 2.059_538_553,
37 2.055_529_439,
38 2.051_830_516,
39 2.048_407_142,
40 2.045_229_642,
41 2.042_272_456,
42];
43
44const Z_975: f64 = 1.959_963_984_540_054;
46
47pub fn student_t_975(degrees_of_freedom: u64) -> f64 {
52 if degrees_of_freedom == 0 {
53 return f64::INFINITY;
54 }
55 if let Some(&quantile) = usize::try_from(degrees_of_freedom - 1)
56 .ok()
57 .and_then(|index| T_975.get(index))
58 {
59 return quantile;
60 }
61 let z = Z_975;
62 let z2 = z * z;
63 let z3 = z2 * z;
64 let z5 = z3 * z2;
65 let z7 = z5 * z2;
66 let z9 = z7 * z2;
67 let g1 = (z3 + z) / 4.0;
68 let g2 = (5.0 * z5 + 16.0 * z3 + 3.0 * z) / 96.0;
69 let g3 = (3.0 * z7 + 19.0 * z5 + 17.0 * z3 - 15.0 * z) / 384.0;
70 let g4 = (79.0 * z9 + 776.0 * z7 + 1482.0 * z5 - 1920.0 * z3 - 945.0 * z) / 92160.0;
71 let inverse = 1.0 / degrees_of_freedom as f64;
72 z + inverse * (g1 + inverse * (g2 + inverse * (g3 + inverse * g4)))
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq)]
77pub struct RunningMoments {
78 count: u64,
79 mean: f64,
80 squares: f64,
82}
83
84impl RunningMoments {
85 pub fn push(&mut self, value: f64) {
87 if !value.is_finite() {
88 return;
89 }
90 self.count += 1;
91 let delta = value - self.mean;
92 self.mean += delta / self.count as f64;
93 self.squares += delta * (value - self.mean);
94 }
95
96 pub fn count(&self) -> u64 {
98 self.count
99 }
100
101 pub fn mean(&self) -> Option<f64> {
103 (self.count > 0).then_some(self.mean)
104 }
105
106 pub fn standard_deviation(&self) -> Option<f64> {
108 (self.count > 1).then(|| (self.squares / (self.count - 1) as f64).sqrt())
109 }
110
111 pub fn summary(&self) -> ReplicateSummary {
113 let standard_deviation = self.standard_deviation();
114 let ci95 = standard_deviation.map(|standard_deviation| {
115 let half_width = student_t_975(self.count - 1) * standard_deviation / (self.count as f64).sqrt();
116 (self.mean - half_width, self.mean + half_width)
117 });
118 ReplicateSummary {
119 n: self.count,
120 mean: self.mean(),
121 standard_deviation,
122 ci95,
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq)]
129pub struct ReplicateSummary {
130 pub n: u64,
132 pub mean: Option<f64>,
134 pub standard_deviation: Option<f64>,
136 pub ci95: Option<(f64, f64)>,
138}
139
140pub fn summarize(values: impl IntoIterator<Item = f64>) -> ReplicateSummary {
142 let mut moments = RunningMoments::default();
143 for value in values {
144 moments.push(value);
145 }
146 moments.summary()
147}
148
149#[derive(Debug, Clone, PartialEq)]
151pub struct SummaryRow {
152 pub config_id: u64,
154 pub runs: u64,
156 pub ok: u64,
158 pub failed: u64,
160 pub ticks: ReplicateSummary,
162 pub reducers: Vec<ReplicateSummary>,
164}
165
166#[derive(Debug, Clone, Default, PartialEq)]
170pub struct SummaryAccumulator {
171 reducer_count: usize,
172 configs: BTreeMap<u64, ConfigTally>,
173}
174
175#[derive(Debug, Clone, PartialEq)]
176struct ConfigTally {
177 runs: u64,
178 ok: u64,
179 failed: u64,
180 ticks: RunningMoments,
181 reducers: Vec<RunningMoments>,
182}
183
184impl SummaryAccumulator {
185 pub fn new(reducer_count: usize) -> Self {
187 Self {
188 reducer_count,
189 configs: BTreeMap::new(),
190 }
191 }
192
193 pub fn push(&mut self, config_id: u64, status: RunStatus, ticks: u64, reducers: &[Option<f64>]) {
198 let reducer_count = self.reducer_count;
199 let tally = self.configs.entry(config_id).or_insert_with(|| ConfigTally {
200 runs: 0,
201 ok: 0,
202 failed: 0,
203 ticks: RunningMoments::default(),
204 reducers: vec![RunningMoments::default(); reducer_count],
205 });
206 tally.runs += 1;
207 if status == RunStatus::Ok {
208 tally.ok += 1;
209 }
210 if status.is_failure() {
211 tally.failed += 1;
212 return;
213 }
214 tally.ticks.push(ticks as f64);
215 for (moments, value) in tally.reducers.iter_mut().zip(reducers) {
216 if let Some(value) = value {
217 moments.push(*value);
218 }
219 }
220 }
221
222 pub fn rows(&self) -> impl Iterator<Item = SummaryRow> + '_ {
224 self.configs.iter().map(|(&config_id, tally)| SummaryRow {
225 config_id,
226 runs: tally.runs,
227 ok: tally.ok,
228 failed: tally.failed,
229 ticks: tally.ticks.summary(),
230 reducers: tally.reducers.iter().map(RunningMoments::summary).collect(),
231 })
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::{RunningMoments, SummaryAccumulator, student_t_975, summarize};
238 use crate::explore::outcome::RunStatus;
239
240 fn close(actual: f64, expected: f64, tolerance: f64) -> bool {
241 (actual - expected).abs() <= tolerance
242 }
243
244 #[test]
245 fn t_quantiles_match_the_table() {
246 for (df, expected) in [(1, 12.706), (2, 4.303), (10, 2.228), (30, 2.042), (120, 1.980)] {
247 let quantile = student_t_975(df);
248 assert!(close(quantile, expected, 1e-3), "df {df}: {quantile}");
249 }
250 assert!(
251 close(student_t_975(31), 2.039_513, 1e-6),
252 "the expansion joins the table"
253 );
254 assert!(
255 close(student_t_975(1_000_000), 1.959_966, 1e-6),
256 "and tends to the normal quantile"
257 );
258 assert!(student_t_975(0).is_infinite());
259 }
260
261 #[test]
262 fn a_replicate_summary_matches_hand_computed_statistics() {
263 let summary = summarize([1.0, 2.0, 3.0, 4.0]);
264 assert_eq!(summary.n, 4);
265 assert_eq!(summary.mean, Some(2.5));
266 let sd = summary.standard_deviation.expect("four values have a spread");
267 assert!(close(sd, 1.2910, 1e-4), "sd {sd}");
268 let (low, high) = summary.ci95.expect("four values have an interval");
269 assert!(close(high - 2.5, 2.054, 1e-3), "half-width {}", high - 2.5);
270 assert!(close(2.5 - low, high - 2.5, 1e-12), "the interval is symmetric");
271 }
272
273 #[test]
274 fn a_single_replicate_has_no_spread() {
275 let summary = summarize([7.0]);
276 assert_eq!(summary.n, 1);
277 assert_eq!(summary.mean, Some(7.0));
278 assert_eq!(summary.standard_deviation, None);
279 assert_eq!(summary.ci95, None);
280 let empty = summarize([]);
281 assert_eq!((empty.n, empty.mean), (0, None));
282 }
283
284 #[test]
285 fn values_that_are_not_finite_are_left_out() {
286 let mut moments = RunningMoments::default();
287 for value in [1.0, f64::NAN, 3.0, f64::INFINITY] {
288 moments.push(value);
289 }
290 assert_eq!(moments.count(), 2);
291 assert_eq!(moments.mean(), Some(2.0));
292 }
293
294 #[test]
295 fn configs_are_summarized_apart_and_failed_runs_are_counted_only() {
296 let mut accumulator = SummaryAccumulator::new(1);
297 accumulator.push(1, RunStatus::Ok, 100, &[Some(4.0)]);
298 accumulator.push(0, RunStatus::Ok, 100, &[Some(1.0)]);
299 accumulator.push(0, RunStatus::NonFinite, 100, &[Some(3.0)]);
300 accumulator.push(0, RunStatus::Panicked, 40, &[Some(99.0)]);
301 accumulator.push(0, RunStatus::Ok, 100, &[None]);
302 let rows: Vec<_> = accumulator.rows().collect();
303 assert_eq!(rows.len(), 2);
304 let first = &rows[0];
305 assert_eq!(first.config_id, 0, "rows come in config order");
306 assert_eq!((first.runs, first.ok, first.failed), (4, 2, 1));
307 assert_eq!(first.ticks.mean, Some(100.0), "the failed run's tick is left out");
308 assert_eq!(first.reducers[0].n, 2, "an empty value is left out");
309 assert_eq!(first.reducers[0].mean, Some(2.0));
310 assert_eq!(rows[1].reducers[0].mean, Some(4.0));
311 }
312}