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    // Science-driven mode selection: when cognitive science features are enabled,
243    // use semantic chunking (cognitive mode) instead of structural-only modes.
244    // Cognitive mode returns 7±2 task-relevant code chunks with bodies — more
245    // useful than signatures-only and still 44-68% savings on medium files.
246    if crate::core::cognitive_gate::basic_science_enabled()
247        && is_code(ext)
248        && ctx.token_count > 500
249        && !crate::core::edit_quality::has_any_penalty(ctx.path)
250    {
251        if ctx.token_count > 8000 {
252            return resolved("cognitive", "science_cognitive_large");
253        }
254        if ctx.token_count > 2000 {
255            return resolved("cognitive", "science_cognitive_medium");
256        }
257        return resolved("cognitive", "science_cognitive_small");
258    }
259
260    // Progressive disclosure (#1309): large files default to compact overviews.
261    // File line count drives the tier; falls back to token-based approximation.
262    let cfg = crate::core::config::Config::load();
263    if cfg.progressive_disclosure_effective() {
264        let lines = ctx
265            .line_count
266            .unwrap_or_else(|| estimate_lines(ctx.token_count));
267        let threshold = cfg.progressive_threshold_lines as usize;
268        let sig_max = cfg.progressive_signatures_max as usize;
269
270        if lines >= sig_max && is_code(ext) {
271            return resolved("map", "progressive_manifest");
272        }
273        if lines >= threshold && is_code(ext) {
274            return resolved("signatures", "progressive_signatures");
275        }
276    }
277
278    // Deterministic cold-read fallback. Every read that reaches here missed the
279    // session cache (a warm hit returns `full`/`diff` above). `structure_first`
280    // lets a phase-isolated host opt into a lower `map` floor for medium code
281    // files; all capability guards (diagnostic / edit-fail / intent) already ran
282    // above, and the anti-inflation guarantee keeps `map` break-even at worst.
283    let structure_first = cfg.structure_first_effective();
284    let heuristic = heuristic_mode(ext, ctx.token_count, structure_first);
285    let source = if structure_first && heuristic == "map" && ctx.token_count <= 6000 {
286        "structure_first"
287    } else {
288        "heuristic"
289    };
290    resolved(&heuristic, source)
291}
292
293/// The opt-in adaptive block (#683): bounce/path memory plus the predictor /
294/// bandit / heatmap / adaptive-policy learning loop. Returns `Some` when a
295/// learning signal decides the mode, `None` to fall through to the deterministic
296/// heuristic. Only invoked when `auto_mode_learning` is enabled, so its disk I/O
297/// and non-determinism never touch the default cascade.
298fn resolve_adaptive(ctx: &AutoModeContext) -> Option<ResolvedMode> {
299    if let Ok(bt) = crate::core::bounce_tracker::global().lock()
300        && bt.should_force_full(ctx.path)
301    {
302        return Some(resolved("full", "bounce_tracker"));
303    }
304
305    // Per-path long-term memory (#496): a file that historically bounced in
306    // the majority of its reads will bounce again — compression is a proven
307    // net loss for it, across process restarts.
308    if crate::core::path_mode_memory::should_force_full(ctx.path) {
309        return Some(resolved("full", "path_bounce_memory"));
310    }
311
312    let sig = FileSignature::from_path(ctx.path, ctx.token_count);
313    let predictor = ModePredictor::new();
314    let mut predicted = predictor
315        .predict_best_mode(&sig)
316        .unwrap_or_else(|| "full".to_string());
317    if predicted == "auto" {
318        predicted = "full".to_string();
319    }
320
321    if predicted != "full"
322        && let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count)
323    {
324        predicted = bandit_override;
325    }
326
327    // Heatmap signal (#496): a frequently-read file where compression barely
328    // saves anything will likely trigger a follow-up read — step one mode more
329    // conservative. avg_compression_ratio is the historical fraction saved.
330    if predicted != "full"
331        && let Some((access_count, avg_ratio)) = crate::core::heatmap::entry_stats(ctx.path)
332        && access_count >= 5
333        && avg_ratio < 0.30
334    {
335        let conservative = match predicted.as_str() {
336            "signatures" | "aggressive" | "entropy" => "map".to_string(),
337            "map" if ctx.token_count <= 6000 => "full".to_string(),
338            other => other.to_string(),
339        };
340        if conservative != predicted {
341            return Some(resolved(&conservative, "heatmap_conservative"));
342        }
343    }
344
345    let request_id = "auto-mode-resolution";
346    let request = ConfigTuningRequest {
347        context: OclaRequestContext {
348            request_id: request_id.to_string(),
349            session_id: "auto-mode".to_string(),
350            agent_id: "lean-ctx".to_string(),
351            content_ref: ctx.path.to_string(),
352            tenant_id: None,
353            trace_id: "tr-unit".into(),
354        },
355        config_ref: predicted.clone(),
356        objective_ref: ctx.task.unwrap_or_default().to_string(),
357    };
358    let chosen = match OclaRegistry::global().config_tuner.propose_tuning(request) {
359        Ok(proposal) => proposal
360            .proposal_ref
361            .strip_prefix(&format!("proposal:{predicted}->"))
362            .and_then(|value| value.strip_suffix(&format!(":{request_id}")))
363            .map_or_else(|| predicted.clone(), ToString::to_string),
364        Err(_) => predicted.clone(),
365    };
366
367    if ctx.token_count > 2000 {
368        if (predicted == "map" || predicted == "signatures")
369            && chosen != "map"
370            && chosen != "signatures"
371        {
372            return Some(resolved(&predicted, "predictor_guard"));
373        }
374        if chosen == "full" && predicted != "full" {
375            return Some(resolved(&predicted, "predictor_override"));
376        }
377    }
378
379    if chosen != predicted {
380        return Some(resolved(&chosen, "adaptive_policy"));
381    }
382
383    if predicted != "full" {
384        return Some(resolved(&predicted, "predictor"));
385    }
386
387    None
388}
389
390/// Unified pressure downgrade table.
391/// Used by both context_gate and intent_router pressure paths.
392pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
393    match action {
394        PressureAction::SuggestCompression => match requested_mode {
395            "auto" | "full" => Some("map".to_string()),
396            _ => None,
397        },
398        PressureAction::ForceCompression => match requested_mode {
399            "full" => Some("map".to_string()),
400            "auto" | "map" | "cognitive" => Some("signatures".to_string()),
401            _ => None,
402        },
403        PressureAction::EvictLeastRelevant => match requested_mode {
404            "full" => Some("map".to_string()),
405            "auto" | "map" | "cognitive" => Some("signatures".to_string()),
406            "signatures" => Some("reference".to_string()),
407            _ => None,
408        },
409        PressureAction::NoAction => None,
410    }
411}
412
413/// True when the task text explicitly names this file (basename match). A real
414/// filename mention ("versioncmp.c") is a strong suspect signal for a bug-fix;
415/// requiring an extension-bearing, non-trivial basename keeps it precise — the
416/// bare stem in "improve the parser" must not match `parser.rs`. A rare false
417/// positive only costs a little compression on a file the user literally named,
418/// so the failure mode is capability-safe.
419fn task_names_file(task: Option<&str>, path: &str) -> bool {
420    let Some(task) = task else {
421        return false;
422    };
423    let basename = std::path::Path::new(path)
424        .file_name()
425        .and_then(|n| n.to_str())
426        .unwrap_or("");
427    if basename.len() < 4 || !basename.contains('.') {
428        return false;
429    }
430    task.to_ascii_lowercase()
431        .contains(&basename.to_ascii_lowercase())
432}
433
434fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
435    let task_desc = task?;
436    let classification = crate::core::intent_engine::classify(task_desc);
437    if classification.confidence < 0.4 {
438        return None;
439    }
440    let route = crate::core::intent_engine::route_intent(task_desc, &classification);
441    let mode =
442        crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
443    if mode == "auto" {
444        return None;
445    }
446    Some(mode)
447}
448
449fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
450    let project_root =
451        crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
452    let ext = std::path::Path::new(file_path)
453        .extension()
454        .and_then(|e| e.to_str())
455        .unwrap_or("");
456    let bucket = match token_count {
457        0..=2000 => "sm",
458        2001..=10000 => "md",
459        10001..=50000 => "lg",
460        _ => "xl",
461    };
462    let bandit_key = crate::core::bandit::bandit_key("mode", ext, Some(bucket));
463    let mut store = crate::core::bandit::BanditStore::load(&project_root);
464    let bandit = store.get_or_create(&bandit_key);
465    // #4: deterministic argmax-of-mean by default; Thompson only under the flag.
466    let arm = bandit.choose_arm();
467    if arm.budget_ratio < 0.25 && token_count > 2000 {
468        Some("aggressive".to_string())
469    } else {
470        None
471    }
472}
473
474fn heuristic_mode(ext: &str, token_count: usize, structure_first: bool) -> String {
475    if token_count > 8000 {
476        if is_code(ext) {
477            return "map".to_string();
478        }
479        return "aggressive".to_string();
480    }
481    if token_count > 2000 && is_prose(ext) {
482        return "aggressive".to_string();
483    }
484    // Large code files need an overview rather than an API-only surface.
485    if token_count > 6000 && is_code(ext) {
486        return "map".to_string();
487    }
488    // Structure-first cold-read floor (#361): on a phase-isolated harness a cold
489    // `full` read never amortizes, so medium code files default to `map`
490    // (deps + exports + key signatures) — cheaper and a better localization
491    // surface. `map` keeps far more than `signatures` (no empty bodies), so the
492    // follow-up-read risk that justifies the 6000 floor above is much lower; the
493    // 500-token floor stays above the trivial files where `full` is already best.
494    if structure_first && token_count > 500 && is_code(ext) {
495        return "map".to_string();
496    }
497    // Medium code files (500–6000 tok): use `signatures` for compression.
498    // Safe because tree-sitter is wrapped in catch_unwind with per-language
499    // blocklist fallback to regex — a panic degrades to regex signatures,
500    // never crashes the session.
501    if token_count > 500 && is_code(ext) {
502        return "signatures".to_string();
503    }
504    "full".to_string()
505}
506
507/// Fast O(1) staleness check: if the file's mtime still matches what was
508/// stored when the cache entry was created, the content is unchanged — no need
509/// to read the file or compute any hash. Falls back to "changed" when metadata
510/// is unavailable (e.g. file deleted) or when the cache entry predates mtime
511/// tracking (legacy entries with `stored_mtime = None`).
512///
513/// mtime comparison is sufficient for correctness on all major filesystems:
514/// every `write(2)` / `truncate(2)` updates mtime (POSIX guarantee).
515fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
516    let Some(stored_mtime) = cached.stored_mtime else {
517        return false;
518    };
519    let Ok(meta) = std::fs::metadata(path) else {
520        return false;
521    };
522    let Ok(current_mtime) = meta.modified() else {
523        return false;
524    };
525    current_mtime == stored_mtime
526}
527
528fn is_code(ext: &str) -> bool {
529    matches!(
530        ext,
531        "rs" | "ts"
532            | "tsx"
533            | "js"
534            | "jsx"
535            | "py"
536            | "go"
537            | "java"
538            | "c"
539            | "cpp"
540            | "cc"
541            | "h"
542            | "hpp"
543            | "rb"
544            | "cs"
545            | "kt"
546            | "swift"
547            | "php"
548            | "zig"
549            | "ex"
550            | "exs"
551            | "scala"
552            | "sc"
553            | "dart"
554            | "toml"
555            | "yaml"
556            | "yml"
557            | "json"
558            | "sh"
559            | "bash"
560            | "svelte"
561            | "vue"
562            | "astro"
563            | "mdx"
564            | "njk"
565            | "hbs"
566            | "ejs"
567            | "erb"
568            | "jinja"
569            | "jinja2"
570            | "twig"
571            | "pug"
572            | "slim"
573            | "haml"
574            | "liquid"
575    )
576}
577
578fn is_prose(ext: &str) -> bool {
579    matches!(
580        ext,
581        "md" | "mdx" | "txt" | "rst" | "adoc" | "org" | "tex" | "html" | "htm"
582    )
583}
584
585/// Approximate line count from tokens when actual line count is unavailable.
586/// Uses 4 tokens/line as a conservative estimate for typical source code.
587fn estimate_lines(token_count: usize) -> usize {
588    token_count / 4
589}
590
591fn is_config_or_data(ext: &str, path: &str) -> bool {
592    if matches!(ext, "xml" | "ini" | "cfg" | "env") {
593        return true;
594    }
595    let name = std::path::Path::new(path)
596        .file_name()
597        .and_then(|n| n.to_str())
598        .unwrap_or("");
599    matches!(
600        name,
601        "Cargo.toml"
602            | "package.json"
603            | "tsconfig.json"
604            | "Makefile"
605            | "Dockerfile"
606            | "docker-compose.yml"
607            | ".gitignore"
608            | ".env"
609            | "pyproject.toml"
610            | "go.mod"
611            | "build.gradle"
612            | "pom.xml"
613    )
614}
615
616fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
617    count_source(source);
618    ResolvedMode {
619        mode: mode.to_string(),
620        source,
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn mode_precedence_is_explicit_then_configured_then_learned_then_default() {
630        assert_eq!(
631            resolve_mode_precedence(
632                Some("raw".to_string()),
633                Some("map".to_string()),
634                Some("signatures".to_string()),
635                "full",
636            ),
637            "raw"
638        );
639        assert_eq!(
640            resolve_mode_precedence(
641                None,
642                Some("map".to_string()),
643                Some("signatures".to_string()),
644                "full",
645            ),
646            "map"
647        );
648        assert_eq!(
649            resolve_mode_precedence(None, None, Some("signatures".to_string()), "full"),
650            "signatures"
651        );
652        assert_eq!(resolve_mode_precedence(None, None, None, "full"), "full");
653    }
654
655    #[test]
656    fn pressure_suggest_full_to_map() {
657        assert_eq!(
658            pressure_downgrade("full", &PressureAction::SuggestCompression),
659            Some("map".to_string())
660        );
661    }
662
663    #[test]
664    fn pressure_suggest_auto_to_map() {
665        assert_eq!(
666            pressure_downgrade("auto", &PressureAction::SuggestCompression),
667            Some("map".to_string())
668        );
669    }
670
671    #[test]
672    fn pressure_suggest_does_not_touch_signatures() {
673        assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
674    }
675
676    #[test]
677    fn pressure_force_full_to_map() {
678        assert_eq!(
679            pressure_downgrade("full", &PressureAction::ForceCompression),
680            Some("map".to_string())
681        );
682    }
683
684    #[test]
685    fn pressure_force_map_to_signatures() {
686        assert_eq!(
687            pressure_downgrade("map", &PressureAction::ForceCompression),
688            Some("signatures".to_string())
689        );
690    }
691
692    #[test]
693    fn pressure_evict_signatures_to_reference() {
694        assert_eq!(
695            pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
696            Some("reference".to_string())
697        );
698    }
699
700    #[test]
701    fn pressure_noaction_returns_none() {
702        assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
703    }
704
705    #[test]
706    fn flush_sources_merges_additively_into_disk_file() {
707        let _lock = crate::core::data_dir::test_env_lock();
708        let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
709        let _ = std::fs::create_dir_all(&dir);
710        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
711        let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
712
713        // Unique test-only keys: parallel resolve() tests count real sources
714        // into the same process-global map, so shared keys would be flaky.
715        count_source("test_flush_alpha");
716        count_source("test_flush_alpha");
717        count_source("test_flush_beta");
718        flush_sources();
719
720        count_source("test_flush_alpha");
721        flush_sources();
722
723        let persisted = persisted_source_counts();
724        let get = |k: &str| {
725            persisted
726                .iter()
727                .find(|(s, _)| s == k)
728                .map_or(0, |(_, n)| *n)
729        };
730        assert_eq!(
731            get("test_flush_alpha"),
732            3,
733            "two flushes must merge additively"
734        );
735        assert_eq!(get("test_flush_beta"), 1);
736
737        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
738        let _ = std::fs::remove_dir_all(&dir);
739    }
740
741    #[test]
742    fn small_file_always_full() {
743        let ctx = AutoModeContext {
744            path: "test.rs",
745            token_count: 100,
746            line_count: None,
747            task: None,
748            cache: None,
749        };
750        let result = resolve(&ctx);
751        assert_eq!(result.mode, "full");
752        assert_eq!(result.source, "small_file");
753    }
754
755    #[test]
756    fn config_file_returns_full() {
757        let ctx = AutoModeContext {
758            path: "config.ini",
759            token_count: 500,
760            line_count: None,
761            task: None,
762            cache: None,
763        };
764        let result = resolve(&ctx);
765        assert_eq!(result.mode, "full");
766        assert_eq!(result.source, "config_data");
767    }
768
769    #[test]
770    fn cached_compressed_only_file_reuses_cached_mode() {
771        // A compressed first read records its mode without marking full content
772        // delivered. An unchanged re-read must reuse that mode and its cached
773        // compressed output rather than re-entering the prediction pipeline.
774        let dir = tempfile::tempdir().unwrap();
775        let file = dir.path().join("large.rs");
776        let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
777        std::fs::write(&file, &body).unwrap();
778        let path = file.to_str().unwrap();
779
780        let mut cache = SessionCache::new();
781        cache.store(path, &body);
782        cache.get_mut(path).unwrap().last_mode = "map".to_string();
783
784        let ctx = AutoModeContext {
785            path,
786            token_count: 7000,
787            line_count: None,
788            task: None,
789            cache: Some(&cache),
790        };
791        let result = resolve(&ctx);
792        assert!(
793            result.mode == "map" || result.mode == "cognitive",
794            "expected map or cognitive, got: {}",
795            result.mode
796        );
797        assert_eq!(result.source, "cache_hit_compressed");
798    }
799
800    #[test]
801    fn cached_full_delivered_file_short_circuits_to_stub() {
802        // Once full content was actually delivered, the cache_hit shortcut still
803        // applies: a re-read resolves to "full" (a cheap `[unchanged]` stub).
804        let dir = tempfile::tempdir().unwrap();
805        let file = dir.path().join("medium.rs");
806        let body = "fn placeholder() { let _ = 1; }\n".repeat(400);
807        std::fs::write(&file, &body).unwrap();
808        let path = file.to_str().unwrap();
809
810        let mut cache = SessionCache::new();
811        cache.store(path, &body);
812        cache.mark_full_delivered(path);
813
814        let ctx = AutoModeContext {
815            path,
816            token_count: 3000,
817            line_count: None,
818            task: None,
819            cache: Some(&cache),
820        };
821        let result = resolve(&ctx);
822        assert_eq!(result.mode, "full");
823        assert_eq!(result.source, "cache_hit");
824    }
825
826    #[test]
827    fn intent_explore_returns_map() {
828        let ctx = AutoModeContext {
829            path: "large.rs",
830            token_count: 5000,
831            line_count: None,
832            task: Some("how does the cache work?"),
833            cache: None,
834        };
835        let result = resolve(&ctx);
836        assert!(
837            result.mode == "map" || result.mode == "cognitive",
838            "expected map or cognitive, got: {}",
839            result.mode
840        );
841        assert_eq!(result.source, "intent");
842    }
843
844    #[test]
845    fn task_names_file_matches_explicit_filename() {
846        assert!(task_names_file(
847            Some("fix the version sort in versioncmp.c"),
848            "src/versioncmp.c"
849        ));
850        assert!(task_names_file(
851            Some("why does graph.ts loop?"),
852            "web/src/graph.ts"
853        ));
854    }
855
856    #[test]
857    fn task_names_file_ignores_bare_stems_and_trivia() {
858        // A bare stem mention must not match the file.
859        assert!(!task_names_file(
860            Some("improve the parser"),
861            "src/parser.rs"
862        ));
863        assert!(!task_names_file(None, "src/parser.rs"));
864        // Trivial / extension-less basenames are excluded.
865        assert!(!task_names_file(Some("touch a.c"), "a.c"));
866        assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
867    }
868
869    #[test]
870    fn task_suspect_file_overrides_intent() {
871        let _lock = crate::core::data_dir::test_env_lock();
872        let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
873        let _ = std::fs::create_dir_all(&dir);
874        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
875
876        // An explore-style task that would otherwise map (cf.
877        // intent_explore_returns_map) — but it names the file, so the suspect
878        // guard keeps the full body for localization.
879        let ctx = AutoModeContext {
880            path: "large.rs",
881            token_count: 5000,
882            line_count: None,
883            task: Some("how does large.rs build the cache?"),
884            cache: None,
885        };
886        let result = resolve(&ctx);
887        assert_eq!(result.mode, "full");
888        assert_eq!(result.source, "task_suspect_file");
889
890        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
891        let _ = std::fs::remove_dir_all(&dir);
892    }
893
894    #[test]
895    fn heuristic_medium_code_uses_signatures_by_default() {
896        assert_eq!(heuristic_mode("rs", 1500, false), "signatures");
897        assert_eq!(heuristic_mode("ts", 1000, false), "signatures");
898    }
899
900    #[test]
901    fn heuristic_structure_first_maps_medium_code() {
902        // Structure-first: medium code becomes `map` on a cold read.
903        assert_eq!(heuristic_mode("rs", 1500, true), "map");
904        assert_eq!(heuristic_mode("c", 800, true), "map");
905    }
906
907    #[test]
908    fn heuristic_structure_first_keeps_tiny_and_prose_full() {
909        // Below the 500-token floor `full` is already best.
910        assert_eq!(heuristic_mode("rs", 400, true), "full");
911        // Prose / markup now gets `aggressive` above 2000 tokens.
912        assert_eq!(heuristic_mode("md", 4000, true), "aggressive");
913        assert_eq!(heuristic_mode("md", 1500, true), "full");
914        assert_eq!(heuristic_mode("txt", 3000, true), "aggressive");
915        assert_eq!(heuristic_mode("txt", 1000, true), "full");
916    }
917
918    #[test]
919    fn code_and_data_extensions_cover_progressive_formats() {
920        for ext in [
921            "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "rb", "swift",
922            "kt", "scala", "toml", "yaml", "yml", "json", "sh", "bash", "svelte", "vue", "astro",
923            "mdx", "njk", "hbs", "ejs", "erb", "jinja", "jinja2", "twig", "pug", "slim", "haml",
924            "liquid",
925        ] {
926            assert!(
927                is_code(ext),
928                "{ext} should participate in structure-first modes"
929            );
930        }
931        // Markdown is prose, not code — must NOT be in is_code().
932        assert!(!is_code("md"));
933    }
934
935    #[test]
936    fn heuristic_large_code_maps_regardless() {
937        assert_eq!(heuristic_mode("rs", 9000, false), "map");
938        assert_eq!(heuristic_mode("rs", 9000, true), "map");
939    }
940
941    /// Bug-fix read pattern: while localizing a planted defect the agent reads
942    /// many medium source files cold. With structure_first the resolver returns
943    /// `map` (cheap, localization-friendly) instead of an un-amortized `full`,
944    /// while every capability guard still takes precedence because it runs
945    /// before this fallback (here: the small-file guard keeps a tiny file full).
946    #[test]
947    fn structure_first_resolve_bugfix_cold_read() {
948        let _lock = crate::core::data_dir::test_env_lock();
949        let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
950        let _ = std::fs::create_dir_all(&dir);
951        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
952        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
953        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
954
955        let suspect = AutoModeContext {
956            path: "src/versioncmp.c",
957            token_count: 1500,
958            line_count: None,
959            task: None,
960            cache: None,
961        };
962        let result = resolve(&suspect);
963        assert!(
964            result.mode == "map" || result.mode == "cognitive",
965            "expected map or cognitive, got: {}",
966            result.mode
967        );
968        assert!(
969            result.source == "structure_first" || result.source.starts_with("science_"),
970            "source: {}",
971            result.source
972        );
973
974        let tiny = AutoModeContext {
975            path: "src/util.c",
976            token_count: 120,
977            line_count: None,
978            task: None,
979            cache: None,
980        };
981        assert_eq!(resolve(&tiny).mode, "full");
982
983        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
984        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
985        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
986        let _ = std::fs::remove_dir_all(&dir);
987    }
988
989    #[test]
990    fn structure_first_off_uses_signatures_for_medium_code() {
991        let _lock = crate::core::data_dir::test_env_lock();
992        let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
993        let _ = std::fs::create_dir_all(&dir);
994        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
995        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
996        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
997
998        let ctx = AutoModeContext {
999            path: "src/versioncmp.c",
1000            token_count: 1500,
1001            line_count: None,
1002            task: None,
1003            cache: None,
1004        };
1005        let result = resolve(&ctx);
1006        assert!(
1007            result.mode == "signatures" || result.mode == "cognitive",
1008            "sf=off → signatures or cognitive, got: {}",
1009            result.mode
1010        );
1011        assert!(
1012            result.source == "heuristic" || result.source.starts_with("science_"),
1013            "source: {}",
1014            result.source
1015        );
1016
1017        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1018        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1019        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1020        let _ = std::fs::remove_dir_all(&dir);
1021    }
1022
1023    /// #683: with learning off (the default), a medium code file with no task
1024    /// resolves through the deterministic size heuristic — never a learning
1025    /// source — and is byte-stable across repeated calls.
1026    #[test]
1027    fn learning_off_by_default_is_deterministic() {
1028        let _lock = crate::core::data_dir::test_env_lock();
1029        let dir = std::env::temp_dir().join(format!("lctx-amr-det-{}", std::process::id()));
1030        let _ = std::fs::create_dir_all(&dir);
1031        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1032        crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
1033        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1034        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1035
1036        let ctx = AutoModeContext {
1037            path: "src/widget.rs",
1038            token_count: 1500,
1039            line_count: None,
1040            task: None,
1041            cache: None,
1042        };
1043        let a = resolve(&ctx);
1044        let b = resolve(&ctx);
1045        assert!(a.mode == "map" || a.mode == "cognitive", "got: {}", a.mode);
1046        assert!(
1047            a.source == "structure_first" || a.source.starts_with("science_"),
1048            "source: {}",
1049            a.source
1050        );
1051        assert_eq!((a.mode, a.source), (b.mode, b.source));
1052
1053        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1054        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1055        let _ = std::fs::remove_dir_all(&dir);
1056    }
1057
1058    /// #683: the `LEAN_CTX_AUTO_MODE_LEARNING` env var gates the adaptive block
1059    /// and wins over the (default-off) config field.
1060    #[test]
1061    fn auto_mode_learning_env_opt_in_is_honored() {
1062        let _lock = crate::core::data_dir::test_env_lock();
1063        crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "1");
1064        assert!(crate::core::config::Config::default().auto_mode_learning_effective());
1065        crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "0");
1066        assert!(!crate::core::config::Config::default().auto_mode_learning_effective());
1067        crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
1068    }
1069
1070    #[test]
1071    fn progressive_small_file_stays_full() {
1072        let _lock = crate::core::data_dir::test_env_lock();
1073        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1074
1075        let ctx = AutoModeContext {
1076            path: "small.rs",
1077            token_count: 200,
1078            line_count: Some(50),
1079            task: None,
1080            cache: None,
1081        };
1082        let result = resolve(&ctx);
1083        assert_eq!(result.mode, "full", "50 lines → full");
1084
1085        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1086    }
1087
1088    #[test]
1089    fn progressive_medium_file_gets_signatures() {
1090        let _lock = crate::core::data_dir::test_env_lock();
1091        let dir = std::env::temp_dir().join(format!("lctx-pd-sig-{}", std::process::id()));
1092        let _ = std::fs::create_dir_all(&dir);
1093        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1094        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1095
1096        let ctx = AutoModeContext {
1097            path: "medium.rs",
1098            token_count: 800,
1099            line_count: Some(200),
1100            task: None,
1101            cache: None,
1102        };
1103        let result = resolve(&ctx);
1104        assert!(
1105            result.mode == "signatures" || result.mode == "cognitive",
1106            "200 lines → signatures or cognitive, got: {}",
1107            result.mode
1108        );
1109        assert!(
1110            result.source == "progressive_signatures" || result.source.starts_with("science_"),
1111            "source: {}",
1112            result.source
1113        );
1114
1115        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1116        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1117        let _ = std::fs::remove_dir_all(&dir);
1118    }
1119
1120    #[test]
1121    fn progressive_large_file_gets_map() {
1122        let _lock = crate::core::data_dir::test_env_lock();
1123        let dir = std::env::temp_dir().join(format!("lctx-pd-map-{}", std::process::id()));
1124        let _ = std::fs::create_dir_all(&dir);
1125        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1126        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1127
1128        let ctx = AutoModeContext {
1129            path: "large.rs",
1130            token_count: 3200,
1131            line_count: Some(800),
1132            task: None,
1133            cache: None,
1134        };
1135        let result = resolve(&ctx);
1136        assert!(
1137            result.mode == "map" || result.mode == "cognitive",
1138            "800 lines → map or cognitive, got: {}",
1139            result.mode
1140        );
1141        assert!(
1142            result.source == "progressive_manifest" || result.source.starts_with("science_"),
1143            "source: {}",
1144            result.source
1145        );
1146
1147        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1148        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1149        let _ = std::fs::remove_dir_all(&dir);
1150    }
1151
1152    #[test]
1153    fn progressive_disabled_skips_tiering() {
1154        let _lock = crate::core::data_dir::test_env_lock();
1155        let dir = std::env::temp_dir().join(format!("lctx-pd-off-{}", std::process::id()));
1156        let _ = std::fs::create_dir_all(&dir);
1157        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1158        crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1159        crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
1160
1161        let ctx = AutoModeContext {
1162            path: "medium.rs",
1163            token_count: 800,
1164            line_count: Some(200),
1165            task: None,
1166            cache: None,
1167        };
1168        let result = resolve(&ctx);
1169        assert!(
1170            result.mode == "signatures" || result.mode == "cognitive",
1171            "progressive off → signatures or cognitive, got: {}",
1172            result.mode
1173        );
1174
1175        crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1176        crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1177        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1178        let _ = std::fs::remove_dir_all(&dir);
1179    }
1180
1181    #[test]
1182    fn estimate_lines_approximation() {
1183        assert_eq!(estimate_lines(400), 100);
1184        assert_eq!(estimate_lines(2000), 500);
1185        assert_eq!(estimate_lines(0), 0);
1186    }
1187}