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 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 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 #[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 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 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 #[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 #[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 assert_eq!(EnsembleDiscovery::default_ensemble().strategies.len(), 3);
578 }
579}