Skip to main content

lean_ctx/core/terse/
engine.rs

1//! Layer 1: Deterministic output compression.
2//!
3//! Replaces the legacy `compress_terse`/`compress_ultra` with a scoring-based
4//! approach that preserves information-dense lines and removes low-value content.
5
6use super::counter;
7use super::dictionaries::{self, DictLevel};
8use super::quality::{self, QualityConfig, QualityReport};
9use super::scoring;
10use crate::core::config::CompressionLevel;
11
12/// Threshold below which a line is considered low-information and may be removed.
13const LOW_SCORE_THRESHOLD: f32 = 2.5;
14
15const STANDARD_SCORE_THRESHOLD: f32 = 3.0;
16const MAX_SCORE_THRESHOLD: f32 = 3.5;
17
18/// Result of Layer 1 compression.
19#[derive(Debug)]
20pub struct EngineResult {
21    pub output: String,
22    pub tokens_before: u32,
23    pub tokens_after: u32,
24    pub quality: QualityReport,
25    pub lines_removed: usize,
26    pub lines_total: usize,
27}
28
29const MIN_LINES_FOR_COMPRESSION: usize = 5;
30
31/// Runs Layer 1 deterministic compression on the input text.
32pub fn compress(text: &str, level: &CompressionLevel) -> EngineResult {
33    let tokens_before = counter::count(text);
34    let lines_total = text.lines().count();
35
36    if !level.is_active() || text.is_empty() || lines_total < MIN_LINES_FOR_COMPRESSION {
37        return EngineResult {
38            output: text.to_string(),
39            tokens_before,
40            tokens_after: tokens_before,
41            quality: quality::check(
42                text,
43                text,
44                tokens_before,
45                tokens_before,
46                &QualityConfig::default(),
47            ),
48            lines_removed: 0,
49            lines_total,
50        };
51    }
52
53    let result = compress_at_level(text, tokens_before, level);
54
55    if result.quality.passed {
56        return result;
57    }
58
59    if matches!(level, CompressionLevel::Max | CompressionLevel::Raw) {
60        let fallback = compress_at_level(text, tokens_before, &CompressionLevel::Standard);
61        if fallback.quality.passed {
62            return fallback;
63        }
64    }
65
66    EngineResult {
67        output: text.to_string(),
68        tokens_before,
69        tokens_after: tokens_before,
70        quality: result.quality,
71        lines_removed: 0,
72        lines_total: text.lines().count(),
73    }
74}
75
76fn compress_at_level(text: &str, tokens_before: u32, level: &CompressionLevel) -> EngineResult {
77    let scores = scoring::score_lines(text);
78    let lines: Vec<&str> = text.lines().collect();
79    let lines_total = lines.len();
80
81    let threshold = match level {
82        CompressionLevel::Max | CompressionLevel::Raw => MAX_SCORE_THRESHOLD,
83        CompressionLevel::Standard => STANDARD_SCORE_THRESHOLD,
84        CompressionLevel::Lite | CompressionLevel::Off => LOW_SCORE_THRESHOLD,
85    };
86
87    let mut kept_lines = Vec::new();
88    let mut lines_removed = 0;
89
90    for (score, line) in scores.iter().zip(lines.iter()) {
91        let trimmed = line.trim();
92
93        if trimmed.is_empty() {
94            lines_removed += 1;
95            continue;
96        }
97
98        // Section delimiters are semantic record boundaries. Dropping one then
99        // joining the surviving lines can attach a later record to an earlier
100        // heading, so they must remain hard breaks regardless of score.
101        if is_section_boundary(trimmed) {
102            kept_lines.push(*line);
103            continue;
104        }
105
106        if is_pure_decoration(trimmed) {
107            lines_removed += 1;
108            continue;
109        }
110
111        if is_filler_line(trimmed) && !score.has_structural_marker {
112            lines_removed += 1;
113            continue;
114        }
115
116        if score.combined < threshold && !score.has_structural_marker {
117            lines_removed += 1;
118            continue;
119        }
120
121        kept_lines.push(*line);
122    }
123
124    let filtered = kept_lines.join("\n");
125
126    let quality_config = match level {
127        CompressionLevel::Max | CompressionLevel::Raw => QualityConfig {
128            min_identifier_preservation: 0.80,
129            ..QualityConfig::default()
130        },
131        _ => QualityConfig::default(),
132    };
133
134    let filtered_tokens = counter::count(&filtered);
135    let quality_report = quality::check(
136        text,
137        &filtered,
138        tokens_before,
139        filtered_tokens,
140        &quality_config,
141    );
142
143    if !quality_report.passed {
144        return EngineResult {
145            output: text.to_string(),
146            tokens_before,
147            tokens_after: tokens_before,
148            quality: quality_report,
149            lines_removed: 0,
150            lines_total,
151        };
152    }
153
154    let dict_level = match level {
155        CompressionLevel::Max | CompressionLevel::Raw | CompressionLevel::Standard => {
156            DictLevel::Full
157        }
158        CompressionLevel::Lite | CompressionLevel::Off => DictLevel::General,
159    };
160    let compressed = dictionaries::apply_dictionaries(&filtered, dict_level);
161    let compressed = match dict_level {
162        DictLevel::Full => super::auto_dict::apply(&compressed).unwrap_or(compressed),
163        DictLevel::General => compressed,
164    };
165    // The critical-fact check runs on the final text, after every rewrite: filtering,
166    // dictionaries and the auto-dictionary all have to keep the facts a reader needs.
167    let critical_lost = quality::critical_facts_lost(text, &compressed);
168    if critical_lost > 0 {
169        return EngineResult {
170            output: text.to_string(),
171            tokens_before,
172            tokens_after: tokens_before,
173            quality: QualityReport {
174                passed: false,
175                critical_lost,
176                ..quality_report
177            },
178            lines_removed: 0,
179            lines_total,
180        };
181    }
182    let tokens_after = counter::count(&compressed);
183
184    EngineResult {
185        output: compressed,
186        tokens_before,
187        tokens_after,
188        quality: quality_report,
189        lines_removed,
190        lines_total,
191    }
192}
193
194fn is_filler_line(line: &str) -> bool {
195    let trimmed = line.trim();
196
197    if trimmed == "|" || trimmed == "| " {
198        return true;
199    }
200
201    let lower = line.to_lowercase();
202    const FILLER_PATTERNS: &[&str] = &[
203        "use \"git add",
204        "use \"git restore",
205        "(use \"git",
206        "run with `rust_backtrace",
207        "for more information about this error",
208        "try `rustc --explain",
209        "run `npm fund`",
210        "run `npm audit`",
211        "to address all issues",
212        "sending build context",
213        "using cache",
214        "packages are looking for funding",
215        "no changes added to commit",
216        "help: ",
217        "= note: ",
218        "---> running in",
219    ];
220    FILLER_PATTERNS.iter().any(|p| lower.contains(p))
221}
222
223fn is_pure_decoration(line: &str) -> bool {
224    let trimmed = line.trim();
225
226    if trimmed.is_empty() {
227        return true;
228    }
229
230    if trimmed.chars().all(|c| c == '|' || c.is_whitespace()) {
231        return true;
232    }
233
234    if line.len() < 3 {
235        return false;
236    }
237
238    if line.starts_with("//") || line.starts_with('#') || line.starts_with("--") {
239        let content = line
240            .trim_start_matches('/')
241            .trim_start_matches('#')
242            .trim_start_matches('-')
243            .trim();
244        return content.is_empty() || is_banner_chars(content);
245    }
246
247    is_banner_chars(line)
248}
249
250/// True when a line uses a common section-header delimiter. These delimiters
251/// separate records in shell output and must never be removed during filtering.
252fn is_section_boundary(line: &str) -> bool {
253    let trimmed = line.trim_start();
254    ["===", "---", "###", "***", "___", ":::"]
255        .iter()
256        .any(|prefix| trimmed.starts_with(prefix))
257}
258
259fn is_banner_chars(line: &str) -> bool {
260    let chars: Vec<char> = line.chars().collect();
261    if chars.len() < 4 {
262        return false;
263    }
264    let first = chars[0];
265    if matches!(
266        first,
267        '=' | '-' | '*' | '─' | '━' | '▀' | '▄' | '╔' | '╚' | '║' | '░' | '█' | '═'
268    ) {
269        let same_count = chars.iter().filter(|c| **c == first).count();
270        return same_count as f64 / chars.len() as f64 > 0.6;
271    }
272    false
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn compress_off_returns_original() {
281        let text = "hello world\n\nsome blank lines\n\n";
282        let result = compress(text, &CompressionLevel::Off);
283        assert_eq!(result.output, text);
284        assert_eq!(result.lines_removed, 0);
285    }
286
287    /// The Cargo dictionary rewrites `test result: FAILED` to `FAIL`. The final-output
288    /// critical-fact check must accept that rewrite rather than fall back to the
289    /// original on every failing test run.
290    #[test]
291    fn dictionary_rewritten_test_summary_keeps_compression() {
292        let text = "running 3 tests\n\n\
293                    test orders::totals ... FAILED\n\n\
294                    test orders::refund ... ok\n\n\
295                    failures:\n\n\
296                    test result: FAILED. 2 passed; 1 failed; 0 ignored\n\n";
297        let result = compress(text, &CompressionLevel::Standard);
298        assert_eq!(result.quality.critical_lost, 0, "{}", result.output);
299        assert_ne!(result.output, text, "compression must not fall back");
300        assert!(result.output.contains("FAIL"), "{}", result.output);
301        assert!(result.output.contains("1 failed"), "{}", result.output);
302    }
303
304    /// Benchmark replay (ledgerlite, Claude Code + Haiku): a passing
305    /// `python3 -m unittest` run reached the model as `---- / Ran 8 tests`
306    /// without its `OK`, and the model re-ran the suite to learn the verdict.
307    #[test]
308    fn unittest_verdict_line_survives_every_level() {
309        let run = "........\n\
310                   ----------------------------------------------------------------------\n\
311                   Ran 8 tests in 0.001s\n\nOK\n";
312        let failed = "..F.....\n\
313                      ----------------------------------------------------------------------\n\
314                      Ran 8 tests in 0.001s\n\nFAILED\n";
315        for level in [
316            CompressionLevel::Lite,
317            CompressionLevel::Standard,
318            CompressionLevel::Max,
319        ] {
320            let ok = compress(run, &level).output;
321            assert!(ok.lines().any(|l| l.trim() == "OK"), "{level:?}: {ok}");
322            assert!(ok.contains("Ran 8 tests"), "{level:?}: {ok}");
323            let bad = compress(failed, &level).output;
324            assert!(bad.contains("FAIL"), "{level:?}: {bad}");
325        }
326    }
327
328    #[test]
329    fn compress_lite_removes_blank_lines() {
330        let text = "line one\n\n\nline two\n\n";
331        let result = compress(text, &CompressionLevel::Lite);
332        assert!(
333            !result.output.contains("\n\n"),
334            "blank lines should be removed"
335        );
336    }
337
338    #[test]
339    fn compress_preserves_paths() {
340        let text = "error in src/main.rs at line 42\n\nsome blank\n\n";
341        let result = compress(text, &CompressionLevel::Standard);
342        assert!(
343            result.output.contains("src/main.rs"),
344            "path must be preserved"
345        );
346    }
347
348    #[test]
349    fn decoration_detection() {
350        assert!(is_pure_decoration("════════════════════"));
351        assert!(is_pure_decoration("--------------------"));
352        assert!(is_pure_decoration("// ================"));
353        assert!(!is_pure_decoration("error: mismatched types"));
354    }
355
356    #[test]
357    fn compress_preserves_multi_section_record_boundaries() {
358        let text = "record alpha payload remains associated with alpha\n\
359                    additional alpha detail for the first record\n\
360                    ===\n\
361                    record beta payload remains associated with beta\n\
362                    additional beta detail for the second record\n\
363                    ---\n\
364                    record gamma payload remains associated with gamma\n\
365                    ###\n\
366                    record delta payload remains associated with delta";
367
368        let result = compress(text, &CompressionLevel::Standard);
369
370        assert!(
371            result.output.contains("==="),
372            "first boundary missing: {}",
373            result.output
374        );
375        assert!(
376            result.output.contains("---"),
377            "second boundary missing: {}",
378            result.output
379        );
380        assert!(
381            result.output.contains("###"),
382            "third boundary missing: {}",
383            result.output
384        );
385        let alpha = result.output.find("record alpha").unwrap();
386        let first_boundary = result.output.find("===").unwrap();
387        let beta = result.output.find("record beta").unwrap();
388        let second_boundary = result.output.find("---").unwrap();
389        let gamma = result.output.find("record gamma").unwrap();
390        let third_boundary = result.output.find("###").unwrap();
391        let delta = result.output.find("record delta").unwrap();
392        assert!(alpha < first_boundary && first_boundary < beta);
393        assert!(beta < second_boundary && second_boundary < gamma);
394        assert!(gamma < third_boundary && third_boundary < delta);
395    }
396
397    #[test]
398    fn compress_returns_token_counts() {
399        let text = "Hello world from the compression engine test";
400        let result = compress(text, &CompressionLevel::Lite);
401        assert!(result.tokens_before > 0);
402    }
403}