diff --git a/cheap-worker/cheap_studio.py b/cheap-worker/cheap_studio.py index 3d0a779d..decf9267 100644 --- a/cheap-worker/cheap_studio.py +++ b/cheap-worker/cheap_studio.py @@ -60,6 +60,54 @@ def _verdict_brief(v) -> dict: return {"pass": v.get("pass"), "failedGates": failed} +def _furthest_stage(*, finished, staged, smoke_ran, played) -> str: + """据收口流程实际到达的步,判本 run 走到的最远阶段(scaffold/code/stage/smoke/play)。 + + 取最远到达者:scaffold 恒成功(失败提前返回、不到此);finished=code 已收敛;staged=esbuild 打包成功; + smoke_ran=跑过冒烟(无论过否);played=跑过九门 play(无论过否)。供 9d trace.stage(后端审核台读)。 + """ + if played: + return "play" + if smoke_ran: + return "smoke" + if staged: + return "stage" + if finished: + return "code" + return "scaffold" + + +def build_trace_source(verdict, driver_type, attempts, stage, model) -> dict: + """组 run-summary 的 9d-trace 源维度(additive 纯函数·可单测;run_studio 收口段调)。 + + 把收口段已算出但没存进 summary 的维度结构化产出,供 result_out 组 payload["trace"]、 + 后端 D11 ReadinessScorer 据此算就绪分。纯字典构造、零外部依赖、同输入恒同输出。 + + Args: + verdict: 完整九门 verdict(含 guards,其中 guards.H_progress 是嵌套对象 {pass,checks,latch,...}); + play 没跑(finished=False / run_gates=False / smoke 失败)时传 None。 + driver_type: ensure_play_spec 产的 driver 类型(ps.get("driverType"));没产 play-spec 时 None。 + attempts: resume 轮数(int,=summary 既有字段)。本函数接收以构成完整 trace-源契约,但 repairs 由 + result_out 据 summary.attempts 派生(max(0,attempts-1)),故此处**不回写 attempts** + (守「不动既有键」)。 + stage: 本 run 走到的最远阶段(scaffold/code/stage/smoke/play 之一,_furthest_stage 产)。 + model: 终态模型名(便宜档单模型 = _bootstrap.SPIKE_MODEL)。 + + Returns: + dict(仅新增键,供 summary.update):verdictFull / driverType / models / stage。 + """ + return { + # 完整 verdict(保留 guards 嵌套,含 H_progress.pass);play 没跑 → None。U2 在 result_out 内削成 {pass,guards}。 + "verdictFull": verdict if isinstance(verdict, dict) else None, + # driver 类型(ensure_play_spec 产);没产 play-spec(对照路 run_gates=False)→ None。 + "driverType": driver_type, + # 便宜档单模型 → {code: SPIKE_MODEL} map(镜像 wg1 models 多模型 map 形态)。 + "models": {"code": model}, + # 最远阶段。 + "stage": stage, + } + + async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=16000, port=4320, cdp_port=9222, run_gates=True): """跑便宜档一局生成(scaffold → ReAct 写 src/ → done 门 → 收口 stage+smoke+九门)。返回 run-summary dict。 @@ -142,6 +190,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens= staged = False smoke_ok = None verdict = None + driver_type = None # M3b U1:ensure_play_spec 产的 driver 类型(对照路 run_gates=False 不产 → 保持 None) if finished: st = cheap_run.stage(game_id) staged = st["ok"] @@ -152,6 +201,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens= _rec(f"smoke {'PASS' if smoke_ok else 'FAIL'}(抓 state 供 play-spec)") if run_gates: ps = cheap_run.ensure_play_spec(game_id, sm.get("state")) + driver_type = ps.get("driverType") # M3b U1:driver 类型存进 trace.gatespec.driver(后端 D11 firstPlay 维) _rec(f"play-spec {'已产 driver=' + ps.get('driverType', '?') if ps.get('wrote') else ps.get('reason', '?')}") pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port) verdict = pr["verdict"] @@ -182,6 +232,12 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens= "doneSummary": session.finished.get("summary") if session.finished else None, "wallSec": round(time.time() - t0, 1), } + # ── M3b U1:additive 富化 9d trace 源维度(verdictFull/driverType/models/stage)── + # 收口段已算出但没存的维度结构化进 summary,供 result_out 组 payload["trace"]、后端 D11 算分; + # 全 additive(新增键、不动既有键),对照路(run_gates=False)verdict/driver_type 为 None 时各键自然降级。 + stage = _furthest_stage(finished=finished, staged=staged, + smoke_ran=(smoke_ok is not None), played=(verdict is not None)) + summary.update(build_trace_source(verdict, driver_type, attempts, stage, _bootstrap.SPIKE_MODEL)) ev_dir = cheap_run.game_dir(game_id) / "evidence" ev_dir.mkdir(parents=True, exist_ok=True) (ev_dir / "run-summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/cheap-worker/dedup.py b/cheap-worker/dedup.py new file mode 100644 index 00000000..a4aaf783 --- /dev/null +++ b/cheap-worker/dedup.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""dedup.py —— D9 反同质化(归一 + 精确撞重签名 + FS 登记查重)。便宜档 M3b U3。 + +【来源 = vendored 复制 wg1/gen-worker/worker/agent_loop/dedup.py 的纯逻辑】 + norm_text / compute_sig / _load_registry / _append_registry / check_similarity 全 stdlib、无外部依赖, + 逐字搬运(wg1 与 tier2/gen-worker 都不在 cheap-worker 的 import 路径上、tier2 也无 dedup,故用 vendor 复制、 + 不跨树 import,见 KTD2)。**唯一功能改动**:DEDUP_REGISTRY 指 cheap-worker 自己的 results/(天然路径隔离, + 绝不与 wg1 串台);另把日志前缀 [wg1-worker] 改 [cheap-worker](可追溯,不改逻辑)。 + +【职责】给生成结果算「相似度」字段(dupHit/titleNorm/sig/dupWith),v0 只做【精确撞重 + 归一完全相等】 + 告警,**绝不阻断生成**(九门已是硬地板挡坏游戏;D9 首版只观测,避免误杀)。撞重只 log.warn + + similarity.dupHit=true,照常回调、照常入 feed。 + +【容错】登记表损坏/读写失败 → try-catch 吞 → similarity 段降级(dupHit=None),不阻断生成。 +""" + +import hashlib +import json +import re +import unicodedata +from pathlib import Path + +# 查重登记表(FS 侧 append-only jsonl)。 +# 【唯一功能改动】指 cheap-worker/results/_dedup_registry.jsonl(本文件在 cheap-worker/ 根,parent 即 cheap-worker/); +# 天然路径隔离、跨任务持久,绝不写 wg1(别用 wg1 的 parents[1])。 +DEDUP_REGISTRY = Path(__file__).resolve().parent / "results" / "_dedup_registry.jsonl" + + +def norm_text(text): + """查重归一化(移植 run_batch.py:96 _norm_text,纯函数≈复制):NFKC + casefold + 去空白与常见中英文标点。""" + s = unicodedata.normalize("NFKC", text or "").casefold() + return re.sub( + r"[\s,.;:!?'\"()\[\]{}<>~`@#$%^&*+=/\\|,。·、!?:;…“”‘’()《》【】—-]+", "", s + ) + + +def compute_sig(title_norm, theme_norm, struct_key=""): + """精确撞重键(移植 ledger.py:50 mint_design_id 思路):sha256(titleNorm+themeNorm+关键结构) hex64。 + + 完全相同的 title/theme/结构 → 同 sig(撞重判定的精确键)。纯拼接 + UTF-8 编码,可复现。 + + :param title_norm: 归一后标题(norm_text 产) + :param theme_norm: 归一后主题(norm_text 产) + :param struct_key: 关键结构串(engine 路可空;填参路可放 templateId 等结构标识) + :return: 64 位小写 hex + """ + raw = "%s|%s|%s" % (title_norm, theme_norm, struct_key) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _load_registry(): + """读 FS 登记表 → [{traceId, sig, titleNorm, themeNorm}](坏行跳过,文件缺失返回空)。 + + 容错:单条坏行(崩溃残行/手改脏数据)跳过不抛;整文件读失败由调用方 try-catch 兜(返回空表 = 视为无历史)。 + """ + if not DEDUP_REGISTRY.is_file(): + return [] + rows = [] + for line in DEDUP_REGISTRY.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except Exception: + # 坏行跳过(borrow ledger.py resume_state 的"崩溃残行容忍"范式),不阻断查重 + continue + return rows + + +def _append_registry(entry): + """登记本款 sig 到 FS 表(append-only,落盘即 flush;目录不存在则建)。""" + DEDUP_REGISTRY.parent.mkdir(parents=True, exist_ok=True) + with DEDUP_REGISTRY.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + fh.flush() + + +def check_similarity(title, theme, trace_id, struct_key="", register=True): + """D9 v0 查重主入口:归一 → 算签名 → 比对 FS 历史 → 命中告警 + 登记本款。 + + v0 范围:完全相同(归一后 sig 相等)= 撞重;不做模糊近似阈值。 + + :param title: 游戏标题(engine 路 = brief 派生的 gameConfig.title) + :param theme: 游戏主题(engine 路占位 generic) + :param trace_id: 本款 traceId(命中时登记到 dupWith 供溯源) + :param struct_key: 关键结构串(参与 sig,可空) + :param register: 是否把本款登记进 FS 表(默认 True;查重不命中才登记,命中只告警不登记防表膨胀) + :return: similarity dict {dupHit, titleNorm, sig, dupWith};读写异常时 dupHit=None(降级,不阻断) + """ + title_norm = norm_text(title) + theme_norm = norm_text(theme) + sig = compute_sig(title_norm, theme_norm, struct_key) + try: + rows = _load_registry() + dup_with = None + for r in rows: + if r.get("traceId") == trace_id: + continue # 排除自身(重试/重投同 traceId 不与自己撞) + # 精确撞重:sig 完全相等(同 title/theme/结构)即命中;归一完全相等亦体现在 sig 相等 + if sig and r.get("sig") == sig: + dup_with = r.get("traceId") or r.get("sig") + break + # 归一标题完全相等(即便 struct_key 不同也告警,对齐 _dedup_gate 的 title 维度撞重) + if title_norm and r.get("titleNorm") == title_norm: + dup_with = r.get("traceId") or r.get("sig") + break + dup_hit = dup_with is not None + if dup_hit: + # v0 不阻断铁律:撞重只告警 + 标 dupHit,照常回调入 feed;命中不再登记(防同款刷表) + print(f"[cheap-worker] [D9] 撞重告警 traceId={trace_id} sig={sig[:12]} dupWith={dup_with}(仅告警不阻断)", + flush=True) + elif register: + # 不撞 → 登记本款 sig(供后续款查重) + _append_registry({"traceId": trace_id, "sig": sig, + "titleNorm": title_norm, "themeNorm": theme_norm}) + return {"dupHit": dup_hit, "titleNorm": title_norm, "sig": sig, "dupWith": dup_with} + except Exception as e: + # 登记表损坏/读写失败 → 降级(dupHit=None 表"未查重"),不阻断生成 + print(f"[cheap-worker] [D9] 查重降级(登记表读写异常,不阻断)traceId={trace_id}: {type(e).__name__}: {e}", + flush=True) + return {"dupHit": None, "titleNorm": title_norm, "sig": sig, "dupWith": None} diff --git a/cheap-worker/result_out.py b/cheap-worker/result_out.py index 01356696..5afa56eb 100644 --- a/cheap-worker/result_out.py +++ b/cheap-worker/result_out.py @@ -97,6 +97,56 @@ def _map_failure_reason(summary: dict, explicit: str | None) -> str: return "llm_error" +def _build_trace(summary: dict) -> dict | None: + """从(U1 富化的)run-summary 组 9d trace(camelCase,镜像 wg1 service._extract_trace;后端 D11 + ReadinessScorer 据此算就绪分)。成功/失败路都抽(失败路 repairs/attempts/stage/wallS 仍有排障价值); + summary 空(兜底路)→ None(不塞 trace)。 + + ⚠️ trace 是后端开放 Map(DifyCallbackReqVO.trace),Jackson **不拒 trace 内层未知键**——键名拼错 + (如 seven_gate_verdict / driverType)会被静默接收、ReadinessScorer 读不到 → 悄悄落中性(假绿)。 + 故内层键名必须逐字镜像 ReadinessScorer 读取口径。 + + 必填七项:pass/repairs/wallS/models/attempts/gameId/stage + · pass = summary.verdict.pass(brief 既有键);repairs = max(0, attempts-1)(首轮 attempts=1→0, + 对齐 wg1 len(attempts)-1);wallS = summary.wallSec;models/attempts/gameId/stage = U1 富化字段直透。 + D11 首局维(None 容错:verdictFull/driverType 为 None 的对照路 → 省略键,不塞 None): + · sevenGateVerdict 只取 {pass,guards}(从 verdictFull 去 url/ts 噪声;guards.H_progress 保嵌套含 .pass); + · gatespec 键名必须是 `driver`(值=driverType;落成 `driverType` 会让后端 hasDriver=false、firstPlay 永中性)。 + similarity:U3 D9 查重产,有则带、缺则省。 + """ + if not summary: + return None + attempts = summary.get("attempts") + # repairs = 自修复轮数 = max(0, attempts-1)(首轮 attempts=1 → repairs=0);attempts 非 int 时降 None。 + repairs = max(0, attempts - 1) if isinstance(attempts, int) else None + verdict_brief = summary.get("verdict") or {} + trace = { + "pass": verdict_brief.get("pass"), + "repairs": repairs, + "wallS": summary.get("wallSec"), + "models": summary.get("models"), + "attempts": attempts, + "gameId": summary.get("gameId"), + "stage": summary.get("stage"), + } + # sevenGateVerdict:只 {pass,guards}(去 url/ts 噪声,对齐 _extract_trace:148-154);verdictFull=None → 省略。 + vf = summary.get("verdictFull") + if isinstance(vf, dict): + trace["sevenGateVerdict"] = {"pass": vf.get("pass"), "guards": vf.get("guards")} + # gatespec:键名必须 driver(driverType=None 的对照路 → 省略,不塞 None)。 + driver_type = summary.get("driverType") + if driver_type is not None: + trace["gatespec"] = {"driver": driver_type} + # cost.totalRmb(维持现状,additive 成本台账;顶层不可有 costRmb,会被后端 ObjectMapper 拒)。 + if "costRmb" in summary: + trace.setdefault("cost", {})["totalRmb"] = summary["costRmb"] + # similarity(U3 D9 查重产;additive,有则带)。 + similarity = summary.get("similarity") + if similarity is not None: + trace["similarity"] = similarity + return trace + + def build_result_out(job: dict, summary: dict, game_dir, *, failure_reason: str | None = None, source_project: str | None = None) -> dict: """据 §6.1 job-in + run-summary + 产物目录组 result-out(DifyCallbackReqVO 形态)。 @@ -142,12 +192,11 @@ def build_result_out(job: dict, summary: dict, game_dir, *, # 失败路:后端据 status=failed 把任务置 FAILED;failureReason 供排障(七值枚举)。 payload["failureReason"] = _map_failure_reason(summary, failure_reason) - # 成本落 trace.cost.totalRmb(成本台账,additive)。 - # ⚠️ 不可放顶层 costRmb:DifyCallbackReqVO 只认 10 个字段且后端 ObjectMapper 严格拒未知字段 - # (slice test 实测),cost 的契约归宿是 trace.cost(VO trace doc:cost={totalRmb,gateRmb,byModel})。 - if "costRmb" in summary: - trace = payload.setdefault("trace", {}) - trace.setdefault("cost", {})["totalRmb"] = summary["costRmb"] + # ── M3b U2:组 9d trace(必填七项 + D11 首局维 sevenGateVerdict/gatespec + cost + similarity)── + # camelCase 逐字镜像 wg1 _extract_trace + 后端 ReadinessScorer 读取口径;成功/失败路都抽,summary 空则省略。 + trace = _build_trace(summary) + if trace: + payload["trace"] = trace # sourceProject best-effort(additive:有则带、缺则省,后端源落库旁路不阻断)。 if source_project is not None: payload["sourceProject"] = source_project diff --git a/cheap-worker/tests/test_dedup.py b/cheap-worker/tests/test_dedup.py new file mode 100644 index 00000000..a782188c --- /dev/null +++ b/cheap-worker/tests/test_dedup.py @@ -0,0 +1,117 @@ +"""test_dedup.py — M3b U3:D9 反同质化(vendored dedup)单测。 + +守的不变量(镜像 wg1 agent_loop/dedup.py,唯一改动 = DEDUP_REGISTRY 指 cheap-worker/results/): + · 首次 (title,theme) 不撞 dupHit=False;第二次同 (title,theme) 撞重 dupHit=True、dupWith 指首款; + · 不同 title 不撞 + sig 不同;norm_text 去空白+casefold+去标点;compute_sig 可复现 64-hex; + · 登记表坏行容错(降级不抛 dupHit ∈ {False,None},不阻断); + · DEDUP_REGISTRY 落 cheap-worker/results/、绝不写 wg1(路径隔离铁律); + · 接线:process_job 把 similarity 塞 summary → trace.similarity;撞重不改 status(仍按九门判)。 + +全 stdlib、零网络/零 LLM(隔离临时 registry,不污染真 cheap-worker/results/)。 +跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_dedup.py +""" + +import importlib +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +import dedup as D # noqa: E402 +import worker_service as W # noqa: E402 + +_GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();" + + +def _iso_registry(): + """隔离临时 registry(不污染真 cheap-worker/results/_dedup_registry.jsonl);两个模块引用同一文件。""" + p = Path(tempfile.mkdtemp(prefix="dedup-test-")) / "_dedup_registry.jsonl" + D.DEDUP_REGISTRY = p + W.dedup.DEDUP_REGISTRY = p # worker_service 内的 dedup 同模块,接线测试用同一隔离表 + return p + + +def test_first_not_dup_second_same_is_dup(): + """首次不撞、第二次同 (title,theme) 撞重 + dupWith 指首款。""" + _iso_registry() + s1 = D.check_similarity("点击得分小游戏", "generic", "trace-1") + assert s1["dupHit"] is False, s1 + s2 = D.check_similarity("点击得分小游戏", "generic", "trace-2") + assert s2["dupHit"] is True, s2 + assert s2["dupWith"] == "trace-1" + + +def test_different_title_not_dup(): + """不同 title → 不撞 + sig 不同。""" + _iso_registry() + a = D.check_similarity("躲避陨石", "generic", "t1") + b = D.check_similarity("消除方块", "generic", "t2") + assert a["dupHit"] is False and b["dupHit"] is False + assert a["sig"] != b["sig"] + + +def test_norm_text_and_compute_sig(): + """norm_text 去空白/英文标点+casefold;compute_sig 可复现 + 64-hex。""" + assert D.norm_text("点点乐 ") == D.norm_text("点点乐") + assert D.norm_text("Hello, World!") == D.norm_text("helloworld") + assert D.compute_sig("a", "b", "c") == D.compute_sig("a", "b", "c") + assert len(D.compute_sig("a", "b", "c")) == 64 + + +def test_registry_corrupt_tolerance(): + """登记表坏行 → 降级不抛(dupHit ∈ {False,None}),不阻断。""" + p = _iso_registry() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("not-json\n{bad\n", encoding="utf-8") + s = D.check_similarity("某游戏", "generic", "t9") + assert s["dupHit"] in (False, None), s + + +def test_registry_path_in_cheap_worker_not_wg1(): + """DEDUP_REGISTRY 默认落 cheap-worker/results/、不写 wg1(路径隔离铁律)。""" + d = importlib.reload(D) # 重载取默认路径(前面测试改过 DEDUP_REGISTRY) + p = str(d.DEDUP_REGISTRY) + assert p.endswith("cheap-worker/results/_dedup_registry.jsonl"), p + assert "wg1" not in p + + +def test_wiring_similarity_into_trace_and_status_unchanged(): + """接线:process_job 把 similarity 塞 trace.similarity;撞重不改 status(仍按九门判)。""" + _iso_registry() + gd = Path(tempfile.mkdtemp(prefix="dedup-gd-")) / "amgen-d1" + (gd / "src").mkdir(parents=True) + (gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") + + captured = [] + state = W.WorkerState( + run_fn=lambda job: ({"verdict": {"pass": True}, "attempts": 1, "costRmb": 0.02}, gd), + send_fn=lambda url, payload, secret: captured.append(payload) or (200, ""), + profile_fn=lambda j, g: None, + ) + job1 = {"job_id": "d1", "traceId": "d1", "templateId": "generic", "brief": "撞重点点乐", + "callback": {"target": "http://cb"}} + job2 = dict(job1, job_id="d2", traceId="d2") # 同 brief → 第二次撞重 + + W.process_job(state, job1) + W.process_job(state, job2) + + sim1 = captured[0]["trace"]["similarity"] + sim2 = captured[1]["trace"]["similarity"] + assert sim1["dupHit"] is False, sim1 + assert sim2["dupHit"] is True, sim2 # 同 brief 撞重 + assert captured[1]["status"] == "succeeded" # 撞重不阻断、不改 status(仍按九门判) + assert "__GameBundle" in captured[1]["engineBundle"] + + +if __name__ == "__main__": + _fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + _failed = 0 + for _fn in _fns: + try: + _fn() + print(f" PASS {_fn.__name__}") + except Exception as e: # noqa: BLE001 + _failed += 1 + print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}") + print(f"\n{len(_fns) - _failed}/{len(_fns)} passed") + sys.exit(1 if _failed else 0) diff --git a/cheap-worker/tests/test_result_out.py b/cheap-worker/tests/test_result_out.py index fbe97c9d..4510db36 100644 --- a/cheap-worker/tests/test_result_out.py +++ b/cheap-worker/tests/test_result_out.py @@ -196,6 +196,124 @@ def test_deterministic_zero_llm(): assert R.build_result_out(job, summary, gd) == R.build_result_out(job, summary, gd) +# ────────────────────────────────────────────────────────────────────────────── +# M3b U2:trace 9d 七项 + D11 首局维(sevenGateVerdict/gatespec)+ repairs 口径 + None 容错 +# 守的不变量(镜像 wg1 _extract_trace + 后端 ReadinessScorer 读取口径): +# · 必填七项 camelCase:pass/repairs/wallS/models/attempts/gameId/stage; +# · sevenGateVerdict 只取 {pass,guards}(去 url/ts 噪声,guards.H_progress 保嵌套含 .pass); +# · gatespec 键名必须是 `driver`(落 driverType 则后端 hasDriver=false、firstPlay 永中性); +# · repairs = max(0, attempts-1)(首轮 attempts=1→repairs=0); +# · 成功/失败路都抽 trace;verdictFull/driverType=None 时省略 sevenGateVerdict/gatespec(不塞 None、不崩)。 +# ────────────────────────────────────────────────────────────────────────────── + +# U1 富化后的成功路 summary(合成;含完整 verdictFull 带 url/ts 噪声 + driverType + stage + models)。 +_SUMMARY_SUCCESS = { + "ok": True, "finished": True, "gameId": "amgen-t1", "attempts": 1, + "verdict": {"pass": True, "failedGates": []}, # brief(既有键) + "verdictFull": { # U1 新增:完整 verdict(带噪声 + guards.H_progress 嵌套) + "pass": True, "url": "http://localhost:4320/?x", "ts": 1700000000, + "guards": { + "A_boot": {"pass": True}, + "H_progress": {"pass": True, "checks": ["score+"], "latch": True, "state0": 0, "state1": 3}, + }, + }, + "driverType": "tap-targets", + "models": {"code": "MiniMax-M3"}, + "stage": "play", + "wallSec": 102.3, + "costRmb": 0.0431, +} + + +def test_trace_seven_required_fields_and_d11_dims_success(): + """成功路 trace:七项齐 + sevenGateVerdict.guards.H_progress.pass + gatespec.driver;噪声 url/ts 已去。""" + gd = _make_game_dir(_tmp()) + job = {"traceId": "u2-ok", "templateId": "generic", "brief": "点击得分小游戏"} + + out = R.build_result_out(job, _SUMMARY_SUCCESS, gd) + tr = out["trace"] + + # 必填七项(camelCase 逐字镜像 _extract_trace) + assert tr["pass"] is True + assert tr["repairs"] == 0 # attempts=1 → max(0,0)=0(首轮 0) + assert tr["wallS"] == 102.3 + assert tr["models"] == {"code": "MiniMax-M3"} + assert tr["attempts"] == 1 + assert tr["gameId"] == "amgen-t1" + assert tr["stage"] == "play" + # D11 首局维:sevenGateVerdict 只 {pass,guards},guards.H_progress 嵌套含 .pass(后端 guardPass 读它) + assert set(tr["sevenGateVerdict"].keys()) == {"pass", "guards"} + assert "url" not in tr["sevenGateVerdict"] and "ts" not in tr["sevenGateVerdict"] + assert tr["sevenGateVerdict"]["guards"]["H_progress"]["pass"] is True + # gatespec 键名必须是 driver(落 driverType 会让后端 hasDriver=false) + assert tr["gatespec"] == {"driver": "tap-targets"} + assert "driverType" not in tr["gatespec"] + # cost 维持现状 + assert tr["cost"]["totalRmb"] == 0.0431 + + +def test_trace_repairs_equals_max0_attempts_minus_1(): + """repairs = max(0, attempts-1):attempts=1→0、attempts=3→2(对齐 wg1 len(attempts)-1)。""" + gd = _make_game_dir(_tmp()) + job = {"traceId": "u2-rep", "templateId": "generic", "brief": "x"} + + s1 = dict(_SUMMARY_SUCCESS, attempts=1) + assert R.build_result_out(job, s1, gd)["trace"]["repairs"] == 0 + + s3 = dict(_SUMMARY_SUCCESS, attempts=3) + assert R.build_result_out(job, s3, gd)["trace"]["repairs"] == 2 + + +def test_trace_failed_path_still_has_seven_required(): + """失败路(verdictFull/driverType=None)trace 仍含七项(repairs/attempts/stage/wallS 排障价值)。""" + gd = _make_game_dir(_tmp(), bundle="var notTheGlobal=1;") # 无 __GameBundle → status=failed + job = {"traceId": "u2-fail", "templateId": "generic", "brief": "x"} + summary = { + "ok": False, "finished": False, "gameId": "amgen-t2", "attempts": 6, + "verdict": None, "verdictFull": None, "driverType": None, + "models": {"code": "MiniMax-M3"}, "stage": "code", "wallSec": 50.1, "costRmb": 0.02, + } + + out = R.build_result_out(job, summary, gd) + assert out["status"] == "failed" + tr = out["trace"] + for k in ("pass", "repairs", "wallS", "models", "attempts", "gameId", "stage"): + assert k in tr, f"失败路 trace 缺必填项 {k}" + assert tr["repairs"] == 5 # attempts=6 → 5 + assert tr["stage"] == "code" + assert tr["pass"] is None # play 没跑 + + +def test_trace_none_tolerance_omits_d11_dims(): + """verdictFull/driverType=None(对照路)→ 省略 sevenGateVerdict/gatespec(不塞 None、不崩)。""" + gd = _make_game_dir(_tmp()) + job = {"traceId": "u2-none", "templateId": "generic", "brief": "x"} + summary = { + "gameId": "amgen-t3", "attempts": 2, "verdict": None, + "verdictFull": None, "driverType": None, + "models": {"code": "MiniMax-M3"}, "stage": "smoke", "wallSec": 10.0, + } + + out = R.build_result_out(job, summary, gd) + tr = out["trace"] + assert "sevenGateVerdict" not in tr # verdictFull=None → 省略 + assert "gatespec" not in tr # driverType=None → 省略 + assert tr["repairs"] == 1 # attempts=2 → 1 + + +def test_trace_keys_camelcase_match_readiness_scorer(): + """trace 键名逐字镜像 ReadinessScorer 读取口径(拼错会被后端静默接收 → 假绿)。""" + gd = _make_game_dir(_tmp()) + job = {"traceId": "u2-keys", "templateId": "generic", "brief": "x"} + tr = R.build_result_out(job, _SUMMARY_SUCCESS, gd)["trace"] + # ReadinessScorer 读取的确切键(camelCase):trace.pass / gatespec.driver / + # sevenGateVerdict.guards.H_progress.pass / repairs / cost.totalRmb + assert "pass" in tr and "gatespec" in tr and "sevenGateVerdict" in tr + assert "wallS" in tr and "gameId" in tr # 非 wall_s / game_id(snake 会被后端读不到) + assert "driver" in tr["gatespec"] + assert "H_progress" in tr["sevenGateVerdict"]["guards"] + + if __name__ == "__main__": _fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] _failed = 0 diff --git a/cheap-worker/tests/test_trace_source.py b/cheap-worker/tests/test_trace_source.py new file mode 100644 index 00000000..5b8ce40a --- /dev/null +++ b/cheap-worker/tests/test_trace_source.py @@ -0,0 +1,86 @@ +"""test_trace_source.py — M3b U1:build_trace_source 9d-trace 源维度富化单测。 + +守的不变量: + · 纯函数 build_trace_source(verdict, driver_type, attempts, stage, model) 产 additive 4 键 + verdictFull/driverType/models/stage,供 result_out 组 payload["trace"]、后端 D11 算分; + · verdictFull 保留完整 verdict(guards.H_progress 仍是嵌套对象,含 pass);play 没跑 → None; + · driverType 透传(没产 play-spec → None);models = {code: model} map; + · 不回写既有键 attempts(守「不动既有键」,repairs 由 result_out 据 summary.attempts 派生); + · _furthest_stage 据收口实际到达步判最远阶段。 + +不跑真 gen(纯函数,合成 verdict dict)。 +跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_trace_source.py +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +import cheap_studio as S # noqa: E402 + +# 合成完整九门 verdict(含 guards.H_progress 嵌套对象,对齐真 verdict.json 形态;带 url/ts 噪声供 U2 验去噪)。 +_FULL_VERDICT = { + "pass": True, + "url": "http://localhost:4320/?cheap", + "ts": 1700000000, + "guards": { + "A_boot": {"pass": True}, + "H_progress": {"pass": True, "checks": ["score+"], "latch": True, "state0": 0, "state1": 3}, + }, +} + + +def test_build_trace_source_success_shape(): + """成功路:verdictFull 含完整 guards(H_progress 嵌套保 pass)、driverType 透传、models map、stage。""" + out = S.build_trace_source(_FULL_VERDICT, "tap-targets", 1, "play", "MiniMax-M3") + assert out["verdictFull"]["pass"] is True + # H_progress 保持嵌套对象(不是裸 bool),后端 ReadinessScorer.guardPass 读其 .pass + assert isinstance(out["verdictFull"]["guards"]["H_progress"], dict) + assert out["verdictFull"]["guards"]["H_progress"]["pass"] is True + assert out["driverType"] == "tap-targets" + assert out["models"] == {"code": "MiniMax-M3"} + assert out["stage"] == "play" + + +def test_build_trace_source_none_verdict(): + """play 没跑(verdict=None)→ verdictFull=None;driverType=None(不崩)。""" + out = S.build_trace_source(None, None, 1, "smoke", "MiniMax-M3") + assert out["verdictFull"] is None + assert out["driverType"] is None + assert out["models"] == {"code": "MiniMax-M3"} + assert out["stage"] == "smoke" + + +def test_build_trace_source_does_not_emit_attempts(): + """不回写既有键 attempts(守「不动既有键」;repairs 由 result_out 据 summary.attempts 派生)。""" + out = S.build_trace_source(_FULL_VERDICT, "tap-targets", 3, "play", "MiniMax-M3") + assert "attempts" not in out + + +def test_build_trace_source_non_dict_verdict_to_none(): + """verdict 非 dict(异常形态)→ verdictFull=None(防御)。""" + out = S.build_trace_source("not-a-dict", None, 1, "code", "MiniMax-M3") + assert out["verdictFull"] is None + + +def test_furthest_stage_ordering(): + """_furthest_stage 据收口实际到达步取最远:play>smoke>stage>code>scaffold。""" + assert S._furthest_stage(finished=True, staged=True, smoke_ran=True, played=True) == "play" + assert S._furthest_stage(finished=True, staged=True, smoke_ran=True, played=False) == "smoke" + assert S._furthest_stage(finished=True, staged=True, smoke_ran=False, played=False) == "stage" + assert S._furthest_stage(finished=True, staged=False, smoke_ran=False, played=False) == "code" + assert S._furthest_stage(finished=False, staged=False, smoke_ran=False, played=False) == "scaffold" + + +if __name__ == "__main__": + _fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + _failed = 0 + for _fn in _fns: + try: + _fn() + print(f" PASS {_fn.__name__}") + except Exception as e: # noqa: BLE001 + _failed += 1 + print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}") + print(f"\n{len(_fns) - _failed}/{len(_fns)} passed") + sys.exit(1 if _failed else 0) diff --git a/cheap-worker/tests/test_worker_service.py b/cheap-worker/tests/test_worker_service.py index a9fa6b22..c04322db 100644 --- a/cheap-worker/tests/test_worker_service.py +++ b/cheap-worker/tests/test_worker_service.py @@ -21,6 +21,11 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ import worker_service as W # noqa: E402 +import dedup # noqa: E402 + +# M3b U3:process_job 现会调 D9 dedup.check_similarity(写 FS 登记表)。把登记表重定向到临时路径, +# 防本测污染真 cheap-worker/results/_dedup_registry.jsonl(与 wg1_groupb_test 隔离范式一致)。 +dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="ws-dedup-")) / "_dedup_registry.jsonl" _GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();" @@ -284,7 +289,9 @@ def test_worker_loop_run_failure_sends_fallback_failed(): state.queue.join() assert captured["payload"]["status"] == "failed" - assert captured["payload"]["failureReason"] == "generation_failed" + # 七值枚举 catch-all:worker 异常兜底 → failureReason=llm_error(M3a 七值收敛后的真值; + # 原断言 generation_failed 是 M3a 遗留陈旧值 —— 非枚举值会被后端 isValidFailureReason 拒)。 + assert captured["payload"]["failureReason"] == "llm_error" if __name__ == "__main__": diff --git a/cheap-worker/worker_service.py b/cheap-worker/worker_service.py index d86d1e9f..2297d860 100644 --- a/cheap-worker/worker_service.py +++ b/cheap-worker/worker_service.py @@ -24,6 +24,7 @@ import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +import dedup import result_out # 默认监听端口 / 队列容量(KTD4 有界;并发上限另由创始人容量画像 conc≤15 + 远期多实例控)。 @@ -157,6 +158,16 @@ def process_job(state: WorkerState, job: dict) -> dict: trace_id = job.get("traceId") or job.get("job_id") summary, game_dir = state.run_fn(job) + # ── M3b U3:D9 反同质化(vendored dedup)。撞重只告警不阻断(status 仍按九门判); + # check_similarity 内部已对 FS 异常返 dupHit=None,另包一层 try 兜底——D9 整体失败不得让回调崩。── + try: + title = result_out._derive_title(job.get("brief") or "") + sim = dedup.check_similarity(title=title, theme="generic", trace_id=trace_id) + if isinstance(summary, dict): + summary["similarity"] = sim # 随 trace.similarity 落库(result_out._build_trace 读 summary.similarity) + except Exception as e: # noqa: BLE001 — D9 非承重,失败只记日志、不阻断主链 + log(f"D9 查重失败(省略 similarity,不阻断) trace_id={trace_id}: {type(e).__name__}: {e}") + # sourceProject best-effort:profile 可派生才组,否则省略(不伪造、不阻断成功)。 source_project = None try: @@ -180,7 +191,10 @@ def _send_failed(state: WorkerState, job: dict, reason: str) -> None: """生成异常兜底:发 failed result-out,避免任务挂 RUNNING(对执行器 callbackFailed 语义)。""" trace_id = job.get("traceId") or job.get("job_id") # 用不存在的 game_dir 让 build_result_out 走失败路(bundle 读不到 → status=failed)。 - payload = result_out.build_result_out(job, {}, Path("/nonexistent-amgen"), failure_reason="generation_failed") + # failure_reason 传七值枚举 catch-all `llm_error`(worker 异常兜底=生成执行链异常的最近桶): + # M3a 前误传非枚举值 `generation_failed`,虽经 _map_failure_reason 映射成 llm_error、回调行为正确, + # 但传真枚举值语义更清、可追溯(错误路红线);具体异常 reason 另由下方日志落痕。 + payload = result_out.build_result_out(job, {}, Path("/nonexistent-amgen"), failure_reason="llm_error") url = (job.get("callback") or {}).get("target") if url: try: diff --git a/docs/plans/2026-06-28-001-feat-cheap-worker-M3b-create-feed-e2e-6gates-plan.md b/docs/plans/2026-06-28-001-feat-cheap-worker-M3b-create-feed-e2e-6gates-plan.md new file mode 100644 index 00000000..9f86866f --- /dev/null +++ b/docs/plans/2026-06-28-001-feat-cheap-worker-M3b-create-feed-e2e-6gates-plan.md @@ -0,0 +1,253 @@ +--- +date: 2026-06-28 +topic: cheap-worker-M3b-create-feed-e2e-and-6gates +status: 已双评审·发现已修·D9=B 已定·执行中(本机 U1-U3 先行,mini-desktop U4-U5 前 checkpoint) +关联: + - cheap-worker/result_out.py (HEAD a327c991 · 现只产 trace.cost.totalRmb L148-150) + - cheap-worker/cheap_studio.py (run_studio summary L164-184 · 缺 repairs/models/stage/逐门 guards) + - wg1/gen-worker/worker/service.py (parity 参考 _extract_trace L91-218) + - wg1/gen-worker/worker/agent_loop/dedup.py (D9 参考) + - game-cloud .../service/task/ReadinessScorer.java (D11 L52-165 · 退化根因) + - game-cloud .../service/callback/DifyCallbackTxService.java (persistTraceQuietly L252-282 · aigc.trace.enabled 默认 false L101) + - game-cloud .../service/publish/PublishOrchestrationServiceImpl.java (发布入 feed L57-100) + - game-studio/src/host/inject.ts (play 装载分流 boot L240-271) + - docs/architecture/架构/生成引擎/验收门.md (6 门 SoT · canonical) + - docs/plans/2026-06-25-生成引擎统一执行计划-AgentScope三档-plan.md (plan① 切片一 M3) +图清单: [图1 create→feed→play 现状链与 M3b 缺口, 图2 6 门新路依赖性分级] +--- + +# 便宜档 M3b · create→feed 端到端 + 对外开闸 6 门(切片一第三里程碑第二片) + +## Summary + +M3a 已把便宜档默认生成路由从 Node/SAA 旧路整体切到 Python cheap-worker(`dispatcher=http`→worker:9501),并在隔离的 iso :48090 上验过两条回调路、live :48080 全程未动。M3b 承接 plan① 切片一 M3 的剩余部分:把"创作页一句话 → 生成 → 九门 → 发布门 → feed → 种子用户真玩"这条生成线在 cheap-worker 新路上端到端跑通,并把对外开闸 6 门在新路逐门复验。 + +M3b 的真实形状和直觉不同:**6 门不是要从零建,而是建在旧路、需要在 cheap-worker 新路复验与补齐。** 6 门里,D12 控制平面与 GP9 合规先行都焊在生成任务的提交入口、与 worker 实现无关,首局体验门焊在引擎无关的九门 harness 里——这三门 cheap-worker 新路天然继承,无需重兑现。真正在新路有缺口的只有三件,而且它们是连环的:worker 侧产出的 **9d trace 七项**当前没产(result_out.py 只产了成本一项),后端 **D11 就绪评分**因此读不到可玩性/首局/稳定性信号、退化成一个近恒定分(实测落 45 或 55 双峰、不随质量浮动),**D9 反同质**则因为 cheap-worker 没有查重逻辑而完全失效。 + +M3b 的生成线工作因此是:**把 cheap-worker 的回调产出补齐到旧路 worker 的 9d-trace 同等口径,救活 D11,再把整条 create→feed→真玩 在 iso 上真验一遍。** create→feed→真玩 的代码链本身已经通了(engineBundle 从 worker 一路落到玩家页的真引擎启动),唯一的人工闸口是发布前的审核门。M3b 不动 live,在 M3a 已就绪的 iso 上把端到端跑通并复验 6 门;生产 cutover 与几项待拍数值是显式延后的创始人决策,不在本里程碑。 + +## Problem Frame + +M3a 完成后,便宜档生成跑在 cheap-worker 上,但它的回调产出和旧路不对等,这个不对等沿着回调链逐级放大成三门失效。 + +第一级在 worker 出口。cheap-worker 的 `result_out.build_result_out` 组回调入参时,`trace` 段只塞了一个 `trace.cost.totalRmb`(result_out.py:148-150)。而旧路两个 worker——原始 HTTP worker 的 `_extract_trace`(wg1/gen-worker/worker/service.py:91-218)和 SAA 路的 `extractTraceQuietly`(SaaGraphDispatcher.java:704-720)——都产出一个 9d trace 子集,其中有一个"必填七项"`{pass, repairs, wallS, models, attempts, gameId, stage}` 作为轨迹完整率的分母,外加 `gatespec`、`sevenGateVerdict`、`cost`、`similarity` 等可选项。cheap-worker 当前七项里只对得上 `pass`(从 verdict)、`wallS`(summary 的 wallSec)、`gameId` 三项,其余四项缺失或形态不同(`attempts` 是整数计数而非逐 attempt 的逐门 guards 列表,`verdict` 被 `_verdict_brief` 削成了 `{pass, failedGates}`、丢了逐门 guards,完全没有 `repairs`/`models`/`stage`)。 + +第二级在后端就绪评分。`ReadinessScorer.score`(ReadinessScorer.java:52-165)按四维加权算 0–100 分:可玩性 0.50 读 `trace.pass`、首局 0.25 读 `trace.gatespec.driver` 且 `trace.sevenGateVerdict.guards.H_progress.pass`(嵌套对象取 `.pass`)、稳定性 0.15 读 `trace.repairs`、效率 0.10 读 `trace.cost.totalRmb`;任一维字段缺失取中性值 0.5。于是当 trace 只有 cost 一项时,前三维全部落到中性 0.5,算出来恒为 `100×(0.45 + 0.10×效率)`。而便宜档真实成本是 ¥0.4–1.2(实测 27 份 run-summary 全 ≥0.30,= 2×效率维预算阈值 `BUDGET_RMB=0.15`),效率维因此恒为 0、对便宜档零区分力——退化分落在 **45**(active 定价)或 **55**(定价降级 spent_rmb=0、cost=0≤0.15→效率满分)双峰,不随质量浮动。**这个看起来在工作的分,其实丢掉了可玩性、首局、稳定性的全部信号**——一款九门全过的好游戏和一款勉强启动的游戏会拿到几乎一样的就绪分,审核台据此排序等于没排(`BUDGET_RMB=0.15` 与便宜档成本量级错配、效率维零区分,需校准,见 §待拍数值)。 + +第三级在反同质。后端完全没有查重逻辑,签名与查重按设计就全在 worker 侧(验收门.md:209);cheap-worker 没有 `dedup.py`、`result_out` 不产 `similarity`,所以新路上 D9 直接失效。D9 当初只落在了原始 HTTP worker 上,SAA 路本就没接(SaaGraphDispatcherTraceTest.java:183 注明"similarity 永远省略,D9 未接 SAA")——也就是说,即便在 M3a 之前,默认跑的 SAA 路也没有 D9。这把"D9 要不要补"变成一个真实的范围取舍,而不是一个单纯的回归修复(见 §决策点)。 + +还有一个会让验证假绿的开关:`aigc.trace.enabled` 默认 false(DifyCallbackTxService.java:101),关着时整段 trace 落库旁路、`trace_json`/`readiness_score` 恒为 NULL。M3b 在 iso 上验 trace 落库前必须先把它打开,否则会误以为"trace 没落是 worker 没产",其实是开关没开。 + +create→feed→真玩 这条链本身在代码层已经接通:回调七步事务产出 game_version + game_runtime_package(status=0 预览就绪)+ 任务置 SUCCEEDED,但**不写 feed**;一条游戏进 feed 需要有人调 `reviewProject(APPROVE)`,经 `PublishOrchestrationServiceImpl.publish`(L57-100)把包翻成 status=1、版本翻 3、项目翻 PUBLISHED、`feedApi.upsertRank` 入流;之后玩家端 `getFeedStream` 拉流、`inject.ts` 的 `boot()`(L240-271)按 `pkg.engineBundle` 分流到 `__GameBundle.bootGameHost` 真引擎掌帧。engineBundle 这条主动脉全程贯通,所以**代码层"→feed→真玩"已通,唯一断点是发布前那道人工审核门**。 + +### 图1 · create→feed→play 现状链与 M3b 缺口 + +```mermaid +flowchart TD + subgraph SUBMIT["提交入口(Node/Python 共享 · 新路天然继承)"] + D12["D12 控制平面
降级/配额并发/背压"] + GP9["GP9 合规先行
prompt 审查 fail-closed"] + end + subgraph WORKER["cheap-worker 新路(M3a 已切 · M3b 补齐)"] + GEN["cheap_studio.run_studio
ReAct 生成 + 九门 play"] + HARNESS["九门 harness + 首局体验门
(引擎无关 · 新路天然继承)"] + RO["result_out.build_result_out
⚠️ trace 只产 cost 一项"] + end + subgraph BACKEND["后端回调七步 + 落库"] + CB["DifyCallbackTxService
校验→建版本→组包→落包→置 SUCCEEDED"] + TRACE["persistTraceQuietly
⚠️ aigc.trace.enabled 默认 false"] + D11["D11 ReadinessScorer
⚠️ 缺七项→退化 45/55 双峰"] + D9X["D9 反同质
⚠️ 后端零逻辑 + worker 无 dedup → 失效"] + end + subgraph PUBLISH["发布入 feed → 真玩"] + REVIEW["reviewProject(APPROVE)
🔶 唯一人工断点"] + PUB["publish: 包 0→1 / 版本 3 / 项目 PUBLISHED / upsertRank"] + FEED["getFeedStream 拉流"] + PLAY["inject.ts boot → __GameBundle.bootGameHost 真引擎"] + end + + D12 --> GP9 --> GEN --> HARNESS --> RO --> CB --> TRACE --> D11 + CB -.-> D9X + CB --> REVIEW --> PUB --> FEED --> PLAY + + style RO fill:#f5b7b1 + style D11 fill:#f5b7b1 + style D9X fill:#f5b7b1 + style TRACE fill:#f9e79f + style REVIEW fill:#f9e79f + style PLAY fill:#a9dfbf +``` + +## Requirements + +- **R1 · 9d trace 七项在 cheap-worker 新路产出。** cheap-worker 回调入参的 `trace` 段补齐必填七项 `{pass, repairs, wallS, models, attempts, gameId, stage}` 加 D11 首局维所需的 `gatespec.driver` 与 `sevenGateVerdict.guards.H_progress`(嵌套 `.pass`),键名一律 camelCase、镜像旧路 `_extract_trace` / `extractTraceQuietly` 口径。验收 = iso 上 `trace_json` 落齐七项、轨迹完整率 7/7。 +- **R2 · D11 就绪评分在新路从真实信号算分。** 随 R1 自动兑现(后端算分器是共享码)。验收 = 修后 trace 喂 ReadinessScorer 时 playability/firstPlay/stability **三维各自脱离中性 0.5**(playability→1.0 或 0、firstPlay→1.0、stability→1.0/0.7/0.4),且九门全过与勉强启动两款算出**显著拉开**的就绪分。不以「合成分脱离某区间」为判据——便宜档真实成本下效率维恒 0、未修态合成分本就在 45(区间外),看区间会假绿。 +- **R3 · D9 反同质的新路处置(决策点)。** 见 §决策点:补到 cheap-worker(全 parity)或显式暂缓(对齐 SAA 先例 + observe-only 性质)。 +- **R4 · create→发布→feed→真玩 端到端在 iso 真验。** 从一句话 brief 提交,经生成→回调七步→`reviewProject(APPROVE)`→publish→`getFeedStream`→manifest→`inject.ts` boot,到 `__GameBundle.bootGameHost` 在真浏览器里真起来可玩。验收 = 真浏览器里点开 feed 里这款游戏能真玩(非兜底壳)。 +- **R5 · 6 门在 cheap-worker 新路逐门复验。** D12/GP9/首局体验验"继承不破",9d trace/D11 验"新路兑现",D9 按 R3。验收 = 逐门给出新路通过证据。 +- **R6 · 非阻断硬约束不破。** trace/dedup 落库或算分失败一律 try-catch 吞 + log,主回调链照常走到发布(沿用后端 best-effort 范式 + 验收门.md 组B 铁律);cheap-worker 侧富化 trace 同样不得让 trace 组装失败拖垮回调。 +- **R7 · 不动 live。** 全部验证在 M3a 的 iso :48090 + worker :9501 上做;live :48080 与生产 cutover 不在 M3b。 + +## Key Technical Decisions + +- **KTD1 · 9d trace 在 cheap_studio 侧捕获、result_out 侧组装,且是纯 additive。** cheap_studio.run_studio 在收口段其实已经算出了 full verdict(含逐门 guards,cheap_studio.py:157)和 gatespec(ensure_play_spec 产的 driverType,:154),只是没存进 summary——summary 只留了 `_verdict_brief` 的 `{pass, failedGates}`(:175)。所以富化是把这些已算出的维度 additively 存进 summary(再加 `repairs`/`models`/`stage` 三个轻量字段),不改任何生成逻辑、不影响现有产物。result_out 再据富化后的 summary 组 `payload["trace"]`,镜像 `_extract_trace` 的 camelCase 语义。 +- **KTD2 · 复用旧路经验证的 trace/dedup 口径,而非另写一套语义。** import 边界要看清:`_bootstrap` 把 **tier2/gen-worker** 加进 sys.path(cheap_studio 据此 import `worker.config`/`worker.middleware`),而 9d-trace 的 `_extract_trace` 与 D9 的 `dedup.py` 在 **wg1/gen-worker**、不在 cheap-worker 的 import 路径上(tier2/gen-worker 里没有这两者)。故两者都按「复用口径、非跨树 import」处理:trace 组装在 result_out 内**手写、镜像 `_extract_trace` 的输出口径**(七项 camelCase + sevenGateVerdict 只取 `{pass, guards}` 去噪;cheap summary 的输入形态与 wg1 `result` 不同,不能直接套 _extract_trace);D9 则把 wg1 的 `dedup.py`(纯模块、无外部依赖、约 130 行)**vendored 复制进 cheap-worker**,`DEDUP_REGISTRY` 直接设为 cheap-worker 自己的 `results/_dedup_registry.jsonl`——比跨树 import + monkey-patch 全局常量更干净、天然路径隔离。 +- **KTD3 · D11 不动后端码,只喂对输入。** 退化的根因不在 ReadinessScorer(它的四维读取与中性兜底都是对的),而在喂给它的 trace 缺字段。所以 M3b 不碰算分器,只让 cheap-worker 产出它要读的字段。这把 D11 的修复收敛成 R1 的副产品、零后端改动。 +- **KTD4 · 端到端验证复用 M3a iso,新增的只是开 trace 开关 + 走通发布门。** iso :48090 已是 `dispatcher=http`→worker:9501,正是 M3b 要验的新路。M3b 在它上面把 `aigc.trace.enabled` 打开、把 `reviewProject(APPROVE)`→feed→真玩 这段走通,不新搭环境。 +- **KTD5 · 发布入 feed 走现有人工审核门,M3b 只是 exercise 它。** SUCCEEDED→feed 的发布编排(publishOrchestrationService)是现成的;M3b 调 `reviewProject(APPROVE)` 把种子游戏推进 feed 以验真玩,审核台 UI、α 自动发布策略归产品/后端线,不在 M3b 自建。 + +## 决策点 · D9 反同质的新路处置(待创始人拍) + +这是 M3b 唯一的范围分叉,单独拎出来给创始人定,不替他默认。 + +事实:D9 是 observe-only 的非阻断门(撞重只告警、绝不拦截,九门才是挡坏游戏的硬地板);它当初只落在原始 HTTP worker 上,默认跑的 SAA 路本就没接;后端零逻辑、补 D9 全是 worker 侧的事;移植成本较低(把 wg1 的纯模块 `dedup.py` vendored 复制进 cheap-worker、registry 指自己的 `results/`,见 KTD2)。 + +- **选项 A(暂缓 · 对齐先例 + 最小):** M3b 不补 D9,在残留里显式记一笔"D9 未接新路(与 SAA 先例一致)"。理由:D9 非阻断、SAA 路也没有,M3b 先把承重的 trace/D11 与端到端打通;D9 作为 observe-only 增强随后单独补。代价:6 门里 D9 这一门在新路仍是缺的,"6 门全验过"打个折扣。 +- **选项 B(补齐 · 完成单一路 parity):** M3b 把 D9 移植进 cheap-worker(U3),让 reframe 钦定的 cheap-worker 单一路在 6 门上比当年任何旧路都全。代价:多一个小单元的工作量 + 验证面。 + +倾向:两者都正当。考虑到 D9 移植成本确实低、且 reframe 已把 cheap-worker 定为唯一便宜档路(留一门缺口未来还要回填),**A 偏最小、B 偏干净完整**。 + +> **创始人裁定(2026-06-28):选 B 补齐** —— D9 移植进 cheap-worker,完成单一路 6 门 parity。U3 转为本里程碑**必做单元**(不再条件化);6 门在新路全验。 + +## High-Level Technical Design + +### 图2 · 6 门在 cheap-worker 新路的依赖性分级 + +```mermaid +flowchart LR + subgraph INHERIT["天然继承(新路无需重兑现)"] + G_D12["D12 控制平面
提交入口共享"] + G_GP9["GP9 合规先行
提交入口共享"] + G_FIRST["首局体验门
引擎无关 harness 共享"] + end + subgraph REDO["worker 侧产出依赖(新路必须兑现)"] + G_TRACE["9d trace 七项
= M3b U1+U2"] + G_D11["D11 就绪评分
= 随 trace 自动救活"] + G_D9["D9 反同质
= U3 决策点"] + end + G_TRACE --> G_D11 + style INHERIT fill:#eafaf1 + style REDO fill:#fef9e7 +``` + +设计的主轴只有一条:**把缺口收敛到 worker 出口的 trace 产出这一个点,其余全是它的下游或与它无关。** D11 退化是 trace 缺字段的下游,补 trace 即救活 D11、零后端改动;D9 是 trace 里另一个可选段(similarity),按决策点处理;D12/GP9/首局与 worker 实现正交,只需复验"切路没切坏"。create→feed→真玩 这条产品链不在生成线缺口里,M3b 对它做的是端到端真验、不是改造。 + +## Implementation Units + +### U1 · cheap_studio 富化 9d trace 维度(cheap-worker 侧 · 本机可执行) + +**交付物:** cheap_studio.run_studio 的 summary additively 携带 D11/trace 所需的全部维度。 +**改动点:** cheap-worker/cheap_studio.py(summary 构造段 L164-184 附近)。 +**Approach:** 把收口段已算出但没存的维度存进 summary——full verdict 的逐门 guards(:157 的 `verdict`,不只 brief)、gatespec 的 driver(:154 的 `ps`);再补三个轻量字段:`repairs`(自修复轮数 = `max(0, attempts-1)`,首轮 0、对齐 wg1 `len(attempts)-1` 与 SAA `SaaGraphDispatcher` 注释口径;注:cheap_studio.py:114-118「门绿但未 finish→踹 finish」分支可能多记 1,`attempts-1` 是可接受近似、实现处注明此口径偏差)、`models`(便宜档单模型,组成 `{code: SPIKE_MODEL}` 形态的 map)、`stage`(本 run 走到的最远阶段,scaffold/code/stage/smoke/play 之一)。全部 additive,不改生成循环、不改现有 summary 既有字段。**对照路容错:**worker_service.py:120 真路默认 `run_gates=True` 故收口段产 verdict/gatespec;`run_gates=False` 的对照路不产,U1 捕获须对 None 容错(缺则 trace 相应键省略、不抛)。 +**TDD:** +- RED:写测断言 run_studio 的 summary(或新增的 trace-source 字段)含 `repairs`(int)、`models`(map)、`stage`(str)、full guards(H_progress 为含 `pass` 的嵌套对象)、gatespec.driver;先跑、看它因字段缺失而失败。 +- GREEN:在 summary 构造段补这些字段。 +- 回归:cheap-worker 既有全套测试(U-A/M1 累计)零回归;一次真生成跑通、现有产物形态不变。 + +### U2 · result_out 组 payload["trace"] 七项(cheap-worker 侧 · 本机可执行) + +**交付物:** result_out.build_result_out 产出的 `trace` 段含必填七项 + D11 首局维所需 `gatespec`/`sevenGateVerdict`,camelCase 镜像 `_extract_trace`。 +**改动点:** cheap-worker/result_out.py(build_result_out 的 trace 组装段 L145-153 附近)。 +**Approach:** 从 U1 富化后的 summary 抽出七项组 `payload["trace"]`:`pass`/`repairs`/`wallS`(summary.wallSec)/`models`/`attempts`/`gameId`/`stage`;`sevenGateVerdict` 只取 `{pass, guards}`(去 url/ts 噪声,对齐 `_extract_trace`:148-154);`gatespec` 带 driver(**键名须落 `driver`、值 = `ensure_play_spec` 的 driverType;落成 `driverType` 则 ReadinessScorer `hasDriver=false`、firstPlay 永中性**);`cost.totalRmb` 维持现状。键名严格 camelCase 且逐字镜像 ReadinessScorer 读取的确切键——注:`trace` 是后端开放 `Map`(DifyCallbackReqVO.trace),Jackson 只校验 10 个顶层字段、**不拒 trace 内层未知键**,键名拼错(如 `seven_gate_verdict`)会被静默接收、ReadinessScorer 读不到 → 悄悄落中性(假绿);真护栏只有 U2 跨语言切片喂真 ReadinessScorer,别寄望「后端拒未知」。失败路同样抽 trace(repairs/attempts/stage 对排障有价值,对齐旧路成功/失败都抽)。 +**TDD:** +- RED:扩 test_result_out.py,断言成功路 trace 含七项 + `sevenGateVerdict.guards.H_progress.pass` + `gatespec.driver`;断言失败路 trace 仍含七项(排障);先跑、看缺字段失败。 +- GREEN:实现 trace 组装。 +- 跨语言切片:扩 game-cloud 的 CheapWorkerResultOutContractTest(M3a 建),用真 fixture 喂 ReadinessScorer.score,**逐维断言** playability/firstPlay/stability 各自脱离中性 0.5(证明信号真进算分、非看合成分落点),且九门全过 fixture 与勉强启动 fixture 算出**显著拉开**的分数。不用「脱离 50–55」作判据——便宜档真实成本下效率维恒 0、未修态合成分即 45 本在区间外,看区间会假绿(M3a 教训:单测桩绕真消费)。 + +### U3 · D9 反同质移植到 cheap-worker(cheap-worker 侧 · 本机可执行 · 创始人定 B、必做) + +**交付物:** cheap-worker 回调产 `trace.similarity`,撞重只告警不拦截。 +**改动点:** 新增 `cheap-worker/dedup.py`(vendored 自 wg1 纯模块)+ 回调路接线调 `check_similarity` + result_out trace 段带 similarity。 +**Approach:** 把 wg1 `worker/agent_loop/dedup.py`(纯模块:`norm_text`/`compute_sig`/`check_similarity`,无外部依赖)**vendored 复制进 `cheap-worker/dedup.py`**,`DEDUP_REGISTRY` 设为 `cheap-worker/results/_dedup_registry.jsonl`(天然路径隔离,无需跨树 import 或 monkey-patch——tier2/gen-worker 在 path 上但无 dedup、wg1 不在 path 上,见 KTD2)。在回调组装前以 `title=_derive_title(brief)`、`theme="generic"`、`trace_id` 调 `check_similarity`,把返回的 `{dupHit,titleNorm,sig,dupWith}` 塞进 `trace.similarity`。容错降级(登记表读写失败 → dupHit=None、不阻断)沿用现成逻辑。 +**TDD:** RED 断言 trace 含 similarity 且第二次同 brief 撞重 dupHit=true;GREEN vendor + 接线;断言撞重不阻断回调(status 仍按九门判)+ registry 落 cheap-worker/results/(路径隔离、不与 wg1 串台)。 +**(决策点已定 B:本单元必做;A 的暂缓分支不适用。)** + +### U4 · iso 开 trace 落库 + D11 真验(mini-desktop · 真 e2e) + +**交付物:** iso :48090 上 trace_json 落齐七项、readiness_score 逐维信号真进算分(三维非全中性)。 +**Approach:** 在 iso 后端配置**同时开两道 feature-flag**:`aigc.trace.enabled=true`(trace 落库前置)与 `aigc.control-plane.enabled=true`(D12/GP9 前置,默认 false 时整段旁路、U5 的拒绝证据无从产生)+ 占位配额/背压阈值;重启 iso :48090(端口精确清理、不碰 live :48080)。经 worker:9501 真生成一款,查 DB `game_aigc_task` 该任务 `trace_json` 七项齐全、`readiness_score` 非 NULL 且逐维信号真进算分(playability/firstPlay/stability 非全中性)。绕代理直连(root@100.64.0.7,Python ProxyHandler({}))沿用 M3a 范式。 +**验证:** DB 实证 trace_json 七项 + readiness 可区分;non-blocking 复验(故意喂坏 trace 看主链仍 SUCCEEDED)。 + +### U5 · create→发布→feed→真玩 端到端 + 6 门逐门复验(mini-desktop · 真 e2e + 真浏览器) + +**交付物:** 一款 cheap-worker 新路生成的游戏,经发布门进 feed,在真浏览器里真玩;6 门逐门给出新路证据。 +**Approach:** 提交一句话 brief → 生成 → 回调七步 → `reviewProject(APPROVE)` → publish → `getFeedStream` 含该款 → manifest 取 engineBundle → 经 `/gstack` 在真浏览器打开玩家页,确认走 `__GameBundle.bootGameHost` 真引擎(非"模板体系重构中"兜底壳)、可真玩。6 门复验(D12/GP9 的实证前置 = U4 已开 `aigc.control-plane.enabled`,否则两门旁路、只能记「架构继承推断、非新路实证」、不计入「6 门全验过」):D12(配额/背压拒绝走业务码 HTTP200)、GP9(违规 brief 被 AIGC_UNSAFE_PROMPT 挡、0 入队)、9d trace(U4 证据)、D11(U4 逐维信号证据)、首局体验(harness verdict 含首局门)、D9(按 R3)。 +**验证:** 真浏览器真玩截图/日志为准(非 stub 通道);6 门逐门 PASS 证据表。 + +### U6 · 蒸馏 + 残留收口 + +**交付物:** 把 trace parity 范式蒸馏回 `.agents/`,更新 memory,plan close-out。 +**Approach:** 更新 `.agents/skills/gen-path-parity-harness.md`(补 9d trace 七项 parity 段 + D11 退化判别法);更新 memory m3a→m3b 进度;plan 文末补"执行发现";残留显式列:生产 cutover(创始人)、待拍数值(配额/D11 权重/D9 阈值)、D9(若决策点 A)、审核台 UI(产品轨)。 + +## 迁移对账(承接 plan① 切片一 · M3b 兑现的移交项) + +| plan① 切片一 M3 项 | M3b 归属 | 兑现单元 | +|---|---|---| +| create→feed 生成线端到端打通 | ✅ 本里程碑 | U4 + U5 | +| 对外开闸 6 门验过 | ✅ 本里程碑(新路复验) | U1/U2(9d trace+D11)、U3(D9 决策点)、U5(D12/GP9/首局复验) | +| 后端路由 flag / D12 接缝 | 已在 M3a 交付 | — | +| 生产真实分布达标门复验 ≥80% | 🔶 延后(需生产 cutover 后) | 创始人决策后 | +| Node 旧路退役(生产 cutover) | 🔶 延后创始人决策 | 不在 M3b | +| studio 创作会话/资产/进度流/重生成 | ❌ 后端线(移交边界) | 不在 M3b | + +## Scope Boundaries + +**做(in scope):** cheap-worker 9d trace 七项产出(U1/U2)、随之救活 D11(零后端改动)、D9 决策点(U3)、iso 上 trace 落库 + D11 + 端到端 create→feed→真玩 真验、6 门新路逐门复验。 + +**不做(out of scope):** 生产 cutover(切 live :48080 dispatcher saa→http,= 真 Node 退役 go-live,创始人决策);待拍数值(会员档配额/并发、D11 权重校准、D11 效率维预算阈值 `BUDGET_RMB`、D9 模糊相似阈值);studio 模块的创作会话/资产模块化/进度流/重生成编排(后端线);审核台 aigc 列表页与就绪分展示 UI(产品轨);多 worker 横向扩容(远期)。 + +## Risks & Dependencies + +- **R-A · trace 富化破坏现有生成(回归风险)。** 缓解:U1 纯 additive、不改生成循环;cheap-worker 既有全套测试零回归 + 一次真生成形态不变作门。 +- **R-B · trace 七项口径与后端 ReadinessScorer 读取不对齐(假兑现)。** 缓解:U2 跨语言切片测用真 fixture 喂真 ReadinessScorer,**逐维**断言三维脱离中性 + 两 fixture 分数拉开——不靠本机自证、不靠「合成分落点」(便宜档效率维恒 0,合成分判据会假绿)。这正是 M3a 三个真 bug 的教训(单测桩会绕过真消费)。 +- **R-C · aigc.trace.enabled 默认 false 致验证假绿(误判 worker 没产)。** 缓解:U4 第一步显式开开关 + DB 实证。 +- **R-D · 真玩验证走了 stub 通道而非 real 引擎(假绿)。** 缓解:U5 在真浏览器确认 `boot-game-host.js` 走 real 通道(L467-496)、非 stub(L436-448)、非兜底壳。 +- **R-E · iso 操作误伤 live :48080。** 缓解:端口精确清理、绝不全局 pkill huijing-server.jar(M3a 铁律);trace 开关只改 iso 配置。 +- **依赖:** mini-desktop 可达(ssh 绕代理)、M3a iso :48090 + worker :9501 仍在(验证态)、真浏览器经 /gstack。 + +## Verification(里程碑收口) + +- cheap-worker 本机:U1/U2/U3 测全绿 + 既有全套零回归 + 跨语言切片测逐维断言三维脱离中性 + 两 fixture 分数拉开。 +- iso :48090 真 e2e:trace_json 七项齐 + readiness 逐维信号真进算分(两款分数拉开)+ non-blocking 复验 + create→发布→feed→真浏览器真玩 + 6 门逐门 PASS 证据(D12/GP9 已开 control-plane)。 +- 收口判据 = create→feed→真玩 在 cheap-worker 新路端到端真通 + 6 门(D9 按决策点)在新路验过;**不含**生产 cutover。 + +## Sources & Research + +代码侦察(2026-06-28,HEAD a327c991):result_out.py:148-150、cheap_studio.py:154/157/164-184、wg1 service.py:91-218、dedup.py、ReadinessScorer.java:52-165、DifyCallbackTxService.java:101/252-282、PublishOrchestrationServiceImpl.java:57-100、inject.ts:240-271、FeedServiceImpl.java:237-239(读侧 PUBLISHED 过滤;515/525-532 是写侧 upsertPublishBaseline 可见态校验)。6 门 SoT:验收门.md(canonical)。plan① 切片一 M3:2026-06-25 统一执行计划。 + +## 待创始人决策(M3b) + +- **D9 范围(本计划决策点):** 选项 A 暂缓(对齐 SAA 先例 + 最小)/ 选项 B 补齐(完成单一路 parity)。 +- **生产 cutover(延后):** 何时切 live :48080 dispatcher saa→http(= 真 Node 退役 go-live)。 +- **待拍数值(延后):** 会员档配额/并发、D11 权重校准、**D11 效率维预算阈值 `BUDGET_RMB`(当前 0.15 与便宜档真实成本 ¥0.4–1.2 错配、效率维恒 0 零区分力)**、D9 模糊相似阈值。 + +## 双评审发现与处置(2026-06-28 · Opus 权威 + Codex 部分轨迹佐证) + +Opus 对抗评审判 needs-rework(窄域),3 MAJOR + 5 MINOR,均 code-grounded、精确小改、不动架构;承重断言(D11 退化机制、trace 开关、create→feed→play 链、parity 参考)逐条核验属实。Codex 路(codex-rescue)走后台不可取回、末段 reconnect 错误未产出最终分级评审,但其调查轨迹(读 studio.py:330-386 的 repairs 源、reviewProject→publish、D12/GP9 共享入队门)佐证 Opus 发现、无新增项;按 §6.8 Codex 不可用回落 Opus 单评为权威。全部发现已在本文修复。 + +| 级别 | 发现 | 处置 | +|---|---|---| +| MAJOR | M1 「脱离 50–55 死区」是假绿判据:便宜档真实成本 ¥0.4–1.2 ≥ 2×`BUDGET_RMB`(0.15)→ 效率维恒 0,退化分实为 45/55 双峰(主峰 45、低于 50),未修态也「脱离 50–55」→ 测试假证修好 | 删此判据,改**逐维断言**(playability/firstPlay/stability 各自脱离中性 0.5)+ 两 fixture 分数拉开;`BUDGET_RMB` 入待拍数值。改动:Summary / Problem Frame / R2 / U2 切片 / R-B / Verification / 图1 节点 | +| MAJOR | M2 `repairs=attempts` off-by-one:cheap_studio `attempts=attempt+1`、干净一次过=1,但 repairs 语义首轮 0(wg1 `len(attempts)-1`、SAA 注释证)→ 好游戏 stability=0.7 永不满分、破 parity | 改 `repairs=max(0, attempts-1)`,注明踹-finish 分支口径偏差(KTD1 / U1) | +| MAJOR | M3 「6 门复验」漏开 `aigc.control-plane.enabled`(默认 false → D12/GP9 旁路)→ 两门复验真空通过 | U4 同时开 control-plane flag + 占位阈值;U5 注明否则两门记「继承推断、非实证」不计入 6 门 | +| MINOR | m1 「后端拒未知字段」对 trace 内层不成立(开放 Map,Jackson 只校验 10 个顶层字段)→ 键名拼错静默落中性 | U2 改述:trace 键名正确性的真护栏只有跨语言切片喂真 ReadinessScorer | +| MINOR | m2 `gatespec.driver` 键名陷阱(源是 driverType,须落键名 `driver`) | U2 显式钉死键名映射 | +| MINOR | m3 D9「重接」实为 monkey-patch 模块级全局常量 `DEDUP_REGISTRY`、非传参 | KTD2 / 决策点改述 + U3 加路径隔离测试 | +| MINOR | m4 §Sources feed 读侧行号引错(515/525-532 是写侧,读侧 PUBLISHED 过滤在 237-239) | Sources 改正 | +| MINOR | m5 散文 4 处元叙述踩 §12 铁律 | 删「钉死了 / 收敛成一句话 / 要诚实记下 / 结论是乐观的」 | +| 补注 | run_gates=False 对照路不产 verdict/gatespec | U1 注明捕获须对 None 容错 | + +## 执行发现(2026-06-28 · 本机 U1-U3 完成 + 验证 · mini-desktop 前 checkpoint) + +本机阶段 U1-U3 已交付并验证(opus 子代理执行 + 主会话亲验)。 + +- **U1/U2/U3 全绿**:cheap_studio 富化(`build_trace_source` + `_furthest_stage`)/ result_out `_build_trace`(七项 + sevenGateVerdict`{pass,guards}` + gatespec`{driver}` + cost + similarity,camelCase 镜像 `_extract_trace`、对 verdictFull/driverType None 容错)/ D9 `dedup.py` vendored 进 cheap-worker(`DEDUP_REGISTRY` 指自己 results/)。cheap-worker **全 16 测文件绿**(test_result_out 18/18、test_trace_source 5/5、test_dedup 6/6、test_worker_service 15/15)。 +- **跨语言反假绿门坐实(Opus M1 真护栏)**:Java `CheapWorkerResultOutContractTest` 新增 `readiness_perDimension_escapes_neutral_and_diverges`——反射调真 ReadinessScorer 私有逐维 scorer:成功 fixture 三维 playability/firstPlay/stability **各=1.0(脱离中性 0.5)**、合成分 **100** vs 仅-cost 退化态 **≤55**、拉开 **≥35**。mvn 实跑 **5/5 绿、BUILD SUCCESS**。坐实 9d trace parity 真救活 D11(非区间-落点假绿)。 +- **顺带修一处预存 M3a 缺陷**:`test_worker_loop_run_failure_sends_fallback_failed` 在 HEAD a327c991 即红——worker 异常兜底 `_send_failed` 传非枚举值 `generation_failed`(经 build_result_out 映射成 `llm_error`,回调行为正确但测断言陈旧)。对代码核实 M3b 未碰相关行、确属预存 → 改 `_send_failed` 传真枚举值 `llm_error`(错误路用真枚举、可追溯)+ 测断言对齐。 +- **执行中纠正的设计假设(红线兑现)**:计划初稿假设「_bootstrap 已把 gen-worker 入 sys.path、可 import wg1 dedup」——核验证伪(_bootstrap 入的是 `tier2/gen-worker`、其无 dedup/_extract_trace;wg1 是另一棵树、不在 cheap-worker 的 import 路径)。U3 据此改为 **vendor 复制** `dedup.py`(更干净、并消除 Opus m3 的 monkey-patch 脆点)。 +- **范围**:改动仅 cheap-worker/(cheap_studio / result_out / worker_service / 新 dedup.py + 4 个测文件)+ 2 个 Java fixture + 1 个 Java 测方法;零跨档、零 live 触碰。 +- **待续**:U4/U5 mini-desktop 真 e2e(开 `aigc.trace.enabled` + `aigc.control-plane.enabled`、trace 落库 + D11 真验、create→发布→feed→真玩 + 6 门复验);U6 蒸馏。 + +--- + +> **状态:** 已过 §6.8 双评审(发现全修)。**创始人定 D9=B**;**本机 U1-U3 ✅ 完成 + 验证**(全 16 Python 测绿 + Java 跨语言切片 5/5 绿 + 预存红测修)。mini-desktop U4-U5 待 checkpoint 后续。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/CheapWorkerResultOutContractTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/CheapWorkerResultOutContractTest.java index f06b101e..67718a6d 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/CheapWorkerResultOutContractTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/CheapWorkerResultOutContractTest.java @@ -5,6 +5,7 @@ import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallbackReqVO; import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO; import com.wanxiang.huijing.game.module.aigc.service.executor.CallbackSignatureVerifier; +import com.wanxiang.huijing.game.module.aigc.service.task.ReadinessScorer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,6 +13,8 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -121,4 +124,46 @@ class CheapWorkerResultOutContractTest extends BaseMockitoUnitTest { assertTrue(verifier.verify(rawBody, workerSig), "worker 算的 HMAC 应被后端验签接受"); assertFalse(verifier.verify(rawBody, "deadbeef" + workerSig.substring(8)), "篡改签名应被拒"); } + + @Test + @DisplayName("M3b: 修后 trace 喂真 ReadinessScorer — 三维各自脱离中性 0.5 + 成功vs退化分显著拉开(反假绿)") + void readiness_perDimension_escapes_neutral_and_diverges() throws Exception { + DifyCallbackReqVO succeeded = JSON.readValue(readFixture("u1-result-out-succeeded.json"), DifyCallbackReqVO.class); + Map trace = succeeded.getTrace(); + assertNotNull(trace, "succeeded trace 不可空(U1/U2 已产 9d trace)"); + // 必填七项齐(轨迹完整率 7/7) + for (String k : new String[]{"pass", "repairs", "wallS", "models", "attempts", "gameId", "stage"}) { + assertTrue(trace.containsKey(k), "9d trace 必填七项缺:" + k); + } + + ReadinessScorer scorer = new ReadinessScorer(); + // 逐维反射断言:成功 fixture 三维各自脱离中性 0.5——证明 U1/U2 新产的 pass / gatespec.driver / + // sevenGateVerdict.guards.H_progress / repairs 真进了算分,而非看合成分落点(Opus M1:便宜档效率维 + // 恒 0,只看合成分会假绿)。 + Method mPlay = ReadinessScorer.class.getDeclaredMethod("scorePlayability", Map.class); + Method mFirst = ReadinessScorer.class.getDeclaredMethod("scoreFirstPlay", Map.class); + Method mStab = ReadinessScorer.class.getDeclaredMethod("scoreStability", Map.class); + mPlay.setAccessible(true); + mFirst.setAccessible(true); + mStab.setAccessible(true); + double playability = (double) mPlay.invoke(scorer, trace); + double firstPlay = (double) mFirst.invoke(scorer, trace); + double stability = (double) mStab.invoke(scorer, trace); + assertEquals(1.0, playability, 1e-9, "playability 应=1.0(trace.pass=true),非中性 0.5"); + assertEquals(1.0, firstPlay, 1e-9, "firstPlay 应=1.0(gatespec.driver + guards.H_progress.pass),非中性 0.5"); + assertEquals(1.0, stability, 1e-9, "stability 应=1.0(repairs=0),非中性 0.5"); + + // 可区分性 + 反假绿:仅保留 cost 的退化 trace(模拟 M3b 修复前 result_out 只产 trace.cost)三维全中性 + // → 合成分 ~45-55;修后全 trace 真信号 → 显著更高。证明退化被治好(非"分数落在某区间")。 + Integer fullScore = scorer.score(trace); + Map degenerate = new HashMap<>(); + degenerate.put("cost", trace.get("cost")); + Integer degenerateScore = scorer.score(degenerate); + assertNotNull(fullScore); + assertNotNull(degenerateScore); + assertTrue(fullScore >= 90, "修后九门全过游戏就绪分应≥90(四维真信号);实得 " + fullScore); + assertTrue(degenerateScore <= 55, "仅 cost 退化 trace 就绪分应≤55(三维中性);实得 " + degenerateScore); + assertTrue(fullScore - degenerateScore >= 35, + "修后 vs 退化态就绪分应显著拉开(≥35);实得 " + fullScore + " vs " + degenerateScore); + } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-failed.json b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-failed.json index afc15dd7..b35c5146 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-failed.json +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-failed.json @@ -1,13 +1,27 @@ { - "traceId": "fix-fail", + "traceId": "cheap-fail", "status": "failed", "templateId": "generic", "gameConfig": { "templateId": "generic", - "title": "x", + "title": "做个超复杂的3D开放世界", "theme": "generic", "engineDriven": true }, "assets": [], - "failureReason": "llm_error" -} \ No newline at end of file + "failureReason": "llm_error", + "trace": { + "pass": null, + "repairs": 5, + "wallS": 41.2, + "models": { + "code": "MiniMax-M3" + }, + "attempts": 6, + "gameId": "amgen-cheap-fail", + "stage": "code", + "cost": { + "totalRmb": 0.0 + } + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-succeeded.json b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-succeeded.json index 007ac8b1..e638d06e 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-succeeded.json +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/cheap-worker/u1-result-out-succeeded.json @@ -1,5 +1,5 @@ { - "traceId": "fix-ok", + "traceId": "cheap-ok", "status": "succeeded", "templateId": "generic", "gameConfig": { @@ -11,9 +11,41 @@ "assets": [], "engineBundle": "var __GameBundle=(function(){return{bootGameHost(){}}})();", "trace": { + "pass": true, + "repairs": 0, + "wallS": 98.5, + "models": { + "code": "MiniMax-M3" + }, + "attempts": 1, + "gameId": "amgen-cheap-ok", + "stage": "play", + "sevenGateVerdict": { + "pass": true, + "guards": { + "A_boot": { + "pass": true + }, + "B_render": { + "pass": true + }, + "H_progress": { + "pass": true, + "checks": [ + "score:0->3" + ], + "latch": true, + "state0": 0, + "state1": 3 + } + } + }, + "gatespec": { + "driver": "tap-targets" + }, "cost": { "totalRmb": 0.0431 } }, - "sourceProject": "{\"schemaVersion\": \"2.0\", \"sourceHash\": \"8ae43cb89961908df58074d3f79f0da5a4c38dfe68e9b82299052b18922bb043\", \"profile\": {\"tickModel\": \"frame\", \"inputModel\": \"tap-targets\", \"progressModel\": \"score\"}, \"files\": {\"src/core.js\": \"// c\\n\", \"src/game-logic.js\": \"// l\\n\"}, \"entry\": \"entry.js\", \"globalName\": \"__GameBundle\"}" -} \ No newline at end of file + "sourceProject": "{\"schemaVersion\": \"2.0\", \"sourceHash\": \"0f7c7d44c1df71cd6085628e25e0077cf08056e6c0a583dde16285e0de0b01b4\", \"profile\": {\"tickModel\": \"frame\", \"inputModel\": \"tap-targets\", \"progressModel\": \"score\"}, \"files\": {\"src/core.js\": \"// 引擎骨架\\n\", \"src/game-logic.js\": \"// 点击得分核心逻辑\\n\"}, \"entry\": \"entry.js\", \"globalName\": \"__GameBundle\"}" +}