1use crate::error::Result;
12
13#[derive(Debug, Clone)]
15pub struct EvictionConfig {
16 pub max_age_turns: u64,
19 pub eviction_ratio: f64,
22 pub min_keep: usize,
25}
26
27impl Default for EvictionConfig {
28 fn default() -> Self {
29 Self {
30 max_age_turns: 30,
31 eviction_ratio: 0.3,
32 min_keep: 5,
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
39pub struct ContextItem {
40 pub id: String,
42 pub content: String,
44 pub last_accessed_turn: u64,
46 pub access_count: u32,
48 pub tokens: u32,
50 pub pinned: bool,
52}
53
54#[derive(Debug, Clone)]
56pub struct EvictionResult {
57 pub kept: Vec<ContextItem>,
59 pub evicted: Vec<EvictedItem>,
61 pub tokens_before: u32,
63 pub tokens_after: u32,
65 pub eviction_summary: String,
68}
69
70#[derive(Debug, Clone)]
72pub struct EvictedItem {
73 pub id: String,
75 pub summary: String,
77 pub original_tokens: u32,
79}
80
81pub fn evict(
89 items: &[ContextItem],
90 current_turn: u64,
91 config: &EvictionConfig,
92) -> Result<EvictionResult> {
93 if items.is_empty() {
94 return Ok(EvictionResult {
95 kept: vec![],
96 evicted: vec![],
97 tokens_before: 0,
98 tokens_after: 0,
99 eviction_summary: String::new(),
100 });
101 }
102
103 let tokens_before: u32 = items.iter().map(|i| i.tokens).sum();
104
105 let mut scored: Vec<(f64, usize)> = items
107 .iter()
108 .enumerate()
109 .map(|(idx, item)| {
110 let score = compute_retention_score(item, current_turn, config);
111 (score, idx)
112 })
113 .collect();
114
115 scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
117
118 let evict_count = ((items.len() as f64 * config.eviction_ratio).ceil() as usize)
120 .min(items.len().saturating_sub(config.min_keep));
121
122 let mut kept = Vec::new();
123 let mut evicted = Vec::new();
124
125 for (rank, &(_, idx)) in scored.iter().enumerate() {
126 let item = &items[idx];
127
128 if item.pinned || rank >= evict_count {
129 kept.push(item.clone());
130 } else {
131 evicted.push(EvictedItem {
132 id: item.id.clone(),
133 summary: summarize_for_eviction(&item.content),
134 original_tokens: item.tokens,
135 });
136 }
137 }
138
139 let tokens_after: u32 = kept.iter().map(|i| i.tokens).sum::<u32>()
140 + evicted.iter().map(|e| estimate_tokens(&e.summary)).sum::<u32>();
141
142 let eviction_summary = if evicted.is_empty() {
143 String::new()
144 } else {
145 format_eviction_summary(&evicted)
146 };
147
148 Ok(EvictionResult {
149 kept,
150 evicted,
151 tokens_before,
152 tokens_after,
153 eviction_summary,
154 })
155}
156
157pub fn should_evict(
159 total_tokens: u32,
160 budget_ceiling: u32,
161 warning_threshold: f64,
162) -> bool {
163 if budget_ceiling == 0 {
164 return false;
165 }
166 let usage_ratio = total_tokens as f64 / budget_ceiling as f64;
167 usage_ratio >= warning_threshold
168}
169
170fn compute_retention_score(
179 item: &ContextItem,
180 current_turn: u64,
181 config: &EvictionConfig,
182) -> f64 {
183 if item.pinned {
185 return f64::MAX;
186 }
187
188 let age = current_turn.saturating_sub(item.last_accessed_turn) as f64;
189 let max_age = config.max_age_turns as f64;
190
191 let recency = (-age / max_age.max(1.0)).exp();
193
194 let frequency = ((item.access_count as f64 + 1.0).ln()) / 5.0_f64.ln();
196 let frequency = frequency.min(1.0);
197
198 0.7 * recency + 0.3 * frequency
200}
201
202fn summarize_for_eviction(content: &str) -> String {
204 let first_line = content.lines().next().unwrap_or("");
205 let truncated = if first_line.len() > 80 {
206 format!("{}...", &first_line[..77])
207 } else {
208 first_line.to_string()
209 };
210 let line_count = content.lines().count();
211 let char_count = content.len();
212 format!("[evicted: {} lines, {} chars] {}", line_count, char_count, truncated)
213}
214
215fn format_eviction_summary(evicted: &[EvictedItem]) -> String {
217 let mut lines = vec![format!("[sqz compact: {} items evicted]", evicted.len())];
218 for item in evicted {
219 lines.push(format!(" {} — {}", item.id, item.summary));
220 }
221 let total_tokens: u32 = evicted.iter().map(|e| e.original_tokens).sum();
222 lines.push(format!(" ({} tokens freed)", total_tokens));
223 lines.join("\n")
224}
225
226fn estimate_tokens(text: &str) -> u32 {
227 ((text.len() as f64) / 4.0).ceil() as u32
228}
229
230#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn make_item(id: &str, tokens: u32, last_turn: u64, access_count: u32) -> ContextItem {
237 ContextItem {
238 id: id.to_string(),
239 content: format!("Content of {id} with some text to make it realistic"),
240 last_accessed_turn: last_turn,
241 access_count,
242 tokens,
243 pinned: false,
244 }
245 }
246
247 #[test]
248 fn test_empty_items() {
249 let result = evict(&[], 10, &EvictionConfig::default()).unwrap();
250 assert!(result.kept.is_empty());
251 assert!(result.evicted.is_empty());
252 assert_eq!(result.tokens_before, 0);
253 }
254
255 #[test]
256 fn test_evicts_oldest_items() {
257 let items = vec![
258 make_item("old1", 100, 0, 1),
259 make_item("old2", 100, 1, 1),
260 make_item("recent1", 100, 8, 1),
261 make_item("recent2", 100, 9, 1),
262 make_item("recent3", 100, 10, 1),
263 ];
264 let config = EvictionConfig {
265 eviction_ratio: 0.4,
266 min_keep: 3,
267 ..Default::default()
268 };
269 let result = evict(&items, 10, &config).unwrap();
270 assert_eq!(result.evicted.len(), 2, "should evict 2 oldest items");
271 assert!(result.evicted.iter().any(|e| e.id == "old1"));
272 assert!(result.evicted.iter().any(|e| e.id == "old2"));
273 assert_eq!(result.kept.len(), 3);
274 }
275
276 #[test]
277 fn test_pinned_items_never_evicted() {
278 let mut items = vec![
279 make_item("old_pinned", 100, 0, 1),
280 make_item("old_unpinned", 100, 0, 1),
281 make_item("recent", 100, 10, 1),
282 ];
283 items[0].pinned = true;
284
285 let config = EvictionConfig {
286 eviction_ratio: 0.5,
287 min_keep: 1,
288 ..Default::default()
289 };
290 let result = evict(&items, 10, &config).unwrap();
291 assert!(
292 result.kept.iter().any(|k| k.id == "old_pinned"),
293 "pinned item should be kept even though it's old"
294 );
295 }
296
297 #[test]
298 fn test_frequently_accessed_items_retained() {
299 let items = vec![
300 make_item("old_frequent", 100, 2, 50), make_item("old_rare", 100, 2, 1), make_item("recent", 100, 10, 1),
303 ];
304 let config = EvictionConfig {
305 eviction_ratio: 0.4,
306 min_keep: 2,
307 ..Default::default()
308 };
309 let result = evict(&items, 10, &config).unwrap();
310 assert!(
312 result.kept.iter().any(|k| k.id == "old_frequent"),
313 "frequently accessed item should be retained"
314 );
315 }
316
317 #[test]
318 fn test_min_keep_respected() {
319 let items = vec![
320 make_item("a", 100, 0, 1),
321 make_item("b", 100, 0, 1),
322 make_item("c", 100, 0, 1),
323 ];
324 let config = EvictionConfig {
325 eviction_ratio: 1.0, min_keep: 2,
327 ..Default::default()
328 };
329 let result = evict(&items, 10, &config).unwrap();
330 assert!(result.kept.len() >= 2, "should keep at least min_keep items");
331 }
332
333 #[test]
334 fn test_eviction_summary_format() {
335 let items = vec![
336 make_item("old", 500, 0, 1),
337 make_item("recent", 100, 10, 1),
338 ];
339 let config = EvictionConfig {
340 eviction_ratio: 0.5,
341 min_keep: 1,
342 ..Default::default()
343 };
344 let result = evict(&items, 10, &config).unwrap();
345 if !result.evicted.is_empty() {
346 assert!(result.eviction_summary.contains("[sqz compact:"));
347 assert!(result.eviction_summary.contains("tokens freed"));
348 }
349 }
350
351 #[test]
352 fn test_should_evict_threshold() {
353 assert!(!should_evict(50000, 200000, 0.7)); assert!(should_evict(150000, 200000, 0.7)); assert!(should_evict(200000, 200000, 0.7)); assert!(!should_evict(0, 0, 0.7)); }
358
359 #[test]
360 fn test_tokens_after_less_than_before() {
361 let items = vec![
362 make_item("a", 500, 0, 1),
363 make_item("b", 500, 1, 1),
364 make_item("c", 100, 10, 1),
365 ];
366 let config = EvictionConfig {
367 eviction_ratio: 0.5,
368 min_keep: 1,
369 ..Default::default()
370 };
371 let result = evict(&items, 10, &config).unwrap();
372 assert!(
373 result.tokens_after <= result.tokens_before,
374 "eviction should not increase token count: {} vs {}",
375 result.tokens_after, result.tokens_before
376 );
377 }
378
379 #[test]
380 fn test_summarize_for_eviction() {
381 let content = "fn main() {\n println!(\"hello\");\n}\n";
382 let summary = summarize_for_eviction(content);
383 assert!(summary.contains("[evicted:"));
384 assert!(summary.contains("fn main()"));
385 }
386
387 use proptest::prelude::*;
388
389 proptest! {
390 #[test]
392 fn prop_eviction_reduces_tokens(
393 n in 2usize..=10usize,
394 current_turn in 5u64..=50u64,
395 ) {
396 let items: Vec<ContextItem> = (0..n)
397 .map(|i| make_item(&format!("item_{i}"), 100 + (i as u32 * 50), i as u64, 1))
398 .collect();
399 let config = EvictionConfig {
400 eviction_ratio: 0.3,
401 min_keep: 1,
402 ..Default::default()
403 };
404 let result = evict(&items, current_turn, &config).unwrap();
405 prop_assert!(
406 result.tokens_after <= result.tokens_before,
407 "tokens_after ({}) should be <= tokens_before ({})",
408 result.tokens_after, result.tokens_before
409 );
410 }
411
412 #[test]
414 fn prop_eviction_accounting(
415 n in 2usize..=10usize,
416 ) {
417 let items: Vec<ContextItem> = (0..n)
418 .map(|i| make_item(&format!("item_{i}"), 100, i as u64, 1))
419 .collect();
420 let config = EvictionConfig::default();
421 let result = evict(&items, 50, &config).unwrap();
422 prop_assert_eq!(
423 result.kept.len() + result.evicted.len(),
424 items.len(),
425 "kept + evicted should equal original count"
426 );
427 }
428 }
429}