From 92e3d5e7a321b4a473b16fd9d028da0f24843b0e Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 9 Sep 2026 13:58:55 +0800 Subject: [PATCH] fix(rectification): count timeline width inclusively and keep eliminated minutes hollow (BUG-602, BUG-603) Co-authored-by: Cursor --- CHANGELOG.md | 7 + docs/BUG_HISTORY.md | 32 ++++ ...ESS-rectification-timeline-fix-20260909.md | 58 ++++++ docs/tasks/README.md | 2 +- .../rectification-timeline-20260909.md | 6 +- frontend/DESIGN.md | 4 +- .../components/rectification-agentic-chat.tsx | 1 + .../src/lib/rectification-candidate-result.ts | 26 +++ .../src/lib/rectification-timeline-scale.ts | 26 ++- .../rectification-candidate-result.test.ts | 36 ++++ .../rectification-timeline-20260909.test.ts | 165 ++++++++++++++++-- 11 files changed, 338 insertions(+), 25 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-timeline-fix-20260909.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f9128b..9df22062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-09 — 时间轴宽度按含两端分钟数来读,被排除的分钟留在原地变空心 + +生时校正对话上方那条时间轴,区间有多宽改成和卡片、验证报告同一套算法:从头到尾的分钟都算进去。所以 `05:07–05:09` 写「3 分钟」,`04:51–04:59` 写「9 分钟」,整天窗口写「24 小时」,单分钟写「1 分钟」,不会再比旁边的卡片少一分钟。 + +答完区分题之后,被排除的分钟也不会从条上消失:它们留在原来的位置变成空心点,还在范围内的仍是实心。这样能看见刚才那一答排掉了哪几分钟。还没有推断层的时候(刚选定时段),引擎给出的候选分钟全部实心。Skill 版本不变。 + + ## 2026-09-09 — 生时校正对话上方常驻一条时间轴,看得见范围在收窄 做生时校正时,对话上方多了一条固定的横条,写着当前的出生时间范围和它有多宽,例如「05:07–05:09 · 2 分钟」。下面那条轴按当前搜索范围铺开,候选分钟是一个个小圆点:还在范围内的是实心,已经被排除的变成空心留在原地——每答一题就能看见自己刚才那一答排掉了哪几分钟。范围收窄或放宽时,轴和圆点会滑到新位置。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5adf2dc7..c8bf9397 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9333,3 +9333,35 @@ - 相关记录:BUG-043(禁止把后台评分状态渲染成用户需要管理的面板。本处边界不同且不冲突:等待屏是只读的,展示的是用户交付物自身的章节结构,`attemptCount`、原始错误码、lease、job id、payload 一律不出服务端)、BUG-576、BUG-574 - 复发自:无 - 修复版本:待发布 + +## BUG-602 | 时间轴宽度按差值算,卡片与报告按含两端算 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`rectification-timeline-scale.ts` 区间读数 `widthLabel`、`frontend/DESIGN.md` §10 +- 用户现象:同一屏上时间轴写的宽度比三列卡和验证报告少 1 分钟。例如区间 04:51–04:59 条上写「8 分钟」、卡上写「9 分钟」;05:07–05:09 条上写「2 分钟」;整天窗口写「23 小时 59 分」。 +- 触发条件:分钟阶段已有 `credible_range`,或时段阶段轴铺满整天窗口。必现。 +- 根因:`widthLabel` 用 `timelineDurationLabel(bandEnd - bandStart)`,少算两端都计入的那一分钟。BUG-593 已把交付报告与三列卡定为含两端分钟数;任务书示例「05:07–05:09 · 2 分钟」是差值口径,首版时间轴照抄了。 +- 修复:改为 `timelineDurationLabel(bandEnd - bandStart + 1)`。整天 00:00–23:59 显示「24 小时」,单分钟显示「1 分钟」。DESIGN.md §10 示例改为「05:07–05:09 · 3 分钟」。 +- 验证:`frontend/tests/rectification-timeline-20260909.test.ts`(含两端宽度表:04:51–04:59 → 9 分钟、05:07–05:09 → 3 分钟、04:51–04:53 → 3 分钟、单分钟 → 1 分钟、整天 → 24 小时);与 `frontend/tests/rectification-delivery-report-facts.test.ts` 的 `width_minutes === 3` 同一形状。 +- 防复发:时间轴读数必须与报告/卡片同一套含两端分钟数;禁止再把 `bandEnd - bandStart` 当宽度。 +- 相关记录:BUG-593 +- 复发自:无 +- 修复版本:待发布 + +## BUG-603 | 被排除的分钟到不了客户端,时间轴空心点画不出来 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`rectification-candidate-result.ts`、`rectification-timeline-scale.ts`、`rectification-agentic-chat.tsx` 的 `timelineView` 候选来源 +- 用户现象:DESIGN.md §10 写「被排除的点留在原地变空心、不消失」;真实会话里答完一题后那些分钟从条上消失,剩下的全是实心。看不到刚才那一答排掉了哪几分钟。 +- 触发条件:分钟阶段已有推断层、至少淘汰过一个候选。必现。 +- 根因:`inference-adapter.ts` 投影只保留 `status !== "eliminated"` 的候选;时间轴把 `candidateResult.candidates.map(time)` 当点,再按是否落在区间带内判 in/out。到达客户端的点都在带内,全是实心;被淘汰的分钟根本不在列表里。测试里的 `["out","in","in","in","out"]` 是合成输入,不是这条数据路径。 +- 修复:`RectificationCandidateResult` 增加只读 `inferenceMarks: [{time, eliminated}]`,从 `decisionReceipt.inference_state.candidates` 的 `time` / `status` 解析(组件不读原始 receipt)。`state = status === "eliminated" ? "out" : "in"`,不再按点是否落在带内判断。没有 `inference_state` 时退回引擎候选全部实心。 +- 验证:`frontend/tests/rectification-timeline-20260909.test.ts`:推断层 9 个候选、7 个 eliminated、可信区间 04:51–04:53 → 9 个点、7 空心 2 实心;再淘汰 1 个 → 8 空心 1 实心,key 不变。`frontend/tests/rectification-candidate-result.test.ts`:有 `inference_state` 时解析出全集,无则 `[]`。聊天源码合同:`timelineView` 读 `inferenceMarks`,不读 `decisionReceipt`。 +- 防复发:时间轴标记必须来自推断层候选全集;禁止再用引擎 active 投影或带内位置当 in/out。空心/实心只看 `status === "eliminated"`。 +- 相关记录:BUG-560、DESIGN.md §10 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-timeline-fix-20260909.md b/docs/tasks/PROGRESS-rectification-timeline-fix-20260909.md new file mode 100644 index 00000000..c3602b9a --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-timeline-fix-20260909.md @@ -0,0 +1,58 @@ +# PROGRESS · 生时校正时间轴修复单:宽度含两端、空心点来自推断层(2026-09-09) + +工作树:`.worktrees/rectification-timeline-fix-20260909` +分支:`codex/rectification-timeline-fix-20260909` +任务书:`docs/tasks/TASK-rectification-timeline-fix-20260909.md` +基线:`origin/staging` @ `d9252516`(任务书写 `724a1215`;本 worktree 按开工指令从当时 `origin/staging` 切出) + +本单状态:**待验收**。未 push,未合入 staging/main,未声称生产部署。 + +预检:本机 `python3 scripts/pre_work_check.py` 失败(系统 Python 3.14 无 pytest、无 swisseph,ERR-078)。不把预检失败写成门禁已过。前端用 worktree 里已有的 `frontend/node_modules` 符号链接。 + +未改:二元编码、只读、固定条高、grid 行、transform-only、`use-conversation-scroll-anchor.ts`、`frontend/src/lib/rectification-agentic/v9/*`、Skill / `references/conversation-strategy.md`、composer-wrap / collect 按钮 / vargaSentence / opening / spoken copy。Skill 版本不变。 + +## 做了什么 + +- **BUG-602** `widthLabel = timelineDurationLabel(bandEnd - bandStart + 1)`,与 BUG-593 交付报告含两端分钟数同一形状。 +- **BUG-603** `RectificationCandidateResult.inferenceMarks` 从 `decisionReceipt.inference_state.candidates` 的 `time` / `status` 解析;组件只把该数组交给时间轴,不读 raw receipt。`state = eliminated ? "out" : "in"`,不再按点是否落在区间带内判断。无 `inference_state` 时引擎候选全部实心。 + +`inferenceMarks` 在类型上是可选的:`parseRectificationCandidateResult` **每次都写出数组**(没有推断层则为 `[]`)。若做成必填,会迫使改 `tests/rectification-range-delivery-20260907.test.ts` 里的结果字面量——该文件不在本单所有权内。 + +## 三栏 · widthLabel + +| 用例 | 原值 | 新值 | 理由 | +| --- | --- | --- | --- | +| 05:07–05:09 | `2 分钟` | `3 分钟` | 含 05:07、05:08、05:09 三分钟;与 DESIGN §10 / 报告口径一致 | +| 04:51–04:59 | `8 分钟` | `9 分钟` | 含两端;与三列卡同一形状 | +| 04:51–04:53 | `2 分钟` | `3 分钟` | 与 `rectification-delivery-report-facts.test.ts` 的 `width_minutes === 3` 同一形状 | +| 05:07–05:07 | `0 分钟` | `1 分钟` | 单分钟窗口 | +| 00:00–23:59 整天 | `23 小时 59 分` | `24 小时` | 1440 分钟;时段阶段尚未排除任何分钟 | +| 04:56–05:20(放宽后) | `24 分钟` | `25 分钟` | 含两端 | +| 00:30–01:30(跨午夜带) | `1 小时` | `1 小时 1 分` | 61 分钟,同一条 `+ 1` 规则 | + +## 三栏 · 标记改按 status + +| 用例 | 原值 | 新值 | 理由 | +| --- | --- | --- | --- | +| 合成 05:06…05:10,带 05:07–05:09,无 inferenceMarks | `["out","in","in","in","out"]`(按是否落在带内) | 全部 `"in"` | 无推断层时引擎候选全部实心,决策 2 | +| 同上,05:08 在带内但 `eliminated` | 05:08 会被判 `"in"` | 05:08 `"out"` | in/out 只看 `status === "eliminated"`,不看带 | +| 推断层 9 点、7 淘汰、带 04:51–04:53,引擎投影只有 2 个 active | 条上 2 个实心点 | 9 个点,7 空心 2 实心 | 客户端以前拿不到被淘汰分钟 | +| 下一轮再淘汰 1 个 | 点消失或 key 重建 | 8 空心 1 实心,key 不变 | 空心留在原地,过渡不重建 | + +## 测试 + +- `frontend` `./node_modules/.bin/tsc --noEmit`:exit 0 +- `npm run lint`:0 error / 108 warning(仓库既有 warning,无新增 error) +- 指定切片 `tsx --test tests/rectification-timeline-20260909.test.ts tests/rectification-delivery-report-facts.test.ts tests/rectification-candidate-result.test.ts`:**39 passed / 0 failed**(时间轴 22、候选解析 13、交付报告事实 4) +- 浏览器真人走查:无登录态,仍见 `docs/testing/rectification-timeline-20260909.md`;§4 第一条已改为「被排除的分钟变空心且不消失(数据来自推断层)」 + +## 偏离 + +1. 任务书基线写 `724a1215`;本 worktree 按开工指令基于当时 `origin/staging` `d9252516`。 +2. `inferenceMarks` 类型可选,解析器始终赋值。见上文。 +3. 跨午夜 00:30–01:30 读数从「1 小时」变为「1 小时 1 分」,任务书未点名,但是同一条含两端规则。 +4. 未改 `inference-adapter.ts`:active 投影仍只给卡片;时间轴改走推断层全集。 + +## 环境缺口 + +无登录态、无 Chrome、无 Docker。浏览器级空心点/贴底仍欠真实会话。不把合同测试写成已经看见真实像素。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index fac8bcf4..4de0e8fe 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -84,7 +84,7 @@ | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | | `TASK-rectification-timeline-20260909.md` | — | 常驻吸顶时间轴:轴锁**当前**搜索窗口并随放宽缩放、时段/分钟两套标记、候选点二元编码不分置信度(BUG-560 blocked)、无 hover(BUG-575)、只读不可采用。实现用第三个 grid 行而非 `position: sticky`,`useConversationScrollAnchor` 一行不改;条高固定是正确性要求;吸顶条只留区间与宽度两个元素 | 已验收通过;宽度口径与空心点两处未通过→修复单 | `ce91a0d2` | -| `TASK-rectification-timeline-fix-20260909.md` | `PROGRESS-rectification-timeline-fix-20260909.md` | 时间轴修复单:宽度读数改含两端分钟数(与 BUG-593 报告/卡片一致);标记改读推断层候选全集,被淘汰分钟留在原地变空心(客户端投影只含 active,DESIGN §10 描述在真实数据下画不出) | 待执行 | `codex/rectification-timeline-fix-20260909`(BUG-602~603) | +| `TASK-rectification-timeline-fix-20260909.md` | `PROGRESS-rectification-timeline-fix-20260909.md` | 时间轴修复单:宽度读数改含两端分钟数(与 BUG-593 报告/卡片一致);标记改读推断层候选全集,被淘汰分钟留在原地变空心(客户端投影只含 active,DESIGN §10 描述在真实数据下画不出) | 待验收 | `codex/rectification-timeline-fix-20260909`(BUG-602~603) | | `TASK-rectification-conversation-economy-20260909.md` | `PROGRESS-rectification-conversation-economy-20260909.md` | 对照竞品后产品拍板三条:开场三句讲做法 + 一次收多件(推翻 opening brief「不要一次说完/不举例」与 SKILL L52);采集题「没有 / 记不清」按钮(不做示例骨架条);每轮只留一句(方法句进活动记录、点选旁白去领先落后、证据轮正文一句复述 + 服务端裁剪);Skill 10.0.19 | 待执行 | `codex/rectification-conversation-economy-20260909`(BUG-604~606) | ### 聊天主链路与首页 diff --git a/docs/testing/rectification-timeline-20260909.md b/docs/testing/rectification-timeline-20260909.md index 4a58a017..918be866 100644 --- a/docs/testing/rectification-timeline-20260909.md +++ b/docs/testing/rectification-timeline-20260909.md @@ -35,7 +35,7 @@ iOS Safari 与 Android Chrome 各走一次,在校正对话里点输入框唤 需要一个「完全不知道出生时间」的 Case 才能走到时段阶段。 -- [ ] 开场(整天或 ±120)时:条上是否**不出现**分钟圆点,区间带铺满整条轴,读数写整个窗口的宽度(如「23 小时 59 分」)? +- [ ] 开场(整天或 ±120)时:条上是否**不出现**分钟圆点,区间带铺满整条轴,读数写整个窗口的宽度(如「24 小时」)? - [ ] 选定时段后:轴是否重新对到新窗口,读数变小? - [ ] 进入分钟阶段后:候选圆点是否出现? - [ ] 触发一次放宽(吻合率低且代表分钟贴边缘,BUG-572):轴是否向外扩,区间带**没有**被裁掉或溢出轴外? @@ -43,11 +43,11 @@ iOS Safari 与 Android Chrome 各走一次,在校正对话里点输入框唤 ## 4. 二元编码与只读 -- [ ] 答完一道区分题后,被排除的分钟是否**留在原地变成空心**而不是消失? +- [ ] 答完一道区分题后,被排除的分钟是否**留在原地变成空心且不消失**(数据来自推断层)? - [ ] 所有圆点是否**一样大**?(不得有任何按可能性大小分级的迹象——BUG-560 状态是 blocked) - [ ] 鼠标悬停在条上任何位置:是否**没有**任何浮层、提示、文案变化? - [ ] 点击条上任何位置:是否**没有**任何反应?(采用只在交付卡上) -- [ ] 条上是否只有两样东西:轴,和形如 `05:07–05:09 · 2 分钟` 的读数?不应出现计数、图例、阶段名、「已对照 N 件经历」或任何注脚。 +- [ ] 条上是否只有两样东西:轴,和形如 `05:07–05:09 · 3 分钟` 的读数?不应出现计数、图例、阶段名、「已对照 N 件经历」或任何注脚。 ## 5. 跨午夜窗口 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 538490d5..5804d115 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -513,7 +513,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px;56px 等于既有的 `--rectification-jump-clearance`(44px 触控尺寸 + `space-3`),不新造常数。 -**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:07–05:09 · 2 分钟`)。以下一律不得加回来——每一条都是被明确砍掉的: +**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:07–05:09 · 3 分钟`)。以下一律不得加回来——每一条都是被明确砍掉的: | 不得出现 | 理由 | | --- | --- | @@ -528,7 +528,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 - **轴 = 当前搜索窗口(`candidate_range`),不是开场窗口。** 开场窗口不是定值(±15 / ±30 / ±60 / ±120 / 整天),且放宽会顶穿它(BUG-572:±15 → ±30 → ±60 → ±120)。窗口变化时轴重新对到新窗口,区间带与标记用 CSS transition 过渡到新位置(`prefers-reduced-motion` 由全局 `*` 规则收成一帧)。窗口不变时轴稳定,收窄照样读得出来。 - **候选标记一律等大,只分在范围内 / 已排除。** 不得按 `relativeSupport` / `probability_percent` 分级(尺寸、深浅、粗细都不行):BUG-560 状态是 blocked,候选间相对支持度 7–9 分(满分 100),校准显示分钟级几乎无区分力,分级显示等于用视觉编码放大统计上不显著的差异。被排除的点留在原地变空心、不消失——用户由此看见自己刚才那一答排掉了哪几分钟。 -- **时段阶段(`block_scan`)不画分钟点**,区间带覆盖整个窗口。那一刻确实什么都还没排除,写「23 小时 59 分」是事实而非占位。 +- **时段阶段(`block_scan`)不画分钟点**,区间带覆盖整个窗口。那一刻确实什么都还没排除,写「24 小时」是事实而非占位。 - 跨午夜的窗口(`late_night` 是 23:00–03:59,末端早于始端)按次日展开,轴保持单调。 - 条上不得出现 `rectification-step-state` 类名(BUG-575 防复发)。 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 6b59517b..d86bc9dc 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1421,6 +1421,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { searchWindow, credibleRange: candidateResult?.credibleRange ?? null, candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [], + inferenceMarks: candidateResult?.inferenceMarks ?? [], stage: caseStage, }); const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 92713057..6b8fe076 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -66,6 +66,11 @@ export type RectificationNatalRecast = Readonly<{ confirmation_allowed: false; }>; +export type RectificationInferenceMark = Readonly<{ + time: string; + eliminated: boolean; +}>; + export type RectificationCandidateResult = Readonly<{ resultId: string; candidates: readonly RectificationCandidate[]; @@ -95,6 +100,8 @@ export type RectificationCandidateResult = Readonly<{ credibleRange: readonly [string, string] | null; rangeDelivery: RangeDeliveryProjection | null; verificationReportMarkdown: string | null; + /** Inference-layer minutes including eliminated ones. Empty when no inference_state. */ + inferenceMarks?: readonly RectificationInferenceMark[]; }>; function record(value: unknown): Record | null { @@ -284,6 +291,24 @@ function parseCredibleRange(value: unknown): readonly [string, string] | null { return [start, end]; } +function parseInferenceMarks(receipt: Record | null): readonly RectificationInferenceMark[] { + const state = record(receipt?.inference_state); + if (!state || !Array.isArray(state.candidates)) return []; + const marks: RectificationInferenceMark[] = []; + const seen = new Set(); + for (const value of state.candidates) { + const row = record(value); + const clock = time(row?.time); + if (!clock || seen.has(clock)) continue; + seen.add(clock); + marks.push({ + time: clock, + eliminated: row?.status === "eliminated", + }); + } + return marks; +} + export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null { const snapshot = record(value); if (!snapshot || typeof snapshot.resultId !== "string") return null; @@ -372,6 +397,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification ?? verificationMarkdownFromUnknown( (snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined, ), + inferenceMarks: parseInferenceMarks(receipt), }; } diff --git a/frontend/src/lib/rectification-timeline-scale.ts b/frontend/src/lib/rectification-timeline-scale.ts index feff970d..54742d8d 100644 --- a/frontend/src/lib/rectification-timeline-scale.ts +++ b/frontend/src/lib/rectification-timeline-scale.ts @@ -45,7 +45,7 @@ export type RectificationTimelineView = Readonly<{ ticks: readonly TimelineTick[]; /** `05:07–05:09` */ rangeLabel: string; - /** `2 分钟` */ + /** `3 分钟` */ widthLabel: string; }>; @@ -131,12 +131,23 @@ function buildTicks(axisStart: number, axisEnd: number): TimelineTick[] { return ticks; } +export type TimelineInferenceMark = Readonly<{ + time: string; + eliminated: boolean; +}>; + export type RectificationTimelineInput = Readonly<{ /** `candidate_range`: the case's current search window, and the axis. */ searchWindow: readonly [string, string] | null; /** `credible_range`; absent during block scan, where the band is the window. */ credibleRange: readonly [string, string] | null; candidateTimes: readonly string[]; + /** + * Inference-layer minutes with elimination status. When present and non-empty, + * these are the marks; `eliminated` is the only in/out judge. When absent, + * engine `candidateTimes` are drawn solid. + */ + inferenceMarks?: readonly TimelineInferenceMark[]; stage: RectificationTimelineStage | null; }>; @@ -177,9 +188,12 @@ export function buildRectificationTimeline( // boundaries do not reach the client as structured data (see PROGRESS). const marks: TimelineMark[] = []; if (input.stage === "minute") { + const sourced = input.inferenceMarks && input.inferenceMarks.length > 0 + ? input.inferenceMarks.map((mark) => ({ time: mark.time, eliminated: mark.eliminated })) + : input.candidateTimes.map((time) => ({ time, eliminated: false })); const seen = new Set(); - for (const raw of input.candidateTimes) { - const parsed = parseClockMinutes(raw); + for (const item of sourced) { + const parsed = parseClockMinutes(item.time); if (parsed === null) continue; const at = alignToAxis(parsed, axisStart, axisEnd); if (at < axisStart || at > axisEnd) continue; @@ -188,8 +202,8 @@ export function buildRectificationTimeline( marks.push({ key: `m${at}`, percent: timelinePercent(at, axisStart, axisEnd), - // Closed interval: a candidate sitting on a boundary is still in range. - state: at >= bandStart && at <= bandEnd ? "in" : "out", + // Status from the inference layer, never band position (BUG-603). + state: item.eliminated ? "out" : "in", }); } marks.sort((left, right) => left.percent - right.percent); @@ -203,6 +217,6 @@ export function buildRectificationTimeline( marks, ticks: buildTicks(axisStart, axisEnd), rangeLabel: `${formatClockMinutes(bandStart)}–${formatClockMinutes(bandEnd)}`, - widthLabel: timelineDurationLabel(bandEnd - bandStart), + widthLabel: timelineDurationLabel(bandEnd - bandStart + 1), }; } diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts index 43ad5123..1ee5b16f 100644 --- a/frontend/tests/rectification-candidate-result.test.ts +++ b/frontend/tests/rectification-candidate-result.test.ts @@ -307,4 +307,40 @@ test("session_outcome gates adopt cards independently of leaked can_adopt", () = assert.equal(canShowRectificationReadonlyRange(adopted), false); }); +test("parses inference marks from decisionReceipt.inference_state.candidates", () => { + const times = [ + "04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59", + ] as const; + const result = parseRectificationCandidateResult({ + ...camelCaseSnapshot, + candidates: [ + { candidateId: CANDIDATE_ID, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 }, + { candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 }, + ], + credible_range: ["04:51", "04:53"], + decisionReceipt: { + ...camelCaseSnapshot.decisionReceipt, + inference_state: { + candidates: times.map((clock) => ({ + time: clock, + status: clock === "04:51" || clock === "04:53" ? "active" : "eliminated", + })), + }, + }, + }); + assert.ok(result); + assert.ok(result.inferenceMarks); + assert.equal(result.candidates.length, 2); + assert.deepEqual(result.inferenceMarks.map((mark) => mark.time), [...times]); + assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7); + assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:51")?.eliminated, false); + assert.equal(result.inferenceMarks.find((mark) => mark.time === "04:45")?.eliminated, true); +}); + +test("inference marks are empty when inference_state is absent", () => { + const result = parseRectificationCandidateResult(camelCaseSnapshot); + assert.ok(result); + assert.deepEqual(result.inferenceMarks, []); +}); + diff --git a/frontend/tests/rectification-timeline-20260909.test.ts b/frontend/tests/rectification-timeline-20260909.test.ts index 15b9b953..2a1edb98 100644 --- a/frontend/tests/rectification-timeline-20260909.test.ts +++ b/frontend/tests/rectification-timeline-20260909.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; import { buildRectificationTimeline, formatClockMinutes, @@ -86,7 +87,8 @@ test("the band is placed against the axis and never escapes it", () => { assert.equal(Math.round(view.bandWidthPercent), 7); assert.ok(view.bandStartPercent + view.bandWidthPercent <= 100); assert.equal(view.rangeLabel, "05:07–05:09"); - assert.equal(view.widthLabel, "2 分钟"); + // Inclusive of both ends: 05:07, 05:08, 05:09. Exclusive difference was "2 分钟". + assert.equal(view.widthLabel, "3 分钟"); // A range reported wider than the window is clamped rather than overflowing. const overflowing = buildRectificationTimeline({ @@ -100,7 +102,33 @@ test("the band is placed against the axis and never escapes it", () => { assert.equal(overflowing.bandWidthPercent, 100); }); -test("candidate marks are binary and the range boundary counts as inside", () => { +test("candidate marks follow inference status, not band position", () => { + const view = buildRectificationTimeline({ + searchWindow: declaredWindow, + credibleRange: ["05:07", "05:09"], + candidateTimes: ["05:07", "05:08", "05:09"], + inferenceMarks: [ + { time: "05:06", eliminated: true }, + { time: "05:07", eliminated: false }, + { time: "05:08", eliminated: true }, + { time: "05:09", eliminated: false }, + { time: "05:10", eliminated: true }, + ], + stage: "minute", + }); + assert.ok(view); + // 05:08 sits inside the band but is eliminated → hollow. Position vs band is not the judge. + assert.deepEqual(view.marks.map((mark) => [mark.key, mark.state]), [ + ["m306", "out"], + ["m307", "in"], + ["m308", "out"], + ["m309", "in"], + ["m310", "out"], + ]); + assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true); +}); + +test("without inference marks every engine candidate is solid", () => { const view = buildRectificationTimeline({ searchWindow: declaredWindow, credibleRange: ["05:07", "05:09"], @@ -108,12 +136,7 @@ test("candidate marks are binary and the range boundary counts as inside", () => stage: "minute", }); assert.ok(view); - assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "in", "in", "out"]); - // Closed interval: both ends of the range are in, not on the fence. - assert.equal(view.marks[1]?.state, "in"); - assert.equal(view.marks[3]?.state, "in"); - // No third state exists to grade confidence with. - assert.equal(view.marks.every((mark) => mark.state === "in" || mark.state === "out"), true); + assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in", "in", "in"]); }); test("marks outside the window are dropped and duplicates collapse", () => { @@ -135,11 +158,11 @@ test("block scan draws the window as the range and no minute marks", () => { stage: "block_scan", }); assert.ok(view); - // Saying "23 小时 59 分" here is the truth: nothing has been ruled out yet. + // Inclusive 00:00–23:59 is a full day. Exclusive difference was "23 小时 59 分". assert.equal(view.bandStartPercent, 0); assert.equal(view.bandWidthPercent, 100); assert.equal(view.rangeLabel, "00:00–23:59"); - assert.equal(view.widthLabel, "23 小时 59 分"); + assert.equal(view.widthLabel, "24 小时"); assert.deepEqual(view.marks, []); }); @@ -176,7 +199,8 @@ test("widening the window rescales the axis and keeps the band inside it", () => // The widened range still fits: an axis pinned to the opening window would not hold it. assert.ok(after.bandStartPercent >= 0); assert.ok(after.bandStartPercent + after.bandWidthPercent <= 100); - assert.equal(after.widthLabel, "24 分钟"); + // 04:56–05:20 inclusive is 25 minutes. Exclusive difference was "24 分钟". + assert.equal(after.widthLabel, "25 分钟"); }); test("a window that crosses midnight stays monotonic", () => { @@ -191,11 +215,126 @@ test("a window that crosses midnight stays monotonic", () => { assert.equal(view.axisStartLabel, "23:00"); assert.equal(view.axisEndLabel, "03:59"); assert.equal(view.rangeLabel, "00:30–01:30"); - assert.equal(view.widthLabel, "1 小时"); - assert.deepEqual(view.marks.map((mark) => mark.state), ["out", "in", "out"]); + // Inclusive 00:30–01:30 is 61 minutes. Exclusive difference was "1 小时". + assert.equal(view.widthLabel, "1 小时 1 分"); + assert.deepEqual(view.marks.map((mark) => mark.state), ["in", "in", "in"]); assert.ok(view.marks.every((mark) => mark.percent >= 0 && mark.percent <= 100)); }); +test("widthLabel counts both clock ends, matching the delivery-report inclusive minute shape", () => { + const label = ( + searchWindow: readonly [string, string], + credibleRange: readonly [string, string] | null, + ) => buildRectificationTimeline({ + searchWindow, + credibleRange, + candidateTimes: [], + stage: credibleRange ? "minute" : "block_scan", + })?.widthLabel; + + assert.equal(label(["04:45", "05:15"], ["04:51", "04:59"]), "9 分钟"); + assert.equal(label(["04:45", "05:15"], ["05:07", "05:09"]), "3 分钟"); + assert.equal(label(["04:45", "05:15"], ["04:51", "04:53"]), "3 分钟"); + assert.equal(label(["04:45", "05:15"], ["05:07", "05:07"]), "1 分钟"); + assert.equal(label(["00:00", "23:59"], null), "24 小时"); +}); + +test("eliminated inference minutes stay as hollow dots and keep stable keys", () => { + const searchWindow = ["04:45", "05:15"] as const; + const credibleRange = ["04:51", "04:53"] as const; + const times = [ + "04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59", + ] as const; + const marksFor = (active: ReadonlySet) => times.map((time) => ({ + time, + eliminated: !active.has(time), + })); + + const first = buildRectificationTimeline({ + searchWindow, + credibleRange, + candidateTimes: ["04:51", "04:53"], + inferenceMarks: marksFor(new Set(["04:51", "04:53"])), + stage: "minute", + }); + assert.ok(first); + assert.equal(first.marks.length, 9); + assert.equal(first.marks.filter((mark) => mark.state === "out").length, 7); + assert.equal(first.marks.filter((mark) => mark.state === "in").length, 2); + const firstKeys = first.marks.map((mark) => mark.key); + + const second = buildRectificationTimeline({ + searchWindow, + credibleRange: ["04:53", "04:53"], + candidateTimes: ["04:53"], + inferenceMarks: marksFor(new Set(["04:53"])), + stage: "minute", + }); + assert.ok(second); + assert.equal(second.marks.length, 9); + assert.equal(second.marks.filter((mark) => mark.state === "out").length, 8); + assert.equal(second.marks.filter((mark) => mark.state === "in").length, 1); + assert.deepEqual(second.marks.map((mark) => mark.key), firstKeys); +}); + +test("timeline chat wiring reads parsed inferenceMarks and never the raw receipt", () => { + const timelineBlock = chat.slice( + chat.indexOf("const timelineView = buildRectificationTimeline"), + chat.indexOf("const persistedOfferKey"), + ); + assert.match(timelineBlock, /inferenceMarks:\s*candidateResult\?\.inferenceMarks/); + assert.doesNotMatch(timelineBlock, /decisionReceipt|inference_state/); +}); + +test("parsed inference_state marks reach the timeline even when engine candidates are only the active minutes", () => { + const activeId = "88888888-8888-4888-8888-888888888881"; + const secondId = "88888888-8888-4888-8888-888888888882"; + const times = [ + "04:45", "04:47", "04:49", "04:51", "04:52", "04:53", "04:55", "04:57", "04:59", + ] as const; + const result = parseRectificationCandidateResult({ + resultId: "11111111-1111-4111-8111-111111111111", + candidates: [ + { candidateId: activeId, rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 3 }, + { candidateId: secondId, rank: 2, time: "04:53", relativeSupport: 38, tiedMinuteCount: 3 }, + ], + overallConfidence: "low", + selectionAllowed: false, + canAdopt: false, + confirmationAllowed: false, + representativeTime: "04:53", + selectedTime: null, + selectionKind: null, + credibleRange: ["04:51", "04:53"], + decisionReceipt: { + inference_state: { + candidates: times.map((time) => ({ + time, + status: time === "04:51" || time === "04:53" ? "active" : "eliminated", + })), + }, + }, + }); + assert.ok(result); + assert.ok(result.inferenceMarks); + assert.equal(result.candidates.length, 2); + assert.equal(result.inferenceMarks.length, 9); + assert.equal(result.inferenceMarks.filter((mark) => mark.eliminated).length, 7); + + const view = buildRectificationTimeline({ + searchWindow: declaredWindow, + credibleRange: result.credibleRange, + candidateTimes: result.candidates.map((candidate) => candidate.time), + inferenceMarks: result.inferenceMarks, + stage: "minute", + }); + assert.ok(view); + assert.equal(view.marks.length, 9); + assert.equal(view.marks.filter((mark) => mark.state === "out").length, 7); + assert.equal(view.marks.filter((mark) => mark.state === "in").length, 2); + assert.equal(view.widthLabel, "3 分钟"); +}); + test("duration reads as clock language, not raw minutes", () => { assert.equal(timelineDurationLabel(2), "2 分钟"); assert.equal(timelineDurationLabel(59), "59 分钟");