Ctrl K
ComunidadGuías…

tests/test_demotion_is_not_removal.py

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

Archivo original ↗Preguntar a KiBot
"""降權要會動,而且不能殺掉「存起來很久以後才用」的東西。

Pan 選了最保守的退場規則,理由是「我自己常常存一些我真的很久以後才會用到的
東西」。那個顧慮是這份測試的第一個 class:規則必須在**定義上**碰不到那種卡片,
不是靠門檻調得夠高。

退場在這個專案有前例可循,而前例都是壞的:excluded 旗標做成單行道,害一張被
解析 bug 誤排除的好卡片只能手動改檔案救回來(2026-09-11);消化的 --apply 用
結論取代筆記,丟掉四成內容還讓壓縮比看起來變好(記憶 xkb-synthesis-silent-loss)。
兩次都是「做了不可逆的事,而且看不出來」。所以這裡每一條測的都是不可逆性:

    降權的東西還在結果裡嗎
    它還會被量測嗎(不然永遠回不來)
    它這次真的相關的時候,會不會還是被壓掉
    統計讀不到的時候,召回會不會跟著壞
"""
from __future__ import annotations

import sys
import tempfile
import unittest
from unittest import mock
from pathlib import Path

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

import xkb_eviction as ev  # noqa: E402
from xkb_evidence import identity_key
import xkb_score  # noqa: E402
from xkb_memory_service import KnowledgeCatalog, Store, tag_demoted  # noqa: E402


class StoredForLaterSurvives(unittest.TestCase):
    """Pan 的顧慮。規則要在定義上碰不到這種卡片。"""

    def test_a_card_nobody_has_searched_for_is_never_demoted(self):
        """存起來以後才用的卡片,特徵是**沒被撈出來過**。

        judged_count 低,所以不管 relevant 是 0 還是什麼,都不該降權。
        這是這條規則跟「很久沒用到就退場」最大的差別——後者正好會殺掉它。
        """
        for considered in range(0, ev.DEMOTE_AFTER_CONSIDERED):
            with self.subTest(considered=considered):
                self.assertFalse(ev.is_demoted(considered, 0))

    def test_demotion_needs_repeated_uselessness_not_age(self):
        """規則裡沒有任何時間欄位。久沒被碰過不是降權理由。"""
        import inspect
        src = inspect.getsource(ev.is_demoted)
        for word in ("days", "last_", "age", "timestamp", "now"):
            self.assertNotIn(word, src,
                             "降權不該看時間——看時間就會殺掉存起來以後才用的東西")

    def test_one_hit_ever_is_enough_to_stay(self):
        """有一次明確的相關判斷就夠了,不需要維持命中率。

        保守在這裡:這不是 gain 那套「比平均差就退」,是「從來沒有用過」。
        """
        self.assertFalse(ev.is_demoted(999, 1))


class DemotionIsNotRemoval(unittest.TestCase):
    def test_a_demoted_record_is_still_in_the_results(self):
        """壓到最後,不是不見。不然索引壞掉會長得像知識庫是空的。"""
        results = xkb_score.rank([
            {"title": "好卡片", "score": 0.88, "score_scale": "card_semantic"},
            {"title": "被降權的", "score": 0.10, "score_scale": "card_semantic",
             "demoted": True},
        ])
        self.assertEqual(len(results), 2)
        self.assertEqual(results[-1]["title"], "被降權的")

    def test_demoted_sinks_below_merely_weak(self):
        """被證實沒用過的,要排在只是這次弱的之後——limit 先切掉它。"""
        results = xkb_score.rank([
            {"title": "這次弱", "score": 0.10, "score_scale": "card_semantic"},
            {"title": "從來沒用過", "score": 0.12, "score_scale": "card_semantic",
             "demoted": True},
        ])
        self.assertEqual([r["title"] for r in results], ["這次弱", "從來沒用過"])

    def test_rank_only_obeys_the_flag_it_does_not_decide(self):
        """排序不判斷該不該降權,只照旗標排。

        判斷在合併召回處依 judge verdict 與 namespace 統計進行,不能拿檢索分數代替。
        """
        import inspect
        src = inspect.getsource(xkb_score.rank)
        self.assertNotIn("RELEVANCE_FLOOR) and", src)
        results = xkb_score.rank([
            {"title": "高分但被降權", "score": 0.88,
             "score_scale": "card_semantic", "demoted": True},
            {"title": "低分沒被降權", "score": 0.60,
             "score_scale": "card_semantic"},
        ])
        self.assertEqual(results[-1]["title"], "高分但被降權")


