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
22pub 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 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 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 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 pub fn flush_co_access(&mut self) {
67 self.co_access.end_burst();
68 }
69
70 #[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 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 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 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 pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
131 self.file_refs.get(&normalize_key(path)).cloned()
132 }
133
134 pub fn get(&self, path: &str) -> Option<&CacheEntry> {
136 self.entries.get(&normalize_key(path))
137 }
138
139 pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
141 self.entries.get_mut(&normalize_key(path))
142 }
143
144 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 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 let tokens = count_tokens(&fresh);
174 return Some((fresh, tokens));
175 }
176 Some((entry.content()?, entry.original_tokens))
177 }
178
179 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 pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
213 let key = normalize_key(path);
214 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 pub fn total_cached_tokens(&self) -> usize {
306 self.entries.values().map(|e| e.original_tokens).sum()
307 }
308
309 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 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 pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
346 self.entries.iter().collect()
347 }
348
349 pub fn get_stats(&self) -> &CacheStats {
351 &self.stats
352 }
353
354 pub fn file_ref_map(&self) -> &HashMap<String, String> {
356 &self.file_refs
357 }
358
359 pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
361 self.shared_blocks = blocks;
362 }
363
364 pub fn get_shared_blocks(&self) -> &[SharedBlock] {
366 &self.shared_blocks
367 }
368
369 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 pub fn invalidate(&mut self, path: &str) -> bool {
398 self.entries.remove(&normalize_key(path)).is_some()
399 }
400
401 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 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 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 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 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 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 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 pub fn count_full_delivered(&self) -> usize {
490 self.entries
491 .values()
492 .filter(|e| e.full_content_delivered)
493 .count()
494 }
495
496 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 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 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 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 })
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 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 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}