Skip to main content

subms_count_min_sketch/
lib.rs

1//! Count-Min Sketch with conservative update and Kirsch-Mitzenmacher hashing.
2//!
3//! `d` rows of `w` counters; each insert increments only the minimum cell(s)
4//! across the d rows. Query returns the minimum cell across the d rows.
5//! Width is rounded up to a power of two so indexing is a bitmask, not a `%`.
6//!
7//! Estimates are one-sided: `estimate(k) >= true_count(k)` always, with the
8//! over-count bounded by `relative_error() * total()` at `confidence()`.
9//!
10//! ```
11//! use subms_count_min_sketch::CountMinSketch;
12//!
13//! // Size from the error budget rather than guessing (d, w): 0.1% of the
14//! // stream volume, 99.9% of the time.
15//! let mut cms = CountMinSketch::with_error_bounds(0.001, 0.999);
16//! for _ in 0..1000 { cms.add("ESZ5"); }
17//! for i in 0..50_000 { cms.add_u64(i); }
18//!
19//! let est = cms.estimate("ESZ5");
20//! assert!(est >= 1000);
21//! assert!(cms.estimate_lower_bound("ESZ5") <= 1000);
22//! assert_eq!(cms.total(), 51_000);
23//!
24//! // Checkpoint and restore without a serialization dependency.
25//! let bytes = cms.to_bytes();
26//! let restored = CountMinSketch::from_bytes(&bytes).unwrap();
27//! assert_eq!(restored.estimate("ESZ5"), est);
28//! ```
29//!
30//! Not thread-safe. Every mutator takes `&mut self`, so a shared sketch needs
31//! external synchronisation; the intended concurrent shape is one sketch per
32//! writer thread folded with the `merge` feature at the join.
33//!
34//! Full writeup, design notes and measured benchmarks:
35//! <https://www.submillisecond.com/cookbook/recipes/subms-count-min-sketch>
36
37use core::f64::consts::E;
38
39const FNV_OFFSET: u64 = 0xcbf29ce484222325;
40const FNV_PRIME: u64 = 0x100000001b3;
41
42/// Row cap. The add path keeps its index set in a fixed stack array, and
43/// `d = 16` already puts the failure probability at `e^-16`, so a deeper
44/// sketch buys nothing a wider one would not buy more cheaply.
45pub const MAX_DEPTH: usize = 16;
46
47const SNAPSHOT_MAGIC: [u8; 8] = *b"SUBMSCMS";
48const SNAPSHOT_VERSION: u16 = 1;
49const SNAPSHOT_HEADER: usize = 32;
50
51/// Why a byte slice could not be decoded into a sketch.
52#[derive(Debug, PartialEq, Eq)]
53pub enum SnapshotError {
54    BadMagic,
55    UnsupportedVersion(u16),
56    BadShape { depth: usize, width: usize },
57    Truncated { expected: usize, actual: usize },
58}
59
60impl core::fmt::Display for SnapshotError {
61    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
62        match self {
63            SnapshotError::BadMagic => write!(f, "not a count-min-sketch snapshot"),
64            SnapshotError::UnsupportedVersion(v) => write!(f, "unsupported snapshot version {v}"),
65            SnapshotError::BadShape { depth, width } => {
66                write!(f, "invalid shape: depth={depth}, width={width}")
67            }
68            SnapshotError::Truncated { expected, actual } => {
69                write!(
70                    f,
71                    "truncated snapshot: expected {expected} bytes, got {actual}"
72                )
73            }
74        }
75    }
76}
77
78impl std::error::Error for SnapshotError {}
79
80pub struct CountMinSketch {
81    d: usize,
82    w: usize,
83    mask: usize,
84    seed: u64,
85    total: u64,
86    rows: Vec<Vec<u32>>,
87}
88
89// Hand-written so a debug print stays a line rather than a d*w counter dump.
90impl core::fmt::Debug for CountMinSketch {
91    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
92        f.debug_struct("CountMinSketch")
93            .field("depth", &self.d)
94            .field("width", &self.w)
95            .field("seed", &self.seed)
96            .field("total", &self.total)
97            .finish()
98    }
99}
100
101impl CountMinSketch {
102    /// `d` hash functions (rows, clamped to `2..=MAX_DEPTH`); `w` is rounded up
103    /// to a power of two. Standard sizing d=5, w=16384 gives an additive error
104    /// of at most `e/w` of the stream volume with probability `1 - e^-d`.
105    pub fn new(d: usize, w: usize) -> Self {
106        Self::with_seed(d, w, 0)
107    }
108
109    /// Same shape as [`CountMinSketch::new`], with the hash family shifted by
110    /// `seed`. Two sketches only merge or compare if their seeds match.
111    pub fn with_seed(d: usize, w: usize, seed: u64) -> Self {
112        let d = d.clamp(2, MAX_DEPTH);
113        let w = w.max(2).next_power_of_two();
114        let rows = (0..d).map(|_| vec![0u32; w]).collect();
115        Self {
116            d,
117            w,
118            mask: w - 1,
119            seed,
120            total: 0,
121            rows,
122        }
123    }
124
125    /// Size from the error budget instead of from `(d, w)`. `epsilon` is the
126    /// tolerated over-count as a fraction of total stream volume; `confidence`
127    /// is the probability the bound holds.
128    pub fn with_error_bounds(epsilon: f64, confidence: f64) -> Self {
129        Self::with_error_bounds_seeded(epsilon, confidence, 0)
130    }
131
132    pub fn with_error_bounds_seeded(epsilon: f64, confidence: f64, seed: u64) -> Self {
133        Self::with_seed(
134            Self::suggest_depth(confidence),
135            Self::suggest_width(epsilon),
136            seed,
137        )
138    }
139
140    /// Width needed for an additive error of `epsilon * total`: `ceil(e/epsilon)`,
141    /// rounded up to a power of two.
142    pub fn suggest_width(epsilon: f64) -> usize {
143        if epsilon.is_nan() || epsilon <= 0.0 {
144            return 1 << 30;
145        }
146        let w = (E / epsilon).ceil();
147        if w >= (1u64 << 30) as f64 {
148            return 1 << 30;
149        }
150        (w as usize).max(2).next_power_of_two()
151    }
152
153    /// Depth needed for the error bound to hold with probability `confidence`:
154    /// `ceil(ln(1/(1-confidence)))`, clamped to `2..=MAX_DEPTH`.
155    pub fn suggest_depth(confidence: f64) -> usize {
156        if confidence.is_nan() || confidence <= 0.0 {
157            return 2;
158        }
159        if confidence >= 1.0 {
160            return MAX_DEPTH;
161        }
162        let d = (1.0 / (1.0 - confidence)).ln().ceil();
163        if d >= MAX_DEPTH as f64 {
164            return MAX_DEPTH;
165        }
166        (d as usize).clamp(2, MAX_DEPTH)
167    }
168
169    pub fn depth(&self) -> usize {
170        self.d
171    }
172    pub fn width(&self) -> usize {
173        self.w
174    }
175    pub fn seed(&self) -> u64 {
176        self.seed
177    }
178
179    /// Total weight ingested, exactly. Unlike the per-key estimates this is a
180    /// running sum, not a sketch, so it carries no error.
181    pub fn total(&self) -> u64 {
182        self.total
183    }
184
185    pub fn is_empty(&self) -> bool {
186        self.total == 0
187    }
188
189    /// Additive error as a fraction of total volume: `e / w`.
190    pub fn relative_error(&self) -> f64 {
191        E / self.w as f64
192    }
193
194    /// Probability the error bound holds: `1 - e^-d`.
195    pub fn confidence(&self) -> f64 {
196        1.0 - (-(self.d as f64)).exp()
197    }
198
199    /// Absolute over-count budget at the current volume: `ceil(e/w * total)`.
200    pub fn error_margin(&self) -> u32 {
201        let m = (self.relative_error() * self.total as f64).ceil();
202        if m >= u32::MAX as f64 {
203            u32::MAX
204        } else {
205            m as u32
206        }
207    }
208
209    /// Fraction of cells that have ever been touched. Climbing past ~0.5 means
210    /// the sketch is undersized for the key cardinality it is seeing.
211    /// O(d*w) - a monitoring call, not a hot-path one.
212    pub fn occupancy(&self) -> f64 {
213        let used: usize = self
214            .rows
215            .iter()
216            .map(|r| r.iter().filter(|&&c| c != 0).count())
217            .sum();
218        used as f64 / (self.d * self.w) as f64
219    }
220
221    /// Counter-matrix footprint in bytes. Fixed at construction: the sketch
222    /// never grows with key cardinality, which is the whole reason to use one.
223    pub fn heap_bytes(&self) -> usize {
224        self.d * self.w * core::mem::size_of::<u32>()
225    }
226
227    /// Increment the count of `key` by 1.
228    pub fn add(&mut self, key: &str) {
229        self.add_bytes_n(key.as_bytes(), 1);
230    }
231
232    /// Increment the count of `key` by `n`. A weighted update - notional,
233    /// message bytes, filled quantity - not just an occurrence count.
234    pub fn add_n(&mut self, key: &str, n: u32) {
235        self.add_bytes_n(key.as_bytes(), n);
236    }
237
238    pub fn add_bytes(&mut self, key: &[u8]) {
239        self.add_bytes_n(key, 1);
240    }
241
242    /// Increment by `n`. Conservative update: raise each of the `d` cells to
243    /// `min + n` and leave any cell already above that alone. The min-query
244    /// never reads those higher cells for this key, so raising them would only
245    /// add slop for whatever else collides there.
246    pub fn add_bytes_n(&mut self, key: &[u8], n: u32) {
247        if n == 0 {
248            return;
249        }
250        let (h1, h2) = self.hashes(key);
251        let mut idxs = [0usize; MAX_DEPTH];
252        let mut min = u32::MAX;
253        for (i, slot) in idxs.iter_mut().take(self.d).enumerate() {
254            let idx = self.cell_index(h1, h2, i);
255            *slot = idx;
256            min = min.min(self.rows[i][idx]);
257        }
258        let floor = min.saturating_add(n);
259        for (i, &idx) in idxs.iter().take(self.d).enumerate() {
260            if self.rows[i][idx] < floor {
261                self.rows[i][idx] = floor;
262            }
263        }
264        self.total = self.total.saturating_add(n as u64);
265    }
266
267    /// Increment an integer key by 1. Identical to hashing the key's
268    /// little-endian bytes, without materialising them.
269    pub fn add_u64(&mut self, key: u64) {
270        self.add_u64_n(key, 1);
271    }
272
273    pub fn add_u64_n(&mut self, key: u64, n: u32) {
274        self.add_bytes_n(&key.to_le_bytes(), n);
275    }
276
277    /// Estimated count for `key`. Always `>=` the true count; the over-count is
278    /// bounded by [`CountMinSketch::error_margin`].
279    pub fn estimate(&self, key: &str) -> u32 {
280        self.estimate_bytes(key.as_bytes())
281    }
282
283    pub fn estimate_bytes(&self, key: &[u8]) -> u32 {
284        let (h1, h2) = self.hashes(key);
285        let mut min = u32::MAX;
286        for i in 0..self.d {
287            let idx = self.cell_index(h1, h2, i);
288            min = min.min(self.rows[i][idx]);
289        }
290        min
291    }
292
293    pub fn estimate_u64(&self, key: u64) -> u32 {
294        self.estimate_bytes(&key.to_le_bytes())
295    }
296
297    /// The other end of the interval: `estimate - error_margin`, floored at
298    /// zero. The true count lies in `[lower_bound, estimate]` at `confidence()`.
299    pub fn estimate_lower_bound(&self, key: &str) -> u32 {
300        self.estimate(key).saturating_sub(self.error_margin())
301    }
302
303    /// Zero every counter and reset the volume. Shape and seed are kept, so a
304    /// long-lived sketch can be recycled without reallocating the matrix.
305    pub fn clear(&mut self) {
306        for row in self.rows.iter_mut() {
307            row.fill(0);
308        }
309        self.total = 0;
310    }
311
312    /// Snapshot to a self-describing byte buffer: a 32-byte header
313    /// (magic, version, depth, width, seed, total) then `d * w` little-endian
314    /// `u32` counters, row-major. Byte-identical to the Java port's output.
315    pub fn to_bytes(&self) -> Vec<u8> {
316        let mut out = Vec::with_capacity(SNAPSHOT_HEADER + self.heap_bytes());
317        out.extend_from_slice(&SNAPSHOT_MAGIC);
318        out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
319        out.extend_from_slice(&(self.d as u16).to_le_bytes());
320        out.extend_from_slice(&(self.w as u32).to_le_bytes());
321        out.extend_from_slice(&self.seed.to_le_bytes());
322        out.extend_from_slice(&self.total.to_le_bytes());
323        for row in &self.rows {
324            for &cell in row {
325                out.extend_from_slice(&cell.to_le_bytes());
326            }
327        }
328        out
329    }
330
331    /// Inverse of [`CountMinSketch::to_bytes`]. Rejects a foreign or truncated
332    /// buffer rather than decoding a plausible-looking sketch out of it.
333    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SnapshotError> {
334        if bytes.len() < SNAPSHOT_HEADER {
335            return Err(SnapshotError::Truncated {
336                expected: SNAPSHOT_HEADER,
337                actual: bytes.len(),
338            });
339        }
340        if bytes[..8] != SNAPSHOT_MAGIC {
341            return Err(SnapshotError::BadMagic);
342        }
343        let version = u16::from_le_bytes([bytes[8], bytes[9]]);
344        if version != SNAPSHOT_VERSION {
345            return Err(SnapshotError::UnsupportedVersion(version));
346        }
347        let d = u16::from_le_bytes([bytes[10], bytes[11]]) as usize;
348        let w = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
349        if !(2..=MAX_DEPTH).contains(&d) || w < 2 || !w.is_power_of_two() {
350            return Err(SnapshotError::BadShape { depth: d, width: w });
351        }
352        let expected = SNAPSHOT_HEADER + d * w * 4;
353        if bytes.len() != expected {
354            return Err(SnapshotError::Truncated {
355                expected,
356                actual: bytes.len(),
357            });
358        }
359        let seed = u64::from_le_bytes(bytes[16..24].try_into().expect("8 bytes"));
360        let total = u64::from_le_bytes(bytes[24..32].try_into().expect("8 bytes"));
361
362        let mut sketch = Self::with_seed(d, w, seed);
363        let mut at = SNAPSHOT_HEADER;
364        for row in sketch.rows.iter_mut() {
365            for cell in row.iter_mut() {
366                *cell = u32::from_le_bytes(bytes[at..at + 4].try_into().expect("4 bytes"));
367                at += 4;
368            }
369        }
370        sketch.total = total;
371        Ok(sketch)
372    }
373
374    fn hashes(&self, key: &[u8]) -> (u64, u64) {
375        // Two base hashes from one FNV-1a pass plus a finalizer mix.
376        let h = mix(fnv1a64(key) ^ self.seed);
377        let h1 = h as u32 as u64;
378        // h2 must be odd to keep the affine combination injective mod 2^k.
379        let h2 = ((h >> 32) as u32 as u64) | 1;
380        (h1, h2)
381    }
382
383    fn cell_index(&self, h1: u64, h2: u64, i: usize) -> usize {
384        // Kirsch-Mitzenmacher: h_i = h1 + i * h2.
385        let idx = h1.wrapping_add((i as u64).wrapping_mul(h2));
386        (idx as usize) & self.mask
387    }
388
389    // Crate-private accessors used by features. Kept off the public
390    // surface to avoid committing the row layout to downstream code.
391    #[cfg(feature = "merge")]
392    pub(crate) fn apply_paired(&mut self, other: &CountMinSketch, sum: bool) {
393        for (i, row) in self.rows.iter_mut().enumerate() {
394            let src = &other.rows[i];
395            for (cell, &s) in row.iter_mut().zip(src.iter()) {
396                *cell = if sum {
397                    cell.saturating_add(s)
398                } else if s > *cell {
399                    s
400                } else {
401                    *cell
402                };
403            }
404        }
405        self.total = self.total.saturating_add(other.total);
406    }
407}
408
409fn fnv1a64(bytes: &[u8]) -> u64 {
410    let mut h = FNV_OFFSET;
411    for &b in bytes {
412        h ^= b as u64;
413        h = h.wrapping_mul(FNV_PRIME);
414    }
415    h
416}
417
418/// SplitMix64 finalizer.
419fn mix(mut h: u64) -> u64 {
420    h ^= h >> 30;
421    h = h.wrapping_mul(0xbf58476d1ce4e5b9);
422    h ^= h >> 27;
423    h = h.wrapping_mul(0x94d049bb133111eb);
424    h ^= h >> 31;
425    h
426}
427
428#[cfg(feature = "harness")]
429pub mod recipe;
430
431// Opt-in feature modules. Each is independent and gated by its own
432// Cargo feature; `cargo add subms-count-min-sketch` alone keeps the
433// base zero-dep + std-only shape.
434#[cfg(any(feature = "heavy-hitters", feature = "windowed", feature = "merge"))]
435pub mod features;
436
437#[cfg(feature = "heavy-hitters")]
438pub use features::heavy_hitters::HeavyHitters;
439#[cfg(feature = "merge")]
440pub use features::merge::{MergeError, merge_disjoint_into, merge_into};
441#[cfg(feature = "windowed")]
442pub use features::windowed::WindowedCountMinSketch;
443
444#[cfg(test)]
445#[path = "cms_tests.rs"]
446mod cms_tests;
447
448#[cfg(test)]
449#[path = "sample_app_tests.rs"]
450mod sample_app_tests;