feat(studio): A11 切片三 M5 三结构断言机器门(改对了没/有没有误伤/血缘可查)

九门只验"仍能玩",验不出"改对了没、有没有误伤";三断言补这层(plan① 线 154)。

cheap_assert.py:
- ① change_applied:改动真生效非 no-op(deterministic=manifest found 且 old≠new / regenerate=changed)
- ② nontarget_stable:base→new 真比对算内容差,非目标文件变=误伤——不信执行器自报标记,
  防执行器 bug 自证(兑现 Opus 评审 P1-2)
- ③ lineage_queryable:baseVersionId 在(worker 侧验必要条件,DB 真可查由 M1 回填 + 后端 e2e)
- three_assertions 合并 verdict

集成进 execute_*:两档加 base_version_id 参 + 附 assertions verdict + 收紧 status
(succeeded ⟺ 九门 ∧ ① ∧ ②;regenerate 原漏判 ①changed 已补);worker 透传 modify.baseVersionId。

测试:test_a11_m5_assertions 12/12 + 全 85 测试独立复跑全绿(含 M3/M4 桩接 kwarg)。
集成端到端 smoke(真九门):ROUND_MS 30000→19000 + baseVersionId=4096
→ status=succeeded、a1/a2/a3 全 true、allPass=true、落盘 19000。

受计费真后端 e2e(经 studio→aigc→worker→回调→新版本+D12计费+血缘)待 mini-desktop 全栈。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-29 02:34:06 -07:00
parent 14c79dbfdd
commit 9a6feddfee
7 changed files with 247 additions and 21 deletions

View File

@ -0,0 +1,76 @@
"""cheap_assert.py — A11 切片三 M5 三结构断言机器门。
plan① 线 154:A11 完成判据 = 九门 + 三层校验 + 三结构断言九门只验"仍能玩",验不出"改对了没、有没有
误伤";三断言补这一层:
change_applied 请求的结构化改动真实生效( no-op 重构);
nontarget_stable 除目标文件外其余字节未变(没顺手改坏别的模块);
lineage_queryable 新版本带 baseVersionId(可回溯改自哪一版)
设计要点:
· 断言②由 basenew files **真比对**得出(算内容差),**不信执行器自报的 untouchedStable 标记**防执行器
自身 bug 自证(评审 Opus P1-2:目标文件内顺手改坏或执行器误报,真比对才抓得到)
· 跨两档统一:deterministic manifest=list[{file,kind,key,old,new,found}] / regenerate manifest=dict
{file,kind,intent,changed,untouchedStable};assert_change_applied 两形态都认
· 纯函数无网络无 LLM可单测;断言③的"可查" worker 侧只验 baseVersionId (必要条件),DB 真可查由
受计费后端 e2e (M1 已把 base_version_id 回填 game_source_project)
"""
def assert_change_applied(manifest) -> dict:
"""断言① 改动真生效(非 no-op)。
deterministic(manifest=list):至少一条 found oldnew(字符串化比对,容数值/字符串);
regenerate(manifest=dict):changed 为真 {pass, reason}
"""
if isinstance(manifest, list):
applied = [m for m in manifest if m.get("found") and str(m.get("old")) != str(m.get("new"))]
return {"pass": bool(applied), "applied": len(applied),
"reason": "" if applied else "无任一条 found 且值真变化(no-op)"}
if isinstance(manifest, dict):
ok = bool(manifest.get("changed"))
return {"pass": ok, "reason": "" if ok else "regenerate changed=false(目标文件未变,no-op)"}
return {"pass": False, "reason": f"manifest 形态未知:{type(manifest).__name__}"}
def _changed_files(base_files: dict, new_files: dict) -> set:
"""base→new 之间内容真变了的文件集(含新增/删除);base/new 形如 路径→文本。"""
base = base_files or {}
new = new_files or {}
keys = set(base) | set(new)
return {k for k in keys if base.get(k) != new.get(k)}
def assert_nontarget_stable(base_files: dict, new_files: dict, target_files) -> dict:
"""断言② 非目标稳定:base→new 真变了的文件必须 ⊆ 目标文件集;有非目标变动 = 误伤。
target_files:允许改动的目标文件相对路径集合(deterministic=manifest 涉及的文件;regenerate={game-logic.js})
{pass, changedFiles, nontargetChanged}
"""
targets = set(target_files or ())
changed = _changed_files(base_files, new_files)
nontarget = sorted(changed - targets)
return {"pass": not nontarget, "changedFiles": sorted(changed), "nontargetChanged": nontarget}
def assert_lineage(base_version_id) -> dict:
"""断言③ 血缘可查:新版本带 baseVersionId(worker 侧验必要条件;DB 真可查由后端 e2e 验)。"""
ok = base_version_id is not None
return {"pass": ok, "baseVersionId": base_version_id,
"reason": "" if ok else "缺 baseVersionId(血缘断链)"}
def three_assertions(manifest, base_files: dict, new_files: dict, target_files, base_version_id) -> dict:
"""合并三断言 verdict:三条全过才算"改成"(配合九门 + 三层校验)。
Returns:
{a1_changeApplied, a2_nontargetStable, a3_lineageQueryable, allPass}
"""
a1 = assert_change_applied(manifest)
a2 = assert_nontarget_stable(base_files, new_files, target_files)
a3 = assert_lineage(base_version_id)
return {
"a1_changeApplied": a1,
"a2_nontargetStable": a2,
"a3_lineageQueryable": a3,
"allPass": bool(a1["pass"] and a2["pass"] and a3["pass"]),
}

