1use chronix_core::SeriesKey;
19
20#[derive(Debug, Clone)]
30pub struct SeriesBloomFilter {
31 bits: Vec<u64>,
33 num_bits: usize,
35 num_hashes: u32,
37 count: usize,
39}
40
41impl SeriesBloomFilter {
42 #[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 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); #[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 let k_clamped = k.clamp(1, 15);
76
77 #[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 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 #[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 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 #[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 #[must_use]
164 pub fn count(&self) -> usize {
165 self.count
166 }
167
168 #[must_use]
170 pub fn num_bits(&self) -> usize {
171 self.num_bits
172 }
173
174 #[must_use]
176 pub fn num_hashes(&self) -> u32 {
177 self.num_hashes
178 }
179
180 pub fn to_bytes(&self) -> std::result::Result<Vec<u8>, crate::index::error::IndexError> {
189 let header_size = 4 + 1 + 4; 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 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 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 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 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 let mut h1 = fnv::FnvHasher::default();
324 h1.write(bytes);
325 let h1 = h1.finish();
326
327 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 #[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 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 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 for i in 0..n {
395 bf.insert(&make_key("cpu", &format!("host-{i}")));
396 }
397
398 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"))); assert_eq!(bf.count(), 1);
445 assert!(bf.insert(&make_key("cpu", "srv2"))); 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)); assert_eq!(bf.count(), 1);
455 assert!(!bf.insert(&key)); assert_eq!(bf.count(), 1);
457 assert!(!bf.insert(&key)); 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 assert!(
468 (8.0..=12.0).contains(&bits_per_key),
469 "bits_per_key = {bits_per_key:.1}, expected ~10"
470 );
471 }
472}