Skip to main content

ferrox_core/
summary_stats.rs

1//! Summary statistics over samples that may be missing.
2//!
3//! Two functions, both of which exist because the obvious version of
4//! them reports a number nobody measured. They live here, and not
5//! beside either caller, because both `ferrox-server`'s serving
6//! telemetry and `ferrox-cli`'s benchmark client summarise latency
7//! samples, and two copies of a percentile definition are free to
8//! disagree about what a p95 means.
9//!
10//! Ported from FreeToken (Apache-2.0); see docs/THIRD_PARTY_NOTICES.md.
11
12/// The `p`-th percentile of `values`, nearest-rank.
13///
14/// `p` is a percentage in `[0, 100]`. The result is always a value that
15/// is actually in `values`: over a handful of requests an interpolated
16/// percentile reports a latency nothing measured, and a UI showing it
17/// beside a request list invites the reader to look for the row it came
18/// from. Interpolated percentiles are fine for continuous data and
19/// wrong for a window of twelve requests.
20///
21/// `None` for an empty input -- a percentile of nothing is not zero.
22pub fn percentile(values: &[f64], p: f64) -> Option<f64> {
23    let mut sorted: Vec<f64> = values.iter().copied().filter(|v| !v.is_nan()).collect();
24    if sorted.is_empty() {
25        return None;
26    }
27    sorted.sort_by(f64::total_cmp);
28    let p = p.clamp(0.0, 100.0);
29    let rank = (p / 100.0 * sorted.len() as f64).ceil() as usize;
30    let index = rank.saturating_sub(1).min(sorted.len() - 1);
31    Some(sorted[index])
32}
33
34/// The mean of the values that exist.
35///
36/// `None` when none do. The distinction is the whole function: a
37/// non-streamed request has no time-to-first-token, and averaging those
38/// in as zero drags the mean toward zero in exact proportion to how many
39/// clients did not stream -- which reads as the server getting faster.
40pub fn mean_of_present<I: IntoIterator<Item = Option<f64>>>(values: I) -> Option<f64> {
41    let mut sum = 0.0;
42    let mut count = 0usize;
43    for value in values.into_iter().flatten() {
44        sum += value;
45        count += 1;
46    }
47    (count > 0).then(|| sum / count as f64)
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    /// Nearest-rank always names a real observation, which is the whole
55    /// reason it is used here over an interpolating definition.
56    #[test]
57    fn a_percentile_is_always_a_value_that_was_actually_measured() {
58        let values = [10.0, 20.0, 30.0, 40.0];
59        for p in [0.0, 25.0, 50.0, 75.0, 95.0, 100.0] {
60            let got = percentile(&values, p).expect("non-empty");
61            assert!(values.contains(&got), "p{p} produced {got}");
62        }
63        assert_eq!(percentile(&values, 50.0), Some(20.0));
64        assert_eq!(percentile(&values, 95.0), Some(40.0));
65        assert_eq!(percentile(&values, 0.0), Some(10.0));
66    }
67
68    #[test]
69    fn a_percentile_of_nothing_is_none_rather_than_zero() {
70        assert_eq!(percentile(&[], 95.0), None);
71        assert_eq!(percentile(&[f64::NAN], 95.0), None);
72    }
73
74    #[test]
75    fn a_mean_ignores_absent_values_instead_of_reading_them_as_zero() {
76        assert_eq!(
77            mean_of_present([Some(100.0), None, Some(300.0), None]),
78            Some(200.0)
79        );
80        assert_eq!(mean_of_present([None, None]), None);
81        assert_eq!(mean_of_present(Vec::<Option<f64>>::new()), None);
82    }
83}