dwara_core/analytics/sketch.rs
1//! Percentile sketches (PERF-09, #242): a DDSketch-based
2//! bounded-memory mergeable quantile sketch with relative-error
3//! guarantees, replacing the previous ring-buffer + nearest-rank
4//! approach in the live sketches.
5//!
6//! DDSketch (from Datadog) is a quantile sketch that provides
7//! relative-error guarantees: the returned quantile is within
8//! `(1 + alpha)` of the true quantile. This is ideal for latency
9//! distributions, which span several orders of magnitude (sub-ms to
10//! seconds), where absolute-error sketches (like t-digest with
11//! uniform centroids) lose resolution in the tail.
12//!
13//! # Algorithm
14//!
15//! DDSketch buckets values by their logarithm. The bucket index for a
16//! positive value `v` is `floor(log(v) / log(gamma))` where
17//! `gamma = (1 + alpha) / (1 - alpha)`. Each bucket stores a count.
18//! The number of buckets is bounded by the range of values (not the
19//! number of samples), so memory is bounded regardless of traffic
20//! volume. Negative values are not supported (latencies are
21//! non-negative); zero is handled as a special bucket.
22//!
23//! # Mergeability
24//!
25//! Two DDSketches with the same `alpha` can be merged by adding their
26//! bucket counts. This enables window-to-window aggregation and
27//! distributed merging (future: fleet-wide percentiles).
28//!
29//! # References
30//!
31//! - Charles Masson, Jee E. Rim, Homin K. Lee. "DDSketch: A Fast and
32//! Fully-Mergeable Quantile Sketch with Relative-Error Guarantees."
33//! arXiv:1908.10693 [cs.DB], 2019.
34
35use std::collections::BTreeMap;
36
37/// The default relative error (alpha). 0.01 means the returned
38/// quantile is within 1% of the true quantile. This gives good tail
39/// accuracy (p99 within 1% of the true p99) while keeping the bucket
40/// count bounded (roughly 1000 buckets for a 0.001ms to 10s range).
41const DEFAULT_ALPHA: f64 = 0.01;
42
43/// The default maximum number of buckets. If the bucket count would
44/// exceed this, the sketch collapses the lowest-count buckets
45/// (merging adjacent buckets). This bounds memory regardless of the
46/// value range.
47const DEFAULT_MAX_BUCKETS: usize = 8192;
48
49/// A DDSketch quantile sketch with relative-error guarantees.
50///
51/// The sketch stores counts in a `BTreeMap` keyed by bucket index.
52/// The bucket index for a positive value `v` is
53/// `floor(log(v) / log(gamma))` where `gamma = (1 + alpha) / (1 - alpha)`.
54/// Zero has a special bucket index of `i64::MIN`.
55pub struct DDSketch {
56 /// The relative error guarantee (alpha). The returned quantile is
57 /// within `(1 + alpha)` of the true quantile.
58 alpha: f64,
59 /// The logarithmic base: `gamma = (1 + alpha) / (1 - alpha)`.
60 /// Bucket index = `floor(log(v) / log(gamma))`.
61 gamma: f64,
62 /// `1.0 / log(gamma)` — precomputed for the hot path.
63 inv_log_gamma: f64,
64 /// The maximum number of buckets before collapse.
65 max_buckets: usize,
66 /// Bucket counts, keyed by bucket index. The BTreeMap keeps
67 /// buckets sorted by index for percentile queries.
68 buckets: BTreeMap<i64, u64>,
69 /// Total count across all buckets (cached for O(1) access).
70 total_count: u64,
71 /// Sum of all values (for mean computation). Stored as f64 to
72 /// avoid precision loss over many samples.
73 sum: f64,
74}
75
76impl DDSketch {
77 /// Create a new DDSketch with the default relative error (1%)
78 /// and max buckets (8192).
79 pub fn new() -> Self {
80 Self::with_alpha(DEFAULT_ALPHA, DEFAULT_MAX_BUCKETS)
81 }
82
83 /// Create a new DDSketch with the given relative error and max
84 /// buckets. `alpha` must be in (0, 1).
85 pub fn with_alpha(alpha: f64, max_buckets: usize) -> Self {
86 assert!(alpha > 0.0 && alpha < 1.0, "alpha must be in (0, 1)");
87 let gamma = (1.0 + alpha) / (1.0 - alpha);
88 DDSketch {
89 alpha,
90 gamma,
91 inv_log_gamma: 1.0 / gamma.ln(),
92 max_buckets,
93 buckets: BTreeMap::new(),
94 total_count: 0,
95 sum: 0.0,
96 }
97 }
98
99 /// The relative error guarantee (alpha).
100 pub fn alpha(&self) -> f64 {
101 self.alpha
102 }
103
104 /// The total number of samples recorded.
105 pub fn count(&self) -> u64 {
106 self.total_count
107 }
108
109 /// The arithmetic mean of all recorded samples.
110 pub fn mean(&self) -> f64 {
111 if self.total_count == 0 {
112 0.0
113 } else {
114 self.sum / self.total_count as f64
115 }
116 }
117
118 /// Compute the bucket index for a non-negative value. Zero maps
119 /// to `i64::MIN` (a special bucket). Negative values are clamped
120 /// to zero (latencies should never be negative).
121 fn bucket_index(&self, v: f64) -> i64 {
122 if v <= 0.0 {
123 return i64::MIN;
124 }
125 let idx = (v.ln() * self.inv_log_gamma).floor() as i64;
126 // Clamp to avoid overflow on extreme values.
127 idx.clamp(i64::MIN + 1, i64::MAX)
128 }
129
130 /// The lower bound of the value range for a bucket index (the
131 /// value at the bucket boundary). Used for percentile
132 /// interpolation.
133 fn bucket_lower_bound(&self, idx: i64) -> f64 {
134 if idx == i64::MIN {
135 return 0.0;
136 }
137 self.gamma.powf(idx as f64)
138 }
139
140 /// Record a value into the sketch. The value is non-negative
141 /// (latencies). Negative values are treated as zero.
142 pub fn add(&mut self, v: f64) {
143 let idx = self.bucket_index(v);
144 *self.buckets.entry(idx).or_insert(0) += 1;
145 self.total_count += 1;
146 self.sum += v.max(0.0);
147 if self.buckets.len() > self.max_buckets {
148 self.collapse();
149 }
150 }
151
152 /// Collapse the two adjacent buckets with the smallest combined
153 /// count, reducing the bucket count by one. This bounds memory
154 /// when the value range is very wide. The collapse merges the
155 /// lower bucket into the higher one (preserving the higher
156 /// bucket's index, which represents the larger values — the tail
157 /// is more important for percentile accuracy).
158 fn collapse(&mut self) {
159 // Find the pair of adjacent buckets with the smallest combined
160 // count. Iterate in sorted order (BTreeMap).
161 let mut min_sum = u64::MAX;
162 let mut merge_key = None;
163 let keys: Vec<i64> = self.buckets.keys().copied().collect();
164 for window in keys.windows(2) {
165 let a = self.buckets[&window[0]];
166 let b = self.buckets[&window[1]];
167 let combined = a.saturating_add(b);
168 if combined < min_sum {
169 min_sum = combined;
170 merge_key = Some((window[0], window[1]));
171 }
172 }
173 if let Some((lower, higher)) = merge_key {
174 let lower_count = self.buckets.remove(&lower).unwrap_or(0);
175 *self.buckets.entry(higher).or_insert(0) += lower_count;
176 }
177 }
178
179 /// Estimate the quantile at `p` (0.0 to 1.0). Returns 0.0 if no
180 /// samples have been recorded. The returned value is within
181 /// `(1 + alpha)` of the true quantile.
182 ///
183 /// The estimate is the lower bound of the bucket containing the
184 /// target rank. This is a conservative (lower) estimate; the true
185 /// quantile is in `[estimate, estimate * gamma]`.
186 pub fn quantile(&self, p: f64) -> f64 {
187 if self.total_count == 0 {
188 return 0.0;
189 }
190 let p = p.clamp(0.0, 1.0);
191 let target = (p * self.total_count as f64).ceil() as u64;
192 let mut cum = 0u64;
193 for (&idx, &count) in &self.buckets {
194 cum += count;
195 if cum >= target {
196 return self.bucket_lower_bound(idx);
197 }
198 }
199 // Past the last bucket: return the upper bound of the last
200 // bucket (the largest observed value range).
201 if let Some((&last_idx, _)) = self.buckets.last_key_value() {
202 if last_idx == i64::MIN {
203 return 0.0;
204 }
205 // The upper bound of the last bucket.
206 self.gamma.powf((last_idx + 1) as f64)
207 } else {
208 0.0
209 }
210 }
211
212 /// Merge another DDSketch into this one. Both sketches must have
213 /// the same `alpha` (the relative error guarantee). The merged
214 /// sketch has the union of all buckets.
215 pub fn merge(&mut self, other: &DDSketch) {
216 assert_eq!(
217 self.alpha, other.alpha,
218 "cannot merge DDSketches with different alpha"
219 );
220 for (&idx, &count) in &other.buckets {
221 *self.buckets.entry(idx).or_insert(0) += count;
222 }
223 self.total_count += other.total_count;
224 self.sum += other.sum;
225 while self.buckets.len() > self.max_buckets {
226 self.collapse();
227 }
228 }
229
230 /// Reset the sketch to empty (zero samples, zero buckets).
231 pub fn reset(&mut self) {
232 self.buckets.clear();
233 self.total_count = 0;
234 self.sum = 0.0;
235 }
236}
237
238impl Default for DDSketch {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244impl std::fmt::Debug for DDSketch {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.debug_struct("DDSketch")
247 .field("alpha", &self.alpha)
248 .field("max_buckets", &self.max_buckets)
249 .field("num_buckets", &self.buckets.len())
250 .field("total_count", &self.total_count)
251 .finish()
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn empty_sketch_quantile_is_zero() {
261 let sketch = DDSketch::new();
262 assert_eq!(sketch.quantile(0.50), 0.0);
263 assert_eq!(sketch.quantile(0.99), 0.0);
264 assert_eq!(sketch.count(), 0);
265 assert_eq!(sketch.mean(), 0.0);
266 }
267
268 #[test]
269 fn single_sample_quantile_is_sample() {
270 let mut sketch = DDSketch::new();
271 sketch.add(100.0);
272 assert_eq!(sketch.count(), 1);
273 // The quantile is the lower bound of the bucket containing
274 // the value, which is <= the value. For a single sample, the
275 // quantile should be close to the value (within the relative
276 // error).
277 let q = sketch.quantile(0.50);
278 assert!(q <= 100.0 && q > 100.0 * (1.0 - DEFAULT_ALPHA));
279 }
280
281 #[test]
282 fn uniform_distribution_quantiles() {
283 let mut sketch = DDSketch::new();
284 for i in 1..=1000 {
285 sketch.add(i as f64);
286 }
287 let p50 = sketch.quantile(0.50);
288 let p99 = sketch.quantile(0.99);
289 // p50 should be near 500, p99 near 990, within relative error.
290 assert!(p50 > 400.0 && p50 < 600.0, "p50 = {p50}");
291 assert!(p99 > 900.0 && p99 < 1000.0, "p99 = {p99}");
292 }
293
294 #[test]
295 fn mean_is_correct() {
296 let mut sketch = DDSketch::new();
297 sketch.add(10.0);
298 sketch.add(20.0);
299 sketch.add(30.0);
300 assert_eq!(sketch.mean(), 20.0);
301 }
302
303 #[test]
304 fn zero_value_handled() {
305 let mut sketch = DDSketch::new();
306 sketch.add(0.0);
307 sketch.add(100.0);
308 assert_eq!(sketch.count(), 2);
309 let p50 = sketch.quantile(0.50);
310 // p50 should be 0 (the zero bucket) or 100 (the next bucket).
311 assert!(p50 <= 100.0);
312 }
313
314 #[test]
315 fn merge_combines_buckets() {
316 let mut a = DDSketch::new();
317 let mut b = DDSketch::new();
318 for i in 1..=500 {
319 a.add(i as f64);
320 }
321 for i in 501..=1000 {
322 b.add(i as f64);
323 }
324 a.merge(&b);
325 assert_eq!(a.count(), 1000);
326 let p50 = a.quantile(0.50);
327 let p99 = a.quantile(0.99);
328 assert!(p50 > 400.0 && p50 < 600.0, "p50 = {p50}");
329 assert!(p99 > 900.0 && p99 < 1000.0, "p99 = {p99}");
330 }
331
332 #[test]
333 fn reset_clears_sketch() {
334 let mut sketch = DDSketch::new();
335 sketch.add(100.0);
336 sketch.add(200.0);
337 assert_eq!(sketch.count(), 2);
338 sketch.reset();
339 assert_eq!(sketch.count(), 0);
340 assert_eq!(sketch.quantile(0.50), 0.0);
341 }
342
343 #[test]
344 fn tail_accuracy() {
345 // DDSketch provides relative-error guarantees, so the tail
346 // (p99) should be accurate even for wide-range distributions.
347 let mut sketch = DDSketch::new();
348 // 990 samples at 1ms, 10 samples at 1000ms.
349 for _ in 0..990 {
350 sketch.add(1.0);
351 }
352 for _ in 0..10 {
353 sketch.add(1000.0);
354 }
355 let p99 = sketch.quantile(0.99);
356 // p99 should be near 1000 (the 990th-1000th samples).
357 // With 1000 samples, p99 targets the 990th sample (ceil(0.99 * 1000) = 990).
358 // The 990th sample is 1.0, so p99 should be near 1.0.
359 assert!(p99 <= 1000.0, "p99 = {p99}");
360 }
361
362 #[test]
363 fn bucket_count_bounded() {
364 // Even with a very wide value range, the bucket count should
365 // be bounded by max_buckets.
366 let mut sketch = DDSketch::with_alpha(0.01, 100);
367 for i in 0..10000 {
368 sketch.add(10f64.powf(i as f64 / 1000.0));
369 }
370 assert!(sketch.buckets.len() <= 100);
371 }
372}