#[cfg(feature = "telemetry")]
use serde::Serialize;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "telemetry", derive(Serialize))]
pub struct SampleStats {
pub count: usize,
pub min: usize,
pub max: usize,
pub mean: f64,
pub median: usize,
pub p90: usize,
pub p95: usize,
pub p99: usize,
pub stddev: f64,
}
impl SampleStats {
pub fn from_samples(samples: &[usize]) -> Option<Self> {
if samples.is_empty() {
return None;
}
let mut sorted: Vec<usize> = samples.to_vec();
sorted.sort_unstable();
Some(Self::from_sorted_samples(&sorted))
}
pub fn from_sorted_samples(sorted: &[usize]) -> Self {
let count = sorted.len();
assert!(count > 0, "from_sorted_samples requires a non-empty slice");
debug_assert!(
sorted.windows(2).all(|w| w[0] <= w[1]),
"from_sorted_samples requires ascending input"
);
let min = sorted[0];
let max = sorted[count - 1];
let sum: u128 = sorted.iter().map(|&x| x as u128).sum();
let mean = sum as f64 / count as f64;
let variance = sorted
.iter()
.map(|&x| {
let d = x as f64 - mean;
d * d
})
.sum::<f64>()
/ count as f64;
let stddev = variance.sqrt();
Self {
count,
min,
max,
mean,
median: percentile(sorted, 50),
p90: percentile(sorted, 90),
p95: percentile(sorted, 95),
p99: percentile(sorted, 99),
stddev,
}
}
}
fn percentile(sorted: &[usize], p: u8) -> usize {
let n = sorted.len();
debug_assert!(n > 0);
let rank = ((p as u128 * n as u128).div_ceil(100)) as usize;
let idx = rank.saturating_sub(1).min(n - 1);
sorted[idx]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_is_none() {
assert!(SampleStats::from_samples(&[]).is_none());
}
#[test]
fn single_sample() {
let s = SampleStats::from_samples(&[42]).unwrap();
assert_eq!(s.count, 1);
assert_eq!(s.min, 42);
assert_eq!(s.max, 42);
assert_eq!(s.mean, 42.0);
assert_eq!(s.median, 42);
assert_eq!(s.p99, 42);
assert_eq!(s.stddev, 0.0);
}
#[test]
fn identical_values_have_zero_stddev() {
let s = SampleStats::from_samples(&[64; 50]).unwrap();
assert_eq!(s.count, 50);
assert_eq!(s.min, 64);
assert_eq!(s.max, 64);
assert_eq!(s.mean, 64.0);
assert_eq!(s.median, 64);
assert_eq!(s.stddev, 0.0);
}
#[test]
fn unsorted_input_gets_sorted() {
let s = SampleStats::from_samples(&[3, 1, 4, 1, 5, 9, 2, 6, 5, 3]).unwrap();
assert_eq!(s.count, 10);
assert_eq!(s.min, 1);
assert_eq!(s.max, 9);
}
#[test]
fn percentiles_nearest_rank() {
let samples: Vec<usize> = (1..=100).collect();
let s = SampleStats::from_samples(&samples).unwrap();
assert_eq!(s.median, 50);
assert_eq!(s.p90, 90);
assert_eq!(s.p95, 95);
assert_eq!(s.p99, 99);
assert_eq!(s.max, 100);
}
#[test]
fn from_sorted_samples_skips_sort() {
let sorted = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let s = SampleStats::from_sorted_samples(&sorted);
assert_eq!(s.median, 5);
assert_eq!(s.max, 10);
}
#[test]
#[should_panic]
fn from_sorted_samples_panics_on_empty() {
let _ = SampleStats::from_sorted_samples(&[]);
}
#[test]
fn outliers_drive_max_not_median() {
let mut samples = vec![64usize; 99];
samples.push(100_000);
let s = SampleStats::from_samples(&samples).unwrap();
assert_eq!(s.median, 64);
assert_eq!(s.p95, 64);
assert_eq!(s.p99, 64); assert_eq!(s.max, 100_000);
assert!(s.stddev > 0.0);
}
#[test]
fn known_mean_and_stddev() {
let s = SampleStats::from_samples(&[2, 4, 4, 4, 5, 5, 7, 9]).unwrap();
assert_eq!(s.mean, 5.0);
assert!((s.stddev - 2.0).abs() < 1e-12);
assert_eq!(s.min, 2);
assert_eq!(s.max, 9);
assert_eq!(s.median, 4);
}
#[test]
#[cfg(not(debug_assertions))]
fn from_sorted_samples_with_unsorted_returns_garbage_silently() {
let s = SampleStats::from_sorted_samples(&[5, 1, 9, 3]);
assert_eq!(s.min, 5); assert_eq!(s.max, 3); }
}