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