Skip to main content

lean_ctx/core/terse/
quality.rs

1//! Quality gate for terse compression.
2//!
3//! Ensures compression does not destroy critical information:
4//! - File paths must be preserved
5//! - Code identifiers (>= 6 chars) must be preserved
6//! - Critical facts (error codes, failing-test counts, failure status, problem
7//!   locations) must survive in the *final* output — terse output has no recovery
8//!   path, so a dropped critical fact would be lost ([`critical_facts_lost`],
9//!   checked by the engine after dictionaries and the auto-dictionary)
10//! - Minimum savings threshold (default 10%) must be met
11
12use 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    /// Critical retention probes missing from the compressed text.
25    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
46/// Checks whether a compression result meets quality thresholds.
47pub 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
97/// Critical facts of `original` missing from the final `delivered` text (after every
98/// rewrite). Terse output has no recovery path, so a missing critical fact is lost.
99/// Returns at least 1 when the facts could not all be checked (fail closed).
100pub 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
141/// A token counts as a must-preserve identifier if it looks like an ordinary
142/// code identifier (has a letter) OR looks like an encoded blob (hex/base64,
143/// e.g. a `git rev-parse HEAD` hash) — the latter has no alphabetic
144/// character for the ordinary check to key on, but dropping it is exactly
145/// the failure mode the noise-detector in `scoring.rs` must not cause: a
146/// payload-shaped blob must still trip this gate if compression removes it.
147fn 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    /// The identifier ratio tolerates 10 % loss, so a long output can lose its one
218    /// failing-test count and still clear it. The critical-fact check must not.
219    #[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    /// A pure-digit, hex-shaped blob (e.g. an all-numeric slice of a
233    /// `git rev-parse HEAD` hash) has no alphabetic character, so the
234    /// ordinary identifier check alone would miss it — `is_blob_token` closes
235    /// that gap so dropping it still fails the gate.
236    #[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}