ddsketch/canonical/store/
mod.rs1use datadog_protos::sketches::Store as ProtoStore;
4
5use super::error::ProtoConversionError;
6
7mod collapsing_highest;
8pub use self::collapsing_highest::CollapsingHighestDenseStore;
9
10mod collapsing_lowest;
11pub use self::collapsing_lowest::CollapsingLowestDenseStore;
12
13mod dense;
14pub use self::dense::DenseStore;
15
16mod sparse;
17pub use self::sparse::SparseStore;
18
19pub trait Store: Clone + Send + Sync {
24 fn add(&mut self, index: i32, count: u64);
26
27 fn total_count(&self) -> u64;
29
30 fn min_index(&self) -> Option<i32>;
32
33 fn max_index(&self) -> Option<i32>;
35
36 fn key_at_rank(&self, rank: u64) -> Option<i32>;
40
41 fn merge(&mut self, other: &Self);
43
44 fn is_empty(&self) -> bool;
46
47 fn clear(&mut self);
49
50 fn merge_from_proto(&mut self, proto: &ProtoStore) -> Result<(), ProtoConversionError>;
52
53 fn to_proto(&self) -> ProtoStore;
55}
56
57pub(crate) fn validate_proto_count(index: i32, count: f64) -> Result<u64, ProtoConversionError> {
63 if count < 0.0 {
64 return Err(ProtoConversionError::NegativeBinCount { index, count });
65 }
66 if count.fract() != 0.0 {
67 return Err(ProtoConversionError::NonIntegerBinCount { index, count });
68 }
69 Ok(count as u64)
70}
71
72#[cfg(test)]
84macro_rules! store_conformance_tests {
85 ($store:ty) => {
86 #[test]
87 fn add_single_value_sets_count_and_bounds() {
88 let mut store = <$store>::default();
89 store.add(5, 1);
90
91 assert_eq!(store.total_count(), 1);
92 assert_eq!(store.min_index(), Some(5));
93 assert_eq!(store.max_index(), Some(5));
94 }
95
96 #[test]
97 fn add_repeated_at_same_index_accumulates() {
98 let mut store = <$store>::default();
99 store.add(5, 3);
100 store.add(5, 2);
101
102 assert_eq!(store.total_count(), 5);
103 assert_eq!(store.min_index(), Some(5));
104 assert_eq!(store.max_index(), Some(5));
105 }
106
107 #[test]
108 fn add_distinct_indices_tracks_distribution_and_bounds() {
109 let mut store = <$store>::default();
110 store.add(5, 1);
111 store.add(10, 2);
112 store.add(3, 3);
113
114 assert_eq!(store.total_count(), 6);
115 assert_eq!(store.min_index(), Some(3));
116 assert_eq!(store.max_index(), Some(10));
117 assert_eq!(
118 $crate::canonical::store::conformance::distribution(&store),
119 [(3, 3), (5, 1), (10, 2)].into_iter().collect()
120 );
121 }
122
123 #[test]
124 fn add_with_zero_count_is_a_noop() {
125 let mut store = <$store>::default();
126 store.add(5, 0);
127
128 assert!(store.is_empty());
129 assert_eq!(store.total_count(), 0);
130 assert_eq!(store.min_index(), None);
131 }
132
133 #[test]
134 fn key_at_rank_maps_each_rank_to_its_index() {
135 let mut store = <$store>::default();
136 store.add(5, 3);
137 store.add(10, 2);
138
139 assert_eq!(store.key_at_rank(0), Some(5));
140 assert_eq!(store.key_at_rank(2), Some(5));
141 assert_eq!(store.key_at_rank(3), Some(10));
142 assert_eq!(store.key_at_rank(4), Some(10));
143 assert_eq!(store.key_at_rank(5), None);
144 }
145
146 #[test]
147 fn empty_store_reports_no_min_max_or_rank() {
148 let store = <$store>::default();
149
150 assert!(store.is_empty());
151 assert_eq!(store.total_count(), 0);
152 assert_eq!(store.min_index(), None);
153 assert_eq!(store.max_index(), None);
154 assert_eq!(store.key_at_rank(0), None);
155 }
156
157 #[test]
158 fn merge_combines_distributions() {
159 let mut store1 = <$store>::default();
160 store1.add(5, 2);
161 store1.add(10, 1);
162
163 let mut store2 = <$store>::default();
164 store2.add(5, 1);
165 store2.add(15, 3);
166
167 store1.merge(&store2);
168
169 assert_eq!(store1.total_count(), 7);
170 assert_eq!(store1.min_index(), Some(5));
171 assert_eq!(store1.max_index(), Some(15));
172 assert_eq!(
173 $crate::canonical::store::conformance::distribution(&store1),
174 [(5, 3), (10, 1), (15, 3)].into_iter().collect()
175 );
176 }
177
178 #[test]
179 fn merge_from_empty_store_is_a_noop() {
180 let mut store = <$store>::default();
181 store.add(5, 2);
182
183 let empty = <$store>::default();
184 store.merge(&empty);
185
186 assert_eq!(store.total_count(), 2);
187 assert_eq!(
188 $crate::canonical::store::conformance::distribution(&store),
189 [(5, 2)].into_iter().collect()
190 );
191 }
192
193 #[test]
194 fn clear_resets_to_empty() {
195 let mut store = <$store>::default();
196 store.add(5, 2);
197 store.add(10, 1);
198
199 store.clear();
200
201 assert!(store.is_empty());
202 assert_eq!(store.total_count(), 0);
203 assert_eq!(store.min_index(), None);
204 }
205
206 #[test]
207 fn handles_negative_indices() {
208 let mut store = <$store>::default();
209 store.add(-5, 1);
210 store.add(5, 1);
211
212 assert_eq!(store.total_count(), 2);
213 assert_eq!(store.min_index(), Some(-5));
214 assert_eq!(store.max_index(), Some(5));
215 assert_eq!(
216 $crate::canonical::store::conformance::distribution(&store),
217 [(-5, 1), (5, 1)].into_iter().collect()
218 );
219 }
220
221 #[test]
222 fn handles_widely_scattered_indices() {
223 let mut store = <$store>::default();
224 store.add(-1000, 1);
225 store.add(0, 2);
226 store.add(1000, 3);
227
228 assert_eq!(store.total_count(), 6);
229 assert_eq!(store.min_index(), Some(-1000));
230 assert_eq!(store.max_index(), Some(1000));
231 assert_eq!(
232 $crate::canonical::store::conformance::distribution(&store),
233 [(-1000, 1), (0, 2), (1000, 3)].into_iter().collect()
234 );
235 }
236
237 #[test]
238 fn proto_round_trip_preserves_distribution() {
239 let mut store = <$store>::default();
240 store.add(-3, 4);
241 store.add(5, 2);
242 store.add(10, 1);
243
244 let proto = store.to_proto();
245 let mut restored = <$store>::default();
246 restored
247 .merge_from_proto(&proto)
248 .expect("round-tripped proto must be valid");
249
250 assert_eq!(restored.total_count(), store.total_count());
251 assert_eq!(
252 $crate::canonical::store::conformance::distribution(&restored),
253 $crate::canonical::store::conformance::distribution(&store)
254 );
255 }
256 };
257}
258
259#[cfg(test)]
260pub(crate) use store_conformance_tests;
261
262#[cfg(test)]
263pub(crate) mod conformance {
264 use std::collections::BTreeMap;
265
266 use super::Store;
267
268 pub(crate) fn distribution<S: Store>(store: &S) -> BTreeMap<i32, u64> {
274 let mut dist = BTreeMap::new();
275 for rank in 0..store.total_count() {
276 let index = store
277 .key_at_rank(rank)
278 .expect("every rank below total_count must map to an index");
279 *dist.entry(index).or_insert(0) += 1;
280 }
281 dist
282 }
283}