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:
@@ -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
@@ -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`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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`,首页静态化不因本单变化。
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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 打开后第一轮会重算一次」。
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user