1use std::path::Path;
2use std::sync::Arc;
3use std::time::Instant;
4
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::bm25::BM25;
8use crate::config::edge_weights::SEMANTIC_DISCOVERY;
9use crate::config::limits::{LIMITS, PPR};
10use crate::config::scoring::{EGO, pit, rrf};
11use crate::config::tokenization::TOKENIZATION;
12use crate::edges;
13use crate::filtering;
14use crate::graph::{self, Graph};
15use crate::mode::{PipelineConfig, ScoringKind};
16use crate::ppr::personalized_pagerank;
17use crate::types::{DiffHunk, Fragment, FragmentId, extract_identifier_list};
18
19pub(crate) fn file_admission_enabled() -> bool {
24 std::env::var_os("DIFFCTX_FILE_ADMISSION").is_none_or(|v| v != "0")
25}
26
27pub struct ScoringResult {
28 pub rel_scores: FxHashMap<FragmentId, f64>,
29 pub filtered_fragments: Vec<Fragment>,
30 pub admissible_files: Option<FxHashSet<std::sync::Arc<str>>>,
34 pub graph: Graph,
35 pub graph_build_ms: f64,
40 pub ppr_truncated: bool,
43 pub ppr_forward_pushes: usize,
44 pub ppr_backward_pushes: usize,
45}
46
47fn finish_scoring(
54 fragments: &[Fragment],
55 core_ids: &FxHashSet<FragmentId>,
56 rel_scores: &FxHashMap<FragmentId, f64>,
57 graph: &Graph,
58) -> Vec<Fragment> {
59 let filtered = filtering::filter_unrelated_fragments(fragments, core_ids, graph);
60 let filtered = filtering::filter_positive_relevance(filtered, core_ids, rel_scores);
61 let filtered = filtering::filter_core_slice_context(filtered, core_ids);
62 filtering::cap_context_fragments(filtered, core_ids, rel_scores)
63}
64
65impl Default for ScoringResult {
66 fn default() -> Self {
67 Self {
68 rel_scores: FxHashMap::default(),
69 filtered_fragments: Vec::new(),
70 admissible_files: None,
71 graph: Graph::new(),
72 graph_build_ms: 0.0,
73 ppr_truncated: false,
74 ppr_forward_pushes: 0,
75 ppr_backward_pushes: 0,
76 }
77 }
78}
79
80pub fn create_scoring_strategy(config: &PipelineConfig) -> Box<dyn ScoringStrategy> {
81 match config.scoring {
82 ScoringKind::Ego => Box::new(EgoGraphScoring::new(config.ego_depth)),
83 ScoringKind::Ppr => Box::new(PPRScoring::new(config.ppr_alpha)),
84 ScoringKind::Bm25 => Box::new(BM25Scoring),
85 ScoringKind::Rrf => Box::new(RrfFusionScoring::new(config.ego_depth)),
86 ScoringKind::Pit => Box::new(PitFusionScoring::new(config.ego_depth)),
87 }
88}
89
90pub trait ScoringStrategy: Send + Sync {
91 #[allow(clippy::too_many_arguments)]
92 fn score_and_filter(
93 &self,
94 all_fragments: &[Fragment],
95 core_ids: &FxHashSet<FragmentId>,
96 hunks: &[DiffHunk],
97 repo_root: Option<&Path>,
98 seed_weights: Option<&FxHashMap<FragmentId, f64>>,
99 discovered_paths: Option<&FxHashSet<Arc<str>>>,
100 deadline: crate::deadline::Deadline,
101 ) -> ScoringResult;
102}
103
104pub struct PPRScoring {
105 pub alpha: f64,
106}
107
108impl PPRScoring {
109 pub fn new(alpha: f64) -> Self {
110 Self { alpha }
111 }
112}
113
114impl ScoringStrategy for PPRScoring {
115 fn score_and_filter(
116 &self,
117 all_fragments: &[Fragment],
118 core_ids: &FxHashSet<FragmentId>,
119 hunks: &[DiffHunk],
120 repo_root: Option<&Path>,
121 seed_weights: Option<&FxHashMap<FragmentId, f64>>,
122 _discovered_paths: Option<&FxHashSet<Arc<str>>>,
123 deadline: crate::deadline::Deadline,
124 ) -> ScoringResult {
125 let skip_expensive = all_fragments.len() > LIMITS.skip_expensive_threshold;
126 let t_graph = Instant::now();
127 let capped =
128 edges::collect_capped_edges(all_fragments, repo_root, skip_expensive, deadline);
129 let admissible_files = file_admission_enabled().then(|| {
130 edges::naming_reachable_files(&capped, core_ids, SEMANTIC_DISCOVERY.max_depth)
131 });
132 let mut g = graph::build_graph_capped(all_fragments, capped);
133 let graph_build_ms = t_graph.elapsed().as_secs_f64() * 1000.0;
134 let ppr = personalized_pagerank(
135 &mut g,
136 core_ids,
137 self.alpha,
138 PPR.convergence_tolerance,
139 PPR.forward_blend,
140 seed_weights,
141 );
142 let mut rel_scores = ppr.scores;
143 if ppr.truncated {
144 tracing::warn!(
145 "PPR push-cap hit on {} nodes (fwd_pushes={}, bwd_pushes={}); rel_scores biased",
146 g.node_count(),
147 ppr.forward_pushes,
148 ppr.backward_pushes,
149 );
150 }
151 filtering::apply_hunk_proximity_bonus(&mut rel_scores, core_ids, all_fragments, hunks);
152
153 let filtered = finish_scoring(all_fragments, core_ids, &rel_scores, &g);
154
155 ScoringResult {
156 admissible_files,
157 rel_scores,
158 filtered_fragments: filtered,
159 graph: g,
160 graph_build_ms,
161 ppr_truncated: ppr.truncated,
162 ppr_forward_pushes: ppr.forward_pushes,
163 ppr_backward_pushes: ppr.backward_pushes,
164 }
165 }
166}
167
168pub struct EgoGraphScoring {
169 pub max_depth: usize,
170}
171
172impl EgoGraphScoring {
173 pub fn new(max_depth: usize) -> Self {
174 Self { max_depth }
175 }
176}
177
178impl ScoringStrategy for EgoGraphScoring {
179 fn score_and_filter(
180 &self,
181 all_fragments: &[Fragment],
182 core_ids: &FxHashSet<FragmentId>,
183 _hunks: &[DiffHunk],
184 repo_root: Option<&Path>,
185 _seed_weights: Option<&FxHashMap<FragmentId, f64>>,
186 _discovered_paths: Option<&FxHashSet<Arc<str>>>,
187 deadline: crate::deadline::Deadline,
188 ) -> ScoringResult {
189 let skip_expensive = all_fragments.len() > LIMITS.skip_expensive_threshold;
190 let t_graph = Instant::now();
191 let capped =
192 edges::collect_capped_edges(all_fragments, repo_root, skip_expensive, deadline);
193 let admissible_files = file_admission_enabled().then(|| {
194 edges::naming_reachable_files(&capped, core_ids, SEMANTIC_DISCOVERY.max_depth)
195 });
196 let g = graph::build_graph_capped(all_fragments, capped);
197 let graph_build_ms = t_graph.elapsed().as_secs_f64() * 1000.0;
198 let mut rel_scores = g.ego_graph(core_ids, self.max_depth);
199
200 let diff_idents: FxHashSet<String> = all_fragments
201 .iter()
202 .filter(|f| core_ids.contains(&f.id))
203 .flat_map(|f| f.identifiers.iter().cloned())
204 .collect();
205
206 if !diff_idents.is_empty() {
207 for frag in all_fragments {
208 if core_ids.contains(&frag.id) || !rel_scores.contains_key(&frag.id) {
209 continue;
210 }
211 let overlap = frag.identifiers.intersection(&diff_idents).count();
212 if overlap > 0 {
213 let bonus = EGO.identifier_overlap_epsilon
214 * overlap.min(EGO.identifier_overlap_cap) as f64
215 / EGO.identifier_overlap_cap as f64;
216 *rel_scores.get_mut(&frag.id).unwrap() += bonus;
217 }
218 }
219 }
220
221 let filtered = finish_scoring(all_fragments, core_ids, &rel_scores, &g);
222
223 ScoringResult {
224 admissible_files,
225 rel_scores,
226 filtered_fragments: filtered,
227 graph: g,
228 graph_build_ms,
229 ..Default::default()
230 }
231 }
232}
233
234pub struct BM25Scoring;
235
236impl ScoringStrategy for BM25Scoring {
237 fn score_and_filter(
238 &self,
239 all_fragments: &[Fragment],
240 core_ids: &FxHashSet<FragmentId>,
241 _hunks: &[DiffHunk],
242 _repo_root: Option<&Path>,
243 _seed_weights: Option<&FxHashMap<FragmentId, f64>>,
244 _discovered_paths: Option<&FxHashSet<Arc<str>>>,
245 _deadline: crate::deadline::Deadline,
246 ) -> ScoringResult {
247 let query_tokens: Vec<String> = all_fragments
248 .iter()
249 .filter(|f| core_ids.contains(&f.id))
250 .flat_map(|f| {
251 extract_identifier_list(&f.content, TOKENIZATION.query_min_identifier_length)
252 })
253 .collect();
254 let query_set: FxHashSet<String> = query_tokens.into_iter().collect();
255
256 let docs: Vec<(FragmentId, Vec<String>)> = all_fragments
257 .iter()
258 .filter(|f| !core_ids.contains(&f.id))
259 .map(|f| {
260 (
261 f.id.clone(),
262 extract_identifier_list(&f.content, TOKENIZATION.query_min_identifier_length),
263 )
264 })
265 .collect();
266
267 let n_docs = docs.len().max(1);
268 let avgdl = docs.iter().map(|(_, d)| d.len()).sum::<usize>() as f64 / n_docs as f64;
269
270 let mut df: FxHashMap<String, usize> = FxHashMap::default();
271 for (_, doc) in &docs {
272 let unique: FxHashSet<&str> = doc.iter().map(|s| s.as_str()).collect();
273 for term in unique {
274 *df.entry(term.to_string()).or_insert(0) += 1;
275 }
276 }
277
278 let idf: FxHashMap<String, f64> = query_set
279 .iter()
280 .map(|t| {
281 let d = df.get(t).copied().unwrap_or(0) as f64;
282 let val =
283 ((n_docs as f64 - d + BM25.idf_smoothing) / (d + BM25.idf_smoothing)).ln_1p();
284 (t.clone(), val)
285 })
286 .collect();
287
288 let mut rel_scores: FxHashMap<FragmentId, f64> = FxHashMap::default();
289 for frag in all_fragments {
290 if core_ids.contains(&frag.id) {
291 rel_scores.insert(frag.id.clone(), 1.0);
292 }
293 }
294 for (fid, doc) in &docs {
295 let dl = doc.len() as f64;
296 let mut tf: FxHashMap<&str, u32> = FxHashMap::default();
297 for t in doc {
298 *tf.entry(t.as_str()).or_insert(0) += 1;
299 }
300 let mut score = 0.0;
301 for t in &query_set {
302 let freq = tf.get(t.as_str()).copied().unwrap_or(0) as f64;
303 if freq == 0.0 {
304 continue;
305 }
306 let idf_val = idf.get(t).copied().unwrap_or(0.0);
307 score += idf_val * (freq * BM25.k1)
308 / (freq + BM25.k1 * (1.0 - BM25.b + BM25.b * dl / avgdl));
309 }
310 if score > 0.0 {
311 rel_scores.insert(fid.clone(), score);
312 }
313 }
314
315 let max_score = rel_scores.values().copied().fold(0.0f64, f64::max);
316 if max_score > 0.0 {
317 for v in rel_scores.values_mut() {
318 *v /= max_score;
319 }
320 }
321
322 let filtered =
326 filtering::filter_positive_relevance(all_fragments.to_vec(), core_ids, &rel_scores);
327 let filtered = filtering::cap_context_fragments(filtered, core_ids, &rel_scores);
328
329 ScoringResult {
330 admissible_files: None,
331 rel_scores,
332 filtered_fragments: filtered,
333 ..Default::default()
334 }
335 }
336}
337
338pub struct RrfFusionScoring {
347 pub ego_depth: usize,
348 pub k: f64,
349}
350
351impl RrfFusionScoring {
352 pub fn new(ego_depth: usize) -> Self {
353 Self {
354 ego_depth,
355 k: rrf().k,
356 }
357 }
358}
359
360fn rank_positions(
372 rel: &FxHashMap<FragmentId, f64>,
373 admitted: &FxHashSet<FragmentId>,
374 core_ids: &FxHashSet<FragmentId>,
375) -> FxHashMap<FragmentId, usize> {
376 let mut ranked: Vec<(&FragmentId, f64)> = rel
377 .iter()
378 .filter(|(fid, score)| **score > 0.0 && !core_ids.contains(*fid) && admitted.contains(*fid))
379 .map(|(fid, score)| (fid, *score))
380 .collect();
381 ranked.sort_by(|(ida, sa), (idb, sb)| sb.total_cmp(sa).then_with(|| ida.cmp(idb)));
384 ranked
385 .into_iter()
386 .enumerate()
387 .map(|(i, (fid, _))| (fid.clone(), i + 1))
388 .collect()
389}
390
391fn fuse_reciprocal_ranks(
392 components: &[(&FxHashMap<FragmentId, f64>, &FxHashSet<FragmentId>)],
393 core_ids: &FxHashSet<FragmentId>,
394 k: f64,
395) -> FxHashMap<FragmentId, f64> {
396 let mut fused: FxHashMap<FragmentId, f64> = FxHashMap::default();
397 for (rel, admitted) in components {
398 for (fid, rank) in rank_positions(rel, admitted, core_ids) {
399 *fused.entry(fid).or_insert(0.0) += 1.0 / (k + rank as f64);
400 }
401 }
402
403 let max_fused = fused.values().copied().fold(0.0f64, f64::max);
404 if max_fused > 0.0 {
405 for v in fused.values_mut() {
406 *v /= max_fused;
407 }
408 }
409 for fid in core_ids {
417 fused.insert(fid.clone(), 1.0);
418 }
419 fused
420}
421
422impl ScoringStrategy for RrfFusionScoring {
423 fn score_and_filter(
424 &self,
425 all_fragments: &[Fragment],
426 core_ids: &FxHashSet<FragmentId>,
427 hunks: &[DiffHunk],
428 repo_root: Option<&Path>,
429 seed_weights: Option<&FxHashMap<FragmentId, f64>>,
430 discovered_paths: Option<&FxHashSet<Arc<str>>>,
431 deadline: crate::deadline::Deadline,
432 ) -> ScoringResult {
433 let ego = EgoGraphScoring::new(self.ego_depth).score_and_filter(
434 all_fragments,
435 core_ids,
436 hunks,
437 repo_root,
438 seed_weights,
439 discovered_paths,
440 deadline,
441 );
442 let lexical = BM25Scoring.score_and_filter(
443 all_fragments,
444 core_ids,
445 hunks,
446 repo_root,
447 seed_weights,
448 discovered_paths,
449 deadline,
450 );
451
452 let ego_admitted: FxHashSet<FragmentId> = ego
453 .filtered_fragments
454 .iter()
455 .map(|f| f.id.clone())
456 .collect();
457 let lexical_admitted: FxHashSet<FragmentId> = lexical
458 .filtered_fragments
459 .iter()
460 .map(|f| f.id.clone())
461 .collect();
462
463 let rel_scores = fuse_reciprocal_ranks(
464 &[
465 (&ego.rel_scores, &ego_admitted),
466 (&lexical.rel_scores, &lexical_admitted),
467 ],
468 core_ids,
469 self.k,
470 );
471
472 let union_ids: FxHashSet<FragmentId> =
473 ego_admitted.union(&lexical_admitted).cloned().collect();
474
475 let union: Vec<Fragment> = all_fragments
476 .iter()
477 .filter(|f| union_ids.contains(&f.id))
478 .cloned()
479 .collect();
480
481 let filtered = finish_scoring(&union, core_ids, &rel_scores, &ego.graph);
499
500 ScoringResult {
501 admissible_files: ego.admissible_files.clone(),
506 rel_scores,
507 filtered_fragments: filtered,
508 graph: ego.graph,
509 graph_build_ms: ego.graph_build_ms,
510 ..Default::default()
511 }
512 }
513}
514
515pub struct PitFusionScoring {
535 pub ego_depth: usize,
536 pub blend: f64,
537 pub agreement_bonus: f64,
538 pub agreement_top_k: usize,
539}
540
541impl PitFusionScoring {
542 pub fn new(ego_depth: usize) -> Self {
543 let cfg = pit();
544 Self {
545 ego_depth,
546 blend: cfg.blend,
547 agreement_bonus: cfg.agreement_bonus,
548 agreement_top_k: cfg.agreement_top_k,
549 }
550 }
551}
552
553fn percentiles(
571 rel: &FxHashMap<FragmentId, f64>,
572 admitted: &FxHashSet<FragmentId>,
573 core_ids: &FxHashSet<FragmentId>,
574 top_k: usize,
575) -> (FxHashMap<FragmentId, f64>, FxHashSet<FragmentId>) {
576 let mut ranked: Vec<(&FragmentId, f64)> = rel
577 .iter()
578 .filter(|(fid, score)| **score > 0.0 && !core_ids.contains(*fid))
579 .map(|(fid, score)| (fid, *score))
580 .collect();
581 ranked.sort_by(|(ida, sa), (idb, sb)| sb.total_cmp(sa).then_with(|| ida.cmp(idb)));
584
585 let n = ranked.len();
586 let mut out: FxHashMap<FragmentId, f64> = FxHashMap::default();
587 let mut top: FxHashSet<FragmentId> = FxHashSet::default();
588 if n == 0 {
589 return (out, top);
590 }
591
592 if std::env::var("DIFFCTX_PIT_TRANSFORM").as_deref() == Ok("maxnorm") {
600 let denom = ranked
601 .iter()
602 .map(|(_, s)| *s)
603 .fold(0.0f64, f64::max)
604 .max(f64::MIN_POSITIVE);
605 for (fid, score) in &ranked {
606 if admitted.contains(*fid) {
607 out.insert((*fid).clone(), *score / denom);
608 }
609 }
610 } else {
611 let mut i = 0;
612 while i < n {
613 let mut j = i;
615 while j + 1 < n && ranked[j + 1].1.to_bits() == ranked[i].1.to_bits() {
616 j += 1;
617 }
618 let mean_pos = (i + j) as f64 / 2.0;
620 let percentile = 1.0 - mean_pos / n as f64;
621 for (fid, _) in &ranked[i..=j] {
622 if admitted.contains(*fid) {
623 out.insert((*fid).clone(), percentile);
624 }
625 }
626 i = j + 1;
627 }
628 }
629
630 for (fid, _) in ranked
634 .iter()
635 .filter(|(fid, _)| admitted.contains(*fid))
636 .take(top_k)
637 {
638 top.insert((*fid).clone());
639 }
640 (out, top)
641}
642
643fn quantile_map_to(
665 reference: &[f64],
666 fused: &FxHashMap<FragmentId, f64>,
667) -> FxHashMap<FragmentId, f64> {
668 let mut order: Vec<(&FragmentId, f64)> = fused
673 .iter()
674 .filter(|(_, s)| **s > 0.0)
675 .map(|(f, s)| (f, *s))
676 .collect();
677 if reference.is_empty() || order.is_empty() {
678 return order.into_iter().map(|(f, s)| (f.clone(), s)).collect();
679 }
680 order.sort_by(|(ida, sa), (idb, sb)| sb.total_cmp(sa).then_with(|| ida.cmp(idb)));
681
682 let m = order.len();
683 let n = reference.len();
684 let mut out = FxHashMap::default();
685 let mut i = 0;
686 while i < m {
687 let mut j = i;
691 while j + 1 < m && order[j + 1].1.to_bits() == order[i].1.to_bits() {
692 j += 1;
693 }
694 let mid_rank = (i + j) as f64 / 2.0;
695 let idx = if m == 1 {
696 n - 1
697 } else {
698 let pos = ((m - 1) as f64 - mid_rank) / (m - 1) as f64;
699 ((pos * (n - 1) as f64).round() as usize).min(n - 1)
700 };
701 for (fid, _) in &order[i..=j] {
702 out.insert((*fid).clone(), reference[idx]);
703 }
704 i = j + 1;
705 }
706 out
707}
708
709impl ScoringStrategy for PitFusionScoring {
710 fn score_and_filter(
711 &self,
712 all_fragments: &[Fragment],
713 core_ids: &FxHashSet<FragmentId>,
714 hunks: &[DiffHunk],
715 repo_root: Option<&Path>,
716 seed_weights: Option<&FxHashMap<FragmentId, f64>>,
717 discovered_paths: Option<&FxHashSet<Arc<str>>>,
718 deadline: crate::deadline::Deadline,
719 ) -> ScoringResult {
720 let ego = EgoGraphScoring::new(self.ego_depth).score_and_filter(
721 all_fragments,
722 core_ids,
723 hunks,
724 repo_root,
725 seed_weights,
726 discovered_paths,
727 deadline,
728 );
729 let lexical = BM25Scoring.score_and_filter(
730 all_fragments,
731 core_ids,
732 hunks,
733 repo_root,
734 seed_weights,
735 discovered_paths,
736 deadline,
737 );
738
739 let ego_admitted: FxHashSet<FragmentId> = ego
740 .filtered_fragments
741 .iter()
742 .map(|f| f.id.clone())
743 .collect();
744 let lexical_admitted: FxHashSet<FragmentId> = lexical
745 .filtered_fragments
746 .iter()
747 .map(|f| f.id.clone())
748 .collect();
749
750 let (ego_pct, ego_top) = percentiles(
751 &ego.rel_scores,
752 &ego_admitted,
753 core_ids,
754 self.agreement_top_k,
755 );
756 let (lex_pct, lex_top) = percentiles(
757 &lexical.rel_scores,
758 &lexical_admitted,
759 core_ids,
760 self.agreement_top_k,
761 );
762
763 let mut rel_scores: FxHashMap<FragmentId, f64> = FxHashMap::default();
764 for fid in ego_pct.keys().chain(lex_pct.keys()) {
765 if rel_scores.contains_key(fid) {
766 continue;
767 }
768 let e = ego_pct.get(fid).copied().unwrap_or(0.0);
774 let l = lex_pct.get(fid).copied().unwrap_or(0.0);
775 let mut score = self.blend * e + (1.0 - self.blend) * l;
776 if ego_top.contains(fid) && lex_top.contains(fid) {
777 score += self.agreement_bonus;
778 }
779 rel_scores.insert(fid.clone(), score);
780 }
781
782 if std::env::var("DIFFCTX_PIT_SHAPE").as_deref() == Ok("flat") {
786 let max_fused = rel_scores.values().copied().fold(0.0f64, f64::max);
787 if max_fused > 0.0 {
788 for v in rel_scores.values_mut() {
789 *v /= max_fused;
790 }
791 }
792 for fid in core_ids {
793 rel_scores.insert(fid.clone(), 1.0);
794 }
795 } else {
796 let mut reference: Vec<f64> = ego
797 .rel_scores
798 .iter()
799 .filter(|(fid, s)| {
800 **s > 0.0 && !core_ids.contains(*fid) && ego_admitted.contains(*fid)
801 })
802 .map(|(_, s)| *s)
803 .collect();
804 reference.sort_by(f64::total_cmp);
805 rel_scores = quantile_map_to(&reference, &rel_scores);
806
807 for fid in core_ids {
811 if let Some(s) = ego.rel_scores.get(fid) {
812 rel_scores.insert(fid.clone(), *s);
813 }
814 }
815 }
816
817 let union_ids: FxHashSet<FragmentId> =
818 ego_admitted.union(&lexical_admitted).cloned().collect();
819 let union: Vec<Fragment> = all_fragments
820 .iter()
821 .filter(|f| union_ids.contains(&f.id))
822 .cloned()
823 .collect();
824 let filtered = finish_scoring(&union, core_ids, &rel_scores, &ego.graph);
825
826 ScoringResult {
827 admissible_files: ego.admissible_files.clone(),
832 rel_scores,
833 filtered_fragments: filtered,
834 graph: ego.graph,
835 graph_build_ms: ego.graph_build_ms,
836 ..Default::default()
837 }
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844 use crate::types::FragmentKind;
845
846 fn fid(path: &str, start: u32) -> FragmentId {
847 FragmentId::new(Arc::from(path), start, start + 4)
848 }
849
850 fn scores(entries: &[(FragmentId, f64)]) -> FxHashMap<FragmentId, f64> {
851 entries.iter().cloned().collect()
852 }
853
854 fn ballot(rel: &FxHashMap<FragmentId, f64>) -> FxHashSet<FragmentId> {
858 rel.keys().cloned().collect()
859 }
860
861 #[test]
866 fn agreement_between_both_signals_outranks_a_single_signal_top_hit() {
867 let agreed = fid("agreed.rs", 1);
868 let ego_only = fid("ego_only.rs", 1);
869 let cores: FxHashSet<FragmentId> = FxHashSet::default();
870
871 let ego = scores(&[(ego_only.clone(), 0.9), (agreed.clone(), 0.5)]);
874 let lexical = scores(&[(agreed.clone(), 0.5)]);
875
876 let fused = fuse_reciprocal_ranks(
877 &[(&ego, &ballot(&ego)), (&lexical, &ballot(&lexical))],
878 &cores,
879 60.0,
880 );
881 assert!(
882 fused[&agreed] > fused[&ego_only],
883 "agreement lost to a single-signal top hit: {:?} vs {:?}",
884 fused[&agreed],
885 fused[&ego_only]
886 );
887 }
888
889 #[test]
893 fn only_the_rank_order_of_a_component_matters_not_its_scale() {
894 let a = fid("a.rs", 1);
895 let b = fid("b.rs", 1);
896 let cores: FxHashSet<FragmentId> = FxHashSet::default();
897 let lexical = scores(&[(a.clone(), 0.4), (b.clone(), 0.1)]);
898
899 let modest = scores(&[(a.clone(), 0.6), (b.clone(), 0.4)]);
900 let enormous = scores(&[(a.clone(), 6_000.0), (b.clone(), 4_000.0)]);
901
902 assert_eq!(
903 fuse_reciprocal_ranks(
904 &[(&modest, &ballot(&modest)), (&lexical, &ballot(&lexical))],
905 &cores,
906 60.0
907 ),
908 fuse_reciprocal_ranks(
909 &[
910 (&enormous, &ballot(&enormous)),
911 (&lexical, &ballot(&lexical))
912 ],
913 &cores,
914 60.0
915 ),
916 "rescaling one component changed the fused scores"
917 );
918 }
919
920 #[test]
924 fn fused_scores_are_normalised_and_cores_anchor_the_top() {
925 let core = fid("changed.rs", 1);
926 let ctx = fid("ctx.rs", 1);
927 let cores: FxHashSet<FragmentId> = std::iter::once(core.clone()).collect();
928 let ego = scores(&[(core.clone(), 1.0), (ctx.clone(), 0.3)]);
929 let lexical = scores(&[(ctx.clone(), 0.2)]);
930
931 let fused = fuse_reciprocal_ranks(
932 &[(&ego, &ballot(&ego)), (&lexical, &ballot(&lexical))],
933 &cores,
934 60.0,
935 );
936 assert_eq!(fused[&core], 1.0, "core is not anchored at the top");
937 for (id, score) in &fused {
938 assert!(
939 (0.0..=1.0).contains(score),
940 "{id:?} scored {score} outside [0, 1]"
941 );
942 }
943 }
944
945 #[test]
949 fn cores_do_not_occupy_rank_positions() {
950 let core = fid("changed.rs", 1);
951 let ctx = fid("ctx.rs", 1);
952 let cores: FxHashSet<FragmentId> = std::iter::once(core.clone()).collect();
953
954 let with_core = scores(&[(core.clone(), 1.0), (ctx.clone(), 0.3)]);
955 let without_core = scores(&[(ctx.clone(), 0.3)]);
956
957 assert_eq!(
958 rank_positions(&with_core, &ballot(&with_core), &cores).get(&ctx),
959 rank_positions(&without_core, &ballot(&without_core), &cores).get(&ctx),
960 "a core shifted the rank of a context fragment"
961 );
962 }
963
964 #[test]
967 fn equal_component_scores_rank_deterministically() {
968 let cores: FxHashSet<FragmentId> = FxHashSet::default();
969 let ids: Vec<FragmentId> = (0..8).map(|i| fid(&format!("f{i}.rs"), 1)).collect();
970 let tied: FxHashMap<FragmentId, f64> = ids.iter().map(|i| (i.clone(), 0.5)).collect();
971
972 let baseline = rank_positions(&tied, &ballot(&tied), &cores);
973 for _ in 0..8 {
974 let again: FxHashMap<FragmentId, f64> =
975 ids.iter().rev().map(|i| (i.clone(), 0.5)).collect();
976 assert_eq!(rank_positions(&again, &ballot(&again), &cores), baseline);
977 }
978
979 let mut sorted = ids.clone();
981 sorted.sort();
982 for (expected_rank, id) in sorted.iter().enumerate() {
983 assert_eq!(baseline[id], expected_rank + 1);
984 }
985 }
986
987 #[test]
990 fn non_positive_component_scores_are_not_ranked() {
991 let kept = fid("kept.rs", 1);
992 let zero = fid("zero.rs", 1);
993 let cores: FxHashSet<FragmentId> = FxHashSet::default();
994 let component = scores(&[(kept.clone(), 0.3), (zero.clone(), 0.0)]);
995
996 let ranks = rank_positions(&component, &ballot(&component), &cores);
997 assert!(ranks.contains_key(&kept));
998 assert!(
999 !ranks.contains_key(&zero),
1000 "a zero-scored fragment was ranked"
1001 );
1002
1003 let fused = fuse_reciprocal_ranks(&[(&component, &ballot(&component))], &cores, 60.0);
1004 assert!(
1005 !fused.contains_key(&zero),
1006 "a zero-scored fragment was fused"
1007 );
1008 }
1009
1010 #[test]
1013 fn a_larger_k_flattens_the_gap_between_adjacent_ranks() {
1014 let first = fid("first.rs", 1);
1015 let second = fid("second.rs", 1);
1016 let cores: FxHashSet<FragmentId> = FxHashSet::default();
1017 let component = scores(&[(first.clone(), 0.9), (second.clone(), 0.8)]);
1018
1019 let sharp = fuse_reciprocal_ranks(&[(&component, &ballot(&component))], &cores, 1.0);
1020 let flat = fuse_reciprocal_ranks(&[(&component, &ballot(&component))], &cores, 60.0);
1021
1022 assert!(
1024 flat[&second] > sharp[&second],
1025 "k did not flatten adjacent ranks: {} vs {}",
1026 flat[&second],
1027 sharp[&second]
1028 );
1029 }
1030
1031 #[test]
1038 fn a_component_cannot_vote_for_what_its_own_filters_rejected() {
1039 let good = fid("good.rs", 1);
1040 let rejected = fid("garbage.rs", 1);
1041 let cores: FxHashSet<FragmentId> = FxHashSet::default();
1042 let component = scores(&[(rejected.clone(), 0.9), (good.clone(), 0.1)]);
1045 let admitted: FxHashSet<FragmentId> = std::iter::once(good.clone()).collect();
1046
1047 let fused = fuse_reciprocal_ranks(&[(&component, &admitted)], &cores, 60.0);
1048
1049 assert!(
1050 !fused.contains_key(&rejected),
1051 "a fragment the component filtered out still earned fused mass {:?}",
1052 fused.get(&rejected)
1053 );
1054 assert!(
1055 fused.contains_key(&good),
1056 "the admitted fragment lost its vote"
1057 );
1058 }
1059
1060 #[test]
1061 fn a_percentile_is_a_position_in_the_full_population_not_the_admitted_subset() {
1062 let cores: FxHashSet<FragmentId> = FxHashSet::default();
1063 let mut entries: Vec<(FragmentId, f64)> = (0..9)
1067 .map(|i| (fid("strong.rs", i + 1), 1.0 - i as f64 * 0.05))
1068 .collect();
1069 let weak = fid("weak.rs", 100);
1070 entries.push((weak.clone(), 0.01));
1071 let rel = scores(&entries);
1072 let admitted: FxHashSet<FragmentId> = std::iter::once(weak.clone()).collect();
1073
1074 let (pct, _) = percentiles(&rel, &admitted, &cores, 5);
1075
1076 assert_eq!(pct.len(), 1, "only admitted fragments may receive a value");
1077 let p = pct[&weak];
1078 assert!(
1079 p <= 0.2,
1080 "the weakest of ten scored {p}, reading as strong because the CDF \
1081 was estimated over the admitted subset"
1082 );
1083 }
1084
1085 #[test]
1086 fn a_rejected_fragment_gets_no_percentile_at_all() {
1087 let cores: FxHashSet<FragmentId> = FxHashSet::default();
1088 let good = fid("good.rs", 1);
1089 let rejected = fid("garbage.rs", 1);
1090 let rel = scores(&[(rejected.clone(), 0.9), (good.clone(), 0.1)]);
1091 let admitted: FxHashSet<FragmentId> = std::iter::once(good.clone()).collect();
1092
1093 let (pct, top) = percentiles(&rel, &admitted, &cores, 5);
1094
1095 assert!(
1096 !pct.contains_key(&rejected),
1097 "the component's veto was lost"
1098 );
1099 assert!(
1100 !top.contains(&rejected),
1101 "a rejected fragment cannot sit in the component's top-k"
1102 );
1103 assert!(pct.contains_key(&good));
1104 }
1105
1106 #[test]
1107 fn quantile_map_restores_the_reference_distribution_in_fused_order() {
1108 let reference = vec![0.01, 0.02, 0.05, 0.4, 1.9];
1110 let a = fid("a.rs", 1);
1111 let b = fid("b.rs", 1);
1112 let c = fid("c.rs", 1);
1113 let fused = scores(&[(a.clone(), 0.9), (b.clone(), 0.5), (c.clone(), 0.1)]);
1116
1117 let mapped = quantile_map_to(&reference, &fused);
1118
1119 assert_eq!(mapped[&a], 1.9, "the fused top must take the reference max");
1120 assert_eq!(
1121 mapped[&c], 0.01,
1122 "the fused bottom must take the reference min"
1123 );
1124 assert!(
1125 mapped[&a] > mapped[&b] && mapped[&b] > mapped[&c],
1126 "the fused order was not preserved"
1127 );
1128 }
1129
1130 #[test]
1131 fn quantile_map_over_the_same_population_is_the_identity_on_values() {
1132 let ids: Vec<FragmentId> = (0..5).map(|i| fid("f.rs", i + 1)).collect();
1136 let ego_scores = [0.02, 0.07, 0.11, 0.55, 0.9];
1137 let mut reference: Vec<f64> = ego_scores.to_vec();
1138 reference.sort_by(f64::total_cmp);
1139 let fused = scores(
1141 &ids.iter()
1142 .zip([0.2, 0.4, 0.6, 0.8, 1.0])
1143 .map(|(id, p)| (id.clone(), p))
1144 .collect::<Vec<_>>(),
1145 );
1146
1147 let mapped = quantile_map_to(&reference, &fused);
1148
1149 for (id, expected) in ids.iter().zip(ego_scores) {
1150 assert_eq!(
1151 mapped[id], expected,
1152 "same-population quantile map must reproduce the component's own values"
1153 );
1154 }
1155 }
1156
1157 #[test]
1158 fn a_strategy_is_created_for_every_scoring_kind() {
1159 for mode in [
1161 crate::mode::ScoringMode::Ego,
1162 crate::mode::ScoringMode::Ppr,
1163 crate::mode::ScoringMode::Bm25,
1164 crate::mode::ScoringMode::Rrf,
1165 ] {
1166 let config = PipelineConfig::from_mode(mode);
1167 let strategy = create_scoring_strategy(&config);
1168 let empty: Vec<Fragment> = Vec::new();
1169 let result = strategy.score_and_filter(
1170 &empty,
1171 &FxHashSet::default(),
1172 &[],
1173 None,
1174 None,
1175 None,
1176 crate::deadline::Deadline::none(),
1177 );
1178 assert!(
1179 result.filtered_fragments.is_empty(),
1180 "{mode:?} invented fragments from an empty universe"
1181 );
1182 }
1183 let _ = FragmentKind::Function;
1185 }
1186}