Skip to main content

sqz_engine/
cache_manager.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use sha2::{Digest, Sha256};
5
6use crate::delta_encoder::DeltaEncoder;
7use crate::error::Result;
8use crate::pipeline::{CompressionPipeline, SessionContext};
9use crate::preset::Preset;
10use crate::session_store::SessionStore;
11use crate::types::CompressedContent;
12
13/// Outcome of a cache lookup in [`CacheManager`].
14pub enum CacheResult {
15    /// Previously seen content — returns a short inline reference (~13 tokens).
16    Dedup {
17        /// Inline token of the form `§ref:<hash_prefix>§`.
18        inline_ref: String,
19        /// Approximate token cost of the reference (always 13).
20        token_cost: u32,
21    },
22    /// Near-duplicate of cached content — returns a compact delta.
23    Delta {
24        /// The delta text (header + changed lines).
25        delta_text: String,
26        /// Approximate token cost of the delta.
27        token_cost: u32,
28        /// Similarity to the cached version.
29        similarity: f64,
30    },
31    /// Content not seen before — full compression result.
32    Fresh { output: CompressedContent },
33}
34
35/// Tracks when a dedup ref was last sent, so we can detect staleness.
36#[derive(Debug, Clone)]
37struct RefEntry {
38    /// The turn number when this ref was last sent to the LLM.
39    last_sent_turn: u64,
40}
41
42/// SHA-256 content-hash deduplication cache backed by [`SessionStore`],
43/// with delta encoding for near-duplicate content and compaction awareness.
44///
45/// The turn-counter heuristic: each call to `get_or_compress` increments a
46/// monotonic turn counter. When a dedup ref was last sent more than
47/// `max_ref_age_turns` turns ago, the ref is considered stale (the original
48/// content may have been compacted out of the LLM's context window) and the
49/// full compressed content is re-sent instead.
50pub struct CacheManager {
51    store: SessionStore,
52    max_size_bytes: u64,
53    delta_encoder: DeltaEncoder,
54    /// Monotonic turn counter — incremented on each get_or_compress call.
55    turn_counter: std::cell::Cell<u64>,
56    /// Maps content hash → turn when the dedup ref was last sent.
57    ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
58    /// Maximum age (in turns) before a dedup ref is considered stale.
59    /// After this many turns, the original content may have been compacted
60    /// out of the LLM's context window, so we re-send the full content.
61    max_ref_age_turns: u64,
62}
63
64impl CacheManager {
65    pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
66        Self {
67            store,
68            max_size_bytes,
69            delta_encoder: DeltaEncoder::new(),
70            turn_counter: std::cell::Cell::new(0),
71            ref_tracker: std::cell::RefCell::new(HashMap::new()),
72            max_ref_age_turns: 20,
73        }
74    }
75
76    /// Create a CacheManager with a custom ref staleness threshold.
77    pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
78        Self {
79            store,
80            max_size_bytes,
81            delta_encoder: DeltaEncoder::new(),
82            turn_counter: std::cell::Cell::new(0),
83            ref_tracker: std::cell::RefCell::new(HashMap::new()),
84            max_ref_age_turns,
85        }
86    }
87
88    /// Compute the SHA-256 hex digest of `bytes`.
89    fn sha256_hex(bytes: &[u8]) -> String {
90        let mut hasher = Sha256::new();
91        hasher.update(bytes);
92        format!("{:x}", hasher.finalize())
93    }
94
95    /// Advance the turn counter. Call this once per LLM interaction turn.
96    pub fn advance_turn(&self) {
97        self.turn_counter.set(self.turn_counter.get() + 1);
98    }
99
100    /// Get the current turn number.
101    pub fn current_turn(&self) -> u64 {
102        self.turn_counter.get()
103    }
104
105    /// Notify the cache that a context compaction has occurred.
106    ///
107    /// This marks ALL existing dedup refs as stale, forcing the next read
108    /// of any cached content to re-send the full compressed version instead
109    /// of a dangling `§ref:...§` token.
110    ///
111    /// Call this when:
112    /// - The harness signals a compaction event (PreCompact hook)
113    /// - A session is resumed after being idle
114    /// - The turn counter exceeds a threshold
115    pub fn notify_compaction(&self) {
116        self.ref_tracker.borrow_mut().clear();
117    }
118
119    /// Check if a dedup ref for the given hash is still fresh (likely still
120    /// in the LLM's context window).
121    fn is_ref_fresh(&self, hash: &str) -> bool {
122        let tracker = self.ref_tracker.borrow();
123        if let Some(entry) = tracker.get(hash) {
124            let age = self.turn_counter.get().saturating_sub(entry.last_sent_turn);
125            age < self.max_ref_age_turns
126        } else {
127            false
128        }
129    }
130
131    /// Record that a dedup ref was sent for the given hash at the current turn.
132    fn record_ref_sent(&self, hash: &str) {
133        self.ref_tracker.borrow_mut().insert(
134            hash.to_string(),
135            RefEntry {
136                last_sent_turn: self.turn_counter.get(),
137            },
138        );
139    }
140
141    /// Look up `content` in the cache with compaction awareness.
142    ///
143    /// - On exact dedup with fresh ref: return `CacheResult::Dedup` (~13 tokens).
144    /// - On exact dedup with stale ref: re-compress and return `CacheResult::Fresh`
145    ///   (the original content may have been compacted out of the LLM's context).
146    /// - On near-duplicate: return `CacheResult::Delta` with a compact diff.
147    /// - On cache miss: compress via `pipeline`, persist, return `CacheResult::Fresh`.
148    pub fn get_or_compress(
149        &self,
150        _path: &Path,
151        content: &[u8],
152        pipeline: &CompressionPipeline,
153    ) -> Result<CacheResult> {
154        let hash = Self::sha256_hex(content);
155
156        // Exact match — check if the ref is still fresh
157        if self.store.get_cache_entry(&hash)?.is_some() {
158            if self.is_ref_fresh(&hash) {
159                // Ref is fresh — the LLM likely still has the original in context
160                let hash_prefix = &hash[..16];
161                let inline_ref = format!("§ref:{hash_prefix}§");
162                // Update the sent timestamp
163                self.record_ref_sent(&hash);
164                return Ok(CacheResult::Dedup {
165                    inline_ref,
166                    token_cost: 13,
167                });
168            } else {
169                // Ref is stale — re-send the full compressed content.
170                // The original may have been compacted out of the LLM's context.
171                let text = String::from_utf8_lossy(content).into_owned();
172                let ctx = SessionContext {
173                    session_id: "cache".to_string(),
174                };
175                let preset = Preset::default();
176                let compressed = pipeline.compress(&text, &ctx, &preset)?;
177                // Record that we re-sent this content
178                self.record_ref_sent(&hash);
179                return Ok(CacheResult::Fresh { output: compressed });
180            }
181        }
182
183        // Near-duplicate check: compare against recent cache entries
184        let text = String::from_utf8_lossy(content).into_owned();
185        if let Some(delta_result) = self.try_delta_encode(&text)? {
186            // Store the new content in cache for future exact matches
187            let ctx = SessionContext {
188                session_id: "cache".to_string(),
189            };
190            let preset = Preset::default();
191            let compressed = pipeline.compress(&text, &ctx, &preset)?;
192            self.store.save_cache_entry(&hash, &compressed)?;
193            self.record_ref_sent(&hash);
194
195            let token_cost = (delta_result.delta_text.len() / 4) as u32;
196            return Ok(CacheResult::Delta {
197                delta_text: delta_result.delta_text,
198                token_cost: token_cost.max(5),
199                similarity: delta_result.similarity,
200            });
201        }
202
203        let ctx = SessionContext {
204            session_id: "cache".to_string(),
205        };
206        let preset = Preset::default();
207        let compressed = pipeline.compress(&text, &ctx, &preset)?;
208        self.store.save_cache_entry(&hash, &compressed)?;
209        // Record that this content was sent at the current turn
210        self.record_ref_sent(&hash);
211
212        Ok(CacheResult::Fresh { output: compressed })
213    }
214
215    /// Try to delta-encode content against recent cache entries.
216    /// Returns Some(DeltaResult) if a near-duplicate was found.
217    fn try_delta_encode(
218        &self,
219        new_content: &str,
220    ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
221        let entries = self.store.list_cache_entries_lru()?;
222
223        // Check the most recent entries (up to 10) for near-duplicates
224        let check_count = entries.len().min(10);
225        for (hash, _) in entries.iter().rev().take(check_count) {
226            if let Some(cached) = self.store.get_cache_entry(hash)? {
227                let hash_prefix = &hash[..hash.len().min(16)];
228                if let Ok(Some(delta)) =
229                    self.delta_encoder
230                        .encode(&cached.data, new_content, hash_prefix)
231                {
232                    // Only use delta if it's actually smaller than the full content
233                    if delta.delta_text.len() < new_content.len() {
234                        return Ok(Some(delta));
235                    }
236                }
237            }
238        }
239
240        Ok(None)
241    }
242
243    /// Check if `content` is already in the persistent cache (dedup lookup only).
244    ///
245    /// Returns `Some(inline_ref)` if cached AND the ref is still fresh,
246    /// `None` if the content is not cached or the ref is stale.
247    pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
248        let hash = Self::sha256_hex(content);
249        if self.store.get_cache_entry(&hash)?.is_some() {
250            if self.is_ref_fresh(&hash) {
251                let hash_prefix = &hash[..16];
252                self.record_ref_sent(&hash);
253                Ok(Some(format!("§ref:{hash_prefix}§")))
254            } else {
255                // Cache hit but ref is stale — don't return a dangling ref
256                Ok(None)
257            }
258        } else {
259            Ok(None)
260        }
261    }
262
263    /// Store a compressed result in the persistent cache, keyed by the
264    /// SHA-256 hash of the original content.
265    ///
266    /// Also records the ref as sent at the current turn for compaction tracking.
267    pub fn store_compressed(
268        &self,
269        original_content: &[u8],
270        compressed: &CompressedContent,
271    ) -> Result<()> {
272        let hash = Self::sha256_hex(original_content);
273        self.store.save_cache_entry(&hash, compressed)?;
274        self.record_ref_sent(&hash);
275        Ok(())
276    }
277
278    /// Invalidate the cache entry for `path` if its current content is known.
279    ///
280    /// Reads the file at `path`, computes its hash, and removes the matching
281    /// entry from the store.  If the file does not exist the call is a no-op.
282    pub fn invalidate(&self, path: &Path) -> Result<()> {
283        if !path.exists() {
284            return Ok(());
285        }
286        let bytes = std::fs::read(path)?;
287        let hash = Self::sha256_hex(&bytes);
288        self.store.delete_cache_entry(&hash)?;
289        Ok(())
290    }
291
292    /// Evict least-recently-used entries until total cache size is at or below
293    /// `max_size_bytes`.
294    ///
295    /// Returns the number of bytes freed.
296    pub fn evict_lru(&self) -> Result<u64> {
297        let entries = self.store.list_cache_entries_lru()?;
298
299        // Compute current total size.
300        let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
301        if total <= self.max_size_bytes {
302            return Ok(0);
303        }
304
305        let mut freed: u64 = 0;
306        let mut remaining = total;
307
308        for (hash, size) in &entries {
309            if remaining <= self.max_size_bytes {
310                break;
311            }
312            self.store.delete_cache_entry(hash)?;
313            freed += size;
314            remaining -= size;
315        }
316
317        Ok(freed)
318    }
319}
320
321// ── Tests ─────────────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::preset::{
327        BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
328        CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
329        ToolSelectionConfig, TruncateStringsConfig,
330    };
331    use crate::session_store::SessionStore;
332
333    fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
334        let dir = tempfile::tempdir().unwrap();
335        let path = dir.path().join("test.db");
336        let store = SessionStore::open_or_create(&path).unwrap();
337        (store, dir)
338    }
339
340    fn test_preset() -> Preset {
341        Preset {
342            preset: PresetMeta {
343                name: "test".into(),
344                version: "1.0".into(),
345                description: String::new(),
346            },
347            compression: CompressionConfig {
348                stages: vec![],
349                keep_fields: None,
350                strip_fields: None,
351                condense: Some(CondenseConfig {
352                    enabled: true,
353                    max_repeated_lines: 3,
354                }),
355                git_diff_fold: None,
356                strip_nulls: Some(StripNullsConfig { enabled: true }),
357                flatten: None,
358                truncate_strings: Some(TruncateStringsConfig {
359                    enabled: true,
360                    max_length: 500,
361                }),
362                collapse_arrays: Some(CollapseArraysConfig {
363                    enabled: true,
364                    max_items: 5,
365                    summary_template: "... and {remaining} more items".into(),
366                }),
367                custom_transforms: Some(CustomTransformsConfig { enabled: true }),
368            },
369            tool_selection: ToolSelectionConfig {
370                max_tools: 5,
371                similarity_threshold: 0.7,
372                default_tools: vec![],
373            },
374            budget: BudgetConfig {
375                warning_threshold: 0.70,
376                ceiling_threshold: 0.85,
377                default_window_size: 200_000,
378                agents: Default::default(),
379            },
380            terse_mode: TerseModeConfig {
381                enabled: false,
382                level: crate::preset::TerseLevel::Moderate,
383            },
384            model: ModelConfig {
385                family: "anthropic".into(),
386                primary: "claude-sonnet-4-20250514".into(),
387                local: String::new(),
388                complexity_threshold: 0.4,
389                pricing: None,
390            },
391        }
392    }
393
394    fn make_pipeline() -> CompressionPipeline {
395        CompressionPipeline::new(&test_preset())
396    }
397
398    #[test]
399    fn first_read_is_miss() {
400        let (store, _dir) = in_memory_store();
401        let cm = CacheManager::new(store, u64::MAX);
402        let pipeline = make_pipeline();
403        let content = b"hello world";
404        let result = cm
405            .get_or_compress(Path::new("file.txt"), content, &pipeline)
406            .unwrap();
407        assert!(matches!(result, CacheResult::Fresh { .. }));
408    }
409
410    #[test]
411    fn second_read_is_hit() {
412        let (store, _dir) = in_memory_store();
413        let cm = CacheManager::new(store, u64::MAX);
414        let pipeline = make_pipeline();
415        let content = b"hello world";
416        let path = Path::new("file.txt");
417
418        // First read — miss
419        cm.get_or_compress(path, content, &pipeline).unwrap();
420
421        // Second read — hit
422        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
423        match result {
424            CacheResult::Dedup {
425                inline_ref,
426                token_cost,
427            } => {
428                assert!(inline_ref.starts_with("§ref:"));
429                assert!(inline_ref.ends_with('§'));
430                assert_eq!(token_cost, 13);
431            }
432            CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
433        }
434    }
435
436    #[test]
437    fn different_content_is_miss() {
438        let (store, _dir) = in_memory_store();
439        let cm = CacheManager::new(store, u64::MAX);
440        let pipeline = make_pipeline();
441        let path = Path::new("file.txt");
442
443        cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
444        let result = cm
445            .get_or_compress(path, b"content v2", &pipeline)
446            .unwrap();
447        assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
448    }
449
450    #[test]
451    fn evict_lru_frees_bytes_when_over_limit() {
452        let (store, _dir) = in_memory_store();
453        // Very small limit so eviction triggers immediately.
454        let cm = CacheManager::new(store, 1);
455        let pipeline = make_pipeline();
456        let path = Path::new("f.txt");
457
458        // Populate cache with a few entries.
459        cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
460        cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
461        cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
462
463        let freed = cm.evict_lru().unwrap();
464        assert!(freed > 0, "expected bytes to be freed");
465    }
466
467    #[test]
468    fn evict_lru_no_op_when_under_limit() {
469        let (store, _dir) = in_memory_store();
470        let cm = CacheManager::new(store, u64::MAX);
471        let pipeline = make_pipeline();
472
473        cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
474            .unwrap();
475
476        let freed = cm.evict_lru().unwrap();
477        assert_eq!(freed, 0);
478    }
479
480    #[test]
481    fn invalidate_removes_entry() {
482        let dir = tempfile::tempdir().unwrap();
483        let file_path = dir.path().join("test.txt");
484        std::fs::write(&file_path, b"some content").unwrap();
485
486        let store_path = dir.path().join("store.db");
487        let store = SessionStore::open_or_create(&store_path).unwrap();
488        let cm = CacheManager::new(store, u64::MAX);
489        let pipeline = make_pipeline();
490
491        // Populate cache.
492        let content = std::fs::read(&file_path).unwrap();
493        cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
494
495        // Verify it's a hit.
496        let hit = cm
497            .get_or_compress(&file_path, &content, &pipeline)
498            .unwrap();
499        assert!(matches!(hit, CacheResult::Dedup { .. }));
500
501        cm.invalidate(&file_path).unwrap();
502
503        let miss = cm
504            .get_or_compress(&file_path, &content, &pipeline)
505            .unwrap();
506        assert!(matches!(miss, CacheResult::Fresh { .. }));
507    }
508
509    #[test]
510    fn invalidate_nonexistent_path_is_noop() {
511        let (store, _dir) = in_memory_store();
512        let cm = CacheManager::new(store, u64::MAX);
513        // Should not error.
514        cm.invalidate(Path::new("/nonexistent/path/file.txt"))
515            .unwrap();
516    }
517
518    // ── Compaction awareness tests ────────────────────────────────────────
519
520    #[test]
521    fn stale_ref_returns_fresh_instead_of_dedup() {
522        let (store, _dir) = in_memory_store();
523        // Set max_ref_age to 3 turns so refs go stale quickly
524        let cm = CacheManager::with_ref_age(store, u64::MAX, 3);
525        let pipeline = make_pipeline();
526        let content = b"hello world";
527        let path = Path::new("file.txt");
528
529        // First read — miss, records ref at turn 0
530        cm.get_or_compress(path, content, &pipeline).unwrap();
531
532        // Second read at turn 0 — ref is fresh (age 0 < 3)
533        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
534        assert!(matches!(result, CacheResult::Dedup { .. }), "ref should be fresh at turn 0");
535
536        // Advance past the staleness threshold
537        for _ in 0..4 {
538            cm.advance_turn();
539        }
540
541        // Third read at turn 4 — ref is stale (age 4 >= 3), should re-send full content
542        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
543        assert!(matches!(result, CacheResult::Fresh { .. }),
544            "stale ref should return Fresh, not Dedup");
545    }
546
547    #[test]
548    fn notify_compaction_invalidates_all_refs() {
549        let (store, _dir) = in_memory_store();
550        let cm = CacheManager::new(store, u64::MAX);
551        let pipeline = make_pipeline();
552        let path = Path::new("file.txt");
553
554        // Populate cache
555        cm.get_or_compress(path, b"content A", &pipeline).unwrap();
556        cm.get_or_compress(path, b"content B", &pipeline).unwrap();
557
558        // Both should be dedup hits
559        assert!(matches!(
560            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
561            CacheResult::Dedup { .. }
562        ));
563        assert!(matches!(
564            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
565            CacheResult::Dedup { .. }
566        ));
567
568        // Simulate compaction
569        cm.notify_compaction();
570
571        // After compaction, refs are stale — should return Fresh
572        assert!(matches!(
573            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
574            CacheResult::Fresh { .. }
575        ));
576        assert!(matches!(
577            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
578            CacheResult::Fresh { .. }
579        ));
580    }
581
582    #[test]
583    fn ref_refreshed_after_resend() {
584        let (store, _dir) = in_memory_store();
585        let cm = CacheManager::with_ref_age(store, u64::MAX, 3);
586        let pipeline = make_pipeline();
587        let content = b"hello world";
588        let path = Path::new("file.txt");
589
590        // First read — miss
591        cm.get_or_compress(path, content, &pipeline).unwrap();
592
593        // Advance past staleness
594        for _ in 0..4 {
595            cm.advance_turn();
596        }
597
598        // Read at turn 4 — stale, returns Fresh (re-sends content)
599        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
600        assert!(matches!(result, CacheResult::Fresh { .. }));
601
602        // Immediately read again — ref was refreshed by the re-send, should be Dedup
603        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
604        assert!(matches!(result, CacheResult::Dedup { .. }),
605            "ref should be fresh after re-send");
606    }
607
608    #[test]
609    fn check_dedup_returns_none_for_stale_ref() {
610        let (store, _dir) = in_memory_store();
611        let cm = CacheManager::with_ref_age(store, u64::MAX, 2);
612        let pipeline = make_pipeline();
613        let content = b"test content";
614        let path = Path::new("file.txt");
615
616        // Populate cache
617        cm.get_or_compress(path, content, &pipeline).unwrap();
618
619        // check_dedup should return Some (ref is fresh)
620        assert!(cm.check_dedup(content).unwrap().is_some());
621
622        // Advance past staleness
623        for _ in 0..3 {
624            cm.advance_turn();
625        }
626
627        // check_dedup should return None (ref is stale)
628        assert!(cm.check_dedup(content).unwrap().is_none(),
629            "stale ref should not be returned by check_dedup");
630    }
631
632    #[test]
633    fn advance_turn_increments_counter() {
634        let (store, _dir) = in_memory_store();
635        let cm = CacheManager::new(store, u64::MAX);
636        assert_eq!(cm.current_turn(), 0);
637        cm.advance_turn();
638        assert_eq!(cm.current_turn(), 1);
639        cm.advance_turn();
640        assert_eq!(cm.current_turn(), 2);
641    }
642
643    // ── Property-based tests ──────────────────────────────────────────────────
644
645    use proptest::prelude::*;
646
647    // ── Property 8: Cache deduplication ──────────────────────────────────────
648    // **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
649    //
650    // For any file content, reading the file twice through the CacheManager
651    // (with no content change between reads) SHALL return a cache hit on the
652    // second read with a reference token of approximately 13 tokens.
653
654    proptest! {
655        /// **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
656        ///
657        /// For any file content, the second read through CacheManager SHALL be
658        /// a cache hit with tokens == 13.
659        #[test]
660        fn prop_cache_deduplication(
661            content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
662        ) {
663            let (store, _dir) = in_memory_store();
664            let cm = CacheManager::new(store, u64::MAX);
665            let pipeline = make_pipeline();
666            let path = Path::new("file.txt");
667
668            // First read — must be a miss.
669            let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
670            prop_assert!(
671                matches!(first, CacheResult::Fresh { .. }),
672                "first read should be a cache miss"
673            );
674
675            let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
676            match second {
677                CacheResult::Dedup { inline_ref, token_cost } => {
678                    prop_assert_eq!(
679                        token_cost, 13,
680                        "cache hit should report ~13 reference tokens"
681                    );
682                    prop_assert!(
683                        inline_ref.starts_with("§ref:"),
684                        "reference token should start with §ref:"
685                    );
686                    prop_assert!(
687                        inline_ref.ends_with('§'),
688                        "reference token should end with §"
689                    );
690                }
691                CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
692                    prop_assert!(false, "second read should be a cache hit, not a miss");
693                }
694            }
695        }
696    }
697
698    // ── Property 9: Cache invalidation on content change ─────────────────────
699    // **Validates: Requirements 8.3, 18.3**
700    //
701    // For any cached file, if the file content changes (producing a different
702    // SHA-256 hash), the CacheManager SHALL treat the next read as a cache miss
703    // and re-compress the updated content.
704
705    proptest! {
706        /// **Validates: Requirements 8.3, 18.3**
707        ///
708        /// For any two distinct byte sequences, the first read of each is a
709        /// cache miss — content change always triggers re-compression.
710        #[test]
711        fn prop_cache_invalidation_on_content_change(
712            content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
713            content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
714        ) {
715            // Only meaningful when the two contents differ (different hashes).
716            prop_assume!(content_a != content_b);
717
718            let (store, _dir) = in_memory_store();
719            let cm = CacheManager::new(store, u64::MAX);
720            let pipeline = make_pipeline();
721            let path = Path::new("file.txt");
722
723            // Cache content_a.
724            let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
725            prop_assert!(
726                matches!(r1, CacheResult::Fresh { .. }),
727                "first read of content_a should be a miss"
728            );
729
730            let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
731            prop_assert!(
732                matches!(r2, CacheResult::Dedup { .. }),
733                "second read of content_a should be a hit"
734            );
735
736            let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
737            prop_assert!(
738                matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
739                "read with changed content should be a cache miss or delta"
740            );
741        }
742    }
743
744    // ── Property 10: Cache LRU eviction ──────────────────────────────────────
745    // **Validates: Requirements 8.5**
746    //
747    // For any cache state where total size exceeds the configured maximum, the
748    // CacheManager SHALL evict entries in LRU order until total size is at or
749    // below the limit.
750
751    proptest! {
752        /// **Validates: Requirements 8.5**
753        ///
754        /// After evict_lru, the total remaining cache size SHALL be at or below
755        /// max_size_bytes.
756        #[test]
757        fn prop_cache_lru_eviction(
758            // Generate 2-8 distinct content entries.
759            entries in proptest::collection::vec(
760                proptest::collection::vec(any::<u8>(), 10..=200usize),
761                2..=8usize,
762            ),
763        ) {
764            // Deduplicate entries so each has a unique hash.
765            let mut unique_entries: Vec<Vec<u8>> = Vec::new();
766            for e in &entries {
767                if !unique_entries.contains(e) {
768                    unique_entries.push(e.clone());
769                }
770            }
771            prop_assume!(unique_entries.len() >= 2);
772
773            let (store, _dir) = in_memory_store();
774            // Use a very small limit (1 byte) to guarantee eviction is needed.
775            let cm = CacheManager::new(store, 1);
776            let pipeline = make_pipeline();
777            let path = Path::new("f.txt");
778
779            // Populate the cache.
780            for entry in &unique_entries {
781                cm.get_or_compress(path, entry, &pipeline).unwrap();
782            }
783
784            // Evict LRU entries.
785            let freed = cm.evict_lru().unwrap();
786
787            // Bytes freed must be > 0 since total > 1 byte.
788            prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
789
790            // After eviction, total remaining size must be <= max_size_bytes (1).
791            // We verify by checking that evict_lru now returns 0 (nothing left to evict).
792            let freed_again = cm.evict_lru().unwrap();
793            prop_assert_eq!(
794                freed_again, 0,
795                "second evict_lru call should free 0 bytes (already at or below limit)"
796            );
797        }
798    }
799
800    // ── Property 34: Cache persistence across sessions ────────────────────────
801    // **Validates: Requirements 18.4**
802    //
803    // For any set of cache entries saved to the SessionStore, reloading the
804    // store (opening the same database file) SHALL produce the same cache
805    // entries, and a subsequent read with the same content hash SHALL return a
806    // cache hit.
807
808    proptest! {
809        /// **Validates: Requirements 18.4**
810        ///
811        /// Cache entries written in one CacheManager instance SHALL survive a
812        /// store close/reopen. After a process restart, the ref tracker is
813        /// empty so the first read returns Fresh (re-sends content since we
814        /// can't know if the LLM still has it in context). The second read
815        /// in the same session returns Dedup.
816        #[test]
817        fn prop_cache_persistence_across_sessions(
818            content in proptest::collection::vec(any::<u8>(), 1..=500usize),
819        ) {
820            use crate::session_store::SessionStore;
821
822            let dir = tempfile::tempdir().unwrap();
823            let db_path = dir.path().join("cache.db");
824            let path = Path::new("file.txt");
825
826            // Session 1: populate the cache.
827            {
828                let store = SessionStore::open_or_create(&db_path).unwrap();
829                let cm = CacheManager::new(store, u64::MAX);
830                let pipeline = make_pipeline();
831
832                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
833                prop_assert!(
834                    matches!(r, CacheResult::Fresh { .. }),
835                    "first read should be a miss"
836                );
837            }
838            // Store is dropped here — connection closed, ref tracker lost.
839
840            // Session 2: reopen the same database file.
841            {
842                let store = SessionStore::open_or_create(&db_path).unwrap();
843                let cm = CacheManager::new(store, u64::MAX);
844                let pipeline = make_pipeline();
845
846                // First read in new session: cache entry exists in SQLite but
847                // ref tracker is empty (process restarted). Returns Fresh to
848                // re-send content since we can't know if the LLM still has it.
849                let r1 = cm.get_or_compress(path, &content, &pipeline).unwrap();
850                prop_assert!(
851                    matches!(r1, CacheResult::Fresh { .. }),
852                    "first read after restart should re-send (ref tracker empty)"
853                );
854
855                // Second read in same session: ref was recorded by the first
856                // read, so now it's a Dedup hit.
857                let r2 = cm.get_or_compress(path, &content, &pipeline).unwrap();
858                match r2 {
859                    CacheResult::Dedup { token_cost, .. } => {
860                        prop_assert_eq!(
861                            token_cost, 13,
862                            "second read in same session should be dedup hit"
863                        );
864                    }
865                    CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
866                        prop_assert!(
867                            false,
868                            "second read should be a dedup hit after re-send"
869                        );
870                    }
871                }
872            }
873        }
874    }
875}