diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3fb6b6..3e638788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-08 — 补经历换了候选分钟后,已答对照题仍按星座和分钟段收范围 + +生时校正答完对照卡后再补一件经历,新出现的候选分钟会按你已经答过的分盘星座和大运时段接着算,不会因为名单里暂时没它的名字就当成没冲突、把范围重新拉宽。如果这次比较让范围变了,即使是在记录经历时内部重算的,旁白末尾也会写「范围从 A–B 变为 C–D」。Skill 版本仍是 10.0.16。 + + ## 2026-09-08 — 交付轮验证报告跟卡片用同一套宽度、双轨和分盘星座 生时校正出区间卡时,正文里的验证报告按当前可信区间写宽度,双轨只看还在范围内的分钟,每个候选的 D9 / D10 上升由服务器算好。不会再把引擎开工时的半小时跨度、已经被淘汰的分钟,或按换升时刻推错的分盘星座写进报告。Skill 版本 10.0.16。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 06f6179e..da78a1fd 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9106,7 +9106,7 @@ - 修复:已答题的定义随状态携带(`carried: true`),重放按候选分钟,新分钟落中性。分盘 `semantic_key` 改为星座分区,例如 `varga.d9.天秤座|天蝎座|射手座`,不再内嵌 `HH:MM`。`candidate_split_hash` 仍按当前候选集计算。 - 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts`(连续两次重算仍 5 轮、未变分钟 delta 不变、新分钟 delta 0);指定 TS 切片 1000/0;`tsc --noEmit` 0。 - 防复发:候选集变化后 `answered_probes` 对应的题目定义必须仍在 `state.probes`。新生成的分盘 `semantic_key` 不得匹配 `\d\d:\d\d`。`nextProbe` 不得把携带题再问一遍。 -- 相关记录:BUG-559、BUG-581、BUG-588、BUG-589 +- 相关记录:BUG-559、BUG-581、BUG-588、BUG-589、BUG-594 - 复发自:BUG-559(去重删生成侧定义);BUG-581(停止路径已修,证据路径未修) - 修复版本:待提交(`codex/rectification-probe-replay-loss-20260908`) @@ -9116,15 +9116,15 @@ - 首次发现:2026-09-08 - 最近更新:2026-09-08 - 影响面:`runV9AgentTurn` 证据/比较轮结算、`withRangeChangedAfterEvidence` -- 用户现象:范围从 8 分钟变回 29 分钟时,三轮证据旁白只说「记下了」,用户要到交付卡才看见变宽。 -- 触发条件:本轮调用了 `rectification-compare-candidates`,compare 前后 `credible_range` 不同。 -- 根因:点选题由服务端写「范围从 X 收到 Y」;证据轮旁白是模型写的,服务端不补范围句。 -- 修复:结算时先按 BUG-585 剪问句,再比较本轮 compare 前后的 `credible_range`。变了就把「范围从 A–B 变为 C–D。」接到正文末尾;没变不写。 -- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts`;`frontend/tests/agent-voice-copy-contract.test.ts` 收录新句并锁 `agent-run` 先剪后接。 -- 防复发:证据轮只要跑过 compare 且范围变了,落库 `assistant_message` 必须含范围句。不得靠提示词让模型自己报。 -- 相关记录:BUG-585、BUG-587、BUG-569 -- 复发自:无 -- 修复版本:待提交(`codex/rectification-probe-replay-loss-20260908`) +- 用户现象:范围从 8 分钟变回 29 分钟时,三轮证据旁白只说「记下了」,用户要到交付卡才看见变宽。后续复发:补经历走 `rectification-record-evidence-batch` 内部重算,旁白仍只说「接下来我们继续」,没有范围句。 +- 触发条件:本轮 `credible_range` 变了。最初只在 `toolsUsed` 含 `rectification-compare-candidates` 时接句;batch 内 compare 不进该集合。 +- 根因:点选题由服务端写「范围从 X 收到 Y」;证据轮旁白是模型写的。第一次修复按工具名触发,batch 路径没有 compare 工具名。 +- 修复:结算时先按 BUG-585 剪问句,再比较本轮开始时与结算时的 `credible_range`。变了就把「范围从 A–B 变为 C–D。」接到正文末尾;没变不写。不再看 `toolsUsed`。 +- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts` 锁结算段不按 compare 工具名触发,batch 形状 04:47–04:59 → 04:47–05:14 仍接范围句;`frontend/tests/agent-voice-copy-contract.test.ts` 收录新句并锁先剪后接。 +- 防复发:范围句看 `credible_range` 事实,不看本轮点了哪个工具。变了必须写进落库 `assistant_message`。不得靠提示词让模型自己报。 +- 相关记录:BUG-585、BUG-587、BUG-569、BUG-594 +- 复发自:无;后续复发见本条 batch 路径(随 BUG-594 同修) +- 修复版本:`df182c16`;batch 路径随 BUG-594 同修 ## BUG-589 | 交付/选择卡在最后一题出现前可能闪现 @@ -9206,3 +9206,19 @@ - 复发自:BUG-290(宽度字段进投影后未随推断层更新);BUG-568(区间读盘,交付正文仍用开工跨度) - 修复版本:`06e44104` +## BUG-594 | 重算后新分钟不继承已答题结论,范围从 13 分钟回弹到 28 分钟 + +- 状态:resolved +- 首次发现:2026-09-08 +- 最近更新:2026-09-08 +- 影响面:`apply-probe-outcome.ts::directionFor`、`buildInferenceState` 携带题重放、`window_scan.transitions`、证据轮范围句 +- 用户现象:5 道点选题把范围收到 04:47–04:59 后,再补迁居经历,交付卡变成 04:47–05:14。新进候选的 05:14 在已答题上全是中性、零冲突,把右端拉宽。 +- 触发条件:引擎重算换入不在旧 `supports/conflicts` 里的分钟;已答题按 BUG-587 携带重放。 +- 根因:BUG-587 任务书决策 2 规定新分钟一律 `neutral`、不做邻近插值。`directionFor` 按候选 id 查成员,查不到就记 0。分盘题本可按换升星座判定,大运/激活题的 outcome 本是连续分钟段,结论可以确定推出。 +- 修复:推翻该中性决策。分盘风格题用 `signFromTransitions` 按已答星座判 support/conflict,缺 transitions 则仍中性。其余题:新分钟落在某 outcome 集合最小与最大分钟之间、且两侧最近旧分钟同属该集合时继承该集合,否则中性。携带题写入 `outcome_by_minute`。`buildCaseInferenceState` 把 `window_scan.transitions` 传进核心层。范围句改看 `credible_range` 事实(见 BUG-588 batch 路径)。淘汰阈值、`SCORE_DELTA`、四选项合同、Skill 版本不动。 +- 验证:`frontend/tests/rectification-probe-replay-loss-20260908.test.ts` BUG-594 (a)–(d):05:14 重放后 `strong_conflict_count ≥ 3` 且 `eliminated`,范围仍 04:47–04:59;两集合之间的分钟保持中性;无 transitions 时分盘题保持中性;连续换入被覆盖的新分钟不把范围拉宽。 +- 防复发:新分钟不得只因不在旧名单里就零冲突存活。分盘题必须能用换升星座继承;大运/激活题必须能用连续分钟段继承。不得再用「不做插值」挡住可确定推出的结论。 +- 相关记录:BUG-587、BUG-588 +- 复发自:BUG-587(任务书决策 2 定错;重放定义已在,结论层把新分钟放生) +- 修复版本:待发布(`codex/rectification-new-minute-inherit-20260908`) + diff --git a/docs/tasks/PROGRESS-rectification-new-minute-inherit-20260908.md b/docs/tasks/PROGRESS-rectification-new-minute-inherit-20260908.md new file mode 100644 index 00000000..7470aaab --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-new-minute-inherit-20260908.md @@ -0,0 +1,47 @@ +# PROGRESS · 重算后新分钟继承已答题结论(2026-09-08) + +工作树:`.worktrees/rectification-new-minute-inherit-20260908` +分支:`codex/rectification-new-minute-inherit-20260908` +任务书:`docs/tasks/TASK-rectification-new-minute-inherit-20260908.md` +基线:`origin/staging` @ `6aba672c`(含修复单 `56067fda` 与 BUG-592/593 验收 `6aba672c`;代码头仍是 `6a687085`) + +本单状态:**待验收**。未 commit、未 push。 + +未改:淘汰阈值 `STRONG_CONFLICT_ELIMINATION_COUNT = 3`、`SCORE_DELTA`、四选项合同、采用门、确认门、Skill 版本(仍 10.0.16)。 + +## 做了什么 + +- **决策 1** `directionFor` 对新分钟按题目语义推结论,不再一律中性。 + - 分盘风格题(`choice_kind = varga_style` 且 `semantic_key` 以 `varga..` 开头):`signFromTransitions` 取该分钟该层上升,对照 `style_options[].sign` 与已答 `answer_class` 判 support/conflict。没有 transitions 或该层无星座 → 中性。 + - 大运/激活/事件质量题:新分钟落在某 outcome 集合最小与最大分钟之间、且两侧最近旧分钟同属该集合 → 继承;否则中性。 + - 携带题写入 `outcome_by_minute`;`rounds.score_deltas` 对新分钟可非 0。 +- **决策 2** `signFromTransitions` 下沉到 `core/sign-from-transitions.ts`。`buildCaseInferenceState` / `score-persist` 把 `window_scan.transitions` 传进核心层。`divergence-panel.ts` 改为引用核心函数,再用 D9/D10 类型表收口星座名。 +- **决策 3** `agent-run` 范围句比较本轮开始时与结算时的 `credible_range`,不再看 `toolsUsed.has("rectification-compare-candidates")`。batch 内重算也会接 `withRangeChangedAfterEvidence`。 +- **决策 4** 记录 BUG-594(关联 BUG-587 决策 2 定错)、BUG-588 batch 路径补记、CHANGELOG、手测脚本第 8 条。 + +## 三栏(被触碰断言) + +| 用例 | 原值 | 新值 | 理由 | +| --- | --- | --- | --- | +| 换入 05:14(D9 射手、D10 狮子,2024.04 支持段、2023.05 反对段) | `strong_conflict_count = 0`、活着、范围到 05:14 | `≥ 3`、`eliminated`、范围仍 04:47–04:59 | 决策 1 | +| 新分钟夹在两个 outcome 集合之间 | 中性(碰巧) | 仍中性 | 决策 1 的「两侧同属」条件 | +| 分盘题无 transitions | 中性 | 仍中性,不报错 | 决策 1 退化 | +| 证据轮 batch 内范围 04:47–04:59 → 04:47–05:14 | 无范围句(没有 compare 工具名) | 旁白末尾接范围句 | 决策 3 | +| BUG-587 旧夹具里的 04:59 | 新分钟 delta 0 | 落在 04:57–05:13 冲突段内,继承 conflict | 推翻决策 2 后的正确结果 | + +## 测试 + +- `frontend` `./node_modules/.bin/tsc --noEmit`:exit 0 +- 本单改动文件 ReadLints:0 error +- `tests/rectification-probe-replay-loss-20260908.test.ts`:10/10 +- `tests/rectification-varga-style-weight.test.ts` + `agent-voice-copy-contract.test.ts` 与上一文件合计 31/0 +- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**1021 passed / 0 failed** +- `exp_case_head.py` 合成 7 事件序列:仓库无此脚本;4.1 (d) 用事故形状的合成 6 题 + 三次换分钟回放代替 +- 浏览器真人走查:本轮未跑,见 `docs/testing/rectification-scenarios-20260907.md` §8 + +## 偏离 + +1. 任务书基线写 `6a687085`。本 worktree 跟 `origin/staging` 尖端 `6aba672c`(多了修复单文档与 592/593 验收记录,无额外代码差)。 +2. 4.1 (d) 没有 `scripts/exp_case_head.py`,用同文件内的事故形状合成序列。 +3. 4.2 的「落库 assistant_message」用源码锁 + `withRangeChangedAfterEvidence` 合同,没有起一整轮 mock agent 写库。 +4. `signFromTransitions` 的类型表收口仍留在 `divergence-panel.ts`,核心层只返回原始星座名,避免 core 依赖 v9。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 4dc3576c..bb18d552 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -78,7 +78,7 @@ | `TASK-rectification-covered-domain-recollect-20260908.md` | `PROGRESS-rectification-covered-domain-recollect-20260908.md` | 范围已收到 2 分钟、决策已是采用,却再问答过的财务题:职业覆盖后的 yearless→采集分支(L2224)不看领域是否已覆盖;`shouldSkipFollowupPersist` 把它当剩余采集挡住出卡;`persistCollectFocus` 撞 id 无条件 `:next` 再插;holdout `occupied` 不认 health/health_pressure 同义 | 已验收通过 | `7ca6ade2`(BUG-590~591) | | `TASK-rectification-same-year-probe-dedupe-20260908.md` | `PROGRESS-rectification-same-year-probe-dedupe-20260908.md` | 答完「2023 年 5 月前后」又问「2023 年前后」(BUG-559 复发):点选不重跑引擎,同一份 probes 里同域同年两题并存;TS 侧 `existenceProbeAsked` 只降权不排除,`inspectDiscriminatorProbes` 连领域+年份规则都没有。决策:同域同年硬排除、相邻年维持降权 | 已验收通过(真实环境未再出同年题) | `8ae17a26`(BUG-592) | | `TASK-rectification-delivery-report-facts-20260908.md` | `PROGRESS-rectification-delivery-report-facts-20260908.md` | 交付轮验证报告写「宽度 29 分钟」「主限偏向 05:06 / 分盘大运偏向 04:59」「04:53 D10 升狮子座」,与卡片(04:51–04:53)矛盾:`skill_verification_report` 用引擎推断前的 width / dasha_agreement,分盘星座让模型自算 | 已验收通过(Skill 10.0.16) | `06e44104`(BUG-593) | -| `TASK-rectification-new-minute-inherit-20260908.md` | `PROGRESS-rectification-new-minute-inherit-20260908.md` | 5 题答完范围 13 分钟,补迁居事件后回弹到 28 分钟:重算换入的新分钟在携带题的 supports/conflicts 里没名字,一律中性、零冲突存活(BUG-587 决策 2 定错,本单推翻:按分盘星座 / 连续分钟段推结论);batch 内重算不触发 BUG-588 范围句 | 待执行 | `codex/rectification-new-minute-inherit-20260908`(BUG-594) | +| `TASK-rectification-new-minute-inherit-20260908.md` | `PROGRESS-rectification-new-minute-inherit-20260908.md` | 5 题答完范围 13 分钟,补迁居事件后回弹到 28 分钟:重算换入的新分钟在携带题的 supports/conflicts 里没名字,一律中性、零冲突存活(BUG-587 决策 2 定错,本单推翻:按分盘星座 / 连续分钟段推结论);batch 内重算不触发 BUG-588 范围句 | 待验收 | `codex/rectification-new-minute-inherit-20260908`(BUG-594) | | `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) | diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index 957b89c4..f74afc8a 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -123,9 +123,10 @@ 期望: -- 补经历后的范围宽度不得超过答题后宽度加上新增的分钟数 -- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」 -- 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样 +- 补经历后,已答对照题必须按分盘星座或连续分钟段给新分钟打上 support/conflict;不得只因新分钟不在旧名单里就零冲突存活 +- 补经历后的范围宽度不得超过答题后宽度加上「任何已答题都盖不到」的新分钟数;事故形状应保持约 13 分钟,不得弹回约半小时 +- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」,包括 batch 内重算、工具集合里没有 compare 的那一轮 +- 不得出现:只说「记下了」或「接下来我们继续」,交付时范围弹回大约半小时,候选分数跟没答过题一样 ## 9. 同领域同年份的点选题只出一次 diff --git a/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts b/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts index 50d94756..0df3a33e 100644 --- a/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts +++ b/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts @@ -1,3 +1,9 @@ +import { + clockMinutes, + normalizeSignName, + signFromTransitions, + type TransitionSignLookup, +} from "./sign-from-transitions.ts"; import { SCORE_DELTA, STRONG_CONFLICT_ELIMINATION_COUNT, @@ -15,6 +21,11 @@ export type ProbeApplyResult = Readonly<{ strong_conflict_counts: Readonly>; }>; +export type ProbeDirectionContext = Readonly<{ + probe?: ConflictProbe; + transitions?: readonly TransitionSignLookup[]; +}>; + export function outcomeForAnswer(probe: ConflictProbe, answer: AnswerClass): ProbeOutcome | null { return probe.expected_outcomes.find((item) => item.answer_class === answer) ?? null; } @@ -23,13 +34,117 @@ export function directionFor( candidateId: string, outcome: ProbeOutcome, choiceKind?: ConflictProbe["choice_kind"], + context: ProbeDirectionContext = {}, ): ScoreDirection { const halfWeight = outcome.answer_class === "weak_yes" && choiceKind !== "varga_style"; - if (outcome.supports.includes(candidateId)) return halfWeight ? "weak_support" : "support"; - if (outcome.conflicts.includes(candidateId)) return halfWeight ? "weak_conflict" : "conflict"; + const raw = rawDirectionFor(candidateId, outcome, context); + if (raw === "support") return halfWeight ? "weak_support" : "support"; + if (raw === "conflict") return halfWeight ? "weak_conflict" : "conflict"; return "neutral"; } +function rawDirectionFor( + candidateId: string, + outcome: ProbeOutcome, + context: ProbeDirectionContext, +): "support" | "conflict" | "neutral" { + if (outcome.supports.includes(candidateId)) return "support"; + if (outcome.conflicts.includes(candidateId)) return "conflict"; + return inheritRawDirection(candidateId, outcome, context); +} + +function inheritRawDirection( + candidateId: string, + outcome: ProbeOutcome, + context: ProbeDirectionContext, +): "support" | "conflict" | "neutral" { + const probe = context.probe; + if (!probe) return "neutral"; + if (isVargaStyleProbe(probe)) { + return inheritVargaDirection(candidateId, outcome, probe, context.transitions); + } + return inheritSegmentDirection(candidateId, outcome); +} + +function isVargaStyleProbe(probe: ConflictProbe): boolean { + return probe.choice_kind === "varga_style" && probe.semantic_key.startsWith("varga."); +} + +function vargaLayerFromKey(key: string): string | null { + return key.match(/^varga\.(d\d+)/)?.[1] ?? null; +} + +function inheritVargaDirection( + minute: string, + outcome: ProbeOutcome, + probe: ConflictProbe, + transitions: readonly TransitionSignLookup[] | undefined, +): "support" | "conflict" | "neutral" { + const layer = vargaLayerFromKey(probe.semantic_key); + if (!layer || !transitions?.length) return "neutral"; + const sign = normalizeSignName(signFromTransitions(transitions, layer, minute)); + if (!sign) return "neutral"; + const option = probe.style_options?.find((item) => normalizeSignName(item.sign) === sign); + if (!option || option.answer_class === "unsure") return "neutral"; + return option.answer_class === outcome.answer_class ? "support" : "conflict"; +} + +function inheritSegmentDirection( + minute: string, + outcome: ProbeOutcome, +): "support" | "conflict" | "neutral" { + const point = clockMinutes(minute); + if (point == null) return "neutral"; + const known = uniqueMinutes([...outcome.supports, ...outcome.conflicts]); + const left = nearestKnown(known, point, "left"); + const right = nearestKnown(known, point, "right"); + if (!left || !right) return "neutral"; + const inSupports = minuteInsideSet(point, left, right, outcome.supports); + const inConflicts = minuteInsideSet(point, left, right, outcome.conflicts); + if (inSupports === inConflicts) return "neutral"; + return inSupports ? "support" : "conflict"; +} + +function uniqueMinutes(ids: readonly string[]): string[] { + const seen = new Set(); + const rows: string[] = []; + for (const id of ids) { + if (clockMinutes(id) == null || seen.has(id)) continue; + seen.add(id); + rows.push(id); + } + return rows; +} + +function nearestKnown( + known: readonly string[], + point: number, + side: "left" | "right", +): string | null { + const ranked = known + .map((id) => ({ id, at: clockMinutes(id) })) + .filter((item): item is { id: string; at: number } => item.at != null) + .filter((item) => (side === "left" ? item.at < point : item.at > point)) + .sort((left, right) => ( + side === "left" ? right.at - left.at : left.at - right.at + )); + return ranked[0]?.id ?? null; +} + +function minuteInsideSet( + point: number, + left: string, + right: string, + set: readonly string[], +): boolean { + if (!set.includes(left) || !set.includes(right)) return false; + const times = set + .map(clockMinutes) + .filter((value): value is number => value != null); + if (times.length === 0) return false; + return point >= Math.min(...times) && point <= Math.max(...times); +} + /** * Pure reducer: every active candidate is updated from the same probe outcome. * Probe answers move scores; only repeated cumulative conflict can eliminate. @@ -42,6 +157,7 @@ export function applyProbeOutcome( options: { eliminatedIds?: ReadonlySet; strongConflictCounts?: Readonly>; + transitions?: readonly TransitionSignLookup[]; } = {}, ): ProbeApplyResult { const eliminated = new Set(options.eliminatedIds ?? []); @@ -64,8 +180,9 @@ export function applyProbeOutcome( strong_conflict_counts: conflictCounts, }; } + const context: ProbeDirectionContext = { probe, transitions: options.transitions }; for (const [id, score] of Object.entries(scores)) { - const direction = directionFor(id, outcome, probe.choice_kind); + const direction = directionFor(id, outcome, probe.choice_kind, context); if (direction === "conflict") conflictCounts[id] = (conflictCounts[id] ?? 0) + 1; if (eliminated.has(id)) { next[id] = score; @@ -98,3 +215,17 @@ export function applyProbeOutcome( strong_conflict_counts: conflictCounts, }; } + +export function outcomeByMinuteForAnswer( + probe: ConflictProbe, + answer: AnswerClass, + candidateIds: readonly string[], + transitions?: readonly TransitionSignLookup[], +): Readonly> | undefined { + const outcome = outcomeForAnswer(probe, answer); + if (!outcome || answer === "unsure") return undefined; + const context: ProbeDirectionContext = { probe, transitions }; + return Object.fromEntries(candidateIds.map((id) => ( + [id, directionFor(id, outcome, probe.choice_kind, context)] + ))); +} diff --git a/frontend/src/lib/rectification-agentic/core/build-state.ts b/frontend/src/lib/rectification-agentic/core/build-state.ts index 0b23bda6..74baccb2 100644 --- a/frontend/src/lib/rectification-agentic/core/build-state.ts +++ b/frontend/src/lib/rectification-agentic/core/build-state.ts @@ -1,4 +1,5 @@ -import { applyProbeOutcome } from "./apply-probe-outcome.ts"; +import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outcome.ts"; +import type { TransitionSignLookup } from "./sign-from-transitions.ts"; import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts"; import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts"; import { unionStillValidRange } from "./credible-range.ts"; @@ -43,6 +44,7 @@ export function buildInferenceState(input: { previous?: InferenceState | null; answered_probes?: readonly ProbeAnswer[]; transition_times?: readonly string[]; + transitions?: readonly TransitionSignLookup[]; event_ledger?: Readonly>>>; phase?: RectificationPhase; }): InferenceState { @@ -53,6 +55,7 @@ export function buildInferenceState(input: { ); const sameSet = input.previous?.candidate_set_id === setId; const previous = sameSet ? input.previous : null; + const transitions = input.transitions ?? input.previous?.transitions; const events = stickyHoldoutEvents(input.events, previous?.events); const holdoutKeys = holdoutDomainYears(events); const liveProbes = input.probes.filter((probe) => !holdoutKeys.has(`${probe.domain}:${probe.year}`)); @@ -89,6 +92,7 @@ export function buildInferenceState(input: { const applied = applyProbeOutcome(scores, probe, answer.answer_class, { eliminatedIds: eliminated, strongConflictCounts: conflictCounts, + transitions, }); scores = { ...applied.scores }; conflictCounts = { ...applied.strong_conflict_counts }; @@ -146,6 +150,20 @@ export function buildInferenceState(input: { const top = active[0] ?? null; const alreadyAnswered = new Set((input.previous?.answered_probes ?? []).map((item) => item.probe_id)); const newAnswerCount = answers.filter((item) => !alreadyAnswered.has(item.probe_id)).length; + const candidateIds = input.candidates.map((item) => item.id); + const probesWithOutcomes = probes.map((probe) => { + const answer = answers.find((item) => ( + item.probe_id === probe.id || item.semantic_key === probe.semantic_key + )); + if (!answer) return probe; + const byMinute = outcomeByMinuteForAnswer( + probe, + answer.answer_class, + candidateIds, + transitions, + ); + return byMinute ? { ...probe, outcome_by_minute: byMinute } : probe; + }); const draft: InferenceState = { algorithm_version: INFERENCE_ALGORITHM_VERSION, candidate_set_id: setId, @@ -156,7 +174,7 @@ export function buildInferenceState(input: { range_end: input.range_end, candidates, events, - probes, + probes: probesWithOutcomes, answered_probes: answers, rounds, last_inference_round: rounds.at(-1) ?? null, @@ -164,6 +182,7 @@ export function buildInferenceState(input: { representative_time: top?.time ?? null, credible_range: unionStillValidRange(candidates), holdout_passed: holdoutPassed, + ...(transitions ? { transitions } : {}), }; const decision = evaluateConvergence({ ...draft, holdout_passed: holdoutPassed }); return { @@ -240,6 +259,7 @@ export function replayInferenceState( probes: state.probes, previous: { ...state, answered_probes: [], rounds: [] }, answered_probes: answers, + transitions: state.transitions, }); } @@ -333,6 +353,7 @@ function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswe probes: state.probes, previous: state, answered_probes: incoming, + transitions: state.transitions, }); } diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index bb975d6f..54710ae2 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -4,6 +4,7 @@ */ import type { AnswerClass, ConflictProbe } from "./types.ts"; +import { signFromTransitions } from "./sign-from-transitions.ts"; import { D9_TYPE_TABLE, D10_TYPE_TABLE, @@ -196,16 +197,10 @@ function remainingGroupSigns( transitions: readonly WindowScanTransition[], layer: string, ): readonly (string | null)[] { - const changes = transitions - .filter((item) => item.layer === layer) - .filter((item) => /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(item.at)) - .sort((left, right) => clockMinutes(left.at) - clockMinutes(right.at)); return groups.map((group) => { const time = group[0]; if (!time) return null; - const crossed = changes.filter((item) => clockMinutes(item.at) <= clockMinutes(time)); - if (crossed.length === 0) return changes[0]?.from_sign ?? null; - return crossed[crossed.length - 1]?.to_sign ?? changes[0]?.from_sign ?? null; + return signFromTransitions(transitions, layer, time); }); } diff --git a/frontend/src/lib/rectification-agentic/core/compose-receipt.ts b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts index b12aa0f2..a8895697 100644 --- a/frontend/src/lib/rectification-agentic/core/compose-receipt.ts +++ b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts @@ -35,6 +35,7 @@ const ANSWER_CLASSES = new Set(["yes", "weak_yes", "no", "unsure"]); const PROBE_CHOICE_KINDS = new Set(["existence", "varga_style", "event_quality"]); const ANSWER_SOURCES = new Set(["choice", "evidence", "declined"]); const ROUND_KINDS = new Set(["informative", "low_information"]); +const SCORE_DIRECTIONS = new Set(["support", "weak_support", "neutral", "weak_conflict", "conflict"]); function isRecord(value: unknown): value is Readonly> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -62,6 +63,12 @@ function isNumberRecord(value: unknown): boolean { return isRecord(value) && Object.values(value).every(isFiniteNumber); } +function isDirectionRecord(value: unknown): boolean { + return isRecord(value) && Object.values(value).every((item) => ( + typeof item === "string" && SCORE_DIRECTIONS.has(item) + )); +} + function isInferenceEvent(value: unknown): boolean { return isRecord(value) && typeof value.id === "string" && value.id.length > 0 @@ -92,7 +99,8 @@ function isConflictProbe(value: unknown): boolean { && typeof value.source === "string" && value.source.length > 0 && (value.choice_kind === undefined || (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind))) - && (value.carried === undefined || value.carried === true || value.carried === false); + && (value.carried === undefined || value.carried === true || value.carried === false) + && (value.outcome_by_minute === undefined || isDirectionRecord(value.outcome_by_minute)); } function isProbeAnswer(value: unknown): boolean { diff --git a/frontend/src/lib/rectification-agentic/core/index.ts b/frontend/src/lib/rectification-agentic/core/index.ts index c7e9e07c..aaa1a265 100644 --- a/frontend/src/lib/rectification-agentic/core/index.ts +++ b/frontend/src/lib/rectification-agentic/core/index.ts @@ -1,6 +1,7 @@ export * from "./types.ts"; export * from "./entropy.ts"; export * from "./apply-probe-outcome.ts"; +export * from "./sign-from-transitions.ts"; export * from "./select-probe.ts"; export * from "./duplicate-probes.ts"; export * from "./cluster-candidates.ts"; diff --git a/frontend/src/lib/rectification-agentic/core/sign-from-transitions.ts b/frontend/src/lib/rectification-agentic/core/sign-from-transitions.ts new file mode 100644 index 00000000..d9c46c1f --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/sign-from-transitions.ts @@ -0,0 +1,48 @@ +/** + * Minute → varga-sign lookup from window-scan transitions. + * Core must not import v9; callers that need type-table keys wrap this. + */ + +export type TransitionSignLookup = Readonly<{ + layer: string; + at: string; + from_sign?: string; + to_sign?: string; +}>; + +const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/; + +export function clockMinutes(time: string): number | null { + const normalized = time.trim().slice(0, 5); + if (!CLOCK.test(normalized)) return null; + return Number(normalized.slice(0, 2)) * 60 + Number(normalized.slice(3, 5)); +} + +export function normalizeSignName(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + if (!trimmed) return null; + return trimmed.endsWith("座") ? trimmed : `${trimmed}座`; +} + +export function signFromTransitions( + transitions: readonly TransitionSignLookup[], + layer: string, + time: string, +): string | null { + const point = clockMinutes(time); + if (point == null) return null; + const ordered = transitions + .filter((item) => item.layer === layer) + .filter((item) => clockMinutes(item.at) != null) + .slice() + .sort((left, right) => (clockMinutes(left.at) ?? 0) - (clockMinutes(right.at) ?? 0)); + if (ordered.length === 0) return null; + let sign = ordered[0]?.from_sign?.trim() || null; + for (const item of ordered) { + const at = clockMinutes(item.at); + if (at == null) continue; + if (at <= point) sign = item.to_sign?.trim() || sign; + else break; + } + return sign || null; +} diff --git a/frontend/src/lib/rectification-agentic/core/types.ts b/frontend/src/lib/rectification-agentic/core/types.ts index 87318242..513b28c6 100644 --- a/frontend/src/lib/rectification-agentic/core/types.ts +++ b/frontend/src/lib/rectification-agentic/core/types.ts @@ -93,6 +93,8 @@ export type ConflictProbe = Readonly<{ }>[]; /** Kept only so an answered probe can be replayed after the candidate set changes. */ carried?: boolean; + /** Per-minute support/conflict after replay, including inherited new minutes. */ + outcome_by_minute?: Readonly>; }>; export type ProbeAnswer = Readonly<{ @@ -135,4 +137,10 @@ export type InferenceState = Readonly<{ representative_time: string | null; credible_range: readonly [string, string] | null; holdout_passed?: boolean | null; + transitions?: readonly Readonly<{ + layer: string; + at: string; + from_sign?: string; + to_sign?: string; + }>[]; }>; diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts index 2dae7e5c..21778bc0 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts @@ -974,16 +974,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise item.layer === layer) - .slice() - .sort((left, right) => left.at.localeCompare(right.at)); - if (ordered.length === 0) return null; - let sign = ordered[0]?.from_sign?.trim() ?? null; - for (const item of ordered) { - if (item.at <= time) sign = item.to_sign?.trim() ?? sign; - else break; - } + const sign = signFromTransitionLookup(transitions, layer, time); const keyed = sign ? signKey(sign) : ""; if (layer === "d9" && D9_TYPE_TABLE[keyed]) return keyed; if (layer === "d10" && D10_TYPE_TABLE[keyed]) return keyed; diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index 9cc22ce5..6a57e9d8 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -17,6 +17,7 @@ import { previousInferenceFromReceipt as parsePreviousInferenceFromReceipt, } from "../core/compose-receipt.ts"; import type { AnswerClass, ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts"; +import type { TransitionSignLookup } from "../core/sign-from-transitions.ts"; import { datedPrecision } from "./evidence-model.ts"; import { isHoldoutVerificationQuote, @@ -397,6 +398,7 @@ export function buildCaseInferenceState(input: { extraProbes?: readonly ConflictProbe[]; previous?: InferenceState | null; transitionTimes?: readonly string[]; + transitions?: readonly TransitionSignLookup[]; eventLedger?: Readonly>>>; }): InferenceState { const events = input.evidence.map((item) => ({ @@ -425,6 +427,7 @@ export function buildCaseInferenceState(input: { previous: input.previous, answered_probes: answersFromEvidence(probes, events), transition_times: input.transitionTimes, + transitions: input.transitions, event_ledger: input.eventLedger, }); } diff --git a/frontend/src/lib/rectification-agentic/v9/score-persist.ts b/frontend/src/lib/rectification-agentic/v9/score-persist.ts index 04eaa010..153ba423 100644 --- a/frontend/src/lib/rectification-agentic/v9/score-persist.ts +++ b/frontend/src/lib/rectification-agentic/v9/score-persist.ts @@ -410,6 +410,7 @@ export async function scoreAndPersistCurrentEvidence(input: { extraProbes: conflictProbesFromContrast(contrastPacket), previous: keepAnswers ? previousInferenceFromReceipt(previousReceipt ?? null) : null, transitionTimes: windowScan?.transitions.map((item) => item.at) ?? [], + transitions: windowScan?.transitions ?? [], }); const decisionReceipt = { ...receipt, diff --git a/frontend/tests/rectification-probe-replay-loss-20260908.test.ts b/frontend/tests/rectification-probe-replay-loss-20260908.test.ts index a842acb6..6fc604b0 100644 --- a/frontend/tests/rectification-probe-replay-loss-20260908.test.ts +++ b/frontend/tests/rectification-probe-replay-loss-20260908.test.ts @@ -9,7 +9,10 @@ import { import { buildInferenceState, nextProbe, + replayInferenceState, } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts"; +import type { TransitionSignLookup } from "../src/lib/rectification-agentic/core/sign-from-transitions.ts"; import { selectHighestGainProbe } from "../src/lib/rectification-agentic/core/select-probe.ts"; import type { ConflictProbe, ProbeAnswer } from "../src/lib/rectification-agentic/core/types.ts"; import { @@ -183,9 +186,10 @@ test("BUG-587 (a) a changed candidate set still replays five answered probes", ( for (const time of KEPT_TIMES) { assert.equal(deltaFor(s2, time), deltaFor(s1, time), time); } - for (const time of NEW_TIMES) { - assert.equal(deltaFor(s2, time), 0, time); - } + // 04:59 sits between conflict minutes 04:57 and 05:13, so it inherits conflict. + assert.ok(deltaFor(s2, "04:59") < 0); + // 05:14 is past the last known minute and this fixture has no transitions. + assert.equal(deltaFor(s2, "05:14"), 0); assert.ok(width(s2.credible_range) <= width(s1.credible_range) + NEW_TIMES.length); }); @@ -197,9 +201,8 @@ test("BUG-587 (b) a second rescore still replays every answer", () => { for (const time of KEPT_TIMES) { assert.equal(deltaFor(afterRelocation, time), deltaFor(s1, time), time); } - for (const time of NEW_TIMES) { - assert.equal(deltaFor(afterRelocation, time), 0, time); - } + assert.ok(deltaFor(afterRelocation, "04:59") < 0); + assert.equal(deltaFor(afterRelocation, "05:14"), 0); assert.equal( afterRelocation.candidates.every((item) => item.posterior_score === item.prior_score), false, @@ -289,7 +292,12 @@ test("BUG-588 evidence narration names a widened range after compare", () => { const stripAt = settlement.indexOf("stripQuestionSentences"); const rangeAt = settlement.indexOf("withRangeChangedAfterEvidence"); assert.ok(stripAt >= 0 && rangeAt > stripAt); - assert.match(agentRun, /rectification-compare-candidates/); + assert.doesNotMatch(settlement, /toolsUsed\.has\("rectification-compare-candidates"\)/); + assert.match(settlement, /rangeAfterEvidence/); + assert.match( + withRangeChangedAfterEvidence("接下来我们继续。", ["04:47", "04:59"], ["04:47", "05:14"]), + /范围从 04:47–04:59 变为 04:47–05:14。/, + ); }); test("P3 selection cards stay gated by busy and a loaded snapshot", () => { @@ -305,3 +313,231 @@ test("P3 selection cards stay gated by busy and a loaded snapshot", () => { assert.match(block, /caseSnapshotLoaded/); assert.match(chat, /startTransition\(/); }); + +const ACCIDENT_EARLY = ["04:47", "04:51", "04:53", "04:55", "04:57", "04:59"] as const; +const ACCIDENT_LATE = ["05:00", "05:06", "05:15"] as const; +const ACCIDENT_OLD = [...ACCIDENT_EARLY, ...ACCIDENT_LATE] as const; +const ACCIDENT_NEW = [...ACCIDENT_EARLY, "04:48", "04:50", "05:14"] as const; +const ACCIDENT_TRANSITIONS: readonly TransitionSignLookup[] = [ + { layer: "d9", at: "05:00", from_sign: "天蝎座", to_sign: "射手座" }, + { layer: "d10", at: "05:00", from_sign: "巨蟹座", to_sign: "狮子座" }, +]; + +function vargaStyleProbe(input: { + id: string; + layer: "d9" | "d10"; + yesSign: string; + otherSign: string; + domain: string; +}): ConflictProbe { + const semanticKey = `varga.${input.layer}.${input.yesSign}|${input.otherSign}`; + return { + id: input.id, + semantic_key: semanticKey, + candidate_split_hash: `${semanticKey}:${ACCIDENT_EARLY.join(",")}`, + domain: input.domain, + year: 0, + question: semanticKey, + candidate_ids: [...ACCIDENT_OLD], + expected_outcomes: [ + { answer_class: "yes", supports: ACCIDENT_EARLY, conflicts: ACCIDENT_LATE }, + { answer_class: "weak_yes", supports: ACCIDENT_LATE, conflicts: ACCIDENT_EARLY }, + { answer_class: "no", supports: [], conflicts: [] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.4, + source: "varga_contrast", + choice_kind: "varga_style", + style_options: [ + { label: input.yesSign, answer_class: "yes", sign: input.yesSign }, + { label: input.otherSign, answer_class: "weak_yes", sign: input.otherSign }, + ], + }; +} + +function segmentProbe(input: { + id: string; + semanticKey: string; + domain: string; + year: number; + yesSupportsLate: boolean; +}): ConflictProbe { + const yesSupports = input.yesSupportsLate ? ACCIDENT_LATE : ACCIDENT_EARLY; + const yesConflicts = input.yesSupportsLate ? ACCIDENT_EARLY : ACCIDENT_LATE; + return clockProbe({ + id: input.id, + semanticKey: input.semanticKey, + domain: input.domain, + year: input.year, + source: "dasha_boundary", + yesSupports, + yesConflicts, + }); +} + +function accidentProbes(): ConflictProbe[] { + return [ + vargaStyleProbe({ + id: "probe:varga.d9.scorpio", + layer: "d9", + yesSign: "天蝎座", + otherSign: "射手座", + domain: "relationship", + }), + vargaStyleProbe({ + id: "probe:varga.d10.cancer", + layer: "d10", + yesSign: "巨蟹座", + otherSign: "狮子座", + domain: "career", + }), + segmentProbe({ + id: "probe:career.2023.05", + semanticKey: "career.2023.05", + domain: "career", + year: 2023, + yesSupportsLate: true, + }), + segmentProbe({ + id: "probe:career.2024.04", + semanticKey: "career.2024.04", + domain: "career", + year: 2024, + yesSupportsLate: true, + }), + segmentProbe({ + id: "probe:relocation.2015.05", + semanticKey: "relocation.2015.05", + domain: "relocation", + year: 2015, + yesSupportsLate: false, + }), + segmentProbe({ + id: "probe:family.2011", + semanticKey: "family.2011", + domain: "family", + year: 2011, + yesSupportsLate: false, + }), + ]; +} + +function accidentAnswers(probes: readonly ConflictProbe[]): ProbeAnswer[] { + return probes.map((probe) => ({ + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + answer_class: probe.semantic_key === "career.2023.05" ? "no" as const : "yes" as const, + classified_from: "choice" as const, + })); +} + +function buildAccidentState( + times: readonly string[], + previous?: ReturnType | null, +) { + const probes = previous ? [] : accidentProbes(); + return buildInferenceState({ + range_start: "04:45", + range_end: "05:15", + candidates: engineCandidates(times), + events: EVENTS, + probes, + previous, + answered_probes: previous ? [] : accidentAnswers(accidentProbes()), + transitions: ACCIDENT_TRANSITIONS, + transition_times: ACCIDENT_TRANSITIONS.map((item) => item.at), + }); +} + +test("BUG-594 (a) a new minute inherits varga signs and segment outcomes and is eliminated", () => { + const s1 = buildAccidentState(ACCIDENT_OLD); + assert.equal(s1.answered_probes.length, 6); + for (const time of ACCIDENT_LATE) { + const row = s1.candidates.find((item) => item.time === time); + assert.ok(row, time); + assert.ok(row.strong_conflict_count >= 3, time); + assert.equal(row.status, "eliminated", time); + } + assert.deepEqual(s1.credible_range, ["04:47", "04:59"]); + + const s2 = buildAccidentState(ACCIDENT_NEW, s1); + const born = s2.candidates.find((item) => item.time === "05:14"); + assert.ok(born); + assert.ok(born.strong_conflict_count >= 3); + assert.equal(born.status, "eliminated"); + assert.deepEqual(s2.credible_range, ["04:47", "04:59"]); + assert.ok(s2.rounds.some((round) => (round.score_deltas?.["05:14"] ?? 0) !== 0)); + const d9 = s2.probes.find((item) => item.semantic_key.startsWith("varga.d9.")); + assert.equal(d9?.outcome_by_minute?.["05:14"], "conflict"); + const career2024 = s2.probes.find((item) => item.semantic_key === "career.2024.04"); + assert.equal(career2024?.outcome_by_minute?.["05:14"], "support"); + const career2023 = s2.probes.find((item) => item.semantic_key === "career.2023.05"); + assert.equal(career2023?.outcome_by_minute?.["05:14"], "conflict"); + const replayed = replayInferenceState(s2, s2.answered_probes); + assert.equal(replayed.candidates.find((item) => item.time === "05:14")?.status, "eliminated"); +}); + +test("BUG-594 (b) a new minute between two outcome sets stays neutral on that probe", () => { + const probe = clockProbe({ + id: "probe:career.2023.05", + semanticKey: "career.2023.05", + domain: "career", + year: 2023, + source: "dasha_boundary", + yesSupports: ["04:47", "04:51", "04:53"], + yesConflicts: ["05:06", "05:15"], + }); + const scores = Object.fromEntries( + ["04:47", "04:51", "04:53", "05:00", "05:06", "05:15"].map((time) => [time, 20]), + ); + const applied = applyProbeOutcome(scores, probe, "yes"); + assert.equal(applied.deltas["05:00"], 0); + assert.ok((applied.deltas["04:47"] ?? 0) > 0); + assert.ok((applied.deltas["05:06"] ?? 0) < 0); +}); + +test("BUG-594 (c) a varga probe without transitions leaves a new minute neutral", () => { + const probe = vargaStyleProbe({ + id: "probe:varga.d9.scorpio", + layer: "d9", + yesSign: "天蝎座", + otherSign: "射手座", + domain: "relationship", + }); + const scores = Object.fromEntries([...ACCIDENT_OLD, "05:14"].map((time) => [time, 20])); + const applied = applyProbeOutcome(scores, probe, "yes"); + assert.equal(applied.deltas["05:14"], 0); + const withSigns = applyProbeOutcome(scores, probe, "yes", { + transitions: ACCIDENT_TRANSITIONS, + }); + assert.ok((withSigns.deltas["05:14"] ?? 0) < 0); +}); + +test("BUG-594 (d) successive covered new minutes do not widen the 13-minute accident range", () => { + let state = buildAccidentState(ACCIDENT_OLD); + assert.deepEqual(state.credible_range, ["04:47", "04:59"]); + assert.equal(width(state.credible_range), 12); + const rounds: readonly (readonly string[])[] = [ + ACCIDENT_NEW, + [...ACCIDENT_EARLY, "04:49", "04:52", "05:14"], + [...ACCIDENT_EARLY, "04:48", "04:54", "05:13"], + ]; + for (const times of rounds) { + const previousWidth = width(state.credible_range); + const previousIds = new Set(state.candidates.map((item) => item.id)); + state = buildAccidentState(times, state); + const uncovered = times.filter((time) => { + if (previousIds.has(time)) return false; + return state.probes.every((probe) => ( + !probe.outcome_by_minute || probe.outcome_by_minute[time] === "neutral" + )); + }); + assert.ok( + width(state.credible_range) <= previousWidth + uncovered.length, + `${times.join(",")}: ${width(state.credible_range)} > ${previousWidth}+${uncovered.length}`, + ); + } + assert.equal(width(state.credible_range), 12); + assert.deepEqual(state.credible_range, ["04:47", "04:59"]); +});