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
9fn 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
29pub(crate) const DEFAULT_CACHE_MAX_TOKENS: usize = 500_000;
31
32fn resolve_cache_max_tokens(env: Option<&str>, configured: usize) -> usize {
39 if let Some(raw) = env
40 && let Ok(n) = raw.trim().parse::<usize>()
41 && n > 0
42 {
43 return n;
44 }
45 if configured > 0 {
46 configured
47 } else {
48 DEFAULT_CACHE_MAX_TOKENS
49 }
50}
51
52pub(crate) fn max_cache_tokens() -> usize {
57 resolve_cache_max_tokens(
58 std::env::var("LEAN_CTX_CACHE_MAX_TOKENS").ok().as_deref(),
59 crate::core::config::Config::load().cache_max_tokens,
60 )
61}
62
63#[derive(Debug)]
69pub struct CacheEntry {
70 compressed_content: Vec<u8>,
71 pub hash: String,
72 pub line_count: usize,
73 pub original_tokens: usize,
74 read_count: AtomicU32,
75 pub path: String,
76 last_access: AtomicU64,
77 pub stored_mtime: Option<SystemTime>,
78 pub compressed_outputs: HashMap<String, String>,
80 pub full_content_delivered: bool,
83 pub last_mode: String,
85}
86
87const ZSTD_LEVEL: i32 = 3;
88
89fn zstd_compress(data: &str) -> Vec<u8> {
90 zstd::encode_all(data.as_bytes(), ZSTD_LEVEL).unwrap_or_else(|_| data.as_bytes().to_vec())
91}
92
93fn zstd_decompress(data: &[u8]) -> Option<String> {
94 zstd::decode_all(data)
95 .ok()
96 .and_then(|v| String::from_utf8(v).ok())
97}
98
99impl CacheEntry {
100 pub fn new(
102 content: &str,
103 hash: String,
104 line_count: usize,
105 original_tokens: usize,
106 path: String,
107 stored_mtime: Option<SystemTime>,
108 ) -> Self {
109 let compressed_content = zstd_compress(content);
110 Self {
111 compressed_content,
112 hash,
113 line_count,
114 original_tokens,
115 read_count: AtomicU32::new(1),
116 path,
117 last_access: AtomicU64::new(encode_instant(Instant::now())),
118 stored_mtime,
119 compressed_outputs: HashMap::new(),
120 full_content_delivered: false,
121 last_mode: String::new(),
122 }
123 }
124
125 pub fn read_count(&self) -> u32 {
127 self.read_count.load(Ordering::Relaxed)
128 }
129
130 pub fn bump_read_count(&self) -> u32 {
132 self.read_count.fetch_add(1, Ordering::Relaxed) + 1
133 }
134
135 pub fn set_read_count(&self, n: u32) {
137 self.read_count.store(n, Ordering::Relaxed);
138 }
139
140 pub fn last_access(&self) -> Instant {
142 decode_instant(self.last_access.load(Ordering::Relaxed))
143 }
144
145 pub fn touch(&self) {
147 self.last_access
148 .store(encode_instant(Instant::now()), Ordering::Relaxed);
149 }
150
151 pub fn set_last_access(&self, when: Instant) {
153 self.last_access
154 .store(encode_instant(when), Ordering::Relaxed);
155 }
156
157 pub fn content(&self) -> Option<String> {
159 zstd_decompress(&self.compressed_content)
160 }
161
162 pub fn set_content(&mut self, content: &str) {
164 self.compressed_content = zstd_compress(content);
165 }
166
167 pub fn compressed_size(&self) -> usize {
169 self.compressed_content.len()
170 }
171}
172
173#[derive(Debug, Clone)]
175pub struct StoreResult {
176 pub line_count: usize,
177 pub original_tokens: usize,
178 pub read_count: u32,
179 pub was_hit: bool,
180 pub full_content_delivered: bool,
182}
183
184impl CacheEntry {
185 pub fn eviction_score_legacy(&self, now: Instant) -> f64 {
187 let elapsed = now
188 .checked_duration_since(self.last_access())
189 .unwrap_or_default()
190 .as_secs_f64();
191 let recency = 1.0 / (1.0 + elapsed.sqrt());
192 let frequency = (self.read_count() as f64 + 1.0).ln();
193 let size_value = (self.original_tokens as f64 + 1.0).ln();
194 recency * 0.4 + frequency * 0.3 + size_value * 0.3
195 }
196
197 pub fn get_compressed(&self, mode_key: &str) -> Option<&String> {
198 self.compressed_outputs.get(mode_key)
199 }
200
201 pub fn set_compressed(&mut self, mode_key: &str, output: String) {
202 const MAX_COMPRESSED_VARIANTS: usize = 3;
203 if self.compressed_outputs.len() >= MAX_COMPRESSED_VARIANTS
204 && !self.compressed_outputs.contains_key(mode_key)
205 && let Some(oldest_key) = self.compressed_outputs.keys().next().cloned()
206 {
207 self.compressed_outputs.remove(&oldest_key);
208 }
209 self.compressed_outputs.insert(mode_key.to_string(), output);
210 }
211
212 pub fn mark_full_delivered(&mut self) {
213 self.full_content_delivered = true;
214 }
215}
216
217const RRF_K: f64 = 60.0;
218
219const HEBBIAN_PROTECT_WEIGHT: f64 = 0.05;
224const HEBBIAN_ACTIVE_SET: usize = 8;
227
228pub fn eviction_scores_rrf(entries: &[(&String, &CacheEntry)], now: Instant) -> Vec<(String, f64)> {
233 if entries.is_empty() {
234 return Vec::new();
235 }
236
237 let n = entries.len();
238
239 let mut recency_order: Vec<usize> = (0..n).collect();
240 recency_order.sort_by(|&a, &b| {
241 let elapsed_a = now
242 .checked_duration_since(entries[a].1.last_access())
243 .unwrap_or_default()
244 .as_secs_f64();
245 let elapsed_b = now
246 .checked_duration_since(entries[b].1.last_access())
247 .unwrap_or_default()
248 .as_secs_f64();
249 elapsed_a
250 .partial_cmp(&elapsed_b)
251 .unwrap_or(std::cmp::Ordering::Equal)
252 });
253
254 let mut frequency_order: Vec<usize> = (0..n).collect();
255 frequency_order.sort_by(|&a, &b| entries[b].1.read_count().cmp(&entries[a].1.read_count()));
256
257 let mut size_order: Vec<usize> = (0..n).collect();
258 size_order.sort_by(|&a, &b| {
259 entries[b]
260 .1
261 .original_tokens
262 .cmp(&entries[a].1.original_tokens)
263 });
264
265 let mut recency_ranks = vec![0usize; n];
266 let mut frequency_ranks = vec![0usize; n];
267 let mut size_ranks = vec![0usize; n];
268
269 for (rank, &idx) in recency_order.iter().enumerate() {
270 recency_ranks[idx] = rank;
271 }
272 for (rank, &idx) in frequency_order.iter().enumerate() {
273 frequency_ranks[idx] = rank;
274 }
275 for (rank, &idx) in size_order.iter().enumerate() {
276 size_ranks[idx] = rank;
277 }
278
279 entries
280 .iter()
281 .enumerate()
282 .map(|(i, (path, _))| {
283 let score = 1.0 / (RRF_K + recency_ranks[i] as f64)
284 + 1.0 / (RRF_K + frequency_ranks[i] as f64)
285 + 1.0 / (RRF_K + size_ranks[i] as f64);
286 ((*path).clone(), score)
287 })
288 .collect()
289}
290
291fn apply_hebbian_bonus(scores: &mut [(String, f64)], bonus: &HashMap<String, f64>) {
294 if bonus.is_empty() {
295 return;
296 }
297 for s in scores.iter_mut() {
298 if let Some(b) = bonus.get(&s.0) {
299 s.1 += *b;
300 }
301 }
302}
303
304#[derive(Debug, Default)]
309pub struct CacheStats {
310 total_reads: AtomicU64,
311 cache_hits: AtomicU64,
312 total_original_tokens: AtomicU64,
313 total_sent_tokens: AtomicU64,
314 files_tracked: AtomicU64,
315}
316
317impl CacheStats {
318 pub fn total_reads(&self) -> u64 {
320 self.total_reads.load(Ordering::Relaxed)
321 }
322
323 pub fn cache_hits(&self) -> u64 {
325 self.cache_hits.load(Ordering::Relaxed)
326 }
327
328 pub fn total_original_tokens(&self) -> u64 {
330 self.total_original_tokens.load(Ordering::Relaxed)
331 }
332
333 pub fn total_sent_tokens(&self) -> u64 {
335 self.total_sent_tokens.load(Ordering::Relaxed)
336 }
337
338 pub fn files_tracked(&self) -> u64 {
340 self.files_tracked.load(Ordering::Relaxed)
341 }
342
343 pub fn hit_rate(&self) -> f64 {
345 let total = self.total_reads();
346 if total == 0 {
347 return 0.0;
348 }
349 (self.cache_hits() as f64 / total as f64) * 100.0
350 }
351
352 pub fn tokens_saved(&self) -> u64 {
354 self.total_original_tokens()
355 .saturating_sub(self.total_sent_tokens())
356 }
357
358 pub fn savings_percent(&self) -> f64 {
360 let original = self.total_original_tokens();
361 if original == 0 {
362 return 0.0;
363 }
364 (self.tokens_saved() as f64 / original as f64) * 100.0
365 }
366}
367
368#[derive(Clone, Debug)]
370pub struct SharedBlock {
371 pub canonical_path: String,
372 pub canonical_ref: String,
373 pub start_line: usize,
374 pub end_line: usize,
375 pub content: String,
376}
377
378pub struct SessionCache {
381 entries: HashMap<String, CacheEntry>,
382 file_refs: HashMap<String, String>,
383 next_ref: usize,
384 stats: CacheStats,
385 shared_blocks: Vec<SharedBlock>,
386 co_access: crate::core::hebbian_cache::CoAccessMatrix,
390}
391
392impl Default for SessionCache {
393 fn default() -> Self {
394 Self::new()
395 }
396}
397
398impl SessionCache {
399 pub fn new() -> Self {
401 Self {
402 entries: HashMap::new(),
403 file_refs: HashMap::new(),
404 next_ref: 1,
405 shared_blocks: Vec::new(),
406 stats: CacheStats::default(),
407 co_access: crate::core::hebbian_cache::CoAccessMatrix::new(),
408 }
409 }
410
411 pub fn record_co_access(&mut self, path: &str) {
415 let key = normalize_key(path);
416 self.co_access
417 .record_access(crate::core::hebbian_cache::path_hash(&key));
418 }
419
420 pub fn flush_co_access(&mut self) {
423 self.co_access.end_burst();
424 }
425
426 pub(crate) fn hebbian_eviction_bonus(&self) -> HashMap<String, f64> {
432 use crate::core::hebbian_cache::path_hash;
433 if self.entries.is_empty() {
434 return HashMap::new();
435 }
436 let mut by_recency: Vec<(&String, Instant)> = self
437 .entries
438 .iter()
439 .map(|(k, e)| (k, e.last_access()))
440 .collect();
441 by_recency.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
442 let active: Vec<u64> = by_recency
443 .iter()
444 .take(HEBBIAN_ACTIVE_SET)
445 .map(|(k, _)| path_hash(k))
446 .collect();
447
448 let mut out = HashMap::new();
449 for k in self.entries.keys() {
450 let h = path_hash(k);
451 let peers: Vec<u64> = active.iter().copied().filter(|&a| a != h).collect();
453 let strength = self.co_access.association_strength(h, &peers);
454 if strength > 0.0 {
455 out.insert(k.clone(), f64::from(strength) * HEBBIAN_PROTECT_WEIGHT);
456 }
457 }
458 if !out.is_empty() {
459 crate::core::introspect::tick("hebbian_cache");
460 }
461 out
462 }
463
464 pub fn get_file_ref(&mut self, path: &str) -> String {
466 let key = normalize_key(path);
467 if let Some(r) = self.file_refs.get(&key) {
468 return r.clone();
469 }
470 let r = format!("F{}", self.next_ref);
471 self.next_ref += 1;
472 self.file_refs.insert(key, r.clone());
473 r
474 }
475
476 pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
478 self.file_refs.get(&normalize_key(path)).cloned()
479 }
480
481 pub fn get(&self, path: &str) -> Option<&CacheEntry> {
483 self.entries.get(&normalize_key(path))
484 }
485
486 pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
488 self.entries.get_mut(&normalize_key(path))
489 }
490
491 pub fn get_full_content(&self, path: &str) -> Option<String> {
494 self.entries
495 .get(&normalize_key(path))
496 .and_then(CacheEntry::content)
497 }
498
499 pub fn current_full_content(&self, path: &str) -> Option<(String, usize)> {
512 let entry = self.entries.get(&normalize_key(path))?;
513 if is_cache_entry_stale_verified(&entry.path, entry.stored_mtime, &entry.hash)
514 && let Ok(fresh) = crate::core::io_boundary::read_file_lossy(&entry.path)
515 {
516 let tokens = count_tokens(&fresh);
521 return Some((fresh, tokens));
522 }
523 Some((entry.content()?, entry.original_tokens))
524 }
525
526 pub fn record_cache_hit(&self, path: &str) -> Option<&CacheEntry> {
532 let key = normalize_key(path);
533 let ref_label = self
534 .file_refs
535 .get(&key)
536 .cloned()
537 .unwrap_or_else(|| "F?".to_string());
538 let entry = self.entries.get(&key)?;
539 let new_count = entry.bump_read_count();
540 entry.touch();
541 self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
542 self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
543 self.stats
544 .total_original_tokens
545 .fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
546 let hit_msg = format!("{ref_label} cached {new_count}t {}L", entry.line_count);
547 self.stats
548 .total_sent_tokens
549 .fetch_add(count_tokens(&hit_msg) as u64, Ordering::Relaxed);
550 crate::core::events::emit_cache_hit(path, entry.original_tokens as u64);
551 Some(entry)
552 }
553
554 pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
556 let key = normalize_key(path);
557 self.co_access
560 .record_access(crate::core::hebbian_cache::path_hash(&key));
561 let hash = compute_md5(content);
562 let line_count = content.lines().count();
563 let original_tokens = count_tokens(content);
564 let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
565 let now = Instant::now();
566
567 self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
568 self.stats
569 .total_original_tokens
570 .fetch_add(original_tokens as u64, Ordering::Relaxed);
571
572 if let Some(existing) = self.entries.get_mut(&key) {
573 existing.set_last_access(now);
574 if stored_mtime.is_some() {
575 existing.stored_mtime = stored_mtime;
576 }
577 if existing.hash == hash {
578 let new_count = existing.bump_read_count();
579 self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
580 let hit_msg = format!(
581 "{} cached {new_count}t {}L",
582 self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
583 existing.line_count,
584 );
585 self.stats
586 .total_sent_tokens
587 .fetch_add(count_tokens(&hit_msg) as u64, Ordering::Relaxed);
588 return StoreResult {
589 line_count: existing.line_count,
590 original_tokens: existing.original_tokens,
591 read_count: new_count,
592 was_hit: true,
593 full_content_delivered: existing.full_content_delivered,
594 };
595 }
596 existing.compressed_outputs.clear();
597 existing.set_content(content);
598 existing.hash = hash;
599 existing.line_count = line_count;
600 existing.original_tokens = original_tokens;
601 let new_count = existing.bump_read_count();
602 existing.full_content_delivered = false;
603 if stored_mtime.is_some() {
604 existing.stored_mtime = stored_mtime;
605 }
606 self.stats
607 .total_sent_tokens
608 .fetch_add(original_tokens as u64, Ordering::Relaxed);
609 return StoreResult {
610 line_count,
611 original_tokens,
612 read_count: new_count,
613 was_hit: false,
614 full_content_delivered: false,
615 };
616 }
617
618 self.evict_if_needed(original_tokens);
619 self.get_file_ref(&key);
620
621 let entry = CacheEntry::new(
622 content,
623 hash,
624 line_count,
625 original_tokens,
626 key.clone(),
627 stored_mtime,
628 );
629
630 self.entries.insert(key, entry);
631 self.stats.files_tracked.fetch_add(1, Ordering::Relaxed);
632 self.stats
633 .total_sent_tokens
634 .fetch_add(original_tokens as u64, Ordering::Relaxed);
635 StoreResult {
636 line_count,
637 original_tokens,
638 read_count: 1,
639 was_hit: false,
640 full_content_delivered: false,
641 }
642 }
643
644 pub fn total_cached_tokens(&self) -> usize {
646 self.entries.values().map(|e| e.original_tokens).sum()
647 }
648
649 pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
652 let max_tokens = max_cache_tokens();
653 let current = self.total_cached_tokens();
654 if current + incoming_tokens <= max_tokens {
655 return;
656 }
657
658 let now = Instant::now();
659 let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
660 let mut scores = eviction_scores_rrf(&all, now);
661 apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
662 scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
664
665 let mut freed = 0usize;
666 let target = (current + incoming_tokens).saturating_sub(max_tokens);
667
668 for (path, _score) in &scores {
669 if freed >= target {
670 break;
671 }
672 if let Some(entry) = self.entries.remove(path) {
673 freed += entry.original_tokens;
674 self.file_refs.remove(path);
675 }
676 }
677 }
678
679 pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
681 self.entries.iter().collect()
682 }
683
684 pub fn get_stats(&self) -> &CacheStats {
686 &self.stats
687 }
688
689 pub fn file_ref_map(&self) -> &HashMap<String, String> {
691 &self.file_refs
692 }
693
694 pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
696 self.shared_blocks = blocks;
697 }
698
699 pub fn get_shared_blocks(&self) -> &[SharedBlock] {
701 &self.shared_blocks
702 }
703
704 pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
706 if self.shared_blocks.is_empty() {
707 return None;
708 }
709 let refs: Vec<&SharedBlock> = self
710 .shared_blocks
711 .iter()
712 .filter(|b| b.canonical_path != path && content.contains(&b.content))
713 .collect();
714 if refs.is_empty() {
715 return None;
716 }
717 let mut result = content.to_string();
718 for block in refs {
719 result = result.replacen(
720 &block.content,
721 &format!(
722 "[= {}:{}-{}]",
723 block.canonical_ref, block.start_line, block.end_line
724 ),
725 1,
726 );
727 }
728 Some(result)
729 }
730
731 pub fn invalidate(&mut self, path: &str) -> bool {
733 self.entries.remove(&normalize_key(path)).is_some()
734 }
735
736 pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
738 self.entries
739 .get(&normalize_key(path))?
740 .get_compressed(mode_key)
741 }
742
743 pub fn mark_full_delivered(&mut self, path: &str) {
745 if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
746 entry.mark_full_delivered();
747 }
748 }
749
750 pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
752 if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
753 entry.set_compressed(mode_key, output);
754 }
755 }
756
757 pub fn reset_delivery_flags(&mut self) -> usize {
761 let mut count = 0;
762 for entry in self.entries.values_mut() {
763 if entry.full_content_delivered {
764 entry.full_content_delivered = false;
765 count += 1;
766 }
767 }
768 count
769 }
770
771 pub fn is_full_delivered(&self, path: &str) -> bool {
773 self.entries
774 .get(&normalize_key(path))
775 .is_some_and(|e| e.full_content_delivered)
776 }
777
778 pub fn trim_compressed_outputs(&mut self) -> usize {
781 let mut trimmed = 0;
782 for entry in self.entries.values_mut() {
783 if !entry.compressed_outputs.is_empty() {
784 entry.compressed_outputs.clear();
785 trimmed += 1;
786 }
787 }
788 trimmed
789 }
790
791 pub fn evict_probationary(&mut self) -> usize {
794 let to_remove: Vec<String> = self
795 .entries
796 .iter()
797 .filter(|(_, e)| e.read_count() <= 1)
798 .map(|(k, _)| k.clone())
799 .collect();
800 let count = to_remove.len();
801 for key in &to_remove {
802 self.entries.remove(key);
803 self.file_refs.remove(key);
804 }
805 count
806 }
807
808 pub fn evict_to_budget(&mut self, target_tokens: usize) {
810 let current = self.total_cached_tokens();
811 if current <= target_tokens {
812 return;
813 }
814 let now = Instant::now();
815 let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
816 let mut scores = eviction_scores_rrf(&all, now);
817 apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
818 scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
819
820 let mut freed = 0usize;
821 let target_free = current.saturating_sub(target_tokens);
822 for (path, _score) in &scores {
823 if freed >= target_free {
824 break;
825 }
826 if let Some(entry) = self.entries.remove(path) {
827 freed += entry.original_tokens;
828 self.file_refs.remove(path);
829 }
830 }
831 }
832
833 pub fn approximate_bytes(&self) -> usize {
835 let entries_bytes: usize = self
836 .entries
837 .values()
838 .map(|e| {
839 e.compressed_content.len()
840 + e.hash.len()
841 + e.path.len()
842 + e.compressed_outputs
843 .iter()
844 .map(|(k, v)| k.len() + v.len())
845 .sum::<usize>()
846 + 128 })
848 .sum();
849 let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
850 let blocks_bytes: usize = self
851 .shared_blocks
852 .iter()
853 .map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
854 .sum();
855 entries_bytes + refs_bytes + blocks_bytes
856 }
857
858 const MAX_SHARED_BLOCKS: usize = 100;
859
860 pub fn trim_shared_blocks(&mut self) {
862 if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
863 let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
864 self.shared_blocks.drain(..excess);
865 }
866 }
867
868 pub fn clear(&mut self) -> usize {
870 let count = self.entries.len();
871 self.entries.clear();
872 self.file_refs.clear();
873 self.shared_blocks.clear();
874 self.next_ref = 1;
875 self.stats = CacheStats::default();
876 count
877 }
878}
879
880pub fn file_mtime(path: &str) -> Option<SystemTime> {
881 std::fs::metadata(path).and_then(|m| m.modified()).ok()
882}
883
884pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
885 let current = file_mtime(path);
886 match (cached_mtime, current) {
887 (None, None) => false,
889 (Some(_), None) | (None, Some(_)) => true,
891 (Some(cached), Some(current)) => current != cached,
894 }
895}
896
897const VERIFY_HASH_CAP_BYTES: u64 = 8 * 1024 * 1024;
900
901fn cache_verify_enabled() -> bool {
902 std::env::var("LEAN_CTX_CACHE_VERIFY").map_or(true, |v| v != "0")
903}
904
905pub fn is_cache_entry_stale_verified(
920 path: &str,
921 cached_mtime: Option<SystemTime>,
922 cached_hash: &str,
923) -> bool {
924 if is_cache_entry_stale(path, cached_mtime) {
925 return true;
926 }
927 if cached_hash.is_empty() || !cache_verify_enabled() {
928 return false;
929 }
930 let Ok(meta) = std::fs::metadata(path) else {
931 return true;
933 };
934 if meta.len() > VERIFY_HASH_CAP_BYTES {
935 return false;
936 }
937 match std::fs::read(path) {
938 Ok(bytes) => compute_md5(&String::from_utf8_lossy(&bytes)) != cached_hash,
940 Err(_) => true,
941 }
942}
943
944fn compute_md5(content: &str) -> String {
945 let mut hasher = Md5::new();
946 hasher.update(content.as_bytes());
947 crate::core::agent_identity::hex_encode(&hasher.finalize())
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use std::time::Duration;
954
955 #[test]
956 fn cache_stores_and_retrieves() {
957 let mut cache = SessionCache::new();
958 let result = cache.store("/test/file.rs", "fn main() {}");
959 assert!(!result.was_hit);
960 assert_eq!(result.line_count, 1);
961 assert!(cache.get("/test/file.rs").is_some());
962 }
963
964 #[test]
965 fn cache_hit_on_same_content() {
966 let mut cache = SessionCache::new();
967 cache.store("/test/file.rs", "content");
968 let result = cache.store("/test/file.rs", "content");
969 assert!(result.was_hit, "same content should be a cache hit");
970 }
971
972 #[test]
973 fn cache_miss_on_changed_content() {
974 let mut cache = SessionCache::new();
975 cache.store("/test/file.rs", "old content");
976 let result = cache.store("/test/file.rs", "new content");
977 assert!(!result.was_hit, "changed content should not be a cache hit");
978 }
979
980 #[test]
981 fn file_refs_are_sequential() {
982 let mut cache = SessionCache::new();
983 assert_eq!(cache.get_file_ref("/a.rs"), "F1");
984 assert_eq!(cache.get_file_ref("/b.rs"), "F2");
985 assert_eq!(cache.get_file_ref("/a.rs"), "F1"); }
987
988 #[test]
989 fn cache_clear_resets_everything() {
990 let mut cache = SessionCache::new();
991 cache.store("/a.rs", "a");
992 cache.store("/b.rs", "b");
993 let count = cache.clear();
994 assert_eq!(count, 2);
995 assert!(cache.get("/a.rs").is_none());
996 assert_eq!(cache.get_file_ref("/c.rs"), "F1"); }
998
999 #[test]
1000 fn cache_invalidate_removes_entry() {
1001 let mut cache = SessionCache::new();
1002 cache.store("/test.rs", "test");
1003 assert!(cache.invalidate("/test.rs"));
1004 assert!(!cache.invalidate("/nonexistent.rs"));
1005 }
1006
1007 #[test]
1008 fn cache_stats_track_correctly() {
1009 let mut cache = SessionCache::new();
1010 cache.store("/a.rs", "hello");
1011 cache.store("/a.rs", "hello"); let stats = cache.get_stats();
1013 assert_eq!(stats.total_reads(), 2);
1014 assert_eq!(stats.cache_hits(), 1);
1015 assert!(stats.hit_rate() > 0.0);
1016 }
1017
1018 #[test]
1019 fn current_full_content_serves_cached_when_fresh() {
1020 let dir = tempfile::tempdir().unwrap();
1021 let file = dir.path().join("handover.md");
1022 std::fs::write(&file, "HANDOVER V1\n").unwrap();
1023 let path = file.to_str().unwrap();
1024
1025 let mut cache = SessionCache::new();
1026 cache.store(path, "HANDOVER V1\n");
1027
1028 let (content, tokens) = cache.current_full_content(path).unwrap();
1029 assert_eq!(content, "HANDOVER V1\n");
1030 assert!(tokens > 0);
1031 }
1032
1033 #[test]
1034 fn current_full_content_rereads_when_file_changed() {
1035 let dir = tempfile::tempdir().unwrap();
1038 let file = dir.path().join("handover.md");
1039 std::fs::write(&file, "HANDOVER V1\n").unwrap();
1040 let path = file.to_str().unwrap();
1041
1042 let mut cache = SessionCache::new();
1043 cache.store(path, "HANDOVER V1\n");
1044
1045 std::thread::sleep(std::time::Duration::from_millis(10));
1047 std::fs::write(&file, "HANDOVER V2 CHANGED\n").unwrap();
1048
1049 let (content, _) = cache.current_full_content(path).unwrap();
1050 assert_eq!(
1051 content, "HANDOVER V2 CHANGED\n",
1052 "stale cached copy must be re-read from disk, not served as-is"
1053 );
1054 }
1055
1056 #[test]
1057 fn current_full_content_none_without_entry() {
1058 let cache = SessionCache::new();
1059 assert!(cache.current_full_content("/no/such/file.rs").is_none());
1060 }
1061
1062 #[test]
1063 fn current_full_content_falls_back_to_cache_when_file_unreadable() {
1064 let dir = tempfile::tempdir().unwrap();
1069 let canon = dir.path().canonicalize().unwrap();
1070 let file = canon.join("gone.md");
1071 std::fs::write(&file, "ORIGINAL\n").unwrap();
1072 let path = file.to_str().unwrap().to_string();
1073
1074 let mut cache = SessionCache::new();
1075 cache.store(&path, "ORIGINAL\n");
1076 std::fs::remove_file(&file).unwrap();
1077
1078 let (content, _) = cache.current_full_content(&path).unwrap();
1079 assert_eq!(
1080 content, "ORIGINAL\n",
1081 "unreadable file must fall back to last-known cached content"
1082 );
1083 }
1084
1085 #[test]
1086 fn record_cache_hit_works_through_shared_ref() {
1087 let mut cache = SessionCache::new();
1088 cache.store("/x.rs", "hello world");
1089 let shared: &SessionCache = &cache;
1091 assert!(shared.record_cache_hit("/x.rs").is_some());
1092 assert!(shared.record_cache_hit("/x.rs").is_some());
1093 assert_eq!(cache.get("/x.rs").unwrap().read_count(), 3);
1095 assert_eq!(cache.get_stats().cache_hits(), 2);
1096 }
1097
1098 #[test]
1099 fn concurrent_cache_hits_are_lossless() {
1100 use std::sync::Arc;
1101 let mut cache = SessionCache::new();
1102 cache.store("/a.rs", "a");
1103 cache.store("/b.rs", "b");
1104 let cache = Arc::new(cache);
1107 let threads = 8;
1108 let iters = 1_000;
1109 let handles: Vec<_> = (0..threads)
1110 .map(|_| {
1111 let c = Arc::clone(&cache);
1112 std::thread::spawn(move || {
1113 for _ in 0..iters {
1114 c.record_cache_hit("/a.rs");
1115 c.record_cache_hit("/b.rs");
1116 }
1117 })
1118 })
1119 .collect();
1120 for h in handles {
1121 h.join().unwrap();
1122 }
1123 let total = (threads * iters) as u64;
1124 assert_eq!(cache.get_stats().cache_hits(), total * 2);
1125 assert_eq!(cache.get("/a.rs").unwrap().read_count(), 1 + total as u32);
1126 assert_eq!(cache.get("/b.rs").unwrap().read_count(), 1 + total as u32);
1127 }
1128
1129 #[test]
1130 fn hebbian_eviction_bonus_is_wired() {
1131 let mut cache = SessionCache::new();
1134 cache.store("/a.rs", "fn a() {}");
1135 cache.store("/b.rs", "fn b() {}");
1136 cache.flush_co_access(); let bonus = cache.hebbian_eviction_bonus();
1138 assert!(
1139 !bonus.is_empty(),
1140 "co-accessed reads must yield a Hebbian eviction bonus (#3 wired)"
1141 );
1142 }
1143
1144 #[test]
1145 fn md5_is_deterministic() {
1146 let h1 = compute_md5("test content");
1147 let h2 = compute_md5("test content");
1148 assert_eq!(h1, h2);
1149 assert_ne!(h1, compute_md5("different"));
1150 }
1151
1152 #[test]
1153 fn rrf_eviction_prefers_recent() {
1154 let key_a = "a.rs".to_string();
1155 let key_b = "b.rs".to_string();
1156 let recent = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1159 let old = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1160 let t_old = Instant::now();
1161 std::thread::sleep(std::time::Duration::from_millis(10));
1162 let t_recent = Instant::now();
1163 old.set_last_access(t_old);
1164 recent.set_last_access(t_recent);
1165 let now = Instant::now();
1166 let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &recent), (&key_b, &old)];
1167 let scores = eviction_scores_rrf(&entries, now);
1168 let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1169 let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1170 assert!(
1171 score_a > score_b,
1172 "recently accessed entries should score higher via RRF"
1173 );
1174 }
1175
1176 #[test]
1177 fn rrf_eviction_prefers_frequent() {
1178 let now = Instant::now();
1179 let key_a = "a.rs".to_string();
1180 let key_b = "b.rs".to_string();
1181 let frequent = {
1182 let e = CacheEntry::new("a", "h1".to_string(), 1, 10, "/a.rs".to_string(), None);
1183 e.set_read_count(20);
1184 e
1185 };
1186 let rare = CacheEntry::new("b", "h2".to_string(), 1, 10, "/b.rs".to_string(), None);
1187 let entries: Vec<(&String, &CacheEntry)> = vec![(&key_a, &frequent), (&key_b, &rare)];
1188 let scores = eviction_scores_rrf(&entries, now);
1189 let score_a = scores.iter().find(|(p, _)| p == "a.rs").unwrap().1;
1190 let score_b = scores.iter().find(|(p, _)| p == "b.rs").unwrap().1;
1191 assert!(
1192 score_a > score_b,
1193 "frequently accessed entries should score higher via RRF"
1194 );
1195 }
1196
1197 #[test]
1198 fn cache_budget_resolver_precedence() {
1199 assert_eq!(resolve_cache_max_tokens(Some("250000"), 999), 250_000);
1201 assert_eq!(resolve_cache_max_tokens(Some(" 80000 "), 0), 80_000);
1202 assert_eq!(resolve_cache_max_tokens(Some("0"), 123_456), 123_456);
1204 assert_eq!(resolve_cache_max_tokens(Some(""), 123_456), 123_456);
1205 assert_eq!(resolve_cache_max_tokens(Some("lots"), 123_456), 123_456);
1206 assert_eq!(resolve_cache_max_tokens(None, 42_000), 42_000);
1208 assert_eq!(resolve_cache_max_tokens(None, 0), DEFAULT_CACHE_MAX_TOKENS);
1210 assert_eq!(
1211 resolve_cache_max_tokens(Some("0"), 0),
1212 DEFAULT_CACHE_MAX_TOKENS
1213 );
1214 }
1215
1216 #[test]
1217 fn evict_if_needed_removes_lowest_score() {
1218 crate::test_env::set_var("LEAN_CTX_CACHE_MAX_TOKENS", "50");
1219 let mut cache = SessionCache::new();
1220 let big_content = "a]".repeat(30); cache.store("/old.rs", &big_content);
1222 let new_content = "b ".repeat(30); cache.store("/new.rs", &new_content);
1226 assert!(
1231 cache.total_cached_tokens() <= 60,
1232 "eviction should have kicked in"
1233 );
1234 crate::test_env::remove_var("LEAN_CTX_CACHE_MAX_TOKENS");
1235 }
1236
1237 #[test]
1238 fn stale_detection_flags_newer_file() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let path = dir.path().join("stale.txt");
1241 let p = path.to_string_lossy().to_string();
1242
1243 std::fs::write(&path, "one").unwrap();
1244 let mut cache = SessionCache::new();
1245 cache.store(&p, "one");
1246
1247 let entry = cache.get(&p).unwrap();
1248 assert!(!is_cache_entry_stale(&p, entry.stored_mtime));
1249
1250 std::thread::sleep(Duration::from_secs(1));
1252 std::fs::write(&path, "two").unwrap();
1253
1254 let entry = cache.get(&p).unwrap();
1255 assert!(is_cache_entry_stale(&p, entry.stored_mtime));
1256 }
1257
1258 #[test]
1260 fn stale_detection_flags_backward_mtime() {
1261 let dir = tempfile::tempdir().unwrap();
1262 let path = dir.path().join("backward.txt");
1263 let p = path.to_string_lossy().to_string();
1264
1265 std::fs::write(&path, "one").unwrap();
1266 let mut cache = SessionCache::new();
1267 cache.store(&p, "one");
1268 let entry_mtime = cache.get(&p).unwrap().stored_mtime;
1269 assert!(!is_cache_entry_stale(&p, entry_mtime));
1270
1271 std::fs::write(&path, "zero").unwrap();
1273 let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1274 f.set_modified(SystemTime::now() - Duration::from_hours(1))
1275 .unwrap();
1276 drop(f);
1277
1278 assert!(
1279 is_cache_entry_stale(&p, entry_mtime),
1280 "older mtime must read as stale"
1281 );
1282 }
1283
1284 #[test]
1287 fn verified_staleness_catches_same_mtime_content_change() {
1288 let dir = tempfile::tempdir().unwrap();
1289 let path = dir.path().join("sneaky.txt");
1290 let p = path.to_string_lossy().to_string();
1291
1292 std::fs::write(&path, "one").unwrap();
1293 let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
1294 let mut cache = SessionCache::new();
1295 cache.store(&p, "one");
1296 let (mtime, hash) = {
1297 let e = cache.get(&p).unwrap();
1298 (e.stored_mtime, e.hash.clone())
1299 };
1300
1301 assert!(!is_cache_entry_stale_verified(&p, mtime, &hash));
1303
1304 std::fs::write(&path, "two").unwrap();
1306 let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
1307 f.set_modified(original_mtime).unwrap();
1308 drop(f);
1309
1310 assert!(
1311 !is_cache_entry_stale(&p, mtime),
1312 "test premise: the mtime check alone is fooled"
1313 );
1314 assert!(
1315 is_cache_entry_stale_verified(&p, mtime, &hash),
1316 "hash verification must catch the change"
1317 );
1318 }
1319
1320 #[test]
1321 fn verified_staleness_flags_unreadable_file() {
1322 let mut cache = SessionCache::new();
1323 cache.store("/nonexistent/file.rs", "content");
1324 let (mtime, hash) = {
1325 let e = cache.get("/nonexistent/file.rs").unwrap();
1326 (e.stored_mtime, e.hash.clone())
1327 };
1328 assert!(is_cache_entry_stale_verified(
1329 "/nonexistent/file.rs",
1330 mtime,
1331 &hash
1332 ));
1333 }
1334
1335 #[test]
1336 fn compressed_outputs_cached_and_retrieved() {
1337 let mut cache = SessionCache::new();
1338 cache.store("/test.rs", "fn main() {}");
1339 cache.set_compressed("/test.rs", "map", "compressed map output".to_string());
1340 assert_eq!(
1341 cache.get_compressed("/test.rs", "map"),
1342 Some(&"compressed map output".to_string())
1343 );
1344 assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1345 }
1346
1347 #[test]
1348 fn compressed_outputs_cleared_on_content_change() {
1349 let mut cache = SessionCache::new();
1350 cache.store("/test.rs", "old content");
1351 cache.set_compressed("/test.rs", "map", "old map".to_string());
1352 assert!(cache.get_compressed("/test.rs", "map").is_some());
1353
1354 cache.store("/test.rs", "new content");
1355 assert_eq!(cache.get_compressed("/test.rs", "map"), None);
1356 }
1357
1358 #[test]
1359 fn compressed_outputs_survive_same_content_store() {
1360 let mut cache = SessionCache::new();
1361 cache.store("/test.rs", "content");
1362 cache.set_compressed("/test.rs", "map", "cached map".to_string());
1363
1364 let result = cache.store("/test.rs", "content");
1365 assert!(result.was_hit);
1366 assert_eq!(
1367 cache.get_compressed("/test.rs", "map"),
1368 Some(&"cached map".to_string())
1369 );
1370 }
1371
1372 #[test]
1373 fn compressed_outputs_cleared_on_invalidate() {
1374 let mut cache = SessionCache::new();
1375 cache.store("/test.rs", "content");
1376 cache.set_compressed("/test.rs", "signatures", "cached sigs".to_string());
1377 cache.invalidate("/test.rs");
1378 assert_eq!(cache.get_compressed("/test.rs", "signatures"), None);
1379 }
1380
1381 #[test]
1382 fn compressed_outputs_cleared_on_clear() {
1383 let mut cache = SessionCache::new();
1384 cache.store("/a.rs", "a");
1385 cache.set_compressed("/a.rs", "map", "map_a".to_string());
1386 cache.clear();
1387 assert_eq!(cache.get_compressed("/a.rs", "map"), None);
1388 }
1389}