- 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)
179 lines
6.2 KiB
Python
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"
|