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

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

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

4176 lines
245 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

"""
cheap_verify.py — 便宜档「丰富度」LLM 验证 agent非阻塞·只报告
架构红线(创始人多轮强调,绝不可破):**项目代码只做机械确定的事**;玩法/美术/音乐的「丰富」——
无论生成还是校验——都需大模型能力,**绝不写成代码校验**code-presence/静态扫描/正则/断言一律禁)。
故本模块只做两件 100% 机械确定的事,「丰富不丰富」的判断本身完全交 LLM judge
① 读产物 L3 源码(确定性文件 I/O+ 拼 judge prompt确定性字符串
② 解析 LLM 返回的 JSON 成结构化评分(确定性解析 + degraded 兜底)。
本模块**不含任何丰富度的代码断言**——11 条丰富度清单只是【喂给 LLM 的评分尺】,命中与否全由 judge 读码后裁;
评分按层标 L2/L3/L4 聚合成三分组小计L2 内容丰富 / L3 留存结构 / L4 传播钩子,不设单一总分),规范见质量模型 SoT §3.3 计分制。
sim-business-game-design.md §10 写了「4 条可机检 code-presence」子集那与本红线冲突——本模块刻意不照它写代码校验。
非阻塞铁律LLM 调失败/超时/解析失败 → 返回 {score:None, degraded:True, reason:...}
**绝不抛、绝不阻断生成、绝不进 verdict / 不改达标判定**。接线方cheap_studio再包一层 try/except 双保险。
被 cheap_studio.run_studio 收口段调用(九门 play 之后、写 run-summary 之前),结果 additive 写 summary["richness"]。
"""
import asyncio
import base64
import fcntl
import hashlib
import json
import math
import os
import stat
import subprocess
import sys
import secrets
import tempfile
import threading
import time
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from pathlib import Path, PurePosixPath
import artifact_snapshot
import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap故纯函数可在无 agentscope 环境单测。
# ── 通用底座 v2 = 11 条丰富度清单judge 的评分 rubric──
# 这是【喂给 LLM judge 的打分尺】,不是代码校验:丰富不丰富全由 judge 读源码后逐条裁。
# 规范来源 = 质量模型 SoT《游戏质量与爆火能力·设计》§3.3 计分制(每条 0/1 + 层标 L2/L3/L4产三分组小计、不设单一总分
# v2 = 现行 8 条(与 .agents/skills/sim-business-game-design.md §10 同源)+ 新增 3 条(首 3 分钟脚本 / 炫耀时刻 / 同款钩子)。
# 每项 = (标准名, 层标, 含义)judge 按标准名逐条回命中与否 + 理由,命中数按层聚合成三分组小计。
# ★锚定纪律§3.3 第 3 条):原 8 条判据文本与顺序一字不改(评分尺变更须金标复验、原 8 条判定漂移 ≤±1
# 新增 3 条追加于末尾;层标只是聚合归属,不改原条目的判据本身。
# 分层含义L2 内容丰富(有料耐玩)/ L3 留存结构(想再玩的结构前提)/ L4 传播钩子(想传出去的结构前提)。
RICHNESS_CHECKLIST = [
# 原 8 条(文本/顺序锁定,仅补层标)
("即时反馈", "L2", "每个主要操作有「飘字 +N + 音效 + 粒子」即时回报"),
("可见成长", "L2", "有数字/规模肉眼可见地变大(分数飙升/店铺扩张/等级上涨)"),
("下一个解锁", "L3", "任意时刻玩家眼前都有「再攒一点就解锁 X」的钩子"),
("30秒爽点", "L2", "开局 30 秒内有第一次升级/解锁"),
("数值滚雪球", "L2", "成长有「越来越快」的暴富段,而非平淡线性"),
("情感锚", "L2", "有萌角色/拥有物让玩家「想养大它」"),
("放置回归", "L3", "离线回来有惊喜(离线收益弹窗等)"),
("音反馈", "L2", "收益/升级/解锁有声音(哪怕程序化)"),
# 新增 3 条v2 草案,随首个品类件落地验证;判据文本源自 §3.3 第 1 条)
("首3分钟脚本", "L3", "首 3 分钟脚本成立010s 零阅读上手 / 1060s 首次升级 / 13min 露出 23 个后续锁"),
("炫耀时刻", "L4", "有可截图分享的结算/成就画面(打完有结算展示场景,不是直接黑屏)"),
("同款钩子", "L4", "有清晰的品类原型 + 主题标签元数据,可供 remix 预填做同款"),
# 第 12 条2026-07-03 增补,质量 SoT §4 规范一「通用底座 12 条口径」;创始人授权默认项拍板采纳):
# 源自便宜档主 prompt 自检第 ⑨ 条(策划知识包 v2 否决项判定句式),升为评分尺条目。
# 锚定纪律:追加于末尾、前 11 条一字不动L2 分母随之 6→7档位观测线百分比不变、分母随升
# 落地与金标复验同批(复验漂移超线按规范三复采样定性再判回退)。
("核心操作非无脑", "L2", "每次主操作含真实决策或技巧含量、判错有真代价(如补错货压库存),不是点哪都加分/点了就自动结算"),
]
_MAX = len(RICHNESS_CHECKLIST) # = 122026-07-03 ⑨升第 12 条;此前 v2 = 11
_LAYERS = ("L2", "L3", "L4") # 三分组固定顺序(消费面按分组小计各自取阈,见 §3.6 档位表)
# ── 品类扩展 rubricW-GENRE 品类件④;质量模型 SoT §4 规范二)──
# 数据源唯一 = fixtures/genre-rubrics/<genre>.jsonitems 键,条目 {name,layer,meaning,positive,negative}
# 经 load_genre_checklist 加载。2026-07-02 rubric 挂点归一:原内联 GENRE_CHECKLISTS 字典退役,
# TRPG 条目一字不动迁入 trpg.json——品类条目自此只有 fixture 一路,不再留内联第二源。
# 每品类另加 ≥4 条品类特有维度:同一字段形状 (标准名, 层标, 含义)、同样标层,
# **分母独立小计**(与通用底座分开、不混合平均——评分输出落 richness.genre.groups通用 groups 不变)。
# 这仍是【喂给 LLM judge 的评分尺】数据、不是代码校验(红线:不进九门/不进 verdict/不进脚手架);
# 设计侧同源对齐 = .agents/skills/<genre>-game-design.md §10 品类自检。
# 锚定纪律(规范三):品类条目文本变更须金标复验(金标正例 + 薄反例,分组小计漂移 ≤±1 即回退)。
_GENRE_RUBRICS_DIR = Path(__file__).resolve().parent / "fixtures" / "genre-rubrics"
# 品类脚手架目录名 → 品类 keyrun_studio 据 scaffold_template 透传 genreNone/未登记 → 不评品类扩展)。
# 非遗品类键收敛 = "heritage"(与 rubric 文件名/散文 skill heritage-game-design.md 同名):
# 模板目录 _template-feiyi 是资产目录名、金标 spec 的 _genre="heritage-craft" 是金标名driver 承重不动),
# 三者同一品类——映射依据详见 fixtures/genre-rubrics/heritage.json 的 _note。
GENRE_BY_TEMPLATE = {
"_template-trpg": "trpg",
"_template-puzzle": "puzzle",
"_template-feiyi": "heritage",
# 2026-07-04 数据面补全(策划知识包 v2):剧情/经营两品类 fixture 落地补线,
# 条目源见各 fixture _note(narrative=skill §10 表逐字迁入;sim-business=质量 SoT §4 规范二名目展开)。
"_template-story": "narrative",
"_template-shop": "sim-business",
}
def load_genre_checklist(genre) -> list:
"""读品类 rubric fixture品类条目唯一数据源→ [(标准名, 层标, 判据)*]genre 空/文件缺/坏条目 → [](绝不抛)。
条目形状随 fixturename/layer/meaning正反例 positive/negative 只作金标锚材料、不进 judge prompt
——prompt 里逐条只给一句可判定判据,与通用底座条目同形)。
"""
if not genre or not isinstance(genre, str):
return []
try:
p = _GENRE_RUBRICS_DIR / (genre + ".json")
if not p.is_file():
return []
data = json.loads(p.read_text(encoding="utf-8"))
out = []
for it in (data.get("items") or []):
if not isinstance(it, dict):
continue
name, layer, meaning = it.get("name"), it.get("layer"), it.get("meaning")
if isinstance(name, str) and name and layer in _LAYERS and isinstance(meaning, str) and meaning:
out.append((name, layer, meaning))
return out
except Exception: # noqa: BLE001 fixture 坏 → 扩展为空,评分退化为纯通用底座,绝不阻断
return []
def _layer_maxes(checklist=RICHNESS_CHECKLIST) -> dict:
"""各层条目数(分母)。通用底座 12 条口径 = L2:7 / L3:3 / L4:22026-07-03 第 12 条入 L2"""
m = {ly: 0 for ly in _LAYERS}
for _name, layer, _meaning in checklist:
m[layer] = m.get(layer, 0) + 1
return m
def _group_subtotals(hits: list, checklist=RICHNESS_CHECKLIST) -> dict:
"""把逐条命中按层标聚合成三分组小计 {L2:{score,max}, L3:..., L4:...}。
这是 §3.3 计分制的落库主形状——三层各自对得上自己的消费点(品类件验收/批次观测/回流分析),
**不做单一总分平均**(单一总分诱导 Goodhart见 §3.3 第 1 条)。
"""
maxes = _layer_maxes(checklist)
scores = {ly: 0 for ly in _LAYERS}
for h in hits:
ly = h.get("layer")
if h.get("hit") and ly in scores:
scores[ly] += 1
return {ly: {"score": scores[ly], "max": maxes.get(ly, 0)} for ly in _LAYERS}
# 只读 L3 游戏本体(玩法/画面/数值/资产);跳过 L1 plumbinghost-config/game.js/index.html——
# 固定脚手架、与「游戏丰富不丰富」无关,喂给 judge 只会稀释信号)。
_L3_SOURCE_FILES = ("game-logic.js", "core.js", "render.js", "balance.js", "assets.js")
_MAX_SRC_BYTES = 24000 # 喂 judge 的源码总量上限(控 token / 成本;超限按字节截断)。
_JUDGE_MAX_TOKENS = 4000 # judge 输出小11 条短理由 + 一句点评4000 足够、M3 无 thinking。
# judge 的 system / user 框定——只评分、不改码、要看源码真实现、输出严格 JSON。
# 计数用 len(RICHNESS_CHECKLIST) 动态拼(2026-07-03 ⑨升第 12 条时改:此前「11 条」写死在文案里,
# 清单变更即漂;动态取数后条数永远与评分尺同源。评分尺变更本身仍受锚定纪律约束=金标复验)。
_JUDGE_SYSTEM = (
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
f"只评判它「作为一款游戏够不够丰富、好玩、耐玩、想传出去」,逐条给出 {len(RICHNESS_CHECKLIST)} 条丰富度清单的命中与否 + 一句中文理由,"
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
f"{len(RICHNESS_CHECKLIST)} 条分三层L2 内容丰富有料耐玩、L3 留存结构想再玩的结构前提、L4 传播钩子(想让别人看/做同款的前提);"
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。"
)
# 品类扩展评分时的 system 变体W-GENRE 件④):仅把量词放宽到「通用 N 条 + 品类扩展若干条」,
# 其余判据措辞一字不动(锚定纪律:无品类路走上面原版 _JUDGE_SYSTEM
_JUDGE_SYSTEM_GENRE = (
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
f"只评判它「作为一款游戏够不够丰富、好玩、耐玩、想传出去」,逐条给出丰富度清单(通用 {len(RICHNESS_CHECKLIST)} 条 + 品类扩展若干条)的命中与否 + 一句中文理由,"
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
"条目分三层L2 内容丰富有料耐玩、L3 留存结构想再玩的结构前提、L4 传播钩子(想让别人看/做同款的前提);"
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。"
)
def _degraded(reason: str) -> dict:
"""降级结果非阻塞铁律score=None + degraded=Truescore 为 None 表示「这次没评出来」,绝不参与达标。
groups 同置 None三分组小计的消费面品类件验收/回流分析)遇 None 即「本次未评出」,与 score=None 同义。
"""
return {"score": None, "max": _MAX, "hits": [], "groups": None, "notes": None, "degraded": True, "reason": reason}
def _coerce_bool(v) -> bool:
"""把 judge 可能给的多形态命中值归一为 booltrue/1/""/"命中" 等 → True其余 → False"""
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v > 0
if isinstance(v, str):
return v.strip().lower() in ("true", "1", "yes", "y", "", "命中", "hit", "", "", "")
return False
def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST, genre_checklist=None, genre_key=None) -> dict:
"""纯函数:把 judge 的 LLM 文本输出解析成结构化丰富度评分(含 degraded 兜底)。可单测、零网络、零 agentscope。
成功 → {
score:int(11 条命中总数 0..11), # ★向后兼容字段:仅供 bake_off.richness_dist 读;非「质量总分」。
max:11, # 设计不设单一总分(诱导 Goodhart主消费形状 = groups 三分组小计。
hits:[{name,layer,hit,why}*11], # 逐条:加 layer 层标L2/L3/L4
groups:{L2:{score,max}, L3:..., L4:...}, # ★三分组小计§3.3 计分制主形状)
notes:str, degraded:False}
传 genre_checklist品类扩展条目W-GENRE 件④)时 additive 加:
genre:{key, hits:[…*N], score, max:N, groups:{…}} # ★品类分母独立小计§4 规范二:不与通用混合平均)
解析失败(空/非 JSON 对象/缺 checks 数组)→ _degraded(...)score=None, groups=None, degraded=True
对齐策略:优先按 name 对齐 judge 的 checksname 对不上的按位置兜底(容忍模型改名/英文名)——
checks 数组约定 = 通用条目在前、品类条目紧随其后,位置兜底按段内偏移算。
layer 层标取自 checklist不依赖 LLM 回传),故分组小计对模型改名鲁棒。
本函数**不含任何丰富度的代码判断**——只搬运 judge 的逐条裁决并按层聚合命中数。
"""
if not isinstance(text, str) or not text.strip():
return _degraded("judge 输出为空")
try:
# json_repair 容忍 markdown 围栏 / 前后赘语 / 尾逗号等 LLM 常见脏输出。
import json_repair
data = json_repair.loads(text)
except Exception as e: # noqa: BLE001 解析层任何异常都降级,绝不抛
return _degraded(f"judge 输出解析失败:{type(e).__name__}")
if not isinstance(data, dict):
return _degraded("judge 输出不是 JSON 对象")
checks_raw = data.get("checks")
if not isinstance(checks_raw, list) or not checks_raw:
return _degraded("judge 输出缺 checks 数组")
by_name = {}
for c in checks_raw:
if isinstance(c, dict):
nm = str(c.get("name", "")).strip()
if nm:
by_name[nm] = c
def _align(items, offset):
"""按 name 对齐(对不上按位置兜底,位置 = offset + 段内下标)→ [{name,layer,hit,why}]。"""
out = []
for i, (name, layer, _meaning) in enumerate(items):
c = by_name.get(name)
pos = offset + i
if c is None and pos < len(checks_raw) and isinstance(checks_raw[pos], dict):
c = checks_raw[pos] # name 对不上 → 位置兜底
hit = _coerce_bool(c.get("hit")) if isinstance(c, dict) else False
why = (str(c.get("why", "")).strip()[:200]) if isinstance(c, dict) else ""
out.append({"name": name, "layer": layer, "hit": hit, "why": why}) # layer 取自 checklist权威
return out
hits = _align(checklist, 0)
score = sum(1 for h in hits if h["hit"])
notes = str(data.get("notes", "")).strip()[:500]
result = {"score": score, "max": len(checklist), "hits": hits,
"groups": _group_subtotals(hits, checklist), "notes": notes, "degraded": False}
# 品类扩展段W-GENRE 件④checks 数组的通用段之后是品类条目;**分母独立小计**(不并进通用 groups/score
if genre_checklist:
ghits = _align(genre_checklist, len(checklist))
result["genre"] = {
"key": genre_key,
"hits": ghits,
"score": sum(1 for h in ghits if h["hit"]),
"max": len(genre_checklist),
"groups": _group_subtotals(ghits, genre_checklist),
}
return result
def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str:
"""读产物 L3 源码game-logic/core/render/balance/assets拼成带文件头的一段文本总量截断到 max_bytes。
只读 L3 游戏本体;读不到的文件跳过;全空 → 返回空串(上层据此降级、跳过 LLM、不白烧钱
"""
src_dir = cheap_run.game_dir(game_id) / "src"
parts = []
total = 0
for name in _L3_SOURCE_FILES:
p = src_dir / name
try:
if not p.is_file():
continue
txt = p.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue # 单文件读失败不致命,跳过即可
chunk = f"\n// ===== {name} =====\n{txt}\n"
parts.append(chunk)
total += len(chunk.encode("utf-8"))
if total >= max_bytes:
break
joined = "".join(parts)
enc = joined.encode("utf-8")
if len(enc) > max_bytes: # 字节级截断兜底(防单个大文件超限撑爆 token
joined = enc[:max_bytes].decode("utf-8", "ignore") + "\n// [源码已截断]"
return joined
def _build_judge_user(src_text: str, brief: str = "", genre_key: str = None, genre_checklist=None) -> str:
"""拼 judge 的 user 消息brief可选+ 11 条清单(带层标)[+ 品类扩展清单] + 产物源码 + 严格 JSON 输出契约。
纯确定性字符串拼接。**不传品类时输出与既有版本逐字节一致**(金标复验基线稳,规范三锚定纪律);
传品类时通用清单后 additive 追加品类段、输出契约条数 = 11+Nchecks 通用在前、品类紧随)。
"""
checklist_lines = "\n".join(
f"{i + 1}. [{layer}] {name}{meaning}" for i, (name, layer, meaning) in enumerate(RICHNESS_CHECKLIST)
)
brief_block = f"这款游戏的 brief玩家想要的{brief}\n\n" if brief else ""
n_genre = len(genre_checklist) if genre_checklist else 0
if n_genre:
genre_lines = "\n".join(
f"{len(RICHNESS_CHECKLIST) + i + 1}. [{layer}] {name}{meaning}"
for i, (name, layer, meaning) in enumerate(genre_checklist)
)
genre_block = (
f"\n另有 {n_genre} 条【{genre_key} 品类扩展清单】(分母独立、不与上面 {len(RICHNESS_CHECKLIST)} 条混合,同样逐条独立判命中):\n"
f"{genre_lines}\n"
)
total_note = f"{len(RICHNESS_CHECKLIST) + n_genre} 条(通用 {len(RICHNESS_CHECKLIST)} 条在前、品类 {n_genre} 条紧随其后)"
name_note = "name 用上面各条的中文名"
else:
genre_block = ""
total_note = f"{len(RICHNESS_CHECKLIST)}"
name_note = f"name 用上面 {len(RICHNESS_CHECKLIST)} 条的中文名"
return (
f"{brief_block}"
f"下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 {len(RICHNESS_CHECKLIST)} 条丰富度清单(每条前的 [L2]/[L3]/[L4] 是分层标注——"
"L2 内容丰富、L3 留存结构、L4 传播钩子——仅用于分组,不改变你对每条的独立判命中),"
"据**源码里真实实现了的玩法/数值/音效/解锁/结算画面/品类元数据**判断(别被注释或空壳骗:比如只 import 了 audioMusic "
"但收益处没真调 playSfx则「音反馈」不算命中\n\n"
f"{checklist_lines}\n"
f"{genre_block}\n"
"=== 源码开始 ===\n"
f"{src_text}\n"
"=== 源码结束 ===\n\n"
"严格只输出以下 JSON不要任何额外文字、不要 markdown 围栏):\n"
f'{{"checks":[{{"name":"即时反馈","hit":true,"why":"一句中文理由"}}, … {total_note}'
f'{name_note}、hit 为 true/false], "notes":"整体一句话点评"}}'
)
def _extract_text(resp) -> str:
"""从 ChatResponse 抽纯文本content 是 TextBlock/ToolCall... 序列,只取 text 块(兼容 pydantic 块与 dict 块)。"""
content = getattr(resp, "content", None)
if content is None and isinstance(resp, dict):
content = resp.get("content")
if isinstance(content, str):
return content
parts = []
for b in (content or []):
if isinstance(b, dict):
if b.get("type") == "text" and isinstance(b.get("text"), str):
parts.append(b["text"])
elif getattr(b, "type", None) == "text":
t = getattr(b, "text", None)
if isinstance(t, str):
parts.append(t)
return "".join(parts)
async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=None,
timeout: float = 90.0, max_src_bytes: int = _MAX_SRC_BYTES,
genre: str = None) -> dict:
"""生成完成后跑一次 LLM 丰富度评分(非阻塞·只报告)。**绝不抛、绝不阻断、绝不进 verdict / 不改达标**。
Args:
game_id: 本 run 游戏 id读 games/amgen-<id>/src/ 下 L3 源码)。
brief: 本局 brief喂 judge 做上下文,提升「情感锚/解锁」等判断质量);缺省空串。
model: 可选注入的 LLM 客户端(单测用 fake model 注入、零网络None → 用 _bootstrap.build_cheap_model
新建**独立** M3 实例judge token 不污染生成成本台账 costRmb——richness 是非阻塞 add-on
sources: 可选直接注入的源码文本(单测用,绕过文件 I/ONone → 从 game_id 收集。
timeout: judge LLM 调用超时秒数;超时 → degraded绝不卡死收口
max_src_bytes: 喂 judge 的源码上限。
genre: 品类 keyW-GENRE 件④);命中 fixtures/genre-rubrics/<genre>.json唯一数据源
同一次评分 additive 追加品类扩展条目(分母独立小计落 result["genre"]
None/fixture 缺 → 行为与既有版本完全一致prompt 逐字节不变)。
Returns:
成功 → parse_judge_output 的结构化评分(含 judgeTokens 观测best-effort
任何失败src 空/LLM 异常/超时/解析失败)→ _degraded(...)score=None, degraded=True, reason
"""
try:
src_text = sources if sources is not None else _collect_sources(game_id, max_bytes=max_src_bytes)
if not src_text or not src_text.strip():
return _degraded("产物 src 为空/读不到,跳过 LLM 评分")
# 惰性 importagentscope / _bootstrap 较重,且让上面的 parse_judge_output 等纯函数能在无 agentscope 环境单测。
from agentscope.message import SystemMsg, UserMsg
m = model
if m is None:
import _bootstrap # 代理旁路 + key 注入由 _bootstrap.build_cheap_model 内部处理
m = _bootstrap.build_cheap_model(max_tokens=_JUDGE_MAX_TOKENS)
# 品类扩展(唯一数据源 = fixtures/genre-rubrics/<genre>.json经 load_genre_checklist
# 没有 fixture → 静默按 None——评分尺没有它的条目别让评分半路造尺
genre_checklist = (load_genre_checklist(genre) or None) if genre else None
genre_key = genre if genre_checklist else None
sys_msg = SystemMsg(name="system", content=_JUDGE_SYSTEM_GENRE if genre_checklist else _JUDGE_SYSTEM)
user_msg = UserMsg(name="user", content=_build_judge_user(src_text, brief,
genre_key=genre_key, genre_checklist=genre_checklist))
# 硬超时包裹真模型调用judge 卡住绝不能拖死收口(非阻塞铁律)。
resp = await asyncio.wait_for(m([sys_msg, user_msg]), timeout=timeout)
result = parse_judge_output(_extract_text(resp), genre_checklist=genre_checklist, genre_key=genre_key)
# best-effort 附 judge token外部 LLM 调用可观测与生成成本台账隔离——judge 用独立 model 实例)。
try:
ti, to = m.usage_sum()
result["judgeTokens"] = {"in": ti, "out": to, "total": ti + to}
except Exception: # noqa: BLE001 观测字段失败不影响评分主体
pass
return result
except (asyncio.TimeoutError, TimeoutError):
return _degraded(f"judge LLM 超时(>{timeout}s")
except Exception as e: # noqa: BLE001 非阻塞铁律:任何异常(网络/import/解析)都降级,绝不抛断生成主链
return _degraded(f"judge 异常:{type(e).__name__}: {e}")
# ══════════════════════════════════════════════════════════════════════════════
# 玩法地板判定(W-AXIS 波2 · 质量模型 SoT 裁定三的代码兑现)
# ──────────────────────────────────────────────────────────────────────────────
# 与上面的丰富度评分是两物,别混:
# · 丰富度(verify_richness):评「有多丰富/多耐玩」的【程度】——非阻塞软信号,只观测、永不拒发(L2)。
# · 玩法地板(judge_gameplay_floor):裁「作为游戏成不成立」的【地板布尔】——阻断放行(L1 的判定半)。
# 裁定三钉死的分界:判定只裁三类【拒绝】的有无,不判程度高低:
# · broken —— 真人玩不通 / 到不了终局 / 起不来 / 卡死一屏 / 核心操作无响应;
# · hollow —— 空壳:核心循环没有决策层(点哪都一样、无输赢取舍、纯自动结算的挂机点击),判「有没有」不判「多好」;
# · off_brief —— 不切题:画面/玩法与 brief 要的游戏是两回事。
# 三条铁律(裁定三):
# ① 出题≠被考(2026-07-10 创始人修正边界):判定只吃运行证据不吃自报;判定模型允许 M3(生成同源)——
# 地板只裁「有无」不裁品味、同源盲区风险最小,且 07-09 spike 实证 M3 当场抓出 M3 自己写的渲染 bug;
# 同源残余风险由金标正反例+创始人抽玩定标兜底。修正动因=glm-5.2 唯一通道(闲鱼二手中转)图像支持
# 按池轮换、07-09 整日全盲,判定层可用性不能押注二手供给。SoT 修订随验收 v2 双评审落档。
# ② fail-closed:判定失败/评不出/无证据 → 不放行、标 degraded(绝不静默降级成自动通过)。
# ③ 校准与阻断开关:金标正反例复验 + 创始人抽玩定标;judge.blocking 开关默认 true,可一键整体回退到修订前口径。
# 判定模型走 new-api:生产档由 generation.yaml judge.model 定(现 MiniMax-M3,一手直连通道);
# 单局判定成本目标 ≤¥0.3(实测 4-6 帧约 ¥0.10,记账进 judge.costRmb)。
# 判定默认档(genconfig 不可达时的编译回落,生产以 YAML 为准):2026-07-10 随创始人决策切 M3。
# 07-09 探针实证 glm-5.2 与 MiniMax-M3 均能读图;glm-5.2 因二手供给整日全盲弃用。
_JUDGE_DEFAULT_MODEL = "MiniMax-M3"
# 三类拒绝的机读键 → 对外类名(off_brief 用下划线,与 LLM 输出的 camelCase offBrief 解耦)。
_FLOOR_CLASSES = (("broken", "broken"), ("hollow", "hollow"), ("offBrief", "off_brief"))
_FLOOR_SYSTEM = (
"你是轻量小游戏的【玩法地板判定 agent】。你不是打分器——只判「这款游戏作为一个游戏,地板成不成立」,"
"只裁【有没有】、绝不裁【做得多好】(丰富/耐玩/好玩的程度问题不归你,归另一套软评分)。"
"你只看运行证据(真玩截图 / 运行日志 / 取证状态时间线),绝不采信任何自报、承诺或源码里写了什么。"
"逐条判这三类【拒绝】是否成立:\n"
"· broken(坏死):真人根本玩不通 —— 起不来 / 一直卡在同一屏 / 核心操作点了没反应 / 到不了任何终局或结算。\n"
"· hollow(空壳):核心循环没有决策层 —— 点哪都一样、没有输赢、没有取舍、判错没有代价,纯自动结算的挂机点击。"
"只判「有没有决策层」,不判「决策做得多有层次」。\n"
"· off_brief(不切题):画面与玩法跟 brief 要的游戏是两回事(主题漂移 / 品类不符)。\n"
"任一类成立 → 整体判 reject;三类都不成立 → 判 accept。"
"证据不足以断定某一类是否成立时,该类按【成立(有问题)】算——地板判定 fail-closed,宁可错拦、不可放过坏游戏"
"(后面有人工复核与金标校准兜)。严格只输出 JSON,不要任何额外文字、不要 markdown 围栏。"
)
_FLOOR_OUTPUT_CONTRACT = (
"严格只输出以下 JSON(problem=true 表示「这一类有问题、应拒绝」,why 给一句中文依据;"
"imagesSeen=你在本条消息里【真实看到】的截图张数,整数——一张都看不到就如实填 0,"
"这个字段用于检测图像传输故障,绝不据它奖惩你):\n"
'{"imagesSeen":0,"broken":{"problem":false,"why":""},"hollow":{"problem":false,"why":""},'
'"offBrief":{"problem":false,"why":""},"verdict":"accept","notes":"整体一句话"}'
)
def _degraded_floor(reason: str) -> dict:
"""fail-closed 降级结果(裁定三②):评不出/无证据/调用失败 → 不放行、标 degraded。
accepted=False 是【地板判定的 fail-closed 语义】,与丰富度的 score=None(非阻塞观测)完全不同:
这里 degraded 直接构成「拒绝」(rejectClasses=['degraded']),阻断放行,由人工复核兜。
"""
return {"accepted": False, "verdict": "reject", "rejectClasses": ["degraded"],
"checks": [], "notes": None, "degraded": True, "reason": reason}
def parse_floor_judgment(text) -> dict:
"""纯函数:把判定 LLM 的 JSON 输出解析成结构化地板裁决(含 fail-closed 兜底)。可单测、零网络、零 agentscope。
成功 → {
accepted: bool, # = 三类均无问题 且 LLM verdict 未判 reject(两者取交,任一拒即拒)
verdict: "accept"|"reject",
rejectClasses: [str], # 命中的拒绝类子集(broken/hollow/off_brief)
checks: [{class, problem, why}*3],
notes: str, degraded: False}
解析失败 / 非对象 / 缺任一类判定字段 → _degraded_floor(...)(accepted=False、fail-closed)。
每一类容忍两种形状:{problem,why} 对象,或裸 bool(模型偷懒直接给 true/false);缺字段一律 fail-closed
(无法确认地板 → 保守拒绝),绝不把「没判出来」静默当成通过。
"""
if not isinstance(text, str) or not text.strip():
return _degraded_floor("判定输出为空")
try:
import json_repair # 容忍 markdown 围栏 / 前后赘语 / 尾逗号等 LLM 常见脏输出
data = json_repair.loads(text)
except Exception as e: # noqa: BLE001 解析层任何异常都 fail-closed,绝不抛
return _degraded_floor(f"判定输出解析失败:{type(e).__name__}")
if not isinstance(data, dict):
return _degraded_floor("判定输出不是 JSON 对象")
checks = []
for raw_key, cname in _FLOOR_CLASSES:
node = data.get(raw_key)
if isinstance(node, dict):
problem = _coerce_bool(node.get("problem"))
why = str(node.get("why", "")).strip()[:200]
elif node is not None: # 裸 bool/字符串:模型没按对象给,也接住
problem = _coerce_bool(node)
why = ""
else: # 缺这一类判定 → 无法确认地板 → fail-closed(不静默判过)
return _degraded_floor(f"判定输出缺 {raw_key} 字段(无法确认地板,fail-closed)")
checks.append({"class": cname, "problem": problem, "why": why})
reject_classes = [c["class"] for c in checks if c["problem"]]
llm_verdict = str(data.get("verdict", "")).strip().lower()
# accepted 需两者一致取 accept:三类均无问题 且 LLM 自评 verdict 不是 reject(任一给拒绝信号 → 拒,fail-closed 偏严)。
accepted = (not reject_classes) and (llm_verdict != "reject")
# imagesSeen:模型自报真实看到的截图张数(图像盲检测的结构化信号;缺失/非数字 → None=未知,不触发盲重掷)。
# 背景(2026-07-09 基线终审实锤):网关对 glm-5.2 按请求体确定性路由,~15% 载荷永远落在丢图通道——
# 同载荷重试必盲、任何字节微扰即换通道。靠措辞 grep 判盲太脆,改为契约字段模型自报。
images_seen = None
try:
raw_seen = data.get("imagesSeen")
if raw_seen is not None and not isinstance(raw_seen, bool):
images_seen = max(0, int(raw_seen))
except (TypeError, ValueError):
images_seen = None
return {"accepted": accepted, "verdict": "accept" if accepted else "reject",
"rejectClasses": reject_classes, "checks": checks, "imagesSeen": images_seen,
"notes": str(data.get("notes", "")).strip()[:300], "degraded": False}
class _JudgeResp:
"""判定多模态调用的极简响应载体(content=纯文本);与丰富度 _extract_text 的 str 分支兼容。"""
def __init__(self, text: str):
self.content = text if isinstance(text, str) else ""
class _NewapiVisionModel:
"""判定用的薄多模态客户端:直连 new-api /v1/chat/completions(OpenAI 兼容,支持 image_url 图像块)。
为什么不复用 build_cheap_model(agentscope M3):判定层要的是薄而可控的多模态 HTTP 客户端——
直连网关对图像块形状可控(image_url data-URI 已 probe 坐实)、finish_reason/reasoning 元信息可留痕,
与生成栈(agentscope 全家桶)解耦;仍走 new-api 统一出口(base+/v1 + NEWAPI_KEY),不违统一网关铁律。
(判定模型 2026-07-10 起=MiniMax-M3,由 YAML judge.model 定;出题≠被考边界修正见 §判定地板头注。)
trust_env=False:判定目标在内网 Tailscale(new-api 100.64.0.8),彻底绕开本机 clash 代理(fake-ip 拦本地/内网,
§7 内网直连必绕代理)。records 记 (in,out,cached) 供 judge 段成本记账(与生成成本台账隔离——判定用独立档)。
"""
def __init__(self, model_name: str, base_url: str, api_key: str, *,
max_tokens: int = 1500, timeout: float = 120.0):
self.model_name = model_name
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.max_tokens = max_tokens
self.timeout = timeout
self.records = [] # [(input_tokens, output_tokens, cached_tokens)]
self.last_meta = {} # 最近一次响应的诊断元信息(finishReason/reasoningChars)——content 空时据此归因
async def __call__(self, messages):
import httpx # noqa: PLC0415 惰性 import(顶层零重依赖;与 cheap_verify 纯函数可无 httpx 环境单测同源)
body = {"model": self.model_name, "max_tokens": self.max_tokens, "messages": messages}
async with httpx.AsyncClient(trust_env=False, timeout=self.timeout) as h:
r = await h.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
)
r.raise_for_status()
d = r.json()
u = d.get("usage") or {}
pi = int(u.get("prompt_tokens") or u.get("input_tokens") or 0)
po = int(u.get("completion_tokens") or u.get("output_tokens") or 0)
cached = 0
det = u.get("prompt_tokens_details") or {}
if isinstance(det, dict):
cached = int(det.get("cached_tokens") or 0)
self.records.append((pi, po, cached))
ch = (d.get("choices") or [{}])[0]
msg = ch.get("message") or {}
text = msg.get("content", "")
# 判定器自身的真相层:留 finish_reason 与 reasoning 长度。glm-5.2 带思考(reasoning_content),思考长度是
# 随机变量——吃光 max_tokens 时 content 为空、finish_reason=length(w2b-verify 2026-07-09 实测案),据此可诊断。
self.last_meta = {"finishReason": ch.get("finish_reason"),
"reasoningChars": len(msg.get("reasoning_content") or "")}
return _JudgeResp(text if isinstance(text, str) else "")
def usage_sum(self):
return (sum(r[0] for r in self.records), sum(r[1] for r in self.records))
def _wg1_evidence_dir(game_id: str) -> Path:
"""判卷证据目录 = _wg1-gen/<id>/evidence/(九门 play 落 first-paint/after-play/midplay/game-log/verdict 处)。"""
return cheap_run.wg1_game_dir(game_id) / "evidence"
def _natural_key(name: str):
"""midplay-2 排在 midplay-10 前:按名字里的数字段自然排序(纯字母段保原序)。"""
import re # noqa: PLC0415
return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", name)]
def _collect_frames(evidence_dir, *, max_frames: int = 6) -> list:
"""收 evidence 目录的真玩截图 → [data:image/png;base64,...],顺序 first-paint → 局中连拍 midplay-* → after-play。
超 max_frames 时保头尾(首帧+局末)、中间连拍均匀采样(判定需要「局前→局中→局末」的时间跨度,不能只留一头)。
读不到目录 / 无截图 → [](上层据此 fail-closed:真玩过的游戏必有 first-paint,缺失即证据异常)。
"""
try:
ev = Path(evidence_dir)
if not ev.is_dir():
return []
ordered = []
fp = ev / "first-paint.png"
if fp.is_file():
ordered.append(fp)
ordered.extend(sorted(ev.glob("midplay-*.png"), key=lambda p: _natural_key(p.name)))
ap = ev / "after-play.png"
if ap.is_file():
ordered.append(ap)
if len(ordered) > max_frames:
head, tail, mid = ordered[0], ordered[-1], ordered[1:-1]
keep = max_frames - 2
if keep <= 0:
ordered = [head, tail]
else:
step = len(mid) / keep
ordered = [head] + [mid[int(i * step)] for i in range(keep)] + [tail]
out = []
for p in ordered:
try:
out.append("data:image/png;base64," + base64.b64encode(p.read_bytes()).decode())
except OSError:
continue
return out
except Exception: # noqa: BLE001 证据装配失败 → 返回空(上层 fail-closed),绝不抛
return []
def _read_state_timeline(evidence_dir) -> str:
"""从九门 verdict.json 抽一段取证状态时间线(判定的文字证据,补截图看不清的数值/终态信号)。
只搬运机械门已测出的客观信号(不加解读):H_progress 的 score 玩前/玩后、E_live 的 played/phase/活性、
latch 的终局 phase 与是否驻留、D_render 的亮度。读不到/坏 → 空串(判定仍靠截图,不伪造)。
"""
try:
ev = Path(evidence_dir)
p = ev / "verdict.json"
if not p.is_file():
return ""
v = json.loads(p.read_text(encoding="utf-8"))
guards = (v or {}).get("guards") or {}
lines = []
hg = guards.get("H_progress") or {}
for c in (hg.get("checks") or []):
if isinstance(c, dict):
lines.append(f"进展[{c.get('path')}] {c.get('op')}: 玩前={c.get('before')} 玩后={c.get('after')}"
f"(机械门判 {'达成' if c.get('pass') else '未达成'}")
latch = hg.get("latch") or {}
if isinstance(latch, dict) and latch:
lines.append(f"终局 latch: 到达终态={bool(latch.get('pass'))} 此刻 phase={latch.get('phaseNow') or latch.get('after')}"
f" {(latch.get('reason') or '')[:60]}")
el = guards.get("E_live") or {}
if isinstance(el, dict) and el:
lines.append(f"活性 E_live: 真进过游玩={el.get('played')} 玩后 phase={el.get('phase1')}"
f" 去重画面态数={el.get('distinctStates')} 状态真变={el.get('liveByState')}")
dr = guards.get("D_render") or {}
if isinstance(dr, dict) and dr:
lines.append(f"渲染 D_render: 亮度={dr.get('bright')} 最大通道={dr.get('maxCh')}")
return "\n".join(lines)
except Exception: # noqa: BLE001 取证时间线 best-effort,读不出降级空串
return ""
def _read_game_log_text(evidence_dir, *, max_entries: int = 12, max_chars: int = 800) -> str:
"""从 evidence/game-log.json 摘运行时日志(游戏语义 + 插件告警),给判定补运行期信号。读不到/坏 → 空串。"""
try:
ev = Path(evidence_dir)
p = ev / "game-log.json"
if not p.is_file():
return ""
arr = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(arr, list) or not arr:
return ""
def _fmt(e):
if not isinstance(e, dict):
return str(e)[:100]
return f"[{e.get('scope')}:{e.get('tag')}] {e.get('msg') or ''}"[:100]
return "\n".join(_fmt(e) for e in arr[-max_entries:])[:max_chars]
except Exception: # noqa: BLE001 日志摘要 best-effort
return ""
def _build_floor_messages(brief: str, state_text: str, log_text: str, data_uris: list) -> list:
"""拼判定的 OpenAI 多模态消息:brief + 取证时间线 + 日志 + 「首帧/局中/局末」截图序列 + 输出契约。"""
head = (
f"这款游戏的 brief玩家想要的{brief or '(未提供)'}\n\n"
"下面给你这一局【真玩】留下的运行证据。请只据这些运行证据判三类拒绝是否成立。\n\n"
f"【取证状态时间线(机械门客观测得,未加解读)】\n{state_text or '(无)'}\n\n"
f"【运行时日志(节选)】\n{log_text or '(无)'}\n\n"
f"【真玩截图】接下来 {len(data_uris)} 张图按时间顺序给出:第 1 张=首帧(刚进入),"
"中间=局中连拍(真玩过程),最后 1 张=局末(真玩结束时)。请对照 brief 看这些画面是不是一款玩得通、"
"有决策层、且切题的游戏:\n"
)
parts = [{"type": "text", "text": head}]
for uri in data_uris:
parts.append({"type": "image_url", "image_url": {"url": uri}})
parts.append({"type": "text", "text": "\n" + _FLOOR_OUTPUT_CONTRACT})
return [{"role": "system", "content": _FLOOR_SYSTEM},
{"role": "user", "content": parts}]
def _build_judge_model(model_name: str, max_tokens: int, timeout: float):
"""装判定多模态客户端(直连 new-api /v1)。key 从 env/凭据档解析(内网阶段单一事实源)。"""
import _bootstrap # noqa: PLC0415 代理旁路时序与 key 注入由 _bootstrap/client 处理
_bootstrap.ensure_api_key_env()
from worker import client # noqa: PLC0415
base = client.resolve_base_url().rstrip("/")
if not base.endswith("/v1"):
base = base + "/v1"
return _NewapiVisionModel(model_name, base, client.get_api_key(),
max_tokens=max_tokens, timeout=timeout)
def _estimate_judge_cost(model_name: str, ti: int, to: int, cached: int):
"""据 new-api /api/pricing 权威倍率把判定 token 折成 ¥(与生成成本台账同口径 observability.cost.compute)。
best-effort:取价失败(网关不可达 / 无 httpx)→ Nonejudge 段记 tokens、costRmb 留空,不伪造成本)。
"""
try:
from observability import cost as _cost, newapi_pricing as _np # noqa: PLC0415
params = _np.fetch_pricing_params()
if not params:
return None
d = _cost.compute(model_name, ti, to, params["pricing"], params["qpu"],
params["usd_rate"], group_ratio=1.0, cached_tokens=cached)
return round(d["rmb"], 5)
except Exception: # noqa: BLE001 成本记账 best-effort,绝不连累判定主体
return None
def _persist_judge_json(evidence_dir, result: dict, raw_text: str) -> None:
"""判定真相层落盘(best-effort,绝不抛):evidence/judge.json = 完整地板裁决 + LLM 原始输出头(截断 2000 字)。
动机(W-AXIS 波2 验收发现,2026-07-09):判定结果此前只进 run-summary → 后端 trace,本地证据目录零痕迹——
verdict.json 孤零零 pass=true,盘上看不出这局判定发生过什么;degraded(如「判定输出为空」)更无从诊断。
判定器自己也要有真相层:每次判定(含 degraded)都在证据目录留完整裁决与原始输出。目录不存在则静默跳过(单测 fake gid)。
"""
try:
ev = Path(evidence_dir) if evidence_dir else None
if ev is None or not ev.is_dir():
return
payload = dict(result)
payload["rawTextHead"] = (raw_text or "")[:2000]
payload["ts"] = int(time.time() * 1000)
(ev / "judge.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception: # noqa: BLE001 落盘失败绝不连累判定主体
pass
async def judge_gameplay_floor(game_id: str, *, brief: str = "", model=None, frames=None,
state_text=None, log_text=None, evidence_dir=None,
model_name: str = None, max_tokens: int = 1500,
timeout: float = 120.0, max_frames: int = 6) -> dict:
"""对一局真玩证据跑独立多模态玩法地板判定(阻断权威;裁定三)。**fail-closed**:任何失败/无证据 → degraded=拒绝)。
与 verify_richness 的非阻塞铁律相反——这里失败不是「静默降级放行」,而是「fail-closed 拒绝」:
评不出的游戏不许自动过,标 degraded 交人工复核。
Args:
game_id: 本局 id读 _wg1-gen/<id>/evidence/ 下截图/日志/verdict
brief: 本局 briefoff_brief 判定必需)。
model: 可注入的判定 LLM 客户端(单测用 fake零网络None → 建 glm-5.2 多模态客户端。
frames/state_text/log_text: 可直接注入的证据(单测用,绕文件 I/ONone → 从 evidence_dir 收集。
evidence_dir: 证据目录None → _wg1-gen/<id>/evidence/。
model_name/max_tokens/timeout/max_frames: 判定档旋钮(缺省走 glm-5.2 / 1500 / 120s / 6 帧)。
Returns:
parse_floor_judgment 的结构(附 judgeTokens/costRmb/model/frames 观测),或 _degraded_floor(...)fail-closed
"""
mn = model_name or _JUDGE_DEFAULT_MODEL
ev = None
try:
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
imgs = frames if frames is not None else _collect_frames(ev, max_frames=max_frames)
if not imgs:
# 无真玩截图:真玩过的游戏必落 first-paint.png,缺失即证据异常 → fail-closed绝不无证据放行
r = _degraded_floor("真玩截图证据缺失(无 first-paint/midplay/after-playfail-closed 不放行")
r["model"] = mn
_persist_judge_json(ev, r, "")
return r
st = state_text if state_text is not None else _read_state_timeline(ev)
lg = log_text if log_text is not None else _read_game_log_text(ev)
m = model if model is not None else _build_judge_model(mn, max_tokens, timeout)
async def _roll(msgs):
"""一掷判定 = 一次调用 + 空输出有界重试一次(2026-07-09 w2b-verify 实测案):glm-5.2 带思考,
思考长度是随机变量——吃光 max_tokens 时 content 为空;同证据复跑即可得答案(主会话复现坐实)。
重试前放大 max_tokens 对症;绝不无限重试(判定成本有账)。放大取 max(原值, ×2封顶6000):
base 已调大(YAML 202752)时绝不反向缩小——旧式 min(×2,6000) 在大 base 下会把上限缩回 6000。"""
t = ""
used = 0
for attempt in range(2):
resp = await asyncio.wait_for(m(msgs), timeout=timeout)
used = attempt + 1
t = _extract_text(resp)
if isinstance(t, str) and t.strip():
break
if attempt == 0 and hasattr(m, "max_tokens"):
cur = int(getattr(m, "max_tokens", 1500))
m.max_tokens = max(cur, min(cur * 2, 6000))
return t, used
text, attempts_used = await _roll(_build_floor_messages(brief, st, lg, imgs))
result = parse_floor_judgment(text)
# 图像盲微扰重掷(2026-07-09 基线终审实锤):网关对 glm-5.2 按请求体【确定性】路由,部分载荷永远落在
# 丢图通道——同载荷重试必盲(3 例假阴复判 3/3 仍盲),而 brief 尾加一个空格微扰载荷即换路由、当场看见。
# 故:送了帧而模型自报 imagesSeen=0 → 微扰重掷一次;重掷仍自报 0 → 这是判定仪器故障不是游戏证据缺失,
# fail-closed degraded 归因「图像通道盲」交人工,不再把好游戏错杀成 hollow/off_brief(基线 3/20 假阴案)。
# imagesSeen=None(模型没报/老 fake)不触发,向后兼容。
if imgs and not result.get("degraded") and result.get("imagesSeen") == 0:
text2, used2 = await _roll(_build_floor_messages(brief + " ", st, lg, imgs))
attempts_used += used2
second = parse_floor_judgment(text2)
if not second.get("degraded") and second.get("imagesSeen") == 0:
second = _degraded_floor("判定模型两掷均自报看不见截图(网关同载荷确定性丢图)——判定仪器故障 fail-closed,待人工复核")
second["imageBlindRetried"] = True
result, text = second, text2
result["model"] = mn
result["frames"] = len(imgs)
if attempts_used > 1:
result["retries"] = attempts_used - 1
# 判定器真相层:附最近响应的 finish_reason(空输出归因的第一手证据;fake 模型无此属性则省略)。
meta = getattr(m, "last_meta", None)
if isinstance(meta, dict) and meta.get("finishReason") is not None:
result["finishReason"] = meta.get("finishReason")
# 判定成本记账(独立档,不污染生成 costRmb——best-effort。
try:
ti, to = m.usage_sum()
cached = sum(r[2] for r in getattr(m, "records", []) if len(r) > 2)
result["judgeTokens"] = {"in": ti, "out": to, "total": ti + to}
result["costRmb"] = _estimate_judge_cost(mn, ti, to, cached)
except Exception: # noqa: BLE001 成本/观测字段失败不影响判定主体
pass
_persist_judge_json(ev, result, text)
return result
except (asyncio.TimeoutError, TimeoutError):
r = _degraded_floor(f"判定 LLM 超时(>{timeout}sfail-closed 不放行")
r["model"] = mn
_persist_judge_json(ev, r, "")
return r
except Exception as e: # noqa: BLE001 fail-closed:任何异常(网络/import/解析)都判 degraded=拒绝,绝不放行
r = _degraded_floor(f"判定异常:{type(e).__name__}: {e}")
r["model"] = mn
_persist_judge_json(ev, r, "")
return r
def _judge_cfg() -> dict:
"""读判定档配置genconfig judge.*worker 不可达时回落编译默认,保证无 agentscope 环境也能取值/单测)。"""
try:
from worker import genconfig # noqa: PLC0415
g = genconfig.get
except Exception: # noqa: BLE001 无 worker纯单测环境→ 用编译默认
def g(_area, _key, default):
return default
return {
"blocking": bool(g("judge", "blocking", True)),
"model": g("judge", "model", _JUDGE_DEFAULT_MODEL),
"max_tokens": int(g("judge", "max_tokens", 1500)),
"timeout_s": float(g("judge", "timeout_s", 120.0)),
"max_frames": int(g("judge", "max_frames", 6)),
"cost_target_rmb": float(g("judge", "cost_target_rmb", 0.3)),
}
def _writeback_verdict_json(evidence_dir, accepted: bool, judge_summary: dict) -> None:
"""把最终验收权威增量写回盘上 evidence/verdict.json(best-effort,绝不抛)。
字段只加不减:`pass` 物理保留(语义=预筛/机械九门),新增 `accepted`(=预筛∧玩法判定,最终权威)与
`judge` 摘要段。动机(W-AXIS 波2 验收发现):此前判定只进 run-summary→后端 trace,盘上 verdict.json
孤零零 pass=true——证据链与真实验收结论脱节(§6.13 亲眼验收看的就是盘上证据)。verdict.json 不存在则跳过。
"""
try:
p = (Path(evidence_dir) / "verdict.json") if evidence_dir else None
if p is None or not p.is_file():
return
v = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(v, dict):
return
v["accepted"] = bool(accepted)
v["judge"] = judge_summary
p.write_text(json.dumps(v, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception: # noqa: BLE001 写回失败绝不连累判定主链
pass
async def apply_gameplay_judge(summary: dict, *, game_id: str, brief: str = "",
model=None, blocking=None, evidence_dir=None,
prefilter=None) -> dict:
"""把玩法地板判定接到 run-summary落 ok 语义切换W-AXIS 波2 · SoT 裁定三代码兑现)。
ok 语义(裁定三):`ok = 预筛 ∧ 判定`。verdict 的 `pass` 字段物理保留、语义收窄为「预筛(机械九门)通过」,
新增 `accepted`= 预筛 ∧ 玩法地板判定)与 `judge` 段(裁决/三类理由/degraded/成本)——字段只加不减。
· 预筛 = 显式 `prefilter` 入参;缺省(None)回落 summary 现有的 ok。**预筛必须是机械九门口径**(裁定一:
driven 时九门全量 AND)——Service 路 summary.ok=九门 judge.passed 天然合规可走缺省;CLI(cheap_studio)路
summary.ok=finished(模型自称收敛),**不是**九门信号,必须显式传 prefilter=finished∧verdict.pass
(2026-07-09 W3 真跑暴露:三门挂的局按 finished 预筛被判定放行成 accepted=True,违裁定三,已修)。
· 判定只对【过筛者】跑(省 ¥:预筛没过的游戏已被机械门拒,不再花判定钱;对齐诊断档 §5「预筛先挡明显死」
· blocking=True默认→ ok=accepted判定阻断放行;blocking=False → ok=预筛(判定观测不阻断,整体回退位)。
· fail-closed:证据缺/调用失败/解析失败 → judge.degraded=True → floor 不成立 → (blocking) accepted=False。
绝不抛(顶层兜底):additive 写 summary['accepted'] 与 summary['judge'],并按上式更新 summary['ok']。
blocking/evidence_dir 缺省 Noneblocking→读 genconfigevidence_dir→按 game_id 推 _wg1-gen/<id>/evidence/。
"""
try:
cfg = _judge_cfg()
blk = cfg["blocking"] if blocking is None else bool(blocking)
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
prefilter = bool(summary.get("ok")) if prefilter is None else bool(prefilter)
if not prefilter:
# 预筛未过:机械门已拒,不花判定钱。accepted=预筛(False);ok 维持 False。
summary["judge"] = {"ran": False, "blocking": blk,
"reason": "预筛(机械九门)未过,不跑玩法判定"}
summary["accepted"] = False
summary["ok"] = False
_writeback_verdict_json(ev, False, summary["judge"])
return summary
floor = await judge_gameplay_floor(
game_id, brief=brief, model=model, evidence_dir=ev,
model_name=cfg["model"], max_tokens=cfg["max_tokens"],
timeout=cfg["timeout_s"], max_frames=cfg["max_frames"])
floor_ok = bool(floor.get("accepted")) and not bool(floor.get("degraded"))
accepted = prefilter and floor_ok
summary["judge"] = {
"ran": True, "blocking": blk,
"verdict": floor.get("verdict"),
"accepted": bool(floor.get("accepted")),
"rejectClasses": floor.get("rejectClasses"),
"checks": floor.get("checks"),
"degraded": bool(floor.get("degraded")),
"reason": floor.get("reason"),
"notes": floor.get("notes"),
"model": floor.get("model"),
"frames": floor.get("frames"),
"costRmb": floor.get("costRmb"),
"judgeTokens": floor.get("judgeTokens"),
}
summary["accepted"] = accepted
summary["ok"] = accepted if blk else prefilter
# 阻断路且未过:degraded 走可重试类失败因result_out 缺省 llm_error 即可重试;不覆盖已有 quota_exhausted
if blk and not accepted and floor.get("degraded"):
summary.setdefault("failureReason", "llm_error")
# 盘上证据链与最终验收结论对齐(judge.json 由 judge_gameplay_floor 已落;这里补 verdict.json 增量段)。
_writeback_verdict_json(ev, accepted, summary["judge"])
return summary
except Exception as e: # noqa: BLE001 判定接线级异常:fail-closedblocking→不放行,observe 模式保预筛。
blk = True if blocking is None else bool(blocking)
try:
blk = _judge_cfg()["blocking"] if blocking is None else bool(blocking)
except Exception: # noqa: BLE001
pass
prefilter = bool(summary.get("ok")) if prefilter is None else bool(prefilter)
summary["judge"] = {"ran": False, "blocking": blk, "degraded": True,
"reason": f"判定接线异常fail-closed{type(e).__name__}: {e}"}
summary["accepted"] = False if blk else prefilter
summary["ok"] = False if blk else prefilter
try: # 接线异常路也尽力对齐盘上证据(ev 推导自身可能失败,再兜一层)
_writeback_verdict_json(
evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id),
summary["accepted"], summary["judge"])
except Exception: # noqa: BLE001
pass
return summary
# ══════════════════════════════════════════════════════════════════════════════
# 验收 v2:四门投影 + 测试 agent 真玩 + 统一编排器(W-AXIS-V2 波1 · 「拆着杀」)
# ──────────────────────────────────────────────────────────────────────────────
# W-AXIS 三波把验收权威交给独立模型判定,但证据仍由「一份固定代码玩几十种游戏」的取证契约(play-spec /
# tap-targets 驱动器 / _forensicsView)生产——n=5 基线逐局破案坐实这条证据生产线本身就是最大失败源。
# 本段兑现创始人 2026-07-09「拆着杀」:契约无关的四门(A/B/C/D)降为地板保留,依赖契约的 E/G/H/I/F 与
# 驱动器退役,验收证据的生产与裁决交测试 agent(playtest.cdp.cjs:视觉引导真玩 + 玩法地板判定合一)。
# 三路(CLI/Service/modify)共用一个编排器 run_acceptance;acceptance.mode 三态 v2/shadow/v1 控阻断与灰度。
# 四门 = 契约无关:读装载标志 / 未捕获异常 / 引擎帧号 / canvas 像素,不需游戏配合任何自报接口,
# 零成本、确定性、抓真死(基线里 boot 空指针正是 A 门拦的)。F_wiring 的期望前缀集源自 play-spec、
# 随契约退役降观测,不入地板(裁定三 2026-07-10 修订)。
_FLOOR_GATES = ("A_boot", "B_uncaught", "C_frame", "D_render")
def project_floor(verdict) -> dict:
"""四门投影(floor 段的**唯一产地**):从 verdict.guards 显式抽取 A_boot/B_uncaught/C_frame/D_render 求 AND。
这是消费层函数,**绝不复用 harness 原生 verdict.pass**——那是九门(driven)/七门(undriven)AND、随驱动器语义,
拆契约后会静默坍缩;四门投影独立于它,是便宜档预筛的权威取值(裁定一 2026-07-10 修订)。
任一门缺失(guards 无该门 dict)或 pass 非 True → floor.pass=False(fail-closed:无四门证据不放行)。
返回 {pass: bool, gates: {A,B,C,D: bool}}(短键 A/B/C/D = plan §4 字段协议)。
"""
guards = (verdict or {}).get("guards") or {}
gates = {}
all_ok = True
for full in _FLOOR_GATES:
short = full.split("_")[0] # A_boot → A
node = guards.get(full)
ok = isinstance(node, dict) and node.get("pass") is True
gates[short] = ok
all_ok = all_ok and ok
return {"pass": all_ok, "gates": gates}
def apply_acceptance_failure_attribution(summary: dict) -> dict:
"""按最终 v2 验收权威重算失败归因,阻止旧 E/G/H 等降观测门污染批账。
v2 的成败权威只有 ``accepted = floor ∧ playtest``
· accepted=True → 无失败,归因固定为 none原因置空
· 四门地板未过 → mechanical只列 A/B/C/D 中未过或缺失的门;
· 测试员 degraded → tester_degraded保留测试员给出的故障原因
· 四门全过但测试员明确拒绝 → gameplay原因只取测试员裁决与客观现象。
shadow/v1 仍以旧验收器为权威,原有三层归因保持不变。函数原地更新 summary 并返回它,供
CLI、Service、modify 三路共用的 ``run_acceptance`` 在最终裁决后统一调用。
"""
if (summary or {}).get("acceptanceVersion") != "v2":
return summary
if summary.get("accepted") is True:
summary["failureLayer"] = {"layer": "none", "reason": None, "failedGates": []}
return summary
floor = summary.get("floor") if isinstance(summary.get("floor"), dict) else {}
playtest = summary.get("playtest") if isinstance(summary.get("playtest"), dict) else {}
judge = summary.get("judge") if isinstance(summary.get("judge"), dict) else {}
# 四门是测试员之前的机械地板;即使后续编排也异常,已知地板失败仍是更靠前、更确定的根因。
# floor/gates 缺字段按 fail-closed 视作对应门未通过,避免无证据时误落 gameplay。
if floor.get("pass") is not True:
gate_names = {"A": "A_boot", "B": "B_uncaught", "C": "C_frame", "D": "D_render"}
gates = floor.get("gates") if isinstance(floor.get("gates"), dict) else {}
failed = [full for short, full in gate_names.items() if gates.get(short) is not True]
summary["failureLayer"] = {
"layer": "mechanical",
"reason": f"四门地板失败({'/'.join(failed)}):装载/异常/掌帧/渲染证据未全部通过",
"failedGates": failed,
}
return summary
# 四门全绿后测试员自身无法完成裁决,失败属于验收仪器,不可误归为游戏玩法缺陷。
# 编排器/写回层异常会在保留已有 playtest 的同时把 judge 标成 degraded此时必须优先归因验收器
# 不能因为测试员先前已产出 accept/reject 就把基础设施故障误算成 gameplay。
if playtest.get("degraded") or judge.get("degraded"):
reason = playtest.get("reason") or judge.get("reason") or "测试员未能产出有效裁决fail-closed 待人工复核"
summary["failureLayer"] = {
"layer": "tester_degraded",
"reason": f"测试员降级:{reason}",
"failedGates": [],
}
return summary
# 测试员明确拒绝时,只引用真玩裁决与去掉“推测”分句后的客观现象,不再读取旧 E/G/H 门结果。
import re # noqa: PLC0415
observations = []
for problem in playtest.get("problems") or []:
phenomenon = "".join(
segment.strip() for segment in re.split(r"[;]", str(problem))
if segment.strip() and "推测" not in segment
)
if phenomenon:
observations.append(phenomenon)
# gameplay 归因只引用测试员记录的客观现象summary/reason 可能夹带推测,不作为有现象时的根因文案。
if observations:
suffix = "".join(observations[:3])
else:
suffix = str(playtest.get("summary") or playtest.get("reason") or "测试员真玩后明确判定未通过")
summary["failureLayer"] = {
"layer": "gameplay",
"reason": f"测试员真玩拒绝:{suffix}",
"failedGates": [],
}
return summary
def _acceptance_cfg() -> dict:
"""读验收 v2 配置(genconfig acceptance.* + playtest.* + judge.*;worker 不可达回落编译默认,保单测)。"""
try:
from worker import genconfig # noqa: PLC0415
g = genconfig.get
except Exception: # noqa: BLE001 无 worker(纯单测)→ 编译默认
def g(_area, _key, default):
return default
configured_mode = str(g("acceptance", "mode", "v3_shadow"))
return {
# v2 helper 保留旧三态返回值避免历史回放测试和显式调用漂移configured_mode 记录生产总开关。
"mode": configured_mode if configured_mode in ("v2", "shadow", "v1") else "v2",
"configured_mode": configured_mode,
"model": g("judge", "model", _JUDGE_DEFAULT_MODEL), # 测试员 = 判定档同一配置(§2;现 MiniMax-M3)
"steps_base": int(g("playtest", "steps_base", 14)), # 基础步数预算(§3 第6件)
"steps_max": int(g("playtest", "steps_max", 24)), # 有进展扩展上限
"second_roll": bool(g("playtest", "second_roll", True)),# fail 二掷确认(§3 第7件)
"timeout_s": float(g("playtest", "timeout_s", 600.0)), # 单掷子进程墙钟超时
"cost_cap_rmb": float(g("playtest", "cost_cap_rmb", 1.5)), # 单局验收成本上限(含二掷)
"blocking": bool(g("judge", "blocking", True)), # 测试员裁决是否阻断放行
}
def _playtest_script() -> Path:
"""serve-and-playtest.sh 路径(与 serve-and-play.sh 同套起服编排,末步换调 playtest.cdp.cjs)。"""
return cheap_run._WG1_DIR / "_shared" / "serve-and-playtest.sh"
def _playtest_env() -> dict:
"""subprocess env:复用 cheap_run 起服 env(CHROME_BIN + NO_PROXY)+ 注入 NEWAPI_KEY/BASE(测试员连 new-api)。
playtest.cdp.cjs 用 Node http 模块直连 new-api、天然绕系统代理;key/base 由本函数从凭据档解析后注入 env。
"""
env = dict(cheap_run._shell_env())
# ActorGuide renderer 依赖 cheap-worker 已安装的 Pillow强制复用当前解释器禁止漂到系统 python3。
env["PYTHON"] = sys.executable
try:
import _bootstrap # noqa: PLC0415 代理旁路时序与 key 注入
_bootstrap.ensure_api_key_env()
from worker import client # noqa: PLC0415
env["NEWAPI_KEY"] = client.get_api_key()
env["NEWAPI_BASE_URL"] = client.resolve_base_url()
except Exception: # noqa: BLE001 凭据解析失败 → 交 cjs 自身的 env 兜底(仍可能从进程环境拿到)
pass
return env
# Chrome unsafe ports(net::ERR_UNSAFE_PORT):派生范围 4998..5097 内被 Chrome 封锁的页面加载端口。
# 2026-07-10 考卷真跑实锤:hard-sim-business-r2 派生到 5060(SIP),curl 就绪检查 200 而 Chrome navigate
# 直接拒(NAT slipstreaming 防护),__genBooted 永不置位 → 假 boot-timeout。派生必须跳过。
_CHROME_UNSAFE_PORTS = {5060, 5061}
def _derive_playtest_ports(game_id) -> tuple:
"""按 game_id 确定性派生测试员起服端口(避并发多局撞:Service 异步队列 / modify / 批跑可能同时收口)。
基址 4998/9331(spike 同段,与九门 play 的 4320/9222 分离)+ game_id 稳定 hash % 100(md5,禁 Python
随机化 hash)。同一 game_id 恒得同端口(可复现);不同 game_id 大概率不撞(并发 ≤15 远低于 100 槽)。
静态服务端口撞 Chrome unsafe 清单(5060/5061=SIP,页面加载被 ERR_UNSAFE_PORT 拒)→ +2 避开
(CDP 端口无此限制——unsafe 清单只管页面加载,不管 Chrome 自身监听)。
"""
import hashlib # noqa: PLC0415
h = int(hashlib.md5(str(game_id).encode("utf-8")).hexdigest()[:4], 16) % 100
port = 4998 + h
if port in _CHROME_UNSAFE_PORTS:
port += 2 # 5060→5062 / 5061→5063(5062/5063 不在清单;仍确定性可复现)
return port, 9331 + h
_V3_PORT_SLOT_COUNT = 20_000
_V3_HTTP_PORT_BASE = 20_000
_V3_CDP_PORT_BASE = 40_000
def _derive_playtest_v3_ports(run_id, attempt: int = 0) -> tuple:
"""只给 v3 派生高端端口attempt 进入 hash基础设施重试不会原样复撞同一端口。
HTTP 使用 20000..39999,避开 Chrome 已知 restricted portsCDP 使用 40000..59999,与静态服务
完全分段。20,000 个稳定槽让生产并发 15 局的生日碰撞概率降到约 0.5%
"""
attempt = int(attempt)
if attempt < 0:
raise ValueError("v3 port attempt 不得为负数")
key = f"{run_id}:attempt:{attempt}".encode("utf-8")
slot = int.from_bytes(hashlib.sha256(key).digest()[:8], "big") % _V3_PORT_SLOT_COUNT
return _V3_HTTP_PORT_BASE + slot, _V3_CDP_PORT_BASE + slot
def _run_playtest_roll_sync(game_id, brief_file, *, model_name, roll, seed, steps_base, steps_max,
evidence_dir, port, cdp_port, timeout) -> dict:
"""同步跑一掷(subprocess serve-and-playtest.sh → playtest.cdp.cjs);解析 stdout 末行 JSON 裁决。
绝不抛——超时/异常/未产裁决一律归 degraded 裁决(fail-closed)。退出码语义(playtest/1 协议):
0=正常裁决、2=运行错误/装载失败(degraded)、3=图像通道故障(degraded,归因测试员图像通道非游戏缺陷)。
"""
argv = ["bash", str(_playtest_script()), str(game_id), str(port), str(cdp_port), "--",
f"--model={model_name}", f"--roll={roll}", f"--seed={seed}",
f"--steps-base={steps_base}", f"--steps-max={steps_max}",
f"--brief-file={brief_file}", f"--evidence-dir={evidence_dir}"]
try:
r = subprocess.run(argv, cwd=str(cheap_run._GAME_RUNTIME), capture_output=True, text=True,
timeout=timeout, env=_playtest_env(), check=False)
except subprocess.TimeoutExpired:
return {"degraded": True, "reason": f"测试员子进程超时(>{timeout}s),fail-closed", "exitCode": -1, "roll": roll}
except Exception as e: # noqa: BLE001 subprocess 层异常一律 fail-closed
return {"degraded": True, "reason": f"测试员子进程异常:{type(e).__name__}: {e}", "exitCode": -1, "roll": roll}
# 解析 stdout 末行单行 JSON(playtest.cdp.cjs 末行 = 裁决)。
out = None
for line in reversed((r.stdout or "").splitlines()):
line = line.strip()
if line.startswith("{") and line.endswith("}"):
try:
out = json.loads(line)
break
except json.JSONDecodeError:
continue
if out is None:
return {"degraded": True, "reason": "测试员未产出裁决 JSON(见 raw)", "exitCode": r.returncode,
"raw": (r.stdout + r.stderr)[-1000:], "roll": roll}
out["exitCode"] = r.returncode
# 退出码/自报标志 → degraded(fail-closed):运行错误、装载失败、图像通道故障都不放行。
if r.returncode == 2 or out.get("runnerError") or out.get("bootDead"):
out["degraded"] = True
out.setdefault("reason", "测试员运行错误/装载失败,fail-closed")
if r.returncode == 3 or out.get("imageBlind"):
out["degraded"] = True
out["imageBlind"] = True
out.setdefault("reason", "测试员图像通道故障(自报看不到截图),fail-closed 归因测试员非游戏")
return out
def _roll_brief(roll) -> dict:
"""把单掷裁决压成轻量摘要(落 playtest.json 的 rolls 列表 / 批次账,不塞完整转写)。"""
return {"roll": roll.get("roll"), "seed": roll.get("seed"), "pass": roll.get("pass"),
"degraded": bool(roll.get("degraded")), "canSee": roll.get("canSee"),
"steps": roll.get("steps"), "tapPoints": roll.get("tapPoints"),
"summary": roll.get("summary"), "reason": roll.get("reason"),
"tokens": roll.get("tokens")}
def _playtest_roll_cost(model_name, roll) -> float:
"""据 new-api 权威倍率把一掷 token 折成 ¥(与判定成本同口径);取价失败 → 0.0(不伪造成本、不误停)。"""
tok = roll.get("tokens") or {}
ti, to = int(tok.get("in") or 0), int(tok.get("out") or 0)
c = _estimate_judge_cost(model_name, ti, to, 0)
return float(c) if isinstance(c, (int, float)) else 0.0
def _persist_playtest_json(evidence_dir, result: dict) -> None:
"""测试员验收真相层落盘(best-effort,绝不抛):evidence/playtest/playtest.json = 汇总裁决 + 二掷账。
与 judge.json 同一动机(W-AXIS 波2):验收结论必须在盘上留痕,§6.13 亲眼验收看的就是它 + roll-N 截图序列。
"""
try:
ev = Path(evidence_dir) if evidence_dir else None
if ev is None:
return
pdir = ev / "playtest"
pdir.mkdir(parents=True, exist_ok=True)
payload = dict(result)
payload["ts"] = int(time.time() * 1000)
(pdir / "playtest.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception: # noqa: BLE001 落盘失败绝不连累验收主链
pass
async def run_playtest(game_id, *, brief="", model_name=None, evidence_dir=None,
port=None, cdp_port=None, steps_base=None, steps_max=None,
second_roll=None, timeout=None, cost_cap_rmb=None, seed=None) -> dict:
"""测试 agent 真玩验收(阻断权威;裁定三 v2)。**fail-closed**:超时/图像故障/未裁决 → degraded=拒绝。
二掷(§3 第7件):roll-1 判 fail 且非 degraded、second_roll 开、成本未撞上限 → roll-2(不同 seed、清
localStorage 独立);两掷同 fail 才落 fail,roll-2 pass 即翻案(治 puzzle 类坐标失手假阴)。pass 一掷即过。
Returns:
playtest 段 dict:{accepted, verdict, pass, degraded, reason, problems, feedback, summary,
firstPlay, rolls, rollCount, costRmb, model, imageBlind}。落 evidence/playtest/playtest.json。
"""
cfg = _acceptance_cfg()
mn = model_name or cfg["model"]
sb = steps_base if steps_base is not None else cfg["steps_base"]
smax = steps_max if steps_max is not None else cfg["steps_max"]
sr = cfg["second_roll"] if second_roll is None else bool(second_roll)
to = timeout if timeout is not None else cfg["timeout_s"]
cap = cost_cap_rmb if cost_cap_rmb is not None else cfg["cost_cap_rmb"]
ev = Path(evidence_dir) if evidence_dir is not None else _wg1_evidence_dir(game_id)
base_seed = seed if seed is not None else int(time.time() * 1000) % 1_000_000
if port is None or cdp_port is None: # 缺省 → 按 game_id 派生 per-game 端口(并发避撞)
dp, dc = _derive_playtest_ports(game_id)
port = dp if port is None else port
cdp_port = dc if cdp_port is None else cdp_port
_NULL_FIRSTPLAY = {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}
result = {"model": mn, "rolls": [], "degraded": False}
brief_file = None
try:
import tempfile # noqa: PLC0415 brief 写临时文件(诚实来源,避免命令行转义/长度)
with tempfile.NamedTemporaryFile("w", suffix=".brieftxt", delete=False, encoding="utf-8") as bf:
bf.write(brief or "")
brief_file = bf.name
async def _one(roll, s):
# subprocess 阻塞 → to_thread 让出事件循环(超时由 subprocess timeout 兜)。
return await asyncio.to_thread(
_run_playtest_roll_sync, game_id, brief_file,
model_name=mn, roll=roll, seed=s, steps_base=sb, steps_max=smax,
evidence_dir=str(ev), port=port, cdp_port=cdp_port, timeout=to)
# roll-1
r1 = await _one(1, base_seed)
result["rolls"].append(_roll_brief(r1))
cost = _playtest_roll_cost(mn, r1)
final = r1
# 二掷:仅当 roll-1 是【确定的 fail】(非 degraded)、开关开、成本未撞线时;degraded 不二掷(图像/运行故障重掷无益)。
if (not r1.get("degraded")) and (r1.get("pass") is False) and sr and cost < cap:
r2 = await _one(2, base_seed + 7919) # 素数偏移换 seed(禁隐式时钟种子)
result["rolls"].append(_roll_brief(r2))
cost += _playtest_roll_cost(mn, r2)
if r2.get("pass") is True:
final = r2 # roll-2 翻案 → pass
elif r2.get("degraded"):
final = r2 # roll-2 degraded → fail-closed
else:
final = r1 # 两掷同 fail → 维持 fail
except Exception as e: # noqa: BLE001 编排层任何异常 → fail-closed degraded
result.update({"accepted": False, "verdict": "reject", "pass": False, "degraded": True,
"reason": f"测试员编排异常:{type(e).__name__}: {e}", "problems": [], "feedback": "",
"summary": "orchestration-error", "firstPlay": dict(_NULL_FIRSTPLAY),
"rollCount": len(result["rolls"]), "costRmb": 0.0, "imageBlind": False})
_persist_playtest_json(ev, result)
return result
finally:
if brief_file:
try:
import os as _os # noqa: PLC0415
_os.unlink(brief_file)
except Exception: # noqa: BLE001
pass
final_pass = final.get("pass") is True
final_degraded = bool(final.get("degraded"))
result.update({
"accepted": final_pass and not final_degraded,
"verdict": "accept" if (final_pass and not final_degraded) else "reject",
"pass": final_pass,
"degraded": final_degraded,
"reason": final.get("reason"),
"problems": final.get("problems") or [], # 现象+推测(修复反馈拼装只引现象,见 _build_repair_feedback)
"feedback": final.get("feedback") or "",
"summary": final.get("summary") or "",
"firstPlay": final.get("firstPlay") or dict(_NULL_FIRSTPLAY),
"rollCount": len(result["rolls"]),
"costRmb": round(cost, 5),
"imageBlind": bool(final.get("imageBlind")),
})
_persist_playtest_json(ev, result)
return result
def _build_repair_feedback(floor: dict, playtest: dict) -> str:
"""修复反馈拼装(现象/推测强制分离,续修**只引现象段**)——给 writer resume 的续修来源。
治 spike 三次「测试员失手后错误归因」失效模式:测试员 problems 里凡含「推测」的分句剔除,只留带步号+落点的
客观现象进反馈;degraded 则不派续修(fail-closed 待人工)。附四门地板逐门结果作接线上下文。
"""
import re # noqa: PLC0415
if playtest.get("degraded"):
return f"测试员未能给出有效裁决(degraded:{playtest.get('reason')}),fail-closed 待人工复核,不派续修。"
g = (floor or {}).get("gates") or {}
gate_str = " ".join(f"{k}={'' if g.get(k) else ''}" for k in ("A", "B", "C", "D"))
lines = []
for p in (playtest.get("problems") or []):
# 现象/推测分离:按分句剔除含「推测」的段,只留客观现象。
phenom = "".join(seg.strip() for seg in re.split(r"[;]", str(p)) if seg.strip() and "推测" not in seg)
if phenom:
lines.append(phenom)
head = (f"[测试员真玩现象·续修依据] 四门地板 {gate_str};测试员判不通过。以下仅列客观现象(带步号与落点坐标,"
f"推测已剔除),据此定位修复:\n")
if not lines:
lines = [playtest.get("summary") or "测试员判不通过但未列具体现象"]
return head + "\n".join(f"- {ln}" for ln in lines)
async def run_acceptance(summary: dict, *, game_id: str, brief: str = "", verdict=None,
evidence_dir=None, mode=None, model=None,
port=None, cdp_port=None) -> dict:
"""统一验收编排器(CLI / Service / modify 三路共用)。accepted = floor ∧ 测试员(mode=v2)/旧口径(shadow/v1)。
· floor = project_floor(verdict):四门投影(预筛权威,唯一产地),写 summary['floor']。
· mode=v2(目标态):测试员真玩阻断,accepted = floor.pass ∧ playtest.accepted。
· mode=shadow(波1 灰度):测试员照跑照落证据(对照),accepted 仍取旧口径(判定器 apply_gameplay_judge);
并写 summary['shadowV2Accepted'] 供批次账新旧口径对照表。
· mode=v1(回退态):W-AXIS 旧口径(apply_gameplay_judge:driven 九门预筛→四门投影 + 判定器读驱动器证据)。
additive 写 summary:floor 段、acceptanceVersion、playtest 段、judge 段(§4 双写窗口)、ok/accepted。
绝不抛(顶层兜底 fail-closed:异常 → accepted=False)。verdict 缺 → 取 summary.verdict/verdictFull。
"""
try:
cfg = _acceptance_cfg()
# v2 只允许显式历史回放。生产总开关进入 v3_shadow/v3 后,旧入口不得静默把新模式当 v2 执行。
if mode is None and cfg.get("configured_mode") not in ("v2", "shadow", "v1"):
summary["acceptanceVersion"] = cfg.get("configured_mode")
summary["accepted"] = False
summary["ok"] = False
summary["judge"] = {
"ran": False, "degraded": True,
"reason": "v2 验收已退出生产自动路径;历史回放必须显式传 mode=v2/shadow/v1",
}
return summary
mode = mode or cfg["mode"]
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
v = verdict if verdict is not None else (summary.get("verdict") or summary.get("verdictFull") or {})
floor = project_floor(v)
summary["floor"] = floor
summary["acceptanceVersion"] = mode
if mode == "v1":
# 回退态:预筛权威统一为四门投影(floor.pass),判定仍走驱动器证据(apply_gameplay_judge→judge_gameplay_floor)。
# 时序约束:v1 仅在 play-spec/驱动器仍挂载的波1~波2 前有效(波2 拆除后不可达)。
return await apply_gameplay_judge(summary, game_id=game_id, brief=brief,
evidence_dir=ev, prefilter=floor["pass"])
# v2 / shadow:跑测试员——仅预筛(四门)过时跑,省 ¥(预筛没过已被机械门拒,不花测试员钱)。
if floor["pass"]:
playtest = await run_playtest(game_id, brief=brief, evidence_dir=ev,
model_name=model or cfg["model"], port=port, cdp_port=cdp_port)
else:
playtest = {"accepted": False, "verdict": "reject", "pass": False, "degraded": False,
"reason": "四门地板未过,不跑测试员(省 ¥)", "problems": [], "feedback": "",
"summary": "floor-fail", "rolls": [], "rollCount": 0, "costRmb": 0.0, "model": cfg["model"],
"firstPlay": {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}}
summary["playtest"] = playtest
v2_accepted = bool(floor["pass"]) and bool(playtest.get("accepted")) and not bool(playtest.get("degraded"))
if mode == "shadow":
# 灰度:accepted 取旧口径(判定器 apply_gameplay_judge 写 judge 段);测试员只观测、并列落对照。
summary = await apply_gameplay_judge(summary, game_id=game_id, brief=brief,
evidence_dir=ev, prefilter=floor["pass"])
summary["shadowV2Accepted"] = v2_accepted # 对照:v2 生效时会是什么(批次账新旧口径对照表)
return summary
# mode == v2:测试员阻断放行。
blk = cfg["blocking"]
# rejectClasses:v2 测试员不产 broken/hollow/off_brief 细类,归一为 playtest_reject / tester_degraded(供 gate 桶归因)。
if playtest.get("degraded"):
reject_classes = ["tester_degraded"]
elif not v2_accepted:
reject_classes = ["playtest_reject"]
else:
reject_classes = []
summary["judge"] = { # §4 双写窗口:v2 下 judge 段镜像 playtest 裁决,喂 trace.gameplayJudge 与旧消费面
"ran": True, "blocking": blk, "verdict": playtest.get("verdict"),
"accepted": bool(playtest.get("accepted")), "rejectClasses": reject_classes,
"degraded": bool(playtest.get("degraded")), "reason": playtest.get("reason"),
"notes": playtest.get("summary"), "model": playtest.get("model"),
"costRmb": playtest.get("costRmb"), "acceptanceVersion": "v2",
}
summary["accepted"] = v2_accepted
summary["ok"] = v2_accepted if blk else floor["pass"]
if not v2_accepted: # 续修回喂来源(只引现象段);accepted 时无失败现象、不拼装(E2E 实测曾误拼自相矛盾文案)
summary["repairFeedback"] = _build_repair_feedback(floor, playtest)
if blk and not v2_accepted and playtest.get("degraded"):
summary.setdefault("failureReason", "tester_degraded") # §4 新增归因层
_writeback_verdict_json(ev, v2_accepted, summary["judge"])
# 最终验收后统一覆盖预先按旧九门生成的 failureLayerCLI/Service/modify 三路由此共享同一权威归因。
return apply_acceptance_failure_attribution(summary)
except Exception as e: # noqa: BLE001 编排接线级异常:fail-closed(v2/shadow 不放行)。
summary["acceptanceVersion"] = summary.get("acceptanceVersion") or (mode or "v2")
summary["judge"] = {"ran": False, "degraded": True,
"reason": f"验收编排异常(fail-closed):{type(e).__name__}: {e}"}
summary["accepted"] = False
summary["ok"] = False
# v2 编排异常属于测试员/验收器降级,不得回落到旧 E/G/H 归因shadow/v1 仍由 helper 保持兼容。
return apply_acceptance_failure_attribution(summary)
# ══════════════════════════════════════════════════════════════════════════════
# 验收 v3 Python 编排核心(playtest/3 · 结构化目标 + 双 Judge 可信证据闭环)
# ──────────────────────────────────────────────────────────────────────────────
_PLAYTEST_V3_SCHEMA = "playtest/3"
_V3_OUTCOMES = {"accept", "reject", "inconclusive", "tester_error"}
_V3_INFRA_SUBTYPES = {
"environment_error", "runner_error", "runner_timeout", "image_error", "judge_error",
"browser_error", "port_error", "schema_error", "seed_mismatch", "unknown_schema",
"profile_contract_error", "provenance_error", "event_contract_error",
}
_V3_CONTRACT_DIR = Path(__file__).resolve().parents[1] / "contracts" / "play-loop"
# W-GOLD-LIVE 检查点 2Python v3 编排产出 acceptance-request/3 —— 即 v2 字段集之上开口三个参照资产
# 可选字段designRef/referenceAssetRecordIds/consumerRef全 optional旧调用方不传即旧行为。canonical
# 校验validate.py 语义层)钉死 /3 与 /2 同一条 proof registry 版本线2026-07-15.v3 → proof-obligations.v2.json
# 注册表版本与 schemaVersion 错配时 Writer 前身份必然校验失败,二者必须成对切换。普通路径的
# interactionBinding 保持 null只有可信调用方显式选择 v3_shadow 校准 profile 时,才生成并贯通
# InteractionBinding/1。canonical hash 口径不变:仍是 canonical 字段集的稳定 JSON SHA-256。
# Node runnerplaytest-v3.cdp.cjs validateProfileProvenance已接受 /1、/2、/3 请求;非空 binding
# 通过独立文件与五项显式参数进入真浏览器路径,普通路径仍以 null 维持原行为。
_V3_OBLIGATIONS_FILE = _V3_CONTRACT_DIR / "proof-obligations.v2.json"
_V3_OBLIGATIONS_SCHEMA = _V3_CONTRACT_DIR / "proof-obligation-registry.schema.json"
_V3_SINGLE_ROLL_SCHEMA = _V3_CONTRACT_DIR / "single-roll-fact-v3.schema.json"
_V3_EVIDENCE_SCHEMA = _V3_CONTRACT_DIR / "playtest-evidence-v3.schema.json"
_V3_ACCEPTANCE_REQUEST_SCHEMA = _V3_CONTRACT_DIR / "acceptance-request-v3.schema.json"
_V3_ACCEPTANCE_PROVENANCE_SCHEMA = _V3_CONTRACT_DIR / "acceptance-provenance-v3.schema.json"
_V4_ACCEPTANCE_PROVENANCE_SCHEMA = _V3_CONTRACT_DIR / "acceptance-provenance-v4.schema.json"
_REFERENCE_ASSET_RECEIPT_SCHEMA = _V3_CONTRACT_DIR / "reference-asset-verification-receipt.schema.json"
_INTERACTION_PROFILE_REGISTRY_FILE = _V3_CONTRACT_DIR / "interaction-profiles.v2.json"
_INTERACTION_PROFILE_REGISTRY_SCHEMA = _V3_CONTRACT_DIR / "interaction-profile-registry-v2.schema.json"
_INTERACTION_BINDING_SCHEMA = _V3_CONTRACT_DIR / "interaction-binding.schema.json"
# Registry/1 仅供旧 acceptance 身份与 provenance 对账兼容;新生成入口的冻结消费统一走
# ReferenceAssetRegistry/2 + release 锚定预检,不得把本兼容器当作生产资产读取入口。
_V3_REFERENCE_ASSET_REGISTRY_FILE = _V3_CONTRACT_DIR / "reference-asset-registry.initial.json"
# 参照资产 v2 生产消费锚点。调用方只可选择 policyId仓根、release 路径、release SHA 与冻结模式
# 全由本模块固定,避免 job/CLI 自报路径或 hash 绕过可信边界。
_REFERENCE_ASSET_POLICY_ID = "survivor-gold-v1"
_REFERENCE_ASSET_POLICY_MODE = "frozen_preflight"
_REFERENCE_ASSET_RELEASE_REF = "contracts/play-loop/reference-asset-release.initial.json"
_REFERENCE_ASSET_RELEASE_SHA256 = "506e181f688f082ea34bd7515e4b1c9952b30a1caad1a5799972ba2d7a3d27a5"
_REFERENCE_ASSET_TRUSTED_ROOT = Path(__file__).resolve().parents[1]
_V3_RESULTS_DIR = Path(__file__).resolve().parent / "results" / "acceptance-v3"
_V3_GENRES = {"narrative", "trpg", "heritage", "puzzle", "sim-business"}
_V3_GUARD_CHECKS = (
"floorPass", "requiredObligationsSatisfied", "referencesValid", "sameActionEvidence",
"noContradictions", "noBlockingProblems", "actorHealthy", "runnerHealthy", "judgeHealthy",
"judgeAccept",
)
_V3_RMB_QUANTUM = Decimal("0.00001")
def _finite_nonnegative_rmb_v3(value, field: str) -> float:
"""把成本入口收敛为有限非负浮点布尔、NaN、Infinity 和负数一律拒绝。"""
if (not isinstance(value, (int, float)) or isinstance(value, bool)
or not math.isfinite(value) or value < 0):
raise ValueError(f"{field} 必须是有限非负金额")
return float(value)
def _finite_positive_rmb_v3(value, field: str) -> float:
"""成本硬帽必须为正有限数,零值不能把所有调用伪装成正常配置。"""
numeric = _finite_nonnegative_rmb_v3(value, field)
if numeric <= 0:
raise ValueError(f"{field} 必须是正有限金额")
return numeric
def _quantize_rmb_v3(value, field: str = "costRmb") -> float:
"""按十进制 ROUND_HALF_UP 统一保留五位,和 Node/contract 使用同一金额口径。"""
numeric = _finite_nonnegative_rmb_v3(value, field)
try:
return float(Decimal(str(numeric)).quantize(_V3_RMB_QUANTUM, rounding=ROUND_HALF_UP))
except (InvalidOperation, ValueError) as exc:
raise ValueError(f"{field} 无法按五位金额量化") from exc
def _acceptance_v3_cfg() -> dict:
"""读取 v3 编排旋钮YAML 未落或纯单测环境时使用批准设计中的保守默认值。"""
try:
from worker import genconfig # noqa: PLC0415
g = genconfig.get
except Exception: # noqa: BLE001
def g(_area, _key, default):
return default
return {
"mode": str(g("acceptance", "mode", "v3_shadow")),
"model": str(g("acceptance_v3", "model", g("judge", "model", _JUDGE_DEFAULT_MODEL))),
"timeout_s": float(g("acceptance_v3", "timeout_s", 600.0)),
"cost_cap_rmb": float(g("acceptance_v3", "cost_cap_rmb", 1.5)),
"parent_chain_cost_cap_rmb": float(g("acceptance_v3", "parent_chain_cost_cap_rmb", 15.0)),
"second_roll": bool(g("acceptance_v3", "second_roll", True)),
"infra_retry": int(g("acceptance_v3", "infra_retry", 1)),
"run_lock_timeout_s": float(g("acceptance_v3", "run_lock_timeout_s", 30.0)),
"action_quantum_ms": int(g("acceptance_v3", "action_quantum_ms", 600)),
"wait_min_ms": int(g("acceptance_v3", "wait_min_ms", 100)),
"wait_max_ms": int(g("acceptance_v3", "wait_max_ms", 600)),
}
def _sha256_text(value: str) -> str:
"""生成协议指纹;统一使用十六进制 SHA-256便于跨 Python/Node/Java 对账。"""
return hashlib.sha256((value or "").encode("utf-8")).hexdigest()
def _is_sha256_v3(value) -> bool:
"""机械校验十六进制 SHA-256证据引用不能只凭长度过关。"""
text = str(value or "")
return len(text) == 64 and all(ch in "0123456789abcdef" for ch in text)
def task_binding_hash_v3(trace_id) -> str:
"""把受信任务 traceId 冻结为验收身份;空白或非字符串一律拒绝。"""
if not isinstance(trace_id, str) or not trace_id.strip():
raise ValueError("v3 task traceId 必须是非空字符串")
return _sha256_text(trace_id)
def _is_safe_game_id_v3(value) -> bool:
"""限制证据目录分段与 runner 一致,阻止 gameId 路径穿越外部结果根。"""
text = str(value or "")
ascii_alnum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return bool(text and text[0] in ascii_alnum
and all(ch in ascii_alnum or ch in "._-" for ch in text))
# recordId 字符集(与 ReferenceAssetRecord/1.recordId 及 acceptance-request/3.$defs.recordId 同形);
# 首字符额外允许下划线以兼容 _fewshot-*/_template-* 既有资产名。
_V3_RECORD_ID_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
def _is_v3_record_id(value) -> bool:
"""机械校验参照资产 recordId 形状;非字符串、空串或含非法字符一律拒绝。"""
return isinstance(value, str) and bool(value) and all(ch in _V3_RECORD_ID_CHARS for ch in value)
_V3_LOCAL_RUN_LOCKS: dict[str, threading.Lock] = {}
_V3_LOCAL_RUN_LOCKS_GUARD = threading.Lock()
def _assert_no_symlink_components(path: Path, *, require_exists: bool) -> Path:
"""逐段拒绝用户 symlink仅兼容 macOS 等系统的根目录直属受管别名。"""
lexical = Path(os.path.abspath(os.fspath(path)))
parts = lexical.parts[1:]
current = Path(lexical.anchor)
for index, part in enumerate(parts):
candidate = current / part
try:
info = candidate.lstat()
except FileNotFoundError:
if require_exists:
raise ValueError(f"证据路径不存在:{candidate}")
# 首个缺失分量之后不可能已有真实子项,保留剩余词法路径交 mkdir 创建。
for rest in parts[index:]:
current /= rest
return current
if stat.S_ISLNK(info.st_mode):
is_root_managed_alias = (current == Path(lexical.anchor)
and index < len(parts) - 1 and info.st_uid == 0)
if not is_root_managed_alias:
raise ValueError(f"证据路径禁止 symlink:{candidate}")
current = candidate.resolve(strict=True)
else:
current = candidate
return current
def _local_v3_run_lock(key: str) -> threading.Lock:
"""同进程协程/线程先经本地锁排队;跨进程再由 flock 仲裁。"""
with _V3_LOCAL_RUN_LOCKS_GUARD:
return _V3_LOCAL_RUN_LOCKS.setdefault(key, threading.Lock())
def _acquire_v3_run_lock(evidence_root: Path, run_dir: Path, timeout_s: float):
"""创建 run 目录并获取本地锁 + 跨进程 flock超时返回空 claim 和可审计原因。"""
timeout_s = max(0.0, float(timeout_s or 0.0))
evidence_root = _assert_no_symlink_components(evidence_root, require_exists=False)
evidence_root.mkdir(parents=True, exist_ok=True)
evidence_root = _assert_no_symlink_components(evidence_root, require_exists=True)
run_dir = _assert_no_symlink_components(run_dir, require_exists=False)
if run_dir.parent != evidence_root:
raise ValueError("v3 runDir 必须是 evidenceRoot 的直接子目录")
run_dir.mkdir(parents=True, exist_ok=True)
run_dir = _assert_no_symlink_components(run_dir, require_exists=True)
local_lock = _local_v3_run_lock(str(run_dir))
started = time.monotonic()
if not local_lock.acquire(timeout=timeout_s):
return None, f"v3 run 本地锁等待超过 {timeout_s:.3f}s"
lock_fd = None
try:
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
lock_fd = os.open(run_dir / ".run.lock", flags, 0o600)
if not stat.S_ISREG(os.fstat(lock_fd).st_mode):
raise ValueError("v3 run 锁文件不是普通文件")
deadline = started + timeout_s
while True:
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return (lock_fd, local_lock), None
except BlockingIOError:
if time.monotonic() >= deadline:
os.close(lock_fd)
lock_fd = None
local_lock.release()
return None, f"v3 run 跨进程锁等待超过 {timeout_s:.3f}s"
time.sleep(0.05)
except Exception:
if lock_fd is not None:
os.close(lock_fd)
local_lock.release()
raise
def _release_v3_run_lock(claim) -> None:
"""释放跨进程与同进程两层锁;锁文件保留作稳定 claim 点。"""
if not claim:
return
lock_fd, local_lock = claim
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
finally:
os.close(lock_fd)
local_lock.release()
def _stable_json_hash_v3(value) -> str:
"""仅用于不含浮点的 Python 侧身份对象game-event 必须使用 payloadCanonical。"""
encoded = json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _stable_json_bytes_v3(value) -> bytes:
"""生成 request/manifest 的稳定 JSON这些身份对象协议禁止浮点。"""
return json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False,
).encode("utf-8")
def _load_v3_validator():
"""加载仓内 canonical 校验器;不可加载属于 tester_error调用方不得降级自判。"""
import importlib.util # noqa: PLC0415
validator_path = _V3_CONTRACT_DIR / "validate.py"
spec = importlib.util.spec_from_file_location("play_loop_validate_runtime", validator_path)
if spec is None or spec.loader is None:
raise RuntimeError("无法加载 contracts/play-loop/validate.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _validate_v3_instance(schema_file: Path, instance: dict) -> list:
"""复用 canonical schema + semantic validator返回空数组表示结构与跨引用均成立。"""
try:
module = _load_v3_validator()
schema = json.loads(Path(schema_file).read_text(encoding="utf-8"))
errors = module.validate(schema, instance, schema)
errors += module._semantic_validate(schema, instance)
return errors
except Exception as exc: # noqa: BLE001
return [f"canonical validator 不可用:{type(exc).__name__}: {exc}"]
def _validate_v3_schema_only(schema_file: Path, instance: dict) -> list:
"""只执行 canonical 结构校验;运行目录内 ref 的语义闭包由可信边界自行复算。"""
try:
module = _load_v3_validator()
schema = json.loads(Path(schema_file).read_text(encoding="utf-8"))
return module.validate(schema, instance, schema)
except Exception as exc: # noqa: BLE001
return [f"canonical validator 不可用:{type(exc).__name__}: {exc}"]
def _validate_v3_instance_parts(schema_file: Path, instance: dict) -> tuple[list, list]:
"""分别返回 schema 与 semantic 错误guard 需据语义错误类型区分 reject/inconclusive/tester_error。"""
try:
module = _load_v3_validator()
schema = json.loads(Path(schema_file).read_text(encoding="utf-8"))
return module.validate(schema, instance, schema), module._semantic_validate(schema, instance)
except Exception as exc: # noqa: BLE001
return [f"canonical validator 不可用:{type(exc).__name__}: {exc}"], []
def _canonical_acceptance_request(identity: dict) -> dict:
"""返回写盘 request 的固定字段acceptanceRequestHash 本身不入包,避免自引用 hash。
v3 字段集 = v2 十四字段(含 interactionBinding普通路径为 null、显式 shadow 校准可为对象)+
三个参照资产可选字段
designRef/referenceAssetRecordIds/consumerRef未声明时分别为 null/[]/null。字段集只增不改
canonical hash 口径不变(稳定 JSON SHA-256
"""
fields = (
"schemaVersion", "gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "interactionBinding",
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
"designRef", "referenceAssetRecordIds", "consumerRef",
)
return {field: identity.get(field) for field in fields}
def _resolve_interaction_profile_v3(interaction_profile_id, resolved_proof: dict) -> dict:
"""读取 canonical 交互注册表,并把显式 profile 与当前 proof 路由逐项交叉核对。"""
if not isinstance(interaction_profile_id, str) or not interaction_profile_id:
raise ValueError("interactionProfileId 必须是非空字符串")
try:
registry = json.loads(_INTERACTION_PROFILE_REGISTRY_FILE.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 注册表不可读时不得降级成普通 puzzle
raise ValueError(f"interaction profile 注册表不可读:{type(exc).__name__}: {exc}") from exc
registry_errors = _validate_v3_instance(_INTERACTION_PROFILE_REGISTRY_SCHEMA, registry)
if registry_errors:
raise ValueError("interaction profile 注册表非法:" + ";".join(registry_errors[:4]))
profiles = registry.get("profiles") if isinstance(registry.get("profiles"), dict) else {}
profile = profiles.get(interaction_profile_id)
if not isinstance(profile, dict) or profile.get("id") != interaction_profile_id:
raise ValueError(f"interactionProfileId 未在可信注册表中唯一登记:{interaction_profile_id}")
cross_checks = {
"genre": resolved_proof.get("genre"),
"templateRoute": resolved_proof.get("templateRoute"),
"proofProfileId": resolved_proof.get("proofProfileId"),
}
for field, expected in cross_checks.items():
if profile.get(field) != expected:
raise ValueError(
f"interactionProfileId 与当前 {field} 不一致:{profile.get(field)} != {expected}")
prompt = ((profile.get("actorProtocol") or {}).get("prompt")
if isinstance(profile.get("actorProtocol"), dict) else None)
if not isinstance(prompt, dict) or prompt.get("runtimeEligible") is not False:
raise ValueError("Match-3 shadow profile 必须保持 runtimeEligible=false")
return {"registry": registry, "profile": profile}
def _build_interaction_binding_v3(interaction_profile_id, task_binding_hash: str,
resolved_proof: dict) -> dict:
"""按 InteractionBinding/1 canonical 前缀与稳定 JSON 生成任务绑定。"""
resolved_interaction = _resolve_interaction_profile_v3(interaction_profile_id, resolved_proof)
content = {
"interactionProfileId": interaction_profile_id,
"interactionRegistryVersion": resolved_interaction["registry"]["registryVersion"],
"taskBindingHash": task_binding_hash,
}
binding = {
"schemaVersion": "InteractionBinding/1",
**content,
"interactionBindingHash": hashlib.sha256(
b"interaction-binding/1\n" + _stable_json_bytes_v3(content)
).hexdigest(),
}
binding_errors = _validate_v3_schema_only(_INTERACTION_BINDING_SCHEMA, binding)
if binding_errors:
raise ValueError("interactionBinding 非法:" + ";".join(binding_errors[:4]))
return binding
def preflight_interaction_profile_v3(interaction_profile_id, acceptance_mode, *, genre: str,
template_route: str, proof_profile_id=None) -> dict | None:
"""在 scaffold/Writer 前验证显式 profile仅 v3_shadow 校准路径允许启用。"""
if interaction_profile_id is None:
return None
if acceptance_mode != "v3_shadow":
raise ValueError("显式 interactionProfileId 仅允许 v3_shadow 校准路径")
resolved_proof = _v3_resolve_registry(
genre, "", template_route=template_route, proof_profile_id=proof_profile_id)
resolved_interaction = _resolve_interaction_profile_v3(interaction_profile_id, resolved_proof)
return {
"interactionProfileId": interaction_profile_id,
"interactionRegistryVersion": resolved_interaction["registry"]["registryVersion"],
"proofProfileId": resolved_proof["proofProfileId"],
"genre": resolved_proof["genre"],
"templateRoute": resolved_proof["templateRoute"],
}
def build_acceptance_v3_identity(game_id: str, brief: str, *, genre: str, template_route: str,
source_artifact_hash=None, parent_acceptance_request_hash=None,
repair_ordinal: int = 0, proof_profile_id=None,
proof_registry_version=None, task_binding_hash=None,
interaction_profile_id=None, interaction_binding=None,
design_ref=None, reference_asset_record_ids=None,
consumer_ref=None) -> dict:
"""Writer 前冻结验收身份profile 只能由可信 templateRoute 选中,修复链不得重选。
W-GOLD-LIVE 检查点 2 起产出 acceptance-request/3v2 字段集之上新增三个可选参照资产字段——
design_ref已批准 designIntent 引用、reference_asset_record_ids声明消费的参照资产 recordId
列表、consumer_ref消费者身份。三者全部可选旧调用方不传即 null/[]/null与 v2 行为一致;
是否真能消费由消费对账reconcile_v3_reference_asset_consumption在编排接线处强制。
"""
repair_ordinal = int(repair_ordinal)
if repair_ordinal not in (0, 1):
raise ValueError("repairOrdinal 只能是 0 或 1")
if source_artifact_hash is not None and not _is_sha256_v3(source_artifact_hash):
raise ValueError("sourceArtifactHash 必须是 SHA-256 或 null")
if not _is_sha256_v3(task_binding_hash):
raise ValueError("taskBindingHash 必须是当前任务 traceId 的 SHA-256")
if repair_ordinal == 0:
if source_artifact_hash is not None or parent_acceptance_request_hash is not None:
raise ValueError("首轮 acceptance identity 的 sourceArtifactHash/parentAcceptanceRequestHash 必须为 null")
elif not _is_sha256_v3(parent_acceptance_request_hash) or not _is_sha256_v3(source_artifact_hash):
raise ValueError("修复 identity 必须固定 parentAcceptanceRequestHash 与上一产物 sourceArtifactHash")
# 三个可选参照资产字段归一化:缺省 ≡ 未声明(向后兼容旧路径);声明则必须形状合法。
if design_ref is not None and (not isinstance(design_ref, str) or not design_ref):
raise ValueError("designRef 必须是非空字符串或 null")
if consumer_ref is not None and (not isinstance(consumer_ref, str) or not consumer_ref):
raise ValueError("consumerRef 必须是非空字符串或 null")
if reference_asset_record_ids is None:
reference_asset_record_ids = []
if not isinstance(reference_asset_record_ids, list):
raise ValueError("referenceAssetRecordIds 必须是 recordId 列表或 null")
for record_id in reference_asset_record_ids:
if not _is_v3_record_id(record_id):
raise ValueError(f"referenceAssetRecordIds 含非法 recordId:{record_id!r}")
resolved = _v3_resolve_registry(
genre, brief, template_route=template_route, proof_profile_id=proof_profile_id,
proof_registry_version=proof_registry_version,
)
if interaction_profile_id is not None and interaction_binding is not None:
raise ValueError("interaction_profile_id 与 interaction_binding 不能同时提供")
if interaction_profile_id is not None:
canonical_interaction_binding = _build_interaction_binding_v3(
interaction_profile_id, task_binding_hash, resolved)
elif interaction_binding is not None:
if not isinstance(interaction_binding, dict):
raise ValueError("interactionBinding 必须是对象或 null")
canonical_interaction_binding = _build_interaction_binding_v3(
interaction_binding.get("interactionProfileId"), task_binding_hash, resolved)
if interaction_binding != canonical_interaction_binding:
raise ValueError("interactionBinding 与当前 task/profile canonical 内容不一致")
else:
canonical_interaction_binding = None
core = {
"schemaVersion": "acceptance-request/3",
"gameId": str(game_id),
"briefHash": _sha256_text(brief or ""),
"genre": resolved["genre"],
"templateRoute": resolved["templateRoute"],
"proofProfileId": resolved["proofProfileId"],
"proofRegistryVersion": resolved["version"],
"taskBindingHash": task_binding_hash,
# 普通 puzzle 保持显式 null仅可信调用方显式选择 shadow profile 时写入 canonical binding。
"interactionBinding": canonical_interaction_binding,
"sourceArtifactHash": source_artifact_hash,
"parentAcceptanceRequestHash": parent_acceptance_request_hash,
"repairOrdinal": repair_ordinal,
"designRef": design_ref,
"referenceAssetRecordIds": list(reference_asset_record_ids),
"consumerRef": consumer_ref,
}
request_errors = _validate_v3_instance(_V3_ACCEPTANCE_REQUEST_SCHEMA, core)
if request_errors:
raise ValueError("acceptance request 非法:" + ";".join(request_errors[:4]))
return {**core, "acceptanceRequestHash": hashlib.sha256(_stable_json_bytes_v3(core)).hexdigest()}
def _write_json_bytes_atomic(path: Path, payload: dict) -> str:
"""原子写 canonical JSON 并返回字节 hash用于可信 request 与 provenance manifest。"""
path.parent.mkdir(parents=True, exist_ok=True)
data = _stable_json_bytes_v3(payload)
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(data)
tmp.replace(path)
return hashlib.sha256(data).hexdigest()
def _write_receipt_json_atomic(path: Path, data: bytes) -> None:
"""用不可预测的 O_EXCL/no-follow 临时普通文件原子发布验收回执。"""
path.parent.mkdir(parents=True, exist_ok=True)
no_follow = getattr(os, "O_NOFOLLOW", None)
if no_follow is None:
raise ValueError("当前平台不支持 no-follow 回执临时文件")
temp_path = None
temp_fd = None
try:
# 临时名不复用目标名,避免攻击者预置固定 .tmp symlink 诱导写到 run 外部。
for _ in range(8):
candidate = path.parent / f".{path.name}.{secrets.token_hex(16)}.tmp"
try:
temp_fd = os.open(
candidate,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | no_follow,
0o600,
)
except FileExistsError:
continue
temp_path = candidate
break
if temp_fd is None or temp_path is None:
raise ValueError("无法创建不可预测的回执临时文件")
if not stat.S_ISREG(os.fstat(temp_fd).st_mode):
raise ValueError("回执临时文件不是普通文件")
with os.fdopen(temp_fd, "wb") as handle:
temp_fd = None
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
# 同目录 rename 保证回执对 runner 只呈现完整 canonical 字节。
os.replace(temp_path, path)
temp_path = None
finally:
if temp_fd is not None:
os.close(temp_fd)
if temp_path is not None:
try:
temp_path.unlink(missing_ok=True)
except OSError:
pass
def write_acceptance_v3_provenance(game_id: str, identity: dict, *, artifact_hash: str,
evidence_root, artifact_path=None,
consumed_reference_assets=None,
reference_asset_verification_receipts=None) -> dict:
"""stage 后在 gameDir 外写 request/manifest证据不得成为浏览器可加载却不入 artifactHash 的旁路。
W-GOLD-LIVE 检查点 2 起产出 acceptance-provenance/3逐字段镜像 v3 request含 interactionBinding
与三个参照资产可选字段),并落 consumed_reference_assets——本次实际消费的参照资产对账快照
[{recordId,role,artifactHash}],冻结消费时刻的 role/hash注册表后续改动不覆盖历史 provenance
快照只许是请求侧 referenceAssetRecordIds 的子集:声明外消费即拒绝,保证请求/来源对账对应。
"""
if not _is_sha256_v3(artifact_hash):
raise ValueError("artifactHash 必须是 SHA-256")
artifact_root = (Path(artifact_path) if artifact_path else cheap_run.wg1_game_dir(game_id)).resolve()
if not artifact_root.is_dir():
raise ValueError(f"staged 产物不存在:{artifact_root}")
evidence_dir = Path(evidence_root).resolve()
if evidence_dir == artifact_root or artifact_root in evidence_dir.parents:
raise ValueError("v3 evidence/provenance 必须位于 staged gameDir 外")
request_payload = _canonical_acceptance_request(identity)
expected_request_hash = str(identity.get("acceptanceRequestHash") or "")
actual_request_hash = hashlib.sha256(_stable_json_bytes_v3(request_payload)).hexdigest()
if actual_request_hash != expected_request_hash:
raise ValueError("acceptance identity 在 Writer 后发生漂移")
request_file = evidence_dir / f"acceptance-request-{expected_request_hash}.json"
written_request_hash = _write_json_bytes_atomic(request_file, request_payload)
if written_request_hash != expected_request_hash:
raise ValueError("acceptance request 写盘 hash 不一致")
interaction_binding = request_payload.get("interactionBinding")
interaction_binding_file = None
interaction_binding_file_hash = None
if interaction_binding is not None:
interaction_binding_file = evidence_dir / "interaction-binding.json"
interaction_binding_file_hash = _write_json_bytes_atomic(
interaction_binding_file, interaction_binding)
# 消费对账快照形状先做声明子集闸role/artifactHash 的机械形状由下方 canonical provenance schema 兜底。
consumed = list(consumed_reference_assets or [])
declared_ids = set(request_payload.get("referenceAssetRecordIds") or [])
for entry in consumed:
if (not isinstance(entry, dict) or not _is_v3_record_id(entry.get("recordId"))
or entry["recordId"] not in declared_ids):
raise ValueError(f"consumedReferenceAssets 含声明外消费:{entry!r}")
receipt_refs = None
if reference_asset_verification_receipts is not None:
receipts = list(reference_asset_verification_receipts)
if not receipts:
raise ValueError("acceptance /4 必须带至少一份验收自产回执")
receipt_ids = []
for index, receipt in enumerate(receipts):
shape_errors = _validate_v3_schema_only(_REFERENCE_ASSET_RECEIPT_SCHEMA, receipt)
if shape_errors:
raise ValueError(
f"验收自产回执[{index}] 形状非法:" + ";".join(shape_errors[:4]))
receipt_id = receipt.get("receiptId")
if not _is_v3_record_id(receipt_id) or receipt_id in receipt_ids:
raise ValueError("验收自产回执 receiptId 非法或重复")
receipt_ids.append(receipt_id)
receipt_records = {receipt["recordId"] for receipt in receipts}
consumed_records = {entry.get("recordId") for entry in consumed if isinstance(entry, dict)}
if receipt_records != declared_ids or receipt_records != consumed_records:
raise ValueError("验收自产回执、声明与消费快照 recordId 集合不闭合")
receipts_by_record = {receipt["recordId"]: receipt for receipt in receipts}
if len({receipt["finalSnapshotHash"] for receipt in receipts}) != 1:
raise ValueError("验收自产回执 finalSnapshotHash 不一致")
for entry in consumed:
receipt = receipts_by_record[entry["recordId"]]
if (receipt["consumerRef"] != request_payload.get("consumerRef")
or entry.get("role") != receipt["role"]
or entry.get("artifactHash") != receipt["expected"]["artifactHash"]):
raise ValueError("验收自产回执与 consumerRef/消费快照不一致")
receipt_dir = evidence_dir / "reference-asset-verification-receipts"
if os.path.lexists(receipt_dir):
receipt_stat = os.lstat(receipt_dir)
if stat.S_ISLNK(receipt_stat.st_mode) or not stat.S_ISDIR(receipt_stat.st_mode):
raise ValueError("验收回执目录必须是当前 run 内普通目录且不得为 symlink")
else:
receipt_dir.mkdir(parents=True)
if receipt_dir.resolve().parent != evidence_dir:
raise ValueError("验收回执目录越出当前 run")
receipt_refs = []
for receipt in receipts:
receipt_file = receipt_dir / f"{receipt['receiptId']}.json"
receipt_bytes = _stable_json_bytes_v3(receipt)
_write_receipt_json_atomic(receipt_file, receipt_bytes)
receipt_refs.append({
"ref": receipt_file.relative_to(evidence_dir).as_posix(),
"hash": hashlib.sha256(receipt_bytes).hexdigest(),
})
manifest = {
"schemaVersion": ("acceptance-provenance/4" if receipt_refs is not None
else "acceptance-provenance/3"),
**{key: request_payload[key] for key in (
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId", "proofRegistryVersion",
"taskBindingHash", "interactionBinding",
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
"designRef", "referenceAssetRecordIds", "consumerRef",
)},
"acceptanceRequestHash": expected_request_hash,
"artifactHash": artifact_hash,
"consumedReferenceAssets": consumed,
**({"referenceAssetVerificationReceipts": receipt_refs}
if receipt_refs is not None else {}),
}
manifest_errors = (_validate_v3_schema_only(_V4_ACCEPTANCE_PROVENANCE_SCHEMA, manifest)
if receipt_refs is not None
else _validate_v3_instance(_V3_ACCEPTANCE_PROVENANCE_SCHEMA, manifest))
if manifest_errors:
raise ValueError("acceptance provenance 非法:" + ";".join(manifest_errors[:4]))
manifest_file = evidence_dir / "acceptance-provenance.json"
manifest_hash = _write_json_bytes_atomic(manifest_file, manifest)
return {
"acceptanceRequestFile": str(request_file),
"acceptanceRequestHash": expected_request_hash,
"provenanceManifestFile": str(manifest_file),
"provenanceManifestHash": manifest_hash,
"interactionBindingFile": (str(interaction_binding_file)
if interaction_binding_file is not None else None),
"interactionBindingFileHash": interaction_binding_file_hash,
"manifest": manifest,
}
# 参照资产消费角色枚举,与 ReferenceAssetRecord/1.role 一致role 与 lifecycleStatus 正交,
# candidate/migration_pending 不是第五种角色。
_V3_REFERENCE_ASSET_ROLES = frozenset(
("harness_fixture", "prompt_eval_gold", "generation_exemplar", "game_content_gold"))
def _load_v3_reference_asset_registry(registry_file=None) -> dict:
"""读取参照资产持久注册表(当前阶段为 migration-list 初始快照)。
注册表不可读必须响亮失败,绝不能被当成『无记录』放行或拒绝消费——读不到注册表时
任何消费判定都失去依据,交由调用方按 fail-closed 处理。
"""
path = Path(registry_file) if registry_file else _V3_REFERENCE_ASSET_REGISTRY_FILE
try:
registry = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc: # noqa: BLE001 —— 注册表缺失/损坏是接线错误,不得静默
raise ValueError(f"参照资产注册表不可读:{type(exc).__name__}: {exc}") from exc
if not isinstance(registry, dict) or not isinstance(registry.get("records"), list):
raise ValueError("参照资产注册表缺 records 列表")
return registry
def reconcile_v3_reference_asset_consumption(consumptions, *, consumer_ref=None,
registry=None, registry_file=None) -> dict:
"""参照资产消费对账(纯本地,金标 SoT §7逐条消费声明过六项闸全过才允许读取资产。
六项闸(口径 = 金标 SoT『消费者按 recordId 对账 role + consumerRef + artifactHash 后才能读取资产』):
① recordId 在注册表存在;
② lifecycleStatus==active——migration_pending/candidate/retired 一律拒绝(参照资产未激活不得消费);
③ role 与消费场景匹配(声明了期望 role 时,记录登记 role 必须一致);
④ consumerRef 登记闸:记录必须已登记 consumerRef调用方报上 consumer_ref 时还必须与登记一致;
⑤ 版本门:调用方报上消费时实际制品 hash 时,必须与记录 artifactHash 一致hash 漂移拒绝);
⑥ 缺维度门role==game_content_gold 的记录必须 designRef 非空(缺已批准 designIntent 拒绝)。
consumptions消费声明列表元素为 recordId 字符串或 {recordId, role?, artifactHash?}
consumed 快照冻结消费时刻记录里的 role/artifactHash注册表后续改动不覆盖历史对账留痕。
返回 {ok, consumed:[{recordId,role,artifactHash}], errors:[...]}。
"""
if registry is None:
registry = _load_v3_reference_asset_registry(registry_file)
by_id = {}
for record in registry.get("records") or []:
if isinstance(record, dict):
record_id = record.get("recordId")
if _is_v3_record_id(record_id) and record_id not in by_id:
by_id[record_id] = record
consumed = []
errors = []
for item in consumptions or []:
if isinstance(item, str):
item = {"recordId": item}
if not isinstance(item, dict):
errors.append(f"消费声明必须是 recordId 字符串或对象:{item!r}")
continue
record_id = item.get("recordId")
if not _is_v3_record_id(record_id):
errors.append(f"消费声明 recordId 非法:{record_id!r}")
continue
record = by_id.get(record_id)
# ① 存在闸
if record is None:
errors.append(f"参照资产记录不存在:{record_id}")
continue
# ② 激活闸:只有 active 可消费(未激活记录的占位 hash 不得进入任何消费快照)
status = record.get("lifecycleStatus")
if status != "active":
errors.append(f"参照资产未激活不得消费:{record_id}(lifecycleStatus={status or 'unknown'})")
continue
# ③ role 闸:角色非法或与消费场景声明不符均拒绝
role = record.get("role")
if role not in _V3_REFERENCE_ASSET_ROLES:
errors.append(f"参照资产 role 非法:{record_id}(role={role or 'unknown'})")
continue
expected_role = item.get("role")
if expected_role is not None and expected_role != role:
errors.append(
f"参照资产 role 与消费场景不匹配:{record_id}(登记={role} 声明={expected_role})")
continue
# ④ consumerRef 闸active 记录必须已登记消费方;调用方报上身份时必须与登记一致
record_consumer = record.get("consumerRef")
if not (isinstance(record_consumer, str) and record_consumer):
errors.append(f"参照资产 consumerRef 未登记:{record_id}")
continue
if consumer_ref is not None and record_consumer != consumer_ref:
errors.append(
f"参照资产 consumerRef 与登记不符:{record_id}(登记={record_consumer} 消费方={consumer_ref})")
continue
# ⑤ 版本门:冻结消费时刻制品身份,登记 hash 与实际 hash 漂移即拒绝
record_hash = record.get("artifactHash")
actual_hash = item.get("artifactHash")
if actual_hash is not None and actual_hash != record_hash:
errors.append(f"参照资产制品 hash 漂移拒绝:{record_id}(登记={record_hash} 消费时={actual_hash})")
continue
# ⑥ 缺维度门:完整内容金标必须指向已批准 designIntent缺维度不得被消费
if role == "game_content_gold":
design_refs = record.get("designRef")
if not (isinstance(design_refs, list) and design_refs):
errors.append(f"game_content_gold 缺 designRef 维度拒绝:{record_id}")
continue
consumed.append({"recordId": record_id, "role": role, "artifactHash": str(record_hash or "")})
return {"ok": not errors, "consumed": consumed, "errors": errors}
def _v3_check_declared_reference_assets(identity: dict) -> tuple:
"""编排接线处的消费对账闸:返回 (拒绝原因, 消费快照)identity 声明消费必须全部过六项对账。
Registry/1 对账仅供旧 acceptance 请求与 provenance 兼容。未声明消费时返回 (None, []) 正常放行;
对账成功时同时返回消费时刻快照 [{recordId,role,artifactHash}],由调用方写入
provenance manifest 的 consumedReferenceAssets 完成消费溯源闭环(快照出自验收时点对账,零信任,
不接受生成侧自报)。新生成入口不得用本兼容器读取资产,统一走 release 锚定冻结预检。
"""
declared = identity.get("referenceAssetRecordIds") or []
if not declared:
return None, []
try:
reconciled = reconcile_v3_reference_asset_consumption(
declared, consumer_ref=identity.get("consumerRef"))
except ValueError as exc: # noqa: BLE001 —— 注册表不可读按 fail-closed 拒绝消费
return f"参照资产消费对账失败:{exc}", []
if reconciled["ok"]:
return None, reconciled["consumed"]
return "参照资产消费对账拒绝:" + ";".join(reconciled["errors"][:4]), []
def preflight_reference_asset_policy(policy_id, acceptance_mode):
"""在 Writer 前按生产内置锚点验证显式冻结策略;未选择策略时返回 ``None``。
调用方唯一可控输入是 ``policy_id`` 与当前 acceptance mode。可信仓根、release 路径、release SHA、
gate mode 均由本模块固定;任何未知策略、非 shadow 模式或资产漂移直接抛错,不做降级回落。
"""
if policy_id is None:
return None
if acceptance_mode == "v3":
raise ValueError("v3 live 禁止使用冻结参照资产 policy")
if acceptance_mode != "v3_shadow":
raise ValueError("冻结参照资产 policy 仅允许 v3_shadow")
import reference_asset_gate # noqa: PLC0415 仅显式策略路径加载可信消费门
return reference_asset_gate.verify_policy(
policy_id,
release_ref=_REFERENCE_ASSET_RELEASE_REF,
expected_release_hash=_REFERENCE_ASSET_RELEASE_SHA256,
trusted_root=_REFERENCE_ASSET_TRUSTED_ROOT,
mode=_REFERENCE_ASSET_POLICY_MODE,
)
def _reverify_acceptance_reference_asset_policy(
request: dict, identity: dict, acceptance_mode: str) -> tuple:
"""验收侧独立复验 policy并与 Writer 前 generation receipts 做 canonical 全等对账。"""
import reference_asset_gate # noqa: PLC0415 验收只消费统一 gate 的公开结果与 canonical 编码
policy_present = "referenceAssetPolicyId" in request
receipts_present = "referenceAssetGenerationReceipts" in request
if any(key.startswith("referenceAssetVerificationReceipt") for key in request):
return "请求不得自报验收回执 ref/hash", None, []
if policy_present != receipts_present:
return "referenceAssetPolicyId/referenceAssetGenerationReceipts 必须成对出现", None, []
if not policy_present:
return None, None, []
policy_id = request.get("referenceAssetPolicyId")
generation_receipts = request.get("referenceAssetGenerationReceipts")
if not isinstance(policy_id, str) or not policy_id:
return "referenceAssetPolicyId 必须是非空字符串", None, []
if (not isinstance(generation_receipts, list) or not generation_receipts
or any(not isinstance(receipt, dict) for receipt in generation_receipts)):
return "referenceAssetGenerationReceipts 必须是非空 JSON 对象数组", None, []
for index, receipt in enumerate(generation_receipts):
shape_errors = _validate_v3_schema_only(_REFERENCE_ASSET_RECEIPT_SCHEMA, receipt)
if shape_errors:
return (f"referenceAssetGenerationReceipts[{index}] 形状非法:"
+ ";".join(shape_errors[:4])), None, []
try:
generation_bytes = reference_asset_gate.canonical_json_bytes(generation_receipts)
verified = preflight_reference_asset_policy(policy_id, acceptance_mode)
acceptance_receipts = reference_asset_gate.to_json_value(verified.receipts)
acceptance_bytes = reference_asset_gate.canonical_json_bytes(acceptance_receipts)
except Exception as exc: # noqa: BLE001 任一锚点或资产漂移都必须转为稳定 provenance_error
return f"验收侧参照资产独立复验失败:{type(exc).__name__}: {exc}", None, []
if generation_bytes != acceptance_bytes:
return "generation receipts 与验收独立复验 canonical 回执不一致", None, []
record_ids = [receipt["recordId"] for receipt in acceptance_receipts]
consumers = {receipt["consumerRef"] for receipt in acceptance_receipts}
snapshots = {receipt["finalSnapshotHash"] for receipt in acceptance_receipts}
declared = identity.get("referenceAssetRecordIds") or []
if len(record_ids) != len(set(record_ids)) or set(declared) != set(record_ids):
return "acceptance identity 的 referenceAssetRecordIds 与验收回执集合不一致", None, []
if len(consumers) != 1 or identity.get("consumerRef") not in consumers:
return "acceptance identity 的 consumerRef 与验收回执不一致", None, []
if len(snapshots) != 1:
return "验收回执 finalSnapshotHash 不一致", None, []
consumed = [{
"recordId": receipt["recordId"],
"role": receipt["role"],
"artifactHash": receipt["expected"]["artifactHash"],
} for receipt in acceptance_receipts]
return None, acceptance_receipts, consumed
def build_frozen_reference_asset_constraint_block(verified) -> str:
"""只用验证器冻结结果生成 Writer 约束,不接收 identity 自报字段。"""
import reference_asset_gate # noqa: PLC0415 与预检结果类型保持单一来源
if not isinstance(verified, reference_asset_gate.VerifiedReferenceAssets):
raise TypeError("参照资产约束只接受 VerifiedReferenceAssets")
lines = [
"【冻结参照资产约束】",
f"policyId={_REFERENCE_ASSET_POLICY_ID}",
f"mode={_REFERENCE_ASSET_POLICY_MODE}",
f"snapshotHash={verified.snapshot_hash}",
"以下文件是本次 Writer 唯一可读的参照资产快照;只能 read_file/list_dir禁止写入或读取活目录",
]
for record in verified.records:
lines.append(f"record={record.get('recordId')} role={record.get('role')}")
for receipt in verified.receipts:
lines.append(
f"receipt={receipt.get('receiptId')} finalSnapshotHash={receipt.get('finalSnapshotHash')}"
)
for record_id in sorted(verified.reference_roots):
roots = ",".join(verified.reference_roots[record_id])
lines.append(f"roots[{record_id}]={roots}")
for path in sorted(verified.reference_files, key=lambda item: item.encode("utf-8")):
content = verified.reference_files[path]
lines.append(f"- {path} size={len(content)} sha256={hashlib.sha256(content).hexdigest()}")
return "\n".join(lines)
def build_v3_reference_asset_generation_constraints(identity: dict, registry=None) -> dict | None:
"""旧 acceptance 兼容注入门Registry/1 对账通过 → 约束块;未声明 → None失败 → ValueError。
本函数只保留给旧 acceptance identity 回放,不是 v2 生产消费入口:
· referenceAssetRecordIds 为空 ≡ 未声明消费 ≡ 旧路径,返回 None生成行为逐字节不变
· 声明消费 → 逐条过 reconcile_v3_reference_asset_consumption 六项闸,任一失败抛 ValueError调用方在
生成入口响亮拒绝);
· 对账通过(仅记录签认 active 后才可能)→ 生成约束文本recordId + role + 制品 hash + assetRef +
designRef + 参照义务(内容完整度/玩法义务须达到参照资产水准),供调用方注入生成 prompt 约束块。
返回 {constraint_block, consumed, records}consumed 为消费时刻快照records 为注册表完整登记(审计用)。
provenance 的 consumedReferenceAssets 不由本函数落——验收侧以验收时点对账零信任重产快照,两侧独立成证。
"""
declared = (identity or {}).get("referenceAssetRecordIds") or []
if not declared:
return None
reconciled = reconcile_v3_reference_asset_consumption(
declared, consumer_ref=(identity or {}).get("consumerRef"), registry=registry)
if not reconciled["ok"]:
raise ValueError("参照资产消费对账拒绝:" + ";".join(reconciled["errors"][:4]))
# 取完整登记记录assetRef/designRef渲染约束文本对账已保证记录存在且 active。by_id 与对账函数同口径首条优先。
if registry is None:
registry = _load_v3_reference_asset_registry()
by_id = {}
for record in registry.get("records") or []:
if isinstance(record, dict):
record_id = record.get("recordId")
if _is_v3_record_id(record_id) and record_id not in by_id:
by_id[record_id] = record
lines = [
"【参照资产消费约束(已对账 active",
"本局生成声明消费以下参照资产并通过对账recordId + role + consumerRef + artifactHash 逐条核验)。"
"生成必须达到参照义务,未达标即验收失败:",
]
records = []
for item in reconciled["consumed"]:
record = by_id.get(item["recordId"]) or {}
records.append(record)
design_refs = record.get("designRef")
design_text = ";".join(design_refs) if isinstance(design_refs, list) and design_refs else ""
lines.append(
f"- recordId={item['recordId']}role={item['role']},制品 hash={item['artifactHash']}"
f"资产={record.get('assetRef') or '-'}"
+ (f"设计参照designRef={design_text}" if design_text else ""))
lines.append(
f" 参照义务:本局游戏的内容完整度与玩法义务须达到参照资产 {item['recordId']} 的水准;"
f"开工先 read 上述 assetRef 源工程,以它为该品类的最低验收基线,不得降级照抄。")
return {"constraint_block": "\n".join(lines), "consumed": reconciled["consumed"], "records": records}
def _artifact_hash_v3(game_id: str, artifact_path=None) -> str:
"""从受限不可变快照计算 staged 指纹;验收与发布不再维护两套读取边界。"""
root = Path(artifact_path) if artifact_path else cheap_run.wg1_game_dir(game_id)
try:
root.lstat()
except FileNotFoundError:
return _sha256_text(f"missing:{root}")
return artifact_snapshot.capture_artifact_snapshot(root).artifact_hash
def _v3_run_id(request: dict, artifact_hash: str, brief_hash: str) -> str:
"""由幂等键和不可变输入生成稳定 runId同输入重入只会命中同一封存结果。"""
idem = str(request.get("idempotencyKey") or request.get("idempotency_key") or request.get("gameId") or "")
parent = str(request.get("parentRunId") or "")
mode = str(request.get("acceptanceMode") or _acceptance_v3_cfg()["mode"])
repair_count = int(request.get("repairCountAcrossParentChain") or 0)
identity = request.get("acceptanceIdentity") if isinstance(request.get("acceptanceIdentity"), dict) else request
acceptance_request_hash = str(identity.get("acceptanceRequestHash") or "")
proof_profile_id = str(identity.get("proofProfileId") or "")
proof_registry_version = str(identity.get("proofRegistryVersion") or "")
raw = (
f"{idem}|{artifact_hash}|{brief_hash}|{mode}|{parent}|{repair_count}|"
f"{acceptance_request_hash}|{proof_profile_id}|{proof_registry_version}"
)
return "v3-" + _sha256_text(raw)[:24]
def _playtest_v3_script() -> Path:
"""返回 playtest/3 runner 的唯一入口runner 未部署时同步封装会诚实返回 tester_error。"""
return cheap_run._WG1_DIR / "_shared" / "serve-and-playtest-v3.sh"
def _trusted_runner_cost_v3(payload: dict) -> float | None:
"""只接受与 Node ``createCostLedger`` 同构且可复算的成本;否则按未知成本处理。
Node 每个 entry 的金额键是 ``rmb``,顶层与 ledger.costRmb 都是 ``sum(entry.rmb).toFixed(5)``。
这里只信可逐项复算的有限非负金额;看错键、只信顶层或不核总和都会让伪造的低成本绕过硬帽。
"""
if not isinstance(payload, dict):
return None
top = payload.get("costRmb")
ledger = payload.get("cost")
ledger_top = ledger.get("costRmb") if isinstance(ledger, dict) else None
if (not isinstance(top, (int, float)) or isinstance(top, bool) or not math.isfinite(top) or top < 0
or not isinstance(ledger_top, (int, float)) or isinstance(ledger_top, bool)
or not math.isfinite(ledger_top) or ledger_top < 0
or not isinstance(ledger.get("entries"), list)):
return None
normalized_top = _quantize_rmb_v3(top, "runner.costRmb")
if float(top) != normalized_top or float(ledger_top) != normalized_top:
return None
total = 0.0
entry_fields = {
"role", "model", "promptTokens", "completionTokens", "cachedTokens",
"modelRatio", "completionRatio", "cacheRatio", "quota", "rmb",
}
for entry in ledger["entries"]:
if not isinstance(entry, dict) or set(entry) != entry_fields:
return None
role = entry.get("role")
model = entry.get("model")
value = entry.get("rmb")
if role not in ("Actor", "JudgeA", "JudgeB") or not isinstance(model, str) or not model.strip():
return None
for key in ("promptTokens", "completionTokens", "cachedTokens"):
token_value = entry.get(key)
if not isinstance(token_value, int) or isinstance(token_value, bool) or token_value < 0:
return None
for key in ("modelRatio", "completionRatio", "cacheRatio", "quota"):
numeric = entry.get(key)
if (not isinstance(numeric, (int, float)) or isinstance(numeric, bool)
or not math.isfinite(numeric) or numeric < 0):
return None
if (not isinstance(value, (int, float)) or isinstance(value, bool)
or not math.isfinite(value) or value < 0):
return None
total += float(value)
if _quantize_rmb_v3(total, "runner.cost.entries") != normalized_top:
return None
return normalized_top
def _run_playtest_v3_roll_sync(game_id: str, *, run_id: str, roll: int, requested_seed: int,
policy_seed: int, brief_hash: str, artifact_hash: str,
brief_file: str, genre: str, template_route: str,
proof_profile_id: str, proof_registry_version: str,
task_binding_hash: str, acceptance_request_hash: str, acceptance_request_file: str,
provenance_manifest_file: str, provenance_manifest_hash: str,
acceptance_mode: str, evidence_mode: str, evidence_dir, model_name: str,
port: int, cdp_port: int, timeout: float,
remaining_cost_rmb: float, cfg: dict, legacy_mapping_file=None,
interaction_binding_file=None, interaction_binding_file_hash=None,
interaction_profile_id=None, interaction_registry_version=None,
interaction_binding_hash=None) -> dict:
"""调用新 serve 脚本执行一掷;只解析 playtest/3 JSON不把进程错误伪装成 gameplay。"""
unknown_cost = max(0.0, float(remaining_cost_rmb or 0.0))
script = _playtest_v3_script()
if not script.is_file():
return {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "roll": roll, "outcome": "tester_error",
"failureSubtype": "runner_error", "reason": f"v3 runner 不存在:{script}",
"infraRetryable": False, "costRmb": 0.0,
}
argv = [
# 与既有 serve-and-playtest.sh 保持同形gameId/port/cdpPort 三个位置参数,随后用 -- 透传 runner 参数。
"bash", str(script), str(game_id), str(port), str(cdp_port), "--",
f"--run-id={run_id}", f"--roll={roll}", f"--requested-seed={requested_seed}",
f"--policy-seed={policy_seed}", f"--brief-hash={brief_hash}",
f"--artifact-hash={artifact_hash}", f"--evidence-dir={evidence_dir}",
f"--brief-file={brief_file}", f"--obligations-file={_V3_OBLIGATIONS_FILE}", f"--genre={genre}",
f"--template-route={template_route}", f"--proof-profile-id={proof_profile_id}",
f"--proof-registry-version={proof_registry_version}",
f"--task-binding-hash={task_binding_hash}",
f"--acceptance-request-hash={acceptance_request_hash}",
f"--acceptance-request-file={acceptance_request_file}",
f"--provenance-manifest={provenance_manifest_file}",
f"--provenance-manifest-hash={provenance_manifest_hash}",
f"--acceptance-mode={acceptance_mode}", f"--evidence-mode={evidence_mode}",
f"--model={model_name}", f"--cost-cap-rmb={max(0.0, remaining_cost_rmb):.6f}",
f"--action-quantum-ms={cfg['action_quantum_ms']}",
f"--wait-min-ms={cfg['wait_min_ms']}", f"--wait-max-ms={cfg['wait_max_ms']}",
]
if legacy_mapping_file:
argv.append(f"--legacy-mapping-file={legacy_mapping_file}")
interaction_values = (
interaction_binding_file, interaction_binding_file_hash, interaction_profile_id,
interaction_registry_version, interaction_binding_hash,
)
if any(value is not None for value in interaction_values):
if not all(value is not None for value in interaction_values):
return {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "roll": roll, "outcome": "tester_error",
"failureSubtype": "profile_contract_error",
"reason": "interaction binding runner 参数必须五项全有或全无",
"infraRetryable": False, "costRmb": 0.0,
}
# 新编排使用语义明确的 --interaction-binding-fileNode 同时兼容历史 --interaction-binding。
argv.extend((
f"--interaction-binding-file={interaction_binding_file}",
f"--interaction-binding-file-hash={interaction_binding_file_hash}",
f"--interaction-profile-id={interaction_profile_id}",
f"--interaction-registry-version={interaction_registry_version}",
f"--interaction-binding-hash={interaction_binding_hash}",
))
try:
proc = subprocess.run(argv, cwd=str(cheap_run._GAME_RUNTIME), capture_output=True, text=True,
timeout=timeout, env=_playtest_env(), check=False)
except subprocess.TimeoutExpired:
return {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "roll": roll, "outcome": "tester_error",
"failureSubtype": "runner_timeout", "reason": f"v3 runner 超时(>{timeout}s)",
"infraRetryable": True, "costRmb": unknown_cost,
}
except Exception as e: # noqa: BLE001
return {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "roll": roll, "outcome": "tester_error",
"failureSubtype": "runner_error", "reason": f"v3 runner 异常:{type(e).__name__}: {e}",
"infraRetryable": True, "costRmb": unknown_cost,
}
payload = None
for line in reversed((proc.stdout or "").splitlines()):
line = line.strip()
if not (line.startswith("{") and line.endswith("}")):
continue
try:
payload = json.loads(line)
break
except json.JSONDecodeError:
continue
if not isinstance(payload, dict):
return {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "roll": roll, "outcome": "tester_error",
"failureSubtype": "schema_error", "reason": "v3 runner 未产出 JSON 对象",
"infraRetryable": True, "raw": ((proc.stdout or "") + (proc.stderr or ""))[-1000:],
"costRmb": unknown_cost,
}
package_type = payload.get("packageType")
trusted_payload_cost = _trusted_runner_cost_v3(payload)
if package_type == "SingleRollRunnerError":
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
payload["outcome"] = "tester_error"
payload.setdefault("failureSubtype", "runner_error")
payload.setdefault("infraRetryable", False)
payload.setdefault("reason", error.get("message") or f"v3 runner 退出码 {proc.returncode}")
payload["costRmb"] = trusted_payload_cost if trusted_payload_cost is not None else unknown_cost
return payload
if package_type != "SingleRollFact":
return {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "packageType": "PythonBoundaryError", "roll": roll,
"outcome": "tester_error", "failureSubtype": "schema_error", "infraRetryable": True,
"reason": f"runner stdout packageType 非法:{package_type or '-'}", "costRmb": unknown_cost,
}
if proc.returncode != 0:
return {
**payload, "outcome": "tester_error", "failureSubtype": "runner_error", "infraRetryable": True,
"reason": f"runner 返回 SingleRollFact 但退出码为 {proc.returncode}",
"costRmb": trusted_payload_cost if trusted_payload_cost is not None else unknown_cost,
}
fact_schema_errors, _ = _validate_v3_instance_parts(_V3_SINGLE_ROLL_SCHEMA, payload)
if fact_schema_errors:
return {
**payload, "outcome": "tester_error", "failureSubtype": "schema_error",
"infraRetryable": False, "reason": "SingleRollFact schema 非法:" + ";".join(fact_schema_errors[:6]),
"costRmb": trusted_payload_cost if trusted_payload_cost is not None else unknown_cost,
}
# stdout 只是单掷索引;可信动作、帧、事件和义务必须从已封存 proofRef 回读,不能信 stdout 复制件。
runner = payload.get("runner") if isinstance(payload.get("runner"), dict) else {}
proof_ref = payload.get("proofPackageRef") or runner.get("proofRef")
proof_path = (Path(evidence_dir) / str(proof_ref or "")).resolve()
evidence_root = Path(evidence_dir).resolve()
try:
if not proof_ref or (proof_path != evidence_root and evidence_root not in proof_path.parents):
raise ValueError("proofRef 缺失或越界")
proof_bytes = proof_path.read_bytes()
actual_proof_hash = hashlib.sha256(proof_bytes).hexdigest()
expected_proof_hash = str(payload.get("proofPackageHash") or runner.get("proofHash") or "")
if not _is_sha256_v3(expected_proof_hash) or actual_proof_hash != expected_proof_hash:
raise ValueError("proofPackageHash 与封存文件字节不一致")
proof = json.loads(proof_bytes.decode("utf-8"))
if proof.get("schemaVersion") != _PLAYTEST_V3_SCHEMA or proof.get("packageType") != "SingleRollProof":
raise ValueError(f"proof packageType/schema 非法:{proof.get('packageType')}/{proof.get('schemaVersion')}")
identities = {
"runId": run_id, "roll": roll, "gameId": str(game_id), "genre": genre,
"templateRoute": template_route, "proofProfileId": proof_profile_id,
"proofRegistryVersion": proof_registry_version,
"taskBindingHash": task_binding_hash,
"acceptanceRequestHash": acceptance_request_hash, "evidenceMode": evidence_mode,
"artifactHash": artifact_hash, "briefHash": brief_hash,
}
for key, expected in identities.items():
if payload.get(key) != expected:
raise ValueError(f"stdout {key} 与请求不一致:{payload.get(key)} != {expected}")
if proof.get(key) != expected:
raise ValueError(f"proof {key} 与请求不一致:{proof.get(key)} != {expected}")
# stdout 只作索引;所有可影响 guard 的事实必须与 hash 封存 proof 逐字段一致。
mirror_fields = (
"requestedSeed", "actualSeed", "policySeed", "environment", "actor", "actions", "frames",
"events", "briefRuleMatches", "proofObligations", "firstPlay", "judgePackageRef",
"judgePackageHash", "judgeARef", "judgeAHash", "judgeBRef", "judgeBHash",
"judgeConsensusRef", "judgeConsensusHash", "costReservationRef", "costReservationHash",
"judge", "errors", "costRmb", "cost",
)
for key in mirror_fields:
if payload.get(key) != proof.get(key):
raise ValueError(f"stdout {key} 与封存 proof 不一致")
payload["_proofPackage"] = proof
payload["_proofPackageRef"] = str(proof_ref)
payload["_evidenceRoot"] = str(evidence_root)
judge_ref = proof.get("judgePackageRef") or payload.get("judgePackageRef")
judge_hash = str(proof.get("judgePackageHash") or payload.get("judgePackageHash") or "")
if judge_ref:
judge_path = (evidence_root / str(judge_ref)).resolve()
if evidence_root not in judge_path.parents or not _is_sha256_v3(judge_hash):
raise ValueError("judgePackageRef 越界或 judgePackageHash 非法")
if hashlib.sha256(judge_path.read_bytes()).hexdigest() != judge_hash:
raise ValueError("judgePackageHash 与封存文件字节不一致")
for slot_name in ("judgeARef", "judgeBRef"):
slot_ref = proof.get(slot_name) if isinstance(proof.get(slot_name), dict) else {}
for ref_field, hash_field in (("rawRef", "rawHash"), ("parsedRef", "parsedHash")):
target = (evidence_root / str(slot_ref.get(ref_field) or "")).resolve()
expected = str(slot_ref.get(hash_field) or "")
if (evidence_root not in target.parents or not target.is_file()
or not _is_sha256_v3(expected)
or hashlib.sha256(target.read_bytes()).hexdigest() != expected):
raise ValueError(f"{slot_name}.{ref_field}/{hash_field} 无法回读复核")
normalized_path = (evidence_root / str(slot_ref.get("normalizedRef") or "")).resolve()
top_hash_field = "judgeAHash" if slot_name == "judgeARef" else "judgeBHash"
normalized_hash = str(proof.get(top_hash_field) or "")
if (evidence_root not in normalized_path.parents or not normalized_path.is_file()
or not _is_sha256_v3(normalized_hash)
or hashlib.sha256(normalized_path.read_bytes()).hexdigest() != normalized_hash):
raise ValueError(f"{slot_name}.normalizedRef/{top_hash_field} 无法回读复核")
consensus_ref = str(proof.get("judgeConsensusRef") or "")
consensus_path = (evidence_root / consensus_ref).resolve()
consensus_hash = str(proof.get("judgeConsensusHash") or "")
if (evidence_root not in consensus_path.parents or not consensus_path.is_file()
or not _is_sha256_v3(consensus_hash)
or hashlib.sha256(consensus_path.read_bytes()).hexdigest() != consensus_hash):
raise ValueError("JudgeConsensus ref/hash 无法回读复核")
consensus = json.loads(consensus_path.read_text(encoding="utf-8"))
if consensus.get("strategyVersion") != "JudgeConsensus/1" \
or consensus.get("judgePackageHash") != judge_hash:
raise ValueError("JudgeConsensus 版本或 packageHash 不一致")
evaluation_hashes = consensus.get("evaluationHashes") \
if isinstance(consensus.get("evaluationHashes"), dict) else {}
if (evaluation_hashes.get("judgeA") != (proof.get("judgeARef") or {}).get("resultHash")
or evaluation_hashes.get("judgeB") != (proof.get("judgeBRef") or {}).get("resultHash")):
raise ValueError("JudgeConsensus evaluationHashes 与 A/B 槽结果不一致")
reservation_ref = str(proof.get("costReservationRef") or "")
reservation_path = (evidence_root / reservation_ref).resolve()
reservation_hash = str(proof.get("costReservationHash") or "")
if (evidence_root not in reservation_path.parents or not reservation_path.is_file()
or not _is_sha256_v3(reservation_hash)
or hashlib.sha256(reservation_path.read_bytes()).hexdigest() != reservation_hash):
raise ValueError("CostReservation ref/hash 无法回读复核")
reservation = json.loads(reservation_path.read_text(encoding="utf-8"))
reservation_errors, reservation_semantic_errors = _validate_v3_instance_parts(
_V3_CONTRACT_DIR / "cost-reservation.schema.json", reservation)
if reservation_errors or reservation_semantic_errors \
or reservation.get("judgePackageHash") != judge_hash:
raise ValueError("CostReservation schema、语义或 judgePackageHash 不一致")
payload["_judgeConsensus"] = consensus
payload["_costReservation"] = reservation
except Exception as e: # noqa: BLE001 —— 封存 proof 不可读属于测试器错误,绝不降成 gameplay reject
return {
**payload, "outcome": "tester_error", "failureSubtype": "schema_error", "infraRetryable": True,
"reason": f"封存 proof 回读失败:{type(e).__name__}: {e}",
"costRmb": trusted_payload_cost if trusted_payload_cost is not None else unknown_cost,
}
payload.setdefault("costRmb", 0.0)
return payload
def _v3_resolve_registry(genre: str, brief: str, *, template_route: str,
proof_profile_id=None, proof_registry_version=None) -> dict:
"""按可信模板路由选择唯一 profile并交叉核对 genre/profile/registryVersion。"""
registry = json.loads(_V3_OBLIGATIONS_FILE.read_text(encoding="utf-8"))
registry_errors = _validate_v3_instance(_V3_OBLIGATIONS_SCHEMA, registry)
if registry_errors:
raise ValueError("proof obligation 注册表非法:" + ";".join(registry_errors[:4]))
registry_version = str(registry.get("registryVersion") or "")
if proof_registry_version is not None and str(proof_registry_version) != registry_version:
raise ValueError("proofRegistryVersion 与 canonical 注册表不一致")
profiles = registry.get("profiles") if isinstance(registry.get("profiles"), dict) else {}
matches = [profile for profile in profiles.values()
if isinstance(profile, dict) and profile.get("templateRoute") == template_route]
if len(matches) != 1:
raise ValueError(f"templateRoute 必须唯一命中可信 profile:{template_route or '-'}")
profile = matches[0]
if profile.get("genre") != genre:
raise ValueError(f"genre/templateRoute 不一致:{genre or '-'} != {profile.get('genre') or '-'}")
if proof_profile_id is not None and profile.get("id") != proof_profile_id:
raise ValueError(f"proofProfileId/templateRoute 不一致:{proof_profile_id} != {profile.get('id')}")
normalized = (brief or "").lower()
promoted = set()
brief_matches = []
for rule in profile.get("briefOptionalRules") or []:
keywords = ((rule.get("when") or {}).get("anyKeywords") or [])
if any(str(keyword).lower() in normalized for keyword in keywords):
brief_matches.append(str(rule.get("id")))
promoted.update(rule.get("requireObligationIds") or [])
obligations = []
for item in profile.get("obligations") or []:
required = bool(item.get("defaultRequired") or item.get("id") in promoted)
obligations.append({
"id": str(item.get("id") or ""),
"required": required,
"evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {},
"status": "missing" if required else "not-required",
"sequenceRefs": [], "actionRefs": [], "postFrameRefs": [], "eventRefs": [],
"contradictions": [], "blockingProblems": [],
})
return {
"schemaVersion": str(registry.get("schemaVersion") or ""),
"version": registry_version,
"proofProfileId": str(profile.get("id") or ""),
"genre": str(profile.get("genre") or ""),
"templateRoute": str(profile.get("templateRoute") or ""),
"briefRuleMatches": brief_matches,
"obligations": obligations,
}
def _v3_judge_contract(raw: dict, *, evidence_dir: str) -> dict | None:
"""读取 playtest/3 双 Judge consensus 投影;不再把单槽身份冒充最终 Judge。"""
judge = raw.get("judge") if isinstance(raw.get("judge"), dict) else {}
if (judge.get("schemaVersion") == "JudgeConsensus/1"
and isinstance(judge.get("obligationResults"), list)
and judge.get("decision") in _V3_OUTCOMES and "degraded" in judge):
return dict(judge)
return None
def _v3_actor_contract(raw: dict, *, evidence_dir: str) -> dict | None:
"""投影 Actor 身份Actor 错误时仍保留身份用于审计,但 guard 会判 actorHealthy=false。"""
actor = raw.get("actor") if isinstance(raw.get("actor"), dict) else {}
if not actor:
return None
prompt = actor.get("prompt") if isinstance(actor.get("prompt"), dict) else {}
return {
"model": str(actor.get("model") or "unknown"),
"apiClientId": str(actor.get("apiClientId") or "playtest-v3-actor-client"),
"sessionId": str(actor.get("sessionId") or "unknown-actor-session"),
"systemPromptVersion": str(actor.get("systemPromptVersion") or prompt.get("version") or "playtest.actor@unknown"),
"contextProjectionVersion": str(actor.get("contextProjectionVersion") or "ActorView/1"),
"evidenceDir": str(actor.get("evidenceDir") or f"{evidence_dir}/actor-output"),
"requestedGameSeed": int(raw.get("requestedSeed") or 0), "actualGameSeed": int(raw.get("actualSeed") or 0),
"policySeed": int(raw.get("policySeed") or 0),
}
_V3_RELATION_ERROR_MARKERS = (
"/sameValue", "所列步骤的值必须逐字相等", "缺少可比较的 JSON scalar",
"orderedSeries", "工序数必须在", "动态 stepId 必须", "stepIndex 必须", "totalSteps 必须",
"judgement 必须", "所有工序必须属于", "工序 stepId 必须唯一", "每道工序必须使用",
"只允许与最后一道工序", "共享最后动作时", "workId 必须与工序链一致",
"orderedStepIds 必须", "allStepsCompleted 必须", "score 必须是有限数", "works 必须大于零",
)
def _v3_relation_contradiction_errors(errors: list) -> list:
"""筛出游戏自报完成态与有序关系冲突;这类是玩法硬证 reject不是 harness tester_error。"""
return [str(error) for error in errors
if any(marker in str(error) for marker in _V3_RELATION_ERROR_MARKERS)]
def _v3_judge_text_reference_errors(roll: dict) -> list:
"""独立复核 Judge 自由文本只能引用本次单掷真实 action/frame/event。"""
import re # noqa: PLC0415
judge = roll.get("judge") if isinstance(roll.get("judge"), dict) else {}
# playtest/3 的最终投影不携带自由文本A/B 原文已在各槽 parser 与 consensus 中封存。
if judge.get("schemaVersion") == "JudgeConsensus/1":
return []
if judge.get("decision") == "tester_error":
return []
action_refs = {str(row.get("actionId")) for row in roll.get("actions") or []
if isinstance(row, dict) and row.get("actionId")}
frame_refs = {str(row.get("ref")) for row in roll.get("frames") or []
if isinstance(row, dict) and row.get("ref")}
event_refs = {f"event:{row.get('seq')}" for row in roll.get("events") or []
if isinstance(row, dict) and isinstance(row.get("seq"), int)
and not isinstance(row.get("seq"), bool) and row.get("seq") >= 1}
allowed = action_refs | frame_refs | event_refs
pattern = re.compile(
r"event:\d+|action-\d+|(?:[A-Za-z0-9._-]+/)+[A-Za-z0-9._-]+[.](?:png|jpe?g)"
)
groups = [
("problems", [str(value) for value in judge.get("problems") or []]),
("contradictions", [str(value) for value in judge.get("contradictions") or []]),
("summary", [str(judge.get("summary") or "")]),
]
used = set()
errors = []
for label, values in groups:
non_empty = [text for text in values if text.strip()]
if label == "summary" and len(non_empty) != 1:
errors.append("Judge summary 不得为空")
continue
refs = {ref for text in non_empty for ref in pattern.findall(text)}
if non_empty and not refs:
errors.append(f"Judge {label} 整体缺 action/frame/event 原始引用")
continue
unknown = sorted(refs - allowed)
if unknown:
errors.append(f"Judge {label} 含跨包或未知引用:{','.join(unknown)}")
used.update(refs)
if judge.get("decision") == "reject" \
and (not (used & event_refs) or not (used & frame_refs)):
errors.append("Judge reject 未同时绑定本次事件和截图")
return errors
def _v3_relation_reject_facts(roll: dict, relation_errors: list) -> tuple[dict, list]:
"""把关系冲突投影成 schema 合法的 contradicted 义务,避免坏自报关系污染最终契约。"""
import copy # noqa: PLC0415
import re # noqa: PLC0415
facts = {
"environment": copy.deepcopy(roll.get("environment")),
"actions": copy.deepcopy(roll.get("actions") or []),
"events": copy.deepcopy(roll.get("events") or []),
"proofObligations": copy.deepcopy(roll.get("proofObligations") or []),
"firstPlay": copy.deepcopy(roll.get("firstPlay") or {}),
"proofPackageRef": str(roll.get("proofPackageRef") or ""),
"judgePackageRef": roll.get("judgePackageRef"),
"proofRegistryVersion": roll.get("proofRegistryVersion"),
"briefRuleMatches": list(roll.get("briefRuleMatches") or []),
}
affected = set()
for error in relation_errors:
match = re.search(r"#/proofObligations/([^/]+)/", error)
if match:
affected.add(match.group(1))
if not affected:
affected = {row.get("id") for row in facts["proofObligations"] if row.get("required") is True}
for row in facts["proofObligations"]:
if row.get("id") not in affected:
continue
row.update({"status": "contradicted", "sequenceRefs": [], "actionRefs": [],
"postFrameRefs": [], "eventRefs": [],
"contradictions": list(relation_errors), "blockingProblems": []})
judge = _v3_judge_contract(roll, evidence_dir=str(roll.get("_evidenceDir") or "evidence/playtest-v3"))
# 关系硬证由 Python guard 单独拒绝;不得改写已 hash 封存的 JudgeConsensus 投影。
facts["judge"] = judge
facts["actor"] = _v3_actor_contract(
roll, evidence_dir=str(roll.get("_evidenceDir") or "evidence/playtest-v3"))
return facts, sorted(str(value) for value in affected if value)
def _roll_guard_v3(roll: dict, *, artifact_hash: str, brief_hash: str, floor_pass: bool = True,
registry: dict | None = None, cfg: dict | None = None,
task_binding_hash=None, acceptance_request_hash=None, evidence_mode="native") -> dict:
"""复核 canonical SingleRollFact、封存字节和可信身份只产单掷四态。"""
del cfg # 时间、sameValue、orderedSeries 等全部由 canonical validator 统一校验。
empty_checks = {key: False for key in _V3_GUARD_CHECKS}
checks = dict(empty_checks)
checks["floorPass"] = bool(floor_pass)
def tester(reason, subtype="schema_error", *, retryable=True):
return {"pass": False, "checks": checks, "blockingProblems": [reason], "contradictions": [],
"reason": reason, "rollOutcome": "tester_error", "subtype": subtype,
"infraRetryable": retryable, "errors": [reason]}
if not isinstance(roll, dict):
return tester("单掷结果不是对象")
if roll.get("schemaVersion") != _PLAYTEST_V3_SCHEMA:
return tester("未知 playtest schema", "unknown_schema")
if roll.get("outcome") == "tester_error" or roll.get("packageType") in ("SingleRollRunnerError", "PythonBoundaryError"):
reason = str(roll.get("reason") or "runner_error")
return tester(reason, roll.get("failureSubtype") or "runner_error",
retryable=bool(roll.get("infraRetryable", True)))
schema_errors, semantic_errors = _validate_v3_instance_parts(_V3_SINGLE_ROLL_SCHEMA, roll)
if schema_errors:
return tester("SingleRollFact schema 非法:" + ";".join(schema_errors[:6]),
"schema_error", retryable=False)
relation_errors = _v3_relation_contradiction_errors(semantic_errors)
other_semantic_errors = [error for error in semantic_errors if error not in relation_errors]
if other_semantic_errors:
return tester("SingleRollFact contract/semantic 非法:" + ";".join(other_semantic_errors[:6]),
"schema_error", retryable=False)
registry = registry or {}
identities = {
"artifactHash": artifact_hash,
"briefHash": brief_hash,
"templateRoute": registry.get("templateRoute"),
"proofProfileId": registry.get("proofProfileId"),
"proofRegistryVersion": registry.get("version"),
"genre": registry.get("genre"),
"evidenceMode": evidence_mode,
}
if task_binding_hash is not None:
identities["taskBindingHash"] = task_binding_hash
if acceptance_request_hash is not None:
identities["acceptanceRequestHash"] = acceptance_request_hash
for field, expected in identities.items():
if expected is not None and roll.get(field) != expected:
return tester(f"SingleRollFact {field} 与可信请求不一致", "profile_contract_error", retryable=False)
if list(roll.get("briefRuleMatches") or []) != list(registry.get("briefRuleMatches") or []):
return tester("briefRuleMatches 与可信 profile 解析不一致", "profile_contract_error", retryable=False)
actions = list(roll.get("actions") or [])
events = list(roll.get("events") or [])
obligations = list(roll.get("proofObligations") or [])
required = [row for row in obligations if row["required"] is True]
actor_raw = roll.get("actor") if isinstance(roll.get("actor"), dict) else {}
judge_raw = roll.get("judge") if isinstance(roll.get("judge"), dict) else {}
consensus_raw = roll.get("_judgeConsensus") if isinstance(roll.get("_judgeConsensus"), dict) else {}
judge = _v3_judge_contract(roll, evidence_dir=str(roll.get("_evidenceDir") or "evidence/playtest-v3"))
actor = _v3_actor_contract(roll, evidence_dir=str(roll.get("_evidenceDir") or "evidence/playtest-v3"))
evidence_hashes = set()
evidence_root_value = roll.get("_evidenceRoot")
evidence_root = Path(evidence_root_value).resolve() if evidence_root_value else None
required_sequences = [ref for row in required for ref in row.get("sequenceRefs") or []]
referenced_frames = {ref.get("postFrameRef") for ref in required_sequences}
referenced_events = {ref.get("eventRef") for ref in required_sequences}
frame_by_ref = {row.get("ref"): row for row in roll.get("frames") or [] if isinstance(row, dict)}
if referenced_frames and evidence_root is None:
return tester("缺 evidenceRoot无法复核动作后截图字节", "image_error")
for frame_ref in referenced_frames:
frame = frame_by_ref.get(frame_ref) or {}
path = (evidence_root / str(frame_ref or "")).resolve() if evidence_root else None
if (not frame_ref or not path or evidence_root not in path.parents
or not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != frame.get("hash")):
return tester(f"动作后截图缺失、越界或 hash 不一致:{frame_ref}", "image_error")
evidence_hashes.add(str(frame.get("hash")))
for event in events:
payload_canonical = event.get("payloadCanonical")
canonical_hash = (hashlib.sha256(payload_canonical.encode("utf-8")).hexdigest()
if isinstance(payload_canonical, str) else None)
if canonical_hash != event.get("payloadHash"):
return tester(f"game-event payloadCanonical hash 无法复核:seq={event.get('seq')}",
"event_contract_error", retryable=False)
if event.get("seq") in referenced_events:
evidence_hashes.add(str(event.get("payloadHash")))
if relation_errors:
facts, affected = _v3_relation_reject_facts(roll, relation_errors)
checks.update({
"requiredObligationsSatisfied": False, "referencesValid": True,
"sameActionEvidence": True, "noContradictions": False,
"noBlockingProblems": True, "actorHealthy": facts.get("actor") is not None,
"runnerHealthy": not roll.get("errors"), "judgeHealthy": facts.get("judge") is not None,
"judgeAccept": False,
})
evidence_refs = {
"actions": list(dict.fromkeys(v for row in required for v in row.get("actionRefs") or [])),
"frames": list(dict.fromkeys(v for row in required for v in row.get("postFrameRefs") or [])),
"events": [str(v) for v in dict.fromkeys(v for row in required for v in row.get("eventRefs") or [])],
}
return {
"pass": False, "checks": checks, "blockingProblems": [],
"contradictions": list(relation_errors),
"reason": "游戏自报完成态与同值/有序关系硬证矛盾",
"rollOutcome": "reject", "subtype": "completion_relation_contradiction",
"failedObligations": affected,
"failureSignature": "completion_relation_contradiction|" + "|".join(affected),
"verified": True, "repairEvidenceComplete": False, "facts": facts,
"evidenceHashes": sorted(evidence_hashes), "evidenceRefs": evidence_refs,
}
required_satisfied = bool(required) and all(row["status"] == "satisfied" for row in required)
consensus_reason = str(consensus_raw.get("reasonCode") or "")
consensus_conflicts = [str(v) for v in consensus_raw.get("conflicts") or []]
contradictions = ([text for row in obligations for text in row["contradictions"]]
+ (consensus_conflicts if consensus_reason in {
"judge_semantic_conflict", "evidence_contradiction"} else []))
blocking = ([text for row in obligations for text in row["blockingProblems"]]
+ ([consensus_reason] if judge_raw.get("decision") == "reject" else []))
actor_healthy = actor is not None
runner_healthy = not roll.get("errors")
judge_healthy = judge is not None and judge_raw.get("degraded") is not True
judge_accept = bool(judge_healthy and judge_raw.get("decision") == "accept")
checks.update({
"requiredObligationsSatisfied": required_satisfied,
"referencesValid": True, "sameActionEvidence": True,
"noContradictions": not contradictions, "noBlockingProblems": not blocking,
"actorHealthy": actor_healthy, "runnerHealthy": runner_healthy,
"judgeHealthy": judge_healthy, "judgeAccept": judge_accept,
})
facts = {"environment": roll.get("environment"), "actor": actor,
"actions": actions, "events": events, "proofObligations": obligations, "judge": judge,
"firstPlay": roll.get("firstPlay"), "proofPackageRef": str(roll.get("proofPackageRef") or ""),
"judgePackageRef": roll.get("judgePackageRef"), "proofRegistryVersion": roll.get("proofRegistryVersion"),
"briefRuleMatches": list(roll.get("briefRuleMatches") or [])}
internal = {"facts": facts, "evidenceHashes": sorted(evidence_hashes),
"evidenceRefs": {"actions": list(dict.fromkeys(v for row in required for v in row["actionRefs"])),
"frames": list(dict.fromkeys(v for row in required for v in row["postFrameRefs"])),
"events": [str(v) for v in dict.fromkeys(v for row in required for v in row["eventRefs"])]}}
if not runner_healthy:
return {"pass": False, "checks": checks, "blockingProblems": list(roll.get("errors") or []),
"contradictions": contradictions, "reason": ";".join(map(str, roll.get("errors") or [])),
"rollOutcome": "tester_error", "subtype": "runner_error", "infraRetryable": True,
"errors": list(roll.get("errors") or []), **internal}
if not judge_healthy or judge_raw.get("decision") == "tester_error":
return {"pass": False, "checks": checks,
"blockingProblems": [str(judge_raw.get("reason") or "Judge 未产出健康裁决")],
"contradictions": contradictions,
"reason": consensus_reason or "Judge 未产出健康裁决",
"rollOutcome": "tester_error", "subtype": "judge_error",
"infraRetryable": True, "errors": ["judge_error"], **internal}
judge_reference_errors = _v3_judge_text_reference_errors(roll)
if judge_reference_errors:
checks["referencesValid"] = False
checks["judgeHealthy"] = False
reason = ";".join(judge_reference_errors)
return {"pass": False, "checks": checks, "blockingProblems": judge_reference_errors,
"contradictions": contradictions, "reason": reason,
"rollOutcome": "tester_error", "subtype": "judge_reference_error",
"infraRetryable": False, "errors": judge_reference_errors, **internal}
if contradictions:
return {"pass": False, "checks": checks, "blockingProblems": blocking,
"contradictions": contradictions, "reason": "截图/事件/Judge 证据互相矛盾",
"rollOutcome": "inconclusive", "subtype": "evidence_contradiction", **internal}
failed = [row for row in required if row["status"] == "failed"]
if failed and judge_raw.get("decision") == "reject":
failed_ids = [row["id"] for row in failed]
signature = str(judge_raw.get("failureSignature") or consensus_raw.get("failureClass")
or "proof_obligation_failed") + "|" + "|".join(failed_ids)
return {"pass": False, "checks": checks, "blockingProblems": blocking, "contradictions": [],
"reason": consensus_reason or f"硬证义务失败:{','.join(failed_ids)}",
"rollOutcome": "reject", "subtype": str(consensus_raw.get("failureClass") or "proof_obligation_failed"),
"failedObligations": failed_ids, "failureSignature": signature, "verified": True,
"repairEvidenceComplete": bool(all(row.get("actionRefs") and row.get("postFrameRefs")
and row.get("eventRefs") for row in failed)), **internal}
# broken/hollow/off_brief 是 Judge 对整局的全局拒绝,不依赖某条 proof obligation 失败。
# 若把它降成 inconclusive第二掷只需补一张新图就可能错误翻案为 accept。
failure_class = str(consensus_raw.get("failureClass") or "")
if judge_raw.get("decision") == "reject" and failure_class in {
"broken", "hollow", "off_brief"}:
signature = str(judge_raw.get("failureSignature") or f"{failure_class}|global")
return {"pass": False, "checks": checks, "blockingProblems": blocking,
"contradictions": [],
"reason": consensus_reason or f"Judge 全局拒绝:{failure_class}",
"rollOutcome": "reject", "subtype": failure_class,
"failedObligations": [], "failureSignature": signature, "verified": True,
"repairEvidenceComplete": False, **internal}
if all(checks.values()):
return {"pass": True, "checks": checks, "blockingProblems": [], "contradictions": [],
"reason": "单掷硬证完整", "rollOutcome": "accept", "subtype": None, "verified": True, **internal}
return {"pass": False, "checks": checks, "blockingProblems": blocking, "contradictions": contradictions,
"reason": consensus_reason or "合法证据不足",
"rollOutcome": "inconclusive", "subtype": "proof_incomplete", **internal}
def _merge_candidate_v3(guards: list) -> dict:
"""按批准矩阵合并一至两掷;返回 contract merge 字段和下划线内部复核信息。"""
roll_refs = [f"roll-{index + 1}" for index in range(len(guards))]
def result(candidate, reason, *, conflicts=None, rescued=None, source=None, **internal):
return {"triggered": len(guards) > 1, "rollRefs": roll_refs, "mergeCandidate": candidate,
"conflicts": list(conflicts or []), "rescuedByRoll": rescued, "reason": reason,
"_sourceGuard": source, **{f"_{k}": v for k, v in internal.items()}}
if not guards:
return result("tester_error", "没有单掷结果", subtype="schema_error")
first = guards[0]
if len(guards) == 1:
return result(first["rollOutcome"], first.get("reason") or "第一掷直接形成候选", source=first,
subtype=first.get("subtype"),
verifiedReject=bool(first.get("verified") and first["rollOutcome"] == "reject"
and first.get("repairEvidenceComplete") is True),
failedObligations=first.get("failedObligations") or [])
second = guards[1]
a, b = first["rollOutcome"], second["rollOutcome"]
if "tester_error" in (a, b):
bad = first if a == "tester_error" else second
return result("tester_error", bad.get("reason") or "测试器错误不进入 gameplay 合并", source=bad,
subtype=bad.get("subtype"))
if a == "reject" and b == "accept":
return result("inconclusive", "一掷证实缺陷、另一掷接受", conflicts=["reject+accept"],
subtype="roll_conflict")
if a == "reject" and b == "reject":
if first.get("failureSignature") == second.get("failureSignature"):
return result("reject", first.get("reason") or "两掷硬证一致拒绝", source=second,
subtype=first.get("subtype"),
verifiedReject=bool(first.get("verified") and second.get("verified")
and first.get("repairEvidenceComplete") is True
and second.get("repairEvidenceComplete") is True),
failedObligations=first.get("failedObligations") or [])
return result("inconclusive", "两掷拒绝原因冲突", conflicts=["reject-signature-conflict"],
subtype="roll_conflict")
if a == "inconclusive" and b == "accept":
if first.get("subtype") in ("evidence_contradiction", "roll_conflict"):
return result("inconclusive", first.get("reason") or "第一掷证据矛盾,禁止自动翻案",
conflicts=["evidence-contradiction"], subtype=first.get("subtype"))
new_evidence = set(second.get("evidenceHashes") or []) - set(first.get("evidenceHashes") or [])
if new_evidence:
return result("accept", "第二掷以新增硬证补齐第一掷缺口", rescued=2, source=second,
newEvidenceHashes=sorted(new_evidence))
return result("inconclusive", "第二掷未新增硬证", subtype="proof_not_new")
if a == "inconclusive" and b == "reject":
return result("inconclusive", "第一掷证据不足、第二掷拒绝,单掷拒绝不得覆盖不确定性",
conflicts=["inconclusive+reject"], subtype="roll_conflict")
if a == "inconclusive" and b == "inconclusive":
return result("inconclusive", "两掷均未形成完整证据", subtype="proof_incomplete")
if a == "accept" and b == "accept":
return result("accept", "两掷均完整证明闭环", source=second)
return result("inconclusive", f"未定义合并:{a}+{b}", conflicts=[f"{a}+{b}"], subtype="roll_conflict")
def _v3_failure_projection(outcome: str, *, floor: dict, merge: dict) -> tuple:
"""把 v3 outcome 单向投影到旧 failureLayer/failureReason旧字段不再自行判定。"""
if outcome == "accept":
return "none", None
if merge.get("_subtype") == "floor_gate":
failed = [name for name, result in (floor.get("gates") or {}).items() if result.get("pass") is not True]
reason = merge.get("reason") or f"四门失败:{'/'.join(failed)}"
return "mechanical", reason
if outcome == "reject":
reason = merge.get("reason") or "硬证证明玩法闭环失败"
return "gameplay", reason
reason = merge.get("reason") or ("证据不足或冲突" if outcome == "inconclusive" else "验收基础设施错误")
return "tester_degraded", reason
def _repair_feedback_v3(merge: dict, rolls: list) -> str:
"""生成可审计 repair 契约;只引用失败义务及其 action/frame/event 硬证。"""
failed = merge.get("_failedObligations") or []
source = merge.get("_sourceGuard") or {}
facts = source.get("facts") or {}
obligations = [row for row in facts.get("proofObligations") or [] if row.get("id") in failed]
action_refs = sorted({ref for row in obligations for ref in row.get("actionRefs") or []})
frame_refs = sorted({ref for row in obligations for ref in row.get("postFrameRefs") or []})
event_refs = sorted({str(ref) for row in obligations for ref in row.get("eventRefs") or []})
failed_gates = [str(v) for v in merge.get("_failedGates") or []]
floor_refs = [f"floor/verdict.json#/guards/{name}" for name in failed_gates]
return ("[v3 已验证拒绝·仅一次修复契约] "
f"失败门={','.join(failed_gates) or '-'}floorRefs={','.join(floor_refs) or '-'}"
f"失败义务={','.join(map(str, failed)) or '-'}actionRefs={','.join(action_refs) or '-'}"
f"postFrameRefs={','.join(frame_refs) or '-'}eventRefs={','.join(event_refs) or '-'}"
f"事实={merge.get('reason') or '硬证证明闭环失败'}")
def _v3_guard_contract(guard: dict | None) -> dict:
"""删除 Python 内部复核字段,只留下 schema guard 五字段。"""
guard = guard or {}
checks = guard.get("checks") if isinstance(guard.get("checks"), dict) else {}
return {"pass": bool(guard.get("pass")), "checks": {key: bool(checks.get(key)) for key in _V3_GUARD_CHECKS},
"blockingProblems": [str(v) for v in guard.get("blockingProblems") or []],
"contradictions": [str(v) for v in guard.get("contradictions") or []],
"reason": str(guard.get("reason") or "")}
def _v3_merge_contract(merge: dict) -> dict:
"""删除 merge 下划线内部字段,保证封存对象只含 contract 字段。"""
return {"triggered": bool(merge.get("triggered")), "rollRefs": list(merge.get("rollRefs") or []),
"mergeCandidate": str(merge.get("mergeCandidate") or "tester_error"),
"conflicts": [str(v) for v in merge.get("conflicts") or []],
"rescuedByRoll": merge.get("rescuedByRoll"), "reason": str(merge.get("reason") or "")}
def _v3_first_play(facts: dict, *, accepted: bool) -> dict:
"""保留 runner 已通过 semantic 校验的冷启动实测;终裁只收紧闭环结论,不重算时间。"""
source = facts.get("firstPlay") if isinstance(facts.get("firstPlay"), dict) else {}
loop_closed = bool(accepted and source.get("loopClosed") is True)
return {
"interactiveAtVirtualMs": source.get("interactiveAtVirtualMs"),
"firstFeedbackAtVirtualMs": source.get("firstFeedbackAtVirtualMs"),
"loopClosedAtVirtualMs": source.get("loopClosedAtVirtualMs") if loop_closed else None,
"loopClosed": loop_closed,
"proofObligationRefs": list(source.get("proofObligationRefs") or []) if loop_closed else [],
}
def _v3_empty_judge_slot_ref(roll_no: int, suffix: str) -> dict:
"""测试器错误尚未产出 Judge 槽时填充显式占位;最终 outcome 仍为 tester_error。"""
base = f"runner-error/roll-{roll_no}/judge-{suffix}"
return {"rawRef": f"{base}/raw", "rawHash": "0" * 64,
"parsedRef": f"{base}/parsed", "parsedHash": "0" * 64,
"normalizedRef": f"{base}/normalized", "resultHash": "0" * 64}
def _v3_roll_contract(record: dict) -> dict:
"""把内部 raw/attempts/guard 记录投影成 schema roll。"""
raw = record.get("raw") if isinstance(record.get("raw"), dict) else {}
guard = record.get("rollGuard") or {}
facts = guard.get("facts") or {}
actor = facts.get("actor") or {}
roll_no = int(record.get("roll") or raw.get("roll") or 1)
errors = [str(v) for v in guard.get("errors") or []]
attempt_cost = sum(float(item.get("costRmb") or 0.0) for item in record.get("attempts") or []
if isinstance(item, dict))
return {"rollId": f"roll-{roll_no}", "roll": roll_no,
"requestedGameSeed": int(raw.get("requestedSeed") or actor.get("requestedGameSeed") or 0),
"actualGameSeed": int(raw.get("actualSeed") or actor.get("actualGameSeed") or 0),
"policySeed": int(raw.get("policySeed") or actor.get("policySeed") or 0),
"actorSessionId": str(actor.get("sessionId") or f"runner-error-roll-{roll_no}"),
"proofPackageRef": str(raw.get("proofPackageRef") or facts.get("proofPackageRef")
or f"playtest-v3/roll-{roll_no}/runner-error.json"),
"proofPackageHash": str(raw.get("proofPackageHash") or "0" * 64),
"judgePackageRef": str(raw.get("judgePackageRef") or facts.get("judgePackageRef") or "runner-error"),
"judgePackageHash": str(raw.get("judgePackageHash") or "0" * 64),
"judgeARef": raw.get("judgeARef") or _v3_empty_judge_slot_ref(roll_no, "a"),
"judgeAHash": str(raw.get("judgeAHash") or "0" * 64),
"judgeBRef": raw.get("judgeBRef") or _v3_empty_judge_slot_ref(roll_no, "b"),
"judgeBHash": str(raw.get("judgeBHash") or "0" * 64),
"judgeConsensusRef": str(raw.get("judgeConsensusRef") or "runner-error"),
"judgeConsensusHash": str(raw.get("judgeConsensusHash") or "0" * 64),
"costReservationRef": str(raw.get("costReservationRef") or "runner-error"),
"costReservationHash": str(raw.get("costReservationHash") or "0" * 64),
"rollGuard": _v3_guard_contract(guard),
"rollOutcome": str(guard.get("rollOutcome") or "tester_error"), "errors": errors,
"costRmb": _quantize_rmb_v3(
attempt_cost if record.get("attempts") else float(raw.get("costRmb") or 0.0),
f"roll-{roll_no}.costRmb",
)}
def _final_postguard_v3(*, mode: str, floor: dict, merge: dict, rolls: list,
repair_count: int, cost_rmb: float, cost_cap_rmb: float,
parent_chain_cost_rmb: float = 0.0, parent_chain_cost_cap_rmb: float = 15.0) -> dict:
"""v3 唯一 decision 写入口;从原始 guard/facts 重算接受条件,不信 merge 自报。"""
cost_rmb = _finite_nonnegative_rmb_v3(cost_rmb, "costRmb")
cost_cap_rmb = _finite_positive_rmb_v3(cost_cap_rmb, "costCapRmb")
parent_chain_cost_rmb = _finite_nonnegative_rmb_v3(
parent_chain_cost_rmb, "preAcceptanceChainCostRmb")
parent_chain_cost_cap_rmb = _finite_positive_rmb_v3(
parent_chain_cost_cap_rmb, "parentChainCostCapRmb")
outcome = str(merge.get("mergeCandidate") or "tester_error")
if outcome not in _V3_OUTCOMES:
merge = _merge_candidate_v3([]); merge["reason"] = "未知 mergeCandidate"; merge["_subtype"] = "unknown_schema"
outcome = "tester_error"
if cost_rmb > cost_cap_rmb + 1e-9:
outcome = "tester_error"
merge.update({"mergeCandidate": outcome, "reason": f"验收成本 ¥{cost_rmb:.5f} 超硬帽 ¥{cost_cap_rmb:.2f}",
"_subtype": "cost_cap_exceeded", "_sourceGuard": None})
chain_cost = float(parent_chain_cost_rmb or 0.0) + float(cost_rmb or 0.0)
if chain_cost > parent_chain_cost_cap_rmb + 1e-9:
outcome = "tester_error"
merge.update({"mergeCandidate": outcome,
"reason": f"parentRun 全链成本 ¥{chain_cost:.5f} 超硬帽 ¥{parent_chain_cost_cap_rmb:.2f}",
"_subtype": "parent_chain_cost_cap_exceeded", "_sourceGuard": None})
if floor.get("pass") is not True and merge.get("_subtype") != "floor_gate":
outcome = "tester_error"
merge.update({"mergeCandidate": outcome, "reason": "四门证据不完整",
"_subtype": "floor_evidence_missing", "_sourceGuard": None})
verified_accept = [record.get("rollGuard") or {} for record in rolls
if (record.get("rollGuard") or {}).get("rollOutcome") == "accept"
and (record.get("rollGuard") or {}).get("verified") is True]
source_guard = merge.get("_sourceGuard") or (verified_accept[-1] if verified_accept else {})
facts = source_guard.get("facts") or {}
source_checks = source_guard.get("checks") if isinstance(source_guard.get("checks"), dict) else {}
final_checks = {key: bool(source_checks.get(key)) for key in _V3_GUARD_CHECKS}
final_checks["floorPass"] = floor.get("pass") is True
accept_recomputed = bool(outcome == "accept" and verified_accept and all(final_checks.values()))
accept_recomputed = accept_recomputed and not merge.get("conflicts")
if outcome == "accept" and merge.get("rescuedByRoll") == 2:
second = next(((record.get("rollGuard") or {}) for record in rolls if record.get("roll") == 2), {})
first = next(((record.get("rollGuard") or {}) for record in rolls if record.get("roll") == 1), {})
new_evidence = set(second.get("evidenceHashes") or []) - set(first.get("evidenceHashes") or [])
accept_recomputed = accept_recomputed and bool(new_evidence) and new_evidence == set(merge.get("_newEvidenceHashes") or [])
if outcome == "accept" and not accept_recomputed:
outcome = "tester_error"
merge.update({"mergeCandidate": outcome, "reason": "finalPostguard 无法从原始硬证复算 accept",
"_subtype": "final_guard_failed", "_sourceGuard": source_guard})
shadow = mode == "v3_shadow"
decision_accepted = outcome == "accept"
authoritative_accepted = decision_accepted and not shadow
repair_evidence_complete = bool(
merge.get("_subtype") == "floor_gate" or source_guard.get("repairEvidenceComplete") is True)
repair_eligible = bool(outcome == "reject" and merge.get("_verifiedReject")
and repair_evidence_complete and repair_count == 0)
failure_layer, failure_reason = _v3_failure_projection(outcome, floor=floor, merge=merge)
first_play = _v3_first_play(facts, accepted=decision_accepted)
final_guard = {"pass": decision_accepted, "mergeCandidate": outcome, "checks": final_checks,
"blockingProblems": [str(v) for v in source_guard.get("blockingProblems") or []],
"contradictions": [str(v) for v in source_guard.get("contradictions") or []],
"writeAuthority": True, "reason": merge.get("reason") or ("硬证完整" if decision_accepted else failure_reason or "")}
repair_feedback = _repair_feedback_v3(merge, rolls) if repair_eligible else None
decision = {
"outcome": outcome, "accepted": decision_accepted,
"shadowAccepted": decision_accepted if shadow else None,
"publishFrozen": shadow or not decision_accepted,
"failure": {"layer": failure_layer, "subtype": merge.get("_subtype"), "reason": failure_reason},
"repairCountAcrossParentChain": repair_count, "repairEligible": repair_eligible,
"repairFeedback": repair_feedback, "rescuedByRoll": merge.get("rescuedByRoll"),
"costRmb": _quantize_rmb_v3(cost_rmb),
"parentChainCostRmb": _quantize_rmb_v3(chain_cost, "parentChainCostRmb"),
}
repair = {"countAcrossParentChain": repair_count, "eligible": repair_eligible,
"attempted": repair_count > 0, "reason": repair_feedback or ("已接受" if decision_accepted else failure_reason or "不可修复")}
judge_contract = facts.get("judge") if isinstance(facts.get("judge"), dict) else None
failed_gates = [name for name, result in (floor.get("gates") or {}).items() if result.get("pass") is not True]
failure_projection = {"layer": failure_layer, "reason": failure_reason, "failedGates": failed_gates}
judge_projection = {
"ran": bool(rolls), "verdict": outcome, "accepted": outcome == "accept",
"degraded": outcome == "tester_error", "reason": failure_reason,
"acceptanceVersion": mode, "schemaVersion": _PLAYTEST_V3_SCHEMA,
}
playtest_projection = {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "outcome": outcome, "accepted": outcome == "accept",
"rollCount": len(rolls), "rescuedByRoll": merge.get("rescuedByRoll"),
"costRmb": _quantize_rmb_v3(cost_rmb), "proofComplete": decision_accepted,
"firstPlay": {"playableAtMs": first_play["interactiveAtVirtualMs"],
"firstFeedbackMs": first_play["firstFeedbackAtVirtualMs"],
"loopClosed": first_play["loopClosed"], "proofRefs": first_play["proofObligationRefs"]},
"evidenceRefs": source_guard.get("evidenceRefs") or {"actions": [], "frames": [], "events": []},
}
compatibility = {
"accepted": authoritative_accepted,
"ok": authoritative_accepted,
"acceptanceVersion": mode,
"publishFrozen": shadow or not decision_accepted,
"schemaVersion": _PLAYTEST_V3_SCHEMA,
"sourceRollId": (judge_contract or {}).get("sourceRollId"),
}
return {"outcome": outcome, "merge": merge, "decision": decision, "repair": repair,
"compatibility": compatibility, "finalPostguard": final_guard, "firstPlay": first_play, "facts": facts}
async def run_playtest_v3(request: dict, *, run_dir, artifact_hash: str, brief_hash: str,
cfg=None) -> dict:
"""执行 v3 一至两掷、基础设施原条件重试和成本硬帽;返回 rollGuard 与 mergeCandidate。"""
cfg = cfg or _acceptance_v3_cfg()
game_id = str(request.get("gameId") or "")
brief = str(request.get("brief") or "")
identity = request.get("acceptanceIdentity") if isinstance(request.get("acceptanceIdentity"), dict) else {}
provenance = request.get("acceptanceProvenance") if isinstance(request.get("acceptanceProvenance"), dict) else {}
genre = str(identity.get("genre") or "unknown")
template_route = str(identity.get("templateRoute") or "")
proof_profile_id = str(identity.get("proofProfileId") or "")
proof_registry_version = str(identity.get("proofRegistryVersion") or "")
task_binding_hash = str(identity.get("taskBindingHash") or "")
interaction_binding = (identity.get("interactionBinding")
if isinstance(identity.get("interactionBinding"), dict) else None)
acceptance_request_hash = str(identity.get("acceptanceRequestHash") or "")
acceptance_mode = str(request.get("acceptanceMode") or cfg.get("mode") or "v3_shadow")
evidence_mode = str(request.get("evidenceMode") or "native")
run_id = str(request.get("runId") or Path(run_dir).name)
seed_value = request.get("gameSeed") if request.get("gameSeed") is not None else time.time_ns() % 1_000_000_000
if request.get("policySeed") is not None:
policy_seed_value = request.get("policySeed")
elif isinstance(seed_value, int) and not isinstance(seed_value, bool):
policy_seed_value = seed_value ^ 0x5F3759DF
else:
policy_seed_value = None
valid_seed = isinstance(seed_value, int) and not isinstance(seed_value, bool) and 0 <= seed_value <= 0xFFFFFFFF
valid_policy_seed = (isinstance(policy_seed_value, int) and not isinstance(policy_seed_value, bool)
and 0 <= policy_seed_value <= 0xFFFFFFFF)
seed = int(seed_value) if valid_seed else 0
policy_seed = int(policy_seed_value) if valid_policy_seed else 0
rolls = []
guards = []
total_cost = 0.0
try:
registry = _v3_resolve_registry(
genre, brief, template_route=template_route, proof_profile_id=proof_profile_id,
proof_registry_version=proof_registry_version,
)
required_provenance = (
"acceptanceRequestFile", "provenanceManifestFile", "provenanceManifestHash",
)
if any(not provenance.get(field) for field in required_provenance):
raise ValueError("acceptance provenance 缺 request/manifest 路径或 manifest hash")
binding_provenance_fields = ("interactionBindingFile", "interactionBindingFileHash")
if interaction_binding is None:
if any(provenance.get(field) is not None for field in binding_provenance_fields):
raise ValueError("interactionBinding=null 时不得携带独立 binding 文件或 hash")
elif any(not provenance.get(field) for field in binding_provenance_fields):
raise ValueError("非空 interactionBinding 缺独立 binding 文件或 hash")
except Exception as e: # noqa: BLE001 —— 未知品类/坏注册表是 tester_error禁止猜测义务后继续
return {"rolls": [], "rollGuards": [],
"merge": {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error",
"conflicts": [], "rescuedByRoll": None,
"reason": f"proof registry 解析失败:{type(e).__name__}: {e}",
"_subtype": "schema_error", "_sourceGuard": None},
"costRmb": 0.0, "registry": {"version": "unknown", "briefRuleMatches": [], "obligations": []}}
if not valid_seed or not valid_policy_seed:
return {"rolls": [], "rollGuards": [],
"merge": {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error",
"conflicts": [], "rescuedByRoll": None,
"reason": "gameSeed/policySeed 必须是 uint32 整数",
"_subtype": "seed_mismatch", "_sourceGuard": None},
"costRmb": 0.0, "registry": registry}
# brief 走临时文件,避免命令行转义和长度截断;两掷及 infra retry 读取同一真实题面。
brief_file = None
with tempfile.NamedTemporaryFile("w", suffix=".brieftxt", delete=False, encoding="utf-8") as bf:
bf.write(brief)
brief_file = bf.name
explicit_port = request.get("port")
explicit_cdp_port = request.get("cdpPort")
def ports_for_attempt(attempt_no: int) -> tuple:
derived_port, derived_cdp = _derive_playtest_v3_ports(run_id, attempt_no)
# 显式端口只约束首尝试若它冲突infra retry 必须换到确定性的备用槽。
if attempt_no == 0:
return (int(explicit_port) if explicit_port is not None else derived_port,
int(explicit_cdp_port) if explicit_cdp_port is not None else derived_cdp)
return derived_port, derived_cdp
async def _execute(roll_no: int, game_seed: int, actor_seed: int, attempt_no: int):
remaining = max(0.0, cfg["cost_cap_rmb"] - total_cost)
port, cdp_port = ports_for_attempt(attempt_no)
raw = await asyncio.to_thread(
_run_playtest_v3_roll_sync, game_id, run_id=run_id, roll=roll_no,
requested_seed=game_seed, policy_seed=actor_seed, brief_hash=brief_hash,
artifact_hash=artifact_hash, brief_file=brief_file, genre=genre,
template_route=template_route, proof_profile_id=proof_profile_id,
proof_registry_version=proof_registry_version,
task_binding_hash=task_binding_hash,
acceptance_request_hash=acceptance_request_hash,
acceptance_request_file=str(provenance["acceptanceRequestFile"]),
provenance_manifest_file=str(provenance["provenanceManifestFile"]),
provenance_manifest_hash=str(provenance["provenanceManifestHash"]),
acceptance_mode=acceptance_mode, evidence_mode=evidence_mode,
evidence_dir=str(run_dir), model_name=cfg["model"], port=port, cdp_port=cdp_port,
timeout=cfg["timeout_s"], remaining_cost_rmb=remaining, cfg=cfg,
legacy_mapping_file=request.get("legacyMappingFile"),
interaction_binding_file=provenance.get("interactionBindingFile"),
interaction_binding_file_hash=provenance.get("interactionBindingFileHash"),
interaction_profile_id=(interaction_binding or {}).get("interactionProfileId"),
interaction_registry_version=(interaction_binding or {}).get("interactionRegistryVersion"),
interaction_binding_hash=(interaction_binding or {}).get("interactionBindingHash"))
raw["_evidenceDir"] = str(Path(run_dir) / f"roll-{roll_no}")
return raw
async def _with_infra_retry(roll_no: int, game_seed: int, actor_seed: int):
nonlocal total_cost
attempts = []
raw = await _execute(roll_no, game_seed, actor_seed, 0)
attempts.append(raw)
total_cost += float(raw.get("costRmb") or 0.0)
guard = _roll_guard_v3(raw, artifact_hash=artifact_hash, brief_hash=brief_hash,
floor_pass=True, registry=registry, cfg=cfg,
task_binding_hash=task_binding_hash,
acceptance_request_hash=acceptance_request_hash,
evidence_mode=evidence_mode)
if (guard.get("rollOutcome") == "tester_error" and guard.get("infraRetryable")
and cfg.get("infra_retry", 1) > 0 and total_cost < cfg["cost_cap_rmb"]):
# seed/policy/run/产物保持不变,仅端口槽按 attempt=1 切换,避免端口冲突原样复撞。
raw = await _execute(roll_no, game_seed, actor_seed, 1)
attempts.append(raw)
total_cost += float(raw.get("costRmb") or 0.0)
guard = _roll_guard_v3(raw, artifact_hash=artifact_hash, brief_hash=brief_hash,
floor_pass=True, registry=registry, cfg=cfg,
task_binding_hash=task_binding_hash,
acceptance_request_hash=acceptance_request_hash,
evidence_mode=evidence_mode)
return raw, guard, attempts
try:
raw1, guard1, attempts1 = await _with_infra_retry(1, seed, policy_seed)
rolls.append({"roll": 1, "raw": raw1, "attempts": attempts1, "rollGuard": guard1})
guards.append(guard1)
if (guard1["rollOutcome"] in ("reject", "inconclusive") and cfg.get("second_roll", True)
and total_cost < cfg["cost_cap_rmb"]):
second_seed = (seed + 7919) & 0xFFFFFFFF
second_policy_seed = (policy_seed + 104729) & 0xFFFFFFFF
assert second_seed != seed and second_policy_seed != policy_seed
raw2, guard2, attempts2 = await _with_infra_retry(2, second_seed, second_policy_seed)
rolls.append({"roll": 2, "raw": raw2, "attempts": attempts2, "rollGuard": guard2})
guards.append(guard2)
merge = _merge_candidate_v3(guards)
if total_cost > cfg["cost_cap_rmb"]:
merge.update({"mergeCandidate": "tester_error", "_subtype": "cost_cap_exceeded",
"reason": f"验收成本 ¥{total_cost:.5f} 超硬帽 ¥{cfg['cost_cap_rmb']:.2f}",
"_sourceGuard": None})
return {"rolls": rolls, "rollGuards": guards, "merge": merge,
"costRmb": _quantize_rmb_v3(total_cost), "registry": registry}
finally:
try:
Path(brief_file).unlink(missing_ok=True)
except OSError:
pass
def _v3_floor_contract(verdict) -> tuple:
"""把旧九门中的 A/B/C/D 投影成 contract gateResult并区分明确失败与证据缺失。"""
guards = verdict.get("guards") if isinstance(verdict, dict) and isinstance(verdict.get("guards"), dict) else {}
gates = {}
missing = []
for name in _FLOOR_GATES:
source = guards.get(name)
if not isinstance(source, dict) or "pass" not in source:
missing.append(name)
source = {}
gates[name] = {"pass": source.get("pass") is True,
"evidenceRef": f"floor/verdict.json#/guards/{name}",
"measurement": source}
return {"pass": not missing and all(result["pass"] for result in gates.values()), "gates": gates}, missing
def _v3_default_environment(artifact_hash: str, cfg: dict) -> dict:
"""无 Actor 的机械拒绝/测试器错误仍需完整环境结构unknown 值明确表示未取到,不冒充健康证据。"""
return {"host": "unknown-runner", "chromeVersion": "unknown",
"viewport": {"width": 390, "height": 844, "dpr": 1}, "buildRef": artifact_hash,
"virtualTime": {"mode": "cdp", "actionQuantumMs": cfg["action_quantum_ms"],
"waitMinMs": cfg["wait_min_ms"], "waitMaxMs": cfg["wait_max_ms"]}}
def _v3_assemble_payload(base: dict, *, floor: dict, play: dict, final: dict,
registry: dict, cfg: dict) -> dict:
"""组 DECIDED 完整封存对象;所有字段都来自 floor、选中单掷 facts 或显式 unknown 降级。"""
facts = final.get("facts") if isinstance(final.get("facts"), dict) else {}
rolls = [_v3_roll_contract(record) for record in play.get("rolls") or []]
top_cost = _quantize_rmb_v3(
sum(float(record.get("costRmb") or 0.0) for record in rolls), "playtest.costRmb")
decision = dict(final["decision"]); decision["costRmb"] = top_cost
compatibility = dict(final["compatibility"])
proof_obligations = list(facts.get("proofObligations") or (
registry.get("obligations") if floor.get("pass") is True else []))
# registry 内的 evidence selector 只供 Python guard 复核,不属于顶层 proofObligation 契约字段。
proof_obligations = [{key: value for key, value in row.items() if key != "evidence"}
for row in proof_obligations if isinstance(row, dict)]
return {
**base, "outcome": final["outcome"],
"environment": facts.get("environment") or _v3_default_environment(base["artifactHash"], cfg),
"floor": floor, "actor": facts.get("actor"), "actions": list(facts.get("actions") or []),
"events": list(facts.get("events") or []),
"proofRegistryVersion": str(facts.get("proofRegistryVersion") or registry.get("version") or "unknown"),
"briefRuleMatches": list(facts.get("briefRuleMatches") or registry.get("briefRuleMatches") or []),
"proofObligations": proof_obligations,
"firstPlay": final["firstPlay"], "rolls": rolls, "merge": _v3_merge_contract(final["merge"]),
"sourceRollId": (facts.get("judge") or {}).get("sourceRollId"),
"judge": facts.get("judge"), "repair": final["repair"],
"costRmb": top_cost, "finalPostguard": final["finalPostguard"], "decision": decision,
"compatibility": compatibility,
}
def validate_acceptance_v3_payload(payload: dict) -> list:
"""调用 canonical validate.py 的 schema + semantic 校验;运行时与测试共用同一权威规则。"""
import importlib.util # noqa: PLC0415 —— 避免模块顶层把 contracts 变成 Python 包依赖
try:
validator_path = _V3_CONTRACT_DIR / "validate.py"
spec = importlib.util.spec_from_file_location("play_loop_validate_runtime", validator_path)
if spec is None or spec.loader is None:
return ["无法加载 canonical validate.py"]
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
schema = json.loads(_V3_EVIDENCE_SCHEMA.read_text(encoding="utf-8"))
errors = module.validate(schema, payload, schema)
if not errors:
errors += module._semantic_validate(schema, payload)
return errors
except Exception as e: # noqa: BLE001 —— 校验器自身不可用必须 fail-closed消费方不应因异常放行
return [f"canonical v3 validator 不可用:{type(e).__name__}: {e}"]
def is_v3_publishable(payload: dict) -> bool:
"""active v3 唯一发布谓词缺失、shadow、四态非 accept 或语义脏对象一律 false。"""
if validate_acceptance_v3_payload(payload):
return False
decision = payload.get("decision") or {}
compatibility = payload.get("compatibility") or {}
return bool(payload.get("acceptanceMode") == "v3" and payload.get("outcome") == "accept"
and decision.get("accepted") is True and decision.get("publishFrozen") is False
and compatibility.get("accepted") is True and compatibility.get("ok") is True
and compatibility.get("publishFrozen") is False)
def is_v3_repair_authorized(payload: dict) -> bool:
"""唯一修复授权谓词;除 contract 合规外,还复核 mechanical/gameplay 硬证与 repair 镜像。"""
if validate_acceptance_v3_payload(payload):
return False
decision = payload.get("decision") or {}
repair = payload.get("repair") or {}
merge = payload.get("merge") or {}
final_guard = payload.get("finalPostguard") or {}
feedback = decision.get("repairFeedback")
mirrors = (payload.get("outcome") == "reject" and merge.get("mergeCandidate") == "reject"
and final_guard.get("mergeCandidate") == "reject" and final_guard.get("pass") is False
and decision.get("repairEligible") is True and repair.get("eligible") is True
and decision.get("repairCountAcrossParentChain") == 0
and repair.get("countAcrossParentChain") == 0 and repair.get("attempted") is False
and isinstance(feedback, str) and feedback and repair.get("reason") == feedback)
if not mirrors:
return False
floor = payload.get("floor") or {}
failed_gates = [name for name, result in (floor.get("gates") or {}).items() if result.get("pass") is not True]
if floor.get("pass") is not True:
return bool(failed_gates and all(f"floor/verdict.json#/guards/{name}" in feedback for name in failed_gates))
failed = [row for row in payload.get("proofObligations") or []
if row.get("required") is True and row.get("status") == "failed"]
return bool(failed and all(row.get("actionRefs") and row.get("postFrameRefs") and row.get("eventRefs")
for row in failed))
def _safe_v3_frame_ref(value) -> str:
"""把契约内截图引用收敛为 run 目录内的规范 POSIX 相对路径。"""
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
raise ValueError(f"截图引用不是安全相对路径:{value!r}")
path = PurePosixPath(value)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise ValueError(f"截图引用越界:{value}")
return path.as_posix()
def _verify_v3_sealed_frames(payload: dict, snapshot: artifact_snapshot.ArtifactSnapshot) -> None:
"""复核封存 decision 中全部 action pre/post 截图的真实字节与 SHA-256。"""
roll_ids = []
for roll in payload.get("rolls") or []:
roll_id = roll.get("rollId") if isinstance(roll, dict) else None
if not isinstance(roll_id, str) or not _is_safe_game_id_v3(roll_id):
raise ValueError(f"封存结果 rollId 非法:{roll_id!r}")
roll_ids.append(roll_id)
post_refs = {}
for action in payload.get("actions") or []:
if not isinstance(action, dict):
raise ValueError("封存结果 action 不是对象")
action_id = str(action.get("actionId") or "")
for field in ("preFrameRef", "postFrameRef"):
frame = action.get(field) if isinstance(action.get(field), dict) else {}
relative = _safe_v3_frame_ref(frame.get("path"))
expected_hash = frame.get("hash")
if not _is_sha256_v3(expected_hash):
raise ValueError(f"封存截图 hash 非法:{action_id}/{field}")
matched = False
for roll_id in roll_ids:
data = snapshot.files.get(f"{roll_id}/{relative}")
if data is not None and hashlib.sha256(data).hexdigest() == expected_hash:
matched = True
break
if not matched:
raise ValueError(f"封存截图缺失、越界或 hash 不一致:{action_id}/{field}:{relative}")
if field == "postFrameRef":
post_refs[relative] = expected_hash
# proof obligation 只允许引用已经逐字节复核过的 action post frame不能另指一条未审计路径。
for obligation in payload.get("proofObligations") or []:
if not isinstance(obligation, dict):
continue
for value in obligation.get("postFrameRefs") or []:
relative = _safe_v3_frame_ref(value)
if relative not in post_refs:
raise ValueError(f"proof obligation 引用了未复核截图:{relative}")
def _load_verified_v3_sealed(evidence_root: Path, run_dir: Path) -> dict:
"""从不可变 run 快照加载 decision并复核契约、目录边界和全部截图证据。"""
evidence_root = _assert_no_symlink_components(evidence_root, require_exists=True)
run_dir = _assert_no_symlink_components(run_dir, require_exists=True)
if run_dir.parent != evidence_root:
raise ValueError("decision runDir 不在声明的 evidenceRoot 直接子目录")
snapshot = artifact_snapshot.capture_artifact_snapshot(run_dir)
decision_bytes = snapshot.files.get("decision.json")
if decision_bytes is None:
raise ValueError("decision.json 不存在或不是普通文件")
try:
payload = json.loads(decision_bytes.decode("utf-8"))
except (UnicodeError, ValueError) as exc:
raise ValueError(f"decision.json 不可解析:{type(exc).__name__}: {exc}") from exc
if not isinstance(payload, dict):
raise ValueError("decision.json 顶层不是对象")
errors = validate_acceptance_v3_payload(payload)
if errors:
raise ValueError("封存结果 contract/semantic 非法:" + ";".join(errors[:6]))
if payload.get("runId") != run_dir.name:
raise ValueError("封存结果 runId 与目录不一致")
_verify_v3_sealed_frames(payload, snapshot)
return payload
def _verify_v3_sealed_reference_provenance(
run_dir: Path, identity: dict, artifact_hash: str,
expected_receipts: list[dict] | None) -> None:
"""复核 sealed run 的 /3 或 /4 provenance并把 /4 回执闭包与本次独立复验全等对账。"""
run_dir = _assert_no_symlink_components(run_dir, require_exists=True)
snapshot = artifact_snapshot.capture_artifact_snapshot(run_dir)
manifest_bytes = snapshot.files.get("acceptance-provenance.json")
if manifest_bytes is None:
raise ValueError("sealed run 缺 acceptance-provenance.json")
try:
manifest = json.loads(manifest_bytes.decode("utf-8"))
except (UnicodeError, ValueError) as exc:
raise ValueError(f"sealed acceptance provenance 不可解析:{type(exc).__name__}: {exc}") from exc
if not isinstance(manifest, dict):
raise ValueError("sealed acceptance provenance 顶层不是对象")
try:
canonical_manifest = _stable_json_bytes_v3(manifest)
except (TypeError, ValueError) as exc:
raise ValueError(f"sealed acceptance provenance 不是 canonical JSON:{exc}") from exc
if canonical_manifest != manifest_bytes:
raise ValueError("sealed acceptance provenance 不是 canonical JSON")
schema = manifest.get("schemaVersion")
if schema == "acceptance-provenance/4":
schema_errors = _validate_v3_schema_only(_V4_ACCEPTANCE_PROVENANCE_SCHEMA, manifest)
elif schema == "acceptance-provenance/3":
schema_errors = _validate_v3_instance(_V3_ACCEPTANCE_PROVENANCE_SCHEMA, manifest)
else:
raise ValueError(f"sealed acceptance provenance schema 未知:{schema!r}")
if schema_errors:
raise ValueError("sealed acceptance provenance contract 非法:" + ";".join(schema_errors[:6]))
for field in (
"gameId", "briefHash", "genre", "templateRoute", "proofProfileId",
"proofRegistryVersion", "taskBindingHash", "interactionBinding", "acceptanceRequestHash",
"sourceArtifactHash", "parentAcceptanceRequestHash", "repairOrdinal",
"designRef", "referenceAssetRecordIds", "consumerRef",
):
if manifest.get(field) != identity.get(field):
raise ValueError(f"sealed provenance {field} 与当前 acceptance identity 不一致")
expected_binding = identity.get("interactionBinding")
binding_bytes = snapshot.files.get("interaction-binding.json")
if expected_binding is None:
if binding_bytes is not None:
raise ValueError("sealed provenance 的 null interactionBinding 不得残留独立 binding 文件")
elif binding_bytes != _stable_json_bytes_v3(expected_binding):
raise ValueError("sealed provenance 的独立 interaction binding 缺失或漂移")
if manifest.get("artifactHash") != artifact_hash:
raise ValueError("sealed provenance artifactHash 与当前产物不一致")
if schema == "acceptance-provenance/3":
if expected_receipts is not None:
raise ValueError("当前请求要求 /4但 sealed provenance 仍是 /3")
return
if expected_receipts is None:
raise ValueError("当前请求未提供 policy但 sealed provenance 含 /4 回执")
links = manifest.get("referenceAssetVerificationReceipts")
if not isinstance(links, list) or not links:
raise ValueError("sealed provenance /4 缺非空回执链接")
persisted_receipts = []
seen_receipt_ids = set()
for index, link in enumerate(links):
if not isinstance(link, dict) or set(link) != {"ref", "hash"}:
raise ValueError(f"sealed provenance /4 回执链接[{index}] 形状非法")
ref = link.get("ref")
if not isinstance(ref, str):
raise ValueError(f"sealed provenance /4 回执链接[{index}] ref 非法")
try:
relative = _safe_v3_frame_ref(ref)
except ValueError as exc:
raise ValueError(f"sealed provenance /4 回执链接[{index}] 越界:{exc}") from exc
receipt_bytes = snapshot.files.get(relative)
if receipt_bytes is None:
raise ValueError(f"sealed provenance /4 回执缺失:{relative}")
if not _is_sha256_v3(link.get("hash")):
raise ValueError(f"sealed provenance /4 回执 hash 非法:{relative}")
if hashlib.sha256(receipt_bytes).hexdigest() != link["hash"]:
raise ValueError(f"sealed provenance /4 回执 hash 漂移:{relative}")
try:
receipt = json.loads(receipt_bytes.decode("utf-8"))
except (UnicodeError, ValueError) as exc:
raise ValueError(f"sealed provenance /4 回执不可解析:{relative}") from exc
if not isinstance(receipt, dict):
raise ValueError(f"sealed provenance /4 回执顶层不是对象:{relative}")
receipt_errors = _validate_v3_schema_only(_REFERENCE_ASSET_RECEIPT_SCHEMA, receipt)
if receipt_errors:
raise ValueError(f"sealed provenance /4 回执 contract 非法:{relative}")
if _stable_json_bytes_v3(receipt) != receipt_bytes:
raise ValueError(f"sealed provenance /4 回执不是 canonical JSON:{relative}")
receipt_id = receipt.get("receiptId")
expected_ref = f"reference-asset-verification-receipts/{receipt_id}.json"
if not _is_v3_record_id(receipt_id) or ref != expected_ref:
raise ValueError(f"sealed provenance /4 回执 ref 与 receiptId 不一致:{relative}")
if receipt_id in seen_receipt_ids:
raise ValueError(f"sealed provenance /4 receiptId 重复:{receipt_id}")
seen_receipt_ids.add(receipt_id)
persisted_receipts.append(receipt)
if _stable_json_bytes_v3(persisted_receipts) != _stable_json_bytes_v3(expected_receipts):
raise ValueError("sealed provenance /4 回执与当前验收独立复验结果不一致")
def _v3_tester_error_payload(base: dict, floor: dict, registry: dict, cfg: dict,
request: dict, *, reason: str, subtype: str,
pre_acceptance_chain_cost_rmb: float = 0.0) -> dict:
"""构造不落盘的 canonical tester_error用于坏封存、证据越界和 run 锁超时。"""
merge = {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error", "conflicts": [],
"rescuedByRoll": None, "reason": reason, "_subtype": subtype, "_sourceGuard": None}
play = {"rolls": [], "merge": merge, "costRmb": 0.0, "registry": registry}
final = _final_postguard_v3(
mode=str(base.get("acceptanceMode") or "v3_shadow"), floor=floor, merge=merge, rolls=[],
repair_count=int(request.get("repairCountAcrossParentChain") or 0),
cost_rmb=0.0, cost_cap_rmb=cfg["cost_cap_rmb"],
parent_chain_cost_rmb=pre_acceptance_chain_cost_rmb,
parent_chain_cost_cap_rmb=cfg["parent_chain_cost_cap_rmb"])
payload = _v3_assemble_payload(base, floor=floor, play=play, final=final,
registry=registry, cfg=cfg)
errors = validate_acceptance_v3_payload(payload)
if errors:
raise ValueError("tester_error 降级结果仍不合规:" + ";".join(errors[:6]))
return payload
def _persist_v3_decision(run_dir: Path, payload: dict) -> None:
"""原子封存 v3 decision已存在结果不覆盖保证证据只读和幂等。"""
errors = validate_acceptance_v3_payload(payload)
if errors:
raise ValueError("playtest/3 封存前 contract 校验失败:" + ";".join(errors[:6]))
run_dir.mkdir(parents=True, exist_ok=True)
target = run_dir / "decision.json"
if target.exists():
return
tmp = run_dir / "decision.json.tmp"
tmp.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8")
tmp.replace(target)
def _validate_v3_repair_parent(request: dict, identity: dict,
evidence_root: Path) -> tuple[str | None, float]:
"""复核唯一修回父决策,并返回封存父链成本;调用方不得自报或重置历史成本。"""
ordinal = int(identity.get("repairOrdinal") or 0)
parent_run_id = request.get("parentRunId")
if ordinal == 0:
return ((None, 0.0) if parent_run_id is None
else ("首轮 acceptance 不得携带 parentRunId", 0.0))
if not isinstance(parent_run_id, str) or not _is_safe_game_id_v3(parent_run_id):
return "repairOrdinal=1 必须携带安全 parentRunId", 0.0
try:
parent = _load_verified_v3_sealed(
Path(evidence_root), Path(evidence_root) / parent_run_id)
except Exception as exc: # noqa: BLE001
return f"修回父 decision/截图证据不可复核:{type(exc).__name__}: {exc}", 0.0
if not is_v3_repair_authorized(parent):
return "父决策未授权唯一修回", 0.0
parent_decision = parent.get("decision") or {}
checks = {
"sourceArtifactHash": (identity.get("sourceArtifactHash"), parent.get("artifactHash")),
"parentAcceptanceRequestHash": (
identity.get("parentAcceptanceRequestHash"), parent.get("acceptanceRequestHash")),
"gameId": (identity.get("gameId"), parent.get("gameId")),
"briefHash": (identity.get("briefHash"), parent.get("briefHash")),
"genre": (identity.get("genre"), parent.get("genre")),
"templateRoute": (identity.get("templateRoute"), parent.get("templateRoute")),
"proofProfileId": (identity.get("proofProfileId"), parent.get("proofProfileId")),
"proofRegistryVersion": (
identity.get("proofRegistryVersion"), parent.get("proofRegistryVersion")),
"taskBindingHash": (identity.get("taskBindingHash"), parent.get("taskBindingHash")),
}
try:
parent_snapshot = artifact_snapshot.capture_artifact_snapshot(Path(evidence_root) / parent_run_id)
parent_request_ref = f"acceptance-request-{parent.get('acceptanceRequestHash')}.json"
parent_request_bytes = parent_snapshot.files.get(parent_request_ref)
if (parent_request_bytes is None
or hashlib.sha256(parent_request_bytes).hexdigest() != parent.get("acceptanceRequestHash")):
raise ValueError("父 acceptance request 缺失或 hash 漂移")
parent_request = json.loads(parent_request_bytes.decode("utf-8"))
if (_stable_json_bytes_v3(parent_request) != parent_request_bytes
or _validate_v3_instance(_V3_ACCEPTANCE_REQUEST_SCHEMA, parent_request)):
raise ValueError("父 acceptance request 不是 canonical 合法请求")
except Exception as exc: # noqa: BLE001 interaction binding 血缘只能信封存父 request
return f"修回父 acceptance request 不可复核:{type(exc).__name__}: {exc}", 0.0
checks["interactionBinding"] = (
identity.get("interactionBinding"), parent_request.get("interactionBinding"))
for field, (actual, expected) in checks.items():
if actual != expected:
return f"修回身份 {field} 未锁定父决策", 0.0
if parent_decision.get("repairCountAcrossParentChain") != 0:
return "父决策 repairCountAcrossParentChain 必须为 0", 0.0
try:
parent_cost = _finite_nonnegative_rmb_v3(
parent_decision.get("parentChainCostRmb"), "父决策 parentChainCostRmb")
except ValueError as exc:
return str(exc), 0.0
return None, parent_cost
async def _run_acceptance_v3_locked(request: dict) -> dict:
"""持有当前 run claim 后执行 v3 编排;公开入口负责跨协程/跨进程幂等锁。"""
request = dict(request or {})
cfg = _acceptance_v3_cfg()
config_cost_error = None
try:
cfg["cost_cap_rmb"] = _finite_positive_rmb_v3(cfg["cost_cap_rmb"], "costCapRmb")
cfg["parent_chain_cost_cap_rmb"] = _finite_positive_rmb_v3(
cfg["parent_chain_cost_cap_rmb"], "parentChainCostCapRmb")
except ValueError as exc:
# 错误配置也要能产出 canonical tester_error故回落批准默认帽但保留错误原因阻断执行。
config_cost_error = str(exc)
cfg["cost_cap_rmb"] = 1.5
cfg["parent_chain_cost_cap_rmb"] = 15.0
mode = str(request.get("acceptanceMode") or cfg["mode"])
game_id = str(request.get("gameId") or "")
if not _is_safe_game_id_v3(game_id):
raise ValueError("v3 gameId 非法")
brief = str(request.get("brief") or "")
identity = request.get("acceptanceIdentity") if isinstance(request.get("acceptanceIdentity"), dict) else None
if identity is None:
raise ValueError("v3 请求缺 Writer 前冻结的 acceptanceIdentity")
identity = dict(identity)
identity_fields = set(_canonical_acceptance_request(identity)) | {"acceptanceRequestHash"}
if set(identity) != identity_fields:
raise ValueError("acceptanceIdentity 字段集合非法")
if str(identity.get("gameId") or "") != game_id:
raise ValueError("acceptanceIdentity.gameId 与入口请求不一致")
expected_identity = build_acceptance_v3_identity(
game_id, brief, genre=str(identity.get("genre") or ""),
template_route=str(identity.get("templateRoute") or ""),
source_artifact_hash=identity.get("sourceArtifactHash"),
parent_acceptance_request_hash=identity.get("parentAcceptanceRequestHash"),
repair_ordinal=int(identity.get("repairOrdinal") or 0),
proof_profile_id=identity.get("proofProfileId"),
proof_registry_version=identity.get("proofRegistryVersion"),
task_binding_hash=identity.get("taskBindingHash"),
interaction_binding=identity.get("interactionBinding"),
design_ref=identity.get("designRef"),
reference_asset_record_ids=identity.get("referenceAssetRecordIds"),
consumer_ref=identity.get("consumerRef"),
)
if identity != expected_identity:
raise ValueError("acceptanceIdentity 与 canonical route/profile/brief 解析不一致")
genre = identity["genre"]
brief_hash = identity["briefHash"]
evidence_mode = str(request.get("evidenceMode") or "native")
artifact_hash = str(request.get("artifactHash") or _artifact_hash_v3(game_id, request.get("artifactPath")))
run_id = _v3_run_id(request, artifact_hash, brief_hash)
request["runId"] = run_id
evidence_root = Path(request.get("evidenceRoot") or (_V3_RESULTS_DIR / game_id))
run_dir = evidence_root / run_id
floor, missing_floor = _v3_floor_contract(request.get("verdict") or {})
try:
registry = _v3_resolve_registry(
genre, brief, template_route=identity["templateRoute"],
proof_profile_id=identity["proofProfileId"],
proof_registry_version=identity["proofRegistryVersion"],
)
except Exception as e: # noqa: BLE001 —— 未知品类绝不猜测义务;仍以 tester_error 响亮失败
registry = {"version": "unknown", "briefRuleMatches": [], "obligations": []}
registry_error = f"proof registry 解析失败:{type(e).__name__}: {e}"
else:
registry_error = None
# 显式 policy 只信验收侧独立重跑 gate 的自产回执;无 policy 才保留 Registry/1 历史兼容对账。
reference_asset_provenance_error = None
acceptance_reference_asset_receipts = None
if ("referenceAssetPolicyId" in request or "referenceAssetGenerationReceipts" in request
or any(key.startswith("referenceAssetVerificationReceipt") for key in request)):
(reference_asset_provenance_error, acceptance_reference_asset_receipts,
consumed_reference_assets) = _reverify_acceptance_reference_asset_policy(
request, identity, mode)
reference_asset_error = None
else:
reference_asset_error, consumed_reference_assets = _v3_check_declared_reference_assets(identity)
base = {
"schemaVersion": _PLAYTEST_V3_SCHEMA, "runId": run_id, "parentRunId": request.get("parentRunId"),
"gameId": game_id, "genre": genre, "templateRoute": identity["templateRoute"],
"proofProfileId": identity["proofProfileId"],
"proofRegistryVersion": identity["proofRegistryVersion"],
"taskBindingHash": identity["taskBindingHash"],
"acceptanceRequestHash": identity["acceptanceRequestHash"],
"artifactHash": artifact_hash, "briefHash": brief_hash,
"acceptanceMode": mode, "evidenceMode": evidence_mode, "phase": "DECIDED",
}
cost_input_error = None
if "parentChainCostRmb" in request:
cost_input_error = "请求不得自报 parentChainCostRmbrepair 历史成本只能从封存父决策推导"
try:
writer_cost_rmb = _finite_nonnegative_rmb_v3(
request.get("writerCostRmb", 0.0), "writerCostRmb")
except ValueError as exc:
writer_cost_rmb = 0.0
cost_input_error = str(exc)
repair_parent_error, sealed_parent_chain_cost = _validate_v3_repair_parent(
request, identity, evidence_root)
pre_acceptance_chain_cost = sealed_parent_chain_cost + writer_cost_rmb
forced_tester_error = request.get("_forcedV3TesterError")
if isinstance(forced_tester_error, str) and forced_tester_error:
return _v3_tester_error_payload(
base, floor, registry, cfg, request, reason=forced_tester_error,
subtype="environment_error",
pre_acceptance_chain_cost_rmb=pre_acceptance_chain_cost)
sealed = run_dir / "decision.json"
if os.path.lexists(sealed) and repair_parent_error is None:
if reference_asset_provenance_error:
return _v3_tester_error_payload(
base, floor, registry, cfg, request,
reason=f"sealed 重入参照资产独立复验失败:{reference_asset_provenance_error}",
subtype="provenance_error",
pre_acceptance_chain_cost_rmb=pre_acceptance_chain_cost)
try:
existing = _load_verified_v3_sealed(evidence_root, run_dir)
if existing.get("schemaVersion") != _PLAYTEST_V3_SCHEMA:
raise ValueError("封存结果 schema 未知")
sealed_identities = {
"artifactHash": artifact_hash, "briefHash": brief_hash, "genre": genre,
"templateRoute": identity["templateRoute"],
"proofProfileId": identity["proofProfileId"],
"proofRegistryVersion": identity["proofRegistryVersion"],
"taskBindingHash": identity["taskBindingHash"],
"acceptanceRequestHash": identity["acceptanceRequestHash"],
"evidenceMode": evidence_mode,
}
for field, expected in sealed_identities.items():
if existing.get(field) != expected:
raise ValueError(f"封存结果 {field} 与请求不一致")
existing_cost = _finite_nonnegative_rmb_v3(existing.get("costRmb"), "封存 costRmb")
expected_chain_cost = _quantize_rmb_v3(
pre_acceptance_chain_cost + existing_cost, "封存 parentChainCostRmb")
if (existing.get("decision") or {}).get("parentChainCostRmb") != expected_chain_cost:
raise ValueError("封存结果 parentChainCostRmb 与封存父成本和本轮 writer 成本不一致")
except Exception as e: # noqa: BLE001
return _v3_tester_error_payload(
base, floor, registry, cfg, request,
reason=f"幂等封存/截图证据复核失败:{type(e).__name__}: {e}",
subtype="image_error")
try:
_verify_v3_sealed_reference_provenance(
run_dir, identity, artifact_hash, acceptance_reference_asset_receipts)
except Exception as e: # noqa: BLE001 —— sealed /4 缺失或漂移不得返回旧 accept
return _v3_tester_error_payload(
base, floor, registry, cfg, request,
reason=f"sealed provenance/receipt 复核失败:{type(e).__name__}: {e}",
subtype="provenance_error",
pre_acceptance_chain_cost_rmb=pre_acceptance_chain_cost)
return existing
repair_count = int(request.get("repairCountAcrossParentChain") or 0)
invalid_request = (
mode not in ("v3_shadow", "v3", "historical_replay") or not game_id
or genre not in _V3_GENRES or repair_count != identity["repairOrdinal"]
or (mode in ("v3", "v3_shadow") and evidence_mode != "native")
or (mode == "historical_replay" and evidence_mode != "legacy-adapted")
or (identity.get("interactionBinding") is not None and mode != "v3_shadow")
)
if (invalid_request or registry_error or repair_parent_error or cost_input_error or config_cost_error
or reference_asset_provenance_error):
reason = (reference_asset_provenance_error or config_cost_error or cost_input_error
or registry_error or repair_parent_error
or f"非法 v3 请求:mode={mode} gameId={game_id or '-'} genre={genre or '-'}")
merge = {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error", "conflicts": [],
"rescuedByRoll": None, "reason": reason,
"_subtype": ("provenance_error" if (repair_parent_error or reference_asset_provenance_error) else
"profile_contract_error" if registry_error else "cost_contract_error"
if (cost_input_error or config_cost_error) else "schema_error"),
"_sourceGuard": None}
play = {"rolls": [], "merge": merge, "costRmb": 0.0, "registry": registry}
elif reference_asset_error:
# 声明消费参照资产但 Registry/1 兼容对账未过 → verified reject + 发布冻结,
# 不起浏览器、不烧模型;这是身份/策略闸先于证据评估floor/playtest
merge = {"triggered": False, "rollRefs": [], "mergeCandidate": "reject", "conflicts": [],
"rescuedByRoll": None, "reason": reference_asset_error,
"_subtype": "reference_asset_consumption_rejected",
"_verifiedReject": True, "_failedObligations": [], "_failedGates": [],
"_sourceGuard": None}
play = {"rolls": [], "merge": merge, "costRmb": 0.0, "registry": registry}
elif missing_floor:
merge = {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error", "conflicts": [],
"rescuedByRoll": None, "reason": f"A/B/C/D 四门证据不完整:{','.join(missing_floor)}",
"_subtype": "floor_evidence_missing", "_sourceGuard": None}
play = {"rolls": [], "merge": merge, "costRmb": 0.0, "registry": registry}
elif not floor["pass"]:
merge = {"triggered": False, "rollRefs": [], "mergeCandidate": "reject", "conflicts": [],
"rescuedByRoll": None, "reason": "A/B/C/D 明确产物失败", "_subtype": "floor_gate",
"_verifiedReject": True, "_failedObligations": [],
"_failedGates": [name for name, result in floor["gates"].items() if result["pass"] is not True],
"_sourceGuard": None}
play = {"rolls": [], "merge": merge, "costRmb": 0.0, "registry": registry}
else:
try:
# W-GOLD-LIVE 检查点 3a验收时点对账的消费快照落 provenance未声明消费时为 [],旧行为不变)。
provenance = write_acceptance_v3_provenance(
game_id, identity, artifact_hash=artifact_hash, evidence_root=run_dir,
artifact_path=request.get("artifactPath"),
consumed_reference_assets=consumed_reference_assets,
reference_asset_verification_receipts=acceptance_reference_asset_receipts)
except Exception as exc: # noqa: BLE001 —— provenance 不可信时禁止启动浏览器与模型
merge = {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error", "conflicts": [],
"rescuedByRoll": None,
"reason": f"acceptance provenance 写入失败:{type(exc).__name__}: {exc}",
"_subtype": "provenance_error", "_sourceGuard": None}
play = {"rolls": [], "merge": merge, "costRmb": 0.0, "registry": registry}
else:
request["acceptanceProvenance"] = provenance
play = await run_playtest_v3(request, run_dir=run_dir, artifact_hash=artifact_hash,
brief_hash=brief_hash, cfg=cfg)
merge = play["merge"]
final = _final_postguard_v3(
mode=mode, floor=floor, merge=merge, rolls=play["rolls"], repair_count=repair_count,
cost_rmb=float(play.get("costRmb") or 0.0), cost_cap_rmb=cfg["cost_cap_rmb"],
parent_chain_cost_rmb=pre_acceptance_chain_cost,
parent_chain_cost_cap_rmb=cfg["parent_chain_cost_cap_rmb"])
payload = _v3_assemble_payload(base, floor=floor, play=play, final=final,
registry=play.get("registry") or registry, cfg=cfg)
contract_errors = validate_acceptance_v3_payload(payload)
if contract_errors:
reason = "Python finalPostguard 产物未通过 canonical contract:" + ";".join(contract_errors[:6])
fallback_merge = {"triggered": False, "rollRefs": [], "mergeCandidate": "tester_error",
"conflicts": [], "rescuedByRoll": None, "reason": reason,
"_subtype": "schema_error", "_sourceGuard": None}
fallback_play = {"rolls": [], "merge": fallback_merge, "costRmb": 0.0,
"registry": play.get("registry") or registry}
fallback_final = _final_postguard_v3(
mode=mode, floor=floor, merge=fallback_merge, rolls=[], repair_count=repair_count,
cost_rmb=0.0, cost_cap_rmb=cfg["cost_cap_rmb"],
parent_chain_cost_rmb=pre_acceptance_chain_cost,
parent_chain_cost_cap_rmb=cfg["parent_chain_cost_cap_rmb"])
payload = _v3_assemble_payload(base, floor=floor, play=fallback_play, final=fallback_final,
registry=fallback_play["registry"], cfg=cfg)
fallback_errors = validate_acceptance_v3_payload(payload)
if fallback_errors:
raise ValueError("playtest/3 fail-closed 结果仍不合规:" + ";".join(fallback_errors[:6]))
_persist_v3_decision(run_dir, payload)
# ── Wave0 运行保障:验收 tester_error 事件实时外发告警(best-effort,绝不阻断验收收口)──────
# sink = cheap_alert_sink(配置见 generation.yaml alert 区;默认 no-op,创始人填 webhook URL +
# 置 enabled=true 才启用)。只告 tester_error(基础设施/预算/超时类失败——W-GOLD-LIVE 3b Actor
# playtest 前撞成本硬帽 cost_cap_exceeded、M3 慢尾 model_timeout 即此类);reject(游戏质量
# 不过)是正常业务结论,不外发。sink 内部已吞全部异常,这里再裹一层 try 兜底,双保险不惊主链。
try:
import cheap_alert_sink # noqa: PLC0415 惰性 import:顶层不牵告警依赖
_decision = final.get("decision") or {}
if _decision.get("outcome") == "tester_error":
_failure = _decision.get("failure") or {}
_subtype = str(_failure.get("subtype") or "unknown")
# 预算类事件(单局/全链撞硬帽)给 critical;超时及其余 tester_error 给 warning。
_alert_level = ("critical" if _subtype in ("cost_cap_exceeded", "parent_chain_cost_cap_exceeded")
else "warning")
cheap_alert_sink.notify_gen_alert(
_alert_level, f"acceptance_v3.{_subtype}",
detail={"gameId": game_id, "runId": run_dir.name,
"reason": str(_failure.get("reason") or ""),
"costRmb": str(_decision.get("costRmb") or "")},
link=str(run_dir))
except Exception: # noqa: BLE001 —— 告警任何异常都不允许影响验收主链
pass
return payload
async def run_acceptance_v3(request: dict) -> dict:
"""v3 唯一公开入口:同 run 加锁后重查封存,所有等待方只返回同一 decision。"""
request = dict(request or {})
game_id = str(request.get("gameId") or "")
if not _is_safe_game_id_v3(game_id):
# 保持原入口的参数错误语义,不为非法路径创建 claim 文件。
return await _run_acceptance_v3_locked(request)
identity = request.get("acceptanceIdentity") if isinstance(request.get("acceptanceIdentity"), dict) else None
if identity is None:
return await _run_acceptance_v3_locked(request)
artifact_hash = str(request.get("artifactHash") or _artifact_hash_v3(game_id, request.get("artifactPath")))
request["artifactHash"] = artifact_hash
brief_hash = str(identity.get("briefHash") or _sha256_text(str(request.get("brief") or "")))
run_id = _v3_run_id(request, artifact_hash, brief_hash)
evidence_root = Path(request.get("evidenceRoot") or (_V3_RESULTS_DIR / game_id))
run_dir = evidence_root / run_id
timeout_s = _acceptance_v3_cfg()["run_lock_timeout_s"]
try:
claim, lock_error = await asyncio.to_thread(
_acquire_v3_run_lock, evidence_root, run_dir, timeout_s)
except Exception as exc: # noqa: BLE001 —— 路径越界/锁文件异常必须形成 tester_error不能绕锁运行
claim = None
lock_error = f"v3 run claim 失败:{type(exc).__name__}: {exc}"
if claim is None:
request["_forcedV3TesterError"] = lock_error or "v3 run claim 未取得"
return await _run_acceptance_v3_locked(request)
try:
return await _run_acceptance_v3_locked(request)
finally:
_release_v3_run_lock(claim)
if __name__ == "__main__":
# 便捷自跑python cheap-worker/cheap_verify.py <gameId> ["brief"] —— 对已有产物真跑一次评分。
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
gid = sys.argv[1] if len(sys.argv) > 1 else "cheap-smoke1"
bf = sys.argv[2] if len(sys.argv) > 2 else ""
out = asyncio.run(verify_richness(gid, brief=bf))
print(json.dumps(out, ensure_ascii=False, indent=2))