1use serde_json::Value;
16
17use lean_ctx::core::aggressiveness::AggressivenessProfile;
18use lean_ctx::core::tokens::count_tokens;
19use lean_ctx::core::web::distill::squeeze_prose;
20
21const MIN_PROSE_CHARS: usize = 400;
24
25const CODE_SYMBOLS: &str = "{}<>;=|\\$`[]";
28
29#[must_use]
36pub fn compress_prose(text: &str, aggressiveness: f64) -> Option<String> {
37 if text.len() < MIN_PROSE_CHARS || !looks_like_prose(text) {
38 return None;
39 }
40 let profile = AggressivenessProfile::from_level(aggressiveness);
41 let budget = ((text.len() as f64) * profile.density_target).ceil() as usize;
47 let squeezed = if budget < text.len() {
48 crate::prose_ranker::squeeze(text, budget)
49 } else {
50 squeeze_prose(text, budget)
51 };
52 let before = count_tokens(text);
53 let after = count_tokens(&squeezed);
54 if after >= before {
55 return None;
56 }
57 super::compress::gate::shortened(text, squeezed)
61}
62
63fn looks_like_prose(text: &str) -> bool {
67 let sample: String = text.chars().take(4000).collect();
68 let total = sample.chars().count();
69 if total < 200 {
70 return false;
71 }
72 let total_f = total as f32;
73 let alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as f32;
74 let spaces = sample.chars().filter(|c| c.is_whitespace()).count() as f32;
75 let symbols = sample.chars().filter(|c| CODE_SYMBOLS.contains(*c)).count() as f32;
76 let sentences = sample.matches(['.', '!', '?']).count();
80 alpha / total_f >= 0.5
81 && spaces / total_f >= 0.08
82 && symbols / total_f <= 0.06
83 && sentences >= 3
84}
85
86fn block_has_cache_control(block: &Value) -> bool {
89 block.get("cache_control").is_some()
90}
91
92#[must_use]
97pub fn value_has_cache_control(v: &Value) -> bool {
98 match v {
99 Value::Array(blocks) => blocks.iter().any(block_has_cache_control),
100 _ => false,
101 }
102}
103
104pub fn compress_string_field(obj: &mut Value, field: &str, aggressiveness: f64) -> bool {
107 let Some(text) = obj.get(field).and_then(Value::as_str) else {
108 return false;
109 };
110 if let Some(compressed) = compress_prose(text, aggressiveness) {
111 obj[field] = Value::String(compressed);
112 return true;
113 }
114 false
115}
116
117pub fn compress_text_blocks(blocks: &mut [Value], aggressiveness: f64) -> u32 {
122 let mut count = 0;
123 for block in blocks.iter_mut() {
124 if block.get("type").and_then(Value::as_str) != Some("text")
125 || block_has_cache_control(block)
126 {
127 continue;
128 }
129 let Some(text) = block.get("text").and_then(Value::as_str) else {
130 continue;
131 };
132 if let Some(compressed) = compress_prose(text, aggressiveness) {
133 block["text"] = Value::String(compressed);
134 count += 1;
135 }
136 }
137 count
138}
139
140pub fn compress_system_value(system: &mut Value, aggressiveness: f64) -> u32 {
143 match system {
144 Value::String(s) => {
145 if let Some(compressed) = compress_prose(s, aggressiveness) {
146 *s = compressed;
147 return 1;
148 }
149 0
150 }
151 Value::Array(blocks) => compress_text_blocks(blocks, aggressiveness),
152 _ => 0,
153 }
154}
155
156pub fn compress_message_content(msg: &mut Value, aggressiveness: f64) -> u32 {
160 match msg.get_mut("content") {
161 Some(Value::String(s)) => {
162 if let Some(compressed) = compress_prose(s, aggressiveness) {
163 *s = compressed;
164 return 1;
165 }
166 0
167 }
168 Some(Value::Array(parts)) => compress_text_blocks(parts, aggressiveness),
169 _ => 0,
170 }
171}
172
173pub fn compress_gemini_text_parts(parts: &mut [Value], aggressiveness: f64) -> u32 {
178 let mut count = 0;
179 for part in parts.iter_mut() {
180 if part.get("functionCall").is_some()
181 || part.get("functionResponse").is_some()
182 || part.get("inlineData").is_some()
183 {
184 continue;
185 }
186 let Some(text) = part.get("text").and_then(Value::as_str) else {
187 continue;
188 };
189 if let Some(compressed) = compress_prose(text, aggressiveness) {
190 part["text"] = Value::String(compressed);
191 count += 1;
192 }
193 }
194 count
195}
196
197#[must_use]
199pub fn estimate_tokens(val: &serde_json::Value) -> usize {
200 let s = serde_json::to_string(val).unwrap_or_default();
201 s.len() / 4
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn long_prose() -> String {
209 let p = "You are a meticulous senior engineer who values clarity and \
212 correctness above all. Always explain your reasoning before \
213 acting, and prefer small, reviewable changes over large ones. ";
214 format!("{p}\n\n{p}\n\n{p}")
215 }
216
217 #[test]
218 fn compresses_long_prose_deterministically() {
219 let _data = lean_ctx::core::data_dir::isolated_data_dir();
220 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
221 let text = long_prose();
222 let a = compress_prose(&text, 0.5);
223 let b = compress_prose(&text, 0.5);
224 assert_eq!(a, b, "compress_prose must be a pure function of its inputs");
225 assert!(a.is_some(), "long, duplicate-rich prose must compress");
226 assert!(count_tokens(&a.unwrap()) < count_tokens(&text));
227 }
228
229 #[test]
230 fn anti_inflation_leaves_short_text() {
231 assert_eq!(compress_prose("Be concise.", 1.0), None);
233 }
234
235 #[test]
236 fn anti_inflation_when_no_saving_possible() {
237 let unique = (0..40)
240 .map(|i| {
241 format!("Distinct instruction number {i} about handling edge case {i} carefully.")
242 })
243 .collect::<Vec<_>>()
244 .join("\n");
245 assert_eq!(compress_prose(&unique, 0.0), None);
246 }
247
248 #[test]
249 fn rejects_code_like_input() {
250 let code = (0..40)
251 .map(|i| format!(" let value_{i} = compute_{i}(ctx, opts);"))
252 .collect::<Vec<_>>()
253 .join("\n");
254 assert!(!looks_like_prose(&code));
255 assert_eq!(compress_prose(&code, 1.0), None);
256 }
257
258 #[test]
259 fn rejects_json_like_input() {
260 let json = r#"{"a": 1, "b": {"c": [1,2,3], "d": "x"}, "e": true, "f": null}"#.repeat(20);
261 assert!(!looks_like_prose(&json));
262 }
263
264 #[test]
265 fn text_blocks_skip_cache_control() {
266 let _data = lean_ctx::core::data_dir::isolated_data_dir();
267 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
268 let big = long_prose();
269 let mut blocks = vec![
270 serde_json::json!({"type": "text", "text": big, "cache_control": {"type": "ephemeral"}}),
271 serde_json::json!({"type": "text", "text": big}),
272 ];
273 let n = compress_text_blocks(&mut blocks, 0.5);
274 assert_eq!(n, 1, "only the non-cache_control block may be rewritten");
275 assert!(
277 blocks[0]["text"]
278 .as_str()
279 .unwrap()
280 .contains("meticulous senior engineer"),
281 "cache_control block must survive verbatim"
282 );
283 }
284
285 #[test]
286 fn system_value_handles_string_and_array() {
287 let _data = lean_ctx::core::data_dir::isolated_data_dir();
288 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
289 let big = long_prose();
290 let mut as_string = Value::String(big.clone());
291 assert_eq!(compress_system_value(&mut as_string, 0.5), 1);
292
293 let mut as_array = serde_json::json!([{"type": "text", "text": big}]);
294 let arr = as_array.as_array_mut().unwrap();
295 assert_eq!(compress_text_blocks(arr, 0.5), 1);
296 }
297}