class RevivalUsesExplicitVerdicts(unittest.TestCase):
    def test_retrieval_scores_do_not_override_rejections(self):
        for scale, score in (("card_semantic", .99), ("card_keyword", 99)):
            records = [{"id": "noise", "score": score, "score_scale": scale}]
            tag_demoted(records, lambda: {identity_key({"id": "noise"})}, relevant_ids=set())
            self.assertTrue(records[0]["demoted"])

    def test_current_relevance_clears_all_legs_and_stale_flags(self):
        records = [{"id": "noise", "score_scale": scale, "demoted": True}
                   for scale in ("card_keyword", "card_semantic")]
        tag_demoted(records, lambda: {identity_key({"id": "noise"})}, relevant_ids={identity_key({"id": "noise"})})
        self.assertFalse(any(r.get("demoted") for r in records))
        records[0]["demoted"] = True
        tag_demoted(records, lambda: set())
        self.assertNotIn("demoted", records[0])


class RevivalNeedsNoIntervention(unittest.TestCase):
    """這是跟 excluded 旗標那個單行道的差別。"""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.store = Store(Path(self.tmp.name) / "memory.sqlite")

    def _consider(self, record_id: str, similarity: float, injected: bool, times: int):
        for _ in range(times):
            item = {"id": record_id, "score": similarity, "judge": .9 if injected else .01}
            self.store.record_recall_usage("private", [item], [item] if injected else [],
                                           {"status": "judged"})

    def test_it_becomes_demoted_after_enough_useless_considerations(self):
        n = ev.DEMOTE_AFTER_CONSIDERED
        self._consider("cards/noise.md", 0.51, False, n - 1)
        self.assertEqual(self.store.demoted_ids(), set(),
                         "還沒到門檻就降權的話,保守的意義就沒了")
        self._consider("cards/noise.md", 0.51, False, 1)
        self.assertEqual(self.store.demoted_ids(), {identity_key({"id": "cards/noise.md"})})

    def test_one_real_hit_lifts_the_demotion_with_nobody_deciding(self):
        """被降權之後還是會被量測,所以它自己能回來。

        excluded 旗標做不到這件事:它把卡片從索引拿掉,於是再也不會被
        judged,relevant_count 永遠是 0,永遠回不來。
        """
        self._consider("cards/borderline.md", 0.53, False,
                       ev.DEMOTE_AFTER_CONSIDERED)
        self.assertIn(identity_key({"id": "cards/borderline.md"}), self.store.demoted_ids())

        # 有一天某個問法讓它通過地板。
        self._consider("cards/borderline.md", 0.71, True, 1)

        self.assertEqual(self.store.demoted_ids(), set(),
                         "降權必須自動解除,不能要人手動改檔案")

    def test_the_threshold_has_one_definition(self):
        """門檻不准有第二份拷貝——尺度與門檻在這個專案各自被複製過一次。"""
        self.assertEqual(
            self.store.demoted_ids.__defaults__, (ev.DEMOTE_AFTER_CONSIDERED,))


