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>
This commit is contained in:
Jesse_Chen
2026-09-08 17:33:24 +08:00
parent 6aba672ce5
commit be71104313
17 changed files with 565 additions and 54 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:4704:59 → 04:4705: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:14D9 射手、D10 狮子,2024.04 支持段、2023.05 反对段) | `strong_conflict_count = 0`、活着、范围到 05:14 | `≥ 3``eliminated`、范围仍 04:4704:59 | 决策 1 |
| 新分钟夹在两个 outcome 集合之间 | 中性(碰巧) | 仍中性 | 决策 1 的「两侧同属」条件 |
| 分盘题无 transitions | 中性 | 仍中性,不报错 | 决策 1 退化 |
| 证据轮 batch 内范围 04:4704:59 → 04:4705:14 | 无范围句(没有 compare 工具名) | 旁白末尾接范围句 | 决策 3 |
| BUG-587 旧夹具里的 04:59 | 新分钟 delta 0 | 落在 04:5705:13 冲突段内,继承 conflict | 推翻决策 2 后的正确结果 |
## 测试
- `frontend` `./node_modules/.bin/tsc --noEmit`exit 0
- 本单改动文件 ReadLints0 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。
+1 -1
View File
@@ -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-590591 |
| `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-505509 |
@@ -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:4704:59 变为 04:4705: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"]);
});