From 1306bf622ad4d9aa2e2efe52c2b0aef75956ca00 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 18:50:46 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(arigraph):=20RRF=20fusion=20primitive?= =?UTF-8?q?=20(D-GR-2a)=20=E2=80=94=20the=20retrieval=20keystone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reciprocal Rank Fusion (Cormack, Clarke & Büttcher 2009): fuse N ranked lists into one ranking by Σ 1/(k+rank), k=60. Named as the D-GR-2 retrieval keystone in the representations inventory (#723) — every ranked leg exists (Bm25Index::rank, PersonalizedPageRank::ranked, CAM-PQ) but nothing fused them. - `arigraph/rrf.rs`: `reciprocal_rank_fusion(&[&[ScoredId]], k) -> Vec` + `DEFAULT_RRF_K = 60`. Fuses by RANK, so the per-list scores need not be commensurable (the reason it combines BM25 f64 / PPR probability / CAM-PQ i8). Deterministic (BTreeMap id-asc + stable score-desc sort); shallowest depth wins; returns the contract `ScoredId`. - Re-exported from `arigraph/mod.rs`. Pure, reversible capability landed ahead of G0 (like Bm25Index / PersonalizedPageRank / Communities). The WIRING into OsintRetriever::retrieve stays gated on the G0 load-bearing verdict. Tests: 7 unit + 1 doctest green; clippy clean (`-p lance-graph --lib`). Board: STATUS_BOARD D-GR-2a + AGENT_LOG. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016b33swuXE23hKtqxsHu9p1 --- .claude/board/AGENT_LOG.md | 7 + .claude/board/STATUS_BOARD.md | 1 + crates/lance-graph/src/graph/arigraph/mod.rs | 2 + crates/lance-graph/src/graph/arigraph/rrf.rs | 180 +++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 crates/lance-graph/src/graph/arigraph/rrf.rs diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index 9a72b347..d7e6f944 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -1,3 +1,10 @@ +## 2026-07-18 — RRF fusion primitive (D-GR-2a) — the retrieval keystone, pure capability ahead of G0 — main thread + +- **Task:** the inventory (#723) named RRF fusion as the D-GR-2 retrieval keystone — every ranked leg exists (`Bm25Index::rank`, `PersonalizedPageRank::ranked`, CAM-PQ) but nothing fused them. Landed the pure fusion primitive (the SAP "Practical GraphRAG" gap), ahead of G0 like `Bm25Index`/`PersonalizedPageRank`/`Communities`. +- **Change:** `arigraph/rrf.rs` — `reciprocal_rank_fusion(ranked_lists: &[&[ScoredId]], k) -> Vec` (Cormack 2009; `Σ 1/(k+rank)`, 1-based, k=60 `DEFAULT_RRF_K`). Fuses by RANK so the per-list scores need not be commensurable (the reason it combines BM25 f64 / PPR probability / CAM-PQ i8). Deterministic (BTreeMap id-asc + stable score-desc sort); shallowest `depth` wins; returns the contract `ScoredId`. Re-exported in `arigraph/mod.rs`. +- **Pure/reversible:** computes a fused ranking, reads no carrier state. The WIRING into `OsintRetriever::retrieve` stays GATED on the G0 verdict (per plan §5 + STATUS_BOARD D-GR-2). +- **Commit / Tests / Outcome:** commit ``; `cargo test -p lance-graph --lib -- graph::arigraph::rrf` 7/7 + doctest 1/1 green; clippy scoped `-p lance-graph --lib` clean on the addition (the 8 warnings are pre-existing `blasgraph/ndarray_bridge.rs` SIMD dead-code). Branch `claude/happy-hamilton-0azlw4` (restarted from post-#723 main). + ## 2026-07-18 — GraphRAG representations inventory — 7 papers × V3 substrate matrix (6 Opus paper-readers + 1 Opus v3-harvest; main-thread synthesis) - **Task:** operator asked for an inventory of 7 papers (6 arXiv + MDPI), formulate 8 representations plus the v3 "should-have-built" set, and answer a matrix per representation (format / witness-ref / witness / context / basins / vertical-horizontal-vs-edges / time / NARS / causality-trajectory-candidate / wire) + "which probe considered all tenants in every SoA." diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index c20df613..95643da5 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -9,6 +9,7 @@ Plan: `.claude/plans/graphrag-doc-retrieval-soa-integration-v1.md` (v1.2). Pure/ | D-GR-3b | PPR (`personalized_pagerank`) + Leiden `refine_connected` + BM25 (`Bm25Index`) — pure capabilities | lance-graph | Shipped (#716) — 13 tests | plan §3b, §5 | | G0 | P-GRAPH-LOADBEARING harness (vector-only vs vector+PPR+community) | lance-graph | Harness shipped (#716); real-corpus verdict OPEN | plan §5, §6 | | D-GR-2 | Fuse CAM-PQ+SPO-G+PPR+community into `retrieval.rs` under the #708 RungElevator | lance-graph | Design done (in `doc_graph.rs` module-doc); impl GATED on G0 | plan §5 | +| D-GR-2a | RRF fusion primitive (`reciprocal_rank_fusion`, Cormack 2009) — the fusion algebra D-GR-2 needs; pure, ahead of G0 | lance-graph | Shipped — `arigraph/rrf.rs`, 7 tests + doctest | plan §5 | | D-GR-4 | Community summaries (no-LLM DeepNSM; Rig-oracle tail) | lance-graph | Deferred (W3-coupled) | plan §5 | | D-GR-5 | `ogar-doc` reconstruct/related-docs → `DocGraphQuery` seam | lance-graph + OGAR | Deferred (mint-gated, doc-W4 council) | plan §5 | | D-GR-6 | Witness-KV separation (DocumentID handle → consumer KV) | lance-graph | Deferred (doc-W4 council) | plan §4a, §5 | diff --git a/crates/lance-graph/src/graph/arigraph/mod.rs b/crates/lance-graph/src/graph/arigraph/mod.rs index 6af26b86..90991543 100644 --- a/crates/lance-graph/src/graph/arigraph/mod.rs +++ b/crates/lance-graph/src/graph/arigraph/mod.rs @@ -13,6 +13,7 @@ pub mod markov_soa; pub mod orchestrator; pub mod ppr; pub mod retrieval; +pub mod rrf; pub mod sensorium; pub mod spo_bridge; pub mod triplet_graph; @@ -23,6 +24,7 @@ pub use bm25::Bm25Index; pub use community::Communities; pub use episodic::EpisodicBasins; pub use ppr::PersonalizedPageRank; +pub use rrf::{reciprocal_rank_fusion, DEFAULT_RRF_K}; pub use witness_corpus::{WitnessCorpus, WitnessEntry, WitnessId, WitnessIndexHashMap}; #[cfg(feature = "with-cam-pq")] diff --git a/crates/lance-graph/src/graph/arigraph/rrf.rs b/crates/lance-graph/src/graph/arigraph/rrf.rs new file mode 100644 index 00000000..d0b0a123 --- /dev/null +++ b/crates/lance-graph/src/graph/arigraph/rrf.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! `rrf` — Reciprocal Rank Fusion (Cormack, Clarke & Büttcher, SIGIR 2009). +//! +//! Fuses N independently-ranked result lists into one ranking, scoring each id +//! by `Σ_lists 1 / (k + rank)` where `rank` is the **1-based position** of the +//! id in that list. RRF fuses by *rank position*, never by the source scores — +//! which is exactly why it combines lists whose scores are **not +//! commensurable**: [`Bm25Index::rank`](super::bm25::Bm25Index::rank) (tf-idf +//! `f64`), [`PersonalizedPageRank::ranked`](super::ppr::PersonalizedPageRank) +//! (unit-sum probability), and CAM-PQ (`i8` distance) share no scale, yet their +//! rank orders fuse cleanly. `k` (default [`DEFAULT_RRF_K`] = 60, the paper's +//! constant) damps deep-ranked items so a strong agreement near the top of +//! several lists dominates a lone top-1 in one list. +//! +//! This is the fusion primitive named as the D-GR-2 retrieval **keystone** in +//! `.claude/knowledge/graphrag-representations-inventory.md` (the SAP "Practical +//! GraphRAG" reader's headline gap: every ranked leg exists — +//! `Bm25Index`/`PersonalizedPageRank`/CAM-PQ — but nothing fused them). It is a +//! **pure, reversible** capability: it computes a fused ranking and reads no +//! carrier state. Wiring it into `OsintRetriever::retrieve` (so the retriever +//! actually fuses its legs) stays gated on the G0 load-bearing verdict — this +//! module only lands the algorithm, ahead of that gate, exactly as +//! `Bm25Index`/`PersonalizedPageRank`/`Communities` landed as pure capabilities. + +use std::collections::BTreeMap; + +use lance_graph_contract::doc_graph::ScoredId; + +/// The paper's default rank-fusion constant (`k = 60`). +pub const DEFAULT_RRF_K: f64 = 60.0; + +/// Fuse `ranked_lists` — each already ordered **best-first** — into one ranking +/// by Reciprocal Rank Fusion. +/// +/// Each id's fused score is `Σ_lists 1 / (k + rank)` with `rank` 1-based. The +/// returned [`ScoredId`]s are sorted by fused score **descending**, ties broken +/// by id **ascending** (deterministic). `depth` carries the *shallowest* depth +/// the id appeared at across the inputs (strongest provenance wins); an id +/// absent from every list simply does not appear. +/// +/// Because fusion is by RANK, the per-list [`ScoredId::score`] values need not +/// be commensurable — the reason RRF can combine the BM25 / PPR / CAM-PQ legs, +/// whose scores live on unrelated scales. +/// +/// `k` should be positive; the paper uses `60` ([`DEFAULT_RRF_K`]). An empty +/// `ranked_lists` (or all-empty lists) yields an empty result. +/// +/// # Examples +/// ``` +/// use lance_graph::graph::arigraph::rrf::{reciprocal_rank_fusion, DEFAULT_RRF_K}; +/// use lance_graph_contract::doc_graph::ScoredId; +/// +/// // `x` is rank-1 in BOTH lists; `y`/`z` are rank-2 in only one each. +/// let list_a = [ScoredId::new("x", 1.0, 0), ScoredId::new("y", 1.0, 0)]; +/// let list_b = [ScoredId::new("x", 1.0, 0), ScoredId::new("z", 1.0, 0)]; +/// let fused = reciprocal_rank_fusion(&[&list_a, &list_b], DEFAULT_RRF_K); +/// +/// assert_eq!(fused.len(), 3); +/// assert_eq!(fused[0].id, "x"); // consensus at the top wins +/// ``` +#[must_use] +pub fn reciprocal_rank_fusion(ranked_lists: &[&[ScoredId]], k: f64) -> Vec { + // id -> (accumulated RRF score, shallowest depth seen). BTreeMap gives a + // deterministic id-ascending iteration order, which the stable sort below + // preserves within equal fused scores. + let mut acc: BTreeMap<&str, (f64, u8)> = BTreeMap::new(); + for list in ranked_lists { + for (pos, item) in list.iter().enumerate() { + let rank = pos as f64 + 1.0; // 1-based + let entry = acc.entry(item.id.as_str()).or_insert((0.0, u8::MAX)); + entry.0 += 1.0 / (k + rank); + entry.1 = entry.1.min(item.depth); + } + } + let mut fused: Vec = acc + .into_iter() + .map(|(id, (score, depth))| { + ScoredId::new(id, score as f32, if depth == u8::MAX { 0 } else { depth }) + }) + .collect(); + // Fused score descending; `sort_by` is stable, so the BTreeMap's id-ascending + // order breaks ties deterministically. + fused.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + }); + fused +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(v: &[ScoredId]) -> Vec<&str> { + v.iter().map(|s| s.id.as_str()).collect() + } + + #[test] + fn consensus_near_top_beats_lone_top_one() { + // `x` rank-1 in both; `y`,`z` rank-2 in one each. + let a = [ScoredId::new("x", 1.0, 0), ScoredId::new("y", 1.0, 0)]; + let b = [ScoredId::new("x", 1.0, 0), ScoredId::new("z", 1.0, 0)]; + let fused = reciprocal_rank_fusion(&[&a, &b], DEFAULT_RRF_K); + assert_eq!(fused.len(), 3); + assert_eq!(fused[0].id, "x"); + } + + #[test] + fn fuses_incommensurable_scores_by_rank_only() { + // Wildly different score scales; only the RANK order matters. + let bm25 = [ScoredId::new("a", 9000.0, 0), ScoredId::new("b", 4000.0, 0)]; + let ppr = [ScoredId::new("b", 0.51, 1), ScoredId::new("a", 0.49, 2)]; + let fused = reciprocal_rank_fusion(&[&bm25, &ppr], DEFAULT_RRF_K); + // a: 1/61 + 1/62 ; b: 1/62 + 1/61 — equal → deterministic id-asc order. + assert_eq!(ids(&fused), ["a", "b"]); + // score is symmetric, not dominated by bm25's huge raw magnitudes. + assert!((fused[0].score - fused[1].score).abs() < 1e-6); + } + + #[test] + fn shallowest_depth_wins() { + let a = [ScoredId::new("a", 1.0, 3)]; + let b = [ScoredId::new("a", 1.0, 1)]; + let fused = reciprocal_rank_fusion(&[&a, &b], DEFAULT_RRF_K); + assert_eq!(fused.len(), 1); + assert_eq!(fused[0].depth, 1); // min(3, 1) + } + + #[test] + fn rank_position_dominates_within_one_list() { + let only = [ + ScoredId::new("first", 0.1, 0), + ScoredId::new("second", 0.1, 0), + ScoredId::new("third", 0.1, 0), + ]; + let fused = reciprocal_rank_fusion(&[&only], DEFAULT_RRF_K); + assert_eq!(ids(&fused), ["first", "second", "third"]); + // strictly decreasing: 1/61 > 1/62 > 1/63 + assert!(fused[0].score > fused[1].score && fused[1].score > fused[2].score); + } + + #[test] + fn smaller_k_sharpens_top_rank_advantage() { + // A rank-1 hit vs a rank-10 hit: smaller k widens their score ratio. + let mk = |k: f64| { + let a = [ScoredId::new("top", 1.0, 0)]; + let mut deep: Vec = + (0..10).map(|i| ScoredId::new(format!("d{i}"), 1.0, 0)).collect(); + deep[9] = ScoredId::new("low", 1.0, 0); // "low" at rank 10 + let f = reciprocal_rank_fusion(&[&a, &deep], k); + let top = f.iter().find(|s| s.id == "top").unwrap().score; + let low = f.iter().find(|s| s.id == "low").unwrap().score; + top / low + }; + assert!(mk(10.0) > mk(60.0)); // smaller k → bigger top-vs-deep ratio + } + + #[test] + fn empty_inputs_are_safe() { + assert!(reciprocal_rank_fusion(&[], DEFAULT_RRF_K).is_empty()); + let empty: [ScoredId; 0] = []; + assert!(reciprocal_rank_fusion(&[&empty, &empty], DEFAULT_RRF_K).is_empty()); + } + + #[test] + fn deterministic() { + let a = [ScoredId::new("a", 1.0, 0), ScoredId::new("b", 1.0, 0)]; + let b = [ScoredId::new("c", 1.0, 0), ScoredId::new("a", 1.0, 0)]; + let x = reciprocal_rank_fusion(&[&a, &b], DEFAULT_RRF_K); + let y = reciprocal_rank_fusion(&[&a, &b], DEFAULT_RRF_K); + assert_eq!(ids(&x), ids(&y)); + assert_eq!( + x.iter().map(|s| s.score).collect::>(), + y.iter().map(|s| s.score).collect::>() + ); + } +} From 2c87c04ce4ba5650e595e0be1986f58a9c676f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 18:57:24 +0000 Subject: [PATCH 2/3] =?UTF-8?q?style(arigraph):=20rustfmt=20rrf.rs=20test?= =?UTF-8?q?=20=E2=80=94=20wrap=20chained=20iterator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI `format` check: rustfmt wants the `(0..10).map(...).collect()` in the smaller_k test wrapped across lines. No logic change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016b33swuXE23hKtqxsHu9p1 --- crates/lance-graph/src/graph/arigraph/rrf.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/lance-graph/src/graph/arigraph/rrf.rs b/crates/lance-graph/src/graph/arigraph/rrf.rs index d0b0a123..09079bc9 100644 --- a/crates/lance-graph/src/graph/arigraph/rrf.rs +++ b/crates/lance-graph/src/graph/arigraph/rrf.rs @@ -147,8 +147,9 @@ mod tests { // A rank-1 hit vs a rank-10 hit: smaller k widens their score ratio. let mk = |k: f64| { let a = [ScoredId::new("top", 1.0, 0)]; - let mut deep: Vec = - (0..10).map(|i| ScoredId::new(format!("d{i}"), 1.0, 0)).collect(); + let mut deep: Vec = (0..10) + .map(|i| ScoredId::new(format!("d{i}"), 1.0, 0)) + .collect(); deep[9] = ScoredId::new("low", 1.0, 0); // "low" at rank 10 let f = reciprocal_rank_fusion(&[&a, &deep], k); let top = f.iter().find(|s| s.id == "top").unwrap().score; From 595a60713afb28288dc3a79dbd8a49aa08801dcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 19:10:01 +0000 Subject: [PATCH 3/3] fix(arigraph): RRF gives each leg one vote per id at its best rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 review on #724: the accumulation loop credited every occurrence of an id within a single ranked list with another 1/(k+rank). A leg that surfaces the same entity more than once (several relations to one node, before caller-side dedup) would double-count and swamp consensus across the other fused legs. Fix: per-list dedup — each list votes once per id at its best (first) rank. Depth still folds the shallowest across all occurrences. +2 regression tests (duplicate_id_in_one_leg_votes_once, duplicate_id_still_folds_shallowest_depth). 9/9 rrf lib tests + doctest green; fmt + clippy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016b33swuXE23hKtqxsHu9p1 --- .claude/board/AGENT_LOG.md | 2 +- crates/lance-graph/src/graph/arigraph/rrf.rs | 46 ++++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index d7e6f944..12f8dd36 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -3,7 +3,7 @@ - **Task:** the inventory (#723) named RRF fusion as the D-GR-2 retrieval keystone — every ranked leg exists (`Bm25Index::rank`, `PersonalizedPageRank::ranked`, CAM-PQ) but nothing fused them. Landed the pure fusion primitive (the SAP "Practical GraphRAG" gap), ahead of G0 like `Bm25Index`/`PersonalizedPageRank`/`Communities`. - **Change:** `arigraph/rrf.rs` — `reciprocal_rank_fusion(ranked_lists: &[&[ScoredId]], k) -> Vec` (Cormack 2009; `Σ 1/(k+rank)`, 1-based, k=60 `DEFAULT_RRF_K`). Fuses by RANK so the per-list scores need not be commensurable (the reason it combines BM25 f64 / PPR probability / CAM-PQ i8). Deterministic (BTreeMap id-asc + stable score-desc sort); shallowest `depth` wins; returns the contract `ScoredId`. Re-exported in `arigraph/mod.rs`. - **Pure/reversible:** computes a fused ranking, reads no carrier state. The WIRING into `OsintRetriever::retrieve` stays GATED on the G0 verdict (per plan §5 + STATUS_BOARD D-GR-2). -- **Commit / Tests / Outcome:** commit ``; `cargo test -p lance-graph --lib -- graph::arigraph::rrf` 7/7 + doctest 1/1 green; clippy scoped `-p lance-graph --lib` clean on the addition (the 8 warnings are pre-existing `blasgraph/ndarray_bridge.rs` SIMD dead-code). Branch `claude/happy-hamilton-0azlw4` (restarted from post-#723 main). +- **Commit / Tests / Outcome:** feature `1306bf6`, fmt `2c87c04`, Codex-flagged per-leg dedup fix follow-up; `cargo test -p lance-graph --lib -- graph::arigraph::rrf` 9/9 + doctest 1/1 green; clippy scoped `-p lance-graph --lib` clean on the addition (the 8 warnings are pre-existing `blasgraph/ndarray_bridge.rs` SIMD dead-code). Codex P2 (RRF must give each leg one vote per id at its best rank — a duplicated entity in one leg was double-counting) FIXED + 2 regression tests. Branch `claude/happy-hamilton-0azlw4`; PR #724. ## 2026-07-18 — GraphRAG representations inventory — 7 papers × V3 substrate matrix (6 Opus paper-readers + 1 Opus v3-harvest; main-thread synthesis) diff --git a/crates/lance-graph/src/graph/arigraph/rrf.rs b/crates/lance-graph/src/graph/arigraph/rrf.rs index 09079bc9..a499a043 100644 --- a/crates/lance-graph/src/graph/arigraph/rrf.rs +++ b/crates/lance-graph/src/graph/arigraph/rrf.rs @@ -24,7 +24,7 @@ //! module only lands the algorithm, ahead of that gate, exactly as //! `Bm25Index`/`PersonalizedPageRank`/`Communities` landed as pure capabilities. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use lance_graph_contract::doc_graph::ScoredId; @@ -67,11 +67,18 @@ pub fn reciprocal_rank_fusion(ranked_lists: &[&[ScoredId]], k: f64) -> Vec = BTreeMap::new(); for list in ranked_lists { + // RRF gives each list AT MOST ONE vote per id, at its best (first) rank. + // A leg that surfaces the same entity more than once (e.g. several + // relations to one node, before caller-side dedup) must not stack + // `1/(k+rank)` contributions and swamp consensus across the other legs. + let mut voted: BTreeSet<&str> = BTreeSet::new(); for (pos, item) in list.iter().enumerate() { - let rank = pos as f64 + 1.0; // 1-based let entry = acc.entry(item.id.as_str()).or_insert((0.0, u8::MAX)); - entry.0 += 1.0 / (k + rank); - entry.1 = entry.1.min(item.depth); + entry.1 = entry.1.min(item.depth); // shallowest depth across all occurrences + if voted.insert(item.id.as_str()) { + // best-first order ⇒ the first occurrence is the best rank + entry.0 += 1.0 / (k + pos as f64 + 1.0); + } } } let mut fused: Vec = acc @@ -178,4 +185,35 @@ mod tests { y.iter().map(|s| s.score).collect::>() ); } + + #[test] + fn duplicate_id_in_one_leg_votes_once() { + // A leg surfaces "a" twice (rank 1 and rank 3) plus "b" at rank 2 — as a + // node with several relations would before caller-side dedup. RRF must + // credit "a" ONCE at its best rank (1), not 1/(k+1)+1/(k+3). + let leg = [ + ScoredId::new("a", 1.0, 0), + ScoredId::new("b", 1.0, 0), + ScoredId::new("a", 1.0, 0), + ]; + let fused = reciprocal_rank_fusion(&[&leg], DEFAULT_RRF_K); + assert_eq!(fused.len(), 2); + let a = fused.iter().find(|s| s.id == "a").unwrap().score; + let b = fused.iter().find(|s| s.id == "b").unwrap().score; + let expected_a = (1.0f64 / (DEFAULT_RRF_K + 1.0)) as f32; // best rank only + assert!( + (a - expected_a).abs() < 1e-7, + "a double-counted: {a} vs {expected_a}" + ); + assert!(a > b); // single best-rank vote still beats b's rank-2 vote + } + + #[test] + fn duplicate_id_still_folds_shallowest_depth() { + // Duplicate occurrences don't re-vote, but depth still takes the min. + let leg = [ScoredId::new("a", 1.0, 5), ScoredId::new("a", 1.0, 2)]; + let fused = reciprocal_rank_fusion(&[&leg], DEFAULT_RRF_K); + assert_eq!(fused.len(), 1); + assert_eq!(fused[0].depth, 2); // min(5, 2), even though the 2nd didn't vote + } }