Skip to main content

chronix_engine/index/
bloom.rs

1//! Bloom filter for series key membership testing.
2//!
3//! Per-segment bloom filters enable fast exclusion of segments that definitely
4//! do not contain a queried series key. This is the second level of segment
5//! pruning (after time-range pruning).
6//!
7//! # Implementation
8//!
9//! Uses the Kirsch–Mitzenmacker double-hashing technique: `k` hash functions
10//! are derived from two base hashes `h1` and `h2` as `h_i(x) = h1(x) + i * h2(x)`.
11//! The base hashes use FNV-1a with different seeds.
12//!
13//! # Memory
14//!
15//! At the default 1% false positive rate, each key costs ~10 bits of memory.
16//! 10,000 keys ≈ 12 KB.
17
18use chronix_core::SeriesKey;
19
20/// Per-segment bloom filter for series key pruning.
21///
22/// A space-efficient probabilistic data structure that answers set membership
23/// queries with possible false positives but **no false negatives**.
24///
25/// # False Positive Rate
26///
27/// The default target FP rate is 1%. At this rate, the filter uses approximately
28/// 10 bits per inserted key, with 7 hash functions.
29#[derive(Debug, Clone)]
30pub struct SeriesBloomFilter {
31    /// Bit vector stored as 64-bit words.
32    bits: Vec<u64>,
33    /// Total number of bits in the filter.
34    num_bits: usize,
35    /// Number of hash functions to apply.
36    num_hashes: u32,
37    /// Number of items inserted.
38    count: usize,
39}
40
41impl SeriesBloomFilter {
42    /// Create a new bloom filter sized for the expected number of items.
43    ///
44    /// # Parameters
45    ///
46    /// - `expected_items`: Expected number of unique series keys
47    /// - `fp_rate`: Target false positive rate (e.g., `0.01` for 1%)
48    ///
49    /// # Panics
50    ///
51    /// Panics if `fp_rate` is not in `(0.0, 1.0)` or `expected_items` is 0.
52    #[must_use]
53    pub fn new(expected_items: usize, fp_rate: f64) -> Self {
54        assert!(expected_items > 0, "expected_items must be > 0");
55        assert!(fp_rate > 0.0 && fp_rate < 1.0, "fp_rate must be in (0, 1)");
56
57        #[allow(clippy::cast_precision_loss)]
58        let n = expected_items as f64;
59        let p = fp_rate;
60
61        // Optimal bit count: m = -n * ln(p) / (ln(2))^2
62        let ln2 = std::f64::consts::LN_2;
63        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
64        let m = (-n * p.ln() / (ln2 * ln2)).ceil() as usize;
65        let m = m.max(64); // minimum 64 bits
66
67        // Optimal hash count: k = (m/n) * ln(2)
68        #[allow(
69            clippy::cast_possible_truncation,
70            clippy::cast_sign_loss,
71            clippy::cast_precision_loss
72        )]
73        let k = ((m as f64 / n) * ln2).ceil() as u32;
74        // Clamp k to [1, 15] for the u8 serialisation format.
75        let k_clamped = k.clamp(1, 15);
76
77        // Warn on the *achieved* rate, computed — not on the clamp, which is
78        // not evidence of anything. `m` has a 64-bit floor, so a filter over
79        // three series gets ~65 bits each and an optimal `k` of 45; clamped to
80        // 15 the real rate is ~1e-11 against a request of 0.01. Warning on the
81        // clamp fired on every small segment and said the opposite of the
82        // truth.
83        #[allow(clippy::cast_precision_loss)]
84        let achieved = {
85            let exponent = -(f64::from(k_clamped) * n) / (m as f64);
86            (1.0 - exponent.exp()).powi(k_clamped as i32)
87        };
88        if achieved > p {
89            tracing::warn!(
90                optimal_k = k,
91                clamped_k = k_clamped,
92                requested_fpr = p,
93                achieved_fpr = achieved,
94                "bloom filter hash count clamped — actual FPR exceeds the requested rate"
95            );
96        }
97
98        // Round up to next multiple of 64
99        let num_bits = (m + 63) & !63;
100        let num_words = num_bits / 64;
101
102        Self {
103            bits: vec![0u64; num_words],
104            num_bits,
105            num_hashes: k_clamped,
106            count: 0,
107        }
108    }
109
110    /// Create a bloom filter with explicit parameters (for deserialization).
111    #[must_use]
112    fn from_raw(bits: Vec<u64>, num_bits: usize, num_hashes: u32, count: usize) -> Self {
113        Self {
114            bits,
115            num_bits,
116            num_hashes,
117            count,
118        }
119    }
120
121    /// Insert a series key into the filter.
122    ///
123    /// Returns `true` if the key was *probably* new (not previously present),
124    /// `false` if it was *definitely* already present. The count is only
125    /// incremented for new insertions, giving a more accurate FP-rate estimate.
126    pub fn insert(&mut self, key: &SeriesKey) -> bool {
127        let (h1, h2) = Self::double_hash(key);
128        let mut all_set = true;
129        for i in 0..self.num_hashes {
130            let bit_idx = self.bit_index(h1, h2, i);
131            let word_idx = bit_idx / 64;
132            let bit_pos = bit_idx % 64;
133            if self.bits[word_idx] & (1u64 << bit_pos) == 0 {
134                all_set = false;
135            }
136            self.bits[word_idx] |= 1u64 << bit_pos;
137        }
138        if !all_set {
139            self.count += 1;
140        }
141        !all_set
142    }
143
144    /// Test whether the filter may contain the given series key.
145    ///
146    /// Returns `false`: the key is **definitely not** in the set.
147    /// Returns `true`: the key is **probably** in the set (with FP rate `p`).
148    #[must_use]
149    pub fn may_contain(&self, key: &SeriesKey) -> bool {
150        let (h1, h2) = Self::double_hash(key);
151        for i in 0..self.num_hashes {
152            let bit_idx = self.bit_index(h1, h2, i);
153            let word_idx = bit_idx / 64;
154            let bit_pos = bit_idx % 64;
155            if self.bits[word_idx] & (1u64 << bit_pos) == 0 {
156                return false;
157            }
158        }
159        true
160    }
161
162    /// Returns the number of items inserted.
163    #[must_use]
164    pub fn count(&self) -> usize {
165        self.count
166    }
167
168    /// Returns the total number of bits in the filter.
169    #[must_use]
170    pub fn num_bits(&self) -> usize {
171        self.num_bits
172    }
173
174    /// Returns the number of hash functions.
175    #[must_use]
176    pub fn num_hashes(&self) -> u32 {
177        self.num_hashes
178    }
179
180    /// Serialize the bloom filter to bytes.
181    ///
182    /// Format: `[num_bits: u32][num_hashes: u8][count: u32][bits...]`
183    ///
184    /// # Errors
185    ///
186    /// Returns `IndexError::Corrupt` if `num_bits` or `count` exceeds
187    /// `u32::MAX` (bloom filter too large for the v1 wire format).
188    pub fn to_bytes(&self) -> std::result::Result<Vec<u8>, crate::index::error::IndexError> {
189        let header_size = 4 + 1 + 4; // num_bits + num_hashes + count
190        let data_size = self.bits.len() * 8;
191        let mut buf = Vec::with_capacity(header_size + data_size);
192
193        let num_bits_u32 =
194            u32::try_from(self.num_bits).map_err(|_| crate::index::error::IndexError::Corrupt {
195                detail: format!(
196                    "bloom filter too large to serialize (num_bits {} > u32::MAX)",
197                    self.num_bits
198                ),
199            })?;
200        let count_u32 =
201            u32::try_from(self.count).map_err(|_| crate::index::error::IndexError::Corrupt {
202                detail: format!(
203                    "bloom filter too large to serialize (count {} > u32::MAX)",
204                    self.count
205                ),
206            })?;
207        buf.extend_from_slice(&num_bits_u32.to_le_bytes());
208        buf.push(self.num_hashes as u8);
209        buf.extend_from_slice(&count_u32.to_le_bytes());
210
211        for word in &self.bits {
212            buf.extend_from_slice(&word.to_le_bytes());
213        }
214
215        // Append a 4-byte XOR checksum of the bit data so that
216        // `from_bytes` can detect bit-flip corruption at read time.
217        let mut checksum: u32 = 0;
218        for word in &self.bits {
219            let bytes = word.to_le_bytes();
220            for chunk in bytes.chunks(4) {
221                let v = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
222                checksum ^= v;
223            }
224        }
225        buf.extend_from_slice(&checksum.to_le_bytes());
226
227        Ok(buf)
228    }
229
230    /// Deserialize a bloom filter from bytes.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the data is too short or corrupt.
235    pub fn from_bytes(data: &[u8]) -> std::result::Result<Self, crate::index::error::IndexError> {
236        if data.len() < 9 {
237            return Err(crate::index::error::IndexError::Corrupt {
238                detail: "bloom filter data too short".into(),
239            });
240        }
241
242        #[allow(clippy::cast_possible_truncation)]
243        let num_bits = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
244
245        if num_bits == 0 {
246            return Err(crate::index::error::IndexError::Corrupt {
247                detail: "bloom filter num_bits must be > 0".into(),
248            });
249        }
250
251        let num_hashes = u32::from(data[4]);
252        #[allow(clippy::cast_possible_truncation)]
253        let count = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
254
255        let num_words = num_bits.div_ceil(64);
256        let expected_data_len = 9 + num_words * 8;
257
258        if data.len() < expected_data_len {
259            return Err(crate::index::error::IndexError::Corrupt {
260                detail: format!(
261                    "bloom filter data too short: need {expected_data_len}, got {}",
262                    data.len()
263                ),
264            });
265        }
266
267        let mut bits = Vec::with_capacity(num_words);
268        for i in 0..num_words {
269            let offset = 9 + i * 8;
270            let word = u64::from_le_bytes([
271                data[offset],
272                data[offset + 1],
273                data[offset + 2],
274                data[offset + 3],
275                data[offset + 4],
276                data[offset + 5],
277                data[offset + 6],
278                data[offset + 7],
279            ]);
280            bits.push(word);
281        }
282
283        // Verify XOR checksum if present (4 bytes after bit data).
284        let checksum_offset = expected_data_len;
285        if data.len() >= checksum_offset + 4 {
286            let stored_checksum = u32::from_le_bytes([
287                data[checksum_offset],
288                data[checksum_offset + 1],
289                data[checksum_offset + 2],
290                data[checksum_offset + 3],
291            ]);
292            let mut computed: u32 = 0;
293            for word in &bits {
294                let bytes = word.to_le_bytes();
295                for chunk in bytes.chunks(4) {
296                    let v = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
297                    computed ^= v;
298                }
299            }
300            if stored_checksum != computed {
301                return Err(crate::index::error::IndexError::Corrupt {
302                    detail: format!(
303                        "bloom filter checksum mismatch (stored={stored_checksum:#010x}, computed={computed:#010x})"
304                    ),
305                });
306            }
307        }
308
309        Ok(Self::from_raw(bits, num_bits, num_hashes, count))
310    }
311
312    /// Compute double hash for Kirsch–Mitzenmacker technique.
313    ///
314    /// Returns `(h1, h2)` where `h1` is FNV-1a and `h2` is FNV-1a with
315    /// a different seed.
316    fn double_hash(key: &SeriesKey) -> (u64, u64) {
317        use std::hash::Hasher;
318
319        let canonical = key.canonical_form();
320        let bytes = canonical.as_bytes();
321
322        // h1: FNV-1a (same as SeriesKey::hash_fnv)
323        let mut h1 = fnv::FnvHasher::default();
324        h1.write(bytes);
325        let h1 = h1.finish();
326
327        // h2: FNV-1a with different initial state (XOR the seed)
328        let mut h2 = fnv::FnvHasher::with_key(0x517c_c1b7_2722_0a95);
329        h2.write(bytes);
330        let h2 = h2.finish();
331
332        (h1, h2)
333    }
334
335    /// Compute bit index for the i-th hash function.
336    #[inline]
337    fn bit_index(&self, h1: u64, h2: u64, i: u32) -> usize {
338        let combined = h1.wrapping_add((u64::from(i)).wrapping_mul(h2));
339        #[allow(clippy::cast_possible_truncation)]
340        let idx = (combined as usize) % self.num_bits;
341        idx
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::collections::BTreeMap;
349
350    fn make_key(measurement: &str, host: &str) -> SeriesKey {
351        let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
352        SeriesKey::new(measurement, tags).unwrap()
353    }
354
355    #[test]
356    fn insert_and_lookup() {
357        let mut bf = SeriesBloomFilter::new(100, 0.01);
358        let k1 = make_key("cpu", "srv1");
359        let k2 = make_key("cpu", "srv2");
360        let k3 = make_key("mem", "srv1");
361
362        bf.insert(&k1);
363        bf.insert(&k2);
364
365        assert!(bf.may_contain(&k1));
366        assert!(bf.may_contain(&k2));
367        // k3 was not inserted — should return false (no false negatives)
368        assert!(!bf.may_contain(&k3));
369    }
370
371    #[test]
372    fn no_false_negatives() {
373        let mut bf = SeriesBloomFilter::new(1000, 0.01);
374        let keys: Vec<SeriesKey> = (0..1000)
375            .map(|i| make_key("cpu", &format!("host-{i}")))
376            .collect();
377
378        for key in &keys {
379            bf.insert(key);
380        }
381
382        // Every inserted key must be found
383        for key in &keys {
384            assert!(bf.may_contain(key), "false negative for key: {key}");
385        }
386    }
387
388    #[test]
389    fn false_positive_rate_within_bounds() {
390        let n = 10_000;
391        let mut bf = SeriesBloomFilter::new(n, 0.01);
392
393        // Insert n keys
394        for i in 0..n {
395            bf.insert(&make_key("cpu", &format!("host-{i}")));
396        }
397
398        // Test with n non-inserted keys
399        let mut false_positives = 0;
400        for i in n..(2 * n) {
401            if bf.may_contain(&make_key("cpu", &format!("host-{i}"))) {
402                false_positives += 1;
403            }
404        }
405
406        #[allow(clippy::cast_precision_loss)]
407        let fp_rate = f64::from(false_positives) / n as f64;
408        assert!(
409            fp_rate < 0.05,
410            "false positive rate too high: {fp_rate:.4} (expected < 0.05)"
411        );
412    }
413
414    #[test]
415    fn serialization_roundtrip() {
416        let mut bf = SeriesBloomFilter::new(100, 0.01);
417        let k1 = make_key("cpu", "srv1");
418        let k2 = make_key("cpu", "srv2");
419        bf.insert(&k1);
420        bf.insert(&k2);
421
422        let bytes = bf.to_bytes().unwrap();
423        let bf2 = SeriesBloomFilter::from_bytes(&bytes).unwrap();
424
425        assert_eq!(bf2.num_bits(), bf.num_bits());
426        assert_eq!(bf2.num_hashes(), bf.num_hashes());
427        assert_eq!(bf2.count(), bf.count());
428        assert!(bf2.may_contain(&k1));
429        assert!(bf2.may_contain(&k2));
430        assert!(!bf2.may_contain(&make_key("mem", "srv1")));
431    }
432
433    #[test]
434    fn from_bytes_too_short() {
435        let result = SeriesBloomFilter::from_bytes(&[0u8; 5]);
436        assert!(result.is_err());
437    }
438
439    #[test]
440    fn count_tracking() {
441        let mut bf = SeriesBloomFilter::new(100, 0.01);
442        assert_eq!(bf.count(), 0);
443        assert!(bf.insert(&make_key("cpu", "srv1"))); // new
444        assert_eq!(bf.count(), 1);
445        assert!(bf.insert(&make_key("cpu", "srv2"))); // new
446        assert_eq!(bf.count(), 2);
447    }
448
449    #[test]
450    fn duplicate_insert_does_not_inflate_count() {
451        let mut bf = SeriesBloomFilter::new(100, 0.01);
452        let key = make_key("cpu", "srv1");
453        assert!(bf.insert(&key)); // new → true
454        assert_eq!(bf.count(), 1);
455        assert!(!bf.insert(&key)); // dup → false
456        assert_eq!(bf.count(), 1);
457        assert!(!bf.insert(&key)); // dup → false
458        assert_eq!(bf.count(), 1);
459    }
460
461    #[test]
462    fn memory_approximately_10_bits_per_key() {
463        let bf = SeriesBloomFilter::new(10_000, 0.01);
464        #[allow(clippy::cast_precision_loss)]
465        let bits_per_key = bf.num_bits() as f64 / 10_000.0;
466        // Should be approximately 10 bits per key (±2)
467        assert!(
468            (8.0..=12.0).contains(&bits_per_key),
469            "bits_per_key = {bits_per_key:.1}, expected ~10"
470        );
471    }
472}