Ctrl K
ComunidadGuías…

tests/test_fixes_hold.py

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

Archivo original ↗Preguntar a KiBot
"""修法本身要有效。這三個都是我 2026-08-31 的「修好了」,隔天外部審查發現
它們沒修好,其中兩個比原本更糟。

只讀旗標    absorb_gate 的寫入條件被我寫反成 `review and write_decisions`。
            於是 `--review`(文件寫明「只顯示結果」)會覆寫決策檔,而真正
            要求寫入的那個旗標反而不寫。原本的 bug 是「不加旗標也會寫」,
            我的版本是「除了要求寫入以外都會寫」。

回傳型別    lookup_card_vectors 改成每張卡回一串論點向量。我更新了一個
            呼叫端,另一個沒有——那條路徑上 _cosine 會拿 list 去乘 float。
            測試沒抓到,因為那一批候選是空的,路徑從來沒被執行。

增量清理    「清掉沒有對應段落的鍵」的判斷是錯的:增量模式只要文件有一塊
            變了,整份文件就算被檢查過,於是沒變的塊全被刪。一張改過的
            卡片會在 #kp 鍵與卡片級鍵之間永遠振盪,每次都重新付費嵌入。
            我當時的驗證是「看它刪了 31 個鍵」——我測的是它有動作,不是
            它動得對。

共同點是我驗證了行為發生,沒驗證行為正確。這三個測試問的都是後者。
"""
from __future__ import annotations

import inspect
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

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


class ReadOnlyFlagsWriteNothing(unittest.TestCase):
    """--review 不可以碰決策檔。"""

    def test_review_alone_does_not_write(self) -> None:
        src = (ROOT / "scripts" / "absorb_gate_semantic.py").read_text(encoding="utf-8")
        self.assertIn("if args.review or not args.write_decisions:", src)
        self.assertNotIn("if args.review and args.write_decisions:", src)

    def test_review_run_leaves_the_decisions_file_untouched(self) -> None:
        import absorb_gate_semantic as gate

        script = Path(inspect.getsourcefile(gate))
        with tempfile.TemporaryDirectory() as tmp:
            # 要把腳本真正會寫的那個檔導到這裡來。原本這個測試在 tmp 底下放
            # 一個哨兵、卻沒告訴子行程,於是那個斷言永遠不可能失敗——真正撐著
            # 它的只有 stdout 檢查。而 guard 壞掉時,它會寫進**正式**的
            # review-decisions.json(Pan 累積的 285 筆判斷)。
            # 一個失敗時會破壞正式資料的測試,比沒有測試更糟。
            decisions = Path(tmp) / "review-decisions.json"
            decisions.write_text(
                '{"_comment": "sentinel", "topics": {}, "decisions": {}}',
                encoding="utf-8")
            before = decisions.read_text(encoding="utf-8")

            env = dict(os.environ, XKB_REVIEW_DECISIONS=str(decisions))
            proc = subprocess.run(
                [sys.executable, str(script), "--review", "--topic", "__no_such_topic__"],
                capture_output=True, text=True, encoding="utf-8",
                errors="replace", timeout=300, cwd=str(ROOT), env=env,
            )
            # 乾淨結束才算數:提早 return 1 的話,下面的斷言會是空的。
            self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
            self.assertEqual(decisions.read_text(encoding="utf-8"), before)
            self.assertNotIn("已寫入", proc.stdout)


class CallersMatchTheReturnType(unittest.TestCase):
    """改回傳型別要把每一個呼叫端都帶到。"""

    def test_lookup_card_vectors_returns_rows_per_card(self) -> None:
        import continuity_recall as cr

        sig = inspect.signature(cr.lookup_card_vectors)
        self.assertIn("list[list[float]]", str(sig.return_annotation))

    def test_every_caller_treats_values_as_a_list_of_vectors(self) -> None:
        # 呼叫端把值當成單一向量的話,_cosine 會拿 list 乘 float。這裡不跑
        # 完整召回(要嵌入服務),只確認每個呼叫點都有拆一層。
        for name in ("absorb_gate_semantic.py", "recall_router.py",
                     "recall_for_conversation.py", "continuity_recall.py"):
            src = (ROOT / "scripts" / name).read_text(encoding="utf-8")
            for lineno, line in enumerate(src.split("\n"), 1):
                if "lookup_card_vectors(" not in line or line.lstrip().startswith("#"):
                    continue
                if "def lookup_card_vectors" in line:
                    continue
                window = "\n".join(src.split("\n")[lineno - 1:lineno + 16])
                self.assertTrue(
                    any(tok in window for tok in ("for vec in", "for v in rows", "max(", "for rows")),
                    f"{name}:{lineno} 沒有把每張卡的多條向量拆開:\n{window}",
                )


class PruningKeysOnEnumerationAndRunsAtAll(unittest.TestCase):
    """清理寫錯過三次,每次都是同一個混淆的不同面向。這裡釘的是那三件事。"""

    SRC = (ROOT / "scripts" / "build_vector_index.py").read_text(encoding="utf-8")

    def test_it_keys_on_what_was_enumerated_not_what_was_queued(self) -> None:
        # 一:用 queued_keys 推來源,會把沒變動的段落當成消失了而刪掉。
        self.assertIn("enumerated_keys", self.SRC)
        self.assertNotIn('examined = {key.rsplit("#", 1)[0] for key in queued_keys}', self.SRC)
        start = self.SRC.index("    examined_docs = ")
        block = self.SRC[start:self.SRC.index("and key not in enumerated_keys]", start)]
        self.assertIn("enumerated_keys", block)
        self.assertNotIn("queued_keys", block)

    def test_it_is_not_gated_on_a_mode_nothing_uses(self) -> None:
        # 二:只在完整重建時清理,等於永遠不清——每一條排程都帶 --incremental。
        start = self.SRC.index("    examined_docs = ")
        prune = self.SRC[start:self.SRC.index("and key not in enumerated_keys]", start)]
        self.assertNotIn("args.incremental", prune)

    def test_it_is_decided_before_the_early_return(self) -> None:
        # 三:規則對了卻放在「沒東西要嵌入就提早結束」後面,平常永遠跑不到。
        decided = self.SRC.index("    stale_keys = [")
        early_return = self.SRC.index('print("✅ Nothing to embed.")')
        self.assertLess(decided, early_return,
                        "死鍵是在提早結束之後才算的,平常那條路根本走不到")
        guard = self.SRC[self.SRC.index("    if not to_embed and _partitions_ok"):]
        self.assertIn("not stale_keys", guard[:guard.index("\n")],
                      "有死鍵時仍會提早結束")

    def test_the_rule_does_not_depend_on_the_mode_at_all(self) -> None:
        """清理規則不看 --incremental,那正是它對的跡象。

        這裡原本有一個測試,宣稱要盯住「每一條排程都帶 --incremental」。它比對的
        是整份檔案裡有沒有出現這個字串,所以 fetch_and_summarize.sh 靠一句無關的
        build_search_index.sh 就過了——而那支腳本本身還有一條刻意的完整重建分支,
        也就是說它宣稱要守的前提本來就不成立。

        但那個前提只對「規則被模式擋住」的那一版有意義。現在規則問的是這一輪
        列舉到了哪些鍵,兩種模式的答案一樣好,所以沒有前提需要守。
        守著一個過期前提、而且用一個根本沒在檢查它的斷言,比沒有測試更糟:
        它對一件自己從沒看過的事情回報信心。
        """
        prune = self.SRC[self.SRC.index("    examined_docs = "):]
        prune = prune[:prune.index("and key not in enumerated_keys]")]
        self.assertNotIn("incremental", prune)


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