Skip to main content

lean_ctx/core/
nc_compress.rs

1//! Non-code compression tuning (`nc-compress-v1`, EPIC 12.14).
2//!
3//! The built-in `identity`/`whitespace` compressors are conservative — right for
4//! code, but they leave easy savings on prose, scraped web text, and Markdown.
5//! This module adds two **lossless-of-meaning** compressors tuned for non-code
6//! corpora, registered through the same
7//! [`extension_registry`](crate::core::extension_registry) path so they are
8//! discoverable and conformance-checked:
9//!
10//! * `prose` — collapse blank-line runs, strip trailing whitespace, collapse
11//!   intra-line whitespace, and drop adjacent duplicate lines (common in logs
12//!   and scraped text).
13//! * `markdown` — everything `prose` does, plus strip HTML comments, drop image
14//!   /badge syntax, and rewrite `[text](url)` links to their visible text
15//!   (removing URL token noise an LLM rarely needs).
16//!
17//! Both honor a hard byte budget and are deterministic — the invariants the
18//! conformance suite (`conformance-v1`) enforces on every registered compressor.
19
20// Compressor::name returns &str; the literal names here would otherwise trip
21// the lint. The flexibility (runtime-owned names) is intentional registry-wide.
22#![allow(clippy::unnecessary_literal_bound)]
23
24use std::sync::Arc;
25
26use super::extension_registry::{Compressor, ExtensionRegistry, truncate_to_budget};
27
28/// `prose`: whitespace + adjacent-duplicate-line compaction for prose corpora.
29struct ProseCompressor;
30impl Compressor for ProseCompressor {
31    fn name(&self) -> &str {
32        "prose"
33    }
34    fn compress(&self, input: &str, budget: Option<usize>) -> String {
35        truncate_to_budget(normalize_prose(input), budget)
36    }
37}
38
39/// `markdown`: prose compaction plus Markdown-noise removal (comments, images,
40/// link URLs).
41struct MarkdownCompressor;
42impl Compressor for MarkdownCompressor {
43    fn name(&self) -> &str {
44        "markdown"
45    }
46    fn compress(&self, input: &str, budget: Option<usize>) -> String {
47        let stripped = strip_html_comments(input);
48        let delinked = rewrite_md_links(&stripped);
49        truncate_to_budget(normalize_prose(&delinked), budget)
50    }
51}
52
53/// Register the non-code compressors into `reg`. Called from
54/// [`ExtensionRegistry::with_builtins`].
55pub fn register_into(reg: &mut ExtensionRegistry) {
56    reg.register_compressor(Arc::new(ProseCompressor));
57    reg.register_compressor(Arc::new(MarkdownCompressor));
58}
59
60/// Collapse blank-line runs (max one), trim + collapse intra-line whitespace,
61/// and drop adjacent duplicate lines. Leading/trailing blank lines are trimmed.
62fn normalize_prose(input: &str) -> String {
63    let mut out: Vec<String> = Vec::new();
64    let mut blank_run = 0u32;
65    for line in input.lines() {
66        let collapsed = collapse_spaces(line.trim());
67        if collapsed.is_empty() {
68            blank_run += 1;
69            if blank_run <= 1 {
70                out.push(String::new());
71            }
72        } else {
73            blank_run = 0;
74            // Drop a line identical to the one immediately before it.
75            if out.last().map(String::as_str) == Some(collapsed.as_str()) {
76                continue;
77            }
78            out.push(collapsed);
79        }
80    }
81    out.join("\n").trim().to_string()
82}
83
84/// Collapse runs of spaces/tabs to a single space; trim trailing space.
85fn collapse_spaces(s: &str) -> String {
86    let mut out = String::with_capacity(s.len());
87    let mut prev_space = false;
88    for ch in s.chars() {
89        if ch == ' ' || ch == '\t' {
90            if !prev_space {
91                out.push(' ');
92                prev_space = true;
93            }
94        } else {
95            out.push(ch);
96            prev_space = false;
97        }
98    }
99    out.trim_end().to_string()
100}
101
102/// Remove `<!-- … -->` comments (unterminated comment drops the remainder).
103fn strip_html_comments(input: &str) -> String {
104    let mut out = String::with_capacity(input.len());
105    let mut rest = input;
106    while let Some(start) = rest.find("<!--") {
107        out.push_str(&rest[..start]);
108        if let Some(end) = rest[start..].find("-->") {
109            rest = &rest[start + end + 3..];
110        } else {
111            rest = "";
112            break;
113        }
114    }
115    out.push_str(rest);
116    out
117}
118
119/// Drop `![alt](url)` images and rewrite `[text](url)` → `text`.
120fn rewrite_md_links(input: &str) -> String {
121    let mut out = String::with_capacity(input.len());
122    let mut i = 0;
123    while i < input.len() {
124        let rest = &input[i..];
125        if let Some(stripped) = rest.strip_prefix("![")
126            && let Some((_, consumed)) = parse_md_link(stripped)
127        {
128            // Skip the whole image: the leading '!' plus the '[..](..)'.
129            i += 1 + consumed;
130            continue;
131        }
132        if rest.starts_with('[')
133            && let Some((text, consumed)) = parse_md_link(rest)
134        {
135            out.push_str(&text);
136            i += consumed;
137            continue;
138        }
139        let ch = rest.chars().next().unwrap();
140        out.push(ch);
141        i += ch.len_utf8();
142    }
143    out
144}
145
146/// Parse a `[text](url)` starting at the leading `[`. Returns the link text and
147/// the number of bytes consumed (through the closing `)`).
148fn parse_md_link(s: &str) -> Option<(String, usize)> {
149    let close_br = s.find(']')?;
150    if s.as_bytes().get(close_br + 1) != Some(&b'(') {
151        return None;
152    }
153    let after = &s[close_br + 2..];
154    let close_par = after.find(')')?;
155    let text = s[1..close_br].to_string();
156    let consumed = close_br + 2 + close_par + 1;
157    Some((text, consumed))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn prose() -> ProseCompressor {
165        ProseCompressor
166    }
167    fn markdown() -> MarkdownCompressor {
168        MarkdownCompressor
169    }
170
171    #[test]
172    fn prose_collapses_blanks_and_trailing_ws() {
173        let out = prose().compress("a  \n\n\n\nb\t\n", None);
174        assert_eq!(out, "a\n\nb");
175    }
176
177    #[test]
178    fn prose_drops_adjacent_duplicate_lines() {
179        let out = prose().compress("same\nsame\nother\nother\nsame", None);
180        assert_eq!(out, "same\nother\nsame");
181    }
182
183    #[test]
184    fn prose_actually_saves_bytes() {
185        let input = "line one    \n\n\n\nline one    \nline two\n\n\n";
186        let out = prose().compress(input, None);
187        assert!(out.len() < input.len());
188    }
189
190    #[test]
191    fn markdown_strips_comments_images_and_link_urls() {
192        let input =
193            "<!-- hidden -->Visit ![badge](http://img) the [docs](https://example.com/x) now.";
194        let out = markdown().compress(input, None);
195        assert!(!out.contains("hidden"));
196        assert!(!out.contains("http://img"));
197        assert!(!out.contains("https://example.com"));
198        assert!(out.contains("docs"));
199        assert!(out.contains("Visit"));
200    }
201
202    #[test]
203    fn budget_is_a_hard_byte_ceiling_utf8_safe() {
204        let out = markdown().compress("äöü漢字 text", Some(3));
205        assert!(out.len() <= 3);
206    }
207
208    #[test]
209    fn deterministic() {
210        let input = "a\n\nb  \n[x](http://y)";
211        assert_eq!(
212            markdown().compress(input, None),
213            markdown().compress(input, None)
214        );
215    }
216
217    #[test]
218    fn empty_input_stays_empty() {
219        assert_eq!(prose().compress("", None), "");
220        assert_eq!(markdown().compress("", None), "");
221    }
222}