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