1pub const BOOTSTRAP_ITERATIONS: usize = 2000;
13
14use rand::Rng;
15
16pub fn percentile(sorted: &[f64], p: f64) -> f64 {
18 assert!(!sorted.is_empty(), "empty sample");
19 let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize;
20 sorted[idx.min(sorted.len() - 1)]
21}
22
23fn sorted_copy(data: &[f64]) -> Vec<f64> {
24 let mut v = data.to_vec();
25 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
26 v
27}
28
29#[derive(Debug, Clone)]
33pub struct BoxTestResult {
34 pub class_a_low_percentile: f64,
35 pub class_b_low_percentile: f64,
36 pub estimated_leak: f64,
38 pub ci_low: f64,
39 pub ci_high: f64,
40 pub confidence: f64,
41}
42
43impl BoxTestResult {
44 pub fn is_significant(&self) -> bool {
47 self.ci_low > 0.0 || self.ci_high < 0.0
48 }
49}
50
51pub fn box_test(
55 class_a: &[f64],
56 class_b: &[f64],
57 low_percentile: f64,
58 confidence: f64,
59) -> BoxTestResult {
60 let a_sorted = sorted_copy(class_a);
61 let b_sorted = sorted_copy(class_b);
62
63 let a_p = percentile(&a_sorted, low_percentile);
64 let b_p = percentile(&b_sorted, low_percentile);
65 let leak = b_p - a_p;
66
67 let (ci_low, ci_high) = bootstrap_ci(
68 class_a,
69 class_b,
70 low_percentile,
71 confidence,
72 BOOTSTRAP_ITERATIONS,
73 );
74
75 BoxTestResult {
76 class_a_low_percentile: a_p,
77 class_b_low_percentile: b_p,
78 estimated_leak: leak,
79 ci_low,
80 ci_high,
81 confidence,
82 }
83}
84
85fn bootstrap_ci(
89 class_a: &[f64],
90 class_b: &[f64],
91 p: f64,
92 confidence: f64,
93 iterations: usize,
94) -> (f64, f64) {
95 let mut rng = rand::thread_rng();
96 let mut diffs = Vec::with_capacity(iterations);
97
98 for _ in 0..iterations {
99 let resample_a = resample(class_a, &mut rng);
100 let resample_b = resample(class_b, &mut rng);
101 let pa = percentile(&sorted_copy(&resample_a), p);
102 let pb = percentile(&sorted_copy(&resample_b), p);
103 diffs.push(pb - pa);
104 }
105
106 diffs.sort_by(|a, b| a.partial_cmp(b).unwrap());
107 let alpha = 1.0 - confidence;
108 let lo_idx = ((alpha / 2.0) * diffs.len() as f64) as usize;
109 let hi_idx = (((1.0 - alpha / 2.0) * diffs.len() as f64) as usize).min(diffs.len() - 1);
110 (diffs[lo_idx], diffs[hi_idx])
111}
112
113fn resample(data: &[f64], rng: &mut impl Rng) -> Vec<f64> {
114 (0..data.len())
115 .map(|_| data[rng.gen_range(0..data.len())])
116 .collect()
117}
118
119pub fn estimate_jitter(pilot: &[f64]) -> f64 {
132 if pilot.is_empty() {
133 return 0.0;
134 }
135 let sorted = sorted_copy(pilot);
136 let median = percentile(&sorted, 50.0);
137 let mut abs_deviations: Vec<f64> = pilot.iter().map(|x| (x - median).abs()).collect();
138 abs_deviations.sort_by(|a, b| a.partial_cmp(b).unwrap());
139 let mad = percentile(&abs_deviations, 50.0);
140 mad * 1.4826
141}
142
143pub fn required_samples(jitter: f64, expected_leak_seconds: f64, confidence: f64) -> u64 {
148 if expected_leak_seconds <= 0.0 {
149 return u64::MAX;
150 }
151 let z_alpha = inverse_normal_cdf(1.0 - (1.0 - confidence) / 2.0);
153 let z_beta = 0.84;
154 let n = 2.0 * (z_alpha + z_beta).powi(2) * jitter.powi(2) / expected_leak_seconds.powi(2);
155 n.ceil() as u64
156}
157
158fn inverse_normal_cdf(p: f64) -> f64 {
161 let a = [
163 -3.969683028665376e+01,
164 2.209460984245205e+02,
165 -2.759285104469687e+02,
166 1.383_577_518_672_69e2,
167 -3.066479806614716e+01,
168 2.506628277459239e+00,
169 ];
170 let b = [
171 -5.447609879822406e+01,
172 1.615858368580409e+02,
173 -1.556989798598866e+02,
174 6.680131188771972e+01,
175 -1.328068155288572e+01,
176 ];
177 let c = [
178 -7.784894002430293e-03,
179 -3.223964580411365e-01,
180 -2.400758277161838e+00,
181 -2.549732539343734e+00,
182 4.374664141464968e+00,
183 2.938163982698783e+00,
184 ];
185 let d = [
186 7.784695709041462e-03,
187 3.224671290700398e-01,
188 2.445134137142996e+00,
189 3.754408661907416e+00,
190 ];
191 let p_low = 0.02425;
192 let p_high = 1.0 - p_low;
193
194 if p < p_low {
195 let q = (-2.0 * p.ln()).sqrt();
196 (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
197 / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
198 } else if p <= p_high {
199 let q = p - 0.5;
200 let r = q * q;
201 (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q
202 / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0)
203 } else {
204 let q = (-2.0 * (1.0 - p).ln()).sqrt();
205 -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5])
206 / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn percentile_basic() {
216 let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
217 assert_eq!(percentile(&data, 0.0), 1.0);
218 assert_eq!(percentile(&data, 100.0), 5.0);
219 assert_eq!(percentile(&data, 50.0), 3.0);
220 }
221
222 #[test]
223 fn jitter_estimate_is_robust_to_a_single_outlier() {
224 let mut stable: Vec<f64> = (0..300)
230 .map(|i| 0.0002 + (i as f64 % 5.0) * 0.00001)
231 .collect();
232 let jitter_without_outlier = estimate_jitter(&stable);
233
234 stable[0] = 0.050;
236 let jitter_with_outlier = estimate_jitter(&stable);
237
238 assert!(
241 jitter_with_outlier < jitter_without_outlier * 3.0,
242 "a single outlier out of 300 samples should not blow up the jitter \
243 estimate this much: {jitter_without_outlier} -> {jitter_with_outlier}"
244 );
245 }
246
247 #[test]
248 fn box_test_detects_no_difference() {
249 let a: Vec<f64> = (0..1000)
250 .map(|i| 0.010 + (i as f64 % 7.0) * 0.0001)
251 .collect();
252 let b = a.clone();
253 let result = box_test(&a, &b, 10.0, 0.95);
254 assert!(
255 !result.is_significant(),
256 "identical samples must not be significant"
257 );
258 }
259
260 #[test]
261 fn box_test_detects_real_difference() {
262 let a: Vec<f64> = (0..2000)
263 .map(|i| 0.010 + (i as f64 % 11.0) * 0.0002)
264 .collect();
265 let b: Vec<f64> = (0..2000)
266 .map(|i| 0.010 + 0.0005 + (i as f64 % 11.0) * 0.0002)
267 .collect();
268 let result = box_test(&a, &b, 10.0, 0.95);
269 assert!(
270 result.is_significant(),
271 "clear 0.5ms shift must be detected"
272 );
273 assert!(result.estimated_leak > 0.0);
274 }
275}