run_pair_dual_sync:同一份 staged src/ 上先自动 spec(ensure_play_spec)、后金标 (inject_golden)各 play 一次,取两组逐门 verdict——隔离生成方差、只暴露驱动器差。 编排序 gen-only→smoke→断言 staged 无残留 spec→ensure→play(auto)→inject→play (golden);auto_gates 在金标注入前捕获。run_batch 端口池+线程前台有界并发(≤15 夹取、复用 bake_off._clamp_conc)、报告 auto-vs-golden-<idx>.json 不覆盖。 mock 编排单测(调用序 + auto 先于 golden + no-spec 守卫)20/20 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
13 KiB
Python
286 lines
13 KiB
Python
"""
|
|
test_auto_vs_golden.py — M2 auto-vs-golden 门单测(U1 逐门 delta 判据 + U2 编排 mock + U3 退役授权)。
|
|
|
|
守的不变量(U1):
|
|
· 逐门 delta = auto 过门率 - golden 过门率;关键门 {E_live,H_progress,G_input} 容差 0(delta≥0)、
|
|
其余门容差 -1/N(允许单款 flake)。
|
|
· 方向单边:查「自动不比金标驱得差」(delta<0 退化),非查「自动比金标松」。
|
|
· double-low 守卫:关键门 auto 与 golden 过门率都 <0.5(两路同挂)→ inconclusive(红)、不误判 aligned。
|
|
· 按品类聚合:三品类全 aligned 才整体 meets;任一 regressed/inconclusive/缺样本 → meets=False。
|
|
· 零 LLM:纯函数、同输入恒同输出。
|
|
|
|
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_auto_vs_golden.py
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
|
|
import auto_vs_golden as A # noqa: E402
|
|
|
|
_ALL_GATES = ["A_boot", "B_uncaught", "C_frame", "D_render", "E_live",
|
|
"F_wiring", "G_input", "H_progress", "I_control"]
|
|
|
|
|
|
def _gates(fail=()):
|
|
"""九门全过,fail 里的门置 False。"""
|
|
return {g: (g not in fail) for g in _ALL_GATES}
|
|
|
|
|
|
def _run(auto_fail=(), golden_fail=()):
|
|
"""一款:auto/golden 两组逐门(默认全过,指定门挂)。"""
|
|
return {"autoGates": _gates(auto_fail), "goldenGates": _gates(golden_fail)}
|
|
|
|
|
|
# ───────────────────────── U1 逐门 delta 判据 ─────────────────────────
|
|
|
|
def test_delta_all_equal_aligned():
|
|
"""auto 每门 = golden(delta 全 0)→ aligned、meets。"""
|
|
j = A.judge_genre_delta([_run() for _ in range(5)])
|
|
assert j["status"] == "aligned" and j["meets"] is True
|
|
assert j["perGate"]["H_progress"]["delta"] == 0.0
|
|
|
|
|
|
def test_delta_key_gate_lower_one_sample_regressed():
|
|
"""关键门 H_progress auto 比 golden 低一款(delta=-0.2)→ regressed(关键门容差 0)、meets=False。"""
|
|
runs = [_run(auto_fail=("H_progress",))] + [_run() for _ in range(4)]
|
|
j = A.judge_genre_delta(runs)
|
|
assert j["status"] == "regressed" and j["meets"] is False
|
|
assert "H_progress" in j["regressedGates"]
|
|
assert j["perGate"]["H_progress"]["delta"] == -0.2
|
|
|
|
|
|
def test_delta_nonkey_gate_lower_one_sample_within_tolerance():
|
|
"""非关键门 D_render auto 低一款(delta=-1/5=-0.2)→ 在容差内 → aligned。"""
|
|
runs = [_run(auto_fail=("D_render",))] + [_run() for _ in range(4)]
|
|
j = A.judge_genre_delta(runs)
|
|
assert j["status"] == "aligned" and j["meets"] is True
|
|
|
|
|
|
def test_delta_nonkey_gate_lower_two_samples_regressed():
|
|
"""非关键门 D_render auto 低两款(delta=-0.4 < -1/5)→ regressed。"""
|
|
runs = [_run(auto_fail=("D_render",)), _run(auto_fail=("D_render",))] + [_run() for _ in range(3)]
|
|
j = A.judge_genre_delta(runs)
|
|
assert j["status"] == "regressed" and "D_render" in j["regressedGates"]
|
|
|
|
|
|
def test_delta_auto_higher_than_golden_not_regressed():
|
|
"""方向单边:auto 关键门比 golden 高(golden 挂、auto 过,delta=+0.2)→ 不算退化、aligned
|
|
(门查的是 auto 不比 golden 差,不查 auto 比 golden 松)。"""
|
|
runs = [_run(golden_fail=("H_progress",))] + [_run() for _ in range(4)]
|
|
j = A.judge_genre_delta(runs)
|
|
assert j["status"] == "aligned" and j["meets"] is True
|
|
assert j["perGate"]["H_progress"]["delta"] == 0.2
|
|
|
|
|
|
def test_delta_double_low_key_gate_inconclusive():
|
|
"""double-low:关键门 E_live auto 与 golden 过门率都 <0.5(5 款里 3 款两路同挂)→ inconclusive(红)、
|
|
meets=False,不被 delta=0 误判 aligned。"""
|
|
runs = [_run(auto_fail=("E_live",), golden_fail=("E_live",)) for _ in range(3)] + [_run() for _ in range(2)]
|
|
j = A.judge_genre_delta(runs)
|
|
assert j["status"] == "inconclusive" and j["meets"] is False
|
|
assert "E_live" in j["inconclusiveGates"]
|
|
|
|
|
|
def test_delta_insufficient_empty():
|
|
j = A.judge_genre_delta([])
|
|
assert j["status"] == "insufficient" and j["meets"] is False and j["total"] == 0
|
|
|
|
|
|
def test_delta_deterministic_zero_llm():
|
|
"""纯函数、同输入恒同输出。"""
|
|
runs = [_run(auto_fail=("D_render",))] + [_run() for _ in range(4)]
|
|
assert A.judge_genre_delta(runs) == A.judge_genre_delta(runs)
|
|
|
|
|
|
# ───────────────────────── U1 按品类聚合 ─────────────────────────
|
|
|
|
def test_aggregate_all_aligned_meets():
|
|
gr = {"click-score": [_run() for _ in range(5)],
|
|
"whack-mole": [_run() for _ in range(5)],
|
|
"shop-serve": [_run() for _ in range(5)]}
|
|
r = A.aggregate_delta(gr, ["click-score", "whack-mole", "shop-serve"])
|
|
assert r["meets"] is True and not r["regressedGenres"] and not r["missingGenres"]
|
|
|
|
|
|
def test_aggregate_one_regressed_fails():
|
|
"""某品类关键门退化 → 整体 meets=False、不被其余品类平均掩盖。"""
|
|
gr = {"click-score": [_run() for _ in range(5)],
|
|
"whack-mole": [_run(auto_fail=("H_progress",))] + [_run() for _ in range(4)],
|
|
"shop-serve": [_run() for _ in range(5)]}
|
|
r = A.aggregate_delta(gr, ["click-score", "whack-mole", "shop-serve"])
|
|
assert r["meets"] is False and "whack-mole" in r["regressedGenres"]
|
|
|
|
|
|
def test_aggregate_missing_genre_fails():
|
|
gr = {"click-score": [_run() for _ in range(5)], "whack-mole": [_run() for _ in range(5)]}
|
|
r = A.aggregate_delta(gr, ["click-score", "whack-mole", "shop-serve"])
|
|
assert r["meets"] is False and "shop-serve" in r["missingGenres"]
|
|
|
|
|
|
# ───────────────────────── U3 Node 退役授权三条齐判定 ─────────────────────────
|
|
|
|
_REQ = ["click-score", "whack-mole", "shop-serve"]
|
|
|
|
|
|
def _write(d: Path, name: str, obj) -> Path:
|
|
p = d / name
|
|
p.write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8")
|
|
return p
|
|
|
|
|
|
def _reports(d: Path, m1=True, equiv=None, avg=True):
|
|
"""造三份报告:M1 达标 / 002 等价品类集 / M2 auto-vs-golden meets。"""
|
|
m1p = _write(d, "m1.json", {"overallMeets": m1})
|
|
equiv = _REQ if equiv is None else equiv
|
|
cmp = _write(d, "cmp.json", {"summary": {"equivalentGenres": equiv}})
|
|
avgp = _write(d, "avg.json", {"meets": avg})
|
|
return m1p, cmp, avgp
|
|
|
|
|
|
def test_retire_all_three_green_authorized():
|
|
with tempfile.TemporaryDirectory() as t:
|
|
m1p, cmp, avgp = _reports(Path(t))
|
|
r = A.retire_authorization(m1p, cmp, avgp, _REQ)
|
|
assert r["authorized"] is True
|
|
assert r["conditions"] == {"m1Met": True, "parityEquivalent": True, "autoVsGoldenAligned": True}
|
|
assert not r["missing"]
|
|
|
|
|
|
def test_retire_m1_not_met_unauthorized():
|
|
with tempfile.TemporaryDirectory() as t:
|
|
m1p, cmp, avgp = _reports(Path(t), m1=False)
|
|
r = A.retire_authorization(m1p, cmp, avgp, _REQ)
|
|
assert r["authorized"] is False and r["conditions"]["m1Met"] is False
|
|
assert any("M1" in m or "达标" in m for m in r["missing"])
|
|
|
|
|
|
def test_retire_parity_missing_genre_unauthorized():
|
|
"""002 等价品类集缺一品类(某品类 regressed)→ 对照等价未达成。"""
|
|
with tempfile.TemporaryDirectory() as t:
|
|
m1p, cmp, avgp = _reports(Path(t), equiv=["click-score", "whack-mole"])
|
|
r = A.retire_authorization(m1p, cmp, avgp, _REQ)
|
|
assert r["authorized"] is False and r["conditions"]["parityEquivalent"] is False
|
|
assert any("对照" in m or "equivalent" in m.lower() for m in r["missing"])
|
|
|
|
|
|
def test_retire_avg_not_met_unauthorized():
|
|
with tempfile.TemporaryDirectory() as t:
|
|
m1p, cmp, avgp = _reports(Path(t), avg=False)
|
|
r = A.retire_authorization(m1p, cmp, avgp, _REQ)
|
|
assert r["authorized"] is False and r["conditions"]["autoVsGoldenAligned"] is False
|
|
assert any("auto-vs-golden" in m or "auto" in m.lower() for m in r["missing"])
|
|
|
|
|
|
def test_retire_missing_report_file_unauthorized():
|
|
"""任一报告文件缺失 → 前置证据缺失、不静默当通过。"""
|
|
with tempfile.TemporaryDirectory() as t:
|
|
m1p, cmp, avgp = _reports(Path(t))
|
|
r = A.retire_authorization(m1p, cmp, Path(t) / "nonexistent.json", _REQ)
|
|
assert r["authorized"] is False and r["conditions"]["autoVsGoldenAligned"] is False
|
|
assert any("缺失" in m or "证据" in m for m in r["missing"])
|
|
|
|
|
|
def test_retire_deterministic_zero_llm():
|
|
with tempfile.TemporaryDirectory() as t:
|
|
m1p, cmp, avgp = _reports(Path(t))
|
|
assert A.retire_authorization(m1p, cmp, avgp, _REQ) == A.retire_authorization(m1p, cmp, avgp, _REQ)
|
|
|
|
|
|
# ───────────────────────── U2 同游戏双驱动 play 编排(mock · 不真跑模型)─────────────────────────
|
|
|
|
def _patch(obj, name, val, saved):
|
|
saved.append((obj, name, getattr(obj, name)))
|
|
setattr(obj, name, val)
|
|
|
|
|
|
def _restore(saved):
|
|
for obj, name, old in saved:
|
|
setattr(obj, name, old)
|
|
|
|
|
|
def test_dual_orchestration_order_auto_before_golden():
|
|
"""编排序 = gen-only → smoke → ensure_play_spec → play(auto) → inject_golden → play(golden);
|
|
auto_gates 在金标注入之前捕获(两次 play 返不同 verdict 可区分)。"""
|
|
import cheap_studio
|
|
import cheap_run
|
|
import compare_node
|
|
calls = []
|
|
saved = []
|
|
with tempfile.TemporaryDirectory() as t:
|
|
td = Path(t)
|
|
|
|
async def fake_gen(gid, brief, **kw):
|
|
calls.append("gen")
|
|
|
|
plays = iter([
|
|
{"verdict": {"guards": {"E_live": {"pass": True}, "H_progress": {"pass": True}}}}, # auto
|
|
{"verdict": {"guards": {"E_live": {"pass": False}, "H_progress": {"pass": True}}}}, # golden
|
|
])
|
|
_patch(cheap_studio, "run_studio", fake_gen, saved)
|
|
_patch(cheap_run, "smoke", lambda gid, **kw: (calls.append("smoke") or {"ok": True, "state": {"targets": []}}), saved)
|
|
_patch(cheap_run, "ensure_play_spec", lambda gid, state: (calls.append("ensure") or {"wrote": True}), saved)
|
|
_patch(cheap_run, "play", lambda gid, **kw: (calls.append("play") or next(plays)), saved)
|
|
_patch(cheap_run, "wg1_game_dir", lambda gid: td / gid, saved) # staged 无 spec
|
|
_patch(cheap_run, "game_dir", lambda gid: td / gid, saved)
|
|
_patch(compare_node, "inject_golden", lambda gid, g: (calls.append("inject") or (td / "g")), saved)
|
|
_patch(compare_node, "golden_spec_path", lambda genre: td / "golden.json", saved)
|
|
_patch(compare_node, "src_shape", lambda d: {"realSrcMultifile": True}, saved)
|
|
try:
|
|
r = A.run_pair_dual_sync({"key": "click-score", "golden": "x.json"}, "brief", "avg-click-score-0")
|
|
finally:
|
|
_restore(saved)
|
|
|
|
assert calls == ["gen", "smoke", "ensure", "play", "inject", "play"]
|
|
# auto_gates 来自第一次 play(E_live=True),golden_gates 来自第二次(E_live=False)。
|
|
assert r["autoGates"]["E_live"] is True and r["goldenGates"]["E_live"] is False
|
|
assert r["gid"] == "avg-click-score-0" and r["realSrc"] is True
|
|
|
|
|
|
def test_dual_raises_when_staged_spec_exists():
|
|
"""staged 残留 play-spec.json → ensure 前置断言触发、不静默用旧 spec 驱动 auto。"""
|
|
import cheap_studio
|
|
import cheap_run
|
|
saved = []
|
|
with tempfile.TemporaryDirectory() as t:
|
|
td = Path(t)
|
|
gd = td / "avg-click-score-0"
|
|
gd.mkdir(parents=True)
|
|
(gd / "play-spec.json").write_text("{}", encoding="utf-8") # 残留旧 spec
|
|
|
|
async def fake_gen(gid, brief, **kw):
|
|
pass
|
|
|
|
_patch(cheap_studio, "run_studio", fake_gen, saved)
|
|
_patch(cheap_run, "smoke", lambda gid, **kw: {"ok": True, "state": {}}, saved)
|
|
_patch(cheap_run, "wg1_game_dir", lambda gid: gd, saved)
|
|
raised = False
|
|
try:
|
|
A.run_pair_dual_sync({"key": "click-score", "golden": "x.json"}, "brief", "avg-click-score-0")
|
|
except RuntimeError:
|
|
raised = True
|
|
finally:
|
|
_restore(saved)
|
|
assert raised is True
|
|
|
|
|
|
def test_clamp_conc_upper_cap():
|
|
"""并发上限夹取 ≤15(复用 bake_off 口径)。"""
|
|
assert A._clamp_conc(100) == 15 and A._clamp_conc(0) == 1 and A._clamp_conc(3) == 3
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
|
_failed = 0
|
|
for _fn in _fns:
|
|
try:
|
|
_fn()
|
|
print(f" PASS {_fn.__name__}")
|
|
except Exception as e: # noqa: BLE001
|
|
_failed += 1
|
|
print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}")
|
|
print(f"\n{len(_fns) - _failed}/{len(_fns)} passed")
|
|
sys.exit(1 if _failed else 0)
|