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