Files
projectAIpopular/tests/test_sense_decision_cache.py
T
tzt 27a092a3f8 feat(sense): T-X3 路由决策缓存——live 模式 embed+线性头去重(采纳 cortiq 决策哈希缓存)
- 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)
2026-09-18 22:41:15 +08:00

158 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""决策缓存测试(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):
"""构造测试 Graderembed 打点计数 + 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