Skip to main content

ipfrs_storage/
bloom.rs

1//! Bloom filter for probabilistic block existence checks.
2//!
3//! Provides fast probabilistic `has()` checks with configurable false positive rates.
4//! A bloom filter can quickly tell if a block definitely doesn't exist,
5//! avoiding expensive disk lookups for cache misses.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use ipfrs_storage::bloom::BloomFilter;
11//!
12//! let mut filter = BloomFilter::new(1_000_000, 0.01); // 1M items, 1% FPR
13//! filter.insert(b"block_cid_bytes");
14//! assert!(filter.contains(b"block_cid_bytes"));
15//! assert!(!filter.contains(b"unknown")); // Probably false, might be true
16//! ```
17
18use ipfrs_core::{Cid, Error, Result};
19use parking_lot::RwLock;
20use serde::{Deserialize, Serialize};
21use std::path::Path;
22
23/// Default false positive rate (1%)
24const DEFAULT_FALSE_POSITIVE_RATE: f64 = 0.01;
25
26/// Bloom filter for fast probabilistic existence checks.
27///
28/// Uses multiple hash functions to minimize false positives while
29/// maintaining constant-time lookups regardless of dataset size.
30pub struct BloomFilter {
31    /// Bit array for the bloom filter
32    inner: RwLock<BloomFilterInner>,
33    /// Configuration
34    config: BloomConfig,
35}
36
37/// Inner mutable state of the bloom filter
38#[derive(Serialize, Deserialize)]
39struct BloomFilterInner {
40    /// Bit vector
41    bits: Vec<u64>,
42    /// Number of items inserted
43    count: usize,
44}
45
46/// Bloom filter configuration
47#[derive(Debug, Clone)]
48pub struct BloomConfig {
49    /// Expected number of items
50    pub expected_items: usize,
51    /// Desired false positive rate (0.0 - 1.0)
52    pub false_positive_rate: f64,
53    /// Number of hash functions to use
54    pub num_hashes: usize,
55    /// Size of the bit array in bits
56    pub num_bits: usize,
57}
58
59impl BloomConfig {
60    /// Create a new configuration with given parameters
61    pub fn new(expected_items: usize, false_positive_rate: f64) -> Self {
62        // Calculate optimal parameters
63        // m = -n * ln(p) / (ln(2)^2) where m = bits, n = items, p = FPR
64        let ln2_squared = std::f64::consts::LN_2 * std::f64::consts::LN_2;
65        let num_bits =
66            (-((expected_items as f64) * false_positive_rate.ln()) / ln2_squared).ceil() as usize;
67
68        // k = (m/n) * ln(2) where k = hash functions
69        let num_hashes =
70            ((num_bits as f64 / expected_items as f64) * std::f64::consts::LN_2).ceil() as usize;
71
72        // Ensure minimum values
73        let num_bits = num_bits.max(64);
74        let num_hashes = num_hashes.clamp(1, 16); // Cap at 16 hash functions
75
76        Self {
77            expected_items,
78            false_positive_rate,
79            num_hashes,
80            num_bits,
81        }
82    }
83
84    /// Create a configuration for low memory usage
85    pub fn low_memory(expected_items: usize) -> Self {
86        Self::new(expected_items, 0.05) // 5% FPR for smaller filter
87    }
88
89    /// Create a configuration for high accuracy
90    pub fn high_accuracy(expected_items: usize) -> Self {
91        Self::new(expected_items, 0.001) // 0.1% FPR
92    }
93
94    /// Calculate memory usage in bytes
95    #[inline]
96    pub fn memory_bytes(&self) -> usize {
97        // Round up to u64 boundary
98        self.num_bits.div_ceil(64) * 8
99    }
100}
101
102impl Default for BloomConfig {
103    fn default() -> Self {
104        Self::new(100_000, DEFAULT_FALSE_POSITIVE_RATE)
105    }
106}
107
108impl BloomFilter {
109    /// Create a new bloom filter with the given expected item count and false positive rate.
110    ///
111    /// # Arguments
112    /// * `expected_items` - Expected number of items to be stored
113    /// * `false_positive_rate` - Desired false positive rate (0.0 - 1.0)
114    pub fn new(expected_items: usize, false_positive_rate: f64) -> Self {
115        let config = BloomConfig::new(expected_items, false_positive_rate);
116        Self::with_config(config)
117    }
118
119    /// Create a bloom filter with custom configuration
120    pub fn with_config(config: BloomConfig) -> Self {
121        let num_u64s = config.num_bits.div_ceil(64);
122        let inner = BloomFilterInner {
123            bits: vec![0u64; num_u64s],
124            count: 0,
125        };
126        Self {
127            inner: RwLock::new(inner),
128            config,
129        }
130    }
131
132    /// Insert a CID into the bloom filter
133    #[inline]
134    pub fn insert_cid(&self, cid: &Cid) {
135        self.insert(&cid.to_bytes());
136    }
137
138    /// Check if a CID might be in the bloom filter
139    ///
140    /// Returns `true` if the CID might be present (may be a false positive),
141    /// Returns `false` if the CID is definitely not present.
142    #[inline]
143    pub fn contains_cid(&self, cid: &Cid) -> bool {
144        self.contains(&cid.to_bytes())
145    }
146
147    /// Insert raw bytes into the bloom filter
148    pub fn insert(&self, data: &[u8]) {
149        let mut inner = self.inner.write();
150        let hashes = self.compute_hashes(data);
151
152        for hash in hashes {
153            let bit_index = hash % self.config.num_bits;
154            let word_index = bit_index / 64;
155            let bit_offset = bit_index % 64;
156            inner.bits[word_index] |= 1u64 << bit_offset;
157        }
158        inner.count += 1;
159    }
160
161    /// Check if raw bytes might be in the bloom filter
162    pub fn contains(&self, data: &[u8]) -> bool {
163        let inner = self.inner.read();
164        let hashes = self.compute_hashes(data);
165
166        for hash in hashes {
167            let bit_index = hash % self.config.num_bits;
168            let word_index = bit_index / 64;
169            let bit_offset = bit_index % 64;
170            if inner.bits[word_index] & (1u64 << bit_offset) == 0 {
171                return false;
172            }
173        }
174        true
175    }
176
177    /// Compute hash values for data using double hashing technique
178    fn compute_hashes(&self, data: &[u8]) -> Vec<usize> {
179        // Use FNV-1a for h1 and a different seed for h2
180        let h1 = fnv1a_hash(data);
181        let h2 = fnv1a_hash_with_seed(data, 0x811c_9dc5);
182
183        let mut hashes = Vec::with_capacity(self.config.num_hashes);
184        for i in 0..self.config.num_hashes {
185            // Double hashing: h(i) = h1 + i * h2
186            let hash = h1.wrapping_add((i as u64).wrapping_mul(h2));
187            hashes.push(hash as usize);
188        }
189        hashes
190    }
191
192    /// Get the number of items inserted
193    #[inline]
194    pub fn count(&self) -> usize {
195        self.inner.read().count
196    }
197
198    /// Get the fill ratio (proportion of bits set)
199    pub fn fill_ratio(&self) -> f64 {
200        let inner = self.inner.read();
201        let set_bits: usize = inner.bits.iter().map(|w| w.count_ones() as usize).sum();
202        set_bits as f64 / self.config.num_bits as f64
203    }
204
205    /// Estimate the actual false positive rate based on current fill
206    pub fn estimated_fpr(&self) -> f64 {
207        let fill = self.fill_ratio();
208        fill.powi(self.config.num_hashes as i32)
209    }
210
211    /// Get memory usage in bytes
212    #[inline]
213    pub fn memory_bytes(&self) -> usize {
214        self.config.memory_bytes()
215    }
216
217    /// Clear the bloom filter
218    pub fn clear(&self) {
219        let mut inner = self.inner.write();
220        for word in inner.bits.iter_mut() {
221            *word = 0;
222        }
223        inner.count = 0;
224    }
225
226    /// Save the bloom filter to a file
227    pub fn save_to_file(&self, path: &Path) -> Result<()> {
228        let inner = self.inner.read();
229        let data = oxicode::serde::encode_to_vec(&*inner, oxicode::config::standard())
230            .map_err(|e| Error::Serialization(format!("Failed to serialize bloom filter: {e}")))?;
231        std::fs::write(path, data)
232            .map_err(|e| Error::Storage(format!("Failed to write bloom filter: {e}")))?;
233        Ok(())
234    }
235
236    /// Load the bloom filter from a file
237    pub fn load_from_file(path: &Path, config: BloomConfig) -> Result<Self> {
238        let data = std::fs::read(path)
239            .map_err(|e| Error::Storage(format!("Failed to read bloom filter: {e}")))?;
240        let inner: BloomFilterInner =
241            oxicode::serde::decode_owned_from_slice(&data, oxicode::config::standard())
242                .map(|(v, _)| v)
243                .map_err(|e| {
244                    Error::Deserialization(format!("Failed to deserialize bloom filter: {e}"))
245                })?;
246
247        // Verify the loaded filter matches expected config
248        let expected_words = config.num_bits.div_ceil(64);
249        if inner.bits.len() != expected_words {
250            return Err(Error::InvalidData(format!(
251                "Bloom filter size mismatch: expected {} words, got {}",
252                expected_words,
253                inner.bits.len()
254            )));
255        }
256
257        Ok(Self {
258            inner: RwLock::new(inner),
259            config,
260        })
261    }
262
263    /// Get bloom filter statistics
264    pub fn stats(&self) -> BloomStats {
265        BloomStats {
266            count: self.count(),
267            memory_bytes: self.memory_bytes(),
268            fill_ratio: self.fill_ratio(),
269            estimated_fpr: self.estimated_fpr(),
270            num_bits: self.config.num_bits,
271            num_hashes: self.config.num_hashes,
272        }
273    }
274}
275
276/// Statistics about a bloom filter
277#[derive(Debug, Clone)]
278pub struct BloomStats {
279    /// Number of items inserted
280    pub count: usize,
281    /// Memory usage in bytes
282    pub memory_bytes: usize,
283    /// Proportion of bits set (0.0 - 1.0)
284    pub fill_ratio: f64,
285    /// Estimated false positive rate
286    pub estimated_fpr: f64,
287    /// Total number of bits
288    pub num_bits: usize,
289    /// Number of hash functions
290    pub num_hashes: usize,
291}
292
293/// FNV-1a hash function
294#[inline]
295fn fnv1a_hash(data: &[u8]) -> u64 {
296    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
297    const FNV_PRIME: u64 = 0x0100_0000_01b3;
298
299    let mut hash = FNV_OFFSET;
300    for &byte in data {
301        hash ^= byte as u64;
302        hash = hash.wrapping_mul(FNV_PRIME);
303    }
304    hash
305}
306
307/// FNV-1a hash with custom seed
308#[inline]
309fn fnv1a_hash_with_seed(data: &[u8], seed: u64) -> u64 {
310    const FNV_PRIME: u64 = 0x0100_0000_01b3;
311
312    let mut hash = seed;
313    for &byte in data {
314        hash ^= byte as u64;
315        hash = hash.wrapping_mul(FNV_PRIME);
316    }
317    hash
318}
319
320/// Convenience constructor: create a `BloomFilter` backed by a fixed bit-count.
321///
322/// Rounds `bits` up to the next multiple of 64 and uses a two-hash (FNV-1a +
323/// multiplicative) scheme with 7 probes — chosen for ~1 % FPR at 100 k elements
324/// in a 1 M-bit filter.
325impl BloomFilter {
326    /// Create a filter with exactly `bits` capacity (rounded up to 64-bit boundary).
327    ///
328    /// Uses a fixed 7-probe two-hash scheme suitable for general-purpose deduplication.
329    pub fn new_with_bits(bits: usize) -> Self {
330        // Round up to next multiple of 64
331        let rounded = bits.div_ceil(64) * 64;
332        let config = BloomConfig {
333            expected_items: 100_000,
334            false_positive_rate: 0.01,
335            num_hashes: 7,
336            num_bits: rounded,
337        };
338        Self::with_config(config)
339    }
340
341    /// Number of elements inserted so far (alias for `count()`).
342    #[inline]
343    pub fn len(&self) -> usize {
344        self.count()
345    }
346
347    /// Whether no elements have been inserted.
348    #[inline]
349    pub fn is_empty(&self) -> bool {
350        self.count() == 0
351    }
352
353    /// Whether no elements have been inserted (semantic alias, kept for test clarity).
354    #[inline]
355    pub fn is_bloom_empty(&self) -> bool {
356        self.is_empty()
357    }
358
359    /// Total number of bits in the filter.
360    #[inline]
361    pub fn bit_count(&self) -> usize {
362        self.config.num_bits
363    }
364
365    /// Probabilistic check: returns `false` iff the key is *definitely* absent.
366    #[inline]
367    pub fn may_contain(&self, key: &[u8]) -> bool {
368        self.contains(key)
369    }
370
371    /// Fraction of bits currently set (0.0 – 1.0).
372    #[inline]
373    pub fn estimated_fill_ratio(&self) -> f64 {
374        self.fill_ratio()
375    }
376}
377
378// ─── BloomFilterConfig ────────────────────────────────────────────────────────
379
380/// High-level configuration for the CID-oriented bloom filter layer.
381#[derive(Debug, Clone)]
382pub struct BloomFilterConfig {
383    /// Total number of bits in the underlying bit array (default: 1 048 576 = 1 M bits).
384    pub bits: usize,
385    /// Expected number of elements to be inserted (used for documentation / stats only).
386    pub expected_elements: usize,
387}
388
389impl Default for BloomFilterConfig {
390    fn default() -> Self {
391        Self {
392            bits: 1_048_576,
393            expected_elements: 100_000,
394        }
395    }
396}
397
398// ─── BloomSnapshot ────────────────────────────────────────────────────────────
399
400/// Point-in-time snapshot of `CidBloomFilter` state.
401#[derive(Debug, Clone)]
402pub struct BloomSnapshot {
403    /// Fraction of bits that are set (0.0 – 1.0).
404    pub fill_ratio: f64,
405    /// Estimated number of distinct elements inserted (via fill-ratio formula).
406    pub estimated_elements: usize,
407    /// Total capacity in bits.
408    pub bit_count: usize,
409}
410
411// ─── CidBloomFilter ───────────────────────────────────────────────────────────
412
413/// CID-specific wrapper around [`BloomFilter`] for write-time deduplication.
414///
415/// Converts CID strings to bytes and delegates to the inner filter.  All
416/// operations are thread-safe via the `parking_lot::RwLock` inside `BloomFilter`.
417pub struct CidBloomFilter {
418    inner: BloomFilter,
419    config: BloomFilterConfig,
420}
421
422impl CidBloomFilter {
423    /// Create a new `CidBloomFilter` with the given configuration.
424    pub fn new(config: BloomFilterConfig) -> Self {
425        let filter = BloomFilter::new_with_bits(config.bits);
426        Self {
427            inner: filter,
428            config,
429        }
430    }
431
432    /// Create a `CidBloomFilter` with default configuration (1 M-bit filter).
433    pub fn default_config() -> Self {
434        Self::new(BloomFilterConfig::default())
435    }
436
437    /// Insert a CID (as a UTF-8 string) into the filter.
438    #[inline]
439    pub fn insert_cid(&self, cid: &str) {
440        self.inner.insert(cid.as_bytes());
441    }
442
443    /// Returns `false` iff the CID is *definitely* not in the filter.
444    #[inline]
445    pub fn may_contain_cid(&self, cid: &str) -> bool {
446        self.inner.may_contain(cid.as_bytes())
447    }
448
449    /// Take a snapshot of the current filter state.
450    pub fn snapshot(&self) -> BloomSnapshot {
451        let fill = self.inner.estimated_fill_ratio();
452        let bit_count = self.inner.bit_count();
453
454        // Estimate elements from fill ratio:
455        //   fill ≈ 1 - exp(-k * n / m)  ⟹  n ≈ -m/k * ln(1 - fill)
456        // k = num_hashes, m = bit_count
457        let k = self.inner.config.num_hashes as f64;
458        let m = bit_count as f64;
459        let estimated_elements = if fill >= 1.0 {
460            usize::MAX
461        } else {
462            let est = -(m / k) * (1.0 - fill).ln();
463            est.round() as usize
464        };
465
466        BloomSnapshot {
467            fill_ratio: fill,
468            estimated_elements,
469            bit_count,
470        }
471    }
472
473    /// Clear the filter (all bits zeroed, count reset to zero).
474    #[inline]
475    pub fn reset(&self) {
476        self.inner.clear();
477    }
478
479    /// Access the underlying `BloomFilter` directly.
480    #[inline]
481    pub fn inner(&self) -> &BloomFilter {
482        &self.inner
483    }
484
485    /// Return the configuration this filter was created with.
486    #[inline]
487    pub fn config(&self) -> &BloomFilterConfig {
488        &self.config
489    }
490}
491
492impl Default for CidBloomFilter {
493    fn default() -> Self {
494        Self::default_config()
495    }
496}
497
498/// Block store wrapper that uses a bloom filter for fast negative lookups
499use crate::traits::BlockStore;
500use async_trait::async_trait;
501use ipfrs_core::Block;
502
503pub struct BloomBlockStore<S: BlockStore> {
504    store: S,
505    filter: BloomFilter,
506}
507
508impl<S: BlockStore> BloomBlockStore<S> {
509    /// Create a new bloom-filtered block store
510    pub fn new(store: S, expected_items: usize, false_positive_rate: f64) -> Self {
511        Self {
512            store,
513            filter: BloomFilter::new(expected_items, false_positive_rate),
514        }
515    }
516
517    /// Create with custom bloom filter configuration
518    pub fn with_config(store: S, config: BloomConfig) -> Self {
519        Self {
520            store,
521            filter: BloomFilter::with_config(config),
522        }
523    }
524
525    /// Rebuild the bloom filter from the store's contents
526    pub fn rebuild_filter(&self) -> Result<()> {
527        self.filter.clear();
528        for cid in self.store.list_cids()? {
529            self.filter.insert_cid(&cid);
530        }
531        Ok(())
532    }
533
534    /// Get bloom filter statistics
535    pub fn bloom_stats(&self) -> BloomStats {
536        self.filter.stats()
537    }
538
539    /// Get reference to underlying store
540    #[inline]
541    pub fn store(&self) -> &S {
542        &self.store
543    }
544}
545
546#[async_trait]
547impl<S: BlockStore> BlockStore for BloomBlockStore<S> {
548    async fn put(&self, block: &Block) -> Result<()> {
549        self.filter.insert_cid(block.cid());
550        self.store.put(block).await
551    }
552
553    async fn put_many(&self, blocks: &[Block]) -> Result<()> {
554        for block in blocks {
555            self.filter.insert_cid(block.cid());
556        }
557        self.store.put_many(blocks).await
558    }
559
560    async fn get(&self, cid: &Cid) -> Result<Option<Block>> {
561        // Fast path: if bloom filter says no, definitely not there
562        if !self.filter.contains_cid(cid) {
563            return Ok(None);
564        }
565        // May be a false positive, check actual store
566        self.store.get(cid).await
567    }
568
569    async fn has(&self, cid: &Cid) -> Result<bool> {
570        // Fast path: if bloom filter says no, definitely not there
571        if !self.filter.contains_cid(cid) {
572            return Ok(false);
573        }
574        // May be a false positive, check actual store
575        self.store.has(cid).await
576    }
577
578    async fn has_many(&self, cids: &[Cid]) -> Result<Vec<bool>> {
579        // Check bloom filter first, only query store for maybes
580        let mut results = Vec::with_capacity(cids.len());
581        let mut to_check = Vec::new();
582        let mut indices = Vec::new();
583
584        for (i, cid) in cids.iter().enumerate() {
585            if self.filter.contains_cid(cid) {
586                to_check.push(*cid);
587                indices.push(i);
588            }
589            results.push(false); // Default to false
590        }
591
592        // Only query store for CIDs that passed bloom filter
593        if !to_check.is_empty() {
594            let store_results = self.store.has_many(&to_check).await?;
595            for (idx, exists) in indices.into_iter().zip(store_results) {
596                results[idx] = exists;
597            }
598        }
599
600        Ok(results)
601    }
602
603    async fn delete(&self, cid: &Cid) -> Result<()> {
604        // Note: We don't remove from bloom filter (standard bloom filters don't support deletion)
605        // The filter may have false positives for deleted items until rebuild
606        self.store.delete(cid).await
607    }
608
609    async fn delete_many(&self, cids: &[Cid]) -> Result<()> {
610        self.store.delete_many(cids).await
611    }
612
613    fn list_cids(&self) -> Result<Vec<Cid>> {
614        self.store.list_cids()
615    }
616
617    fn len(&self) -> usize {
618        self.store.len()
619    }
620
621    fn is_empty(&self) -> bool {
622        self.store.is_empty()
623    }
624
625    async fn flush(&self) -> Result<()> {
626        self.store.flush().await
627    }
628
629    async fn close(&self) -> Result<()> {
630        self.store.close().await
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    #[test]
639    fn test_bloom_filter_basic() {
640        let filter = BloomFilter::new(1000, 0.01);
641
642        filter.insert(b"hello");
643        filter.insert(b"world");
644
645        assert!(filter.contains(b"hello"));
646        assert!(filter.contains(b"world"));
647        assert!(!filter.contains(b"foo")); // Might be false positive, but unlikely
648    }
649
650    #[test]
651    fn test_bloom_filter_false_positive_rate() {
652        let filter = BloomFilter::new(10000, 0.01);
653
654        // Insert 10000 items
655        for i in 0i32..10000 {
656            filter.insert(&i.to_le_bytes());
657        }
658
659        // Check false positives on items not inserted
660        let mut false_positives = 0;
661        for i in 10000i32..20000 {
662            if filter.contains(&i.to_le_bytes()) {
663                false_positives += 1;
664            }
665        }
666
667        // Should be around 1% false positives (allow some margin)
668        let fpr = false_positives as f64 / 10000.0;
669        assert!(fpr < 0.03, "False positive rate {} too high", fpr);
670    }
671
672    #[test]
673    fn test_bloom_config_memory() {
674        let config = BloomConfig::new(1_000_000, 0.01);
675        let memory_mb = config.memory_bytes() as f64 / (1024.0 * 1024.0);
676        // Should be less than 10MB for 1M items (verified target)
677        assert!(
678            memory_mb < 10.0,
679            "Memory {} MB exceeds 10MB target",
680            memory_mb
681        );
682    }
683
684    #[test]
685    fn test_bloom_filter_stats() {
686        let filter = BloomFilter::new(1000, 0.01);
687
688        for i in 0i32..100 {
689            filter.insert(&i.to_le_bytes());
690        }
691
692        let stats = filter.stats();
693        assert_eq!(stats.count, 100);
694        assert!(stats.fill_ratio > 0.0);
695        assert!(stats.fill_ratio < 1.0);
696    }
697
698    // ── Tests for the new deduplication layer ────────────────────────────────
699
700    /// 1. new_with_bits rounds bits up to 64-bit boundary correctly.
701    #[test]
702    fn test_new_with_bits_rounding() {
703        let f = BloomFilter::new_with_bits(1);
704        assert_eq!(f.bit_count(), 64, "1 bit should round up to 64");
705
706        let f2 = BloomFilter::new_with_bits(65);
707        assert_eq!(f2.bit_count(), 128, "65 bits should round up to 128");
708
709        let f3 = BloomFilter::new_with_bits(1_048_576);
710        assert_eq!(
711            f3.bit_count(),
712            1_048_576,
713            "exact multiple must stay unchanged"
714        );
715    }
716
717    /// 2. Zero false negatives: every inserted item is found.
718    #[test]
719    fn test_zero_false_negatives() {
720        let filter = BloomFilter::new_with_bits(1_048_576);
721        let items: Vec<String> = (0..500).map(|i| format!("item-{}", i)).collect();
722
723        for item in &items {
724            filter.insert(item.as_bytes());
725        }
726        for item in &items {
727            assert!(
728                filter.may_contain(item.as_bytes()),
729                "False negative detected for '{}'",
730                item
731            );
732        }
733    }
734
735    /// 3. may_contain returns false for items that were never inserted
736    ///    (for clearly distinct keys this is deterministic).
737    #[test]
738    fn test_absent_keys_not_found() {
739        let filter = BloomFilter::new_with_bits(1_048_576);
740        // Nothing inserted — no key should be found.
741        assert!(!filter.may_contain(b"never-inserted-key-abc"));
742        assert!(!filter.may_contain(b"another-absent-key-xyz"));
743    }
744
745    /// 4. False-positive rate is < 1 % for 1 000 elements in a 1 M-bit filter.
746    #[test]
747    fn test_false_positive_rate_under_one_percent() {
748        let filter = BloomFilter::new_with_bits(1_048_576);
749
750        // Insert 1 000 items using a prefix that won't overlap with the probe set.
751        for i in 0u32..1_000 {
752            filter.insert(format!("inserted-{}", i).as_bytes());
753        }
754
755        // Probe 5 000 distinct keys that were NOT inserted.
756        let mut false_positives = 0usize;
757        let total = 5_000usize;
758        for i in 0u32..total as u32 {
759            if filter.may_contain(format!("probe-{}", i).as_bytes()) {
760                false_positives += 1;
761            }
762        }
763        let fpr = false_positives as f64 / total as f64;
764        assert!(
765            fpr < 0.01,
766            "FPR {:.4} ≥ 1 % for 1 000 elements in 1 M-bit filter",
767            fpr
768        );
769    }
770
771    /// 5. clear() zeroes all bits and resets the element counter.
772    #[test]
773    fn test_clear_resets_filter() {
774        let filter = BloomFilter::new_with_bits(1_048_576);
775        filter.insert(b"key-a");
776        filter.insert(b"key-b");
777        assert!(filter.may_contain(b"key-a"));
778        assert_eq!(filter.len(), 2);
779
780        filter.clear();
781
782        assert_eq!(filter.len(), 0);
783        assert_eq!(filter.estimated_fill_ratio(), 0.0);
784        assert!(
785            !filter.may_contain(b"key-a"),
786            "key-a should be absent after clear"
787        );
788        assert!(
789            !filter.may_contain(b"key-b"),
790            "key-b should be absent after clear"
791        );
792    }
793
794    /// 6. estimated_fill_ratio grows monotonically with insertions.
795    #[test]
796    fn test_fill_ratio_grows_with_insertions() {
797        let filter = BloomFilter::new_with_bits(1_048_576);
798        let mut prev = filter.estimated_fill_ratio();
799
800        for i in 0u32..200 {
801            filter.insert(format!("grow-{}", i).as_bytes());
802            let current = filter.estimated_fill_ratio();
803            assert!(
804                current >= prev,
805                "fill_ratio decreased after insertion {} ({} < {})",
806                i,
807                current,
808                prev
809            );
810            prev = current;
811        }
812        assert!(prev > 0.0, "fill_ratio must be positive after insertions");
813    }
814
815    /// 7. bit_count() and len() accessors return consistent values.
816    #[test]
817    fn test_accessors_consistency() {
818        let filter = BloomFilter::new_with_bits(1_048_576);
819        assert_eq!(filter.bit_count(), 1_048_576);
820        assert_eq!(filter.len(), 0);
821
822        filter.insert(b"x");
823        assert_eq!(filter.len(), 1);
824    }
825
826    /// 8. CidBloomFilter – inserted CIDs are always found (zero false negatives).
827    #[test]
828    fn test_cid_bloom_zero_false_negatives() {
829        let cbf = CidBloomFilter::default_config();
830        let cids: Vec<String> = (0..300).map(|i| format!("Qm{:044}", i)).collect();
831
832        for cid in &cids {
833            cbf.insert_cid(cid);
834        }
835        for cid in &cids {
836            assert!(
837                cbf.may_contain_cid(cid),
838                "CidBloomFilter false negative for '{}'",
839                cid
840            );
841        }
842    }
843
844    /// 9. CidBloomFilter – absent CIDs are not found by default.
845    #[test]
846    fn test_cid_bloom_absent_cids() {
847        let cbf = CidBloomFilter::default_config();
848        assert!(!cbf.may_contain_cid("QmNeverInserted000000000000000000000000000000000"));
849    }
850
851    /// 10. CidBloomFilter::reset() clears the filter completely.
852    #[test]
853    fn test_cid_bloom_reset() {
854        let cbf = CidBloomFilter::default_config();
855        cbf.insert_cid("QmSomeTestCid0000000000000000000000000000000000");
856        assert!(cbf.may_contain_cid("QmSomeTestCid0000000000000000000000000000000000"));
857
858        cbf.reset();
859
860        assert!(
861            !cbf.may_contain_cid("QmSomeTestCid0000000000000000000000000000000000"),
862            "CID should be absent after reset"
863        );
864        let snap = cbf.snapshot();
865        assert_eq!(snap.fill_ratio, 0.0, "fill_ratio must be 0 after reset");
866    }
867
868    /// 11. BloomSnapshot reflects correct bit_count and fill_ratio direction.
869    #[test]
870    fn test_bloom_snapshot_fields() {
871        let cbf = CidBloomFilter::new(BloomFilterConfig {
872            bits: 1_048_576,
873            expected_elements: 100_000,
874        });
875
876        let snap_before = cbf.snapshot();
877        assert_eq!(snap_before.bit_count, 1_048_576);
878        assert_eq!(snap_before.fill_ratio, 0.0);
879
880        for i in 0u32..100 {
881            cbf.insert_cid(&format!("Qm{:044}", i));
882        }
883
884        let snap_after = cbf.snapshot();
885        assert!(
886            snap_after.fill_ratio > 0.0,
887            "fill_ratio must increase after insertions"
888        );
889        assert_eq!(snap_after.bit_count, 1_048_576);
890        assert!(
891            snap_after.estimated_elements > 0,
892            "estimated_elements must be positive after insertions"
893        );
894    }
895
896    /// 12. BloomFilterConfig default values are as specified.
897    #[test]
898    fn test_bloom_filter_config_defaults() {
899        let cfg = BloomFilterConfig::default();
900        assert_eq!(cfg.bits, 1_048_576, "default bits should be 1 048 576");
901        assert_eq!(
902            cfg.expected_elements, 100_000,
903            "default expected_elements should be 100 000"
904        );
905    }
906
907    /// 13. CidBloomFilter::snapshot() estimated_elements grows with insertions.
908    #[test]
909    fn test_snapshot_estimated_elements_grows() {
910        let cbf = CidBloomFilter::default_config();
911        let snap0 = cbf.snapshot();
912        assert_eq!(snap0.estimated_elements, 0);
913
914        for i in 0u32..500 {
915            cbf.insert_cid(&format!("Qm{:044}", i));
916        }
917        let snap1 = cbf.snapshot();
918        assert!(
919            snap1.estimated_elements > 0,
920            "estimated_elements should be > 0 after 500 insertions"
921        );
922    }
923
924    /// 14. BloomFilter::is_bloom_empty() reflects insertion state.
925    #[test]
926    fn test_is_bloom_empty() {
927        let f = BloomFilter::new_with_bits(1_048_576);
928        assert!(f.is_bloom_empty(), "freshly created filter must be empty");
929        f.insert(b"one");
930        assert!(
931            !f.is_bloom_empty(),
932            "filter must not be empty after one insertion"
933        );
934        f.clear();
935        assert!(f.is_bloom_empty(), "filter must be empty after clear");
936    }
937}