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 struct PathModeStats {
33    pub bounce_count: u32,
34    /// Reads observed since the path entered the store (first bounce).
35    pub read_count: u32,
36    pub last_bounce_unix: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct PathModeMemory {
41    pub paths: HashMap<String, PathModeStats>,
42    #[serde(skip)]
43    dirty: bool,
44}
45
46impl PathModeMemory {
47    fn load_from_disk() -> Self {
48        let Ok(raw) = std::fs::read_to_string(store_path()) else {
49            return Self::default();
50        };
51        let mut store: Self = serde_json::from_str(&raw).unwrap_or_default();
52        store.decay(now_unix());
53        store
54    }
55
56    /// Drop entries whose last bounce is older than `DECAY_SECS`.
57    fn decay(&mut self, now: u64) {
58        let before = self.paths.len();
59        self.paths
60            .retain(|_, s| now.saturating_sub(s.last_bounce_unix) <= DECAY_SECS);
61        if self.paths.len() != before {
62            self.dirty = true;
63        }
64    }
65
66    fn evict_to_cap(&mut self) {
67        if self.paths.len() <= MAX_PATHS {
68            return;
69        }
70        let mut items: Vec<(String, u64)> = self
71            .paths
72            .iter()
73            .map(|(p, s)| (p.clone(), s.last_bounce_unix))
74            .collect();
75        items.sort_by_key(|(_, ts)| *ts);
76        let drop_n = self.paths.len() - MAX_PATHS;
77        for (path, _) in items.into_iter().take(drop_n) {
78            self.paths.remove(&path);
79        }
80        self.dirty = true;
81    }
82
83    pub fn record_bounce(&mut self, norm_path: &str, now: u64) {
84        let entry = self.paths.entry(norm_path.to_string()).or_default();
85        entry.bounce_count = entry.bounce_count.saturating_add(1);
86        // The bounce implies a read happened; count it so the majority rule
87        // (`bounce_count * 2 >= read_count`) stays meaningful from day one.
88        entry.read_count = entry.read_count.max(entry.bounce_count);
89        entry.last_bounce_unix = now;
90        self.dirty = true;
91        self.evict_to_cap();
92    }
93
94    /// Count a read only for paths already being tracked (i.e. that bounced
95    /// before). Tracking every read of every file would bloat the store for
96    /// zero signal.
97    pub fn record_read_if_tracked(&mut self, norm_path: &str) {
98        if let Some(entry) = self.paths.get_mut(norm_path) {
99            entry.read_count = entry.read_count.saturating_add(1);
100            self.dirty = true;
101        }
102    }
103
104    /// A path is force-full when it bounced at least twice and bounces make up
105    /// the majority of its observed reads — compressing it keeps backfiring.
106    pub fn should_force_full(&self, norm_path: &str) -> bool {
107        self.paths.get(norm_path).is_some_and(|s| {
108            s.bounce_count >= 2 && u64::from(s.bounce_count) * 2 >= u64::from(s.read_count)
109        })
110    }
111
112    pub fn save(&self) -> std::io::Result<()> {
113        let path = store_path();
114        if let Some(parent) = path.parent() {
115            std::fs::create_dir_all(parent)?;
116        }
117        let json = serde_json::to_string(self)?;
118        let tmp = path.with_extension("tmp");
119        std::fs::write(&tmp, json)?;
120        std::fs::rename(&tmp, &path)
121    }
122}
123
124fn store_path() -> PathBuf {
125    crate::core::data_dir::lean_ctx_data_dir()
126        .unwrap_or_else(|_| PathBuf::from("."))
127        .join(STORE_FILE)
128}
129
130fn now_unix() -> u64 {
131    std::time::SystemTime::now()
132        .duration_since(std::time::UNIX_EPOCH)
133        .map_or(0, |d| d.as_secs())
134}
135
136fn global() -> &'static Mutex<PathModeMemory> {
137    STORE.get_or_init(|| Mutex::new(PathModeMemory::load_from_disk()))
138}
139
140/// Process-global: record a confirmed bounce for `path` (already normalized
141/// by the caller — `BounceTracker` normalizes via `pathutil`).
142pub fn record_bounce(norm_path: &str) {
143    let Ok(mut store) = global().lock() else {
144        return;
145    };
146    store.record_bounce(norm_path, now_unix());
147    maybe_flush(&mut store);
148}
149
150/// Process-global: count a read for an already-tracked path.
151pub fn record_read_if_tracked(norm_path: &str) {
152    let Ok(mut store) = global().lock() else {
153        return;
154    };
155    store.record_read_if_tracked(norm_path);
156    maybe_flush(&mut store);
157}
158
159/// Process-global: should `mode=auto` resolve to `full` for this path?
160pub fn should_force_full(path: &str) -> bool {
161    let norm = crate::core::pathutil::normalize_tool_path(path);
162    global().lock().is_ok_and(|s| s.should_force_full(&norm))
163}
164
165pub fn flush() {
166    if let Ok(store) = global().lock() {
167        if store.dirty {
168            let _ = store.save();
169        }
170    }
171}
172
173/// Dashboard summary: `(tracked_paths, forced_full_paths)`. Reads straight
174/// from disk so a separate process (the dashboard) sees the same state the
175/// MCP/CLI processes persisted (#505).
176pub fn disk_summary() -> (usize, usize) {
177    let store = PathModeMemory::load_from_disk();
178    let forced = store
179        .paths
180        .keys()
181        .filter(|p| store.should_force_full(p))
182        .count();
183    (store.paths.len(), forced)
184}
185
186fn maybe_flush(store: &mut PathModeMemory) {
187    let n = RECORD_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
188    if n.is_multiple_of(FLUSH_EVERY) && store.dirty && store.save().is_ok() {
189        store.dirty = false;
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn force_full_after_two_majority_bounces() {
199        let mut m = PathModeMemory::default();
200        m.record_bounce("a.yml", 1000);
201        assert!(!m.should_force_full("a.yml"), "one bounce is not a pattern");
202        m.record_bounce("a.yml", 1001);
203        assert!(m.should_force_full("a.yml"));
204    }
205
206    #[test]
207    fn many_clean_reads_outweigh_old_bounces() {
208        let mut m = PathModeMemory::default();
209        m.record_bounce("b.rs", 1000);
210        m.record_bounce("b.rs", 1001);
211        assert!(m.should_force_full("b.rs"));
212        // 5 clean reads later the bounce majority is gone (2*2 < 7).
213        for _ in 0..5 {
214            m.record_read_if_tracked("b.rs");
215        }
216        assert!(!m.should_force_full("b.rs"));
217    }
218
219    #[test]
220    fn decay_drops_stale_entries() {
221        let mut m = PathModeMemory::default();
222        m.record_bounce("old.ts", 1000);
223        m.record_bounce("old.ts", 1001);
224        m.record_bounce("fresh.ts", 5000);
225        m.record_bounce("fresh.ts", 5001);
226        m.decay(5001 + DECAY_SECS - 10);
227        assert!(!m.paths.contains_key("old.ts"));
228        assert!(m.paths.contains_key("fresh.ts"));
229    }
230
231    #[test]
232    fn eviction_keeps_newest_bounces() {
233        let mut m = PathModeMemory::default();
234        for i in 0..(MAX_PATHS + 20) {
235            m.record_bounce(&format!("f{i}.rs"), 1000 + i as u64);
236        }
237        assert_eq!(m.paths.len(), MAX_PATHS);
238        assert!(!m.paths.contains_key("f0.rs"), "oldest evicted");
239        let newest = format!("f{}.rs", MAX_PATHS + 19);
240        assert!(m.paths.contains_key(&newest));
241    }
242
243    #[test]
244    fn untracked_reads_are_ignored() {
245        let mut m = PathModeMemory::default();
246        m.record_read_if_tracked("never_bounced.rs");
247        assert!(m.paths.is_empty());
248    }
249
250    #[test]
251    fn roundtrip_serialization() {
252        let mut m = PathModeMemory::default();
253        m.record_bounce("x.rs", 42);
254        let json = serde_json::to_string(&m).unwrap();
255        let back: PathModeMemory = serde_json::from_str(&json).unwrap();
256        assert_eq!(back.paths.get("x.rs").unwrap().bounce_count, 1);
257        assert_eq!(back.paths.get("x.rs").unwrap().last_bounce_unix, 42);
258    }
259}