lili eb95441dd6 fix(cheap): 收口采集重复打印幂等(工单 f)+ bake_off brief 全缺 fail-fast(工单 i)
工单 f:_CheapRunCollector._flush 在 on_reply 的 REPLY_END / except / finally 三条降级路
会被调 ≥2 次,落盘每次覆盖是幂等的(采集语义不变、driver 恒读到最新),但「收口采集落盘」
成功行重复打印纯观感噪声。加实例标记 _flush_logged 只在首次成功落盘时打,后续静默覆盖
(失败行不受此门、每次都报便于诊断)。

工单 i:run_bakeoff 对无 brief 品类 skip+continue,全缺时 valid 空 → aggregate(genre_runs,[])
的 overall=(not [])and(not [])=True → 在 0 款上打「达标 」exit 0(S5b 第一跑生产实录的
空过陷阱 / 无声截断)。抽 _resolve_briefs 纯解析 valid/missing;采样列表为空 → 立即
SystemExit(2)、stderr 打印缺失清单(品类 + 期望 run-summary 路径)、绝不进入判定;有样本
但部分缺 → 逐条告警后按既有逻辑继续(不升级为全停,judge_genre/aggregate 判定口径不改)。

测试 +6:工单 f 直接三次 _flush + 崩溃路端到端各验只打一行(capsys);工单 i 全缺 fail-fast
+缺失清单 / 正常路径行为不变 / 部分缺继续 / _resolve_briefs 拆分。全套 335 passed(329+6)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:41:56 -07:00

273 lines
16 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_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)