Skip to main content

subms_bloom_filter/
lib.rs

1//! Minimal bloom filter - standalone, reusable, zero-dependency.
2//!
3//! Standard double-hashed bloom filter: FNV-1a 64-bit produces two 32-bit
4//! subhashes for the double-hashing trick. Sizing defaults to ~10 bits per
5//! key and k=7, which gives ~1% false-positive rate. Suitable as a building
6//! block for other cookbook samples (LSM tree SSTables, in particular).
7//!
8//! ```
9//! use subms_bloom_filter::BloomFilter;
10//!
11//! let mut bf = BloomFilter::new(10_000);
12//! bf.add("alice");
13//! assert!(bf.might_contain("alice"));   // stored keys always report present
14//! assert!(!bf.might_contain("bob"));    // absent keys usually report absent
15//! ```
16//!
17//! The on-disk layout is fixed and language-agnostic:
18//!
19//! ```text
20//! bit_count: u32 (big-endian)
21//! k:         u32 (big-endian)
22//! words:     u32 (big-endian) - number of u64 words
23//! bits:      (u64 big-endian) * words
24//! ```
25//!
26//! Full writeup, design notes and measured benchmarks:
27//! <https://www.submillisecond.com/cookbook/recipes/subms-bloom-filter>
28
29#[cfg(feature = "harness")]
30pub mod recipe;
31
32// Opt-in feature modules. Each is independent of the base filter and
33// gated by its own Cargo feature; `cargo add subms-bloom-filter` alone
34// keeps the base zero-dep + std-only shape.
35//
36// See README and the cookbook page for the per-feature p99 numbers,
37// memory cost, and composition guidance.
38#[cfg(any(feature = "counting", feature = "scalable", feature = "partitioned"))]
39pub mod features;
40
41#[cfg(feature = "counting")]
42pub use features::counting::CountingBloomFilter;
43#[cfg(feature = "partitioned")]
44pub use features::partitioned::PartitionedBloomFilter;
45#[cfg(feature = "scalable")]
46pub use features::scalable::ScalableBloomFilter;
47
48use std::io::{self, Write};
49
50pub(crate) const FNV_OFFSET: u64 = 0xcbf29ce484222325;
51pub(crate) const FNV_PRIME: u64 = 0x100000001b3;
52
53pub struct BloomFilter {
54    bit_count: u32,
55    k: u32,
56    bits: Vec<u64>,
57}
58
59impl BloomFilter {
60    /// Build an empty filter sized for `expected_entries` at ~1% FPR
61    /// (10 bits/key, k=7). The 64-bit floor matters when expected_entries is small.
62    pub fn new(expected_entries: usize) -> Self {
63        let bit_count = expected_entries.saturating_mul(10).max(64) as u32;
64        let words = (bit_count as usize).div_ceil(64);
65        Self {
66            bit_count,
67            k: 7,
68            bits: vec![0u64; words],
69        }
70    }
71
72    pub fn add(&mut self, key: &str) {
73        let h = fnv1a64(key);
74        let h1 = h as u32;
75        let h2 = ((h >> 32) as u32) | 1;
76        for i in 0..self.k {
77            let idx = h1.wrapping_add(i.wrapping_mul(h2)) % self.bit_count;
78            self.bits[(idx / 64) as usize] |= 1u64 << (idx % 64);
79        }
80    }
81
82    pub fn might_contain(&self, key: &str) -> bool {
83        let h = fnv1a64(key);
84        let h1 = h as u32;
85        let h2 = ((h >> 32) as u32) | 1;
86        for i in 0..self.k {
87            let idx = h1.wrapping_add(i.wrapping_mul(h2)) % self.bit_count;
88            if self.bits[(idx / 64) as usize] & (1u64 << (idx % 64)) == 0 {
89                return false;
90            }
91        }
92        true
93    }
94
95    pub fn bit_count(&self) -> u32 {
96        self.bit_count
97    }
98
99    pub fn k(&self) -> u32 {
100        self.k
101    }
102
103    /// Population count of the bit array. Walks every word, so keep it off
104    /// the hot path; it is the input to both saturation estimators below.
105    pub fn set_bits(&self) -> u64 {
106        self.bits.iter().map(|w| w.count_ones() as u64).sum()
107    }
108
109    /// Swamidass-Baldi estimate of how many distinct keys were added:
110    /// `-(m/k) * ln(1 - X/m)` for `X` set bits. Diverges once the array
111    /// saturates, so a fully set filter reports `u64::MAX` rather than a
112    /// number that reads as real.
113    pub fn approximate_element_count(&self) -> u64 {
114        let m = self.bit_count as f64;
115        let x = self.set_bits() as f64;
116        if x >= m {
117            return u64::MAX;
118        }
119        let n = -(m / self.k as f64) * (1.0 - x / m).ln();
120        n.round() as u64
121    }
122
123    /// Current false-positive probability given actual occupancy:
124    /// `(X/m)^k`. This is the measured rate, not the design-point ~1%,
125    /// so it is what tells you the filter has outgrown its sizing.
126    pub fn estimated_fpp(&self) -> f64 {
127        let ratio = self.set_bits() as f64 / self.bit_count as f64;
128        ratio.powi(self.k as i32)
129    }
130
131    /// Two filters can be unioned only if they agree on `m` and `k` -
132    /// the bit positions mean nothing otherwise.
133    pub fn is_compatible(&self, other: &BloomFilter) -> bool {
134        self.bit_count == other.bit_count
135            && self.k == other.k
136            && self.bits.len() == other.bits.len()
137    }
138
139    /// OR another filter's bits into this one. The result is the filter you
140    /// would have built by adding both key sets to one array, which is what
141    /// makes a shard-per-producer build mergeable at fan-in.
142    pub fn union(&mut self, other: &BloomFilter) -> Result<(), GeometryMismatch> {
143        if !self.is_compatible(other) {
144            return Err(GeometryMismatch {
145                lhs: (self.bit_count, self.k),
146                rhs: (other.bit_count, other.k),
147            });
148        }
149        for (dst, src) in self.bits.iter_mut().zip(&other.bits) {
150            *dst |= *src;
151        }
152        Ok(())
153    }
154
155    /// Zero the bits, keeping the allocation. A generation boundary that
156    /// rebuilds membership from a source of truth reuses the array instead
157    /// of dropping and re-allocating it.
158    pub fn clear(&mut self) {
159        self.bits.fill(0);
160    }
161
162    pub fn write_to<W: Write>(&self, out: &mut W) -> io::Result<()> {
163        out.write_all(&self.bit_count.to_be_bytes())?;
164        out.write_all(&self.k.to_be_bytes())?;
165        out.write_all(&(self.bits.len() as u32).to_be_bytes())?;
166        for w in &self.bits {
167            out.write_all(&w.to_be_bytes())?;
168        }
169        Ok(())
170    }
171
172    /// Parse a serialised bloom filter from `buf`. Errors if the buffer is
173    /// shorter than the header or truncated mid-bits.
174    pub fn parse(buf: &[u8]) -> io::Result<Self> {
175        if buf.len() < 12 {
176            return Err(io::Error::new(
177                io::ErrorKind::InvalidData,
178                "bloom section too short",
179            ));
180        }
181        let bit_count = u32::from_be_bytes(buf[0..4].try_into().unwrap());
182        let k = u32::from_be_bytes(buf[4..8].try_into().unwrap());
183        let words = u32::from_be_bytes(buf[8..12].try_into().unwrap()) as usize;
184        if buf.len() < 12 + words * 8 {
185            return Err(io::Error::new(
186                io::ErrorKind::InvalidData,
187                "bloom section truncated",
188            ));
189        }
190        let mut bits = Vec::with_capacity(words);
191        for i in 0..words {
192            let off = 12 + i * 8;
193            bits.push(u64::from_be_bytes(buf[off..off + 8].try_into().unwrap()));
194        }
195        Ok(Self { bit_count, k, bits })
196    }
197}
198
199/// Returned by [`BloomFilter::union`] when the two filters were sized
200/// differently. Bit `i` of one filter has no relationship to bit `i` of the
201/// other unless `m` and `k` match, so the merge is refused rather than
202/// silently producing a filter with false negatives.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub struct GeometryMismatch {
205    /// `(bit_count, k)` of the filter being merged into.
206    pub lhs: (u32, u32),
207    /// `(bit_count, k)` of the filter being merged from.
208    pub rhs: (u32, u32),
209}
210
211impl std::fmt::Display for GeometryMismatch {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        write!(
214            f,
215            "incompatible bloom geometry: m={} k={} vs m={} k={}",
216            self.lhs.0, self.lhs.1, self.rhs.0, self.rhs.1
217        )
218    }
219}
220
221impl std::error::Error for GeometryMismatch {}
222
223pub(crate) fn fnv1a64(key: &str) -> u64 {
224    let mut h = FNV_OFFSET;
225    for &b in key.as_bytes() {
226        h ^= b as u64;
227        h = h.wrapping_mul(FNV_PRIME);
228    }
229    h
230}
231
232// Unit tests live in colocated files (org convention: `<module>_tests.rs`
233// alongside the module), not the top-level `tests/` dir. Zero-dep std-only,
234// so no genuine integration tests exist.
235#[cfg(test)]
236#[path = "lib_tests.rs"]
237mod lib_tests;
238
239#[cfg(test)]
240#[path = "sample_app_tests.rs"]
241mod sample_app_tests;