fix(rectification): inherit answered-probe outcomes onto new minutes (BUG-594)
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>
This commit is contained in:
@@ -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
@@ -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。
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user