Skip to main content

lean_ctx/tools/
ctx_plan.rs

1//! ctx_plan -- Context planning tool.
2//!
3//! Given a task and budget, computes the optimal context plan using the
4//! Context Field potential function, intent router, and deficit analysis.
5
6use serde_json::Value;
7
8use crate::core::context_compiler::CompileCandidate;
9use crate::core::context_field::{
10    ContextField, ContextItemId, ContextKind, ContextState, FieldSignals, TokenBudget, ViewCosts,
11    ViewKind,
12};
13use crate::core::context_ledger::ContextLedger;
14use crate::core::context_policies::PolicySet;
15
16const FALLBACK_BUDGET: usize = 12_000;
17
18pub fn handle(
19    args: Option<&serde_json::Map<String, Value>>,
20    ledger: &ContextLedger,
21    policies: &PolicySet,
22) -> String {
23    let task = get_str(args, "task").unwrap_or_else(|| "general".to_string());
24    let explicit_budget = args
25        .and_then(|a| a.get("budget"))
26        .and_then(|v| {
27            v.as_u64()
28                .or_else(|| v.as_str().and_then(|s| s.parse::<u64>().ok()))
29        })
30        .map(|b| b as usize);
31    let default_budget = if ledger.window_size > 0 {
32        ledger.window_size
33    } else {
34        FALLBACK_BUDGET
35    };
36    let budget_tokens = explicit_budget.unwrap_or(default_budget);
37    let profile = get_str(args, "profile").unwrap_or_else(|| "balanced".to_string());
38
39    let field = ContextField::new();
40    let budget = TokenBudget {
41        total: budget_tokens,
42        used: 0,
43    };
44    let temperature = budget.temperature();
45
46    let intent_route = crate::core::intent_router::route_v1(&task);
47    let intent_mode = &intent_route.decision.effective_read_mode;
48
49    let mut plan_items: Vec<PlanItem> = Vec::new();
50    let mut total_estimated = 0usize;
51
52    for entry in &ledger.entries {
53        let path = &entry.path;
54        let seen_before = true;
55
56        let effective_state = policies.effective_state(
57            path,
58            entry.state.unwrap_or(ContextState::Included),
59            seen_before,
60            entry.original_tokens,
61        );
62        if effective_state == ContextState::Excluded {
63            plan_items.push(PlanItem {
64                path: path.clone(),
65                recommended_view: "excluded".to_string(),
66                estimated_tokens: 0,
67                phi: 0.0,
68                state: "excluded".to_string(),
69                reason: "policy".to_string(),
70            });
71            continue;
72        }
73
74        let phi = entry.phi.unwrap_or_else(|| {
75            let signals = FieldSignals {
76                relevance: if task != "general" && path.contains(&task) {
77                    0.8
78                } else {
79                    0.3
80                },
81                ..Default::default()
82            };
83            field.compute_phi(&signals)
84        });
85
86        let view_costs = entry
87            .view_costs
88            .clone()
89            .unwrap_or_else(|| ViewCosts::from_full_tokens(entry.original_tokens));
90
91        let recommended_view = policies
92            .recommended_view(path, seen_before, entry.original_tokens)
93            .unwrap_or_else(|| {
94                if intent_mode != "auto" && intent_mode != "reference" {
95                    ViewKind::parse(intent_mode)
96                } else {
97                    field.select_view(&view_costs, temperature)
98                }
99            });
100
101        let estimated_tokens = view_costs.get(&recommended_view);
102        total_estimated += estimated_tokens;
103
104        plan_items.push(PlanItem {
105            path: path.clone(),
106            recommended_view: recommended_view.as_str().to_string(),
107            estimated_tokens,
108            phi,
109            state: format!("{effective_state:?}"),
110            reason: format!("profile:{profile}"),
111        });
112    }
113
114    let loaded_paths: Vec<String> = ledger.entries.iter().map(|e| e.path.clone()).collect();
115    let (target_files, keywords) = crate::core::task_relevance::parse_task_hints(&task);
116    let classification = crate::core::intent_engine::classify(&task);
117    let structured = crate::core::intent_engine::StructuredIntent {
118        task_type: classification.task_type,
119        confidence: classification.confidence,
120        targets: target_files,
121        keywords,
122        scope: crate::core::intent_engine::IntentScope::MultiFile,
123        language_hint: None,
124        urgency: 0.5,
125        action_verb: None,
126    };
127    let deficit = crate::core::context_deficit::detect_deficit(ledger, &structured, &loaded_paths);
128
129    for suggestion in &deficit.suggested_files {
130        if !plan_items.iter().any(|p| p.path == suggestion.path) {
131            plan_items.push(PlanItem {
132                path: suggestion.path.clone(),
133                recommended_view: suggestion.recommended_mode.clone(),
134                estimated_tokens: suggestion.estimated_tokens,
135                phi: 0.5,
136                state: "suggested".to_string(),
137                reason: format!("{:?}", suggestion.reason),
138            });
139            total_estimated += suggestion.estimated_tokens;
140        }
141    }
142
143    plan_items.sort_by(|a, b| {
144        b.phi
145            .partial_cmp(&a.phi)
146            .unwrap_or(std::cmp::Ordering::Equal)
147    });
148
149    if total_estimated > budget_tokens {
150        degrade_views(&mut plan_items, budget_tokens, &mut total_estimated);
151    }
152
153    format_plan(&task, budget_tokens, total_estimated, &plan_items, &profile)
154}
155
156/// Convert the plan into compile candidates for use with the compiler.
157pub fn plan_to_candidates(ledger: &ContextLedger, policies: &PolicySet) -> Vec<CompileCandidate> {
158    let field = ContextField::new();
159    let mut candidates = Vec::new();
160
161    for entry in &ledger.entries {
162        let path = &entry.path;
163        let seen_before = true;
164        let effective_state = policies.effective_state(
165            path,
166            entry.state.unwrap_or(ContextState::Included),
167            seen_before,
168            entry.original_tokens,
169        );
170
171        let phi = entry.phi.unwrap_or_else(|| {
172            let signals = FieldSignals {
173                relevance: 0.3,
174                ..Default::default()
175            };
176            field.compute_phi(&signals)
177        });
178
179        let view_costs = entry
180            .view_costs
181            .clone()
182            .unwrap_or_else(|| ViewCosts::from_full_tokens(entry.original_tokens));
183
184        let item_id = entry
185            .id
186            .clone()
187            .unwrap_or_else(|| ContextItemId::from_file(path));
188
189        candidates.push(CompileCandidate {
190            id: item_id,
191            kind: entry.kind.unwrap_or(ContextKind::File),
192            path: path.clone(),
193            state: effective_state,
194            phi,
195            view_costs: view_costs.clone(),
196            selected_view: entry.active_view.unwrap_or(ViewKind::Full),
197            selected_tokens: entry.sent_tokens,
198            pinned: effective_state == ContextState::Pinned,
199        });
200    }
201
202    candidates
203}
204
205#[derive(Debug)]
206struct PlanItem {
207    path: String,
208    recommended_view: String,
209    estimated_tokens: usize,
210    phi: f64,
211    state: String,
212    reason: String,
213}
214
215fn format_plan(
216    task: &str,
217    budget: usize,
218    estimated: usize,
219    items: &[PlanItem],
220    profile: &str,
221) -> String {
222    let utilization = if budget > 0 {
223        estimated as f64 / budget as f64 * 100.0
224    } else {
225        0.0
226    };
227
228    let mut out = String::new();
229    out.push_str(&format!("[ctx_plan] task=\"{task}\" profile={profile}\n"));
230    out.push_str(&format!(
231        "Budget: {estimated}/{budget} tokens ({utilization:.1}% estimated)\n\n"
232    ));
233
234    let included: Vec<_> = items.iter().filter(|i| i.state != "excluded").collect();
235    let excluded: Vec<_> = items.iter().filter(|i| i.state == "excluded").collect();
236
237    if !included.is_empty() {
238        out.push_str("Planned items:\n");
239        for item in &included {
240            let default_reason = format!("profile:{profile}");
241            let extra = if item.reason.is_empty() || item.reason == default_reason {
242                String::new()
243            } else {
244                format!(" {}", item.reason)
245            };
246            out.push_str(&format!(
247                "  {} {} {}t phi={:.2} [{}]{}\n",
248                item.path,
249                item.recommended_view,
250                item.estimated_tokens,
251                item.phi,
252                item.state,
253                extra
254            ));
255        }
256    }
257
258    if !excluded.is_empty() {
259        out.push_str(&format!("\nExcluded ({}):\n", excluded.len()));
260        for item in &excluded {
261            out.push_str(&format!("  {} — {}\n", item.path, item.reason));
262        }
263    }
264
265    if utilization > 90.0 {
266        out.push_str(
267            "\nWARNING: Estimated tokens exceed 90% of budget. Consider stricter views.\n",
268        );
269    }
270
271    out
272}
273
274fn degrade_views(items: &mut [PlanItem], budget: usize, total: &mut usize) {
275    let degrade_order: &[(&str, &str, f64)] = &[
276        ("full", "map", 0.3),
277        ("map", "signatures", 0.5),
278        ("signatures", "signatures", 0.7),
279    ];
280
281    for &(from, to, ratio) in degrade_order {
282        if *total <= budget {
283            break;
284        }
285        let mut candidates: Vec<usize> = items
286            .iter()
287            .enumerate()
288            .filter(|(_, it)| {
289                it.recommended_view == from && it.state != "excluded" && it.state != "Pinned"
290            })
291            .map(|(i, _)| i)
292            .collect();
293        candidates.sort_by(|&a, &b| {
294            items[a]
295                .phi
296                .partial_cmp(&items[b].phi)
297                .unwrap_or(std::cmp::Ordering::Equal)
298        });
299        for idx in candidates {
300            if *total <= budget {
301                break;
302            }
303            let old_tokens = items[idx].estimated_tokens;
304            let new_tokens = (old_tokens as f64 * ratio) as usize;
305            *total = total.saturating_sub(old_tokens) + new_tokens;
306            items[idx].estimated_tokens = new_tokens;
307            items[idx].recommended_view = to.to_string();
308            items[idx].reason = format!("degraded:{from}->{to}");
309        }
310    }
311}
312
313fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
314    args?
315        .get(key)?
316        .as_str()
317        .map(std::string::ToString::to_string)
318}