"""test_a11_m3_deterministic.py — A11 切片三 M3「确定性类纠错执行」单测(execution 段零 LLM)。 守的不变量: · apply_deterministic_modify(纯函数):config 改 core.js 一处常量 / asset 改 assets.js 一处资产键 / 多常量一次改 / 字符串·数组值 / 多行常量(Object.freeze)/ **no-op 检测**(目标找不到 → found=false、ok=false, 断言①地基)/ 坏输入容错不抛。 · materialize_source_project:base 源全 files(含 L1 plumbing)系统级落盘到 game_dir(绕 write_file 的 L1 守卫)。 · worker 接线:modifyMode=deterministic 走确定性执行(manifest 附进 trace、成功带新版 sourceProject); regenerate-module → 显式 failed(不静默吞);create 路(无 modifyMode)零副作用、不调 modify_fn。 跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m3_deterministic.py """ import hashlib import json import sys import tempfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ import cheap_run # noqa: E402 import cheap_modify as M # noqa: E402 import worker_service as W # noqa: E402 import dedup # noqa: E402 import pytest # noqa: E402 # game_dir 与 dedup 登记表在本模块测试期间重定向到临时目录:materialize、worker 接线、create 路(D9 dedup) # 的落盘都进临时目录,不碰真 games/ 与真登记表(cheap_modify、worker_service 与本模块同引用一个 cheap_run # 模块对象,改模块属性即对三者同时生效)。重定向收进 autouse fixture:只在本模块测试期间生效、跑完即恢复 # 原值,不再像早先那样在 import 时无恢复地替换 cheap_run.game_dir——那样会污染同会话后续的 test_run / # test_studio_smoke / test_toolkit(它们按真 games/ 根校验 write 边界,game_dir 被换后一写就判越界)。 def _install_tmp_redirect(): """把 cheap_run.game_dir、dedup.DEDUP_REGISTRY 指向新建临时目录,返回二者原值供恢复。""" orig = (cheap_run.game_dir, dedup.DEDUP_REGISTRY) tmp_games = Path(tempfile.mkdtemp(prefix="a11-m3-games-")) cheap_run.game_dir = lambda gid: tmp_games / f"amgen-{gid}" # noqa: E731 dedup.DEDUP_REGISTRY = Path(tempfile.mkdtemp(prefix="a11m3-dedup-")) / "_dedup_registry.jsonl" return orig @pytest.fixture(autouse=True, scope="module") def _tmp_redirect(): orig_game_dir, orig_registry = _install_tmp_redirect() try: yield finally: cheap_run.game_dir = orig_game_dir dedup.DEDUP_REGISTRY = orig_registry _GOOD_BUNDLE = "var __GameBundle=(function(){return{bootGameHost(){}}})();" # ───────────────────────── ① apply_deterministic_modify ───────────────────────── def test_config_change_round_ms(): """config:ROUND_MS 30000→20000,core.js 那一处变、别处不动、manifest 命中、ok=true。""" sp = {"files": {"src/core.js": "// core\nexport const ROUND_MS = 30000;\nexport const PAD = 4;\n"}} patch = {"target": {"kind": "config", "path": "core.js#ROUND_MS"}, "payload": {"value": {"ROUND_MS": 20000}}} out = M.apply_deterministic_modify(sp, patch) assert out["ok"] is True, out assert "export const ROUND_MS = 20000;" in out["files"]["src/core.js"] assert "export const PAD = 4;" in out["files"]["src/core.js"], "别的常量不应被动" rec = out["manifest"][0] assert rec["found"] is True and rec["key"] == "ROUND_MS" assert rec["old"] == "30000" and rec["new"] == "20000" assert rec["file"].endswith("core.js") and rec["kind"] == "config" def test_asset_change_image_file(): """asset:assets.js IMAGE_FILES 的 heroSprite 换文件名,manifest 命中、别的资产不动。""" sp = {"files": {"src/assets.js": "export const IMAGE_FILES = { heroSprite: 'hero.png', bg: 'bg.jpg' };\n"}} patch = {"target": {"kind": "asset", "path": "assets.js#heroSprite"}, "payload": {"value": {"heroSprite": "hero-green.png"}}} out = M.apply_deterministic_modify(sp, patch) assert out["ok"] is True, out assert "hero-green.png" in out["files"]["src/assets.js"] assert "bg.jpg" in out["files"]["src/assets.js"], "别的资产键不应被动" rec = out["manifest"][0] assert rec["found"] is True and rec["old"] == "hero.png" and rec["new"] == "hero-green.png" assert rec["kind"] == "asset" def test_asset_base_url_change(): """asset:baseUrl 特例 → 换 loadAssets 默认资产根。""" sp = {"files": {"src/assets.js": "const root = baseUrl == null ? './assets/' : baseUrl;\n"}} patch = {"target": {"kind": "asset"}, "payload": {"value": {"baseUrl": "https://cdn.x/g/"}}} out = M.apply_deterministic_modify(sp, patch) assert out["ok"] is True, out assert "https://cdn.x/g/" in out["files"]["src/assets.js"] assert out["manifest"][0]["old"] == "./assets/" def test_noop_const_not_found(): """no-op(断言①地基):目标常量不存在 → found=false、整体 ok=false、带 error。""" sp = {"files": {"src/core.js": "export const ROUND_MS = 30000;\n"}} patch = {"target": {"kind": "config"}, "payload": {"value": {"NOT_THERE": 5}}} out = M.apply_deterministic_modify(sp, patch) assert out["ok"] is False, "no-op 不应算真改动" assert out["manifest"][0]["found"] is False assert out["error"] # 文本未被改动。 assert out["files"]["src/core.js"] == "export const ROUND_MS = 30000;\n" def test_noop_same_value(): """no-op:新值与旧值相同(常量命中但无变化)→ ok=false。""" sp = {"files": {"src/core.js": "export const SPEED = 5;\n"}} patch = {"target": {"kind": "config"}, "payload": {"value": {"SPEED": 5}}} out = M.apply_deterministic_modify(sp, patch) assert out["manifest"][0]["found"] is True, "常量命中" assert out["ok"] is False, "命中但值未变 = no-op" def test_string_array_and_multi_const(): """字符串值 / 数组值 / 多常量一次改 —— 都支持,manifest 三条全命中。""" sp = {"files": {"src/core.js": ( "export const SPEED = 5;\n" "export const LABEL = 'old';\n" "export const KEYS = ['a','b'];\n" )}} patch = {"target": {"kind": "config"}, "payload": {"value": {"SPEED": 8, "LABEL": "new", "KEYS": ["x", "y", "z"]}}} out = M.apply_deterministic_modify(sp, patch) assert out["ok"] is True, out txt = out["files"]["src/core.js"] assert "export const SPEED = 8;" in txt assert 'export const LABEL = "new";' in txt, "字符串值 → JS 字符串字面量(json 双引号合法)" assert 'export const KEYS = ["x", "y", "z"];' in txt, "数组值 → JS 数组字面量" assert len(out["manifest"]) == 3 and all(r["found"] for r in out["manifest"]) def test_config_searches_other_l3_when_not_in_core(): """config 常量不在 core.js 时,按优先序搜其它 L3(game-logic.js)并命中。""" sp = {"files": { "src/core.js": "export const A = 1;\n", "src/game-logic.js": "export const SPAWN_RATE = 2;\n", }} patch = {"target": {"kind": "config"}, "payload": {"value": {"SPAWN_RATE": 9}}} out = M.apply_deterministic_modify(sp, patch) assert out["ok"] is True, out assert "export const SPAWN_RATE = 9;" in out["files"]["src/game-logic.js"] assert out["manifest"][0]["file"].endswith("game-logic.js") def test_multiline_const_replace(): """多行常量(Object.freeze 跨行)→ 多行兜底正则替换,且不吞掉其后的常量。""" sp = {"files": {"src/core.js": ( "export const PALETTE = Object.freeze([\n" " { fill: '#fff', score: 1 },\n" " { fill: '#000', score: 2 },\n" "]);\n" "export const NEXT = 7;\n" )}} patch = {"target": {"kind": "config"}, "payload": {"value": {"PALETTE": [1, 2, 3]}}} out = M.apply_deterministic_modify(sp, patch) assert out["ok"] is True, out txt = out["files"]["src/core.js"] assert "export const PALETTE = [1, 2, 3];" in txt assert "export const NEXT = 7;" in txt, "多行替换必须锚到本常量行尾分号,不能吞掉后续常量" def test_level_complex_layout_unsupported(): """level:复杂布局重排(value 非 {常量名:新值})→ unsupported、ok=false。""" sp = {"files": {"src/core.js": "export const X = 1;\n"}} out = M.apply_deterministic_modify(sp, {"target": {"kind": "level"}, "payload": {"layout": [[1, 0], [0, 1]]}}) assert out["ok"] is False and "level" in (out["error"] or "") def test_level_const_text_replace_supported(): """level:关卡常量文本替换(payload.value={常量名:新值})与 config 同路,支持。""" sp = {"files": {"src/core.js": "export const LEVEL_ROWS = 8;\n"}} out = M.apply_deterministic_modify(sp, {"target": {"kind": "level"}, "payload": {"value": {"LEVEL_ROWS": 12}}}) assert out["ok"] is True assert "export const LEVEL_ROWS = 12;" in out["files"]["src/core.js"] assert out["manifest"][0]["kind"] == "level" def test_tolerant_bad_inputs(): """坏 source_project / 坏 patch / 非确定性 kind —— 容错返 ok=false,绝不抛。""" assert M.apply_deterministic_modify("{not json", {"target": {"kind": "config"}, "payload": {"value": {"X": 1}}})["ok"] is False assert M.apply_deterministic_modify(None, {})["ok"] is False assert M.apply_deterministic_modify({"files": {"src/core.js": "export const X = 1;\n"}}, "{bad")["ok"] is False assert M.apply_deterministic_modify({"files": {"src/core.js": "x"}}, None)["ok"] is False out = M.apply_deterministic_modify({"files": {"src/core.js": "x"}}, {"target": {"kind": "behavior"}, "payload": {"value": {"X": 1}}}) assert out["ok"] is False and "behavior" in (out["error"] or "") def test_apply_does_not_mutate_input(): """apply 不原地改入参 files(返回的是改后拷贝)。""" sp = {"files": {"src/core.js": "export const A = 1;\n"}} M.apply_deterministic_modify(sp, {"target": {"kind": "config"}, "payload": {"value": {"A": 9}}}) assert sp["files"]["src/core.js"] == "export const A = 1;\n", "入参 files 不应被原地改动" # ───────────────────────── ② materialize_source_project ───────────────────────── def test_materialize_writes_all_files_incl_l1(): """全 files(含 L1 plumbing host-config/game/index/entry)落盘到 game_dir(系统落盘绕 L1 守卫)。""" files = { "src/core.js": "export const A = 1;\n", "src/host-config.js": "// L1 plumbing host-config\n", "src/game.js": "// L1 wrapper game\n", "index.html": "g", "entry-bundle.js": "// L1 entry\n", } gd = M.materialize_source_project("mat1", files) assert (gd / "src" / "core.js").read_text(encoding="utf-8") == "export const A = 1;\n" assert (gd / "src" / "host-config.js").exists(), "L1 host-config 也应落盘(系统落盘不拦 L1)" assert (gd / "src" / "game.js").exists(), "L1 game.js 也应落盘" assert (gd / "index.html").read_text(encoding="utf-8") == "g" assert (gd / "entry-bundle.js").exists(), "L1 entry-bundle 也应落盘" def test_materialize_skips_escape_path(): """越界路径(../ 逃逸)被跳过、不写到 game_dir 外,且不抛。""" gd = M.materialize_source_project("mat2", {"../escape.js": "x", "src/ok.js": "y"}) assert (gd / "src" / "ok.js").exists() assert not (gd.parent / "escape.js").exists(), "越界路径必须被跳过" def test_deterministic_v3_reject_uses_one_restricted_writer(monkeypatch): """A11 确定性改首轮 v3 verified reject 后只调用一次受限 writer,并以复验结果收口。""" import cheap_verify gid = "det-v3-repair" gd = cheap_run.game_dir(gid) gd.mkdir(parents=True, exist_ok=True) (gd / "entry-bundle.js").write_text("// entry", encoding="utf-8") origin_brief = "一个数字华容道解谜" source = {"scaffoldTemplate": "_template-puzzle", "originBrief": origin_brief, "originBriefHash": hashlib.sha256(origin_brief.encode("utf-8")).hexdigest(), "files": {"src/core.js": "export const ROUND_MS = 30000;\n"}} patch = {"target": {"kind": "config"}, "payload": {"value": {"ROUND_MS": 20000}}} monkeypatch.setattr(cheap_run, "build", lambda game_id: {"ok": True, "output": ""}) monkeypatch.setattr(cheap_run, "stage", lambda game_id: {"ok": True, "output": ""}) monkeypatch.setattr(cheap_run, "smoke", lambda *a, **k: {"ok": True}) verdict = {"pass": False, "guards": {name: {"pass": True} for name in cheap_verify._FLOOR_GATES}} monkeypatch.setattr(cheap_run, "play", lambda *a, **k: {"ok": True, "verdict": verdict}) monkeypatch.setattr(cheap_verify, "_acceptance_v3_cfg", lambda: {"mode": "v3"}) async def _reject(req): return {"runId": "a11-r1", "floor": {"pass": True}, "artifactHash": "a" * 64, "acceptanceRequestHash": req["acceptanceIdentity"]["acceptanceRequestHash"], "decision": {"outcome": "reject", "accepted": False, "publishFrozen": True, "repairEligible": True, "repairFeedback": "修闭环", "parentChainCostRmb": 0.2}, "compatibility": {"accepted": False, "ok": False, "acceptanceVersion": "v3", "trace": {}}} monkeypatch.setattr(cheap_verify, "run_acceptance_v3", _reject) monkeypatch.setattr(cheap_verify, "is_v3_repair_authorized", lambda payload: (payload.get("decision") or {}).get("repairEligible") is True) monkeypatch.setattr(cheap_verify, "is_v3_publishable", lambda payload: (payload.get("decision") or {}).get("outcome") == "accept") repair_calls = [] def _repair(game_id, intent, **kwargs): repair_calls.append((game_id, intent, kwargs)) return {"accepted": True, "acceptanceVersion": "v3", "verdictFull": verdict, "acceptanceV3": {"runId": "a11-r2", "decision": {"outcome": "accept", "accepted": True}}} monkeypatch.setattr(M, "_default_rewrite", _repair) out = M.execute_deterministic_modify( gid, source, patch, base_version_id="base-1", task_trace_id="trace-det-v3-repair") assert out["status"] == "succeeded" and len(repair_calls) == 1 _, intent, kwargs = repair_calls[0] assert intent == "修闭环" and kwargs["acceptance_repair_count"] == 1 assert kwargs["acceptance_parent_run_id"] == "a11-r1" assert kwargs["acceptance_brief"] == "一个数字华容道解谜", "复验沿用原游戏 brief,不得换成修复反馈" assert kwargs["genre"] == "puzzle" acceptance = out["acceptance"] assert acceptance["acceptanceV3FirstPass"]["runId"] == "a11-r1" assert acceptance["repairAttempted"] is True and acceptance["acceptedAfterRepair"] is True def test_a11_origin_brief_requires_nonempty_self_consistent_source_lineage(): """fresh worker 只能从 SourceProject 恢复原题面;缺失、空值和伪 hash 均 fail-closed。""" brief = "原始解谜题面" valid = {"originBrief": brief, "originBriefHash": hashlib.sha256(brief.encode("utf-8")).hexdigest()} assert M._resolve_a11_origin_brief(valid) == (brief, valid["originBriefHash"]) assert M._resolve_a11_origin_brief({}) == (None, None) assert M._resolve_a11_origin_brief({"originBrief": "", "originBriefHash": hashlib.sha256(b"").hexdigest()}) == (None, None) assert M._resolve_a11_origin_brief({"originBrief": brief, "originBriefHash": "a" * 64}) == (None, None) # ───────────────────────── ④ worker 接线 ───────────────────────── def test_worker_deterministic_route_attaches_manifest_on_failed(): """modifyMode=deterministic → 走确定性执行;失败(no-op)路也把 manifest/error 附进 trace 回调。""" captured = {} fake = {"status": "failed", "manifest": [{"file": "src/core.js", "kind": "config", "key": "X", "old": None, "new": "5", "found": False}], "verdict": None, "error": "no-op:常量未找到", "stage": "apply"} state = W.WorkerState(modify_fn=lambda gid, sp, patch, **kw: fake, send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), profile_fn=lambda j, g: None) job = {"job_id": "m1", "traceId": "m1", "modifyMode": "deterministic", "gameId": "nope-x", "sourceProject": "{}", "modifyPatch": "{}", "brief": "x", "callback": {"target": "http://cb"}} W.process_job(state, job) p = captured["payload"] assert p["status"] == "failed", "无 bundle + verdict 失败 → failed" assert p["trace"]["modifyManifest"][0]["key"] == "X" assert p["trace"]["modifyError"] == "no-op:常量未找到" def test_worker_deterministic_route_success_carries_bundle_and_source(): """deterministic 成功 → result-out 带 engineBundle(重建产物)+ manifest + 新版 sourceProject(链式改)。""" gid = "m3ok" gd = cheap_run.game_dir(gid) (gd / "src").mkdir(parents=True, exist_ok=True) (gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") (gd / "src" / "core.js").write_text("export const A = 1;\n", encoding="utf-8") captured = {} fake = {"status": "succeeded", "manifest": [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS", "old": "30000", "new": "20000", "found": True}], # 真实 verdict.guards 带完整四门(W-AXIS-V2 预筛权威 = 四门投影;build_result_out 据它算 status)。 "verdict": {"pass": True, "guards": {"A_boot": {"pass": True}, "B_uncaught": {"pass": True}, "C_frame": {"pass": True}, "D_render": {"pass": True}}}, "error": None, "stage": "play"} profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"} state = W.WorkerState(modify_fn=lambda *a, **kw: fake, send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), profile_fn=lambda j, g: profile) job = {"job_id": "m2", "traceId": "m2", "modifyMode": "deterministic", "gameId": gid, "sourceProject": '{"scaffoldTemplate":"_template-puzzle"}', "modifyPatch": "{}", "brief": "点点乐", "callback": {"target": "http://cb"}} W.process_job(state, job) p = captured["payload"] assert p["status"] == "succeeded" assert "__GameBundle" in p["engineBundle"], "成功路携重建后的 bundle" assert p["trace"]["modifyManifest"][0]["key"] == "ROUND_MS" assert "sourceProject" in p, "成功路 best-effort 带新版源(供下一次链式改)" assert json.loads(p["sourceProject"])["scaffoldTemplate"] == "_template-puzzle" def test_worker_modify_summary_preserves_v3_sealed_identity(): """deterministic 包装层不得丢 acceptanceV3,否则 result_out 会错误回落 legacy。""" sealed = { "runId": "run-1", "artifactHash": "a" * 64, "briefHash": "b" * 64, "acceptanceRequestHash": "c" * 64, "floor": {"pass": True}, "decision": {"outcome": "accept", "accepted": True, "publishFrozen": False}, "compatibility": {"accepted": True, "ok": True, "acceptanceVersion": "v3", "trace": {}}, } result = {"status": "succeeded", "stage": "play", "verdict": None, "acceptance": {"acceptanceV3": sealed}} summary = W._modify_summary(result, "g1") assert summary["acceptanceV3"] is sealed assert summary["acceptanceRunId"] == "run-1" assert summary["acceptanceArtifactHash"] == "a" * 64 assert summary["acceptanceBriefHash"] == "b" * 64 assert summary["acceptanceRequestHash"] == "c" * 64 def test_worker_regenerate_module_routes_to_executor_no_llm(): """modifyMode=regenerate-module(M4 已实现)→ 走 execute_regenerate_modify、不再占位 failed。 无 sourceProject/modifyPatch → intent 空,executor 早失败(零 LLM、不 scaffold/不重写)→ 仍发 failed 回调 (任务不挂 RUNNING)+ 失败原因进 trace(不静默吞)。验 regen 分流已接真执行器。完整 regen 单测见 test_a11_m4。 """ captured = {} state = W.WorkerState(send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""), profile_fn=lambda j, g: None) # 默认 regen_fn → 真 execute_regenerate_modify(intent 空零 LLM) job = {"job_id": "rm1", "traceId": "rm1", "modifyMode": "regenerate-module", "gameId": "g", "brief": "x", "callback": {"target": "http://cb"}} # 无 sourceProject/modifyPatch W.process_job(state, job) assert captured["payload"]["status"] == "failed", "intent 空 → failed 回调(任务不挂 RUNNING)" assert captured["payload"]["trace"]["modifyError"], "失败原因附进 trace(不静默吞)" def _boom_modify(*a): raise AssertionError("create 路不应调用 modify_fn") def test_create_path_unaffected_by_modify_wiring(): """无 modifyMode 的 create 路:走 run_fn、不触 modify_fn(modify 接线对 create 零副作用)。""" gid = "create1" gd = cheap_run.game_dir(gid) (gd / "src").mkdir(parents=True, exist_ok=True) (gd / "bundle.iife.js").write_text(_GOOD_BUNDLE, encoding="utf-8") called = {} state = W.WorkerState( run_fn=lambda job: (called.update(ran=True) or {"verdict": {"pass": True, "guards": { "A_boot": {"pass": True}, "B_uncaught": {"pass": True}, "C_frame": {"pass": True}, "D_render": {"pass": True}}}}, gd), send_fn=lambda u, p, s: called.update(payload=p) or (200, ""), profile_fn=lambda j, g: None, modify_fn=_boom_modify, ) job = {"job_id": "c1", "traceId": "c1", "brief": "x", "callback": {"target": "http://cb"}} # 无 modifyMode W.process_job(state, job) assert called.get("ran") is True, "create 路必须走 run_fn" assert called["payload"]["status"] == "succeeded" if __name__ == "__main__": _install_tmp_redirect() # 独立运行(非 pytest,autouse fixture 不触发):单进程一次性装重定向,退出即释放、无需恢复 _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[test_a11_m3_deterministic] {len(_fns) - _failed}/{len(_fns)} passed") sys.exit(1 if _failed else 0)