Files
Jyotisha/docs/tasks/TASK-rectification-cross-midnight-gate-fix-20260920.md
T
jesse-uxandClaude Code 25232ce4ce test(rectification): compare same-day scores against legacy path in process
Replace machine-specific float hashes with strict score and matrix byte comparisons. Keep quick bridge coverage and document duplicate collection. Verify Windows/Linux float behavior and in-memory candidate-date reversal; record the separate-machine acceptance gap and BUG-984 end-to-end blocker.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 14:38:29 +08:00

166 lines
12 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 · 跨午夜修复 review 修复单:门禁级浮点哈希断言(2026-09-20)
> 状态:本地实现完成,待第二台独立机器验收,未合入 staging。执行记录见 `PROGRESS-rectification-cross-midnight-gate-fix-20260920.md`。**这是 `aa46da10` 的合入阻塞项**:核心修复本身已验收通过,卡住的是随它新增的一条测试会让快速门在别的机器上红。
> 本单只改测试断言方式,**不改被修的打分代码**。
## 0. 基线与交付
- 基线:分支 `codex/rectification-cross-midnight-20260920` = `aa46da10`(不是 `staging`;本单改的是该分支上的新增测试)。
- 参照:`origin/staging` 在 review 时为 `f8e40e5d`;`aa46da10` 的分叉点是 `03cba478`。
- worktree `.worktrees/rectification-cross-midnight-gate-fix-20260920`,分支从 `aa46da10` 起,或直接在原分支上追加提交。
- **合入顺序**:本单修完 → `aa46da10` 方可快进推 `staging` → 之后才做 `TASK-rectification-cross-midnight-dasha-fix-20260920.md`(BUG-984 缓存身份补单)。三者串行,不得并行改同一批测试文件。
## 1. 事故实证
Claude 2026-09-20 在 `aa46da10` 上独立 review,以下全部为实测。
### 1.1 断言写法
`tests/test_dasha_transition_proximity_cross_midnight.py` 的 `test_same_day_public_aa_scores_keep_pre_fix_bytes` 用 `hashlib.sha256(_canonical(scores)).hexdigest()` 把 121 个分数的 canonical JSON 哈希**写死成字面量**,三个 ordinal 各一个。
### 1.2 实测:该测试在别的机器上红,且与修复无关
| 运行环境 | 结果 |
| --- | --- |
| `aa46da10`(修复分支),Claude 本机 | `ordinal 2`、`ordinal 3` **红** |
| `03cba478`(修复前基线),把同一测试文件拷过去跑 | `ordinal 2`、`ordinal 3` **同样红** |
两侧红的是同一组,**说明不是本轮修复造成的**,是跨机浮点差异。
进一步用同机对照确认修复无辜:以该测试自己的 `shifted_window(case, 0, 60)` 口径,在基线与修复分支各算一次 121 个分数——
| ordinal | 基线哈希前 16 位 | 修复分支哈希前 16 位 | 是否相同 |
| --- | --- | --- | --- |
| 1 | `5e47d5b17c9534f1` | `5e47d5b17c9534f1` | 是 |
| 2 | `b371f6430c7adfc8` | `b371f6430c7adfc8` | 是 |
| 3 | `3fb2b626d86eb7c3` | `3fb2b626d86eb7c3` | 是 |
**同日分数在基线与修复之间逐位不变**(另经半径 10 的 6 例独立复核,同样 6/6 相同;执行方自己的 `docs/testing/rectification-cross-midnight-scoring-evidence-20260920.json` 覆盖 19 例 / 2299 候选,全部 `scores_bytes_equal: true`)。红的原因只是本机算出的字节与写死的字面量不同。
### 1.3 影响面:它在快速门里
`tests/test_rectification_cross_midnight_gate.py` 把该用例 re-export,文件名命中 `scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS` 中的 `tests/test_rectification_*.py`。实测同 glob:
| 分支 | 结果 |
| --- | --- |
| `03cba478` | **214 passed / 0 failed** |
| `aa46da10` | **4 failed**(2 条 × 2,bridge 重复收集) |
即:推 `staging` 会触发 `backend-quality-gate`,而该门在浮点行为与执行方机器不一致的任何机器上都会红。
### 1.4 差异不是 repr 噪声,是第 4 位真的不同
实测该组 121 个分数全部满足 `repr(s) == repr(round(s, 4))`,**0 个带浮点尾噪**。所以哈希不同意味着至少一个分数在第 4 位小数上真的不同,属跨机 libm / pyswisseph 层面的差异,不是 JSON 序列化噪声。这类差异无法靠「多取几位」或「统一序列化」消除。
### 1.5 同一提交里引用了这条教训,又踩了它
`tests/test_rectification_engine_memoization.py` 的 docstring(本轮刚被同一提交修改过)明写:跨机 libm / pyswisseph 舍入**已经**让 `margin_percent` 漂移 `1.1e-3`,因此**不得**对整份 payload 做 `==` 比较。写死浮点分数的 SHA-256 比整份 `==` 更严格。
### 1.6 执行方自己的证据文件用的是正确做法
`docs/testing/rectification-cross-midnight-scoring-evidence-20260920.json` 的 `same_day.comparisons` 每条记的是 `scores_bytes_equal` / `matrix_bytes_equal`——**同一台机器上基线与当前的对照**,而不是跨机硬编码哈希。正确做法已经在仓库里,只是没有用在测试断言上。
## 2. 根因
这条回归要证的是「修复不改同日分数」,那是一个**相对**不变量:同机、同环境下,基线行为与当前行为一致。但它被实现成了**绝对**不变量:当前行为等于某一台机器在某一时刻算出的字节。绝对写法把 libm / pyswisseph 的版本差异一并纳入了断言范围,于是换机器就红,而红的信息与要守护的性质无关。
## 3. 决策记录
- **2026-09-20 产品要求就本项出修复单。**
- 本单只改测试断言方式。`aa46da10` 的核心修复(`dasha_transition_proximity.py` 取候选日期、缓存键带日期、`scoring_service.py` 版本号 7→8)已由 Claude 独立验收通过,**不在本单改动范围**。
- BUG-984(时段缓存不校验算法身份)另有补单,串行在本单与 `aa46da10` 合入之后。
## 4. 硬红线
1. **不得用消红代替修复**:不得删除该测试、不得 `skip` / `xfail`、不得把哈希改成「本机当前算出的值」——最后一种只是把红转移给下一台机器。
2. **不得改** `scripts/rectification/dasha_transition_proximity.py` 与 `scripts/rectification/scoring_service.py`。核心修复已验收通过(§1.2)。
3. 不得动打分常数、确认门、Skill 版本、`INPUT_CONTRACT_VERSION`、已封存的历史哈希。
4. **不得因为改测试而降低覆盖**:「同日不变性」这条回归必须仍然存在,且仍然留在快速门里。
5. 处理 bridge 重复收集时,不得让原测试脱离快速门 glob。
6. 不顺手升级依赖、不修不在本单内的 warning。
## 5. 任务分解
### F1 · 把绝对哈希换成同机相对比对(BUG-985)
**推荐做法**:用生产代码里**已经存在**的 legacy 回退路径造出「修复前行为」,在同一次运行内对照。
`merge_transition_proximity()` 对缺少 `candidate_at` 的上下文仍回退到请求的 `birth_date`(即修复前的行为,该分支本轮已保留并有专门测试 `test_legacy_context_without_candidate_at_retains_request_date` 覆盖)。因此同日窗口下:
- A 组:正常 contexts(带 `candidate_at`,走新路径)
- B 组:同一批 contexts 去掉 `candidate_at`(走 legacy 路径 = 修复前行为)
- 断言 **A 组分数与 B 组分数逐位相等**
这条断言在任何机器上都成立(因为同日窗口下候选日期本就等于 `birth_date`),且直指要守护的性质,不含任何跨机字面量。
**备选做法**(若 A/B 构造受限):改为结构性断言——同日窗口下,`_vim_start_dates` / `_narayana_start_dates` 收到的日期参数集合恰为 `{birth_date}`。该文件已有同型写法(`test_every_candidate_uses_own_date_and_caches_do_not_cross_dates` 用的就是捕获调用参数)。
验收标准:
- 三个 ordinal 全绿,且**在至少两台浮点环境不同的机器上各跑一次**——执行方自己的机器 + 一台 Linux 全依赖环境;两侧都绿才算通过。做不到两台就在进度记录写成环境缺口,不得写成通过。
- 把核心修复临时回退(令 `candidate_date` 恒等于 `birth_date`)后,该测试**仍然绿**——证明它守的是同日不变性,不是跨午夜回归的替身;跨午夜回归由 `test_real_cross_midnight_all_candidates_match_independent_dated_calculation` 负责,那条必须仍然在回退后转红。
- 文件内不再出现任何写死的分数哈希字面量。
### F2 · bridge 重复收集
当前 `tests/test_rectification_cross_midnight_gate.py` 的 re-export 让同一条用例在快速门里被收集两次(实测 4 failed = 2 × 2)。确认这是刻意还是副作用:
- 若刻意(只为让 glob 命中),在文件顶部注释写明「用例会被重复收集,计数非独立用例数」——执行方进度记录里已有「桥接重复收集的 14 项,不能算 14 项独立用例」的说法,把它落到代码注释里。
- 若非刻意,改为不产生重复计数的挂载方式。
验收标准:快速门里该组的用例数与直接跑原文件一致,或有书面理由写在文件内。
### F3 · 查证 `block_scan` 重算是否经过被修 helper(决定 BUG-984 优先级)
这是 Claude review 的遗留问题,成本低但改变 BUG-984 的定级:
- `frontend/src/lib/rectification-agentic/v9/score-persist.ts` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true`,不读 `readV9EngineScoringIdentity()`;`minute` 分支有 `cachedEngineScoreIsReusable` 身份门。
- `late_night`(`23:00–03:59`,299 分钟)与 `unknown`(1439 分钟)都超过 `MINUTE_GRID_MAX_SPAN_MINUTES = 120`,因此都走 `block_scan`——**而这两类窗口恰恰是最容易跨午夜的**。
要查证的是:`block_scan` 的真实重算路径是否会调用到 `merge_transition_proximity()`。
- **若会**:BUG-981 在 `late_night` / `unknown` 路径上等于没上线,BUG-984 升级为 BUG-981 的阻塞项,必须在宣称跨午夜问题已修之前解决。
- **若不会**:BUG-984 与本次修复解耦,可按原优先级排。
验收标准:给出调用链证据(从 `block_scan` 分支一路到 `merge_transition_proximity`,或证明不可达),结论写进 `BUG-984` 正文与其补单的 §0。**不得以「大概会」结案。**
### F4 · 记录
- `docs/BUG_HISTORY.md` 新增 `BUG-985`(门禁级浮点哈希断言),关联 `BUG-981`;在「防复发」里写明:**同日/不变性类回归一律用同机相对比对,不得写死跨机浮点字面量**,并引用 `test_rectification_engine_memoization.py` docstring 里已有的同类教训。
- `BUG-981` 正文补一行:门禁级测试问题已单独立号 `BUG-985`,核心修复本身经独立 review 通过。
- `BUG-984` 正文补 F3 的结论。
- `docs/tasks/README.md` 状态板加一行;实现合入 staging 的同一次推送里改状态。
- `CHANGELOG.md` 不写(纯测试改动,无用户可感知行为变化)。
## 6. 让步顺序
1. **最先保 F1** —— 它是 `aa46da10` 的合入阻塞项,不修则整条跨午夜修复上不了 staging。
2. 其次 F3 —— 只是查证,成本低,但它决定「跨午夜问题是否真的修好了」这句话能不能说。
3. F2 可延后,但延后必须在进度记录里写明快速门中该组存在重复计数的事实,不得让后来人把 18 当成 18 条独立用例。
4. **不得为赶工砍掉 F1 验收里「两台机器各跑一次」那条。** 只在一台机器上绿,正是这次出问题的原因。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-cross-midnight-gate-fix-20260920 \
.worktrees/rectification-cross-midnight-gate-fix-20260920 \
origin/codex/rectification-cross-midnight-20260920
cd .worktrees/rectification-cross-midnight-gate-fix-20260920
git log --oneline -1 # 必须是 aa46da10 或其后代
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45
```
复现命令(应在与执行方不同的机器上看到红):
```bash
python3 -m pytest tests/test_dasha_transition_proximity_cross_midnight.py -q
python3 -m pytest tests/test_rectification_*.py -q # 快速门同 glob
```
环境备忘:Claude 的 review 环境为 Linux + 系统 `python3` 3.13(可 `import swisseph`),无项目 `.venv`;frontend **无 `node_modules`**,前端侧检查需在有依赖的环境做。执行方环境为 Windows + Python 3.11.7。两者浮点行为不同,正是本单的起因。
## 8. BUG 编号起点
`docs/BUG_HISTORY.md` 在 `origin/staging`(`f8e40e5d`)上的最大号为 **980**;分支 `aa46da10` 已占用 **981–984**(均为 `investigating`,尚未合入 staging)。本单从 **BUG-985** 起。开工时对 `staging` 与本分支两边取最大号再顺延,并在进度记录里写明取的是哪一侧。