use crate::accum::{Accumulator, Mergeable};
use crate::error::StatError;
#[derive(Debug, Clone, Copy)]
struct Centroid {
mean: f64,
weight: f64,
}
#[derive(Debug, Clone)]
pub struct TDigest {
centroids: Vec<Centroid>,
ingest_buf: Vec<f64>,
total_weight: f64,
n: u64,
min: f64,
max: f64,
delta: f64,
buf_cap: usize,
}
#[derive(Debug, Clone)]
pub struct TDigestResult {
centroids: Vec<Centroid>,
total_weight: f64,
n: u64,
min: f64,
max: f64,
}
fn k1(q: f64, delta: f64) -> f64 {
(delta / (2.0 * core::f64::consts::PI)) * libm::asin(2.0 * q - 1.0)
}
fn merge_sorted_centroids(a: &[Centroid], b: &[Centroid]) -> Vec<Centroid> {
let mut out = Vec::with_capacity(a.len() + b.len());
let (mut i, mut j) = (0, 0);
while i < a.len() && j < b.len() {
if a[i].mean <= b[j].mean {
out.push(a[i]);
i += 1;
} else {
out.push(b[j]);
j += 1;
}
}
out.extend_from_slice(&a[i..]);
out.extend_from_slice(&b[j..]);
out
}
fn compress(centroids: &mut Vec<Centroid>, total: f64, delta: f64) {
if centroids.len() <= 1 {
return;
}
let mut out: Vec<Centroid> = Vec::with_capacity(centroids.len());
let mut q_lo = 0.0_f64;
let mut current = centroids[0];
for &c in ¢roids[1..] {
let q_hi = (q_lo * total + current.weight + c.weight) / total;
if k1(q_hi, delta) - k1(q_lo, delta) <= 1.0 {
let new_w = current.weight + c.weight;
current.mean = (current.mean * current.weight + c.mean * c.weight) / new_w;
current.weight = new_w;
} else {
q_lo += current.weight / total;
out.push(current);
current = c;
}
}
out.push(current);
*centroids = out;
}
fn quantile_interior(centroids: &[Centroid], total: f64, min: f64, max: f64, q: f64) -> f64 {
if centroids.len() == 1 {
return centroids[0].mean;
}
let r = q * total;
let mut cum = 0.0_f64; let mut prev_pos = 0.0_f64;
let mut prev_mean = min; for c in centroids {
let pos = cum + c.weight / 2.0;
if r < pos {
let span = pos - prev_pos;
let t = if span > 0.0 { (r - prev_pos) / span } else { 0.0 };
return prev_mean + t * (c.mean - prev_mean);
}
prev_pos = pos;
prev_mean = c.mean;
cum += c.weight;
}
let span = total - prev_pos;
let t = if span > 0.0 { (r - prev_pos) / span } else { 0.0 };
prev_mean + t * (max - prev_mean)
}
fn cdf_core(centroids: &[Centroid], total: f64, n: u64, min: f64, max: f64, x: f64) -> f64 {
if n == 0 {
return f64::NAN;
}
if x < min {
return 0.0;
}
if x > max {
return 1.0;
}
if centroids.is_empty() {
return if x >= min { 1.0 } else { 0.0 };
}
let mut cum = 0.0_f64;
let mut prev_pos = 0.0_f64;
let mut prev_mean = min;
for c in centroids {
let pos = cum + c.weight / 2.0;
if x < c.mean {
let span = c.mean - prev_mean;
let t = if span > 0.0 { (x - prev_mean) / span } else { 0.0 };
return (prev_pos + t * (pos - prev_pos)) / total;
}
prev_pos = pos;
prev_mean = c.mean;
cum += c.weight;
}
let span = max - prev_mean;
let t = if span > 0.0 { (x - prev_mean) / span } else { 1.0 };
(prev_pos + t * (total - prev_pos)) / total
}
pub fn quantile_edges(digest: &TDigest, k: usize) -> Result<Vec<f64>, StatError> {
if k < 1 {
return Err(StatError::DomainError("quantile_edges requires k >= 1"));
}
if digest.count() == 0 {
return Err(StatError::DomainError("quantile_edges on empty digest"));
}
let mut edges = Vec::with_capacity(k + 1);
for i in 0..=k {
let q = i as f64 / k as f64;
edges.push(digest.quantile(q)?);
}
Ok(edges)
}
impl TDigest {
pub fn new(delta: f64) -> Result<Self, StatError> {
if delta.is_nan() || delta < 1.0 {
return Err(StatError::DomainError("t-digest delta must be >= 1"));
}
Ok(TDigest {
centroids: Vec::new(),
ingest_buf: Vec::new(),
total_weight: 0.0,
n: 0,
min: f64::INFINITY,
max: f64::NEG_INFINITY,
delta,
buf_cap: (10.0 * delta) as usize,
})
}
pub fn default_delta() -> f64 {
100.0
}
pub fn empty() -> Self {
TDigest::new(TDigest::default_delta()).expect("default_delta() >= 1")
}
pub fn count(&self) -> u64 {
self.n
}
pub fn min(&self) -> f64 {
self.min
}
pub fn max(&self) -> f64 {
self.max
}
pub fn update(&mut self, x: f64) {
if x.is_nan() {
return;
}
self.ingest_buf.push(x);
self.total_weight += 1.0;
self.n += 1;
if x < self.min {
self.min = x;
}
if x > self.max {
self.max = x;
}
if self.ingest_buf.len() >= self.buf_cap {
self.flush();
}
}
fn flush(&mut self) {
if self.ingest_buf.is_empty() {
return;
}
self.ingest_buf
.sort_unstable_by(|a, b| a.partial_cmp(b).expect("buffer is NaN-free"));
let fresh: Vec<Centroid> = self
.ingest_buf
.iter()
.map(|&v| Centroid { mean: v, weight: 1.0 })
.collect();
let mut all = merge_sorted_centroids(&self.centroids, &fresh);
compress(&mut all, self.total_weight, self.delta);
self.centroids = all;
self.ingest_buf.clear();
}
pub fn quantile(&self, q: f64) -> Result<f64, StatError> {
if !(0.0..=1.0).contains(&q) {
return Err(StatError::ProbabilityOutOfRange(q));
}
if self.n == 0 {
return Err(StatError::EmptyInput);
}
if q == 0.0 {
return Ok(self.min);
}
if q == 1.0 {
return Ok(self.max);
}
let mut flushed = self.clone();
flushed.flush();
Ok(quantile_interior(
&flushed.centroids,
flushed.total_weight,
flushed.min,
flushed.max,
q,
))
}
pub fn cdf(&self, x: f64) -> f64 {
let mut flushed = self.clone();
flushed.flush();
cdf_core(
&flushed.centroids,
flushed.total_weight,
flushed.n,
flushed.min,
flushed.max,
x,
)
}
#[cfg(test)]
pub(crate) fn default_delta_used(&self) -> f64 {
self.delta
}
#[cfg(test)]
pub(crate) fn flush_for_test(&mut self) {
self.flush();
}
#[cfg(test)]
pub(crate) fn total_weight_for_test(&self) -> f64 {
self.total_weight
}
#[cfg(test)]
pub(crate) fn centroid_len_for_test(&self) -> usize {
self.centroids.len()
}
}
impl Mergeable for TDigest {
fn merge(&mut self, other: &Self) {
debug_assert_eq!(self.delta, other.delta, "t-digest merge mixes deltas");
if self.n == 0 {
*self = other.clone();
return;
}
if other.n == 0 {
return;
}
self.flush();
let mut rhs = other.clone();
rhs.flush();
let total = self.total_weight + rhs.total_weight;
let mut all = merge_sorted_centroids(&self.centroids, &rhs.centroids);
compress(&mut all, total, self.delta);
self.centroids = all;
self.total_weight = total;
self.n += rhs.n;
self.min = self.min.min(rhs.min);
self.max = self.max.max(rhs.max);
}
}
impl Accumulator for TDigest {
type Item = f64;
type Output = TDigestResult;
fn empty() -> Self {
TDigest::empty()
}
fn update(&mut self, x: f64) {
TDigest::update(self, x);
}
fn finalize(&self) -> TDigestResult {
let mut flushed = self.clone();
flushed.flush();
TDigestResult {
centroids: flushed.centroids,
total_weight: flushed.total_weight,
n: flushed.n,
min: flushed.min,
max: flushed.max,
}
}
}
impl TDigestResult {
pub fn quantile(&self, q: f64) -> Result<f64, StatError> {
if !(0.0..=1.0).contains(&q) {
return Err(StatError::ProbabilityOutOfRange(q));
}
if self.n == 0 {
return Err(StatError::EmptyInput);
}
if q == 0.0 {
return Ok(self.min);
}
if q == 1.0 {
return Ok(self.max);
}
Ok(quantile_interior(
&self.centroids,
self.total_weight,
self.min,
self.max,
q,
))
}
pub fn cdf(&self, x: f64) -> f64 {
cdf_core(&self.centroids, self.total_weight, self.n, self.min, self.max, x)
}
pub fn count(&self) -> u64 {
self.n
}
pub fn min(&self) -> f64 {
self.min
}
pub fn max(&self) -> f64 {
self.max
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_rejects_bad_delta() {
assert_eq!(TDigest::new(0.5).unwrap_err(), StatError::DomainError("t-digest delta must be >= 1"));
assert_eq!(TDigest::new(0.0).unwrap_err(), StatError::DomainError("t-digest delta must be >= 1"));
assert_eq!(TDigest::new(-3.0).unwrap_err(), StatError::DomainError("t-digest delta must be >= 1"));
assert_eq!(TDigest::new(f64::NAN).unwrap_err(), StatError::DomainError("t-digest delta must be >= 1"));
assert!(TDigest::new(1.0).is_ok());
assert!(TDigest::new(100.0).is_ok());
}
#[test]
fn empty_identity_fields() {
let d = TDigest::empty();
assert_eq!(d.count(), 0);
assert_eq!(d.min(), f64::INFINITY);
assert_eq!(d.max(), f64::NEG_INFINITY);
assert_eq!(d.default_delta_used(), TDigest::default_delta());
}
#[test]
fn default_delta_is_100() {
assert_eq!(TDigest::default_delta(), 100.0);
}
#[test]
fn k1_monotonic_and_bounded() {
let delta = 100.0;
assert!((k1(0.0, delta) - (-delta / 4.0)).abs() < 1e-9);
assert!((k1(1.0, delta) - (delta / 4.0)).abs() < 1e-9);
assert!((k1(0.5, delta)).abs() < 1e-9); let mut prev = f64::NEG_INFINITY;
for i in 0..=100 {
let q = i as f64 / 100.0;
let k = k1(q, delta);
assert!(k > prev, "k1 not increasing at q={q}");
assert!(k >= -delta / 4.0 - 1e-9 && k <= delta / 4.0 + 1e-9);
prev = k;
}
}
#[test]
fn ingest_single_value() {
let mut d = TDigest::new(100.0).unwrap();
d.update(7.0);
d.flush_for_test();
assert_eq!(d.count(), 1);
assert_eq!(d.min(), 7.0);
assert_eq!(d.max(), 7.0);
assert_eq!(d.total_weight_for_test(), 1.0);
}
#[test]
fn ingest_counts_and_extremes() {
let mut d = TDigest::new(100.0).unwrap();
for x in [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0] {
d.update(x);
}
d.flush_for_test();
assert_eq!(d.count(), 8);
assert_eq!(d.min(), 1.0);
assert_eq!(d.max(), 9.0);
assert_eq!(d.total_weight_for_test(), 8.0);
}
#[test]
fn ingest_omits_nan_without_error() {
let mut d = TDigest::new(100.0).unwrap();
d.update(2.0);
d.update(f64::NAN);
d.update(4.0);
d.flush_for_test();
assert_eq!(d.count(), 2);
assert_eq!(d.min(), 2.0);
assert_eq!(d.max(), 4.0);
}
#[test]
fn ingest_duplicates() {
let mut d = TDigest::new(100.0).unwrap();
for _ in 0..1000 {
d.update(5.0);
}
d.flush_for_test();
assert_eq!(d.count(), 1000);
assert_eq!(d.total_weight_for_test(), 1000.0);
assert_eq!(d.min(), 5.0);
assert_eq!(d.max(), 5.0);
}
#[test]
fn compress_reduces_centroid_count() {
let mut d = TDigest::new(10.0).unwrap();
for i in 0..10_000 {
d.update(i as f64);
}
d.flush_for_test();
assert!(
d.centroid_len_for_test() < 10_000,
"compress did not merge centroids: {}",
d.centroid_len_for_test()
);
assert_eq!(d.total_weight_for_test(), 10_000.0);
}
#[test]
fn quantile_endpoints_exact() {
let mut d = TDigest::new(100.0).unwrap();
for x in [10.0, 20.0, 30.0, 40.0, 50.0] {
d.update(x);
}
assert_eq!(d.quantile(0.0).unwrap(), 10.0); assert_eq!(d.quantile(1.0).unwrap(), 50.0); let median = d.quantile(0.5).unwrap();
assert!((median - 30.0).abs() < 1.0, "median {median} off");
}
#[test]
fn quantile_rejects_bad_q() {
let mut d = TDigest::new(100.0).unwrap();
d.update(1.0);
assert_eq!(d.quantile(-0.1), Err(StatError::ProbabilityOutOfRange(-0.1)));
assert_eq!(d.quantile(1.5), Err(StatError::ProbabilityOutOfRange(1.5)));
}
#[test]
fn quantile_empty_errors() {
let d = TDigest::empty();
assert_eq!(d.quantile(0.5), Err(StatError::EmptyInput));
}
#[test]
fn cdf_endpoints_and_empty() {
let mut d = TDigest::new(100.0).unwrap();
for x in [10.0, 20.0, 30.0, 40.0, 50.0] {
d.update(x);
}
assert_eq!(d.cdf(5.0), 0.0); assert_eq!(d.cdf(99.0), 1.0); assert!(TDigest::empty().cdf(0.0).is_nan());
}
#[test]
fn cdf_monotone() {
let mut d = TDigest::new(100.0).unwrap();
for i in 0..500 {
d.update((i as f64 * 0.123).sin().abs());
}
let mut prev = f64::NEG_INFINITY;
for i in 0..=200 {
let x = -0.5 + 2.0 * (i as f64 / 200.0);
let c = d.cdf(x);
assert!(c >= prev - 1e-12, "cdf decreased at x={x}: {c} < {prev}");
assert!((0.0..=1.0).contains(&c));
prev = c;
}
}
#[test]
fn finalize_snapshot_matches_digest_queries() {
let mut d = TDigest::new(100.0).unwrap();
for i in 0..1000 {
d.update((i as f64 * 0.317).sin().abs());
}
let snap = d.finalize();
assert_eq!(snap.count(), d.count());
assert_eq!(snap.min(), d.min());
assert_eq!(snap.max(), d.max());
for q in [0.0, 0.25, 0.5, 0.75, 1.0] {
let a = d.quantile(q).unwrap();
let b = snap.quantile(q).unwrap();
assert!((a - b).abs() < 1e-12, "q={q}: digest {a} vs snapshot {b}");
}
}
fn sample(n: usize) -> Vec<f64> {
(0..n).map(|i| ((i as f64 + 1.0) * 0.6180339887).sin().abs() * 100.0).collect()
}
fn digest_of(xs: &[f64]) -> TDigest {
let mut d = TDigest::new(100.0).unwrap();
for &x in xs {
d.update(x);
}
d
}
#[test]
fn merge_empty_absorb() {
let xs = sample(2000);
let full = digest_of(&xs);
let mut left = TDigest::empty();
left.merge(&full);
let mut right = full.clone();
right.merge(&TDigest::empty());
for q in [0.1, 0.5, 0.9] {
assert_eq!(left.quantile(q).unwrap(), full.quantile(q).unwrap());
assert_eq!(right.quantile(q).unwrap(), full.quantile(q).unwrap());
}
assert_eq!(left.count(), full.count());
assert_eq!(right.count(), full.count());
}
#[test]
fn merge_associative_left_vs_right_fold() {
let xs = sample(9000);
let (a, b, c) = (&xs[0..3000], &xs[3000..6000], &xs[6000..9000]);
let (da, db, dc) = (digest_of(a), digest_of(b), digest_of(c));
let mut left = da.clone();
left.merge(&db);
left.merge(&dc);
let mut bc = db.clone();
bc.merge(&dc);
let mut right = da.clone();
right.merge(&bc);
for q in [0.05, 0.25, 0.5, 0.75, 0.95] {
let l = left.quantile(q).unwrap();
let r = right.quantile(q).unwrap();
let lr = left.cdf(l);
let rr = right.cdf(r);
assert!((lr - rr).abs() <= 0.0075, "q={q}: rank {lr} vs {rr}");
}
assert_eq!(left.count(), 9000);
assert_eq!(right.count(), 9000);
assert_eq!(left.min(), right.min());
assert_eq!(left.max(), right.max());
}
#[test]
fn merge_chunked_equals_streaming() {
let xs = sample(8000);
let streamed = digest_of(&xs);
let mut chunked = TDigest::empty();
for chunk in xs.chunks(1000) {
chunked.merge(&digest_of(chunk));
}
assert_eq!(chunked.count(), streamed.count());
assert_eq!(chunked.min(), streamed.min());
assert_eq!(chunked.max(), streamed.max());
for q in [0.05, 0.25, 0.5, 0.75, 0.95] {
let target = streamed.quantile(q).unwrap();
assert!((chunked.cdf(target) - q).abs() <= 0.0075, "q={q}");
}
}
#[test]
fn quantile_edges_shape_and_endpoints() {
let xs = sample(5000);
let d = digest_of(&xs);
let edges = quantile_edges(&d, 8).unwrap();
assert_eq!(edges.len(), 9); assert_eq!(edges[0], d.min());
assert_eq!(edges[8], d.max());
for w in edges.windows(2) {
assert!(w[1] >= w[0], "edges not sorted: {:?}", w);
}
}
#[test]
fn quantile_edges_k1_is_just_min_max() {
let d = digest_of(&sample(100));
let edges = quantile_edges(&d, 1).unwrap();
assert_eq!(edges, vec![d.min(), d.max()]);
}
#[test]
fn quantile_edges_rejects_k0_and_empty() {
let d = digest_of(&sample(100));
assert_eq!(
quantile_edges(&d, 0),
Err(StatError::DomainError("quantile_edges requires k >= 1"))
);
assert_eq!(
quantile_edges(&TDigest::empty(), 4),
Err(StatError::DomainError("quantile_edges on empty digest"))
);
}
}