Skip to main content

nd_300/speedtest/
mod.rs

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
24/// SpeedQX methodology version stamped into every result payload
25/// (METHODOLOGY.md §1). Byte-identical to the website/app implementations.
26pub const METHODOLOGY_VERSION: &str = "4.0";
27
28/// Per-provider hard cap in FAST mode (METHODOLOGY.md §8): a provider that
29/// hasn't converged reports what it has; a stuck one is abandoned. This is the
30/// outer safety net (`run_provider_future`); providers whose runtime is
31/// RTT-scaled (Cloudflare's dense-latency engine) additionally self-bound a
32/// margin below this so they return real partial data instead of being killed
33/// and discarded here.
34pub(crate) const FAST_HARD_CAP: Duration = Duration::from_secs(25);
35
36/// Short per-direction budget for FAST-mode providers. Cloudflare's RTT-scaled
37/// dense-latency engine can still push a single provider toward [`FAST_HARD_CAP`]
38/// on high-RTT links, so `cloudflare::run` additionally caps its whole FAST run
39/// against an internal soft deadline (a margin below `FAST_HARD_CAP`) and
40/// returns the data it has gathered, rather than being killed by the outer
41/// timeout and discarded (§8 "reports what it has"). The empirical-Bernstein
42/// confidence sequence (§8) trims the transfer phases further per provider.
43const FAST_PER_DIR_SECS: u64 = 8;
44
45/// Dense idle-latency probe count (METHODOLOGY.md §4):
46/// `clamp(50, round(durationSeconds × 3.3), 200)` — auto (≈15 s) → 50, 30 s → 99.
47pub fn dense_probe_count(duration_secs: f64) -> u32 {
48    (duration_secs * 3.3).round().clamp(50.0, 200.0) as u32
49}
50
51/// Inter-probe interval for the dense latency engine (METHODOLOGY.md §4).
52pub const DENSE_PROBE_INTERVAL: Duration = Duration::from_millis(50);
53
54/// Warm-up probes discarded by the dense latency engine (DNS + TCP + TLS
55/// amortization), METHODOLOGY.md §4.
56pub const DENSE_PROBE_WARMUP: usize = 3;
57
58/// Test duration configuration
59#[derive(Debug, Clone)]
60pub enum TestDuration {
61    /// Fixed duration per direction in seconds (e.g., 30 = 30s download + 30s upload)
62    Seconds(u64),
63    /// Let providers use their natural duration
64    Auto,
65}
66
67/// Which providers to run (METHODOLOGY.md §3).
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub enum ProviderSet {
70    /// FULL: every provider — Cloudflare, NDT7, MSAK, LibreSpeed, fast.com,
71    /// CacheFly, Vultr, Apple networkQuality (speedqx default).
72    All,
73    /// FAST: Cloudflare + NDT7 + MSAK, with per-provider empirical-Bernstein
74    /// confidence-sequence early stop (RTT-gated) and a 25 s per-provider cap.
75    Fast,
76    /// Diagnostic subset: Cloudflare + NDT7 only (nd300 default).
77    Diagnostic,
78}
79
80/// Configuration for the speed test orchestrator
81#[derive(Debug, Clone)]
82pub struct SpeedTestConfig {
83    /// Duration per direction for CF, NDT7, LibreSpeed, MSAK, Apple (default: 30s)
84    pub duration: TestDuration,
85    /// Duration per direction for fast.com (default: Auto)
86    pub fastcom_duration: TestDuration,
87    /// Number of latency probes
88    pub latency_probes: u32,
89    /// Which providers to run
90    pub provider_set: ProviderSet,
91    /// Run the M-Lab MSAK multi-stream provider (All mode only)
92    pub msak_enabled: bool,
93    /// Run the Apple networkQuality provider (All mode only)
94    pub apple_enabled: bool,
95    /// Enable colored output
96    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/// Phase indicator for progress callbacks
114#[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/// Raw per-request Mbps samples for statistical post-processing.
143#[derive(Debug, Clone, Default, Serialize)]
144pub struct BandwidthSamples {
145    pub download: Vec<f64>,
146    pub upload: Vec<f64>,
147}
148
149/// Provider availability in the v4 payload (METHODOLOGY.md §3). On the CLI a
150/// provider either `ran` or `failed`; `unavailable-platform` exists for schema
151/// parity with the browser/app producers (nothing is platform-blocked here).
152#[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/// Dense idle-latency percentile block (METHODOLOGY.md §4/§9). The raw sample
161/// array is kept internally (for bufferbloat's idle P50) but not serialized —
162/// it is L3 drill-down data, absent from the headline schema.
163#[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    /// Canonical jitter: `P95 − P50`.
176    pub pdv: f64,
177    pub jitter_mad: f64,
178    /// Compatibility field only (RFC 3550 EWMA).
179    pub jitter_rfc3550: f64,
180}
181
182impl LatencyStats {
183    /// Build the percentile block from an RTT series (ms). `None` when empty.
184    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/// Loaded-latency RTT samples captured during Cloudflare saturation (§7).
208/// Feeds the delta-ms bufferbloat grade and the RPM estimate.
209#[derive(Debug, Clone, Default, Serialize)]
210pub struct LoadedLatency {
211    pub download: Vec<f64>,
212    pub upload: Vec<f64>,
213}
214
215/// A merged directional estimate with its confidence interval (METHODOLOGY.md §6/§9).
216#[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/// Provider agreement summary (`I²` + band) — replaces the v3 ">30% spread" flag.
227#[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/// Delta-ms bufferbloat summary (METHODOLOGY.md §7).
235#[derive(Debug, Clone, Serialize)]
236pub struct BufferbloatSummary {
237    pub grade: statistics::BufferbloatGrade,
238    pub delta_ms: f64,
239    pub ratio: f64,
240}
241
242/// Connection stability metrics (coefficient of variation).
243#[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/// Provider divergence detection.
252#[derive(Debug, Clone, Serialize)]
253pub struct ProviderDivergence {
254    pub download: f64,
255    pub upload: f64,
256    pub significant: bool,
257}
258
259/// A provider direction excluded from the headline merge for insufficient
260/// samples (additive JSON field, v3.4.0+).
261#[derive(Debug, Clone, Serialize)]
262pub struct MergeExclusion {
263    pub provider: String,
264    /// "download" or "upload".
265    pub direction: &'static str,
266    pub samples: usize,
267}
268
269/// Bootstrap confidence intervals on the pooled per-direction sample sets
270/// (additive JSON field, v3.4.0+). The pooled-pipeline CI is an honest proxy
271/// for the headline number's uncertainty: it naturally widens when providers
272/// diverge.
273#[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/// Per-provider speed test result
283#[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    /// Availability for the v4 payload (`ran`/`failed`/`unavailable-platform`).
308    pub availability: ProviderAvailability,
309    /// Dense idle-latency percentile block (HTTP providers only; §4).
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub latency_stats: Option<LatencyStats>,
312    /// Loaded-latency probes captured during saturation (Cloudflare only; §7).
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub loaded_latency: Option<LoadedLatency>,
315}
316
317/// Aggregated speed test result (used by both speedqx and nd300).
318///
319/// Carries the SpeedQX Methodology v4 payload (capacity/consensus + CIs, I²
320/// agreement, RPM, delta-ms bufferbloat, PDV jitter, per-provider availability)
321/// alongside the legacy v3 headline fields kept for one release.
322#[derive(Debug, Clone, Serialize)]
323pub struct SpeedTestResult {
324    /// Methodology version ("4.0").
325    pub methodology_version: &'static str,
326    /// Producer platform identifier ("cli").
327    pub platform: &'static str,
328    /// Test mode: "full" / "fast" / "diagnostic".
329    pub provider_set: &'static str,
330
331    /// Headline min-RTT ping across engine + kernel MinRTT (METHODOLOGY.md §4).
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub ping_ms: Option<f64>,
334    /// Canonical jitter — PDV (`P95 − P50`).
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub jitter_ms: Option<f64>,
337    /// Compatibility jitter (RFC 3550 EWMA).
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub jitter_rfc3550: Option<f64>,
340    /// Headline download = capacity (legacy field name kept for one release).
341    pub download_mbps: f64,
342    /// Headline upload = capacity (legacy field name kept for one release).
343    pub upload_mbps: f64,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub packet_loss_pct: Option<f64>,
346
347    /// CAPACITY: capability-weighted top-tier robust mean ± CI (headline).
348    pub capacity: MergedDirection,
349    /// CONSENSUS: conservative all-providers random-effects mean ± CI.
350    pub consensus: MergedDirection,
351    /// Download-direction agreement (I² + band).
352    pub agreement: AgreementInfo,
353    /// Upload-direction agreement (I² + band).
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub upload_agreement: Option<AgreementInfo>,
356    /// Responsiveness (round-trips per minute) from CF loaded latency, if present.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub rpm: Option<f64>,
359    /// Delta-ms bufferbloat grade from CF loaded latency, if present.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub bufferbloat: Option<BufferbloatSummary>,
362    /// Headline latency percentile block (the §4 Cloudflare instrument).
363    #[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
378/// Canonical registry rank (METHODOLOGY.md §3). The shared PCG32 block-bootstrap
379/// stream is drawn in this order (download then upload per provider) so the
380/// index streams are reproducible regardless of the order providers ran in.
381fn 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
395/// Lowercase registry key for a provider display name — drives the capability
396/// prior in [`statistics::merge_providers`] and labels merge exclusions.
397fn 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
411/// Display name for a registry key (inverse of [`registry_key`]); labels merge
412/// exclusions with the human-facing provider name.
413fn 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
428/// Per-provider cleaning (METHODOLOGY.md §5 steps 2–4, minus bootstrap):
429/// sanitize → plateau warm-up discard → (upload only: keep fastest 50%) →
430/// IQR fences at k = 1.5. Mirrors the TS `cleanDirection`.
431fn 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        // Keep the fastest ceil(n/2) post-warm-up samples before the IQR filter.
440        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
450/// Aggregation result carrying the full v4 payload for one run.
451struct 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
480/// SpeedQX v4 cross-provider aggregation (METHODOLOGY.md §5–§7).
481///
482/// Per successful provider (drawn in registry order so the shared PCG32
483/// block-bootstrap stream is reproducible), each direction is cleaned
484/// (plateau → [upload: fastest 50%] → IQR) and block-bootstrapped for its
485/// trimean point estimate + variance; the results feed
486/// [`statistics::merge_providers`] to produce capacity (headline) + consensus
487/// with HKSJ CIs and I² agreement. Headline ping is min-RTT across every
488/// provider's ping (NDT7/MSAK carry kernel MinRTT — the cross-check); headline
489/// jitter is PDV from the dense latency block; bufferbloat + RPM come from the
490/// Cloudflare loaded-latency probes when present.
491fn aggregate(providers: &[ProviderResult]) -> AggregateResult {
492    // Successful providers in canonical registry order.
493    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    // ── Per-provider cleaning + block bootstrap (ONE PCG32 stream) ──────
526    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    // Per-provider cleaned point estimates, keyed by display name.
532    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    // Headline = capacity; degrade to the max per-provider point when no
583    // provider qualified for the merge (never 0.0 while data exists).
584    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    // ── Headline ping: min-RTT across engine + kernel MinRTT (§4) ───────
602    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    // Headline latency block = the §4 instrument (Cloudflare), else the first
618    // provider in registry order with a dense engine block.
619    let latency_stats = ordered.iter().find_map(|p| p.latency_stats.clone());
620
621    // Headline jitter = PDV (canonical); RFC 3550 EWMA kept as a compat field.
622    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    // ── Bufferbloat + RPM from Cloudflare loaded-latency probes (§7) ────
636    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    // Packet loss from Cloudflare (only provider that measures it).
660    let packet_loss = ordered
661        .iter()
662        .find(|p| p.provider == "Cloudflare")
663        .and_then(|p| p.packet_loss_pct);
664
665    // ── Stability CV on pooled cleaned samples ──────────────────────────
666    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    // ── Agreement bands from I² (replaces the v3 >30% spread flag) ──────
680    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    // ── Capacity / consensus with CIs ───────────────────────────────────
701    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    // Legacy confidence_intervals surface (capacity CI) for one release.
715    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    // ── Merge exclusions from both directions (display-name labeled) ────
733    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
770/// Callback type for provider completion notifications.
771pub type ProviderCompleteCallback = Arc<dyn Fn(&ProviderResult) + Send + Sync>;
772
773/// Synthesize a failed [`ProviderResult`] (used by the FAST hard-cap path).
774fn 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
796/// Await a provider future, applying the FAST per-provider 25 s hard cap
797/// (METHODOLOGY.md §8). Outside FAST the future runs to completion.
798/// Liveness watchdog: a provider that reports NO progress for this long is
799/// wedged on something other than the network under test (a rate limiter, a
800/// silent WebSocket, a dead service, a hung discovery call) and is failed so
801/// the rest of the run continues untouched. Every honest code path — including
802/// a badly degraded link — emits progress at seconds scale (per latency probe,
803/// per completed/deadline-bounded transfer request, per WS measurement
804/// message), so 60 s of true silence is never a slow network.
805const 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
848/// Wrap a progress callback so every event stamps the provider's liveness
849/// clock for the stall watchdog in [`run_provider_future`].
850fn 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
865/// Run the speed test with the given configuration and progress callback.
866/// The `on_provider_complete` callback is called after each provider finishes,
867/// allowing the UI to show per-provider summaries.
868pub 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    // FAST providers use a short per-direction budget. Cloudflare additionally
882    // bounds its whole run against an internal soft deadline (below the 25 s
883    // hard cap) so its RTT-scaled dense-latency engine can't push it past the
884    // cap and get its completed work discarded; the empirical-Bernstein early
885    // stop (inside the providers, gated on measured min-RTT) trims the transfer
886    // phases further.
887    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    // ── Providers run sequentially in canonical registry order (§2/§3) ──
904
905    // Cloudflare (always).
906    {
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    // M-Lab NDT7 (always).
920    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    // M-Lab MSAK — FAST always; FULL when enabled; never in Diagnostic.
934    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        // Both M-Lab providers are assigned servers by the same Locate API.
941        // If NDT7's discovery was just refused with a rate limit, MSAK's
942        // would be too — skip the request instead of feeding another call
943        // into an already-tripped per-IP limiter.
944        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    // FULL-only providers, in registry order: LibreSpeed, fast.com, CacheFly,
962    // Vultr, Apple networkQuality.
963    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
1049/// Format Mbps value for display
1050pub 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
1062/// Format bytes for display
1063pub 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    /// A dense, tight sample series (16 points around ~100) so both directions
1106    /// survive plateau + IQR cleaning with ≥ 4 cleaned samples.
1107    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); // round(49.5)=50
1114        assert_eq!(dense_probe_count(30.0), 99); // round(99)
1115        assert_eq!(dense_probe_count(60.0), 198);
1116        assert_eq!(dense_probe_count(1000.0), 200); // clamp high
1117        assert_eq!(dense_probe_count(1.0), 50); // clamp low
1118    }
1119
1120    /// Headline download = capacity; well-sampled providers around ~100 Mbps
1121    /// merge to a capacity near 100 with a CI attached.
1122    #[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    /// A download-only provider (empty upload) is surfaced as an upload
1139    /// exclusion rather than silently dropped.
1140    #[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    /// A 1-sample provider collapses to zero cleaned samples and is excluded;
1160    /// the headline still tracks the well-sampled provider.
1161    #[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    /// When no provider reaches the 4-sample floor, the headline degrades to
1186    /// the max per-provider point estimate — never 0.0 while data exists.
1187    #[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    /// Headline ping is the min-RTT across providers (NDT7 kernel MinRTT wins
1198    /// here — the cross-check), not a weighted blend.
1199    #[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    /// PDV is the canonical headline jitter, sourced from the dense latency block.
1210    #[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    /// Bufferbloat + RPM are computed from Cloudflare loaded-latency probes.
1222    #[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    /// The empty-run path yields a zeroed headline and an insufficient band.
1241    #[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}