lean_ctx/core/terse/
quality.rs1use std::collections::HashSet;
13
14#[derive(Debug, Clone)]
15pub struct QualityReport {
16 pub passed: bool,
17 pub savings_pct: f32,
18 pub paths_preserved: bool,
19 pub identifiers_preserved: bool,
20 pub paths_total: usize,
21 pub paths_found: usize,
22 pub identifiers_total: usize,
23 pub identifiers_found: usize,
24 pub critical_lost: usize,
26}
27
28pub struct QualityConfig {
29 pub min_savings_pct: f32,
30 pub min_path_preservation: f32,
31 pub min_identifier_preservation: f32,
32 pub min_identifier_len: usize,
33}
34
35impl Default for QualityConfig {
36 fn default() -> Self {
37 Self {
38 min_savings_pct: 3.0,
39 min_path_preservation: 1.0,
40 min_identifier_preservation: 0.90,
41 min_identifier_len: 6,
42 }
43 }
44}
45
46pub fn check(
48 original: &str,
49 compressed: &str,
50 tokens_before: u32,
51 tokens_after: u32,
52 config: &QualityConfig,
53) -> QualityReport {
54 let savings_pct = if tokens_before > 0 && tokens_before >= tokens_after {
55 ((tokens_before - tokens_after) as f32 / tokens_before as f32) * 100.0
56 } else {
57 0.0
58 };
59
60 let orig_paths = extract_paths(original);
61 let comp_paths = extract_paths(compressed);
62 let paths_found = orig_paths
63 .iter()
64 .filter(|p| comp_paths.contains(*p))
65 .count();
66 let paths_preserved = orig_paths.is_empty()
67 || (paths_found as f32 / orig_paths.len() as f32) >= config.min_path_preservation;
68
69 let orig_idents = extract_identifiers(original, config.min_identifier_len);
70 let comp_words: HashSet<String> = compressed
71 .split(|c: char| !c.is_alphanumeric() && c != '_')
72 .filter(|w| w.len() >= config.min_identifier_len)
73 .map(str::to_lowercase)
74 .collect();
75 let idents_found = orig_idents
76 .iter()
77 .filter(|id| comp_words.contains(&id.to_lowercase()))
78 .count();
79 let identifiers_preserved = orig_idents.is_empty()
80 || (idents_found as f32 / orig_idents.len() as f32) >= config.min_identifier_preservation;
81
82 let passed = paths_preserved && identifiers_preserved;
83
84 QualityReport {
85 passed,
86 savings_pct,
87 paths_preserved,
88 identifiers_preserved,
89 paths_total: orig_paths.len(),
90 paths_found,
91 identifiers_total: orig_idents.len(),
92 identifiers_found: idents_found,
93 critical_lost: 0,
94 }
95}
96
97pub fn critical_facts_lost(original: &str, delivered: &str) -> usize {
101 if original == delivered {
102 return 0;
103 }
104 let report = crate::core::context_quality::retention::assess(
105 original,
106 delivered,
107 crate::core::context_quality::RecoveryPath::None,
108 );
109 if report.passes() {
110 0
111 } else {
112 report.critical.lost.max(1)
113 }
114}
115
116fn extract_paths(text: &str) -> HashSet<String> {
117 let mut paths = HashSet::new();
118 for word in text.split_whitespace() {
119 let cleaned = word.trim_matches(|c: char| c == '\'' || c == '"' || c == ',' || c == ';');
120 if looks_like_path(cleaned) {
121 paths.insert(cleaned.to_string());
122 }
123 }
124 paths
125}
126
127fn looks_like_path(s: &str) -> bool {
128 if s.len() < 3 {
129 return false;
130 }
131 let has_separator = s.contains('/') || s.contains('\\');
132 let has_extension = s.rfind('.').is_some_and(|dot| {
133 let ext = &s[dot + 1..];
134 !ext.is_empty() && ext.len() <= 6 && ext.chars().all(|c| c.is_ascii_alphanumeric())
135 });
136 has_separator || (has_extension && s.chars().filter(|c| *c == '.').count() <= 2)
137}
138
139const MAX_IDENTIFIERS: usize = 200;
140
141fn extract_identifiers(text: &str, min_len: usize) -> HashSet<String> {
148 let mut idents = HashSet::new();
149 for word in text.split(|c: char| !c.is_alphanumeric() && c != '_') {
150 let looks_like_identifier = word.len() >= min_len
151 && (word.chars().any(char::is_alphabetic) || super::scoring::is_blob_token(word));
152 if looks_like_identifier {
153 idents.insert(word.to_string());
154 if idents.len() >= MAX_IDENTIFIERS {
155 break;
156 }
157 }
158 }
159 idents
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn path_detection() {
168 assert!(looks_like_path("src/main.rs"));
169 assert!(looks_like_path("config.toml"));
170 assert!(!looks_like_path("hello"));
171 assert!(!looks_like_path("a"));
172 }
173
174 #[test]
175 fn extract_paths_from_text() {
176 let paths = extract_paths("error in src/lib.rs at line 42");
177 assert!(paths.contains("src/lib.rs"));
178 }
179
180 #[test]
181 fn extract_identifiers_min_len() {
182 let idents = extract_identifiers("fn configure_premium_features(home: Path)", 6);
183 assert!(idents.contains("configure_premium_features"));
184 assert!(!idents.contains("home"));
185 }
186
187 #[test]
188 fn quality_passes_with_good_compression() {
189 let original =
190 "src/main.rs: error[E0308]: mismatched types\nlong description here that is verbose";
191 let compressed = "src/main.rs: err[E0308]: mismatched types";
192 let report = check(original, compressed, 100, 60, &QualityConfig::default());
193 assert!(report.paths_preserved);
194 }
195
196 #[test]
197 fn quality_passes_when_identifiers_preserved() {
198 let report = check("hello", "hello", 100, 98, &QualityConfig::default());
199 assert!(
200 report.passed,
201 "should pass when paths and identifiers are preserved"
202 );
203 assert!(
204 report.savings_pct < 3.0,
205 "savings should still be tracked as low"
206 );
207 }
208
209 #[test]
210 fn quality_fails_missing_path() {
211 let original = "error in src/config.rs";
212 let compressed = "error occurred";
213 let report = check(original, compressed, 100, 50, &QualityConfig::default());
214 assert!(!report.paths_preserved);
215 }
216
217 #[test]
220 fn critical_fact_check_catches_what_the_identifier_ratio_tolerates() {
221 let cases = (0..40)
222 .map(|i| format!("payments_integration::refund_case_{i:02} ... ok"))
223 .collect::<Vec<_>>()
224 .join("\n");
225 let original = format!("{cases}\nsummary: 40 passed\n2 failed\n");
226 let compressed = format!("{cases}\nsummary: 40 passed\n");
227 let report = check(&original, &compressed, 100, 70, &QualityConfig::default());
228 assert!(report.identifiers_preserved, "{report:?}");
229 assert!(critical_facts_lost(&original, &compressed) > 0);
230 }
231
232 #[test]
237 fn quality_fails_when_payload_blob_is_dropped() {
238 let blob = "1".repeat(9) + &"2".repeat(9) + &"3".repeat(9);
239 let original = format!("commit hash: {blob}");
240 let compressed = "commit hash:";
241 let report = check(&original, compressed, 100, 10, &QualityConfig::default());
242 assert!(
243 !report.identifiers_preserved,
244 "dropping a pure-digit hash-shaped blob must fail the identifier gate"
245 );
246 }
247}