1use std::collections::HashMap;
2use std::sync::Mutex;
3
4use crate::core::cache::SessionCache;
5use crate::core::context_ledger::PressureAction;
6use crate::core::mode_predictor::{FileSignature, ModePredictor};
7
8static SOURCE_COUNTS: Mutex<Option<HashMap<&'static str, u64>>> = Mutex::new(None);
11
12fn count_source(source: &'static str) {
13 if let Ok(mut guard) = SOURCE_COUNTS.lock() {
14 *guard
15 .get_or_insert_with(HashMap::new)
16 .entry(source)
17 .or_insert(0) += 1;
18 }
19}
20
21pub fn source_counts() -> Vec<(&'static str, u64)> {
23 let Ok(guard) = SOURCE_COUNTS.lock() else {
24 return Vec::new();
25 };
26 let mut items: Vec<(&'static str, u64)> = guard
27 .as_ref()
28 .map(|m| m.iter().map(|(k, v)| (*k, *v)).collect())
29 .unwrap_or_default();
30 items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
31 items
32}
33
34fn sources_path() -> Option<std::path::PathBuf> {
35 crate::core::data_dir::lean_ctx_data_dir()
36 .ok()
37 .map(|d| d.join("auto_mode_sources.json"))
38}
39
40pub fn flush_sources() {
45 let drained: Vec<(String, u64)> = {
46 let Ok(mut guard) = SOURCE_COUNTS.lock() else {
47 return;
48 };
49 match guard.take() {
50 Some(m) if !m.is_empty() => m.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
51 _ => return,
52 }
53 };
54 let Some(path) = sources_path() else {
55 return;
56 };
57 let mut on_disk: HashMap<String, u64> = std::fs::read_to_string(&path)
58 .ok()
59 .and_then(|s| serde_json::from_str(&s).ok())
60 .unwrap_or_default();
61 for (k, v) in drained {
62 *on_disk.entry(k).or_insert(0) += v;
63 }
64 let Ok(json) = serde_json::to_string_pretty(&on_disk) else {
65 return;
66 };
67 let tmp = path.with_extension("json.tmp");
68 if std::fs::write(&tmp, json).is_ok() {
69 let _ = std::fs::rename(&tmp, &path);
70 }
71}
72
73pub fn persisted_source_counts() -> Vec<(String, u64)> {
76 let Some(path) = sources_path() else {
77 return Vec::new();
78 };
79 let map: HashMap<String, u64> = std::fs::read_to_string(&path)
80 .ok()
81 .and_then(|s| serde_json::from_str(&s).ok())
82 .unwrap_or_default();
83 let mut items: Vec<(String, u64)> = map.into_iter().collect();
84 items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
85 items
86}
87
88pub struct AutoModeContext<'a> {
89 pub path: &'a str,
90 pub token_count: usize,
91 pub task: Option<&'a str>,
92 pub cache: Option<&'a SessionCache>,
93}
94
95pub struct ResolvedMode {
96 pub mode: String,
97 pub source: &'static str,
98}
99
100pub fn resolve(ctx: &AutoModeContext) -> ResolvedMode {
103 if crate::core::edit_quality::take_pending_escalation(ctx.path) {
106 return resolved("full", "edit_fail_escalation");
107 }
108
109 let r = resolve_inner(ctx);
110
111 if r.mode != "full" && crate::core::edit_quality::is_risky_mode(ctx.path, &r.mode) {
114 return resolved("full", "edit_quality_penalty");
115 }
116 r
117}
118
119fn resolve_inner(ctx: &AutoModeContext) -> ResolvedMode {
120 if crate::tools::ctx_read::is_instruction_file(ctx.path) {
121 return resolved("full", "instruction_file");
122 }
123
124 if crate::core::binary_detect::is_binary_file(ctx.path) {
125 return resolved("full", "binary");
126 }
127
128 if let Some(cache) = ctx.cache {
129 if let Some(cached) = cache.get(ctx.path) {
130 if file_unchanged(ctx.path, cached) {
131 return resolved("full", "cache_hit");
132 }
133 return resolved("diff", "cache_changed");
134 }
135 }
136
137 if ctx.token_count <= 200 {
138 return resolved("full", "small_file");
139 }
140
141 let ext = std::path::Path::new(ctx.path)
142 .extension()
143 .and_then(|e| e.to_str())
144 .unwrap_or("");
145
146 if is_config_or_data(ext, ctx.path) {
147 return resolved("full", "config_data");
148 }
149
150 if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
151 if bt.should_force_full(ctx.path) {
152 return resolved("full", "bounce_tracker");
153 }
154 }
155
156 if crate::core::path_mode_memory::should_force_full(ctx.path) {
160 return resolved("full", "path_bounce_memory");
161 }
162
163 if crate::core::diagnostics_store::has_error(ctx.path) {
166 return resolved("full", "active_diagnostic");
167 }
168
169 if task_names_file(ctx.task, ctx.path) {
174 return resolved("full", "task_suspect_file");
175 }
176
177 if let Some(mode) = intent_recommended_mode(ctx.task) {
178 return resolved(&mode, "intent");
179 }
180
181 let sig = FileSignature::from_path(ctx.path, ctx.token_count);
182 let predictor = ModePredictor::new();
183 let mut predicted = predictor
184 .predict_best_mode(&sig)
185 .unwrap_or_else(|| "full".to_string());
186 if predicted == "auto" {
187 predicted = "full".to_string();
188 }
189
190 if predicted != "full" {
191 if let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count) {
192 predicted = bandit_override;
193 }
194 }
195
196 if predicted != "full" {
200 if let Some((access_count, avg_ratio)) = crate::core::heatmap::entry_stats(ctx.path) {
201 if access_count >= 5 && avg_ratio < 0.30 {
202 let conservative = match predicted.as_str() {
203 "signatures" | "aggressive" | "entropy" => "map".to_string(),
204 "map" if ctx.token_count <= 6000 => "full".to_string(),
205 other => other.to_string(),
206 };
207 if conservative != predicted {
208 return resolved(&conservative, "heatmap_conservative");
209 }
210 }
211 }
212 }
213
214 let policy = crate::core::adaptive_mode_policy::AdaptiveModePolicyStore::load();
215 let chosen = policy.choose_auto_mode(ctx.task, &predicted);
216
217 if ctx.token_count > 2000 {
218 if (predicted == "map" || predicted == "signatures")
219 && chosen != "map"
220 && chosen != "signatures"
221 {
222 return resolved(&predicted, "predictor_guard");
223 }
224 if chosen == "full" && predicted != "full" {
225 return resolved(&predicted, "predictor_override");
226 }
227 }
228
229 if chosen != predicted {
230 return resolved(&chosen, "adaptive_policy");
231 }
232
233 if predicted != "full" {
234 return resolved(&predicted, "predictor");
235 }
236
237 let structure_first = crate::core::config::Config::load().structure_first_effective();
244 let heuristic = heuristic_mode(ext, ctx.token_count, structure_first);
245 let source = if structure_first && heuristic == "map" && ctx.token_count <= 6000 {
246 "structure_first"
247 } else {
248 "heuristic"
249 };
250 resolved(&heuristic, source)
251}
252
253pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
256 match action {
257 PressureAction::SuggestCompression => match requested_mode {
258 "auto" | "full" => Some("map".to_string()),
259 _ => None,
260 },
261 PressureAction::ForceCompression => match requested_mode {
262 "full" => Some("map".to_string()),
263 "auto" | "map" => Some("signatures".to_string()),
264 _ => None,
265 },
266 PressureAction::EvictLeastRelevant => match requested_mode {
267 "full" => Some("map".to_string()),
268 "auto" | "map" => Some("signatures".to_string()),
269 "signatures" => Some("reference".to_string()),
270 _ => None,
271 },
272 PressureAction::NoAction => None,
273 }
274}
275
276fn task_names_file(task: Option<&str>, path: &str) -> bool {
283 let Some(task) = task else {
284 return false;
285 };
286 let basename = std::path::Path::new(path)
287 .file_name()
288 .and_then(|n| n.to_str())
289 .unwrap_or("");
290 if basename.len() < 4 || !basename.contains('.') {
291 return false;
292 }
293 task.to_ascii_lowercase()
294 .contains(&basename.to_ascii_lowercase())
295}
296
297fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
298 let task_desc = task?;
299 let classification = crate::core::intent_engine::classify(task_desc);
300 if classification.confidence < 0.4 {
301 return None;
302 }
303 let route = crate::core::intent_engine::route_intent(task_desc, &classification);
304 let mode =
305 crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
306 if mode == "auto" {
307 return None;
308 }
309 Some(mode)
310}
311
312fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
313 let project_root =
314 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
315 let ext = std::path::Path::new(file_path)
316 .extension()
317 .and_then(|e| e.to_str())
318 .unwrap_or("");
319 let bucket = match token_count {
320 0..=2000 => "sm",
321 2001..=10000 => "md",
322 10001..=50000 => "lg",
323 _ => "xl",
324 };
325 let bandit_key = format!("{ext}_{bucket}");
326 let mut store = crate::core::bandit::BanditStore::load(&project_root);
327 let bandit = store.get_or_create(&bandit_key);
328 let arm = bandit.select_arm();
329 if arm.budget_ratio < 0.25 && token_count > 2000 {
330 Some("aggressive".to_string())
331 } else {
332 None
333 }
334}
335
336fn heuristic_mode(ext: &str, token_count: usize, structure_first: bool) -> String {
337 if token_count > 8000 {
338 if is_code(ext) {
339 return "map".to_string();
340 }
341 return "aggressive".to_string();
342 }
343 if token_count > 6000 && is_code(ext) {
348 return "map".to_string();
349 }
350 if structure_first && token_count > 500 && is_code(ext) {
357 return "map".to_string();
358 }
359 "full".to_string()
360}
361
362fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
371 let Some(stored_mtime) = cached.stored_mtime else {
372 return false;
373 };
374 let Ok(meta) = std::fs::metadata(path) else {
375 return false;
376 };
377 let Ok(current_mtime) = meta.modified() else {
378 return false;
379 };
380 current_mtime == stored_mtime
381}
382
383fn is_code(ext: &str) -> bool {
384 matches!(
385 ext,
386 "rs" | "ts"
387 | "tsx"
388 | "js"
389 | "jsx"
390 | "py"
391 | "go"
392 | "java"
393 | "c"
394 | "cpp"
395 | "cc"
396 | "h"
397 | "hpp"
398 | "rb"
399 | "cs"
400 | "kt"
401 | "swift"
402 | "php"
403 | "zig"
404 | "ex"
405 | "exs"
406 | "scala"
407 | "sc"
408 | "dart"
409 | "sh"
410 | "bash"
411 | "svelte"
412 | "vue"
413 )
414}
415
416fn is_config_or_data(ext: &str, path: &str) -> bool {
417 if matches!(ext, "xml" | "ini" | "cfg" | "env") {
418 return true;
419 }
420 let name = std::path::Path::new(path)
421 .file_name()
422 .and_then(|n| n.to_str())
423 .unwrap_or("");
424 matches!(
425 name,
426 "Cargo.toml"
427 | "package.json"
428 | "tsconfig.json"
429 | "Makefile"
430 | "Dockerfile"
431 | "docker-compose.yml"
432 | ".gitignore"
433 | ".env"
434 | "pyproject.toml"
435 | "go.mod"
436 | "build.gradle"
437 | "pom.xml"
438 )
439}
440
441fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
442 count_source(source);
443 ResolvedMode {
444 mode: mode.to_string(),
445 source,
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn pressure_suggest_full_to_map() {
455 assert_eq!(
456 pressure_downgrade("full", &PressureAction::SuggestCompression),
457 Some("map".to_string())
458 );
459 }
460
461 #[test]
462 fn pressure_suggest_auto_to_map() {
463 assert_eq!(
464 pressure_downgrade("auto", &PressureAction::SuggestCompression),
465 Some("map".to_string())
466 );
467 }
468
469 #[test]
470 fn pressure_suggest_does_not_touch_signatures() {
471 assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
472 }
473
474 #[test]
475 fn pressure_force_full_to_map() {
476 assert_eq!(
477 pressure_downgrade("full", &PressureAction::ForceCompression),
478 Some("map".to_string())
479 );
480 }
481
482 #[test]
483 fn pressure_force_map_to_signatures() {
484 assert_eq!(
485 pressure_downgrade("map", &PressureAction::ForceCompression),
486 Some("signatures".to_string())
487 );
488 }
489
490 #[test]
491 fn pressure_evict_signatures_to_reference() {
492 assert_eq!(
493 pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
494 Some("reference".to_string())
495 );
496 }
497
498 #[test]
499 fn pressure_noaction_returns_none() {
500 assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
501 }
502
503 #[test]
504 fn flush_sources_merges_additively_into_disk_file() {
505 let _lock = crate::core::data_dir::test_env_lock();
506 let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
507 let _ = std::fs::create_dir_all(&dir);
508 std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
509 let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
510
511 count_source("test_flush_alpha");
514 count_source("test_flush_alpha");
515 count_source("test_flush_beta");
516 flush_sources();
517
518 count_source("test_flush_alpha");
519 flush_sources();
520
521 let persisted = persisted_source_counts();
522 let get = |k: &str| {
523 persisted
524 .iter()
525 .find(|(s, _)| s == k)
526 .map_or(0, |(_, n)| *n)
527 };
528 assert_eq!(
529 get("test_flush_alpha"),
530 3,
531 "two flushes must merge additively"
532 );
533 assert_eq!(get("test_flush_beta"), 1);
534
535 std::env::remove_var("LEAN_CTX_DATA_DIR");
536 let _ = std::fs::remove_dir_all(&dir);
537 }
538
539 #[test]
540 fn small_file_always_full() {
541 let ctx = AutoModeContext {
542 path: "test.rs",
543 token_count: 100,
544 task: None,
545 cache: None,
546 };
547 let result = resolve(&ctx);
548 assert_eq!(result.mode, "full");
549 assert_eq!(result.source, "small_file");
550 }
551
552 #[test]
553 fn config_file_returns_full() {
554 let ctx = AutoModeContext {
555 path: "config.ini",
556 token_count: 500,
557 task: None,
558 cache: None,
559 };
560 let result = resolve(&ctx);
561 assert_eq!(result.mode, "full");
562 assert_eq!(result.source, "config_data");
563 }
564
565 #[test]
566 fn intent_explore_returns_map() {
567 let ctx = AutoModeContext {
568 path: "large.rs",
569 token_count: 5000,
570 task: Some("how does the cache work?"),
571 cache: None,
572 };
573 let result = resolve(&ctx);
574 assert_eq!(result.mode, "map");
575 assert_eq!(result.source, "intent");
576 }
577
578 #[test]
579 fn task_names_file_matches_explicit_filename() {
580 assert!(task_names_file(
581 Some("fix the version sort in versioncmp.c"),
582 "src/versioncmp.c"
583 ));
584 assert!(task_names_file(
585 Some("why does graph.ts loop?"),
586 "web/src/graph.ts"
587 ));
588 }
589
590 #[test]
591 fn task_names_file_ignores_bare_stems_and_trivia() {
592 assert!(!task_names_file(
594 Some("improve the parser"),
595 "src/parser.rs"
596 ));
597 assert!(!task_names_file(None, "src/parser.rs"));
598 assert!(!task_names_file(Some("touch a.c"), "a.c"));
600 assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
601 }
602
603 #[test]
604 fn task_suspect_file_overrides_intent() {
605 let _lock = crate::core::data_dir::test_env_lock();
606 let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
607 let _ = std::fs::create_dir_all(&dir);
608 std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
609
610 let ctx = AutoModeContext {
614 path: "large.rs",
615 token_count: 5000,
616 task: Some("how does large.rs build the cache?"),
617 cache: None,
618 };
619 let result = resolve(&ctx);
620 assert_eq!(result.mode, "full");
621 assert_eq!(result.source, "task_suspect_file");
622
623 std::env::remove_var("LEAN_CTX_DATA_DIR");
624 let _ = std::fs::remove_dir_all(&dir);
625 }
626
627 #[test]
628 fn heuristic_full_for_medium_code_by_default() {
629 assert_eq!(heuristic_mode("rs", 1500, false), "full");
632 assert_eq!(heuristic_mode("ts", 1000, false), "full");
633 }
634
635 #[test]
636 fn heuristic_structure_first_maps_medium_code() {
637 assert_eq!(heuristic_mode("rs", 1500, true), "map");
639 assert_eq!(heuristic_mode("c", 800, true), "map");
640 }
641
642 #[test]
643 fn heuristic_structure_first_keeps_tiny_and_prose_full() {
644 assert_eq!(heuristic_mode("rs", 400, true), "full");
646 assert_eq!(heuristic_mode("md", 4000, true), "full");
648 assert_eq!(heuristic_mode("txt", 1000, true), "full");
649 }
650
651 #[test]
652 fn heuristic_large_code_maps_regardless() {
653 assert_eq!(heuristic_mode("rs", 9000, false), "map");
654 assert_eq!(heuristic_mode("rs", 9000, true), "map");
655 }
656
657 #[test]
663 fn structure_first_resolve_bugfix_cold_read() {
664 let _lock = crate::core::data_dir::test_env_lock();
665 let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
666 let _ = std::fs::create_dir_all(&dir);
667 std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
668 std::env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
669
670 let suspect = AutoModeContext {
671 path: "src/versioncmp.c",
672 token_count: 1500,
673 task: None,
674 cache: None,
675 };
676 let result = resolve(&suspect);
677 assert_eq!(result.mode, "map");
678 assert_eq!(result.source, "structure_first");
679
680 let tiny = AutoModeContext {
681 path: "src/util.c",
682 token_count: 120,
683 task: None,
684 cache: None,
685 };
686 assert_eq!(resolve(&tiny).mode, "full");
687
688 std::env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
689 std::env::remove_var("LEAN_CTX_DATA_DIR");
690 let _ = std::fs::remove_dir_all(&dir);
691 }
692
693 #[test]
694 fn structure_first_off_keeps_medium_code_full() {
695 let _lock = crate::core::data_dir::test_env_lock();
696 let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
697 let _ = std::fs::create_dir_all(&dir);
698 std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
699 std::env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
700
701 let ctx = AutoModeContext {
702 path: "src/versioncmp.c",
703 token_count: 1500,
704 task: None,
705 cache: None,
706 };
707 let result = resolve(&ctx);
708 assert_eq!(result.mode, "full");
709 assert_eq!(result.source, "heuristic");
710
711 std::env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
712 std::env::remove_var("LEAN_CTX_DATA_DIR");
713 let _ = std::fs::remove_dir_all(&dir);
714 }
715}