Ctrl K
ComunidadGuías…

tests/test_xkb_score_rrf.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""xkb_score.rank() — reciprocal rank fusion.

rank() replaced a hand-tuned anchor table (one anchor per scale, measured on 8
queries) with RRF: unified_score = Σ weight_leg/(K + rank_in_leg); below-floor
hits then get -1.0 so they sort last regardless of leg size.
The properties that matter, and the ones review found broken in the first cut:
"""
from __future__ import annotations

import sys
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

import xkb_score as xs


class RankFusesRanksNotScales(unittest.TestCase):
    def test_keyword_score_cannot_dominate_a_cosine_score(self) -> None:
        # 舊系統的核心 bug:關鍵字分數 6~20、餘弦 0~1,直接比大小。RRF 之下
        # 兩者都只是「腿內第 1 名」,貢獻幾乎相同。
        out = xs.rank([
            {"t": "kw", "score": 15.0, "score_scale": "card_keyword"},
            {"t": "cos", "score": 0.62, "score_scale": "card_semantic"},
        ])
        self.assertLess(abs(out[0]["unified_score"] - out[1]["unified_score"]), 0.001)

    def test_weak_sole_member_of_a_thin_leg_does_not_steal_rank_one(self) -> None:
        # review finding 1:wiki 腿只有十幾個 topic,常常只回一個弱命中。純 RRF
        # 會讓那個弱命中拿腿內第 1、壓過強卡片。相關度地板要擋住這個。
        out = xs.rank([
            {"t": "strong_card", "score": 0.9, "score_scale": "card_semantic"},
            {"t": "weak_wiki", "score": 0.31, "score_scale": "wiki_semantic"},
        ])
        self.assertEqual(out[0]["t"], "strong_card")

    def test_a_legitimate_wiki_hit_still_outranks_a_card_by_authority(self) -> None:
        # 但正常強度的 wiki 命中(過了自己的門檻)還是靠權威度排在卡片之前。
        out = xs.rank([
            {"t": "wiki", "score": 0.45, "score_scale": "wiki_semantic"},
            {"t": "card", "score": 0.75, "score_scale": "card_semantic"},
        ])
        self.assertEqual(out[0]["t"], "wiki")

    def test_below_floor_hit_sorts_behind_every_above_floor_hit_any_leg_size(self) -> None:
        # review:eff = i + 20 只把弱命中推到某條腿的前 ~17 名之後。一條腿有
        # 25 筆時,第 18~25 的真命中還是會被弱命中壓過。分層排序不管腿多長都對。
        cards = [{"t": f"c{n}", "score": 0.72 - n * 0.006, "score_scale": "card_semantic"}
                 for n in range(25)]
        weak_wiki = {"t": "weak", "score": 0.29, "score_scale": "wiki_semantic"}
        out = xs.rank(cards + [weak_wiki])
        self.assertEqual(out[-1]["t"], "weak",
                         "下地板命中沒有排在所有上地板命中之後")

    def test_leg_rank_and_unified_score_do_not_contradict(self) -> None:
        # review finding 2:leg_rank 存 penalty 前的 i,unified 反映 i+20。
        # 分層排序沒有動名次,兩者不該打架:同一條腿裡 leg_rank 小 => unified 大。
        out = xs.rank([
            {"t": "a", "score": 0.9, "score_scale": "card_semantic"},
            {"t": "b", "score": 0.7, "score_scale": "card_semantic"},
            {"t": "c", "score": 0.6, "score_scale": "card_semantic"},
        ])
        by_leg_rank = sorted(out, key=lambda r: r["leg_rank"])
        by_unified = sorted(out, key=lambda r: r["unified_score"], reverse=True)
        self.assertEqual([r["t"] for r in by_leg_rank], [r["t"] for r in by_unified])

    def test_authority_is_a_few_ranks_not_a_domination(self) -> None:
        # wiki 腿內第 3 名壓過卡片第 1 名可以;第 8 名不行。
        legs_wiki = [{"t": f"w{i}", "score": 0.8 - i * 0.02, "score_scale": "wiki_semantic"}
                     for i in range(10)]
        card1 = {"t": "c1", "score": 0.85, "score_scale": "card_semantic"}
        out = xs.rank(legs_wiki + [card1])
        pos = [r["t"] for r in out].index("c1")
        self.assertGreaterEqual(pos, 2, "卡片第 1 名被 wiki 壓太多")
        self.assertLessEqual(pos, 6, "卡片第 1 名壓過太多 wiki")

    def test_zero_or_missing_score_gets_no_rank_and_sorts_last(self) -> None:
        out = xs.rank([
            {"t": "real", "score": 0.7, "score_scale": "card_semantic"},
            {"t": "zero", "score": 0.0, "source_type": "action"},
            {"t": "missing", "source_type": "action"},
        ])
        self.assertEqual(out[0]["t"], "real")
        self.assertLess(out[-1]["unified_score"], out[0]["unified_score"])
        self.assertEqual({r["t"] for r in out[1:]}, {"zero", "missing"})

    def test_rank_is_idempotent(self) -> None:
        d = [{"t": "x", "score": 0.7, "score_scale": "card_semantic"},
             {"t": "y", "score": 0.5, "score_scale": "card_semantic"}]
        xs.rank(d); xs.rank(d)
        third = xs.rank(d)
        for item in third:
            self.assertEqual(item["matched_by"], ["card_semantic"])
        self.assertEqual([r["t"] for r in third], [r["t"] for r in xs.rank(d)])

    def test_unified_score_and_relevance_agree_on_direction_within_a_leg(self) -> None:
        # review finding 3:召回服務叫模型「拿 relevance 和 unified_score 當提示」,
        # 兩者在同一條腿裡不能反向。
        out = xs.rank([
            {"t": "hi", "score": 0.9, "score_scale": "card_semantic"},
            {"t": "lo", "score": 0.6, "score_scale": "card_semantic"},
        ])
        by_u = sorted(out, key=lambda r: r["unified_score"], reverse=True)
        by_r = sorted(out, key=lambda r: r["relevance"], reverse=True)
        self.assertEqual([r["t"] for r in by_u], [r["t"] for r in by_r])


    def test_unified_score_order_matches_returned_order_across_tiers(self) -> None:
        # review:sort key 是 (tier, -rrf) 但 unified_score 只存 rrf,於是照
        # unified_score 重排會把被降級的弱命中又抬回去。兩者必須一致。
        cards = [{"t": f"c{n}", "score": 0.72 - n * 0.01, "score_scale": "card_semantic"}
                 for n in range(6)]
        weak = {"t": "weak", "score": 0.29, "score_scale": "wiki_semantic"}
        out = xs.rank(cards + [weak])
        by_unified = sorted(out, key=lambda r: r["unified_score"], reverse=True)
        self.assertEqual([r["t"] for r in out], [r["t"] for r in by_unified])


class RankKeyAndLegDedup(unittest.TestCase):
    def test_id_without_url_fuses_and_usage_keeps_distinct_wiki_sections(self):
        import tempfile
        from xkb_memory_service import Store
        from xkb_evidence import identity_key
        records = [
            {"id": "card-a", "score_scale": leg, "score": .8, "judge": .9}
            for leg in ("card_semantic", "card_keyword")]
        records += [{"id": "wiki/topics/a.md", "record_type": "wiki_topic", "section": section,
                     "score_scale": "wiki_semantic", "score": .8, "judge": .9}
                    for section in ("First", "Second")]
        ranked = xs.rank(records)
        self.assertEqual(len(ranked), 3)
        with tempfile.TemporaryDirectory() as tmp:
            store = Store(Path(tmp) / "usage.sqlite")
            store.record_recall_usage("private", records, ranked, {"status": "judged"})
            with store.connect() as db:
                rows = db.execute("SELECT record_id,considered_count,returned_count FROM recall_usage").fetchall()
        self.assertEqual({r["record_id"] for r in rows}, {identity_key(r) for r in ranked})
        self.assertTrue(all(r["considered_count"] == r["returned_count"] == 1 for r in rows))

    def test_same_url_does_not_merge_distinct_ids_or_namespaces(self):
        records = [{"id": key, "namespace": ns, "source_url": "https://example.test/common", "score": .8}
                   for key, ns in (("a", "private"), ("b", "private"), ("a", "team"))]
        self.assertEqual(len(xs.rank(records)), 3)

    def test_same_key_same_leg_adds_the_rrf_term_once(self) -> None:
        # 合併後 _legs 對同一條腿有兩筆(同頁 wiki 兩段被舊 _key 併掉、或
        # search() 不再去重後的重複列)。w/(K+i) 不能加兩次、matched_by 不能重複。
        row = lambda sc: {"source_type": "wiki", "source_file": "wiki/topics/x.md",
                          "section": "a", "score": sc, "score_scale": "wiki_semantic"}
        one = xs.rank([row(0.8)])
        dup = xs.rank([row(0.8), row(0.5)])
        self.assertEqual(len(dup), 1)
        self.assertEqual(dup[0]["matched_by"], ["wiki_semantic"])
        self.assertAlmostEqual(dup[0]["unified_score"], one[0]["unified_score"], places=6)

    def test_two_sections_of_one_wiki_file_stay_separate(self) -> None:
        out = xs.rank([
            {"source_type": "wiki", "source_file": "wiki/topics/x.md",
             "section": "四層架構", "score": 0.8, "score_scale": "wiki_semantic"},
            {"source_type": "wiki", "source_file": "wiki/topics/x.md",
             "section": "退場機制", "score": 0.7, "score_scale": "wiki_semantic"},
        ])
        self.assertEqual(len(out), 2)

    def test_card_merges_across_legs_even_if_section_differs(self) -> None:
        # 卡片身分是路徑;語意腿和 BM25 腿給的 section(標題)可能字面不同,
        # 不能因此就併不起來——否則雙腿 RRF 加分又沒了。
        out = xs.rank([
            {"source_type": "card", "source_file": "cards/a.md",
             "section": "標題 A", "score": 0.6, "score_scale": "card_semantic"},
            {"source_type": "card", "source_file": "cards/a.md",
             "section": "標題 A(索引版)", "score": 12.0, "score_scale": "card_bm25"},
        ])
        self.assertEqual(len(out), 1)
        self.assertEqual(set(out[0]["matched_by"]), {"card_semantic", "card_bm25"})

    def test_titleless_rows_do_not_merge_on_a_shared_section(self) -> None:
        out = xs.rank([
            {"section": "常見問題", "score": 0.6, "score_scale": "card_semantic"},
            {"section": "常見問題", "score": 0.7, "score_scale": "card_semantic"},
        ])
        self.assertEqual(len(out), 2)


if __name__ == "__main__":
    unittest.main()