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