Skip to main content

ipfrs_network/
message_dedup.rs

1//! Message deduplication using a two-layer approach.
2//!
3//! Provides efficient duplicate detection for gossip/pubsub messages via:
4//! - **Layer 1 – Bloom filter**: Fast probabilistic rejection using a bit array
5//!   with Kirsch-Mitzenmacher double hashing (FNV-1a based).  If the bloom
6//!   filter reports "not seen", the message is *definitely* new.
7//! - **Layer 2 – Bounded LRU cache**: Exact deduplication backed by a
8//!   `HashMap<[u8;32], DedupEntry>` with an insertion-order `Vec` for LRU
9//!   eviction once capacity is reached.
10//!
11//! ## Usage
12//!
13//! ```rust
14//! use ipfrs_network::message_dedup::{MessageDeduplicator, DedupConfig, MsgId};
15//!
16//! let config = DedupConfig {
17//!     bloom_bits: 1 << 16,
18//!     bloom_hash_count: 7,
19//!     cache_capacity: 4096,
20//!     window_ms: 60_000,
21//! };
22//! let mut dedup = MessageDeduplicator::new(config);
23//!
24//! let id = MessageDeduplicator::make_msg_id(b"hello world");
25//! assert!(!dedup.check_and_insert(&id, 1_000));  // new
26//! assert!( dedup.check_and_insert(&id, 1_001));  // duplicate
27//! ```
28
29use std::collections::HashMap;
30
31// ── FNV-1a constants ──────────────────────────────────────────────────────────
32
33/// FNV-1a 64-bit offset basis.
34const FNV_OFFSET_BASIS_64: u64 = 14_695_981_039_346_656_037;
35/// FNV-1a 64-bit prime.
36const FNV_PRIME_64: u64 = 1_099_511_628_211;
37
38/// Compute FNV-1a 64-bit hash of `data`.
39#[inline]
40fn fnv1a_64(data: &[u8]) -> u64 {
41    let mut h = FNV_OFFSET_BASIS_64;
42    for &b in data {
43        h ^= b as u64;
44        h = h.wrapping_mul(FNV_PRIME_64);
45    }
46    h
47}
48
49/// Compute a seeded FNV-1a 64-bit hash by pre-mixing a seed into the state.
50#[inline]
51fn fnv1a_64_seeded(data: &[u8], seed: u64) -> u64 {
52    // Mix the seed into the initial state using the FNV prime, then hash data.
53    let mut h = FNV_OFFSET_BASIS_64 ^ seed;
54    h = h.wrapping_mul(FNV_PRIME_64);
55    for &b in data {
56        h ^= b as u64;
57        h = h.wrapping_mul(FNV_PRIME_64);
58    }
59    h
60}
61
62// ── MessageId ────────────────────────────────────────────────────────────────
63
64/// A 32-byte message fingerprint used for deduplication.
65///
66/// Constructed deterministically from message content via [`MessageDeduplicator::make_msg_id`].
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
68pub struct MsgId {
69    /// Raw 32-byte fingerprint.
70    pub bytes: [u8; 32],
71}
72
73impl MsgId {
74    /// Construct a `MsgId` directly from raw bytes.
75    #[inline]
76    pub fn from_bytes(bytes: [u8; 32]) -> Self {
77        Self { bytes }
78    }
79}
80
81// ── DedupConfig ───────────────────────────────────────────────────────────────
82
83/// Configuration for [`MessageDeduplicator`].
84#[derive(Debug, Clone)]
85pub struct DedupConfig {
86    /// Number of bits in the bloom filter bit-array.  Rounded up to the next
87    /// multiple of 64 internally.  Minimum: 64.
88    pub bloom_bits: usize,
89    /// Number of independent hash functions for the bloom filter.  More hashes
90    /// reduce false-positive rate at the cost of slightly more CPU per insert.
91    pub bloom_hash_count: u32,
92    /// Maximum number of entries held in the exact-dedup LRU cache.
93    pub cache_capacity: usize,
94    /// Deduplication window in milliseconds.  Entries older than this are
95    /// eligible for expiry via [`MessageDeduplicator::purge_expired`].
96    pub window_ms: u64,
97}
98
99impl Default for DedupConfig {
100    fn default() -> Self {
101        Self {
102            bloom_bits: 1 << 17, // 128 Ki bits ≈ 16 KiB
103            bloom_hash_count: 7,
104            cache_capacity: 8_192,
105            window_ms: 120_000, // 2 minutes
106        }
107    }
108}
109
110// ── DedupEntry ────────────────────────────────────────────────────────────────
111
112/// A single entry in the exact-dedup LRU cache.
113#[derive(Debug, Clone)]
114pub struct DedupEntry {
115    /// The message identifier.
116    pub msg_id: MsgId,
117    /// Timestamp (ms) when the message was first seen.
118    pub first_seen: u64,
119    /// Number of duplicate sightings *after* the first (i.e. 0 means seen once).
120    pub count: u32,
121}
122
123// ── MsgDedupStats ─────────────────────────────────────────────────────────────
124
125/// Accumulated statistics for a [`MessageDeduplicator`].
126#[derive(Debug, Clone, Default)]
127pub struct MsgDedupStats {
128    /// Total messages processed (unique + duplicate).
129    pub total_seen: u64,
130    /// Messages identified as duplicates.
131    pub duplicates: u64,
132    /// Messages accepted as unique.
133    pub unique: u64,
134    /// Cases where the bloom filter reported "seen" but the cache said "new"
135    /// (i.e. bloom false-positives caught by the exact cache layer).
136    pub bloom_false_positives: u64,
137    /// Number of cache entries evicted due to LRU capacity enforcement.
138    pub cache_evictions: u64,
139}
140
141// ── MessageDeduplicator ───────────────────────────────────────────────────────
142
143/// Two-layer message deduplicator: Bloom filter + bounded LRU cache.
144///
145/// See [module documentation](crate::message_dedup) for usage.
146pub struct MessageDeduplicator {
147    config: DedupConfig,
148    /// Bloom filter bit-array stored as 64-bit words.
149    bloom: Vec<u64>,
150    /// Exact-dedup map: msg bytes → entry.
151    cache: HashMap<[u8; 32], DedupEntry>,
152    /// Tracks insertion order for LRU eviction (oldest at index 0).
153    insertion_order: Vec<[u8; 32]>,
154    /// Accumulated statistics.
155    stats: MsgDedupStats,
156}
157
158impl MessageDeduplicator {
159    // ── Construction ────────────────────────────────────────────────────────
160
161    /// Create a new deduplicator with the given configuration.
162    pub fn new(config: DedupConfig) -> Self {
163        // Round bloom_bits up to a multiple of 64, ensure at least 64 bits.
164        let bloom_bits = config.bloom_bits.max(64);
165        let words = bloom_bits.div_ceil(64);
166
167        let cache_capacity = config.cache_capacity.max(1);
168        Self {
169            bloom: vec![0u64; words],
170            cache: HashMap::with_capacity(cache_capacity),
171            insertion_order: Vec::with_capacity(cache_capacity),
172            stats: MsgDedupStats::default(),
173            config: DedupConfig {
174                bloom_bits: words * 64,
175                cache_capacity,
176                ..config
177            },
178        }
179    }
180
181    // ── Public API ──────────────────────────────────────────────────────────
182
183    /// Check whether `msg_id` has been seen before and, if not, record it.
184    ///
185    /// Returns `true` if the message is a **duplicate** (was already seen),
186    /// `false` if it is **new** (first occurrence).
187    ///
188    /// `now` is the caller-supplied timestamp in milliseconds (e.g. from
189    /// `std::time::SystemTime` or a monotonic clock).
190    pub fn check_and_insert(&mut self, msg_id: &MsgId, now: u64) -> bool {
191        self.stats.total_seen += 1;
192
193        // Layer 1: bloom fast path.
194        let bloom_says_seen = self.is_duplicate_bloom(msg_id);
195
196        if bloom_says_seen {
197            // Layer 2: exact cache check.
198            if let Some(entry) = self.cache.get_mut(&msg_id.bytes) {
199                // Confirmed duplicate.
200                entry.count = entry.count.saturating_add(1);
201                self.stats.duplicates += 1;
202                return true;
203            }
204            // Bloom false positive — fall through to insert as new.
205            self.stats.bloom_false_positives += 1;
206        }
207
208        // New message: insert into bloom and cache.
209        self.insert_bloom(msg_id);
210        self.insert_cache(msg_id, now);
211        self.stats.unique += 1;
212        false
213    }
214
215    /// Check the bloom filter alone (probabilistic — may have false positives).
216    ///
217    /// Returns `true` if the bloom filter believes `msg_id` was seen before.
218    pub fn is_duplicate_bloom(&self, msg_id: &MsgId) -> bool {
219        let total_bits = self.bloom.len() * 64;
220        if total_bits == 0 {
221            return false;
222        }
223        for pos in Self::bloom_bit_positions(msg_id, self.config.bloom_hash_count) {
224            let bit_idx = pos % total_bits;
225            let word = bit_idx / 64;
226            let bit = bit_idx % 64;
227            if self.bloom[word] & (1u64 << bit) == 0 {
228                return false;
229            }
230        }
231        true
232    }
233
234    /// Set the bloom filter bits for `msg_id`.
235    pub fn insert_bloom(&mut self, msg_id: &MsgId) {
236        let total_bits = self.bloom.len() * 64;
237        if total_bits == 0 {
238            return;
239        }
240        for pos in Self::bloom_bit_positions(msg_id, self.config.bloom_hash_count) {
241            let bit_idx = pos % total_bits;
242            let word = bit_idx / 64;
243            let bit = bit_idx % 64;
244            self.bloom[word] |= 1u64 << bit;
245        }
246    }
247
248    /// Compute `count` bloom bit positions for `msg_id` using
249    /// Kirsch-Mitzenmacher double hashing over FNV-1a.
250    ///
251    /// Uses two independent FNV-1a 64-bit hashes (`h1`, `h2`) and derives
252    /// positions as `(h1 + i * h2) mod usize::MAX`.  The modulo against the
253    /// actual bit-array size is applied by the caller.
254    pub fn bloom_bit_positions(msg_id: &MsgId, count: u32) -> Vec<usize> {
255        let h1 = fnv1a_64(&msg_id.bytes);
256        // Use a different seed for h2 to get an independent hash.
257        let h2 = fnv1a_64_seeded(&msg_id.bytes, 0xDEAD_BEEF_CAFE_BABE_u64);
258        // Ensure h2 is odd so it covers all positions in power-of-two arrays.
259        let h2 = h2 | 1;
260
261        (0..count as u64)
262            .map(|i| h1.wrapping_add(i.wrapping_mul(h2)) as usize)
263            .collect()
264    }
265
266    /// Construct a [`MsgId`] from arbitrary data.
267    ///
268    /// Produces a 32-byte fingerprint using four independent FNV-1a 64-bit
269    /// hashes (seeded with 0, 1, 2, 3) concatenated into a `[u8; 32]`.
270    /// This is deterministic: the same `data` always yields the same [`MsgId`].
271    pub fn make_msg_id(data: &[u8]) -> MsgId {
272        let h0 = fnv1a_64(data);
273        let h1 = fnv1a_64_seeded(data, 1);
274        let h2 = fnv1a_64_seeded(data, 2);
275        let h3 = fnv1a_64_seeded(data, 3);
276
277        let mut bytes = [0u8; 32];
278        bytes[0..8].copy_from_slice(&h0.to_le_bytes());
279        bytes[8..16].copy_from_slice(&h1.to_le_bytes());
280        bytes[16..24].copy_from_slice(&h2.to_le_bytes());
281        bytes[24..32].copy_from_slice(&h3.to_le_bytes());
282        MsgId { bytes }
283    }
284
285    /// Remove all cache entries whose `first_seen` timestamp is older than
286    /// `now - window_ms`.  Returns the number of entries purged.
287    ///
288    /// Note: the bloom filter is **not** cleared by this call (bloom filters
289    /// do not support deletion).  Only the exact cache is pruned.
290    pub fn purge_expired(&mut self, now: u64) -> usize {
291        let cutoff = now.saturating_sub(self.config.window_ms);
292        let before = self.cache.len();
293
294        // Collect expired keys.
295        let expired: Vec<[u8; 32]> = self
296            .cache
297            .iter()
298            .filter(|(_, e)| e.first_seen < cutoff)
299            .map(|(k, _)| *k)
300            .collect();
301
302        for key in &expired {
303            self.cache.remove(key);
304            // Remove from insertion_order as well.
305            if let Some(pos) = self.insertion_order.iter().position(|k| k == key) {
306                self.insertion_order.remove(pos);
307            }
308        }
309
310        before - self.cache.len()
311    }
312
313    /// Evict the oldest (least recently inserted) entry from the cache.
314    ///
315    /// Returns `true` if an entry was evicted, `false` if the cache was empty.
316    pub fn evict_oldest(&mut self) -> bool {
317        if let Some(oldest) = self.insertion_order.first().copied() {
318            self.insertion_order.remove(0);
319            self.cache.remove(&oldest);
320            self.stats.cache_evictions += 1;
321            true
322        } else {
323            false
324        }
325    }
326
327    /// Return the number of unique entries currently in the exact cache.
328    pub fn seen_count(&self) -> usize {
329        self.cache.len()
330    }
331
332    /// Return a reference to the accumulated statistics.
333    pub fn stats(&self) -> &MsgDedupStats {
334        &self.stats
335    }
336
337    /// Reset the deduplicator to a clean initial state (clears bloom, cache,
338    /// insertion order, and stats).  Configuration is preserved.
339    pub fn reset(&mut self) {
340        for word in &mut self.bloom {
341            *word = 0;
342        }
343        self.cache.clear();
344        self.insertion_order.clear();
345        self.stats = MsgDedupStats::default();
346    }
347
348    // ── Private helpers ─────────────────────────────────────────────────────
349
350    /// Insert a new entry into the exact cache, enforcing capacity via LRU
351    /// eviction when necessary.
352    fn insert_cache(&mut self, msg_id: &MsgId, now: u64) {
353        // Evict until we have room.
354        while self.cache.len() >= self.config.cache_capacity {
355            self.evict_oldest();
356        }
357        let entry = DedupEntry {
358            msg_id: msg_id.clone(),
359            first_seen: now,
360            count: 0,
361        };
362        self.cache.insert(msg_id.bytes, entry);
363        self.insertion_order.push(msg_id.bytes);
364    }
365}
366
367// ── Tests ─────────────────────────────────────────────────────────────────────
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn default_config() -> DedupConfig {
374        DedupConfig {
375            bloom_bits: 1 << 14, // 16 Ki bits
376            bloom_hash_count: 7,
377            cache_capacity: 128,
378            window_ms: 60_000,
379        }
380    }
381
382    fn make_dedup() -> MessageDeduplicator {
383        MessageDeduplicator::new(default_config())
384    }
385
386    // ── 1. New message returns false ─────────────────────────────────────────
387    #[test]
388    fn test_new_message_returns_false() {
389        let mut dedup = make_dedup();
390        let id = MessageDeduplicator::make_msg_id(b"hello");
391        assert!(!dedup.check_and_insert(&id, 1_000));
392    }
393
394    // ── 2. Duplicate returns true ────────────────────────────────────────────
395    #[test]
396    fn test_duplicate_returns_true() {
397        let mut dedup = make_dedup();
398        let id = MessageDeduplicator::make_msg_id(b"hello");
399        assert!(!dedup.check_and_insert(&id, 1_000));
400        assert!(dedup.check_and_insert(&id, 1_001));
401    }
402
403    // ── 3. Multiple duplicates all return true ───────────────────────────────
404    #[test]
405    fn test_multiple_duplicates() {
406        let mut dedup = make_dedup();
407        let id = MessageDeduplicator::make_msg_id(b"repeat");
408        assert!(!dedup.check_and_insert(&id, 100));
409        for i in 1..10u64 {
410            assert!(dedup.check_and_insert(&id, 100 + i));
411        }
412    }
413
414    // ── 4. Bloom fast path: after insert bloom_says_seen ────────────────────
415    #[test]
416    fn test_bloom_fast_path() {
417        let mut dedup = make_dedup();
418        let id = MessageDeduplicator::make_msg_id(b"bloom_test");
419        assert!(!dedup.is_duplicate_bloom(&id));
420        dedup.insert_bloom(&id);
421        assert!(dedup.is_duplicate_bloom(&id));
422    }
423
424    // ── 5. Bloom positions are deterministic ────────────────────────────────
425    #[test]
426    fn test_bloom_positions_deterministic() {
427        let id = MessageDeduplicator::make_msg_id(b"positions");
428        let p1 = MessageDeduplicator::bloom_bit_positions(&id, 7);
429        let p2 = MessageDeduplicator::bloom_bit_positions(&id, 7);
430        assert_eq!(p1, p2);
431    }
432
433    // ── 6. Bloom positions count matches hash_count ──────────────────────────
434    #[test]
435    fn test_bloom_positions_count() {
436        let id = MessageDeduplicator::make_msg_id(b"count_test");
437        for k in [1u32, 3, 7, 10, 20] {
438            let positions = MessageDeduplicator::bloom_bit_positions(&id, k);
439            assert_eq!(positions.len(), k as usize);
440        }
441    }
442
443    // ── 7. make_msg_id is deterministic ─────────────────────────────────────
444    #[test]
445    fn test_make_msg_id_deterministic() {
446        let id1 = MessageDeduplicator::make_msg_id(b"data");
447        let id2 = MessageDeduplicator::make_msg_id(b"data");
448        assert_eq!(id1, id2);
449    }
450
451    // ── 8. Identical data → same MsgId ───────────────────────────────────────
452    #[test]
453    fn test_identical_data_same_id() {
454        let data = b"same content here";
455        let id1 = MessageDeduplicator::make_msg_id(data);
456        let id2 = MessageDeduplicator::make_msg_id(data);
457        assert_eq!(id1.bytes, id2.bytes);
458    }
459
460    // ── 9. Different data → different MsgId ──────────────────────────────────
461    #[test]
462    fn test_different_data_different_id() {
463        let id1 = MessageDeduplicator::make_msg_id(b"message A");
464        let id2 = MessageDeduplicator::make_msg_id(b"message B");
465        assert_ne!(id1.bytes, id2.bytes);
466    }
467
468    // ── 10. Empty data produces a valid, non-zero MsgId ──────────────────────
469    #[test]
470    fn test_empty_data_msg_id() {
471        let id = MessageDeduplicator::make_msg_id(b"");
472        // All-zero would be suspicious; FNV offset basis guarantees non-zero.
473        assert_ne!(id.bytes, [0u8; 32]);
474    }
475
476    // ── 11. Stats: total_seen / unique / duplicates ───────────────────────────
477    #[test]
478    fn test_stats_accuracy() {
479        let mut dedup = make_dedup();
480        let id_a = MessageDeduplicator::make_msg_id(b"A");
481        let id_b = MessageDeduplicator::make_msg_id(b"B");
482
483        dedup.check_and_insert(&id_a, 1);
484        dedup.check_and_insert(&id_a, 2); // dup
485        dedup.check_and_insert(&id_b, 3);
486
487        let s = dedup.stats();
488        assert_eq!(s.total_seen, 3);
489        assert_eq!(s.unique, 2);
490        assert_eq!(s.duplicates, 1);
491    }
492
493    // ── 12. seen_count reflects unique inserts ────────────────────────────────
494    #[test]
495    fn test_seen_count() {
496        let mut dedup = make_dedup();
497        assert_eq!(dedup.seen_count(), 0);
498        let id = MessageDeduplicator::make_msg_id(b"x");
499        dedup.check_and_insert(&id, 0);
500        assert_eq!(dedup.seen_count(), 1);
501    }
502
503    // ── 13. LRU eviction: capacity enforcement ────────────────────────────────
504    #[test]
505    fn test_lru_capacity_enforcement() {
506        let config = DedupConfig {
507            bloom_bits: 1 << 16,
508            bloom_hash_count: 7,
509            cache_capacity: 5,
510            window_ms: 60_000,
511        };
512        let mut dedup = MessageDeduplicator::new(config);
513
514        for i in 0u64..10 {
515            let id = MessageDeduplicator::make_msg_id(&i.to_le_bytes());
516            dedup.check_and_insert(&id, i);
517        }
518        // Cache must never exceed capacity.
519        assert!(dedup.seen_count() <= 5);
520    }
521
522    // ── 14. LRU eviction increments cache_evictions stat ─────────────────────
523    #[test]
524    fn test_lru_eviction_stats() {
525        let config = DedupConfig {
526            bloom_bits: 1 << 16,
527            bloom_hash_count: 7,
528            cache_capacity: 3,
529            window_ms: 60_000,
530        };
531        let mut dedup = MessageDeduplicator::new(config);
532
533        for i in 0u64..6 {
534            let id = MessageDeduplicator::make_msg_id(&i.to_le_bytes());
535            dedup.check_and_insert(&id, i);
536        }
537        // We inserted 6 into a capacity-3 cache → at least 3 evictions.
538        assert!(dedup.stats().cache_evictions >= 3);
539    }
540
541    // ── 15. evict_oldest removes the oldest entry ────────────────────────────
542    #[test]
543    fn test_evict_oldest() {
544        let mut dedup = make_dedup();
545        let id0 = MessageDeduplicator::make_msg_id(b"first");
546        let id1 = MessageDeduplicator::make_msg_id(b"second");
547        dedup.check_and_insert(&id0, 0);
548        dedup.check_and_insert(&id1, 1);
549        assert_eq!(dedup.seen_count(), 2);
550        let evicted = dedup.evict_oldest();
551        assert!(evicted);
552        assert_eq!(dedup.seen_count(), 1);
553    }
554
555    // ── 16. evict_oldest on empty cache returns false ─────────────────────────
556    #[test]
557    fn test_evict_oldest_empty() {
558        let mut dedup = make_dedup();
559        assert!(!dedup.evict_oldest());
560    }
561
562    // ── 17. purge_expired removes entries older than window ──────────────────
563    #[test]
564    fn test_purge_expired() {
565        let mut dedup = make_dedup(); // window_ms = 60_000
566        let id_old = MessageDeduplicator::make_msg_id(b"old");
567        let id_new = MessageDeduplicator::make_msg_id(b"new");
568
569        // Insert old entry at t=0, new entry at t=100_000.
570        dedup.check_and_insert(&id_old, 0);
571        dedup.check_and_insert(&id_new, 100_000);
572
573        let now = 120_001; // window end: 120_001 - 60_000 = 60_001 → old (0) expired
574        let purged = dedup.purge_expired(now);
575        assert_eq!(purged, 1);
576        assert_eq!(dedup.seen_count(), 1);
577    }
578
579    // ── 18. purge_expired keeps entries within window ────────────────────────
580    #[test]
581    fn test_purge_expired_keeps_recent() {
582        let mut dedup = make_dedup(); // window_ms = 60_000
583        let id = MessageDeduplicator::make_msg_id(b"recent");
584        dedup.check_and_insert(&id, 1_000);
585        let purged = dedup.purge_expired(30_000); // only 29 s elapsed
586        assert_eq!(purged, 0);
587        assert_eq!(dedup.seen_count(), 1);
588    }
589
590    // ── 19. purge_expired on empty cache returns 0 ───────────────────────────
591    #[test]
592    fn test_purge_expired_empty() {
593        let mut dedup = make_dedup();
594        assert_eq!(dedup.purge_expired(999_999), 0);
595    }
596
597    // ── 20. reset clears bloom, cache, and stats ─────────────────────────────
598    #[test]
599    fn test_reset_clears_state() {
600        let mut dedup = make_dedup();
601        let id = MessageDeduplicator::make_msg_id(b"pre-reset");
602        dedup.check_and_insert(&id, 0);
603        dedup.check_and_insert(&id, 1); // dup
604
605        dedup.reset();
606
607        assert_eq!(dedup.seen_count(), 0);
608        let s = dedup.stats();
609        assert_eq!(s.total_seen, 0);
610        assert_eq!(s.duplicates, 0);
611        assert_eq!(s.unique, 0);
612
613        // After reset the bloom is clear, so the same message should be new.
614        assert!(!dedup.check_and_insert(&id, 2));
615    }
616
617    // ── 21. reset resets bloom (false positive would reoccur without reset) ──
618    #[test]
619    fn test_reset_clears_bloom() {
620        let mut dedup = make_dedup();
621        let id = MessageDeduplicator::make_msg_id(b"bloom_reset");
622        dedup.insert_bloom(&id);
623        assert!(dedup.is_duplicate_bloom(&id));
624        dedup.reset();
625        assert!(!dedup.is_duplicate_bloom(&id));
626    }
627
628    // ── 22. Bloom false positive tracking ────────────────────────────────────
629    #[test]
630    fn test_bloom_false_positive_tracking() {
631        // Use a tiny bloom (64 bits, many hashes) to force false positives.
632        let config = DedupConfig {
633            bloom_bits: 64,
634            bloom_hash_count: 20,
635            cache_capacity: 1024,
636            window_ms: 60_000,
637        };
638        let mut dedup = MessageDeduplicator::new(config);
639
640        // Fill the bloom heavily.
641        for i in 0u64..50 {
642            let id = MessageDeduplicator::make_msg_id(&i.to_le_bytes());
643            dedup.check_and_insert(&id, i);
644        }
645        // With a 64-bit bloom and 20 hashes, virtually every bit is set →
646        // any new key will hit a bloom false positive.
647        let id_new = MessageDeduplicator::make_msg_id(b"definitely_new_unique_key_9999");
648        dedup.check_and_insert(&id_new, 9999);
649
650        // bloom_false_positives may be > 0 in this degenerate setup.
651        // We just ensure it doesn't panic and the stat is accessible.
652        let _ = dedup.stats().bloom_false_positives;
653    }
654
655    // ── 23. Large volume: 1000 unique messages ───────────────────────────────
656    #[test]
657    fn test_large_volume_unique() {
658        let config = DedupConfig {
659            bloom_bits: 1 << 17,
660            bloom_hash_count: 7,
661            cache_capacity: 2048,
662            window_ms: 60_000,
663        };
664        let mut dedup = MessageDeduplicator::new(config);
665
666        for i in 0u64..1000 {
667            let data = format!("message-{}", i);
668            let id = MessageDeduplicator::make_msg_id(data.as_bytes());
669            assert!(!dedup.check_and_insert(&id, i));
670        }
671        assert_eq!(dedup.stats().total_seen, 1000);
672        // unique + bloom_false_positives == total_seen since all were new.
673        // (A bloom false-positive still counts as unique from the dedup perspective)
674        assert_eq!(dedup.stats().unique, 1000 - dedup.stats().duplicates);
675    }
676
677    // ── 24. Large volume: 1000 duplicate messages ────────────────────────────
678    #[test]
679    fn test_large_volume_duplicates() {
680        let mut dedup = make_dedup();
681        let id = MessageDeduplicator::make_msg_id(b"same_msg");
682        assert!(!dedup.check_and_insert(&id, 0));
683        for i in 1u64..1000 {
684            assert!(dedup.check_and_insert(&id, i));
685        }
686        assert_eq!(dedup.stats().duplicates, 999);
687    }
688
689    // ── 25. DedupEntry count increments on repeated duplicates ───────────────
690    #[test]
691    fn test_dedup_entry_count() {
692        let mut dedup = make_dedup();
693        let id = MessageDeduplicator::make_msg_id(b"counted");
694        dedup.check_and_insert(&id, 0);
695        dedup.check_and_insert(&id, 1);
696        dedup.check_and_insert(&id, 2);
697        // The entry.count should be 2 (two extra sightings).
698        let entry = dedup.cache.get(&id.bytes).cloned();
699        assert!(entry.is_some());
700        let e = entry.unwrap_or_else(|| DedupEntry {
701            msg_id: id.clone(),
702            first_seen: 0,
703            count: 0,
704        });
705        assert_eq!(e.count, 2);
706    }
707
708    // ── 26. Distinct messages each tracked independently ─────────────────────
709    #[test]
710    fn test_distinct_messages_independent() {
711        let mut dedup = make_dedup();
712        for i in 0u64..20 {
713            let id = MessageDeduplicator::make_msg_id(&i.to_le_bytes());
714            assert!(!dedup.check_and_insert(&id, i * 100));
715        }
716        // Re-check each one should be a duplicate now.
717        for i in 0u64..20 {
718            let id = MessageDeduplicator::make_msg_id(&i.to_le_bytes());
719            // After LRU may have evicted some (capacity=128 >> 20, so all present).
720            assert!(dedup.check_and_insert(&id, i * 100 + 50));
721        }
722    }
723
724    // ── 27. from_bytes round-trip ─────────────────────────────────────────────
725    #[test]
726    fn test_msg_id_from_bytes_roundtrip() {
727        let original = [42u8; 32];
728        let id = MsgId::from_bytes(original);
729        assert_eq!(id.bytes, original);
730    }
731
732    // ── 28. Config defaults are valid ────────────────────────────────────────
733    #[test]
734    fn test_default_config_valid() {
735        let config = DedupConfig::default();
736        let dedup = MessageDeduplicator::new(config);
737        assert!(!dedup.bloom.is_empty());
738        assert!(dedup.config.cache_capacity > 0);
739    }
740}