View File

@ -27,6 +27,7 @@ import json
import re
from pathlib import Path
import cheap_assert
import cheap_run
# A11 M4 模块重生成behavior 在便宜档映射到玩法文件KTD4——只重写它其余文件保持不变。
@ -272,7 +273,7 @@ def materialize_source_project(game_id: str, files) -> Path:
# ───────────────────────── ③ execute_deterministic_modify(集成) ─────────────────────────
def execute_deterministic_modify(game_id: str, source_project, modify_patch,
*, port: int = 4320, cdp_port: int = 9222) -> dict:
*, base_version_id=None, port: int = 4320, cdp_port: int = 9222) -> dict:
"""确定性执行集成:apply 改一处 → 落盘已知良源 → 重建 → 九门。复用 cheap_run 九门,不另造。
流程:apply_deterministic_modify ok:( plumbing scaffold 已知良 entry-bundle/index.html)
@ -326,11 +327,21 @@ def execute_deterministic_modify(game_id: str, source_project, modify_patch,
pr = cheap_run.play(game_id, port=port, cdp_port=cdp_port)
verdict = pr.get("verdict")
gates_pass = isinstance(verdict, dict) and verdict.get("pass") is True
status = "succeeded" if gates_pass else "failed"
log(f"确定性改重建+九门完成 game_id={game_id} status={status} smokeOk={sm.get('ok')} playOk={pr.get('ok')}")
return {"status": status, "manifest": manifest, "verdict": verdict,
"stage": "play", "error": None if status == "succeeded" else "九门未通过",
"smokeOk": sm.get("ok"), "playOk": pr.get("ok")}
# M5 三结构断言:base→改后 真比对(断言①改动真生效 / 断言②非目标稳)+ 血缘(③ baseVersionId)。
# 断言②对 deterministic 是兜 apply bug(理应只改目标文件,真比对抓"是否顺手碰了非目标")。
base_files = _as_dict(source_project).get("files") if isinstance(_as_dict(source_project).get("files"), dict) else {}
target_files = {m["file"] for m in manifest if m.get("file")}
assertions = cheap_assert.three_assertions(manifest, base_files, applied["files"], target_files, base_version_id)
a12_pass = assertions["a1_changeApplied"]["pass"] and assertions["a2_nontargetStable"]["pass"]
status = "succeeded" if (gates_pass and a12_pass) else "failed"
err = None
if status != "succeeded":
err = "九门未通过" if not gates_pass else "三断言未通过:" + json.dumps(
{k: v for k, v in assertions.items() if isinstance(v, dict) and not v.get("pass")}, ensure_ascii=False)
log(f"确定性改重建+九门+三断言完成 game_id={game_id} status={status} gates={gates_pass} a12={a12_pass}")
return {"status": status, "manifest": manifest, "verdict": verdict, "assertions": assertions,
"stage": "play", "error": err, "smokeOk": sm.get("ok"), "playOk": pr.get("ok")}
# ───────────────────────── ④ execute_regenerate_modify模块重生成 · 改玩法)─────────────────────────
@ -368,10 +379,12 @@ def _regen_manifest(intent: str, *, changed, stable) -> dict:
"changed": changed, "untouchedStable": stable}
def _regen_why(gates_pass: bool, untouched_stable: bool) -> str:
"""失败诊断文案:断言②泄漏优先(误伤非目标比九门不过更严重)"""
def _regen_why(gates_pass: bool, untouched_stable: bool, changed: bool = True) -> str:
"""失败诊断文案:断言②泄漏优先(误伤非目标比九门不过更严重)→ 断言①no-op → 九门"""
if not untouched_stable:
return "断言②失败模块重生成误伤了非目标文件core/render/assets/host-config 等应保持不变)"
if not changed:
return "断言①失败:模块重生成未改变 game-logic.jsno-op目标文件没动"
if not gates_pass:
return "九门未通过"
return "模块重生成失败"
@ -408,7 +421,7 @@ def _default_rewrite(game_id: str, intent: str, *, port: int = 4320, cdp_port: i
def execute_regenerate_modify(game_id: str, source_project, modify_patch,
*, rewrite_fn=None, port: int = 4320, cdp_port: int = 9222) -> dict:
*, base_version_id=None, rewrite_fn=None, port: int = 4320, cdp_port: int = 9222) -> dict:
"""模块重生成执行(改玩法):取 base 源 + intent → 有界单文件重写 game-logic.js → 九门 → 断言②非目标稳定 + 改动清单。
流程 intentfailed 不重写 base files无源failed scaffold-if-needed + materialize
@ -487,14 +500,25 @@ def execute_regenerate_modify(game_id: str, source_project, modify_patch,
"verdict": None, "summary": None,
"error": f"模块重生成异常:{type(e).__name__}: {e}"}
# 7. 据九门 verdict + 断言②判 status九门过 ∧ 非目标稳 才算成功(误伤非目标=边界泄漏,即便九门过也判失败)。
# 7. 据九门 verdict + 三断言判 status九门过 ∧ 改动真生效(①) ∧ 非目标稳(②) 才算成功
# (误伤非目标=边界泄漏、目标没变=no-op即便九门过也判失败
verdict_full = run_summary.get("verdictFull") if isinstance(run_summary.get("verdictFull"), dict) else None
vb = run_summary.get("verdict") if isinstance(run_summary.get("verdict"), dict) else None
gates_pass = bool(vb and vb.get("pass") is True)
status = "succeeded" if (gates_pass and untouched_stable) else "failed"
# 三断言 verdict统一形态与 deterministic 路一致①changed ②非目标稳(含误伤清单)③血缘。
nontarget_changed = sorted(k for k in set(nontarget_before) | set(nontarget_after)
if nontarget_before.get(k) != nontarget_after.get(k))
assertions = {
"a1_changeApplied": {"pass": changed, "reason": "" if changed else "目标文件未变(no-op)"},
"a2_nontargetStable": {"pass": untouched_stable, "nontargetChanged": nontarget_changed},
"a3_lineageQueryable": cheap_assert.assert_lineage(base_version_id),
"allPass": bool(changed and untouched_stable and base_version_id is not None),
}
a12_pass = changed and untouched_stable
status = "succeeded" if (gates_pass and a12_pass) else "failed"
stage = run_summary.get("stage") or "code"
log(f"模块重生成完成 game_id={game_id} status={status} changed={changed} "
f"untouchedStable={untouched_stable} gatesPass={gates_pass} stage={stage}")
return {"status": status, "stage": stage, "manifest": manifest,
"verdict": verdict_full, "summary": run_summary,
"error": None if status == "succeeded" else _regen_why(gates_pass, untouched_stable)}
"verdict": verdict_full, "summary": run_summary, "assertions": assertions,
"error": None if status == "succeeded" else _regen_why(gates_pass, untouched_stable, changed)}

