实现: 装配正文五章真实冻结评测

将卡稳定选择器、冻结历史正文和目标 scaffold 绑定到同一只读快照;固定上下文预算并净化诊断索引,收紧盲评输入边界,防止目标事实和选卡信息泄漏。
This commit is contained in:
zizi 2026-07-21 16:09:23 +08:00
parent b9ff4d0b40
commit fa922f8cc5
10 changed files with 2046 additions and 19 deletions

View File

@ -392,9 +392,22 @@ def _index_hint(card: Mapping[str, Any], *, as_of: int) -> dict[str, Any]:
latest = state_as_of[-1] latest = state_as_of[-1]
if not isinstance(latest, Mapping): if not isinstance(latest, Mapping):
raise RetrievalError(f"卡 {card.get('cardId')} 最新冻结状态非法") raise RetrievalError(f"卡 {card.get('cardId')} 最新冻结状态非法")
content = normalize_text( if card.get("sourceKind") == "eval_draft":
str(latest.get("fact") or latest.get("台阶") or canonical_json(latest)) # 诊断卡只能公开索引元数据;里程碑台阶属于被测信息,不能再透传给 B/C 臂。
) content = canonical_json(
{
"name": str(card.get("name") or ""),
"type": str(card.get("type") or ""),
"sourceChapters": sorted(
{_chapter(ref.get("chapter"), "card.sourceRefs.chapter") for ref in card.get("sourceRefs", [])}
),
}
)
else:
# 生产 Canonical 卡保持原有冻结状态提示行为。
content = normalize_text(
str(latest.get("fact") or latest.get("台阶") or canonical_json(latest))
)
hint = { hint = {
"cardId": str(card.get("cardId") or ""), "cardId": str(card.get("cardId") or ""),
"name": str(card.get("name") or ""), "name": str(card.get("name") or ""),
@ -491,7 +504,11 @@ def retrieve_writer_sources(
"chapter": _chapter(row.get("chapter"), "prose.chapter"), "chapter": _chapter(row.get("chapter"), "prose.chapter"),
"sourceRef": copy.deepcopy(ref), "sourceRef": copy.deepcopy(ref),
"contentSha256": _content_hash(text), "contentSha256": _content_hash(text),
"purpose": str(row.get("purpose") or "card_source"), "purpose": (
"card_chapter_proxy"
if ref.get("sourceType") == "card_chapter_proxy"
else str(row.get("purpose") or "card_source")
),
"text": text, "text": text,
"isRecentBaseline": False, "isRecentBaseline": False,
} }

View File

@ -292,6 +292,29 @@ class RetrieveWriterSourcesTest(unittest.TestCase):
[{"sourceId": "canonical-entity:2", "reason": "missing_source_refs"}], [{"sourceId": "canonical-entity:2", "reason": "missing_source_refs"}],
) )
def test_eval_draft_hint_only_contains_index_metadata_and_proxy_is_explicit(self):
"""评测卡不透传里程碑台阶,整章代理必须保留降级来源和用途。"""
replay_card = card("1", 0.9)
replay_card["sourceKind"] = "eval_draft"
replay_card["sourceRefs"][0]["sourceType"] = "card_chapter_proxy"
result = retrieve_writer_sources(
plan=self.plan,
card_repository=FakeCardRepository([replay_card]),
prose_repository=FakeProseRepository(),
)
hint_content = result["indexHints"][0]["content"]
self.assertEqual(
hint_content,
'{"name":"角色1","sourceChapters":[3],"type":"character"}',
)
self.assertNotIn("冻结线内状态", hint_content)
self.assertEqual(
result["proseEvidence"][0]["sourceRef"]["sourceType"],
"card_chapter_proxy",
)
self.assertEqual(result["proseEvidence"][0]["purpose"], "card_chapter_proxy")
def test_snapshot_transaction_is_repeatable_read_and_read_only(self): def test_snapshot_transaction_is_repeatable_read_and_read_only(self):
connection = FakeConnection() connection = FakeConnection()
begin_read_snapshot(connection) begin_read_snapshot(connection)

View File

