Skip to main content

nodedb_types/approx/
tdigest.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! TDigest — approximate percentile estimation (mergeable centroids).
4
5/// Centroid in the t-digest: represents a cluster of values.
6#[derive(Debug, Clone, Copy, zerompk::ToMessagePack, zerompk::FromMessagePack)]
7struct Centroid {
8    mean: f64,
9    count: u64,
10}
11
12/// T-digest approximate quantile estimator.
13///
14/// Maintains a sorted set of centroids that approximate the data distribution.
15/// Accurate at the extremes (p1, p99) and reasonable in the middle.
16/// Mergeable across partitions and shards.
17#[derive(Debug, zerompk::ToMessagePack, zerompk::FromMessagePack)]
18pub struct TDigest {
19    centroids: Vec<Centroid>,
20    max_centroids: usize,
21    total_count: u64,
22}
23
24impl TDigest {
25    pub fn new() -> Self {
26        Self::with_compression(200)
27    }
28
29    pub fn with_compression(max_centroids: usize) -> Self {
30        Self {
31            centroids: Vec::with_capacity(max_centroids),
32            max_centroids: max_centroids.max(10),
33            total_count: 0,
34        }
35    }
36
37    pub fn add(&mut self, value: f64) {
38        if value.is_nan() {
39            return;
40        }
41        self.centroids.push(Centroid {
42            mean: value,
43            count: 1,
44        });
45        self.total_count += 1;
46
47        if self.centroids.len() > self.max_centroids * 2 {
48            self.compress();
49        }
50    }
51
52    pub fn add_batch(&mut self, values: &[f64]) {
53        for &v in values {
54            self.add(v);
55        }
56    }
57
58    /// Estimate the value at a given quantile (0.0 to 1.0).
59    pub fn quantile(&self, q: f64) -> f64 {
60        let q = q.clamp(0.0, 1.0);
61        if self.centroids.is_empty() {
62            return f64::NAN;
63        }
64        self.compress_clone().quantile_sorted(q)
65    }
66
67    pub fn merge(&mut self, other: &TDigest) {
68        self.centroids.extend_from_slice(&other.centroids);
69        self.total_count += other.total_count;
70        if self.centroids.len() > self.max_centroids * 2 {
71            self.compress();
72        }
73    }
74
75    pub fn count(&self) -> u64 {
76        self.total_count
77    }
78
79    /// Add a pre-aggregated centroid (for merge/deserialization).
80    pub fn add_centroid(&mut self, mean: f64, count: u64) {
81        self.centroids.push(Centroid { mean, count });
82        self.total_count += count;
83        if self.centroids.len() > self.max_centroids * 2 {
84            self.compress();
85        }
86    }
87
88    /// Access centroids as (mean, count) pairs for serialization.
89    pub fn centroids(&self) -> Vec<(f64, u64)> {
90        self.centroids.iter().map(|c| (c.mean, c.count)).collect()
91    }
92
93    /// Approximate memory usage in bytes.
94    pub fn memory_bytes(&self) -> usize {
95        std::mem::size_of::<Self>() + self.centroids.capacity() * std::mem::size_of::<Centroid>()
96    }
97
98    /// Sort the centroids by mean, then merge the closest pairs down to
99    /// `max_centroids`. `quantile_sorted` reads them in sorted order, so the
100    /// sort runs even when no merge is needed.
101    fn compress(&mut self) {
102        self.centroids.sort_by(|a, b| a.mean.total_cmp(&b.mean));
103        if self.centroids.len() <= self.max_centroids {
104            return;
105        }
106
107        let target = self.max_centroids;
108        while self.centroids.len() > target {
109            let mut best_i = 0;
110            let mut best_gap = f64::INFINITY;
111            for i in 0..self.centroids.len() - 1 {
112                let gap = self.centroids[i + 1].mean - self.centroids[i].mean;
113                if gap < best_gap {
114                    best_gap = gap;
115                    best_i = i;
116                }
117            }
118            let a = self.centroids[best_i];
119            let b = self.centroids.remove(best_i + 1);
120            let total = a.count + b.count;
121            self.centroids[best_i] = Centroid {
122                mean: (a.mean * a.count as f64 + b.mean * b.count as f64) / total as f64,
123                count: total,
124            };
125        }
126    }
127
128    fn compress_clone(&self) -> TDigest {
129        let mut clone = self.clone_inner();
130        clone.compress();
131        clone
132    }
133
134    fn clone_inner(&self) -> TDigest {
135        TDigest {
136            centroids: self.centroids.clone(),
137            max_centroids: self.max_centroids,
138            total_count: self.total_count,
139        }
140    }
141
142    fn quantile_sorted(&self, q: f64) -> f64 {
143        if self.centroids.is_empty() {
144            return f64::NAN;
145        }
146        if self.centroids.len() == 1 {
147            return self.centroids[0].mean;
148        }
149
150        let target = q * self.total_count as f64;
151        let mut cumulative = 0.0;
152
153        for c in &self.centroids {
154            cumulative += c.count as f64;
155            if cumulative >= target {
156                return c.mean;
157            }
158        }
159
160        self.centroids.last().map_or(f64::NAN, |c| c.mean)
161    }
162}
163
164impl Default for TDigest {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn tdigest_empty() {
176        let td = TDigest::new();
177        assert!(td.quantile(0.5).is_nan());
178    }
179
180    /// Fewer values than the centroid limit are never merged, and their
181    /// quantiles still come from the sorted order, not the insertion order.
182    #[test]
183    fn tdigest_quantile_below_the_centroid_limit_reads_sorted_values() {
184        let mut td = TDigest::new();
185        for i in 0..100 {
186            td.add(f64::from(i % 10));
187        }
188        let p50 = td.quantile(0.5);
189        assert!((4.0..=5.0).contains(&p50), "p50 expected 4..=5, got {p50}");
190        assert_eq!(td.quantile(0.0), 0.0);
191        assert_eq!(td.quantile(1.0), 9.0);
192    }
193
194    #[test]
195    fn tdigest_single_value() {
196        let mut td = TDigest::new();
197        td.add(42.0);
198        assert!((td.quantile(0.5) - 42.0).abs() < f64::EPSILON);
199    }
200
201    #[test]
202    fn tdigest_uniform() {
203        let mut td = TDigest::new();
204        for i in 0..10_000 {
205            td.add(i as f64);
206        }
207        let p50 = td.quantile(0.5);
208        assert!(
209            (4500.0..5500.0).contains(&p50),
210            "p50 expected ~5000, got {p50:.0}"
211        );
212        let p99 = td.quantile(0.99);
213        assert!(
214            (9800.0..10000.0).contains(&p99),
215            "p99 expected ~9900, got {p99:.0}"
216        );
217    }
218
219    #[test]
220    fn tdigest_merge() {
221        let mut a = TDigest::new();
222        let mut b = TDigest::new();
223        for i in 0..5000 {
224            a.add(i as f64);
225        }
226        for i in 5000..10000 {
227            b.add(i as f64);
228        }
229        a.merge(&b);
230        let p50 = a.quantile(0.5);
231        assert!(
232            (4000.0..6000.0).contains(&p50),
233            "merged p50 expected ~5000, got {p50:.0}"
234        );
235    }
236
237    #[test]
238    fn tdigest_msgpack_roundtrip_merge_semantics() {
239        let mut a = TDigest::new();
240        let mut b = TDigest::new();
241        for i in 0..500 {
242            a.add(i as f64);
243        }
244        for i in 500..1000 {
245            b.add(i as f64);
246        }
247
248        let bytes = zerompk::to_msgpack_vec(&a).expect("serialize TDigest");
249        let mut a_prime: TDigest = zerompk::from_msgpack(&bytes).expect("deserialize TDigest");
250
251        a_prime.merge(&b);
252        a.merge(&b);
253
254        let p50_orig = a.quantile(0.5);
255        let p50_rt = a_prime.quantile(0.5);
256        // Both are approximate; require within 10% of each other.
257        let diff = (p50_orig - p50_rt).abs() / p50_orig.abs().max(1.0);
258        assert!(
259            diff < 0.10,
260            "roundtrip diverged: orig p50={p50_orig:.0}, roundtrip p50={p50_rt:.0}"
261        );
262    }
263}