# 触发器 T 判读器（LLM 判据）—— 实施计划 v2

> **For agentic workers:** 本计划按 `superpowers:writing-plans` 写就。步骤用 `- [ ]` 勾选。
> ⛔ **含预注册常量，必须「先 commit 再跑」**（§30.5 N80.1）。
> ⛔ **校准档不过，不许放量**（Task 4 fail closed）。

**Goal:** v1 的结构判据 D2 已被证伪（[报告](../reports/2026-08-31-trigger-t-coverage.md)）——真实的横切是
「同一批条目的**不同属性**被切到不同段」，条目名恰恰不重复，纯 markdown 结构匹配对它结构性失明。
本计划把判据换成 **LLM 判读**，prompt 直接引用触发器 T 的**规则原文**、⛔ 不写 lab 自编判据，
先跑**校准档 20 次**产出噪声读数，据此判这条路走不走得通。

**Architecture:** 新增 `adapters/llm-judge-container.mjs`。复用 `run-golden-task.mjs` 的
headless 调用范式（`spawnSync` 直接拿 status ⛔ 不接管道、`--print --output-format stream-json`、
`--strict-mcp-config`）。T 的规则原文**从 pin 里程序化抽取**（⛔ 不手抄，手抄会漂）。
模型只回答**事实问题**「加一个同类新条目要改哪几个已有单元」，
**verdict 由 lab 按 `appendCost ≥ 1` 算**（§3.5 计数与归因分开，⛔ 不让模型自己下分类结论）。

**Tech Stack:** 纯 Node ESM、零依赖（D4）。只读 pin。headless `claude` CLI。

**Spec:** DS `docs/meta-rules.md` 触发器 T（pin P7 `6ac56de2`）；
v1 计划与其负结果 [`docs/2026-08-31-trigger-t-coverage-probe-plan.md`](./2026-08-31-trigger-t-coverage-probe-plan.md)、
[`reports/2026-08-31-trigger-t-coverage.md`](../reports/2026-08-31-trigger-t-coverage.md)。

---

## Global Constraints

- **lab 只审查、不裁定**（`AGENTS.md` §8）⇒ 出读数，⛔ 不推荐 T 该不该配闸。
- **三元组主键必填**（§5.2）：`subjectSha=6ac56de2` · `modelId=<实际模型>` ·
  `promptTemplateVersion` —— 后两项**不再是 `n/a`**，这是与 v1 最大的口径差异。
  ⇒ **读数带 `modelId` + `promptTemplateHash`，换模型或改 prompt 就是另一个量，⛔ 不可跨读数比较。**
- **⛔ 二分类判读是低基数离散量，绝不许只跑一次**（§2.7）。lab 自己的实证：
  `ds-conformance.errors` 在 N=2 上两次都是 1、CV **0.0%**，据此写下「离散型 T1 零变异」；
  **N=5 实测 `[1,1,4,0,2]`、CV 94.8%**，是全表方差最大的那个。
  ⇒ 每份样本 **N=5**，且**必须报完整分布**，⛔ 不许只报「5 次都一样」。
- **⛔ `sd=0` 不得读作「无噪声」**（同上）。
- **预注册先行**：`PREREG` 写死进源码、先 commit 再跑，⛔ 不许跑完调阈值。
- **lab 事先不知道的量 ⛔ 不设期望值，显式写 `null`**。
- **扰动 / 合成样本必须照着真实病灶造，⛔ 不许照着判据造**
  （v1 教训：`perturb()` 照 D2 形状造 ⇒ 恒真的 must-hit，见报告 §3.2）。
- **⛔ 不手抄 T 的规则原文**，从 pin 程序化抽取；抽取失败即 fail closed。
- **只读 pin**，跑完验 `git status` 为空。
- 取退出码 ⛔ 不接管道（zsh 下 `${PIPESTATUS[0]}` 是空的）⇒ `cmd; echo "exit=$?"`。

### ⛔ 本计划不碰的文件（并行 session 隔离）

