Skip to main content

lean_ctx/core/terse/
scoring.rs

1//! Surprisal-based line scoring for deterministic compression.
2//!
3//! Each line receives an information density score based on:
4//! - Character-level entropy (Shannon)
5//! - Structural markers (paths, numbers, identifiers)
6//! - Repetition detection (overlap with previous lines)
7
8use std::collections::HashSet;
9
10/// Score for a single line — higher means more informative.
11#[derive(Debug, Clone)]
12pub struct LineScore {
13    pub line_idx: usize,
14    pub entropy: f32,
15    pub has_structural_marker: bool,
16    pub repetition_ratio: f32,
17    pub combined: f32,
18}
19
20const MAX_TRIGRAM_SET_SIZE: usize = 10_000;
21
22/// Scores all lines in the input text for information density.
23pub fn score_lines(text: &str) -> Vec<LineScore> {
24    let lines: Vec<&str> = text.lines().collect();
25    let mut seen_trigrams: HashSet<String> = HashSet::new();
26    let mut trigram_saturated = false;
27    let mut scores = Vec::with_capacity(lines.len());
28
29    for (idx, line) in lines.iter().enumerate() {
30        let trimmed = line.trim();
31
32        let entropy = char_entropy(trimmed);
33        let is_noise = is_encoded_blob(trimmed);
34        let has_marker = !is_noise && has_structural_marker(trimmed);
35        let rep_ratio = if trigram_saturated {
36            0.0
37        } else {
38            repetition_ratio(trimmed, &seen_trigrams)
39        };
40
41        if !trigram_saturated {
42            register_trigrams(trimmed, &mut seen_trigrams);
43            if seen_trigrams.len() >= MAX_TRIGRAM_SET_SIZE {
44                trigram_saturated = true;
45            }
46        }
47
48        let combined = compute_combined(entropy, has_marker, rep_ratio, is_noise);
49
50        scores.push(LineScore {
51            line_idx: idx,
52            entropy,
53            has_structural_marker: has_marker,
54            repetition_ratio: rep_ratio,
55            combined,
56        });
57    }
58
59    scores
60}
61
62fn char_entropy(line: &str) -> f32 {
63    if line.is_empty() {
64        return 0.0;
65    }
66    let mut freq = [0u32; 128];
67    let mut total = 0u32;
68    for b in line.bytes() {
69        if (b as usize) < 128 {
70            freq[b as usize] += 1;
71            total += 1;
72        }
73    }
74    if total == 0 {
75        return 0.0;
76    }
77    let mut ent = 0.0f32;
78    for &count in &freq {
79        if count > 0 {
80            let p = count as f32 / total as f32;
81            ent -= p * p.log2();
82        }
83    }
84    ent
85}
86
87fn has_structural_marker(line: &str) -> bool {
88    if line.contains('/') && (line.contains('.') || line.contains("src")) {
89        return true;
90    }
91    if line.chars().any(|c| c.is_ascii_digit()) {
92        return true;
93    }
94    if line.contains("error") || line.contains("Error") || line.contains("ERROR") {
95        return true;
96    }
97    if line.contains("warning") || line.contains("Warning") || line.contains("WARN") {
98        return true;
99    }
100    if is_verdict_line(line) {
101        return true;
102    }
103    let long_idents = line
104        .split(|c: char| !c.is_alphanumeric() && c != '_')
105        .filter(|w| w.len() >= 6)
106        .count();
107    long_idents >= 2
108}
109
110/// A run's pass/fail verdict on a line of its own (`OK` after Python
111/// `unittest`, `FAILED`, `PASSED`). Short and low-entropy, so the score alone
112/// drops it — and without it the agent re-runs the command to learn whether
113/// it passed (benchmark pilots: one extra model step per affected test run).
114fn is_verdict_line(line: &str) -> bool {
115    let word = line
116        .trim()
117        .split(|c: char| !c.is_ascii_alphabetic())
118        .next()
119        .unwrap_or("");
120    line.trim().len() <= 60
121        && matches!(
122            word,
123            "OK" | "FAILED" | "FAIL" | "PASSED" | "PASS" | "SUCCESS" | "SUCCEEDED"
124        )
125}
126
127fn repetition_ratio(line: &str, seen: &HashSet<String>) -> f32 {
128    let chars: Vec<char> = line.chars().collect();
129    if chars.len() < 9 {
130        return 0.0;
131    }
132    let total = chars.len().saturating_sub(2);
133    if total == 0 {
134        return 0.0;
135    }
136    let mut repeated = 0;
137    for i in 0..total {
138        let end = (i + 3).min(chars.len());
139        let trigram: String = chars[i..end].iter().collect();
140        if seen.contains(&trigram) {
141            repeated += 1;
142        }
143    }
144    repeated as f32 / total as f32
145}
146
147fn register_trigrams(line: &str, seen: &mut HashSet<String>) {
148    let chars: Vec<char> = line.chars().collect();
149    if chars.len() < 3 {
150        return;
151    }
152    for i in 0..chars.len().saturating_sub(2) {
153        let end = (i + 3).min(chars.len());
154        let trigram: String = chars[i..end].iter().collect();
155        seen.insert(trigram);
156    }
157}
158
159fn compute_combined(entropy: f32, has_marker: bool, rep_ratio: f32, is_noise: bool) -> f32 {
160    if is_noise {
161        return 0.0;
162    }
163    let marker_bonus = if has_marker { 0.3 } else { 0.0 };
164    let rep_penalty = rep_ratio * 0.5;
165    (entropy + marker_bonus - rep_penalty).max(0.0)
166}
167
168/// True when `line` contains an encoded blob (base64 or hex) — either as the
169/// whole line or as one whitespace-delimited token in a `label: <blob>`
170/// shaped line (`trace id: <hex>`, `commit <sha>`, `session token: <b64>`,
171/// all common in real logs) — rather than prose/code. High Shannon entropy
172/// but zero semantic content, so it must not be scored as information-dense.
173fn is_encoded_blob(line: &str) -> bool {
174    line.split_whitespace().any(is_blob_token)
175}
176
177/// True when a single whitespace-delimited token looks like a random,
178/// non-prose blob (base64 or hex) rather than an English word or code
179/// identifier. `pub(super)` so `quality::check` can also treat a long blob
180/// as a must-preserve identifier — a payload-shaped blob (e.g. the resolved
181/// hash from `git rev-parse HEAD`) must still trip the quality gate if
182/// dropped, even though it has no alphabetic characters for the ordinary
183/// identifier check to key on.
184pub(super) fn is_blob_token(token: &str) -> bool {
185    const MIN_BLOB_LEN: usize = 24;
186
187    if token.len() < MIN_BLOB_LEN {
188        return false;
189    }
190
191    let is_hex = token.chars().all(|c| c.is_ascii_hexdigit());
192
193    // Base64 padding/symbols are an unambiguous signal on their own. Without
194    // them, require the digit+upper+lower mix typical of random tokens so a
195    // long plain identifier (all-lowercase or camelCase, no digits) doesn't
196    // get misclassified as noise.
197    let has_b64_symbol = token.contains('+') || token.contains('/') || token.contains('=');
198    let charset_ok = token
199        .chars()
200        .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=');
201    let has_digit = token.chars().any(|c| c.is_ascii_digit());
202    let has_upper = token.chars().any(|c| c.is_ascii_uppercase());
203    let has_lower = token.chars().any(|c| c.is_ascii_lowercase());
204    let is_base64 = charset_ok && (has_b64_symbol || (has_digit && has_upper && has_lower));
205
206    is_hex || is_base64
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn empty_line_zero_entropy() {
215        assert_eq!(char_entropy(""), 0.0);
216    }
217
218    #[test]
219    fn uniform_string_low_entropy() {
220        let e = char_entropy("aaaaaaaaaa");
221        assert!(e < 0.01, "uniform string should have ~0 entropy, got {e}");
222    }
223
224    #[test]
225    fn mixed_string_higher_entropy() {
226        let low = char_entropy("aaaaaaaaaa");
227        let high = char_entropy("abcdefghij");
228        assert!(high > low, "mixed > uniform entropy");
229    }
230
231    #[test]
232    fn structural_marker_path() {
233        assert!(has_structural_marker("src/core/config.rs"));
234    }
235
236    #[test]
237    fn structural_marker_error() {
238        assert!(has_structural_marker("error[E0308]: mismatched types"));
239    }
240
241    #[test]
242    fn structural_marker_missing() {
243        assert!(!has_structural_marker("this is a simple line"));
244    }
245
246    #[test]
247    fn encoded_blob_detected_as_noise() {
248        assert!(is_encoded_blob(
249            "MTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6MDk4NzY1NDMyMQ=="
250        ));
251        assert!(is_encoded_blob(
252            "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
253        ));
254        assert!(!is_encoded_blob("src/core/config.rs"));
255        assert!(!is_encoded_blob("this is a simple line with words"));
256    }
257
258    #[test]
259    fn prefixed_blob_is_still_detected_as_noise() {
260        // Real logs almost always label the blob rather than emit it bare
261        // (`trace id: <hex>`, `commit <sha>`, `session token: <b64>`). Built
262        // at runtime (not a literal) so a fake test token isn't itself
263        // mistaken for a real secret by tooling.
264        let hex64: String = "9f86d0".repeat(11);
265        let b64_padded: String = format!("{}==", "aZ9".repeat(8));
266
267        assert!(is_encoded_blob(&format!("trace id: {hex64}")));
268        assert!(is_encoded_blob(&format!(
269            "build session token: {b64_padded}"
270        )));
271        assert!(is_encoded_blob(&format!("commit {hex64}")));
272    }
273
274    #[test]
275    fn prefixed_real_content_is_not_noise() {
276        assert!(!is_encoded_blob(
277            "error in module ConfigurationManagerFactory during init"
278        ));
279    }
280
281    #[test]
282    fn long_camel_case_identifier_is_not_noise() {
283        assert!(!is_encoded_blob(
284            "configureApplicationRuntimeEnvironmentSettings"
285        ));
286        assert!(!is_encoded_blob(
287            "configure_premium_feature_flags_for_tenant"
288        ));
289    }
290
291    #[test]
292    fn encoded_blob_scores_lower_than_real_error_line() {
293        let text = "error: connection refused at host during handshake attempt\nMTIzNDU2Nzg5MGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6MDk4NzY1NDMyMQ==";
294        let scores = score_lines(text);
295        assert!(
296            scores[0].combined > scores[1].combined,
297            "real error line should score above encoded blob noise: {} vs {}",
298            scores[0].combined,
299            scores[1].combined
300        );
301    }
302
303    #[test]
304    fn score_lines_returns_all_lines() {
305        let text = "line one\nline two\nline three";
306        let scores = score_lines(text);
307        assert_eq!(scores.len(), 3);
308    }
309
310    #[test]
311    fn repetitive_lines_get_lower_score() {
312        let text = "exactly the same line repeated here\nexactly the same line repeated here\nunique content with different words";
313        let scores = score_lines(text);
314        assert!(
315            scores[2].combined >= scores[1].combined,
316            "unique line should score >= repeated: {} vs {}",
317            scores[2].combined,
318            scores[1].combined
319        );
320    }
321}