@@ -6,11 +6,12 @@ use rustc_hash::{FxHashMap, FxHashSet};
66
77use crate :: config:: bm25:: BM25 ;
88use crate :: config:: limits:: { LIMITS , PPR } ;
9- use crate :: config:: scoring:: EGO ;
9+ use crate :: config:: scoring:: { EGO , rrf } ;
1010use crate :: config:: tokenization:: TOKENIZATION ;
1111use crate :: edges;
1212use crate :: filtering;
1313use crate :: graph:: { self , Graph } ;
14+ use crate :: mode:: { PipelineConfig , ScoringKind } ;
1415use crate :: ppr:: personalized_pagerank;
1516use crate :: types:: { DiffHunk , Fragment , FragmentId , extract_identifier_list} ;
1617
@@ -30,6 +31,18 @@ pub struct ScoringResult {
3031 pub ppr_backward_pushes : usize ,
3132}
3233
34+ pub fn create_scoring_strategy ( config : & PipelineConfig ) -> Box < dyn ScoringStrategy > {
35+ match config. scoring {
36+ ScoringKind :: Ego => Box :: new ( EgoGraphScoring :: new ( config. ego_depth ) ) ,
37+ ScoringKind :: Ppr => Box :: new ( PPRScoring :: new (
38+ config. ppr_alpha ,
39+ config. low_relevance_filter ,
40+ ) ) ,
41+ ScoringKind :: Bm25 => Box :: new ( BM25Scoring ) ,
42+ ScoringKind :: Rrf => Box :: new ( RrfFusionScoring :: new ( config. ego_depth ) ) ,
43+ }
44+ }
45+
3346pub trait ScoringStrategy : Send + Sync {
3447 fn score_and_filter (
3548 & self ,
@@ -282,3 +295,136 @@ impl ScoringStrategy for BM25Scoring {
282295 }
283296 }
284297}
298+
299+ /// Reciprocal-rank fusion of the structural (EGO) and lexical (BM25) signals.
300+ ///
301+ /// The two are complementary rather than redundant: on genuine
302+ /// retrieval the lexical component alone outranks the deployed
303+ /// graph+lexical mixture, and their score-free union raises the reachable
304+ /// recall well above either — a miscalibrated-mixture signature. RRF
305+ /// fuses on ranks only, so neither component's score scale can dominate
306+ /// the other, which is exactly the failure the weighted mixture had.
307+ pub struct RrfFusionScoring {
308+ pub ego_depth : usize ,
309+ pub k : f64 ,
310+ }
311+
312+ impl RrfFusionScoring {
313+ pub fn new ( ego_depth : usize ) -> Self {
314+ Self {
315+ ego_depth,
316+ k : rrf ( ) . k ,
317+ }
318+ }
319+ }
320+
321+ fn rank_positions (
322+ rel : & FxHashMap < FragmentId , f64 > ,
323+ core_ids : & FxHashSet < FragmentId > ,
324+ ) -> FxHashMap < FragmentId , usize > {
325+ let mut ranked: Vec < ( & FragmentId , f64 ) > = rel
326+ . iter ( )
327+ . filter ( |( fid, score) | * * score > 0.0 && !core_ids. contains ( * fid) )
328+ . map ( |( fid, score) | ( fid, * score) )
329+ . collect ( ) ;
330+ // Ties broken by id so the rank list — and therefore every fused
331+ // score — is independent of hash-map iteration order.
332+ ranked. sort_by ( |( ida, sa) , ( idb, sb) | sb. total_cmp ( sa) . then_with ( || ida. cmp ( idb) ) ) ;
333+ ranked
334+ . into_iter ( )
335+ . enumerate ( )
336+ . map ( |( i, ( fid, _) ) | ( fid. clone ( ) , i + 1 ) )
337+ . collect ( )
338+ }
339+
340+ fn fuse_reciprocal_ranks (
341+ components : & [ & FxHashMap < FragmentId , f64 > ] ,
342+ core_ids : & FxHashSet < FragmentId > ,
343+ k : f64 ,
344+ ) -> FxHashMap < FragmentId , f64 > {
345+ let mut fused: FxHashMap < FragmentId , f64 > = FxHashMap :: default ( ) ;
346+ for rel in components {
347+ for ( fid, rank) in rank_positions ( rel, core_ids) {
348+ * fused. entry ( fid) . or_insert ( 0.0 ) += 1.0 / ( k + rank as f64 ) ;
349+ }
350+ }
351+
352+ let max_fused = fused. values ( ) . copied ( ) . fold ( 0.0f64 , f64:: max) ;
353+ if max_fused > 0.0 {
354+ for v in fused. values_mut ( ) {
355+ * v /= max_fused;
356+ }
357+ }
358+ // Cores anchor the top of the scale, matching every other strategy —
359+ // downstream `r_cap` normalisation and the absolute relevance gates
360+ // read these values, so the fused range has to stay [0, 1].
361+ for fid in core_ids {
362+ fused. insert ( fid. clone ( ) , 1.0 ) ;
363+ }
364+ fused
365+ }
366+
367+ impl ScoringStrategy for RrfFusionScoring {
368+ fn score_and_filter (
369+ & self ,
370+ all_fragments : & [ Fragment ] ,
371+ core_ids : & FxHashSet < FragmentId > ,
372+ hunks : & [ DiffHunk ] ,
373+ repo_root : Option < & Path > ,
374+ seed_weights : Option < & FxHashMap < FragmentId , f64 > > ,
375+ discovered_paths : Option < & FxHashSet < Arc < str > > > ,
376+ ) -> ScoringResult {
377+ let ego = EgoGraphScoring :: new ( self . ego_depth ) . score_and_filter (
378+ all_fragments,
379+ core_ids,
380+ hunks,
381+ repo_root,
382+ seed_weights,
383+ discovered_paths,
384+ ) ;
385+ let lexical = BM25Scoring . score_and_filter (
386+ all_fragments,
387+ core_ids,
388+ hunks,
389+ repo_root,
390+ seed_weights,
391+ discovered_paths,
392+ ) ;
393+
394+ let rel_scores =
395+ fuse_reciprocal_ranks ( & [ & ego. rel_scores , & lexical. rel_scores ] , core_ids, self . k ) ;
396+
397+ let mut union_ids: FxHashSet < FragmentId > = ego
398+ . filtered_fragments
399+ . iter ( )
400+ . map ( |f| f. id . clone ( ) )
401+ . collect ( ) ;
402+ union_ids. extend ( lexical. filtered_fragments . iter ( ) . map ( |f| f. id . clone ( ) ) ) ;
403+
404+ let union: Vec < Fragment > = all_fragments
405+ . iter ( )
406+ . filter ( |f| union_ids. contains ( & f. id ) )
407+ . cloned ( )
408+ . collect ( ) ;
409+
410+ // The union re-admits paths that EGO's structural guards dropped
411+ // (hub noise, generic-config-only code), because BM25 has no graph
412+ // to judge them by. Re-applying the guards keeps the fusion a
413+ // recall gain rather than a precision regression, and the per-file
414+ // cap has to be recomputed against the fused scores since each
415+ // component capped against its own.
416+ let filtered = filtering:: filter_unrelated_fragments ( & union, core_ids, & ego. graph ) ;
417+ let filtered = filtering:: filter_positive_relevance ( filtered, core_ids, & rel_scores) ;
418+ let filtered = filtering:: cap_context_fragments ( filtered, core_ids, & rel_scores) ;
419+
420+ ScoringResult {
421+ rel_scores,
422+ filtered_fragments : filtered,
423+ graph : ego. graph ,
424+ graph_build_ms : ego. graph_build_ms ,
425+ ppr_truncated : false ,
426+ ppr_forward_pushes : 0 ,
427+ ppr_backward_pushes : 0 ,
428+ }
429+ }
430+ }
0 commit comments