"""决策缓存测试(T-X3):LRU+TTL 单元契约 + Grader live 模式接线(embed 去重)。""" import asyncio import pytest from gateway.sense.config import build_sense_config from gateway.sense.decision_cache import DecisionCache from gateway.sense.grader import Grader from gateway.sense.store import SenseStore # ---------------- DecisionCache 单元 ---------------- class _Clock: def __init__(self): self.t = 100.0 def __call__(self): return self.t def test_key_scope_and_text_sensitive(): assert DecisionCache.key("proxy", "abc") == DecisionCache.key("proxy", "abc") assert DecisionCache.key("proxy", "abc") != DecisionCache.key("pipeline", "abc") assert DecisionCache.key("proxy", "abc") != DecisionCache.key("proxy", "abd") def test_put_get_hit_miss_counters(): c = DecisionCache() assert c.get("s", "q") is None c.put("s", "q", {"tier": "T1"}) assert c.get("s", "q") == {"tier": "T1"} st = c.stats() assert st["hits"] == 1 and st["misses"] == 1 and st["entries"] == 1 def test_ttl_expiry_with_fake_clock(): clock = _Clock() c = DecisionCache(ttl_s=60.0, now=clock) c.put("s", "q", {"v": 1}) assert c.get("s", "q") == {"v": 1} clock.t += 61.0 # 超时 assert c.get("s", "q") is None assert c.stats()["entries"] == 0 # 过期条目被清除 def test_lru_eviction_at_maxsize(): c = DecisionCache(maxsize=2) c.put("s", "a", 1) c.put("s", "b", 2) c.get("s", "a") # a 变为最近使用 c.put("s", "c", 3) # 淘汰 b assert c.get("s", "a") == 1 assert c.get("s", "b") is None assert c.get("s", "c") == 3 def test_clear_keeps_counters_but_drops_entries(): c = DecisionCache() c.put("s", "q", 1) c.get("s", "q") c.clear() assert c.get("s", "q") is None assert c.stats()["hits"] == 1 # 计数保留(审计口径不回退) # ---------------- Grader live 模式接线 ---------------- class _CountingObserver: def __init__(self): self.logs = [] def log(self, obs): self.logs.append(obs) class _FakeHead: version = "test-head" def predict(self, vec): return {"t1": 0.95, "t2": 0.03, "t3": 0.02} def _make_grader(tmp_path, mode, counter, observer=None): """构造测试 Grader:embed 打点计数 + FakeHead 注入。""" import gateway.sense.grader as gr cfg = build_sense_config({"sense": { "enabled": True, "mode": mode, "db_path": str(tmp_path / "s.sqlite3"), "models_dir": str(tmp_path / "models")}}) store = SenseStore.init_db(cfg.db_path) g = Grader(cfg, store, observer or _CountingObserver()) g._load_head = lambda: _FakeHead() async def fake_embed(text, c): counter["n"] += 1 return [1, 2, 3] orig = gr.embed gr.embed = fake_embed return g, orig def test_grader_live_mode_dedupes_embed(tmp_path): """live 模式:同文本第二次 decide 命中缓存,embed 只跑一次;观察仍写两条。""" import gateway.sense.grader as gr counter = {"n": 0} observer = _CountingObserver() g, orig = _make_grader(tmp_path, "live", counter, observer) try: async def run(): d1 = await g.decide("什么是递归", "proxy") d2 = await g.decide("什么是递归", "proxy") d3 = await g.decide("换一个问题", "proxy") return d1, d2, d3 d1, d2, d3 = asyncio.run(run()) finally: gr.embed = orig assert d1.tier == "T1" and d2.tier == "T1" and d3.tier == "T1" assert counter["n"] == 2 # 同文本去重:2 个不同文本各 1 次 assert d2.head_version == d1.head_version assert len(observer.logs) == 3 # 观察不因缓存命中而跳过 def test_grader_collect_mode_no_cache(tmp_path): """collect/shadow:校准数据必须全量产出,不走缓存。""" import gateway.sense.grader as gr counter = {"n": 0} g, orig = _make_grader(tmp_path, "collect", counter) try: async def run(): await g.decide("同一个问题", "proxy") await g.decide("同一个问题", "proxy") asyncio.run(run()) finally: gr.embed = orig assert counter["n"] == 2 def test_grader_invalidate_clears_cache(tmp_path): """invalidate(工件切换)后缓存清空:同文本重新 embed。""" import gateway.sense.grader as gr counter = {"n": 0} g, orig = _make_grader(tmp_path, "live", counter) try: async def run(): await g.decide("什么是递归", "proxy") g.invalidate() await g.decide("什么是递归", "proxy") asyncio.run(run()) finally: gr.embed = orig assert counter["n"] == 2