- gateway/sense/decision_cache.py:DecisionCache(sha256(scope+文本) 键、 LRU + TTL 60s、4096 条上限、时钟可注入),对齐 auth._AuthCache 进程内模式 - Grader:仅 live 模式缓存纯决策负载(probs/head_version/vec); 命中跳过 embed + 线性头预测;tier 仍按当前 conformal 阈值即时重算; 观察落盘(observer.log)不因缓存命中跳过——校准数据完整性不受影响 - collect/shadow 模式不走缓存(校准必须全量);invalidate() 同步清空缓存 (阈值/工件切换即时生效,TTL 仅兜底陈旧) pytest 455 passed(T-X2 后 447 + 8)
158 lines
4.7 KiB
Python
158 lines
4.7 KiB
Python
"""决策缓存测试(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
|