1use std::collections::{HashMap, HashSet};
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
63const MAX_COMPRESSED_VARIANTS: usize = 6;
64
65static COMPRESSED_OUTPUT_ACCESS_CLOCK: AtomicU64 = AtomicU64::new(0);
69
70fn next_compressed_output_access() -> u64 {
71 COMPRESSED_OUTPUT_ACCESS_CLOCK.fetch_add(1, Ordering::Relaxed) + 1
72}
73
74#[derive(Debug)]
75struct CompressedOutputVariant {
76 output: String,
77 last_access: AtomicU64,
78 delivered_conversation: Option<String>,
87}
88
89impl CompressedOutputVariant {
90 fn new(output: String, delivered_conversation: Option<String>) -> Self {
91 Self {
92 output,
93 last_access: AtomicU64::new(next_compressed_output_access()),
94 delivered_conversation,
95 }
96 }
97
98 fn touch(&self) {
99 self.last_access
100 .store(next_compressed_output_access(), Ordering::Relaxed);
101 }
102
103 fn last_access(&self) -> u64 {
104 self.last_access.load(Ordering::Relaxed)
105 }
106}
107
108#[derive(Debug, Default)]
110pub struct CompressedOutputLru {
111 entries: HashMap<String, CompressedOutputVariant>,
112}
113
114impl CompressedOutputLru {
115 fn get(&self, mode_key: &str) -> Option<&String> {
116 let variant = self.entries.get(mode_key)?;
117 variant.touch();
118 Some(&variant.output)
119 }
120
121 #[allow(clippy::option_option)]
128 fn delivered_conversation(&self, mode_key: &str) -> Option<Option<&str>> {
129 self.entries
130 .get(mode_key)
131 .map(|variant| variant.delivered_conversation.as_deref())
132 }
133
134 fn set(
136 &mut self,
137 mode_key: &str,
138 output: String,
139 delivered_conversation: Option<String>,
140 ) -> Option<String> {
141 if let Some(variant) = self.entries.get_mut(mode_key) {
142 variant.output = output;
143 variant.delivered_conversation = delivered_conversation;
144 variant.touch();
145 return None;
146 }
147
148 let evicted = if self.entries.len() >= MAX_COMPRESSED_VARIANTS {
149 let key = self
150 .entries
151 .iter()
152 .min_by(|(left_key, left), (right_key, right)| {
153 left.last_access()
154 .cmp(&right.last_access())
155 .then_with(|| left_key.cmp(right_key))
156 })
157 .map(|(key, _)| key.clone());
158 key.and_then(|key| self.entries.remove(&key).map(|_| key))
159 } else {
160 None
161 };
162
163 self.entries.insert(
164 mode_key.to_string(),
165 CompressedOutputVariant::new(output, delivered_conversation),
166 );
167 evicted
168 }
169
170 pub fn contains_key(&self, mode_key: &str) -> bool {
171 self.entries.contains_key(mode_key)
172 }
173
174 pub(super) fn is_empty(&self) -> bool {
175 self.entries.is_empty()
176 }
177
178 pub(super) fn clear(&mut self) {
179 self.entries.clear();
180 }
181
182 pub(super) fn keys(&self) -> impl Iterator<Item = &String> {
183 self.entries.keys()
184 }
185
186 pub(super) fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
187 self.entries
188 .iter()
189 .map(|(key, variant)| (key, &variant.output))
190 }
191}
192
193#[derive(Debug)]
199pub struct CacheEntry {
200 pub(super) compressed_content: Vec<u8>,
201 pub hash: String,
202 pub line_count: usize,
203 pub original_tokens: usize,
204 read_count: AtomicU32,
205 reread_since_full_delivery: AtomicU32,
206 pub path: String,
207 last_access: AtomicU64,
208 pub stored_mtime: Option<SystemTime>,
209 pub compressed_outputs: CompressedOutputLru,
211 evicted_compressed_variants: HashSet<String>,
215 pub full_content_delivered: bool,
218 pub delivered_conversation: Option<String>,
223 pub last_mode: String,
225}
226
227const ZSTD_LEVEL: i32 = 3;
228
229fn zstd_compress(data: &str) -> Vec<u8> {
230 zstd::encode_all(data.as_bytes(), ZSTD_LEVEL).unwrap_or_else(|_| data.as_bytes().to_vec())
231}
232
233fn zstd_decompress(data: &[u8]) -> Option<String> {
234 zstd::decode_all(data)
235 .ok()
236 .and_then(|v| String::from_utf8(v).ok())
237}
238
239impl CacheEntry {
240 pub fn new(
242 content: &str,
243 hash: String,
244 line_count: usize,
245 original_tokens: usize,
246 path: String,
247 stored_mtime: Option<SystemTime>,
248 ) -> Self {
249 let compressed_content = zstd_compress(content);
250 Self {
251 compressed_content,
252 hash,
253 line_count,
254 original_tokens,
255 read_count: AtomicU32::new(1),
256 reread_since_full_delivery: AtomicU32::new(0),
257 path,
258 last_access: AtomicU64::new(encode_instant(Instant::now())),
259 stored_mtime,
260 compressed_outputs: CompressedOutputLru::default(),
261 evicted_compressed_variants: HashSet::new(),
262 full_content_delivered: false,
263 delivered_conversation: None,
264 last_mode: String::new(),
265 }
266 }
267
268 pub fn read_count(&self) -> u32 {
270 self.read_count.load(Ordering::Relaxed)
271 }
272
273 pub fn bump_read_count(&self) -> u32 {
275 self.read_count.fetch_add(1, Ordering::Relaxed) + 1
276 }
277
278 pub fn bump_reread(&self) -> u32 {
280 self.reread_since_full_delivery
281 .fetch_add(1, Ordering::Relaxed)
282 + 1
283 }
284
285 pub fn reset_reread_count(&self) {
287 self.reread_since_full_delivery.store(0, Ordering::Relaxed);
288 }
289
290 pub fn set_read_count(&self, n: u32) {
292 self.read_count.store(n, Ordering::Relaxed);
293 }
294
295 pub fn last_access(&self) -> Instant {
297 decode_instant(self.last_access.load(Ordering::Relaxed))
298 }
299
300 pub fn touch(&self) {
302 self.last_access
303 .store(encode_instant(Instant::now()), Ordering::Relaxed);
304 }
305
306 pub fn set_last_access(&self, when: Instant) {
308 self.last_access
309 .store(encode_instant(when), Ordering::Relaxed);
310 }
311
312 pub fn content(&self) -> Option<String> {
314 zstd_decompress(&self.compressed_content)
315 }
316
317 pub fn set_content(&mut self, content: &str) {
319 self.compressed_content = zstd_compress(content);
320 }
321
322 pub fn compressed_size(&self) -> usize {
324 self.compressed_content.len()
325 }
326}
327
328#[derive(Debug, Clone)]
330pub struct StoreResult {
331 pub line_count: usize,
332 pub original_tokens: usize,
333 pub read_count: u32,
334 pub was_hit: bool,
335 pub full_content_delivered: bool,
337}
338
339impl CacheEntry {
340 pub fn eviction_score_legacy(&self, now: Instant) -> f64 {
342 let elapsed = now
343 .checked_duration_since(self.last_access())
344 .unwrap_or_default()
345 .as_secs_f64();
346 let recency = 1.0 / (1.0 + elapsed.sqrt());
347 let frequency = (self.read_count() as f64 + 1.0).ln();
348 let size_value = (self.original_tokens as f64 + 1.0).ln();
349 recency * 0.4 + frequency * 0.3 + size_value * 0.3
350 }
351
352 pub fn get_compressed(&self, mode_key: &str) -> Option<&String> {
353 self.compressed_outputs.get(mode_key)
354 }
355
356 #[allow(clippy::option_option)]
362 pub fn compressed_delivered_conversation(&self, mode_key: &str) -> Option<Option<&str>> {
363 self.compressed_outputs.delivered_conversation(mode_key)
364 }
365
366 pub fn set_compressed(&mut self, mode_key: &str, output: String) {
367 let conversation = crate::core::conversation::current_conversation_id();
371 if let Some(evicted_key) = self.compressed_outputs.set(mode_key, output, conversation) {
372 self.evicted_compressed_variants.insert(evicted_key);
373 }
374 self.evicted_compressed_variants.remove(mode_key);
375 }
376
377 pub fn was_compressed_variant_evicted(&self, mode_key: &str) -> bool {
378 self.evicted_compressed_variants.contains(mode_key)
379 }
380
381 pub fn clear_compressed_outputs(&mut self) {
382 self.compressed_outputs.clear();
383 self.evicted_compressed_variants.clear();
384 }
385
386 pub fn evict_compressed_outputs(&mut self) -> bool {
387 if self.compressed_outputs.is_empty() {
388 return false;
389 }
390 self.evicted_compressed_variants
391 .extend(self.compressed_outputs.keys().cloned());
392 self.compressed_outputs.clear();
393 true
394 }
395
396 pub fn mark_full_delivered(&mut self, conversation: Option<String>) {
397 self.full_content_delivered = true;
398 self.reset_reread_count();
399 self.delivered_conversation = conversation;
400 }
401}
402
403const RRF_K: f64 = 60.0;
404
405pub fn eviction_scores_rrf(entries: &[(&String, &CacheEntry)], now: Instant) -> Vec<(String, f64)> {
410 if entries.is_empty() {
411 return Vec::new();
412 }
413
414 let n = entries.len();
415
416 let mut recency_order: Vec<usize> = (0..n).collect();
417 recency_order.sort_by(|&a, &b| {
418 let elapsed_a = now
419 .checked_duration_since(entries[a].1.last_access())
420 .unwrap_or_default()
421 .as_secs_f64();
422 let elapsed_b = now
423 .checked_duration_since(entries[b].1.last_access())
424 .unwrap_or_default()
425 .as_secs_f64();
426 elapsed_a
427 .partial_cmp(&elapsed_b)
428 .unwrap_or(std::cmp::Ordering::Equal)
429 });
430
431 let mut frequency_order: Vec<usize> = (0..n).collect();
432 frequency_order.sort_by(|&a, &b| entries[b].1.read_count().cmp(&entries[a].1.read_count()));
433
434 let mut size_order: Vec<usize> = (0..n).collect();
435 size_order.sort_by(|&a, &b| {
436 entries[b]
437 .1
438 .original_tokens
439 .cmp(&entries[a].1.original_tokens)
440 });
441
442 let mut recency_ranks = vec![0usize; n];
443 let mut frequency_ranks = vec![0usize; n];
444 let mut size_ranks = vec![0usize; n];
445
446 for (rank, &idx) in recency_order.iter().enumerate() {
447 recency_ranks[idx] = rank;
448 }
449 for (rank, &idx) in frequency_order.iter().enumerate() {
450 frequency_ranks[idx] = rank;
451 }
452 for (rank, &idx) in size_order.iter().enumerate() {
453 size_ranks[idx] = rank;
454 }
455
456 entries
457 .iter()
458 .enumerate()
459 .map(|(i, (path, _))| {
460 let score = 1.0 / (RRF_K + recency_ranks[i] as f64)
461 + 1.0 / (RRF_K + frequency_ranks[i] as f64)
462 + 1.0 / (RRF_K + size_ranks[i] as f64);
463 ((*path).clone(), score)
464 })
465 .collect()
466}
467
468#[derive(Debug, Default)]
473pub struct CacheStats {
474 pub(super) total_reads: AtomicU64,
475 pub(super) cache_hits: AtomicU64,
476 pub(super) total_original_tokens: AtomicU64,
477 pub(super) total_sent_tokens: AtomicU64,
478 pub(super) files_tracked: AtomicU64,
479}
480
481impl CacheStats {
482 pub fn total_reads(&self) -> u64 {
484 self.total_reads.load(Ordering::Relaxed)
485 }
486
487 pub fn cache_hits(&self) -> u64 {
489 self.cache_hits.load(Ordering::Relaxed)
490 }
491
492 pub fn total_original_tokens(&self) -> u64 {
494 self.total_original_tokens.load(Ordering::Relaxed)
495 }
496
497 pub fn total_sent_tokens(&self) -> u64 {
499 self.total_sent_tokens.load(Ordering::Relaxed)
500 }
501
502 pub fn files_tracked(&self) -> u64 {
504 self.files_tracked.load(Ordering::Relaxed)
505 }
506
507 pub fn hit_rate(&self) -> f64 {
509 let total = self.total_reads();
510 if total == 0 {
511 return 0.0;
512 }
513 (self.cache_hits() as f64 / total as f64) * 100.0
514 }
515
516 pub fn tokens_saved(&self) -> u64 {
518 self.total_original_tokens()
519 .saturating_sub(self.total_sent_tokens())
520 }
521
522 pub fn savings_percent(&self) -> f64 {
524 let original = self.total_original_tokens();
525 if original == 0 {
526 return 0.0;
527 }
528 (self.tokens_saved() as f64 / original as f64) * 100.0
529 }
530}
531
532#[derive(Clone, Debug)]
534pub struct SharedBlock {
535 pub canonical_path: String,
536 pub canonical_ref: String,
537 pub start_line: usize,
538 pub end_line: usize,
539 pub content: String,
540}