Skip to main content

_diffctx/
scoring.rs

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
19/// Per-file naming admission (#65) is the default since the v5 cycle:
20/// screening (12-cell grid), calibration + held-out validation, and the
21/// confirmation sweep all passed the pre-registered criteria. Opt out with
22/// DIFFCTX_FILE_ADMISSION=0.
23pub(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    /// Files openable by the greedy under per-file admission (#65): reachable
31    /// from the core set via naming-class edges. None = admission off (flag
32    /// unset or a strategy without a typed graph), every file admissible.
33    pub admissible_files: Option<FxHashSet<std::sync::Arc<str>>>,
34    pub graph: Graph,
35    /// Wall time spent constructing the typed dependency graph (edge
36    /// builders + dedup + hub suppression + per-source cap). Reported
37    /// separately so `scoring_ms` stays pure rank computation. Zero for
38    /// BM25 (no graph built).
39    pub graph_build_ms: f64,
40    /// PPR push-iteration was cut by `max_pushes_cap` before convergence.
41    /// Always false for non-PPR strategies (EGO/BM25).
42    pub ppr_truncated: bool,
43    pub ppr_forward_pushes: usize,
44    pub ppr_backward_pushes: usize,
45}
46
47/// The guard chain every graph-backed strategy ends with: drop what the graph
48/// says is unrelated, drop what scored zero, then cap per file.
49///
50/// EGO, PPR and RRF each spelled this out. BM25 spelled out its own copy of the
51/// middle step instead of calling it — semantically the same today, and exactly
52/// the shape that let the two test-file classifiers drift apart (#182).
53fn 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        // Deliberately NOT `finish_scoring`: there is no graph here, so the
323        // structural guard has nothing to judge with. The other two steps are
324        // the shared ones rather than a local re-spelling of the same predicate.
325        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
338/// Reciprocal-rank fusion of the structural (EGO) and lexical (BM25) signals.
339///
340/// The two are complementary rather than redundant: on genuine
341/// retrieval the lexical component alone outranks the deployed
342/// graph+lexical mixture, and their score-free union raises the reachable
343/// recall well above either — a miscalibrated-mixture signature. RRF
344/// fuses on ranks only, so neither component's score scale can dominate
345/// the other, which is exactly the failure the weighted mixture had.
346pub 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
360/// A component's ballot: the fragments it admitted, ordered by its own score.
361///
362/// `admitted` is the component's `filtered_fragments`, not its whole score map.
363/// Rank fusion is defined over the result lists retrievers return, and each
364/// component's guards are the only place an *absolute* judgement survives —
365/// a rank cannot express "this scored near zero". Ranking the full score map
366/// instead lets a component vote for fragments its own filters rejected, and
367/// reciprocal rank then promotes that tail: BM25 scores anything sharing a
368/// generic token, so garbage landed at a respectable rank and earned real
369/// fused mass. Measured on the oracle corpus, that cost 97 cases against EGO
370/// on precision (`forbidden_rate >= 90%` on 91 of them) while recall held.
371fn 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    // Ties broken by id so the rank list — and therefore every fused
382    // score — is independent of hash-map iteration order.
383    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    // Cores sit at the top of the scale, matching every other strategy —
410    // downstream `r_cap` normalisation and the absolute relevance gates read
411    // these values, so the fused range has to stay [0, 1]. Note they do not
412    // strictly dominate: max-normalisation already put the best non-core at
413    // exactly 1.0, so it ties with the cores rather than sitting below them.
414    // `r_cap` excludes cores when it computes its spread, so the tie is benign
415    // there — but do not read this as a guarantee that cores rank first.
416    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        // The union re-admits paths that EGO's structural guards dropped
482        // (hub noise, generic-config-only code), because BM25 has no graph
483        // to judge them by. The guards are re-applied and the per-file cap
484        // recomputed against the fused scores, since each component capped
485        // against its own.
486        //
487        // Measured caveat, not a claim of soundness: re-applying them does NOT
488        // make the union a net win. On the oracle corpus RRF loses 97 cases to
489        // EGO and gains 18, all on precision, and restricting candidates to
490        // EGO's admitted set recovers only 28 of the 82 (#125).
491        //
492        // A second, unmeasured degree of freedom lives here: each component
493        // already applied `cap_context_fragments` (30/file) against its own
494        // scores before voting, so a fragment the fused ranking would have kept
495        // can have been capped away before it ever reached the ballot. The cap
496        // is per file and the losses are cross-file, so this is unlikely to be
497        // the 97 — but it has not been isolated.
498        let filtered = finish_scoring(&union, core_ids, &rel_scores, &ego.graph);
499
500        ScoringResult {
501            // The fusion inherits the ego component's naming-reachability set:
502            // admission is a graph property, and the fusion's structural half
503            // IS that graph. Leaving this None silently ran fusion arms
504            // without the gate.
505            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
515/// Percentile fusion: the same two signals as RRF, blended on their empirical
516/// distribution position rather than on rank alone.
517///
518/// RRF converts each component to a pure rank, which throws away the magnitude
519/// that says "this scored near zero". Measured on the oracle corpus that costs
520/// 97 cases against EGO and wins 18, all on precision: BM25 gives any
521/// generic-token match a small positive score, and `1/(k + rank)` promotes that
522/// noise to real fused mass (#125).
523///
524/// The probability-integral transform keeps the position. A fragment in the 5th
525/// percentile of a component contributes 0.05 from it, not `1/(k + 12)`. Two
526/// signals that disagree therefore cannot manufacture a strong candidate out of
527/// two weak opinions, which is precisely what the rank form allowed.
528///
529/// `score = blend * PIT(ego) + (1 - blend) * PIT(bm25) + bonus * [both in top-k]`
530///
531/// The agreement term is what fusion is actually for — a fragment both signals
532/// rank highly is more trustworthy than either alone — and it is additive and
533/// small so it breaks ties rather than deciding the ranking.
534pub 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
553/// Empirical-CDF position in `[0, 1]` for every admitted, positively-scored
554/// fragment, plus the set that sits in the component's own top-k.
555///
556/// Ties share a percentile: two fragments a component cannot separate must not
557/// be separated here either, or the blend would invent a preference the signal
558/// never expressed.
559///
560/// The CDF is estimated over everything the component scored positively, while
561/// only the fragments it *admitted* receive a value. Those are two different
562/// questions and conflating them was a defect: a percentile read off a
563/// component's admitted set is a position within that set, and the two
564/// components' admitted sets differ by an order of magnitude on a real repo
565/// (BM25 admits a handful, EGO hundreds). Blending a position-among-6 with a
566/// position-among-300 as if they were the same quantity is not a fusion of the
567/// two signals. The admission veto itself is kept — a fragment a component
568/// rejected still contributes nothing from it (#125, `091c4db3`) — because the
569/// component's own guards are the only place an absolute judgement survives.
570fn 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    // Descending by score, id as the tie-break so the traversal is independent
582    // of hash-map iteration order.
583    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    // Ablation (not a shipped mode): `DIFFCTX_PIT_TRANSFORM=maxnorm` fuses the
593    // components on their own score shape, rescaled to a common [0, 1], instead
594    // of on distributional position. It is the control that isolates the
595    // transform, because a linear rescale leaves every downstream
596    // magnitude-reading rule invariant: `r_cap = median + sigma*std` scales with
597    // the data, so `rel / r_cap` is unchanged. The percentile does not have that
598    // property, which is the whole point of comparing them.
599    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            // One run of equal scores shares the mean percentile of the run.
614            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            // Position 0 is the strongest, so invert: the best fragment gets ~1.0.
619            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    // Top-k is drawn from the admitted fragments: the agreement bonus asks
631    // "do both components rank this highly", and a fragment a component
632    // rejected is not ranked highly by it.
633    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
643/// Map a fused ranking back onto the reference component's own score
644/// distribution, preserving order.
645///
646/// Selection does not read `rel` as an ordering alone. `compute_r_cap` takes
647/// `median + sigma*std` of the score cloud and the utility uses
648/// `(rel / r_cap).min(1.0)`, so the *shape* of the distribution decides how many
649/// candidates saturate. EGO's raw scores are strongly right-skewed (hop decay
650/// puts most mass near zero), which makes `r_cap` small and the saturation
651/// meaningful. A percentile is uniform on [0, 1] by construction: its median is
652/// ~0.5 and `median + 2*std` lands above the maximum, so nothing saturates and
653/// the selector runs in a regime nothing was calibrated for.
654///
655/// That is a unit mismatch, not a tuning problem, so the fix is to restore the
656/// units rather than to re-tune `r_cap_sigma` and `tau` per transform. Fusion
657/// then decides the order — which is what fusion is for — and the selector sees
658/// the score cloud it was built against.
659///
660/// A consequence worth stating because it doubles as the correctness gate: at
661/// `blend = 1.0` with no agreement bonus the fused order is EGO's order over
662/// EGO's own admitted set, so this maps every fragment back to its exact EGO
663/// score and the mode must reproduce EGO bit-for-bit.
664fn quantile_map_to(
665    reference: &[f64],
666    fused: &FxHashMap<FragmentId, f64>,
667) -> FxHashMap<FragmentId, f64> {
668    // A fused score of zero means no component endorsed the fragment.
669    // Mapping it anyway would hand it a positive reference value and resurrect
670    // a candidate `filter_positive_relevance` is required to drop — at
671    // blend=1.0 that alone broke the EGO-equivalence gate (390 vs 371).
672    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        // A run of equal fused scores is a tie the fusion never resolved, so
688        // the run shares one mapped value — its midpoint slot — rather than
689        // being fanned across adjacent reference values by id order.
690        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            // A fragment only one component admitted contributes 0 from the
769            // other — that is the point. Under RRF an absent component was
770            // simply silent; here it is an explicit "this signal ranks you at
771            // the bottom", which is what stops one weak opinion carrying a
772            // fragment.
773            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        // `DIFFCTX_PIT_SHAPE=flat` keeps the fused percentiles as the scores the
783        // selector sees. That is the pre-`quantile_map_to` behaviour, retained
784        // so the transform's cost stays measurable rather than only argued.
785        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            // Cores keep EGO's own values rather than a pinned 1.0. `r_cap`
808            // excludes cores, but the utility does not, and a synthetic 1.0 on
809            // EGO's raw scale is a different number from the one EGO assigns.
810            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            // The fusion inherits the ego component's naming-reachability set:
828            // admission is a graph property, and the fusion's structural half
829            // IS that graph. Leaving this None silently ran fusion arms
830            // without the gate.
831            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    /// Every scored fragment counts as admitted, which is the pre-#125-fix
855    /// behaviour these properties were written against. Tests that care about
856    /// the admission gate itself build the ballot explicitly.
857    fn ballot(rel: &FxHashMap<FragmentId, f64>) -> FxHashSet<FragmentId> {
858        rel.keys().cloned().collect()
859    }
860
861    /// The property RRF exists for, and the reason `k` damps the top of each
862    /// list: agreement between the two signals beats a single signal's first
863    /// place. A weighted mixture cannot express this without calibrating the two
864    /// score scales against each other — which is the failure RRF replaces.
865    #[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        // `ego_only` is rank 1 in ego and absent from bm25; `agreed` is only
872        // rank 2 in each, but present in both.
873        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    /// Only ranks may cross between the components. If a raw score leaked in,
890    /// one signal's scale could dominate the other — the miscalibrated-mixture
891    /// behaviour the mode was added to avoid.
892    #[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    /// Downstream `r_cap` normalisation and the absolute relevance gates read
921    /// these values, so the fused range has to stay within [0, 1] with the cores
922    /// at the top.
923    #[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    /// Cores are ranked separately (they are always placed first), so letting
946    /// them consume rank slots would push every context fragment down and change
947    /// the fused scores for reasons unrelated to relevance.
948    #[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    /// Hash-map iteration order must not reach the fused scores: equal
965    /// component scores are ranked by fragment id.
966    #[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        // Ascending id order is the tie-break, so ranks follow the sorted ids.
980        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    /// A zero or negative component score is "not a candidate", not "ranked
988    /// last": including it would hand it a reciprocal-rank contribution.
989    #[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    /// `DIFFCTX_RRF_K` is a documented knob; a larger k flattens the reciprocal
1011    /// curve, which is what makes agreement outweigh a single top hit.
1012    #[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        // Both are max-normalised, so compare the runner-up's share of the top.
1023        assert!(
1024            flat[&second] > sharp[&second],
1025            "k did not flatten adjacent ranks: {} vs {}",
1026            flat[&second],
1027            sharp[&second]
1028        );
1029    }
1030
1031    /// The gate that ranks cannot express. A component scores far more
1032    /// fragments than it admits — BM25 gives anything sharing a generic token a
1033    /// small positive score — and a rank list is purely ordinal, so the moment a
1034    /// rejected fragment appears in it the reciprocal rank hands it real fused
1035    /// mass. Fusing whole score maps instead of the returned result lists cost
1036    /// 97 oracle cases on precision.
1037    #[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        // The rejected fragment outscores the admitted one, so if it is ranked
1043        // at all it takes rank 1 and the top of the normalised scale with it.
1044        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        // Nine strong fragments the component scored but did not admit, plus
1064        // one weak admitted straggler. Its percentile must say "bottom of the
1065        // component's world", not "top of the admitted set of one".
1066        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        // Reference: a skewed cloud like EGO's (mass near zero).
1109        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        // Fused scores are uniform-ish percentiles; only their order may
1114        // survive the mapping.
1115        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        // blend=1.0, bonus=0: the fused order IS ego's order over ego's own
1133        // admitted set, so mapping back onto ego's sorted scores must return
1134        // exactly those scores — the property the corpus gate checks end to end.
1135        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        // Fused percentiles in the same order as the ego scores.
1140        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        // A new mode that forgets its arm here would silently score as another.
1160        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        // Keeps `FragmentKind` in scope for the helper above.
1184        let _ = FragmentKind::Function;
1185    }
1186}