Skip to main content

lean_ctx/core/
task_relevance.rs

1use std::collections::{HashMap, HashSet};
2
3use super::graph_provider::{EdgeInfo, GraphProvider};
4use super::neural::attention_learned::LearnedAttention;
5
6#[derive(Debug, Clone)]
7pub struct RelevanceScore {
8    pub path: String,
9    pub score: f64,
10    pub recommended_mode: &'static str,
11}
12
13pub fn compute_relevance(
14    gp: &GraphProvider,
15    task_files: &[String],
16    task_keywords: &[String],
17) -> Vec<RelevanceScore> {
18    let all_edges = gp.edges();
19    let file_set: HashSet<String> = gp.file_paths().into_iter().collect();
20    let adj = build_adjacency_resolved(&all_edges, &file_set);
21    let all_nodes: Vec<String> = file_set.into_iter().collect();
22    if all_nodes.is_empty() {
23        return Vec::new();
24    }
25
26    let node_idx: HashMap<&str, usize> = all_nodes
27        .iter()
28        .enumerate()
29        .map(|(i, n)| (n.as_str(), i))
30        .collect();
31    let n = all_nodes.len();
32
33    // Build degree-normalized adjacency for heat diffusion
34    let degrees: Vec<f64> = all_nodes
35        .iter()
36        .map(|node| {
37            adj.get(node)
38                .map_or(0.0, |neigh| neigh.len() as f64)
39                .max(1.0)
40        })
41        .collect();
42
43    // Seed vector: task files get 1.0
44    let mut heat: Vec<f64> = vec![0.0; n];
45    for f in task_files {
46        if let Some(&idx) = node_idx.get(f.as_str()) {
47            heat[idx] = 1.0;
48        }
49    }
50
51    // Heat diffusion: h(t+1) = (1-alpha)*h(t) + alpha * A_norm * h(t)
52    // Run for k iterations
53    let alpha = 0.5;
54    let iterations = 4;
55    for _ in 0..iterations {
56        let mut new_heat = vec![0.0; n];
57        for (i, node) in all_nodes.iter().enumerate() {
58            let self_term = (1.0 - alpha) * heat[i];
59            let mut neighbor_sum = 0.0;
60            if let Some(neighbors) = adj.get(node) {
61                for neighbor in neighbors {
62                    if let Some(&j) = node_idx.get(neighbor.as_str()) {
63                        neighbor_sum += heat[j] / degrees[j];
64                    }
65                }
66            }
67            new_heat[i] = self_term + alpha * neighbor_sum;
68        }
69        heat = new_heat;
70    }
71
72    // PageRank centrality for gateway detection
73    let mut pagerank = vec![1.0 / n as f64; n];
74    let damping = 0.85;
75    for _ in 0..8 {
76        let mut new_pr = vec![(1.0 - damping) / n as f64; n];
77        for (i, node) in all_nodes.iter().enumerate() {
78            if let Some(neighbors) = adj.get(node) {
79                let out_deg = neighbors.len().max(1) as f64;
80                for neighbor in neighbors {
81                    if let Some(&j) = node_idx.get(neighbor.as_str()) {
82                        new_pr[j] += damping * pagerank[i] / out_deg;
83                    }
84                }
85            }
86        }
87        pagerank = new_pr;
88    }
89
90    // Combine: heat (primary) + pagerank centrality (gateway bonus)
91    let mut scores: HashMap<String, f64> = HashMap::new();
92    let heat_max = heat.iter().copied().fold(0.0_f64, f64::max).max(1e-10);
93    let pr_max = pagerank.iter().copied().fold(0.0_f64, f64::max).max(1e-10);
94
95    for (i, node) in all_nodes.iter().enumerate() {
96        let h = heat[i] / heat_max;
97        let pr = pagerank[i] / pr_max;
98        let combined = h * 0.8 + pr * 0.2;
99        if combined > 0.01 {
100            scores.insert(node.clone(), combined);
101        }
102    }
103
104    if !task_keywords.is_empty() {
105        let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
106        for file_path in &all_nodes {
107            let path_lower = file_path.to_lowercase();
108            let mut keyword_hits = 0;
109            for kw in &kw_lower {
110                if path_lower.contains(kw) {
111                    keyword_hits += 1;
112                }
113                if let Some(entry) = gp.get_file_entry(file_path) {
114                    for export in &entry.exports {
115                        if export.to_lowercase().contains(kw) {
116                            keyword_hits += 1;
117                        }
118                    }
119                }
120            }
121            if keyword_hits > 0 {
122                let boost = (keyword_hits as f64 * 0.15).min(0.6);
123                let entry = scores.entry(file_path.clone()).or_insert(0.0);
124                *entry = (*entry + boost).min(1.0);
125            }
126        }
127    }
128
129    let mut result: Vec<RelevanceScore> = scores
130        .into_iter()
131        .map(|(path, score)| {
132            let mode = recommend_mode(score);
133            RelevanceScore {
134                path,
135                score,
136                recommended_mode: mode,
137            }
138        })
139        .collect();
140
141    result.sort_by(|a, b| {
142        b.score
143            .partial_cmp(&a.score)
144            .unwrap_or(std::cmp::Ordering::Equal)
145    });
146    result
147}
148
149pub fn compute_relevance_from_intent(
150    gp: &GraphProvider,
151    intent: &super::intent_engine::StructuredIntent,
152) -> Vec<RelevanceScore> {
153    use super::intent_engine::IntentScope;
154
155    let mut file_seeds: Vec<String> = Vec::new();
156    let mut extra_keywords: Vec<String> = intent.keywords.clone();
157
158    let file_paths = gp.file_paths();
159    for target in &intent.targets {
160        if target.contains('.') || target.contains('/') {
161            let matched = resolve_target_to_files(&file_paths, target);
162            if matched.is_empty() {
163                extra_keywords.push(target.clone());
164            } else {
165                file_seeds.extend(matched);
166            }
167        } else {
168            let from_symbol = resolve_symbol_to_files(gp, target);
169            if from_symbol.is_empty() {
170                extra_keywords.push(target.clone());
171            } else {
172                file_seeds.extend(from_symbol);
173            }
174        }
175    }
176
177    if let Some(lang) = &intent.language_hint {
178        let lang_ext = match lang.as_str() {
179            "rust" => Some("rs"),
180            "typescript" => Some("ts"),
181            "javascript" => Some("js"),
182            "python" => Some("py"),
183            "go" => Some("go"),
184            "ruby" => Some("rb"),
185            "java" => Some("java"),
186            _ => None,
187        };
188        if let Some(ext) = lang_ext
189            && file_seeds.is_empty()
190        {
191            for path in &file_paths {
192                if path.ends_with(&format!(".{ext}")) {
193                    extra_keywords.push(
194                        std::path::Path::new(path)
195                            .file_stem()
196                            .and_then(|s| s.to_str())
197                            .unwrap_or("")
198                            .to_string(),
199                    );
200                    break;
201                }
202            }
203        }
204    }
205
206    let mut result = compute_relevance(gp, &file_seeds, &extra_keywords);
207
208    match intent.scope {
209        IntentScope::SingleFile => {
210            result.truncate(5);
211        }
212        IntentScope::MultiFile => {
213            result.truncate(15);
214        }
215        IntentScope::CrossModule | IntentScope::ProjectWide => {}
216    }
217
218    result
219}
220
221fn resolve_target_to_files(file_paths: &[String], target: &str) -> Vec<String> {
222    file_paths
223        .iter()
224        .filter(|path| path.ends_with(target) || path.contains(target))
225        .cloned()
226        .collect()
227}
228
229fn resolve_symbol_to_files(gp: &GraphProvider, symbol: &str) -> Vec<String> {
230    let found = gp.find_symbols(symbol, None, None);
231    let mut matches: Vec<String> = found
232        .into_iter()
233        .map(|s| s.file)
234        .collect::<HashSet<_>>()
235        .into_iter()
236        .collect();
237    if matches.is_empty() {
238        let sym_lower = symbol.to_lowercase();
239        for path in gp.file_paths() {
240            if let Some(entry) = gp.get_file_entry(&path)
241                && entry
242                    .exports
243                    .iter()
244                    .any(|e| e.to_lowercase().contains(&sym_lower))
245                && !matches.contains(&path)
246            {
247                matches.push(path);
248            }
249        }
250    }
251    matches
252}
253
254fn recommend_mode(score: f64) -> &'static str {
255    if score >= 0.8 {
256        "full"
257    } else if score >= 0.5 {
258        "signatures"
259    } else if score >= 0.2 {
260        "map"
261    } else {
262        "reference"
263    }
264}
265
266fn build_adjacency_resolved(
267    edges: &[EdgeInfo],
268    file_set: &HashSet<String>,
269) -> HashMap<String, Vec<String>> {
270    let file_paths_vec: Vec<&str> = file_set.iter().map(String::as_str).collect();
271    let module_to_file = build_module_map(edges, file_set, &file_paths_vec);
272    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
273
274    for edge in edges {
275        let from = &edge.from;
276        let to_resolved = module_to_file
277            .get(&edge.to)
278            .cloned()
279            .unwrap_or_else(|| edge.to.clone());
280
281        if file_set.contains(from) && file_set.contains(&to_resolved) {
282            adj.entry(from.clone())
283                .or_default()
284                .push(to_resolved.clone());
285            adj.entry(to_resolved).or_default().push(from.clone());
286        }
287    }
288    adj
289}
290
291fn build_module_map(
292    edges: &[EdgeInfo],
293    file_set: &HashSet<String>,
294    file_paths: &[&str],
295) -> HashMap<String, String> {
296    let mut mapping: HashMap<String, String> = HashMap::new();
297
298    let edge_targets: HashSet<String> = edges.iter().map(|e| e.to.clone()).collect();
299
300    for target in &edge_targets {
301        if file_set.contains(target) {
302            mapping.insert(target.clone(), target.clone());
303            continue;
304        }
305
306        if let Some(resolved) = resolve_module_to_file(target, file_paths) {
307            mapping.insert(target.clone(), resolved);
308        }
309    }
310
311    mapping
312}
313
314fn resolve_module_to_file(module_path: &str, file_paths: &[&str]) -> Option<String> {
315    let cleaned = module_path
316        .trim_start_matches("crate::")
317        .trim_start_matches("super::");
318
319    // Strip trailing symbol (e.g. `core::tokens::count_tokens` → `core::tokens`)
320    let parts: Vec<&str> = cleaned.split("::").collect();
321
322    // Try progressively shorter prefixes to find a matching file
323    for end in (1..=parts.len()).rev() {
324        let candidate = parts[..end].join("/");
325
326        // Try as .rs file
327        for fp in file_paths {
328            let fp_normalized = fp
329                .trim_start_matches("rust/src/")
330                .trim_start_matches("src/");
331
332            if fp_normalized == format!("{candidate}.rs")
333                || fp_normalized == format!("{candidate}/mod.rs")
334                || fp.ends_with(&format!("/{candidate}.rs"))
335                || fp.ends_with(&format!("/{candidate}/mod.rs"))
336            {
337                return Some(fp.to_string());
338            }
339        }
340    }
341
342    // Fallback: match by last segment as filename stem
343    if let Some(last) = parts.last() {
344        let stem = format!("{last}.rs");
345        for fp in file_paths {
346            if fp.ends_with(&stem) {
347                return Some(fp.to_string());
348            }
349        }
350    }
351
352    None
353}
354
355/// Extract likely task-relevant file paths and keywords from a task description.
356pub fn parse_task_hints(task_description: &str) -> (Vec<String>, Vec<String>) {
357    let mut files = Vec::new();
358    let mut keywords = Vec::new();
359
360    for word in task_description.split_whitespace() {
361        let clean = word.trim_matches(|c: char| {
362            !c.is_alphanumeric() && c != '.' && c != '/' && c != '_' && c != '-'
363        });
364        if clean.contains('.') && {
365            let p = std::path::Path::new(clean);
366            clean.contains('/')
367                || p.extension().is_some_and(|e| {
368                    e.eq_ignore_ascii_case("rs")
369                        || e.eq_ignore_ascii_case("ts")
370                        || e.eq_ignore_ascii_case("py")
371                        || e.eq_ignore_ascii_case("go")
372                        || e.eq_ignore_ascii_case("js")
373                })
374        } {
375            files.push(clean.to_string());
376        } else if clean.len() >= 3 && !STOP_WORDS.contains(&clean.to_lowercase().as_str()) {
377            keywords.push(clean.to_string());
378        }
379    }
380
381    (files, keywords)
382}
383
384const STOP_WORDS: &[&str] = &[
385    "the", "and", "for", "that", "this", "with", "from", "have", "has", "was", "are", "been",
386    "not", "but", "all", "can", "had", "her", "one", "our", "out", "you", "its", "will", "each",
387    "make", "like", "fix", "add", "use", "get", "set", "run", "new", "old", "should", "would",
388    "could", "into", "also", "than", "them", "then", "when", "just", "only", "very", "some",
389    "more", "other", "nach", "und", "die", "der", "das", "ist", "ein", "eine", "nicht", "auf",
390    "mit",
391];
392
393struct StructuralWeights {
394    error_handling: f64,
395    definition: f64,
396    control_flow: f64,
397    closing_brace: f64,
398    other: f64,
399}
400
401impl StructuralWeights {
402    const DEFAULT: Self = Self {
403        error_handling: 1.5,
404        definition: 1.0,
405        control_flow: 0.5,
406        closing_brace: 0.15,
407        other: 0.3,
408    };
409
410    fn for_task_type(task_type: Option<super::intent_engine::TaskType>) -> Self {
411        use super::intent_engine::TaskType;
412        match task_type {
413            Some(TaskType::FixBug) => Self {
414                error_handling: 2.0,
415                definition: 0.8,
416                control_flow: 0.8,
417                closing_brace: 0.1,
418                other: 0.2,
419            },
420            Some(TaskType::Debug) => Self {
421                error_handling: 2.0,
422                definition: 0.6,
423                control_flow: 1.0,
424                closing_brace: 0.1,
425                other: 0.2,
426            },
427            Some(TaskType::Generate) => Self {
428                error_handling: 0.8,
429                definition: 1.5,
430                control_flow: 0.3,
431                closing_brace: 0.15,
432                other: 0.4,
433            },
434            Some(TaskType::Refactor) => Self {
435                error_handling: 1.0,
436                definition: 1.5,
437                control_flow: 0.6,
438                closing_brace: 0.2,
439                other: 0.3,
440            },
441            Some(TaskType::Test) => Self {
442                error_handling: 1.2,
443                definition: 1.3,
444                control_flow: 0.4,
445                closing_brace: 0.15,
446                other: 0.3,
447            },
448            Some(TaskType::Review) => Self {
449                error_handling: 1.3,
450                definition: 1.2,
451                control_flow: 0.6,
452                closing_brace: 0.15,
453                other: 0.3,
454            },
455            None | Some(TaskType::Explore | _) => Self::DEFAULT,
456        }
457    }
458}
459
460/// Information Bottleneck filter v3 — Mutual Information scoring, QUITO-X inspired.
461///
462/// IB principle: maximize I(T;Y) (task relevance) while minimizing I(T;X) (input redundancy).
463/// v3: MI(line, task) approximated via token overlap + IDF weighting + structural importance.
464///
465/// Key changes from v2:
466///   - Mutual Information scoring: MI(line, task) = H(line) - H(line|task)
467///   - Adaptive budget allocation based on task type via TaskClassifier
468///   - Token-level IDF computed over full document for better term weighting
469///   - Maintains L-curve attention, MMR dedup, error-handling priority from v2
470pub fn information_bottleneck_filter(
471    content: &str,
472    task_keywords: &[String],
473    budget_ratio: f64,
474    force_keep: &[String],
475) -> String {
476    information_bottleneck_filter_typed(content, task_keywords, budget_ratio, None, force_keep)
477}
478
479/// #840: IB filter with optional markdown section-header preservation.
480/// When `preserve_section_headers` is true, the nearest preceding `#`-header
481/// for each selected line is injected into the output so fragments remain
482/// attributable to their enclosing sections.
483pub fn information_bottleneck_filter_with_headers(
484    content: &str,
485    task_keywords: &[String],
486    budget_ratio: f64,
487    force_keep: &[String],
488    preserve_section_headers: bool,
489) -> String {
490    if !preserve_section_headers {
491        return information_bottleneck_filter(content, task_keywords, budget_ratio, force_keep);
492    }
493    let filtered = information_bottleneck_filter(content, task_keywords, budget_ratio, force_keep);
494    inject_section_headers(content, &filtered)
495}
496
497/// For each line in `filtered` that appears in a markdown-sectioned `original`,
498/// find and inject the nearest preceding `#`-header line if not already present.
499fn inject_section_headers(original: &str, filtered: &str) -> String {
500    let orig_lines: Vec<&str> = original.lines().collect();
501    let filt_lines: Vec<&str> = filtered.lines().collect();
502
503    let header_indices: Vec<usize> = orig_lines
504        .iter()
505        .enumerate()
506        .filter(|(_, l)| l.starts_with('#'))
507        .map(|(i, _)| i)
508        .collect();
509
510    if header_indices.is_empty() {
511        return filtered.to_string();
512    }
513
514    let mut result_lines: Vec<&str> = Vec::with_capacity(filt_lines.len() + header_indices.len());
515    let mut injected_headers: std::collections::HashSet<usize> = std::collections::HashSet::new();
516
517    for filt_line in &filt_lines {
518        let trimmed = filt_line.trim();
519        if trimmed.is_empty() || trimmed.starts_with("[task:") {
520            result_lines.push(filt_line);
521            continue;
522        }
523        if let Some(orig_idx) = orig_lines.iter().position(|l| *l == *filt_line)
524            && let Some(&h) = header_indices.iter().rev().find(|&&h| h <= orig_idx)
525            && !injected_headers.contains(&h)
526            && !filt_lines.contains(&orig_lines[h])
527        {
528            injected_headers.insert(h);
529            result_lines.push(orig_lines[h]);
530        }
531        result_lines.push(filt_line);
532    }
533
534    result_lines.join("\n")
535}
536
537/// Task-type-aware IB filter. Uses `TaskType` to adjust structural weights.
538/// `force_keep` lines (explicit `protect` tokens, #709) are kept verbatim on top
539/// of the budget; `&[]` reproduces the pre-protect output byte-for-byte (#498).
540pub fn information_bottleneck_filter_typed(
541    content: &str,
542    task_keywords: &[String],
543    budget_ratio: f64,
544    task_type: Option<super::intent_engine::TaskType>,
545    force_keep: &[String],
546) -> String {
547    let lines: Vec<&str> = content.lines().collect();
548    if lines.is_empty() {
549        return String::new();
550    }
551
552    let n = lines.len();
553    let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
554    let attention = LearnedAttention::with_defaults();
555
556    let mut global_token_freq: HashMap<&str, usize> = HashMap::new();
557    for line in &lines {
558        for token in line.split_whitespace() {
559            *global_token_freq.entry(token).or_insert(0) += 1;
560        }
561    }
562    let total_unique = global_token_freq.len().max(1) as f64;
563    let total_lines = n.max(1) as f64;
564
565    let task_token_set: HashSet<String> = kw_lower
566        .iter()
567        .flat_map(|kw| kw.split(|c: char| !c.is_alphanumeric()).map(String::from))
568        .filter(|t| t.len() >= 2)
569        .collect();
570
571    let effective_ratio = if task_token_set.is_empty() {
572        budget_ratio
573    } else {
574        adaptive_ib_budget(content, budget_ratio)
575    };
576
577    let weights = StructuralWeights::for_task_type(task_type);
578
579    let mut scored_lines: Vec<(usize, &str, f64)> = lines
580        .iter()
581        .enumerate()
582        .map(|(i, line)| {
583            let trimmed = line.trim();
584            if trimmed.is_empty() {
585                return (i, *line, 0.05);
586            }
587
588            let line_lower = trimmed.to_lowercase();
589            let line_tokens: Vec<&str> = trimmed.split_whitespace().collect();
590            let line_token_count = line_tokens.len().max(1) as f64;
591
592            let mi_score = if task_token_set.is_empty() {
593                0.0
594            } else {
595                let line_token_set: HashSet<String> =
596                    line_tokens.iter().map(|t| t.to_lowercase()).collect();
597                let overlap: f64 = line_token_set
598                    .iter()
599                    .filter(|t| task_token_set.iter().any(|kw| t.contains(kw.as_str())))
600                    .map(|t| {
601                        let freq = *global_token_freq.get(t.as_str()).unwrap_or(&1) as f64;
602                        (total_lines / freq).ln().max(0.1)
603                    })
604                    .sum();
605                overlap / line_token_count
606            };
607
608            let keyword_hits: f64 = kw_lower
609                .iter()
610                .filter(|kw| line_lower.contains(kw.as_str()))
611                .count() as f64;
612
613            let structural = if is_error_handling(trimmed) {
614                weights.error_handling
615            } else if is_definition_line(trimmed) {
616                weights.definition
617            } else if is_control_flow(trimmed) {
618                weights.control_flow
619            } else if is_closing_brace(trimmed) {
620                weights.closing_brace
621            } else {
622                weights.other
623            };
624            let relevance = mi_score * 0.4 + keyword_hits * 0.3 + structural;
625
626            let unique_in_line = line_tokens.iter().collect::<HashSet<_>>().len() as f64;
627            let token_diversity = unique_in_line / line_token_count;
628
629            let avg_idf: f64 = if line_tokens.is_empty() {
630                0.0
631            } else {
632                line_tokens
633                    .iter()
634                    .map(|t| {
635                        let freq = *global_token_freq.get(t).unwrap_or(&1) as f64;
636                        (total_unique / freq).ln().max(0.0)
637                    })
638                    .sum::<f64>()
639                    / line_token_count
640            };
641            let information = (token_diversity * 0.4 + (avg_idf.min(3.0) / 3.0) * 0.6).min(1.0);
642
643            let pos = i as f64 / n.max(1) as f64;
644            let attn_weight = attention.weight(pos);
645
646            let score = (relevance * 0.6 + 0.05)
647                * (information * 0.25 + 0.05)
648                * (attn_weight * 0.15 + 0.05);
649
650            // Explicit protect tokens (#709) force the line to the top of the
651            // ranking; INF survives the MMR lambda penalty so it is always kept.
652            let score = if super::protect::line_is_protected(line, force_keep) {
653                f64::INFINITY
654            } else {
655                score
656            };
657
658            (i, *line, score)
659        })
660        .collect();
661
662    // Protected lines (#709) are kept on top of the ranked budget, so widen the
663    // budget to hold them without displacing other selected content. With an
664    // empty `force_keep` this adds zero and the budget is byte-identical (#498).
665    let protected_count = lines
666        .iter()
667        .filter(|l| super::protect::line_is_protected(l, force_keep))
668        .count();
669    let budget = (((n as f64) * effective_ratio).ceil() as usize) + protected_count;
670
671    scored_lines.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
672
673    let selected = mmr_select(&scored_lines, budget, 0.3);
674
675    let mut output_lines: Vec<&str> = Vec::with_capacity(budget + 1);
676
677    if !kw_lower.is_empty() {
678        output_lines.push("");
679    }
680
681    for (_, line, _) in &selected {
682        output_lines.push(line);
683    }
684
685    if !kw_lower.is_empty() {
686        let summary = format!("[task: {}]", task_keywords.join(", "));
687        let mut result = summary;
688        result.push('\n');
689        result.push_str(&output_lines[1..].to_vec().join("\n"));
690        return result;
691    }
692
693    output_lines.join("\n")
694}
695
696/// Maximum Marginal Relevance selection — greedy selection that penalizes
697/// redundancy with already-selected lines using token-set Jaccard similarity.
698///
699/// MMR(i) = relevance(i) - lambda * max_{j in S} jaccard(i, j)
700fn mmr_select<'a>(
701    candidates: &[(usize, &'a str, f64)],
702    budget: usize,
703    lambda: f64,
704) -> Vec<(usize, &'a str, f64)> {
705    if candidates.is_empty() || budget == 0 {
706        return Vec::new();
707    }
708
709    let mut selected: Vec<(usize, &'a str, f64)> = Vec::with_capacity(budget);
710    let mut remaining: Vec<(usize, &'a str, f64)> = candidates.to_vec();
711
712    // Always take the top-scored line first
713    selected.push(remaining.remove(0));
714
715    while selected.len() < budget && !remaining.is_empty() {
716        let mut best_idx = 0;
717        let mut best_mmr = f64::NEG_INFINITY;
718
719        for (i, &(_, cand_line, cand_score)) in remaining.iter().enumerate() {
720            let cand_tokens: HashSet<&str> = cand_line.split_whitespace().collect();
721            if cand_tokens.is_empty() {
722                if cand_score > best_mmr {
723                    best_mmr = cand_score;
724                    best_idx = i;
725                }
726                continue;
727            }
728
729            let max_sim = selected
730                .iter()
731                .map(|&(_, sel_line, _)| {
732                    let sel_tokens: HashSet<&str> = sel_line.split_whitespace().collect();
733                    if sel_tokens.is_empty() {
734                        return 0.0;
735                    }
736                    let inter = cand_tokens.intersection(&sel_tokens).count();
737                    let union = cand_tokens.union(&sel_tokens).count();
738                    if union == 0 {
739                        0.0
740                    } else {
741                        inter as f64 / union as f64
742                    }
743                })
744                .fold(0.0_f64, f64::max);
745
746            let mmr = cand_score - lambda * max_sim;
747            if mmr > best_mmr {
748                best_mmr = mmr;
749                best_idx = i;
750            }
751        }
752
753        selected.push(remaining.remove(best_idx));
754    }
755
756    selected
757}
758
759fn is_error_handling(line: &str) -> bool {
760    line.starts_with("return Err(")
761        || line.starts_with("Err(")
762        || line.starts_with("bail!(")
763        || line.starts_with("anyhow::bail!")
764        || line.contains(".map_err(")
765        || line.contains("unwrap()")
766        || line.contains("expect(\"")
767        || line.starts_with("raise ")
768        || line.starts_with("throw ")
769        || line.starts_with("catch ")
770        || line.starts_with("except ")
771        || line.starts_with("try ")
772        || (line.contains("?;") && !line.starts_with("//"))
773        || line.starts_with("panic!(")
774        || line.contains("Error::")
775        || line.contains("error!")
776}
777
778/// Compute an adaptive IB budget ratio based on content characteristics.
779/// Highly repetitive content → more aggressive filtering (lower ratio).
780/// High-entropy diverse content → more conservative (higher ratio).
781pub fn adaptive_ib_budget(content: &str, base_ratio: f64) -> f64 {
782    let lines: Vec<&str> = content.lines().collect();
783    if lines.len() < 10 {
784        return 1.0;
785    }
786
787    let mut token_freq: HashMap<&str, usize> = HashMap::new();
788    let mut total_tokens = 0usize;
789    for line in &lines {
790        for token in line.split_whitespace() {
791            *token_freq.entry(token).or_insert(0) += 1;
792            total_tokens += 1;
793        }
794    }
795
796    if total_tokens == 0 {
797        return base_ratio;
798    }
799
800    let unique_ratio = token_freq.len() as f64 / total_tokens as f64;
801    let repetition_factor = 1.0 - unique_ratio;
802
803    (base_ratio * (1.0 - repetition_factor * 0.3)).clamp(0.2, 1.0)
804}
805
806fn is_definition_line(line: &str) -> bool {
807    let prefixes = [
808        "fn ",
809        "pub fn ",
810        "async fn ",
811        "pub async fn ",
812        "struct ",
813        "pub struct ",
814        "enum ",
815        "pub enum ",
816        "trait ",
817        "pub trait ",
818        "impl ",
819        "type ",
820        "pub type ",
821        "const ",
822        "pub const ",
823        "static ",
824        "pub static ",
825        "class ",
826        "export class ",
827        "interface ",
828        "export interface ",
829        "function ",
830        "export function ",
831        "async function ",
832        "def ",
833        "async def ",
834        "func ",
835    ];
836    prefixes
837        .iter()
838        .any(|p| line.starts_with(p) || line.trim_start().starts_with(p))
839}
840
841fn is_control_flow(line: &str) -> bool {
842    let trimmed = line.trim();
843    trimmed.starts_with("if ")
844        || trimmed.starts_with("else ")
845        || trimmed.starts_with("match ")
846        || trimmed.starts_with("for ")
847        || trimmed.starts_with("while ")
848        || trimmed.starts_with("return ")
849        || trimmed.starts_with("break")
850        || trimmed.starts_with("continue")
851        || trimmed.starts_with("yield")
852        || trimmed.starts_with("await ")
853}
854
855fn is_closing_brace(line: &str) -> bool {
856    let trimmed = line.trim();
857    trimmed == "}" || trimmed == "};" || trimmed == "})" || trimmed == "});"
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    #[test]
865    fn parse_task_finds_files_and_keywords() {
866        let (files, keywords) =
867            parse_task_hints("Fix the authentication bug in src/auth.rs and update tests");
868        assert!(files.iter().any(|f| f.contains("auth.rs")));
869        assert!(
870            keywords
871                .iter()
872                .any(|k| k.to_lowercase().contains("authentication"))
873        );
874    }
875
876    #[test]
877    fn recommend_mode_by_score() {
878        assert_eq!(recommend_mode(1.0), "full");
879        assert_eq!(recommend_mode(0.6), "signatures");
880        assert_eq!(recommend_mode(0.3), "map");
881        assert_eq!(recommend_mode(0.1), "reference");
882    }
883
884    #[test]
885    fn info_bottleneck_preserves_definitions() {
886        let content = "fn main() {\n    let x = 42;\n    // boring comment\n    println!(x);\n}\n";
887        let result = information_bottleneck_filter(content, &["main".to_string()], 0.6, &[]);
888        assert!(result.contains("fn main"), "definitions must be preserved");
889        assert!(result.contains("[task: main]"), "should have task summary");
890    }
891
892    #[test]
893    fn protect_force_keeps_line_in_ib() {
894        let content = "fn main() {\n    let x = 1;\n    let unimportant = 2;\n    let y = 3;\n    let z = 4;\n}\n";
895        // Tiny budget → the 'unimportant' line is normally filtered out.
896        let kept = information_bottleneck_filter(
897            content,
898            &["main".to_string()],
899            0.1,
900            &["unimportant".to_string()],
901        );
902        assert!(
903            kept.contains("let unimportant = 2;"),
904            "protected line must survive the IB budget: {kept}"
905        );
906    }
907
908    #[test]
909    fn ib_empty_force_keep_is_byte_identical() {
910        // Protect must not change the unprotected IB output (#498).
911        let content = "fn main() {\n    let x = 1;\n    return Err(\"e\");\n    let y = 2;\n}\n";
912        let a = information_bottleneck_filter(content, &["main".to_string()], 0.5, &[]);
913        let b = information_bottleneck_filter_typed(content, &["main".to_string()], 0.5, None, &[]);
914        assert_eq!(a, b);
915    }
916
917    #[test]
918    fn info_bottleneck_error_handling_priority() {
919        let content = "fn validate() {\n    let data = parse()?;\n    return Err(\"invalid\");\n    let x = 1;\n    let y = 2;\n}\n";
920        let result = information_bottleneck_filter(content, &["validate".to_string()], 0.5, &[]);
921        assert!(
922            result.contains("return Err"),
923            "error handling should survive filtering"
924        );
925    }
926
927    #[test]
928    fn info_bottleneck_score_sorted() {
929        let content = "fn important() {\n    let x = 1;\n    let y = 2;\n    let z = 3;\n}\n}\n";
930        let result = information_bottleneck_filter(content, &[], 0.6, &[]);
931        let lines: Vec<&str> = result.lines().collect();
932        let def_pos = lines.iter().position(|l| l.contains("fn important"));
933        let brace_pos = lines.iter().position(|l| l.trim() == "}");
934        if let (Some(d), Some(b)) = (def_pos, brace_pos) {
935            assert!(
936                d < b,
937                "definitions should appear before closing braces in score-sorted output"
938            );
939        }
940    }
941
942    #[test]
943    fn adaptive_budget_reduces_for_repetitive() {
944        let repetitive = "let x = 1;\n".repeat(50);
945        let diverse = (0..50)
946            .map(|i| format!("let var_{i} = func_{i}(arg_{i});"))
947            .collect::<Vec<_>>()
948            .join("\n");
949        let budget_rep = super::adaptive_ib_budget(&repetitive, 0.7);
950        let budget_div = super::adaptive_ib_budget(&diverse, 0.7);
951        assert!(
952            budget_rep < budget_div,
953            "repetitive content should get lower budget"
954        );
955    }
956}