1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use rustc_hash::{FxHashMap, FxHashSet};
5
6use crate::config::selection::rescue;
7use crate::fragmentation::create_whole_file_fragment;
8use crate::git::CatFileBatch;
9use crate::graph::Graph;
10use crate::interval::IntervalIndex;
11use crate::types::{Fragment, FragmentId, FragmentKind};
12
13fn find_dangling_semantic_names(
14 selected: &[Fragment],
15 graph: &Graph,
16 frag_by_id: &FxHashMap<FragmentId, &Fragment>,
17 selected_ids: &FxHashSet<FragmentId>,
18) -> FxHashSet<String> {
19 let mut dangling = FxHashSet::default();
20 for frag in selected {
21 graph.for_each_forward_neighbor(&frag.id, |nbr_id, _w| {
22 if selected_ids.contains(nbr_id) {
23 return;
24 }
25 let cat = graph.edge_category(&frag.id, nbr_id);
26 if cat
27 .map(|c| c != crate::graph::EdgeCategory::Semantic)
28 .unwrap_or(true)
29 {
30 return;
31 }
32 if let Some(nbr_frag) = frag_by_id.get(nbr_id) {
33 if let Some(ref name) = nbr_frag.symbol_name {
34 dangling.insert(name.to_lowercase());
35 }
36 }
37 });
38 }
39 dangling
40}
41
42fn pick_best_fragment<'a>(
43 candidates: &[&'a Fragment],
44 selected_ids: &FxHashSet<FragmentId>,
45) -> Option<&'a Fragment> {
46 let available: Vec<&&'a Fragment> = candidates
47 .iter()
48 .filter(|c| !selected_ids.contains(&c.id))
49 .collect();
50 let full = available.iter().find(|f| !f.kind.is_signature()).copied();
51 let sig = available.iter().find(|f| f.kind.is_signature()).copied();
52 full.or(sig).map(|f| *f)
53}
54
55fn change_coverage_rank(f: &Fragment, core_ids: &FxHashSet<FragmentId>) -> u8 {
56 if core_ids.contains(&f.id) {
57 return 0;
58 }
59 let is_core_stub = f.kind == FragmentKind::Excerpt
62 || (f.kind.is_signature()
63 && core_ids
64 .iter()
65 .any(|c| c.path == f.id.path && c.start_line == f.id.start_line));
66 if is_core_stub { 1 } else { 2 }
67}
68
69fn pick_smallest_fitting(
70 candidates: &[Fragment],
71 selected_ids: &FxHashSet<FragmentId>,
72 budget_left: u32,
73 core_ids: &FxHashSet<FragmentId>,
74) -> Option<Fragment> {
75 let mut sorted: Vec<&Fragment> = candidates.iter().collect();
76 sorted.sort_by_key(|f| (change_coverage_rank(f, core_ids), f.token_count));
82 for cand in &sorted {
83 if cand.token_count == 0 || selected_ids.contains(&cand.id) {
84 continue;
85 }
86 if cand.token_count <= budget_left {
87 return Some((*cand).clone());
88 }
89 }
90 None
94}
95
96pub fn coherence_post_pass(
97 selected: &mut Vec<Fragment>,
98 all_fragments: &[Fragment],
99 graph: &Graph,
100 budget: u32,
101) {
102 let selected_ids: FxHashSet<FragmentId> = selected.iter().map(|f| f.id.clone()).collect();
103 let mut interval_idx = IntervalIndex::new();
104 for f in selected.iter() {
105 interval_idx.add(f);
106 }
107 let used: u32 = selected.iter().map(|f| f.token_count).sum();
108 let mut remaining = budget.saturating_sub(used);
109
110 let mut name_to_frags: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
111 for f in all_fragments {
112 if let Some(ref name) = f.symbol_name {
113 name_to_frags
114 .entry(name.to_lowercase())
115 .or_default()
116 .push(f);
117 }
118 }
119
120 let frag_by_id: FxHashMap<FragmentId, &Fragment> =
121 all_fragments.iter().map(|f| (f.id.clone(), f)).collect();
122 let dangling_names = find_dangling_semantic_names(selected, graph, &frag_by_id, &selected_ids);
123
124 let mut added_ids = selected_ids;
125 for name in &dangling_names {
126 let candidates = match name_to_frags.get(name) {
127 Some(c) => c,
128 None => continue,
129 };
130 let pick = match pick_best_fragment(candidates, &added_ids) {
131 Some(p) => p,
132 None => continue,
133 };
134 if pick.token_count <= remaining
135 && !added_ids.contains(&pick.id)
136 && !interval_idx.overlaps(pick)
137 {
138 selected.push(pick.clone());
139 added_ids.insert(pick.id.clone());
140 interval_idx.add(pick);
141 remaining = remaining.saturating_sub(pick.token_count);
142 }
143 }
144}
145
146fn compute_rescue_threshold(
147 all_fragments: &[Fragment],
148 rel_scores: &FxHashMap<FragmentId, f64>,
149 core_ids: &FxHashSet<FragmentId>,
150) -> f64 {
151 let mut context_scores: Vec<f64> = all_fragments
152 .iter()
153 .filter(|f| !core_ids.contains(&f.id))
154 .map(|f| rel_scores.get(&f.id).copied().unwrap_or(0.0))
155 .filter(|&s| s > 0.0)
156 .collect();
157 if context_scores.is_empty() {
158 return f64::INFINITY;
159 }
160 context_scores.sort_by(|a, b| b.total_cmp(a));
161 let idx = (context_scores.len() as f64 * (1.0 - rescue().min_score_percentile)) as usize;
162 context_scores[idx.min(context_scores.len() - 1)]
163}
164
165pub fn rescue_nontrivial_context(
166 selected: &mut Vec<Fragment>,
167 all_fragments: &[Fragment],
168 rel_scores: &FxHashMap<FragmentId, f64>,
169 core_ids: &FxHashSet<FragmentId>,
170 budget: u32,
171) {
172 let used: u32 = selected.iter().map(|f| f.token_count).sum();
173 let remaining = budget.saturating_sub(used);
174 let rescue_budget = remaining.min((budget as f64 * rescue().budget_fraction) as u32);
175 if rescue_budget == 0 {
176 return;
177 }
178
179 let min_score = compute_rescue_threshold(all_fragments, rel_scores, core_ids);
180 if min_score == f64::INFINITY {
181 return;
182 }
183
184 let selected_ids: FxHashSet<FragmentId> = selected.iter().map(|f| f.id.clone()).collect();
185 let mut represented_paths: FxHashSet<Arc<str>> =
189 selected.iter().map(|f| f.id.path.clone()).collect();
190 let changed_paths: FxHashSet<Arc<str>> = core_ids.iter().map(|fid| fid.path.clone()).collect();
191
192 let mut candidates: Vec<&Fragment> = all_fragments
193 .iter()
194 .filter(|f| {
195 !selected_ids.contains(&f.id)
196 && !core_ids.contains(&f.id)
197 && !changed_paths.contains(&f.id.path)
198 && !represented_paths.contains(&f.id.path)
199 && rel_scores.get(&f.id).copied().unwrap_or(0.0) >= min_score
200 && f.token_count <= rescue_budget
201 })
202 .collect();
203 candidates.sort_by(|a, b| {
204 let sa = rel_scores.get(&a.id).copied().unwrap_or(0.0);
205 let sb = rel_scores.get(&b.id).copied().unwrap_or(0.0);
206 sb.total_cmp(&sa).then_with(|| a.id.cmp(&b.id))
207 });
208
209 let mut interval_idx = IntervalIndex::new();
210 for f in selected.iter() {
211 interval_idx.add(f);
212 }
213
214 let mut budget_used = 0u32;
215 for cand in candidates {
216 if represented_paths.contains(&cand.id.path) {
222 continue;
223 }
224 if budget_used + cand.token_count > rescue_budget {
225 continue;
226 }
227 if interval_idx.overlaps(cand) {
228 continue;
229 }
230 selected.push(cand.clone());
231 interval_idx.add(cand);
232 represented_paths.insert(cand.id.path.clone());
233 budget_used += cand.token_count;
234 }
235}
236
237pub fn ensure_changed_files_represented(
238 selected: &mut Vec<Fragment>,
239 all_fragments: &[Fragment],
240 changed_files: &[PathBuf],
241 remaining_budget: u32,
242 root_dir: &Path,
243 preferred_revs: &[String],
244 mut batch_reader: Option<&mut CatFileBatch>,
245 core_ids: &FxHashSet<FragmentId>,
246 core_excerpts: &FxHashMap<FragmentId, Fragment>,
247) {
248 let selected_paths: FxHashSet<String> = selected
249 .iter()
250 .map(|f| f.id.path.as_ref().to_string())
251 .collect();
252 let mut missing_paths: Vec<&PathBuf> = changed_files
253 .iter()
254 .filter(|p| !selected_paths.contains(&p.to_string_lossy().as_ref().to_string()))
255 .collect();
256 missing_paths.sort();
257
258 if missing_paths.is_empty() {
259 return;
260 }
261
262 let missing_lookup: FxHashSet<String> = missing_paths
268 .iter()
269 .map(|p| p.to_string_lossy().into_owned())
270 .collect();
271 let mut frags_by_path: FxHashMap<String, Vec<Fragment>> = FxHashMap::default();
272 for f in all_fragments.iter().chain(core_excerpts.values()) {
273 let path_str = f.id.path.as_ref();
274 if missing_lookup.contains(path_str) {
275 frags_by_path
276 .entry(path_str.to_string())
277 .or_default()
278 .push(f.clone());
279 }
280 }
281
282 let mut budget_left = remaining_budget;
283 let mut selected_ids: FxHashSet<FragmentId> = selected.iter().map(|f| f.id.clone()).collect();
284 let mut interval_idx = IntervalIndex::new();
285 for f in selected.iter() {
286 interval_idx.add(f);
287 }
288
289 for path in missing_paths.iter().copied() {
290 let path_str = path.to_string_lossy().to_string();
291 let candidates = frags_by_path.get(&path_str).cloned().unwrap_or_default();
292 let candidates = if candidates.is_empty() {
293 match create_whole_file_fragment(
294 path,
295 root_dir,
296 preferred_revs,
297 batch_reader.as_deref_mut(),
298 ) {
299 Some(f) => vec![f],
300 None => continue,
301 }
302 } else {
303 candidates
304 };
305
306 if let Some(picked) =
307 pick_smallest_fitting(&candidates, &selected_ids, budget_left, core_ids)
308 {
309 if !interval_idx.overlaps(&picked) {
310 budget_left = budget_left.saturating_sub(picked.token_count);
311 selected_ids.insert(picked.id.clone());
312 interval_idx.add(&picked);
313 selected.push(picked);
314 }
315 }
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn frag(
324 path: &str,
325 start: u32,
326 end: u32,
327 kind: crate::types::FragmentKind,
328 tokens: u32,
329 ) -> Fragment {
330 Fragment {
331 id: FragmentId::new(Arc::from(path), start, end),
332 kind,
333 content: Arc::from(format!("fragment {path}:{start}-{end}")),
334 identifiers: FxHashSet::default(),
335 token_count: tokens,
336 symbol_name: None,
337 }
338 }
339
340 #[test]
346 fn ensure_changed_files_represented_prefers_core_fragment_when_it_fits() {
347 let core = frag("a.ts", 10, 20, crate::types::FragmentKind::Function, 60);
348 let stub = frag(
349 "a.ts",
350 10,
351 10,
352 crate::types::FragmentKind::FunctionSignature,
353 10,
354 );
355 let all_fragments = vec![core.clone(), stub.clone()];
356 let core_ids: FxHashSet<FragmentId> = std::iter::once(core.id.clone()).collect();
357 let changed_files = vec![PathBuf::from("a.ts")];
358 let mut selected: Vec<Fragment> = Vec::new();
359
360 ensure_changed_files_represented(
361 &mut selected,
362 &all_fragments,
363 &changed_files,
364 100,
365 Path::new("."),
366 &[],
367 None,
368 &core_ids,
369 &FxHashMap::default(),
370 );
371
372 assert_eq!(selected.len(), 1, "expected exactly one fallback fragment");
373 assert_eq!(
374 selected[0].id, core.id,
375 "fallback picked the signature stub instead of the fragment covering the actual diff hunk"
376 );
377 }
378
379 #[test]
383 fn ensure_changed_files_represented_falls_back_to_stub_when_core_does_not_fit() {
384 let core = frag("a.ts", 10, 20, crate::types::FragmentKind::Function, 60);
385 let stub = frag(
386 "a.ts",
387 10,
388 10,
389 crate::types::FragmentKind::FunctionSignature,
390 10,
391 );
392 let all_fragments = vec![core.clone(), stub.clone()];
393 let core_ids: FxHashSet<FragmentId> = std::iter::once(core.id.clone()).collect();
394 let changed_files = vec![PathBuf::from("a.ts")];
395 let mut selected: Vec<Fragment> = Vec::new();
396
397 ensure_changed_files_represented(
398 &mut selected,
399 &all_fragments,
400 &changed_files,
401 15,
402 Path::new("."),
403 &[],
404 None,
405 &core_ids,
406 &FxHashMap::default(),
407 );
408
409 assert_eq!(selected.len(), 1);
410 assert_eq!(selected[0].id, stub.id);
411 }
412
413 fn cost(selected: &[Fragment]) -> u32 {
414 selected.iter().map(|f| f.token_count).sum()
415 }
416
417 #[test]
422 fn post_passes_never_push_the_selection_past_the_budget() {
423 use crate::types::FragmentKind;
424
425 let core = frag("changed.rs", 1, 9, FragmentKind::Function, 40);
426 let all = vec![
427 core.clone(),
428 frag("changed.rs", 20, 60, FragmentKind::Function, 300),
429 frag("changed.rs", 70, 75, FragmentKind::FunctionSignature, 12),
430 frag("other.rs", 1, 30, FragmentKind::Class, 180),
431 frag("other.rs", 40, 44, FragmentKind::Function, 25),
432 ];
433 let core_ids: FxHashSet<FragmentId> = std::iter::once(core.id.clone()).collect();
434 let rel: FxHashMap<FragmentId, f64> = all.iter().map(|f| (f.id.clone(), 0.6)).collect();
435 let changed = vec![PathBuf::from("changed.rs"), PathBuf::from("other.rs")];
436 let excerpts: FxHashMap<FragmentId, Fragment> = FxHashMap::default();
437
438 for budget in [0u32, 11, 12, 40, 65, 200, 600] {
439 let mut selected: Vec<Fragment> = if budget >= core.token_count {
440 vec![core.clone()]
441 } else {
442 Vec::new()
443 };
444
445 rescue_nontrivial_context(&mut selected, &all, &rel, &core_ids, budget);
446 assert!(
447 cost(&selected) <= budget,
448 "rescue overran budget {budget}: cost {}",
449 cost(&selected)
450 );
451
452 let remaining = budget.saturating_sub(cost(&selected));
453 ensure_changed_files_represented(
454 &mut selected,
455 &all,
456 &changed,
457 remaining,
458 Path::new("."),
459 &[],
460 None,
461 &core_ids,
462 &excerpts,
463 );
464 assert!(
465 cost(&selected) <= budget,
466 "ensure_changed_files_represented overran budget {budget}: cost {}",
467 cost(&selected)
468 );
469
470 let ids: FxHashSet<&FragmentId> = selected.iter().map(|f| &f.id).collect();
471 assert_eq!(ids.len(), selected.len(), "a fragment was selected twice");
472 }
473 }
474
475 #[test]
480 fn rescue_spends_its_budget_on_distinct_files() {
481 use crate::types::FragmentKind;
482
483 let core = frag("changed.rs", 1, 10, FragmentKind::Function, 100);
484 let mut all = vec![core.clone()];
485 let mut rel: FxHashMap<FragmentId, f64> = FxHashMap::default();
486 rel.insert(core.id.clone(), 1.0);
487
488 for i in 0..2u32 {
493 let f = frag(
494 "crowded.rs",
495 1 + i * 100,
496 50 + i * 100,
497 FragmentKind::Function,
498 40,
499 );
500 rel.insert(f.id.clone(), 0.90 - f64::from(i) * 0.01);
501 all.push(f);
502 }
503 let lonely = frag("lonely.rs", 1, 50, FragmentKind::Function, 40);
504 rel.insert(lonely.id.clone(), 0.88);
505 all.push(lonely);
506
507 for i in 0..12u32 {
510 let f = frag(
511 "filler.rs",
512 1 + i * 100,
513 50 + i * 100,
514 FragmentKind::Function,
515 40,
516 );
517 rel.insert(f.id.clone(), 0.10);
518 all.push(f);
519 }
520
521 let core_ids: FxHashSet<FragmentId> = std::iter::once(core.id.clone()).collect();
522
523 let mut selected = vec![core];
524 rescue_nontrivial_context(&mut selected, &all, &rel, &core_ids, 2_000);
526
527 let rescued: Vec<&str> = selected
528 .iter()
529 .skip(1)
530 .map(|f| f.id.path.as_ref())
531 .collect();
532 let distinct: FxHashSet<&str> = rescued.iter().copied().collect();
533 assert_eq!(
534 rescued.len(),
535 distinct.len(),
536 "rescue stacked several fragments of one file: {rescued:?}"
537 );
538 assert!(
539 distinct.contains("lonely.rs"),
540 "the second file was never reached: {rescued:?}"
541 );
542 }
543
544 #[test]
545 fn pick_smallest_fitting_refuses_every_oversized_candidate() {
546 use crate::types::FragmentKind;
547
548 let candidates = vec![
549 frag("a.rs", 1, 40, FragmentKind::Function, 500),
550 frag("a.rs", 50, 90, FragmentKind::Function, 400),
551 ];
552 let core_ids: FxHashSet<FragmentId> = FxHashSet::default();
553 assert!(
554 pick_smallest_fitting(&candidates, &FxHashSet::default(), 399, &core_ids).is_none(),
555 "returned a candidate that does not fit — the budget contract is broken"
556 );
557 assert!(
558 pick_smallest_fitting(&candidates, &FxHashSet::default(), 400, &core_ids).is_some(),
559 "refused a candidate that fits exactly"
560 );
561 }
562
563 #[test]
564 fn pick_smallest_fitting_skips_already_selected_and_zero_cost_fragments() {
565 use crate::types::FragmentKind;
566
567 let taken = frag("a.rs", 1, 10, FragmentKind::Function, 30);
568 let zero = frag("a.rs", 20, 30, FragmentKind::Function, 0);
569 let free = frag("a.rs", 40, 50, FragmentKind::Function, 60);
570 let selected: FxHashSet<FragmentId> = std::iter::once(taken.id.clone()).collect();
571 let picked = pick_smallest_fitting(
572 &[taken, zero, free.clone()],
573 &selected,
574 1_000,
575 &FxHashSet::default(),
576 );
577 assert_eq!(picked.map(|f| f.id), Some(free.id));
578 }
579}