Files
Jyotisha/TASK-rectification-decision-authority-20260831.md
T
Jesse_Chen 857f9dc9b9
Independent Staging Quality Gate / validate (push) Successful in 15m49s
Independent Staging Quality Gate / publish (push) Successful in 10m28s
docs(rectification): record skill bump identity pitfalls
The registry packagePath points at versions/<version>/, but the root
SKILL.md copy makes it easy to hash the package root instead. Hashing
the root pulls the entire versions/ subtree into the digest and still
returns a value, so the mistake surfaces only as a failing gate later.
Records BUG-459 and corrects the task brief's D-2 table, which listed
the root SKILL.md as the hash target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
2026-08-31 18:25:32 +00:00

259 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 生时校正决策权威归一与停止语义重构(2026-08-31)
基线:`origin/staging` @ `67101f03`
本轮是**结构性重构**,不是补分支。上一轮 `3a4396a4`stop 语义)用"在 if 链里插一个分支"的方式改交付条件,直接造成了线上事故:**引擎判定"证据不足、不许采用",界面却把 9 个几乎并列的候选摆出来让用户点"采用此时间"。** 同类补丁不要再打了。
---
## 事故实证
真实 case `3fe32834-9068-4275-81b1-0a57e860e68e`,用户只提供了 1 条证据(2016-09 上大学)。同一份 `latest_result` 内部自相矛盾:
| 字段 | 引擎 `decisionReceipt`(Python) | 顶层投影(前端读这个) |
| --- | --- | --- |
| `selection_allowed` | **false** | **true** |
| `acceptance_allowed` / `can_adopt` | **false** | **true** |
| `propose_allowed` | **false** | **true** |
| `result_status` | **insufficient_evidence** | `completed_with_range` |
| `precision_stage` | **d9_refine**(需继续细化) | **ready_to_adopt** |
引擎把理由写得很完整:`acceptance_reasons: ["insufficient_events","insufficient_domain_diversity"]`;四道闸门全挂 —— `event_quality` 1 件 < 最低 3、`domain_diversity` 1 个 < 最低 2、`unique_top` 有 3 个并列第一、`diagnostic_quality` margin 5.24% < 最低 10%`overall_confidence: "low"`
候选跨度 04:47–05:1529 分钟),前四名支持度 12/12/12/12 完全并列。用户点任意一个,`accepted_time` 落库并出现"用这个时间看盘"入口 —— **拿引擎明说证据不足的时间去排盘**。这是数据正确性事故,不是 UX 瑕疵。
已用真实分数复现(`decideRectification` 直接调用):
```
输入:datedEventCount=1, datedDomainCount=1, trainingGateOpen=false,
methodCoverageAll=false, snapshotCurrent=true, userStopped=false
输出:nextAction=complete_with_range stopReason=insufficient_dated_events
selectionAllowed=true canAdopt=true precisionStage=ready_to_adopt
separation.status=not_separated tiedForFirst=true
```
末行是关键:系统**自己知道**候选分不开(`not_separated` + `tiedForFirst`),仍然放行采用。
---
## 四个结构性病灶
本轮要根治的是这四条,不是上面那一个症状。
### 病灶 1 · 双重决策权威,且下游可单方面推翻上游
Python 引擎已经算出 `selection_allowed` / `acceptance_allowed` / `propose_allowed` / `confirmation_allowed` / `result_status` 并附完整 gates。TypeScript 的 `decideRectification` 又独立重算一遍同样的判断。两个权威源之间**没有任何一致性约束**。
覆盖点在 `v9/decision-from-dossier.ts:619-635``overlayPublicDecision`
```ts
return {
...snapshot, // 引擎结果
...fields, // TS 决策 —— 无条件盖掉引擎
...(inconsistent ? { can_adopt: false, selection_allowed: false } : {}),
```
唯一的 fail-closed 是候选投影不一致(`inconsistent`)。引擎自己写的 `acceptance_allowed: false` **完全不在考虑范围内**
### 病灶 2 · "证据不足"与"已经查完"共用一个出口
`core/rectification-decision.ts:186-188`
```ts
if (stopReason) {
return completeWithRange(separation, holdout, range, "exhausted", stopReason);
}
```
`stopReason` 有四个取值,语义截然相反却走同一出口:
| stopReason | 真实含义 | 正确出口 | 现状 |
| --- | --- | --- | --- |
| `insufficient_dated_events` | 证据不够 | **继续收集** | 交付并允许采用 |
| `insufficient_domains` | 领域不够 | **继续收集** | 交付并允许采用 |
| `tied_first` | 问不动了 | 交付区间 | 交付区间 ✓ |
| `user_uncertainty_too_high` | 问不动了 | 交付区间 | 交付区间 ✓ |
`completeWithRange(kind: "exhausted")` 的语义是"已穷尽、交付最终区间",`terminal = true`,并**无条件**设 `canAdopt: true` / `selectionAllowed: true` / `precisionStage: "ready_to_adopt"` / `phase: "completed"`(见 `core/rectification-decision.ts:414-452`)。
次生问题:这个 `if (stopReason)` 被插在 `coverageBlocks` 检查**之前**。`coverageBlocks` 本会因 `trainingGateOpen=false` 返回 `collect()` 继续收集 —— 被 stopReason 抢跑了。
### 病灶 3 · 能力标志散落在 11 个分支里各自硬编码
`decideRectification` 是一条 11 分支的顺序 if 链,`canAdopt` / `selectionAllowed` / `proposeAllowed` / `canConfirmExactMinute``finish()` / `completeWithRange()` / `collect()` / `discriminate()` / `offerRangeWithoutAdopt()` / `holdoutValidation()` 各自决定。**没有任何单点能回答"什么条件下允许采用"**,也无法审计。上一轮的回归正是这种结构的必然产物。
### 病灶 4 · 多处 fail-open
`core/rectification-decision.ts:222-233`
```ts
if (holdout === "unavailable") {
if (userStopped) { return completeWithRange(..., "user_stopped"); }
return finish("adopt_representative", { ..., canConfirmExactMinute: false });
}
```
`holdout` 的默认值就是 `input.holdoutValidation ?? "unavailable"`。**独立核对不可用时反而放行采用**,`canAdopt: true`。事故快照里 sealed holdout 的真实状态是 `top_1_rate: 0.15``required_cases: 20``status: "not_ready"` —— 基准根本没建起来,系统却据此放行。
同理 `evidenceStopReason` 首行 `if (separation.ranked.length === 0) return null;` —— 没有候选时不给任何停止原因,也是 fail-open 方向。
---
## 硬红线
1. **撤销上一轮任务书的红线 3。** `TASK-rectification-billing-20260831.md` 写的"不得修改 `core/rectification-decision.ts`,与本次故障无关"——就计费故障而言成立,但它正是本轮的根因所在。**本轮必须改它。**
2. **引擎是门禁的唯一上限权威。** TS 层对 `acceptance_allowed` / `selection_allowed` / `propose_allowed` / `confirmation_allowed` **只能收紧,永远不能放宽**。引擎说 false,最终必须是 false,没有例外。
3. **一切 fail-closed。** 引擎字段缺失、holdout 不可用、候选投影不一致、快照过期 —— 一律按"不允许采用"处理。不确定时禁止交付,不是放行。
4. **不得为了让重构通过而放宽任何既有门槛。** `MIN_STANDALONE_DATED_EVENTS=3` / `MIN_STANDALONE_DATED_DOMAINS=2` / 4-3 确认门 / `MIN_SEPARATION_LEAD` 数值一律不动。本轮改的是**这些判断如何流向能力标志**,不是判断本身。
5. **不得修改既有测试断言** —— 除非该断言锁住的正是本轮要修的缺陷本身(预计 `rectification-convergence-budget.test.ts` 等会有若干条锁住了错误行为)。那种情况必须在断言上方注明"原值是什么、为什么它是错的",并在 PROGRESS 里逐条单列。**发现需要改的断言超过 5 条时停下来先汇报**,那意味着方案偏了。
6. **任务 0 是门控。** 不变量测试必须先写、先失败,才允许动实现。
7. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`** —— 新建 worktree 未 `npm install` 时 npx 会装到空包 `tsc@2.0.4`
8. `tests/rectification-*.test.ts` 不得低于基线 **705**,且 `fail=0`。全量 `npm test` 需要 Docker;无 Docker 时必须逐条比对失败清单确认无新增。
9. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:**不得放行不该放行的采用 > 数据正确性 > 功能与测试不回归 > 可诊断性 > 代码整洁**。
## 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/rectification-decision-authority-20260831 \
../.worktrees/rectification-decision-authority-20260831 origin/staging
```
基线必须是 `origin/staging`。读 `docs/research/pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`。改前在 `docs/BUG_HISTORY.md` 检索同类记录(`47b4b06b` 刚补过一条)。
**下面所有行号只是线索,请按符号名定位。**
---
## 任务 0(门控)· 先写不变量测试,先让它红
在既有的 `frontend/tests/rectification-decision-authority.test.ts` 里补一组**不变量**测试。这些断言与实现无关,重构前后都必须成立:
1. **引擎上限不可逾越**:对任意输入组合,`decision.canAdopt` 为 true ⟹ 引擎 `acceptance_allowed !== false``selectionAllowed` / `proposeAllowed` / `canConfirmExactMinute` 同理。
2. **证据不足永不放行**`datedEventCount < MIN_STANDALONE_DATED_EVENTS``datedDomainCount < MIN_STANDALONE_DATED_DOMAINS``canAdopt === false``selectionAllowed === false`
3. **分不开就不能采用**`separation.tiedForFirst === true``canAdopt === false`(用户主动停止的路径除外,但那条必须带 `completionStatus: "provisional_range_user_stopped"`)。
4. **holdout 不可用不得放行**`holdoutValidation === "unavailable"` 且非 `userStopped``canAdopt === false`
5. **覆盖只能收紧**`overlayPublicDecision` 的输出对四个门禁字段,必须是引擎值与 TS 值的逻辑与。
建议用组合枚举(笛卡尔积)而非手挑用例覆盖输入空间 —— 病灶 3 的教训就是单点用例挡不住分支链回归。
**跑一遍,确认 1/2/3/4/5 里至少 4 条是红的。** 全绿说明测试没写对,停下来重写。
---
## 任务 A(P0)· 停止原因分类化,与交付能力解耦
`EvidenceStopReason` 升级为带出口语义的分类。建议形状(可自行调整命名,但**语义必须三分**):
```ts
type StopClass =
| { kind: "keep_collecting"; reason: "insufficient_dated_events" | "insufficient_domains" }
| { kind: "exhausted"; reason: "tied_first" | "user_uncertainty_too_high" }
| { kind: "user_stopped" };
```
- `keep_collecting` **不得**进入 `completeWithRange`,必须走 `collect()`
- `completeWithRange``kind` 参数删掉 `"exhausted"` 承接证据不足的用法;该函数今后只接受真正可交付的停止
- 修正 `if (stopReason)``coverageBlocks` 的先后:`keep_collecting` 必须让位给 `coverageBlocks` 的收集分支
---
## 任务 B(P0)· 能力标志单一来源 + 引擎上限交集
**B-1 抽出唯一计算点。** 新增一个纯函数,成为 `canAdopt` / `selectionAllowed` / `proposeAllowed` / `canConfirmExactMinute` 的**唯一**产出处:
```ts
function deliveryCapability(input: {
stopClass: StopClass | null;
separation: CandidateSeparation;
holdout: HoldoutValidationStatus;
engineCeiling: EngineCapabilityCeiling;
accepted: boolean;
userStopped: boolean;
}): { canAdopt: boolean; selectionAllowed: boolean; proposeAllowed: boolean; canConfirmExactMinute: boolean }
```
改完之后,`finish()` / `completeWithRange()` / `collect()` / `discriminate()` / `offerRangeWithoutAdopt()` / `holdoutValidation()` 里**不允许再出现这四个字段的字面量赋值**,一律取自该函数。请在 PR 里贴出 `grep` 结果自证。
**B-2 引擎上限。** 新增 `EngineCapabilityCeiling`,从 `decisionReceipt` 提取 `acceptance_allowed` / `selection_allowed` / `propose_allowed` / `confirmation_allowed`。**字段缺失或无法解析时一律视为 false**(红线 3)。`deliveryCapability` 的输出必须与之取逻辑与。
**B-3 修 `overlayPublicDecision`。** `v9/decision-from-dossier.ts``...snapshot` 后跟 `...fields` 的无条件覆盖改为对四个门禁字段取交集。保留既有的 `inconsistent` fail-closed。
**B-4 修病灶 4 的 fail-open。** `holdout === "unavailable"` 且非 `userStopped` 不得再 `finish("adopt_representative")``evidenceStopReason``ranked.length === 0` 时的 fail-open 一并处理。
---
## 任务 CP1)· 前端 fail-closed
`src/components/rectification-agentic-chat.tsx:1073``showSelectionCards` 目前只看 `candidateResult?.selectionAllowed && candidateResult?.canAdopt`。即便后端修好,这里也应有独立防线:渲染采用卡片前校验引擎 receipt 未否决。
**这是纵深防御,不是后端修复的替代品** —— 后端必须独立正确,任务 0 的不变量测试不允许依赖前端。
---
## 任务 D(P0)· 问题槽单一文案源
第二个用户可见缺陷:opening 轮问题卡片显示"请再说一件记得大概时间的经历。"——但用户**一件都还没说过**"再"字不通。
成因是**两条独立的问题生成路径**:模型在回复正文里自然地问了一个问题,服务端的 `persistNextInterviewIfIdle` 又确定性地生成了一个 focus。零证据时 `buildMethodFollowupPlan` 没有素材,`spokenFollowupForUser` 返回 null,落到 `v9/answer-choice.ts:329,390``v9/method-followup.ts:951` 的兜底句。
根本原则:**一个回合只能有一个"当前问题",且只能有一个产出点。** 本轮 D-1 与 D-2 都要做。
### D-1 · 服务端成为唯一问题源
- 零证据的 opening 轮必须生成真正的第一个问题,而不是"请再说一件…"的兜底句
- 三处兜底文案(`answer-choice.ts:329``answer-choice.ts:390``method-followup.ts:951`)要么删除,要么改成不预设"已经说过"的措辞
- 模型正文只做承接与解释,不再自带问句
### D-2 · Skill 升版,禁止模型正文自带问句
在 skill prompt 里硬性约束:问题由服务端问题槽呈现,正文不得自带问句。
**上一版任务书说这条"成本显著更高、需处理存量 case 的 skill_identity_mismatch"——那个判断是错的,已核实。** 实际情况:
- `agent/route.ts:219``skillVersion` 取自 **case 自己绑定的 `skill_version`**,不是全局常量
- `resolveExact``skill-package-registry.ts:459`)只在 `status === "blocked"` 时失败,**`deprecated` 正常放行**
- 因此存量 case 会继续跑它绑定的 10.0.13**不会 mismatch,不需要数据迁移**
- registry 里 10.0.0 → 10.0.13 共 15 个版本、历史版本全部 `deprecated`**升版是本仓库的常规操作**
升版必须同步改动以下 **5 处**,缺一即坏:
| # | 位置 | 改什么 |
| --- | --- | --- |
| 1 | `skills/jyotish-birth-time-rectification/versions/10.0.14/` | 以 `versions/10.0.13/` 为基础新建;改 `SKILL.md` 正文约束 + frontmatter `version: 10.0.14` |
| 2 | `skills/jyotish-birth-time-rectification/SKILL.md` | 根目录是最新版的**副本**,与上一行保持字节一致 |
| 3 | `skills/skill-package-registry.json` | 新增 10.0.14 条目 `status: "active"``packagePath` 指向 `versions/10.0.14`10.0.13 改 `"deprecated"` |
| 4 | 同上条目的 `sha256` | 用 `computeSkillPackageSha256()``skill-package-registry.ts:289` 已导出)**只对 `versions/10.0.14/` 计算** |
| 5 | `frontend/src/lib/rectification-agentic/v9/case-status.ts:92` | `RECTIFICATION_SKILL_VERSION = "10.0.14"` |
| 6 | `frontend/tests/skill-registry.test.ts` | 该文件**硬编码了 sha256**,必须同步 |
**SHA 只对 registry `packagePath` 指向的目录计算,绝不对 Skill 根目录计算。** 根目录含 `versions/` 整棵子树,对它计算会把全部历史版本吃进哈希——而且能正常返回一个值、不会立刻报错,错了也发现不了。
两个坑:
- sha256 覆盖目标目录全部内容(递归遍历,每个文件写入长度前缀的 path / executable bit / bytes)。**改 frontmatter 也会改 sha** —— 顺序必须是"先改完所有字节,最后统一重算",不得边改边算。
- `case-service.ts:301` 校验 `resolveActiveSkillPackage(...).version === RECTIFICATION_SKILL_VERSION`registry 与常量不一致会让**新建 case 直接抛 `skill_registry_version_mismatch`**,功能整个开不了场。上表第 3 与第 5 处必须同时改。
前车之鉴:`797a423a` 就是"改了 skill 字节但忘了刷新 registry hash"的补救提交。**改完务必跑 `npx tsx --test tests/skill-registry.test.ts` 验证。**
`skills/jyotish-birth-time-rectification/versions/` 目录存有历史版本快照,按既有惯例处理。
---
## 验收标准
1. `cd frontend && ./node_modules/.bin/tsc --noEmit` exit 0
2. `cd frontend && npx tsx --test tests/rectification-*.test.ts` 不低于 705 条且 `fail=0`
3. 任务 0 的 5 条不变量全绿
4. **回归实证**:用事故 case 的真实输入(1 条证据 / 1 个 domain / 9 个候选 12,12,12,12,11,11,10,10,10)跑 `decideRectification`,断言 `canAdopt === false``selectionAllowed === false``nextAction` 回到收集类,并把前后输出贴进 PR
5. B-1 的 `grep` 自证:四个能力字段在决策分支函数里无字面量赋值
6. `npx tsx --test tests/skill-registry.test.ts` 通过,且 registry / frontmatter / `RECTIFICATION_SKILL_VERSION` / 测试内 sha256 四处一致
7. 新建一个 case 能正常开场(验证 `skill_registry_version_mismatch` 未被触发),且 opening 轮问题槽文案不含"再"字预设
## 交付前必须说明
- 逐条列出改动的既有测试断言(红线 5),每条写清原值与为什么原值是错的
- 本任务书的病灶分析来自**静态溯源 + 本地 `decideRectification` 直接调用复现**,作者无 staging 凭据(仓库内无 `.env`),**未在真实环境验证**。你若同样无凭据,不要声称已验证。