Skip to main content

_diffctx/
discovery.rs

1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use rayon::prelude::*;
6use rustc_hash::{FxHashMap, FxHashSet};
7
8use crate::config::bm25::BM25;
9use crate::token_corpus::{DocTokens, TokenCorpus};
10use crate::types::extract_identifier_list;
11
12pub struct DiscoveryContext {
13    pub root_dir: PathBuf,
14    pub changed_files: Vec<PathBuf>,
15    pub all_candidates: Vec<PathBuf>,
16    pub diff_text: String,
17    pub expansion_concepts: FxHashSet<String>,
18    pub file_cache: FxHashMap<PathBuf, String>,
19    pub token_corpus: OnceLock<TokenCorpus>,
20}
21
22impl DiscoveryContext {
23    pub fn read_file(&self, path: &Path) -> Option<Cow<'_, str>> {
24        if let Some(content) = self.file_cache.get(path) {
25            return Some(Cow::Borrowed(content.as_str()));
26        }
27        std::fs::read_to_string(path).ok().map(Cow::Owned)
28    }
29
30    pub fn shared_corpus(&self) -> &TokenCorpus {
31        self.token_corpus.get_or_init(|| TokenCorpus::build(self))
32    }
33}
34
35pub trait DiscoveryStrategy: Send + Sync {
36    fn discover(&self, ctx: &DiscoveryContext) -> Vec<PathBuf>;
37}
38
39pub struct DefaultDiscovery;
40
41impl DiscoveryStrategy for DefaultDiscovery {
42    fn discover(&self, ctx: &DiscoveryContext) -> Vec<PathBuf> {
43        let changed_set: FxHashSet<&Path> = ctx.changed_files.iter().map(|p| p.as_path()).collect();
44
45        let mut discovered = crate::edges::discover_all_related_files(
46            &ctx.changed_files,
47            &ctx.all_candidates,
48            Some(ctx.root_dir.as_path()),
49            Some(&ctx.file_cache),
50        );
51        discovered.retain(|p| !changed_set.contains(p.as_path()));
52
53        let rare_files = expand_by_rare_identifiers(ctx);
54        let existing: FxHashSet<PathBuf> = discovered.iter().cloned().collect();
55        for f in rare_files {
56            if !existing.contains(&f) {
57                discovered.push(f);
58            }
59        }
60
61        discovered
62    }
63}
64
65fn expand_by_rare_identifiers(ctx: &DiscoveryContext) -> Vec<PathBuf> {
66    let rare_threshold = crate::config::limits::LIMITS.rare_identifier_threshold;
67
68    let mut ident_to_files: FxHashMap<String, Vec<PathBuf>> = FxHashMap::default();
69    for (path, doc) in &ctx.shared_corpus().docs {
70        for ident in &ctx.expansion_concepts {
71            if doc.term_counts.contains_key(ident) {
72                ident_to_files
73                    .entry(ident.clone())
74                    .or_default()
75                    .push(path.clone());
76            }
77        }
78    }
79
80    let mut result: Vec<PathBuf> = Vec::new();
81    let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
82    for (_ident, files) in &ident_to_files {
83        if files.len() <= rare_threshold {
84            for f in files {
85                if seen.insert(f.clone()) {
86                    result.push(f.clone());
87                }
88            }
89        }
90    }
91    result
92}
93
94pub struct TestFileDiscovery;
95
96const TEST_PREFIXES: &[&str] = &["test_", "spec_"];
97const TEST_SUFFIXES: &[&str] = &["_test", "_spec", ".test", ".spec", "-test", "-spec"];
98
99impl DiscoveryStrategy for TestFileDiscovery {
100    fn discover(&self, ctx: &DiscoveryContext) -> Vec<PathBuf> {
101        let changed_set: FxHashSet<&Path> = ctx.changed_files.iter().map(|p| p.as_path()).collect();
102        let mut target_stems: FxHashSet<String> = FxHashSet::default();
103
104        for f in &ctx.changed_files {
105            let stem = f
106                .file_stem()
107                .map(|s| s.to_string_lossy().to_lowercase())
108                .unwrap_or_default();
109            if TEST_PREFIXES.iter().any(|p| stem.starts_with(p)) {
110                continue;
111            }
112            if TEST_SUFFIXES.iter().any(|s| stem.ends_with(s)) {
113                continue;
114            }
115            target_stems.insert(stem.clone());
116            for prefix in TEST_PREFIXES {
117                target_stems.insert(format!("{}{}", prefix, stem));
118            }
119            for suffix in TEST_SUFFIXES {
120                target_stems.insert(format!("{}{}", stem, suffix));
121            }
122        }
123
124        let mut discovered: Vec<PathBuf> = Vec::new();
125        for candidate in &ctx.all_candidates {
126            if changed_set.contains(candidate.as_path()) {
127                continue;
128            }
129            let stem = candidate
130                .file_stem()
131                .map(|s| s.to_string_lossy().to_lowercase())
132                .unwrap_or_default();
133            if target_stems.contains(&stem) {
134                discovered.push(candidate.clone());
135            }
136        }
137        discovered
138    }
139}
140
141pub struct BM25Discovery {
142    pub top_k: usize,
143}
144
145impl BM25Discovery {
146    pub fn new(top_k: usize) -> Self {
147        Self { top_k }
148    }
149
150    fn bm25_score(
151        doc: &DocTokens,
152        query_set: &FxHashSet<String>,
153        idf: &FxHashMap<String, f64>,
154        avgdl: f64,
155    ) -> f64 {
156        let dl = doc.total_len as f64;
157        let mut s = 0.0;
158        for t in query_set {
159            let freq = doc.term_counts.get(t).copied().unwrap_or(0) as f64;
160            if freq == 0.0 {
161                continue;
162            }
163            let idf_val = idf.get(t).copied().unwrap_or(0.0);
164            s += idf_val * (freq * BM25.k1)
165                / (freq + BM25.k1 * (1.0 - BM25.b + BM25.b * dl / avgdl));
166        }
167        s
168    }
169}
170
171impl DiscoveryStrategy for BM25Discovery {
172    fn discover(&self, ctx: &DiscoveryContext) -> Vec<PathBuf> {
173        let query_tokens = extract_identifier_list(&ctx.diff_text, BM25.min_query_token_length);
174        if query_tokens.is_empty() {
175            return Vec::new();
176        }
177        let query_set: FxHashSet<String> = query_tokens.into_iter().collect();
178
179        let pairs = &ctx.shared_corpus().docs;
180
181        if pairs.is_empty() {
182            return Vec::new();
183        }
184        let n_docs = pairs.len();
185        if n_docs > 5000 {
186            tracing::warn!(
187                "BM25Discovery: large candidate corpus ({n_docs} docs) — using inverted-index fast path"
188            );
189        }
190
191        // Single pass: compute df globally + inverted-index posting lists
192        // for query terms only (skip indexing terms not in the query — they
193        // are never needed and would balloon memory on large repos).
194        let mut df: FxHashMap<String, usize> = FxHashMap::default();
195        let mut postings: FxHashMap<String, Vec<usize>> = FxHashMap::default();
196        let mut total_len: usize = 0;
197        for (doc_id, (_, doc)) in pairs.iter().enumerate() {
198            total_len += doc.total_len as usize;
199            for term in doc.term_counts.keys() {
200                *df.entry(term.clone()).or_insert(0) += 1;
201                if query_set.contains(term.as_str()) {
202                    postings.entry(term.clone()).or_default().push(doc_id);
203                }
204            }
205        }
206        let avgdl = total_len as f64 / n_docs as f64;
207
208        let idf: FxHashMap<String, f64> = query_set
209            .iter()
210            .map(|t| {
211                let d = df.get(t).copied().unwrap_or(0) as f64;
212                let val =
213                    ((n_docs as f64 - d + BM25.idf_smoothing) / (d + BM25.idf_smoothing)).ln_1p();
214                (t.clone(), val)
215            })
216            .collect();
217
218        // Candidate doc-ids = union of posting lists for query terms. Docs
219        // not in this set contain zero query terms and would score 0 — skip
220        // them. This is the algorithmic win: scoring shrinks from O(N_docs)
221        // to O(|posting-list union|), typically ~10-100× smaller on big
222        // corpora where the query is sparse against the corpus vocabulary.
223        let mut candidate_ids: FxHashSet<usize> = FxHashSet::default();
224        for term in &query_set {
225            if let Some(p) = postings.get(term) {
226                candidate_ids.extend(p);
227            }
228        }
229        if candidate_ids.is_empty() {
230            return Vec::new();
231        }
232
233        let candidate_vec: Vec<usize> = candidate_ids.into_iter().collect();
234        let scored: Vec<(usize, f64)> = candidate_vec
235            .par_iter()
236            .map(|&doc_id| {
237                let s = Self::bm25_score(&pairs[doc_id].1, &query_set, &idf, avgdl);
238                (doc_id, s)
239            })
240            .collect();
241
242        let mut ranked: Vec<(usize, f64)> = scored.into_iter().filter(|(_, s)| *s > 0.0).collect();
243        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
244
245        ranked
246            .into_iter()
247            .take(self.top_k)
248            .map(|(i, _)| pairs[i].0.clone())
249            .collect()
250    }
251}
252
253pub struct EnsembleDiscovery {
254    strategies: Vec<Box<dyn DiscoveryStrategy>>,
255}
256
257impl EnsembleDiscovery {
258    pub fn new(strategies: Vec<Box<dyn DiscoveryStrategy>>) -> Self {
259        Self { strategies }
260    }
261
262    pub fn default_ensemble() -> Self {
263        Self {
264            strategies: vec![
265                Box::new(DefaultDiscovery),
266                Box::new(TestFileDiscovery),
267                Box::new(BM25Discovery::new(1)),
268            ],
269        }
270    }
271}
272
273impl DiscoveryStrategy for EnsembleDiscovery {
274    fn discover(&self, ctx: &DiscoveryContext) -> Vec<PathBuf> {
275        let per_strategy: Vec<Vec<PathBuf>> = self
276            .strategies
277            .par_iter()
278            .map(|strategy| strategy.discover(ctx))
279            .collect();
280
281        let mut seen: FxHashSet<PathBuf> = FxHashSet::default();
282        let mut result: Vec<PathBuf> = Vec::new();
283        for paths in per_strategy {
284            for path in paths {
285                if seen.insert(path.clone()) {
286                    result.push(path);
287                }
288            }
289        }
290
291        result
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    fn doc(text: &str) -> DocTokens {
300        let terms = extract_identifier_list(text, 1);
301        let total_len = terms.len() as u32;
302        let mut term_counts: FxHashMap<String, u32> = FxHashMap::default();
303        for t in terms {
304            *term_counts.entry(t).or_insert(0) += 1;
305        }
306        DocTokens {
307            term_counts,
308            total_len,
309        }
310    }
311
312    struct CtxBuilder {
313        changed: Vec<&'static str>,
314        candidates: Vec<&'static str>,
315        diff_text: String,
316        concepts: Vec<&'static str>,
317        docs: Vec<(&'static str, &'static str)>,
318    }
319
320    impl CtxBuilder {
321        fn new() -> Self {
322            Self {
323                changed: Vec::new(),
324                candidates: Vec::new(),
325                diff_text: String::new(),
326                concepts: Vec::new(),
327                docs: Vec::new(),
328            }
329        }
330
331        fn build(self) -> DiscoveryContext {
332            let root = PathBuf::from("/repo");
333            let corpus = TokenCorpus {
334                docs: self
335                    .docs
336                    .iter()
337                    .map(|(p, text)| (root.join(p), doc(text)))
338                    .collect(),
339            };
340            let token_corpus = OnceLock::new();
341            token_corpus
342                .set(corpus)
343                .unwrap_or_else(|_| unreachable!("fresh OnceLock"));
344            DiscoveryContext {
345                root_dir: root.clone(),
346                changed_files: self.changed.iter().map(|p| root.join(p)).collect(),
347                all_candidates: self.candidates.iter().map(|p| root.join(p)).collect(),
348                diff_text: self.diff_text,
349                expansion_concepts: self.concepts.iter().map(|s| s.to_string()).collect(),
350                file_cache: FxHashMap::default(),
351                token_corpus,
352            }
353        }
354    }
355
356    fn names(paths: &[PathBuf]) -> Vec<String> {
357        let mut v: Vec<String> = paths
358            .iter()
359            .map(|p| {
360                p.strip_prefix("/repo")
361                    .unwrap_or(p)
362                    .to_string_lossy()
363                    .into_owned()
364            })
365            .collect();
366        v.sort();
367        v
368    }
369
370    /// Each naming convention pairs through a different entry in
371    /// TEST_PREFIXES/TEST_SUFFIXES, so removing one leaves the others working
372    /// and only that ecosystem's coverage silently disappears.
373    #[test]
374    fn test_file_discovery_pairs_every_supported_naming_convention() {
375        let ctx = CtxBuilder {
376            changed: vec!["src/auth.py", "web/handler.go", "ui/widget.ts"],
377            candidates: vec![
378                "tests/test_auth.py",
379                "web/handler_test.go",
380                "ui/widget.test.ts",
381                "ui/widget.spec.ts",
382                "ui/widget-spec.ts",
383            ],
384            ..CtxBuilder::new()
385        }
386        .build();
387
388        assert_eq!(
389            names(&TestFileDiscovery.discover(&ctx)),
390            vec![
391                "tests/test_auth.py",
392                "ui/widget-spec.ts",
393                "ui/widget.spec.ts",
394                "ui/widget.test.ts",
395                "web/handler_test.go",
396            ]
397        );
398    }
399
400    #[test]
401    fn test_file_discovery_does_not_pair_on_a_prefix_match() {
402        let ctx = CtxBuilder {
403            changed: vec!["src/authenticate.py"],
404            candidates: vec!["tests/test_auth.py", "tests/test_authenticate.py"],
405            ..CtxBuilder::new()
406        }
407        .build();
408        assert_eq!(
409            names(&TestFileDiscovery.discover(&ctx)),
410            vec!["tests/test_authenticate.py"]
411        );
412    }
413
414    #[test]
415    fn test_file_discovery_skips_changed_test_files_and_never_returns_a_changed_file() {
416        // A changed `test_auth.py` must not drag in `auth.py` via this
417        // strategy, and a candidate that is itself changed is never returned.
418        let ctx = CtxBuilder {
419            changed: vec!["tests/test_auth.py", "src/auth.py"],
420            candidates: vec!["tests/test_auth.py", "src/auth.py", "tests/test_other.py"],
421            ..CtxBuilder::new()
422        }
423        .build();
424        let found = names(&TestFileDiscovery.discover(&ctx));
425        assert!(!found.contains(&"src/auth.py".to_string()));
426        assert!(!found.contains(&"tests/test_auth.py".to_string()));
427    }
428
429    #[test]
430    fn rare_identifier_expansion_keeps_rare_terms_and_drops_common_ones() {
431        let threshold = crate::config::limits::LIMITS.rare_identifier_threshold;
432        let mut docs: Vec<(&'static str, &'static str)> = vec![
433            ("rare_a.py", "unique_marker"),
434            ("rare_b.py", "unique_marker"),
435        ];
436        // Push `common_marker` past the rarity threshold so it stops expanding.
437        let common: [&'static str; 6] = ["c0.py", "c1.py", "c2.py", "c3.py", "c4.py", "c5.py"];
438        for p in common.iter().take(threshold + 2) {
439            docs.push((p, "common_marker"));
440        }
441
442        let ctx = CtxBuilder {
443            concepts: vec!["unique_marker", "common_marker"],
444            docs,
445            ..CtxBuilder::new()
446        }
447        .build();
448
449        let found = names(&expand_by_rare_identifiers(&ctx));
450        assert!(
451            found.contains(&"rare_a.py".to_string()),
452            "rare term did not expand: {found:?}"
453        );
454        assert!(
455            found.contains(&"rare_b.py".to_string()),
456            "rare term did not expand: {found:?}"
457        );
458        assert!(
459            !found.iter().any(|f| f.starts_with("c")),
460            "a term appearing in more than {threshold} files still expanded: {found:?}"
461        );
462    }
463
464    #[test]
465    fn rare_identifier_expansion_is_empty_without_concepts() {
466        let ctx = CtxBuilder {
467            docs: vec![("a.py", "anything")],
468            ..CtxBuilder::new()
469        }
470        .build();
471        assert!(expand_by_rare_identifiers(&ctx).is_empty());
472    }
473
474    /// IDF is what makes a rare query term outrank a corpus-wide one. Negate or
475    /// flatten it and BM25 silently returns the most common file instead.
476    #[test]
477    fn bm25_ranks_a_rare_query_term_above_a_ubiquitous_one() {
478        let ctx = CtxBuilder {
479            diff_text: "+ use rare_needle; use ubiquitous_helper;".into(),
480            docs: vec![
481                ("has_rare.py", "rare_needle body body"),
482                ("common_1.py", "ubiquitous_helper body body"),
483                ("common_2.py", "ubiquitous_helper body body"),
484                ("common_3.py", "ubiquitous_helper body body"),
485                ("common_4.py", "ubiquitous_helper body body"),
486                ("common_5.py", "ubiquitous_helper body body"),
487            ],
488            ..CtxBuilder::new()
489        }
490        .build();
491
492        let ranked = BM25Discovery::new(6).discover(&ctx);
493        assert!(!ranked.is_empty(), "BM25 returned nothing");
494        assert_eq!(
495            names(&ranked[..1]),
496            vec!["has_rare.py"],
497            "the rare term did not win: {:?}",
498            names(&ranked)
499        );
500    }
501
502    #[test]
503    fn bm25_returns_nothing_when_no_document_contains_a_query_term() {
504        let ctx = CtxBuilder {
505            diff_text: "+ absent_symbol_xyz".into(),
506            docs: vec![("a.py", "unrelated content here")],
507            ..CtxBuilder::new()
508        }
509        .build();
510        assert!(BM25Discovery::new(5).discover(&ctx).is_empty());
511    }
512
513    #[test]
514    fn bm25_returns_nothing_on_an_empty_query_or_an_empty_corpus() {
515        let empty_query = CtxBuilder {
516            docs: vec![("a.py", "content")],
517            ..CtxBuilder::new()
518        }
519        .build();
520        assert!(BM25Discovery::new(5).discover(&empty_query).is_empty());
521
522        let empty_corpus = CtxBuilder {
523            diff_text: "+ some_symbol".into(),
524            ..CtxBuilder::new()
525        }
526        .build();
527        assert!(BM25Discovery::new(5).discover(&empty_corpus).is_empty());
528    }
529
530    #[test]
531    fn bm25_honours_top_k() {
532        let ctx = CtxBuilder {
533            diff_text: "+ shared_term".into(),
534            docs: vec![
535                ("a.py", "shared_term shared_term a"),
536                ("b.py", "shared_term b b b"),
537                ("c.py", "shared_term c c c c"),
538            ],
539            ..CtxBuilder::new()
540        }
541        .build();
542        assert_eq!(BM25Discovery::new(2).discover(&ctx).len(), 2);
543    }
544
545    /// The ensemble is the only caller in production, and it is what hides a
546    /// dead channel: if one strategy stops returning anything the others still
547    /// produce results, so recall drops with no error anywhere.
548    #[test]
549    fn ensemble_deduplicates_across_strategies_and_preserves_first_hit_order() {
550        struct Fixed(Vec<&'static str>);
551        impl DiscoveryStrategy for Fixed {
552            fn discover(&self, ctx: &DiscoveryContext) -> Vec<PathBuf> {
553                self.0.iter().map(|p| ctx.root_dir.join(p)).collect()
554            }
555        }
556
557        let ctx = CtxBuilder::new().build();
558        let ensemble = EnsembleDiscovery::new(vec![
559            Box::new(Fixed(vec!["a.py", "b.py"])),
560            Box::new(Fixed(vec!["b.py", "c.py"])),
561            Box::new(Fixed(vec![])),
562        ]);
563        let found = ensemble.discover(&ctx);
564        assert_eq!(
565            found
566                .iter()
567                .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
568                .collect::<Vec<_>>(),
569            vec!["a.py", "b.py", "c.py"]
570        );
571    }
572
573    #[test]
574    fn default_ensemble_wires_three_channels() {
575        // A channel silently disappearing from the default wiring is exactly
576        // the regression the ensemble's own output cannot reveal.
577        assert_eq!(EnsembleDiscovery::default_ensemble().strategies.len(), 3);
578    }
579}