Skip to main content

lean_ctx/core/
path_mode_memory.rs

1//! Persistent per-path bounce memory (#496).
2//!
3//! The in-process `BounceTracker` detects bounces (compressed read followed by
4//! a full read of the same file within a short window) but forgets everything
5//! on restart, and `should_force_full` only knows per-extension rates. This
6//! store remembers which *specific files* keep bouncing across sessions so
7//! `mode=auto` stops compressing them — compression is a net token loss for
8//! a file the agent always re-reads in full.
9//!
10//! Storage: `~/.lean-ctx/path_mode_memory.json`, atomic write (tmp+rename),
11//! loaded once per process, flushed periodically like the heatmap.
12
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::{Mutex, OnceLock};
17
18use serde::{Deserialize, Serialize};
19
20const STORE_FILE: &str = "path_mode_memory.json";
21/// Entries without a bounce for this long are dropped on load — the codebase
22/// or the agent's reading pattern has likely changed.
23const DECAY_SECS: u64 = 30 * 24 * 3600;
24/// Hard cap; oldest-bounce entries are evicted first.
25const MAX_PATHS: usize = 500;
26const FLUSH_EVERY: usize = 25;
27
28static STORE: OnceLock<Mutex<PathModeMemory>> = OnceLock::new();
29static RECORD_CALLS: AtomicUsize = AtomicUsize::new(0);
30
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub(crate) struct PathModeStats {
33    pub bounce_count: u32,
34    /// Compressed reads observed since the path entered the store (first
35    /// bounce). Whole reads — the bounce re-reads among them — are not
36    /// compression attempts and are never counted, so a path that bounces on
37    /// every compressed read reaches a ratio of 1, not ~0.5.
38    pub read_count: u32,
39    pub last_bounce_unix: u64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub(crate) struct PathModeMemory {
44    pub paths: HashMap<String, PathModeStats>,
45    #[serde(skip)]
46    dirty: bool,
47}
48
49impl PathModeMemory {
50    fn load_from_disk() -> Self {
51        let Ok(raw) = std::fs::read_to_string(store_path()) else {
52            return Self::default();
53        };
54        let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
55        store.decay(now_unix());
56        store
57    }
58
59    /// Drop entries whose last bounce is older than `DECAY_SECS`.
60    fn decay(&mut self, now: u64) {
61        let before = self.paths.len();
62        self.paths
63            .retain(|_, s| now.saturating_sub(s.last_bounce_unix) <= DECAY_SECS);
64        if self.paths.len() != before {
65            self.dirty = true;
66        }
67    }
68
69    fn evict_to_cap(&mut self) {
70        if self.paths.len() <= MAX_PATHS {
71            return;
72        }
73        let mut items: Vec<(String, u64)> = self
74            .paths
75            .iter()
76            .map(|(p, s)| (p.clone(), s.last_bounce_unix))
77            .collect();
78        items.sort_by_key(|(_, ts)| *ts);
79        let drop_n = self.paths.len() - MAX_PATHS;
80        for (path, _) in items.into_iter().take(drop_n) {
81            self.paths.remove(&path);
82        }
83        self.dirty = true;
84    }
85
86    pub(crate) fn record_bounce(&mut self, norm_path: &str, now: u64) {
87        let entry = self.paths.entry(norm_path.to_string()).or_default();
88        entry.bounce_count = entry.bounce_count.saturating_add(1);
89        // The bounce implies a compressed read happened (the first one, before
90        // the path was tracked, was never counted); count it so the majority
91        // rule (`bounce_count * 2 >= read_count`) stays meaningful from day one.
92        entry.read_count = entry.read_count.max(entry.bounce_count);
93        entry.last_bounce_unix = now;
94        self.dirty = true;
95        self.evict_to_cap();
96    }
97
98    /// Count a compressed read only for paths already being tracked (i.e. that
99    /// bounced before). Tracking every read of every file would bloat the store
100    /// for zero signal.
101    pub(crate) fn record_compressed_read_if_tracked(&mut self, norm_path: &str) {
102        if let Some(entry) = self.paths.get_mut(norm_path) {
103            entry.read_count = entry.read_count.saturating_add(1);
104            self.dirty = true;
105        }
106    }
107
108    /// A path is force-full when it bounced at least twice and at least half
109    /// of its compressed reads bounced — compressing it keeps backfiring.
110    pub(crate) fn should_force_full(&self, norm_path: &str) -> bool {
111        self.paths.get(norm_path).is_some_and(|s| {
112            s.bounce_count >= 2 && u64::from(s.bounce_count) * 2 >= u64::from(s.read_count)
113        })
114    }
115
116    pub(crate) fn save(&self) -> std::io::Result<()> {
117        let path = store_path();
118        if let Some(parent) = path.parent() {
119            std::fs::create_dir_all(parent)?;
120        }
121        let json = serde_json::to_string(self)?;
122        let tmp = path.with_extension("tmp");
123        std::fs::write(&tmp, json)?;
124        std::fs::rename(&tmp, &path)
125    }
126}
127
128fn store_path() -> PathBuf {
129    crate::core::paths::cache_dir()
130        .unwrap_or_else(|_| PathBuf::from("."))
131        .join(STORE_FILE)
132}
133
134fn now_unix() -> u64 {
135    std::time::SystemTime::now()
136        .duration_since(std::time::UNIX_EPOCH)
137        .map_or(0, |d| d.as_secs())
138}
139
140fn global() -> &'static Mutex<PathModeMemory> {
141    STORE.get_or_init(|| Mutex::new(PathModeMemory::load_from_disk()))
142}
143
144/// Process-global: record a confirmed bounce for `path` (already normalized
145/// by the caller — `BounceTracker` normalizes via `pathutil`).
146pub fn record_bounce(norm_path: &str) {
147    let Ok(mut store) = global().lock() else {
148        return;
149    };
150    store.record_bounce(norm_path, now_unix());
151    maybe_flush(&mut store);
152}
153
154/// Process-global: count a compressed read for an already-tracked path.
155pub(crate) fn record_compressed_read_if_tracked(norm_path: &str) {
156    let Ok(mut store) = global().lock() else {
157        return;
158    };
159    store.record_compressed_read_if_tracked(norm_path);
160    maybe_flush(&mut store);
161}
162
163/// Process-global: should `mode=auto` resolve to `full` for this path?
164pub fn should_force_full(path: &str) -> bool {
165    let norm = crate::core::pathutil::normalize_tool_path(path);
166    global().lock().is_ok_and(|s| s.should_force_full(&norm))
167}
168
169pub(crate) fn flush() {
170    if let Ok(store) = global().lock()
171        && store.dirty
172    {
173        let _ = store.save();
174    }
175}
176
177/// Dashboard summary: `(tracked_paths, forced_full_paths)`. Reads straight
178/// from disk so a separate process (the dashboard) sees the same state the
179/// MCP/CLI processes persisted (#505).
180pub fn disk_summary() -> (usize, usize) {
181    let store = PathModeMemory::load_from_disk();
182    let forced = store
183        .paths
184        .keys()
185        .filter(|p| store.should_force_full(p))
186        .count();
187    (store.paths.len(), forced)
188}
189
190fn maybe_flush(store: &mut PathModeMemory) {
191    let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
192    if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
193        store.dirty = false;
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn force_full_after_two_majority_bounces() {
203        let mut m = PathModeMemory::default();
204        m.record_bounce("a.yml", 1000);
205        assert!(!m.should_force_full("a.yml"), "one bounce is not a pattern");
206        m.record_bounce("a.yml", 1001);
207        assert!(m.should_force_full("a.yml"));
208    }
209
210    #[test]
211    fn many_clean_reads_outweigh_old_bounces() {
212        let mut m = PathModeMemory::default();
213        m.record_bounce("b.rs", 1000);
214        m.record_bounce("b.rs", 1001);
215        assert!(m.should_force_full("b.rs"));
216        // 5 clean compressed reads later the bounce majority is gone (2*2 < 7).
217        for _ in 0..5 {
218            m.record_compressed_read_if_tracked("b.rs");
219        }
220        assert!(!m.should_force_full("b.rs"));
221    }
222
223    #[test]
224    fn bounce_on_every_compressed_read_forces_full() {
225        // Pattern compressed → bounce, repeated: before the fix every bounce
226        // re-read also entered the denominator and the ratio hovered at ~0.5.
227        let mut m = PathModeMemory::default();
228        m.record_bounce("c.rs", 1000);
229        for ts in 1001..1005 {
230            m.record_compressed_read_if_tracked("c.rs");
231            m.record_bounce("c.rs", ts);
232        }
233        let s = m.paths.get("c.rs").unwrap();
234        assert_eq!(s.bounce_count, 5);
235        assert_eq!(s.read_count, 5, "only compressed reads are counted");
236        assert!(m.should_force_full("c.rs"));
237    }
238
239    #[test]
240    fn decay_drops_stale_entries() {
241        let mut m = PathModeMemory::default();
242        m.record_bounce("old.ts", 1000);
243        m.record_bounce("old.ts", 1001);
244        m.record_bounce("fresh.ts", 5000);
245        m.record_bounce("fresh.ts", 5001);
246        m.decay(5001 + DECAY_SECS - 10);
247        assert!(!m.paths.contains_key("old.ts"));
248        assert!(m.paths.contains_key("fresh.ts"));
249    }
250
251    #[test]
252    fn eviction_keeps_newest_bounces() {
253        let mut m = PathModeMemory::default();
254        for i in 0..(MAX_PATHS + 20) {
255            m.record_bounce(&format!("f{i}.rs"), 1000 + i as u64);
256        }
257        assert_eq!(m.paths.len(), MAX_PATHS);
258        assert!(!m.paths.contains_key("f0.rs"), "oldest evicted");
259        let newest = format!("f{}.rs", MAX_PATHS + 19);
260        assert!(m.paths.contains_key(&newest));
261    }
262
263    #[test]
264    fn untracked_reads_are_ignored() {
265        let mut m = PathModeMemory::default();
266        m.record_compressed_read_if_tracked("never_bounced.rs");
267        assert!(m.paths.is_empty());
268    }
269
270    #[test]
271    fn roundtrip_serialization() {
272        let mut m = PathModeMemory::default();
273        m.record_bounce("x.rs", 42);
274        let json = serde_json::to_string(&m).unwrap();
275        let back: PathModeMemory = serde_json::from_str(&json).unwrap();
276        assert_eq!(back.paths.get("x.rs").unwrap().bounce_count, 1);
277        assert_eq!(back.paths.get("x.rs").unwrap().last_bounce_unix, 42);
278    }
279}