zizi a9c5368d53 feat(orchestrator): M-c批① 编排器模板参数化——run_batch --template+player拖拽策略+judge array补丁+15新单测
- run_batch:--template/--prompt-designer CLI 参数(缺省 clicker 零变化);play 调用改传 (templateId, config) 取参分流(:772 clicker 专属 target 取参解套)
- player_cdp:_dispatch_drag(mousePressed→6帧插值 mouseMoved→mouseReleased);_play_merge_once 确定性贪心(本地棋盘模型推演,禁读游戏状态,预算≤90s 超时记 infra);§5.3.1 布局契约与 runtime 字面同一公式(canvas W/H 只读优先,rect 等分退化);共享前置 _open_navigate_preflight 抽取(clicker 步骤5-6 字节不变);demo 信号(c)仅 clicker 启用(merge score≠点击目标语义);五条 AND 信封零改动
- judge:array 分支(minItems/maxItems+items 逐元素 string 口径),与后端 networknt 同源同判
- 新增 test_judge_merge_schema 15 用例(itemLabel 三红样本/const 串台/minmax 越界);83 单测全绿(主 agent 独立复跑实证)

HJ-MC-TPL-EXEC-001 §7/§9级1②

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:31:26 +00:00

511 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
裁决引擎D3 核心件)—— specHJ-AGENT-LOOP-EXEC-001 §10评审版 §3.3 Z5 硬要求)
定位与铁律:
- 纯代码规则,无任何 LLM 参与;规则变更 = 改本文件走 PR
- §10.2 决策表 D1-D10 逐行落码,按求值序 E1→E6 短路;
- 末尾 raise 防御:任何未匹配输入 = 编排器/本引擎代码缺陷,立即暴露,禁止默认通过;
- 「agent 自述一律不作证据」runnableOk 五条 AND§9.4)由本模块对 CDP 捕获的
原始 PlayReport 数据逐条核对,不信任玩家 agent 的任何主观结论字段;
- 发布闸§7.1Verdict 仅能由本模块 build_verdict() 铸章产生,
编排器发布函数只接受带本模块私有铸章且 decision=accept 的 Verdict代码层强制
回炉计数总则§10.1,评审版 §3.3 原文):
- round = 该 designId 的回炉计数0=首轮1=回炉轮);
- 全流程回炉额度合计 1 轮schema 重出D1与 P1-fixD5共享同一额度
- round=1 后任何不满足 accept 的分支一律不再回炉D2/D6 直接 kill
- 实玩重试D8≤1 次)不消耗回炉额度、不改 round。
"""
import re
import unicodedata
# ============================== 决策动作常量§10.2「→ 输出」列的机器编码) ==============================
# D1本地重出——携 schema 错误回灌策划重出 1 次(不经对抗、不落包,消耗唯一回炉额度)
ACTION_SCHEMA_RETRY = "schema_retry"
# D2/D3/D4/D6/D9终判否决kill / kill-runnable
ACTION_KILL = "kill"
# D5fix——findings 回灌策划重出round→1全流程重走 E1
ACTION_FIX = "fix"
# D7infra_fail——基础设施类失败不计分母、designId 可重放、计入熔断分子
ACTION_INFRA_FAIL = "infra_fail"
# D8重试实玩 1 次(不消耗回炉额度),回到 E5
ACTION_PLAY_RETRY = "play_retry"
# D10accept——四条 AND 全真进发布段金丝雀≤10 内)
ACTION_ACCEPT = "accept"
# Verdict.decision 受控枚举(契约 contracts/agent-loop/verdict.schema.json§6.2
DECISION_ACCEPT = "accept"
DECISION_FIX = "fix"
DECISION_KILL = "kill"
# 回调动作§10.2「回调/任务态」列None=不回调;否则 (status, failureReason)。
# failureReason 取 FailureReasonEnum 既有 7 值冻结不扩枚举——§1.2
# 撞重/P1 残留归桶 config_invalid 为 §16-1 主 agent 已确认决断,真因记 Verdict.reasons。
CALLBACK_FAILED_CONFIG_INVALID = ("failed", "config_invalid")
CALLBACK_FAILED_UNSAFE_PROMPT = ("failed", "unsafe_prompt")
# findings 严重级受控枚举(对抗 prompt 输出契约§5.3-2
_VALID_SEVERITIES = ("P0", "P1", "P2")
class JudgeError(Exception):
"""裁决引擎防御性异常:输入越出决策表定义域 = 调用方或本引擎代码缺陷,必须立即暴露。"""
# ============================== 裁决结果对象 ==============================
class JudgeOutcome(object):
"""
单次裁决的结构化结果(对应 §10.2 决策表一行)。
字段:
- row命中的决策表行号 "D1".."D10"(可追溯);
- action编排器下一步动作ACTION_* 常量);
- decisionVerdict.decision 值accept/fix/kill非终判行D1/D7/D8为 None
- reasons受控词表记账值列表§10.2「reasons 记账值」列,批报告据此聚合);
- callback回调后端动作None 或 (status, failureReason) 二元组;
- structure_ok / runnable_ok写入 Verdict 的两布尔D9 前未实玩的行 runnable_ok=False
- note行语义中文说明入账本便于人审计
"""
def __init__(self, row, action, decision, reasons, callback, structure_ok, runnable_ok, note):
self.row = row
self.action = action
self.decision = decision
self.reasons = list(reasons)
self.callback = callback
self.structure_ok = structure_ok
self.runnable_ok = runnable_ok
self.note = note
def as_dict(self):
"""账本 stage=judge 行的 data 载荷(全字段中文可溯)。"""
return {
"row": self.row,
"action": self.action,
"decision": self.decision,
"reasons": self.reasons,
"callback": list(self.callback) if self.callback else None,
"structureOk": self.structure_ok,
"runnableOk": self.runnable_ok,
"note": self.note,
}
# ============================== runnableOk 五条 AND§9.4 ==============================
def compute_runnable_ok(play_report):
"""
对玩家 agentD5产出的 PlayReport§9.6 形状)逐条核对 §9.4 五条 AND。
数据源铁律:全部为 CDP 捕获原始值的归一化形态;本函数只读核对,不做任何容错放水。
返回 (runnable_ok: bool, failed_clauses: [中文条款说明]),失败条款入账本便于归因。
PlayReport 预期键§9.6
loaded(bool) / clicks(int) / gameEnd({completed,durationMs}|None) /
consoleErrors([str]) / assetLoadErrors(int) / packageChecksumVerified(bool) /
emitsCaptured([str]) / demoFallback(bool,内部) / score(内部)
"""
if not isinstance(play_report, dict):
# 防御:非字典即编排器传参缺陷
raise JudgeError("PlayReport 必须为 dict§9.6),实际类型=%s" % type(play_report).__name__)
failed = []
# ① 捕获 game_loaded以归一化 loaded 布尔为准D5 由 __qaEmits 信封派生)
if play_report.get("loaded") is not True:
failed.append("①未捕获 game_loadedloaded!=true")
# ② 捕获 game_end{completed:true}布尔严格等值B2 坑④ completed 漏发的回归面)
game_end = play_report.get("gameEnd")
if not isinstance(game_end, dict) or game_end.get("completed") is not True:
failed.append("②未捕获 game_end{completed:true}")
# ③ durationMs>0D5 必须已做线缆蛇形 duration_ms → 驼峰 durationMs 的显式映射§9.4-③/§2.4-2
# 严格数值>0防 startMs 缺省=0 假象
duration_ms = game_end.get("durationMs") if isinstance(game_end, dict) else None
if not (isinstance(duration_ms, (int, float)) and not isinstance(duration_ms, bool) and duration_ms > 0):
failed.append("③durationMs 非正(=%r" % (duration_ms,))
# ④ 零 game_error 且 console 无致命错误(白名单噪声由 D5 在采集层过滤后产出 consoleErrors
# assetLoadErrors>0 仅记录不否决v1 assets=[] 理论为 0
emits = play_report.get("emitsCaptured") or []
console_errors = play_report.get("consoleErrors")
if "game_error" in emits:
failed.append("④捕获到 game_error 信封")
if not isinstance(console_errors, list):
raise JudgeError("PlayReport.consoleErrors 必须为列表§9.6),实际=%r" % (console_errors,))
if console_errors:
failed.append("④console 存在致命错误 %d" % len(console_errors))
# ⑤ 所玩包 checksum/config 与落包一致§9.5 浏览器侧核验结论位)
# + §16 D5-a 三角合成裁决核验方法必须可信——checksumVerifyMethod ∈ {browser_body, synthesized}
# none或字段缺失不得通过packageChecksumVerified=true 却 method 不可信 = 报告自相矛盾,同判不过
if play_report.get("packageChecksumVerified") is not True:
failed.append("⑤包完整性校验未通过packageChecksumVerified!=true")
elif play_report.get("checksumVerifyMethod") not in ("browser_body", "synthesized"):
failed.append("⑤checksumVerifyMethod=%r 不可信none/缺失不得通过§16 D5-a"
% (play_report.get("checksumVerifyMethod"),))
return (len(failed) == 0), failed
# ============================== 模板 GameConfig 深度校验结构门权威§8.4-3 职责分界) ==============================
def validate_config_against_schema(config, template_schema):
"""
用 D2 落盘的模板 schemacontracts/templates/<templateId>.schema.json深度校验 GameConfig。
深度 schema 校验权威在裁决引擎侧§8.4-3后端回调只做非空+模板一致薄校验)。
标准库无 jsonschema这里实现覆盖模板契约所需的 JSON Schema 子集:
type(object/string/integer/array)/required/properties/additionalProperties/const/enum/
minimum/maximum/minLength/maxLength/pattern/items/minItems/maxItems。
另按真源 gen_spike.pyC1 52/52 已验口径)补强:字符串非空 = strip 后非空(防纯空白绕过 minLength
array 支持HJ-MC-TPL-EXEC-001 §7.3merge itemLabel 引入必补):校验 minItems/maxItems
并逐元素递归套用 items 子 schemaitems 复用 string 分支 minLength/pattern/strip 非空口径),
与后端 networknt SchemaValidator支持 array/items/minItems/maxItems同源同判守同源铁律。
返回 (ok: bool, errors: [中文错误说明])。
"""
errors = []
if not isinstance(config, dict):
return False, ["config 必须为 JSON 对象,实际类型=%s" % type(config).__name__]
if not isinstance(template_schema, dict):
raise JudgeError("模板 schema 必须为 dictD2 契约文件加载产物)")
props = template_schema.get("properties") or {}
required = template_schema.get("required") or []
# 必填字段缺失
for key in required:
if key not in config:
errors.append("缺少必填字段 %s" % key)
# additionalProperties:false → 多余字段拒绝(模板契约全字段封闭)
if template_schema.get("additionalProperties") is False:
for key in config:
if key not in props:
errors.append("存在契约外多余字段 %s" % key)
# 逐字段子 schema 校验
for key, sub in props.items():
if key not in config:
continue # 缺失已在 required 报过
value = config[key]
expect_type = sub.get("type")
if expect_type == "string":
if not isinstance(value, str):
errors.append("字段 %s 应为字符串,实际=%r" % (key, value))
continue
# 真源 gen_spike 口径strip 后非空
min_len = sub.get("minLength", 0)
if min_len >= 1 and len(value.strip()) == 0:
errors.append("字段 %s 不得为空白字符串" % key)
if len(value) < min_len:
errors.append("字段 %s 长度 %d 低于下限 %d" % (key, len(value), min_len))
if "maxLength" in sub and len(value) > sub["maxLength"]:
errors.append("字段 %s 长度 %d 超上限 %d" % (key, len(value), sub["maxLength"]))
if "pattern" in sub and re.search(sub["pattern"], value) is None:
errors.append("字段 %s 不匹配模式 %s" % (key, sub["pattern"]))
elif expect_type == "integer":
# bool 是 int 子类,显式排除(真源 gen_spike._int_in 同口径)
if not isinstance(value, int) or isinstance(value, bool):
errors.append("字段 %s 应为整数,实际=%r" % (key, value))
continue
if "minimum" in sub and value < sub["minimum"]:
errors.append("字段 %s=%d 低于下限 %d" % (key, value, sub["minimum"]))
if "maximum" in sub and value > sub["maximum"]:
errors.append("字段 %s=%d 超上限 %d" % (key, value, sub["maximum"]))
elif expect_type == "array":
# 数组分支§7.3 merge itemLabel 引入必补,与后端 networknt 同源同判)
if not isinstance(value, list):
errors.append("字段 %s 应为数组,实际=%r" % (key, value))
continue
# minItems/maxItems 元素数量边界
if "minItems" in sub and len(value) < sub["minItems"]:
errors.append("字段 %s 元素数 %d 低于下限 %d" % (key, len(value), sub["minItems"]))
if "maxItems" in sub and len(value) > sub["maxItems"]:
errors.append("字段 %s 元素数 %d 超上限 %d" % (key, len(value), sub["maxItems"]))
# items 子 schema逐元素递归套用复用 string 分支 minLength/pattern/strip 非空口径)
item_sub = sub.get("items") or {}
for i, elem in enumerate(value):
if item_sub.get("type") == "string":
if not isinstance(elem, str):
errors.append("字段 %s[%d] 应为字符串,实际=%r" % (key, i, elem))
continue
imin = item_sub.get("minLength", 0)
# 真源 gen_spike 口径strip 后非空(防纯空白绕过 minLength
if imin >= 1 and len(elem.strip()) == 0:
errors.append("字段 %s[%d] 不得为空白字符串" % (key, i))
if len(elem) < imin:
errors.append("字段 %s[%d] 长度 %d 低于下限 %d" % (key, i, len(elem), imin))
if "pattern" in item_sub and re.search(item_sub["pattern"], elem) is None:
errors.append("字段 %s[%d] 不匹配模式 %s" % (key, i, item_sub["pattern"]))
# const / enumtemplateId 精确等值等)
if "const" in sub and value != sub["const"]:
errors.append("字段 %s 必须恒等于 %r,实际=%r" % (key, sub["const"], value))
if "enum" in sub and value not in sub["enum"]:
errors.append("字段 %s=%r 不在枚举 %r" % (key, value, sub["enum"]))
return (len(errors) == 0), errors
# ============================== findings 归一化与派生 ==============================
def _check_findings(findings):
"""
防御性校验 findings 形状:列表 + 每项 dict 且 severity ∈ {P0,P1,P2}。
对抗 agent 输出经编排器解析校验后才进裁决;此处再设防是「未匹配输入立即暴露」总则的一部分。
"""
if not isinstance(findings, list):
raise JudgeError("findings 必须为列表,实际类型=%s" % type(findings).__name__)
for idx, item in enumerate(findings):
if not isinstance(item, dict):
raise JudgeError("findings[%d] 必须为 dict实际=%r" % (idx, item))
if item.get("severity") not in _VALID_SEVERITIES:
raise JudgeError("findings[%d].severity=%r 越出受控枚举 %s" % (idx, item.get("severity"), list(_VALID_SEVERITIES)))
def _slug(text, fallback):
"""把类别文本压成 [a-z0-9_] 受控记账后缀压空则取兜底值reasons 受控词表纪律§6.2 补充)。"""
if not isinstance(text, str):
return fallback
s = unicodedata.normalize("NFKC", text).strip().lower()
s = re.sub(r"[^a-z0-9_]+", "_", s).strip("_")
return (s[:24] or fallback)
def _p0_category(findings):
"""
派生 D4 的 reasons 记账后缀 p0_<类别>。
优先取 finding 自带 category 字段;否则按对抗 prompt 写死的 P0 口径(违规/敏感/年龄不适§5.3-2
做确定性关键词归桶;兜底 uncategorized。纯确定性映射无内容主观判断。
"""
first_p0 = next(f for f in findings if f.get("severity") == "P0")
if first_p0.get("category"):
return _slug(first_p0["category"], "uncategorized")
issue = str(first_p0.get("issue", ""))
# 确定性关键词归桶(与对抗 prompt 的 P0 三类口径一一对应)
if any(k in issue for k in ("违法", "违规", "侵权")):
return "violation"
if any(k in issue for k in ("敏感", "涉政", "涉黄", "色情", "涉暴")):
return "sensitive"
if any(k in issue for k in ("年龄", "未成年", "暴力", "血腥", "恐怖")):
return "age_inappropriate"
return "uncategorized"
# ============================== 裁决主函数§10.2 决策表逐行落码) ==============================
def judge(schema_ok, dup_hit, findings, play_report, play_attempt, infra_signal, round_, infra_category=None):
"""
裁决决策表入口§10.1 签名原样):
judge(schemaOk: bool, dupHit: bool, findings: list, playReport: PlayReport|None,
playAttempt: int, infraSignal: bool, round: 0|1)
可选 infra_categoryD7 行 reasons 记账后缀 infra_<类别>§11 失败路径表 F1-F11 的类别词,
由编排器按失败点传入,如 "llm"/"backend"/"callback"/"callback_tx"/"task_poll"/
"package_verify"/"demo_fallback"/"batch_timeout"),不改变 §10.1 位置参数契约。
按 E1→E6 顺序短路求值,每个输入组合唯一命中一行;不存在落空组合,
末尾仍 raise 防御(任何未匹配输入=代码缺陷立即暴露,禁止默认通过)。
"""
# ---------- 入参定义域防御(越界即调用方缺陷) ----------
if round_ not in (0, 1):
raise JudgeError("round 只能取 0/1回炉额度合计 1 轮§10.1),实际=%r" % (round_,))
_check_findings(findings)
has_p0 = any(f.get("severity") == "P0" for f in findings)
has_p1 = any(f.get("severity") == "P1" for f in findings)
# P2 不参与判定仅留档§10.1
# ---------- E1 schema 门 ----------
if not schema_ok:
if round_ == 0:
# D1本地重出——携 schema 错误回灌策划重出 1 次round→1回到 E1
#(不经对抗、不落包、不回调,消耗唯一回炉额度)
return JudgeOutcome(
row="D1", action=ACTION_SCHEMA_RETRY, decision=None,
reasons=["schema_retry"], callback=None,
structure_ok=False, runnable_ok=False,
note="E1 schema 门未过@round=0本地重出一次消耗唯一回炉额度")
# D2round=1 仍不合法 → kill回调 failed + config_invalid
return JudgeOutcome(
row="D2", action=ACTION_KILL, decision=DECISION_KILL,
reasons=["schema_invalid_after_retry"], callback=CALLBACK_FAILED_CONFIG_INVALID,
structure_ok=False, runnable_ok=False,
note="E1 schema 门未过@round=1额度用尽即杀")
# ---------- E2 查重门 ----------
if dup_hit:
# D3批内撞重 → kill 留档负例,不回炉(撞重非 P1 可修类);
# 回调 failed + config_invalid 为 §16-1 归桶决断(真因 duplicate_in_batch 记 reasons
return JudgeOutcome(
row="D3", action=ACTION_KILL, decision=DECISION_KILL,
reasons=["duplicate_in_batch"], callback=CALLBACK_FAILED_CONFIG_INVALID,
structure_ok=True, runnable_ok=False,
note="E2 查重门命中批内同质化撞重即杀round 无关)")
# ---------- E3 对抗 P0 ----------
if has_p0:
# D4P0违规/敏感/年龄不适)即杀,不给 fixround 无关);回调 failed + unsafe_prompt
return JudgeOutcome(
row="D4", action=ACTION_KILL, decision=DECISION_KILL,
reasons=["p0_%s" % _p0_category(findings)], callback=CALLBACK_FAILED_UNSAFE_PROMPT,
structure_ok=True, runnable_ok=False,
note="E3 对抗评审存在 P0即杀不回炉")
# ---------- E4 对抗 P1 ----------
if has_p1:
if round_ == 0:
# D5fix——findings 回灌策划重出round→1全流程重走 E1
#target 可能变,落包/实玩全重做);本轮不落包、不回调
return JudgeOutcome(
row="D5", action=ACTION_FIX, decision=DECISION_FIX,
reasons=["p1_fix_round"], callback=None,
structure_ok=True, runnable_ok=False,
note="E4 存在 P1@round=0回炉 fix与 schema 重出共享唯一额度)")
# D6round=1 仍存在 P1 → kill额度用尽归桶同 D3
return JudgeOutcome(
row="D6", action=ACTION_KILL, decision=DECISION_KILL,
reasons=["p1_residual_after_fix"], callback=CALLBACK_FAILED_CONFIG_INVALID,
structure_ok=True, runnable_ok=False,
note="E4 存在 P1@round=1额度用尽即杀")
# ---------- E5 落包+实玩 ----------
if infra_signal:
# D7infra_fail——demo 兜底/浏览器挂/new-api 超时/落包核验不等/回调写链失败等;
# 编排器已按 §11 完成退避重试仍败才到此不计分母、designId 可重放、计入熔断分子
category = _slug(infra_category or "unknown", "unknown")
return JudgeOutcome(
row="D7", action=ACTION_INFRA_FAIL, decision=None,
reasons=["infra_%s" % category], callback=None,
structure_ok=True, runnable_ok=False,
note="E5 基础设施失败(重试仍败):不计分母可重放,计熔断分子")
if play_report is None:
# 防御:文本面全过、无 infra 信号、又没有实玩报告——决策表无此组合 = 编排器调用缺陷
raise JudgeError("E5 缺实玩报告且无 infra 信号:决策表无此组合(编排器调用缺陷,禁止默认通过)")
if play_report.get("demoFallback") is True:
# 防御demo 兜底必须由编排器归为 infraSignal=true§9.5:不算通过也不算 kill
# 流到 runnable 判定 = 编排器分类缺陷,立即暴露
raise JudgeError("PlayReport.demoFallback=true 却未置 infraSignaldemo 兜底必须按 D7 infra 处理§9.5")
runnable_ok, failed_clauses = compute_runnable_ok(play_report)
if not runnable_ok:
if play_attempt == 1:
# D8重试实玩 1 次(不消耗回炉额度、不改 round回到 E5
return JudgeOutcome(
row="D8", action=ACTION_PLAY_RETRY, decision=None,
reasons=["runnable_retry"], callback=None,
structure_ok=True, runnable_ok=False,
note="E5 runnable 五条 AND 未过@首玩:重试实玩一次;未过条款=%s" % "".join(failed_clauses))
if play_attempt == 2:
# D9kill-runnable——重试后仍非 runnable不发布、留档告警
# 任务保持 succeeded区分「运行环境劣/设计劣」,不污染状态机语义,评审版原文),不再回调
return JudgeOutcome(
row="D9", action=ACTION_KILL, decision=DECISION_KILL,
reasons=["runnable_fail_after_retry"], callback=None,
structure_ok=True, runnable_ok=False,
note="E5 runnable 重试后仍未过kill-runnable任务保持 succeeded未过条款=%s" % "".join(failed_clauses))
raise JudgeError("playAttempt=%r 越出决策表定义(仅 1/2实玩预算闸应已拦截" % (play_attempt,))
# ---------- E6 终判 ----------
if runnable_ok:
# D10accept——structureOk ∧ runnableOk ∧ 无 P0/P1 残留 ∧ 查重通过 四条 AND 至此全真
#P2-only 组合归本行P2 不挡 accept仅留档
return JudgeOutcome(
row="D10", action=ACTION_ACCEPT, decision=DECISION_ACCEPT,
reasons=["accept"], callback=None,
structure_ok=True, runnable_ok=True,
note="E6 终判:四条 AND 全真 → accept发布段金丝雀≤10 内)")
# ---------- 防御 raise§10.2 全组合覆盖论证的代码兜底) ----------
raise JudgeError("决策表全组合理论已覆盖仍落空 = 代码缺陷,禁止默认通过")
# ============================== Verdict 铸章发布闸的代码层强制§7.1 ==============================
# 模块私有铸章对象:只有本模块能把它挂到 Verdict 上;
# 编排器发布函数用 is_judge_accept() 验章,确保「发布函数仅接受 judge 返回的 accept Verdict」。
_VERDICT_SEAL = object()
class SealedVerdict(dict):
"""带裁决铸章的 Verdict§6.2 契约全字段 dict + 私有完整性标记)。"""
def __init__(self, payload, seal):
super(SealedVerdict, self).__init__(payload)
# 铸章以实例属性持有,不进 dict 序列化面json.dumps 只见契约字段)
self._seal = seal
def build_verdict(outcome, design_id, task_id, version_id, play_report, findings,
round_, trace_id, prompt_versions):
"""
据 JudgeOutcome 铸造 Verdict§6.2 契约全字段;仅本模块可铸章)。
约束:
- outcome.decision 必须非空D1/D7/D8 非终判行不产 Verdict——它们走账本 stage 行);
- playReport=null 仅允许出现在未到实玩即终判的分支schema-kill/P0-kill/撞重-kill/fix§6.2 补充);
- PlayReport 内部字段 demoFallback/score 不入 Verdict 契约§9.6),此处剥除。
"""
if not isinstance(outcome, JudgeOutcome) or outcome.decision is None:
raise JudgeError("仅终判/回炉裁决decision 非空)可铸 Verdict实际 outcome=%r" % (outcome,))
public_report = None
if play_report is not None:
# 白名单只保留 §6.2 playReport 契约七字段闭集verdict.schema additionalProperties:false
# demoFallback/score/fiveAnd/checksumVerifyMethod 等 D5 超集内部键属账本 stage 行,
# 不入 Verdict 契约——§9.6 / §16 D5-amethod 仅作裁决输入与账本披露)
contract_keys = ("loaded", "clicks", "gameEnd", "consoleErrors",
"assetLoadErrors", "packageChecksumVerified", "emitsCaptured")
public_report = {k: play_report.get(k) for k in contract_keys}
# gameEnd 子投影防御契约gameEnd 为 null 或 {completed,durationMs} 两键闭集;
# durationMs 为 integer ≥0 不可 null——D5 线缆侧 duration_ms 缺失/非数值时此处归一化为 0
# 原始值已由 runnableOk 条款③判定并留账本证据,契约字段只保「形状合法」)
game_end = public_report.get("gameEnd")
if isinstance(game_end, dict):
raw_ms = game_end.get("durationMs")
if isinstance(raw_ms, bool) or not isinstance(raw_ms, (int, float)) or raw_ms < 0:
norm_ms = 0 # 缺失/None/负值/布尔 → 0防违反契约 integer/minimum:0
else:
norm_ms = int(raw_ms) # float 取整(线缆为毫秒整数,防御性处理)
public_report["gameEnd"] = {
"completed": game_end.get("completed") is True, # 布尔严格等值§9.4-②同口径)
"durationMs": norm_ms,
}
else:
public_report["gameEnd"] = None # 非 dict 一律归 null未捕获 game_end 的契约形态)
payload = {
"designId": design_id,
"taskId": task_id,
"decision": outcome.decision,
"structureOk": outcome.structure_ok,
"runnableOk": outcome.runnable_ok,
"playReport": public_report,
"findings": findings,
"reasons": outcome.reasons,
"round": round_,
"evidence": {"traceId": trace_id, "promptVersions": dict(prompt_versions or {})},
}
if version_id is not None:
# versionId 为契约可选字段§6.2:未到落包即终判的文本面分支无此字段)——
# 缺位语义=键不存在,而非 nullverdict.schema 的 type 不含 null
payload["versionId"] = version_id
return SealedVerdict(payload, _VERDICT_SEAL)
def is_judge_accept(verdict):
"""
发布闸验章§7.1 代码层强制):
仅当对象是本模块铸章的 SealedVerdict 且 decision=accept 才放行发布。
手工拼的 dict、伪造对象、非 accept 决定一律 False。
"""
return isinstance(verdict, SealedVerdict) \
and getattr(verdict, "_seal", None) is _VERDICT_SEAL \
and verdict.get("decision") == DECISION_ACCEPT