Files
Jyotisha/docs/tasks/TASK-rectification-convergence-exit-20260906.md
T

242 lines
30 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.
# TASK · 生时校正:采集完没有出口、反推题形态不对、候选收不敛(2026-09-06)
- 基线:`origin/staging` @ `e2d6f203`(代码头 `e2f4b55c`staging 已部署同一 SHA,事故就是这版跑出来的)
- 分支:`codex/rectification-convergence-exit-20260906`
- 执行方:coding agent;验收:Claude
- 涉及文件:
- TS`frontend/src/lib/rectification-agentic/v9/answer-choice.ts``method-followup.ts``decision-from-dossier.ts``probe-question-contract.ts``core/build-state.ts``core/candidate-separation.ts`(只读)、`frontend/src/app/api/rectification/agent/route.ts``frontend/src/components/rectification-agentic-chat.tsx``frontend/src/lib/rectification-agentic/user-copy.ts`
- Python`scripts/rectification/decision_policy.py``scripts/rectification/event_probes.py``scripts/rectification/probe_question_contract.py``contracts/probe-question-v1.json`
- 不改 `scripts/jyotish_api_server.py`(增长冻结),不改 `frontend/src/app/page.tsx`
- BUG 编号起点:**BUG-558**BUG-557 已被 `TASK-session-title-guard-fix-20260906.md` 预留;开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核)
- 与其他任务书的关系:与 `TASK-session-title-guard-fix-20260906.md`(consult 路由)无文件交集,可并行。本单三个工作流 A → B → C **串行**,同一分支分三次提交。
## 1. 事故实证(staging2026-09-06,产品负责人实测)
年份一律抽象(Y、Y+4 …),不写出生资料。用户一次走完整个采集,共说了 7 件带时间的经历,答了 6 张点选卡,最后停在一句"也可以再说一件你记得大概时间的事",界面只剩"目前范围 …–…,还在收窄"的只读小字,没有候选卡、没有停止按钮、没有下一步。
| 轮 | 系统问 | 用户答 | 备注 |
| --- | --- | --- | --- |
| 1 | 开场采集(`GENERIC_COLLECT_QUESTION`) | 上大学 Y 年 9 月、毕业 Y+4 年 6 月 | 2 件 month |
| 2 | 感情采集(`USER_COLLECT_QUESTION.relationship` 原文) | Y+8 年 5 月认识、8 月 8 日分手 | month + day |
| 3 | 点选卡「在亲密关系里更接近哪一种 A/B/C/D」 | 点选 | varga_style,有事件锚点 |
| 4 | 点选卡「平时做事更接近哪一种 A/B/C/D」 | 点选 | varga_style**无任何时间锚点** |
| 5 | 点选卡「Y+7 年 5 月前后有没有入职/换工作 A/B/C/D」 | 点选 | existencecareer |
| 6 | 点选卡「Y+8 年 4 月前后有没有入职/换工作」 | 点选 | existencecareer |
| 7 | 点选卡「Y−1 年 5 月前后有没有搬家/长期住外地」 | 点选 | existencerelocation |
| 8 | 家人采集(原文) | "没有" | declined |
| 9 | 钱的采集(原文) | Y+10 年 1 月欠薪、开始欠债 | month;助手随后说"想先看看当前候选也直接说一声,我来汇总" |
| 10 | 搬家采集(原文) | Y+7 年 7 月搬到外地 | month |
| 11 | 点选卡「**Y+7 年前后**工作上有没有入职/换工作」 | 点选 | 与第 5 轮同域同年,只是去掉了月份 |
| 12 | 身体/压力采集(原文) | Y+10 年 18 月难熬 | range |
| 13 | 职业采集(原文) | 三段职业,无年份 | occupation_note |
| 14 | **「也可以再说一件你记得大概时间的事。」**(`USER_COLLECT_QUESTION.other` 原文) | —— | 死胡同 |
只读小字显示范围仍有 27 分钟宽。每张点选卡答完只回"已记录你的选择,并更新了候选比较",看不出哪一题让范围动了。
产品负责人的三条判断,本单照单全收:
1. 与 Agent 互动完之后没有下一步,用户不知道该做什么,卡在那里。
2. 占星反推题与验证冲突题应当是"**在几年几月发生了什么事 → 你觉得更像 A/B/C/D**"这种形态;现在是一个领域一个领域地问"那时候有没有 X",同一个时间点被拆成几道题,还有没时间锚点的性格题。
3. 时间根本收敛不了。
## 2. 根因(三条,互相独立,都已在代码里定位)
### 2.1 出口:非收敛出牌 / 非终止修复都退化成"再说一件事",没有交付
- `answer-choice.ts::persistNextInterviewIfIdle``isNonConvergingRangeOffer(decision)``canOfferRange && !canAdopt && nextAction === "offer_provisional_range"`)时调用 `persistExhaustionCollect``ensureNonTerminalTurnExit` 在"没有问题也没有采用载体"时同样调用它;`route.ts` 无焦点分支也走同一条。
- `persistExhaustionCollect``method-followup.ts::exhaustionSpokenCollectFollowup`:先 `nextDatedCollectFollowup`(七个领域都问过/拒过就为空),再职业,最后 **无条件落到 `otherCollectFollowup`**,即第 14 轮那句话。它的 `domain="other"` 不在 `REMAINING_EVIDENCE_COLLECT_DOMAINS`,所以既不算"剩余采集"也不挡出牌,但它作为 `collect:other:*` 焦点被持久化后,下一轮再答一件事又回到同一处——**没有终止条件**。
- 出口所依赖的 `canAdopt=false` 从哪来,服务端没有留下任何日志(`persistExhaustionCollect` 不记录 `decision` 的输入)。可能来源有四个:引擎 `acceptance_allowed=false``decision_policy.py` 的 event_quality / domain_diversity / date_quality 门)、`trainingScoreableGate` 因证据 status 非 `confirmed` 而关闭、`candidateScoresFromDossier` 在 inference 存在但投影不一致时返回空数组(`ranked.length===0``keep_collecting`)、`engineCapabilityCeilingFromReceipt` 因 receipt 字段自相矛盾而返回全关。**本单不猜是哪一个**,先补日志与形状回归,再把"不管哪一个都不能落到 other"钉死。
- 用户能看到的唯一状态是 `RectificationReadonlyRange``sessionOutcome ∈ {collect_evidence, discriminate_candidates}` 时渲染),它不可点。口述采集态没有"先这样,先看当前范围"按钮(`CHOICE_STOP_LABEL` 只挂在点选卡上)。`turn-intent-classifier.ts` 定义了 `ask_about_result``route.ts` 却只处理 `stop_rectification`,而且只在点选焦点分支里处理;助手那句"想先看看当前候选也直接说一声"是模型自由发挥,系统兑现不了。
### 2.2 题型:探针按"领域 × 大运边界月"一条条出,同一日期拆成多题,同域同年月份不同再问一遍
- `event_probes.py::_discriminating_event_probe_lists` 对每个领域独立扫描 `_union_boundary_dates`,同一个边界月会给 career、finance、relocation 各生成一条 existence 探针。用虚构出生资料(1998-03-15,北京,04:3005:30)把本次事故的 7 件事喂进 `score_candidates`,引擎公开的 8 条探针是:
| semantic_key | gain | 说明 |
| --- | --- | --- |
| `career.2022.06.dasha_boundary` | 1.11 | 同一月 |
| `finance.2022.06.dasha_boundary` | 1.06 | 同一月 |
| `career.2023.05.dasha_boundary` | 1.10 | 同一月 |
| `finance.2023.05.dasha_boundary` | 1.01 | 同一月 |
| `relocation.2018.05.dasha_boundary` | 0.99 | |
| `relationship.2019.02.dasha_boundary` | 0.96 | |
| `finance.2024.03.dasha_boundary` | 0.92 | |
| `career.2024.05.dasha_boundary` | 0.88 | |
8 条里 4 条是"同一个月、换个领域再问一次"。用户感受就是"同一个时间点被反复问有没有 X、有没有 Y"。
- 去重键是 `(domain, year, month, source)``_partition_ranked_probes`)与 TS 侧 `askedKeys`semantic_key / split hash 精确匹配)。`_try_activation_probe` 用年龄带中点年份生成年级探针(`career.<Y>.dasha_activation`,无月份),`_existence_blocked_years` 只看**账本**里的年份,不看**已问过**的探针年份,于是第 5 轮问了 `career.Y+7.05`,第 11 轮又问 `career.Y+7`(BUG-540 修的是质量探针绑定,没覆盖这条)。
- varga_style 卡(第 4 轮"平时做事")来自 `contrastPacket` 的 D10 类型对照,没有任何日期锚点;第 3 轮同类卡之所以可接受,只是因为模型自己把"回想你 Y+8 年的这段感情"写进了题干。
- `_agent_brief` 给模型的提示是"写一句自然的是/否题",产品要的"那时候发生了什么 → 更像哪一种"在探针层根本没有这个题型。
### 2.3 收敛:相对支持度把引擎证据压平成 7–9,之后只有点选卡在推分
同一份虚构资料的量化结果(`references/` 里没有该用户任何资料,数字只说明机制):
| 指标 | 4 件事 | 7 件事 |
| --- | --- | --- |
| 61 个分钟的原始分 min / max | 6.03 / 11.19 | 10.96 / 15.52 |
| 原始分极差 | 5.16 | 4.56 |
| 公开候选(12 个特征簇代表)的 `relative_support` | 9 9 9 9 9 8 8 8 8 8 8 7 | 9 9 9 9 9 8 8 8 8 8 8 7 |
| top-2 领先 | 0 | 0 |
| 诊断 `margin_percent` | 0.80 | 2.26 |
| `acceptance_allowed` / `propose_allowed` | true / true | true / true |
- `decision_policy.py::_relative_support` 把 100 按**原始分正比例**分给最多 12 个簇代表。原始分是各事件正分之和,底座 11–15,事件带来的差异只有 4.5,正比例归一后每个簇 7–9 分,**任何证据组合都到不了 `MIN_SEPARATION_LEAD=8`**。
- `core/build-state.ts::buildInferenceState``relative_support``prior_score`,之后每道点选卡按 `SCORE_DELTA` ±2 推分;`credible-range.ts::unionStillValidRange` 取"与峰值差 < 8"的簇并集。于是:7 件带年月的经历对可信区间几乎没有贡献,区间只靠点选卡一题题往下压,压 4 题才可能淘汰一个簇;点选卡又有 8 轮 / 10 答的熔断。这就是"收不敛"。
- 同一份数据换成"支持度 ∝ (原始分 − 窗口最低分)"12 个簇代表变成 14/14/12/12/10/9/8/7/6/5/2/0;换成 softmaxT=0.5)是 29/26/14/13/6/4/3/1/1/1/0/0。差别来自引擎已经算出来的证据,不是新证据。
- `TASK-rectification-provisional-adopt-20260901.md` §2 当时已经看到"lead 永远停在 5",决策是"只改门、四个常数不动",但没有追到 prior 被压平这一层。本单不动 `MIN_SEPARATION_LEAD=8` 的数值,改的是喂给它的尺度。
## 3. 决策记录(产品负责人 2026-09-06 授权"彻查并彻底解决",以下是 PM 代为落地的决策,可否决)
1. **采集封顶必须交付。** 方法覆盖完成(`blockingMethodsCovered`)、七个带年月领域都问过或拒过、职业已覆盖之后,不得再问任何开放式采集题。`USER_COLLECT_QUESTION.other` / `USER_COLLECT_QUESTION_RETRY.other``otherCollectFollowup` 只保留给开场(`!dashaCovered`);`exhaustionSpokenCollectFollowup` 删除 `other` 兜底。这推翻 `TASK-rectification-nonterminal-exit-20260901.md` 里"每轮至少留一个问题"的实现方式:**每轮至少留一个问题或一个交付载体**的规则不变,但穷尽时的载体改为交付,不是"再说一件事"。
2. **交付分两种。** 引擎 `acceptance_allowed=true` → 走既有 `adopt_representative` 出牌(候选卡 + 八法报告 + 停止原因句)。`acceptance_allowed=false` → 不出牌,但必须把 receipt 的 `acceptance_reasons` 翻成用户能照做的一句话(例:"还差 1 件带月份的经历,领域不限" / "两件事的日期还没对清"),并给"先这样,先看当前范围"按钮;范围小字改为可点。不得再出现"也可以再说一件事"。
3. **口述采集态也要有停止按钮**,与点选卡共用 `CHOICE_STOP_LABEL`,走 `STOP_ACTION`(→ `paused``provisional_range_user_stopped`)。`ask_about_result` 意图等同于用户按了这个按钮。助手文案不得承诺系统做不到的事(Skill 文案另开单,本单只在 `user-copy.ts` 与提示词里删掉"说一声我来汇总"类措辞的来源——若来源在模型自由发挥,进度记录写明即可)。
4. **题型改为"某年某月发生了什么"。** 同一 (year, month) 的多领域 existence 探针合并成一张卡(见 5.B2),选项是领域,不是"发生了 / 没发生";题干必须写出年月,若账本里有 ±2 个月内的其他领域事件,题干要提到它("Y 年 7 月你搬到外地前后,工作上……")。这是产品要的"反推 + 验证冲突"形态。四选项契约(BUG-390)不放宽:一张卡仍是 A/B/C/D 四项,只是每项映射到两条探针的答案。
5. **同域同年只问一次。** 已问过 `career.Y.05.*`,则 `career.Y``career.Y.03``career.Y±1`(按 `EXISTENCE_NEARBY_YEARS`)全部视为已问。Python 与 TS 两侧都要生效。
6. **无锚点的性格题不出。** varga_style 卡只在同域账本里有带年月事件时才可渲染,题干提示必须带那件事的年月;排序在带年月卡之后。
7. **prior 换尺度。** `_relative_support` 改为按 (原始分 − 全窗口最低分) 归一(或温度可调的 softmax,由校准决定),`MIN_SEPARATION_LEAD=8`、3/2 采用门、4/3 确认门、`maxPlateauRounds` 等数值一律不动。这是对 `TASK-rectification-decision-authority-20260831.md` §4 "数值不动"的补充而非推翻:常数不动,改的是输入尺度,并且必须过 5.C2 的校准门。**合入前把校准表拿给产品负责人看一眼**,他点头再合。
8. **先补日志再修。** `persistExhaustionCollect` 的每次进入都要写一条结构化日志(无 PII),本单合入后若真实环境再复现,靠日志定位 `canAdopt=false` 的来源,不靠猜。
## 4. 硬红线
- BUG-390 四选项契约、`contracts/probe-question-v1.json` 的 yes / weak_yes / no / unsure 语义不放宽;新增 choice_kind 必须同时进 Python `probe_question_contract.py` 与 TS `probe-question-contract.ts` 的契约测试。
- `tests/test_rectification_engine_convergence.py::test_scripts_and_frontend_have_no_answer_key_literals` 必须继续通过:不得在代码里写任何真实案例的年份/分钟字面量。
- 采用门 / 确认门 / 训练门 / 熔断的数值不动(`MIN_ACCEPTANCE_*``MIN_CONFIRMATION_*``MIN_SEPARATION_LEAD``DEFAULT_MAX_DISCRIMINATION_ROUNDS``EFFECTIVE_ANSWER_SAFETY_CAP``maxPlateauRounds``minUncertaintyAnswers`)。
- BUG-503(一道 D 不停)、BUG-520(区分卡答"否"不是拒答)、BUG-540/541(质量探针绑定)、BUG-544/546/547/549/550 的用例原样通过;改既有断言写"原值 / 新值 / 原因"三栏。
- 点选答案仍不写证据账本(BUG-389–393 分层)。
- 前端:`tsc --noEmit` 0 错、`npm run lint` 0 error、测试总数不低于开工时 `origin/staging` 实测;`page.tsx` 不增长。Python`run_quality_gate.py --profile quick` 通过,`scripts/jyotish_api_server.py` 不增长。
- 任务书 / 进度 / Bug 历史 / 测试 fixture 只用抽象年份或公开名人(`references/real_case_calibration/`)。
## 5. 任务分解
### A · 出口(BUG-558
**A1 结构化日志。** `persistExhaustionCollect` 入口 `console.warn(JSON.stringify({ event: "rectification_exhaustion_collect", case_id, can_adopt, ceiling: {acceptance, selection, propose}, training_gate: {count, domains, open}, stop_class, ranked_count, probe_key, budget: {rounds, answers, plateau}, next_domain, next_source }))`
- 验收:单测断言字段齐全且不含 evidence summary / 年份。
**A2 穷尽即交付。**`exhaustionSpokenCollectFollowup`:删 `other` 兜底,返回 `null` 时由调用方分流——
- `engineCeiling.acceptanceAllowed && trainingGate.open` → 走 `adopt_representative` 交付(复用 `adoptHostNarration` / `deliveryAdoptNarration``stopReason` 用新增的 `"probe_pool_exhausted"`,文案:"能分开候选的问题已经问完,先按现有材料给你结果。")。`decideRectification` 需要一条新的 `completeWithRange(..., "exhausted")` 入口:`probe===null && !datedMethodCollectOpen && methodCoverageAll``capability.canAdopt` 时不再走 `offerRangeWithoutAdopt`,而是 `finish("adopt_representative")`
- 否则 → `hostNarration` = 范围句 + 门槛翻译句(`acceptance_reasons` → 用户文案映射表放 `user-copy.ts``insufficient_events``insufficient_domain_diversity``low_date_quality``no_candidates`TS 侧 `keep_collecting``insufficient_dated_events` / `insufficient_domains` 同表);不持久化任何焦点;`ensureNonTerminalTurnExit` 的"满足"条件加上"本轮已写出门槛翻译句"(用 turn 上的 `question=null && terminal_note` 标记,具体载体由执行方定,进度记录写明)。
- `route.ts` 无焦点分支的 `isNonConvergingRangeOffer` 同步改为上面两种出口。
- 验收:形状回归见 A4`grep -rn "USER_COLLECT_QUESTION.other\|collectQuestionRetryByDomain.other" frontend/src` 只剩开场路径;`listUserVisibleCopy``agent-voice-copy-contract` 测试更新。
**A3 停止入口。** `rectification-agentic-chat.tsx``currentQuestion.kind === "collect_spoken"` 时在输入框上方渲染与点选卡同款的 `CHOICE_STOP_LABEL` 按钮(复用 `submitStop` 路径,`STOP_ACTION`);`RectificationReadonlyRange` 改为按钮,同一动作。`route.ts``ask_about_result` 在点选与口述两个焦点分支都映射到 `STOP_ACTION`(口述分支现在只处理 decline,要补 stop 与 ask_about_result)。`frontend/DESIGN.md` 记录新按钮位置;文案对照 `frontend/docs/VOICE.md`
- 验收:`rectification-v9-entry-routing` 或新测试覆盖 `ask_about_result` → paused;组件测试覆盖口述态出现停止按钮。
**A4 事故形状回归(核心)。** 新文件 `frontend/tests/rectification-exhaustion-exit-20260906.test.ts`dossier 形状:7 条 confirmed 证据(education×2 month、relationship month+day、finance month、relocation month、health_pressure range)、family declined、occupation_note、`answered_probes` 6 条(2 条 varga_style、4 条 existence,含同域同年一月一年的两条)、`discriminating_event_probes` 全部已问或 `no_split_among_active`、receipt `acceptance_allowed/selection_allowed/propose_allowed=true``confirmation_allowed=false`。断言:
1. `decideFromDossier` 不是 `collect_evidence``persistNextInterviewIfIdle` 不写 `collect:other:*` 焦点;hostNarration 含停止原因句与范围句。
2. 同形状但 receipt `acceptance_allowed=false, acceptance_reasons=["insufficient_events"]` → hostNarration 含门槛翻译句,不写焦点,`ensureNonTerminalTurnExit` 不抛。
3. 同形状但 `candidateScoresFromDossier` 返回空(模拟投影不一致)→ 同 2,且日志行 `ranked_count=0`
### B · 题型(BUG-559
**B1 同域同年去重(两侧)。** Python`_discriminating_event_probe_lists` 接收 `asked_probe_keys`(由 API 请求体新增可选字段 `asked_probe_keys: string[]` 透传;`normalize_rectification_request` 允许该字段,不改 `jyotish_api_server.py` 主体),`_existence_blocked_years(domain, known_years asked_years)``_try_activation_probe` 同样受阻。TS`remainingConflictProbes` / `renderableEventProbe` / `renderableContrastProbe``asked` 判断改为按 `domain.year` 前缀匹配(`career.2023.05.dasha_boundary` 覆盖 `career.2023*`),并按 `EXISTENCE_NEARBY_YEARS` 扩到相邻年。
- 验收:`tests/test_rectification_event_probes.py` 新增:已问 `career.Y.05.dasha_boundary` 后不再产出 `career.Y.*``career.Y±1.*`TS `rectification-choice-card.test.ts` 新增同样断言。
**B2 "某年某月发生了什么"合并卡。**`choice_kind: "boundary_pair"`
- Python`_partition_ranked_probes` 之后,对同 (year, month) 且领域不同的两条 existence 探针(取 gain 最高的两条)生成一条合并探针:`semantic_key = boundary.<Y>.<MM>``component_probes = [key1, key2]``style_options`A `<dom1 event_family 短句>`(→ p1 yes, p2 no)、B `<dom2 短句>`(→ p1 no, p2 yes)、C `两件都有`(→ 都 yes)、D `都没有`(→ 都 no);`expected_outcomes` 按四个组合合并 supports/conflicts`information_gain` 取两条的联合分组熵(`_group_entropy` 对四组)。原两条单领域探针从 public 列表移除,进 `dropped_probes(reason="merged_into_boundary_pair")`。选项标签长度守 `LABEL_MIN/MAX`,用 `DOMAIN_CATALOG` 里的 `event_family` 缩到 ≤ 12 字。
- 契约:`contracts/probe-question-v1.json``boundary_pair``answer_class` 映射(A=`pair_first`、B=`pair_second`、C=`pair_both`、D=`pair_none`"这题跳过" = 两条 unsure);`probe_question_contract.py``probe-question-contract.ts` 同步,契约测试比对字节。
- TS`applyRectificationChoice``boundary_pair` 写两条 `answered_probes`(各自 `semantic_key``answer_class` yes/no),`buildInferenceState` 不改;`askedKeys` 同时记 `boundary.<Y>.<MM>` 与两条分量键(配合 B1)。
- 题干:`_agent_brief` 新分支:"时间范围锁定 {Y 年 M 月前后};请问用户那段时间身上发生了什么变化,选项由服务端给出;若账本里 ±2 个月内有其他领域事件,题干先提那件事再问。" `method-followup.ts``user_prompt_hint` 里把邻近账本事件(只给领域与年月,不给 summary)拼进去。
- 验收:Python 测试——两条同月探针合并成一条、四个 outcome 的 supports 互不重叠且并集覆盖两条分量、原探针进 dropped;TS 测试——一张 `boundary_pair` 卡答 A 后 `answered_probes` 两条、posterior 变化等于分别答 p1 yes + p2 no;契约测试;`agent-voice-copy-contract` 更新。
- 若 B2 做不完(见 §6),至少 B1 + 题干锚点(邻近账本事件写进 `user_prompt_hint`)要落地。
**B3 varga_style 卡要锚点。** `rankRenderableDiscriminators` / `firstRenderableYearlessFollowup``choice_kind === "varga_style"` 且同域账本无 confirmed 带年月事件 → 进 `dropped_probes(reason="unanchored_varga_style")`,不渲染;有事件时 `user_prompt_hint` 带该事件年月,`selection_score` 乘 0.8 排在带年月卡之后。
- 验收:`rectification-varga-style-*.test.ts` 新增:无同域事件不出卡;有事件出卡且 hint 含年月。
### C · 收敛(BUG-560
**C1 prior 换尺度。** `decision_policy.py::_relative_support(scores, floor)``weight = max(score floor, 0)``floor` = 全分钟网格(`built["candidate_times"]` 全部行,不只是公开代表)的最低分;全零时保持均分。可选 softmax 版本 `exp((score max)/T)`,两者都实现,由 C2 选定默认并记录在 `POLICY_VERSION`bump 到 `rectification-candidate-policy-v4``ALGORITHM_VERSION` 同步 bump`test_algorithm_and_policy_versions_change_with_proximity_semantics` 模式照抄)。`rectification_report``candidate_summary` 里所有引用 relative_support 的文案不变。
- 验收:`tests/test_rectification_diagnostics_clusters.py` 或新测试——同一 rows 下新支持度的 top-2 领先 ≥ 旧值;均分回退用例。
**C2 校准(合入门)。** 新脚本 `scripts/rectification_prior_calibration.py`(只读 `references/real_case_calibration/minute_rectification_development_v1.json` 调参、`minute_rectification_holdout_v3.json` 20 例验证;`excluded_from_tuning=true` 的例子只进验证):每例以真实分钟 ± `candidate_radius_minutes` 为窗口跑 `score_candidates`,分别按旧归一、`scoremin`、softmax(T∈{0.25,0.5,1,2}) 计算 `unionStillValidRange`(lead 8)宽度与是否覆盖真实分钟。输出表:方案 × {holdout 覆盖数/20, 中位宽度, 均值宽度}。
- 通过标准:所选方案 holdout 覆盖数 ≥ 旧方案覆盖数 − 1,且中位宽度 < 旧方案;否则保留旧归一,C1 只留代码不启用,进度记录写明。
- 表格进 `PROGRESS-*.md` §C2,**产品负责人确认后**才合 C。
**C3 存量。** `POLICY_VERSION` 变化会让 `candidateSnapshotSource.scoringPolicyVersion` 失配 → `snapshotCurrent=false` → 已有 Case 下一轮自动重算(`TASK-rectification-tails-20260901.md` 已铺路);执行方确认这条路径仍通,并在 `docs/testing/` 清单里写"旧 Case 打开后第一轮会重算一次"。
**C4 显示。** `isRecommendedRectificationCandidate` 沿用 lead 8,不改;`RectificationCandidateCards` 的"相对支持度 N"不改文案。进度记录附一张 4 件事 / 7 件事虚构样例的前后支持度对比表(用 §2.3 的虚构资料即可)。
### D · 记录
- `docs/BUG_HISTORY.md`BUG-558(出口)、BUG-559(题型与去重)、BUG-560prior 压平);BUG-560 关联 BUG-463 与 `TASK-rectification-provisional-adopt-20260901.md` §2。
- `CHANGELOG.md`:三条用户可感知变化;Skill 版本不 bump(本单不改 SKILL.md;文案单另开)。
- `docs/tasks/PROGRESS-rectification-convergence-exit-20260906.md`:A/B/C 各一节,含测试数字、C2 校准表、偏离。
- `docs/testing/rectification-convergence-exit-20260906.md`:真实环境清单——(1) 走完七个领域后出现候选卡或门槛翻译句,不再出现"也可以再说一件事";(2) 口述采集态有"先这样"按钮,点了出范围;(3) 同一年月不再被问两次;(4) 合并卡题干带年月并提到邻近事件;(5) 旧 Case 首轮重算一次。
- `docs/tasks/README.md` 状态板加一行。
## 6. 让步顺序
1. A1 + A2 + A4 不可拆,是本单的底线;A3 可以后置到同分支第二次提交。
2. B1 + B3 必做;B2 若一天内做不完,改为"B1 去重 + 题干锚点",并在进度记录写明 B2 未做与原因,本单状态保持"部分完成",不得标完成。
3. C1 + C2 必做;C2 不过门就不启用 C1,按 §5.C2 写明。
4. D 不可省。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-convergence-exit-20260906 .worktrees/rectification-convergence-exit-20260906 origin/staging
cd .worktrees/rectification-convergence-exit-20260906
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
ln -s /workspace/Jyotisha/.venv .venv
# 基线数字(写进进度记录)
cd frontend && npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts 2>&1 | grep -E "^# (tests|pass|fail)"; cd ..
.venv/bin/python -m pytest tests/test_rectification_event_probes.py tests/test_rectification_engine_convergence.py tests/test_rectification_diagnostics_clusters.py tests/test_active_rectification_api.py -q
grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1
```
收尾:同一组命令 fail=0`./node_modules/.bin/tsc --noEmit``npm run lint``npm run build``/` 仍 Static、首屏 gzip ±2%`.venv/bin/python scripts/run_quality_gate.py --profile quick`
## 附录 · 复现脚本(虚构资料,可直接跑)
```python
# .venv/bin/python 下运行;出生资料为虚构值,仅复现机制
import uuid, sys, os
sys.path.insert(0, "."); sys.path.insert(0, "scripts")
from scripts.rectification.contracts import normalize_rectification_request
from scripts.rectification.api_service import score_candidates
EV = [("education","education_start","2016-09-01","2016-09-30","month"),
("education","education_completion","2020-06-01","2020-06-30","month"),
("relationship","relationship_start","2024-05-01","2024-05-31","month"),
("relationship","relationship_end","2024-08-08","2024-08-08","day"),
("finance","finance_loss","2026-01-01","2026-01-31","month"),
("relocation","relocation","2023-07-01","2023-07-31","month"),
("health_pressure","pressure_period","2026-01-01","2026-08-31","range")]
req = normalize_rectification_request({"birth_date":"1998-03-15","start_time":"04:30","end_time":"05:30",
"lat":39.9042,"lon":116.4074,"tz":8.0,"events":[{"id":str(uuid.uuid4()),"domain":d,"event_kind":k,
"date_start":s,"date_end":e,"precision":p,"summary":k} for d,k,s,e,p in EV]})
out = score_candidates(req)
print([(c["time"], c["relative_support"]) for c in out["candidate_decisions"]])
print([(p["semantic_key"], p["information_gain"]) for p in out["decision_receipt"]["discriminating_event_probes"]])
```
## 验收(Claude2026-09-06,基线 `origin/staging` @ `b938c76a`
实现 `150d7ef1`A/ `3a9ae736`B/ `62afa521`(C+D)已由执行方直接合入 staging。独立复跑:
| 门 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | 0 error / 82 warning(新增测试文件带 1 条既有类型的 warning) |
| 非 DB `tests/rectification-*.test.ts` + `agent-voice-copy-contract` | 905 pass / 0 fail |
| `scripts/rectification_prior_calibration.py` | 与进度记录同数:holdout proportional 17/20 · 中位宽 21offset 12/20 · 10softmax 0.25/0.5/1/2 = 10/11/12/16 · 6/9/14.5/21;无方案过门 |
| Python 定向(event_probes / engine_convergence / relative_support / v5_services / diagnostics_clusters / active_rectification_api | 1 失败:`test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow`,在基线 `e2f4b55c` 上同样失败(已复跑),非本轮引入 |
逐条结论:
| 项 | 结论 |
| --- | --- |
| A1 日志 | 通过。字段齐全,无 summary / evidence`probe_key` 含探针年份(大运边界年,非出生资料),可接受 |
| A2 穷尽即交付 | **未通过(P1**:门槛翻译句路径每个回合写 2 条相同助手消息(scratch 计数 2;evidence 动作回合为 3;点选路径一条独立 + 一条拼在「已记录你的选择」后)。见修复单 BUG-565 |
| A2 决策层 | 通过。`keep_collecting` 仍采集;`probe_pool_exhausted` 停止原因进 `finish` |
| A3 口述停止 + `ask_about_result` | 通过(服务端 STOP 在采集焦点上不抛,`answerClass=null` 分支放行)。P2:范围小字改成按钮但文案仍是状态句,与新按钮重复,建议还原为状态句 |
| A4 形状回归 | 通过,三例齐 |
| B1 同域同年去重 | 通过,两侧生效,`asked_probe_keys` 不进指纹 |
| B2 合并卡 | 未做,按让步顺序允许,状态「部分完成」正确 |
| B3 varga 锚点 | 通过 |
| C1/C2 | 通过(默认未启用;校准数字复现一致)。**校准门本身有缺陷**:旧方案"覆盖 17/20"是因为它根本不收窄(中位宽 21 = 整个 21 分钟窗口),拿它当基线任何收窄方案都过不了。真正的发现是:把先验拉尖后,真实分钟落在领先集合里的只有 12/20(宽 10),接近随机(10/21≈48%)。引擎原始分在分钟级的区分力近乎没有,这是产品级结论,见修复单 §2 |
| C3 | 通过(未 bump 版本,旧 Case 不重算,清单已按此写) |
| D | 通过;BUG-560 标 blocked 正确 |
| 新问题 | P2`persistNextInterviewIfIdle` 新增的「无剩余采集且不可采用 → 穷尽」分支排在问题持久化之前,引擎采用门关闭但仍有可渲染区分卡 / holdout 题时会把题吞掉 |