Skip to main content

_diffctx/
postpass.rs

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    // An excerpt is cut from a core fragment around the diff hunk, so it always
60    // covers the change; a signature only does when it belongs to a core.
61    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    // Prefer a fragment that actually covers the diff hunk (core_ids, i.e.
77    // what render.rs marks `role: "changed"`), then its signature stub, over
78    // an unrelated same-file fragment. Sorting by token_count alone picks
79    // whichever candidate is smallest regardless of relevance, which can
80    // silently hide the real change behind a tiny unrelated stub (#83).
81    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    // Nothing fits: the budget cap is a hard contract (cost(C) <= B). The
91    // changed file stays unrepresented and shows up downstream as
92    // changed-file retention < 1 rather than as a silent budget overrun.
93    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    // Files already represented anywhere in the selection are out of scope: the
186    // metric this pass serves is file-level (gold files outside the diff), so
187    // its budget only buys something when it reaches a *new* file.
188    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        // The path filter above was a snapshot of the incoming selection, so
217        // without this the pass could spend its whole budget stacking several
218        // fragments of one newly reached file — no gain on the file-level
219        // metric it exists for, at the cost of every other file it could
220        // still have reached.
221        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    // Membership through a set, not a scan of `missing_paths` per fragment.
263    // The scan ran `to_string_lossy().to_string()` on both sides of every
264    // comparison, so grouping cost fragments x missing-paths *string
265    // allocations* — on a range that adds hundreds of files it dominated the
266    // whole run. Same buckets, same first-seen order within each.
267    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    /// Regression for #83: when a changed file has no selected fragment and
341    /// the postpass fallback must pick one, a same-file signature stub that
342    /// is merely *smaller* must not be preferred over a same-file fragment
343    /// that actually covers the diff hunk (core_ids), as long as the core
344    /// fragment also fits the remaining budget.
345    #[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    /// When the core fragment does NOT fit the remaining budget, falling
380    /// back to the smaller non-core stub is still the correct behavior
381    /// (some representation beats none).
382    #[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    /// `cost(C) <= B` is stated as a hard contract in `pick_smallest_fitting`
418    /// and gated at five separate call sites, none of which was asserted. The
419    /// oracle corpus cannot catch a breach either: its budget is always >=2.5x
420    /// the whole repository, so no post-pass ever runs near the cap there.
421    #[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    /// The rescue budget exists to reach files the selection missed entirely,
476    /// which is what `nontrivial_file_recall` counts. The path filter was a
477    /// snapshot taken before the loop, so several fragments of one freshly
478    /// reached file could absorb the whole allowance.
479    #[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        // Two fragments in one unrelated file scoring above a third in another
489        // file, so the crowded file is visited first. At 40 tokens each only two
490        // of the three fit the allowance — the budget, not the threshold, is
491        // what decides whether the second file is ever reached.
492        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        // Low-score filler so the 80th-percentile threshold admits exactly the
508        // three fragments above instead of collapsing onto the maximum.
509        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        // 5% of 2000 = 100 tokens of rescue: room for two 40-token picks.
525        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}