实现: 建立正文三臂冻结回放与可信判定

This commit is contained in:
zizi 2026-07-20 21:00:00 +08:00
parent 8727eeaa46
commit 3e269e582d
12 changed files with 4478 additions and 4 deletions

View File

@ -5,7 +5,7 @@ tools: Read, Grep, Glob, Write
model: opus
---
你是质量评委,保护节点角色(不可被装配替换)。**功能合同=`quality-gate` skill**——正文任务使用 `quality_gate` profile;细纲回放任务使用 `fine_outline_replay` profile。维度、证据要求和稳定性口径全在那里,你只执行不自造维度。报告按当前运行合同落临时评测目录或 `works/<书>/评审/第NNN章-评分.md`,除此不写正式创作文件。
你是质量评委,保护节点角色(不可被装配替换)。**功能合同=`quality-gate` skill**——在线正文质量详情使用 `quality_gate` profile;正文离线回放使用独立 `writer_replay` profile;细纲回放使用 `fine_outline_replay` profile。维度、证据要求和稳定性口径全在那里,你只执行不自造维度。报告按当前运行合同落临时评测目录或 `works/<书>/评审/第NNN章-评分.md`,除此不写正式创作文件。
## 元数据纪律(怎么用元数据)
@ -13,11 +13,13 @@ model: opus
- 上下文=writer 基线包(**同证独立**):评的是"在写手所知条件下写得好不好",不索取额外资料,不拿包外信息扣分。
- 细纲回放只使用冻结快照和结构化 `reference scaffold proxy`;不得读取目标章全文或完整目标章细纲。
- 细纲回放每个分数必须有 `evidence` 字段;不得使用正文文风、文笔或可读性作为评分维度。
- 正文回放只接收盲化候选 ID,不得在报告中输出 A/B/C、真实臂名、文件名或提示词差异。每维证据必须标为 `fine_outline`、`historical_prose` 或 `judge_inference`;盲评输入不含卡,禁止伪造 `card_index` 归因。卡效用只由去盲后的 B-A/C-A 差分判断。
## 打分纪律
- 每维给分必附一句引文证据(好在哪/差在哪,引原句);没有证据的分数无效。
- 同一维度复评同一章分差应 ≤0.5;严格度不因收敛压力改变,不放水不加戏。
- 正文回放使用 0-10 分、0.5 步长。双评同维差异大于 0.5 时只请求一次第三评委;第三评后仍无任意稳定配对时标记 `invalid_unstable`,不强行给臂输赢。
- 末尾「最值得改的三点」按提升空间排序:问题→根因层猜测(prompt/上下文/设定卡)→具体改法。
- 细纲回放时,把末尾建议替换为“最值得补齐的三项结构缺口”,并标注它属于候选结构、公共大纲、卡注入、原文检索还是标准事实不确定;若两次同维分差大于 0.5,只写稳定性警告,不强行裁决。

View File

