Skip to main content

lean_ctx_proxy/
prose.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Frozen-region prose compression for the proxy (#710).
3//!
4//! The proxy already prunes OLD tool-result content at a frozen, cache-aware
5//! boundary (see [`super::history_prune`]). This module adds the *prose*
6//! counterpart: when an operator opts in via `[proxy.role_aggressiveness]`,
7//! system and user free-text is squeezed with a deterministic, anti-inflation
8//! pass. Because the output is a pure function of `(text, aggressiveness)`, a
9//! frozen-region rewrite is byte-identical on every later turn, so the provider
10//! prompt-cache prefix stays valid (#498).
11//!
12//! Assistant turns are never passed to this module — the passthrough guarantee
13//! lives at the call sites, which only invoke it for system/user roles.
14
15use 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
21/// Below this many chars a string is never worth a prose pass — the squeeze can
22/// only add risk, not save meaningful tokens. Keeps short instructions intact.
23const MIN_PROSE_CHARS: usize = 400;
24
25/// Code/structured symbols whose density cleanly separates source, JSON, logs
26/// and tables from natural-language prose.
27const CODE_SYMBOLS: &str = "{}<>;=|\\$`[]";
28
29/// Compress a single prose string at `aggressiveness` (`0.0–1.0`).
30///
31/// Returns `Some(compressed)` only when the text is long enough, *looks like*
32/// prose, and the squeeze actually saves tokens; otherwise `None` (leave the
33/// original intact — the anti-inflation guard). Deterministic: the result is a
34/// pure function of `(text, aggressiveness)`.
35#[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    // `density_target` is the fraction of content to keep; map it to a char
42    // budget. Below the budget the squeeze is a near-lossless dedup pass; when it
43    // must actually shrink (budget < len) we use cache-safe extractive ranking
44    // (#895) — keeping the most central sentences instead of just the prefix —
45    // which falls back to truncation when the embedding engine is unavailable.
46    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    // The squeeze drops and ranks sentences: it loses text. Under protection
58    // it is applied only with a recovery copy bound to the lineage of `text`
59    // (the shared gate); otherwise the prose stays whole and is counted.
60    super::compress::gate::shortened(text, squeezed)
61}
62
63/// Conservative prose gate: substantial, letter-dense, low on code symbols.
64/// Excludes source code, JSON, logs and tables while accepting natural-language
65/// system prompts and user turns (including bulleted instructions).
66fn 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    // Real prose has sentences; source code, JSON, logs and tables largely do
77    // not. This is the signal that separates `let x = f(a);`-dense code (which
78    // slips under a pure symbol-ratio gate) from natural-language instructions.
79    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
86/// `true` if a content block carries a `cache_control` breakpoint — such a
87/// block anchors the client's prompt cache and must never be rewritten.
88fn block_has_cache_control(block: &Value) -> bool {
89    block.get("cache_control").is_some()
90}
91
92/// `true` if a `system` value (string or array of blocks) carries any
93/// `cache_control` breakpoint. Then it anchors the client's prompt cache and
94/// the whole field must be left verbatim. A plain string system prompt never
95/// carries one (Anthropic places `cache_control` on blocks), so it is safe.
96#[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
104/// Compress a JSON *string* field in place (e.g. OpenAI message `content`).
105/// Returns `true` if it was rewritten.
106pub 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
117/// Compress every `{ "type": "text", "text": … }` block in a content array
118/// (Anthropic message content / system blocks). Blocks carrying a
119/// `cache_control` breakpoint are skipped so client cache anchors survive.
120/// Returns the number of blocks rewritten.
121pub 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
140/// Compress an Anthropic top-level `system` field, which may be a plain string
141/// or an array of text blocks. Returns the number of segments rewritten.
142pub 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
156/// Compress an OpenAI chat message's `content`, which is either a plain string
157/// or an array of `{type:"text", text}` parts (multimodal). Returns the number
158/// of segments rewritten.
159pub 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
173/// Compress the plain-`text` parts of a Gemini `parts` array. `functionCall`,
174/// `functionResponse` and `inlineData` parts are never touched (tool I/O and
175/// binary), so only natural-language turns are squeezed. Returns segments
176/// rewritten.
177pub 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/// Rough token estimate for a JSON value (chars / 4).
198#[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        // Natural-language paragraphs with a repeated sentence the squeeze can
210        // dedup, comfortably over the prose floor.
211        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        // Below the prose floor → never touched.
232        assert_eq!(compress_prose("Be concise.", 1.0), None);
233    }
234
235    #[test]
236    fn anti_inflation_when_no_saving_possible() {
237        // Long but already-unique, high-entropy prose at a=0.0 (keep everything)
238        // cannot get smaller → None, never a same-or-bigger rewrite.
239        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        // The cached block is byte-identical to its original.
276        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}