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
14fn count_source(source: &'static str) {
15 if let Ok(mut guard) = SOURCE_COUNTS.lock() {
16 *guard
17 .get_or_insert_with(HashMap::new)
18 .entry(source)
19 .or_insert(0) += 1;
20 }
21}
22
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(ctx: &AutoModeContext) -> ResolvedMode {
106 if crate::core::edit_quality::take_pending_anchored_escalation(ctx.path) {
111 return resolved("anchored", "anchored_edit_fail_escalation");
112 }
113
114 if crate::core::edit_quality::take_pending_escalation(ctx.path) {
117 return resolved("full", "edit_fail_escalation");
118 }
119
120 let r = resolve_inner(ctx);
121
122 if r.mode != "full" && crate::core::edit_quality::is_risky_mode(ctx.path, &r.mode) {
125 return resolved("full", "edit_quality_penalty");
126 }
127 r
128}
129
130fn resolve_inner(ctx: &AutoModeContext) -> ResolvedMode {
131 if crate::tools::ctx_read::is_instruction_file(ctx.path) {
132 return resolved("full", "instruction_file");
133 }
134
135 if crate::core::binary_detect::is_binary_file(ctx.path) {
136 return resolved("full", "binary");
137 }
138
139 if let Some(cache) = ctx.cache
140 && let Some(cached) = cache.get(ctx.path)
141 {
142 if !file_unchanged(ctx.path, cached) {
143 return resolved("diff", "cache_changed");
144 }
145 if cache.is_full_delivered(ctx.path) {
157 return resolved("full", "cache_hit");
158 }
159 }
160
161 if ctx.token_count <= 200 {
162 return resolved("full", "small_file");
163 }
164
165 let ext = std::path::Path::new(ctx.path)
166 .extension()
167 .and_then(|e| e.to_str())
168 .unwrap_or("");
169
170 if is_config_or_data(ext, ctx.path) {
171 return resolved("full", "config_data");
172 }
173
174 if crate::core::diagnostics_store::has_error(ctx.path) {
177 return resolved("full", "active_diagnostic");
178 }
179
180 if task_names_file(ctx.task, ctx.path) {
185 return resolved("full", "task_suspect_file");
186 }
187
188 if let Some(mode) = intent_recommended_mode(ctx.task) {
189 return resolved(&mode, "intent");
190 }
191
192 if crate::core::config::Config::load().auto_mode_learning_effective()
198 && let Some(r) = resolve_adaptive(ctx)
199 {
200 return r;
201 }
202
203 let cfg = crate::core::config::Config::load();
206 if cfg.progressive_disclosure_effective() {
207 let lines = ctx
208 .line_count
209 .unwrap_or_else(|| estimate_lines(ctx.token_count));
210 let threshold = cfg.progressive_threshold_lines as usize;
211 let sig_max = cfg.progressive_signatures_max as usize;
212
213 if lines >= sig_max && is_code(ext) {
214 return resolved("map", "progressive_manifest");
215 }
216 if lines >= threshold && is_code(ext) {
217 return resolved("signatures", "progressive_signatures");
218 }
219 }
220
221 let structure_first = cfg.structure_first_effective();
227 let heuristic = heuristic_mode(ext, ctx.token_count, structure_first);
228 let source = if structure_first && heuristic == "map" && ctx.token_count <= 6000 {
229 "structure_first"
230 } else {
231 "heuristic"
232 };
233 resolved(&heuristic, source)
234}
235
236fn resolve_adaptive(ctx: &AutoModeContext) -> Option<ResolvedMode> {
242 if let Ok(bt) = crate::core::bounce_tracker::global().lock()
243 && bt.should_force_full(ctx.path)
244 {
245 return Some(resolved("full", "bounce_tracker"));
246 }
247
248 if crate::core::path_mode_memory::should_force_full(ctx.path) {
252 return Some(resolved("full", "path_bounce_memory"));
253 }
254
255 let sig = FileSignature::from_path(ctx.path, ctx.token_count);
256 let predictor = ModePredictor::new();
257 let mut predicted = predictor
258 .predict_best_mode(&sig)
259 .unwrap_or_else(|| "full".to_string());
260 if predicted == "auto" {
261 predicted = "full".to_string();
262 }
263
264 if predicted != "full"
265 && let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count)
266 {
267 predicted = bandit_override;
268 }
269
270 if predicted != "full"
274 && let Some((access_count, avg_ratio)) = crate::core::heatmap::entry_stats(ctx.path)
275 && access_count >= 5
276 && avg_ratio < 0.30
277 {
278 let conservative = match predicted.as_str() {
279 "signatures" | "aggressive" | "entropy" => "map".to_string(),
280 "map" if ctx.token_count <= 6000 => "full".to_string(),
281 other => other.to_string(),
282 };
283 if conservative != predicted {
284 return Some(resolved(&conservative, "heatmap_conservative"));
285 }
286 }
287
288 let request_id = "auto-mode-resolution";
289 let request = ConfigTuningRequest {
290 context: OclaRequestContext {
291 request_id: request_id.to_string(),
292 session_id: "auto-mode".to_string(),
293 agent_id: "lean-ctx".to_string(),
294 content_ref: ctx.path.to_string(),
295 tenant_id: None,
296 trace_id: "tr-unit".into(),
297 },
298 config_ref: predicted.clone(),
299 objective_ref: ctx.task.unwrap_or_default().to_string(),
300 };
301 let chosen = match OclaRegistry::global().config_tuner.propose_tuning(request) {
302 Ok(proposal) => proposal
303 .proposal_ref
304 .strip_prefix(&format!("proposal:{predicted}->"))
305 .and_then(|value| value.strip_suffix(&format!(":{request_id}")))
306 .map_or_else(|| predicted.clone(), ToString::to_string),
307 Err(_) => predicted.clone(),
308 };
309
310 if ctx.token_count > 2000 {
311 if (predicted == "map" || predicted == "signatures")
312 && chosen != "map"
313 && chosen != "signatures"
314 {
315 return Some(resolved(&predicted, "predictor_guard"));
316 }
317 if chosen == "full" && predicted != "full" {
318 return Some(resolved(&predicted, "predictor_override"));
319 }
320 }
321
322 if chosen != predicted {
323 return Some(resolved(&chosen, "adaptive_policy"));
324 }
325
326 if predicted != "full" {
327 return Some(resolved(&predicted, "predictor"));
328 }
329
330 None
331}
332
333pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
336 match action {
337 PressureAction::SuggestCompression => match requested_mode {
338 "auto" | "full" => Some("map".to_string()),
339 _ => None,
340 },
341 PressureAction::ForceCompression => match requested_mode {
342 "full" => Some("map".to_string()),
343 "auto" | "map" => Some("signatures".to_string()),
344 _ => None,
345 },
346 PressureAction::EvictLeastRelevant => match requested_mode {
347 "full" => Some("map".to_string()),
348 "auto" | "map" => Some("signatures".to_string()),
349 "signatures" => Some("reference".to_string()),
350 _ => None,
351 },
352 PressureAction::NoAction => None,
353 }
354}
355
356fn task_names_file(task: Option<&str>, path: &str) -> bool {
363 let Some(task) = task else {
364 return false;
365 };
366 let basename = std::path::Path::new(path)
367 .file_name()
368 .and_then(|n| n.to_str())
369 .unwrap_or("");
370 if basename.len() < 4 || !basename.contains('.') {
371 return false;
372 }
373 task.to_ascii_lowercase()
374 .contains(&basename.to_ascii_lowercase())
375}
376
377fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
378 let task_desc = task?;
379 let classification = crate::core::intent_engine::classify(task_desc);
380 if classification.confidence < 0.4 {
381 return None;
382 }
383 let route = crate::core::intent_engine::route_intent(task_desc, &classification);
384 let mode =
385 crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
386 if mode == "auto" {
387 return None;
388 }
389 Some(mode)
390}
391
392fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
393 let project_root =
394 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
395 let ext = std::path::Path::new(file_path)
396 .extension()
397 .and_then(|e| e.to_str())
398 .unwrap_or("");
399 let bucket = match token_count {
400 0..=2000 => "sm",
401 2001..=10000 => "md",
402 10001..=50000 => "lg",
403 _ => "xl",
404 };
405 let bandit_key = crate::core::bandit::bandit_key("mode", ext, Some(bucket));
406 let mut store = crate::core::bandit::BanditStore::load(&project_root);
407 let bandit = store.get_or_create(&bandit_key);
408 let arm = bandit.choose_arm();
410 if arm.budget_ratio < 0.25 && token_count > 2000 {
411 Some("aggressive".to_string())
412 } else {
413 None
414 }
415}
416
417fn heuristic_mode(ext: &str, token_count: usize, structure_first: bool) -> String {
418 if token_count > 8000 {
419 if is_code(ext) {
420 return "map".to_string();
421 }
422 return "aggressive".to_string();
423 }
424 if token_count > 6000 && is_code(ext) {
429 return "map".to_string();
430 }
431 if structure_first && token_count > 500 && is_code(ext) {
438 return "map".to_string();
439 }
440 "full".to_string()
441}
442
443fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
452 let Some(stored_mtime) = cached.stored_mtime else {
453 return false;
454 };
455 let Ok(meta) = std::fs::metadata(path) else {
456 return false;
457 };
458 let Ok(current_mtime) = meta.modified() else {
459 return false;
460 };
461 current_mtime == stored_mtime
462}
463
464fn is_code(ext: &str) -> bool {
465 matches!(
466 ext,
467 "rs" | "ts"
468 | "tsx"
469 | "js"
470 | "jsx"
471 | "py"
472 | "go"
473 | "java"
474 | "c"
475 | "cpp"
476 | "cc"
477 | "h"
478 | "hpp"
479 | "rb"
480 | "cs"
481 | "kt"
482 | "swift"
483 | "php"
484 | "zig"
485 | "ex"
486 | "exs"
487 | "scala"
488 | "sc"
489 | "dart"
490 | "sh"
491 | "bash"
492 | "svelte"
493 | "vue"
494 )
495}
496
497fn estimate_lines(token_count: usize) -> usize {
500 token_count / 4
501}
502
503fn is_config_or_data(ext: &str, path: &str) -> bool {
504 if matches!(ext, "xml" | "ini" | "cfg" | "env") {
505 return true;
506 }
507 let name = std::path::Path::new(path)
508 .file_name()
509 .and_then(|n| n.to_str())
510 .unwrap_or("");
511 matches!(
512 name,
513 "Cargo.toml"
514 | "package.json"
515 | "tsconfig.json"
516 | "Makefile"
517 | "Dockerfile"
518 | "docker-compose.yml"
519 | ".gitignore"
520 | ".env"
521 | "pyproject.toml"
522 | "go.mod"
523 | "build.gradle"
524 | "pom.xml"
525 )
526}
527
528fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
529 count_source(source);
530 ResolvedMode {
531 mode: mode.to_string(),
532 source,
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn pressure_suggest_full_to_map() {
542 assert_eq!(
543 pressure_downgrade("full", &PressureAction::SuggestCompression),
544 Some("map".to_string())
545 );
546 }
547
548 #[test]
549 fn pressure_suggest_auto_to_map() {
550 assert_eq!(
551 pressure_downgrade("auto", &PressureAction::SuggestCompression),
552 Some("map".to_string())
553 );
554 }
555
556 #[test]
557 fn pressure_suggest_does_not_touch_signatures() {
558 assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
559 }
560
561 #[test]
562 fn pressure_force_full_to_map() {
563 assert_eq!(
564 pressure_downgrade("full", &PressureAction::ForceCompression),
565 Some("map".to_string())
566 );
567 }
568
569 #[test]
570 fn pressure_force_map_to_signatures() {
571 assert_eq!(
572 pressure_downgrade("map", &PressureAction::ForceCompression),
573 Some("signatures".to_string())
574 );
575 }
576
577 #[test]
578 fn pressure_evict_signatures_to_reference() {
579 assert_eq!(
580 pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
581 Some("reference".to_string())
582 );
583 }
584
585 #[test]
586 fn pressure_noaction_returns_none() {
587 assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
588 }
589
590 #[test]
591 fn flush_sources_merges_additively_into_disk_file() {
592 let _lock = crate::core::data_dir::test_env_lock();
593 let dir = std::env::temp_dir().join(format!("lctx-amr-flush-{}", std::process::id()));
594 let _ = std::fs::create_dir_all(&dir);
595 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
596 let _ = std::fs::remove_file(dir.join("auto_mode_sources.json"));
597
598 count_source("test_flush_alpha");
601 count_source("test_flush_alpha");
602 count_source("test_flush_beta");
603 flush_sources();
604
605 count_source("test_flush_alpha");
606 flush_sources();
607
608 let persisted = persisted_source_counts();
609 let get = |k: &str| {
610 persisted
611 .iter()
612 .find(|(s, _)| s == k)
613 .map_or(0, |(_, n)| *n)
614 };
615 assert_eq!(
616 get("test_flush_alpha"),
617 3,
618 "two flushes must merge additively"
619 );
620 assert_eq!(get("test_flush_beta"), 1);
621
622 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
623 let _ = std::fs::remove_dir_all(&dir);
624 }
625
626 #[test]
627 fn small_file_always_full() {
628 let ctx = AutoModeContext {
629 path: "test.rs",
630 token_count: 100,
631 line_count: None,
632 task: None,
633 cache: None,
634 };
635 let result = resolve(&ctx);
636 assert_eq!(result.mode, "full");
637 assert_eq!(result.source, "small_file");
638 }
639
640 #[test]
641 fn config_file_returns_full() {
642 let ctx = AutoModeContext {
643 path: "config.ini",
644 token_count: 500,
645 line_count: None,
646 task: None,
647 cache: None,
648 };
649 let result = resolve(&ctx);
650 assert_eq!(result.mode, "full");
651 assert_eq!(result.source, "config_data");
652 }
653
654 #[test]
655 fn cached_compressed_only_file_does_not_escalate_to_full() {
656 let dir = tempfile::tempdir().unwrap();
668 let file = dir.path().join("large.rs");
669 let body = "fn placeholder() { let _ = 1; }\n".repeat(900);
670 std::fs::write(&file, &body).unwrap();
671 let path = file.to_str().unwrap();
672
673 let mut cache = SessionCache::new();
674 cache.store(path, &body);
675 let ctx = AutoModeContext {
678 path,
679 token_count: 7000,
680 line_count: None,
681 task: None,
682 cache: Some(&cache),
683 };
684 let result = resolve(&ctx);
685 assert_ne!(
686 result.mode, "full",
687 "compressed-only cached file must not escalate to full on re-read"
688 );
689 assert_ne!(result.source, "cache_hit");
690 }
691
692 #[test]
693 fn cached_full_delivered_file_short_circuits_to_stub() {
694 let dir = tempfile::tempdir().unwrap();
697 let file = dir.path().join("medium.rs");
698 let body = "fn placeholder() { let _ = 1; }\n".repeat(400);
699 std::fs::write(&file, &body).unwrap();
700 let path = file.to_str().unwrap();
701
702 let mut cache = SessionCache::new();
703 cache.store(path, &body);
704 cache.mark_full_delivered(path);
705
706 let ctx = AutoModeContext {
707 path,
708 token_count: 3000,
709 line_count: None,
710 task: None,
711 cache: Some(&cache),
712 };
713 let result = resolve(&ctx);
714 assert_eq!(result.mode, "full");
715 assert_eq!(result.source, "cache_hit");
716 }
717
718 #[test]
719 fn intent_explore_returns_map() {
720 let ctx = AutoModeContext {
721 path: "large.rs",
722 token_count: 5000,
723 line_count: None,
724 task: Some("how does the cache work?"),
725 cache: None,
726 };
727 let result = resolve(&ctx);
728 assert_eq!(result.mode, "map");
729 assert_eq!(result.source, "intent");
730 }
731
732 #[test]
733 fn task_names_file_matches_explicit_filename() {
734 assert!(task_names_file(
735 Some("fix the version sort in versioncmp.c"),
736 "src/versioncmp.c"
737 ));
738 assert!(task_names_file(
739 Some("why does graph.ts loop?"),
740 "web/src/graph.ts"
741 ));
742 }
743
744 #[test]
745 fn task_names_file_ignores_bare_stems_and_trivia() {
746 assert!(!task_names_file(
748 Some("improve the parser"),
749 "src/parser.rs"
750 ));
751 assert!(!task_names_file(None, "src/parser.rs"));
752 assert!(!task_names_file(Some("touch a.c"), "a.c"));
754 assert!(!task_names_file(Some("look at Makefile"), "Makefile"));
755 }
756
757 #[test]
758 fn task_suspect_file_overrides_intent() {
759 let _lock = crate::core::data_dir::test_env_lock();
760 let dir = std::env::temp_dir().join(format!("lctx-amr-suspect-{}", std::process::id()));
761 let _ = std::fs::create_dir_all(&dir);
762 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
763
764 let ctx = AutoModeContext {
768 path: "large.rs",
769 token_count: 5000,
770 line_count: None,
771 task: Some("how does large.rs build the cache?"),
772 cache: None,
773 };
774 let result = resolve(&ctx);
775 assert_eq!(result.mode, "full");
776 assert_eq!(result.source, "task_suspect_file");
777
778 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
779 let _ = std::fs::remove_dir_all(&dir);
780 }
781
782 #[test]
783 fn heuristic_full_for_medium_code_by_default() {
784 assert_eq!(heuristic_mode("rs", 1500, false), "full");
787 assert_eq!(heuristic_mode("ts", 1000, false), "full");
788 }
789
790 #[test]
791 fn heuristic_structure_first_maps_medium_code() {
792 assert_eq!(heuristic_mode("rs", 1500, true), "map");
794 assert_eq!(heuristic_mode("c", 800, true), "map");
795 }
796
797 #[test]
798 fn heuristic_structure_first_keeps_tiny_and_prose_full() {
799 assert_eq!(heuristic_mode("rs", 400, true), "full");
801 assert_eq!(heuristic_mode("md", 4000, true), "full");
803 assert_eq!(heuristic_mode("txt", 1000, true), "full");
804 }
805
806 #[test]
807 fn heuristic_large_code_maps_regardless() {
808 assert_eq!(heuristic_mode("rs", 9000, false), "map");
809 assert_eq!(heuristic_mode("rs", 9000, true), "map");
810 }
811
812 #[test]
818 fn structure_first_resolve_bugfix_cold_read() {
819 let _lock = crate::core::data_dir::test_env_lock();
820 let dir = std::env::temp_dir().join(format!("lctx-amr-sf-{}", std::process::id()));
821 let _ = std::fs::create_dir_all(&dir);
822 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
823 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "1");
824 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
825
826 let suspect = AutoModeContext {
827 path: "src/versioncmp.c",
828 token_count: 1500,
829 line_count: None,
830 task: None,
831 cache: None,
832 };
833 let result = resolve(&suspect);
834 assert_eq!(result.mode, "map");
835 assert_eq!(result.source, "structure_first");
836
837 let tiny = AutoModeContext {
838 path: "src/util.c",
839 token_count: 120,
840 line_count: None,
841 task: None,
842 cache: None,
843 };
844 assert_eq!(resolve(&tiny).mode, "full");
845
846 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
847 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
848 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
849 let _ = std::fs::remove_dir_all(&dir);
850 }
851
852 #[test]
853 fn structure_first_off_keeps_medium_code_full() {
854 let _lock = crate::core::data_dir::test_env_lock();
855 let dir = std::env::temp_dir().join(format!("lctx-amr-sfoff-{}", std::process::id()));
856 let _ = std::fs::create_dir_all(&dir);
857 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
858 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
859 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
860
861 let ctx = AutoModeContext {
862 path: "src/versioncmp.c",
863 token_count: 1500,
864 line_count: None,
865 task: None,
866 cache: None,
867 };
868 let result = resolve(&ctx);
869 assert_eq!(result.mode, "full");
870 assert_eq!(result.source, "heuristic");
871
872 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
873 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
874 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
875 let _ = std::fs::remove_dir_all(&dir);
876 }
877
878 #[test]
882 fn learning_off_by_default_is_deterministic() {
883 let _lock = crate::core::data_dir::test_env_lock();
884 let dir = std::env::temp_dir().join(format!("lctx-amr-det-{}", std::process::id()));
885 let _ = std::fs::create_dir_all(&dir);
886 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
887 crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
888 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
889 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
890
891 let ctx = AutoModeContext {
892 path: "src/widget.rs",
893 token_count: 1500,
894 line_count: None,
895 task: None,
896 cache: None,
897 };
898 let a = resolve(&ctx);
899 let b = resolve(&ctx);
900 assert_eq!(a.mode, "map");
901 assert_eq!(a.source, "structure_first");
902 assert_eq!((a.mode, a.source), (b.mode, b.source));
903
904 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
905 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
906 let _ = std::fs::remove_dir_all(&dir);
907 }
908
909 #[test]
912 fn auto_mode_learning_env_opt_in_is_honored() {
913 let _lock = crate::core::data_dir::test_env_lock();
914 crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "1");
915 assert!(crate::core::config::Config::default().auto_mode_learning_effective());
916 crate::test_env::set_var("LEAN_CTX_AUTO_MODE_LEARNING", "0");
917 assert!(!crate::core::config::Config::default().auto_mode_learning_effective());
918 crate::test_env::remove_var("LEAN_CTX_AUTO_MODE_LEARNING");
919 }
920
921 #[test]
922 fn progressive_small_file_stays_full() {
923 let _lock = crate::core::data_dir::test_env_lock();
924 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
925
926 let ctx = AutoModeContext {
927 path: "small.rs",
928 token_count: 200,
929 line_count: Some(50),
930 task: None,
931 cache: None,
932 };
933 let result = resolve(&ctx);
934 assert_eq!(result.mode, "full", "50 lines → full");
935
936 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
937 }
938
939 #[test]
940 fn progressive_medium_file_gets_signatures() {
941 let _lock = crate::core::data_dir::test_env_lock();
942 let dir = std::env::temp_dir().join(format!("lctx-pd-sig-{}", std::process::id()));
943 let _ = std::fs::create_dir_all(&dir);
944 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
945 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
946
947 let ctx = AutoModeContext {
948 path: "medium.rs",
949 token_count: 800,
950 line_count: Some(200),
951 task: None,
952 cache: None,
953 };
954 let result = resolve(&ctx);
955 assert_eq!(result.mode, "signatures", "200 lines → signatures");
956 assert_eq!(result.source, "progressive_signatures");
957
958 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
959 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
960 let _ = std::fs::remove_dir_all(&dir);
961 }
962
963 #[test]
964 fn progressive_large_file_gets_map() {
965 let _lock = crate::core::data_dir::test_env_lock();
966 let dir = std::env::temp_dir().join(format!("lctx-pd-map-{}", std::process::id()));
967 let _ = std::fs::create_dir_all(&dir);
968 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
969 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "1");
970
971 let ctx = AutoModeContext {
972 path: "large.rs",
973 token_count: 3200,
974 line_count: Some(800),
975 task: None,
976 cache: None,
977 };
978 let result = resolve(&ctx);
979 assert_eq!(result.mode, "map", "800 lines → map (manifest)");
980 assert_eq!(result.source, "progressive_manifest");
981
982 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
983 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
984 let _ = std::fs::remove_dir_all(&dir);
985 }
986
987 #[test]
988 fn progressive_disabled_skips_tiering() {
989 let _lock = crate::core::data_dir::test_env_lock();
990 let dir = std::env::temp_dir().join(format!("lctx-pd-off-{}", std::process::id()));
991 let _ = std::fs::create_dir_all(&dir);
992 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
993 crate::test_env::set_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE", "0");
994 crate::test_env::set_var("LEAN_CTX_STRUCTURE_FIRST", "0");
995
996 let ctx = AutoModeContext {
997 path: "medium.rs",
998 token_count: 800,
999 line_count: Some(200),
1000 task: None,
1001 cache: None,
1002 };
1003 let result = resolve(&ctx);
1004 assert_eq!(
1005 result.mode, "full",
1006 "progressive off → falls through to heuristic (full for medium code)"
1007 );
1008
1009 crate::test_env::remove_var("LEAN_CTX_PROGRESSIVE_DISCLOSURE");
1010 crate::test_env::remove_var("LEAN_CTX_STRUCTURE_FIRST");
1011 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1012 let _ = std::fs::remove_dir_all(&dir);
1013 }
1014
1015 #[test]
1016 fn estimate_lines_approximation() {
1017 assert_eq!(estimate_lines(400), 100);
1018 assert_eq!(estimate_lines(2000), 500);
1019 assert_eq!(estimate_lines(0), 0);
1020 }
1021}