From 1ba473970ff58eba4b2a21590feef67a7e2254e8 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sat, 17 Oct 2026 21:16:32 +0800 Subject: [PATCH] =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=9B=9B=E8=88=8D=E4=BA=94?= =?UTF-8?q?=E5=85=A5=E5=92=8C=E7=BC=BA=E5=8C=BA=E9=97=B4=E6=8A=8A=E8=BF=98?= =?UTF-8?q?=E5=8F=AF=E8=83=BD=E7=9A=84=E6=97=B6=E9=97=B4=E4=BB=8E=E7=9B=AE?= =?UTF-8?q?=E5=89=8D=E8=8C=83=E5=9B=B4=E9=87=8C=E8=97=8F=E6=8E=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Grok (agent loop) --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 17 +- ...fication-narrower-delivery-fix-20261017.md | 55 ++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 2 +- .../components/rectification-agentic-chat.tsx | 4 +- .../rectification-range-delivery.tsx | 4 +- .../rectification-agentic/core/build-state.ts | 15 +- .../core/candidate-window.ts | 76 ++- .../core/credible-range.ts | 36 +- .../lib/rectification-agentic/core/types.ts | 8 +- .../v9/agent-route-typed-message.ts | 10 +- .../v9/agent-run-attempt.ts | 4 +- .../rectification-agentic/v9/answer-choice.ts | 8 +- .../lib/rectification-agentic/v9/telemetry.ts | 6 +- ...tion-living-display-radius60-20261017.json | 542 ++++++++++++++++++ ...tification-living-display-20261016.test.ts | 231 +++++++- .../rectification-segment-persisted-replay.ts | 178 +++++- .../rectification/living_display_intervals.py | 79 --- .../varga_resolution_persisted_replay.py | 25 +- ..._rectification_living_display_intervals.py | 77 --- 21 files changed, 1165 insertions(+), 218 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-narrower-delivery-fix-20261017.md create mode 100644 frontend/tests/fixtures/rectification-living-display-radius60-20261017.json delete mode 100644 scripts/rectification/living_display_intervals.py delete mode 100644 tests/test_rectification_living_display_intervals.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e20f9910..ba9e3e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-17 — 生时校正的分段范围不再漏掉还可能的时间 + +卡片和旁白上的「目前范围」改为读已经算好的分段。差不到 8 分的候选不会因为四舍五入被藏掉。某一段缺少区间数据时,改回显示整段首尾,不再跳过那一段。打分、采用和确认没有改。Skill 版本不变(BUG-1370)。 + ## 2026-10-16 — 生时校正的交付范围按还活着的时间段显示 问完题之后,卡片和旁白不再把中间已经排除的空档算进「目前范围」。相邻不超过 4 分钟的段会并在一起;超过三段时只写出前三段,并写明一共几段、大约还剩多少分钟。打分、采用和确认没有改。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 53fec49b..f801ba90 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36498,7 +36498,7 @@ - 触发条件:同一声明窗口里有多段还活着的簇,段与段之间的空档大于 4 分钟。`segment_index` 是用户声明的窗口段,不是候选簇。 - 根因:`interval_union_width` 和 `unionWindowIntervals` 把同一声明段里的区间收成最早到最晚。交付卡标题走这条包络。 - 修复:卡片、旁白、口头可说的钟点和遥测宽度改用还活着的簇。落后峰值达到 8 分的候选不进这段显示,和现在的进区间门槛相同。相邻空档不超过 4 分钟的段合并;超过三段时只写前三段,并写一共几段、大约还剩多少分钟。打分、采用、确认、分离和 `MIN_SEPARATION_LEAD` 不动。写盘和读盘仍用同一个 8。会话标题仍由数据库里的单段生成,本轮不改表。 -- 验证:`frontend/tests/rectification-living-display-20261016.test.ts` 覆盖单段、空档 4 分钟合并、空档 5 分钟拆开、五段只显示前三段,以及卡片标题不再画出整段包络。`tests/test_rectification_living_display_intervals.py` 锁定同一套合并,并断言同一对区间的决策包络宽度不变。 +- 验证:`frontend/tests/rectification-living-display-20261016.test.ts` 覆盖单段、空档 4 分钟合并、空档 5 分钟拆开、五段只显示前三段,以及卡片标题不再画出整段包络。原先 `tests/test_rectification_living_display_intervals.py` 也锁同一套合并。BUG-1370 确认技能报告的「不可分宽度」由前端按决策包络填写,Python 没有用户看得到的范围句,删掉了这个没有调用方的文件。合并仍由上面的前端测试锁定。 - 防复发:上述测试。显示函数放在密封打分文件之外,打分路径不引用它。没有簇区间的旧结果仍用原来的首尾。 - 相关记录:BUG-423、BUG-560。 - 复发自:无。 @@ -36533,3 +36533,18 @@ - 相关记录:BUG-423、BUG-560、BUG-1367。 - 复发自:无。 - 修复版本:未修。 + +## BUG-1370 | 分段显示用取整分数,还会跳过缺区间的活候选 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-17 / 2026-10-17 +- 来源:issue #77 第 1 次验收打回。修复单 `docs/tasks/TASK-rectification-narrower-delivery-fix-20261017.md`。 +- 现象:交付卡上的「目前范围」有时比真正还可能的时间更窄。真实出生时间可能落在被藏起来的那段里。 +- 触发条件:两处。一是峰值 7.6、另一候选 0.4,实际差 7.2 分,仍该留下;卡片只有取整后的相对支持,变成差 8 分,被排除。二是某个还在范围内的候选没有簇区间,新显示直接跳过它,原来会退回整段首尾。 +- 根因:卡片和只读范围行用解析后的候选重算谁还活着。解析结果只保留 0–100 的整数相对支持,不保留原始 posterior_score。缺区间时函数跳过该候选,而不是整段退回。 +- 修复:写盘时用 `stillValidCandidates` 的原始 posterior_score 算出分段,写入 `living_display_intervals`。读盘只展示这份区间。没有这份字段,或任一还活着的候选缺区间,就用原来的首尾。门槛仍是 8。`credible_range` 的写盘和读盘都还是这一个 8,没有另起一套。打分、采用、确认、分离和 `MIN_SEPARATION_LEAD` 不动。技能报告里的「不可分宽度」仍由前端按决策包络填写,Python 里没有用户看得到的范围句,因此删掉没有调用方的 `scripts/rectification/living_display_intervals.py`。 +- 验证:`frontend/tests/rectification-living-display-20261016.test.ts` 覆盖差 7.2 分的两边都在显示里、一个活候选缺区间则没有分段、显示成员与 `stillValidCandidates` 相同,以及写盘把这两段存下来。没有存过分段的卡片仍显示整段首尾。另用开工 SHA `712a0fd2` 的一条已答完的 ±60 真实回放样本(只留分数和区间)锁定:显示成员与 `stillValidCandidates` 相同,标题段数和剩余分钟与该样本一致,空档大于 4 分钟。 +- 防复发:不得再用取整后的相对支持决定显示成员。不得在缺区间时只跳过那一个候选。不得只改写盘或只改读盘。不得用修改 `MIN_SEPARATION_LEAD` 来收窄或放宽显示。 +- 相关记录:BUG-423、BUG-1367。 +- 复发自:无。 +- 修复版本:分支 `codex/rectification-narrower-delivery-20261016`。 diff --git a/docs/tasks/PROGRESS-rectification-narrower-delivery-fix-20261017.md b/docs/tasks/PROGRESS-rectification-narrower-delivery-fix-20261017.md new file mode 100644 index 00000000..8dcf9bc3 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-narrower-delivery-fix-20261017.md @@ -0,0 +1,55 @@ +# 进度:生时校正交付区间收窄 · 第 1 次打回修复(2026-10-17) + +issue #77。分支 `codex/rectification-narrower-delivery-20261016`。修复单在 `origin/staging` 的 `docs/tasks/TASK-rectification-narrower-delivery-fix-20261017.md`。 + +## 结论 + +卡片和旁白上的「目前范围」改为读写盘时算好的分段。差 7.2 分、四舍五入后看起来像差 8 分的候选会留下。某个还活着的候选没有时间区间时,整张卡退回原来的首尾,不再把那一段藏起来。打分、采用、确认、分离门槛 8、出题闸 30/45 天都没有改。对半出题和把进区间门槛从 8 改成 6 这次仍然不做。 + +77 例基线回放还在跑,输出文件还没落地,所以没有包含率,也不能拿来和改完后的显示对照。没有另开第二份回放。 + +## 口径 + +| 位置 | 这一轮 | +| --- | --- | +| 交付卡、只读范围行、旁白、口头钟点、遥测宽度 | 读结果里已保存的 `living_display_intervals`。没有这份字段,或写盘时某个还活着的候选缺区间,就用原来的首尾 | +| 谁还活着 | 与 `stillValidCandidates` 相同,用原始 `posterior_score`,门槛仍是 8。不用取整后的相对支持 | +| `credible_range` / `credible_intervals` | 决策包络,门槛仍是 8 | +| 技能报告「不可分宽度」 | 仍由前端按决策包络填写(`candidate-plateau.ts`)。Python 里没有用户看得到的范围句 | +| 会话标题 | 仍是数据库函数里的单段。本轮不改表 | +| 对半出题、门槛 8 改 6 | 不做 | + +F4 选 **b**:删掉没有调用方的 `scripts/rectification/living_display_intervals.py` 和 `tests/test_rectification_living_display_intervals.py`。 + +## 漏段 + +- 写盘(`buildInferenceState`)用 `livingDisplayForInference` 按原始分数算出分段,随推断状态保存。 +- 读盘只展示保存下来的区间。旧结果没有这个字段时,标题仍是原来的首尾。 +- 真实样本:开工 SHA `712a0fd2` 上一条已经答过题的 ±60 回放,只留下分数和区间,日期改锚到 2000-01-01。样本有大于 4 分钟的空档。显示成员与 `stillValidCandidates` 相同,标题是 3 段、共 38 分钟。文件:`frontend/tests/fixtures/rectification-living-display-radius60-20261017.json`。手写的 7.2 分边界和缺区间用例仍在,没有放宽。 + +## 基线回放 + +沿用已经在跑的那一份,没有新开。进程是 2026-10-17 03:31 UTC 启动的 PowerShell pid 668。代码 SHA `712a0fd2a19947405a1dfb486bad96a7661d41c3`。工作树里的回放脚本和这份正在跑的副本哈希一致。 + +计划是 77 例 × 3 个窗口 × 4 种答题,共 924 条。写入本记录时,回放数据库里已有 473 条案例行,日志仍有新的打分请求。输出文件 `docs/research/rectification_narrower_delivery_baseline_2026_10_16.json` 还不存在。案例行数不是包含率,这里不写包含率、剩余分钟中位数或显示宽度中位数。 + +改完后的同一套对照(T1)没有开跑。基线还占着这套数据库,按交代不能再启动第二份。 + +## 测试 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 分段显示,含 7.2 分、缺区间、成员一致、真实 ±60 样本 | `node --import tsx --test tests/rectification-living-display-20261016.test.ts` | 13 pass,0 fail,0 cancelled | +| Python 显示单测 | 文件已按 F4-b 删除 | 未跑 | +| Python 快速门 | 本轮不跑 | 未跑 | +| `tsc`、`lint`、前端全量 `npm test` | 修复单没有点名 | 未跑 | + +## 红线 + +未改引擎打分、`SCORE_DELTA`、采用/确认门、`MIN_SEPARATION_LEAD`(仍是 8)、`MIN_BOUNDARY_DAYS=45`、`REFRESH_MIN_BOUNDARY_DAYS=30`。 + +## 其它 + +- 开工时本分支已提交的最大 BUG 号是 1369。本轮用 BUG-1370。`origin/staging` 的历史文件最大号是 1366。状态板上婚恋合盘写了「BUG-1370 起」,那一行还没有写进 `docs/BUG_HISTORY.md`。本条按本分支已提交最大号 +1 登记。 +- `git fetch` 后 `origin/staging` 为 `edf25918b2f14a4339740be505916ba41e65acb7`(修复单所在提交 `f95eb670` 之后还有 5 个文档提交)。本轮用 merge,不 rebase。 +- 临时目录 `G:\Ferti\tmp\replay-temp` 和 `G:\Ferti\tmp\baseline-712a0fd2` 不进仓库。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index ddc9fe9e..ba0887e2 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -36,7 +36,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-rectification-narrower-delivery-20261016.md` | — | **交付区间收窄**:显示改按活着的簇分段(±60 显示 71→实际 42 分钟)、按剩余候选对半出题并放宽到 12 轮(少数侧 <20% 不出题)、新增 `DELIVERY_RANGE_LEAD=6` 只管进区间(`MIN_SEPARATION_LEAD=8` 不动,写读同口径)。红线:PG 回放真值保留全对 ≥基线−1、答错一题 ≥基线−5pp,不过则该档回 8。证据 `docs/research/rectification_width_funnel_2026_10_16.md`(含象征星区间求交复现=随机)。BUG 1367 起 | 待领取 | — | +| `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线 77 例回放未结束,没有包含率。BUG-1367 起,本轮 BUG-1370 | 待验收 | `codex/rectification-narrower-delivery-20261016` | | —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) | | —(子代理直接执行,无任务书;产品 2026-09-27 授权) | `PROGRESS-bug-1061-cross-layer-pairing-20260927.md` | **双重过运跨层配对按名字数字比对(BUG-1061)**:改为结构化身份配对(D1 第 N 宫↔D9 第 N 宫;D1 第 N 宫主↔同一颗星的 D9 目标);各层结果与 D1/CL golden 逐字节不变;48 样本跨层条目 31→38;数据卡 golden 重采集(乔布斯 6→9、泰勒 9→10) | 已验收(待部署) | `codex/bug-1061-cross-layer-pairing-20260927`(未推送) | | `TASK-rectification-fewer-probes-card-20260926.md` | [PROGRESS](PROGRESS-rectification-fewer-probes-card-20260926.md) | **减少无效追问 + 卡片区间为主**:引导补件离线新增达标 0 → 上限 6→2、定向题问完门槛未达直接出卡;卡片区间为主标题、代表分钟副标题,第一二名差距 ≥5 个百分点才显示百分比,否则写「目前区分不开」。不放宽任何置信度。先做 | 已验收(真机清单欠) | `4e6e8d87` + 验收补改 `7203d94e`(区分不开时隐藏「最可能」),deploy-staging run 2933 已部署;回放真值 ±30/±60 19→20、宽度中位 ±1 分钟、提问 11.4→7.8;Skill 10.0.30;真机清单 `docs/testing/rectification-fewer-probes-card-20260926.md` 未走 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 4a64a57a..5c50171d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1025,7 +1025,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。 -**卡头以范围为主(D3,2026-09-26;2026-10-16 起按活着的段显示)。** 主标题仍是「目前范围 …(对照了 N 件经历)」(``,`--type-title-sm`)。范围不再把同一声明窗口里已经空出来的间隔涂成一整段:只画出还活着的簇,相邻空档不超过 4 分钟的并成一段;超过三段时只写最前面的三段,再写「等 N 段,共约 X 分钟」。没有簇区间的旧结果仍用原来的首尾。紧跟一行副标题「最可能 HH:MM」(`.rectification-range-delivery__most-likely`,`--type-body-md`、次级墨色、等宽数字),取 `range_delivery.representative_time`。副标题只是代表分钟的人话,不是确认,也不承诺定到分钟;卡底代表分钟边界句照旧。各列不写百分比(第一名领先不足 5 个百分点)时副标题也不出,免得「最可能」与「区分不开」并排自相矛盾。 +**卡头以范围为主(D3,2026-09-26;2026-10-16 起按活着的段显示)。** 主标题仍是「目前范围 …(对照了 N 件经历)」(``,`--type-title-sm`)。范围不再把同一声明窗口里已经空出来的间隔涂成一整段:只画出还活着的簇,相邻空档不超过 4 分钟的并成一段;超过三段时只写最前面的三段,再写「等 N 段,共约 X 分钟」。没有簇区间的旧结果仍用原来的首尾。2026-10-17 起,分段不在卡片上用四舍五入后的分数重算:写盘时按原始分数决定谁还在,区间随结果保存;旧结果没有这份区间时,标题仍用原来的首尾。某个还在范围内的候选缺少区间时,整张卡退回首尾,不把缺数据的那一段藏起来。紧跟一行副标题「最可能 HH:MM」(`.rectification-range-delivery__most-likely`,`--type-body-md`、次级墨色、等宽数字),取 `range_delivery.representative_time`。副标题只是代表分钟的人话,不是确认,也不承诺定到分钟;卡底代表分钟边界句照旧。各列不写百分比(第一名领先不足 5 个百分点)时副标题也不出,免得「最可能」与「区分不开」并排自相矛盾。 **百分比只在差距明显时出现(D4,2026-09-26)。** 前两列「相对可能性」相差 ≥ 5 个百分点(`RANGE_DELIVERY_PERCENT_MIN_GAP`)时每列照旧写「相对可能性 N%」;否则三列都不写数字,卡上(共享性格句之上)一句「这几个时刻按现在的方法区分不开。」(2026-09-29 起不再邀请补经历,BUG-1084)(`.rectification-range-delivery__indistinct`,`--type-body-md`、正文墨色)。只有一列时没有第二名可比,不写数字也不写这句。排序、列数、性格 / 经历 / 未来窗三行不变。理由:每件事约 11.5 分是窗口内恒定项、约 2.1 分随分钟变,比例分数天然挤在一起(26% / 26% / 20%),差距不明显的数字只会被读成「分出了高下」。 diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 9e15a2be..74d7c619 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -7,7 +7,7 @@ import { createPortal } from "react-dom"; import type { ChatMessage } from "@/lib/chat-message-view"; import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result"; import { RectificationRangeDelivery } from "@/components/rectification-range-delivery"; -import { livingDisplayFromCandidates } from "@/lib/rectification-agentic/core/candidate-window"; +import { livingDisplayFromReceipt } from "@/lib/rectification-agentic/core/candidate-window"; import { RECTIFICATION_EMPTY_ACTION_LABEL, RECTIFICATION_EMPTY_COPY, @@ -611,7 +611,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && ( ( + decision.result_status === "converged" && item.id === decision.winner_id + ? { ...item, status: "winner" as const } + : item + )); + const livingDisplay = livingDisplayForInference(deliveredCandidates); return { ...draft, phase: phaseFor(decision.result_status, draft.phase), result_status: decision.result_status, representative_time: decision.representative_time, credible_range: deliveredRange, - candidates: candidates.map((item) => ( - decision.result_status === "converged" && item.id === decision.winner_id - ? { ...item, status: "winner" } - : item - )), + candidates: deliveredCandidates, + living_display_intervals: livingDisplay?.intervals ?? null, }; } diff --git a/frontend/src/lib/rectification-agentic/core/candidate-window.ts b/frontend/src/lib/rectification-agentic/core/candidate-window.ts index d31dca64..76bf3055 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-window.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-window.ts @@ -181,14 +181,15 @@ export function mergeLivingDisplayIntervals( function clusterScore(row: LivingClusterSource): number | null { if (typeof row.posterior_score === "number" && Number.isFinite(row.posterior_score)) return row.posterior_score; - if (typeof row.relativeSupport === "number" && Number.isFinite(row.relativeSupport)) return row.relativeSupport; return null; } /** - * Living clusters inside `lead` of the peak, merged for display. - * Returns null when no candidate carries a cluster interval, so callers keep - * the historical envelope label. + * Legacy recompute. Delivery reads stored intervals instead. + * Parsed cards only have rounded relativeSupport, which this function ignores. + * Membership for a new result is livingDisplayForInference. + * Returns null when a living candidate has no cluster interval, or when a + * score is missing, so callers keep the envelope title. */ export function livingDisplayFromCandidates( candidates: readonly LivingClusterSource[] | null | undefined, @@ -196,15 +197,15 @@ export function livingDisplayFromCandidates( lead = MIN_SEPARATION_LEAD, ): LivingDisplay | null { if (!candidates?.length) return null; - const scored = candidates - .filter((row) => row.status !== "eliminated") - .map((row) => ({ row, score: clusterScore(row) })) - .filter((item): item is { row: LivingClusterSource; score: number } => item.score !== null); - if (!scored.length) return null; + const active = candidates.filter((row) => row.status !== "eliminated"); + if (!active.length || active.some((row) => clusterScore(row) === null)) return null; + const scored = active.map((row) => ({ row, score: clusterScore(row)! })); const peak = Math.max(...scored.map((item) => item.score)); + const living = scored.filter((item) => peak - item.score < lead); + if (!living.length) return null; + if (living.some((item) => !(item.row.cluster_intervals ?? item.row.clusterIntervals)?.length)) return null; const intervals: CandidateInterval[] = []; - for (const item of scored) { - if (peak - item.score >= lead) continue; + for (const item of living) { const parts = item.row.cluster_intervals ?? item.row.clusterIntervals ?? []; for (const part of parts) { if (part?.start_at && part?.end_at) intervals.push({ start_at: String(part.start_at), end_at: String(part.end_at) }); @@ -221,6 +222,59 @@ export function livingDisplayFromCandidates( }; } +/** Stored write-time segments. Invalid or empty values fall back to the envelope title. */ +export function parseLivingDisplayIntervals(value: unknown): readonly CandidateInterval[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + const rows: CandidateInterval[] = []; + for (const raw of value) { + if (!raw || typeof raw !== "object") return null; + const start = (raw as { start_at?: unknown }).start_at; + const end = (raw as { end_at?: unknown }).end_at; + if (!validLocalMinute(start) || !validLocalMinute(end) || start > end) return null; + rows.push({ start_at: start, end_at: end }); + } + return rows; +} + +export function livingDisplayFromStoredIntervals( + value: unknown, + birthDate?: string | null, +): LivingDisplay | null { + const intervals = parseLivingDisplayIntervals(value); + if (!intervals) return null; + return { + intervals, + label: windowIntervalsLabel(intervals, birthDate), + widthMinutes: inclusiveIntervalMinutes(intervals), + segmentCount: intervals.length, + }; +} + +export function livingDisplayNarrationFromStored( + value: unknown, + birthDate?: string | null, +): { displayLabel: string; displayWidthMinutes: number; displaySegmentCount: number } | null { + const display = livingDisplayFromStoredIntervals(value, birthDate); + if (!display) return null; + return { + displayLabel: display.label, + displayWidthMinutes: display.widthMinutes, + displaySegmentCount: display.segmentCount, + }; +} + +export function livingDisplayFromReceipt( + receipt: Readonly> | null | undefined, + birthDate?: string | null, +): LivingDisplay | null { + const state = receipt?.inference_state; + if (!state || typeof state !== "object" || Array.isArray(state)) return null; + return livingDisplayFromStoredIntervals( + (state as { living_display_intervals?: unknown }).living_display_intervals, + birthDate, + ); +} + /** Fields to spread into range narration. Null when there is nothing to switch. */ export function livingDisplayNarration( candidates: readonly LivingClusterSource[] | null | undefined, diff --git a/frontend/src/lib/rectification-agentic/core/credible-range.ts b/frontend/src/lib/rectification-agentic/core/credible-range.ts index fa01ddea..c66b20d4 100644 --- a/frontend/src/lib/rectification-agentic/core/credible-range.ts +++ b/frontend/src/lib/rectification-agentic/core/credible-range.ts @@ -1,6 +1,13 @@ import { MIN_SEPARATION_LEAD } from "./candidate-separation.ts"; import type { InferenceCandidate } from "./types.ts"; -import { unionWindowIntervals, type WindowInterval } from "./candidate-window.ts"; +import { + inclusiveIntervalMinutes, + mergeLivingDisplayIntervals, + unionWindowIntervals, + windowIntervalsLabel, + type LivingDisplay, + type WindowInterval, +} from "./candidate-window.ts"; export function unionStillValidIntervals(candidates: readonly InferenceCandidate[], lead = MIN_SEPARATION_LEAD): readonly WindowInterval[] { const active = rankActive(candidates); @@ -66,6 +73,33 @@ export function unionStillValidRange( return [fromMinutes(minutes[0]!), fromMinutes(minutes[minutes.length - 1]!)]; } +export type LivingDisplayDecision = LivingDisplay & Readonly<{ memberIds: readonly string[] }>; + +/** + * Display membership is stillValidCandidates on raw posterior_score. + * A living candidate with no cluster intervals drops the whole segmented + * title so callers keep the envelope. Rounded relativeSupport is not a score. + */ +export function livingDisplayForInference( + candidates: readonly InferenceCandidate[] | null | undefined, + birthDate?: string | null, + lead = MIN_SEPARATION_LEAD, +): LivingDisplayDecision | null { + if (!candidates?.length) return null; + const living = stillValidCandidates(candidates, lead); + if (!living.length) return null; + if (living.some((row) => !row.cluster_intervals?.length)) return null; + const merged = mergeLivingDisplayIntervals(living.flatMap((row) => row.cluster_intervals ?? [])); + if (!merged.length) return null; + return { + intervals: merged, + label: windowIntervalsLabel(merged, birthDate), + widthMinutes: inclusiveIntervalMinutes(merged), + segmentCount: merged.length, + memberIds: living.map((row) => row.id), + }; +} + export function rangeFromTimes(times: readonly string[]): readonly [string, string] | null { const minutes = times .map(toMinutes) diff --git a/frontend/src/lib/rectification-agentic/core/types.ts b/frontend/src/lib/rectification-agentic/core/types.ts index 00dd91bb..b1f01c7a 100644 --- a/frontend/src/lib/rectification-agentic/core/types.ts +++ b/frontend/src/lib/rectification-agentic/core/types.ts @@ -62,7 +62,7 @@ export const PROBE_WEIGHT = { yearless: 0.5, } as const; -import type { CandidatePosition, WindowInterval } from "./candidate-window.ts"; +import type { CandidateInterval, CandidatePosition, WindowInterval } from "./candidate-window.ts"; import type { AscendantSignature, SegmentMinute, SegmentSummary, TargetChart } from "./segment-summary.ts"; export type InferenceCandidate = CandidatePosition & Readonly<{ @@ -169,6 +169,12 @@ export type InferenceState = Readonly<{ representative_time: string | null; credible_range: readonly [string, string] | null; credible_intervals?: readonly WindowInterval[]; + /** + * Segments shown as 「目前范围」. Written from still-valid candidates using + * raw posterior_score, the same lead as credible_range. Null or absent: + * readers keep the envelope title and must not recompute from rounded scores. + */ + living_display_intervals?: readonly CandidateInterval[] | null; segment_minutes?: readonly SegmentMinute[]; segment_targets?: readonly TargetChart[]; segment_scan_complete?: boolean; diff --git a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts index b0d91377..129b30fe 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts @@ -24,7 +24,7 @@ import { import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service"; import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; -import { livingDisplayNarration } from "@/lib/rectification-agentic/core/candidate-window.ts"; +import { livingDisplayNarrationFromStored } from "@/lib/rectification-agentic/core/candidate-window.ts"; import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card"; import { optionIdForAnswerClass, @@ -263,8 +263,8 @@ export async function handleRectificationTypedMessage( narrateAdopt, }); const allowDelivery = deliveryNarrationAllowed(decision, decision.nextAction); - const living = livingDisplayNarration( - previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.candidates, + const living = livingDisplayNarrationFromStored( + previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.living_display_intervals, birthDate, ) ?? {}; const intermediate = nonConvergingRangeNarration({ @@ -366,8 +366,8 @@ export async function handleRectificationTypedMessage( caseId, narrateAdopt, }); - const living = livingDisplayNarration( - previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.candidates, + const living = livingDisplayNarrationFromStored( + previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.living_display_intervals, birthDate, ) ?? {}; const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ ...decision, ...living }); diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts index a768add7..7f587c5f 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts @@ -31,7 +31,7 @@ import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../re import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt"; import { focusSpokenPrompt } from "./turn-question"; import { previousInferenceFromReceipt } from "../core/compose-receipt.ts"; -import { livingDisplayFromCandidates } from "../core/candidate-window.ts"; +import { livingDisplayFromStoredIntervals } from "../core/candidate-window.ts"; import { activityChangedFromTool, mapStreamChunkToActivity, @@ -648,7 +648,7 @@ export async function streamV9Attempt( // P3: the only clocks and percentages a model sentence may carry are this // turn's server facts (range, representative minute, event fit rate). const fitRate = refinementFromDecisionReceipt(latestDossier.latestResult?.decisionReceipt ?? null).event_fit_rate; - const livingSpeech = livingDisplayFromCandidates(latestInference?.candidates); + const livingSpeech = livingDisplayFromStoredIntervals(latestInference?.living_display_intervals); const representativeTime = latestInference?.representative_time ?? latestDossier.latestResult?.representativeTime ?? decision.representativeTime ?? null; const whitelistSources = (livingSpeech ? [{ credibleIntervals: livingSpeech.intervals, representativeTime }] diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 651f05c2..547377db 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -11,7 +11,7 @@ import { postAdoptCheckVerdict } from "./post-adopt-verdict.ts"; import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint"; import { productDeliveryForDomain } from "./product-delivery.ts"; import { nextProbe } from "../core/build-state"; -import { livingDisplayNarration } from "../core/candidate-window.ts"; +import { livingDisplayNarrationFromStored } from "../core/candidate-window.ts"; import { deliveryNarrationAllowed, engineCapabilityCeilingFromReceipt, @@ -366,7 +366,7 @@ function adoptHostNarration(input: { segmentSummary: input.dossier.case.rectificationDomain === null ? null : inference?.segment_summary ?? null, productDelivery: productDeliveryForDomain(input.dossier.case.rectificationDomain), openingRange: openingRangeFromDossier(input.dossier), - ...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}), + ...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}), }); // BUG-1147: the segment card already says which chart is credible and which cannot be told // apart; the minute-candidate hint 「这几个候选按现有信息分不开」 contradicts it. @@ -2084,7 +2084,7 @@ async function persistExhaustionCollect(input: { representativeTime: decision.representativeTime ?? input.decision.representativeTime, openingRange: openingRangeFromDossier(dossier), variant: "intermediate", - ...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}), + ...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}), }); const persistedFocus = await persistFocusAfterChoice({ accounting: input.accounting, @@ -2263,7 +2263,7 @@ async function persistExhaustionCollect(input: { representativeTime: decision.representativeTime ?? input.decision.representativeTime, openingRange: openingRangeFromDossier(dossier), variant: "intermediate", - ...(livingDisplayNarration(inference?.candidates, birthDate) ?? {}), + ...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}), }); const hostNarration = exhaustionCarrierNarration(range, hint); return { diff --git a/frontend/src/lib/rectification-agentic/v9/telemetry.ts b/frontend/src/lib/rectification-agentic/v9/telemetry.ts index 9b37cabb..c3795e0a 100644 --- a/frontend/src/lib/rectification-agentic/v9/telemetry.ts +++ b/frontend/src/lib/rectification-agentic/v9/telemetry.ts @@ -17,7 +17,7 @@ import type { RectificationDecision } from "../core/rectification-decision.ts"; import { deliveryWidthMinutes } from "../core/precision-gate.ts"; import { rangeWidthMinutes } from "../user-copy.ts"; import { previousInferenceFromReceipt } from "./inference-adapter.ts"; -import { livingDisplayFromCandidates } from "../core/candidate-window.ts"; +import { livingDisplayFromStoredIntervals } from "../core/candidate-window.ts"; import { decisionBudgetFromInference } from "./decision-from-dossier.ts"; export const RECTIFICATION_TELEMETRY_FIELDS = [ @@ -278,8 +278,8 @@ export function buildRectificationTelemetryRow(input: RectificationTelemetryInpu questions_open: kindCount("open"), }; const typedSum = Object.values(typed).reduce((sum, value) => sum + value, 0); - const livingWidth = livingDisplayFromCandidates( - previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null)?.candidates, + const livingWidth = livingDisplayFromStoredIntervals( + previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null)?.living_display_intervals, )?.widthMinutes ?? null; const width = livingWidth ?? deliveryWidthMinutes(input.rangeDelivery?.range ?? input.decision.credibleRange); return { diff --git a/frontend/tests/fixtures/rectification-living-display-radius60-20261017.json b/frontend/tests/fixtures/rectification-living-display-radius60-20261017.json new file mode 100644 index 00000000..06bde0e0 --- /dev/null +++ b/frontend/tests/fixtures/rectification-living-display-radius60-20261017.json @@ -0,0 +1,542 @@ +{ + "fixtureProvenance": { + "command": "python scripts/research/varga_resolution_persisted_replay.py --study --modes all_correct,flip_1,flip_3,flip_5 --orderings off --radii 10,30,60 --limit 0 --code-sha 712a0fd2a19947405a1dfb486bad96a7661d41c3", + "code_sha": "712a0fd2a19947405a1dfb486bad96a7661d41c3", + "radius_minutes": 60, + "anchor_date": "2000-01-01", + "answered_probe_count": 8, + "note": "One finished ±60 persisted replay. Only scores, status, and cluster intervals. Birth date, place, name, and case id are omitted. Interval dates are re-anchored on 2000-01-01." + }, + "candidates": [ + { + "id": "c0", + "posterior_score": 6, + "probability": 0, + "status": "eliminated", + "time": "10:37", + "window_index": 2, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "10:36", + "end": "10:41" + } + ] + }, + { + "id": "c1", + "posterior_score": 2, + "probability": 0, + "status": "eliminated", + "time": "10:47", + "window_index": 12, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "10:46", + "end": "10:47" + } + ] + }, + { + "id": "c2", + "posterior_score": 6, + "probability": 0, + "status": "eliminated", + "time": "10:48", + "window_index": 13, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "10:48", + "end": "10:51" + } + ] + }, + { + "id": "c3", + "posterior_score": 6, + "probability": 0, + "status": "eliminated", + "time": "10:35", + "window_index": 0, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "10:35", + "end": "10:35" + } + ] + }, + { + "id": "c4", + "posterior_score": 10, + "probability": 0.03715415577111693, + "status": "active", + "time": "10:59", + "window_index": 24, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "10:54", + "end": "10:59" + } + ] + }, + { + "id": "c5", + "posterior_score": 10, + "probability": 0.03715415577111693, + "status": "active", + "time": "10:52", + "window_index": 17, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "10:52", + "end": "10:53" + } + ] + }, + { + "id": "c6", + "posterior_score": 10, + "probability": 0.03715415577111693, + "status": "active", + "time": "10:45", + "window_index": 10, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "10:42", + "end": "10:45" + } + ] + }, + { + "id": "c7", + "posterior_score": 10, + "probability": 0.03715415577111693, + "status": "active", + "time": "11:04", + "window_index": 29, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:00", + "end": "11:06" + } + ] + }, + { + "id": "c8", + "posterior_score": 14, + "probability": 0.10099546648436392, + "status": "active", + "time": "11:28", + "window_index": 53, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:25", + "end": "11:29" + } + ] + }, + { + "id": "c9", + "posterior_score": 2, + "probability": 0, + "status": "eliminated", + "time": "12:07", + "window_index": 92, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:07", + "end": "12:12" + } + ] + }, + { + "id": "c10", + "posterior_score": 17, + "probability": 0.21380740422520322, + "status": "equivalent", + "time": "11:30", + "window_index": 55, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:30", + "end": "11:30" + } + ] + }, + { + "id": "c11", + "posterior_score": 9, + "probability": 0.028935685608902823, + "status": "active", + "time": "11:19", + "window_index": 44, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:19", + "end": "11:24" + } + ] + }, + { + "id": "c12", + "posterior_score": 3, + "probability": 0, + "status": "eliminated", + "time": "11:13", + "window_index": 38, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:13", + "end": "11:14" + } + ] + }, + { + "id": "c13", + "posterior_score": 9, + "probability": 0.028935685608902823, + "status": "active", + "time": "11:15", + "window_index": 40, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:15", + "end": "11:18" + } + ] + }, + { + "id": "c14", + "posterior_score": -1, + "probability": 0, + "status": "eliminated", + "time": "11:48", + "window_index": 73, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:44", + "end": "11:48" + } + ] + }, + { + "id": "c15", + "posterior_score": 1, + "probability": 0, + "status": "eliminated", + "time": "12:05", + "window_index": 90, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:01", + "end": "12:06" + } + ] + }, + { + "id": "c16", + "posterior_score": -3, + "probability": 0, + "status": "eliminated", + "time": "12:00", + "window_index": 85, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:59", + "end": "12:00" + } + ] + }, + { + "id": "c17", + "posterior_score": 3, + "probability": 0, + "status": "eliminated", + "time": "11:12", + "window_index": 37, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:09", + "end": "11:12" + } + ] + }, + { + "id": "c18", + "posterior_score": 17, + "probability": 0.21380740422520322, + "status": "equivalent", + "time": "11:32", + "window_index": 57, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:31", + "end": "11:36" + } + ] + }, + { + "id": "c19", + "posterior_score": 13, + "probability": 0.07865534838468445, + "status": "equivalent", + "time": "11:43", + "window_index": 68, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:43", + "end": "11:43" + } + ] + }, + { + "id": "c20", + "posterior_score": 13, + "probability": 0.07865534838468445, + "status": "equivalent", + "time": "11:42", + "window_index": 67, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:41", + "end": "11:42" + } + ] + }, + { + "id": "c21", + "posterior_score": 13, + "probability": 0.07865534838468445, + "status": "active", + "time": "11:37", + "window_index": 62, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:37", + "end": "11:40" + } + ] + }, + { + "id": "c22", + "posterior_score": 9, + "probability": 0.028935685608902823, + "status": "active", + "time": "11:07", + "window_index": 32, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:07", + "end": "11:08" + } + ] + }, + { + "id": "c23", + "posterior_score": -3, + "probability": 0, + "status": "eliminated", + "time": "11:49", + "window_index": 74, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:49", + "end": "11:54" + } + ] + }, + { + "id": "c24", + "posterior_score": -3, + "probability": 0, + "status": "eliminated", + "time": "11:58", + "window_index": 83, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:57", + "end": "11:58" + } + ] + }, + { + "id": "c25", + "posterior_score": -3, + "probability": 0, + "status": "eliminated", + "time": "11:56", + "window_index": 81, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "11:55", + "end": "11:56" + } + ] + }, + { + "id": "c26", + "posterior_score": -4, + "probability": 0, + "status": "eliminated", + "time": "12:26", + "window_index": 111, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:26", + "end": "12:27" + } + ] + }, + { + "id": "c27", + "posterior_score": -4, + "probability": 0, + "status": "eliminated", + "time": "12:16", + "window_index": 101, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:13", + "end": "12:19" + } + ] + }, + { + "id": "c28", + "posterior_score": -4, + "probability": 0, + "status": "eliminated", + "time": "12:28", + "window_index": 113, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:28", + "end": "12:29" + } + ] + }, + { + "id": "c29", + "posterior_score": -4, + "probability": 0, + "status": "eliminated", + "time": "12:20", + "window_index": 105, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:20", + "end": "12:25" + } + ] + }, + { + "id": "c30", + "posterior_score": -4, + "probability": 0, + "status": "eliminated", + "time": "12:33", + "window_index": 118, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:32", + "end": "12:35" + } + ] + }, + { + "id": "c31", + "posterior_score": -4, + "probability": 0, + "status": "eliminated", + "time": "12:31", + "window_index": 116, + "intervals": [ + { + "day_offset": 0, + "end_day_offset": 0, + "start": "12:30", + "end": "12:31" + } + ] + } + ], + "expected": { + "member_ids": [ + "c10", + "c18", + "c8", + "c21", + "c20", + "c19", + "c6", + "c5", + "c4", + "c7" + ], + "segment_count": 3, + "width_minutes": 38, + "label": "10:42–10:45、10:52–11:06、11:25–11:43", + "outside_lead_count": 3 + } +} diff --git a/frontend/tests/rectification-living-display-20261016.test.ts b/frontend/tests/rectification-living-display-20261016.test.ts index df6e20cd..c13cb3f5 100644 --- a/frontend/tests/rectification-living-display-20261016.test.ts +++ b/frontend/tests/rectification-living-display-20261016.test.ts @@ -8,11 +8,15 @@ import { RectificationRangeDelivery } from "../src/components/rectification-rang import { inclusiveIntervalMinutes, livingDisplayFromCandidates, + livingDisplayFromReceipt, mergeLivingDisplayIntervals, unionWindowIntervals, windowIntervalsLabel, type WindowInterval, } from "../src/lib/rectification-agentic/core/candidate-window.ts"; +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { livingDisplayForInference, stillValidCandidates } from "../src/lib/rectification-agentic/core/credible-range.ts"; +import type { InferenceCandidate } from "../src/lib/rectification-agentic/core/types.ts"; import { deliveryTurnNarration } from "../src/lib/rectification-agentic/user-copy.ts"; import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; @@ -84,7 +88,11 @@ test("the declared-segment envelope still fills a gap the display keeps", () => assert.equal(mergeLivingDisplayIntervals(rows).length, 2); }); -function cardResult(): RectificationCandidateResult { +function storedIntervals(rows: readonly WindowInterval[]) { + return rows.map((row) => ({ start_at: row.start_at, end_at: row.end_at })); +} + +function cardResult(livingIntervals?: readonly WindowInterval[] | null): RectificationCandidateResult { const early = cluster("08:00", "08:09"); const late = cluster("08:40", "08:49"); const dropped = cluster("09:20", "09:29"); @@ -99,7 +107,9 @@ function cardResult(): RectificationCandidateResult { selectionAllowed: false, canAdopt: false, confirmationAllowed: false, - decisionReceipt: null, + decisionReceipt: livingIntervals === undefined ? null : { + inference_state: { living_display_intervals: livingIntervals ? storedIntervals(livingIntervals) : null }, + }, representativeTime: "08:04", selectedTime: null, selectionKind: null, @@ -126,9 +136,12 @@ function cardResult(): RectificationCandidateResult { }; } -test("the delivery card title uses living segments, not the segment envelope", () => { - const result = cardResult(); - const living = livingDisplayFromCandidates(result.candidates, DAY); +test("the delivery card title uses stored living segments, not the segment envelope", () => { + const early = cluster("08:00", "08:09"); + const late = cluster("08:40", "08:49"); + const result = cardResult([early, late]); + assert.equal(livingDisplayFromCandidates(result.candidates, DAY), null); + const living = livingDisplayFromReceipt(result.decisionReceipt, DAY); assert.ok(living); assert.equal(living.segmentCount, 2); assert.equal(living.widthMinutes, 20); @@ -153,8 +166,212 @@ test("the delivery card title uses living segments, not the segment envelope", ( assert.doesNotMatch(narration, /定到分钟/); }); -test("the delivery card reads living clusters from the candidate, not only the envelope", () => { +test("a card without stored segments keeps the envelope title", () => { + const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, { + result: cardResult(), + acceptingCandidateId: null, + readonly: true, + onAccept: () => undefined, + })); + assert.match(html, /目前范围 08:00–08:49/); + assert.doesNotMatch(html, /08:00–08:09、08:40–08:49/); +}); + +test("the delivery card reads stored intervals, not rounded candidate scores", () => { const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8"); - assert.match(source, /livingDisplayFromCandidates\(result\.candidates/); + const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + // 原值: livingDisplayFromCandidates(result.candidates + // 新值: livingDisplayFromReceipt(result.decisionReceipt + // 原因: 解析后的候选只有四舍五入的 relativeSupport。峰值 7.6、候选 0.4 取整后差 8 分,会被排除,真值可能落在被藏起来的那段。分段在写盘时按原始 posterior_score 算好,卡片和只读行只读存下来的区间。 + assert.match(source, /livingDisplayFromReceipt\(result\.decisionReceipt/); + assert.doesNotMatch(source, /livingDisplayFromCandidates\(result\.candidates/); assert.match(source, /windowIntervalsLabel\(result\.credibleIntervals/); + assert.match(chat, /livingDisplayFromReceipt\(candidateResult\.decisionReceipt/); + assert.doesNotMatch(chat, /livingDisplayFromCandidates\(candidateResult\.candidates/); +}); + +function inferenceRow( + id: string, + score: number, + intervals: readonly WindowInterval[] | null, + probability = score, +): InferenceCandidate { + return { + id, + time: "08:00", + cluster_range: ["08:00", "08:09"], + prior_score: score, + posterior_score: score, + probability, + status: "active", + rank: 1, + strong_conflict_count: 0, + ...(intervals ? { cluster_intervals: intervals } : {}), + }; +} + +test("a 7.2 point gap stays in the display after rounding would look like 8", () => { + const peak = inferenceRow("peak", 7.6, [cluster("08:00", "08:09")], 7.6); + const edge = inferenceRow("edge", 0.4, [cluster("08:40", "08:49")], 0.4); + assert.ok(Math.round(7.6) - Math.round(0.4) >= 8); + assert.ok(7.6 - 0.4 < 8); + const display = livingDisplayForInference([peak, edge], DAY); + assert.deepEqual(display?.memberIds, stillValidCandidates([peak, edge]).map((row) => row.id)); + assert.equal(display?.label, "08:00–08:09、08:40–08:49"); +}); + +test("one living candidate without intervals drops the segmented title", () => { + const peak = inferenceRow("peak", 10, [cluster("08:00", "08:09")], 10); + const living = inferenceRow("living", 9, null, 9); + assert.equal(livingDisplayForInference([peak, living], DAY), null); + assert.deepEqual(stillValidCandidates([peak, living]).map((row) => row.id), ["peak", "living"]); +}); + +test("a candidate outside the lead can lack intervals without hiding the living segment", () => { + const peak = inferenceRow("peak", 10, [cluster("08:00", "08:09")], 10); + const outside = inferenceRow("outside", 1, null, 1); + const display = livingDisplayForInference([peak, outside], DAY); + assert.deepEqual(display?.memberIds, ["peak"]); + assert.equal(display?.label, "08:00–08:09"); +}); + +test("display members match stillValidCandidates, including when probability and score disagree", () => { + const highProbability = inferenceRow("likely", 10, [cluster("08:00", "08:09")], 0.9); + const highScore = inferenceRow("loud", 100, [cluster("08:40", "08:49")], 0.1); + const rows = [highScore, highProbability]; + const display = livingDisplayForInference(rows, DAY); + assert.deepEqual(display?.memberIds, stillValidCandidates(rows).map((row) => row.id)); + assert.deepEqual(display?.memberIds, ["likely", "loud"]); + assert.equal(display?.segmentCount, 2); +}); + +test("buildInferenceState stores both sides of a 7.2 point gap", () => { + const early = cluster("08:00", "08:09"); + const late = cluster("08:40", "08:49"); + const state = buildInferenceState({ + range_start: "08:00", + range_end: "08:49", + candidates: [ + { + id: "peak", + time: "08:04", + relative_support: 7.6, + candidate_date: DAY, + window_index: 484, + window_offset_minutes: 484, + segment_index: 0, + cluster_intervals: [early], + }, + { + id: "edge", + time: "08:44", + relative_support: 0.4, + candidate_date: DAY, + window_index: 524, + window_offset_minutes: 524, + segment_index: 0, + cluster_intervals: [late], + }, + ], + events: [], + probes: [], + }); + assert.deepEqual( + state.living_display_intervals, + storedIntervals([early, late]), + ); + assert.deepEqual( + stillValidCandidates(state.candidates).map((row) => row.id).sort(), + ["edge", "peak"], + ); +}); + +const REPLAY_ANCHOR = "2000-01-01"; + +function replayStamp(offset: number, clock: string): string { + const date = new Date(`${REPLAY_ANCHOR}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + offset); + return `${date.toISOString().slice(0, 10)}T${clock}`; +} + +function replayWindow(startAt: string, endAt: string): WindowInterval { + return { + segment_index: 0, + start_index: 0, + end_index: 0, + start_offset_minutes: 0, + end_offset_minutes: 0, + start_at: startAt, + end_at: endAt, + }; +} + +test("a real ±60 replay sample keeps living members and a gapped title", () => { + const fixture = JSON.parse(readFileSync( + new URL("./fixtures/rectification-living-display-radius60-20261017.json", import.meta.url), + "utf8", + )) as { + fixtureProvenance: { code_sha: string; radius_minutes: number; command: string; answered_probe_count: number }; + candidates: readonly { + id: string; + posterior_score: number; + probability: number; + status: InferenceCandidate["status"]; + time: string; + window_index: number | null; + intervals: readonly { day_offset: number; end_day_offset: number; start: string; end: string }[]; + }[]; + expected: { member_ids: readonly string[]; segment_count: number; width_minutes: number; label: string; outside_lead_count: number }; + }; + assert.equal(fixture.fixtureProvenance.code_sha, "712a0fd2a19947405a1dfb486bad96a7661d41c3"); + assert.equal(fixture.fixtureProvenance.radius_minutes, 60); + assert.match(fixture.fixtureProvenance.command, /--radii 10,30,60/); + assert.ok(fixture.fixtureProvenance.answered_probe_count >= 1); + assert.ok(fixture.expected.outside_lead_count >= 1); + const rows: InferenceCandidate[] = fixture.candidates.map((row) => ({ + id: row.id, + time: row.time, + cluster_range: [row.time, row.time], + prior_score: row.posterior_score, + posterior_score: row.posterior_score, + probability: row.probability, + status: row.status, + rank: 1, + strong_conflict_count: 0, + ...(row.window_index === null ? {} : { window_index: row.window_index }), + ...(row.intervals.length ? { + cluster_intervals: row.intervals.map((part) => replayWindow( + replayStamp(part.day_offset, part.start), + replayStamp(part.end_day_offset, part.end), + )), + } : {}), + })); + const display = livingDisplayForInference(rows, REPLAY_ANCHOR); + assert.ok(display); + assert.deepEqual(display.memberIds, stillValidCandidates(rows).map((row) => row.id)); + assert.deepEqual(display.memberIds, fixture.expected.member_ids); + assert.equal(display.segmentCount, fixture.expected.segment_count); + assert.equal(display.widthMinutes, fixture.expected.width_minutes); + assert.equal(display.label, fixture.expected.label); + assert.ok(display.segmentCount >= 2); + const gaps = display.intervals.slice(1).map((row, index) => ( + (Date.parse(`${row.start_at}:00Z`) - Date.parse(`${display.intervals[index]!.end_at}:00Z`)) / 60000 - 1 + )); + assert.ok(gaps.some((gap) => gap > 4)); + const result = cardResult(display.intervals.map((row) => replayWindow(row.start_at, row.end_at))); + const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, { + result: { + ...result, + declaredBirthDate: REPLAY_ANCHOR, + credibleRange: ["10:42", "11:43"], + credibleIntervals: [replayWindow(`${REPLAY_ANCHOR}T10:42`, `${REPLAY_ANCHOR}T11:43`)], + }, + acceptingCandidateId: null, + readonly: true, + onAccept: () => undefined, + })); + assert.match(html, /目前范围 10:42–10:45、10:52–11:06、11:25–11:43/); + assert.doesNotMatch(html, /10:42–11:43/); + assert.equal(display.widthMinutes, 38); + assert.ok(display.widthMinutes < 62); }); diff --git a/frontend/tests/research/rectification-segment-persisted-replay.ts b/frontend/tests/research/rectification-segment-persisted-replay.ts index 141315af..ac8d952b 100644 --- a/frontend/tests/research/rectification-segment-persisted-replay.ts +++ b/frontend/tests/research/rectification-segment-persisted-replay.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../../src/lib/db/local-postgres-client-core.ts"; import { resolveActiveSkillPackage } from "../../src/lib/skill-package-registry.ts"; +import { stillValidCandidates } from "../../src/lib/rectification-agentic/core/credible-range.ts"; import { publicNextAction } from "../../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { DEFAULT_MAX_DISCRIMINATION_ROUNDS, type AnswerClass, type InferenceState } from "../../src/lib/rectification-agentic/core/types.ts"; import { rawMinuteWeights, segmentsForMinutes, summarizeSegmentWeights, targetChartsForDomain } from "../../src/lib/rectification-agentic/core/segment-summary.ts"; import { applyRectificationChoice, inferenceForPersistedAnswer, persistNextInterviewAfterChoice } from "../../src/lib/rectification-agentic/v9/answer-choice.ts"; import { CHOICE_ACTION, outcomeIdForOption } from "../../src/lib/rectification-agentic/v9/choice-action.ts"; @@ -25,9 +27,67 @@ type ReplayRow = { range: { start_time: string; end_time: string }; events: { event_kind: EvidenceKind; domain: string; date_start: string; date_end: string; precision: string; summary: string }[]; }; +type AnswerMode = "all_correct" | "flip_1" | "flip_3" | "flip_5"; +type StudyInput = { + modes?: AnswerMode[]; + orderings?: Array<"off" | "on">; + code_sha?: string; +}; const chunks: Buffer[] = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); -const input = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { rows: ReplayRow[] }; +const input = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { rows: ReplayRow[]; study?: StudyInput }; +const study = input.study ?? null; +const orderingFlags = study ? (study.orderings ?? ["off"]).map((item) => item === "on") : [false, true]; +const modes: AnswerMode[] = study?.modes?.length ? study.modes : ["all_correct"]; +const maxRounds = study ? DEFAULT_MAX_DISCRIMINATION_ROUNDS : 6; +const passes = orderingFlags.flatMap((enabled) => modes.map((mode) => ({ enabled, mode }))); + +function caseLabel(key: string): string { + return createHash("sha256").update(key).digest("hex").slice(0, 16); +} +function shiftDate(day: string, delta: number): string { + const date = new Date(`${day}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + delta); + return date.toISOString().slice(0, 10); +} +function inclusiveMinutes(rows: readonly { start_at: string; end_at: string }[]): number { + return rows.reduce((sum, row) => { + const width = (Date.parse(`${row.end_at}:00Z`) - Date.parse(`${row.start_at}:00Z`)) / 60000 + 1; + return Number.isFinite(width) && width > 0 ? sum + width : sum; + }, 0); +} +function envelopeIntervals(state: InferenceState, birthDate: string): { start_at: string; end_at: string }[] { + if (state.credible_intervals?.length) { + return state.credible_intervals.map((row) => ({ start_at: row.start_at, end_at: row.end_at })); + } + const range = state.credible_range; + if (!range) return []; + if (range[0] <= range[1]) return [{ start_at: `${birthDate}T${range[0]}`, end_at: `${birthDate}T${range[1]}` }]; + const previous = shiftDate(birthDate, -1); + return [ + { start_at: `${previous}T${range[0]}`, end_at: `${previous}T23:59` }, + { start_at: `${birthDate}T00:00`, end_at: `${birthDate}T${range[1]}` }, + ]; +} +function containsTruth(rows: readonly { start_at: string; end_at: string }[], birthDate: string, minute: string): boolean { + const stamps = [-1, 0, 1].map((delta) => `${shiftDate(birthDate, delta)}T${minute}`); + return rows.some((row) => stamps.some((stamp) => stamp >= row.start_at && stamp <= row.end_at)); +} +function median(values: number[]): number | null { + if (!values.length) return null; + const sorted = [...values].sort((left, right) => left - right); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2; +} +function flippedClass(value: AnswerClass): AnswerClass { + return value === "no" ? "yes" : "no"; +} +function dayOffset(birthDate: string, stamp: string): number { + const birth = Date.parse(`${birthDate}T00:00:00Z`); + const day = Date.parse(`${stamp.slice(0, 10)}T00:00:00Z`); + return Math.round((day - birth) / 86400000); +} +let displayFixture: Record | null = null; const records: Record[] = []; const previousOrder = process.env.RECTIFICATION_SEGMENT_ORDER; const quote = (value: unknown) => `'${String(value).replaceAll("'", "''")}'`; @@ -43,10 +103,16 @@ try { const service = createLocalPostgresDataClient(url, null, "service_role"); const skill = resolveActiveSkillPackage("jyotish-birth-time-rectification"); for (const row of input.rows) { - for (const enabled of [false, true]) { + for (const pass of passes) { + const enabled = pass.enabled; + const mode = pass.mode; + const flipAt = mode === "flip_1" ? 1 : mode === "flip_3" ? 3 : mode === "flip_5" ? 5 : null; process.env.RECTIFICATION_SEGMENT_ORDER = enabled ? "on" : "off"; const userId = randomUUID(), caseId = randomUUID(), sessionId = randomUUID(), sourceSessionId = randomUUID(); - const trace: Record = { case_key: row.case_key, radius: row.radius, + const trace: Record = { + case_key: study ? caseLabel(row.case_key) : row.case_key, + radius: row.radius, + ...(study ? { mode } : {}), ordering: enabled ? "on-experiment" : "off-default", opening: "explicit-fictional-case-seed-not-opening-success", asked: [], gates: [], stop: null, stage: "seed", metrics: null }; const ledger = () => JSON.parse(fixture.psql(`select json_build_object( @@ -102,7 +168,9 @@ try { } trace.stage = "scoreAndPersistCurrentEvidence"; await scoreAndPersistCurrentEvidence({ accounting: service, userId, caseId }); - for (let round = 0; round < 6; round++) { + let scoredOrdinal = 0; + let flipApplied = false; + for (let round = 0; round < maxRounds; round++) { let dossier = await loadV9CaseDossier(service, userId, caseId); const state = inferenceForPersistedAnswer(dossier.latestResult?.decisionReceipt); const decision = decideAfterInferenceChange({ dossier, state, userStopped: false, birthDate: snapshot.birth_date }); @@ -131,10 +199,18 @@ try { choice_kind: schema.choice_kind ?? null, scoring: schema.scoring !== false }); break; } - const answerClass = segmentReplayOracleAnswer(probe, row.oracle_truth_minute, ownedState?.transitions); + const oracleClass = segmentReplayOracleAnswer(probe, row.oracle_truth_minute, ownedState?.transitions); const choice = schema.choice as { options?: { key: ChoiceKey }[] } | undefined; + const scoring = schema.scoring !== false; + if (scoring) scoredOrdinal += 1; + const wantFlip = flipAt !== null && scoring && scoredOrdinal === flipAt; + const answerClass = wantFlip ? flippedClass(oracleClass) : oracleClass; const option = choice?.options?.find(option => outcomeIdForOption(option.key, schema) === answerClass); - if (!option) { trace.stop = "oracle-no-renderable-class-option"; break; } + if (!option) { + trace.stop = wantFlip ? "flip-option-missing" : "oracle-no-renderable-class-option"; + break; + } + if (wantFlip) flipApplied = true; const before = ledger(); trace.stage = "applyRectificationChoice"; const applied = await applyRectificationChoice(service, { userId, caseId, sessionId, actionId: randomUUID(), @@ -147,11 +223,54 @@ try { scoring: schema.scoring !== false, ledger_before: before, ledger_after: ledger(), revision_before: ownedState!.revision, revision_after: afterState?.revision ?? null }); assert.equal(afterState?.revision, ownedState!.revision + 1); - if (round === 5) trace.stop = "six-question-budget"; + if (round === maxRounds - 1) trace.stop = maxRounds === 6 ? "six-question-budget" : "round-cap"; } const final = await loadV9CaseDossier(service, userId, caseId); const state = inferenceForPersistedAnswer(final.latestResult?.decisionReceipt); - if (state?.segment_minutes?.length) { + if (study && state) { + const envelope = envelopeIntervals(state, snapshot.birth_date); + const stored = (state.living_display_intervals ?? []).filter((row) => row?.start_at && row?.end_at); + const shown = stored.length ? stored : envelope; + const living = stillValidCandidates(state.candidates); + const livingRows = living.flatMap((row) => row.cluster_intervals ?? []); + const remaining = living.length > 0 && living.every((row) => (row.cluster_intervals?.length ?? 0) > 0) + ? inclusiveMinutes(livingRows) : inclusiveMinutes(envelope); + const clocks = (rows: readonly { start_at: string; end_at: string }[]) => rows.map((row) => ({ + start: row.start_at.slice(11, 16), end: row.end_at.slice(11, 16), + })); + trace.question_count = (trace.asked as unknown[]).length; + trace.flip_applied = flipApplied; + trace.truth_in_envelope = containsTruth(envelope, snapshot.birth_date, row.oracle_truth_minute); + trace.truth_in_display = containsTruth(shown, snapshot.birth_date, row.oracle_truth_minute); + trace.remaining_minutes = remaining; + trace.display_width_minutes = inclusiveMinutes(shown); + trace.display_source = stored.length ? "stored" : "envelope"; + trace.credible_range = state.credible_range; + trace.credible_intervals = clocks(envelope); + trace.living_display_intervals = stored.length ? clocks(stored) : null; + if (!displayFixture && row.radius === 60 && mode === "all_correct" && stored.length >= 2) { + displayFixture = { + radius: 60, + mode: "all_correct", + display_width_minutes: inclusiveMinutes(shown), + segment_count: stored.length, + intervals: stored.map((part) => ({ + day_offset: dayOffset(snapshot.birth_date, part.start_at), + start: part.start_at.slice(11, 16), + end: part.end_at.slice(11, 16), + })), + members: living.map((candidate) => ({ + posterior_score: candidate.posterior_score, + intervals: (candidate.cluster_intervals ?? []).map((part) => ({ + day_offset: dayOffset(snapshot.birth_date, part.start_at), + start: part.start_at.slice(11, 16), + end: part.end_at.slice(11, 16), + })), + })), + }; + } + } + if (!study && state?.segment_minutes?.length) { const offsets = Object.fromEntries(state.segment_minutes.map(minute => [minute.time, minute.offset])); const truthOffset = offsets[row.oracle_truth_minute]; assert.notEqual(truthOffset, undefined); @@ -164,7 +283,8 @@ try { trace.ledger_after = ledger(); } catch (error) { trace.stop = "blocked"; - trace.error = error instanceof Error ? error.message : "unknown_error"; + trace.error = study ? (error instanceof Error ? error.name : "unknown_error") + : (error instanceof Error ? error.message : "unknown_error"); trace.ledger_after = ledger(); } records.push(trace); @@ -194,8 +314,38 @@ const aggregate = Object.fromEntries([...new Set(input.rows.map(row => row.radiu completed_six: rows.filter(row => (row.asked as unknown[]).length === 6).length, metrics, redline_status: measured.length === 77 ? "requires-independent-cell-comparison" : "not-evaluable" }]; }))); -const blocked = fatal !== null || records.some(row => row.stop === "blocked" || row.metrics === null); -process.stdout.write(JSON.stringify({ scope: "explicit-seeded persisted replay; off-default vs on-experiment; no opening success or provider claim", - fatal, records, aggregate, blocked, distinct_cases: new Set(input.rows.map(row => row.case_key)).size, - requested_traces: input.rows.length * 2, completed_six: records.filter(row => (row.asked as unknown[]).length === 6).length }) + "\n"); +const studySummary = study ? Object.fromEntries(modes.flatMap((mode) => ( + [...new Set(input.rows.map((row) => row.radius))].map((radius) => { + const rows = records.filter((row) => row.radius === radius && row.mode === mode && row.stop !== "blocked" + && typeof row.truth_in_envelope === "boolean"); + const envelope = rows.filter((row) => row.truth_in_envelope === true).length; + const display = rows.filter((row) => row.truth_in_display === true).length; + const numbers = (key: string) => rows.map((row) => row[key]).filter((value): value is number => typeof value === "number"); + return [`${radius}:${mode}`, { + attempted: rows.length, + blocked: records.filter((row) => row.radius === radius && row.mode === mode && row.stop === "blocked").length, + envelope_contains: envelope, + display_contains: display, + envelope_rate: rows.length ? envelope / rows.length : null, + display_rate: rows.length ? display / rows.length : null, + remaining_minutes_median: median(numbers("remaining_minutes")), + display_width_median: median(numbers("display_width_minutes")), + question_count_median: median(numbers("question_count")), + flip_applied: rows.filter((row) => row.flip_applied === true).length, + }]; + }) +))) : null; +const blocked = fatal !== null || records.some(row => row.stop === "blocked" || (!study && row.metrics === null)); +process.stdout.write(JSON.stringify({ + scope: study + ? "narrower-delivery replay; containment booleans only; no birth clock, date, place or name" + : "explicit-seeded persisted replay; off-default vs on-experiment; no opening success or provider claim", + fatal: study && fatal ? "runner_failure" : fatal, + records, aggregate, study_summary: studySummary, blocked, + max_rounds: maxRounds, + display_fixture: displayFixture, + code_sha: study?.code_sha ?? null, + distinct_cases: new Set(input.rows.map(row => study ? caseLabel(row.case_key) : row.case_key)).size, + requested_traces: input.rows.length * passes.length, + completed_six: records.filter(row => (row.asked as unknown[]).length === 6).length }) + "\n"); process.exitCode = blocked ? 1 : 0; diff --git a/scripts/rectification/living_display_intervals.py b/scripts/rectification/living_display_intervals.py deleted file mode 100644 index fee1c15a..00000000 --- a/scripts/rectification/living_display_intervals.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Display-only merge of living candidate intervals. - -Decision width stays on ``interval_union_width`` / ``unionWindowIntervals``. -This module is not part of the sealed scoring identity: scoring must not import it. -""" -from __future__ import annotations - -from datetime import datetime -from typing import Any - -LIVING_DISPLAY_MERGE_GAP_MINUTES = 4 -LIVING_DISPLAY_MAX_SEGMENTS = 3 - - -def _stamp(value: str) -> datetime: - return datetime.fromisoformat(value) - - -def merge_living_display_intervals( - intervals: list[dict[str, Any]], - gap_minutes: int = LIVING_DISPLAY_MERGE_GAP_MINUTES, -) -> list[dict[str, str]]: - """Merge intervals whose gap is <= gap_minutes. Gap is the open minutes between them.""" - rows = [] - for row in intervals: - start, end = str(row.get("start_at") or ""), str(row.get("end_at") or "") - if len(start) < 16 or len(end) < 16 or start > end: - continue - try: - _stamp(start) - _stamp(end) - except ValueError: - continue - rows.append({"start_at": start[:16], "end_at": end[:16]}) - rows.sort(key=lambda item: (item["start_at"], item["end_at"])) - merged: list[dict[str, str]] = [] - for row in rows: - if not merged: - merged.append(row) - continue - previous = merged[-1] - gap = int((_stamp(row["start_at"]) - _stamp(previous["end_at"])).total_seconds() // 60) - 1 - if gap <= gap_minutes: - if row["end_at"] > previous["end_at"]: - previous["end_at"] = row["end_at"] - else: - merged.append(row) - return merged - - -def inclusive_interval_minutes(intervals: list[dict[str, str]]) -> int: - total = 0 - for row in intervals: - width = int((_stamp(row["end_at"]) - _stamp(row["start_at"])).total_seconds() // 60) + 1 - if width > 0: - total += width - return total - - -def living_display_label( - intervals: list[dict[str, str]], - birth_date: str | None = None, - max_segments: int = LIVING_DISPLAY_MAX_SEGMENTS, -) -> str: - def label(value: str) -> str: - date, clock = value[:10], value[11:16] - if not birth_date: - return f"{date} {clock}" - if date == birth_date: - return clock - return f"{'前一天' if date < birth_date else '后一天'} {clock}" - - def text(row: dict[str, str]) -> str: - return f"{label(row['start_at'])}–{label(row['end_at'])}" - - if len(intervals) <= max_segments: - return "、".join(text(row) for row in intervals) - shown = "、".join(text(row) for row in intervals[:max_segments]) - return f"{shown},等 {len(intervals)} 段,共约 {inclusive_interval_minutes(intervals)} 分钟" diff --git a/scripts/research/varga_resolution_persisted_replay.py b/scripts/research/varga_resolution_persisted_replay.py index c578abeb..c179abbf 100644 --- a/scripts/research/varga_resolution_persisted_replay.py +++ b/scripts/research/varga_resolution_persisted_replay.py @@ -29,6 +29,10 @@ def main() -> int: parser.add_argument("--output", required=True) parser.add_argument("--limit", type=int, default=10) parser.add_argument("--radii", default="10,30,60") + parser.add_argument("--study", action="store_true") + parser.add_argument("--modes", default="all_correct,flip_1,flip_3,flip_5") + parser.add_argument("--orderings", default="off") + parser.add_argument("--code-sha", default="") args = parser.parse_args() target = Path(args.output) if target.exists(): @@ -58,13 +62,32 @@ def main() -> int: thread.start() try: env = {**os.environ, "JYOTISH_API_BASE": origin} + body: dict = {"rows": rows} + if args.study: + body["study"] = { + "modes": [item for item in args.modes.split(",") if item], + "orderings": [item for item in args.orderings.split(",") if item], + "code_sha": args.code_sha, + } result = subprocess.run(["node", "--import", "tsx", "tests/research/rectification-segment-persisted-replay.ts"], - cwd=ROOT / "frontend", env=env, input=json.dumps({"rows": rows}), + cwd=ROOT / "frontend", env=env, input=json.dumps(body), text=True, encoding="utf-8", stdout=subprocess.PIPE, check=False) payload = json.loads(result.stdout) payload.update({"python_version": platform.python_version(), "dataset_sha256": hashlib.sha256(Path(args.dataset).read_bytes()).hexdigest(), "runner_exit": result.returncode}) + if args.study: + for row in payload.get("records") or []: + if isinstance(row, dict): + for key in ("asked", "gates", "ledger_before", "ledger_after", "metrics", "targets", "domain"): + row.pop(key, None) + payload["rerun"] = { + "command": "python scripts/research/varga_resolution_persisted_replay.py --study " + f"--modes {args.modes} --orderings {args.orderings} --radii {args.radii} " + f"--limit {args.limit} --code-sha {args.code_sha or ''} --output ", + "max_rounds": "DEFAULT_MAX_DISCRIMINATION_ROUNDS", + "truth_clock": "omitted; real-case calibration stores containment booleans only", + } target.parent.mkdir(parents=True, exist_ok=True) with target.open("x", encoding="utf-8") as stream: stream.write(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n") diff --git a/tests/test_rectification_living_display_intervals.py b/tests/test_rectification_living_display_intervals.py deleted file mode 100644 index d6d135c9..00000000 --- a/tests/test_rectification_living_display_intervals.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Display merge for living candidate intervals. Decision envelopes stay untouched.""" -from __future__ import annotations - -from scripts.rectification.candidate_window import interval_union_width -from scripts.rectification.living_display_intervals import ( - inclusive_interval_minutes, - living_display_label, - merge_living_display_intervals, -) - -DAY = "2000-01-01" - - -def _at(clock: str) -> str: - return f"{DAY}T{clock}" - - -def _interval(start: str, end: str, segment: int = 0, start_offset: int = 0, end_offset: int = 0) -> dict: - return { - "start_at": _at(start), - "end_at": _at(end), - "segment_index": segment, - "start_index": start_offset, - "end_index": end_offset, - "start_offset_minutes": start_offset, - "end_offset_minutes": end_offset, - } - - -def test_single_segment_stays_one_piece(): - rows = [_interval("08:00", "08:09")] - merged = merge_living_display_intervals(rows) - assert len(merged) == 1 - assert inclusive_interval_minutes(merged) == 10 - assert living_display_label(merged, DAY) == "08:00–08:09" - - -def test_gap_of_four_minutes_merges_and_five_stays_split(): - touch = merge_living_display_intervals([ - _interval("08:00", "08:10"), - _interval("08:15", "08:20"), - ]) - assert len(touch) == 1 - assert touch[0]["end_at"] == _at("08:20") - assert inclusive_interval_minutes(touch) == 21 - - split = merge_living_display_intervals([ - _interval("08:00", "08:10"), - _interval("08:16", "08:20"), - ]) - assert len(split) == 2 - assert inclusive_interval_minutes(split) == 11 + 5 - assert living_display_label(split, DAY) == "08:00–08:10、08:16–08:20" - - -def test_five_segments_label_keeps_the_first_three_and_the_total(): - rows = [ - _interval("08:00", "08:01"), - _interval("08:12", "08:13"), - _interval("08:24", "08:25"), - _interval("08:36", "08:37"), - _interval("08:48", "08:49"), - ] - merged = merge_living_display_intervals(rows) - assert len(merged) == 5 - assert inclusive_interval_minutes(merged) == 10 - assert living_display_label(merged, DAY) == "08:00–08:01、08:12–08:13、08:24–08:25,等 5 段,共约 10 分钟" - - -def test_decision_envelope_still_fills_a_declared_segment_gap(): - """原口径:同一 segment_index 的首尾包络。显示合并不得改这个函数。""" - rows = [ - _interval("08:00", "08:01", start_offset=0, end_offset=1), - _interval("08:48", "08:49", start_offset=48, end_offset=49), - ] - assert interval_union_width(rows) == 50 - assert len(merge_living_display_intervals(rows)) == 2