Skip to main content

lean_ctx/core/cache/
entry.rs

1use std::collections::HashMap;
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
63/// A cached file read: zstd-compressed content, hash, token count, and access metadata.
64///
65/// `read_count` and `last_access` use interior mutability (atomics) so cache
66/// hits can be recorded under a shared (read) lock — parallel reads of distinct
67/// files no longer serialize on a global write lock.
68#[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    /// Mode-specific compressed outputs (e.g. "map", "signatures") cached to avoid re-parsing.
80    pub compressed_outputs: HashMap<String, String>,
81    /// Whether full (uncompressed) content was already delivered for this hash.
82    /// Prevents cache-stub loops when upgrading from compressed to full mode.
83    pub full_content_delivered: bool,
84    /// Conversation id that received the full content (see
85    /// `crate::core::conversation`). The `[unchanged]` stub is only valid for
86    /// a re-read from this same conversation; `None` means delivered without a
87    /// known conversation context (legacy / hooks absent).
88    pub delivered_conversation: Option<String>,
89    /// Last read mode used for this file (for auto-escalation on edit failure).
90    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    /// Creates a new entry with zstd-compressed content.
107    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    /// Current read count (lock-free).
134    pub fn read_count(&self) -> u32 {
135        self.read_count.load(Ordering::Relaxed)
136    }
137
138    /// Atomically increments the read count and returns the new value (lock-free).
139    pub fn bump_read_count(&self) -> u32 {
140        self.read_count.fetch_add(1, Ordering::Relaxed) + 1
141    }
142
143    /// Atomically increments full-delivery re-reads and returns the new value.
144    pub fn bump_reread(&self) -> u32 {
145        self.reread_since_full_delivery
146            .fetch_add(1, Ordering::Relaxed)
147            + 1
148    }
149
150    /// Resets the full-delivery re-read count.
151    pub fn reset_reread_count(&self) {
152        self.reread_since_full_delivery.store(0, Ordering::Relaxed);
153    }
154
155    /// Overwrites the read count (used by `store` and tests).
156    pub fn set_read_count(&self, n: u32) {
157        self.read_count.store(n, Ordering::Relaxed);
158    }
159
160    /// Last access time, decoded from the atomic millisecond offset.
161    pub fn last_access(&self) -> Instant {
162        decode_instant(self.last_access.load(Ordering::Relaxed))
163    }
164
165    /// Marks the entry as accessed now (lock-free).
166    pub fn touch(&self) {
167        self.last_access
168            .store(encode_instant(Instant::now()), Ordering::Relaxed);
169    }
170
171    /// Overwrites the last-access time (used by tests and eviction setup).
172    pub fn set_last_access(&self, when: Instant) {
173        self.last_access
174            .store(encode_instant(when), Ordering::Relaxed);
175    }
176
177    /// Decompresses and returns the full file content.
178    pub fn content(&self) -> Option<String> {
179        zstd_decompress(&self.compressed_content)
180    }
181
182    /// Replaces the stored content with new zstd-compressed data.
183    pub fn set_content(&mut self, content: &str) {
184        self.compressed_content = zstd_compress(content);
185    }
186
187    /// Approximate RAM usage of the compressed content in bytes.
188    pub fn compressed_size(&self) -> usize {
189        self.compressed_content.len()
190    }
191}
192
193/// Result of a cache store operation, indicating whether it was a hit or new entry.
194#[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    /// Whether full content was previously delivered for this cache entry.
201    pub full_content_delivered: bool,
202}
203
204impl CacheEntry {
205    /// Computes a legacy eviction score blending recency, frequency, and size.
206    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
241/// Hebbian protection added to an entry's RRF eviction score per unit of
242/// association strength with the currently-active working set (#3). Files that
243/// are read together resist eviction together ("fire together, wire together").
244/// Deterministic: a fixed multiplier, no sampling.
245pub(super) const HEBBIAN_PROTECT_WEIGHT: f64 = 0.05;
246/// Size of the "active working set" (most-recently-accessed entries) against
247/// which Hebbian association is measured during eviction.
248pub(super) const HEBBIAN_ACTIVE_SET: usize = 8;
249
250/// Compute Reciprocal Rank Fusion eviction scores for a batch of cache entries.
251/// Each signal (recency, frequency, size) produces an independent ranking.
252/// The final score is the sum of `1/(k + rank)` across all signals.
253/// Higher score = more valuable = keep longer.
254pub 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
313/// Add the Hebbian co-access bonus (#3) to RRF eviction scores in place. A
314/// higher score means "keep longer", so co-accessed entries are protected.
315pub(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/// Aggregated cache statistics: hits, reads, and token savings.
327///
328/// Counters are atomic so they can be updated on the read-locked cache-hit
329/// fast path without taking a write lock.
330#[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    /// Total number of read operations recorded.
341    pub fn total_reads(&self) -> u64 {
342        self.total_reads.load(Ordering::Relaxed)
343    }
344
345    /// Total number of cache hits recorded.
346    pub fn cache_hits(&self) -> u64 {
347        self.cache_hits.load(Ordering::Relaxed)
348    }
349
350    /// Sum of original (uncompressed) token counts across all reads.
351    pub fn total_original_tokens(&self) -> u64 {
352        self.total_original_tokens.load(Ordering::Relaxed)
353    }
354
355    /// Sum of tokens actually sent to the model.
356    pub fn total_sent_tokens(&self) -> u64 {
357        self.total_sent_tokens.load(Ordering::Relaxed)
358    }
359
360    /// Number of distinct files currently tracked.
361    pub fn files_tracked(&self) -> u64 {
362        self.files_tracked.load(Ordering::Relaxed)
363    }
364
365    /// Returns the cache hit rate as a percentage (0–100).
366    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    /// Returns the total number of tokens saved by cache hits.
375    pub fn tokens_saved(&self) -> u64 {
376        self.total_original_tokens()
377            .saturating_sub(self.total_sent_tokens())
378    }
379
380    /// Returns the savings as a percentage of total original tokens.
381    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/// A block shared across multiple files, identified by its canonical source.
391#[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}