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            // Content fingerprint for redundancy/MMR (#5): the ledger's content
200            // hash identifies byte-identical items so duplicates collapse to one;
201            // distinct content yields no false overlap (word-Jaccard over a single
202            // hash token). Falls back to the path when no hash is recorded.
203            content_sketch: entry.source_hash.clone(),
204        });
205    }
206
207    candidates
208}
209
210#[derive(Debug)]
211struct PlanItem {
212    path: String,
213    recommended_view: String,
214    estimated_tokens: usize,
215    phi: f64,
216    state: String,
217    reason: String,
218}
219
220fn format_plan(
221    task: &str,
222    budget: usize,
223    estimated: usize,
224    items: &[PlanItem],
225    profile: &str,
226) -> String {
227    let utilization = if budget > 0 {
228        estimated as f64 / budget as f64 * 100.0
229    } else {
230        0.0
231    };
232
233    let mut out = String::new();
234    out.push_str(&format!("[ctx_plan] task=\"{task}\" profile={profile}\n"));
235    out.push_str(&format!(
236        "Budget: {estimated}/{budget} tokens ({utilization:.1}% estimated)\n\n"
237    ));
238
239    let included: Vec<_> = items.iter().filter(|i| i.state != "excluded").collect();
240    let excluded: Vec<_> = items.iter().filter(|i| i.state == "excluded").collect();
241
242    if !included.is_empty() {
243        out.push_str("Planned items:\n");
244        for item in &included {
245            let default_reason = format!("profile:{profile}");
246            let extra = if item.reason.is_empty() || item.reason == default_reason {
247                String::new()
248            } else {
249                format!(" {}", item.reason)
250            };
251            out.push_str(&format!(
252                "  {} {} {}t phi={:.2} [{}]{}\n",
253                item.path,
254                item.recommended_view,
255                item.estimated_tokens,
256                item.phi,
257                item.state,
258                extra
259            ));
260        }
261    }
262
263    if !excluded.is_empty() {
264        out.push_str(&format!("\nExcluded ({}):\n", excluded.len()));
265        for item in &excluded {
266            out.push_str(&format!("  {} — {}\n", item.path, item.reason));
267        }
268    }
269
270    if utilization > 90.0 {
271        out.push_str(
272            "\nWARNING: Estimated tokens exceed 90% of budget. Consider stricter views.\n",
273        );
274    }
275
276    out
277}
278
279fn degrade_views(items: &mut [PlanItem], budget: usize, total: &mut usize) {
280    let degrade_order: &[(&str, &str, f64)] = &[
281        ("full", "map", 0.3),
282        ("map", "signatures", 0.5),
283        ("signatures", "signatures", 0.7),
284    ];
285
286    for &(from, to, ratio) in degrade_order {
287        if *total <= budget {
288            break;
289        }
290        let mut candidates: Vec<usize> = items
291            .iter()
292            .enumerate()
293            .filter(|(_, it)| {
294                it.recommended_view == from && it.state != "excluded" && it.state != "Pinned"
295            })
296            .map(|(i, _)| i)
297            .collect();
298        candidates.sort_by(|&a, &b| {
299            items[a]
300                .phi
301                .partial_cmp(&items[b].phi)
302                .unwrap_or(std::cmp::Ordering::Equal)
303        });
304        for idx in candidates {
305            if *total <= budget {
306                break;
307            }
308            let old_tokens = items[idx].estimated_tokens;
309            let new_tokens = (old_tokens as f64 * ratio) as usize;
310            *total = total.saturating_sub(old_tokens) + new_tokens;
311            items[idx].estimated_tokens = new_tokens;
312            items[idx].recommended_view = to.to_string();
313            items[idx].reason = format!("degraded:{from}->{to}");
314        }
315    }
316}
317
318fn get_str(args: Option<&serde_json::Map<String, Value>>, key: &str) -> Option<String> {
319    args?
320        .get(key)?
321        .as_str()
322        .map(std::string::ToString::to_string)
323}