"""验收 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()