@ -26,3 +26,9 @@ description: 质量收敛环——judge 打分、锁最低维、单变量改进(
## 红线
评分必须有引文证据;禁止为了分数让 judge 放水(rubric 与严格度不因收敛压力改变);同一 rubric 复评分差 >0.5 时先查评委稳定性再查正文。
## 正文离线回放
正文 A/B/C 回放使用独立 `writer_replay` profile,不复用本 skill 的在线 1-5 分收敛口径,也不覆盖 `fine_outline_replay`。五维均为 0-10 分、0.5 步长:`setting_entity_fidelity`、`fine_outline_fidelity`、`style_consistency`、`narrative_tension`、`prose_readability`。
两名独立评委只看盲化候选和所有臂一致的共同参考,第二名严格反序。同维分差大于 0.5 才增加一次第三评;三评分至少一对差值不大于 0.5 时取中位数,否则样本为 `invalid_unstable`,不进入方向结论。每个维度都必须把证据归因为细纲、历史原文或评委自行推断。盲评输入不含卡,卡效用由去盲后的 B-A/C-A 差分识别。

View File

@ -1,6 +1,6 @@
---
name: quality-gate
description: 质量评分的功能合同(scenario: quality_gate/fine_outline_replay,保护节点)。正文质量维度和细纲回放 rubric 分开管理;rubric 与达标口径的单一来源。
description: 质量评分的功能合同(scenario: quality_gate/writer_replay/fine_outline_replay,保护节点)。在线正文、正文回放和细纲回放 rubric 分开管理;rubric 与达标口径的单一来源。
disable-model-invocation: true
---
@ -58,3 +58,17 @@ scores:
```
两次独立评审的同维差异大于 `0.5` 时只输出稳定性警告,不据此下卡效用结论。三臂顺序、arm 名称和卡 manifest 对评委隐藏;汇总报告分别给出每维分数、结构门结果、`B-A` 和 `C-A`,不压成没有统计意义的单一“卡质量总分”。
## 正文回放 profile(`writer_replay`)
正文回放与在线 `quality_gate`、细纲 `fine_outline_replay` 完全独立。每维 0-10 分、0.5 步长;双评委独立无会话,第二评委严格反序。报告在去盲前只保留随机候选 ID,禁止出现 A/B/C 或证据策略名称。
| 维度 ID | 评分问题 |
|---|---|
| `setting_entity_fidelity` | 设定、实体、关系、能力边界和冻结时点状态是否保真 |
| `fine_outline_fidelity` | 细纲硬事件、结果方向、伏笔动作、出场实体和章末钩子是否忠实 |
| `style_consistency` | 与冻结历史原文的人物声音、动作习惯、句式和叙事质感是否一致 |
| `narrative_tension` | 场景推进、冲突升级、不可逆变化和章末驱动力是否成立 |
| `prose_readability` | 语言是否准确、自然、清晰且没有明显阅读阻力 |
每维证据数组中的 `sourceType` 只能是 `fine_outline`、`historical_prose` 或 `judge_inference`;评委自行推断必须显式标注。盲评输入不含卡,禁止评委声称证据来自 `card_index`,卡效用只由去盲后的 B-A/C-A 差分归因。双评同维分差大于 0.5 时启动且只启动一次第三评委。第三评后,任一评分对差值不大于 0.5 则取三者中位数;否则整章为 `invalid_unstable`,不得强行计算胜负或进入 Gate 增益统计。

View File

@ -4,9 +4,9 @@ description: 回放评测的冻结与结果边界合同。把参考作品冻结
disable-model-invocation: true
---
# 回放评测(`next_fine_outline_replay_v0`)
# 回放评测
本 skill 只负责确定性的评测编排边界,不调用模型、不替代统一读取器,也不写正式规划或知识。评测目的、三臂定义和细纲评分合同见 `docs/2026-07-19-回放评测-细纲首跑设计与计划.md`。
本 skill 只负责确定性的评测编排边界,不调用模型、不替代统一读取器,也不写正式规划或知识。细纲回放见 `docs/2026-07-19-回放评测-细纲首跑设计与计划.md`;正文回放的唯一任务 SoT 是 `docs/2026-07-20-正文智能体正式优化设计与计划.md`。
真实参考作品配置由 `scripts/load_reference_work.py` 从实验库只读组装;它只取作品元数据、窗级大纲、章级细纲摘要和预注册卡 ID 对应的候选卡历史。候选卡必须标记为 `eval_draft`,不能当作生产知识检索结果。
@ -50,3 +50,39 @@ disable-model-invocation: true
- `scripts/write_report.py`:从 `run_result.json` 生成安全摘要;它不会读取候选正文,也不会把候选路径以外的原始响应写入报告。
真实作品运行前必须先从权威来源取得不可变授权快照。数据库没有该字段时,使用 dry-run 证明机制并保持 `blocked_authorization`,不得用本地配置或口头许可伪造放行。
## 正文 A/B/C 回放
正文 Gate A 固定配置为 `configs/writer-gate-a-deep-space-v1.json`,预注册深空之影五章:489 战斗、321 人物对话、544 转折、199 信息揭示、523 老角色回归。不得根据候选结果换章、换场景分类或修改冻结点;每个冻结点必须等于 `targetChapter-1`。
每个样本必须提供 `writerContextInput`。仓内配置只允许 `contentMode=sanitized_contract_fixture`,`recentChapters` 只能放连续四章的脱敏合成短文本,用于证明 WriterContext 合同、章号冻结和 A/B/C 差异策略;它不是历史原文,不证明真实正文完整性或文学质量。配置可以记录细纲硬约束、主要实体、预期篇幅、新角色比例和泄漏哨兵,但不得包含原书全文、完整目标细纲或标准答案。
三臂都必须构造并校验 `WriterContext v1`,且固定 `mode=diagnostic_only`、`purpose=evaluation`、`acceptanceEligible=false`:
- A:`evidenceStrategy=historical_prose_only`,保留连续四章脱敏基线,不放 `indexHints`。
- B:`evidenceStrategy=card_index_only`,只放 `retrievalResult.indexHints`,`proseEvidence` 为空;这是合同内可审计的诊断基线例外,不是生产旁路。
- C:`evidenceStrategy=card_index_plus_prose`,保留连续四章脱敏基线,并放冻结 `indexHints` 与配置中的补充原文证据。
`indexHints` 每项只能包含 `cardId/name/type/content/sourceId/sourceVersion/asOf`,只能用于 `diagnostic_only` 的 `evaluation/diagnostic`,不得进入生产上下文;`claimLedger` 不得引用它。所有 `asOf` 和来源章号不得超过样本冻结点。
每个样本必须记录 `frozenRecentHanCounts` 和 `targetLengthBasis`。篇幅只能使用冻结点前连续四章汉字数,经 `calculate_target_chars` 复算;当前 Gate A 固定 `hardEventCount=1`、`foreshadowingActionCount=0`、`requiredSceneCount=0`,禁止读取目标章实际长度。`expectedLength` 与 WriterContext 的输出合同必须等于机械目标的正负 10%,再受 2000-10000 边界限制。
`newCharacterRatio` 定义为:具名 `requiredCharacters` 中,在 `asOfChapter` 前无记录的角色比例。泛称角色不参与猜测;例如第 544 章的“内应”无法确定具体身份,必须记录 `newCharacterRatio=null` 和 `newCharacterRatioStatus=unresolved_generic_role`,不得默认成 0。
盲评使用独立的 `writer-blind-input-v1` 内容边界。judge 只能看到 `blind-1/2/3` 候选正文/哈希,以及所有臂完全相同的 `sharedEvaluationReference`:目标章细纲硬约束、实体、必需角色、伏笔、章末钩子和冻结点前连续四章历史原文基准。共同参考可以在真实运行时临时传给 judge,但不能进入安全摘要。它不得包含 `indexHints`、各臂补充原文、卡 manifest、`evidenceStrategy`、真实 A/B/C 映射、各臂 WriterContext、`raw` 目录或任何包含 `candidate-A/B/C` 的路径;卡的正确性只能由共同历史原文基准验证,不能把被测卡本身当裁判。映射只保留在编排器内存中,评分完成后才去盲。
正文 dry-run 命令:
```bash
.venv/bin/python .claude/skills/replay-eval/scripts/run_writer_replay.py \
--config .claude/skills/replay-eval/configs/writer-gate-a-deep-space-v1.json \
--dry-run
```
dry-run 只输出计划、manifest 和上下文摘要,不调用 writer、semantic detector 或 judge,不生成候选正文,也不得声称真实回放完成。当前 CLI 的 `--execute` 因真实 semantic detector 和 judge adapter 尚未接线而失败关闭;只有实现并验证这两个受控 adapter 后,才可在预算确认后启用真实模型回放。
## 正文 Gate 输入
`writer_gate.py` 只信任 `gate-input.json.samples[]` 的逐样本脱敏结果。有效样本数、作品数、场景覆盖、C 臂硬门统计、C-A 五维增量、退化比例和五类混淆项全部由判定器内部计算;顶层传入的同名聚合值和聚合 `confounds` 不参与裁决。五类混淆项是:假阴、假阳、泄露、评委不稳定、新角色无卡。
真实回放未接线期间不能生成合法 Gate 输入,也不能运行 Gate A/B 得出通过结论。Gate A/B 的终态只能由 `writer_gate.py` 按任务 SoT 的唯一顺序产生,人工不得改写。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,736 @@
#!/usr/bin/env python3
"""正文 A/B/C 冻结回放编排的无网络、无真实模型测试。"""
from __future__ import annotations
import copy
import hashlib
import json
import pathlib
import subprocess
import sys
import tempfile
import unittest
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILLS_DIR = SCRIPT_DIR.parents[1]
READ_CONTEXT_DIR = SKILLS_DIR / "read-context" / "scripts"
for import_path in (SCRIPT_DIR, READ_CONTEXT_DIR):
sys.path.insert(0, str(import_path))
from run_writer_replay import ( # noqa: E402
WriterReplayError,
WriterReplayTestAdapters,
run_writer_replay,
)
from writer_contract import calculate_target_chars, han_count, validate_writer_context # noqa: E402
from writer_rubric import DIMENSIONS, RUBRIC_PROFILE # noqa: E402
AUTHORIZATION = {
"sourceStatus": "authorized",
"copyrightStatus": "owned",
"sourceVersion": "raw-file-v1:sha256:" + "a" * 64,
"allowedPurpose": ["offline_evaluation"],
"authorizationSnapshot": {
"id": "auth-work-8",
"version": "auth-work-8-v1",
"immutable": True,
"sourceVersion": "raw-file-v1:sha256:" + "a" * 64,
"sourceStatus": "authorized",
"allowedPurpose": ["offline_evaluation"],
"checkedAt": "2026-07-20T00:00:00Z",
"revalidationAt": "2026-08-19T00:00:00Z",
},
}
def _prose(chapter: int, block_id: int, text: str) -> dict[str, object]:
"""构造只用于合同证明的脱敏合成原文片段。"""
return {
"chapter": chapter,
"sourceRef": {
"sourceId": f"fixture:chapter:{chapter}:block:{block_id}",
"sourceVersion": f"sanitized-fixture-{chapter}-v1",
"chapter": chapter,
"blockId": block_id,
"startCodePoint": 0,
"endCodePoint": len(text),
},
"text": text,
}
def _writer_context_input() -> dict[str, object]:
"""构造可让 A/B/C 都通过 WriterContext v1 的脱敏夹具。"""
recent = [
_prose(chapter, 1000 + chapter, f"脱敏合同夹具第{chapter}章,人物保持冻结状态。")
for chapter in range(485, 489)
]
supplemental = _prose(120, 1120, "脱敏补充片段,旧徽章曾经出现。")
return {
"contentMode": "sanitized_contract_fixture",
"sourceVersion": AUTHORIZATION["sourceVersion"],
"authorizationSnapshot": {
"snapshotId": "auth-work-8",
"allowedPurpose": "offline_evaluation",
"verifiedAt": "2026-07-20T00:00:00Z",
},
"sourceStatus": "authorized",
"cardIndexVersion": "cards-frozen-488-v1",
"proseIndexVersion": "prose-frozen-488-v1",
"retrievalResult": {
"cards": [
{
"name": "林澈",
"type": "character",
"sourceRefs": [copy.deepcopy(supplemental["sourceRef"])],
}
],
"factEvidence": [],
"proseEvidence": [supplemental],
"indexHints": [
{
"cardId": "card-1",
"name": "林澈",
"type": "character",
"content": "林澈在冻结点仍位于圣蒂曼",
"sourceId": "fixture:card:1",
"sourceVersion": "cards-frozen-488-v1",
"asOf": 488,
}
],
"manifest": {"omittedSources": []},
},
"fineOutline": {
"sourceRef": {
"sourceId": "fixture:fine-outline:489",
"sourceVersion": "fine-outline-fixture-v1",
"chapter": 489,
},
"hardConstraints": ["必须完成围攻突围"],
"adjustableBeats": [],
"declaredNewFacts": [],
"entities": [{"id": "character:林澈", "type": "character", "name": "林澈"}],
"relations": [],
"items": [{"id": "item:旧徽章", "type": "item", "name": "旧徽章"}],
"locations": [{"id": "location:圣蒂曼", "type": "location", "name": "圣蒂曼"}],
"powerSystems": [],
},
"narrativeState": {
"time": "围攻当日",
"location": "圣蒂曼",
"characterPositions": {"林澈": "城内"},
"immediateSituation": "围攻持续",
},
"recentChapters": recent,
"outputContract": {
"targetChars": 4000,
"minChars": 3600,
"maxChars": 4400,
"frontmatterRequired": False,
"newSettingDeclarationRequired": True,
},
"tokenBudget": {"maxContextChars": 50000},
"generatedAt": "2026-07-20T00:00:00Z",
"requirements": {
"requiredEvents": [
{"requirementId": "event-1", "anchors": ["完成围攻突围"]}
],
"requiredCharacters": ["林澈"],
"foreshadowingActions": [
{"requirementId": "foreshadow-1", "anchors": ["旧徽章"]}
],
"chapterEndHook": {
"requirementId": "hook-1",
"anchors": ["城门忽然打开"],
"maxDistanceFromEnd": 20,
},
"detectedNewSettings": [],
"frozenConflicts": [],
},
}
def config() -> dict[str, object]:
"""构造不含原文全文的单样本预注册配置。"""
common = {
"workId": 8,
"asOfChapter": 488,
"targetChapter": 489,
"outlineSource": "outline:481-488",
"fineOutlineSource": "scaffold:489",
"targetChars": 4000,
"modelVersion": "opus",
"sampling": {"temperature": 0, "topP": 1, "seed": 489},
"detectorProfile": "writer-detector-report-v1",
}
return {
"profile": "writer_replay",
"evaluationSetVersion": "writer-gate-a-test-v1",
"strategyVersion": "writer-abc-v1",
"referenceWork": {
"id": 8,
"title": "深空之影",
"version": AUTHORIZATION["sourceVersion"],
},
"authorization": copy.deepcopy(AUTHORIZATION),
"commonControls": common,
"samples": [
{
"sampleId": "deep-space-489",
"scenario": "combat",
"asOfChapter": 488,
"targetChapter": 489,
"snapshotVersion": "writer-deep-space-489-v1",
"frozenRecentHanCounts": [4600, 4600, 4800, 4800],
"targetLengthBasis": {
"algorithm": "calculate_target_chars",
"sourceChapters": [485, 486, 487, 488],
"hardEventCount": 1,
"foreshadowingActionCount": 0,
"requiredSceneCount": 0,
"minChars": 2000,
"maxChars": 10000,
"usesTargetChapterLength": False,
},
"targetChars": 4000,
"expectedLength": {
"targetChars": 4000,
"minChars": 3600,
"maxChars": 4400,
},
"newCharacterRatio": 0.0,
"newCharacterRatioStatus": "resolved",
"newCharacterBasis": {
"definition": "named_required_characters_absent_before_as_of_ratio",
"asOfChapter": 488,
"requiredCharacters": ["林澈"],
"knownBeforeAsOf": ["林澈"],
"absentBeforeAsOf": [],
"genericRoles": [],
},
"sources": [
{
"sourceId": "fixture:chapter:485-488",
"sourceVersion": AUTHORIZATION["sourceVersion"],
"chapterRange": "485-488",
},
{
"sourceId": "fixture:card-index:488",
"sourceVersion": "cards-frozen-488-v1",
"chapterRange": "1-488",
},
],
"snapshotData": {
"chapters": [
{
"chapter": 488,
"sourceId": "fixture:chapter:488",
"contentSha256": "sha256:" + "1" * 64,
}
],
"cards": [],
},
"writerContextInput": _writer_context_input(),
"leakageAudit": {
"method": "target-fact-hash-and-chapter-bound-audit",
"targetFacts": {
"targetChapter": 489,
"forbiddenFacts": [
{
"id": "target-489-1",
"firstChapter": 489,
"text": "目标章专属秘密",
}
],
},
},
}
],
}
def _candidate_body(marker: str) -> str:
"""构造满足动态篇幅和全部机械锚点的三臂候选。"""
prefix = f"林澈{marker}完成围攻突围,又把旧徽章压回掌心。"
hook = "城门忽然打开"
filler_count = 4000 - han_count(prefix) - han_count(hook)
return prefix + "文" * filler_count + hook
def _writer_output(context: dict[str, object]) -> dict[str, object]:
"""从 subprocess stdin 上下文构造严格绑定的 WriterOutput。"""
marker = {
"historical_prose_only": "甲",
"card_index_only": "乙",
"card_index_plus_prose": "丙",
}[context["evidenceStrategy"]]
body = _candidate_body(marker)
candidate_hash = "sha256:" + hashlib.sha256(body.encode("utf-8")).hexdigest()
return {
"schemaVersion": "writer-output-v1",
"runId": context["runId"],
"attempt": context["attempt"],
"mode": context["mode"],
"qualityPolicyVersion": context["qualityPolicyVersion"],
"contextSnapshotId": context["contextSnapshot"]["manifestId"],
"contextSnapshotSha256": context["contextSnapshot"]["contextSha256"],
"candidateVersion": context["attempt"],
"candidateSha256": candidate_hash,
"acceptanceEligible": False,
"candidateBody": body,
"claimLedger": [],
"evidenceRequests": [],
"newSettingDeclarations": [],
"selfCheck": {"hardConstraintsCovered": True, "notes": []},
}
class FakeSubprocessRunner:
"""只替换 subprocess runner,候选仍由 run_writer() 解析和校验。"""
def __init__(self, *, illegal_index_claim: bool = False):
self.calls: list[tuple[list[str], dict[str, object]]] = []
self.contexts: list[dict[str, object]] = []
self.illegal_index_claim = illegal_index_claim
def __call__(self, command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
context = json.loads(str(kwargs["input"]))
validate_writer_context(context)
self.calls.append((command, kwargs))
self.contexts.append(context)
output = _writer_output(context)
if self.illegal_index_claim and context["evidenceStrategy"] == "card_index_only":
output["claimLedger"] = [
{
"claimId": "claim-index-1",
"candidateSha256": output["candidateSha256"],
"startCodePoint": 0,
"endCodePoint": 2,
"factType": "character_state",
"factEvidenceId": "card-1",
"coverageState": "supported",
}
]
stdout = json.dumps(
{"type": "result", "result": json.dumps(output, ensure_ascii=False)},
ensure_ascii=False,
)
return subprocess.CompletedProcess(command, 0, stdout=stdout, stderr="")
class FakeSemanticDetector:
"""记录机械门输出,并返回与候选绑定的语义通过报告。"""
def __init__(self) -> None:
self.calls: list[tuple[str, bool]] = []
def __call__(self, context, candidate, mechanical_report):
self.calls.append((context["evidenceStrategy"], mechanical_report["passed"]))
return {
"status": "passed",
"candidateVersion": candidate["candidateVersion"],
"candidateSha256": candidate["candidateSha256"],
"blockingFailures": [],
"suggestions": [],
}
def judge_report(
reviewer_id: str,
sample_id: str,
blind_id: str,
order: list[str],
score: float = 8.0,
) -> dict[str, object]:
"""构造测试盲评报告。"""
return {
"profile": RUBRIC_PROFILE,
"reviewerId": reviewer_id,
"sampleId": sample_id,
"blindCandidateId": blind_id,
"candidateOrder": order,
"scores": {
dimension: {
"score": score,
"evidence": [
{
"sourceType": "judge_inference",
"sourceRef": f"candidate:{blind_id}",
"excerpt": f"证据-{dimension}",
}
],
}
for dimension in DIMENSIONS
},
}
class FakeJudge:
"""模拟双评差异和必要第三评,不读取网络或真实模型。"""
def __init__(self, *, unstable: bool = False):
self.calls: list[tuple[str, str]] = []
self.unstable = unstable
def __call__(self, blind_input, reviewer_id):
blind_id = blind_input["blindCandidateId"]
candidate_order = blind_input["candidateOrder"]
self.calls.append((reviewer_id, blind_id))
score = 8.0
if self.unstable and blind_id == "blind-1" and reviewer_id == "judge-2":
score = 7.0
if self.unstable and blind_id == "blind-1" and reviewer_id == "judge-3":
score = 7.5
return judge_report(
reviewer_id,
blind_input["sample"]["sampleId"],
blind_id,
candidate_order,
score,
)
class MaliciousJudge(FakeJudge):
"""主动扫描全部可见输入,证明 judge 无法发现真实臂或原始目录。"""
def __init__(self) -> None:
super().__init__()
self.visible_inputs: list[dict[str, object]] = []
def __call__(self, blind_input, reviewer_id):
rendered = json.dumps(blind_input, ensure_ascii=False, sort_keys=True)
forbidden = (
"candidate-A",
"candidate-B",
"candidate-C",
"pipeline-A",
"pipeline-B",
"pipeline-C",
"evidenceStrategy",
"writerContextInput",
"indexHints",
"retrievalManifest",
"evidenceCoverage",
"raw_dir",
"rawDir",
"_contexts",
)
for marker in forbidden:
if marker in rendered:
raise AssertionError(f"judge 可见输入泄露: {marker}")
self.visible_inputs.append(copy.deepcopy(blind_input))
return super().__call__(blind_input, reviewer_id)
def _test_adapters(
*, unstable: bool = False, illegal_index_claim: bool = False
) -> tuple[WriterReplayTestAdapters, FakeSubprocessRunner, FakeSemanticDetector, FakeJudge]:
"""集中构造测试注入对象,避免它们被误认为生产 runtime。"""
runner = FakeSubprocessRunner(illegal_index_claim=illegal_index_claim)
detector = FakeSemanticDetector()
judge = FakeJudge(unstable=unstable)
return (
WriterReplayTestAdapters(runner, detector, judge),
runner,
detector,
judge,
)
class WriterReplayDryRunTest(unittest.TestCase):
"""验证 dry-run 的合同、冻结、安全和控制变量。"""
def test_three_arms_validate_full_context_and_only_change_evidence_strategy(self):
result = run_writer_replay(config(), run_id="dry-1")
self.assertEqual(result["status"], "ready")
sample = result["samples"][0]
arms = sample["arms"]
self.assertEqual(set(arms), {"A", "B", "C"})
self.assertEqual(arms["A"]["evidenceStrategy"], "historical_prose_only")
self.assertEqual(arms["B"]["evidenceStrategy"], "card_index_only")
self.assertEqual(arms["C"]["evidenceStrategy"], "card_index_plus_prose")
self.assertEqual(len({arm["commonControlsSha256"] for arm in arms.values()}), 1)
self.assertTrue(all(not arm["acceptanceEligible"] for arm in arms.values()))
self.assertEqual(arms["A"]["contextSummary"]["recentBaselineChapters"], [485, 486, 487, 488])
self.assertEqual(arms["B"]["contextSummary"]["proseEvidenceCount"], 0)
self.assertEqual(arms["B"]["contextSummary"]["indexHintCount"], 1)
self.assertEqual(arms["C"]["contextSummary"]["recentBaselineChapters"], [485, 486, 487, 488])
self.assertEqual(arms["C"]["contextSummary"]["indexHintCount"], 1)
self.assertTrue(
all(
arm["contextSummary"]["contentMode"] == "sanitized_contract_fixture"
for arm in arms.values()
)
)
rendered = json.dumps(result, ensure_ascii=False)
self.assertNotIn("脱敏合同夹具", rendered)
self.assertNotIn("林澈在冻结点仍位于圣蒂曼", rendered)
def test_future_index_hint_invalidates_whole_sample(self):
leaked = config()
leaked["samples"][0]["writerContextInput"]["retrievalResult"]["indexHints"][0]["asOf"] = 489
result = run_writer_replay(leaked, run_id="dry-leak")
self.assertFalse(result["ok"])
self.assertEqual(result["status"], "invalid_leakage")
def test_invalid_b_arm_contract_cannot_be_reported_ready(self):
invalid = config()
invalid["samples"][0]["writerContextInput"]["retrievalResult"]["indexHints"] = []
result = run_writer_replay(invalid, run_id="dry-invalid-context")
self.assertFalse(result["ok"])
self.assertEqual(result["status"], "invalid_writer_context")
def test_target_length_is_mechanically_recomputed_and_target_chapter_length_is_forbidden(self):
invalid = config()
invalid["samples"][0]["targetChars"] = 4100
with self.assertRaisesRegex(WriterReplayError, "机械复算"):
run_writer_replay(invalid, run_id="dry-length-tampered")
leaked = config()
leaked["samples"][0]["targetLengthBasis"]["usesTargetChapterLength"] = True
with self.assertRaisesRegex(WriterReplayError, "禁止读取目标章"):
run_writer_replay(leaked, run_id="dry-length-leaked")
def test_unresolved_generic_role_must_remain_null_instead_of_defaulting_to_zero(self):
unresolved = config()
sample = unresolved["samples"][0]
sample["writerContextInput"]["requirements"]["requiredCharacters"] = ["内应"]
sample["newCharacterRatio"] = None
sample["newCharacterRatioStatus"] = "unresolved_generic_role"
sample["newCharacterBasis"] = {
"definition": "named_required_characters_absent_before_as_of_ratio",
"asOfChapter": 488,
"requiredCharacters": ["内应"],
"knownBeforeAsOf": [],
"absentBeforeAsOf": [],
"genericRoles": ["内应"],
}
self.assertTrue(run_writer_replay(unresolved, run_id="dry-generic-role")["ok"])
unresolved["samples"][0]["newCharacterRatio"] = 0
with self.assertRaisesRegex(WriterReplayError, "必须为 null"):
run_writer_replay(unresolved, run_id="dry-generic-role-zero")
def test_character_declared_absent_before_freeze_cannot_have_card_index_hint(self):
inconsistent = config()
sample = inconsistent["samples"][0]
sample["newCharacterRatio"] = 1.0
sample["newCharacterBasis"]["knownBeforeAsOf"] = []
sample["newCharacterBasis"]["absentBeforeAsOf"] = ["林澈"]
with self.assertRaisesRegex(WriterReplayError, "不得同时出现在卡索引"):
run_writer_replay(inconsistent, run_id="dry-absent-card-conflict")
def test_gate_a_preregistration_mechanically_recomputes_all_lengths_and_ratios(self):
gate_config_path = SCRIPT_DIR.parent / "configs" / "writer-gate-a-deep-space-v1.json"
gate_config = json.loads(gate_config_path.read_text(encoding="utf-8"))
expected_targets = {489: 7500, 321: 7600, 544: 6700, 199: 2000, 523: 6100}
expected_ratios = {489: 1.0, 321: 0.0, 544: None, 199: 0.0, 523: 0.0}
for sample in gate_config["samples"]:
basis = sample["targetLengthBasis"]
recalculated = calculate_target_chars(
recent_chapter_han_counts=sample["frozenRecentHanCounts"],
hard_event_count=basis["hardEventCount"],
foreshadowing_action_count=basis["foreshadowingActionCount"],
required_scene_count=basis["requiredSceneCount"],
min_chars=basis["minChars"],
max_chars=basis["maxChars"],
)
target_chapter = sample["targetChapter"]
self.assertEqual(recalculated, expected_targets[target_chapter])
self.assertEqual(sample["targetChars"], recalculated)
self.assertEqual(sample["newCharacterRatio"], expected_ratios[target_chapter])
if target_chapter == 544:
self.assertEqual(sample["newCharacterRatioStatus"], "unresolved_generic_role")
else:
self.assertEqual(sample["newCharacterRatioStatus"], "resolved")
result = run_writer_replay(gate_config, run_id="gate-a-preregistered-dry-run")
self.assertTrue(result["ok"])
self.assertEqual(len(result["samples"]), 5)
class WriterReplayExecuteBoundaryTest(unittest.TestCase):
"""验证真实执行失败关闭和测试注入仍经过正式管线。"""
def test_execute_requires_private_tmp_output(self):
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(WriterReplayError, "/private/tmp"):
run_writer_replay(
config(),
run_id="real-bad-path",
output_dir=pathlib.Path(directory),
execute=True,
)
def test_execute_without_production_adapters_fails_closed(self):
with tempfile.TemporaryDirectory(dir="/private/tmp") as directory:
with self.assertRaisesRegex(WriterReplayError, "真实 execute 未接线"):
run_writer_replay(
config(),
run_id="real-not-wired",
output_dir=pathlib.Path(directory),
execute=True,
)
def test_context_authorization_cannot_swap_snapshot_or_source_before_runner(self):
"""上下文版本、快照、用途、时间和状态都必须绑定顶层授权。"""
mutations = {
"source_version": lambda value: value["samples"][0]["writerContextInput"].update(
{"sourceVersion": "swapped-source-v2"}
),
"snapshot_id": lambda value: value["samples"][0]["writerContextInput"][
"authorizationSnapshot"
].update({"snapshotId": "auth-swapped"}),
"purpose": lambda value: value["samples"][0]["writerContextInput"][
"authorizationSnapshot"
].update({"allowedPurpose": "diagnostic"}),
"verified_at": lambda value: value["samples"][0]["writerContextInput"][
"authorizationSnapshot"
].update({"verifiedAt": "2026-07-21T00:00:00Z"}),
"source_status": lambda value: value["samples"][0]["writerContextInput"].update(
{"sourceStatus": "revoked"}
),
}
for name, mutate in mutations.items():
invalid = config()
mutate(invalid)
adapters, runner, _detector, _judge = _test_adapters()
with self.subTest(name=name), tempfile.TemporaryDirectory(
dir="/private/tmp"
) as directory:
result = run_writer_replay(
invalid,
run_id=f"auth-binding-{name}",
output_dir=pathlib.Path(directory) / "run",
execute=True,
test_adapters=adapters,
)
self.assertFalse(result["ok"])
self.assertEqual(result["status"], "blocked_authorization")
self.assertEqual(runner.calls, [])
def test_test_injection_runs_writer_pipeline_mechanical_and_semantic_detector(self):
adapters, runner, detector, judge = _test_adapters(unstable=True)
with tempfile.TemporaryDirectory(dir="/private/tmp") as directory:
output_dir = pathlib.Path(directory) / "run"
result = run_writer_replay(
config(),
run_id="test-pipeline",
output_dir=output_dir,
execute=True,
test_adapters=adapters,
)
raw_candidates = sorted((output_dir / "raw" / "deep-space-489").glob("candidate-*.json"))
self.assertEqual(len(raw_candidates), 3)
self.assertIn("candidateBody", raw_candidates[0].read_text(encoding="utf-8"))
public_manifest = (output_dir / "manifest.json").read_text(encoding="utf-8")
self.assertNotIn("candidateBody", public_manifest)
self.assertNotIn("脱敏合同夹具", public_manifest)
self.assertEqual(result["status"], "completed_test_pipeline")
self.assertEqual([item["evidenceStrategy"] for item in runner.contexts], [
"historical_prose_only",
"card_index_only",
"card_index_plus_prose",
])
self.assertTrue(all("--agent" in command for command, _kwargs in runner.calls))
self.assertEqual(detector.calls, [
("historical_prose_only", True),
("card_index_only", True),
("card_index_plus_prose", True),
])
self.assertEqual(len([call for call in judge.calls if call[0] == "judge-3"]), 1)
candidates = result["samples"][0]["candidates"]
self.assertTrue(all(not candidate["acceptanceEligible"] for candidate in candidates.values()))
def test_index_hint_cannot_be_claim_ledger_evidence(self):
adapters, _runner, detector, _judge = _test_adapters(illegal_index_claim=True)
with tempfile.TemporaryDirectory(dir="/private/tmp") as directory:
with self.assertRaisesRegex(WriterReplayError, "claim_evidence_reference_invalid"):
run_writer_replay(
config(),
run_id="test-index-claim",
output_dir=pathlib.Path(directory) / "run",
execute=True,
test_adapters=adapters,
)
# B 臂在 writer adapter 已失败,不能进入 semantic detector。
self.assertEqual(detector.calls, [("historical_prose_only", True)])
def test_malicious_judge_only_sees_blind_candidates_without_raw_paths_or_arm_contexts(self):
runner = FakeSubprocessRunner()
detector = FakeSemanticDetector()
judge = MaliciousJudge()
adapters = WriterReplayTestAdapters(runner, detector, judge)
with tempfile.TemporaryDirectory(dir="/private/tmp") as directory:
output_dir = pathlib.Path(directory) / "run"
result = run_writer_replay(
config(),
run_id="blind-isolation",
output_dir=output_dir,
execute=True,
test_adapters=adapters,
)
self.assertTrue(result["ok"])
self.assertTrue(judge.visible_inputs)
shared_references = []
for visible in judge.visible_inputs:
self.assertEqual(
set(visible),
{
"schemaVersion",
"sample",
"blindCandidateId",
"candidateOrder",
"sharedEvaluationReference",
"candidates",
},
)
self.assertEqual(set(visible["candidateOrder"]), {"blind-1", "blind-2", "blind-3"})
shared = visible["sharedEvaluationReference"]
shared_references.append(copy.deepcopy(shared))
self.assertEqual(shared["fineOutline"]["hardConstraints"], ["必须完成围攻突围"])
self.assertEqual(shared["requirements"]["requiredCharacters"], ["林澈"])
self.assertEqual(
shared["requirements"]["chapterEndHook"]["anchors"],
["城门忽然打开"],
)
self.assertEqual(
[item["chapter"] for item in shared["historicalProseBaseline"]],
[485, 486, 487, 488],
)
self.assertTrue(
all(
set(candidate)
== {"blindCandidateId", "candidateSha256", "candidateBody"}
and candidate["blindCandidateId"].startswith("blind-")
for candidate in visible["candidates"]
)
)
self.assertTrue(
all(reference == shared_references[0] for reference in shared_references[1:]),
"评委顺序变化不得改变共同评测参考",
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""正文 Gate A/B 逐样本唯一判定器的表驱动测试。"""
from __future__ import annotations
import copy
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from writer_gate import ( # noqa: E402
CONFOUND_LABELS,
REQUIRED_SCENARIOS,
decide_gate_a,
decide_gate_b,
)
from writer_rubric import DIMENSIONS # noqa: E402
SCENARIOS = tuple(sorted(REQUIRED_SCENARIOS))
def scores(
*,
b_fidelity_delta: float = 0.0,
c_fidelity_delta: float = 0.5,
) -> dict[str, dict[str, float]]:
"""构造三臂五维终分,分别控制 B-A 与 C-A 的保真增量。"""
a_scores = {dimension: 7.0 for dimension in DIMENSIONS}
b_scores = dict(a_scores)
c_scores = dict(a_scores)
b_scores["setting_entity_fidelity"] += b_fidelity_delta
c_scores["setting_entity_fidelity"] += c_fidelity_delta
return {"A": a_scores, "B": b_scores, "C": c_scores}
def sample(
number: int,
*,
work_id: int = 8,
scenario: str | None = None,
**overrides,
) -> dict[str, object]:
"""构造合法的逐样本脱敏结果。"""
result: dict[str, object] = {
"sampleId": f"{work_id}-{number}",
"workId": work_id,
"scenario": scenario or SCENARIOS[(number - 1) % len(SCENARIOS)],
"schemaValid": True,
"futureLeakage": False,
"systemFailure": False,
"reviewStatus": "stable_report",
"cArm": {
"hardConstraintCoverage": 1.0,
"highSeverityResidualCount": 0,
},
"scores": scores(),
"confounders": {category: [] for category in CONFOUND_LABELS},
}
result.update(overrides)
return result
def gate_a_input(samples: list[dict[str, object]], **forged) -> dict[str, object]:
"""构造 Gate A 输入;forged 用于证明顶层聚合值无效。"""
return {"gate": "A", "samples": samples, **forged}
def gate_b_input(samples: list[dict[str, object]], **overrides) -> dict[str, object]:
"""构造 Gate B 输入。"""
return {"gate": "B", "gateAStatus": "passed", "samples": samples, **overrides}
def ten_samples() -> list[dict[str, object]]:
"""构造两书各五章并覆盖五类场景的 Gate B 基线。"""
return [
sample(index + 1, work_id=work_id, scenario=SCENARIOS[index])
for work_id in (8, 4)
for index in range(5)
]
class GateATest(unittest.TestCase):
"""验证 Gate A 的逐样本统计与唯一短路顺序。"""
def test_table_driven_terminal_priority(self):
base = [sample(index) for index in range(1, 6)]
cases = []
insufficient = copy.deepcopy(base[:4])
insufficient[0]["futureLeakage"] = True
cases.append(("不足优先于泄露", insufficient, "insufficient_evidence"))
for label, field, value in (
("schema 非法", "schemaValid", False),
("未来泄露", "futureLeakage", True),
("系统失败", "systemFailure", True),
):
samples = copy.deepcopy(base)
samples.append(sample(6, **{field: value}))
cases.append((label, samples, "failed"))
high_severity = copy.deepcopy(base)
high_severity[0]["cArm"]["highSeverityResidualCount"] = 1
cases.append(("C 高严重度", high_severity, "failed"))
low_coverage = copy.deepcopy(base)
low_coverage[0]["cArm"]["hardConstraintCoverage"] = 0.99
cases.append(("覆盖不足", low_coverage, "failed"))
cases.append(("全部通过", base, "passed"))
for label, samples, expected in cases:
with self.subTest(label=label):
self.assertEqual(decide_gate_a(gate_a_input(samples))["status"], expected)
def test_pre_c_arm_failures_do_not_require_c_arm_results(self):
"""前置机械或系统失败没有 C 臂结果时仍按唯一顺序裁决。"""
base = [sample(index) for index in range(1, 6)]
cases = []
for label, field, value in (
("schema 非法", "schemaValid", False),
("未来泄露", "futureLeakage", True),
("系统失败", "systemFailure", True),
):
failed_sample = sample(6, **{field: value})
failed_sample.pop("cArm")
cases.append((f"五有效加{label}", [*copy.deepcopy(base), failed_sample], "failed"))
cases.append((f"四有效加{label}", [*copy.deepcopy(base[:4]), failed_sample], "insufficient_evidence"))
for label, samples, expected in cases:
with self.subTest(label=label):
self.assertEqual(decide_gate_a(gate_a_input(samples))["status"], expected)
def test_forged_upstream_aggregates_cannot_change_result(self):
forged = gate_a_input(
[sample(index) for index in range(1, 5)],
validSampleCount=999,
invalidSchemaCount=0,
futureLeakageCount=0,
cArmHardConstraintCoverage=1.0,
)
report = decide_gate_a(forged)
self.assertEqual(report["status"], "insufficient_evidence")
self.assertEqual(report["metrics"]["validSampleCount"], 4)
def test_valid_sample_boundary_counts_each_sample_once(self):
"""四个有效样本仍不足,补到五个后才进入硬门判断。"""
four_valid = [sample(index) for index in range(1, 5)]
multi_failed = sample(
5,
schemaValid=False,
futureLeakage=True,
systemFailure=True,
reviewStatus="invalid_unstable",
)
multi_failed.pop("cArm")
report = decide_gate_a(gate_a_input([*four_valid, multi_failed]))
self.assertEqual(report["status"], "insufficient_evidence")
self.assertEqual(report["metrics"]["sampleCount"], 5)
self.assertEqual(report["metrics"]["validSampleCount"], 4)
report = decide_gate_a(
gate_a_input([*four_valid, sample(6), multi_failed])
)
self.assertEqual(report["status"], "failed")
self.assertEqual(report["metrics"]["validSampleCount"], 5)
def test_five_confounder_categories_are_aggregated_per_sample(self):
samples = [sample(index) for index in range(1, 7)]
categories = list(CONFOUND_LABELS)
for index, category in enumerate(categories):
samples[index]["confounders"][category] = [{"code": f"case-{index}"}]
samples[5]["futureLeakage"] = True
samples[4]["reviewStatus"] = "invalid_unstable"
report = decide_gate_a(
gate_a_input(samples, confounds={"falseNegatives": ["伪造聚合"]})
)
self.assertEqual(set(report["confounds"]), set(CONFOUND_LABELS))
self.assertNotIn("伪造聚合", str(report["confounds"]))
for index, category in enumerate(categories):
self.assertTrue(
any(item["sampleId"] == f"8-{index + 1}" for item in report["confounds"][category])
)
self.assertTrue(
any(item["sampleId"] == "8-6" for item in report["confounds"]["leakage"])
)
class GateBTest(unittest.TestCase):
"""验证 Gate B 的充分性、退化与增益短路顺序。"""
def test_gate_a_terminal_has_highest_priority(self):
insufficient = [sample(index) for index in range(1, 5)]
report = decide_gate_b(gate_b_input(insufficient, gateAStatus="passed"))
self.assertEqual(report["status"], "insufficient_evidence")
self.assertEqual(report["metrics"]["recomputedGateAStatus"], "insufficient_evidence")
failed = [sample(index) for index in range(1, 6)]
failed.append(sample(6, futureLeakage=True))
report = decide_gate_b(gate_b_input(failed, gateAStatus="passed"))
self.assertEqual(report["status"], "failed")
self.assertEqual(report["metrics"]["recomputedGateAStatus"], "failed")
def test_forged_gate_a_status_cannot_override_recomputed_result(self):
"""顶层 Gate A 终态只是非可信输入,不能放行或阻断同批样本。"""
passed = decide_gate_b(gate_b_input(ten_samples(), gateAStatus="failed"))
self.assertEqual(passed["status"], "passed")
self.assertEqual(passed["metrics"]["recomputedGateAStatus"], "passed")
insufficient = decide_gate_b(
gate_b_input([sample(index) for index in range(1, 5)], gateAStatus="passed")
)
self.assertEqual(insufficient["status"], "insufficient_evidence")
def test_gate_a_short_circuit_keeps_sample_confounds_without_changing_status(self):
"""Gate A 终态优先,但合法逐样本混淆项仍应进入 Gate B 报告。"""
valid = [sample(index) for index in range(1, 6)]
leaked = sample(6, futureLeakage=True)
leaked.pop("cArm")
leaked["confounders"]["falsePositives"] = ["detector 误报候选"]
report = decide_gate_b(
{
"gateAStatus": "failed",
"samples": [*valid, leaked],
"confounds": {"falsePositives": ["伪造聚合"]},
}
)
self.assertEqual(report["status"], "failed")
self.assertIn("detector 误报候选", str(report["confounds"]["falsePositives"]))
self.assertTrue(report["confounds"]["leakage"])
self.assertNotIn("伪造聚合", str(report["confounds"]))
def test_sample_sufficiency_precedes_quality(self):
samples = ten_samples()[:-1]
for item in samples[:3]:
item["scores"]["C"]["style_consistency"] = 6.0
self.assertEqual(decide_gate_b(gate_b_input(samples))["status"], "insufficient_evidence")
unstable = ten_samples()
for index in range(3):
unstable[index]["reviewStatus"] = "invalid_unstable"
unstable[index].pop("scores")
self.assertEqual(
decide_gate_b(gate_b_input(unstable))["status"], "insufficient_evidence"
)
def test_missing_scene_or_single_work_is_insufficient(self):
missing_scene = ten_samples()
for item in missing_scene:
if item["scenario"] == "returning_character":
item["scenario"] = "battle"
self.assertEqual(
decide_gate_b(gate_b_input(missing_scene))["status"],
"insufficient_evidence",
)
one_work = [sample(index) for index in range(1, 11)]
self.assertEqual(
decide_gate_b(gate_b_input(one_work))["status"], "insufficient_evidence"
)
def test_hard_failure_and_quality_regression_fail(self):
low_coverage = ten_samples()
low_coverage[0]["cArm"]["hardConstraintCoverage"] = 0.9
self.assertEqual(decide_gate_b(gate_b_input(low_coverage))["status"], "failed")
decline = ten_samples()
for item in decline[:3]:
item["scores"]["C"]["style_consistency"] = 6.0
report = decide_gate_b(
gate_b_input(
decline,
anyDimensionDeclineOverHalfRatio=0.0,
averageDeltas={"setting_entity_fidelity": 999},
)
)
self.assertEqual(report["status"], "failed")
self.assertEqual(report["metrics"]["anyDimensionDeclineOverHalfRatio"], 0.3)
def test_gain_threshold_distinguishes_passed_and_no_gain(self):
self.assertEqual(decide_gate_b(gate_b_input(ten_samples()))["status"], "passed")
no_gain = ten_samples()
for item in no_gain:
item["scores"] = scores(c_fidelity_delta=0.0)
self.assertEqual(decide_gate_b(gate_b_input(no_gain))["status"], "no_gain")
def test_stable_sample_requires_complete_a_b_c_scores(self):
missing_b = ten_samples()
missing_b[0]["scores"].pop("B")
with self.assertRaisesRegex(ValueError, "精确包含 A/B/C"):
decide_gate_b(gate_b_input(missing_b))
def test_b_minus_a_is_diagnostic_and_c_minus_a_drives_gate(self):
"""即使 B-A 明显退化,只要 C-A 达标,正式 Gate B 仍按 C-A 通过。"""
samples = ten_samples()
for item in samples:
item["scores"] = scores(
b_fidelity_delta=-2.0,
c_fidelity_delta=0.5,
)
report = decide_gate_b(gate_b_input(samples))
self.assertEqual(report["status"], "passed")
self.assertEqual(
report["metrics"]["averageDeltas"]["B-A"]["setting_entity_fidelity"],
-2.0,
)
self.assertEqual(
report["metrics"]["averageDeltas"]["C-A"]["setting_entity_fidelity"],
0.5,
)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""正文五维盲评量表的离线回归测试。"""
from __future__ import annotations
import pathlib
import sys
import unittest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from writer_rubric import ( # noqa: E402
DIMENSIONS,
RUBRIC_PROFILE,
adjudicate_reviews,
deblind_reports,
validate_blind_pair,
validate_report,
)
def scorecard(score: float = 8.0) -> dict[str, dict[str, object]]:
"""构造包含四类证据归因的合法五维评分。"""
source_types = ("fine_outline", "historical_prose", "judge_inference")
return {
dimension: {
"score": score,
"evidence": [
{
"sourceType": source_types[index % len(source_types)],
"sourceRef": f"source:{dimension}",
"excerpt": f"证据-{dimension}",
}
],
}
for index, dimension in enumerate(DIMENSIONS)
}
def report(
reviewer_id: str,
scores: dict[str, dict[str, object]] | None = None,
*,
order: tuple[str, ...] = ("blind-1", "blind-2", "blind-3"),
) -> dict[str, object]:
"""构造不携带真实臂名的单评委报告。"""
return {
"profile": RUBRIC_PROFILE,
"reviewerId": reviewer_id,
"sampleId": "deep-space-489",
"blindCandidateId": "blind-1",
"candidateOrder": list(order),
"scores": scores or scorecard(),
}
class WriterRubricValidationTest(unittest.TestCase):
"""验证量表结构与盲评纪律。"""
def test_five_dimensions_use_zero_to_ten_half_steps(self):
self.assertEqual(len(DIMENSIONS), 5)
self.assertEqual(validate_report(report("judge-1", scorecard(0.0))), [])
self.assertEqual(validate_report(report("judge-1", scorecard(10.0))), [])
bad = scorecard(8.0)
bad[DIMENSIONS[0]]["score"] = 8.25
self.assertTrue(any("0.5" in error for error in validate_report(report("judge-1", bad))))
def test_each_dimension_requires_typed_evidence(self):
bad = scorecard()
bad[DIMENSIONS[2]]["evidence"] = []
self.assertTrue(any("证据" in error for error in validate_report(report("judge-1", bad))))
bad = scorecard()
bad[DIMENSIONS[3]]["evidence"][0]["sourceType"] = "target_answer"
self.assertTrue(any("来源类型" in error for error in validate_report(report("judge-1", bad))))
hidden_card = scorecard()
hidden_card[DIMENSIONS[0]]["evidence"][0]["sourceType"] = "card_index"
self.assertTrue(
any("来源类型" in error for error in validate_report(report("judge-1", hidden_card)))
)
def test_pair_must_be_independent_blind_and_reverse_ordered(self):
first = report("judge-1")
second = report("judge-2", order=("blind-3", "blind-2", "blind-1"))
self.assertEqual(validate_blind_pair(first, second), [])
same_order = report("judge-2")
self.assertTrue(any("反序" in error for error in validate_blind_pair(first, same_order)))
different_candidate = report(
"judge-2", order=("blind-3", "blind-2", "blind-1")
)
different_candidate["blindCandidateId"] = "blind-2"
self.assertTrue(
any("同一盲化候选" in error for error in validate_blind_pair(first, different_candidate))
)
leaked = report("judge-2", order=("blind-3", "blind-2", "blind-1"))
leaked["arm"] = "C"
self.assertTrue(any("去盲" in error for error in validate_blind_pair(first, leaked)))
def test_deblind_uses_preregistered_mapping_not_position(self):
reports = [
report("judge-1"),
{**report("judge-1"), "blindCandidateId": "blind-3"},
]
mapping = {"blind-1": "C", "blind-2": "A", "blind-3": "B"}
deblinded = deblind_reports(reports, mapping)
self.assertEqual([item["arm"] for item in deblinded], ["C", "B"])
class WriterRubricAdjudicationTest(unittest.TestCase):
"""验证双评稳定性与最多一次第三评委仲裁。"""
def test_gap_above_half_requires_third_reviewer_once(self):
first = report("judge-1", scorecard(8.0))
second_scores = scorecard(8.0)
second_scores[DIMENSIONS[0]]["score"] = 7.0
second = report("judge-2", second_scores, order=("blind-3", "blind-2", "blind-1"))
result = adjudicate_reviews(first, second)
self.assertEqual(result["status"], "needs_third_reviewer")
self.assertEqual(result["unstableDimensions"], [DIMENSIONS[0]])
def test_third_reviewer_uses_median_when_any_stable_pair_exists(self):
first = report("judge-1", scorecard(8.0))
second_scores = scorecard(8.0)
second_scores[DIMENSIONS[1]]["score"] = 6.5
second = report("judge-2", second_scores, order=("blind-3", "blind-2", "blind-1"))
third_scores = scorecard(8.0)
third_scores[DIMENSIONS[1]]["score"] = 7.5
third = report("judge-3", third_scores)
result = adjudicate_reviews(first, second, third)
self.assertEqual(result["status"], "adjudicated_report")
self.assertEqual(result["scores"][DIMENSIONS[1]], 7.5)
def test_third_reviewer_must_bind_same_candidate_and_candidate_set(self):
"""第三评不得用另一候选或漂移后的候选集合参与仲裁。"""
first = report("judge-1", scorecard(8.0))
second_scores = scorecard(8.0)
second_scores[DIMENSIONS[1]]["score"] = 6.5
second = report("judge-2", second_scores, order=("blind-3", "blind-2", "blind-1"))
different_candidate = report("judge-3", scorecard(7.5))
different_candidate["blindCandidateId"] = "blind-2"
candidate_result = adjudicate_reviews(first, second, different_candidate)
self.assertEqual(candidate_result["status"], "invalid_report")
self.assertTrue(any("同一盲化候选" in error for error in candidate_result["errors"]))
different_set = report(
"judge-3",
scorecard(7.5),
order=("blind-1", "blind-2", "blind-4"),
)
set_result = adjudicate_reviews(first, second, different_set)
self.assertEqual(set_result["status"], "invalid_report")
self.assertTrue(any("相同的盲化候选集合" in error for error in set_result["errors"]))
def test_no_stable_pair_marks_sample_invalid(self):
first = report("judge-1", scorecard(8.0))
second_scores = scorecard(8.0)
second_scores[DIMENSIONS[4]]["score"] = 6.5
second = report("judge-2", second_scores, order=("blind-3", "blind-2", "blind-1"))
third_scores = scorecard(8.0)
third_scores[DIMENSIONS[4]]["score"] = 9.5
third = report("judge-3", third_scores)
result = adjudicate_reviews(first, second, third)
self.assertEqual(result["status"], "invalid_unstable")
self.assertEqual(result["unstableDimensions"], [DIMENSIONS[4]])
self.assertNotIn("winner", result)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,586 @@
#!/usr/bin/env python3
"""正文智能体 Gate A/B 的确定性唯一判定器。
判定器只消费逐样本脱敏结果,并在可信边界内计算全部统计值。顶层传入的
样本数、平均分、覆盖率或混淆项等聚合字段一律不参与裁决,避免上游通过
伪造汇总改变终态。命中较高优先级终态后不再使用较低优先级条件改写结果。
"""
from __future__ import annotations
import argparse
import json
import math
import sys
from pathlib import Path
from typing import Any, Mapping, Sequence
from writer_rubric import DIMENSIONS
REQUIRED_SCENARIOS = frozenset(
{
"battle",
"character_dialogue",
"turning_point",
"information_reveal",
"returning_character",
}
)
CONFOUND_LABELS = {
"falseNegatives": "假阴",
"falsePositives": "假阳",
"leakage": "泄露",
"reviewerInstability": "评委不稳定",
"newCharactersWithoutCards": "新角色无卡",
}
STABLE_REVIEW_STATUSES = frozenset({"stable_report", "adjudicated_report"})
REVIEW_STATUSES = frozenset({*STABLE_REVIEW_STATUSES, "invalid_unstable"})
def _require_mapping(value: object, field: str) -> Mapping[str, Any]:
"""取得必需对象字段,避免缺失数据被静默当成空对象。"""
if not isinstance(value, Mapping):
raise ValueError(f"{field} 必须是对象")
return value
def _require_samples(value: object) -> list[Mapping[str, Any]]:
"""取得非空逐样本数组,Gate 不接受只有聚合值的输入。"""
if not isinstance(value, list) or not value:
raise ValueError("samples 必须是非空逐样本数组")
if any(not isinstance(item, Mapping) for item in value):
raise ValueError("samples 每项必须是对象")
return list(value)
def _require_integer(value: object, field: str) -> int:
"""取得非负整数计数。"""
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{field} 必须是非负整数")
return value
def _require_number(value: object, field: str) -> float:
"""取得有限数值,显式拒绝布尔值、NaN 和无穷值。"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{field} 必须是有限数值")
result = float(value)
if not math.isfinite(result):
raise ValueError(f"{field} 必须是有限数值")
return result
def _require_ratio(value: object, field: str) -> float:
"""取得闭区间 0-1 内的比例。"""
result = _require_number(value, field)
if not 0.0 <= result <= 1.0:
raise ValueError(f"{field} 必须在 0-1 之间")
return result
def _require_boolean(value: object, field: str) -> bool:
"""取得严格布尔值,拒绝 0/1 等隐式真假值。"""
if not isinstance(value, bool):
raise ValueError(f"{field} 必须是布尔值")
return value
def _sample_identity(sample: Mapping[str, Any], index: int) -> tuple[str, str, str]:
"""校验并返回样本 ID、作品 ID 和场景分类。"""
sample_id = sample.get("sampleId")
if not isinstance(sample_id, str) or not sample_id.strip():
raise ValueError(f"samples[{index}].sampleId 必须是非空字符串")
work_id = sample.get("workId")
if isinstance(work_id, bool) or not isinstance(work_id, (int, str)) or not str(work_id).strip():
raise ValueError(f"samples[{index}].workId 必须是非空整数或字符串")
scenario = sample.get("scenario")
if scenario not in REQUIRED_SCENARIOS:
raise ValueError(f"samples[{index}].scenario 未登记")
return sample_id, str(work_id), str(scenario)
def _sample_status(sample: Mapping[str, Any], index: int) -> dict[str, Any]:
"""从单样本机械结果推导有效性,不接受上游 valid 标记。"""
schema_valid = _require_boolean(sample.get("schemaValid"), f"samples[{index}].schemaValid")
future_leakage = _require_boolean(
sample.get("futureLeakage"), f"samples[{index}].futureLeakage"
)
system_failure = _require_boolean(
sample.get("systemFailure"), f"samples[{index}].systemFailure"
)
review_status = sample.get("reviewStatus")
if review_status not in REVIEW_STATUSES:
raise ValueError(
f"samples[{index}].reviewStatus 必须是 stable_report、"
"adjudicated_report 或 invalid_unstable"
)
return {
"schemaValid": schema_valid,
"futureLeakage": future_leakage,
"systemFailure": system_failure,
"reviewStatus": review_status,
"valid": (
schema_valid
and not future_leakage
and not system_failure
and review_status in STABLE_REVIEW_STATUSES
),
}
def _sample_c_arm(sample: Mapping[str, Any], index: int) -> dict[str, Any]:
"""读取单样本 C 臂机械门结果,聚合时不允许平均掩盖硬错误。"""
c_arm = _require_mapping(sample.get("cArm"), f"samples[{index}].cArm")
return {
"hardConstraintCoverage": _require_ratio(
c_arm.get("hardConstraintCoverage"),
f"samples[{index}].cArm.hardConstraintCoverage",
),
"highSeverityResidualCount": _require_integer(
c_arm.get("highSeverityResidualCount"),
f"samples[{index}].cArm.highSeverityResidualCount",
),
}
def _sample_scores(sample: Mapping[str, Any], index: int) -> dict[str, dict[str, float]]:
"""读取稳定样本的 A/B/C 三臂五维脱敏终分。"""
scores = _require_mapping(sample.get("scores"), f"samples[{index}].scores")
if set(scores) != {"A", "B", "C"}:
raise ValueError(f"samples[{index}].scores 必须精确包含 A/B/C")
result: dict[str, dict[str, float]] = {}
for arm in ("A", "B", "C"):
arm_scores = _require_mapping(scores[arm], f"samples[{index}].scores.{arm}")
if set(arm_scores) != set(DIMENSIONS):
raise ValueError(f"samples[{index}].scores.{arm} 必须精确包含正文五维")
result[arm] = {
dimension: _require_number(
arm_scores[dimension], f"samples[{index}].scores.{arm}.{dimension}"
)
for dimension in DIMENSIONS
}
return result
def _sample_confounds(sample: Mapping[str, Any], index: int) -> dict[str, list[Any]]:
"""校验单样本五类混淆项;顶层聚合混淆项不在信任边界内。"""
source = sample.get("confounders", {})
source = _require_mapping(source, f"samples[{index}].confounders")
unknown = sorted(set(source) - set(CONFOUND_LABELS))
if unknown:
raise ValueError(
f"samples[{index}].confounders 存在未知分类: {','.join(unknown)}"
)
result: dict[str, list[Any]] = {}
for category in CONFOUND_LABELS:
items = source.get(category, [])
if not isinstance(items, list):
raise ValueError(f"samples[{index}].confounders.{category} 必须是数组")
if any(not isinstance(item, (str, Mapping)) for item in items):
raise ValueError(
f"samples[{index}].confounders.{category} 只能包含字符串或对象"
)
result[category] = list(items)
return result
def _aggregate_confounds(samples: Sequence[Mapping[str, Any]]) -> dict[str, list[Any]]:
"""逐样本汇总五类混淆项,并补入可机械推导的泄露与不稳定记录。"""
aggregated = {category: [] for category in CONFOUND_LABELS}
for index, sample in enumerate(samples):
sample_id, _, _ = _sample_identity(sample, index)
status = _sample_status(sample, index)
for category, items in _sample_confounds(sample, index).items():
for item in items:
if isinstance(item, str):
aggregated[category].append({"sampleId": sample_id, "detail": item})
else:
aggregated[category].append({**dict(item), "sampleId": sample_id})
if status["futureLeakage"] and not any(
item.get("sampleId") == sample_id for item in aggregated["leakage"]
):
aggregated["leakage"].append(
{"sampleId": sample_id, "code": "future_leakage_detected"}
)
if status["reviewStatus"] == "invalid_unstable" and not any(
item.get("sampleId") == sample_id
for item in aggregated["reviewerInstability"]
):
aggregated["reviewerInstability"].append(
{"sampleId": sample_id, "code": "invalid_unstable"}
)
return aggregated
def _report(
gate: str,
status: str,
reasons: list[str],
confounds: dict[str, list[Any]],
metrics: Mapping[str, Any],
) -> dict[str, Any]:
"""组装稳定报告结构;reasons 顺序同时表达当前层内优先级。"""
return {
"schemaVersion": "writer-gate-report-v1",
"gate": gate,
"status": status,
"primaryReason": reasons[0],
"reasons": reasons,
"metrics": dict(metrics),
"confounds": confounds,
}
def _gate_a_metrics(samples: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"""完全从逐样本结果计算 Gate A 统计。"""
statuses: list[dict[str, Any]] = []
c_arms: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for index, sample in enumerate(samples):
sample_id, _, _ = _sample_identity(sample, index)
if sample_id in seen_ids:
raise ValueError(f"sampleId 重复: {sample_id}")
seen_ids.add(sample_id)
status = _sample_status(sample, index)
statuses.append(status)
# schema、泄漏或系统失败可能发生在 C 臂生成前,此时没有 cArm 是合法的
# 逐样本失败形态,不能让缺失的下游结果覆盖 Gate 的既定裁决顺序。
if (
status["schemaValid"]
and not status["futureLeakage"]
and not status["systemFailure"]
):
c_arms.append(_sample_c_arm(sample, index))
return {
"sampleCount": len(samples),
"validSampleCount": sum(item["valid"] for item in statuses),
"invalidSchemaCount": sum(not item["schemaValid"] for item in statuses),
"futureLeakageCount": sum(item["futureLeakage"] for item in statuses),
"systemFailureCount": sum(item["systemFailure"] for item in statuses),
"unstableSampleCount": sum(
item["reviewStatus"] == "invalid_unstable" for item in statuses
),
"cArmHighSeverityResidualCount": sum(
item["highSeverityResidualCount"] for item in c_arms
),
"cArmHardConstraintCoverage": min(
(item["hardConstraintCoverage"] for item in c_arms), default=1.0
),
}
def _short_circuit_confounds(gate_input: Mapping[str, Any]) -> dict[str, list[Any]]:
"""Gate A 已决定 Gate B 终态时,尽量保留合法逐样本混淆项。"""
if "samples" not in gate_input:
return {category: [] for category in CONFOUND_LABELS}
try:
return _aggregate_confounds(_require_samples(gate_input.get("samples")))
except ValueError:
# 混淆项属于报告信息,不能反向推翻更高优先级的 Gate A 终态。
return {category: [] for category in CONFOUND_LABELS}
def decide_gate_a(gate_input: Mapping[str, Any]) -> dict[str, Any]:
"""按“证据不足 -> 硬失败 -> 通过”唯一顺序裁决 Gate A。"""
gate_input = _require_mapping(gate_input, "Gate A 输入")
samples = _require_samples(gate_input.get("samples"))
normalized = _gate_a_metrics(samples)
confounds = _aggregate_confounds(samples)
if normalized["validSampleCount"] < 5:
return _report(
"A",
"insufficient_evidence",
["valid_sample_count_below_5"],
confounds,
normalized,
)
failure_reasons: list[str] = []
if normalized["invalidSchemaCount"] > 0:
failure_reasons.append("schema_invalid")
if normalized["futureLeakageCount"] > 0:
failure_reasons.append("future_leakage")
if normalized["systemFailureCount"] > 0:
failure_reasons.append("system_failure")
if normalized["cArmHighSeverityResidualCount"] > 0:
failure_reasons.append("c_arm_high_severity_residual")
if normalized["cArmHardConstraintCoverage"] < 1.0:
failure_reasons.append("c_arm_hard_constraint_coverage_below_100_percent")
if failure_reasons:
return _report("A", "failed", failure_reasons, confounds, normalized)
return _report(
"A", "passed", ["all_gate_a_conditions_met"], confounds, normalized
)
def _gate_b_metrics(samples: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"""完全从逐样本结果计算 Gate B 充分性、退化和增益指标。"""
work_counts: dict[str, int] = {}
scenarios: set[str] = set()
statuses: list[dict[str, Any]] = []
c_arms: list[dict[str, Any]] = []
deltas: dict[str, list[dict[str, float]]] = {"B-A": [], "C-A": []}
seen_ids: set[str] = set()
for index, sample in enumerate(samples):
sample_id, work_id, scenario = _sample_identity(sample, index)
if sample_id in seen_ids:
raise ValueError(f"sampleId 重复: {sample_id}")
seen_ids.add(sample_id)
work_counts[work_id] = work_counts.get(work_id, 0) + 1
scenarios.add(scenario)
status = _sample_status(sample, index)
statuses.append(status)
c_arms.append(_sample_c_arm(sample, index))
if status["reviewStatus"] in STABLE_REVIEW_STATUSES:
scores = _sample_scores(sample, index)
for comparison, arm in (("B-A", "B"), ("C-A", "C")):
deltas[comparison].append(
{
dimension: scores[arm][dimension] - scores["A"][dimension]
for dimension in DIMENSIONS
}
)
total_count = len(samples)
stable_count = len(deltas["C-A"])
averages = {
comparison: {
dimension: (
sum(item[dimension] for item in comparison_deltas) / stable_count
if stable_count
else 0.0
)
for dimension in DIMENSIONS
}
for comparison, comparison_deltas in deltas.items()
}
# Gate B 的正式退化与增益阈值只消费 C-A;B-A 仅作为卡索引单线的诊断信息。
c_minus_a_deltas = deltas["C-A"]
decline_ratios = {
dimension: (
sum(item[dimension] < -0.5 for item in c_minus_a_deltas) / stable_count
if stable_count
else 0.0
)
for dimension in DIMENSIONS
}
any_decline_ratio = (
sum(
any(item[dimension] < -0.5 for dimension in DIMENSIONS)
for item in c_minus_a_deltas
)
/ stable_count
if stable_count
else 0.0
)
fidelity_positive_ratio = (
sum(
item["setting_entity_fidelity"] > 0 for item in c_minus_a_deltas
)
/ stable_count
if stable_count
else 0.0
)
return {
"workSampleCounts": dict(sorted(work_counts.items())),
"workCount": len(work_counts),
"totalSampleCount": total_count,
"stableSampleCount": stable_count,
"coveredScenarios": sorted(scenarios),
"unstableSampleRatio": sum(
item["reviewStatus"] == "invalid_unstable" for item in statuses
)
/ total_count,
"cArmHardConstraintCoverage": min(
(item["hardConstraintCoverage"] for item in c_arms), default=0.0
),
"cArmHighSeverityResidualCount": sum(
item["highSeverityResidualCount"] for item in c_arms
),
"averageDeltas": averages,
"bMinusAAverageDeltas": averages["B-A"],
"cMinusAAverageDeltas": averages["C-A"],
"dimensionDeclineOverHalfRatios": decline_ratios,
"anyDimensionDeclineOverHalfRatio": any_decline_ratio,
"fidelityPositiveSampleRatio": fidelity_positive_ratio,
}
def decide_gate_b(gate_input: Mapping[str, Any]) -> dict[str, Any]:
"""按 Gate A、证据、退化、增益四层唯一顺序裁决 Gate B。"""
gate_input = _require_mapping(gate_input, "Gate B 输入")
samples = _require_samples(gate_input.get("samples"))
# 顶层 gateAStatus 不在可信边界内;必须用 Gate B 的同批逐样本结果重算。
gate_a_report = decide_gate_a({"gate": "A", "samples": samples})
gate_a_status = gate_a_report["status"]
gate_a_metrics = {
"recomputedGateAStatus": gate_a_status,
"recomputedGateAMetrics": gate_a_report["metrics"],
}
if gate_a_status == "insufficient_evidence":
return _report(
"B",
"insufficient_evidence",
["gate_a_insufficient_evidence"],
gate_a_report["confounds"],
gate_a_metrics,
)
if gate_a_status == "failed":
return _report(
"B",
"failed",
["gate_a_failed"],
gate_a_report["confounds"],
gate_a_metrics,
)
normalized = _gate_b_metrics(samples)
normalized.update(gate_a_metrics)
confounds = _aggregate_confounds(samples)
evidence_reasons: list[str] = []
if normalized["workCount"] < 2:
evidence_reasons.append("work_count_below_2")
if any(count < 5 for count in normalized["workSampleCounts"].values()):
evidence_reasons.append("per_work_sample_count_below_5")
if normalized["totalSampleCount"] < 10:
evidence_reasons.append("total_sample_count_below_10")
if not REQUIRED_SCENARIOS.issubset(normalized["coveredScenarios"]):
evidence_reasons.append("scenario_coverage_incomplete")
if normalized["unstableSampleRatio"] > 0.20:
evidence_reasons.append("unstable_sample_ratio_above_20_percent")
if evidence_reasons:
return _report(
"B", "insufficient_evidence", evidence_reasons, confounds, normalized
)
quality_reasons: list[str] = []
if normalized["cArmHardConstraintCoverage"] < 1.0:
quality_reasons.append("c_arm_hard_constraint_coverage_below_100_percent")
if normalized["cArmHighSeverityResidualCount"] > 0:
quality_reasons.append("c_arm_high_severity_residual")
averages = normalized["averageDeltas"]["C-A"]
if averages["style_consistency"] < -0.25:
quality_reasons.append("style_consistency_average_delta_below_minus_0_25")
if averages["narrative_tension"] < -0.25:
quality_reasons.append("narrative_tension_average_delta_below_minus_0_25")
if normalized["anyDimensionDeclineOverHalfRatio"] > 0.20:
quality_reasons.append("dimension_decline_over_half_ratio_above_20_percent")
if quality_reasons:
return _report("B", "failed", quality_reasons, confounds, normalized)
if (
averages["setting_entity_fidelity"] >= 0.25
and normalized["fidelityPositiveSampleRatio"] >= 0.60
):
return _report(
"B", "passed", ["fidelity_gain_threshold_met"], confounds, normalized
)
return _report(
"B", "no_gain", ["fidelity_gain_threshold_not_met"], confounds, normalized
)
def decide_gate(gate_input: Mapping[str, Any]) -> dict[str, Any]:
"""从带 gate 字段的统一输入分派 Gate A 或 Gate B。"""
gate_input = _require_mapping(gate_input, "gate-input.json")
gate = gate_input.get("gate")
if gate == "A":
return decide_gate_a(gate_input)
if gate == "B":
return decide_gate_b(gate_input)
raise ValueError("gate 必须是 A 或 B")
def render_summary(report: Mapping[str, Any]) -> str:
"""渲染只含内部统计、终态和混淆项的 Markdown 摘要。"""
lines = [
f"# Writer Gate {report['gate']} 裁决摘要",
"",
f"- 终态:`{report['status']}`",
f"- 主原因:`{report['primaryReason']}`",
f"- 原因码:`{', '.join(report['reasons'])}`",
"",
"## 混淆项",
"",
]
confounds = _require_mapping(report.get("confounds"), "report.confounds")
for category, label in CONFOUND_LABELS.items():
lines.append(f"### {label}")
lines.append("")
items = confounds.get(category, [])
if not items:
lines.append("- 未观察到或未报告")
else:
for item in items:
rendered = (
item
if isinstance(item, str)
else json.dumps(item, ensure_ascii=False, sort_keys=True)
)
lines.append(f"- {rendered}")
lines.append("")
return "\n".join(lines)
def _parse_args() -> argparse.Namespace:
"""解析任务计划约定的运行目录与摘要输出参数。"""
parser = argparse.ArgumentParser(description="裁决正文智能体 Gate A/B")
parser.add_argument(
"--run-dir", type=Path, required=True, help="包含 gate-input.json 的回放运行目录"
)
parser.add_argument("--summary-output", type=Path, help="可选的脱敏 Markdown 摘要输出路径")
return parser.parse_args()
def main() -> int:
"""执行离线判定,并写出唯一 JSON 终态和可选摘要。"""
args = _parse_args()
input_path = args.run_dir / "gate-input.json"
output_path = args.run_dir / "gate-report.json"
try:
gate_input = json.loads(input_path.read_text(encoding="utf-8"))
report = decide_gate(gate_input)
output_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if args.summary_output is not None:
args.summary_output.parent.mkdir(parents=True, exist_ok=True)
args.summary_output.write_text(render_summary(report), encoding="utf-8")
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"writer_gate: {error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""正文回放五维量表与确定性稳定性仲裁。"""
from __future__ import annotations
from statistics import median
from typing import Any, Mapping, Sequence
RUBRIC_PROFILE = "writer_replay"
DIMENSIONS = (
"setting_entity_fidelity",
"fine_outline_fidelity",
"style_consistency",
"narrative_tension",
"prose_readability",
)
EVIDENCE_SOURCE_TYPES = frozenset(
{"fine_outline", "historical_prose", "judge_inference"}
)
REPORT_FIELDS = frozenset(
{"profile", "reviewerId", "sampleId", "blindCandidateId", "candidateOrder", "scores"}
)
def _is_half_step(value: Any) -> bool:
"""只接受 0 到 10 的数字和 0.5 步长,布尔值不算数字。"""
return (
not isinstance(value, bool)
and isinstance(value, (int, float))
and 0 <= float(value) <= 10
and float(value) * 2 == int(float(value) * 2)
)
def validate_scores(scores: Any) -> list[str]:
"""校验五维分数、步长和逐项证据归因。"""
if not isinstance(scores, Mapping):
return ["scores 必须是对象"]
errors: list[str] = []
missing = [dimension for dimension in DIMENSIONS if dimension not in scores]
unexpected = sorted(set(scores) - set(DIMENSIONS))
if missing:
errors.append(f"缺少正文 rubric 维度: {','.join(missing)}")
if unexpected:
errors.append(f"存在未登记正文 rubric 维度: {','.join(unexpected)}")
for dimension in DIMENSIONS:
item = scores.get(dimension)
if not isinstance(item, Mapping):
errors.append(f"维度必须包含 score/evidence 对象: {dimension}")
continue
if set(item) != {"score", "evidence"}:
errors.append(f"维度字段必须精确为 score/evidence: {dimension}")
if not _is_half_step(item.get("score")):
errors.append(f"分数必须在 0-10 且使用 0.5 步长: {dimension}")
evidence = item.get("evidence")
if not isinstance(evidence, list) or not evidence:
errors.append(f"分数缺少证据: {dimension}")
continue
for index, raw in enumerate(evidence):
if not isinstance(raw, Mapping):
errors.append(f"证据必须是对象: {dimension}[{index}]")
continue
if set(raw) != {"sourceType", "sourceRef", "excerpt"}:
errors.append(f"证据字段必须精确为 sourceType/sourceRef/excerpt: {dimension}[{index}]")
continue
if raw.get("sourceType") not in EVIDENCE_SOURCE_TYPES:
errors.append(f"证据来源类型未登记: {dimension}[{index}]")
for field in ("sourceRef", "excerpt"):
if not isinstance(raw.get(field), str) or not raw[field].strip():
errors.append(f"证据 {field} 不能为空: {dimension}[{index}]")
return errors
def validate_report(report: Any) -> list[str]:
"""校验单个盲评报告,真实臂名或额外字段一律失败关闭。"""
if not isinstance(report, Mapping):
return ["评委报告必须是对象"]
errors: list[str] = []
if set(report) != REPORT_FIELDS:
errors.append("评委报告字段非法,去盲前不得包含真实臂名或额外信息")
if report.get("profile") != RUBRIC_PROFILE:
errors.append(f"profile 必须是 {RUBRIC_PROFILE}")
for field in ("reviewerId", "sampleId", "blindCandidateId"):
if not isinstance(report.get(field), str) or not report[field].strip():
errors.append(f"{field} 必须是非空字符串")
order = report.get("candidateOrder")
if (
not isinstance(order, list)
or len(order) < 2
or any(not isinstance(item, str) or not item for item in order)
or len(set(order)) != len(order)
):
errors.append("candidateOrder 必须是无重复盲化候选 ID 数组")
elif report.get("blindCandidateId") not in order:
errors.append("blindCandidateId 不在本轮 candidateOrder 中")
errors.extend(validate_scores(report.get("scores")))
return errors
def validate_blind_pair(first: Any, second: Any) -> list[str]:
"""确认双评独立、同样本、同候选集合且第二评委严格反序。"""
errors = [*validate_report(first), *validate_report(second)]
if errors or not isinstance(first, Mapping) or not isinstance(second, Mapping):
return errors
if first["reviewerId"] == second["reviewerId"]:
errors.append("双评委必须使用独立 reviewerId")
if first["sampleId"] != second["sampleId"]:
errors.append("双评委必须评审同一样本")
if first["blindCandidateId"] != second["blindCandidateId"]:
errors.append("双评委必须评审同一盲化候选")
if set(first["candidateOrder"]) != set(second["candidateOrder"]):
errors.append("双评委的 candidateOrder 必须包含同一盲化候选集合")
if second["candidateOrder"] != list(reversed(first["candidateOrder"])):
errors.append("第二评委必须对相同盲化候选严格反序")
return errors
def deblind_reports(
reports: Sequence[Mapping[str, Any]], mapping: Mapping[str, str]
) -> list[dict[str, Any]]:
"""评分结束后按预注册映射去盲,绝不依赖展示位置推断真实臂。"""
if set(mapping.values()) != {"A", "B", "C"} or len(mapping) != 3:
raise ValueError("去盲映射必须将三个盲 ID 一一映射到 A/B/C")
result: list[dict[str, Any]] = []
for index, report in enumerate(reports):
errors = validate_report(report)
if errors:
raise ValueError(f"reports[{index}] 非法: {'; '.join(errors)}")
blind_id = str(report["blindCandidateId"])
if blind_id not in mapping:
raise ValueError(f"reports[{index}] 的 blindCandidateId 未预注册")
result.append({**dict(report), "arm": mapping[blind_id]})
return result
def _numeric_scores(report: Mapping[str, Any]) -> dict[str, float]:
"""从已校验报告提取确定性浮点分数。"""
return {
dimension: float(report["scores"][dimension]["score"])
for dimension in DIMENSIONS
}
def _stable_pair_exists(values: Sequence[float], threshold: float) -> bool:
"""判断三个评分中是否至少存在一对落在稳定阈值内。"""
return any(
abs(values[left] - values[right]) <= threshold
for left in range(len(values))
for right in range(left + 1, len(values))
)
def adjudicate_reviews(
first: Mapping[str, Any],
second: Mapping[str, Any],
third: Mapping[str, Any] | None = None,
*,
threshold: float = 0.5,
) -> dict[str, Any]:
"""按双评差异触发最多一次第三评委,并输出稳定终态。"""
pair_errors = validate_blind_pair(first, second)
if pair_errors:
return {"status": "invalid_report", "errors": pair_errors}
first_scores = _numeric_scores(first)
second_scores = _numeric_scores(second)
unstable = [
dimension
for dimension in DIMENSIONS
if abs(first_scores[dimension] - second_scores[dimension]) > threshold
]
if not unstable:
return {
"status": "stable_report",
"scores": {
dimension: (first_scores[dimension] + second_scores[dimension]) / 2
for dimension in DIMENSIONS
},
"unstableDimensions": [],
"reviewCount": 2,
}
if third is None:
return {
"status": "needs_third_reviewer",
"unstableDimensions": unstable,
"reviewCount": 2,
}
third_errors = validate_report(third)
if third_errors:
return {"status": "invalid_report", "errors": third_errors}
if third["sampleId"] != first["sampleId"]:
return {"status": "invalid_report", "errors": ["第三评委必须评审同一样本"]}
if third["blindCandidateId"] != first["blindCandidateId"]:
return {"status": "invalid_report", "errors": ["第三评委必须评审同一盲化候选"]}
allowed_third_orders = (
first["candidateOrder"],
list(reversed(first["candidateOrder"])),
)
if third["candidateOrder"] not in allowed_third_orders:
return {
"status": "invalid_report",
"errors": ["第三评委必须使用与双评相同的盲化候选集合及第一评或反序顺序"],
}
if third["reviewerId"] in {first["reviewerId"], second["reviewerId"]}:
return {"status": "invalid_report", "errors": ["第三评委必须使用独立 reviewerId"]}
third_scores = _numeric_scores(third)
unresolved = [
dimension
for dimension in unstable
if not _stable_pair_exists(
[first_scores[dimension], second_scores[dimension], third_scores[dimension]],
threshold,
)
]
if unresolved:
return {
"status": "invalid_unstable",
"unstableDimensions": unresolved,
"reviewCount": 3,
}
return {
"status": "adjudicated_report",
"scores": {
dimension: float(
median(
[first_scores[dimension], second_scores[dimension], third_scores[dimension]]
)
)
for dimension in DIMENSIONS
},
"unstableDimensions": unstable,
"reviewCount": 3,
}
__all__ = [
"RUBRIC_PROFILE",
"DIMENSIONS",
"EVIDENCE_SOURCE_TYPES",
"validate_scores",
"validate_report",
"validate_blind_pair",
"deblind_reports",
"adjudicate_reviews",
]

View File

@ -197,6 +197,8 @@ writer 可以创造非 Canonical 的环境细节、动作、过渡、无名配
| `narrativeState` | object | 是 | 时间、地点、角色位置、即时局面 |
| `factEvidence[]` | array | 是 | 事实 ID、来源类型、sourceRef、hash |
| `proseEvidence[]` | array | 是 | 章号、offset、hash、用途、临时片段 |
| `indexHints[]` | array | 否 | 仅诊断上下文可用的冻结卡提示;严格字段为 `cardId/name/type/content/sourceId/sourceVersion/asOf` |
| `evidenceStrategy` | enum | 否 | 生产缺省视为 `production_dual_evidence`;正文 A/B/C 回放必须显式记录各臂证据策略 |
| `patternReferences[]` | array | 否 | 已授权范式卡,只作结构方法参考 |
| `evidenceCoverage[]` | array | 是 | 细纲要素到证据的覆盖状态 |
| `outputContract` | object | 是 | 篇幅、场景、frontmatter、申报规则 |
@ -204,6 +206,8 @@ writer 可以创造非 Canonical 的环境细节、动作、过渡、无名配
所有对象使用严格 schema,额外字段失败;引用的 ID、版本和 hash 必须存在且一致。
`evidenceStrategy` 是兼容字段:生产上下文未填写时按 `production_dual_evidence` 校验;正文 A/B/C 回放不得使用缺省值,必须分别显式填写 `historical_prose_only`、`card_index_only`、`card_index_plus_prose`。`indexHints` 只能在 `mode=diagnostic_only`、`purpose=evaluation/diagnostic` 且 `acceptanceEligible=false` 时出现;生产上下文硬拒绝。其 `asOf` 不得超过上下文冻结点,`claimLedger` 不得引用 `indexHints`。只有显式填写 `evidenceStrategy=card_index_only` 的 B 臂允许在合同内跳过连续四章基线,并且必须保持 `proseEvidence=[]`;该例外不能用于生产。
### 7.2 `WriterOutput v1`
```text
@ -269,6 +273,7 @@ detector 只阻断可定位、可验证的问题:细纲硬约束漏项、事
- 离线优化允许最多 5 轮,且每轮只改一个变量;与生产两轮返修是两套状态机。
- 三臂使用相同细纲、冻结点、模型、篇幅算法和最大生成预算。
- 候选臂名映射为随机化 ID;顺序种子由预注册 `evaluationSetVersion + sampleId` 派生并固定。
- judge 的可信输入边界是独立 `writer-blind-input-v1` 内容,包含 `blind-1/2/3` 候选正文/哈希,以及所有臂完全相同的 `sharedEvaluationReference`。共同参考只取目标章细纲硬约束、实体、必需角色、伏笔、章末钩子和冻结点前连续四章历史原文基准,用于评价细纲忠实、设定、文风和卡索引正确性;它可在真实运行时临时传递,但不进入安全摘要。共同参考严禁包含 `indexHints`、各臂补充原文、卡 manifest、`evidenceStrategy`、真实映射或臂名,不能用被测卡本身给被测候选背书。真实 A/B/C 映射仅在编排器内存中存在,judge 不得访问各臂 WriterContext、原始运行目录或含 `candidate-A/B/C` 的路径。
- 两个评委使用独立无会话实例,第二评委反转顺序;评分步长为 0.5。
- 同维分差 > 0.5 时判不稳定,最多增加一次第三评委。三评分中若至少一对差值 <=0.5,则该维最终分取三者中位数;若不存在稳定配对,则样本进入 `invalid_unstable`,不参与方向结论。
- 五维:设定与实体保真、情节与细纲忠实、叙事完整与张力、文风一致、文笔质量。
@ -288,6 +293,36 @@ detector 只阻断可定位、可验证的问题:细纲硬约束漏项、事
只有 Gate B=`passed` 才解锁细纲智能体真实能力验收;`no_gain` 只形成“卡未证明增益”结论,不视为通过。
### 10.2 Task10 Gate A 预注册与当前可验证边界
唯一配置为 `.claude/skills/replay-eval/configs/writer-gate-a-deep-space-v1.json`。样本固定如下,不得根据生成结果替换章节或场景分类:
| 目标章 | 冻结点 | 场景 | 预注册目标 | 大纲/细纲来源 | 预期长度 | 主要实体 | 新角色比例 | 泄漏检查 |
|---:|---:|---|---|---|---:|---|---:|---|
| 489 | 488 | 战斗 | 圣蒂曼围攻/加特朗战 | `outline-window:deep-space:481-488` / `fine-outline:deep-space:489` | 7500 | 圣蒂曼、加特朗 | 1.0 | 禁读 489 及以后;目标/未来读取数必须为 0 |
| 321 | 320 | 人物对话 | 莫妮卡道别与朋友确认 | `outline-window:deep-space:313-320` / `fine-outline:deep-space:321` | 7600 | 莫妮卡、朋友关系 | 0 | 禁读 321 及以后;目标/未来读取数必须为 0 |
| 544 | 543 | 转折 | 迷途之地内应反水 | `outline-window:deep-space:536-543` / `fine-outline:deep-space:544` | 6700 | 迷途之地、内应 | 未知 | 禁读 544 及以后;目标/未来读取数必须为 0 |
| 199 | 198 | 信息揭示 | 赛莉丝身份与联赛锁死真相 | `outline-window:deep-space:191-198` / `fine-outline:deep-space:199` | 2000 | 赛莉丝、联赛 | 0 | 禁读 199 及以后;目标/未来读取数必须为 0 |
| 523 | 522 | 老角色回归 | 伊蕾莉雅与旧部重逢 | `outline-window:deep-space:515-522` / `fine-outline:deep-space:523` | 6100 | 伊蕾莉雅、旧部 | 0 | 禁读 523 及以后;目标/未来读取数必须为 0 |
预期长度不是人工填写:配置记录目标章之前连续四章的 `frozenRecentHanCounts`,统一以 `hardEventCount=1`、`foreshadowingActionCount=0`、`requiredSceneCount=0` 调用 `calculate_target_chars`,且 `usesTargetChapterLength=false`。五章机械结果固定为 489=7500、321=7600、544=6700、199=2000、523=6100;上下限取正负 10% 后再受 2000-10000 限幅。
新角色比例只统计具名 `requiredCharacters` 在冻结点前无记录的比例。489 的加特朗在 488 前无记录,因此为 1.0;321/199/523 的具名角色已有记录,因此为 0;544 的“内应”是泛称、无法机械判定具体身份,必须为 `null + unresolved_generic_role`,不允许默认成 0。
仓内配置不含原文全文。`writerContextInput.contentMode=sanitized_contract_fixture`,连续四章只放脱敏合成短文本,用于 dry-run 机械证明 WriterContext 合同、冻结边界、三臂差异、manifest 可复现和候选不可接受;不能据此声称历史原文完整、生成质量通过或 real-run 完成。
dry-run 命令:
```bash
.venv/bin/python .claude/skills/replay-eval/scripts/run_writer_replay.py \
--config .claude/skills/replay-eval/configs/writer-gate-a-deep-space-v1.json \
--dry-run
```
dry-run 只生成计划、manifest 和上下文摘要,不调用模型。测试注入路径虽可验证 writer、detector 与盲评编排,但 judge 只接收独立盲化内容和共同评测参考,不能取得 raw 目录、真实臂映射或任一臂专属证据;评委顺序变化不得改变共同参考。当前真实 semantic detector 与 judge adapter 尚未实现,CLI `--execute` 必须明确失败关闭;在 adapter 接线、离线测试和预算确认完成前,不得声称真实回放或 Gate A 已完成。
Gate 判定只消费逐样本脱敏输入。`writer_gate.py` 在可信边界内自行计算 Gate A/B 的有效样本、作品/场景覆盖、C 臂硬门、C-A 五维增量、退化比例与五类混淆项;不信任上游聚合数字。五类混淆项为假阴、假阳、泄露、评委不稳定和新角色无卡,必须逐样本记录后汇总。
## 11. 用户闭环与下游交接
Shadow 候选展示后提供三决策: