games-development-ai/cheap-worker/tests/test_acceptance_v3.py
lili cbfd4d871b
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(acceptance): 闭合 playtest v3 与 A+ 可信消费链
固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。

将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。

同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
2026-07-28 20:16:13 -07:00

2367 lines
117 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 = "一款分支叙事游戏"
MATCH3_PROFILE_ID = "match3.orthogonal-swap-v1"
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 _match3_identity(game_id="match3-v3-test", *, repair_ordinal=0, source=None, parent=None,
task_trace_id="trace-match3-v3-test", interaction_binding=None):
"""构造显式 Match-3 shadow 身份;普通 puzzle 测试仍直接调用 builder 并保持 binding=null。"""
kwargs = ({"interaction_profile_id": MATCH3_PROFILE_ID}
if interaction_binding is None and repair_ordinal == 0
else {"interaction_binding": interaction_binding})
return V.build_acceptance_v3_identity(
game_id, "标准三消解谜", genre="puzzle", template_route="_template-puzzle",
repair_ordinal=repair_ordinal, source_artifact_hash=source,
parent_acceptance_request_hash=parent,
task_binding_hash=V.task_binding_hash_v3(task_trace_id),
**kwargs,
)
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_explicit_match3_identity_builds_canonical_binding_without_auto_select():
"""显式 profile 才生成 InteractionBinding/1;普通 puzzle 不得按模板或 brief 自动选择。"""
task_hash = V.task_binding_hash_v3("trace-match3-v3-test")
content = {
"interactionProfileId": MATCH3_PROFILE_ID,
"interactionRegistryVersion": "2026-07-15.v2",
"taskBindingHash": task_hash,
}
expected_hash = hashlib.sha256(
b"interaction-binding/1\n" + json.dumps(
content, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
).hexdigest()
explicit = _match3_identity()
ordinary = V.build_acceptance_v3_identity(
"ordinary-puzzle", "标准三消解谜", genre="puzzle", template_route="_template-puzzle",
task_binding_hash=V.task_binding_hash_v3("trace-ordinary-puzzle"),
)
assert explicit["interactionBinding"] == {
"schemaVersion": "InteractionBinding/1",
**content,
"interactionBindingHash": expected_hash,
}
assert ordinary["interactionBinding"] is None
def test_match3_binding_must_match_proof_route_and_repair_parent_binding():
"""profile 与 proof 路由必须同源;修回只能继承首轮 binding,篡改或降为 null 都会被拒绝。"""
first = _match3_identity(game_id="match3-repair")
repair = _match3_identity(
game_id="match3-repair", repair_ordinal=1, source="a" * 64,
parent=first["acceptanceRequestHash"], interaction_binding=first["interactionBinding"],
)
assert repair["interactionBinding"] == first["interactionBinding"]
drifted = copy.deepcopy(first["interactionBinding"])
drifted["taskBindingHash"] = "f" * 64
with pytest.raises(ValueError, match="interactionBinding"):
_match3_identity(
game_id="match3-repair", repair_ordinal=1, source="a" * 64,
parent=first["acceptanceRequestHash"], interaction_binding=drifted,
)
with pytest.raises(ValueError, match="interactionProfileId"):
V.build_acceptance_v3_identity(
"wrong-route", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=V.task_binding_hash_v3("trace-wrong-route"),
interaction_profile_id=MATCH3_PROFILE_ID,
)
def test_run_studio_external_match3_identity_inherits_profile_before_scaffold(monkeypatch):
"""外部 canonical identity 已显式绑定 Match-3 时,create 重验不得把 profile 丢在 scaffold 外。"""
trace_id = "trace-run-studio-match3"
identity = _match3_identity(
game_id="run-studio-match3", task_trace_id=trace_id)
scaffold_profiles = []
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(cheap_studio.cheap_run, "archive_prior_run", lambda _gid: None)
def _stop_scaffold(_gid, _template=None, interaction_profile_id=None):
scaffold_profiles.append(interaction_profile_id)
return {"ok": False, "output": "测试在 scaffold 边界停止"}
monkeypatch.setattr(cheap_studio.cheap_run, "scaffold", _stop_scaffold)
result = asyncio.run(cheap_studio.run_studio(
"run-studio-match3", "标准三消解谜",
scaffold_template="_template-puzzle", genre="puzzle",
acceptance_identity=identity, acceptance_task_trace_id=trace_id,
))
assert scaffold_profiles == [MATCH3_PROFILE_ID]
assert result["ok"] is False
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}])
def test_match3_provenance_writes_independent_canonical_binding_file(tmp_path):
"""非空 binding 必须有独立 canonical 文件与字节 hash;null 路径不产生半套证据。"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("ok", encoding="utf-8")
identity = _match3_identity()
artifact_hash = V._artifact_hash_v3(identity["gameId"], staged)
provenance = V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=artifact_hash,
evidence_root=tmp_path / "match3-results", artifact_path=staged)
binding_file = Path(provenance["interactionBindingFile"])
expected_bytes = json.dumps(
identity["interactionBinding"], ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
assert binding_file.name == "interaction-binding.json"
assert binding_file.read_bytes() == expected_bytes
assert provenance["interactionBindingFileHash"] == hashlib.sha256(expected_bytes).hexdigest()
ordinary = _identity(game_id="ordinary-provenance")
ordinary_provenance = V.write_acceptance_v3_provenance(
ordinary["gameId"], ordinary, artifact_hash=artifact_hash,
evidence_root=tmp_path / "ordinary-results", artifact_path=staged)
assert ordinary_provenance["interactionBindingFile"] is None
assert ordinary_provenance["interactionBindingFileHash"] is None
assert not (tmp_path / "ordinary-results" / "interaction-binding.json").exists()
def test_sealed_match3_provenance_rejects_independent_binding_drift(tmp_path):
"""sealed 重入必须复核独立 binding 字节;只改文件、不改 manifest 也不能返回旧结果。"""
staged = tmp_path / "staged"; staged.mkdir(); (staged / "index.html").write_text("ok", encoding="utf-8")
identity = _match3_identity(game_id="sealed-match3")
artifact_hash = V._artifact_hash_v3(identity["gameId"], staged)
run_dir = tmp_path / "run"
provenance = V.write_acceptance_v3_provenance(
identity["gameId"], identity, artifact_hash=artifact_hash,
evidence_root=run_dir, artifact_path=staged)
V._verify_v3_sealed_reference_provenance(run_dir, identity, artifact_hash, None)
Path(provenance["interactionBindingFile"]).write_text("{}", encoding="utf-8")
with pytest.raises(ValueError, match="interaction binding"):
V._verify_v3_sealed_reference_provenance(run_dir, identity, artifact_hash, None)
def test_repair_parent_rejects_binding_downgrade_to_null(tmp_path, monkeypatch):
"""修回不得沿用父 request hash 却把父 Match-3 binding 降为 null。"""
first = _match3_identity(game_id="repair-binding-parent")
evidence_root = tmp_path / "evidence"
parent_run = evidence_root / "parent-run"; parent_run.mkdir(parents=True)
parent_request = V._canonical_acceptance_request(first)
request_file = parent_run / f"acceptance-request-{first['acceptanceRequestHash']}.json"
V._write_json_bytes_atomic(request_file, parent_request)
parent = {
"artifactHash": "a" * 64, "acceptanceRequestHash": first["acceptanceRequestHash"],
"gameId": first["gameId"], "briefHash": first["briefHash"], "genre": first["genre"],
"templateRoute": first["templateRoute"], "proofProfileId": first["proofProfileId"],
"proofRegistryVersion": first["proofRegistryVersion"],
"taskBindingHash": first["taskBindingHash"],
"decision": {"repairCountAcrossParentChain": 0, "parentChainCostRmb": 0.0},
}
monkeypatch.setattr(V, "_load_verified_v3_sealed", lambda *_args, **_kwargs: parent)
monkeypatch.setattr(V, "is_v3_repair_authorized", lambda _payload: True)
downgraded = V.build_acceptance_v3_identity(
first["gameId"], "标准三消解谜", genre="puzzle", template_route="_template-puzzle",
source_artifact_hash=parent["artifactHash"],
parent_acceptance_request_hash=first["acceptanceRequestHash"], repair_ordinal=1,
proof_profile_id=first["proofProfileId"],
proof_registry_version=first["proofRegistryVersion"], task_binding_hash=first["taskBindingHash"],
)
error, _cost = V._validate_v3_repair_parent(
{"parentRunId": "parent-run"}, downgraded, evidence_root)
assert "interactionBinding" in error
# ── 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():
"""未激活迁移项继续拒绝;首款已签认内容金标按登记身份与 hash 成功消费。"""
for record_id in ("gold-m3-gem-r3", "_template-puzzle"):
result = V.reconcile_v3_reference_asset_consumption([record_id])
assert result["ok"] is False and result["consumed"] == []
assert "参照资产未激活不得消费" in result["errors"][0]
artifact_hash = "17b9073c767faf7990e0bf4563a86d55ffa81121f11e7c8ad37c8b8ce72e8cbd"
active = V.reconcile_v3_reference_asset_consumption(
[{"recordId": "gac-shanhai-xingji", "role": "game_content_gold",
"artifactHash": artifact_hash}],
consumer_ref="generation-runtime@reference-assets/1")
assert active == {
"ok": True,
"consumed": [{"recordId": "gac-shanhai-xingji", "role": "game_content_gold",
"artifactHash": artifact_hash}],
"errors": [],
}
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 not any(arg.startswith("--interaction-") for arg in args)
assert out["costRmb"] == 0.0
def test_runner_boundary_passes_all_match3_binding_fields_only_when_verified(tmp_path, monkeypatch):
"""非空 binding 必须把独立文件、文件 hash 与三项身份一次性传给 Node runner。"""
identity = _match3_identity(); captured = {}
script = tmp_path / "runner.sh"; script.write_text("#!/bin/sh\n", encoding="utf-8")
binding_file = tmp_path / "interaction-binding.json"
binding_bytes = json.dumps(
identity["interactionBinding"], ensure_ascii=False, sort_keys=True, separators=(",", ":"),
).encode("utf-8")
binding_file.write_bytes(binding_bytes)
binding_file_hash = hashlib.sha256(binding_bytes).hexdigest()
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)
kwargs = _sync_kwargs(tmp_path, identity)
kwargs.update({
"interaction_binding_file": str(binding_file),
"interaction_binding_file_hash": binding_file_hash,
"interaction_profile_id": MATCH3_PROFILE_ID,
"interaction_registry_version": "2026-07-15.v2",
"interaction_binding_hash": identity["interactionBinding"]["interactionBindingHash"],
})
V._run_playtest_v3_roll_sync(identity["gameId"], **kwargs)
args = set(captured["argv"])
assert {
f"--interaction-binding-file={binding_file}",
f"--interaction-binding-file-hash={binding_file_hash}",
f"--interaction-profile-id={MATCH3_PROFILE_ID}",
"--interaction-registry-version=2026-07-15.v2",
f"--interaction-binding-hash={identity['interactionBinding']['interactionBindingHash']}",
} <= args
def test_run_playtest_v3_forwards_provenance_binding_to_sync_runner(tmp_path, monkeypatch):
"""异步编排必须从 identity/provenance 取五项绑定,不能只在底层 sync helper 留孤立参数。"""
identity = _match3_identity(); captured = []
binding_file = tmp_path / "interaction-binding.json"
binding_file.write_text("{}", encoding="utf-8")
def _runner(*args, **kwargs):
captured.append(kwargs)
return {
"schemaVersion": "playtest/3", "packageType": "SingleRollRunnerError", "roll": 1,
"outcome": "tester_error", "failureSubtype": "browser_error", "reason": "测试停止",
"infraRetryable": False, "costRmb": 0.0,
"cost": {"costRmb": 0.0, "entries": []},
}
monkeypatch.setattr(V, "_run_playtest_v3_roll_sync", _runner)
request = {
"gameId": identity["gameId"], "brief": "标准三消解谜", "acceptanceIdentity": identity,
"acceptanceMode": "v3_shadow", "evidenceMode": "native", "runId": "match3-forward",
"gameSeed": 101, "policySeed": 202,
"acceptanceProvenance": {
"acceptanceRequestFile": str(tmp_path / "request.json"),
"provenanceManifestFile": str(tmp_path / "manifest.json"),
"provenanceManifestHash": "a" * 64,
"interactionBindingFile": str(binding_file),
"interactionBindingFileHash": "b" * 64,
},
}
asyncio.run(V.run_playtest_v3(
request, run_dir=tmp_path / "run", artifact_hash="c" * 64,
brief_hash=identity["briefHash"],
cfg={**V._acceptance_v3_cfg(), "second_roll": False, "infra_retry": 0},
))
assert len(captured) == 1
assert captured[0]["interaction_binding_file"] == str(binding_file)
assert captured[0]["interaction_binding_file_hash"] == "b" * 64
assert captured[0]["interaction_profile_id"] == MATCH3_PROFILE_ID
assert captured[0]["interaction_registry_version"] == "2026-07-15.v2"
assert captured[0]["interaction_binding_hash"] == identity["interactionBinding"]["interactionBindingHash"]
@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 /2);W-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 并重算文件字节 hash(runner 参数同步)——正是字段闸必须封住的攻击路径。"""
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/3(stderr={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 快照的 /3(stderr={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)
# ③ 真实注册表中的未激活迁移项仍拒绝注入;已有 active 记录不放松其它条目的生命周期闸。
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 == []
def test_frozen_reference_preflight_default_skips_gate(monkeypatch):
"""未显式选择 policy 时不得触发可信消费门,保持既有生成路径。"""
import reference_asset_gate
def forbidden(*args, **kwargs):
raise AssertionError("默认路径不得调用 reference asset gate")
monkeypatch.setattr(reference_asset_gate, "verify_policy", forbidden)
assert V.preflight_reference_asset_policy(None, "v3_shadow") is None
def test_frozen_reference_preflight_uses_fixed_trust_inputs():
"""显式 survivor policy 必须以生产代码内置锚点完成一次真实预检。"""
verified = V.preflight_reference_asset_policy("survivor-gold-v1", "v3_shadow")
assert verified is not None
assert verified.snapshot_hash == "743faf24e2267b706043c1d23f2e1cec62efb8ad5ee28bb1174688e52ed9f06e"
assert "game-runtime/games/shanhai-xingji/README.md" in verified.reference_files
assert set(verified.reference_roots) == {"gac-shanhai-xingji"}
@pytest.mark.parametrize(
("policy_id", "acceptance_mode", "message"),
[
("unknown-policy", "v3_shadow", "reference_policy_missing"),
("survivor-gold-v1", "v3", "v3 live"),
],
)
def test_frozen_reference_preflight_rejects_unknown_policy_and_v3_live(
policy_id, acceptance_mode, message):
"""未知策略与 v3 live 都必须在 Writer 前响亮失败。"""
with pytest.raises(ValueError, match=message):
V.preflight_reference_asset_policy(policy_id, acceptance_mode)
@pytest.mark.parametrize("code", ["reference_artifact_hash_mismatch", "reference_entry_hash_mismatch"])
def test_frozen_reference_preflight_propagates_bundle_and_entry_drift(monkeypatch, code):
"""bundle 或 manifest entry 漂移不得被 helper 吞掉或降级。"""
import reference_asset_gate
def drift(*args, **kwargs):
raise reference_asset_gate.ReferenceAssetGateError(code, "gac-shanhai-xingji")
monkeypatch.setattr(reference_asset_gate, "verify_policy", drift)
with pytest.raises(reference_asset_gate.ReferenceAssetGateError, match=code):
V.preflight_reference_asset_policy("survivor-gold-v1", "v3_shadow")
def test_frozen_reference_constraint_uses_only_verified_snapshot_paths():
"""Writer 约束中的可读文件只能来自验证后快照,不能接纳 identity 自报路径。"""
verified = V.preflight_reference_asset_policy("survivor-gold-v1", "v3_shadow")
block = V.build_frozen_reference_asset_constraint_block(verified)
assert "snapshotHash=743faf24e2267b706043c1d23f2e1cec62efb8ad5ee28bb1174688e52ed9f06e" in block
for path in verified.reference_files:
assert path in block
assert "identity-self-reported/evil.js" not in block
def test_run_studio_frozen_preflight_happens_before_scaffold(monkeypatch):
"""CLI 显式 policy 的预检失败必须发生在 scaffold 和 Writer 之前。"""
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
def rejected(*args, **kwargs):
raise ValueError("preflight rejected")
monkeypatch.setattr(V, "preflight_reference_asset_policy", rejected)
monkeypatch.setattr(cheap_studio.cheap_run, "archive_prior_run",
lambda game_id: (_ for _ in ()).throw(AssertionError("不得 scaffold")))
with pytest.raises(ValueError, match="preflight rejected"):
asyncio.run(cheap_studio.run_studio(
"preflight-before-scaffold", "解谜点击", reference_asset_policy_id="survivor-gold-v1"))
def test_run_studio_binds_verified_snapshot_to_session(monkeypatch):
"""CLI 预检成功后必须把冻结 files/roots 原样绑定 CheapSession。"""
verified = V.preflight_reference_asset_policy("survivor-gold-v1", "v3_shadow")
captured = {}
class StopAtSession(BaseException):
pass
def capture_session(*args, **kwargs):
captured.update(kwargs)
raise StopAtSession()
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(V, "preflight_reference_asset_policy", lambda *args, **kwargs: verified)
monkeypatch.setattr(cheap_studio, "CheapSession", capture_session)
monkeypatch.setattr(cheap_studio, "_persist_brief_forensics", lambda *args, **kwargs: None)
with pytest.raises(StopAtSession):
asyncio.run(cheap_studio.run_studio(
"snapshot-session", "测试", run_gates=False,
prepare=lambda game_id: {"ok": True, "output": ""},
reference_asset_policy_id="survivor-gold-v1"))
assert captured["reference_files"] is verified.reference_files
assert captured["reference_roots"] is verified.reference_roots
def test_run_studio_v3_shadow_policy_reaches_session_without_v1_reconcile(monkeypatch):
"""CLI 完整 v3_shadow 路径使用 /2 快照时,不得再进入 Registry/1 兼容对账。"""
captured = {}
class StopAtSession(BaseException):
pass
def capture_session(*args, **kwargs):
captured.update(kwargs)
raise StopAtSession()
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(cheap_studio, "CheapSession", capture_session)
monkeypatch.setattr(cheap_studio, "_persist_brief_forensics", lambda *args, **kwargs: None)
with pytest.raises(StopAtSession):
asyncio.run(cheap_studio.run_studio(
"snapshot-session-full-gates",
"叙事测试",
prepare=lambda game_id: {"ok": True, "output": ""},
genre="narrative",
acceptance_template_route="_template-story",
reference_asset_policy_id="survivor-gold-v1",
))
assert captured["reference_files"]
assert captured["reference_roots"]
def test_run_studio_rejects_external_identity_mismatching_frozen_policy(monkeypatch):
"""外部 identity 未声明 /2 回执资产时,必须在 Writer Session 创建前拒绝。"""
brief = "叙事测试"
task_trace_id = "trace-policy-identity-mismatch"
identity = V.build_acceptance_v3_identity(
"policy-identity-mismatch",
brief,
genre="narrative",
template_route="_template-story",
task_binding_hash=V.task_binding_hash_v3(task_trace_id),
)
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(cheap_studio, "_persist_brief_forensics", lambda *args, **kwargs: None)
monkeypatch.setattr(
cheap_studio,
"CheapSession",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("不得创建 Writer Session")),
)
result = asyncio.run(cheap_studio.run_studio(
"policy-identity-mismatch",
brief,
prepare=lambda game_id: {"ok": True, "output": ""},
acceptance_identity=identity,
acceptance_task_trace_id=task_trace_id,
reference_asset_policy_id="survivor-gold-v1",
))
assert result["ok"] is False
assert result["publishFrozen"] is True
assert "冻结 policy 与 acceptance identity 不一致" in result["failureReason"]
def test_run_studio_accepts_external_identity_matching_frozen_policy(monkeypatch):
"""外部 identity 与 /2 回执精确一致时,完整路径应继续到 Writer Session。"""
import reference_asset_gate
brief = "叙事测试"
task_trace_id = "trace-policy-identity-match"
verified = V.preflight_reference_asset_policy("survivor-gold-v1", "v3_shadow")
receipts = reference_asset_gate.to_json_value(verified.receipts)
identity = V.build_acceptance_v3_identity(
"policy-identity-match",
brief,
genre="narrative",
template_route="_template-story",
task_binding_hash=V.task_binding_hash_v3(task_trace_id),
reference_asset_record_ids=[receipt["recordId"] for receipt in receipts],
consumer_ref=receipts[0]["consumerRef"],
)
captured = {}
class StopAtSession(BaseException):
pass
def capture_session(*args, **kwargs):
captured.update(kwargs)
raise StopAtSession()
monkeypatch.setattr(cheap_studio, "acceptance_v3_mode", lambda: "v3_shadow")
monkeypatch.setattr(cheap_studio, "_persist_brief_forensics", lambda *args, **kwargs: None)
monkeypatch.setattr(cheap_studio, "CheapSession", capture_session)
with pytest.raises(StopAtSession):
asyncio.run(cheap_studio.run_studio(
"policy-identity-match",
brief,
prepare=lambda game_id: {"ok": True, "output": ""},
acceptance_identity=identity,
acceptance_task_trace_id=task_trace_id,
reference_asset_policy_id="survivor-gold-v1",
))
assert captured["reference_files"]
assert captured["reference_roots"]
def _policy_acceptance_case(tmp_path, *, game_id="policy-acceptance"):
"""构造显式 policy 的可信 generation receipts 与闭合 acceptance identity。"""
import reference_asset_gate
verified = V.preflight_reference_asset_policy("survivor-gold-v1", "v3_shadow")
receipts = reference_asset_gate.to_json_value(verified.receipts)
identity = V.build_acceptance_v3_identity(
game_id, BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=V.task_binding_hash_v3(f"trace-{game_id}"),
reference_asset_record_ids=[receipt["recordId"] for receipt in receipts],
consumer_ref=receipts[0]["consumerRef"],
)
staged = tmp_path / f"staged-{game_id}"
staged.mkdir()
(staged / "index.html").write_text("policy game", encoding="utf-8")
request = _request(staged, tmp_path / "results", identity, mode="v3_shadow")
request.update({
"referenceAssetPolicyId": "survivor-gold-v1",
"referenceAssetGenerationReceipts": receipts,
})
return request, receipts
def test_build_request_requires_policy_and_generation_receipts_as_a_pair():
"""内部 policy 与 generation receipts 必须成对,默认路径不得出现这两个字段。"""
import reference_asset_gate
identity = _identity()
verified = V.preflight_reference_asset_policy("survivor-gold-v1", "v3_shadow")
receipts = reference_asset_gate.to_json_value(verified.receipts)
plain = cheap_studio.build_acceptance_v3_request(
identity["gameId"], BRIEF, _floor(True), acceptance_identity=identity)
assert "referenceAssetPolicyId" not in plain
assert "referenceAssetGenerationReceipts" not in plain
bound = cheap_studio.build_acceptance_v3_request(
identity["gameId"], BRIEF, _floor(True), acceptance_identity=identity,
reference_asset_policy_id="survivor-gold-v1",
reference_asset_generation_receipts=receipts)
assert bound["referenceAssetPolicyId"] == "survivor-gold-v1"
assert bound["referenceAssetGenerationReceipts"] == receipts
for kwargs in (
{"reference_asset_policy_id": "survivor-gold-v1"},
{"reference_asset_generation_receipts": receipts},
{"reference_asset_policy_id": "survivor-gold-v1",
"reference_asset_generation_receipts": [{"receiptId": "broken"}]},
):
with pytest.raises(ValueError, match="referenceAsset"):
cheap_studio.build_acceptance_v3_request(
identity["gameId"], BRIEF, _floor(True), acceptance_identity=identity, **kwargs)
@pytest.mark.parametrize("code", ["reference_artifact_hash_mismatch", "reference_entry_hash_mismatch"])
def test_acceptance_policy_reverification_rejects_drift_before_playtest(tmp_path, monkeypatch, code):
"""Writer 前 generation receipts 合法,验收时 bundle/entry 漂移仍须独立拒绝。"""
import reference_asset_gate
request, _ = _policy_acceptance_case(tmp_path, game_id=f"acceptance-drift-{code[-4:]}")
called = False
def drift(*args, **kwargs):
raise reference_asset_gate.ReferenceAssetGateError(code, "gac-shanhai-xingji")
async def forbidden(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr(V, "preflight_reference_asset_policy", drift)
monkeypatch.setattr(V, "run_playtest_v3", forbidden)
payload = asyncio.run(V.run_acceptance_v3(request))
assert called is False
assert payload["outcome"] == "tester_error"
assert payload["decision"]["failure"]["subtype"] == "provenance_error"
assert code in payload["decision"]["failure"]["reason"]
def test_acceptance_rejects_tampered_generation_receipt_and_external_receipt_refs(
tmp_path, monkeypatch):
"""generation receipt 字段篡改或调用方自报验收 ref/hash 都不得启动浏览器。"""
request, receipts = _policy_acceptance_case(tmp_path, game_id="acceptance-tamper")
called = False
async def forbidden(*args, **kwargs):
nonlocal called
called = True
monkeypatch.setattr(V, "run_playtest_v3", forbidden)
tampered = copy.deepcopy(request)
tampered["referenceAssetGenerationReceipts"][0]["finalSnapshotHash"] = "0" * 64
payload = asyncio.run(V.run_acceptance_v3(tampered))
assert called is False
assert payload["outcome"] == "tester_error"
assert payload["decision"]["failure"]["subtype"] == "provenance_error"
self_reported = copy.deepcopy(request)
self_reported["gameId"] = "acceptance-self-report"
self_reported["acceptanceIdentity"] = V.build_acceptance_v3_identity(
"acceptance-self-report", BRIEF, genre="narrative", template_route="_template-story",
task_binding_hash=V.task_binding_hash_v3("trace-acceptance-self-report"),
reference_asset_record_ids=[receipts[0]["recordId"]],
consumer_ref=receipts[0]["consumerRef"])
self_reported["referenceAssetVerificationReceipts"] = [
{"ref": "outside/forged.json", "hash": "f" * 64}]
payload = asyncio.run(V.run_acceptance_v3(self_reported))
assert called is False
assert payload["outcome"] == "tester_error"
assert payload["decision"]["failure"]["subtype"] == "provenance_error"
def test_policy_acceptance_writes_v4_and_own_canonical_receipt_while_legacy_stays_v3(
tmp_path, monkeypatch):
"""显式 policy 只在验收重验成功后产 /4;默认路径继续产原 /3。"""
import reference_asset_gate
policy_request, receipts = _policy_acceptance_case(tmp_path, game_id="acceptance-v4")
_patch_play(monkeypatch, "accept")
policy_payload = asyncio.run(V.run_acceptance_v3(policy_request))
policy_run_dir = Path(policy_request["evidenceRoot"]) / policy_payload["runId"]
policy_manifest = json.loads(
(policy_run_dir / "acceptance-provenance.json").read_text(encoding="utf-8"))
assert policy_manifest["schemaVersion"] == "acceptance-provenance/4"
assert len(policy_manifest["referenceAssetVerificationReceipts"]) == len(receipts)
receipt_link = policy_manifest["referenceAssetVerificationReceipts"][0]
receipt_file = policy_run_dir / receipt_link["ref"]
receipt_bytes = receipt_file.read_bytes()
assert hashlib.sha256(receipt_bytes).hexdigest() == receipt_link["hash"]
assert receipt_bytes == reference_asset_gate.canonical_json_bytes(receipts[0])
staged = tmp_path / "legacy-staged"
staged.mkdir()
(staged / "index.html").write_text("legacy", encoding="utf-8")
identity = _identity(game_id="acceptance-v3-legacy")
legacy_request = _request(staged, tmp_path / "legacy-results", identity, mode="v3_shadow")
legacy_payload = asyncio.run(V.run_acceptance_v3(legacy_request))
legacy_manifest = json.loads((
Path(legacy_request["evidenceRoot"]) / legacy_payload["runId"]
/ "acceptance-provenance.json").read_text(encoding="utf-8"))
assert legacy_manifest["schemaVersion"] == "acceptance-provenance/3"
assert "referenceAssetVerificationReceipts" not in legacy_manifest
def test_sealed_v4_reentry_rejects_deleted_receipt(tmp_path, monkeypatch):
"""同一 sealed run 重入时,/4 receipt 缺失必须按 provenance_error 失败。"""
request, _ = _policy_acceptance_case(tmp_path, game_id="sealed-v4-receipt-delete")
_patch_play(monkeypatch, "accept")
first = asyncio.run(V.run_acceptance_v3(request))
run_dir = Path(request["evidenceRoot"]) / first["runId"]
manifest = json.loads((run_dir / "acceptance-provenance.json").read_text(encoding="utf-8"))
receipt_file = run_dir / manifest["referenceAssetVerificationReceipts"][0]["ref"]
receipt_file.unlink()
second = asyncio.run(V.run_acceptance_v3(request))
assert first["outcome"] == "accept"
assert second["outcome"] == "tester_error"
assert second["decision"]["failure"]["subtype"] == "provenance_error"
def test_v4_receipt_fixed_tmp_symlink_cannot_write_external_file(tmp_path, monkeypatch):
"""预置固定 .tmp symlink 时,随机临时文件仍可安全发布且不得改写外部目标。"""
request, receipts = _policy_acceptance_case(tmp_path, game_id="receipt-tmp-symlink")
staged = Path(request["artifactPath"])
artifact_hash = V._artifact_hash_v3(request["gameId"], staged)
run_id = V._v3_run_id(request, artifact_hash, request["acceptanceIdentity"]["briefHash"])
receipt_dir = Path(request["evidenceRoot"]) / run_id / "reference-asset-verification-receipts"
receipt_dir.mkdir(parents=True)
external = tmp_path / "external-receipt-target.json"
sentinel = b"must remain unchanged"
external.write_bytes(sentinel)
fixed_tmp = receipt_dir / f"{receipts[0]['receiptId']}.json.tmp"
os.symlink(external, fixed_tmp)
_patch_play(monkeypatch, "accept")
payload = asyncio.run(V.run_acceptance_v3(request))
assert payload["outcome"] == "accept"
assert external.read_bytes() == sentinel
assert fixed_tmp.is_symlink()