Skip to main content

webfetch_core/
compress.rs

1use once_cell::sync::Lazy;
2use regex::Regex;
3
4static WHITESPACE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
5static DECORATIVE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[▶→←▼▲•·◆◇◊✓✗✔✘‣⁃◦]").unwrap());
6
7/// Semantic text reduction: strip decorative glyphs, then collapse runs of
8/// whitespace, then trim.
9///
10/// Order matters — decorative characters are removed *before* collapsing
11/// whitespace so that a glyph surrounded by spaces (e.g. `"Click ▶ to play"`)
12/// does not leave a double space behind.
13pub fn compress_text(text: &str) -> String {
14    let clean = DECORATIVE_RE.replace_all(text, "");
15    let collapsed = WHITESPACE_RE.replace_all(&clean, " ");
16    collapsed.trim().to_string()
17}
18
19/// Collapse repeated blank lines while preserving paragraph breaks, and
20/// compress whitespace within each line.
21pub fn compress_block(text: &str) -> String {
22    let mut lines: Vec<String> = Vec::new();
23    let mut prev_blank = false;
24    for raw in text.lines() {
25        let line = compress_text(raw);
26        let blank = line.is_empty();
27        if blank && prev_blank {
28            continue;
29        }
30        lines.push(line);
31        prev_blank = blank;
32    }
33    lines.join("\n").trim().to_string()
34}
35
36/// Is this byte one of the punctuation characters a BPE tokenizer almost
37/// always splits on? See [`estimate_tokens`].
38fn is_url_punct(b: u8) -> bool {
39    matches!(
40        b,
41        b'/' | b':' | b'.' | b'?' | b'#' | b'&' | b'=' | b'%' | b'~'
42    )
43}
44
45/// Token cost of `text` in *quarter-tokens*, the unit both [`estimate_tokens`]
46/// and [`truncate_to_tokens`] work in so the two can never disagree.
47///
48/// One byte costs one quarter-token (the ~4-chars-per-token rule); each URL
49/// punctuation byte costs two extra (the half-token surcharge).
50fn cost_quarters(text: &str) -> usize {
51    text.len() + 2 * text.bytes().filter(|b| is_url_punct(*b)).count()
52}
53
54/// Fast token approximation.
55///
56/// Prose is ~4 characters per token, which matches common BPE tokenizers
57/// closely enough for budgeting. URLs and reference blocks, however, are
58/// punctuation-dense — BPE breaks on `/ : . ? # & = % ~`, so a URL yields far
59/// more tokens per character than prose and a naive `len/4` badly
60/// *under*-budgets them. We therefore add a surcharge of half a token per such
61/// punctuation byte, which pushes URL-heavy text (the trailing reference block
62/// especially) toward its true token count while leaving prose essentially
63/// unchanged. The heuristic is deterministic and a single linear scan.
64pub fn estimate_tokens(text: &str) -> usize {
65    cost_quarters(text) / 4
66}
67
68/// The elision marker appended when [`truncate_to_tokens`] drops content.
69pub const TRUNCATION_MARKER: &str = "\n…[truncated]";
70
71/// Truncate text to roughly `max_tokens`, on a character boundary, appending
72/// an elision marker when content is dropped.
73///
74/// The prefix is chosen with the *same* cost model [`estimate_tokens`] uses, so
75/// `estimate_tokens(truncate_to_tokens(t, n)) <= n` holds for the returned body
76/// even on punctuation-dense text. (A naive `max_tokens * 4` character cut
77/// ignores the URL surcharge and overshoots badly on link-heavy pages.)
78pub fn truncate_to_tokens(text: &str, max_tokens: usize) -> String {
79    if estimate_tokens(text) <= max_tokens {
80        return text.to_string();
81    }
82    // Reserve room for the marker so the returned string still fits the budget.
83    let budget = (max_tokens * 4).saturating_sub(cost_quarters(TRUNCATION_MARKER));
84
85    let mut spent = 0usize;
86    let mut end = 0usize;
87    for (i, b) in text.bytes().enumerate() {
88        let next = spent + if is_url_punct(b) { 3 } else { 1 };
89        if next > budget {
90            break;
91        }
92        spent = next;
93        end = i + 1;
94    }
95    while end > 0 && !text.is_char_boundary(end) {
96        end -= 1;
97    }
98    format!("{}{}", &text[..end], TRUNCATION_MARKER)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn estimate_unchanged_for_plain_prose() {
107        assert_eq!(estimate_tokens(&"a".repeat(100)), 25);
108    }
109
110    #[test]
111    fn url_heavy_text_estimates_higher_than_prose_of_same_length() {
112        // Four reference lines: punctuation-dense URLs.
113        let urls = "[1] https://example.com/a/b?c=d#e\n\
114                    [2] https://example.org/x/y/z?q=1\n\
115                    [3] https://sub.example.net/path/to/thing\n\
116                    [4] https://example.io/foo/bar/baz?k=v";
117        // Same byte length, but plain prose (no URL punctuation).
118        let prose = "x".repeat(urls.len());
119        assert_eq!(urls.len(), prose.len());
120        assert!(
121            estimate_tokens(urls) > estimate_tokens(&prose),
122            "urls={} prose={}",
123            estimate_tokens(urls),
124            estimate_tokens(&prose)
125        );
126    }
127
128    #[test]
129    fn truncate_respects_the_budget_it_reports() {
130        let text = "a".repeat(1000);
131        let out = truncate_to_tokens(&text, 20);
132        assert!(out.ends_with(TRUNCATION_MARKER));
133        assert!(
134            estimate_tokens(&out) <= 20,
135            "estimate {}",
136            estimate_tokens(&out)
137        );
138    }
139
140    /// The old `max_tokens * 4` character cut ignored the URL surcharge, so
141    /// punctuation-dense text came back well over budget.
142    #[test]
143    fn truncate_respects_budget_on_url_heavy_text() {
144        let urls = "[1] https://example.com/a/b?c=d#e\n".repeat(200);
145        let out = truncate_to_tokens(&urls, 50);
146        assert!(
147            estimate_tokens(&out) <= 50,
148            "estimate {}",
149            estimate_tokens(&out)
150        );
151    }
152
153    #[test]
154    fn truncate_is_a_noop_within_budget() {
155        let text = "short enough";
156        assert_eq!(truncate_to_tokens(text, 1000), text);
157    }
158
159    #[test]
160    fn truncate_never_splits_a_utf8_char() {
161        let text = "é".repeat(500);
162        let out = truncate_to_tokens(&text, 10);
163        // Round-trips as valid UTF-8 (the type system guarantees it, but the
164        // boundary walk is what makes the slice legal in the first place).
165        assert!(out.starts_with('é'));
166        assert!(estimate_tokens(&out) <= 10);
167    }
168}