Compare commits

...
Author SHA1 Message Date
Jesse_ChenandCursor be71104313 fix(rectification): inherit answered-probe outcomes onto new minutes (BUG-594)
Independent Staging Quality Gate / validate (push) Successful in 13m45s
Independent Staging Quality Gate / publish (push) Successful in 12m20s
Rescored candidate sets were widening because new minutes stayed
neutral. Derive support/conflict from varga signs and dasha segments,
and announce range changes from facts rather than tool names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 17:33:24 +08:00
Jesse_ChenandClaude Fable 5.1 6aba672ce5 docs(tasks): accept BUG-592/593 on 6a687085
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-08 08:48:07 +00:00
Jesse_ChenandClaude Fable 5.1 56067fda28 docs(tasks): new-minute inheritance fix brief (BUG-594)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-08 08:47:30 +00:00
20 changed files with 671 additions and 55 deletions
+5
View File
@@ -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。
+26 -10
View File
@@ -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`)
@@ -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.<layer>.` 开头):`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。
+3 -2
View File
@@ -76,8 +76,9 @@
| `TASK-rectification-other-collect-fallback-20260908.md` | `PROGRESS-rectification-other-collect-fallback-20260908.md` | 七领域问完落到「也可以再说一件」、职业题从未出现、不自动出卡:`USER_COLLECT_QUESTION.other` 仍有三条活路(职业焦点 `target_domain` 压成 other 后被 active-focus 承接 followup 重建;set-focus 两次无效兜底查表;无领域采集归 other),`collect:other:*` 焦点挂着让 `persistNextInterviewIfIdle` 的出卡路径永远不跑 | 已验收通过(触发链仍 investigating,BUG-586 mitigated) | `9aec5029`(BUG-586) |
| `TASK-rectification-probe-replay-loss-20260908.md` | `PROGRESS-rectification-probe-replay-loss-20260908.md` | **P0** 收敛不了的直接原因:答完 5 道题范围收到 8 分钟,之后每补一件经历触发的重算换了候选集,`buildInferenceState` 找不到已答题定义(引擎与对照包按 asked keys 排除、状态 probes 不携带、分盘 key 内嵌分钟列表)→ `rounds:[]`、posterior=prior,交付回到 29 分钟;证据轮旁白不报范围变化;交付卡闪现待证实 | 已验收通过(真实 Case 范围收到 2 分钟;BUG-589 闪现 investigating) | `df182c16`(BUG-587~589) |
| `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,分盘星座让模型自算 | 待验收 | `06e44104`(BUG-593,Skill 10.0.16) |
| `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-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) |
@@ -47,3 +47,20 @@ ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ./node_modules/.bin/tsc --noEmit; npm run lint 2>&1 | tail -1
ls tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts tests/skill-registry.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
```
## 验收(Claude,2026-09-08,`origin/staging` @ `6a687085`,staging 已部署同 SHA)
| 门 | 结果 |
| --- | --- |
| tsc | 0 错 |
| lint | 0 error / 103 warning |
| 前端 rectification + consultation + voice + skill-registry(非 DB) | 1270 / 0 |
| Python v5_services + event_probes + growth contract | 全绿 |
| 项 | 结论 |
| --- | --- |
| 4.1 决策 1 | 通过。真实 Case 报告"28 分钟不可分区间(04:47–05:14)"与卡片一致;双轨改为 active 过滤,未点名已淘汰分钟 |
| 4.2 决策 2 | 通过。`sign_by_candidate` 表进报告,真实 Case 04:53 D10 写巨蟹座;Skill 10.0.16 与 versions/ 字节一致 |
| 4.3 记录 | 通过 |
结论:通过。但同一 Case 暴露范围回弹(13→28 分钟),另立 `TASK-rectification-new-minute-inherit-20260908.md`(BUG-594)。
@@ -0,0 +1,70 @@
# TASK · 生时校正修复单:重算后新出现的候选分钟不继承已答题结论,范围从 13 分钟回弹到 28 分钟;batch 内重算不报范围变化(2026-09-08)
- 基线:`origin/staging` @ `6a687085`(BUG-592/593 已合入并部署;本轮转录跑在这版)
- 分支:`codex/rectification-new-minute-inherit-20260908`,基于 `origin/staging`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/rectification-agentic/core/build-state.ts`(`carriedAnsweredProbes`、重放循环)、`core/apply-probe-outcome.ts`(`directionFor`)、`v9/inference-adapter.ts`(`buildCaseInferenceState` 传 `window_scan.transitions`)、`v9/divergence-panel.ts::signFromTransitions`(复用)、`v9/agent-run.ts` L977(范围句触发条件)
- BUG 编号起点:**BUG-594**
- 优先级:**P1**(BUG-587 的"新分钟中性"决策是我定的,实测证明它让范围回弹;用户问"这样是不是已经可以了",答案是流程可以、结果不行)
## 1. 事故实证(2026-09-08 staging `6a687085`,只写结构)
| 时点 | 旁白报的范围 |
| --- | --- |
| 5 道点选题答完(D9、2023.05、2015.05、2024.04、D10) | 04:47–04:59(13 分钟) |
| 之后补迁居事件 → batch 内 compare 重算 | 旁白只说"接下来我们继续",**没有范围句** |
| 健康拒答、职业答完 → 交付 | 卡片 04:47–05:14(28 分钟),报告"当前候选基本并列:04:53、04:59、04:51、04:47、05:14",相对支持 17/13/11/10/10 |
报告里的分盘表:05:14 的 D9 是射手座、D10 是狮子座。用户在 D9 题选了天蝎座一侧、D10 题选了巨蟹座一侧,05:14 在两道题上都该是 conflict;2024.04 事业题答"发生"支持 05:00 之后的分钟,2023.05 答"没发生"又反对 05:00–05:14——按老候选集的账,05:xx 这一侧早就被淘汰过(所以此前范围是 04:47–04:59)。05:14 是重算后新进候选集的分钟,它在任何一道已答题的 `supports / conflicts` 里都没有名字,于是 6 道题对它全部记 0,`strong_conflict_count = 0`,活着,范围右端被它拉到 05:14。
## 2. 根因
### 2.1 BUG-594(P1):新分钟对携带题一律中性
`TASK-rectification-probe-replay-loss-20260908.md` 决策 2 写的是"新候选集里不在旧 `supports/conflicts` 的分钟落为 `neutral`,不做邻近插值"。`apply-probe-outcome.ts::directionFor` 按候选 id 查成员,新分钟查不到 → `neutral`。引擎每次重算都重新挑 9 个分钟,几乎每次都有新分钟;新分钟零冲突就成了"幸存者",范围只会变宽不会变窄。这条是我在任务书里定错的,本单推翻。
题目语义本来就是按分钟连续定义的:分盘风格题按该分钟的分盘上升星座(`window_scan.transitions` 给出每层换升时刻),大运边界/激活题的 `expected_outcomes` 是连续分钟段。新分钟的结论可以确定性地推出来,不需要"插值"猜。
### 2.2 BUG-588 缺口:batch 内重算不触发范围句
`agent-run.ts` L977 只在 `toolsUsed.has("rectification-compare-candidates")` 时接范围句;证据轮的 compare 是 `rectification-record-evidence-batch` 内部跑的,工具集合里没有 compare,于是 13→28 分钟没有一句提示。
## 3. 决策记录
1. **携带题对新分钟按题目语义推结论,不再中性。**
- 分盘风格题(`choice_kind = varga_style`,`semantic_key` 以 `varga.<layer>.` 开头):用 `signFromTransitions(transitions, layer, minute)` 得到新分钟的该层上升星座,按 `style_options[].sign` 与已答 `answer_class` 判 `support / conflict`;没有 transitions 或该层无星座 → `neutral`。
- 大运边界/激活/事件质量题(`expected_outcomes` 的 supports/conflicts 是分钟集合):新分钟落在某个 outcome 集合的**最小分钟与最大分钟之间且两侧最近的旧分钟同属该集合** → 继承该集合;否则 `neutral`。
- 携带题在状态里补 `outcome_by_minute`(或等价缓存)以便审计;`rounds.score_deltas` 对新分钟出现非 0 值。
2. **`buildCaseInferenceState` 把 `window_scan.transitions` 传给核心层**(`score-persist.ts` 已有 `windowScan`),核心层不依赖 v9;`signFromTransitions` 下沉到 `core/`,`divergence-panel.ts` 改为引用。
3. **范围句改看事实不看工具。** `agent-run.ts`:本轮结算前比较 `dossier.latestResult.decisionReceipt.inference_state.credible_range` 与开始时的值(或 `resultId` 变化),变了就接 `withRangeChangedAfterEvidence`,不再看 `toolsUsed`。
4. 淘汰阈值 `STRONG_CONFLICT_ELIMINATION_COUNT = 3`、`SCORE_DELTA`、各道门槛、四选项合同不动;Skill 不 bump。
## 4. 任务分解
### 4.1 BUG-594(决策 1、2)
- 验收(`rectification-probe-replay-loss-20260908.test.ts` 扩):
- (a) 旧候选集 9 分钟、6 道已答题使 05:00 之后全部 `strong_conflict_count ≥ 3` 被淘汰、范围 04:47–04:59;新候选集换入 05:14(D9 射手、D10 狮子,2024.04 supports 段内、2023.05 conflicts 段内)→ 重放后 05:14 `strong_conflict_count ≥ 3`、`status = eliminated`,`credible_range` 仍为 04:47–04:59。
- (b) 换入的新分钟落在两个 outcome 集合之间(两侧旧分钟分属不同集合)→ 该题对它 `neutral`。
- (c) 无 `transitions` 时分盘题对新分钟 `neutral`(退化到今天的行为,不报错)。
- (d) 真实 Case 回放(用 `exp_case_head.py` 的合成 7 事件序列):每次加事件后 `credible_range` 宽度 ≤ 上一轮宽度 + 新分钟数中未被任何题覆盖的个数;本轮事故形状应保持 13 分钟。
### 4.2 BUG-588 缺口(决策 3)
- 验收:mock 一轮 batch 触发的 compare,范围 04:47–04:59 → 04:47–05:14,落库 `assistant_message` 末尾含范围句;范围不变不含;用 compare 工具的路径行为不变。
### 4.3 记录
- `docs/BUG_HISTORY.md` BUG-594(关联 BUG-587;写明是任务书决策 2 定错);BUG-588 记录补"后续复发:batch 路径"并在同条下修复;`CHANGELOG.md`;`PROGRESS-rectification-new-minute-inherit-20260908.md`;`docs/testing/rectification-scenarios-20260907.md` 加"答题后再补经历,范围不得变宽;变了必须有范围句"。
## 5. 让步顺序
4.1 (a) 必做并当天部署;(b)(c) 必做;(d) 可用现有合成序列;4.2 必做;4.3 不可省。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-new-minute-inherit-20260908 .worktrees/rectification-new-minute-inherit-20260908 origin/staging
cd .worktrees/rectification-new-minute-inherit-20260908
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ./node_modules/.bin/tsc --noEmit; npm run lint 2>&1 | tail -1
ls tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
```
@@ -58,3 +58,20 @@ cd frontend && ./node_modules/.bin/tsc --noEmit; npm run lint 2>&1 | tail -1
ls tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
cd .. && .venv/bin/python -m pytest tests/test_rectification_event_probes.py -q
```
## 验收(Claude,2026-09-08,`origin/staging` @ `6a687085`,staging 已部署同 SHA)
| 门 | 结果 |
| --- | --- |
| tsc | 0 错 |
| lint | 0 error / 103 warning |
| 前端 rectification + consultation + voice + skill-registry(非 DB) | 1270 / 0 |
| Python v5_services + event_probes + growth contract | 全绿 |
| 项 | 结论 |
| --- | --- |
| 4.1 (a)(c)(d) | 通过。`sameYearProbeAsked` 在 inspect 与 followup 两条路都硬排除;真实 Case(2026-09-08 第八次实测)答完 2023.05 后未再出 2023 activation |
| 4.1 (b) | 通过。相邻年只降权;偏离 3(`remainingConflictProbes` 改同年硬排除)合理 |
| 4.2 记录 | 通过(复发自 BUG-559) |
结论:通过。
@@ -123,9 +123,10 @@
期望:
- 补经历后的范围宽度不得超过答题后宽度加上新增的分钟数
- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」
- 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样
- 补经历后,已答对照题必须按分盘星座或连续分钟段给新分钟打上 support/conflict;不得只因新分钟不在旧名单里就零冲突存活
- 补经历后的范围宽度不得超过答题后宽度加上「任何已答题都盖不到」的新分钟数;事故形状应保持约 13 分钟,不得弹回约半小时
- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」,包括 batch 内重算、工具集合里没有 compare 的那一轮
- 不得出现:只说「记下了」或「接下来我们继续」,交付时范围弹回大约半小时,候选分数跟没答过题一样
## 9. 同领域同年份的点选题只出一次
@@ -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<Record<string, number>>;
}>;
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<string>();
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<string>;
strongConflictCounts?: Readonly<Record<string, number>>;
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<Record<string, ScoreDirection>> | 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)]
)));
}
@@ -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<Record<string, Readonly<Record<string, number>>>>;
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,
});
}
@@ -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);
});
}
@@ -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<Record<string, unknown>> {
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 {
@@ -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";
@@ -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;
}
@@ -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<Record<string, ScoreDirection>>;
}>;
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;
}>[];
}>;
@@ -974,16 +974,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (next !== answerText) answerText = next;
}
}
if (toolsUsed.has("rectification-compare-candidates")) {
const rangeAfterCompare = previousInferenceFromReceipt(
latestDossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
answerText = withRangeChangedAfterEvidence(
answerText,
rangeBeforeCompare,
rangeAfterCompare,
);
}
const rangeAfterEvidence = previousInferenceFromReceipt(
latestDossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
answerText = withRangeChangedAfterEvidence(
answerText,
rangeBeforeCompare,
rangeAfterEvidence,
);
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
return completeAttempt();
} finally {
@@ -6,6 +6,7 @@
*/
import { rankActive } from "../core/convergence-evaluator.ts";
import { signFromTransitions as signFromTransitionLookup } from "../core/sign-from-transitions.ts";
import type {
ConflictProbe,
InferenceCandidate,
@@ -127,16 +128,7 @@ export function signFromTransitions(
layer: "d9" | "d10",
time: string,
): string | null {
const ordered = transitions
.filter((item) => 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;
@@ -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<Record<string, Readonly<Record<string, number>>>>;
}): 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,
});
}
@@ -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,
@@ -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<typeof buildInferenceState> | 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"]);
});