Skip to main content

sqz_engine/
kv_cache_optimizer.rs

1//! KV Cache Attention Sink Preservation — compresses content while preserving
2//! tokens critical for LLM KV cache efficiency.
3//!
4//! LLMs allocate disproportionate attention to the first few tokens ("attention
5//! sinks") and to tokens at provider-specific cache boundaries. This module
6//! ensures those tokens are never removed during compression.
7//!
8//! # Provider-specific behavior
9//! - **Anthropic**: preserves tokens after `cache_control` markers (detected by
10//!   `[cache_break]` sentinel in content) and the first N attention sink tokens.
11//! - **OpenAI**: preserves the first 1024 tokens (automatic cache boundary) and
12//!   the first N attention sink tokens.
13//! - **Google/Gemini**: preserves only the first N attention sink tokens (no
14//!   provider cache boundary).
15//! - **Local**: preserves only the first N attention sink tokens.
16//!
17//! # Example
18//! ```
19//! use sqz_engine::kv_cache_optimizer::compress_with_sinks;
20//! use sqz_engine::types::ModelFamily;
21//!
22//! let content = "Important system prompt.\n".repeat(100);
23//! let result = compress_with_sinks(&content, &ModelFamily::AnthropicClaude);
24//! assert!(!result.is_empty());
25//! ```
26
27use crate::types::ModelFamily;
28
29/// Default number of attention sink tokens to always preserve.
30const DEFAULT_SINK_TOKENS: usize = 4;
31
32/// Approximate characters per token (used for heuristic token boundaries).
33const CHARS_PER_TOKEN: usize = 4;
34
35/// OpenAI automatic cache boundary in tokens.
36const OPENAI_CACHE_TOKENS: usize = 1024;
37
38/// Sentinel marker for Anthropic cache_control breakpoints in content.
39const ANTHROPIC_CACHE_SENTINEL: &str = "[cache_break]";
40
41/// Compress content while preserving attention sink tokens and provider
42/// cache boundaries.
43///
44/// # Arguments
45/// * `content` — the full text content to compress
46/// * `model_family` — the model family, used to determine cache boundary rules
47///
48/// # Returns
49/// Compressed content string with critical tokens preserved.
50pub fn compress_with_sinks(content: &str, model_family: &ModelFamily) -> String {
51    if content.is_empty() {
52        return String::new();
53    }
54
55    let sink_chars = DEFAULT_SINK_TOKENS * CHARS_PER_TOKEN;
56
57    // Determine the protected prefix length based on provider
58    let protected_prefix = match model_family {
59        ModelFamily::AnthropicClaude => {
60            // Protect attention sinks + content up to the last cache_control marker
61            let cache_end = find_anthropic_cache_end(content);
62            sink_chars.max(cache_end)
63        }
64        ModelFamily::OpenAiGpt => {
65            // Protect attention sinks + first 1024 tokens (≈4096 chars)
66            let openai_boundary = OPENAI_CACHE_TOKENS * CHARS_PER_TOKEN;
67            sink_chars.max(openai_boundary)
68        }
69        ModelFamily::GoogleGemini | ModelFamily::Local(_) => {
70            // Only protect attention sinks
71            sink_chars
72        }
73    };
74
75    // Clamp to content length
76    // Clamp to length AND to a char boundary — sink_chars is a byte
77    // estimate (tokens × 4) that can land mid-character on non-ASCII
78    // content (issue #34 class).
79    let protected_prefix =
80        crate::text_boundary::floor_char_boundary(content, protected_prefix);
81
82    // Split content into protected and compressible regions
83    let protected = &content[..protected_prefix];
84    let compressible = &content[protected_prefix..];
85
86    if compressible.is_empty() {
87        return content.to_string();
88    }
89
90    // Compress the non-protected region by removing redundant whitespace
91    // and collapsing repeated lines
92    let compressed_tail = compress_region(compressible);
93
94    format!("{protected}{compressed_tail}")
95}
96
97/// Compress content with a custom sink token count.
98///
99/// Same as [`compress_with_sinks`] but allows overriding the default
100/// attention sink token count.
101pub fn compress_with_custom_sinks(
102    content: &str,
103    model_family: &ModelFamily,
104    sink_tokens: usize,
105) -> String {
106    if content.is_empty() {
107        return String::new();
108    }
109
110    let sink_chars = sink_tokens * CHARS_PER_TOKEN;
111
112    let protected_prefix = match model_family {
113        ModelFamily::AnthropicClaude => {
114            let cache_end = find_anthropic_cache_end(content);
115            sink_chars.max(cache_end)
116        }
117        ModelFamily::OpenAiGpt => {
118            let openai_boundary = OPENAI_CACHE_TOKENS * CHARS_PER_TOKEN;
119            sink_chars.max(openai_boundary)
120        }
121        ModelFamily::GoogleGemini | ModelFamily::Local(_) => sink_chars,
122    };
123
124    let protected_prefix =
125        crate::text_boundary::floor_char_boundary(content, protected_prefix);
126    let protected = &content[..protected_prefix];
127    let compressible = &content[protected_prefix..];
128
129    if compressible.is_empty() {
130        return content.to_string();
131    }
132
133    let compressed_tail = compress_region(compressible);
134    format!("{protected}{compressed_tail}")
135}
136
137// ── Internal helpers ──────────────────────────────────────────────────────
138
139/// Find the byte offset after the last `[cache_break]` sentinel in content.
140/// Returns 0 if no sentinel is found.
141fn find_anthropic_cache_end(content: &str) -> usize {
142    if let Some(pos) = content.rfind(ANTHROPIC_CACHE_SENTINEL) {
143        pos + ANTHROPIC_CACHE_SENTINEL.len()
144    } else {
145        0
146    }
147}
148
149/// Simple compression of a text region: collapse consecutive blank lines,
150/// remove trailing whitespace, and deduplicate consecutive identical lines.
151fn compress_region(text: &str) -> String {
152    let mut result = Vec::new();
153    let mut prev_line: Option<&str> = None;
154    let mut blank_count = 0;
155
156    for line in text.lines() {
157        let trimmed = line.trim_end();
158
159        if trimmed.is_empty() {
160            blank_count += 1;
161            if blank_count <= 1 {
162                result.push(String::new());
163            }
164            prev_line = None;
165            continue;
166        }
167
168        blank_count = 0;
169
170        // Skip consecutive duplicate lines
171        if prev_line == Some(trimmed) {
172            continue;
173        }
174
175        result.push(trimmed.to_string());
176        prev_line = Some(trimmed);
177    }
178
179    // Preserve trailing newline if original had one
180    let mut out = result.join("\n");
181    if text.ends_with('\n') && !out.ends_with('\n') {
182        out.push('\n');
183    }
184    out
185}
186
187// ── Tests ─────────────────────────────────────────────────────────────────────
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_empty_content() {
195        let result = compress_with_sinks("", &ModelFamily::AnthropicClaude);
196        assert!(result.is_empty());
197    }
198
199    #[test]
200    fn test_short_content_preserved() {
201        // Content shorter than the sink region should be fully preserved
202        let content = "hello";
203        let result = compress_with_sinks(content, &ModelFamily::AnthropicClaude);
204        assert_eq!(result, content);
205    }
206
207    #[test]
208    fn test_anthropic_cache_sentinel_preserved() {
209        let mut content = String::new();
210        content.push_str("System prompt line 1\n");
211        content.push_str("System prompt line 2\n");
212        content.push_str(ANTHROPIC_CACHE_SENTINEL);
213        content.push('\n');
214        // Add compressible content after the sentinel
215        for _ in 0..50 {
216            content.push_str("repeated line\n");
217        }
218
219        let result = compress_with_sinks(&content, &ModelFamily::AnthropicClaude);
220
221        // Everything up to and including the sentinel should be preserved
222        assert!(
223            result.contains(ANTHROPIC_CACHE_SENTINEL),
224            "cache sentinel should be preserved"
225        );
226        // The repeated lines after should be compressed
227        assert!(
228            result.len() < content.len(),
229            "compressed ({}) should be shorter than original ({})",
230            result.len(),
231            content.len()
232        );
233    }
234
235    #[test]
236    fn test_openai_preserves_first_1024_tokens() {
237        // Create content longer than 1024 tokens (≈4096 chars)
238        let prefix = "x".repeat(4096);
239        let suffix = "repeated line\n".repeat(100);
240        let content = format!("{prefix}{suffix}");
241
242        let result = compress_with_sinks(&content, &ModelFamily::OpenAiGpt);
243
244        // The first 4096 chars should be preserved exactly
245        assert!(
246            result.starts_with(&prefix),
247            "first 4096 chars should be preserved for OpenAI"
248        );
249        // Overall should be compressed
250        assert!(result.len() < content.len());
251    }
252
253    #[test]
254    fn test_local_model_only_preserves_sinks() {
255        let content = "important\n".repeat(100);
256        let result = compress_with_sinks(&content, &ModelFamily::Local("llama".into()));
257
258        // First DEFAULT_SINK_TOKENS * CHARS_PER_TOKEN chars preserved
259        let sink_chars = DEFAULT_SINK_TOKENS * CHARS_PER_TOKEN;
260        let expected_prefix = &content[..sink_chars.min(content.len())];
261        assert!(
262            result.starts_with(expected_prefix),
263            "sink tokens should be preserved"
264        );
265    }
266
267    #[test]
268    fn test_custom_sink_count() {
269        let content = "a".repeat(200);
270        let result = compress_with_custom_sinks(
271            &content,
272            &ModelFamily::GoogleGemini,
273            10, // 10 sink tokens = 40 chars
274        );
275        // First 40 chars should be preserved
276        assert!(result.starts_with(&"a".repeat(40)));
277    }
278
279    #[test]
280    fn test_compress_region_deduplicates() {
281        let text = "line a\nline a\nline a\nline b\nline b\n";
282        let compressed = compress_region(text);
283        assert_eq!(compressed, "line a\nline b\n");
284    }
285
286    #[test]
287    fn test_compress_region_collapses_blanks() {
288        let text = "line a\n\n\n\n\nline b\n";
289        let compressed = compress_region(text);
290        assert_eq!(compressed, "line a\n\nline b\n");
291    }
292
293    // ── Property-based tests ──────────────────────────────────────────────
294
295    use proptest::prelude::*;
296
297    fn arb_model_family() -> impl Strategy<Value = ModelFamily> {
298        prop_oneof![
299            Just(ModelFamily::AnthropicClaude),
300            Just(ModelFamily::OpenAiGpt),
301            Just(ModelFamily::GoogleGemini),
302            Just(ModelFamily::Local("test".into())),
303        ]
304    }
305
306    proptest! {
307        /// Compressed output is never longer than the original.
308        #[test]
309        fn prop_compressed_not_longer(
310            content in "[a-z \n]{10,500}",
311            model in arb_model_family(),
312        ) {
313            let result = compress_with_sinks(&content, &model);
314            prop_assert!(
315                result.len() <= content.len(),
316                "compressed ({}) should be <= original ({})",
317                result.len(),
318                content.len()
319            );
320        }
321
322        /// The attention sink prefix is always preserved.
323        #[test]
324        fn prop_sink_prefix_preserved(
325            content in "[a-z]{50,200}",
326            model in arb_model_family(),
327        ) {
328            let result = compress_with_sinks(&content, &model);
329            let sink_chars = (DEFAULT_SINK_TOKENS * CHARS_PER_TOKEN).min(content.len());
330            let expected_prefix = &content[..sink_chars];
331            prop_assert!(
332                result.starts_with(expected_prefix),
333                "sink prefix should be preserved"
334            );
335        }
336
337        /// Empty input always produces empty output.
338        #[test]
339        fn prop_empty_in_empty_out(model in arb_model_family()) {
340            let result = compress_with_sinks("", &model);
341            prop_assert!(result.is_empty());
342        }
343    }
344}