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.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
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 selected_paths: FxHashSet<Arc<str>> = selected.iter().map(|f| f.id.path.clone()).collect();
186 let changed_paths: FxHashSet<Arc<str>> = core_ids.iter().map(|fid| fid.path.clone()).collect();
187
188 let mut candidates: Vec<&Fragment> = all_fragments
189 .iter()
190 .filter(|f| {
191 !selected_ids.contains(&f.id)
192 && !core_ids.contains(&f.id)
193 && !changed_paths.contains(&f.id.path)
194 && !selected_paths.contains(&f.id.path)
195 && rel_scores.get(&f.id).copied().unwrap_or(0.0) >= min_score
196 && f.token_count <= rescue_budget
197 })
198 .collect();
199 candidates.sort_by(|a, b| {
200 let sa = rel_scores.get(&a.id).copied().unwrap_or(0.0);
201 let sb = rel_scores.get(&b.id).copied().unwrap_or(0.0);
202 sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
203 });
204
205 let mut interval_idx = IntervalIndex::new();
206 for f in selected.iter() {
207 interval_idx.add(f);
208 }
209
210 let mut budget_used = 0u32;
211 for cand in candidates {
212 if budget_used + cand.token_count > rescue_budget {
213 continue;
214 }
215 if interval_idx.overlaps(cand) {
216 continue;
217 }
218 selected.push(cand.clone());
219 interval_idx.add(cand);
220 budget_used += cand.token_count;
221 }
222}
223
224pub fn ensure_changed_files_represented(
225 selected: &mut Vec<Fragment>,
226 all_fragments: &[Fragment],
227 changed_files: &[PathBuf],
228 remaining_budget: u32,
229 root_dir: &Path,
230 preferred_revs: &[String],
231 mut batch_reader: Option<&mut CatFileBatch>,
232 core_ids: &FxHashSet<FragmentId>,
233 core_excerpts: &FxHashMap<FragmentId, Fragment>,
234) {
235 let selected_paths: FxHashSet<String> = selected
236 .iter()
237 .map(|f| f.id.path.as_ref().to_string())
238 .collect();
239 let mut missing_paths: Vec<&PathBuf> = changed_files
240 .iter()
241 .filter(|p| !selected_paths.contains(&p.to_string_lossy().as_ref().to_string()))
242 .collect();
243 missing_paths.sort();
244
245 if missing_paths.is_empty() {
246 return;
247 }
248
249 let mut frags_by_path: FxHashMap<String, Vec<Fragment>> = FxHashMap::default();
250 for f in all_fragments.iter().chain(core_excerpts.values()) {
251 let path_str = f.id.path.as_ref().to_string();
252 if missing_paths
253 .iter()
254 .any(|p| p.to_string_lossy().as_ref() == path_str)
255 {
256 frags_by_path.entry(path_str).or_default().push(f.clone());
257 }
258 }
259
260 let mut budget_left = remaining_budget;
261 let mut selected_ids: FxHashSet<FragmentId> = selected.iter().map(|f| f.id.clone()).collect();
262 let mut interval_idx = IntervalIndex::new();
263 for f in selected.iter() {
264 interval_idx.add(f);
265 }
266
267 for path in missing_paths.iter().copied() {
268 let path_str = path.to_string_lossy().to_string();
269 let candidates = frags_by_path.get(&path_str).cloned().unwrap_or_default();
270 let candidates = if candidates.is_empty() {
271 match create_whole_file_fragment(
272 path,
273 root_dir,
274 preferred_revs,
275 batch_reader.as_deref_mut(),
276 ) {
277 Some(f) => vec![f],
278 None => continue,
279 }
280 } else {
281 candidates
282 };
283
284 if let Some(picked) =
285 pick_smallest_fitting(&candidates, &selected_ids, budget_left, core_ids)
286 {
287 if !interval_idx.overlaps(&picked) {
288 budget_left = budget_left.saturating_sub(picked.token_count);
289 selected_ids.insert(picked.id.clone());
290 interval_idx.add(&picked);
291 selected.push(picked);
292 }
293 }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 fn frag(
302 path: &str,
303 start: u32,
304 end: u32,
305 kind: crate::types::FragmentKind,
306 tokens: u32,
307 ) -> Fragment {
308 Fragment {
309 id: FragmentId::new(Arc::from(path), start, end),
310 kind,
311 content: Arc::from(format!("fragment {path}:{start}-{end}")),
312 identifiers: FxHashSet::default(),
313 token_count: tokens,
314 symbol_name: None,
315 }
316 }
317
318 #[test]
324 fn ensure_changed_files_represented_prefers_core_fragment_when_it_fits() {
325 let core = frag("a.ts", 10, 20, crate::types::FragmentKind::Function, 60);
326 let stub = frag(
327 "a.ts",
328 10,
329 10,
330 crate::types::FragmentKind::FunctionSignature,
331 10,
332 );
333 let all_fragments = vec![core.clone(), stub.clone()];
334 let core_ids: FxHashSet<FragmentId> = std::iter::once(core.id.clone()).collect();
335 let changed_files = vec![PathBuf::from("a.ts")];
336 let mut selected: Vec<Fragment> = Vec::new();
337
338 ensure_changed_files_represented(
339 &mut selected,
340 &all_fragments,
341 &changed_files,
342 100,
343 Path::new("."),
344 &[],
345 None,
346 &core_ids,
347 &FxHashMap::default(),
348 );
349
350 assert_eq!(selected.len(), 1, "expected exactly one fallback fragment");
351 assert_eq!(
352 selected[0].id, core.id,
353 "fallback picked the signature stub instead of the fragment covering the actual diff hunk"
354 );
355 }
356
357 #[test]
361 fn ensure_changed_files_represented_falls_back_to_stub_when_core_does_not_fit() {
362 let core = frag("a.ts", 10, 20, crate::types::FragmentKind::Function, 60);
363 let stub = frag(
364 "a.ts",
365 10,
366 10,
367 crate::types::FragmentKind::FunctionSignature,
368 10,
369 );
370 let all_fragments = vec![core.clone(), stub.clone()];
371 let core_ids: FxHashSet<FragmentId> = std::iter::once(core.id.clone()).collect();
372 let changed_files = vec![PathBuf::from("a.ts")];
373 let mut selected: Vec<Fragment> = Vec::new();
374
375 ensure_changed_files_represented(
376 &mut selected,
377 &all_fragments,
378 &changed_files,
379 15,
380 Path::new("."),
381 &[],
382 None,
383 &core_ids,
384 &FxHashMap::default(),
385 );
386
387 assert_eq!(selected.len(), 1);
388 assert_eq!(selected[0].id, stub.id);
389 }
390
391 fn cost(selected: &[Fragment]) -> u32 {
392 selected.iter().map(|f| f.token_count).sum()
393 }
394
395 #[test]
400 fn post_passes_never_push_the_selection_past_the_budget() {
401 use crate::types::FragmentKind;
402
403 let core = frag("changed.rs", 1, 9, FragmentKind::Function, 40);
404 let all = vec![
405 core.clone(),
406 frag("changed.rs", 20, 60, FragmentKind::Function, 300),
407 frag("changed.rs", 70, 75, FragmentKind::FunctionSignature, 12),
408 frag("other.rs", 1, 30, FragmentKind::Class, 180),
409 frag("other.rs", 40, 44, FragmentKind::Function, 25),
410 ];
411 let core_ids: FxHashSet<FragmentId> = std::iter::once(core.id.clone()).collect();
412 let rel: FxHashMap<FragmentId, f64> = all.iter().map(|f| (f.id.clone(), 0.6)).collect();
413 let changed = vec![PathBuf::from("changed.rs"), PathBuf::from("other.rs")];
414 let excerpts: FxHashMap<FragmentId, Fragment> = FxHashMap::default();
415
416 for budget in [0u32, 11, 12, 40, 65, 200, 600] {
417 let mut selected: Vec<Fragment> = if budget >= core.token_count {
418 vec![core.clone()]
419 } else {
420 Vec::new()
421 };
422
423 rescue_nontrivial_context(&mut selected, &all, &rel, &core_ids, budget);
424 assert!(
425 cost(&selected) <= budget,
426 "rescue overran budget {budget}: cost {}",
427 cost(&selected)
428 );
429
430 let remaining = budget.saturating_sub(cost(&selected));
431 ensure_changed_files_represented(
432 &mut selected,
433 &all,
434 &changed,
435 remaining,
436 Path::new("."),
437 &[],
438 None,
439 &core_ids,
440 &excerpts,
441 );
442 assert!(
443 cost(&selected) <= budget,
444 "ensure_changed_files_represented overran budget {budget}: cost {}",
445 cost(&selected)
446 );
447
448 let ids: FxHashSet<&FragmentId> = selected.iter().map(|f| &f.id).collect();
449 assert_eq!(ids.len(), selected.len(), "a fragment was selected twice");
450 }
451 }
452
453 #[test]
454 fn pick_smallest_fitting_refuses_every_oversized_candidate() {
455 use crate::types::FragmentKind;
456
457 let candidates = vec![
458 frag("a.rs", 1, 40, FragmentKind::Function, 500),
459 frag("a.rs", 50, 90, FragmentKind::Function, 400),
460 ];
461 let core_ids: FxHashSet<FragmentId> = FxHashSet::default();
462 assert!(
463 pick_smallest_fitting(&candidates, &FxHashSet::default(), 399, &core_ids).is_none(),
464 "returned a candidate that does not fit — the budget contract is broken"
465 );
466 assert!(
467 pick_smallest_fitting(&candidates, &FxHashSet::default(), 400, &core_ids).is_some(),
468 "refused a candidate that fits exactly"
469 );
470 }
471
472 #[test]
473 fn pick_smallest_fitting_skips_already_selected_and_zero_cost_fragments() {
474 use crate::types::FragmentKind;
475
476 let taken = frag("a.rs", 1, 10, FragmentKind::Function, 30);
477 let zero = frag("a.rs", 20, 30, FragmentKind::Function, 0);
478 let free = frag("a.rs", 40, 50, FragmentKind::Function, 60);
479 let selected: FxHashSet<FragmentId> = std::iter::once(taken.id.clone()).collect();
480 let picked = pick_smallest_fitting(
481 &[taken, zero, free.clone()],
482 &selected,
483 1_000,
484 &FxHashSet::default(),
485 );
486 assert_eq!(picked.map(|f| f.id), Some(free.id));
487 }
488}