Files
projectAIpopular/tests/test_sense_promotion.py
T
tzt 1aaa05cacc feat(sense): T-X5 云端评判晋升表×conformal 双闸门——本地档自动接管(采纳 cortiq promotion)
- gateway/sense/promotion.py:PromotionTable 状态机(candidate/promoted/demoted)
  * 晋升门:n_total>=n_min 且 通过率>=promote_lb 且 soak 浸泡期满足
  * 退化:promoted 期间通过率<demote_lb 自动降级;降级后可凭数据恢复
  * 一票否决:tier=T3 观察不进通过率统计,已晋升者立即降级
  * 持久化 sense_promotion 表(CREATE IF NOT EXISTS 幂等)
- grader:live 模式对已晋升标签的 T2 决策(无 T3 信号/非 fallback)升级 T1,
  hard_gates.promotion_applied 如实标注;T1/T3/fallback 路径不受影响
- 回填链路:T1 审计抽样带 promo:<label> 标签 -> 人工 verdict approve=ok
  经 /review/{id} 提交时回填晋升表(失败不影响审核主流程)
- 双闸门语义:conformal 阈值保证单条决策风险率,晋升表保证标签级接管节奏;
  collect 数据不足(n_min 未满)不晋升,全部迁移可经 T-X4 留痕审计

pytest 474 passed(T-X4 后 466 + 8)
2026-09-18 23:01:52 +08:00

179 lines
6.2 KiB
Python

