Skip to main content

lean_ctx_server/server/
predictive_preload.rs

1// SPDX-License-Identifier: Apache-2.0
2#![allow(clippy::case_sensitive_file_extension_comparisons)]
3//! Predict file reads before the agent asks for them.
4//!
5//! The predictor deliberately stays small and deterministic: task triage plus
6//! file topology provide useful first-read predictions without a remote model.
7
8use std::collections::{HashMap, HashSet};
9use std::fs::{self, OpenOptions};
10use std::io::{Read, Write};
11use std::path::{Component, Path, PathBuf};
12use std::sync::{Mutex, OnceLock};
13
14const MAX_RECENT_FILES: usize = 3;
15const MAX_WARM_BYTES: u64 = 256 * 1024;
16
17type PredictionKey = (String, Vec<String>);
18
19#[derive(Debug)]
20pub struct PredictivePreloader {
21    /// Learned and heuristic predictions, keyed by triage class and read context.
22    model: HashMap<PredictionKey, Vec<String>>,
23    pending: HashSet<String>,
24    last_key: Option<PredictionKey>,
25    predicted: u64,
26    used: u64,
27    history_path: PathBuf,
28}
29
30impl Default for PredictivePreloader {
31    fn default() -> Self {
32        Self::with_history_path(default_history_path())
33    }
34}
35
36impl PredictivePreloader {
37    #[must_use]
38    pub fn with_history_path(history_path: PathBuf) -> Self {
39        Self {
40            model: HashMap::new(),
41            pending: HashSet::new(),
42            last_key: None,
43            predicted: 0,
44            used: 0,
45            history_path,
46        }
47    }
48
49    /// Returns deterministic candidates for the next read and records them for
50    /// later accuracy accounting.
51    #[must_use]
52    pub fn predict_next(&mut self, task_class: &str, recent_files: &[&str]) -> Vec<String> {
53        let recent = normalize_recent_files(recent_files);
54        if recent.is_empty() {
55            return Vec::new();
56        }
57
58        let key = (task_class.to_ascii_lowercase(), recent.clone());
59        let mut predictions = self.model.get(&key).cloned().unwrap_or_default();
60        for path in &recent {
61            predictions.extend(file_predictions(path));
62        }
63        if is_debugging_task(task_class) {
64            predictions.extend(debugging_predictions());
65        }
66
67        predictions = unique_predictions(predictions, &recent);
68        self.model.insert(key.clone(), predictions.clone());
69        self.last_key = Some(key);
70        for path in &predictions {
71            if self.pending.insert(path.clone()) {
72                self.predicted += 1;
73            }
74        }
75        self.append_history("predict", None, &predictions);
76        predictions
77    }
78
79    /// Records an observed file read, learns it for the preceding context, and
80    /// marks a pending prediction as used when applicable.
81    pub fn on_file_read(&mut self, path: &str) {
82        let path = normalize_path(path);
83        let hit = self.pending.remove(&path);
84        if hit {
85            self.used += 1;
86        }
87        if let Some(key) = self.last_key.take() {
88            let learned = self.model.entry(key).or_default();
89            if !learned.contains(&path) {
90                learned.push(path.clone());
91            }
92        }
93        self.append_history("read", Some(hit), &[path]);
94    }
95
96    #[must_use]
97    pub fn accuracy(&self) -> f32 {
98        if self.predicted == 0 {
99            0.0
100        } else {
101            self.used as f32 / self.predicted as f32
102        }
103    }
104
105    fn append_history(&self, event: &str, hit: Option<bool>, paths: &[String]) {
106        let Some(parent) = self.history_path.parent() else {
107            return;
108        };
109        if fs::create_dir_all(parent).is_err() {
110            return;
111        }
112        let Ok(mut file) = OpenOptions::new()
113            .create(true)
114            .append(true)
115            .open(&self.history_path)
116        else {
117            return;
118        };
119        let record = serde_json::json!({
120            "event": event,
121            "hit": hit,
122            "paths": paths,
123        });
124        let _ = writeln!(file, "{record}");
125    }
126}
127
128static PRELOADER: OnceLock<Mutex<PredictivePreloader>> = OnceLock::new();
129
130/// Record a completed read and produce paths safe to warm in the background.
131#[must_use]
132pub fn record_read_and_predict(task_class: &str, path: &str) -> Vec<String> {
133    let Ok(mut preloader) = global_preloader().lock() else {
134        tracing::debug!("predictive preload lock poisoned; skipping prediction");
135        return Vec::new();
136    };
137    preloader.on_file_read(path);
138    preloader.predict_next(task_class, &[path])
139}
140
141/// Predict the next reads for callers that do not need access to the model.
142#[must_use]
143pub fn predict_next(task_class: &str, recent_files: &[&str]) -> Vec<String> {
144    let Ok(mut preloader) = global_preloader().lock() else {
145        tracing::debug!("predictive preload lock poisoned; skipping prediction");
146        return Vec::new();
147    };
148    preloader.predict_next(task_class, recent_files)
149}
150
151/// Record an externally observed file read in the shared predictor.
152pub fn on_file_read(path: &str) {
153    if let Ok(mut preloader) = global_preloader().lock() {
154        preloader.on_file_read(path);
155    } else {
156        tracing::debug!("predictive preload lock poisoned; skipping read record");
157    }
158}
159
160/// Returns the proportion of emitted predictions that were later read.
161#[must_use]
162pub fn accuracy() -> f32 {
163    global_preloader()
164        .lock()
165        .map_or(0.0, |preloader| preloader.accuracy())
166}
167
168/// Primes the OS file cache. The bounded read deliberately avoids allocating a
169/// second copy of a large source file in the MCP process.
170pub fn warm_paths(project_root: Option<&Path>, paths: &[String]) {
171    for path in paths {
172        if !is_safe_relative_path(path) {
173            continue;
174        }
175        let full_path = project_root.map_or_else(|| PathBuf::from(path), |root| root.join(path));
176        let Ok(file) = fs::File::open(full_path) else {
177            continue;
178        };
179        let mut reader = file.take(MAX_WARM_BYTES);
180        let mut contents = Vec::new();
181        let _ = reader.read_to_end(&mut contents);
182    }
183}
184
185fn global_preloader() -> &'static Mutex<PredictivePreloader> {
186    PRELOADER.get_or_init(|| Mutex::new(PredictivePreloader::default()))
187}
188
189fn default_history_path() -> PathBuf {
190    dirs::home_dir().map_or_else(
191        || PathBuf::from(".local/share/lean-ctx/predictions.jsonl"),
192        |home| home.join(".local/share/lean-ctx/predictions.jsonl"),
193    )
194}
195
196fn normalize_recent_files(recent_files: &[&str]) -> Vec<String> {
197    recent_files
198        .iter()
199        .rev()
200        .take(MAX_RECENT_FILES)
201        .rev()
202        .map(|path| normalize_path(path))
203        .filter(|path| !path.is_empty())
204        .collect()
205}
206
207fn normalize_path(path: &str) -> String {
208    path.trim().replace('\\', "/")
209}
210
211fn unique_predictions(predictions: Vec<String>, recent: &[String]) -> Vec<String> {
212    let mut seen = HashSet::new();
213    predictions
214        .into_iter()
215        .map(|path| normalize_path(&path))
216        .filter(|path| is_safe_relative_path(path) && !recent.contains(path))
217        .filter(|path| seen.insert(path.clone()))
218        .collect()
219}
220
221fn file_predictions(path: &str) -> Vec<String> {
222    let mut predictions = Vec::new();
223    if path.ends_with(".md") {
224        predictions.extend(documented_rust_paths(path));
225    }
226    if is_test_file(path) {
227        predictions.extend(implementation_candidates(path));
228    } else if path.ends_with(".rs") {
229        predictions.extend(rust_source_predictions(path));
230    }
231    if path.starts_with("src/proxy/") {
232        predictions.extend([
233            "src/proxy/mod.rs".to_owned(),
234            "src/proxy/forward/mod.rs".to_owned(),
235        ]);
236    }
237    predictions
238}
239
240fn rust_source_predictions(path: &str) -> Vec<String> {
241    let source = Path::new(path);
242    let Some(stem) = source.file_stem().and_then(|stem| stem.to_str()) else {
243        return Vec::new();
244    };
245    let mut predictions = vec![format!("tests/{stem}.rs"), "Cargo.toml".to_owned()];
246    if let Some(parent) = source.parent() {
247        let module = parent.join("mod.rs");
248        if module != source {
249            predictions.push(module.to_string_lossy().replace('\\', "/"));
250        }
251    }
252    if path.starts_with("src/") && path != "src/lib.rs" {
253        predictions.push("src/lib.rs".to_owned());
254    }
255    predictions
256}
257
258fn implementation_candidates(path: &str) -> Vec<String> {
259    let normalized = normalize_path(path);
260    let file_name = Path::new(&normalized)
261        .file_name()
262        .and_then(|name| name.to_str())
263        .unwrap_or_default();
264    let stem = file_name.trim_end_matches(".rs");
265    let implementation = stem
266        .trim_end_matches("_tests")
267        .trim_end_matches("_test")
268        .replace(".test", "");
269
270    let mut predictions = Vec::new();
271    if normalized.starts_with("tests/") {
272        predictions.push(format!("src/{implementation}.rs"));
273        predictions.push(format!("src/{implementation}/mod.rs"));
274    } else if let Some(parent) = Path::new(&normalized).parent() {
275        predictions.push(
276            parent
277                .join(format!("{implementation}.rs"))
278                .to_string_lossy()
279                .replace('\\', "/"),
280        );
281    }
282    predictions
283}
284
285fn is_test_file(path: &str) -> bool {
286    let path = path.to_ascii_lowercase();
287    path.starts_with("tests/")
288        || path.contains("/tests/")
289        || path.contains(".test.")
290        || path.ends_with("_test.rs")
291        || path.ends_with("_tests.rs")
292}
293
294fn documented_rust_paths(path: &str) -> Vec<String> {
295    let Ok(contents) = fs::read_to_string(path) else {
296        return Vec::new();
297    };
298    contents
299        .split(|character: char| character.is_whitespace() || "`'\"()[]{}<>,:;".contains(character))
300        .map(|token| token.trim_end_matches(|character| matches!(character, '.' | ')' | ']')))
301        .filter(|token| token.ends_with(".rs") && is_safe_relative_path(token))
302        .map(str::to_owned)
303        .collect()
304}
305
306fn is_debugging_task(task_class: &str) -> bool {
307    let task = task_class.to_ascii_lowercase();
308    task.contains("debug")
309        || task.contains("diagnos")
310        || task.contains("trace")
311        || task.contains("fix")
312}
313
314fn debugging_predictions() -> Vec<String> {
315    vec![
316        "logs/error.log".to_owned(),
317        ".lean-ctx/config.toml".to_owned(),
318        "Cargo.toml".to_owned(),
319    ]
320}
321
322fn is_safe_relative_path(path: &str) -> bool {
323    let path = Path::new(path);
324    path.is_relative()
325        && path
326            .components()
327            .all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::PredictivePreloader;
333    use std::path::PathBuf;
334
335    fn preloader() -> PredictivePreloader {
336        PredictivePreloader::with_history_path(PathBuf::from("/dev/null/predictions.jsonl"))
337    }
338
339    #[test]
340    fn predicts_test_file_from_implementation_file() {
341        let mut preloader = preloader();
342        let predictions = preloader.predict_next("coding_new", &["src/server/context_gate.rs"]);
343
344        assert!(predictions.contains(&"tests/context_gate.rs".to_owned()));
345        assert!(predictions.contains(&"Cargo.toml".to_owned()));
346    }
347
348    #[test]
349    fn predicts_parent_mod_from_child_module() {
350        let mut preloader = preloader();
351        let predictions = preloader.predict_next("coding_new", &["src/server/context_gate.rs"]);
352
353        assert!(predictions.contains(&"src/server/mod.rs".to_owned()));
354    }
355
356    #[test]
357    fn tracks_prediction_accuracy() {
358        let mut preloader = preloader();
359        let predictions = preloader.predict_next("coding_new", &["src/lib.rs"]);
360        let predicted = predictions.len() as f32;
361
362        preloader.on_file_read("Cargo.toml");
363
364        assert_eq!(preloader.accuracy(), 1.0 / predicted);
365    }
366
367    #[test]
368    fn debugging_task_predicts_logs_tests_and_configuration() {
369        let mut preloader = preloader();
370        let predictions = preloader.predict_next("coding_fix", &["src/proxy/pre_optimize.rs"]);
371
372        assert!(predictions.contains(&"logs/error.log".to_owned()));
373        assert!(predictions.contains(&".lean-ctx/config.toml".to_owned()));
374        assert!(predictions.contains(&"tests/pre_optimize.rs".to_owned()));
375    }
376
377    #[test]
378    fn test_file_predicts_implementation() {
379        let mut preloader = preloader();
380        let predictions = preloader.predict_next("coding_fix", &["tests/context_gate.rs"]);
381
382        assert!(predictions.contains(&"src/context_gate.rs".to_owned()));
383    }
384}