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) {
484 return "signatures".to_string();
485 }
486 "full".to_string()
487}
488
489fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
498 let Some(stored_mtime) = cached.stored_mtime else {
499 return false;
500 };
501 let Ok(meta) = std::fs::metadata(path) else {
502 return false;
503 };
504 let Ok(current_mtime) = meta.modified() else {
505 return false;
506 };
507 current_mtime == stored_mtime
508}
509
510fn is_code(ext: &str) -> bool {
511 matches!(
512 ext,
513 "rs" | "ts"
514 | "tsx"
515 | "js"
516 | "jsx"
517 | "py"
518 | "go"
519 | "java"
520 | "c"
521 | "cpp"
522 | "cc"
523 | "h"
524 | "hpp"
525 | "rb"
526 | "cs"
527 | "kt"
528 | "swift"
529 | "php"
530 | "zig"
531 | "ex"
532 | "exs"
533 | "scala"
534 | "sc"
535 | "dart"
536 | "toml"
537 | "yaml"
538 | "yml"
539 | "json"
540 | "sh"
541 | "bash"
542 | "svelte"
543 | "vue"
544 | "astro"
545 | "mdx"
546 | "njk"
547 | "hbs"
548 | "ejs"
549 | "erb"
550 | "jinja"
551 | "jinja2"
552 | "twig"
553 | "pug"
554 | "slim"
555 | "haml"
556 | "liquid"
557 )
558}
559
560fn is_prose(ext: &str) -> bool {
561 matches!(
562 ext,
563 "md" | "mdx" | "txt" | "rst" | "adoc" | "org" | "tex" | "html" | "htm"
564 )
565}
566
567fn estimate_lines(token_count: usize) -> usize {
570 token_count / 4
571}
572
573fn is_config_or_data(ext: &str, path: &str) -> bool {
574 if matches!(ext, "xml" | "ini" | "cfg" | "env") {
575 return true;
576 }
577 let name = std::path::Path::new(path)
578 .file_name()
579 .and_then(|n| n.to_str())
580 .unwrap_or("");
581 matches!(
582 name,
583 "Cargo.toml"
584 | "package.json"
585 | "tsconfig.json"
586 | "Makefile"
587 | "Dockerfile"
588 | "docker-compose.yml"
589 | ".gitignore"
590 | ".env"
591 | "pyproject.toml"
592 | "go.mod"
593 | "build.gradle"
594 | "pom.xml"
595 )
596}
597
598fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
599 count_source(source);
600 ResolvedMode {
601 mode: mode.to_string(),
602 source,
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn mode_precedence_is_explicit_then_configured_then_learned_then_default() {
612 assert_eq!(
613 resolve_mode_precedence(
614 Some("raw".to_string()),
615 Some("map".to_string()),
616 Some("signatures".to_string()),
617 "full",
618 ),
619 "raw"
620 );
621 assert_eq!(
622 resolve_mode_precedence(
623 None,
624 Some("map".to_string()),
625 Some("signatures".to_string()),
626 "full",
627 ),
628 "map"
629 );
630 assert_eq!(
631 resolve_mode_precedence(None, None, Some("signatures".to_string()), "full"),
632 "signatures"
633 );
634 assert_eq!(resolve_mode_precedence(None, None, None, "full"), "full");
635 }
636
637 #[test]
638 fn pressure_suggest_full_to_map() {
639 assert_eq!(
640 pressure_downgrade("full", &PressureAction::SuggestCompression),
641 Some("map".to_string())
642 );
643 }
644
645 #[test]
646 fn pressure_suggest_auto_to_map() {
647 assert_eq!(
648 pressure_downgrade("auto", &PressureAction::SuggestCompression),
649 Some("map".to_string())
650 );
651 }
652
653 #[test]
654 fn pressure_suggest_does_not_touch_signatures() {
655 assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
656 }
657
658 #[test]
659 fn pressure_force_full_to_map() {
660 assert_eq!(
661 pressure_downgrade("full", &PressureAction::ForceCompression),
662 Some("map".to_string())
663 );
664 }
665
666 #[test]
667 fn pressure_force_map_to_signatures() {
668 assert_eq!(
669 pressure_downgrade("map", &PressureAction::ForceCompression),
670 Some("signatures".to_string())
671 );
672 }
673
674 #[test]
675 fn pressure_evict_signatures_to_reference() {
676 assert_eq!(
677 pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
678 Some("reference".to_string())
679 );
680 }
681
682 #[test]
683 fn pressure_noaction_returns_none() {
684 assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
685 }
686
687 #[test]
688 fn flush_sources_merges_additively_into_disk_file() {
689 let _lock = crate::core::data_dir::test_env_lock();
690 let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
691 let _ = std::fs::create_dir_all(&dir);
692 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
693 let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
694
695 count_source("test_flush_alpha");
698 count_source("test_flush_alpha");
699 count_source("test_flush_beta");
700 flush_sources();
701
702 count_source("test_flush_alpha");
703 flush_sources();
704
705 let persisted = persisted_source_counts();
706 let get = |k: &str| {
707 persisted
708 .iter()
709 .find(|(s, _)| s == k)
710 .map_or(0, |(_, n)| *n)
711 };
712 assert_eq!(
713 get("test_flush_alpha"),
714 3,
715 "two flushes must merge additively"
716 );
717 assert_eq!(get("test_flush_beta"), 1);
718
719 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
720 let _ = std::fs::remove_dir_all(&dir);
721 }
722
723 #[test]
724 fn small_file_always_full() {
725 let ctx = AutoModeContext {
726 path: "test.rs",
727 token_count: 100,
728 line_count: None,
729 task: None,
730 cache: None,
731 };
732 let result = resolve(&ctx);
733 assert_eq!(result.mode, "full");
734 assert_eq!(result.source, "small_file");
735 }
736
737 #[test]
738 fn config_file_returns_full() {
739 let ctx = AutoModeContext {
740 path: "config.ini",
741 token_count: 500,
742 line_count: None,
743 task: None,
744 cache: None,
745 };
746 let result = resolve(&ctx);
747 assert_eq!(result.mode, "full");
748 assert_eq!(result.source, "config_data");
749 }
750
751 #[test]
752 fn cached_compressed_only_file_reuses_cached_mode() {
753 let dir = tempfile::tempdir().unwrap();
757 let file = dir.path().join("large.rs");
758 let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
759 std::fs::write(&file, &body).unwrap();
760 let path = file.to_str().unwrap();
761
762 let mut cache = SessionCache::new();
763 cache.store(path, &body);
764 cache.get_mut(path).unwrap().last_mode = "map".to_string();
765
766 let ctx = AutoModeContext {
767 path,
768 token_count: 7000,
769 line_count: None,
770 task: None,
771 cache: Some(&cache),
772 };
773 let result = resolve(&ctx);
774 assert_eq!(result.mode, "map");
775 assert_eq!(result.source, "cache_hit_compressed");
776 }
777
778 #[test]
779 fn cached_full_delivered_file_short_circuits_to_stub() {
780 let dir = tempfile::tempdir().unwrap();
783 let file = dir.path().join("medium.rs");
784 let body = "fn placeholder() { let _ = 1; }\n".repeat(400);
785 std::fs::write(&file, &body).unwrap();
786 let path = file.to_str().unwrap();
787
788 let mut cache = SessionCache::new();
789 cache.store(path, &body);
790 cache.mark_full_delivered(path);
791
792 let ctx = AutoModeContext {
793 path,
794 token_count: 3000,
795 line_count: None,
796 task: None,
797 cache: Some(&cache),
798 };
799 let result = resolve(&ctx);
800 assert_eq!(result.mode, "full");
801 assert_eq!(result.source, "cache_hit");
802 }
803
804 #[test]
805 fn intent_explore_returns_map() {
806 let ctx = AutoModeContext {
807 path: "large.rs",
808 token_count: 5000,
809 line_count: None,
810 task: Some("how does the cache work?"),
811 cache: None,
812 };
813 let result = resolve(&ctx);
814 assert_eq!(result.mode, "map");
815 assert_eq!(result.source, "intent");
816 }
817
818 #[test]
819 fn task_names_file_matches_explicit_filename() {
820 assert!(task_names_file(
821 Some("fix the version sort in versioncmp.c"),
822 "src/versioncmp.c"
823 ));
824 assert!(task_names_file(
825 Some("why does graph.ts loop?"),
826 "web/src/graph.ts"
827 ));
828 }
829
830 #[test]
831 fn task_names_file_ignores_bare_stems_and_trivia() {
832 assert!(!task_names_file(
834 Some("improve the parser"),
835 "src/parser.rs"
836 ));
837 assert!(!task_names_file(None, "src/parser.rs"));
838 assert!(!task_names_file(Some("touch a.c"), "a.c"));
840 assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
841 }
842
843 #[test]
844 fn task_suspect_file_overrides_intent() {
845 let _lock = crate::core::data_dir::test_env_lock();
846 let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
847 let _ = std::fs::create_dir_all(&dir);
848 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
849
850 let ctx = AutoModeContext {
854 path: "large.rs",
855 token_count: 5000,
856 line_count: None,
857 task: Some("how does large.rs build the cache?"),
858 cache: None,
859 };
860 let result = resolve(&ctx);
861 assert_eq!(result.mode, "full");
862 assert_eq!(result.source, "task_suspect_file");
863
864 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
865 let _ = std::fs::remove_dir_all(&dir);
866 }
867
868 #[test]
869 fn heuristic_medium_code_uses_signatures_by_default() {
870 assert_eq!(heuristic_mode("rs", 1500, false), "signatures");
871 assert_eq!(heuristic_mode("ts", 1000, false), "signatures");
872 }
873
874 #[test]
875 fn heuristic_structure_first_maps_medium_code() {
876 assert_eq!(heuristic_mode("rs", 1500, true), "map");
878 assert_eq!(heuristic_mode("c", 800, true), "map");
879 }
880
881 #[test]
882 fn heuristic_structure_first_keeps_tiny_and_prose_full() {
883 assert_eq!(heuristic_mode("rs", 400, true), "full");
885 assert_eq!(heuristic_mode("md", 4000, true), "aggressive");
887 assert_eq!(heuristic_mode("md", 1500, true), "full");
888 assert_eq!(heuristic_mode("txt", 3000, true), "aggressive");
889 assert_eq!(heuristic_mode("txt", 1000, true), "full");
890 }
891
892 #[test]
893 fn code_and_data_extensions_cover_progressive_formats() {
894 for ext in [
895 "rs", "py", "ts", "tsx", "js", "jsx", "go", "java", "c", "cpp", "h", "rb", "swift",
896 "kt", "scala", "toml", "yaml", "yml", "json", "sh", "bash", "svelte", "vue", "astro",
897 "mdx", "njk", "hbs", "ejs", "erb", "jinja", "jinja2", "twig", "pug", "slim", "haml",
898 "liquid",
899 ] {
900 assert!(
901 is_code(ext),
902 "{ext} should participate in structure-first modes"
903 );
904 }
905 assert!(!is_code("md"));
907 }
908
909 #[test]
910 fn heuristic_large_code_maps_regardless() {
911 assert_eq!(heuristic_mode("rs", 9000, false), "map");
912 assert_eq!(heuristic_mode("rs", 9000, true), "map");
913 }
914
915 #[test]
921 fn structure_first_resolve_bugfix_cold_read() {
922 let _lock = crate::core::data_dir::test_env_lock();
923 let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
924 let _ = std::fs::create_dir_all(&dir);
925 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
926 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
927 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
928
929 let suspect = AutoModeContext {
930 path: "src/versioncmp.c",
931 token_count: 1500,
932 line_count: None,
933 task: None,
934 cache: None,
935 };
936 let result = resolve(&suspect);
937 assert_eq!(result.mode, "map");
938 assert_eq!(result.source, "structure_first");
939
940 let tiny = AutoModeContext {
941 path: "src/util.c",
942 token_count: 120,
943 line_count: None,
944 task: None,
945 cache: None,
946 };
947 assert_eq!(resolve(&tiny).mode, "full");
948
949 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
950 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
951 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
952 let _ = std::fs::remove_dir_all(&dir);
953 }
954
955 #[test]
956 fn structure_first_off_uses_signatures_for_medium_code() {
957 let _lock = crate::core::data_dir::test_env_lock();
958 let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
959 let _ = std::fs::create_dir_all(&dir);
960 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
961 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
962 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
963
964 let ctx = AutoModeContext {
965 path: "src/versioncmp.c",
966 token_count: 1500,
967 line_count: None,
968 task: None,
969 cache: None,
970 };
971 let result = resolve(&ctx);
972 assert_eq!(result.mode, "signatures");
973 assert_eq!(result.source, "heuristic");
974
975 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
976 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
977 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
978 let _ = std::fs::remove_dir_all(&dir);
979 }
980
981 #[test]
985 fn learning_off_by_default_is_deterministic() {
986 let _lock = crate::core::data_dir::test_env_lock();
987 let dir = std::env::temp_dir().join(format!("lctx-amr-det-{}", std::process::id()));
988 let _ = std::fs::create_dir_all(&dir);
989 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
990 crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
991 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
992 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
993
994 let ctx = AutoModeContext {
995 path: "src/widget.rs",
996 token_count: 1500,
997 line_count: None,
998 task: None,
999 cache: None,
1000 };
1001 let a = resolve(&ctx);
1002 let b = resolve(&ctx);
1003 assert_eq!(a.mode, "map");
1004 assert_eq!(a.source, "structure_first");
1005 assert_eq!((a.mode, a.source), (b.mode, b.source));
1006
1007 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1008 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1009 let _ = std::fs::remove_dir_all(&dir);
1010 }
1011
1012 #[test]
1015 fn auto_mode_learning_env_opt_in_is_honored() {
1016 let _lock = crate::core::data_dir::test_env_lock();
1017 crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "1");
1018 assert!(crate::core::config::Config::default().auto_mode_learning_effective());
1019 crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "0");
1020 assert!(!crate::core::config::Config::default().auto_mode_learning_effective());
1021 crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
1022 }
1023
1024 #[test]
1025 fn progressive_small_file_stays_full() {
1026 let _lock = crate::core::data_dir::test_env_lock();
1027 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1028
1029 let ctx = AutoModeContext {
1030 path: "small.rs",
1031 token_count: 200,
1032 line_count: Some(50),
1033 task: None,
1034 cache: None,
1035 };
1036 let result = resolve(&ctx);
1037 assert_eq!(result.mode, "full", "50 lines → full");
1038
1039 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1040 }
1041
1042 #[test]
1043 fn progressive_medium_file_gets_signatures() {
1044 let _lock = crate::core::data_dir::test_env_lock();
1045 let dir = std::env::temp_dir().join(format!("lctx-pd-sig-{}", std::process::id()));
1046 let _ = std::fs::create_dir_all(&dir);
1047 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1048 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1049
1050 let ctx = AutoModeContext {
1051 path: "medium.rs",
1052 token_count: 800,
1053 line_count: Some(200),
1054 task: None,
1055 cache: None,
1056 };
1057 let result = resolve(&ctx);
1058 assert_eq!(result.mode, "signatures", "200 lines → signatures");
1059 assert_eq!(result.source, "progressive_signatures");
1060
1061 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1062 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1063 let _ = std::fs::remove_dir_all(&dir);
1064 }
1065
1066 #[test]
1067 fn progressive_large_file_gets_map() {
1068 let _lock = crate::core::data_dir::test_env_lock();
1069 let dir = std::env::temp_dir().join(format!("lctx-pd-map-{}", std::process::id()));
1070 let _ = std::fs::create_dir_all(&dir);
1071 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1072 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
1073
1074 let ctx = AutoModeContext {
1075 path: "large.rs",
1076 token_count: 3200,
1077 line_count: Some(800),
1078 task: None,
1079 cache: None,
1080 };
1081 let result = resolve(&ctx);
1082 assert_eq!(result.mode, "map", "800 lines → map (manifest)");
1083 assert_eq!(result.source, "progressive_manifest");
1084
1085 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1086 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1087 let _ = std::fs::remove_dir_all(&dir);
1088 }
1089
1090 #[test]
1091 fn progressive_disabled_skips_tiering() {
1092 let _lock = crate::core::data_dir::test_env_lock();
1093 let dir = std::env::temp_dir().join(format!("lctx-pd-off-{}", std::process::id()));
1094 let _ = std::fs::create_dir_all(&dir);
1095 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
1096 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
1097 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
1098
1099 let ctx = AutoModeContext {
1100 path: "medium.rs",
1101 token_count: 800,
1102 line_count: Some(200),
1103 task: None,
1104 cache: None,
1105 };
1106 let result = resolve(&ctx);
1107 assert_eq!(
1108 result.mode, "signatures",
1109 "progressive off → heuristic uses signatures for medium code"
1110 );
1111
1112 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1113 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1114 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1115 let _ = std::fs::remove_dir_all(&dir);
1116 }
1117
1118 #[test]
1119 fn estimate_lines_approximation() {
1120 assert_eq!(estimate_lines(400), 100);
1121 assert_eq!(estimate_lines(2000), 500);
1122 assert_eq!(estimate_lines(0), 0);
1123 }
1124}