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.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    /// Regression for #83: when a changed file has no selected fragment and
319    /// the postpass fallback must pick one, a same-file signature stub that
320    /// is merely *smaller* must not be preferred over a same-file fragment
321    /// that actually covers the diff hunk (core_ids), as long as the core
322    /// fragment also fits the remaining budget.
323    #[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    /// When the core fragment does NOT fit the remaining budget, falling
358    /// back to the smaller non-core stub is still the correct behavior
359    /// (some representation beats none).
360    #[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    /// `cost(C) <= B` is stated as a hard contract in `pick_smallest_fitting`
396    /// and gated at five separate call sites, none of which was asserted. The
397    /// oracle corpus cannot catch a breach either: its budget is always >=2.5x
398    /// the whole repository, so no post-pass ever runs near the cap there.
399    #[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}