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 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 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 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
293fn 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 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 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
390pub 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
413fn 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 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 if token_count > 6000 && is_code(ext) {
486 return "map".to_string();
487 }
488 if structure_first && token_count > 500 && is_code(ext) {
495 return "map".to_string();
496 }
497 if token_count > 500 && is_code(ext) {
502 return "signatures".to_string();
503 }
504 "full".to_string()
505}
506
507fn 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
585fn 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 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 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 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 assert!(!task_names_file(
860 Some("improve the parser"),
861 "src/parser.rs"
862 ));
863 assert!(!task_names_file(None, "src/parser.rs"));
864 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 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 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 assert_eq!(heuristic_mode("rs", 400, true), "full");
911 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 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 #[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 #[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 #[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}