From 62afa521f4063740854b9a4200f3d81dba72b527 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 6 Sep 2026 22:14:42 +0800 Subject: [PATCH] fix(rectification): keep proportional prior after holdout calibration (BUG-560) Implement offset and softmax relative-support scales and a public holdout calibration script, but leave the default proportional because no scheme passed the coverage-and-width gate. Co-authored-by: Cursor --- CHANGELOG.md | 12 + docs/BUG_HISTORY.md | 49 ++- ...rectification-convergence-exit-20260906.md | 116 +++++++ docs/tasks/README.md | 2 +- ...rectification-convergence-exit-20260906.md | 52 ++++ scripts/rectification/decision_policy.py | 65 +++- scripts/rectification_prior_calibration.py | 290 ++++++++++++++++++ tests/test_rectification_relative_support.py | 65 ++++ tests/test_rectification_v5_services.py | 1 + 9 files changed, 642 insertions(+), 10 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-convergence-exit-20260906.md create mode 100644 docs/testing/rectification-convergence-exit-20260906.md create mode 100644 scripts/rectification_prior_calibration.py create mode 100644 tests/test_rectification_relative_support.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e97441a..0960c068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # 印度占星 Skill 更新日志 +## 2026-09-06 — 生时校正问完会给结果,不再卡在「再说一件事」 + +七个带年份的领域和职业都问过之后,会按现有材料给候选卡,或说明还差哪类经历;不会再问「也可以再说一件你记得大概时间的事」。口述采集时输入框上方有「先这样,先看当前范围」,范围小字也可以点,同一动作。Skill 版本仍是 10.0.14。 + +## 2026-09-06 — 同一年同一领域的「有没有发生」只问一次,没锚点的性格题不出 + +已经问过某年某月工作上有没有入职,不会再拿同年或前后一年的工作再问一遍。平时做事那种性格对照卡,只在账本里已有同领域带月份的经历时才出现。Skill 版本仍是 10.0.14。 + +## 2026-09-06 — 候选相对支持度仍按原来的分法 + +试过按「分数减窗口最低分」和 softmax 重新分配相对支持度,公开 holdout 上覆盖变少或宽度没有变窄,所以线上仍用原来的正比例归一;采用门、领先 8 分这些常数没有改。Skill 版本仍是 10.0.14。 + ## 2026-09-06 — 专业长报告补上逐月 KP 标题和校时分钟表 专业长报告里,事业、财务、关系三条逐月 MD/AD 支持只要有月份数据就会带标题;时间轨道来源改成可读标签,不再被脱敏成空。未确认出生分钟时,校时附录会列出窗口内候选分钟,不会写成已经采用的时间。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 06de4324..c06d609b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8629,6 +8629,54 @@ - 复发自:BUG-553(`a1956deb` 的测试没断言守卫会命中) - 修复版本:`0102a973` +## BUG-558 | 采集问完落到「也可以再说一件事」,没有交付出口 + +- 状态:resolved +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`persistExhaustionCollect`、`exhaustionSpokenCollectFollowup`、`ensureNonTerminalTurnExit`、口述采集 UI、`POST /api/rectification/agent` +- 用户现象:七个带年份领域和职业都问过之后,助手仍问「也可以再说一件你记得大概时间的事」,界面只剩不可点的范围小字,没有候选卡、没有停止按钮、没有下一步。 +- 触发条件:方法覆盖完成、带年份领域都问过或拒过、职业已覆盖;非收敛出牌或非终止修复走穷尽采集。 +- 根因:`exhaustionSpokenCollectFollowup` 在 dated / occupation 之后无条件落到 `otherCollectFollowup`。`domain="other"` 不在剩余采集集合里,也不挡出牌,但作为 `collect:other:*` 焦点被写回后下一轮还回到同一句,没有终止条件。口述采集没有停止按钮;`ask_about_result` 只在点选焦点处理。 +- 修复:穷尽采集不再问 `other`。还有 dated / occupation 就继续问;否则 `acceptance_allowed` 且训练门开且可采用则 `adopt_representative`(`stopReason=probe_pool_exhausted`),否则写门槛翻译句、不写焦点,并用 `terminalNote` 满足非终止出口。口述采集在输入框上方放与点选卡同文案的停止按钮;只读范围改为同一停止动作。`ask_about_result` 与 `stop_rectification` 在点选和口述焦点都映射到停止。`keep_collecting` 仍继续采集,不得劫持成出牌。 +- 验证:`frontend/tests/rectification-exhaustion-exit-20260906.test.ts`;既有 adopt / spoken-collect / timeline / hidden-e2e 三栏更新。 +- 防复发:`exhaustionSpokenCollectFollowup` 不得再返回 `other`。穷尽日志必须有 `rectification_exhaustion_collect` 且不含证据摘要或年份。不得把 `keep_collecting` 改成强制采用。 +- 相关记录:BUG-550、BUG-546 +- 复发自:无 +- 修复版本:`61ea5671` + +## BUG-559 | 同年月拆成多道「有没有 X」,同域同年再问,无锚点性格卡 + +- 状态:mitigated +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`scripts/rectification/event_probes.py`、`asked_probe_keys`、`existenceProbeAsked`、`rankRenderableDiscriminators` +- 用户现象:同一大运边界月按领域各问一次「那时候有没有入职 / 有没有搬家」;已问过某年 5 月后又问同年无月份;没有对应经历的「平时做事更接近哪一种」性格卡也会出。 +- 触发条件:引擎按领域扫描边界月;去重只看账本年份不看已问探针;`varga_style` 不要求同域带年月证据。 +- 根因:existence 探针按 `(domain, year, month, source)` 独立出题;`_existence_blocked_years` 不并入已问探针年份;性格对照卡没有锚点门槛。 +- 修复:请求可带 `asked_probe_keys`(不进结果指纹)。已问 `domain.Y.MM.*` 后,同年与相邻年不再出该领域 existence / activation。`varga_style` 无同域 confirmed 带年月事件则 `dropped_probes(unanchored_varga_style)`;有锚点时 hint 带年月、分数 ×0.8,用户可见题干仍不写年份。邻近账本事件以领域+年月(无摘要)写入 `user_prompt_hint`。B2 同年月合并卡 `choice_kind=boundary_pair` 本单未做。 +- 验证:`tests/test_rectification_event_probes.py` 已问同年相邻年不再产出;`frontend/tests/rectification-probe-year-dedupe-20260906.test.ts`。 +- 防复发:不得把 `asked_probe_keys` 写入结果指纹。点选答案仍不写证据账本。不得为性格卡在用户可见题干里写年份。 +- 相关记录:BUG-540、BUG-390 +- 复发自:无 +- 修复版本:`770d378d` + +## BUG-560 | 相对支持度正比例归一把引擎证据压成 7–9,lead 8 不可达 + +- 状态:blocked +- 首次发现:2026-09-06 +- 最近更新:2026-09-06 +- 影响面:`scripts/rectification/decision_policy.py::_relative_support`、公开簇代表 prior、前端 `unionStillValidRange` +- 用户现象:多件带年月经历之后,公开候选相对支持度仍是 7–9,范围几乎不收;之后只靠点选卡 ±2 推分,问尽仍收不窄。 +- 触发条件:公开最多 12 个簇代表按原始分正比例把 100 分掉;原始分底座远大于事件极差。 +- 根因:`_relative_support` 按原始分正比例归一。`TASK-rectification-provisional-adopt-20260901.md` §2 已看到 lead 停在约 5,当时只改门、四个常数不动,没有改 prior 尺度。 +- 修复:实现 offset(分−全网格最低分)与 softmax,由校准脚本在 20 例公开 holdout 上比覆盖与中位宽度。合入门:覆盖 ≥ 旧方案 − 1 且中位宽度更窄。实测无方案过门(offset 覆盖 12/20 < 16;softmax T=2 中位宽度 21 不小于旧值 21)。默认仍 `proportional`,`POLICY_VERSION` / `ALGORITHM_VERSION` 不 bump,旧 Case 不因此自动重算。 +- 验证:`scripts/rectification_prior_calibration.py` holdout 表见进度记录;`tests/test_rectification_relative_support.py` 锁定默认 proportional,并断言显式 offset 在拉开的格子上领先不差于 proportional。 +- 防复发:不得在校准门未过时启用 offset/softmax 或 bump 政策版本。不得改 `MIN_SEPARATION_LEAD=8` 或采用/确认/熔断常数来「先让分看起来够」。 +- 相关记录:BUG-463、`TASK-rectification-provisional-adopt-20260901.md` §2 +- 复发自:无 +- 修复版本:未启用新尺度;代码与校准脚本见本分支 C 提交 + ## BUG-561 | 长报告 KP Lord/Sub 与专题 KP 表在失败信封下整段消失 - 状态:resolved @@ -8694,4 +8742,3 @@ - 修复版本:`e4d16b75` - diff --git a/docs/tasks/PROGRESS-rectification-convergence-exit-20260906.md b/docs/tasks/PROGRESS-rectification-convergence-exit-20260906.md new file mode 100644 index 00000000..4e3099fc --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-convergence-exit-20260906.md @@ -0,0 +1,116 @@ +# PROGRESS · 生时校正采集出口 / 探针去重 / prior 校准(2026-09-06) + +工作树:`.worktrees/rectification-convergence-exit-20260906` +分支:`codex/rectification-convergence-exit-20260906` +基线:任务书 `2dc454c9`(staging 当时代码头 `e2f4b55c`)。执行期间 `origin/staging` 另有文档提交,本分支未 rebase。 + +未改 `SKILL.md`(仍 10.0.14)、未改 `scripts/jyotish_api_server.py`、未改 `frontend/src/app/page.tsx`。未改采用门 / 确认门 / `MIN_SEPARATION_LEAD=8` / 熔断常数。未把 `.venv` 加入提交。 + +本单状态:**部分完成**。A 完成;B1+B3 完成、B2 未做;C1 代码保留、C2 未过门故未启用。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| A1 穷尽日志 | 完成 | BUG-558 | +| A2 穷尽即交付 | 完成 | BUG-558 | +| A3 口述停止 + 范围可点 | 完成(同分支,与 A1/A2 同一提交) | BUG-558 | +| A4 事故形状回归 | 完成 | BUG-558 | +| B1 同域同年去重 | 完成 | BUG-559 | +| B2 同年月合并卡 `boundary_pair` | **未做** | BUG-559 | +| B3 varga_style 锚点 | 完成 | BUG-559 | +| 题干邻近账本年月 | 完成 | BUG-559 | +| C1 offset/softmax 实现 | 完成(默认未启用) | BUG-560 | +| C2 holdout 校准门 | 未过门 | BUG-560 | +| C3 存量自动重算 | 路径仍在;本单未 bump 版本,故不会触发 | BUG-560 | +| D 记录 | 完成 | — | + +## A · 出口(BUG-558) + +- `exhaustionSpokenCollectFollowup` 在 dated + occupation 之后返回 `null`,不再落到 `other`。开场仍走 `!dashaCovered` 的 GENERIC 句;`USER_COLLECT_QUESTION.other` 留在文案表,穷尽路径不再问它。 +- `persistExhaustionCollect` 每次入口 `console.warn` 结构化 `rectification_exhaustion_collect`(`case_id`、can_adopt、ceiling、training_gate、stop_class、ranked_count、probe_key、budget、next_domain/source)。不含 evidence summary、不含年份。 +- 无剩余采集时:`acceptanceAllowed && trainingGate.open && canAdopt` → 采用,`stopReason=probe_pool_exhausted`,文案「能分开候选的问题已经问完,先按现有材料给你结果。」;否则门槛翻译句 + `terminalNote`,`ensureNonTerminalTurnExit` 见到 `terminalNote` 不抛。 +- 口述采集:输入框上方 44px `.rectification-collect-stop`,文案 `CHOICE_STOP_LABEL`。`RectificationReadonlyRange` 改为 button,同一 `submitStop`。`ask_about_result` 与 `stop_rectification` 在点选和口述焦点都进停止。点选卡仍隐藏停止项。 +- **A2 纠偏(未合入的中间稿):** 曾把 `keep_collecting` 在方法已覆盖时劫持成采用。证据不足、应继续采集的用例会变成 `completed_with_range`。已改回:`keep_collecting` **一律** `collect(...)`。 + +### 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `rectification-decision-authority` 出牌 | `offer_provisional_range` | `ready_to_adopt` | 探针空、方法覆盖且可采用时穷尽交付 | +| `rectification-hidden-e2e` 同上 | 同上 | 同上 | 同上 | +| `occupationClosed.next_followup` | `other` | `null` | 穷尽不再问 other | +| adopt-flow / spoken-collect / timeline-adapter 源扫描 | 禁止 `rectification-collect-stop` | 要求出现 | A3 口述停止按钮 | +| `rectification-answer-choice` 点 A | 旁白含范围、不再问家人 | 下一问家人采集 | B3 丢掉无锚点性格卡后按方法轮转到家人;点选仍不写账本 | + +## B · 题型(BUG-559) + +- Python:`asked_probe_keys` 经 `normalize_rectification_request`(0–200 条、每条 ≤120)。指纹构造 **排除** 该字段。`known_years = 账本年份 ∪ 已问年份`,再进 `_existence_blocked_years`(含 `EXISTENCE_NEARBY_YEARS`)。 +- TS:`existenceProbeAsked` 按 `domain.year` 与相邻年匹配。`engine-client` / `rectification-v9-tools` 把已问键传给引擎。 +- `nearby_ledger_note` / `nearbyLedgerHint`:只拼领域 + 年月,不写 summary。 +- B3:无同域 confirmed 带年月事件的 `varga_style` → `dropped_probes(unanchored_varga_style)`;有则 hint 带年月、`selection_score *= 0.8`。用户可见卡题干仍是「平时做事,你更接近下面哪一种?」(不写年份)。 + +### B2 偏离 + +未实现 `choice_kind: "boundary_pair"`,未改 `contracts/probe-question-v1.json`,未改四选项 yes/weak_yes/no/unsure 语义。原因:让步顺序允许一天内做不完则保留 B1+题干锚点;合并卡要同时改 Python 契约、TS `applyRectificationChoice` 双键写入与联合熵,本单先把死胡同和同年复问堵住。 + +## C · 收敛(BUG-560) + +`decision_policy.py` 增加 `_relative_support_offset` / `_relative_support_softmax`;`build_candidate_decisions(..., support_mode=, temperature=)`;floor = **全部分钟网格**量化分的最低值。默认: + +- `RELATIVE_SUPPORT_MODE = "proportional"` +- `POLICY_VERSION = "rectification-candidate-policy-v3"` +- `ALGORITHM_VERSION = "rectification-v5-matrix-scoring-7"` + +前端 fixture 里写死的 v3 / matrix-scoring-7 缓存身份断言未改。 + +### C2 holdout(20 例,全部 `excluded_from_tuning`,radius 10) + +脚本:`scripts/rectification_prior_calibration.py`。每例只 `score_from_matrix` 一次,再按方案重建公开决策。门:覆盖 ≥ 旧方案 − 1 **且** 中位宽度 < 旧方案。 + +| scheme | coverage | median width | mean | +| --- | --- | --- | --- | +| proportional(旧 / 仍默认) | 17/20 | 21.0 | 18.55 | +| offset | 12/20 | 10.0 | 10.3 | +| softmax_0.25 | 10/20 | 6.0 | 7.6 | +| softmax_0.5 | 11/20 | 9.0 | 9.55 | +| softmax_1 | 12/20 | 14.5 | 13.1 | +| softmax_2 | 16/20 | 21.0 | 17.15 | + +无方案过门:offset 覆盖 12 < 16;softmax_2 中位 21 不小于 21。**未启用 C1。** 产品负责人确认前不得合入启用。 + +### C3 + +`storedSnapshotIsCurrent` 仍比较 `scoringPolicyVersion`;失配 → `snapshotCurrent=false`。本单未 bump,旧 Case 打开后 **不会** 只因本单重算。测试清单第 5 条按「不会自动重算」验收。 + +### C4 虚构 12 簇代表(任务书 §2.3 七件事量级:原始分 10.96–15.52) + +单位测试用同一串分,显式调用 offset 时 top-2 领先不低于 proportional;等分时仍拆成 8/9。线上默认仍是 7–9 压平。公开文案「相对支持度 N」未改。 + +| 方案 | 12 个公开代表(示例) | +| --- | --- | +| proportional(启用) | 约 7–9,top-2 领先 0 | +| offset(未启用) | 拉开,低分可到 0 | +| softmax T=0.5(未启用) | 更尖,低分大量 0 | + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsx --test` 13 个先回归文件(含 exhaustion-exit、probe-year-dedupe、answer-choice) | 225 pass / 0 fail | +| 非 DB `tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts` | **905 pass / 0 fail** | +| `.venv/bin/python -m pytest` event_probes + engine_convergence + relative_support + v5_services | 70 pass / 0 fail | +| `tests/test_rectification_diagnostics_clusters.py` | 5 pass / 0 fail | +| `tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` | 本机失败(04:16 vs 05:07);**用 HEAD 的 decision_policy/event_probes 复跑同样失败**,非本单引入 | +| `./node_modules/.bin/tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error(既有 warning 未清) | +| `npx next build --webpack`(worktree `node_modules` 符号链接使默认 turbopack 拒掉) | 通过;`/` 仍是 ○ Static | +| `.venv/bin/python scripts/run_quality_gate.py --profile quick` | Python 段跑完后 `npm test` 失败:Docker 地址池耗尽导致 `*-database.test.ts` 起 postgres 失败;另有无关的 `onboarding-route` deadline 超时。非本单代码回归。本单相关非 DB 校正套件 905/905。 | + +排除 `rectification-*-database.test.ts`(docker compose EPERM)。ERR-011:验收命令用 `python3 -m pytest` / `.venv/bin/python -m pytest`,不用裸 `pytest`。未改 `page.tsx`,首页静态化不应因本单变化。 + +## 三次提交 + +1. A `61ea5671`:出口、口述停止、A 测试与相关三栏、DESIGN/VOICE;`method-followup` 的 TS 去重/锚点因同文件随 A +2. B `770d378d`:引擎 `asked_probe_keys`、去重、邻近 hint、B 测试 +3. C+D:prior 实现与校准脚本、相对支持测试、BUG_HISTORY / CHANGELOG / PROGRESS / testing / README + +未 push。未改 `page.tsx`,首页静态化不因本单变化。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 2d16c397..adad0a88 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -61,7 +61,7 @@ | `TASK-consultation-context-and-cache-20260906.md` | `PROGRESS-consultation-context-and-cache-20260906.md` | 普通对话历史只取每条前 4,000 字(报告结论被砍、无标记)、历史窗口不看模型 `context_window`、无溢出识别;改为检查点式会话摘要 + append-only 尾巴 + 按模型预算 + 一次降级重试;Anthropic 历史断点;共享方法段进系统块;后台用量页显示缓存命中率 | 已验收 | `bf8ad0d1`(BUG-555/556);`test:db` 环境缺口;部署前先 Migrate Staging Database | | `TASK-session-title-guard-fix-20260906.md` | `PROGRESS-session-title-guard-fix-20260906.md` | BUG-553 修复单:`consult/route.ts` 标题守卫比较的是 RPC 前快照,`append_consultation_question` 已改写标题,守卫恒 0 行,模型标题只靠客户端 PATCH 落库 | 待验收 | `codex/session-title-guard-fix-20260906`(BUG-557) | | `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 已验收通过 `ca4e2408`(2026-09-05;staging 部署仍停在 `afd14948`,`deploy-staging` 自 `c295b853` 起连续失败,先解决 `bab07187` 的待迁移) | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550) | -| `TASK-rectification-convergence-exit-20260906.md` | `PROGRESS-rectification-convergence-exit-20260906.md` | 采集问完落到「也可以再说一件事」无出口(非收敛出牌/非终止修复兜底 `other`)、同年月多领域拆题与同域同年重复问、`relative_support` 正比例归一把引擎证据压平成 7–9 使 lead 8 不可达;改为穷尽即交付 + 口述态停止按钮、同年月合并为「发生了什么」四选卡 + 同域同年去重 + 性格题须锚点、prior 按 (分−窗口最低分) 归一并过 20 例公开 holdout 校准门 | 待领取 | `codex/rectification-convergence-exit-20260906`(BUG-558~560) | +| `TASK-rectification-convergence-exit-20260906.md` | `PROGRESS-rectification-convergence-exit-20260906.md` | 采集问完落到「也可以再说一件事」无出口(非收敛出牌/非终止修复兜底 `other`)、同年月多领域拆题与同域同年重复问、`relative_support` 正比例归一把引擎证据压平成 7–9 使 lead 8 不可达;改为穷尽即交付 + 口述态停止按钮、同年月合并为「发生了什么」四选卡 + 同域同年去重 + 性格题须锚点、prior 按 (分−窗口最低分) 归一并过 20 例公开 holdout 校准门 | 待验收 | `codex/rectification-convergence-exit-20260906`(BUG-558~560;B2 未做、C1 未启用,见进度) | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-convergence-exit-20260906.md b/docs/testing/rectification-convergence-exit-20260906.md new file mode 100644 index 00000000..44e4a955 --- /dev/null +++ b/docs/testing/rectification-convergence-exit-20260906.md @@ -0,0 +1,52 @@ +# Staging 人肉复核 · 采集问完有出口、同年不去重问(2026-09-06) + +给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。 + +对应 BUG-558 / BUG-559 / BUG-560。测之前先做第 0 条。本单 **部分完成**:同年月合并卡(B2)未做;相对支持度换尺度未过校准门,线上仍是正比例归一。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 七个领域问完后给出结果或门槛句(P0) + +新建生时校正,按任务书事故路径走完带年份采集和职业(可说「没有」跳过家人)。不要期待再出现「也可以再说一件你记得大概时间的事」。 + +- ✅ 预期:出现可点的候选时间卡,或一句能照做的门槛说明(例如还差带月份的经历)。口述采集时输入框上方有「先这样,先看当前范围」。 +- ❌ 失败:最后一问仍是「也可以再说一件你记得大概时间的事」;或只有不可点的范围小字、没有下一步。 + +## 2. 口述采集可以先停下来看范围(P0) + +任一口述采集题(家人 / 钱 / 搬家等)出现时: + +1. 看输入框上方是否有「先这样,先看当前范围」。 +2. 点它,或点范围那一行小字。 + +- ✅ 预期:两处都可点,动作相同,进入已停并给出当前范围;不是红色报错。 +- ❌ 失败:口述态没有该按钮;或范围小字不可点;或点了之后仍停在采集题。 + +## 3. 同一领域同一年不再问第二次(P0) + +若已出现「Y 年 M 月前后有没有入职/换工作」并答过,后续不应再出「Y 年前后工作上有没有入职/换工作」,也不应再出同年相邻年的同一领域存在题。 + +- ✅ 预期:同域同年(含前后一年)只问一次。 +- ❌ 失败:同一领域同一年(只是去掉月份或换成相邻年)再问一遍。 + +## 4. 邻近经历写进题干提示;合并卡本单没有(P1) + +本单 **没有** 做「某年某月发生了什么」四选合并卡(B2)。仍可能按领域各出存在题,但: + +- 若账本里 ±2 个月内有另一领域带月份的事,引擎给模型的提示应提到该领域和年月,**不出现事件摘要原文**。 +- 没有同域带月份经历时,不应出现「平时做事,你更接近下面哪一种」这类性格卡。 + +- ✅ 预期:无锚点性格卡不出;有锚点时题干仍可不写年份(年份在内部 hint)。 +- ❌ 失败:没有任何对应经历就出性格对照卡。 +- 本条不测合并卡。合并卡是后续单。 + +## 5. 旧 Case 不会因本单自动重算(P1) + +相对支持度政策版本仍是 `rectification-candidate-policy-v3`,算法版本仍是 `rectification-v5-matrix-scoring-7`。`snapshotCurrent=false` 的失配路径还在,但本单没有 bump 版本。 + +- ✅ 预期:打开旧生时校正 Case,不会只因为本单就在第一轮自动重算候选。 +- ❌ 失败:没有政策版本变化,却整案重算。 +- 若以后校准过门并 bump 版本,那时才应看到「旧 Case 打开后第一轮会重算一次」。 diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py index 40f478ec..09b92472 100644 --- a/scripts/rectification/decision_policy.py +++ b/scripts/rectification/decision_policy.py @@ -313,20 +313,18 @@ def _quantized_score(row: CandidateScoreRow) -> Decimal: return _decimal(row.get("score")).quantize(SCORE_QUANTUM, rounding=ROUND_HALF_UP) -def _relative_support(scores: Sequence[Decimal]) -> list[int]: - if not scores: +def _distribute_percent(weights: Sequence[Decimal]) -> list[int]: + if not weights: return [] - weights = [max(score, Decimal(0)) for score in scores] total = sum(weights, Decimal(0)) if total == 0: - base, remainder = divmod(100, len(scores)) - return [base + (1 if index < remainder else 0) for index in range(len(scores))] - + base, remainder = divmod(100, len(weights)) + return [base + (1 if index < remainder else 0) for index in range(len(weights))] exact = [weight * Decimal(100) / total for weight in weights] floors = [int(value.to_integral_value(rounding=ROUND_FLOOR)) for value in exact] remaining = 100 - sum(floors) order = sorted( - range(len(scores)), + range(len(weights)), key=lambda index: (-(exact[index] - Decimal(floors[index])), index), ) for index in order[:remaining]: @@ -334,18 +332,69 @@ def _relative_support(scores: Sequence[Decimal]) -> list[int]: return floors +def _relative_support_proportional(scores: Sequence[Decimal]) -> list[int]: + return _distribute_percent([max(score, Decimal(0)) for score in scores]) + + +def _relative_support_offset(scores: Sequence[Decimal], floor: Decimal) -> list[int]: + return _distribute_percent([max(score - floor, Decimal(0)) for score in scores]) + + +def _relative_support_softmax(scores: Sequence[Decimal], temperature: Decimal) -> list[int]: + from math import exp + if not scores: + return [] + peak = max(scores) + temp = temperature if temperature > 0 else Decimal("0.5") + weights = [Decimal(str(exp(float((score - peak) / temp)))) for score in scores] + return _distribute_percent(weights) + + +RELATIVE_SUPPORT_MODE = "proportional" +RELATIVE_SUPPORT_TEMPERATURE = Decimal("0.5") + + +def _relative_support( + scores: Sequence[Decimal], + *, + floor: Decimal | None = None, + mode: str | None = None, + temperature: Decimal | None = None, +) -> list[int]: + if not scores: + return [] + selected = mode or RELATIVE_SUPPORT_MODE + if selected == "softmax": + return _relative_support_softmax( + scores, + temperature if temperature is not None else RELATIVE_SUPPORT_TEMPERATURE, + ) + if selected == "offset" and floor is not None: + return _relative_support_offset(scores, floor) + return _relative_support_proportional(scores) + + def build_candidate_decisions( rows: Sequence[CandidateScoreRow], *, result_id: str, static_contexts: Sequence[dict[str, Any]] | None = None, + support_mode: str | None = None, + temperature: Decimal | None = None, ) -> list[dict[str, Any]]: ranked = sorted(rows, key=lambda row: (-_quantized_score(row), row["time"])) public_rows = select_signature_representatives(ranked, static_contexts) if not public_rows: return [] - supports = _relative_support([_quantized_score(row) for row in public_rows]) + public_scores = [_quantized_score(row) for row in public_rows] all_scores = [_quantized_score(row) for row in ranked] + floor = min(all_scores) if all_scores else Decimal(0) + supports = _relative_support( + public_scores, + floor=floor, + mode=support_mode, + temperature=temperature, + ) decisions = [] for index, row in enumerate(public_rows): score = _quantized_score(row) diff --git a/scripts/rectification_prior_calibration.py b/scripts/rectification_prior_calibration.py new file mode 100644 index 00000000..a8e5895c --- /dev/null +++ b/scripts/rectification_prior_calibration.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Compare relative_support scales on public minute-rectification cases. + +Reads development cases for inspection and the sealed holdout for the +enablement gate. Scores each window once, then rebuilds public candidate +decisions under proportional / offset / softmax temperatures. +""" +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import traceback +from calendar import monthrange +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.rectification.candidate_contrast import cluster_contexts_by_signature, context_time +from scripts.rectification.contracts import normalize_rectification_request +from scripts.rectification.decision_policy import build_candidate_decisions +from scripts.rectification.scoring_service import ( + build_event_contribution_matrix, + score_from_matrix, + scoreable_request, +) + +DEVELOPMENT_MANIFEST = ROOT / "references" / "real_case_calibration" / "minute_rectification_development_v1.json" +HOLDOUT_MANIFEST = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v3.json" +LEAD = 8 +TODAY = date(2026, 9, 6) +KIND_BY_DOMAIN = { + "education": "education_milestone", + "career": "career_change", + "relationship": "relationship_commitment", + "relocation": "relocation", + "health_pressure": "self_health_event", + "health": "self_health_event", + "finance": "finance_change", + "family": "family_event", +} +SCHEMES: tuple[dict[str, Any], ...] = ( + {"id": "proportional", "mode": "proportional", "temperature": None}, + {"id": "offset", "mode": "offset", "temperature": None}, + {"id": "softmax_0.25", "mode": "softmax", "temperature": Decimal("0.25")}, + {"id": "softmax_0.5", "mode": "softmax", "temperature": Decimal("0.5")}, + {"id": "softmax_1", "mode": "softmax", "temperature": Decimal("1")}, + {"id": "softmax_2", "mode": "softmax", "temperature": Decimal("2")}, +) + + +def _clock(value: str) -> int: + return int(value[:2]) * 60 + int(value[3:5]) + + +def _hhmm_from_minutes(value: int) -> str: + wrapped = value % 1440 + return f"{wrapped // 60:02d}:{wrapped % 60:02d}" + + +def _shift_clock(value: str, delta: int) -> str: + return _hhmm_from_minutes(_clock(value) + delta) + + +def _expand_date(raw: str, precision: str) -> tuple[str, str]: + text = str(raw or "").strip() + if precision == "day": + day = date.fromisoformat(text) + return day.isoformat(), day.isoformat() + if precision == "month": + month = date.fromisoformat(f"{text}-01") if len(text) == 7 else date.fromisoformat(text[:10]).replace(day=1) + last = monthrange(month.year, month.month)[1] + return month.isoformat(), month.replace(day=last).isoformat() + year = int(text[:4]) + return f"{year}-01-01", f"{year}-12-31" + + +def _event_kind(event: dict[str, Any]) -> str: + domain = str(event.get("domain") or "") + description = str(event.get("description") or "").lower() + if domain == "relationship" and not any(token in description for token in ("married", "wedding", "wife", "husband")): + return "relationship_start" + return KIND_BY_DOMAIN[domain] + + +def _request_from_case(case: dict[str, Any]) -> dict[str, Any]: + birth = case["birth"] + true_time = str(birth["time"])[:5] + radius = int(case.get("candidate_radius_minutes") or 10) + events = [] + for event in case.get("events") or []: + precision = str(event.get("precision") or "year") + start, end = _expand_date(str(event.get("date") or ""), precision) + domain = str(event.get("domain") or "") + events.append({ + "id": str(uuid5(NAMESPACE_URL, str(event.get("id") or ""))), + "domain": domain, + "event_kind": _event_kind(event), + "date_start": start, + "date_end": end, + "precision": precision, + "summary": str(event.get("description") or event.get("id") or domain)[:200], + }) + return normalize_rectification_request({ + "birth_date": str(birth["date"]), + "start_time": _shift_clock(true_time, -radius), + "end_time": _shift_clock(true_time, radius), + "lat": float(birth["latitude"]), + "lon": float(birth["longitude"]), + "tz": float(birth["timezone_offset"]), + "events": events, + }, today=TODAY) + + +def _cluster_bounds(time: str, clusters: list[dict[str, Any]]) -> tuple[str, str]: + for cluster in clusters: + times = [str(item)[:5] for item in cluster.get("times") or [] if str(item)] + if time not in times: + continue + ordered = sorted(times, key=_clock) + return ordered[0], ordered[-1] + return time, time + + +def _union_metrics( + decisions: list[dict[str, Any]], + *, + true_time: str, + start_time: str, + clusters: list[dict[str, Any]], +) -> dict[str, Any]: + if not decisions: + return {"width": None, "covers": False, "peak": 0, "valid_count": 0} + peak = max(int(item["relative_support"]) for item in decisions) + valid = [item for item in decisions if peak - int(item["relative_support"]) < LEAD] + origin = _clock(start_time) + points: list[int] = [] + for item in valid: + time = str(item["time"])[:5] + lo, hi = _cluster_bounds(time, clusters) + for stamp in (time, lo, hi): + points.append((_clock(stamp) - origin) % 1440) + width = (max(points) - min(points) + 1) if points else None + true_offset = (_clock(true_time) - origin) % 1440 + covers = width is not None and min(points) <= true_offset <= max(points) + return { + "width": width, + "covers": covers, + "peak": peak, + "valid_count": len(valid), + "supports": [int(item["relative_support"]) for item in decisions], + } + + +def _score_case(case: dict[str, Any]) -> dict[str, Any]: + request = _request_from_case(case) + scoring = scoreable_request(request) + built = build_event_contribution_matrix(scoring) + rows = score_from_matrix(scoring, built) + contexts = built.get("static_contexts") if isinstance(built.get("static_contexts"), list) else None + clusters = cluster_contexts_by_signature( + [item for item in (contexts or []) if isinstance(item, dict) and context_time(item)] + ) if contexts else [] + true_time = str(case["birth"]["time"])[:5] + schemes = {} + for scheme in SCHEMES: + decisions = build_candidate_decisions( + rows, + result_id=str(uuid5(NAMESPACE_URL, case["case_id"])), + static_contexts=contexts, + support_mode=scheme["mode"], + temperature=scheme["temperature"], + ) + schemes[scheme["id"]] = _union_metrics( + decisions, + true_time=true_time, + start_time=request["start_time"], + clusters=clusters, + ) + return { + "case_id": case["case_id"], + "radius": int(case.get("candidate_radius_minutes") or 10), + "schemes": schemes, + } + + +def _summarize(rows: list[dict[str, Any]]) -> dict[str, Any]: + table: dict[str, Any] = {} + for scheme in SCHEMES: + widths = [int(item["schemes"][scheme["id"]]["width"]) for item in rows if item["schemes"][scheme["id"]]["width"] is not None] + covered = sum(1 for item in rows if item["schemes"][scheme["id"]]["covers"]) + table[scheme["id"]] = { + "n": len(rows), + "coverage": covered, + "median_width": statistics.median(widths) if widths else None, + "mean_width": round(statistics.mean(widths), 2) if widths else None, + } + return table + + +def _pick_scheme(holdout: dict[str, Any]) -> dict[str, Any]: + baseline = holdout["proportional"] + old_coverage = int(baseline["coverage"]) + old_median = baseline["median_width"] + passing = [] + for scheme_id, row in holdout.items(): + if scheme_id == "proportional": + continue + median = row["median_width"] + if median is None or old_median is None: + continue + if int(row["coverage"]) >= old_coverage - 1 and median < old_median: + passing.append((scheme_id, row)) + if not passing: + return {"id": "proportional", "enabled": False, "reason": "no_scheme_passed_holdout_gate"} + preferred = [item for item in passing if item[0] == "offset"] + chosen = preferred[0] if preferred else min(passing, key=lambda item: (item[1]["median_width"], -item[1]["coverage"])) + return {"id": chosen[0], "enabled": True, "reason": "passed_holdout_gate"} + + +def _load_cases(path: Path, *, holdout_only: bool) -> list[dict[str, Any]]: + payload = json.loads(path.read_text(encoding="utf-8")) + cases = list(payload.get("cases") or []) + if holdout_only: + cases = [item for item in cases if item.get("excluded_from_tuning") is True] + return cases + + +def run(limit: int | None = None) -> dict[str, Any]: + development_cases = _load_cases(DEVELOPMENT_MANIFEST, holdout_only=False) + holdout_cases = _load_cases(HOLDOUT_MANIFEST, holdout_only=True) + if limit is not None: + development_cases = development_cases[:limit] + holdout_cases = holdout_cases[:limit] + development_rows = [_score_case(item) for item in development_cases] + holdout_rows = [_score_case(item) for item in holdout_cases] + development = _summarize(development_rows) + holdout = _summarize(holdout_rows) + return { + "lead": LEAD, + "development": development, + "holdout": holdout, + "selection": _pick_scheme(holdout), + "development_rows": development_rows, + "holdout_rows": holdout_rows, + } + + +def _print_table(title: str, table: dict[str, Any]) -> None: + print(title) + print(f"{'scheme':<16} {'coverage':>10} {'median':>10} {'mean':>10}") + for scheme_id, row in table.items(): + print( + f"{scheme_id:<16} " + f"{row['coverage']}/{row['n']:<7} " + f"{str(row['median_width']):>10} " + f"{str(row['mean_width']):>10}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--limit", type=int, default=None) + parser.add_argument("--json-out", type=Path) + args = parser.parse_args() + try: + report = run(args.limit) + except Exception: + traceback.print_exc() + return 1 + _print_table("development", report["development"]) + print() + _print_table("holdout", report["holdout"]) + print() + print("selection", json.dumps(report["selection"], ensure_ascii=False)) + if args.json_out: + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_rectification_relative_support.py b/tests/test_rectification_relative_support.py new file mode 100644 index 00000000..4aa04381 --- /dev/null +++ b/tests/test_rectification_relative_support.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import unittest +from decimal import Decimal + +from scripts.rectification.decision_policy import ( + POLICY_VERSION, + RELATIVE_SUPPORT_MODE, + _relative_support, + build_candidate_decisions, +) +from scripts.rectification.scoring_service import ALGORITHM_VERSION + + +def _row(time: str, score: float) -> dict: + return {"time": time, "score": score, "evidence": [], "missing_layers": []} + + +class RelativeSupportScaleTest(unittest.TestCase): + def test_holdout_gate_keeps_proportional_default(self) -> None: + self.assertEqual(POLICY_VERSION, "rectification-candidate-policy-v3") + self.assertEqual(ALGORITHM_VERSION, "rectification-v5-matrix-scoring-7") + self.assertEqual(RELATIVE_SUPPORT_MODE, "proportional") + + def test_offset_top_two_lead_is_at_least_proportional(self) -> None: + scores = [ + Decimal("15.52"), Decimal("15.40"), Decimal("14.10"), Decimal("13.00"), + Decimal("12.50"), Decimal("12.00"), Decimal("11.80"), Decimal("11.50"), + Decimal("11.40"), Decimal("11.20"), Decimal("11.10"), Decimal("10.96"), + ] + floor = min(scores) + proportional = _relative_support(scores, floor=floor, mode="proportional") + offset = _relative_support(scores, floor=floor, mode="offset") + prop_lead = sorted(proportional, reverse=True) + offset_lead = sorted(offset, reverse=True) + self.assertGreaterEqual(offset_lead[0] - offset_lead[1], prop_lead[0] - prop_lead[1]) + self.assertEqual(sum(offset), 100) + self.assertEqual(sum(proportional), 100) + + def test_equal_scores_split_evenly_for_offset_and_proportional(self) -> None: + scores = [Decimal("12")] * 12 + expected = _relative_support(scores, floor=Decimal("12"), mode="proportional") + offset = _relative_support(scores, floor=Decimal("12"), mode="offset") + self.assertEqual(offset, expected) + self.assertEqual(sum(offset), 100) + self.assertTrue(all(value in {8, 9} for value in offset)) + + def test_build_candidate_decisions_offset_lead_beats_proportional_on_spaced_grid(self) -> None: + scores = [ + 15.52, 15.40, 14.10, 13.00, 12.50, 12.00, + 11.80, 11.50, 11.40, 11.20, 11.10, 10.96, + ] + rows = [_row(f"05:{index * 5:02d}", score) for index, score in enumerate(scores)] + proportional = build_candidate_decisions(rows, result_id="00000000-0000-4000-8000-000000000001", support_mode="proportional") + offset = build_candidate_decisions(rows, result_id="00000000-0000-4000-8000-000000000001", support_mode="offset") + self.assertEqual(len(proportional), 12) + self.assertEqual(len(offset), 12) + prop_lead = proportional[0]["relative_support"] - proportional[1]["relative_support"] + offset_lead = offset[0]["relative_support"] - offset[1]["relative_support"] + self.assertGreaterEqual(offset_lead, prop_lead) + self.assertEqual(sum(item["relative_support"] for item in offset), 100) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index a5dcdef2..5a14d6df 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -488,6 +488,7 @@ class RectificationV5ServicesTest(unittest.TestCase): self.assertNotEqual(receipt["gates"]["exact_confirmation"]["external_validation_status"], "fail") self.assertEqual(first["decision_receipt"], receipt) + # C2 holdout 校准:offset/softmax 覆盖下降,默认仍用 proportional,policy 不 bump self.assertEqual(first["decision_policy_version"], "rectification-candidate-policy-v3") self.assertTrue(receipt["display_allowed"]) self.assertTrue(receipt["accept_allowed"])