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)
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""晋升表测试(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"
|
||||
Reference in New Issue
Block a user