lean_ctx/core/terse/
scoring.rs1use std::collections::HashSet;
9
10#[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
22pub 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
110fn 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
168fn is_encoded_blob(line: &str) -> bool {
174 line.split_whitespace().any(is_blob_token)
175}
176
177pub(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 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 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}