lili aaa9461bd9 feat(cheap-worker): bake-off 达标门改质量口径 + 编排未收敛单列(M1 U3 收口)
创始人 2026-06-27 定:达标门只衡量九门质量地板,编排未收敛(M3 撞
timeout/step_cap、无 verdict)剔出达标率分母、单列 unconvergedTotal 归 M2
编排硬化。judge_genre 分母改 converged 款,rawPassRate 并报(贴近生产真实
交付率、不作判据);converged==0 → insufficient。

实测合并 14 款 click-score(batch2 满21 + batch3 补7 offset7):质量
11/12=0.917 达标(原始 0.786,2 款未收敛=timeout+step_cap),whack-mole
7/7=1.0、shop-serve 6/7=0.857 → M1 三品类全过 ≥0.8、达标门绿。

bake_off 单测 12→14(加未收敛剔除 + unconvergedTotal 汇总);全套件 86 绿。
M1 合并收口报告 → cheap-worker/results/bake-off-M1-final.json(results gitignored)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:30:53 -07:00

205 lines
11 KiB
Python
Raw 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)。
代表样本(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_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 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)
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,
"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")}
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)
valid = []
genre_runs = {}
for key in genre_keys:
genre = C._GENRE_BY_KEY.get(key)
if genre is None:
print(f"[skip] 未知品类 {key}", file=sys.stderr)
continue
brief = C.load_brief(genre)
if not brief:
print(f"[skip] {key} 无 brief(base {genre['base']} run-summary 缺失)", file=sys.stderr)
continue
valid.append((key, brief))
genre_runs[key] = []
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)
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)