docs(tasks): 跨午夜日期锚点与簇跨度任务书(BUG-982 + BUG-983)

两条 Bug 都实测复现,合并成一单,因为根因相同:契约只传钟点、日期不在
契约里,后端按钟点大小猜哪端跨日。

BUG-983 是静默算错盘:_candidate_datetimes() 把起始钟点无条件绑在申报
日期上,前端零补偿。实测申报 00:10 / 申报日 2000-06-15,申报分钟本身
落到 06-16 00:10,整套盘算在错误日期上。late_night 更重:其 00:00–03:59
共 240 分钟全部落在用户没有申报过的日期上。983 不修,BUG-981 的候选级
日期修复在这批人身上等于没上线。

BUG-982 影响面比原记录大:同缺陷在 _cluster_span、unionStillValidRange、
indistinguishableWidthMinutes 三处出现,原记录只写了 Python 两处。实测
跨午夜三分钟簇被报成 1440 分钟,且该链直达交付卡的「范围 X 分钟」。

三个待决点:late_night 的语义(按出生证明口径两段都在同一日,现行实现
一半落在次日)、申报贴近午夜时允不允许窗口跨到前一天、日期怎么进契约。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-20 20:15:30 +08:00
co-authored by Claude Opus 5
parent 8d0359fc62
commit 8aa5f6a444
2 changed files with 206 additions and 0 deletions
+1
View File
@@ -294,6 +294,7 @@
| `TASK-rectification-cross-midnight-dasha-20260920.md` | — | **跨午夜候选的 Dasha 边界错一天(生产打分)**:`scoring_service.py` 调 `merge_transition_proximity()` 只传**一个** `birth_date`,该函数用它算全部候选的 Vimshottari / Narayana 起始日期,候选之间只靠 `_context_time()` 的 `HH:MM` 区分、**日期被丢掉**。窗口跨午夜时午夜后候选的 dasha 边界整体错一天。Claude 验收时在生产调用链独立复现:窗口 `23:50→00:10`、21 个候选,**恰好那 11 个跨日候选分数错、10 个同日候选逐位相同**(幅度 +0.0267 / −0.0133,本例头名未变)。算术上界 = `cap/kernel_width`day 精度 0.067 分/件,18 件可累计约 1.2 分,而随分钟变化项总量仅约 2.1 分 —— **上界是推的不是实测,真实幅度本单必须实测**。踩中路径:`late_night` 时段 `23:0003:59`、`unknown` `00:0023:59`、23:45 后或 00:15 前申报的 ±15 窗。连带发现:`calculation_spec()` 不含打分实现身份,修复后同一 spec hash 对应不同分数,历史 Case 静默失去可复现性(同 BUG-427 类型)。**产品 2026-09-20 已就三点拍板:A 修、B 修完重新冻结并重跑 T1/T2、C 让新旧结果可区分。** C 的做法经查证已修正:`calculation_spec_hash` 全在 **V4** 链路、**V9 零引用**,原提案 bump `INPUT_CONTRACT_VERSION` 对真实历史无效已作废;改为随修复 bump `engine_version``v9EngineVersion()` 缺省串用于 started/failed;成功回执及 minute 缓存身份来自后端,旧「只写不比」假设作废),**不得动 `skill_version`**BUG-621open RPC 要求绑定 Skill 等于当前版本,bump 会让历史校正打不开)。硬红线:只改「按候选日期取 dasha 起始」,不得动 kernel/cap/share 任一常数;确认门不变。BUG-981 | **核心修复与 BUG-985 已 review 通过,本地合并 b27d4de9;推 staging 认证失败,端到端仍受 BUG-984 阻塞** | 实现 `aa46da10`(分支 `codex/rectification-cross-midnight-20260920`,未合入 staging)。Claude 独立 review:修复后整窗打分与**逐候选独立重算 21/21 一致**;非跨午夜分数基线 vs 修复**逐位不变**(我测半径 10 六例 + 半径 60 三例,执行方证据覆盖 19 例 / 2299 候选全部 `bytes_equal`);kernel/cap/share/`PRECISION_WEIGHTS` 一个未动;`status=not_ready`、coverage 0、`holdout_passed()` False、官方试次 0;决策 C 执行正确(`ALGORITHM_VERSION` 7→8`INPUT_CONTRACT_VERSION` 与 `skill_version` 未动,golden 里 `calculation_spec_hash` 不变可证);既有断言改动均带三栏说明且为加强。**P1 阻塞**:新增的 `test_same_day_public_aa_scores_keep_pre_fix_bytes` 写死 121 个浮点分数的 SHA-256,在 Claude 机器上 ordinal 2/3 红、**基线与修复分支同样红**(非修复所致),且经 `test_rectification_cross_midnight_gate.py` re-export 落入快速门 glob —— 同 glob 基线 214 passed/0 failed、`aa46da10` 4 failed,推 staging 会让门禁红。执行方自报未通过的 BUG-984 经独立确认成立且更重:`block_scan` 只比 evidence 指纹不读打分身份,而 `late_night`(299 min)/`unknown`(1439 min) 均 >120 走该分支、又恰是最易跨午夜的窗口 |
| `TASK-rectification-cross-midnight-gate-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-gate-fix-20260920.md` | **review 修复单:门禁级浮点哈希断言(`aa46da10` 的合入阻塞项)**:新增回归把 121 个分数的 SHA-256 写死成字面量,于是断言范围把跨机 libm/pyswisseph 差异也圈了进去。实测该组 121 个分数**全无浮点尾噪**(`repr(s)==repr(round(s,4))` 121/121),所以差异是第 4 位真的不同,靠改序列化消不掉。同一提交刚改过的 `test_rectification_engine_memoization.py` docstring 正好写着「跨机舍入已漂 1.1e-3,不得整体 `==` 比较」——教训被引用了又被踩。执行方自己的证据 JSON 用的却是正确做法(`scores_bytes_equal` = 同机基线 vs 当前)。**根因**:要证的是相对不变量(同机基线 vs 当前),却实现成绝对不变量(当前 vs 某台机器的历史哈希)。F1 改用生产代码已有的 legacy 回退路径做同机 A/B 对照(去掉 `candidate_at` 即修复前行为),验收要求**两台浮点环境不同的机器各跑一次**、且回退核心修复后该测试仍绿(证明它守的是同日不变性而非跨午夜回归的替身);F2 bridge 重复收集(4 failed = 2×2);F3 查证 `block_scan` 重算是否经过被修 helper —— 会则 BUG-981 在 late_night/unknown 路径等于没上线、BUG-984 升为阻塞项;F4 记录。**硬红线:不得删测试/skip/把哈希改成本机当前值消红,不得改已验收的打分代码。** 串行:本单 → `aa46da10` 合入 → BUG-984 补单。BUG-985 | **已 review 通过;BUG-985 resolved;合入推送被 Gitea 认证阻塞,远端仍 a3577ce2** | 实现 `25232ce4`(分支 `codex/rectification-cross-midnight-gate-fix-20260920`)。F1 改为同进程 A/B:在 `scoring_service.merge_transition_proximity` 调用边界剥掉 `candidate_at` 走生产既有 legacy 回退,对比 121 个分数与整份贡献矩阵的 canonical 字节;另加 `legacy_calls == [121]` 与 `static_contexts is contexts` 两道防空转保险。Claude 复核:写死哈希字面量 **0 残留**;**第三套环境(Linux + Python 3.13,与执行方 Windows 3.11.7 不同)定向 18 条全绿** → 两机验收闭环;**回退日期修复探针:同日 3 条全绿、跨午夜相关 4 条转红**,证明同日不变性与跨午夜正确性已真正分开;快速门 glob staging `1b646659` 200 passed/0 failed → `25232ce4` 216 passed/0 failed+16,零新增失败,总数未降);相对合并点仅动 2 个测试文件 + 文档,`scripts/`、前端、golden、打分常数零改动。BUG-985 记录含我要求的防复发条,并正确认定为 BUG-733 同形复发。**建议 BUG-985 由 `blocked` 改 `resolved`**(证据即第三环境复跑)。环境缺口:完整快速门在 Claude 机器 120 秒超时被杀,6→4 那组数以执行方记录为准 |
| `TASK-rectification-cross-midnight-dasha-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-fix-20260920.md` | **BUG-984 缓存与结果身份补单(BUG-981 的端到端阻塞项)**:`scoreAndPersistCurrentEvidence()` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true` 与旧 `algorithmVersion`,该返回发生在 `readV9EngineScoringIdentity()` **之前**`minute` 分支则有身份门。F3 已查证完整调用链到 `merge_transition_proximity()`,故核心修复合入后,**证据未变的历史跨午夜时段缓存命中仍返回修复前分数**——在本单闭环前不得声称跨午夜问题已修。边界已按源码收窄:`late_night`(23:0003:59) 跨日;`unknown`(00:0023:59) 虽 >120 分钟但**本身同日**,选中跨午夜子时段后才触发(此处修正了 Claude 先前把两者并列的说法)。产品 2026-09-20 放行且**同日拍板 F3 策略选 b**:版本接口取不到可信身份时,旧缓存**只读展示 + 显著标注「按旧算法产出」**,否决 a(重算,会把接口抖动放大成长等待,时段扫描受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 限流)与 c(照常复用,与已定原则冲突)。b 的三条边界:只读结果**服务端拒绝采用/确认**(靠删不靠藏,须有定向用例)、标注必须用户可见并对照 `VOICE.md`(涉界面同提交更新 `DESIGN.md`)、回执来源身份仍是产出它的版本。**F2 的 SQL 问题已查清并定序(A 先上 / B 兜底 / 第 10 版前必须解决)**:`engine_version` 一个字段被「部署声称的版本」(started/failed 行,取前端常量)与「实际产出结果的版本」(completed 行,命中旧缓存即旧版本)共用,聚合却用与版本先后无关的字符串 `max`。**该缺陷此前一直撞对,`aa46da10` 之后才变真错**:它把 `v9EngineVersion()` 缺省由 `rectification-v5` 改为 `…scoring-8`,started 行遂在字符串序上反超 completed 行 → 回执显示第 8 版而分数来自第 7 版缓存;已核 `deploy/`、`.gitea/` 未设 `RECTIFICATION_ENGINE_VERSION`,走缺省,**是真实行为**。第二个缺陷:实跑 `max("…-10","…-9") = "…-9"`,**该聚合在第 10 版静默反向**(现为第 8 版)。Astarted/failed 不再写版本(应用层,必做);A 的漏洞(同 turn 多个 completed 行版本不同)**必须实测取证,不得以「应该不会」结案**;B=聚合改取成功结果那一行(只改函数体,向后兼容);C=拆列本单不做。**四项全部可开工。**硬红线:不重标/不删历史结果,不 bump Skill,不改 V4 input contract,不引入按 `engineVersion` 拒绝打开历史会话。串行:BUG-985 合入 → 本单。BUG-984 | **本地完成:diagnostics追加修复后全量3575→3588全绿,标准DB40/40Static/完整28资源gzip+0.040085%tsc/lint通过;主会话独立DB40与最终定向40全绿。未推送,真人/部署待主会话** | `codex/rectification-cross-midnight-fix-20260920`;前置认证已解除,代码基线 `3f39bafc`、文档基线 `f09f3d80`;本轮未推送 |
| `TASK-rectification-midnight-date-anchor-20260920.md` | — | **跨午夜的日期锚点与簇跨度(BUG-982 + BUG-983 合并一单)**:两条同根——线上契约只传钟点、日期不在契约里,后端按钟点大小**猜**哪端跨日。**BUG-983 是静默算错盘**`_candidate_datetimes()` 把起始钟点无条件绑在申报日期上,前端零补偿。Claude 实测(申报日 2000-06-15):申报 `00:10` → 候选 `06-15 23:55`…`06-16 00:25`**申报分钟本身落在 `06-16 00:10`+1 天**`00:02` 同样 +1 天;`12:00` 对照正确。受影响:申报落在午夜后半径内者(±15≈1.0%、±30≈2.1%、±60≈4.2%、±120≈8.3%);`late_night`(23:0003:59) 更重,其 `00:00``03:59` 共 240 分钟**全部落在用户没申报过的日期上**。**BUG-982 影响面比原记录大**:同缺陷在链路上出现三处(`_cluster_span` / `unionStillValidRange` / `indistinguishableWidthMinutes`),原记录只写了 Python 两处。实测跨午夜簇 `23:58/23:59/00:00` 报告宽度 **1440 分钟**(真实 3)、`23:50/23:55/00:05` 报 **1431**(真实 16);该链经 `reportWidth` 直达 **交付卡「范围 X 分钟」**,深夜用户会看到候选其实差几分钟却被告知范围一千多分钟。**与 BUG-981 的关系**:981 让每个候选按自己日期算 Dasha 是对的,但 983 给的日期本身就错,**983 不修则 981 在申报近午夜的场景等于没上线**。§3 三个待决点:**D1 `late_night` 到底指哪两段**(按出生证明口径「6/15 深夜」两段都在 15 日,现行实现一半落在 16 日;Claude 建议先问用户,答不上退「同日两段」)、D2 申报贴近午夜时允不允许窗口跨到**前一天**(Claude 建议允许并在交付说明日期也会变,依据 Part B「真值覆盖率优先于区间宽度」)、D3 日期怎么进契约(建议加窗口相对序号)。硬红线:不调打分常数/确认门阈值;不得以排除真值换窄宽度;不得只修 `_cluster_span` 就宣称 982 已修;非跨午夜必须逐位不变且用同机 A/B(不得写死跨机浮点哈希,见 BUG-985)。沿用 BUG-982/983,不新开号 | **待产品拍板**D1/D2/D3 | — |
## 命名与归档
@@ -0,0 +1,205 @@
# TASK · 跨午夜的日期锚点与簇跨度(BUG-982 / BUG-9832026-09-20
> 状态:**待产品拍板后开工**。§3 有三个待决点,其中 D1 是产品语义问题,不是技术选型。
> 两条 Bug 合并成一单,因为它们**根因相同**:线上契约只传钟点字符串,没有任何一侧拥有「这一分钟属于哪一天」。分开修会产生两套互相冲突的日期表示。
## 0. 基线与交付
- 基线:`origin/staging` = `8d0359fc`2026-09-20 实测核对)。
- worktree `.worktrees/rectification-midnight-date-anchor-20260920`,分支 `codex/rectification-midnight-date-anchor-20260920`
- 改动落点:`scripts/rectification/**``scripts/active_rectification_event_engine.py``frontend/src/lib/rectification-agentic/**``tests/**``frontend/tests/**`。全在 `deploy/gated-paths.txt` 内。
- **串行依赖**:本单改 `active_rectification_event_engine.py``decision_policy.py`。BUG-984 那批(`8d0359fc`)已合入,但其**部署与真人验收尚未完成**;开工前确认没有别的会话在改同两个文件。
- **与 BUG-981 的关系**BUG-981 的修复让每个候选按**自己的日期**算 Dasha,这是对的。但 BUG-983 给出的候选日期本身就是错的,于是 981 忠实地在错误日期上计算。**983 不修,981 在申报近午夜的场景里等于没用。**
## 1. 事故实证
基线 `8d0359fc`,以下全部为 Claude 2026-09-20 实测,非转述。
### 1.1 BUG-983:申报近午夜时,申报分钟本身被算到次日
`scripts/active_rectification_event_engine.py``_candidate_datetimes()`
```
start = datetime.combine(birth_date, start_time)
end = datetime.combine(birth_date, end_time)
if end < start: end += timedelta(days=1)
```
**起始钟点被无条件绑在申报日期上**,较小的终止钟点往后推一天。前端 `engine-client.ts` 只传原始 `birth_date` 加钟点范围,**没有任何日期补偿**(已全仓检索确认)。
实测(申报日期 `2000-06-15`,默认半径 ±15):
| 申报时间 | 请求窗口 | 候选首 / 末 | 申报分钟落在 | 判定 |
| --- | --- | --- | --- | --- |
| `00:10` | `23:55``00:25` | `06-15 23:55` / `06-16 00:25` | **`06-16 00:10`** | **错,+1 天** |
| `00:02` | `23:47``00:17` | `06-15 23:47` / `06-16 00:17` | **`06-16 00:02`** | **错,+1 天** |
| `12:00`(对照) | `11:45``12:15` | `06-15 11:45` / `06-16 —` | `06-15 12:00` | 对 |
也就是说:用户说「6 月 15 日 00:10 出生」,引擎拿 **6 月 16 日 00:10** 当候选中心,整套盘算在错误的日期上——月亮差约 13°,Dasha 边界整体错一天。
**受影响人群**(申报分钟落在午夜后 `半径` 分钟内):
| 半径 | 受影响的申报时刻 | 占一天的比例 |
| --- | --- | ---: |
| ±15(默认) | `00:00``00:14` | ≈1.0% |
| ±30 | `00:00``00:29` | ≈2.1% |
| ±60 | `00:00``00:59` | ≈4.2% |
| ±120 | `00:00``01:59` | ≈8.3% |
**`late_night` 时段更严重。** `declared-birth-window.ts` 把它映射为 `23:00``03:59`;按现行实现,候选是 `D 23:00``D+1 03:59`**其中 `00:00``03:59` 这 240 分钟全部落在用户没有申报过的日期上**。
### 1.2 BUG-982:跨午夜的簇被报成接近全天
`scripts/rectification/decision_policy.py``_cluster_span()` 按「当天第几分钟」排序取首尾:`times.sort(key=lambda v: int(v[:2])*60 + int(v[3:5]))`。实测:
| 簇成员 | 报告的范围 | 报告宽度 | 真实宽度 |
| --- | --- | ---: | ---: |
| `23:58` `23:59` `00:00` | `00:00`~`23:59` | **1440 分钟** | 3 分钟 |
| `23:50` `23:55` `00:05` | `00:05`~`23:55` | **1431 分钟** | 16 分钟 |
| `10:01` `10:02` `10:03`(对照) | `10:01`~`10:03` | 3 分钟 | 3 分钟 |
**同一缺陷在链路上出现三次,不止 `_cluster_span` 一处**(这一点比 `BUG-982` 记录里写的范围大):
| 位置 | 函数 | 做法 |
| --- | --- | --- |
| `scripts/rectification/decision_policy.py` | `_cluster_span()` | 按当天分钟排序取首尾 |
| `frontend/src/lib/rectification-agentic/core/credible-range.ts` | `unionStillValidRange()` | 把 `cluster_range``time` 映射成当天分钟后取 min/max |
| `frontend/src/lib/rectification-agentic/v9/candidate-plateau.ts` | `indistinguishableWidthMinutes()` | 对 `clusterStart`/`clusterEnd``max(ends) - min(starts) + 1` |
**这条链直接走到用户眼前**`unionStillValidRange()``reportWidth``rectification-v9-tools.ts`)→ `skill_verification_report.width_minutes`**交付卡上那句「范围 X 分钟」**。另一路 `indistinguishableWidthMinutes()``confirmation_gate.indistinguishable_width_minutes`
后果:深夜出生的用户,候选其实只差几分钟,交付卡却告诉他范围是一千多分钟——看上去整轮校正毫无收获。
## 2. 根因
**线上契约只传钟点字符串,日期不在契约里。** 前端传 `{birth_date, start_time, end_time}`,后端按钟点大小关系**猜**哪一端跨日;候选之间只靠 `HH:MM` 互相区分。于是:
- 猜错了归属 → BUG-983
- 下游任何需要比较先后的地方只能拿「当天第几分钟」当序 → BUG-982。
`BUG-098` 记录里的「跨午夜兼容」只覆盖**逐分钟枚举**能跨日,不覆盖「枚举到的日期对不对」,也不覆盖后加的簇跨度与宽度计算。
## 3. 决策记录(**三个待决点,未拍板不得开工**)
### D1(产品语义,不是技术选型):`late_night` 到底指哪两段?
用户说「我出生在 6 月 15 日,深夜」。出生证明记的是**日历日期**,所以这个人的出生瞬间在 6 月 15 日——要么是 `06-15 00:00``03:59`,要么是 `06-15 23:00``23:59`。**两段都在 15 日,不是一段跨到 16 日。**
现行实现搜的是 `D 23:00``D+1 03:59`,其中一半落在 16 日——**一个用户没有申报过的日期**。
| 方案 | 含义 | 代价 |
| --- | --- | --- |
| **a. 同日两段**`[D 00:0003:59] [D 23:0023:59]` | 严格按申报日期 | 候选集不连续,簇、区间、交付卡都要支持「两段」;改动最大但语义最正确 |
| **b. 维持一段,但锚到申报日**`[D-1 23:00, D 03:59]` | 把「深夜」理解为跨夜的那一夜,且申报日是**后半夜**所在的日期 | 改动小;但当用户其实是 23:xx 出生时,又把他放到了 D-1 |
| **c. 开工前先问用户是午夜前还是午夜后** | 把歧义还给用户 | 多一次提问,但一次性消除歧义;与「先等用户说」的既有口径一致 |
Claude 建议 **c**,并在用户答不上来时退到 a。理由:这是真歧义,不是我们能替用户推断的;本仓既有口径是「先等用户说、不用生日推年份」,同一条原则适用。
### D2:申报分钟贴近午夜时,窗口允不允许跨到**前一天**?
用户说「6 月 15 日 00:10」,±15 分钟意味着真实瞬间可能是 `06-14 23:55`。**若允许,等于承认出生日期本身也不确定**;若不允许,窗口要在 `00:00` 截断。
Claude 建议**允许跨到前一天,但必须在交付时说明「若落在 23:5x,出生日期是前一天」**。理由:时间不确定就必然带来日期不确定,截断会把真值排除在外,而 `AGENTS.md` Part B 的红线是「真值覆盖率优先于区间宽度」。
### D3(技术):日期怎么进契约?
| 方案 | 做法 | 评价 |
| --- | --- | --- |
| **1. 候选带完整 ISO datetime** | 契约里不再只有 `HH:MM` | 最彻底;改动面大,涉及回执、缓存指纹、golden |
| **2. 加窗口相对序号**(0..N-1) | 排序与跨度一律用序号,钟点只作展示 | 改动集中在比较逻辑;推荐 |
| **3. 每个候选加 `day_offset`** | 钟点 + 偏移天数 | 折中,但 `day_offset` 会到处传染 |
Claude 建议 **2**:BUG-982 的三处全部是「需要一个可比较的序」,序号能一次解决;BUG-983 则由请求侧明确锚点解决,两者不互相纠缠。
**注意**:任何改动都会改变候选身份与缓存指纹。必须核对是否触发 BUG-984 刚建立的身份校验(`scoringIdentityMatches`),并确认历史结果按既有规则只读,不被重标。
## 4. 硬红线
1. **不得调打分常数、权重、确认门阈值**。本单只改日期归属与跨度计算。
2. **不得为了让宽度好看而截断真值**`AGENTS.md` Part B B4「真值覆盖率优先于区间宽度」。宽度变窄若以排除真值为代价,一律不放行。
3. **不得重标或删除历史结果**。日期口径变化必然使旧结果与新结果不可比;按 BUG-984 已建立的机制标记来源,不得静默覆盖。
4. 不得 bump Skill 版本(`BUG-621`open RPC 要求绑定 Skill 等于当前版本)。
5. 不得只修 `_cluster_span` 就宣称 BUG-982 已修——§1.2 的三处必须一起修,且要有贯穿的回归。
6. 非跨午夜场景的候选、分数、宽度**必须逐位不变**,比照 `BUG-985` 已确立的同机 A/B 做法,**不得写死跨机浮点哈希**。
7. 不顺手升级依赖、不修不在本单内的 warning。
## 5. 任务分解
### T1 · 先写失败测试(两条 Bug 各一组)
- **983**:断言申报 `00:10` / 申报日 `D` 时,候选中**存在** `D 00:10` 且**不存在** `D+1 00:10``late_night` 按 D1 拍板结果断言。
- **982**:断言跨午夜簇 `['23:58','23:59','00:00']` 的报告宽度为 **3**,不是 1440;三处(`_cluster_span``unionStillValidRange``indistinguishableWidthMinutes`)各有用例。
- 另加一组**非跨午夜对照**,断言修复前后逐位不变。
验收标准:这两组在修复前必须红,修复后转绿;进度记录贴出修复前的失败输出。
### T2 · 修 BUG-983(日期锚点)
按 D2/D3 的拍板结果,让**申报分钟锚定在申报日期上**,窗口由此向两侧展开。请求契约需要明确表达锚点,不再让后端按钟点大小猜。
验收标准:
- T1 的 983 组全绿。
- 非跨午夜请求的候选集**逐位不变**(至少 3 个公开 AA 案例,同机 A/B 对照)。
- 明确核对并记录:候选身份 / 缓存指纹是否变化,若变化,BUG-984 的身份校验行为如何(旧结果应只读,不得被重标)。
### T3 · 修 BUG-982(跨度与宽度)
§1.2 的三处一起改为按 D3 选定的可比较序,不再用「当天第几分钟」。
验收标准:
- T1 的 982 组全绿,三处各自有用例。
- `skill_verification_report.width_minutes``confirmation_gate.indistinguishable_width_minutes` 在跨午夜场景下给出真实宽度。
- 非跨午夜场景宽度逐位不变。
### T4 · 交付层说明(若 D2 选「允许跨前一天」)
交付卡 / 旁白需要能说明「若落在 `23:5x`,出生日期是前一天」。文案对照 `frontend/docs/VOICE.md`;涉及界面则同一提交更新 `frontend/DESIGN.md`
### T5 · 记录
- 更新 `BUG-982` / `BUG-983`(沿用既有编号,不新开):补上 §1.2 的三处链路、§1.1 的受影响人群估算、与 `BUG-981` 的关系。
- `BUG-982` 的影响面需修正——原记录只写了 `candidate_contrast.py` / `decision_policy.py`,实际还有两处前端。
- 关联 `BUG-098`(其「跨午夜兼容」只覆盖枚举)、`BUG-981``BUG-984`
- `CHANGELOG.md` 记一行(用户可感知:深夜出生的候选日期与范围宽度会变)。
- `docs/tasks/README.md` 状态板加一行;实现合入 staging 的同一次推送里改状态。
## 6. 让步顺序
1. **最先保 T2BUG-983**。它是静默算错盘,比宽度显示错严重得多,而且不修的话 BUG-981 在这批人身上等于没上线。
2. 其次 T3 的**交付卡那一路**`unionStillValidRange``width_minutes`),它是用户直接看到的数字。
3. `indistinguishableWidthMinutes` 那一路可稍后——确认门目前被 `holdout not_ready` 挡着,宽度错不改变最终判定,但仍要修。
4. **不得砍掉「非跨午夜逐位不变」的对照**。砍了就无法把日期修复与打分变更区分开。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-midnight-date-anchor-20260920 \
.worktrees/rectification-midnight-date-anchor-20260920 origin/staging
cd .worktrees/rectification-midnight-date-anchor-20260920
git log --oneline -1 # 必须是 8d0359fc 或其后代
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 节三条提醒,尤其「真值覆盖率优先于区间宽度」);`BUG-985` 记录(同机 A/B 对照的正确写法,不得写死跨机浮点哈希)。
复现命令(Claude 用过,可直接抄):
```bash
PYTHONPATH=.:scripts python3 -c "
from scripts.active_rectification_event_engine import _candidate_datetimes
c=_candidate_datetimes({'birth_date':'2000-06-15','start_time':'23:55','end_time':'00:25','minute_step':1})
print([x for x in c if x.strftime('%H:%M')=='00:10'])"
PYTHONPATH=.:scripts python3 -c "
from scripts.rectification.decision_policy import _cluster_span
print(_cluster_span({'cluster_times':['23:58','23:59','00:00'],'time':'23:58'}))"
```
环境备忘:本机无 `.venv`,系统 `python3`3.13)可 `import swisseph``scripts/` 下的导入需要 `PYTHONPATH=.:scripts`。前端依赖可从主检出借用(`ln -s /workspace/Jyotisha/frontend/node_modules`),`npx tsx --test` 可跑;**无 Docker**`npm run test:db` 跑不了。
## 8. BUG 编号
沿用既有 **BUG-982 / BUG-983**(均为 `investigating`),**不新开编号**。当前最大号 985,如本单发现新问题再从 986 起。