# 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** 起。开工时重新核对最大号,若已被占用则顺延并在进度记录里说明。