Skip to main content

subms_stats/
tail.rs

1//! Tail analysis: the bit institutional users actually care about.
2//! Behind the `tail` Cargo feature (on by default).
3
4use crate::percentiles::percentile;
5
6/// Conditional tail expectation: the mean of all samples ABOVE the
7/// given quantile. Also known as expected shortfall (ES) or
8/// conditional value-at-risk (CVaR). For latency: "given that we're
9/// in the worst N% of cases, what does that average look like?"
10///
11/// `q` in `[0.0, 1.0]`. Returns 0 if no samples fall above the
12/// quantile threshold.
13pub fn conditional_tail_expectation(samples: &[u64], q: f64) -> u64 {
14    if samples.is_empty() {
15        return 0;
16    }
17    let mut sorted = samples.to_vec();
18    sorted.sort_unstable();
19    let cutoff = percentile(&sorted, q);
20    let tail: Vec<u64> = sorted.into_iter().filter(|&v| v > cutoff).collect();
21    if tail.is_empty() {
22        return cutoff;
23    }
24    tail.iter().sum::<u64>() / tail.len() as u64
25}
26
27/// Hill estimator for the tail index. Higher values mean a heavier
28/// tail (more frequent extreme outliers). Uses the top `k` order
29/// statistics. Returns `None` if `k < 2` or fewer than `k+1` samples.
30///
31/// Interpretation: a Hill index above 1.0 indicates a power-law tail
32/// where occasional 10x-typical outliers should be expected; below
33/// 0.3 indicates a near-exponential tail with rare outliers.
34pub fn hill_tail_index(samples: &[u64], k: usize) -> Option<f64> {
35    if k < 2 || samples.len() <= k {
36        return None;
37    }
38    let mut sorted = samples.to_vec();
39    sorted.sort_unstable();
40    let top: Vec<u64> = sorted.iter().rev().take(k + 1).copied().collect();
41    let pivot = top[k];
42    if pivot == 0 {
43        return None;
44    }
45    let pivot_f = pivot as f64;
46    let mut sum = 0.0;
47    for v in top.iter().take(k) {
48        let ratio = (*v as f64) / pivot_f;
49        if ratio > 0.0 {
50            sum += ratio.ln();
51        }
52    }
53    Some(sum / k as f64)
54}
55
56/// Ratio of p99 over p50. Quick "how fat is the tail" indicator:
57/// 1.0 means uniform, 10+ means a meaningful tail. Returns 0 when
58/// p50 is 0.
59pub fn tail_fatness_ratio(samples: &[u64]) -> f64 {
60    if samples.is_empty() {
61        return 0.0;
62    }
63    let mut sorted = samples.to_vec();
64    sorted.sort_unstable();
65    let p50 = percentile(&sorted, 0.50);
66    let p99 = percentile(&sorted, 0.99);
67    if p50 == 0 {
68        return 0.0;
69    }
70    p99 as f64 / p50 as f64
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn cte_exceeds_quantile() {
79        let v: Vec<u64> = (0..100).collect();
80        let cte99 = conditional_tail_expectation(&v, 0.95);
81        assert!(cte99 >= 95);
82    }
83
84    #[test]
85    fn cte_empty_is_zero() {
86        assert_eq!(conditional_tail_expectation(&[], 0.99), 0);
87    }
88
89    #[test]
90    fn hill_returns_none_for_tiny_input() {
91        assert!(hill_tail_index(&[1, 2, 3], 5).is_none());
92    }
93
94    #[test]
95    fn hill_powerlike_tail_returns_positive() {
96        let v: Vec<u64> = (1..1000).map(|i| (i as u64).pow(2)).collect();
97        let idx = hill_tail_index(&v, 50).unwrap();
98        assert!(
99            idx > 0.0,
100            "Hill estimator on power-law tail should be positive: {}",
101            idx
102        );
103    }
104
105    #[test]
106    fn fatness_ratio_uniform_close_to_one() {
107        let v = vec![100u64; 1000];
108        let r = tail_fatness_ratio(&v);
109        assert!((r - 1.0).abs() < 0.01);
110    }
111
112    #[test]
113    fn fatness_ratio_heavy_tail_exceeds_one() {
114        let mut v: Vec<u64> = vec![100; 990];
115        v.resize(v.len() + 10, 10_000);
116        let r = tail_fatness_ratio(&v);
117        assert!(r > 1.0);
118    }
119}