use std::fmt;
#[derive(Debug, Clone)]
pub struct Histogram {
boundaries: Vec<f64>,
counts: Vec<u64>,
samples: u64,
min: f64,
max: f64,
sum: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum HistogramError {
InvalidBoundaries,
BoundariesMismatch,
}
impl fmt::Display for HistogramError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HistogramError::InvalidBoundaries => {
f.write_str("histogram boundaries must be non-empty, sorted, and finite")
}
HistogramError::BoundariesMismatch => {
f.write_str("histogram boundaries do not match merge target")
}
}
}
}
impl std::error::Error for HistogramError {}
impl Histogram {
pub fn with_buckets(boundaries: &[f64]) -> Self {
Self::try_with_buckets(boundaries).expect("invalid histogram boundaries")
}
pub fn try_with_buckets(boundaries: &[f64]) -> Result<Self, HistogramError> {
if boundaries.is_empty() || !boundaries.iter().all(|b| b.is_finite()) {
return Err(HistogramError::InvalidBoundaries);
}
for w in boundaries.windows(2) {
if w[0] >= w[1] {
return Err(HistogramError::InvalidBoundaries);
}
}
let counts = vec![0u64; boundaries.len() + 1];
Ok(Self {
boundaries: boundaries.to_vec(),
counts,
samples: 0,
min: f64::INFINITY,
max: f64::NEG_INFINITY,
sum: 0.0,
})
}
pub fn log_spaced(low: f64, high: f64, count: usize) -> Self {
assert!(low > 0.0 && high > low, "log_spaced needs 0 < low < high");
assert!(count > 0, "log_spaced count must be > 0");
let log_low = low.ln();
let log_high = high.ln();
let step = (log_high - log_low) / count as f64;
let boundaries: Vec<f64> = (0..=count)
.map(|i| (log_low + step * i as f64).exp())
.collect();
Self::with_buckets(&boundaries)
}
pub fn record(&mut self, value: f64) {
if !value.is_finite() {
return;
}
let idx = match self
.boundaries
.binary_search_by(|b| b.partial_cmp(&value).unwrap_or(std::cmp::Ordering::Equal))
{
Ok(i) => i + 1,
Err(i) => i,
};
self.counts[idx] += 1;
self.samples += 1;
self.sum += value;
if value < self.min {
self.min = value;
}
if value > self.max {
self.max = value;
}
}
pub fn quantile(&self, q: f64) -> f64 {
if self.samples == 0 {
return 0.0;
}
let q = q.clamp(0.0, 1.0);
let target_rank = q * self.samples as f64;
let mut running: f64 = 0.0;
for (i, &c) in self.counts.iter().enumerate() {
let next = running + c as f64;
if next >= target_rank {
let lo = if i == 0 {
self.min.min(self.boundaries[0])
} else {
self.boundaries[i - 1]
};
let hi = if i == self.boundaries.len() {
self.max.max(*self.boundaries.last().unwrap())
} else {
self.boundaries[i]
};
if c == 0 {
return lo;
}
let frac = (target_rank - running) / c as f64;
return lo + (hi - lo) * frac.clamp(0.0, 1.0);
}
running = next;
}
self.max
}
pub fn samples(&self) -> u64 {
self.samples
}
pub fn mean(&self) -> f64 {
if self.samples == 0 {
0.0
} else {
self.sum / self.samples as f64
}
}
pub fn min(&self) -> f64 {
if self.samples == 0 { 0.0 } else { self.min }
}
pub fn max(&self) -> f64 {
if self.samples == 0 { 0.0 } else { self.max }
}
pub fn merge(&mut self, other: &Histogram) -> Result<(), HistogramError> {
if self.boundaries != other.boundaries {
return Err(HistogramError::BoundariesMismatch);
}
for (a, b) in self.counts.iter_mut().zip(other.counts.iter()) {
*a += *b;
}
self.samples += other.samples;
self.sum += other.sum;
if other.samples > 0 {
self.min = self.min.min(other.min);
self.max = self.max.max(other.max);
}
Ok(())
}
pub fn buckets(&self) -> impl Iterator<Item = (f64, u64)> + '_ {
self.counts.iter().enumerate().map(|(i, &c)| {
let upper = if i < self.boundaries.len() {
self.boundaries[i]
} else {
f64::INFINITY
};
(upper, c)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_buckets_accepts_sorted_finite() {
let h = Histogram::with_buckets(&[0.1, 1.0, 10.0]);
assert_eq!(h.buckets().count(), 4);
}
#[test]
fn try_with_buckets_rejects_unsorted() {
let err = Histogram::try_with_buckets(&[1.0, 0.5]).unwrap_err();
assert_eq!(err, HistogramError::InvalidBoundaries);
}
#[test]
fn try_with_buckets_rejects_empty() {
let err = Histogram::try_with_buckets(&[]).unwrap_err();
assert_eq!(err, HistogramError::InvalidBoundaries);
}
#[test]
fn try_with_buckets_rejects_non_finite() {
let err = Histogram::try_with_buckets(&[1.0, f64::NAN, 3.0]).unwrap_err();
assert_eq!(err, HistogramError::InvalidBoundaries);
}
#[test]
fn record_counts_into_correct_bucket() {
let mut h = Histogram::with_buckets(&[1.0, 10.0]);
h.record(0.5); h.record(5.0); h.record(50.0); h.record(1.0); let counts: Vec<u64> = h.buckets().map(|(_, c)| c).collect();
assert_eq!(counts, vec![1, 2, 1]);
assert_eq!(h.samples(), 4);
}
#[test]
fn record_skips_non_finite() {
let mut h = Histogram::with_buckets(&[1.0]);
h.record(f64::NAN);
h.record(f64::INFINITY);
h.record(f64::NEG_INFINITY);
assert_eq!(h.samples(), 0);
}
#[test]
fn quantile_zero_samples_returns_zero() {
let h = Histogram::with_buckets(&[1.0]);
assert_eq!(h.quantile(0.5), 0.0);
}
#[test]
fn quantile_returns_reasonable_estimate() {
let mut h = Histogram::with_buckets(&[1.0, 10.0, 100.0]);
for _ in 0..100 {
h.record(5.0);
}
let p50 = h.quantile(0.5);
assert!((1.0..10.0).contains(&p50), "p50 = {p50}");
}
#[test]
fn merge_combines_counts() {
let mut a = Histogram::with_buckets(&[1.0, 10.0]);
let mut b = Histogram::with_buckets(&[1.0, 10.0]);
a.record(0.5);
a.record(5.0);
b.record(5.0);
b.record(50.0);
a.merge(&b).unwrap();
let counts: Vec<u64> = a.buckets().map(|(_, c)| c).collect();
assert_eq!(counts, vec![1, 2, 1]);
assert_eq!(a.samples(), 4);
}
#[test]
fn merge_rejects_mismatched_boundaries() {
let mut a = Histogram::with_buckets(&[1.0]);
let b = Histogram::with_buckets(&[2.0]);
assert_eq!(a.merge(&b).unwrap_err(), HistogramError::BoundariesMismatch);
}
#[test]
fn log_spaced_geometric_progression() {
let h = Histogram::log_spaced(1.0, 1000.0, 3);
let edges: Vec<f64> = h.buckets().take(3).map(|(b, _)| b).collect();
assert!((edges[0] - 1.0).abs() < 1e-6);
assert!((edges[1] - 10.0).abs() < 1e-6);
assert!((edges[2] - 100.0).abs() < 1e-6);
}
#[test]
fn mean_min_max() {
let mut h = Histogram::with_buckets(&[10.0]);
h.record(1.0);
h.record(2.0);
h.record(3.0);
assert!((h.mean() - 2.0).abs() < 1e-9);
assert_eq!(h.min(), 1.0);
assert_eq!(h.max(), 3.0);
}
}