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/// Task-type-aware IB filter. Uses `TaskType` to adjust structural weights.
480/// `force_keep` lines (explicit `protect` tokens, #709) are kept verbatim on top
481/// of the budget; `&[]` reproduces the pre-protect output byte-for-byte (#498).
482pub fn information_bottleneck_filter_typed(
483    content: &str,
484    task_keywords: &[String],
485    budget_ratio: f64,
486    task_type: Option<super::intent_engine::TaskType>,
487    force_keep: &[String],
488) -> String {
489    let lines: Vec<&str> = content.lines().collect();
490    if lines.is_empty() {
491        return String::new();
492    }
493
494    let n = lines.len();
495    let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
496    let attention = LearnedAttention::with_defaults();
497
498    let mut global_token_freq: HashMap<&str, usize> = HashMap::new();
499    for line in &lines {
500        for token in line.split_whitespace() {
501            *global_token_freq.entry(token).or_insert(0) += 1;
502        }
503    }
504    let total_unique = global_token_freq.len().max(1) as f64;
505    let total_lines = n.max(1) as f64;
506
507    let task_token_set: HashSet<String> = kw_lower
508        .iter()
509        .flat_map(|kw| kw.split(|c: char| !c.is_alphanumeric()).map(String::from))
510        .filter(|t| t.len() >= 2)
511        .collect();
512
513    let effective_ratio = if task_token_set.is_empty() {
514        budget_ratio
515    } else {
516        adaptive_ib_budget(content, budget_ratio)
517    };
518
519    let weights = StructuralWeights::for_task_type(task_type);
520
521    let mut scored_lines: Vec<(usize, &str, f64)> = lines
522        .iter()
523        .enumerate()
524        .map(|(i, line)| {
525            let trimmed = line.trim();
526            if trimmed.is_empty() {
527                return (i, *line, 0.05);
528            }
529
530            let line_lower = trimmed.to_lowercase();
531            let line_tokens: Vec<&str> = trimmed.split_whitespace().collect();
532            let line_token_count = line_tokens.len().max(1) as f64;
533
534            let mi_score = if task_token_set.is_empty() {
535                0.0
536            } else {
537                let line_token_set: HashSet<String> =
538                    line_tokens.iter().map(|t| t.to_lowercase()).collect();
539                let overlap: f64 = line_token_set
540                    .iter()
541                    .filter(|t| task_token_set.iter().any(|kw| t.contains(kw.as_str())))
542                    .map(|t| {
543                        let freq = *global_token_freq.get(t.as_str()).unwrap_or(&1) as f64;
544                        (total_lines / freq).ln().max(0.1)
545                    })
546                    .sum();
547                overlap / line_token_count
548            };
549
550            let keyword_hits: f64 = kw_lower
551                .iter()
552                .filter(|kw| line_lower.contains(kw.as_str()))
553                .count() as f64;
554
555            let structural = if is_error_handling(trimmed) {
556                weights.error_handling
557            } else if is_definition_line(trimmed) {
558                weights.definition
559            } else if is_control_flow(trimmed) {
560                weights.control_flow
561            } else if is_closing_brace(trimmed) {
562                weights.closing_brace
563            } else {
564                weights.other
565            };
566            let relevance = mi_score * 0.4 + keyword_hits * 0.3 + structural;
567
568            let unique_in_line = line_tokens.iter().collect::<HashSet<_>>().len() as f64;
569            let token_diversity = unique_in_line / line_token_count;
570
571            let avg_idf: f64 = if line_tokens.is_empty() {
572                0.0
573            } else {
574                line_tokens
575                    .iter()
576                    .map(|t| {
577                        let freq = *global_token_freq.get(t).unwrap_or(&1) as f64;
578                        (total_unique / freq).ln().max(0.0)
579                    })
580                    .sum::<f64>()
581                    / line_token_count
582            };
583            let information = (token_diversity * 0.4 + (avg_idf.min(3.0) / 3.0) * 0.6).min(1.0);
584
585            let pos = i as f64 / n.max(1) as f64;
586            let attn_weight = attention.weight(pos);
587
588            let score = (relevance * 0.6 + 0.05)
589                * (information * 0.25 + 0.05)
590                * (attn_weight * 0.15 + 0.05);
591
592            // Explicit protect tokens (#709) force the line to the top of the
593            // ranking; INF survives the MMR lambda penalty so it is always kept.
594            let score = if super::protect::line_is_protected(line, force_keep) {
595                f64::INFINITY
596            } else {
597                score
598            };
599
600            (i, *line, score)
601        })
602        .collect();
603
604    // Protected lines (#709) are kept on top of the ranked budget, so widen the
605    // budget to hold them without displacing other selected content. With an
606    // empty `force_keep` this adds zero and the budget is byte-identical (#498).
607    let protected_count = lines
608        .iter()
609        .filter(|l| super::protect::line_is_protected(l, force_keep))
610        .count();
611    let budget = (((n as f64) * effective_ratio).ceil() as usize) + protected_count;
612
613    scored_lines.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
614
615    let selected = mmr_select(&scored_lines, budget, 0.3);
616
617    let mut output_lines: Vec<&str> = Vec::with_capacity(budget + 1);
618
619    if !kw_lower.is_empty() {
620        output_lines.push("");
621    }
622
623    for (_, line, _) in &selected {
624        output_lines.push(line);
625    }
626
627    if !kw_lower.is_empty() {
628        let summary = format!("[task: {}]", task_keywords.join(", "));
629        let mut result = summary;
630        result.push('\n');
631        result.push_str(&output_lines[1..].to_vec().join("\n"));
632        return result;
633    }
634
635    output_lines.join("\n")
636}
637
638/// Maximum Marginal Relevance selection — greedy selection that penalizes
639/// redundancy with already-selected lines using token-set Jaccard similarity.
640///
641/// MMR(i) = relevance(i) - lambda * max_{j in S} jaccard(i, j)
642fn mmr_select<'a>(
643    candidates: &[(usize, &'a str, f64)],
644    budget: usize,
645    lambda: f64,
646) -> Vec<(usize, &'a str, f64)> {
647    if candidates.is_empty() || budget == 0 {
648        return Vec::new();
649    }
650
651    let mut selected: Vec<(usize, &'a str, f64)> = Vec::with_capacity(budget);
652    let mut remaining: Vec<(usize, &'a str, f64)> = candidates.to_vec();
653
654    // Always take the top-scored line first
655    selected.push(remaining.remove(0));
656
657    while selected.len() < budget && !remaining.is_empty() {
658        let mut best_idx = 0;
659        let mut best_mmr = f64::NEG_INFINITY;
660
661        for (i, &(_, cand_line, cand_score)) in remaining.iter().enumerate() {
662            let cand_tokens: HashSet<&str> = cand_line.split_whitespace().collect();
663            if cand_tokens.is_empty() {
664                if cand_score > best_mmr {
665                    best_mmr = cand_score;
666                    best_idx = i;
667                }
668                continue;
669            }
670
671            let max_sim = selected
672                .iter()
673                .map(|&(_, sel_line, _)| {
674                    let sel_tokens: HashSet<&str> = sel_line.split_whitespace().collect();
675                    if sel_tokens.is_empty() {
676                        return 0.0;
677                    }
678                    let inter = cand_tokens.intersection(&sel_tokens).count();
679                    let union = cand_tokens.union(&sel_tokens).count();
680                    if union == 0 {
681                        0.0
682                    } else {
683                        inter as f64 / union as f64
684                    }
685                })
686                .fold(0.0_f64, f64::max);
687
688            let mmr = cand_score - lambda * max_sim;
689            if mmr > best_mmr {
690                best_mmr = mmr;
691                best_idx = i;
692            }
693        }
694
695        selected.push(remaining.remove(best_idx));
696    }
697
698    selected
699}
700
701fn is_error_handling(line: &str) -> bool {
702    line.starts_with("return Err(")
703        || line.starts_with("Err(")
704        || line.starts_with("bail!(")
705        || line.starts_with("anyhow::bail!")
706        || line.contains(".map_err(")
707        || line.contains("unwrap()")
708        || line.contains("expect(\"")
709        || line.starts_with("raise ")
710        || line.starts_with("throw ")
711        || line.starts_with("catch ")
712        || line.starts_with("except ")
713        || line.starts_with("try ")
714        || (line.contains("?;") && !line.starts_with("//"))
715        || line.starts_with("panic!(")
716        || line.contains("Error::")
717        || line.contains("error!")
718}
719
720/// Compute an adaptive IB budget ratio based on content characteristics.
721/// Highly repetitive content → more aggressive filtering (lower ratio).
722/// High-entropy diverse content → more conservative (higher ratio).
723pub fn adaptive_ib_budget(content: &str, base_ratio: f64) -> f64 {
724    let lines: Vec<&str> = content.lines().collect();
725    if lines.len() < 10 {
726        return 1.0;
727    }
728
729    let mut token_freq: HashMap<&str, usize> = HashMap::new();
730    let mut total_tokens = 0usize;
731    for line in &lines {
732        for token in line.split_whitespace() {
733            *token_freq.entry(token).or_insert(0) += 1;
734            total_tokens += 1;
735        }
736    }
737
738    if total_tokens == 0 {
739        return base_ratio;
740    }
741
742    let unique_ratio = token_freq.len() as f64 / total_tokens as f64;
743    let repetition_factor = 1.0 - unique_ratio;
744
745    (base_ratio * (1.0 - repetition_factor * 0.3)).clamp(0.2, 1.0)
746}
747
748fn is_definition_line(line: &str) -> bool {
749    let prefixes = [
750        "fn ",
751        "pub fn ",
752        "async fn ",
753        "pub async fn ",
754        "struct ",
755        "pub struct ",
756        "enum ",
757        "pub enum ",
758        "trait ",
759        "pub trait ",
760        "impl ",
761        "type ",
762        "pub type ",
763        "const ",
764        "pub const ",
765        "static ",
766        "pub static ",
767        "class ",
768        "export class ",
769        "interface ",
770        "export interface ",
771        "function ",
772        "export function ",
773        "async function ",
774        "def ",
775        "async def ",
776        "func ",
777    ];
778    prefixes
779        .iter()
780        .any(|p| line.starts_with(p) || line.trim_start().starts_with(p))
781}
782
783fn is_control_flow(line: &str) -> bool {
784    let trimmed = line.trim();
785    trimmed.starts_with("if ")
786        || trimmed.starts_with("else ")
787        || trimmed.starts_with("match ")
788        || trimmed.starts_with("for ")
789        || trimmed.starts_with("while ")
790        || trimmed.starts_with("return ")
791        || trimmed.starts_with("break")
792        || trimmed.starts_with("continue")
793        || trimmed.starts_with("yield")
794        || trimmed.starts_with("await ")
795}
796
797fn is_closing_brace(line: &str) -> bool {
798    let trimmed = line.trim();
799    trimmed == "}" || trimmed == "};" || trimmed == "})" || trimmed == "});"
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn parse_task_finds_files_and_keywords() {
808        let (files, keywords) =
809            parse_task_hints("Fix the authentication bug in src/auth.rs and update tests");
810        assert!(files.iter().any(|f| f.contains("auth.rs")));
811        assert!(
812            keywords
813                .iter()
814                .any(|k| k.to_lowercase().contains("authentication"))
815        );
816    }
817
818    #[test]
819    fn recommend_mode_by_score() {
820        assert_eq!(recommend_mode(1.0), "full");
821        assert_eq!(recommend_mode(0.6), "signatures");
822        assert_eq!(recommend_mode(0.3), "map");
823        assert_eq!(recommend_mode(0.1), "reference");
824    }
825
826    #[test]
827    fn info_bottleneck_preserves_definitions() {
828        let content = "fn main() {\n    let x = 42;\n    // boring comment\n    println!(x);\n}\n";
829        let result = information_bottleneck_filter(content, &["main".to_string()], 0.6, &[]);
830        assert!(result.contains("fn main"), "definitions must be preserved");
831        assert!(result.contains("[task: main]"), "should have task summary");
832    }
833
834    #[test]
835    fn protect_force_keeps_line_in_ib() {
836        let content = "fn main() {\n    let x = 1;\n    let unimportant = 2;\n    let y = 3;\n    let z = 4;\n}\n";
837        // Tiny budget → the 'unimportant' line is normally filtered out.
838        let kept = information_bottleneck_filter(
839            content,
840            &["main".to_string()],
841            0.1,
842            &["unimportant".to_string()],
843        );
844        assert!(
845            kept.contains("let unimportant = 2;"),
846            "protected line must survive the IB budget: {kept}"
847        );
848    }
849
850    #[test]
851    fn ib_empty_force_keep_is_byte_identical() {
852        // Protect must not change the unprotected IB output (#498).
853        let content = "fn main() {\n    let x = 1;\n    return Err(\"e\");\n    let y = 2;\n}\n";
854        let a = information_bottleneck_filter(content, &["main".to_string()], 0.5, &[]);
855        let b = information_bottleneck_filter_typed(content, &["main".to_string()], 0.5, None, &[]);
856        assert_eq!(a, b);
857    }
858
859    #[test]
860    fn info_bottleneck_error_handling_priority() {
861        let content = "fn validate() {\n    let data = parse()?;\n    return Err(\"invalid\");\n    let x = 1;\n    let y = 2;\n}\n";
862        let result = information_bottleneck_filter(content, &["validate".to_string()], 0.5, &[]);
863        assert!(
864            result.contains("return Err"),
865            "error handling should survive filtering"
866        );
867    }
868
869    #[test]
870    fn info_bottleneck_score_sorted() {
871        let content = "fn important() {\n    let x = 1;\n    let y = 2;\n    let z = 3;\n}\n}\n";
872        let result = information_bottleneck_filter(content, &[], 0.6, &[]);
873        let lines: Vec<&str> = result.lines().collect();
874        let def_pos = lines.iter().position(|l| l.contains("fn important"));
875        let brace_pos = lines.iter().position(|l| l.trim() == "}");
876        if let (Some(d), Some(b)) = (def_pos, brace_pos) {
877            assert!(
878                d < b,
879                "definitions should appear before closing braces in score-sorted output"
880            );
881        }
882    }
883
884    #[test]
885    fn adaptive_budget_reduces_for_repetitive() {
886        let repetitive = "let x = 1;\n".repeat(50);
887        let diverse = (0..50)
888            .map(|i| format!("let var_{i} = func_{i}(arg_{i});"))
889            .collect::<Vec<_>>()
890            .join("\n");
891        let budget_rep = super::adaptive_ib_budget(&repetitive, 0.7);
892        let budget_div = super::adaptive_ib_budget(&diverse, 0.7);
893        assert!(
894            budget_rep < budget_div,
895            "repetitive content should get lower budget"
896        );
897    }
898}