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    {
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        record(&mut providers, r);
926    }
927
928    // M-Lab MSAK — FAST always; FULL when enabled; never in Diagnostic.
929    let run_msak = match config.provider_set {
930        ProviderSet::Fast => true,
931        ProviderSet::All => config.msak_enabled,
932        ProviderSet::Diagnostic => false,
933    };
934    if run_msak {
935        let last = Arc::new(StdMutex::new(Instant::now()));
936        let cb = stamped_progress(progress.clone(), last.clone());
937        let r =
938            run_provider_future(fast, "M-Lab MSAK", last, msak::run(&provider_config, cb)).await;
939        record(&mut providers, r);
940    }
941
942    // FULL-only providers, in registry order: LibreSpeed, fast.com, CacheFly,
943    // Vultr, Apple networkQuality.
944    if config.provider_set == ProviderSet::All {
945        {
946            let last = Arc::new(StdMutex::new(Instant::now()));
947            let cb = stamped_progress(progress.clone(), last.clone());
948            let r = run_provider_future(
949                fast,
950                "LibreSpeed",
951                last,
952                librespeed::run(&provider_config, cb),
953            )
954            .await;
955            record(&mut providers, r);
956        }
957        {
958            let last = Arc::new(StdMutex::new(Instant::now()));
959            let cb = stamped_progress(progress.clone(), last.clone());
960            let r = run_provider_future(fast, "fast.com", last, fastcom::run(&provider_config, cb))
961                .await;
962            record(&mut providers, r);
963        }
964        {
965            let last = Arc::new(StdMutex::new(Instant::now()));
966            let cb = stamped_progress(progress.clone(), last.clone());
967            let r =
968                run_provider_future(fast, "CacheFly", last, cachefly::run(&provider_config, cb))
969                    .await;
970            record(&mut providers, r);
971        }
972        {
973            let last = Arc::new(StdMutex::new(Instant::now()));
974            let cb = stamped_progress(progress.clone(), last.clone());
975            let r =
976                run_provider_future(fast, "Vultr", last, vultr::run(&provider_config, cb)).await;
977            record(&mut providers, r);
978        }
979        if config.apple_enabled {
980            let last = Arc::new(StdMutex::new(Instant::now()));
981            let cb = stamped_progress(progress.clone(), last.clone());
982            let r = run_provider_future(
983                fast,
984                "Apple networkQuality",
985                last,
986                applenq::run(&provider_config, cb),
987            )
988            .await;
989            record(&mut providers, r);
990        }
991    }
992
993    progress(Phase::Computing, 1.0);
994
995    let agg = aggregate(&providers);
996    let duration = start.elapsed().as_secs_f64();
997
998    let provider_set = match config.provider_set {
999        ProviderSet::All => "full",
1000        ProviderSet::Fast => "fast",
1001        ProviderSet::Diagnostic => "diagnostic",
1002    };
1003
1004    SpeedTestResult {
1005        methodology_version: METHODOLOGY_VERSION,
1006        platform: "cli",
1007        provider_set,
1008        ping_ms: agg.ping,
1009        jitter_ms: agg.jitter,
1010        jitter_rfc3550: agg.jitter_rfc3550,
1011        download_mbps: agg.download,
1012        upload_mbps: agg.upload,
1013        packet_loss_pct: agg.packet_loss,
1014        capacity: agg.capacity,
1015        consensus: agg.consensus,
1016        agreement: agg.agreement,
1017        upload_agreement: Some(agg.upload_agreement),
1018        rpm: agg.rpm,
1019        bufferbloat: agg.bufferbloat,
1020        latency_stats: agg.latency_stats,
1021        providers,
1022        duration_s: duration,
1023        stability: agg.stability,
1024        provider_divergence: agg.divergence,
1025        confidence_intervals: agg.confidence,
1026        merge_exclusions: agg.exclusions,
1027    }
1028}
1029
1030/// Format Mbps value for display
1031pub fn format_mbps(mbps: f64) -> String {
1032    if mbps >= 1000.0 {
1033        format!("{:.1} Gbps", mbps / 1000.0)
1034    } else if mbps >= 100.0 {
1035        format!("{:.0} Mbps", mbps)
1036    } else if mbps >= 10.0 {
1037        format!("{:.1} Mbps", mbps)
1038    } else {
1039        format!("{:.2} Mbps", mbps)
1040    }
1041}
1042
1043/// Format bytes for display
1044pub fn format_bytes(bytes: u64) -> String {
1045    const KB: u64 = 1024;
1046    const MB: u64 = 1024 * KB;
1047    const GB: u64 = 1024 * MB;
1048
1049    if bytes >= GB {
1050        format!("{:.2} GB", bytes as f64 / GB as f64)
1051    } else if bytes >= MB {
1052        format!("{:.1} MB", bytes as f64 / MB as f64)
1053    } else if bytes >= KB {
1054        format!("{:.1} KB", bytes as f64 / KB as f64)
1055    } else {
1056        format!("{} B", bytes)
1057    }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062    use super::*;
1063
1064    fn provider_with_samples(name: &str, download: Vec<f64>, upload: Vec<f64>) -> ProviderResult {
1065        ProviderResult {
1066            provider: name.to_string(),
1067            server: "test".to_string(),
1068            location: None,
1069            ping_ms: None,
1070            jitter_ms: None,
1071            download_mbps: download.last().copied(),
1072            upload_mbps: upload.last().copied(),
1073            download_bytes: 1,
1074            upload_bytes: 1,
1075            download_duration_s: 1.0,
1076            upload_duration_s: 1.0,
1077            packet_loss_pct: None,
1078            error: None,
1079            bandwidth_samples: Some(BandwidthSamples { download, upload }),
1080            availability: ProviderAvailability::Ran,
1081            latency_stats: None,
1082            loaded_latency: None,
1083        }
1084    }
1085
1086    /// A dense, tight sample series (16 points around ~100) so both directions
1087    /// survive plateau + IQR cleaning with ≥ 4 cleaned samples.
1088    fn dense(center: f64) -> Vec<f64> {
1089        (0..16).map(|i| center + (i % 4) as f64 - 1.5).collect()
1090    }
1091
1092    #[test]
1093    fn dense_probe_count_clamps() {
1094        assert_eq!(dense_probe_count(15.0), 50); // round(49.5)=50
1095        assert_eq!(dense_probe_count(30.0), 99); // round(99)
1096        assert_eq!(dense_probe_count(60.0), 198);
1097        assert_eq!(dense_probe_count(1000.0), 200); // clamp high
1098        assert_eq!(dense_probe_count(1.0), 50); // clamp low
1099    }
1100
1101    /// Headline download = capacity; well-sampled providers around ~100 Mbps
1102    /// merge to a capacity near 100 with a CI attached.
1103    #[test]
1104    fn capacity_headline_tracks_well_sampled_providers() {
1105        let providers = vec![
1106            provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1107            provider_with_samples("M-Lab NDT7", dense(100.0), dense(20.0)),
1108        ];
1109        let agg = aggregate(&providers);
1110        assert!(
1111            (agg.download - 100.0).abs() < 10.0,
1112            "capacity should track ~100, got {}",
1113            agg.download
1114        );
1115        assert_eq!(agg.capacity.download, agg.download);
1116        assert!(agg.capacity.download_ci.is_some());
1117    }
1118
1119    /// A download-only provider (empty upload) is surfaced as an upload
1120    /// exclusion rather than silently dropped.
1121    #[test]
1122    fn download_only_provider_recorded_as_upload_exclusion() {
1123        let providers = vec![
1124            provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1125            provider_with_samples("CacheFly", dense(400.0), vec![]),
1126        ];
1127        let agg = aggregate(&providers);
1128        assert!(
1129            agg.exclusions
1130                .iter()
1131                .any(|e| e.provider == "CacheFly" && e.direction == "upload"),
1132            "CacheFly should be an upload exclusion: {:?}",
1133            agg.exclusions
1134                .iter()
1135                .map(|e| (&e.provider, e.direction, e.samples))
1136                .collect::<Vec<_>>()
1137        );
1138    }
1139
1140    /// A 1-sample provider collapses to zero cleaned samples and is excluded;
1141    /// the headline still tracks the well-sampled provider.
1142    #[test]
1143    fn low_sample_provider_excluded_from_merge() {
1144        let providers = vec![
1145            provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1146            provider_with_samples("LibreSpeed", vec![1000.0], vec![1000.0]),
1147        ];
1148        let agg = aggregate(&providers);
1149        assert!(
1150            (agg.download - 100.0).abs() < 12.0,
1151            "merge should track the well-sampled provider, got {}",
1152            agg.download
1153        );
1154        assert!(
1155            agg.exclusions
1156                .iter()
1157                .any(|e| e.provider == "LibreSpeed" && e.direction == "download"),
1158            "LibreSpeed download exclusion should be recorded: {:?}",
1159            agg.exclusions
1160                .iter()
1161                .map(|e| (&e.provider, e.direction, e.samples))
1162                .collect::<Vec<_>>()
1163        );
1164    }
1165
1166    /// When no provider reaches the 4-sample floor, the headline degrades to
1167    /// the max per-provider point estimate — never 0.0 while data exists.
1168    #[test]
1169    fn degraded_all_sparse_still_nonzero() {
1170        let providers = vec![
1171            provider_with_samples("Cloudflare", vec![100.0, 102.0], vec![20.0, 21.0]),
1172            provider_with_samples("LibreSpeed", vec![110.0, 108.0], vec![22.0, 23.0]),
1173        ];
1174        let agg = aggregate(&providers);
1175        assert!(agg.download > 0.0, "degraded merge must not return 0.0");
1176    }
1177
1178    /// Headline ping is the min-RTT across providers (NDT7 kernel MinRTT wins
1179    /// here — the cross-check), not a weighted blend.
1180    #[test]
1181    fn headline_ping_is_min_rtt() {
1182        let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1183        cf.ping_ms = Some(30.0);
1184        let mut ndt = provider_with_samples("M-Lab NDT7", dense(100.0), dense(20.0));
1185        ndt.ping_ms = Some(12.0);
1186        let agg = aggregate(&[cf, ndt]);
1187        assert_eq!(agg.ping, Some(12.0));
1188    }
1189
1190    /// PDV is the canonical headline jitter, sourced from the dense latency block.
1191    #[test]
1192    fn headline_jitter_is_pdv_from_latency_block() {
1193        let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1194        cf.latency_stats =
1195            LatencyStats::from_rtts(&[10.0, 11.0, 12.0, 20.0, 10.5, 30.0, 10.2, 11.5]);
1196        let agg = aggregate(&[cf]);
1197        let ls = agg.latency_stats.as_ref().expect("latency block present");
1198        assert_eq!(agg.jitter, Some(ls.pdv));
1199        assert!(agg.jitter.unwrap() >= 0.0);
1200    }
1201
1202    /// Bufferbloat + RPM are computed from Cloudflare loaded-latency probes.
1203    #[test]
1204    fn bufferbloat_and_rpm_from_loaded_latency() {
1205        let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1206        cf.latency_stats = LatencyStats::from_rtts(&[10.0; 8]);
1207        cf.loaded_latency = Some(LoadedLatency {
1208            download: vec![50.0; 5],
1209            upload: vec![55.0; 5],
1210        });
1211        let agg = aggregate(&[cf]);
1212        let bb = agg.bufferbloat.expect("bufferbloat present");
1213        assert!(
1214            bb.delta_ms > 30.0,
1215            "loaded 50-55 vs idle 10, got {}",
1216            bb.delta_ms
1217        );
1218        assert!(agg.rpm.expect("rpm present") > 0.0);
1219    }
1220
1221    /// The empty-run path yields a zeroed headline and an insufficient band.
1222    #[test]
1223    fn empty_run_is_zeroed() {
1224        let mut failed = provider_with_samples("Cloudflare", vec![], vec![]);
1225        failed.error = Some("boom".to_string());
1226        let agg = aggregate(&[failed]);
1227        assert_eq!(agg.download, 0.0);
1228        assert_eq!(agg.agreement.band, statistics::AgreementBand::Insufficient);
1229        assert!(agg.ping.is_none());
1230    }
1231}