use serde::{Deserialize, Serialize};
pub const PERCENTILE_METHOD: &str = "linear";
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Stats {
pub samples: usize,
pub min_ms: f64,
pub max_ms: f64,
pub mean_ms: f64,
pub p50_ms: f64,
pub p95_ms: f64,
pub stddev_ms: f64,
}
impl Stats {
pub fn from_samples(mut values: Vec<f64>) -> Self {
if values.is_empty() {
return Self::default();
}
values.sort_by(f64::total_cmp);
let samples = values.len();
let mean = values.iter().sum::<f64>() / samples as f64;
let variance = if samples > 1 {
values
.iter()
.map(|value| (value - mean).powi(2))
.sum::<f64>()
/ (samples - 1) as f64
} else {
0.0
};
Self {
samples,
min_ms: values[0],
max_ms: values[samples - 1],
mean_ms: mean,
p50_ms: percentile(&values, 0.50),
p95_ms: percentile(&values, 0.95),
stddev_ms: variance.sqrt(),
}
}
pub fn quantile(values: &[f64], q: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(f64::total_cmp);
percentile(&sorted, q)
}
}
pub fn percentile(sorted: &[f64], fraction: f64) -> f64 {
assert!(
sorted.iter().all(|value| value.is_finite()),
"percentile samples must be finite; found a non-finite value"
);
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let position = fraction * (sorted.len() - 1) as f64;
let lower = position.floor() as usize;
let upper = position.ceil() as usize;
if lower == upper {
return sorted[lower];
}
let weight = position - lower as f64;
sorted[lower] * (1.0 - weight) + sorted[upper] * weight
}
#[cfg(test)]
mod tests {
use super::{PERCENTILE_METHOD, Stats, percentile};
#[test]
fn stats_serializes_the_frozen_contract_key_set() {
let value = serde_json::to_value(Stats::from_samples(vec![4.0, 1.0, 3.0, 2.0]))
.expect("Stats must serialize");
let mut keys: Vec<&str> = value
.as_object()
.expect("Stats serializes as a JSON object")
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"max_ms",
"mean_ms",
"min_ms",
"p50_ms",
"p95_ms",
"samples",
"stddev_ms"
],
"the formula report's Stats block must keep exactly these keys"
);
assert_eq!(value["samples"], 4);
assert!((value["min_ms"].as_f64().unwrap() - 1.0).abs() < 1e-12);
assert!((value["max_ms"].as_f64().unwrap() - 4.0).abs() < 1e-12);
assert!((value["mean_ms"].as_f64().unwrap() - 2.5).abs() < 1e-12);
assert!((value["p50_ms"].as_f64().unwrap() - 2.5).abs() < 1e-12);
assert!((value["p95_ms"].as_f64().unwrap() - 3.85).abs() < 1e-12);
}
#[test]
fn percentile_matches_linear_interpolation() {
let values = vec![1.0, 2.0, 3.0, 4.0];
assert!((percentile(&values, 0.0) - 1.0).abs() < 1e-12);
assert!((percentile(&values, 1.0) - 4.0).abs() < 1e-12);
assert!((percentile(&values, 0.5) - 2.5).abs() < 1e-12);
assert!((percentile(&values, 0.9) - 3.7).abs() < 1e-12);
assert!((percentile(&values, 0.95) - 3.85).abs() < 1e-12);
}
#[test]
fn percentile_of_an_empty_sample_is_zero() {
assert_eq!(percentile(&[], 0.5), 0.0);
}
#[test]
fn percentile_of_a_single_sample_ignores_the_fraction() {
assert_eq!(percentile(&[7.0], 0.0), 7.0);
assert_eq!(percentile(&[7.0], 0.5), 7.0);
assert_eq!(percentile(&[7.0], 1.0), 7.0);
}
#[test]
#[should_panic(expected = "must be finite")]
fn percentile_rejects_non_finite_samples() {
percentile(&[1.0, f64::NAN, 3.0], 0.5);
}
#[test]
fn quantile_matches_percentile_without_touching_the_serialized_shape() {
let values = [4.0, 1.0, 3.0, 2.0];
assert!((Stats::quantile(&values, 0.9) - 3.7).abs() < 1e-12);
assert_eq!(Stats::quantile(&[], 0.5), 0.0);
let value = serde_json::to_value(Stats::from_samples(values.to_vec())).unwrap();
assert!(value.get("quantile").is_none());
}
#[test]
fn the_percentile_method_marker_is_locked_to_linear() {
assert_eq!(PERCENTILE_METHOD, "linear");
}
#[test]
fn stats_reports_min_max_mean_and_stddev() {
let stats = Stats::from_samples(vec![4.0, 1.0, 3.0, 2.0]);
assert_eq!(stats.samples, 4);
assert!((stats.min_ms - 1.0).abs() < 1e-12);
assert!((stats.max_ms - 4.0).abs() < 1e-12);
assert!((stats.mean_ms - 2.5).abs() < 1e-12);
assert!((stats.p50_ms - 2.5).abs() < 1e-12);
assert!(stats.stddev_ms > 0.0);
}
#[test]
fn empty_stats_are_zeroed() {
let stats = Stats::from_samples(Vec::new());
assert_eq!(stats.samples, 0);
assert_eq!(stats.mean_ms, 0.0);
assert_eq!(stats.p95_ms, 0.0);
}
}