1use std::collections::HashMap;
2use std::sync::OnceLock;
3use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
4use std::time::{Duration, Instant, SystemTime};
5
6fn instant_base() -> Instant {
10 static BASE: OnceLock<Instant> = OnceLock::new();
11 *BASE.get_or_init(Instant::now)
12}
13
14fn encode_instant(i: Instant) -> u64 {
15 i.saturating_duration_since(instant_base()).as_millis() as u64
16}
17
18fn decode_instant(ms: u64) -> Instant {
19 instant_base() + Duration::from_millis(ms)
20}
21
22pub(super) fn normalize_key(path: &str) -> String {
23 crate::core::pathutil::normalize_tool_path(path)
24}
25
26pub(crate) const DEFAULT_CACHE_MAX_TOKENS: usize = 2_000_000;
31
32pub(super) fn 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 pub(super) compressed_content: Vec<u8>,
71 pub hash: String,
72 pub line_count: usize,
73 pub original_tokens: usize,
74 read_count: AtomicU32,
75 reread_since_full_delivery: AtomicU32,
76 pub path: String,
77 last_access: AtomicU64,
78 pub stored_mtime: Option<SystemTime>,
79 pub compressed_outputs: HashMap<String, String>,
81 pub full_content_delivered: bool,
84 pub delivered_conversation: Option<String>,
89 pub last_mode: String,
91}
92
93const ZSTD_LEVEL: i32 = 3;
94
95fn zstd_compress(data: &str) -> Vec<u8> {
96 zstd::encode_all(data.as_bytes(), ZSTD_LEVEL).unwrap_or_else(|_| data.as_bytes().to_vec())
97}
98
99fn zstd_decompress(data: &[u8]) -> Option<String> {
100 zstd::decode_all(data)
101 .ok()
102 .and_then(|v| String::from_utf8(v).ok())
103}
104
105impl CacheEntry {
106 pub fn new(
108 content: &str,
109 hash: String,
110 line_count: usize,
111 original_tokens: usize,
112 path: String,
113 stored_mtime: Option<SystemTime>,
114 ) -> Self {
115 let compressed_content = zstd_compress(content);
116 Self {
117 compressed_content,
118 hash,
119 line_count,
120 original_tokens,
121 read_count: AtomicU32::new(1),
122 reread_since_full_delivery: AtomicU32::new(0),
123 path,
124 last_access: AtomicU64::new(encode_instant(Instant::now())),
125 stored_mtime,
126 compressed_outputs: HashMap::new(),
127 full_content_delivered: false,
128 delivered_conversation: None,
129 last_mode: String::new(),
130 }
131 }
132
133 pub fn read_count(&self) -> u32 {
135 self.read_count.load(Ordering::Relaxed)
136 }
137
138 pub fn bump_read_count(&self) -> u32 {
140 self.read_count.fetch_add(1, Ordering::Relaxed) + 1
141 }
142
143 pub fn bump_reread(&self) -> u32 {
145 self.reread_since_full_delivery
146 .fetch_add(1, Ordering::Relaxed)
147 + 1
148 }
149
150 pub fn reset_reread_count(&self) {
152 self.reread_since_full_delivery.store(0, Ordering::Relaxed);
153 }
154
155 pub fn set_read_count(&self, n: u32) {
157 self.read_count.store(n, Ordering::Relaxed);
158 }
159
160 pub fn last_access(&self) -> Instant {
162 decode_instant(self.last_access.load(Ordering::Relaxed))
163 }
164
165 pub fn touch(&self) {
167 self.last_access
168 .store(encode_instant(Instant::now()), Ordering::Relaxed);
169 }
170
171 pub fn set_last_access(&self, when: Instant) {
173 self.last_access
174 .store(encode_instant(when), Ordering::Relaxed);
175 }
176
177 pub fn content(&self) -> Option<String> {
179 zstd_decompress(&self.compressed_content)
180 }
181
182 pub fn set_content(&mut self, content: &str) {
184 self.compressed_content = zstd_compress(content);
185 }
186
187 pub fn compressed_size(&self) -> usize {
189 self.compressed_content.len()
190 }
191}
192
193#[derive(Debug, Clone)]
195pub struct StoreResult {
196 pub line_count: usize,
197 pub original_tokens: usize,
198 pub read_count: u32,
199 pub was_hit: bool,
200 pub full_content_delivered: bool,
202}
203
204impl CacheEntry {
205 pub fn eviction_score_legacy(&self, now: Instant) -> f64 {
207 let elapsed = now
208 .checked_duration_since(self.last_access())
209 .unwrap_or_default()
210 .as_secs_f64();
211 let recency = 1.0 / (1.0 + elapsed.sqrt());
212 let frequency = (self.read_count() as f64 + 1.0).ln();
213 let size_value = (self.original_tokens as f64 + 1.0).ln();
214 recency * 0.4 + frequency * 0.3 + size_value * 0.3
215 }
216
217 pub fn get_compressed(&self, mode_key: &str) -> Option<&String> {
218 self.compressed_outputs.get(mode_key)
219 }
220
221 pub fn set_compressed(&mut self, mode_key: &str, output: String) {
222 const MAX_COMPRESSED_VARIANTS: usize = 3;
223 if self.compressed_outputs.len() >= MAX_COMPRESSED_VARIANTS
224 && !self.compressed_outputs.contains_key(mode_key)
225 && let Some(oldest_key) = self.compressed_outputs.keys().next().cloned()
226 {
227 self.compressed_outputs.remove(&oldest_key);
228 }
229 self.compressed_outputs.insert(mode_key.to_string(), output);
230 }
231
232 pub fn mark_full_delivered(&mut self, conversation: Option<String>) {
233 self.full_content_delivered = true;
234 self.reset_reread_count();
235 self.delivered_conversation = conversation;
236 }
237}
238
239const RRF_K: f64 = 60.0;
240
241pub(super) const HEBBIAN_PROTECT_WEIGHT: f64 = 0.05;
246pub(super) const HEBBIAN_ACTIVE_SET: usize = 8;
249
250pub fn eviction_scores_rrf(entries: &[(&String, &CacheEntry)], now: Instant) -> Vec<(String, f64)> {
255 if entries.is_empty() {
256 return Vec::new();
257 }
258
259 let n = entries.len();
260
261 let mut recency_order: Vec<usize> = (0..n).collect();
262 recency_order.sort_by(|&a, &b| {
263 let elapsed_a = now
264 .checked_duration_since(entries[a].1.last_access())
265 .unwrap_or_default()
266 .as_secs_f64();
267 let elapsed_b = now
268 .checked_duration_since(entries[b].1.last_access())
269 .unwrap_or_default()
270 .as_secs_f64();
271 elapsed_a
272 .partial_cmp(&elapsed_b)
273 .unwrap_or(std::cmp::Ordering::Equal)
274 });
275
276 let mut frequency_order: Vec<usize> = (0..n).collect();
277 frequency_order.sort_by(|&a, &b| entries[b].1.read_count().cmp(&entries[a].1.read_count()));
278
279 let mut size_order: Vec<usize> = (0..n).collect();
280 size_order.sort_by(|&a, &b| {
281 entries[b]
282 .1
283 .original_tokens
284 .cmp(&entries[a].1.original_tokens)
285 });
286
287 let mut recency_ranks = vec![0usize; n];
288 let mut frequency_ranks = vec![0usize; n];
289 let mut size_ranks = vec![0usize; n];
290
291 for (rank, &idx) in recency_order.iter().enumerate() {
292 recency_ranks[idx] = rank;
293 }
294 for (rank, &idx) in frequency_order.iter().enumerate() {
295 frequency_ranks[idx] = rank;
296 }
297 for (rank, &idx) in size_order.iter().enumerate() {
298 size_ranks[idx] = rank;
299 }
300
301 entries
302 .iter()
303 .enumerate()
304 .map(|(i, (path, _))| {
305 let score = 1.0 / (RRF_K + recency_ranks[i] as f64)
306 + 1.0 / (RRF_K + frequency_ranks[i] as f64)
307 + 1.0 / (RRF_K + size_ranks[i] as f64);
308 ((*path).clone(), score)
309 })
310 .collect()
311}
312
313pub(super) fn apply_hebbian_bonus(scores: &mut [(String, f64)], bonus: &HashMap<String, f64>) {
316 if bonus.is_empty() {
317 return;
318 }
319 for s in scores.iter_mut() {
320 if let Some(b) = bonus.get(&s.0) {
321 s.1 += *b;
322 }
323 }
324}
325
326#[derive(Debug, Default)]
331pub struct CacheStats {
332 pub(super) total_reads: AtomicU64,
333 pub(super) cache_hits: AtomicU64,
334 pub(super) total_original_tokens: AtomicU64,
335 pub(super) total_sent_tokens: AtomicU64,
336 pub(super) files_tracked: AtomicU64,
337}
338
339impl CacheStats {
340 pub fn total_reads(&self) -> u64 {
342 self.total_reads.load(Ordering::Relaxed)
343 }
344
345 pub fn cache_hits(&self) -> u64 {
347 self.cache_hits.load(Ordering::Relaxed)
348 }
349
350 pub fn total_original_tokens(&self) -> u64 {
352 self.total_original_tokens.load(Ordering::Relaxed)
353 }
354
355 pub fn total_sent_tokens(&self) -> u64 {
357 self.total_sent_tokens.load(Ordering::Relaxed)
358 }
359
360 pub fn files_tracked(&self) -> u64 {
362 self.files_tracked.load(Ordering::Relaxed)
363 }
364
365 pub fn hit_rate(&self) -> f64 {
367 let total = self.total_reads();
368 if total == 0 {
369 return 0.0;
370 }
371 (self.cache_hits() as f64 / total as f64) * 100.0
372 }
373
374 pub fn tokens_saved(&self) -> u64 {
376 self.total_original_tokens()
377 .saturating_sub(self.total_sent_tokens())
378 }
379
380 pub fn savings_percent(&self) -> f64 {
382 let original = self.total_original_tokens();
383 if original == 0 {
384 return 0.0;
385 }
386 (self.tokens_saved() as f64 / original as f64) * 100.0
387 }
388}
389
390#[derive(Clone, Debug)]
392pub struct SharedBlock {
393 pub canonical_path: String,
394 pub canonical_ref: String,
395 pub start_line: usize,
396 pub end_line: usize,
397 pub content: String,
398}