use serde::{Deserialize, Serialize};
pub const CUSTOM_BUCKETS_SCHEMA: i8 = -53;
pub const MIN_SCHEMA: i8 = -4;
pub const MAX_SCHEMA: i8 = 8;
pub const MAX_EXACT_COUNT: f64 = 9_007_199_254_740_992.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ResetHint {
#[default]
Unknown,
Reset,
NoReset,
Gauge,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Bucket {
pub index: i32,
pub count: f64,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum HistogramError {
#[error(
"histogram schema {0} is not a valid resolution: expected {MIN_SCHEMA}..={MAX_SCHEMA} \
for exponential buckets, or {CUSTOM_BUCKETS_SCHEMA} for custom boundaries"
)]
InvalidSchema(i8),
#[error("histogram {what} is {value}, which is not a count")]
InvalidCount {
what: &'static str,
value: f64,
},
#[error(
"histogram {what} is {value}, above 2^53 — beyond that an f64 cannot \
distinguish consecutive integers, so the value could not be read back unchanged"
)]
CountNotExact {
what: &'static str,
value: f64,
},
#[error("histogram {side} buckets are not strictly ascending by index (at {index})")]
UnorderedBuckets {
side: &'static str,
index: i32,
},
#[error("histogram zero_threshold is {0}, which cannot bound an interval around zero")]
InvalidZeroThreshold(f64),
#[error("{0}")]
CustomValues(&'static str),
#[error(
"histogram buckets total {observed} but count is {declared}: a histogram \
whose parts exceed its whole cannot be interpolated"
)]
CountMismatch {
observed: f64,
declared: f64,
},
}
fn exp2i(k: i32) -> f64 {
const MAX_NORMAL_EXP: i32 = 1023;
const MIN_NORMAL_EXP: i32 = -1022;
const MIN_SUBNORMAL_EXP: i32 = -1074;
if k > MAX_NORMAL_EXP {
return f64::INFINITY;
}
if k < MIN_SUBNORMAL_EXP {
return 0.0;
}
if k >= MIN_NORMAL_EXP {
#[allow(clippy::cast_sign_loss)] return f64::from_bits(((k + 1023) as u64) << 52);
}
#[allow(clippy::cast_sign_loss)] f64::from_bits(1u64 << ((k - MIN_SUBNORMAL_EXP) as u32))
}
fn root_of_two(n: u32) -> f64 {
let mut v = 2.0_f64;
for _ in 0..n {
v = v.sqrt();
}
v
}
fn frac_power_of_two(r: u32, s: u32) -> f64 {
let mut acc = 1.0_f64;
for bit in 0..s {
if r & (1 << bit) != 0 {
acc *= root_of_two(s - bit);
}
}
acc
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Histogram {
pub schema: i8,
pub zero_threshold: f64,
pub zero_count: f64,
pub count: f64,
pub sum: f64,
pub positive: Vec<Bucket>,
pub negative: Vec<Bucket>,
pub custom_values: Vec<f64>,
pub reset_hint: ResetHint,
}
impl Default for Histogram {
fn default() -> Self {
Self::empty(0)
}
}
impl Histogram {
#[must_use]
pub fn empty(schema: i8) -> Self {
Self {
schema,
zero_threshold: 0.0,
zero_count: 0.0,
count: 0.0,
sum: 0.0,
positive: Vec::new(),
negative: Vec::new(),
custom_values: Vec::new(),
reset_hint: ResetHint::Unknown,
}
}
#[must_use]
pub const fn has_custom_buckets(&self) -> bool {
self.schema == CUSTOM_BUCKETS_SCHEMA
}
#[must_use]
pub fn base(schema: i8) -> f64 {
if schema <= 0 {
exp2i(1i32 << (-i32::from(schema)).min(30))
} else {
root_of_two(u32::from(schema.unsigned_abs()))
}
}
#[must_use]
pub fn index_for(schema: i8, value: f64) -> i32 {
debug_assert!(value > 0.0 && value.is_finite());
let scaled = value.log2() * (2.0_f64).powi(i32::from(schema));
let nearest = scaled.round();
let tolerance = 1e-12 * scaled.abs().max(1.0);
let i = if (scaled - nearest).abs() <= tolerance {
nearest
} else {
scaled.ceil()
};
i as i32
}
#[must_use]
pub fn bucket_bounds(&self, index: i32, negative: bool) -> (f64, f64) {
if self.has_custom_buckets() {
let n = self.custom_values.len();
let i = index as usize;
let lower = if i == 0 {
f64::NEG_INFINITY
} else {
self.custom_values
.get(i - 1)
.copied()
.unwrap_or(f64::NEG_INFINITY)
};
let upper = if i >= n {
f64::INFINITY
} else {
self.custom_values.get(i).copied().unwrap_or(f64::INFINITY)
};
return (lower, upper);
}
let upper = Self::bound(self.schema, index);
let lower = Self::bound(self.schema, index - 1);
if negative {
(-upper, -lower)
} else {
(lower, upper)
}
}
#[must_use]
pub fn bound(schema: i8, index: i32) -> f64 {
let s = i32::from(schema);
if s <= 0 {
let shift = (-s).min(30);
return match index.checked_shl(shift as u32) {
Some(k) => exp2i(k),
None if index > 0 => f64::INFINITY,
None => 0.0,
};
}
let m = 1i32 << s;
let q = index.div_euclid(m);
let r = index.rem_euclid(m);
let frac = frac_power_of_two(r as u32, s as u32);
let scale = exp2i(q);
if scale == 0.0 || !scale.is_finite() {
return scale * frac.signum().abs();
}
scale * frac
}
pub fn observe(&mut self, value: f64) {
self.count += 1.0;
self.sum += value;
if value.abs() <= self.zero_threshold {
self.zero_count += 1.0;
return;
}
let (side, magnitude) = if value < 0.0 {
(&mut self.negative, -value)
} else {
(&mut self.positive, value)
};
let index = if self.schema == CUSTOM_BUCKETS_SCHEMA {
self.custom_values
.iter()
.position(|&b| value <= b)
.unwrap_or(self.custom_values.len()) as i32
} else {
Self::index_for(self.schema, magnitude)
};
match side.binary_search_by_key(&index, |b| b.index) {
Ok(at) => side[at].count += 1.0,
Err(at) => side.insert(at, Bucket { index, count: 1.0 }),
}
}
pub fn canonicalise(&mut self) {
for side in [&mut self.positive, &mut self.negative] {
side.sort_by_key(|b| b.index);
side.dedup_by(|b, a| {
if a.index == b.index {
a.count += b.count;
true
} else {
false
}
});
side.retain(|b| b.count != 0.0);
}
}
pub fn validate(&self) -> Result<(), HistogramError> {
if self.schema != CUSTOM_BUCKETS_SCHEMA && !(MIN_SCHEMA..=MAX_SCHEMA).contains(&self.schema)
{
return Err(HistogramError::InvalidSchema(self.schema));
}
if self.has_custom_buckets() {
if self.custom_values.is_empty() {
return Err(HistogramError::CustomValues(
"schema -53 means the bucket boundaries are listed, and none were given",
));
}
if self
.custom_values
.windows(2)
.any(|w| !matches!(w[0].partial_cmp(&w[1]), Some(std::cmp::Ordering::Less)))
{
return Err(HistogramError::CustomValues(
"custom bucket boundaries must be strictly ascending",
));
}
if !self.negative.is_empty() {
return Err(HistogramError::CustomValues(
"custom buckets carry their own boundaries, which may be negative, \
so the negative bucket list must be empty",
));
}
} else if !self.custom_values.is_empty() {
return Err(HistogramError::CustomValues(
"custom bucket boundaries were given for an exponential schema, \
where the boundaries are computed from the schema",
));
}
if !self.zero_threshold.is_finite() || self.zero_threshold < 0.0 {
return Err(HistogramError::InvalidZeroThreshold(self.zero_threshold));
}
let mut total = 0.0;
for (what, v) in [("count", self.count), ("zero_count", self.zero_count)] {
if !v.is_finite() || v < 0.0 {
return Err(HistogramError::InvalidCount { what, value: v });
}
if v > MAX_EXACT_COUNT {
return Err(HistogramError::CountNotExact { what, value: v });
}
}
total += self.zero_count;
for (side, name) in [(&self.positive, "positive"), (&self.negative, "negative")] {
let mut previous: Option<i32> = None;
for b in side {
if !b.count.is_finite() || b.count < 0.0 {
return Err(HistogramError::InvalidCount {
what: "bucket count",
value: b.count,
});
}
if b.count > MAX_EXACT_COUNT {
return Err(HistogramError::CountNotExact {
what: "bucket count",
value: b.count,
});
}
if previous.is_some_and(|p| p >= b.index) {
return Err(HistogramError::UnorderedBuckets {
side: name,
index: b.index,
});
}
previous = Some(b.index);
total += b.count;
}
}
if total > self.count {
return Err(HistogramError::CountMismatch {
observed: total,
declared: self.count,
});
}
Ok(())
}
#[must_use]
pub fn avg(&self) -> f64 {
if self.count == 0.0 {
return f64::NAN;
}
self.sum / self.count
}
fn representative(&self, index: i32, negative: bool) -> f64 {
let (lower, upper) = self.bucket_bounds(index, negative);
if self.has_custom_buckets() {
if lower.is_infinite() {
return upper;
}
if upper.is_infinite() {
return lower;
}
return (upper + lower) / 2.0;
}
if lower <= 0.0 && upper >= 0.0 {
return 0.0;
}
let g = (upper.abs() * lower.abs()).sqrt();
if negative { -g } else { g }
}
#[must_use]
pub fn variance(&self) -> f64 {
if self.count == 0.0 || self.sum.is_nan() {
return f64::NAN;
}
let mean = self.avg();
let mut acc = 0.0;
if self.zero_count > 0.0 {
acc += self.zero_count * (0.0 - mean).powi(2);
}
for (side, negative) in [(&self.positive, false), (&self.negative, true)] {
for b in side {
if b.count == 0.0 {
continue;
}
acc += b.count * (self.representative(b.index, negative) - mean).powi(2);
}
}
acc / self.count
}
#[must_use]
pub fn stddev(&self) -> f64 {
self.variance().sqrt()
}
fn ordered_buckets(&self) -> Vec<(f64, f64, f64)> {
let mut out: Vec<(f64, f64, f64)> = Vec::with_capacity(
self.negative.len() + self.positive.len() + usize::from(self.zero_count > 0.0),
);
for b in self.negative.iter().rev() {
let (lo, hi) = self.bucket_bounds(b.index, true);
out.push((lo, hi, b.count));
}
if self.zero_count > 0.0 {
out.push((-self.zero_threshold, self.zero_threshold, self.zero_count));
}
for b in &self.positive {
let (lo, hi) = self.bucket_bounds(b.index, false);
out.push((lo, hi, b.count));
}
out
}
fn interpolate(&self, lower: f64, upper: f64, fraction: f64) -> f64 {
if fraction <= 0.0 {
return lower;
}
if fraction >= 1.0 {
return upper;
}
let linear = lower + (upper - lower) * fraction;
if self.has_custom_buckets() || (lower <= 0.0 && upper >= 0.0) {
return linear;
}
if !lower.is_finite() || !upper.is_finite() || lower == 0.0 || upper == 0.0 {
return linear;
}
let interpolated = if lower < 0.0 {
let log_hi = (-lower).log2();
let log_lo = (-upper).log2();
-(2.0_f64).powf(log_hi + (log_lo - log_hi) * fraction)
} else {
let log_lo = lower.log2();
let log_hi = upper.log2();
(2.0_f64).powf(log_lo + (log_hi - log_lo) * fraction)
};
interpolated.clamp(lower, upper)
}
#[must_use]
pub fn quantile(&self, q: f64) -> f64 {
if q.is_nan() || self.count == 0.0 {
return f64::NAN;
}
if q < 0.0 {
return f64::NEG_INFINITY;
}
if q > 1.0 {
return f64::INFINITY;
}
let buckets = self.ordered_buckets();
if buckets.is_empty() {
return f64::NAN;
}
let rank = q * self.count;
let mut cumulative = 0.0;
for (lower, upper, count) in &buckets {
if cumulative + count >= rank {
let fraction = if *count == 0.0 {
0.0
} else {
((rank - cumulative) / count).clamp(0.0, 1.0)
};
if !lower.is_finite() {
return *upper;
}
if !upper.is_finite() {
return *lower;
}
return self.interpolate(*lower, *upper, fraction);
}
cumulative += count;
}
let (_, upper, _) = buckets[buckets.len() - 1];
if upper.is_finite() {
upper
} else {
f64::INFINITY
}
}
#[must_use]
pub fn fraction(&self, lower: f64, upper: f64) -> f64 {
if self.count == 0.0 || lower.is_nan() || upper.is_nan() {
return f64::NAN;
}
if lower >= upper {
return 0.0;
}
(self.rank_at(upper) - self.rank_at(lower)) / self.count
}
fn rank_at(&self, value: f64) -> f64 {
if value == f64::NEG_INFINITY {
return 0.0;
}
if value == f64::INFINITY {
return self.count;
}
let mut cumulative = 0.0;
for (lo, hi, count) in self.ordered_buckets() {
if value <= lo {
break;
}
if value >= hi {
cumulative += count;
continue;
}
let fraction = if self.has_custom_buckets() || (lo <= 0.0 && hi >= 0.0) {
if hi == lo {
0.0
} else {
(value - lo) / (hi - lo)
}
} else if lo > 0.0 {
let (a, b) = (lo.log2(), hi.log2());
if (b - a).abs() < f64::EPSILON {
0.0
} else {
(value.log2() - a) / (b - a)
}
} else {
let (a, b) = ((-lo).log2(), (-hi).log2());
if (b - a).abs() < f64::EPSILON {
0.0
} else {
((-value).log2() - a) / (b - a)
}
};
cumulative += count * fraction.clamp(0.0, 1.0);
break;
}
cumulative
}
#[must_use]
pub fn classic_buckets(&self) -> Vec<(f64, f64)> {
let mut out = Vec::new();
let mut cumulative = 0.0;
for (_, upper, count) in self.ordered_buckets() {
cumulative += count;
if upper.is_finite() {
out.push((upper, cumulative));
}
}
out.push((f64::INFINITY, self.count));
out
}
pub fn merge(&mut self, other: &Self) -> Result<(), HistogramError> {
if self.schema != other.schema {
return Err(HistogramError::CustomValues(
"cannot merge histograms of different schemas without re-bucketing, \
which changes the resolution and must be asked for explicitly",
));
}
if self.has_custom_buckets() && self.custom_values != other.custom_values {
return Err(HistogramError::CustomValues(
"cannot merge custom-bucket histograms with different boundaries",
));
}
if self.count == 0.0 && self.positive.is_empty() && self.negative.is_empty() {
self.zero_threshold = other.zero_threshold;
} else if (self.zero_threshold - other.zero_threshold).abs() > f64::EPSILON {
return Err(HistogramError::CustomValues(
"cannot merge histograms with different zero thresholds: the zero \
bucket would cover different intervals on either side",
));
}
self.count += other.count;
self.sum += other.sum;
self.zero_count += other.zero_count;
for (mine, theirs) in [
(&mut self.positive, &other.positive),
(&mut self.negative, &other.negative),
] {
for b in theirs {
match mine.binary_search_by_key(&b.index, |x| x.index) {
Ok(at) => mine[at].count += b.count,
Err(at) => mine.insert(at, *b),
}
}
}
self.reset_hint = ResetHint::Unknown;
Ok(())
}
}
#[cfg(test)]
mod tests;