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}