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 let Some(mode) = intent_recommended_mode(ctx.task) {
170 return resolved(&mode, "intent");
171 }
172
173 let sig = FileSignature::from_path(ctx.path, ctx.token_count);
174 let predictor = ModePredictor::new();
175 let mut predicted = predictor
176 .predict_best_mode(&sig)
177 .unwrap_or_else(|| "full".to_string());
178 if predicted == "auto" {
179 predicted = "full".to_string();
180 }
181
182 if predicted != "full" {
183 if let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count) {
184 predicted = bandit_override;
185 }
186 }
187
188 if predicted != "full" {
192 if let Some((access_count, avg_ratio)) = crate::core::heatmap::entry_stats(ctx.path) {
193 if access_count >= 5 && avg_ratio < 0.30 {
194 let conservative = match predicted.as_str() {
195 "signatures" | "aggressive" | "entropy" => "map".to_string(),
196 "map" if ctx.token_count <= 6000 => "full".to_string(),
197 other => other.to_string(),
198 };
199 if conservative != predicted {
200 return resolved(&conservative, "heatmap_conservative");
201 }
202 }
203 }
204 }
205
206 let policy = crate::core::adaptive_mode_policy::AdaptiveModePolicyStore::load();
207 let chosen = policy.choose_auto_mode(ctx.task, &predicted);
208
209 if ctx.token_count > 2000 {
210 if (predicted == "map" || predicted == "signatures")
211 && chosen != "map"
212 && chosen != "signatures"
213 {
214 return resolved(&predicted, "predictor_guard");
215 }
216 if chosen == "full" && predicted != "full" {
217 return resolved(&predicted, "predictor_override");
218 }
219 }
220
221 if chosen != predicted {
222 return resolved(&chosen, "adaptive_policy");
223 }
224
225 if predicted != "full" {
226 return resolved(&predicted, "predictor");
227 }
228
229 let heuristic = heuristic_mode(ext, ctx.token_count);
230 resolved(&heuristic, "heuristic")
231}
232
233pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
236 match action {
237 PressureAction::SuggestCompression => match requested_mode {
238 "auto" | "full" => Some("map".to_string()),
239 _ => None,
240 },
241 PressureAction::ForceCompression => match requested_mode {
242 "full" => Some("map".to_string()),
243 "auto" | "map" => Some("signatures".to_string()),
244 _ => None,
245 },
246 PressureAction::EvictLeastRelevant => match requested_mode {
247 "full" => Some("map".to_string()),
248 "auto" | "map" => Some("signatures".to_string()),
249 "signatures" => Some("reference".to_string()),
250 _ => None,
251 },
252 PressureAction::NoAction => None,
253 }
254}
255
256fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
257 let task_desc = task?;
258 let classification = crate::core::intent_engine::classify(task_desc);
259 if classification.confidence < 0.4 {
260 return None;
261 }
262 let route = crate::core::intent_engine::route_intent(task_desc, &classification);
263 let mode =
264 crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
265 if mode == "auto" {
266 return None;
267 }
268 Some(mode)
269}
270
271fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
272 let project_root =
273 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
274 let ext = std::path::Path::new(file_path)
275 .extension()
276 .and_then(|e| e.to_str())
277 .unwrap_or("");
278 let bucket = match token_count {
279 0..=2000 => "sm",
280 2001..=10000 => "md",
281 10001..=50000 => "lg",
282 _ => "xl",
283 };
284 let bandit_key = format!("{ext}_{bucket}");
285 let mut store = crate::core::bandit::BanditStore::load(&project_root);
286 let bandit = store.get_or_create(&bandit_key);
287 let arm = bandit.select_arm();
288 if arm.budget_ratio < 0.25 && token_count > 2000 {
289 Some("aggressive".to_string())
290 } else {
291 None
292 }
293}
294
295fn heuristic_mode(ext: &str, token_count: usize) -> String {
296 if token_count > 8000 {
297 if is_code(ext) {
298 return "map".to_string();
299 }
300 return "aggressive".to_string();
301 }
302 if token_count > 6000 && is_code(ext) {
307 return "map".to_string();
308 }
309 "full".to_string()
310}
311
312fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
321 let Some(stored_mtime) = cached.stored_mtime else {
322 return false;
323 };
324 let Ok(meta) = std::fs::metadata(path) else {
325 return false;
326 };
327 let Ok(current_mtime) = meta.modified() else {
328 return false;
329 };
330 current_mtime == stored_mtime
331}
332
333fn is_code(ext: &str) -> bool {
334 matches!(
335 ext,
336 "rs" | "ts"
337 | "tsx"
338 | "js"
339 | "jsx"
340 | "py"
341 | "go"
342 | "java"
343 | "c"
344 | "cpp"
345 | "cc"
346 | "h"
347 | "hpp"
348 | "rb"
349 | "cs"
350 | "kt"
351 | "swift"
352 | "php"
353 | "zig"
354 | "ex"
355 | "exs"
356 | "scala"
357 | "sc"
358 | "dart"
359 | "sh"
360 | "bash"
361 | "svelte"
362 | "vue"
363 )
364}
365
366fn is_config_or_data(ext: &str, path: &str) -> bool {
367 if matches!(ext, "xml" | "ini" | "cfg" | "env") {
368 return true;
369 }
370 let name = std::path::Path::new(path)
371 .file_name()
372 .and_then(|n| n.to_str())
373 .unwrap_or("");
374 matches!(
375 name,
376 "Cargo.toml"
377 | "package.json"
378 | "tsconfig.json"
379 | "Makefile"
380 | "Dockerfile"
381 | "docker-compose.yml"
382 | ".gitignore"
383 | ".env"
384 | "pyproject.toml"
385 | "go.mod"
386 | "build.gradle"
387 | "pom.xml"
388 )
389}
390
391fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
392 count_source(source);
393 ResolvedMode {
394 mode: mode.to_string(),
395 source,
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn pressure_suggest_full_to_map() {
405 assert_eq!(
406 pressure_downgrade("full", &PressureAction::SuggestCompression),
407 Some("map".to_string())
408 );
409 }
410
411 #[test]
412 fn pressure_suggest_auto_to_map() {
413 assert_eq!(
414 pressure_downgrade("auto", &PressureAction::SuggestCompression),
415 Some("map".to_string())
416 );
417 }
418
419 #[test]
420 fn pressure_suggest_does_not_touch_signatures() {
421 assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
422 }
423
424 #[test]
425 fn pressure_force_full_to_map() {
426 assert_eq!(
427 pressure_downgrade("full", &PressureAction::ForceCompression),
428 Some("map".to_string())
429 );
430 }
431
432 #[test]
433 fn pressure_force_map_to_signatures() {
434 assert_eq!(
435 pressure_downgrade("map", &PressureAction::ForceCompression),
436 Some("signatures".to_string())
437 );
438 }
439
440 #[test]
441 fn pressure_evict_signatures_to_reference() {
442 assert_eq!(
443 pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
444 Some("reference".to_string())
445 );
446 }
447
448 #[test]
449 fn pressure_noaction_returns_none() {
450 assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
451 }
452
453 #[test]
454 fn flush_sources_merges_additively_into_disk_file() {
455 let _lock = crate::core::data_dir::test_env_lock();
456 let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
457 let _ = std::fs::create_dir_all(&dir);
458 std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
459 let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
460
461 count_source("test_flush_alpha");
464 count_source("test_flush_alpha");
465 count_source("test_flush_beta");
466 flush_sources();
467
468 count_source("test_flush_alpha");
469 flush_sources();
470
471 let persisted = persisted_source_counts();
472 let get = |k: &str| {
473 persisted
474 .iter()
475 .find(|(s, _)| s == k)
476 .map_or(0, |(_, n)| *n)
477 };
478 assert_eq!(
479 get("test_flush_alpha"),
480 3,
481 "two flushes must merge additively"
482 );
483 assert_eq!(get("test_flush_beta"), 1);
484
485 std::env::remove_var("LEAN_CTX_DATA_DIR");
486 let _ = std::fs::remove_dir_all(&dir);
487 }
488
489 #[test]
490 fn small_file_always_full() {
491 let ctx = AutoModeContext {
492 path: "test.rs",
493 token_count: 100,
494 task: None,
495 cache: None,
496 };
497 let result = resolve(&ctx);
498 assert_eq!(result.mode, "full");
499 assert_eq!(result.source, "small_file");
500 }
501
502 #[test]
503 fn config_file_returns_full() {
504 let ctx = AutoModeContext {
505 path: "config.ini",
506 token_count: 500,
507 task: None,
508 cache: None,
509 };
510 let result = resolve(&ctx);
511 assert_eq!(result.mode, "full");
512 assert_eq!(result.source, "config_data");
513 }
514
515 #[test]
516 fn intent_explore_returns_map() {
517 let ctx = AutoModeContext {
518 path: "large.rs",
519 token_count: 5000,
520 task: Some("how does the cache work?"),
521 cache: None,
522 };
523 let result = resolve(&ctx);
524 assert_eq!(result.mode, "map");
525 assert_eq!(result.source, "intent");
526 }
527}