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 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 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 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 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 pub fn flush_co_access(&mut self) {
57 self.co_access.end_burst();
58 }
59
60 #[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 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 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 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 pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
121 self.file_refs.get(&normalize_key(path)).cloned()
122 }
123
124 pub fn get(&self, path: &str) -> Option<&CacheEntry> {
126 self.entries.get(&normalize_key(path))
127 }
128
129 pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
131 self.entries.get_mut(&normalize_key(path))
132 }
133
134 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 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 let tokens = count_tokens(&fresh);
164 return Some((fresh, tokens));
165 }
166 Some((entry.content()?, entry.original_tokens))
167 }
168
169 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 pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
203 let key = normalize_key(path);
204 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 pub fn total_cached_tokens(&self) -> usize {
295 self.entries.values().map(|e| e.original_tokens).sum()
296 }
297
298 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 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 pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
335 self.entries.iter().collect()
336 }
337
338 pub fn get_stats(&self) -> &CacheStats {
340 &self.stats
341 }
342
343 pub fn file_ref_map(&self) -> &HashMap<String, String> {
345 &self.file_refs
346 }
347
348 pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
350 self.shared_blocks = blocks;
351 }
352
353 pub fn get_shared_blocks(&self) -> &[SharedBlock] {
355 &self.shared_blocks
356 }
357
358 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 pub fn invalidate(&mut self, path: &str) -> bool {
387 self.entries.remove(&normalize_key(path)).is_some()
388 }
389
390 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 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 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 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 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 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 pub fn count_full_delivered(&self) -> usize {
470 self.entries
471 .values()
472 .filter(|e| e.full_content_delivered)
473 .count()
474 }
475
476 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 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 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 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 })
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 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 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}