- **只新增文件**；⛔ 不改任何已有 `metrics/*.mjs` / `adapters/*.mjs`。
- ⛔ **不碰 [`docs/round2-status.md`](./round2-status.md)**（另一条线要登记 N82），
  本线的登记留到读数出来后单独一次、且先 `git pull`。
- ⛔ **不改 v1 的计划与报告** —— 留痕不删（同 f4 那次「原 §2.6 表留痕不删」）。
- ⛔ 不写 DS 仓。

---

## 判读 prompt 的设计

### 为什么 prompt 里不能有 lab 自编的判据

v1 的 D2 是我编的正则，而我**已经读过 LCD 前版全文** ⇒ 再编结构判据就是照着答案设计，
`must-hit-0` 必然绿，那是过拟合不是判据成立。改用 T 的**规则原文**当判据，`must-hit-0` 才是真检验。
⇒ **prompt 模板里 ⛔ 不许出现「跨段重叠」「条目名重复」这类 v1 的失败模型**，只放 T 原文 + 事实问题。

### 模板（冻结）

```
以下是一条文档编写规则的原文：

<<<RULE
{T 的规则原文，从 pin 的 docs/meta-rules.md 程序化抽取}
RULE

以下是一份待评估的文档：

<<<DOC
{待判文档全文}
DOC

请只回答一个事实问题：**如果要往这份文档里加一个同类的新条目**（按这份文档自己
正在登记/组织的那类东西），需要**修改哪些已经存在的部分**？

- 「修改已存在的部分」指：为了让新条目完整生效，必须去改动的、已经写好的段落或表格。
- 只追加新内容、不动任何已有部分，则答案是空列表。

⛔ 不要给出评价、建议或结论，只列事实。
最后用一个 json 代码块给出结构化答案：

{"unitsToModify": ["<段落标题>", ...], "appendCost": <整数>}
```

**verdict 由 lab 算**：`hit = appendCost >= 1`。⛔ 不让模型自己输出 `verdict` /
`cross-cutting` 这类分类标签 —— 那会把「事实判断」和「归因结论」混在一起（§3.5）。

---

## 控制组（校准档，每条 N=5）

| id | 样本 | 期望 | 它和谁比 / 是否同源（N81.1） |
|---|---|:-:|---|
| `must-hit-0/lcd-before` | TVU Pack `83db51d:docs/specs/lcd-message-copy.md` | `appendCost ≥ 1` | 真实已知阳性。判据是 **DS 写的 T 原文**、非 lab 自编 ⇒ ⛔ 不存在照答案设计 |
| `must-not-hit-0/lcd-after` | TVU Pack `d885d72:docs/specs/lcd-message-strings.md` | `appendCost = 0` | 与上条**同一文档的两个时点**，互为判别力证明 |
| `must-hit-1/synthetic-attribute-split` | 合成样本：`## 文案`表 + `## 通用规则` + `## 出处`表，**三段条目名互不相同** | `appendCost ≥ 1` | **照真实病灶形态造，⛔ 不照判据造**（v1 教训）。它精确测 D2 失明的那个点 ⇒ 直接回答「LLM 补上结构判据的盲区没有」 |
| `observe/oneshot-plan` | `docs/2026-08-31-f4-denominator-branches-plan.md` 原样 | **`null`** | ⚠️ **观察项、不是控制项** —— 「往一份一次性计划加一个 Task 要不要动已有部分」lab 事先不知道答案，⛔ 不编期望值。只记读数 |

⛔ **`observe` 项不参与放量闸判定**（它没有期望值，判不出对错）。

---

## 预注册常量

```js
const PREREG = {
  // 控制组期望（可证伪）
  controls: {
    'must-hit-0/lcd-before': 'appendCost>=1',
    'must-not-hit-0/lcd-after': 'appendCost==0',
    'must-hit-1/synthetic-attribute-split': 'appendCost>=1',
    'observe/oneshot-plan': null,   // ⛔ 事先不知道，不编
  },
  n: 5,                    // §6.2：N<5 不足以谈分布
  minAgreement: 0.8,       // 放量闸：每条控制 ≥4/5 次方向正确
  // ⛔ 正式面从未测过 —— 设任何期望值都是编的
  liveHitCount: null,
  liveAgreement: null,
}
```

