ddsketch/agent/
sketch.rs

1//! Agent-specific DDSketch implementation.
2
3use std::cmp::Ordering;
4
5use datadog_protos::metrics::Dogsketch;
6use ordered_float::OrderedFloat;
7use smallvec::SmallVec;
8
9use super::bin::Bin;
10use super::bucket::Bucket;
11use super::config::{
12    Config, DDSKETCH_CONF_BIN_LIMIT, DDSKETCH_CONF_GAMMA_LN, DDSKETCH_CONF_GAMMA_V, DDSKETCH_CONF_NORM_BIAS,
13    DDSKETCH_CONF_NORM_MIN,
14};
15use crate::canonical::mapping::LogarithmicMapping;
16use crate::common::float_eq;
17
18static SKETCH_CONFIG: Config = Config::new(
19    DDSKETCH_CONF_BIN_LIMIT,
20    DDSKETCH_CONF_GAMMA_V,
21    DDSKETCH_CONF_GAMMA_LN,
22    DDSKETCH_CONF_NORM_MIN,
23    DDSKETCH_CONF_NORM_BIAS,
24);
25const MAX_BIN_WIDTH: u32 = u32::MAX;
26
27#[cfg(feature = "serde")]
28mod serde_helpers {
29    use serde::{Deserialize, Deserializer};
30
31    /// Deserializes an `f64` field, restoring JSON `null` to `NaN`.
32    ///
33    /// `serde_json` serializes non-finite floating-point values as `null`. This preserves the value's non-finite
34    /// semantics when a sketch is round-tripped through JSON without fabricating a zero value.
35    pub(super) fn null_as_nan<'de, D>(deserializer: D) -> Result<f64, D::Error>
36    where
37        D: Deserializer<'de>,
38    {
39        Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
40    }
41}
42
43/// [DDSketch][ddsketch] implementation based on the [Datadog Agent][ddagent].
44///
45/// This implementation is subtly different from the open-source implementations of `DDSketch`, as Datadog made some
46/// slight tweaks to configuration values and in-memory layout to optimize it for insertion performance within the
47/// agent.
48///
49/// We've mimicked the agent version of `DDSketch` here in order to support a future where we can take sketches shipped
50/// by the agent, handle them internally, merge them, and so on, without any loss of accuracy, eventually forwarding
51/// them to Datadog ourselves.
52///
53/// As such, this implementation is constrained in the same ways: the configuration parameters can't be changed, the
54/// collapsing strategy is fixed, and we support a limited number of methods for inserting into the sketch.
55///
56/// Importantly, we've a special function, again taken from the agent version, to allow us to interpolate histograms,
57/// specifically our own aggregated histograms, into a sketch so that we can emit useful default quantiles, rather than
58/// having to ship the buckets -- upper bound and count -- to a downstream system that might have no native way to do
59/// the same thing, basically providing no value as they have no way to render useful data from them.
60///
61/// # Features
62///
63/// This crate exposes a single feature, `serde`, which enables serialization and deserialization of `DDSketch` with
64/// `serde`. This feature isn't enabled by default, as it can be slightly risky to use. This is primarily due to the
65/// fact that the format of `DDSketch` isn't promised to be stable over time. If you enable this feature, you should
66/// take care to avoid storing serialized `DDSketch` data for long periods of time, as deserializing it in the future
67/// may work but could lead to incorrect/unexpected behavior or issues with correctness.
68///
69/// [ddsketch]: https://www.vldb.org/pvldb/vol12/p2195-masson.pdf
70/// [ddagent]: https://github.com/DataDog/datadog-agent
71#[derive(Clone, Debug)]
72#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
73pub struct DDSketch {
74    /// The bins within the sketch.
75    bins: SmallVec<[Bin; 4]>,
76
77    /// The number of observations within the sketch.
78    count: u64,
79
80    /// The minimum value of all observations within the sketch.
81    #[cfg_attr(feature = "serde", serde(deserialize_with = "serde_helpers::null_as_nan"))]
82    min: f64,
83
84    /// The maximum value of all observations within the sketch.
85    #[cfg_attr(feature = "serde", serde(deserialize_with = "serde_helpers::null_as_nan"))]
86    max: f64,
87
88    /// The sum of all observations within the sketch.
89    #[cfg_attr(feature = "serde", serde(deserialize_with = "serde_helpers::null_as_nan"))]
90    sum: f64,
91
92    /// The average value of all observations within the sketch.
93    #[cfg_attr(feature = "serde", serde(deserialize_with = "serde_helpers::null_as_nan"))]
94    avg: f64,
95}
96
97impl DDSketch {
98    /// Returns the canonical DDSketch mapping that aligns with the agent sketch's key space.
99    pub fn remap_mapping() -> LogarithmicMapping {
100        LogarithmicMapping::new_with_gamma_and_offset(DDSKETCH_CONF_GAMMA_V, f64::from(DDSKETCH_CONF_NORM_BIAS) + 0.5)
101            .expect("agent sketch gamma and offset are always valid")
102    }
103
104    /// Returns the representative value for the given agent sketch key.
105    pub fn value_for_key(key: i16) -> f64 {
106        SKETCH_CONFIG.bin_lower_bound(key)
107    }
108
109    /// Returns the number of bins in the sketch.
110    pub fn bin_count(&self) -> usize {
111        self.bins.len()
112    }
113
114    /// Whether or not this sketch is empty.
115    pub fn is_empty(&self) -> bool {
116        self.count == 0
117    }
118
119    /// Number of samples currently represented by this sketch.
120    pub fn count(&self) -> u64 {
121        self.count
122    }
123
124    /// Overrides the sample count tracked by this sketch.
125    pub fn set_count(&mut self, count: u64) {
126        self.count = count;
127    }
128
129    /// Overrides the sum of all values tracked by this sketch.
130    pub fn set_sum(&mut self, sum: f64) {
131        self.sum = sum;
132    }
133
134    /// Overrides the average value tracked by this sketch.
135    pub fn set_avg(&mut self, avg: f64) {
136        self.avg = avg;
137    }
138
139    /// Overrides the minimum value tracked by this sketch.
140    pub fn set_min(&mut self, min: f64) {
141        self.min = min;
142    }
143
144    /// Overrides the maximum value tracked by this sketch.
145    pub fn set_max(&mut self, max: f64) {
146        self.max = max;
147    }
148
149    /// Minimum value seen by this sketch.
150    ///
151    /// Returns `None` if the sketch is empty.
152    pub fn min(&self) -> Option<f64> {
153        if self.is_empty() {
154            None
155        } else {
156            Some(self.min)
157        }
158    }
159
160    /// Maximum value seen by this sketch.
161    ///
162    /// Returns `None` if the sketch is empty.
163    pub fn max(&self) -> Option<f64> {
164        if self.is_empty() {
165            None
166        } else {
167            Some(self.max)
168        }
169    }
170
171    /// Sum of all values seen by this sketch.
172    ///
173    /// Returns `None` if the sketch is empty.
174    pub fn sum(&self) -> Option<f64> {
175        if self.is_empty() {
176            None
177        } else {
178            Some(self.sum)
179        }
180    }
181
182    /// Returns the stored sum summary field without considering the sample count.
183    pub fn stored_sum(&self) -> f64 {
184        self.sum
185    }
186
187    /// Average value seen by this sketch.
188    ///
189    /// Returns `None` if the sketch is empty.
190    pub fn avg(&self) -> Option<f64> {
191        if self.is_empty() {
192            None
193        } else {
194            Some(self.avg)
195        }
196    }
197
198    /// Returns the stored average summary field without considering the sample count.
199    pub fn stored_avg(&self) -> f64 {
200        self.avg
201    }
202
203    /// Returns the stored minimum summary field without considering the sample count.
204    pub fn stored_min(&self) -> f64 {
205        self.min
206    }
207
208    /// Returns the stored maximum summary field without considering the sample count.
209    pub fn stored_max(&self) -> f64 {
210        self.max
211    }
212
213    /// Returns the current bins of this sketch.
214    pub fn bins(&self) -> &[Bin] {
215        &self.bins
216    }
217
218    /// Clears the sketch, removing all bins and resetting all statistics.
219    pub fn clear(&mut self) {
220        self.count = 0;
221        self.min = f64::MAX;
222        self.max = f64::MIN;
223        self.avg = 0.0;
224        self.sum = 0.0;
225        self.bins.clear();
226    }
227
228    fn adjust_basic_stats(&mut self, v: f64, n: u64) {
229        // Every insert path funnels through here, so this is where we guard that the incoming sample is finite. If
230        // this is not true something has gone wrong with the DogStatsD codec.
231        saluki_antithesis::always!(v.is_finite(), "DDSketch sample is finite at insert");
232
233        if v < self.min {
234            self.min = v;
235        }
236
237        if v > self.max {
238            self.max = v;
239        }
240
241        saluki_antithesis::always_le!(self.min, self.max, "DDSketch min does not exceed max after insert");
242
243        self.count += n;
244        // It's possible that self.sum will be INF after this multiplication, even though we've demonstrated that `v`
245        // is finite. The Datadog Agent sketch sum behaves the same way, so we do not assert that self.sum is itself
246        // finite.
247        self.sum += v * n as f64;
248
249        if n == 1 {
250            self.avg += (v - self.avg) / self.count as f64;
251        } else {
252            // TODO: From the Agent source code, this method apparently loses precision when the
253            // two averages -- v and self.avg -- are close.  Is there a better approach?
254            self.avg = self.avg + (v - self.avg) * n as f64 / self.count as f64;
255        }
256    }
257
258    fn insert_key_counts(&mut self, counts: &[(i16, u64)]) {
259        let mut temp = SmallVec::<[Bin; 4]>::new();
260
261        let mut bins_idx = 0;
262        let mut key_idx = 0;
263        let bins_len = self.bins.len();
264        let counts_len = counts.len();
265
266        // PERF TODO: there's probably a fast path to be had where could check if all if the counts have existing bins
267        // that aren't yet full, and we just update them directly, although we'd still be doing a linear scan to find
268        // them since keys aren't 1:1 with their position in `self.bins` but using this method just to update one or two
269        // bins is clearly suboptimal and we wouldn't really want to scan them all just to have to back out and actually
270        // do the non-fast path.. maybe a first pass could be checking if the first/last key falls within our known
271        // min/max key, and if it doesn't, then we know we have to go through the non-fast path, and if it passes, we do
272        // the scan to see if we can just update bins directly?
273        while bins_idx < bins_len && key_idx < counts_len {
274            let bin = self.bins[bins_idx];
275            let vk = counts[key_idx].0;
276            let kn = counts[key_idx].1;
277
278            match bin.k.cmp(&vk) {
279                Ordering::Greater => {
280                    generate_bins(&mut temp, vk, kn);
281                    key_idx += 1;
282                }
283                Ordering::Less => {
284                    temp.push(bin);
285                    bins_idx += 1;
286                }
287                Ordering::Equal => {
288                    generate_bins(&mut temp, bin.k, u64::from(bin.n) + kn);
289                    bins_idx += 1;
290                    key_idx += 1;
291                }
292            }
293        }
294
295        temp.extend_from_slice(&self.bins[bins_idx..]);
296
297        while key_idx < counts_len {
298            let vk = counts[key_idx].0;
299            let kn = counts[key_idx].1;
300            generate_bins(&mut temp, vk, kn);
301            key_idx += 1;
302        }
303
304        trim_left(&mut temp, SKETCH_CONFIG.bin_limit);
305
306        // PERF TODO: This is where we might do a mem::swap instead so that we could shove the bin vector into an object
307        // pool but I'm not sure this actually matters at the moment.
308        self.bins = temp;
309    }
310
311    fn insert_keys(&mut self, mut keys: Vec<i16>) {
312        // Updating more than 4 billion keys would be very very weird and likely indicative of something horribly
313        // broken.
314        //
315        // TODO: I don't actually understand why I wrote this assertion in this way. Either the code can handle
316        // collapsing values in order to maintain the relative error bounds, or we have to cap it to the maximum allowed
317        // number of bins. Gotta think about this some more.
318        assert!(keys.len() <= u32::MAX.try_into().expect("we don't support 16-bit systems"));
319
320        keys.sort_unstable();
321
322        let mut temp = SmallVec::<[Bin; 4]>::new();
323
324        let mut bins_idx = 0;
325        let mut key_idx = 0;
326        let bins_len = self.bins.len();
327        let keys_len = keys.len();
328
329        // PERF TODO: there's probably a fast path to be had where could check if all if the counts have existing bins
330        // that aren't yet full, and we just update them directly, although we'd still be doing a linear scan to find
331        // them since keys aren't 1:1 with their position in `self.bins` but using this method just to update one or two
332        // bins is clearly suboptimal and we wouldn't really want to scan them all just to have to back out and actually
333        // do the non-fast path.. maybe a first pass could be checking if the first/last key falls within our known
334        // min/max key, and if it doesn't, then we know we have to go through the non-fast path, and if it passes, we do
335        // the scan to see if we can just update bins directly?
336        while bins_idx < bins_len && key_idx < keys_len {
337            let bin = self.bins[bins_idx];
338            let vk = keys[key_idx];
339
340            match bin.k.cmp(&vk) {
341                Ordering::Greater => {
342                    let kn = buf_count_leading_equal(&keys, key_idx);
343                    generate_bins(&mut temp, vk, kn);
344                    key_idx += kn as usize;
345                }
346                Ordering::Less => {
347                    temp.push(bin);
348                    bins_idx += 1;
349                }
350                Ordering::Equal => {
351                    let kn = buf_count_leading_equal(&keys, key_idx);
352                    generate_bins(&mut temp, bin.k, u64::from(bin.n) + kn);
353                    bins_idx += 1;
354                    key_idx += kn as usize;
355                }
356            }
357        }
358
359        temp.extend_from_slice(&self.bins[bins_idx..]);
360
361        while key_idx < keys_len {
362            let vk = keys[key_idx];
363            let kn = buf_count_leading_equal(&keys, key_idx);
364            generate_bins(&mut temp, vk, kn);
365            key_idx += kn as usize;
366        }
367
368        trim_left(&mut temp, SKETCH_CONFIG.bin_limit);
369
370        // PERF TODO: This is where we might do a mem::swap instead so that we could shove the bin vector into an object
371        // pool but I'm not sure this actually matters at the moment.
372        self.bins = temp;
373    }
374
375    /// Inserts a single value into the sketch.
376    pub fn insert(&mut self, v: f64) {
377        // TODO: This should return a result that makes sure we have enough room to actually add 1 more sample without
378        // hitting `self.config.max_count()`
379        self.adjust_basic_stats(v, 1);
380
381        let key = SKETCH_CONFIG.key(v);
382
383        let mut insert_at = None;
384
385        for (bin_idx, b) in self.bins.iter_mut().enumerate() {
386            if b.k == key {
387                if b.n < MAX_BIN_WIDTH {
388                    // Fast path for adding to an existing bin without overflow.
389                    b.n += 1;
390                    return;
391                } else {
392                    insert_at = Some(bin_idx);
393                    break;
394                }
395            }
396            if b.k > key {
397                insert_at = Some(bin_idx);
398                break;
399            }
400        }
401
402        if let Some(bin_idx) = insert_at {
403            self.bins.insert(bin_idx, Bin { k: key, n: 1 });
404        } else {
405            self.bins.push(Bin { k: key, n: 1 });
406        }
407        trim_left(&mut self.bins, SKETCH_CONFIG.bin_limit);
408    }
409
410    /// Inserts many values into the sketch.
411    pub fn insert_many(&mut self, vs: &[f64]) {
412        // TODO: This should return a result that makes sure we have enough room to actually add N more samples without
413        // hitting `self.config.bin_limit`.
414        let mut keys = Vec::with_capacity(vs.len());
415        for v in vs {
416            self.adjust_basic_stats(*v, 1);
417            keys.push(SKETCH_CONFIG.key(*v));
418        }
419        self.insert_keys(keys);
420    }
421
422    /// Inserts a single value into the sketch `n` times.
423    pub fn insert_n(&mut self, v: f64, n: u64) {
424        // TODO: This should return a result that makes sure we have enough room to actually add N more samples without
425        // hitting `self.config.max_count()`.
426        if n == 1 {
427            self.insert(v);
428        } else {
429            self.adjust_basic_stats(v, n);
430
431            let key = SKETCH_CONFIG.key(v);
432            self.insert_key_counts(&[(key, n)]);
433        }
434    }
435
436    fn insert_interpolate_bucket(&mut self, lower: f64, upper: f64, count: u64) {
437        // Find the keys for the bins where the lower bound and upper bound would end up, and collect all of the keys in
438        // between, inclusive.
439        let lower_key = SKETCH_CONFIG.key(lower);
440        let upper_key = SKETCH_CONFIG.key(upper);
441        let keys = (lower_key..=upper_key).collect::<Vec<_>>();
442
443        let mut key_counts = Vec::new();
444        let mut remaining_count = count;
445        let distance = upper - lower;
446        let mut start_idx = 0;
447        let mut end_idx = 1;
448        let mut lower_bound = SKETCH_CONFIG.bin_lower_bound(keys[start_idx]);
449        let mut remainder = 0.0;
450
451        while end_idx < keys.len() && remaining_count > 0 {
452            // For each key, map the total distance between the input lower/upper bound against the sketch lower/upper
453            // bound for the current sketch bin, which tells us how much of the input count to apply to the current
454            // sketch bin.
455            let upper_bound = SKETCH_CONFIG.bin_lower_bound(keys[end_idx]);
456            let fkn = ((upper_bound - lower_bound) / distance) * count as f64;
457            if fkn > 1.0 {
458                remainder += fkn - fkn.trunc();
459            }
460
461            // SAFETY: This integer cast is intentional: we want to get the non-fractional part, as we've captured the
462            // fractional part in the above conditional.
463            #[allow(clippy::cast_possible_truncation)]
464            let mut kn = fkn as u64;
465            if remainder > 1.0 {
466                kn += 1;
467                remainder -= 1.0;
468            }
469
470            if kn > 0 {
471                if kn > remaining_count {
472                    kn = remaining_count;
473                }
474
475                self.adjust_basic_stats(lower_bound, kn);
476                key_counts.push((keys[start_idx], kn));
477
478                remaining_count -= kn;
479                start_idx = end_idx;
480                lower_bound = upper_bound;
481            }
482
483            end_idx += 1;
484        }
485
486        if remaining_count > 0 {
487            let last_key = keys[start_idx];
488            lower_bound = SKETCH_CONFIG.bin_lower_bound(last_key);
489            self.adjust_basic_stats(lower_bound, remaining_count);
490            key_counts.push((last_key, remaining_count));
491        }
492
493        // Sort the key counts first, as that's required by `insert_key_counts`.
494        key_counts.sort_unstable_by_key(|(k, _)| *k);
495
496        self.insert_key_counts(&key_counts);
497    }
498
499    /// Inserts histogram buckets into the sketch via linear interpolation.
500    ///
501    /// ## Errors
502    ///
503    /// Returns an error if a bucket size is greater that `u32::MAX`.
504    pub fn insert_interpolate_buckets(&mut self, mut buckets: Vec<Bucket>) -> Result<(), &'static str> {
505        // Buckets need to be sorted from lowest to highest so that we can properly calculate the rolling lower/upper
506        // bounds.
507        buckets.sort_by(|a, b| {
508            let oa = OrderedFloat(a.upper_limit);
509            let ob = OrderedFloat(b.upper_limit);
510
511            oa.cmp(&ob)
512        });
513
514        let mut lower = f64::NEG_INFINITY;
515
516        if buckets.iter().any(|bucket| bucket.count > u64::from(u32::MAX)) {
517            return Err("bucket size greater than u32::MAX");
518        }
519
520        for bucket in buckets {
521            let original_upper = bucket.upper_limit;
522            let mut upper = bucket.upper_limit;
523            if upper.is_sign_positive() && upper.is_infinite() {
524                upper = lower;
525            } else if lower.is_sign_negative() && lower.is_infinite() {
526                lower = upper;
527            } else if lower == 0.0 && upper > 0.0 {
528                // OpenTelemetry explicit buckets use (lower, upper]. After a zero boundary, the
529                // next bucket is (0, upper] and excludes zero. Collapse interpolation to `upper`
530                // so it does not add samples at zero.
531                lower = upper;
532            }
533
534            self.insert_interpolate_bucket(lower, upper, bucket.count);
535            lower = original_upper;
536        }
537
538        Ok(())
539    }
540
541    /// Adds a bin directly into the sketch.
542    ///
543    /// Used only for unit testing so that we can create a sketch with an exact layout, which allows testing around the
544    /// resulting bins when feeding in specific values, as well as generating explicitly bad layouts for testing.
545    #[allow(dead_code)]
546    pub(crate) fn insert_raw_bin(&mut self, k: i16, n: u32) {
547        let v = SKETCH_CONFIG.bin_lower_bound(k);
548        self.adjust_basic_stats(v, u64::from(n));
549        self.bins.push(Bin { k, n });
550    }
551
552    /// Gets the value at a given quantile.
553    pub fn quantile(&self, q: f64) -> Option<f64> {
554        if self.count == 0 {
555            return None;
556        }
557
558        if q <= 0.0 {
559            return Some(self.min);
560        }
561
562        if q >= 1.0 {
563            return Some(self.max);
564        }
565
566        let mut n = 0.0;
567        let mut estimated = None;
568        let wanted_rank = rank(self.count, q);
569
570        for (i, bin) in self.bins.iter().enumerate() {
571            n += f64::from(bin.n);
572            if n <= wanted_rank {
573                continue;
574            }
575
576            let weight = (n - wanted_rank) / f64::from(bin.n);
577            let mut v_low = SKETCH_CONFIG.bin_lower_bound(bin.k);
578            let mut v_high = v_low * SKETCH_CONFIG.gamma_v;
579
580            if i == self.bins.len() {
581                v_high = self.max;
582            } else if i == 0 {
583                v_low = self.min;
584            }
585
586            estimated = Some(v_low * weight + v_high * (1.0 - weight));
587            break;
588        }
589
590        estimated.map(|v| v.clamp(self.min, self.max)).or(Some(f64::NAN))
591    }
592
593    /// Merges another sketch into this sketch, without a loss of accuracy.
594    ///
595    /// All samples present in the other sketch will be correctly represented in this sketch, and summary statistics
596    /// such as the sum, average, count, min, and max, will represent the sum of samples from both sketches.
597    ///
598    /// A zero-count sketch can retain bins after an upstream cumulative-to-delta conversion. Its summary fields do not
599    /// describe samples in the current interval, so they do not contribute to the merged summary. Its bins are still
600    /// merged.
601    pub fn merge(&mut self, other: &DDSketch) {
602        // Zero-count destination matches other summary but does not affect an existing summary
603        if self.count == 0 {
604            self.count = other.count;
605            self.min = other.min;
606            self.max = other.max;
607            self.sum = other.sum;
608            self.avg = other.avg;
609        } else if other.count > 0 {
610            self.count += other.count;
611            if other.max > self.max {
612                self.max = other.max;
613            }
614            if other.min < self.min {
615                self.min = other.min;
616            }
617            self.sum += other.sum;
618            self.avg = self.avg + (other.avg - self.avg) * other.count as f64 / self.count as f64;
619        }
620
621        // Merge the bins regardless of the summary count.
622        let mut temp = SmallVec::<[Bin; 4]>::new();
623
624        let mut bins_idx = 0;
625        for other_bin in &other.bins {
626            let start = bins_idx;
627            while bins_idx < self.bins.len() && self.bins[bins_idx].k < other_bin.k {
628                bins_idx += 1;
629            }
630
631            temp.extend_from_slice(&self.bins[start..bins_idx]);
632
633            if bins_idx >= self.bins.len() || self.bins[bins_idx].k > other_bin.k {
634                temp.push(*other_bin);
635            } else if self.bins[bins_idx].k == other_bin.k {
636                generate_bins(
637                    &mut temp,
638                    other_bin.k,
639                    u64::from(other_bin.n) + u64::from(self.bins[bins_idx].n),
640                );
641                bins_idx += 1;
642            }
643        }
644
645        temp.extend_from_slice(&self.bins[bins_idx..]);
646        trim_left(&mut temp, SKETCH_CONFIG.bin_limit);
647
648        self.bins = temp;
649    }
650
651    /// Merges this sketch into the `Dogsketch` Protocol Buffers representation.
652    pub fn merge_to_dogsketch(&self, dogsketch: &mut Dogsketch) {
653        dogsketch.set_cnt(i64::try_from(self.count).unwrap_or(i64::MAX));
654        dogsketch.set_min(self.min);
655        dogsketch.set_max(self.max);
656        dogsketch.set_avg(self.avg);
657        dogsketch.set_sum(self.sum);
658
659        let mut k = Vec::new();
660        let mut n = Vec::new();
661
662        for bin in &self.bins {
663            k.push(i32::from(bin.k));
664            n.push(bin.n);
665        }
666
667        dogsketch.set_k(k);
668        dogsketch.set_n(n);
669    }
670}
671
672impl PartialEq for DDSketch {
673    fn eq(&self, other: &Self) -> bool {
674        // We skip checking the configuration because we don't allow creating configurations by hand, and it's always
675        // locked to the constants used by the Datadog Agent.  We only check the configuration equality manually in
676        // `DDSketch::merge`, to protect ourselves in the future if different configurations become allowed.
677        //
678        // Additionally, we also use floating-point-specific relative comparisons for sum/avg because they can be
679        // minimally different between sketches purely due to floating-point behavior, despite being fed the same exact
680        // data in terms of recorded samples.
681        self.count == other.count
682            && float_eq(self.min, other.min)
683            && float_eq(self.max, other.max)
684            && float_eq(self.sum, other.sum)
685            && float_eq(self.avg, other.avg)
686            && self.bins == other.bins
687    }
688}
689
690impl Default for DDSketch {
691    fn default() -> Self {
692        Self {
693            bins: SmallVec::new(),
694            count: 0,
695            min: f64::MAX,
696            max: f64::MIN,
697            sum: 0.0,
698            avg: 0.0,
699        }
700    }
701}
702
703impl Eq for DDSketch {}
704
705impl TryFrom<Dogsketch> for DDSketch {
706    type Error = &'static str;
707
708    fn try_from(value: Dogsketch) -> Result<Self, Self::Error> {
709        let mut sketch = DDSketch {
710            count: u64::try_from(value.cnt).map_err(|_| "sketch count overflows u64 or is negative")?,
711            min: value.min,
712            max: value.max,
713            avg: value.avg,
714            sum: value.sum,
715            ..Default::default()
716        };
717
718        let k = value.k;
719        let n = value.n;
720
721        if k.len() != n.len() {
722            return Err("k and n bin vectors have differing lengths");
723        }
724
725        for (k, n) in k.into_iter().zip(n) {
726            let k = i16::try_from(k).map_err(|_| "bin key overflows i16")?;
727
728            sketch.bins.push(Bin { k, n });
729        }
730
731        Ok(sketch)
732    }
733}
734
735fn rank(count: u64, q: f64) -> f64 {
736    let rank = q * (count - 1) as f64;
737    rank.round_ties_even()
738}
739
740#[allow(clippy::cast_possible_truncation)]
741fn buf_count_leading_equal(keys: &[i16], start_idx: usize) -> u64 {
742    if start_idx == keys.len() - 1 {
743        return 1;
744    }
745
746    let mut idx = start_idx;
747    while idx < keys.len() && keys[idx] == keys[start_idx] {
748        idx += 1;
749    }
750
751    // SAFETY: We limit the size of the vector (used to provide the slice given to us here) to be no larger than 2^32,
752    // so we can't exceed u64 here.
753    (idx - start_idx) as u64
754}
755
756fn trim_left(bins: &mut SmallVec<[Bin; 4]>, bin_limit: u16) {
757    // We won't ever support Vector running on anything other than a 32-bit platform and above, I imagine, so this
758    // should always be safe.
759    let bin_limit = bin_limit as usize;
760    if bin_limit == 0 || bins.len() <= bin_limit {
761        return;
762    }
763
764    let num_to_remove = bins.len() - bin_limit;
765    let mut missing: u64 = 0;
766
767    // Sum all mass from the bins being removed. Per CollapsingLowestDenseStore in sketches-go,
768    // all removed mass collapses into the first kept bin (the new minimum index).
769    for bin in bins.iter().take(num_to_remove) {
770        missing += u64::from(bin.n);
771    }
772
773    // Fold the accumulated mass into the first kept bin, matching Go's `bins[newMinIndex] += n`.
774    // Any remainder that overflows u32::MAX is discarded—this requires >4B observations in a
775    // single collapsed bin and is an intentional divergence from the Datadog Agent (which uses
776    // float64 counts and never loses mass).
777    bins[num_to_remove].increment(missing);
778
779    // Drop the removed prefix, leaving exactly bin_limit bins.
780    bins.drain(0..num_to_remove);
781
782    // This is the one place every mutating method routes through, so asserting here guards the bin-count bound for
783    // all of them.
784    saluki_antithesis::reachable!("DDSketch bin collapse reached");
785    saluki_antithesis::always_le!(bins.len(), bin_limit, "DDSketch bin count within bin_limit");
786}
787
788#[allow(clippy::cast_possible_truncation)]
789fn generate_bins(bins: &mut SmallVec<[Bin; 4]>, k: i16, n: u64) {
790    if n < u64::from(MAX_BIN_WIDTH) {
791        // SAFETY: `n < MAX_BIN_WIDTH = u32::MAX`, so it fits in u32.
792        bins.push(Bin { k, n: n as u32 });
793    } else {
794        let overflow = n % u64::from(MAX_BIN_WIDTH);
795        if overflow != 0 {
796            bins.push(Bin {
797                k,
798                // SAFETY: `overflow = n % u32::MAX`, so overflow <= u32::MAX - 1, which fits in u32.
799                n: overflow as u32,
800            });
801        }
802
803        for _ in 0..(n / u64::from(MAX_BIN_WIDTH)) {
804            bins.push(Bin { k, n: MAX_BIN_WIDTH });
805        }
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    // Helper: build a SmallVec<[Bin; 4]> from (k, n) pairs. Assumes input is already sorted by k.
814    fn make_bins(pairs: &[(i16, u32)]) -> SmallVec<[Bin; 4]> {
815        pairs.iter().map(|&(k, n)| Bin { k, n }).collect()
816    }
817
818    // Helper: extract (k, n) pairs from a SmallVec for easy assertion.
819    fn to_pairs(bins: &SmallVec<[Bin; 4]>) -> Vec<(i16, u32)> {
820        bins.iter().map(|b| (b.k, b.n)).collect()
821    }
822
823    /// Basic collapse: when bins exceed the limit, the mass from removed bins is merged
824    /// into the first kept bin (lowest surviving key). This mirrors the CollapsingLowestDenseStore
825    /// semantics from sketches-go: all bins with index < (`maxIndex` - limit + 1) collapse into
826    /// the bin at (`maxIndex` - limit + 1).
827    ///
828    /// Input:  [(0,2), (1,3), (2,4), (3,5)]  limit=2  →  remove 2 bins
829    /// missing = n[0] + n[1] = 2 + 3 = 5
830    /// first kept bin: k=2, n=4 → increment by 5 → n=9
831    /// Result: [(2,9), (3,5)]
832    #[test]
833    fn trim_left_collapses_removed_mass_into_first_kept_bin() {
834        let mut bins = make_bins(&[(0, 2), (1, 3), (2, 4), (3, 5)]);
835        trim_left(&mut bins, 2);
836        assert_eq!(to_pairs(&bins), vec![(2, 9), (3, 5)]);
837    }
838
839    /// Total count is preserved exactly when the collapse fits within a single u32 bin.
840    ///
841    /// Input:  [(10,5), (20,3), (30,7)]  limit=2  →  remove 1 bin
842    /// missing = 5; first kept bin k=20, n=3 → n=8
843    /// Result: [(20,8), (30,7)], total=15 == 5+3+7
844    #[test]
845    fn trim_left_preserves_total_count_when_no_overflow() {
846        let mut bins = make_bins(&[(10, 5), (20, 3), (30, 7)]);
847        let total_before: u64 = bins.iter().map(|b| u64::from(b.n)).sum();
848        trim_left(&mut bins, 2);
849        let total_after: u64 = bins.iter().map(|b| u64::from(b.n)).sum();
850        assert_eq!(to_pairs(&bins), vec![(20, 8), (30, 7)]);
851        assert_eq!(total_before, total_after);
852    }
853
854    /// With u32 bin counts, collapsed mass from multiple removed bins fits in a single bin
855    /// without saturation for typical weights, fully preserving the total count.
856    ///
857    /// Input:  [(0,50000), (1,50000), (2,1)]  limit=1  →  remove 2 bins
858    /// missing = 100000; bins[2].increment(100000): 100001 < u32::MAX → n=100001
859    /// bins.drain(0..2). Final: [(2,100001)], all mass preserved.
860    ///
861    /// With the old u16 layout, this same input would have saturated at 65535 and discarded
862    /// 34466 observations. u32 eliminates that loss for any per-bin count below ~4.3 billion.
863    #[test]
864    fn trim_left_preserves_exact_count_with_u32_bins() {
865        let mut bins = make_bins(&[(0, 50000), (1, 50000), (2, 1)]);
866        let total_before: u64 = bins.iter().map(|b| u64::from(b.n)).sum();
867        trim_left(&mut bins, 1);
868        let total_after: u64 = bins.iter().map(|b| u64::from(b.n)).sum();
869        assert_eq!(bins.len(), 1);
870        assert_eq!(bins[0].k, 2);
871        assert_eq!(bins[0].n, 100001);
872        assert_eq!(total_before, total_after, "all mass must be preserved with u32 bins");
873    }
874
875    /// When already at or under the limit, `trim_left` is a no-op.
876    #[test]
877    fn trim_left_no_op_when_within_limit() {
878        let original = make_bins(&[(5, 10), (6, 20)]);
879        let mut bins = original.clone();
880        trim_left(&mut bins, 2);
881        assert_eq!(to_pairs(&bins), to_pairs(&original));
882        trim_left(&mut bins, 3);
883        assert_eq!(to_pairs(&bins), to_pairs(&original));
884    }
885
886    /// Regression test for `trim_left` bin count with large per-sample weights.
887    ///
888    /// With the old `u16` layout, a sample weight of ~260M would generate `ceil(260M / 65535)`, or 3969, bins per key,
889    /// causing bin count explosion and an encoder panic. With `u32`, the same weight fits in a single bin (up to
890    /// `u32::MAX`, ~4.3 billion, samples per bin), so one `insert_n` call produces exactly one bin per key and the bin
891    /// limit is trivially respected.
892    ///
893    /// This test inserts several values with a weight representative of what ADP receives when clamping an incoming
894    /// sample rate of `3e-9` to its minimum of `3.845e-9` (~260M per sample), then asserts the bin count never exceeds
895    /// `DDSKETCH_CONF_BIN_LIMIT`.
896    #[test]
897    fn trim_left_respects_bin_limit_with_large_weights() {
898        // Weight corresponding to ADP's minimum safe sample rate (1 / 3.845e-9 ≈ 260_078_024).
899        // With u32 bins, this fits in a single bin per key (260_078_024 < u32::MAX).
900        let weight: u64 = 260_078_024;
901        let bin_limit = usize::from(DDSKETCH_CONF_BIN_LIMIT);
902
903        let mut sketch = DDSketch::default();
904
905        // Insert enough distinct values to repeatedly trigger trim_left.  Ten values is more
906        // than sufficient; two already exceed the bin limit without the fix.
907        for i in 1..=10_i32 {
908            sketch.insert_n(f64::from(i), weight);
909            assert!(
910                sketch.bins().len() <= bin_limit,
911                "bin count {} exceeded limit {} after inserting {} value(s) at weight {}",
912                sketch.bins().len(),
913                bin_limit,
914                i,
915                weight,
916            );
917        }
918    }
919
920    #[test]
921    fn interpolate_buckets_does_not_seed_zero_bin_for_positive_bucket_after_zero_bound() {
922        let mut sketch = DDSketch::default();
923
924        sketch
925            .insert_interpolate_buckets(vec![
926                Bucket {
927                    upper_limit: 0.0,
928                    count: 0,
929                },
930                Bucket {
931                    upper_limit: 10.0,
932                    count: 10,
933                },
934            ])
935            .expect("buckets should interpolate");
936
937        assert_eq!(sketch.count(), 10);
938        assert!(
939            sketch.min().expect("sketch should not be empty") > 0.0,
940            "positive bucket after a zero lower bound must not seed a zero minimum"
941        );
942    }
943
944    /// When the accumulated missing mass plus the first kept bin's existing count exceeds
945    /// u32::MAX, increment saturates at u32::MAX and the remainder is discarded.
946    ///
947    /// Input:  [(0, u32::MAX), (1, 1)]  limit=1  →  remove 1 bin
948    /// missing = u32::MAX; bins[1].increment(u32::MAX): next = u32::MAX+1 > u32::MAX
949    /// → n = u32::MAX, remainder = 1 (discarded)
950    /// Final: [(1, u32::MAX)]—1 observation lost
951    #[test]
952    fn trim_left_saturates_first_kept_bin_and_discards_remainder() {
953        let mut bins = make_bins(&[(0, u32::MAX), (1, 1)]);
954        trim_left(&mut bins, 1);
955        assert_eq!(bins.len(), 1);
956        assert_eq!(bins[0].k, 1);
957        assert_eq!(bins[0].n, u32::MAX);
958    }
959
960    /// Collapsing works correctly with negative bin keys. The highest key always survives;
961    /// lower (more negative) keys collapse into the first kept key.
962    /// Adapted from TestAddIntDatasets in sketches-go which covers negative indices.
963    ///
964    /// Input:  [(-3,5), (-2,3), (-1,2), (0,1)]  limit=2  →  remove 2 bins
965    /// missing = 5+3=8; bins[-1].increment(8): 2+8=10 < u32::MAX → n=10
966    /// Final: [(-1,10), (0,1)]
967    #[test]
968    fn trim_left_collapses_negative_keys_correctly() {
969        let mut bins = make_bins(&[(-3, 5), (-2, 3), (-1, 2), (0, 1)]);
970        trim_left(&mut bins, 2);
971        assert_eq!(to_pairs(&bins), vec![(-1, 10), (0, 1)]);
972    }
973
974    /// Monotonic ascending sequence: inserting keys 0..N where N > limit collapses the
975    /// lowest keys into the first kept key, with their total mass summed there.
976    /// Adapted from TestAddMonotonous in sketches-go.
977    ///
978    /// Input:  [(0,1),(1,1),(2,1),(3,1),(4,1),(5,1),(6,1),(7,1),(8,1),(9,1)]  limit=4
979    /// num_to_remove=6; missing=6; bins[6].increment(6): 1+6=7 → n=7
980    /// Final: [(6,7),(7,1),(8,1),(9,1)] —only top 4 keys kept, collapsed mass in first
981    #[test]
982    fn trim_left_monotonic_ascending_keeps_top_keys() {
983        let pairs: Vec<(i16, u32)> = (0..10).map(|i| (i, 1)).collect();
984        let mut bins = make_bins(&pairs);
985        trim_left(&mut bins, 4);
986        assert_eq!(to_pairs(&bins), vec![(6, 7), (7, 1), (8, 1), (9, 1)]);
987    }
988
989    #[test]
990    fn merge_handles_zero_count_summaries_without_dropping_bins() {
991        for (name, destination_is_zero_count, source_is_zero_count, expected_summary) in [
992            ("zero-count source", false, true, 10.0),
993            ("zero-count destination", true, false, 20.0),
994        ] {
995            let mut destination = DDSketch::default();
996            destination.insert(10.0);
997
998            let mut source = DDSketch::default();
999            source.insert(20.0);
1000
1001            if destination_is_zero_count {
1002                destination.set_count(0);
1003                destination.set_min(-1.0);
1004                destination.set_max(100.0);
1005                destination.set_sum(999.0);
1006                destination.set_avg(f64::NAN);
1007            }
1008
1009            if source_is_zero_count {
1010                source.set_count(0);
1011                source.set_min(-1.0);
1012                source.set_max(100.0);
1013                source.set_sum(999.0);
1014                source.set_avg(f64::NAN);
1015            }
1016
1017            destination.merge(&source);
1018
1019            assert_eq!(destination.count(), 1, "{name}");
1020            assert_eq!(destination.stored_min(), expected_summary, "{name}");
1021            assert_eq!(destination.stored_max(), expected_summary, "{name}");
1022            assert_eq!(destination.stored_sum(), expected_summary, "{name}");
1023            assert_eq!(destination.stored_avg(), expected_summary, "{name}");
1024            assert_eq!(destination.bin_count(), 2, "{name}");
1025        }
1026    }
1027
1028    #[cfg(feature = "serde")]
1029    #[test]
1030    fn json_null_summary_round_trips_as_nan() {
1031        let mut sketch = DDSketch::default();
1032        sketch.insert(42.0);
1033        sketch.set_avg(f64::NAN);
1034
1035        let encoded = serde_json::to_string(&sketch).expect("sketch should serialize");
1036        assert!(encoded.contains("\"avg\":null"));
1037
1038        let decoded: DDSketch = serde_json::from_str(&encoded).expect("sketch should deserialize");
1039        assert!(decoded.avg().expect("sketch should not be empty").is_nan());
1040    }
1041}
1042
1043/// Property-based tests for `trim_left`, adapted from TestAddFuzzy / TestAddIntFuzzy /
1044/// TestMergeFuzzy in sketches-go/ddsketch/store/store_test.go.
1045///
1046/// The sketches-go suite generates random (index, count) inputs, passes them through
1047/// CollapsingLowestDenseStore, and asserts structural invariants using the `collapsingLowest`
1048/// oracle transform. We do the same here: generate random sorted distinct-key bins, run
1049/// `trim_left`, and check the same invariants.
1050#[cfg(test)]
1051mod property_tests {
1052    use proptest::prelude::*;
1053
1054    use super::*;
1055
1056    /// Strategy: a non-empty sorted vec of distinct (k: i16, n: u32) pairs.
1057    /// Keys are drawn from i16 without repetition; counts are 1..=u32::MAX.
1058    fn arb_bins(max_len: usize) -> impl Strategy<Value = SmallVec<[Bin; 4]>> {
1059        proptest::collection::btree_map(any::<i16>(), 1u32..=u32::MAX, 1..=max_len)
1060            .prop_map(|map| map.into_iter().map(|(k, n)| Bin { k, n }).collect())
1061    }
1062
1063    /// Strategy: a `bin_limit` in 1..=32 (small enough to exercise collapsing frequently).
1064    fn arb_limit() -> impl Strategy<Value = u16> {
1065        1u16..=32
1066    }
1067
1068    proptest! {
1069        /// After `trim_left`, the bin count must never exceed `bin_limit`.
1070        ///
1071        /// Mirrors the core bin-count invariant checked throughout the sketches-go suite:
1072        /// every store operation must leave the store within its configured capacity.
1073        #[test]
1074        fn prop_bin_count_never_exceeds_limit(
1075            mut bins in arb_bins(64),
1076            limit in arb_limit(),
1077        ) {
1078            trim_left(&mut bins, limit);
1079            prop_assert!(
1080                bins.len() <= limit as usize,
1081                "bin count {} exceeded limit {}",
1082                bins.len(),
1083                limit,
1084            );
1085        }
1086
1087        /// After `trim_left`, bins must remain sorted by key with no duplicate keys.
1088        ///
1089        /// `trim_left` only drains a prefix and modifies bins[num_to_remove].n in place;
1090        /// it must not disturb the ordering of the surviving bins.
1091        #[test]
1092        fn prop_output_bins_are_sorted_and_distinct(
1093            mut bins in arb_bins(64),
1094            limit in arb_limit(),
1095        ) {
1096            trim_left(&mut bins, limit);
1097            for window in bins.windows(2) {
1098                prop_assert!(
1099                    window[0].k < window[1].k,
1100                    "bins not strictly sorted after trim_left: {:?} >= {:?}",
1101                    window[0].k,
1102                    window[1].k,
1103                );
1104            }
1105        }
1106
1107        /// When bins.len() <= limit, `trim_left` is a no-op: bins is unchanged.
1108        #[test]
1109        fn prop_no_op_when_within_limit(
1110            bins in arb_bins(16),
1111            extra in 0u16..=16,
1112        ) {
1113            let limit = bins.len() as u16 + extra;
1114            let original = bins.clone();
1115            let mut bins = bins;
1116            trim_left(&mut bins, limit);
1117            prop_assert_eq!(bins, original);
1118        }
1119
1120        /// Total count is preserved exactly when no u32 overflow occurs.
1121        ///
1122        /// This is the key invariant from sketches-go's assertEncodeBins:
1123        /// `store.TotalCount()` must equal the sum of all inserted counts.
1124        /// We restrict counts to keep the collapsed sum safely below u32::MAX
1125        /// (sketches-go never loses mass because it uses float64; we match that
1126        /// guarantee for all inputs where the collapsed bin doesn't overflow u32).
1127        ///
1128        /// Strategy: counts capped at 1000 so that even if all 64 bins collapse
1129        /// into one the sum (≤ 64_000) is far below u32::MAX.
1130        #[test]
1131        fn prop_total_count_preserved_when_no_overflow(
1132            mut bins in proptest::collection::btree_map(any::<i16>(), 1u32..=1000, 1..=64usize)
1133                .prop_map(|map| -> SmallVec<[Bin; 4]> {
1134                    map.into_iter().map(|(k, n)| Bin { k, n }).collect()
1135                }),
1136            limit in arb_limit(),
1137        ) {
1138            let total_before: u64 = bins.iter().map(|b| u64::from(b.n)).sum();
1139            trim_left(&mut bins, limit);
1140            let total_after: u64 = bins.iter().map(|b| u64::from(b.n)).sum();
1141            prop_assert_eq!(
1142                total_before, total_after,
1143                "total count changed: before={}, after={}",
1144                total_before, total_after,
1145            );
1146        }
1147
1148        /// The surviving bins are always the `bin_limit` highest-key bins from the input.
1149        ///
1150        /// This is the direct encoding of the `collapsingLowest` oracle from sketches-go:
1151        /// minCollapsedIndex = `maxIndex` - limit + 1; all bins with key < minCollapsedIndex
1152        /// are removed (their mass folds into minCollapsedIndex). The output keys must
1153        /// exactly match the top min(len, limit) keys of the input.
1154        #[test]
1155        fn prop_output_keys_are_highest_from_input(
1156            mut bins in arb_bins(64),
1157            limit in arb_limit(),
1158        ) {
1159            let all_keys: Vec<i16> = bins.iter().map(|b| b.k).collect();
1160            let expected_len = all_keys.len().min(limit as usize);
1161            let expected_keys: Vec<i16> = all_keys[all_keys.len() - expected_len..].to_vec();
1162
1163            trim_left(&mut bins, limit);
1164
1165            let actual_keys: Vec<i16> = bins.iter().map(|b| b.k).collect();
1166            prop_assert_eq!(
1167                actual_keys, expected_keys,
1168                "output keys don't match top {} keys of input",
1169                limit,
1170            );
1171        }
1172    }
1173}
1174
1175/// Direct unit tests for the agent `DDSketch`'s public API.
1176///
1177/// The `tests`/`property_tests` modules above only cover the internal `trim_left` collapse helper; this module
1178/// exercises the public surface (insert/insert_n/insert_many, quantile, merge, the basic-statistics accessors,
1179/// clear, histogram-bucket interpolation, and `Dogsketch` conversion) that all production callers actually use.
1180#[cfg(test)]
1181mod public_api_tests {
1182    use datadog_protos::metrics::Dogsketch;
1183
1184    use super::*;
1185    // `remap_mapping` returns a `LogarithmicMapping`; bring the trait into scope so its `gamma()` accessor is callable.
1186    use crate::canonical::mapping::IndexMapping as _;
1187
1188    // The agent sketch is configured with eps = 1/128, giving gamma_v = 1.015625 and a per-bin relative accuracy of
1189    // ~1.56%. Interior quantiles are therefore approximate; min/max/sum/avg are tracked exactly.
1190    const QUANTILE_ABS_TOLERANCE: f64 = 2.0;
1191
1192    #[test]
1193    fn default_sketch_is_empty() {
1194        let sketch = DDSketch::default();
1195
1196        assert!(sketch.is_empty());
1197        assert_eq!(sketch.count(), 0);
1198        assert_eq!(sketch.bin_count(), 0);
1199        assert_eq!(sketch.min(), None);
1200        assert_eq!(sketch.max(), None);
1201        assert_eq!(sketch.sum(), None);
1202        assert_eq!(sketch.avg(), None);
1203        assert_eq!(sketch.quantile(0.5), None);
1204    }
1205
1206    #[test]
1207    fn insert_tracks_exact_basic_statistics() {
1208        let mut sketch = DDSketch::default();
1209        sketch.insert(1.0);
1210        sketch.insert(2.0);
1211        sketch.insert(3.0);
1212
1213        assert!(!sketch.is_empty());
1214        assert_eq!(sketch.count(), 3);
1215        // min/max/sum/avg are recorded from the raw samples, so they're exact (not bucketed).
1216        assert_eq!(sketch.min(), Some(1.0));
1217        assert_eq!(sketch.max(), Some(3.0));
1218        assert_eq!(sketch.sum(), Some(6.0));
1219        assert_eq!(sketch.avg(), Some(2.0));
1220    }
1221
1222    #[test]
1223    fn insert_n_applies_the_weight_to_every_statistic() {
1224        let mut sketch = DDSketch::default();
1225        sketch.insert_n(10.0, 5);
1226
1227        assert_eq!(sketch.count(), 5);
1228        assert_eq!(sketch.min(), Some(10.0));
1229        assert_eq!(sketch.max(), Some(10.0));
1230        assert_eq!(sketch.sum(), Some(50.0));
1231        assert_eq!(sketch.avg(), Some(10.0));
1232    }
1233
1234    #[test]
1235    fn insert_many_matches_repeated_single_inserts() {
1236        let mut many = DDSketch::default();
1237        many.insert_many(&[1.0, 2.0, 3.0, 4.0]);
1238
1239        let mut single = DDSketch::default();
1240        for v in [1.0, 2.0, 3.0, 4.0] {
1241            single.insert(v);
1242        }
1243
1244        assert_eq!(many.count(), 4);
1245        // Feeding identical samples through either entry point must produce an identical sketch.
1246        assert_eq!(many, single);
1247    }
1248
1249    #[test]
1250    fn quantile_at_extremes_returns_exact_min_and_max() {
1251        let mut sketch = DDSketch::default();
1252        for i in 1..=100 {
1253            sketch.insert(f64::from(i));
1254        }
1255
1256        // q <= 0 and q >= 1 short-circuit to the exactly-tracked min/max.
1257        assert_eq!(sketch.quantile(0.0), Some(1.0));
1258        assert_eq!(sketch.quantile(-1.0), Some(1.0));
1259        assert_eq!(sketch.quantile(1.0), Some(100.0));
1260        assert_eq!(sketch.quantile(2.0), Some(100.0));
1261    }
1262
1263    #[test]
1264    fn quantile_estimates_interior_percentiles_within_relative_accuracy() {
1265        let mut sketch = DDSketch::default();
1266        for i in 1..=100 {
1267            sketch.insert(f64::from(i));
1268        }
1269
1270        let median = sketch.quantile(0.5).expect("non-empty sketch has a median");
1271        assert!(
1272            (median - 50.0).abs() <= QUANTILE_ABS_TOLERANCE,
1273            "median {} should be within {} of 50",
1274            median,
1275            QUANTILE_ABS_TOLERANCE
1276        );
1277
1278        let p90 = sketch.quantile(0.9).expect("non-empty sketch has a p90");
1279        assert!(
1280            (p90 - 90.0).abs() <= QUANTILE_ABS_TOLERANCE,
1281            "p90 {} should be near 90",
1282            p90
1283        );
1284    }
1285
1286    #[test]
1287    fn merge_combines_counts_bounds_and_sums() {
1288        let mut sketch1 = DDSketch::default();
1289        sketch1.insert(1.0);
1290        sketch1.insert(2.0);
1291
1292        let mut sketch2 = DDSketch::default();
1293        sketch2.insert(3.0);
1294        sketch2.insert(4.0);
1295
1296        sketch1.merge(&sketch2);
1297
1298        assert_eq!(sketch1.count(), 4);
1299        assert_eq!(sketch1.min(), Some(1.0));
1300        assert_eq!(sketch1.max(), Some(4.0));
1301        assert_eq!(sketch1.sum(), Some(10.0));
1302        assert_eq!(sketch1.avg(), Some(2.5));
1303    }
1304
1305    #[test]
1306    fn merge_produces_same_sketch_as_inserting_all_samples() {
1307        let mut merged = DDSketch::default();
1308        let mut left = DDSketch::default();
1309        let mut right = DDSketch::default();
1310        for i in 1..=50 {
1311            left.insert(f64::from(i));
1312        }
1313        for i in 51..=100 {
1314            right.insert(f64::from(i));
1315        }
1316        merged.merge(&left);
1317        merged.merge(&right);
1318
1319        let mut all_at_once = DDSketch::default();
1320        for i in 1..=100 {
1321            all_at_once.insert(f64::from(i));
1322        }
1323
1324        assert_eq!(merged.count(), all_at_once.count());
1325        assert_eq!(merged.bins(), all_at_once.bins());
1326    }
1327
1328    #[test]
1329    fn clear_resets_the_sketch_to_empty() {
1330        let mut sketch = DDSketch::default();
1331        sketch.insert(1.0);
1332        sketch.insert(2.0);
1333
1334        sketch.clear();
1335
1336        assert!(sketch.is_empty());
1337        assert_eq!(sketch.count(), 0);
1338        assert_eq!(sketch.bin_count(), 0);
1339        assert_eq!(sketch.min(), None);
1340    }
1341
1342    #[test]
1343    fn setters_override_the_tracked_statistics() {
1344        let mut sketch = DDSketch::default();
1345        sketch.insert(1.0);
1346
1347        sketch.set_count(100);
1348        sketch.set_sum(42.0);
1349        sketch.set_avg(0.42);
1350        sketch.set_min(-5.0);
1351        sketch.set_max(500.0);
1352
1353        assert_eq!(sketch.count(), 100);
1354        assert_eq!(sketch.sum(), Some(42.0));
1355        assert_eq!(sketch.avg(), Some(0.42));
1356        assert_eq!(sketch.min(), Some(-5.0));
1357        assert_eq!(sketch.max(), Some(500.0));
1358    }
1359
1360    #[test]
1361    fn insert_interpolate_buckets_preserves_total_count() {
1362        let mut sketch = DDSketch::default();
1363        sketch
1364            .insert_interpolate_buckets(vec![
1365                Bucket {
1366                    upper_limit: 10.0,
1367                    count: 4,
1368                },
1369                Bucket {
1370                    upper_limit: 20.0,
1371                    count: 6,
1372                },
1373            ])
1374            .expect("well-formed buckets should interpolate successfully");
1375
1376        // Every observation from the histogram buckets must be represented in the sketch.
1377        assert_eq!(sketch.count(), 10);
1378        let median = sketch.quantile(0.5).expect("non-empty sketch has a median");
1379        assert!(
1380            (5.0..=25.0).contains(&median),
1381            "median {} should land within the interpolated 10..20 range",
1382            median
1383        );
1384    }
1385
1386    #[test]
1387    fn insert_interpolate_buckets_rejects_oversized_buckets() {
1388        let mut sketch = DDSketch::default();
1389        let result = sketch.insert_interpolate_buckets(vec![Bucket {
1390            upper_limit: 10.0,
1391            count: u64::from(u32::MAX) + 1,
1392        }]);
1393
1394        assert_eq!(result, Err("bucket size greater than u32::MAX"));
1395    }
1396
1397    #[test]
1398    fn dogsketch_round_trip_preserves_the_sketch() {
1399        let mut sketch = DDSketch::default();
1400        for i in 1..=25 {
1401            sketch.insert(f64::from(i));
1402        }
1403
1404        let mut dogsketch = Dogsketch::new();
1405        sketch.merge_to_dogsketch(&mut dogsketch);
1406        let recovered = DDSketch::try_from(dogsketch).expect("dogsketch produced by merge_to_dogsketch is valid");
1407
1408        assert_eq!(sketch, recovered);
1409    }
1410
1411    #[test]
1412    fn try_from_dogsketch_rejects_mismatched_bin_vectors() {
1413        let mut dogsketch = Dogsketch::new();
1414        dogsketch.set_cnt(3);
1415        dogsketch.set_k(vec![1, 2]);
1416        dogsketch.set_n(vec![1]); // fewer counts than keys
1417
1418        let result = DDSketch::try_from(dogsketch);
1419        assert_eq!(result, Err("k and n bin vectors have differing lengths"));
1420    }
1421
1422    #[test]
1423    fn value_for_key_is_zero_at_the_origin_and_monotonic() {
1424        assert_eq!(DDSketch::value_for_key(0), 0.0);
1425
1426        // Higher keys map to strictly larger representative values within the positive range.
1427        let low = DDSketch::value_for_key(100);
1428        let high = DDSketch::value_for_key(200);
1429        assert!(low > 0.0, "positive key should map to a positive value, got {}", low);
1430        assert!(high > low, "value_for_key should be monotonic: {} !> {}", high, low);
1431    }
1432
1433    #[test]
1434    fn remap_mapping_uses_the_agent_gamma() {
1435        let mapping = DDSketch::remap_mapping();
1436        assert!(crate::common::float_eq(mapping.gamma(), DDSKETCH_CONF_GAMMA_V));
1437    }
1438}