1use std::fs::{File, OpenOptions};
36use std::path::{Path, PathBuf};
37
38use memmap2::{MmapMut, MmapOptions};
39
40use crate::shared_bit_vec::{BitVecError, SharedBitVec};
41
42pub const BLOOM_MAGIC: u64 = 0x4150_424C_4F4F_4D31;
43
44#[repr(C, align(64))]
45pub struct BloomHeader {
46 pub magic: u64,
47 pub n_bits: u64,
48 pub n_hashes: u32,
49 _pad: [u8; 44],
50}
51
52const _: () = {
53 assert!(std::mem::size_of::<BloomHeader>() == 64);
54};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum BloomError {
58 BitVec(BitVecError),
59 LayoutMismatch,
60 InvalidConfig,
61 IoError(std::io::ErrorKind),
62}
63
64impl From<BitVecError> for BloomError {
65 fn from(e: BitVecError) -> Self { Self::BitVec(e) }
66}
67impl From<std::io::Error> for BloomError {
68 fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
69}
70
71fn header_path(base: &Path) -> PathBuf {
72 let mut p = base.to_path_buf();
73 let stem = p.file_name().unwrap().to_string_lossy().to_string();
74 p.set_file_name(format!("{stem}.bloom.bin"));
75 p
76}
77fn bits_path(base: &Path) -> PathBuf {
78 let mut p = base.to_path_buf();
79 let stem = p.file_name().unwrap().to_string_lossy().to_string();
80 p.set_file_name(format!("{stem}.bits.bin"));
81 p
82}
83
84pub struct SharedBloomFilter {
85 _file: File,
86 _mmap: MmapMut,
87 bits: SharedBitVec,
88 n_bits: u64,
89 n_hashes: u32,
90 header_sidecar: subetha_core::HandshakeHeader,
91 ring_sidecar: Box<subetha_core::ObservationRing>,
92}
93
94unsafe impl Send for SharedBloomFilter {}
95unsafe impl Sync for SharedBloomFilter {}
96
97impl subetha_sidecar::AdaptiveInstance for SharedBloomFilter {
98 fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
99 fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
100 fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
101 Box::new(subetha_sidecar::NoMigrationPolicy)
102 }
103}
104
105const FNV_OFFSET_BASIS_1: u64 = 0xcbf2_9ce4_8422_2325;
106const FNV_OFFSET_BASIS_2: u64 = 0x84222325_cbf29ce4;
107const FNV_PRIME: u64 = 0x100_0000_01b3;
108
109#[inline]
110fn fnv1a_seeded(bytes: &[u8], offset_basis: u64) -> u64 {
111 let mut h = offset_basis;
112 for &b in bytes {
113 h ^= b as u64;
114 h = h.wrapping_mul(FNV_PRIME);
115 }
116 h
117}
118
119#[inline]
125fn fmix64(mut h: u64) -> u64 {
126 h ^= h >> 33;
127 h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
128 h ^= h >> 33;
129 h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
130 h ^= h >> 33;
131 h
132}
133
134impl SharedBloomFilter {
135 pub fn suggest_config(n_items: usize, p: f64) -> (usize, u32) {
138 let n = n_items as f64;
139 let n_bits = (-n * p.ln() / (std::f64::consts::LN_2 * std::f64::consts::LN_2)).ceil() as usize;
140 let n_hashes = ((n_bits as f64 / n) * std::f64::consts::LN_2).round() as u32;
141 (n_bits.max(64), n_hashes.max(1))
142 }
143
144 pub fn create(
145 base_path: impl AsRef<Path>, n_bits: usize, n_hashes: u32,
146 ) -> Result<Self, BloomError> {
147 if n_bits == 0 || n_hashes == 0 {
148 return Err(BloomError::InvalidConfig);
149 }
150 let base = base_path.as_ref();
151 let hpath = header_path(base);
152 let file = OpenOptions::new()
153 .read(true).write(true).create(true).truncate(true)
154 .open(&hpath)?;
155 file.set_len(std::mem::size_of::<BloomHeader>() as u64)?;
156 let mut mmap = unsafe {
157 MmapOptions::new().len(std::mem::size_of::<BloomHeader>()).map_mut(&file)?
158 };
159 let hdr = mmap.as_mut_ptr() as *mut BloomHeader;
160 unsafe {
161 std::ptr::write_bytes(hdr as *mut u8, 0, std::mem::size_of::<BloomHeader>());
162 (*hdr).magic = BLOOM_MAGIC;
163 (*hdr).n_bits = n_bits as u64;
164 (*hdr).n_hashes = n_hashes;
165 }
166 let bits = SharedBitVec::create(bits_path(base), n_bits)?;
167 Ok(Self {
168 _file: file, _mmap: mmap, bits,
169 n_bits: n_bits as u64, n_hashes,
170 header_sidecar: subetha_core::HandshakeHeader::new(),
171 ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
172 })
173 }
174
175 pub fn open(
176 base_path: impl AsRef<Path>, n_bits: usize, n_hashes: u32,
177 ) -> Result<Self, BloomError> {
178 let base = base_path.as_ref();
179 let hpath = header_path(base);
180 let file = OpenOptions::new().read(true).write(true).open(&hpath)?;
181 if file.metadata()?.len() < std::mem::size_of::<BloomHeader>() as u64 {
182 return Err(BloomError::LayoutMismatch);
183 }
184 let mmap = unsafe {
185 MmapOptions::new().len(std::mem::size_of::<BloomHeader>()).map_mut(&file)?
186 };
187 let hdr = unsafe { &*(mmap.as_ptr() as *const BloomHeader) };
188 if hdr.magic != BLOOM_MAGIC
189 || hdr.n_bits != n_bits as u64
190 || hdr.n_hashes != n_hashes
191 {
192 return Err(BloomError::LayoutMismatch);
193 }
194 let bits = SharedBitVec::open(bits_path(base), n_bits)?;
195 Ok(Self {
196 _file: file, _mmap: mmap, bits,
197 n_bits: n_bits as u64, n_hashes,
198 header_sidecar: subetha_core::HandshakeHeader::new(),
199 ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
200 })
201 }
202
203 #[inline]
204 pub fn n_bits(&self) -> u64 { self.n_bits }
205 #[inline]
206 pub fn n_hashes(&self) -> u32 { self.n_hashes }
207
208 #[inline]
217 fn position(&self, h1: u64, h2: u64, i: u64) -> usize {
218 let h = h1.wrapping_add(i.wrapping_mul(h2));
219 ((h as u128 * self.n_bits as u128) >> 64) as usize
220 }
221
222 pub fn insert(&self, item: &[u8]) -> Result<(), BloomError> {
223 let h1 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_1));
224 let h2 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_2));
225 for i in 0..self.n_hashes as u64 {
226 self.bits.set(self.position(h1, h2, i))?;
227 }
228 self.ring_sidecar
229 .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
230 Ok(())
231 }
232
233 pub fn contains(&self, item: &[u8]) -> Result<bool, BloomError> {
236 let h1 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_1));
237 let h2 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_2));
238 for i in 0..self.n_hashes as u64 {
239 if !self.bits.get(self.position(h1, h2, i))? {
240 self.ring_sidecar
241 .push_op(crate::sidecar_ops::sketch::OP_QUERY, 2); return Ok(false);
243 }
244 }
245 self.ring_sidecar
246 .push_op(crate::sidecar_ops::sketch::OP_QUERY, 0);
247 Ok(true)
248 }
249
250 pub fn clear(&self) {
252 self.bits.clear_all();
253 self.ring_sidecar
254 .push_op(crate::sidecar_ops::sketch::OP_CLEAR, 0);
255 }
256
257 pub fn estimated_false_positive_rate(&self) -> f64 {
261 let fill = self.bits.count_ones() as f64;
262 let n = self.n_bits as f64;
263 let k = self.n_hashes as f64;
264 if fill == 0.0 { return 0.0; }
265 (fill / n).powf(k)
266 }
267
268 pub fn estimated_insert_count(&self) -> u64 {
271 let fill = self.bits.count_ones() as f64;
272 let n = self.n_bits as f64;
273 let k = self.n_hashes as f64;
274 if fill == 0.0 { return 0; }
275 if fill >= n { return u64::MAX; }
276 ((-n / k) * (1.0 - fill / n).ln()).round() as u64
277 }
278
279 pub fn flush(&self) -> Result<(), BloomError> {
280 Ok(self.bits.flush()?)
281 }
282
283 pub fn flush_async(&self) -> Result<(), BloomError> {
284 Ok(self.bits.flush_async()?)
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn tmp_base(name: &str) -> PathBuf {
293 let mut p = std::env::temp_dir();
294 let pid = std::process::id();
295 p.push(format!("subetha-bloom-{name}-{pid}"));
296 p
297 }
298
299 fn cleanup(base: &Path) {
300 std::fs::remove_file(header_path(base)).ok();
301 std::fs::remove_file(bits_path(base)).ok();
302 }
303
304 #[test]
305 fn empty_filter_contains_nothing() {
306 let base = tmp_base("empty");
307 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
308 assert!(!b.contains(b"anything").unwrap());
309 assert!(!b.contains(b"").unwrap());
310 cleanup(&base);
311 }
312
313 #[test]
314 fn insert_then_contains_returns_true_no_false_negatives() {
315 let base = tmp_base("no-fn");
316 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
317 let items: &[&[u8]] = &[
318 b"hello", b"world", b"adaptive-prims", b"42",
319 b"the quick brown fox", b"", b"a",
320 ];
321 for &item in items {
322 b.insert(item).unwrap();
323 }
324 for &item in items {
325 assert!(b.contains(item).unwrap(),
326 "false negative for item: {item:?}");
327 }
328 cleanup(&base);
329 }
330
331 #[test]
332 fn invalid_config_rejected() {
333 let base = tmp_base("invalid");
334 assert_eq!(
335 SharedBloomFilter::create(&base, 0, 3).err(),
336 Some(BloomError::InvalidConfig)
337 );
338 assert_eq!(
339 SharedBloomFilter::create(&base, 1024, 0).err(),
340 Some(BloomError::InvalidConfig)
341 );
342 cleanup(&base);
343 }
344
345 #[test]
346 fn false_positive_rate_within_bounds_at_target_load() {
347 let (n_bits, n_hashes) = SharedBloomFilter::suggest_config(1000, 0.01);
348 let base = tmp_base("fpr");
349 let b = SharedBloomFilter::create(&base, n_bits, n_hashes).unwrap();
350 for i in 0..1000u32 {
351 b.insert(format!("item-{i:04}").as_bytes()).unwrap();
352 }
353 let mut fp = 0u32;
354 for i in 10_000u32..20_000 {
355 if b.contains(format!("query-{i:05}").as_bytes()).unwrap() {
356 fp += 1;
357 }
358 }
359 let observed_fpr = fp as f64 / 10_000.0;
360 assert!(observed_fpr < 0.03,
362 "observed FPR {observed_fpr} should be below 0.03 (target was 0.01)");
363 cleanup(&base);
364 }
365
366 #[test]
367 fn clear_resets_filter() {
368 let base = tmp_base("clear");
369 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
370 b.insert(b"foo").unwrap();
371 b.insert(b"bar").unwrap();
372 assert!(b.contains(b"foo").unwrap());
373 b.clear();
374 assert!(!b.contains(b"foo").unwrap());
375 assert!(!b.contains(b"bar").unwrap());
376 cleanup(&base);
377 }
378
379 #[test]
380 fn cross_handle_visibility() {
381 let base = tmp_base("cross-handle");
382 let writer = SharedBloomFilter::create(&base, 1024, 3).unwrap();
383 let reader = SharedBloomFilter::open(&base, 1024, 3).unwrap();
384 writer.insert(b"cross-process").unwrap();
385 assert!(reader.contains(b"cross-process").unwrap());
386 assert!(!reader.contains(b"not-inserted").unwrap());
387 cleanup(&base);
388 }
389
390 #[test]
391 fn config_mismatch_at_open_rejected() {
392 let base = tmp_base("mismatch");
393 let _w = SharedBloomFilter::create(&base, 1024, 3).unwrap();
394 assert!(matches!(
395 SharedBloomFilter::open(&base, 2048, 3),
396 Err(BloomError::LayoutMismatch)
397 ));
398 assert!(matches!(
399 SharedBloomFilter::open(&base, 1024, 5),
400 Err(BloomError::LayoutMismatch)
401 ));
402 cleanup(&base);
403 }
404
405 #[test]
406 fn estimated_count_tracks_real_inserts() {
407 let base = tmp_base("count-est");
408 let (n_bits, n_hashes) = SharedBloomFilter::suggest_config(1000, 0.01);
409 let b = SharedBloomFilter::create(&base, n_bits, n_hashes).unwrap();
410 for i in 0..500u32 {
411 b.insert(format!("k{i}").as_bytes()).unwrap();
412 }
413 let est = b.estimated_insert_count();
414 assert!(est > 350 && est < 650,
416 "estimated insert count {est} should be near 500");
417 cleanup(&base);
418 }
419
420 #[test]
421 fn suggest_config_returns_sensible_values() {
422 let (n_bits, n_hashes) = SharedBloomFilter::suggest_config(10_000, 0.01);
423 assert!(n_bits > 90_000 && n_bits < 100_000,
424 "n_bits {n_bits} should be ~95k");
425 assert!((6..=8).contains(&n_hashes),
426 "n_hashes {n_hashes} should be ~7");
427 }
428
429 #[test]
430 fn disk_persistence_survives_reopen() {
431 let base = tmp_base("disk");
432 {
433 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
434 b.insert(b"persisted").unwrap();
435 b.insert(b"also-persisted").unwrap();
436 b.flush().unwrap();
437 }
438 let b2 = SharedBloomFilter::open(&base, 1024, 3).unwrap();
439 assert!(b2.contains(b"persisted").unwrap());
440 assert!(b2.contains(b"also-persisted").unwrap());
441 assert!(!b2.contains(b"not-there").unwrap());
442 cleanup(&base);
443 }
444
445 #[test]
446 fn concurrent_inserters_no_lost_updates() {
447 use std::sync::Arc;
448 use std::thread;
449 let base = tmp_base("concurrent");
450 let b: Arc<SharedBloomFilter>
451 = Arc::new(SharedBloomFilter::create(&base, 8192, 5).unwrap());
452 let n_threads = 4;
453 let per_thread = 100;
454 let mut handles = vec![];
455 for t in 0..n_threads {
456 let b = b.clone();
457 handles.push(thread::spawn(move || {
458 for i in 0..per_thread {
459 b.insert(format!("t{t}-i{i}").as_bytes()).unwrap();
460 }
461 }));
462 }
463 for h in handles { h.join().unwrap(); }
464 for t in 0..n_threads {
465 for i in 0..per_thread {
466 let item = format!("t{t}-i{i}");
467 assert!(b.contains(item.as_bytes()).unwrap(),
468 "missing item {item}");
469 }
470 }
471 cleanup(&base);
472 }
473}