Carry explicit local date intervals instead of inferring the day from clock order. Cluster width, delivery, adoption, and reports keep the actual civil date; adopted date is stored separately from the reported birth_date. Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation thresholds, and Skill version are unchanged. Isolated Linux final-3 gates passed; four pre-existing Python failures remain. This is not a production release.
17 KiB
TASK · 跨午夜的日期锚点与簇跨度(BUG-982 / BUG-983,2026-09-20)
状态:执行中(2026-09-20,实际基线
3be740f84;T1 首批红测已留证,见同主题 PROGRESS。产品对 D1 / D2 / D3 全部拍板,见 §3)。 两条 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. 决策记录
2026-09-20 产品对三点全部拍板,均采纳 Claude 建议。
D1 · late_night 语义:先问用户,答不上退「同日两段」
出生证明记日历日期,所以「6 月 15 日深夜」出生的人,出生瞬间必定在 15 日:要么 00:00–03:59,要么 23:00–23:59。现行实现搜 D 23:00 → D+1 03:59,一半落在用户没申报过的日期上,是错的。
- 首选:在选
late_night时追问一次「是午夜之前还是午夜之后」。答午夜前 →[D 23:00–23:59];答午夜后 →[D 00:00–03:59]。 - 退路:用户说不知道 / 跳过 → 搜同日两段
[D 00:00–03:59] ∪ [D 23:00–23:59],不得擅自选一段,更不得跨到D+1。 - 这一问必须符合本仓既有口径:先等用户说,不用生日推年份;答不上来要能顺畅跳过,不得变成卡住流程的必答题。新增提问对照
frontend/docs/VOICE.md。
连锁范围(本单必须一并处理):退路会产生不连续的候选集。以下都要支持两段而不是假定一段连续区间:候选枚举、签名聚类与簇跨度、交付区间(unionStillValidRange)、宽度计算、交付卡的范围表述。不得用「取两段的最小到最大」把它糊成一段——那正是 BUG-982 的同型错误。
D2 · 申报贴近午夜时,允许窗口跨到前一天
用户说「6 月 15 日 00:10」、±15 分钟时,真实瞬间可能是 06-14 23:55。允许窗口覆盖到前一天。
依据:时间不确定必然带来日期不确定;在 00:00 截断会把真值排除在窗外,而 AGENTS.md Part B B4 的红线是真值覆盖率优先于区间宽度。
附带义务:当交付区间或被采用的候选落在前一天时,必须在交付层说明「若落在 23:5x,出生日期是前一天」,不得静默改变用户的出生日期。见 T4。
D3 · 日期进契约的方式:加窗口相对序号
候选除钟点外携带一个窗口内相对序号(0..N-1)。所有排序、取首尾、算跨度一律用序号,钟点只用于展示。
这样 §1.2 的三处(_cluster_span、unionStillValidRange、indistinguishableWidthMinutes)一次解决;BUG-983 由请求侧明确锚点解决,两者不互相纠缠。
注意(红线 3 的具体化):序号入契约会改变候选身份与缓存指纹。必须核对是否触发 BUG-984 刚建立的 scoringIdentityMatches 校验,并确认历史结果按既有规则只读、不被重标。这一条写进 T2 / T3 的验收。
D4 · 执行中补充授权:本轮打通跨日采用(2026-09-20)
独立审计发现,既有 adopt/confirm 只写 active_birth_time,新对话和星盘将其与原 birth_date 组合。仅修候选日期和展示,会把正确的前一日候选在采用后重新排成错误日期。
主会话向产品明确提供「只计算展示、暂禁异日采用」与「本轮打通跨日采用」两项;产品选择 本轮打通跨日采用。据此扩大本单范围,授权:
- 新增独立的已采用出生日期与来源持久化,服务端从已保存的候选事实原子写入日期及钟点;浏览器仍只提交 candidate/result/case 标识,不得自行指定采用日期。
- 保留原申报
birth_date不变,不得通过覆盖原始资料补救;历史结果不回填猜测日期、不重标。 - 同步新对话、账户星盘、报告等有效出生资料读取链,使用统一的已采用日期/钟点真相源;资料更改、改选与清理后的生命周期必须安全。
- 实施约束(代码核验后明确):既有候选引擎使用请求固定 UTC offset,并未按 IANA 逐候选重新解析。采用结果须同时保存并复用其实际计算 offset 与来源,不能采用后换用另一偏移造成不同星盘;本单不改变引擎 DST 算法,不把固定 offset 边界宣称已修。分段窗口在时段转分钟与刷新时须完整持久化日期,可在新增兼容迁移中扩展窗口 RPC,不得缩窗后再按钟点猜日期。
- 允许新增向后兼容的数据库迁移(新增字段、函数体或相应约束,不改已应用迁移、不删改原字段语义);部署前后兼容旧代码,必须真跑
npm run test:db。 - 增补 T4 验收:前一日与后一日候选采用后,新对话和星盘使用相同的正确日期;同日行为不变;未授权/伪造候选/过期基线不能写入;历史缺日期结果不被伪装为新日期契约。
此项扩大 §0 的改动落点至兼容迁移、profile/咨询/星盘等共享日期读取模块及相关测试。并行分工仅限文件互斥:主实现负责引擎、rectification-agentic/**、候选交付;采用实现负责新迁移及外围有效出生资料读取。共同文件由原负责人串行整合,不并行编辑。该授权不包括提升 main、生产发布、workflow/DNS 改动或打分阈值变化。
执行文件互斥补充:transition 实现独占 scripts/rectification/refinement_packet.py、前端 refinement-packet.ts / sign-from-transitions.ts / candidate-contrast-packet.ts / varga-observations.ts 与专用回归;交还冻结后由核心代理串行整合 segment-start marker 消费端和 lagna 段边界。核心代理负责新窗口迁移30000;采用代理负责新采用迁移20000及外围资料模块。无并行编辑 Home 或共享组件;主会话独立验收,不写在途实现。
4. 硬红线
- 不得调打分常数、权重、确认门阈值。本单只改日期归属与跨度计算。
- 不得为了让宽度好看而截断真值:
AGENTS.mdPart B B4「真值覆盖率优先于区间宽度」。宽度变窄若以排除真值为代价,一律不放行。 - 不得重标或删除历史结果。日期口径变化必然使旧结果与新结果不可比;按 BUG-984 已建立的机制标记来源,不得静默覆盖。
- 不得 bump Skill 版本(
BUG-621:open RPC 要求绑定 Skill 等于当前版本)。 - 不得只修
_cluster_span就宣称 BUG-982 已修——§1.2 的三处必须一起修,且要有贯穿的回归。 - 非跨午夜场景的候选、分数、宽度必须逐位不变,比照
BUG-985已确立的同机 A/B 做法,不得写死跨机浮点哈希。 - 不顺手升级依赖、不修不在本单内的 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:允许向前跨到 D-1)。请求契约必须明确表达锚点,不再让后端按钟点大小猜。late_night 按 D1 实现:追问一次,答不上退同日两段。
验收标准:
- 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. 让步顺序
- 最先保 T2(BUG-983)。它是静默算错盘,比宽度显示错严重得多,而且不修的话 BUG-981 在这批人身上等于没上线。
- 其次 T3 的交付卡那一路(
unionStillValidRange→width_minutes),它是用户直接看到的数字。 indistinguishableWidthMinutes那一路可稍后——确认门目前被holdout not_ready挡着,宽度错不改变最终判定,但仍要修。- D1 的追问可以先上、两段退路后上:只要追问能覆盖绝大多数用户,不连续候选集的支持可以单独一轮。但在两段退路就绪前,
late_night一律不得跨到D+1——宁可只搜[D 00:00–03:59]并说明只搜了后半夜,也不要搜到用户没申报过的日期上。 - 不得砍掉「非跨午夜逐位不变」的对照。砍了就无法把日期修复与打分变更区分开。
7. 开工前置命令
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 用过,可直接抄):
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 起。