ddsketch/canonical/store/
collapsing_highest.rs

1use datadog_protos::sketches::Store as ProtoStore;
2
3use super::{validate_proto_count, Store};
4use crate::canonical::error::ProtoConversionError;
5
6/// A dense store that collapses highest-indexed bins when capacity is exceeded.
7///
8/// This store maintains a maximum number of bins. When adding a new index would exceed this limit, the highest-indexed
9/// bins are collapsed (merged into the next highest bin), sacrificing accuracy for higher quantiles to preserve
10/// accuracy for lower quantiles.
11///
12/// Use this store when:
13/// - You need bounded memory usage
14/// - Lower quantiles (for example, p1, p5) are more important than higher quantiles
15/// - You're tracking metrics where the minimum values matter most
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct CollapsingHighestDenseStore {
18    /// The bin counts, stored contiguously.
19    bins: Vec<u64>,
20
21    /// The count stored in bins[0] corresponds to this index.
22    offset: i32,
23
24    /// Maximum number of bins to maintain.
25    max_num_bins: usize,
26
27    /// Total count across all bins.
28    count: u64,
29
30    /// Whether collapsing has occurred (accuracy may be compromised for high quantiles).
31    is_collapsed: bool,
32}
33
34impl CollapsingHighestDenseStore {
35    /// Creates an empty `CollapsingHighestDenseStore` with the given maximum number of bins.
36    pub fn new(max_num_bins: usize) -> Self {
37        assert!(max_num_bins >= 1, "max_num_bins must be at least 1");
38        Self {
39            bins: Vec::new(),
40            offset: 0,
41            max_num_bins,
42            count: 0,
43            is_collapsed: false,
44        }
45    }
46
47    /// Returns `true` if this store has collapsed bins.
48    ///
49    /// If true, accuracy guarantees may not hold for higher quantiles.
50    pub fn is_collapsed(&self) -> bool {
51        self.is_collapsed
52    }
53
54    /// Ensures the store can accommodate the given index, growing and collapsing if necessary.
55    fn grow(&mut self, index: i32) {
56        if self.bins.is_empty() {
57            self.bins.push(0);
58            self.offset = index;
59            return;
60        }
61
62        if index >= self.offset + self.bins.len() as i32 {
63            // Need to append bins - but first check if we need to collapse
64            let new_len = (index - self.offset + 1) as usize;
65
66            if new_len > self.max_num_bins {
67                // We need to collapse the new high indices into the current highest
68                // Don't actually add the bins, just record that we collapsed
69                self.is_collapsed = true;
70                // The index is above our range, so when we add the count,
71                // we'll add it to the highest bin
72                return;
73            }
74
75            self.bins.resize(new_len, 0);
76        } else if index < self.offset {
77            // Need to prepend bins
78            let num_prepend = (self.offset - index) as usize;
79            let new_len = self.bins.len() + num_prepend;
80
81            if new_len > self.max_num_bins {
82                // Need to collapse highest bins to make room for lower indices
83                let bins_to_collapse = new_len - self.max_num_bins;
84                self.collapse_highest(bins_to_collapse);
85            }
86
87            // Now prepend
88            let target_prepend =
89                ((self.offset - index) as usize).min(self.max_num_bins - self.bins.len().min(self.max_num_bins));
90            if target_prepend > 0 {
91                let mut new_bins = vec![0u64; target_prepend + self.bins.len()];
92                new_bins[target_prepend..].copy_from_slice(&self.bins);
93                self.bins = new_bins;
94                self.offset = index;
95            }
96        }
97    }
98
99    /// Collapses the highest `n` bins into the bin at index `len - n - 1`.
100    fn collapse_highest(&mut self, n: usize) {
101        if n == 0 || self.bins.is_empty() {
102            return;
103        }
104
105        self.is_collapsed = true;
106
107        let n = n.min(self.bins.len() - 1);
108        if n == 0 {
109            return;
110        }
111
112        let collapse_start = self.bins.len() - n;
113
114        // Sum up the bins to collapse
115        let collapsed_count: u64 = self.bins[collapse_start..].iter().sum();
116
117        // Add to the bin that will become the new highest
118        self.bins[collapse_start - 1] = self.bins[collapse_start - 1].saturating_add(collapsed_count);
119
120        // Remove the collapsed bins
121        self.bins.truncate(collapse_start);
122    }
123
124    /// Returns the index into the bins array for the given logical index.
125    #[inline]
126    fn bin_index(&self, index: i32) -> Option<usize> {
127        if index >= self.offset + self.bins.len() as i32 {
128            // Index is above our range, map to highest bin
129            if self.bins.is_empty() {
130                None
131            } else {
132                Some(self.bins.len() - 1)
133            }
134        } else if index < self.offset {
135            None
136        } else {
137            Some((index - self.offset) as usize)
138        }
139    }
140}
141
142impl Store for CollapsingHighestDenseStore {
143    fn add(&mut self, index: i32, count: u64) {
144        if count == 0 {
145            return;
146        }
147
148        self.grow(index);
149
150        if let Some(bin_idx) = self.bin_index(index) {
151            self.bins[bin_idx] = self.bins[bin_idx].saturating_add(count);
152        }
153        self.count = self.count.saturating_add(count);
154    }
155
156    fn total_count(&self) -> u64 {
157        self.count
158    }
159
160    fn min_index(&self) -> Option<i32> {
161        if self.bins.is_empty() {
162            return None;
163        }
164
165        for (i, &count) in self.bins.iter().enumerate() {
166            if count > 0 {
167                return Some(self.offset + i as i32);
168            }
169        }
170        None
171    }
172
173    fn max_index(&self) -> Option<i32> {
174        if self.bins.is_empty() {
175            return None;
176        }
177
178        for (i, &count) in self.bins.iter().enumerate().rev() {
179            if count > 0 {
180                return Some(self.offset + i as i32);
181            }
182        }
183        None
184    }
185
186    fn key_at_rank(&self, rank: u64) -> Option<i32> {
187        if rank >= self.count {
188            return None;
189        }
190
191        let mut cumulative = 0u64;
192        for (i, &count) in self.bins.iter().enumerate() {
193            cumulative += count;
194            if cumulative > rank {
195                return Some(self.offset + i as i32);
196            }
197        }
198        None
199    }
200
201    fn merge(&mut self, other: &Self) {
202        if other.bins.is_empty() {
203            return;
204        }
205
206        if other.is_collapsed {
207            self.is_collapsed = true;
208        }
209
210        // Process each bin from the other store
211        for (i, &count) in other.bins.iter().enumerate() {
212            if count > 0 {
213                let index = other.offset + i as i32;
214                self.add(index, count);
215            }
216        }
217    }
218
219    fn is_empty(&self) -> bool {
220        self.count == 0
221    }
222
223    fn clear(&mut self) {
224        self.bins.clear();
225        self.offset = 0;
226        self.count = 0;
227        self.is_collapsed = false;
228    }
229
230    fn merge_from_proto(&mut self, proto: &ProtoStore) -> Result<(), ProtoConversionError> {
231        for (&index, &count) in &proto.binCounts {
232            let count = validate_proto_count(index, count)?;
233            if count > 0 {
234                self.add(index, count);
235            }
236        }
237
238        let offset = proto.contiguousBinIndexOffset;
239        for (i, &count) in proto.contiguousBinCounts.iter().enumerate() {
240            let index = offset + i as i32;
241            let count = validate_proto_count(index, count)?;
242            if count > 0 {
243                self.add(index, count);
244            }
245        }
246
247        Ok(())
248    }
249
250    fn to_proto(&self) -> ProtoStore {
251        let mut proto = ProtoStore::new();
252
253        if self.bins.is_empty() {
254            return proto;
255        }
256
257        // Use contiguous encoding for dense store
258        proto.contiguousBinIndexOffset = self.offset;
259        proto.contiguousBinCounts = self.bins.iter().map(|&c| c as f64).collect();
260
261        proto
262    }
263}
264
265impl Default for CollapsingHighestDenseStore {
266    /// Creates a collapsing highest dense store with a default of 2048 bins.
267    fn default() -> Self {
268        Self::new(2048)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    // Shared `Store` trait conformance suite. The default 2048-bin capacity is far larger than the small index
277    // ranges these cases use, so no collapsing occurs and the standard `Store` contract holds.
278    crate::canonical::store::store_conformance_tests!(CollapsingHighestDenseStore);
279
280    #[test]
281    fn within_limit_does_not_collapse() {
282        let mut store = CollapsingHighestDenseStore::new(10);
283        for i in 0..10 {
284            store.add(i, 1);
285        }
286
287        assert_eq!(store.total_count(), 10);
288        assert!(!store.is_collapsed());
289        assert_eq!(store.bins.len(), 10);
290    }
291
292    #[test]
293    fn collapse_on_low_index() {
294        let mut store = CollapsingHighestDenseStore::new(5);
295
296        // Add bins 5-9
297        for i in 5..10 {
298            store.add(i, 1);
299        }
300        assert!(!store.is_collapsed());
301
302        // Adding index 0 should trigger collapse of highest bins
303        store.add(0, 1);
304
305        assert!(store.is_collapsed());
306        assert_eq!(store.total_count(), 6);
307        assert!(store.bins.len() <= 5);
308    }
309
310    #[test]
311    fn collapse_on_high_index() {
312        let mut store = CollapsingHighestDenseStore::new(5);
313
314        // Add bins 0-4
315        for i in 0..5 {
316            store.add(i, 1);
317        }
318        assert!(!store.is_collapsed());
319
320        // Adding index 10 should trigger collapse since it would need more than 5 bins
321        store.add(10, 1);
322
323        assert!(store.is_collapsed());
324        assert_eq!(store.total_count(), 6);
325    }
326
327    #[test]
328    fn key_at_rank_after_collapse() {
329        let mut store = CollapsingHighestDenseStore::new(3);
330
331        store.add(0, 1);
332        store.add(1, 1);
333        store.add(2, 1);
334        // Adding a lower index should trigger collapse
335        store.add(-1, 1);
336
337        assert!(store.is_collapsed());
338        assert_eq!(store.total_count(), 4);
339
340        // All counts should still be accounted for
341        assert!(store.key_at_rank(0).is_some());
342        assert!(store.key_at_rank(3).is_some());
343        assert!(store.key_at_rank(4).is_none());
344    }
345
346    #[test]
347    fn merge_respects_collapse() {
348        let mut store1 = CollapsingHighestDenseStore::new(5);
349        store1.add(0, 1);
350
351        let mut store2 = CollapsingHighestDenseStore::new(5);
352        for i in 0..10 {
353            store2.add(i, 1);
354        }
355
356        assert!(store2.is_collapsed());
357
358        store1.merge(&store2);
359
360        assert!(store1.is_collapsed());
361    }
362}