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