Skip to content

Commit fa5ecc3

Browse files
committed
feat: --scoring rrf, rank fusion of ego and bm25 (#125)
1 parent 33731da commit fa5ecc3

18 files changed

Lines changed: 216 additions & 37 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **`--scoring rrf`** — reciprocal-rank fusion of the structural (`ego`) and
13+
lexical (`bm25`) signals: each ranks the same candidate universe, and a
14+
fragment scores `Σ 1/(k + rank_i)` with `k=60` (`DIFFCTX_RRF_K`). Fusion on
15+
ranks alone removes the score-scale calibration between the two signals
16+
that made the deployed mixture rank below its own lexical component on
17+
genuine retrieval, and the candidate set becomes their union rather than
18+
either one alone. EGO's structural guards (hub-noise and
19+
generic-config-only suppression) and the per-file cap are re-applied to
20+
the union against the fused scores, so the wider candidate set does not
21+
leak the noise EGO already rejects. `ego` remains the default (#125).
1222
- **`--mode locate`** emits the ranked selection as compact
1323
`diffctx.locate.v1` JSON — path, line range, kind, symbol, relevance score
1424
and machine-readable provenance reasons (`changed`, `edge` with category /

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ the `--budget` token cap is hit.
7373

7474
| Flag | Default | Description |
7575
|-------------|---------|--------------------------------------------------------------------------|
76-
| `--scoring` | `ego` | `ego` = bounded expansion around changed nodes (fast, predictable radius); `ppr` = Personalized PageRank (global, smoother decay, slower); `bm25` = lexical retrieval against the diff hunks (baseline for sparse graphs) |
76+
| `--scoring` | `ego` | `ego` = bounded expansion around changed nodes (fast, predictable radius); `ppr` = Personalized PageRank (global, smoother decay, slower); `bm25` = lexical retrieval against the diff hunks (baseline for sparse graphs); `rrf` = reciprocal-rank fusion of `ego` and `bm25` (widest recall, no scale calibration between the two signals) |
7777
| `--budget` | auto | Hard cap in o200k_base tokens (see [Token counting](docs/product/token-budget.md)): `N` enforces a fixed cap, `-1` disables it, `0` is a strict-zero floor (empty selection; use `--full` for changed files only) |
7878
| `--alpha` | 0.60 | PPR damping; higher = context clusters tighter around changes (`--scoring ppr` only) |
7979
| `--tau` | 0.12 | Relevance threshold for full fragment content; lower-scoring fragments are stubbed or dropped (lower = more context) |

‎action.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ inputs:
3232
scoring:
3333
description: >-
3434
Scoring mode (`--scoring`): `ego` = bounded expansion around the changed
35-
nodes, `ppr` = Personalized PageRank, `bm25` = lexical retrieval.
35+
nodes, `ppr` = Personalized PageRank, `bm25` = lexical retrieval,
36+
`rrf` = reciprocal-rank fusion of `ego` and `bm25`.
3637
required: false
3738
default: ego
3839

‎crates/diffctx-native/src/config/scoring.rs‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use once_cell::sync::Lazy;
22

3-
use super::env_overrides::{read_env_fraction, read_env_open_fraction};
3+
use super::env_overrides::{read_env_f64, read_env_fraction, read_env_open_fraction};
44

55
pub struct EgoScoringConfig {
66
pub identifier_overlap_epsilon: f64,
@@ -19,3 +19,22 @@ impl Default for EgoScoringConfig {
1919
}
2020

2121
pub static EGO: Lazy<EgoScoringConfig> = Lazy::new(EgoScoringConfig::default);
22+
23+
pub struct RrfConfig {
24+
pub k: f64,
25+
}
26+
27+
impl Default for RrfConfig {
28+
fn default() -> Self {
29+
Self {
30+
// k=60 is the Cormack et al. 2009 constant; it damps the top of
31+
// each component list so a rank-1 hit in one signal cannot alone
32+
// outrank an item both signals agree on at ranks 2-3.
33+
k: read_env_f64("DIFFCTX_RRF_K", 60.0).max(1.0),
34+
}
35+
}
36+
}
37+
38+
pub fn rrf() -> RrfConfig {
39+
RrfConfig::default()
40+
}

‎crates/diffctx-native/src/main.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ struct Cli {
7474
full: bool,
7575

7676
/// Relevance scoring mode
77-
#[arg(long, default_value = "ego", value_parser = ["ppr", "ego", "bm25"])]
77+
#[arg(long, default_value = "ego", value_parser = ["ppr", "ego", "bm25", "rrf"])]
7878
scoring: String,
7979

8080
/// Output mode: `pack` = context with source bodies; `locate` = ranked

‎crates/diffctx-native/src/memory_pipeline.rs‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ use crate::config::limits::LIMITS;
1111
use crate::config::tokenization::TOKENIZATION;
1212
use crate::core::{compute_seed_weights, identify_core_fragments};
1313
use crate::edges;
14-
use crate::mode::{PipelineConfig, ScoringKind, ScoringMode};
14+
use crate::mode::{PipelineConfig, ScoringMode};
1515
use crate::parsers::fragment_file;
1616
use crate::render::{DiffContextOutput, build_diff_context_output};
17-
use crate::scoring::{BM25Scoring, EgoGraphScoring, PPRScoring, ScoringStrategy};
17+
use crate::scoring::create_scoring_strategy;
1818
use crate::signatures::generate_signature_variants;
1919
use crate::tokenizer::count_tokens;
2020
use crate::types::{DiffHunk, Fragment, FragmentId};
@@ -104,14 +104,7 @@ pub fn build_diff_context_in_memory(
104104
.map(|s| Arc::from(s.as_str()))
105105
.collect();
106106

107-
let strategy: Box<dyn ScoringStrategy> = match config.scoring {
108-
ScoringKind::Ego => Box::new(EgoGraphScoring::new(config.ego_depth)),
109-
ScoringKind::Ppr => Box::new(PPRScoring::new(
110-
config.ppr_alpha,
111-
config.low_relevance_filter,
112-
)),
113-
ScoringKind::Bm25 => Box::new(BM25Scoring),
114-
};
107+
let strategy = create_scoring_strategy(&config);
115108

116109
let scoring_result = strategy.score_and_filter(
117110
&all_fragments,

‎crates/diffctx-native/src/mode.rs‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub enum ScoringMode {
66
Ppr,
77
Ego,
88
Bm25,
9+
Rrf,
910
}
1011

1112
impl ScoringMode {
@@ -14,8 +15,9 @@ impl ScoringMode {
1415
"ppr" => Ok(Self::Ppr),
1516
"ego" => Ok(Self::Ego),
1617
"bm25" => Ok(Self::Bm25),
18+
"rrf" => Ok(Self::Rrf),
1719
other => Err(format!(
18-
"unknown scoring_mode '{other}': expected one of ppr|ego|bm25"
20+
"unknown scoring_mode '{other}': expected one of ppr|ego|bm25|rrf"
1921
)),
2022
}
2123
}
@@ -32,6 +34,7 @@ pub enum ScoringKind {
3234
Ppr,
3335
Ego,
3436
Bm25,
37+
Rrf,
3538
}
3639

3740
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -91,6 +94,18 @@ impl PipelineConfig {
9194
ppr_alpha: PPR.alpha,
9295
objective: ObjectiveMode::Submodular,
9396
},
97+
// Discovery is deliberately identical to EGO's: the fusion gain
98+
// must come from ranking the same universe better, not from a
99+
// wider universe that would confound it with a discovery change.
100+
ScoringMode::Rrf => Self {
101+
discovery: DiscoveryKind::Ensemble,
102+
scoring: ScoringKind::Rrf,
103+
low_relevance_filter: false,
104+
bm25_top_k: m.bm25_top_k_primary,
105+
ego_depth: m.ego_depth_extended,
106+
ppr_alpha: PPR.alpha,
107+
objective: ObjectiveMode::Submodular,
108+
},
94109
}
95110
}
96111
}

‎crates/diffctx-native/src/pipeline.rs‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ use crate::discovery::{
1818
};
1919
use crate::fragmentation::process_files_for_fragments;
2020
use crate::git::{self, CatFileBatch};
21-
use crate::mode::{DiscoveryKind, PipelineConfig, ScoringKind, ScoringMode};
21+
use crate::mode::{DiscoveryKind, PipelineConfig, ScoringMode};
2222
use crate::postpass;
2323
use crate::render::{self, DiffContextOutput};
24-
use crate::scoring::{BM25Scoring, EgoGraphScoring, PPRScoring, ScoringResult, ScoringStrategy};
24+
use crate::scoring::{ScoringResult, create_scoring_strategy};
2525
use crate::signatures::generate_signature_variants;
2626
use crate::tokenizer::count_tokens;
2727
use crate::types::{Fragment, FragmentId};
@@ -337,14 +337,7 @@ pub fn compute_scored_state(
337337
.map(|p| Arc::from(p.to_string_lossy().as_ref()))
338338
.collect();
339339

340-
let strategy: Box<dyn ScoringStrategy> = match config.scoring {
341-
ScoringKind::Ego => Box::new(EgoGraphScoring::new(config.ego_depth)),
342-
ScoringKind::Ppr => Box::new(PPRScoring::new(
343-
config.ppr_alpha,
344-
config.low_relevance_filter,
345-
)),
346-
ScoringKind::Bm25 => Box::new(BM25Scoring),
347-
};
340+
let strategy = create_scoring_strategy(&config);
348341

349342
let scoring_result = strategy.score_and_filter(
350343
&all_fragments,

‎crates/diffctx-native/src/scoring.rs‎

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ use rustc_hash::{FxHashMap, FxHashSet};
66

77
use crate::config::bm25::BM25;
88
use crate::config::limits::{LIMITS, PPR};
9-
use crate::config::scoring::EGO;
9+
use crate::config::scoring::{EGO, rrf};
1010
use crate::config::tokenization::TOKENIZATION;
1111
use crate::edges;
1212
use crate::filtering;
1313
use crate::graph::{self, Graph};
14+
use crate::mode::{PipelineConfig, ScoringKind};
1415
use crate::ppr::personalized_pagerank;
1516
use 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+
3346
pub 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+
}

‎crates/diffctx-native/tests/yaml_cases.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,11 +475,11 @@ fn main() {
475475
})
476476
.collect();
477477

478-
// Every oracle case above runs Ego. `--scoring ppr` and `--scoring bm25` are
478+
// Every oracle case above runs Ego. `--scoring ppr`, `bm25` and `rrf` are
479479
// documented, user-selectable modes whose only other coverage asserts an
480-
// exit code, so either could degrade to changed-files-only and ship green.
480+
// exit code, so any of them could degrade to changed-files-only and ship green.
481481
for c in cases.iter().filter(|c| c.name.starts_with("selection/")) {
482-
for scoring in [ScoringMode::Ppr, ScoringMode::Bm25] {
482+
for scoring in [ScoringMode::Ppr, ScoringMode::Bm25, ScoringMode::Rrf] {
483483
let (name, path) = (c.name.clone(), c.path.clone());
484484
let label = format!("scoring/{scoring:?}/{}", c.name).to_lowercase();
485485
trials.push(

0 commit comments

Comments
 (0)