Skip to main content

sqz_engine/
delta_encoder.rs

1/// Delta Encoding for Near-Duplicate Content.
2///
3/// When a file changes by a few lines, the SHA-256 dedup cache misses and
4/// the entire file is re-compressed. Delta encoding computes a line-level
5/// diff between the cached version and the new version, sending only the
6/// changed lines prefixed with a cache reference.
7///
8/// The similarity check uses a rolling hash fingerprint over fixed-size
9/// blocks to quickly determine if two pieces of content are "near-duplicate"
10/// (similarity > threshold) before computing the full diff.
11
12use std::collections::HashMap;
13use std::hash::{Hash, Hasher};
14
15use crate::error::Result;
16use crate::simhash::{simhash, SimHashFingerprint};
17
18/// Configuration for delta encoding.
19#[derive(Debug, Clone)]
20pub struct DeltaConfig {
21    /// Minimum similarity (0.0–1.0) to trigger delta encoding.
22    /// Below this threshold, full compression is used instead.
23    /// Default: 0.6
24    pub similarity_threshold: f64,
25    /// Number of context lines to include around each change.
26    /// Default: 1
27    pub context_lines: usize,
28}
29
30impl Default for DeltaConfig {
31    fn default() -> Self {
32        Self {
33            similarity_threshold: 0.6,
34            context_lines: 1,
35        }
36    }
37}
38
39/// A single edit operation in a delta.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum DeltaOp {
42    /// Keep N lines unchanged (only the context lines next to a change are emitted).
43    Keep(usize),
44    /// Insert new lines.
45    Insert(Vec<String>),
46    /// Delete N lines from the original.
47    Delete(usize),
48}
49
50/// Result of delta encoding.
51#[derive(Debug, Clone)]
52pub struct DeltaResult {
53    /// The compact delta representation.
54    pub delta_text: String,
55    /// Number of lines changed.
56    pub lines_changed: usize,
57    /// Total lines in the original.
58    pub lines_original: usize,
59    /// Similarity score between old and new content.
60    pub similarity: f64,
61}
62
63/// Delta encoder for near-duplicate content.
64pub struct DeltaEncoder {
65    config: DeltaConfig,
66    /// SimHash fingerprint index: maps fingerprints to content identifiers.
67    /// Enables O(1) near-duplicate detection instead of O(n×m) LCS comparison.
68    fingerprint_index: HashMap<String, SimHashFingerprint>,
69}
70
71impl DeltaEncoder {
72    pub fn new() -> Self {
73        Self::with_config(DeltaConfig::default())
74    }
75
76    pub fn with_config(config: DeltaConfig) -> Self {
77        Self {
78            config,
79            fingerprint_index: HashMap::new(),
80        }
81    }
82
83    /// Compute the SimHash fingerprint of a text.
84    pub fn fingerprint(&self, text: &str) -> SimHashFingerprint {
85        simhash(text)
86    }
87
88    /// Index a piece of content by its identifier for future O(1) lookups.
89    pub fn index_content(&mut self, id: &str, text: &str) {
90        let fp = simhash(text);
91        self.fingerprint_index.insert(id.to_string(), fp);
92    }
93
94    /// Find the best near-duplicate match from the index using SimHash.
95    /// Returns the id of the closest match within `max_hamming_distance`,
96    /// or None if no match is close enough.
97    pub fn find_nearest(&self, text: &str, max_hamming_distance: u32) -> Option<&str> {
98        let fp = simhash(text);
99        let mut best_id: Option<&str> = None;
100        let mut best_dist = u32::MAX;
101
102        for (id, &indexed_fp) in &self.fingerprint_index {
103            let dist = fp.hamming_distance(&indexed_fp);
104            if dist < best_dist && dist <= max_hamming_distance {
105                best_dist = dist;
106                best_id = Some(id.as_str());
107            }
108        }
109
110        best_id
111    }
112
113    /// Compute similarity between two texts using SimHash for the fast path,
114    /// falling back to line-level fingerprinting for precision.
115    pub fn similarity(&self, old: &str, new: &str) -> f64 {
116        // Fast path: SimHash gives a quick estimate
117        let fp_old = simhash(old);
118        let fp_new = simhash(new);
119        let simhash_sim = fp_old.estimated_similarity(&fp_new);
120
121        // If SimHash says very different (< 0.3), trust it — skip expensive check
122        if simhash_sim < 0.3 {
123            return simhash_sim;
124        }
125
126        // For borderline cases, use precise line-level comparison
127        self.similarity_precise(old, new)
128    }
129
130    /// Precise line-level similarity (the original O(n) algorithm).
131    fn similarity_precise(&self, old: &str, new: &str) -> f64 {
132        let old_lines: Vec<&str> = old.lines().collect();
133        let new_lines: Vec<&str> = new.lines().collect();
134
135        if old_lines.is_empty() && new_lines.is_empty() {
136            return 1.0;
137        }
138        if old_lines.is_empty() || new_lines.is_empty() {
139            return 0.0;
140        }
141
142        // Build fingerprint set for old content
143        let old_hashes: HashMap<u64, usize> = old_lines
144            .iter()
145            .map(|l| line_hash(l))
146            .fold(HashMap::new(), |mut acc, h| {
147                *acc.entry(h).or_insert(0) += 1;
148                acc
149            });
150
151        let new_hashes: HashMap<u64, usize> = new_lines
152            .iter()
153            .map(|l| line_hash(l))
154            .fold(HashMap::new(), |mut acc, h| {
155                *acc.entry(h).or_insert(0) += 1;
156                acc
157            });
158
159        // Count shared line hashes (min of counts)
160        let mut shared = 0usize;
161        for (hash, &old_count) in &old_hashes {
162            if let Some(&new_count) = new_hashes.get(hash) {
163                shared += old_count.min(new_count);
164            }
165        }
166
167        let total = old_lines.len().max(new_lines.len());
168        shared as f64 / total as f64
169    }
170
171    /// Compute a line-level diff and produce a compact delta representation.
172    ///
173    /// Returns `None` if similarity is below the threshold (caller should
174    /// fall back to full compression).
175    pub fn encode(&self, old: &str, new: &str, hash_prefix: &str) -> Result<Option<DeltaResult>> {
176        let sim = self.similarity(old, new);
177        if sim < self.config.similarity_threshold {
178            return Ok(None);
179        }
180
181        let old_lines: Vec<&str> = old.lines().collect();
182        let new_lines: Vec<&str> = new.lines().collect();
183
184        let ops = compute_diff(&old_lines, &new_lines);
185        let (delta_text, lines_changed) =
186            format_delta(&ops, &new_lines, hash_prefix, self.config.context_lines);
187
188        Ok(Some(DeltaResult {
189            delta_text,
190            lines_changed,
191            lines_original: old_lines.len(),
192            similarity: sim,
193        }))
194    }
195
196    /// Check if delta encoding would be beneficial for these two texts.
197    pub fn should_delta(&self, old: &str, new: &str) -> bool {
198        let sim = self.similarity(old, new);
199        sim >= self.config.similarity_threshold && sim < 1.0
200    }
201}
202
203impl Default for DeltaEncoder {
204    fn default() -> Self {
205        Self::new()
206    }
207}
208
209// ── Diff algorithm (Myers-like, simplified for line-level) ────────────────────
210
211/// Compute the edit operations to transform `old` into `new`.
212fn compute_diff<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<DeltaOp> {
213    // Use LCS (Longest Common Subsequence) to find matching lines,
214    // then derive insert/delete/keep operations.
215    let lcs = lcs_table(old, new);
216    let mut ops = Vec::new();
217    let mut i = old.len();
218    let mut j = new.len();
219
220    // Trace back through the LCS table
221    let mut raw_ops: Vec<RawOp> = Vec::new();
222    while i > 0 || j > 0 {
223        if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
224            raw_ops.push(RawOp::Keep);
225            i -= 1;
226            j -= 1;
227        } else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) {
228            raw_ops.push(RawOp::Insert(new[j - 1].to_string()));
229            j -= 1;
230        } else if i > 0 {
231            raw_ops.push(RawOp::Delete);
232            i -= 1;
233        }
234    }
235    raw_ops.reverse();
236
237    // Compact raw ops into DeltaOps
238    let mut keep_count = 0usize;
239    let mut del_count = 0usize;
240    let mut ins_buf: Vec<String> = Vec::new();
241
242    for op in raw_ops {
243        match op {
244            RawOp::Keep => {
245                if del_count > 0 {
246                    ops.push(DeltaOp::Delete(del_count));
247                    del_count = 0;
248                }
249                if !ins_buf.is_empty() {
250                    ops.push(DeltaOp::Insert(std::mem::take(&mut ins_buf)));
251                }
252                keep_count += 1;
253            }
254            RawOp::Delete => {
255                if keep_count > 0 {
256                    ops.push(DeltaOp::Keep(keep_count));
257                    keep_count = 0;
258                }
259                if !ins_buf.is_empty() {
260                    ops.push(DeltaOp::Insert(std::mem::take(&mut ins_buf)));
261                }
262                del_count += 1;
263            }
264            RawOp::Insert(line) => {
265                if keep_count > 0 {
266                    ops.push(DeltaOp::Keep(keep_count));
267                    keep_count = 0;
268                }
269                if del_count > 0 {
270                    ops.push(DeltaOp::Delete(del_count));
271                    del_count = 0;
272                }
273                ins_buf.push(line);
274            }
275        }
276    }
277    // Flush remaining
278    if keep_count > 0 {
279        ops.push(DeltaOp::Keep(keep_count));
280    }
281    if del_count > 0 {
282        ops.push(DeltaOp::Delete(del_count));
283    }
284    if !ins_buf.is_empty() {
285        ops.push(DeltaOp::Insert(ins_buf));
286    }
287
288    ops
289}
290
291#[derive(Debug)]
292enum RawOp {
293    Keep,
294    Delete,
295    Insert(String),
296}
297
298/// Build the LCS length table for two line slices.
299fn lcs_table(old: &[&str], new: &[&str]) -> Vec<Vec<usize>> {
300    let m = old.len();
301    let n = new.len();
302    let mut table = vec![vec![0usize; n + 1]; m + 1];
303
304    for i in 1..=m {
305        for j in 1..=n {
306            if old[i - 1] == new[j - 1] {
307                table[i][j] = table[i - 1][j - 1] + 1;
308            } else {
309                table[i][j] = table[i - 1][j].max(table[i][j - 1]);
310            }
311        }
312    }
313    table
314}
315
316/// Format delta operations into a compact text representation.
317fn format_delta(
318    ops: &[DeltaOp],
319    new_lines: &[&str],
320    hash_prefix: &str,
321    context_lines: usize,
322) -> (String, usize) {
323    let mut output = Vec::new();
324    output.push(format!("§delta:{hash_prefix}§"));
325
326    let mut line_num = 0usize;
327    let mut new_line = 0usize;
328    let mut lines_changed = 0usize;
329
330    for (i, op) in ops.iter().enumerate() {
331        match op {
332            DeltaOp::Keep(n) => {
333                let kept = &new_lines[new_line..new_line + n];
334                let lead = if i == 0 { 0 } else { context_lines.min(*n) };
335                let trail = if i + 1 == ops.len() { 0 } else { context_lines.min(n - lead) };
336                for line in &kept[..lead] {
337                    output.push(format!(" {line}"));
338                }
339                let skip = n - lead - trail;
340                if skip > 0 {
341                    output.push(format!(" @@ skip {skip} unchanged lines @@"));
342                }
343                for line in &kept[n - trail..] {
344                    output.push(format!(" {line}"));
345                }
346                line_num += n;
347                new_line += n;
348            }
349            DeltaOp::Delete(n) => {
350                output.push(format!("-[{n} lines removed at L{}]", line_num + 1));
351                line_num += n;
352                lines_changed += n;
353            }
354            DeltaOp::Insert(lines) => {
355                for line in lines {
356                    output.push(format!("+{line}"));
357                }
358                new_line += lines.len();
359                lines_changed += lines.len();
360            }
361        }
362    }
363
364    (output.join("\n"), lines_changed)
365}
366
367/// Compute a fast hash of a single line for fingerprinting.
368fn line_hash(line: &str) -> u64 {
369    let mut hasher = std::collections::hash_map::DefaultHasher::new();
370    line.hash(&mut hasher);
371    hasher.finish()
372}
373
374// ── Tests ─────────────────────────────────────────────────────────────────────
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_identical_content_similarity_1() {
382        let enc = DeltaEncoder::new();
383        let text = "line 1\nline 2\nline 3\n";
384        assert!((enc.similarity(text, text) - 1.0).abs() < 1e-9);
385    }
386
387    #[test]
388    fn test_completely_different_similarity_0() {
389        let enc = DeltaEncoder::new();
390        let old = "aaa\nbbb\nccc\n";
391        let new = "xxx\nyyy\nzzz\n";
392        assert!(enc.similarity(old, new) < 0.01);
393    }
394
395    #[test]
396    fn test_small_edit_high_similarity() {
397        let enc = DeltaEncoder::new();
398        let old = "line 1\nline 2\nline 3\nline 4\nline 5\n";
399        let new = "line 1\nline 2\nline 3 modified\nline 4\nline 5\n";
400        let sim = enc.similarity(old, new);
401        assert!(sim > 0.7, "expected high similarity, got {sim}");
402    }
403
404    #[test]
405    fn test_encode_returns_none_below_threshold() {
406        let enc = DeltaEncoder::new();
407        let old = "aaa\nbbb\nccc\n";
408        let new = "xxx\nyyy\nzzz\n";
409        let result = enc.encode(old, new, "abc123").unwrap();
410        assert!(result.is_none());
411    }
412
413    #[test]
414    fn test_encode_returns_delta_for_small_edit() {
415        let enc = DeltaEncoder::new();
416        let old = "line 1\nline 2\nline 3\nline 4\nline 5\n";
417        let new = "line 1\nline 2\nline 3 modified\nline 4\nline 5\n";
418        let result = enc.encode(old, new, "abc123").unwrap();
419        assert!(result.is_some(), "expected delta for small edit");
420        let delta = result.unwrap();
421        assert!(delta.delta_text.starts_with("§delta:abc123§"));
422        assert!(delta.lines_changed > 0);
423        assert!(delta.similarity > 0.7);
424    }
425
426    #[test]
427    fn test_encode_identical_content() {
428        let enc = DeltaEncoder::new();
429        let text = "line 1\nline 2\nline 3\n";
430        let result = enc.encode(text, text, "abc123").unwrap();
431        // Identical content has similarity 1.0 which is >= threshold but
432        // should produce a delta with 0 changes
433        if let Some(delta) = result {
434            assert_eq!(delta.lines_changed, 0);
435        }
436        // It's also valid for the caller to use exact dedup instead
437    }
438
439    #[test]
440    fn test_should_delta() {
441        let enc = DeltaEncoder::new();
442        let old = "line 1\nline 2\nline 3\nline 4\nline 5\n";
443        let new = "line 1\nline 2\nline 3 modified\nline 4\nline 5\n";
444        assert!(enc.should_delta(old, new));
445
446        // Identical content: similarity == 1.0, should_delta returns false
447        assert!(!enc.should_delta(old, old));
448
449        // Completely different: below threshold
450        assert!(!enc.should_delta("aaa\nbbb\n", "xxx\nyyy\n"));
451    }
452
453    #[test]
454    fn test_empty_inputs() {
455        let enc = DeltaEncoder::new();
456        assert!((enc.similarity("", "") - 1.0).abs() < 1e-9);
457        assert_eq!(enc.similarity("", "something"), 0.0);
458        assert_eq!(enc.similarity("something", ""), 0.0);
459    }
460
461    #[test]
462    fn test_delta_format_contains_header() {
463        let enc = DeltaEncoder::new();
464        let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
465        let new = "a\nb\nc\nX\ne\nf\ng\nh\ni\nj\n";
466        if let Some(delta) = enc.encode(old, new, "deadbeef").unwrap() {
467            assert!(delta.delta_text.contains("§delta:deadbeef§"));
468        }
469    }
470
471    #[test]
472    fn test_delta_numbers_lines_from_one_and_accounts_for_each() {
473        let enc = DeltaEncoder::new();
474        let old: String = (1..=20).map(|i| format!("row {i:02}\n")).collect();
475
476        let removed = old.replace("row 03\n", "");
477        let delta = enc.encode(&old, &removed, "f1").unwrap().unwrap();
478        assert_eq!(
479            delta.delta_text,
480            "§delta:f1§\n @@ skip 1 unchanged lines @@\n row 02\n-[1 lines removed at L3]\n row 04\n @@ skip 16 unchanged lines @@"
481        );
482
483        let edited = old.replace("row 10\n", "row ten\n");
484        let delta = enc.encode(&old, &edited, "f1").unwrap().unwrap();
485        assert_eq!(
486            delta.delta_text,
487            "§delta:f1§\n @@ skip 8 unchanged lines @@\n row 09\n-[1 lines removed at L10]\n+row ten\n row 11\n @@ skip 9 unchanged lines @@"
488        );
489
490        let appended = format!("{old}row 21\nrow 22\n");
491        let delta = enc.encode(&old, &appended, "f1").unwrap().unwrap();
492        assert_eq!(delta.delta_text, "§delta:f1§\n @@ skip 19 unchanged lines @@\n row 20\n+row 21\n+row 22");
493    }
494
495    #[test]
496    fn test_compute_diff_basic() {
497        let old = vec!["a", "b", "c"];
498        let new = vec!["a", "x", "c"];
499        let ops = compute_diff(&old, &new);
500        // Should have: Keep(1), Delete(1), Insert(["x"]), Keep(1)
501        assert!(!ops.is_empty());
502    }
503
504    #[test]
505    fn test_compute_diff_insertion() {
506        let old = vec!["a", "c"];
507        let new = vec!["a", "b", "c"];
508        let ops = compute_diff(&old, &new);
509        assert!(!ops.is_empty());
510    }
511
512    #[test]
513    fn test_compute_diff_deletion() {
514        let old = vec!["a", "b", "c"];
515        let new = vec!["a", "c"];
516        let ops = compute_diff(&old, &new);
517        assert!(!ops.is_empty());
518    }
519
520    fn replay(old: &[String], delta: &str) -> Vec<String> {
521        let mut out = Vec::new();
522        let mut at = 0;
523        for line in delta.lines().skip(1) {
524            if let Some(rest) = line.strip_prefix(" @@ skip ") {
525                let n: usize = rest.split(' ').next().unwrap().parse().unwrap();
526                out.extend_from_slice(&old[at..at + n]);
527                at += n;
528            } else if let Some(rest) = line.strip_prefix("-[") {
529                let (n, pos) = rest.trim_end_matches(']').split_once(" lines removed at L").unwrap();
530                assert_eq!(pos.parse::<usize>().unwrap(), at + 1, "{delta}");
531                at += n.parse::<usize>().unwrap();
532            } else if let Some(added) = line.strip_prefix('+') {
533                out.push(added.to_string());
534            } else {
535                assert_eq!(line.strip_prefix(' '), Some(old[at].as_str()), "{delta}");
536                out.push(old[at].clone());
537                at += 1;
538            }
539        }
540        assert_eq!(at, old.len(), "{delta}");
541        out
542    }
543
544    // ── Property tests ────────────────────────────────────────────────────────
545
546    use proptest::prelude::*;
547
548    proptest! {
549        /// Similarity is always in [0.0, 1.0].
550        #[test]
551        fn prop_similarity_bounded(
552            old in "[a-z\n]{5,100}",
553            new in "[a-z\n]{5,100}",
554        ) {
555            let enc = DeltaEncoder::new();
556            let sim = enc.similarity(&old, &new);
557            prop_assert!(sim >= 0.0 && sim <= 1.0, "similarity out of bounds: {sim}");
558        }
559
560        /// Similarity of identical content is 1.0.
561        #[test]
562        fn prop_similarity_identity(
563            text in "[a-z ]{5,100}"
564        ) {
565            let enc = DeltaEncoder::new();
566            let sim = enc.similarity(&text, &text);
567            prop_assert!(
568                (sim - 1.0).abs() < 1e-9,
569                "identical content should have similarity 1.0, got {sim}"
570            );
571        }
572
573        /// Replaying a delta over the old lines gives the new lines.
574        #[test]
575        fn prop_delta_replays_to_the_new_text(
576            old in prop::collection::vec("[a-z]{1,6}", 3..30),
577            edits in prop::collection::vec((0usize..40, prop::option::of("[a-z]{1,6}")), 1..4),
578        ) {
579            let mut new = old.clone();
580            for (at, line) in edits {
581                let at = at % (new.len() + 1);
582                match line {
583                    Some(line) => new.insert(at, line),
584                    None if at < new.len() => {
585                        new.remove(at);
586                    }
587                    None => {}
588                }
589            }
590            prop_assume!(!new.is_empty());
591            let enc = DeltaEncoder::new();
592            let (old_text, new_text) = (old.join("\n") + "\n", new.join("\n") + "\n");
593            if let Some(delta) = enc.encode(&old_text, &new_text, "p").unwrap() {
594                prop_assert_eq!(replay(&old, &delta.delta_text), new);
595            }
596        }
597
598        /// Delta text always starts with the §delta: header when produced.
599        #[test]
600        fn prop_delta_header_present(
601            base in "[a-z]{3,10}\n[a-z]{3,10}\n[a-z]{3,10}\n[a-z]{3,10}\n[a-z]{3,10}\n",
602        ) {
603            let enc = DeltaEncoder::new();
604            // Make a small edit
605            let new = format!("{base}extra line\n");
606            if let Some(delta) = enc.encode(&base, &new, "test1234").unwrap() {
607                prop_assert!(
608                    delta.delta_text.starts_with("§delta:test1234§"),
609                    "delta should start with header"
610                );
611            }
612        }
613    }
614}