**放量闸（Task 4）**：三条有期望的控制**各自** ≥4/5 方向正确 ⇒ 放量。
任一条 <4/5 ⇒ **不放量**，只出噪声读数交 owner。⛔ 不许调 `minAgreement` 让它过。

---

## Task 1：判读器 + 预注册先 commit

**Files:**
- Create: `adapters/llm-judge-container.mjs`

**Interfaces:**
- Produces:
  - `extractRuleT(pinPath) → string`（抽 T 原文，抽不到即抛）
  - `buildPrompt(ruleText, docText) → string`
  - `parseVerdict(resultText) → { unitsToModify: string[], appendCost: number } | null`
  - `judgeOnce(cli, model, prompt) → { raw, parsed, wallMs, exitCode }`
  - CLI: `--calibrate`（跑控制组）/ `--dry-run` / `--scan-list <f>`（放量档）

- [ ] **Step 1: 抽取 T 原文（⛔ 不手抄）**

```js
export function extractRuleT(pin) {
  const md = readFileSync(join(pin, 'docs/meta-rules.md'), 'utf8')
  const start = md.indexOf('### 触发器 T')
  if (start < 0) throw new Error('[fail-closed] pin 里找不到「### 触发器 T」⇒ 规则原文不可得')
  const rest = md.slice(start + 4)
  const nextIdx = rest.indexOf('\n### ')
  const text = nextIdx < 0 ? md.slice(start) : md.slice(start, start + 4 + nextIdx)
  if (text.length < 500) throw new Error(`[fail-closed] 抽到的 T 原文只有 ${text.length} 字，疑似截断`)
  return text.trim()
}
```

- [ ] **Step 2: 拼 prompt + 冻结 hash**

```js
const TEMPLATE_VERSION = 'jt-v1'

export function buildPrompt(ruleText, docText) {
  return `以下是一条文档编写规则的原文：

<<<RULE
${ruleText}
RULE

以下是一份待评估的文档：

<<<DOC
${docText}
DOC

请只回答一个事实问题：**如果要往这份文档里加一个同类的新条目**（按这份文档自己正在登记/组织的那类东西），需要**修改哪些已经存在的部分**？

- 「修改已存在的部分」指：为了让新条目完整生效，必须去改动的、已经写好的段落或表格。
- 只追加新内容、不动任何已有部分，则答案是空列表。

⛔ 不要给出评价、建议或结论，只列事实。
最后用一个 json 代码块给出结构化答案：

{"unitsToModify": ["<段落标题>", ...], "appendCost": <整数>}
`
}

// promptTemplateHash 对「模板 + T 原文」算（不含待判文档）——
// 规则原文变了 prompt 就变了，⛔ 不能只对模板算
export const templateHash = (ruleText) =>
  createHash('sha256').update(TEMPLATE_VERSION).update(ruleText).digest('hex')
```

- [ ] **Step 3: 解析模型输出**

```js
export function parseVerdict(text) {
  const blocks = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1])
  for (const b of blocks.reverse()) {          // 取最后一个 json 块
    try {
      const o = JSON.parse(b)
      if (typeof o.appendCost === 'number' && Array.isArray(o.unitsToModify)) return o
    } catch { /* continue */ }
  }
  return null      // ⛔ 解析失败记 null，**不猜**，且不计为绿（N79.2 同源纪律）
}
```

- [ ] **Step 4: 单次判读（复用 run-golden-task 的调用范式）**

