lean_ctx/core/
information_bottleneck.rs1use 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
38pub fn compress_ib(text: &str, target_ratio: f64) -> String {
41 compress_ib_with_query(text, target_ratio, None)
42}
43
44fn 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
53fn 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 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
99pub 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 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 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 assert!(ratio <= 0.6, "ratio stays near target, got {ratio}");
246 assert!(out.contains("webhook") && !out.contains("dashboard"));
247 }
248}