lili 8d851ea716
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(cheap-gen): W-AXIS-V2 波2——取证契约退役+提示全加载链清洗,四门∧测试员为唯一验收链
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>
2026-07-10 09:21:04 -07:00

277 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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=12)先联调聚合/判定逻辑;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;小批联调用 12)")
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)