games-development-ai/cheap-worker/tests/test_acceptance_v3.py
lili 87777975c7 feat(w-axis): R1 生成可信闸代码收口(actor/judge闭合+W-GOLD-LIVE+full_gate+三批闸门)
actor/judge prompt eval 闭合: actor 3.0.5 正文强化+baseline(7轮); judge 3.0.14 正文强化+anyTerms同义词补全+baseline(judge-a 13轮/judge-b 33轮); registry/checker 版本对齐+顺序锚点 fail-closed 双保险
W-GOLD-LIVE live_prompt: play-loop 契约开口(request/3+provenance/3+ReferenceAssetRecord/1+Registry/1+迁移清单15条); cheap_verify 切v3+消费对账六闸(只消费active)+生成prompt注入接线(0 active不注入); Node runner playtest-v3.cdp.cjs 升/3+绊线恢复; 真模型回归验向后兼容
full_gate+三批基线: full_gate.py 集成runner串六子门+降级+22测; baseline_gates.py fresh25阈值+historical11预期表(2 needs_human交创始人定标)+shadow20框架+38测
注: registry cheap-system 登记 1.8.0→1.8.1 同步此前在途 cheap-system.md frontmatter 升版(修 pre-commit 版本漂移); cheap_verify/cheap_studio/validate/test_acceptance_v3/test_cheap_service_driver 为在途M含本会话叠加+此前W-AXIS在途(同一线无法hunk分离); 3红测试根因与raw审计未入本提交
2026-07-25 18:47:47 -07:00