View File

@ -209,7 +209,7 @@ def test_worker_deterministic_route_attaches_manifest_on_failed():
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: fake,
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",
@ -234,7 +234,7 @@ def test_worker_deterministic_route_success_carries_bundle_and_source():
"old": "30000", "new": "20000", "found": True}],
"verdict": {"pass": True, "guards": {}}, "error": None, "stage": "play"}
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
state = W.WorkerState(modify_fn=lambda *a: fake,
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,

View File

@ -188,7 +188,7 @@ def test_worker_regenerate_route_success_carries_manifest_bundle_source():
"attempts": 4, "stage": "play", "costRmb": 0.7, "models": {"code": "MiniMax-M3"}, "gameId": gid},
"error": None}
profile = {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"}
state = W.WorkerState(regen_fn=lambda *a: fake,
state = W.WorkerState(regen_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": "rmok", "traceId": "rmok", "modifyMode": "regenerate-module", "gameId": gid,
@ -214,7 +214,7 @@ def test_worker_regenerate_route_failed_attaches_error_and_manifest():
"changed": True, "untouchedStable": False},
"verdict": {"pass": True, "guards": {}}, "summary": None,
"error": "断言②失败:模块重生成误伤了非目标文件"}
state = W.WorkerState(regen_fn=lambda *a: fake,
state = W.WorkerState(regen_fn=lambda *a, **kw: fake,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None)
job = {"job_id": "rmf", "traceId": "rmf", "modifyMode": "regenerate-module", "gameId": gid,
@ -226,7 +226,7 @@ def test_worker_regenerate_route_failed_attaches_error_and_manifest():
assert "sourceProject" not in captured["payload"], "失败路不带新版源"
def _boom_regen(*a):
def _boom_regen(*a, **kw):
raise AssertionError("deterministic/create 路不应调用 regen_fn")
@ -240,7 +240,7 @@ def test_worker_deterministic_unaffected_by_regen_wiring():
det = {"status": "succeeded",
"manifest": [{"file": "src/core.js", "kind": "config", "key": "SPEED", "old": "5", "new": "8", "found": True}],
"verdict": {"pass": True, "guards": {}}, "error": None, "stage": "play"}
state = W.WorkerState(modify_fn=lambda *a: det, regen_fn=_boom_regen,
state = W.WorkerState(modify_fn=lambda *a, **kw: det, regen_fn=_boom_regen,
send_fn=lambda u, p, s: captured.update(payload=p) or (200, ""),
profile_fn=lambda j, g: None)
job = {"job_id": "det1", "traceId": "det1", "modifyMode": "deterministic", "gameId": gid,

View File

@ -0,0 +1,117 @@
"""test_a11_m5_assertions.py — A11 切片三 M5 三结构断言机器门单测。
plan① 线 154 A11 完成判据(九门之外补三条,"改对了没、有没有误伤"):
改动真生效( no-op):请求的结构化改动可断言真实落地;
非目标稳定:除目标文件外,其余文件字节未变(没顺手改坏别的模块);
血缘可查:新版本带 baseVersionId(可回溯改自哪一版)
三断言跨两档统一(deterministic manifest=list / regenerate manifest=dict), basenew files 真比对得出,
不只信执行器自报的标记(防执行器 bug 自证)
:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m5_assertions.py
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_assert as A # noqa: E402
_BASE = {
"src/core.js": "export const ROUND_MS = 30000;\n",
"src/game-logic.js": "// 玩法 v1\n",
"src/render.js": "// 渲染\n",
"src/assets.js": "export const IMAGE_FILES = {};\n",
}
# ── 断言①:改动真生效(非 no-op)──
def test_a1_deterministic_real_change():
manifest = [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS", "old": "30000", "new": "20000", "found": True}]
assert A.assert_change_applied(manifest)["pass"] is True
def test_a1_deterministic_noop_found_false():
manifest = [{"file": "src/core.js", "kind": "config", "key": "NOPE", "old": None, "new": None, "found": False}]
assert A.assert_change_applied(manifest)["pass"] is False
def test_a1_deterministic_noop_same_value():
manifest = [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS", "old": "30000", "new": "30000", "found": True}]
assert A.assert_change_applied(manifest)["pass"] is False, "found 但值没变=no-op"
def test_a1_regenerate_changed_true():
assert A.assert_change_applied({"file": "src/game-logic.js", "kind": "behavior", "changed": True})["pass"] is True
def test_a1_regenerate_changed_false():
assert A.assert_change_applied({"file": "src/game-logic.js", "kind": "behavior", "changed": False})["pass"] is False
# ── 断言②:非目标稳定(base→new 真比对,不信自报)──
def test_a2_only_target_changed():
new = dict(_BASE)
new["src/core.js"] = "export const ROUND_MS = 20000;\n" # 只改目标
out = A.assert_nontarget_stable(_BASE, new, target_files={"src/core.js"})
assert out["pass"] is True
assert out["nontargetChanged"] == []
def test_a2_collateral_damage_caught():
new = dict(_BASE)
new["src/core.js"] = "export const ROUND_MS = 20000;\n"
new["src/render.js"] = "// 渲染 被顺手改坏\n" # 误伤非目标
out = A.assert_nontarget_stable(_BASE, new, target_files={"src/core.js"})
assert out["pass"] is False, "误伤非目标必须被抓"
assert "src/render.js" in out["nontargetChanged"]
def test_a2_regenerate_target_gamelogic():
new = dict(_BASE)
new["src/game-logic.js"] = "// 玩法 v2 连击\n"
out = A.assert_nontarget_stable(_BASE, new, target_files={"src/game-logic.js"})
assert out["pass"] is True
# ── 断言③:血缘可查(baseVersionId 在)──
def test_a3_lineage_present():
assert A.assert_lineage(4096)["pass"] is True
def test_a3_lineage_missing():
assert A.assert_lineage(None)["pass"] is False
# ── 合并三断言 verdict ──
def test_three_assertions_all_pass():
new = dict(_BASE)
new["src/core.js"] = "export const ROUND_MS = 20000;\n"
manifest = [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS", "old": "30000", "new": "20000", "found": True}]
v = A.three_assertions(manifest, _BASE, new, target_files={"src/core.js"}, base_version_id=4096)
assert v["allPass"] is True
assert v["a1_changeApplied"]["pass"] and v["a2_nontargetStable"]["pass"] and v["a3_lineageQueryable"]["pass"]
def test_three_assertions_fails_on_collateral():
new = dict(_BASE)
new["src/core.js"] = "export const ROUND_MS = 20000;\n"
new["src/render.js"] = "// 误伤\n"
manifest = [{"file": "src/core.js", "kind": "config", "key": "ROUND_MS", "old": "30000", "new": "20000", "found": True}]
v = A.three_assertions(manifest, _BASE, new, target_files={"src/core.js"}, base_version_id=4096)
assert v["allPass"] is False, "误伤非目标 → 整体不过"
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f"{fn.__name__}")
print(f"\n[test_a11_m5_assertions] {len(fns)}/{len(fns)} passed")
if __name__ == "__main__":
_run_all()

View File

@ -277,7 +277,8 @@ def _process_modify_job(state: WorkerState, job: dict, modify: dict) -> dict:
import cheap_modify
modify_fn = cheap_modify.execute_deterministic_modify
result = modify_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch"))
result = modify_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch"),
base_version_id=modify.get("baseVersionId")) # M5 三断言③血缘透传
summary = _modify_summary(result, game_id)
game_dir = cheap_run.game_dir(game_id)
@ -353,7 +354,8 @@ def _process_regenerate_job(state: WorkerState, job: dict, modify: dict) -> dict
import cheap_modify
regen_fn = cheap_modify.execute_regenerate_modify
result = regen_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch"))
result = regen_fn(game_id, modify.get("sourceProject"), modify.get("modifyPatch"),
base_version_id=modify.get("baseVersionId")) # M5 三断言③血缘透传
summary = _regen_summary(result, game_id)
game_dir = cheap_run.game_dir(game_id)

View File

@ -306,6 +306,13 @@ stateDiagram-v2
- **一次真 LLM smoke子代理单跑本机 Chrome + new-api M3**base=`amgen-bake-click-score-0`、intent="把命中加分从固定改成连击递增" → **status=succeeded、untouchedStable=True、九门 verdict.pass=True、attempts=1、¥0.31、137s、无熔断**;独立 diff 佐证 5 个非目标文件字节相同、仅 game-logic.js 变且真实现连击递增。**M4 plumbing 通**(重写发生 + 写边界守住 + 非目标稳 + 九门跑到 verdict此次质量也恰好达标质量归 agent 层、不在 M4 判据)。
- **M4 收口**两档执行deterministic + regenerate-module全落地断言②地基非目标 hash 稳)就位。
**M5 验收闭环 · 三断言机器门完成、绿2026-06-29· 受计费 e2e 评估中**
- **`cheap_assert.py` 三结构断言**:① change_applied改动真生效非 no-opdeterministic=manifest 有 found 且 old≠new / regenerate=changed② nontarget_stable**base→new 真比对**算内容差,非目标文件变=误伤——**不信执行器自报标记**,防执行器 bug 自证,兑现 Opus P1-2③ lineage_queryablebaseVersionId 在worker 侧验必要条件DB 真可查由后端 e2e + M1 回填)。`three_assertions` 合并 verdict。`test_a11_m5_assertions` 12/12。
- **集成进 execute_***:两档执行加 `base_version_id` 参 + 计算并附 `assertions` verdict + 收紧 status 判据succeeded ⟺ 九门 ∧ ① ∧ ②regenerate 原漏判 ①changed 已补no-op 重写不再算成功worker `_process_*_job` 透传 `modify.baseVersionId`。全 **85 测试**独立复跑全绿(含 M3/M4 注入桩接 kwarg 修)。
- **集成端到端 smoke主会话独立、真九门**ROUND_MS 30000→19000 + base_version_id=4096 → **status=succeeded、a1/a2/a3 全 true、allPass=true、落盘 core.js=19000**。三断言真接进执行器、清洁改动全过。
- **受计费真后端 e2emini-desktop**A11 全链路代码已完成 + 本机端到端逐段验证(判意图链路 / 两档执行 + 真九门 / 三断言 / 血缘回填均已一手坐实)。剩最终集成证 = 经真后端studio `/modify/plan`+`/modify` → aigc → worker → 回调 → 新预览版 + D12 计费 + base_version_id 可查)跑通一次受计费调整任务,需 mini-desktop 全栈起。
## 状态
双评审已过(发现全处置)→ 待创始人批 → 执行M1→M5 顺序M1 为 plan① 所称"首个里程碑")。