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.
220 lines
17 KiB
Markdown
220 lines
17 KiB
Markdown
# 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. 硬红线
|
||
|
||
1. **不得调打分常数、权重、确认门阈值**。本单只改日期归属与跨度计算。
|
||
2. **不得为了让宽度好看而截断真值**:`AGENTS.md` Part B B4「真值覆盖率优先于区间宽度」。宽度变窄若以排除真值为代价,一律不放行。
|
||
3. **不得重标或删除历史结果**。日期口径变化必然使旧结果与新结果不可比;按 BUG-984 已建立的机制标记来源,不得静默覆盖。
|
||
4. 不得 bump Skill 版本(`BUG-621`:open 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: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. 让步顺序
|
||
|
||
1. **最先保 T2(BUG-983)**。它是静默算错盘,比宽度显示错严重得多,而且不修的话 BUG-981 在这批人身上等于没上线。
|
||
2. 其次 T3 的**交付卡那一路**(`unionStillValidRange` → `width_minutes`),它是用户直接看到的数字。
|
||
3. `indistinguishableWidthMinutes` 那一路可稍后——确认门目前被 `holdout not_ready` 挡着,宽度错不改变最终判定,但仍要修。
|
||
4. **D1 的追问可以先上、两段退路后上**:只要追问能覆盖绝大多数用户,不连续候选集的支持可以单独一轮。但**在两段退路就绪前,`late_night` 一律不得跨到 `D+1`**——宁可只搜 `[D 00:00–03:59]` 并说明只搜了后半夜,也不要搜到用户没申报过的日期上。
|
||
5. **不得砍掉「非跨午夜逐位不变」的对照**。砍了就无法把日期修复与打分变更区分开。
|
||
|
||
## 7. 开工前置命令
|
||
|
||
```bash
|
||
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 用过,可直接抄):
|
||
|
||
```bash
|
||
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 起。
|