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