#[derive(Debug, Clone)]
pub struct BloomFilter {
bits: Vec<u64>,
bit_count: usize,
hash_count: u32,
item_count: usize,
}
impl BloomFilter {
#[must_use]
pub fn new(capacity: usize, false_positive_rate: f64) -> Self {
assert!(capacity > 0, "BloomFilter capacity must be > 0");
assert!(
false_positive_rate > 0.0 && false_positive_rate < 1.0,
"BloomFilter false_positive_rate must be in (0, 1)"
);
let (bit_count, hash_count) = optimal_params(capacity, false_positive_rate);
let word_count = bit_count.div_ceil(64);
Self {
bits: vec![0u64; word_count],
bit_count,
hash_count,
item_count: 0,
}
}
pub fn insert(&mut self, item: &str) {
for h in self.hashes(item) {
let idx = h % self.bit_count;
self.bits[idx / 64] |= 1u64 << (idx % 64);
}
self.item_count += 1;
}
#[must_use]
pub fn might_contain(&self, item: &str) -> bool {
for h in self.hashes(item) {
let idx = h % self.bit_count;
if self.bits[idx / 64] & (1u64 << (idx % 64)) == 0 {
return false;
}
}
true
}
#[must_use]
pub fn item_count(&self) -> usize {
self.item_count
}
#[must_use]
pub fn bit_count(&self) -> usize {
self.bit_count
}
#[must_use]
pub fn hash_count(&self) -> u32 {
self.hash_count
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn estimated_false_positive_rate(&self) -> f64 {
let k = self.hash_count as f64;
let n = self.item_count as f64;
let m = self.bit_count as f64;
(1.0 - (-k * n / m).exp()).powf(k)
}
fn hashes(&self, item: &str) -> impl Iterator<Item = usize> {
let h1 = fnv1a(item);
let h2 = djb2(item);
let k = self.hash_count;
let m = self.bit_count;
(0..k).map(move |i| {
let combined = h1.wrapping_add((i as u64).wrapping_mul(h2));
(combined % m as u64) as usize
})
}
}
fn fnv1a(s: &str) -> u64 {
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
let mut hash = FNV_OFFSET;
for byte in s.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
fn djb2(s: &str) -> u64 {
let mut hash: u64 = 5381;
for byte in s.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(u64::from(byte));
}
if hash == 0 {
1
} else {
hash
}
}
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
fn optimal_params(capacity: usize, fpr: f64) -> (usize, u32) {
let n = capacity as f64;
let ln2 = std::f64::consts::LN_2;
let ln2_sq = ln2 * ln2;
let m = (-(n * fpr.ln()) / ln2_sq).ceil() as usize;
let m = m.max(64);
let k = ((m as f64 / n) * ln2).round() as u32;
let k = k.max(1).min(32);
(m, k)
}
pub struct BloomFilterBuilder {
filter: BloomFilter,
}
impl BloomFilterBuilder {
#[must_use]
pub fn new(capacity: usize, false_positive_rate: f64) -> Self {
Self {
filter: BloomFilter::new(capacity, false_positive_rate),
}
}
#[must_use]
pub fn insert(mut self, item: &str) -> Self {
self.filter.insert(item);
self
}
#[must_use]
pub fn build(self) -> BloomFilter {
self.filter
}
}
pub struct CandidatePreFilter {
filter: BloomFilter,
}
impl CandidatePreFilter {
pub fn from_filenames<'a>(
filenames: impl Iterator<Item = &'a str>,
expected_count: usize,
false_positive_rate: f64,
) -> Self {
let mut filter = BloomFilter::new(expected_count.max(1), false_positive_rate);
for name in filenames {
filter.insert(name);
}
Self { filter }
}
#[must_use]
pub fn might_be_candidate(&self, filename: &str) -> bool {
self.filter.might_contain(filename)
}
#[must_use]
pub fn filter(&self) -> &BloomFilter {
&self.filter
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bloom_filter_no_false_negatives() {
let mut bf = BloomFilter::new(1000, 0.01);
let items = ["alpha.mov", "beta.mxf", "gamma.mp4", "delta.wav"];
for item in &items {
bf.insert(item);
}
for item in &items {
assert!(bf.might_contain(item), "False negative for {item}");
}
}
#[test]
fn test_bloom_filter_contains_after_insert() {
let mut bf = BloomFilter::new(100, 0.01);
bf.insert("test_file.mov");
assert!(bf.might_contain("test_file.mov"));
}
#[test]
fn test_bloom_filter_negative_for_absent_item() {
let bf = BloomFilter::new(1000, 0.001);
let _ = bf.might_contain("definitely_not_inserted_xyz_12345");
}
#[test]
fn test_bloom_filter_item_count() {
let mut bf = BloomFilter::new(100, 0.01);
assert_eq!(bf.item_count(), 0);
bf.insert("a");
bf.insert("b");
assert_eq!(bf.item_count(), 2);
}
#[test]
fn test_bloom_filter_bit_count_positive() {
let bf = BloomFilter::new(1000, 0.01);
assert!(bf.bit_count() >= 64);
}
#[test]
fn test_bloom_filter_hash_count_positive() {
let bf = BloomFilter::new(1000, 0.01);
assert!(bf.hash_count() >= 1);
}
#[test]
fn test_bloom_filter_estimated_fpr_zero_inserts() {
let bf = BloomFilter::new(100, 0.01);
let fpr = bf.estimated_false_positive_rate();
assert_eq!(fpr, 0.0);
}
#[test]
fn test_bloom_filter_estimated_fpr_grows_with_inserts() {
let mut bf = BloomFilter::new(10, 0.01);
let fpr_before = bf.estimated_false_positive_rate();
bf.insert("item1");
bf.insert("item2");
let fpr_after = bf.estimated_false_positive_rate();
assert!(fpr_after > fpr_before);
}
#[test]
fn test_bloom_filter_builder() {
let bf = BloomFilterBuilder::new(100, 0.01)
.insert("file1.mov")
.insert("file2.mp4")
.build();
assert_eq!(bf.item_count(), 2);
assert!(bf.might_contain("file1.mov"));
assert!(bf.might_contain("file2.mp4"));
}
#[test]
fn test_candidate_pre_filter_positive() {
let filenames = vec!["alpha.mov", "beta.mp4"];
let pf = CandidatePreFilter::from_filenames(filenames.into_iter(), 10, 0.01);
assert!(pf.might_be_candidate("alpha.mov"));
assert!(pf.might_be_candidate("beta.mp4"));
}
#[test]
fn test_candidate_pre_filter_negative() {
let filenames = vec!["known.mov"];
let pf = CandidatePreFilter::from_filenames(filenames.into_iter(), 10, 0.001);
let _ = pf.might_be_candidate("totally_unknown_99999.mov");
}
#[test]
fn test_double_hashing_uniform_distribution() {
let mut bf = BloomFilter::new(10_000, 0.001);
for i in 0..500 {
bf.insert(&format!("file_{i:04}.mov"));
}
assert_eq!(bf.item_count(), 500);
for i in 0..500 {
assert!(bf.might_contain(&format!("file_{i:04}.mov")));
}
}
}