@ -61,6 +61,8 @@ disable-model-invocation: true
正文 Gate A 固定配置为 `configs/writer-gate-a-deep-space-v1.json`,预注册深空之影五章:489 战斗、321 人物对话、544 转折、199 信息揭示、523 老角色回归。不得根据候选结果换章、换场景分类或修改冻结点;每个冻结点必须等于 `targetChapter-1`。 正文 Gate A 固定配置为 `configs/writer-gate-a-deep-space-v1.json`,预注册深空之影五章:489 战斗、321 人物对话、544 转折、199 信息揭示、523 老角色回归。不得根据候选结果换章、换场景分类或修改冻结点;每个冻结点必须等于 `targetChapter-1`。
真实五章装配还必须在 `commonControls` 预注册选择器版本、选择器规范 JSON 的原始字节 SHA-256、`inputProvenance=oracle_reference_scaffold` 和统一 `maxContextChars=140000`。loader 在数据库读取前机械核对这些值:选择器篡改、来源标记变化或样本预算与公共控制不一致都失败关闭;loader 不得根据实际文本抬高预算。连续四章基线装不下时由 assembler 阻断,弱补充原文由 assembler 按预算裁剪。
每个样本必须提供 `writerContextInput`。仓内配置只允许 `contentMode=sanitized_contract_fixture`,`recentChapters` 只能放连续四章的脱敏合成短文本,用于证明 WriterContext 合同、章号冻结和 A/B/C 差异策略;它不是历史原文,不证明真实正文完整性或文学质量。配置可以记录细纲硬约束、主要实体、预期篇幅、新角色比例和泄漏哨兵,但不得包含原书全文、完整目标细纲或标准答案。 每个样本必须提供 `writerContextInput`。仓内配置只允许 `contentMode=sanitized_contract_fixture`,`recentChapters` 只能放连续四章的脱敏合成短文本,用于证明 WriterContext 合同、章号冻结和 A/B/C 差异策略;它不是历史原文,不证明真实正文完整性或文学质量。配置可以记录细纲硬约束、主要实体、预期篇幅、新角色比例和泄漏哨兵,但不得包含原书全文、完整目标细纲或标准答案。
三臂都必须构造并校验 `WriterContext v1`,且固定 `mode=diagnostic_only`、`purpose=evaluation`、`acceptanceEligible=false`: 三臂都必须构造并校验 `WriterContext v1`,且固定 `mode=diagnostic_only`、`purpose=evaluation`、`acceptanceEligible=false`:
@ -69,13 +71,15 @@ disable-model-invocation: true
- B:`evidenceStrategy=card_index_only`,只放 `retrievalResult.indexHints`,`proseEvidence` 为空;这是合同内可审计的诊断基线例外,不是生产旁路。 - B:`evidenceStrategy=card_index_only`,只放 `retrievalResult.indexHints`,`proseEvidence` 为空;这是合同内可审计的诊断基线例外,不是生产旁路。
- C:`evidenceStrategy=card_index_plus_prose`,保留连续四章脱敏基线,并放冻结 `indexHints` 与配置中的补充原文证据。 - C:`evidenceStrategy=card_index_plus_prose`,保留连续四章脱敏基线,并放冻结 `indexHints` 与配置中的补充原文证据。
`indexHints` 每项只能包含 `cardId/name/type/content/sourceId/sourceVersion/asOf`,只能用于 `diagnostic_only` 的 `evaluation/diagnostic`,不得进入生产上下文;`claimLedger` 不得引用它。所有 `asOf` 和来源章号不得超过样本冻结点。 `indexHints` 每项只能包含 `cardId/name/type/content/sourceId/sourceVersion/asOf`,只能用于 `diagnostic_only` 的 `evaluation/diagnostic`,不得进入生产上下文;`claimLedger` 不得引用它。eval_draft 的 `content` 只能编码卡名、类型和实际回读章号,不得透传里程碑台阶;生产 Canonical 卡行为不变。所有 `asOf` 和来源章号不得超过样本冻结点。
卡没有持久化 `sourceRefs` 时,允许把冻结线内最近三个里程碑章的整章 Canonical block 作为降级回读,但每个引用必须标记 `sourceType=card_chapter_proxy`,对应 `proseEvidence.purpose=card_chapter_proxy`,不得冒充精确片段。每个代理章必须实际出现卡规范名或选择器预注册的合法别名,否则失败关闭。
每个样本必须记录 `frozenRecentHanCounts` 和 `targetLengthBasis`。篇幅只能使用冻结点前连续四章汉字数,经 `calculate_target_chars` 复算;当前 Gate A 固定 `hardEventCount=1`、`foreshadowingActionCount=0`、`requiredSceneCount=0`,禁止读取目标章实际长度。`expectedLength` 与 WriterContext 的输出合同必须等于机械目标的正负 10%,再受 2000-10000 边界限制。 每个样本必须记录 `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。 `newCharacterRatio` 定义为:具名 `requiredCharacters` 中,在 `asOfChapter` 前无记录的角色比例。真实 loader 必须在同一 `REPEATABLE READ READ ONLY` 事务内直接扫描冻结历史 Canonical 正文,按名字返回首次命中章和命中章集合,再重算 `knownBeforeAsOf/absentBeforeAsOf/newCharacterRatio`;卡内容不得参与这个判定。泛称角色不参与猜测;例如第 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` 的路径;卡的正确性只能由共同历史原文基准验证,不能把被测卡本身当裁判。映射只保留在编排器内存中,评分完成后才去盲。 盲评使用独立的 `writer-blind-input-v1` 内容边界。judge 只能看到 `blind-1/2/3` 候选正文/哈希,以及所有臂完全相同的 `sharedEvaluationReference`:细纲严格四字段 `sourceRef/hardConstraints/adjustableBeats/declaredNewFacts`、必需角色、伏笔、章末钩子和冻结点前连续四章历史原文基准;不得增加 `entities`。共同参考可以在真实运行时临时传给 judge,但不能进入安全摘要。它不得包含 `indexHints`、各臂补充原文、卡 manifest、`evidenceStrategy`、真实 A/B/C 映射、各臂 WriterContext、`raw` 目录或任何包含 `candidate-A/B/C` 的路径;卡的正确性只能由共同历史原文基准验证,不能把被测卡本身当裁判。映射只保留在编排器内存中,评分完成后才去盲。
正文 dry-run 命令: 正文 dry-run 命令:

View File

@ -0,0 +1,64 @@
{
"selectorVersion": "writer-gate-a-deep-space-card-selectors-v1",
"evaluationSetVersion": "writer-gate-a-deep-space-v1",
"workId": 8,
"samples": [
{
"sampleId": "deep-space-489-battle",
"targetChapter": 489,
"cards": [
{
"type": "location",
"name": "圣蒂曼行星"
}
]
},
{
"sampleId": "deep-space-321-character-dialogue",
"targetChapter": 321,
"cards": [
{
"type": "character",
"name": "莫妮卡"
}
]
},
{
"sampleId": "deep-space-544-turning-point",
"targetChapter": 544,
"cards": [
{
"type": "location",
"name": "迷途之地"
}
]
},
{
"sampleId": "deep-space-199-information-reveal",
"targetChapter": 199,
"cards": [
{
"type": "character",
"name": "赛莉丝"
},
{
"type": "event",
"name": "联赛系统被机械一族入侵",
"sourceAliases": [
"联赛系统"
]
}
]
},
{
"sampleId": "deep-space-523-returning-character",
"targetChapter": 523,
"cards": [
{
"type": "character",
"name": "伊蕾莉雅"
}
]
}
]
}

View File