1711 lines
87 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""验收 v3 Python 编排回归:使用 proof-obligations/2 与 playtest/3 canonical 契约。"""
import asyncio
import copy
import hashlib
import json
import os
import subprocess
from pathlib import Path
import pytest
import cheap_studio
import cheap_verify as V
CONTRACTS = Path(__file__).resolve().parents[2] / "contracts" / "play-loop"
SINGLE_ROLL_SAMPLE = (
CONTRACTS / "samples" / "single-roll-fact-v3" / "valid" / "01-targeted-dual-judge.json"
)
LEGACY_NARRATIVE_SAMPLE = (
CONTRACTS / "samples" / "single-roll-fact" / "valid" / "01-native-narrative.json"
)
HERITAGE_VALID = (
CONTRACTS / "samples" / "playtest-evidence" / "valid" / "06-accepted-heritage-ordered-series.json"
)
BRIEF = "一款分支叙事游戏"
def _load(path: Path) -> dict:
payload = json.loads(path.read_text(encoding="utf-8"))
payload.pop("_note", None)
return payload
def _seal_event_payload(event: dict, payload=None, *, canonical: str | None = None) -> None:
"""按 game-event/1 封存 payload浮点用例显式传入 Node stableStringify 原文。"""
if payload is not None:
event["payload"] = payload
if canonical is None:
canonical = json.dumps(
event["payload"], ensure_ascii=False, sort_keys=True, separators=(",", ":"),
allow_nan=False,
)
event["payloadCanonical"] = canonical
event["payloadHash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _identity(game_id="v3-test", brief=BRIEF, *, repair_ordinal=0, source=None, parent=None,
task_trace_id="trace-v3-test"):
return V.build_acceptance_v3_identity(
game_id, brief, genre="narrative", template_route="_template-story",
repair_ordinal=repair_ordinal, source_artifact_hash=source,
parent_acceptance_request_hash=parent,
task_binding_hash=V.task_binding_hash_v3(task_trace_id),
)
def test_direct_cli_local_task_binding_is_unique_per_invocation():
"""直接 CLI 没有后端 traceId 时,也不能让同 gameId 的下一次运行复用旧证据。"""
first = cheap_studio._new_local_acceptance_trace_id("same-game")
second = cheap_studio._new_local_acceptance_trace_id("same-game")
assert first != second
assert first.startswith("studio-same-game-")
assert V.task_binding_hash_v3(first) != V.task_binding_hash_v3(second)
def test_external_acceptance_identity_requires_current_raw_trace_id():
"""只有 identity hash、没有当前原始 traceId 时,编排层不能自行声称绑定成立。"""
with pytest.raises(ValueError, match="必须同时提供当前任务 traceId"):
cheap_studio._resolve_acceptance_task_trace_id(
"same-game", None, identity_supplied=True)
assert cheap_studio._resolve_acceptance_task_trace_id(
"same-game", "trace-current", identity_supplied=True) == "trace-current"
def _floor(pass_value=True) -> dict:
return {"guards": {
"A_boot": {"pass": pass_value, "measurement": True},
"B_uncaught": {"pass": pass_value, "measurement": 0},
"C_frame": {"pass": pass_value, "measurement": 12},
"D_render": {"pass": pass_value, "measurement": 0.42},
}}
def _cost_entry(role="Actor", rmb=0.05, *, model="MiniMax-M3") -> dict:
"""构造与 Node createCostLedger.add 完全同形的单次模型计费项。"""
return {
"role": role, "model": model, "promptTokens": 100, "completionTokens": 20,
"cachedTokens": 0, "modelRatio": 0.15, "completionRatio": 4.0,
"cacheRatio": 1.0, "quota": 27.0, "rmb": rmb,
}
def _materialize_frames(fact: dict, evidence_root: Path) -> None:
"""给 canonical fact 写真实帧字节,并同步 action/frame 中的 hash。"""
evidence_root.mkdir(parents=True, exist_ok=True)
frame_by_key = {}
for frame in fact["frames"]:
ref = frame["ref"]
data = ("frame:" + ref).encode("utf-8")
path = evidence_root / ref
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
frame["hash"] = hashlib.sha256(data).hexdigest()
frame["artifactHash"] = fact["artifactHash"]
frame_by_key[(frame.get("actionId"), frame["kind"])] = frame
for action in fact["actions"]:
for kind in ("pre", "post"):
frame = frame_by_key[(action["actionId"], kind)]
action[f"{kind}FrameRef"] = {"path": frame["ref"], "hash": frame["hash"]}
resolution = action.get("targetResolution") or {}
if resolution.get("status") == "resolved":
resolution["sourceFrameRef"] = action["preFrameRef"]["path"]
resolution["sourceFrameHash"] = action["preFrameRef"]["hash"]
def _rewrite_frame_bytes(fact: dict, evidence_root: Path, frame_ref: str, data: bytes) -> str:
"""改写一张真实证据帧并同步引用 hash用于证明第二掷确有新增硬证。"""
path = evidence_root / frame_ref
path.write_bytes(data)
digest = hashlib.sha256(data).hexdigest()
frame = next(row for row in fact["frames"] if row["ref"] == frame_ref)
frame["hash"] = digest
action = next(row for row in fact["actions"] if row["actionId"] == frame["actionId"])
action[f"{frame['kind']}FrameRef"]["hash"] = digest
return digest
def _hash_fixture(label: str) -> str:
"""给 ref/hash fixture 生成稳定 SHA-256避免测试散落无语义的魔法值。"""
return hashlib.sha256(label.encode("utf-8")).hexdigest()
def _upgrade_actions_v3(actions: list, *, roll: int) -> list:
"""把历史坐标动作集中升级为 ActorSelection/1 + TargetResolution/1。"""
upgraded = copy.deepcopy(actions)
for index, action in enumerate(upgraded, start=1):
normalized = copy.deepcopy(action["normalized"])
action_type = normalized["type"]
target_set_hash = _hash_fixture(f"roll-{roll}-target-set-{index}")
if action_type == "tap":
target = {"id": f"r{index:02d}"}
selection = {
"schemaVersion": "ActorSelection/1", "type": "tap",
"targetSetHash": target_set_hash, "target": target,
}
resolved_points = [{
"role": "target", "target": target,
"point": {"x": normalized["x"], "y": normalized["y"]},
}]
elif action_type == "drag":
from_target, to_target = {"id": f"r{index:02d}"}, {"id": f"r{index + 1:02d}"}
selection = {
"schemaVersion": "ActorSelection/1", "type": "drag",
"targetSetHash": target_set_hash, "from": from_target, "to": to_target, "ms": 600,
}
resolved_points = [
{"role": "from", "target": from_target, "point": normalized["from"]},
{"role": "to", "target": to_target, "point": normalized["to"]},
]
elif action_type == "key":
selection = {"schemaVersion": "ActorSelection/1", "type": "key", "key": normalized["key"]}
resolved_points = None
elif action_type == "wait":
selection = {"schemaVersion": "ActorSelection/1", "type": "wait", "ms": normalized["ms"]}
resolved_points = None
else:
raise AssertionError(f"测试 fixture 含未知动作:{action_type}")
action["request"] = {
"raw": json.dumps(selection, ensure_ascii=False, sort_keys=True),
"parsed": copy.deepcopy(selection),
}
action["retryCount"] = 0
action["protocolErrors"] = []
if resolved_points is None:
action["targetResolution"] = {"status": "not_applicable"}
else:
pre = action["preFrameRef"]
action["targetResolution"] = {
"status": "resolved",
"targetSetRef": f"roll-{roll}/targets/action-{index}.json",
"targetSetHash": target_set_hash,
"sourceFrameRef": pre["path"], "sourceFrameHash": pre["hash"],
"guideManifestRef": f"roll-{roll}/guides/action-{index}.json",
"guideManifestHash": _hash_fixture(f"roll-{roll}-guide-{index}"),
"resolverVersion": "1.0.0", "selection": copy.deepcopy(selection),
"resolvedPoints": resolved_points, "error": None,
}
return upgraded
def _attach_dual_judge_v3(fact: dict, legacy_judge: dict, *, roll: int) -> None:
"""把旧单 Judge 的义务事实投影成互相致盲的 A/B 与确定性 consensus fixture。"""
roll_id = f"roll-{roll}"
package_hash = str(legacy_judge["packageHash"])
result_a = _hash_fixture(f"{roll_id}-judge-a-result")
result_b = _hash_fixture(f"{roll_id}-judge-b-result")
consensus_hash = _hash_fixture(f"{roll_id}-consensus")
fact.update({
"rollId": roll_id,
"judgeARef": {
"rawRef": f"{roll_id}/judge-a/raw.json", "rawHash": _hash_fixture(f"{roll_id}-a-raw"),
"parsedRef": f"{roll_id}/judge-a/parsed.json", "parsedHash": _hash_fixture(f"{roll_id}-a-parsed"),
"normalizedRef": f"{roll_id}/judge-a/normalized.json", "resultHash": result_a,
},
"judgeAHash": _hash_fixture(f"{roll_id}-judge-a-manifest"),
"judgeBRef": {
"rawRef": f"{roll_id}/judge-b/raw.json", "rawHash": _hash_fixture(f"{roll_id}-b-raw"),
"parsedRef": f"{roll_id}/judge-b/parsed.json", "parsedHash": _hash_fixture(f"{roll_id}-b-parsed"),
"normalizedRef": f"{roll_id}/judge-b/normalized.json", "resultHash": result_b,
},
"judgeBHash": _hash_fixture(f"{roll_id}-judge-b-manifest"),
"judgeConsensusRef": f"{roll_id}/consensus.json",
"judgeConsensusHash": consensus_hash,
"costReservationRef": f"{roll_id}/cost-reservation.json",
"costReservationHash": _hash_fixture(f"{roll_id}-cost-reservation"),
})
fact["judge"] = {
"schemaVersion": "JudgeConsensus/1", "sourceRollId": roll_id,
"consensusRef": fact["judgeConsensusRef"], "consensusHash": consensus_hash,
"packageHash": package_hash, "strategyVersion": "1.0.0",
"decision": legacy_judge["decision"], "degraded": bool(legacy_judge.get("degraded")),
"sameModel": True,
"independenceChecks": {
"differentLogicalClient": True, "differentSession": True, "differentPolicySeed": True,
"differentPromptArtifact": True, "differentEvidenceDir": True,
"differentRawFile": True, "sameJudgePackage": True,
"sameImageManifest": True, "sameModel": True,
},
"judgeAResultHash": result_a, "judgeBResultHash": result_b,
"obligationResults": copy.deepcopy(legacy_judge["obligationResults"]),
"conflicts": [], "failureSignature": legacy_judge.get("failureSignature"),
}
fact["_judgeConsensus"] = {
"strategyVersion": "JudgeConsensus/1", "judgePackageHash": package_hash,
"decision": legacy_judge["decision"], "failureClass": legacy_judge.get("failureClass", "none"),
"reasonCode": "accepted" if legacy_judge["decision"] == "accept" else legacy_judge["decision"],
"conflicts": [],
}
def _set_outcome(fact: dict, outcome: str) -> None:
target = next(row for row in fact["proofObligations"] if row["id"] == "narrative.ending-reached")
judge_row = next(row for row in fact["judge"]["obligationResults"] if row["id"] == target["id"])
if outcome == "accept":
return
fact["firstPlay"].update({
"loopClosedAtVirtualMs": None, "loopClosed": False, "proofObligationRefs": [],
})
if outcome == "missing":
target.update({"status": "missing", "sequenceRefs": [], "actionRefs": [],
"postFrameRefs": [], "eventRefs": [], "contradictions": [],
"blockingProblems": []})
judge_row.update({"status": "missing", "sequenceRefs": [], "evidenceRefs": []})
fact["judge"].update({"decision": "inconclusive", "failureSignature": None})
fact["_judgeConsensus"].update({
"decision": "inconclusive", "failureClass": "none",
"reasonCode": "proof_missing", "conflicts": [],
})
elif outcome == "reject":
target.update({"status": "failed", "blockingProblems": ["结局状态未成立"]})
judge_row["status"] = "failed"
fact["judge"].update({"decision": "reject", "failureSignature": "ending-failed"})
fact["_judgeConsensus"].update({
"decision": "reject", "failureClass": "broken",
"reasonCode": "proof_obligation_failed", "conflicts": [],
})
elif outcome == "contradicted":
target.update({"status": "contradicted", "contradictions": ["截图与事件结局相反"]})
judge_row["status"] = "contradicted"
fact["judge"].update({
"decision": "inconclusive", "conflicts": ["截图与事件结局相反"],
"failureSignature": None,
})
fact["_judgeConsensus"].update({
"decision": "inconclusive", "failureClass": "none",
"reasonCode": "evidence_contradiction", "conflicts": ["截图与事件结局相反"],
})
elif outcome == "judge_error":
fact["judge"].update({"decision": "tester_error", "degraded": True, "failureSignature": None})
fact["_judgeConsensus"].update({
"decision": "tester_error", "failureClass": "none",
"reasonCode": "judge_error", "conflicts": [],
})
else:
raise AssertionError(outcome)
def _set_global_reject(fact: dict, failure_class="off_brief") -> None:
"""保留全部义务 satisfied只把双 Judge consensus 设置为整局级拒绝。"""
fact["judge"].update({
"decision": "reject", "failureSignature": f"{failure_class}|global",
})
fact["_judgeConsensus"].update({
"decision": "reject", "failureClass": failure_class,
"reasonCode": f"global_{failure_class}", "conflicts": [],
})
def _fact(evidence_root: Path, identity: dict, artifact_hash: str, *, outcome="accept",
roll=1, run_id="run-test", first_interactive=137) -> dict:
fact = _load(SINGLE_ROLL_SAMPLE)
legacy = _load(LEGACY_NARRATIVE_SAMPLE)
fact.update({
"schemaVersion": "playtest/3", "runId": run_id, "rollId": f"roll-{roll}", "roll": roll,
"gameId": identity["gameId"], "genre": identity["genre"],
"templateRoute": identity["templateRoute"], "proofProfileId": identity["proofProfileId"],
"proofRegistryVersion": identity["proofRegistryVersion"],
"taskBindingHash": identity["taskBindingHash"],
"acceptanceRequestHash": identity["acceptanceRequestHash"], "evidenceMode": "native",
"artifactHash": artifact_hash, "briefHash": identity["briefHash"],
"requestedSeed": 100 + roll, "actualSeed": 100 + roll, "policySeed": 7000 + roll,
"actions": _upgrade_actions_v3(legacy["actions"], roll=roll),
"frames": copy.deepcopy(legacy["frames"]), "events": copy.deepcopy(legacy["events"]),
"briefRuleMatches": copy.deepcopy(legacy["briefRuleMatches"]),
"proofObligations": copy.deepcopy(legacy["proofObligations"]),
"firstPlay": copy.deepcopy(legacy["firstPlay"]),
"proofPackageRef": f"roll-{roll}/single-roll-proof.json",
"proofPackageHash": _hash_fixture(f"roll-{roll}-proof-package"),
"judgePackageRef": f"roll-{roll}/judge-package.json",
"judgePackageHash": legacy["judgePackageHash"],
"errors": [], "costRmb": 0.12,
"cost": {"costRmb": 0.12, "entries": [
_cost_entry("Actor", 0.04), _cost_entry("JudgeA", 0.04), _cost_entry("JudgeB", 0.04),
]},
})
fact["actor"].update({
"sessionId": f"actor-session-{roll}", "contextProjectionVersion": "ActorView/3",
"requestedGameSeed": 100 + roll, "actualGameSeed": 100 + roll, "policySeed": 7000 + roll,
})
_attach_dual_judge_v3(fact, legacy["judge"], roll=roll)
fact["environment"]["buildRef"] = artifact_hash
fact["firstPlay"]["interactiveAtVirtualMs"] = first_interactive
for event in fact["events"]:
_seal_event_payload(event)
_set_outcome(fact, outcome)
_materialize_frames(fact, evidence_root)
fact["_evidenceRoot"] = str(evidence_root)
fact["_evidenceDir"] = str(evidence_root)
return fact
def _fact_from_final(path: Path, evidence_root: Path, identity: dict, artifact_hash: str) -> dict:
"""把 canonical final 样本补齐为 SingleRollFact用于 orderedSeries guard 回归。"""
final = _load(path)
actor = copy.deepcopy(final["actor"])
actor.update({"contextProjectionVersion": "ActorView/3",
"prompt": {"id": "playtest.actor", "version": "2.4.0",
"hash": "1" * 64}, "usage": []})
legacy_judge = copy.deepcopy(final["judge"])
legacy_judge.update({"problems": [], "failureClass": "none", "summary": "完整闭环",
"reason": "完整闭环", "failureSignature": None})
frames = []
for action in final["actions"]:
for kind in ("pre", "post"):
ref = action[f"{kind}FrameRef"]
frames.append({"actionId": action["actionId"], "kind": kind, "ref": ref["path"],
"hash": ref["hash"], "artifactHash": artifact_hash})
environment = copy.deepcopy(final["environment"])
environment["buildRef"] = artifact_hash
environment["virtualTime"]["waitMaxMs"] = 600
fact = {
"schemaVersion": "playtest/3", "packageType": "SingleRollFact", "runId": "heritage-run",
"rollId": "roll-1", "roll": 1, "gameId": identity["gameId"], "genre": final["genre"],
"templateRoute": identity["templateRoute"],
# proofRegistryVersion 取自可信身份而非旧样本:真跑时 runner 按 config身份给 fact 打版本,
# guard 会交叉核对『fact 版本 == 可信请求版本』;样本停留在 2026-07-14.v2 只是历史快照值。
"proofProfileId": final["proofProfileId"], "proofRegistryVersion": identity["proofRegistryVersion"],
"taskBindingHash": identity["taskBindingHash"],
"acceptanceRequestHash": identity["acceptanceRequestHash"], "evidenceMode": "native",
"artifactHash": artifact_hash, "briefHash": identity["briefHash"],
"requestedSeed": actor["requestedGameSeed"], "actualSeed": actor["actualGameSeed"],
"policySeed": actor["policySeed"], "seedOptionSource": "requested-seed",
"environment": environment, "actor": actor,
"actions": _upgrade_actions_v3(final["actions"], roll=1), "frames": frames,
"events": final["events"], "briefRuleMatches": final["briefRuleMatches"],
"proofObligations": final["proofObligations"], "firstPlay": final["firstPlay"],
"proofPackageRef": "roll-1/single-roll-proof.json", "proofPackageHash": "3" * 64,
"judgePackageRef": "roll-1/judge-package.json", "judgePackageHash": legacy_judge["packageHash"],
"errors": [], "costRmb": 0.1,
"cost": {"costRmb": 0.1, "entries": [
_cost_entry("Actor", 0.04), _cost_entry("JudgeA", 0.06),
]},
}
_attach_dual_judge_v3(fact, legacy_judge, roll=1)
for event in fact["events"]:
_seal_event_payload(event)
_materialize_frames(fact, evidence_root)
fact["_evidenceRoot"] = str(evidence_root)
fact["_evidenceDir"] = str(evidence_root)
return fact
def _registry(identity: dict, brief=BRIEF) -> dict:
return V._v3_resolve_registry(
identity["genre"], brief, template_route=identity["templateRoute"],
proof_profile_id=identity["proofProfileId"],
proof_registry_version=identity["proofRegistryVersion"],
)
def _guard(fact: dict, identity: dict, artifact_hash: str, brief=BRIEF) -> dict:
return V._roll_guard_v3(
fact, artifact_hash=artifact_hash, brief_hash=identity["briefHash"],
registry=_registry(identity, brief), task_binding_hash=identity["taskBindingHash"],
acceptance_request_hash=identity["acceptanceRequestHash"],
evidence_mode="native",
)
def _request(staged: Path, evidence_root: Path, identity: dict, *, mode="v3", parent_run_id=None,
writer_cost=0.0) -> dict:
return {
"gameId": identity["gameId"], "brief": BRIEF, "acceptanceIdentity": identity,
"verdict": _floor(True), "acceptanceMode": mode, "evidenceMode": "native",
"idempotencyKey": identity["gameId"] + ":test", "repairCountAcrossParentChain": identity["repairOrdinal"],
"parentRunId": parent_run_id, "writerCostRmb": writer_cost,
"artifactPath": str(staged), "evidenceRoot": str(evidence_root),
}
def _patch_play(monkeypatch, outcome: str):
async def fake(request, *, run_dir, artifact_hash, brief_hash, cfg=None):
identity = request["acceptanceIdentity"]
raw = _fact(Path(run_dir) / "roll-1", identity, artifact_hash, outcome=outcome,
run_id=request["runId"])
guard = _guard(raw, identity, artifact_hash, request["brief"])
record = {"roll": 1, "raw": raw, "attempts": [raw], "rollGuard": guard}
return {"rolls": [record], "rollGuards": [guard], "merge": V._merge_candidate_v3([guard]),
"costRmb": raw["costRmb"], "registry": _registry(identity, request["brief"])}
monkeypatch.setattr(V, "run_playtest_v3", fake)
def test_identity_route_is_authority_and_repair_locks_profile():
first = _identity()
assert first["proofProfileId"] == "narrative.branching-story"
assert first["sourceArtifactHash"] is None and first["parentAcceptanceRequestHash"] is None
with pytest.raises(ValueError, match="proofProfileId/templateRoute"):
V.build_acceptance_v3_identity(
"v3-test", BRIEF, genre="narrative", template_route="_template-story",
proof_profile_id="puzzle.match-board", task_binding_hash=first["taskBindingHash"])
repair = _identity(repair_ordinal=1, source="a" * 64, parent=first["acceptanceRequestHash"])
assert repair["proofProfileId"] == first["proofProfileId"]
assert repair["proofRegistryVersion"] == first["proofRegistryVersion"]
assert repair["acceptanceRequestHash"] != first["acceptanceRequestHash"]
def test_unicode_game_id_rejected_before_path_use():
with pytest.raises(ValueError, match="acceptance request 非法"):
_identity(game_id="游戏-1")
def test_provenance_external_exact_and_artifact_hash_covers_evidence(tmp_path):
staged = tmp_path / "staged"; staged.mkdir()
(staged / "index.html").write_text("ok", encoding="utf-8")
identity = _identity()
before = V._artifact_hash_v3(identity["gameId"], staged)
external = tmp_path / "results" / "run"
provenance = V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=before, evidence_root=external, artifact_path=staged)
assert not (staged / "evidence").exists()
# 升 acceptance-provenance/3 后 manifest 在 v2 字段集上多了 designRef/referenceAssetRecordIds/
# consumerRef/consumedReferenceAssets 等 optional 字段;断言由『精确等于 v1 十三字段』放宽为
# 『v2 十四字段(含 interactionBinding⊆ manifest 字段集』——原断言意图(身份字段一个不能少)
# 原样保留,仅允许 v3 新增 optional。
v2_manifest_fields = {
"schemaVersion", "gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "interactionBinding",
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
"acceptanceRequestHash", "artifactHash",
}
assert v2_manifest_fields <= set(provenance["manifest"])
assert not V._validate_v3_instance(V._V3_ACCEPTANCE_REQUEST_SCHEMA,
json.loads(Path(provenance["acceptanceRequestFile"]).read_text()))
assert not V._validate_v3_instance(V._V3_ACCEPTANCE_PROVENANCE_SCHEMA, provenance["manifest"])
hidden = staged / "evidence" / "evil.js"; hidden.parent.mkdir(); hidden.write_text("window.pwned=1", encoding="utf-8")
assert V._artifact_hash_v3(identity["gameId"], staged) != before
def test_identity_v3_schema_and_backward_compat_optional_fields():
"""旧调用方不传新字段 → identity 仍冻结(向后兼容),新字段取缺省并过 v3 canonical 双层校验。"""
identity = _identity()
assert identity["schemaVersion"] == "acceptance-request/3"
# /3 与 /2 同一条冻结 proof registry 版本线validate.py 语义层钉死,错配即 Writer 前身份失败)。
assert identity["proofRegistryVersion"] == "2026-07-15.v3"
assert identity["interactionBinding"] is None # Python 编排不走 Node /2 交互绑定路径
assert identity["designRef"] is None
assert identity["referenceAssetRecordIds"] == []
assert identity["consumerRef"] is None
# canonical request不含自引用 hash过 acceptance-request-v3 schema + 语义双层校验。
assert not V._validate_v3_instance(
V._V3_ACCEPTANCE_REQUEST_SCHEMA, V._canonical_acceptance_request(identity))
def test_identity_v3_declares_reference_asset_fields():
"""三个新字段声明时入 identity 并参与 canonical hash非法形状在冻结前拒绝。"""
base = _identity()
declared = V.build_acceptance_v3_identity(
"v3-test", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=base["taskBindingHash"],
design_ref="gac-shanhai-xingji",
reference_asset_record_ids=["gold-m3-gem-r3", "_template-puzzle"],
consumer_ref="cheap-worker.run_acceptance_v3@test")
assert declared["designRef"] == "gac-shanhai-xingji"
assert declared["referenceAssetRecordIds"] == ["gold-m3-gem-r3", "_template-puzzle"]
assert declared["consumerRef"] == "cheap-worker.run_acceptance_v3@test"
# canonical hash 口径不变(稳定 JSON SHA-256、字段集只增不改同身份声明 vs 不声明 hash 必不同。
assert declared["acceptanceRequestHash"] != base["acceptanceRequestHash"]
assert not V._validate_v3_instance(
V._V3_ACCEPTANCE_REQUEST_SCHEMA, V._canonical_acceptance_request(declared))
with pytest.raises(ValueError, match="recordId"):
V.build_acceptance_v3_identity(
"v3-test", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=base["taskBindingHash"], reference_asset_record_ids=["bad id!"])
with pytest.raises(ValueError, match="designRef"):
V.build_acceptance_v3_identity(
"v3-test", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=base["taskBindingHash"], design_ref="")
def test_provenance_v3_freezes_consumed_reference_asset_snapshot(tmp_path):
"""provenance/3 落 consumedReferenceAssets快照冻结消费时刻值声明外消费拒绝。"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("ok", encoding="utf-8")
base = _identity()
identity = V.build_acceptance_v3_identity(
"v3-test", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=base["taskBindingHash"],
reference_asset_record_ids=["gold-m3-gem-r3"], consumer_ref="cheap-worker@test")
artifact_hash = V._artifact_hash_v3(identity["gameId"], staged)
snapshot = [{"recordId": "gold-m3-gem-r3", "role": "harness_fixture", "artifactHash": "9" * 64}]
provenance = V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=artifact_hash,
evidence_root=tmp_path / "results", artifact_path=staged,
consumed_reference_assets=snapshot)
manifest = provenance["manifest"]
assert manifest["schemaVersion"] == "acceptance-provenance/3"
# 快照逐字冻结(注册表后续改动不覆盖历史 provenance并与请求侧声明对账对应。
assert manifest["consumedReferenceAssets"] == snapshot
assert manifest["referenceAssetRecordIds"] == ["gold-m3-gem-r3"]
assert manifest["consumerRef"] == "cheap-worker@test"
assert not V._validate_v3_instance(V._V3_ACCEPTANCE_PROVENANCE_SCHEMA, manifest)
assert not V._validate_v3_instance(
V._V3_ACCEPTANCE_REQUEST_SCHEMA, json.loads(Path(provenance["acceptanceRequestFile"]).read_text()))
# 声明外消费拒绝:快照 recordId 必须是请求侧 referenceAssetRecordIds 的子集。
with pytest.raises(ValueError, match="声明外消费"):
V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=artifact_hash,
evidence_root=tmp_path / "results2", artifact_path=staged,
consumed_reference_assets=[{"recordId": "_template-puzzle", "role": "generation_exemplar",
"artifactHash": "8" * 64}])
# ── W-GOLD-LIVE 消费对账纪律:只消费 active + 版本门 + 缺维度拒绝负例 ──────────────────────
def _test_registry(*records) -> dict:
"""组测试用运行时注册表ReferenceAssetRegistry/1 同形),与迁移清单初始快照隔离。"""
return {"schemaVersion": "ReferenceAssetRegistry/1", "registryVersion": "test",
"sourceOfTruth": "test", "records": list(records)}
def _active_record(record_id="gold-test-r1", role="harness_fixture", artifact_hash="a" * 64,
consumer_ref="cheap-worker@test", design_ref=None) -> dict:
"""一条 active 记录active 必填 consumerRef/signedBy/signedAt 已补齐)。"""
return {"schemaVersion": "ReferenceAssetRecord/1", "recordId": record_id, "role": role,
"lifecycleStatus": "active", "assetRef": f"test/{record_id}", "assetVersion": "r1",
"artifactHash": artifact_hash, "consumerRef": consumer_ref, "designRef": design_ref,
"evidenceRefs": ["test/evidence"], "signedBy": "tester", "signedAt": "2026-07-25"}
def test_reconcile_consumes_only_active_records():
"""当前迁移清单 15 条全 migration_pending/candidate消费任意一条都被拒完成前不得新增 live 消费)。"""
for record_id in ("gold-m3-gem-r3", "_template-puzzle", "gac-shanhai-xingji"):
result = V.reconcile_v3_reference_asset_consumption([record_id])
assert result["ok"] is False and result["consumed"] == []
assert "参照资产未激活不得消费" in result["errors"][0]
def test_reconcile_rejects_unknown_record_id():
"""① 存在闸:注册表没有的 recordId 拒绝。"""
result = V.reconcile_v3_reference_asset_consumption(["no-such-asset"])
assert result["ok"] is False
assert "参照资产记录不存在" in result["errors"][0]
def test_reconcile_rejects_artifact_hash_drift():
"""⑤ 版本门:消费时实际制品 hash 与注册表冻结值不一致 → 漂移拒绝;一致则过并冻结快照。"""
registry = _test_registry(_active_record(artifact_hash="a" * 64))
drifted = V.reconcile_v3_reference_asset_consumption(
[{"recordId": "gold-test-r1", "artifactHash": "b" * 64}], registry=registry)
assert drifted["ok"] is False
assert "制品 hash 漂移拒绝" in drifted["errors"][0]
matched = V.reconcile_v3_reference_asset_consumption(
[{"recordId": "gold-test-r1", "artifactHash": "a" * 64}], registry=registry)
assert matched["ok"] is True
assert matched["consumed"] == [
{"recordId": "gold-test-r1", "role": "harness_fixture", "artifactHash": "a" * 64}]
def test_reconcile_rejects_game_content_gold_missing_design_ref():
"""⑥ 缺维度门active 的 game_content_gold 无已批准 designIntent → 拒绝;补齐后可消费。"""
missing = V.reconcile_v3_reference_asset_consumption(
["gac-test"], registry=_test_registry(
_active_record(record_id="gac-test", role="game_content_gold", design_ref=None)))
assert missing["ok"] is False
assert "game_content_gold 缺 designRef 维度拒绝" in missing["errors"][0]
complete = V.reconcile_v3_reference_asset_consumption(
["gac-test"], registry=_test_registry(
_active_record(record_id="gac-test", role="game_content_gold",
design_ref=["docs/test-design.md"])))
assert complete["ok"] is True
def test_reconcile_role_and_consumer_ref_gates():
"""③ role 与消费场景不符、④ consumerRef 未登记/与登记不符均拒绝;全对则放行。"""
registry = _test_registry(_active_record(consumer_ref="cheap-worker@registered"))
role_mismatch = V.reconcile_v3_reference_asset_consumption(
[{"recordId": "gold-test-r1", "role": "generation_exemplar"}], registry=registry)
assert role_mismatch["ok"] is False and "role 与消费场景不匹配" in role_mismatch["errors"][0]
consumer_mismatch = V.reconcile_v3_reference_asset_consumption(
["gold-test-r1"], consumer_ref="cheap-worker@someone-else", registry=registry)
assert consumer_mismatch["ok"] is False and "与登记不符" in consumer_mismatch["errors"][0]
bare = _active_record(); bare["consumerRef"] = None
unregistered = V.reconcile_v3_reference_asset_consumption(
["gold-test-r1"], registry=_test_registry(bare))
assert unregistered["ok"] is False and "consumerRef 未登记" in unregistered["errors"][0]
ok = V.reconcile_v3_reference_asset_consumption(
[{"recordId": "gold-test-r1", "role": "harness_fixture"}],
consumer_ref="cheap-worker@registered", registry=registry)
assert ok["ok"] is True and len(ok["consumed"]) == 1
def test_declared_reference_asset_consumption_is_rejected_without_active_record(tmp_path, monkeypatch):
"""编排接线:声明消费参照资产而注册表无 active → verified reject + 冻结,不起浏览器、不烧模型。"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
base = _identity()
identity = V.build_acceptance_v3_identity(
"v3-test", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=base["taskBindingHash"],
reference_asset_record_ids=["gold-m3-gem-r3"], consumer_ref="cheap-worker@test")
called = False
async def forbidden(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr(V, "run_playtest_v3", forbidden)
payload = asyncio.run(V.run_acceptance_v3(_request(staged, tmp_path / "results", identity)))
assert called is False # 消费对账闸先于证据评估:拒绝消费时绝不启动 playtest
assert payload["outcome"] == "reject"
assert payload["decision"]["publishFrozen"] is True
assert payload["decision"]["repairEligible"] is False # 策略拒绝不可 writer 修复
assert "参照资产未激活不得消费" in payload["decision"]["failure"]["reason"]
assert not V.validate_acceptance_v3_payload(payload)
def test_undeclared_reference_asset_consumption_takes_legacy_path(tmp_path, monkeypatch):
"""向后兼容未声明消费referenceAssetRecordIds 空)≡ 旧路径,正常走完编排。"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
identity = _identity() # 旧调用方:不传三个新字段
_patch_play(monkeypatch, "accept")
payload = asyncio.run(V.run_acceptance_v3(_request(staged, tmp_path / "results", identity)))
assert payload["outcome"] == "accept"
assert not V.validate_acceptance_v3_payload(payload)
def test_artifact_hash_rejects_symlink_directory(tmp_path):
staged = tmp_path / "staged"; staged.mkdir()
outside = tmp_path / "outside"; outside.mkdir(); (outside / "payload.js").write_text("evil", encoding="utf-8")
os.symlink(outside, staged / "assets")
with pytest.raises(ValueError, match="symlink"):
V._artifact_hash_v3("v3-test", staged)
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="平台不支持 FIFO")
def test_artifact_hash_rejects_non_regular_file(tmp_path):
staged = tmp_path / "staged"; staged.mkdir()
os.mkfifo(staged / "pipe")
with pytest.raises(ValueError, match="非普通文件"):
V._artifact_hash_v3("v3-test", staged)
def test_artifact_hash_rejects_file_count_over_shared_hard_cap(tmp_path, monkeypatch):
"""Node 前的 Python 验收入口同样必须在第 N+1 个文件前拒绝。"""
staged = tmp_path / "staged"; staged.mkdir()
(staged / "a.js").write_text("a", encoding="utf-8")
(staged / "b.js").write_text("b", encoding="utf-8")
assert V.artifact_snapshot.MAX_ARTIFACT_FILES == 4096
monkeypatch.setattr(V.artifact_snapshot, "MAX_ARTIFACT_FILES", 1)
with pytest.raises(ValueError, match="文件数超过 1"):
V._artifact_hash_v3("v3-test", staged)
def test_artifact_hash_rejects_total_bytes_over_shared_hard_cap(tmp_path, monkeypatch):
"""超大 staged 文件不能在启动 Node 前先被 Python 全量读入内存。"""
staged = tmp_path / "staged"; staged.mkdir()
(staged / "large.js").write_bytes(b"12")
assert V.artifact_snapshot.MAX_ARTIFACT_BYTES == 128 * 1024 * 1024
monkeypatch.setattr(V.artifact_snapshot, "MAX_ARTIFACT_BYTES", 1)
with pytest.raises(ValueError, match="总字节超过 1"):
V._artifact_hash_v3("v3-test", staged)
def test_artifact_hash_rejects_rewrite_then_restore_during_read(tmp_path, monkeypatch):
"""同 inode 在读取中被改写后恢复原文,也不能产出看似稳定的旧 hash。"""
staged = tmp_path / "staged"; staged.mkdir()
target = staged / "bundle.iife.js"
original_bytes = b"var __GameBundle={ok:true};"
target.write_bytes(original_bytes)
original_read = V.artifact_snapshot.os.read
mutated = False
def racing_read(fd, size):
nonlocal mutated
data = original_read(fd, size)
if data and not mutated:
mutated = True
target.write_bytes(b"var __GameBundle={xx:true};")
target.write_bytes(original_bytes)
return data
monkeypatch.setattr(V.artifact_snapshot.os, "read", racing_read)
with pytest.raises(ValueError, match="读取中发生改写"):
V._artifact_hash_v3("v3-test", staged)
def test_artifact_snapshot_v1_breaks_path_content_concatenation_collision(tmp_path):
"""旧 path||content 会碰撞的两棵树,在带域和长度前缀的新算法下必须不同。"""
left = tmp_path / "left"; left.mkdir(); (left / "a").write_bytes(b"bc")
right = tmp_path / "right"; right.mkdir(); (right / "ab").write_bytes(b"c")
assert V._artifact_hash_v3("left", left) == "e2e4387200e847080115d37eeaf56a1c26b17e4309a3e83915515f6e86d2b1fe"
assert V._artifact_hash_v3("right", right) == "76514714613669966423bb21a2706d62a0a7d9dc68b957eaa1d6748503932e47"
def test_artifact_snapshot_v1_cross_language_unicode_fixed_vector(tmp_path):
"""BMP/非 BMP 路径按 UTF-8 字节序排序,固定向量须与 Node 逐字一致。"""
staged = tmp_path / "vector"; (staged / "目录").mkdir(parents=True)
(staged / "a.txt").write_bytes(b"A")
(staged / "目录" / "\ue000.txt").write_bytes(b"BMP")
(staged / "目录" / "😀.txt").write_bytes(b"NONBMP")
assert V._artifact_hash_v3("vector", staged) == (
"0c307413de500b3047b4dd6d2e024f08b844a584881b6cd2aac4bd72f969b18f")
@pytest.mark.parametrize("outcome,expected", [
("accept", "accept"), ("missing", "inconclusive"), ("reject", "reject"),
("contradicted", "inconclusive"), ("judge_error", "tester_error"),
])
def test_guard_four_states_and_contradiction_boundary(tmp_path, outcome, expected):
identity = _identity(); artifact = "a" * 64
guard = _guard(_fact(tmp_path / outcome, identity, artifact, outcome=outcome), identity, artifact)
assert guard["rollOutcome"] == expected
if outcome == "reject":
assert guard["verified"] is True and guard["repairEvidenceComplete"] is True
if outcome == "contradicted":
assert guard["subtype"] == "evidence_contradiction" and not guard.get("verified")
@pytest.mark.parametrize("failure_class", ["off_brief", "broken", "hollow"])
def test_global_judge_reject_is_not_downgraded_when_all_obligations_are_satisfied(
tmp_path, failure_class):
"""off_brief/broken/hollow 属于整局拒绝,不能因义务表全绿而降成证据不足。"""
identity = _identity(); artifact = "6" * 64
fact = _fact(tmp_path / "global-reject", identity, artifact)
_set_global_reject(fact, failure_class)
guard = _guard(fact, identity, artifact)
assert all(row["status"] == "satisfied" for row in fact["proofObligations"] if row["required"])
assert guard["rollOutcome"] == "reject"
assert guard["subtype"] == failure_class
assert guard["failedObligations"] == []
assert guard["repairEvidenceComplete"] is False
@pytest.mark.parametrize("mutation", ["consensus-hash", "judge-a-result", "shared-raw"])
def test_dual_judge_ref_hash_drift_fails_closed_before_verified_reject(tmp_path, mutation):
"""v3 不保留 Judge 自由文本consensus/A/B 任一引用漂移只能形成 tester_error。"""
identity = _identity(); artifact = "9" * 64
fact = _fact(tmp_path / mutation, identity, artifact)
_set_global_reject(fact, "off_brief")
if mutation == "consensus-hash":
fact["judge"]["consensusHash"] = "0" * 64
elif mutation == "judge-a-result":
fact["judge"]["judgeAResultHash"] = "0" * 64
else:
fact["judgeBRef"]["rawRef"] = fact["judgeARef"]["rawRef"]
guard = _guard(fact, identity, artifact)
assert guard["rollOutcome"] == "tester_error"
assert guard["subtype"] == "schema_error"
assert guard.get("verified") is not True
assert "SingleRollFact" in guard["reason"]
def test_second_roll_accept_cannot_rescue_global_judge_reject(tmp_path):
"""即使第二掷产生新截图,整局拒绝与 accept 也只能形成冲突,不能自动翻案。"""
identity = _identity(); artifact = "7" * 64
first_fact = _fact(tmp_path / "first", identity, artifact)
_set_global_reject(first_fact, "broken")
first = _guard(first_fact, identity, artifact)
second_root = tmp_path / "second"
second_fact = _fact(second_root, identity, artifact, roll=2)
new_hash = _rewrite_frame_bytes(
second_fact, second_root, "frames/action-2-post.jpg", b"second-roll-new-hard-evidence")
second = _guard(second_fact, identity, artifact)
assert second["rollOutcome"] == "accept"
assert new_hash in set(second["evidenceHashes"]) - set(first["evidenceHashes"])
merge = V._merge_candidate_v3([first, second])
assert merge["mergeCandidate"] == "inconclusive"
assert merge["conflicts"] == ["reject+accept"]
assert merge["rescuedByRoll"] is None
def test_inconclusive_then_reject_stays_inconclusive(tmp_path):
"""第一掷证据不足时,第二掷单次拒绝不能覆盖不确定性。"""
identity = _identity(); artifact = "8" * 64
first = _guard(
_fact(tmp_path / "first", identity, artifact, outcome="missing", roll=1),
identity, artifact,
)
second = _guard(
_fact(tmp_path / "second", identity, artifact, outcome="reject", roll=2),
identity, artifact,
)
merge = V._merge_candidate_v3([first, second])
assert first["rollOutcome"] == "inconclusive"
assert second["rollOutcome"] == "reject"
assert merge["mergeCandidate"] == "inconclusive"
assert merge["conflicts"] == ["inconclusive+reject"]
assert merge["rescuedByRoll"] is None
def test_global_judge_reject_survives_final_postguard_without_repair_authority(
tmp_path, monkeypatch):
"""全局拒绝必须贯穿最终写回,但没有失败义务三证时不能授权 writer 修回。"""
staged = tmp_path / "staged"; staged.mkdir()
(staged / "index.html").write_text("global reject", encoding="utf-8")
identity = _identity(game_id="global-reject-final")
async def fake_play(request, *, run_dir, artifact_hash, brief_hash, cfg=None):
raw = _fact(Path(run_dir) / "roll-1", identity, artifact_hash, run_id=request["runId"])
_set_global_reject(raw, "hollow")
guard = _guard(raw, identity, artifact_hash, request["brief"])
record = {"roll": 1, "raw": raw, "attempts": [raw], "rollGuard": guard}
return {
"rolls": [record], "rollGuards": [guard],
"merge": V._merge_candidate_v3([guard]), "costRmb": raw["costRmb"],
"registry": _registry(identity, request["brief"]),
}
monkeypatch.setattr(V, "run_playtest_v3", fake_play)
payload = asyncio.run(V.run_acceptance_v3(_request(
staged, tmp_path / "results", identity)))
assert payload["outcome"] == "reject"
assert payload["decision"]["failure"]["subtype"] == "hollow"
assert payload["decision"]["publishFrozen"] is True
assert payload["decision"]["repairEligible"] is False
assert not V.validate_acceptance_v3_payload(payload)
assert V.is_v3_repair_authorized(payload) is False
@pytest.mark.parametrize("actual,predicate,expected", [
(True, {"path": "/value", "op": "equals", "value": 1}, False),
(1, {"path": "/value", "op": "equals", "value": True}, False),
(False, {"path": "/value", "op": "oneOf", "value": [0, 1]}, False),
(1, {"path": "/value", "op": "oneOf", "value": [True, 1]}, True),
])
def test_predicate_equality_uses_strict_json_types(actual, predicate, expected):
"""Python bool 是 int 子类,但契约谓词必须保持 JSON boolean/number 类型边界。"""
validator = V._load_v3_validator()
assert validator._predicate_matches({"value": actual}, predicate) is expected
def test_same_value_relation_is_hard_reject_without_repair(tmp_path):
identity = _identity(); artifact = "b" * 64
fact = _fact(tmp_path, identity, artifact)
ending = next(event for event in fact["events"] if event["type"] == "narrative.ending-reached")
ending["payload"]["branchId"] = "other-branch"
_seal_event_payload(ending)
guard = _guard(fact, identity, artifact)
assert guard["rollOutcome"] == "reject"
assert guard["subtype"] == "completion_relation_contradiction"
assert guard["repairEvidenceComplete"] is False
merge = V._merge_candidate_v3([guard])
assert merge["mergeCandidate"] == "reject" and merge["_verifiedReject"] is False
def test_ordered_series_relation_is_hard_reject(tmp_path):
identity = V.build_acceptance_v3_identity(
"heritage-test", "非遗工艺", genre="heritage", template_route="_template-feiyi",
task_binding_hash=V.task_binding_hash_v3("trace-heritage-test"))
artifact = "c" * 64
fact = _fact_from_final(HERITAGE_VALID, tmp_path, identity, artifact)
summary_event = fact["events"][3]
summary_event["payload"]["orderedStepIds"] = ["material", "paint", "shape"]
_seal_event_payload(summary_event)
guard = V._roll_guard_v3(
fact, artifact_hash=artifact, brief_hash=identity["briefHash"],
registry=_registry(identity, "非遗工艺"), task_binding_hash=identity["taskBindingHash"],
acceptance_request_hash=identity["acceptanceRequestHash"])
assert guard["rollOutcome"] == "reject"
assert guard["subtype"] == "completion_relation_contradiction"
def test_time_advance_mismatch_is_tester_error(tmp_path):
identity = _identity(); artifact = "d" * 64
fact = _fact(tmp_path, identity, artifact)
fact["actions"][0]["timeAdvance"]["executedFrames"] -= 1
guard = _guard(fact, identity, artifact)
assert guard["rollOutcome"] == "tester_error"
def test_unrelated_event_does_not_expand_rescue_evidence(tmp_path):
identity = _identity(); artifact = "e" * 64
first = _guard(_fact(tmp_path / "first", identity, artifact, outcome="missing"), identity, artifact)
accepted = _fact(tmp_path / "second", identity, artifact, roll=2)
# 新增事件没有进入任何 required sequenceRefs不得成为第二掷“新硬证”。
accepted["events"].append({**copy.deepcopy(accepted["events"][0]), "seq": 3,
"payload": {"choiceId": "noise", "branchId": "noise",
"node": {"before": "x", "after": "y"}},
"actionId": "action-2", "virtualTimeMs": 1200})
_seal_event_payload(accepted["events"][-1])
accepted["actions"][1]["postEventSeq"] = 3
second = _guard(accepted, identity, artifact)
assert second["rollOutcome"] == "accept"
assert not (set(second["evidenceHashes"]) - set(_guard(
_fact(tmp_path / "baseline", identity, artifact), identity, artifact)["evidenceHashes"]))
assert V._merge_candidate_v3([first, second])["mergeCandidate"] == "inconclusive"
def test_float_payload_uses_node_canonical_text_without_cross_language_false_negative(tmp_path):
identity = _identity(); artifact = "f" * 64
fact = _fact(tmp_path / "float", identity, artifact)
event = {**copy.deepcopy(fact["events"][0]), "seq": 3, "actionId": "action-2",
"virtualTimeMs": 1200}
_seal_event_payload(
event, {"tiny": 1e-7, "epsilon": 1e-6},
canonical='{"epsilon":0.000001,"tiny":1e-7}',
)
fact["events"].append(event)
fact["actions"][1]["postEventSeq"] = 3
guard = _guard(fact, identity, artifact)
assert guard["rollOutcome"] == "accept"
def test_payload_canonical_semantic_mismatch_is_tester_error(tmp_path):
identity = _identity(); artifact = "9" * 64
fact = _fact(tmp_path / "float-mismatch", identity, artifact)
event = {**copy.deepcopy(fact["events"][0]), "seq": 3, "actionId": "action-2",
"virtualTimeMs": 1200}
_seal_event_payload(
event, {"tiny": 1e-7, "epsilon": 1e-6},
canonical='{"epsilon":0.000001,"tiny":0.000001}',
)
fact["events"].append(event)
fact["actions"][1]["postEventSeq"] = 3
guard = _guard(fact, identity, artifact)
assert guard["rollOutcome"] == "tester_error"
assert "payloadCanonical" in guard["reason"]
def test_first_play_preserves_measured_interactive_time(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("game", encoding="utf-8")
identity = _identity(); evidence = tmp_path / "results"
_patch_play(monkeypatch, "accept")
payload = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
assert payload["firstPlay"]["interactiveAtVirtualMs"] == 137
assert payload["firstPlay"]["firstFeedbackAtVirtualMs"] == 600
assert not V.validate_acceptance_v3_payload(payload)
@pytest.mark.parametrize("outcome", ["accept", "missing", "reject", "judge_error"])
def test_final_outcomes_are_contract_valid_and_nonaccept_never_publishes(tmp_path, monkeypatch, outcome):
staged = tmp_path / outcome; staged.mkdir(); (staged / "index.html").write_text(outcome, encoding="utf-8")
identity = _identity(game_id=f"final-{outcome}")
_patch_play(monkeypatch, outcome)
payload = asyncio.run(V.run_acceptance_v3(_request(staged, tmp_path / "results", identity)))
expected = {"accept": "accept", "missing": "inconclusive", "reject": "reject",
"judge_error": "tester_error"}[outcome]
assert payload["outcome"] == expected
assert not V.validate_acceptance_v3_payload(payload)
assert V.is_v3_publishable(payload) is (outcome == "accept")
if outcome != "accept":
assert payload["decision"]["publishFrozen"] is True
def test_floor_failure_never_starts_playtest(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
identity = _identity()
called = False
async def forbidden(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr(V, "run_playtest_v3", forbidden)
request = _request(staged, tmp_path / "results", identity); request["verdict"] = _floor(False)
payload = asyncio.run(V.run_acceptance_v3(request))
assert called is False and payload["outcome"] == "reject"
assert payload["decision"]["publishFrozen"] is True
def test_repair_requires_real_authorized_parent(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); artifact_file = staged / "index.html"
artifact_file.write_text("parent", encoding="utf-8")
evidence = tmp_path / "results"
first_identity = _identity(game_id="repair-chain")
_patch_play(monkeypatch, "reject")
parent = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, first_identity)))
assert V.is_v3_repair_authorized(parent)
artifact_file.write_text("repaired", encoding="utf-8")
repair_identity = _identity(
game_id="repair-chain", repair_ordinal=1, source=parent["artifactHash"],
parent=parent["acceptanceRequestHash"])
_patch_play(monkeypatch, "accept")
repaired = asyncio.run(V.run_acceptance_v3(_request(
staged, evidence, repair_identity, parent_run_id=parent["runId"],
writer_cost=0.0)))
assert repaired["outcome"] == "accept" and repaired["repair"]["attempted"] is True
forged = _identity(
game_id="repair-chain", repair_ordinal=1, source="f" * 64,
parent=parent["acceptanceRequestHash"])
rejected = asyncio.run(V.run_acceptance_v3(_request(
staged, evidence, forged, parent_run_id=parent["runId"])))
assert rejected["outcome"] == "tester_error"
assert rejected["decision"]["publishFrozen"] is True
def test_repair_chain_cost_is_derived_from_sealed_parent_and_cannot_reset(tmp_path, monkeypatch):
"""repair 请求只给 writer 增量;即使增量为零,封存父成本也必须完整继承。"""
staged = tmp_path / "staged"; staged.mkdir(); artifact_file = staged / "index.html"
artifact_file.write_text("parent", encoding="utf-8")
evidence = tmp_path / "results"; first_identity = _identity(game_id="repair-cost-chain")
_patch_play(monkeypatch, "reject")
parent = asyncio.run(V.run_acceptance_v3(_request(
staged, evidence, first_identity, writer_cost=14.7)))
assert parent["decision"]["parentChainCostRmb"] == 14.82
artifact_file.write_text("repaired", encoding="utf-8")
repair_identity = _identity(
game_id="repair-cost-chain", repair_ordinal=1, source=parent["artifactHash"],
parent=parent["acceptanceRequestHash"])
_patch_play(monkeypatch, "accept")
repaired = asyncio.run(V.run_acceptance_v3(_request(
staged, evidence, repair_identity, parent_run_id=parent["runId"], writer_cost=0.0)))
assert repaired["outcome"] == "accept"
assert repaired["decision"]["parentChainCostRmb"] == 14.94
reset = _request(
staged, evidence, repair_identity, parent_run_id=parent["runId"], writer_cost=0.0)
reset["idempotencyKey"] = "repair-cost-chain:reset"
reset["parentChainCostRmb"] = 0.0
blocked = asyncio.run(V.run_acceptance_v3(reset))
assert blocked["outcome"] == "tester_error"
assert blocked["decision"]["failure"]["subtype"] == "cost_contract_error"
@pytest.mark.parametrize("label,bad_cost", [
("nan", float("nan")), ("inf", float("inf")), ("negative", -0.01), ("boolean", True),
])
def test_writer_cost_rejects_nonfinite_negative_and_boolean(tmp_path, monkeypatch, label, bad_cost):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
identity = _identity(game_id=f"bad-writer-cost-{label}")
_patch_play(monkeypatch, "accept")
payload = asyncio.run(V.run_acceptance_v3(_request(
staged, tmp_path / "results", identity, writer_cost=bad_cost)))
assert payload["outcome"] == "tester_error"
assert payload["decision"]["failure"]["subtype"] == "cost_contract_error"
json.dumps(payload, allow_nan=False)
def test_sealed_reentry_cross_checks_acceptance_identity(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
evidence = tmp_path / "results"; identity = _identity(game_id="sealed-id")
_patch_play(monkeypatch, "accept")
first = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
sealed = evidence / first["runId"] / "decision.json"
dirty = json.loads(sealed.read_text(encoding="utf-8")); dirty["acceptanceRequestHash"] = "f" * 64
sealed.write_text(json.dumps(dirty), encoding="utf-8")
second = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
assert second["outcome"] == "tester_error"
assert second["decision"]["publishFrozen"] is True
def test_sealed_reentry_rejects_deleted_action_frame(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
evidence = tmp_path / "results"; identity = _identity(game_id="sealed-frame-delete")
_patch_play(monkeypatch, "accept")
first = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
frame_ref = first["actions"][0]["postFrameRef"]["path"]
(evidence / first["runId"] / "roll-1" / frame_ref).unlink()
second = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
assert second["outcome"] == "tester_error"
assert "截图" in second["decision"]["failure"]["reason"]
def test_sealed_reentry_rejects_tampered_action_frame(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
evidence = tmp_path / "results"; identity = _identity(game_id="sealed-frame-tamper")
_patch_play(monkeypatch, "accept")
first = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
frame_ref = first["actions"][0]["preFrameRef"]["path"]
(evidence / first["runId"] / "roll-1" / frame_ref).write_bytes(b"tampered")
second = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
assert second["outcome"] == "tester_error"
assert second["decision"]["publishFrozen"] is True
def test_sealed_reentry_rejects_frame_path_traversal(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
evidence = tmp_path / "results"; identity = _identity(game_id="sealed-frame-path")
_patch_play(monkeypatch, "accept")
first = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
sealed = evidence / first["runId"] / "decision.json"
dirty = json.loads(sealed.read_text(encoding="utf-8"))
dirty["actions"][0]["preFrameRef"]["path"] = "../outside.jpg"
sealed.write_text(json.dumps(dirty), encoding="utf-8")
second = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
assert second["outcome"] == "tester_error"
assert second["decision"]["publishFrozen"] is True
def test_sealed_reentry_rejects_decision_symlink(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
evidence = tmp_path / "results"; identity = _identity(game_id="sealed-decision-link")
_patch_play(monkeypatch, "accept")
first = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
sealed = evidence / first["runId"] / "decision.json"
external = tmp_path / "external-decision.json"; external.write_bytes(sealed.read_bytes())
sealed.unlink(); os.symlink(external, sealed)
second = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
assert second["outcome"] == "tester_error"
assert second["decision"]["publishFrozen"] is True
def test_reentry_rejects_symlinked_evidence_root(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
evidence = tmp_path / "results"; identity = _identity(game_id="sealed-root-link")
_patch_play(monkeypatch, "accept")
first = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, identity)))
alias = tmp_path / "results-link"; os.symlink(evidence, alias)
second = asyncio.run(V.run_acceptance_v3(_request(staged, alias, identity)))
assert first["outcome"] == "accept"
assert second["outcome"] == "tester_error"
assert second["decision"]["publishFrozen"] is True
def test_repair_parent_rejects_missing_parent_frame(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); artifact_file = staged / "index.html"
artifact_file.write_text("parent", encoding="utf-8")
evidence = tmp_path / "results"; first_identity = _identity(game_id="repair-frame-missing")
_patch_play(monkeypatch, "reject")
parent = asyncio.run(V.run_acceptance_v3(_request(staged, evidence, first_identity)))
frame_ref = parent["actions"][0]["postFrameRef"]["path"]
(evidence / parent["runId"] / "roll-1" / frame_ref).unlink()
artifact_file.write_text("repaired", encoding="utf-8")
repair_identity = _identity(
game_id="repair-frame-missing", repair_ordinal=1, source=parent["artifactHash"],
parent=parent["acceptanceRequestHash"])
repaired = asyncio.run(V.run_acceptance_v3(_request(
staged, evidence, repair_identity, parent_run_id=parent["runId"])))
assert repaired["outcome"] == "tester_error"
assert "父 decision/截图证据不可复核" in repaired["decision"]["failure"]["reason"]
def test_concurrent_same_run_executes_runner_once_and_returns_same_sealed_result(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
evidence = tmp_path / "results"; identity = _identity(game_id="same-run-lock")
_patch_play(monkeypatch, "accept")
fake_play = V.run_playtest_v3
calls = 0
async def counted_play(*args, **kwargs):
nonlocal calls
calls += 1
await asyncio.sleep(0.05)
return await fake_play(*args, **kwargs)
monkeypatch.setattr(V, "run_playtest_v3", counted_play)
request = _request(staged, evidence, identity)
async def run_both():
return await asyncio.gather(V.run_acceptance_v3(request), V.run_acceptance_v3(request))
first, second = asyncio.run(run_both())
assert calls == 1
assert first == second
assert first["outcome"] == "accept"
def test_run_lock_timeout_returns_tester_error_without_runner(tmp_path, monkeypatch):
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("x", encoding="utf-8")
identity = _identity(game_id="run-lock-timeout")
called = False
async def forbidden(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr(V, "run_playtest_v3", forbidden)
monkeypatch.setattr(V, "_acquire_v3_run_lock", lambda *args: (None, "测试锁超时"))
payload = asyncio.run(V.run_acceptance_v3(_request(staged, tmp_path / "results", identity)))
assert called is False
assert payload["outcome"] == "tester_error"
assert "测试锁超时" in payload["decision"]["failure"]["reason"]
def test_timeout_cost_consumes_remaining_cap_and_disables_retry(tmp_path, monkeypatch):
identity = _identity(); calls = 0
def timeout(*args, **kwargs):
nonlocal calls
calls += 1
return {"schemaVersion": "playtest/3", "packageType": "SingleRollRunnerError",
"roll": 1, "outcome": "tester_error", "failureSubtype": "runner_timeout",
"reason": "timeout", "infraRetryable": True, "costRmb": 1.5}
monkeypatch.setattr(V, "_run_playtest_v3_roll_sync", timeout)
request = {"gameId": identity["gameId"], "brief": BRIEF, "acceptanceIdentity": identity,
"acceptanceMode": "v3", "evidenceMode": "native", "runId": "cost-run",
"acceptanceProvenance": {"acceptanceRequestFile": "r", "provenanceManifestFile": "m",
"provenanceManifestHash": "a" * 64}}
out = asyncio.run(V.run_playtest_v3(
request, run_dir=tmp_path, artifact_hash="a" * 64, brief_hash=identity["briefHash"],
cfg={**V._acceptance_v3_cfg(), "cost_cap_rmb": 1.5, "infra_retry": 1, "second_roll": True}))
assert calls == 1
assert out["merge"]["mergeCandidate"] == "tester_error"
assert out["costRmb"] == 1.5
def test_trusted_cost_ledger_accepts_zero_and_node_rounded_sum():
"""零调用成本合法;有调用时以 entry.rmb 求和并按 Node 五位小数快照对账。"""
assert V._trusted_runner_cost_v3({
"costRmb": 0.0, "cost": {"costRmb": 0.0, "entries": []},
}) == 0.0
rounded = {
"costRmb": 0.12346,
"cost": {"costRmb": 0.12346, "entries": [
_cost_entry("Actor", 0.123456),
_cost_entry("JudgeA", 0.000004),
]},
}
assert V._trusted_runner_cost_v3(rounded) == 0.12346
@pytest.mark.parametrize("entry_cost, expected", [
(0.015624999, 0.01562),
(0.015625, 0.01563),
(0.015625001, 0.01563),
])
def test_trusted_cost_ledger_uses_cross_language_half_up(entry_cost, expected):
payload = {
"costRmb": expected,
"cost": {"costRmb": expected, "entries": [_cost_entry("Actor", entry_cost)]},
}
assert V._trusted_runner_cost_v3(payload) == expected
@pytest.mark.parametrize("payload", [
# top=0 但 entry 明确计费:旧实现只看顶层会把真实成本吞掉。
{"costRmb": 0.0, "cost": {"costRmb": 0.0, "entries": [
_cost_entry("Actor", 0.01)]}},
{"costRmb": float("nan"), "cost": {"costRmb": float("nan"), "entries": []}},
{"costRmb": 0.1, "cost": {"costRmb": 0.1, "entries": [
_cost_entry("Actor", -0.1)]}},
{"costRmb": 0.1, "cost": {"costRmb": 0.1, "entries": [
_cost_entry("Actor", float("nan"))]}},
{"costRmb": 0.1, "cost": {"costRmb": 0.1, "entries": [
_cost_entry("writer", 0.1, model="")]}},
{"costRmb": 0.1, "cost": {"costRmb": 0.1, "entries": [
_cost_entry("Actor", 0.09)]}},
])
def test_trusted_cost_ledger_rejects_forged_or_nonfinite_entries(payload):
assert V._trusted_runner_cost_v3(payload) is None
def test_build_request_uses_same_identity_without_reselection():
identity = _identity()
request = cheap_studio.build_acceptance_v3_request(
identity["gameId"], BRIEF, _floor(True), acceptance_identity=identity)
assert request["acceptanceIdentity"] == identity
assert request["repairCountAcrossParentChain"] == identity["repairOrdinal"]
assert request["writerCostRmb"] == 0.0
assert "parentChainCostRmb" not in request
with pytest.raises(ValueError, match="repair_count"):
cheap_studio.build_acceptance_v3_request(
identity["gameId"], BRIEF, _floor(True), acceptance_identity=identity, repair_count=1)
for bad_cost in (float("nan"), float("inf"), -0.01, True):
with pytest.raises(ValueError, match="writerCostRmb"):
cheap_studio.build_acceptance_v3_request(
identity["gameId"], BRIEF, _floor(True), acceptance_identity=identity,
writer_cost_rmb=bad_cost)
def _sync_kwargs(tmp_path: Path, identity: dict, artifact="a" * 64, remaining=1.5) -> dict:
brief_file = tmp_path / "brief.txt"; brief_file.write_text(BRIEF, encoding="utf-8")
request_file = tmp_path / "request.json"; request_file.write_text("{}", encoding="utf-8")
manifest_file = tmp_path / "manifest.json"; manifest_file.write_text("{}", encoding="utf-8")
return {
"run_id": "sync-run", "roll": 1, "requested_seed": 101, "policy_seed": 7001,
"brief_hash": identity["briefHash"], "artifact_hash": artifact,
"brief_file": str(brief_file), "genre": identity["genre"],
"template_route": identity["templateRoute"], "proof_profile_id": identity["proofProfileId"],
"proof_registry_version": identity["proofRegistryVersion"],
"task_binding_hash": identity["taskBindingHash"],
"acceptance_request_hash": identity["acceptanceRequestHash"],
"acceptance_request_file": str(request_file),
"provenance_manifest_file": str(manifest_file), "provenance_manifest_hash": "f" * 64,
"acceptance_mode": "v3", "evidence_mode": "native", "evidence_dir": tmp_path,
"model_name": "MiniMax-M3", "port": 5000, "cdp_port": 9400, "timeout": 10,
"remaining_cost_rmb": remaining, "cfg": V._acceptance_v3_cfg(),
}
def test_runner_boundary_passes_playtest3_cli_fields(tmp_path, monkeypatch):
identity = _identity(); captured = {}
script = tmp_path / "runner.sh"; script.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setattr(V, "_playtest_v3_script", lambda: script)
class Proc:
returncode = 2
stderr = ""
stdout = json.dumps({
"schemaVersion": "playtest/3", "packageType": "SingleRollRunnerError",
"failureSubtype": "browser_error", "reason": "boot", "infraRetryable": True,
"error": {"message": "boot"}, "costRmb": 0.0,
"cost": {"costRmb": 0.0, "entries": []},
})
def run(argv, **kwargs):
captured["argv"] = argv
return Proc()
monkeypatch.setattr(V.subprocess, "run", run)
out = V._run_playtest_v3_roll_sync(identity["gameId"], **_sync_kwargs(tmp_path, identity))
args = set(captured["argv"])
for expected in (
f"--template-route={identity['templateRoute']}",
f"--proof-profile-id={identity['proofProfileId']}",
f"--proof-registry-version={identity['proofRegistryVersion']}",
f"--task-binding-hash={identity['taskBindingHash']}",
f"--acceptance-request-hash={identity['acceptanceRequestHash']}",
"--acceptance-mode=v3", "--evidence-mode=native",
):
assert expected in args
assert out["costRmb"] == 0.0
@pytest.mark.parametrize("failure,expected_cost", [("timeout", 1.5), ("no-json", 1.5),
("proof-corrupt", 0.12)])
def test_cost_paths_use_trusted_ledger_or_remaining_cap(tmp_path, monkeypatch, failure, expected_cost):
identity = _identity(); script = tmp_path / "runner.sh"; script.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setattr(V, "_playtest_v3_script", lambda: script)
if failure == "timeout":
monkeypatch.setattr(V.subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(
subprocess.TimeoutExpired(cmd="runner", timeout=10)))
elif failure == "no-json":
monkeypatch.setattr(V.subprocess, "run", lambda *a, **k: type(
"Proc", (), {"returncode": 0, "stdout": "not-json", "stderr": ""})())
else:
fact = _fact(tmp_path / "frames", identity, "a" * 64)
proof = tmp_path / fact["proofPackageRef"]
proof.parent.mkdir(parents=True, exist_ok=True)
proof.write_bytes(b"corrupt-proof")
fact["proofPackageHash"] = hashlib.sha256(proof.read_bytes()).hexdigest()
monkeypatch.setattr(V.subprocess, "run", lambda *a, **k: type(
"Proc", (), {"returncode": 0, "stdout": json.dumps(fact), "stderr": ""})())
out = V._run_playtest_v3_roll_sync(identity["gameId"], **_sync_kwargs(tmp_path, identity))
assert out["outcome"] == "tester_error"
assert out["costRmb"] == expected_cost
def test_zero_cost_boot_error_keeps_single_infra_retry(tmp_path, monkeypatch):
identity = _identity(); calls = 0; ports = []
def boot_error(*args, **kwargs):
nonlocal calls
calls += 1
ports.append((kwargs["port"], kwargs["cdp_port"]))
return {"schemaVersion": "playtest/3", "packageType": "SingleRollRunnerError",
"roll": 1, "outcome": "tester_error", "failureSubtype": "browser_error",
"reason": "chrome boot", "infraRetryable": True, "costRmb": 0.0,
"cost": {"costRmb": 0.0, "entries": []}}
monkeypatch.setattr(V, "_run_playtest_v3_roll_sync", boot_error)
request = {"gameId": identity["gameId"], "brief": BRIEF, "acceptanceIdentity": identity,
"acceptanceMode": "v3", "evidenceMode": "native", "runId": "boot-retry",
"acceptanceProvenance": {"acceptanceRequestFile": "r", "provenanceManifestFile": "m",
"provenanceManifestHash": "a" * 64}}
asyncio.run(V.run_playtest_v3(
request, run_dir=tmp_path, artifact_hash="a" * 64, brief_hash=identity["briefHash"],
cfg={**V._acceptance_v3_cfg(), "cost_cap_rmb": 1.5, "infra_retry": 1, "second_roll": False}))
assert calls == 2
assert ports[0] != ports[1], "端口类基础设施错误重试时必须切换备用 HTTP/CDP 槽"
def test_v3_port_derivation_has_low_collision_over_large_run_sample():
"""v3 高端 20k 槽在 1000 个不同 run 样本上保持低碰撞legacy v2 派生函数不变。"""
pairs = [V._derive_playtest_v3_ports(f"run-{index}") for index in range(1000)]
assert len(set(pairs)) >= 950
assert all(20_000 <= port <= 39_999 and 40_000 <= cdp <= 59_999 for port, cdp in pairs)
assert V._derive_playtest_v3_ports("same-run", 0) != V._derive_playtest_v3_ports("same-run", 1)
def test_second_roll_uint32_seed_wraps_without_invalid_seed(tmp_path, monkeypatch):
"""首掷取 uint32 最大值时,第二掷按 32 位环绕且仍与首掷不同。"""
identity = _identity(game_id="seed-wrap")
artifact = "a" * 64
seen = []
def inconclusive_roll(game_id, **kwargs):
seen.append((kwargs["requested_seed"], kwargs["policy_seed"]))
fact = _fact(
tmp_path / f"seed-roll-{kwargs['roll']}", identity, artifact,
outcome="missing", roll=kwargs["roll"], run_id=kwargs["run_id"])
fact.update({"requestedSeed": kwargs["requested_seed"], "actualSeed": kwargs["requested_seed"],
"policySeed": kwargs["policy_seed"]})
fact["actor"].update({"requestedGameSeed": kwargs["requested_seed"],
"actualGameSeed": kwargs["requested_seed"],
"policySeed": kwargs["policy_seed"]})
return fact
monkeypatch.setattr(V, "_run_playtest_v3_roll_sync", inconclusive_roll)
request = {
"gameId": identity["gameId"], "brief": BRIEF, "acceptanceIdentity": identity,
"acceptanceMode": "v3", "evidenceMode": "native", "runId": "seed-wrap-run",
"gameSeed": 0xFFFFFFFF, "policySeed": 0xFFFFFFFF,
"acceptanceProvenance": {"acceptanceRequestFile": "r", "provenanceManifestFile": "m",
"provenanceManifestHash": "a" * 64},
}
asyncio.run(V.run_playtest_v3(
request, run_dir=tmp_path / "run", artifact_hash=artifact, brief_hash=identity["briefHash"],
cfg={**V._acceptance_v3_cfg(), "infra_retry": 0, "second_roll": True}))
assert seen == [
(0xFFFFFFFF, 0xFFFFFFFF),
((0xFFFFFFFF + 7919) & 0xFFFFFFFF, (0xFFFFFFFF + 104729) & 0xFFFFFFFF),
]
assert all(0 <= seed <= 0xFFFFFFFF and 0 <= policy <= 0xFFFFFFFF for seed, policy in seen)
@pytest.mark.parametrize("bad_seed", [-1, 0x1_0000_0000, True, "7"])
def test_first_roll_rejects_non_uint32_seed_before_runner(tmp_path, monkeypatch, bad_seed):
identity = _identity(game_id="seed-invalid")
called = False
def forbidden(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr(V, "_run_playtest_v3_roll_sync", forbidden)
request = {
"gameId": identity["gameId"], "brief": BRIEF, "acceptanceIdentity": identity,
"acceptanceMode": "v3", "evidenceMode": "native", "runId": "seed-invalid-run",
"gameSeed": bad_seed, "policySeed": 1,
"acceptanceProvenance": {"acceptanceRequestFile": "r", "provenanceManifestFile": "m",
"provenanceManifestHash": "a" * 64},
}
result = asyncio.run(V.run_playtest_v3(
request, run_dir=tmp_path / "run", artifact_hash="a" * 64,
brief_hash=identity["briefHash"], cfg=V._acceptance_v3_cfg()))
assert called is False
assert result["merge"]["mergeCandidate"] == "tester_error"
assert result["merge"]["_subtype"] == "seed_mismatch"
def _run_runner_validate(config):
"""调 Node runner validateProfileProvenance返回 (returncode, stderr)exit 0=接受2=响亮拒绝。"""
runner = Path(__file__).resolve().parents[2] / "game-runtime" / "games" / "_wg1-gen" / "_shared" / "playtest-v3.cdp.cjs"
code = (
"const m=require(process.argv[1]);const c=JSON.parse(process.argv[2]);"
"try{m.validateProfileProvenance(c);process.exit(0)}catch(e){console.error(e.message);process.exit(2)}"
)
proc = subprocess.run(["node", "-e", code, str(runner), json.dumps(config)],
capture_output=True, text=True)
return proc.returncode, proc.stderr
def test_runner_rejects_tampered_provenance_even_with_rehashed_file(tmp_path):
"""runner 侧必须拒绝 provenance 篡改 + 重 hash——检查点 3a runner 学会 /3 后恢复原两段断言。
检查点 2 曾把本测试固化为「runner 整拒 /3」的接缝绊线彼时 runner 只认 /1 /2W-GOLD-LIVE
检查点 3a 升 runner 认 acceptance-request/3 契约——三个参照资产可选字段designRef /
referenceAssetRecordIds / consumerRef允许出现且 request/manifest 逐字段镜像manifest 侧
consumedReferenceAssets 出现即逐条校验三元组形状recordId/role/artifactHash与声明子集闸——
故按检查点 2 docstring 约定恢复原意,并把篡改覆盖范围扩到 v3 新字段:
段一:合法 v3 acceptance 被 runner 接受(未声明消费 + 声明且快照合法 两例);
段二:单侧篡改 manifest + 重算文件 hash 仍被整拒(镜像闸 / 形状闸 / 声明子集闸 / 三方一致闸),
覆盖 designRef / referenceAssetRecordIds / consumedReferenceAssets 与基础身份字段。
"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("game", encoding="utf-8")
gem_hash = "94075fb645952bd068c8429042a0e82247ffe66e72c48951e1af68603d71f4fc"
def make(identity, *, consumed=None, tag="case"):
artifact = V._artifact_hash_v3(identity["gameId"], staged)
provenance = V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=artifact,
evidence_root=tmp_path / f"external-{tag}", artifact_path=staged,
consumed_reference_assets=consumed)
config = {
"acceptanceRequestFile": provenance["acceptanceRequestFile"],
"provenanceManifestFile": provenance["provenanceManifestFile"],
"acceptanceRequestHash": identity["acceptanceRequestHash"],
"provenanceManifestHash": provenance["provenanceManifestHash"],
"gameId": identity["gameId"], "genre": identity["genre"],
"templateRoute": identity["templateRoute"], "proofProfileId": identity["proofProfileId"],
"proofRegistryVersion": identity["proofRegistryVersion"],
"taskBindingHash": identity["taskBindingHash"],
"expectedBriefHash": identity["briefHash"], "expectedArtifactHash": artifact,
}
return provenance, config
def tamper(provenance, config, mutate):
"""单侧篡改 manifest 并重算文件字节 hashrunner 参数同步)——正是字段闸必须封住的攻击路径。"""
manifest = dict(provenance["manifest"])
mutate(manifest)
data = V._stable_json_bytes_v3(manifest)
Path(provenance["provenanceManifestFile"]).write_bytes(data)
new_config = dict(config)
new_config["provenanceManifestHash"] = hashlib.sha256(data).hexdigest()
return new_config
identity = _identity()
# 段一a合法 v3 acceptance未声明消费三可选字段 null/[]/null→ runner 接受。
provenance, config = make(identity, tag="plain")
rc, stderr = _run_runner_validate(config)
assert rc == 0, f"runner 应接受合法 acceptance-request/3stderr={stderr!r}"
# 段一b合法 v3 acceptance声明消费 + 合法消费快照)→ runner 接受。
declared_identity = V.build_acceptance_v3_identity(
"v3-consumed", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=V.task_binding_hash_v3("trace-v3-consumed"),
design_ref="docs/agent-specs/reference.md",
reference_asset_record_ids=["gold-m3-gem-r3"], consumer_ref="playtest.runner.v3")
provenance_c, config_c = make(
declared_identity, tag="consumed",
consumed=[{"recordId": "gold-m3-gem-r3", "role": "harness_fixture", "artifactHash": gem_hash}])
rc, stderr = _run_runner_validate(config_c)
assert rc == 0, f"runner 应接受带合法 consumedReferenceAssets 快照的 /3stderr={stderr!r}"
# 段二:单侧篡改 + 重 hash → 整拒(覆盖 v3 新字段与基础身份字段)。
cases = [
("designRef", lambda m: m.update({"designRef": "docs/evil.md"}), "designRef"),
("referenceAssetRecordIds",
lambda m: m.update({"referenceAssetRecordIds": ["gold-m3-gem-r3"]}), "referenceAssetRecordIds"),
("consumedReferenceAssets 声明外消费",
lambda m: m.update({"consumedReferenceAssets": [
{"recordId": "gold-m3-gem-r3", "role": "harness_fixture", "artifactHash": gem_hash}]}),
"声明外消费"),
("consumedReferenceAssets role 越权",
lambda m: m.update({"consumedReferenceAssets": [
{"recordId": "gold-m3-gem-r3", "role": "super_admin", "artifactHash": gem_hash}]}),
"形状非法"),
("gameId 基础字段", lambda m: m.update({"gameId": "v3-evil"}), "gameId"),
]
for name, mutate, expected_message in cases:
bad = tamper(provenance, config, mutate)
rc, stderr = _run_runner_validate(bad)
assert rc == 2, f"篡改 {name} + 重 hash 应被整拒rc={rc} stderr={stderr!r}"
assert expected_message in stderr, f"{name} 拒绝原因应命中严格闸stderr={stderr!r}"
# 声明例也过一轮篡改核验:把快照 recordId 改成声明集之外 → 声明子集闸拒绝。
bad = tamper(
provenance_c, config_c,
lambda m: m.update({"consumedReferenceAssets": [
{"recordId": "gold-m3-candy-r3", "role": "harness_fixture", "artifactHash": gem_hash}]}))
rc, stderr = _run_runner_validate(bad)
assert rc == 2, f"篡改声明例 consumed recordId + 重 hash 应被整拒rc={rc} stderr={stderr!r}"
assert "声明外消费" in stderr
# ---------------------------------------------------------------------------
# W-GOLD-LIVE 检查点 3a生成 prompt 参照资产注入接线离线测试
# ---------------------------------------------------------------------------
# mock 注册表:一条 active可消费+ 一条 migration_pending当前实况未激活不得消费
_MOCK_ACTIVE_REGISTRY = {
"schemaVersion": "ReferenceAssetRegistry/1",
"registryVersion": "2026-07-25.test-active",
"records": [
{
"schemaVersion": "ReferenceAssetRecord/1",
"recordId": "mock-active-exemplar",
"role": "generation_exemplar",
"lifecycleStatus": "active",
"assetRef": "game-runtime/games/_mock-active-exemplar",
"assetVersion": "r1",
"artifactHash": "b" * 64,
"consumerRef": "test-consumer",
"designRef": ["docs/agent-specs/mock-design.md"],
"evidenceRefs": ["evidence/mock.json"],
"signedBy": "tester",
"signedAt": "2026-07-25",
},
{
"schemaVersion": "ReferenceAssetRecord/1",
"recordId": "mock-pending-exemplar",
"role": "generation_exemplar",
"lifecycleStatus": "migration_pending",
"assetRef": "game-runtime/games/_mock-pending-exemplar",
"assetVersion": "r1",
"artifactHash": "c" * 64,
"consumerRef": None,
"designRef": None,
"evidenceRefs": [],
"signedBy": None,
"signedAt": None,
},
],
}
def test_generation_prompt_injection_absent_when_not_declared_or_no_active():
"""向后兼容:未声明消费 → None 不注入;声明消费但无 active 记录 → 响亮拒绝fail-closed"""
# ① 未声明消费(当前生成线全量)→ 返回 None生成 prompt 不含任何参照注入。
assert V.build_v3_reference_asset_generation_constraints({}) is None
assert V.build_v3_reference_asset_generation_constraints(
{"referenceAssetRecordIds": [], "consumerRef": None}) is None
# ② 声明消费但记录未激活mock 的 migration_pending→ 对账拒绝。
with pytest.raises(ValueError, match="未激活不得消费"):
V.build_v3_reference_asset_generation_constraints(
{"referenceAssetRecordIds": ["mock-pending-exemplar"], "consumerRef": "test-consumer"},
registry=_MOCK_ACTIVE_REGISTRY)
# ③ 真实迁移清单initial.json 全 migration_pending/candidate、0 active→ 任何声明都被拒,
# 即『完成前不得新增 live 消费』的机器强制:当前真实注册表下绝无注入发生。
with pytest.raises(ValueError, match="参照资产消费对账拒绝"):
V.build_v3_reference_asset_generation_constraints(
{"referenceAssetRecordIds": ["gold-m3-gem-r3"], "consumerRef": "test-consumer"})
def test_generation_prompt_injection_present_for_mock_active_record():
"""mock 一条 active 记录 → 约束块含 recordId + designRef + 参照义务consumed 快照冻结三元组。"""
result = V.build_v3_reference_asset_generation_constraints(
{"referenceAssetRecordIds": ["mock-active-exemplar"], "consumerRef": "test-consumer"},
registry=_MOCK_ACTIVE_REGISTRY)
assert result is not None
block = result["constraint_block"]
# 注入三要素recordId、designRef已批准设计引用、参照义务文本。
assert "mock-active-exemplar" in block
assert "docs/agent-specs/mock-design.md" in block
assert "参照义务" in block and "game-runtime/games/_mock-active-exemplar" in block
# 消费快照冻结消费时刻的 role + artifactHash供验收 provenance 独立对账)。
assert result["consumed"] == [
{"recordId": "mock-active-exemplar", "role": "generation_exemplar", "artifactHash": "b" * 64}]
assert result["records"][0]["recordId"] == "mock-active-exemplar"
def test_generation_prompt_injection_consumer_mismatch_rejected():
"""consumerRef 与登记不符(六项闸 ④)→ 即使 active 也拒绝注入。"""
with pytest.raises(ValueError, match="consumerRef 与登记不符"):
V.build_v3_reference_asset_generation_constraints(
{"referenceAssetRecordIds": ["mock-active-exemplar"], "consumerRef": "other-consumer"},
registry=_MOCK_ACTIVE_REGISTRY)
def test_acceptance_reconcile_gate_returns_consumed_snapshot_for_provenance():
"""验收侧对账闸:未声明 → (None, []);声明且 active → (None, 快照);声明未激活 → (拒绝原因, [])。"""
# 未声明 ≡ 旧路径放行、无快照provenance consumedReferenceAssets 落 [])。
assert V._v3_check_declared_reference_assets({"referenceAssetRecordIds": []}) == (None, [])
# 声明且对账通过 → 返回消费时刻快照(验收侧零信任自产,不接收生成侧自报)。
identity = {"referenceAssetRecordIds": ["mock-active-exemplar"], "consumerRef": "test-consumer"}
# _v3_check_declared_reference_assets 读真实注册表;用 monkeypatch 注入 mock 注册表文件口径。
real_loader = V._load_v3_reference_asset_registry
V._load_v3_reference_asset_registry = lambda registry_file=None: _MOCK_ACTIVE_REGISTRY
try:
error, consumed = V._v3_check_declared_reference_assets(identity)
finally:
V._load_v3_reference_asset_registry = real_loader
assert error is None
assert consumed == [
{"recordId": "mock-active-exemplar", "role": "generation_exemplar", "artifactHash": "b" * 64}]
# 声明但真实注册表无 active → 拒绝原因非空、快照为空。
error, consumed = V._v3_check_declared_reference_assets(
{"referenceAssetRecordIds": ["gold-m3-gem-r3"], "consumerRef": "x"})
assert error is not None and "未激活不得消费" in error
assert consumed == []