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 (500–6000 tok): use `signatures` for compression.
480    // Safe because tree-sitter is wrapped in catch_unwind with per-language
481    // blocklist fallback to regex — a panic degrades to regex signatures,
482    // never crashes the session.
483    if token_count > 500 && is_code(ext) {
484        return "signatures".to_string();
485    }
486    "full".to_string()
487}
488
489/// Fast O(1) staleness check: if the file's mtime still matches what was
490/// stored when the cache entry was created, the content is unchanged — no need
491/// to read the file or compute any hash. Falls back to "changed" when metadata
492/// is unavailable (e.g. file deleted) or when the cache entry predates mtime
493/// tracking (legacy entries with `stored_mtime = None`).
494///
495/// mtime comparison is sufficient for correctness on all major filesystems:
496/// every `write(2)` / `truncate(2)` updates mtime (POSIX guarantee).
497fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
498    let Some(stored_mtime) = cached.stored_mtime else {
499        return false;
500    };
501    let Ok(meta) = std::fs::metadata(path) else {
502        return false;
503    };
504    let Ok(current_mtime) = meta.modified() else {
505        return false;
506    };
507    current_mtime == stored_mtime
508}
509
510fn is_code(ext: &str) -> bool {
511    matches!(
512        ext,
513        "rs" | "ts"
514            | "tsx"
515            | "js"
516            | "jsx"
517            | "py"
518            | "go"
519            | "java"
520            | "c"
521            | "cpp"
522            | "cc"
523            | "h"
524            | "hpp"
525            | "rb"
526            | "cs"
527            | "kt"
528            | "swift"
529            | "php"
530            | "zig"
531            | "ex"
532            | "exs"
533            | "scala"
534            | "sc"
535            | "dart"
536            | "toml"
537            | "yaml"
538            | "yml"
539            | "json"
540            | "sh"
541            | "bash"
542            | "svelte"
543            | "vue"
544            | "astro"
545            | "mdx"
546            | "njk"
547            | "hbs"
548            | "ejs"
549            | "erb"
550            | "jinja"
551            | "jinja2"
552            | "twig"
553            | "pug"
554            | "slim"
555            | "haml"
556            | "liquid"
557    )
558}
559
560fn is_prose(ext: &str) -> bool {
561    matches!(
562        ext,
563        "md" | "mdx" | "txt" | "rst" | "adoc" | "org" | "tex" | "html" | "htm"
564    )
565}
566
567/// Approximate line count from tokens when actual line count is unavailable.
568/// Uses 4 tokens/line as a conservative estimate for typical source code.
569fn estimate_lines(token_count: usize) -> usize {
570    token_count / 4
571}
572
573fn is_config_or_data(ext: &str, path: &str) -> bool {
574    if matches!(ext, "xml" | "ini" | "cfg" | "env") {
575        return true;
576    }
577    let name = std::path::Path::new(path)
578        .file_name()
579        .and_then(|n| n.to_str())
580        .unwrap_or("");
581    matches!(
582        name,
583        "Cargo.toml"
584            | "package.json"
585            | "tsconfig.json"
586            | "Makefile"
587            | "Dockerfile"
588            | "docker-compose.yml"
589            | ".gitignore"
590            | ".env"
591            | "pyproject.toml"
592            | "go.mod"
593            | "build.gradle"
594            | "pom.xml"
595    )
596}
597
598fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
599    count_source(source);
600    ResolvedMode {
601        mode: mode.to_string(),
602        source,
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn mode_precedence_is_explicit_then_configured_then_learned_then_default() {
612        assert_eq!(
613            resolve_mode_precedence(
614                Some("raw".to_string()),
615                Some("map".to_string()),
616                Some("signatures".to_string()),
617                "full",
618            ),
619            "raw"
620        );
621        assert_eq!(
622            resolve_mode_precedence(
623                None,
624                Some("map".to_string()),
625                Some("signatures".to_string()),
626                "full",
627            ),
628            "map"
629        );
630        assert_eq!(
631            resolve_mode_precedence(None, None, Some("signatures".to_string()), "full"),
632            "signatures"
633        );
634        assert_eq!(resolve_mode_precedence(None, None, None, "full"), "full");
635    }
636
637    #[test]
638    fn pressure_suggest_full_to_map() {
639        assert_eq!(
640            pressure_downgrade("full", &PressureAction::SuggestCompression),
641            Some("map".to_string())
642        );
643    }
644
645    #[test]
646    fn pressure_suggest_auto_to_map() {
647        assert_eq!(
648            pressure_downgrade("auto", &PressureAction::SuggestCompression),
649            Some("map".to_string())
650        );
651    }
652
653    #[test]
654    fn pressure_suggest_does_not_touch_signatures() {
655        assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
656    }
657
658    #[test]
659    fn pressure_force_full_to_map() {
660        assert_eq!(
661            pressure_downgrade("full", &PressureAction::ForceCompression),
662            Some("map".to_string())
663        );
664    }
665
666    #[test]
667    fn pressure_force_map_to_signatures() {
668        assert_eq!(
669            pressure_downgrade("map", &PressureAction::ForceCompression),
670            Some("signatures".to_string())
671        );
672    }
673
674    #[test]
675    fn pressure_evict_signatures_to_reference() {
676        assert_eq!(
677            pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
678            Some("reference".to_string())
679        );
680    }
681
682    #[test]
683    fn pressure_noaction_returns_none() {
684        assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
685    }
686
687    #[test]
688    fn flush_sources_merges_additively_into_disk_file() {
689        let _lock = crate::core::data_dir::test_env_lock();
690        let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
691        let _ = std::fs::create_dir_all(&dir);
692        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
693        let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
694
695        // Unique test-only keys: parallel resolve() tests count real sources
696        // into the same process-global map, so shared keys would be flaky.
697        count_source("test_flush_alpha");
698        count_source("test_flush_alpha");
699        count_source("test_flush_beta");
700        flush_sources();
701
702        count_source("test_flush_alpha");
703        flush_sources();
704
705        let persisted = persisted_source_counts();
706        let get = |k: &str| {
707            persisted
708                .iter()
709                .find(|(s, _)| s == k)
710                .map_or(0, |(_, n)| *n)
711        };
712        assert_eq!(
713            get("test_flush_alpha"),
714            3,
715            "two flushes must merge additively"
716        );
717        assert_eq!(get("test_flush_beta"), 1);
718
719        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
720        let _ = std::fs::remove_dir_all(&dir);
721    }
722
723    #[test]
724    fn small_file_always_full() {
725        let ctx = AutoModeContext {
726            path: "test.rs",
727            token_count: 100,
728            line_count: None,
729            task: None,
730            cache: None,
731        };
732        let result = resolve(&ctx);
733        assert_eq!(result.mode, "full");
734        assert_eq!(result.source, "small_file");
735    }
736
737    #[test]
738    fn config_file_returns_full() {
739        let ctx = AutoModeContext {
740            path: "config.ini",
741            token_count: 500,
742            line_count: None,
743            task: None,
744            cache: None,
745        };
746        let result = resolve(&ctx);
747        assert_eq!(result.mode, "full");
748        assert_eq!(result.source, "config_data");
749    }
750
751    #[test]
752    fn cached_compressed_only_file_reuses_cached_mode() {
753        // A compressed first read records its mode without marking full content
754        // delivered. An unchanged re-read must reuse that mode and its cached
755        // compressed output rather than re-entering the prediction pipeline.
756        let dir = tempfile::tempdir().unwrap();
757        let file = dir.path().join("large.rs");
758        let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
759        std::fs::write(&file, &body).unwrap();
760        let path = file.to_str().unwrap();
761
762        let mut cache = SessionCache::new();
763        cache.store(path, &body);
764        cache.get_mut(path).unwrap().last_mode = "map".to_string();
765
766        let ctx = AutoModeContext {
767            path,
768            token_count: 7000,
769            line_count: None,
770            task: None,
771            cache: Some(&cache),
772        };
773        let result = resolve(&ctx);
774        assert_eq!(result.mode, "map");
775        assert_eq!(result.source, "cache_hit_compressed");
776    }
777
778    #[test]
779    fn cached_full_delivered_file_short_circuits_to_stub() {
780        // Once full content was actually delivered, the cache_hit shortcut still
781        // applies: a re-read resolves to "full" (a cheap `[unchanged]` stub).
782        let dir = tempfile::tempdir().unwrap();
783        let file = dir.path().join("medium.rs");
784        let body = "fn placeholder() { let _ = 1; }\n".repeat(400);
785        std::fs::write(&file, &body).unwrap();
786        let path = file.to_str().unwrap();
787
788        let mut cache = SessionCache::new();
789        cache.store(path, &body);
790        cache.mark_full_delivered(path);
791
792        let ctx = AutoModeContext {
793            path,
794            token_count: 3000,
795            line_count: None,
796            task: None,
797            cache: Some(&cache),
798        };
799        let result = resolve(&ctx);
800        assert_eq!(result.mode, "full");
801        assert_eq!(result.source, "cache_hit");
802    }
803
804    #[test]
805    fn intent_explore_returns_map() {
806        let ctx = AutoModeContext {
807            path: "large.rs",
808            token_count: 5000,
809            line_count: None,
810            task: Some("how does the cache work?"),
811            cache: None,
812        };
813        let result = resolve(&ctx);
814        assert_eq!(result.mode, "map");
815        assert_eq!(result.source, "intent");
816    }
817
818    #[test]
819    fn task_names_file_matches_explicit_filename() {
820        assert!(task_names_file(
821            Some("fix the version sort in versioncmp.c"),
822            "src/versioncmp.c"
823        ));
824        assert!(task_names_file(
825            Some("why does graph.ts loop?"),
826            "web/src/graph.ts"
827        ));
828    }
829
830    #[test]
831    fn task_names_file_ignores_bare_stems_and_trivia() {
832        // A bare stem mention must not match the file.
833        assert!(!task_names_file(
834            Some("improve the parser"),
835            "src/parser.rs"
836        ));
837        assert!(!task_names_file(None, "src/parser.rs"));
838        // Trivial / extension-less basenames are excluded.
839        assert!(!task_names_file(Some("touch a.c"), "a.c"));
840        assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
841    }
842
843    #[test]
844    fn task_suspect_file_overrides_intent() {
845        let _lock = crate::core::data_dir::test_env_lock();
846        let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
847        let _ = std::fs::create_dir_all(&dir);
848        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
849
850        // An explore-style task that would otherwise map (cf.
851        // intent_explore_returns_map) — but it names the file, so the suspect
852        // guard keeps the full body for localization.
853        let ctx = AutoModeContext {
854            path: "large.rs",
855            token_count: 5000,
856            line_count: None,
857            task: Some("how does large.rs build the cache?"),
858            cache: None,
859        };
860        let result = resolve(&ctx);
861        assert_eq!(result.mode, "full");
862        assert_eq!(result.source, "task_suspect_file");
863
864        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
865        let _ = std::fs::remove_dir_all(&dir);
866    }
867
868    #[test]
869    fn heuristic_medium_code_uses_signatures_by_default() {
870        assert_eq!(heuristic_mode("rs", 1500, false), "signatures");
871        assert_eq!(heuristic_mode("ts", 1000, false), "signatures");
872    }
873
874    #[test]
875    fn heuristic_structure_first_maps_medium_code() {
876        // Structure-first: medium code becomes `map` on a cold read.
877        assert_eq!(heuristic_mode("rs", 1500, true), "map");
878        assert_eq!(heuristic_mode("c", 800, true), "map");
879    }
880
881    #[test]
882    fn heuristic_structure_first_keeps_tiny_and_prose_full() {
883        // Below the 500-token floor `full` is already best.
884        assert_eq!(heuristic_mode("rs", 400, true), "full");
885        // Prose / markup now gets `aggressive` above 2000 tokens.
886        assert_eq!(heuristic_mode("md", 4000, true), "aggressive");
887        assert_eq!(heuristic_mode("md", 1500, true), "full");
888        assert_eq!(heuristic_mode("txt", 3000, true), "aggressive");
889        assert_eq!(heuristic_mode("txt", 1000, true), "full");
890    }
891
892    #[test]
893    fn code_and_data_extensions_cover_progressive_formats() {
894        for ext in [
895            "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "rb", "swift",
896            "kt", "scala", "toml", "yaml", "yml", "json", "sh", "bash", "svelte", "vue", "astro",
897            "mdx", "njk", "hbs", "ejs", "erb", "jinja", "jinja2", "twig", "pug", "slim", "haml",
898            "liquid",
899        ] {
900            assert!(
901                is_code(ext),
902                "{ext} should participate in structure-first modes"
903            );
904        }
905        // Markdown is prose, not code — must NOT be in is_code().
906        assert!(!is_code("md"));
907    }
908
909    #[test]
910    fn heuristic_large_code_maps_regardless() {
911        assert_eq!(heuristic_mode("rs", 9000, false), "map");
912        assert_eq!(heuristic_mode("rs", 9000, true), "map");
913    }
914
915    /// Bug-fix read pattern: while localizing a planted defect the agent reads
916    /// many medium source files cold. With structure_first the resolver returns
917    /// `map` (cheap, localization-friendly) instead of an un-amortized `full`,
918    /// while every capability guard still takes precedence because it runs
919    /// before this fallback (here: the small-file guard keeps a tiny file full).
920    #[test]
921    fn structure_first_resolve_bugfix_cold_read() {
922        let _lock = crate::core::data_dir::test_env_lock();
923        let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
924        let _ = std::fs::create_dir_all(&dir);
925        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
926        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
927        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
928
929        let suspect = AutoModeContext {
930            path: "src/versioncmp.c",
931            token_count: 1500,
932            line_count: None,
933            task: None,
934            cache: None,
935        };
936        let result = resolve(&suspect);
937        assert_eq!(result.mode, "map");
938        assert_eq!(result.source, "structure_first");
939
940        let tiny = AutoModeContext {
941            path: "src/util.c",
942            token_count: 120,
943            line_count: None,
944            task: None,
945            cache: None,
946        };
947        assert_eq!(resolve(&tiny).mode, "full");
948
949        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
950        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
951        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
952        let _ = std::fs::remove_dir_all(&dir);
953    }
954
955    #[test]
956    fn structure_first_off_uses_signatures_for_medium_code() {
957        let _lock = crate::core::data_dir::test_env_lock();
958        let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
959        let _ = std::fs::create_dir_all(&dir);
960        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
961        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
962        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
963
964        let ctx = AutoModeContext {
965            path: "src/versioncmp.c",
966            token_count: 1500,
967            line_count: None,
968            task: None,
969            cache: None,
970        };
971        let result = resolve(&ctx);
972        assert_eq!(result.mode, "signatures");
973        assert_eq!(result.source, "heuristic");
974
975        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
976        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
977        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
978        let _ = std::fs::remove_dir_all(&dir);
979    }
980
981    /// #683: with learning off (the default), a medium code file with no task
982    /// resolves through the deterministic size heuristic — never a learning
983    /// source — and is byte-stable across repeated calls.
984    #[test]
985    fn learning_off_by_default_is_deterministic() {
986        let _lock = crate::core::data_dir::test_env_lock();
987        let dir = std::env::temp_dir().join(format!("lctx-amr-det-{}", std::process::id()));
988        let _ = std::fs::create_dir_all(&dir);
989        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
990        crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
991        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
992        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
993
994        let ctx = AutoModeContext {
995            path: "src/widget.rs",
996            token_count: 1500,
997            line_count: None,
998            task: None,
999            cache: None,
1000        };
1001        let a = resolve(&ctx);
1002        let b = resolve(&ctx);
1003        assert_eq!(a.mode, "map");
1004        assert_eq!(a.source, "structure_first");
1005        assert_eq!((a.mode, a.source), (b.mode, b.source));
1006
1007        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1008        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1009        let _ = std::fs::remove_dir_all(&dir);
1010    }
1011
1012    /// #683: the `LEAN_CTX_AUTO_MODE_LEARNING` env var gates the adaptive block
1013    /// and wins over the (default-off) config field.
1014    #[test]
1015    fn auto_mode_learning_env_opt_in_is_honored() {
1016        let _lock = crate::core::data_dir::test_env_lock();
1017        crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "1");
1018        assert!(crate::core::config::Config::default().auto_mode_learning_effective());
1019        crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "0");
1020        assert!(!crate::core::config::Config::default().auto_mode_learning_effective());
1021        crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
1022    }
1023
1024    #[test]
1025    fn progressive_small_file_stays_full() {
1026        let _lock = crate::core::data_dir::test_env_lock();
1027        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1028
1029        let ctx = AutoModeContext {
1030            path: "small.rs",
1031            token_count: 200,
1032            line_count: Some(50),
1033            task: None,
1034            cache: None,
1035        };
1036        let result = resolve(&ctx);
1037        assert_eq!(result.mode, "full", "50 lines → full");
1038
1039        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1040    }
1041
1042    #[test]
1043    fn progressive_medium_file_gets_signatures() {
1044        let _lock = crate::core::data_dir::test_env_lock();
1045        let dir = std::env::temp_dir().join(format!("lctx-pd-sig-{}", std::process::id()));
1046        let _ = std::fs::create_dir_all(&dir);
1047        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1048        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1049
1050        let ctx = AutoModeContext {
1051            path: "medium.rs",
1052            token_count: 800,
1053            line_count: Some(200),
1054            task: None,
1055            cache: None,
1056        };
1057        let result = resolve(&ctx);
1058        assert_eq!(result.mode, "signatures", "200 lines → signatures");
1059        assert_eq!(result.source, "progressive_signatures");
1060
1061        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1062        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1063        let _ = std::fs::remove_dir_all(&dir);
1064    }
1065
1066    #[test]
1067    fn progressive_large_file_gets_map() {
1068        let _lock = crate::core::data_dir::test_env_lock();
1069        let dir = std::env::temp_dir().join(format!("lctx-pd-map-{}", std::process::id()));
1070        let _ = std::fs::create_dir_all(&dir);
1071        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1072        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1073
1074        let ctx = AutoModeContext {
1075            path: "large.rs",
1076            token_count: 3200,
1077            line_count: Some(800),
1078            task: None,
1079            cache: None,
1080        };
1081        let result = resolve(&ctx);
1082        assert_eq!(result.mode, "map", "800 lines → map (manifest)");
1083        assert_eq!(result.source, "progressive_manifest");
1084
1085        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1086        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1087        let _ = std::fs::remove_dir_all(&dir);
1088    }
1089
1090    #[test]
1091    fn progressive_disabled_skips_tiering() {
1092        let _lock = crate::core::data_dir::test_env_lock();
1093        let dir = std::env::temp_dir().join(format!("lctx-pd-off-{}", std::process::id()));
1094        let _ = std::fs::create_dir_all(&dir);
1095        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1096        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1097        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
1098
1099        let ctx = AutoModeContext {
1100            path: "medium.rs",
1101            token_count: 800,
1102            line_count: Some(200),
1103            task: None,
1104            cache: None,
1105        };
1106        let result = resolve(&ctx);
1107        assert_eq!(
1108            result.mode, "signatures",
1109            "progressive off → heuristic uses signatures for medium code"
1110        );
1111
1112        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1113        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1114        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1115        let _ = std::fs::remove_dir_all(&dir);
1116    }
1117
1118    #[test]
1119    fn estimate_lines_approximation() {
1120        assert_eq!(estimate_lines(400), 100);
1121        assert_eq!(estimate_lines(2000), 500);
1122        assert_eq!(estimate_lines(0), 0);
1123    }
1124}