asupersync/net/atp/chunk/
change_detect.rs1use serde::{Deserialize, Serialize};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub struct FileSignature {
37 pub size: u64,
39 pub mtime_nanos: u64,
41 pub ctime_nanos: u64,
45}
46
47impl FileSignature {
48 #[must_use]
50 pub const fn new(size: u64, mtime_nanos: u64, ctime_nanos: u64) -> Self {
51 Self {
52 size,
53 mtime_nanos,
54 ctime_nanos,
55 }
56 }
57
58 #[must_use]
63 pub fn from_metadata(meta: &std::fs::Metadata) -> Self {
64 let mtime_nanos = meta
65 .modified()
66 .ok()
67 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
68 .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
69 let ctime_nanos = platform_ctime_nanos(meta);
70 Self {
71 size: meta.len(),
72 mtime_nanos,
73 ctime_nanos,
74 }
75 }
76
77 #[must_use]
80 const fn quick_check_key(&self) -> (u64, u64) {
81 (self.size, self.mtime_nanos)
82 }
83}
84
85#[cfg(unix)]
86fn platform_ctime_nanos(meta: &std::fs::Metadata) -> u64 {
87 use std::os::unix::fs::MetadataExt;
88 let secs = u64::try_from(meta.ctime()).unwrap_or(0);
89 let nanos = u64::try_from(meta.ctime_nsec()).unwrap_or(0);
90 secs.saturating_mul(1_000_000_000).saturating_add(nanos)
91}
92
93#[cfg(not(unix))]
94fn platform_ctime_nanos(_meta: &std::fs::Metadata) -> u64 {
95 0
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum ChangeVerdict {
101 Unchanged,
103 SuspectChanged,
105}
106
107impl ChangeVerdict {
108 #[must_use]
110 pub const fn is_skippable(self) -> bool {
111 matches!(self, Self::Unchanged)
112 }
113}
114
115#[must_use]
122pub fn classify(prior: Option<&FileSignature>, current: &FileSignature) -> ChangeVerdict {
123 match prior {
124 Some(p) if p.quick_check_key() == current.quick_check_key() => ChangeVerdict::Unchanged,
125 _ => ChangeVerdict::SuspectChanged,
126 }
127}
128
129const SIMHASH_BITS: usize = 64;
130
131#[must_use]
138pub fn simhash64(features: impl IntoIterator<Item = u64>) -> u64 {
139 let mut acc = [0i64; SIMHASH_BITS];
140 let mut any = false;
141 for feature in features {
142 any = true;
143 for (bit, slot) in acc.iter_mut().enumerate() {
144 if (feature >> bit) & 1 == 1 {
145 *slot += 1;
146 } else {
147 *slot -= 1;
148 }
149 }
150 }
151 if !any {
152 return 0;
153 }
154 let mut hash = 0u64;
155 for (bit, slot) in acc.iter().enumerate() {
156 if *slot > 0 {
157 hash |= 1u64 << bit;
158 }
159 }
160 hash
161}
162
163#[must_use]
169pub fn simhash_of_chunk_ids<'a>(chunk_ids: impl IntoIterator<Item = &'a [u8; 32]>) -> u64 {
170 simhash64(chunk_ids.into_iter().map(|id| {
171 let mut bytes = [0u8; 8];
172 bytes.copy_from_slice(&id[..8]);
173 u64::from_le_bytes(bytes)
174 }))
175}
176
177#[must_use]
180pub fn hamming_similarity(a: u64, b: u64) -> f64 {
181 let distance = (a ^ b).count_ones();
182 1.0 - f64::from(distance) / SIMHASH_BITS as f64
183}
184
185#[derive(Debug, Clone, PartialEq)]
187pub struct RenameMatch {
188 pub prior_path: String,
190 pub similarity: f64,
192}
193
194#[must_use]
200pub fn best_rename_source<'a>(
201 current_simhash: u64,
202 candidates: impl IntoIterator<Item = (&'a str, u64)>,
203 min_similarity: f64,
204) -> Option<RenameMatch> {
205 let mut best: Option<RenameMatch> = None;
206 for (path, simhash) in candidates {
207 let similarity = hamming_similarity(current_simhash, simhash);
208 if similarity < min_similarity {
209 continue;
210 }
211 if best.as_ref().is_none_or(|b| similarity > b.similarity) {
212 best = Some(RenameMatch {
213 prior_path: path.to_string(),
214 similarity,
215 });
216 }
217 }
218 best
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 fn sig(size: u64, mtime: u64, ctime: u64) -> FileSignature {
226 FileSignature::new(size, mtime, ctime)
227 }
228
229 #[test]
230 fn unchanged_when_size_and_mtime_match() {
231 let prior = sig(4096, 1_000, 500);
232 let current = sig(4096, 1_000, 500);
233 assert_eq!(classify(Some(&prior), ¤t), ChangeVerdict::Unchanged);
234 assert!(classify(Some(&prior), ¤t).is_skippable());
235 }
236
237 #[test]
238 fn ctime_only_change_is_ignored() {
239 let prior = sig(4096, 1_000, 500);
241 let current = sig(4096, 1_000, 9_999); assert_eq!(classify(Some(&prior), ¤t), ChangeVerdict::Unchanged);
243 }
244
245 #[test]
246 fn mtime_change_is_suspect() {
247 let prior = sig(4096, 1_000, 500);
248 let current = sig(4096, 2_000, 500);
249 assert_eq!(
250 classify(Some(&prior), ¤t),
251 ChangeVerdict::SuspectChanged
252 );
253 }
254
255 #[test]
256 fn size_change_is_suspect() {
257 let prior = sig(4096, 1_000, 500);
258 let current = sig(8192, 1_000, 500);
259 assert_eq!(
260 classify(Some(&prior), ¤t),
261 ChangeVerdict::SuspectChanged
262 );
263 }
264
265 #[test]
266 fn no_prior_is_suspect() {
267 let current = sig(4096, 1_000, 500);
268 assert_eq!(classify(None, ¤t), ChangeVerdict::SuspectChanged);
269 assert!(!classify(None, ¤t).is_skippable());
270 }
271
272 #[test]
273 fn simhash_is_deterministic_and_order_independent() {
274 let a = simhash64([1u64, 2, 3, 4]);
275 let b = simhash64([4u64, 3, 2, 1]);
276 assert_eq!(a, b, "simhash must not depend on feature order");
277 assert_eq!(simhash64(std::iter::empty()), 0);
278 }
279
280 #[test]
281 fn identical_chunk_sets_are_a_perfect_rename_match() {
282 let ids = [[7u8; 32], [9u8; 32], [11u8; 32]];
284 let prior = simhash_of_chunk_ids(ids.iter());
285 let renamed = simhash_of_chunk_ids(ids.iter());
286 assert_eq!(prior, renamed);
287 assert!((hamming_similarity(prior, renamed) - 1.0).abs() < f64::EPSILON);
288 }
289
290 #[test]
291 fn small_edit_stays_highly_similar() {
292 let base: Vec<[u8; 32]> = (0..32u8).map(|i| [i; 32]).collect();
293 let mut edited = base.clone();
294 edited[0] = [200u8; 32]; let h_base = simhash_of_chunk_ids(base.iter());
296 let h_edited = simhash_of_chunk_ids(edited.iter());
297 assert!(
298 hamming_similarity(h_base, h_edited) > 0.7,
299 "a one-chunk edit should remain a strong delta candidate"
300 );
301 }
302
303 #[test]
304 fn best_rename_source_picks_the_nearest_above_threshold() {
305 let current = 0xfeed_face_cafe_beefu64;
306 let near = current ^ 0x0000_0000_0000_0001;
307 let far = current ^ 0x0000_0000_0000_ffff;
308
309 let candidates = [("old/far.bin", far), ("old/near.bin", near)];
310 let m = best_rename_source(current, candidates.iter().map(|(p, h)| (*p, *h)), 0.6)
311 .expect("a near match clears the threshold");
312 assert_eq!(m.prior_path, "old/near.bin");
313 }
314
315 #[test]
316 fn best_rename_source_returns_none_when_nothing_is_similar_enough() {
317 let current = simhash_of_chunk_ids([[1u8; 32], [2u8; 32]].iter());
318 let far = simhash_of_chunk_ids([[200u8; 32], [201u8; 32]].iter());
319 assert!(best_rename_source(current, [("old/x", far)], 0.95).is_none());
320 }
321}