Skip to main content

ipfrs_storage/
deduplication_pipeline.rs

1//! Multi-stage block deduplication pipeline combining exact-match and content-based deduplication.
2//!
3//! The pipeline executes three configurable stages in order:
4//! 1. **ExactHash** — FNV-1a over the full block; identical bytes → same hash → duplicate.
5//! 2. **ChunkHash** — fixed-size CDC chunking followed by per-chunk FNV-1a hashing; detects
6//!    near-identical blocks that share a large portion of chunks.
7//! 3. **Similarity** — 64-bit SimHash fingerprint (FNV-1a on sliding byte windows) combined
8//!    with Hamming distance to detect semantically similar blocks.
9//!
10//! Each stage is tried in order; the first match short-circuits the remaining stages and records
11//! the originating block's CID together with the bytes saved.
12
13use std::collections::HashMap;
14
15// ─────────────────────────────────────────────────────────────────────────────
16// Public Types
17// ─────────────────────────────────────────────────────────────────────────────
18
19/// Identifies which deduplication stage detected a duplicate.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum DedupStage {
22    /// Full-block FNV-1a hash equality — byte-for-byte identical data.
23    ExactHash,
24    /// Per-chunk FNV-1a hash equality after fixed-size CDC splitting.
25    ChunkHash,
26    /// SimHash / MinHash Hamming-distance similarity.
27    Similarity,
28}
29
30/// Result produced by [`DeduplicationPipeline::process_block`].
31#[derive(Debug, Clone)]
32pub struct DedupResult {
33    /// Content Identifier of the block that was processed.
34    pub cid: String,
35    /// `true` when the block was identified as a duplicate of an existing entry.
36    pub is_duplicate: bool,
37    /// CID of the canonical (first-seen) block if this block is a duplicate.
38    pub duplicate_of: Option<String>,
39    /// Which pipeline stage detected the duplication.
40    pub stage_detected: Option<DedupStage>,
41    /// Bytes that can be reclaimed because this block need not be stored again.
42    pub space_saved: u64,
43}
44
45/// Runtime configuration for [`DeduplicationPipeline`].
46#[derive(Debug, Clone)]
47pub struct PipelineConfig {
48    /// Ordered list of stages to execute.
49    pub stages: Vec<DedupStage>,
50    /// Target (maximum) chunk size in bytes used by the `ChunkHash` stage.
51    pub chunk_size: usize,
52    /// Minimum chunk size in bytes; chunks smaller than this are merged into
53    /// the preceding chunk instead of being emitted as independent entries.
54    pub min_chunk_size: usize,
55    /// Hamming-distance threshold (expressed as similarity ∈ [0, 1]) for the
56    /// `Similarity` stage.  Two fingerprints are considered similar when
57    /// `1 − distance/bits ≥ similarity_threshold`.
58    pub similarity_threshold: f64,
59    /// Number of significant bits used in the SimHash computation.  Must be
60    /// ≤ 64 (clamped internally).
61    pub fingerprint_bits: u32,
62}
63
64impl Default for PipelineConfig {
65    fn default() -> Self {
66        Self {
67            stages: vec![
68                DedupStage::ExactHash,
69                DedupStage::ChunkHash,
70                DedupStage::Similarity,
71            ],
72            chunk_size: 4096,
73            min_chunk_size: 512,
74            similarity_threshold: 0.9,
75            fingerprint_bits: 64,
76        }
77    }
78}
79
80/// An indexed block entry stored inside the pipeline.
81#[derive(Debug, Clone)]
82pub struct BlockEntry {
83    /// Content Identifier assigned by the caller.
84    pub cid: String,
85    /// Raw block payload.
86    pub data: Vec<u8>,
87    /// FNV-1a hash over the complete payload.
88    pub full_hash: u64,
89    /// FNV-1a hashes of individual fixed-size chunks.
90    pub chunks: Vec<u64>,
91    /// 64-bit SimHash fingerprint of the payload.
92    pub fingerprint: u64,
93    /// Payload length in bytes.
94    pub size: u64,
95}
96
97/// Accumulated statistics for a running [`DeduplicationPipeline`].
98#[derive(Debug, Clone, Default)]
99pub struct DedupPipelineStats {
100    /// Total number of blocks submitted to the pipeline.
101    pub total_processed: u64,
102    /// Blocks identified as exact (byte-level) duplicates.
103    pub exact_duplicates: u64,
104    /// Blocks identified as chunk-level duplicates.
105    pub chunk_duplicates: u64,
106    /// Blocks identified as similarity duplicates.
107    pub similarity_duplicates: u64,
108    /// Cumulative bytes saved (not re-stored) across all duplicate detections.
109    pub bytes_saved: u64,
110    /// Blocks that passed through all stages and were stored as originals.
111    pub unique_blocks: u64,
112}
113
114// ─────────────────────────────────────────────────────────────────────────────
115// Pipeline
116// ─────────────────────────────────────────────────────────────────────────────
117
118/// Multi-stage block deduplication pipeline.
119///
120/// # Example
121/// ```rust
122/// use ipfrs_storage::deduplication_pipeline::{DeduplicationPipeline, PipelineConfig, DedupStage};
123///
124/// let config = PipelineConfig::default();
125/// let mut pipeline = DeduplicationPipeline::new(config);
126///
127/// let result = pipeline.process_block("cid-1", b"hello world");
128/// assert!(!result.is_duplicate);
129///
130/// let result2 = pipeline.process_block("cid-2", b"hello world");
131/// assert!(result2.is_duplicate);
132/// ```
133pub struct DeduplicationPipeline {
134    config: PipelineConfig,
135    /// full_hash → first-seen CID
136    index: HashMap<u64, String>,
137    /// chunk_hash → first-seen CID
138    chunk_index: HashMap<u64, String>,
139    /// (fingerprint, cid) pairs for similarity search
140    fingerprints: Vec<(u64, String)>,
141    stats: DedupPipelineStats,
142}
143
144impl DeduplicationPipeline {
145    // ─────────────────────────────────────────────────────────────────────────
146    // Construction
147    // ─────────────────────────────────────────────────────────────────────────
148
149    /// Create a new pipeline with the given [`PipelineConfig`].
150    pub fn new(config: PipelineConfig) -> Self {
151        Self {
152            config,
153            index: HashMap::new(),
154            chunk_index: HashMap::new(),
155            fingerprints: Vec::new(),
156            stats: DedupPipelineStats::default(),
157        }
158    }
159
160    // ─────────────────────────────────────────────────────────────────────────
161    // Core processing
162    // ─────────────────────────────────────────────────────────────────────────
163
164    /// Submit a block to the pipeline.
165    ///
166    /// The block is run through the configured stages in order.  The first
167    /// stage that reports a match short-circuits the remaining stages and
168    /// returns a [`DedupResult`] with `is_duplicate = true`.  If no stage
169    /// matches, the block is indexed and returned as unique.
170    pub fn process_block(&mut self, cid: &str, data: &[u8]) -> DedupResult {
171        self.stats.total_processed += 1;
172
173        let full_hash = Self::compute_full_hash(data);
174        let bits = self.config.fingerprint_bits.min(64);
175
176        // Run each configured stage in order.
177        for stage in self.config.stages.clone() {
178            match stage {
179                DedupStage::ExactHash => {
180                    if let Some(original_cid) = self.index.get(&full_hash).cloned() {
181                        let saved = data.len() as u64;
182                        self.stats.exact_duplicates += 1;
183                        self.stats.bytes_saved += saved;
184                        return DedupResult {
185                            cid: cid.to_string(),
186                            is_duplicate: true,
187                            duplicate_of: Some(original_cid),
188                            stage_detected: Some(DedupStage::ExactHash),
189                            space_saved: saved,
190                        };
191                    }
192                }
193
194                DedupStage::ChunkHash => {
195                    let chunks = Self::compute_chunks(
196                        data,
197                        self.config.chunk_size,
198                        self.config.min_chunk_size,
199                    );
200                    if let Some(original_cid) = self.find_chunk_duplicate(&chunks) {
201                        let saved = data.len() as u64;
202                        self.stats.chunk_duplicates += 1;
203                        self.stats.bytes_saved += saved;
204                        // Still index this block's chunks so future queries benefit.
205                        self.index_chunks(&chunks, cid);
206                        return DedupResult {
207                            cid: cid.to_string(),
208                            is_duplicate: true,
209                            duplicate_of: Some(original_cid),
210                            stage_detected: Some(DedupStage::ChunkHash),
211                            space_saved: saved,
212                        };
213                    }
214                    // Not a duplicate at this stage — index chunks for future lookups.
215                    self.index_chunks(&chunks, cid);
216                }
217
218                DedupStage::Similarity => {
219                    let fingerprint = Self::compute_simhash(data, bits);
220                    let threshold = self.config.similarity_threshold;
221                    if let Some(original_cid) = self
222                        .check_similarity(fingerprint, threshold)
223                        .map(str::to_string)
224                    {
225                        let saved = data.len() as u64;
226                        self.stats.similarity_duplicates += 1;
227                        self.stats.bytes_saved += saved;
228                        // Index the fingerprint even for duplicates for richer future queries.
229                        self.fingerprints.push((fingerprint, cid.to_string()));
230                        return DedupResult {
231                            cid: cid.to_string(),
232                            is_duplicate: true,
233                            duplicate_of: Some(original_cid),
234                            stage_detected: Some(DedupStage::Similarity),
235                            space_saved: saved,
236                        };
237                    }
238                    self.fingerprints.push((fingerprint, cid.to_string()));
239                }
240            }
241        }
242
243        // Block is unique — record in primary index.
244        self.index.insert(full_hash, cid.to_string());
245        self.stats.unique_blocks += 1;
246
247        DedupResult {
248            cid: cid.to_string(),
249            is_duplicate: false,
250            duplicate_of: None,
251            stage_detected: None,
252            space_saved: 0,
253        }
254    }
255
256    // ─────────────────────────────────────────────────────────────────────────
257    // Hashing primitives
258    // ─────────────────────────────────────────────────────────────────────────
259
260    /// Compute a 64-bit FNV-1a hash over the entire `data` slice.
261    pub fn compute_full_hash(data: &[u8]) -> u64 {
262        fnv1a_64(data)
263    }
264
265    /// Split `data` into fixed-size chunks and return the FNV-1a hash of each.
266    ///
267    /// Chunks smaller than `min_size` are merged into the preceding chunk.
268    /// If `data` is empty, an empty `Vec` is returned.
269    pub fn compute_chunks(data: &[u8], chunk_size: usize, min_size: usize) -> Vec<u64> {
270        if data.is_empty() {
271            return Vec::new();
272        }
273
274        // Guard against degenerate configurations.
275        let effective_chunk = chunk_size.max(1);
276        let effective_min = min_size.min(effective_chunk);
277
278        let mut hashes: Vec<u64> = Vec::new();
279        let mut offset = 0usize;
280
281        while offset < data.len() {
282            let remaining = data.len() - offset;
283
284            // If the remaining bytes are less than min_size, merge into the
285            // previous chunk (or emit as the first/only chunk).
286            if remaining < effective_min && !hashes.is_empty() {
287                // Re-hash last chunk + tail together.
288                let tail_start = offset.saturating_sub(effective_chunk);
289                let merged_hash = fnv1a_64(&data[tail_start..]);
290                if let Some(last) = hashes.last_mut() {
291                    *last = merged_hash;
292                }
293                break;
294            }
295
296            let end = (offset + effective_chunk).min(data.len());
297            hashes.push(fnv1a_64(&data[offset..end]));
298            offset = end;
299        }
300
301        hashes
302    }
303
304    /// Compute a `bits`-wide SimHash fingerprint of `data`.
305    ///
306    /// Uses FNV-1a on sliding 8-byte (or smaller near the end) windows to
307    /// build feature hashes, then accumulates a weighted bit-vector which is
308    /// thresholded into the final fingerprint.
309    pub fn compute_simhash(data: &[u8], bits: u32) -> u64 {
310        let bits = bits.min(64) as usize;
311        if data.is_empty() || bits == 0 {
312            return 0;
313        }
314
315        // bit_weights[i] accumulates the signed weight for bit position i.
316        let mut bit_weights = vec![0i64; bits];
317        let window = 8usize;
318
319        // Slide a window across `data`, computing FNV-1a for each window.
320        let num_windows = data.len().saturating_sub(window).saturating_add(1);
321        let actual_windows = num_windows.max(1);
322
323        for start in 0..actual_windows {
324            let end = (start + window).min(data.len());
325            let h = fnv1a_64(&data[start..end]);
326
327            // For each bit position, update the weight based on whether the
328            // corresponding bit in `h` is set.
329            for (bit, weight) in bit_weights.iter_mut().enumerate().take(bits) {
330                if (h >> bit) & 1 == 1 {
331                    *weight += 1;
332                } else {
333                    *weight -= 1;
334                }
335            }
336        }
337
338        // Build the fingerprint: bit i is 1 when weight[i] > 0.
339        let mut fingerprint = 0u64;
340        for (bit, &weight) in bit_weights.iter().enumerate().take(bits) {
341            if weight > 0 {
342                fingerprint |= 1u64 << bit;
343            }
344        }
345
346        fingerprint
347    }
348
349    /// Compute the Hamming distance between two 64-bit values (number of
350    /// differing bits via `popcount` of XOR).
351    #[inline]
352    pub fn hamming_distance(a: u64, b: u64) -> u32 {
353        (a ^ b).count_ones()
354    }
355
356    /// Derive a similarity score in [0, 1] from a Hamming distance.
357    ///
358    /// `similarity = 1 − distance / bits`
359    ///
360    /// Returns 0.0 when `bits == 0` to avoid division by zero.
361    #[inline]
362    pub fn similarity_from_hamming(distance: u32, bits: u32) -> f64 {
363        if bits == 0 {
364            return 0.0;
365        }
366        1.0 - (distance as f64 / bits as f64)
367    }
368
369    /// Search the fingerprint index for any stored fingerprint that has a
370    /// similarity ≥ `threshold` with `fingerprint`.
371    ///
372    /// Returns the CID of the first match found, or `None`.
373    pub fn check_similarity(&self, fingerprint: u64, threshold: f64) -> Option<&str> {
374        let bits = self.config.fingerprint_bits.min(64);
375        for (stored_fp, stored_cid) in &self.fingerprints {
376            let distance = Self::hamming_distance(fingerprint, *stored_fp);
377            let similarity = Self::similarity_from_hamming(distance, bits);
378            if similarity >= threshold {
379                return Some(stored_cid.as_str());
380            }
381        }
382        None
383    }
384
385    // ─────────────────────────────────────────────────────────────────────────
386    // Index management
387    // ─────────────────────────────────────────────────────────────────────────
388
389    /// Remove all index entries associated with `cid`.
390    ///
391    /// Returns `true` if at least one entry was removed.
392    pub fn remove_block(&mut self, cid: &str) -> bool {
393        let mut removed = false;
394
395        // Remove from primary (full-hash) index.
396        self.index.retain(|_, v| {
397            if v.as_str() == cid {
398                removed = true;
399                false
400            } else {
401                true
402            }
403        });
404
405        // Remove from chunk index.
406        self.chunk_index.retain(|_, v| {
407            if v.as_str() == cid {
408                removed = true;
409                false
410            } else {
411                true
412            }
413        });
414
415        // Remove from fingerprint list.
416        let before = self.fingerprints.len();
417        self.fingerprints.retain(|(_, c)| c.as_str() != cid);
418        if self.fingerprints.len() < before {
419            removed = true;
420        }
421
422        removed
423    }
424
425    /// Return the number of unique full-hash entries in the primary index.
426    pub fn index_size(&self) -> usize {
427        self.index.len()
428    }
429
430    /// Return a reference to the accumulated pipeline statistics.
431    pub fn stats(&self) -> &DedupPipelineStats {
432        &self.stats
433    }
434
435    // ─────────────────────────────────────────────────────────────────────────
436    // Internal helpers
437    // ─────────────────────────────────────────────────────────────────────────
438
439    /// Check whether any chunk hash in `chunks` already appears in the chunk
440    /// index, indicating a previously stored block shares that chunk.
441    fn find_chunk_duplicate(&self, chunks: &[u64]) -> Option<String> {
442        for &ch in chunks {
443            if let Some(original_cid) = self.chunk_index.get(&ch) {
444                return Some(original_cid.clone());
445            }
446        }
447        None
448    }
449
450    /// Insert all chunk hashes from `chunks` into the chunk index, mapping to
451    /// `cid`.  Existing mappings are not overwritten to preserve the canonical
452    /// (first-seen) CID.
453    fn index_chunks(&mut self, chunks: &[u64], cid: &str) {
454        for &ch in chunks {
455            self.chunk_index
456                .entry(ch)
457                .or_insert_with(|| cid.to_string());
458        }
459    }
460}
461
462// ─────────────────────────────────────────────────────────────────────────────
463// FNV-1a 64-bit implementation
464// ─────────────────────────────────────────────────────────────────────────────
465
466/// 64-bit FNV-1a hash of an arbitrary byte slice.
467#[inline]
468fn fnv1a_64(data: &[u8]) -> u64 {
469    const OFFSET_BASIS: u64 = 14695981039346656037;
470    const PRIME: u64 = 1099511628211;
471    let mut hash = OFFSET_BASIS;
472    for &byte in data {
473        hash ^= byte as u64;
474        hash = hash.wrapping_mul(PRIME);
475    }
476    hash
477}
478
479// ─────────────────────────────────────────────────────────────────────────────
480// Tests
481// ─────────────────────────────────────────────────────────────────────────────
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    // ── helpers ───────────────────────────────────────────────────────────────
488
489    fn default_pipeline() -> DeduplicationPipeline {
490        DeduplicationPipeline::new(PipelineConfig::default())
491    }
492
493    fn exact_only_pipeline() -> DeduplicationPipeline {
494        DeduplicationPipeline::new(PipelineConfig {
495            stages: vec![DedupStage::ExactHash],
496            ..PipelineConfig::default()
497        })
498    }
499
500    fn chunk_only_pipeline() -> DeduplicationPipeline {
501        DeduplicationPipeline::new(PipelineConfig {
502            stages: vec![DedupStage::ChunkHash],
503            ..PipelineConfig::default()
504        })
505    }
506
507    fn similarity_only_pipeline() -> DeduplicationPipeline {
508        DeduplicationPipeline::new(PipelineConfig {
509            stages: vec![DedupStage::Similarity],
510            similarity_threshold: 0.8,
511            ..PipelineConfig::default()
512        })
513    }
514
515    // ── 1. Unique block accepted ───────────────────────────────────────────────
516
517    #[test]
518    fn test_unique_block_accepted() {
519        let mut p = default_pipeline();
520        let r = p.process_block("cid-1", b"unique data block");
521        assert!(!r.is_duplicate);
522        assert!(r.duplicate_of.is_none());
523        assert!(r.stage_detected.is_none());
524        assert_eq!(r.space_saved, 0);
525        assert_eq!(r.cid, "cid-1");
526    }
527
528    // ── 2. Exact duplicate detected ───────────────────────────────────────────
529
530    #[test]
531    fn test_exact_duplicate_detected() {
532        let mut p = exact_only_pipeline();
533        p.process_block("cid-1", b"identical block data");
534        let r = p.process_block("cid-2", b"identical block data");
535        assert!(r.is_duplicate);
536        assert_eq!(r.duplicate_of.as_deref(), Some("cid-1"));
537        assert_eq!(r.stage_detected, Some(DedupStage::ExactHash));
538        assert_eq!(r.space_saved, b"identical block data".len() as u64);
539    }
540
541    // ── 3. Different blocks are not exact duplicates ──────────────────────────
542
543    #[test]
544    fn test_different_blocks_not_exact_duplicate() {
545        let mut p = exact_only_pipeline();
546        p.process_block("cid-1", b"block A");
547        let r = p.process_block("cid-2", b"block B");
548        assert!(!r.is_duplicate);
549    }
550
551    // ── 4. Three-way exact duplicate chain ────────────────────────────────────
552
553    #[test]
554    fn test_three_way_exact_duplicate() {
555        let mut p = exact_only_pipeline();
556        p.process_block("cid-1", b"shared data");
557        let r2 = p.process_block("cid-2", b"shared data");
558        let r3 = p.process_block("cid-3", b"shared data");
559        assert!(r2.is_duplicate);
560        assert!(r3.is_duplicate);
561        // Both duplicates point to the original.
562        assert_eq!(r2.duplicate_of.as_deref(), Some("cid-1"));
563        assert_eq!(r3.duplicate_of.as_deref(), Some("cid-1"));
564    }
565
566    // ── 5. Chunk duplicate detected ───────────────────────────────────────────
567
568    #[test]
569    fn test_chunk_duplicate_detected() {
570        let mut p = chunk_only_pipeline();
571        // Build a block that is larger than chunk_size (4096) so chunking happens.
572        let block: Vec<u8> = (0u8..=255).cycle().take(8192).collect();
573        p.process_block("cid-1", &block);
574        // Submit same block under a new CID.
575        let r = p.process_block("cid-2", &block);
576        assert!(r.is_duplicate);
577        assert_eq!(r.stage_detected, Some(DedupStage::ChunkHash));
578    }
579
580    // ── 6. Chunk stage: partial overlap triggers duplicate ────────────────────
581
582    #[test]
583    fn test_chunk_partial_overlap() {
584        let mut p = chunk_only_pipeline();
585        let block_a: Vec<u8> = vec![0xABu8; 8192];
586        p.process_block("cid-1", &block_a);
587
588        // Second block shares the first 4096 bytes exactly.
589        let mut block_b = block_a[..4096].to_vec();
590        block_b.extend_from_slice(&[0xCDu8; 4096]);
591        let r = p.process_block("cid-2", &block_b);
592        assert!(r.is_duplicate);
593        assert_eq!(r.stage_detected, Some(DedupStage::ChunkHash));
594        assert_eq!(r.duplicate_of.as_deref(), Some("cid-1"));
595    }
596
597    // ── 7. compute_full_hash is deterministic ─────────────────────────────────
598
599    #[test]
600    fn test_compute_full_hash_deterministic() {
601        let data = b"deterministic hash input";
602        let h1 = DeduplicationPipeline::compute_full_hash(data);
603        let h2 = DeduplicationPipeline::compute_full_hash(data);
604        assert_eq!(h1, h2);
605    }
606
607    // ── 8. compute_full_hash differs for different inputs ────────────────────
608
609    #[test]
610    fn test_compute_full_hash_differs() {
611        let h1 = DeduplicationPipeline::compute_full_hash(b"aaa");
612        let h2 = DeduplicationPipeline::compute_full_hash(b"bbb");
613        assert_ne!(h1, h2);
614    }
615
616    // ── 9. compute_full_hash empty slice ─────────────────────────────────────
617
618    #[test]
619    fn test_compute_full_hash_empty() {
620        // Should not panic.
621        let h = DeduplicationPipeline::compute_full_hash(b"");
622        // FNV-1a offset basis for empty input.
623        assert_eq!(h, 14695981039346656037u64);
624    }
625
626    // ── 10. compute_chunks basic ─────────────────────────────────────────────
627
628    #[test]
629    fn test_compute_chunks_basic() {
630        let data: Vec<u8> = (0u8..255).collect();
631        let chunks = DeduplicationPipeline::compute_chunks(&data, 64, 16);
632        // 255 bytes / 64 ≈ 3.98 → at most 4 chunks.
633        assert!(!chunks.is_empty());
634        assert!(chunks.len() <= 4);
635    }
636
637    // ── 11. compute_chunks empty input ───────────────────────────────────────
638
639    #[test]
640    fn test_compute_chunks_empty() {
641        let chunks = DeduplicationPipeline::compute_chunks(&[], 64, 16);
642        assert!(chunks.is_empty());
643    }
644
645    // ── 12. compute_chunks single chunk (data smaller than chunk_size) ────────
646
647    #[test]
648    fn test_compute_chunks_single_chunk() {
649        let data = vec![0x42u8; 100];
650        let chunks = DeduplicationPipeline::compute_chunks(&data, 4096, 512);
651        assert_eq!(chunks.len(), 1);
652    }
653
654    // ── 13. hamming_distance identical values ────────────────────────────────
655
656    #[test]
657    fn test_hamming_distance_identical() {
658        assert_eq!(
659            DeduplicationPipeline::hamming_distance(
660                0xDEAD_BEEF_CAFE_BABEu64,
661                0xDEAD_BEEF_CAFE_BABEu64
662            ),
663            0
664        );
665    }
666
667    // ── 14. hamming_distance all bits differ ─────────────────────────────────
668
669    #[test]
670    fn test_hamming_distance_all_bits() {
671        assert_eq!(DeduplicationPipeline::hamming_distance(0u64, u64::MAX), 64);
672    }
673
674    // ── 15. hamming_distance single bit ──────────────────────────────────────
675
676    #[test]
677    fn test_hamming_distance_single_bit() {
678        assert_eq!(DeduplicationPipeline::hamming_distance(0u64, 1u64), 1);
679        assert_eq!(DeduplicationPipeline::hamming_distance(0u64, 1u64 << 63), 1);
680    }
681
682    // ── 16. similarity_from_hamming exact values ──────────────────────────────
683
684    #[test]
685    fn test_similarity_from_hamming_exact() {
686        // 0 differing bits out of 64 → similarity 1.0.
687        let s = DeduplicationPipeline::similarity_from_hamming(0, 64);
688        assert!((s - 1.0).abs() < 1e-10);
689
690        // 64 differing bits out of 64 → similarity 0.0.
691        let s = DeduplicationPipeline::similarity_from_hamming(64, 64);
692        assert!((s - 0.0).abs() < 1e-10);
693
694        // 32 differing bits out of 64 → similarity 0.5.
695        let s = DeduplicationPipeline::similarity_from_hamming(32, 64);
696        assert!((s - 0.5).abs() < 1e-10);
697    }
698
699    // ── 17. similarity_from_hamming zero bits ─────────────────────────────────
700
701    #[test]
702    fn test_similarity_from_hamming_zero_bits() {
703        let s = DeduplicationPipeline::similarity_from_hamming(0, 0);
704        assert_eq!(s, 0.0);
705    }
706
707    // ── 18. compute_simhash deterministic ────────────────────────────────────
708
709    #[test]
710    fn test_compute_simhash_deterministic() {
711        let data = b"simhash test data";
712        let f1 = DeduplicationPipeline::compute_simhash(data, 64);
713        let f2 = DeduplicationPipeline::compute_simhash(data, 64);
714        assert_eq!(f1, f2);
715    }
716
717    // ── 19. compute_simhash empty input ──────────────────────────────────────
718
719    #[test]
720    fn test_compute_simhash_empty() {
721        // Should not panic and should return 0.
722        let f = DeduplicationPipeline::compute_simhash(b"", 64);
723        assert_eq!(f, 0);
724    }
725
726    // ── 20. Similarity duplicate detected ────────────────────────────────────
727
728    #[test]
729    fn test_similarity_duplicate_detected() {
730        let mut p = similarity_only_pipeline();
731        let base: Vec<u8> = (0u8..=200).cycle().take(500).collect();
732        p.process_block("cid-1", &base);
733
734        // Submit an identical block; Hamming distance = 0 → similarity = 1.0.
735        let r = p.process_block("cid-2", &base);
736        assert!(r.is_duplicate);
737        assert_eq!(r.stage_detected, Some(DedupStage::Similarity));
738        assert_eq!(r.duplicate_of.as_deref(), Some("cid-1"));
739    }
740
741    // ── 21. Similarity threshold boundary — below threshold not duplicate ─────
742
743    #[test]
744    fn test_similarity_threshold_below_not_duplicate() {
745        let mut p = DeduplicationPipeline::new(PipelineConfig {
746            stages: vec![DedupStage::Similarity],
747            // Very high threshold — only identical fingerprints qualify.
748            similarity_threshold: 1.0,
749            ..PipelineConfig::default()
750        });
751        let data_a = b"alpha alpha alpha alpha alpha alpha alpha alpha alpha alpha";
752        let data_b = b"beta beta beta beta beta beta beta beta beta beta beta beta";
753        p.process_block("cid-1", data_a);
754        let r = p.process_block("cid-2", data_b);
755        // Very different data almost certainly produces different fingerprints.
756        // Even if it somehow matches (unlikely), the test validates pipeline logic.
757        // We just verify the pipeline does not panic.
758        let _ = r.is_duplicate;
759    }
760
761    // ── 22. Multi-stage pipeline: exact takes precedence ─────────────────────
762
763    #[test]
764    fn test_multi_stage_exact_precedence() {
765        let mut p = default_pipeline();
766        let data = b"multi-stage block";
767        p.process_block("cid-1", data);
768        let r = p.process_block("cid-2", data);
769        // Exact match should be detected before chunk or similarity stages.
770        assert_eq!(r.stage_detected, Some(DedupStage::ExactHash));
771    }
772
773    // ── 23. Stats: total_processed tracked correctly ──────────────────────────
774
775    #[test]
776    fn test_stats_total_processed() {
777        let mut p = default_pipeline();
778        p.process_block("c1", b"a");
779        p.process_block("c2", b"b");
780        p.process_block("c3", b"c");
781        assert_eq!(p.stats().total_processed, 3);
782    }
783
784    // ── 24. Stats: exact_duplicates counted ───────────────────────────────────
785
786    #[test]
787    fn test_stats_exact_duplicates() {
788        let mut p = exact_only_pipeline();
789        p.process_block("c1", b"same");
790        p.process_block("c2", b"same");
791        p.process_block("c3", b"same");
792        assert_eq!(p.stats().exact_duplicates, 2);
793        assert_eq!(p.stats().unique_blocks, 1);
794    }
795
796    // ── 25. Stats: bytes_saved accumulates ────────────────────────────────────
797
798    #[test]
799    fn test_stats_bytes_saved() {
800        let data = b"12345678901234567890"; // 20 bytes
801        let mut p = exact_only_pipeline();
802        p.process_block("c1", data);
803        p.process_block("c2", data);
804        p.process_block("c3", data);
805        assert_eq!(p.stats().bytes_saved, 40); // 2 duplicates × 20 bytes each
806    }
807
808    // ── 26. Stats: unique_blocks counted ──────────────────────────────────────
809
810    #[test]
811    fn test_stats_unique_blocks() {
812        let mut p = exact_only_pipeline();
813        p.process_block("c1", b"alpha");
814        p.process_block("c2", b"beta");
815        p.process_block("c3", b"gamma");
816        assert_eq!(p.stats().unique_blocks, 3);
817        assert_eq!(p.stats().exact_duplicates, 0);
818    }
819
820    // ── 27. index_size reflects unique full-hash entries ─────────────────────
821
822    #[test]
823    fn test_index_size() {
824        let mut p = exact_only_pipeline();
825        assert_eq!(p.index_size(), 0);
826        p.process_block("c1", b"a");
827        p.process_block("c2", b"b");
828        assert_eq!(p.index_size(), 2);
829        // Duplicate should not grow the index.
830        p.process_block("c3", b"a");
831        assert_eq!(p.index_size(), 2);
832    }
833
834    // ── 28. remove_block removes from primary index ───────────────────────────
835
836    #[test]
837    fn test_remove_block_primary_index() {
838        let mut p = exact_only_pipeline();
839        p.process_block("c1", b"removable data");
840        assert_eq!(p.index_size(), 1);
841        let removed = p.remove_block("c1");
842        assert!(removed);
843        assert_eq!(p.index_size(), 0);
844    }
845
846    // ── 29. remove_block: non-existent CID returns false ─────────────────────
847
848    #[test]
849    fn test_remove_block_not_found() {
850        let mut p = default_pipeline();
851        p.process_block("c1", b"existing block");
852        let removed = p.remove_block("c-nonexistent");
853        assert!(!removed);
854    }
855
856    // ── 30. remove_block allows re-insertion after removal ───────────────────
857
858    #[test]
859    fn test_remove_block_allows_reinsertion() {
860        let mut p = exact_only_pipeline();
861        let data = b"reinsert me";
862        p.process_block("c1", data);
863        p.remove_block("c1");
864
865        // After removal the block should be treated as unique again.
866        let r = p.process_block("c2", data);
867        assert!(!r.is_duplicate);
868    }
869
870    // ── 31. All three stages enabled end-to-end ───────────────────────────────
871
872    #[test]
873    fn test_all_stages_enabled() {
874        let mut p = default_pipeline();
875
876        // First block — unique.
877        let r1 = p.process_block("cid-a", b"hello from stage one");
878        assert!(!r1.is_duplicate);
879
880        // Exact duplicate of the first block.
881        let r2 = p.process_block("cid-b", b"hello from stage one");
882        assert!(r2.is_duplicate);
883        assert_eq!(r2.stage_detected, Some(DedupStage::ExactHash));
884
885        // Distinct block — unique.
886        let r3 = p.process_block("cid-c", b"completely different data");
887        assert!(!r3.is_duplicate);
888
889        assert_eq!(p.stats().total_processed, 3);
890        assert_eq!(p.stats().unique_blocks, 2);
891        assert_eq!(p.stats().exact_duplicates, 1);
892    }
893
894    // ── 32. Chunk index populated for unique blocks ───────────────────────────
895
896    #[test]
897    fn test_chunk_index_populated() {
898        let mut p = chunk_only_pipeline();
899        let block: Vec<u8> = vec![0x77u8; 8192];
900        let r1 = p.process_block("c1", &block);
901        assert!(!r1.is_duplicate);
902        // Now submit the same block content under a different CID.
903        let r2 = p.process_block("c2", &block);
904        assert!(r2.is_duplicate);
905    }
906
907    // ── 33. Empty block handled without panic ─────────────────────────────────
908
909    #[test]
910    fn test_empty_block_no_panic() {
911        let mut p = default_pipeline();
912        let r = p.process_block("empty", b"");
913        // An empty block should be processable without panic.
914        assert_eq!(r.cid, "empty");
915    }
916
917    // ── 34. Stats: similarity_duplicates counted ──────────────────────────────
918
919    #[test]
920    fn test_stats_similarity_duplicates() {
921        let mut p = similarity_only_pipeline();
922        let data: Vec<u8> = (0u8..=127).cycle().take(256).collect();
923        p.process_block("c1", &data);
924        p.process_block("c2", &data); // identical → similarity = 1.0
925        assert_eq!(p.stats().similarity_duplicates, 1);
926    }
927
928    // ── 35. remove_block removes fingerprint entries ──────────────────────────
929
930    #[test]
931    fn test_remove_block_removes_fingerprint() {
932        let mut p = similarity_only_pipeline();
933        let data: Vec<u8> = (0u8..=200).cycle().take(400).collect();
934        p.process_block("c1", &data);
935        let removed = p.remove_block("c1");
936        assert!(removed);
937
938        // After removal, the same data should be treated as unique.
939        let r = p.process_block("c2", &data);
940        assert!(!r.is_duplicate);
941    }
942}