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>
166 lines
12 KiB
Markdown
166 lines
12 KiB
Markdown
# 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` 与本分支两边取最大号再顺延,并在进度记录里写明取的是哪一侧。
|