1pub(crate) mod adaptive;
2pub mod applenq;
3pub mod cachefly;
4pub mod cloudflare;
5pub mod display;
6pub mod fastcom;
7pub mod librespeed;
8pub mod msak;
9pub mod ndt7;
10pub mod stat_primitives;
11pub mod statistics;
12pub mod vultr;
13
14#[cfg(test)]
15mod golden_tests;
16
17use serde::Serialize;
18use stat_primitives::Pcg32;
19use statistics::{merge_providers, BcaInterval, MergeProviderInput};
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex as StdMutex};
22use std::time::{Duration, Instant};
23
24pub const METHODOLOGY_VERSION: &str = "4.0";
27
28pub(crate) const FAST_HARD_CAP: Duration = Duration::from_secs(25);
35
36const FAST_PER_DIR_SECS: u64 = 8;
44
45pub fn dense_probe_count(duration_secs: f64) -> u32 {
48 (duration_secs * 3.3).round().clamp(50.0, 200.0) as u32
49}
50
51pub const DENSE_PROBE_INTERVAL: Duration = Duration::from_millis(50);
53
54pub const DENSE_PROBE_WARMUP: usize = 3;
57
58#[derive(Debug, Clone)]
60pub enum TestDuration {
61 Seconds(u64),
63 Auto,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
69pub enum ProviderSet {
70 All,
73 Fast,
76 Diagnostic,
78}
79
80#[derive(Debug, Clone)]
82pub struct SpeedTestConfig {
83 pub duration: TestDuration,
85 pub fastcom_duration: TestDuration,
87 pub latency_probes: u32,
89 pub provider_set: ProviderSet,
91 pub msak_enabled: bool,
93 pub apple_enabled: bool,
95 pub use_colors: bool,
97}
98
99impl Default for SpeedTestConfig {
100 fn default() -> Self {
101 Self {
102 duration: TestDuration::Seconds(30),
103 fastcom_duration: TestDuration::Auto,
104 latency_probes: 20,
105 provider_set: ProviderSet::All,
106 msak_enabled: true,
107 apple_enabled: true,
108 use_colors: true,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
115pub enum Phase {
116 CfLatency,
117 CfDownload,
118 CfUpload,
119 Ndt7Discovery,
120 Ndt7Download,
121 Ndt7Upload,
122 LsDiscovery,
123 LsDownload,
124 LsUpload,
125 FcDiscovery,
126 FcDownload,
127 FcUpload,
128 MsakDiscovery,
129 MsakDownload,
130 MsakUpload,
131 CfyLatency,
132 CfyDownload,
133 VultrDiscovery,
134 VultrLatency,
135 VultrDownload,
136 AnqDiscovery,
137 AnqDownload,
138 AnqUpload,
139 Computing,
140}
141
142#[derive(Debug, Clone, Default, Serialize)]
144pub struct BandwidthSamples {
145 pub download: Vec<f64>,
146 pub upload: Vec<f64>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "kebab-case")]
154pub enum ProviderAvailability {
155 Ran,
156 Failed,
157 UnavailablePlatform,
158}
159
160#[derive(Debug, Clone, Default, Serialize)]
164pub struct LatencyStats {
165 #[serde(skip)]
166 pub samples: Vec<f64>,
167 pub p50: f64,
168 pub p75: f64,
169 pub p95: f64,
170 pub p99: f64,
171 pub min: f64,
172 pub max: f64,
173 pub mean: f64,
174 pub stddev: f64,
175 pub pdv: f64,
177 pub jitter_mad: f64,
178 pub jitter_rfc3550: f64,
180}
181
182impl LatencyStats {
183 pub fn from_rtts(rtts: &[f64]) -> Option<Self> {
185 if rtts.is_empty() {
186 return None;
187 }
188 let mut sorted = rtts.to_vec();
189 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
190 Some(Self {
191 samples: rtts.to_vec(),
192 p50: statistics::percentile(&sorted, 0.50),
193 p75: statistics::percentile(&sorted, 0.75),
194 p95: statistics::percentile(&sorted, 0.95),
195 p99: statistics::percentile(&sorted, 0.99),
196 min: sorted[0],
197 max: sorted[sorted.len() - 1],
198 mean: statistics::mean(rtts),
199 stddev: statistics::stddev(rtts),
200 pdv: statistics::pdv(rtts),
201 jitter_mad: statistics::jitter_mad(rtts),
202 jitter_rfc3550: statistics::jitter_rfc3550(rtts),
203 })
204 }
205}
206
207#[derive(Debug, Clone, Default, Serialize)]
210pub struct LoadedLatency {
211 pub download: Vec<f64>,
212 pub upload: Vec<f64>,
213}
214
215#[derive(Debug, Clone, Serialize)]
217pub struct MergedDirection {
218 pub download: f64,
219 pub upload: f64,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub download_ci: Option<statistics::CiBounds>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub upload_ci: Option<statistics::CiBounds>,
224}
225
226#[derive(Debug, Clone, Serialize)]
228pub struct AgreementInfo {
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub i2: Option<f64>,
231 pub band: statistics::AgreementBand,
232}
233
234#[derive(Debug, Clone, Serialize)]
236pub struct BufferbloatSummary {
237 pub grade: statistics::BufferbloatGrade,
238 pub delta_ms: f64,
239 pub ratio: f64,
240}
241
242#[derive(Debug, Clone, Serialize)]
244pub struct StabilityMetrics {
245 pub download_cv: f64,
246 pub upload_cv: f64,
247 pub download_stable: bool,
248 pub upload_stable: bool,
249}
250
251#[derive(Debug, Clone, Serialize)]
253pub struct ProviderDivergence {
254 pub download: f64,
255 pub upload: f64,
256 pub significant: bool,
257}
258
259#[derive(Debug, Clone, Serialize)]
262pub struct MergeExclusion {
263 pub provider: String,
264 pub direction: &'static str,
266 pub samples: usize,
267}
268
269#[derive(Debug, Clone, Serialize)]
274pub struct ConfidenceIntervals {
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub download: Option<statistics::BootstrapCI>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub upload: Option<statistics::BootstrapCI>,
279 pub confidence_level: f64,
280}
281
282#[derive(Debug, Clone, Serialize)]
284pub struct ProviderResult {
285 pub provider: String,
286 pub server: String,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub location: Option<String>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub ping_ms: Option<f64>,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub jitter_ms: Option<f64>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub download_mbps: Option<f64>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub upload_mbps: Option<f64>,
297 pub download_bytes: u64,
298 pub upload_bytes: u64,
299 pub download_duration_s: f64,
300 pub upload_duration_s: f64,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub packet_loss_pct: Option<f64>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub error: Option<String>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub bandwidth_samples: Option<BandwidthSamples>,
307 pub availability: ProviderAvailability,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub latency_stats: Option<LatencyStats>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub loaded_latency: Option<LoadedLatency>,
315}
316
317#[derive(Debug, Clone, Serialize)]
323pub struct SpeedTestResult {
324 pub methodology_version: &'static str,
326 pub platform: &'static str,
328 pub provider_set: &'static str,
330
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub ping_ms: Option<f64>,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub jitter_ms: Option<f64>,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub jitter_rfc3550: Option<f64>,
340 pub download_mbps: f64,
342 pub upload_mbps: f64,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub packet_loss_pct: Option<f64>,
346
347 pub capacity: MergedDirection,
349 pub consensus: MergedDirection,
351 pub agreement: AgreementInfo,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub upload_agreement: Option<AgreementInfo>,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub rpm: Option<f64>,
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub bufferbloat: Option<BufferbloatSummary>,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub latency_stats: Option<LatencyStats>,
365
366 pub providers: Vec<ProviderResult>,
367 pub duration_s: f64,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub stability: Option<StabilityMetrics>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 pub provider_divergence: Option<ProviderDivergence>,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub confidence_intervals: Option<ConfidenceIntervals>,
374 #[serde(skip_serializing_if = "Vec::is_empty")]
375 pub merge_exclusions: Vec<MergeExclusion>,
376}
377
378fn registry_rank(display_name: &str) -> usize {
382 match display_name {
383 "Cloudflare" => 0,
384 "M-Lab NDT7" => 1,
385 "M-Lab MSAK" => 2,
386 "LibreSpeed" => 3,
387 "fast.com" => 4,
388 "CacheFly" => 5,
389 "Vultr" => 6,
390 "Apple networkQuality" => 7,
391 _ => usize::MAX,
392 }
393}
394
395fn registry_key(display_name: &str) -> &'static str {
398 match display_name {
399 "Cloudflare" => "cloudflare",
400 "M-Lab NDT7" => "ndt7",
401 "M-Lab MSAK" => "msak",
402 "LibreSpeed" => "librespeed",
403 "fast.com" => "fastcom",
404 "CacheFly" => "cachefly",
405 "Vultr" => "vultr",
406 "Apple networkQuality" => "applenq",
407 _ => "unknown",
408 }
409}
410
411fn display_name_for_key(key: &str) -> String {
414 match key {
415 "cloudflare" => "Cloudflare",
416 "ndt7" => "M-Lab NDT7",
417 "msak" => "M-Lab MSAK",
418 "librespeed" => "LibreSpeed",
419 "fastcom" => "fast.com",
420 "cachefly" => "CacheFly",
421 "vultr" => "Vultr",
422 "applenq" => "Apple networkQuality",
423 other => other,
424 }
425 .to_string()
426}
427
428fn clean_direction(raw: &[f64], is_upload: bool) -> Vec<f64> {
432 let sane = statistics::sanitize(raw);
433 if sane.is_empty() {
434 return Vec::new();
435 }
436 let cut = statistics::plateau_start(&sane).min(sane.len());
437 let after_plateau = &sane[cut..];
438 if is_upload {
439 let mut desc = after_plateau.to_vec();
441 desc.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
442 let keep = ((desc.len() as f64) / 2.0).ceil() as usize;
443 let top = &desc[..keep.min(desc.len())];
444 statistics::filter_outliers_iqr(top, 1.5)
445 } else {
446 statistics::filter_outliers_iqr(after_plateau, 1.5)
447 }
448}
449
450struct AggregateResult {
452 ping: Option<f64>,
453 jitter: Option<f64>,
454 jitter_rfc3550: Option<f64>,
455 download: f64,
456 upload: f64,
457 packet_loss: Option<f64>,
458 capacity: MergedDirection,
459 consensus: MergedDirection,
460 agreement: AgreementInfo,
461 upload_agreement: AgreementInfo,
462 rpm: Option<f64>,
463 bufferbloat: Option<BufferbloatSummary>,
464 latency_stats: Option<LatencyStats>,
465 stability: Option<StabilityMetrics>,
466 divergence: Option<ProviderDivergence>,
467 confidence: Option<ConfidenceIntervals>,
468 exclusions: Vec<MergeExclusion>,
469}
470
471fn empty_direction() -> MergedDirection {
472 MergedDirection {
473 download: 0.0,
474 upload: 0.0,
475 download_ci: None,
476 upload_ci: None,
477 }
478}
479
480fn aggregate(providers: &[ProviderResult]) -> AggregateResult {
492 let mut ordered: Vec<&ProviderResult> =
494 providers.iter().filter(|p| p.error.is_none()).collect();
495 ordered.sort_by_key(|p| registry_rank(&p.provider));
496
497 if ordered.is_empty() {
498 return AggregateResult {
499 ping: None,
500 jitter: None,
501 jitter_rfc3550: None,
502 download: 0.0,
503 upload: 0.0,
504 packet_loss: None,
505 capacity: empty_direction(),
506 consensus: empty_direction(),
507 agreement: AgreementInfo {
508 i2: None,
509 band: statistics::AgreementBand::Insufficient,
510 },
511 upload_agreement: AgreementInfo {
512 i2: None,
513 band: statistics::AgreementBand::Insufficient,
514 },
515 rpm: None,
516 bufferbloat: None,
517 latency_stats: None,
518 stability: None,
519 divergence: None,
520 confidence: None,
521 exclusions: Vec::new(),
522 };
523 }
524
525 let mut rng = Pcg32::new();
527 let mut dl_inputs: Vec<MergeProviderInput> = Vec::new();
528 let mut ul_inputs: Vec<MergeProviderInput> = Vec::new();
529 let mut pooled_dl: Vec<f64> = Vec::new();
530 let mut pooled_ul: Vec<f64> = Vec::new();
531 let mut points: HashMap<String, (Option<f64>, Option<f64>)> = HashMap::new();
533
534 for p in &ordered {
535 let (dl_raw, ul_raw): (&[f64], &[f64]) = match &p.bandwidth_samples {
536 Some(bs) => (&bs.download, &bs.upload),
537 None => (&[], &[]),
538 };
539 let dl_clean = clean_direction(dl_raw, false);
540 let ul_clean = clean_direction(ul_raw, true);
541 let dl_boot = statistics::circular_block_bootstrap(&dl_clean, &mut rng, 2000);
542 let ul_boot = statistics::circular_block_bootstrap(&ul_clean, &mut rng, 2000);
543 let key = registry_key(&p.provider);
544
545 dl_inputs.push(MergeProviderInput {
546 name: key.to_string(),
547 y: dl_boot.theta_hat,
548 v: Some(dl_boot.variance),
549 samples: dl_clean.len(),
550 capability: None,
551 bca: Some(BcaInterval {
552 lower: dl_boot.ci_lower,
553 upper: dl_boot.ci_upper,
554 }),
555 });
556 ul_inputs.push(MergeProviderInput {
557 name: key.to_string(),
558 y: ul_boot.theta_hat,
559 v: Some(ul_boot.variance),
560 samples: ul_clean.len(),
561 capability: None,
562 bca: Some(BcaInterval {
563 lower: ul_boot.ci_lower,
564 upper: ul_boot.ci_upper,
565 }),
566 });
567
568 points.insert(
569 p.provider.clone(),
570 (
571 (!dl_clean.is_empty()).then_some(dl_boot.theta_hat),
572 (!ul_clean.is_empty()).then_some(ul_boot.theta_hat),
573 ),
574 );
575 pooled_dl.extend_from_slice(&dl_clean);
576 pooled_ul.extend_from_slice(&ul_clean);
577 }
578
579 let dl_merge = merge_providers(&dl_inputs);
580 let ul_merge = merge_providers(&ul_inputs);
581
582 let fallback_max = |download: bool| -> f64 {
585 points
586 .values()
587 .filter_map(|(dl, ul)| if download { *dl } else { *ul })
588 .fold(0.0_f64, f64::max)
589 };
590 let download = if dl_merge.capacity > 0.0 {
591 dl_merge.capacity
592 } else {
593 fallback_max(true)
594 };
595 let upload = if ul_merge.capacity > 0.0 {
596 ul_merge.capacity
597 } else {
598 fallback_max(false)
599 };
600
601 let mut ping = f64::INFINITY;
603 for p in &ordered {
604 if let Some(pg) = p.ping_ms {
605 if pg > 0.0 && pg < ping {
606 ping = pg;
607 }
608 }
609 if let Some(ls) = &p.latency_stats {
610 if ls.min > 0.0 && ls.min < ping {
611 ping = ls.min;
612 }
613 }
614 }
615 let ping = ping.is_finite().then_some(ping);
616
617 let latency_stats = ordered.iter().find_map(|p| p.latency_stats.clone());
620
621 let jitter = latency_stats.as_ref().map(|ls| ls.pdv).or_else(|| {
623 let js: Vec<f64> = ordered
624 .iter()
625 .filter_map(|p| p.jitter_ms)
626 .filter(|j| *j > 0.0)
627 .collect();
628 (!js.is_empty()).then(|| statistics::mean(&js))
629 });
630 let jitter_rfc3550 = latency_stats
631 .as_ref()
632 .map(|ls| ls.jitter_rfc3550)
633 .or_else(|| ordered.iter().find_map(|p| p.jitter_ms));
634
635 let mut bufferbloat = None;
637 let mut rpm = None;
638 if let Some(cf) = ordered.iter().find(|p| p.provider == "Cloudflare") {
639 if let Some(loaded) = &cf.loaded_latency {
640 let mut loaded_pooled = loaded.download.clone();
641 loaded_pooled.extend_from_slice(&loaded.upload);
642 if !loaded_pooled.is_empty() {
643 let idle = cf
644 .latency_stats
645 .as_ref()
646 .map(|ls| ls.samples.clone())
647 .unwrap_or_default();
648 let bb = statistics::bufferbloat_delta(&idle, &loaded_pooled);
649 rpm = Some(statistics::rpm(&loaded_pooled));
650 bufferbloat = Some(BufferbloatSummary {
651 grade: bb.grade,
652 delta_ms: bb.delta_ms,
653 ratio: bb.ratio,
654 });
655 }
656 }
657 }
658
659 let packet_loss = ordered
661 .iter()
662 .find(|p| p.provider == "Cloudflare")
663 .and_then(|p| p.packet_loss_pct);
664
665 let stability = if pooled_dl.len() > 2 || pooled_ul.len() > 2 {
667 let dl_cv = statistics::coefficient_of_variation(&pooled_dl);
668 let ul_cv = statistics::coefficient_of_variation(&pooled_ul);
669 Some(StabilityMetrics {
670 download_cv: dl_cv,
671 upload_cv: ul_cv,
672 download_stable: dl_cv < 0.15,
673 upload_stable: ul_cv < 0.15,
674 })
675 } else {
676 None
677 };
678
679 let agreement = AgreementInfo {
681 i2: dl_merge.i2,
682 band: dl_merge.band,
683 };
684 let upload_agreement = AgreementInfo {
685 i2: ul_merge.i2,
686 band: ul_merge.band,
687 };
688 let band_low = |b: statistics::AgreementBand| {
689 matches!(
690 b,
691 statistics::AgreementBand::Low | statistics::AgreementBand::VeryLow
692 )
693 };
694 let divergence = Some(ProviderDivergence {
695 download: dl_merge.i2.unwrap_or(0.0),
696 upload: ul_merge.i2.unwrap_or(0.0),
697 significant: band_low(dl_merge.band) || band_low(ul_merge.band),
698 });
699
700 let capacity = MergedDirection {
702 download,
703 upload,
704 download_ci: (dl_merge.k > 0).then_some(dl_merge.capacity_ci),
705 upload_ci: (ul_merge.k > 0).then_some(ul_merge.capacity_ci),
706 };
707 let consensus = MergedDirection {
708 download: dl_merge.consensus,
709 upload: ul_merge.consensus,
710 download_ci: (dl_merge.k > 0).then_some(dl_merge.consensus_ci),
711 upload_ci: (ul_merge.k > 0).then_some(ul_merge.consensus_ci),
712 };
713
714 let ci_from = |value: f64, ci: statistics::CiBounds| statistics::BootstrapCI {
716 estimate: value,
717 lower: ci.lower,
718 upper: ci.upper,
719 margin: ((ci.upper - ci.lower) / 2.0).max(0.0),
720 };
721 let confidence = if (dl_merge.k > 0 && download > 0.0) || (ul_merge.k > 0 && upload > 0.0) {
722 Some(ConfidenceIntervals {
723 download: (dl_merge.k > 0 && download > 0.0)
724 .then(|| ci_from(download, dl_merge.capacity_ci)),
725 upload: (ul_merge.k > 0 && upload > 0.0).then(|| ci_from(upload, ul_merge.capacity_ci)),
726 confidence_level: 0.95,
727 })
728 } else {
729 None
730 };
731
732 let mut exclusions: Vec<MergeExclusion> = Vec::new();
734 for e in &dl_merge.exclusions {
735 exclusions.push(MergeExclusion {
736 provider: display_name_for_key(&e.name),
737 direction: "download",
738 samples: e.samples,
739 });
740 }
741 for e in &ul_merge.exclusions {
742 exclusions.push(MergeExclusion {
743 provider: display_name_for_key(&e.name),
744 direction: "upload",
745 samples: e.samples,
746 });
747 }
748
749 AggregateResult {
750 ping,
751 jitter,
752 jitter_rfc3550,
753 download,
754 upload,
755 packet_loss,
756 capacity,
757 consensus,
758 agreement,
759 upload_agreement,
760 rpm,
761 bufferbloat,
762 latency_stats,
763 stability,
764 divergence,
765 confidence,
766 exclusions,
767 }
768}
769
770pub type ProviderCompleteCallback = Arc<dyn Fn(&ProviderResult) + Send + Sync>;
772
773fn failed_provider(provider: &str, msg: &str) -> ProviderResult {
775 ProviderResult {
776 provider: provider.to_string(),
777 server: "unknown".to_string(),
778 location: None,
779 ping_ms: None,
780 jitter_ms: None,
781 download_mbps: None,
782 upload_mbps: None,
783 download_bytes: 0,
784 upload_bytes: 0,
785 download_duration_s: 0.0,
786 upload_duration_s: 0.0,
787 packet_loss_pct: None,
788 error: Some(msg.to_string()),
789 bandwidth_samples: None,
790 availability: ProviderAvailability::Failed,
791 latency_stats: None,
792 loaded_latency: None,
793 }
794}
795
796const PROVIDER_STALL_SECS: u64 = 60;
806
807async fn run_provider_future(
808 fast: bool,
809 provider_name: &str,
810 last_activity: Arc<StdMutex<Instant>>,
811 fut: impl std::future::Future<Output = ProviderResult>,
812) -> ProviderResult {
813 let stall = Duration::from_secs(PROVIDER_STALL_SECS);
814 let watchdog = async {
815 loop {
816 tokio::time::sleep(Duration::from_secs(1)).await;
817 let idle = last_activity
818 .lock()
819 .map(|t| t.elapsed())
820 .unwrap_or_default();
821 if idle >= stall {
822 break;
823 }
824 }
825 };
826 let bounded = async {
827 tokio::select! {
828 r = fut => Some(r),
829 _ = watchdog => None,
830 }
831 };
832 let outcome = if fast {
833 match tokio::time::timeout(FAST_HARD_CAP, bounded).await {
834 Ok(o) => o,
835 Err(_) => return failed_provider(provider_name, "FAST 25s hard cap reached"),
836 }
837 } else {
838 bounded.await
839 };
840 outcome.unwrap_or_else(|| {
841 failed_provider(
842 provider_name,
843 &format!("stalled — no progress for {PROVIDER_STALL_SECS}s (non-network wedge)"),
844 )
845 })
846}
847
848fn stamped_progress<F>(
851 progress: Arc<F>,
852 last_activity: Arc<StdMutex<Instant>>,
853) -> impl Fn(Phase, f64) + Send + Sync
854where
855 F: Fn(Phase, f64) + Send + Sync + 'static,
856{
857 move |phase, p| {
858 if let Ok(mut t) = last_activity.lock() {
859 *t = Instant::now();
860 }
861 progress(phase, p)
862 }
863}
864
865pub async fn run<F>(
869 config: SpeedTestConfig,
870 progress: F,
871 on_provider_complete: Option<ProviderCompleteCallback>,
872) -> SpeedTestResult
873where
874 F: Fn(Phase, f64) + Send + Sync + 'static,
875{
876 let start = Instant::now();
877 let mut providers = Vec::new();
878 let progress = Arc::new(progress);
879 let fast = config.provider_set == ProviderSet::Fast;
880
881 let provider_config = if fast {
888 SpeedTestConfig {
889 duration: TestDuration::Seconds(FAST_PER_DIR_SECS),
890 ..config.clone()
891 }
892 } else {
893 config.clone()
894 };
895
896 let record = |providers: &mut Vec<ProviderResult>, result: ProviderResult| {
897 if let Some(ref cb) = on_provider_complete {
898 cb(&result);
899 }
900 providers.push(result);
901 };
902
903 {
907 let last = Arc::new(StdMutex::new(Instant::now()));
908 let cb = stamped_progress(progress.clone(), last.clone());
909 let r = run_provider_future(
910 fast,
911 "Cloudflare",
912 last,
913 cloudflare::run(&provider_config, cb),
914 )
915 .await;
916 record(&mut providers, r);
917 }
918
919 let ndt7_locate_rate_limited = {
921 let last = Arc::new(StdMutex::new(Instant::now()));
922 let cb = stamped_progress(progress.clone(), last.clone());
923 let r =
924 run_provider_future(fast, "M-Lab NDT7", last, ndt7::run(&provider_config, cb)).await;
925 let rate_limited = r
926 .error
927 .as_deref()
928 .is_some_and(|e| e.contains("discovery rate-limited"));
929 record(&mut providers, r);
930 rate_limited
931 };
932
933 let run_msak = match config.provider_set {
935 ProviderSet::Fast => true,
936 ProviderSet::All => config.msak_enabled,
937 ProviderSet::Diagnostic => false,
938 };
939 if run_msak {
940 if ndt7_locate_rate_limited {
945 record(
946 &mut providers,
947 msak::error_result(
948 "skipped: M-Lab Locate is rate-limiting this network (NDT7 discovery was refused) — try again later"
949 .to_string(),
950 ),
951 );
952 } else {
953 let last = Arc::new(StdMutex::new(Instant::now()));
954 let cb = stamped_progress(progress.clone(), last.clone());
955 let r = run_provider_future(fast, "M-Lab MSAK", last, msak::run(&provider_config, cb))
956 .await;
957 record(&mut providers, r);
958 }
959 }
960
961 if config.provider_set == ProviderSet::All {
964 {
965 let last = Arc::new(StdMutex::new(Instant::now()));
966 let cb = stamped_progress(progress.clone(), last.clone());
967 let r = run_provider_future(
968 fast,
969 "LibreSpeed",
970 last,
971 librespeed::run(&provider_config, cb),
972 )
973 .await;
974 record(&mut providers, r);
975 }
976 {
977 let last = Arc::new(StdMutex::new(Instant::now()));
978 let cb = stamped_progress(progress.clone(), last.clone());
979 let r = run_provider_future(fast, "fast.com", last, fastcom::run(&provider_config, cb))
980 .await;
981 record(&mut providers, r);
982 }
983 {
984 let last = Arc::new(StdMutex::new(Instant::now()));
985 let cb = stamped_progress(progress.clone(), last.clone());
986 let r =
987 run_provider_future(fast, "CacheFly", last, cachefly::run(&provider_config, cb))
988 .await;
989 record(&mut providers, r);
990 }
991 {
992 let last = Arc::new(StdMutex::new(Instant::now()));
993 let cb = stamped_progress(progress.clone(), last.clone());
994 let r =
995 run_provider_future(fast, "Vultr", last, vultr::run(&provider_config, cb)).await;
996 record(&mut providers, r);
997 }
998 if config.apple_enabled {
999 let last = Arc::new(StdMutex::new(Instant::now()));
1000 let cb = stamped_progress(progress.clone(), last.clone());
1001 let r = run_provider_future(
1002 fast,
1003 "Apple networkQuality",
1004 last,
1005 applenq::run(&provider_config, cb),
1006 )
1007 .await;
1008 record(&mut providers, r);
1009 }
1010 }
1011
1012 progress(Phase::Computing, 1.0);
1013
1014 let agg = aggregate(&providers);
1015 let duration = start.elapsed().as_secs_f64();
1016
1017 let provider_set = match config.provider_set {
1018 ProviderSet::All => "full",
1019 ProviderSet::Fast => "fast",
1020 ProviderSet::Diagnostic => "diagnostic",
1021 };
1022
1023 SpeedTestResult {
1024 methodology_version: METHODOLOGY_VERSION,
1025 platform: "cli",
1026 provider_set,
1027 ping_ms: agg.ping,
1028 jitter_ms: agg.jitter,
1029 jitter_rfc3550: agg.jitter_rfc3550,
1030 download_mbps: agg.download,
1031 upload_mbps: agg.upload,
1032 packet_loss_pct: agg.packet_loss,
1033 capacity: agg.capacity,
1034 consensus: agg.consensus,
1035 agreement: agg.agreement,
1036 upload_agreement: Some(agg.upload_agreement),
1037 rpm: agg.rpm,
1038 bufferbloat: agg.bufferbloat,
1039 latency_stats: agg.latency_stats,
1040 providers,
1041 duration_s: duration,
1042 stability: agg.stability,
1043 provider_divergence: agg.divergence,
1044 confidence_intervals: agg.confidence,
1045 merge_exclusions: agg.exclusions,
1046 }
1047}
1048
1049pub fn format_mbps(mbps: f64) -> String {
1051 if mbps >= 1000.0 {
1052 format!("{:.1} Gbps", mbps / 1000.0)
1053 } else if mbps >= 100.0 {
1054 format!("{:.0} Mbps", mbps)
1055 } else if mbps >= 10.0 {
1056 format!("{:.1} Mbps", mbps)
1057 } else {
1058 format!("{:.2} Mbps", mbps)
1059 }
1060}
1061
1062pub fn format_bytes(bytes: u64) -> String {
1064 const KB: u64 = 1024;
1065 const MB: u64 = 1024 * KB;
1066 const GB: u64 = 1024 * MB;
1067
1068 if bytes >= GB {
1069 format!("{:.2} GB", bytes as f64 / GB as f64)
1070 } else if bytes >= MB {
1071 format!("{:.1} MB", bytes as f64 / MB as f64)
1072 } else if bytes >= KB {
1073 format!("{:.1} KB", bytes as f64 / KB as f64)
1074 } else {
1075 format!("{} B", bytes)
1076 }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082
1083 fn provider_with_samples(name: &str, download: Vec<f64>, upload: Vec<f64>) -> ProviderResult {
1084 ProviderResult {
1085 provider: name.to_string(),
1086 server: "test".to_string(),
1087 location: None,
1088 ping_ms: None,
1089 jitter_ms: None,
1090 download_mbps: download.last().copied(),
1091 upload_mbps: upload.last().copied(),
1092 download_bytes: 1,
1093 upload_bytes: 1,
1094 download_duration_s: 1.0,
1095 upload_duration_s: 1.0,
1096 packet_loss_pct: None,
1097 error: None,
1098 bandwidth_samples: Some(BandwidthSamples { download, upload }),
1099 availability: ProviderAvailability::Ran,
1100 latency_stats: None,
1101 loaded_latency: None,
1102 }
1103 }
1104
1105 fn dense(center: f64) -> Vec<f64> {
1108 (0..16).map(|i| center + (i % 4) as f64 - 1.5).collect()
1109 }
1110
1111 #[test]
1112 fn dense_probe_count_clamps() {
1113 assert_eq!(dense_probe_count(15.0), 50); assert_eq!(dense_probe_count(30.0), 99); assert_eq!(dense_probe_count(60.0), 198);
1116 assert_eq!(dense_probe_count(1000.0), 200); assert_eq!(dense_probe_count(1.0), 50); }
1119
1120 #[test]
1123 fn capacity_headline_tracks_well_sampled_providers() {
1124 let providers = vec![
1125 provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1126 provider_with_samples("M-Lab NDT7", dense(100.0), dense(20.0)),
1127 ];
1128 let agg = aggregate(&providers);
1129 assert!(
1130 (agg.download - 100.0).abs() < 10.0,
1131 "capacity should track ~100, got {}",
1132 agg.download
1133 );
1134 assert_eq!(agg.capacity.download, agg.download);
1135 assert!(agg.capacity.download_ci.is_some());
1136 }
1137
1138 #[test]
1141 fn download_only_provider_recorded_as_upload_exclusion() {
1142 let providers = vec![
1143 provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1144 provider_with_samples("CacheFly", dense(400.0), vec![]),
1145 ];
1146 let agg = aggregate(&providers);
1147 assert!(
1148 agg.exclusions
1149 .iter()
1150 .any(|e| e.provider == "CacheFly" && e.direction == "upload"),
1151 "CacheFly should be an upload exclusion: {:?}",
1152 agg.exclusions
1153 .iter()
1154 .map(|e| (&e.provider, e.direction, e.samples))
1155 .collect::<Vec<_>>()
1156 );
1157 }
1158
1159 #[test]
1162 fn low_sample_provider_excluded_from_merge() {
1163 let providers = vec![
1164 provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1165 provider_with_samples("LibreSpeed", vec![1000.0], vec![1000.0]),
1166 ];
1167 let agg = aggregate(&providers);
1168 assert!(
1169 (agg.download - 100.0).abs() < 12.0,
1170 "merge should track the well-sampled provider, got {}",
1171 agg.download
1172 );
1173 assert!(
1174 agg.exclusions
1175 .iter()
1176 .any(|e| e.provider == "LibreSpeed" && e.direction == "download"),
1177 "LibreSpeed download exclusion should be recorded: {:?}",
1178 agg.exclusions
1179 .iter()
1180 .map(|e| (&e.provider, e.direction, e.samples))
1181 .collect::<Vec<_>>()
1182 );
1183 }
1184
1185 #[test]
1188 fn degraded_all_sparse_still_nonzero() {
1189 let providers = vec![
1190 provider_with_samples("Cloudflare", vec![100.0, 102.0], vec![20.0, 21.0]),
1191 provider_with_samples("LibreSpeed", vec![110.0, 108.0], vec![22.0, 23.0]),
1192 ];
1193 let agg = aggregate(&providers);
1194 assert!(agg.download > 0.0, "degraded merge must not return 0.0");
1195 }
1196
1197 #[test]
1200 fn headline_ping_is_min_rtt() {
1201 let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1202 cf.ping_ms = Some(30.0);
1203 let mut ndt = provider_with_samples("M-Lab NDT7", dense(100.0), dense(20.0));
1204 ndt.ping_ms = Some(12.0);
1205 let agg = aggregate(&[cf, ndt]);
1206 assert_eq!(agg.ping, Some(12.0));
1207 }
1208
1209 #[test]
1211 fn headline_jitter_is_pdv_from_latency_block() {
1212 let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1213 cf.latency_stats =
1214 LatencyStats::from_rtts(&[10.0, 11.0, 12.0, 20.0, 10.5, 30.0, 10.2, 11.5]);
1215 let agg = aggregate(&[cf]);
1216 let ls = agg.latency_stats.as_ref().expect("latency block present");
1217 assert_eq!(agg.jitter, Some(ls.pdv));
1218 assert!(agg.jitter.unwrap() >= 0.0);
1219 }
1220
1221 #[test]
1223 fn bufferbloat_and_rpm_from_loaded_latency() {
1224 let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1225 cf.latency_stats = LatencyStats::from_rtts(&[10.0; 8]);
1226 cf.loaded_latency = Some(LoadedLatency {
1227 download: vec![50.0; 5],
1228 upload: vec![55.0; 5],
1229 });
1230 let agg = aggregate(&[cf]);
1231 let bb = agg.bufferbloat.expect("bufferbloat present");
1232 assert!(
1233 bb.delta_ms > 30.0,
1234 "loaded 50-55 vs idle 10, got {}",
1235 bb.delta_ms
1236 );
1237 assert!(agg.rpm.expect("rpm present") > 0.0);
1238 }
1239
1240 #[test]
1242 fn empty_run_is_zeroed() {
1243 let mut failed = provider_with_samples("Cloudflare", vec![], vec![]);
1244 failed.error = Some("boom".to_string());
1245 let agg = aggregate(&[failed]);
1246 assert_eq!(agg.download, 0.0);
1247 assert_eq!(agg.agreement.band, statistics::AgreementBand::Insufficient);
1248 assert!(agg.ping.is_none());
1249 }
1250}