1use super::stat_primitives::{
20 inv_normal, phi, quantile, sample_mean, sample_variance, sum, t975, Pcg32,
21};
22use serde::Serialize;
23
24pub 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
43pub fn sanitize(values: &[f64]) -> Vec<f64> {
50 values.iter().copied().filter(|v| v.is_finite()).collect()
51}
52
53pub 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
71pub fn stddev(values: &[f64]) -> f64 {
73 if values.len() < 2 {
74 return 0.0;
75 }
76 variance(values).sqrt()
77}
78
79pub 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
88pub 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
103pub 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
116pub 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
137pub 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
149pub 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
163pub 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 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 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
200pub 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 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 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 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
249pub 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
265pub 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
274pub 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
285pub 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#[derive(Debug, Clone, Serialize)]
304pub struct InverseVarianceResult {
305 pub value: f64,
306 pub weight_a: f64,
307 pub weight_b: f64,
308}
309
310pub 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 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#[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
389struct Xorshift64(u64);
391
392impl Xorshift64 {
393 fn new(seed: u64) -> Self {
394 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
412pub 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 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
464pub const MIN_MERGE_SAMPLES: usize = 4;
470
471pub 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
483fn 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
492pub 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
536pub 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#[derive(Debug, Clone, Serialize)]
562pub struct BlockBootstrapResult {
563 pub theta_hat: f64,
565 pub theta_star_mean: f64,
567 pub variance: f64,
569 pub ci_lower: f64,
571 pub ci_upper: f64,
573 pub block_length: usize,
575 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 = aa.clamp(0.0, 1.0);
593 quantile(sorted_theta_star, aa)
594}
595
596pub 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 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 let prop = (count_less as f64 / b_count as f64).clamp(EPS, 1.0 - EPS);
670 let z0 = inv_normal(prop);
671
672 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#[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 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#[derive(Debug, Clone, Copy, Serialize, serde::Deserialize)]
737pub struct BcaInterval {
738 pub lower: f64,
739 pub upper: f64,
740}
741
742#[derive(Debug, Clone, Copy, Serialize)]
744pub struct CiBounds {
745 pub lower: f64,
746 pub upper: f64,
747}
748
749#[derive(Debug, Clone, serde::Deserialize)]
751pub struct MergeProviderInput {
752 pub name: String,
754 pub y: f64,
756 #[serde(default)]
758 pub v: Option<f64>,
759 pub samples: usize,
761 #[serde(default)]
763 pub capability: Option<f64>,
764 #[serde(default)]
766 pub bca: Option<BcaInterval>,
767}
768
769#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
771pub struct MergeExclusion {
772 pub name: String,
773 pub samples: usize,
774}
775
776#[derive(Debug, Clone, Serialize)]
778pub struct MergeWeight {
779 pub name: String,
780 pub y: f64,
781 pub v: f64,
783 pub w_star: f64,
784 pub w_star_capped: f64,
785 pub w_cap: f64,
786}
787
788#[derive(Debug, Clone, Serialize)]
790pub struct MergeResult {
791 pub k: usize,
793 pub capacity: f64,
795 pub consensus: f64,
797 pub capacity_ci: CiBounds,
798 pub consensus_ci: CiBounds,
799 pub tau2: f64,
801 pub i2: Option<f64>,
803 pub q: f64,
805 pub band: AgreementBand,
806 pub tier: Vec<String>,
808 pub weights: Vec<MergeWeight>,
809 pub exclusions: Vec<MergeExclusion>,
810}
811
812fn 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
843pub 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 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 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 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 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 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 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
1140pub 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
1152pub 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
1161pub 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#[derive(Debug, Clone, Serialize)]
1174pub struct JitterMetrics {
1175 pub pdv: f64,
1177 pub ipdv_mean: f64,
1179 pub mad: f64,
1181 pub jitter_rfc3550: f64,
1183}
1184
1185pub 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#[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 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
1223pub 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#[derive(Debug, Clone, Serialize)]
1242pub struct BufferbloatDeltaResult {
1243 pub delta_ms: f64,
1245 pub ratio: f64,
1247 pub grade: BufferbloatGrade,
1248}
1249
1250pub 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
1279pub 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#[derive(Debug, Clone, Serialize)]
1298pub struct ConfidenceSequence {
1299 pub t: usize,
1301 pub u: f64,
1303 pub mu_hat_mbps: f64,
1305 pub half_width_mbps: f64,
1307 pub width: f64,
1309 pub stop: bool,
1311}
1312
1313pub 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 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 #[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 #[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}