opus 执行位交付、主会话四步验收过(三套测试亲跑/红线 diff 亲读/冒烟截图亲眼): - 验收消费:check 新增源码级插件调用存在断言(_pluginCallPresenceErrors 词法层, 接替 F_wiring 真接线护栏);_forensicsView「必须实现」拦截摘除,降级为可选调试 接口(handleTap 输入契约与 ctx.log 日志纪律保留);gate_judge 放行措辞对齐四门 - play-spec/驱动器退役(逐件裁定 10 件):gen.mjs done 门自动产 play-spec 摘除、 cheap_gates/cheap_studio/cheap_modify/service_driver 调用摘除、_read_driver_type 改读 evidence/playtest/playtest.json;golden-spec 三工具(auto_vs_golden/bake_off/ compare_node)显式封存;ensure_play_spec 两实现保留一个版本窗口(只摘调用不删码) - prompt 全加载链清洗:cheap-system.md v1.6.3→1.7.0(删 _forensicsView 自动验收 契约红线与全部 targets/occupied 语义,五法句/品类指路同步),cheap_roles 内置 回退逐字节同步(test_roles 12/12 证 parity),modify 切片经运行时切块自动同步; 5 品类 skills+littlejs 手册+recipes×2+模板 8 件+README 6 件教学清洗,真设计 价值(单击 casual/可解性质/双层反馈/latch)保留 - 批账:hard_genre_batch/xtheme 行加 playtest 段,_row_total_cost 修 shadow 下 测试员成本漏算(v2 取 max 防双计) 验收(主会话亲验):pytest cheap-worker 512 绿 + tier2 141 绿 + node 49/49; 25 局基线新旧口径重放对照——真坏 5 局全拦(floor 拦 2/测试员拦 3)、无一例旧拦 新放,翻案 2 局均为破案坐实的仪器误杀;n=3 冒烟(mode=v2)全链绿:w2v2-sim harness verdict.pass=True 而测试员 reject(机械放行测试员逮住,盘上双证)、 w2v2-sim-fix 现象反馈两轮回喂→fix2 收敛(配方显示修成,局内截图亲眼); rg 零残留(模板残留均为「验收不读它」的降级调试接口合规形态);真跑 ¥8.4。 已知余项:repairFeedback 有产地、Service 续修环消费者未接(测试员 reject 不入 RepairMiddleware,续修判据仍四门)——是否接入随波3 校准数据一并裁;v1 配置级 回退窗口关闭为创始人知悉项,genconfig 默认 mode 仍 shadow、切 v2 归创始人。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 lines
16 KiB
Python
277 lines
16 KiB
Python
"""
|
||
bake_off.py — 便宜档 ≥80% 达标门(按品类 · 零 LLM)。
|
||
|
||
【封存(W-AXIS-V2 波2,2026-07-10)】本工具以 verdict.pass(driven 九门)为达标判据,依赖 ensure_play_spec
|
||
自动 spec 驱动;驱动器已随取证契约退出便宜档验收链。v2 的达标口径 = accepted(四门投影 ∧ 测试 agent 真玩),
|
||
批量达标测量由 hard_genre_batch / hard_genre_xtheme(已切新口径)承担。保留代码供历史复跑;不再作达标门。
|
||
|
||
代表样本(tap-targets occupied 三品类:点击得分/打地鼠/经营点客)各 n 款,每款:
|
||
run_studio(run_gates=True) 生成 → 自动 spec(U2 补 prefixes)驱动九门 → 取 verdict.pass。
|
||
按品类聚合 pass_count/total,逐品类 ≥0.8 判达标;任一品类缺样本报「覆盖不足」、任一 <0.8 整体未达标
|
||
——绝不用总体平均掩盖卡死品类。判定纯走确定性九门 verdict.pass,不调任何 LLM 当裁判(防 Goodhart + 可复现)。
|
||
|
||
真跑前台进程内有界并发(端口池 + 线程,复用 compare_node)、conc≤15、gameId 品类前缀隔离、报告不覆盖。
|
||
小批(每品类 n=1–2)先联调聚合/判定逻辑;M3 主模型 20 款实测(约每品类 7)为 M1 收口硬判据(plan U3),
|
||
小批不替代收口。本机/实验室口径,生产真实分布达标在 M3 复验。
|
||
|
||
CLI:cheap-worker/.venv/bin/python cheap-worker/bake_off.py [--genres click-score,whack-mole,shop-serve] [--n 7] [--conc 3]
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # → cheap-worker/
|
||
import _bootstrap # noqa: E402,F401 跨包 sys.path + key 兜底
|
||
import cheap_run # noqa: E402 brief 期望路径构造(缺失清单用,与 compare_node.load_brief 同源)
|
||
import cheap_studio # noqa: E402
|
||
import compare_node as C # noqa: E402 复用 GENRES / load_brief / _port_pool / _next_index / _GENRE_BY_KEY
|
||
|
||
_RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||
_PASS_THRESHOLD = 0.8 # 逐品类达标线(plan R3 ≥80%)
|
||
_CONC_CAP = 15 # 并发硬上限(plan R5;便宜档容量画像:瓶颈是 Chrome/esbuild 的 CPU 突发)
|
||
|
||
|
||
# ───────────────────────── 纯逻辑(可单测,不依赖真跑、零 LLM)─────────────────────────
|
||
|
||
def judge_genre(runs: list) -> dict:
|
||
"""单品类达标判定(质量口径,创始人 2026-06-27 定)。
|
||
|
||
runs = [{"passed": bool, "finished": bool}, ...]。区分两类失败:
|
||
· **编排未收敛**(finished=False:M3 撞 timeout/step_cap,根本没产出可玩游戏、无九门 verdict)
|
||
—— 属生成稳定性,有独立编排闸在拦,归 M2 编排硬化,**不计入质量达标率分母**、单列 unconverged。
|
||
· **九门质量挂**(finished=True 但 verdict.pass=False)—— 真·游戏不可玩,计入失败。
|
||
|
||
`passRate` = 质量口径 = passed / converged(分母只数 finished 款),据它判 ≥0.8。
|
||
`rawPassRate` = 原始口径 = passed / total(含未收敛,贴近生产真实交付率,供对照、不作判据)。
|
||
converged==0(无任何款收敛产出 verdict)→ insufficient,质量无从判。
|
||
"""
|
||
total = len(runs)
|
||
unconverged = sum(1 for r in runs if not r.get("finished"))
|
||
converged = total - unconverged
|
||
passed = sum(1 for r in runs if r.get("finished") and r.get("passed"))
|
||
raw_rate = round(passed / total, 3) if total else 0.0
|
||
base = {"total": total, "converged": converged, "unconverged": unconverged,
|
||
"passed": passed, "rawPassRate": raw_rate}
|
||
if converged == 0:
|
||
return {**base, "passRate": 0.0, "status": "insufficient", "meets": False}
|
||
rate = round(passed / converged, 3)
|
||
meets = rate >= _PASS_THRESHOLD
|
||
return {**base, "passRate": rate, "status": "meets" if meets else "below", "meets": meets}
|
||
|
||
|
||
def richness_dist(runs: list) -> dict:
|
||
"""单品类丰富度分布(U-B1 · 纯报告 · **与达标判定完全正交**)。
|
||
|
||
richness 是 LLM judge(cheap_verify)读产物源码后给的非阻塞质量信号(命中数 0..8),
|
||
与九门 verdict.pass 的达标判定**互不影响** —— 本函数只统计分布、绝不参与 meets。
|
||
非降级款收集 score 算均分/区间;降级款(LLM 评分失败/超时/空 src)单列计数;
|
||
无 richness 字段的款(对照路 run_gates=False / 老产物)直接不计。
|
||
"""
|
||
scored = []
|
||
degraded = 0
|
||
for r in runs:
|
||
rv = r.get("richness")
|
||
if not isinstance(rv, dict):
|
||
continue # 没跑 richness → 不计入分布
|
||
if rv.get("degraded") or rv.get("score") is None:
|
||
degraded += 1
|
||
continue
|
||
try:
|
||
scored.append(int(rv["score"]))
|
||
except (TypeError, ValueError):
|
||
degraded += 1 # 脏评分值兜底为降级,绝不抛
|
||
mean = round(sum(scored) / len(scored), 2) if scored else None
|
||
return {"max": 8, "scoredCount": len(scored), "degradedCount": degraded,
|
||
"meanScore": mean, "scores": sorted(scored)}
|
||
|
||
|
||
def aggregate(genre_runs: dict, required_genres: list) -> dict:
|
||
"""按品类聚合 + 整体达标判定(质量口径)。
|
||
|
||
genre_runs = {key: [{"passed","finished"}, ...]}。整体达标 = 所有 required 核心品类
|
||
都有收敛样本(converged>0)且逐品类质量达标率 ≥0.8。任一无收敛样本(missing)或任一 <0.8(below)
|
||
即整体未达标 —— 卡死品类不被其余品类平均掩盖(plan R3 防自欺)。
|
||
|
||
编排未收敛(timeout/step_cap)按质量口径剔出分母、单列 `unconvergedTotal` 作 M2 编排稳定性指标
|
||
(创始人 2026-06-27 定:M1 达标门只衡量九门质量地板,未收敛归 M2 编排硬化)。
|
||
"""
|
||
per_genre = {k: judge_genre(genre_runs.get(k, [])) for k in required_genres}
|
||
missing = [k for k in required_genres if per_genre[k]["converged"] == 0]
|
||
below = [k for k in required_genres if per_genre[k]["converged"] > 0 and not per_genre[k]["meets"]]
|
||
overall = (not missing) and (not below)
|
||
unconverged_total = sum(per_genre[k]["unconverged"] for k in required_genres)
|
||
sample_total = sum(per_genre[k]["total"] for k in required_genres)
|
||
# U-B1:LLM 丰富度分布(非阻塞报告 · 与达标判定正交)。只观察「游戏变丰富没」,绝不改 overallMeets。
|
||
richness_by_genre = {k: richness_dist(genre_runs.get(k, [])) for k in required_genres}
|
||
return {
|
||
"perGenre": per_genre,
|
||
"requiredGenres": required_genres,
|
||
"missingGenres": missing,
|
||
"belowGenres": below,
|
||
"overallMeets": overall,
|
||
"threshold": _PASS_THRESHOLD,
|
||
"unconvergedTotal": unconverged_total, # 编排未收敛款数(M2 follow-up,不计入质量达标率)
|
||
"sampleTotal": sample_total,
|
||
"richnessByGenre": richness_by_genre, # U-B1:逐品类丰富度分布(LLM judge 非阻塞信号,不入达标判据)
|
||
"note": "质量口径(分母=收敛款,编排未收敛剔出、单列 unconvergedTotal 归 M2);"
|
||
"本机/实验室口径,生产真实分布达标在 M3 复验;判定零 LLM(纯确定性九门 verdict.pass)",
|
||
}
|
||
|
||
|
||
def _clamp_conc(conc) -> int:
|
||
"""并发夹到 [1, 15](plan R5 上限纪律:不可超 15)。"""
|
||
try:
|
||
conc = int(conc)
|
||
except (TypeError, ValueError):
|
||
conc = 1
|
||
return max(1, min(_CONC_CAP, conc))
|
||
|
||
|
||
def _next_report_path() -> Path:
|
||
"""报告分批次文件、不覆盖历史(plan U3)。"""
|
||
_RESULTS_DIR.mkdir(exist_ok=True)
|
||
idx = C._next_index([p.stem for p in _RESULTS_DIR.glob("bake-off-*.json")])
|
||
return _RESULTS_DIR / f"bake-off-{idx}.json"
|
||
|
||
|
||
# ───────────────────────── 真跑单款(run_studio 生成 + 自动 spec 九门)─────────────────────────
|
||
|
||
def run_one_sync(genre_key: str, brief: str, k: int, port: int, cdp: int) -> dict:
|
||
"""一款达标样本(同步,由 run_bakeoff 经 to_thread 并发调度):生成→自动 spec→九门,取 verdict.pass。
|
||
|
||
走生产形态(run_gates=True:run_studio 内部 ensure_play_spec 自动 spec + 九门 play),非金标注入
|
||
—— 达标门量的就是「生产自动 spec 驱动能不能稳定过九门」。
|
||
"""
|
||
gid = f"bake-{genre_key}-{k}"
|
||
summary = asyncio.run(cheap_studio.run_studio(gid, brief, run_gates=True, port=port, cdp_port=cdp))
|
||
v = summary.get("verdict") # _verdict_brief: {pass, failedGates} 或 None
|
||
breaker = summary.get("breaker") # 未收敛时 {kind, reason}:timeout / step_cap / budget
|
||
return {"gid": gid, "passed": bool(v and v.get("pass")), "finished": summary.get("finished"),
|
||
"failedGates": (v or {}).get("failedGates"),
|
||
"breakerKind": (breaker or {}).get("kind"), # 未收敛原因(归 M2 编排稳定性诊断)
|
||
"costRmb": summary.get("costRmb"),
|
||
"richness": summary.get("richness")} # U-B1:additive 丰富度评分(run_studio 已写 summary,非阻塞)
|
||
|
||
|
||
def _brief_path(genre: dict) -> Path:
|
||
"""品类 brief 的期望源路径(与 compare_node.load_brief 同源:base 样本 evidence/run-summary.json)。
|
||
|
||
仅用于缺失清单里给出「品类 → 期望路径」,便于定位是哪份 run-summary 没生成/为空(工单 i)。
|
||
"""
|
||
return cheap_run.game_dir(genre["base"]) / "evidence" / "run-summary.json"
|
||
|
||
|
||
def _resolve_briefs(genre_keys: list) -> tuple:
|
||
"""把品类键解析成 (valid, missing) —— 纯解析,零真跑、零 LLM(可单测)。
|
||
|
||
valid = [(key, brief)]:brief 源存在且非空的品类(保持输入序,与既有 run_bakeoff 收集逻辑一致)。
|
||
missing = [(key, detail)]:未知品类(detail=原因)或 brief 源缺失/为空(detail=期望 run-summary 路径)。
|
||
调用方据 valid 是否为空决定「空过陷阱兜底」是否触发(见 run_bakeoff);判定阈值/口径不涉。
|
||
"""
|
||
valid, missing = [], []
|
||
for key in genre_keys:
|
||
genre = C._GENRE_BY_KEY.get(key)
|
||
if genre is None:
|
||
missing.append((key, "未知品类(不在 GENRES 注册表)"))
|
||
continue
|
||
brief = C.load_brief(genre)
|
||
if not brief:
|
||
missing.append((key, str(_brief_path(genre))))
|
||
continue
|
||
valid.append((key, brief))
|
||
return valid, missing
|
||
|
||
|
||
async def run_bakeoff(genre_keys: list, n: int, conc: int = 1, offset: int = 0,
|
||
base_port: int = 4320, base_cdp: int = 9222) -> dict:
|
||
"""三品类 × n 达标批跑:逐款 run_studio(自动 spec 九门),按品类聚合 ≥80%。
|
||
|
||
前台进程内有界并发:端口池(每槽独立 port/cdp)+ 信号量限并发(≤conc),每款经 to_thread 独立线程跑
|
||
(线程内 run_studio 自带 event loop)。禁后台子代理 / monitor / 自我唤醒重试。
|
||
"""
|
||
conc = _clamp_conc(conc)
|
||
n = max(1, int(n))
|
||
pool = asyncio.Queue() # 端口池:size=conc,get 空则阻塞 → 天然限并发到 ≤conc
|
||
for pp in C._port_pool(conc, base_port, base_cdp):
|
||
pool.put_nowait(pp)
|
||
|
||
# brief 源解析(纯逻辑,见 _resolve_briefs):valid=有非空 brief 的品类;missing=未知/缺 brief 的品类+期望路径。
|
||
valid, missing = _resolve_briefs(genre_keys)
|
||
for key, detail in missing: # 缺失品类逐条告警(部分缺也打,便于定位是哪份 run-summary 缺;partial 不升级为全停)
|
||
print(f"[skip] {key} 无 brief(期望 {detail})", file=sys.stderr)
|
||
# 空过陷阱兜底(工单 i · S5b 第一跑生产实录):采样列表为空(所有必需品类都缺 brief)→ 立即非零退出、绝不进入判定。
|
||
# 现状会静默跳过全部品类 → 后面 aggregate(genre_runs, []) 的 overall = (not []) and (not []) = True,
|
||
# 于是在 0 款上打「达标 ✅」exit 0(无声截断)。这里在进入 gather/aggregate 前把「无样本可判」变响亮失败,
|
||
# 拒绝在 0 款上判达标。有样本但部分缺 → 上面已逐条告警、按既有逻辑继续(judge_genre/aggregate 判定口径不改)。
|
||
if not valid:
|
||
print("\n[bake-off] ✗ 无任何可判样本:所有必需品类都缺 brief,拒绝在 0 款上判达标(空过陷阱兜底)。",
|
||
file=sys.stderr)
|
||
print("[bake-off] 缺失清单(品类 → 期望 brief 源):", file=sys.stderr)
|
||
for key, detail in missing:
|
||
print(f" · {key} → {detail}", file=sys.stderr)
|
||
raise SystemExit(2) # 立即非零退出:绝不进入 aggregate,把「0 款假达标」变永久绊线
|
||
genre_runs = {key: [] for (key, _) in valid}
|
||
|
||
async def one(key, brief, k):
|
||
port, cdp = await pool.get()
|
||
try:
|
||
print(f"[{key} {k + 1}/{n}] port={port}/{cdp} 生成→自动spec→九门 …", file=sys.stderr)
|
||
r = await asyncio.to_thread(run_one_sync, key, brief, offset + k, port, cdp)
|
||
genre_runs[key].append(r)
|
||
print(f"[{key} {k + 1}/{n}] passed={r['passed']} finished={r['finished']} cost={r['costRmb']}",
|
||
file=sys.stderr)
|
||
except Exception as e: # noqa: BLE001 单款失败不拖垮整批,记录后继续
|
||
print(f"[{key} {k + 1}/{n}] 异常:{type(e).__name__}: {e}", file=sys.stderr)
|
||
finally:
|
||
pool.put_nowait((port, cdp)) # 归还端口
|
||
|
||
tasks = [one(key, brief, k) for (key, brief) in valid for k in range(n)]
|
||
print(f"[bake-off] {len(valid)} 品类 × n={n} = {len(tasks)} 款,conc={conc}(端口段 {base_port}.. / {base_cdp}..)",
|
||
file=sys.stderr)
|
||
await asyncio.gather(*tasks)
|
||
|
||
report = aggregate(genre_runs, [k for (k, _) in valid]) # 质量口径:judge_genre 直吃 run dict(剔未收敛)
|
||
report["runs"] = genre_runs
|
||
out = _next_report_path()
|
||
out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
print(f"[report] → {out}", file=sys.stderr)
|
||
print_report(report)
|
||
return report
|
||
|
||
|
||
def print_report(report: dict) -> None:
|
||
print("\n=== 便宜档 ≥80% 达标门(按品类 · 自动 spec 驱动九门 · 零 LLM · 质量口径)===", file=sys.stderr)
|
||
for k, j in report["perGenre"].items():
|
||
unc = f" +{j['unconverged']} 未收敛(剔出)" if j.get("unconverged") else ""
|
||
print(f"[{k}] 质量 {j['passed']}/{j['converged']} = {j['passRate']} {j['status']}"
|
||
f" (原始 {j['rawPassRate']}/{j['total']} 款){unc}", file=sys.stderr)
|
||
rd = report.get("richnessByGenre", {}).get(k, {}) # U-B1:丰富度只读旁注(非阻塞·不入达标)
|
||
if rd.get("scoredCount") or rd.get("degradedCount"):
|
||
deg = f" +{rd['degradedCount']} 降级" if rd.get("degradedCount") else ""
|
||
print(f" 丰富度(LLM·非阻塞)均分 {rd.get('meanScore')}/8 "
|
||
f"评分 {rd.get('scoredCount')} 款 {rd.get('scores')}{deg}", file=sys.stderr)
|
||
flag = "达标 ✅" if report["overallMeets"] else "未达标 ❌"
|
||
print(f">>> 整体:{flag}(逐品类阈值 {report['threshold']}) 缺样本:{report['missingGenres']} "
|
||
f"不达标:{report['belowGenres']}", file=sys.stderr)
|
||
print(f">>> 编排未收敛:{report.get('unconvergedTotal', 0)}/{report.get('sampleTotal', 0)} 款"
|
||
f"(timeout/step_cap,归 M2 编排硬化、不计质量分母)", file=sys.stderr)
|
||
print(f">>> 注:{report['note']}\n", file=sys.stderr)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
ap = argparse.ArgumentParser(description="便宜档 ≥80% 达标门(按品类 · 零 LLM)")
|
||
ap.add_argument("--genres", default="click-score,whack-mole,shop-serve",
|
||
help="逗号分隔品类键(默认三 tap-targets 代表品类)")
|
||
ap.add_argument("--n", type=int, default=7, help="每品类款数(20 款收口约每品类 7;小批联调用 1–2)")
|
||
ap.add_argument("--conc", type=int, default=1, help="并发款数(端口池+线程,≤15;本机 Chrome 建议 ≤4)")
|
||
ap.add_argument("--offset", type=int, default=0, help="gameId 起始索引(补跑用,避免覆盖既有 bake-<key>-<k>)")
|
||
a = ap.parse_args()
|
||
keys = [k.strip() for k in a.genres.split(",") if k.strip()]
|
||
rep = asyncio.run(run_bakeoff(keys, a.n, conc=a.conc, offset=a.offset))
|
||
# 退出码:整体达标 0、未达标 1。
|
||
sys.exit(0 if rep["overallMeets"] else 1)
|