Files
Jyotisha/docs/tasks/TASK-rectification-midnight-date-anchor-20260920.md
T
jesse-ux b85c4a686a
Independent Staging Quality Gate / validate (push) Successful in 13m27s
Independent Staging Quality Gate / publish (push) Failing after 1h0m1s
fix(rectification): anchor candidate windows to civil dates across midnight
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.
2026-09-21 02:55:00 +08:00

17 KiB
Raw Blame History

TASK · 跨午夜的日期锚点与簇跨度(BUG-982 / BUG-9832026-09-20

状态:执行中2026-09-20,实际基线 3be740f84;T1 首批红测已留证,见同主题 PROGRESS。产品对 D1 / D2 / D3 全部拍板,见 §3)。 两条 Bug 合并成一单,因为它们根因相同:线上契约只传钟点字符串,没有任何一侧拥有「这一分钟属于哪一天」。分开修会产生两套互相冲突的日期表示。

0. 基线与交付

  • 基线:origin/staging = 8d0359fc2026-09-20 实测核对)。
  • worktree .worktrees/rectification-midnight-date-anchor-20260920,分支 codex/rectification-midnight-date-anchor-20260920
  • 改动落点:scripts/rectification/**scripts/active_rectification_event_engine.pyfrontend/src/lib/rectification-agentic/**tests/**frontend/tests/**。全在 deploy/gated-paths.txt 内。
  • 串行依赖:本单改 active_rectification_event_engine.pydecision_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:5500:25 06-15 23:55 / 06-16 00:25 06-16 00:10 错,+1 天
00:02 23:4700:17 06-15 23:47 / 06-16 00:17 06-16 00:02 错,+1 天
12:00(对照) 11:4512:15 06-15 11:45 / 06-16 — 06-15 12:00

也就是说:用户说「6 月 15 日 00:10 出生」,引擎拿 6 月 16 日 00:10 当候选中心,整套盘算在错误的日期上——月亮差约 13°,Dasha 边界整体错一天。

受影响人群(申报分钟落在午夜后 半径 分钟内):

半径 受影响的申报时刻 占一天的比例
±15(默认) 00:0000:14 ≈1.0%
±30 00:0000:29 ≈2.1%
±60 00:0000:59 ≈4.2%
±120 00:0001:59 ≈8.3%

late_night 时段更严重。 declared-birth-window.ts 把它映射为 23:0003:59;按现行实现,候选是 D 23:00D+1 03:59其中 00:0003: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_rangetime 映射成当天分钟后取 min/max
frontend/src/lib/rectification-agentic/v9/candidate-plateau.ts indistinguishableWidthMinutes() clusterStart/clusterEndmax(ends) - min(starts) + 1

这条链直接走到用户眼前unionStillValidRange()reportWidthrectification-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:0003:59,要么 23:0023:59现行实现搜 D 23:00D+1 03:59,一半落在用户没申报过的日期上,是错的。

  • 首选:在选 late_night 时追问一次「是午夜之前还是午夜之后」。答午夜前 → [D 23:0023:59];答午夜后 → [D 00:0003:59]
  • 退路:用户说不知道 / 跳过 → 搜同日两段 [D 00:0003:59] [D 23:0023: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_spanunionStillValidRangeindistinguishableWidthMinutes)一次解决;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. 硬红线

  1. 不得调打分常数、权重、确认门阈值。本单只改日期归属与跨度计算。
  2. 不得为了让宽度好看而截断真值AGENTS.md Part B B4「真值覆盖率优先于区间宽度」。宽度变窄若以排除真值为代价,一律不放行。
  3. 不得重标或删除历史结果。日期口径变化必然使旧结果与新结果不可比;按 BUG-984 已建立的机制标记来源,不得静默覆盖。
  4. 不得 bump Skill 版本(BUG-621open RPC 要求绑定 Skill 等于当前版本)。
  5. 不得只修 _cluster_span 就宣称 BUG-982 已修——§1.2 的三处必须一起修,且要有贯穿的回归。
  6. 非跨午夜场景的候选、分数、宽度必须逐位不变,比照 BUG-985 已确立的同机 A/B 做法,不得写死跨机浮点哈希
  7. 不顺手升级依赖、不修不在本单内的 warning。

5. 任务分解

T1 · 先写失败测试(两条 Bug 各一组)

  • 983:断言申报 00:10 / 申报日 D 时,候选中存在 D 00:10不存在 D+1 00:10late_night 按 D1 拍板结果断言。
  • 982:断言跨午夜簇 ['23:58','23:59','00:00'] 的报告宽度为 3,不是 1440;三处(_cluster_spanunionStillValidRangeindistinguishableWidthMinutes)各有用例。
  • 另加一组非跨午夜对照,断言修复前后逐位不变。

验收标准:这两组在修复前必须红,修复后转绿;进度记录贴出修复前的失败输出。

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_minutesconfirmation_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-981BUG-984
  • CHANGELOG.md 记一行(用户可感知:深夜出生的候选日期与范围宽度会变)。
  • docs/tasks/README.md 状态板加一行;实现合入 staging 的同一次推送里改状态。

6. 让步顺序

  1. 最先保 T2BUG-983。它是静默算错盘,比宽度显示错严重得多,而且不修的话 BUG-981 在这批人身上等于没上线。
  2. 其次 T3 的交付卡那一路unionStillValidRangewidth_minutes),它是用户直接看到的数字。
  3. indistinguishableWidthMinutes 那一路可稍后——确认门目前被 holdout not_ready 挡着,宽度错不改变最终判定,但仍要修。
  4. D1 的追问可以先上、两段退路后上:只要追问能覆盖绝大多数用户,不连续候选集的支持可以单独一轮。但在两段退路就绪前,late_night 一律不得跨到 D+1——宁可只搜 [D 00:0003:59] 并说明只搜了后半夜,也不要搜到用户没申报过的日期上。
  5. 不得砍掉「非跨午夜逐位不变」的对照。砍了就无法把日期修复与打分变更区分开。

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,系统 python33.13)可 import swissephscripts/ 下的导入需要 PYTHONPATH=.:scripts。前端依赖可从主检出借用(ln -s /workspace/Jyotisha/frontend/node_modules),npx tsx --test 可跑;无 Dockernpm run test:db 跑不了。

8. BUG 编号

沿用既有 BUG-982 / BUG-983(均为 investigating),不新开编号。当前最大号 985,如本单发现新问题再从 986 起。