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

30 KiB
Raw Blame History

TASK · 生时校正:采集完没有出口、反推题形态不对、候选收不敛(2026-09-06)

  • 基线:origin/staging @ e2d6f203(代码头 e2f4b55cstaging 已部署同一 SHA,事故就是这版跑出来的)
  • 分支:codex/rectification-convergence-exit-20260906
  • 执行方:coding agent;验收:Claude
  • 涉及文件:
    • TSfrontend/src/lib/rectification-agentic/v9/answer-choice.tsmethod-followup.tsdecision-from-dossier.tsprobe-question-contract.tscore/build-state.tscore/candidate-separation.ts(只读)、frontend/src/app/api/rectification/agent/route.tsfrontend/src/components/rectification-agentic-chat.tsxfrontend/src/lib/rectification-agentic/user-copy.ts
    • Pythonscripts/rectification/decision_policy.pyscripts/rectification/event_probes.pyscripts/rectification/probe_question_contract.pycontracts/probe-question-v1.json
    • 不改 scripts/jyotish_api_server.py(增长冻结),不改 frontend/src/app/page.tsx
  • BUG 编号起点:BUG-558BUG-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::persistNextInterviewIfIdleisNonConvergingRangeOffer(decision)canOfferRange && !canAdopt && nextAction === "offer_provisional_range")时调用 persistExhaustionCollectensureNonTerminalTurnExit 在"没有问题也没有采用载体"时同样调用它;route.ts 无焦点分支也走同一条。
  • persistExhaustionCollectmethod-followup.ts::exhaustionSpokenCollectFollowup:先 nextDatedCollectFollowup(七个领域都问过/拒过就为空),再职业,最后 无条件落到 otherCollectFollowup,即第 14 轮那句话。它的 domain="other" 不在 REMAINING_EVIDENCE_COLLECT_DOMAINS,所以既不算"剩余采集"也不挡出牌,但它作为 collect:other:* 焦点被持久化后,下一轮再答一件事又回到同一处——没有终止条件
  • 出口所依赖的 canAdopt=false 从哪来,服务端没有留下任何日志(persistExhaustionCollect 不记录 decision 的输入)。可能来源有四个:引擎 acceptance_allowed=falsedecision_policy.py 的 event_quality / domain_diversity / date_quality 门)、trainingScoreableGate 因证据 status 非 confirmed 而关闭、candidateScoresFromDossier 在 inference 存在但投影不一致时返回空数组(ranked.length===0keep_collecting)、engineCapabilityCeilingFromReceipt 因 receipt 字段自相矛盾而返回全关。本单不猜是哪一个,先补日志与形状回归,再把"不管哪一个都不能落到 other"钉死。
  • 用户能看到的唯一状态是 RectificationReadonlyRangesessionOutcome ∈ {collect_evidence, discriminate_candidates} 时渲染),它不可点。口述采集态没有"先这样,先看当前范围"按钮(CHOICE_STOP_LABEL 只挂在点选卡上)。turn-intent-classifier.ts 定义了 ask_about_resultroute.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 侧 askedKeyssemantic_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::buildInferenceStaterelative_supportprior_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.otherotherCollectFollowup 只保留给开场(!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(→ pausedprovisional_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.Ycareer.Y.03career.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_LEADDEFAULT_MAX_DISCRIMINATION_ROUNDSEFFECTIVE_ANSWER_SAFETY_CAPmaxPlateauRoundsminUncertaintyAnswers)。
  • 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 不增长。Pythonrun_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 / deliveryAdoptNarrationstopReason 用新增的 "probe_pool_exhausted",文案:"能分开候选的问题已经问完,先按现有材料给你结果。")。decideRectification 需要一条新的 completeWithRange(..., "exhausted") 入口:probe===null && !datedMethodCollectOpen && methodCoverageAllcapability.canAdopt 时不再走 offerRangeWithoutAdopt,而是 finish("adopt_representative")
  • 否则 → hostNarration = 范围句 + 门槛翻译句(acceptance_reasons → 用户文案映射表放 user-copy.tsinsufficient_eventsinsufficient_domain_diversitylow_date_qualityno_candidatesTS 侧 keep_collectinginsufficient_dated_events / insufficient_domains 同表);不持久化任何焦点;ensureNonTerminalTurnExit 的"满足"条件加上"本轮已写出门槛翻译句"(用 turn 上的 question=null && terminal_note 标记,具体载体由执行方定,进度记录写明)。
  • route.ts 无焦点分支的 isNonConvergingRangeOffer 同步改为上面两种出口。
  • 验收:形状回归见 A4grep -rn "USER_COLLECT_QUESTION.other\|collectQuestionRetryByDomain.other" frontend/src 只剩开场路径;listUserVisibleCopyagent-voice-copy-contract 测试更新。

A3 停止入口。 rectification-agentic-chat.tsxcurrentQuestion.kind === "collect_spoken" 时在输入框上方渲染与点选卡同款的 CHOICE_STOP_LABEL 按钮(复用 submitStop 路径,STOP_ACTION);RectificationReadonlyRange 改为按钮,同一动作。route.tsask_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.tsdossier 形状: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=trueconfirmation_allowed=false。断言:

  1. decideFromDossier 不是 collect_evidencepersistNextInterviewIfIdle 不写 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 同样受阻。TSremainingConflictProbes / renderableEventProbe / renderableContrastProbeasked 判断改为按 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_optionsA <dom1 event_family 短句>(→ p1 yes, p2 no)、B <dom2 短句>(→ p1 no, p2 yes)、C 两件都有(→ 都 yes)、D 都没有(→ 都 no);expected_outcomes 按四个组合合并 supports/conflictsinformation_gain 取两条的联合分组熵(_group_entropy 对四组)。原两条单领域探针从 public 列表移除,进 dropped_probes(reason="merged_into_boundary_pair")。选项标签长度守 LABEL_MIN/MAX,用 DOMAIN_CATALOG 里的 event_family 缩到 ≤ 12 字。
  • 契约:contracts/probe-question-v1.jsonboundary_pairanswer_class 映射(A=pair_first、B=pair_second、C=pair_both、D=pair_none"这题跳过" = 两条 unsure);probe_question_contract.pyprobe-question-contract.ts 同步,契约测试比对字节。
  • TSapplyRectificationChoiceboundary_pair 写两条 answered_probes(各自 semantic_keyanswer_class yes/no),buildInferenceState 不改;askedKeys 同时记 boundary.<Y>.<MM> 与两条分量键(配合 B1)。
  • 题干:_agent_brief 新分支:"时间范围锁定 {Y 年 M 月前后};请问用户那段时间身上发生了什么变化,选项由服务端给出;若账本里 ±2 个月内有其他领域事件,题干先提那件事再问。" method-followup.tsuser_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 / firstRenderableYearlessFollowupchoice_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_VERSIONbump 到 rectification-candidate-policy-v4ALGORITHM_VERSION 同步 bumptest_algorithm_and_policy_versions_change_with_proximity_semantics 模式照抄)。rectification_reportcandidate_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.mdBUG-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. 开工前置命令

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 --noEmitnpm run lintnpm run build/ 仍 Static、首屏 gzip ±2%.venv/bin/python scripts/run_quality_gate.py --profile quick

附录 · 复现脚本(虚构资料,可直接跑)

# .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

实现 150d7ef1A/ 3a9ae736B/ 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 / evidenceprobe_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 正确
新问题 P2persistNextInterviewIfIdle 新增的「无剩余采集且不可采用 → 穷尽」分支排在问题持久化之前,引擎采用门关闭但仍有可渲染区分卡 / holdout 题时会把题吞掉