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>
12 KiB
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. 硬红线
- 不得用消红代替修复:不得删除该测试、不得
skip/xfail、不得把哈希改成「本机当前算出的值」——最后一种只是把红转移给下一台机器。 - 不得改
scripts/rectification/dasha_transition_proximity.py与scripts/rectification/scoring_service.py。核心修复已验收通过(§1.2)。 - 不得动打分常数、确认门、Skill 版本、
INPUT_CONTRACT_VERSION、已封存的历史哈希。 - 不得因为改测试而降低覆盖:「同日不变性」这条回归必须仍然存在,且仍然留在快速门里。
- 处理 bridge 重复收集时,不得让原测试脱离快速门 glob。
- 不顺手升级依赖、不修不在本单内的 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.pydocstring 里已有的同类教训。BUG-981正文补一行:门禁级测试问题已单独立号BUG-985,核心修复本身经独立 review 通过。BUG-984正文补 F3 的结论。docs/tasks/README.md状态板加一行;实现合入 staging 的同一次推送里改状态。CHANGELOG.md不写(纯测试改动,无用户可感知行为变化)。
6. 让步顺序
- 最先保 F1 —— 它是
aa46da10的合入阻塞项,不修则整条跨午夜修复上不了 staging。 - 其次 F3 —— 只是查证,成本低,但它决定「跨午夜问题是否真的修好了」这句话能不能说。
- F2 可延后,但延后必须在进度记录里写明快速门中该组存在重复计数的事实,不得让后来人把 18 当成 18 条独立用例。
- 不得为赶工砍掉 F1 验收里「两台机器各跑一次」那条。 只在一台机器上绿,正是这次出问题的原因。
7. 开工前置命令
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
复现命令(应在与执行方不同的机器上看到红):
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 与本分支两边取最大号再顺延,并在进度记录里写明取的是哪一侧。