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

12 KiB
Raw Blame History

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. 开工前置命令

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