Skip to main content

lean_ctx/core/
task_relevance.rs

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