nodedb_types/approx/
tdigest.rs1#[derive(Debug, Clone, Copy, zerompk::ToMessagePack, zerompk::FromMessagePack)]
7struct Centroid {
8 mean: f64,
9 count: u64,
10}
11
12#[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 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 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 pub fn centroids(&self) -> Vec<(f64, u64)> {
90 self.centroids.iter().map(|c| (c.mean, c.count)).collect()
91 }
92
93 pub fn memory_bytes(&self) -> usize {
95 std::mem::size_of::<Self>() + self.centroids.capacity() * std::mem::size_of::<Centroid>()
96 }
97
98 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 #[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 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}