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 force_keep: &[String],
475) -> String {
476 information_bottleneck_filter_typed(content, task_keywords, budget_ratio, None, force_keep)
477}
478
479pub 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 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 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
638fn 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 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
720pub 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 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 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}