Skip to main content

sqz_engine/
context_evictor.rs

1/// Proactive Context Eviction — gives the agent explicit control over
2/// context management by summarizing and evicting stale content.
3///
4/// Unlike passive compaction (which the LLM harness does unpredictably),
5/// proactive eviction lets sqz decide what to keep and what to summarize
6/// based on recency, access frequency, and content importance.
7///
8/// The agent can call `sqz compact` to trigger eviction, or the engine
9/// can suggest eviction when the budget approaches the ceiling.
10
11use crate::error::Result;
12
13/// Configuration for context eviction.
14#[derive(Debug, Clone)]
15pub struct EvictionConfig {
16    /// Maximum age (in turns) before content is eligible for eviction.
17    /// Default: 30.
18    pub max_age_turns: u64,
19    /// Fraction of context to evict when triggered (0.0–1.0).
20    /// Default: 0.3 (evict the oldest 30%).
21    pub eviction_ratio: f64,
22    /// Minimum number of items to always keep (most recent).
23    /// Default: 5.
24    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/// A tracked context item with metadata for eviction decisions.
38#[derive(Debug, Clone)]
39pub struct ContextItem {
40    /// Unique identifier (e.g., file path or content hash prefix).
41    pub id: String,
42    /// The content (or a summary of it).
43    pub content: String,
44    /// Turn number when this item was last accessed.
45    pub last_accessed_turn: u64,
46    /// Number of times this item has been accessed.
47    pub access_count: u32,
48    /// Estimated token count.
49    pub tokens: u32,
50    /// Whether this item is pinned (protected from eviction).
51    pub pinned: bool,
52}
53
54/// Result of an eviction pass.
55#[derive(Debug, Clone)]
56pub struct EvictionResult {
57    /// Items that were kept (most recent / most accessed / pinned).
58    pub kept: Vec<ContextItem>,
59    /// Items that were evicted (summarized to one-line references).
60    pub evicted: Vec<EvictedItem>,
61    /// Total tokens before eviction.
62    pub tokens_before: u32,
63    /// Total tokens after eviction.
64    pub tokens_after: u32,
65    /// A compact summary of what was evicted, suitable for injecting
66    /// into the context so the agent knows what's no longer available.
67    pub eviction_summary: String,
68}
69
70/// A summarized evicted item.
71#[derive(Debug, Clone)]
72pub struct EvictedItem {
73    /// Original item id.
74    pub id: String,
75    /// One-line summary of the evicted content.
76    pub summary: String,
77    /// Original token count.
78    pub original_tokens: u32,
79}
80
81/// Run a proactive eviction pass on a set of context items.
82///
83/// Items are scored by a combination of recency and access frequency.
84/// The lowest-scoring items (up to `eviction_ratio` of the total) are
85/// evicted and replaced with one-line summaries.
86///
87/// Pinned items are never evicted.
88pub 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    // Score each item: higher = more valuable = keep
106    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    // Sort by score ascending (lowest score = evict first)
116    scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
117
118    // Determine how many to evict
119    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
157/// Suggest whether eviction should be triggered based on current usage.
158pub 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
170// ── Scoring ───────────────────────────────────────────────────────────────
171
172/// Compute a retention score for an item. Higher = more valuable.
173///
174/// Score = recency_weight × recency + frequency_weight × frequency
175///
176/// Recency: 1.0 for current turn, decays toward 0.0 as age increases.
177/// Frequency: log2(access_count + 1) normalized to [0, 1].
178fn compute_retention_score(
179    item: &ContextItem,
180    current_turn: u64,
181    config: &EvictionConfig,
182) -> f64 {
183    // Pinned items get maximum score
184    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    // Recency: exponential decay
192    let recency = (-age / max_age.max(1.0)).exp();
193
194    // Frequency: logarithmic scaling
195    let frequency = ((item.access_count as f64 + 1.0).ln()) / 5.0_f64.ln();
196    let frequency = frequency.min(1.0);
197
198    // Combined score (recency weighted 70%, frequency 30%)
199    0.7 * recency + 0.3 * frequency
200}
201
202/// Create a one-line summary of content for eviction.
203fn 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
215/// Format a summary of all evicted items.
216fn 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// ── Tests ─────────────────────────────────────────────────────────────────
231
232#[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),  // old but accessed 50 times
301            make_item("old_rare", 100, 2, 1),        // old and rarely accessed
302            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        // The frequently accessed old item should be retained over the rare one
311        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, // try to evict everything
326            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));  // 25% usage
354        assert!(should_evict(150000, 200000, 0.7));   // 75% usage
355        assert!(should_evict(200000, 200000, 0.7));   // 100% usage
356        assert!(!should_evict(0, 0, 0.7));             // zero budget
357    }
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        /// Eviction never increases total token count.
391        #[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        /// Kept + evicted = original count.
413        #[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}