Skip to main content

netspeed_cli/formatter/
stability.rs

1//! Speed stability analysis and latency percentiles.
2
3#![allow(
4    clippy::cast_precision_loss,
5    clippy::cast_possible_truncation,
6    clippy::cast_sign_loss
7)]
8
9use crate::theme::{Colors, Theme};
10
11/// Compute coefficient of variation (CV) as a percentage.
12#[must_use]
13pub fn compute_cv(speeds: &[f64]) -> f64 {
14    if speeds.is_empty() {
15        return 0.0;
16    }
17    // Safe: sample counts are small (≤1000), well under 2^53.
18    let n = speeds.len() as f64;
19    let mean = speeds.iter().sum::<f64>() / n;
20    if mean <= 0.0 {
21        return 0.0;
22    }
23    let variance = speeds.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / n;
24    let stddev = variance.sqrt();
25    (stddev / mean) * 100.0
26}
27
28#[must_use]
29pub fn format_stability_line(cv: f64, nc: bool, theme: Theme) -> String {
30    let label = if cv < 5.0 {
31        "rock-solid"
32    } else if cv < 10.0 {
33        "very stable"
34    } else if cv < 20.0 {
35        "moderate"
36    } else if cv < 35.0 {
37        "variable"
38    } else {
39        "unstable"
40    };
41    let text = format!("±{cv:.0}% {label}");
42    if nc {
43        text
44    } else if cv < 5.0 {
45        Colors::good(&text, theme)
46    } else if cv < 20.0 {
47        Colors::warn(&text, theme)
48    } else {
49        Colors::bad(&text, theme)
50    }
51}
52
53#[must_use]
54pub fn compute_percentiles(samples: &[f64]) -> Option<(f64, f64, f64)> {
55    let n = samples.len();
56    if n < 3 {
57        return None;
58    }
59    let mut data = samples.to_vec();
60    let p50_idx = n * 50 / 100;
61    let p95_idx = (n * 95 / 100).min(n - 1);
62    let p99_idx = (n * 99 / 100).min(n - 1);
63
64    // Partition at p99: elements before are <= p99, elements after are >= p99
65    data.select_nth_unstable_by(p99_idx, |a, b| {
66        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
67    });
68    let p99 = data[p99_idx];
69
70    // Partition the left slice at p95
71    data.select_nth_unstable_by(p95_idx, |a, b| {
72        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
73    });
74    let p95 = data[p95_idx];
75
76    // Partition the left slice at p50
77    data.select_nth_unstable_by(p50_idx, |a, b| {
78        a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
79    });
80    let p50 = data[p50_idx];
81
82    Some((p50, p95, p99))
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn test_compute_cv_constant() {
91        let speeds = vec![100.0, 100.0, 100.0];
92        assert!(compute_cv(&speeds).abs() < f64::EPSILON);
93    }
94
95    #[test]
96    fn test_compute_cv_empty() {
97        assert!(compute_cv(&[]).abs() < f64::EPSILON);
98    }
99
100    #[test]
101    fn test_compute_percentiles_basic() {
102        let samples: Vec<f64> = (1..=100).map(f64::from).collect();
103        let result = compute_percentiles(&samples);
104        assert!(result.is_some());
105        let (p50, p95, p99) = result.unwrap();
106        // With 100 elements (indices 0..99), n*50/100 = 50 → index 50 → value 51.0
107        assert!((p50 - 51.0).abs() < 1.0);
108        assert!((p95 - 96.0).abs() < 1.0);
109        assert!((p99 - 100.0).abs() < 1.0);
110    }
111
112    #[test]
113    fn test_compute_percentiles_too_few() {
114        assert!(compute_percentiles(&[1.0, 2.0]).is_none());
115    }
116}