固化 Match-3 生产者、视觉、音频与双 Judge 证据闭包。 将《山海行纪》r1.1 绑定新的不可变 release,并以生产预检现场核验 bundle、Registry/2 和 25 项 Writer 快照。 同步地图1平衡锁值、跨游戏回归修复、验收契约与 SoT 证据。
6424 lines
358 KiB
Python
6424 lines
358 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""play-loop 契约校验器(PlaySpec / VerdictFeedback / playtest/2 / playtest/3)。
|
||
|
||
零依赖:只用 Python 标准库(json/re/sys/pathlib)。之所以不引 jsonschema/ajv——本仓
|
||
没装 jsonschema、也没 vendor ajv,而 W-ARCH① 红线③要求零新依赖;且核心契约的消费方
|
||
(cheap-worker gate_judge.py、tier2 run.py)本就是 Python,落一份 stdlib 校验器与消费侧同栈。
|
||
本文件自带一个 JSON-Schema Draft-2020-12 子集校验器,只实现本目录 schema 真正用到的关键字:
|
||
type / required / properties / patternProperties / additionalProperties /
|
||
enum / const / pattern / minimum / maximum / minItems / maxItems / minLength / maxLength /
|
||
items / anyOf / oneOf / allOf / not / if-then-else / $ref(仅 #/$defs/<name> 本地引用)。
|
||
关键字集是刻意收窄的:schema 由本任务作者,只用上述关键字,正负样本套件即本校验器的回归证明——
|
||
若某关键字实现错了,要么正样本会被误拦、要么负样本会漏过,跑套件即暴露。
|
||
|
||
用法:
|
||
1) 单点校验:python3 validate.py <schema.json> <sample.json> [<sample2.json> ...]
|
||
逐样本打印 PASS/FAIL 与错误路径;全部合规 exit 0,否则 exit 1。
|
||
2) 套件校验:python3 validate.py --suite [samples 根目录,默认脚本旁 samples/]
|
||
走 samples/<contract>/valid(应全 PASS)与 samples/<contract>/invalid(应全 FAIL),
|
||
<contract> 目录名对应同名 schema(play-spec → play-spec.schema.json)。
|
||
打印每契约『正样本 N 过 / 负样本 M 拦』与总计;期望全部满足 exit 0,否则 exit 1。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import re
|
||
import stat
|
||
import struct
|
||
import sys
|
||
import unicodedata
|
||
import zlib
|
||
from copy import deepcopy
|
||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||
from pathlib import Path
|
||
|
||
_HERE = Path(__file__).resolve().parent
|
||
_MISSING = object()
|
||
_RMB_QUANTUM = Decimal("0.00001")
|
||
_TRACE_PATH = None
|
||
_SELECTION_OVERLAY_CONFIG_HASH = "36ea1973df76c5c814ebaa256ea804b08b2ce8aad477271d738817a6e1801678"
|
||
_PRODUCER_SEEDED_GENERATOR_CONFIG_HASH = "73c582f1926e195dba1cb1394bcde9ad9ebd17b4994f90bc724f39dc8bd2c905"
|
||
_PRODUCER_LATTICE_ALGORITHM_VERSION = "match3-producer-canonical-lattice/2"
|
||
_PRODUCER_OPENCV_CONFIG = {
|
||
"schemaVersion": "generatorConfig/1",
|
||
"region": {
|
||
"minArea": 64, "minSide": 24, "maxAreaRatio": 0.35,
|
||
"nmsIou": 0.65, "limit": 24, "colorQuantizationStep": 32,
|
||
"morphKernel": 5,
|
||
},
|
||
"lattice": {
|
||
"minRows": 3, "maxRows": 12, "minColumns": 3, "maxColumns": 12,
|
||
"maxSpacingCv": 0.12, "maxSizeCv": 0.15, "maxAxisResidualPx": 3,
|
||
"minObservedCoverage": 0.70, "limit": 4,
|
||
},
|
||
"spatialGrid": {
|
||
"id": "g01", "columns": 8, "rows": 17,
|
||
"subAnchors": [
|
||
"center", "north", "south", "east", "west",
|
||
"northwest", "northeast", "southwest", "southeast",
|
||
],
|
||
"limit": 1,
|
||
},
|
||
"topLevelLimit": 29,
|
||
}
|
||
|
||
|
||
def _display_path(value) -> str | None:
|
||
"""审计日志只写仓内相对路径;仓外路径退化为文件名,避免机器绝对路径泄漏。"""
|
||
if value is None:
|
||
return None
|
||
if not isinstance(value, Path):
|
||
return str(value)
|
||
resolved = value.resolve()
|
||
repo_root = _HERE.parent.parent
|
||
try:
|
||
return resolved.relative_to(repo_root).as_posix()
|
||
except ValueError:
|
||
return resolved.name
|
||
|
||
|
||
def _audit_path_ref(value: Path):
|
||
"""按生产 auditPathRef 规则从可信实际路径生成审计引用。"""
|
||
resolved = value.resolve()
|
||
repo_root = _HERE.parent.parent
|
||
try:
|
||
return resolved.relative_to(repo_root).as_posix()
|
||
except ValueError:
|
||
return {
|
||
"basename": resolved.name,
|
||
"pathHash": hashlib.sha256(str(resolved).encode("utf-8")).hexdigest(),
|
||
}
|
||
|
||
|
||
def _trace(stage: str, check: str, *, ref=None, path=None, declared_hash=None,
|
||
computed_hash=None, result: str, error: str | None = None) -> None:
|
||
"""追加结构化审计日志;只记录身份与断言结果,不记录 brief、payload 或模型内容。"""
|
||
if _TRACE_PATH is None:
|
||
return
|
||
safe_error = None
|
||
if error is not None:
|
||
# schema/语义错误正文可能包含非法输入的实际值。持久日志只保留 JSON 指针和
|
||
# 稳定 check 名称;完整错误仍由 CLI 打印,不把 brief/payload/model 内容落盘。
|
||
match = re.match(r"^(#[^:]*):", str(error))
|
||
safe_error = f"{match.group(1)}: {check} 失败" if match else f"{check} 失败"
|
||
record = {
|
||
"stage": stage,
|
||
"check": check,
|
||
"ref": None if ref is None else str(ref),
|
||
"path": _display_path(path),
|
||
"declaredHash": declared_hash,
|
||
"computedHash": computed_hash,
|
||
"result": result,
|
||
"error": safe_error,
|
||
}
|
||
with _TRACE_PATH.open("a", encoding="utf-8") as handle:
|
||
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
||
|
||
|
||
def _configure_trace(argv: list) -> list:
|
||
"""解析兼容式 `--trace-jsonl <path>`;移除该选项后继续走原 CLI 分支。"""
|
||
global _TRACE_PATH
|
||
if "--trace-jsonl" not in argv:
|
||
return argv
|
||
index = argv.index("--trace-jsonl")
|
||
if index + 1 >= len(argv):
|
||
raise ValueError("--trace-jsonl 缺输出路径")
|
||
_TRACE_PATH = Path(argv[index + 1]).resolve()
|
||
_TRACE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
_TRACE_PATH.write_text("", encoding="utf-8")
|
||
return argv[:index] + argv[index + 2:]
|
||
|
||
# wait 只允许证明受控时间推进后才会出现的明确业务结果。直接输入、选择、移动、
|
||
# 攻击和交付步骤不得借 wait 冒充玩家操作。
|
||
_WAIT_ELIGIBLE_EVENT_TYPES = {
|
||
"trpg.floor-entered",
|
||
"puzzle.level-entered",
|
||
"sim-business.order-presented",
|
||
"sim-business.settlement-recorded",
|
||
}
|
||
|
||
_PROOF_REGISTRY_SNAPSHOTS = {
|
||
"2026-07-14.v2": "proof-obligations.2026-07-14.v2.json",
|
||
"2026-07-15.v3": "proof-obligations.v2.json",
|
||
}
|
||
|
||
_INTERACTION_REGISTRY_SNAPSHOTS = {
|
||
"2026-07-15.v1": "interaction-profiles.v1.json",
|
||
"2026-07-15.v2": "interaction-profiles.v2.json",
|
||
}
|
||
|
||
|
||
def _load_proof_registry(version: str) -> dict:
|
||
"""按证据声明版本读取冻结快照;未知版本不得回退到最新注册表。"""
|
||
filename = _PROOF_REGISTRY_SNAPSHOTS.get(version)
|
||
if filename is None:
|
||
raise ValueError(f"未知 proof registry version: {version}")
|
||
return _load(_HERE / filename)
|
||
|
||
|
||
def _load_interaction_registry(version: str) -> dict:
|
||
"""按 interactionRegistryVersion 分派冻结/当前注册表,未知版本禁止回退。"""
|
||
filename = _INTERACTION_REGISTRY_SNAPSHOTS.get(version)
|
||
if filename is None:
|
||
raise ValueError(f"未知 interaction registry version: {version}")
|
||
return _load(_HERE / filename)
|
||
|
||
|
||
# ── JSON 类型判定(区分 integer/number/boolean:Python 里 bool 是 int 子类,须显式排除)──
|
||
def _json_type_ok(value, t: str) -> bool:
|
||
if t == "object":
|
||
return isinstance(value, dict)
|
||
if t == "array":
|
||
return isinstance(value, list)
|
||
if t == "string":
|
||
return isinstance(value, str)
|
||
if t == "boolean":
|
||
return isinstance(value, bool)
|
||
if t == "integer":
|
||
return isinstance(value, int) and not isinstance(value, bool)
|
||
if t == "number":
|
||
return (isinstance(value, (int, float)) and not isinstance(value, bool)
|
||
and (not isinstance(value, float) or math.isfinite(value)))
|
||
if t == "null":
|
||
return value is None
|
||
return False
|
||
|
||
|
||
def _resolve_ref(ref: str, root: dict):
|
||
"""解析本目录 JSON Schema 引用,返回(目标节点,目标根 schema)。
|
||
|
||
playtest/2 会直接引用 game-event/1 和公共 defs;显式支持本地 fragment 与同目录
|
||
`file.json#/<pointer>`,其它目录和网络引用一律拒绝,避免校验器静默读到非 SoT。
|
||
"""
|
||
if ref.startswith("#"):
|
||
target_root = root
|
||
fragment = ref[1:]
|
||
else:
|
||
file_name, separator, fragment = ref.partition("#")
|
||
if "/" in file_name or "\\" in file_name or not file_name.endswith(".json"):
|
||
raise ValueError(f"仅支持本目录 JSON Schema 引用,收到:{ref}")
|
||
schema_path = (_HERE / file_name).resolve()
|
||
if schema_path.parent != _HERE or not schema_path.is_file():
|
||
raise ValueError(f"外部 $ref 文件不存在或越界:{ref}")
|
||
target_root = _load(schema_path)
|
||
fragment = fragment if separator else ""
|
||
|
||
node = target_root
|
||
if not fragment:
|
||
return node, target_root
|
||
if not fragment.startswith("/"):
|
||
raise ValueError(f"$ref fragment 必须是 JSON Pointer:{ref}")
|
||
for part in fragment[1:].split("/"):
|
||
# JSON Pointer 转义还原(~1→/,~0→~),本仓 key 无特殊字符,兜个底而已。
|
||
part = part.replace("~1", "/").replace("~0", "~")
|
||
if not isinstance(node, dict) or part not in node:
|
||
raise ValueError(f"$ref 指向不存在的节点:{ref}")
|
||
node = node[part]
|
||
return node, target_root
|
||
|
||
|
||
def validate(schema, instance, root: dict, path: str = "#") -> list:
|
||
"""按 schema 校验 instance,返回错误串列表(空=合规)。schema 为 dict 或 bool。"""
|
||
# 布尔 schema:true=恒过,false=恒否(本仓未用,兜底)。
|
||
if isinstance(schema, bool):
|
||
return [] if schema else [f"{path}: 布尔 schema false,恒不合规"]
|
||
if not isinstance(schema, dict):
|
||
return [f"{path}: schema 非法(既非对象也非布尔)"]
|
||
|
||
errors: list = []
|
||
|
||
# $ref:解析后就地校验(本仓 $ref 节点自带完整约束,直接接管本层)。
|
||
if "$ref" in schema:
|
||
target, target_root = _resolve_ref(schema["$ref"], root)
|
||
return validate(target, instance, target_root, path)
|
||
|
||
# type
|
||
if "type" in schema:
|
||
types = schema["type"]
|
||
types = types if isinstance(types, list) else [types]
|
||
if not any(_json_type_ok(instance, t) for t in types):
|
||
errors.append(f"{path}: 类型应为 {types},实际 {type(instance).__name__}")
|
||
|
||
# const / enum
|
||
if "const" in schema and instance != schema["const"]:
|
||
errors.append(f"{path}: 应恒等于 {schema['const']!r},实际 {instance!r}")
|
||
if "enum" in schema and instance not in schema["enum"]:
|
||
errors.append(f"{path}: 应属枚举 {schema['enum']},实际 {instance!r}")
|
||
|
||
# 字符串约束
|
||
if isinstance(instance, str):
|
||
if "minLength" in schema and len(instance) < schema["minLength"]:
|
||
errors.append(f"{path}: 字符串长度应 ≥ {schema['minLength']},实际 {len(instance)}")
|
||
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
||
errors.append(f"{path}: 字符串长度应 ≤ {schema['maxLength']},实际 {len(instance)}")
|
||
if "pattern" in schema and re.search(schema["pattern"], instance) is None:
|
||
errors.append(f"{path}: 应匹配正则 /{schema['pattern']}/,实际 {instance!r}")
|
||
|
||
# 数值约束(排除 bool)
|
||
if isinstance(instance, (int, float)) and not isinstance(instance, bool):
|
||
if isinstance(instance, float) and not math.isfinite(instance):
|
||
errors.append(f"{path}: JSON number 必须是有限数")
|
||
return errors
|
||
if "minimum" in schema and instance < schema["minimum"]:
|
||
errors.append(f"{path}: 应 ≥ {schema['minimum']},实际 {instance}")
|
||
if "maximum" in schema and instance > schema["maximum"]:
|
||
errors.append(f"{path}: 应 ≤ {schema['maximum']},实际 {instance}")
|
||
|
||
# 数组约束
|
||
if isinstance(instance, list):
|
||
if "minItems" in schema and len(instance) < schema["minItems"]:
|
||
errors.append(f"{path}: 数组元素数应 ≥ {schema['minItems']},实际 {len(instance)}")
|
||
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
||
errors.append(f"{path}: 数组元素数应 ≤ {schema['maxItems']},实际 {len(instance)}")
|
||
if "items" in schema:
|
||
for i, item in enumerate(instance):
|
||
errors += validate(schema["items"], item, root, f"{path}/{i}")
|
||
|
||
# 对象约束
|
||
if isinstance(instance, dict):
|
||
for key in schema.get("required", []):
|
||
if key not in instance:
|
||
errors.append(f"{path}: 缺必填字段 '{key}'")
|
||
props = schema.get("properties", {})
|
||
pattern_props = schema.get("patternProperties", {})
|
||
add_props = schema.get("additionalProperties", True)
|
||
for key, val in instance.items():
|
||
handled = False
|
||
if key in props:
|
||
errors += validate(props[key], val, root, f"{path}/{key}")
|
||
handled = True
|
||
for pat, sub in pattern_props.items():
|
||
if re.search(pat, key) is not None:
|
||
errors += validate(sub, val, root, f"{path}/{key}")
|
||
handled = True
|
||
if not handled:
|
||
if add_props is False:
|
||
errors.append(f"{path}: 不允许的额外字段 '{key}'(additionalProperties=false)")
|
||
elif isinstance(add_props, dict):
|
||
errors += validate(add_props, val, root, f"{path}/{key}")
|
||
|
||
# 组合子:allOf / anyOf / oneOf
|
||
for i, sub in enumerate(schema.get("allOf", [])):
|
||
errors += validate(sub, instance, root, f"{path}/allOf[{i}]")
|
||
if "anyOf" in schema:
|
||
branch_errs = [validate(sub, instance, root, f"{path}/anyOf[{i}]")
|
||
for i, sub in enumerate(schema["anyOf"])]
|
||
if not any(len(e) == 0 for e in branch_errs):
|
||
errors.append(f"{path}: anyOf 无一分支匹配(各分支首错:" +
|
||
";".join(e[0] for e in branch_errs if e) + ")")
|
||
if "oneOf" in schema:
|
||
matched = sum(1 for sub in schema["oneOf"]
|
||
if len(validate(sub, instance, root, path)) == 0)
|
||
if matched != 1:
|
||
errors.append(f"{path}: oneOf 应恰好 1 分支匹配,实际 {matched}")
|
||
if "not" in schema and len(validate(schema["not"], instance, root, path)) == 0:
|
||
errors.append(f"{path}: 命中了 not 禁止的结构")
|
||
|
||
# 条件子:if-then-else(if 本身不产错,命中则套 then、否则套 else)
|
||
if "if" in schema:
|
||
if len(validate(schema["if"], instance, root, path)) == 0:
|
||
if "then" in schema:
|
||
errors += validate(schema["then"], instance, root, f"{path}/then")
|
||
else:
|
||
if "else" in schema:
|
||
errors += validate(schema["else"], instance, root, f"{path}/else")
|
||
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_registry_step(step: dict, path: str, genre: str) -> list:
|
||
"""校验一个静态或动态证据步骤的事件、动作白名单和谓词形状。"""
|
||
errors = []
|
||
event_type = ((step.get("event") or {}).get("type"))
|
||
if event_type and not str(event_type).startswith(f"{genre}."):
|
||
errors.append(f"{path}/event/type: 必须属于 profile 品类 {genre}")
|
||
allowed_actions = step.get("allowedActionTypes") or []
|
||
if len(allowed_actions) != len(set(allowed_actions)):
|
||
errors.append(f"{path}/allowedActionTypes: 动作类型不得重复")
|
||
if "wait" in allowed_actions and event_type not in _WAIT_ELIGIBLE_EVENT_TYPES:
|
||
errors.append(f"{path}/allowedActionTypes: wait 只能用于明确的定时业务结果")
|
||
for pidx, predicate in enumerate(((step.get("event") or {}).get("predicates") or [])):
|
||
ppath = f"{path}/event/predicates/{pidx}"
|
||
op = predicate.get("op")
|
||
value = predicate.get("value")
|
||
if op == "oneOf" and (not isinstance(value, list) or not value):
|
||
errors.append(f"{ppath}/value: oneOf 必须提供非空数组")
|
||
if op in ("gt", "gte", "lt", "lte") and (
|
||
not isinstance(value, (int, float)) or isinstance(value, bool)):
|
||
errors.append(f"{ppath}/value: 数值比较必须提供有限数值")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_registry(instance: dict, *, validate_interaction: bool = True) -> list:
|
||
"""补 proof-obligations/2 的 profile、义务、步骤和 brief 引用不变量。"""
|
||
errors = []
|
||
profiles = instance.get("profiles") or {}
|
||
expected = {
|
||
"narrative.branching-story", "trpg.dice-tower", "heritage.ordered-craft",
|
||
"puzzle.match-board", "sim-business.recipe-fulfillment",
|
||
}
|
||
if set(profiles) != expected:
|
||
errors.append(f"#/profiles: 必须且只能登记五个可信 profile {sorted(expected)}")
|
||
|
||
global_ids = set()
|
||
global_interaction_rule_ids = set()
|
||
interaction_registry = {"profiles": {}}
|
||
if validate_interaction:
|
||
try:
|
||
interaction_registry = _load(_HERE / "interaction-profiles.v1.json")
|
||
except Exception as exc: # noqa: BLE001 —— canonical 交互注册表损坏必须响亮失败
|
||
errors.append(f"#/interactionOptionalRules: 无法加载 canonical interaction registry:{exc}")
|
||
seen_template_routes = set()
|
||
for profile_id, profile in profiles.items():
|
||
ppath = f"#/profiles/{profile_id}"
|
||
if profile.get("id") != profile_id:
|
||
errors.append(f"{ppath}/id: 必须与 profiles 的键一致")
|
||
template_route = profile.get("templateRoute")
|
||
if template_route in seen_template_routes:
|
||
errors.append(f"{ppath}/templateRoute: 五个可信 profile 的模板路由必须唯一")
|
||
seen_template_routes.add(template_route)
|
||
genre = str(profile.get("genre") or "")
|
||
obligations = profile.get("obligations") or []
|
||
local_ids = set()
|
||
for idx, obligation in enumerate(obligations):
|
||
oid = obligation.get("id")
|
||
path = f"{ppath}/obligations/{idx}"
|
||
if oid in global_ids:
|
||
errors.append(f"{path}/id: 义务 id 重复:{oid}")
|
||
global_ids.add(oid)
|
||
local_ids.add(oid)
|
||
if not str(oid).startswith(f"{genre}."):
|
||
errors.append(f"{path}/id: 义务 id 必须以 '{genre}.' 开头")
|
||
evidence = obligation.get("evidence") or {}
|
||
sequence = evidence.get("sequence") or []
|
||
step_ids = set()
|
||
for sidx, step in enumerate(sequence):
|
||
spath = f"{path}/evidence/sequence/{sidx}"
|
||
step_id = step.get("stepId")
|
||
if step_id in step_ids:
|
||
errors.append(f"{spath}/stepId: 同一义务步骤 id 重复:{step_id}")
|
||
step_ids.add(step_id)
|
||
errors += _semantic_validate_registry_step(step, spath, genre)
|
||
|
||
seen_same_value_paths = set()
|
||
for vidx, rule in enumerate(evidence.get("sameValue") or []):
|
||
vpath = f"{path}/evidence/sameValue/{vidx}"
|
||
pointer = rule.get("path")
|
||
if not pointer:
|
||
errors.append(f"{vpath}/path: sameValue 不允许根路径或空路径")
|
||
if pointer in seen_same_value_paths:
|
||
errors.append(f"{vpath}/path: sameValue 路径重复")
|
||
seen_same_value_paths.add(pointer)
|
||
refs = rule.get("stepIds") or []
|
||
if len(refs) != len(set(refs)):
|
||
errors.append(f"{vpath}/stepIds: stepId 不得重复")
|
||
for step_id in refs:
|
||
if step_id not in step_ids:
|
||
errors.append(f"{vpath}/stepIds: 引用了本义务不存在的步骤 {step_id}")
|
||
|
||
# wait 只证明受控时间窗内观察到了事件。多步骤关系义务若允许 wait,
|
||
# 必须再用同值字段把该事件和前序业务事实关联,不能把时间邻近冒充因果。
|
||
same_value_rules = evidence.get("sameValue") or []
|
||
for sidx, step in enumerate(sequence):
|
||
if "wait" not in (step.get("allowedActionTypes") or []):
|
||
continue
|
||
spath = f"{path}/evidence/sequence/{sidx}"
|
||
step_id = step.get("stepId")
|
||
if len(sequence) == 1:
|
||
predicates = ((step.get("event") or {}).get("predicates") or [])
|
||
if not predicates:
|
||
errors.append(f"{spath}: 单步骤 wait 必须以 payload 谓词限定业务结果")
|
||
continue
|
||
correlated = any(
|
||
step_id in (rule.get("stepIds") or [])
|
||
and any(ref != step_id for ref in (rule.get("stepIds") or []))
|
||
for rule in same_value_rules
|
||
)
|
||
if not correlated:
|
||
errors.append(f"{spath}: 多步骤 wait 必须通过 sameValue 与其他步骤建立业务关联")
|
||
|
||
ordered = evidence.get("orderedSeries")
|
||
if isinstance(ordered, dict):
|
||
opath = f"{path}/evidence/orderedSeries"
|
||
if profile_id != "heritage.ordered-craft":
|
||
errors.append(f"{opath}: orderedSeries 首版只允许 heritage.ordered-craft")
|
||
errors += _semantic_validate_registry_step(ordered, opath, genre)
|
||
summary = ordered.get("summary") or {}
|
||
terminal = ordered.get("terminal") or {}
|
||
errors += _semantic_validate_registry_step(summary, f"{opath}/summary", genre)
|
||
errors += _semantic_validate_registry_step(terminal, f"{opath}/terminal", genre)
|
||
if ordered.get("allowedActionTypes") != ["tap", "key", "drag"]:
|
||
errors.append(f"{opath}/allowedActionTypes: 工序步骤必须固定为 tap/key/drag")
|
||
dynamic_ids = {ordered.get("seriesId"), summary.get("stepId"), terminal.get("stepId")}
|
||
if len(dynamic_ids) != 3:
|
||
errors.append(f"{opath}: seriesId、summary.stepId、terminal.stepId 必须两两不同")
|
||
for key in ("groupByPath", "indexPath", "itemIdPath", "totalPath", "judgementPath"):
|
||
if not ordered.get(key):
|
||
errors.append(f"{opath}/{key}: 动态链路径不得为空")
|
||
for holder_name, holder, keys in (
|
||
("summary", summary, ("groupByPath", "totalPath", "orderedItemIdsPath", "completedPath")),
|
||
("terminal", terminal, ("groupByPath", "scorePath", "worksPath")),
|
||
):
|
||
for key in keys:
|
||
if not holder.get(key):
|
||
errors.append(f"{opath}/{holder_name}/{key}: 动态链路径不得为空")
|
||
|
||
# 冻结的 2026-07-14.v2 注册表没有 interaction 规则,其只读验证不能依赖当前注册表。
|
||
if not validate_interaction:
|
||
continue
|
||
interaction_profile_ids = set()
|
||
promoted_by_interaction = set()
|
||
for ridx, rule in enumerate(profile.get("interactionOptionalRules") or []):
|
||
rpath = f"{ppath}/interactionOptionalRules/{ridx}"
|
||
rule_id = rule.get("id")
|
||
if rule_id in global_interaction_rule_ids:
|
||
errors.append(f"{rpath}/id: interaction 规则 id 必须在整表全局唯一")
|
||
global_interaction_rule_ids.add(rule_id)
|
||
interaction_profile_id = rule.get("interactionProfileId")
|
||
if interaction_profile_id in interaction_profile_ids:
|
||
errors.append(f"{rpath}/interactionProfileId: 同一 proof profile 不得重复匹配")
|
||
interaction_profile_ids.add(interaction_profile_id)
|
||
interaction_profile = (
|
||
(interaction_registry.get("profiles") or {}).get(interaction_profile_id) or {}
|
||
)
|
||
if not interaction_profile:
|
||
errors.append(f"{rpath}/interactionProfileId: 未命中 canonical interaction profile")
|
||
else:
|
||
if interaction_profile.get("proofProfileId") != profile_id:
|
||
errors.append(f"{rpath}/interactionProfileId: interaction proofProfileId 不一致")
|
||
if interaction_profile.get("genre") != profile.get("genre"):
|
||
errors.append(f"{rpath}/interactionProfileId: interaction genre 不一致")
|
||
if interaction_profile.get("templateRoute") != profile.get("templateRoute"):
|
||
errors.append(f"{rpath}/interactionProfileId: interaction templateRoute 不一致")
|
||
refs = rule.get("requireObligationIds") or []
|
||
if len(refs) != len(set(refs)):
|
||
errors.append(f"{rpath}/requireObligationIds: 义务 id 不得重复")
|
||
for target in refs:
|
||
if target in promoted_by_interaction:
|
||
errors.append(f"{rpath}/requireObligationIds: 同一义务不得由多条 interaction 规则提升")
|
||
promoted_by_interaction.add(target)
|
||
target_obj = next((o for o in obligations if o.get("id") == target), None)
|
||
if target_obj is None:
|
||
errors.append(f"{rpath}/requireObligationIds: 引用了本 profile 不存在的义务 {target}")
|
||
elif target_obj.get("defaultRequired") is not False:
|
||
errors.append(f"{rpath}/requireObligationIds: {target} 必须保持 defaultRequired=false")
|
||
|
||
rule_ids = set()
|
||
for ridx, rule in enumerate(profile.get("briefOptionalRules") or []):
|
||
rpath = f"{ppath}/briefOptionalRules/{ridx}"
|
||
rule_id = rule.get("id")
|
||
if rule_id in rule_ids:
|
||
errors.append(f"{rpath}/id: brief 可选规则 id 重复:{rule_id}")
|
||
rule_ids.add(rule_id)
|
||
for target in rule.get("requireObligationIds") or []:
|
||
if target not in local_ids:
|
||
errors.append(f"{rpath}/requireObligationIds: 引用了本 profile 不存在的义务 {target}")
|
||
continue
|
||
target_obj = next((o for o in obligations if o.get("id") == target), None)
|
||
if target_obj and target_obj.get("defaultRequired") is not False:
|
||
errors.append(f"{rpath}/requireObligationIds: {target} 已默认必需,不应再提升")
|
||
return errors
|
||
|
||
|
||
def _registry_event_types(registry: dict, profile_id: str | None = None) -> set:
|
||
"""返回 canonical 注册表声明的事件类型;传 profile 时只看该可信 profile。"""
|
||
profiles = registry.get("profiles") or {}
|
||
selected = [profiles.get(profile_id)] if profile_id else profiles.values()
|
||
event_types = set()
|
||
for profile in selected:
|
||
if not isinstance(profile, dict):
|
||
continue
|
||
for obligation in profile.get("obligations") or []:
|
||
evidence = obligation.get("evidence") or {}
|
||
for step in evidence.get("sequence") or []:
|
||
event_type = ((step.get("event") or {}).get("type"))
|
||
if event_type:
|
||
event_types.add(event_type)
|
||
ordered = evidence.get("orderedSeries")
|
||
if isinstance(ordered, dict):
|
||
for step in (ordered, ordered.get("summary") or {}, ordered.get("terminal") or {}):
|
||
event_type = ((step.get("event") or {}).get("type"))
|
||
if event_type:
|
||
event_types.add(event_type)
|
||
return event_types
|
||
|
||
|
||
def _semantic_validate_legacy_mapping(instance: dict) -> list:
|
||
"""历史适配表只能做精确、无歧义、可回溯的 allowlist 映射。"""
|
||
errors = []
|
||
registry = _load_proof_registry(instance.get("proofRegistryVersion"))
|
||
if instance.get("proofRegistryVersion") != registry.get("registryVersion"):
|
||
errors.append("#/proofRegistryVersion: 必须与 canonical proof-obligations.v2.json 一致")
|
||
profiles = registry.get("profiles") or {}
|
||
seen_ids = set()
|
||
seen_keys = set()
|
||
for idx, mapping in enumerate(instance.get("mappings") or []):
|
||
path = f"#/mappings/{idx}"
|
||
mapping_id = mapping.get("id")
|
||
if mapping_id in seen_ids:
|
||
errors.append(f"{path}/id: 映射 id 重复:{mapping_id}")
|
||
seen_ids.add(mapping_id)
|
||
profile_id = mapping.get("proofProfileId")
|
||
if profile_id not in profiles:
|
||
errors.append(f"{path}/proofProfileId: 未知 profile {profile_id}")
|
||
payload_paths = mapping.get("requiredPayloadPaths") or []
|
||
if len(payload_paths) != len(set(payload_paths)):
|
||
errors.append(f"{path}/requiredPayloadPaths: 路径不得重复")
|
||
composite = (profile_id, mapping.get("legacyTag"), tuple(sorted(payload_paths)))
|
||
if composite in seen_keys:
|
||
errors.append(f"{path}: profile+tag+payloadPaths 映射键重复")
|
||
seen_keys.add(composite)
|
||
if mapping.get("type") not in _registry_event_types(registry, profile_id):
|
||
errors.append(f"{path}/type: 目标事件不属于该 profile 注册表")
|
||
return errors
|
||
|
||
|
||
def _load_payload_canonical(text: str):
|
||
"""解析 Node 封存原文,并拒绝 JSON 非有限数与重复键。"""
|
||
def reject_constant(value):
|
||
raise ValueError(f"JSON 非有限数不允许:{value}")
|
||
|
||
def reject_duplicate_keys(pairs):
|
||
result = {}
|
||
for key, value in pairs:
|
||
if key in result:
|
||
raise ValueError(f"JSON 对象键重复:{key}")
|
||
result[key] = value
|
||
return result
|
||
|
||
return json.loads(
|
||
text, parse_constant=reject_constant, object_pairs_hook=reject_duplicate_keys,
|
||
)
|
||
|
||
|
||
def _json_semantically_equal(left, right) -> bool:
|
||
"""按 JSON 类型递归比较;数字允许 int/float 等值,但 bool 不能冒充 0/1。"""
|
||
if isinstance(left, bool) or isinstance(right, bool):
|
||
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
||
if isinstance(left, (int, float)) or isinstance(right, (int, float)):
|
||
return (isinstance(left, (int, float)) and not isinstance(left, bool)
|
||
and isinstance(right, (int, float)) and not isinstance(right, bool)
|
||
and (not isinstance(left, float) or math.isfinite(left))
|
||
and (not isinstance(right, float) or math.isfinite(right))
|
||
and left == right)
|
||
if left is None or right is None:
|
||
return left is None and right is None
|
||
if isinstance(left, str) or isinstance(right, str):
|
||
return isinstance(left, str) and isinstance(right, str) and left == right
|
||
if isinstance(left, list) or isinstance(right, list):
|
||
return (isinstance(left, list) and isinstance(right, list) and len(left) == len(right)
|
||
and all(_json_semantically_equal(a, b) for a, b in zip(left, right)))
|
||
if isinstance(left, dict) or isinstance(right, dict):
|
||
return (isinstance(left, dict) and isinstance(right, dict)
|
||
and set(left) == set(right)
|
||
and all(_json_semantically_equal(left[key], right[key]) for key in left))
|
||
return False
|
||
|
||
|
||
def _semantic_validate_game_event(instance: dict) -> list:
|
||
"""校验 game-event/1 的派生字段、封存原文与 payloadHash。"""
|
||
errors = []
|
||
event_type = instance.get("type")
|
||
if instance.get("namespace") != f"game/{event_type}":
|
||
errors.append("#/namespace: 必须严格等于 game/<type>")
|
||
canonical = instance.get("payloadCanonical")
|
||
if isinstance(canonical, str):
|
||
expected_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||
if instance.get("payloadHash") != expected_hash:
|
||
errors.append("#/payloadHash: 与 payloadCanonical 原始 UTF-8 字节的 SHA-256 不一致")
|
||
try:
|
||
parsed = _load_payload_canonical(canonical)
|
||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||
errors.append(f"#/payloadCanonical: 不是严格 JSON:{exc}")
|
||
else:
|
||
if not isinstance(parsed, dict):
|
||
errors.append("#/payloadCanonical: 根值必须是 JSON 对象")
|
||
elif not _json_semantically_equal(parsed, instance.get("payload")):
|
||
errors.append("#/payloadCanonical: 解析结果与 payload 语义不一致")
|
||
payload = instance.get("payload") or {}
|
||
if event_type == "puzzle.swap-committed":
|
||
pair = payload.get("pair") or []
|
||
if len(pair) == 2:
|
||
pair_key = [(cell.get("row"), cell.get("column")) for cell in pair]
|
||
if pair_key != sorted(pair_key) or pair_key[0] == pair_key[1]:
|
||
errors.append("#/payload/pair: 必须按 row/column 保存两个不同端点")
|
||
if abs(pair_key[0][0] - pair_key[1][0]) + abs(pair_key[0][1] - pair_key[1][1]) != 1:
|
||
errors.append("#/payload/pair: 交换端点必须正交相邻")
|
||
before = payload.get("boardRevisionBefore")
|
||
after = payload.get("boardRevisionAfter")
|
||
if isinstance(before, int) and isinstance(after, int) and after <= before:
|
||
errors.append("#/payload/boardRevisionAfter: 必须严格大于 boardRevisionBefore")
|
||
elif event_type == "puzzle.match-formed":
|
||
runs = payload.get("runs") or []
|
||
run_keys = []
|
||
union = set()
|
||
for index, run in enumerate(runs):
|
||
path = f"#/payload/runs/{index}/cells"
|
||
cells = run.get("cells") or []
|
||
tuples = [(cell.get("row"), cell.get("column")) for cell in cells]
|
||
if run.get("axis") == "horizontal":
|
||
if len({row for row, _ in tuples}) != 1 or tuples != sorted(tuples, key=lambda item: item[1]):
|
||
errors.append(f"{path}: horizontal run 必须同 row 且按 column 排序")
|
||
elif any(right[1] != left[1] + 1 for left, right in zip(tuples, tuples[1:])):
|
||
errors.append(f"{path}: horizontal run 必须连续")
|
||
else:
|
||
if len({column for _, column in tuples}) != 1 or tuples != sorted(tuples):
|
||
errors.append(f"{path}: vertical run 必须同 column 且按 row 排序")
|
||
elif any(right[0] != left[0] + 1 for left, right in zip(tuples, tuples[1:])):
|
||
errors.append(f"{path}: vertical run 必须连续")
|
||
union.update(tuples)
|
||
run_keys.append((0 if run.get("axis") == "horizontal" else 1, tuple(tuples)))
|
||
if run_keys != sorted(run_keys):
|
||
errors.append("#/payload/runs: 必须按 axis 与 cells 规范排序")
|
||
matched = [(cell.get("row"), cell.get("column")) for cell in payload.get("matchedCells") or []]
|
||
if matched != sorted(set(matched)):
|
||
errors.append("#/payload/matchedCells: 必须去重并按 row/column 排序")
|
||
if set(matched) != union:
|
||
errors.append("#/payload/matchedCells: 必须等于 runs 的逐格去重并集")
|
||
if payload.get("matchedCount") != len(matched):
|
||
errors.append("#/payload/matchedCount: 必须等于 matchedCells 长度")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_acceptance_profile(instance: dict) -> list:
|
||
"""验收请求和来源清单必须精确绑定 canonical profile,禁止调用方自报一套自洽别名。"""
|
||
errors = []
|
||
try:
|
||
schema_version = instance.get("schemaVersion")
|
||
# v3 仅在 v2 之上开口参照资产可选字段(W-GOLD-LIVE),proof registry 冻结版本不变。
|
||
expected_registry_version = {
|
||
"acceptance-request/1": "2026-07-14.v2",
|
||
"acceptance-provenance/1": "2026-07-14.v2",
|
||
"acceptance-request/2": "2026-07-15.v3",
|
||
"acceptance-provenance/2": "2026-07-15.v3",
|
||
"acceptance-request/3": "2026-07-15.v3",
|
||
"acceptance-provenance/3": "2026-07-15.v3",
|
||
"acceptance-provenance/4": "2026-07-15.v3",
|
||
}.get(schema_version)
|
||
if expected_registry_version and instance.get("proofRegistryVersion") != expected_registry_version:
|
||
errors.append(
|
||
f"#/proofRegistryVersion: {schema_version} 必须使用冻结版本 {expected_registry_version}",
|
||
)
|
||
registry = _load_proof_registry(instance.get("proofRegistryVersion"))
|
||
profile_id = instance.get("proofProfileId")
|
||
profile = (registry.get("profiles") or {}).get(profile_id)
|
||
if not isinstance(profile, dict):
|
||
errors.append(f"#/proofProfileId: 未知 canonical profile {profile_id}")
|
||
return errors
|
||
if instance.get("genre") != profile.get("genre"):
|
||
errors.append("#/genre: 必须与 proofProfileId 的 canonical genre 一致")
|
||
if instance.get("templateRoute") != profile.get("templateRoute"):
|
||
errors.append("#/templateRoute: 必须与 proofProfileId 的 canonical templateRoute 一致")
|
||
if instance.get("schemaVersion") in (
|
||
"acceptance-request/2", "acceptance-provenance/2",
|
||
"acceptance-request/3", "acceptance-provenance/3",
|
||
"acceptance-provenance/4",
|
||
):
|
||
binding = instance.get("interactionBinding")
|
||
if isinstance(binding, dict):
|
||
for message in _semantic_validate_interaction_binding(binding):
|
||
errors.append(f"#/interactionBinding{message[1:]}")
|
||
if binding.get("taskBindingHash") != instance.get("taskBindingHash"):
|
||
errors.append("#/interactionBinding/taskBindingHash: 必须与请求顶层任务绑定一致")
|
||
interaction_registry = _load_interaction_registry(
|
||
binding.get("interactionRegistryVersion"),
|
||
)
|
||
interaction_profile = (
|
||
(interaction_registry.get("profiles") or {}).get(
|
||
binding.get("interactionProfileId"),
|
||
) or {}
|
||
)
|
||
if interaction_profile.get("proofProfileId") != instance.get("proofProfileId"):
|
||
errors.append("#/interactionBinding/interactionProfileId: proof profile 不一致")
|
||
if interaction_profile.get("genre") != instance.get("genre"):
|
||
errors.append("#/interactionBinding/interactionProfileId: genre 不一致")
|
||
if interaction_profile.get("templateRoute") != instance.get("templateRoute"):
|
||
errors.append("#/interactionBinding/interactionProfileId: templateRoute 不一致")
|
||
except Exception as exc: # noqa: BLE001 —— canonical 注册表坏掉必须响亮失败
|
||
errors.append(f"#/proofRegistryVersion: 无法加载 canonical 注册表:{exc}")
|
||
return errors
|
||
|
||
|
||
def _json_pointer_get(document, pointer: str):
|
||
"""读取受限 JSON Pointer;缺路径返回哨兵,绝不把缺失与 null 混为一谈。"""
|
||
if pointer == "":
|
||
return document
|
||
if not isinstance(pointer, str) or not pointer.startswith("/"):
|
||
return _MISSING
|
||
node = document
|
||
for raw_part in pointer[1:].split("/"):
|
||
part = raw_part.replace("~1", "/").replace("~0", "~")
|
||
if isinstance(node, dict):
|
||
if part not in node:
|
||
return _MISSING
|
||
node = node[part]
|
||
elif isinstance(node, list) and part.isdigit():
|
||
index = int(part)
|
||
if index >= len(node):
|
||
return _MISSING
|
||
node = node[index]
|
||
else:
|
||
return _MISSING
|
||
return node
|
||
|
||
|
||
def _predicate_matches(payload: dict, predicate: dict) -> bool:
|
||
"""执行注册表白名单谓词;任何缺路径或类型不符都按不匹配 fail-closed。"""
|
||
op = predicate.get("op")
|
||
if op in ("changed", "increased"):
|
||
before = _json_pointer_get(payload, predicate.get("beforePath"))
|
||
after = _json_pointer_get(payload, predicate.get("afterPath"))
|
||
if before is _MISSING or after is _MISSING:
|
||
return False
|
||
if op == "changed":
|
||
return before != after
|
||
numeric = lambda value: isinstance(value, (int, float)) and not isinstance(value, bool)
|
||
return numeric(before) and numeric(after) and after > before
|
||
|
||
actual = _json_pointer_get(payload, predicate.get("path"))
|
||
if actual is _MISSING:
|
||
return False
|
||
if op == "exists":
|
||
return actual is not None
|
||
expected = predicate.get("value")
|
||
if op == "equals":
|
||
return _json_semantically_equal(actual, expected)
|
||
if op == "oneOf":
|
||
return (isinstance(expected, list)
|
||
and any(_json_semantically_equal(actual, item) for item in expected))
|
||
numeric = lambda value: isinstance(value, (int, float)) and not isinstance(value, bool)
|
||
if not numeric(actual) or not numeric(expected):
|
||
return False
|
||
if op == "gt":
|
||
return actual > expected
|
||
if op == "gte":
|
||
return actual >= expected
|
||
if op == "lt":
|
||
return actual < expected
|
||
if op == "lte":
|
||
return actual <= expected
|
||
return False
|
||
|
||
|
||
def _validate_canonical_step_ref(
|
||
canonical_step: dict, sequence_ref: dict, event_by_seq: dict, action_by_id: dict, path: str) -> list:
|
||
"""把一个已解析 sequenceRef 重新对照 canonical step,拒绝错 type、坏 payload 与错动作。"""
|
||
errors = []
|
||
event = event_by_seq.get(sequence_ref.get("eventRef"))
|
||
action = action_by_id.get(sequence_ref.get("actionRef"))
|
||
if not isinstance(event, dict) or not isinstance(action, dict):
|
||
return errors
|
||
expected_event = canonical_step.get("event") or {}
|
||
if event.get("type") != expected_event.get("type"):
|
||
errors.append(f"{path}/eventRef: 事件 type 与 canonical step 不一致")
|
||
payload = event.get("payload") or {}
|
||
for pidx, predicate in enumerate(expected_event.get("predicates") or []):
|
||
if not _predicate_matches(payload, predicate):
|
||
errors.append(f"{path}/eventRef: payload 未满足 canonical 谓词 {pidx}")
|
||
action_type = ((action.get("normalized") or {}).get("type"))
|
||
if action_type not in (canonical_step.get("allowedActionTypes") or []):
|
||
errors.append(f"{path}/actionRef: 动作类型 {action_type!r} 不在该步骤白名单")
|
||
return errors
|
||
|
||
|
||
def _json_scalar_token(value):
|
||
"""把 JSON scalar 变成类型敏感的稳定文本;对象、数组、null 与非有限数均不合法。"""
|
||
if value is None or isinstance(value, (dict, list)):
|
||
return None
|
||
if isinstance(value, float) and not math.isfinite(value):
|
||
return None
|
||
try:
|
||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _quantize_nonnegative_rmb(value, path: str) -> tuple[float | None, list]:
|
||
"""按跨语言统一的十进制 ROUND_HALF_UP 五位口径量化非负金额。"""
|
||
if (not isinstance(value, (int, float)) or isinstance(value, bool)
|
||
or isinstance(value, float) and not math.isfinite(value) or value < 0):
|
||
return None, [f"{path}: 必须是有限非负金额"]
|
||
try:
|
||
quantized = Decimal(str(float(value))).quantize(_RMB_QUANTUM, rounding=ROUND_HALF_UP)
|
||
except (InvalidOperation, ValueError):
|
||
return None, [f"{path}: 无法按五位金额量化"]
|
||
return float(quantized), []
|
||
|
||
|
||
def _validate_same_value(
|
||
rules: list, sequence_refs: list, event_by_seq: dict, path: str) -> list:
|
||
"""机械核对跨步骤同路径 scalar 逐字相等,防止跨升级周期或跨订单拼证据。"""
|
||
errors = []
|
||
ref_by_step = {row.get("stepId"): row for row in sequence_refs}
|
||
for ridx, rule in enumerate(rules):
|
||
rpath = f"{path}/{ridx}"
|
||
tokens = []
|
||
for step_id in rule.get("stepIds") or []:
|
||
ref = ref_by_step.get(step_id)
|
||
event = event_by_seq.get((ref or {}).get("eventRef")) or {}
|
||
value = _json_pointer_get(event.get("payload") or {}, rule.get("path"))
|
||
token = None if value is _MISSING else _json_scalar_token(value)
|
||
if token is None:
|
||
errors.append(f"{rpath}: 步骤 {step_id} 缺少可比较的 JSON scalar")
|
||
tokens.append(token)
|
||
if tokens and (None in tokens or len(set(tokens)) != 1):
|
||
errors.append(f"{rpath}: 所列步骤的值必须逐字相等")
|
||
return errors
|
||
|
||
|
||
def _validate_ordered_series(
|
||
ordered: dict, sequence_refs: list, event_by_seq: dict, action_by_id: dict, path: str) -> list:
|
||
"""重建 heritage 动态工序链,核对完整 N 步、动作独立性、汇总和成品终态。"""
|
||
errors = []
|
||
if len(sequence_refs) < 2:
|
||
return [f"{path}: orderedSeries 至少需要工序、summary 与 terminal 引用"]
|
||
series_refs = sequence_refs[:-2]
|
||
summary_ref, terminal_ref = sequence_refs[-2:]
|
||
count = len(series_refs)
|
||
minimum = ordered.get("minItems")
|
||
maximum = ordered.get("maxItems")
|
||
if not isinstance(count, int) or count < minimum or count > maximum:
|
||
errors.append(f"{path}: 工序数必须在 {minimum}..{maximum},实际 {count}")
|
||
expected_ids = [f"{ordered.get('seriesId')}:{index}" for index in range(1, count + 1)]
|
||
expected_ids += [
|
||
(ordered.get("summary") or {}).get("stepId"),
|
||
(ordered.get("terminal") or {}).get("stepId"),
|
||
]
|
||
actual_ids = [row.get("stepId") for row in sequence_refs]
|
||
if actual_ids != expected_ids:
|
||
errors.append(f"{path}: 动态 stepId 必须严格展开为 seriesId:1..N/summary/terminal")
|
||
|
||
group_tokens = []
|
||
item_ids = []
|
||
series_action_ids = []
|
||
series_frame_refs = []
|
||
for index, ref in enumerate(series_refs, start=1):
|
||
rpath = f"{path}/{index - 1}"
|
||
errors += _validate_canonical_step_ref(ordered, ref, event_by_seq, action_by_id, rpath)
|
||
event = event_by_seq.get(ref.get("eventRef")) or {}
|
||
payload = event.get("payload") or {}
|
||
group_value = _json_pointer_get(payload, ordered.get("groupByPath"))
|
||
group_tokens.append(None if group_value is _MISSING else _json_scalar_token(group_value))
|
||
actual_index = _json_pointer_get(payload, ordered.get("indexPath"))
|
||
total = _json_pointer_get(payload, ordered.get("totalPath"))
|
||
item_id = _json_pointer_get(payload, ordered.get("itemIdPath"))
|
||
judgement = _json_pointer_get(payload, ordered.get("judgementPath"))
|
||
if actual_index != index or isinstance(actual_index, bool):
|
||
errors.append(f"{rpath}/eventRef: stepIndex 必须严格等于 {index}")
|
||
if total != count or isinstance(total, bool):
|
||
errors.append(f"{rpath}/eventRef: totalSteps 必须等于完整工序数 {count}")
|
||
if not isinstance(item_id, str) or not item_id.strip():
|
||
errors.append(f"{rpath}/eventRef: stepId 必须是非空字符串")
|
||
item_ids.append(item_id)
|
||
if judgement is _MISSING or judgement is None or (
|
||
isinstance(judgement, str) and not judgement.strip()):
|
||
errors.append(f"{rpath}/eventRef: judgement 必须非空")
|
||
series_action_ids.append(ref.get("actionRef"))
|
||
series_frame_refs.append(ref.get("postFrameRef"))
|
||
if group_tokens and (None in group_tokens or len(set(group_tokens)) != 1):
|
||
errors.append(f"{path}: 所有工序必须属于同一非空 workId")
|
||
if len(item_ids) != len(set(item_ids)):
|
||
errors.append(f"{path}: 工序 stepId 必须唯一")
|
||
if len(series_action_ids) != len(set(series_action_ids)):
|
||
errors.append(f"{path}: 每道工序必须使用两两不同的 actionId")
|
||
if len(series_frame_refs) != len(set(series_frame_refs)):
|
||
errors.append(f"{path}: 每道工序必须使用两两不同的动作后帧")
|
||
|
||
summary = ordered.get("summary") or {}
|
||
terminal = ordered.get("terminal") or {}
|
||
errors += _validate_canonical_step_ref(
|
||
summary, summary_ref, event_by_seq, action_by_id, f"{path}/{len(series_refs)}")
|
||
errors += _validate_canonical_step_ref(
|
||
terminal, terminal_ref, event_by_seq, action_by_id, f"{path}/{len(series_refs) + 1}")
|
||
last_action = series_action_ids[-1] if series_action_ids else None
|
||
last_frame = series_frame_refs[-1] if series_frame_refs else None
|
||
for label, ref in (("summary", summary_ref), ("terminal", terminal_ref)):
|
||
if ref.get("actionRef") in set(series_action_ids[:-1]):
|
||
errors.append(f"{path}/{label}: 只允许与最后一道工序共享 actionId")
|
||
if ref.get("postFrameRef") in set(series_frame_refs[:-1]):
|
||
errors.append(f"{path}/{label}: 只允许与最后一道工序共享 post frame")
|
||
if ref.get("actionRef") == last_action and ref.get("postFrameRef") != last_frame:
|
||
errors.append(f"{path}/{label}: 共享最后动作时必须同时共享其动作后帧")
|
||
|
||
summary_payload = (event_by_seq.get(summary_ref.get("eventRef")) or {}).get("payload") or {}
|
||
terminal_payload = (event_by_seq.get(terminal_ref.get("eventRef")) or {}).get("payload") or {}
|
||
group_token = group_tokens[0] if group_tokens else None
|
||
for label, holder, payload in (
|
||
("summary", summary, summary_payload), ("terminal", terminal, terminal_payload)):
|
||
value = _json_pointer_get(payload, holder.get("groupByPath"))
|
||
if _json_scalar_token(value) != group_token:
|
||
errors.append(f"{path}/{label}: workId 必须与工序链一致")
|
||
if _json_pointer_get(summary_payload, summary.get("totalPath")) != count:
|
||
errors.append(f"{path}/summary: totalSteps 必须等于工序数")
|
||
if _json_pointer_get(summary_payload, summary.get("orderedItemIdsPath")) != item_ids:
|
||
errors.append(f"{path}/summary: orderedStepIds 必须与工序 stepId 逐项一致")
|
||
if _json_pointer_get(summary_payload, summary.get("completedPath")) is not True:
|
||
errors.append(f"{path}/summary: allStepsCompleted 必须为 true")
|
||
score = _json_pointer_get(terminal_payload, terminal.get("scorePath"))
|
||
works = _json_pointer_get(terminal_payload, terminal.get("worksPath"))
|
||
if not isinstance(score, (int, float)) or isinstance(score, bool) or not math.isfinite(score):
|
||
errors.append(f"{path}/terminal: score 必须是有限数")
|
||
if not isinstance(works, (int, float)) or isinstance(works, bool) or not math.isfinite(works) or works <= 0:
|
||
errors.append(f"{path}/terminal: works 必须大于零")
|
||
return errors
|
||
|
||
|
||
def _all_checks_true(guard: dict | None) -> bool:
|
||
"""判断 guard 的机械检查是否全部为真;缺字段一律视为不通过。"""
|
||
checks = (guard or {}).get("checks") or {}
|
||
return bool(checks) and all(value is True for value in checks.values())
|
||
|
||
|
||
def _semantic_validate_environment(instance: dict, *, wait_max_ms: int = 3000) -> list:
|
||
"""环境必须逐字对应 runner 固定口径与本次顶层 artifact。"""
|
||
errors = []
|
||
environment = instance.get("environment") or {}
|
||
if environment.get("buildRef") != instance.get("artifactHash"):
|
||
errors.append("#/environment/buildRef: 必须严格等于顶层 artifactHash")
|
||
viewport = environment.get("viewport") or {}
|
||
if viewport != {"width": 390, "height": 844, "dpr": 1}:
|
||
errors.append("#/environment/viewport: 必须严格等于 390x844@1")
|
||
virtual_time = environment.get("virtualTime") or {}
|
||
expected_virtual_time = {
|
||
"mode": "cdp", "actionQuantumMs": 600, "waitMinMs": 100, "waitMaxMs": wait_max_ms,
|
||
}
|
||
for key, expected in expected_virtual_time.items():
|
||
if virtual_time.get(key) != expected:
|
||
errors.append(f"#/environment/virtualTime/{key}: 必须严格等于 {expected!r}")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_playtest(instance: dict, *, wait_max_ms: int = 3000) -> list:
|
||
"""校验共享事实链;调用方显式提供其协议代际的等待上限。"""
|
||
errors = []
|
||
errors += _semantic_validate_environment(instance, wait_max_ms=wait_max_ms)
|
||
phase = instance.get("phase")
|
||
decided = phase == "DECIDED"
|
||
mode = instance.get("acceptanceMode")
|
||
evidence_mode = instance.get("evidenceMode")
|
||
if mode == "historical_replay" and evidence_mode != "legacy-adapted":
|
||
errors.append("#/evidenceMode: historical_replay 必须使用 legacy-adapted")
|
||
if mode in ("v3", "v3_shadow") and evidence_mode != "native":
|
||
errors.append("#/evidenceMode: 新生产 v3/v3_shadow 必须使用 native")
|
||
terminal_fields = ("outcome", "finalPostguard", "decision", "compatibility")
|
||
if decided:
|
||
for field in terminal_fields:
|
||
if field not in instance:
|
||
errors.append(f"#: DECIDED 必须带 {field}")
|
||
else:
|
||
for field in terminal_fields:
|
||
if field in instance:
|
||
errors.append(f"#/{field}: 非 DECIDED 阶段不得提前写终裁字段")
|
||
|
||
floor = instance.get("floor") or {}
|
||
gate_results = (floor.get("gates") or {}).values()
|
||
if gate_results and floor.get("pass") != all(g.get("pass") is True for g in gate_results):
|
||
errors.append("#/floor/pass: 必须等于 A/B/C/D 四门 AND")
|
||
|
||
actor = instance.get("actor")
|
||
judge = instance.get("judge")
|
||
if actor:
|
||
if actor.get("requestedGameSeed") != actor.get("actualGameSeed"):
|
||
errors.append("#/actor: requestedGameSeed 与 actualGameSeed 不一致")
|
||
if actor and judge:
|
||
for field in ("apiClientId", "sessionId", "systemPromptVersion",
|
||
"contextProjectionVersion", "evidenceDir"):
|
||
if actor.get(field) == judge.get(field):
|
||
errors.append(f"#/judge/{field}: Actor 与 Judge 必须物理隔离,不能共用 {field}")
|
||
|
||
actions = instance.get("actions") or []
|
||
action_by_id = {}
|
||
post_frame_paths = set()
|
||
for idx, action in enumerate(actions):
|
||
aid = action.get("actionId")
|
||
if aid in action_by_id:
|
||
errors.append(f"#/actions/{idx}/actionId: actionId 重复:{aid}")
|
||
action_by_id[aid] = action
|
||
post_ref = action.get("postFrameRef") or {}
|
||
if post_ref.get("path"):
|
||
post_frame_paths.add(post_ref["path"])
|
||
|
||
valid = action.get("valid") is True
|
||
normalized = action.get("normalized")
|
||
dispatch = action.get("dispatchReceipt")
|
||
budget = action.get("virtualTimeBudgetMs")
|
||
time_advance = action.get("timeAdvance")
|
||
if valid:
|
||
if normalized is None or dispatch is None:
|
||
errors.append(f"#/actions/{idx}: 合法动作必须有 normalized 与 dispatchReceipt")
|
||
if normalized and normalized.get("type") in ("tap", "key", "drag") and budget != 600:
|
||
errors.append(f"#/actions/{idx}/virtualTimeBudgetMs: tap/key/drag 必须精确推进 600ms")
|
||
if normalized and normalized.get("type") == "wait" and budget != normalized.get("ms"):
|
||
errors.append(f"#/actions/{idx}/virtualTimeBudgetMs: wait 必须按请求值推进")
|
||
if not isinstance(time_advance, dict):
|
||
errors.append(f"#/actions/{idx}/timeAdvance: 合法动作必须封存推进事实")
|
||
else:
|
||
if time_advance.get("ok") is not True:
|
||
errors.append(f"#/actions/{idx}/timeAdvance/ok: 合法动作必须闭合")
|
||
if time_advance.get("requestedBudgetMs") != budget:
|
||
errors.append(f"#/actions/{idx}/timeAdvance/requestedBudgetMs: 必须等于动作预算")
|
||
if time_advance.get("plannedFrames") != time_advance.get("executedFrames"):
|
||
errors.append(f"#/actions/{idx}/timeAdvance: plannedFrames 必须等于 executedFrames")
|
||
else:
|
||
pre = action.get("preFrameRef") or {}
|
||
post = action.get("postFrameRef") or {}
|
||
if dispatch is not None or budget != 0:
|
||
errors.append(f"#/actions/{idx}: 非法动作不得派发或推进虚拟时间")
|
||
if time_advance is not None:
|
||
errors.append(f"#/actions/{idx}/timeAdvance: 非法动作必须为 null")
|
||
if pre.get("hash") != post.get("hash") or action.get("preEventSeq") != action.get("postEventSeq"):
|
||
errors.append(f"#/actions/{idx}: 非法动作重试期间帧和事件游标必须冻结")
|
||
|
||
events = instance.get("events") or []
|
||
event_by_seq = {}
|
||
last_seq = 0
|
||
for idx, event in enumerate(events):
|
||
seq = event.get("seq")
|
||
if seq in event_by_seq:
|
||
errors.append(f"#/events/{idx}/seq: 事件 seq 重复:{seq}")
|
||
if isinstance(seq, int) and seq <= last_seq:
|
||
errors.append(f"#/events/{idx}/seq: 事件必须按 seq 严格递增")
|
||
if isinstance(seq, int):
|
||
last_seq = seq
|
||
event_by_seq[seq] = event
|
||
for message in _semantic_validate_game_event(event):
|
||
errors.append(f"#/events/{idx}{message[1:]}")
|
||
if event.get("evidenceMode") != evidence_mode:
|
||
errors.append(f"#/events/{idx}/evidenceMode: 必须与顶层 evidenceMode 一致")
|
||
aid = event.get("actionId")
|
||
action = action_by_id.get(aid)
|
||
if action is None:
|
||
errors.append(f"#/events/{idx}/actionId: 引用了不存在的动作 {aid}")
|
||
elif not (action.get("preEventSeq", -1) < seq <= action.get("postEventSeq", -1)):
|
||
errors.append(f"#/events/{idx}: 事件 seq 不在动作 {aid} 的 (preSeq, postSeq] 区间")
|
||
|
||
obligations = instance.get("proofObligations") or []
|
||
obligation_by_id = {}
|
||
for idx, obligation in enumerate(obligations):
|
||
oid = obligation.get("id")
|
||
if oid in obligation_by_id:
|
||
errors.append(f"#/proofObligations/{idx}/id: 义务 id 重复:{oid}")
|
||
obligation_by_id[oid] = obligation
|
||
sequence_refs = obligation.get("sequenceRefs") or []
|
||
action_refs = obligation.get("actionRefs") or []
|
||
frame_refs = obligation.get("postFrameRefs") or []
|
||
event_refs = obligation.get("eventRefs") or []
|
||
projected_actions = []
|
||
projected_frames = []
|
||
projected_events = []
|
||
last_event_ref = 0
|
||
seen_steps = set()
|
||
for sidx, sequence_ref in enumerate(sequence_refs):
|
||
spath = f"#/proofObligations/{idx}/sequenceRefs/{sidx}"
|
||
step_id = sequence_ref.get("stepId")
|
||
event_ref = sequence_ref.get("eventRef")
|
||
action_ref = sequence_ref.get("actionRef")
|
||
frame_ref = sequence_ref.get("postFrameRef")
|
||
if step_id in seen_steps:
|
||
errors.append(f"{spath}/stepId: 同一义务步骤不得重复")
|
||
seen_steps.add(step_id)
|
||
if isinstance(event_ref, int) and event_ref <= last_event_ref:
|
||
errors.append(f"{spath}/eventRef: sequenceRefs 必须严格递增")
|
||
if isinstance(event_ref, int):
|
||
last_event_ref = event_ref
|
||
event = event_by_seq.get(event_ref)
|
||
action = action_by_id.get(action_ref)
|
||
if event is None:
|
||
errors.append(f"{spath}/eventRef: 引用了不存在的事件 {event_ref}")
|
||
elif event.get("actionId") != action_ref:
|
||
errors.append(f"{spath}: 事件与 actionRef 不在同一动作区间")
|
||
if action is None:
|
||
errors.append(f"{spath}/actionRef: 引用了不存在的动作 {action_ref}")
|
||
elif (action.get("postFrameRef") or {}).get("path") != frame_ref:
|
||
errors.append(f"{spath}/postFrameRef: 必须是 actionRef 的动作后帧")
|
||
if action_ref not in projected_actions:
|
||
projected_actions.append(action_ref)
|
||
if frame_ref not in projected_frames:
|
||
projected_frames.append(frame_ref)
|
||
if event_ref not in projected_events:
|
||
projected_events.append(event_ref)
|
||
if action_refs != projected_actions:
|
||
errors.append(f"#/proofObligations/{idx}/actionRefs: 必须由 sequenceRefs 确定性投影")
|
||
if frame_refs != projected_frames:
|
||
errors.append(f"#/proofObligations/{idx}/postFrameRefs: 必须由 sequenceRefs 确定性投影")
|
||
if event_refs != projected_events:
|
||
errors.append(f"#/proofObligations/{idx}/eventRefs: 必须由 sequenceRefs 确定性投影")
|
||
for aid in action_refs:
|
||
if aid not in action_by_id:
|
||
errors.append(f"#/proofObligations/{idx}/actionRefs: 引用了不存在的动作 {aid}")
|
||
for frame in frame_refs:
|
||
if frame not in post_frame_paths:
|
||
errors.append(f"#/proofObligations/{idx}/postFrameRefs: 引用了不存在的动作后帧 {frame}")
|
||
for seq in event_refs:
|
||
event = event_by_seq.get(seq)
|
||
if event is None:
|
||
errors.append(f"#/proofObligations/{idx}/eventRefs: 引用了不存在的事件 {seq}")
|
||
continue
|
||
if event.get("actionId") not in action_refs:
|
||
errors.append(f"#/proofObligations/{idx}: 事件 {seq} 与 actionRefs 不在同一动作区间")
|
||
action = action_by_id.get(event.get("actionId")) or {}
|
||
if (action.get("postFrameRef") or {}).get("path") not in frame_refs:
|
||
errors.append(f"#/proofObligations/{idx}: 事件 {seq} 对应动作的 post frame 未被引用")
|
||
if obligation.get("status") == "satisfied" and not sequence_refs:
|
||
errors.append(f"#/proofObligations/{idx}: satisfied 义务必须有权威 sequenceRefs")
|
||
|
||
# resolved obligation 必须能从固定版本注册表重建,防 runner 或模型临时删减必需义务。
|
||
try:
|
||
registry = _load_proof_registry(instance.get("proofRegistryVersion"))
|
||
profile_id = instance.get("proofProfileId")
|
||
profile = ((registry.get("profiles") or {}).get(profile_id) or {})
|
||
if not profile:
|
||
errors.append(f"#/proofProfileId: canonical 注册表不存在 profile {profile_id}")
|
||
elif profile.get("genre") != instance.get("genre"):
|
||
errors.append("#/proofProfileId: profile 品类与顶层 genre 不一致")
|
||
elif profile.get("templateRoute") != instance.get("templateRoute"):
|
||
errors.append("#/templateRoute: 必须与 proofProfileId 的 canonical templateRoute 一致")
|
||
registered = {o.get("id"): o for o in profile.get("obligations") or []}
|
||
allowed_event_types = _registry_event_types(registry, profile_id)
|
||
for eidx, event in enumerate(events):
|
||
if event.get("type") not in allowed_event_types:
|
||
errors.append(f"#/events/{eidx}/type: 事件不在当前 proof profile 白名单")
|
||
expected_required = {oid for oid, obj in registered.items() if obj.get("defaultRequired") is True}
|
||
rule_by_id = {r.get("id"): r for r in profile.get("briefOptionalRules") or []}
|
||
seen_rule_matches = set()
|
||
for rid in instance.get("briefRuleMatches") or []:
|
||
if rid in seen_rule_matches:
|
||
errors.append(f"#/briefRuleMatches: brief 规则重复:{rid}")
|
||
seen_rule_matches.add(rid)
|
||
rule = rule_by_id.get(rid)
|
||
if rule is None:
|
||
errors.append(f"#/briefRuleMatches: 引用了本品类不存在的 brief 规则 {rid}")
|
||
else:
|
||
expected_required.update(rule.get("requireObligationIds") or [])
|
||
for oid in obligation_by_id:
|
||
if oid not in registered:
|
||
errors.append(f"#/proofObligations: 出现注册表不存在的义务 {oid}")
|
||
for oid, resolved in obligation_by_id.items():
|
||
canonical = registered.get(oid) or {}
|
||
evidence = canonical.get("evidence") or {}
|
||
resolved_refs = resolved.get("sequenceRefs") or []
|
||
canonical_sequence = evidence.get("sequence") or []
|
||
ordered = evidence.get("orderedSeries")
|
||
if isinstance(ordered, dict):
|
||
if resolved.get("status") in ("satisfied", "failed"):
|
||
errors += _validate_ordered_series(
|
||
ordered, resolved_refs, event_by_seq, action_by_id,
|
||
f"#/proofObligations/{oid}/sequenceRefs",
|
||
)
|
||
else:
|
||
expected_steps = [row.get("stepId") for row in canonical_sequence]
|
||
actual_steps = [row.get("stepId") for row in resolved_refs]
|
||
if resolved.get("status") in ("satisfied", "failed") and actual_steps != expected_steps:
|
||
errors.append(f"#/proofObligations/{oid}/sequenceRefs: 必须完整保持 canonical step 顺序")
|
||
for sidx, (sequence_ref, canonical_step) in enumerate(zip(
|
||
resolved_refs, canonical_sequence)):
|
||
errors += _validate_canonical_step_ref(
|
||
canonical_step, sequence_ref, event_by_seq, action_by_id,
|
||
f"#/proofObligations/{oid}/sequenceRefs/{sidx}",
|
||
)
|
||
if resolved_refs:
|
||
errors += _validate_same_value(
|
||
evidence.get("sameValue") or [], resolved_refs, event_by_seq,
|
||
f"#/proofObligations/{oid}/sameValue",
|
||
)
|
||
# 四门失败时不会进入 Actor 取证,允许义务数组为空;一旦四门通过,resolved 义务必须完整。
|
||
if floor.get("pass") is True:
|
||
for oid in expected_required:
|
||
resolved = obligation_by_id.get(oid)
|
||
if resolved is None:
|
||
errors.append(f"#/proofObligations: 缺少注册表要求的义务 {oid}")
|
||
elif resolved.get("required") is not True:
|
||
errors.append(f"#/proofObligations: 注册表要求的义务 {oid} 必须标 required=true")
|
||
except Exception as exc: # noqa: BLE001 —— canonical 注册表坏掉属于契约校验器自身响亮失败
|
||
errors.append(f"#/proofRegistryVersion: 无法加载 canonical 注册表:{exc}")
|
||
|
||
first_play = instance.get("firstPlay") or {}
|
||
for oid in first_play.get("proofObligationRefs") or []:
|
||
if oid not in obligation_by_id:
|
||
errors.append(f"#/firstPlay/proofObligationRefs: 引用了不存在的义务 {oid}")
|
||
first_feedback_at = first_play.get("firstFeedbackAtVirtualMs")
|
||
if first_feedback_at is not None:
|
||
direct_input_events = [
|
||
event for event in events
|
||
if event.get("virtualTimeMs") == first_feedback_at
|
||
and ((action_by_id.get(event.get("actionId")) or {}).get("normalized") or {}).get("type")
|
||
in ("tap", "key", "drag")
|
||
]
|
||
if not direct_input_events:
|
||
errors.append("#/firstPlay/firstFeedbackAtVirtualMs: 必须对应 tap/key/drag 的因果事件,wait 不得计入")
|
||
|
||
rolls = instance.get("rolls") or []
|
||
roll_by_id = {}
|
||
seen_actor_sessions = set()
|
||
for idx, roll in enumerate(rolls):
|
||
rid = roll.get("rollId")
|
||
if rid in roll_by_id:
|
||
errors.append(f"#/rolls/{idx}/rollId: rollId 重复:{rid}")
|
||
roll_by_id[rid] = roll
|
||
if roll.get("requestedGameSeed") != roll.get("actualGameSeed"):
|
||
errors.append(f"#/rolls/{idx}: requestedGameSeed 与 actualGameSeed 不一致")
|
||
actor_session = roll.get("actorSessionId")
|
||
if actor_session in seen_actor_sessions:
|
||
errors.append(f"#/rolls/{idx}/actorSessionId: 两掷不得共用 Actor session")
|
||
seen_actor_sessions.add(actor_session)
|
||
guard = roll.get("rollGuard") or {}
|
||
roll_accept = roll.get("rollOutcome") == "accept"
|
||
if guard.get("pass") != roll_accept:
|
||
errors.append(f"#/rolls/{idx}: rollGuard.pass 必须与 rollOutcome=accept 等价")
|
||
if roll_accept and not _all_checks_true(guard):
|
||
errors.append(f"#/rolls/{idx}: accept 的 rollGuard 检查必须全真")
|
||
|
||
merge = instance.get("merge")
|
||
if merge:
|
||
for rid in merge.get("rollRefs") or []:
|
||
if rid not in roll_by_id:
|
||
errors.append(f"#/merge/rollRefs: 引用了不存在的 roll {rid}")
|
||
|
||
if isinstance(judge, dict):
|
||
seen_judge_ids = set()
|
||
for idx, row in enumerate(judge.get("obligationResults") or []):
|
||
oid = row.get("id")
|
||
if oid in seen_judge_ids:
|
||
errors.append(f"#/judge/obligationResults/{idx}/id: Judge 义务结果重复")
|
||
seen_judge_ids.add(oid)
|
||
canonical = obligation_by_id.get(oid)
|
||
if canonical is None:
|
||
errors.append(f"#/judge/obligationResults/{idx}/id: Judge 引用了未知义务 {oid}")
|
||
elif row.get("sequenceRefs") != canonical.get("sequenceRefs"):
|
||
errors.append(f"#/judge/obligationResults/{idx}/sequenceRefs: 必须原样引用候选序列")
|
||
|
||
if not decided:
|
||
return errors
|
||
|
||
outcome = instance.get("outcome")
|
||
decision = instance.get("decision") or {}
|
||
final_guard = instance.get("finalPostguard") or {}
|
||
compatibility = instance.get("compatibility") or {}
|
||
repair = instance.get("repair") or {}
|
||
failure = decision.get("failure") or {}
|
||
playtest = compatibility.get("playtest") or {}
|
||
judge_projection = compatibility.get("judge") or {}
|
||
|
||
if decision.get("outcome") != outcome:
|
||
errors.append("#/decision/outcome: 必须与顶层 outcome 一致")
|
||
accepted = outcome == "accept"
|
||
if decision.get("accepted") != accepted:
|
||
errors.append("#/decision/accepted: 必须严格等于 outcome == accept")
|
||
expected_frozen = mode in ("v3_shadow", "historical_replay") or not accepted
|
||
if decision.get("publishFrozen") != expected_frozen:
|
||
errors.append("#/decision/publishFrozen: 必须等于 v3_shadow 或 outcome!=accept")
|
||
expected_shadow_accepted = accepted if mode == "v3_shadow" else None
|
||
if decision.get("shadowAccepted") != expected_shadow_accepted:
|
||
errors.append("#/decision/shadowAccepted: shadow 时镜像 decision.accepted,active v3 时必须为 null")
|
||
if final_guard.get("pass") != accepted:
|
||
errors.append("#/finalPostguard/pass: 必须严格等于最终 accept")
|
||
if merge and final_guard.get("mergeCandidate") != merge.get("mergeCandidate"):
|
||
errors.append("#/finalPostguard/mergeCandidate: 必须与 merge.mergeCandidate 一致")
|
||
# merge 只产候选,finalPostguard 可以把候选降级为非 accept(如成本硬帽/最终复核失败),但绝不能反向升级为 accept。
|
||
if accepted and merge and merge.get("mergeCandidate") != "accept":
|
||
errors.append("#/outcome: finalPostguard 不得把非 accept mergeCandidate 升级为 accept")
|
||
|
||
top_cost = instance.get("costRmb")
|
||
roll_cost = sum(float(roll.get("costRmb") or 0.0) for roll in rolls)
|
||
if abs(float(top_cost or 0.0) - roll_cost) > 1e-9:
|
||
errors.append("#/costRmb: 必须等于 rolls[].costRmb 之和")
|
||
if decision.get("costRmb") != top_cost:
|
||
errors.append("#/decision/costRmb: 必须与顶层 costRmb 一致")
|
||
if float(decision.get("parentChainCostRmb") or 0.0) + 1e-9 < float(top_cost or 0.0):
|
||
errors.append("#/decision/parentChainCostRmb: 不得小于当前 run costRmb")
|
||
if decision.get("rescuedByRoll") != ((merge or {}).get("rescuedByRoll")):
|
||
errors.append("#/decision/rescuedByRoll: 必须与 merge.rescuedByRoll 一致")
|
||
|
||
if decision.get("repairCountAcrossParentChain") != repair.get("countAcrossParentChain"):
|
||
errors.append("#/decision/repairCountAcrossParentChain: 必须与 repair.countAcrossParentChain 一致")
|
||
if decision.get("repairEligible") != repair.get("eligible"):
|
||
errors.append("#/decision/repairEligible: 必须与 repair.eligible 一致")
|
||
expected_attempted = repair.get("countAcrossParentChain", 0) > 0
|
||
if repair.get("attempted") != expected_attempted:
|
||
errors.append("#/repair/attempted: 必须等价于 parent 链已发生过一次 repair")
|
||
if decision.get("repairEligible"):
|
||
if outcome != "reject" or decision.get("repairCountAcrossParentChain") != 0:
|
||
errors.append("#/decision/repairEligible: 仅首次 verified reject 可为 true")
|
||
if not decision.get("repairFeedback") or repair.get("reason") != decision.get("repairFeedback"):
|
||
errors.append("#/repair/reason: eligible 时必须逐字镜像 decision.repairFeedback")
|
||
elif decision.get("repairFeedback") is not None:
|
||
errors.append("#/decision/repairFeedback: repairEligible=false 时必须为 null")
|
||
|
||
expected_layer = "none" if accepted else (
|
||
"tester_degraded" if outcome in ("inconclusive", "tester_error")
|
||
else ("mechanical" if floor.get("pass") is not True else "gameplay")
|
||
)
|
||
if failure.get("layer") != expected_layer:
|
||
errors.append("#/decision/failure/layer: 与 outcome/floor 的权威投影不一致")
|
||
if accepted and (failure.get("subtype") is not None or failure.get("reason") is not None):
|
||
errors.append("#/decision/failure: accept 的 subtype/reason 必须为 null")
|
||
if not accepted and not failure.get("reason"):
|
||
errors.append("#/decision/failure/reason: 非 accept 必须保留权威事实原因")
|
||
|
||
compatibility_accepted = accepted and mode == "v3"
|
||
for field in ("accepted", "ok"):
|
||
if compatibility.get(field) != compatibility_accepted:
|
||
errors.append(f"#/compatibility/{field}: 仅 active v3 accept 可为 true,shadow 必须冻结")
|
||
if compatibility.get("acceptanceVersion") != mode:
|
||
errors.append("#/compatibility/acceptanceVersion: 必须与顶层 acceptanceMode 一致")
|
||
if compatibility.get("publishFrozen") != expected_frozen:
|
||
errors.append("#/compatibility/publishFrozen: 必须与 decision.publishFrozen 一致")
|
||
failure_projection = compatibility.get("failureLayer") or {}
|
||
if failure_projection.get("layer") != failure.get("layer"):
|
||
errors.append("#/compatibility/failureLayer/layer: 必须与 decision.failure.layer 一致")
|
||
if failure_projection.get("reason") != failure.get("reason"):
|
||
errors.append("#/compatibility/failureLayer/reason: 必须与 decision.failure.reason 一致")
|
||
if compatibility.get("failureReason") != failure.get("reason"):
|
||
errors.append("#/compatibility/failureReason: 必须与 decision.failure.reason 一致")
|
||
failed_gates = [name for name, result in (floor.get("gates") or {}).items()
|
||
if result.get("pass") is not True]
|
||
expected_failed_gates = failed_gates if failure.get("layer") == "mechanical" else []
|
||
if failure_projection.get("failedGates") != expected_failed_gates:
|
||
errors.append("#/compatibility/failureLayer/failedGates: 必须精确投影机械失败门")
|
||
|
||
if playtest.get("outcome") != outcome or playtest.get("accepted") != accepted:
|
||
errors.append("#/compatibility/playtest: outcome/accepted 必须由 decision 单向投影")
|
||
if playtest.get("rollCount") != len(rolls):
|
||
errors.append("#/compatibility/playtest/rollCount: 必须等于 rolls 数量")
|
||
if playtest.get("rescuedByRoll") != ((merge or {}).get("rescuedByRoll")):
|
||
errors.append("#/compatibility/playtest/rescuedByRoll: 必须与 merge 一致")
|
||
if playtest.get("costRmb") != top_cost:
|
||
errors.append("#/compatibility/playtest/costRmb: 必须与顶层 costRmb 一致")
|
||
compatibility_first_play = playtest.get("firstPlay") or {}
|
||
expected_first_play = {
|
||
"playableAtMs": first_play.get("interactiveAtVirtualMs"),
|
||
"firstFeedbackMs": first_play.get("firstFeedbackAtVirtualMs"),
|
||
"loopClosed": first_play.get("loopClosed"),
|
||
"proofRefs": first_play.get("proofObligationRefs") or [],
|
||
}
|
||
if accepted and compatibility_first_play != expected_first_play:
|
||
errors.append("#/compatibility/playtest/firstPlay: 必须由顶层 firstPlay 逐字段投影")
|
||
for oid in compatibility_first_play.get("proofRefs") or []:
|
||
if oid not in obligation_by_id:
|
||
errors.append(f"#/compatibility/playtest/firstPlay/proofRefs: 引用了不存在的义务 {oid}")
|
||
evidence_refs = playtest.get("evidenceRefs") or {}
|
||
for aid in evidence_refs.get("actions") or []:
|
||
if aid not in action_by_id:
|
||
errors.append(f"#/compatibility/playtest/evidenceRefs/actions: 引用了不存在的动作 {aid}")
|
||
for frame in evidence_refs.get("frames") or []:
|
||
if frame not in post_frame_paths:
|
||
errors.append(f"#/compatibility/playtest/evidenceRefs/frames: 引用了不存在的动作后帧 {frame}")
|
||
for seq_text in evidence_refs.get("events") or []:
|
||
if int(seq_text) not in event_by_seq:
|
||
errors.append(f"#/compatibility/playtest/evidenceRefs/events: 引用了不存在的事件 {seq_text}")
|
||
|
||
expected_judge_projection = {
|
||
"ran": bool(rolls),
|
||
"verdict": outcome,
|
||
"accepted": accepted,
|
||
"degraded": outcome == "tester_error",
|
||
"reason": failure.get("reason"),
|
||
"acceptanceVersion": mode,
|
||
"schemaVersion": "playtest/2",
|
||
}
|
||
if judge_projection != expected_judge_projection:
|
||
errors.append("#/compatibility/judge: 必须是独立 Judge/终裁的固定轻量投影")
|
||
trace_projection = compatibility.get("trace") or {}
|
||
if trace_projection.get("playtest") != playtest:
|
||
errors.append("#/compatibility/trace/playtest: 必须逐字镜像 compatibility.playtest")
|
||
if trace_projection.get("gameplayJudge") != judge_projection:
|
||
errors.append("#/compatibility/trace/gameplayJudge: 必须逐字镜像 compatibility.judge")
|
||
|
||
if accepted:
|
||
if floor.get("pass") is not True:
|
||
errors.append("#/floor/pass: accept 必须四门全绿")
|
||
required = [o for o in obligations if o.get("required") is True]
|
||
if not required or any(o.get("status") != "satisfied" for o in required):
|
||
errors.append("#/proofObligations: accept 的全部 required 义务必须 satisfied")
|
||
if any(o.get("contradictions") or o.get("blockingProblems") for o in obligations):
|
||
errors.append("#/proofObligations: accept 不能与矛盾或阻塞问题共存")
|
||
if judge is None or judge.get("decision") != "accept" or judge.get("degraded") is True:
|
||
errors.append("#/judge: accept 必须有健康且建议 accept 的独立 Judge")
|
||
if (judge or {}).get("contradictions") or (judge or {}).get("blockingProblems"):
|
||
errors.append("#/judge: accept 不能与 Judge 矛盾或阻塞问题共存")
|
||
if final_guard.get("contradictions") or final_guard.get("blockingProblems"):
|
||
errors.append("#/finalPostguard: accept 不能与矛盾或阻塞问题共存")
|
||
if merge and merge.get("conflicts"):
|
||
errors.append("#/merge/conflicts: accept 不能与两掷冲突共存")
|
||
if not _all_checks_true(final_guard):
|
||
errors.append("#/finalPostguard/checks: accept 的最终机械检查必须全真")
|
||
if first_play.get("loopClosed") is not True or not first_play.get("proofObligationRefs"):
|
||
errors.append("#/firstPlay: accept 必须以 proof obligation 引用证明 loopClosed")
|
||
if playtest.get("proofComplete") is not True:
|
||
errors.append("#/compatibility/playtest/proofComplete: accept 必须为 true")
|
||
if any(not evidence_refs.get(kind) for kind in ("actions", "frames", "events")):
|
||
errors.append("#/compatibility/playtest/evidenceRefs: accept 必须保留动作、画面、事件三类引用")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_single_roll(instance: dict) -> list:
|
||
"""校验 Node runner 输出的单掷事实,并复用 playtest/2 的证据链不变量。"""
|
||
errors = _semantic_validate_environment(instance)
|
||
evidence_mode = instance.get("evidenceMode")
|
||
evidence_view = {
|
||
"phase": "COLLECTING",
|
||
"acceptanceMode": "historical_replay" if evidence_mode == "legacy-adapted" else "v3_shadow",
|
||
"evidenceMode": evidence_mode,
|
||
"genre": instance.get("genre"),
|
||
"templateRoute": instance.get("templateRoute"),
|
||
"proofProfileId": instance.get("proofProfileId"),
|
||
"proofRegistryVersion": instance.get("proofRegistryVersion"),
|
||
"acceptanceRequestHash": instance.get("acceptanceRequestHash"),
|
||
"artifactHash": instance.get("artifactHash"),
|
||
"environment": instance.get("environment"),
|
||
"floor": {"pass": True, "gates": {}},
|
||
"actor": instance.get("actor"),
|
||
"judge": instance.get("judge"),
|
||
"actions": instance.get("actions") or [],
|
||
"events": instance.get("events") or [],
|
||
"briefRuleMatches": instance.get("briefRuleMatches") or [],
|
||
"proofObligations": instance.get("proofObligations") or [],
|
||
"firstPlay": instance.get("firstPlay") or {},
|
||
"rolls": [],
|
||
}
|
||
errors += _semantic_validate_playtest(evidence_view)
|
||
|
||
if instance.get("requestedSeed") != instance.get("actualSeed"):
|
||
errors.append("#/actualSeed: 必须等于 requestedSeed")
|
||
actor = instance.get("actor") or {}
|
||
seed_pairs = (
|
||
("requestedGameSeed", "requestedSeed"),
|
||
("actualGameSeed", "actualSeed"),
|
||
("policySeed", "policySeed"),
|
||
)
|
||
for actor_field, top_field in seed_pairs:
|
||
if actor.get(actor_field) != instance.get(top_field):
|
||
errors.append(f"#/actor/{actor_field}: 必须与顶层 {top_field} 一致")
|
||
|
||
action_by_id = {row.get("actionId"): row for row in instance.get("actions") or []}
|
||
frame_by_ref = {}
|
||
for idx, frame in enumerate(instance.get("frames") or []):
|
||
ref = frame.get("ref")
|
||
if ref in frame_by_ref:
|
||
errors.append(f"#/frames/{idx}/ref: frame ref 重复:{ref}")
|
||
frame_by_ref[ref] = frame
|
||
if frame.get("artifactHash") != instance.get("artifactHash"):
|
||
errors.append(f"#/frames/{idx}/artifactHash: 必须与单掷 artifactHash 一致")
|
||
action_id = frame.get("actionId")
|
||
if action_id is None:
|
||
continue
|
||
action = action_by_id.get(action_id)
|
||
if action is None:
|
||
errors.append(f"#/frames/{idx}/actionId: 引用了不存在的动作 {action_id}")
|
||
continue
|
||
expected_ref = action.get("postFrameRef") if frame.get("kind") == "post" else action.get("preFrameRef")
|
||
expected_ref = expected_ref or {}
|
||
if frame.get("ref") != expected_ref.get("path") or frame.get("hash") != expected_ref.get("hash"):
|
||
errors.append(f"#/frames/{idx}: frame 必须与动作的 {frame.get('kind')}FrameRef 一致")
|
||
|
||
for oidx, obligation in enumerate(instance.get("proofObligations") or []):
|
||
for sidx, sequence_ref in enumerate(obligation.get("sequenceRefs") or []):
|
||
frame_ref = sequence_ref.get("postFrameRef")
|
||
frame = frame_by_ref.get(frame_ref)
|
||
if frame is None or frame.get("kind") != "post":
|
||
errors.append(
|
||
f"#/proofObligations/{oidx}/sequenceRefs/{sidx}/postFrameRef: "
|
||
"必须引用本单掷封存的动作后帧"
|
||
)
|
||
|
||
judge = instance.get("judge") or {}
|
||
if judge.get("packageHash") != instance.get("judgePackageHash"):
|
||
errors.append("#/judge/packageHash: 必须等于 judgePackageHash")
|
||
judge_decision = judge.get("decision")
|
||
failure_class = judge.get("failureClass")
|
||
if judge_decision == "accept":
|
||
required = [row for row in instance.get("proofObligations") or [] if row.get("required") is True]
|
||
if not required or any(row.get("status") != "satisfied" for row in required):
|
||
errors.append("#/judge/decision: accept 时全部 required 义务必须 satisfied")
|
||
if (failure_class != "none" or judge.get("problems") or judge.get("contradictions")
|
||
or judge.get("blockingProblems")):
|
||
errors.append("#/judge/decision: accept 必须 failureClass=none 且无问题、矛盾或阻塞项")
|
||
elif judge_decision == "reject":
|
||
if failure_class not in ("broken", "hollow", "off_brief") or not judge.get("problems"):
|
||
errors.append("#/judge/decision: reject 必须有产物 failureClass 和非空 problems")
|
||
elif failure_class != "none":
|
||
errors.append("#/judge/failureClass: inconclusive/tester_error 必须为 none")
|
||
|
||
if instance.get("costRmb") != (instance.get("cost") or {}).get("costRmb"):
|
||
errors.append("#/costRmb: 必须与 cost.costRmb 一致")
|
||
cost = instance.get("cost") or {}
|
||
entry_cost = sum(float(row.get("rmb") or 0.0) for row in cost.get("entries") or [])
|
||
expected_cost, quantize_errors = _quantize_nonnegative_rmb(entry_cost, "#/cost/entries")
|
||
errors += quantize_errors
|
||
if not quantize_errors and float(cost.get("costRmb") or 0.0) != expected_cost:
|
||
errors.append("#/cost/entries: cost.costRmb 必须严格等于 ROUND_HALF_UP(sum(entries[].rmb), 5)")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_actor_selection(instance: dict, path: str = "#") -> list:
|
||
"""校验 ActorSelection/1 里由目标种类决定、schema 难以表达的选择约束。"""
|
||
errors = []
|
||
selection_type = instance.get("type")
|
||
refs = []
|
||
if selection_type == "tap":
|
||
refs = [(instance.get("target") or {}, f"{path}/target")]
|
||
elif selection_type == "drag":
|
||
refs = [
|
||
(instance.get("from") or {}, f"{path}/from"),
|
||
(instance.get("to") or {}, f"{path}/to"),
|
||
]
|
||
|
||
for target, target_path in refs:
|
||
target_id = str(target.get("id") or "")
|
||
has_cell = "cell" in target
|
||
has_anchor = "subAnchor" in target
|
||
if target_id.startswith("r") and (has_cell or has_anchor):
|
||
errors.append(f"{target_path}: region 只能选择目标 ID,不得携带 cell/subAnchor")
|
||
if target_id.startswith(("l", "g")) and not has_cell:
|
||
errors.append(f"{target_path}: lattice/spatial_grid 必须携带从 1 起的 cell")
|
||
|
||
if selection_type == "drag" and instance.get("from") == instance.get("to"):
|
||
errors.append(f"{path}: drag 起终 targetRef 不得完全相同")
|
||
return errors
|
||
|
||
|
||
def _point_in_bounds(point: dict, bounds: dict) -> bool:
|
||
"""按像素半开区间判断点是否落在 bounds 内,右/下边界不属于区域。"""
|
||
return (
|
||
bounds.get("x", 0) <= point.get("x", -1) < bounds.get("x", 0) + bounds.get("width", 0)
|
||
and bounds.get("y", 0) <= point.get("y", -1) < bounds.get("y", 0) + bounds.get("height", 0)
|
||
)
|
||
|
||
|
||
def _semantic_validate_visual_target_set(instance: dict) -> list:
|
||
"""校验 OpenCV 与 producer-seeded 两种 TargetSet 的输入绑定、预算和几何。"""
|
||
errors = []
|
||
generator = instance.get("generator") or {}
|
||
generator_id = generator.get("id")
|
||
expected_version = {
|
||
"opencv-visual-targets": "1.0.1",
|
||
"match3-producer-seeded-targets": "1.0.0",
|
||
}.get(generator_id)
|
||
if expected_version is None:
|
||
errors.append("#/generator/id: 未知 generator identity")
|
||
elif generator.get("version") != expected_version:
|
||
errors.append("#/generator/version: generator identity 与版本不匹配")
|
||
producer_capability, producer_geometry, producer_geometry_hash = _canonical_producer_geometry()
|
||
if generator_id == "match3-producer-seeded-targets":
|
||
expected_producer_hash = _producer_generator_config_hash(producer_capability)
|
||
if expected_producer_hash != _PRODUCER_SEEDED_GENERATOR_CONFIG_HASH:
|
||
errors.append("#/generator/configHash: validator 内置 producer config 与锁版 hash 漂移")
|
||
if generator.get("configHash") != expected_producer_hash:
|
||
errors.append("#/generator/configHash: producer-seeded 算法或 authority hash 不匹配")
|
||
producer_source_count = 0
|
||
targets = instance.get("targets") or []
|
||
target_ids = [row.get("id") for row in targets]
|
||
if len(target_ids) != len(set(target_ids)):
|
||
errors.append("#/targets: target id 必须全局唯一")
|
||
kinds = [row.get("kind") for row in targets]
|
||
kind_order = {"lattice": 0, "region": 1, "spatial_grid": 2}
|
||
if kinds != sorted(kinds, key=lambda kind: kind_order.get(kind, 99)):
|
||
errors.append("#/targets: 必须按 lattice、region、spatial_grid 的确定性种类顺序输出")
|
||
if sum(1 for kind in kinds if kind == "region") > 24:
|
||
errors.append("#/targets: region 候选不得超过 24 个")
|
||
if sum(1 for kind in kinds if kind == "lattice") > 4:
|
||
errors.append("#/targets: lattice 候选不得超过 4 个")
|
||
if [row.get("id") for row in targets if row.get("kind") == "spatial_grid"] != ["g01"]:
|
||
errors.append("#/targets: 必须且只能包含永久降级面 g01")
|
||
|
||
expected_ids = {
|
||
"lattice": [f"l{index:02d}" for index in range(1, kinds.count("lattice") + 1)],
|
||
"region": [f"r{index:02d}" for index in range(1, kinds.count("region") + 1)],
|
||
"spatial_grid": ["g01"],
|
||
}
|
||
for kind, ids in expected_ids.items():
|
||
actual_ids = [row.get("id") for row in targets if row.get("kind") == kind]
|
||
if actual_ids != ids:
|
||
errors.append(f"#/targets: {kind} id 必须按稳定顺序连续编号 {ids}")
|
||
|
||
for idx, target in enumerate(targets):
|
||
path = f"#/targets/{idx}"
|
||
bounds = target.get("bounds") or {}
|
||
if bounds.get("x", 0) + bounds.get("width", 0) > 390:
|
||
errors.append(f"{path}/bounds: 右边界越出 390px 视口")
|
||
if bounds.get("y", 0) + bounds.get("height", 0) > 844:
|
||
errors.append(f"{path}/bounds: 下边界越出 844px 视口")
|
||
gestures = target.get("allowedGestures") or []
|
||
if len(gestures) != len(set(gestures)):
|
||
errors.append(f"{path}/allowedGestures: gesture 不得重复")
|
||
if target.get("kind") == "region" and not _point_in_bounds(target.get("safePoint") or {}, bounds):
|
||
errors.append(f"{path}/safePoint: 必须落在 region bounds 内")
|
||
safe_point = target.get("safePoint") or {}
|
||
if safe_point and (safe_point.get("x", 390) >= 390 or safe_point.get("y", 844) >= 844):
|
||
errors.append(f"{path}/safePoint: 必须落在有效像素索引范围 x<390、y<844")
|
||
if target.get("kind") == "lattice":
|
||
dimensions = target.get("dimensions") or {}
|
||
rows, columns = dimensions.get("rows"), dimensions.get("columns")
|
||
row_centers = target.get("rowCenters") or []
|
||
column_centers = target.get("columnCenters") or []
|
||
if len(row_centers) != rows:
|
||
errors.append(f"{path}/rowCenters: 数量必须等于 dimensions.rows")
|
||
if len(column_centers) != columns:
|
||
errors.append(f"{path}/columnCenters: 数量必须等于 dimensions.columns")
|
||
if any(right <= left for left, right in zip(row_centers, row_centers[1:])):
|
||
errors.append(f"{path}/rowCenters: 必须严格递增")
|
||
if any(right <= left for left, right in zip(column_centers, column_centers[1:])):
|
||
errors.append(f"{path}/columnCenters: 必须严格递增")
|
||
observed = target.get("observedCells")
|
||
valid_observed_shape = isinstance(observed, list) and all(
|
||
isinstance(cell, dict) and set(cell) == {"row", "column"}
|
||
for cell in observed
|
||
)
|
||
observed_keys = [
|
||
(cell["row"], cell["column"])
|
||
for cell in observed
|
||
] if valid_observed_shape else []
|
||
expected_order = sorted(observed, key=lambda cell: (cell["row"], cell["column"])) \
|
||
if valid_observed_shape else []
|
||
in_bounds = valid_observed_shape and all(
|
||
isinstance(row, int) and not isinstance(row, bool)
|
||
and isinstance(column, int) and not isinstance(column, bool)
|
||
and 1 <= row <= (rows or 0) and 1 <= column <= (columns or 0)
|
||
for row, column in observed_keys
|
||
)
|
||
if not valid_observed_shape or observed != expected_order \
|
||
or len(set(observed_keys)) != len(observed_keys) or not in_bounds:
|
||
errors.append(f"{path}/observedCells: 必须是 dimensions 内行优先唯一子集")
|
||
row_support = [sum(1 for row, _column in observed_keys if row == value)
|
||
for value in range(1, (rows or 0) + 1)]
|
||
column_support = [sum(1 for _row, column in observed_keys if column == value)
|
||
for value in range(1, (columns or 0) + 1)]
|
||
if row_support and (min(row_support) < 3 or min(column_support) < 3):
|
||
errors.append(f"{path}/observedCells: 每行每列至少需要 3 个直接支持点")
|
||
expected_coverage = round(
|
||
len(observed_keys) * 1000 / max(1, (rows or 0) * (columns or 0)),
|
||
)
|
||
if not valid_observed_shape \
|
||
or (target.get("metrics") or {}).get("coveragePermille") != expected_coverage:
|
||
errors.append(f"{path}/metrics/coveragePermille: 必须与 observedCells 覆盖率一致")
|
||
geometry_source = target.get("geometrySource")
|
||
if generator_id == "match3-producer-seeded-targets":
|
||
producer_source_count += 1
|
||
expected_source = {
|
||
"mode": "producer_capability",
|
||
"capabilityId": producer_capability.get("id"),
|
||
"capabilityVersion": producer_capability.get("version"),
|
||
"capabilityConfigHash": producer_capability.get("configHash"),
|
||
"geometryHash": producer_geometry_hash,
|
||
}
|
||
if geometry_source != expected_source:
|
||
errors.append(f"{path}/geometrySource: 必须逐字匹配 registry capability 与 canonical geometry")
|
||
if target.get("dimensions") != producer_geometry.get("dimensions") \
|
||
or target.get("rowCenters") != producer_geometry.get("rowCenters") \
|
||
or target.get("columnCenters") != producer_geometry.get("columnCenters"):
|
||
errors.append(f"{path}: producer lattice 几何必须逐字匹配 registry")
|
||
grid = ((producer_capability.get("config") or {}).get("grid") or {})
|
||
expected_bounds = {
|
||
"x": grid.get("originX"), "y": grid.get("originY"),
|
||
"width": grid.get("columns", 0) * grid.get("pitchPx", 0),
|
||
"height": grid.get("rows", 0) * grid.get("pitchPx", 0),
|
||
}
|
||
if target.get("bounds") != expected_bounds:
|
||
errors.append(f"{path}/bounds: producer lattice bounds 必须逐字匹配 registry")
|
||
if len(observed_keys) != (producer_geometry.get("dimensions", {}).get("rows", 0)
|
||
* producer_geometry.get("dimensions", {}).get("columns", 0)):
|
||
errors.append(f"{path}/observedCells: producer lattice 必须逐格通过当前 PNG 支撑扫描")
|
||
elif geometry_source is not None:
|
||
errors.append(f"{path}/geometrySource: OpenCV lattice 不得携带 producer provenance")
|
||
tolerance = 3
|
||
x1, y1 = bounds.get("x", 0), bounds.get("y", 0)
|
||
x2 = x1 + bounds.get("width", 0)
|
||
y2 = y1 + bounds.get("height", 0)
|
||
if not all(x1 - tolerance <= value <= x2 + tolerance for value in column_centers):
|
||
errors.append(f"{path}/columnCenters: 必须落在 lattice bounds 容差内")
|
||
if not all(y1 - tolerance <= value <= y2 + tolerance for value in row_centers):
|
||
errors.append(f"{path}/rowCenters: 必须落在 lattice bounds 容差内")
|
||
|
||
canonical = {
|
||
"schemaVersion": instance.get("schemaVersion"),
|
||
"sourceFrameHash": instance.get("sourceFrameHash"),
|
||
"viewport": instance.get("viewport"),
|
||
"generator": instance.get("generator"),
|
||
"targets": instance.get("targets"),
|
||
}
|
||
canonical_bytes = json.dumps(
|
||
canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
||
).encode("utf-8")
|
||
expected_hash = hashlib.sha256(b"VisualTargetSet/1\x00" + canonical_bytes).hexdigest()
|
||
if instance.get("targetSetHash") != expected_hash:
|
||
errors.append("#/targetSetHash: 与 VisualTargetSet canonical 内容不一致")
|
||
if generator_id == "match3-producer-seeded-targets" and producer_source_count != 1:
|
||
errors.append("#/targets: producer-seeded TargetSet 必须且只能包含一个 producer lattice")
|
||
if generator_id == "opencv-visual-targets" and producer_source_count:
|
||
errors.append("#/targets: OpenCV TargetSet 不得包含 producer lattice")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_target_resolution(instance: dict, path: str = "#") -> list:
|
||
"""校验 TargetResolution/1 的选择绑定、点角色和退化 drag。"""
|
||
errors = []
|
||
status = instance.get("status")
|
||
selection = instance.get("selection")
|
||
if not isinstance(selection, dict):
|
||
return errors
|
||
errors += _semantic_validate_actor_selection(selection, f"{path}/selection")
|
||
selection_type = selection.get("type")
|
||
if selection_type in ("key", "wait"):
|
||
errors.append(f"{path}/selection: key/wait 只能使用 status=not_applicable")
|
||
return errors
|
||
if status not in ("resolved", "failed"):
|
||
errors.append(f"{path}/status: tap/drag 只能是 resolved 或 failed")
|
||
return errors
|
||
if instance.get("targetSetHash") != selection.get("targetSetHash"):
|
||
errors.append(f"{path}/targetSetHash: 必须与 selection.targetSetHash 相同")
|
||
if status == "failed":
|
||
return errors
|
||
|
||
points = instance.get("resolvedPoints") or []
|
||
for idx, row in enumerate(points):
|
||
point = row.get("point") or {}
|
||
if point.get("x", 390) >= 390 or point.get("y", 844) >= 844:
|
||
errors.append(f"{path}/resolvedPoints/{idx}/point: 必须落在有效像素索引范围 x<390、y<844")
|
||
if selection_type == "tap":
|
||
expected = [("target", selection.get("target"))]
|
||
else:
|
||
expected = [("from", selection.get("from")), ("to", selection.get("to"))]
|
||
actual = [(row.get("role"), row.get("target")) for row in points]
|
||
if actual != expected:
|
||
errors.append(f"{path}/resolvedPoints: 点角色和 targetRef 必须逐项镜像 selection")
|
||
if selection_type == "drag" and len(points) == 2 and points[0].get("point") == points[1].get("point"):
|
||
errors.append(f"{path}/resolvedPoints: drag 解析后的起终坐标不得相同")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_cost_reservation(instance: dict) -> list:
|
||
"""校验 CostReservation/1 的 A/B 原子预留和 usage 上界。"""
|
||
errors = []
|
||
slots = instance.get("slots") or []
|
||
slot_names = [row.get("slot") for row in slots]
|
||
if sorted(slot_names) != ["judge-a", "judge-b"]:
|
||
errors.append("#/slots: 必须恰好各预留一次 judge-a 与 judge-b")
|
||
|
||
for field in ("reservedRmb", "retryReservedRmb"):
|
||
actual, actual_errors = _quantize_nonnegative_rmb(instance.get(field), f"#/{field}")
|
||
errors += actual_errors
|
||
total = sum(float(row.get(field) or 0) for row in slots)
|
||
expected, total_errors = _quantize_nonnegative_rmb(total, f"#/slots/*/{field}")
|
||
errors += total_errors
|
||
if not actual_errors and not total_errors and actual != expected:
|
||
errors.append(f"#/{field}: 必须等于两槽 {field} 的五位半入和")
|
||
|
||
for idx, slot in enumerate(slots):
|
||
if slot.get("inputTokenUpperBound") != slot.get("requestBytes"):
|
||
errors.append(
|
||
f"#/slots/{idx}/inputTokenUpperBound: 必须等于完整 HTTP request body 的 UTF-8 字节数"
|
||
)
|
||
|
||
slot_by_name = {row.get("slot"): row for row in slots}
|
||
seen_attempts = set()
|
||
for idx, settlement in enumerate(instance.get("settlements") or []):
|
||
path = f"#/settlements/{idx}"
|
||
slot_name = settlement.get("slot")
|
||
attempt_key = (slot_name, settlement.get("attempt"))
|
||
if attempt_key in seen_attempts:
|
||
errors.append(f"{path}: 同一槽同一次 attempt 不得重复结算")
|
||
seen_attempts.add(attempt_key)
|
||
upper = (slot_by_name.get(slot_name) or {}).get("inputTokenUpperBound")
|
||
if upper is not None and settlement.get("promptTokens", 0) >= upper:
|
||
errors.append(f"{path}/promptTokens: 必须严格小于预留时的保守 input token 上界")
|
||
if settlement.get("attempt") == 2:
|
||
first = next(
|
||
(row for row in instance.get("settlements") or []
|
||
if row.get("slot") == slot_name and row.get("attempt") == 1),
|
||
None,
|
||
)
|
||
if first is None or first.get("contentReceived") is not False:
|
||
errors.append(f"{path}/attempt: 仅第一次未收到任何模型内容时允许同槽重试")
|
||
if (slot_by_name.get(slot_name) or {}).get("retryReservedRmb", 0) <= 0:
|
||
errors.append(f"{path}/attempt: 第二次调用前必须完成该槽追加预留")
|
||
if settlement.get("contentReceived") is True and not settlement.get("providerRequestId"):
|
||
errors.append(f"{path}/providerRequestId: 收到模型内容时必须保留 provider request id")
|
||
if instance.get("status") == "reserved" and instance.get("settlements"):
|
||
errors.append("#/status: reserved 状态不得已有 settlements")
|
||
if instance.get("status") == "reserved" and instance.get("retryReservedRmb") != 0:
|
||
errors.append("#/retryReservedRmb: 初始 reserved 阶段不得提前占用重试额度")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_v3_action(action: dict, path: str) -> list:
|
||
"""校验 playtest/3 正式动作的 selection、resolution、normalized 与时钟闭环。"""
|
||
errors = []
|
||
selection = ((action.get("request") or {}).get("parsed") or {})
|
||
resolution = action.get("targetResolution") or {}
|
||
normalized = action.get("normalized") or {}
|
||
selection_type = selection.get("type")
|
||
protocol_errors = action.get("protocolErrors") or []
|
||
if action.get("retryCount") != len(protocol_errors):
|
||
errors.append(f"{path}/retryCount: 必须严格等于 protocolErrors 数量")
|
||
errors += _semantic_validate_actor_selection(selection, f"{path}/request/parsed")
|
||
if selection_type in ("tap", "drag"):
|
||
if resolution.get("status") != "resolved":
|
||
errors.append(f"{path}/targetResolution: 正式 tap/drag 动作必须 resolved")
|
||
else:
|
||
errors += _semantic_validate_target_resolution(resolution, f"{path}/targetResolution")
|
||
if resolution.get("selection") != selection:
|
||
errors.append(f"{path}/targetResolution/selection: 必须逐字镜像 request.parsed")
|
||
if normalized.get("type") != selection_type:
|
||
errors.append(f"{path}/normalized/type: 必须与 selection.type 相同")
|
||
points = resolution.get("resolvedPoints") or []
|
||
if selection_type == "tap" and len(points) == 1:
|
||
if normalized.get("x") != (points[0].get("point") or {}).get("x") or normalized.get("y") != (points[0].get("point") or {}).get("y"):
|
||
errors.append(f"{path}/normalized: tap 坐标必须来自 resolved target 点")
|
||
if selection_type == "drag" and len(points) == 2:
|
||
if normalized.get("from") != points[0].get("point") or normalized.get("to") != points[1].get("point"):
|
||
errors.append(f"{path}/normalized: drag 坐标必须逐项来自 resolved from/to 点")
|
||
elif selection_type in ("key", "wait"):
|
||
if resolution != {"status": "not_applicable"}:
|
||
errors.append(f"{path}/targetResolution: key/wait 必须精确等于 not_applicable")
|
||
if normalized.get("type") != selection_type:
|
||
errors.append(f"{path}/normalized/type: 必须与 selection.type 相同")
|
||
mirror_field = "key" if selection_type == "key" else "ms"
|
||
if normalized.get(mirror_field) != selection.get(mirror_field):
|
||
errors.append(f"{path}/normalized/{mirror_field}: 必须镜像 selection")
|
||
|
||
expected_budget = selection.get("ms") if selection_type == "wait" else 600
|
||
if action.get("virtualTimeBudgetMs") != expected_budget:
|
||
errors.append(f"{path}/virtualTimeBudgetMs: 必须等于动作预算 {expected_budget}")
|
||
advance = action.get("timeAdvance") or {}
|
||
if advance.get("requestedBudgetMs") != expected_budget:
|
||
errors.append(f"{path}/timeAdvance/requestedBudgetMs: 必须等于动作预算")
|
||
if advance.get("plannedFrames") != advance.get("executedFrames"):
|
||
errors.append(f"{path}/timeAdvance: plannedFrames 必须等于 executedFrames")
|
||
if action.get("postEventSeq", 0) < action.get("preEventSeq", 0):
|
||
errors.append(f"{path}/postEventSeq: 不得早于 preEventSeq")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_playtest_v3(instance: dict) -> list:
|
||
"""校验 playtest/3 的结构化动作、双 Judge 与 source roll 单向投影。"""
|
||
errors = _semantic_validate_environment(instance, wait_max_ms=600)
|
||
errors += _semantic_validate_acceptance_profile(instance)
|
||
if (instance.get("environment") or {}).get("buildRef") != instance.get("artifactHash"):
|
||
errors.append("#/environment/buildRef: 必须等于顶层 artifactHash")
|
||
|
||
for idx, action in enumerate(instance.get("actions") or []):
|
||
errors += _semantic_validate_v3_action(action, f"#/actions/{idx}")
|
||
|
||
rolls = instance.get("rolls") or []
|
||
roll_by_id = {}
|
||
for idx, roll in enumerate(rolls):
|
||
path = f"#/rolls/{idx}"
|
||
roll_id = roll.get("rollId")
|
||
if roll_id in roll_by_id:
|
||
errors.append(f"{path}/rollId: rollId 不得重复")
|
||
roll_by_id[roll_id] = roll
|
||
if roll_id != f"roll-{roll.get('roll')}":
|
||
errors.append(f"{path}/rollId: 必须与 roll 数字一致")
|
||
if roll.get("requestedGameSeed") != roll.get("actualGameSeed"):
|
||
errors.append(f"{path}/actualGameSeed: 必须等于 requestedGameSeed")
|
||
a_ref, b_ref = roll.get("judgeARef") or {}, roll.get("judgeBRef") or {}
|
||
for field in ("rawRef", "parsedRef", "normalizedRef"):
|
||
if a_ref.get(field) == b_ref.get(field):
|
||
errors.append(f"{path}: Judge A/B 的 {field} 必须隔离")
|
||
|
||
source_roll_id = instance.get("sourceRollId")
|
||
judge = instance.get("judge")
|
||
if judge is None:
|
||
if source_roll_id is not None:
|
||
errors.append("#/sourceRollId: judge=null 时必须为 null")
|
||
else:
|
||
if judge.get("sourceRollId") != source_roll_id:
|
||
errors.append("#/judge/sourceRollId: 必须镜像顶层 sourceRollId")
|
||
if judge.get("sameModel") != (judge.get("independenceChecks") or {}).get("sameModel"):
|
||
errors.append("#/judge/sameModel: 必须镜像 independenceChecks.sameModel")
|
||
source_roll = roll_by_id.get(source_roll_id)
|
||
if source_roll_id is not None and source_roll is None:
|
||
errors.append("#/sourceRollId: 必须引用本 run 已封存的 roll")
|
||
if source_roll is not None:
|
||
mirror_pairs = (
|
||
("consensusRef", source_roll.get("judgeConsensusRef")),
|
||
("consensusHash", source_roll.get("judgeConsensusHash")),
|
||
("packageHash", source_roll.get("judgePackageHash")),
|
||
("judgeAResultHash", (source_roll.get("judgeARef") or {}).get("resultHash")),
|
||
("judgeBResultHash", (source_roll.get("judgeBRef") or {}).get("resultHash")),
|
||
)
|
||
for judge_field, expected in mirror_pairs:
|
||
if judge.get(judge_field) != expected:
|
||
errors.append(f"#/judge/{judge_field}: 必须镜像 source roll 的封存事实")
|
||
if source_roll_id is None and judge.get("decision") in ("accept", "reject"):
|
||
errors.append("#/judge/decision: 无 source roll 时不能投影 accept/reject")
|
||
|
||
roll_cost = sum(float(row.get("costRmb") or 0) for row in rolls)
|
||
expected_cost, cost_errors = _quantize_nonnegative_rmb(roll_cost, "#/rolls/*/costRmb")
|
||
actual_cost, actual_errors = _quantize_nonnegative_rmb(instance.get("costRmb"), "#/costRmb")
|
||
errors += cost_errors + actual_errors
|
||
if not cost_errors and not actual_errors and expected_cost != actual_cost:
|
||
errors.append("#/costRmb: 必须等于 rolls 成本五位半入和")
|
||
|
||
if instance.get("phase") == "DECIDED":
|
||
decision = instance.get("decision") or {}
|
||
outcome = instance.get("outcome")
|
||
if decision.get("outcome") != outcome or decision.get("accepted") != (outcome == "accept"):
|
||
errors.append("#/decision: outcome/accepted 必须由顶层 outcome 单向投影")
|
||
compatibility = instance.get("compatibility") or {}
|
||
if compatibility.get("sourceRollId") != source_roll_id:
|
||
errors.append("#/compatibility/sourceRollId: 必须镜像顶层 sourceRollId")
|
||
if outcome == "accept" and (judge or {}).get("decision") != "accept":
|
||
errors.append("#/judge/decision: 最终 accept 必须来自 source roll 的 consensus accept")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_single_roll_v3(instance: dict) -> list:
|
||
"""校验 playtest/3 Node→Python 单掷事实,不借用冻结 v2 的 Judge/动作语义。"""
|
||
errors = _semantic_validate_environment(instance, wait_max_ms=600)
|
||
errors += _semantic_validate_acceptance_profile(instance)
|
||
# 事件因果、payloadCanonical、proof 引用、sameValue 与 orderedSeries 与协议代际无关。
|
||
# 复用冻结 v2 的事实层校验,但显式移除 Actor/Judge,避免把旧单 Judge 身份语义套到双 Judge。
|
||
errors += _semantic_validate_playtest({
|
||
"phase": "COLLECTING",
|
||
"acceptanceMode": "historical_replay"
|
||
if instance.get("evidenceMode") == "legacy-adapted" else "v3_shadow",
|
||
"evidenceMode": instance.get("evidenceMode"),
|
||
"genre": instance.get("genre"),
|
||
"templateRoute": instance.get("templateRoute"),
|
||
"proofProfileId": instance.get("proofProfileId"),
|
||
"proofRegistryVersion": instance.get("proofRegistryVersion"),
|
||
"acceptanceRequestHash": instance.get("acceptanceRequestHash"),
|
||
"artifactHash": instance.get("artifactHash"),
|
||
"environment": instance.get("environment"),
|
||
# 单掷 schema 允许用于局部 fixture;required 完整性由生产 registry/guard 另查,
|
||
# 这里关掉 floorPass 只跳过“必须出现全部义务”,不跳过已出现义务的关系校验。
|
||
"floor": {"pass": False, "gates": {}},
|
||
"actor": None,
|
||
"judge": None,
|
||
"actions": instance.get("actions") or [],
|
||
"events": instance.get("events") or [],
|
||
"briefRuleMatches": instance.get("briefRuleMatches") or [],
|
||
"proofObligations": instance.get("proofObligations") or [],
|
||
"firstPlay": instance.get("firstPlay") or {},
|
||
"rolls": [],
|
||
}, wait_max_ms=600)
|
||
if (instance.get("environment") or {}).get("buildRef") != instance.get("artifactHash"):
|
||
errors.append("#/environment/buildRef: 必须等于单掷 artifactHash")
|
||
if instance.get("rollId") != f"roll-{instance.get('roll')}":
|
||
errors.append("#/rollId: 必须与 roll 数字一致")
|
||
if instance.get("requestedSeed") != instance.get("actualSeed"):
|
||
errors.append("#/actualSeed: 必须等于 requestedSeed")
|
||
|
||
actor = instance.get("actor") or {}
|
||
seed_mirrors = (
|
||
("requestedGameSeed", "requestedSeed"),
|
||
("actualGameSeed", "actualSeed"),
|
||
("policySeed", "policySeed"),
|
||
)
|
||
for actor_field, top_field in seed_mirrors:
|
||
if actor.get(actor_field) != instance.get(top_field):
|
||
errors.append(f"#/actor/{actor_field}: 必须镜像顶层 {top_field}")
|
||
|
||
actions = instance.get("actions") or []
|
||
action_by_id = {}
|
||
for idx, action in enumerate(actions):
|
||
action_id = action.get("actionId")
|
||
if action_id in action_by_id:
|
||
errors.append(f"#/actions/{idx}/actionId: actionId 不得重复")
|
||
action_by_id[action_id] = action
|
||
errors += _semantic_validate_v3_action(action, f"#/actions/{idx}")
|
||
|
||
frame_refs = set()
|
||
for idx, frame in enumerate(instance.get("frames") or []):
|
||
path = f"#/frames/{idx}"
|
||
frame_ref = frame.get("ref")
|
||
if frame_ref in frame_refs:
|
||
errors.append(f"{path}/ref: frame ref 不得重复")
|
||
frame_refs.add(frame_ref)
|
||
if frame.get("artifactHash") != instance.get("artifactHash"):
|
||
errors.append(f"{path}/artifactHash: 必须等于单掷 artifactHash")
|
||
action = action_by_id.get(frame.get("actionId"))
|
||
if action is None:
|
||
errors.append(f"{path}/actionId: 引用了不存在的正式动作")
|
||
continue
|
||
expected_ref = action.get("postFrameRef") if frame.get("kind") == "post" else action.get("preFrameRef")
|
||
expected_ref = expected_ref or {}
|
||
if frame_ref != expected_ref.get("path") or frame.get("hash") != expected_ref.get("hash"):
|
||
errors.append(f"{path}: 必须逐字镜像动作的 {frame.get('kind')}FrameRef")
|
||
|
||
a_ref, b_ref = instance.get("judgeARef") or {}, instance.get("judgeBRef") or {}
|
||
for field in ("rawRef", "parsedRef", "normalizedRef"):
|
||
if a_ref.get(field) == b_ref.get(field):
|
||
errors.append(f"#/judgeBRef/{field}: Judge A/B 文件必须隔离")
|
||
|
||
judge = instance.get("judge") or {}
|
||
if judge.get("sourceRollId") != instance.get("rollId"):
|
||
errors.append("#/judge/sourceRollId: 必须等于当前单掷 rollId")
|
||
if judge.get("sameModel") != (judge.get("independenceChecks") or {}).get("sameModel"):
|
||
errors.append("#/judge/sameModel: 必须镜像 independenceChecks.sameModel")
|
||
judge_mirrors = (
|
||
("consensusRef", instance.get("judgeConsensusRef")),
|
||
("consensusHash", instance.get("judgeConsensusHash")),
|
||
("packageHash", instance.get("judgePackageHash")),
|
||
("judgeAResultHash", a_ref.get("resultHash")),
|
||
("judgeBResultHash", b_ref.get("resultHash")),
|
||
)
|
||
for judge_field, expected in judge_mirrors:
|
||
if judge.get(judge_field) != expected:
|
||
errors.append(f"#/judge/{judge_field}: 必须镜像单掷封存事实")
|
||
|
||
cost = instance.get("cost") or {}
|
||
if instance.get("costRmb") != cost.get("costRmb"):
|
||
errors.append("#/costRmb: 必须与 cost.costRmb 一致")
|
||
entry_cost = sum(float(row.get("rmb") or 0) for row in cost.get("entries") or [])
|
||
expected_cost, expected_errors = _quantize_nonnegative_rmb(entry_cost, "#/cost/entries")
|
||
actual_cost, actual_errors = _quantize_nonnegative_rmb(cost.get("costRmb"), "#/cost/costRmb")
|
||
errors += expected_errors + actual_errors
|
||
if not expected_errors and not actual_errors and expected_cost != actual_cost:
|
||
errors.append("#/cost/entries: costRmb 必须等于明细五位半入和")
|
||
return errors
|
||
|
||
|
||
def _canonical_domain_hash(domain_label: str, value) -> str:
|
||
"""按 Node stableStringify 的口径计算带域标签 SHA-256。"""
|
||
canonical = json.dumps(
|
||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
|
||
)
|
||
return hashlib.sha256(f"{domain_label}\n{canonical}".encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _canonical_producer_geometry() -> tuple[dict, dict, str]:
|
||
"""从 interaction registry authority 复算 producer lattice 几何身份。"""
|
||
registry = _load(_HERE / "interaction-profiles.v2.json")
|
||
capability = (((registry.get("profiles") or {}).get("match3.orthogonal-swap-v1") or {})
|
||
.get("producerCapability") or {})
|
||
grid = ((capability.get("config") or {}).get("grid") or {})
|
||
rows, columns = grid.get("rows"), grid.get("columns")
|
||
pitch, origin_x, origin_y = grid.get("pitchPx"), grid.get("originX"), grid.get("originY")
|
||
if not all(isinstance(value, int) for value in (rows, columns, pitch, origin_x, origin_y)):
|
||
return capability, {}, ""
|
||
geometry = {
|
||
"dimensions": {"rows": rows, "columns": columns},
|
||
"rowCenters": [origin_y + row * pitch + pitch // 2 for row in range(rows)],
|
||
"columnCenters": [origin_x + column * pitch + pitch // 2 for column in range(columns)],
|
||
}
|
||
return capability, geometry, _canonical_domain_hash("match3-lattice-geometry/1", geometry)
|
||
|
||
|
||
def _producer_generator_config_hash(capability: dict) -> str:
|
||
"""独立复算 renderer 的 generatorConfig/1\0 producer-seeded hash。"""
|
||
value = {
|
||
"schemaVersion": "generatorConfig/1",
|
||
"opencv": _PRODUCER_OPENCV_CONFIG,
|
||
"producerSeed": {
|
||
"algorithmVersion": _PRODUCER_LATTICE_ALGORITHM_VERSION,
|
||
"capability": capability,
|
||
},
|
||
}
|
||
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||
return hashlib.sha256(b"generatorConfig/1\x00" + canonical.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _semantic_validate_interaction_registry(instance: dict) -> list:
|
||
"""交互注册表必须与 proof profile 对账,并封存规则配置及整表哈希。"""
|
||
errors = []
|
||
profiles = instance.get("profiles") or {}
|
||
if set(profiles) != {"match3.orthogonal-swap-v1"}:
|
||
errors.append("#/profiles: 第一版必须且只能登记 match3.orthogonal-swap-v1")
|
||
return errors
|
||
profile = profiles.get("match3.orthogonal-swap-v1") or {}
|
||
if profile.get("id") != "match3.orthogonal-swap-v1":
|
||
errors.append("#/profiles/match3.orthogonal-swap-v1/id: 必须与注册表键一致")
|
||
|
||
proof_registry = _load(_HERE / "proof-obligations.v2.json")
|
||
proof_profile = (proof_registry.get("profiles") or {}).get(profile.get("proofProfileId"))
|
||
path = "#/profiles/match3.orthogonal-swap-v1"
|
||
if not isinstance(proof_profile, dict):
|
||
errors.append(f"{path}/proofProfileId: 未命中 canonical proof profile")
|
||
else:
|
||
if profile.get("genre") != proof_profile.get("genre"):
|
||
errors.append(f"{path}/genre: 必须与 canonical proof profile 一致")
|
||
if profile.get("templateRoute") != proof_profile.get("templateRoute"):
|
||
errors.append(f"{path}/templateRoute: 必须与 canonical proof profile 一致")
|
||
|
||
solver = profile.get("solver") or {}
|
||
expected_config_hash = _canonical_domain_hash(
|
||
"match3-solver-config/1", solver.get("config") or {},
|
||
)
|
||
if solver.get("configHash") != expected_config_hash:
|
||
errors.append(f"{path}/solver/configHash: 与版本化 solver config 不一致")
|
||
registry_content = {key: deepcopy(value) for key, value in instance.items() if key != "registryHash"}
|
||
expected_registry_hash = _canonical_domain_hash(
|
||
"interaction-profile-registry/1", registry_content,
|
||
)
|
||
if instance.get("registryHash") != expected_registry_hash:
|
||
errors.append("#/registryHash: 与 canonical 注册表内容不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_interaction_registry_v2(instance: dict) -> list:
|
||
"""View/4 enforce registry 必须绑定 proof profile,并封存完整协议、感知和恢复策略。"""
|
||
errors = []
|
||
profile = (instance.get("profiles") or {}).get("match3.orthogonal-swap-v1") or {}
|
||
proof_profile = (
|
||
(_load(_HERE / "proof-obligations.v2.json").get("profiles") or {})
|
||
.get(profile.get("proofProfileId")) or {}
|
||
)
|
||
for field in ("genre", "templateRoute"):
|
||
if profile.get(field) != proof_profile.get(field):
|
||
errors.append(f"#/profiles/match3.orthogonal-swap-v1/{field}: 必须与 proof profile 一致")
|
||
components = [
|
||
profile.get("solver") or {},
|
||
profile.get("producerCapability") or {},
|
||
((profile.get("actorProtocol") or {}).get("parser") or {}),
|
||
((profile.get("actorProtocol") or {}).get("resolver") or {}),
|
||
profile.get("overlayNormalizer") or {},
|
||
profile.get("equivalenceAlgorithm") or {},
|
||
]
|
||
for component in components:
|
||
expected_hash = _canonical_domain_hash(
|
||
f"interaction-component-config/{component.get('id')}/1",
|
||
component.get("config") or {},
|
||
)
|
||
if component.get("configHash") != expected_hash:
|
||
errors.append(
|
||
"#/profiles/match3.orthogonal-swap-v1: "
|
||
f"{component.get('id')} configHash 与 strict config 不一致",
|
||
)
|
||
prompt = ((profile.get("actorProtocol") or {}).get("prompt") or {})
|
||
prompt_ref = prompt.get("artifactRef")
|
||
prompt_path = (_HERE / str(prompt_ref)).resolve()
|
||
if prompt_path.parent != (_HERE / "artifacts").resolve() or not prompt_path.is_file():
|
||
errors.append("#/profiles/match3.orthogonal-swap-v1/actorProtocol/prompt/artifactRef: 工件不存在或越界")
|
||
elif hashlib.sha256(prompt_path.read_bytes()).hexdigest() != prompt.get("hash"):
|
||
errors.append("#/profiles/match3.orthogonal-swap-v1/actorProtocol/prompt/hash: 与工件原始字节不一致")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "registryHash"}
|
||
expected = _canonical_domain_hash("interaction-profile-registry/2", content)
|
||
if instance.get("registryHash") != expected:
|
||
errors.append("#/registryHash: 与 View/4 enforce registry canonical 内容不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_match3_producer_preflight(instance: dict) -> list:
|
||
"""生产者 preflight 必须把能力身份、隔离事实和退出分类封成一份不可重签洗白的事实。"""
|
||
errors = []
|
||
profile = (
|
||
(_load(_HERE / "interaction-profiles.v2.json").get("profiles") or {})
|
||
.get(instance.get("interactionProfileId")) or {}
|
||
)
|
||
canonical_capability = profile.get("producerCapability") or {}
|
||
expected = instance.get("expectedCapability") or {}
|
||
probed = instance.get("probedCapability")
|
||
|
||
def capability_hash_ok(capability: dict) -> bool:
|
||
expected_hash = _canonical_domain_hash(
|
||
f"interaction-component-config/{capability.get('id')}/1",
|
||
capability.get("config") or {},
|
||
)
|
||
return capability.get("configHash") == expected_hash
|
||
|
||
expected_matches_registry = expected == canonical_capability
|
||
probed_hash_ok = isinstance(probed, dict) and capability_hash_ok(probed)
|
||
probed_matches_registry = probed == canonical_capability
|
||
binding_content = {
|
||
"interactionProfileId": instance.get("interactionProfileId"),
|
||
"interactionRegistryVersion": instance.get("interactionRegistryVersion"),
|
||
"taskBindingHash": instance.get("taskBindingHash"),
|
||
}
|
||
expected_binding_hash = _canonical_domain_hash("interaction-binding/1", binding_content)
|
||
binding_matches = instance.get("interactionBindingHash") == expected_binding_hash
|
||
# 身份漂移本身是一种可封存的 tester_error 事实,只有伪装成 compatible/incompatible 才拒绝。
|
||
stages = [
|
||
"environment_setup", "browser_connected", "capability_probed",
|
||
"initial_frame_captured", "first_action_dispatched",
|
||
"post_frame_captured", "normalizer_completed",
|
||
]
|
||
stage = instance.get("executionStage")
|
||
stage_index = stages.index(stage) if stage in stages else -1
|
||
capability_was_probed = stage_index >= stages.index("capability_probed")
|
||
profile_error = (
|
||
not expected_matches_registry or not binding_matches
|
||
or (capability_was_probed and (not probed_hash_ok or not probed_matches_registry))
|
||
)
|
||
|
||
# producer grid 的 origin 是棋格左上角;lattice 证据绑定的是逐格中心坐标。
|
||
grid = (canonical_capability.get("config") or {}).get("grid") or {}
|
||
rows = grid.get("rows")
|
||
columns = grid.get("columns")
|
||
pitch = grid.get("pitchPx")
|
||
origin_x = grid.get("originX")
|
||
origin_y = grid.get("originY")
|
||
|
||
def center_axis(origin, count):
|
||
if not all(isinstance(value, int) for value in (origin, count, pitch)):
|
||
return []
|
||
return [origin + index * pitch + pitch // 2 for index in range(count)]
|
||
|
||
canonical_geometry = {
|
||
"dimensions": {"rows": rows, "columns": columns},
|
||
"rowCenters": center_axis(origin_y, rows),
|
||
"columnCenters": center_axis(origin_x, columns),
|
||
}
|
||
canonical_geometry_hash = _canonical_domain_hash(
|
||
"match3-lattice-geometry/1", canonical_geometry,
|
||
)
|
||
|
||
event_interval = instance.get("eventInterval") or {}
|
||
pre_seq = event_interval.get("preEventSeq")
|
||
post_seq = event_interval.get("postEventSeq")
|
||
if isinstance(pre_seq, int) and isinstance(post_seq, int) and post_seq < pre_seq:
|
||
errors.append("#/eventInterval/postEventSeq: 不得早于 preEventSeq")
|
||
|
||
endpoint = instance.get("selectedEndpoint") or {}
|
||
lattice = instance.get("lattice") or {}
|
||
if endpoint and lattice and endpoint.get("latticeId") != lattice.get("latticeId"):
|
||
errors.append("#/selectedEndpoint/latticeId: 必须绑定 preflight lattice")
|
||
|
||
normalizer = instance.get("normalizerAudit") or {}
|
||
normalizer_not_run = normalizer.get("status") == "not_run"
|
||
if normalizer_not_run and any(normalizer.get(field) is not None for field in (
|
||
"ref", "hash", "changedPixelCount", "outsideSelectedCellCount",
|
||
"maximumReplayResidual", "supportMaskHash",
|
||
)):
|
||
errors.append("#/normalizerAudit: not_run 不得伪造 ref/hash 或像素结果")
|
||
if not normalizer_not_run and any(normalizer.get(field) is None for field in (
|
||
"ref", "hash", "changedPixelCount", "outsideSelectedCellCount",
|
||
"maximumReplayResidual",
|
||
)):
|
||
errors.append("#/normalizerAudit: 已运行 normalizer 必须封存 ref/hash 与完整数值")
|
||
|
||
# executionStage 是已完成的最后边界;未来阶段的证据不得提前出现。
|
||
stage_evidence = (
|
||
("capability_probed", ("probedCapability",)),
|
||
("initial_frame_captured", ("initialFrame",)),
|
||
("first_action_dispatched", ("selectedEndpoint", "lattice", "swapSet")),
|
||
("post_frame_captured", ("postFrame", "eventInterval")),
|
||
)
|
||
for required_stage, fields in stage_evidence:
|
||
if stage_index < stages.index(required_stage):
|
||
for field in fields:
|
||
if instance.get(field) is not None:
|
||
errors.append(f"#/{field}: executionStage={stage} 时不得提前出现")
|
||
if stage_index < stages.index("normalizer_completed") and not normalizer_not_run:
|
||
errors.append("#/normalizerAudit/status: normalizer_completed 前必须为 not_run")
|
||
environment = instance.get("environment") or {}
|
||
canvas_backing = environment.get("canvasBacking")
|
||
if stage_index < stages.index("browser_connected"):
|
||
if environment.get("browserVersion") is not None \
|
||
or environment.get("runtimeIdentityHash") is not None \
|
||
or canvas_backing is not None \
|
||
or environment.get("freshContext") is not False \
|
||
or environment.get("contextIsolation") != "not-created":
|
||
errors.append("#/environment: browser_connected 前不得伪造浏览器或 runtime identity")
|
||
elif environment.get("browserVersion") is None \
|
||
or environment.get("freshContext") is not True \
|
||
or environment.get("contextIsolation") != "one-time-dedicated":
|
||
errors.append("#/environment: browser_connected 后必须封存一次性浏览器 context 身份")
|
||
if isinstance(canvas_backing, dict):
|
||
scale_x = canvas_backing.get("scaleX")
|
||
scale_y = canvas_backing.get("scaleY")
|
||
if not isinstance(scale_x, int) or not isinstance(scale_y, int) \
|
||
or scale_x != scale_y \
|
||
or canvas_backing.get("backingWidth") != canvas_backing.get("cssWidth", 0) * scale_x \
|
||
or canvas_backing.get("backingHeight") != canvas_backing.get("cssHeight", 0) * scale_y:
|
||
errors.append("#/environment/canvasBacking: backing 尺寸必须等于 CSS 尺寸乘统一整数 scale")
|
||
if environment.get("freshContext") is True and environment.get("contextDestroyed") is not True:
|
||
errors.append("#/environment/contextDestroyed: 已创建的一次性 context 必须销毁后才能签发事实")
|
||
|
||
expected_support_mask_hash = (
|
||
((canonical_capability.get("config") or {}).get("selectionOverlay") or {})
|
||
.get("supportMaskHash")
|
||
)
|
||
compatible_evidence = (
|
||
probed_matches_registry
|
||
and lattice.get("dimensions") == canonical_geometry["dimensions"]
|
||
and lattice.get("geometryHash") == canonical_geometry_hash
|
||
and normalizer.get("status") == "normalized"
|
||
and normalizer.get("changedPixelCount") == 472
|
||
and normalizer.get("outsideSelectedCellCount") == 0
|
||
and isinstance(normalizer.get("maximumReplayResidual"), int)
|
||
and normalizer.get("maximumReplayResidual") <= 1
|
||
and normalizer.get("supportMaskHash") == expected_support_mask_hash
|
||
and event_interval.get("businessEventCount") == 0
|
||
and pre_seq == post_seq
|
||
)
|
||
result = instance.get("result")
|
||
reason = instance.get("reasonCode")
|
||
completed_fields = (
|
||
"initialFrame", "postFrame", "eventInterval", "selectedEndpoint", "lattice", "swapSet",
|
||
)
|
||
if result == "compatible":
|
||
if reason != "none":
|
||
errors.append("#/reasonCode: compatible 必须为 none")
|
||
if stage != "normalizer_completed" or any(instance.get(field) is None for field in completed_fields):
|
||
errors.append("#/executionStage: compatible 必须完成全部 preflight 证据阶段")
|
||
if environment.get("freshContext") is not True or environment.get("contextDestroyed") is not True \
|
||
or environment.get("contextIsolation") != "one-time-dedicated" \
|
||
or environment.get("browserVersion") is None \
|
||
or environment.get("runtimeIdentityHash") is None:
|
||
errors.append("#/environment: compatible 必须来自已销毁的一次性隔离 context")
|
||
if profile_error or not compatible_evidence:
|
||
errors.append("#/result: compatible 必须具备 canonical probe/geometry、472 support、零格外变化、残差≤1且首击无事件")
|
||
elif result == "incompatible":
|
||
if reason != "profile_contract_incompatible":
|
||
errors.append("#/reasonCode: incompatible 必须固定 profile_contract_incompatible")
|
||
if stage != "normalizer_completed" or any(instance.get(field) is None for field in completed_fields):
|
||
errors.append("#/executionStage: incompatible 必须完成全部 preflight 证据阶段")
|
||
if environment.get("freshContext") is not True or environment.get("contextDestroyed") is not True \
|
||
or environment.get("contextIsolation") != "one-time-dedicated" \
|
||
or environment.get("browserVersion") is None \
|
||
or environment.get("runtimeIdentityHash") is None:
|
||
errors.append("#/environment: incompatible 必须来自已销毁的一次性隔离 context")
|
||
if profile_error:
|
||
errors.append("#/result: capability 身份或 hash 漂移属于 tester_error,不得降为 incompatible")
|
||
if compatible_evidence:
|
||
errors.append("#/result: 已满足 producer contract 的证据不得标为 incompatible")
|
||
if normalizer.get("status") == "not_run":
|
||
errors.append("#/normalizerAudit/status: 未运行 normalizer 属于 tester_error,不得标为 incompatible")
|
||
elif result == "tester_error":
|
||
if normalizer.get("status") != "not_run":
|
||
errors.append("#/normalizerAudit/status: tester_error 必须在 normalizer 前 fail-closed")
|
||
if reason == "profile_contract_error":
|
||
if not profile_error:
|
||
errors.append("#/reasonCode: profile_contract_error 必须有 capability/registry/probe 身份错误")
|
||
if not capability_was_probed and expected_matches_registry and binding_matches:
|
||
errors.append("#/executionStage: probe 身份错误必须至少完成 capability_probed")
|
||
elif reason == "environment_error":
|
||
if profile_error:
|
||
errors.append("#/reasonCode: capability 身份错误必须归 profile_contract_error")
|
||
if normalizer.get("status") != "not_run":
|
||
errors.append("#/normalizerAudit/status: environment_error 必须为 not_run")
|
||
if stage == "normalizer_completed":
|
||
errors.append("#/executionStage: environment_error 不得声称 normalizer_completed")
|
||
elif reason == "board_perception_unresolved":
|
||
if profile_error:
|
||
errors.append("#/reasonCode: capability 身份错误必须归 profile_contract_error")
|
||
if stage != "initial_frame_captured" or instance.get("initialFrame") is None:
|
||
errors.append("#/executionStage: board_perception_unresolved 必须停在已封存首帧的阶段")
|
||
if any(instance.get(field) is not None for field in (
|
||
"postFrame", "eventInterval", "selectedEndpoint", "lattice", "swapSet")):
|
||
errors.append("#/reasonCode: 初始感知未解析不得伪造首击或后续证据")
|
||
else:
|
||
errors.append("#/reasonCode: tester_error 原因码不受支持")
|
||
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "factHash"}
|
||
expected_fact_hash = _canonical_domain_hash("match3-producer-preflight/1", content)
|
||
if instance.get("factHash") != expected_fact_hash:
|
||
errors.append("#/factHash: 与 canonical Match3ProducerPreflight 内容不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_interaction_binding(instance: dict) -> list:
|
||
"""bindingHash 必须覆盖 profile、registryVersion 与当前任务绑定。"""
|
||
errors = []
|
||
try:
|
||
registry = _load_interaction_registry(instance.get("interactionRegistryVersion"))
|
||
except ValueError as exc:
|
||
return [f"#/interactionRegistryVersion: {exc}"]
|
||
profile_id = instance.get("interactionProfileId")
|
||
if instance.get("interactionRegistryVersion") != registry.get("registryVersion"):
|
||
errors.append("#/interactionRegistryVersion: 必须与 canonical interaction registry 一致")
|
||
if profile_id not in (registry.get("profiles") or {}):
|
||
errors.append(f"#/interactionProfileId: 未知 canonical interaction profile {profile_id}")
|
||
content = {
|
||
"interactionProfileId": profile_id,
|
||
"interactionRegistryVersion": instance.get("interactionRegistryVersion"),
|
||
"taskBindingHash": instance.get("taskBindingHash"),
|
||
}
|
||
expected = _canonical_domain_hash("interaction-binding/1", content)
|
||
if instance.get("interactionBindingHash") != expected:
|
||
errors.append(
|
||
"#/interactionBindingHash: 必须覆盖 interactionProfileId/interactionRegistryVersion/taskBindingHash",
|
||
)
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_proof_resolution(instance: dict) -> list:
|
||
"""Resolution 必须按注册表顺序重建 required 集,并能复算语义 hash。"""
|
||
errors = []
|
||
registry = _load(_HERE / "proof-obligations.v2.json")
|
||
if instance.get("proofRegistryVersion") != registry.get("registryVersion"):
|
||
errors.append("#/proofRegistryVersion: 必须与 canonical proof registry 一致")
|
||
profile = (registry.get("profiles") or {}).get(instance.get("proofProfileId")) or {}
|
||
if not profile:
|
||
errors.append("#/proofProfileId: 未命中 canonical proof profile")
|
||
return errors
|
||
obligations = profile.get("obligations") or []
|
||
obligation_by_id = {row.get("id"): row for row in obligations}
|
||
brief_rules = {row.get("id"): row for row in profile.get("briefOptionalRules") or []}
|
||
interaction_rules = {
|
||
row.get("id"): row for row in profile.get("interactionOptionalRules") or []
|
||
}
|
||
brief_matches = instance.get("briefRuleMatches") or []
|
||
interaction_matches = instance.get("interactionRuleMatches") or []
|
||
for rule_id in brief_matches:
|
||
if rule_id not in brief_rules:
|
||
errors.append(f"#/briefRuleMatches: 未知 canonical brief rule {rule_id}")
|
||
for rule_id in interaction_matches:
|
||
if rule_id not in interaction_rules:
|
||
errors.append(f"#/interactionRuleMatches: 未知 canonical interaction rule {rule_id}")
|
||
if instance.get("interactionBindingStatus") == "absent" and interaction_matches:
|
||
errors.append("#/interactionRuleMatches: absent 时必须为空")
|
||
|
||
required = {row.get("id") for row in obligations if row.get("defaultRequired") is True}
|
||
for rule_id in brief_matches:
|
||
required.update((brief_rules.get(rule_id) or {}).get("requireObligationIds") or [])
|
||
for rule_id in interaction_matches:
|
||
required.update((interaction_rules.get(rule_id) or {}).get("requireObligationIds") or [])
|
||
expected = [row.get("id") for row in obligations if row.get("id") in required]
|
||
if instance.get("requiredObligationIds") != expected:
|
||
errors.append("#/requiredObligationIds: 必须按 profile obligations 原顺序保存确定性并集")
|
||
if any(oid not in obligation_by_id for oid in instance.get("requiredObligationIds") or []):
|
||
errors.append("#/requiredObligationIds: 含 canonical profile 不存在的义务")
|
||
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "resolutionHash"}
|
||
expected_hash = _canonical_domain_hash("proof-obligation-resolution/1", content)
|
||
if instance.get("resolutionHash") != expected_hash:
|
||
errors.append("#/resolutionHash: 与 canonical Resolution 内容不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_single_roll_proof_v1(instance: dict) -> list:
|
||
"""冻结旧包仍使用完整 v3 actionRecord,只读复用历史事实语义。"""
|
||
fact_view = deepcopy(instance)
|
||
fact_view["rollId"] = f"roll-{instance.get('roll')}"
|
||
fact_view["seedOptionSource"] = "requested-seed"
|
||
return _semantic_validate_single_roll_v3(fact_view)
|
||
|
||
|
||
def _semantic_validate_single_roll_proof_v2(instance: dict) -> list:
|
||
"""新内部包只验证 ProofAction 窄投影,禁止把 Actor/TargetSet/interaction 审计带入。"""
|
||
errors = _semantic_validate_environment(instance, wait_max_ms=600)
|
||
errors += _semantic_validate_acceptance_profile(instance)
|
||
synthetic_actions = []
|
||
for action in instance.get("actions") or []:
|
||
synthetic_actions.append({
|
||
"actionId": action.get("actionId"),
|
||
"normalized": action.get("normalized"),
|
||
"valid": True,
|
||
"retryCount": 0,
|
||
"preFrameRef": action.get("preFrameRef"),
|
||
"preEventSeq": action.get("preEventSeq"),
|
||
"dispatchReceipt": {"accepted": True, "wallTimeMs": 0},
|
||
"virtualTimeBudgetMs": action.get("virtualTimeBudgetMs"),
|
||
"timeAdvance": action.get("timeAdvance"),
|
||
"postFrameRef": action.get("postFrameRef"),
|
||
"postEventSeq": action.get("postEventSeq"),
|
||
"protocolErrors": [],
|
||
})
|
||
errors += _semantic_validate_playtest({
|
||
"phase": "COLLECTING", "acceptanceMode": "v3_shadow", "evidenceMode": "native",
|
||
"genre": instance.get("genre"), "templateRoute": instance.get("templateRoute"),
|
||
"proofProfileId": instance.get("proofProfileId"),
|
||
"proofRegistryVersion": instance.get("proofRegistryVersion"),
|
||
"acceptanceRequestHash": instance.get("acceptanceRequestHash"),
|
||
"artifactHash": instance.get("artifactHash"), "environment": instance.get("environment"),
|
||
"floor": {"pass": False, "gates": {}}, "actor": None, "judge": None,
|
||
"actions": synthetic_actions, "events": instance.get("events") or [],
|
||
"briefRuleMatches": instance.get("briefRuleMatches") or [],
|
||
"proofObligations": instance.get("proofObligations") or [],
|
||
"firstPlay": instance.get("firstPlay") or {}, "rolls": [],
|
||
}, wait_max_ms=600)
|
||
actor = instance.get("actor") or {}
|
||
for actor_field, top_field in (
|
||
("requestedGameSeed", "requestedSeed"),
|
||
("actualGameSeed", "actualSeed"),
|
||
("policySeed", "policySeed"),
|
||
):
|
||
if actor.get(actor_field) != instance.get(top_field):
|
||
errors.append(f"#/actor/{actor_field}: 必须镜像顶层 {top_field}")
|
||
if instance.get("requestedSeed") != instance.get("actualSeed"):
|
||
errors.append("#/actualSeed: 必须等于 requestedSeed")
|
||
if (instance.get("judge") or {}).get("packageHash") != instance.get("judgePackageHash"):
|
||
errors.append("#/judge/packageHash: 必须等于 judgePackageHash")
|
||
if instance.get("costRmb") != (instance.get("cost") or {}).get("costRmb"):
|
||
errors.append("#/costRmb: 必须与 cost.costRmb 一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_collection_proof(instance: dict) -> list:
|
||
"""CollectionProof 保存完整 runner 事实;基础语义与 playtest/3 动作、事件口径一致。"""
|
||
errors = _semantic_validate_environment(instance, wait_max_ms=600)
|
||
errors += _semantic_validate_acceptance_profile(instance)
|
||
if instance.get("requestedSeed") != instance.get("actualSeed"):
|
||
errors.append("#/actualSeed: 必须等于 requestedSeed")
|
||
if (instance.get("environment") or {}).get("buildRef") != instance.get("artifactHash"):
|
||
errors.append("#/environment/buildRef: 必须等于 artifactHash")
|
||
for index, action in enumerate(instance.get("actions") or []):
|
||
errors += _semantic_validate_v3_action(action, f"#/actions/{index}")
|
||
for index, event in enumerate(instance.get("events") or []):
|
||
errors += [f"#/events/{index}{error[1:]}" for error in _semantic_validate_game_event(event)]
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_judge_package(instance: dict) -> list:
|
||
"""JudgePackage 只允许事实投影,并机械复算文字引用范围与 brief hash。"""
|
||
errors = []
|
||
forbidden = {
|
||
"interactionBinding", "interactionRuleMatches", "requiredObligationIds",
|
||
"proofObligationResolutionRef", "proofObligationResolutionHash", "resolutionHash",
|
||
"interactionProfileId", "interactionRegistryVersion", "interactionBindingHash",
|
||
"solver", "BoardProjection", "SwapSet", "Match3BoardProjection", "Match3SwapSet",
|
||
"boardProjection", "match3BoardProjection", "swapSet", "candidateId",
|
||
"candidateSetHash", "beforeRuns", "newRuns", "classifier",
|
||
"interactionBindingFileHash", "interactionCandidates", "pairResolution",
|
||
"boardIdentityCheck", "actionGroup", "selectionAttempt", "groupedAction",
|
||
"targetSetHash", "targetSetRef", "guideManifestRef", "guideManifestHash",
|
||
}
|
||
|
||
def walk(value, path="#"):
|
||
if isinstance(value, dict):
|
||
for key, child in value.items():
|
||
if key in forbidden:
|
||
errors.append(f"{path}/{key}: JudgePackage 禁止泄漏 interaction/Resolution/solver 字段")
|
||
walk(child, f"{path}/{key}")
|
||
elif isinstance(value, list):
|
||
for index, child in enumerate(value):
|
||
walk(child, f"{path}/{index}")
|
||
|
||
# gameEvents[].payload 属于游戏业务命名空间,允许出现 candidateId/newRuns/solver 等同名字段;
|
||
# 只扫描 runner 自有投影,避免把业务事件误判为验收器内部身份泄漏。
|
||
for key in (
|
||
"registry", "proofObligations", "actions", "frames", "textReferenceScope",
|
||
"environment", "floor",
|
||
):
|
||
walk(instance.get(key), f"#/{key}")
|
||
for key in forbidden:
|
||
if key in instance:
|
||
errors.append(f"#/{key}: JudgePackage 禁止泄漏 interaction/Resolution/solver 字段")
|
||
if hashlib.sha256(str(instance.get("brief") or "").encode("utf-8")).hexdigest() != instance.get("briefHash"):
|
||
errors.append("#/briefHash: 必须等于 JudgePackage brief 原始 UTF-8 字节 hash")
|
||
registry = instance.get("registry") or {}
|
||
for field, top_field in (
|
||
("registryVersion", "proofRegistryVersion"), ("proofProfileId", "proofProfileId"),
|
||
("genre", "genre"), ("templateRoute", "templateRoute"),
|
||
):
|
||
if registry.get(field) != instance.get(top_field):
|
||
errors.append(f"#/registry/{field}: 必须镜像顶层 {top_field}")
|
||
expected_scope = {
|
||
"actionRefs": list(dict.fromkeys(
|
||
row.get("actionId") for row in instance.get("actions") or [] if row.get("actionId")
|
||
)),
|
||
"frameRefs": list(dict.fromkeys(
|
||
row.get("ref") for row in instance.get("frames") or [] if row.get("ref")
|
||
)),
|
||
"eventRefs": list(dict.fromkeys(
|
||
f"event:{row.get('seq')}" for row in instance.get("gameEvents") or []
|
||
if isinstance(row.get("seq"), int) and row.get("seq") >= 1
|
||
)),
|
||
}
|
||
if instance.get("textReferenceScope") != expected_scope:
|
||
errors.append("#/textReferenceScope: 必须由 actions/frames/gameEvents 按出现顺序确定性投影")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_match3_projection(instance: dict) -> list:
|
||
"""投影必须完整、行优先、匿名 classId 稳定,并能复算 projectionHash。"""
|
||
errors = []
|
||
dimensions = instance.get("dimensions") or {}
|
||
rows, columns = dimensions.get("rows"), dimensions.get("columns")
|
||
cells = instance.get("cells") or []
|
||
if isinstance(rows, int) and isinstance(columns, int) and len(cells) != rows * columns:
|
||
errors.append("#/cells: 必须完整覆盖 dimensions 声明的每一个棋格")
|
||
|
||
class_map = {}
|
||
next_class = 1
|
||
all_classified = True
|
||
for index, entry in enumerate(cells):
|
||
path = f"#/cells/{index}"
|
||
expected = {
|
||
"row": index // columns + 1 if isinstance(columns, int) and columns > 0 else None,
|
||
"column": index % columns + 1 if isinstance(columns, int) and columns > 0 else None,
|
||
}
|
||
if entry.get("cell") != expected:
|
||
errors.append(f"{path}/cell: cells 必须按行优先完整排列")
|
||
alternatives = entry.get("alternatives") or []
|
||
alternative_ids = [row.get("classId") for row in alternatives]
|
||
if len(alternative_ids) != len(set(alternative_ids)):
|
||
errors.append(f"{path}/alternatives: classId 不得重复")
|
||
sorted_alternatives = sorted(
|
||
alternatives, key=lambda row: (-row.get("confidencePermille", -1), row.get("classId", "")),
|
||
)
|
||
if alternatives != sorted_alternatives:
|
||
errors.append(f"{path}/alternatives: 必须按 confidencePermille 降序、classId 升序排列")
|
||
if entry.get("state") != "classified":
|
||
all_classified = False
|
||
continue
|
||
class_id = entry.get("classId")
|
||
if instance.get("interactionRegistryVersion") == "2026-07-15.v1":
|
||
if class_id not in class_map:
|
||
class_map[class_id] = f"vc{next_class:02d}"
|
||
next_class += 1
|
||
if class_map[class_id] != class_id:
|
||
errors.append(f"{path}/classId: v1 必须按行优先首次出现次序编号")
|
||
if instance.get("projectionStatus") == "resolved" and not all_classified:
|
||
errors.append("#/projectionStatus: resolved 要求全部棋格 classified")
|
||
|
||
registry = _load_interaction_registry(instance.get("interactionRegistryVersion"))
|
||
if instance.get("interactionRegistryVersion") != registry.get("registryVersion"):
|
||
errors.append("#/interactionRegistryVersion: 与 canonical interaction registry 不一致")
|
||
if instance.get("interactionProfileId") not in (registry.get("profiles") or {}):
|
||
errors.append("#/interactionProfileId: 未知 canonical interaction profile")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "projectionHash"}
|
||
expected_hash = _canonical_domain_hash("match3-board-projection/1", content)
|
||
if instance.get("projectionHash") != expected_hash:
|
||
errors.append("#/projectionHash: 与 canonical 投影内容不一致")
|
||
return errors
|
||
|
||
|
||
def _match3_cell_key(cell: dict) -> str:
|
||
return f"r{cell.get('row')}c{cell.get('column')}"
|
||
|
||
|
||
def _match3_run_key(run: dict) -> tuple:
|
||
return (
|
||
0 if run.get("axis") == "horizontal" else 1,
|
||
tuple((cell.get("row"), cell.get("column")) for cell in run.get("cells") or []),
|
||
run.get("classId"),
|
||
)
|
||
|
||
|
||
def _semantic_validate_match3_swap_set(instance: dict) -> list:
|
||
"""交换集校验 pair、run、稳定 ID、状态关系和两层 canonical hash。"""
|
||
errors = []
|
||
registry = _load_interaction_registry(instance.get("interactionRegistryVersion"))
|
||
profile = (registry.get("profiles") or {}).get(instance.get("interactionProfileId")) or {}
|
||
canonical_solver = profile.get("solver") or {}
|
||
solver = instance.get("solver") or {}
|
||
for field in ("id", "version", "configHash"):
|
||
if solver.get(field) != canonical_solver.get(field):
|
||
errors.append(f"#/solver/{field}: 必须与 canonical interaction profile 一致")
|
||
if instance.get("interactionRegistryVersion") != registry.get("registryVersion"):
|
||
errors.append("#/interactionRegistryVersion: 与 canonical interaction registry 不一致")
|
||
|
||
before_runs = instance.get("beforeRuns") or []
|
||
if before_runs != sorted(before_runs, key=_match3_run_key):
|
||
errors.append("#/beforeRuns: 必须按 axis/首格/classId 规范排序")
|
||
candidates = instance.get("candidates") or []
|
||
pair_keys = []
|
||
for index, candidate in enumerate(candidates):
|
||
path = f"#/candidates/{index}"
|
||
expected_id = f"ms{index + 1:02d}"
|
||
if candidate.get("candidateId") != expected_id:
|
||
errors.append(f"{path}/candidateId: 必须按规范 pair 顺序连续编号")
|
||
pair = candidate.get("pair") or []
|
||
if len(pair) == 2:
|
||
left, right = pair
|
||
if (left.get("row"), left.get("column")) >= (right.get("row"), right.get("column")):
|
||
errors.append(f"{path}/pair: 两端必须按 row/column 字典序保存")
|
||
distance = abs(left.get("row", 0) - right.get("row", 0)) + abs(
|
||
left.get("column", 0) - right.get("column", 0),
|
||
)
|
||
if distance != 1:
|
||
errors.append(f"{path}/pair: 两端必须正交相邻")
|
||
expected_pair_key = f"{_match3_cell_key(left)}-{_match3_cell_key(right)}"
|
||
if candidate.get("pairKey") != expected_pair_key:
|
||
errors.append(f"{path}/pairKey: 必须由规范 pair 逐字生成")
|
||
if len(set(candidate.get("beforeClassIds") or [])) != 2:
|
||
errors.append(f"{path}/beforeClassIds: 交换前两端必须属于不同匿名类别")
|
||
new_runs = candidate.get("newRuns") or []
|
||
if new_runs != sorted(new_runs, key=_match3_run_key):
|
||
errors.append(f"{path}/newRuns: 必须按 axis/首格/classId 规范排序")
|
||
for run_index, run in enumerate(new_runs):
|
||
run_path = f"{path}/newRuns/{run_index}"
|
||
cells = run.get("cells") or []
|
||
if not any(cell in pair for cell in cells):
|
||
errors.append(f"{run_path}/cells: 新 run 必须包含至少一个交换端点")
|
||
if run.get("axis") == "horizontal":
|
||
canonical = sorted(cells, key=lambda cell: (cell.get("column"), cell.get("row")))
|
||
if len({cell.get("row") for cell in cells}) != 1 or cells != canonical:
|
||
errors.append(f"{run_path}/cells: 横向 run 必须同 row 且按 column 排序")
|
||
elif any(
|
||
right.get("column") != left.get("column") + 1
|
||
for left, right in zip(cells, cells[1:])):
|
||
errors.append(f"{run_path}/cells: 横向 run 必须连续")
|
||
else:
|
||
canonical = sorted(cells, key=lambda cell: (cell.get("row"), cell.get("column")))
|
||
if len({cell.get("column") for cell in cells}) != 1 or cells != canonical:
|
||
errors.append(f"{run_path}/cells: 纵向 run 必须同 column 且按 row 排序")
|
||
elif any(
|
||
right.get("row") != left.get("row") + 1
|
||
for left, right in zip(cells, cells[1:])):
|
||
errors.append(f"{run_path}/cells: 纵向 run 必须连续")
|
||
pair_keys.append(candidate.get("pairKey"))
|
||
content = {key: deepcopy(value) for key, value in candidate.items() if key != "candidateHash"}
|
||
expected_hash = _canonical_domain_hash("match3-swap-candidate/1", content)
|
||
if candidate.get("candidateHash") != expected_hash:
|
||
errors.append(f"{path}/candidateHash: 与 canonical candidate 内容不一致")
|
||
if pair_keys != sorted(pair_keys, key=lambda key: tuple(
|
||
int(value) for value in re.findall(r"[0-9]+", key or ""))):
|
||
errors.append("#/candidates: 必须按规范 pair 数值顺序输出")
|
||
if len(pair_keys) != len(set(pair_keys)):
|
||
errors.append("#/candidates: 无序 pair 不得正反重复")
|
||
|
||
status = instance.get("status")
|
||
if status == "resolved" and not candidates:
|
||
errors.append("#/status: resolved 必须有非空 candidates")
|
||
if status in ("no_candidate", "indeterminate") and candidates:
|
||
errors.append(f"#/status: {status} 时 candidates 必须为空")
|
||
if status == "indeterminate" and before_runs:
|
||
errors.append("#/beforeRuns: indeterminate 时不得声称已可靠枚举交换前连续段")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "candidateSetHash"}
|
||
expected_hash = _canonical_domain_hash("match3-swap-set/1", content)
|
||
if instance.get("candidateSetHash") != expected_hash:
|
||
errors.append("#/candidateSetHash: 与 canonical SwapSet 内容不一致")
|
||
return errors
|
||
|
||
|
||
def _cell_tuple(cell: dict) -> tuple:
|
||
return cell.get("row"), cell.get("column")
|
||
|
||
|
||
def _semantic_validate_actor_view_v4(instance: dict) -> list:
|
||
"""ActorView/4 保持真实 View/3 source 绑定,并把普通路径与 M3 pair 路径互斥。"""
|
||
errors = []
|
||
catalog = instance.get("visualTargetCatalog") or {}
|
||
if catalog.get("sourceFrameRef") != instance.get("currentFrameRef"):
|
||
errors.append("#/visualTargetCatalog/sourceFrameRef: 必须镜像 currentFrameRef")
|
||
if catalog.get("sourceFrameHash") != instance.get("currentFrameHash"):
|
||
errors.append("#/visualTargetCatalog/sourceFrameHash: 必须镜像 currentFrameHash")
|
||
legal = instance.get("legalSelections") or {}
|
||
types = legal.get("types") or []
|
||
order = ["tap", "drag", "key", "wait", "swap_pair"]
|
||
if types != [item for item in order if item in types] or len(types) != len(set(types)):
|
||
errors.append("#/legalSelections/types: 必须按协议顺序去重")
|
||
safe_keys = legal.get("safeKeys") or []
|
||
if ("key" in types) != bool(safe_keys):
|
||
errors.append("#/legalSelections/safeKeys: 必须与 key 能力同时存在或同时为空")
|
||
candidates = instance.get("interactionCandidates")
|
||
if candidates is None:
|
||
if "swap_pair" in types:
|
||
errors.append("#/legalSelections/types: 普通 View/4 不得允许 swap_pair")
|
||
else:
|
||
if types != ["swap_pair"] or safe_keys:
|
||
errors.append("#/legalSelections: 标准 Match-3 resolved 只能允许 swap_pair")
|
||
rows = ((candidates.get("match3Swaps") or {}).get("candidates") or [])
|
||
ids = []
|
||
pair_keys = []
|
||
for index, row in enumerate(rows):
|
||
ids.append(row.get("candidateId"))
|
||
pair = [_cell_tuple(item.get("cell") or {}) for item in row.get("pair") or []]
|
||
if len(pair) == 2:
|
||
if pair != sorted(pair) or pair[0] == pair[1]:
|
||
errors.append(f"#/interactionCandidates/match3Swaps/candidates/{index}/pair: 必须规范排序")
|
||
if abs(pair[0][0] - pair[1][0]) + abs(pair[0][1] - pair[1][1]) != 1:
|
||
errors.append(f"#/interactionCandidates/match3Swaps/candidates/{index}/pair: 必须正交相邻")
|
||
pair_keys.append(tuple(pair))
|
||
if len(ids) != len(set(ids)):
|
||
errors.append("#/interactionCandidates/match3Swaps/candidates: candidateId 不得重复")
|
||
if pair_keys != sorted(pair_keys):
|
||
errors.append("#/interactionCandidates/match3Swaps/candidates: 必须按 pair 规范排序")
|
||
progress = instance.get("objectiveProgress")
|
||
if isinstance(progress, dict):
|
||
payload = {
|
||
"candidateOnly": progress.get("candidateOnly"),
|
||
"snapshotSeq": progress.get("snapshotSeq"),
|
||
"obligations": progress.get("obligations"),
|
||
}
|
||
canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||
if progress.get("hash") != hashlib.sha256(canonical.encode("utf-8")).hexdigest():
|
||
errors.append("#/objectiveProgress/hash: 与稳定投影不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_actor_protocol_bundle(instance: dict) -> list:
|
||
"""联合锁定 Prompt、View、Selection 与 validator 代际,并校验当前可见候选。"""
|
||
errors = []
|
||
view = instance.get("actorView") or {}
|
||
selection = instance.get("selection") or {}
|
||
prompt = instance.get("prompt") or {}
|
||
validator = instance.get("validator") or {}
|
||
view_version = view.get("projectionVersion")
|
||
selection_version = selection.get("schemaVersion")
|
||
errors += _semantic_validate_actor_view_v4(view)
|
||
if view_version == "ActorView/3":
|
||
expected = {
|
||
"selection": "ActorSelection/1", "promptVersion": "2.4.0",
|
||
"validatorId": "actor-selection", "validatorVersion": "1.0.0",
|
||
}
|
||
else:
|
||
profile = (_load(_HERE / "interaction-profiles.v2.json").get("profiles") or {})[
|
||
"match3.orthogonal-swap-v1"
|
||
]
|
||
protocol = profile.get("actorProtocol") or {}
|
||
expected = {
|
||
"selection": protocol.get("selectionVersion"),
|
||
"promptVersion": (protocol.get("prompt") or {}).get("version"),
|
||
"validatorId": (protocol.get("parser") or {}).get("id"),
|
||
"validatorVersion": (protocol.get("parser") or {}).get("version"),
|
||
}
|
||
if prompt != protocol.get("prompt"):
|
||
errors.append("#/prompt: View/4 必须逐字使用 v2 registry prompt identity")
|
||
if selection_version != expected["selection"]:
|
||
errors.append("#/selection/schemaVersion: 与 ActorView 代际不匹配")
|
||
if prompt.get("version") != expected["promptVersion"]:
|
||
errors.append("#/prompt/version: 与 ActorView 代际不匹配")
|
||
if validator.get("id") != expected["validatorId"] or validator.get("version") != expected["validatorVersion"]:
|
||
errors.append("#/validator: 与 ActorView 代际不匹配")
|
||
if validator.get("selectionSchemaVersion") != selection_version:
|
||
errors.append("#/validator/selectionSchemaVersion: 必须镜像 selection")
|
||
errors += _semantic_validate_actor_selection_context({"actorView": view, "selection": selection})
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_actor_selection_context(instance: dict) -> list:
|
||
"""联合校验 View/4 可见能力与 Selection/2,不允许普通动作穿过 M3 专用门。"""
|
||
errors = []
|
||
view = instance.get("actorView") or {}
|
||
selection = instance.get("selection") or {}
|
||
candidates = view.get("interactionCandidates")
|
||
selection_type = selection.get("type")
|
||
legal_types = (view.get("legalSelections") or {}).get("types") or []
|
||
if selection_type not in legal_types:
|
||
errors.append("#/selection/type: 不在 ActorView legalSelections 中")
|
||
catalog_hash = (view.get("visualTargetCatalog") or {}).get("targetSetHash")
|
||
if selection_type in ("tap", "drag", "swap_pair") and selection.get("targetSetHash") != catalog_hash:
|
||
errors.append("#/selection/targetSetHash: 与 ActorView 当前目录不一致")
|
||
if candidates is None:
|
||
if selection_type == "swap_pair":
|
||
errors.append("#/selection/type: 普通 View/4 不得返回 swap_pair")
|
||
if selection_type == "key" and selection.get("key") not in (view.get("legalSelections") or {}).get("safeKeys", []):
|
||
errors.append("#/selection/key: 不在 ActorView safeKeys 中")
|
||
return errors
|
||
if selection_type != "swap_pair":
|
||
errors.append("#/selection/type: 标准 Match-3 View/4 只能返回 swap_pair")
|
||
return errors
|
||
swaps = candidates.get("match3Swaps") or {}
|
||
if selection.get("candidateSetHash") != swaps.get("candidateSetHash"):
|
||
errors.append("#/selection/candidateSetHash: 已过期或与 ActorView 不一致")
|
||
candidate = next(
|
||
(row for row in swaps.get("candidates") or [] if row.get("candidateId") == selection.get("candidateId")),
|
||
None,
|
||
)
|
||
if candidate is None:
|
||
errors.append("#/selection/candidateId: 未命中 ActorView 当前候选")
|
||
return errors
|
||
endpoint = selection.get("firstEndpoint") or {}
|
||
if endpoint.get("id") != candidate.get("latticeId"):
|
||
errors.append("#/selection/firstEndpoint/id: 必须等于候选 latticeId")
|
||
pair = [(item.get("cell") or {}) for item in candidate.get("pair") or []]
|
||
if endpoint.get("cell") not in pair:
|
||
errors.append("#/selection/firstEndpoint/cell: 必须命中所选 candidate 的一个端点")
|
||
if endpoint.get("subAnchor") != "center":
|
||
errors.append("#/selection/firstEndpoint/subAnchor: 必须为 center")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_selection_attempt(instance: dict) -> list:
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "attemptHash"}
|
||
expected = _canonical_domain_hash("actor-selection-attempt/1", content)
|
||
return [] if instance.get("attemptHash") == expected else ["#/attemptHash: 与 canonical attempt 内容不一致"]
|
||
|
||
|
||
def _semantic_validate_pair_resolution(instance: dict) -> list:
|
||
"""PairResolution 必须保存规范 pair,第二端只能是第一端之外的另一端。"""
|
||
errors = []
|
||
pair = instance.get("pair") or []
|
||
tuples = [_cell_tuple(cell) for cell in pair]
|
||
if len(tuples) == 2:
|
||
if tuples != sorted(tuples) or tuples[0] == tuples[1]:
|
||
errors.append("#/pair: 必须规范排序且端点不同")
|
||
if abs(tuples[0][0] - tuples[1][0]) + abs(tuples[0][1] - tuples[1][1]) != 1:
|
||
errors.append("#/pair: 必须正交相邻")
|
||
first = instance.get("firstEndpoint") or {}
|
||
second = instance.get("secondEndpoint") or {}
|
||
if first.get("id") != instance.get("latticeId") or second.get("id") != instance.get("latticeId"):
|
||
errors.append("#/latticeId: 两端必须属于同一候选 lattice")
|
||
if first.get("cell") not in pair:
|
||
errors.append("#/firstEndpoint/cell: 不属于 canonical pair")
|
||
expected_second = next((cell for cell in pair if cell != first.get("cell")), None)
|
||
if second.get("cell") != expected_second:
|
||
errors.append("#/secondEndpoint/cell: 必须由 candidate 确定为另一端")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "resolutionHash"}
|
||
if instance.get("resolutionHash") != _canonical_domain_hash("match3-pair-resolution/1", content):
|
||
errors.append("#/resolutionHash: 与 canonical pair resolution 不一致")
|
||
return errors
|
||
|
||
|
||
def _validate_lattice_geometry(lattice: dict, path: str) -> list:
|
||
errors = []
|
||
dimensions = lattice.get("dimensions") or {}
|
||
rows = lattice.get("rowCenters") or []
|
||
columns = lattice.get("columnCenters") or []
|
||
if len(rows) != dimensions.get("rows") or rows != sorted(set(rows)):
|
||
errors.append(f"{path}/rowCenters: 必须与 rows 等长且严格递增")
|
||
if len(columns) != dimensions.get("columns") or columns != sorted(set(columns)):
|
||
errors.append(f"{path}/columnCenters: 必须与 columns 等长且严格递增")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_board_identity(instance: dict) -> list:
|
||
"""单文件只验证封存形状;跨文件 bundle 负责从 TargetSet/Projection 重算等价分区。"""
|
||
errors = []
|
||
registry = _load(_HERE / "interaction-profiles.v2.json")
|
||
profile = (registry.get("profiles") or {}).get(instance.get("interactionProfileId")) or {}
|
||
if instance.get("interactionRegistryVersion") != registry.get("registryVersion"):
|
||
errors.append("#/interactionRegistryVersion: BoardIdentity 必须使用 v2 registry")
|
||
if instance.get("overlayNormalizer") != profile.get("overlayNormalizer"):
|
||
errors.append("#/overlayNormalizer: 必须逐字镜像 v2 registry")
|
||
if instance.get("equivalenceAlgorithm") != profile.get("equivalenceAlgorithm"):
|
||
errors.append("#/equivalenceAlgorithm: 必须逐字镜像 v2 registry")
|
||
for side in ("initial", "postFirst"):
|
||
errors += _validate_lattice_geometry((instance.get(side) or {}).get("lattice") or {}, f"#/{side}/lattice")
|
||
initial = instance.get("initial") or {}
|
||
post = instance.get("postFirst") or {}
|
||
normalization = instance.get("overlayNormalizationEvidence") or {}
|
||
if normalization.get("overlayReferenceFrameRef") != initial.get("sourceFrameRef") \
|
||
or normalization.get("overlayReferenceFrameHash") != initial.get("sourceFrameHash"):
|
||
errors.append("#/overlayNormalizationEvidence: overlay reference 必须绑定 initial frame")
|
||
if normalization.get("postFirstFrameRef") != post.get("sourceFrameRef") \
|
||
or normalization.get("postFirstFrameHash") != post.get("sourceFrameHash"):
|
||
errors.append("#/overlayNormalizationEvidence: post-first frame 必须绑定 postFirst")
|
||
if normalization.get("selectedCell") != (instance.get("firstEndpoint") or {}).get("cell"):
|
||
errors.append("#/overlayNormalizationEvidence/selectedCell: 必须镜像 firstEndpoint.cell")
|
||
same = (
|
||
(initial.get("lattice") or {}).get("dimensions") == (post.get("lattice") or {}).get("dimensions")
|
||
and (initial.get("lattice") or {}).get("rowCenters") == (post.get("lattice") or {}).get("rowCenters")
|
||
and (initial.get("lattice") or {}).get("columnCenters") == (post.get("lattice") or {}).get("columnCenters")
|
||
and initial.get("equivalenceMatrixHash") == post.get("equivalenceMatrixHash")
|
||
and initial.get("equivalenceMatrixHash") is not None
|
||
)
|
||
if instance.get("result") == "equivalent" and not same:
|
||
errors.append("#/result: equivalent 要求 geometry 与匿名等价矩阵一致")
|
||
if instance.get("result") == "equivalent" and (
|
||
normalization.get("status") not in ("normalized", "not-needed")
|
||
or normalization.get("outsideSelectedCellCount") != 0
|
||
or normalization.get("reasonCodes") != []
|
||
):
|
||
errors.append("#/result: equivalent 要求归一化证据已解决且无越界像素")
|
||
if instance.get("result") == "changed" and same:
|
||
errors.append("#/result: changed 必须存在 geometry 或等价分区变化")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "checkHash"}
|
||
if instance.get("checkHash") != _canonical_domain_hash("board-identity-check/1", content):
|
||
errors.append("#/checkHash: 与 canonical BoardIdentityCheck 不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_action_group(instance: dict) -> list:
|
||
errors = []
|
||
pair = instance.get("pair") or []
|
||
tuples = [_cell_tuple(cell) for cell in pair]
|
||
if len(tuples) == 2 and (tuples != sorted(tuples) or tuples[0] == tuples[1]):
|
||
errors.append("#/pair: 必须规范排序且端点不同")
|
||
first = instance.get("firstEndpoint") or {}
|
||
second = instance.get("secondEndpoint") or {}
|
||
if first.get("id") != instance.get("latticeId") or second.get("id") != instance.get("latticeId"):
|
||
errors.append("#/latticeId: 两端必须属于同一 lattice")
|
||
if first.get("cell") not in pair or second.get("cell") not in pair or first.get("cell") == second.get("cell"):
|
||
errors.append("#/firstEndpoint: 两端必须逐一覆盖 canonical pair")
|
||
first_action = instance.get("firstActionId")
|
||
second_action = instance.get("secondActionId")
|
||
if first_action is not None and first_action == second_action:
|
||
errors.append("#/secondActionId: 两个原生 actionId 必须不同")
|
||
state = instance.get("state")
|
||
refs = (
|
||
instance.get("firstGroupedActionRef"), instance.get("firstGroupedActionHash"),
|
||
instance.get("secondGroupedActionRef"), instance.get("secondGroupedActionHash"),
|
||
instance.get("boardIdentityCheckRef"), instance.get("boardIdentityCheckHash"),
|
||
)
|
||
if state == "awaiting_first" and (first_action is not None or second_action is not None or any(refs)):
|
||
errors.append("#/state: awaiting_first 不得提前封存动作或 BoardIdentity")
|
||
if state == "awaiting_second" and (
|
||
first_action is None or second_action is not None or not all(refs[:2] + refs[4:]) or any(refs[2:4])
|
||
):
|
||
errors.append("#/state: awaiting_second 必须只有 first action 与 BoardIdentity")
|
||
if state == "completed" and (
|
||
first_action is None or second_action is None or not all(refs) or instance.get("abortReason") is not None
|
||
):
|
||
errors.append("#/state: completed 必须闭合双 tap、BoardIdentity 且无 abortReason")
|
||
if state == "aborted" and (instance.get("abortReason") is None or second_action is not None or any(refs[2:4])):
|
||
errors.append("#/state: aborted 必须有原因且不得派发 second action")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "actionGroupHash"}
|
||
if instance.get("actionGroupHash") != _canonical_domain_hash("match3-action-group/1", content):
|
||
errors.append("#/actionGroupHash: 与 canonical actionGroup 不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_roll_audit(instance: dict) -> list:
|
||
errors = []
|
||
groups = instance.get("groups") or []
|
||
reset = instance.get("reset")
|
||
recovery = (((_load(_HERE / "interaction-profiles.v2.json").get("profiles") or {})
|
||
.get(instance.get("interactionProfileId")) or {}).get("recoveryPolicy") or {})
|
||
expected_budget = recovery.get("maxActionGroupsPerRoll", 0) * 2 + recovery.get("maxResetPerRoll", 0)
|
||
if instance.get("initialRemainingBudget") != expected_budget:
|
||
errors.append("#/initialRemainingBudget: 必须由 v2 recoveryPolicy 确定性计算")
|
||
if len({row.get("actionGroupId") for row in groups}) != len(groups):
|
||
errors.append("#/groups: actionGroupId 不得重复")
|
||
if reset is None and len(groups) > 1:
|
||
errors.append("#/reset: 两个 actionGroup 之间必须有一次授权 reset")
|
||
if reset is not None:
|
||
if len(groups) != 2 or (reset.get("betweenActionGroupIds") or []) != [
|
||
groups[0].get("actionGroupId"), groups[1].get("actionGroupId"),
|
||
]:
|
||
errors.append("#/reset/betweenActionGroupIds: reset 必须位于两条 sealed group 之间")
|
||
if groups[0].get("state") != "aborted":
|
||
errors.append("#/groups/0/state: 只有首组 aborted 才允许 reset")
|
||
if reset.get("resultingCandidateSetHash") == groups[0].get("candidateSetHash"):
|
||
errors.append("#/reset/resultingCandidateSetHash: reset 后必须生成新 candidateSet 身份")
|
||
if groups[1].get("candidateSetHash") != reset.get("resultingCandidateSetHash"):
|
||
errors.append("#/groups/1/candidateSetHash: 必须使用 reset 后的新集合")
|
||
executed_actions = sum(2 if row.get("state") == "completed" else 1 for row in groups) + (1 if reset else 0)
|
||
if executed_actions > expected_budget:
|
||
errors.append("#/groups: 实际 group/reset 原生动作数超过 recoveryPolicy 预算")
|
||
if instance.get("finalState") == "completed" and groups[-1].get("state") != "completed":
|
||
errors.append("#/finalState: completed 必须来自最后 actionGroup completed")
|
||
if len(groups) == 2 and groups[-1].get("state") == "aborted" \
|
||
and instance.get("finalState") != "inconclusive_board_identity_unresolved":
|
||
errors.append("#/finalState: 第二 actionGroup aborted 必须固定 inconclusive")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "auditHash"}
|
||
if instance.get("auditHash") != _canonical_domain_hash("match3-roll-interaction-audit/1", content):
|
||
errors.append("#/auditHash: 与 canonical roll interaction audit 不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_match3_audio_audit(instance: dict) -> list:
|
||
"""复算完整 limiter 后窗口账;空账只表示尚未启音,不能进入正式 Audit/2。"""
|
||
errors = []
|
||
samples = instance.get("samples") or []
|
||
capacity = instance.get("capacity")
|
||
if isinstance(capacity, int) and len(samples) > capacity:
|
||
errors.append("#/samples: 样本数不得超过 capacity")
|
||
if not samples:
|
||
for field in (
|
||
"auditId", "themeHash", "configHash", "audioArtifactBinding", "runtimeBinding",
|
||
"firstWindowSeq", "lastWindowSeq",
|
||
):
|
||
if instance.get(field) is not None:
|
||
errors.append(f"#/{field}: 空音频账必须为 null")
|
||
if instance.get("droppedWindowCount") != 0:
|
||
errors.append("#/droppedWindowCount: 空音频账必须为 0")
|
||
if instance.get("pendingOnsetCueReceiptRefs"):
|
||
errors.append("#/pendingOnsetCueReceiptRefs: 空音频账不得伪造 pending cue")
|
||
return errors
|
||
|
||
runtime = instance.get("runtimeBinding") or {}
|
||
artifact = instance.get("audioArtifactBinding") or {}
|
||
expected_audit_id = _canonical_domain_hash("match3-audio-audit/1", {
|
||
"themeHash": instance.get("themeHash"),
|
||
"configHash": instance.get("configHash"),
|
||
"audioArtifactBinding": artifact,
|
||
"plugin": {
|
||
"name": runtime.get("pluginName"),
|
||
"version": runtime.get("pluginVersion"),
|
||
},
|
||
"auditTapId": runtime.get("auditTapId"),
|
||
})
|
||
if instance.get("auditId") != expected_audit_id:
|
||
errors.append("#/auditId: 未绑定 theme/config/已验证 BGM 原始字节/plugin/auditTap")
|
||
|
||
first_seq = samples[0].get("windowSeq")
|
||
last_seq = samples[-1].get("windowSeq")
|
||
if instance.get("firstWindowSeq") != first_seq:
|
||
errors.append("#/firstWindowSeq: 必须等于首个完整窗口序号")
|
||
if instance.get("lastWindowSeq") != last_seq:
|
||
errors.append("#/lastWindowSeq: 必须等于末个完整窗口序号")
|
||
if isinstance(first_seq, int) and first_seq != instance.get("droppedWindowCount", 0) + 1:
|
||
errors.append("#/droppedWindowCount: 必须与 ring 丢弃后的首窗口序号闭合")
|
||
|
||
previous = None
|
||
global_cues = [
|
||
cue
|
||
for sample in samples
|
||
for cue in (sample.get("cueReceiptRefs") or [])
|
||
]
|
||
global_cue_seqs = [cue.get("cueSeq") for cue in global_cues]
|
||
if global_cue_seqs != sorted(set(global_cue_seqs)):
|
||
errors.append("#/samples: cue receipt 必须在整份审计账中唯一升序封存")
|
||
global_transition_seqs = [
|
||
transition.get("transitionSeq")
|
||
for sample in samples
|
||
for transition in (sample.get("transitionRefs") or [])
|
||
]
|
||
if global_transition_seqs != sorted(set(global_transition_seqs)):
|
||
errors.append("#/samples: transition 必须在整份审计账中唯一升序封存")
|
||
onset_check_rows = []
|
||
for index, sample in enumerate(samples):
|
||
path = f"#/samples/{index}"
|
||
if sample.get("windowSeq") != first_seq + index:
|
||
errors.append(f"{path}/windowSeq: 必须从 firstWindowSeq 严格连续")
|
||
if sample.get("auditTapId") != runtime.get("auditTapId"):
|
||
errors.append(f"{path}/auditTapId: 必须镜像 runtimeBinding.auditTapId")
|
||
if sample.get("nodeIds") != runtime.get("nodeIds"):
|
||
errors.append(f"{path}/nodeIds: 必须逐字镜像 runtimeBinding.nodeIds")
|
||
start = sample.get("windowStartAudioTime")
|
||
end = sample.get("windowEndAudioTime")
|
||
fft_size = sample.get("fftSize")
|
||
sample_rate = sample.get("sampleRate")
|
||
if all(isinstance(value, (int, float)) for value in (start, end, fft_size, sample_rate)) \
|
||
and sample_rate > 0 \
|
||
and not math.isclose(end - start, fft_size / sample_rate, rel_tol=0, abs_tol=1e-6):
|
||
# runtime 在 AudioContext 积满一个 fft 窗前返回 null,因此正式账中每个窗都必须完整。
|
||
errors.append(f"{path}: 音频窗边界必须等于 fftSize/sampleRate")
|
||
if isinstance(end, (int, float)) and not math.isclose(
|
||
sample.get("audioTime", -1), end, rel_tol=0, abs_tol=1e-9,
|
||
):
|
||
errors.append(f"{path}/audioTime: 必须等于 windowEndAudioTime")
|
||
noise_floor = sample.get("noiseFloorDbfs")
|
||
threshold = sample.get("onsetThresholdDbfs")
|
||
if isinstance(noise_floor, (int, float)) and isinstance(threshold, (int, float)) \
|
||
and not math.isclose(threshold, max(noise_floor + 12, -45), rel_tol=0, abs_tol=1e-9):
|
||
errors.append(f"{path}/onsetThresholdDbfs: 必须为 max(noiseFloor+12dB, -45dBFS)")
|
||
rms, peak = sample.get("rms"), sample.get("peak")
|
||
if isinstance(rms, (int, float)) and isinstance(peak, (int, float)) and peak < rms:
|
||
errors.append(f"{path}/peak: peak 不得小于 RMS")
|
||
if sample.get("muted") is True and (rms != 0 or peak != 0):
|
||
errors.append(f"{path}: 静音只能由 limiter 后 auditTap 的零 RMS/peak 证明")
|
||
transition_refs = sample.get("transitionRefs") or []
|
||
transition_seqs = [row.get("transitionSeq") for row in transition_refs]
|
||
if transition_seqs != sorted(set(transition_seqs)):
|
||
errors.append(f"{path}/transitionRefs: transitionSeq 必须唯一升序")
|
||
if any(seq > sample.get("lastTransitionSeq", -1) for seq in transition_seqs):
|
||
errors.append(f"{path}/lastTransitionSeq: 不得小于窗口引用的 transition")
|
||
cue_refs = sample.get("cueReceiptRefs") or []
|
||
cue_seqs = [row.get("cueSeq") for row in cue_refs]
|
||
if cue_seqs != sorted(set(cue_seqs)):
|
||
errors.append(f"{path}/cueReceiptRefs: cueSeq 必须唯一升序")
|
||
if any(seq > sample.get("lastCueSeq", -1) for seq in cue_seqs):
|
||
errors.append(f"{path}/lastCueSeq: 不得小于窗口引用的 cue receipt")
|
||
if any(row.get("transitionSeq") not in transition_seqs for row in cue_refs):
|
||
errors.append(f"{path}/cueReceiptRefs: 每个 cue 必须引用同窗 transition")
|
||
transition_by_seq = {row.get("transitionSeq"): row for row in transition_refs}
|
||
expected_cue_kind = {
|
||
"swap": "swap", "swap_forward": "swap", "rollback": "rollback",
|
||
"clear": "clear", "fall_refill": "fall",
|
||
}
|
||
for cue_index, cue in enumerate(cue_refs):
|
||
transition = transition_by_seq.get(cue.get("transitionSeq")) or {}
|
||
for field in (
|
||
"transactionId", "phase", "cascadeIndex", "transitionVirtualTimeMs",
|
||
):
|
||
if cue.get(field) != transition.get(field):
|
||
errors.append(f"{path}/cueReceiptRefs/{cue_index}/{field}: 必须镜像完整 transition")
|
||
cue_kind = expected_cue_kind.get(transition.get("phase"))
|
||
if transition.get("phase") == "settle" \
|
||
and transition.get("terminalKind") in ("win", "lose"):
|
||
cue_kind = f"terminal-{transition.get('terminalKind')}"
|
||
if cue.get("cueKind") != cue_kind:
|
||
errors.append(f"{path}/cueReceiptRefs/{cue_index}/cueKind: 与 transition phase/terminal 不一致")
|
||
expected_late = max(0, (cue.get("startCallAudioTime", 0)
|
||
- cue.get("scheduledAudioTime", 0)) * 1000)
|
||
if not math.isclose(cue.get("lateByMs", -1), expected_late, rel_tol=0, abs_tol=1e-6):
|
||
errors.append(f"{path}/cueReceiptRefs/{cue_index}/lateByMs: 不能由调度时间复算")
|
||
for check in sample.get("onsetChecks") or []:
|
||
onset_check_rows.append((sample.get("windowSeq"), check))
|
||
if previous is not None:
|
||
if sample.get("frame", -1) < previous.get("frame", -1):
|
||
errors.append(f"{path}/frame: 必须单调不减")
|
||
if sample.get("virtualNowMs", -1) < previous.get("virtualNowMs", -1):
|
||
errors.append(f"{path}/virtualNowMs: 必须单调不减")
|
||
if start < previous.get("windowStartAudioTime", -1):
|
||
errors.append(f"{path}/windowStartAudioTime: 必须单调不减")
|
||
if sample.get("lastTransitionSeq", -1) < previous.get("lastTransitionSeq", -1):
|
||
errors.append(f"{path}/lastTransitionSeq: 必须跨窗单调不减")
|
||
if sample.get("lastCueSeq", -1) < previous.get("lastCueSeq", -1):
|
||
errors.append(f"{path}/lastCueSeq: 必须跨窗单调不减")
|
||
previous = sample
|
||
|
||
def cue_identity(value: dict) -> tuple:
|
||
return (
|
||
value.get("cueSeq"), value.get("transitionSeq"), value.get("cueSemanticHash"),
|
||
)
|
||
|
||
cue_by_identity = {cue_identity(cue): cue for cue in global_cues}
|
||
window_by_seq = {sample.get("windowSeq"): sample for sample in samples}
|
||
closed_identities = []
|
||
for owner_seq, check in onset_check_rows:
|
||
identity = cue_identity(check.get("cueReceiptRef") or {})
|
||
closed_identities.append(identity)
|
||
cue = cue_by_identity.get(identity)
|
||
if cue is None:
|
||
errors.append("#/samples/onsetChecks/cueReceiptRef: 未命中完整 cue ledger")
|
||
continue
|
||
expected_deadline = cue.get("scheduledAudioTime", 0) + 0.05
|
||
if not math.isclose(
|
||
check.get("deadlineAudioTime", -1), expected_deadline, rel_tol=0, abs_tol=1e-9,
|
||
):
|
||
errors.append("#/samples/onsetChecks/deadlineAudioTime: 必须等于 scheduledAudioTime+50ms")
|
||
if check.get("status") == "detected":
|
||
first_seq = check.get("firstAboveWindowSeq")
|
||
confirmation_seq = check.get("confirmationWindowSeq")
|
||
first_window = window_by_seq.get(first_seq)
|
||
confirmation_window = window_by_seq.get(confirmation_seq)
|
||
if not isinstance(first_seq, int) or not isinstance(confirmation_seq, int) \
|
||
or confirmation_seq != first_seq + 1 or owner_seq != confirmation_seq:
|
||
errors.append("#/samples/onsetChecks: detected 必须在连续第二窗确认并封存")
|
||
if first_window is None or confirmation_window is None:
|
||
errors.append("#/samples/onsetChecks: detected 引用的连续两窗必须仍在审计账内")
|
||
continue
|
||
first_end = first_window.get("windowEndAudioTime")
|
||
if first_end < cue.get("scheduledAudioTime", 0) \
|
||
or first_end > check.get("deadlineAudioTime", -1):
|
||
errors.append("#/samples/onsetChecks: 首个过阈窗必须落在 cue 调度后的 50ms 内")
|
||
if not math.isclose(
|
||
check.get("analyserOnsetAudioTime", -1), first_end, rel_tol=0, abs_tol=1e-9,
|
||
):
|
||
errors.append("#/samples/onsetChecks/analyserOnsetAudioTime: 必须等于首个过阈窗 end")
|
||
for window in (first_window, confirmation_window):
|
||
window_rms = window.get("rms")
|
||
window_db = 20 * math.log10(max(window_rms, 1e-12)) \
|
||
if isinstance(window_rms, (int, float)) else -240
|
||
if window.get("onsetDeltaDb", -999) < 12 \
|
||
or window_db < window.get("onsetThresholdDbfs", -45):
|
||
errors.append("#/samples/onsetChecks: detected 的连续两窗未达到 cue onset 阈值")
|
||
break
|
||
else:
|
||
for field in (
|
||
"firstAboveWindowSeq", "confirmationWindowSeq", "analyserOnsetAudioTime",
|
||
):
|
||
if check.get(field) is not None:
|
||
errors.append(f"#/samples/onsetChecks/{field}: missed 时必须为 null")
|
||
owner = window_by_seq.get(owner_seq) or {}
|
||
if owner.get("windowEndAudioTime", -1) < check.get("deadlineAudioTime", 0):
|
||
errors.append("#/samples/onsetChecks: missed 只能在 deadline 到达后封存")
|
||
|
||
pending = instance.get("pendingOnsetCueReceiptRefs") or []
|
||
pending_identities = [cue_identity(row) for row in pending]
|
||
for row in pending:
|
||
identity = cue_identity(row)
|
||
cue = cue_by_identity.get(identity)
|
||
if cue is None:
|
||
errors.append("#/pendingOnsetCueReceiptRefs: 未命中完整 cue ledger")
|
||
continue
|
||
expected_deadline = cue.get("scheduledAudioTime", 0) + 0.05
|
||
if not math.isclose(row.get("deadlineAudioTime", -1), expected_deadline, rel_tol=0, abs_tol=1e-9):
|
||
errors.append("#/pendingOnsetCueReceiptRefs/deadlineAudioTime: 必须等于 scheduledAudioTime+50ms")
|
||
if samples[-1].get("windowEndAudioTime", 0) >= row.get("deadlineAudioTime", 0):
|
||
errors.append("#/pendingOnsetCueReceiptRefs: deadline 已到的 cue 不得继续 pending")
|
||
if len(set(closed_identities + pending_identities)) != len(closed_identities + pending_identities):
|
||
errors.append("#/samples: 同一 cue receipt 不得重复进入 onset check/pending")
|
||
if set(closed_identities + pending_identities) != set(cue_by_identity):
|
||
errors.append("#/samples: 每个完整 cue receipt 必须恰好进入 onset check 或 pending")
|
||
|
||
# 完成事务的视觉阶段是音频闭包的权威输入。每个应发声 transition 必须恰好生成一张
|
||
# 有实际声部的 receipt;中间观察态允许 pending,全部关闭后才检查真实 onset 质量。
|
||
transitions = [
|
||
transition
|
||
for sample in samples
|
||
for transition in (sample.get("transitionRefs") or [])
|
||
]
|
||
cues_by_transition = {}
|
||
for cue in global_cues:
|
||
cues_by_transition.setdefault(cue.get("transitionSeq"), []).append(cue)
|
||
checks_by_identity = {
|
||
cue_identity(check.get("cueReceiptRef") or {}): check
|
||
for _, check in onset_check_rows
|
||
}
|
||
transactions = {}
|
||
for transition in transitions:
|
||
key = (transition.get("transactionId"), transition.get("transactionHash"))
|
||
transactions.setdefault(key, []).append(transition)
|
||
audible_phases = {"swap", "swap_forward", "rollback", "clear", "fall_refill"}
|
||
for (transaction_id, _), transaction_transitions in transactions.items():
|
||
if not any(row.get("phase") == "settle" for row in transaction_transitions):
|
||
continue
|
||
expected = [
|
||
row for row in transaction_transitions
|
||
if row.get("phase") in audible_phases
|
||
or (row.get("phase") == "settle" and row.get("terminalKind") in ("win", "lose"))
|
||
]
|
||
transaction_cues = []
|
||
for transition in expected:
|
||
matched = cues_by_transition.get(transition.get("transitionSeq"), [])
|
||
if len(matched) != 1:
|
||
errors.append(
|
||
"#/samples: 完成事务的每个应发声 transition 必须恰好有一张 cue receipt"
|
||
f"(transaction={transaction_id}, transitionSeq={transition.get('transitionSeq')})",
|
||
)
|
||
continue
|
||
cue = matched[0]
|
||
transaction_cues.append((transition, cue))
|
||
if not isinstance(cue.get("layerCount"), int) or cue.get("layerCount") <= 0:
|
||
errors.append("#/samples/cueReceiptRefs/layerCount: 应发声 cue 必须至少成功调度一层")
|
||
|
||
transaction_cue_identities = {cue_identity(cue) for _, cue in transaction_cues}
|
||
if transaction_cue_identities & set(pending_identities):
|
||
continue
|
||
detected = [
|
||
(transition, cue)
|
||
for transition, cue in transaction_cues
|
||
if (checks_by_identity.get(cue_identity(cue)) or {}).get("status") == "detected"
|
||
]
|
||
if not detected:
|
||
errors.append(f"#/samples/onsetChecks: 完成事务至少需要一个 detected(transaction={transaction_id})")
|
||
for transition, cue in transaction_cues:
|
||
if transition.get("phase") == "settle" \
|
||
and transition.get("terminalKind") in ("win", "lose") \
|
||
and (checks_by_identity.get(cue_identity(cue)) or {}).get("status") != "detected":
|
||
errors.append("#/samples/onsetChecks: terminal cue 必须为 detected")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_roll_audit_v2(instance: dict) -> list:
|
||
"""Audit/2 单文件只复算自身 hash;文件存在性和 sidecar 语义由目录门完成。"""
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "auditHash"}
|
||
expected_hash = _canonical_domain_hash("match3-roll-interaction-audit/2", content)
|
||
return [] if instance.get("auditHash") == expected_hash else [
|
||
"#/auditHash: 与 canonical Match3RollInteractionAudit/2 内容不一致",
|
||
]
|
||
|
||
|
||
def _semantic_validate_single_roll_proof_v3(instance: dict) -> list:
|
||
"""Proof/3 复用冻结 Proof/2 的业务语义,只新增 Audit/2 单向引用。"""
|
||
return _semantic_validate_single_roll_proof_v2(instance)
|
||
|
||
|
||
def _semantic_validate_build_manifest(instance: dict) -> list:
|
||
errors = []
|
||
refs = [row.get("ref") for row in instance.get("files") or []]
|
||
hashes = [row.get("sha256") for row in instance.get("files") or []]
|
||
if len(refs) != len(set(refs)):
|
||
errors.append("#/files: ref 不得重复")
|
||
if len(hashes) != len(set(hashes)):
|
||
errors.append("#/files: 同一原始字节不得冒充多个 build 文件")
|
||
return errors
|
||
|
||
|
||
def _cell_key(cell: dict) -> tuple:
|
||
return cell.get("row"), cell.get("column")
|
||
|
||
|
||
def _semantic_validate_visual_transaction(instance: dict) -> list:
|
||
errors = []
|
||
rounds = instance.get("rounds") or []
|
||
if instance.get("valid") is True and not rounds:
|
||
errors.append("#/rounds: 有效交换必须至少一轮")
|
||
if instance.get("valid") is False and rounds:
|
||
errors.append("#/rounds: 无效交换不得伪造消除轮次")
|
||
if instance.get("visualComplete") is True and instance.get("degradedReason") is not None:
|
||
errors.append("#/degradedReason: 完整视觉事务必须为 null")
|
||
if instance.get("visualComplete") is False and instance.get("degradedReason") is None:
|
||
errors.append("#/degradedReason: 降级视觉事务必须给出原因")
|
||
for index, round_value in enumerate(rounds):
|
||
if round_value.get("cascadeIndex") != index:
|
||
errors.append(f"#/rounds/{index}/cascadeIndex: 必须从 0 连续递增")
|
||
cells = round_value.get("matchedCells") or []
|
||
if [_cell_key(cell) for cell in cells] != sorted(set(_cell_key(cell) for cell in cells)):
|
||
errors.append(f"#/rounds/{index}/matchedCells: 必须行优先唯一排序")
|
||
content = {key: deepcopy(value) for key, value in instance.items() if key != "transactionHash"}
|
||
expected = _canonical_domain_hash("match3-visual-transaction/1", content)
|
||
if instance.get("transactionHash") != expected:
|
||
errors.append("#/transactionHash: 与 canonical 视觉事务不一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_visual_audit(instance: dict) -> list:
|
||
cells = instance.get("matchedCells") or []
|
||
if [_cell_key(cell) for cell in cells] != sorted(set(_cell_key(cell) for cell in cells)):
|
||
return ["#/matchedCells: 必须行优先唯一排序"]
|
||
return []
|
||
|
||
|
||
def _semantic_validate_effect_audit(instance: dict) -> list:
|
||
errors = []
|
||
cascades = instance.get("cascadeIndexes") or []
|
||
if cascades != sorted(cascades):
|
||
errors.append("#/cascadeIndexes: 必须唯一升序")
|
||
refs = [row.get("ref") for row in instance.get("renderReceiptRefs") or []]
|
||
if len(refs) != len(set(refs)):
|
||
errors.append("#/renderReceiptRefs: receipt ref 不得重复")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_filmstrip(instance: dict) -> list:
|
||
errors = []
|
||
frames = instance.get("frames") or []
|
||
times = [row.get("relativeMs") for row in frames]
|
||
if times != sorted(times) or len(times) != len(set(times)):
|
||
errors.append("#/frames/relativeMs: 必须严格递增")
|
||
phases = [row.get("phase") for row in frames]
|
||
cursor = -1
|
||
for phase in ("swap", "clear", "fall_refill", "settle"):
|
||
try:
|
||
cursor = phases.index(phase, cursor + 1)
|
||
except ValueError:
|
||
errors.append("#/frames: 必须按顺序包含 swap→clear→fall_refill→settle")
|
||
break
|
||
if frames and (
|
||
frames[-1].get("phase") != "settle"
|
||
or frames[-1].get("ref") != instance.get("settlementFrameRef")
|
||
or frames[-1].get("hash") != instance.get("settlementFrameHash")
|
||
):
|
||
errors.append("#/settlementFrameRef: 必须等于末个 settle 帧")
|
||
return errors
|
||
|
||
|
||
def _reference_asset_path_errors(value, path: str) -> list:
|
||
"""校验新参照资产契约中的 NFC、相对路径和目录边界约束。"""
|
||
if not isinstance(value, str):
|
||
return []
|
||
errors = []
|
||
if unicodedata.normalize("NFC", value) != value:
|
||
errors.append(f"{path}: 路径必须是 NFC 规范化字符串")
|
||
parts = value.split("/")
|
||
if value.startswith("/") or "\\" in value or "" in parts or any(part in (".", "..") for part in parts):
|
||
errors.append(f"{path}: 必须是仓内相对 POSIX 路径,不得越界")
|
||
if any(ord(char) < 0x20 or ord(char) == 0x7F for char in value):
|
||
errors.append(f"{path}: 路径不得包含控制字符")
|
||
return errors
|
||
|
||
|
||
def _reference_asset_record_path_errors(record: dict, path: str = "#") -> list:
|
||
"""检查 Record/2 中所有可能指向制品或证据的引用。"""
|
||
errors = []
|
||
for field in ("assetRef", "artifactRef", "consumptionManifestRef"):
|
||
if record.get(field) is not None:
|
||
errors += _reference_asset_path_errors(record[field], f"{path}/{field}")
|
||
for field in ("designRef", "evidenceRefs"):
|
||
for index, ref in enumerate(record.get(field) or []):
|
||
errors += _reference_asset_path_errors(ref, f"{path}/{field}/{index}")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_reference_asset_record_v2(instance: dict) -> list:
|
||
"""补 Record/2 的路径规范化约束,结构与生命周期条件由 schema 层负责。"""
|
||
return _reference_asset_record_path_errors(instance)
|
||
|
||
|
||
def _semantic_validate_reference_asset_registry_v2(instance: dict) -> list:
|
||
"""补 Registry/2 的唯一性、迁移状态和每条记录的路径约束。"""
|
||
errors = _semantic_validate_reference_asset_registry(instance)
|
||
for index, record in enumerate(instance.get("records") or []):
|
||
if isinstance(record, dict):
|
||
errors += _reference_asset_record_path_errors(record, f"#/records/{index}")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_reference_asset_consumption_manifest(instance: dict) -> list:
|
||
"""强制 manifest entries 的 canonical 路径、唯一性与排序。"""
|
||
errors = []
|
||
entries = instance.get("entries") or []
|
||
paths = []
|
||
for index, entry in enumerate(entries):
|
||
if not isinstance(entry, dict):
|
||
continue # 结构层负责报告非对象 entry
|
||
path = entry.get("path")
|
||
if isinstance(path, str):
|
||
paths.append(path)
|
||
errors += _reference_asset_path_errors(path, f"#/entries/{index}/path")
|
||
if len(paths) != len(set(paths)):
|
||
errors.append("#/entries: path 不得重复")
|
||
if paths != sorted(paths):
|
||
errors.append("#/entries: 必须按 path 的 canonical 字典序升序排列")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_reference_asset_consumption_manifest_bytes(
|
||
sample_path: Path, instance: dict,
|
||
) -> list:
|
||
"""校验真实 manifest 文件的原始字节必须等于 canonical JSON。"""
|
||
descriptor = _load(sample_path)
|
||
if isinstance(descriptor, dict) and "_base" in descriptor:
|
||
# 声明式负样本不是生产 manifest 字节,结构/语义变异由实例校验负责。
|
||
return []
|
||
raw = sample_path.read_bytes()
|
||
canonical = _canonical_json_bytes(instance)
|
||
if raw != canonical:
|
||
return [
|
||
"#: manifest 原始字节必须逐字等于 canonical JSON(UTF-8 无 BOM、无尾随换行、"
|
||
"键按 Unicode code point 排序且不转义非 ASCII)",
|
||
]
|
||
return []
|
||
|
||
|
||
def _semantic_validate_reference_asset_release(instance: dict) -> list:
|
||
"""检查 release 中两个外部引用的 canonical 路径。"""
|
||
errors = []
|
||
for field in ("registryRef", "policyRef"):
|
||
if instance.get(field) is not None:
|
||
errors += _reference_asset_path_errors(instance[field], f"#/{field}")
|
||
return errors
|
||
|
||
|
||
_REFERENCE_ASSET_SCHEMA_FILES = {
|
||
"ReferenceAssetReleaseV1": "reference-asset-release.schema.json",
|
||
"ReferenceAssetRegistryV2": "reference-asset-registry-v2.schema.json",
|
||
"ReferenceAssetConsumptionPolicyV1": "reference-asset-consumption-policy.schema.json",
|
||
"ReferenceAssetVerificationReceiptV1": "reference-asset-verification-receipt.schema.json",
|
||
}
|
||
|
||
|
||
def _load_reference_asset_file(
|
||
ref: str | None,
|
||
path: str,
|
||
expected_title: str,
|
||
errors: list,
|
||
) -> tuple[dict | None, str | None, bool]:
|
||
"""静态读取仓内普通 JSON 文件,并闭合其 schema、语义和原始字节 hash。
|
||
|
||
这里只服务 U1 的仓内 fixture 校验。它故意不承担 U3 的受信 fd、祖先 symlink、
|
||
TOCTOU 或大小限额职责;运行时消费必须使用 reference_asset_gate.py 的实现。
|
||
"""
|
||
if not isinstance(ref, str):
|
||
return None, None, False
|
||
path_errors = _reference_asset_path_errors(ref, path)
|
||
errors.extend(path_errors)
|
||
if path_errors:
|
||
return None, None, False
|
||
|
||
target = _HERE.parent.parent / ref
|
||
try:
|
||
target_stat = target.lstat()
|
||
except FileNotFoundError:
|
||
errors.append(f"{path}: 引用文件不存在")
|
||
return None, None, False
|
||
except OSError:
|
||
errors.append(f"{path}: 引用文件不可读取")
|
||
return None, None, False
|
||
if stat.S_ISLNK(target_stat.st_mode):
|
||
errors.append(f"{path}: 末级引用不得是 symlink")
|
||
return None, None, False
|
||
if not stat.S_ISREG(target_stat.st_mode):
|
||
errors.append(f"{path}: 引用文件必须是普通文件")
|
||
return None, None, False
|
||
|
||
try:
|
||
raw = target.read_bytes()
|
||
raw_hash = hashlib.sha256(raw).hexdigest()
|
||
instance = _load_payload_canonical(raw.decode("utf-8"))
|
||
except (OSError, UnicodeError, ValueError) as exc:
|
||
errors.append(f"{path}: 引用文件不是合法 UTF-8 JSON:{exc}")
|
||
return None, None, False
|
||
if not isinstance(instance, dict):
|
||
errors.append(f"{path}: 引用文件顶层必须是对象")
|
||
return None, raw_hash, False
|
||
|
||
schema_file = _REFERENCE_ASSET_SCHEMA_FILES.get(expected_title)
|
||
if schema_file is None:
|
||
errors.append(f"{path}: 未登记的参照资产 schema 类型 {expected_title}")
|
||
return instance, raw_hash, False
|
||
schema = _load(_HERE / schema_file)
|
||
schema_errors = validate(schema, instance, schema)
|
||
if schema_errors:
|
||
errors.extend(f"{path}: {message}" for message in schema_errors)
|
||
return instance, raw_hash, False
|
||
semantic_errors = _semantic_validate(schema, instance)
|
||
if semantic_errors:
|
||
errors.extend(f"{path}: {message}" for message in semantic_errors)
|
||
return instance, raw_hash, False
|
||
return instance, raw_hash, True
|
||
|
||
|
||
def _semantic_validate_reference_asset_verification_receipt(instance: dict) -> list:
|
||
"""冻结 release、policy、record 与制品双身份,任何 hash 漂移都 fail closed。"""
|
||
errors = []
|
||
|
||
release_ref = instance.get("releaseRef")
|
||
release, _, release_valid = _load_reference_asset_file(
|
||
release_ref,
|
||
"#/releaseRef",
|
||
"ReferenceAssetReleaseV1",
|
||
errors,
|
||
)
|
||
|
||
if isinstance(release, dict) and release_valid:
|
||
for receipt_field, release_field in (
|
||
("expectedRegistryHash", "registryHash"),
|
||
("policyHash", "policyHash"),
|
||
("verifierVersion", "verifierVersion"),
|
||
("trustedRootId", "trustedRootId"),
|
||
):
|
||
if instance.get(receipt_field) != release.get(release_field):
|
||
errors.append(
|
||
f"#/{receipt_field}: 必须与 releaseRef 对应的 release.{release_field} 一致",
|
||
)
|
||
|
||
policy_ref = release.get("policyRef")
|
||
policy, policy_hash, policy_valid = _load_reference_asset_file(
|
||
policy_ref,
|
||
"#/releaseRef/policyRef",
|
||
"ReferenceAssetConsumptionPolicyV1",
|
||
errors,
|
||
)
|
||
if policy_valid and isinstance(policy, dict):
|
||
if policy_hash != release.get("policyHash"):
|
||
errors.append("#/releaseRef/policyHash: 必须等于 policy 原始字节 SHA-256")
|
||
for field in ("recordId", "role", "consumerRef"):
|
||
if instance.get(field) != policy.get(field):
|
||
errors.append(f"#/{field}: 必须与 release policy 的 {field} 一致")
|
||
|
||
registry_ref = release.get("registryRef")
|
||
registry, registry_hash, registry_valid = _load_reference_asset_file(
|
||
registry_ref,
|
||
"#/releaseRef/registryRef",
|
||
"ReferenceAssetRegistryV2",
|
||
errors,
|
||
)
|
||
if registry_valid and isinstance(registry, dict):
|
||
if registry_hash != release.get("registryHash"):
|
||
errors.append("#/releaseRef/registryHash: 必须等于 registry 原始字节 SHA-256")
|
||
if instance.get("expectedRegistryHash") != release.get("registryHash"):
|
||
errors.append("#/expectedRegistryHash: 必须与 release.registryHash 一致")
|
||
if instance.get("observedRegistryHash") != registry_hash:
|
||
errors.append("#/observedRegistryHash: 必须等于 registry 原始字节 SHA-256")
|
||
if instance.get("registryVersion") != registry.get("registryVersion"):
|
||
errors.append("#/registryVersion: 必须与 release registry 的 registryVersion 一致")
|
||
record = next(
|
||
(
|
||
candidate for candidate in registry.get("records") or []
|
||
if isinstance(candidate, dict) and candidate.get("recordId") == instance.get("recordId")
|
||
),
|
||
None,
|
||
)
|
||
if record is None:
|
||
errors.append("#/recordId: release registry 中不存在对应 recordId")
|
||
elif record.get("lifecycleStatus") != "active":
|
||
errors.append("#/recordId: release registry record 必须处于 active 生命周期")
|
||
else:
|
||
for field in ("role", "consumerRef", "artifactRef", "consumptionManifestRef"):
|
||
if instance.get(field) != record.get(field):
|
||
errors.append(f"#/{field}: 必须与 release registry record 一致")
|
||
for receipt_field, record_field in (
|
||
("artifactHash", "artifactHash"),
|
||
("consumptionManifestHash", "consumptionManifestHash"),
|
||
):
|
||
if (instance.get("expected") or {}).get(receipt_field) != record.get(record_field):
|
||
errors.append(f"#/expected/{receipt_field}: 必须与 release registry record 一致")
|
||
|
||
trusted_root_id = instance.get("trustedRootId")
|
||
if isinstance(trusted_root_id, str) and (
|
||
Path(trusted_root_id).is_absolute()
|
||
or trusted_root_id.startswith(("/", "\\"))
|
||
or "/" in trusted_root_id
|
||
or "\\" in trusted_root_id
|
||
):
|
||
errors.append("#/trustedRootId: 只能是逻辑身份,不得是绝对路径或文件路径")
|
||
|
||
expected = instance.get("expected") or {}
|
||
observed = instance.get("observed") or {}
|
||
if isinstance(expected, dict) and isinstance(observed, dict):
|
||
for field in ("artifactHash", "consumptionManifestHash"):
|
||
if expected.get(field) != observed.get(field):
|
||
errors.append(f"#/observed/{field}: 必须等于 expected/{field}")
|
||
if instance.get("expectedRegistryHash") != instance.get("observedRegistryHash"):
|
||
errors.append("#/observedRegistryHash: 必须等于 expectedRegistryHash")
|
||
for field in ("artifactRef", "consumptionManifestRef"):
|
||
if instance.get(field) is not None:
|
||
errors += _reference_asset_path_errors(instance[field], f"#/{field}")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_acceptance_provenance_v4(instance: dict) -> list:
|
||
"""读取 v4 provenance 的回执文件,并闭合 ref/hash 与消费身份声明。"""
|
||
errors = []
|
||
seen = set()
|
||
receipt_record_ids = set()
|
||
record_ids = instance.get("referenceAssetRecordIds")
|
||
consumed_assets = instance.get("consumedReferenceAssets")
|
||
consumed_by_id = {}
|
||
if isinstance(consumed_assets, list):
|
||
for index, asset in enumerate(consumed_assets):
|
||
if not isinstance(asset, dict):
|
||
continue # 结构层负责报告非对象资产
|
||
record_id = asset.get("recordId")
|
||
if record_id in consumed_by_id:
|
||
errors.append(f"#/consumedReferenceAssets/{index}/recordId: recordId 不得重复")
|
||
else:
|
||
consumed_by_id[record_id] = asset
|
||
|
||
if not isinstance(record_ids, list) or not record_ids:
|
||
errors.append("#/referenceAssetRecordIds: 有 receipt 时必须声明非空 recordId 集合")
|
||
if not isinstance(instance.get("consumerRef"), str):
|
||
errors.append("#/consumerRef: 有 receipt 时必须声明 consumerRef")
|
||
if not isinstance(consumed_assets, list) or not consumed_assets:
|
||
errors.append("#/consumedReferenceAssets: 有 receipt 时必须声明非空消费资产集合")
|
||
|
||
for index, receipt in enumerate(instance.get("referenceAssetVerificationReceipts") or []):
|
||
if not isinstance(receipt, dict):
|
||
continue # 结构层负责报告非对象回执引用
|
||
ref = receipt.get("ref")
|
||
if isinstance(ref, str):
|
||
errors += _reference_asset_path_errors(ref, f"#/referenceAssetVerificationReceipts/{index}/ref")
|
||
if ref in seen:
|
||
errors.append(f"#/referenceAssetVerificationReceipts/{index}/ref: 回执 ref 不得重复")
|
||
seen.add(ref)
|
||
loaded_receipt, raw_hash, receipt_valid = _load_reference_asset_file(
|
||
ref,
|
||
f"#/referenceAssetVerificationReceipts/{index}/ref",
|
||
"ReferenceAssetVerificationReceiptV1",
|
||
errors,
|
||
)
|
||
if raw_hash is not None and receipt.get("hash") != raw_hash:
|
||
errors.append(
|
||
f"#/referenceAssetVerificationReceipts/{index}/hash: 必须等于 receipt 原始字节 SHA-256",
|
||
)
|
||
if not receipt_valid or not isinstance(loaded_receipt, dict):
|
||
continue
|
||
receipt_record_id = loaded_receipt.get("recordId")
|
||
if receipt_record_id in receipt_record_ids:
|
||
errors.append(f"#/referenceAssetVerificationReceipts/{index}: receipt recordId 不得重复")
|
||
receipt_record_ids.add(receipt_record_id)
|
||
if not isinstance(record_ids, list) or receipt_record_id not in record_ids:
|
||
errors.append(f"#/referenceAssetRecordIds: 缺少 receipt 的 recordId {receipt_record_id}")
|
||
if loaded_receipt.get("consumerRef") != instance.get("consumerRef"):
|
||
errors.append(f"#/consumerRef: 必须与 receipt {receipt_record_id} 一致")
|
||
consumed = consumed_by_id.get(receipt_record_id)
|
||
if consumed is None:
|
||
errors.append(f"#/consumedReferenceAssets: 缺少 receipt 的 recordId {receipt_record_id}")
|
||
else:
|
||
if consumed.get("role") != loaded_receipt.get("role"):
|
||
errors.append(f"#/consumedReferenceAssets/{receipt_record_id}/role: 必须与 receipt 一致")
|
||
expected_hash = (loaded_receipt.get("expected") or {}).get("artifactHash")
|
||
if consumed.get("artifactHash") != expected_hash:
|
||
errors.append(f"#/consumedReferenceAssets/{receipt_record_id}/artifactHash: 必须与 receipt expected 一致")
|
||
|
||
if isinstance(record_ids, list) and set(record_ids) != receipt_record_ids:
|
||
errors.append("#/referenceAssetRecordIds: 必须与 receipt recordId 集合一致")
|
||
if set(consumed_by_id) != receipt_record_ids:
|
||
errors.append("#/consumedReferenceAssets: 必须与 receipt recordId 集合一致")
|
||
return errors
|
||
|
||
|
||
def _semantic_validate_reference_asset_registry(instance: dict) -> list:
|
||
"""参照资产注册表跨字段语义:recordId 全局唯一、注释不悬空、迁移清单不得含 active。
|
||
|
||
JSON Schema 子集表达不了跨数组元素的字段唯一性,recordId 唯一(金标 SoT §7『稳定唯一』)
|
||
在此强制。registryVersion 带 .migration-list 后缀表示本快照只是迁移清单、不是可供新 live
|
||
消费的机器注册表——含 active 记录即自相矛盾(『不伪装 active』红线的机器化)。
|
||
"""
|
||
errors = []
|
||
records = instance.get("records") or []
|
||
seen: dict = {}
|
||
for index, record in enumerate(records):
|
||
if not isinstance(record, dict):
|
||
continue # 结构错由 schema 层报,语义层只做跨字段不变量
|
||
record_id = record.get("recordId")
|
||
if record_id in seen:
|
||
errors.append(
|
||
f"#/records/{index}/recordId: {record_id} 与 #/records/{seen[record_id]} 重复,"
|
||
"违反金标 SoT §7 recordId 稳定唯一约束"
|
||
)
|
||
else:
|
||
seen[record_id] = index
|
||
for note_key in instance.get("migrationNotes") or {}:
|
||
if note_key != "_registry" and note_key not in seen:
|
||
errors.append(f"#/migrationNotes/{note_key}: 注释指向 records 中不存在的 recordId")
|
||
registry_version = instance.get("registryVersion") or ""
|
||
if registry_version.endswith(".migration-list"):
|
||
for index, record in enumerate(records):
|
||
if isinstance(record, dict) and record.get("lifecycleStatus") == "active":
|
||
errors.append(
|
||
f"#/records/{index}/lifecycleStatus: migration-list 版本注册表不得含 active 记录"
|
||
"(迁移完成前不是可供 live 消费的机器注册表)"
|
||
)
|
||
return errors
|
||
|
||
|
||
def _semantic_validate(schema: dict, instance) -> list:
|
||
"""按 schema 标题分派跨字段语义校验;旧 C5/C6 不改变既有行为。"""
|
||
if not isinstance(instance, dict):
|
||
return []
|
||
title = schema.get("title")
|
||
if title == "ProofObligationRegistry":
|
||
return _semantic_validate_registry(instance)
|
||
if title == "ProofObligationRegistryLegacyV2":
|
||
return _semantic_validate_registry(instance, validate_interaction=False)
|
||
if title == "LegacyGameLogMapping":
|
||
return _semantic_validate_legacy_mapping(instance)
|
||
if title == "GameEvent":
|
||
return _semantic_validate_game_event(instance)
|
||
if title == "PlaytestEvidence":
|
||
return _semantic_validate_playtest(instance)
|
||
if title == "SingleRollFact":
|
||
return _semantic_validate_single_roll(instance)
|
||
if title == "ActorSelectionV1":
|
||
return _semantic_validate_actor_selection(instance)
|
||
if title in ("ActorViewV3Frozen", "ActorViewV4"):
|
||
return _semantic_validate_actor_view_v4(instance)
|
||
if title == "ActorProtocolBundleV1":
|
||
return _semantic_validate_actor_protocol_bundle(instance)
|
||
if title == "ActorSelectionContextV1":
|
||
return _semantic_validate_actor_selection_context(instance)
|
||
if title == "ActorSelectionAttemptV1":
|
||
return _semantic_validate_selection_attempt(instance)
|
||
if title == "Match3PairResolutionV1":
|
||
return _semantic_validate_pair_resolution(instance)
|
||
if title == "VisualTargetSetV1":
|
||
return _semantic_validate_visual_target_set(instance)
|
||
if title == "TargetResolutionV1":
|
||
return _semantic_validate_target_resolution(instance)
|
||
if title == "CostReservationV1":
|
||
return _semantic_validate_cost_reservation(instance)
|
||
if title == "PlaytestEvidenceV3":
|
||
return _semantic_validate_playtest_v3(instance)
|
||
if title == "SingleRollFactV3":
|
||
return _semantic_validate_single_roll_v3(instance)
|
||
if title in (
|
||
"AcceptanceRequest", "AcceptanceProvenance",
|
||
"AcceptanceRequestV2", "AcceptanceProvenanceV2",
|
||
"AcceptanceRequestV3", "AcceptanceProvenanceV3",
|
||
):
|
||
return _semantic_validate_acceptance_profile(instance)
|
||
if title == "InteractionProfileRegistry":
|
||
return _semantic_validate_interaction_registry(instance)
|
||
if title == "InteractionProfileRegistryV2":
|
||
return _semantic_validate_interaction_registry_v2(instance)
|
||
if title == "ReferenceAssetRegistryV1":
|
||
return _semantic_validate_reference_asset_registry(instance)
|
||
if title == "ReferenceAssetRecordV2":
|
||
return _semantic_validate_reference_asset_record_v2(instance)
|
||
if title == "ReferenceAssetRegistryV2":
|
||
return _semantic_validate_reference_asset_registry_v2(instance)
|
||
if title == "ReferenceAssetConsumptionManifestV1":
|
||
return _semantic_validate_reference_asset_consumption_manifest(instance)
|
||
if title == "ReferenceAssetConsumptionPolicyV1":
|
||
return []
|
||
if title == "ReferenceAssetReleaseV1":
|
||
return _semantic_validate_reference_asset_release(instance)
|
||
if title == "ReferenceAssetVerificationReceiptV1":
|
||
return _semantic_validate_reference_asset_verification_receipt(instance)
|
||
if title == "AcceptanceProvenanceV4":
|
||
return (
|
||
_semantic_validate_acceptance_profile(instance)
|
||
+ _semantic_validate_acceptance_provenance_v4(instance)
|
||
)
|
||
if title == "Match3ProducerPreflightV1":
|
||
return _semantic_validate_match3_producer_preflight(instance)
|
||
if title == "InteractionBindingV1":
|
||
return _semantic_validate_interaction_binding(instance)
|
||
if title == "ProofObligationResolutionV1":
|
||
return _semantic_validate_proof_resolution(instance)
|
||
if title == "SingleRollProofV2":
|
||
return _semantic_validate_single_roll_proof_v2(instance)
|
||
if title == "CollectionProofV1":
|
||
return _semantic_validate_collection_proof(instance)
|
||
if title == "JudgePackageV1":
|
||
return _semantic_validate_judge_package(instance)
|
||
if title == "Match3BoardProjectionV1":
|
||
return _semantic_validate_match3_projection(instance)
|
||
if title == "Match3SwapSetV1":
|
||
return _semantic_validate_match3_swap_set(instance)
|
||
if title == "BoardIdentityCheckV1":
|
||
return _semantic_validate_board_identity(instance)
|
||
if title == "Match3ActionGroupV1":
|
||
return _semantic_validate_action_group(instance)
|
||
if title == "Match3RollInteractionAuditV1":
|
||
return _semantic_validate_roll_audit(instance)
|
||
if title == "Match3AudioAuditV1":
|
||
return _semantic_validate_match3_audio_audit(instance)
|
||
if title == "Match3RollInteractionAuditV2":
|
||
return _semantic_validate_roll_audit_v2(instance)
|
||
if title == "SingleRollProofV3":
|
||
return _semantic_validate_single_roll_proof_v3(instance)
|
||
if title == "BuildManifestV1":
|
||
return _semantic_validate_build_manifest(instance)
|
||
if title == "Match3VisualTransactionV1":
|
||
return _semantic_validate_visual_transaction(instance)
|
||
if title == "Match3VisualAuditV1":
|
||
return _semantic_validate_visual_audit(instance)
|
||
if title == "Match3EffectAuditV1":
|
||
return _semantic_validate_effect_audit(instance)
|
||
if title == "Match3FilmstripAnalyzerV1":
|
||
return _semantic_validate_filmstrip(instance)
|
||
return []
|
||
|
||
|
||
def _canonical_json_bytes(value) -> bytes:
|
||
"""按项目自有规则生成 canonical JSON 原始字节,不加 BOM 或尾随换行。"""
|
||
# Python 对字符串键按 Unicode code point 排序;ensure_ascii=False 保留非 ASCII 原字节。
|
||
return json.dumps(
|
||
value,
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
allow_nan=False,
|
||
).encode("utf-8")
|
||
|
||
|
||
def _load(p: Path):
|
||
return _load_payload_canonical(p.read_text(encoding="utf-8"))
|
||
|
||
|
||
def _pointer_parts(pointer: str) -> list:
|
||
"""把 JSON Pointer 拆成键路径;样本变异器只接受绝对 pointer。"""
|
||
if not pointer.startswith("/"):
|
||
raise ValueError(f"JSON Pointer 必须以 / 开头:{pointer}")
|
||
return [part.replace("~1", "/").replace("~0", "~")
|
||
for part in pointer[1:].split("/")]
|
||
|
||
|
||
def _walk_pointer(root, pointer: str):
|
||
"""走到 JSON Pointer 的父节点,返回(父节点,末段键)。"""
|
||
parts = _pointer_parts(pointer)
|
||
if not parts:
|
||
raise ValueError("不允许替换样本根节点")
|
||
node = root
|
||
for part in parts[:-1]:
|
||
node = node[int(part)] if isinstance(node, list) else node[part]
|
||
return node, parts[-1]
|
||
|
||
|
||
def _set_pointer(root, pointer: str, value) -> None:
|
||
"""按 JSON Pointer 设置测试样本字段,供负样本从正样本做最小单点变异。"""
|
||
parent, key = _walk_pointer(root, pointer)
|
||
if isinstance(parent, list):
|
||
parent[int(key)] = deepcopy(value)
|
||
else:
|
||
parent[key] = deepcopy(value)
|
||
|
||
|
||
def _delete_pointer(root, pointer: str) -> None:
|
||
"""按 JSON Pointer 删除测试样本字段。"""
|
||
parent, key = _walk_pointer(root, pointer)
|
||
if isinstance(parent, list):
|
||
del parent[int(key)]
|
||
else:
|
||
del parent[key]
|
||
|
||
|
||
class _SampleFixtureLoadError(RuntimeError):
|
||
"""声明式样本的基底缺失或损坏;这属于测试基础设施故障。"""
|
||
|
||
|
||
def _load_sample_instance(sample_path: Path):
|
||
"""读取样本;带 _base 的负样本按声明式变异生成完整待校验实例。
|
||
|
||
负样本往往只改一个跨字段不变量。若复制整份几百行 playtest/2 正样本,后续协议加字段时
|
||
多份副本会一起漂移。`_base + _set/_delete` 让每个负样本只声明那一个真实错法,同时
|
||
校验器最终看到的仍是完整契约实例,不会因缺一堆无关必填字段而“偶然被拦”。
|
||
"""
|
||
descriptor = _load(sample_path)
|
||
if not isinstance(descriptor, dict) or "_base" not in descriptor:
|
||
return descriptor
|
||
base_path = (sample_path.parent / descriptor["_base"]).resolve()
|
||
# fixture 可以在同一契约族内逐层复用;每层仍只声明本次变异,最终得到完整实例。
|
||
try:
|
||
instance = deepcopy(_load_sample_instance(base_path))
|
||
except _SampleFixtureLoadError:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001 —— 基底不是被测负样本,任何装载失败都必须使套件失败
|
||
raise _SampleFixtureLoadError(
|
||
f"{sample_path}: 无法装载 _base {base_path}: {exc}",
|
||
) from exc
|
||
for pointer, value in (descriptor.get("_set") or {}).items():
|
||
_set_pointer(instance, pointer, value)
|
||
for pointer in descriptor.get("_delete") or []:
|
||
_delete_pointer(instance, pointer)
|
||
return instance
|
||
|
||
|
||
def _sealed_sample_bytes(sample_path: Path, instance: dict) -> bytes:
|
||
"""生产文件按原始字节取 hash;声明式 fixture 按 Node 两空格封存口径物化。"""
|
||
descriptor = _load(sample_path)
|
||
if isinstance(descriptor, dict) and "_base" in descriptor:
|
||
return (json.dumps(instance, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||
return sample_path.read_bytes()
|
||
|
||
|
||
_SAMPLE_INFRASTRUCTURE_ERROR_PREFIX = "#: 测试基础设施失败:"
|
||
|
||
|
||
def _raw_sample_hash(path: Path, instance: dict) -> str:
|
||
return hashlib.sha256(_sealed_sample_bytes(path, instance)).hexdigest()
|
||
|
||
|
||
def _extract_top_level_json_value(source: str, wanted_key: str) -> str:
|
||
"""保留顶层字段的原始 JSON 片段,用于复核 helper 两次 Projection 字节身份。"""
|
||
decoder = json.JSONDecoder()
|
||
index = 0
|
||
length = len(source)
|
||
|
||
def skip_space(position: int) -> int:
|
||
while position < length and source[position].isspace():
|
||
position += 1
|
||
return position
|
||
|
||
index = skip_space(index)
|
||
if index >= length or source[index] != "{":
|
||
raise ValueError("顶层不是对象")
|
||
index += 1
|
||
while True:
|
||
index = skip_space(index)
|
||
if index < length and source[index] == "}":
|
||
break
|
||
key, index = decoder.raw_decode(source, index)
|
||
if not isinstance(key, str):
|
||
raise ValueError("对象键不是字符串")
|
||
index = skip_space(index)
|
||
if index >= length or source[index] != ":":
|
||
raise ValueError("对象键后缺冒号")
|
||
value_start = skip_space(index + 1)
|
||
_, value_end = decoder.raw_decode(source, value_start)
|
||
if key == wanted_key:
|
||
return source[value_start:value_end]
|
||
index = skip_space(value_end)
|
||
if index < length and source[index] == ",":
|
||
index += 1
|
||
continue
|
||
if index < length and source[index] == "}":
|
||
break
|
||
raise ValueError("对象字段分隔非法")
|
||
raise ValueError(f"缺少顶层字段 {wanted_key}")
|
||
|
||
|
||
def _decode_png_rgb(raw: bytes) -> tuple[int, int, bytes]:
|
||
"""解码证据门使用的 8-bit RGB/RGBA 非隔行 PNG;拒绝未知格式而不猜测。"""
|
||
if not raw.startswith(b"\x89PNG\r\n\x1a\n"):
|
||
raise ValueError("PNG signature")
|
||
index = 8
|
||
width = height = color_type = None
|
||
compressed = bytearray()
|
||
while index + 12 <= len(raw):
|
||
size = struct.unpack(">I", raw[index:index + 4])[0]
|
||
kind = raw[index + 4:index + 8]
|
||
payload = raw[index + 8:index + 8 + size]
|
||
if index + 12 + size > len(raw):
|
||
raise ValueError("PNG chunk")
|
||
if zlib.crc32(kind + payload) & 0xFFFFFFFF != struct.unpack(
|
||
">I", raw[index + 8 + size:index + 12 + size],
|
||
)[0]:
|
||
raise ValueError("PNG crc")
|
||
if kind == b"IHDR":
|
||
width, height, depth, color_type, compression, filtering, interlace = struct.unpack(
|
||
">IIBBBBB", payload,
|
||
)
|
||
if depth != 8 or color_type not in (2, 6) or compression != 0 \
|
||
or filtering != 0 or interlace != 0:
|
||
raise ValueError("PNG format")
|
||
elif kind == b"IDAT":
|
||
compressed.extend(payload)
|
||
elif kind == b"IEND":
|
||
break
|
||
index += 12 + size
|
||
if not isinstance(width, int) or not isinstance(height, int) or width <= 0 or height <= 0:
|
||
raise ValueError("PNG IHDR")
|
||
channels = 3 if color_type == 2 else 4
|
||
scanlines = zlib.decompress(bytes(compressed))
|
||
stride = width * channels
|
||
if len(scanlines) != height * (stride + 1):
|
||
raise ValueError("PNG scanline size")
|
||
decoded = bytearray(height * stride)
|
||
source_index = 0
|
||
previous = bytearray(stride)
|
||
for row in range(height):
|
||
filter_type = scanlines[source_index]
|
||
source_index += 1
|
||
current = bytearray(scanlines[source_index:source_index + stride])
|
||
source_index += stride
|
||
for offset in range(stride):
|
||
left = current[offset - channels] if offset >= channels else 0
|
||
up = previous[offset]
|
||
up_left = previous[offset - channels] if offset >= channels else 0
|
||
if filter_type == 1:
|
||
current[offset] = (current[offset] + left) & 0xFF
|
||
elif filter_type == 2:
|
||
current[offset] = (current[offset] + up) & 0xFF
|
||
elif filter_type == 3:
|
||
current[offset] = (current[offset] + ((left + up) // 2)) & 0xFF
|
||
elif filter_type == 4:
|
||
predictor = left + up - up_left
|
||
distances = (abs(predictor - left), abs(predictor - up), abs(predictor - up_left))
|
||
paeth = (left, up, up_left)[distances.index(min(distances))]
|
||
current[offset] = (current[offset] + paeth) & 0xFF
|
||
elif filter_type != 0:
|
||
raise ValueError("PNG filter")
|
||
decoded[row * stride:(row + 1) * stride] = current
|
||
previous = current
|
||
if channels == 3:
|
||
return width, height, bytes(decoded)
|
||
rgb = bytearray(width * height * 3)
|
||
for pixel in range(width * height):
|
||
rgb[pixel * 3:pixel * 3 + 3] = decoded[pixel * 4:pixel * 4 + 3]
|
||
return width, height, bytes(rgb)
|
||
|
||
|
||
def _projection_equivalence_hash(projection: dict) -> str | None:
|
||
"""按 cell 两两是否同类生成与 classId 字面无关的等价分区 hash。"""
|
||
cells = projection.get("cells") or []
|
||
if not cells or any(row.get("state") != "classified" for row in cells):
|
||
return None
|
||
labels = [row.get("classId") for row in cells]
|
||
relations = [[left == right for right in labels] for left in labels]
|
||
payload = {"dimensions": projection.get("dimensions"), "relations": relations}
|
||
return _canonical_domain_hash("anonymous-cell-equivalence/1", payload)
|
||
|
||
|
||
def _proof_action_projection(action: dict) -> dict:
|
||
"""完整原生 actionRecord 到 ProofAction/1 的唯一允许投影。"""
|
||
return {
|
||
"schemaVersion": "ProofAction/1",
|
||
"actionId": action.get("actionId"),
|
||
"normalized": deepcopy(action.get("normalized")),
|
||
"preFrameRef": deepcopy(action.get("preFrameRef")),
|
||
"preEventSeq": action.get("preEventSeq"),
|
||
"virtualTimeBudgetMs": action.get("virtualTimeBudgetMs"),
|
||
"timeAdvance": deepcopy(action.get("timeAdvance")),
|
||
"postFrameRef": deepcopy(action.get("postFrameRef")),
|
||
"postEventSeq": action.get("postEventSeq"),
|
||
}
|
||
|
||
|
||
def _judge_action_projection(action: dict, events: list) -> dict:
|
||
"""完整原生 actionRecord 到 JudgeAction 的唯一允许投影。"""
|
||
pre_seq, post_seq = action.get("preEventSeq", 0), action.get("postEventSeq", 0)
|
||
return {
|
||
"actionId": action.get("actionId"),
|
||
"normalized": deepcopy(action.get("normalized")),
|
||
"valid": action.get("valid"),
|
||
"preFrameRef": deepcopy(action.get("preFrameRef")),
|
||
"preFrameHash": (action.get("preFrameRef") or {}).get("hash"),
|
||
"preEventSeq": pre_seq,
|
||
"virtualTimeBudgetMs": action.get("virtualTimeBudgetMs"),
|
||
"postFrameRef": deepcopy(action.get("postFrameRef")),
|
||
"postFrameHash": (action.get("postFrameRef") or {}).get("hash"),
|
||
"postEventSeq": post_seq,
|
||
"gameEvents": [deepcopy(event) for event in events if pre_seq < event.get("seq", 0) <= post_seq],
|
||
}
|
||
|
||
|
||
def _proof_obligation_from_candidate(candidate: dict) -> dict:
|
||
"""Judge 候选到 SingleRollProof obligation 的确定性状态投影。"""
|
||
state = candidate.get("candidateState")
|
||
return {
|
||
"id": candidate.get("id"),
|
||
"required": candidate.get("required"),
|
||
"status": "satisfied" if state == "complete" else "missing",
|
||
"sequenceRefs": deepcopy(candidate.get("sequenceRefs") or []),
|
||
"actionRefs": deepcopy(candidate.get("actionRefs") or []),
|
||
"postFrameRefs": deepcopy(candidate.get("postFrameRefs") or []),
|
||
"eventRefs": deepcopy(candidate.get("eventRefs") or []),
|
||
"contradictions": [],
|
||
"blockingProblems": deepcopy(candidate.get("candidateProblems") or []),
|
||
}
|
||
|
||
|
||
def _schema_errors(schema_name: str, instance: dict) -> list:
|
||
schema = _load(_HERE / schema_name)
|
||
errors = validate(schema, instance, schema)
|
||
if not errors:
|
||
errors += _semantic_validate(schema, instance)
|
||
return errors
|
||
|
||
|
||
def _validate_match3_action_bundle(manifest_path: Path) -> list:
|
||
"""读取 D-F 全部 runner 审计制品,验证 pair 双 tap 到两步 proof 的完整闭包。"""
|
||
errors = []
|
||
manifest = _load_sample_instance(manifest_path)
|
||
manifest_schema = _load(_HERE / "match3-action-bundle.schema.json")
|
||
errors += validate(manifest_schema, manifest, manifest_schema)
|
||
_trace(
|
||
"match3-action-bundle", "manifest-schema", path=manifest_path,
|
||
result="pass" if not errors else "fail", error=errors[0] if errors else None,
|
||
)
|
||
if errors:
|
||
return errors
|
||
|
||
def load_ref(field: str):
|
||
path = (manifest_path.parent / manifest[field]).resolve()
|
||
if not path.is_file():
|
||
errors.append(f"#/{field}: 引用文件不存在")
|
||
_trace("load-ref", "file-exists", ref=manifest.get(field), path=path,
|
||
result="fail", error="引用文件不存在")
|
||
return None, None
|
||
_trace("load-ref", "file-exists", ref=manifest.get(field), path=path, result="pass")
|
||
return path, _load_sample_instance(path)
|
||
|
||
specs = {
|
||
"requestRef": "acceptance-request-v2.schema.json",
|
||
"provenanceRef": "acceptance-provenance-v2.schema.json",
|
||
"bindingRef": "interaction-binding.schema.json",
|
||
"registryRef": "interaction-profile-registry-v2.schema.json",
|
||
"actorProtocolBundleRef": "actor-protocol-bundle.schema.json",
|
||
"actorViewRef": "actor-view-v4.schema.json",
|
||
"selectionRef": "actor-selection-v2.schema.json",
|
||
"selectionAttemptRef": "actor-selection-attempt.schema.json",
|
||
"pairResolutionRef": "match3-pair-resolution.schema.json",
|
||
"swapSetRef": "match3-swap-set.schema.json",
|
||
"initialTargetSetRef": "visual-target-set.schema.json",
|
||
"postFirstTargetSetRef": "visual-target-set.schema.json",
|
||
"initialProjectionRef": "match3-board-projection.schema.json",
|
||
"postFirstProjectionRef": "match3-board-projection.schema.json",
|
||
"firstGroupedActionRef": "grouped-action-ref.schema.json",
|
||
"secondGroupedActionRef": "grouped-action-ref.schema.json",
|
||
"boardIdentityCheckRef": "board-identity-check.schema.json",
|
||
"actionGroupRef": "match3-action-group.schema.json",
|
||
"resolutionRef": "proof-obligation-resolution.schema.json",
|
||
"collectionProofRef": "collection-proof.schema.json",
|
||
"singleRollProofRef": "single-roll-proof-v2.schema.json",
|
||
"judgePackageRef": "judge-package.schema.json",
|
||
"rollAuditRef": "match3-roll-interaction-audit.schema.json",
|
||
}
|
||
loaded = {}
|
||
paths = {}
|
||
for field, schema_name in specs.items():
|
||
path, value = load_ref(field)
|
||
if path is None:
|
||
continue
|
||
paths[field], loaded[field] = path, value
|
||
ref_errors = [f"#/{field}{error[1:]}" for error in _schema_errors(schema_name, value)]
|
||
errors += ref_errors
|
||
_trace("load-ref", "schema-and-semantic", ref=manifest.get(field), path=path,
|
||
result="pass" if not ref_errors else "fail", error=ref_errors[0] if ref_errors else None)
|
||
for field in ("firstActionRef", "secondActionRef"):
|
||
path, value = load_ref(field)
|
||
if path is None:
|
||
continue
|
||
paths[field], loaded[field] = path, value
|
||
root = _load(_HERE / "playtest-evidence-v3.schema.json")
|
||
action_schema = root["$defs"]["actionRecord"]
|
||
action_errors = validate(action_schema, value, root)
|
||
if not action_errors:
|
||
action_errors += _semantic_validate_v3_action(value, "#")
|
||
errors += [f"#/{field}{error[1:]}" for error in action_errors]
|
||
perception_audit_path, perception_audit = load_ref("postFirstPerceptionAuditRef")
|
||
if perception_audit_path is not None:
|
||
paths["postFirstPerceptionAuditRef"] = perception_audit_path
|
||
loaded["postFirstPerceptionAuditRef"] = perception_audit
|
||
if (manifest_path.parent / manifest["postFirstPerceptionAuditRef"]).is_symlink():
|
||
errors.append("#/postFirstPerceptionAuditRef: 感知审计不得是符号链接")
|
||
if not isinstance(perception_audit, dict):
|
||
errors.append("#/postFirstPerceptionAuditRef: 感知审计顶层必须是 JSON 对象")
|
||
events_path, events = load_ref("eventsRef")
|
||
if events_path is not None:
|
||
paths["eventsRef"], loaded["eventsRef"] = events_path, events
|
||
if not isinstance(events, list) or len(events) < 2:
|
||
errors.append("#/eventsRef: 必须封存覆盖双 tap 区间的完整 game event slice")
|
||
else:
|
||
for index, event in enumerate(events):
|
||
errors += [
|
||
f"#/eventsRef/{index}{error[1:]}"
|
||
for error in _schema_errors("game-event.schema.json", event)
|
||
]
|
||
|
||
brief_path = (manifest_path.parent / manifest["briefRef"]).resolve()
|
||
if brief_path.is_symlink() or not brief_path.is_file():
|
||
errors.append("#/briefRef: brief 必须是存在的普通非符号链接文件")
|
||
_trace("load-ref", "brief-regular-file", ref=manifest.get("briefRef"), path=brief_path,
|
||
result="fail", error="brief 不是普通文件")
|
||
brief_bytes = b""
|
||
else:
|
||
brief_bytes = brief_path.read_bytes()
|
||
_trace("load-ref", "brief-regular-file", ref=manifest.get("briefRef"), path=brief_path, result="pass")
|
||
|
||
frame_bytes = {}
|
||
for ref_field, hash_field in (
|
||
("initialFrameFileRef", "initialFrameHash"),
|
||
("postFirstFrameFileRef", "postFirstFrameHash"),
|
||
("secondPostFrameFileRef", "secondPostFrameHash"),
|
||
):
|
||
frame_path = (manifest_path.parent / manifest[ref_field]).resolve()
|
||
try:
|
||
mode = frame_path.lstat().st_mode
|
||
except FileNotFoundError:
|
||
mode = None
|
||
if mode is None or not stat.S_ISREG(mode) or frame_path.is_symlink():
|
||
errors.append(f"#/{ref_field}: frame 必须是存在的普通非符号链接文件")
|
||
_trace("frame", "regular-file", ref=manifest.get(ref_field), path=frame_path,
|
||
declared_hash=manifest.get(hash_field), result="fail", error="frame 文件不存在或不是普通文件")
|
||
continue
|
||
raw = frame_path.read_bytes()
|
||
computed = hashlib.sha256(raw).hexdigest()
|
||
frame_bytes[ref_field] = raw
|
||
if manifest.get(hash_field) != computed:
|
||
errors.append(f"#/{hash_field}: 与 frame 实际字节 SHA-256 不一致")
|
||
_trace("frame", "bytes-sha256", ref=manifest.get(ref_field), path=frame_path,
|
||
declared_hash=manifest.get(hash_field), computed_hash=computed,
|
||
result="fail", error="frame 字节 hash 漂移")
|
||
else:
|
||
_trace("frame", "bytes-sha256", ref=manifest.get(ref_field), path=frame_path,
|
||
declared_hash=manifest.get(hash_field), computed_hash=computed, result="pass")
|
||
if errors:
|
||
# 结构错误后仍允许下面的跨字段门产出目标根因,但缺文件时必须停止解引用。
|
||
if any(value is None for value in loaded.values()) or len(loaded) < len(specs) + 4:
|
||
return errors
|
||
|
||
request = loaded["requestRef"]
|
||
provenance = loaded["provenanceRef"]
|
||
binding = loaded["bindingRef"]
|
||
registry = loaded["registryRef"]
|
||
protocol = loaded["actorProtocolBundleRef"]
|
||
view = loaded["actorViewRef"]
|
||
selection = loaded["selectionRef"]
|
||
attempt = loaded["selectionAttemptRef"]
|
||
pair_resolution = loaded["pairResolutionRef"]
|
||
swap_set = loaded["swapSetRef"]
|
||
initial_targets = loaded["initialTargetSetRef"]
|
||
post_targets = loaded["postFirstTargetSetRef"]
|
||
initial_projection = loaded["initialProjectionRef"]
|
||
post_projection = loaded["postFirstProjectionRef"]
|
||
perception_audit = loaded["postFirstPerceptionAuditRef"]
|
||
first_grouped = loaded["firstGroupedActionRef"]
|
||
second_grouped = loaded["secondGroupedActionRef"]
|
||
first_action = loaded["firstActionRef"]
|
||
second_action = loaded["secondActionRef"]
|
||
board = loaded["boardIdentityCheckRef"]
|
||
group = loaded["actionGroupRef"]
|
||
events = loaded["eventsRef"]
|
||
resolution = loaded["resolutionRef"]
|
||
collection = loaded["collectionProofRef"]
|
||
proof = loaded["singleRollProofRef"]
|
||
judge_package = loaded["judgePackageRef"]
|
||
roll_audit = loaded["rollAuditRef"]
|
||
|
||
def bundle_check(error_path: str, actual, expected, stage: str, check: str) -> None:
|
||
"""记录 bundle 跨文件等值断言;日志只存 hash/路径类标量,不存业务内容。"""
|
||
trace_hash = lambda value: value if isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) else None
|
||
if actual != expected:
|
||
message = f"{error_path}: 跨文件因果身份不一致"
|
||
errors.append(message)
|
||
_trace(stage, check, declared_hash=trace_hash(actual),
|
||
computed_hash=trace_hash(expected),
|
||
result="fail", error=message)
|
||
else:
|
||
_trace(stage, check, declared_hash=trace_hash(actual),
|
||
computed_hash=trace_hash(expected), result="pass")
|
||
|
||
if registry != _load(_HERE / "interaction-profiles.v2.json"):
|
||
errors.append("#/registryRef: D-F 只能使用 canonical v2 registry")
|
||
roll_errors = _validate_match3_roll_audit(paths["rollAuditRef"])
|
||
errors += [f"#/rollAuditRef{error[1:]}" for error in roll_errors]
|
||
for field in ("interactionProfileId", "interactionRegistryVersion", "interactionBindingHash"):
|
||
if roll_audit.get(field) != binding.get(field):
|
||
errors.append(f"#/rollAuditRef/{field}: 必须镜像 bundle binding")
|
||
request_hash = _canonical_domain_hash("acceptance-request/2", request)
|
||
for field in (
|
||
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
|
||
"proofRegistryVersion", "taskBindingHash", "interactionBinding",
|
||
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
|
||
):
|
||
if provenance.get(field) != request.get(field):
|
||
errors.append(f"#/provenanceRef/{field}: 必须镜像 request")
|
||
if provenance.get("acceptanceRequestHash") != request_hash:
|
||
errors.append("#/provenanceRef/acceptanceRequestHash: 与 canonical request 不一致")
|
||
if request.get("interactionBinding") != binding or provenance.get("interactionBinding") != binding:
|
||
errors.append("#/bindingRef: request/provenance/file 三方 binding 必须逐字一致")
|
||
if binding.get("interactionRegistryVersion") != "2026-07-15.v2":
|
||
errors.append("#/bindingRef/interactionRegistryVersion: D-F enforce 必须使用 v2")
|
||
for field, expected in (
|
||
("gameId", request.get("gameId")),
|
||
("artifactHash", provenance.get("artifactHash")),
|
||
("taskBindingHash", request.get("taskBindingHash")),
|
||
("acceptanceRequestHash", request_hash),
|
||
("seed", proof.get("requestedSeed")),
|
||
):
|
||
bundle_check(
|
||
f"#/rollAuditRef/{field}", roll_audit.get(field), expected,
|
||
"roll-preflight", f"formal-{field}",
|
||
)
|
||
|
||
if protocol.get("actorView") != view or protocol.get("selection") != selection:
|
||
errors.append("#/actorProtocolBundleRef: 必须逐字封存当前 View 与 Selection")
|
||
view_swaps = ((view.get("interactionCandidates") or {}).get("match3Swaps") or {})
|
||
expected_candidates = [
|
||
{
|
||
"candidateId": row.get("candidateId"),
|
||
"latticeId": swap_set.get("latticeId"),
|
||
"pair": [{"cell": deepcopy(cell)} for cell in row.get("pair") or []],
|
||
}
|
||
for row in swap_set.get("candidates") or []
|
||
]
|
||
if view_swaps != {
|
||
"candidateSetHash": swap_set.get("candidateSetHash"), "candidates": expected_candidates,
|
||
}:
|
||
errors.append("#/actorViewRef/interactionCandidates: 必须是 SwapSet 的精确脱敏投影")
|
||
errors += [f"#/actorProtocolBundleRef{error[1:]}" for error in _semantic_validate_actor_selection_context({
|
||
"actorView": view, "selection": selection,
|
||
})]
|
||
|
||
raw_hashes = {field: _raw_sample_hash(paths[field], loaded[field]) for field in paths}
|
||
normalization = board.get("overlayNormalizationEvidence") or {}
|
||
audit_raw = paths["postFirstPerceptionAuditRef"].read_bytes()
|
||
audit_input = perception_audit.get("input") or {}
|
||
audit_invocations = ((perception_audit.get("helper") or {}).get("invocations") or [])
|
||
if manifest.get("postFirstPerceptionAuditHash") != raw_hashes.get("postFirstPerceptionAuditRef"):
|
||
errors.append("#/postFirstPerceptionAuditHash: 与感知审计原始字节不一致")
|
||
if normalization.get("auditRef") != manifest.get("postFirstPerceptionAuditRef") \
|
||
or normalization.get("auditHash") != raw_hashes.get("postFirstPerceptionAuditRef"):
|
||
errors.append("#/boardIdentityCheckRef/overlayNormalizationEvidence: 未绑定感知审计原始文件")
|
||
if perception_audit.get("schemaVersion") != "Match3PerceptionShadowAudit/1" \
|
||
or perception_audit.get("mode") != "shadow" \
|
||
or perception_audit.get("status") not in ("shadow_resolved", "shadow_indeterminate") \
|
||
or perception_audit.get("error") is not None \
|
||
or perception_audit.get("auditBytes") != len(audit_raw):
|
||
errors.append("#/postFirstPerceptionAuditRef: 审计身份、状态或 auditBytes 非法")
|
||
if perception_audit.get("interactionBinding") != binding:
|
||
errors.append("#/postFirstPerceptionAuditRef/interactionBinding: 必须镜像可信 binding")
|
||
expected_audit_input = {
|
||
"sourcePath": _audit_path_ref(
|
||
(manifest_path.parent / manifest["postFirstFrameFileRef"]).resolve(),
|
||
),
|
||
"sourceFrameRef": post_targets.get("sourceFrameRef"),
|
||
"expectedSourceHash": post_targets.get("sourceFrameHash"),
|
||
"targetSetRef": manifest.get("postFirstTargetSetRef"),
|
||
"targetSetPath": _audit_path_ref(paths["postFirstTargetSetRef"]),
|
||
"expectedTargetSetHash": post_targets.get("targetSetHash"),
|
||
"latticeId": post_projection.get("latticeId"),
|
||
"projectionRef": manifest.get("postFirstProjectionRef"),
|
||
"overlayReferenceFrameRef": initial_targets.get("sourceFrameRef"),
|
||
"overlayReferencePath": _audit_path_ref(
|
||
(manifest_path.parent / manifest["initialFrameFileRef"]).resolve(),
|
||
),
|
||
"expectedOverlayReferenceHash": initial_targets.get("sourceFrameHash"),
|
||
"selectedCell": (pair_resolution.get("firstEndpoint") or {}).get("cell"),
|
||
}
|
||
for field, expected in expected_audit_input.items():
|
||
if audit_input.get(field) != expected:
|
||
errors.append(f"#/postFirstPerceptionAuditRef/input/{field}: 未绑定双 tap 当前闭包")
|
||
if perception_audit.get("projection") != post_projection:
|
||
errors.append("#/postFirstPerceptionAuditRef/projection: 必须逐字镜像 post-first Projection")
|
||
|
||
first_invocation = audit_invocations[0] if len(audit_invocations) == 2 else {}
|
||
second_invocation = audit_invocations[1] if len(audit_invocations) == 2 else {}
|
||
helper = perception_audit.get("helper") or {}
|
||
expected_flags = [
|
||
"--source", "--target-set", "--expected-source-hash", "--expected-target-set-hash",
|
||
"--lattice-id", "--interaction-binding-hash", "--interaction-registry-version",
|
||
"--source-frame-ref", "--target-set-ref", "--overlay-reference",
|
||
"--expected-overlay-reference-hash", "--selected-row", "--selected-column",
|
||
]
|
||
helper_args = helper.get("args") or []
|
||
helper_values = {}
|
||
if helper.get("path") != "game-runtime/games/_wg1-gen/_shared/match3_board_perception.py" \
|
||
or len(helper_args) != len(expected_flags) * 2:
|
||
errors.append("#/postFirstPerceptionAuditRef/helper: helper 身份或参数形状非法")
|
||
else:
|
||
for index, flag in enumerate(expected_flags):
|
||
if helper_args[index * 2] != flag:
|
||
errors.append("#/postFirstPerceptionAuditRef/helper/args: 参数顺序非法")
|
||
break
|
||
helper_values[flag] = helper_args[index * 2 + 1]
|
||
expected_helper_values = {
|
||
"--source": audit_input.get("sourcePath"),
|
||
"--target-set": audit_input.get("targetSetPath"),
|
||
"--expected-source-hash": audit_input.get("expectedSourceHash"),
|
||
"--expected-target-set-hash": audit_input.get("expectedTargetSetHash"),
|
||
"--lattice-id": audit_input.get("latticeId"),
|
||
"--interaction-binding-hash": binding.get("interactionBindingHash"),
|
||
"--interaction-registry-version": binding.get("interactionRegistryVersion"),
|
||
"--source-frame-ref": audit_input.get("sourceFrameRef"),
|
||
"--target-set-ref": audit_input.get("targetSetRef"),
|
||
"--overlay-reference": audit_input.get("overlayReferencePath"),
|
||
"--expected-overlay-reference-hash": audit_input.get("expectedOverlayReferenceHash"),
|
||
"--selected-row": str(((pair_resolution.get("firstEndpoint") or {}).get("cell") or {}).get("row")),
|
||
"--selected-column": str(((pair_resolution.get("firstEndpoint") or {}).get("cell") or {}).get("column")),
|
||
}
|
||
if any(helper_values.get(flag) != value for flag, value in expected_helper_values.items()):
|
||
errors.append("#/postFirstPerceptionAuditRef/helper/args: 参数未绑定当前证据闭包")
|
||
|
||
invocation_keys = {
|
||
"ordinal", "exitCode", "signal", "spawnError", "stdoutBytes", "stdoutHash", "stdout",
|
||
"stderrBytes", "stderrHash", "stderr", "projectionRawBytes", "projectionRawHash",
|
||
"diagnostics", "peakRssBytes",
|
||
}
|
||
projection_raw_hashes = []
|
||
for ordinal, invocation in enumerate((first_invocation, second_invocation), start=1):
|
||
stdout = invocation.get("stdout")
|
||
stderr = invocation.get("stderr")
|
||
try:
|
||
output = json.loads(stdout)
|
||
projection_raw = _extract_top_level_json_value(stdout, "projection")
|
||
except (TypeError, ValueError, json.JSONDecodeError):
|
||
output = {}
|
||
projection_raw = ""
|
||
raw_stdout = stdout.encode("utf-8") if isinstance(stdout, str) else b""
|
||
raw_stderr = stderr.encode("utf-8") if isinstance(stderr, str) else b""
|
||
raw_projection = projection_raw.encode("utf-8")
|
||
projection_hash = hashlib.sha256(raw_projection).hexdigest()
|
||
projection_raw_hashes.append(projection_hash)
|
||
if set(invocation) != invocation_keys \
|
||
or invocation.get("ordinal") != ordinal \
|
||
or invocation.get("exitCode") != 0 \
|
||
or invocation.get("signal") is not None \
|
||
or invocation.get("spawnError") is not None \
|
||
or invocation.get("stdoutBytes") != len(raw_stdout) \
|
||
or invocation.get("stdoutHash") != hashlib.sha256(raw_stdout).hexdigest() \
|
||
or invocation.get("stderrBytes") != len(raw_stderr) \
|
||
or invocation.get("stderrHash") != hashlib.sha256(raw_stderr).hexdigest() \
|
||
or stderr != "" \
|
||
or invocation.get("projectionRawBytes") != len(raw_projection) \
|
||
or invocation.get("projectionRawHash") != projection_hash \
|
||
or set(output) != {"ok", "projection", "diagnostics"} \
|
||
or output.get("ok") is not True \
|
||
or output.get("projection") != perception_audit.get("projection") \
|
||
or output.get("diagnostics") != invocation.get("diagnostics"):
|
||
errors.append(
|
||
f"#/postFirstPerceptionAuditRef/helper/invocations/{ordinal - 1}: "
|
||
"执行状态、原始输出或解析结果不一致",
|
||
)
|
||
if len(projection_raw_hashes) != 2 or projection_raw_hashes[0] != projection_raw_hashes[1]:
|
||
errors.append("#/postFirstPerceptionAuditRef/helper/invocations: 两次 Projection 原始字节不一致")
|
||
|
||
first_diagnostics = first_invocation.get("diagnostics") or {}
|
||
second_diagnostics = second_invocation.get("diagnostics") or {}
|
||
first_config = (first_diagnostics.get("config") or {}).get("selectionOverlay")
|
||
second_config = (second_diagnostics.get("config") or {}).get("selectionOverlay")
|
||
first_result = first_diagnostics.get("selectionOverlay")
|
||
second_result = second_diagnostics.get("selectionOverlay")
|
||
if len(audit_invocations) != 2 or first_invocation.get("ordinal") != 1 \
|
||
or second_invocation.get("ordinal") != 2 \
|
||
or first_config != second_config or first_result != second_result:
|
||
errors.append("#/postFirstPerceptionAuditRef/helper/invocations: 双跑配置与归一化结果必须一致")
|
||
first_config = first_config if isinstance(first_config, dict) else {}
|
||
first_result = first_result if isinstance(first_result, dict) else {}
|
||
perception_config = first_diagnostics.get("config") or {}
|
||
perception_config_hash = _canonical_domain_hash("match3-perception-config/1", perception_config)
|
||
overlay_config_hash = _canonical_domain_hash("match3-selection-overlay-config/1", first_config or {})
|
||
if overlay_config_hash != _SELECTION_OVERLAY_CONFIG_HASH:
|
||
errors.append(
|
||
"#/postFirstPerceptionAuditRef/helper/diagnostics/config/selectionOverlay: "
|
||
"必须逐字使用 canonical overlay config",
|
||
)
|
||
initial_classifier = initial_projection.get("classifier") or {}
|
||
post_classifier = post_projection.get("classifier") or {}
|
||
if initial_classifier != post_classifier \
|
||
or post_classifier.get("id") != "deterministic-lab-cell-cluster" \
|
||
or post_classifier.get("version") != "1.1.0" \
|
||
or post_classifier.get("configHash") != perception_config_hash:
|
||
errors.append("#/postFirstPerceptionAuditRef/helper/diagnostics/config: classifier/configHash 不可比较")
|
||
support_hash = first_result.get("supportMaskHash") if isinstance(first_result, dict) else None
|
||
if isinstance(first_result, dict) and first_result.get("status") == "normalized":
|
||
side = (first_config or {}).get("cellSizePx")
|
||
spans = (first_config or {}).get("supportSpans")
|
||
mask = bytearray(side * side) if isinstance(side, int) and 0 < side <= 256 else None
|
||
try:
|
||
if mask is None or not isinstance(spans, list) or len(spans) != side:
|
||
raise ValueError("support mask shape")
|
||
for row, intervals in enumerate(spans):
|
||
for start, end in intervals:
|
||
if not (isinstance(start, int) and isinstance(end, int)
|
||
and 0 <= start <= end < side):
|
||
raise ValueError("support span")
|
||
mask[row * side + start:row * side + end + 1] = b"\x01" * (end - start + 1)
|
||
if hashlib.sha256(mask).hexdigest() != support_hash \
|
||
or sum(mask) != first_result.get("changedPixelCount"):
|
||
raise ValueError("support hash")
|
||
except (TypeError, ValueError):
|
||
errors.append("#/postFirstPerceptionAuditRef/helper/diagnostics/selectionOverlay/supportMaskHash: 复算失败")
|
||
|
||
# bundle 门不信 audit 自报的 normalized/not-needed;直接从两张封存 PNG 复算像素事实。
|
||
try:
|
||
initial_width, initial_height, initial_rgb = _decode_png_rgb(
|
||
frame_bytes["initialFrameFileRef"],
|
||
)
|
||
post_width, post_height, post_rgb = _decode_png_rgb(
|
||
frame_bytes["postFirstFrameFileRef"],
|
||
)
|
||
if (initial_width, initial_height) != (post_width, post_height):
|
||
raise ValueError("frame dimensions")
|
||
selected_cell = (pair_resolution.get("firstEndpoint") or {}).get("cell") or {}
|
||
lattice = next(
|
||
(item for item in post_targets.get("targets") or []
|
||
if item.get("id") == post_projection.get("latticeId")),
|
||
{},
|
||
)
|
||
row_centers = lattice.get("rowCenters") or []
|
||
column_centers = lattice.get("columnCenters") or []
|
||
row = selected_cell.get("row")
|
||
column = selected_cell.get("column")
|
||
side = (first_config or {}).get("cellSizePx")
|
||
if not isinstance(row, int) or not isinstance(column, int) or not isinstance(side, int):
|
||
raise ValueError("selected cell")
|
||
center_y = row_centers[row - 1]
|
||
center_x = column_centers[column - 1]
|
||
top, left = center_y - side // 2, center_x - side // 2
|
||
expected_pixels = set()
|
||
for relative_row, intervals in enumerate((first_config or {}).get("supportSpans") or []):
|
||
for start, end in intervals:
|
||
expected_pixels.update(
|
||
(top + relative_row, left + relative_column)
|
||
for relative_column in range(start, end + 1)
|
||
)
|
||
changed_pixels = set()
|
||
maximum_alpha_spread = 0.0
|
||
maximum_replay_residual = 0
|
||
opaque_pixel_count = 0
|
||
for y in range(initial_height):
|
||
for x in range(initial_width):
|
||
offset = (y * initial_width + x) * 3
|
||
before = tuple(initial_rgb[offset:offset + 3])
|
||
after = tuple(post_rgb[offset:offset + 3])
|
||
if before == after:
|
||
continue
|
||
changed_pixels.add((y, x))
|
||
if any(current < previous for previous, current in zip(before, after)):
|
||
raise ValueError("channel decreased")
|
||
alphas = [
|
||
(current - previous) * 1000.0 / (255 - previous)
|
||
for previous, current in zip(before, after)
|
||
if previous <= (first_config or {}).get("maximumReferenceChannelForAlpha", -1)
|
||
]
|
||
if not alphas:
|
||
raise ValueError("alpha unresolved")
|
||
maximum_alpha_spread = max(maximum_alpha_spread, max(alphas) - min(alphas))
|
||
alpha = sum(alphas) / len(alphas)
|
||
if alpha >= (first_config or {}).get("opaqueAlphaThresholdPermille", 1001):
|
||
opaque_pixel_count += 1
|
||
replay = tuple(round(previous + (alpha / 1000.0) * (255 - previous)) for previous in before)
|
||
maximum_replay_residual = max(
|
||
maximum_replay_residual,
|
||
max(abs(replayed - current) for replayed, current in zip(replay, after)),
|
||
)
|
||
outside_count = len(changed_pixels - expected_pixels)
|
||
opaque_pixel_permille = (
|
||
opaque_pixel_count * 1000 // max(1, len(changed_pixels))
|
||
)
|
||
if not changed_pixels:
|
||
if (first_result or {}).get("status") != "not-needed" \
|
||
or (first_result or {}).get("changedPixelCount") != 0 \
|
||
or (first_result or {}).get("outsideSelectedCellCount") != 0:
|
||
raise ValueError("not-needed mismatch")
|
||
elif changed_pixels != expected_pixels \
|
||
or (first_result or {}).get("status") != "normalized" \
|
||
or (first_result or {}).get("changedPixelCount") != len(changed_pixels) \
|
||
or (first_result or {}).get("outsideSelectedCellCount") != outside_count \
|
||
or (first_result or {}).get("maximumAlphaSpreadPermille") != math.ceil(maximum_alpha_spread) \
|
||
or (first_result or {}).get("maximumReplayResidual") != maximum_replay_residual \
|
||
or (first_result or {}).get("opaquePixelPermille") != opaque_pixel_permille \
|
||
or opaque_pixel_permille < (first_config or {}).get("minimumOpaquePixelPermille", 1001):
|
||
raise ValueError("normalized pixel mismatch")
|
||
except (IndexError, KeyError, TypeError, ValueError, zlib.error, struct.error):
|
||
errors.append(
|
||
"#/postFirstPerceptionAuditRef/helper/diagnostics/selectionOverlay: "
|
||
"与 initial/post-first PNG 独立像素复算不一致",
|
||
)
|
||
expected_normalization = {
|
||
"schemaVersion": "OverlayNormalizationEvidence/1",
|
||
"auditRef": manifest.get("postFirstPerceptionAuditRef"),
|
||
"auditHash": raw_hashes.get("postFirstPerceptionAuditRef"),
|
||
"auditSchemaVersion": perception_audit.get("schemaVersion"),
|
||
"overlayReferenceFrameRef": initial_targets.get("sourceFrameRef"),
|
||
"overlayReferenceFrameHash": initial_targets.get("sourceFrameHash"),
|
||
"postFirstFrameRef": post_targets.get("sourceFrameRef"),
|
||
"postFirstFrameHash": post_targets.get("sourceFrameHash"),
|
||
"selectedCell": (pair_resolution.get("firstEndpoint") or {}).get("cell"),
|
||
"perceptionConfigHash": perception_config_hash,
|
||
"selectionOverlayConfigHash": overlay_config_hash,
|
||
"invocationCount": 2,
|
||
"deterministic": True,
|
||
"status": (first_result or {}).get("status"),
|
||
"changedPixelCount": (first_result or {}).get("changedPixelCount"),
|
||
"outsideSelectedCellCount": (first_result or {}).get("outsideSelectedCellCount"),
|
||
"supportMaskHash": (first_result or {}).get("supportMaskHash"),
|
||
"maximumAlphaSpreadPermille": (first_result or {}).get("maximumAlphaSpreadPermille"),
|
||
"maximumReplayResidual": (first_result or {}).get("maximumReplayResidual"),
|
||
"reasonCodes": (first_result or {}).get("reasonCodes"),
|
||
"resultHash": _canonical_domain_hash("match3-selection-overlay-result/1", first_result or {}),
|
||
}
|
||
if normalization != expected_normalization:
|
||
errors.append("#/boardIdentityCheckRef/overlayNormalizationEvidence: 必须从原始 audit 独立重建")
|
||
if manifest.get("rollAuditHash") != raw_hashes.get("rollAuditRef"):
|
||
errors.append("#/rollAuditHash: 与 roll audit 封存字节不一致")
|
||
audit_groups = roll_audit.get("groups") or []
|
||
audit_last = audit_groups[-1] if audit_groups else {}
|
||
audit_group_path = (
|
||
paths["rollAuditRef"].parent / str(audit_last.get("ref") or "")
|
||
).resolve()
|
||
if audit_group_path != paths["actionGroupRef"] \
|
||
or audit_last.get("hash") != raw_hashes["actionGroupRef"] \
|
||
or audit_last.get("actionGroupId") != group.get("actionGroupId"):
|
||
errors.append("#/rollAuditRef/groups: 最终 group 必须绑定 bundle actionGroup 原始文件")
|
||
if attempt.get("actorViewRef") != manifest.get("actorViewRef") \
|
||
or attempt.get("actorViewHash") != raw_hashes["actorViewRef"]:
|
||
errors.append("#/selectionAttemptRef/actorViewHash: 未绑定 ActorView 原始文件")
|
||
if attempt.get("selectionRef") != manifest.get("selectionRef") \
|
||
or attempt.get("selectionHash") != raw_hashes["selectionRef"] \
|
||
or attempt.get("selection") != selection or attempt.get("accepted") is not True:
|
||
errors.append("#/selectionAttemptRef: 必须绑定唯一 accepted Selection")
|
||
if pair_resolution.get("selectionRef") != manifest.get("selectionRef") \
|
||
or pair_resolution.get("selectionHash") != raw_hashes["selectionRef"]:
|
||
errors.append("#/pairResolutionRef/selectionHash: 未绑定 Selection")
|
||
if pair_resolution.get("swapSetRef") != manifest.get("swapSetRef") \
|
||
or pair_resolution.get("swapSetHash") != raw_hashes["swapSetRef"]:
|
||
errors.append("#/pairResolutionRef/swapSetHash: 未绑定 SwapSet")
|
||
candidate = next((row for row in swap_set.get("candidates") or []
|
||
if row.get("candidateId") == selection.get("candidateId")), None)
|
||
if candidate is None:
|
||
errors.append("#/selectionRef/candidateId: 未命中 sealed SwapSet")
|
||
else:
|
||
expected_pair = candidate.get("pair")
|
||
for field, expected in (
|
||
("candidateSetHash", swap_set.get("candidateSetHash")),
|
||
("candidateId", candidate.get("candidateId")), ("latticeId", swap_set.get("latticeId")),
|
||
("pair", expected_pair), ("firstEndpoint", selection.get("firstEndpoint")),
|
||
):
|
||
if pair_resolution.get(field) != expected:
|
||
errors.append(f"#/pairResolutionRef/{field}: 必须由 Selection + SwapSet 确定性解析")
|
||
|
||
identity = ("interactionProfileId", "interactionRegistryVersion", "interactionBindingHash")
|
||
for holder_name, holder in (
|
||
("swapSetRef", swap_set), ("initialProjectionRef", initial_projection),
|
||
("postFirstProjectionRef", post_projection), ("pairResolutionRef", pair_resolution),
|
||
("boardIdentityCheckRef", board),
|
||
("actionGroupRef", group),
|
||
):
|
||
for field in identity:
|
||
if holder.get(field) != binding.get(field):
|
||
errors.append(f"#/{holder_name}/{field}: 必须镜像 request binding")
|
||
|
||
def target_lattice(target_set: dict, lattice_id: str):
|
||
return next((row for row in target_set.get("targets") or [] if row.get("id") == lattice_id), None)
|
||
|
||
for side_name, target_set, projection, target_ref_field, projection_ref_field in (
|
||
("initial", initial_targets, initial_projection, "initialTargetSetRef", "initialProjectionRef"),
|
||
("postFirst", post_targets, post_projection, "postFirstTargetSetRef", "postFirstProjectionRef"),
|
||
):
|
||
side = board.get(side_name) or {}
|
||
bundle_check(
|
||
f"#/{projection_ref_field}/sourceFrameRef", projection.get("sourceFrameRef"),
|
||
target_set.get("sourceFrameRef"), "board-causality", f"{side_name}-projection-source-ref",
|
||
)
|
||
bundle_check(
|
||
f"#/{projection_ref_field}/sourceFrameHash", projection.get("sourceFrameHash"),
|
||
target_set.get("sourceFrameHash"), "board-causality", f"{side_name}-projection-source-hash",
|
||
)
|
||
bundle_check(
|
||
f"#/{projection_ref_field}/targetSetRef", projection.get("targetSetRef"),
|
||
manifest.get(target_ref_field), "board-causality", f"{side_name}-projection-target-ref",
|
||
)
|
||
bundle_check(
|
||
f"#/{projection_ref_field}/targetSetHash", projection.get("targetSetHash"),
|
||
target_set.get("targetSetHash"), "board-causality", f"{side_name}-projection-target-hash",
|
||
)
|
||
lattice = target_lattice(target_set, projection.get("latticeId"))
|
||
if lattice is None:
|
||
errors.append(f"#/boardIdentityCheckRef/{side_name}/lattice: TargetSet 不含 projection lattice")
|
||
continue
|
||
expected_geometry = {
|
||
"latticeId": lattice.get("id"), "dimensions": lattice.get("dimensions"),
|
||
"rowCenters": lattice.get("rowCenters"), "columnCenters": lattice.get("columnCenters"),
|
||
}
|
||
expected_values = {
|
||
"sourceFrameRef": target_set.get("sourceFrameRef"),
|
||
"sourceFrameHash": target_set.get("sourceFrameHash"),
|
||
"targetSetRef": manifest.get(target_ref_field),
|
||
"targetSetHash": target_set.get("targetSetHash"),
|
||
"projectionRef": manifest.get(projection_ref_field),
|
||
"projectionHash": raw_hashes[projection_ref_field],
|
||
"lattice": expected_geometry,
|
||
"equivalenceMatrixHash": _projection_equivalence_hash(projection),
|
||
}
|
||
for field, expected in expected_values.items():
|
||
if side.get(field) != expected:
|
||
errors.append(f"#/boardIdentityCheckRef/{side_name}/{field}: 必须由 TargetSet/Projection 重算")
|
||
|
||
first_pre = first_action.get("preFrameRef") or {}
|
||
first_post = first_action.get("postFrameRef") or {}
|
||
second_pre = second_action.get("preFrameRef") or {}
|
||
second_post = second_action.get("postFrameRef") or {}
|
||
for error_path, actual, expected, check in (
|
||
("#/initialTargetSetRef/sourceFrameRef", initial_targets.get("sourceFrameRef"), first_pre.get("path"), "initial-target-action-ref"),
|
||
("#/initialTargetSetRef/sourceFrameHash", initial_targets.get("sourceFrameHash"), first_pre.get("hash"), "initial-target-action-hash"),
|
||
("#/postFirstTargetSetRef/sourceFrameRef", post_targets.get("sourceFrameRef"), first_post.get("path"), "post-target-first-action-ref"),
|
||
("#/postFirstTargetSetRef/sourceFrameHash", post_targets.get("sourceFrameHash"), first_post.get("hash"), "post-target-first-action-hash"),
|
||
("#/secondActionRef/preFrameRef/path", second_pre.get("path"), first_post.get("path"), "second-pre-first-post-ref"),
|
||
("#/secondActionRef/preFrameRef/hash", second_pre.get("hash"), first_post.get("hash"), "second-pre-first-post-hash"),
|
||
("#/initialFrameHash", manifest.get("initialFrameHash"), first_pre.get("hash"), "initial-frame-bytes-action-hash"),
|
||
("#/postFirstFrameHash", manifest.get("postFirstFrameHash"), first_post.get("hash"), "post-frame-bytes-action-hash"),
|
||
("#/secondPostFrameHash", manifest.get("secondPostFrameHash"), second_post.get("hash"), "second-post-frame-bytes-action-hash"),
|
||
("#/secondPostFrameRef", manifest.get("secondPostFrameRef"), second_post.get("path"), "second-post-logical-ref"),
|
||
):
|
||
bundle_check(error_path, actual, expected, "frame-causality", check)
|
||
|
||
for field, expected in (
|
||
("sourceFrameRef", initial_targets.get("sourceFrameRef")),
|
||
("sourceFrameHash", initial_targets.get("sourceFrameHash")),
|
||
("targetSetRef", manifest.get("initialTargetSetRef")),
|
||
("targetSetHash", initial_targets.get("targetSetHash")),
|
||
("projectionRef", manifest.get("initialProjectionRef")),
|
||
("projectionHash", initial_projection.get("projectionHash")),
|
||
):
|
||
bundle_check(f"#/swapSetRef/{field}", swap_set.get(field), expected,
|
||
"board-causality", f"swap-{field}")
|
||
if board.get("firstActionRef") != manifest.get("firstActionRef") \
|
||
or board.get("firstActionHash") != raw_hashes["firstActionRef"]:
|
||
errors.append("#/boardIdentityCheckRef/firstActionHash: 未绑定第一原生 action")
|
||
if board.get("firstEndpoint") != pair_resolution.get("firstEndpoint"):
|
||
errors.append("#/boardIdentityCheckRef/firstEndpoint: 必须镜像 pair resolution")
|
||
|
||
for grouped_name, grouped, action_name, action, role, endpoint in (
|
||
("firstGroupedActionRef", first_grouped, "firstActionRef", first_action, "first", pair_resolution.get("firstEndpoint")),
|
||
("secondGroupedActionRef", second_grouped, "secondActionRef", second_action, "second", pair_resolution.get("secondEndpoint")),
|
||
):
|
||
if grouped.get("actionGroupId") != group.get("actionGroupId") or grouped.get("role") != role:
|
||
errors.append(f"#/{grouped_name}: actionGroupId/role 不一致")
|
||
if grouped.get("actionId") != action.get("actionId") or grouped.get("endpoint") != endpoint:
|
||
errors.append(f"#/{grouped_name}: 未绑定对应原生 action 与 endpoint")
|
||
if grouped.get("actionRef") != manifest.get(action_name) \
|
||
or grouped.get("actionHash") != raw_hashes[action_name]:
|
||
errors.append(f"#/{grouped_name}/actionHash: 未绑定原生 action 文件")
|
||
|
||
if int(re.sub(r"\D", "", second_action.get("actionId") or "0")) \
|
||
!= int(re.sub(r"\D", "", first_action.get("actionId") or "0")) + 1:
|
||
errors.append("#/secondActionRef/actionId: 双 tap 原生 action 必须连续")
|
||
if first_action.get("postFrameRef") != second_action.get("preFrameRef"):
|
||
errors.append("#/secondActionRef/preFrameRef: 必须承接第一 tap post frame")
|
||
if first_action.get("postEventSeq") != second_action.get("preEventSeq"):
|
||
errors.append("#/secondActionRef/preEventSeq: 必须承接第一 tap postEventSeq")
|
||
if second_action.get("postFrameRef", {}).get("path") != manifest.get("secondPostFrameRef"):
|
||
errors.append("#/secondPostFrameRef: 必须等于第二 tap post frame")
|
||
for action, endpoint, target_set, label in (
|
||
(first_action, pair_resolution.get("firstEndpoint"), initial_targets, "firstActionRef"),
|
||
(second_action, pair_resolution.get("secondEndpoint"), post_targets, "secondActionRef"),
|
||
):
|
||
lattice = target_lattice(target_set, endpoint.get("id")) or {}
|
||
cell = endpoint.get("cell") or {}
|
||
expected_xy = {
|
||
"type": "tap",
|
||
"x": (lattice.get("columnCenters") or [])[cell.get("column", 1) - 1],
|
||
"y": (lattice.get("rowCenters") or [])[cell.get("row", 1) - 1],
|
||
}
|
||
if action.get("normalized") != expected_xy:
|
||
errors.append(f"#/{label}/normalized: 必须由对应时刻 TargetSet lattice center 解析")
|
||
|
||
group_expected = {
|
||
"selectionAttemptRef": manifest.get("selectionAttemptRef"),
|
||
"selectionAttemptHash": raw_hashes["selectionAttemptRef"],
|
||
"pairResolutionRef": manifest.get("pairResolutionRef"),
|
||
"pairResolutionHash": raw_hashes["pairResolutionRef"],
|
||
"candidateSetHash": swap_set.get("candidateSetHash"),
|
||
"candidateId": pair_resolution.get("candidateId"),
|
||
"initialSourceFrameHash": swap_set.get("sourceFrameHash"),
|
||
"initialTargetSetHash": swap_set.get("targetSetHash"),
|
||
"latticeId": pair_resolution.get("latticeId"), "pair": pair_resolution.get("pair"),
|
||
"firstEndpoint": pair_resolution.get("firstEndpoint"),
|
||
"secondEndpoint": pair_resolution.get("secondEndpoint"),
|
||
"firstActionId": first_action.get("actionId"), "secondActionId": second_action.get("actionId"),
|
||
"firstGroupedActionRef": manifest.get("firstGroupedActionRef"),
|
||
"firstGroupedActionHash": raw_hashes["firstGroupedActionRef"],
|
||
"secondGroupedActionRef": manifest.get("secondGroupedActionRef"),
|
||
"secondGroupedActionHash": raw_hashes["secondGroupedActionRef"],
|
||
"boardIdentityCheckRef": manifest.get("boardIdentityCheckRef"),
|
||
"boardIdentityCheckHash": raw_hashes["boardIdentityCheckRef"],
|
||
}
|
||
for field, expected in group_expected.items():
|
||
if group.get(field) != expected:
|
||
errors.append(f"#/actionGroupRef/{field}: 未闭合 runner 审计引用")
|
||
|
||
if board.get("result") != "equivalent":
|
||
errors.append("#/boardIdentityCheckRef/result: 只有 equivalent 才能派发第二 tap")
|
||
if group.get("state") != "completed":
|
||
errors.append("#/actionGroupRef/state: 只有 completed 才能形成玩法证明")
|
||
if isinstance(events, list) and len(events) >= 2:
|
||
event_seqs = [event.get("seq") for event in events]
|
||
expected_slice = list(range(first_action.get("preEventSeq", 0) + 1, second_action.get("postEventSeq", 0) + 1))
|
||
if event_seqs != expected_slice:
|
||
errors.append("#/eventsRef/seq: 必须完整覆盖 firstAction.preEventSeq 到 secondAction.postEventSeq 的事件区间")
|
||
first_types = [
|
||
event.get("type") for event in events
|
||
if first_action.get("preEventSeq", 0) < event.get("seq", 0) <= first_action.get("postEventSeq", 0)
|
||
]
|
||
if any(event_type in ("puzzle.swap-committed", "puzzle.match-formed") for event_type in first_types):
|
||
errors.append("#/eventsRef: 第一 tap 区间不得提前产生 swap-committed/match-formed")
|
||
proof_events = [
|
||
event for event in events
|
||
if event.get("actionId") == second_action.get("actionId")
|
||
and event.get("type") in ("puzzle.swap-committed", "puzzle.match-formed")
|
||
]
|
||
if len(proof_events) != 2:
|
||
errors.append("#/eventsRef: second action 必须且只能产生一条 swap-committed 与一条 match-formed")
|
||
return errors
|
||
swap_event, match_event = proof_events
|
||
for index, event in enumerate((swap_event, match_event)):
|
||
if event.get("evidenceMode") != "native":
|
||
errors.append(f"#/eventsRef/{index}/evidenceMode: Match-3 proof 事件必须为 native")
|
||
if event.get("eventRole") != "profile-proof":
|
||
errors.append(f"#/eventsRef/{index}/eventRole: Match-3 proof 事件必须为 profile-proof")
|
||
if [swap_event.get("type"), match_event.get("type")] != [
|
||
"puzzle.swap-committed", "puzzle.match-formed",
|
||
]:
|
||
errors.append("#/eventsRef: 事件必须按 swap-committed→match-formed 排序")
|
||
swap_index = events.index(swap_event)
|
||
match_index = events.index(match_event)
|
||
if swap_index + 1 != match_index or swap_event.get("seq", 0) + 1 != match_event.get("seq"):
|
||
errors.append("#/eventsRef: 两条 proof 事件必须在完整事件流中相邻")
|
||
if swap_event.get("actionId") != second_action.get("actionId") \
|
||
or match_event.get("actionId") != second_action.get("actionId"):
|
||
errors.append("#/eventsRef/actionId: 两事件必须属于 secondActionId")
|
||
second_pre_seq = second_action.get("preEventSeq", 0)
|
||
second_post_seq = second_action.get("postEventSeq", 0)
|
||
target_event_seqs = [swap_event.get("seq"), match_event.get("seq")]
|
||
if not all(
|
||
isinstance(seq, int) and second_pre_seq < seq <= second_post_seq
|
||
for seq in target_event_seqs
|
||
):
|
||
errors.append("#/eventsRef/seq: 两事件必须位于 secondAction 的 (preEventSeq, postEventSeq] 区间")
|
||
if swap_event.get("virtualTimeMs") != match_event.get("virtualTimeMs"):
|
||
errors.append("#/eventsRef/virtualTimeMs: 两事件必须来自同一同步栈")
|
||
swap_payload, match_payload = swap_event.get("payload") or {}, match_event.get("payload") or {}
|
||
if swap_payload.get("moveId") != match_payload.get("moveId"):
|
||
errors.append("#/eventsRef/moveId: 两事件 moveId 必须一致")
|
||
if swap_payload.get("pair") != group.get("pair"):
|
||
errors.append("#/eventsRef/0/payload/pair: 必须等于 actionGroup canonical pair")
|
||
run_cells = [cell for run in match_payload.get("runs") or [] for cell in run.get("cells") or []]
|
||
if not any(cell in group.get("pair", []) for cell in run_cells):
|
||
errors.append("#/eventsRef/1/payload/runs: 至少一个 run 必须包含交换端点")
|
||
expected_proof = {
|
||
"id": "puzzle.match3-valid-swap", "required": True, "status": "satisfied",
|
||
"sequenceRefs": [
|
||
{"stepId": "swap-committed", "eventRef": swap_event.get("seq"),
|
||
"actionRef": second_action.get("actionId"), "postFrameRef": manifest.get("secondPostFrameRef")},
|
||
{"stepId": "match-formed", "eventRef": match_event.get("seq"),
|
||
"actionRef": second_action.get("actionId"), "postFrameRef": manifest.get("secondPostFrameRef")},
|
||
],
|
||
"actionRefs": [second_action.get("actionId")],
|
||
"postFrameRefs": [manifest.get("secondPostFrameRef")],
|
||
"eventRefs": [swap_event.get("seq"), match_event.get("seq")],
|
||
"contradictions": [], "blockingProblems": [],
|
||
}
|
||
|
||
proof_registry = _load_proof_registry(request.get("proofRegistryVersion"))
|
||
proof_profile = (proof_registry.get("profiles") or {}).get(request.get("proofProfileId")) or {}
|
||
obligation_by_id = {row.get("id"): row for row in proof_profile.get("obligations") or []}
|
||
required_ids = resolution.get("requiredObligationIds") or []
|
||
if "puzzle.match3-valid-swap" not in required_ids:
|
||
errors.append("#/resolutionRef/requiredObligationIds: 必须提升 puzzle.match3-valid-swap")
|
||
expected_candidates = []
|
||
for obligation_id in required_ids:
|
||
canonical = obligation_by_id.get(obligation_id) or {}
|
||
complete = obligation_id == "puzzle.match3-valid-swap"
|
||
sequence_refs = deepcopy(expected_proof["sequenceRefs"]) if complete else []
|
||
action_refs = deepcopy(expected_proof["actionRefs"]) if complete else []
|
||
post_refs = deepcopy(expected_proof["postFrameRefs"]) if complete else []
|
||
event_refs = deepcopy(expected_proof["eventRefs"]) if complete else []
|
||
steps = ((canonical.get("evidence") or {}).get("sequence") or [])
|
||
expected_candidates.append({
|
||
"id": obligation_id,
|
||
"title": canonical.get("title", ""),
|
||
"description": canonical.get("description", ""),
|
||
"required": True,
|
||
"evidence": deepcopy(canonical.get("evidence") or {}),
|
||
"candidateOnly": True,
|
||
"candidateState": "complete" if complete else "missing",
|
||
"candidateProblems": [],
|
||
"sequenceRefs": sequence_refs,
|
||
"actionRefs": action_refs,
|
||
"postFrameRefs": post_refs,
|
||
"eventRefs": event_refs,
|
||
"evidenceRefs": action_refs + post_refs + [f"event:{seq}" for seq in event_refs],
|
||
"seenStepTitles": [step.get("title") for step in steps] if complete else [],
|
||
"missingStepTitles": [] if complete else [step.get("title") for step in steps],
|
||
})
|
||
expected_proof_obligations = [_proof_obligation_from_candidate(row) for row in expected_candidates]
|
||
|
||
request_hash = _canonical_domain_hash("acceptance-request/2", request)
|
||
brief_hash = hashlib.sha256(brief_bytes).hexdigest()
|
||
for holder_name, holder in (
|
||
("collectionProofRef", collection), ("singleRollProofRef", proof), ("judgePackageRef", judge_package),
|
||
):
|
||
for field, expected in (
|
||
("gameId", request.get("gameId")), ("genre", request.get("genre")),
|
||
("templateRoute", request.get("templateRoute")), ("proofProfileId", request.get("proofProfileId")),
|
||
("proofRegistryVersion", request.get("proofRegistryVersion")),
|
||
("taskBindingHash", request.get("taskBindingHash")),
|
||
("acceptanceRequestHash", request_hash), ("artifactHash", provenance.get("artifactHash")),
|
||
("briefHash", brief_hash),
|
||
):
|
||
bundle_check(f"#/{holder_name}/{field}", holder.get(field), expected,
|
||
"proof-identity", f"{holder_name}-{field}")
|
||
|
||
resolution_raw_hash = raw_hashes["resolutionRef"]
|
||
collection_raw_hash = raw_hashes["collectionProofRef"]
|
||
judge_raw_hash = raw_hashes["judgePackageRef"]
|
||
for field, expected in (
|
||
("proofProfileId", request.get("proofProfileId")),
|
||
("proofRegistryVersion", request.get("proofRegistryVersion")),
|
||
("taskBindingHash", request.get("taskBindingHash")),
|
||
("acceptanceRequestHash", request_hash),
|
||
("interactionBindingRef", manifest.get("bindingRef")),
|
||
("interactionBindingFileHash", raw_hashes["bindingRef"]),
|
||
("interactionBindingHash", binding.get("interactionBindingHash")),
|
||
):
|
||
bundle_check(f"#/resolutionRef/{field}", resolution.get(field), expected,
|
||
"resolution", field)
|
||
try:
|
||
brief_text = brief_bytes.decode("utf-8", errors="strict")
|
||
except UnicodeDecodeError:
|
||
errors.append("#/briefRef: brief 必须是严格 UTF-8")
|
||
brief_text = ""
|
||
bundle_check("#/judgePackageRef/brief", judge_package.get("brief"), brief_text,
|
||
"judge-projection", "brief-bytes")
|
||
bundle_check("#/singleRollProofRef/proofObligationResolutionRef",
|
||
proof.get("proofObligationResolutionRef"), manifest.get("resolutionRef"),
|
||
"proof-hash", "resolution-ref")
|
||
bundle_check("#/singleRollProofRef/proofObligationResolutionHash",
|
||
proof.get("proofObligationResolutionHash"), resolution_raw_hash,
|
||
"proof-hash", "resolution-bytes")
|
||
bundle_check("#/singleRollProofRef/collectionProofRef", proof.get("collectionProofRef"),
|
||
manifest.get("collectionProofRef"), "proof-hash", "collection-ref")
|
||
bundle_check("#/singleRollProofRef/collectionProofHash", proof.get("collectionProofHash"),
|
||
collection_raw_hash, "proof-hash", "collection-bytes")
|
||
bundle_check("#/singleRollProofRef/judgePackageRef", proof.get("judgePackageRef"),
|
||
manifest.get("judgePackageRef"), "proof-hash", "judge-ref")
|
||
bundle_check("#/singleRollProofRef/judgePackageHash", proof.get("judgePackageHash"),
|
||
judge_raw_hash, "proof-hash", "judge-bytes")
|
||
bundle_check("#/singleRollProofRef/judge/packageHash", (proof.get("judge") or {}).get("packageHash"),
|
||
judge_raw_hash, "proof-hash", "judge-consensus-package")
|
||
bundle_check("#/judgePackageRef/proofRef", judge_package.get("proofRef"),
|
||
manifest.get("collectionProofRef"), "judge-projection", "collection-ref")
|
||
bundle_check("#/judgePackageRef/proofHash", judge_package.get("proofHash"),
|
||
collection_raw_hash, "judge-projection", "collection-bytes")
|
||
|
||
expected_actions = [first_action, second_action]
|
||
expected_proof_actions = [_proof_action_projection(action) for action in expected_actions]
|
||
expected_judge_actions = [_judge_action_projection(action, events) for action in expected_actions]
|
||
bundle_check("#/collectionProofRef/actions", collection.get("actions"), expected_actions,
|
||
"collection", "native-actions")
|
||
bundle_check("#/collectionProofRef/events", collection.get("events"), events,
|
||
"collection", "native-events")
|
||
bundle_check("#/collectionProofRef/obligationCandidates", collection.get("obligationCandidates"),
|
||
expected_candidates, "collection", "obligation-candidates")
|
||
bundle_check("#/collectionProofRef/provenance/requestHash",
|
||
(collection.get("provenance") or {}).get("requestHash"), request_hash,
|
||
"collection", "request-hash")
|
||
bundle_check("#/collectionProofRef/provenance/manifestHash",
|
||
(collection.get("provenance") or {}).get("manifestHash"),
|
||
raw_hashes["actionGroupRef"], "collection", "action-group-manifest-hash")
|
||
bundle_check("#/singleRollProofRef/actions", proof.get("actions"), expected_proof_actions,
|
||
"proof-projection", "proof-actions")
|
||
bundle_check("#/singleRollProofRef/events", proof.get("events"), proof_events,
|
||
"proof-projection", "proof-events")
|
||
bundle_check("#/singleRollProofRef/proofObligations", proof.get("proofObligations"),
|
||
expected_proof_obligations, "proof-projection", "proof-obligations")
|
||
bundle_check("#/judgePackageRef/proofObligations", judge_package.get("proofObligations"),
|
||
expected_candidates, "judge-projection", "obligation-candidates")
|
||
bundle_check("#/judgePackageRef/actions", judge_package.get("actions"), expected_judge_actions,
|
||
"judge-projection", "actions")
|
||
bundle_check("#/judgePackageRef/gameEvents", judge_package.get("gameEvents"), events,
|
||
"judge-projection", "events")
|
||
|
||
expected_frames = [
|
||
{"actionId": first_action.get("actionId"), "kind": "pre", "ref": first_pre.get("path"),
|
||
"hash": first_pre.get("hash"), "artifactHash": provenance.get("artifactHash")},
|
||
{"actionId": first_action.get("actionId"), "kind": "post", "ref": first_post.get("path"),
|
||
"hash": first_post.get("hash"), "artifactHash": provenance.get("artifactHash")},
|
||
{"actionId": second_action.get("actionId"), "kind": "pre", "ref": second_pre.get("path"),
|
||
"hash": second_pre.get("hash"), "artifactHash": provenance.get("artifactHash")},
|
||
{"actionId": second_action.get("actionId"), "kind": "post", "ref": second_post.get("path"),
|
||
"hash": second_post.get("hash"), "artifactHash": provenance.get("artifactHash")},
|
||
]
|
||
bundle_check("#/collectionProofRef/frames", collection.get("frames"), expected_frames,
|
||
"collection", "sealed-frames")
|
||
bundle_check("#/singleRollProofRef/frames", proof.get("frames"), expected_frames,
|
||
"proof-projection", "sealed-frames")
|
||
bundle_check("#/judgePackageRef/frames", judge_package.get("frames"), expected_frames,
|
||
"judge-projection", "sealed-frames")
|
||
expected_scope = {
|
||
"actionRefs": [first_action.get("actionId"), second_action.get("actionId")],
|
||
"frameRefs": list(dict.fromkeys(row["ref"] for row in expected_frames)),
|
||
"eventRefs": [f"event:{event.get('seq')}" for event in events],
|
||
}
|
||
bundle_check("#/judgePackageRef/textReferenceScope", judge_package.get("textReferenceScope"),
|
||
expected_scope, "judge-projection", "text-reference-scope")
|
||
return errors
|
||
|
||
|
||
def _validate_match3_roll_audit(audit_path: Path) -> list:
|
||
"""回读 roll audit 引用的 group/reset,禁止只靠自报摘要通过恢复预算门。"""
|
||
audit = _load_sample_instance(audit_path)
|
||
errors = _schema_errors("match3-roll-interaction-audit.schema.json", audit)
|
||
_trace("roll-audit", "manifest-schema", path=audit_path,
|
||
result="pass" if not errors else "fail", error=errors[0] if errors else None)
|
||
if errors:
|
||
return errors
|
||
|
||
def trace_sealed(stage: str, check: str, path: Path, declared: str, value) -> str:
|
||
"""记录 roll audit 引用工件的原始字节身份,不写业务载荷。"""
|
||
computed = _raw_sample_hash(path, value)
|
||
ok = declared == computed
|
||
_trace(stage, check, path=path, declared_hash=declared, computed_hash=computed,
|
||
result="pass" if ok else "fail", error=None if ok else "封存字节 hash 漂移")
|
||
return computed
|
||
recovery = (((_load(_HERE / "interaction-profiles.v2.json").get("profiles") or {})
|
||
.get(audit.get("interactionProfileId")) or {}).get("recoveryPolicy") or {})
|
||
expected_budget = recovery.get("maxActionGroupsPerRoll", 0) * 2 + recovery.get("maxResetPerRoll", 0)
|
||
_trace("roll-audit", "recovery-budget", declared_hash=None, computed_hash=None,
|
||
result="pass" if audit.get("initialRemainingBudget") == expected_budget else "fail",
|
||
error=None if audit.get("initialRemainingBudget") == expected_budget else "initialRemainingBudget 非配置推导值")
|
||
|
||
preflight_path = (audit_path.parent / audit.get("producerPreflightRef", "")).resolve()
|
||
if preflight_path.is_symlink() or not preflight_path.is_file():
|
||
errors.append("#/producerPreflightRef: 引用 preflight 文件不存在或不是普通文件")
|
||
_trace("roll-preflight", "regular-file", ref=audit.get("producerPreflightRef"),
|
||
path=preflight_path, result="fail", error="preflight 文件不存在或不是普通文件")
|
||
else:
|
||
preflight = _load_sample_instance(preflight_path)
|
||
preflight_errors = _schema_errors("match3-producer-preflight.schema.json", preflight)
|
||
errors += [f"#/producerPreflightRef{error[1:]}" for error in preflight_errors]
|
||
_trace("roll-preflight", "schema-and-semantic", ref=audit.get("producerPreflightRef"),
|
||
path=preflight_path, result="pass" if not preflight_errors else "fail",
|
||
error=preflight_errors[0] if preflight_errors else None)
|
||
computed_preflight_hash = trace_sealed(
|
||
"roll-preflight", "sealed-bytes", preflight_path,
|
||
audit.get("producerPreflightHash"), preflight,
|
||
)
|
||
if audit.get("producerPreflightHash") != computed_preflight_hash:
|
||
errors.append("#/producerPreflightHash: 与 preflight 原始字节不一致")
|
||
if preflight.get("result") != "compatible" or preflight.get("reasonCode") != "none":
|
||
errors.append("#/producerPreflightRef/result: 正式 roll 只能绑定 compatible/none preflight")
|
||
preflight_identity = {
|
||
"gameId": "gameId",
|
||
"artifactHash": "artifactHash",
|
||
"taskBindingHash": "taskBindingHash",
|
||
"acceptanceRequestHash": "acceptanceRequestHash",
|
||
"seed": "seed",
|
||
"interactionProfileId": "interactionProfileId",
|
||
"interactionRegistryVersion": "interactionRegistryVersion",
|
||
"interactionBindingHash": "interactionBindingHash",
|
||
}
|
||
for audit_field, preflight_field in preflight_identity.items():
|
||
if audit.get(audit_field) != preflight.get(preflight_field):
|
||
errors.append(
|
||
f"#/{audit_field}: 必须镜像 compatible preflight 的 {preflight_field}",
|
||
)
|
||
|
||
binding_path = (audit_path.parent / audit.get("interactionBindingRef", "")).resolve()
|
||
if binding_path.is_symlink() or not binding_path.is_file():
|
||
errors.append("#/interactionBindingRef: 引用 binding 文件不存在或不是普通文件")
|
||
_trace("roll-binding", "regular-file", ref=audit.get("interactionBindingRef"),
|
||
path=binding_path, result="fail", error="binding 文件不存在或不是普通文件")
|
||
else:
|
||
binding = _load_sample_instance(binding_path)
|
||
binding_errors = _schema_errors("interaction-binding.schema.json", binding)
|
||
errors += [f"#/interactionBindingRef{error[1:]}" for error in binding_errors]
|
||
_trace("roll-binding", "schema-and-semantic", ref=audit.get("interactionBindingRef"),
|
||
path=binding_path, result="pass" if not binding_errors else "fail",
|
||
error=binding_errors[0] if binding_errors else None)
|
||
computed_binding_hash = trace_sealed(
|
||
"roll-binding", "sealed-bytes", binding_path,
|
||
audit.get("interactionBindingFileHash"), binding,
|
||
)
|
||
if audit.get("interactionBindingFileHash") != computed_binding_hash:
|
||
errors.append("#/interactionBindingFileHash: 与 binding 原始字节不一致")
|
||
for field in ("interactionProfileId", "interactionRegistryVersion", "interactionBindingHash"):
|
||
if audit.get(field) != binding.get(field):
|
||
errors.append(f"#/{field}: 必须镜像真实 binding 文件")
|
||
if audit.get("taskBindingHash") != binding.get("taskBindingHash"):
|
||
errors.append("#/taskBindingHash: 必须镜像真实 binding 文件")
|
||
|
||
identity = {
|
||
"interactionProfileId": audit.get("interactionProfileId"),
|
||
"interactionRegistryVersion": audit.get("interactionRegistryVersion"),
|
||
"interactionBindingHash": audit.get("interactionBindingHash"),
|
||
}
|
||
groups = []
|
||
pair_resolutions = []
|
||
for index, group_ref in enumerate(audit.get("groups") or []):
|
||
path = (audit_path.parent / group_ref.get("ref", "")).resolve()
|
||
if not path.is_file():
|
||
errors.append(f"#/groups/{index}/ref: 引用 actionGroup 不存在")
|
||
continue
|
||
group = _load_sample_instance(path)
|
||
groups.append(group)
|
||
group_errors = [
|
||
f"#/groups/{index}{error[1:]}"
|
||
for error in _schema_errors("match3-action-group.schema.json", group)
|
||
]
|
||
errors += group_errors
|
||
_trace("roll-group", "schema-and-semantic", path=path,
|
||
result="pass" if not group_errors else "fail",
|
||
error=group_errors[0] if group_errors else None)
|
||
if group_ref.get("hash") != trace_sealed(
|
||
"roll-group", "sealed-bytes", path, group_ref.get("hash"), group,
|
||
):
|
||
errors.append(f"#/groups/{index}/hash: 与 actionGroup 封存字节不一致")
|
||
for field in ("actionGroupId", "candidateSetHash", "candidateId", "state"):
|
||
if group_ref.get(field) != group.get(field):
|
||
errors.append(f"#/groups/{index}/{field}: 必须镜像 actionGroup")
|
||
for field, expected in identity.items():
|
||
if group.get(field) != expected:
|
||
errors.append(f"#/groups/{index}/{field}: 必须镜像 roll audit 身份")
|
||
nested_specs = (
|
||
("selectionAttemptRef", "selectionAttemptHash", "actor-selection-attempt.schema.json"),
|
||
("pairResolutionRef", "pairResolutionHash", "match3-pair-resolution.schema.json"),
|
||
("firstGroupedActionRef", "firstGroupedActionHash", "grouped-action-ref.schema.json"),
|
||
("boardIdentityCheckRef", "boardIdentityCheckHash", "board-identity-check.schema.json"),
|
||
)
|
||
if group_ref.get("secondGroupedActionRef") is not None:
|
||
nested_specs += (("secondGroupedActionRef", "secondGroupedActionHash", "grouped-action-ref.schema.json"),)
|
||
pair_resolution = None
|
||
for ref_field, hash_field, schema_name in nested_specs:
|
||
if group_ref.get(ref_field) != group.get(ref_field) or group_ref.get(hash_field) != group.get(hash_field):
|
||
errors.append(f"#/groups/{index}/{ref_field}: 必须镜像 actionGroup 完整引用")
|
||
nested_path = (audit_path.parent / str(group_ref.get(ref_field) or "")).resolve()
|
||
if not nested_path.is_file():
|
||
errors.append(f"#/groups/{index}/{ref_field}: 引用工件不存在")
|
||
continue
|
||
nested = _load_sample_instance(nested_path)
|
||
nested_errors = [
|
||
f"#/groups/{index}/{ref_field}{error[1:]}"
|
||
for error in _schema_errors(schema_name, nested)
|
||
]
|
||
errors += nested_errors
|
||
_trace("roll-group-ref", f"{ref_field}-schema", path=nested_path,
|
||
result="pass" if not nested_errors else "fail",
|
||
error=nested_errors[0] if nested_errors else None)
|
||
if group_ref.get(hash_field) != trace_sealed(
|
||
"roll-group-ref", f"{ref_field}-bytes", nested_path,
|
||
group_ref.get(hash_field), nested,
|
||
):
|
||
errors.append(f"#/groups/{index}/{hash_field}: 与封存工件原始字节不一致")
|
||
if ref_field == "selectionAttemptRef":
|
||
for attempt_ref, attempt_hash, schema in (
|
||
(nested.get("actorViewRef"), nested.get("actorViewHash"), "actor-view-v4.schema.json"),
|
||
(nested.get("selectionRef"), nested.get("selectionHash"), "actor-selection-v2.schema.json"),
|
||
):
|
||
attempt_path = (audit_path.parent / str(attempt_ref or "")).resolve()
|
||
if not attempt_path.is_file():
|
||
errors.append(f"#/groups/{index}/{ref_field}: attempt 子工件不存在")
|
||
continue
|
||
attempt_value = _load_sample_instance(attempt_path)
|
||
attempt_errors = [
|
||
f"#/groups/{index}/{ref_field}{error[1:]}"
|
||
for error in _schema_errors(schema, attempt_value)
|
||
]
|
||
errors += attempt_errors
|
||
_trace("roll-selection", f"{schema}-schema", path=attempt_path,
|
||
result="pass" if not attempt_errors else "fail",
|
||
error=attempt_errors[0] if attempt_errors else None)
|
||
if attempt_hash != trace_sealed(
|
||
"roll-selection", f"{schema}-bytes", attempt_path,
|
||
attempt_hash, attempt_value,
|
||
):
|
||
errors.append(f"#/groups/{index}/{ref_field}: attempt 子工件 hash 不一致")
|
||
selection_path = (audit_path.parent / str(nested.get("selectionRef") or "")).resolve()
|
||
if selection_path.is_file() and nested.get("selection") != _load_sample_instance(selection_path):
|
||
errors.append(f"#/groups/{index}/{ref_field}/selection: 必须镜像 selection 文件")
|
||
elif ref_field == "pairResolutionRef":
|
||
pair_resolution = nested
|
||
for field in ("candidateSetHash", "candidateId", "latticeId", "pair", "firstEndpoint", "secondEndpoint"):
|
||
if group.get(field) != nested.get(field):
|
||
errors.append(f"#/groups/{index}/{ref_field}/{field}: 必须镜像 actionGroup")
|
||
for pair_ref, pair_hash, schema in (
|
||
(nested.get("selectionRef"), nested.get("selectionHash"), "actor-selection-v2.schema.json"),
|
||
(nested.get("swapSetRef"), nested.get("swapSetHash"), "match3-swap-set.schema.json"),
|
||
):
|
||
pair_path = (audit_path.parent / str(pair_ref or "")).resolve()
|
||
if not pair_path.is_file():
|
||
errors.append(f"#/groups/{index}/{ref_field}: pair 子工件不存在")
|
||
continue
|
||
pair_value = _load_sample_instance(pair_path)
|
||
pair_errors = [
|
||
f"#/groups/{index}/{ref_field}{error[1:]}"
|
||
for error in _schema_errors(schema, pair_value)
|
||
]
|
||
errors += pair_errors
|
||
_trace("roll-pair", f"{schema}-schema", path=pair_path,
|
||
result="pass" if not pair_errors else "fail",
|
||
error=pair_errors[0] if pair_errors else None)
|
||
if pair_hash != trace_sealed(
|
||
"roll-pair", f"{schema}-bytes", pair_path, pair_hash, pair_value,
|
||
):
|
||
errors.append(f"#/groups/{index}/{ref_field}: pair 子工件 hash 不一致")
|
||
elif "GroupedAction" in schema_name or schema_name == "grouped-action-ref.schema.json":
|
||
expected_role = "first" if ref_field == "firstGroupedActionRef" else "second"
|
||
expected_action_id = group.get("firstActionId") if expected_role == "first" else group.get("secondActionId")
|
||
if nested.get("actionGroupId") != group.get("actionGroupId") \
|
||
or nested.get("role") != expected_role \
|
||
or nested.get("actionId") != expected_action_id:
|
||
errors.append(f"#/groups/{index}/{ref_field}: 必须绑定 actionGroup 对应原生动作")
|
||
native_path = (audit_path.parent / str(nested.get("actionRef") or "")).resolve()
|
||
if not native_path.is_file():
|
||
errors.append(f"#/groups/{index}/{ref_field}/actionRef: 原生 action 不存在")
|
||
else:
|
||
native = _load_sample_instance(native_path)
|
||
action_root = _load(_HERE / "playtest-evidence-v3.schema.json")
|
||
native_errors = validate(action_root["$defs"]["actionRecord"], native, action_root)
|
||
if not native_errors:
|
||
native_errors += _semantic_validate_v3_action(native, "#")
|
||
errors += [f"#/groups/{index}/{ref_field}/actionRef{error[1:]}" for error in native_errors]
|
||
if nested.get("actionHash") != _raw_sample_hash(native_path, native):
|
||
errors.append(f"#/groups/{index}/{ref_field}/actionHash: 与原生 action 封存字节不一致")
|
||
trace_sealed("roll-action", f"{ref_field}-native-action", native_path,
|
||
nested.get("actionHash"), native)
|
||
elif ref_field == "boardIdentityCheckRef":
|
||
if nested.get("actionGroupId") != group.get("actionGroupId"):
|
||
errors.append(f"#/groups/{index}/{ref_field}/actionGroupId: 必须镜像 actionGroup")
|
||
if group.get("state") == "completed" and nested.get("result") != "equivalent":
|
||
errors.append(f"#/groups/{index}/{ref_field}/result: completed group 必须 equivalent")
|
||
if group.get("state") == "aborted" and nested.get("result") == "equivalent":
|
||
errors.append(f"#/groups/{index}/{ref_field}/result: aborted group 不得 equivalent")
|
||
for side_name in ("initial", "postFirst"):
|
||
side = nested.get(side_name) or {}
|
||
target_path = (audit_path.parent / str(side.get("targetSetRef") or "")).resolve()
|
||
projection_path = (audit_path.parent / str(side.get("projectionRef") or "")).resolve()
|
||
if not target_path.is_file() or not projection_path.is_file():
|
||
errors.append(f"#/groups/{index}/{ref_field}/{side_name}: TargetSet/Projection 不存在")
|
||
continue
|
||
target_set = _load_sample_instance(target_path)
|
||
projection = _load_sample_instance(projection_path)
|
||
errors += [f"#/groups/{index}/{ref_field}/{side_name}{error[1:]}" for error in _schema_errors("visual-target-set.schema.json", target_set)]
|
||
errors += [f"#/groups/{index}/{ref_field}/{side_name}{error[1:]}" for error in _schema_errors("match3-board-projection.schema.json", projection)]
|
||
if side.get("targetSetHash") != target_set.get("targetSetHash") \
|
||
or side.get("projectionHash") != _raw_sample_hash(projection_path, projection) \
|
||
or side.get("equivalenceMatrixHash") != _projection_equivalence_hash(projection):
|
||
errors.append(f"#/groups/{index}/{ref_field}/{side_name}: 必须从 TargetSet/Projection 重算")
|
||
target_identity_ok = side.get("targetSetHash") == target_set.get("targetSetHash")
|
||
_trace("roll-board", f"{side_name}-target-set-identity", path=target_path,
|
||
declared_hash=side.get("targetSetHash"),
|
||
computed_hash=target_set.get("targetSetHash"),
|
||
result="pass" if target_identity_ok else "fail",
|
||
error=None if target_identity_ok else "TargetSet 身份漂移")
|
||
trace_sealed("roll-board", f"{side_name}-projection", projection_path,
|
||
side.get("projectionHash"), projection)
|
||
first_action_path = (audit_path.parent / str(nested.get("firstActionRef") or "")).resolve()
|
||
if not first_action_path.is_file():
|
||
errors.append(f"#/groups/{index}/{ref_field}/firstActionRef: 原生 action 不存在")
|
||
else:
|
||
first_action_value = _load_sample_instance(first_action_path)
|
||
if nested.get("firstActionHash") != _raw_sample_hash(first_action_path, first_action_value):
|
||
errors.append(f"#/groups/{index}/{ref_field}/firstActionHash: 与原生 action 字节不一致")
|
||
pair_resolutions.append(pair_resolution)
|
||
|
||
reset = audit.get("reset")
|
||
if isinstance(reset, dict):
|
||
reset_path = (audit_path.parent / reset.get("groupedActionRef", "")).resolve()
|
||
if not reset_path.is_file():
|
||
errors.append("#/reset/groupedActionRef: 引用 reset action 不存在")
|
||
else:
|
||
grouped = _load_sample_instance(reset_path)
|
||
errors += [
|
||
f"#/reset{error[1:]}"
|
||
for error in _schema_errors("grouped-action-ref.schema.json", grouped)
|
||
]
|
||
if reset.get("groupedActionHash") != _raw_sample_hash(reset_path, grouped):
|
||
errors.append("#/reset/groupedActionHash: 与 reset action 封存字节不一致")
|
||
trace_sealed("roll-reset", "grouped-action", reset_path,
|
||
reset.get("groupedActionHash"), grouped)
|
||
if grouped.get("role") != "reset" or grouped.get("endpoint") is not None:
|
||
errors.append("#/reset/groupedActionRef: 必须引用 endpoint=null 的 reset action")
|
||
if groups and grouped.get("actionGroupId") != groups[0].get("actionGroupId"):
|
||
errors.append("#/reset/groupedActionRef/actionGroupId: reset 必须归属首个 aborted group")
|
||
native_path = (audit_path.parent / reset.get("nativeActionRef", "")).resolve()
|
||
if not native_path.is_file():
|
||
errors.append("#/reset/nativeActionRef: reset 原生 action 不存在")
|
||
else:
|
||
native_action = _load_sample_instance(native_path)
|
||
action_root = _load(_HERE / "playtest-evidence-v3.schema.json")
|
||
action_errors = validate(action_root["$defs"]["actionRecord"], native_action, action_root)
|
||
if not action_errors:
|
||
action_errors += _semantic_validate_v3_action(native_action, "#")
|
||
errors += [f"#/reset/nativeActionRef{error[1:]}" for error in action_errors]
|
||
if reset.get("nativeActionHash") != _raw_sample_hash(native_path, native_action):
|
||
errors.append("#/reset/nativeActionHash: 与 reset 原生 action 封存字节不一致")
|
||
trace_sealed("roll-reset", "native-action", native_path,
|
||
reset.get("nativeActionHash"), native_action)
|
||
if grouped.get("actionRef") != reset.get("nativeActionRef") \
|
||
or grouped.get("actionHash") != reset.get("nativeActionHash") \
|
||
or grouped.get("actionId") != native_action.get("actionId"):
|
||
errors.append("#/reset/groupedActionRef: 必须绑定同一个 reset 原生 action")
|
||
|
||
reset_target_path = (audit_path.parent / reset.get("resetTargetSetRef", "")).resolve()
|
||
frame_path = (audit_path.parent / reset.get("postResetFrameFileRef", "")).resolve()
|
||
target_path = (audit_path.parent / reset.get("postResetTargetSetRef", "")).resolve()
|
||
projection_path = (audit_path.parent / reset.get("postResetProjectionRef", "")).resolve()
|
||
swap_path = (audit_path.parent / reset.get("postResetSwapSetRef", "")).resolve()
|
||
if (not reset_target_path.is_file() or not target_path.is_file()
|
||
or not projection_path.is_file() or not swap_path.is_file()):
|
||
errors.append("#/reset: reset 前后 TargetSet/Projection/SwapSet 引用不存在")
|
||
else:
|
||
reset_target_set = _load_sample_instance(reset_target_path)
|
||
target_set = _load_sample_instance(target_path)
|
||
projection = _load_sample_instance(projection_path)
|
||
swap_set = _load_sample_instance(swap_path)
|
||
errors += [f"#/reset/resetTargetSetRef{error[1:]}" for error in _schema_errors("visual-target-set.schema.json", reset_target_set)]
|
||
errors += [f"#/reset/postResetTargetSetRef{error[1:]}" for error in _schema_errors("visual-target-set.schema.json", target_set)]
|
||
errors += [f"#/reset/postResetProjectionRef{error[1:]}" for error in _schema_errors("match3-board-projection.schema.json", projection)]
|
||
errors += [f"#/reset/postResetSwapSetRef{error[1:]}" for error in _schema_errors("match3-swap-set.schema.json", swap_set)]
|
||
if reset.get("resetTargetSetHash") != _raw_sample_hash(reset_target_path, reset_target_set):
|
||
errors.append("#/reset/resetTargetSetHash: 与 reset 前 TargetSet 封存字节不一致")
|
||
if reset.get("postResetTargetSetHash") != _raw_sample_hash(target_path, target_set):
|
||
errors.append("#/reset/postResetTargetSetHash: 与 TargetSet 封存字节不一致")
|
||
if reset.get("postResetProjectionHash") != _raw_sample_hash(projection_path, projection):
|
||
errors.append("#/reset/postResetProjectionHash: 与 Projection 封存字节不一致")
|
||
if reset.get("postResetSwapSetHash") != _raw_sample_hash(swap_path, swap_set):
|
||
errors.append("#/reset/postResetSwapSetHash: 与 SwapSet 封存字节不一致")
|
||
trace_sealed("roll-reset", "pre-target-set", reset_target_path,
|
||
reset.get("resetTargetSetHash"), reset_target_set)
|
||
trace_sealed("roll-reset", "post-target-set", target_path,
|
||
reset.get("postResetTargetSetHash"), target_set)
|
||
trace_sealed("roll-reset", "post-projection", projection_path,
|
||
reset.get("postResetProjectionHash"), projection)
|
||
trace_sealed("roll-reset", "post-swap-set", swap_path,
|
||
reset.get("postResetSwapSetHash"), swap_set)
|
||
native_post = native_action.get("postFrameRef") or {}
|
||
if frame_path.is_symlink() or not frame_path.is_file():
|
||
errors.append("#/reset/postResetFrameFileRef: 必须是存在的普通非符号链接文件")
|
||
_trace("roll-reset", "post-frame-regular-file", ref=reset.get("postResetFrameFileRef"),
|
||
path=frame_path, result="fail", error="frame 不存在或不是普通文件")
|
||
else:
|
||
frame_hash = hashlib.sha256(frame_path.read_bytes()).hexdigest()
|
||
if reset.get("postResetFrameHash") != frame_hash or native_post.get("hash") != frame_hash:
|
||
errors.append("#/reset/postResetFrameHash: 必须等于实际 frame 字节与 reset action postFrameHash")
|
||
_trace("roll-reset", "post-frame-bytes", ref=reset.get("postResetFrameFileRef"), path=frame_path,
|
||
declared_hash=reset.get("postResetFrameHash"), computed_hash=frame_hash,
|
||
result="fail", error="reset post frame hash 漂移")
|
||
else:
|
||
_trace("roll-reset", "post-frame-bytes", ref=reset.get("postResetFrameFileRef"), path=frame_path,
|
||
declared_hash=reset.get("postResetFrameHash"), computed_hash=frame_hash, result="pass")
|
||
for actual, expected, message in (
|
||
(target_set.get("sourceFrameRef"), native_post.get("path"), "postReset TargetSet sourceFrameRef"),
|
||
(target_set.get("sourceFrameHash"), native_post.get("hash"), "postReset TargetSet sourceFrameHash"),
|
||
(projection.get("sourceFrameRef"), target_set.get("sourceFrameRef"), "postReset Projection sourceFrameRef"),
|
||
(projection.get("sourceFrameHash"), target_set.get("sourceFrameHash"), "postReset Projection sourceFrameHash"),
|
||
(projection.get("targetSetRef"), reset.get("postResetTargetSetRef"), "postReset Projection targetSetRef"),
|
||
(projection.get("targetSetHash"), target_set.get("targetSetHash"), "postReset Projection targetSetHash"),
|
||
(swap_set.get("sourceFrameRef"), target_set.get("sourceFrameRef"), "postReset SwapSet sourceFrameRef"),
|
||
(swap_set.get("sourceFrameHash"), target_set.get("sourceFrameHash"), "postReset SwapSet sourceFrameHash"),
|
||
(swap_set.get("targetSetRef"), reset.get("postResetTargetSetRef"), "postReset SwapSet targetSetRef"),
|
||
(swap_set.get("targetSetHash"), target_set.get("targetSetHash"), "postReset SwapSet targetSetHash"),
|
||
(swap_set.get("projectionRef"), reset.get("postResetProjectionRef"), "postReset SwapSet projectionRef"),
|
||
(swap_set.get("projectionHash"), projection.get("projectionHash"), "postReset SwapSet projectionHash"),
|
||
):
|
||
if actual != expected:
|
||
errors.append(f"#/reset: {message} 因果身份不一致")
|
||
if reset.get("resultingCandidateSetHash") != swap_set.get("candidateSetHash"):
|
||
errors.append("#/reset/resultingCandidateSetHash: 必须来自 reset 后 sealed SwapSet")
|
||
resolution = native_action.get("targetResolution") or {}
|
||
if resolution.get("targetSetHash") != reset_target_set.get("targetSetHash") \
|
||
or resolution.get("sourceFrameRef") != reset_target_set.get("sourceFrameRef") \
|
||
or resolution.get("sourceFrameHash") != reset_target_set.get("sourceFrameHash"):
|
||
errors.append("#/reset/nativeActionRef/targetResolution: 必须绑定 reset 前 TargetSet")
|
||
endpoint = (pair_resolutions[0] or {}).get("firstEndpoint") if pair_resolutions else None
|
||
lattice = next((row for row in reset_target_set.get("targets") or [] if row.get("id") == (endpoint or {}).get("id")), None)
|
||
cell = (endpoint or {}).get("cell") or {}
|
||
if lattice is None:
|
||
errors.append("#/reset/postResetTargetSetRef: 不含首组 firstEndpoint lattice")
|
||
else:
|
||
expected_action = {
|
||
"type": "tap",
|
||
"x": (lattice.get("columnCenters") or [])[cell.get("column", 1) - 1],
|
||
"y": (lattice.get("rowCenters") or [])[cell.get("row", 1) - 1],
|
||
}
|
||
if native_action.get("normalized") != expected_action or native_action.get("virtualTimeBudgetMs") != 600:
|
||
errors.append("#/reset/nativeActionRef: 必须以 600ms repeat-first-endpoint 原生 tap 执行")
|
||
|
||
events_path = (audit_path.parent / reset.get("eventSliceRef", "")).resolve()
|
||
if not events_path.is_file():
|
||
errors.append("#/reset/eventSliceRef: reset 完整事件切片不存在")
|
||
else:
|
||
reset_events = _load_sample_instance(events_path)
|
||
if reset.get("eventSliceHash") != _raw_sample_hash(events_path, reset_events):
|
||
errors.append("#/reset/eventSliceHash: 与 reset 事件切片原始字节不一致")
|
||
trace_sealed("roll-reset", "event-slice", events_path,
|
||
reset.get("eventSliceHash"), reset_events)
|
||
if not isinstance(reset_events, list):
|
||
errors.append("#/reset/eventSliceRef: 必须是完整事件数组")
|
||
else:
|
||
expected_seqs = list(range(native_action.get("preEventSeq", 0) + 1, native_action.get("postEventSeq", 0) + 1))
|
||
if [event.get("seq") for event in reset_events] != expected_seqs:
|
||
errors.append("#/reset/eventSliceRef: 必须完整覆盖 reset action 事件区间")
|
||
for event in reset_events:
|
||
errors += [f"#/reset/eventSliceRef{error[1:]}" for error in _schema_errors("game-event.schema.json", event)]
|
||
if any(event.get("type") in ("puzzle.swap-committed", "puzzle.match-formed") for event in reset_events):
|
||
errors.append("#/reset/eventSliceRef: reset 不得产生 swap-committed/match-formed")
|
||
return errors
|
||
|
||
|
||
def _validate_match3_specialty_proof_bundle(manifest_path: Path) -> list:
|
||
"""回读 Proof/3→Audit/2→全部 sidecar,复算原始字节 hash 与跨文件身份。"""
|
||
errors = []
|
||
manifest = _load_sample_instance(manifest_path)
|
||
if manifest.get("schemaVersion") != "Match3SpecialtyProofBundle/1":
|
||
return ["#/schemaVersion: 必须为 Match3SpecialtyProofBundle/1"]
|
||
allowed_manifest_keys = {
|
||
"schemaVersion", "singleRollProofRef", "singleRollProofHash", "expectedErrorPattern",
|
||
}
|
||
extra_keys = set(manifest) - allowed_manifest_keys
|
||
if extra_keys:
|
||
errors.append(f"#: specialty bundle 含未知字段 {sorted(extra_keys)}")
|
||
|
||
def resolve_ref(owner: Path, ref, field: str) -> Path | None:
|
||
if not isinstance(ref, str) or not ref or ref.startswith("/"):
|
||
errors.append(f"#/{field}: 必须是非空相对引用")
|
||
return None
|
||
target = (owner.parent / ref).resolve()
|
||
try:
|
||
target.relative_to(_HERE)
|
||
except ValueError:
|
||
errors.append(f"#/{field}: 引用越出 contracts/play-loop")
|
||
return None
|
||
if target.is_symlink() or not target.is_file():
|
||
errors.append(f"#/{field}: 引用不存在或不是普通文件")
|
||
return None
|
||
return target
|
||
|
||
def load_json_ref(owner: Path, ref, declared_hash, field: str, schema_name=None,
|
||
canonical_hash_field=None):
|
||
target = resolve_ref(owner, ref, field)
|
||
if target is None:
|
||
return None, None
|
||
try:
|
||
value = _load_sample_instance(target)
|
||
except Exception as exc: # noqa: BLE001 —— 引用文件损坏必须保留原始根因。
|
||
errors.append(f"#/{field}: JSON 不可读:{exc}")
|
||
return None, target
|
||
if canonical_hash_field is None:
|
||
computed = _raw_sample_hash(target, value)
|
||
if declared_hash != computed:
|
||
errors.append(f"#/{field}: 声明 hash 与封存原始字节不一致")
|
||
elif declared_hash != value.get(canonical_hash_field):
|
||
errors.append(f"#/{field}: 声明 hash 必须等于 Audit/2 canonical auditHash")
|
||
if schema_name:
|
||
schema_errors = _schema_errors(schema_name, value)
|
||
errors.extend(f"#/{field}{error[1:]}" for error in schema_errors)
|
||
return value, target
|
||
|
||
proof, proof_path = load_json_ref(
|
||
manifest_path, manifest.get("singleRollProofRef"),
|
||
manifest.get("singleRollProofHash"), "singleRollProofRef",
|
||
"single-roll-proof-v3.schema.json",
|
||
)
|
||
if proof is None or proof_path is None:
|
||
return errors
|
||
proof_schema = _load(_HERE / "single-roll-proof-v3.schema.json")
|
||
proof_schema_errors = validate(proof_schema, proof, proof_schema)
|
||
if not proof_schema_errors:
|
||
errors += [
|
||
f"#/singleRollProofRef{error[1:]}"
|
||
for error in _semantic_validate_single_roll_proof_v3(proof)
|
||
]
|
||
if proof.get("packageType") != "SingleRollProof/3":
|
||
errors.append("#/singleRollProofRef/packageType: 旧 Proof/2 不得冒充视觉音频闭包")
|
||
|
||
audit, audit_path = load_json_ref(
|
||
proof_path, proof.get("match3RollInteractionAuditRef"),
|
||
proof.get("match3RollInteractionAuditHash"), "match3RollInteractionAuditRef",
|
||
"match3-roll-interaction-audit-v2.schema.json", "auditHash",
|
||
)
|
||
if audit is None or audit_path is None:
|
||
return errors
|
||
audit_schema = _load(_HERE / "match3-roll-interaction-audit-v2.schema.json")
|
||
audit_schema_errors = validate(audit_schema, audit, audit_schema)
|
||
if not audit_schema_errors:
|
||
errors += [
|
||
f"#/match3RollInteractionAuditRef{error[1:]}"
|
||
for error in _semantic_validate_roll_audit_v2(audit)
|
||
]
|
||
|
||
for proof_field, audit_field in (
|
||
("gameId", "gameId"), ("artifactHash", "artifactHash"),
|
||
("taskBindingHash", "taskBindingHash"),
|
||
("acceptanceRequestHash", "acceptanceRequestHash"),
|
||
):
|
||
if proof.get(proof_field) != audit.get(audit_field):
|
||
errors.append(f"#/singleRollProofRef/{proof_field}: 必须镜像 Audit/2")
|
||
if f"roll-{proof.get('roll')}" != audit.get("rollId") \
|
||
or proof.get("requestedSeed") != audit.get("seed"):
|
||
errors.append("#/singleRollProofRef: roll/seed 必须镜像 Audit/2")
|
||
|
||
sidecars = {}
|
||
for key, schema_name in (
|
||
("actionAudit", "match3-roll-interaction-audit.schema.json"),
|
||
("buildManifest", "build-manifest.schema.json"),
|
||
("visualTransaction", "match3-visual-transaction.schema.json"),
|
||
("visualAudit", "match3-visual-audit.schema.json"),
|
||
("effectAudit", "match3-effect-audit.schema.json"),
|
||
("audioAudit", "match3-audio-audit.schema.json"),
|
||
("inputLockProbe", "input-lock-probe.schema.json"),
|
||
("filmstripManifest", "match3-filmstrip-analyzer.schema.json"),
|
||
):
|
||
value, target = load_json_ref(
|
||
audit_path, audit.get(f"{key}Ref"), audit.get(f"{key}Hash"), f"{key}Ref", schema_name,
|
||
)
|
||
sidecars[key] = (value, target)
|
||
settlement_path = resolve_ref(
|
||
audit_path, audit.get("visualSettlementFrameRef"), "visualSettlementFrameRef",
|
||
)
|
||
if settlement_path is not None:
|
||
settlement_hash = hashlib.sha256(settlement_path.read_bytes()).hexdigest()
|
||
if settlement_hash != audit.get("visualSettlementFrameHash"):
|
||
errors.append("#/visualSettlementFrameHash: 与稳定终盘 PNG 原始字节不一致")
|
||
|
||
action_audit, action_path = sidecars["actionAudit"]
|
||
if action_audit is not None and action_path is not None:
|
||
errors += [f"#/actionAuditRef{error[1:]}" for error in _validate_match3_roll_audit(action_path)]
|
||
if action_audit.get("schemaVersion") != "Match3RollInteractionAudit/1":
|
||
errors.append("#/actionAuditRef/schemaVersion: 必须显式引用冻结 /1")
|
||
if action_audit.get("finalState") != "completed":
|
||
errors.append("#/actionAuditRef/finalState: Audit/2 只能提升 completed 动作账")
|
||
for field in (
|
||
"rollId", "gameId", "artifactHash", "taskBindingHash", "acceptanceRequestHash",
|
||
"seed", "interactionProfileId", "interactionRegistryVersion", "interactionBindingHash",
|
||
):
|
||
if action_audit.get(field) != audit.get(field):
|
||
errors.append(f"#/actionAuditRef/{field}: 必须镜像 Audit/2")
|
||
|
||
build, _ = sidecars["buildManifest"]
|
||
transaction, _ = sidecars["visualTransaction"]
|
||
visual, _ = sidecars["visualAudit"]
|
||
effect, effect_path = sidecars["effectAudit"]
|
||
audio, _ = sidecars["audioAudit"]
|
||
lock, _ = sidecars["inputLockProbe"]
|
||
filmstrip, _ = sidecars["filmstripManifest"]
|
||
expected_versions = {
|
||
"buildManifest": "BuildManifest/1",
|
||
"visualTransaction": "Match3VisualTransaction/1",
|
||
"visualAudit": "Match3VisualAudit/1",
|
||
"effectAudit": "Match3EffectAudit/1",
|
||
"audioAudit": "Match3AudioAudit/1",
|
||
"inputLockProbe": "InputLockProbe/1",
|
||
"filmstripManifest": "Match3FilmstripAnalyzer/1",
|
||
}
|
||
for key, expected_version in expected_versions.items():
|
||
value = sidecars[key][0] or {}
|
||
if value.get("schemaVersion") != expected_version:
|
||
errors.append(f"#/{key}Ref/schemaVersion: 必须为 {expected_version}")
|
||
|
||
if build:
|
||
if build.get("artifactHash") != audit.get("artifactHash") \
|
||
or build.get("gameId") != audit.get("gameId"):
|
||
errors.append("#/buildManifestRef: game/artifact 身份与 Audit/2 分叉")
|
||
bgm_rows = [row for row in build.get("files") or [] if row.get("role") == "bgm"]
|
||
if len(bgm_rows) != 1:
|
||
errors.append("#/buildManifestRef/files: 必须且只能绑定一份 BGM 原始字节")
|
||
else:
|
||
bgm_rows = []
|
||
|
||
for key, value in (
|
||
("visualTransaction", transaction), ("visualAudit", visual),
|
||
("effectAudit", effect), ("inputLockProbe", lock),
|
||
("filmstripManifest", filmstrip),
|
||
):
|
||
value = value or {}
|
||
if value.get("transactionId") != audit.get("transactionId") \
|
||
or value.get("transactionHash") != audit.get("transactionHash"):
|
||
errors.append(f"#/{key}Ref: transaction 身份与 Audit/2 分叉")
|
||
if transaction:
|
||
if transaction.get("moveId") != audit.get("moveId") \
|
||
or transaction.get("visualComplete") is not True \
|
||
or transaction.get("degradedReason") is not None:
|
||
errors.append("#/visualTransactionRef: 必须是同 move 的完整非降级视觉事务")
|
||
if visual:
|
||
if visual.get("artifactHash") != audit.get("artifactHash") \
|
||
or visual.get("status") != "pass" \
|
||
or visual.get("moveId") != audit.get("moveId"):
|
||
errors.append("#/visualAuditRef: artifact/move/status 与 Audit/2 不闭合")
|
||
if visual.get("visualTransactionRef") != audit.get("visualTransactionRef") \
|
||
or visual.get("visualTransactionHash") != audit.get("visualTransactionHash"):
|
||
errors.append("#/visualAuditRef: 未单向绑定同一 VisualTransaction")
|
||
if visual.get("visualSettlementFrameRef") != audit.get("visualSettlementFrameRef") \
|
||
or visual.get("visualSettlementFrameHash") != audit.get("visualSettlementFrameHash"):
|
||
errors.append("#/visualAuditRef: 未绑定 Audit/2 稳定终盘")
|
||
if effect:
|
||
if effect.get("artifactHash") != audit.get("artifactHash") or effect.get("status") != "pass":
|
||
errors.append("#/effectAuditRef: artifact/status 与 Audit/2 不闭合")
|
||
if effect.get("buildManifestRef") != audit.get("buildManifestRef") \
|
||
or effect.get("buildManifestHash") != audit.get("buildManifestHash"):
|
||
errors.append("#/effectAuditRef: 未绑定同一 build manifest")
|
||
if effect.get("filmstripManifestRef") != audit.get("filmstripManifestRef") \
|
||
or effect.get("filmstripManifestHash") != audit.get("filmstripManifestHash"):
|
||
errors.append("#/effectAuditRef: 未绑定同一 filmstrip")
|
||
if not effect.get("effectIds") or not effect.get("renderReceiptRefs"):
|
||
errors.append("#/effectAuditRef: 必须有局部 effect 与可信主画布 receipt")
|
||
for index, receipt_ref in enumerate(effect.get("renderReceiptRefs") or []):
|
||
receipt, _ = load_json_ref(
|
||
effect_path, receipt_ref.get("ref"), receipt_ref.get("hash"),
|
||
f"effectAuditRef/renderReceiptRefs/{index}",
|
||
"match3-effect-render-receipt.schema.json",
|
||
)
|
||
if not receipt or receipt.get("schemaVersion") != "Match3EffectRenderReceipt/1" \
|
||
or receipt.get("transactionId") != audit.get("transactionId") \
|
||
or receipt.get("mainContextIdentity") != "plugin-init-main-context":
|
||
errors.append(f"#/effectAuditRef/renderReceiptRefs/{index}: 主画布 receipt 不可信")
|
||
if lock:
|
||
if lock.get("artifactHash") != audit.get("artifactHash") \
|
||
or lock.get("probeSeed") != audit.get("seed") \
|
||
or lock.get("lockedStateUnchanged") is not True \
|
||
or lock.get("settleInputRestored") is not True \
|
||
or lock.get("status") != "pass":
|
||
errors.append("#/inputLockProbeRef: 输入锁 control/probe 未闭合")
|
||
if filmstrip:
|
||
if filmstrip.get("artifactHash") != audit.get("artifactHash") \
|
||
or filmstrip.get("status") != "pass":
|
||
errors.append("#/filmstripManifestRef: artifact/status 与 Audit/2 不闭合")
|
||
if filmstrip.get("settlementFrameRef") != audit.get("visualSettlementFrameRef") \
|
||
or filmstrip.get("settlementFrameHash") != audit.get("visualSettlementFrameHash"):
|
||
errors.append("#/filmstripManifestRef: 未绑定 Audit/2 稳定终盘")
|
||
phases = [row.get("phase") for row in filmstrip.get("frames") or []]
|
||
cursor = -1
|
||
for phase in ("swap", "clear", "fall_refill", "settle"):
|
||
try:
|
||
cursor = phases.index(phase, cursor + 1)
|
||
except ValueError:
|
||
errors.append("#/filmstripManifestRef/frames: 缺 swap→clear→fall_refill→settle 顺序")
|
||
break
|
||
if audio:
|
||
audio_schema = _load(_HERE / "match3-audio-audit.schema.json")
|
||
audio_errors = validate(audio_schema, audio, audio_schema)
|
||
if not audio_errors:
|
||
errors += [
|
||
f"#/audioAuditRef{error[1:]}"
|
||
for error in _semantic_validate_match3_audio_audit(audio)
|
||
]
|
||
if not audio.get("samples") or audio.get("pendingOnsetCueReceiptRefs"):
|
||
errors.append("#/audioAuditRef: Audit/2 只接受 populated 且无 pending onset 的完整音频账")
|
||
bgm_hash = bgm_rows[0].get("sha256") if len(bgm_rows) == 1 else None
|
||
if (audio.get("audioArtifactBinding") or {}).get("sha256") != bgm_hash:
|
||
errors.append("#/audioAuditRef/audioArtifactBinding: 未绑定 build manifest 的 BGM 原始字节")
|
||
for sample in audio.get("samples") or []:
|
||
for transition in sample.get("transitionRefs") or []:
|
||
if transition.get("transactionId") != audit.get("transactionId") \
|
||
or transition.get("transactionHash") != audit.get("transactionHash"):
|
||
errors.append("#/audioAuditRef/transitionRefs: transaction 与 Audit/2 分叉")
|
||
|
||
def has_reverse_ref(value) -> bool:
|
||
if isinstance(value, dict):
|
||
if "singleRollProofRef" in value or "match3RollInteractionAuditRef" in value:
|
||
return True
|
||
return any(has_reverse_ref(child) for child in value.values())
|
||
if isinstance(value, list):
|
||
return any(has_reverse_ref(child) for child in value)
|
||
return False
|
||
|
||
for key, (value, _) in sidecars.items():
|
||
if key != "actionAudit" and has_reverse_ref(value):
|
||
errors.append(f"#/{key}Ref: sidecar 不得反向引用 Audit/2 或 Proof/3")
|
||
return errors
|
||
|
||
|
||
def _validate_proof_resolution_bundle(manifest_path: Path) -> list:
|
||
"""跨文件核对 request→binding→Resolution→SingleRollProof→evidence 闭包。"""
|
||
errors = []
|
||
manifest_schema = _load(_HERE / "proof-resolution-bundle.schema.json")
|
||
manifest = _load_sample_instance(manifest_path)
|
||
errors += validate(manifest_schema, manifest, manifest_schema)
|
||
if errors:
|
||
return errors
|
||
|
||
def ref_path(field: str, *, nullable: bool = False):
|
||
ref = manifest.get(field)
|
||
if ref is None and nullable:
|
||
return None
|
||
path = (manifest_path.parent / str(ref)).resolve()
|
||
if not path.is_file():
|
||
errors.append(f"#/{field}: 引用文件不存在 {ref}")
|
||
return None
|
||
return path
|
||
|
||
request_path = ref_path("requestRef")
|
||
provenance_path = ref_path("provenanceRef")
|
||
parent_request_path = ref_path("parentRequestRef", nullable=True)
|
||
parent_provenance_path = ref_path("parentProvenanceRef", nullable=True)
|
||
brief_path = ref_path("briefRef", nullable=True)
|
||
proof_path = ref_path("singleRollProofRef")
|
||
binding_path = ref_path("interactionBindingRef", nullable=True)
|
||
resolution_path = ref_path("resolutionRef", nullable=True)
|
||
if not request_path or not provenance_path or not proof_path:
|
||
return errors
|
||
|
||
request = _load_sample_instance(request_path)
|
||
provenance = _load_sample_instance(provenance_path)
|
||
proof = _load_sample_instance(proof_path)
|
||
request_version = request.get("schemaVersion")
|
||
if request_version == "acceptance-request/1":
|
||
request_schema = _load(_HERE / "acceptance-request.schema.json")
|
||
provenance_schema = _load(_HERE / "acceptance-provenance.schema.json")
|
||
errors += validate(request_schema, request, request_schema)
|
||
errors += _semantic_validate_acceptance_profile(request)
|
||
errors += validate(provenance_schema, provenance, provenance_schema)
|
||
errors += _semantic_validate_acceptance_profile(provenance)
|
||
legacy_proof_schema = _load(_HERE / "single-roll-proof-v1.schema.json")
|
||
legacy_proof_errors = validate(legacy_proof_schema, proof, legacy_proof_schema)
|
||
errors += [f"#/singleRollProofRef{error[1:]}" for error in legacy_proof_errors]
|
||
if not legacy_proof_errors:
|
||
errors += [
|
||
f"#/singleRollProofRef{error[1:]}"
|
||
for error in _semantic_validate_single_roll_proof_v1(proof)
|
||
]
|
||
legacy_mirror_fields = (
|
||
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
|
||
"proofRegistryVersion", "taskBindingHash", "sourceArtifactHash",
|
||
"parentAcceptanceRequestHash", "repairOrdinal",
|
||
)
|
||
for field in legacy_mirror_fields:
|
||
if provenance.get(field) != request.get(field):
|
||
errors.append(f"#/provenanceRef/{field}: 必须逐字段镜像 legacy request")
|
||
for field in (
|
||
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
|
||
"proofRegistryVersion", "taskBindingHash",
|
||
):
|
||
if proof.get(field) != request.get(field):
|
||
errors.append(f"#/singleRollProofRef/{field}: 必须镜像 legacy request")
|
||
if proof.get("acceptanceRequestHash") != provenance.get("acceptanceRequestHash"):
|
||
errors.append(
|
||
"#/singleRollProofRef/acceptanceRequestHash: "
|
||
"必须绑定 legacy provenance 的 opaque request hash",
|
||
)
|
||
if proof.get("artifactHash") != provenance.get("artifactHash"):
|
||
errors.append("#/singleRollProofRef/artifactHash: 必须等于 legacy provenance artifactHash")
|
||
if provenance.get("schemaVersion") != "acceptance-provenance/1":
|
||
errors.append("#/provenanceRef: legacy request 必须配 acceptance-provenance/1")
|
||
if binding_path or resolution_path:
|
||
errors.append("#: legacy /1 不得读取 binding 或生成 Resolution sidecar")
|
||
if any(value is not None for value in (manifest.get("runnerBinding") or {}).values()):
|
||
errors.append("#/runnerBinding: legacy /1 不得传新 interaction path/scalars")
|
||
if manifest.get("publishFrozen") is not True:
|
||
errors.append("#/publishFrozen: legacy /1 必须冻结发布")
|
||
if parent_request_path is not None or parent_provenance_path is not None:
|
||
errors.append("#/parentRequestRef: legacy /1 不走 /2 repair 继承校验")
|
||
elif request_version == "acceptance-request/2":
|
||
request_schema = _load(_HERE / "acceptance-request-v2.schema.json")
|
||
provenance_schema = _load(_HERE / "acceptance-provenance-v2.schema.json")
|
||
proof_schema = _load(_HERE / "single-roll-proof-v2.schema.json")
|
||
resolution_schema = _load(_HERE / "proof-obligation-resolution.schema.json")
|
||
errors += validate(request_schema, request, request_schema)
|
||
errors += _semantic_validate_acceptance_profile(request)
|
||
errors += validate(provenance_schema, provenance, provenance_schema)
|
||
errors += _semantic_validate_acceptance_profile(provenance)
|
||
errors += validate(proof_schema, proof, proof_schema)
|
||
if not validate(proof_schema, proof, proof_schema):
|
||
errors += _semantic_validate_single_roll_proof_v2(proof)
|
||
if provenance.get("schemaVersion") != "acceptance-provenance/2":
|
||
errors.append("#/provenanceRef: /2 request 必须配 acceptance-provenance/2")
|
||
if resolution_path is None:
|
||
errors.append("#/resolutionRef: 新 /2 强制 Resolution sidecar")
|
||
return errors
|
||
resolution = _load_sample_instance(resolution_path)
|
||
errors += validate(resolution_schema, resolution, resolution_schema)
|
||
if not errors:
|
||
errors += _semantic_validate_proof_resolution(resolution)
|
||
|
||
request_hash = _canonical_domain_hash("acceptance-request/2", request)
|
||
if provenance.get("acceptanceRequestHash") != request_hash:
|
||
errors.append("#/provenanceRef/acceptanceRequestHash: 与完整 canonical request 不一致")
|
||
mirror_fields = [
|
||
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
|
||
"proofRegistryVersion", "taskBindingHash", "interactionBinding",
|
||
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
|
||
]
|
||
for field in mirror_fields:
|
||
if provenance.get(field) != request.get(field):
|
||
errors.append(f"#/provenanceRef/{field}: 必须逐字镜像 request")
|
||
resolution_identity = {
|
||
"acceptanceRequestHash": request_hash,
|
||
"proofProfileId": request.get("proofProfileId"),
|
||
"proofRegistryVersion": request.get("proofRegistryVersion"),
|
||
"taskBindingHash": request.get("taskBindingHash"),
|
||
}
|
||
for field, expected in resolution_identity.items():
|
||
if resolution.get(field) != expected:
|
||
errors.append(
|
||
f"#/resolutionRef/{field}: 必须与 canonical request 身份一致",
|
||
)
|
||
if request.get("repairOrdinal") == 0:
|
||
if parent_request_path is not None:
|
||
errors.append("#/parentRequestRef: repairOrdinal=0 时必须为 null")
|
||
if parent_provenance_path is not None:
|
||
errors.append("#/parentProvenanceRef: repairOrdinal=0 时必须为 null")
|
||
else:
|
||
parent_request = None
|
||
if parent_request_path is None:
|
||
errors.append("#/parentRequestRef: repairOrdinal=1 时必须引用 parent request/2")
|
||
else:
|
||
parent_request = _load_sample_instance(parent_request_path)
|
||
parent_errors = validate(request_schema, parent_request, request_schema)
|
||
if parent_errors:
|
||
errors.append(f"#/parentRequestRef: parent 不是合法 acceptance-request/2:{parent_errors[0]}")
|
||
else:
|
||
errors += _semantic_validate_acceptance_profile(parent_request)
|
||
parent_hash = _canonical_domain_hash("acceptance-request/2", parent_request)
|
||
if request.get("parentAcceptanceRequestHash") != parent_hash:
|
||
errors.append("#/parentAcceptanceRequestHash: 与 parent canonical request hash 不一致")
|
||
if parent_request.get("repairOrdinal") != 0:
|
||
errors.append("#/parentRequestRef/repairOrdinal: parent 必须是初始请求")
|
||
for field in (
|
||
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
|
||
"proofRegistryVersion", "taskBindingHash", "interactionBinding",
|
||
):
|
||
if request.get(field) != parent_request.get(field):
|
||
errors.append(f"#/parentRequestRef/{field}: repair 必须逐字段继承")
|
||
if parent_provenance_path is None:
|
||
errors.append("#/parentProvenanceRef: repairOrdinal=1 时必须引用 parent provenance/2")
|
||
else:
|
||
parent_provenance = _load_sample_instance(parent_provenance_path)
|
||
parent_provenance_errors = validate(
|
||
provenance_schema, parent_provenance, provenance_schema,
|
||
)
|
||
if parent_provenance_errors:
|
||
errors.append(
|
||
"#/parentProvenanceRef: parent 不是合法 acceptance-provenance/2:"
|
||
f"{parent_provenance_errors[0]}",
|
||
)
|
||
else:
|
||
errors += _semantic_validate_acceptance_profile(parent_provenance)
|
||
if parent_request is not None:
|
||
parent_request_hash = _canonical_domain_hash(
|
||
"acceptance-request/2", parent_request,
|
||
)
|
||
if parent_provenance.get("acceptanceRequestHash") != parent_request_hash:
|
||
errors.append(
|
||
"#/parentProvenanceRef/acceptanceRequestHash: "
|
||
"必须绑定 parent canonical request",
|
||
)
|
||
for field in (
|
||
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
|
||
"proofRegistryVersion", "taskBindingHash", "interactionBinding",
|
||
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
|
||
):
|
||
if parent_provenance.get(field) != parent_request.get(field):
|
||
errors.append(
|
||
f"#/parentProvenanceRef/{field}: "
|
||
"必须逐字段镜像 parent request",
|
||
)
|
||
if request.get("sourceArtifactHash") != parent_provenance.get("artifactHash"):
|
||
errors.append(
|
||
"#/parentProvenanceRef/artifactHash: repair sourceArtifactHash "
|
||
"必须等于 parent artifactHash",
|
||
)
|
||
|
||
if brief_path is None:
|
||
errors.append("#/briefRef: acceptance-request/2 必须引用原始 brief 文件")
|
||
brief_text = ""
|
||
else:
|
||
brief_raw = brief_path.read_bytes()
|
||
try:
|
||
brief_text = brief_raw.decode("utf-8", errors="strict")
|
||
except UnicodeDecodeError as exc:
|
||
brief_text = ""
|
||
errors.append(f"#/briefRef: brief 不是严格 UTF-8:{exc}")
|
||
brief_hash = hashlib.sha256(brief_raw).hexdigest()
|
||
for holder_path, holder in (
|
||
("requestRef", request), ("provenanceRef", provenance),
|
||
("singleRollProofRef", proof),
|
||
):
|
||
if holder.get("briefHash") != brief_hash:
|
||
errors.append(f"#/{holder_path}/briefHash: 与 brief 原始字节 SHA-256 不一致")
|
||
|
||
proof_registry = _load_proof_registry(request.get("proofRegistryVersion"))
|
||
profile = (proof_registry.get("profiles") or {}).get(request.get("proofProfileId")) or {}
|
||
normalized_brief = brief_text.lower()
|
||
expected_brief_matches = []
|
||
for rule in profile.get("briefOptionalRules") or []:
|
||
when = rule.get("when") or {}
|
||
any_keywords = when.get("anyKeywords") or []
|
||
all_keywords = when.get("allKeywords") or []
|
||
matched = (
|
||
(not any_keywords or any(str(keyword).lower() in normalized_brief for keyword in any_keywords))
|
||
and all(str(keyword).lower() in normalized_brief for keyword in all_keywords)
|
||
)
|
||
if matched:
|
||
expected_brief_matches.append(rule.get("id"))
|
||
for holder_path, actual in (
|
||
("resolutionRef", resolution.get("briefRuleMatches")),
|
||
("singleRollProofRef", proof.get("briefRuleMatches")),
|
||
("evidenceProjection", (manifest.get("evidenceProjection") or {}).get("briefRuleMatches")),
|
||
):
|
||
if actual != expected_brief_matches:
|
||
errors.append(f"#/{holder_path}/briefRuleMatches: 与原始 brief 确定性匹配结果不一致")
|
||
|
||
runner = manifest.get("runnerBinding") or {}
|
||
binding = request.get("interactionBinding")
|
||
if binding is None:
|
||
if binding_path is not None or any(value is not None for value in runner.values()):
|
||
errors.append("#: absent 时不得存在 binding 文件或 runner path/scalars")
|
||
if resolution.get("interactionBindingStatus") != "absent":
|
||
errors.append("#/resolutionRef/interactionBindingStatus: absent request 必须为 absent")
|
||
expected_interaction_matches = []
|
||
else:
|
||
if binding_path is None:
|
||
errors.append("#/interactionBindingRef: verified 时 binding 文件必需")
|
||
else:
|
||
binding_raw = binding_path.read_bytes()
|
||
binding_file_hash = hashlib.sha256(binding_raw).hexdigest()
|
||
try:
|
||
binding_file_value = _load_payload_canonical(binding_raw.decode("utf-8"))
|
||
except Exception as exc: # noqa: BLE001 —— 文件损坏必须稳定归契约错误
|
||
binding_file_value = None
|
||
errors.append(f"#/interactionBindingRef: 非严格 JSON:{exc}")
|
||
if not isinstance(binding_file_value, dict):
|
||
errors.append("#/interactionBindingRef: 必须是 InteractionBinding/1 对象,JSON null 非法")
|
||
elif binding_file_value != binding or provenance.get("interactionBinding") != binding:
|
||
errors.append("#/interactionBindingRef: request/provenance/file 三方对象不一致")
|
||
expected_runner = {
|
||
"bindingPath": manifest.get("interactionBindingRef"),
|
||
"interactionProfileId": binding.get("interactionProfileId"),
|
||
"interactionRegistryVersion": binding.get("interactionRegistryVersion"),
|
||
"interactionBindingHash": binding.get("interactionBindingHash"),
|
||
"taskBindingHash": binding.get("taskBindingHash"),
|
||
}
|
||
if runner != expected_runner:
|
||
errors.append("#/runnerBinding: 必须由 verified binding 确定性派生")
|
||
if resolution.get("interactionBindingRef") != manifest.get("interactionBindingRef"):
|
||
errors.append("#/resolutionRef/interactionBindingRef: 必须镜像 manifest 与 runner path")
|
||
if resolution.get("interactionBindingStatus") != "verified":
|
||
errors.append("#/resolutionRef/interactionBindingStatus: 完整 binding 必须为 verified")
|
||
if resolution.get("interactionBindingFileHash") != binding_file_hash:
|
||
errors.append("#/resolutionRef/interactionBindingFileHash: 与 binding 原始字节不一致")
|
||
if resolution.get("interactionBindingHash") != binding.get("interactionBindingHash"):
|
||
errors.append("#/resolutionRef/interactionBindingHash: 与语义 binding hash 不一致")
|
||
expected_interaction_matches = [
|
||
rule.get("id") for rule in profile.get("interactionOptionalRules") or []
|
||
if rule.get("interactionProfileId") == binding.get("interactionProfileId")
|
||
]
|
||
if resolution.get("interactionRuleMatches") != expected_interaction_matches:
|
||
errors.append("#/resolutionRef/interactionRuleMatches: 与 verified binding 精确匹配结果不一致")
|
||
|
||
resolution_bytes = _sealed_sample_bytes(resolution_path, resolution)
|
||
resolution_file_hash = hashlib.sha256(resolution_bytes).hexdigest()
|
||
if proof.get("proofObligationResolutionRef") != manifest.get("resolutionRef"):
|
||
errors.append("#/singleRollProofRef/proofObligationResolutionRef: 与 manifest 不一致")
|
||
if proof.get("proofObligationResolutionHash") != resolution_file_hash:
|
||
errors.append("#/singleRollProofRef/proofObligationResolutionHash: 未绑定 sidecar 原始字节")
|
||
for field in (
|
||
"gameId", "genre", "templateRoute", "proofProfileId", "proofRegistryVersion",
|
||
"taskBindingHash", "briefHash",
|
||
):
|
||
if proof.get(field) != request.get(field):
|
||
errors.append(f"#/singleRollProofRef/{field}: 必须镜像 request")
|
||
if proof.get("acceptanceRequestHash") != request_hash:
|
||
errors.append("#/singleRollProofRef/acceptanceRequestHash: 必须镜像 canonical request hash")
|
||
if proof.get("artifactHash") != provenance.get("artifactHash"):
|
||
errors.append("#/singleRollProofRef/artifactHash: 必须等于 provenance artifactHash")
|
||
for field in (
|
||
"acceptanceRequestHash", "proofProfileId", "proofRegistryVersion", "taskBindingHash",
|
||
):
|
||
if proof.get(field) != resolution.get(field):
|
||
errors.append(f"#/singleRollProofRef/{field}: 必须逐字镜像 Resolution 身份")
|
||
if proof.get("briefRuleMatches") != resolution.get("briefRuleMatches"):
|
||
errors.append("#/singleRollProofRef/briefRuleMatches: 必须镜像 Resolution")
|
||
|
||
judge_ref = proof.get("judgePackageRef")
|
||
judge_path = (proof_path.parent / str(judge_ref)).resolve()
|
||
if not judge_path.is_file():
|
||
errors.append(f"#/singleRollProofRef/judgePackageRef: 引用文件不存在 {judge_ref}")
|
||
else:
|
||
judge_schema = _load(_HERE / "judge-package.schema.json")
|
||
judge_package = _load_sample_instance(judge_path)
|
||
judge_errors = validate(judge_schema, judge_package, judge_schema)
|
||
# 隔离泄漏要先于结构噪声报告,便于审计确认被拦的是越权输入而非偶然字段错误。
|
||
errors += [
|
||
f"#/judgePackageRef{error[1:]}"
|
||
for error in _semantic_validate_judge_package(judge_package)
|
||
]
|
||
errors += [f"#/judgePackageRef{error[1:]}" for error in judge_errors]
|
||
judge_hash = hashlib.sha256(_sealed_sample_bytes(judge_path, judge_package)).hexdigest()
|
||
if proof.get("judgePackageHash") != judge_hash:
|
||
errors.append("#/singleRollProofRef/judgePackageHash: 与 JudgePackage 原始字节不一致")
|
||
if (proof.get("judge") or {}).get("packageHash") != judge_hash:
|
||
errors.append("#/singleRollProofRef/judge/packageHash: 必须镜像 JudgePackage hash")
|
||
for field in (
|
||
"gameId", "genre", "templateRoute", "proofProfileId", "proofRegistryVersion",
|
||
"taskBindingHash", "acceptanceRequestHash", "briefHash",
|
||
):
|
||
if judge_package.get(field) != proof.get(field):
|
||
errors.append(f"#/judgePackageRef/{field}: 必须镜像 SingleRollProof/2")
|
||
if judge_package.get("brief") != brief_text:
|
||
errors.append("#/judgePackageRef/brief: 必须逐字等于原始 brief")
|
||
if (judge_package.get("registry") or {}).get("briefRuleMatches") != expected_brief_matches:
|
||
errors.append("#/judgePackageRef/registry/briefRuleMatches: 必须镜像重算结果")
|
||
|
||
projection = manifest.get("evidenceProjection") or {}
|
||
if projection.get("acceptanceRequestHash") != request_hash:
|
||
errors.append("#/evidenceProjection/acceptanceRequestHash: 必须镜像 canonical request hash")
|
||
if projection.get("briefRuleMatches") != resolution.get("briefRuleMatches"):
|
||
errors.append("#/evidenceProjection/briefRuleMatches: 必须镜像 Resolution")
|
||
required_map = {
|
||
row.get("id"): row.get("required") for row in projection.get("proofObligations") or []
|
||
}
|
||
expected_required = set(resolution.get("requiredObligationIds") or [])
|
||
if {oid for oid, required in required_map.items() if required is True} != expected_required:
|
||
errors.append("#/evidenceProjection/proofObligations: required 集与 Resolution 不一致")
|
||
proof_projection = [
|
||
{"id": row.get("id"), "required": row.get("required")}
|
||
for row in proof.get("proofObligations") or []
|
||
]
|
||
if projection.get("proofObligations") != proof_projection:
|
||
errors.append("#/evidenceProjection/proofObligations: 必须由 SingleRollProof/2 逐项投影")
|
||
proof_required = {
|
||
row.get("id") for row in proof.get("proofObligations") or []
|
||
if row.get("required") is True
|
||
}
|
||
if proof_required != expected_required:
|
||
errors.append("#/singleRollProofRef/proofObligations: required 集与 Resolution 不一致")
|
||
else:
|
||
errors.append(f"#/requestRef/schemaVersion: 未知 request 版本 {request_version}")
|
||
|
||
proof_bytes = _sealed_sample_bytes(proof_path, proof)
|
||
proof_hash = hashlib.sha256(proof_bytes).hexdigest()
|
||
if manifest.get("proofPackageHash") != proof_hash:
|
||
errors.append("#/proofPackageHash: 与 SingleRollProof 封存字节不一致")
|
||
return errors
|
||
|
||
|
||
def _validate_file(schema: dict, sample_path: Path) -> list:
|
||
"""校验单个样本文件,并把样本装载故障标成测试基础设施失败。"""
|
||
try:
|
||
instance = _load_sample_instance(sample_path)
|
||
except _SampleFixtureLoadError as e:
|
||
return [f"{_SAMPLE_INFRASTRUCTURE_ERROR_PREFIX}{e}"]
|
||
except Exception as e: # noqa: BLE001 —— 顶层故意构造的非法 JSON 仍是契约负样本
|
||
return [f"#: JSON 解析失败:{e}"]
|
||
errors = validate(schema, instance, schema)
|
||
# 先保证结构成立再跑跨引用语义,避免结构缺字段时产生大量次生噪声。
|
||
if not errors:
|
||
errors += _semantic_validate(schema, instance)
|
||
if not errors and schema.get("title") == "ReferenceAssetConsumptionManifestV1":
|
||
errors += _semantic_validate_reference_asset_consumption_manifest_bytes(sample_path, instance)
|
||
return errors
|
||
|
||
|
||
def _run_suite(samples_root: Path) -> int:
|
||
"""套件模式:正样本应全过、负样本应全拦。返回进程退出码(0=期望全满足)。"""
|
||
contracts = [
|
||
("play-spec", _HERE / "play-spec.schema.json", []),
|
||
("verdict-feedback", _HERE / "verdict-feedback.schema.json", []),
|
||
("game-event", _HERE / "game-event.schema.json", []),
|
||
("playtest-evidence", _HERE / "playtest-evidence.schema.json", []),
|
||
("proof-obligation-registry", _HERE / "proof-obligation-registry.schema.json",
|
||
[_HERE / "proof-obligations.v2.json"]),
|
||
("proof-obligation-registry-legacy-v2",
|
||
_HERE / "proof-obligation-registry.2026-07-14.v2.schema.json",
|
||
[_HERE / "proof-obligations.2026-07-14.v2.json"]),
|
||
("legacy-game-log-mapping", _HERE / "legacy-game-log-mapping.schema.json",
|
||
[_HERE / "legacy-game-log-mapping.v1.json"]),
|
||
("single-roll-fact", _HERE / "single-roll-fact.schema.json", []),
|
||
("acceptance-request", _HERE / "acceptance-request.schema.json", []),
|
||
("acceptance-provenance", _HERE / "acceptance-provenance.schema.json", []),
|
||
("acceptance-request-v2", _HERE / "acceptance-request-v2.schema.json", []),
|
||
("acceptance-provenance-v2", _HERE / "acceptance-provenance-v2.schema.json", []),
|
||
("acceptance-request-v3", _HERE / "acceptance-request-v3.schema.json", []),
|
||
("acceptance-provenance-v3", _HERE / "acceptance-provenance-v3.schema.json", []),
|
||
("reference-asset-record", _HERE / "reference-asset-record.schema.json", []),
|
||
("reference-asset-registry", _HERE / "reference-asset-registry.schema.json",
|
||
[_HERE / "reference-asset-registry.initial.json"]),
|
||
("reference-asset-record-v2", _HERE / "reference-asset-record-v2.schema.json", []),
|
||
("reference-asset-registry-v2", _HERE / "reference-asset-registry-v2.schema.json", []),
|
||
("reference-asset-consumption-manifest",
|
||
_HERE / "reference-asset-consumption-manifest.schema.json", []),
|
||
("reference-asset-consumption-policy",
|
||
_HERE / "reference-asset-consumption-policy.schema.json", []),
|
||
("reference-asset-release", _HERE / "reference-asset-release.schema.json", []),
|
||
("reference-asset-verification-receipt",
|
||
_HERE / "reference-asset-verification-receipt.schema.json", []),
|
||
("acceptance-provenance-v4", _HERE / "acceptance-provenance-v4.schema.json", []),
|
||
("actor-selection", _HERE / "actor-selection.schema.json", []),
|
||
("judge-result", _HERE / "judge-result.schema.json", []),
|
||
("visual-target-set", _HERE / "visual-target-set.schema.json", []),
|
||
("target-resolution", _HERE / "target-resolution.schema.json", []),
|
||
("cost-reservation", _HERE / "cost-reservation.schema.json", []),
|
||
("playtest-evidence-v3", _HERE / "playtest-evidence-v3.schema.json", []),
|
||
("single-roll-fact-v3", _HERE / "single-roll-fact-v3.schema.json", []),
|
||
("interaction-profile-registry", _HERE / "interaction-profile-registry.schema.json",
|
||
[_HERE / "interaction-profiles.v1.json"]),
|
||
("interaction-profile-registry-v2", _HERE / "interaction-profile-registry-v2.schema.json",
|
||
[_HERE / "interaction-profiles.v2.json"]),
|
||
("match3-producer-preflight", _HERE / "match3-producer-preflight.schema.json", []),
|
||
("interaction-binding", _HERE / "interaction-binding.schema.json", []),
|
||
("proof-obligation-resolution", _HERE / "proof-obligation-resolution.schema.json", []),
|
||
("single-roll-proof-v1", _HERE / "single-roll-proof-v1.schema.json",
|
||
[_HERE / "samples/proof-resolution-bundle/fixtures/legacy-single-roll-proof.json"]),
|
||
("single-roll-proof-v2", _HERE / "single-roll-proof-v2.schema.json", []),
|
||
("single-roll-proof-v3", _HERE / "single-roll-proof-v3.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/single-roll-proof-v3.json",
|
||
]),
|
||
("judge-package", _HERE / "judge-package.schema.json", []),
|
||
("match3-board-projection", _HERE / "match3-board-projection.schema.json", []),
|
||
("match3-swap-set", _HERE / "match3-swap-set.schema.json", []),
|
||
("actor-selection-v2", _HERE / "actor-selection-v2.schema.json", []),
|
||
("actor-view-v3", _HERE / "actor-view-v3.schema.json", []),
|
||
("actor-view-v4", _HERE / "actor-view-v4.schema.json", []),
|
||
("actor-selection-context", _HERE / "actor-selection-context.schema.json", []),
|
||
("actor-protocol-bundle", _HERE / "actor-protocol-bundle.schema.json", []),
|
||
("actor-selection-attempt", _HERE / "actor-selection-attempt.schema.json", []),
|
||
("match3-pair-resolution", _HERE / "match3-pair-resolution.schema.json", []),
|
||
("grouped-action-ref", _HERE / "grouped-action-ref.schema.json", []),
|
||
("board-identity-check", _HERE / "board-identity-check.schema.json", []),
|
||
("match3-action-group", _HERE / "match3-action-group.schema.json", []),
|
||
("match3-roll-interaction-audit-unit", _HERE / "match3-roll-interaction-audit.schema.json", []),
|
||
("match3-audio-audit", _HERE / "match3-audio-audit.schema.json", []),
|
||
("match3-roll-interaction-audit-v2", _HERE / "match3-roll-interaction-audit-v2.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/match3-roll-interaction-audit-v2.json",
|
||
]),
|
||
("build-manifest", _HERE / "build-manifest.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/specialty-build-manifest.json",
|
||
]),
|
||
("match3-visual-transaction", _HERE / "match3-visual-transaction.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/specialty-visual-transaction.json",
|
||
]),
|
||
("match3-visual-audit", _HERE / "match3-visual-audit.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/specialty-visual-audit.json",
|
||
]),
|
||
("match3-effect-audit", _HERE / "match3-effect-audit.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/specialty-effect-audit.json",
|
||
]),
|
||
("match3-effect-render-receipt", _HERE / "match3-effect-render-receipt.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/specialty-effect-receipt.json",
|
||
]),
|
||
("input-lock-probe", _HERE / "input-lock-probe.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/specialty-input-lock.json",
|
||
]),
|
||
("match3-filmstrip-analyzer", _HERE / "match3-filmstrip-analyzer.schema.json", [
|
||
_HERE / "samples/match3-action-bundle/fixtures/specialty-filmstrip.json",
|
||
]),
|
||
]
|
||
total_pass = total_block = 0
|
||
all_ok = True
|
||
for name, schema_path, canonical_valids in contracts:
|
||
schema = _load(schema_path)
|
||
base = samples_root / name
|
||
valid_dir, invalid_dir = base / "valid", base / "invalid"
|
||
valids = list(canonical_valids)
|
||
if valid_dir.is_dir():
|
||
valids += sorted(valid_dir.glob("*.json"))
|
||
invalids = sorted(invalid_dir.glob("*.json")) if invalid_dir.is_dir() else []
|
||
print(f"\n══ {name}({schema_path.name})══")
|
||
|
||
n_pass = 0
|
||
for f in valids:
|
||
errs = _validate_file(schema, f)
|
||
if errs:
|
||
all_ok = False
|
||
print(f" ✗ 正样本本应过却被拦:{f.name}")
|
||
for e in errs[:4]:
|
||
print(f" {e}")
|
||
else:
|
||
n_pass += 1
|
||
print(f" ✓ 正样本过:{f.name}")
|
||
|
||
n_block = 0
|
||
for f in invalids:
|
||
errs = _validate_file(schema, f)
|
||
infrastructure_errors = [
|
||
error for error in errs
|
||
if error.startswith(_SAMPLE_INFRASTRUCTURE_ERROR_PREFIX)
|
||
]
|
||
if infrastructure_errors:
|
||
all_ok = False
|
||
print(f" ✗ 负样本测试基础设施失败:{f.name}")
|
||
for error in infrastructure_errors[:4]:
|
||
print(f" {error}")
|
||
elif errs:
|
||
n_block += 1
|
||
# 打印首条错误,证明是按预期规则拦下的(非偶然)。
|
||
print(f" ✓ 负样本拦:{f.name} —— {errs[0]}")
|
||
else:
|
||
all_ok = False
|
||
print(f" ✗ 负样本本应拦却放过:{f.name}")
|
||
|
||
print(f" 小计:正样本 {n_pass}/{len(valids)} 过,负样本 {n_block}/{len(invalids)} 拦")
|
||
total_pass += n_pass
|
||
total_block += n_block
|
||
|
||
bundle_base = samples_root / "proof-resolution-bundle"
|
||
bundle_valids = sorted((bundle_base / "valid").glob("[0-9][0-9]-*.json"))
|
||
bundle_invalids = sorted((bundle_base / "invalid").glob("[0-9][0-9]-*.json"))
|
||
print("\n══ proof-resolution-bundle(跨文件闭包)══")
|
||
bundle_pass = bundle_block = 0
|
||
for manifest in bundle_valids:
|
||
errs = _validate_proof_resolution_bundle(manifest)
|
||
if errs:
|
||
all_ok = False
|
||
print(f" ✗ 正样本本应过却被拦:{manifest.name}")
|
||
for error in errs[:4]:
|
||
print(f" {error}")
|
||
else:
|
||
bundle_pass += 1
|
||
print(f" ✓ 正样本过:{manifest.name}")
|
||
for manifest in bundle_invalids:
|
||
errs = _validate_proof_resolution_bundle(manifest)
|
||
if errs:
|
||
descriptor = _load(manifest)
|
||
expected_pattern = descriptor.get("expectedErrorPattern")
|
||
matched = (
|
||
expected_pattern is None
|
||
or any(re.search(expected_pattern, error) for error in errs)
|
||
)
|
||
if matched:
|
||
bundle_block += 1
|
||
print(f" ✓ 负样本拦:{manifest.name} —— {errs[0]}")
|
||
else:
|
||
all_ok = False
|
||
print(
|
||
f" ✗ 负样本虽被拦但未命中预期错误 {expected_pattern!r}:"
|
||
f"{manifest.name} —— {errs[0]}",
|
||
)
|
||
else:
|
||
all_ok = False
|
||
print(f" ✗ 负样本本应拦却放过:{manifest.name}")
|
||
print(
|
||
f" 小计:正样本 {bundle_pass}/{len(bundle_valids)} 过,"
|
||
f"负样本 {bundle_block}/{len(bundle_invalids)} 拦",
|
||
)
|
||
total_pass += bundle_pass
|
||
total_block += bundle_block
|
||
|
||
for title, directory, validator in (
|
||
("match3-action-bundle(双 tap 跨文件闭包)", "match3-action-bundle", _validate_match3_action_bundle),
|
||
("match3-roll-interaction-audit(恢复预算跨文件闭包)", "match3-roll-interaction-audit", _validate_match3_roll_audit),
|
||
("match3-specialty-proof(视觉音频跨文件闭包)", "match3-specialty-proof", _validate_match3_specialty_proof_bundle),
|
||
):
|
||
base = samples_root / directory
|
||
valids = sorted((base / "valid").glob("[0-9][0-9]-*.json"))
|
||
invalids = sorted((base / "invalid").glob("[0-9][0-9]-*.json"))
|
||
print(f"\n══ {title} ══")
|
||
valid_count = invalid_count = 0
|
||
for sample in valids:
|
||
errs = validator(sample)
|
||
if errs:
|
||
all_ok = False
|
||
print(f" ✗ 正样本本应过却被拦:{sample.name}")
|
||
for error in errs[:4]:
|
||
print(f" {error}")
|
||
else:
|
||
valid_count += 1
|
||
print(f" ✓ 正样本过:{sample.name}")
|
||
for sample in invalids:
|
||
errs = validator(sample)
|
||
descriptor = _load(sample)
|
||
pattern = descriptor.get("expectedErrorPattern") if isinstance(descriptor, dict) else None
|
||
if errs and (pattern is None or any(re.search(pattern, error) for error in errs)):
|
||
invalid_count += 1
|
||
print(f" ✓ 负样本拦:{sample.name} —— {errs[0]}")
|
||
elif errs:
|
||
all_ok = False
|
||
print(f" ✗ 负样本未命中预期错误 {pattern!r}:{sample.name} —— {errs[0]}")
|
||
else:
|
||
all_ok = False
|
||
print(f" ✗ 负样本本应拦却放过:{sample.name}")
|
||
print(f" 小计:正样本 {valid_count}/{len(valids)} 过,负样本 {invalid_count}/{len(invalids)} 拦")
|
||
total_pass += valid_count
|
||
total_block += invalid_count
|
||
|
||
print(f"\n══ 总计:正样本 {total_pass} 过 / 负样本 {total_block} 拦 —— "
|
||
f"{'全部符合预期' if all_ok else '存在未达预期项(见上 ✗)'}")
|
||
return 0 if all_ok else 1
|
||
|
||
|
||
def main(argv: list) -> int:
|
||
try:
|
||
argv = _configure_trace(argv)
|
||
except ValueError as exc:
|
||
print(f"FAIL {exc}")
|
||
return 2
|
||
if len(argv) >= 2 and argv[1] == "--suite":
|
||
root = Path(argv[2]).resolve() if len(argv) >= 3 else (_HERE / "samples")
|
||
return _run_suite(root)
|
||
if len(argv) == 3 and argv[1] == "--proof-package-dir":
|
||
errors = _validate_proof_resolution_bundle(Path(argv[2]).resolve())
|
||
if errors:
|
||
for error in errors:
|
||
print(f"FAIL {error}")
|
||
return 1
|
||
print(f"PASS {Path(argv[2]).name}")
|
||
return 0
|
||
if len(argv) == 3 and argv[1] in (
|
||
"--match3-action-bundle", "--match3-roll-audit", "--match3-specialty-proof",
|
||
):
|
||
validator = {
|
||
"--match3-action-bundle": _validate_match3_action_bundle,
|
||
"--match3-roll-audit": _validate_match3_roll_audit,
|
||
"--match3-specialty-proof": _validate_match3_specialty_proof_bundle,
|
||
}[argv[1]]
|
||
target = Path(argv[2]).resolve()
|
||
errors = validator(target)
|
||
if errors:
|
||
for error in errors:
|
||
print(f"FAIL {error}")
|
||
return 1
|
||
print(f"PASS {target.name}")
|
||
return 0
|
||
if len(argv) < 3:
|
||
print(__doc__)
|
||
return 2
|
||
schema = _load(Path(argv[1]).resolve())
|
||
exit_code = 0
|
||
for sample_arg in argv[2:]:
|
||
sample_path = Path(sample_arg).resolve()
|
||
errs = _validate_file(schema, sample_path)
|
||
if errs:
|
||
exit_code = 1
|
||
print(f"FAIL {sample_path.name}")
|
||
for e in errs:
|
||
print(f" {e}")
|
||
else:
|
||
print(f"PASS {sample_path.name}")
|
||
return exit_code
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv))
|