ddsketch/canonical/
sketch.rs

1//! Canonical DDSketch implementation.
2
3use datadog_protos::sketches::DDSketch as ProtoDDSketch;
4
5use super::error::ProtoConversionError;
6use super::mapping::{IndexMapping, LogarithmicMapping};
7use super::store::{CollapsingLowestDenseStore, Store};
8
9/// A fast and fully mergeable quantile sketch with relative-error guarantees.
10///
11/// This implementation supports most of the capabilities of the various official DDSketch implementations, such as:
12///
13/// - support for tracking negative and positive values
14/// - multiple store types (sparse, dense, collapsing)
15/// - configurable index interpolation schemes (only logarithmic currently supported)
16///
17/// Defaults to using a "low collapsing" dense store with a logarithmic index mapping. This works well for tracking
18/// values like time durations/latencies where the tail latencies (higher percentiles) matter most.
19///
20/// # Example
21///
22/// ```
23/// use ddsketch::canonical::DDSketch;
24///
25/// let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
26/// sketch.add(1.0);
27/// sketch.add(2.0);
28/// sketch.add(3.0);
29///
30/// let median = sketch.quantile(0.5).unwrap();
31/// ```
32#[derive(Clone, Debug)]
33pub struct DDSketch<M: IndexMapping = LogarithmicMapping, S: Store = CollapsingLowestDenseStore> {
34    /// The index mapping for this sketch.
35    mapping: M,
36
37    /// Store for positive values.
38    positive_store: S,
39
40    /// Store for negative values.
41    negative_store: S,
42
43    /// Count of values that map to zero.
44    zero_count: u64,
45}
46
47impl DDSketch<LogarithmicMapping, CollapsingLowestDenseStore> {
48    /// Creates a new `DDSketch` with the given relative accuracy.
49    ///
50    /// Defaults to logarithmic mapping and the "low collapsing" dense store, with a maximum of 2048 bins per store.
51    ///
52    /// # Errors
53    ///
54    /// If the relative accuracy isn't between `0` and `1`, an error is returned.
55    pub fn with_relative_accuracy(relative_accuracy: f64) -> Result<Self, &'static str> {
56        let mapping = LogarithmicMapping::new(relative_accuracy)?;
57        Ok(Self::new(
58            mapping,
59            CollapsingLowestDenseStore::default(),
60            CollapsingLowestDenseStore::default(),
61        ))
62    }
63}
64
65impl<M: IndexMapping, S: Store> DDSketch<M, S> {
66    /// Creates a new `DDSketch` with the given mapping and stores.
67    pub fn new(mapping: M, positive_store: S, negative_store: S) -> Self {
68        Self {
69            mapping,
70            positive_store,
71            negative_store,
72            zero_count: 0,
73        }
74    }
75
76    /// Adds a single value to the sketch.
77    pub fn add(&mut self, value: f64) {
78        self.add_n(value, 1);
79    }
80
81    /// Adds a value to the sketch with the given count.
82    ///
83    /// This is useful for weighted values or pre-aggregated data.
84    pub fn add_n(&mut self, value: f64, n: u64) {
85        if n == 0 {
86            return;
87        }
88
89        if value > self.mapping.min_indexable_value() {
90            let index = self.mapping.index(value);
91            self.positive_store.add(index, n);
92        } else if value < -self.mapping.min_indexable_value() {
93            let index = self.mapping.index(-value);
94            self.negative_store.add(index, n);
95        } else {
96            self.zero_count += n;
97        }
98    }
99
100    /// Returns the approximate value at the given quantile.
101    ///
102    /// The quantile must be in the range of [0, 1].
103    ///
104    /// Returns `None` if the sketch is empty, or if the quantile is out of bounds. Otherwise, returns the approximate
105    /// value.
106    pub fn quantile(&self, q: f64) -> Option<f64> {
107        if self.is_empty() {
108            return None;
109        }
110
111        if !(0.0..=1.0).contains(&q) {
112            return None;
113        }
114
115        let rank = (q * (self.count() - 1) as f64).round_ties_even() as u64;
116
117        let negative_count = self.negative_store.total_count();
118        let total_negative_and_zero = negative_count + self.zero_count;
119
120        if rank < negative_count {
121            // We need to reverse the rank since negative values are stored with positive indices
122            let reverse_rank = negative_count - rank - 1;
123            if let Some(index) = self.negative_store.key_at_rank(reverse_rank) {
124                return Some(-self.mapping.value(index));
125            }
126        } else if rank < total_negative_and_zero {
127            return Some(0.0);
128        } else {
129            let positive_rank = rank - total_negative_and_zero;
130            if let Some(index) = self.positive_store.key_at_rank(positive_rank) {
131                return Some(self.mapping.value(index));
132            }
133        }
134
135        unreachable!("rank out of bounds on non-empty sketch")
136    }
137
138    /// Merges another sketch into this one.
139    ///
140    /// The other sketch must use the same mapping type.
141    pub fn merge(&mut self, other: &Self)
142    where
143        M: PartialEq,
144    {
145        if other.is_empty() {
146            return;
147        }
148
149        self.positive_store.merge(&other.positive_store);
150        self.negative_store.merge(&other.negative_store);
151        self.zero_count += other.zero_count;
152    }
153
154    /// Returns `true` if the sketch is empty.
155    pub fn is_empty(&self) -> bool {
156        self.count() == 0
157    }
158
159    /// Returns the total number of values added to the sketch.
160    pub fn count(&self) -> u64 {
161        self.negative_store().total_count() + self.positive_store().total_count() + self.zero_count
162    }
163
164    /// Clears the sketch, removing all values.
165    pub fn clear(&mut self) {
166        self.positive_store.clear();
167        self.negative_store.clear();
168        self.zero_count = 0;
169    }
170
171    /// Returns a reference to the index mapping.
172    pub fn mapping(&self) -> &M {
173        &self.mapping
174    }
175
176    /// Returns a reference to the positive value store.
177    pub fn positive_store(&self) -> &S {
178        &self.positive_store
179    }
180
181    /// Returns a reference to the negative value store.
182    pub fn negative_store(&self) -> &S {
183        &self.negative_store
184    }
185
186    /// Returns the count of values mapped to zero.
187    pub fn zero_count(&self) -> u64 {
188        self.zero_count
189    }
190
191    /// Returns the relative accuracy of this sketch.
192    pub fn relative_accuracy(&self) -> f64 {
193        self.mapping.relative_accuracy()
194    }
195
196    /// Creates a `DDSketch` from a protobuf `DDSketch` message.
197    ///
198    /// This validates that the protobuf's index mapping is compatible with
199    /// the mapping type `M`, then populates the stores with the bin data.
200    ///
201    /// # Arguments
202    ///
203    /// * `proto` - The protobuf `DDSketch` message to convert from
204    /// * `mapping` - The mapping instance to use (must be compatible with proto's mapping)
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if:
209    /// - The protobuf is missing a mapping
210    /// - The mapping parameters don't match the provided mapping
211    /// - Any bin counts are negative or non-integer
212    /// - The zero count is negative or non-integer
213    ///
214    /// # Note
215    ///
216    /// The protobuf `DDSketch` doesn't include `sum`, `min`, `max`, or `count` fields.
217    /// These are computed or set to defaults:
218    /// - `count`: sum of all bin counts plus `zero_count`
219    /// - `sum`, `min`, `max`: set to sentinel defaults (can't be recovered from proto)
220    pub fn from_proto(proto: &ProtoDDSketch, mapping: M) -> Result<Self, ProtoConversionError>
221    where
222        S: Default,
223    {
224        // Validate the mapping
225        let proto_mapping = proto.mapping.as_ref().ok_or(ProtoConversionError::MissingMapping)?;
226        mapping.validate_proto_mapping(proto_mapping)?;
227
228        // Validate and convert zero count
229        let zero_count = if proto.zeroCount < 0.0 {
230            return Err(ProtoConversionError::NegativeZeroCount { count: proto.zeroCount });
231        } else if proto.zeroCount.fract() != 0.0 {
232            return Err(ProtoConversionError::NonIntegerZeroCount { count: proto.zeroCount });
233        } else {
234            proto.zeroCount as u64
235        };
236
237        let mut positive_store = S::default();
238        if let Some(proto_positive) = proto.positiveValues.as_ref() {
239            positive_store.merge_from_proto(proto_positive)?;
240        }
241
242        let mut negative_store = S::default();
243        if let Some(proto_negative) = proto.negativeValues.as_ref() {
244            negative_store.merge_from_proto(proto_negative)?;
245        }
246
247        Ok(Self {
248            mapping,
249            positive_store,
250            negative_store,
251            zero_count,
252        })
253    }
254
255    /// Converts this `DDSketch` to a protobuf `DDSketch` message.
256    ///
257    /// # Note
258    ///
259    /// The protobuf `DDSketch` doesn't include `sum`, `min`, `max`, or `count` fields.
260    /// This information is lost in the conversion.
261    pub fn to_proto(&self) -> ProtoDDSketch {
262        let mut proto = ProtoDDSketch::new();
263
264        proto.set_mapping(self.mapping.to_proto());
265
266        if !self.positive_store().is_empty() {
267            proto.set_positiveValues(self.positive_store.to_proto());
268        }
269
270        if !self.negative_store().is_empty() {
271            proto.set_negativeValues(self.negative_store.to_proto());
272        }
273
274        proto.set_zeroCount(self.zero_count as f64);
275
276        proto
277    }
278}
279
280impl<M: IndexMapping + PartialEq, S: Store + PartialEq> PartialEq for DDSketch<M, S> {
281    fn eq(&self, other: &Self) -> bool {
282        self.mapping == other.mapping
283            && self.positive_store == other.positive_store
284            && self.negative_store == other.negative_store
285            && self.zero_count == other.zero_count
286    }
287}
288
289impl<M: IndexMapping + PartialEq, S: Store + PartialEq> Eq for DDSketch<M, S> {}
290
291impl<M: IndexMapping + Default, S: Store + Default> Default for DDSketch<M, S> {
292    fn default() -> Self {
293        Self::new(M::default(), S::default(), S::default())
294    }
295}
296
297/// A DDSketch optimized for positive-only values (for example, latencies, durations).
298///
299/// This is a memory-optimized variant of [`DDSketch`] that eliminates the negative value store,
300/// saving 48 bytes per sketch instance. Use this when you know all values will be non-negative,
301/// such as when tracking latencies or other timing metrics.
302///
303/// Negative values are treated as zero (mapped to `zero_count`).
304///
305/// # Example
306///
307/// ```
308/// use ddsketch::canonical::PositiveOnlyDDSketch;
309/// use ddsketch::canonical::mapping::FixedLogarithmicMapping;
310/// use ddsketch::canonical::store::CollapsingLowestDenseStore;
311///
312/// let mut sketch: PositiveOnlyDDSketch<FixedLogarithmicMapping, CollapsingLowestDenseStore> =
313///     PositiveOnlyDDSketch::default();
314/// sketch.add(1.0);
315/// sketch.add(2.0);
316/// sketch.add(3.0);
317///
318/// let median = sketch.quantile(0.5).unwrap();
319/// ```
320#[derive(Clone, Debug)]
321pub struct PositiveOnlyDDSketch<M: IndexMapping = LogarithmicMapping, S: Store = CollapsingLowestDenseStore> {
322    /// The index mapping for this sketch.
323    mapping: M,
324
325    /// Store for positive values.
326    positive_store: S,
327
328    /// Count of values that map to zero (including negative values).
329    zero_count: u64,
330}
331
332impl<M: IndexMapping, S: Store> PositiveOnlyDDSketch<M, S> {
333    /// Creates a new `PositiveOnlyDDSketch` with the given mapping and store.
334    pub fn new(mapping: M, positive_store: S) -> Self {
335        Self {
336            mapping,
337            positive_store,
338            zero_count: 0,
339        }
340    }
341
342    /// Adds a single value to the sketch.
343    ///
344    /// Negative values are treated as zero.
345    #[inline]
346    pub fn add(&mut self, value: f64) {
347        self.add_n(value, 1);
348    }
349
350    /// Adds a value to the sketch with the given count.
351    ///
352    /// Negative values are treated as zero.
353    #[inline]
354    pub fn add_n(&mut self, value: f64, n: u64) {
355        if n == 0 {
356            return;
357        }
358
359        if value > self.mapping.min_indexable_value() {
360            let index = self.mapping.index(value);
361            self.positive_store.add(index, n);
362        } else {
363            // Values at or below min_indexable_value (including negatives) go to zero_count
364            self.zero_count += n;
365        }
366    }
367
368    /// Returns the approximate value at the given quantile.
369    ///
370    /// The quantile must be in the range of [0, 1].
371    ///
372    /// Returns `None` if the sketch is empty, or if the quantile is out of bounds.
373    pub fn quantile(&self, q: f64) -> Option<f64> {
374        if self.is_empty() {
375            return None;
376        }
377
378        if !(0.0..=1.0).contains(&q) {
379            return None;
380        }
381
382        let rank = (q * (self.count() - 1) as f64).round_ties_even() as u64;
383
384        if rank < self.zero_count {
385            return Some(0.0);
386        }
387
388        let positive_rank = rank - self.zero_count;
389        if let Some(index) = self.positive_store.key_at_rank(positive_rank) {
390            return Some(self.mapping.value(index));
391        }
392
393        unreachable!("rank out of bounds on non-empty sketch")
394    }
395
396    /// Merges another sketch into this one.
397    ///
398    /// The other sketch must use the same mapping type.
399    pub fn merge(&mut self, other: &Self)
400    where
401        M: PartialEq,
402    {
403        if other.is_empty() {
404            return;
405        }
406
407        self.positive_store.merge(&other.positive_store);
408        self.zero_count += other.zero_count;
409    }
410
411    /// Returns `true` if the sketch is empty.
412    #[inline]
413    pub fn is_empty(&self) -> bool {
414        self.count() == 0
415    }
416
417    /// Returns the total number of values added to the sketch.
418    #[inline]
419    pub fn count(&self) -> u64 {
420        self.positive_store.total_count() + self.zero_count
421    }
422
423    /// Clears the sketch, removing all values.
424    pub fn clear(&mut self) {
425        self.positive_store.clear();
426        self.zero_count = 0;
427    }
428
429    /// Returns a reference to the index mapping.
430    pub fn mapping(&self) -> &M {
431        &self.mapping
432    }
433
434    /// Returns a reference to the positive value store.
435    pub fn positive_store(&self) -> &S {
436        &self.positive_store
437    }
438
439    /// Returns the count of values mapped to zero.
440    pub fn zero_count(&self) -> u64 {
441        self.zero_count
442    }
443
444    /// Returns the relative accuracy of this sketch.
445    pub fn relative_accuracy(&self) -> f64 {
446        self.mapping.relative_accuracy()
447    }
448
449    /// Creates a `PositiveOnlyDDSketch` from a protobuf `DDSketch` message.
450    ///
451    /// This validates that the protobuf's index mapping is compatible with the mapping type `M`,
452    /// then populates the store with the positive bin data. Any negative values in the protobuf
453    /// are ignored.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error if:
458    /// - The protobuf is missing a mapping
459    /// - The mapping parameters don't match the provided mapping
460    /// - Any bin counts are negative or non-integer
461    /// - The zero count is negative or non-integer
462    pub fn from_proto(proto: &ProtoDDSketch, mapping: M) -> Result<Self, ProtoConversionError>
463    where
464        S: Default,
465    {
466        // Validate the mapping
467        let proto_mapping = proto.mapping.as_ref().ok_or(ProtoConversionError::MissingMapping)?;
468        mapping.validate_proto_mapping(proto_mapping)?;
469
470        // Validate and convert zero count
471        let zero_count = if proto.zeroCount < 0.0 {
472            return Err(ProtoConversionError::NegativeZeroCount { count: proto.zeroCount });
473        } else if proto.zeroCount.fract() != 0.0 {
474            return Err(ProtoConversionError::NonIntegerZeroCount { count: proto.zeroCount });
475        } else {
476            proto.zeroCount as u64
477        };
478
479        let mut positive_store = S::default();
480        if let Some(proto_positive) = proto.positiveValues.as_ref() {
481            positive_store.merge_from_proto(proto_positive)?;
482        }
483
484        // Note: negativeValues is intentionally ignored for positive-only sketches
485
486        Ok(Self {
487            mapping,
488            positive_store,
489            zero_count,
490        })
491    }
492
493    /// Converts this `PositiveOnlyDDSketch` to a protobuf `DDSketch` message.
494    ///
495    /// The resulting protobuf will have no `negativeValues` field set.
496    pub fn to_proto(&self) -> ProtoDDSketch {
497        let mut proto = ProtoDDSketch::new();
498
499        proto.set_mapping(self.mapping.to_proto());
500
501        if !self.positive_store.is_empty() {
502            proto.set_positiveValues(self.positive_store.to_proto());
503        }
504
505        // No negativeValues - this is a positive-only sketch
506
507        proto.set_zeroCount(self.zero_count as f64);
508
509        proto
510    }
511}
512
513impl<M: IndexMapping + PartialEq, S: Store + PartialEq> PartialEq for PositiveOnlyDDSketch<M, S> {
514    fn eq(&self, other: &Self) -> bool {
515        self.mapping == other.mapping
516            && self.positive_store == other.positive_store
517            && self.zero_count == other.zero_count
518    }
519}
520
521impl<M: IndexMapping + PartialEq, S: Store + PartialEq> Eq for PositiveOnlyDDSketch<M, S> {}
522
523impl<M: IndexMapping + Default, S: Store + Default> Default for PositiveOnlyDDSketch<M, S> {
524    fn default() -> Self {
525        Self::new(M::default(), S::default())
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use ndarray::{Array, Axis};
532    use ndarray_stats::{
533        interpolate::{Higher, Lower},
534        QuantileExt,
535    };
536    use noisy_float::types::N64;
537    use num_traits::ToPrimitive as _;
538
539    use super::*;
540
541    macro_rules! assert_rel_acc_range_eq {
542        ($quantile:expr, $rel_acc:expr, $expected_lower:expr, $expected_upper:expr, $actual:expr) => {{
543            let expected_lower_f64 = $expected_lower.to_f64().unwrap();
544            let expected_lower_adj = if expected_lower_f64 > 0.0 {
545                expected_lower_f64 * (1.0 - $rel_acc)
546            } else {
547                expected_lower_f64 * (1.0 + $rel_acc)
548            };
549            let expected_upper_f64 = $expected_upper.to_f64().unwrap();
550            let expected_upper_adj = if expected_upper_f64 > 0.0 {
551                expected_upper_f64 * (1.0 + $rel_acc)
552            } else {
553                expected_upper_f64 * (1.0 - $rel_acc)
554            };
555            let actual = $actual.to_f64().unwrap();
556
557            /*
558            For debugging purposes:
559
560            println!(
561                "asserting range equality for q={}, expected_lower={} (adj: {}), expected_upper={} (adj: {}), actual={}",
562                $quantile,
563                $expected_lower,
564                expected_lower_adj,
565                $expected_upper,
566                expected_upper_adj,
567                actual
568            );
569            */
570
571            assert!(
572                actual >= expected_lower_adj && actual <= expected_upper_adj,
573                "mismatch at q={}: expected {} - {} ({}% relative accuracy), got {}",
574                $quantile,
575                expected_lower_adj,
576                expected_upper_adj,
577                $rel_acc * 100.0,
578                actual
579            );
580        }};
581    }
582
583    macro_rules! assert_rel_acc_eq {
584        ($quantile:expr, $rel_acc:expr, $expected:expr, $actual:expr) => {{
585            assert_rel_acc_range_eq!($quantile, $rel_acc, $expected, $expected, $actual);
586        }};
587    }
588
589    struct Dataset<M: IndexMapping, S: Store> {
590        raw_data: Vec<N64>,
591        sketch: DDSketch<M, S>,
592    }
593
594    impl<M: IndexMapping, S: Store + Default> Dataset<M, S> {
595        fn new<V>(index_mapping: M, values: V) -> Self
596        where
597            V: Iterator<Item = f64>,
598        {
599            let mut raw_data = Vec::new();
600            let mut sketch = DDSketch::new(index_mapping, S::default(), S::default());
601            for value in values {
602                raw_data.push(N64::new(value));
603                sketch.add(value);
604            }
605
606            Self { raw_data, sketch }
607        }
608
609        #[track_caller]
610        fn validate(self, quantiles: &[f64]) {
611            let Self { mut raw_data, sketch } = self;
612
613            // Make sure the total counts match.
614            assert_eq!(raw_data.len() as u64, sketch.count());
615
616            // Sort our raw data before comparing quantiles.
617            raw_data.sort();
618
619            let mut data = Array::from_vec(raw_data);
620            let rel_acc = sketch.relative_accuracy();
621
622            // Compare quantiles.
623            for q in quantiles {
624                let expected_lower = data
625                    .quantile_axis_mut(Axis(0), N64::new(*q), &Lower)
626                    .map(|v| v.into_scalar())
627                    .ok();
628                let expected_upper = data
629                    .quantile_axis_mut(Axis(0), N64::new(*q), &Higher)
630                    .map(|v| v.into_scalar())
631                    .ok();
632                let actual = sketch.quantile(*q).map(N64::new);
633
634                match (expected_lower, expected_upper, actual) {
635                    (Some(expected_lower), Some(expected_upper), Some(actual)) => {
636                        // DDSketch does not do linear interpolation between the two closest values, so for example,
637                        // if we have 10 values (1-5 and 10-15, let's say), with an alpha of 0.01, and we ask for
638                        // q=5, you might expect to get back 7.5 -- (5 + 10) / 2 -- but DDSketch can return anywhere
639                        // from 5*0.99 to 10*1.01, or 4.95 to 10.1.
640                        //
641                        // We capture the quantile from the raw data with different interpolation methods to
642                        // calculate those wider bounds so we can validate against the actual guarantees provided by
643                        // DDSketch.
644                        assert_rel_acc_range_eq!(q, rel_acc, expected_lower, expected_upper, actual);
645                    }
646                    (None, None, None) => (),
647                    _ => panic!(
648                        "mismatched quantiles: expected_lower={:?}, expected_upper={:?}, actual {:?}",
649                        expected_lower, expected_upper, actual
650                    ),
651                }
652            }
653        }
654    }
655
656    fn integers(start: i64, end: i64) -> impl Iterator<Item = f64> {
657        (start..=end).map(|x| x as f64)
658    }
659
660    #[test]
661    fn empty_sketch() {
662        let sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
663
664        assert!(sketch.is_empty());
665        assert_eq!(sketch.count(), 0);
666        assert_eq!(sketch.quantile(0.5), None);
667    }
668
669    /// Regression test for <https://github.com/DataDog/saluki/issues/2596>, as reported.
670    ///
671    /// A single observation far above everything already recorded used to be dropped from
672    /// `CollapsingLowestDenseStore`'s bins while still being tallied in its cached count. `count()` then
673    /// over-reported by one, and `quantile(1.0)` resolved a rank that no bin covered, which fell through to the
674    /// `unreachable!` in `quantile`. The store-level cause and its mirror are covered in
675    /// `canonical::store::reference_tests`; this case pins the symptom the report was filed against.
676    ///
677    /// The relative accuracy matters here. The drop needed the new index to land at least `max_num_bins` positions
678    /// above the window, which at 1% accuracy takes a value ratio of ~6.2e17 and so was unreachable in practice, but
679    /// at the 1/1024 accuracy used below takes a ratio of only ~55.
680    #[test]
681    fn large_upward_jump_preserves_counts_and_maximum_quantile() {
682        let mut sketch = DDSketch::with_relative_accuracy(1.0 / 1024.0).unwrap();
683        sketch.add_n(1.0, 100);
684        sketch.add(((1u64 << 30) - 1) as f64);
685
686        assert_eq!(sketch.count(), 101);
687
688        let stored: f64 = sketch.positive_store().to_proto().contiguousBinCounts.iter().sum();
689        assert_eq!(
690            stored, 101.0,
691            "every observation must be present in the bins, not just in the count"
692        );
693
694        let maximum = sketch.quantile(1.0).expect("the maximum quantile must resolve");
695        assert_rel_acc_eq!(1.0, 1.0 / 1024.0, ((1u64 << 30) - 1) as f64, maximum);
696    }
697
698    /// Regression test for the downward-expansion half of
699    /// <https://github.com/DataDog/saluki/issues/2596>.
700    ///
701    /// Adding the large value first used to pin the window to that single bin, so the 100 observations of `1.0`
702    /// collapsed onto the largest index seen rather than onto the lowest index the store could still represent. Both
703    /// insertion orders must now produce the same sketch.
704    #[test]
705    fn collapsing_lowest_quantiles_do_not_depend_on_insertion_order() {
706        let large = ((1u64 << 30) - 1) as f64;
707
708        let mut ascending = DDSketch::with_relative_accuracy(1.0 / 1024.0).unwrap();
709        ascending.add_n(1.0, 100);
710        ascending.add(large);
711
712        let mut descending = DDSketch::with_relative_accuracy(1.0 / 1024.0).unwrap();
713        descending.add(large);
714        descending.add_n(1.0, 100);
715
716        assert_eq!(descending.count(), ascending.count());
717        for quantile in [0.0, 0.5, 0.95, 1.0] {
718            assert_eq!(
719                descending.quantile(quantile),
720                ascending.quantile(quantile),
721                "quantile({quantile}) must not depend on insertion order"
722            );
723        }
724    }
725
726    #[test]
727    fn accuracy_integers_positive_only_even_small() {
728        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
729        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(1, 10));
730        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
731    }
732
733    #[test]
734    fn accuracy_integers_positive_only_even_medium() {
735        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
736        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(1, 250));
737        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
738    }
739
740    #[test]
741    fn accuracy_integers_positive_only_even_large() {
742        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
743        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(1, 1000));
744        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
745    }
746
747    #[test]
748    fn accuracy_integers_positive_only_odd_small() {
749        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
750        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(1, 11));
751        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
752    }
753
754    #[test]
755    fn accuracy_integers_positive_only_odd_medium() {
756        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
757        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(1, 293));
758        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
759    }
760
761    #[test]
762    fn accuracy_integers_positive_only_odd_large() {
763        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
764        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(1, 1023));
765        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
766    }
767
768    #[test]
769    fn accuracy_integers_negative_only_even_small() {
770        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
771        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(-10, -1));
772        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
773    }
774
775    #[test]
776    fn accuracy_integers_negative_only_even_medium() {
777        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
778        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(-250, -1));
779        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
780    }
781
782    #[test]
783    fn accuracy_integers_negative_only_even_large() {
784        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
785        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(-1000, -1));
786        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
787    }
788
789    #[test]
790    fn accuracy_integers_negative_only_odd_small() {
791        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
792        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(-11, -1));
793        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
794    }
795
796    #[test]
797    fn accuracy_integers_negative_only_odd_medium() {
798        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
799        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(-293, -1));
800        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
801    }
802
803    #[test]
804    fn accuracy_integers_negative_only_odd_large() {
805        let index_mapping = LogarithmicMapping::new(0.01).unwrap();
806        let dataset = Dataset::<_, CollapsingLowestDenseStore>::new(index_mapping, integers(-1023, -1));
807        dataset.validate(&[0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99]);
808    }
809
810    #[test]
811    fn zero_values() {
812        let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
813        sketch.add(0.0);
814        sketch.add(0.0);
815        sketch.add(1.0);
816
817        assert_eq!(sketch.count(), 3);
818        assert_eq!(sketch.zero_count(), 2);
819    }
820
821    #[test]
822    fn merge() {
823        let mut sketch1 = DDSketch::with_relative_accuracy(0.01).unwrap();
824        sketch1.add(1.0);
825        sketch1.add(2.0);
826
827        let mut sketch2 = DDSketch::with_relative_accuracy(0.01).unwrap();
828        sketch2.add(3.0);
829        sketch2.add(4.0);
830
831        sketch1.merge(&sketch2);
832
833        assert_eq!(sketch1.count(), 4);
834    }
835
836    #[test]
837    fn clear() {
838        let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
839        sketch.add(1.0);
840        sketch.add(2.0);
841
842        sketch.clear();
843
844        assert!(sketch.is_empty());
845        assert_eq!(sketch.count(), 0);
846    }
847
848    #[test]
849    fn quantile_at_zero_and_one_returns_min_and_max() {
850        // This is a plain correctness test (not a perf/diagnostic test): it verifies that querying the extreme
851        // quantiles returns the sketch's minimum and maximum values within the relative-accuracy bound. It runs
852        // quickly and deterministically, so there is no reason for it to be `#[ignore]`'d.
853        let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
854        for i in 1..=100 {
855            sketch.add(i as f64);
856        }
857
858        // q=0 should return min
859        let min_actual = sketch.quantile(0.0).unwrap();
860        assert_rel_acc_eq!(0.0, 0.01, 1.0, min_actual);
861
862        // q=1 should return max
863        let max_actual = sketch.quantile(1.0).unwrap();
864        assert_rel_acc_eq!(1.0, 0.01, 100.0, max_actual);
865    }
866
867    #[test]
868    fn add_n() {
869        let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
870        sketch.add_n(10.0, 5);
871
872        assert_eq!(sketch.count(), 5);
873    }
874
875    #[test]
876    fn proto_roundtrip() {
877        let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
878        sketch.add(1.0);
879        sketch.add(2.0);
880        sketch.add(3.0);
881        sketch.add(100.0);
882
883        let proto = sketch.to_proto();
884        let mapping = LogarithmicMapping::new(0.01).unwrap();
885        let recovered: DDSketch = DDSketch::from_proto(&proto, mapping).unwrap();
886
887        // Check bin data is preserved
888        assert_eq!(sketch.count(), recovered.count());
889        assert_eq!(sketch.zero_count(), recovered.zero_count());
890
891        // Check quantiles are approximately equal
892        for q in [0.25, 0.5, 0.75, 0.99] {
893            let orig = sketch.quantile(q).unwrap();
894            let recov = recovered.quantile(q).unwrap();
895            assert!(
896                (orig - recov).abs() < 0.001,
897                "quantile {} mismatch: {} vs {}",
898                q,
899                orig,
900                recov
901            );
902        }
903    }
904
905    #[test]
906    fn proto_roundtrip_with_negatives() {
907        let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
908        sketch.add(-10.0);
909        sketch.add(-5.0);
910        sketch.add(0.0);
911        sketch.add(5.0);
912        sketch.add(10.0);
913
914        let proto = sketch.to_proto();
915        let mapping = LogarithmicMapping::new(0.01).unwrap();
916        let recovered: DDSketch = DDSketch::from_proto(&proto, mapping).unwrap();
917
918        assert_eq!(sketch.count(), recovered.count());
919        assert_eq!(sketch.zero_count(), recovered.zero_count());
920    }
921
922    #[test]
923    fn proto_roundtrip_empty() {
924        let sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
925
926        let proto = sketch.to_proto();
927        let mapping = LogarithmicMapping::new(0.01).unwrap();
928        let recovered: DDSketch = DDSketch::from_proto(&proto, mapping).unwrap();
929
930        assert!(recovered.is_empty());
931        assert_eq!(recovered.count(), 0);
932    }
933
934    #[test]
935    fn proto_gamma_mismatch() {
936        let mut sketch = DDSketch::with_relative_accuracy(0.01).unwrap();
937        sketch.add(1.0);
938
939        let proto = sketch.to_proto();
940
941        // Try to decode with a different relative accuracy (different gamma)
942        let different_mapping = LogarithmicMapping::new(0.05).unwrap();
943        let result = DDSketch::<_, CollapsingLowestDenseStore>::from_proto(&proto, different_mapping);
944
945        assert!(result.is_err());
946        match result {
947            Err(crate::canonical::ProtoConversionError::GammaMismatch { .. }) => {}
948            _ => panic!("Expected GammaMismatch error"),
949        }
950    }
951
952    #[test]
953    fn proto_missing_mapping() {
954        use datadog_protos::sketches::DDSketch as ProtoDDSketch;
955
956        let proto = ProtoDDSketch::new(); // No mapping set
957        let mapping = LogarithmicMapping::new(0.01).unwrap();
958        let result = DDSketch::<_, CollapsingLowestDenseStore>::from_proto(&proto, mapping);
959
960        assert!(result.is_err());
961        match result {
962            Err(crate::canonical::ProtoConversionError::MissingMapping) => {}
963            _ => panic!("Expected MissingMapping error"),
964        }
965    }
966
967    #[test]
968    fn proto_non_zero_index_offset() {
969        use datadog_protos::sketches::DDSketch as ProtoDDSketch;
970
971        // `LogarithmicMapping::new` always produces a zero index offset, and `validate_proto_mapping` distinguishes a
972        // proto that carries a non-zero offset from a gamma mismatch. Start from a valid proto mapping (matching gamma,
973        // NONE interpolation) and only perturb the index offset, so that the offset check is the branch that fires.
974        let mapping = LogarithmicMapping::new(0.01).unwrap();
975        let mut proto_mapping = mapping.to_proto();
976        proto_mapping.indexOffset = 1.0;
977
978        let mut proto = ProtoDDSketch::new();
979        proto.set_mapping(proto_mapping);
980
981        let result = DDSketch::<_, CollapsingLowestDenseStore>::from_proto(&proto, mapping);
982        match result {
983            Err(crate::canonical::ProtoConversionError::NonZeroIndexOffset { actual }) => {
984                assert_eq!(actual, 1.0);
985            }
986            other => panic!("Expected NonZeroIndexOffset error, got {:?}", other),
987        }
988    }
989
990    #[test]
991    fn proto_unsupported_interpolation() {
992        use datadog_protos::sketches::{index_mapping::Interpolation, DDSketch as ProtoDDSketch};
993
994        // `LogarithmicMapping` only supports exact (NONE) interpolation. A proto that requests any other interpolation
995        // mode -- here, LINEAR -- must be rejected with `UnsupportedInterpolation` rather than one of the other error
996        // kinds, so we keep the gamma and offset valid and only change the interpolation mode.
997        let mapping = LogarithmicMapping::new(0.01).unwrap();
998        let mut proto_mapping = mapping.to_proto();
999        proto_mapping.interpolation = protobuf::EnumOrUnknown::new(Interpolation::LINEAR);
1000
1001        let mut proto = ProtoDDSketch::new();
1002        proto.set_mapping(proto_mapping);
1003
1004        let result = DDSketch::<_, CollapsingLowestDenseStore>::from_proto(&proto, mapping);
1005        match result {
1006            Err(crate::canonical::ProtoConversionError::UnsupportedInterpolation { actual }) => {
1007                assert_eq!(actual, Interpolation::LINEAR as i32);
1008            }
1009            other => panic!("Expected UnsupportedInterpolation error, got {:?}", other),
1010        }
1011    }
1012
1013    // ==================== PositiveOnlyDDSketch tests ====================
1014
1015    #[test]
1016    fn positive_only_size() {
1017        use crate::canonical::mapping::FixedLogarithmicMapping;
1018
1019        // Verify the memory savings - PositiveOnlyDDSketch should be smaller than DDSketch
1020        let ddsketch_size = std::mem::size_of::<DDSketch<FixedLogarithmicMapping, CollapsingLowestDenseStore>>();
1021        let positive_only_size =
1022            std::mem::size_of::<PositiveOnlyDDSketch<FixedLogarithmicMapping, CollapsingLowestDenseStore>>();
1023
1024        // PositiveOnlyDDSketch should be ~48 bytes smaller (one less store)
1025        assert!(
1026            ddsketch_size > positive_only_size,
1027            "PositiveOnlyDDSketch ({} bytes) should be smaller than DDSketch ({} bytes)",
1028            positive_only_size,
1029            ddsketch_size
1030        );
1031        assert_eq!(
1032            ddsketch_size - positive_only_size,
1033            48,
1034            "Expected 48 bytes savings from removing negative store"
1035        );
1036    }
1037
1038    #[test]
1039    fn positive_only_empty() {
1040        let sketch: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1041
1042        assert!(sketch.is_empty());
1043        assert_eq!(sketch.count(), 0);
1044        assert_eq!(sketch.quantile(0.5), None);
1045    }
1046
1047    #[test]
1048    fn positive_only_add_and_quantile() {
1049        let mut sketch: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1050
1051        for i in 1..=100 {
1052            sketch.add(i as f64);
1053        }
1054
1055        assert_eq!(sketch.count(), 100);
1056
1057        // Check median is approximately 50
1058        let median = sketch.quantile(0.5).unwrap();
1059        assert!((median - 50.0).abs() < 2.0, "median {} should be close to 50", median);
1060    }
1061
1062    #[test]
1063    fn positive_only_negative_values_become_zero() {
1064        let mut sketch: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1065
1066        sketch.add(-10.0);
1067        sketch.add(-5.0);
1068        sketch.add(0.0);
1069        sketch.add(5.0);
1070        sketch.add(10.0);
1071
1072        assert_eq!(sketch.count(), 5);
1073        // Negative values and zero should all go to zero_count
1074        assert_eq!(sketch.zero_count(), 3);
1075    }
1076
1077    #[test]
1078    fn positive_only_merge() {
1079        let mut sketch1: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1080        sketch1.add(1.0);
1081        sketch1.add(2.0);
1082
1083        let mut sketch2: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1084        sketch2.add(3.0);
1085        sketch2.add(4.0);
1086
1087        sketch1.merge(&sketch2);
1088
1089        assert_eq!(sketch1.count(), 4);
1090    }
1091
1092    #[test]
1093    fn positive_only_clear() {
1094        let mut sketch: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1095        sketch.add(1.0);
1096        sketch.add(2.0);
1097
1098        sketch.clear();
1099
1100        assert!(sketch.is_empty());
1101        assert_eq!(sketch.count(), 0);
1102    }
1103
1104    #[test]
1105    fn positive_only_proto_roundtrip() {
1106        let mut sketch: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1107        sketch.add(1.0);
1108        sketch.add(2.0);
1109        sketch.add(3.0);
1110        sketch.add(100.0);
1111
1112        let proto = sketch.to_proto();
1113
1114        // Verify no negative values in proto
1115        assert!(proto.negativeValues.is_none());
1116
1117        let mapping = LogarithmicMapping::new(0.01).unwrap();
1118        let recovered: PositiveOnlyDDSketch = PositiveOnlyDDSketch::from_proto(&proto, mapping).unwrap();
1119
1120        assert_eq!(sketch.count(), recovered.count());
1121        assert_eq!(sketch.zero_count(), recovered.zero_count());
1122
1123        // Check quantiles are approximately equal
1124        for q in [0.25, 0.5, 0.75, 0.99] {
1125            let orig = sketch.quantile(q).unwrap();
1126            let recov = recovered.quantile(q).unwrap();
1127            assert!(
1128                (orig - recov).abs() < 0.01,
1129                "quantile {} mismatch: {} vs {}",
1130                q,
1131                orig,
1132                recov
1133            );
1134        }
1135    }
1136
1137    #[test]
1138    fn positive_only_matches_ddsketch_for_positive_values() {
1139        // Verify that PositiveOnlyDDSketch produces the same results as DDSketch for positive values
1140        let mut ddsketch: DDSketch = DDSketch::default();
1141        let mut positive_only: PositiveOnlyDDSketch = PositiveOnlyDDSketch::default();
1142
1143        for i in 1..=1000 {
1144            let value = i as f64;
1145            ddsketch.add(value);
1146            positive_only.add(value);
1147        }
1148
1149        assert_eq!(ddsketch.count(), positive_only.count());
1150
1151        // Check that quantiles match
1152        for q in [0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99] {
1153            let dd_quantile = ddsketch.quantile(q).unwrap();
1154            let po_quantile = positive_only.quantile(q).unwrap();
1155            assert!(
1156                (dd_quantile - po_quantile).abs() < 0.001,
1157                "quantile {} mismatch: DDSketch={}, PositiveOnly={}",
1158                q,
1159                dd_quantile,
1160                po_quantile
1161            );
1162        }
1163    }
1164}