lili e6525aaa3d feat(cheap-worker): 便宜档生成丰富度 + M1 达标门富化重验(切片一 A/B)
接 sim-business 设计指导到生成 agent(cheap_roles+prompt.mjs 双源,先设计后写码+MVP-first);新建纯 LLM 丰富度验证 agent cheap_verify(非阻塞·不进 verdict·零 code-presence 断言,红线落地);bake_off 加 richness 列;cheap_roles 三级回落配置热取(C2a 加载器);cheap_studio trace 接线(C1b)。富游戏 M1 三品类各 5/5=100% 过九门达标。测试 test_roles 12 / test_cheap_verify 11 / test_bake_off 17 / test_cheap_trace 4 全绿。

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

240 lines
13 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=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_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,非阻塞)
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)
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)