Skip to main content

lean_ctx/proxy/
prose.rs

1//! Frozen-region prose compression for the proxy (#710).
2//!
3//! The proxy already prunes OLD tool-result content at a frozen, cache-aware
4//! boundary (see [`super::history_prune`]). This module adds the *prose*
5//! counterpart: when an operator opts in via `[proxy.role_aggressiveness]`,
6//! system and user free-text is squeezed with a deterministic, anti-inflation
7//! pass. Because the output is a pure function of `(text, aggressiveness)`, a
8//! frozen-region rewrite is byte-identical on every later turn, so the provider
9//! prompt-cache prefix stays valid (#498).
10//!
11//! Assistant turns are never passed to this module — the passthrough guarantee
12//! lives at the call sites, which only invoke it for system/user roles.
13
14use serde_json::Value;
15
16use crate::core::aggressiveness::AggressivenessProfile;
17use crate::core::tokens::count_tokens;
18use crate::core::web::distill::squeeze_prose;
19
20/// Below this many chars a string is never worth a prose pass — the squeeze can
21/// only add risk, not save meaningful tokens. Keeps short instructions intact.
22const MIN_PROSE_CHARS: usize = 400;
23
24/// Code/structured symbols whose density cleanly separates source, JSON, logs
25/// and tables from natural-language prose.
26const CODE_SYMBOLS: &str = "{}<>;=|\\$`[]";
27
28/// Compress a single prose string at `aggressiveness` (`0.0–1.0`).
29///
30/// Returns `Some(compressed)` only when the text is long enough, *looks like*
31/// prose, and the squeeze actually saves tokens; otherwise `None` (leave the
32/// original intact — the anti-inflation guard). Deterministic: the result is a
33/// pure function of `(text, aggressiveness)`.
34#[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    // `density_target` is the fraction of content to keep; map it to a char
41    // budget. Below the budget the squeeze is a near-lossless dedup pass; when it
42    // must actually shrink (budget < len) we use cache-safe extractive ranking
43    // (#895) — keeping the most central sentences instead of just the prefix —
44    // which falls back to truncation when the embedding engine is unavailable.
45    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
56/// Conservative prose gate: substantial, letter-dense, low on code symbols.
57/// Excludes source code, JSON, logs and tables while accepting natural-language
58/// system prompts and user turns (including bulleted instructions).
59fn 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    // Real prose has sentences; source code, JSON, logs and tables largely do
70    // not. This is the signal that separates `let x = f(a);`-dense code (which
71    // slips under a pure symbol-ratio gate) from natural-language instructions.
72    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
79/// `true` if a content block carries a `cache_control` breakpoint — such a
80/// block anchors the client's prompt cache and must never be rewritten.
81fn block_has_cache_control(block: &Value) -> bool {
82    block.get("cache_control").is_some()
83}
84
85/// `true` if a `system` value (string or array of blocks) carries any
86/// `cache_control` breakpoint. Then it anchors the client's prompt cache and
87/// the whole field must be left verbatim. A plain string system prompt never
88/// carries one (Anthropic places `cache_control` on blocks), so it is safe.
89#[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
97/// Compress a JSON *string* field in place (e.g. OpenAI message `content`).
98/// Returns `true` if it was rewritten.
99pub 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
110/// Compress every `{ "type": "text", "text": … }` block in a content array
111/// (Anthropic message content / system blocks). Blocks carrying a
112/// `cache_control` breakpoint are skipped so client cache anchors survive.
113/// Returns the number of blocks rewritten.
114pub 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
133/// Compress an Anthropic top-level `system` field, which may be a plain string
134/// or an array of text blocks. Returns the number of segments rewritten.
135pub 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
149/// Compress an OpenAI chat message's `content`, which is either a plain string
150/// or an array of `{type:"text", text}` parts (multimodal). Returns the number
151/// of segments rewritten.
152pub 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
166/// Compress the plain-`text` parts of a Gemini `parts` array. `functionCall`,
167/// `functionResponse` and `inlineData` parts are never touched (tool I/O and
168/// binary), so only natural-language turns are squeezed. Returns segments
169/// rewritten.
170pub 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#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn long_prose() -> String {
195        // Natural-language paragraphs with a repeated sentence the squeeze can
196        // dedup, comfortably over the prose floor.
197        let p = "You are a meticulous senior engineer who values clarity and \
198                 correctness above all. Always explain your reasoning before \
199                 acting, and prefer small, reviewable changes over large ones. ";
200        format!("{p}\n\n{p}\n\n{p}")
201    }
202
203    #[test]
204    fn compresses_long_prose_deterministically() {
205        let text = long_prose();
206        let a = compress_prose(&text, 0.5);
207        let b = compress_prose(&text, 0.5);
208        assert_eq!(a, b, "compress_prose must be a pure function of its inputs");
209        assert!(a.is_some(), "long, duplicate-rich prose must compress");
210        assert!(count_tokens(&a.unwrap()) < count_tokens(&text));
211    }
212
213    #[test]
214    fn anti_inflation_leaves_short_text() {
215        // Below the prose floor → never touched.
216        assert_eq!(compress_prose("Be concise.", 1.0), None);
217    }
218
219    #[test]
220    fn anti_inflation_when_no_saving_possible() {
221        // Long but already-unique, high-entropy prose at a=0.0 (keep everything)
222        // cannot get smaller → None, never a same-or-bigger rewrite.
223        let unique = (0..40)
224            .map(|i| {
225                format!("Distinct instruction number {i} about handling edge case {i} carefully.")
226            })
227            .collect::<Vec<_>>()
228            .join("\n");
229        assert_eq!(compress_prose(&unique, 0.0), None);
230    }
231
232    #[test]
233    fn rejects_code_like_input() {
234        let code = (0..40)
235            .map(|i| format!("    let value_{i} = compute_{i}(ctx, opts);"))
236            .collect::<Vec<_>>()
237            .join("\n");
238        assert!(!looks_like_prose(&code));
239        assert_eq!(compress_prose(&code, 1.0), None);
240    }
241
242    #[test]
243    fn rejects_json_like_input() {
244        let json = r#"{"a": 1, "b": {"c": [1,2,3], "d": "x"}, "e": true, "f": null}"#.repeat(20);
245        assert!(!looks_like_prose(&json));
246    }
247
248    #[test]
249    fn text_blocks_skip_cache_control() {
250        let big = long_prose();
251        let mut blocks = vec![
252            serde_json::json!({"type": "text", "text": big, "cache_control": {"type": "ephemeral"}}),
253            serde_json::json!({"type": "text", "text": big}),
254        ];
255        let n = compress_text_blocks(&mut blocks, 0.5);
256        assert_eq!(n, 1, "only the non-cache_control block may be rewritten");
257        // The cached block is byte-identical to its original.
258        assert!(
259            blocks[0]["text"]
260                .as_str()
261                .unwrap()
262                .contains("meticulous senior engineer"),
263            "cache_control block must survive verbatim"
264        );
265    }
266
267    #[test]
268    fn system_value_handles_string_and_array() {
269        let big = long_prose();
270        let mut as_string = Value::String(big.clone());
271        assert_eq!(compress_system_value(&mut as_string, 0.5), 1);
272
273        let mut as_array = serde_json::json!([{"type": "text", "text": big}]);
274        let arr = as_array.as_array_mut().unwrap();
275        assert_eq!(compress_text_blocks(arr, 0.5), 1);
276    }
277}