Files
Jyotisha/docs/tasks/TASK-rectification-cross-midnight-dasha-20260920.md
T
Jesse_ChenandClaude Opus 5 03cba4780a docs(tasks): 跨午夜单产品拍板放行 + 更正决策 C 的挂载点
产品 2026-09-20 就三点拍板:A 修;B 修完重新冻结并重跑 T1/T2;C 让修复
前后的结果可区分。任务书 §3 三个待决点改为已决,状态转待领取。

更正 C 的做法。原提案是改 calculation_spec 并 bump INPUT_CONTRACT_VERSION,
但查证发现 calculation_spec_hash 的消费方全在 V4 链路,生产跑的 V9 对它零
引用,改了对真实用户历史无效。改为随修复 bump engine_version:它取自
v9EngineVersion(),全仓只写不比、无任何相等性门控。明令不得动 skill_version,
BUG-621 就是 open RPC 的 Skill 版本绑定让历史校正打不开。

§1.6 同步重写为 V9 的真实身份字段表;T4 改为 engine_version bump,验收加
一条历史会话仍能打开的定向回归。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
2026-09-20 12:28:44 +08:00

185 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK · 跨午夜候选的 Dasha 边界错一天(生产打分,2026-09-20)
> 状态:**待领取**。本单会**改动生产打分结果**,与 `TASK-rectification-validation-integrity-20260920` 的「不改打分」红线相反;产品已于 2026-09-20 就三点全部拍板放行,见 §3。
> 起因:执行 BUG-979 偏差评测时,独立审查在离线适配层发现该问题;Claude 2026-09-20 验收时在**生产调用链上独立复现**。
## 0. 基线与交付
- 基线:`origin/staging` = `932f2fff`(2026-09-20 实测核对)。
- worktree `.worktrees/rectification-cross-midnight-20260920`,分支 `codex/rectification-cross-midnight-20260920`。
- 改动落点:`scripts/rectification/**`、`tests/**`,可能含 `references/rectification_sealed_holdout.v1.json` 与 `docs/research/**`(取决于 §3 决策 B)。全部在 `deploy/gated-paths.txt` 内,推 staging 会触发门禁并重新发布镜像。
- **串行依赖**:本单改的 `scripts/rectification/scoring_service.py` 与 `dasha_transition_proximity.py` 是 `frozen_scoring.files` 之外的文件,但改动会让 `932f2fff` 落盘的 T1/T2 数字失效(§3 决策 B)。开工前确认没有别的会话正在改同两个文件。
## 1. 事故实证
基线 `932f2fff`,符号定位。
### 1.1 缺陷位置
`scripts/rectification/scoring_service.py` 的 `build_event_contribution_matrix()` 在调用 `merge_transition_proximity()` 时,只传**一个** `scoring_request["birth_date"]`:
```
merge_transition_proximity(matrix_payload, scoring_request["events"],
static_contexts, scoring_request["birth_date"], ...)
```
`scripts/rectification/dasha_transition_proximity.py` 的 `merge_transition_proximity()` 把这个值用于**全部候选**:`vim_key = (birth_date, moon, lo, hi)`、`_vim_start_dates(birth_date, ...)`、`_narayana_start_dates(..., birth_date, ...)`。候选之间只靠 `_context_time()` 取出的 `HH:MM` 区分,**日期被丢掉**。
因此当搜索窗跨午夜时,午夜之后的候选仍按窗口起始日计算 Vimshottari / Narayana 起始日期,**全部 dasha 边界整体错一天**,事件邻近度得分随之错。
对照:同模块的静态星盘层读 `candidate_at`,日期是对的。**只有 transition-proximity 这一个 helper 吃单一 birth_date**(`scripts/research/reported_offset_sweep.py` 的 `score_window()` docstring 已记录这一边界)。
### 1.2 实测复现(Claude 验收时独立执行,基线 `932f2fff`)
取公开 AA 案例,把窗口人为挪到 `23:50 → 00:10`(21 个候选,其中 11 个跨日),同一组 `static_contexts` 分别走生产路径与按候选日期分组的正确路径:
| 项 | 实测 |
| --- | --- |
| 分数不同的候选数 | **11 / 21** |
| 分数相同的候选 | 10 个,全部是同日候选 |
| 偏移幅度 | `00:00`–`00:04` 为 `+0.0267`;`00:05`–`00:10` 为 `-0.0133` |
| 本例头名是否改变 | 否(`00:10` 两侧一致) |
**错的恰好就是那 11 个跨日候选,同日候选一个都不差** —— 诊断精确坐实,不是浮点噪声。
### 1.3 上界是算术推的,不是实测
`dasha_transition_proximity.py` 常数:`DAY_KERNEL_DAYS = 15` / `DAY_MAX_POINTS = 1.0`,`MONTH_KERNEL_DAYS = 45` / `MONTH_MAX_POINTS = 0.35`,`VIM_SHARE = 0.6` / `NARAYANA_SHARE = 0.4`。
差一天导致的单事件最大偏移 = `cap / kernel_width`:day 精度 `1.0/15 ≈ 0.067` 分,month 精度 `0.35/45 ≈ 0.008` 分。
真实会话每例 5–18 件事。若多数为 day 精度且同向偏移,累计上界可达 **≈1.2 分**。对照 `docs/research/rectification_minute_resolution_closure_2026_09_14.md` 实测的**随分钟变化项总量约 2.1 分**,这个量级足以改变头名与并列关系。**1.2 分是算术上界,不是实测值**;1.2 节的实测样本只有 3 件事、以 month/year 精度为主,所以只错了 0.03。真实影响幅度**本单必须实测,不得沿用任何一侧的数字**。
### 1.4 谁会踩到
窗口跨午夜的真实路径(均已在 Bug 历史登记):
- `period_only` 的 `late_night` 时段映射为 `23:00–03:59`(见 `BUG-3315` 所在记录)。
- `unknown` 映射为 `00:00–23:59`。
- 申报时间在 23:45 之后、或 00:15 之前,默认 ±15 分钟窗即跨午夜(`frontend/src/lib/rectification-agentic/v9/search-window.ts` 的 `ENGINE_SEARCH_RADIUS_MINUTES = 15`);放宽到 ±30/60/120 后覆盖面更大。
### 1.5 记录现状
该事实目前**只写在 `BUG-979` 的「独立审查追加」一行**与 `BLOCKED.md` 里,没有独立编号、没有 `investigating` 状态。`BUG-979` 自身是 `resolved`,其正文已声明「resolved 仅指离线缺少偏差维度的缺口……不代表生产跨日问题已修」。按 `AGENTS.md` §5.4,已确认事实必须有自己的记录与状态。
### 1.6 生产结果没有打分实现身份(连带发现,2026-09-20 修正)
**先更正一条**:`scripts/rectification/scoring_service.py` 的 `calculation_spec()` 与随结果落库的 `calculation_spec_hash`,其消费方全部在 **V4** 链路(`frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql`、`frontend/src/mastra/rectification-tools.ts`)。**生产跑的是 V9,V9 对 `calculation_spec_hash` 零引用**(已全仓检索确认)。因此「bump `INPUT_CONTRACT_VERSION` 以区分新旧结果」对真实用户历史无效,不要做。
V9 的真实情况:
| 字段 | 来源 | 是否被相等性门控 |
| --- | --- | --- |
| `skill_version` | Skill 包版本 | **是**。`BUG-621`:open RPC 要求绑定 Skill 等于当前版本,bump 会让历史校正打不开 |
| `engine_version` | `frontend/src/lib/rectification-agentic/v9/engine-client.ts` 的 `v9EngineVersion()`=环境变量 `RECTIFICATION_ENGINE_VERSION`,缺省 `"rectification-v5"` | **否**。全仓只写不比,无任何 `===` / SQL 相等判断 |
| 打分实现身份(如 12 文件的 `implementation_sha256`) | **不存在** | — |
所以修复打分后,V9 历史结果与新结果在回执里**没有任何可区分的标记**。这与 `BUG-427` 的防复发条(「评测指标必须与产生它的 `implementation_sha256` 绑定记录」)是同一类问题,只是发生在生产结果侧而非评测侧。
## 2. 根因
1. `merge_transition_proximity()` 的接口把「出生日期」当成窗口级常量,而不是候选级属性。候选身份在 `_context_time()` 处被压成 `HH:MM`,日期信息在进入该函数时就已丢失。
2. 全仓没有一条跨午夜窗口的**打分层**回归。`BUG-098` 记录里「跨午夜兼容」指的是**逐分钟枚举**,transition proximity 是后加的层,没有被那条覆盖。`tests/` 下无任何 transition / proximity 命名的测试文件。
3. 生产结果的可复现性只绑定输入规格,不绑定打分实现身份,所以打分变更不会触发任何告警。
## 3. 决策记录
**2026-09-20 产品负责人已就三点拍板,本单可以开工。**
- **A. 修不修?→ 修。** 修复会改变跨午夜窗口下所有候选的分数,可能改变头名、并列关系与交付区间。产品接受这一影响:它是确定性错误,不是精度取舍。
- **B. 修完要不要重新冻结并重跑 T1/T2?→ 要。** `932f2fff` 的 900 组合与 v3 重跑成绩是用有缺陷的实现算的,修复后不再对应。重跑成本已实测:T2 约 10 秒,T1 全量 900 组合约 4 分钟。旧数字保留并标注作废原因,不删除历史。
- **C. 历史已交付 Case 怎么处理?→ 让新旧结果可区分,但挂在 `engine_version` 上。**
- 原始提案(改 `calculation_spec` 并 bump `INPUT_CONTRACT_VERSION`)**已作废**:§1.6 查证表明那条链路属 V4,生产 V9 零引用,改了对真实历史无效。
- 采纳做法:随修复 bump `engine_version`(`v9EngineVersion()` 的缺省串,或部署侧 `RECTIFICATION_ENGINE_VERSION`),使修复前后的 V9 回执可区分。该字段全仓只写不比,bump 不影响历史会话打开。
- **不得 bump `skill_version`**(`BUG-621`:open RPC 要求绑定 Skill 等于当前版本,bump 会让历史校正打不开)。
- 是否把 12 文件的 `implementation_sha256` 也写进 V9 回执,作为可选增强;若做,必须是新增字段,不得改动既有字段的含义或类型。
## 4. 硬红线
1. **不得为了让修复看起来无影响而缩小窗口、改时段映射或回避跨午夜路径。**
2. 修复只允许改「按候选日期取 dasha 起始」这一件事。不得顺带调 `DAY_KERNEL_DAYS` / `MONTH_KERNEL_DAYS` / `*_MAX_POINTS` / `VIM_SHARE` / `NARAYANA_SHARE` 中的任何一个常数——那是打分权重,属 `rectification_minute_resolution_closure_2026_09_14.md` 已证伪的改法方向。
3. **不得触碰确认门**:`status` 保持 `not_ready`、`confirmation_coverage_rate` 保持 `0.0`、`holdout_passed()` 保持 `False`。
4. 不得把修复后的重跑成绩写成「独立官方盲测」——`BUG-428` 的红线不因换实现而解除(`official_valid_independent_blind` 保持 `false`、官方试次保持 `0`)。
5. 缓存键必须跟着改:`vim_key` / `narayana_key` 现在以 `birth_date` 为首元素,改成按候选日期后,**必须确认缓存不会跨日期串用**,否则修了接口没修行为。
6. 不得写入真实用户资料;实证只用已登记的公开 AA 案例。
7. 不顺手升级依赖、不修不在本单内的 warning。
## 5. 任务分解
### T1 · 先写失败测试(BUG-981)
在改实现之前,新增 `tests/test_dasha_transition_proximity_cross_midnight.py`:
- 构造跨午夜窗口(如 `23:50–00:10`),断言**每个跨日候选**的 dasha 起始日期等于**该候选自己的日期**推出的值,而不是窗口起始日。
- 断言同日候选的分数不受修复影响(回归保护)。
- 断言缓存不跨日期串用(红线 5)。
验收标准:该测试在**修复前必须红**、修复后转绿;进度记录里贴出修复前的失败输出。
### T2 · 修实现
把候选日期传到底。参考实现是 `scripts/research/reported_offset_sweep.py` 的 `score_window()`(按候选日期分组、每组一次矩阵、合并后全窗排序),但**那是离线适配层,不要照搬结构**——生产侧应在 `merge_transition_proximity()` 内部按候选日期取 dasha 起始,避免多次重建矩阵带来的性能回退。
验收标准:
- T1 全绿;`tests/test_rectification_*.py`、`tests/test_active_rectification_*.py`、`tests/test_minute_rectification_*.py` 与基线逐条比对,0 新增失败。
- **非跨午夜窗口的分数逐位不变**:取至少 3 个公开 AA 案例,修复前后分数字节相同。这是本单最重要的一条回归。
- 性能不回退:同窗口打分耗时与基线相比不超过 +20%(基线实测:20 例 ±60 档 8.6 秒)。
### T3 · 按决策 B 重新冻结与重跑
产品已答「要」,本项必做。
- 重新计算 12 个 `frozen_scoring.files` 的实现哈希(修复若落在这 12 个文件内,哈希会变;若落在之外,需在报告里说明为何成绩仍变)。
- 重跑 `scripts/research/sealed_holdout_rerun.py` 与 `scripts/research/reported_offset_sweep.py`,更新两份研究文档与 JSON,**旧数字保留并标注作废原因**,不删除历史。
- 更新 `references/rectification_sealed_holdout.v1.json` 的 `current_tree_scorer`,`status` 仍为 `not_ready`。
验收标准:`tests/test_sealed_holdout_contract_freshness.py` 绿(它会因哈希漂移而红,这正是它存在的意义);两份研究文档的口径块含新哈希;`official_valid_independent_blind` 仍 `false`。
### T4 · 按决策 C 给结果加可区分标记
- bump `engine_version`:改 `frontend/src/lib/rectification-agentic/v9/engine-client.ts` 的 `v9EngineVersion()` 缺省串(或与部署侧 `RECTIFICATION_ENGINE_VERSION` 协同,二选一并在进度记录写明选了哪个及原因)。
- **不得**改 `skill_version`;**不得** bump `INPUT_CONTRACT_VERSION`(见 §1.6,那是 V4 死链路)。
- 可选增强:在 V9 回执新增打分实现身份字段。做则必须是新增字段,不得改既有字段含义或类型。
验收标准:
- 修复后新建 Case 的回执 `engine_version` 为新值,历史 Case 回执保留旧值。
- **历史校正会话仍能打开**(`BUG-621` 的定向回归必须跑,结果贴进度记录)。
- 全仓确认 `engine_version` 仍无相等性门控——若本单引入了任何按它比较的逻辑,视为越界。
### T5 · 记录
- `docs/BUG_HISTORY.md` 新增 `BUG-981`,关联 `BUG-098`、`BUG-427`、`BUG-979`;说明 `BUG-098` 的「跨午夜兼容」只覆盖逐分钟枚举、不覆盖后加的 transition proximity 层。
- 修正 `BUG-979`:在正文补一行指向 `BUG-981`,不改其 `resolved` 状态(它的 resolved 本就只指离线缺口)。
- 顺带修正 `BUG-978` 的状态字符串 `resolved/partial` —— `AGENTS.md` §5 只认 `investigating` / `blocked` / `resolved` 三种,按实际改成其中之一并把「partial」的含义写进正文。
- `BLOCKED.md` 里那条跨午夜条目改为指向 `BUG-981`,不删除。
- `docs/tasks/README.md` 状态板加一行;实现合入 staging 的同一次推送里改状态。
- `CHANGELOG.md`:若决策 A 为修,则记一行(跨午夜出生时段的候选打分修正,用户可感知为候选排序变化)。
## 6. 让步顺序
1. **最先保 T1 + T2**:失败测试与修复本身。没有 T1 就没有证据证明修对了。
2. 其次 T3(重跑,产品已拍板必做)。确实跑不完时,必须在两份研究文档顶部加一行「本文数字产出自已修复前的实现」,不得留着不说。
3. T4 的 `engine_version` bump 不可砍(产品已拍板 C);可选的「回执加打分实现身份」可砍,砍了要在 §1.6 的事实进 `BUG-981` 正文。
4. **不得砍掉 T2 的「非跨午夜窗口分数逐位不变」回归。** 砍了它,这次修复就无法与打分权重变更区分开。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-cross-midnight-20260920 \
.worktrees/rectification-cross-midnight-20260920 origin/staging
cd .worktrees/rectification-cross-midnight-20260920
git log --oneline -1 # 必须是 932f2fff 或其后代
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
```
本单属 `AGENTS.md` §9 的引擎类任务,`pre_work_check.py` 必跑,并先读 `docs/research/pre_work_error_ledger.md`。
**必读**:`docs/research/rectification_minute_resolution_closure_2026_09_14.md`(尤其第 6 节三条提醒与「不得调打分权重」的结论)、`docs/research/reported_offset_2026_09_20.md` 的「初扫作废与独立复核」一节(离线侧是怎么修的、为什么不能照搬)。
环境备忘:本机无 `.venv`,系统 `python3`(3.13)可 `import swisseph`;frontend **无 `node_modules`**,前端侧检查需在有依赖的环境做。
## 8. BUG 编号起点
`docs/BUG_HISTORY.md` 当前最大号 **980**。本单从 **BUG-981** 起。开工时重新核对最大号,若已被占用则顺延并在进度记录里说明。