lean_ctx_server/server/
predictive_preload.rs1#![allow(clippy::case_sensitive_file_extension_comparisons)]
3use 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 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 #[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 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#[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#[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
151pub 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#[must_use]
162pub fn accuracy() -> f32 {
163 global_preloader()
164 .lock()
165 .map_or(0.0, |preloader| preloader.accuracy())
166}
167
168pub 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}