#[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
}
}
#[derive(Debug, Clone)]
pub struct CandidateBloom {
bits: Vec<u64>,
k: usize,
bit_count: usize,
}
impl CandidateBloom {
#[must_use]
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
pub fn new(n_expected: usize, fpr: f64) -> Self {
assert!(n_expected > 0, "n_expected must be > 0");
assert!(fpr > 0.0 && fpr < 1.0, "fpr must be in (0, 1)");
let n = n_expected as f64;
let ln2 = std::f64::consts::LN_2;
let ln2_sq = ln2 * ln2;
let m_bits = (-(n * fpr.ln()) / ln2_sq).ceil() as usize;
let m_bits = m_bits.max(64);
let k = ((m_bits as f64 / n) * ln2).ceil() as usize;
let k = k.max(1).min(32);
let word_count = m_bits.div_ceil(64);
Self {
bits: vec![0u64; word_count],
k,
bit_count: m_bits,
}
}
pub fn insert(&mut self, key: &impl std::hash::Hash) {
for bit_idx in self.bit_indices(key) {
self.bits[bit_idx / 64] |= 1u64 << (bit_idx % 64);
}
}
#[must_use]
pub fn might_contain(&self, key: &impl std::hash::Hash) -> bool {
for bit_idx in self.bit_indices(key) {
if self.bits[bit_idx / 64] & (1u64 << (bit_idx % 64)) == 0 {
return false;
}
}
true
}
fn bit_indices(&self, key: &impl std::hash::Hash) -> impl Iterator<Item = usize> {
let m = self.bit_count;
let k = self.k;
let base_hashes: Vec<usize> = (0..k)
.map(|i| {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let seed: u64 = 0x9e3779b97f4a7c15_u64.wrapping_add(i as u64);
let mut h = DefaultHasher::default();
seed.hash(&mut h);
key.hash(&mut h);
(h.finish() % m as u64) as usize
})
.collect();
base_hashes.into_iter()
}
}
pub struct MatchingBloom {
inner: super::strategies::MatchStrategy,
bloom: CandidateBloom,
}
impl MatchingBloom {
#[must_use]
pub fn new(strategy: super::strategies::MatchStrategy, n_expected: usize, fpr: f64) -> Self {
Self {
inner: strategy,
bloom: CandidateBloom::new(n_expected.max(1), fpr),
}
}
pub fn insert_key(&mut self, key: &str) {
self.bloom.insert(&key.to_string());
}
pub fn build_from_catalog(&mut self, media: &[crate::types::MediaFile]) {
for m in media {
self.bloom.insert(&m.filename);
}
}
#[must_use]
pub fn match_clip(
&self,
clip: &crate::types::ClipReference,
all_media: &[crate::types::MediaFile],
) -> Vec<crate::types::ClipMatch> {
let candidates: Vec<&crate::types::MediaFile> = all_media
.iter()
.filter(|m| self.bloom.might_contain(&m.filename))
.collect();
let owned: Vec<crate::types::MediaFile> = candidates.into_iter().cloned().collect();
self.inner.match_clip(clip, &owned)
}
#[must_use]
pub fn bloom(&self) -> &CandidateBloom {
&self.bloom
}
}
#[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")));
}
}
#[test]
fn test_candidate_bloom_no_false_negatives() {
let keys: Vec<String> = (0..200).map(|i| format!("media_{i:04}.mov")).collect();
let mut bloom = CandidateBloom::new(256, 0.01);
for k in &keys {
bloom.insert(k);
}
for k in &keys {
assert!(
bloom.might_contain(k),
"CandidateBloom must not produce false negatives (key: {k})"
);
}
}
#[test]
fn test_candidate_bloom_prefilter_equals_full_match() {
use crate::config::ConformConfig;
use crate::types::{FrameRate, MediaFile, Timecode, TrackType};
use std::path::PathBuf;
let config = ConformConfig::default();
let media: Vec<MediaFile> = (0..10)
.map(|i| MediaFile::new(PathBuf::from(format!("/catalog/file_{i:02}.mov"))))
.collect();
let mut mbloom = MatchingBloom::new(
crate::matching::strategies::MatchStrategy::new(config.clone()),
media.len(),
0.01,
);
mbloom.build_from_catalog(&media);
let plain_strategy = crate::matching::strategies::MatchStrategy::new(config);
let clip = crate::types::ClipReference {
id: "test_clip".to_string(),
source_file: Some("file_05.mov".to_string()),
source_in: Timecode::new(1, 0, 0, 0),
source_out: Timecode::new(1, 0, 5, 0),
record_in: Timecode::new(1, 0, 0, 0),
record_out: Timecode::new(1, 0, 5, 0),
track: TrackType::Video,
fps: FrameRate::Fps25,
metadata: std::collections::HashMap::new(),
};
let bloom_matches = mbloom.match_clip(&clip, &media);
let plain_matches = plain_strategy.match_clip(&clip, &media);
for pm in &plain_matches {
let found = bloom_matches.iter().any(|bm| bm.media.id == pm.media.id);
assert!(
found,
"MatchingBloom dropped a correct match: {:?}",
pm.media.filename
);
}
assert_eq!(
bloom_matches.first().map(|m| &m.media.filename),
plain_matches.first().map(|m| &m.media.filename),
"MatchingBloom and plain strategy should return the same best match"
);
}
#[test]
fn test_candidate_bloom_fpr_within_bound() {
let n = 1_000usize;
let target_fpr = 0.02_f64;
let mut bloom = CandidateBloom::new(n, target_fpr);
for i in 0..n {
bloom.insert(&format!("inserted_{i:05}"));
}
let total_probes = 10_000usize;
let mut false_positives = 0usize;
for i in 0..total_probes {
if bloom.might_contain(&format!("absent_{i:06}")) {
false_positives += 1;
}
}
let observed_fpr = false_positives as f64 / total_probes as f64;
assert!(
observed_fpr <= target_fpr * 3.0,
"Observed FPR {observed_fpr:.4} exceeds 3× target ({:.4})",
target_fpr * 3.0
);
}
}