docs(tasks): hold-exit regression fix and the cluster-width research brief

验收 7f28bfec/d97b9e9f:tsc、lint、Python quick gate 均无新增失败,但 npm test
由基线 27 红涨到 33——6 条全是「该交付却不交付」,因为 shouldHoldForTieBreak
放宽后连 exhausted 与 closed-ceiling 收口路径也被压住(BUG-688 修复单)。

研究单接上一轮结论:五个打分改法都改不动交付区间宽度,所有方案宽度中位数等于
整个搜索窗。先查 sweep 的宽度口径是否包含淘汰,再查 cap_clusters_by_adjacent_merge
「从不丢簇」与签名层过细两条线索,量三个候选改法。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
This commit is contained in:
Jesse_Chen
2026-09-14 13:11:43 +00:00
co-authored by Claude Fable 5
parent 6fd7925ad4
commit 8f50af91e3
3 changed files with 180 additions and 0 deletions
+4
View File
@@ -184,6 +184,10 @@
| `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` |
| `TASK-rectification-tiebreak-hold-exit-fix-20260914.md` | `PROGRESS-rectification-tiebreak-hold-exit-20260914.md` | **P0 回归修复单**:BUG-686 的风格题前置把耗尽与收口路径的交付也压住了,`npm test` 由 27 红涨到 33(6 条「该交付却不交付」)。后续 `6fd7925a` 只改断言把它们改绿:4 条文案放宽可接受,2 条实质弱化(「恰好一条交付闸」退化成短路、参考题确认语被算作出口载体)必须恢复;行为层根因未动。hold 要带出口、不得作用于 exhausted/closed-ceiling,最多 hold 一次(BUG-688)。尚未部署,未影响线上 | 待执行 | `codex/rectification-tiebreak-hold-exit-20260914` |
| `TASK-rectification-cluster-width-research-20260914.md` | `PROGRESS-rectification-cluster-width-research-20260914.md` | **研究单**:上一轮证明调权重改不动交付区间宽度——所有方案宽度中位数都等于整个搜索窗。先确认 sweep 的宽度口径是否含淘汰(M0),再画簇结构像(M1),最后量三个改法:放宽簇上限、按分差决定是否合并、交付区间改分位覆盖(M2)。真值覆盖率不得下降 | 待执行 | `codex/rectification-cluster-width-research-20260914` |
| `TASK-rectification-minute-resolution-research-20260914.md` | `PROGRESS-rectification-minute-resolution-research-20260914.md` | **研究单**:候选分不开的根因是打分尺度——窗口内恒定项 11.5 分 vs 随分钟变化项 2.125 分(≈5:1)。先修封存基准(v3 每例仅 3 件事且被标 invalidated)出 v4,再离线量五个改法:分盘除数、去底座、**KP 宫头子主计分(产品 09-14 拍板,推翻 BUG-325 一条红线)**、年精度事件改边际似然、聚类签名层对齐。有收益才立实现单 | 待验收 | `codex/rectification-minute-resolution-research-20260914` |
| `TASK-rectification-tiebreak-before-card-20260914.md` | `PROGRESS-rectification-tiebreak-before-card-20260914.md` | **P0**:点「再答两道参考题」服务端 ok 但界面无反应(`requestTieBreak` 的 `loadCaseSnapshot()` 不带合并回调,新 turn 不入对话区)(BUG-685);产品拍板放宽版 A——出交付卡前只要参考题可用且没用过就先问两道(不看分差),卡上收起按钮(BUG-686);交付文案列出用户已拒答的线(BUG-687) | 待验收 | `codex/rectification-tiebreak-before-card-20260914` |
@@ -0,0 +1,76 @@
# 研究单 · 为什么交付区间宽度永远等于整个搜索窗(2026-09-14)
- 基线:`origin/staging` @ `2d2467dc`
- 分支:`codex/rectification-cluster-width-research-20260914`worktree `.worktrees/rectification-cluster-width-research-20260914`
- 性质:**离线测量与取证,不改线上行为。** 有可行改法才另立实现单。
- 前序:`TASK-rectification-minute-resolution-research-20260914.md`(结果 `docs/research/minute_resolution_2026_09_14.md`)——五个打分改法在三档半径上都没过门,**最硬的发现是所有方案(含基线)的交付区间宽度中位数都等于整个搜索窗**(±10 → 21 分钟、±30 → 61、±60 → 121)。本单接着查这一条。
## 1. 问题
用户端最直接的痛点是"范围卡给的区间太宽、几个候选可能性差不多"。上一轮证明:**调打分权重改不动宽度**。所以宽度是被别的东西决定的。
两条待查线索(都在 `scripts/rectification/candidate_contrast.py`):
1. **公开簇的并集按设计铺满窗口。** `cap_clusters_by_adjacent_merge``:247`)的 docstring 原文就是 *"Keep the whole window. If there are too many clusters, merge adjacent weak ones."*——簇超过上限时**合并**相邻弱簇,但**从不丢弃**,所以簇并集恒等于整个搜索窗。交付区间取的是"仍然有效的簇的并集",于是只要没有候选被真正淘汰,宽度就等于整窗。
2. **签名层可能过细。** `SIGNATURE_LAYERS = ("d1","d9","d10","d24","d4","d12","md")``:25`)。若在 ±10 分钟内几乎每分钟一个签名,簇数会一直顶到上限,再靠上面的合并强行压回——等于用"合并"替代了"区分"。
还有一条**必须先排除的测量口径问题**:上一轮 sweep 的宽度指标是否包含**淘汰**?真机上范围确实会收窄(真实会话里 04:4505:15 → 04:4805:07 是靠 4 个候选被淘汰实现的)。如果 sweep 只做打分与回放、不做淘汰,那"宽度恒等于整窗"就是**测量口径的产物**,而不是线上行为。**这一条不查清,后面所有结论都不成立。**
## 2. 要量的东西
### M0(前置,必做)· 先确认口径
-`scripts/research/minute_resolution_sweep.py``minute_resolution_lib.py`,确认宽度指标的定义:是取"全部候选簇的并集"还是"未淘汰候选簇的并集";回放六题时有没有执行淘汰。
- 如果没有淘汰:在 sweep 里补上与线上一致的淘汰逻辑,**重跑基线宽度**,给出"含淘汰 / 不含淘汰"两组数字。
- 交付判断:若含淘汰后宽度本来就会收窄,则上一轮"宽度不变"的结论只适用于不含淘汰的口径,要在 `docs/research/minute_resolution_2026_09_14.md` 补一条勘误(不删原文,追加说明)。
### M1 · 簇结构画像
在 v4 的 20 例、三档半径上统计:
- 每例的原始签名簇数(合并前)、合并次数、合并后簇数;
- 合并掉的簇的峰值分差(被合并的两个簇分数差多少);
- 真实分钟所在的原始簇,**在合并之后是否还独立存在**(关键:如果真值簇被并进一个大簇,用户就永远看不到那个分钟单列);
- `SIGNATURE_LAYERS` 里每一层在 ±10 窗内的取值变化次数(找出哪些层在窄窗内是常数——例如 `md` 月宿在同日窗口内恒定,占了签名位却不提供区分)。
### M2 · 三个候选改法(只量,不改线上)
| 编号 | 改法 | 关注指标 |
| --- | --- | --- |
| W1 | 提高 `MAX_PUBLIC_CLUSTERS`(不合并或少合并),看真值簇是否更常独立存在;同时看卡片是否变得太长(用户一次看不过来) | 真值簇独立率、簇数中位数 |
| W2 | 合并时改按**分数差**而不是"相邻且弱":分差超过阈值的两簇不得合并,宁可超上限 | 真值簇独立率、被合并的分差分布 |
| W3 | 交付区间改用"仍然有效候选的**分数分位覆盖**"(例如覆盖 posterior 质量 80% 的最小连续区间),而不是簇并集 | 宽度中位数、真值落在区间内的比例(**不得下降**) |
统一指标沿用上一轮五项:真实分钟命中率、**真值落在交付区间内的比例(不得下降)**、区间宽度中位数、并列率、每轮熵降;另加"真值簇独立率"。
## 3. 交付
- `scripts/research/cluster_width_probe.py`(新)+ 结果 `docs/research/cluster_width_2026_09_14.md` / `.json`
- 口径声明与上一轮一致(ayanamsa `raman`、node mode `mean`),不得与上游 true-node 数字直接比。
- 结论只允许三种:有收益(真值覆盖率不降、宽度中位下降或真值簇独立率显著上升)→ 立实现单并给推荐参数;无收益 → 关闭写明;不确定 → 说明缺什么。
- 不得改 `scripts/rectification/candidate_contrast.py` 的线上默认值;不得把研究脚本接进生产路径。
## 4. 硬红线
1. Python 改动跑 `.venv/bin/python scripts/run_quality_gate.py --profile quick`(已知既有缺口:`test_shadbala_endpoint_returns_ranked_planet_strength`,时区依赖缺失,基线同样红,不计入)。
2. **不得为了把宽度做窄而牺牲真值覆盖率**——真实分钟被挤出交付区间是比"区间宽"严重得多的错误(`AGENTS.md` Part B B4)。
3. 不得放宽置信度或确认门控。
4. 不得使用真实用户资料;样本只用既有 v4 公开 AA 数据。
5. 结论必须落到数字,不接受"感觉更准"。
## 5. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-cluster-width-research-20260914 \
.worktrees/rectification-cluster-width-research-20260914 origin/staging
cd .worktrees/rectification-cluster-width-research-20260914
ln -s /workspace/Jyotisha/.venv .venv
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
```
开工前读:`docs/research/minute_resolution_2026_09_14.md`(上一轮结论与它的适用边界)、`docs/research/upstream_rectification_gap_2026_09_14.md``scripts/rectification/candidate_contrast.py` 全文。
## 6. BUG 编号
- 研究单,**不新增 BUG 编号**。若立实现单,届时按当时最大号顺延。
@@ -0,0 +1,100 @@
# TASK · 修复单:参考题前置把"该交付"的出口也压住了 — 2026-09-14
- 基线:`origin/staging` @ `2d2467dc`(**注意:staging 当前部署的是 `9da42c99``7f28bfec` / `d97b9e9f` 这两个代码提交尚未部署,本回归没有影响线上用户**)。
- 分支:`codex/rectification-tiebreak-hold-exit-20260914`worktree `.worktrees/rectification-tiebreak-hold-exit-20260914`
- 关联:**本单是 `TASK-rectification-tiebreak-before-card-20260914.md`BUG-685/686/687,实现 `7f28bfec` + `d97b9e9f`)的回归修复单**。另关联 BUG-590、BUG-651、BUG-653、BUG-656"必须有出口、不得无题永久等待"那条线)。
- 串行:只改 `core/rectification-decision.ts` 与相关测试;与研究单 `TASK-rectification-cluster-width-research-20260914.md` 无文件重叠,可并行。
## 1. 事故实证(Claude 验收,2026-09-14
`2d2467dc` 上跑门禁:`tsc --noEmit` 0 错、`npm run lint` 0 error、Python quick gate 只剩既有环境缺口(`test_shadbala_endpoint_returns_ranked_planet_strength`,时区依赖缺失,基线 `14d199e6` 上复跑同样红)。
`npm test` 从基线 27 条失败涨到 **33 条**,新增 6 条**全部是"该交付却没交付"**
| 文件 | 红掉的断言 |
| --- | --- |
| `frontend/tests/rectification-exhaustion-exit-20260906.test.ts` | `closed ceiling with training open delivers a range and a concrete next-collect hint``inconsistent projection logs ranked_count 0 and still delivers a gate``ensureNonTerminalTurnExit writes a host turn when exhausted with no carrier`(实测 0 !== 1,收口 turn 没写);`last closed-ceiling card concatenates the gate into one append` |
| `frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts` | `T3: skipped persist still leaves a non-empty carrier; 没有了 delivers the range card``T4: exhausted refresh and declined targeted collect titles the card 目前范围` |
新套件 `rectification-tiebreak-before-card-20260914.test.ts` 本身 9/9 全绿——**问题不在新功能,在它压住了旧出口**。
根因在 `d97b9e9f` 把守卫放宽之后:
```ts
// frontend/src/lib/rectification-agentic/core/rectification-decision.ts
export function shouldHoldForTieBreak(input, separation, kind?): boolean {
if (kind === "user_stopped" || input.userStopped === true) return false;
if (input.pendingTieBreak !== true) return false;
return separation.ranked.length >= 2; // ← 只要还有没问过的风格题就压住交付
}
```
它被插在六处交付前(含 `coverageBlocks` 里的 `offerRangeWithoutAdopt``canAdopt && methodCoverageAll``finish``holdout === "unavailable"` 的收口、`confirmationAllowed` 的 finish 等)。于是**连"引擎已经没东西可问"的耗尽路径、closed-ceiling 收口路径也被压住**`holdForTieBreak` 返回 `discriminate_candidates` + `waitToNarrowCapability`(交付能力全零)。
风险不止于测试红:风格题自身还有两道门——候选在该分盘上的上升星座要 ≥2 种(`core/candidate-contrast-packet.ts:610`)、焦点要能落库。任一不过,案子就停在 `discriminate_candidates` 且没有题,**正是 BUG-656/674/680 那类死角**。
## 2. 根因
"还有风格题没问"被当成了**无条件**的延迟交付理由,而它实际上只应该在"系统本来就打算交付、且风格题确实能问出来"时生效。原任务书 D1 写的是「带年月的题问完、出交付卡之前」,实现把它扩大成了"任何交付分支之前",且没有给 hold 设出口。
## 3. 决策记录
| 决策 | 内容 |
| --- | --- |
| D1 | **hold 必须带出口。** `shouldHoldForTieBreak` 增加:风格题**确实可出**才 hold。判据不能只看 `pendingTieBreak` 这个布尔,要看"这一轮真的能拿到一道可渲染的风格题"(`tieBreakPersonalityFollowup(...) !== null` 且其 `choice_frame` 非空)。拿不到就立即放行交付。 |
| D2 | **耗尽与收口路径不得被 hold。** `stopClass?.kind === "exhausted"`(含 `tied_first``user_uncertainty_too_high`)、`ensureNonTerminalTurnExit` 依赖的收口分支、closed-ceiling`coverageBlocks``engineOffers && !narrowingOpen`)三处**不插 hold**。风格题前置只作用于"正常收敛到可交付"的路径。这是对 `TASK-rectification-tiebreak-before-card-20260914.md` D1 的收窄:产品意图(出卡前先问风格题)保留,但不得以牺牲出口为代价。 |
| D3 | **hold 有次数上限。** 同一 case 的 tie-break hold 最多生效 1 次(两道风格题是一次连出)。已经 hold 过一次仍拿不到题,直接交付,不得反复 hold。 |
| D4 | 六条红测试**必须恢复绿**。若确有断言需要改,按 `AGENTS.md` §7.3 写"原值 / 新值 / 原因"三栏,并说明为什么新行为不违反 BUG-590/651/653/656 的防复发条;不得静默删除或跳过。 |
| D5 | 不动数据库结构与迁移、不动 `deploy/**` 与 workflow、不动 `page.tsx`、不新增依赖。不得回退 BUG-685(合并回调)、BUG-687(文案)与锚点闸删除。 |
## 4. 硬红线
1. `tsc --noEmit` 0 错;`npm run lint` 0 error**`npm test` 失败数必须回到基线 27 条**(本机无 Docker 基线;工作树缺 `.venv` 软链会多一条 workflow YAML 假红,补软链即绿);`next build` 通过且 `/``○ Static`
2. Python 侧只需确认 quick gate 不新增失败(shadbala 时区那条是既有环境缺口,基线同样红)。
3. 新增断言必须是行为断言。
4. 不得用"把测试改成期望不交付"来收口(D4)。
## 5. 任务分解
### 任务 1 · 给 hold 加出口(P0)
- `shouldHoldForTieBreak` 的入参从 `pendingTieBreak: boolean` 改为可判定"能否真的出题":由 `decision-from-dossier.ts` 侧用 `tieBreakGateInput` 计算出 `tieBreakFollowupReady`followup 非空且 `choice_frame` 非空)传入。
- 按 D2 把 exhausted / closed-ceiling / 收口三处的 hold 调用去掉。
- 按 D3 加 hold 次数上限(可用既有 `tie_break_round` 标记或 `declinedSkippedTopics` 里的痕迹判断"已经 hold 过")。
- 验收标准:
- 行为单测:`pendingTieBreak` 为真但 `tieBreakPersonalityFollowup` 返回 null(例如候选在 D9/D10 上只有一种星座)→ **交付**,不得 hold。
- 行为单测:`stopClass.kind === "exhausted"``pendingTieBreak` 为真 → **交付**,不得 hold。
- 行为单测:正常收敛路径 + 风格题可出且未用过 → hold(保留 BUG-686 的产品行为)。
- 行为单测:已经 hold 过一次、风格题仍拿不到 → 交付。
### 任务 2 · 六条红测试恢复绿(P0)
- 逐条跑 `frontend/tests/rectification-exhaustion-exit-20260906.test.ts``frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts`,确认全绿。
- 若某条确需改断言,按 D4 三栏说明。
### 任务 3 · 记录
- `docs/BUG_HISTORY.md` 新增 **BUG-688**`resolved`),写明「复发自 BUG-686 的修复」,防复发条:**任何"延迟交付"的守卫都必须带出口,且不得作用于耗尽与收口路径;验收必须比对 `npm test` 失败数与基线**。
- `CHANGELOG.md` 一行;`PROGRESS-rectification-tiebreak-hold-exit-20260914.md`
## 6. 让步顺序
1. 任务 1 的 D2(耗尽/收口不 hold)最优先——它直接决定会不会出现死角。
2. D1(拿不到题就放行)次之。
3. D3(次数上限)可延后,但要写进进度记录。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-tiebreak-hold-exit-20260914 \
.worktrees/rectification-tiebreak-hold-exit-20260914 origin/staging
cd .worktrees/rectification-tiebreak-hold-exit-20260914
ln -s /workspace/Jyotisha/.venv .venv
cd frontend && npm ci
npx tsx --test tests/rectification-exhaustion-exit-20260906.test.ts tests/rectification-probe-pool-exhausted-20260911.test.ts # 先复现 6 条红
```
## 8. BUG 编号起点
- 起点 **BUG-688**(当前最大号 687)。