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