Skip to main content

lean_ctx/core/
information_bottleneck.rs

1//! QUITO-X–style trade-off: compress by dropping low token-entropy lines while targeting an output/input token ratio.
2//!
3//! Query-conditioned variant (#542, EFF-5): the IB objective is
4//! `min I(T;X) − β·I(T;Y)` — the relevance variable Y (the task/query) must
5//! condition the compression. `compress_ib_with_query` fuses normalized
6//! entropy with an IDF-weighted query-term overlap (the lexical core of
7//! BM25), so two different queries keep different lines from the same file
8//! (QUITO-X EMNLP'25: query-conditioned beats query-agnostic by 20-25%
9//! accuracy at equal rate). Without a query the behavior is byte-identical
10//! to the entropy-only path.
11
12use super::entropy::normalized_token_entropy;
13use super::tokens::count_tokens;
14
15fn flush_omitted(out: &mut Vec<String>, run: &mut usize) {
16    if *run > 0 {
17        out.push(format!("// ... {} low-info lines omitted", *run));
18        *run = 0;
19    }
20}
21
22fn render_ib(lines: &[&str], scores: &[f64], threshold: f64) -> String {
23    debug_assert_eq!(lines.len(), scores.len());
24    let mut out = Vec::new();
25    let mut omit_run = 0usize;
26    for (&line, &score) in lines.iter().zip(scores.iter()) {
27        if score >= threshold {
28            flush_omitted(&mut out, &mut omit_run);
29            out.push(line.to_string());
30        } else {
31            omit_run += 1;
32        }
33    }
34    flush_omitted(&mut out, &mut omit_run);
35    out.join("\n")
36}
37
38/// Compress `text` toward `target_ratio` (output tokens / input tokens) by dropping lines whose
39/// normalized BPE token entropy falls below a dynamically chosen threshold.
40pub fn compress_ib(text: &str, target_ratio: f64) -> String {
41    compress_ib_with_query(text, target_ratio, None)
42}
43
44/// Tokenize for relevance scoring: lowercase alphanumeric runs, length >= 2.
45fn relevance_terms(s: &str) -> Vec<String> {
46    s.to_lowercase()
47        .split(|c: char| !c.is_alphanumeric())
48        .filter(|t| t.len() >= 2)
49        .map(str::to_string)
50        .collect()
51}
52
53/// Per-line I(T;Y) proxy: IDF-weighted overlap between query terms and line
54/// terms, normalized to [0,1] across the document. Deterministic, no model.
55fn query_relevance_scores(lines: &[&str], query: &str) -> Option<Vec<f64>> {
56    let q_terms: std::collections::HashSet<String> = relevance_terms(query).into_iter().collect();
57    if q_terms.is_empty() {
58        return None;
59    }
60
61    // Document frequency per query term (over lines).
62    let mut df: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
63    let line_terms: Vec<Vec<String>> = lines.iter().map(|l| relevance_terms(l)).collect();
64    for terms in &line_terms {
65        let unique: std::collections::HashSet<&str> = terms.iter().map(String::as_str).collect();
66        for t in unique {
67            if q_terms.contains(t) {
68                *df.entry(t).or_insert(0) += 1;
69            }
70        }
71    }
72    if df.is_empty() {
73        return None;
74    }
75
76    let n = lines.len() as f64;
77    let raw: Vec<f64> = line_terms
78        .iter()
79        .map(|terms| {
80            let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
81            terms
82                .iter()
83                .filter(|t| q_terms.contains(t.as_str()) && seen.insert(t.as_str()))
84                .map(|t| {
85                    let d = *df.get(t.as_str()).unwrap_or(&1) as f64;
86                    ((n + 1.0) / d).ln()
87                })
88                .sum::<f64>()
89        })
90        .collect();
91
92    let max = raw.iter().copied().fold(0.0_f64, f64::max);
93    if max <= 0.0 {
94        return None;
95    }
96    Some(raw.into_iter().map(|s| s / max).collect())
97}
98
99/// Query-conditioned IB compression (#542). With a query, the keep-score is
100/// `0.5·entropy + 0.5·relevance`; without one (or when the query shares no
101/// terms with the document) this is exactly the entropy-only `compress_ib`.
102pub fn compress_ib_with_query(text: &str, target_ratio: f64, query: Option<&str>) -> String {
103    if text.is_empty() {
104        return String::new();
105    }
106    let input_tokens = count_tokens(text);
107    if input_tokens == 0 {
108        return text.to_string();
109    }
110    let ratio_target = target_ratio.clamp(0.02, 1.0);
111
112    let lines_vec: Vec<&str> = text.lines().collect();
113    let lines: &[&str] = &lines_vec;
114    let entropy_scores: Vec<f64> = lines
115        .iter()
116        .map(|ln| normalized_token_entropy(ln))
117        .collect();
118
119    let scores: Vec<f64> = match query.and_then(|q| query_relevance_scores(lines, q)) {
120        Some(relevance) => entropy_scores
121            .iter()
122            .zip(relevance.iter())
123            .map(|(e, r)| 0.5 * e + 0.5 * r)
124            .collect(),
125        None => entropy_scores,
126    };
127
128    // Higher threshold ⇒ fewer kept lines ⇒ lower output ratio (monotone decreasing in threshold).
129    let mut lo = 0.0_f64;
130    let mut hi = 1.0_f64;
131    let mut best = render_ib(lines, &scores, 0.0);
132    let mut best_diff = f64::INFINITY;
133
134    let mut consider = |thr: f64| {
135        let cand = render_ib(lines, &scores, thr);
136        let r = count_tokens(&cand) as f64 / input_tokens as f64;
137        let diff = (r - ratio_target).abs();
138        if diff < best_diff {
139            best_diff = diff;
140            best = cand;
141        }
142    };
143
144    for _ in 0..26 {
145        let mid = (lo + hi) * 0.5;
146        let cand = render_ib(lines, &scores, mid);
147        let r = count_tokens(&cand) as f64 / input_tokens as f64;
148        consider(mid);
149        if r > ratio_target {
150            lo = mid;
151        } else {
152            hi = mid;
153        }
154    }
155
156    for thr in [0.0_f64, 1.0_f64, lo, hi, (lo + hi) * 0.5] {
157        consider(thr);
158    }
159
160    best
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn empty_and_ratio_one_keeps_content() {
169        assert_eq!(compress_ib("", 0.5), "");
170        let s = "fn main() {\n    println!(\"hi\");\n}\n";
171        let full = compress_ib(s, 1.0);
172        assert!(full.contains("fn main"));
173    }
174
175    #[test]
176    fn strong_compression_drops_redundant_lines() {
177        let mut boring = String::new();
178        for _ in 0..30 {
179            boring.push_str("aaa bbb aaa bbb\n");
180        }
181        boring.push_str("unique_identifier_xyz_quartz\n");
182        let out = compress_ib(&boring, 0.15);
183        assert!(out.contains("low-info lines omitted"));
184        assert!(out.contains("unique_identifier_xyz_quartz"));
185        assert!(count_tokens(&out) < count_tokens(&boring));
186    }
187
188    #[test]
189    fn placeholder_counts_skipped_lines() {
190        let lines: Vec<String> = (0..5).map(|_| "x x x x".into()).collect();
191        let mut text = lines.join("\n");
192        text.push('\n');
193        text.push_str("serde Deserialize TraitBounds\n");
194        let out = compress_ib(&text, 0.25);
195        assert!(out.contains("low-info lines omitted"));
196        assert!(out.contains("serde"));
197    }
198
199    fn two_topic_fixture() -> String {
200        let mut s = String::new();
201        for _ in 0..10 {
202            s.push_str(
203                "fn parse_webhook_event(payload: Json) -> StripeEvent { decode(payload) }\n",
204            );
205        }
206        for _ in 0..10 {
207            s.push_str("fn render_dashboard_chart(data: Series) -> Svg { plot(data) }\n");
208        }
209        s
210    }
211
212    #[test]
213    fn different_queries_keep_different_lines() {
214        let text = two_topic_fixture();
215        let a = compress_ib_with_query(&text, 0.3, Some("stripe webhook event parsing"));
216        let b = compress_ib_with_query(&text, 0.3, Some("dashboard chart rendering svg"));
217        assert_ne!(a, b, "query must condition the kept lines");
218        assert!(a.contains("webhook"), "query-a keeps its topic: {a}");
219        assert!(b.contains("dashboard"), "query-b keeps its topic: {b}");
220    }
221
222    #[test]
223    fn no_query_is_byte_identical_to_entropy_only() {
224        let text = two_topic_fixture();
225        assert_eq!(
226            compress_ib_with_query(&text, 0.3, None),
227            compress_ib(&text, 0.3)
228        );
229        // A query sharing no terms with the document degrades gracefully to
230        // the entropy-only result as well.
231        assert_eq!(
232            compress_ib_with_query(&text, 0.3, Some("zzz qqq vvv")),
233            compress_ib(&text, 0.3)
234        );
235    }
236
237    #[test]
238    fn compression_ratio_invariant_holds_with_query() {
239        let text = two_topic_fixture();
240        let out = compress_ib_with_query(&text, 0.3, Some("stripe webhook"));
241        let ratio = count_tokens(&out) as f64 / count_tokens(&text) as f64;
242        // The 2-topic fixture only has two score levels, so the closest
243        // reachable ratio to 0.3 is "keep one topic" (~0.55). The invariant
244        // is: never blow past the coarsest achievable step.
245        assert!(ratio <= 0.6, "ratio stays near target, got {ratio}");
246        assert!(out.contains("webhook") && !out.contains("dashboard"));
247    }
248}