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