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};
7use crate::core::ocla::registry::OclaRegistry;
8use crate::core::ocla::types::{ConfigTuningRequest, OclaRequestContext};
9
10/// Per-process counters of which signal decided each auto-mode resolution.
11/// Surfaced by `ctx_metrics` so the learning loops are observable (#496).
12static SOURCE_COUNTS: Mutex<Option<HashMap<&'static str, u64>>> = Mutex::new(None);
13
14pub fn count_source(source: &'static str) {
15    if let Ok(mut guard) = SOURCE_COUNTS.lock() {
16        *guard
17            .get_or_insert_with(HashMap::new)
18            .entry(source)
19            .or_insert(0) += 1;
20    }
21}
22
23/// Snapshot of auto-mode decision sources, sorted by count descending.
24pub fn source_counts() -> Vec<(&'static str, u64)> {
25    let Ok(guard) = SOURCE_COUNTS.lock() else {
26        return Vec::new();
27    };
28    let mut items: Vec<(&'static str, u64)> = guard
29        .as_ref()
30        .map(|m| m.iter().map(|(k, v)| (*k, *v)).collect())
31        .unwrap_or_default();
32    items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
33    items
34}
35
36fn sources_path() -> Option<std::path::PathBuf> {
37    crate::core::data_dir::lean_ctx_data_dir()
38        .ok()
39        .map(|d| d.join("auto_mode_sources.json"))
40}
41
42/// Persist the in-process counters by *adding* them into the cumulative
43/// on-disk file, then reset the process counters. The counters live in the
44/// MCP/CLI process — the dashboard is a separate process and can only see
45/// them through this file (#505).
46pub fn flush_sources() {
47    let drained: Vec<(String, u64)> = {
48        let Ok(mut guard) = SOURCE_COUNTS.lock() else {
49            return;
50        };
51        match guard.take() {
52            Some(m) if !m.is_empty() => m.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
53            _ => return,
54        }
55    };
56    let Some(path) = sources_path() else {
57        return;
58    };
59    let mut on_disk: HashMap<String, u64> = std::fs::read_to_string(&path)
60        .ok()
61        .and_then(|s| serde_json::from_str(&s).ok())
62        .unwrap_or_default();
63    for (k, v) in drained {
64        *on_disk.entry(k).or_insert(0) += v;
65    }
66    let Ok(json) = serde_json::to_string_pretty(&on_disk) else {
67        return;
68    };
69    let tmp = path.with_extension("json.tmp");
70    if std::fs::write(&tmp, json).is_ok() {
71        let _ = std::fs::rename(&tmp, &path);
72    }
73}
74
75/// Cumulative auto-mode decision sources from disk (all processes, all time),
76/// sorted by count descending. Used by the dashboard's Live Signals panel.
77pub fn persisted_source_counts() -> Vec<(String, u64)> {
78    let Some(path) = sources_path() else {
79        return Vec::new();
80    };
81    let map: HashMap<String, u64> = std::fs::read_to_string(&path)
82        .ok()
83        .and_then(|s| serde_json::from_str(&s).ok())
84        .unwrap_or_default();
85    let mut items: Vec<(String, u64)> = map.into_iter().collect();
86    items.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
87    items
88}
89
90pub struct AutoModeContext<'a> {
91    pub path: &'a str,
92    pub token_count: usize,
93    pub line_count: Option<usize>,
94    pub task: Option<&'a str>,
95    pub cache: Option<&'a SessionCache>,
96}
97
98pub struct ResolvedMode {
99    pub mode: String,
100    pub source: &'static str,
101}
102
103/// Resolves read-mode sources with one documented precedence order:
104/// explicit request > configured default > learned selection > built-in default.
105pub fn resolve_mode_precedence(
106    explicit: Option<String>,
107    configured: Option<String>,
108    learned: Option<String>,
109    default: &str,
110) -> String {
111    explicit
112        .or(configured)
113        .or(learned)
114        .unwrap_or_else(|| default.to_string())
115}
116
117/// Returns the configured read-mode default, excluding the profile's `auto`
118/// sentinel so callers can ask the learned resolver for a per-file decision.
119pub fn configured_default_mode() -> Option<String> {
120    crate::core::policy::runtime::active()
121        .and_then(|policy| policy.resolved.default_read_mode.clone())
122        .or_else(|| crate::core::persona::active().read_mode_override())
123        .or_else(|| {
124            let profile = crate::core::profiles::active_profile();
125            let mode = profile.read.default_mode_effective();
126            (mode != "auto").then(|| mode.to_string())
127        })
128}
129
130/// Single entry point for auto-mode resolution.
131/// Merges Pipeline A (select_mode_with_task) and Pipeline B (resolve_auto_mode).
132pub fn resolve(ctx: &AutoModeContext) -> ResolvedMode {
133    // Quality loop (#1008): a ctx_patch anchor went stale — hand back fresh line
134    // anchors (not `full`) so the agent retries by reference, one-shot. Checked
135    // before the `full` escalation: it is the strictly better recovery for a
136    // model already editing via anchors.
137    if crate::core::edit_quality::take_pending_anchored_escalation(ctx.path) {
138        return resolved("anchored", "anchored_edit_fail_escalation");
139    }
140
141    // Quality loop (#494), signal 1: an edit on this file just failed after a
142    // compressed read — the agent needs the real body now, one-shot.
143    if crate::core::edit_quality::take_pending_escalation(ctx.path) {
144        return resolved("full", "edit_fail_escalation");
145    }
146
147    let r = resolve_inner(ctx);
148
149    // Quality loop (#494), signal 2: this mode keeps producing edit failures
150    // for this file type — compression here is a proven net loss. Instead of
151    // jumping straight to `full`, try `signatures` first (the next-safest
152    // compressed mode). This preserves ~85% compression when only `map` is risky.
153    if r.mode != "full" && crate::core::edit_quality::is_risky_mode(ctx.path, &r.mode) {
154        if r.mode != "signatures"
155            && !crate::core::edit_quality::is_risky_mode(ctx.path, "signatures")
156        {
157            return resolved("signatures", "edit_quality_fallback");
158        }
159        return resolved("full", "edit_quality_penalty");
160    }
161    r
162}
163
164fn resolve_inner(ctx: &AutoModeContext) -> ResolvedMode {
165    if crate::tools::ctx_read::is_instruction_file(ctx.path) {
166        return resolved("full", "instruction_file");
167    }
168
169    if crate::core::binary_detect::is_binary_file(ctx.path) {
170        return resolved("full", "binary");
171    }
172
173    if let Some(cache) = ctx.cache
174        && let Some(cached) = cache.get(ctx.path)
175    {
176        if !file_unchanged(ctx.path, cached) {
177            return resolved("diff", "cache_changed");
178        }
179        // Unchanged. Resolving to "full" is only a cheap stub hit when full
180        // content was actually delivered before.
181        if cache.is_full_delivered(ctx.path) {
182            return resolved("full", "cache_hit");
183        }
184        // Reuse the last compressed mode so the dispatcher can serve its cached
185        // output rather than re-running the predictor and compression pipeline.
186        if let Some(prev_mode) = cache.last_mode(ctx.path)
187            && prev_mode != "full"
188        {
189            return resolved(&prev_mode, "cache_hit_compressed");
190        }
191    }
192
193    if ctx.token_count <= 200 {
194        return resolved("full", "small_file");
195    }
196
197    let ext = std::path::Path::new(ctx.path)
198        .extension()
199        .and_then(|e| e.to_str())
200        .unwrap_or("");
201
202    if ctx.token_count <= 400 && is_code(ext) {
203        return resolved("full", "small_code_file");
204    }
205
206    if is_config_or_data(ext, ctx.path) {
207        if ctx.token_count <= 1000 {
208            return resolved("full", "config_data");
209        }
210        return resolved("map", "config_data_large");
211    }
212
213    // Active compiler error (#499): the agent reads this file to fix the
214    // build — compressed modes would hide the error region.
215    if crate::core::diagnostics_store::has_error(ctx.path) {
216        return resolved("full", "active_diagnostic");
217    }
218
219    // Suspect file (#361 capability): the task explicitly names this file
220    // (e.g. "fix the version sort in versioncmp.c"), so the agent is about to
221    // inspect it for the defect. Keep the full body it needs to localize and
222    // edit, ahead of any task-type intent default that might compress it.
223    if task_names_file(ctx.task, ctx.path) {
224        return resolved("full", "task_suspect_file");
225    }
226
227    if let Some(mode) = intent_recommended_mode(ctx.task) {
228        return resolved(&mode, "intent");
229    }
230
231    // Adaptive learning signals (predictor, bandit, heatmap, adaptive policy,
232    // bounce/path memory) are opt-in (#683). Off by default, the capability
233    // guards above plus the deterministic heuristic below make `auto` a pure
234    // function of (file, task) — byte-stable for provider prompt caching (#498)
235    // and free of the per-read disk I/O these stores incur.
236    if crate::core::config::Config::load().auto_mode_learning_effective()
237        && let Some(r) = resolve_adaptive(ctx)
238    {
239        return r;
240    }
241
242    // Progressive disclosure (#1309): large files default to compact overviews.
243    // File line count drives the tier; falls back to token-based approximation.
244    let cfg = crate::core::config::Config::load();
245    if cfg.progressive_disclosure_effective() {
246        let lines = ctx
247            .line_count
248            .unwrap_or_else(|| estimate_lines(ctx.token_count));
249        let threshold = cfg.progressive_threshold_lines as usize;
250        let sig_max = cfg.progressive_signatures_max as usize;
251
252        if lines >= sig_max && is_code(ext) {
253            return resolved("map", "progressive_manifest");
254        }
255        if lines >= threshold && is_code(ext) {
256            return resolved("signatures", "progressive_signatures");
257        }
258    }
259
260    // Deterministic cold-read fallback. Every read that reaches here missed the
261    // session cache (a warm hit returns `full`/`diff` above). `structure_first`
262    // lets a phase-isolated host opt into a lower `map` floor for medium code
263    // files; all capability guards (diagnostic / edit-fail / intent) already ran
264    // above, and the anti-inflation guarantee keeps `map` break-even at worst.
265    let structure_first = cfg.structure_first_effective();
266    let heuristic = heuristic_mode(ext, ctx.token_count, structure_first);
267    let source = if structure_first && heuristic == "map" && ctx.token_count <= 6000 {
268        "structure_first"
269    } else {
270        "heuristic"
271    };
272    resolved(&heuristic, source)
273}
274
275/// The opt-in adaptive block (#683): bounce/path memory plus the predictor /
276/// bandit / heatmap / adaptive-policy learning loop. Returns `Some` when a
277/// learning signal decides the mode, `None` to fall through to the deterministic
278/// heuristic. Only invoked when `auto_mode_learning` is enabled, so its disk I/O
279/// and non-determinism never touch the default cascade.
280fn resolve_adaptive(ctx: &AutoModeContext) -> Option<ResolvedMode> {
281    if let Ok(bt) = crate::core::bounce_tracker::global().lock()
282        && bt.should_force_full(ctx.path)
283    {
284        return Some(resolved("full", "bounce_tracker"));
285    }
286
287    // Per-path long-term memory (#496): a file that historically bounced in
288    // the majority of its reads will bounce again — compression is a proven
289    // net loss for it, across process restarts.
290    if crate::core::path_mode_memory::should_force_full(ctx.path) {
291        return Some(resolved("full", "path_bounce_memory"));
292    }
293
294    let sig = FileSignature::from_path(ctx.path, ctx.token_count);
295    let predictor = ModePredictor::new();
296    let mut predicted = predictor
297        .predict_best_mode(&sig)
298        .unwrap_or_else(|| "full".to_string());
299    if predicted == "auto" {
300        predicted = "full".to_string();
301    }
302
303    if predicted != "full"
304        && let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count)
305    {
306        predicted = bandit_override;
307    }
308
309    // Heatmap signal (#496): a frequently-read file where compression barely
310    // saves anything will likely trigger a follow-up read — step one mode more
311    // conservative. avg_compression_ratio is the historical fraction saved.
312    if predicted != "full"
313        && let Some((access_count, avg_ratio)) = crate::core::heatmap::entry_stats(ctx.path)
314        && access_count >= 5
315        && avg_ratio < 0.30
316    {
317        let conservative = match predicted.as_str() {
318            "signatures" | "aggressive" | "entropy" => "map".to_string(),
319            "map" if ctx.token_count <= 6000 => "full".to_string(),
320            other => other.to_string(),
321        };
322        if conservative != predicted {
323            return Some(resolved(&conservative, "heatmap_conservative"));
324        }
325    }
326
327    let request_id = "auto-mode-resolution";
328    let request = ConfigTuningRequest {
329        context: OclaRequestContext {
330            request_id: request_id.to_string(),
331            session_id: "auto-mode".to_string(),
332            agent_id: "lean-ctx".to_string(),
333            content_ref: ctx.path.to_string(),
334            tenant_id: None,
335            trace_id: "tr-unit".into(),
336        },
337        config_ref: predicted.clone(),
338        objective_ref: ctx.task.unwrap_or_default().to_string(),
339    };
340    let chosen = match OclaRegistry::global().config_tuner.propose_tuning(request) {
341        Ok(proposal) => proposal
342            .proposal_ref
343            .strip_prefix(&format!("proposal:{predicted}->"))
344            .and_then(|value| value.strip_suffix(&format!(":{request_id}")))
345            .map_or_else(|| predicted.clone(), ToString::to_string),
346        Err(_) => predicted.clone(),
347    };
348
349    if ctx.token_count > 2000 {
350        if (predicted == "map" || predicted == "signatures")
351            && chosen != "map"
352            && chosen != "signatures"
353        {
354            return Some(resolved(&predicted, "predictor_guard"));
355        }
356        if chosen == "full" && predicted != "full" {
357            return Some(resolved(&predicted, "predictor_override"));
358        }
359    }
360
361    if chosen != predicted {
362        return Some(resolved(&chosen, "adaptive_policy"));
363    }
364
365    if predicted != "full" {
366        return Some(resolved(&predicted, "predictor"));
367    }
368
369    None
370}
371
372/// Unified pressure downgrade table.
373/// Used by both context_gate and intent_router pressure paths.
374pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
375    match action {
376        PressureAction::SuggestCompression => match requested_mode {
377            "auto" | "full" => Some("map".to_string()),
378            _ => None,
379        },
380        PressureAction::ForceCompression => match requested_mode {
381            "full" => Some("map".to_string()),
382            "auto" | "map" => Some("signatures".to_string()),
383            _ => None,
384        },
385        PressureAction::EvictLeastRelevant => match requested_mode {
386            "full" => Some("map".to_string()),
387            "auto" | "map" => Some("signatures".to_string()),
388            "signatures" => Some("reference".to_string()),
389            _ => None,
390        },
391        PressureAction::NoAction => None,
392    }
393}
394
395/// True when the task text explicitly names this file (basename match). A real
396/// filename mention ("versioncmp.c") is a strong suspect signal for a bug-fix;
397/// requiring an extension-bearing, non-trivial basename keeps it precise — the
398/// bare stem in "improve the parser" must not match `parser.rs`. A rare false
399/// positive only costs a little compression on a file the user literally named,
400/// so the failure mode is capability-safe.
401fn task_names_file(task: Option<&str>, path: &str) -> bool {
402    let Some(task) = task else {
403        return false;
404    };
405    let basename = std::path::Path::new(path)
406        .file_name()
407        .and_then(|n| n.to_str())
408        .unwrap_or("");
409    if basename.len() < 4 || !basename.contains('.') {
410        return false;
411    }
412    task.to_ascii_lowercase()
413        .contains(&basename.to_ascii_lowercase())
414}
415
416fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
417    let task_desc = task?;
418    let classification = crate::core::intent_engine::classify(task_desc);
419    if classification.confidence < 0.4 {
420        return None;
421    }
422    let route = crate::core::intent_engine::route_intent(task_desc, &classification);
423    let mode =
424        crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
425    if mode == "auto" {
426        return None;
427    }
428    Some(mode)
429}
430
431fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
432    let project_root =
433        crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
434    let ext = std::path::Path::new(file_path)
435        .extension()
436        .and_then(|e| e.to_str())
437        .unwrap_or("");
438    let bucket = match token_count {
439        0..=2000 => "sm",
440        2001..=10000 => "md",
441        10001..=50000 => "lg",
442        _ => "xl",
443    };
444    let bandit_key = crate::core::bandit::bandit_key("mode", ext, Some(bucket));
445    let mut store = crate::core::bandit::BanditStore::load(&project_root);
446    let bandit = store.get_or_create(&bandit_key);
447    // #4: deterministic argmax-of-mean by default; Thompson only under the flag.
448    let arm = bandit.choose_arm();
449    if arm.budget_ratio < 0.25 && token_count > 2000 {
450        Some("aggressive".to_string())
451    } else {
452        None
453    }
454}
455
456fn heuristic_mode(ext: &str, token_count: usize, structure_first: bool) -> String {
457    if token_count > 8000 {
458        if is_code(ext) {
459            return "map".to_string();
460        }
461        return "aggressive".to_string();
462    }
463    if token_count > 2000 && is_prose(ext) {
464        return "aggressive".to_string();
465    }
466    // Large code files need an overview rather than an API-only surface.
467    if token_count > 6000 && is_code(ext) {
468        return "map".to_string();
469    }
470    // Structure-first cold-read floor (#361): on a phase-isolated harness a cold
471    // `full` read never amortizes, so medium code files default to `map`
472    // (deps + exports + key signatures) — cheaper and a better localization
473    // surface. `map` keeps far more than `signatures` (no empty bodies), so the
474    // follow-up-read risk that justifies the 6000 floor above is much lower; the
475    // 500-token floor stays above the trivial files where `full` is already best.
476    if structure_first && token_count > 500 && is_code(ext) {
477        return "map".to_string();
478    }
479    // Medium code files are common during exploration. Keep the tiny-file floor
480    // intact, but avoid delivering full bodies once signatures can localize the
481    // relevant symbol cheaply.
482    if token_count > 500 && is_code(ext) {
483        return "signatures".to_string();
484    }
485    "full".to_string()
486}
487
488/// Fast O(1) staleness check: if the file's mtime still matches what was
489/// stored when the cache entry was created, the content is unchanged — no need
490/// to read the file or compute any hash. Falls back to "changed" when metadata
491/// is unavailable (e.g. file deleted) or when the cache entry predates mtime
492/// tracking (legacy entries with `stored_mtime = None`).
493///
494/// mtime comparison is sufficient for correctness on all major filesystems:
495/// every `write(2)` / `truncate(2)` updates mtime (POSIX guarantee).
496fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
497    let Some(stored_mtime) = cached.stored_mtime else {
498        return false;
499    };
500    let Ok(meta) = std::fs::metadata(path) else {
501        return false;
502    };
503    let Ok(current_mtime) = meta.modified() else {
504        return false;
505    };
506    current_mtime == stored_mtime
507}
508
509fn is_code(ext: &str) -> bool {
510    matches!(
511        ext,
512        "rs" | "ts"
513            | "tsx"
514            | "js"
515            | "jsx"
516            | "py"
517            | "go"
518            | "java"
519            | "c"
520            | "cpp"
521            | "cc"
522            | "h"
523            | "hpp"
524            | "rb"
525            | "cs"
526            | "kt"
527            | "swift"
528            | "php"
529            | "zig"
530            | "ex"
531            | "exs"
532            | "scala"
533            | "sc"
534            | "dart"
535            | "toml"
536            | "yaml"
537            | "yml"
538            | "json"
539            | "sh"
540            | "bash"
541            | "svelte"
542            | "vue"
543            | "astro"
544            | "mdx"
545            | "njk"
546            | "hbs"
547            | "ejs"
548            | "erb"
549            | "jinja"
550            | "jinja2"
551            | "twig"
552            | "pug"
553            | "slim"
554            | "haml"
555            | "liquid"
556    )
557}
558
559fn is_prose(ext: &str) -> bool {
560    matches!(
561        ext,
562        "md" | "mdx" | "txt" | "rst" | "adoc" | "org" | "tex" | "html" | "htm"
563    )
564}
565
566/// Approximate line count from tokens when actual line count is unavailable.
567/// Uses 4 tokens/line as a conservative estimate for typical source code.
568fn estimate_lines(token_count: usize) -> usize {
569    token_count / 4
570}
571
572fn is_config_or_data(ext: &str, path: &str) -> bool {
573    if matches!(ext, "xml" | "ini" | "cfg" | "env") {
574        return true;
575    }
576    let name = std::path::Path::new(path)
577        .file_name()
578        .and_then(|n| n.to_str())
579        .unwrap_or("");
580    matches!(
581        name,
582        "Cargo.toml"
583            | "package.json"
584            | "tsconfig.json"
585            | "Makefile"
586            | "Dockerfile"
587            | "docker-compose.yml"
588            | ".gitignore"
589            | ".env"
590            | "pyproject.toml"
591            | "go.mod"
592            | "build.gradle"
593            | "pom.xml"
594    )
595}
596
597fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
598    count_source(source);
599    ResolvedMode {
600        mode: mode.to_string(),
601        source,
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn mode_precedence_is_explicit_then_configured_then_learned_then_default() {
611        assert_eq!(
612            resolve_mode_precedence(
613                Some("raw".to_string()),
614                Some("map".to_string()),
615                Some("signatures".to_string()),
616                "full",
617            ),
618            "raw"
619        );
620        assert_eq!(
621            resolve_mode_precedence(
622                None,
623                Some("map".to_string()),
624                Some("signatures".to_string()),
625                "full",
626            ),
627            "map"
628        );
629        assert_eq!(
630            resolve_mode_precedence(None, None, Some("signatures".to_string()), "full"),
631            "signatures"
632        );
633        assert_eq!(resolve_mode_precedence(None, None, None, "full"), "full");
634    }
635
636    #[test]
637    fn pressure_suggest_full_to_map() {
638        assert_eq!(
639            pressure_downgrade("full", &PressureAction::SuggestCompression),
640            Some("map".to_string())
641        );
642    }
643
644    #[test]
645    fn pressure_suggest_auto_to_map() {
646        assert_eq!(
647            pressure_downgrade("auto", &PressureAction::SuggestCompression),
648            Some("map".to_string())
649        );
650    }
651
652    #[test]
653    fn pressure_suggest_does_not_touch_signatures() {
654        assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
655    }
656
657    #[test]
658    fn pressure_force_full_to_map() {
659        assert_eq!(
660            pressure_downgrade("full", &PressureAction::ForceCompression),
661            Some("map".to_string())
662        );
663    }
664
665    #[test]
666    fn pressure_force_map_to_signatures() {
667        assert_eq!(
668            pressure_downgrade("map", &PressureAction::ForceCompression),
669            Some("signatures".to_string())
670        );
671    }
672
673    #[test]
674    fn pressure_evict_signatures_to_reference() {
675        assert_eq!(
676            pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
677            Some("reference".to_string())
678        );
679    }
680
681    #[test]
682    fn pressure_noaction_returns_none() {
683        assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
684    }
685
686    #[test]
687    fn flush_sources_merges_additively_into_disk_file() {
688        let _lock = crate::core::data_dir::test_env_lock();
689        let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
690        let _ = std::fs::create_dir_all(&dir);
691        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
692        let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
693
694        // Unique test-only keys: parallel resolve() tests count real sources
695        // into the same process-global map, so shared keys would be flaky.
696        count_source("test_flush_alpha");
697        count_source("test_flush_alpha");
698        count_source("test_flush_beta");
699        flush_sources();
700
701        count_source("test_flush_alpha");
702        flush_sources();
703
704        let persisted = persisted_source_counts();
705        let get = |k: &str| {
706            persisted
707                .iter()
708                .find(|(s, _)| s == k)
709                .map_or(0, |(_, n)| *n)
710        };
711        assert_eq!(
712            get("test_flush_alpha"),
713            3,
714            "two flushes must merge additively"
715        );
716        assert_eq!(get("test_flush_beta"), 1);
717
718        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
719        let _ = std::fs::remove_dir_all(&dir);
720    }
721
722    #[test]
723    fn small_file_always_full() {
724        let ctx = AutoModeContext {
725            path: "test.rs",
726            token_count: 100,
727            line_count: None,
728            task: None,
729            cache: None,
730        };
731        let result = resolve(&ctx);
732        assert_eq!(result.mode, "full");
733        assert_eq!(result.source, "small_file");
734    }
735
736    #[test]
737    fn config_file_returns_full() {
738        let ctx = AutoModeContext {
739            path: "config.ini",
740            token_count: 500,
741            line_count: None,
742            task: None,
743            cache: None,
744        };
745        let result = resolve(&ctx);
746        assert_eq!(result.mode, "full");
747        assert_eq!(result.source, "config_data");
748    }
749
750    #[test]
751    fn cached_compressed_only_file_reuses_cached_mode() {
752        // A compressed first read records its mode without marking full content
753        // delivered. An unchanged re-read must reuse that mode and its cached
754        // compressed output rather than re-entering the prediction pipeline.
755        let dir = tempfile::tempdir().unwrap();
756        let file = dir.path().join("large.rs");
757        let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
758        std::fs::write(&file, &body).unwrap();
759        let path = file.to_str().unwrap();
760
761        let mut cache = SessionCache::new();
762        cache.store(path, &body);
763        cache.get_mut(path).unwrap().last_mode = "map".to_string();
764
765        let ctx = AutoModeContext {
766            path,
767            token_count: 7000,
768            line_count: None,
769            task: None,
770            cache: Some(&cache),
771        };
772        let result = resolve(&ctx);
773        assert_eq!(result.mode, "map");
774        assert_eq!(result.source, "cache_hit_compressed");
775    }
776
777    #[test]
778    fn cached_full_delivered_file_short_circuits_to_stub() {
779        // Once full content was actually delivered, the cache_hit shortcut still
780        // applies: a re-read resolves to "full" (a cheap `[unchanged]` stub).
781        let dir = tempfile::tempdir().unwrap();
782        let file = dir.path().join("medium.rs");
783        let body = "fn placeholder() { let _ = 1; }\n".repeat(400);
784        std::fs::write(&file, &body).unwrap();
785        let path = file.to_str().unwrap();
786
787        let mut cache = SessionCache::new();
788        cache.store(path, &body);
789        cache.mark_full_delivered(path);
790
791        let ctx = AutoModeContext {
792            path,
793            token_count: 3000,
794            line_count: None,
795            task: None,
796            cache: Some(&cache),
797        };
798        let result = resolve(&ctx);
799        assert_eq!(result.mode, "full");
800        assert_eq!(result.source, "cache_hit");
801    }
802
803    #[test]
804    fn intent_explore_returns_map() {
805        let ctx = AutoModeContext {
806            path: "large.rs",
807            token_count: 5000,
808            line_count: None,
809            task: Some("how does the cache work?"),
810            cache: None,
811        };
812        let result = resolve(&ctx);
813        assert_eq!(result.mode, "map");
814        assert_eq!(result.source, "intent");
815    }
816
817    #[test]
818    fn task_names_file_matches_explicit_filename() {
819        assert!(task_names_file(
820            Some("fix the version sort in versioncmp.c"),
821            "src/versioncmp.c"
822        ));
823        assert!(task_names_file(
824            Some("why does graph.ts loop?"),
825            "web/src/graph.ts"
826        ));
827    }
828
829    #[test]
830    fn task_names_file_ignores_bare_stems_and_trivia() {
831        // A bare stem mention must not match the file.
832        assert!(!task_names_file(
833            Some("improve the parser"),
834            "src/parser.rs"
835        ));
836        assert!(!task_names_file(None, "src/parser.rs"));
837        // Trivial / extension-less basenames are excluded.
838        assert!(!task_names_file(Some("touch a.c"), "a.c"));
839        assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
840    }
841
842    #[test]
843    fn task_suspect_file_overrides_intent() {
844        let _lock = crate::core::data_dir::test_env_lock();
845        let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
846        let _ = std::fs::create_dir_all(&dir);
847        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
848
849        // An explore-style task that would otherwise map (cf.
850        // intent_explore_returns_map) — but it names the file, so the suspect
851        // guard keeps the full body for localization.
852        let ctx = AutoModeContext {
853            path: "large.rs",
854            token_count: 5000,
855            line_count: None,
856            task: Some("how does large.rs build the cache?"),
857            cache: None,
858        };
859        let result = resolve(&ctx);
860        assert_eq!(result.mode, "full");
861        assert_eq!(result.source, "task_suspect_file");
862
863        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
864        let _ = std::fs::remove_dir_all(&dir);
865    }
866
867    #[test]
868    fn heuristic_medium_code_uses_signatures_by_default() {
869        assert_eq!(heuristic_mode("rs", 1500, false), "signatures");
870        assert_eq!(heuristic_mode("ts", 1000, false), "signatures");
871    }
872
873    #[test]
874    fn heuristic_structure_first_maps_medium_code() {
875        // Structure-first: medium code becomes `map` on a cold read.
876        assert_eq!(heuristic_mode("rs", 1500, true), "map");
877        assert_eq!(heuristic_mode("c", 800, true), "map");
878    }
879
880    #[test]
881    fn heuristic_structure_first_keeps_tiny_and_prose_full() {
882        // Below the 500-token floor `full` is already best.
883        assert_eq!(heuristic_mode("rs", 400, true), "full");
884        // Prose / markup now gets `aggressive` above 2000 tokens.
885        assert_eq!(heuristic_mode("md", 4000, true), "aggressive");
886        assert_eq!(heuristic_mode("md", 1500, true), "full");
887        assert_eq!(heuristic_mode("txt", 3000, true), "aggressive");
888        assert_eq!(heuristic_mode("txt", 1000, true), "full");
889    }
890
891    #[test]
892    fn code_and_data_extensions_cover_progressive_formats() {
893        for ext in [
894            "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "rb", "swift",
895            "kt", "scala", "toml", "yaml", "yml", "json", "sh", "bash", "svelte", "vue", "astro",
896            "mdx", "njk", "hbs", "ejs", "erb", "jinja", "jinja2", "twig", "pug", "slim", "haml",
897            "liquid",
898        ] {
899            assert!(
900                is_code(ext),
901                "{ext} should participate in structure-first modes"
902            );
903        }
904        // Markdown is prose, not code — must NOT be in is_code().
905        assert!(!is_code("md"));
906    }
907
908    #[test]
909    fn heuristic_large_code_maps_regardless() {
910        assert_eq!(heuristic_mode("rs", 9000, false), "map");
911        assert_eq!(heuristic_mode("rs", 9000, true), "map");
912    }
913
914    /// Bug-fix read pattern: while localizing a planted defect the agent reads
915    /// many medium source files cold. With structure_first the resolver returns
916    /// `map` (cheap, localization-friendly) instead of an un-amortized `full`,
917    /// while every capability guard still takes precedence because it runs
918    /// before this fallback (here: the small-file guard keeps a tiny file full).
919    #[test]
920    fn structure_first_resolve_bugfix_cold_read() {
921        let _lock = crate::core::data_dir::test_env_lock();
922        let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
923        let _ = std::fs::create_dir_all(&dir);
924        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
925        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
926        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
927
928        let suspect = AutoModeContext {
929            path: "src/versioncmp.c",
930            token_count: 1500,
931            line_count: None,
932            task: None,
933            cache: None,
934        };
935        let result = resolve(&suspect);
936        assert_eq!(result.mode, "map");
937        assert_eq!(result.source, "structure_first");
938
939        let tiny = AutoModeContext {
940            path: "src/util.c",
941            token_count: 120,
942            line_count: None,
943            task: None,
944            cache: None,
945        };
946        assert_eq!(resolve(&tiny).mode, "full");
947
948        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
949        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
950        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
951        let _ = std::fs::remove_dir_all(&dir);
952    }
953
954    #[test]
955    fn structure_first_off_uses_signatures_for_medium_code() {
956        let _lock = crate::core::data_dir::test_env_lock();
957        let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
958        let _ = std::fs::create_dir_all(&dir);
959        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
960        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
961        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
962
963        let ctx = AutoModeContext {
964            path: "src/versioncmp.c",
965            token_count: 1500,
966            line_count: None,
967            task: None,
968            cache: None,
969        };
970        let result = resolve(&ctx);
971        assert_eq!(result.mode, "signatures");
972        assert_eq!(result.source, "heuristic");
973
974        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
975        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
976        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
977        let _ = std::fs::remove_dir_all(&dir);
978    }
979
980    /// #683: with learning off (the default), a medium code file with no task
981    /// resolves through the deterministic size heuristic — never a learning
982    /// source — and is byte-stable across repeated calls.
983    #[test]
984    fn learning_off_by_default_is_deterministic() {
985        let _lock = crate::core::data_dir::test_env_lock();
986        let dir = std::env::temp_dir().join(format!("lctx-amr-det-{}", std::process::id()));
987        let _ = std::fs::create_dir_all(&dir);
988        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
989        crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
990        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
991        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
992
993        let ctx = AutoModeContext {
994            path: "src/widget.rs",
995            token_count: 1500,
996            line_count: None,
997            task: None,
998            cache: None,
999        };
1000        let a = resolve(&ctx);
1001        let b = resolve(&ctx);
1002        assert_eq!(a.mode, "map");
1003        assert_eq!(a.source, "structure_first");
1004        assert_eq!((a.mode, a.source), (b.mode, b.source));
1005
1006        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1007        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1008        let _ = std::fs::remove_dir_all(&dir);
1009    }
1010
1011    /// #683: the `LEAN_CTX_AUTO_MODE_LEARNING` env var gates the adaptive block
1012    /// and wins over the (default-off) config field.
1013    #[test]
1014    fn auto_mode_learning_env_opt_in_is_honored() {
1015        let _lock = crate::core::data_dir::test_env_lock();
1016        crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "1");
1017        assert!(crate::core::config::Config::default().auto_mode_learning_effective());
1018        crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "0");
1019        assert!(!crate::core::config::Config::default().auto_mode_learning_effective());
1020        crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
1021    }
1022
1023    #[test]
1024    fn progressive_small_file_stays_full() {
1025        let _lock = crate::core::data_dir::test_env_lock();
1026        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1027
1028        let ctx = AutoModeContext {
1029            path: "small.rs",
1030            token_count: 200,
1031            line_count: Some(50),
1032            task: None,
1033            cache: None,
1034        };
1035        let result = resolve(&ctx);
1036        assert_eq!(result.mode, "full", "50 lines → full");
1037
1038        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1039    }
1040
1041    #[test]
1042    fn progressive_medium_file_gets_signatures() {
1043        let _lock = crate::core::data_dir::test_env_lock();
1044        let dir = std::env::temp_dir().join(format!("lctx-pd-sig-{}", std::process::id()));
1045        let _ = std::fs::create_dir_all(&dir);
1046        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1047        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1048
1049        let ctx = AutoModeContext {
1050            path: "medium.rs",
1051            token_count: 800,
1052            line_count: Some(200),
1053            task: None,
1054            cache: None,
1055        };
1056        let result = resolve(&ctx);
1057        assert_eq!(result.mode, "signatures", "200 lines → signatures");
1058        assert_eq!(result.source, "progressive_signatures");
1059
1060        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1061        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1062        let _ = std::fs::remove_dir_all(&dir);
1063    }
1064
1065    #[test]
1066    fn progressive_large_file_gets_map() {
1067        let _lock = crate::core::data_dir::test_env_lock();
1068        let dir = std::env::temp_dir().join(format!("lctx-pd-map-{}", std::process::id()));
1069        let _ = std::fs::create_dir_all(&dir);
1070        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1071        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1072
1073        let ctx = AutoModeContext {
1074            path: "large.rs",
1075            token_count: 3200,
1076            line_count: Some(800),
1077            task: None,
1078            cache: None,
1079        };
1080        let result = resolve(&ctx);
1081        assert_eq!(result.mode, "map", "800 lines → map (manifest)");
1082        assert_eq!(result.source, "progressive_manifest");
1083
1084        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1085        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1086        let _ = std::fs::remove_dir_all(&dir);
1087    }
1088
1089    #[test]
1090    fn progressive_disabled_skips_tiering() {
1091        let _lock = crate::core::data_dir::test_env_lock();
1092        let dir = std::env::temp_dir().join(format!("lctx-pd-off-{}", std::process::id()));
1093        let _ = std::fs::create_dir_all(&dir);
1094        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1095        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1096        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
1097
1098        let ctx = AutoModeContext {
1099            path: "medium.rs",
1100            token_count: 800,
1101            line_count: Some(200),
1102            task: None,
1103            cache: None,
1104        };
1105        let result = resolve(&ctx);
1106        assert_eq!(
1107            result.mode, "signatures",
1108            "progressive off → heuristic uses signatures for medium code"
1109        );
1110
1111        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1112        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1113        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1114        let _ = std::fs::remove_dir_all(&dir);
1115    }
1116
1117    #[test]
1118    fn estimate_lines_approximation() {
1119        assert_eq!(estimate_lines(400), 100);
1120        assert_eq!(estimate_lines(2000), 500);
1121        assert_eq!(estimate_lines(0), 0);
1122    }
1123}