```js
const CLI = arg('--cli') ?? '/Users/nancy/.vscode/extensions/anthropic.claude-code-2.1.241-darwin-arm64/resources/native-binary/claude'

export function judgeOnce(cli, model, prompt) {
  const args = ['--print', '--output-format', 'stream-json', '--verbose',
    '--model', model, '--disallowedTools', 'Write', 'Edit', 'NotebookEdit', 'Bash',
    '--strict-mcp-config']
  const t0 = Date.now()
  // ⛔ §3.9：spawnSync 直接拿 status，不接管道
  const r = spawnSync(cli, args, { input: prompt, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 })
  if (r.error) throw new Error(`通道启动失败：${r.error.message}`)
  let resultText = ''
  for (const line of (r.stdout ?? '').split('\n')) {
    if (!line.trim()) continue
    let ev; try { ev = JSON.parse(line) } catch { continue }
    if (ev.type === 'result' && typeof ev.result === 'string') resultText = ev.result
  }
  return { raw: resultText, parsed: parseVerdict(resultText), wallMs: Date.now() - t0, exitCode: r.status }
}
```

- [ ] **Step 5: 预注册先 commit（⛔ 跑之前）**

```bash
git add docs/2026-08-31-trigger-t-llm-judge-plan.md adapters/llm-judge-container.mjs
git commit -m "feat(adapters): 触发器 T 判读器（LLM 判据）+ 预注册（先提交，⛔ 不许跑完调）"
echo "exit=$?"
```

---

## Task 2：dry-run 验证调用链（⛔ 不烧 token）

- [ ] **Step 1: 跑 dry-run**

```bash
node adapters/llm-judge-container.mjs --calibrate --dry-run \
  --pin ~/.ai-ds-lab/pins/tvu-ds-6ac56de2; echo "exit=$?"
```

Expected: 打印 4 个样本各自的 prompt 字节数、`templateHash` 前 16 位、CLI 路径与 `--version`。
⛔ 一次真实调用都不发。

- [ ] **Step 2: 肉眼核 T 原文抽全了**

dry-run 要打印抽到的 T 原文**首尾各 200 字**。确认结尾落在「⛔ 三条边界」那段之后、
⛔ 没有串进下一个触发器。串了就修 `extractRuleT` 的边界，**这不算调判据**。

---

## Task 3：校准档实跑（4 样本 × N=5 = 20 次）

**Files:**
- Create: `runs/judge-cal-6ac56de2/{sample}-{i}.json` · 汇总 `runs/judge-cal-6ac56de2/summary.json`

- [ ] **Step 1: 跑**

```bash
node adapters/llm-judge-container.mjs --calibrate \
  --pin ~/.ai-ds-lab/pins/tvu-ds-6ac56de2 \
  --model claude-opus-5 --n 5 --out runs/judge-cal-6ac56de2; echo "exit=$?"
```

- [ ] **Step 2: 每次 run 落全 §5.2 主键**

每份 JSON 必含 `subjectSha=6ac56de2` · `modelId` · `promptTemplateVersion=jt-v1` ·
`templateHash` · `docHash` · `cliVersion` · `appendCost` · `unitsToModify` · `wallMs` · `exitCode`。
⛔ 缺则该次作废（§2.1）。

- [ ] **Step 3: 报完整分布，⛔ 不只报一致率**

每个样本输出逐次的 `appendCost` 数组，例如 `must-hit-0: [2,3,2,2,3]`。
⛔ **不许只写「5/5 一致」** —— §2.7 的教训就是「看起来确定」把人骗了。

- [ ] **Step 4: 验 pin 干净**

```bash
git -C ~/.ai-ds-lab/pins/tvu-ds-6ac56de2 status --short; echo "exit=$?"
```

Expected: 无输出。

---

## Task 4：放量闸（fail closed）

- [ ] **Step 1: 逐条对预注册判**

| 控制 | 判据 |
|---|---|
| `must-hit-0` | ≥4/5 次 `appendCost ≥ 1` |
| `must-not-hit-0` | ≥4/5 次 `appendCost == 0` |
| `must-hit-1` | ≥4/5 次 `appendCost ≥ 1` |
| `observe` | ⛔ 不判，只记 |

- [ ] **Step 2: 三条全过 ⇒ 放量；任一条不过 ⇒ 停**

