Skip to main content

tt_plan_core/
bootstrap.rs

1//! Deterministic bootstrap confidence intervals. Uses ChaCha8 (seeded)
2//! so the same `(samples, seed, iterations)` triple always produces the
3//! same `(lo, hi)`. ChaCha8 is the same RNG family `rand` itself uses for
4//! `StdRng` on most platforms; the explicit choice here is so we don't
5//! inherit a future `rand` default-change as a determinism break.
6
7use rand::{Rng, SeedableRng};
8use rand_chacha::ChaCha8Rng;
9
10/// Resample `samples` with replacement `iterations` times. For each
11/// resample, compute the mean. Return the percentiles of the resampled
12/// means — the canonical non-parametric bootstrap CI of the mean.
13///
14/// Returns `(0.0, 0.0)` when `samples` is empty or `iterations == 0`
15/// rather than panicking — callers in `replay.rs` rely on this for the
16/// "no requests in window" fast path.
17#[must_use]
18pub fn bootstrap_ci(
19    samples: &[f64],
20    seed: u64,
21    iterations: u32,
22    percentiles: (f64, f64),
23) -> (f64, f64) {
24    let n = samples.len();
25    if n == 0 || iterations == 0 {
26        return (0.0, 0.0);
27    }
28    let mut rng = ChaCha8Rng::seed_from_u64(seed);
29    let n_f = n as f64;
30    let mut means: Vec<f64> = Vec::with_capacity(iterations as usize);
31    for _ in 0..iterations {
32        let mut sum = 0.0;
33        for _ in 0..n {
34            let idx = rng.gen_range(0..n);
35            sum += samples[idx];
36        }
37        means.push(sum / n_f);
38    }
39    means.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
40    let iter_f = iterations as f64;
41    let lo_idx = ((percentiles.0 * iter_f) as usize).min(means.len() - 1);
42    let hi_idx = ((percentiles.1 * iter_f) as usize).min(means.len() - 1);
43    (means[lo_idx], means[hi_idx])
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    #[test]
51    fn empty_input_returns_zero() {
52        assert_eq!(bootstrap_ci(&[], 1, 1000, (0.025, 0.975)), (0.0, 0.0));
53    }
54
55    #[test]
56    fn zero_iterations_returns_zero() {
57        assert_eq!(
58            bootstrap_ci(&[1.0, 2.0, 3.0], 1, 0, (0.025, 0.975)),
59            (0.0, 0.0)
60        );
61    }
62
63    #[test]
64    fn determinism_same_seed() {
65        let samples: Vec<f64> = (0..100).map(|i| i as f64).collect();
66        let a = bootstrap_ci(&samples, 42, 1000, (0.025, 0.975));
67        let b = bootstrap_ci(&samples, 42, 1000, (0.025, 0.975));
68        assert_eq!(a, b);
69    }
70
71    #[test]
72    fn different_seed_different_output() {
73        let samples: Vec<f64> = (0..100).map(|i| i as f64).collect();
74        let a = bootstrap_ci(&samples, 42, 1000, (0.025, 0.975));
75        let b = bootstrap_ci(&samples, 43, 1000, (0.025, 0.975));
76        assert_ne!(a, b);
77    }
78
79    #[test]
80    fn constant_samples_zero_width_ci() {
81        let samples = vec![5.0; 50];
82        let (lo, hi) = bootstrap_ci(&samples, 7, 500, (0.025, 0.975));
83        // Every resample's mean is exactly 5.0 — CI collapses.
84        assert!((lo - 5.0).abs() < 1e-12);
85        assert!((hi - 5.0).abs() < 1e-12);
86    }
87}