Skip to main content

sqz_engine/
rle_compressor.rs

1/// Run-Length Encoding (RLE) for structured repetition patterns.
2///
3/// Generalizes the condense stage to catch repeated patterns within lines
4/// and repeated structured blocks, not just consecutive identical lines.
5///
6/// RLE is provably optimal for data with runs of identical symbols
7/// (Shannon 1948). This implementation works at the token/phrase level
8/// rather than the byte level, making it effective for CLI output patterns.
9
10use crate::error::Result;
11
12/// Result of RLE compression.
13#[derive(Debug, Clone)]
14pub struct RleResult {
15    /// The compressed text.
16    pub text: String,
17    /// Number of runs collapsed.
18    pub runs_collapsed: usize,
19    /// Tokens saved (estimated).
20    pub tokens_saved: u32,
21}
22
23/// Compress text using token-level run-length encoding.
24///
25/// Detects repeated phrases/tokens and replaces runs with compact notation:
26/// `token [×N]` where N is the repeat count.
27pub fn rle_compress(text: &str, min_run_length: usize) -> Result<RleResult> {
28    rle_compress_with_original(text, None, min_run_length)
29}
30
31pub(crate) fn rle_compress_with_original(
32    text: &str,
33    original: Option<&str>,
34    min_run_length: usize,
35) -> Result<RleResult> {
36    let lines: Vec<&str> = text.lines().collect();
37    if lines.len() < 2 {
38        return Ok(RleResult {
39            text: text.to_string(),
40            runs_collapsed: 0,
41            tokens_saved: 0,
42        });
43    }
44
45    let mut original_runs = original.map(line_runs).unwrap_or_default().into_iter();
46    let mut output = Vec::new();
47    let mut runs_collapsed = 0usize;
48    let mut tokens_saved = 0u32;
49    let mut i = 0;
50
51    while i < lines.len() {
52        // Try to find a run of identical lines (lossless — safe to collapse)
53        let mut run_len = 1;
54        while i + run_len < lines.len() && lines[i] == lines[i + run_len] {
55            run_len += 1;
56        }
57        let count = match original_runs.next() {
58            Some((line, n)) if line == lines[i] => n,
59            _ => run_len,
60        };
61
62        if run_len >= min_run_length {
63            // Collapse the run of identical lines. Lossless: N copies of the
64            // same text can be recovered from "{text} [×N]".
65            output.push(format!("{} [×{}]", lines[i], count));
66            runs_collapsed += 1;
67            let line_tokens = (lines[i].len() as u32 + 3) / 4;
68            tokens_saved += line_tokens * (run_len as u32 - 1);
69            i += run_len;
70        } else {
71            // Pattern-run detection (lines with shared word-prefix but
72            // different suffixes) was removed because it was LOSSY: it
73            // replaced the varying parts with "{count} unique values",
74            // discarding real filenames from `ls -l` output. See Reddit
75            // report #1 and the test_ls_output_preserves_all_filenames
76            // regression test. Exact duplicates above are still collapsed.
77            output.extend(lines[i..i + run_len].iter().map(|line| line.to_string()));
78            i += run_len;
79        }
80    }
81
82    let trailing_newline = text.ends_with('\n');
83    let mut result = output.join("\n");
84    if trailing_newline && !result.ends_with('\n') {
85        result.push('\n');
86    }
87
88    Ok(RleResult {
89        text: result,
90        runs_collapsed,
91        tokens_saved,
92    })
93}
94
95fn line_runs(text: &str) -> Vec<(&str, usize)> {
96    let mut runs: Vec<(&str, usize)> = Vec::new();
97    for line in text.lines() {
98        match runs.last_mut() {
99            Some((prev, n)) if *prev == line => *n += 1,
100            _ => runs.push((line, 1)),
101        }
102    }
103    runs
104}
105
106// ── Log-template compaction ──────────────────────────────────────────────────
107
108/// Result of log-template compaction.
109#[derive(Debug, Clone)]
110pub struct LogTemplateResult {
111    pub text: String,
112    pub runs_collapsed: usize,
113    pub tokens_saved: u32,
114}
115
116/// Collapse consecutive lines that are identical except for VOLATILE spans:
117/// timestamps, durations, percentages. What exact RLE (identical lines only)
118/// leaves on the table in real logs is mostly timestamp spam.
119///
120/// Deliberately narrow — the predecessor of this stage (pattern-run
121/// detection) was removed for eating `ls` filenames. Rules:
122/// - Only timestamps, durations, and percentages count as volatile.
123///   Hex ids, UUIDs, filenames, and plain integers make lines DIFFERENT.
124/// - Only consecutive lines collapse, and the first line stays verbatim.
125/// - The marker names what varied, so the loss is explicit.
126pub fn log_template_collapse(text: &str, min_run_length: usize) -> Result<LogTemplateResult> {
127    let lines: Vec<&str> = text.lines().collect();
128    if lines.len() < min_run_length {
129        return Ok(LogTemplateResult {
130            text: text.to_string(),
131            runs_collapsed: 0,
132            tokens_saved: 0,
133        });
134    }
135
136    let templates: Vec<String> = lines.iter().map(|l| mask_volatiles(l)).collect();
137
138    let mut output = Vec::new();
139    let mut runs_collapsed = 0usize;
140    let mut tokens_saved = 0u32;
141    let mut i = 0;
142
143    while i < lines.len() {
144        let mut run_len = 1;
145        // A run requires the same template AND that masking actually did
146        // something (otherwise identical lines belong to exact RLE).
147        while i + run_len < lines.len()
148            && templates[i + run_len] == templates[i]
149            && templates[i] != lines[i]
150            && lines[i + run_len] != lines[i]
151        {
152            run_len += 1;
153        }
154
155        if run_len >= min_run_length {
156            output.push(lines[i].to_string());
157            output.push(format!(
158                "[×{} similar lines; only timestamps/durations vary]",
159                run_len - 1
160            ));
161            runs_collapsed += 1;
162            for line in &lines[i + 1..i + run_len] {
163                tokens_saved += (line.len() as u32).div_ceil(4);
164            }
165            i += run_len;
166        } else {
167            output.push(lines[i].to_string());
168            i += 1;
169        }
170    }
171
172    let trailing_newline = text.ends_with('\n');
173    let mut result = output.join("\n");
174    if trailing_newline && !result.ends_with('\n') {
175        result.push('\n');
176    }
177
178    Ok(LogTemplateResult {
179        text: result,
180        runs_collapsed,
181        tokens_saved,
182    })
183}
184
185/// Mask volatile spans (timestamps, durations, percentages) with a
186/// placeholder. Everything else — hex ids, UUIDs, paths, plain integers —
187/// is preserved so identifier-bearing lines never share a template.
188fn mask_volatiles(line: &str) -> String {
189    let mut out = String::with_capacity(line.len());
190    for token in line.split_inclusive(|c: char| c.is_whitespace()) {
191        let (word, ws) = match token.find(|c: char| c.is_whitespace()) {
192            Some(idx) => token.split_at(idx),
193            None => (token, ""),
194        };
195        let stripped = word.trim_matches(|c: char| "[]()<>,".contains(c));
196        if is_volatile(stripped) {
197            out.push_str(&word.replace(stripped, "‹›"));
198        } else {
199            out.push_str(word);
200        }
201        out.push_str(ws);
202    }
203    out
204}
205
206fn is_volatile(token: &str) -> bool {
207    if token.len() < 2 {
208        return false;
209    }
210    // Durations: 123ms, 4.5s, 12µs, 3m20s
211    for suffix in ["ms", "µs", "us", "ns", "s", "m"] {
212        if let Some(num) = token.strip_suffix(suffix) {
213            if !num.is_empty()
214                && num.chars().all(|c| c.is_ascii_digit() || c == '.')
215                && num.chars().any(|c| c.is_ascii_digit())
216            {
217                return true;
218            }
219        }
220    }
221    // Percentages: 45%, 99.9%
222    if let Some(num) = token.strip_suffix('%') {
223        if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit() || c == '.') {
224            return true;
225        }
226    }
227    // Clock times and dates: 12:34:56(.789), 2026-08-15, 2026-08-15T12:34:56Z
228    let digits = token.chars().filter(|c| c.is_ascii_digit()).count();
229    if digits >= 4
230        && token
231            .chars()
232            .all(|c| c.is_ascii_digit() || ":-.TZ+".contains(c))
233        && (token.contains(':') || token.matches('-').count() >= 2)
234    {
235        return true;
236    }
237    false
238}
239
240// ── Sliding Window Dedup ──────────────────────────────────────────────────────
241
242/// Token-level sliding window deduplication.
243///
244/// Finds repeated substrings within a text using a sliding window approach.
245/// When the same phrase appears multiple times, subsequent occurrences are
246/// replaced with back-references: `[→L{line}]`.
247///
248/// This catches repeated substrings that aren't line-aligned — e.g., the
249/// same error message appearing in two different stack frames.
250pub fn sliding_window_dedup(text: &str, min_match_words: usize) -> Result<SlidingWindowResult> {
251    let lines: Vec<&str> = text.lines().collect();
252    if lines.len() < 2 {
253        return Ok(SlidingWindowResult {
254            text: text.to_string(),
255            dedup_count: 0,
256            tokens_saved: 0,
257        });
258    }
259
260    // Build a phrase index: map each n-word phrase to its first occurrence line
261    let mut phrase_index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
262    let mut output = Vec::new();
263    let mut dedup_count = 0usize;
264    let mut tokens_saved = 0u32;
265
266    for (line_idx, &line) in lines.iter().enumerate() {
267        let words: Vec<&str> = line.split_whitespace().collect();
268
269        // Check if this line (as a phrase) was seen before
270        let phrase = words.join(" ");
271        if phrase.len() >= min_match_words * 3 {
272            if let Some(&first_line) = phrase_index.get(&phrase) {
273                // This exact line appeared before — replace with back-reference
274                output.push(format!("[→L{}]", first_line + 1));
275                dedup_count += 1;
276                tokens_saved += (phrase.len() as u32 + 3) / 4;
277                continue;
278            }
279        }
280
281        // Index this line's phrase
282        if phrase.len() >= min_match_words * 3 {
283            phrase_index.insert(phrase, line_idx);
284        }
285        output.push(line.to_string());
286    }
287
288    let trailing_newline = text.ends_with('\n');
289    let mut result = output.join("\n");
290    if trailing_newline && !result.ends_with('\n') {
291        result.push('\n');
292    }
293
294    Ok(SlidingWindowResult {
295        text: result,
296        dedup_count,
297        tokens_saved,
298    })
299}
300
301/// Result of sliding window deduplication.
302#[derive(Debug, Clone)]
303pub struct SlidingWindowResult {
304    pub text: String,
305    pub dedup_count: usize,
306    pub tokens_saved: u32,
307}
308
309// ── Tests ─────────────────────────────────────────────────────────────────────
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    // ── log-template compaction ───────────────────────────────────────
316
317    #[test]
318    fn template_collapses_timestamp_spam() {
319        let input = "\
32012:00:01 worker heartbeat ok in 12ms
32112:00:02 worker heartbeat ok in 14ms
32212:00:03 worker heartbeat ok in 11ms
32312:00:04 worker heartbeat ok in 13ms
324done\n";
325        let result = log_template_collapse(input, 3).unwrap();
326        assert_eq!(result.runs_collapsed, 1);
327        assert!(result.text.contains("12:00:01 worker heartbeat ok in 12ms"), "first line verbatim");
328        assert!(result.text.contains("[×3 similar lines; only timestamps/durations vary]"));
329        assert!(!result.text.contains("12:00:03"), "later volatiles collapsed");
330        assert!(result.text.ends_with("done\n"));
331    }
332
333    #[test]
334    fn template_never_collapses_identifier_bearing_lines() {
335        // Same shape but each line carries a distinct hex id: these are the
336        // `ls`-filenames class of lines that the removed pattern-run stage
337        // used to eat. They must all survive.
338        let input = "\
33912:00:01 pushed commit deadbeef01 to origin
34012:00:02 pushed commit cafebabe02 to origin
34112:00:03 pushed commit 0ddba11f03 to origin
34212:00:04 pushed commit feedface04 to origin\n";
343        let result = log_template_collapse(input, 3).unwrap();
344        assert_eq!(result.runs_collapsed, 0, "{}", result.text);
345        for id in ["deadbeef01", "cafebabe02", "0ddba11f03", "feedface04"] {
346            assert!(result.text.contains(id));
347        }
348    }
349
350    #[test]
351    fn template_never_collapses_distinct_filenames() {
352        let input = "\
353-rw-r--r-- 1 u staff 120 Aug 15 12:00 alpha.rs
354-rw-r--r-- 1 u staff 340 Aug 15 12:01 beta.rs
355-rw-r--r-- 1 u staff 560 Aug 15 12:02 gamma.rs
356-rw-r--r-- 1 u staff 780 Aug 15 12:03 delta.rs\n";
357        let result = log_template_collapse(input, 3).unwrap();
358        for name in ["alpha.rs", "beta.rs", "gamma.rs", "delta.rs"] {
359            assert!(result.text.contains(name), "filename lost: {}", result.text);
360        }
361    }
362
363    #[test]
364    fn template_leaves_identical_lines_to_exact_rle() {
365        let input = "same line\nsame line\nsame line\nsame line\n";
366        let result = log_template_collapse(input, 3).unwrap();
367        assert_eq!(result.runs_collapsed, 0);
368        assert_eq!(result.text, input);
369    }
370
371    #[test]
372    fn template_short_runs_untouched() {
373        let input = "12:00:01 tick\n12:00:02 tick\nother\n";
374        let result = log_template_collapse(input, 3).unwrap();
375        assert_eq!(result.text, input);
376    }
377
378    // --- RLE tests ---
379
380    #[test]
381    fn test_rle_no_repetition() {
382        let result = rle_compress("alpha\nbeta\ngamma\n", 2).unwrap();
383        assert_eq!(result.runs_collapsed, 0);
384    }
385
386    #[test]
387    fn test_rle_exact_repetition() {
388        let input = "ok\nok\nok\nok\nok\n";
389        let result = rle_compress(input, 2).unwrap();
390        assert!(result.runs_collapsed > 0);
391        assert!(result.text.contains("[×5]"), "output: {}", result.text);
392    }
393
394    #[test]
395    fn test_rle_pattern_no_longer_collapses() {
396        // Pattern-run collapsing was removed because it was lossy — it
397        // replaced varying filename/identifier suffixes with "N unique
398        // values", dropping data the LLM needs. Different lines that
399        // merely share a prefix must pass through unchanged.
400        let input = "Compiling foo v1.0\nCompiling bar v2.0\nCompiling baz v3.0\ndone\n";
401        let result = rle_compress(input, 2).unwrap();
402        assert_eq!(result.runs_collapsed, 0, "different lines must not be collapsed");
403        assert!(result.text.contains("foo"), "filename 'foo' must survive: {}", result.text);
404        assert!(result.text.contains("bar"), "filename 'bar' must survive: {}", result.text);
405        assert!(result.text.contains("baz"), "filename 'baz' must survive: {}", result.text);
406    }
407
408    #[test]
409    fn test_rle_ls_l_output_preserves_filenames() {
410        // Regression for https://github.com/ojuschugh1/sqz/issues/1
411        // ls -l lines share the 'drwxr-xr-x' prefix but have distinct
412        // filenames. Pattern-run collapsing used to delete them.
413        let input = "drwxr-xr-x  6 user user  192 Apr 18 10:00 packages\n\
414                     drwxr-xr-x  3 user user   96 Apr 18 10:00 configuration\n\
415                     drwxr-xr-x  4 user user  128 Apr 18 10:00 documentation\n\
416                     drwxr-xr-x  2 user user   64 Apr 18 10:00 environment\n";
417        let result = rle_compress(input, 2).unwrap();
418        for name in &["packages", "configuration", "documentation", "environment"] {
419            assert!(result.text.contains(name),
420                "filename '{}' must be preserved — got:\n{}", name, result.text);
421        }
422    }
423
424    #[test]
425    fn test_rle_mixed_content() {
426        let input = "header\ntest a ... ok\ntest b ... ok\ntest c ... ok\nfooter\n";
427        let result = rle_compress(input, 2).unwrap();
428        assert!(result.text.contains("header"));
429        assert!(result.text.contains("footer"));
430    }
431
432    #[test]
433    fn test_rle_preserves_trailing_newline() {
434        let result = rle_compress("a\na\na\n", 2).unwrap();
435        assert!(result.text.ends_with('\n'));
436    }
437
438    #[test]
439    fn test_rle_empty_input() {
440        let result = rle_compress("", 2).unwrap();
441        assert_eq!(result.text, "");
442        assert_eq!(result.runs_collapsed, 0);
443    }
444
445    #[test]
446    fn test_rle_single_line() {
447        let result = rle_compress("hello\n", 2).unwrap();
448        assert_eq!(result.runs_collapsed, 0);
449    }
450
451    #[test]
452    fn test_rle_min_run_length_respected() {
453        let input = "ok\nok\ndone\n";
454        let result_2 = rle_compress(input, 2).unwrap();
455        let result_3 = rle_compress(input, 3).unwrap();
456        assert!(result_2.runs_collapsed > 0, "run of 2 should collapse with min=2");
457        assert_eq!(result_3.runs_collapsed, 0, "run of 2 should NOT collapse with min=3");
458    }
459
460    #[test]
461    fn test_rle_counts_runs_of_the_original() {
462        let original = format!("x\nx\n{}b\n{}", "a\n".repeat(10), "c\n".repeat(3));
463        let condensed = "x\nx\na\na\na\nb\nc\nc\nc\n";
464        let result = rle_compress_with_original(condensed, Some(&original), 3).unwrap();
465        assert_eq!(result.text, "x\nx\na [×10]\nb\nc [×3]\n");
466        assert_eq!(rle_compress(condensed, 3).unwrap().text, "x\nx\na [×3]\nb\nc [×3]\n");
467    }
468
469    // --- Sliding window dedup tests ---
470
471    #[test]
472    fn test_sliding_window_no_duplicates() {
473        let result = sliding_window_dedup("line 1\nline 2\nline 3\n", 3).unwrap();
474        assert_eq!(result.dedup_count, 0);
475    }
476
477    #[test]
478    fn test_sliding_window_exact_duplicate_lines() {
479        let input = "error: connection refused at port 8080\nsome other line\nerror: connection refused at port 8080\n";
480        let result = sliding_window_dedup(input, 3).unwrap();
481        assert!(result.dedup_count > 0, "should detect duplicate line");
482        assert!(result.text.contains("[→L1]"), "output: {}", result.text);
483    }
484
485    #[test]
486    fn test_sliding_window_preserves_first_occurrence() {
487        let input = "the error message here\nother stuff\nthe error message here\n";
488        let result = sliding_window_dedup(input, 3).unwrap();
489        // First occurrence should be preserved verbatim
490        assert!(result.text.starts_with("the error message here\n"));
491    }
492
493    #[test]
494    fn test_sliding_window_short_lines_ignored() {
495        let input = "ok\nok\nok\n";
496        let result = sliding_window_dedup(input, 3).unwrap();
497        // "ok" is too short (< min_match_words * 3 = 9 chars)
498        assert_eq!(result.dedup_count, 0);
499    }
500
501    #[test]
502    fn test_sliding_window_empty_input() {
503        let result = sliding_window_dedup("", 3).unwrap();
504        assert_eq!(result.text, "");
505    }
506
507    use proptest::prelude::*;
508
509    proptest! {
510        /// RLE never produces output longer than input (excluding the run markers).
511        #[test]
512        fn prop_rle_tokens_saved_non_negative(
513            text in "[a-z]{3,10}(\n[a-z]{3,10}){2,10}\n"
514        ) {
515            let result = rle_compress(&text, 2).unwrap();
516            // tokens_saved is u32, always >= 0
517            let _ = result.tokens_saved;
518            // Output should not be empty if input isn't
519            prop_assert!(!result.text.is_empty());
520        }
521
522        /// Sliding window dedup count is non-negative and bounded.
523        #[test]
524        fn prop_sliding_window_bounded(
525            text in "[a-z ]{10,50}(\n[a-z ]{10,50}){2,8}\n"
526        ) {
527            let result = sliding_window_dedup(&text, 3).unwrap();
528            let line_count = text.lines().count();
529            prop_assert!(
530                result.dedup_count <= line_count,
531                "dedup count {} exceeds line count {}",
532                result.dedup_count, line_count
533            );
534        }
535    }
536}