@ -43,6 +43,10 @@
}, },
"commonControls": { "commonControls": {
"workId": 8, "workId": 8,
"selectorVersion": "writer-gate-a-deep-space-card-selectors-v1",
"selectorSha256": "sha256:9df06f7d5864696deda3dd119f8cbded01ba9a9c7e5bf5655553df938f267691",
"inputProvenance": "oracle_reference_scaffold",
"maxContextChars": 140000,
"modelVersion": "writer-model-unwired", "modelVersion": "writer-model-unwired",
"sampling": { "sampling": {
"temperature": 0, "temperature": 0,
@ -248,7 +252,7 @@
"newSettingDeclarationRequired": true "newSettingDeclarationRequired": true
}, },
"tokenBudget": { "tokenBudget": {
"maxContextChars": 50000 "maxContextChars": 140000
}, },
"generatedAt": "2026-07-20T00:00:00Z", "generatedAt": "2026-07-20T00:00:00Z",
"requirements": { "requirements": {
@ -475,7 +479,7 @@
"newSettingDeclarationRequired": true "newSettingDeclarationRequired": true
}, },
"tokenBudget": { "tokenBudget": {
"maxContextChars": 50000 "maxContextChars": 140000
}, },
"generatedAt": "2026-07-20T00:00:00Z", "generatedAt": "2026-07-20T00:00:00Z",
"requirements": { "requirements": {
@ -709,7 +713,7 @@
"newSettingDeclarationRequired": true "newSettingDeclarationRequired": true
}, },
"tokenBudget": { "tokenBudget": {
"maxContextChars": 50000 "maxContextChars": 140000
}, },
"generatedAt": "2026-07-20T00:00:00Z", "generatedAt": "2026-07-20T00:00:00Z",
"requirements": { "requirements": {
@ -943,7 +947,7 @@
"newSettingDeclarationRequired": true "newSettingDeclarationRequired": true
}, },
"tokenBudget": { "tokenBudget": {
"maxContextChars": 50000 "maxContextChars": 140000
}, },
"generatedAt": "2026-07-20T00:00:00Z", "generatedAt": "2026-07-20T00:00:00Z",
"requirements": { "requirements": {
@ -1177,7 +1181,7 @@
"newSettingDeclarationRequired": true "newSettingDeclarationRequired": true
}, },
"tokenBudget": { "tokenBudget": {
"maxContextChars": 50000 "maxContextChars": 140000
}, },
"generatedAt": "2026-07-20T00:00:00Z", "generatedAt": "2026-07-20T00:00:00Z",
"requirements": { "requirements": {

File diff suppressed because it is too large Load Diff

View File

@ -67,6 +67,13 @@ SNAPSHOT_COMPAT_ARMS = (
"outline_plus_placebo_cards", "outline_plus_placebo_cards",
) )
CONTENT_MODES = frozenset({"sanitized_contract_fixture", "canonical_frozen_prose"}) CONTENT_MODES = frozenset({"sanitized_contract_fixture", "canonical_frozen_prose"})
# Judge 只能复用写手细纲合同中的四个字段,不能直接信任原始评测配置。
FINE_OUTLINE_JUDGE_FIELDS = (
"sourceRef",
"hardConstraints",
"adjustableBeats",
"declaredNewFacts",
)
class WriterReplayError(ValueError): class WriterReplayError(ValueError):
@ -768,6 +775,7 @@ def _candidate_summary(candidate: Mapping[str, Any], arm: str) -> dict[str, Any]
def _build_blind_input( def _build_blind_input(
*, *,
raw_sample: Mapping[str, Any], raw_sample: Mapping[str, Any],
writer_fine_outline: Mapping[str, Any],
candidates: Mapping[str, Mapping[str, Any]], candidates: Mapping[str, Mapping[str, Any]],
mapping: Mapping[str, str], mapping: Mapping[str, str],
blind_id: str, blind_id: str,
@ -798,10 +806,14 @@ def _build_blind_input(
) )
context_input = _mapping(raw_sample.get("writerContextInput"), "writerContextInput") context_input = _mapping(raw_sample.get("writerContextInput"), "writerContextInput")
requirements = _mapping(context_input.get("requirements"), "writerContextInput.requirements") requirements = _mapping(context_input.get("requirements"), "writerContextInput.requirements")
if set(writer_fine_outline) != set(FINE_OUTLINE_JUDGE_FIELDS):
raise WriterReplayError("写手细纲字段必须严格匹配 judge 共同参考合同")
shared_reference = { shared_reference = {
"fineOutline": copy.deepcopy( # 只复制写手已通过合同校验的四字段视图,禁止从原始 fineOutline 倾倒评委不可见字段。
_mapping(context_input.get("fineOutline"), "writerContextInput.fineOutline") "fineOutline": {
), field: copy.deepcopy(writer_fine_outline[field])
for field in FINE_OUTLINE_JUDGE_FIELDS
},
"requirements": { "requirements": {
"requiredEvents": copy.deepcopy( "requiredEvents": copy.deepcopy(
_sequence(requirements.get("requiredEvents"), "requirements.requiredEvents") _sequence(requirements.get("requiredEvents"), "requirements.requiredEvents")
@ -905,11 +917,24 @@ def _execute_sample_for_test(
} }
mapping, blind_order = _blind_mapping(evaluation_set_version, str(prepared["sampleId"])) mapping, blind_order = _blind_mapping(evaluation_set_version, str(prepared["sampleId"]))
# 共同参考取自实际写手上下文,并在进入 judge 前确认三臂视图完全一致。
writer_fine_outline = _mapping(
prepared["_contexts"][REQUIRED_ARMS[0]].get("fineOutline"),
"writerContext.fineOutline",
)
for arm in REQUIRED_ARMS[1:]:
current_fine_outline = _mapping(
prepared["_contexts"][arm].get("fineOutline"),
f"writerContext.{arm}.fineOutline",
)
if current_fine_outline != writer_fine_outline:
raise WriterReplayError("三臂写手细纲不一致,不能构造共同评测参考")
reviews: dict[str, Any] = {} reviews: dict[str, Any] = {}
first_reports: list[Mapping[str, Any]] = [] first_reports: list[Mapping[str, Any]] = []
for blind_id in blind_order: for blind_id in blind_order:
first_input = _build_blind_input( first_input = _build_blind_input(
raw_sample=raw_sample, raw_sample=raw_sample,
writer_fine_outline=writer_fine_outline,
candidates=raw_candidates, candidates=raw_candidates,
mapping=mapping, mapping=mapping,
blind_id=blind_id, blind_id=blind_id,
@ -919,6 +944,7 @@ def _execute_sample_for_test(
first_reports.append(first) first_reports.append(first)
second_input = _build_blind_input( second_input = _build_blind_input(
raw_sample=raw_sample, raw_sample=raw_sample,
writer_fine_outline=writer_fine_outline,
candidates=raw_candidates, candidates=raw_candidates,
mapping=mapping, mapping=mapping,
blind_id=blind_id, blind_id=blind_id,
@ -929,6 +955,7 @@ def _execute_sample_for_test(
if adjudication.get("status") == "needs_third_reviewer": if adjudication.get("status") == "needs_third_reviewer":
third_input = _build_blind_input( third_input = _build_blind_input(
raw_sample=raw_sample, raw_sample=raw_sample,
writer_fine_outline=writer_fine_outline,
candidates=raw_candidates, candidates=raw_candidates,
mapping=mapping, mapping=mapping,
blind_id=blind_id, blind_id=blind_id,

View File

@ -0,0 +1,558 @@
#!/usr/bin/env python3
"""Writer Gate A 真实五章临时配置装配器测试。"""
from __future__ import annotations
import json
import pathlib
import sys
import tempfile
import unittest
from unittest.mock import patch
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
READ_CONTEXT_SCRIPTS = SCRIPT_DIR.parents[1] / "read-context" / "scripts"
sys.path.insert(0, str(SCRIPT_DIR))
sys.path.insert(0, str(READ_CONTEXT_SCRIPTS))
import load_writer_reference_work as loader # noqa: E402
from load_writer_reference_work import ( # noqa: E402
WriterReferenceWorkError,
assemble_writer_gate_config,
load_writer_reference_rows,
milestone_reference_chapters,
resolve_card_selectors,
write_temporary_config,
)
from run_writer_replay import run_writer_replay # noqa: E402
BASE_CONFIG_PATH = SCRIPT_DIR.parent / "configs" / "writer-gate-a-deep-space-v1.json"
SELECTOR_PATH = (
SCRIPT_DIR.parent / "configs" / "writer-gate-a-deep-space-card-selectors-v1.json"
)
SELECTOR_DIGEST = loader.selector_sha256(SELECTOR_PATH.read_bytes())
FILE_HASH = "02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4"
SOURCE_HASH = f"sha256:{FILE_HASH}"
SOURCE_VERSION = f"raw-file-v1:{SOURCE_HASH}"
def _card_row(
card_id: int,
card_type: str,
name: str,
*,
aliases: list[str] | None = None,
milestones: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""构造符合 upgrade_book 冻结投影合同的卡行。"""
return {
"id": card_id,
"status": "pending",
"source_type": "upgrade_book",
"source_id": 8,
"revision": 1,
"deleted": False,
"draft_payload": {
"type": card_type,
"名称": name,
"别名": aliases or [],
"出场章": [],
"字段": {
"演变历程": milestones or [{"章": 1, "台阶": f"{name}历史事实"}],
},
},
}
def _selected_cards() -> list[dict[str, object]]:
"""构造五样本固定选择所需的六张唯一卡。"""
return [
_card_row(101, "location", "圣蒂曼行星", milestones=[{"章": 487, "台阶": "历史地点"}]),
_card_row(102, "character", "莫妮卡", milestones=[{"章": 317, "台阶": "历史人物"}]),
_card_row(103, "location", "迷途之地", milestones=[{"章": "539-542", "台阶": "历史地点"}]),
_card_row(104, "character", "璐茜", aliases=["赛莉丝"], milestones=[{"章": 190, "台阶": "历史身份"}]),
_card_row(105, "event", "联赛系统被机械一族入侵", milestones=[{"章": "188-189", "台阶": "历史事件"}]),
_card_row(106, "character", "伊蕾莉雅", milestones=[{"章": "460-462", "台阶": "历史人物"}]),
]
def _authorization() -> dict[str, object]:
"""构造通过既有授权门禁的真实原文件授权投影。"""
snapshot = {
"id": "1",
"version": "auth-work-8-test-v1",
"immutable": True,
"sourceHash": SOURCE_HASH,
"sourceVersion": SOURCE_VERSION,
"sourceStatus": "active",
"copyrightStatus": "research_only",
"allowedPurpose": ["offline_evaluation"],
"forbiddenPurpose": ["external_distribution"],
"authorizationBasis": "user_authorization",
"authorizedBy": "user:1",
"displaySummary": "仅供离线评测",
"checkedAt": "2026-07-19T00:00:00+00:00",
"expiresAt": None,
"revalidationAt": "2099-07-19T00:00:00+00:00",
}
return {
"sourceStatus": "active",
"copyrightStatus": "research_only",
"sourceHash": SOURCE_HASH,
"sourceVersion": SOURCE_VERSION,
"allowedPurpose": ["offline_evaluation"],
"forbiddenPurpose": ["external_distribution"],
"authorizationSnapshot": snapshot,
}
def _block_row(chapter: int, text: str | None = None) -> dict[str, object]:
"""构造单章唯一 Canonical block;默认正文长度只用于小型测试。"""
body = text if text is not None else f"第{chapter}章历史正文"
return {
"chapter": chapter,
"chapter_id": 10000 + chapter,
"chapter_status": "published",
"block_id": 20000 + chapter,
"block_order": 1,
"revision": 1,
"content_text": body,
}
def _assembly_rows(base_config: dict[str, object]) -> dict[str, object]:
"""按预注册 Han 计数构造可通过真实 dry-run 的纯数据快照。"""
recent_counts: dict[int, int] = {}
target_scaffolds: list[dict[str, object]] = []
for sample in base_config["samples"]:
for chapter, count in zip(
sample["targetLengthBasis"]["sourceChapters"],
sample["frozenRecentHanCounts"],
strict=True,
):
recent_counts[chapter] = count
target = sample["targetChapter"]
target_scaffolds.append(
{
"id": 30000 + target,
"chapter": target,
"title": f"第{target}章目标",
"outline_text": f"第{target}章合法目标细纲,完成预注册场景。",
"entities": [],
"pattern_hints": [],
}
)
extra_card_chapters = {188, 189, 190, 317, 460, 461, 462, 487, 540, 541, 542}
proxy_names = {
188: "联赛系统",
189: "联赛系统",
190: "赛莉丝",
317: "莫妮卡",
460: "伊蕾莉雅",
461: "伊蕾莉雅",
462: "伊蕾莉雅",
487: "圣蒂曼行星",
540: "迷途之地",
541: "迷途之地",
542: "迷途之地",
}
block_rows = []
for chapter, count in recent_counts.items():
prefix = proxy_names.get(chapter, "")
block_rows.append(_block_row(chapter, prefix + "文" * (count - loader.han_count(prefix))))
block_rows.extend(
_block_row(chapter, f"第{chapter}章{proxy_names[chapter]}历史正文")
for chapter in sorted(extra_card_chapters - set(recent_counts))
)
return {
"work": {"id": 8, "title": "深空之影", "chapter_count": 594},
"reference": {"id": 8, "imported_chapter_count": 594},
"source": {
"sourceHash": SOURCE_HASH,
"sourceVersion": SOURCE_VERSION,
"fileName": "深空之影_远瞳.txt",
},
"authorization": _authorization(),
"target_scaffolds": target_scaffolds,
"card_rows": _selected_cards(),
"block_rows": block_rows,
"canonical_character_mentions": [
{
"sample_id": "deep-space-489-battle",
"name": "加特朗",
"as_of_chapter": 488,
"first_chapter": None,
"hit_chapters": [],
},
{
"sample_id": "deep-space-321-character-dialogue",
"name": "莫妮卡",
"as_of_chapter": 320,
"first_chapter": 317,
"hit_chapters": [317],
},
{
"sample_id": "deep-space-199-information-reveal",
"name": "赛莉丝",
"as_of_chapter": 198,
"first_chapter": 190,
"hit_chapters": [190],
},
{
"sample_id": "deep-space-523-returning-character",
"name": "伊蕾莉雅",
"as_of_chapter": 522,
"first_chapter": 460,
"hit_chapters": [460, 461, 462],
},
],
}
class FakeQueryResult:
"""提供 psycopg 结果对象的最小 fetch 接口。"""
def __init__(self, rows: object):
self.rows = rows if isinstance(rows, list) else [rows]
def fetchone(self):
"""返回第一行或空值。"""
return self.rows[0] if self.rows else None
def fetchall(self):
"""返回全部测试行。"""
return self.rows
class FakeReadOnlyConnection:
"""记录全部 SQL,证明所有读取都处在同一只读事务。"""
def __init__(self, rows: dict[str, object]):
self.rows = rows
self.queries: list[str] = []
def __enter__(self):
"""模拟 psycopg 连接上下文。"""
return self
def __exit__(self, exc_type, exc_value, traceback):
"""测试连接不吞掉异常。"""
return False
def execute(self, query, params=None):
"""按表名路由固定结果,并保留事务语句供断言。"""
del params
sql = " ".join(str(query).split())
self.queries.append(sql)
if sql.startswith("SET TRANSACTION"):
return FakeQueryResult([])
if "FROM muse_content_work" in sql:
return FakeQueryResult(self.rows["work"])
if "FROM example_reference_work" in sql:
return FakeQueryResult([{**self.rows["reference"], "source_file": "深空之影_远瞳.txt", "deleted": False}])
if "FROM muse_content_import_task" in sql:
return FakeQueryResult(
[{
"id": 11,
"status": "succeeded",
"command_id": f"import-{FILE_HASH[:16]}",
"source_snapshot": {"file": "深空之影_远瞳.txt"},
"deleted": False,
}]
)
if "FROM muse_knowledge_document" in sql:
return FakeQueryResult(
[{"id": 12, "file_name": "深空之影_远瞳.txt", "file_hash": FILE_HASH, "deleted": False}]
)
if "FROM example_reference_authorization_snapshot" in sql:
snapshot = self.rows["authorization"]["authorizationSnapshot"]
return FakeQueryResult(
{
"id": 1,
"snapshot_version": snapshot["version"],
"source_hash": SOURCE_HASH,
"source_version": SOURCE_VERSION,
"copyright_status": "research_only",
"source_status": "active",
"allowed_purpose": ["offline_evaluation"],
"forbidden_purpose": ["external_distribution"],
"authorization_basis": "user_authorization",
"authorized_by": "user:1",
"display_summary": "仅供离线评测",
"checked_at": snapshot["checkedAt"],
"expires_at": None,
"revalidation_at": snapshot["revalidationAt"],
}
)
if "FROM example_parse_scaffold" in sql:
return FakeQueryResult(self.rows["target_scaffolds"])
if "FROM muse_knowledge_draft" in sql:
return FakeQueryResult(self.rows["card_rows"])
if "WITH probes AS" in sql:
return FakeQueryResult(self.rows["canonical_character_mentions"])
if "FROM muse_content_chapter ch" in sql and "muse_content_block" in sql:
return FakeQueryResult(self.rows["block_rows"])
raise AssertionError(f"未处理 SQL: {sql}")
class LoadWriterReferenceWorkTest(unittest.TestCase):
"""覆盖选择、冻结、事务和真实 Writer dry-run 合同。"""
def setUp(self):
"""每个测试都从仓内预注册输入与稳定选择器开始。"""
self.base_config = json.loads(BASE_CONFIG_PATH.read_text(encoding="utf-8"))
self.selectors = json.loads(SELECTOR_PATH.read_text(encoding="utf-8"))
def test_resolves_exact_canonical_name_and_alias(self):
"""canonical name 与精确 alias 都必须唯一解析,不能模糊挑卡。"""
resolved = resolve_card_selectors(_selected_cards(), self.selectors)
self.assertEqual(resolved["deep-space-489-battle"][0]["id"], 101)
self.assertEqual(resolved["deep-space-199-information-reveal"][0]["id"], 104)
self.assertEqual(
resolved["deep-space-199-information-reveal"][0]["draft_payload"]["名称"],
"璐茜",
)
def test_duplicate_or_missing_selector_match_fails_closed(self):
"""同一选择器命中零张或多张卡时都不能猜测。"""
duplicate = _selected_cards() + [_card_row(999, "character", "莫妮卡")]
with self.assertRaises(WriterReferenceWorkError):
resolve_card_selectors(duplicate, self.selectors)
missing = [row for row in _selected_cards() if row["id"] != 106]
with self.assertRaises(WriterReferenceWorkError):
resolve_card_selectors(missing, self.selectors)
def test_selector_hash_version_and_frozen_controls_fail_closed_on_tamper(self):
"""选择器内容、版本、scaffold 来源和上下文上限都必须与 base 公共控制一致。"""
self.assertEqual(self.base_config["commonControls"]["selectorSha256"], SELECTOR_DIGEST)
tampered = json.loads(json.dumps(self.selectors, ensure_ascii=False))
tampered["samples"][0]["cards"][0]["name"] = "被篡改"
with self.assertRaisesRegex(WriterReferenceWorkError, "SHA-256"):
assemble_writer_gate_config(
base_config=self.base_config,
selector_config=tampered,
selector_digest=loader.selector_sha256(
json.dumps(tampered, ensure_ascii=False).encode("utf-8")
),
rows=_assembly_rows(self.base_config),
)
invalid = json.loads(json.dumps(self.base_config, ensure_ascii=False))
invalid["commonControls"]["inputProvenance"] = "runtime_scaffold"
with self.assertRaisesRegex(WriterReferenceWorkError, "inputProvenance"):
assemble_writer_gate_config(
base_config=invalid,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
rows=_assembly_rows(self.base_config),
)
invalid = json.loads(json.dumps(self.base_config, ensure_ascii=False))
invalid["samples"][0]["writerContextInput"]["tokenBudget"]["maxContextChars"] += 1
with self.assertRaisesRegex(WriterReferenceWorkError, "原样使用"):
assemble_writer_gate_config(
base_config=invalid,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
rows=_assembly_rows(self.base_config),
)
synchronized_tamper = json.loads(json.dumps(self.base_config, ensure_ascii=False))
synchronized_tamper["commonControls"]["maxContextChars"] = 150000
for sample in synchronized_tamper["samples"]:
sample["writerContextInput"]["tokenBudget"]["maxContextChars"] = 150000
with self.assertRaisesRegex(WriterReferenceWorkError, "严格等于预注册固定值 140000"):
assemble_writer_gate_config(
base_config=synchronized_tamper,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
rows=_assembly_rows(self.base_config),
)
def test_milestone_ranges_expand_and_keep_latest_three_unique_chapters(self):
"""明确区间按绝对章展开,重复章去重后只保留最近三章。"""
chapters = milestone_reference_chapters(
[
{"章": 3, "台阶": "早期"},
{"章": "5-8", "台阶": "区间"},
{"章": 8, "台阶": "重复"},
],
as_of=8,
)
self.assertEqual(chapters, [6, 7, 8])
def test_future_block_or_non_continuous_recent_chapters_fail_closed(self):
"""目标章正文和缺章近章都不能进入装配结果。"""
with self.assertRaises(WriterReferenceWorkError):
loader.index_unique_canonical_blocks([_block_row(9)], allowed_chapters={8})
with self.assertRaises(WriterReferenceWorkError):
loader.select_recent_canonical_blocks(
[_block_row(5), _block_row(6), _block_row(8)],
as_of=8,
)
repository = loader.SnapshotProseRepository({8: _block_row(8)})
with self.assertRaises(WriterReferenceWorkError):
repository.read_source_refs(
work_id=8,
as_of=8,
source_refs=[
{
"sourceId": "chapter:9:block:20009",
"sourceVersion": "chapter:9:block:20009:revision:1",
"chapter": 9,
"blockId": 20009,
"startCodePoint": 0,
"endCodePoint": 1,
}
],
)
def test_han_count_drift_blocks_assembly(self):
"""真实近章 Han 计数只要一章漂移就必须阻断。"""
rows = _assembly_rows(self.base_config)
rows["block_rows"][0]["content_text"] += "文"
with self.assertRaisesRegex(WriterReferenceWorkError, "Han 计数漂移"):
assemble_writer_gate_config(
base_config=self.base_config,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
rows=rows,
)
def test_all_database_reads_share_one_repeatable_read_only_transaction(self):
"""来源、授权、scaffold、卡与正文必须由同一连接快照读取。"""
rows = _assembly_rows(self.base_config)
connection = FakeReadOnlyConnection(rows)
with patch.object(loader.psycopg, "connect", return_value=connection) as connect:
loaded = load_writer_reference_rows(
dsn="postgresql://unused",
tenant_id=1,
work_id=8,
targets=[199, 321, 489, 523, 544],
base_config=self.base_config,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
)
connect.assert_called_once()
self.assertEqual(
connection.queries.count("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"),
1,
)
self.assertEqual(len(loaded["target_scaffolds"]), 5)
self.assertEqual(len(loaded["card_rows"]), 6)
mentions = {item["name"]: item for item in loaded["canonical_character_mentions"]}
self.assertEqual(mentions["加特朗"]["hit_chapters"], [])
self.assertEqual(mentions["莫妮卡"]["first_chapter"], 317)
def test_complete_real_content_config_passes_writer_dry_run(self):
"""纯数据装配出的 canonical_frozen_prose 五样本配置通过真实 dry-run。"""
config = assemble_writer_gate_config(
base_config=self.base_config,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
rows=_assembly_rows(self.base_config),
)
self.assertEqual(len(config["samples"]), 5)
self.assertTrue(
all(
sample["writerContextInput"]["contentMode"] == "canonical_frozen_prose"
for sample in config["samples"]
)
)
for sample in config["samples"]:
context = sample["writerContextInput"]
retrieval = context["retrievalResult"]
self.assertEqual(retrieval["factEvidence"], [])
self.assertEqual(context["tokenBudget"]["maxContextChars"], 140000)
self.assertTrue(all(len(card["sourceRefs"]) <= 3 for card in retrieval["cards"]))
self.assertTrue(
all(
ref["sourceType"] == "card_chapter_proxy"
for card in retrieval["cards"]
for ref in card["sourceRefs"]
)
)
self.assertTrue(
all(
evidence["purpose"] == "card_chapter_proxy"
for evidence in retrieval["proseEvidence"]
)
)
self.assertTrue(
all(
evidence["chapter"] <= sample["asOfChapter"]
for evidence in [*context["recentChapters"], *retrieval["proseEvidence"]]
)
)
result = run_writer_replay(config, run_id="writer-loader-test")
self.assertTrue(result["ok"], result)
self.assertEqual(result["status"], "ready")
def test_character_ratio_comes_from_canonical_text_not_contaminated_card(self):
"""卡内塞入角色名不能把 Canonical 正文从未出现的角色伪装成已知。"""
rows = _assembly_rows(self.base_config)
rows["card_rows"][0]["draft_payload"]["别名"].append("加特朗")
config = assemble_writer_gate_config(
base_config=self.base_config,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
rows=rows,
)
samples = {item["targetChapter"]: item for item in config["samples"]}
self.assertEqual(samples[489]["newCharacterBasis"]["absentBeforeAsOf"], ["加特朗"])
self.assertEqual(samples[489]["newCharacterRatio"], 1.0)
self.assertEqual(samples[321]["newCharacterBasis"]["knownBeforeAsOf"], ["莫妮卡"])
def test_derived_chapter_proxy_requires_name_or_preregistered_alias_in_each_chapter(self):
"""整章代理若不能在对应正文命中规范名或预注册别名,必须失败关闭。"""
rows = _assembly_rows(self.base_config)
target = next(row for row in rows["block_rows"] if row["chapter"] == 188)
target["content_text"] = "第188章无关历史正文"
with self.assertRaisesRegex(WriterReferenceWorkError, "规范名或合法别名"):
assemble_writer_gate_config(
base_config=self.base_config,
selector_config=self.selectors,
selector_digest=SELECTOR_DIGEST,
rows=rows,
)
def test_output_must_be_new_private_tmp_child(self):
"""配置只能写入 /private/tmp 的全新独立子目录。"""
config = {"profile": "writer_replay"}
with tempfile.TemporaryDirectory(dir="/private/tmp") as parent:
output_dir = pathlib.Path(parent) / "writer-config"
path = write_temporary_config(config, output_dir)
self.assertEqual(path, output_dir / "config.json")
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), config)
with self.assertRaises(WriterReferenceWorkError):
write_temporary_config(config, output_dir)
with self.assertRaises(WriterReferenceWorkError):
write_temporary_config(config, SCRIPT_DIR / "forbidden-output")
if __name__ == "__main__":
unittest.main()

View File

@ -547,6 +547,15 @@ class WriterReplayDryRunTest(unittest.TestCase):
gate_config = json.loads(gate_config_path.read_text(encoding="utf-8")) 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_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} expected_ratios = {489: 1.0, 321: 0.0, 544: None, 199: 0.0, 523: 0.0}
self.assertEqual(
gate_config["commonControls"]["selectorSha256"],
"sha256:9df06f7d5864696deda3dd119f8cbded01ba9a9c7e5bf5655553df938f267691",
)
self.assertEqual(
gate_config["commonControls"]["inputProvenance"],
"oracle_reference_scaffold",
)
self.assertEqual(gate_config["commonControls"]["maxContextChars"], 140000)
for sample in gate_config["samples"]: for sample in gate_config["samples"]:
basis = sample["targetLengthBasis"] basis = sample["targetLengthBasis"]
@ -562,6 +571,10 @@ class WriterReplayDryRunTest(unittest.TestCase):
self.assertEqual(recalculated, expected_targets[target_chapter]) self.assertEqual(recalculated, expected_targets[target_chapter])
self.assertEqual(sample["targetChars"], recalculated) self.assertEqual(sample["targetChars"], recalculated)
self.assertEqual(sample["newCharacterRatio"], expected_ratios[target_chapter]) self.assertEqual(sample["newCharacterRatio"], expected_ratios[target_chapter])
self.assertEqual(
sample["writerContextInput"]["tokenBudget"]["maxContextChars"],
gate_config["commonControls"]["maxContextChars"],
)
if target_chapter == 544: if target_chapter == 544:
self.assertEqual(sample["newCharacterRatioStatus"], "unresolved_generic_role") self.assertEqual(sample["newCharacterRatioStatus"], "unresolved_generic_role")
else: else:
@ -686,10 +699,21 @@ class WriterReplayExecuteBoundaryTest(unittest.TestCase):
detector = FakeSemanticDetector() detector = FakeSemanticDetector()
judge = MaliciousJudge() judge = MaliciousJudge()
adapters = WriterReplayTestAdapters(runner, detector, judge) adapters = WriterReplayTestAdapters(runner, detector, judge)
evaluation_config = config()
fine_outline = evaluation_config["samples"][0]["writerContextInput"]["fineOutline"]
# 恶意配置模拟把目标原文、索引、文件路径和真实臂塞进细纲对象;这些字段写手看不到,评委也不能看到。
fine_outline.update(
{
"targetOriginal": "目标章原文泄漏哨兵",
"indexHints": [{"content": "被测卡索引泄漏哨兵"}],
"path": "/private/tmp/candidate-A.json",
"arm": "A",
}
)
with tempfile.TemporaryDirectory(dir="/private/tmp") as directory: with tempfile.TemporaryDirectory(dir="/private/tmp") as directory:
output_dir = pathlib.Path(directory) / "run" output_dir = pathlib.Path(directory) / "run"
result = run_writer_replay( result = run_writer_replay(
config(), evaluation_config,
run_id="blind-isolation", run_id="blind-isolation",
output_dir=output_dir, output_dir=output_dir,
execute=True, execute=True,
@ -698,6 +722,20 @@ class WriterReplayExecuteBoundaryTest(unittest.TestCase):
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
self.assertTrue(judge.visible_inputs) self.assertTrue(judge.visible_inputs)
expected_fine_outline = {
"sourceRef": {
"sourceId": "fixture:fine-outline:489",
"sourceVersion": "fine-outline-fixture-v1",
"chapter": 489,
},
"hardConstraints": ["必须完成围攻突围"],
"adjustableBeats": [],
"declaredNewFacts": [],
}
self.assertTrue(
all(context["fineOutline"] == expected_fine_outline for context in runner.contexts),
"三臂写手必须只看到严格细纲合同字段",
)
shared_references = [] shared_references = []
for visible in judge.visible_inputs: for visible in judge.visible_inputs:
self.assertEqual( self.assertEqual(
@ -714,7 +752,8 @@ class WriterReplayExecuteBoundaryTest(unittest.TestCase):
self.assertEqual(set(visible["candidateOrder"]), {"blind-1", "blind-2", "blind-3"}) self.assertEqual(set(visible["candidateOrder"]), {"blind-1", "blind-2", "blind-3"})
shared = visible["sharedEvaluationReference"] shared = visible["sharedEvaluationReference"]
shared_references.append(copy.deepcopy(shared)) shared_references.append(copy.deepcopy(shared))
self.assertEqual(shared["fineOutline"]["hardConstraints"], ["必须完成围攻突围"]) self.assertEqual(shared["fineOutline"], expected_fine_outline)
self.assertNotIn("entities", shared["fineOutline"])
self.assertEqual(shared["requirements"]["requiredCharacters"], ["林澈"]) self.assertEqual(shared["requirements"]["requiredCharacters"], ["林澈"])
self.assertEqual( self.assertEqual(
shared["requirements"]["chapterEndHook"]["anchors"], shared["requirements"]["chapterEndHook"]["anchors"],

View File

@ -136,6 +136,8 @@ writer 可以创造非 Canonical 的环境细节、动作、过渡、无名配
正向创作没有回放冻结点时,`asOf` 等于当前 Canonical 最新章。 正向创作没有回放冻结点时,`asOf` 等于当前 Canonical 最新章。
离线 eval_draft 的 `indexHint.content` 只允许保存卡名、类型和实际回读章号,不得透传里程碑台阶。这个收紧只作用于诊断评测卡,生产 Canonical 卡继续使用既有冻结状态提示。
### 5.3 根据卡回读原文 ### 5.3 根据卡回读原文
首版必须保留 run3 已验证条件:**目标章之前连续 4 章全文**。选择性裁剪只能作为后续 A/B 实验变量;未证明不降质前,不得替代四章全文基线。 首版必须保留 run3 已验证条件:**目标章之前连续 4 章全文**。选择性裁剪只能作为后续 A/B 实验变量;未证明不降质前,不得替代四章全文基线。
@ -147,6 +149,8 @@ writer 可以创造非 Canonical 的环境细节、动作、过渡、无名配
3. 能力、物品或关系的代表性表现; 3. 能力、物品或关系的代表性表现;
4. 与本章细纲同类的历史场景。 4. 与本章细纲同类的历史场景。
若 eval_draft 卡没有持久化 `sourceRefs`,Gate A 可把冻结线内最近三个里程碑章的整章 Canonical block 作为降级代理。该来源必须同时标记 `sourceRef.sourceType=card_chapter_proxy` 和 `proseEvidence.purpose=card_chapter_proxy`,不能伪装成精确片段;每个代理章正文必须出现卡规范名或预注册合法别名,否则失败关闭。连续四章基线不可裁剪,代理等弱补充由 assembler 在固定预算内裁剪。
`RetrievalManifest` 保存 `planId`、查询、过滤、排序、来源版本、章号、片段 offset、内容哈希和裁剪原因。`manifestId` 对规范化来源集合计算 SHA-256,排除 `runId`、时间戳和执行节点;同一 `planId` 与索引版本必须产生相同 `manifestId`。 `RetrievalManifest` 保存 `planId`、查询、过滤、排序、来源版本、章号、片段 offset、内容哈希和裁剪原因。`manifestId` 对规范化来源集合计算 SHA-256,排除 `runId`、时间戳和执行节点;同一 `planId` 与索引版本必须产生相同 `manifestId`。
## 6. 双证据模型 ## 6. 双证据模型
@ -206,6 +210,8 @@ writer 可以创造非 Canonical 的环境细节、动作、过渡、无名配
所有对象使用严格 schema,额外字段失败;引用的 ID、版本和 hash 必须存在且一致。 所有对象使用严格 schema,额外字段失败;引用的 ID、版本和 hash 必须存在且一致。
Gate A 的公共控制还固定 `selectorVersion`、选择器规范 JSON 原始字节 SHA-256、`inputProvenance=oracle_reference_scaffold` 和 `maxContextChars=140000`。loader 在任何数据库读取前核对并原样沿用这些值,不得按真实输入扩大预算;若固定预算连细纲硬约束和连续四章全文基线都装不下,assembler 必须失败关闭。
`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=[]`;该例外不能用于生产。 `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` ### 7.2 `WriterOutput v1`
@ -273,7 +279,7 @@ detector 只阻断可定位、可验证的问题:细纲硬约束漏项、事
- 离线优化允许最多 5 轮,且每轮只改一个变量;与生产两轮返修是两套状态机。 - 离线优化允许最多 5 轮,且每轮只改一个变量;与生产两轮返修是两套状态机。
- 三臂使用相同细纲、冻结点、模型、篇幅算法和最大生成预算。 - 三臂使用相同细纲、冻结点、模型、篇幅算法和最大生成预算。
- 候选臂名映射为随机化 ID;顺序种子由预注册 `evaluationSetVersion + sampleId` 派生并固定。 - 候选臂名映射为随机化 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` 的路径。 - judge 的可信输入边界是独立 `writer-blind-input-v1` 内容,包含 `blind-1/2/3` 候选正文/哈希,以及所有臂完全相同的 `sharedEvaluationReference`。共同参考的细纲只保留 `sourceRef/hardConstraints/adjustableBeats/declaredNewFacts` 四字段,不增加 `entities`;另带必需角色、伏笔、章末钩子和冻结点前连续四章历史原文基准,用于评价细纲忠实、设定、文风和卡索引正确性。它可在真实运行时临时传递,但不进入安全摘要。共同参考严禁包含 `indexHints`、各臂补充原文、卡 manifest、`evidenceStrategy`、真实映射或臂名,不能用被测卡本身给被测候选背书。真实 A/B/C 映射仅在编排器内存中存在,judge 不得访问各臂 WriterContext、原始运行目录或含 `candidate-A/B/C` 的路径。
- 两个评委使用独立无会话实例,第二评委反转顺序;评分步长为 0.5。 - 两个评委使用独立无会话实例,第二评委反转顺序;评分步长为 0.5。
- 同维分差 > 0.5 时判不稳定,最多增加一次第三评委。三评分中若至少一对差值 <=0.5,则该维最终分取三者中位数;若不存在稳定配对,则样本进入 `invalid_unstable`,不参与方向结论。 - 同维分差 > 0.5 时判不稳定,最多增加一次第三评委。三评分中若至少一对差值 <=0.5,则该维最终分取三者中位数;若不存在稳定配对,则样本进入 `invalid_unstable`,不参与方向结论。
- 五维:设定与实体保真、情节与细纲忠实、叙事完整与张力、文风一致、文笔质量。 - 五维:设定与实体保真、情节与细纲忠实、叙事完整与张力、文风一致、文笔质量。
@ -307,7 +313,9 @@ detector 只阻断可定位、可验证的问题:细纲硬约束漏项、事
预期长度不是人工填写:配置记录目标章之前连续四章的 `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 限幅。 预期长度不是人工填写:配置记录目标章之前连续四章的 `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。 新角色比例只统计具名 `requiredCharacters` 在冻结点前无记录的比例。真实 loader 在来源、授权、scaffold、卡和正文所在的同一个 `REPEATABLE READ READ ONLY` 事务内,直接扫描冻结历史 Canonical 正文并返回名字对应的首次命中章与命中章集合,再独立重算 `knownBeforeAsOf/absentBeforeAsOf/newCharacterRatio`,不得相信卡内是否出现该名字。489 的加特朗在 488 前无记录,因此为 1.0;321/199/523 的具名角色已有记录,因此为 0;544 的“内应”是泛称、无法机械判定具体身份,必须为 `null + unresolved_generic_role`,不允许默认成 0。
真实装配保留目标章 reference scaffold,并以 `inputProvenance=oracle_reference_scaffold` 明示来源。它只用于固定本次正文层细纲输入和目标事实泄漏代理;这项验证不覆盖上游清洗、抽卡、范式、细纲生成或完整创作链,不能据此宣称整条创作链完成。
仓内配置不含原文全文。`writerContextInput.contentMode=sanitized_contract_fixture`,连续四章只放脱敏合成短文本,用于 dry-run 机械证明 WriterContext 合同、冻结边界、三臂差异、manifest 可复现和候选不可接受;不能据此声称历史原文完整、生成质量通过或 real-run 完成。 仓内配置不含原文全文。`writerContextInput.contentMode=sanitized_contract_fixture`,连续四章只放脱敏合成短文本,用于 dry-run 机械证明 WriterContext 合同、冻结边界、三臂差异、manifest 可复现和候选不可接受;不能据此声称历史原文完整、生成质量通过或 real-run 完成。