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
10static 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
23pub 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
42pub 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
75pub 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
103pub 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
117pub 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
130pub fn resolve(ctx: &AutoModeContext) -> ResolvedMode {
133 if crate::core::edit_quality::take_pending_anchored_escalation(ctx.path) {
138 return resolved("anchored", "anchored_edit_fail_escalation");
139 }
140
141 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 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 if cache.is_full_delivered(ctx.path) {
182 return resolved("full", "cache_hit");
183 }
184 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 if crate::core::diagnostics_store::has_error(ctx.path) {
216 return resolved("full", "active_diagnostic");
217 }
218
219 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 if crate::core::config::Config::load().auto_mode_learning_effective()
237 && let Some(r) = resolve_adaptive(ctx)
238 {
239 return r;
240 }
241
242 let cfg = crate::core::config::Config::load();
245 if cfg.progressive_disclosure_effective() {
246 let lines = ctx
247 .line_count
248 .unwrap_or_else(|| estimate_lines(ctx.token_count));
249 let threshold = cfg.progressive_threshold_lines as usize;
250 let sig_max = cfg.progressive_signatures_max as usize;
251
252 if lines >= sig_max && is_code(ext) {
253 return resolved("map", "progressive_manifest");
254 }
255 if lines >= threshold && is_code(ext) {
256 return resolved("signatures", "progressive_signatures");
257 }
258 }
259
260 let structure_first = cfg.structure_first_effective();
266 let heuristic = heuristic_mode(ext, ctx.token_count, structure_first);
267 let source = if structure_first && heuristic == "map" && ctx.token_count <= 6000 {
268 "structure_first"
269 } else {
270 "heuristic"
271 };
272 resolved(&heuristic, source)
273}
274
275fn resolve_adaptive(ctx: &AutoModeContext) -> Option<ResolvedMode> {
281 if let Ok(bt) = crate::core::bounce_tracker::global().lock()
282 && bt.should_force_full(ctx.path)
283 {
284 return Some(resolved("full", "bounce_tracker"));
285 }
286
287 if crate::core::path_mode_memory::should_force_full(ctx.path) {
291 return Some(resolved("full", "path_bounce_memory"));
292 }
293
294 let sig = FileSignature::from_path(ctx.path, ctx.token_count);
295 let predictor = ModePredictor::new();
296 let mut predicted = predictor
297 .predict_best_mode(&sig)
298 .unwrap_or_else(|| "full".to_string());
299 if predicted == "auto" {
300 predicted = "full".to_string();
301 }
302
303 if predicted != "full"
304 && let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count)
305 {
306 predicted = bandit_override;
307 }
308
309 if predicted != "full"
313 && let Some((access_count, avg_ratio)) = crate::core::heatmap::entry_stats(ctx.path)
314 && access_count >= 5
315 && avg_ratio < 0.30
316 {
317 let conservative = match predicted.as_str() {
318 "signatures" | "aggressive" | "entropy" => "map".to_string(),
319 "map" if ctx.token_count <= 6000 => "full".to_string(),
320 other => other.to_string(),
321 };
322 if conservative != predicted {
323 return Some(resolved(&conservative, "heatmap_conservative"));
324 }
325 }
326
327 let request_id = "auto-mode-resolution";
328 let request = ConfigTuningRequest {
329 context: OclaRequestContext {
330 request_id: request_id.to_string(),
331 session_id: "auto-mode".to_string(),
332 agent_id: "lean-ctx".to_string(),
333 content_ref: ctx.path.to_string(),
334 tenant_id: None,
335 trace_id: "tr-unit".into(),
336 },
337 config_ref: predicted.clone(),
338 objective_ref: ctx.task.unwrap_or_default().to_string(),
339 };
340 let chosen = match OclaRegistry::global().config_tuner.propose_tuning(request) {
341 Ok(proposal) => proposal
342 .proposal_ref
343 .strip_prefix(&format!("proposal:{predicted}->"))
344 .and_then(|value| value.strip_suffix(&format!(":{request_id}")))
345 .map_or_else(|| predicted.clone(), ToString::to_string),
346 Err(_) => predicted.clone(),
347 };
348
349 if ctx.token_count > 2000 {
350 if (predicted == "map" || predicted == "signatures")
351 && chosen != "map"
352 && chosen != "signatures"
353 {
354 return Some(resolved(&predicted, "predictor_guard"));
355 }
356 if chosen == "full" && predicted != "full" {
357 return Some(resolved(&predicted, "predictor_override"));
358 }
359 }
360
361 if chosen != predicted {
362 return Some(resolved(&chosen, "adaptive_policy"));
363 }
364
365 if predicted != "full" {
366 return Some(resolved(&predicted, "predictor"));
367 }
368
369 None
370}
371
372pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
375 match action {
376 PressureAction::SuggestCompression => match requested_mode {
377 "auto" | "full" => Some("map".to_string()),
378 _ => None,
379 },
380 PressureAction::ForceCompression => match requested_mode {
381 "full" => Some("map".to_string()),
382 "auto" | "map" => Some("signatures".to_string()),
383 _ => None,
384 },
385 PressureAction::EvictLeastRelevant => match requested_mode {
386 "full" => Some("map".to_string()),
387 "auto" | "map" => Some("signatures".to_string()),
388 "signatures" => Some("reference".to_string()),
389 _ => None,
390 },
391 PressureAction::NoAction => None,
392 }
393}
394
395fn task_names_file(task: Option<&str>, path: &str) -> bool {
402 let Some(task) = task else {
403 return false;
404 };
405 let basename = std::path::Path::new(path)
406 .file_name()
407 .and_then(|n| n.to_str())
408 .unwrap_or("");
409 if basename.len() < 4 || !basename.contains('.') {
410 return false;
411 }
412 task.to_ascii_lowercase()
413 .contains(&basename.to_ascii_lowercase())
414}
415
416fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
417 let task_desc = task?;
418 let classification = crate::core::intent_engine::classify(task_desc);
419 if classification.confidence < 0.4 {
420 return None;
421 }
422 let route = crate::core::intent_engine::route_intent(task_desc, &classification);
423 let mode =
424 crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
425 if mode == "auto" {
426 return None;
427 }
428 Some(mode)
429}
430
431fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
432 let project_root =
433 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
434 let ext = std::path::Path::new(file_path)
435 .extension()
436 .and_then(|e| e.to_str())
437 .unwrap_or("");
438 let bucket = match token_count {
439 0..=2000 => "sm",
440 2001..=10000 => "md",
441 10001..=50000 => "lg",
442 _ => "xl",
443 };
444 let bandit_key = crate::core::bandit::bandit_key("mode", ext, Some(bucket));
445 let mut store = crate::core::bandit::BanditStore::load(&project_root);
446 let bandit = store.get_or_create(&bandit_key);
447 let arm = bandit.choose_arm();
449 if arm.budget_ratio < 0.25 && token_count > 2000 {
450 Some("aggressive".to_string())
451 } else {
452 None
453 }
454}
455
456fn heuristic_mode(ext: &str, token_count: usize, structure_first: bool) -> String {
457 if token_count > 8000 {
458 if is_code(ext) {
459 return "map".to_string();
460 }
461 return "aggressive".to_string();
462 }
463 if token_count > 2000 && is_prose(ext) {
464 return "aggressive".to_string();
465 }
466 if token_count > 6000 && is_code(ext) {
468 return "map".to_string();
469 }
470 if structure_first && token_count > 500 && is_code(ext) {
477 return "map".to_string();
478 }
479 if token_count > 500 && is_code(ext) {
483 return "signatures".to_string();
484 }
485 "full".to_string()
486}
487
488fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
497 let Some(stored_mtime) = cached.stored_mtime else {
498 return false;
499 };
500 let Ok(meta) = std::fs::metadata(path) else {
501 return false;
502 };
503 let Ok(current_mtime) = meta.modified() else {
504 return false;
505 };
506 current_mtime == stored_mtime
507}
508
509fn is_code(ext: &str) -> bool {
510 matches!(
511 ext,
512 "rs" | "ts"
513 | "tsx"
514 | "js"
515 | "jsx"
516 | "py"
517 | "go"
518 | "java"
519 | "c"
520 | "cpp"
521 | "cc"
522 | "h"
523 | "hpp"
524 | "rb"
525 | "cs"
526 | "kt"
527 | "swift"
528 | "php"
529 | "zig"
530 | "ex"
531 | "exs"
532 | "scala"
533 | "sc"
534 | "dart"
535 | "toml"
536 | "yaml"
537 | "yml"
538 | "json"
539 | "sh"
540 | "bash"
541 | "svelte"
542 | "vue"
543 | "astro"
544 | "mdx"
545 | "njk"
546 | "hbs"
547 | "ejs"
548 | "erb"
549 | "jinja"
550 | "jinja2"
551 | "twig"
552 | "pug"
553 | "slim"
554 | "haml"
555 | "liquid"
556 )
557}
558
559fn is_prose(ext: &str) -> bool {
560 matches!(
561 ext,
562 "md" | "mdx" | "txt" | "rst" | "adoc" | "org" | "tex" | "html" | "htm"
563 )
564}
565
566fn estimate_lines(token_count: usize) -> usize {
569 token_count / 4
570}
571
572fn is_config_or_data(ext: &str, path: &str) -> bool {
573 if matches!(ext, "xml" | "ini" | "cfg" | "env") {
574 return true;
575 }
576 let name = std::path::Path::new(path)
577 .file_name()
578 .and_then(|n| n.to_str())
579 .unwrap_or("");
580 matches!(
581 name,
582 "Cargo.toml"
583 | "package.json"
584 | "tsconfig.json"
585 | "Makefile"
586 | "Dockerfile"
587 | "docker-compose.yml"
588 | ".gitignore"
589 | ".env"
590 | "pyproject.toml"
591 | "go.mod"
592 | "build.gradle"
593 | "pom.xml"
594 )
595}
596
597fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
598 count_source(source);
599 ResolvedMode {
600 mode: mode.to_string(),
601 source,
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn mode_precedence_is_explicit_then_configured_then_learned_then_default() {
611 assert_eq!(
612 resolve_mode_precedence(
613 Some("raw".to_string()),
614 Some("map".to_string()),
615 Some("signatures".to_string()),
616 "full",
617 ),
618 "raw"
619 );
620 assert_eq!(
621 resolve_mode_precedence(
622 None,
623 Some("map".to_string()),
624 Some("signatures".to_string()),
625 "full",
626 ),
627 "map"
628 );
629 assert_eq!(
630 resolve_mode_precedence(None, None, Some("signatures".to_string()), "full"),
631 "signatures"
632 );
633 assert_eq!(resolve_mode_precedence(None, None, None, "full"), "full");
634 }
635
636 #[test]
637 fn pressure_suggest_full_to_map() {
638 assert_eq!(
639 pressure_downgrade("full", &PressureAction::SuggestCompression),
640 Some("map".to_string())
641 );
642 }
643
644 #[test]
645 fn pressure_suggest_auto_to_map() {
646 assert_eq!(
647 pressure_downgrade("auto", &PressureAction::SuggestCompression),
648 Some("map".to_string())
649 );
650 }
651
652 #[test]
653 fn pressure_suggest_does_not_touch_signatures() {
654 assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
655 }
656
657 #[test]
658 fn pressure_force_full_to_map() {
659 assert_eq!(
660 pressure_downgrade("full", &PressureAction::ForceCompression),
661 Some("map".to_string())
662 );
663 }
664
665 #[test]
666 fn pressure_force_map_to_signatures() {
667 assert_eq!(
668 pressure_downgrade("map", &PressureAction::ForceCompression),
669 Some("signatures".to_string())
670 );
671 }
672
673 #[test]
674 fn pressure_evict_signatures_to_reference() {
675 assert_eq!(
676 pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
677 Some("reference".to_string())
678 );
679 }
680
681 #[test]
682 fn pressure_noaction_returns_none() {
683 assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
684 }
685
686 #[test]
687 fn flush_sources_merges_additively_into_disk_file() {
688 let _lock = crate::core::data_dir::test_env_lock();
689 let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
690 let _ = std::fs::create_dir_all(&dir);
691 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
692 let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
693
694 count_source("test_flush_alpha");
697 count_source("test_flush_alpha");
698 count_source("test_flush_beta");
699 flush_sources();
700
701 count_source("test_flush_alpha");
702 flush_sources();
703
704 let persisted = persisted_source_counts();
705 let get = |k: &str| {
706 persisted
707 .iter()
708 .find(|(s, _)| s == k)
709 .map_or(0, |(_, n)| *n)
710 };
711 assert_eq!(
712 get("test_flush_alpha"),
713 3,
714 "two flushes must merge additively"
715 );
716 assert_eq!(get("test_flush_beta"), 1);
717
718 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
719 let _ = std::fs::remove_dir_all(&dir);
720 }
721
722 #[test]
723 fn small_file_always_full() {
724 let ctx = AutoModeContext {
725 path: "test.rs",
726 token_count: 100,
727 line_count: None,
728 task: None,
729 cache: None,
730 };
731 let result = resolve(&ctx);
732 assert_eq!(result.mode, "full");
733 assert_eq!(result.source, "small_file");
734 }
735
736 #[test]
737 fn config_file_returns_full() {
738 let ctx = AutoModeContext {
739 path: "config.ini",
740 token_count: 500,
741 line_count: None,
742 task: None,
743 cache: None,
744 };
745 let result = resolve(&ctx);
746 assert_eq!(result.mode, "full");
747 assert_eq!(result.source, "config_data");
748 }
749
750 #[test]
751 fn cached_compressed_only_file_reuses_cached_mode() {
752 let dir = tempfile::tempdir().unwrap();
756 let file = dir.path().join("large.rs");
757 let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
758 std::fs::write(&file, &body).unwrap();
759 let path = file.to_str().unwrap();
760
761 let mut cache = SessionCache::new();
762 cache.store(path, &body);
763 cache.get_mut(path).unwrap().last_mode = "map".to_string();
764
765 let ctx = AutoModeContext {
766 path,
767 token_count: 7000,
768 line_count: None,
769 task: None,
770 cache: Some(&cache),
771 };
772 let result = resolve(&ctx);
773 assert_eq!(result.mode, "map");
774 assert_eq!(result.source, "cache_hit_compressed");
775 }
776
777 #[test]
778 fn cached_full_delivered_file_short_circuits_to_stub() {
779 let dir = tempfile::tempdir().unwrap();
782 let file = dir.path().join("medium.rs");
783 let body = "fn placeholder() { let _ = 1; }\n".repeat(400);
784 std::fs::write(&file, &body).unwrap();
785 let path = file.to_str().unwrap();
786
787 let mut cache = SessionCache::new();
788 cache.store(path, &body);
789 cache.mark_full_delivered(path);
790
791 let ctx = AutoModeContext {
792 path,
793 token_count: 3000,
794 line_count: None,
795 task: None,
796 cache: Some(&cache),
797 };
798 let result = resolve(&ctx);
799 assert_eq!(result.mode, "full");
800 assert_eq!(result.source, "cache_hit");
801 }
802
803 #[test]
804 fn intent_explore_returns_map() {
805 let ctx = AutoModeContext {
806 path: "large.rs",
807 token_count: 5000,
808 line_count: None,
809 task: Some("how does the cache work?"),
810 cache: None,
811 };
812 let result = resolve(&ctx);
813 assert_eq!(result.mode, "map");
814 assert_eq!(result.source, "intent");
815 }
816
817 #[test]
818 fn task_names_file_matches_explicit_filename() {
819 assert!(task_names_file(
820 Some("fix the version sort in versioncmp.c"),
821 "src/versioncmp.c"
822 ));
823 assert!(task_names_file(
824 Some("why does graph.ts loop?"),
825 "web/src/graph.ts"
826 ));
827 }
828
829 #[test]
830 fn task_names_file_ignores_bare_stems_and_trivia() {
831 assert!(!task_names_file(
833 Some("improve the parser"),
834 "src/parser.rs"
835 ));
836 assert!(!task_names_file(None, "src/parser.rs"));
837 assert!(!task_names_file(Some("touch a.c"), "a.c"));
839 assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
840 }
841
842 #[test]
843 fn task_suspect_file_overrides_intent() {
844 let _lock = crate::core::data_dir::test_env_lock();
845 let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
846 let _ = std::fs::create_dir_all(&dir);
847 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
848
849 let ctx = AutoModeContext {
853 path: "large.rs",
854 token_count: 5000,
855 line_count: None,
856 task: Some("how does large.rs build the cache?"),
857 cache: None,
858 };
859 let result = resolve(&ctx);
860 assert_eq!(result.mode, "full");
861 assert_eq!(result.source, "task_suspect_file");
862
863 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
864 let _ = std::fs::remove_dir_all(&dir);
865 }
866
867 #[test]
868 fn heuristic_medium_code_uses_signatures_by_default() {
869 assert_eq!(heuristic_mode("rs", 1500, false), "signatures");
870 assert_eq!(heuristic_mode("ts", 1000, false), "signatures");
871 }
872
873 #[test]
874 fn heuristic_structure_first_maps_medium_code() {
875 assert_eq!(heuristic_mode("rs", 1500, true), "map");
877 assert_eq!(heuristic_mode("c", 800, true), "map");
878 }
879
880 #[test]
881 fn heuristic_structure_first_keeps_tiny_and_prose_full() {
882 assert_eq!(heuristic_mode("rs", 400, true), "full");
884 assert_eq!(heuristic_mode("md", 4000, true), "aggressive");
886 assert_eq!(heuristic_mode("md", 1500, true), "full");
887 assert_eq!(heuristic_mode("txt", 3000, true), "aggressive");
888 assert_eq!(heuristic_mode("txt", 1000, true), "full");
889 }
890
891 #[test]
892 fn code_and_data_extensions_cover_progressive_formats() {
893 for ext in [
894 "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "rb", "swift",
895 "kt", "scala", "toml", "yaml", "yml", "json", "sh", "bash", "svelte", "vue", "astro",
896 "mdx", "njk", "hbs", "ejs", "erb", "jinja", "jinja2", "twig", "pug", "slim", "haml",
897 "liquid",
898 ] {
899 assert!(
900 is_code(ext),
901 "{ext} should participate in structure-first modes"
902 );
903 }
904 assert!(!is_code("md"));
906 }
907
908 #[test]
909 fn heuristic_large_code_maps_regardless() {
910 assert_eq!(heuristic_mode("rs", 9000, false), "map");
911 assert_eq!(heuristic_mode("rs", 9000, true), "map");
912 }
913
914 #[test]
920 fn structure_first_resolve_bugfix_cold_read() {
921 let _lock = crate::core::data_dir::test_env_lock();
922 let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
923 let _ = std::fs::create_dir_all(&dir);
924 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
925 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
926 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
927
928 let suspect = AutoModeContext {
929 path: "src/versioncmp.c",
930 token_count: 1500,
931 line_count: None,
932 task: None,
933 cache: None,
934 };
935 let result = resolve(&suspect);
936 assert_eq!(result.mode, "map");
937 assert_eq!(result.source, "structure_first");
938
939 let tiny = AutoModeContext {
940 path: "src/util.c",
941 token_count: 120,
942 line_count: None,
943 task: None,
944 cache: None,
945 };
946 assert_eq!(resolve(&tiny).mode, "full");
947
948 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
949 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
950 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
951 let _ = std::fs::remove_dir_all(&dir);
952 }
953
954 #[test]
955 fn structure_first_off_uses_signatures_for_medium_code() {
956 let _lock = crate::core::data_dir::test_env_lock();
957 let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
958 let _ = std::fs::create_dir_all(&dir);
959 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
960 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
961 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
962
963 let ctx = AutoModeContext {
964 path: "src/versioncmp.c",
965 token_count: 1500,
966 line_count: None,
967 task: None,
968 cache: None,
969 };
970 let result = resolve(&ctx);
971 assert_eq!(result.mode, "signatures");
972 assert_eq!(result.source, "heuristic");
973
974 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
975 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
976 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
977 let _ = std::fs::remove_dir_all(&dir);
978 }
979
980 #[test]
984 fn learning_off_by_default_is_deterministic() {
985 let _lock = crate::core::data_dir::test_env_lock();
986 let dir = std::env::temp_dir().join(format!("lctx-amr-det-{}", std::process::id()));
987 let _ = std::fs::create_dir_all(&dir);
988 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
989 crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
990 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
991 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
992
993 let ctx = AutoModeContext {
994 path: "src/widget.rs",
995 token_count: 1500,
996 line_count: None,
997 task: None,
998 cache: None,
999 };
1000 let a = resolve(&ctx);
1001 let b = resolve(&ctx);
1002 assert_eq!(a.mode, "map");
1003 assert_eq!(a.source, "structure_first");
1004 assert_eq!((a.mode, a.source), (b.mode, b.source));
1005
1006 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1007 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1008 let _ = std::fs::remove_dir_all(&dir);
1009 }
1010
1011 #[test]
1014 fn auto_mode_learning_env_opt_in_is_honored() {
1015 let _lock = crate::core::data_dir::test_env_lock();
1016 crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "1");
1017 assert!(crate::core::config::Config::default().auto_mode_learning_effective());
1018 crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "0");
1019 assert!(!crate::core::config::Config::default().auto_mode_learning_effective());
1020 crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
1021 }
1022
1023 #[test]
1024 fn progressive_small_file_stays_full() {
1025 let _lock = crate::core::data_dir::test_env_lock();
1026 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1027
1028 let ctx = AutoModeContext {
1029 path: "small.rs",
1030 token_count: 200,
1031 line_count: Some(50),
1032 task: None,
1033 cache: None,
1034 };
1035 let result = resolve(&ctx);
1036 assert_eq!(result.mode, "full", "50 lines → full");
1037
1038 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1039 }
1040
1041 #[test]
1042 fn progressive_medium_file_gets_signatures() {
1043 let _lock = crate::core::data_dir::test_env_lock();
1044 let dir = std::env::temp_dir().join(format!("lctx-pd-sig-{}", std::process::id()));
1045 let _ = std::fs::create_dir_all(&dir);
1046 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1047 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1048
1049 let ctx = AutoModeContext {
1050 path: "medium.rs",
1051 token_count: 800,
1052 line_count: Some(200),
1053 task: None,
1054 cache: None,
1055 };
1056 let result = resolve(&ctx);
1057 assert_eq!(result.mode, "signatures", "200 lines → signatures");
1058 assert_eq!(result.source, "progressive_signatures");
1059
1060 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1061 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1062 let _ = std::fs::remove_dir_all(&dir);
1063 }
1064
1065 #[test]
1066 fn progressive_large_file_gets_map() {
1067 let _lock = crate::core::data_dir::test_env_lock();
1068 let dir = std::env::temp_dir().join(format!("lctx-pd-map-{}", std::process::id()));
1069 let _ = std::fs::create_dir_all(&dir);
1070 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1071 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1072
1073 let ctx = AutoModeContext {
1074 path: "large.rs",
1075 token_count: 3200,
1076 line_count: Some(800),
1077 task: None,
1078 cache: None,
1079 };
1080 let result = resolve(&ctx);
1081 assert_eq!(result.mode, "map", "800 lines → map (manifest)");
1082 assert_eq!(result.source, "progressive_manifest");
1083
1084 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1085 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1086 let _ = std::fs::remove_dir_all(&dir);
1087 }
1088
1089 #[test]
1090 fn progressive_disabled_skips_tiering() {
1091 let _lock = crate::core::data_dir::test_env_lock();
1092 let dir = std::env::temp_dir().join(format!("lctx-pd-off-{}", std::process::id()));
1093 let _ = std::fs::create_dir_all(&dir);
1094 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1095 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1096 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
1097
1098 let ctx = AutoModeContext {
1099 path: "medium.rs",
1100 token_count: 800,
1101 line_count: Some(200),
1102 task: None,
1103 cache: None,
1104 };
1105 let result = resolve(&ctx);
1106 assert_eq!(
1107 result.mode, "signatures",
1108 "progressive off → heuristic uses signatures for medium code"
1109 );
1110
1111 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1112 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1113 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1114 let _ = std::fs::remove_dir_all(&dir);
1115 }
1116
1117 #[test]
1118 fn estimate_lines_approximation() {
1119 assert_eq!(estimate_lines(400), 100);
1120 assert_eq!(estimate_lines(2000), 500);
1121 assert_eq!(estimate_lines(0), 0);
1122 }
1123}