1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3use std::time::Instant;
4
5const STATS_FILE: &str = "mode_stats.json";
6const PREDICTOR_FLUSH_SECS: u64 = 10;
7
8static PREDICTOR_BUFFER: Mutex<Option<(Arc<ModePredictor>, Instant)>> = Mutex::new(None);
9
10#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
12pub struct ModeOutcome {
13 pub mode: String,
14 pub tokens_in: usize,
15 pub tokens_out: usize,
16 pub density: f64,
17}
18
19impl ModeOutcome {
20 pub fn efficiency(&self) -> f64 {
22 if self.tokens_out == 0 {
23 return 0.0;
24 }
25 self.density / (self.tokens_out as f64 / self.tokens_in.max(1) as f64)
26 }
27}
28
29#[derive(Clone, Debug, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
31pub struct FileSignature {
32 pub ext: String,
33 pub size_bucket: u8,
34}
35
36impl FileSignature {
37 pub fn from_path(path: &str, token_count: usize) -> Self {
39 let ext = std::path::Path::new(path)
40 .extension()
41 .and_then(|e| e.to_str())
42 .unwrap_or("")
43 .to_string();
44 let size_bucket = match token_count {
45 0..=500 => 0,
46 501..=2000 => 1,
47 2001..=5000 => 2,
48 5001..=20000 => 3,
49 _ => 4,
50 };
51 Self { ext, size_bucket }
52 }
53}
54
55#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
57pub struct ModePredictor {
58 #[serde(with = "history_serde")]
66 history: HashMap<FileSignature, Vec<ModeOutcome>>,
67 project_root: Option<String>,
68}
69
70mod history_serde {
73 use super::{FileSignature, ModeOutcome};
74 use serde::{Deserialize, Deserializer, Serialize, Serializer};
75 use std::collections::HashMap;
76
77 pub(super) fn serialize<S: Serializer>(
78 history: &HashMap<FileSignature, Vec<ModeOutcome>>,
79 serializer: S,
80 ) -> Result<S::Ok, S::Error> {
81 let entries: Vec<(&FileSignature, &Vec<ModeOutcome>)> = history.iter().collect();
82 entries.serialize(serializer)
83 }
84
85 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
86 deserializer: D,
87 ) -> Result<HashMap<FileSignature, Vec<ModeOutcome>>, D::Error> {
88 let entries: Vec<(FileSignature, Vec<ModeOutcome>)> = Vec::deserialize(deserializer)?;
89 Ok(entries.into_iter().collect())
90 }
91}
92
93impl ModePredictor {
94 pub fn new() -> Self {
96 let mut guard = PREDICTOR_BUFFER
97 .lock()
98 .unwrap_or_else(std::sync::PoisonError::into_inner);
99 if let Some((ref predictor, _)) = *guard {
100 return Self {
101 history: predictor.history.clone(),
102 project_root: predictor.project_root.clone(),
103 };
104 }
105 let mut loaded = Self::load_from_disk().unwrap_or_default();
106 if loaded.project_root.is_none() {
107 loaded.project_root = std::env::current_dir()
108 .ok()
109 .map(|p| p.to_string_lossy().to_string());
110 }
111 *guard = Some((Arc::new(loaded.clone()), Instant::now()));
112 loaded
113 }
114
115 pub fn with_project_root(mut self, root: &str) -> Self {
116 self.project_root = Some(root.to_string());
117 self
118 }
119
120 pub fn set_project_root(&mut self, root: &str) {
121 self.project_root = Some(root.to_string());
122 }
123
124 pub fn record(&mut self, sig: FileSignature, outcome: ModeOutcome) {
126 let entries = self.history.entry(sig).or_default();
127 entries.push(outcome);
128 if entries.len() > 100 {
129 entries.drain(0..50);
130 }
131 }
132
133 pub fn predict_best_mode(&self, sig: &FileSignature) -> Option<String> {
136 let default_mode = Self::predict_from_defaults(sig);
137
138 let allow_override = |candidate: &str| -> bool {
139 let Some(def) = default_mode.as_deref() else {
140 return true;
141 };
142 if candidate == "full" {
143 return false;
144 }
145 if (def == "map" || def == "signatures")
147 && (candidate == "aggressive" || candidate == "entropy")
148 {
149 return false;
150 }
151 true
152 };
153
154 if let Some(local) = self.predict_from_local(sig)
155 && allow_override(&local)
156 {
157 return Some(local);
158 }
159 if let Some(bandit) = self.predict_from_bandit(sig)
160 && allow_override(&bandit)
161 {
162 return Some(bandit);
163 }
164 if let Some(cloud) = self.predict_from_cloud(sig)
165 && allow_override(&cloud)
166 {
167 return Some(cloud);
168 }
169 default_mode
170 }
171
172 fn predict_from_bandit(&self, sig: &FileSignature) -> Option<String> {
173 let key = crate::core::bandit::bandit_key("feedback", &sig.ext, None);
174 let store =
175 crate::core::bandit::BanditStore::load(self.project_root.as_deref().unwrap_or("."));
176 let bandit = store.bandits.get(&key)?;
177 if bandit.total_pulls < 5 {
178 return None;
179 }
180 let best_arm = bandit.arms.iter().max_by(|a, b| {
181 a.mean()
182 .partial_cmp(&b.mean())
183 .unwrap_or(std::cmp::Ordering::Equal)
184 })?;
185 let mode = match best_arm.name.as_str() {
196 "conservative" => "aggressive",
197 "balanced" => "signatures",
198 "aggressive" => "map",
199 _ => return None,
200 };
201 Some(mode.to_string())
202 }
203
204 fn predict_from_local(&self, sig: &FileSignature) -> Option<String> {
205 let entries = self.history.get(sig)?;
206 if entries.len() < 3 {
207 return None;
208 }
209
210 let mut mode_scores: HashMap<&str, (f64, usize)> = HashMap::new();
211 for entry in entries {
212 let (sum, count) = mode_scores.entry(&entry.mode).or_insert((0.0, 0));
213 *sum += entry.efficiency();
214 *count += 1;
215 }
216
217 mode_scores
218 .into_iter()
219 .max_by(|a, b| {
220 let avg_a = a.1.0 / a.1.1 as f64;
221 let avg_b = b.1.0 / b.1.1 as f64;
222 avg_a
223 .partial_cmp(&avg_b)
224 .unwrap_or(std::cmp::Ordering::Equal)
225 })
226 .map(|(mode, _)| mode.to_string())
227 }
228
229 #[allow(clippy::unused_self)]
232 fn predict_from_cloud(&self, sig: &FileSignature) -> Option<String> {
233 let data = crate::cloud_client::load_cloud_models()?;
234 let models = data["models"].as_array()?;
235
236 let ext_with_dot = format!(".{}", sig.ext);
237 let bucket_name = match sig.size_bucket {
238 0 => "0-500",
239 1 => "500-2k",
240 2 => "2k-10k",
241 _ => "10k+",
242 };
243
244 let mut best: Option<(&str, f64)> = None;
245
246 for model in models {
247 let m_ext = model["file_ext"].as_str().unwrap_or("");
248 let m_bucket = model["size_bucket"].as_str().unwrap_or("");
249 let confidence = model["confidence"].as_f64().unwrap_or(0.0);
250
251 if m_ext == ext_with_dot
252 && m_bucket == bucket_name
253 && confidence > 0.5
254 && let Some(mode) = model["recommended_mode"].as_str()
255 && best.is_none_or(|(_, c)| confidence > c)
256 {
257 best = Some((mode, confidence));
258 }
259 }
260
261 if let Some((mode, _)) = best {
262 return Some(mode.to_string());
263 }
264
265 for model in models {
266 let m_ext = model["file_ext"].as_str().unwrap_or("");
267 let confidence = model["confidence"].as_f64().unwrap_or(0.0);
268 if m_ext == ext_with_dot && confidence > 0.5 {
269 return model["recommended_mode"]
270 .as_str()
271 .map(std::string::ToString::to_string);
272 }
273 }
274
275 None
276 }
277
278 fn predict_from_defaults(sig: &FileSignature) -> Option<String> {
282 if sig.size_bucket == 0 {
283 return None;
284 }
285 if matches!(sig.ext.as_str(), "md" | "mdx" | "txt" | "rst") {
286 return None;
287 }
288
289 let mode = match (sig.ext.as_str(), sig.size_bucket) {
290 (
292 "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "rb"
293 | "swift" | "kt" | "cs" | "vue" | "svelte" | "gd",
294 4..,
295 ) => "signatures",
296
297 ("lock" | "json" | "yaml" | "yml" | "toml", _)
299 | (
300 "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "rb"
301 | "swift" | "kt" | "cs" | "vue" | "svelte" | "gd",
302 2 | 3,
303 )
304 | ("sql", 2..) => "map",
305
306 ("xml" | "csv", _) | ("css" | "scss" | "less" | "sass", 2..) | (_, 3..) => "aggressive",
308
309 _ => return None,
310 };
311 Some(mode.to_string())
312 }
313
314 pub fn save(&self) {
316 let mut guard = PREDICTOR_BUFFER
317 .lock()
318 .unwrap_or_else(std::sync::PoisonError::into_inner);
319 let should_flush = match *guard {
320 Some((_, ref last_flush)) => last_flush.elapsed().as_secs() >= PREDICTOR_FLUSH_SECS,
321 None => true,
322 };
323 *guard = Some((Arc::new(self.clone()), Instant::now()));
324 if should_flush {
325 self.save_to_disk();
326 }
327 }
328
329 fn save_to_disk(&self) {
330 let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir() else {
331 return;
332 };
333 let _ = std::fs::create_dir_all(&dir);
334 let path = dir.join(STATS_FILE);
335 if let Ok(json) = serde_json::to_string_pretty(self) {
336 let tmp = dir.join(".mode_stats.tmp");
337 if std::fs::write(&tmp, &json).is_ok() {
338 let _ = std::fs::rename(&tmp, &path);
339 }
340 }
341 }
342
343 pub fn flush() {
345 let guard = PREDICTOR_BUFFER
346 .lock()
347 .unwrap_or_else(std::sync::PoisonError::into_inner);
348 if let Some((ref predictor, _)) = *guard {
349 predictor.save_to_disk();
350 }
351 }
352
353 fn load_from_disk() -> Option<Self> {
354 let path = crate::core::data_dir::lean_ctx_data_dir()
355 .ok()?
356 .join(STATS_FILE);
357 let data = std::fs::read_to_string(path).ok()?;
358 serde_json::from_str(&data).ok()
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn file_signature_buckets() {
368 assert_eq!(FileSignature::from_path("main.rs", 100).size_bucket, 0);
369 assert_eq!(FileSignature::from_path("main.rs", 1000).size_bucket, 1);
370 assert_eq!(FileSignature::from_path("main.rs", 3000).size_bucket, 2);
371 assert_eq!(FileSignature::from_path("main.rs", 10000).size_bucket, 3);
372 assert_eq!(FileSignature::from_path("main.rs", 50000).size_bucket, 4);
373 }
374
375 #[test]
376 fn predict_returns_none_without_history() {
377 let predictor = ModePredictor::default();
378 let sig = FileSignature::from_path("test.zzz", 500);
379 assert!(predictor.predict_from_local(&sig).is_none());
380 }
381
382 #[test]
383 fn predict_returns_none_with_too_few_entries() {
384 let mut predictor = ModePredictor::default();
385 let sig = FileSignature::from_path("test.zzz", 500);
386 predictor.record(
387 sig.clone(),
388 ModeOutcome {
389 mode: "full".to_string(),
390 tokens_in: 100,
391 tokens_out: 100,
392 density: 0.5,
393 },
394 );
395 assert!(predictor.predict_from_local(&sig).is_none());
396 }
397
398 #[test]
399 fn predict_learns_best_mode() {
400 let mut predictor = ModePredictor::default();
401 let sig = FileSignature::from_path("big.rs", 5000);
402 for _ in 0..5 {
403 predictor.record(
404 sig.clone(),
405 ModeOutcome {
406 mode: "full".to_string(),
407 tokens_in: 5000,
408 tokens_out: 5000,
409 density: 0.3,
410 },
411 );
412 predictor.record(
413 sig.clone(),
414 ModeOutcome {
415 mode: "map".to_string(),
416 tokens_in: 5000,
417 tokens_out: 800,
418 density: 0.6,
419 },
420 );
421 }
422 let best = predictor.predict_best_mode(&sig);
423 assert_eq!(best, Some("map".to_string()));
424 }
425
426 #[test]
427 fn history_round_trips_through_json() {
428 let mut predictor = ModePredictor::default();
433 let sig = FileSignature::from_path("main.rs", 1000);
434 predictor.record(
435 sig.clone(),
436 ModeOutcome {
437 mode: "map".to_string(),
438 tokens_in: 1000,
439 tokens_out: 200,
440 density: 0.8,
441 },
442 );
443
444 let json = serde_json::to_string(&predictor).expect("predictor must serialize to JSON");
445 let restored: ModePredictor =
446 serde_json::from_str(&json).expect("predictor must deserialize from JSON");
447
448 assert_eq!(
449 restored.history.get(&sig).map(Vec::len),
450 Some(1),
451 "recorded outcome must survive the round-trip"
452 );
453 }
454
455 #[test]
456 fn predict_from_bandit_maps_conservative_to_high_compression() {
457 let _env = crate::core::data_dir::test_env_lock();
462 let data_dir = tempfile::tempdir().unwrap();
463 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
464
465 let project = tempfile::tempdir().unwrap();
466 let root = project.path().to_string_lossy().to_string();
467
468 let mut store = crate::core::bandit::BanditStore::default();
469 let bandit = store.get_or_create(&crate::core::bandit::bandit_key("feedback", "rs", None));
470 bandit.total_pulls = 10;
471 for _ in 0..5 {
472 bandit.update("conservative", true);
473 }
474 store.save(&root).unwrap();
475
476 let mut predictor = ModePredictor::new();
477 predictor.set_project_root(&root);
478 let sig = FileSignature::from_path("big.rs", 5000);
479 assert_eq!(
480 predictor.predict_from_bandit(&sig),
481 Some("aggressive".to_string()),
482 "winning conservative arm must map to a high-compression mode, not full"
483 );
484 }
485
486 #[test]
487 fn history_caps_at_100() {
488 let mut predictor = ModePredictor::default();
489 let sig = FileSignature::from_path("test.rs", 100);
490 for _ in 0..120 {
491 predictor.record(
492 sig.clone(),
493 ModeOutcome {
494 mode: "full".to_string(),
495 tokens_in: 100,
496 tokens_out: 100,
497 density: 0.5,
498 },
499 );
500 }
501 assert!(predictor.history.get(&sig).unwrap().len() <= 100);
502 }
503
504 #[test]
505 fn defaults_return_none_for_small_files() {
506 let sig = FileSignature::from_path("small.rs", 200);
507 assert!(ModePredictor::predict_from_defaults(&sig).is_none());
508 }
509
510 #[test]
511 fn defaults_recommend_map_for_medium_code() {
512 let sig = FileSignature::from_path("medium.rs", 3000);
513 assert_eq!(
514 ModePredictor::predict_from_defaults(&sig),
515 Some("map".to_string())
516 );
517 }
518
519 #[test]
520 fn defaults_recommend_map_for_json() {
521 let sig = FileSignature::from_path("config.json", 1000);
522 assert_eq!(
523 ModePredictor::predict_from_defaults(&sig),
524 Some("map".to_string())
525 );
526 }
527
528 #[test]
529 fn defaults_recommend_signatures_for_huge_code() {
530 let sig = FileSignature::from_path("huge.ts", 25000);
531 assert_eq!(
532 ModePredictor::predict_from_defaults(&sig),
533 Some("signatures".to_string())
534 );
535 }
536
537 #[test]
538 fn defaults_recommend_aggressive_for_large_unknown() {
539 let sig = FileSignature::from_path("data.xyz", 8000);
540 assert_eq!(
541 ModePredictor::predict_from_defaults(&sig),
542 Some("aggressive".to_string())
543 );
544 }
545
546 #[test]
547 fn defaults_never_compress_markdown() {
548 for tokens in [600, 3000, 8000, 25000] {
549 let sig = FileSignature::from_path("SKILL.md", tokens);
550 assert!(
551 ModePredictor::predict_from_defaults(&sig).is_none(),
552 "SKILL.md at {tokens} tokens should get full (None), not compressed"
553 );
554 }
555 let sig = FileSignature::from_path("AGENTS.md", 5000);
556 assert!(ModePredictor::predict_from_defaults(&sig).is_none());
557 let sig = FileSignature::from_path("README.md", 12000);
558 assert!(ModePredictor::predict_from_defaults(&sig).is_none());
559 }
560
561 #[test]
562 fn mode_outcome_efficiency() {
563 let o = ModeOutcome {
564 mode: "map".to_string(),
565 tokens_in: 1000,
566 tokens_out: 200,
567 density: 0.6,
568 };
569 assert!(o.efficiency() > 0.0);
570 }
571}