Skip to main content

sqz_engine/
cache_manager.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::time::Duration;
4
5use sha2::{Digest, Sha256};
6
7use crate::delta_encoder::DeltaEncoder;
8use crate::error::Result;
9use crate::pipeline::{CompressionPipeline, SessionContext};
10use crate::preset::Preset;
11use crate::session_store::SessionStore;
12use crate::types::CompressedContent;
13
14/// Outcome of a cache lookup in [`CacheManager`].
15///
16/// The cache has three possible outcomes:
17/// - **Dedup**: exact match, returns a tiny `§ref:HASH§` token (~13 tokens)
18/// - **Delta**: near-duplicate, returns a compact diff against the cached version
19/// - **Fresh**: cache miss, returns the full compressed output
20pub enum CacheResult {
21    /// Previously seen content — returns a short inline reference (~13 tokens).
22    Dedup {
23        /// Inline token of the form `§ref:<hash_prefix>§`.
24        inline_ref: String,
25        /// Approximate token cost of the reference (always 13).
26        token_cost: u32,
27    },
28    /// Near-duplicate of cached content — returns a compact delta.
29    Delta {
30        /// The delta text (header + changed lines).
31        delta_text: String,
32        /// Approximate token cost of the delta.
33        token_cost: u32,
34        /// Similarity to the cached version (0.0–1.0).
35        similarity: f64,
36    },
37    /// Content not seen before — full compression result.
38    Fresh { output: CompressedContent },
39}
40
41/// Result of resolving a dedup-ref prefix via
42/// [`CacheManager::expand_prefix`].
43///
44/// Two shapes because not every stored entry can be round-tripped to the
45/// raw bytes: cache entries written before the `original` column was
46/// introduced (or by a code path that didn't thread originals through)
47/// fall into `CompressedOnly`. The CLI uses this to pick a message for
48/// the user.
49#[derive(Debug, Clone)]
50pub enum ExpandResult {
51    /// Full pre-compression bytes available — this is what `sqz expand`
52    /// was invented to return.
53    Original {
54        /// Full 64-hex SHA-256 of the original content. Lets callers
55        /// print `expanded a1b2c3d4 (full hash a1b2c3d4…)`.
56        hash: String,
57        /// The raw bytes the agent asked to see.
58        bytes: Vec<u8>,
59    },
60    /// Only the compressed form is stored. Agent still gets legible
61    /// output; the CLI attaches a note so the user knows why re-running
62    /// uncompressed may be worthwhile.
63    CompressedOnly {
64        hash: String,
65        /// Compressed text as served to the LLM originally.
66        compressed: String,
67    },
68}
69
70/// Tracks when a dedup ref was last sent, so we can detect staleness.
71///
72/// Historically used for an in-memory per-process turn counter; now kept
73/// only for interface compatibility (clear on notify_compaction). Actual
74/// staleness is computed from SQLite `accessed_at` timestamps so it works
75/// across the shell-hook invocation model where each sqz process is short-
76/// lived. See the comment on `is_ref_fresh` for details.
77#[derive(Debug, Clone)]
78#[allow(dead_code)]
79struct RefEntry {
80    /// The turn number when this ref was last sent to the LLM.
81    last_sent_turn: u64,
82}
83
84/// SHA-256 content-hash deduplication cache backed by [`SessionStore`],
85/// with delta encoding for near-duplicate content and compaction awareness.
86///
87/// # Freshness model
88///
89/// A dedup ref is considered fresh (safe to serve instead of the full
90/// content) when the cache entry's `accessed_at` timestamp in SQLite is
91/// within `max_ref_age` of now. When sqz is invoked from shell hooks each
92/// invocation is a short-lived process, so the freshness check must be
93/// persistent — in-memory state is gone the moment the process exits.
94///
95/// The previous turn-counter heuristic was in-memory only and therefore
96/// never registered freshness across hook invocations, which silently
97/// disabled the dedup feature in production. Issue found April 18 2026.
98///
99/// Default TTL: 30 minutes. Empirically matches a typical active coding
100/// session before a context compaction. Use [`with_ref_age`] to tune.
101pub struct CacheManager {
102    store: SessionStore,
103    max_size_bytes: u64,
104    delta_encoder: DeltaEncoder,
105    /// Retained for notify_compaction's semantic ("forget all tracked refs"),
106    /// but no longer consulted for freshness checks.
107    #[allow(dead_code)]
108    turn_counter: std::cell::Cell<u64>,
109    /// Retained for notify_compaction; cleared on compaction events.
110    #[allow(dead_code)]
111    ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
112    /// Maximum wall-clock age before a dedup ref is considered stale.
113    /// After this duration we assume the LLM's context window has rolled
114    /// over enough to have dropped the original content, so we re-send the
115    /// full version instead of a dangling ref.
116    max_ref_age: Duration,
117    /// Records the instant at which the in-memory compaction flag was set.
118    /// Any cache entry whose `accessed_at` predates this instant is stale.
119    /// Reset by [`notify_compaction`].
120    compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
121}
122
123impl CacheManager {
124    /// Create a new cache manager backed by the given session store.
125    ///
126    /// `max_size_bytes` controls when LRU eviction kicks in. A good default
127    /// is 512 MB (`512 * 1024 * 1024`). Dedup refs go stale after 30 minutes
128    /// of wall-clock time by default — use [`with_ref_age`] to tune.
129    pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
130        Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
131    }
132
133    /// Create a CacheManager with a custom ref staleness threshold measured
134    /// in turns. The turn count is converted to wall-clock time by assuming
135    /// ~1 minute per turn (a rough approximation; the real freshness check
136    /// uses SQLite timestamps). This constructor exists for backward
137    /// compatibility with tests that previously advanced a turn counter.
138    #[doc(hidden)]
139    pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
140        Self::with_ref_age_duration(
141            store,
142            max_size_bytes,
143            Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
144        )
145    }
146
147    /// Create a CacheManager with an explicit wall-clock ref-age cap.
148    pub fn with_ref_age_duration(
149        store: SessionStore,
150        max_size_bytes: u64,
151        max_ref_age: Duration,
152    ) -> Self {
153        Self {
154            store,
155            max_size_bytes,
156            delta_encoder: DeltaEncoder::new(),
157            turn_counter: std::cell::Cell::new(0),
158            ref_tracker: std::cell::RefCell::new(HashMap::new()),
159            max_ref_age,
160            compaction_marker: std::cell::Cell::new(None),
161        }
162    }
163
164    /// Compute the SHA-256 hex digest of `bytes`.
165    fn sha256_hex(bytes: &[u8]) -> String {
166        let mut hasher = Sha256::new();
167        hasher.update(bytes);
168        format!("{:x}", hasher.finalize())
169    }
170
171    /// Advance the turn counter. Retained for API compatibility; not used
172    /// for freshness. The context_evictor still reads `current_turn` for
173    /// LRU scoring during `sqz compact`.
174    pub fn advance_turn(&self) {
175        self.turn_counter.set(self.turn_counter.get() + 1);
176    }
177
178    /// Get the current turn number. Used by the context_evictor for scoring.
179    pub fn current_turn(&self) -> u64 {
180        self.turn_counter.get()
181    }
182
183    /// Notify the cache that a context compaction has occurred.
184    ///
185    /// Persists a compaction timestamp into the session store so any cache
186    /// entry whose `accessed_at` predates the marker is considered stale
187    /// by **every subsequent sqz process**, not just this one. The shell-
188    /// hook invocation model means this method is typically called from a
189    /// short-lived `sqz hook precompact` process, and the check runs in a
190    /// different `sqz compress` process milliseconds later.
191    ///
192    /// Call this when:
193    /// - The harness signals a compaction event (PreCompact hook)
194    /// - A session is resumed after being idle
195    /// - The user runs `sqz compact`
196    pub fn notify_compaction(&self) {
197        let now = chrono::Utc::now();
198        self.compaction_marker.set(Some(now));
199        self.ref_tracker.borrow_mut().clear();
200        // Persist the marker so other sqz processes see the invalidation.
201        // Silently swallow a write error: losing the marker means some
202        // refs may survive the compaction and show as dedup hits in the
203        // next few calls — annoying, not wrong (the agent still receives
204        // valid content; it just sees a short-ref it has to resolve).
205        let _ = self
206            .store
207            .set_metadata("last_compaction_at", &now.to_rfc3339());
208    }
209
210    /// Check if a dedup ref for the given hash is still fresh (likely still
211    /// in the LLM's context window).
212    ///
213    /// Uses the SQLite `accessed_at` timestamp rather than the in-memory
214    /// turn counter. This works across sqz process invocations: shell hooks
215    /// spawn a new sqz process per intercepted command, so any in-memory
216    /// counter would reset every time. The database survives.
217    ///
218    /// The compaction marker is read from SQLite on every check so that
219    /// a `sqz hook precompact` call from another process immediately
220    /// invalidates refs in the current process. Without the persistent
221    /// read, the invalidation would only affect the process that called
222    /// notify_compaction — which is never the same process that serves
223    /// dedup hits.
224    fn is_ref_fresh(&self, hash: &str) -> bool {
225        let accessed = match self.store.get_cache_entry_accessed_at(hash) {
226            Ok(Some(ts)) => ts,
227            _ => return false,
228        };
229        // In-memory compaction marker (set in this process).
230        if let Some(marker) = self.compaction_marker.get() {
231            if accessed < marker {
232                return false;
233            }
234        }
235        // Persistent compaction marker — set by `sqz hook precompact` in
236        // a different process. Without this read the in-memory marker is
237        // never consulted because each hook invocation is a fresh process.
238        if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
239            if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
240                if accessed < marker {
241                    return false;
242                }
243            }
244        }
245        let age = (chrono::Utc::now() - accessed)
246            .to_std()
247            .unwrap_or(Duration::from_secs(0));
248        age < self.max_ref_age
249    }
250
251    /// Record that a dedup ref was sent for the given hash. Updates the
252    /// persistent `accessed_at` timestamp so subsequent freshness checks
253    /// see this send. Silently swallows SQLite errors — losing a touch
254    /// means the next call may treat the ref as stale and re-send, which
255    /// is strictly worse on tokens but never wrong.
256    fn record_ref_sent(&self, hash: &str) {
257        let _ = self.store.touch_cache_entry(hash);
258    }
259
260    /// Look up `content` in the cache with compaction awareness.
261    ///
262    /// - On exact dedup with fresh ref: return `CacheResult::Dedup` (~13 tokens).
263    /// - On exact dedup with stale ref: re-compress and return `CacheResult::Fresh`
264    ///   (the original content may have been compacted out of the LLM's context).
265    /// - On near-duplicate: return `CacheResult::Delta` with a compact diff.
266    /// - On cache miss: compress via `pipeline`, persist, return `CacheResult::Fresh`.
267    pub fn get_or_compress(
268        &self,
269        _path: &Path,
270        content: &[u8],
271        pipeline: &CompressionPipeline,
272    ) -> Result<CacheResult> {
273        let hash = Self::sha256_hex(content);
274
275        // Exact match — check if the ref is still fresh
276        // Exact match — probe without touching accessed_at, then check
277        // freshness. Touching on the probe would make every ref appear
278        // fresh immediately (the timestamp we just wrote is `now`).
279        let exists = self.store.cache_entry_exists(&hash)?;
280        if exists {
281            if self.is_ref_fresh(&hash) {
282                // Ref is fresh — the LLM likely still has the original in context
283                let hash_prefix = &hash[..16];
284                let inline_ref = format!("§ref:{hash_prefix}§");
285                // Update the sent timestamp
286                self.record_ref_sent(&hash);
287                return Ok(CacheResult::Dedup {
288                    inline_ref,
289                    token_cost: 13,
290                });
291            } else {
292                // Ref is stale — re-send the full compressed content.
293                // The original may have been compacted out of the LLM's context.
294                let text = String::from_utf8_lossy(content).into_owned();
295                let ctx = SessionContext {
296                    session_id: "cache".to_string(),
297                };
298                let preset = Preset::default();
299                let compressed = pipeline.compress(&text, &ctx, &preset)?;
300                // Record that we re-sent this content
301                self.record_ref_sent(&hash);
302                return Ok(CacheResult::Fresh { output: compressed });
303            }
304        }
305
306        // Near-duplicate check: compare against recent cache entries
307        let text = String::from_utf8_lossy(content).into_owned();
308        if let Some(delta_result) = self.try_delta_encode(&text)? {
309            // Store the new content in cache for future exact matches
310            let ctx = SessionContext {
311                session_id: "cache".to_string(),
312            };
313            let preset = Preset::default();
314            let compressed = pipeline.compress(&text, &ctx, &preset)?;
315            // Persist the raw bytes so `sqz expand <prefix>` can round-trip.
316            self.store
317                .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
318            self.record_ref_sent(&hash);
319
320            let token_cost = (delta_result.delta_text.len() / 4) as u32;
321            return Ok(CacheResult::Delta {
322                delta_text: delta_result.delta_text,
323                token_cost: token_cost.max(5),
324                similarity: delta_result.similarity,
325            });
326        }
327
328        let ctx = SessionContext {
329            session_id: "cache".to_string(),
330        };
331        let preset = Preset::default();
332        let compressed = pipeline.compress(&text, &ctx, &preset)?;
333        self.store
334            .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
335        // Record that this content was sent at the current turn
336        self.record_ref_sent(&hash);
337
338        Ok(CacheResult::Fresh { output: compressed })
339    }
340
341    /// Try to delta-encode content against recent cache entries.
342    /// Returns Some(DeltaResult) if a near-duplicate was found.
343    fn try_delta_encode(
344        &self,
345        new_content: &str,
346    ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
347        let entries = self.store.list_cache_entries_lru()?;
348
349        // Check the most recent entries (up to 10) for near-duplicates
350        let check_count = entries.len().min(10);
351        for (hash, _) in entries.iter().rev().take(check_count) {
352            if let Some(cached) = self.store.get_cache_entry(hash)? {
353                let hash_prefix = &hash[..hash.len().min(16)];
354                if let Ok(Some(delta)) =
355                    self.delta_encoder
356                        .encode(&cached.data, new_content, hash_prefix)
357                {
358                    // Only use delta if it's actually smaller than the full content
359                    if delta.delta_text.len() < new_content.len() {
360                        return Ok(Some(delta));
361                    }
362                }
363            }
364        }
365
366        Ok(None)
367    }
368
369    /// Check if `content` is already in the persistent cache (dedup lookup only).
370    ///
371    /// Returns `Some(inline_ref)` if cached AND the ref is still fresh,
372    /// `None` if the content is not cached or the ref is stale.
373    ///
374    /// Unlike [`get_or_compress`], this method does not touch `accessed_at`
375    /// until after the freshness check — otherwise every read would make
376    /// itself "fresh."
377    pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
378        let hash = Self::sha256_hex(content);
379        // Probe existence without touching accessed_at.
380        let fresh = self.is_ref_fresh(&hash);
381        if fresh {
382            let hash_prefix = &hash[..16];
383            self.record_ref_sent(&hash);
384            Ok(Some(format!("§ref:{hash_prefix}§")))
385        } else {
386            // If the entry exists but is stale, don't return a dangling ref.
387            // If it doesn't exist at all, same result: no dedup.
388            Ok(None)
389        }
390    }
391
392    /// Store a compressed result in the persistent cache, keyed by the
393    /// SHA-256 hash of the original content.
394    ///
395    /// Also records the ref as sent at the current turn for compaction tracking.
396    /// Persists `original_content` alongside `compressed` so that
397    /// `sqz expand <prefix>` can recover the raw bytes for agents that
398    /// cannot parse `§ref:…§` dedup tokens.
399    pub fn store_compressed(
400        &self,
401        original_content: &[u8],
402        compressed: &CompressedContent,
403    ) -> Result<()> {
404        let hash = Self::sha256_hex(original_content);
405        self.store
406            .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
407        self.record_ref_sent(&hash);
408        Ok(())
409    }
410
411    /// Resolve a hex prefix (the 16-char tail of a `§ref:<prefix>§` token,
412    /// or any longer hex string pasted by a user) to the cached content.
413    ///
414    /// Designed for the `sqz expand <prefix>` CLI: the agent sees a ref
415    /// token it can't parse, runs `sqz expand a1b2c3d4e5f6g7h8`, and gets
416    /// back the raw bytes that produced the ref. This is the
417    /// user-visible escape hatch SquireNed asked for.
418    ///
419    /// Three outcomes:
420    ///
421    /// * `Ok(Some(Original))` — the original bytes were captured when
422    ///   the entry was stored (new cache entries from v0.10.0+ always
423    ///   capture the original). Write `bytes` to stdout.
424    /// * `Ok(Some(CompressedOnly))` — the entry exists but its `original`
425    ///   column is `NULL` (pre-migration data). We still return the
426    ///   compressed form — always legible, always useful — and the CLI
427    ///   surfaces a note that tells the user to re-run their original
428    ///   command with `--no-cache` to capture a truly uncompressed copy.
429    /// * `Ok(None)` — no entry matches. Usually means the ref was
430    ///   truncated from a different sqz database (a different machine,
431    ///   a wiped `~/.sqz/sessions.db`, etc.).
432    /// * `Err(_)` — prefix was ambiguous or the DB is broken. The error
433    ///   carries a user-readable message explaining what went wrong.
434    ///
435    /// Touches `accessed_at` on hit (consistent with `get_cache_entry`).
436    pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
437        let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
438            return Ok(None);
439        };
440
441        // Prefer the original bytes when we have them — that's the
442        // whole point of this feature.
443        if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
444            return Ok(Some(ExpandResult::Original { hash, bytes }));
445        }
446        Ok(Some(ExpandResult::CompressedOnly {
447            hash,
448            compressed: compressed_entry.data,
449        }))
450    }
451
452    /// Invalidate the cache entry for `path` if its current content is known.
453    ///
454    /// Reads the file at `path`, computes its hash, and removes the matching
455    /// entry from the store.  If the file does not exist the call is a no-op.
456    pub fn invalidate(&self, path: &Path) -> Result<()> {
457        if !path.exists() {
458            return Ok(());
459        }
460        let bytes = std::fs::read(path)?;
461        let hash = Self::sha256_hex(&bytes);
462        self.store.delete_cache_entry(&hash)?;
463        Ok(())
464    }
465
466    /// Evict least-recently-used entries until total cache size is at or below
467    /// `max_size_bytes`.
468    ///
469    /// Returns the number of bytes freed.
470    pub fn evict_lru(&self) -> Result<u64> {
471        let entries = self.store.list_cache_entries_lru()?;
472
473        // Compute current total size.
474        let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
475        if total <= self.max_size_bytes {
476            return Ok(0);
477        }
478
479        let mut freed: u64 = 0;
480        let mut remaining = total;
481
482        for (hash, size) in &entries {
483            if remaining <= self.max_size_bytes {
484                break;
485            }
486            self.store.delete_cache_entry(hash)?;
487            freed += size;
488            remaining -= size;
489        }
490
491        Ok(freed)
492    }
493}
494
495// ── Tests ─────────────────────────────────────────────────────────────────────
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::preset::{
501        BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
502        CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
503        ToolSelectionConfig, TruncateStringsConfig,
504    };
505    use crate::session_store::SessionStore;
506
507    fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
508        let dir = tempfile::tempdir().unwrap();
509        let path = dir.path().join("test.db");
510        let store = SessionStore::open_or_create(&path).unwrap();
511        (store, dir)
512    }
513
514    fn test_preset() -> Preset {
515        Preset {
516            preset: PresetMeta {
517                name: "test".into(),
518                version: "1.0".into(),
519                description: String::new(),
520            },
521            compression: CompressionConfig {
522                stages: vec![],
523                keep_fields: None,
524                strip_fields: None,
525                condense: Some(CondenseConfig {
526                    enabled: true,
527                    max_repeated_lines: 3,
528                }),
529                git_diff_fold: None,
530                strip_nulls: Some(StripNullsConfig { enabled: true }),
531                flatten: None,
532                truncate_strings: Some(TruncateStringsConfig {
533                    enabled: true,
534                    max_length: 500,
535                }),
536                collapse_arrays: Some(CollapseArraysConfig {
537                    enabled: true,
538                    max_items: 5,
539                    summary_template: "... and {remaining} more items".into(),
540                }),
541                custom_transforms: Some(CustomTransformsConfig { enabled: true }),
542            },
543            tool_selection: ToolSelectionConfig {
544                max_tools: 5,
545                similarity_threshold: 0.7,
546                default_tools: vec![],
547            },
548            budget: BudgetConfig {
549                warning_threshold: 0.70,
550                ceiling_threshold: 0.85,
551                default_window_size: 200_000,
552                agents: Default::default(),
553            },
554            terse_mode: TerseModeConfig {
555                enabled: false,
556                level: crate::preset::TerseLevel::Moderate,
557            },
558            model: ModelConfig {
559                family: "anthropic".into(),
560                primary: "claude-sonnet-4-20250514".into(),
561                local: String::new(),
562                complexity_threshold: 0.4,
563                pricing: None,
564            },
565        }
566    }
567
568    fn make_pipeline() -> CompressionPipeline {
569        CompressionPipeline::new(&test_preset())
570    }
571
572    #[test]
573    fn first_read_is_miss() {
574        let (store, _dir) = in_memory_store();
575        let cm = CacheManager::new(store, u64::MAX);
576        let pipeline = make_pipeline();
577        let content = b"hello world";
578        let result = cm
579            .get_or_compress(Path::new("file.txt"), content, &pipeline)
580            .unwrap();
581        assert!(matches!(result, CacheResult::Fresh { .. }));
582    }
583
584    #[test]
585    fn second_read_is_hit() {
586        let (store, _dir) = in_memory_store();
587        let cm = CacheManager::new(store, u64::MAX);
588        let pipeline = make_pipeline();
589        let content = b"hello world";
590        let path = Path::new("file.txt");
591
592        // First read — miss
593        cm.get_or_compress(path, content, &pipeline).unwrap();
594
595        // Second read — hit
596        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
597        match result {
598            CacheResult::Dedup {
599                inline_ref,
600                token_cost,
601            } => {
602                assert!(inline_ref.starts_with("§ref:"));
603                assert!(inline_ref.ends_with('§'));
604                assert_eq!(token_cost, 13);
605            }
606            CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
607        }
608    }
609
610    #[test]
611    fn different_content_is_miss() {
612        let (store, _dir) = in_memory_store();
613        let cm = CacheManager::new(store, u64::MAX);
614        let pipeline = make_pipeline();
615        let path = Path::new("file.txt");
616
617        cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
618        let result = cm
619            .get_or_compress(path, b"content v2", &pipeline)
620            .unwrap();
621        assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
622    }
623
624    #[test]
625    fn evict_lru_frees_bytes_when_over_limit() {
626        let (store, _dir) = in_memory_store();
627        // Very small limit so eviction triggers immediately.
628        let cm = CacheManager::new(store, 1);
629        let pipeline = make_pipeline();
630        let path = Path::new("f.txt");
631
632        // Populate cache with a few entries.
633        cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
634        cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
635        cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
636
637        let freed = cm.evict_lru().unwrap();
638        assert!(freed > 0, "expected bytes to be freed");
639    }
640
641    #[test]
642    fn evict_lru_no_op_when_under_limit() {
643        let (store, _dir) = in_memory_store();
644        let cm = CacheManager::new(store, u64::MAX);
645        let pipeline = make_pipeline();
646
647        cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
648            .unwrap();
649
650        let freed = cm.evict_lru().unwrap();
651        assert_eq!(freed, 0);
652    }
653
654    #[test]
655    fn invalidate_removes_entry() {
656        let dir = tempfile::tempdir().unwrap();
657        let file_path = dir.path().join("test.txt");
658        std::fs::write(&file_path, b"some content").unwrap();
659
660        let store_path = dir.path().join("store.db");
661        let store = SessionStore::open_or_create(&store_path).unwrap();
662        let cm = CacheManager::new(store, u64::MAX);
663        let pipeline = make_pipeline();
664
665        // Populate cache.
666        let content = std::fs::read(&file_path).unwrap();
667        cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
668
669        // Verify it's a hit.
670        let hit = cm
671            .get_or_compress(&file_path, &content, &pipeline)
672            .unwrap();
673        assert!(matches!(hit, CacheResult::Dedup { .. }));
674
675        cm.invalidate(&file_path).unwrap();
676
677        let miss = cm
678            .get_or_compress(&file_path, &content, &pipeline)
679            .unwrap();
680        assert!(matches!(miss, CacheResult::Fresh { .. }));
681    }
682
683    #[test]
684    fn invalidate_nonexistent_path_is_noop() {
685        let (store, _dir) = in_memory_store();
686        let cm = CacheManager::new(store, u64::MAX);
687        // Should not error.
688        cm.invalidate(Path::new("/nonexistent/path/file.txt"))
689            .unwrap();
690    }
691
692    // ── Compaction / freshness tests ──────────────────────────────────────
693    //
694    // These tests used to exercise an in-memory turn counter. Freshness is
695    // now computed from SQLite `accessed_at` timestamps so dedup works
696    // across the shell-hook model (each hook invocation is a fresh
697    // process). The tests below use wall-clock durations instead.
698
699    #[test]
700    fn stale_ref_returns_fresh_instead_of_dedup() {
701        let (store, _dir) = in_memory_store();
702        // Set max_ref_age to 0 — every ref goes stale immediately.
703        let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
704        let pipeline = make_pipeline();
705        let content = b"hello world";
706        let path = Path::new("file.txt");
707
708        // First read — miss. accessed_at recorded.
709        cm.get_or_compress(path, content, &pipeline).unwrap();
710
711        // Second read — with TTL=0 the ref is already stale, should re-send.
712        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
713        assert!(
714            matches!(result, CacheResult::Fresh { .. }),
715            "stale ref (TTL=0) should return Fresh, not Dedup"
716        );
717    }
718
719    #[test]
720    fn fresh_ref_returns_dedup() {
721        let (store, _dir) = in_memory_store();
722        // Generous TTL: one day. Refs stay fresh for the life of the test.
723        let cm = CacheManager::with_ref_age_duration(
724            store,
725            u64::MAX,
726            Duration::from_secs(86_400),
727        );
728        let pipeline = make_pipeline();
729        let content = b"hello world";
730        let path = Path::new("file.txt");
731
732        cm.get_or_compress(path, content, &pipeline).unwrap();
733        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
734        assert!(
735            matches!(result, CacheResult::Dedup { .. }),
736            "fresh ref should dedup"
737        );
738    }
739
740    #[test]
741    fn notify_compaction_invalidates_all_refs() {
742        let (store, _dir) = in_memory_store();
743        let cm = CacheManager::with_ref_age_duration(
744            store,
745            u64::MAX,
746            Duration::from_secs(86_400),
747        );
748        let pipeline = make_pipeline();
749        let path = Path::new("file.txt");
750
751        // Populate cache — every subsequent read is a dedup hit.
752        cm.get_or_compress(path, b"content A", &pipeline).unwrap();
753        cm.get_or_compress(path, b"content B", &pipeline).unwrap();
754        assert!(matches!(
755            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
756            CacheResult::Dedup { .. }
757        ));
758        assert!(matches!(
759            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
760            CacheResult::Dedup { .. }
761        ));
762
763        // Simulate a context compaction. The compaction marker is set to
764        // `now`; any cache entry whose accessed_at predates this moment is
765        // treated as stale even though the TTL hasn't expired.
766        // Sleep 10ms to ensure `now` is strictly after the last touch.
767        std::thread::sleep(std::time::Duration::from_millis(10));
768        cm.notify_compaction();
769
770        // After compaction, refs predate the marker — re-send full content.
771        assert!(matches!(
772            cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
773            CacheResult::Fresh { .. }
774        ));
775        assert!(matches!(
776            cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
777            CacheResult::Fresh { .. }
778        ));
779    }
780
781    #[test]
782    fn ref_refreshed_after_resend() {
783        let (store, _dir) = in_memory_store();
784        // TTL of 10ms: a fresh send bumps accessed_at, so immediately after
785        // the re-send the ref is fresh again.
786        let cm = CacheManager::with_ref_age_duration(
787            store,
788            u64::MAX,
789            Duration::from_millis(10),
790        );
791        let pipeline = make_pipeline();
792        let content = b"hello world";
793        let path = Path::new("file.txt");
794
795        cm.get_or_compress(path, content, &pipeline).unwrap();
796        // Wait past the TTL so the entry is stale.
797        std::thread::sleep(std::time::Duration::from_millis(25));
798
799        // Stale — must re-send Fresh. The re-send bumps accessed_at.
800        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
801        assert!(matches!(result, CacheResult::Fresh { .. }));
802
803        // Immediately read again — the freshly-updated accessed_at is
804        // within the 10ms TTL, so the ref is fresh.
805        let result = cm.get_or_compress(path, content, &pipeline).unwrap();
806        assert!(
807            matches!(result, CacheResult::Dedup { .. }),
808            "ref should be fresh after re-send"
809        );
810    }
811
812    #[test]
813    fn check_dedup_returns_none_for_stale_ref() {
814        let (store, _dir) = in_memory_store();
815        let cm = CacheManager::with_ref_age_duration(
816            store,
817            u64::MAX,
818            Duration::from_millis(10),
819        );
820        let pipeline = make_pipeline();
821        let content = b"test content";
822        let path = Path::new("file.txt");
823
824        cm.get_or_compress(path, content, &pipeline).unwrap();
825
826        // Immediately fresh.
827        assert!(cm.check_dedup(content).unwrap().is_some());
828
829        // Wait past TTL.
830        std::thread::sleep(std::time::Duration::from_millis(25));
831        assert!(
832            cm.check_dedup(content).unwrap().is_none(),
833            "stale ref should not be returned by check_dedup"
834        );
835    }
836
837    #[test]
838    fn advance_turn_increments_counter() {
839        // The counter is retained for context_evictor compatibility.
840        let (store, _dir) = in_memory_store();
841        let cm = CacheManager::new(store, u64::MAX);
842        assert_eq!(cm.current_turn(), 0);
843        cm.advance_turn();
844        assert_eq!(cm.current_turn(), 1);
845        cm.advance_turn();
846        assert_eq!(cm.current_turn(), 2);
847    }
848
849    // ── Expand feature tests ────────────────────────────────────────────
850
851    #[test]
852    fn expand_returns_original_bytes_for_new_entry() {
853        // New cache entries (written after the `original` column migration)
854        // must always round-trip back to the exact bytes the agent sent.
855        // This is the core guarantee `sqz expand` exists to provide.
856        let (store, _dir) = in_memory_store();
857        let cm = CacheManager::new(store, u64::MAX);
858        let pipeline = make_pipeline();
859        let path = Path::new("x.txt");
860        let content = b"hello\nworld\nthis is the original\n";
861
862        // Seed the cache by running the content through get_or_compress.
863        let first = cm.get_or_compress(path, content, &pipeline).unwrap();
864        let CacheResult::Fresh { .. } = first else {
865            panic!("first read should be a Fresh miss, got something else");
866        };
867
868        // Find the ref prefix by doing a second read (which dedups).
869        let second = cm.get_or_compress(path, content, &pipeline).unwrap();
870        let inline_ref = match second {
871            CacheResult::Dedup { inline_ref, .. } => inline_ref,
872            _ => panic!("second read should dedup"),
873        };
874
875        // Strip the `§ref:` / `§` wrappers to get the raw prefix.
876        let prefix = inline_ref
877            .strip_prefix("§ref:")
878            .and_then(|s| s.strip_suffix('§'))
879            .expect("unexpected ref format");
880
881        let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
882        match result {
883            ExpandResult::Original { bytes, hash } => {
884                assert_eq!(bytes, content, "expand must return exact original bytes");
885                assert!(
886                    hash.starts_with(prefix),
887                    "returned full hash {hash} must start with prefix {prefix}"
888                );
889                assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
890            }
891            ExpandResult::CompressedOnly { .. } => {
892                panic!("new cache entries must carry the original bytes");
893            }
894        }
895    }
896
897    #[test]
898    fn expand_prefix_rejects_nonhex_input() {
899        // Agents paste all sorts of things — ref tokens with the §'s still
900        // attached, mixed-case hashes, stray whitespace. The store layer
901        // normalises most of this but we also want to reject obvious
902        // garbage without touching SQLite.
903        let (store, _dir) = in_memory_store();
904        let cm = CacheManager::new(store, u64::MAX);
905        assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
906        assert!(cm.expand_prefix("not a hex").unwrap().is_none());
907        assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
908        assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
909    }
910
911    #[test]
912    fn expand_prefix_returns_none_for_unknown_prefix() {
913        // Very common in practice: agent pastes a ref from a different
914        // machine, a different sqz install, or after a cache wipe.
915        let (store, _dir) = in_memory_store();
916        let cm = CacheManager::new(store, u64::MAX);
917        let pipeline = make_pipeline();
918        // Seed one entry.
919        let _ = cm
920            .get_or_compress(
921                Path::new("y.txt"),
922                b"some cached content that won't match the prefix below",
923                &pipeline,
924            )
925            .unwrap();
926        assert!(
927            cm.expand_prefix("0000000000000000").unwrap().is_none(),
928            "prefix that matches nothing must return None, not error"
929        );
930    }
931
932    #[test]
933    fn expand_prefix_errors_on_ambiguous_match() {
934        // Two entries whose hashes both start with the same hex prefix
935        // must be detected and reported. The caller is expected to
936        // surface "use a longer prefix" — we don't want to silently
937        // pick one.
938        let (store, _dir) = in_memory_store();
939        // Hand-craft two entries with prefixes that collide on "ab".
940        let compressed = crate::types::CompressedContent {
941            data: "compressed-a".to_string(),
942            tokens_compressed: 1,
943            tokens_original: 10,
944            stages_applied: vec![],
945            compression_ratio: 0.1,
946            provenance: Default::default(),
947            verify: None,
948        };
949        store
950            .save_cache_entry_with_original(
951                &format!("ab{}", "0".repeat(62)),
952                &compressed,
953                Some(b"content a"),
954            )
955            .unwrap();
956        store
957            .save_cache_entry_with_original(
958                &format!("ab{}", "1".repeat(62)),
959                &compressed,
960                Some(b"content b"),
961            )
962            .unwrap();
963
964        let cm = CacheManager::new(store, u64::MAX);
965        // Unambiguous 3-char prefixes resolve:
966        let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
967        assert!(matches!(ok_a, ExpandResult::Original { .. }));
968        // Ambiguous 2-char prefix errors out.
969        let err = cm.expand_prefix("ab").unwrap_err();
970        let msg = err.to_string();
971        assert!(
972            msg.contains("multiple entries") || msg.contains("longer prefix"),
973            "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
974        );
975    }
976
977    #[test]
978    fn expand_returns_compressed_only_for_pre_migration_entry() {
979        // Entries written via `save_cache_entry` (without the `_with_original`
980        // suffix) leave the `original` column NULL. Expand must still return
981        // something useful — the compressed blob — with a note (handled by
982        // the CLI). Here we assert the engine returns the right variant.
983        let (store, _dir) = in_memory_store();
984        let compressed = crate::types::CompressedContent {
985            data: "the compressed version".to_string(),
986            tokens_compressed: 4,
987            tokens_original: 100,
988            stages_applied: vec!["condense".to_string()],
989            compression_ratio: 0.04,
990            provenance: Default::default(),
991            verify: None,
992        };
993        // Deliberately use the legacy save_cache_entry (no original).
994        let hash = format!("deadbeef{}", "0".repeat(56));
995        store.save_cache_entry(&hash, &compressed).unwrap();
996
997        let cm = CacheManager::new(store, u64::MAX);
998        let result = cm
999            .expand_prefix("deadbeef")
1000            .unwrap()
1001            .expect("deadbeef prefix should match");
1002        match result {
1003            ExpandResult::CompressedOnly { compressed, hash: h } => {
1004                assert_eq!(compressed, "the compressed version");
1005                assert_eq!(h, hash);
1006            }
1007            ExpandResult::Original { .. } => {
1008                panic!("legacy entry without original bytes should return CompressedOnly");
1009            }
1010        }
1011    }
1012
1013    #[test]
1014    fn expand_preserves_non_utf8_original_bytes() {
1015        // Shell output routinely contains non-UTF-8 sequences (terminal
1016        // control codes, binary blobs from `cat` on a wrong file, etc.).
1017        // Expand must round-trip byte-for-byte — not through a UTF-8
1018        // lossy conversion — or agents auditing the cache will see
1019        // spurious `\uFFFD` REPLACEMENT CHARACTER bytes.
1020        let (store, _dir) = in_memory_store();
1021        let cm = CacheManager::new(store, u64::MAX);
1022        let pipeline = make_pipeline();
1023
1024        // Mix of valid UTF-8 and isolated 0xFF bytes.
1025        let content: Vec<u8> = vec![
1026            b'h', b'e', b'l', b'l', b'o', b'\n',
1027            0xFF, 0xFE, 0xFD,
1028            b'\n',
1029        ];
1030        let path = Path::new("bin.dat");
1031        let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1032        // Round-trip via expand_prefix.
1033        let hash = CacheManager::sha256_hex(&content);
1034        let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1035        match result {
1036            ExpandResult::Original { bytes, .. } => {
1037                assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1038            }
1039            ExpandResult::CompressedOnly { .. } => {
1040                panic!("fresh entry should have original bytes");
1041            }
1042        }
1043    }
1044
1045    #[test]
1046    fn dedup_survives_cache_manager_restart() {
1047        // Regression for the April 18 bug: the turn counter was in-memory
1048        // only, so every new sqz process saw an empty ref tracker and the
1049        // dedup feature silently produced Fresh results forever. With
1050        // accessed_at-based freshness, a fresh CacheManager reading the
1051        // same SQLite store picks up the dedup correctly.
1052        let dir = tempfile::tempdir().unwrap();
1053        let db_path = dir.path().join("cache.db");
1054        let pipeline = make_pipeline();
1055        let content = b"a substantial chunk of content to dedup";
1056        let path = Path::new("x.txt");
1057
1058        // First "process": populate cache.
1059        {
1060            let store = SessionStore::open_or_create(&db_path).unwrap();
1061            let cm = CacheManager::with_ref_age_duration(
1062                store,
1063                u64::MAX,
1064                Duration::from_secs(3600),
1065            );
1066            let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1067            assert!(matches!(first, CacheResult::Fresh { .. }));
1068        }
1069
1070        // Second "process": new CacheManager, same DB. Dedup must fire.
1071        {
1072            let store = SessionStore::open_or_create(&db_path).unwrap();
1073            let cm = CacheManager::with_ref_age_duration(
1074                store,
1075                u64::MAX,
1076                Duration::from_secs(3600),
1077            );
1078            let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1079            assert!(
1080                matches!(second, CacheResult::Dedup { .. }),
1081                "second-process read must dedup — this was broken before the April 18 fix"
1082            );
1083        }
1084    }
1085
1086    #[test]
1087    fn compaction_from_one_process_invalidates_refs_in_another() {
1088        // Regression for the PreCompact hook wiring: the host harness
1089        // (e.g. Claude Code) runs `sqz hook precompact` in a short-lived
1090        // process to signal auto-compaction. The actual dedup serving runs
1091        // in a DIFFERENT sqz process (the shell hook). notify_compaction
1092        // must persist through SQLite so the second process sees it.
1093        //
1094        // Before the fix, compaction_marker was Cell<Option<DateTime>>
1095        // in memory only — the precompact process set it, exited, the
1096        // state was lost. Next shell-hook process started with a clean
1097        // marker, served stale refs to the agent, and the agent saw a
1098        // §ref:HASH§ pointing at content no longer in its context.
1099        let dir = tempfile::tempdir().unwrap();
1100        let db_path = dir.path().join("cache.db");
1101        let pipeline = make_pipeline();
1102        let content = b"content that needs stale-marking after compaction";
1103        let path = Path::new("file.txt");
1104        let ttl = Duration::from_secs(3600);
1105
1106        // Process A: populate the cache so the content is dedup-eligible.
1107        {
1108            let store = SessionStore::open_or_create(&db_path).unwrap();
1109            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1110            cm.get_or_compress(path, content, &pipeline).unwrap();
1111        }
1112        // Sleep so the compaction marker is strictly after the touch.
1113        std::thread::sleep(Duration::from_millis(10));
1114
1115        // Process B: simulates `sqz hook precompact`. Just calls
1116        // notify_compaction and exits. No reads.
1117        {
1118            let store = SessionStore::open_or_create(&db_path).unwrap();
1119            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1120            cm.notify_compaction();
1121        }
1122
1123        // Process C: simulates the next `sqz compress` shell-hook call.
1124        // Reads the same content. MUST re-send Fresh, not return a ref
1125        // the agent can no longer resolve.
1126        {
1127            let store = SessionStore::open_or_create(&db_path).unwrap();
1128            let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1129            let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1130            assert!(
1131                matches!(result, CacheResult::Fresh { .. }),
1132                "post-compaction read from a fresh process must re-send Fresh; \
1133                 returning Dedup would be a dangling-ref bug"
1134            );
1135        }
1136    }
1137
1138    use proptest::prelude::*;
1139
1140    // ── Property 8: Cache deduplication ──────────────────────────────────────
1141    // **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1142    //
1143    // For any file content, reading the file twice through the CacheManager
1144    // (with no content change between reads) SHALL return a cache hit on the
1145    // second read with a reference token of approximately 13 tokens.
1146
1147    proptest! {
1148        /// **Validates: Requirements 8.1, 8.2, 18.1, 18.2**
1149        ///
1150        /// For any file content, the second read through CacheManager SHALL be
1151        /// a cache hit with tokens == 13.
1152        #[test]
1153        fn prop_cache_deduplication(
1154            content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1155        ) {
1156            let (store, _dir) = in_memory_store();
1157            let cm = CacheManager::new(store, u64::MAX);
1158            let pipeline = make_pipeline();
1159            let path = Path::new("file.txt");
1160
1161            // First read — must be a miss.
1162            let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1163            prop_assert!(
1164                matches!(first, CacheResult::Fresh { .. }),
1165                "first read should be a cache miss"
1166            );
1167
1168            let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1169            match second {
1170                CacheResult::Dedup { inline_ref, token_cost } => {
1171                    prop_assert_eq!(
1172                        token_cost, 13,
1173                        "cache hit should report ~13 reference tokens"
1174                    );
1175                    prop_assert!(
1176                        inline_ref.starts_with("§ref:"),
1177                        "reference token should start with §ref:"
1178                    );
1179                    prop_assert!(
1180                        inline_ref.ends_with('§'),
1181                        "reference token should end with §"
1182                    );
1183                }
1184                CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1185                    prop_assert!(false, "second read should be a cache hit, not a miss");
1186                }
1187            }
1188        }
1189    }
1190
1191    // ── Property 9: Cache invalidation on content change ─────────────────────
1192    // **Validates: Requirements 8.3, 18.3**
1193    //
1194    // For any cached file, if the file content changes (producing a different
1195    // SHA-256 hash), the CacheManager SHALL treat the next read as a cache miss
1196    // and re-compress the updated content.
1197
1198    proptest! {
1199        /// **Validates: Requirements 8.3, 18.3**
1200        ///
1201        /// For any two distinct byte sequences, the first read of each is a
1202        /// cache miss — content change always triggers re-compression.
1203        #[test]
1204        fn prop_cache_invalidation_on_content_change(
1205            content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1206            content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1207        ) {
1208            // Only meaningful when the two contents differ (different hashes).
1209            prop_assume!(content_a != content_b);
1210
1211            let (store, _dir) = in_memory_store();
1212            let cm = CacheManager::new(store, u64::MAX);
1213            let pipeline = make_pipeline();
1214            let path = Path::new("file.txt");
1215
1216            // Cache content_a.
1217            let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1218            prop_assert!(
1219                matches!(r1, CacheResult::Fresh { .. }),
1220                "first read of content_a should be a miss"
1221            );
1222
1223            let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1224            prop_assert!(
1225                matches!(r2, CacheResult::Dedup { .. }),
1226                "second read of content_a should be a hit"
1227            );
1228
1229            let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1230            prop_assert!(
1231                matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
1232                "read with changed content should be a cache miss or delta"
1233            );
1234        }
1235    }
1236
1237    // ── Property 10: Cache LRU eviction ──────────────────────────────────────
1238    // **Validates: Requirements 8.5**
1239    //
1240    // For any cache state where total size exceeds the configured maximum, the
1241    // CacheManager SHALL evict entries in LRU order until total size is at or
1242    // below the limit.
1243
1244    proptest! {
1245        /// **Validates: Requirements 8.5**
1246        ///
1247        /// After evict_lru, the total remaining cache size SHALL be at or below
1248        /// max_size_bytes.
1249        #[test]
1250        fn prop_cache_lru_eviction(
1251            // Generate 2-8 distinct content entries.
1252            entries in proptest::collection::vec(
1253                proptest::collection::vec(any::<u8>(), 10..=200usize),
1254                2..=8usize,
1255            ),
1256        ) {
1257            // Deduplicate entries so each has a unique hash.
1258            let mut unique_entries: Vec<Vec<u8>> = Vec::new();
1259            for e in &entries {
1260                if !unique_entries.contains(e) {
1261                    unique_entries.push(e.clone());
1262                }
1263            }
1264            prop_assume!(unique_entries.len() >= 2);
1265
1266            let (store, _dir) = in_memory_store();
1267            // Use a very small limit (1 byte) to guarantee eviction is needed.
1268            let cm = CacheManager::new(store, 1);
1269            let pipeline = make_pipeline();
1270            let path = Path::new("f.txt");
1271
1272            // Populate the cache.
1273            for entry in &unique_entries {
1274                cm.get_or_compress(path, entry, &pipeline).unwrap();
1275            }
1276
1277            // Evict LRU entries.
1278            let freed = cm.evict_lru().unwrap();
1279
1280            // Bytes freed must be > 0 since total > 1 byte.
1281            prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
1282
1283            // After eviction, total remaining size must be <= max_size_bytes (1).
1284            // We verify by checking that evict_lru now returns 0 (nothing left to evict).
1285            let freed_again = cm.evict_lru().unwrap();
1286            prop_assert_eq!(
1287                freed_again, 0,
1288                "second evict_lru call should free 0 bytes (already at or below limit)"
1289            );
1290        }
1291    }
1292
1293    // ── Property 34: Cache persistence across sessions ────────────────────────
1294    // **Validates: Requirements 18.4**
1295    //
1296    // For any set of cache entries saved to the SessionStore, reloading the
1297    // store (opening the same database file) SHALL produce the same cache
1298    // entries, and a subsequent read with the same content hash SHALL return a
1299    // cache hit.
1300
1301    proptest! {
1302        /// **Validates: Requirements 18.4**
1303        ///
1304        /// Cache entries written in one CacheManager instance SHALL survive
1305        /// a store close/reopen. With the wall-clock freshness model
1306        /// (introduced April 18 2026), a subsequent CacheManager reading
1307        /// the same database SHALL see the entry as fresh (within TTL) and
1308        /// return a Dedup hit on the very first read — this is the whole
1309        /// point of the cross-process fix. Previous behavior (Fresh on
1310        /// first read after restart) was a bug that silently disabled the
1311        /// dedup feature in production.
1312        #[test]
1313        fn prop_cache_persistence_across_sessions(
1314            content in proptest::collection::vec(any::<u8>(), 1..=500usize),
1315        ) {
1316            use crate::session_store::SessionStore;
1317
1318            let dir = tempfile::tempdir().unwrap();
1319            let db_path = dir.path().join("cache.db");
1320            let path = Path::new("file.txt");
1321
1322            // Session 1: populate the cache.
1323            {
1324                let store = SessionStore::open_or_create(&db_path).unwrap();
1325                // Explicit long TTL so tests don't race with wall-clock drift.
1326                let cm = CacheManager::with_ref_age_duration(
1327                    store,
1328                    u64::MAX,
1329                    Duration::from_secs(3600),
1330                );
1331                let pipeline = make_pipeline();
1332
1333                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1334                prop_assert!(
1335                    matches!(r, CacheResult::Fresh { .. }),
1336                    "first-ever read should be a miss"
1337                );
1338            }
1339
1340            // Session 2: reopen the same database file.
1341            {
1342                let store = SessionStore::open_or_create(&db_path).unwrap();
1343                let cm = CacheManager::with_ref_age_duration(
1344                    store,
1345                    u64::MAX,
1346                    Duration::from_secs(3600),
1347                );
1348                let pipeline = make_pipeline();
1349
1350                // First read in the new session MUST dedup. The entry was
1351                // just written (within TTL), so the wall-clock freshness
1352                // check finds it fresh. This is what makes sqz's dedup
1353                // actually work across shell-hook invocations.
1354                let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1355                match r {
1356                    CacheResult::Dedup { token_cost, .. } => {
1357                        prop_assert_eq!(
1358                            token_cost, 13,
1359                            "first read after restart must be a 13-token dedup ref"
1360                        );
1361                    }
1362                    CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1363                        prop_assert!(
1364                            false,
1365                            "first read after restart must dedup — this was the \
1366                             April 18 bug and its fix is the whole reason this \
1367                             test exists"
1368                        );
1369                    }
1370                }
1371            }
1372        }
1373    }
1374}