use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::time::Instant;
use super::entry::{
CacheEntry, CacheStats, HEBBIAN_ACTIVE_SET, HEBBIAN_PROTECT_WEIGHT, SharedBlock, StoreResult,
apply_hebbian_bonus, eviction_scores_rrf, max_cache_tokens, normalize_key,
};
use super::validation::{compute_md5, is_cache_entry_stale_verified};
use crate::core::tokens::count_tokens;
pub(crate) const DEFAULT_FULL_DEGRADATION_THRESHOLD: u32 = 2;
pub(crate) fn full_degradation_threshold() -> u32 {
std::env::var("LCTX_FULL_DEGRADATION_THRESHOLD")
.ok()
.and_then(|value| value.trim().parse().ok())
.filter(|&threshold| threshold > 0)
.unwrap_or(DEFAULT_FULL_DEGRADATION_THRESHOLD)
}
pub struct SessionCache {
entries: HashMap<String, CacheEntry>,
file_refs: HashMap<String, String>,
next_ref: usize,
stats: CacheStats,
shared_blocks: Vec<SharedBlock>,
co_access: crate::core::hebbian_cache::CoAccessMatrix,
}
impl Default for SessionCache {
fn default() -> Self {
Self::new()
}
}
impl SessionCache {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
file_refs: HashMap::new(),
next_ref: 1,
shared_blocks: Vec::new(),
stats: CacheStats::default(),
co_access: crate::core::hebbian_cache::CoAccessMatrix::new(),
}
}
pub fn record_co_access(&mut self, path: &str) {
let key = normalize_key(path);
self.co_access
.record_access(crate::core::hebbian_cache::path_hash(&key));
}
pub fn flush_co_access(&mut self) {
self.co_access.end_burst();
}
#[cfg(test)]
pub fn set_co_access_burst_window(&mut self, window: std::time::Duration) {
self.co_access.set_burst_window(window);
}
pub(crate) fn hebbian_eviction_bonus(&self) -> HashMap<String, f64> {
use crate::core::hebbian_cache::path_hash;
if self.entries.is_empty() {
return HashMap::new();
}
let mut by_recency: Vec<(&String, Instant)> = self
.entries
.iter()
.map(|(k, e)| (k, e.last_access()))
.collect();
by_recency.sort_by_key(|(_, t)| std::cmp::Reverse(*t));
let active: Vec<u64> = by_recency
.iter()
.take(HEBBIAN_ACTIVE_SET)
.map(|(k, _)| path_hash(k))
.collect();
let mut out = HashMap::new();
for k in self.entries.keys() {
let h = path_hash(k);
let peers: Vec<u64> = active.iter().copied().filter(|&a| a != h).collect();
let strength = self.co_access.association_strength(h, &peers);
if strength > 0.0 {
out.insert(k.clone(), f64::from(strength) * HEBBIAN_PROTECT_WEIGHT);
}
}
if !out.is_empty() {
crate::core::introspect::tick("hebbian_cache");
}
out
}
pub fn get_file_ref(&mut self, path: &str) -> String {
let key = normalize_key(path);
if let Some(r) = self.file_refs.get(&key) {
return r.clone();
}
let r = format!("F{}", self.next_ref);
self.next_ref += 1;
self.file_refs.insert(key, r.clone());
r
}
pub fn get_file_ref_readonly(&self, path: &str) -> Option<String> {
self.file_refs.get(&normalize_key(path)).cloned()
}
pub fn get(&self, path: &str) -> Option<&CacheEntry> {
self.entries.get(&normalize_key(path))
}
pub fn get_mut(&mut self, path: &str) -> Option<&mut CacheEntry> {
self.entries.get_mut(&normalize_key(path))
}
pub fn get_full_content(&self, path: &str) -> Option<String> {
self.entries
.get(&normalize_key(path))
.and_then(CacheEntry::content)
}
pub fn current_full_content(&self, path: &str) -> Option<(String, usize)> {
let entry = self.entries.get(&normalize_key(path))?;
if is_cache_entry_stale_verified(&entry.path, entry.stored_mtime, &entry.hash)
&& let Ok(fresh) = crate::core::io_boundary::read_file_lossy(&entry.path)
{
let tokens = count_tokens(&fresh);
return Some((fresh, tokens));
}
Some((entry.content()?, entry.original_tokens))
}
pub fn record_cache_hit(&self, path: &str) -> Option<&CacheEntry> {
let key = normalize_key(path);
let ref_label = self
.file_refs
.get(&key)
.cloned()
.unwrap_or_else(|| "F?".to_string());
let entry = self.entries.get(&key)?;
let new_count = entry.bump_read_count();
entry.touch();
self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
self.stats
.total_original_tokens
.fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
let hit_msg = format!("{ref_label} cached {new_count}t {}L", entry.line_count);
let sent_tokens = count_tokens(&hit_msg) as u64;
self.stats
.total_sent_tokens
.fetch_add(sent_tokens, Ordering::Relaxed);
crate::core::events::emit_cache_hit(
path,
(entry.original_tokens as u64).saturating_sub(sent_tokens),
);
Some(entry)
}
pub fn store(&mut self, path: &str, content: &str) -> StoreResult {
let key = normalize_key(path);
self.co_access
.record_access(crate::core::hebbian_cache::path_hash(&key));
let hash = compute_md5(content);
let line_count = content.lines().count();
let original_tokens = count_tokens(content);
let stored_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
let now = Instant::now();
self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
self.stats
.total_original_tokens
.fetch_add(original_tokens as u64, Ordering::Relaxed);
if let Some(existing) = self.entries.get_mut(&key) {
existing.set_last_access(now);
if stored_mtime.is_some() {
existing.stored_mtime = stored_mtime;
}
if existing.hash == hash {
let new_count = existing.bump_read_count();
self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
let hit_msg = format!(
"{} cached {new_count}t {}L",
self.file_refs.get(&key).unwrap_or(&"F?".to_string()),
existing.line_count,
);
let sent_tokens = count_tokens(&hit_msg) as u64;
self.stats
.total_sent_tokens
.fetch_add(sent_tokens, Ordering::Relaxed);
return StoreResult {
line_count: existing.line_count,
original_tokens: existing.original_tokens,
read_count: new_count,
was_hit: true,
full_content_delivered: existing.full_content_delivered,
};
}
existing.compressed_outputs.clear();
existing.set_content(content);
existing.hash = hash;
existing.line_count = line_count;
existing.original_tokens = original_tokens;
let new_count = existing.bump_read_count();
existing.full_content_delivered = false;
existing.delivered_conversation = None;
existing.last_mode.clear();
if stored_mtime.is_some() {
existing.stored_mtime = stored_mtime;
}
self.stats
.total_sent_tokens
.fetch_add(original_tokens as u64, Ordering::Relaxed);
return StoreResult {
line_count,
original_tokens,
read_count: new_count,
was_hit: false,
full_content_delivered: false,
};
}
self.evict_if_needed(original_tokens);
self.get_file_ref(&key);
let entry = CacheEntry::new(
content,
hash,
line_count,
original_tokens,
key.clone(),
stored_mtime,
);
self.entries.insert(key, entry);
self.stats.files_tracked.fetch_add(1, Ordering::Relaxed);
self.stats
.total_sent_tokens
.fetch_add(original_tokens as u64, Ordering::Relaxed);
StoreResult {
line_count,
original_tokens,
read_count: 1,
was_hit: false,
full_content_delivered: false,
}
}
pub fn total_cached_tokens(&self) -> usize {
self.entries.values().map(|e| e.original_tokens).sum()
}
pub fn evict_if_needed(&mut self, incoming_tokens: usize) {
let max_tokens = max_cache_tokens();
let current = self.total_cached_tokens();
if current + incoming_tokens <= max_tokens {
return;
}
let now = Instant::now();
let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
let mut scores = eviction_scores_rrf(&all, now);
apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut freed = 0usize;
let mut redelivered = 0u64;
let target = (current + incoming_tokens).saturating_sub(max_tokens);
for (path, _score) in &scores {
if freed >= target {
break;
}
if let Some(entry) = self.entries.remove(path) {
freed += entry.original_tokens;
if entry.full_content_delivered {
redelivered += 1;
}
self.file_refs.remove(path);
}
}
crate::core::cache_telemetry::record_eviction(redelivered);
}
pub fn get_all_entries(&self) -> Vec<(&String, &CacheEntry)> {
self.entries.iter().collect()
}
pub fn get_stats(&self) -> &CacheStats {
&self.stats
}
pub fn file_ref_map(&self) -> &HashMap<String, String> {
&self.file_refs
}
pub fn set_shared_blocks(&mut self, blocks: Vec<SharedBlock>) {
self.shared_blocks = blocks;
}
pub fn get_shared_blocks(&self) -> &[SharedBlock] {
&self.shared_blocks
}
pub fn apply_dedup(&self, path: &str, content: &str) -> Option<String> {
if self.shared_blocks.is_empty() {
return None;
}
let refs: Vec<&SharedBlock> = self
.shared_blocks
.iter()
.filter(|b| b.canonical_path != path && content.contains(&b.content))
.collect();
if refs.is_empty() {
return None;
}
let mut result = content.to_string();
for block in refs {
result = result.replacen(
&block.content,
&format!(
"[= {}:{}-{}]",
block.canonical_ref, block.start_line, block.end_line
),
1,
);
}
Some(result)
}
pub fn invalidate(&mut self, path: &str) -> bool {
self.entries.remove(&normalize_key(path)).is_some()
}
pub fn get_compressed(&self, path: &str, mode_key: &str) -> Option<&String> {
let key = normalize_key(path);
let entry = self.entries.get(&key)?;
let result = entry.get_compressed(mode_key)?;
entry.bump_read_count();
entry.touch();
self.stats.total_reads.fetch_add(1, Ordering::Relaxed);
self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
self.stats
.total_original_tokens
.fetch_add(entry.original_tokens as u64, Ordering::Relaxed);
let sent = count_tokens(result);
self.stats
.total_sent_tokens
.fetch_add(sent as u64, Ordering::Relaxed);
crate::core::events::emit_cache_hit(
path,
(entry.original_tokens as u64).saturating_sub(sent as u64),
);
crate::core::stats::record_reread(entry.original_tokens.saturating_sub(sent));
Some(result)
}
pub fn mark_full_delivered(&mut self, path: &str) {
let conversation = crate::core::conversation::current_conversation_id();
let key = normalize_key(path);
let file_ref = self.file_refs.get(&key).cloned();
if let Some(entry) = self.entries.get_mut(&key) {
entry.mark_full_delivered(conversation.clone());
crate::core::read_stub_index::record(crate::core::read_stub_index::StubRecord::new(
key.clone(),
entry.hash.clone(),
entry.stored_mtime,
entry.line_count,
file_ref.unwrap_or_default(),
conversation,
));
}
}
pub fn set_compressed(&mut self, path: &str, mode_key: &str, output: String) {
if let Some(entry) = self.entries.get_mut(&normalize_key(path)) {
entry.set_compressed(mode_key, output);
}
}
pub fn reset_delivery_flags(&mut self) -> usize {
let mut count = 0;
for entry in self.entries.values_mut() {
if entry.full_content_delivered {
entry.full_content_delivered = false;
count += 1;
}
}
count
}
pub fn is_full_delivered(&self, path: &str) -> bool {
self.entries
.get(&normalize_key(path))
.is_some_and(|e| e.full_content_delivered)
}
pub fn last_mode(&self, path: &str) -> Option<String> {
self.entries
.get(&normalize_key(path))
.map(|entry| entry.last_mode.clone())
.filter(|mode| !mode.is_empty())
}
pub fn count_full_delivered(&self) -> usize {
self.entries
.values()
.filter(|e| e.full_content_delivered)
.count()
}
pub fn trim_compressed_outputs(&mut self) -> usize {
let mut trimmed = 0;
for entry in self.entries.values_mut() {
if !entry.compressed_outputs.is_empty() {
entry.compressed_outputs.clear();
trimmed += 1;
}
}
trimmed
}
pub fn evict_probationary(&mut self) -> usize {
let to_remove: Vec<String> = self
.entries
.iter()
.filter(|(_, e)| e.read_count() <= 1)
.map(|(k, _)| k.clone())
.collect();
let count = to_remove.len();
let mut redelivered = 0u64;
for key in &to_remove {
if self
.entries
.remove(key)
.is_some_and(|e| e.full_content_delivered)
{
redelivered += 1;
}
self.file_refs.remove(key);
}
crate::core::cache_telemetry::record_eviction(redelivered);
count
}
pub fn evict_to_budget(&mut self, target_tokens: usize) {
let current = self.total_cached_tokens();
if current <= target_tokens {
return;
}
let now = Instant::now();
let all: Vec<(&String, &CacheEntry)> = self.entries.iter().collect();
let mut scores = eviction_scores_rrf(&all, now);
apply_hebbian_bonus(&mut scores, &self.hebbian_eviction_bonus());
scores.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut freed = 0usize;
let mut redelivered = 0u64;
let target_free = current.saturating_sub(target_tokens);
for (path, _score) in &scores {
if freed >= target_free {
break;
}
if let Some(entry) = self.entries.remove(path) {
freed += entry.original_tokens;
if entry.full_content_delivered {
redelivered += 1;
}
self.file_refs.remove(path);
}
}
crate::core::cache_telemetry::record_eviction(redelivered);
}
pub fn approximate_bytes(&self) -> usize {
let entries_bytes: usize = self
.entries
.values()
.map(|e| {
e.compressed_content.len()
+ e.hash.len()
+ e.path.len()
+ e.compressed_outputs
.iter()
.map(|(k, v)| k.len() + v.len())
.sum::<usize>()
+ 128 })
.sum();
let refs_bytes: usize = self.file_refs.iter().map(|(k, v)| k.len() + v.len()).sum();
let blocks_bytes: usize = self
.shared_blocks
.iter()
.map(|b| b.canonical_path.len() + b.canonical_ref.len() + b.content.len() + 32)
.sum();
entries_bytes + refs_bytes + blocks_bytes
}
const MAX_SHARED_BLOCKS: usize = 100;
pub fn trim_shared_blocks(&mut self) {
if self.shared_blocks.len() > Self::MAX_SHARED_BLOCKS {
let excess = self.shared_blocks.len() - Self::MAX_SHARED_BLOCKS;
self.shared_blocks.drain(..excess);
}
}
pub fn clear(&mut self) -> usize {
let count = self.entries.len();
self.entries.clear();
self.file_refs.clear();
self.shared_blocks.clear();
self.next_ref = 1;
self.stats = CacheStats::default();
count
}
}