Skip to main content

lean_ctx/core/cache/
session.rs

1use std::collections::HashMap;
2use std::sync::atomic::Ordering;
3use std::time::Instant;
4
5use super::entry::{
6    CacheEntry, CacheStats, HEBBIAN_ACTIVE_SET, HEBBIAN_PROTECT_WEIGHT, SharedBlock, StoreResult,
7    apply_hebbian_bonus, eviction_scores_rrf, max_cache_tokens, normalize_key,
8};
9use super::validation::{compute_md5, is_cache_entry_stale_verified};
10use crate::core::tokens::count_tokens;
11
12pub(crate) const DEFAULT_FULL_DEGRADATION_THRESHOLD: u32 = 2;
13
14pub(crate) fn full_degradation_threshold() -> u32 {
15    std::env::var("LCTX_FULL_DEGRADATION_THRESHOLD")
16        .ok()
17        .and_then(|value| value.trim().parse().ok())
18        .filter(|&threshold| threshold > 0)
19        .unwrap_or(DEFAULT_FULL_DEGRADATION_THRESHOLD)
20}
21
22/// In-memory file cache with segmented LRU eviction (probationary vs protected),
23/// file references, and cross-file dedup.
24pub struct SessionCache {
25    entries: HashMap<String, CacheEntry>,
26    file_refs: HashMap<String, String>,
27    next_ref: usize,
28    stats: CacheStats,
29    shared_blocks: Vec<SharedBlock>,
30    /// Hebbian co-access matrix (#3): tracks which files are read together so
31    /// eviction can protect co-accessed clusters. Updated on `store`, consulted
32    /// during eviction.
33    co_access: crate::core::hebbian_cache::CoAccessMatrix,
34}
35
36impl Default for SessionCache {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl SessionCache {
43    /// Creates an empty session cache with default stats.
44    pub fn new() -> Self {
45        Self {
46            entries: HashMap::new(),
47            file_refs: HashMap::new(),
48            next_ref: 1,
49            shared_blocks: Vec::new(),
50            stats: CacheStats::default(),
51            co_access: crate::core::hebbian_cache::CoAccessMatrix::new(),
52        }
53    }
54
55    /// Record that `path` was accessed, strengthening its Hebbian association
56    /// with other files read in the same burst window (#3). Called on every
57    /// `store`; co-access boundaries are flushed via `flush_co_access`.
58    pub fn record_co_access(&mut self, path: &str) {
59        let key = normalize_key(path);
60        self.co_access
61            .record_access(crate::core::hebbian_cache::path_hash(&key));
62    }
63
64    /// Close the current co-access burst so its associations are committed.
65    /// Call at the end of a logical tool call (post-dispatch).
66    pub fn flush_co_access(&mut self) {
67        self.co_access.end_burst();
68    }
69
70    /// Widen the co-access burst window. Test-only: lets a test keep several
71    /// `store()` calls in one burst without depending on them landing inside
72    /// the real-time 500ms window (a scheduling-jitter flake under parallel
73    /// test execution).
74    #[cfg(test)]
75    pub fn set_co_access_burst_window(&mut self, window: std::time::Duration) {
76        self.co_access.set_burst_window(window);
77    }
78
79    /// Per-entry Hebbian eviction bonus (#3): each cached entry that is
80    /// co-accessed with the recently-active working set earns a positive bonus
81    /// that is added to its RRF score, so clustered files survive eviction
82    /// together. Deterministic (no sampling); ticks the activation registry when
83    /// any association actually influences the decision.
84    pub(crate) fn hebbian_eviction_bonus(&self) -> HashMap<String, f64> {
85        use crate::core::hebbian_cache::path_hash;
86        if self.entries.is_empty() {
87            return HashMap::new();
88        }
89        let mut by_recency: Vec<(&String, Instant)> = self
90            .entries
91            .iter()
92            .map(|(k, e)| (k, e.last_access()))
93            .collect();
94        by_recency.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
95        let active: Vec<u64> = by_recency
96            .iter()
97            .take(HEBBIAN_ACTIVE_SET)
98            .map(|(k, _)| path_hash(k))
99            .collect();
100
101        let mut out = HashMap::new();
102        for k in self.entries.keys() {
103            let h = path_hash(k);
104            // Exclude self so an entry never "protects itself".
105            let peers: Vec<u64> = active.iter().copied().filter(|&a| a != h).collect();
106            let strength = self.co_access.association_strength(h, &peers);
107            if strength > 0.0 {
108                out.insert(k.clone(), f64::from(strength) * HEBBIAN_PROTECT_WEIGHT);
109            }
110        }
111        if !out.is_empty() {
112            crate::core::introspect::tick("hebbian_cache");
113        }
114        out
115    }
116
117    /// Returns or assigns a short file reference label (F1, F2, ...) for the given path.
118    pub fn get_file_ref(&mut self, path: &str) -> String {
119        let key = normalize_key(path);
120        if let Some(r) = self.file_refs.get(&key) {
121            return r.clone();
122        }
123        let r = format!("F{}", self.next_ref);
124        self.next_ref += 1;
125        self.file_refs.insert(key, r.clone());
126        r
127    }
128
129    /// Returns the file reference label for a path without assigning a new one.
130    pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
131        self.file_refs.get(&normalize_key(path)).cloned()
132    }
133
134    /// Looks up a cached entry by file path.
135    pub fn get(&self, path: &str) -> Option<&CacheEntry> {
136        self.entries.get(&normalize_key(path))
137    }
138
139    /// Mutable lookup of a cached entry by file path.
140    pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
141        self.entries.get_mut(&normalize_key(path))
142    }
143
144    /// Retrieves the full (uncompressed) content for a file path, if cached.
145    /// Used by the CCR (Compress-Cache-Retrieve) mechanism.
146    pub fn get_full_content(&self, path: &str) -> Option<String> {
147        self.entries
148            .get(&normalize_key(path))
149            .and_then(CacheEntry::content)
150    }
151
152    /// Staleness-safe accessor for the *current* full content and its token
153    /// count: returns the cached copy when it is still fresh, or a fresh disk
154    /// re-read when the cached copy is stale (mtime/hash changed since it was
155    /// cached). Returns `None` when there is no cache entry, or the entry is
156    /// stale and the file can no longer be read.
157    ///
158    /// Cross-agent / retrieve paths (`ctx_retrieve`, `ctx_share`) MUST use this
159    /// instead of [`get_full_content`](Self::get_full_content): serving the raw
160    /// cached copy hands an agent a version that may no longer match disk — e.g.
161    /// a handover file edited between two agents — silently feeding it stale
162    /// context. Validation uses the entry's stored absolute `path`, because a
163    /// caller's `path` may be relative and resolve against a different CWD.
164    pub fn current_full_content(&self, path: &str) -> Option<(String, usize)> {
165        let entry = self.entries.get(&normalize_key(path))?;
166        if is_cache_entry_stale_verified(&entry.path, entry.stored_mtime, &entry.hash)
167            && let Ok(fresh) = crate::core::io_boundary::read_file_lossy(&entry.path)
168        {
169            // Cache is behind disk → serve the current bytes. If the file is now
170            // unreadable (deleted/permission), fall through to the cached copy:
171            // last-known content beats nothing, and that fall-through is not the
172            // staleness bug (it only fires when there is no current content).
173            let tokens = count_tokens(&fresh);
174            return Some((fresh, tokens));
175        }
176        Some((entry.content()?, entry.original_tokens))
177    }
178
179    /// Records a cache hit, updates access stats, and emits a cache-hit event.
180    ///
181    /// Takes `&self`: the hit counters use interior-mutable atomics, so this
182    /// runs under a shared (read) lock and lets parallel reads of different
183    /// files proceed concurrently instead of serializing on a write lock.
184    pub fn record_cache_hit(&self, path: &str) -> Option<&CacheEntry> {
185        let key = normalize_key(path);
186        let ref_label = self
187            .file_refs
188            .get(&key)
189            .cloned()
190            .unwrap_or_else(|| "F?".to_string());
191        let entry = self.entries.get(&key)?;
192        let new_count = entry.bump_read_count();
193        entry.touch();
194        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
195        self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
196        self.stats
197            .total_original_tokens
198            .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
199        let hit_msg = format!("{ref_label} cached {new_count}t {}L", entry.line_count);
200        let sent_tokens = count_tokens(&hit_msg) as u64;
201        self.stats
202            .total_sent_tokens
203            .fetch_add(sent_tokens, Ordering::Relaxed);
204        crate::core::events::emit_cache_hit(
205            path,
206            (entry.original_tokens as u64).saturating_sub(sent_tokens),
207        );
208        Some(entry)
209    }
210
211    /// Stores file content in the cache; returns a hit if content hash matches.
212    pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
213        let key = normalize_key(path);
214        // #3: feed the Hebbian co-access matrix on every read so eviction can
215        // later protect files that are habitually read together.
216        self.co_access
217            .record_access(crate::core::hebbian_cache::path_hash(&key));
218        let hash = compute_md5(content);
219        let line_count = content.lines().count();
220        let original_tokens = count_tokens(content);
221        let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
222        let now = Instant::now();
223
224        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
225        self.stats
226            .total_original_tokens
227            .fetch_add(original_tokens as u64, Ordering::Relaxed);
228
229        if let Some(existing) = self.entries.get_mut(&key) {
230            existing.set_last_access(now);
231            if stored_mtime.is_some() {
232                existing.stored_mtime = stored_mtime;
233            }
234            if existing.hash == hash {
235                let new_count = existing.bump_read_count();
236                self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
237                let hit_msg = format!(
238                    "{} cached {new_count}t {}L",
239                    self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
240                    existing.line_count,
241                );
242                let sent_tokens = count_tokens(&hit_msg) as u64;
243                self.stats
244                    .total_sent_tokens
245                    .fetch_add(sent_tokens, Ordering::Relaxed);
246                return StoreResult {
247                    line_count: existing.line_count,
248                    original_tokens: existing.original_tokens,
249                    read_count: new_count,
250                    was_hit: true,
251                    full_content_delivered: existing.full_content_delivered,
252                };
253            }
254            existing.compressed_outputs.clear();
255            existing.set_content(content);
256            existing.hash = hash;
257            existing.line_count = line_count;
258            existing.original_tokens = original_tokens;
259            let new_count = existing.bump_read_count();
260            existing.full_content_delivered = false;
261            existing.delivered_conversation = None;
262            existing.last_mode.clear();
263            if stored_mtime.is_some() {
264                existing.stored_mtime = stored_mtime;
265            }
266            self.stats
267                .total_sent_tokens
268                .fetch_add(original_tokens as u64, Ordering::Relaxed);
269            return StoreResult {
270                line_count,
271                original_tokens,
272                read_count: new_count,
273                was_hit: false,
274                full_content_delivered: false,
275            };
276        }
277
278        self.evict_if_needed(original_tokens);
279        self.get_file_ref(&key);
280
281        let entry = CacheEntry::new(
282            content,
283            hash,
284            line_count,
285            original_tokens,
286            key.clone(),
287            stored_mtime,
288        );
289
290        self.entries.insert(key, entry);
291        self.stats.files_tracked.fetch_add(1, Ordering::Relaxed);
292        self.stats
293            .total_sent_tokens
294            .fetch_add(original_tokens as u64, Ordering::Relaxed);
295        StoreResult {
296            line_count,
297            original_tokens,
298            read_count: 1,
299            was_hit: false,
300            full_content_delivered: false,
301        }
302    }
303
304    /// Returns the sum of original token counts across all cached entries.
305    pub fn total_cached_tokens(&self) -> usize {
306        self.entries.values().map(|e| e.original_tokens).sum()
307    }
308
309    /// Evict until cache fits within token budget using RRF (Reciprocal Rank Fusion).
310    /// Combines recency, frequency, and size signals to evict least-valuable entries first.
311    pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
312        let max_tokens = max_cache_tokens();
313        let current = self.total_cached_tokens();
314        if current + incoming_tokens <= max_tokens {
315            return;
316        }
317
318        let now = Instant::now();
319        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
320        let mut scores = eviction_scores_rrf(&all, now);
321        apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
322        // Sort ascending: lowest RRF score = least valuable = evict first
323        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
324
325        let mut freed = 0usize;
326        let mut redelivered = 0u64;
327        let target = (current + incoming_tokens).saturating_sub(max_tokens);
328
329        for (path, _score) in &scores {
330            if freed >= target {
331                break;
332            }
333            if let Some(entry) = self.entries.remove(path) {
334                freed += entry.original_tokens;
335                if entry.full_content_delivered {
336                    redelivered += 1;
337                }
338                self.file_refs.remove(path);
339            }
340        }
341        crate::core::cache_telemetry::record_eviction(redelivered);
342    }
343
344    /// Returns all cached entries as (path, entry) pairs.
345    pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
346        self.entries.iter().collect()
347    }
348
349    /// Returns a reference to the aggregated cache statistics.
350    pub fn get_stats(&self) -> &CacheStats {
351        &self.stats
352    }
353
354    /// Returns the path-to-file-ref mapping (e.g. "/src/main.rs" → "F1").
355    pub fn file_ref_map(&self) -> &HashMap<String, String> {
356        &self.file_refs
357    }
358
359    /// Replaces the cross-file shared blocks used for deduplication.
360    pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
361        self.shared_blocks = blocks;
362    }
363
364    /// Returns the current set of cross-file shared blocks.
365    pub fn get_shared_blocks(&self) -> &[SharedBlock] {
366        &self.shared_blocks
367    }
368
369    /// Replace shared blocks in content with cross-file references.
370    pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
371        if self.shared_blocks.is_empty() {
372            return None;
373        }
374        let refs: Vec<&SharedBlock> = self
375            .shared_blocks
376            .iter()
377            .filter(|b| b.canonical_path != path && content.contains(&b.content))
378            .collect();
379        if refs.is_empty() {
380            return None;
381        }
382        let mut result = content.to_string();
383        for block in refs {
384            result = result.replacen(
385                &block.content,
386                &format!(
387                    "[= {}:{}-{}]",
388                    block.canonical_ref, block.start_line, block.end_line
389                ),
390                1,
391            );
392        }
393        Some(result)
394    }
395
396    /// Removes a file from the cache, forcing a fresh read on next access.
397    pub fn invalidate(&mut self, path: &str) -> bool {
398        self.entries.remove(&normalize_key(path)).is_some()
399    }
400
401    /// Returns a cached compressed output for a given file and mode key.
402    /// Counts as a cache hit — the caller avoids a full disk read + recompression.
403    pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
404        let key = normalize_key(path);
405        let entry = self.entries.get(&key)?;
406        let result = entry.get_compressed(mode_key)?;
407        entry.bump_read_count();
408        entry.touch();
409        self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
410        self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
411        self.stats
412            .total_original_tokens
413            .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
414        let sent = count_tokens(result);
415        self.stats
416            .total_sent_tokens
417            .fetch_add(sent as u64, Ordering::Relaxed);
418        crate::core::events::emit_cache_hit(
419            path,
420            (entry.original_tokens as u64).saturating_sub(sent as u64),
421        );
422        crate::core::stats::record_reread(entry.original_tokens.saturating_sub(sent));
423        Some(result)
424    }
425
426    /// Marks that full (uncompressed) content was delivered for this file,
427    /// tagging it with the current conversation so a later re-read only serves
428    /// the `[unchanged]` stub to the same conversation (see
429    /// `crate::core::conversation`).
430    pub fn mark_full_delivered(&mut self, path: &str) {
431        let conversation = crate::core::conversation::current_conversation_id();
432        let key = normalize_key(path);
433        let file_ref = self.file_refs.get(&key).cloned();
434        if let Some(entry) = self.entries.get_mut(&key) {
435            entry.mark_full_delivered(conversation.clone());
436            // Write-through to the persistent stub index so an unchanged re-read
437            // in the same conversation survives a daemon restart / idle clear
438            // (#955). `record` ignores None-conversation deliveries.
439            crate::core::read_stub_index::record(crate::core::read_stub_index::StubRecord::new(
440                key.clone(),
441                entry.hash.clone(),
442                entry.stored_mtime,
443                entry.line_count,
444                file_ref.unwrap_or_default(),
445                conversation,
446            ));
447        }
448    }
449
450    /// Stores a compressed output for a given file and mode key.
451    pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
452        if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
453            entry.set_compressed(mode_key, output);
454        }
455    }
456
457    /// Resets `full_content_delivered` for all entries without removing them.
458    /// Used after host context compaction — forces re-delivery on next read
459    /// while preserving compressed content and file refs.
460    pub fn reset_delivery_flags(&mut self) -> usize {
461        let mut count = 0;
462        for entry in self.entries.values_mut() {
463            if entry.full_content_delivered {
464                entry.full_content_delivered = false;
465                count += 1;
466            }
467        }
468        count
469    }
470
471    /// Returns whether full content was previously delivered for this path.
472    pub fn is_full_delivered(&self, path: &str) -> bool {
473        self.entries
474            .get(&normalize_key(path))
475            .is_some_and(|e| e.full_content_delivered)
476    }
477
478    /// Returns the last successful read mode for an entry, if one was recorded.
479    pub fn last_mode(&self, path: &str) -> Option<String> {
480        self.entries
481            .get(&normalize_key(path))
482            .map(|entry| entry.last_mode.clone())
483            .filter(|mode| !mode.is_empty())
484    }
485
486    /// Counts entries that have full content delivered — i.e. those that would
487    /// serve a cheap `[unchanged]` stub and therefore force a full re-delivery
488    /// if dropped. Used by re-delivery telemetry at clear/eviction sites.
489    pub fn count_full_delivered(&self) -> usize {
490        self.entries
491            .values()
492            .filter(|e| e.full_content_delivered)
493            .count()
494    }
495
496    /// Removes all compressed output variants (map, signatures, etc.) from every entry,
497    /// keeping the full zstd-compressed content intact. Returns the number of entries trimmed.
498    pub fn trim_compressed_outputs(&mut self) -> usize {
499        let mut trimmed = 0;
500        for entry in self.entries.values_mut() {
501            if !entry.compressed_outputs.is_empty() {
502                entry.compressed_outputs.clear();
503                trimmed += 1;
504            }
505        }
506        trimmed
507    }
508
509    /// Evicts all entries that have been read at most once (probationary).
510    /// Returns the number of entries removed.
511    pub fn evict_probationary(&mut self) -> usize {
512        let to_remove: Vec<String> = self
513            .entries
514            .iter()
515            .filter(|(_, e)| e.read_count() <= 1)
516            .map(|(k, _)| k.clone())
517            .collect();
518        let count = to_remove.len();
519        let mut redelivered = 0u64;
520        for key in &to_remove {
521            if self
522                .entries
523                .remove(key)
524                .is_some_and(|e| e.full_content_delivered)
525            {
526                redelivered += 1;
527            }
528            self.file_refs.remove(key);
529        }
530        crate::core::cache_telemetry::record_eviction(redelivered);
531        count
532    }
533
534    /// Evicts entries via RRF scoring until total tokens are at or below `target_tokens`.
535    pub fn evict_to_budget(&mut self, target_tokens: usize) {
536        let current = self.total_cached_tokens();
537        if current <= target_tokens {
538            return;
539        }
540        let now = Instant::now();
541        let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
542        let mut scores = eviction_scores_rrf(&all, now);
543        apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
544        scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
545
546        let mut freed = 0usize;
547        let mut redelivered = 0u64;
548        let target_free = current.saturating_sub(target_tokens);
549        for (path, _score) in &scores {
550            if freed >= target_free {
551                break;
552            }
553            if let Some(entry) = self.entries.remove(path) {
554                freed += entry.original_tokens;
555                if entry.full_content_delivered {
556                    redelivered += 1;
557                }
558                self.file_refs.remove(path);
559            }
560        }
561        crate::core::cache_telemetry::record_eviction(redelivered);
562    }
563
564    /// Estimates the approximate heap memory usage in bytes.
565    pub fn approximate_bytes(&self) -> usize {
566        let entries_bytes: usize = self
567            .entries
568            .values()
569            .map(|e| {
570                e.compressed_content.len()
571                    + e.hash.len()
572                    + e.path.len()
573                    + e.compressed_outputs
574                        .iter()
575                        .map(|(k, v)| k.len() + v.len())
576                        .sum::<usize>()
577                    + 128 // fixed overhead per entry
578            })
579            .sum();
580        let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
581        let blocks_bytes: usize = self
582            .shared_blocks
583            .iter()
584            .map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
585            .sum();
586        entries_bytes + refs_bytes + blocks_bytes
587    }
588
589    const MAX_SHARED_BLOCKS: usize = 100;
590
591    /// Trims shared blocks to a maximum count, keeping the most recent.
592    pub fn trim_shared_blocks(&mut self) {
593        if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
594            let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
595            self.shared_blocks.drain(..excess);
596        }
597    }
598
599    /// Clears all cached entries, file refs, and resets stats. Returns the number of entries removed.
600    pub fn clear(&mut self) -> usize {
601        let count = self.entries.len();
602        self.entries.clear();
603        self.file_refs.clear();
604        self.shared_blocks.clear();
605        self.next_ref = 1;
606        self.stats = CacheStats::default();
607        count
608    }
609}