class ItMustNotDependOnWhichLegFoundIt(unittest.TestCase):
    """第一版掛在語意腿尾端,真實資料一驗就破。

    每條召回腿都自己新建 dict(語意、關鍵字、wiki 各有一份欄位清單),所以掛在
    其中一條上的標記會被其他腿繞過。那張被撈出 8 次、從來沒用上的卡片,在日文
    查詢下是**關鍵字腿**撈到的——標記完全沒掛上,而單元測試全綠。

    這是記憶 xkb-many-writers-one-reader 的同一個形狀:保護要放在讀的那一端。
    這裡用關鍵字腿的欄位形狀(line 666 那一長串,沒有經過 _drop_irrelevant)
    來證明標記與腿無關。
    """

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.store = Store(Path(self.tmp.name) / "memory.sqlite")
        for _ in range(ev.DEMOTE_AFTER_CONSIDERED):
            self.store.record_recall_usage("private", [{"id": "02-seo-geo/999", "judge": .01}], [],
                                           {"status": "judged"})

    def _keyword_leg_record(self, record_id: str, score: float) -> dict:
        """關鍵字腿產出的形狀。它不經過 _drop_irrelevant。"""
        return {"record_type": "knowledge_card", "id": record_id,
                "title": record_id, "summary": "", "source_file": f"cards/{record_id}.md",
                "source_type": "card", "score_scale": "card_keyword", "score": score,
                "retrieval": "keyword"}

    def test_a_keyword_leg_hit_is_still_demoted(self):
        demoted_id = "02-seo-geo/999"
        records = [self._keyword_leg_record(demoted_id, 3.0),
                   self._keyword_leg_record("01-good/111", 1.0)]
        self.store.catalog.search = lambda *a, **k: {
            "records": records, "filtered_counts": {"by_layer": {}}}
        self.store.recall = lambda *a, **k: {"memories": []}

        with mock.patch("xkb_memory_service.xkb_jev.relevance", return_value=None):
            out = self.store.knowledge_recall("ヘアメイク プロンプト", limit=10)

        got = {r["id"]: r for r in out["records"]}
        self.assertTrue(got[demoted_id].get("demoted"),
                        "關鍵字腿撈到的也要被降權——不然換一條腿就繞過了")
        self.assertFalse(got["01-good/111"].get("demoted"))

    def test_no_leg_tags_on_its_own(self):
        """標記只能在合併點發生。多一處就多一條可以不一致的路。"""
        import inspect
        from xkb_memory_service import KnowledgeCatalog as KC, Store as St
        per_leg = (KC._semantic_search, KC._wiki_search, KC._drop_irrelevant,
                   KC.search)
        for fn in per_leg:
            with self.subTest(fn=fn.__name__):
                self.assertNotIn("tag_demoted", inspect.getsource(fn),
                                 "降權不要掛在單一條腿上")
        self.assertIn("tag_demoted", inspect.getsource(St.knowledge_recall),
                      "合併點沒有標記,降權就完全不會發生")


class LosingTheStatsMustNotBreakRecall(unittest.TestCase):
    """降權是最佳化,不是正確性。"""

    def test_no_sink_tags_nothing(self):
        records = [{"id": "cards/a.md"}]
        tag_demoted(records, None)
        self.assertNotIn("demoted", records[0])

    def test_a_failing_sink_is_swallowed(self):
        def boom():
            raise RuntimeError("統計表不見了")

        records = [{"id": "cards/a.md"}]
        tag_demoted(records, boom)  # 不可以炸
        self.assertNotIn("demoted", records[0])

    def test_it_tags_only_the_matching_record(self):
        records = [{"id": "cards/noise.md"}, {"id": "cards/good.md"}]
        tag_demoted(records, lambda: {identity_key({"id": "cards/noise.md"})})
        self.assertTrue(records[0].get("demoted"))
        self.assertNotIn("demoted", records[1])

    def test_the_catalog_does_not_need_to_know_about_demotion(self):
        """降權跟「去哪裡找知識」無關——catalog 替身不該為它多長一個方法。"""
        self.assertFalse(hasattr(KnowledgeCatalog(), "_tag_demoted"))
        self.assertFalse(hasattr(KnowledgeCatalog(), "demoted_sink"))


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