Skip to main content

_diffctx/
locate.rs

1use std::path::Path;
2
3use rustc_hash::{FxHashMap, FxHashSet};
4use serde::Serialize;
5
6use crate::pipeline::{ScoredState, SelectionOutcome};
7use crate::provenance::{incoming_attribution, seed_hops};
8use crate::types::{Fragment, FragmentId};
9
10pub const LOCATE_SCHEMA: &str = "diffctx.locate.v1";
11
12#[derive(Serialize)]
13pub struct LocateOutput {
14    pub schema: &'static str,
15    pub name: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub commit_message: Option<String>,
18    #[serde(skip_serializing_if = "Vec::is_empty")]
19    pub changed_files: Vec<String>,
20    #[serde(skip_serializing_if = "Vec::is_empty")]
21    pub deleted_files: Vec<String>,
22    #[serde(skip_serializing_if = "Vec::is_empty")]
23    pub renamed_files: Vec<RenameEntry>,
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub lockfile_changes: Vec<String>,
26    #[serde(skip_serializing_if = "Vec::is_empty", default)]
27    pub ignored_changes: Vec<String>,
28    #[serde(skip_serializing_if = "crate::render::is_zero", default)]
29    pub policy_excluded_count: usize,
30    pub budget_tokens: u32,
31    /// Blast-radius counts over the ranked items (#135): distinct files,
32    /// changed vs context fragments, and how many ranked items (changed or
33    /// context) are tests.
34    pub summary: Summary,
35    pub item_count: usize,
36    pub items: Vec<LocateItem>,
37    /// What the run could NOT see or could not fit (#136).
38    ///
39    /// An agent told honestly where the selection is thin can grep the gap
40    /// itself; one told nothing has to distrust the whole answer. Emitted only
41    /// when there is something to report, so a clean run costs no tokens.
42    #[serde(skip_serializing_if = "Coverage::is_clean")]
43    pub coverage: Coverage,
44    /// Ranked candidates that did not fit `budget_tokens`, without bodies.
45    #[serde(skip_serializing_if = "Vec::is_empty")]
46    pub overflow: Vec<OverflowItem>,
47    /// True total behind `overflow`, which is capped at `MAX_OVERFLOW_ITEMS`.
48    #[serde(skip_serializing_if = "crate::render::is_zero")]
49    pub overflow_count: usize,
50}
51
52// The reference is serde's contract for `skip_serializing_if`, not a choice.
53#[allow(clippy::trivially_copy_pass_by_ref)]
54/// The overflow list is a pointer to what was skipped, not a second selection.
55/// Past this many entries it stops being a hint and starts being the cost the
56/// budget existed to avoid.
57pub const MAX_OVERFLOW_ITEMS: usize = 50;
58
59#[derive(Serialize, Default)]
60pub struct Coverage {
61    /// Changed files with no symbol-level structure: every fragment is a
62    /// chunk/section fallback, so the parser could not see inside them and
63    /// nothing was pulled in by symbol.
64    #[serde(skip_serializing_if = "Vec::is_empty")]
65    pub unparsed_files: Vec<String>,
66    /// Changed files whose fragments have no graph edge in either direction —
67    /// no caller, import, type or co-change link was found, so relevance had no
68    /// path to travel and context for them could only arrive by proximity.
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    pub zero_edge_files: Vec<String>,
71    /// PPR push-iteration hit its cap before converging: the ranking is a
72    /// partial diffusion, so low-scoring items are less trustworthy than usual.
73    /// Never set outside `--scoring ppr`.
74    #[serde(skip_serializing_if = "std::ops::Not::not")]
75    pub ppr_truncated: bool,
76    /// How many of the top-ranked overflow items a 25% larger budget would
77    /// admit. Zero when the budget was not what stopped selection.
78    ///
79    /// This answers "would paying more change the answer", which neither the raw
80    /// overflow total nor a score comparison does. The total is thousands of
81    /// candidates the budget correctly ignored; "scores at least as high as the
82    /// weakest selected item" was worse than useless, because raising the budget
83    /// lowers that bar and so *increased* the reported gap.
84    #[serde(skip_serializing_if = "crate::render::is_zero")]
85    pub next_up: usize,
86    /// Documented heuristic in [0, 1], NOT a probability and not a promise:
87    /// `parsed_share * linked_share * fit_share`, less 0.1 when PPR truncated.
88    /// It says how much of the changed surface the run could see and fit — it
89    /// cannot say whether what it selected is the right thing.
90    pub confidence: f64,
91}
92
93impl Coverage {
94    /// A run with nothing to disclose. `confidence` alone is not a finding:
95    /// emitting the block for it would put a number in every response and
96    /// invite it to be read as a quality score.
97    pub fn is_clean(&self) -> bool {
98        self.unparsed_files.is_empty()
99            && self.zero_edge_files.is_empty()
100            && !self.ppr_truncated
101            && self.next_up == 0
102    }
103}
104
105#[derive(Serialize)]
106pub struct OverflowItem {
107    pub path: String,
108    pub lines: String,
109    pub score: f64,
110    pub tokens: u32,
111    /// Single strongest reason, compact: the overflow list exists to be cheap,
112    /// and the full `reasons` array on a selected item costs several times this.
113    pub why: String,
114}
115
116#[derive(Serialize)]
117pub struct Summary {
118    pub files: usize,
119    pub changed: usize,
120    pub context: usize,
121    pub tests: usize,
122}
123
124#[derive(Serialize)]
125pub struct RenameEntry {
126    pub from: String,
127    pub to: String,
128}
129
130/// Rank is the array position (items are emitted in selection order);
131/// `role` is serialized only for `"changed"` — absence means context.
132#[derive(Serialize)]
133pub struct LocateItem {
134    pub path: String,
135    pub lines: String,
136    pub kind: String,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub symbol: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub role: Option<&'static str>,
141    /// Coarse impact group: `test`, `type`, or `config`; absent = general
142    /// code (callers and friends). Path- and kind-derived, presentation only.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub group: Option<&'static str>,
145    pub score: f64,
146    pub tokens: u32,
147    pub reasons: Vec<Reason>,
148}
149
150/// #182 unified the `TestEdge` builder and the needs matcher onto
151/// `crate::testfiles`, but missed this one — locate carried a third, weaker
152/// answer, and `summary.tests` in the shipped blast-radius view undercounted
153/// because of it. It lowercased the path first, so every CamelCase convention
154/// was invisible: `FooTest.java` outside a test directory, `AuthSpec.scala`,
155/// `widget-test.js` (hyphen form) and `src/tests.rs` all read as ordinary code.
156///
157/// One rule moves the other way: a `testing/` directory no longer counts. The
158/// shared implementation excludes it deliberately — `testing` is Go's stdlib
159/// package name and such directories hold helpers, not tests — and its unit
160/// tests pin `src/testing.rs` as non-test.
161fn is_test_path(path: &str) -> bool {
162    crate::testfiles::is_test_path(Path::new(path))
163}
164
165const CONFIG_EXTENSIONS: &[&str] = &[
166    "yaml",
167    "yml",
168    "json",
169    "toml",
170    "ini",
171    "cfg",
172    "conf",
173    "env",
174    "properties",
175];
176
177fn group_of(path: &str, kind: crate::types::FragmentKind) -> Option<&'static str> {
178    use crate::types::FragmentKind as K;
179    if is_test_path(path) {
180        return Some("test");
181    }
182    if matches!(
183        kind,
184        K::Struct
185            | K::Enum
186            | K::Interface
187            | K::Type
188            | K::Record
189            | K::StructSignature
190            | K::ClassSignature
191    ) {
192        return Some("type");
193    }
194    let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
195    if CONFIG_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
196        return Some("config");
197    }
198    None
199}
200
201#[derive(Serialize)]
202#[serde(tag = "type", rename_all = "snake_case")]
203pub enum Reason {
204    /// The fragment overlaps the diff hunks — it IS the change.
205    Changed,
206    /// Relevance arrived over a typed edge; `from` is the strongest source.
207    Edge {
208        category: String,
209        from: String,
210        mass: f64,
211    },
212    /// Graph distance from the nearest changed fragment.
213    Proximity { seed_hops: u32 },
214    /// Added by a selection post-pass (changed-file representation /
215    /// nontrivial-context rescue) rather than by scored relevance.
216    PostPass,
217}
218
219/// Falls back to the path as given when it lies outside the root: locate is a
220/// navigation list, and an unattributable entry is still more useful named than
221/// dropped. The separator handling comes from `crate::paths` so it matches the
222/// pack renderer instead of unconditionally rewriting backslashes, which on
223/// POSIX renames a legal file.
224fn rel_path(state: &ScoredState, path: &str) -> String {
225    crate::paths::display_rel(&state.root_dir, Path::new(path))
226        .unwrap_or_else(|| crate::paths::to_posix_display(std::borrow::Cow::Borrowed(path)))
227}
228
229/// Same predicate on both surfaces (pack render agrees via
230/// `render::carries_changed_role`): the fragment is a core, an excerpt
231/// stand-in, or a signature stub substituted at a core's location (#209).
232fn carries_change(
233    state: &ScoredState,
234    frag: &Fragment,
235    core_locs: &rustc_hash::FxHashSet<(std::sync::Arc<str>, u32)>,
236) -> bool {
237    state.core_ids.contains(&frag.id)
238        || frag.kind == crate::types::FragmentKind::Excerpt
239        || (frag.kind.is_signature()
240            && core_locs.contains(&(frag.id.path.clone(), frag.id.start_line)))
241}
242
243fn reasons_for(
244    state: &ScoredState,
245    frag: &Fragment,
246    core_locs: &rustc_hash::FxHashSet<(std::sync::Arc<str>, u32)>,
247    hops: Option<u32>,
248    attribution: Option<&Vec<(String, String, f64)>>,
249) -> Vec<Reason> {
250    if carries_change(state, frag, core_locs) {
251        return vec![Reason::Changed];
252    }
253    let mut reasons: Vec<Reason> = Vec::new();
254    if let Some(per_cat) = attribution {
255        // Top edge only: the strongest category+source explains the pull;
256        // the full per-category breakdown lives in DIFFCTX_PROVENANCE_DUMP.
257        if let Some((category, from, mass)) = per_cat.first() {
258            reasons.push(Reason::Edge {
259                category: category.clone(),
260                from: rel_path(state, from),
261                mass: (mass * 1e3).round() / 1e3,
262            });
263        }
264    }
265    if let Some(h) = hops {
266        reasons.push(Reason::Proximity { seed_hops: h });
267    }
268    if reasons.is_empty() {
269        reasons.push(Reason::PostPass);
270    }
271    reasons
272}
273
274/// Fragment kinds that mean "the parser produced structure here".
275///
276/// `Chunk` is the fallback for a file no grammar could parse; `Excerpt` is a
277/// budget stand-in for a core, not evidence of parsing. `Section` is neither —
278/// it is the markdown parser's genuine structural output, and counting it as a
279/// degradation reported every documentation file in a diff as a blind spot.
280fn is_structural(kind: crate::types::FragmentKind) -> bool {
281    use crate::types::FragmentKind as K;
282    !matches!(kind, K::Chunk | K::Excerpt)
283}
284
285fn build_coverage(
286    state: &ScoredState,
287    outcome: &SelectionOutcome,
288    next_up: usize,
289    attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
290) -> Coverage {
291    let graph = &state.scoring_result.graph;
292    let changed: Vec<String> = state
293        .changed_files
294        .iter()
295        .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
296        .collect();
297
298    // One grouping pass, not one scan per changed file: the naive form is
299    // O(changed x all_fragments) with a path allocation per pair, and both
300    // factors grow with the diff on exactly the repos already at risk of the
301    // timeouts in #121.
302    let mut by_file: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
303    for f in &state.all_fragments {
304        by_file
305            .entry(rel_path(state, f.id.path.as_ref()))
306            .or_default()
307            .push(f);
308    }
309
310    let mut unparsed: Vec<String> = Vec::new();
311    let mut zero_edge: Vec<String> = Vec::new();
312    let empty: Vec<&Fragment> = Vec::new();
313    for file in &changed {
314        let frags = by_file.get(file).unwrap_or(&empty);
315        if frags.is_empty() {
316            // Deleted, ignored, or never fragmented: absence here is already
317            // reported by deleted_files / lockfile_changes, and claiming it as
318            // a parse failure would be a second, wrong story about it.
319            continue;
320        }
321        // Only where structure was expected. A `.md` or `.json` file has no
322        // symbols to find, so listing it as a blind spot is true and useless:
323        // there is nothing for the caller to grep for.
324        let parseable = crate::languages::get_language_for_file(file).is_some();
325        if parseable && !frags.iter().any(|f| is_structural(f.kind)) {
326            unparsed.push(file.clone());
327        }
328        let linked = frags.iter().any(|f| {
329            if attribution.contains_key(&f.id) {
330                return true;
331            }
332            let mut has_out = false;
333            graph.for_each_forward_neighbor(&f.id, |_, _| has_out = true);
334            has_out
335        });
336        if !linked {
337            zero_edge.push(file.clone());
338        }
339    }
340    unparsed.sort();
341    unparsed.dedup();
342    zero_edge.sort();
343    zero_edge.dedup();
344
345    let n_changed = changed.len().max(1) as f64;
346    let parsed_share = 1.0 - unparsed.len() as f64 / n_changed;
347    let linked_share = 1.0 - zero_edge.len() as f64 / n_changed;
348    // Context only: a changed fragment that did not fit is a budget floor
349    // problem the caller already sees in `budget_tokens`, and counting it here
350    // would make a tiny budget look like a discovery failure.
351    let selected_context = outcome
352        .selected
353        .iter()
354        .filter(|f| !state.core_ids.contains(&f.id))
355        .count();
356    // Against what one more budget step would add, not against the whole
357    // admitted universe. Selecting 21 of 3153 admitted candidates is a budget
358    // working correctly, and reading that as 0.7% coverage made `confidence` 0.0
359    // on every real run.
360    let fit_share = if selected_context + next_up == 0 {
361        1.0
362    } else {
363        selected_context as f64 / (selected_context + next_up) as f64
364    };
365    let truncated = state.scoring_result.ppr_truncated;
366    let raw = parsed_share * linked_share * fit_share - if truncated { 0.1 } else { 0.0 };
367
368    Coverage {
369        unparsed_files: unparsed,
370        zero_edge_files: zero_edge,
371        ppr_truncated: truncated,
372        next_up,
373        confidence: (raw.clamp(0.0, 1.0) * 1e2).round() / 1e2,
374    }
375}
376
377/// Ranked admitted candidates that the budget left behind.
378///
379/// Returns the capped list, the true total, and the near-miss count. The first
380/// two differ whenever the cap bites — reporting only the capped length would
381/// understate the gap in exactly the runs where it matters most — and the third
382/// is the only one of the three that says whether a bigger budget would have
383/// helped.
384fn build_overflow(
385    state: &ScoredState,
386    outcome: &SelectionOutcome,
387    hops: &FxHashMap<FragmentId, u32>,
388    attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
389) -> (Vec<OverflowItem>, usize, usize) {
390    let selected: FxHashSet<&FragmentId> = outcome.selected.iter().map(|f| &f.id).collect();
391    let rel = &state.scoring_result.rel_scores;
392    let mut skipped: Vec<&Fragment> = state
393        .scoring_result
394        .filtered_fragments
395        .iter()
396        .filter(|f| !selected.contains(&f.id) && !state.core_ids.contains(&f.id))
397        .collect();
398    skipped.sort_by(|a, b| {
399        let sa = rel.get(&a.id).copied().unwrap_or(0.0);
400        let sb = rel.get(&b.id).copied().unwrap_or(0.0);
401        sb.total_cmp(&sa).then_with(|| a.id.cmp(&b.id))
402    });
403    let total = skipped.len();
404    // Only when the budget is what stopped selection. With headroom left the stop
405    // came from the adaptive tau threshold, and then no larger budget admits
406    // anything — an earlier version ignored that and reported 2711 near misses
407    // at `--budget -1`, where by construction nothing was crowded out at all.
408    let spent: u32 = outcome.selected.iter().map(|f| f.token_count).sum();
409    let headroom = outcome.effective_budget.saturating_sub(spent);
410    let smallest_skipped = skipped.iter().map(|f| f.token_count).min().unwrap_or(0);
411    let budget_bound = !skipped.is_empty() && headroom < smallest_skipped;
412    let next_up = if budget_bound {
413        // Walk the overflow ranking against a 25% budget increase. Bounded by
414        // that increment, so — unlike a score threshold — it does not grow just
415        // because a larger budget selected more and lowered the bar.
416        let extra = outcome.effective_budget / 4;
417        let mut spare = headroom + extra;
418        let mut n = 0;
419        for f in &skipped {
420            if f.token_count > spare {
421                break;
422            }
423            spare -= f.token_count;
424            n += 1;
425        }
426        n
427    } else {
428        0
429    };
430    let items = skipped
431        .into_iter()
432        .take(MAX_OVERFLOW_ITEMS)
433        .map(|frag| OverflowItem {
434            path: rel_path(state, frag.id.path.as_ref()),
435            lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
436            score: rel
437                .get(&frag.id)
438                .map(|s| (s * 1e4).round() / 1e4)
439                .unwrap_or(0.0),
440            tokens: frag.token_count,
441            why: overflow_why(
442                state,
443                hops.get(&frag.id).copied(),
444                attribution.get(&frag.id),
445            ),
446        })
447        .collect();
448    (items, total, next_up)
449}
450
451fn overflow_why(
452    state: &ScoredState,
453    hops: Option<u32>,
454    attribution: Option<&Vec<(String, String, f64)>>,
455) -> String {
456    if let Some((category, from, _)) = attribution.and_then(|rows| rows.first()) {
457        return format!("{category} from {}", rel_path(state, from));
458    }
459    match hops {
460        Some(h) => format!("{h} hop(s) from a change"),
461        None => "post-pass candidate".to_string(),
462    }
463}
464
465/// Renders the shared selection outcome as the `diffctx.locate.v1` navigation
466/// list: ranked fragments with provenance reasons and NO source bodies. Uses
467/// only the edge metadata and relevance the pipeline already computed.
468pub fn build_locate(state: &ScoredState, outcome: &SelectionOutcome) -> LocateOutput {
469    let rel = &state.scoring_result.rel_scores;
470    let hops = seed_hops(state);
471    let attribution = incoming_attribution(state);
472    let (overflow, overflow_count, next_up) = build_overflow(state, outcome, &hops, &attribution);
473
474    let core_locs = crate::render::core_substitute_locs(&state.core_ids);
475    let items: Vec<LocateItem> = outcome
476        .selected
477        .iter()
478        .map(|frag| {
479            let is_changed = carries_change(state, frag, &core_locs);
480            let path = rel_path(state, frag.id.path.as_ref());
481            let group = group_of(&path, frag.kind);
482            LocateItem {
483                path,
484                lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
485                kind: format!("{:?}", frag.kind).to_lowercase(),
486                symbol: frag.symbol_name.clone(),
487                role: if is_changed { Some("changed") } else { None },
488                group,
489                score: rel
490                    .get(&frag.id)
491                    .map(|s| (s * 1e4).round() / 1e4)
492                    .unwrap_or(0.0),
493                tokens: frag.token_count,
494                reasons: reasons_for(
495                    state,
496                    frag,
497                    &core_locs,
498                    hops.get(&frag.id).copied(),
499                    attribution.get(&frag.id),
500                ),
501            }
502        })
503        .collect();
504
505    LocateOutput {
506        schema: LOCATE_SCHEMA,
507        name: state
508            .root_dir
509            .file_name()
510            .map(|n| n.to_string_lossy().to_string())
511            .unwrap_or_else(|| state.root_dir.to_string_lossy().to_string()),
512        commit_message: state.commit_message.clone(),
513        changed_files: state
514            .changed_files
515            .iter()
516            .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
517            .collect(),
518        deleted_files: state.deleted_files.clone(),
519        renamed_files: state
520            .renamed_files
521            .iter()
522            .map(|(from, to)| RenameEntry {
523                from: from.clone(),
524                to: to.clone(),
525            })
526            .collect(),
527        lockfile_changes: state.lockfile_changes.clone(),
528        ignored_changes: state.ignored_changes.clone(),
529        policy_excluded_count: state.policy_excluded_count,
530        budget_tokens: outcome.effective_budget,
531        summary: Summary {
532            files: items
533                .iter()
534                .map(|i| i.path.as_str())
535                .collect::<std::collections::BTreeSet<_>>()
536                .len(),
537            changed: items.iter().filter(|i| i.role == Some("changed")).count(),
538            context: items.iter().filter(|i| i.role.is_none()).count(),
539            tests: items.iter().filter(|i| i.group == Some("test")).count(),
540        },
541        item_count: items.len(),
542        items,
543        coverage: build_coverage(state, outcome, next_up, &attribution),
544        overflow,
545        overflow_count,
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::types::FragmentKind;
553
554    /// locate carried its own test-file rule until it was delegated to
555    /// `crate::testfiles`. It lowercased the path first, so every CamelCase
556    /// convention was invisible and `summary.tests` in the blast-radius view
557    /// (#135) undercounted. The bitcheck fixture is this repo, whose tests are
558    /// all `test_*.py` under `tests/` — a shape both rules agree on — so the
559    /// divergence only shows up on cases like these.
560    #[test]
561    fn the_grouping_uses_the_shared_test_classifier() {
562        for path in [
563            "src/main/java/com/example/FooTest.java",
564            "src/main/scala/AuthSpec.scala",
565            "src/XMLTest.java",
566            "ui/widget-spec.js",
567            "src/tests.rs",
568        ] {
569            assert_eq!(
570                group_of(path, FragmentKind::Function),
571                Some("test"),
572                "not grouped as a test: {path}"
573            );
574        }
575    }
576
577    /// The one rule that moved the other way: `testing/` used to count as a test
578    /// directory here. The shared implementation excludes it on purpose —
579    /// `testing` is Go's stdlib package name and such directories hold helpers.
580    #[test]
581    fn a_testing_directory_is_not_itself_a_test() {
582        assert_eq!(
583            group_of("src/testing/helpers.go", FragmentKind::Function),
584            None
585        );
586    }
587
588    /// Grouping is ordered: a type declaration inside a test file is a test,
589    /// not a type, because the test grouping is checked first. Pinned because
590    /// swapping the order silently re-buckets every test fixture struct.
591    #[test]
592    fn a_type_in_a_test_file_groups_as_test() {
593        assert_eq!(
594            group_of("tests/fixtures.rs", FragmentKind::Struct),
595            Some("test")
596        );
597        assert_eq!(group_of("src/model.rs", FragmentKind::Struct), Some("type"));
598    }
599}