Skip to main content

lean_ctx/core/
auto_mode_resolver.rs

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
8/// Per-process counters of which signal decided each auto-mode resolution.
9/// Surfaced by `ctx_metrics` so the learning loops are observable (#496).
10static 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
21/// Snapshot of auto-mode decision sources, sorted by count descending.
22pub 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
40/// Persist the in-process counters by *adding* them into the cumulative
41/// on-disk file, then reset the process counters. The counters live in the
42/// MCP/CLI process — the dashboard is a separate process and can only see
43/// them through this file (#505).
44pub 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
73/// Cumulative auto-mode decision sources from disk (all processes, all time),
74/// sorted by count descending. Used by the dashboard's Live Signals panel.
75pub 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
100/// Single entry point for auto-mode resolution.
101/// Merges Pipeline A (select_mode_with_task) and Pipeline B (resolve_auto_mode).
102pub fn resolve(ctx: &AutoModeContext) -> ResolvedMode {
103    // Quality loop (#494), signal 1: an edit on this file just failed after a
104    // compressed read — the agent needs the real body now, one-shot.
105    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    // Quality loop (#494), signal 2: this mode keeps producing edit failures
112    // for this file type — compression here is a proven net loss, use full.
113    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    // Per-path long-term memory (#496): a file that historically bounced in
157    // the majority of its reads will bounce again — compression is a proven
158    // net loss for it, across process restarts.
159    if crate::core::path_mode_memory::should_force_full(ctx.path) {
160        return resolved("full", "path_bounce_memory");
161    }
162
163    // Active compiler error (#499): the agent reads this file to fix the
164    // build — compressed modes would hide the error region.
165    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    // Heatmap signal (#496): a frequently-read file where compression barely
189    // saves anything will likely trigger a follow-up read — step one mode more
190    // conservative. avg_compression_ratio is the historical fraction saved.
191    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
233/// Unified pressure downgrade table.
234/// Used by both context_gate and intent_router pressure paths.
235pub 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    // Raised from 3000 → 6000: at 3-6k tokens, returning only signatures forces
303    // the agent into a follow-up full/lines read for the body it actually
304    // needs. Keeping `full` here trades a few hundred tokens per call for
305    // fewer round-trips — the right call per the total-task-token principle.
306    if token_count > 6000 && is_code(ext) {
307        return "map".to_string();
308    }
309    "full".to_string()
310}
311
312/// Fast O(1) staleness check: if the file's mtime still matches what was
313/// stored when the cache entry was created, the content is unchanged — no need
314/// to read the file or compute any hash. Falls back to "changed" when metadata
315/// is unavailable (e.g. file deleted) or when the cache entry predates mtime
316/// tracking (legacy entries with `stored_mtime = None`).
317///
318/// mtime comparison is sufficient for correctness on all major filesystems:
319/// every `write(2)` / `truncate(2)` updates mtime (POSIX guarantee).
320fn 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        // Unique test-only keys: parallel resolve() tests count real sources
462        // into the same process-global map, so shared keys would be flaky.
463        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}