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>
421 lines
20 KiB
Python
421 lines
20 KiB
Python
"""
|
||
compare_node.py — 便宜档 Python 路 vs Node 旧路对照验证。
|
||
|
||
【封存(W-AXIS-V2 波2,2026-07-10)】对照范式以金标 play-spec 驱动九门(H_progress 为可玩门);play-spec/
|
||
驱动器已随取证契约退出便宜档验收链,Node 旧路亦已退役——两路对照使命完结。保留代码供历史复跑;
|
||
生产链不再调用,play-spec 生成器窗口期结束后随其一并清理。
|
||
|
||
两层用法:
|
||
· 单品类对照(spike U6 起点):compare_logic / build_report / main_async —— 同 brief 各跑一次,纯逻辑见下。
|
||
· 三品类批量对照(WU-A 后续 U2):run_multi —— 三个 tap-targets 代表品类(点击得分/打地鼠/经营点客)各 n=3–5,
|
||
用每品类金标 play-spec 喂两路 held constant、把驱动器从对照变量里摘掉,按两层判据 + 逐品类绝对地板聚合,
|
||
产"框架等价证据(金标 spec 下)"。
|
||
|
||
对照公平的关键(KTD3 + Codex C1/C4 + Opus O1):
|
||
· 金标 spec 强制覆写到 staged 路径 games/_wg1-gen/<id>/play-spec.json(play.cdp.cjs 真实读取处,非 evidence 目录),
|
||
在「生成」与「九门 play」之间注入 —— 两路同用一份驱动器,过门率差才只反映生成质量。
|
||
· Python 路用 run_studio(run_gates=False) 做 generation-only(内部不 play),Node 路 gen.mjs 本就不 play;
|
||
两路都「生成 → 注入金标 → 单独 play」。
|
||
· 成功定义 = 坐实「Python ≈ Node、范式未回退」(金标 spec 下),≠「过九门」、≠「生产退役许可」(后者另 gate
|
||
在 WU-C 自动 spec 达金标同等过门率)。
|
||
|
||
CLI:cheap-worker/.venv/bin/python cheap-worker/compare_node.py [--genres click-score,whack-mole,shop-serve] [--n 3]
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import statistics
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # → cheap-worker/
|
||
import _bootstrap # noqa: E402,F401 跨包 sys.path 兜底
|
||
import cheap_run # noqa: E402
|
||
import cheap_studio # noqa: E402
|
||
|
||
_GOLDEN_DIR = Path(__file__).resolve().parent / "fixtures" / "golden-specs"
|
||
_RESULTS_DIR = Path(__file__).resolve().parent / "results"
|
||
|
||
# 三个 tap-targets 代表品类(plan KTD2):brief 取自 base1/base2/base4 run-summary,金标 spec 见 fixtures/golden-specs/。
|
||
GENRES = [
|
||
{"key": "click-score", "base": "base1", "golden": "click-score.play-spec.json"},
|
||
{"key": "whack-mole", "base": "base2", "golden": "whack-mole.play-spec.json"},
|
||
{"key": "shop-serve", "base": "base4", "golden": "shop-serve.play-spec.json"},
|
||
]
|
||
_GENRE_BY_KEY = {g["key"]: g for g in GENRES}
|
||
|
||
# 判据阈值(R3 两层 + 逐品类绝对地板 + 双低标红)。
|
||
_EPS = 1e-9
|
||
_PLAY_GATE = "H_progress" # 「真玩起来了」的可玩门 = 有真实游戏进展(driven 后 H 过)。
|
||
_DOUBLE_LOW_PLAY = 0.5 # 两路真玩比例都 < 此 → 双低、对照不充分、标红。
|
||
_FLOOR_PLAY = 0.6 # 坐实绝对地板:Python 须在 ≥60% 局真玩起来(带余量,不只 ≥Node)。
|
||
_MAX_N = 5 # n>5 小批口径(plan R5),上限夹 5、不滑向 n≥30 统计批跑。
|
||
|
||
|
||
# ───────────────────────── 纯逻辑(可单测,不依赖真跑)─────────────────────────
|
||
|
||
def gates_from_verdict(verdict) -> dict:
|
||
"""从九门 verdict.json 抽 {gate: pass_bool}。"""
|
||
if not isinstance(verdict, dict):
|
||
return {}
|
||
guards = verdict.get("guards") or {}
|
||
return {k: bool(g.get("pass")) for k, g in guards.items() if isinstance(g, dict)}
|
||
|
||
|
||
def src_shape(game_dir: Path) -> dict:
|
||
"""产物形态:是否真 src/ 多文件工程(game-logic.js 在 + ≥2 个 .js)。"""
|
||
src = game_dir / "src"
|
||
js = list(src.glob("*.js")) if src.exists() else []
|
||
return {"realSrcMultifile": (src / "game-logic.js").exists() and len(js) >= 2, "jsCount": len(js)}
|
||
|
||
|
||
def _pass_rate(gates: dict) -> float:
|
||
return round(sum(1 for v in gates.values() if v) / len(gates), 3) if gates else 0.0
|
||
|
||
|
||
def compare_logic(py_gates: dict, node_gates: dict, py_real_src: bool) -> dict:
|
||
"""单品类对照判定(spike U6 起点):Python 产真 src/ 多文件 且 九门过门率不低于 Node → 坐实。"""
|
||
py_rate = _pass_rate(py_gates)
|
||
node_rate = _pass_rate(node_gates)
|
||
spike_pass = bool(py_real_src and py_rate >= node_rate)
|
||
return {
|
||
"pyPassRate": py_rate,
|
||
"nodePassRate": node_rate,
|
||
"rateDelta": round(py_rate - node_rate, 3),
|
||
"pyRealSrcMultifile": py_real_src,
|
||
"spikePass": spike_pass,
|
||
"verdict": "范式可移植坐实(Python 产真 src/ 多文件且九门过门率不低于 Node)" if spike_pass
|
||
else "未达标(Python 产物非真 src/ 多文件,或九门过门率低于 Node)",
|
||
}
|
||
|
||
|
||
def _played(gates: dict) -> bool:
|
||
"""这一局是否真玩起来了 = H_progress 过(有真实游戏进展;两路同卡菜单时此门为 False)。"""
|
||
return gates.get(_PLAY_GATE) is True
|
||
|
||
|
||
def _mean(xs) -> float:
|
||
xs = list(xs)
|
||
return round(statistics.fmean(xs), 3) if xs else 0.0
|
||
|
||
|
||
def judge_genre(py_runs: list, node_runs: list) -> dict:
|
||
"""逐品类两层判据 + 绝对地板 + 双低标红(R3)。
|
||
|
||
py_runs/node_runs:每元素 = {"gates": {门: pass_bool}, "realSrc": bool}(一局)。
|
||
|
||
status:
|
||
· inconclusive(标红):两路真玩比例都 < 0.5 —— 两路都没真玩起来,对照不充分,不计坐实。
|
||
· equivalent(坐实):Python 每局产真 src/ 且 过门率不低于 Node(相对层)且 Python 真玩比例 ≥0.6(绝对地板)。
|
||
· regressed:Python 明显差(非真 src/ / 过门率低 / 真玩比例不达地板)。
|
||
"""
|
||
py_rate = _mean(_pass_rate(r["gates"]) for r in py_runs)
|
||
node_rate = _mean(_pass_rate(r["gates"]) for r in node_runs)
|
||
py_play = _mean(1.0 if _played(r["gates"]) else 0.0 for r in py_runs)
|
||
node_play = _mean(1.0 if _played(r["gates"]) else 0.0 for r in node_runs)
|
||
py_real = bool(py_runs) and all(r.get("realSrc") for r in py_runs)
|
||
|
||
relative_ok = py_rate >= node_rate - _EPS
|
||
double_low = py_play < _DOUBLE_LOW_PLAY and node_play < _DOUBLE_LOW_PLAY
|
||
floor_ok = py_play >= _FLOOR_PLAY
|
||
|
||
if double_low:
|
||
status, reason = "inconclusive", "两路真玩比例都低(同卡/同低分)→ 对照不充分、标红、不计坐实"
|
||
elif py_real and relative_ok and floor_ok:
|
||
status, reason = "equivalent", "Python 产真 src/ + 过门率不低于 Node + 真玩比例达绝对地板 → 坐实等价"
|
||
else:
|
||
bits = []
|
||
if not py_real:
|
||
bits.append("Python 非每局真 src/")
|
||
if not relative_ok:
|
||
bits.append(f"过门率低于 Node({py_rate}<{node_rate})")
|
||
if not floor_ok:
|
||
bits.append(f"Python 真玩比例 {py_play}<{_FLOOR_PLAY} 地板")
|
||
status, reason = "regressed", "回退:" + "、".join(bits)
|
||
|
||
return {
|
||
"pyPassRate": py_rate, "nodePassRate": node_rate, "rateDelta": round(py_rate - node_rate, 3),
|
||
"pyPlayRatio": py_play, "nodePlayRatio": node_play, "pyRealSrcAll": py_real,
|
||
"doubleLowRed": double_low, "status": status, "reason": reason,
|
||
}
|
||
|
||
|
||
def _read_verdict(game_id: str):
|
||
vp = cheap_run.wg1_game_dir(game_id) / "evidence" / "verdict.json"
|
||
if vp.exists():
|
||
try:
|
||
return json.loads(vp.read_text(encoding="utf-8"))
|
||
except json.JSONDecodeError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _clamp_n(n: int) -> int:
|
||
"""n>5 小批口径:夹到 [1, 5],不滑向 n≥30 统计批跑(plan R5)。"""
|
||
try:
|
||
n = int(n)
|
||
except (TypeError, ValueError):
|
||
n = 3
|
||
return max(1, min(_MAX_N, n))
|
||
|
||
|
||
# ───────────────────────── 金标 spec 注入(staged 路径、强制覆写)─────────────────────────
|
||
|
||
def golden_spec_path(genre: dict) -> Path:
|
||
return _GOLDEN_DIR / genre["golden"]
|
||
|
||
|
||
def _write_spec_atomic(dst_path: Path, content: str) -> None:
|
||
"""原子写 play-spec.json(写 tmp 后 rename,防 play 读半成品)。"""
|
||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = dst_path.parent / (dst_path.name + ".tmp")
|
||
tmp.write_text(content, encoding="utf-8")
|
||
tmp.replace(dst_path)
|
||
|
||
|
||
def inject_golden(game_id: str, golden: Path) -> Path:
|
||
"""强制覆写金标 spec 到 staged games/_wg1-gen/<id>/play-spec.json(play.cdp.cjs 真实读取处)。
|
||
|
||
与 ensure_play_spec 的「不覆盖」不同:这里**强制覆写**两路各自生成时产的自动 spec,把驱动器恒定到金标
|
||
—— 这是对照公平的落点(Codex C1/C4)。返回写入的 staged spec 路径。
|
||
"""
|
||
dst = cheap_run.wg1_game_dir(game_id) / "play-spec.json"
|
||
_write_spec_atomic(dst, golden.read_text(encoding="utf-8"))
|
||
return dst
|
||
|
||
|
||
# ───────────────────────── 真跑两路(生成 / play 拆步、金标注入、串行隔离)─────────────────────────
|
||
|
||
def load_brief(genre: dict) -> str:
|
||
"""品类 brief 取自 base 样本 run-summary(单一来源);缺失则空串(由调用方兜底)。"""
|
||
rs = cheap_run.game_dir(genre["base"]) / "evidence" / "run-summary.json"
|
||
if rs.exists():
|
||
try:
|
||
return json.loads(rs.read_text(encoding="utf-8")).get("brief") or ""
|
||
except json.JSONDecodeError:
|
||
pass
|
||
return ""
|
||
|
||
|
||
def _gen_node(brief: str, game_id: str, port: int = 4320, cdp_port: int = 9222,
|
||
timeout: float = 900.0) -> dict:
|
||
"""Node 旧路 generation-only:gen.mjs --mode saa(scaffold+stage+done 门 smoke,SAA 不跑九门)。不在此 play。
|
||
|
||
传 --port/--cdp:gen.mjs done 门内部 smoke-boot 用这俩端口(行 142-143/249),并发时每槽独立端口防撞。
|
||
"""
|
||
r = subprocess.run(
|
||
["node", str(cheap_run._AMODEL_GEN / "gen.mjs"), "--mode", "saa", "--game-id", game_id,
|
||
"--brief", brief, "--port", str(port), "--cdp", str(cdp_port)],
|
||
cwd=str(cheap_run._GAME_RUNTIME), capture_output=True, text=True, timeout=timeout,
|
||
env=cheap_run._shell_env(), check=False,
|
||
)
|
||
return {"rc": r.returncode, "raw": (r.stdout + r.stderr)[-1200:]}
|
||
|
||
|
||
def run_pair_sync(genre: dict, brief: str, py_id: str, node_id: str,
|
||
port: int = 4320, cdp_port: int = 9222) -> dict:
|
||
"""一局对照(同步、线程内整对跑):两路各 generation-only → 注入同一份金标 spec → 各自单独 play。
|
||
|
||
设计为同步,由 run_multi 经 asyncio.to_thread 并发调度(每对一个线程、独立端口);线程内 run_studio 用自带
|
||
event loop(asyncio.run)。返回逐门 + 形态。
|
||
"""
|
||
golden = golden_spec_path(genre)
|
||
|
||
# Python 路:generation-only(run_gates=False,内部不 play)→ 注入金标 → play。
|
||
asyncio.run(cheap_studio.run_studio(py_id, brief, run_gates=False, port=port, cdp_port=cdp_port))
|
||
inject_golden(py_id, golden)
|
||
py_play = cheap_run.play(py_id, port=port, cdp_port=cdp_port)
|
||
|
||
# Node 路:gen.mjs --mode saa(不 play,内部 smoke 用同槽端口)→ 注入金标(覆写自动 spec)→ play。
|
||
node_gen = _gen_node(brief, node_id, port=port, cdp_port=cdp_port)
|
||
inject_golden(node_id, golden)
|
||
node_play = cheap_run.play(node_id, port=port, cdp_port=cdp_port)
|
||
|
||
py_gates = gates_from_verdict(py_play.get("verdict"))
|
||
node_gates = gates_from_verdict(node_play.get("verdict"))
|
||
return {
|
||
"pyId": py_id, "nodeId": node_id, "nodeGenRc": node_gen["rc"],
|
||
"py": {"gates": py_gates, "realSrc": src_shape(cheap_run.game_dir(py_id))["realSrcMultifile"]},
|
||
"node": {"gates": node_gates, "realSrc": src_shape(cheap_run.game_dir(node_id))["realSrcMultifile"]},
|
||
}
|
||
|
||
|
||
def aggregate(genre_runs: dict) -> dict:
|
||
"""按品类聚合:每品类的多局 runs → judge_genre 结论 + 顶层退役就绪范围。
|
||
|
||
genre_runs: {genreKey: [run, ...]},run = run_pair 返回。
|
||
"""
|
||
per_genre = []
|
||
for key, runs in genre_runs.items():
|
||
py_runs = [r["py"] for r in runs]
|
||
node_runs = [r["node"] for r in runs]
|
||
judge = judge_genre(py_runs, node_runs)
|
||
per_genre.append({"genre": key, "n": len(runs), "judge": judge,
|
||
"runs": [{"pyId": r["pyId"], "nodeId": r["nodeId"],
|
||
"pyGates": r["py"]["gates"], "nodeGates": r["node"]["gates"]} for r in runs]})
|
||
equivalent = [g["genre"] for g in per_genre if g["judge"]["status"] == "equivalent"]
|
||
inconclusive = [g["genre"] for g in per_genre if g["judge"]["status"] == "inconclusive"]
|
||
regressed = [g["genre"] for g in per_genre if g["judge"]["status"] == "regressed"]
|
||
return {
|
||
"perGenre": per_genre,
|
||
"summary": {
|
||
"equivalentGenres": equivalent,
|
||
"inconclusiveGenres": inconclusive,
|
||
"regressedGenres": regressed,
|
||
# 「框架等价证据(金标 spec 下)」:坐实等价的品类 = Node 旧路在该品类可退役就绪(金标 spec 下);
|
||
# 面向生产的退役授权另 gate 在 WU-C 自动 spec 达金标同等过门率(auto-vs-golden),不在本计划。
|
||
"retireReadyUnderGolden": equivalent,
|
||
"note": "本对照在金标 spec 下采证 = 框架等价;生产退役授权须另核对 WU-C 自动 spec 达金标同等(plan 对账表)",
|
||
},
|
||
}
|
||
|
||
|
||
def _pair_ids(genre_key: str, k: int) -> tuple:
|
||
"""一局两路的 gameId(前缀隔离:cheap-* vs node-*,品类×k 不撞)。"""
|
||
return f"cheap-{genre_key}-{k}", f"node-{genre_key}-{k}"
|
||
|
||
|
||
def _port_pool(conc: int, base_port: int = 4320, base_cdp: int = 9222) -> list:
|
||
"""每并发槽一对独立 (server_port, cdp_port):两两不撞、server 段(43xx)与 cdp 段(92xx)不重叠。
|
||
|
||
并发的真正约束 = 九门 play/smoke 的固定端口 + Chrome 实例;每槽独立端口才能并发不撞(gameId 前缀已隔离产物)。
|
||
"""
|
||
conc = max(1, conc)
|
||
return [(base_port + i * 2, base_cdp + i * 2) for i in range(conc)]
|
||
|
||
|
||
def _next_index(stems: list) -> int:
|
||
"""从已有 compare-multi-<N> 文件名算下一个序号(不覆盖历史)。"""
|
||
nums = [int(s.rsplit("-", 1)[-1]) for s in stems if s.rsplit("-", 1)[-1].isdigit()]
|
||
return (max(nums) + 1) if nums else 1
|
||
|
||
|
||
def _next_report_path() -> Path:
|
||
"""报告分批次文件、不覆盖历史(plan U2)。"""
|
||
_RESULTS_DIR.mkdir(exist_ok=True)
|
||
idx = _next_index([p.stem for p in _RESULTS_DIR.glob("compare-multi-*.json")])
|
||
return _RESULTS_DIR / f"compare-multi-{idx}.json"
|
||
|
||
|
||
def print_multi_report(report: dict) -> None:
|
||
print("\n=== 三品类对照报告:Python 新路 vs Node 旧路(金标 spec 下)===", file=sys.stderr)
|
||
for g in report["perGenre"]:
|
||
j = g["judge"]
|
||
flag = " ⛔RED" if j["doubleLowRed"] else ""
|
||
print(f"[{g['genre']}] n={g['n']} status={j['status']}{flag} "
|
||
f"py_rate={j['pyPassRate']} node_rate={j['nodePassRate']} "
|
||
f"py_play={j['pyPlayRatio']} node_play={j['nodePlayRatio']} — {j['reason']}", file=sys.stderr)
|
||
s = report["summary"]
|
||
print(f"\n>>> 坐实等价(金标 spec 下,Node 可退役就绪): {s['equivalentGenres']}", file=sys.stderr)
|
||
print(f">>> 对照不充分(标红): {s['inconclusiveGenres']} 回退: {s['regressedGenres']}", file=sys.stderr)
|
||
print(f">>> 注:{s['note']}\n", file=sys.stderr)
|
||
|
||
|
||
async def run_multi(genre_keys: list, n: int, conc: int = 1,
|
||
base_port: int = 4320, base_cdp: int = 9222) -> dict:
|
||
"""三品类 × n 对照:逐局生成→注入金标→play,按品类聚合。
|
||
|
||
conc=1 串行;conc>1 有界并发 —— 端口池(每槽独立 port/cdp)+ 信号量限并发(≤conc 同时跑),每对经
|
||
asyncio.to_thread 在独立线程跑(线程内 run_studio 自带 loop)。前台进程内有界并发,非后台子代理。
|
||
"""
|
||
n = _clamp_n(n)
|
||
conc = max(1, conc)
|
||
pool = asyncio.Queue() # 端口池:size=conc,get 空则阻塞 → 天然限并发到 ≤conc
|
||
for pp in _port_pool(conc, base_port, base_cdp):
|
||
pool.put_nowait(pp)
|
||
|
||
# 收集每品类的 runs(线程并发写不同 key 的 list,key 互斥、无共享写)。
|
||
genre_runs = {}
|
||
valid = []
|
||
for key in genre_keys:
|
||
genre = _GENRE_BY_KEY.get(key)
|
||
if genre is None:
|
||
print(f"[skip] 未知品类 {key}", file=sys.stderr)
|
||
continue
|
||
brief = load_brief(genre)
|
||
if not brief:
|
||
print(f"[skip] {key} 无 brief(base {genre['base']} run-summary 缺失)", file=sys.stderr)
|
||
continue
|
||
valid.append((key, genre, brief))
|
||
genre_runs[key] = []
|
||
|
||
async def one(key, genre, brief, k):
|
||
py_id, node_id = _pair_ids(key, k)
|
||
port, cdp = await pool.get() # 取一对独立端口(无空闲则等)
|
||
try:
|
||
print(f"[{key} {k + 1}/{n}] py={py_id} node={node_id} port={port}/{cdp} 生成→注入金标→play …",
|
||
file=sys.stderr)
|
||
r = await asyncio.to_thread(run_pair_sync, genre, brief, py_id, node_id, port, cdp)
|
||
genre_runs[key].append(r)
|
||
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, genre, brief, k) for (key, genre, brief) in valid for k in range(n)]
|
||
print(f"[run] {len(valid)} 品类 × n={n} = {len(tasks)} 对,conc={conc}(端口段 {base_port}.. / {base_cdp}..)",
|
||
file=sys.stderr)
|
||
await asyncio.gather(*tasks)
|
||
genre_runs = {k: v for k, v in genre_runs.items() if v} # 去掉全失败的品类
|
||
|
||
report = aggregate(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_multi_report(report)
|
||
return report
|
||
|
||
|
||
# ───────────────────────── 单品类对照(spike U6 起点,保留)─────────────────────────
|
||
|
||
async def run_python(brief: str, game_id: str) -> dict:
|
||
"""Python 新路单局(spike:run_studio 内部已跑九门)。"""
|
||
t0 = time.time()
|
||
summary = await cheap_studio.run_studio(game_id, brief)
|
||
return {"summary": summary, "wallSec": round(time.time() - t0, 1)}
|
||
|
||
|
||
def run_node(brief: str, game_id: str) -> dict:
|
||
"""Node 旧路单局(spike:gen.mjs --mode saa,再跑同款九门 play 用自动 spec)。"""
|
||
t0 = time.time()
|
||
gen = _gen_node(brief, game_id)
|
||
pr = cheap_run.play(game_id)
|
||
return {"nodeRC": gen["rc"], "wallSec": round(time.time() - t0, 1), "playVerdict": pr["verdict"], "raw": gen["raw"]}
|
||
|
||
|
||
def build_report(brief: str, py_id: str, node_id: str) -> dict:
|
||
py_v = _read_verdict(py_id)
|
||
node_v = _read_verdict(node_id)
|
||
py_gates = gates_from_verdict(py_v)
|
||
node_gates = gates_from_verdict(node_v)
|
||
py_shape = src_shape(cheap_run.game_dir(py_id))
|
||
node_shape = src_shape(cheap_run.game_dir(node_id))
|
||
cmp = compare_logic(py_gates, node_gates, py_shape["realSrcMultifile"])
|
||
return {
|
||
"brief": brief, "pyId": py_id, "nodeId": node_id,
|
||
"pyGates": py_gates, "nodeGates": node_gates,
|
||
"pyShape": py_shape, "nodeShape": node_shape,
|
||
"pyOverallPass": bool(py_v and py_v.get("pass")),
|
||
"nodeOverallPass": bool(node_v and node_v.get("pass")),
|
||
"compare": cmp,
|
||
}
|
||
|
||
|
||
# ───────────────────────── CLI ─────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
ap = argparse.ArgumentParser(description="便宜档 Python 路 vs Node 旧路三品类对照验证(金标 spec 下)")
|
||
ap.add_argument("--genres", default="click-score,whack-mole,shop-serve",
|
||
help="逗号分隔品类键(默认三 tap-targets 代表品类)")
|
||
ap.add_argument("--n", type=int, default=3, help="每品类重复次数(n>5 小批,上限夹 5)")
|
||
ap.add_argument("--conc", type=int, default=1, help="并发对数(1=串行;>1 端口池+线程有界并发,本机 Chrome 建议 ≤4)")
|
||
a = ap.parse_args()
|
||
keys = [k.strip() for k in a.genres.split(",") if k.strip()]
|
||
rep = asyncio.run(run_multi(keys, a.n, conc=a.conc))
|
||
# 退出码:有任一品类坐实等价即 0(机制验证,非达标判定)。
|
||
sys.exit(0 if rep["summary"]["equivalentGenres"] else 1)
|