Skip to main content

lean_ctx/core/
auto_mode_resolver.rs

1use crate::core::cache::SessionCache;
2use crate::core::context_ledger::PressureAction;
3use crate::core::mode_predictor::{FileSignature, ModePredictor};
4
5pub struct AutoModeContext<'a> {
6    pub path: &'a str,
7    pub token_count: usize,
8    pub task: Option<&'a str>,
9    pub cache: Option<&'a SessionCache>,
10}
11
12pub struct ResolvedMode {
13    pub mode: String,
14    pub source: &'static str,
15}
16
17/// Single entry point for auto-mode resolution.
18/// Merges Pipeline A (select_mode_with_task) and Pipeline B (resolve_auto_mode).
19pub fn resolve(ctx: &AutoModeContext) -> ResolvedMode {
20    if crate::tools::ctx_read::is_instruction_file(ctx.path) {
21        return resolved("full", "instruction_file");
22    }
23
24    if crate::core::binary_detect::is_binary_file(ctx.path) {
25        return resolved("full", "binary");
26    }
27
28    if let Some(cache) = ctx.cache {
29        if let Some(cached) = cache.get(ctx.path) {
30            if file_unchanged(ctx.path, cached) {
31                return resolved("full", "cache_hit");
32            }
33            return resolved("diff", "cache_changed");
34        }
35    }
36
37    if ctx.token_count <= 200 {
38        return resolved("full", "small_file");
39    }
40
41    let ext = std::path::Path::new(ctx.path)
42        .extension()
43        .and_then(|e| e.to_str())
44        .unwrap_or("");
45
46    if is_config_or_data(ext, ctx.path) {
47        return resolved("full", "config_data");
48    }
49
50    if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
51        if bt.should_force_full(ctx.path) {
52            return resolved("full", "bounce_tracker");
53        }
54    }
55
56    if let Some(mode) = intent_recommended_mode(ctx.task) {
57        return resolved(&mode, "intent");
58    }
59
60    let sig = FileSignature::from_path(ctx.path, ctx.token_count);
61    let predictor = ModePredictor::new();
62    let mut predicted = predictor
63        .predict_best_mode(&sig)
64        .unwrap_or_else(|| "full".to_string());
65    if predicted == "auto" {
66        predicted = "full".to_string();
67    }
68
69    if predicted != "full" {
70        if let Some(bandit_override) = bandit_explore(ctx.path, ctx.token_count) {
71            predicted = bandit_override;
72        }
73    }
74
75    let policy = crate::core::adaptive_mode_policy::AdaptiveModePolicyStore::load();
76    let chosen = policy.choose_auto_mode(ctx.task, &predicted);
77
78    if ctx.token_count > 2000 {
79        if (predicted == "map" || predicted == "signatures")
80            && chosen != "map"
81            && chosen != "signatures"
82        {
83            return resolved(&predicted, "predictor_guard");
84        }
85        if chosen == "full" && predicted != "full" {
86            return resolved(&predicted, "predictor_override");
87        }
88    }
89
90    if chosen != predicted {
91        return resolved(&chosen, "adaptive_policy");
92    }
93
94    if predicted != "full" {
95        return resolved(&predicted, "predictor");
96    }
97
98    let heuristic = heuristic_mode(ext, ctx.token_count);
99    resolved(&heuristic, "heuristic")
100}
101
102/// Unified pressure downgrade table.
103/// Used by both context_gate and intent_router pressure paths.
104pub fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
105    match action {
106        PressureAction::SuggestCompression => match requested_mode {
107            "auto" | "full" => Some("map".to_string()),
108            _ => None,
109        },
110        PressureAction::ForceCompression => match requested_mode {
111            "full" => Some("map".to_string()),
112            "auto" | "map" => Some("signatures".to_string()),
113            _ => None,
114        },
115        PressureAction::EvictLeastRelevant => match requested_mode {
116            "full" => Some("map".to_string()),
117            "auto" | "map" => Some("signatures".to_string()),
118            "signatures" => Some("reference".to_string()),
119            _ => None,
120        },
121        PressureAction::NoAction => None,
122    }
123}
124
125fn intent_recommended_mode(task: Option<&str>) -> Option<String> {
126    let task_desc = task?;
127    let classification = crate::core::intent_engine::classify(task_desc);
128    if classification.confidence < 0.4 {
129        return None;
130    }
131    let route = crate::core::intent_engine::route_intent(task_desc, &classification);
132    let mode =
133        crate::core::intent_router::read_mode_for_tier(route.model_tier, classification.task_type);
134    if mode == "auto" {
135        return None;
136    }
137    Some(mode)
138}
139
140fn bandit_explore(file_path: &str, token_count: usize) -> Option<String> {
141    let project_root =
142        crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)?;
143    let ext = std::path::Path::new(file_path)
144        .extension()
145        .and_then(|e| e.to_str())
146        .unwrap_or("");
147    let bucket = match token_count {
148        0..=2000 => "sm",
149        2001..=10000 => "md",
150        10001..=50000 => "lg",
151        _ => "xl",
152    };
153    let bandit_key = format!("{ext}_{bucket}");
154    let mut store = crate::core::bandit::BanditStore::load(&project_root);
155    let bandit = store.get_or_create(&bandit_key);
156    let arm = bandit.select_arm();
157    if arm.budget_ratio < 0.25 && token_count > 2000 {
158        Some("aggressive".to_string())
159    } else {
160        None
161    }
162}
163
164fn heuristic_mode(ext: &str, token_count: usize) -> String {
165    if token_count > 8000 {
166        if is_code(ext) {
167            return "map".to_string();
168        }
169        return "aggressive".to_string();
170    }
171    // Raised from 3000 → 6000: at 3-6k tokens, returning only signatures forces
172    // the agent into a follow-up full/lines read for the body it actually
173    // needs. Keeping `full` here trades a few hundred tokens per call for
174    // fewer round-trips — the right call per the total-task-token principle.
175    if token_count > 6000 && is_code(ext) {
176        return "map".to_string();
177    }
178    "full".to_string()
179}
180
181/// Fast O(1) staleness check: if the file's mtime still matches what was
182/// stored when the cache entry was created, the content is unchanged — no need
183/// to read the file or compute any hash. Falls back to "changed" when metadata
184/// is unavailable (e.g. file deleted) or when the cache entry predates mtime
185/// tracking (legacy entries with `stored_mtime = None`).
186///
187/// mtime comparison is sufficient for correctness on all major filesystems:
188/// every `write(2)` / `truncate(2)` updates mtime (POSIX guarantee).
189fn file_unchanged(path: &str, cached: &crate::core::cache::CacheEntry) -> bool {
190    let Some(stored_mtime) = cached.stored_mtime else {
191        return false;
192    };
193    let Ok(meta) = std::fs::metadata(path) else {
194        return false;
195    };
196    let Ok(current_mtime) = meta.modified() else {
197        return false;
198    };
199    current_mtime == stored_mtime
200}
201
202fn is_code(ext: &str) -> bool {
203    matches!(
204        ext,
205        "rs" | "ts"
206            | "tsx"
207            | "js"
208            | "jsx"
209            | "py"
210            | "go"
211            | "java"
212            | "c"
213            | "cpp"
214            | "cc"
215            | "h"
216            | "hpp"
217            | "rb"
218            | "cs"
219            | "kt"
220            | "swift"
221            | "php"
222            | "zig"
223            | "ex"
224            | "exs"
225            | "scala"
226            | "sc"
227            | "dart"
228            | "sh"
229            | "bash"
230            | "svelte"
231            | "vue"
232    )
233}
234
235fn is_config_or_data(ext: &str, path: &str) -> bool {
236    if matches!(ext, "xml" | "ini" | "cfg" | "env") {
237        return true;
238    }
239    let name = std::path::Path::new(path)
240        .file_name()
241        .and_then(|n| n.to_str())
242        .unwrap_or("");
243    matches!(
244        name,
245        "Cargo.toml"
246            | "package.json"
247            | "tsconfig.json"
248            | "Makefile"
249            | "Dockerfile"
250            | "docker-compose.yml"
251            | ".gitignore"
252            | ".env"
253            | "pyproject.toml"
254            | "go.mod"
255            | "build.gradle"
256            | "pom.xml"
257    )
258}
259
260fn resolved(mode: &str, source: &'static str) -> ResolvedMode {
261    ResolvedMode {
262        mode: mode.to_string(),
263        source,
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn pressure_suggest_full_to_map() {
273        assert_eq!(
274            pressure_downgrade("full", &PressureAction::SuggestCompression),
275            Some("map".to_string())
276        );
277    }
278
279    #[test]
280    fn pressure_suggest_auto_to_map() {
281        assert_eq!(
282            pressure_downgrade("auto", &PressureAction::SuggestCompression),
283            Some("map".to_string())
284        );
285    }
286
287    #[test]
288    fn pressure_suggest_does_not_touch_signatures() {
289        assert!(pressure_downgrade("signatures", &PressureAction::SuggestCompression).is_none());
290    }
291
292    #[test]
293    fn pressure_force_full_to_map() {
294        assert_eq!(
295            pressure_downgrade("full", &PressureAction::ForceCompression),
296            Some("map".to_string())
297        );
298    }
299
300    #[test]
301    fn pressure_force_map_to_signatures() {
302        assert_eq!(
303            pressure_downgrade("map", &PressureAction::ForceCompression),
304            Some("signatures".to_string())
305        );
306    }
307
308    #[test]
309    fn pressure_evict_signatures_to_reference() {
310        assert_eq!(
311            pressure_downgrade("signatures", &PressureAction::EvictLeastRelevant),
312            Some("reference".to_string())
313        );
314    }
315
316    #[test]
317    fn pressure_noaction_returns_none() {
318        assert!(pressure_downgrade("full", &PressureAction::NoAction).is_none());
319    }
320
321    #[test]
322    fn small_file_always_full() {
323        let ctx = AutoModeContext {
324            path: "test.rs",
325            token_count: 100,
326            task: None,
327            cache: None,
328        };
329        let result = resolve(&ctx);
330        assert_eq!(result.mode, "full");
331        assert_eq!(result.source, "small_file");
332    }
333
334    #[test]
335    fn config_file_returns_full() {
336        let ctx = AutoModeContext {
337            path: "config.ini",
338            token_count: 500,
339            task: None,
340            cache: None,
341        };
342        let result = resolve(&ctx);
343        assert_eq!(result.mode, "full");
344        assert_eq!(result.source, "config_data");
345    }
346
347    #[test]
348    fn intent_explore_returns_map() {
349        let ctx = AutoModeContext {
350            path: "large.rs",
351            token_count: 5000,
352            task: Some("how does the cache work?"),
353            cache: None,
354        };
355        let result = resolve(&ctx);
356        assert_eq!(result.mode, "map");
357        assert_eq!(result.source, "intent");
358    }
359}