Skip to main content

fxcp_core/
chunk_delta.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4//! Content-defined chunk diff algorithm.
5
6use std::collections::HashSet;
7
8/// A reference to a content-addressed chunk with its size.
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub struct ChunkRef {
11    /// BLAKE3 hex hash of the chunk content (content-addressed identity).
12    pub hash: String,
13    /// Uncompressed size of the chunk in bytes.
14    pub size: u64,
15}
16
17/// Result of diffing two ordered chunk lists.
18///
19/// Efficiency semantics:
20/// - `0.0` when `new` is empty (no chunks = no efficiency to measure).
21/// - `1.0` when all new chunks already exist in old (perfect dedup).
22#[derive(Debug, Clone)]
23pub struct ChunkDelta {
24    /// Chunks in `new` not present in `old`  --  must be uploaded.
25    pub added: Vec<ChunkRef>,
26    /// Chunks in `old` not present in `new`  --  become orphans (handled by compact()).
27    pub removed: Vec<ChunkRef>,
28    /// Number of chunks shared between old and new (reused, no upload needed).
29    pub unchanged: usize,
30    /// Fraction of new chunks that are reused: `unchanged / new.len()`.
31    /// 1.0 = no change. 0.0 = all new (or empty).
32    pub efficiency: f64,
33    /// If true, >80% of old chunks changed  --  full re-upload is more efficient than delta.
34    pub full_upload_recommended: bool,
35}
36
37/// Compute the delta between two ordered chunk lists.
38///
39/// Uses content-addressed identity: same BLAKE3 hash = same content = no upload needed.
40/// GearChunker content-defined boundaries ensure most chunks survive edits, so diffs
41/// are typically sparse.
42///
43/// # Arguments
44/// * `old` - Chunk list from the existing stored version (from manifest).
45/// * `new` - Chunk list from the new version (freshly chunked).
46///
47/// # Returns
48/// [`ChunkDelta`] with added/removed/unchanged counts and efficiency metric.
49pub fn compute_chunk_delta(old: &[ChunkRef], new: &[ChunkRef]) -> ChunkDelta {
50    let old_set: HashSet<&str> = old.iter().map(|c| c.hash.as_str()).collect();
51    let new_set: HashSet<&str> = new.iter().map(|c| c.hash.as_str()).collect();
52
53    let added: Vec<ChunkRef> = new.iter()
54        .filter(|c| !old_set.contains(c.hash.as_str()))
55        .cloned()
56        .collect();
57
58    let removed: Vec<ChunkRef> = old.iter()
59        .filter(|c| !new_set.contains(c.hash.as_str()))
60        .cloned()
61        .collect();
62
63    let unchanged = new.len().saturating_sub(added.len());
64
65    let efficiency = if new.is_empty() {
66        0.0
67    } else {
68        unchanged as f64 / new.len() as f64
69    };
70
71    let full_upload_recommended = if old.is_empty() {
72        false
73    } else {
74        removed.len() as f64 / old.len() as f64 > 0.80
75    };
76
77    ChunkDelta { added, removed, unchanged, efficiency, full_upload_recommended }
78}
79
80#[cfg(test)]
81mod tests {
82    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
83    use super::*;
84
85    fn chunk(hash: &str, size: u64) -> ChunkRef {
86        ChunkRef { hash: hash.to_string(), size }
87    }
88
89    #[test]
90    fn test_empty_old_empty_new() {
91        let d = compute_chunk_delta(&[], &[]);
92        assert_eq!(d.added.len(), 0);
93        assert_eq!(d.removed.len(), 0);
94        assert_eq!(d.unchanged, 0);
95        assert_eq!(d.efficiency, 0.0);
96        assert!(!d.full_upload_recommended);
97    }
98
99    #[test]
100    fn test_empty_old_five_new() {
101        let new = vec![chunk("a",100), chunk("b",100), chunk("c",100), chunk("d",100), chunk("e",100)];
102        let d = compute_chunk_delta(&[], &new);
103        assert_eq!(d.added.len(), 5);
104        assert_eq!(d.removed.len(), 0);
105        assert_eq!(d.unchanged, 0);
106        assert_eq!(d.efficiency, 0.0);
107        assert!(!d.full_upload_recommended); // empty old -> first upload
108    }
109
110    #[test]
111    fn test_five_old_empty_new() {
112        let old = vec![chunk("a",100), chunk("b",100), chunk("c",100), chunk("d",100), chunk("e",100)];
113        let d = compute_chunk_delta(&old, &[]);
114        assert_eq!(d.added.len(), 0);
115        assert_eq!(d.removed.len(), 5);
116        assert_eq!(d.unchanged, 0);
117        assert_eq!(d.efficiency, 0.0);
118        // 5/5 = 100% removed > 80% -> true
119        assert!(d.full_upload_recommended);
120    }
121
122    #[test]
123    fn test_identical_chunks() {
124        let chunks = vec![chunk("a",100), chunk("b",200), chunk("c",300)];
125        let d = compute_chunk_delta(&chunks, &chunks);
126        assert_eq!(d.added.len(), 0);
127        assert_eq!(d.removed.len(), 0);
128        assert_eq!(d.unchanged, 3);
129        assert!((d.efficiency - 1.0).abs() < f64::EPSILON);
130        assert!(!d.full_upload_recommended);
131    }
132
133    #[test]
134    fn test_one_chunk_changed_out_of_ten() {
135        let old: Vec<ChunkRef> = (0..10).map(|i| chunk(&format!("old_{i}"), 100)).collect();
136        // new = same 9 + one replacement
137        let mut new_chunks = old.clone();
138        new_chunks[5] = chunk("new_5", 100);
139        let d = compute_chunk_delta(&old, &new_chunks);
140        assert_eq!(d.added.len(), 1);
141        assert_eq!(d.removed.len(), 1);
142        assert_eq!(d.unchanged, 9);
143        assert!((d.efficiency - 0.9).abs() < 1e-10);
144        assert!(!d.full_upload_recommended); // 1/10 = 10% removed
145    }
146
147    #[test]
148    fn test_all_chunks_changed() {
149        let old: Vec<ChunkRef> = (0..10).map(|i| chunk(&format!("old_{i}"), 100)).collect();
150        let new: Vec<ChunkRef> = (0..10).map(|i| chunk(&format!("new_{i}"), 100)).collect();
151        let d = compute_chunk_delta(&old, &new);
152        assert_eq!(d.added.len(), 10);
153        assert_eq!(d.removed.len(), 10);
154        assert_eq!(d.unchanged, 0);
155        assert_eq!(d.efficiency, 0.0);
156        assert!(d.full_upload_recommended); // 100% changed
157    }
158
159    #[test]
160    fn test_append_two_new_chunks() {
161        let old = vec![chunk("a",100), chunk("b",100)];
162        let new_chunks = vec![chunk("a",100), chunk("b",100), chunk("c",100), chunk("d",100)];
163        let d = compute_chunk_delta(&old, &new_chunks);
164        assert_eq!(d.added.len(), 2);
165        assert_eq!(d.removed.len(), 0);
166        assert_eq!(d.unchanged, 2);
167        assert!((d.efficiency - 0.5).abs() < f64::EPSILON);
168        assert!(!d.full_upload_recommended); // 0 removed from old
169    }
170
171    #[test]
172    fn test_remove_three_chunks_from_tail() {
173        let old: Vec<ChunkRef> = (0..5).map(|i| chunk(&format!("c{i}"), 100)).collect();
174        let new_chunks = old[..2].to_vec(); // keep first 2
175        let d = compute_chunk_delta(&old, &new_chunks);
176        assert_eq!(d.added.len(), 0);
177        assert_eq!(d.removed.len(), 3);
178        assert_eq!(d.unchanged, 2);
179        // 3/5 = 60% removed  --  below 80% threshold
180        assert!(!d.full_upload_recommended);
181    }
182
183    #[test]
184    fn test_full_upload_threshold_exactly_80_percent() {
185        // 8 of 10 old chunks replaced -> exactly 80% -> NOT above threshold
186        let old: Vec<ChunkRef> = (0..10).map(|i| chunk(&format!("old_{i}"), 100)).collect();
187        let mut new_chunks = old.clone();
188        for i in 0..8 {
189            new_chunks[i] = chunk(&format!("new_{i}"), 100);
190        }
191        let d = compute_chunk_delta(&old, &new_chunks);
192        // 8/10 = 80.0%  --  NOT strictly greater than 80%
193        assert!(!d.full_upload_recommended);
194
195        // 9 of 10 -> 90% -> IS above threshold
196        let mut new2 = old.clone();
197        for i in 0..9 {
198            new2[i] = chunk(&format!("newx_{i}"), 100);
199        }
200        let d2 = compute_chunk_delta(&old, &new2);
201        assert!(d2.full_upload_recommended);
202    }
203
204    #[test]
205    fn test_single_chunk_change() {
206        let old = vec![chunk("only_chunk", 512)];
207        let new_chunks = vec![chunk("new_chunk", 512)];
208        let d = compute_chunk_delta(&old, &new_chunks);
209        assert_eq!(d.added.len(), 1);
210        assert_eq!(d.removed.len(), 1);
211        assert_eq!(d.unchanged, 0);
212        // 1/1 = 100% removed -> full_upload_recommended
213        assert!(d.full_upload_recommended);
214    }
215
216    #[test]
217    fn test_order_does_not_matter_for_identity() {
218        // Same hashes in different order still recognized as unchanged
219        let old = vec![chunk("a",100), chunk("b",100), chunk("c",100)];
220        let new_chunks = vec![chunk("c",100), chunk("a",100), chunk("b",100)];
221        let d = compute_chunk_delta(&old, &new_chunks);
222        assert_eq!(d.added.len(), 0);
223        assert_eq!(d.removed.len(), 0);
224        assert_eq!(d.unchanged, 3);
225        assert!(!d.full_upload_recommended);
226    }
227}