Skip to main content

chronix_engine/segment/
bloom.rs

1//! Bloom filter for tag column filtering.
2//!
3//! Provides a simple, self-contained bloom filter implementation using
4//! double-hashing (two independent FNV-1a hash functions) for tag-value
5//! membership testing in segment metadata.  Enables segment-level
6//! predicate pushdown: if a queried tag value is definitely not in the
7//! bloom filter, the entire segment can be skipped without reading any
8//! column blocks.
9//!
10//! # Design
11//!
12//! - ~10 bits per element for approximately 1% false positive rate
13//! - Double-hashing: `h_i = h1 + i * h2` using two FNV-1a variants
14//!   with different offset bases (matching chronix-index approach)
15//! - k=7 hash probes (optimal for 10 bits/element)
16//! - Serialized as a raw byte array (`Vec<u8>`)
17
18use std::collections::HashSet;
19
20/// FNV-1a hash offset basis (64-bit).
21const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
22/// Second FNV-1a offset basis for independent double-hashing.
23const FNV_OFFSET_2: u64 = 0x517c_c1b7_2722_0a95;
24/// FNV-1a hash prime (64-bit).
25const FNV_PRIME: u64 = 1_099_511_628_211;
26
27/// Number of hash probes. For m/n ≈ 10 bits/element, k=7 is optimal,
28/// yielding a theoretical false positive rate of ~0.8%.
29const K: u32 = 7;
30
31/// Compute FNV-1a hash of a byte slice with a given offset basis.
32fn fnv1a_seeded(data: &[u8], offset: u64) -> u64 {
33    let mut hash = offset;
34    for &byte in data {
35        hash ^= u64::from(byte);
36        hash = hash.wrapping_mul(FNV_PRIME);
37    }
38    hash
39}
40
41/// Build a bloom filter from a set of string values.
42///
43/// The filter is sized based on the number of **unique** values and the
44/// requested false positive rate, using the formula:
45///
46/// m = -n·ln(p) / (ln 2)²
47///
48/// where *n* is the number of unique values and *p* is `false_positive_rate`.
49///
50/// # Arguments
51///
52/// - `values` — String values to insert into the filter.
53/// - `false_positive_rate` — Target FPR (e.g., 0.01 for ~1%).
54///
55/// # Returns
56///
57/// Serialized bit array as a `Vec<u8>`. Empty if `values` is empty.
58pub fn bloom_filter_build(values: &[&str], false_positive_rate: f64) -> Vec<u8> {
59    if values.is_empty() {
60        return Vec::new();
61    }
62
63    // Deduplicate for optimal sizing
64    let unique: HashSet<&str> = values.iter().copied().collect();
65    let n = unique.len().max(1);
66
67    // Calculate optimal number of bits
68    let m_f = -(n as f64) * false_positive_rate.ln() / (std::f64::consts::LN_2.powi(2));
69    let m = (m_f as usize).max(8);
70    let m_bytes = m.div_ceil(8);
71    let m_bits = (m_bytes * 8) as u64;
72
73    let mut bits = vec![0u8; m_bytes];
74
75    for &val in &unique {
76        let h1 = fnv1a_seeded(val.as_bytes(), FNV_OFFSET);
77        let h2 = fnv1a_seeded(val.as_bytes(), FNV_OFFSET_2);
78        for i in 0..K {
79            let idx = h1.wrapping_add(u64::from(i).wrapping_mul(h2)) % m_bits;
80            bits[(idx / 8) as usize] |= 1 << (idx % 8);
81        }
82    }
83
84    bits
85}
86
87/// Check if a bloom filter possibly contains a value.
88///
89/// Returns `true` if the value **might** be in the set (possible false
90/// positive), or `false` if the value is **definitely not** in the set
91/// (no false negatives).
92///
93/// # Arguments
94///
95/// - `filter` — Serialized bloom filter bytes (from [`bloom_filter_build`]).
96/// - `value` — The string value to test.
97pub fn bloom_filter_contains(filter: &[u8], value: &str) -> bool {
98    if filter.is_empty() {
99        return false;
100    }
101
102    let m_bits = (filter.len() * 8) as u64;
103    let h1 = fnv1a_seeded(value.as_bytes(), FNV_OFFSET);
104    let h2 = fnv1a_seeded(value.as_bytes(), FNV_OFFSET_2);
105
106    for i in 0..K {
107        let idx = h1.wrapping_add(u64::from(i).wrapping_mul(h2)) % m_bits;
108        if filter[(idx / 8) as usize] & (1 << (idx % 8)) == 0 {
109            return false;
110        }
111    }
112
113    true
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn build_contains_known_values() {
122        let values = vec!["server-1", "server-2", "server-3", "us-east-1", "us-west-2"];
123        let filter = bloom_filter_build(&values, 0.01);
124
125        // All inserted values must be found (no false negatives)
126        for &v in &values {
127            assert!(
128                bloom_filter_contains(&filter, v),
129                "bloom filter should contain '{v}'"
130            );
131        }
132    }
133
134    #[test]
135    fn rejects_unknown_values() {
136        // Insert a reasonable number of values
137        let owned: Vec<String> = (0..100).map(|i| format!("host-{i}")).collect();
138        let values: Vec<&str> = owned.iter().map(String::as_str).collect();
139        let filter = bloom_filter_build(&values, 0.01);
140
141        // Test with 1000 values that were NOT inserted.
142        // At ~1% FPR, we expect roughly 10 false positives out of 1000 tests.
143        let mut false_positives = 0;
144        for i in 1000..2000 {
145            let test_val = format!("unknown-{i}");
146            if bloom_filter_contains(&filter, &test_val) {
147                false_positives += 1;
148            }
149        }
150
151        // Allow up to 5% to account for statistical variance
152        assert!(
153            false_positives < 50,
154            "too many false positives: {false_positives}/1000 (expected <50)"
155        );
156    }
157
158    #[test]
159    fn empty_filter() {
160        let filter = bloom_filter_build(&[], 0.01);
161        assert!(filter.is_empty());
162        assert!(!bloom_filter_contains(&filter, "anything"));
163    }
164
165    #[test]
166    fn single_value() {
167        let filter = bloom_filter_build(&["hello"], 0.01);
168        assert!(bloom_filter_contains(&filter, "hello"));
169        assert!(!bloom_filter_contains(&filter, "world"));
170    }
171
172    #[test]
173    fn duplicate_values_handled() {
174        let values = vec!["a", "a", "b", "b", "c", "c"];
175        let filter = bloom_filter_build(&values, 0.01);
176        assert!(bloom_filter_contains(&filter, "a"));
177        assert!(bloom_filter_contains(&filter, "b"));
178        assert!(bloom_filter_contains(&filter, "c"));
179    }
180}