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