Skip to main content

lean_ctx/core/
cache.rs

1use md5::{Digest, Md5};
2use std::collections::HashMap;
3use std::sync::OnceLock;
4use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
5use std::time::{Duration, Instant, SystemTime};
6
7use super::tokens::count_tokens;
8
9/// Process-global monotonic base for encoding `Instant`s into an `AtomicU64`.
10/// Stored as milliseconds since this base, which is sufficient resolution for
11/// LRU/RRF eviction recency while allowing lock-free access on cache hits.
12fn instant_base() -> Instant {
13    static BASE: OnceLock<Instant> = OnceLock::new();
14    *BASE.get_or_init(Instant::now)
15}
16
17fn encode_instant(i: Instant) -> u64 {
18    i.saturating_duration_since(instant_base()).as_millis() as u64
19}
20
21fn decode_instant(ms: u64) -> Instant {
22    instant_base() + Duration::from_millis(ms)
23}
24
25fn normalize_key(path: &str) -> String {
26    crate::core::pathutil::normalize_tool_path(path)
27}
28
29/// Built-in default token budget for the in-memory read cache.
30/// 2M covers ~100 typical source files, reducing premature eviction
31/// before re-reads occur. RAM-pressure eviction via EvictionOrchestrator
32/// provides an independent safety net regardless of this budget.
33pub(crate) const DEFAULT_CACHE_MAX_TOKENS: usize = 2_000_000;
34
35/// Pure resolver for the read-cache token budget. `env` (the raw
36/// `LEAN_CTX_CACHE_MAX_TOKENS` value) wins when it parses to a positive integer,
37/// then the `configured` `[core] cache_max_tokens`, else
38/// [`DEFAULT_CACHE_MAX_TOKENS`]. A `0` (or unparseable env) in either source
39/// means "use the default". Split out so the precedence is unit-testable without
40/// touching the global env or config.
41fn resolve_cache_max_tokens(env: Option<&str>, configured: usize) -> usize {
42    if let Some(raw) = env
43        && let Ok(n) = raw.trim().parse::<usize>()
44        && n > 0
45    {
46        return n;
47    }
48    if configured > 0 {
49        configured
50    } else {
51        DEFAULT_CACHE_MAX_TOKENS
52    }
53}
54
55/// Resolved token budget for the read cache. `LEAN_CTX_CACHE_MAX_TOKENS` wins
56/// (env-first keeps the hot eviction path cheap for power users), then
57/// `[core] cache_max_tokens` in config.toml, else [`DEFAULT_CACHE_MAX_TOKENS`].
58/// Shared with `eviction_orchestrator` so both eviction rails read one budget.
59pub(crate) fn max_cache_tokens() -> usize {
60    resolve_cache_max_tokens(
61        std::env::var("LEAN_CTX_CACHE_MAX_TOKENS").ok().as_deref(),
62        crate::core::config::Config::load().cache_max_tokens,
63    )
64}
65
66/// A cached file read: zstd-compressed content, hash, token count, and access metadata.
67///
68/// `read_count` and `last_access` use interior mutability (atomics) so cache
69/// hits can be recorded under a shared (read) lock — parallel reads of distinct
70/// files no longer serialize on a global write lock.
71#[derive(Debug)]
72pub struct CacheEntry {
73    compressed_content: Vec<u8>,
74    pub hash: String,
75    pub line_count: usize,
76    pub original_tokens: usize,
77    read_count: AtomicU32,
78    pub path: String,
79    last_access: AtomicU64,
80    pub stored_mtime: Option<SystemTime>,
81    /// Mode-specific compressed outputs (e.g. "map", "signatures") cached to avoid re-parsing.
82    pub compressed_outputs: HashMap<String, String>,
83    /// Whether full (uncompressed) content was already delivered for this hash.
84    /// Prevents cache-stub loops when upgrading from compressed to full mode.
85    pub full_content_delivered: bool,
86    /// Conversation id that received the full content (see
87    /// [`crate::core::conversation`]). The `[unchanged]` stub is only valid for
88    /// a re-read from this same conversation; `None` means delivered without a
89    /// known conversation context (legacy / hooks absent).
90    pub delivered_conversation: Option<String>,
91    /// Last read mode used for this file (for auto-escalation on edit failure).
92    pub last_mode: String,
93}
94
95const ZSTD_LEVEL: i32 = 3;
96
97fn zstd_compress(data: &str) -> Vec<u8> {
98    zstd::encode_all(data.as_bytes(), ZSTD_LEVEL).unwrap_or_else(|_| data.as_bytes().to_vec())
99}
100
101fn zstd_decompress(data: &[u8]) -> Option<String> {
102    zstd::decode_all(data)
103        .ok()
104        .and_then(|v| String::from_utf8(v).ok())
105}
106
107impl CacheEntry {
108    /// Creates a new entry with zstd-compressed content.
109    pub fn new(
110        content: &str,
111        hash: String,
112        line_count: usize,
113        original_tokens: usize,
114        path: String,
115        stored_mtime: Option<SystemTime>,
116    ) -> Self {
117        let compressed_content = zstd_compress(content);
118        Self {
119            compressed_content,
120            hash,
121            line_count,
122            original_tokens,
123            read_count: AtomicU32::new(1),
124            path,
125            last_access: AtomicU64::new(encode_instant(Instant::now())),
126            stored_mtime,
127            compressed_outputs: HashMap::new(),
128            full_content_delivered: false,
129            delivered_conversation: None,
130            last_mode: String::new(),
131        }
132    }
133
134    /// Current read count (lock-free).
135    pub fn read_count(&self) -> u32 {
136        self.read_count.load(Ordering::Relaxed)
137    }
138
139    /// Atomically increments the read count and returns the new value (lock-free).
140    pub fn bump_read_count(&self) -> u32 {
141        self.read_count.fetch_add(1, Ordering::Relaxed) + 1
142    }
143
144    /// Overwrites the read count (used by `store` and tests).
145    pub fn set_read_count(&self, n: u32) {
146        self.read_count.store(n, Ordering::Relaxed);
147    }
148
149    /// Last access time, decoded from the atomic millisecond offset.
150    pub fn last_access(&self) -> Instant {
151        decode_instant(self.last_access.load(Ordering::Relaxed))
152    }
153
154    /// Marks the entry as accessed now (lock-free).
155    pub fn touch(&self) {
156        self.last_access
157            .store(encode_instant(Instant::now()), Ordering::Relaxed);
158    }
159
160    /// Overwrites the last-access time (used by tests and eviction setup).
161    pub fn set_last_access(&self, when: Instant) {
162        self.last_access
163            .store(encode_instant(when), Ordering::Relaxed);
164    }
165
166    /// Decompresses and returns the full file content.
167    pub fn content(&self) -> Option<String> {
168        zstd_decompress(&self.compressed_content)
169    }
170
171    /// Replaces the stored content with new zstd-compressed data.
172    pub fn set_content(&mut self, content: &str) {
173        self.compressed_content = zstd_compress(content);
174    }
175
176    /// Approximate RAM usage of the compressed content in bytes.
177    pub fn compressed_size(&self) -> usize {
178        self.compressed_content.len()
179    }
180}
181
182/// Result of a cache store operation, indicating whether it was a hit or new entry.
183#[derive(Debug, Clone)]
184pub struct StoreResult {
185    pub line_count: usize,
186    pub original_tokens: usize,
187    pub read_count: u32,
188    pub was_hit: bool,
189    /// Whether full content was previously delivered for this cache entry.
190    pub full_content_delivered: bool,
191}
192
193impl CacheEntry {
194    /// Computes a legacy eviction score blending recency, frequency, and size.
195    pub fn eviction_score_legacy(&self, now: Instant) -> f64 {
196        let elapsed = now
197            .checked_duration_since(self.last_access())
198            .unwrap_or_default()
199            .as_secs_f64();
200        let recency = 1.0 / (1.0 + elapsed.sqrt());
201        let frequency = (self.read_count() as f64 + 1.0).ln();
202        let size_value = (self.original_tokens as f64 + 1.0).ln();
203        recency * 0.4 + frequency * 0.3 + size_value * 0.3
204    }
205
206    pub fn get_compressed(&self, mode_key: &str) -> Option<&String> {
207        self.compressed_outputs.get(mode_key)
208    }
209
210    pub fn set_compressed(&mut self, mode_key: &str, output: String) {
211        const MAX_COMPRESSED_VARIANTS: usize = 3;
212        if self.compressed_outputs.len() >= MAX_COMPRESSED_VARIANTS
213            && !self.compressed_outputs.contains_key(mode_key)
214            && let Some(oldest_key) = self.compressed_outputs.keys().next().cloned()
215        {
216            self.compressed_outputs.remove(&oldest_key);
217        }
218        self.compressed_outputs.insert(mode_key.to_string(), output);
219    }
220
221    pub fn mark_full_delivered(&mut self, conversation: Option<String>) {
222        self.full_content_delivered = true;
223        self.delivered_conversation = conversation;
224    }
225}
226
227const RRF_K: f64 = 60.0;
228
229/// Hebbian protection added to an entry's RRF eviction score per unit of
230/// association strength with the currently-active working set (#3). Files that
231/// are read together resist eviction together ("fire together, wire together").
232/// Deterministic: a fixed multiplier, no sampling.
233const HEBBIAN_PROTECT_WEIGHT: f64 = 0.05;
234/// Size of the "active working set" (most-recently-accessed entries) against
235/// which Hebbian association is measured during eviction.
236const HEBBIAN_ACTIVE_SET: usize = 8;
237
238/// Compute Reciprocal Rank Fusion eviction scores for a batch of cache entries.
239/// Each signal (recency, frequency, size) produces an independent ranking.
240/// The final score is the sum of `1/(k + rank)` across all signals.
241/// Higher score = more valuable = keep longer.
242pub fn eviction_scores_rrf(entries: &[(&String, &CacheEntry)], now: Instant) -> Vec<(String, f64)> {
243    if entries.is_empty() {
244        return Vec::new();
245    }
246
247    let n = entries.len();
248
249    let mut recency_order: Vec<usize> = (0..n).collect();
250    recency_order.sort_by(|&a, &b| {
251        let elapsed_a = now
252            .checked_duration_since(entries[a].1.last_access())
253            .unwrap_or_default()
254            .as_secs_f64();
255        let elapsed_b = now
256            .checked_duration_since(entries[b].1.last_access())
257            .unwrap_or_default()
258            .as_secs_f64();
259        elapsed_a
260            .partial_cmp(&elapsed_b)
261            .unwrap_or(std::cmp::Ordering::Equal)
262    });
263
264    let mut frequency_order: Vec<usize> = (0..n).collect();
265    frequency_order.sort_by(|&a, &b| entries[b].1.read_count().cmp(&entries[a].1.read_count()));
266
267    let mut size_order: Vec<usize> = (0..n).collect();
268    size_order.sort_by(|&a, &b| {
269        entries[b]
270            .1
271            .original_tokens
272            .cmp(&entries[a].1.original_tokens)
273    });
274
275    let mut recency_ranks = vec![0usize; n];
276    let mut frequency_ranks = vec![0usize; n];
277    let mut size_ranks = vec![0usize; n];
278
279    for (rank, &idx) in recency_order.iter().enumerate() {
280        recency_ranks[idx] = rank;
281    }
282    for (rank, &idx) in frequency_order.iter().enumerate() {
283        frequency_ranks[idx] = rank;
284    }
285    for (rank, &idx) in size_order.iter().enumerate() {
286        size_ranks[idx] = rank;
287    }
288
289    entries
290        .iter()
291        .enumerate()
292        .map(|(i, (path, _))| {
293            let score = 1.0 / (RRF_K + recency_ranks[i] as f64)
294                + 1.0 / (RRF_K + frequency_ranks[i] as f64)
295                + 1.0 / (RRF_K + size_ranks[i] as f64);
296            ((*path).clone(), score)
297        })
298        .collect()
299}
300
301/// Add the Hebbian co-access bonus (#3) to RRF eviction scores in place. A
302/// higher score means "keep longer", so co-accessed entries are protected.
303fn apply_hebbian_bonus(scores: &mut [(String, f64)], bonus: &HashMap<String, f64>) {
304    if bonus.is_empty() {
305        return;
306    }
307    for s in scores.iter_mut() {
308        if let Some(b) = bonus.get(&s.0) {
309            s.1 += *b;
310        }
311    }
312}
313
314/// Aggregated cache statistics: hits, reads, and token savings.
315///
316/// Counters are atomic so they can be updated on the read-locked cache-hit
317/// fast path without taking a write lock.
318#[derive(Debug, Default)]
319pub struct CacheStats {
320    total_reads: AtomicU64,
321    cache_hits: AtomicU64,
322    total_original_tokens: AtomicU64,
323    total_sent_tokens: AtomicU64,
324    files_tracked: AtomicU64,
325}
326
327impl CacheStats {
328    /// Total number of read operations recorded.
329    pub fn total_reads(&self) -> u64 {
330        self.total_reads.load(Ordering::Relaxed)
331    }
332
333    /// Total number of cache hits recorded.
334    pub fn cache_hits(&self) -> u64 {
335        self.cache_hits.load(Ordering::Relaxed)
336    }
337
338    /// Sum of original (uncompressed) token counts across all reads.
339    pub fn total_original_tokens(&self) -> u64 {
340        self.total_original_tokens.load(Ordering::Relaxed)
341    }
342
343    /// Sum of tokens actually sent to the model.
344    pub fn total_sent_tokens(&self) -> u64 {
345        self.total_sent_tokens.load(Ordering::Relaxed)
346    }
347
348    /// Number of distinct files currently tracked.
349    pub fn files_tracked(&self) -> u64 {
350        self.files_tracked.load(Ordering::Relaxed)
351    }
352
353    /// Returns the cache hit rate as a percentage (0–100).
354    pub fn hit_rate(&self) -> f64 {
355        let total = self.total_reads();
356        if total == 0 {
357            return 0.0;
358        }
359        (self.cache_hits() as f64 / total as f64) * 100.0
360    }
361
362    /// Returns the total number of tokens saved by cache hits.
363    pub fn tokens_saved(&self) -> u64 {
364        self.total_original_tokens()
365            .saturating_sub(self.total_sent_tokens())
366    }
367
368    /// Returns the savings as a percentage of total original tokens.
369    pub fn savings_percent(&self) -> f64 {
370        let original = self.total_original_tokens();
371        if original == 0 {
372            return 0.0;
373        }
374        (self.tokens_saved() as f64 / original as f64) * 100.0
375    }
376}
377
378/// A block shared across multiple files, identified by its canonical source.
379#[derive(Clone, Debug)]
380pub struct SharedBlock {
381    pub canonical_path: String,
382    pub canonical_ref: String,
383    pub start_line: usize,
384    pub end_line: usize,
385    pub content: String,
386}
387
388/// In-memory file cache with segmented LRU eviction (probationary vs protected),
389/// file references, and cross-file dedup.
390pub struct SessionCache {
391    entries: HashMap<String, CacheEntry>,
392    file_refs: HashMap<String, String>,
393    next_ref: usize,
394    stats: CacheStats,
395    shared_blocks: Vec<SharedBlock>,
396    /// Hebbian co-access matrix (#3): tracks which files are read together so
397    /// eviction can protect co-accessed clusters. Updated on `store`, consulted
398    /// during eviction.
399    co_access: crate::core::hebbian_cache::CoAccessMatrix,
400}
401
402impl Default for SessionCache {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408impl SessionCache {
409    /// Creates an empty session cache with default stats.
410    pub fn new() -> Self {
411        Self {
412            entries: HashMap::new(),
413            file_refs: HashMap::new(),
414            next_ref: 1,
415            shared_blocks: Vec::new(),
416            stats: CacheStats::default(),
417            co_access: crate::core::hebbian_cache::CoAccessMatrix::new(),
418        }
419    }
420
421    /// Record that `path` was accessed, strengthening its Hebbian association
422    /// with other files read in the same burst window (#3). Called on every
423    /// `store`; co-access boundaries are flushed via `flush_co_access`.
424    pub fn record_co_access(&mut self, path: &str) {
425        let key = normalize_key(path);
426        self.co_access
427            .record_access(crate::core::hebbian_cache::path_hash(&key));
428    }
429
430    /// Close the current co-access burst so its associations are committed.
431    /// Call at the end of a logical tool call (post-dispatch).
432    pub fn flush_co_access(&mut self) {
433        self.co_access.end_burst();
434    }
435
436    /// Per-entry Hebbian eviction bonus (#3): each cached entry that is
437    /// co-accessed with the recently-active working set earns a positive bonus
438    /// that is added to its RRF score, so clustered files survive eviction
439    /// together. Deterministic (no sampling); ticks the activation registry when
440    /// any association actually influences the decision.
441    pub(crate) fn hebbian_eviction_bonus(&self) -> HashMap<String, f64> {
442        use crate::core::hebbian_cache::path_hash;
443        if self.entries.is_empty() {
444            return HashMap::new();
445        }
446        let mut by_recency: Vec<(&String, Instant)> = self
447            .entries
448            .iter()
449            .map(|(k, e)| (k, e.last_access()))
450            .collect();
451        by_recency.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
452        let active: Vec<u64> = by_recency
453            .iter()
454            .take(HEBBIAN_ACTIVE_SET)
455            .map(|(k, _)| path_hash(k))
456            .collect();
457
458        let mut out = HashMap::new();
459        for k in self.entries.keys() {
460            let h = path_hash(k);
461            // Exclude self so an entry never "protects itself".
462            let peers: Vec<u64> = active.iter().copied().filter(|&a| a != h).collect();
463            let strength = self.co_access.association_strength(h, &peers);
464            if strength > 0.0 {
465                out.insert(k.clone(), f64::from(strength) * HEBBIAN_PROTECT_WEIGHT);
466            }
467        }
468        if !out.is_empty() {
469            crate::core::introspect::tick("hebbian_cache");
470        }
471        out
472    }
473
474    /// Returns or assigns a short file reference label (F1, F2, ...) for the given path.
475    pub fn get_file_ref(&mut self, path: &str) -> String {
476        let key = normalize_key(path);
477        if let Some(r) = self.file_refs.get(&key) {
478            return r.clone();
479        }
480        let r = format!("F{}", self.next_ref);
481        self.next_ref += 1;
482        self.file_refs.insert(key, r.clone());
483        r
484    }
485
486    /// Returns the file reference label for a path without assigning a new one.
487    pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
488        self.file_refs.get(&normalize_key(path)).cloned()
489    }
490
491    /// Looks up a cached entry by file path.
492    pub fn get(&self, path: &str) -> Option<&CacheEntry> {
493        self.entries.get(&normalize_key(path))
494    }
495
496    /// Mutable lookup of a cached entry by file path.
497    pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
498        self.entries.get_mut(&normalize_key(path))
499    }
500
501    /// Retrieves the full (uncompressed) content for a file path, if cached.
502    /// Used by the CCR (Compress-Cache-Retrieve) mechanism.
503    pub fn get_full_content(&self, path: &str) -> Option<String> {
504        self.entries
505            .get(&normalize_key(path))
506            .and_then(CacheEntry::content)
507    }
508
509    /// Staleness-safe accessor for the *current* full content and its token
510    /// count: returns the cached copy when it is still fresh, or a fresh disk
511    /// re-read when the cached copy is stale (mtime/hash changed since it was
512    /// cached). Returns `None` when there is no cache entry, or the entry is
513    /// stale and the file can no longer be read.
514    ///
515    /// Cross-agent / retrieve paths (`ctx_retrieve`, `ctx_share`) MUST use this
516    /// instead of [`get_full_content`](Self::get_full_content): serving the raw
517    /// cached copy hands an agent a version that may no longer match disk — e.g.
518    /// a handover file edited between two agents — silently feeding it stale
519    /// context. Validation uses the entry's stored absolute `path`, because a
520    /// caller's `path` may be relative and resolve against a different CWD.
521    pub fn current_full_content(&self, path: &str) -> Option<(String, usize)> {
522        let entry = self.entries.get(&normalize_key(path))?;
523        if is_cache_entry_stale_verified(&entry.path, entry.stored_mtime, &entry.hash)
524            && let Ok(fresh) = crate::core::io_boundary::read_file_lossy(&entry.path)
525        {
526            // Cache is behind disk → serve the current bytes. If the file is now
527            // unreadable (deleted/permission), fall through to the cached copy:
528            // last-known content beats nothing, and that fall-through is not the
529            // staleness bug (it only fires when there is no current content).
530            let tokens = count_tokens(&fresh);
531            return Some((fresh, tokens));
532        }
533        Some((entry.content()?, entry.original_tokens))
534    }
535
536    /// Records a cache hit, updates access stats, and emits a cache-hit event.
537    ///
538    /// Takes `&self`: the hit counters use interior-mutable atomics, so this
539    /// runs under a shared (read) lock and lets parallel reads of different
540    /// files proceed concurrently instead of serializing on a write lock.
541    pub fn record_cache_hit(&self, path: &str) -> Option<&CacheEntry> {
542        let key = normalize_key(path);
543        let ref_label = self
544            .file_refs
545            .get(&key)
546            .cloned()
547            .unwrap_or_else(|| "F?".to_string());
548        let entry = self.entries.get(&key)?;
549        let new_count = entry.bump_read_count();
550        entry.touch();
551        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
552        self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
553        self.stats
554            .total_original_tokens
555            .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
556        let hit_msg = format!("{ref_label} cached {new_count}t {}L", entry.line_count);
557        let sent_tokens = count_tokens(&hit_msg) as u64;
558        self.stats
559            .total_sent_tokens
560            .fetch_add(sent_tokens, Ordering::Relaxed);
561        crate::core::events::emit_cache_hit(
562            path,
563            (entry.original_tokens as u64).saturating_sub(sent_tokens),
564        );
565        Some(entry)
566    }
567
568    /// Stores file content in the cache; returns a hit if content hash matches.
569    pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
570        let key = normalize_key(path);
571        // #3: feed the Hebbian co-access matrix on every read so eviction can
572        // later protect files that are habitually read together.
573        self.co_access
574            .record_access(crate::core::hebbian_cache::path_hash(&key));
575        let hash = compute_md5(content);
576        let line_count = content.lines().count();
577        let original_tokens = count_tokens(content);
578        let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
579        let now = Instant::now();
580
581        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
582        self.stats
583            .total_original_tokens
584            .fetch_add(original_tokens as u64, Ordering::Relaxed);
585
586        if let Some(existing) = self.entries.get_mut(&key) {
587            existing.set_last_access(now);
588            if stored_mtime.is_some() {
589                existing.stored_mtime = stored_mtime;
590            }
591            if existing.hash == hash {
592                let new_count = existing.bump_read_count();
593                self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
594                let hit_msg = format!(
595                    "{} cached {new_count}t {}L",
596                    self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
597                    existing.line_count,
598                );
599                let sent_tokens = count_tokens(&hit_msg) as u64;
600                self.stats
601                    .total_sent_tokens
602                    .fetch_add(sent_tokens, Ordering::Relaxed);
603                return StoreResult {
604                    line_count: existing.line_count,
605                    original_tokens: existing.original_tokens,
606                    read_count: new_count,
607                    was_hit: true,
608                    full_content_delivered: existing.full_content_delivered,
609                };
610            }
611            existing.compressed_outputs.clear();
612            existing.set_content(content);
613            existing.hash = hash;
614            existing.line_count = line_count;
615            existing.original_tokens = original_tokens;
616            let new_count = existing.bump_read_count();
617            existing.full_content_delivered = false;
618            existing.delivered_conversation = None;
619            if stored_mtime.is_some() {
620                existing.stored_mtime = stored_mtime;
621            }
622            self.stats
623                .total_sent_tokens
624                .fetch_add(original_tokens as u64, Ordering::Relaxed);
625            return StoreResult {
626                line_count,
627                original_tokens,
628                read_count: new_count,
629                was_hit: false,
630                full_content_delivered: false,
631            };
632        }
633
634        self.evict_if_needed(original_tokens);
635        self.get_file_ref(&key);
636
637        let entry = CacheEntry::new(
638            content,
639            hash,
640            line_count,
641            original_tokens,
642            key.clone(),
643            stored_mtime,
644        );
645
646        self.entries.insert(key, entry);
647        self.stats.files_tracked.fetch_add(1, Ordering::Relaxed);
648        self.stats
649            .total_sent_tokens
650            .fetch_add(original_tokens as u64, Ordering::Relaxed);
651        StoreResult {
652            line_count,
653            original_tokens,
654            read_count: 1,
655            was_hit: false,
656            full_content_delivered: false,
657        }
658    }
659
660    /// Returns the sum of original token counts across all cached entries.
661    pub fn total_cached_tokens(&self) -> usize {
662        self.entries.values().map(|e| e.original_tokens).sum()
663    }
664
665    /// Evict until cache fits within token budget using RRF (Reciprocal Rank Fusion).
666    /// Combines recency, frequency, and size signals to evict least-valuable entries first.
667    pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
668        let max_tokens = max_cache_tokens();
669        let current = self.total_cached_tokens();
670        if current + incoming_tokens <= max_tokens {
671            return;
672        }
673
674        let now = Instant::now();
675        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
676        let mut scores = eviction_scores_rrf(&all, now);
677        apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
678        // Sort ascending: lowest RRF score = least valuable = evict first
679        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
680
681        let mut freed = 0usize;
682        let mut redelivered = 0u64;
683        let target = (current + incoming_tokens).saturating_sub(max_tokens);
684
685        for (path, _score) in &scores {
686            if freed >= target {
687                break;
688            }
689            if let Some(entry) = self.entries.remove(path) {
690                freed += entry.original_tokens;
691                if entry.full_content_delivered {
692                    redelivered += 1;
693                }
694                self.file_refs.remove(path);
695            }
696        }
697        crate::core::cache_telemetry::record_eviction(redelivered);
698    }
699
700    /// Returns all cached entries as (path, entry) pairs.
701    pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
702        self.entries.iter().collect()
703    }
704
705    /// Returns a reference to the aggregated cache statistics.
706    pub fn get_stats(&self) -> &CacheStats {
707        &self.stats
708    }
709
710    /// Returns the path-to-file-ref mapping (e.g. "/src/main.rs" → "F1").
711    pub fn file_ref_map(&self) -> &HashMap<String, String> {
712        &self.file_refs
713    }
714
715    /// Replaces the cross-file shared blocks used for deduplication.
716    pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
717        self.shared_blocks = blocks;
718    }
719
720    /// Returns the current set of cross-file shared blocks.
721    pub fn get_shared_blocks(&self) -> &[SharedBlock] {
722        &self.shared_blocks
723    }
724
725    /// Replace shared blocks in content with cross-file references.
726    pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
727        if self.shared_blocks.is_empty() {
728            return None;
729        }
730        let refs: Vec<&SharedBlock> = self
731            .shared_blocks
732            .iter()
733            .filter(|b| b.canonical_path != path && content.contains(&b.content))
734            .collect();
735        if refs.is_empty() {
736            return None;
737        }
738        let mut result = content.to_string();
739        for block in refs {
740            result = result.replacen(
741                &block.content,
742                &format!(
743                    "[= {}:{}-{}]",
744                    block.canonical_ref, block.start_line, block.end_line
745                ),
746                1,
747            );
748        }
749        Some(result)
750    }
751
752    /// Removes a file from the cache, forcing a fresh read on next access.
753    pub fn invalidate(&mut self, path: &str) -> bool {
754        self.entries.remove(&normalize_key(path)).is_some()
755    }
756
757    /// Returns a cached compressed output for a given file and mode key.
758    /// Counts as a cache hit — the caller avoids a full disk read + recompression.
759    pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
760        let key = normalize_key(path);
761        let entry = self.entries.get(&key)?;
762        let result = entry.get_compressed(mode_key)?;
763        entry.bump_read_count();
764        entry.touch();
765        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
766        self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
767        self.stats
768            .total_original_tokens
769            .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
770        let sent = count_tokens(result) as u64;
771        self.stats
772            .total_sent_tokens
773            .fetch_add(sent, Ordering::Relaxed);
774        crate::core::events::emit_cache_hit(
775            path,
776            (entry.original_tokens as u64).saturating_sub(sent),
777        );
778        Some(result)
779    }
780
781    /// Marks that full (uncompressed) content was delivered for this file,
782    /// tagging it with the current conversation so a later re-read only serves
783    /// the `[unchanged]` stub to the same conversation (see
784    /// [`crate::core::conversation`]).
785    pub fn mark_full_delivered(&mut self, path: &str) {
786        let conversation = crate::core::conversation::current_conversation_id();
787        let key = normalize_key(path);
788        let file_ref = self.file_refs.get(&key).cloned();
789        if let Some(entry) = self.entries.get_mut(&key) {
790            entry.mark_full_delivered(conversation.clone());
791            // Write-through to the persistent stub index so an unchanged re-read
792            // in the same conversation survives a daemon restart / idle clear
793            // (#955). `record` ignores None-conversation deliveries.
794            crate::core::read_stub_index::record(crate::core::read_stub_index::StubRecord::new(
795                key.clone(),
796                entry.hash.clone(),
797                entry.stored_mtime,
798                entry.line_count,
799                file_ref.unwrap_or_default(),
800                conversation,
801            ));
802        }
803    }
804
805    /// Stores a compressed output for a given file and mode key.
806    pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
807        if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
808            entry.set_compressed(mode_key, output);
809        }
810    }
811
812    /// Resets `full_content_delivered` for all entries without removing them.
813    /// Used after host context compaction — forces re-delivery on next read
814    /// while preserving compressed content and file refs.
815    pub fn reset_delivery_flags(&mut self) -> usize {
816        let mut count = 0;
817        for entry in self.entries.values_mut() {
818            if entry.full_content_delivered {
819                entry.full_content_delivered = false;
820                count += 1;
821            }
822        }
823        count
824    }
825
826    /// Returns whether full content was previously delivered for this path.
827    pub fn is_full_delivered(&self, path: &str) -> bool {
828        self.entries
829            .get(&normalize_key(path))
830            .is_some_and(|e| e.full_content_delivered)
831    }
832
833    /// Counts entries that have full content delivered — i.e. those that would
834    /// serve a cheap `[unchanged]` stub and therefore force a full re-delivery
835    /// if dropped. Used by re-delivery telemetry at clear/eviction sites.
836    pub fn count_full_delivered(&self) -> usize {
837        self.entries
838            .values()
839            .filter(|e| e.full_content_delivered)
840            .count()
841    }
842
843    /// Removes all compressed output variants (map, signatures, etc.) from every entry,
844    /// keeping the full zstd-compressed content intact. Returns the number of entries trimmed.
845    pub fn trim_compressed_outputs(&mut self) -> usize {
846        let mut trimmed = 0;
847        for entry in self.entries.values_mut() {
848            if !entry.compressed_outputs.is_empty() {
849                entry.compressed_outputs.clear();
850                trimmed += 1;
851            }
852        }
853        trimmed
854    }
855
856    /// Evicts all entries that have been read at most once (probationary).
857    /// Returns the number of entries removed.
858    pub fn evict_probationary(&mut self) -> usize {
859        let to_remove: Vec<String> = self
860            .entries
861            .iter()
862            .filter(|(_, e)| e.read_count() <= 1)
863            .map(|(k, _)| k.clone())
864            .collect();
865        let count = to_remove.len();
866        let mut redelivered = 0u64;
867        for key in &to_remove {
868            if self
869                .entries
870                .remove(key)
871                .is_some_and(|e| e.full_content_delivered)
872            {
873                redelivered += 1;
874            }
875            self.file_refs.remove(key);
876        }
877        crate::core::cache_telemetry::record_eviction(redelivered);
878        count
879    }
880
881    /// Evicts entries via RRF scoring until total tokens are at or below `target_tokens`.
882    pub fn evict_to_budget(&mut self, target_tokens: usize) {
883        let current = self.total_cached_tokens();
884        if current <= target_tokens {
885            return;
886        }
887        let now = Instant::now();
888        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
889        let mut scores = eviction_scores_rrf(&all, now);
890        apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
891        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
892
893        let mut freed = 0usize;
894        let mut redelivered = 0u64;
895        let target_free = current.saturating_sub(target_tokens);
896        for (path, _score) in &scores {
897            if freed >= target_free {
898                break;
899            }
900            if let Some(entry) = self.entries.remove(path) {
901                freed += entry.original_tokens;
902                if entry.full_content_delivered {
903                    redelivered += 1;
904                }
905                self.file_refs.remove(path);
906            }
907        }
908        crate::core::cache_telemetry::record_eviction(redelivered);
909    }
910
911    /// Estimates the approximate heap memory usage in bytes.
912    pub fn approximate_bytes(&self) -> usize {
913        let entries_bytes: usize = self
914            .entries
915            .values()
916            .map(|e| {
917                e.compressed_content.len()
918                    + e.hash.len()
919                    + e.path.len()
920                    + e.compressed_outputs
921                        .iter()
922                        .map(|(k, v)| k.len() + v.len())
923                        .sum::<usize>()
924                    + 128 // fixed overhead per entry
925            })
926            .sum();
927        let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
928        let blocks_bytes: usize = self
929            .shared_blocks
930            .iter()
931            .map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
932            .sum();
933        entries_bytes + refs_bytes + blocks_bytes
934    }
935
936    const MAX_SHARED_BLOCKS: usize = 100;
937
938    /// Trims shared blocks to a maximum count, keeping the most recent.
939    pub fn trim_shared_blocks(&mut self) {
940        if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
941            let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
942            self.shared_blocks.drain(..excess);
943        }
944    }
945
946    /// Clears all cached entries, file refs, and resets stats. Returns the number of entries removed.
947    pub fn clear(&mut self) -> usize {
948        let count = self.entries.len();
949        self.entries.clear();
950        self.file_refs.clear();
951        self.shared_blocks.clear();
952        self.next_ref = 1;
953        self.stats = CacheStats::default();
954        count
955    }
956}
957
958pub fn file_mtime(path: &str) -> Option<SystemTime> {
959    std::fs::metadata(path).and_then(|m| m.modified()).ok()
960}
961
962pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
963    let current = file_mtime(path);
964    match (cached_mtime, current) {
965        // Both unavailable (e.g. WSL DrvFS): can't tell → assume fresh (conservative).
966        (None, None) => false,
967        // One side missing: metadata changed or appeared/disappeared → stale.
968        (Some(_), None) | (None, Some(_)) => true,
969        // `!=`, not `>`: a *backward* mtime (git checkout, touch -t, snapshot
970        // restore) is just as much a content change as a forward one.
971        (Some(cached), Some(current)) => current != cached,
972    }
973}
974
975/// Files larger than this are not content-hashed for stub verification; the
976/// mtime check alone decides. Keeps the stub fast-path O(small-file-read).
977const VERIFY_HASH_CAP_BYTES: u64 = 8 * 1024 * 1024;
978
979fn cache_verify_enabled() -> bool {
980    std::env::var("LEAN_CTX_CACHE_VERIFY").map_or(true, |v| v != "0")
981}
982
983/// Staleness with content verification: like [`is_cache_entry_stale`], but when
984/// the mtime claims "unchanged", additionally compares the md5 of the on-disk
985/// content against the cached hash.
986///
987/// mtime alone cannot be trusted for *correctness*: same-second writes are
988/// invisible on coarse-granularity filesystems (HFS+ 1s, FAT 2s) and mtimes can
989/// be restored by tools. Serving an `[unchanged]` stub for changed content
990/// would silently mislead the agent — the worst failure mode a context layer
991/// can have. The extra disk read costs microseconds for typical source files;
992/// the stub's token savings are unaffected. Opt out: `LEAN_CTX_CACHE_VERIFY=0`.
993///
994/// Note: entries whose stored content differs from disk by design (e.g. secret
995/// redaction) hash differently and therefore never serve stubs — conservative
996/// and correct.
997pub fn is_cache_entry_stale_verified(
998    path: &str,
999    cached_mtime: Option<SystemTime>,
1000    cached_hash: &str,
1001) -> bool {
1002    if is_cache_entry_stale(path, cached_mtime) {
1003        return true;
1004    }
1005    if cached_hash.is_empty() || !cache_verify_enabled() {
1006        return false;
1007    }
1008    let Ok(meta) = std::fs::metadata(path) else {
1009        // Can't stat → never serve a stub on top of it.
1010        return true;
1011    };
1012    if meta.len() > VERIFY_HASH_CAP_BYTES {
1013        return false;
1014    }
1015    match std::fs::read(path) {
1016        // Hash the same view of the bytes that `store()` hashed (lossy UTF-8).
1017        Ok(bytes) => compute_md5(&String::from_utf8_lossy(&bytes)) != cached_hash,
1018        Err(_) => true,
1019    }
1020}
1021
1022fn compute_md5(content: &str) -> String {
1023    let mut hasher = Md5::new();
1024    hasher.update(content.as_bytes());
1025    crate::core::agent_identity::hex_encode(&hasher.finalize())
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031    use std::time::Duration;
1032
1033    #[test]
1034    fn cache_stores_and_retrieves() {
1035        let mut cache = SessionCache::new();
1036        let result = cache.store("/test/file.rs", "fn main() {}");
1037        assert!(!result.was_hit);
1038        assert_eq!(result.line_count, 1);
1039        assert!(cache.get("/test/file.rs").is_some());
1040    }
1041
1042    #[test]
1043    fn cache_hit_on_same_content() {
1044        let mut cache = SessionCache::new();
1045        cache.store("/test/file.rs", "content");
1046        let result = cache.store("/test/file.rs", "content");
1047        assert!(result.was_hit, "same content should be a cache hit");
1048    }
1049
1050    #[test]
1051    fn cache_miss_on_changed_content() {
1052        let mut cache = SessionCache::new();
1053        cache.store("/test/file.rs", "old content");
1054        let result = cache.store("/test/file.rs", "new content");
1055        assert!(!result.was_hit, "changed content should not be a cache hit");
1056    }
1057
1058    #[test]
1059    fn file_refs_are_sequential() {
1060        let mut cache = SessionCache::new();
1061        assert_eq!(cache.get_file_ref("/a.rs"), "F1");
1062        assert_eq!(cache.get_file_ref("/b.rs"), "F2");
1063        assert_eq!(cache.get_file_ref("/a.rs"), "F1"); // stable
1064    }
1065
1066    #[test]
1067    fn cache_clear_resets_everything() {
1068        let mut cache = SessionCache::new();
1069        cache.store("/a.rs", "a");
1070        cache.store("/b.rs", "b");
1071        let count = cache.clear();
1072        assert_eq!(count, 2);
1073        assert!(cache.get("/a.rs").is_none());
1074        assert_eq!(cache.get_file_ref("/c.rs"), "F1"); // refs reset
1075    }
1076
1077    #[test]
1078    fn cache_invalidate_removes_entry() {
1079        let mut cache = SessionCache::new();
1080        cache.store("/test.rs", "test");
1081        assert!(cache.invalidate("/test.rs"));
1082        assert!(!cache.invalidate("/nonexistent.rs"));
1083    }
1084
1085    #[test]
1086    fn cache_stats_track_correctly() {
1087        let mut cache = SessionCache::new();
1088        cache.store("/a.rs", "hello");
1089        cache.store("/a.rs", "hello"); // hit
1090        let stats = cache.get_stats();
1091        assert_eq!(stats.total_reads(), 2);
1092        assert_eq!(stats.cache_hits(), 1);
1093        assert!(stats.hit_rate() > 0.0);
1094    }
1095
1096    #[test]
1097    fn current_full_content_serves_cached_when_fresh() {
1098        let dir = tempfile::tempdir().unwrap();
1099        let file = dir.path().join("handover.md");
1100        std::fs::write(&file, "HANDOVER V1\n").unwrap();
1101        let path = file.to_str().unwrap();
1102
1103        let mut cache = SessionCache::new();
1104        cache.store(path, "HANDOVER V1\n");
1105
1106        let (content, tokens) = cache.current_full_content(path).unwrap();
1107        assert_eq!(content, "HANDOVER V1\n");
1108        assert!(tokens > 0);
1109    }
1110
1111    #[test]
1112    fn current_full_content_rereads_when_file_changed() {
1113        // Handover staleness: a file cached by agent A and then edited must not
1114        // be served from the stale cache to agent B (ctx_retrieve / ctx_share).
1115        let dir = tempfile::tempdir().unwrap();
1116        let file = dir.path().join("handover.md");
1117        std::fs::write(&file, "HANDOVER V1\n").unwrap();
1118        let path = file.to_str().unwrap();
1119
1120        let mut cache = SessionCache::new();
1121        cache.store(path, "HANDOVER V1\n");
1122
1123        // Simulate an edit between agents (new mtime + new content).
1124        std::thread::sleep(std::time::Duration::from_millis(10));
1125        std::fs::write(&file, "HANDOVER V2 CHANGED\n").unwrap();
1126
1127        let (content, _) = cache.current_full_content(path).unwrap();
1128        assert_eq!(
1129            content, "HANDOVER V2 CHANGED\n",
1130            "stale cached copy must be re-read from disk, not served as-is"
1131        );
1132    }
1133
1134    #[test]
1135    fn current_full_content_none_without_entry() {
1136        let cache = SessionCache::new();
1137        assert!(cache.current_full_content("/no/such/file.rs").is_none());
1138    }
1139
1140    #[test]
1141    fn current_full_content_falls_back_to_cache_when_file_unreadable() {
1142        // Stale + now-unreadable (deleted/moved): there is no current content to
1143        // serve, so the last-known cached copy is returned rather than nothing.
1144        // Canonicalize the temp dir up front so the cache key is stable after the
1145        // file is removed (macOS /var -> /private/var symlink).
1146        let dir = tempfile::tempdir().unwrap();
1147        let canon = dir.path().canonicalize().unwrap();
1148        let file = canon.join("gone.md");
1149        std::fs::write(&file, "ORIGINAL\n").unwrap();
1150        let path = file.to_str().unwrap().to_string();
1151
1152        let mut cache = SessionCache::new();
1153        cache.store(&path, "ORIGINAL\n");
1154        std::fs::remove_file(&file).unwrap();
1155
1156        let (content, _) = cache.current_full_content(&path).unwrap();
1157        assert_eq!(
1158            content, "ORIGINAL\n",
1159            "unreadable file must fall back to last-known cached content"
1160        );
1161    }
1162
1163    #[test]
1164    fn record_cache_hit_works_through_shared_ref() {
1165        let mut cache = SessionCache::new();
1166        cache.store("/x.rs", "hello world");
1167        // &self path: a cache hit can be recorded without a write lock.
1168        let shared: &SessionCache = &cache;
1169        assert!(shared.record_cache_hit("/x.rs").is_some());
1170        assert!(shared.record_cache_hit("/x.rs").is_some());
1171        // store=1 + two hits => read_count 3, cache_hits 2.
1172        assert_eq!(cache.get("/x.rs").unwrap().read_count(), 3);
1173        assert_eq!(cache.get_stats().cache_hits(), 2);
1174    }
1175
1176    #[test]
1177    fn concurrent_cache_hits_are_lossless() {
1178        use std::sync::Arc;
1179        let mut cache = SessionCache::new();
1180        cache.store("/a.rs", "a");
1181        cache.store("/b.rs", "b");
1182        // Shared (no RwLock): proves SessionCache is Sync and hit recording is
1183        // lock-free and atomic — the whole point of the read-mostly refactor.
1184        let cache = Arc::new(cache);
1185        let threads = 8;
1186        let iters = 1_000;
1187        let handles: Vec<_> = (0..threads)
1188            .map(|_| {
1189                let c = Arc::clone(&cache);
1190                std::thread::spawn(move || {
1191                    for _ in 0..iters {
1192                        c.record_cache_hit("/a.rs");
1193                        c.record_cache_hit("/b.rs");
1194                    }
1195                })
1196            })
1197            .collect();
1198        for h in handles {
1199            h.join().unwrap();
1200        }
1201        let total = (threads * iters) as u64;
1202        assert_eq!(cache.get_stats().cache_hits(), total * 2);
1203        assert_eq!(cache.get("/a.rs").unwrap().read_count(), 1 + total as u32);
1204        assert_eq!(cache.get("/b.rs").unwrap().read_count(), 1 + total as u32);
1205    }
1206
1207    #[test]
1208    fn hebbian_eviction_bonus_is_wired() {
1209        // #3: files read together build a Hebbian association via store()'s
1210        // recording, and that association must feed the eviction bonus.
1211        //
1212        // Warm up tiktoken first: the very first count_tokens() in the process
1213        // lazily loads the BPE tables (can exceed the 500ms co-access burst
1214        // window). store() calls count_tokens() internally, so without warming
1215        // up, the two store() calls below straddle that window and never
1216        // associate — a flaky-empty bonus. Warming up keeps them in one burst.
1217        let _ = count_tokens("warmup");
1218        let mut cache = SessionCache::new();
1219        cache.store("/a.rs", "fn a() {}");
1220        cache.store("/b.rs", "fn b() {}");
1221        cache.flush_co_access(); // commit the burst → association (a,b) forms
1222        let bonus = cache.hebbian_eviction_bonus();
1223        assert!(
1224            !bonus.is_empty(),
1225            "co-accessed reads must yield a Hebbian eviction bonus (#3 wired)"
1226        );
1227    }
1228
1229    #[test]
1230    fn md5_is_deterministic() {
1231        let h1 = compute_md5("test content");
1232        let h2 = compute_md5("test content");
1233        assert_eq!(h1, h2);
1234        assert_ne!(h1, compute_md5("different"));
1235    }
1236
1237    #[test]
1238    fn rrf_eviction_prefers_recent() {
1239        let key_a = "a.rs".to_string();
1240        let key_b = "b.rs".to_string();
1241        // Construct entries first so the global instant base is initialized,
1242        // then assign access times relative to a post-init reference.
1243        let recent = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1244        let old = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1245        let t_old = Instant::now();
1246        std::thread::sleep(std::time::Duration::from_millis(10));
1247        let t_recent = Instant::now();
1248        old.set_last_access(t_old);
1249        recent.set_last_access(t_recent);
1250        let now = Instant::now();
1251        let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &recent), (&key_b, &old)];
1252        let scores = eviction_scores_rrf(&entries, now);
1253        let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1254        let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1255        assert!(
1256            score_a > score_b,
1257            "recently accessed entries should score higher via RRF"
1258        );
1259    }
1260
1261    #[test]
1262    fn rrf_eviction_prefers_frequent() {
1263        let now = Instant::now();
1264        let key_a = "a.rs".to_string();
1265        let key_b = "b.rs".to_string();
1266        let frequent = {
1267            let e = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1268            e.set_read_count(20);
1269            e
1270        };
1271        let rare = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1272        let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &frequent), (&key_b, &rare)];
1273        let scores = eviction_scores_rrf(&entries, now);
1274        let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1275        let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1276        assert!(
1277            score_a > score_b,
1278            "frequently accessed entries should score higher via RRF"
1279        );
1280    }
1281
1282    #[test]
1283    fn cache_budget_resolver_precedence() {
1284        // env wins when positive
1285        assert_eq!(resolve_cache_max_tokens(Some("250000"), 999), 250_000);
1286        assert_eq!(resolve_cache_max_tokens(Some(" 80000 "), 0), 80_000);
1287        // env 0 / blank / garbage falls through to config
1288        assert_eq!(resolve_cache_max_tokens(Some("0"), 123_456), 123_456);
1289        assert_eq!(resolve_cache_max_tokens(Some(""), 123_456), 123_456);
1290        assert_eq!(resolve_cache_max_tokens(Some("lots"), 123_456), 123_456);
1291        // no env → config field
1292        assert_eq!(resolve_cache_max_tokens(None, 42_000), 42_000);
1293        // nothing set anywhere → built-in default
1294        assert_eq!(resolve_cache_max_tokens(None, 0), DEFAULT_CACHE_MAX_TOKENS);
1295        assert_eq!(
1296            resolve_cache_max_tokens(Some("0"), 0),
1297            DEFAULT_CACHE_MAX_TOKENS
1298        );
1299    }
1300
1301    #[test]
1302    fn evict_if_needed_removes_lowest_score() {
1303        crate::test_env::set_var("LEAN_CTX_CACHE_MAX_TOKENS", "50");
1304        let mut cache = SessionCache::new();
1305        let big_content = "a]".repeat(30); // ~30 tokens
1306        cache.store("/old.rs", &big_content);
1307        // /old.rs now in cache with ~30 tokens
1308
1309        let new_content = "b ".repeat(30); // ~30 tokens incoming
1310        cache.store("/new.rs", &new_content);
1311        // should have evicted /old.rs to make room
1312        // (total would be ~60 which exceeds 50)
1313
1314        // At least one should remain, total should be <= 50
1315        assert!(
1316            cache.total_cached_tokens() <= 60,
1317            "eviction should have kicked in"
1318        );
1319        crate::test_env::remove_var("LEAN_CTX_CACHE_MAX_TOKENS");
1320    }
1321
1322    #[test]
1323    fn stale_detection_flags_newer_file() {
1324        let dir = tempfile::tempdir().unwrap();
1325        let path = dir.path().join("stale.txt");
1326        let p = path.to_string_lossy().to_string();
1327
1328        std::fs::write(&path, "one").unwrap();
1329        let mut cache = SessionCache::new();
1330        cache.store(&p, "one");
1331
1332        let entry = cache.get(&p).unwrap();
1333        assert!(!is_cache_entry_stale(&p, entry.stored_mtime));
1334
1335        // Ensure mtime granularity differences don't make this flaky.
1336        std::thread::sleep(Duration::from_secs(1));
1337        std::fs::write(&path, "two").unwrap();
1338
1339        let entry = cache.get(&p).unwrap();
1340        assert!(is_cache_entry_stale(&p, entry.stored_mtime));
1341    }
1342
1343    // P0-7 (#419): a *backward* mtime (git checkout, touch -t) is a change.
1344    #[test]
1345    fn stale_detection_flags_backward_mtime() {
1346        let dir = tempfile::tempdir().unwrap();
1347        let path = dir.path().join("backward.txt");
1348        let p = path.to_string_lossy().to_string();
1349
1350        std::fs::write(&path, "one").unwrap();
1351        let mut cache = SessionCache::new();
1352        cache.store(&p, "one");
1353        let entry_mtime = cache.get(&p).unwrap().stored_mtime;
1354        assert!(!is_cache_entry_stale(&p, entry_mtime));
1355
1356        // Simulate `git checkout` of an older version: content + older mtime.
1357        std::fs::write(&path, "zero").unwrap();
1358        let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1359        f.set_modified(SystemTime::now() - Duration::from_hours(1))
1360            .unwrap();
1361        drop(f);
1362
1363        assert!(
1364            is_cache_entry_stale(&p, entry_mtime),
1365            "older mtime must read as stale"
1366        );
1367    }
1368
1369    // P0-7 (#419): identical mtime with different content (same-second write,
1370    // restored timestamps) is caught by the content-hash verification.
1371    #[test]
1372    fn verified_staleness_catches_same_mtime_content_change() {
1373        let dir = tempfile::tempdir().unwrap();
1374        let path = dir.path().join("sneaky.txt");
1375        let p = path.to_string_lossy().to_string();
1376
1377        std::fs::write(&path, "one").unwrap();
1378        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
1379        let mut cache = SessionCache::new();
1380        cache.store(&p, "one");
1381        let (mtime, hash) = {
1382            let e = cache.get(&p).unwrap();
1383            (e.stored_mtime, e.hash.clone())
1384        };
1385
1386        // Unchanged file: both checks agree it is fresh.
1387        assert!(!is_cache_entry_stale_verified(&p, mtime, &hash));
1388
1389        // Change the content but restore the exact original mtime.
1390        std::fs::write(&path, "two").unwrap();
1391        let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1392        f.set_modified(original_mtime).unwrap();
1393        drop(f);
1394
1395        assert!(
1396            !is_cache_entry_stale(&p, mtime),
1397            "test premise: the mtime check alone is fooled"
1398        );
1399        assert!(
1400            is_cache_entry_stale_verified(&p, mtime, &hash),
1401            "hash verification must catch the change"
1402        );
1403    }
1404
1405    #[test]
1406    fn verified_staleness_flags_unreadable_file() {
1407        let mut cache = SessionCache::new();
1408        cache.store("/nonexistent/file.rs", "content");
1409        let (mtime, hash) = {
1410            let e = cache.get("/nonexistent/file.rs").unwrap();
1411            (e.stored_mtime, e.hash.clone())
1412        };
1413        assert!(is_cache_entry_stale_verified(
1414            "/nonexistent/file.rs",
1415            mtime,
1416            &hash
1417        ));
1418    }
1419
1420    #[test]
1421    fn compressed_outputs_cached_and_retrieved() {
1422        let mut cache = SessionCache::new();
1423        cache.store("/test.rs", "fn main() {}");
1424        cache.set_compressed("/test.rs", "map", "compressed map output".to_string());
1425        assert_eq!(
1426            cache.get_compressed("/test.rs", "map"),
1427            Some(&"compressed map output".to_string())
1428        );
1429        assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1430    }
1431
1432    #[test]
1433    fn compressed_outputs_cleared_on_content_change() {
1434        let mut cache = SessionCache::new();
1435        cache.store("/test.rs", "old content");
1436        cache.set_compressed("/test.rs", "map", "old map".to_string());
1437        assert!(cache.get_compressed("/test.rs", "map").is_some());
1438
1439        cache.store("/test.rs", "new content");
1440        assert_eq!(cache.get_compressed("/test.rs", "map"), None);
1441    }
1442
1443    #[test]
1444    fn compressed_outputs_survive_same_content_store() {
1445        let mut cache = SessionCache::new();
1446        cache.store("/test.rs", "content");
1447        cache.set_compressed("/test.rs", "map", "cached map".to_string());
1448
1449        let result = cache.store("/test.rs", "content");
1450        assert!(result.was_hit);
1451        assert_eq!(
1452            cache.get_compressed("/test.rs", "map"),
1453            Some(&"cached map".to_string())
1454        );
1455    }
1456
1457    #[test]
1458    fn compressed_outputs_cleared_on_invalidate() {
1459        let mut cache = SessionCache::new();
1460        cache.store("/test.rs", "content");
1461        cache.set_compressed("/test.rs", "signatures", "cached sigs".to_string());
1462        cache.invalidate("/test.rs");
1463        assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1464    }
1465
1466    #[test]
1467    fn compressed_outputs_cleared_on_clear() {
1468        let mut cache = SessionCache::new();
1469        cache.store("/a.rs", "a");
1470        cache.set_compressed("/a.rs", "map", "map_a".to_string());
1471        cache.clear();
1472        assert_eq!(cache.get_compressed("/a.rs", "map"), None);
1473    }
1474}