Skip to main content

sqz_engine/
verifier.rs

1/// Two-pass compression verifier.
2///
3/// After compression, the verifier checks that critical information was
4/// preserved. If confidence is below the threshold, it signals the caller
5/// to fall back to a safer (less aggressive) compression mode.
6///
7/// Checks performed:
8/// 1. Required JSON keys present (if original was JSON)
9/// 2. Numeric fields unchanged (no value corruption)
10/// 3. Error/warning lines retained (critical signal preservation)
11/// 4. Diff hunk headers present (if input was a git diff)
12/// 5. File paths preserved (no path truncation)
13/// 6. Minimum content retention (output not too short vs input)
14/// 7. Identifier/path/URL preservation — deterministic token scan covering
15///    filesystem paths, URLs, backtick-quoted code identifiers, environment
16///    variable names, and version numbers. Added after the sessions that
17///    produced the `packages → pkgs` / `configuration/` / `repository/` bug
18///    class — the idea (post-compression preservation check) was prompted
19///    by caveman-compress's validate.py, but the scan mechanism, inputs,
20///    and integration point are sqz-specific.
21
22use crate::text_search;
23use crate::types::VerifyResult;
24
25/// Confidence threshold below which fallback is triggered.
26const FALLBACK_THRESHOLD: f64 = 0.6;
27
28pub struct Verifier;
29
30impl Verifier {
31    /// Run all invariant checks on `original` → `compressed`.
32    ///
33    /// Returns a `VerifyResult` with confidence score and check details.
34    /// If `result.confidence < FALLBACK_THRESHOLD`, the caller should
35    /// re-compress with a safer preset.
36    pub fn verify(original: &str, compressed: &str) -> VerifyResult {
37        let mut passed = Vec::new();
38        let mut failed = Vec::new();
39
40        // Check 1: Minimum content retention (output must be ≥ 10% of input length)
41        let retention = if original.is_empty() {
42            1.0
43        } else {
44            compressed.len() as f64 / original.len() as f64
45        };
46        if retention >= 0.10 {
47            passed.push("min_retention".to_string());
48        } else {
49            failed.push((
50                "min_retention".to_string(),
51                format!("output is only {:.1}% of input length", retention * 100.0),
52            ));
53        }
54
55        // Check 2: Error/warning lines retained
56        let error_lines: Vec<&str> = original
57            .lines()
58            .filter(|l| {
59                let lower = l.to_lowercase();
60                lower.contains("error:") || lower.contains("warning:") || lower.contains("fatal:")
61                    || lower.contains("panic:") || lower.contains("exception:")
62            })
63            .collect();
64        if error_lines.is_empty() {
65            passed.push("error_lines".to_string());
66        } else {
67            let trimmed: Vec<&str> = error_lines.iter().map(|l| l.trim()).collect();
68            let missing = text_search::present(compressed, &trimmed)
69                .iter()
70                .filter(|&&p| !p)
71                .count();
72            if missing == 0 {
73                passed.push("error_lines".to_string());
74            } else {
75                failed.push((
76                    "error_lines".to_string(),
77                    format!("{missing} error/warning line(s) missing from output"),
78                ));
79            }
80        }
81
82        // Check 3: File paths preserved (lines containing / or \ with extension)
83        let path_lines: Vec<&str> = original
84            .lines()
85            .filter(|l| {
86                (l.contains('/') || l.contains('\\'))
87                    && l.chars().any(|c| c == '.')
88                    && l.len() < 200 // skip very long lines
89            })
90            .take(20) // only check first 20 path-like lines
91            .collect();
92        if path_lines.is_empty() {
93            passed.push("file_paths".to_string());
94        } else {
95            let missing_paths = path_lines
96                .iter()
97                .filter(|&&line| {
98                    // Extract the path-like token and check it's in the output
99                    let token = line.split_whitespace()
100                        .find(|t| t.contains('/') || t.contains('\\'))
101                        .unwrap_or("");
102                    !token.is_empty() && !compressed.contains(token)
103                })
104                .count();
105            if missing_paths == 0 {
106                passed.push("file_paths".to_string());
107            } else {
108                failed.push((
109                    "file_paths".to_string(),
110                    format!("{missing_paths} file path(s) missing from output"),
111                ));
112            }
113        }
114
115        // Check 4: JSON key preservation (if original is JSON)
116        // We check that the compressed output contains at least 50% of the
117        // original top-level keys. Intentionally stripped keys are expected to be absent.
118        let orig_trimmed = original.trim();
119        if orig_trimmed.starts_with('{') || orig_trimmed.starts_with('[') {
120            if let Ok(orig_val) = serde_json::from_str::<serde_json::Value>(orig_trimmed) {
121                let orig_keys = collect_top_level_keys(&orig_val);
122                if orig_keys.is_empty() {
123                    passed.push("json_keys".to_string());
124                } else {
125                    let present: usize = orig_keys
126                        .iter()
127                        .filter(|&&k| compressed.contains(k))
128                        .count();
129                    let retention_ratio = present as f64 / orig_keys.len() as f64;
130                    // Pass if at least 50% of original keys are present
131                    if retention_ratio >= 0.5 {
132                        passed.push("json_keys".to_string());
133                    } else {
134                        let missing: Vec<&str> = orig_keys
135                            .iter()
136                            .filter(|&&k| !compressed.contains(k))
137                            .copied()
138                            .collect();
139                        failed.push((
140                            "json_keys".to_string(),
141                            format!("only {:.0}% of JSON keys retained; missing: {:?}",
142                                retention_ratio * 100.0,
143                                &missing[..missing.len().min(5)]),
144                        ));
145                    }
146                }
147            } else {
148                passed.push("json_keys".to_string()); // not valid JSON, skip
149            }
150        } else {
151            passed.push("json_keys".to_string()); // not JSON, skip
152        }
153
154        // Check 5: Diff hunk headers preserved (if input is a git diff)
155        let hunk_headers: Vec<&str> = original
156            .lines()
157            .filter(|l| l.starts_with("@@"))
158            .collect();
159        if hunk_headers.is_empty() {
160            passed.push("diff_hunks".to_string());
161        } else {
162            let missing_hunks = text_search::present(compressed, &hunk_headers)
163                .iter()
164                .filter(|&&p| !p)
165                .count();
166            if missing_hunks == 0 {
167                passed.push("diff_hunks".to_string());
168            } else {
169                failed.push((
170                    "diff_hunks".to_string(),
171                    format!("{missing_hunks} diff hunk header(s) missing"),
172                ));
173            }
174        }
175
176        // Check 6: Numeric values preserved (spot-check first 10 numbers)
177        let numbers: Vec<&str> = original
178            .split(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
179            .filter(|s| !s.is_empty() && s.len() >= 2 && s.parse::<f64>().is_ok())
180            .take(10)
181            .collect();
182        if numbers.is_empty() {
183            passed.push("numeric_values".to_string());
184        } else {
185            let missing_nums = numbers
186                .iter()
187                .filter(|&&n| !compressed.contains(n))
188                .count();
189            if missing_nums == 0 {
190                passed.push("numeric_values".to_string());
191            } else {
192                failed.push((
193                    "numeric_values".to_string(),
194                    format!("{missing_nums} numeric value(s) missing from output"),
195                ));
196            }
197        }
198
199        // Check 7: Preservation tokens — identifier-shaped substrings the
200        // model may dereference (paths, URLs, backticked code, env vars,
201        // version numbers). See the session fixes for `packages/`,
202        // `configuration/`, `repository/` — this check catches that bug
203        // class deterministically rather than waiting for user reports.
204        //
205        // We require at least 85% of preservation tokens to survive. Lower
206        // than 100% because: (1) dedup may have collapsed a repeated path
207        // into a `§ref:…§` marker intentionally, and (2) the scanner is
208        // heuristic and may flag a token that the pipeline legitimately
209        // rewrote (e.g. long base64 truncated by entropy_truncate).
210        let preservation_tokens = extract_preservation_tokens(original);
211        if preservation_tokens.is_empty() {
212            passed.push("preservation".to_string());
213        } else {
214            let tokens: Vec<&str> = preservation_tokens.iter().map(String::as_str).collect();
215            let hits = text_search::present(compressed, &tokens);
216            let present = hits.iter().filter(|&&p| p).count();
217            let total = preservation_tokens.len();
218            let ratio = present as f64 / total as f64;
219            if ratio >= 0.85 {
220                passed.push("preservation".to_string());
221            } else {
222                let missing: Vec<&str> = tokens
223                    .iter()
224                    .zip(&hits)
225                    .filter(|(_, &p)| !p)
226                    .take(5)
227                    .map(|(&t, _)| t)
228                    .collect();
229                failed.push((
230                    "preservation".to_string(),
231                    format!(
232                        "only {}/{} preservation tokens retained ({:.0}%); missing: {:?}",
233                        present, total, ratio * 100.0, missing,
234                    ),
235                ));
236            }
237        }
238
239        // Compute confidence: ratio of passed checks to total checks.
240        //
241        // Special case: preservation is a "sentinel" check. If it fails, the
242        // LLM will likely try to dereference a token (filename, URL, identifier)
243        // that no longer exists in the compressed output, which causes
244        // cascading failures in agent sessions (the exact bug class that
245        // produced fixes fd4603d, f6dc86c, b8bd0d7). Cap confidence at 0.5
246        // when preservation fails so that the fallback kicks in even when
247        // the other 6 checks pass.
248        let total = passed.len() + failed.len();
249        let mut confidence = if total == 0 {
250            1.0
251        } else {
252            passed.len() as f64 / total as f64
253        };
254        let preservation_failed = failed.iter().any(|(k, _)| k == "preservation");
255        if preservation_failed {
256            confidence = confidence.min(0.5);
257        }
258
259        let fallback_triggered = confidence < FALLBACK_THRESHOLD;
260
261        VerifyResult {
262            passed: failed.is_empty(),
263            confidence,
264            checks_passed: passed,
265            checks_failed: failed,
266            fallback_triggered,
267        }
268    }
269
270    /// Check if a verify result warrants fallback to safer compression.
271    pub fn should_fallback(result: &VerifyResult) -> bool {
272        result.fallback_triggered
273    }
274}
275
276fn collect_top_level_keys(value: &serde_json::Value) -> Vec<&str> {
277    match value {
278        serde_json::Value::Object(map) => map.keys().map(|k| k.as_str()).collect(),
279        _ => vec![],
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Preservation-token extractor
285// ---------------------------------------------------------------------------
286//
287// Pulls out identifier-shaped substrings from input that the LLM may try to
288// dereference. The verifier requires these tokens to appear somewhere in the
289// compressed output — if too many go missing, the compression is rejected
290// and the caller falls back to Safe mode.
291//
292// Design notes:
293// - Byte-level ASCII scan. No regex crate dependency; avoids ReDoS.
294// - Conservative by design: false positives (over-preservation) cause at
295//   worst a missed compression opportunity, whereas false negatives cause
296//   silent data loss. We err toward flagging more.
297// - Deduplicates tokens (each unique token counted once).
298// - Caps the scan at 1 MB of input to bound worst-case runtime. Larger
299//   inputs are truncated for scanning only; the actual compression still
300//   processes the full input.
301
302const MAX_SCAN_BYTES: usize = 1024 * 1024;
303const MAX_TOKENS: usize = 500;
304
305/// Scan input for preservation tokens: filesystem paths, URLs, backtick-quoted
306/// code identifiers, environment variable names, and version numbers.
307fn extract_preservation_tokens(input: &str) -> Vec<String> {
308    let scan = crate::text_boundary::truncate_str(input, MAX_SCAN_BYTES);
309    let bytes = scan.as_bytes();
310    let mut tokens: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
311
312    let mut i = 0;
313    while i < bytes.len() && tokens.len() < MAX_TOKENS {
314        let b = bytes[i];
315
316        // Backtick-quoted identifier: `foo_bar`, `Type::method`, `api::v1`
317        if b == b'`' {
318            if let Some(end) = find_closing(bytes, i + 1, b'`') {
319                let slice = &scan[i + 1..end];
320                // Must look like an identifier (not prose): contain at least
321                // one non-space char and no spaces unless it's path-like.
322                if !slice.is_empty()
323                    && slice.len() <= 200
324                    && is_identifier_or_path_content(slice)
325                {
326                    tokens.insert(slice.to_string());
327                }
328                i = end + 1;
329                continue;
330            }
331        }
332
333        // Environment variable: $HOME, $PATH, ${FOO}
334        if b == b'$' && i + 1 < bytes.len() {
335            let next = bytes[i + 1];
336            if next == b'{' {
337                if let Some(end) = find_closing(bytes, i + 2, b'}') {
338                    let slice = &scan[i + 2..end];
339                    if is_env_name(slice) {
340                        tokens.insert(format!("${{{}}}", slice));
341                    }
342                    i = end + 1;
343                    continue;
344                }
345            } else if next.is_ascii_uppercase() || next == b'_' {
346                // Bare $VARNAME — consume uppercase/underscore/digit run
347                let start = i + 1;
348                let mut j = start;
349                while j < bytes.len()
350                    && (bytes[j].is_ascii_uppercase()
351                        || bytes[j] == b'_'
352                        || bytes[j].is_ascii_digit())
353                {
354                    j += 1;
355                }
356                if j > start {
357                    tokens.insert(format!("${}", &scan[start..j]));
358                    i = j;
359                    continue;
360                }
361            }
362        }
363
364        // URL: detect common protocol prefixes
365        if is_url_start(bytes, i) {
366            let end = scan_url_end(bytes, i);
367            if end > i + 8 {
368                // min "http://x"
369                tokens.insert(scan[i..end].to_string());
370                i = end;
371                continue;
372            }
373        }
374
375        // Path or path-like token. Must have at least one '/' with alphanum
376        // on both sides. Starts with '/', '.', or alphanum; ends at whitespace
377        // or unambiguous terminator.
378        if is_path_start(bytes, i) {
379            let end = scan_path_end(bytes, i);
380            if end > i {
381                let slice = &scan[i..end];
382                // Require at least one '/' for path-ness.
383                if slice.contains('/') && is_plausible_path(slice) {
384                    tokens.insert(slice.to_string());
385                    i = end;
386                    continue;
387                }
388            }
389        }
390
391        // Version number: digit.digit(.digit)+, optional 'v' prefix, optional suffix
392        if b.is_ascii_digit() || (b == b'v' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit()) {
393            let end = scan_version_end(bytes, i);
394            if end > i {
395                let slice = &scan[i..end];
396                if is_version(slice) {
397                    tokens.insert(slice.to_string());
398                    i = end;
399                    continue;
400                }
401            }
402        }
403
404        i += 1;
405    }
406
407    tokens.into_iter().collect()
408}
409
410fn find_closing(bytes: &[u8], start: usize, target: u8) -> Option<usize> {
411    // Look up to 256 bytes forward for the closing char. Anything longer
412    // than that is almost certainly not a single token — give up.
413    let end = (start + 256).min(bytes.len());
414    bytes[start..end].iter().position(|&b| b == target).map(|off| start + off)
415}
416
417fn is_identifier_or_path_content(s: &str) -> bool {
418    // Reject if it's mostly whitespace or contains multiple spaces (prose).
419    // Allow single spaces (e.g. `some command --flag` in docs is prose, but
420    // `cargo test` in docs is a command; we treat both conservatively as
421    // preserve-worthy since splitting would lose the intent).
422    let space_count = s.bytes().filter(|&b| b == b' ').count();
423    if space_count > 3 {
424        return false;
425    }
426    // Must contain at least one "identifier-ish" byte.
427    s.bytes().any(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'/' || b == b':')
428}
429
430fn is_env_name(s: &str) -> bool {
431    !s.is_empty()
432        && s.len() <= 64
433        && s.bytes()
434            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
435}
436
437fn is_url_start(bytes: &[u8], i: usize) -> bool {
438    const PREFIXES: &[&[u8]] = &[
439        b"https://", b"http://", b"git://", b"ssh://", b"ftp://",
440        b"file://", b"git@", b"ws://", b"wss://",
441    ];
442    PREFIXES.iter().any(|p| bytes[i..].starts_with(p))
443}
444
445fn scan_url_end(bytes: &[u8], start: usize) -> usize {
446    // URL ends at whitespace, quote, angle bracket, comma followed by space,
447    // or closing paren/bracket at end-of-sentence. Cap at 2KB.
448    let cap = (start + 2048).min(bytes.len());
449    let mut i = start;
450    while i < cap {
451        let b = bytes[i];
452        if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r'
453            || b == b'"' || b == b'\'' || b == b'<' || b == b'>'
454            || b == b'`'
455        {
456            break;
457        }
458        i += 1;
459    }
460    // Trim trailing punctuation that's usually sentence, not URL.
461    while i > start {
462        let last = bytes[i - 1];
463        if last == b'.' || last == b',' || last == b';' || last == b':'
464            || last == b')' || last == b']' || last == b'!' || last == b'?'
465        {
466            i -= 1;
467        } else {
468            break;
469        }
470    }
471    i
472}
473
474fn is_path_start(bytes: &[u8], i: usize) -> bool {
475    if i >= bytes.len() {
476        return false;
477    }
478    // Path start can't follow an alphanum (otherwise we'd split identifiers).
479    if i > 0 && bytes[i - 1].is_ascii_alphanumeric() {
480        return false;
481    }
482    let b = bytes[i];
483    b == b'/' || b == b'.' || b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
484}
485
486fn scan_path_end(bytes: &[u8], start: usize) -> usize {
487    // Path characters: alphanum, '_', '-', '.', '/', and nothing else.
488    // Cap at 512 bytes to avoid pathological inputs.
489    let cap = (start + 512).min(bytes.len());
490    let mut i = start;
491    while i < cap {
492        let b = bytes[i];
493        if b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/' {
494            i += 1;
495        } else {
496            break;
497        }
498    }
499    // Trim trailing '.' that's probably sentence punctuation.
500    while i > start && bytes[i - 1] == b'.' {
501        i -= 1;
502    }
503    i
504}
505
506fn is_plausible_path(s: &str) -> bool {
507    let bytes = s.as_bytes();
508    let slash = match bytes.iter().position(|&b| b == b'/') {
509        Some(p) => p,
510        None => return false,
511    };
512    // Require: something before the slash (so "/" alone isn't a path), OR
513    // content after the slash (so a path can start with '/'). Also require
514    // at least one alphabetic byte anywhere, to reject "3/4" fractions.
515    let has_before = slash > 0;
516    let has_after = slash + 1 < bytes.len();
517    if !has_before && !has_after {
518        return false;
519    }
520    bytes.iter().any(|b| b.is_ascii_alphabetic())
521}
522
523fn scan_version_end(bytes: &[u8], start: usize) -> usize {
524    let cap = (start + 64).min(bytes.len());
525    let mut i = start;
526    while i < cap {
527        let b = bytes[i];
528        if b.is_ascii_alphanumeric() || b == b'.' || b == b'-' {
529            i += 1;
530        } else {
531            break;
532        }
533    }
534    i
535}
536
537fn is_version(s: &str) -> bool {
538    // Semver-ish: needs at least two '.' with digits on both sides.
539    // Optional leading 'v'. Optional pre-release suffix.
540    let trimmed = s.strip_prefix('v').unwrap_or(s);
541    let dots = trimmed.bytes().filter(|&b| b == b'.').count();
542    if dots < 2 {
543        return false;
544    }
545    // First segment must be digits.
546    let first_segment: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect();
547    if first_segment.is_empty() {
548        return false;
549    }
550    // Second segment must start with digit (after first '.').
551    let after_first: &str = &trimmed[first_segment.len() + 1..];
552    after_first.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
553}
554
555// ---------------------------------------------------------------------------
556// Tests
557// ---------------------------------------------------------------------------
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn verify_identical_passes_all() {
565        let text = "error: something went wrong\nfile: src/main.rs\n";
566        let result = Verifier::verify(text, text);
567        assert!(result.passed);
568        assert!((result.confidence - 1.0).abs() < f64::EPSILON);
569        assert!(!result.fallback_triggered);
570    }
571
572    #[test]
573    fn verify_empty_input_passes() {
574        let result = Verifier::verify("", "");
575        assert!(result.passed);
576    }
577
578    #[test]
579    fn verify_detects_missing_error_line() {
580        let original = "error: connection refused\nsome other content here\n";
581        let compressed = "some other content here\n"; // error line stripped
582        let result = Verifier::verify(original, compressed);
583        assert!(!result.passed);
584        assert!(result.checks_failed.iter().any(|(k, _)| k == "error_lines"));
585    }
586
587    #[test]
588    fn verify_detects_over_compression() {
589        // Use content with multiple checkable markers so more checks fail
590        let original = "error: critical failure at line 42\n@@ -1,5 +1,5 @@\n/path/to/file.rs\nvalue: 12345\n".repeat(20);
591        let compressed = "x"; // almost nothing retained
592        let result = Verifier::verify(&original, compressed);
593        assert!(!result.passed);
594        assert!(result.checks_failed.iter().any(|(k, _)| k == "min_retention"));
595        assert!(result.fallback_triggered, "should trigger fallback: confidence={:.2}", result.confidence);
596    }
597
598    #[test]
599    fn verify_json_keys_preserved() {
600        let original = r#"{"id":1,"name":"Alice","status":"active"}"#;
601        let compressed = r#"TOON:{id:1,name:"Alice",status:"active"}"#;
602        let result = Verifier::verify(original, compressed);
603        assert!(result.checks_passed.contains(&"json_keys".to_string()));
604    }
605
606    #[test]
607    fn verify_detects_missing_json_keys() {
608        let original = r#"{"id":1,"name":"Alice","status":"active","role":"admin","email":"a@b.com","created":"2024-01-01"}"#;
609        let compressed = r#"TOON:{id:1}"#; // only 1 of 6 keys retained (17%)
610        let result = Verifier::verify(original, compressed);
611        assert!(result.checks_failed.iter().any(|(k, _)| k == "json_keys"),
612            "should fail json_keys when <50% of keys retained");
613    }
614
615    #[test]
616    fn verify_diff_hunks_preserved() {
617        let original = "@@ -1,5 +1,5 @@\n-old\n+new\n context\n";
618        let compressed = "@@ -1,5 +1,5 @@\n-old\n+new\n";
619        let result = Verifier::verify(original, compressed);
620        assert!(result.checks_passed.contains(&"diff_hunks".to_string()));
621    }
622
623    #[test]
624    fn verify_detects_missing_diff_hunks() {
625        let original = "@@ -1,5 +1,5 @@\n-old\n+new\n";
626        let compressed = "-old\n+new\n"; // hunk header stripped
627        let result = Verifier::verify(original, compressed);
628        assert!(result.checks_failed.iter().any(|(k, _)| k == "diff_hunks"));
629    }
630
631    #[test]
632    fn fallback_threshold_triggers_correctly() {
633        // Create a result that fails most checks
634        let original = "error: critical failure\n@@ -1,5 +1,5 @@\n/path/to/file.rs:42\n";
635        let compressed = "x"; // almost nothing retained
636        let result = Verifier::verify(original, compressed);
637        assert!(result.fallback_triggered, "should trigger fallback on low confidence");
638    }
639
640    // ── Real-world coding session patterns ────────────────────────────────
641
642    #[test]
643    fn verify_cargo_test_output_preserved() {
644        let original = "running 47 tests\ntest engine::tests::test_compress ... ok\ntest pipeline::tests::compress_json ... ok\ntest result: ok. 47 passed; 0 failed; 0 ignored; finished in 2.34s\n";
645        let compressed = "47 tests\ntest result: ok. 47 passed; 0 failed; finished in 2.34s\n";
646        let result = Verifier::verify(original, compressed);
647        // Should pass — key info retained, no error lines, no JSON
648        assert!(result.confidence >= 0.7, "cargo test output should verify well: {:.2}", result.confidence);
649    }
650
651    #[test]
652    fn verify_rust_compile_error_preserved() {
653        let original = "error[E0308]: mismatched types\n --> src/main.rs:42:5\n  |\n42 |     let x: i32 = \"hello\";\n  |                  ^^^^^^^ expected `i32`, found `&str`\n\nerror: aborting due to previous error\n";
654        let compressed = "error[E0308]: mismatched types\n --> src/main.rs:42:5\nerror: aborting due to previous error\n";
655        let result = Verifier::verify(original, compressed);
656        // Error lines must be retained
657        assert!(result.checks_passed.contains(&"error_lines".to_string()),
658            "error lines should be preserved");
659    }
660
661    #[test]
662    fn verify_git_log_output() {
663        let original = "commit a1b2c3d4\nAuthor: Ojus Chugh <ojuschugh@gmail.com>\nDate:   Sun Apr 12 10:00:00 2026\n\n    feat: Add compression engine\n\ncommit b2c3d4e5\nAuthor: Ojus Chugh <ojuschugh@gmail.com>\nDate:   Sat Apr 11 15:30:00 2026\n\n    fix: Handle edge case\n";
664        let compressed = "commit a1b2c3d4\n    feat: Add compression engine\ncommit b2c3d4e5\n    fix: Handle edge case\n";
665        let result = Verifier::verify(original, compressed);
666        assert!(result.confidence >= 0.7, "git log should verify well: {:.2}", result.confidence);
667    }
668
669    #[test]
670    fn verify_json_api_with_stripped_nulls() {
671        // Simulates what the pipeline does: strip null fields, TOON encode
672        let original = r#"{"id":1,"name":"Alice","debug_info":null,"trace_id":null,"status":"active"}"#;
673        let compressed = r#"TOON:{id:1,name:"Alice",status:"active"}"#;
674        let result = Verifier::verify(original, compressed);
675        // 3 of 5 keys retained (60%) — should pass the 50% threshold
676        assert!(result.checks_passed.contains(&"json_keys".to_string()),
677            "60% key retention should pass: {:?}", result.checks_failed);
678    }
679
680    // ── Preservation-token extractor tests ──────────────────────────────
681
682    #[test]
683    fn extract_detects_absolute_paths() {
684        let tokens = extract_preservation_tokens("see /etc/myapp/config.yml for details");
685        assert!(tokens.contains(&"/etc/myapp/config.yml".to_string()),
686            "absolute path should be extracted: {:?}", tokens);
687    }
688
689    #[test]
690    fn extract_detects_relative_paths() {
691        let tokens = extract_preservation_tokens("edit src/main.rs and tests/util.rs");
692        assert!(tokens.contains(&"src/main.rs".to_string()), "{:?}", tokens);
693        assert!(tokens.contains(&"tests/util.rs".to_string()), "{:?}", tokens);
694    }
695
696    #[test]
697    fn extract_detects_directory_listing_entries() {
698        // The Reddit case: `packages/` directory listing
699        let input = "drwxr-xr-x  user staff  192 Apr 18 packages/\n\
700                     drwxr-xr-x  user staff   96 Apr 18 configuration/\n";
701        let tokens = extract_preservation_tokens(input);
702        // These should appear as path-like tokens (they have `/`)
703        assert!(tokens.iter().any(|t| t.contains("packages")),
704            "should extract packages: {:?}", tokens);
705        assert!(tokens.iter().any(|t| t.contains("configuration")),
706            "should extract configuration: {:?}", tokens);
707    }
708
709    #[test]
710    fn extract_detects_urls() {
711        let input = "clone from https://github.com/example/repository and \
712                     read https://docs.example.com/guide.";
713        let tokens = extract_preservation_tokens(input);
714        assert!(tokens.contains(&"https://github.com/example/repository".to_string()),
715            "{:?}", tokens);
716        assert!(tokens.iter().any(|t| t.starts_with("https://docs.example.com")),
717            "{:?}", tokens);
718    }
719
720    #[test]
721    fn extract_detects_backtick_identifiers() {
722        let tokens = extract_preservation_tokens(
723            "use `SqzEngine::new` and `CompressionPipeline::compress`"
724        );
725        assert!(tokens.contains(&"SqzEngine::new".to_string()), "{:?}", tokens);
726        assert!(tokens.contains(&"CompressionPipeline::compress".to_string()), "{:?}", tokens);
727    }
728
729    #[test]
730    fn extract_detects_env_vars() {
731        let tokens = extract_preservation_tokens("set $HOME and ${FOO_BAR} and $PATH");
732        assert!(tokens.contains(&"$HOME".to_string()), "{:?}", tokens);
733        assert!(tokens.contains(&"${FOO_BAR}".to_string()), "{:?}", tokens);
734        assert!(tokens.contains(&"$PATH".to_string()), "{:?}", tokens);
735    }
736
737    #[test]
738    fn extract_detects_version_numbers() {
739        let tokens = extract_preservation_tokens(
740            "upgrade to 1.2.3 from v0.7.0 and pin 2.0.0-beta.1"
741        );
742        assert!(tokens.iter().any(|t| t.starts_with("1.2.3")), "{:?}", tokens);
743        assert!(tokens.iter().any(|t| t.starts_with("v0.7.0")), "{:?}", tokens);
744    }
745
746    #[test]
747    fn extract_ignores_prose() {
748        // Plain prose without paths/URLs/identifiers should produce no tokens
749        let tokens = extract_preservation_tokens(
750            "The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet."
751        );
752        assert!(tokens.is_empty(), "prose should yield no preservation tokens: {:?}", tokens);
753    }
754
755    #[test]
756    fn extract_ignores_fractions_in_prose() {
757        // "3/4 of the way" is not a path
758        let tokens = extract_preservation_tokens("We completed 3/4 of the tasks");
759        assert!(tokens.iter().all(|t| !t.contains("3/4")),
760            "fraction should not be extracted as path: {:?}", tokens);
761    }
762
763    #[test]
764    fn extract_scan_cap_lands_on_a_char_boundary() {
765        let input = format!("{}{}", "x".repeat(MAX_SCAN_BYTES - 1), "日".repeat(50));
766        assert!(Verifier::verify(&input, &input).passed);
767        assert!(extract_preservation_tokens(&input).is_empty());
768    }
769
770    #[test]
771    fn extract_caps_at_max_tokens() {
772        // Generate input with far more than MAX_TOKENS paths
773        let mut input = String::new();
774        for i in 0..1000 {
775            input.push_str(&format!("file_{}/sub_{}.txt ", i, i));
776        }
777        let tokens = extract_preservation_tokens(&input);
778        assert!(tokens.len() <= MAX_TOKENS, "should cap at {MAX_TOKENS}, got {}", tokens.len());
779    }
780
781    // ── Preservation check integration tests (regression for session bugs) ─
782
783    #[test]
784    fn verify_rejects_packages_to_pkgs_rewrite() {
785        // Reddit bug: `packages` directory renamed to `pkgs` in output
786        let original = "drwxr-xr-x  user staff  192 Apr 18 packages/\n\
787                        drwxr-xr-x  user staff  128 Apr 18 documentation/\n";
788        let compressed = "drwxr-xr-x  user staff  192 Apr 18 pkgs/\n\
789                          drwxr-xr-x  user staff  128 Apr 18 docs/\n";
790        let result = Verifier::verify(original, compressed);
791        assert!(
792            result.checks_failed.iter().any(|(k, _)| k == "preservation"),
793            "should fail preservation when packages→pkgs: {:?}", result.checks_failed
794        );
795    }
796
797    #[test]
798    fn verify_rejects_config_path_rewrite() {
799        // /etc/myapp/configuration/ → /etc/myapp/config/
800        let original = "check /etc/myapp/configuration/default.yml for errors";
801        let compressed = "check /etc/myapp/config/default.yml for errors";
802        let result = Verifier::verify(original, compressed);
803        assert!(
804            result.checks_failed.iter().any(|(k, _)| k == "preservation"),
805            "should fail preservation when path segment rewritten: {:?}", result.checks_failed
806        );
807    }
808
809    #[test]
810    fn verify_rejects_github_repo_rewrite() {
811        // github.com/.../repository → github.com/.../repo
812        let original = "origin  https://github.com/example/repository (fetch)";
813        let compressed = "origin  https://github.com/example/repo (fetch)";
814        let result = Verifier::verify(original, compressed);
815        assert!(
816            result.checks_failed.iter().any(|(k, _)| k == "preservation"),
817            "should fail preservation when URL path rewritten: {:?}", result.checks_failed
818        );
819    }
820
821    #[test]
822    fn verify_rejects_drops_filenames_entirely() {
823        // RLE pattern-run bug: 4 directory lines collapsed to a summary
824        let original = "drwxr-xr-x packages/\n\
825                        drwxr-xr-x configuration/\n\
826                        drwxr-xr-x documentation/\n\
827                        drwxr-xr-x environment/\n";
828        let compressed = "drwxr-xr-x ... [×4, varying: 4 unique values]\n";
829        let result = Verifier::verify(original, compressed);
830        assert!(
831            result.checks_failed.iter().any(|(k, _)| k == "preservation"),
832            "should fail preservation when filenames dropped: {:?}", result.checks_failed
833        );
834    }
835
836    #[test]
837    fn verify_accepts_lossless_dedup_output() {
838        // Legitimate dedup: original content is fully represented in output
839        let original = "see /etc/myapp/default.yml and src/main.rs";
840        let compressed = "see /etc/myapp/default.yml and src/main.rs";
841        let result = Verifier::verify(original, compressed);
842        assert!(
843            result.checks_passed.contains(&"preservation".to_string()),
844            "identical content must pass preservation: {:?}", result.checks_failed
845        );
846    }
847
848    #[test]
849    fn verify_accepts_json_null_stripped() {
850        // stripping null fields from JSON should leave paths in values intact
851        let original = r#"{"path":"/etc/foo.yml","debug":null,"log":"/var/log/app.log"}"#;
852        let compressed = r#"TOON:{path:"/etc/foo.yml",log:"/var/log/app.log"}"#;
853        let result = Verifier::verify(original, compressed);
854        assert!(
855            result.checks_passed.contains(&"preservation".to_string()),
856            "null-stripping must not trip preservation: {:?}", result.checks_failed
857        );
858    }
859
860    #[test]
861    fn verify_accepts_empty_input() {
862        // No preservation tokens in empty input → check passes vacuously
863        let result = Verifier::verify("", "");
864        assert!(result.checks_passed.contains(&"preservation".to_string()));
865    }
866
867    #[test]
868    fn preservation_failure_triggers_fallback() {
869        // Even if all other checks pass, a preservation failure must drop
870        // confidence below the fallback threshold. This is the defense we
871        // set up against the bug class from the April 18 audit session
872        // (fd4603d / f6dc86c / b8bd0d7).
873        let original = "commit: check /etc/myapp/configuration/default.yml\n\
874                        file: src/main.rs line 42\n";
875        let compressed = "commit: check /etc/myapp/config/default.yml\n\
876                          file: src/main.rs line 42\n";
877        let result = Verifier::verify(original, compressed);
878        assert!(
879            result.checks_failed.iter().any(|(k, _)| k == "preservation"),
880            "preservation should fail"
881        );
882        assert!(
883            result.fallback_triggered,
884            "preservation failure alone must trigger fallback (confidence={:.2})",
885            result.confidence
886        );
887    }
888
889    #[test]
890    fn verify_is_linear_on_error_dense_input() {
891        let input = |bytes: usize| {
892            let mut s = String::new();
893            let mut i = 0;
894            while s.len() < bytes {
895                s.push_str(&format!(
896                    "worker {i}: error: request {i} timed out after 30s\n"
897                ));
898                s.push_str(&format!("@@ -{i},7 +{i},8 @@ fn handler_{i}()\n"));
899                i += 1;
900            }
901            s
902        };
903        let quote = |s: &str| s.lines().map(|l| format!("> {l}\n")).collect::<String>();
904        let (small, large) = (input(100 << 10), input(1 << 20));
905        let (small_quoted, large_quoted) = (quote(&small), quote(&large));
906        for compressed in [&large, &large_quoted] {
907            let result = Verifier::verify(&large, compressed);
908            assert!(result.checks_passed.contains(&"error_lines".to_string()));
909            assert!(result.checks_passed.contains(&"diff_hunks".to_string()));
910        }
911        let dropped = large_quoted.replace("request 9999 timed", "request timed");
912        let result = Verifier::verify(&large, &dropped);
913        assert!(result
914            .checks_failed
915            .iter()
916            .any(|(k, m)| k == "error_lines" && m.starts_with("1 ")));
917        let check = |input: &str, quoted: &str| {
918            std::hint::black_box(Verifier::verify(input, input));
919            std::hint::black_box(Verifier::verify(input, quoted));
920        };
921        let ratio = crate::text_search::time_ratio(
922            || check(&small, &small_quoted),
923            || check(&large, &large_quoted),
924        );
925        assert!(ratio < 25.0, "1 MB took {ratio:.1}x as long as 100 KB");
926    }
927}