Skip to main content

nd_300/speedtest/
statistics.rs

1//! Statistical utilities for accurate speed test measurement.
2//!
3//! Port of the QubeTX web speed test's `statistics.ts` module.
4//!
5//! Split into two eras:
6//!   * **v3 estimators** (percentile, classic/modified trimean, IQR filter,
7//!     slow-start discard, RFC 3550 jitter, CV, percentile bootstrap,
8//!     inverse-variance merge) — retained for the current aggregation pipeline
9//!     and imported by diagnostics elsewhere in the crate.
10//!   * **v4 core** (SpeedQX Methodology v4): plateau warm-up detection,
11//!     Hodges–Lehmann cross-check, circular block bootstrap + BCa, DerSimonian–
12//!     Laird τ²/I² with HKSJ confidence intervals, capacity/consensus hybrid
13//!     merge, PDV/IPDV/MAD jitter, delta-ms bufferbloat + grade + RPM, and the
14//!     empirical-Bernstein confidence sequence for FAST-mode early stopping.
15//!
16//! The v4 functions build only on the pinned primitives in [`stat_primitives`]
17//! so the TypeScript and Rust implementations produce identical golden vectors.
18
19use super::stat_primitives::{
20    inv_normal, phi, quantile, sample_mean, sample_variance, sum, t975, Pcg32,
21};
22use serde::Serialize;
23
24// ── Percentile ──────────────────────────────────────────────────────────
25
26/// Linear-interpolation percentile on a pre-sorted slice. `p` is in [0.0, 1.0].
27pub fn percentile(sorted: &[f64], p: f64) -> f64 {
28    if sorted.is_empty() {
29        return 0.0;
30    }
31    if sorted.len() == 1 {
32        return sorted[0];
33    }
34    let idx = p * (sorted.len() - 1) as f64;
35    let lo = idx.floor() as usize;
36    let hi = idx.ceil() as usize;
37    if lo == hi {
38        return sorted[lo];
39    }
40    sorted[lo] + (sorted[hi] - sorted[lo]) * (idx - lo as f64)
41}
42
43// ── Sanitization ────────────────────────────────────────────────────────
44
45/// Drop non-finite samples (NaN / ±inf). The single choke point through
46/// which every merge input flows: provider arithmetic is individually
47/// guarded, but a corrupted sample must never reach the trimean pipeline or
48/// the inverse-variance merge.
49pub fn sanitize(values: &[f64]) -> Vec<f64> {
50    values.iter().copied().filter(|v| v.is_finite()).collect()
51}
52
53// ── Central tendency ────────────────────────────────────────────────────
54
55pub fn mean(values: &[f64]) -> f64 {
56    if values.is_empty() {
57        return 0.0;
58    }
59    values.iter().sum::<f64>() / values.len() as f64
60}
61
62pub fn median(values: &[f64]) -> f64 {
63    if values.is_empty() {
64        return 0.0;
65    }
66    let mut sorted = values.to_vec();
67    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
68    percentile(&sorted, 0.5)
69}
70
71/// Sample standard deviation (Bessel's correction).
72pub fn stddev(values: &[f64]) -> f64 {
73    if values.len() < 2 {
74        return 0.0;
75    }
76    variance(values).sqrt()
77}
78
79/// Sample variance (Bessel's correction).
80pub fn variance(values: &[f64]) -> f64 {
81    if values.len() < 2 {
82        return 0.0;
83    }
84    let m = mean(values);
85    values.iter().map(|v| (v - m).powi(2)).sum::<f64>() / (values.len() - 1) as f64
86}
87
88// ── Trimean ─────────────────────────────────────────────────────────────
89
90/// Classic Tukey trimean: `(Q1 + 2*median + Q3) / 4`.
91pub fn trimean(values: &[f64]) -> f64 {
92    if values.is_empty() {
93        return 0.0;
94    }
95    let mut sorted = values.to_vec();
96    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
97    let q1 = percentile(&sorted, 0.25);
98    let q2 = percentile(&sorted, 0.50);
99    let q3 = percentile(&sorted, 0.75);
100    (q1 + 2.0 * q2 + q3) / 4.0
101}
102
103/// Ookla-style modified trimean: `(P10 + 8*P50 + P90) / 10`.
104pub fn modified_trimean(values: &[f64]) -> f64 {
105    if values.is_empty() {
106        return 0.0;
107    }
108    let mut sorted = values.to_vec();
109    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
110    let p10 = percentile(&sorted, 0.10);
111    let p50 = percentile(&sorted, 0.50);
112    let p90 = percentile(&sorted, 0.90);
113    (p10 + 8.0 * p50 + p90) / 10.0
114}
115
116// ── Outlier filtering ───────────────────────────────────────────────────
117
118/// Remove values outside `[Q1 - k*IQR, Q3 + k*IQR]`. Default `k = 1.5`.
119pub fn filter_outliers_iqr(values: &[f64], k: f64) -> Vec<f64> {
120    if values.len() < 4 {
121        return values.to_vec();
122    }
123    let mut sorted = values.to_vec();
124    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
125    let q1 = percentile(&sorted, 0.25);
126    let q3 = percentile(&sorted, 0.75);
127    let iqr = q3 - q1;
128    let lo = q1 - k * iqr;
129    let hi = q3 + k * iqr;
130    values
131        .iter()
132        .copied()
133        .filter(|v| *v >= lo && *v <= hi)
134        .collect()
135}
136
137// ── Slow-start discard ──────────────────────────────────────────────────
138
139/// Discard the first `fraction` of samples to eliminate TCP slow-start
140/// ramp-up contamination. Default: discard first 30%.
141pub fn discard_slow_start(values: &[f64], fraction: f64) -> Vec<f64> {
142    if values.len() < 4 {
143        return values.to_vec();
144    }
145    let cut = (values.len() as f64 * fraction).ceil() as usize;
146    values[cut..].to_vec()
147}
148
149// ── Winsorization ───────────────────────────────────────────────────────
150
151/// Cap extreme values at the given percentiles instead of removing them.
152pub fn winsorize(values: &[f64], lower: f64, upper: f64) -> Vec<f64> {
153    if values.len() < 4 {
154        return values.to_vec();
155    }
156    let mut sorted = values.to_vec();
157    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
158    let lo = percentile(&sorted, lower);
159    let hi = percentile(&sorted, upper);
160    values.iter().map(|v| v.max(lo).min(hi)).collect()
161}
162
163// ── Bandwidth pipelines ─────────────────────────────────────────────────
164
165/// Full accuracy pipeline for download bandwidth samples:
166/// 1. Discard slow-start ramp-up (first 30%)
167/// 2. Remove IQR outliers
168/// 3. Compute modified trimean
169/// 4. Cross-validate with Winsorized trimean (average if >15% divergence)
170pub fn accurate_bandwidth(samples: &[f64]) -> f64 {
171    if samples.is_empty() {
172        return 0.0;
173    }
174    let after_slow_start = discard_slow_start(samples, 0.3);
175
176    // Primary: IQR-filtered trimean
177    let cleaned = filter_outliers_iqr(&after_slow_start, 1.5);
178    let iqr_result = if cleaned.is_empty() {
179        modified_trimean(&after_slow_start)
180    } else {
181        modified_trimean(&cleaned)
182    };
183
184    // Cross-check: Winsorized trimean
185    if after_slow_start.len() >= 4 {
186        let winsorized = winsorize(&after_slow_start, 0.05, 0.95);
187        let win_result = modified_trimean(&winsorized);
188
189        if iqr_result > 0.0 && win_result > 0.0 {
190            let divergence = (iqr_result - win_result).abs() / iqr_result.max(win_result);
191            if divergence > 0.15 {
192                return (iqr_result + win_result) / 2.0;
193            }
194        }
195    }
196
197    iqr_result
198}
199
200/// Upload-specific accuracy pipeline.
201/// Upload ramp-up is slower and more variable than download. Following
202/// Speedtest.net's methodology, we keep only the fastest 50% of post-warmup
203/// samples before computing the trimean.
204///
205/// Pipeline:
206/// 1. Discard slow-start ramp-up (first 30%)
207/// 2. Keep only the fastest 50% of remaining samples
208/// 3. Remove IQR outliers
209/// 4. Compute modified trimean + Winsorized cross-validation
210pub fn accurate_upload_bandwidth(samples: &[f64]) -> f64 {
211    if samples.is_empty() {
212        return 0.0;
213    }
214    let after_slow_start = discard_slow_start(samples, 0.3);
215    if after_slow_start.len() < 2 {
216        return accurate_bandwidth(samples);
217    }
218
219    // Keep fastest 50% (sort descending, take top half)
220    let mut sorted_desc = after_slow_start.clone();
221    sorted_desc.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
222    let top_half_count = (sorted_desc.len() as f64 / 2.0).ceil() as usize;
223    let top_half: Vec<f64> = sorted_desc[..top_half_count].to_vec();
224
225    // Primary: IQR filter → modified trimean
226    let cleaned = filter_outliers_iqr(&top_half, 1.5);
227    let iqr_result = if cleaned.is_empty() {
228        modified_trimean(&top_half)
229    } else {
230        modified_trimean(&cleaned)
231    };
232
233    // Cross-check: Winsorized trimean on same top-half set
234    if top_half.len() >= 4 {
235        let winsorized = winsorize(&top_half, 0.05, 0.95);
236        let win_result = modified_trimean(&winsorized);
237
238        if iqr_result > 0.0 && win_result > 0.0 {
239            let divergence = (iqr_result - win_result).abs() / iqr_result.max(win_result);
240            if divergence > 0.15 {
241                return (iqr_result + win_result) / 2.0;
242            }
243        }
244    }
245
246    iqr_result
247}
248
249// ── Jitter ──────────────────────────────────────────────────────────────
250
251/// RFC 3550 jitter: exponentially weighted moving average of inter-arrival
252/// variance. `J[i] = J[i-1] + (|D(i-1,i)| - J[i-1]) / 16`
253pub fn jitter_rfc3550(samples: &[f64]) -> f64 {
254    if samples.len() < 2 {
255        return 0.0;
256    }
257    let mut j = 0.0_f64;
258    for i in 1..samples.len() {
259        let d = (samples[i] - samples[i - 1]).abs();
260        j += (d - j) / 16.0;
261    }
262    j
263}
264
265/// Mean absolute deviation of consecutive samples (original method).
266pub fn jitter_mad(samples: &[f64]) -> f64 {
267    if samples.len() < 2 {
268        return 0.0;
269    }
270    let sum: f64 = samples.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
271    sum / (samples.len() - 1) as f64
272}
273
274// ── Stability ───────────────────────────────────────────────────────────
275
276/// Coefficient of variation (stddev / mean). Lower = more stable.
277pub fn coefficient_of_variation(values: &[f64]) -> f64 {
278    let m = mean(values);
279    if m == 0.0 {
280        return 0.0;
281    }
282    stddev(values) / m
283}
284
285// ── Confidence-weighted merge ───────────────────────────────────────────
286
287/// Weighted average of two values. If one is zero/missing, return the other.
288/// `weight_a` is the weight for `a`; `b` gets `1 - weight_a`.
289pub fn weighted_merge(a: f64, b: f64, weight_a: f64) -> f64 {
290    let has_a = a > 0.0;
291    let has_b = b > 0.0;
292    if has_a && has_b {
293        a * weight_a + b * (1.0 - weight_a)
294    } else if has_a {
295        a
296    } else {
297        b
298    }
299}
300
301// ── Inverse-variance merge ─────────────────────────────────────────────
302
303#[derive(Debug, Clone, Serialize)]
304pub struct InverseVarianceResult {
305    pub value: f64,
306    pub weight_a: f64,
307    pub weight_b: f64,
308}
309
310/// Inverse-variance weighted merge of two estimates.
311/// Minimum-variance unbiased estimator for combining independent measurements.
312/// Weights clamped to [0.3, 0.7] to prevent one source from dominating.
313pub fn inverse_variance_merge(a: f64, var_a: f64, b: f64, var_b: f64) -> InverseVarianceResult {
314    if a <= 0.0 && b <= 0.0 {
315        return InverseVarianceResult {
316            value: 0.0,
317            weight_a: 0.5,
318            weight_b: 0.5,
319        };
320    }
321    if a <= 0.0 {
322        return InverseVarianceResult {
323            value: b,
324            weight_a: 0.0,
325            weight_b: 1.0,
326        };
327    }
328    if b <= 0.0 {
329        return InverseVarianceResult {
330            value: a,
331            weight_a: 1.0,
332            weight_b: 0.0,
333        };
334    }
335    if var_a <= 0.0 && var_b <= 0.0 {
336        return InverseVarianceResult {
337            value: (a + b) / 2.0,
338            weight_a: 0.5,
339            weight_b: 0.5,
340        };
341    }
342    if var_a <= 0.0 {
343        return InverseVarianceResult {
344            value: a,
345            weight_a: 1.0,
346            weight_b: 0.0,
347        };
348    }
349    if var_b <= 0.0 {
350        return InverseVarianceResult {
351            value: b,
352            weight_a: 0.0,
353            weight_b: 1.0,
354        };
355    }
356
357    let w_a = 1.0 / var_a;
358    let w_b = 1.0 / var_b;
359    let total = w_a + w_b;
360    let mut weight_a = w_a / total;
361    let mut weight_b = w_b / total;
362
363    // Clamp to [0.3, 0.7] to prevent degenerate weighting
364    if weight_a < 0.3 {
365        weight_a = 0.3;
366        weight_b = 0.7;
367    } else if weight_a > 0.7 {
368        weight_a = 0.7;
369        weight_b = 0.3;
370    }
371
372    InverseVarianceResult {
373        value: a * weight_a + b * weight_b,
374        weight_a,
375        weight_b,
376    }
377}
378
379// ── Bootstrap confidence interval ──────────────────────────────────────
380
381#[derive(Debug, Clone, Serialize)]
382pub struct BootstrapCI {
383    pub estimate: f64,
384    pub lower: f64,
385    pub upper: f64,
386    pub margin: f64,
387}
388
389/// Simple xorshift64 PRNG for bootstrap resampling. Deterministic given seed.
390struct Xorshift64(u64);
391
392impl Xorshift64 {
393    fn new(seed: u64) -> Self {
394        // Ensure non-zero seed
395        Self(if seed == 0 { 0x517cc1b727220a95 } else { seed })
396    }
397
398    fn next(&mut self) -> u64 {
399        let mut x = self.0;
400        x ^= x << 13;
401        x ^= x >> 7;
402        x ^= x << 17;
403        self.0 = x;
404        x
405    }
406
407    fn next_usize(&mut self, bound: usize) -> usize {
408        (self.next() % bound as u64) as usize
409    }
410}
411
412/// Bootstrap confidence interval via percentile method.
413/// Resamples the data `b` times, computes the statistic on each,
414/// then takes the alpha/2 and 1-alpha/2 percentiles as CI bounds.
415pub fn bootstrap_ci(
416    samples: &[f64],
417    stat_fn: fn(&[f64]) -> f64,
418    b: usize,
419    alpha: f64,
420) -> BootstrapCI {
421    if samples.len() < 4 {
422        let est = stat_fn(samples);
423        return BootstrapCI {
424            estimate: est,
425            lower: est,
426            upper: est,
427            margin: 0.0,
428        };
429    }
430
431    let estimate = stat_fn(samples);
432
433    // Seed from sample data for deterministic results
434    let seed = samples.iter().fold(0u64, |acc, v| {
435        acc.wrapping_add(v.to_bits())
436            .wrapping_mul(6364136223846793005)
437    });
438    let mut rng = Xorshift64::new(seed);
439
440    let n = samples.len();
441    let mut bootstrap_stats: Vec<f64> = Vec::with_capacity(b);
442    let mut resample = vec![0.0_f64; n];
443
444    for _ in 0..b {
445        for val in resample.iter_mut() {
446            *val = samples[rng.next_usize(n)];
447        }
448        bootstrap_stats.push(stat_fn(&resample));
449    }
450
451    bootstrap_stats.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
452
453    let lower = percentile(&bootstrap_stats, alpha / 2.0);
454    let upper = percentile(&bootstrap_stats, 1.0 - alpha / 2.0);
455
456    BootstrapCI {
457        estimate,
458        lower,
459        upper,
460        margin: (upper - lower) / 2.0,
461    }
462}
463
464// ════════════════════════════════════════════════════════════════════════
465// SpeedQX Methodology v4 core
466// ════════════════════════════════════════════════════════════════════════
467
468/// Minimum cleaned samples for a provider to qualify for the cross-provider merge.
469pub const MIN_MERGE_SAMPLES: usize = 4;
470
471/// Capability prior for a provider (METHODOLOGY.md §3), keyed by lowercase
472/// registry name. Returns `None` for an unknown provider (caller defaults to 1.0).
473pub fn capability_prior(name: &str) -> Option<f64> {
474    match name {
475        "cloudflare" | "applenq" | "fastcom" => Some(1.0),
476        "librespeed" | "cachefly" | "vultr" => Some(0.95),
477        "msak" => Some(0.85),
478        "ndt7" => Some(0.70),
479        _ => None,
480    }
481}
482
483/// Median on a fresh sorted copy via the type-7 [`quantile`] primitive.
484/// Mirrors the TS `medianOf` helper (used by plateau/HL/MAD), which is
485/// bit-identical to [`median`] but kept separate to track the reference.
486fn median_of(values: &[f64]) -> f64 {
487    let mut s = values.to_vec();
488    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
489    quantile(&s, 0.5)
490}
491
492// ── Plateau warm-up detector ─────────────────────────────────────────────
493
494/// Warm-up cut index (replaces the fixed 30% slow-start discard).
495///
496/// Steady state begins at the first index `t` where 3 consecutive samples sit
497/// within ±10% of the forward median `median(samples[t..end])`; the cut is
498/// clamped to `[ceil(0.10·n), floor(0.40·n)]`. For `n < 8` (too few to detect)
499/// returns `ceil(0.30·n)`. Discard `samples[0 .. plateau_start)`.
500pub fn plateau_start(samples: &[f64]) -> usize {
501    let n = samples.len();
502    if n < 8 {
503        return (0.30_f64 * n as f64).ceil() as usize;
504    }
505
506    let eps = 0.10;
507    let w_len = 3;
508    let mut t_star: i64 = -1;
509
510    for t in 0..=(n - w_len) {
511        let ref_med = median_of(&samples[t..]);
512        if ref_med <= 0.0 {
513            continue;
514        }
515        let mut ok = true;
516        for &s in &samples[t..t + w_len] {
517            if (s - ref_med).abs() / ref_med >= eps {
518                ok = false;
519                break;
520            }
521        }
522        if ok {
523            t_star = t as i64;
524            break;
525        }
526    }
527    if t_star < 0 {
528        t_star = (0.30_f64 * n as f64).ceil() as i64;
529    }
530
531    let lo = (0.10_f64 * n as f64).ceil() as i64;
532    let hi = (0.40_f64 * n as f64).floor() as i64;
533    t_star.max(lo).min(hi) as usize
534}
535
536// ── Hodges–Lehmann estimator ─────────────────────────────────────────────
537
538/// Hodges–Lehmann location: median of all Walsh averages `(x_i + x_j)/2` for
539/// `i ≤ j`. Internal robustness cross-check against the trimean.
540pub fn hodges_lehmann(values: &[f64]) -> f64 {
541    let n = values.len();
542    if n == 0 {
543        return 0.0;
544    }
545    if n == 1 {
546        return values[0];
547    }
548    let mut walsh = Vec::with_capacity(n * (n + 1) / 2);
549    for i in 0..n {
550        for &vj in &values[i..] {
551            walsh.push((values[i] + vj) / 2.0);
552        }
553    }
554    walsh.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
555    quantile(&walsh, 0.5)
556}
557
558// ── Circular block bootstrap + BCa ───────────────────────────────────────
559
560/// Result of the circular block bootstrap of the modified trimean.
561#[derive(Debug, Clone, Serialize)]
562pub struct BlockBootstrapResult {
563    /// Point estimate: `modified_trimean(cleaned)`.
564    pub theta_hat: f64,
565    /// Mean of the `B` resample trimeans.
566    pub theta_star_mean: f64,
567    /// Bootstrap variance of the trimean (`v_j` for the merge; `n-1` denominator).
568    pub variance: f64,
569    /// BCa lower bound (2.5%).
570    pub ci_lower: f64,
571    /// BCa upper bound (97.5%).
572    pub ci_upper: f64,
573    /// Block length `ℓ = max(2, round(n^(1/3)))`.
574    pub block_length: usize,
575    /// Resample count `B`.
576    pub b: usize,
577}
578
579fn bca_bound(sorted_theta_star: &[f64], z0: f64, a: f64, alpha: f64) -> f64 {
580    let z = inv_normal(alpha);
581    let denom = 1.0 - a * (z0 + z);
582    let adj = if denom != 0.0 {
583        z0 + (z0 + z) / denom
584    } else {
585        z0
586    };
587    let mut aa = phi(adj);
588    if !aa.is_finite() {
589        aa = alpha;
590    }
591    // `aa` is finite here, so clamp is bit-identical to the TS `min(max(aa,0),1)`.
592    aa = aa.clamp(0.0, 1.0);
593    quantile(sorted_theta_star, aa)
594}
595
596/// Circular block bootstrap of the modified trimean with a BCa 95% interval.
597///
598/// Block length `ℓ = max(2, round(n^(1/3)))`; `num_blocks = ceil(n/ℓ)`; each
599/// resample concatenates whole circular blocks (start via PCG32 + Lemire,
600/// wrapping mod `n`) and is trimmed to `n`. The `rng` stream is caller-owned so
601/// the orchestrator can thread ONE deterministic stream across DL then UL, per
602/// provider in registry order. Returns the bootstrap variance for the merge.
603pub fn circular_block_bootstrap(
604    cleaned: &[f64],
605    rng: &mut Pcg32,
606    b_count: usize,
607) -> BlockBootstrapResult {
608    let n = cleaned.len();
609    let theta_hat = modified_trimean(cleaned);
610    if n < 2 {
611        return BlockBootstrapResult {
612            theta_hat,
613            theta_star_mean: theta_hat,
614            variance: 0.0,
615            ci_lower: theta_hat,
616            ci_upper: theta_hat,
617            block_length: n,
618            b: b_count,
619        };
620    }
621
622    let l = 2.max((n as f64).cbrt().round() as usize);
623    let num_blocks = n.div_ceil(l);
624    let mut theta_star = vec![0.0_f64; b_count];
625    let mut resample = vec![0.0_f64; n];
626
627    for slot in theta_star.iter_mut() {
628        let mut filled = 0usize;
629        let mut blk = 0usize;
630        while blk < num_blocks && filled < n {
631            let start = rng.bounded_index(n);
632            let mut t = 0usize;
633            while t < l && filled < n {
634                resample[filled] = cleaned[(start + t) % n];
635                filled += 1;
636                t += 1;
637            }
638            blk += 1;
639        }
640        *slot = modified_trimean(&resample);
641    }
642
643    let theta_star_mean = sample_mean(&theta_star);
644    let boot_var = sample_variance(&theta_star);
645    let mut sorted_ts = theta_star.clone();
646    sorted_ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
647
648    if boot_var == 0.0 {
649        return BlockBootstrapResult {
650            theta_hat,
651            theta_star_mean,
652            variance: 0.0,
653            ci_lower: theta_hat,
654            ci_upper: theta_hat,
655            block_length: l,
656            b: b_count,
657        };
658    }
659
660    // Bias-correction z0.
661    let mut count_less = 0usize;
662    for &ts in &theta_star {
663        if ts < theta_hat {
664            count_less += 1;
665        }
666    }
667    const EPS: f64 = 1e-12;
668    // The ratio is finite in [0, 1], so clamp matches the TS `min(max(p,EPS),1-EPS)`.
669    let prop = (count_less as f64 / b_count as f64).clamp(EPS, 1.0 - EPS);
670    let z0 = inv_normal(prop);
671
672    // Acceleration a via jackknife of the trimean.
673    let mut jack = vec![0.0_f64; n];
674    let mut loo = vec![0.0_f64; n - 1];
675    for (i, jack_i) in jack.iter_mut().enumerate() {
676        let mut idx = 0usize;
677        for (j, &c) in cleaned.iter().enumerate() {
678            if j != i {
679                loo[idx] = c;
680                idx += 1;
681            }
682        }
683        *jack_i = modified_trimean(&loo);
684    }
685    let jack_mean = sample_mean(&jack);
686    let mut s2 = 0.0_f64;
687    let mut s3 = 0.0_f64;
688    for &jk in &jack {
689        let d = jack_mean - jk;
690        s2 += d * d;
691        s3 += d * d * d;
692    }
693    let a_den = 6.0 * s2.powf(1.5);
694    let a = if a_den != 0.0 { s3 / a_den } else { 0.0 };
695
696    let ci_lower = bca_bound(&sorted_ts, z0, a, 0.025);
697    let ci_upper = bca_bound(&sorted_ts, z0, a, 0.975);
698    BlockBootstrapResult {
699        theta_hat,
700        theta_star_mean,
701        variance: boot_var,
702        ci_lower,
703        ci_upper,
704        block_length: l,
705        b: b_count,
706    }
707}
708
709// ── Cross-provider hybrid merge (DL τ²/I² + HKSJ + capacity/consensus) ────
710
711/// Provider agreement band derived from `I²` (METHODOLOGY.md §6).
712#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
713#[serde(rename_all = "kebab-case")]
714pub enum AgreementBand {
715    High,
716    Moderate,
717    Low,
718    VeryLow,
719    Insufficient,
720}
721
722impl AgreementBand {
723    /// Canonical wire string (`high`/`moderate`/`low`/`very-low`/`insufficient`).
724    pub fn as_str(self) -> &'static str {
725        match self {
726            AgreementBand::High => "high",
727            AgreementBand::Moderate => "moderate",
728            AgreementBand::Low => "low",
729            AgreementBand::VeryLow => "very-low",
730            AgreementBand::Insufficient => "insufficient",
731        }
732    }
733}
734
735/// A provider's own BCa interval (used verbatim when `k == 1`).
736#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize)]
737pub struct BcaInterval {
738    pub lower: f64,
739    pub upper: f64,
740}
741
742/// Confidence-interval bounds for a merged estimate.
743#[derive(Debug, Clone, Copy, Serialize)]
744pub struct CiBounds {
745    pub lower: f64,
746    pub upper: f64,
747}
748
749/// One provider-direction input to [`merge_providers`].
750#[derive(Debug, Clone, serde::Deserialize)]
751pub struct MergeProviderInput {
752    /// Lowercase registry name (looks up [`capability_prior`]).
753    pub name: String,
754    /// Point estimate (modified trimean) for this direction.
755    pub y: f64,
756    /// Bootstrap variance `v_j`; `None`/non-finite/≤0 is treated as "unknown".
757    #[serde(default)]
758    pub v: Option<f64>,
759    /// Cleaned sample count in this direction (qualification gate).
760    pub samples: usize,
761    /// Optional capability override; else `capability_prior(name)` ?? 1.0.
762    #[serde(default)]
763    pub capability: Option<f64>,
764    /// Provider's own BCa interval, used verbatim when `k == 1`.
765    #[serde(default)]
766    pub bca: Option<BcaInterval>,
767}
768
769/// A provider direction excluded from the merge for insufficient samples.
770#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
771pub struct MergeExclusion {
772    pub name: String,
773    pub samples: usize,
774}
775
776/// Per-provider weight diagnostics from the merge.
777#[derive(Debug, Clone, Serialize)]
778pub struct MergeWeight {
779    pub name: String,
780    pub y: f64,
781    /// Effective variance (unknown → max known).
782    pub v: f64,
783    pub w_star: f64,
784    pub w_star_capped: f64,
785    pub w_cap: f64,
786}
787
788/// Full result of the SpeedQX hybrid cross-provider merge for one direction.
789#[derive(Debug, Clone, Serialize)]
790pub struct MergeResult {
791    /// Qualifying provider count (`samples ≥ MIN_MERGE_SAMPLES`).
792    pub k: usize,
793    /// Headline: capability-weighted top-tier robust mean.
794    pub capacity: f64,
795    /// Secondary: DL random-effects mean over all qualifying providers.
796    pub consensus: f64,
797    pub capacity_ci: CiBounds,
798    pub consensus_ci: CiBounds,
799    /// DerSimonian–Laird between-provider variance τ².
800    pub tau2: f64,
801    /// I² heterogeneity (`None` when `k < 2`).
802    pub i2: Option<f64>,
803    /// Cochran's Q (diagnostic).
804    pub q: f64,
805    pub band: AgreementBand,
806    /// Provider names in the capacity tier.
807    pub tier: Vec<String>,
808    pub weights: Vec<MergeWeight>,
809    pub exclusions: Vec<MergeExclusion>,
810}
811
812/// Whether a variance is a usable known value (finite and strictly positive).
813fn known_variance(v: Option<f64>) -> Option<f64> {
814    match v {
815        Some(x) if x.is_finite() && x > 0.0 => Some(x),
816        _ => None,
817    }
818}
819
820fn empty_merge(exclusions: Vec<MergeExclusion>) -> MergeResult {
821    MergeResult {
822        k: 0,
823        capacity: 0.0,
824        consensus: 0.0,
825        capacity_ci: CiBounds {
826            lower: 0.0,
827            upper: 0.0,
828        },
829        consensus_ci: CiBounds {
830            lower: 0.0,
831            upper: 0.0,
832        },
833        tau2: 0.0,
834        i2: None,
835        q: 0.0,
836        band: AgreementBand::Insufficient,
837        tier: Vec::new(),
838        weights: Vec::new(),
839        exclusions,
840    }
841}
842
843/// SpeedQX hybrid cross-provider merge for one direction (METHODOLOGY.md §6).
844///
845/// Qualification: `samples ≥ MIN_MERGE_SAMPLES`; the rest are recorded in
846/// `exclusions`. Unknown-variance qualifiers adopt the maximum known variance
847/// (least trusted); if no variance is known, all weights are equal.
848///
849/// * `k = 0` → empty result. `k = 1` → passthrough with the provider's own BCa CI.
850/// * `k = 2` → capacity/consensus points computed, but CI is the honest union
851///   band `[min(y − 1.96·se), max(y + 1.96·se)]` and agreement = "insufficient".
852/// * `k ≥ 3` → DL τ²/I², capped (0.70) random-effects consensus with HKSJ CI,
853///   and the capability-weighted capacity tier (`y ≥ 0.85·max`, ≥ 2 members)
854///   with its own HKSJ CI over the tier.
855pub fn merge_providers(inputs: &[MergeProviderInput]) -> MergeResult {
856    let mut exclusions: Vec<MergeExclusion> = Vec::new();
857    let mut qualifying: Vec<&MergeProviderInput> = Vec::new();
858    for p in inputs {
859        if p.samples >= MIN_MERGE_SAMPLES {
860            qualifying.push(p);
861        } else {
862            exclusions.push(MergeExclusion {
863                name: p.name.clone(),
864                samples: p.samples,
865            });
866        }
867    }
868    let k = qualifying.len();
869    if k == 0 {
870        return empty_merge(exclusions);
871    }
872
873    // Effective variances: unknown → max known; none known → 1 (equal weights).
874    let known_vs: Vec<f64> = qualifying
875        .iter()
876        .filter_map(|p| known_variance(p.v))
877        .collect();
878    let max_known_v = known_vs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
879    let v_eff: Vec<f64> = qualifying
880        .iter()
881        .map(|p| {
882            known_variance(p.v).unwrap_or(if known_vs.is_empty() {
883                1.0
884            } else {
885                max_known_v
886            })
887        })
888        .collect();
889    let capability: Vec<f64> = qualifying
890        .iter()
891        .map(|p| {
892            p.capability
893                .unwrap_or_else(|| capability_prior(&p.name).unwrap_or(1.0))
894        })
895        .collect();
896
897    if k == 1 {
898        let p = qualifying[0];
899        let (lo, hi) = match p.bca {
900            Some(b) => (b.lower, b.upper),
901            None => (p.y, p.y),
902        };
903        let w_star = 1.0 / v_eff[0];
904        return MergeResult {
905            k: 1,
906            capacity: p.y,
907            consensus: p.y,
908            capacity_ci: CiBounds {
909                lower: lo,
910                upper: hi,
911            },
912            consensus_ci: CiBounds {
913                lower: lo,
914                upper: hi,
915            },
916            tau2: 0.0,
917            i2: None,
918            q: 0.0,
919            band: AgreementBand::Insufficient,
920            tier: vec![p.name.clone()],
921            weights: vec![MergeWeight {
922                name: p.name.clone(),
923                y: p.y,
924                v: v_eff[0],
925                w_star,
926                w_star_capped: w_star,
927                w_cap: capability[0] / v_eff[0],
928            }],
929            exclusions,
930        };
931    }
932
933    // DerSimonian–Laird heterogeneity (k ≥ 2).
934    let w: Vec<f64> = v_eff.iter().map(|v| 1.0 / v).collect();
935    let sum_w = sum(&w);
936    let mu_f = {
937        let terms: Vec<f64> = qualifying
938            .iter()
939            .enumerate()
940            .map(|(i, p)| w[i] * p.y)
941            .collect();
942        sum(&terms) / sum_w
943    };
944    let q = {
945        let terms: Vec<f64> = qualifying
946            .iter()
947            .enumerate()
948            .map(|(i, p)| {
949                let d = p.y - mu_f;
950                w[i] * (d * d)
951            })
952            .collect();
953        sum(&terms)
954    };
955    let sum_w2 = {
956        let terms: Vec<f64> = w.iter().map(|x| x * x).collect();
957        sum(&terms)
958    };
959    let c = sum_w - sum_w2 / sum_w;
960    let tau2 = if c > 0.0 {
961        (0.0_f64).max((q - (k - 1) as f64) / c)
962    } else {
963        0.0
964    };
965    let i2 = if q > 0.0 {
966        (0.0_f64).max((q - (k - 1) as f64) / q)
967    } else {
968        0.0
969    };
970
971    // Random-effects weights with a single 0.70·Σ cap (defense in depth).
972    let w_star: Vec<f64> = v_eff.iter().map(|v| 1.0 / (v + tau2)).collect();
973    let sum_w_star = sum(&w_star);
974    let cap = 0.70 * sum_w_star;
975    let w_star_capped: Vec<f64> = w_star.iter().map(|x| x.min(cap)).collect();
976    let sum_w_star_capped = sum(&w_star_capped);
977    let consensus = {
978        let terms: Vec<f64> = qualifying
979            .iter()
980            .enumerate()
981            .map(|(i, p)| w_star_capped[i] * p.y)
982            .collect();
983        sum(&terms) / sum_w_star_capped
984    };
985
986    // Capacity tier: y ≥ 0.85·max; if k ≥ 3 and fewer than 2, take the top-2 by y.
987    let ymax = qualifying
988        .iter()
989        .map(|p| p.y)
990        .fold(f64::NEG_INFINITY, f64::max);
991    let mut tier_idx: Vec<usize> = Vec::new();
992    for (i, p) in qualifying.iter().enumerate() {
993        if p.y >= 0.85 * ymax {
994            tier_idx.push(i);
995        }
996    }
997    if k >= 3 && tier_idx.len() < 2 {
998        let mut order: Vec<usize> = (0..k).collect();
999        order.sort_by(|&i, &j| {
1000            qualifying[j]
1001                .y
1002                .partial_cmp(&qualifying[i].y)
1003                .unwrap_or(std::cmp::Ordering::Equal)
1004                .then(i.cmp(&j))
1005        });
1006        let mut top2: Vec<usize> = order.into_iter().take(2).collect();
1007        top2.sort_unstable();
1008        tier_idx = top2;
1009    }
1010    let w_cap: Vec<f64> = v_eff
1011        .iter()
1012        .enumerate()
1013        .map(|(i, v)| capability[i] / (v + tau2))
1014        .collect();
1015    let cap_den = {
1016        let terms: Vec<f64> = tier_idx.iter().map(|&i| w_cap[i]).collect();
1017        sum(&terms)
1018    };
1019    let capacity = if cap_den > 0.0 {
1020        let terms: Vec<f64> = tier_idx
1021            .iter()
1022            .map(|&i| w_cap[i] * qualifying[i].y)
1023            .collect();
1024        sum(&terms) / cap_den
1025    } else {
1026        ymax
1027    };
1028
1029    let consensus_ci;
1030    let capacity_ci;
1031    let band;
1032
1033    if k == 2 {
1034        let lower = qualifying
1035            .iter()
1036            .enumerate()
1037            .map(|(i, p)| p.y - 1.96 * v_eff[i].sqrt())
1038            .fold(f64::INFINITY, f64::min);
1039        let upper = qualifying
1040            .iter()
1041            .enumerate()
1042            .map(|(i, p)| p.y + 1.96 * v_eff[i].sqrt())
1043            .fold(f64::NEG_INFINITY, f64::max);
1044        consensus_ci = CiBounds { lower, upper };
1045        capacity_ci = CiBounds { lower, upper };
1046        band = AgreementBand::Insufficient;
1047    } else {
1048        // HKSJ over all qualifying → consensus CI.
1049        let qc_num = {
1050            let terms: Vec<f64> = qualifying
1051                .iter()
1052                .enumerate()
1053                .map(|(i, p)| {
1054                    let d = p.y - consensus;
1055                    w_star_capped[i] * (d * d)
1056                })
1057                .collect();
1058            sum(&terms)
1059        };
1060        let se_c = ((1.0_f64).max(qc_num / (k - 1) as f64) / sum_w_star_capped).sqrt();
1061        consensus_ci = CiBounds {
1062            lower: consensus - t975(k - 1) * se_c,
1063            upper: consensus + t975(k - 1) * se_c,
1064        };
1065
1066        // HKSJ over the tier (same RE weights) → capacity CI.
1067        let tier_n = tier_idx.len();
1068        if tier_n >= 2 {
1069            let sum_w_star_tier = {
1070                let terms: Vec<f64> = tier_idx.iter().map(|&i| w_star_capped[i]).collect();
1071                sum(&terms)
1072            };
1073            let q_cap_num = {
1074                let terms: Vec<f64> = tier_idx
1075                    .iter()
1076                    .map(|&i| {
1077                        let d = qualifying[i].y - capacity;
1078                        w_star_capped[i] * (d * d)
1079                    })
1080                    .collect();
1081                sum(&terms)
1082            };
1083            let se_cap = ((1.0_f64).max(q_cap_num / (tier_n - 1) as f64) / sum_w_star_tier).sqrt();
1084            capacity_ci = CiBounds {
1085                lower: capacity - t975(tier_n - 1) * se_cap,
1086                upper: capacity + t975(tier_n - 1) * se_cap,
1087            };
1088        } else {
1089            let i = tier_idx[0];
1090            let se = v_eff[i].sqrt();
1091            capacity_ci = CiBounds {
1092                lower: qualifying[i].y - 1.96 * se,
1093                upper: qualifying[i].y + 1.96 * se,
1094            };
1095        }
1096
1097        band = if i2 < 0.25 {
1098            AgreementBand::High
1099        } else if i2 < 0.50 {
1100            AgreementBand::Moderate
1101        } else if i2 < 0.75 {
1102            AgreementBand::Low
1103        } else {
1104            AgreementBand::VeryLow
1105        };
1106    }
1107
1108    let weights: Vec<MergeWeight> = qualifying
1109        .iter()
1110        .enumerate()
1111        .map(|(i, p)| MergeWeight {
1112            name: p.name.clone(),
1113            y: p.y,
1114            v: v_eff[i],
1115            w_star: w_star[i],
1116            w_star_capped: w_star_capped[i],
1117            w_cap: w_cap[i],
1118        })
1119        .collect();
1120
1121    MergeResult {
1122        k,
1123        capacity,
1124        consensus,
1125        capacity_ci,
1126        consensus_ci,
1127        tau2,
1128        i2: Some(i2),
1129        q,
1130        band,
1131        tier: tier_idx
1132            .iter()
1133            .map(|&i| qualifying[i].name.clone())
1134            .collect(),
1135        weights,
1136        exclusions,
1137    }
1138}
1139
1140// ── Jitter (PDV / IPDV / MAD) ────────────────────────────────────────────
1141
1142/// Canonical jitter — packet delay variation: `P95(RTT) − P50(RTT)` (RFC 5481 flavor).
1143pub fn pdv(rtts: &[f64]) -> f64 {
1144    if rtts.is_empty() {
1145        return 0.0;
1146    }
1147    let mut s = rtts.to_vec();
1148    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1149    quantile(&s, 0.95) - quantile(&s, 0.5)
1150}
1151
1152/// IPDV mean: mean of `|consecutive ΔRTT|`.
1153pub fn ipdv_mean(rtts: &[f64]) -> f64 {
1154    if rtts.len() < 2 {
1155        return 0.0;
1156    }
1157    let total: f64 = rtts.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
1158    total / (rtts.len() - 1) as f64
1159}
1160
1161/// Robust scale: `1.4826 · median(|x − median(x)|)`.
1162pub fn median_absolute_deviation(values: &[f64]) -> f64 {
1163    if values.is_empty() {
1164        return 0.0;
1165    }
1166    let med = median_of(values);
1167    let mut dev: Vec<f64> = values.iter().map(|v| (v - med).abs()).collect();
1168    dev.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1169    1.4826 * quantile(&dev, 0.5)
1170}
1171
1172/// The v4 jitter bundle (PDV canonical, IPDV/MAD secondary, RFC 3550 compat).
1173#[derive(Debug, Clone, Serialize)]
1174pub struct JitterMetrics {
1175    /// Canonical: `P95 − P50`.
1176    pub pdv: f64,
1177    /// Secondary: mean `|ΔRTT|`.
1178    pub ipdv_mean: f64,
1179    /// Secondary: `1.4826 · MAD`.
1180    pub mad: f64,
1181    /// Compatibility field only.
1182    pub jitter_rfc3550: f64,
1183}
1184
1185/// Compute all v4 jitter metrics from an RTT series.
1186pub fn jitter_metrics(rtts: &[f64]) -> JitterMetrics {
1187    JitterMetrics {
1188        pdv: pdv(rtts),
1189        ipdv_mean: ipdv_mean(rtts),
1190        mad: median_absolute_deviation(rtts),
1191        jitter_rfc3550: jitter_rfc3550(rtts),
1192    }
1193}
1194
1195// ── Bufferbloat (delta-ms + grade) + RPM ─────────────────────────────────
1196
1197/// Bufferbloat letter grade.
1198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1199pub enum BufferbloatGrade {
1200    #[serde(rename = "A+")]
1201    APlus,
1202    A,
1203    B,
1204    C,
1205    D,
1206    F,
1207}
1208
1209impl BufferbloatGrade {
1210    /// Canonical wire string (`A+`/`A`/`B`/`C`/`D`/`F`).
1211    pub fn as_str(self) -> &'static str {
1212        match self {
1213            BufferbloatGrade::APlus => "A+",
1214            BufferbloatGrade::A => "A",
1215            BufferbloatGrade::B => "B",
1216            BufferbloatGrade::C => "C",
1217            BufferbloatGrade::D => "D",
1218            BufferbloatGrade::F => "F",
1219        }
1220    }
1221}
1222
1223/// Grade the bufferbloat delta (ms): A+ <5 · A <30 · B <60 · C <200 · D <400 · F ≥400.
1224pub fn bufferbloat_grade(delta_ms: f64) -> BufferbloatGrade {
1225    if delta_ms < 5.0 {
1226        BufferbloatGrade::APlus
1227    } else if delta_ms < 30.0 {
1228        BufferbloatGrade::A
1229    } else if delta_ms < 60.0 {
1230        BufferbloatGrade::B
1231    } else if delta_ms < 200.0 {
1232        BufferbloatGrade::C
1233    } else if delta_ms < 400.0 {
1234        BufferbloatGrade::D
1235    } else {
1236        BufferbloatGrade::F
1237    }
1238}
1239
1240/// Delta-ms bufferbloat result (canonical delta, secondary ratio, grade).
1241#[derive(Debug, Clone, Serialize)]
1242pub struct BufferbloatDeltaResult {
1243    /// Canonical: `P95(loaded RTT) − P50(idle RTT)`.
1244    pub delta_ms: f64,
1245    /// Secondary: `P95(loaded) / P50(idle)`.
1246    pub ratio: f64,
1247    pub grade: BufferbloatGrade,
1248}
1249
1250/// Delta-ms bufferbloat: `P95(loaded) − P50(idle)`, graded; ratio disclosed as secondary.
1251pub fn bufferbloat_delta(idle_rtts: &[f64], loaded_rtts: &[f64]) -> BufferbloatDeltaResult {
1252    let p50_idle = if idle_rtts.is_empty() {
1253        0.0
1254    } else {
1255        let mut s = idle_rtts.to_vec();
1256        s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1257        quantile(&s, 0.5)
1258    };
1259    let p95_loaded = if loaded_rtts.is_empty() {
1260        0.0
1261    } else {
1262        let mut s = loaded_rtts.to_vec();
1263        s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1264        quantile(&s, 0.95)
1265    };
1266    let delta_ms = p95_loaded - p50_idle;
1267    let ratio = if p50_idle > 0.0 {
1268        p95_loaded / p50_idle
1269    } else {
1270        0.0
1271    };
1272    BufferbloatDeltaResult {
1273        delta_ms,
1274        ratio,
1275        grade: bufferbloat_grade(delta_ms),
1276    }
1277}
1278
1279/// Responsiveness (approx): `60000 / P50(loaded RTT ms)` round-trips per minute.
1280pub fn rpm(loaded_rtts: &[f64]) -> f64 {
1281    if loaded_rtts.is_empty() {
1282        return 0.0;
1283    }
1284    let mut s = loaded_rtts.to_vec();
1285    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1286    let p50 = quantile(&s, 0.5);
1287    if p50 > 0.0 {
1288        60000.0 / p50
1289    } else {
1290        0.0
1291    }
1292}
1293
1294// ── FAST-mode empirical-Bernstein confidence sequence ────────────────────
1295
1296/// Anytime-valid empirical-Bernstein confidence sequence state at one sample count.
1297#[derive(Debug, Clone, Serialize)]
1298pub struct ConfidenceSequence {
1299    /// Samples consumed.
1300    pub t: usize,
1301    /// Rescaling cap `U = 2·max(samples)`.
1302    pub u: f64,
1303    /// CS midpoint in Mbps.
1304    pub mu_hat_mbps: f64,
1305    /// CS half-width in Mbps (the quantity the stop rule tests).
1306    pub half_width_mbps: f64,
1307    /// Rescaled `[0, 1]` half-width.
1308    pub width: f64,
1309    /// Stop early: `t ≥ 12`, RTT gate open, and `halfWidth ≤ max(5%·est, 2 Mbps)`.
1310    pub stop: bool,
1311}
1312
1313/// Empirical-Bernstein confidence sequence for FAST-mode early termination
1314/// (METHODOLOGY.md §8).
1315///
1316/// Samples (raw Mbps, time order) are rescaled by `U = 2·max` into `[0,1]`; the
1317/// running mean inside the variance accumulator is **strictly predictable** —
1318/// `muHat_i = (0.5 + Σ_{j<i} X_j)/i` uses prior samples plus the 0.5 prior,
1319/// NEVER the current sample (the anytime-valid guarantee requires a predictable
1320/// comparator; PINNED 2026-07-06). Stop when
1321/// `half_width_mbps ≤ max(0.05·estimate, 2 Mbps)` AND `t ≥ 12`, unless the
1322/// measured min-RTT gate (`> 50 ms`) forbids early stop. Caller applies the 25 s
1323/// hard cap.
1324pub fn empirical_bernstein_cs(
1325    samples_so_far: &[f64],
1326    alpha: f64,
1327    min_rtt_ms: f64,
1328) -> ConfidenceSequence {
1329    let t = samples_so_far.len();
1330    if t == 0 {
1331        return ConfidenceSequence {
1332            t: 0,
1333            u: 0.0,
1334            mu_hat_mbps: 0.0,
1335            half_width_mbps: f64::INFINITY,
1336            width: f64::INFINITY,
1337            stop: false,
1338        };
1339    }
1340
1341    let mut max_v = 0.0_f64;
1342    for &s in samples_so_far {
1343        if s > max_v {
1344            max_v = s;
1345        }
1346    }
1347    let u = 2.0 * max_v;
1348    if u <= 0.0 {
1349        return ConfidenceSequence {
1350            t,
1351            u: 0.0,
1352            mu_hat_mbps: 0.0,
1353            half_width_mbps: f64::INFINITY,
1354            width: f64::INFINITY,
1355            stop: false,
1356        };
1357    }
1358
1359    let mut x_sum = 0.0_f64;
1360    let mut sig2_sum = 0.0_f64;
1361    for (i, &s) in samples_so_far.iter().enumerate() {
1362        let x = s / u;
1363        // Predictable (prior-only) running mean: muHat_i uses X_1..X_{i-1} plus
1364        // the 0.5 prior, NEVER the current sample. Compute from x_sum BEFORE
1365        // adding x. Spec decision 2026-07-06 (METHODOLOGY.md §8).
1366        let mu_hat_prior = (0.5 + x_sum) / (i as f64 + 1.0);
1367        let d = x - mu_hat_prior;
1368        sig2_sum += d * d;
1369        x_sum += x;
1370    }
1371    let mu_hat_t = (0.5 + x_sum) / (t as f64 + 1.0);
1372    let sig2_t = (0.25 + sig2_sum) / (t as f64 + 1.0);
1373    let ln_term = (2.0 / alpha).ln();
1374    let width = (2.0 * sig2_t * ln_term / t as f64).sqrt() + 3.0 * ln_term / t as f64;
1375    let half_width_mbps = width * u;
1376    let mu_hat_mbps = mu_hat_t * u;
1377    let gated = min_rtt_ms > 50.0;
1378    let stop = !gated && t >= 12 && half_width_mbps <= (0.05 * mu_hat_mbps).max(2.0);
1379    ConfidenceSequence {
1380        t,
1381        u,
1382        mu_hat_mbps,
1383        half_width_mbps,
1384        width,
1385        stop,
1386    }
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391    use super::*;
1392
1393    #[test]
1394    fn sanitize_drops_nan_and_infinite() {
1395        let cleaned = sanitize(&[f64::NAN, 1.0, f64::INFINITY, 2.0, f64::NEG_INFINITY]);
1396        assert_eq!(cleaned, vec![1.0, 2.0]);
1397    }
1398
1399    #[test]
1400    fn sanitize_keeps_clean_input_intact() {
1401        let cleaned = sanitize(&[3.0, 1.0, 2.0]);
1402        assert_eq!(cleaned, vec![3.0, 1.0, 2.0]);
1403    }
1404
1405    fn well_behaved_samples() -> Vec<f64> {
1406        (0..20).map(|i| 95.0 + (i % 5) as f64 * 2.5).collect()
1407    }
1408
1409    /// The PRNG is seeded from the data, so identical input must produce
1410    /// identical bounds — JSON output stays reproducible.
1411    #[test]
1412    fn bootstrap_ci_is_deterministic() {
1413        let samples = well_behaved_samples();
1414        let a = bootstrap_ci(&samples, accurate_bandwidth, 1000, 0.05);
1415        let b = bootstrap_ci(&samples, accurate_bandwidth, 1000, 0.05);
1416        assert_eq!(a.lower, b.lower);
1417        assert_eq!(a.upper, b.upper);
1418        assert_eq!(a.estimate, b.estimate);
1419    }
1420
1421    #[test]
1422    fn bootstrap_ci_brackets_estimate() {
1423        let samples = well_behaved_samples();
1424        let ci = bootstrap_ci(&samples, accurate_bandwidth, 1000, 0.05);
1425        assert!(
1426            ci.lower <= ci.estimate && ci.estimate <= ci.upper,
1427            "lower {} <= estimate {} <= upper {}",
1428            ci.lower,
1429            ci.estimate,
1430            ci.upper
1431        );
1432        assert!(ci.margin >= 0.0);
1433    }
1434
1435    /// Below 4 samples the percentile method is degenerate; the function
1436    /// returns a zero-margin CI — callers must suppress display in that case.
1437    #[test]
1438    fn bootstrap_ci_degenerate_below_four() {
1439        let ci = bootstrap_ci(&[100.0, 110.0, 105.0], accurate_bandwidth, 1000, 0.05);
1440        assert_eq!(ci.margin, 0.0);
1441        assert_eq!(ci.lower, ci.upper);
1442    }
1443}