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