1use super::model::{EconomicsDistribution, EconomicsInterval};
25
26pub fn reduction(control: u64, treatment: u64) -> Option<f64> {
38 if control == 0 {
39 return None;
40 }
41 Some(round(1.0 - treatment as f64 / control as f64))
42}
43
44pub fn quantile(sorted: &[f64], q: f64) -> Option<f64> {
54 match sorted.len() {
55 0 => None,
56 1 => Some(sorted[0]),
57 n => {
58 let h = (n - 1) as f64 * q.clamp(0.0, 1.0);
59 let lo = h.floor() as usize;
60 let hi = h.ceil() as usize;
61 Some(sorted[lo] + (h - lo as f64) * (sorted[hi] - sorted[lo]))
62 }
63 }
64}
65
66fn sorted(values: &[f64]) -> Vec<f64> {
67 let mut v: Vec<f64> = values.iter().copied().filter(|x| x.is_finite()).collect();
68 crate::order::numeric(&mut v);
69 v
70}
71
72pub fn distribution(values: &[f64]) -> Option<EconomicsDistribution> {
83 let v = sorted(values);
84 let n = v.len();
85 if n == 0 {
86 return None;
87 }
88 let mean = v.iter().sum::<f64>() / n as f64;
89 let sd = (n > 1)
90 .then(|| (v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64).sqrt());
91 Some(EconomicsDistribution {
92 n,
93 mean: round(mean),
94 median: round(quantile(&v, 0.5)?),
95 p25: round(quantile(&v, 0.25)?),
96 p75: round(quantile(&v, 0.75)?),
97 p95: round(quantile(&v, 0.95)?),
98 min: round(v[0]),
99 max: round(v[n - 1]),
100 sd: sd.map(round),
101 })
102}
103
104pub(crate) fn round(x: f64) -> f64 {
107 (x * 1e12).round() / 1e12
108}
109
110struct SplitMix64(u64);
113
114impl SplitMix64 {
115 fn next(&mut self) -> u64 {
116 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
117 let mut z = self.0;
118 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
119 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
120 z ^ (z >> 31)
121 }
122
123 fn below(&mut self, n: u64) -> u64 {
125 let zone = u64::MAX - u64::MAX % n;
126 loop {
127 let x = self.next();
128 if x < zone {
129 return x % n;
130 }
131 }
132 }
133}
134
135fn percentile_interval(
137 mut medians: Vec<f64>,
138 level_bp: u32,
139 resamples: u32,
140 seed: u64,
141 method: &str,
142) -> Option<EconomicsInterval> {
143 crate::order::numeric(&mut medians);
144 let tail = (1.0 - level_bp as f64 / 10_000.0) / 2.0;
145 Some(EconomicsInterval {
146 level_bp,
147 low: round(quantile(&medians, tail)?),
148 high: round(quantile(&medians, 1.0 - tail)?),
149 method: method.into(),
150 resamples,
151 seed,
152 })
153}
154
155pub const INDEPENDENT_METHOD: &str =
157 "percentile bootstrap of the median, each value resampled independently with replacement";
158
159pub const CLUSTER_METHOD: &str = "percentile bootstrap of the median, cluster bootstrap over tasks: each resample draws tasks with replacement and pools every value of each drawn task";
161
162pub fn bootstrap_median(
183 values: &[f64],
184 level_bp: u32,
185 resamples: u32,
186 seed: u64,
187 min_n: usize,
188) -> Option<EconomicsInterval> {
189 let v = sorted(values);
190 let n = v.len();
191 if n < min_n.max(2) || resamples == 0 {
192 return None;
193 }
194 let mut rng = SplitMix64(seed);
195 let mut medians = Vec::with_capacity(resamples as usize);
196 let h = (n - 1) as f64 * 0.5;
202 let (lo, hi) = (h.floor() as usize, h.ceil() as usize);
203 let mut drawn = vec![0usize; n];
204 for _ in 0..resamples {
205 drawn.fill(0);
206 for _ in 0..n {
207 drawn[rng.below(n as u64) as usize] += 1;
208 }
209 let (a, b) = order_statistics(&v, &drawn, lo, hi);
210 medians.push(a + (h - lo as f64) * (b - a));
211 }
212 percentile_interval(medians, level_bp, resamples, seed, INDEPENDENT_METHOD)
213}
214
215fn order_statistics(v: &[f64], drawn: &[usize], lo: usize, hi: usize) -> (f64, f64) {
218 let mut seen = 0;
219 let mut low = None;
220 for (value, ×) in v.iter().zip(drawn) {
221 seen += times;
222 if low.is_none() && seen > lo {
223 low = Some(*value);
224 }
225 if seen > hi {
226 return (low.unwrap_or(*value), *value);
227 }
228 }
229 let last = v.last().copied().unwrap_or(f64::NAN);
231 (low.unwrap_or(last), last)
232}
233
234pub fn bootstrap_median_clustered(
261 clusters: &[Vec<f64>],
262 level_bp: u32,
263 resamples: u32,
264 seed: u64,
265 min_n: usize,
266) -> Option<EconomicsInterval> {
267 let mut c: Vec<Vec<f64>> = clusters
268 .iter()
269 .map(|v| sorted(v.as_slice()))
270 .filter(|v| !v.is_empty())
271 .collect();
272 crate::order::numeric_samples(&mut c);
273 let n: usize = c.iter().map(Vec::len).sum();
274 let k = c.len();
275 if n < min_n.max(2) || k < 2 || resamples == 0 {
276 return None;
277 }
278 let mut rng = SplitMix64(seed);
279 let mut medians = Vec::with_capacity(resamples as usize);
280 let mut pool = Vec::with_capacity(n * 2);
281 for _ in 0..resamples {
282 pool.clear();
283 for _ in 0..k {
284 pool.extend_from_slice(&c[rng.below(k as u64) as usize]);
285 }
286 crate::order::numeric(&mut pool);
287 medians.push(quantile(&pool, 0.5)?);
288 }
289 percentile_interval(medians, level_bp, resamples, seed, CLUSTER_METHOD)
290}
291
292pub fn level_text(level_bp: u32) -> String {
304 let (whole, rest) = (level_bp / 100, level_bp % 100);
305 if rest == 0 {
306 format!("{whole}%")
307 } else {
308 let digits = format!("{rest:02}");
309 format!("{whole}.{}%", digits.trim_end_matches('0'))
310 }
311}
312
313pub fn percent_text(ratio: f64) -> String {
327 if !ratio.is_finite() {
328 return "n/a".into();
329 }
330 let x = ratio * 100.0;
331 let magnitude = format!("{:.1}", x.abs());
332 if magnitude.bytes().all(|b| b == b'0' || b == b'.') {
333 "0.0%".into()
334 } else if x < 0.0 {
335 format!("-{magnitude}%")
336 } else {
337 format!("+{magnitude}%")
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn the_formula_is_the_one_the_methodology_states() {
347 assert_eq!(reduction(100, 50), Some(0.5));
348 assert_eq!(reduction(100, 100), Some(0.0));
349 assert_eq!(reduction(100, 120), Some(-0.2));
350 assert_eq!(reduction(100_000, 55_000), Some(0.45));
351 }
352
353 #[test]
354 fn a_zero_control_has_no_reduction_rather_than_an_infinite_one() {
355 assert_eq!(reduction(0, 0), None);
356 assert_eq!(reduction(0, 1), None);
357 }
358
359 #[test]
360 fn a_regression_is_never_clamped() {
361 let r = reduction(1_000, 3_000).unwrap();
362 assert!(
363 (r + 2.0).abs() < 1e-12,
364 "a treatment three times the control is -200 %, got {r}"
365 );
366 }
367
368 #[test]
369 fn quantiles_interpolate_linearly() {
370 let v = [10.0, 20.0, 30.0, 40.0, 50.0];
371 assert_eq!(quantile(&v, 0.0), Some(10.0));
372 assert_eq!(quantile(&v, 0.25), Some(20.0));
373 assert_eq!(quantile(&v, 0.5), Some(30.0));
374 assert!((quantile(&v, 0.95).unwrap() - 48.0).abs() < 1e-9);
375 assert_eq!(quantile(&v, 1.0), Some(50.0));
376 }
377
378 #[test]
379 fn a_distribution_drops_nothing_finite_and_nothing_else() {
380 let d = distribution(&[f64::NAN, 1.0, f64::INFINITY, 3.0]).unwrap();
381 assert_eq!(d.n, 2, "non-finite values are not measurements");
382 assert_eq!(d.mean, 2.0);
383 }
384
385 #[test]
386 fn the_bootstrap_is_reproducible_from_its_seed_and_moves_with_it() {
387 let v: Vec<f64> = (0..40).map(|i| (i as f64 * 0.37).sin()).collect();
388 let a = bootstrap_median(&v, 9500, 1000, 1, 5).unwrap();
389 let b = bootstrap_median(&v, 9500, 1000, 1, 5).unwrap();
390 let c = bootstrap_median(&v, 9500, 1000, 2, 5).unwrap();
391 assert_eq!(a, b);
392 assert!(a != c, "a different seed is a different resampling");
393 assert!(a.low <= a.high);
394 }
395
396 #[test]
400 fn counting_is_sorting() {
401 fn by_sorting(
402 values: &[f64],
403 level_bp: u32,
404 resamples: u32,
405 seed: u64,
406 ) -> EconomicsInterval {
407 let v = sorted(values);
408 let n = v.len();
409 let mut rng = SplitMix64(seed);
410 let mut medians = Vec::with_capacity(resamples as usize);
411 let mut sample = vec![0.0; n];
412 for _ in 0..resamples {
413 for slot in sample.iter_mut() {
414 *slot = v[rng.below(n as u64) as usize];
415 }
416 crate::order::numeric(&mut sample);
417 medians.push(quantile(&sample, 0.5).unwrap());
418 }
419 percentile_interval(medians, level_bp, resamples, seed, INDEPENDENT_METHOD).unwrap()
420 }
421 let mut sets: Vec<Vec<f64>> = (2..=13)
422 .map(|n| (0..n).map(|i| ((i * 7 % 5) as f64 - 2.0) * 0.1).collect())
423 .collect();
424 sets.push(vec![0.0, -0.0, 0.0, -0.0, 1.0]);
425 sets.push(vec![0.25; 9]);
426 sets.push((0..40).map(|i| (i as f64 * 0.37).sin()).collect());
427 sets.push(vec![f64::NAN, 3.0, -1.5, f64::INFINITY, 2.0, 2.0]);
428 for v in &sets {
429 for seed in 0..25 {
430 let counted = bootstrap_median(v, 9500, 300, seed, 2).unwrap();
431 let sorted = by_sorting(v, 9500, 300, seed);
432 assert_eq!(
433 counted.low.to_bits(),
434 sorted.low.to_bits(),
435 "{v:?} seed {seed}"
436 );
437 assert_eq!(
438 counted.high.to_bits(),
439 sorted.high.to_bits(),
440 "{v:?} seed {seed}"
441 );
442 assert_eq!(counted, sorted, "{v:?} seed {seed}");
443 }
444 }
445 }
446
447 #[test]
451 fn draws_that_never_pass_the_high_rank_answer_with_the_last_value() {
452 let v = [1.0, 2.0, 3.0];
453 assert_eq!(order_statistics(&v, &[0, 1, 0], 0, 1), (2.0, 3.0));
455 assert_eq!(order_statistics(&v, &[0, 0, 0], 0, 1), (3.0, 3.0));
457 let (low, high) = order_statistics(&[], &[], 0, 0);
458 assert!(low.is_nan() && high.is_nan(), "{low} {high}");
459 }
460
461 #[test]
462 fn the_bootstrap_of_identical_values_is_a_point() {
463 let i = bootstrap_median(&[0.25; 8], 9500, 500, 3, 5).unwrap();
464 assert_eq!((i.low, i.high), (0.25, 0.25));
465 }
466
467 #[test]
468 fn the_cluster_bootstrap_needs_two_clusters_and_enough_values() {
469 let many = vec![vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]];
470 assert!(
471 bootstrap_median_clustered(&many, 9500, 500, 3, 5).is_none(),
472 "one task"
473 );
474 let few = vec![vec![0.1, 0.2], vec![0.3]];
475 assert!(
476 bootstrap_median_clustered(&few, 9500, 500, 3, 5).is_none(),
477 "three values"
478 );
479 let empty_dropped = vec![vec![], vec![0.1, 0.2, 0.3], vec![f64::NAN]];
480 assert!(
481 bootstrap_median_clustered(&empty_dropped, 9500, 500, 3, 2).is_none(),
482 "an empty or non-finite cluster is not a second task"
483 );
484 let ok = vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5]];
485 assert!(
486 bootstrap_median_clustered(&ok, 9500, 0, 3, 5).is_none(),
487 "no resamples"
488 );
489 let i = bootstrap_median_clustered(&ok, 9500, 500, 3, 5).unwrap();
490 assert!(i.low <= i.high);
491 assert_eq!(i.method, CLUSTER_METHOD);
492 }
493
494 #[test]
495 fn the_cluster_bootstrap_is_reproducible_from_its_seed() {
496 let c: Vec<Vec<f64>> = (0..8)
497 .map(|t| {
498 (0..4)
499 .map(|r| (t as f64 * 0.37 + r as f64 * 0.01).sin())
500 .collect()
501 })
502 .collect();
503 let a = bootstrap_median_clustered(&c, 9500, 1000, 1, 5).unwrap();
504 assert_eq!(a, bootstrap_median_clustered(&c, 9500, 1000, 1, 5).unwrap());
505 assert!(a != bootstrap_median_clustered(&c, 9500, 1000, 2, 5).unwrap());
506 }
507
508 #[test]
509 fn tasks_that_differ_widen_the_interval_past_the_pooled_one() {
510 let clusters: Vec<Vec<f64>> = (0..6)
513 .map(|t| (0..5).map(|r| t as f64 * 0.1 + r as f64 * 0.001).collect())
514 .collect();
515 let pooled: Vec<f64> = clusters.iter().flatten().copied().collect();
516 let p = bootstrap_median(&pooled, 9500, 4000, 5, 5).unwrap();
517 let c = bootstrap_median_clustered(&clusters, 9500, 4000, 5, 5).unwrap();
518 assert!(
519 c.high - c.low >= p.high - p.low,
520 "cluster {c:?} against pooled {p:?}"
521 );
522 }
523
524 #[test]
525 fn a_level_is_printed_with_the_decimals_it_has() {
526 assert_eq!(level_text(9500), "95%");
527 assert_eq!(level_text(9750), "97.5%");
528 assert_eq!(level_text(9905), "99.05%");
529 assert_eq!(level_text(10_000), "100%");
530 assert_eq!(level_text(50), "0.5%");
531 }
532
533 #[test]
534 fn a_percentage_that_rounds_to_zero_has_no_sign() {
535 assert_eq!(percent_text(-0.0), "0.0%");
536 assert_eq!(percent_text(-0.00049), "0.0%");
537 assert_eq!(percent_text(0.00049), "0.0%");
538 assert_eq!(percent_text(-0.0006), "-0.1%");
539 assert_eq!(percent_text(1.5), "+150.0%");
540 assert_eq!(percent_text(f64::INFINITY), "n/a");
541 }
542
543 mod invariants {
544 use super::super::*;
545 use proptest::prelude::*;
546
547 proptest! {
548 #[test]
549 fn identical_sides_reduce_by_nothing(c in 1u64..10_000_000) {
550 prop_assert_eq!(reduction(c, c), Some(0.0));
551 }
552
553 #[test]
554 fn the_sign_follows_which_side_spent_more(c in 1u64..10_000_000, t in 0u64..10_000_000) {
555 let r = reduction(c, t).unwrap();
556 if t < c { prop_assert!(r > 0.0); }
557 if t > c { prop_assert!(r < 0.0); }
558 if t == c { prop_assert_eq!(r, 0.0); }
559 }
560
561 #[test]
562 fn order_never_changes_an_aggregate(mut v in prop::collection::vec(-3.0f64..1.0, 5..40), seed in 0u64..1000) {
563 let a = (distribution(&v), bootstrap_median(&v, 9500, 200, seed, 5));
564 v.reverse();
565 let b = (distribution(&v), bootstrap_median(&v, 9500, 200, seed, 5));
566 prop_assert_eq!(a, b);
567 }
568
569 #[test]
570 fn cluster_order_never_changes_the_cluster_interval(
571 clusters in prop::collection::vec(prop::collection::vec(-3.0f64..1.0, 1..6), 2..10),
572 seed in 0u64..1000,
573 rotate in 0usize..10,
574 ) {
575 let a = bootstrap_median_clustered(&clusters, 9500, 200, seed, 5);
576 let mut shuffled: Vec<Vec<f64>> = clusters
577 .iter()
578 .map(|c| { let mut c = c.clone(); c.reverse(); c })
579 .collect();
580 shuffled.reverse();
581 let r = rotate % shuffled.len();
582 shuffled.rotate_left(r);
583 prop_assert_eq!(a, bootstrap_median_clustered(&shuffled, 9500, 200, seed, 5));
584 }
585
586 #[test]
587 fn the_cluster_interval_lies_within_the_values(
588 clusters in prop::collection::vec(prop::collection::vec(-3.0f64..1.0, 1..6), 2..10),
589 ) {
590 let all: Vec<f64> = clusters.iter().flatten().copied().collect();
591 let d = distribution(&all).unwrap();
592 if let Some(i) = bootstrap_median_clustered(&clusters, 9500, 200, 11, 5) {
593 prop_assert!(d.min - 1e-9 <= i.low && i.low <= i.high && i.high <= d.max + 1e-9);
594 }
595 }
596
597 #[test]
598 fn a_percentage_never_prints_negative_zero(x in -0.01f64..0.01) {
599 let s = percent_text(x);
600 prop_assert!(!s.starts_with("-0.0%") && s != "+0.0%", "{}", s);
601 }
602
603 #[test]
604 fn the_median_lies_within_its_interval_bounds(v in prop::collection::vec(-3.0f64..1.0, 5..40)) {
605 let d = distribution(&v).unwrap();
606 prop_assert!(d.min <= d.median && d.median <= d.max);
607 let i = bootstrap_median(&v, 9500, 300, 11, 5).unwrap();
608 prop_assert!(d.min - 1e-9 <= i.low && i.high <= d.max + 1e-9);
609 }
610 }
611 }
612}