1use std::collections::HashMap;
15use std::sync::mpsc;
16use std::time::Duration;
17
18use crate::core::graph_provider;
19use crate::core::tokens::count_tokens;
20use crate::tools::CrpMode;
21
22const DEFAULT_SEMANTIC_BUDGET_MS: u64 = 4000;
28
29fn semantic_budget() -> Duration {
30 let ms = std::env::var("LEAN_CTX_COMPOSE_BUDGET_MS")
31 .ok()
32 .and_then(|v| v.parse::<u64>().ok())
33 .filter(|&v| v > 0)
34 .unwrap_or(DEFAULT_SEMANTIC_BUDGET_MS);
35 Duration::from_millis(ms)
36}
37
38const DEFAULT_SYMBOL_BUDGET_TOKENS: usize = 600;
42
43fn symbol_budget_tokens() -> usize {
44 std::env::var("LEAN_CTX_COMPOSE_SYMBOL_TOKENS")
45 .ok()
46 .and_then(|v| v.parse::<usize>().ok())
47 .filter(|&v| v > 0)
48 .unwrap_or(DEFAULT_SYMBOL_BUDGET_TOKENS)
49}
50
51const DEFAULT_GRAPH_BUDGET_MS: u64 = 1500;
56
57fn graph_budget() -> Duration {
58 let ms = std::env::var("LEAN_CTX_COMPOSE_GRAPH_BUDGET_MS")
59 .ok()
60 .and_then(|v| v.parse::<u64>().ok())
61 .filter(|&v| v > 0)
62 .unwrap_or(DEFAULT_GRAPH_BUDGET_MS);
63 Duration::from_millis(ms)
64}
65
66const SPREAD_DECAY: f64 = 0.6;
70const SPREAD_HOPS: usize = 3;
71const SPREAD_TOP_K: usize = 8;
73
74fn build_associative_block(project_root: &str, keywords: &[String]) -> String {
80 let Some(open) = graph_provider::open_or_build(project_root) else {
81 return String::new();
82 };
83 let gp = &open.provider;
84
85 let mut seed_files: Vec<String> = Vec::new();
87 for kw in keywords {
88 for sym in gp.find_symbols(kw, None, None) {
89 if !seed_files.contains(&sym.file) {
90 seed_files.push(sym.file);
91 }
92 }
93 }
94 if seed_files.is_empty() {
95 return String::new();
96 }
97
98 crate::core::cooccurrence::record_access(project_root, &seed_files);
101
102 let mut adjacency: HashMap<String, Vec<(String, f64)>> = HashMap::new();
105 let mut add_edge = |a: &str, b: &str, w: f64| {
106 adjacency
107 .entry(a.to_string())
108 .or_default()
109 .push((b.to_string(), w));
110 adjacency
111 .entry(b.to_string())
112 .or_default()
113 .push((a.to_string(), w));
114 };
115 for e in gp.edges() {
116 add_edge(&e.from, &e.to, if e.weight > 0.0 { e.weight } else { 1.0 });
117 }
118 let coaccess = crate::core::cooccurrence::load(project_root);
119 for sf in &seed_files {
120 for (nbr, w) in coaccess.related(sf, 16) {
121 add_edge(sf, &nbr, w);
122 }
123 }
124
125 let seeds: HashMap<String, f64> = seed_files.iter().map(|f| (f.clone(), 1.0)).collect();
126 let ranked = crate::core::spreading_activation::related_ranked(
127 &seeds,
128 &adjacency,
129 SPREAD_DECAY,
130 SPREAD_HOPS,
131 SPREAD_TOP_K,
132 );
133 if ranked.is_empty() {
134 return String::new();
135 }
136
137 let mut s = String::from("\n## Related (associative: import/call graph + learned co-access)\n");
138 for (file, activation) in ranked {
139 let file = crate::core::protocol::display_path(&file);
142 s.push_str(&format!("- {file} (activation {activation:.2})\n"));
143 }
144 s
145}
146
147fn associative_block_budgeted(project_root: &str, keywords: &[String]) -> String {
151 if keywords.is_empty() {
152 return String::new();
153 }
154 let (tx, rx) = mpsc::channel::<String>();
155 let root = project_root.to_string();
156 let kws = keywords.to_vec();
157 std::thread::spawn(move || {
158 let block = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
159 build_associative_block(&root, &kws)
160 }))
161 .unwrap_or_else(|_| {
162 tracing::warn!("[ctx_compose: associative block panicked; omitting section]");
163 String::new()
164 });
165 let _ = tx.send(block);
166 });
167 rx.recv_timeout(graph_budget()).unwrap_or_default()
168}
169
170const STOPWORDS: &[&str] = &[
172 "the",
173 "and",
174 "for",
175 "with",
176 "that",
177 "this",
178 "from",
179 "into",
180 "how",
181 "where",
182 "what",
183 "does",
184 "are",
185 "was",
186 "use",
187 "used",
188 "uses",
189 "add",
190 "all",
191 "any",
192 "can",
193 "get",
194 "set",
195 "via",
196 "out",
197 "its",
198 "his",
199 "her",
200 "you",
201 "your",
202 "our",
203 "find",
204 "show",
205 "list",
206 "make",
207 "when",
208 "then",
209 "has",
210 "have",
211 "had",
212 "not",
213 "but",
214 "see",
215 "function",
216 "method",
217 "class",
218 "code",
219 "file",
220 "files",
221 "implement",
222 "implementation",
223];
224
225fn extract_keywords(task: &str, max: usize) -> Vec<String> {
228 let mut seen = std::collections::HashSet::new();
229 let mut out = Vec::new();
230 for raw in task.split(|c: char| !(c.is_alphanumeric() || c == '_')) {
231 if raw.len() < 3 {
232 continue;
233 }
234 if STOPWORDS.contains(&raw.to_ascii_lowercase().as_str()) {
235 continue;
236 }
237 if seen.insert(raw.to_string()) {
238 out.push(raw.to_string());
239 if out.len() >= max {
240 break;
241 }
242 }
243 }
244 out
245}
246
247fn exact_match_keyword(keywords: &[String]) -> Option<&str> {
248 keywords
249 .iter()
250 .filter(|keyword| keyword.contains('_') || keyword.chars().skip(1).any(char::is_uppercase))
251 .max_by_key(|keyword| keyword.len())
252 .map(String::as_str)
253}
254
255fn ranked_files_budgeted(task: &str, project_root: &str, crp_mode: CrpMode) -> String {
259 let shared_cache = crate::tools::ctx_semantic_search::get_thread_cache();
260 let (tx, rx) = mpsc::channel::<String>();
261 let task_owned = task.to_string();
262 let root_owned = project_root.to_string();
263
264 std::thread::spawn(move || {
265 if let Some(cache) = shared_cache {
266 crate::tools::ctx_semantic_search::set_thread_cache(cache);
267 }
268 let ranked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
269 crate::tools::ctx_semantic_search::handle(
270 &task_owned,
271 &root_owned,
272 8,
273 crp_mode,
274 None,
275 None,
276 None,
277 Some(false),
278 Some(false),
279 )
280 }))
281 .unwrap_or_else(|_| {
282 tracing::warn!("[ctx_compose: semantic ranking panicked; omitting section]");
283 String::new()
284 });
285 let _ = tx.send(ranked);
288 });
289
290 match rx.recv_timeout(semantic_budget()) {
291 Ok(ranked) => ranked.trim().to_string(),
292 Err(_) => deferred_ranking_note(project_root),
293 }
294}
295
296fn deferred_ranking_note(project_root: &str) -> String {
305 let exact = "the exact matches below are authoritative for this call";
306 let s = crate::core::index_orchestrator::bm25_summary(project_root);
307 match s.state {
308 "failed" => {
309 let why = s
310 .last_error
311 .or(s.note)
312 .unwrap_or_else(|| "unknown error".to_string());
313 format!(
314 "(semantic ranking unavailable — index build FAILED: {why}. {exact}. \
315 Inspect with `ctx_index status` / `lean-ctx doctor`, then `lean-ctx reindex`)"
316 )
317 }
318 "building" => {
319 let secs = s.elapsed_ms.map_or(0, |ms| ms / 1000);
320 format!(
321 "(deferred — semantic index is building ({secs}s elapsed); {exact}, \
322 and ranking becomes available once the build finishes)"
323 )
324 }
325 _ => match s.note {
329 Some(note) if note.contains("NOT persisted") => {
330 format!("(semantic ranking deferred — {note} {exact}.)")
331 }
332 _ => format!(
333 "(deferred — semantic index is warming; {exact}, \
334 and ranking will be fast on the next call once the index is cached)"
335 ),
336 },
337 }
338}
339
340pub fn handle(task: &str, project_root: &str, crp_mode: CrpMode) -> (String, usize) {
342 let task = task.trim();
343 if task.is_empty() {
344 return ("ERROR: task is required".to_string(), 0);
345 }
346
347 let keywords = extract_keywords(task, 6);
348 let allow_secret = crate::core::roles::active_role().io.allow_secret_paths;
349
350 let mut out = String::new();
351 out.push_str(&format!("TASK: {task}\n"));
352 if keywords.is_empty() {
353 out.push_str("KEYWORDS: (none extracted — using full task for ranking)\n");
354 } else {
355 out.push_str(&format!("KEYWORDS: {}\n", keywords.join(", ")));
356 }
357
358 out.push_str("\n## Ranked files (semantic)\n");
363 out.push_str(&ranked_files_budgeted(task, project_root, crp_mode));
364 out.push('\n');
365
366 if let Some(primary) = exact_match_keyword(&keywords) {
370 let grep = crate::tools::ctx_search::handle(
371 primary,
372 project_root,
373 None,
374 10,
375 crp_mode,
376 true,
377 allow_secret,
378 false,
379 )
380 .text;
381 out.push_str(&format!("\n## Exact matches: '{primary}'\n"));
382 out.push_str(grep.trim());
383 out.push('\n');
384 }
385
386 use crate::core::context_packing::{CoverageItem, greedy_max_coverage};
392 let mut snippets: Vec<String> = Vec::new();
393 let mut items: Vec<CoverageItem> = Vec::new();
394 for kw in &keywords {
395 if let Some((rendered, toks)) =
396 crate::tools::ctx_symbol::best_symbol_snippet_for_task(kw, task, project_root)
397 {
398 let mut terms: std::collections::HashSet<String> =
401 std::collections::HashSet::from([kw.clone()]);
402 for other in &keywords {
403 if other != kw && rendered.contains(other.as_str()) {
404 terms.insert(other.clone());
405 }
406 }
407 items.push(CoverageItem {
408 terms,
409 cost: toks.max(1),
410 });
411 snippets.push(rendered);
412 }
413 }
414 if !items.is_empty() {
415 let chosen = greedy_max_coverage(&items, symbol_budget_tokens(), |_| 1.0);
416 let mut seen = std::collections::HashSet::new();
417 let mut header_written = false;
418 for idx in chosen {
419 let rendered = snippets[idx].trim();
420 if rendered.is_empty() || !seen.insert(rendered.to_string()) {
421 continue;
422 }
423 if !header_written {
424 out.push_str("\n## Top symbols (bodies)\n");
425 header_written = true;
426 }
427 out.push_str(rendered);
428 out.push('\n');
429 }
430 }
431
432 out.push_str(&associative_block_budgeted(project_root, &keywords));
436
437 let sent = count_tokens(&out);
438 (out, sent)
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn extract_keywords_drops_stopwords_and_short_tokens() {
447 let kw = extract_keywords("How does the BM25Index cache work for ctx_search?", 6);
448 assert!(kw.contains(&"BM25Index".to_string()));
449 assert!(kw.contains(&"cache".to_string()));
450 assert!(kw.contains(&"ctx_search".to_string()));
451 assert!(!kw.iter().any(|k| k == "the" || k == "How" || k == "for"));
452 }
453
454 #[test]
455 fn extract_keywords_dedups_and_caps() {
456 let kw = extract_keywords("alpha alpha beta gamma delta epsilon zeta eta", 3);
457 assert_eq!(kw.len(), 3);
458 assert_eq!(kw[0], "alpha");
459 }
460
461 #[test]
462 fn exact_matches_choose_specific_identifier_not_first_broad_keyword() {
463 let keywords = extract_keywords(
464 "OCPP charger GetMaxCurrent Current.Offered measurand CurrentGetter",
465 6,
466 );
467 assert_eq!(exact_match_keyword(&keywords), Some("GetMaxCurrent"));
468
469 let prose = extract_keywords("Fix semantic ranking exact matches", 6);
470 assert_eq!(exact_match_keyword(&prose), None);
471 }
472
473 #[test]
474 fn empty_task_is_rejected() {
475 let (out, tok) = handle(" ", "/tmp", CrpMode::Off);
476 assert!(out.starts_with("ERROR"));
477 assert_eq!(tok, 0);
478 }
479
480 #[test]
481 fn deferred_note_for_idle_index_is_optimistic_but_honest() {
482 let tmp = tempfile::tempdir().unwrap();
486 let note = deferred_ranking_note(tmp.path().to_string_lossy().as_ref());
487 assert!(
488 note.contains("warming") || note.contains("building"),
489 "note: {note}"
490 );
491 assert!(
492 note.contains("authoritative"),
493 "note must reassure that exact matches are authoritative: {note}"
494 );
495 assert!(
496 !note.contains("instant on the next call"),
497 "must not repeat the dishonest 'instant next call' promise: {note}"
498 );
499 }
500}