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}