Skip to main content

majordomus_cli/economics/
stats.rs

1//! The arithmetic of the economics subsystem, and nothing else: the reduction formula,
2//! order statistics, a seeded bootstrap (over independent values, or over clusters when
3//! the values are repetitions of the same task), and the two ways a number is printed.
4//! Every surface that shows a number gets it from here through [`super::summarize`];
5//! nothing downstream recomputes one, and nothing downstream formats a percentage its own
6//! way.
7//!
8//! Two rules are enforced by the types rather than by care:
9//!
10//! - A reduction against a zero control is `None`, never zero and never infinity: there is
11//!   nothing to reduce, so there is no ratio.
12//! - A negative reduction is kept. Treatment costing more than control is a result.
13//!
14//! ```
15//! use majordomus_cli::economics::stats::{reduction, distribution, percent_text};
16//! assert_eq!(reduction(100, 50), Some(0.5));
17//! assert_eq!(reduction(100, 120), Some(-0.2));
18//! assert_eq!(reduction(0, 10), None);
19//! let d = distribution(&[0.1, 0.3, 0.2]).unwrap();
20//! assert_eq!(d.median, 0.2);
21//! assert_eq!(percent_text(reduction(100, 120).unwrap()), "-20.0%");
22//! ```
23
24use super::model::{EconomicsDistribution, EconomicsInterval};
25
26/// `1 - treatment / control`: the share of the control's quantity the treatment did not
27/// spend. Negative when the treatment spent more; `None` when the control spent nothing,
28/// because a ratio against zero is not a number anyone should be shown.
29///
30/// ```
31/// use majordomus_cli::economics::stats::reduction;
32/// assert_eq!(reduction(100, 100), Some(0.0));
33/// assert_eq!(reduction(100, 0), Some(1.0));
34/// assert_eq!(reduction(100, 120), Some(-0.2));
35/// assert_eq!(reduction(0, 0), None);
36/// ```
37pub 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
44/// The `q` quantile of sorted values, linear between the two nearest ranks (Hyndman and
45/// Fan's type 7, the default of R and NumPy). `None` for no values.
46///
47/// ```
48/// use majordomus_cli::economics::stats::quantile;
49/// assert_eq!(quantile(&[1.0, 2.0, 3.0, 4.0], 0.5), Some(2.5));
50/// assert_eq!(quantile(&[7.0], 0.95), Some(7.0));
51/// assert_eq!(quantile(&[], 0.5), None);
52/// ```
53pub 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
72/// The distribution of a set of values: order statistics, mean and sample standard
73/// deviation. Independent of the order the values arrive in. `None` for no values.
74///
75/// ```
76/// use majordomus_cli::economics::stats::distribution;
77/// let d = distribution(&[0.32, 0.51, 0.41]).unwrap();
78/// assert_eq!((d.n, d.median, d.min, d.max), (3, 0.41, 0.32, 0.51));
79/// assert!(distribution(&[0.5]).unwrap().sd.is_none(), "one value has no spread");
80/// assert!(distribution(&[]).is_none());
81/// ```
82pub 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
104/// Twelve decimal places: enough for any ratio this subsystem computes, few enough that the
105/// same arithmetic on two machines prints the same JSON.
106pub(crate) fn round(x: f64) -> f64 {
107    (x * 1e12).round() / 1e12
108}
109
110/// SplitMix64: a small, fully specified generator, so that the interval a seed produces is
111/// the same on every machine and in every version of every dependency.
112struct 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    /// Uniform in `0..n` by rejection, so that no index is favoured.
124    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
135/// The percentile interval of a set of bootstrap medians at `level_bp`.
136fn 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
155/// What [`bootstrap_median`] writes into [`EconomicsInterval::method`].
156pub const INDEPENDENT_METHOD: &str =
157    "percentile bootstrap of the median, each value resampled independently with replacement";
158
159/// What [`bootstrap_median_clustered`] writes into [`EconomicsInterval::method`].
160pub 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
162/// A percentile bootstrap interval of the median: resample the values with replacement
163/// `resamples` times, take each resample's median, and report the quantiles of those
164/// medians at the requested confidence. The values are sorted first, so the interval
165/// depends on the set of values and the seed, never on the order they were listed in.
166/// `None` below `min_n` values: an interval over two pairs would be precision nobody has.
167///
168/// Resampling each value on its own assumes the values are independent. That holds for
169/// the context suite's seeds, each a separate compilation; it does not hold for the
170/// repetitions of one task, which is what [`bootstrap_median_clustered`] is for.
171///
172/// ```
173/// use majordomus_cli::economics::stats::{bootstrap_median, INDEPENDENT_METHOD};
174/// let v = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6];
175/// let a = bootstrap_median(&v, 9500, 2000, 7, 5).unwrap();
176/// let b = bootstrap_median(&[0.6, 0.5, 0.4, 0.3, 0.2, 0.1], 9500, 2000, 7, 5).unwrap();
177/// assert_eq!(a, b, "order does not matter");
178/// assert!(a.low <= 0.35 && 0.35 <= a.high);
179/// assert_eq!(a.method, INDEPENDENT_METHOD);
180/// assert!(bootstrap_median(&v[..3], 9500, 2000, 7, 5).is_none());
181/// ```
182pub 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    // A resample is `n` draws of an index into `v`, and `v` is in order, so the resample in
197    // order is `v` at its drawn indices in order: how often each index was drawn gives the
198    // two order statistics `quantile(_, 0.5)` reads, without sorting the resample. Sorting it
199    // was most of `generate`'s economics on 2026-10-15. The same draws and the same
200    // arithmetic give the same median to the bit (`counting_is_sorting`, below).
201    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
215/// The `lo`-th and `hi`-th values (from zero, `lo <= hi`) of the resample that drew index `i`
216/// of the ordered `v` `drawn[i]` times: where the running count of draws first passes each.
217fn 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, &times) 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    // `drawn` sums to `v.len()`, which is more than `hi`: the loop has already returned
230    let last = v.last().copied().unwrap_or(f64::NAN);
231    (low.unwrap_or(last), last)
232}
233
234/// A percentile bootstrap interval of the median over clustered values: each resample
235/// draws as many clusters as there are, with replacement, pools every value of each drawn
236/// cluster, and takes the median of the pool.
237///
238/// The repetitions of one task share its fixture, its prompt and its difficulty, so they
239/// are not independent observations: resampling them one by one would treat four runs of
240/// one easy task as four tasks and report an interval narrower than the evidence allows.
241/// Resampling whole clusters (tasks) keeps what varies between tasks in the interval.
242///
243/// Deterministic: non-finite values are dropped, each cluster is sorted, empty clusters
244/// are dropped, and the clusters are sorted by their sorted values before any draw, so the
245/// interval depends on the clusters and the seed, never on the order they were listed in.
246/// `None` below `min_n` values in total, with fewer than two clusters (a single task has
247/// no between-task variation to resample), or with no resamples.
248///
249/// ```
250/// use majordomus_cli::economics::stats::{bootstrap_median_clustered, CLUSTER_METHOD};
251/// let tasks = vec![vec![0.1, 0.12], vec![0.3, 0.31, 0.29], vec![0.5, 0.52]];
252/// let a = bootstrap_median_clustered(&tasks, 9500, 2000, 7, 5).unwrap();
253/// let shuffled = vec![vec![0.52, 0.5], vec![0.1, 0.12], vec![0.29, 0.31, 0.3]];
254/// assert_eq!(a, bootstrap_median_clustered(&shuffled, 9500, 2000, 7, 5).unwrap());
255/// assert!(a.low <= 0.3 && 0.3 <= a.high);
256/// assert_eq!(a.method, CLUSTER_METHOD);
257/// let one_task = vec![vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6]];
258/// assert!(bootstrap_median_clustered(&one_task, 9500, 2000, 7, 5).is_none());
259/// ```
260pub 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
292/// A confidence level in basis points as a person reads it: `9500` is `95%`, `9750` is
293/// `97.5%`. Whole percentages carry no decimals and a fraction carries only the digits it
294/// needs, so a level is never rounded into one the methodology did not declare.
295///
296/// ```
297/// use majordomus_cli::economics::stats::level_text;
298/// assert_eq!(level_text(9500), "95%");
299/// assert_eq!(level_text(9750), "97.5%");
300/// assert_eq!(level_text(9999), "99.99%");
301/// assert_eq!(level_text(9000), "90%");
302/// ```
303pub 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
313/// A ratio as a signed percentage with one decimal: `0.2` is `+20.0%`, `-0.05` is `-5.0%`.
314/// Anything that rounds to zero at one decimal prints `0.0%` with no sign, so a surface
315/// never shows `-0.0%` for a result that is, to the precision shown, no change. A value
316/// that is not a number prints `n/a` rather than a figure.
317///
318/// ```
319/// use majordomus_cli::economics::stats::percent_text;
320/// assert_eq!(percent_text(0.2), "+20.0%");
321/// assert_eq!(percent_text(-0.05), "-5.0%");
322/// assert_eq!(percent_text(-0.0001), "0.0%", "never -0.0%");
323/// assert_eq!(percent_text(0.0), "0.0%");
324/// assert_eq!(percent_text(f64::NAN), "n/a");
325/// ```
326pub 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    /// Counting the draws answers what sorting each resample answered, to the bit: the
397    /// interval is compared with the one the sorting implementation computed, over odd and
398    /// even sizes, repeated values, both zeros, negative values and many seeds.
399    #[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    /// Draws that never pass `hi` — which the bootstrap cannot produce, since its draws sum
448    /// to the sample size — still answer: the high statistic is the last value, the low one
449    /// whatever was passed, or the last value too, and an empty sample is not a number.
450    #[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        // one draw of the middle value passes rank 0 but never rank 1
454        assert_eq!(order_statistics(&v, &[0, 1, 0], 0, 1), (2.0, 3.0));
455        // no draw at all passes neither rank
456        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        // six tasks, each repeated five times with nearly identical results: the pooled
511        // bootstrap sees thirty independent values, the cluster bootstrap sees six tasks
512        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}