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 <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-06 22:14:42 +08:00
parent 3a9ae736e1
commit 62afa521f4
9 changed files with 642 additions and 10 deletions
+12
View File
@@ -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 版本未变。
+48 -1
View File
@@ -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 < 16softmax 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`
@@ -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 holdout20 例,全部 `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 < 16softmax_2 中位 21 不小于 21。**未启用 C1。** 产品负责人确认前不得合入启用。
### C3
`storedSnapshotIsCurrent` 仍比较 `scoringPolicyVersion`;失配 → `snapshotCurrent=false`。本单未 bump,旧 Case 打开后 **不会** 只因本单重算。测试清单第 5 条按「不会自动重算」验收。
### C4 虚构 12 簇代表(任务书 §2.3 七件事量级:原始分 10.9615.52
单位测试用同一串分,显式调用 offset 时 top-2 领先不低于 proportional;等分时仍拆成 8/9。线上默认仍是 7–9 压平。公开文案「相对支持度 N」未改。
| 方案 | 12 个公开代表(示例) |
| --- | --- |
| proportional(启用) | 约 79top-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`,首页静态化不因本单变化。
+1 -1
View File
@@ -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-05staging 部署仍停在 `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-558560 |
| `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-558560B2 未做、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-505509 |
@@ -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 打开后第一轮会重算一次」。
+57 -8
View File
@@ -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)
+290
View File
@@ -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())
@@ -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()
+1
View File
@@ -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 覆盖下降,默认仍用 proportionalpolicy 不 bump
self.assertEqual(first["decision_policy_version"], "rectification-candidate-policy-v3")
self.assertTrue(receipt["display_allowed"])
self.assertTrue(receipt["accept_allowed"])