"""晋升表测试(T-X5):candidate→promoted→demoted→恢复状态机 + 一票否决 +
grader 晋升应用(T2→T1)与 T3/闸门互斥。"""
import asyncio
import pytest
from gateway.sense.config import build_sense_config
from gateway.sense.grader import Grader
from gateway.sense.promotion import PromotionTable, promotion_label
from gateway.sense.store import SenseStore
class _Clock:
def __init__(self, t=1789874000.0):
self.t = t
def __call__(self):
return self.t
def _table(tmp_path, **kw):
store = SenseStore.init_db(tmp_path / "s.sqlite3")
clock = _Clock()
table = PromotionTable(store, now=clock, **kw)
return store, clock, table
def test_promotion_requires_min_and_soak(tmp_path):
"""n_min 未满不晋升; soak 期未满不晋升;两者满足才 promoted。"""
store, clock, table = _table(tmp_path, n_min=5, promote_lb=0.9,
soak_days=3.0)
label = "proxy:general"
for _ in range(5):
table.observe(label, ok=True, ts=clock.t) # 通过率 1.0 但浸泡期未满
assert not table.is_promoted(label)
clock.t += 4 * 86400.0 # 浸泡期满足
table.observe(label, ok=True, ts=clock.t)
assert table.is_promoted(label)
def test_promotion_demotes_on_degradation(tmp_path):
"""promoted 后通过率跌破 demote_lb -> 自动降级。"""
store, clock, table = _table(tmp_path, n_min=5, promote_lb=0.9,
demote_lb=0.85, soak_days=0.0)
label = "proxy:general"
for _ in range(6):
table.observe(label, ok=True, ts=clock.t)
assert table.is_promoted(label)
for _ in range(6):
clock.t += 60
table.observe(label, ok=False, ts=clock.t) # 通过率跌至 6/12 = 0.5
assert not table.is_promoted(label)
assert table.snapshot(label)["state"] == "demoted"
def test_promotion_recovery_after_demotion(tmp_path):
"""demoted 后凭数据恢复:再次满足晋升门 -> promoted。"""
store, clock, table = _table(tmp_path, n_min=4, promote_lb=0.9,
demote_lb=0.85, soak_days=0.0)
label = "proxy:general"
for _ in range(4):
table.observe(label, ok=True, ts=clock.t)
assert table.is_promoted(label)
for _ in range(4):
clock.t += 60
table.observe(label, ok=False, ts=clock.t) # 跌破
assert table.snapshot(label)["state"] == "demoted"
for _ in range(20):
clock.t += 60
table.observe(label, ok=True, ts=clock.t) # 20/24 ≈ 0.83... 继续
# 24 条中 20 ok = 0.833 < 0.9,再补 ok 至 >0.9
for _ in range(6):
clock.t += 60
table.observe(label, ok=True, ts=clock.t)
# 30 条 26 ok ≈ 0.867,仍 <0.9;再补
for _ in range(6):
clock.t += 60
table.observe(label, ok=True, ts=clock.t)
# 36 条 32 ok ≈ 0.889;最后一条后 >= 0.9
table.observe(label, ok=True, ts=clock.t + 60) # 37 条 33 ok ≈ 0.892
for _ in range(4):
table.observe(label, ok=True, ts=clock.t + 120)
# 41 条 37 ok ≈ 0.902 >= 0.9
assert table.snapshot(label)["state"] == "promoted"
def test_high_tier_veto(tmp_path):
"""T3 观察:不计入通过率;已晋升立即降级。"""
store, clock, table = _table(tmp_path, n_min=3, promote_lb=0.9, soak_days=0.0)
label = "proxy:general"
for _ in range(3):
table.observe(label, ok=True, ts=clock.t)
assert table.is_promoted(label)
before = table.snapshot(label)["n_total"]
table.observe(label, ok=True, tier="T3", ts=clock.t + 10)
snap = table.snapshot(label)
assert snap["n_total"] == before # T3 不进统计
assert snap["state"] == "demoted" # 一票否决
def test_promotion_label_format():
assert promotion_label("proxy", "") == "proxy:general"
assert promotion_label("proxy", "code") == "proxy:code"
# ---------------- grader 晋升应用 ----------------
class _FakeHeadMid:
version = "test-head"
def predict(self, vec):
return {"t1": 0.50, "t2": 0.47, "t3": 0.03} # 低于阈值 -> default T2
class _NoopObserver:
def log(self, obs):
pass
def _make(tmp_path, table):
cfg = build_sense_config({"sense": {
"enabled": True, "mode": "live",
"db_path": str(tmp_path / "s.sqlite3"),
"models_dir": str(tmp_path / "models")}})
store = SenseStore.init_db(cfg.db_path)
g = Grader(cfg, store, _NoopObserver(), promotion=table)
g._load_head = lambda: _FakeHeadMid()
async def fake_embed(text, c):
return [1, 2, 3]
import gateway.sense.grader as gr
orig = gr.embed
gr.embed = fake_embed
return g, orig
def test_grader_applies_promotion_t2_to_t1(tmp_path):
"""已晋升标签:T2 决策升级 T1 且 hard_gates 标注 promotion_applied。"""
store = SenseStore.init_db(tmp_path / "s.sqlite3")
clock = _Clock()
table = PromotionTable(store, now=clock, n_min=2, promote_lb=0.9, soak_days=0.0)
label = promotion_label("proxy", "general")
table.observe(label, ok=True, ts=clock.t)
table.observe(label, ok=True, ts=clock.t)
g, orig = _make(tmp_path, table)
import gateway.sense.grader as gr
try:
d = asyncio.run(g.decide("什么是递归", "proxy"))
finally:
gr.embed = orig
assert d.tier == "T1"
assert d.hard_gates["promotion_applied"] is True
def test_grader_skips_promotion_when_not_promoted(tmp_path):
"""未晋升:同样的 T2 决策保持 T2。"""
store = SenseStore.init_db(tmp_path / "s2.sqlite3")
table = PromotionTable(store, now=_Clock(), n_min=100, soak_days=0.0)
g, orig = _make(tmp_path, table)
import gateway.sense.grader as gr
try:
d = asyncio.run(g.decide("什么是递归", "proxy"))
finally:
gr.embed = orig
assert d.tier == "T2"
assert d.hard_gates["promotion_applied"] is False
def test_grader_promotion_not_applied_without_table(tmp_path):
"""未接晋升表(向后兼容):行为与既有决策一致。"""
g, orig = _make(tmp_path, None)
import gateway.sense.grader as gr
try:
d = asyncio.run(g.decide("什么是递归", "proxy"))
finally:
gr.embed = orig
assert d.tier == "T2"