Skip to main content

lean_ctx/core/cache/
entry.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::OnceLock;
3use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
4use std::time::{Duration, Instant, SystemTime};
5
6/// Process-global monotonic base for encoding `Instant`s into an `AtomicU64`.
7/// Stored as milliseconds since this base, which is sufficient resolution for
8/// LRU/RRF eviction recency while allowing lock-free access on cache hits.
9fn 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
26/// Built-in default token budget for the in-memory read cache.
27/// 2M covers ~100 typical source files, reducing premature eviction
28/// before re-reads occur. RAM-pressure eviction via EvictionOrchestrator
29/// provides an independent safety net regardless of this budget.
30pub(crate) const DEFAULT_CACHE_MAX_TOKENS: usize = 2_000_000;
31
32/// Pure resolver for the read-cache token budget. `env` (the raw
33/// `LEAN_CTX_CACHE_MAX_TOKENS` value) wins when it parses to a positive integer,
34/// then the `configured` `[core] cache_max_tokens`, else
35/// [`DEFAULT_CACHE_MAX_TOKENS`]. A `0` (or unparseable env) in either source
36/// means "use the default". Split out so the precedence is unit-testable without
37/// touching the global env or config.
38pub(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
52/// Resolved token budget for the read cache. `LEAN_CTX_CACHE_MAX_TOKENS` wins
53/// (env-first keeps the hot eviction path cheap for power users), then
54/// `[core] cache_max_tokens` in config.toml, else [`DEFAULT_CACHE_MAX_TOKENS`].
55/// Shared with `eviction_orchestrator` so both eviction rails read one budget.
56pub(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
65/// Monotonic logical clock for compressed-output LRU recency.
66///
67/// A process-local counter avoids wall-clock ties and makes eviction deterministic.
68static 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    /// #1287: the conversation that first received this variant. A later
79    /// re-read from the SAME conversation of the unchanged file can collapse
80    /// to the ~15-token variant stub; other conversations keep receiving the
81    /// full payload (stub semantics must never leak across chats, #1042).
82    /// Set at store time — storing a variant IS delivering it. A payload hit
83    /// from a different conversation does not retarget this field (interior
84    /// mutability would be needed); that conversation simply keeps payload
85    /// delivery, which is correct, just not optimal.
86    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/// A fixed-size, access-ordered cache of render variants for one source file.
109#[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    /// #1287: which conversation first received this variant. Outer `None`
122    /// means the variant does not exist; inner `None` means it was stored
123    /// without a resolvable conversation id (stub must not be served).
124    /// The nested Option is deliberate for this private, allocation-free
125    /// helper — the public API (`SessionCache::compressed_delivered_conversation`)
126    /// exposes it as the explicit `VariantDelivery` enum.
127    #[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    /// Stores a variant and returns the deterministic LRU victim, if any.
135    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/// A cached file read: zstd-compressed content, hash, token count, and access metadata.
194///
195/// `read_count` and `last_access` use interior mutability (atomics) so cache
196/// hits can be recorded under a shared (read) lock — parallel reads of distinct
197/// files no longer serialize on a global write lock.
198#[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    /// Mode-specific compressed outputs (e.g. "map", "signatures") cached to avoid re-parsing.
210    pub compressed_outputs: CompressedOutputLru,
211    /// Render-cache keys evicted by the bounded variant cache. Retained as
212    /// lightweight metadata so reuse telemetry can distinguish a cold render
213    /// from a known variant eviction without retaining the evicted output.
214    evicted_compressed_variants: HashSet<String>,
215    /// Whether full (uncompressed) content was already delivered for this hash.
216    /// Prevents cache-stub loops when upgrading from compressed to full mode.
217    pub full_content_delivered: bool,
218    /// Conversation id that received the full content (see
219    /// `crate::core::conversation`). The `[unchanged]` stub is only valid for
220    /// a re-read from this same conversation; `None` means delivered without a
221    /// known conversation context (legacy / hooks absent).
222    pub delivered_conversation: Option<String>,
223    /// Last read mode used for this file (for auto-escalation on edit failure).
224    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    /// Creates a new entry with zstd-compressed content.
241    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    /// Current read count (lock-free).
269    pub fn read_count(&self) -> u32 {
270        self.read_count.load(Ordering::Relaxed)
271    }
272
273    /// Atomically increments the read count and returns the new value (lock-free).
274    pub fn bump_read_count(&self) -> u32 {
275        self.read_count.fetch_add(1, Ordering::Relaxed) + 1
276    }
277
278    /// Atomically increments full-delivery re-reads and returns the new value.
279    pub fn bump_reread(&self) -> u32 {
280        self.reread_since_full_delivery
281            .fetch_add(1, Ordering::Relaxed)
282            + 1
283    }
284
285    /// Resets the full-delivery re-read count.
286    pub fn reset_reread_count(&self) {
287        self.reread_since_full_delivery.store(0, Ordering::Relaxed);
288    }
289
290    /// Overwrites the read count (used by `store` and tests).
291    pub fn set_read_count(&self, n: u32) {
292        self.read_count.store(n, Ordering::Relaxed);
293    }
294
295    /// Last access time, decoded from the atomic millisecond offset.
296    pub fn last_access(&self) -> Instant {
297        decode_instant(self.last_access.load(Ordering::Relaxed))
298    }
299
300    /// Marks the entry as accessed now (lock-free).
301    pub fn touch(&self) {
302        self.last_access
303            .store(encode_instant(Instant::now()), Ordering::Relaxed);
304    }
305
306    /// Overwrites the last-access time (used by tests and eviction setup).
307    pub fn set_last_access(&self, when: Instant) {
308        self.last_access
309            .store(encode_instant(when), Ordering::Relaxed);
310    }
311
312    /// Decompresses and returns the full file content.
313    pub fn content(&self) -> Option<String> {
314        zstd_decompress(&self.compressed_content)
315    }
316
317    /// Replaces the stored content with new zstd-compressed data.
318    pub fn set_content(&mut self, content: &str) {
319        self.compressed_content = zstd_compress(content);
320    }
321
322    /// Approximate RAM usage of the compressed content in bytes.
323    pub fn compressed_size(&self) -> usize {
324        self.compressed_content.len()
325    }
326}
327
328/// Result of a cache store operation, indicating whether it was a hit or new entry.
329#[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    /// Whether full content was previously delivered for this cache entry.
336    pub full_content_delivered: bool,
337}
338
339impl CacheEntry {
340    /// Computes a legacy eviction score blending recency, frequency, and size.
341    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    /// #1287: which conversation first received this compressed variant.
357    /// Outer `None` = variant absent; inner `None` = stored without a
358    /// resolvable conversation (variant stub must not be served). Nested
359    /// Option kept allocation-free here; `SessionCache` exposes the explicit
360    /// `VariantDelivery` enum publicly.
361    #[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        // #1287: storing a variant IS delivering it — stamp the conversation
368        // so a same-conversation re-read of the unchanged file can collapse
369        // to the variant stub.
370        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
405/// Compute Reciprocal Rank Fusion eviction scores for a batch of cache entries.
406/// Each signal (recency, frequency, size) produces an independent ranking.
407/// The final score is the sum of `1/(k + rank)` across all signals.
408/// Higher score = more valuable = keep longer.
409pub 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/// Aggregated cache statistics: hits, reads, and token savings.
469///
470/// Counters are atomic so they can be updated on the read-locked cache-hit
471/// fast path without taking a write lock.
472#[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    /// Total number of read operations recorded.
483    pub fn total_reads(&self) -> u64 {
484        self.total_reads.load(Ordering::Relaxed)
485    }
486
487    /// Total number of cache hits recorded.
488    pub fn cache_hits(&self) -> u64 {
489        self.cache_hits.load(Ordering::Relaxed)
490    }
491
492    /// Sum of original (uncompressed) token counts across all reads.
493    pub fn total_original_tokens(&self) -> u64 {
494        self.total_original_tokens.load(Ordering::Relaxed)
495    }
496
497    /// Sum of tokens actually sent to the model.
498    pub fn total_sent_tokens(&self) -> u64 {
499        self.total_sent_tokens.load(Ordering::Relaxed)
500    }
501
502    /// Number of distinct files currently tracked.
503    pub fn files_tracked(&self) -> u64 {
504        self.files_tracked.load(Ordering::Relaxed)
505    }
506
507    /// Returns the cache hit rate as a percentage (0–100).
508    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    /// Returns the total number of tokens saved by cache hits.
517    pub fn tokens_saved(&self) -> u64 {
518        self.total_original_tokens()
519            .saturating_sub(self.total_sent_tokens())
520    }
521
522    /// Returns the savings as a percentage of total original tokens.
523    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/// A block shared across multiple files, identified by its canonical source.
533#[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}