⛔ **不许调 `minAgreement` 让它过**（N80.1）。不过时照样出 Task 6 的报告，
如实写「LLM 判据在 N=5 上未达放量门槛」+ 完整分布 + 解析失败次数。

- [ ] **Step 3: 特别检查 `parseVerdict` 返回 null 的次数**

null 即模型没给出可解析的 json 块。⛔ **null 不计为绿**，且 null 率本身要报 ——
它是「这条 prompt 稳不稳」的直接读数。

---

## Task 5：放量档（**条件执行**，Task 4 过了才做）

- [ ] **Step 1: 用臂 A 筛正式面**

复用 v1 **未被证伪**的臂 A（`hasGrowthClaim`）缩小分母：

```bash
node metrics/container-extensibility.mjs --scan ~/.ai-ds-lab/pins/tvu-ds-6ac56de2 \
  --json-out /tmp/armA.json; echo "exit=$?"
```

⛔ 只取其 `live.armA` 字段（自述会增长的文件清单）。
⛔ **不得引用该器的 `hits` / `armB`** —— D2 已证伪。

- [ ] **Step 2: 对这批跑判读，N=5**

```bash
node adapters/llm-judge-container.mjs --scan-list /tmp/armA.json \
  --pin ~/.ai-ds-lab/pins/tvu-ds-6ac56de2 --model claude-opus-5 --n 5 \
  --out runs/judge-live-6ac56de2; echo "exit=$?"
```

- [ ] **Step 3: 先报成本画像再看结论**

样本数 × 5 的实际调用次数、总 wall time、null 率。⚠️ 若样本数 > 60，
**先停下来把成本报给 owner**，⛔ 不擅自烧完。

---

## Task 6：报告

**Files:**
- Create: `reports/2026-08-31-trigger-t-llm-judge.md`

- [ ] **Step 1: 必含项**

双主键（`modelId` + `promptTemplateHash`）· 逐样本完整分布（⛔ 不只报一致率）·
null 率 · 放量闸结论 · 成本画像 · **「本读数只对该 modelId + templateHash 成立，
⛔ 不可跨模型/跨模板比较」** · **「Figma 面完全未测」**。

- [ ] **Step 2: 回答 v1 留下的三个立项问题**

| 立项问题 | v1 状态 | v2 能否回答 |
|---|---|---|
| ① 判据跨域迁移值不值得机制化 | 未回答 | 放量档过了才能答 |
| ② 判据要怎么写才可迁移 | 答了（负向） | v2 追加一条：**结构判不了的，未必人才判得了 —— 先试 LLM 判读** |
| ③ 主动扫盲区的成本画像 | 未回答 | 校准档即出下界 |

- [ ] **Step 3: commit + 登记（⛔ 先 pull）**

```bash
git add reports/2026-08-31-trigger-t-llm-judge.md runs/judge-cal-6ac56de2
git commit -m "feat(metrics): 触发器 T LLM 判据校准档读数 @6ac56de2"
git pull --rebase; echo "exit=$?"
```

确认另一条线的 N82 落没落，再往 `round2-status.md` 追加、编号顺延。

---

## 自检

- [x] **无占位符**：无 TBD / TODO / 「类似 Task N」。
- [x] **类型一致**：`extractRuleT` / `buildPrompt` / `parseVerdict` / `judgeOnce` 在 Task 1 定义，
      Task 2–5 用的是同名同签名。
- [x] **预注册在跑之前**：Task 1 Step 5 commit，Task 3 才实跑。
- [x] **合成样本照真实病灶造**：`must-hit-1` 用属性分段形态（D2 的盲区），⛔ 不照判据造。
- [x] **不设编造的期望值**：`observe` 项与正式面均显式 `null`。
- [x] **离散量不只跑一次**：N=5，且强制报完整分布。
- [x] **口径声明**：读数带 `modelId` + `templateHash`，⛔ 不可跨读数比较。
- [x] **并行隔离**：不碰 `round2-status.md`（除 Task 6 且先 pull）、不改已有量具、不改 v1 留痕、不写 DS。
