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
229fn reasons_for(
230    state: &ScoredState,
231    frag: &Fragment,
232    hops: Option<u32>,
233    attribution: Option<&Vec<(String, String, f64)>>,
234) -> Vec<Reason> {
235    if state.core_ids.contains(&frag.id) || frag.kind == crate::types::FragmentKind::Excerpt {
236        return vec![Reason::Changed];
237    }
238    let mut reasons: Vec<Reason> = Vec::new();
239    if let Some(per_cat) = attribution {
240        // Top edge only: the strongest category+source explains the pull;
241        // the full per-category breakdown lives in DIFFCTX_PROVENANCE_DUMP.
242        if let Some((category, from, mass)) = per_cat.first() {
243            reasons.push(Reason::Edge {
244                category: category.clone(),
245                from: rel_path(state, from),
246                mass: (mass * 1e3).round() / 1e3,
247            });
248        }
249    }
250    if let Some(h) = hops {
251        reasons.push(Reason::Proximity { seed_hops: h });
252    }
253    if reasons.is_empty() {
254        reasons.push(Reason::PostPass);
255    }
256    reasons
257}
258
259/// Fragment kinds that mean "the parser produced structure here".
260///
261/// `Chunk` is the fallback for a file no grammar could parse; `Excerpt` is a
262/// budget stand-in for a core, not evidence of parsing. `Section` is neither —
263/// it is the markdown parser's genuine structural output, and counting it as a
264/// degradation reported every documentation file in a diff as a blind spot.
265fn is_structural(kind: crate::types::FragmentKind) -> bool {
266    use crate::types::FragmentKind as K;
267    !matches!(kind, K::Chunk | K::Excerpt)
268}
269
270fn build_coverage(
271    state: &ScoredState,
272    outcome: &SelectionOutcome,
273    next_up: usize,
274    attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
275) -> Coverage {
276    let graph = &state.scoring_result.graph;
277    let changed: Vec<String> = state
278        .changed_files
279        .iter()
280        .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
281        .collect();
282
283    // One grouping pass, not one scan per changed file: the naive form is
284    // O(changed x all_fragments) with a path allocation per pair, and both
285    // factors grow with the diff on exactly the repos already at risk of the
286    // timeouts in #121.
287    let mut by_file: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
288    for f in &state.all_fragments {
289        by_file
290            .entry(rel_path(state, f.id.path.as_ref()))
291            .or_default()
292            .push(f);
293    }
294
295    let mut unparsed: Vec<String> = Vec::new();
296    let mut zero_edge: Vec<String> = Vec::new();
297    let empty: Vec<&Fragment> = Vec::new();
298    for file in &changed {
299        let frags = by_file.get(file).unwrap_or(&empty);
300        if frags.is_empty() {
301            // Deleted, ignored, or never fragmented: absence here is already
302            // reported by deleted_files / lockfile_changes, and claiming it as
303            // a parse failure would be a second, wrong story about it.
304            continue;
305        }
306        // Only where structure was expected. A `.md` or `.json` file has no
307        // symbols to find, so listing it as a blind spot is true and useless:
308        // there is nothing for the caller to grep for.
309        let parseable = crate::languages::get_language_for_file(file).is_some();
310        if parseable && !frags.iter().any(|f| is_structural(f.kind)) {
311            unparsed.push(file.clone());
312        }
313        let linked = frags.iter().any(|f| {
314            if attribution.contains_key(&f.id) {
315                return true;
316            }
317            let mut has_out = false;
318            graph.for_each_forward_neighbor(&f.id, |_, _| has_out = true);
319            has_out
320        });
321        if !linked {
322            zero_edge.push(file.clone());
323        }
324    }
325    unparsed.sort();
326    unparsed.dedup();
327    zero_edge.sort();
328    zero_edge.dedup();
329
330    let n_changed = changed.len().max(1) as f64;
331    let parsed_share = 1.0 - unparsed.len() as f64 / n_changed;
332    let linked_share = 1.0 - zero_edge.len() as f64 / n_changed;
333    // Context only: a changed fragment that did not fit is a budget floor
334    // problem the caller already sees in `budget_tokens`, and counting it here
335    // would make a tiny budget look like a discovery failure.
336    let selected_context = outcome
337        .selected
338        .iter()
339        .filter(|f| !state.core_ids.contains(&f.id))
340        .count();
341    // Against what one more budget step would add, not against the whole
342    // admitted universe. Selecting 21 of 3153 admitted candidates is a budget
343    // working correctly, and reading that as 0.7% coverage made `confidence` 0.0
344    // on every real run.
345    let fit_share = if selected_context + next_up == 0 {
346        1.0
347    } else {
348        selected_context as f64 / (selected_context + next_up) as f64
349    };
350    let truncated = state.scoring_result.ppr_truncated;
351    let raw = parsed_share * linked_share * fit_share - if truncated { 0.1 } else { 0.0 };
352
353    Coverage {
354        unparsed_files: unparsed,
355        zero_edge_files: zero_edge,
356        ppr_truncated: truncated,
357        next_up,
358        confidence: (raw.clamp(0.0, 1.0) * 1e2).round() / 1e2,
359    }
360}
361
362/// Ranked admitted candidates that the budget left behind.
363///
364/// Returns the capped list, the true total, and the near-miss count. The first
365/// two differ whenever the cap bites — reporting only the capped length would
366/// understate the gap in exactly the runs where it matters most — and the third
367/// is the only one of the three that says whether a bigger budget would have
368/// helped.
369fn build_overflow(
370    state: &ScoredState,
371    outcome: &SelectionOutcome,
372    hops: &FxHashMap<FragmentId, u32>,
373    attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
374) -> (Vec<OverflowItem>, usize, usize) {
375    let selected: FxHashSet<&FragmentId> = outcome.selected.iter().map(|f| &f.id).collect();
376    let rel = &state.scoring_result.rel_scores;
377    let mut skipped: Vec<&Fragment> = state
378        .scoring_result
379        .filtered_fragments
380        .iter()
381        .filter(|f| !selected.contains(&f.id) && !state.core_ids.contains(&f.id))
382        .collect();
383    skipped.sort_by(|a, b| {
384        let sa = rel.get(&a.id).copied().unwrap_or(0.0);
385        let sb = rel.get(&b.id).copied().unwrap_or(0.0);
386        sb.total_cmp(&sa).then_with(|| a.id.cmp(&b.id))
387    });
388    let total = skipped.len();
389    // Only when the budget is what stopped selection. With headroom left the stop
390    // came from the adaptive tau threshold, and then no larger budget admits
391    // anything — an earlier version ignored that and reported 2711 near misses
392    // at `--budget -1`, where by construction nothing was crowded out at all.
393    let spent: u32 = outcome.selected.iter().map(|f| f.token_count).sum();
394    let headroom = outcome.effective_budget.saturating_sub(spent);
395    let smallest_skipped = skipped.iter().map(|f| f.token_count).min().unwrap_or(0);
396    let budget_bound = !skipped.is_empty() && headroom < smallest_skipped;
397    let next_up = if budget_bound {
398        // Walk the overflow ranking against a 25% budget increase. Bounded by
399        // that increment, so — unlike a score threshold — it does not grow just
400        // because a larger budget selected more and lowered the bar.
401        let extra = outcome.effective_budget / 4;
402        let mut spare = headroom + extra;
403        let mut n = 0;
404        for f in &skipped {
405            if f.token_count > spare {
406                break;
407            }
408            spare -= f.token_count;
409            n += 1;
410        }
411        n
412    } else {
413        0
414    };
415    let items = skipped
416        .into_iter()
417        .take(MAX_OVERFLOW_ITEMS)
418        .map(|frag| OverflowItem {
419            path: rel_path(state, frag.id.path.as_ref()),
420            lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
421            score: rel
422                .get(&frag.id)
423                .map(|s| (s * 1e4).round() / 1e4)
424                .unwrap_or(0.0),
425            tokens: frag.token_count,
426            why: overflow_why(
427                state,
428                hops.get(&frag.id).copied(),
429                attribution.get(&frag.id),
430            ),
431        })
432        .collect();
433    (items, total, next_up)
434}
435
436fn overflow_why(
437    state: &ScoredState,
438    hops: Option<u32>,
439    attribution: Option<&Vec<(String, String, f64)>>,
440) -> String {
441    if let Some((category, from, _)) = attribution.and_then(|rows| rows.first()) {
442        return format!("{category} from {}", rel_path(state, from));
443    }
444    match hops {
445        Some(h) => format!("{h} hop(s) from a change"),
446        None => "post-pass candidate".to_string(),
447    }
448}
449
450/// Renders the shared selection outcome as the `diffctx.locate.v1` navigation
451/// list: ranked fragments with provenance reasons and NO source bodies. Uses
452/// only the edge metadata and relevance the pipeline already computed.
453pub fn build_locate(state: &ScoredState, outcome: &SelectionOutcome) -> LocateOutput {
454    let rel = &state.scoring_result.rel_scores;
455    let hops = seed_hops(state);
456    let attribution = incoming_attribution(state);
457    let (overflow, overflow_count, next_up) = build_overflow(state, outcome, &hops, &attribution);
458
459    let items: Vec<LocateItem> = outcome
460        .selected
461        .iter()
462        .map(|frag| {
463            let is_changed = state.core_ids.contains(&frag.id)
464                || frag.kind == crate::types::FragmentKind::Excerpt;
465            let path = rel_path(state, frag.id.path.as_ref());
466            let group = group_of(&path, frag.kind);
467            LocateItem {
468                path,
469                lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
470                kind: format!("{:?}", frag.kind).to_lowercase(),
471                symbol: frag.symbol_name.clone(),
472                role: if is_changed { Some("changed") } else { None },
473                group,
474                score: rel
475                    .get(&frag.id)
476                    .map(|s| (s * 1e4).round() / 1e4)
477                    .unwrap_or(0.0),
478                tokens: frag.token_count,
479                reasons: reasons_for(
480                    state,
481                    frag,
482                    hops.get(&frag.id).copied(),
483                    attribution.get(&frag.id),
484                ),
485            }
486        })
487        .collect();
488
489    LocateOutput {
490        schema: LOCATE_SCHEMA,
491        name: state
492            .root_dir
493            .file_name()
494            .map(|n| n.to_string_lossy().to_string())
495            .unwrap_or_else(|| state.root_dir.to_string_lossy().to_string()),
496        commit_message: state.commit_message.clone(),
497        changed_files: state
498            .changed_files
499            .iter()
500            .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
501            .collect(),
502        deleted_files: state.deleted_files.clone(),
503        renamed_files: state
504            .renamed_files
505            .iter()
506            .map(|(from, to)| RenameEntry {
507                from: from.clone(),
508                to: to.clone(),
509            })
510            .collect(),
511        lockfile_changes: state.lockfile_changes.clone(),
512        ignored_changes: state.ignored_changes.clone(),
513        policy_excluded_count: state.policy_excluded_count,
514        budget_tokens: outcome.effective_budget,
515        summary: Summary {
516            files: items
517                .iter()
518                .map(|i| i.path.as_str())
519                .collect::<std::collections::BTreeSet<_>>()
520                .len(),
521            changed: items.iter().filter(|i| i.role == Some("changed")).count(),
522            context: items.iter().filter(|i| i.role.is_none()).count(),
523            tests: items.iter().filter(|i| i.group == Some("test")).count(),
524        },
525        item_count: items.len(),
526        items,
527        coverage: build_coverage(state, outcome, next_up, &attribution),
528        overflow,
529        overflow_count,
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::types::FragmentKind;
537
538    /// locate carried its own test-file rule until it was delegated to
539    /// `crate::testfiles`. It lowercased the path first, so every CamelCase
540    /// convention was invisible and `summary.tests` in the blast-radius view
541    /// (#135) undercounted. The bitcheck fixture is this repo, whose tests are
542    /// all `test_*.py` under `tests/` — a shape both rules agree on — so the
543    /// divergence only shows up on cases like these.
544    #[test]
545    fn the_grouping_uses_the_shared_test_classifier() {
546        for path in [
547            "src/main/java/com/example/FooTest.java",
548            "src/main/scala/AuthSpec.scala",
549            "src/XMLTest.java",
550            "ui/widget-spec.js",
551            "src/tests.rs",
552        ] {
553            assert_eq!(
554                group_of(path, FragmentKind::Function),
555                Some("test"),
556                "not grouped as a test: {path}"
557            );
558        }
559    }
560
561    /// The one rule that moved the other way: `testing/` used to count as a test
562    /// directory here. The shared implementation excludes it on purpose —
563    /// `testing` is Go's stdlib package name and such directories hold helpers.
564    #[test]
565    fn a_testing_directory_is_not_itself_a_test() {
566        assert_eq!(
567            group_of("src/testing/helpers.go", FragmentKind::Function),
568            None
569        );
570    }
571
572    /// Grouping is ordered: a type declaration inside a test file is a test,
573    /// not a type, because the test grouping is checked first. Pinned because
574    /// swapping the order silently re-buckets every test fixture struct.
575    #[test]
576    fn a_type_in_a_test_file_groups_as_test() {
577        assert_eq!(
578            group_of("tests/fixtures.rs", FragmentKind::Struct),
579            Some("test")
580        );
581        assert_eq!(group_of("src/model.rs", FragmentKind::Struct), Some("type"));
582    }
583}