From 9aebd0474a089ed5bec1fc8e8432d0d85e93ffb6 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 31 Aug 2026 14:37:26 +0000 Subject: [PATCH] docs(rectification): add decision-authority refactor task brief The engine receipt says acceptance_allowed/selection_allowed false with reasons insufficient_events and insufficient_domain_diversity, yet the top-level projection flips both to true and renders adoptable candidates after a single piece of evidence. Records the four structural causes: dual decision authority where overlayPublicDecision lets TS override the engine, insufficient-evidence sharing an exit with exhausted, capability flags hardcoded across eleven branches, and several fail-open paths including holdout "unavailable" granting adoption. Also folds in the skill bump (D-2) after verifying that existing cases bind their own skill_version and deprecated packages still resolve, so no case migration is required. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8 --- ...ctification-decision-authority-20260831.md | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 TASK-rectification-decision-authority-20260831.md diff --git a/TASK-rectification-decision-authority-20260831.md b/TASK-rectification-decision-authority-20260831.md new file mode 100644 index 00000000..3ddf9362 --- /dev/null +++ b/TASK-rectification-decision-authority-20260831.md @@ -0,0 +1,255 @@ +# 任务书 · 生时校正决策权威归一与停止语义重构(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:15(29 分钟),前四名支持度 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 一并处理。 + +--- + +## 任务 C(P1)· 前端 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/SKILL.md` | 正文约束 + frontmatter 的 `version: 10.0.14` | +| 2 | `skills/skill-package-registry.json` | 新增 10.0.14 条目 `status: "active"`;10.0.13 改 `"deprecated"` | +| 3 | 同上条目的 `sha256` | 用 `computeSkillPackageSha256()`(`skill-package-registry.ts:289` 已导出)对**整个包目录**重算 | +| 4 | `frontend/src/lib/rectification-agentic/v9/case-status.ts:92` | `RECTIFICATION_SKILL_VERSION = "10.0.14"` | +| 5 | `frontend/tests/skill-registry.test.ts` | 该文件**硬编码了 sha256**,必须同步 | + +两个坑: + +- sha256 覆盖整个目录(递归遍历,每个文件写入长度前缀的 path / executable bit / bytes)。**改 frontmatter 也会改 sha** —— 顺序必须是"先改完所有字节,最后统一重算"。 +- `case-service.ts:301` 校验 `resolveActiveSkillPackage(...).version === RECTIFICATION_SKILL_VERSION`,registry 与常量不一致会让**新建 case 直接抛 `skill_registry_version_mismatch`**。第 2、4 处必须同时改。 + +前车之鉴:`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`),**未在真实环境验证**。你若同样无凭据,不要声称已验证。