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