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

17 KiB
Raw Blame History

任务书 · 生时校正决策权威归一与停止语义重构(2026-08-31)

基线:origin/staging @ 67101f03

本轮是结构性重构,不是补分支。上一轮 3a4396a4stop 语义)用"在 if 链里插一个分支"的方式改交付条件,直接造成了线上事故:引擎判定"证据不足、不许采用",界面却把 9 个几乎并列的候选摆出来让用户点"采用此时间"。 同类补丁不要再打了。


事故实证

真实 case 3fe32834-9068-4275-81b1-0a57e860e68e,用户只提供了 1 条证据(2016-09 上大学)。同一份 latest_result 内部自相矛盾:

字段 引擎 decisionReceiptPython 顶层投影(前端读这个)
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-635overlayPublicDecision

return {
  ...snapshot,   // 引擎结果
  ...fields,     // TS 决策 —— 无条件盖掉引擎
  ...(inconsistent ? { can_adopt: false, selection_allowed: false } : {}),

唯一的 fail-closed 是候选投影不一致(inconsistent)。引擎自己写的 acceptance_allowed: false 完全不在考虑范围内

病灶 2 · "证据不足"与"已经查完"共用一个出口

core/rectification-decision.ts:186-188

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 / canConfirmExactMinutefinish() / completeWithRange() / collect() / discriminate() / offerRangeWithoutAdopt() / holdoutValidation() 各自决定。没有任何单点能回答"什么条件下允许采用",也无法审计。上一轮的回归正是这种结构的必然产物。

病灶 4 · 多处 fail-open

core/rectification-decision.ts:222-233

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.15required_cases: 20status: "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。

让步顺序:不得放行不该放行的采用 > 数据正确性 > 功能与测试不回归 > 可诊断性 > 代码整洁

开工前置

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 !== falseselectionAllowed / proposeAllowed / canConfirmExactMinute 同理。
  2. 证据不足永不放行datedEventCount < MIN_STANDALONE_DATED_EVENTSdatedDomainCount < MIN_STANDALONE_DATED_DOMAINScanAdopt === falseselectionAllowed === false
  3. 分不开就不能采用separation.tiedForFirst === truecanAdopt === false(用户主动停止的路径除外,但那条必须带 completionStatus: "provisional_range_user_stopped")。
  4. holdout 不可用不得放行holdoutValidation === "unavailable" 且非 userStoppedcanAdopt === false
  5. 覆盖只能收紧overlayPublicDecision 的输出对四个门禁字段,必须是引擎值与 TS 值的逻辑与。

建议用组合枚举(笛卡尔积)而非手挑用例覆盖输入空间 —— 病灶 3 的教训就是单点用例挡不住分支链回归。

跑一遍,确认 1/2/3/4/5 里至少 4 条是红的。 全绿说明测试没写对,停下来重写。


任务 A(P0)· 停止原因分类化,与交付能力解耦

EvidenceStopReason 升级为带出口语义的分类。建议形状(可自行调整命名,但语义必须三分):

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()
  • completeWithRangekind 参数删掉 "exhausted" 承接证据不足的用法;该函数今后只接受真正可交付的停止
  • 修正 if (stopReason)coverageBlocks 的先后:keep_collecting 必须让位给 coverageBlocks 的收集分支

任务 B(P0)· 能力标志单一来源 + 引擎上限交集

B-1 抽出唯一计算点。 新增一个纯函数,成为 canAdopt / selectionAllowed / proposeAllowed / canConfirmExactMinute唯一产出处:

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")evidenceStopReasonranked.length === 0 时的 fail-open 一并处理。


任务 CP1)· 前端 fail-closed

src/components/rectification-agentic-chat.tsx:1073showSelectionCards 目前只看 candidateResult?.selectionAllowed && candidateResult?.canAdopt。即便后端修好,这里也应有独立防线:渲染采用卡片前校验引擎 receipt 未否决。

这是纵深防御,不是后端修复的替代品 —— 后端必须独立正确,任务 0 的不变量测试不允许依赖前端。


任务 DP0)· 问题槽单一文案源

第二个用户可见缺陷:opening 轮问题卡片显示"请再说一件记得大概时间的经历。"——但用户一件都还没说过"再"字不通。

成因是两条独立的问题生成路径:模型在回复正文里自然地问了一个问题,服务端的 persistNextInterviewIfIdle 又确定性地生成了一个 focus。零证据时 buildMethodFollowupPlan 没有素材,spokenFollowupForUser 返回 null,落到 v9/answer-choice.ts:329,390v9/method-followup.ts:951 的兜底句。

根本原则:一个回合只能有一个"当前问题",且只能有一个产出点。 本轮 D-1 与 D-2 都要做。

D-1 · 服务端成为唯一问题源

  • 零证据的 opening 轮必须生成真正的第一个问题,而不是"请再说一件…"的兜底句
  • 三处兜底文案(answer-choice.ts:329answer-choice.ts:390method-followup.ts:951)要么删除,要么改成不预设"已经说过"的措辞
  • 模型正文只做承接与解释,不再自带问句

D-2 · Skill 升版,禁止模型正文自带问句

在 skill prompt 里硬性约束:问题由服务端问题槽呈现,正文不得自带问句。

上一版任务书说这条"成本显著更高、需处理存量 case 的 skill_identity_mismatch"——那个判断是错的,已核实。 实际情况:

  • agent/route.ts:219skillVersion 取自 case 自己绑定的 skill_version,不是全局常量
  • resolveExactskill-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.1410.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_VERSIONregistry 与常量不一致会让新建 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 === falseselectionAllowed === falsenextAction 回到收集类,并把前后输出贴进 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),未在真实环境验证。你若同样无凭据,不要声称已验证。