fix(rectification): drop same-year probes after a dated answer (BUG-592)

Point-choice used one compare packet, so a May existence card and the same-domain year card both stayed eligible. Hard-exclude same domain+year; keep adjacent years downranked.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 15:47:25 +08:00
parent f17af254c5
commit 8ae17a2630
10 changed files with 562 additions and 12 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-09-08 — 答过某年某月后,不再问同一领域同一年的整年题
生时校正点选题里,已经答过「2023 年 5 月前后有没有…」之后,不会再问「2023 年前后有没有…」。相邻年仍可能问,因为分开的是另一组候选。Skill 版本仍是 10.0.15。
## 2026-09-08 — 范围收到后不再把已答领域再问一遍
生时校正把七个带年月领域和职业问完、范围已经收到、决策已是采用时,下一步是区间交付卡。不会再把已经答过的财务或其他领域采集题拿出来问。同一道采集题撞过题号时,也不会改个后缀再问一次。Skill 版本仍是 10.0.15。
+19 -3
View File
@@ -8647,9 +8647,9 @@
## BUG-559 | 同年月拆成多道「有没有 X」,同域同年再问,无锚点性格卡
- 状态:mitigated
- 状态:mitigated(点选同年复发见 BUG-592
- 首次发现:2026-09-06
- 最近更新:2026-09-06
- 最近更新:2026-09-08
- 影响面:`scripts/rectification/event_probes.py``asked_probe_keys``existenceProbeAsked``rankRenderableDiscriminators`
- 用户现象:同一大运边界月按领域各问一次「那时候有没有入职 / 有没有搬家」;已问过某年 5 月后又问同年无月份;没有对应经历的「平时做事更接近哪一种」性格卡也会出。
- 触发条件:引擎按领域扫描边界月;去重只看账本年份不看已问探针;`varga_style` 不要求同域带年月证据。
@@ -8657,7 +8657,7 @@
- 修复:请求可带 `asked_probe_keys`(不进结果指纹)。已问 `domain.Y.MM.*` 后,同年与相邻年不再出该领域 existence / activation。`varga_style` 无同域 confirmed 带年月事件则 `dropped_probes(unanchored_varga_style)`;有锚点时 hint 带年月、分数 ×0.8,用户可见题干仍不写年份。邻近账本事件以领域+年月(无摘要)写入 `user_prompt_hint`。B2 同年月合并卡 `choice_kind=boundary_pair` 本单未做。
- 验证:`tests/test_rectification_event_probes.py` 已问同年相邻年不再产出;`frontend/tests/rectification-probe-year-dedupe-20260906.test.ts`
- 防复发:不得把 `asked_probe_keys` 写入结果指纹。点选答案仍不写证据账本。不得为性格卡在用户可见题干里写年份。
- 相关记录:BUG-540、BUG-390
- 相关记录:BUG-540、BUG-390、BUG-592
- 复发自:无
- 修复版本:`3a9ae736`
@@ -9174,3 +9174,19 @@
- 复发自:BUG-462`:next` 重试本为穷尽采集撞开场题号)
- 修复版本:待提交(`codex/rectification-covered-domain-recollect-20260908`
## BUG-592 | 答完「2023 年 5 月前后」又问「2023 年前后」(同域同年点选题)
- 状态:resolved
- 首次发现:2026-09-08
- 最近更新:2026-09-08
- 影响面:`inspectDiscriminatorProbes``rankRenderableDiscriminators``existenceProbeAsked` / `sameYearProbeAsked`、点选后 `decideAfterInferenceChange`
- 用户现象:已经答过某年某月「有没有入职/换工作」,下一张点选卡又问同一领域同一年的整年题,题干几乎同一句。
- 触发条件:同一次候选比较生成的 `inference_state.probes` 里同时有 `domain.YYYY.MM.*``domain.YYYY.*`;点选不重跑引擎,按信息量从高到低问。
- 根因:BUG-559 引擎侧只在重新生成时按 `asked_probe_keys` 屏蔽同年与相邻年;点选下一题走 TS 对照包。`inspectDiscriminatorProbes` 只认精确 `semantic_key` / hash / `probe_id`,没有领域+年份规则。`rankRenderableDiscriminators` 虽调 `existenceProbeAsked`,结果只是把 `asked=true` 交给 `rankDiscriminatorScore` 降权,不排除。旧验证只锁了引擎重生成和 `existenceProbeAsked` 返回值。
- 修复:同领域同年份硬排除(`dropped_probes(reason="same_year_asked")`),`year=0` 的分盘风格题不动。相邻年(±1)继续降权、不硬排除。`existenceProbeAsked` 拆出 `sameYearProbeAsked`。引擎 `asked_probe_keys``SCORE_DELTA`、四选项合同、Skill 版本(仍 10.0.15)不动。
- 验证:`frontend/tests/rectification-probe-year-dedupe-20260906.test.ts`:答完 5 月后不得再问同年 activation、相邻年 2024 仍可问、`buildMethodFollowupPlan` 同口径、事故五题回放第五题不再是同年整年题。`frontend/tests/rectification-inference-machine.test.ts` 锁核心 identity 不去重、inspect 硬排除。`tests/test_rectification_event_probes.py` 不改、须仍绿。
- 防复发:同一份 probes 列表里,同领域同年份答过一道就不得再问第二道。不得只靠降权。相邻年不得被硬排除。点选路径必须走 `inspectDiscriminatorProbes` / `rankRenderableDiscriminators` 的同年硬排除,不能只依赖引擎重生成。
- 相关记录:BUG-559
- 复发自:BUG-559(引擎侧只管重生成;TS 侧只降权)
- 修复版本:待提交(`codex/rectification-same-year-probe-dedupe-20260908`
@@ -0,0 +1,41 @@
# PROGRESS · 同域同年点选题去重(2026-09-08)
工作树:`.worktrees/rectification-same-year-probe-dedupe-20260908`
分支:`codex/rectification-same-year-probe-dedupe-20260908`
任务书:`docs/tasks/TASK-rectification-same-year-probe-dedupe-20260908.md`
基线:`origin/staging` @ `f17af254`(含 BUG-592 修复单 `f3a1a583`;代码基线仍是 `7ca6ade2`
本单状态:**待验收**。
未改:引擎 `asked_probe_keys``SCORE_DELTA`、四选项合同、采用门、确认门、Skill 版本(仍 10.0.15)。相邻年仍只降权。
## 做了什么
- **决策 1** `sameYearProbeAsked`:同 `domain` 且同 `year``SEMANTIC_YEAR_KEY` 解析)硬排除。`inspectDiscriminatorProbes``rankRenderableDiscriminators`event / contrast)都 `dropped_probes(reason="same_year_asked")``year=0` 分盘风格题不受影响。
- **决策 2** 相邻年(±1)仍走 `existenceProbeAsked``rankDiscriminatorScore` 降权,不硬排除。`remainingReverseVerifyProbes` 继续按邻近年排除(采用后核对,不动)。
- **决策 3** `remainingConflictProbes` 改用 `sameYearProbeAsked`,让邻近年事件探针还能进排序降权,而不是在进目录前被整年砍掉。
- **决策 4** 记录 BUG-592(复发自 BUG-559)、CHANGELOG、手测脚本第 9 条。
## 三栏(被触碰断言)
| 用例 | 原值 | 新值 | 理由 |
| --- | --- | --- | --- |
| 答完 `career.2023.05.*` 后选 `career.2023.dasha_activation` | 会问(inspect 无年份规则;followup 只降权) | 不问,`dropped_probes.reason = same_year_asked` | 决策 1 |
| 同列表 `career.2024.04.*` | 可问 | 仍可问 | 决策 2 |
| 事故五题回放第五题 | `career.2023.dasha_activation` | D10 风格题或交付 | 决策 1 |
| 核心 `isDuplicateProbe` | 不同 key 不算重复 | 仍不算;inspect 负责硬排除 | 旧防线缺口 |
## 测试
- `frontend` `./node_modules/.bin/tsc --noEmit`exit 0
- 任务书指定 TS 切片(`tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts`,排除 database):**1012 passed / 0 failed**
- `.venv/bin/python -m pytest tests/test_rectification_event_probes.py -q`34 passed
- `npm run lint`:本机对改动文件 ReadLints 0 error;全量 `npm run lint` 未在本轮沙盒跑完
- 浏览器真人走查:本轮未跑,见 `docs/testing/rectification-scenarios-20260907.md` §9
- 工作期间 `origin/staging` 又合入 `06e44104`BUG-593)。本分支仍基于 `f17af254`,未自动 merge(工作区有未提交文档与代码,且与 BUG-593 改了同一批 changelog / BUG_HISTORY / 手测脚本)
## 偏离
1. 任务书开工基线写 `7ca6ade2`。本 worktree 跟 `origin/staging` 尖端 `f17af254`(多了 BUG-593 修复单文档,无代码差)。
2. `sameYearProbeAsked` 放在 `probe-question-contract.ts`,避免 `candidate-contrast-packet``method-followup` 循环依赖。
3. `remainingConflictProbes` 从邻近年硬排除改为仅同年硬排除,否则 event 探针路径仍会把有信息量的 2024 题提前砍掉。
+1 -1
View File
@@ -76,7 +76,7 @@
| `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` 的出卡路径永远不跑 | 已验收通过(触发链仍 investigatingBUG-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-587589 |
| `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` 连领域+年份规则都没有。决策:同域同年硬排除、相邻年维持降权 | 待执行 | `codex/rectification-same-year-probe-dedupe-20260908`BUG-592 |
| `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` 连领域+年份规则都没有。决策:同域同年硬排除、相邻年维持降权 | 待验收 | `codex/rectification-same-year-probe-dedupe-20260908`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,分盘星座让模型自算 | 待执行 | `codex/rectification-delivery-report-facts-20260908`BUG-593Skill 10.0.16 |
| `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 |
@@ -127,3 +127,15 @@
- 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」
- 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样
## 9. 同领域同年份的点选题只出一次
资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。
开场后说到能出对照卡。若出现「某年某月前后有没有入职/换工作」,答「明确没有发生」。
期望:
- 下一张点选卡不得再问同一领域同一年的整年题(例如刚答过 2023 年 5 月,不得再问 2023 年前后)
- 相邻年(例如 2024 年 4 月)仍可以问
- 不得出现:同一句「有没有入职、换工作或职责加重」只把月份去掉再问一遍
@@ -17,6 +17,8 @@ import {
isRenderableProbe,
type DroppedProbe,
rankDiscriminatorScore,
sameYearProbeAsked,
SEMANTIC_YEAR_KEY,
} from "../v9/probe-question-contract.ts";
export type ContrastChoiceKind = "existence" | "varga_style" | "event_quality";
@@ -405,6 +407,18 @@ export function vargaLayerFromSemanticKey(key: string): string | null {
return key.match(/^varga\.(d\d+)/)?.[1] ?? null;
}
function probeDomainYear(probe: Pick<CandidateDiscriminatorProbe, "domain" | "year" | "semanticKey">): {
domain: string;
year: number;
} | null {
if (probe.domain && probe.year && probe.year > 0) {
return { domain: probe.domain, year: probe.year };
}
const match = probe.semanticKey.match(SEMANTIC_YEAR_KEY);
if (!match) return null;
return { domain: match[1], year: Number(match[2]) };
}
export function inspectDiscriminatorProbes(
packet: CandidateContrastPacket | null | undefined,
options?: {
@@ -461,6 +475,15 @@ export function inspectDiscriminatorProbes(
});
return [];
}
const domainYear = probeDomainYear(completed.probe);
if (domainYear && sameYearProbeAsked(asked, domainYear.domain, domainYear.year)) {
dropped.push({
semantic_key: completed.probe.semanticKey,
information_gain: completed.probe.informationGain,
reason: "same_year_asked",
});
return [];
}
const layer = vargaLayerFromSemanticKey(completed.probe.semanticKey);
const askedAlready = asked.has(completed.probe.semanticKey)
|| asked.has(completed.probe.candidateSplitHash)
@@ -110,6 +110,8 @@ import {
informationGainAmongActive,
isRenderableProbe,
rankDiscriminatorScore,
sameYearProbeAsked,
SEMANTIC_YEAR_KEY,
EXISTENCE_STYLE_OPTIONS,
type DroppedProbe,
type ProbeStyleOption,
@@ -142,6 +144,7 @@ import {
} from "./window-widen.ts";
import { DATE_RELIABILITY_PROMPT, pendingDateReliabilityEvidence } from "./date-reliability.ts";
export { sameYearProbeAsked };
export const METHOD_FOLLOWUP_IDS = [
"dasha_events",
@@ -335,18 +338,18 @@ function existenceNearbyYears(domain: string): number {
return EXISTENCE_NEARBY_YEARS[domain] ?? 0;
}
const SEMANTIC_YEAR = /^([a-z_]+)\.((?:19|20)\d{2})(?:\.|$)/;
export function existenceProbeAsked(
askedKeys: ReadonlySet<string> | readonly string[],
domain: string,
year: number,
): boolean {
if (!domain || !year) return false;
if (sameYearProbeAsked(askedKeys, domain, year)) return true;
const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys);
const nearby = existenceNearbyYears(domain);
if (nearby <= 0) return false;
for (const key of asked) {
const match = key.match(SEMANTIC_YEAR);
const match = key.match(SEMANTIC_YEAR_KEY);
if (!match || match[1] !== domain) continue;
const askedYear = Number(match[2]);
if (Number.isInteger(askedYear) && Math.abs(askedYear - year) <= nearby) return true;
@@ -806,7 +809,7 @@ function remainingConflictProbes(
if (
askedKeys.has(semantic)
|| (split && askedKeys.has(split))
|| existenceProbeAsked(askedKeys, probe.domain, probe.year)
|| sameYearProbeAsked(askedKeys, probe.domain, probe.year)
) continue;
rows.push(probe);
}
@@ -889,6 +892,9 @@ function renderableEventProbe(
if (!rankedGain) {
return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "no_split_among_active") };
}
if (sameYearProbeAsked(askedKeys, probe.domain, probe.year)) {
return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, "same_year_asked") };
}
const layer = vargaLayerFromSemanticKey(key);
const asked = askedKeys.has(key)
|| Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash))
@@ -960,10 +966,17 @@ function renderableContrastProbe(
dropped: droppedFromProbe(working.semanticKey, working.informationGain, "no_split_among_active"),
};
}
if (sameYearProbeAsked(askedKeys, working.domain, working.year)) {
return {
row: null,
dropped: droppedFromProbe(working.semanticKey, working.informationGain, "same_year_asked"),
};
}
const layer = vargaLayerFromSemanticKey(working.semanticKey);
const asked = askedKeys.has(working.semanticKey)
|| askedKeys.has(working.candidateSplitHash)
|| askedKeys.has(working.probeId)
|| existenceProbeAsked(askedKeys, working.domain ?? "", working.year ?? 0)
|| (layer ? vargaLayerCovered(mentionedKeys, layer) : false);
return {
row: {
@@ -36,7 +36,8 @@ export type ProbeRejectReason =
| "yearless_ungrounded_contrast"
| "no_split_among_active"
| "unanchored_varga_style"
| "frameless_distinguish";
| "frameless_distinguish"
| "same_year_asked";
export type StyleOptionsResult =
| { ok: true; options: ProbeStyleOption[] }
@@ -312,6 +313,23 @@ export function discriminatorPriority(input: {
return input.informationGain * novelty * coverage - penalty;
}
export const SEMANTIC_YEAR_KEY = /^([a-z_]+)\.((?:19|20)\d{2})(?:\.|$)/;
export function sameYearProbeAsked(
askedKeys: ReadonlySet<string> | readonly string[],
domain: string | null | undefined,
year: number | null | undefined,
): boolean {
if (!domain || !year) return false;
const asked = askedKeys instanceof Set ? askedKeys : new Set(askedKeys);
for (const key of asked) {
const match = key.match(SEMANTIC_YEAR_KEY);
if (!match || match[1] !== domain) continue;
if (Number(match[2]) === year) return true;
}
return false;
}
export function rankDiscriminatorScore(input: {
informationGain: number;
asked?: boolean;
@@ -24,6 +24,7 @@ import { clusterEquivalentCandidates } from "../src/lib/rectification-agentic/co
import { evaluateConvergence } from "../src/lib/rectification-agentic/core/convergence-evaluator.ts";
import { isDuplicateProbe } from "../src/lib/rectification-agentic/core/duplicate-probes.ts";
import { selectHighestGainProbe } from "../src/lib/rectification-agentic/core/select-probe.ts";
import { inspectDiscriminatorProbes } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { holdoutEventIds, splitHoldoutEvents } from "../src/lib/rectification-agentic/core/split-holdout.ts";
import type { AnswerClass, ConflictProbe, InferenceCandidate, ProbeAnswer } from "../src/lib/rectification-agentic/core/types.ts";
@@ -1079,3 +1080,78 @@ test("persisted focus can answer a lower-gain probe while conflicting schema ide
true,
);
});
test("core identity does not treat same-year siblings as duplicates; inspect drops them", () => {
const may = {
...probe({
id: "probe:career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
gain: 1.1,
yesSupports: ["05:00"],
yesConflicts: ["05:20"],
split: "career.2023.05.dasha_boundary",
}),
semantic_key: "career.2023.05.dasha_boundary",
};
const activation = {
...probe({
id: "probe:career.2023.dasha_activation",
domain: "career",
year: 2023,
gain: 0.46,
yesSupports: ["05:00"],
yesConflicts: ["05:20"],
split: "career.2023.dasha_activation",
}),
semantic_key: "career.2023.dasha_activation",
};
const nearby = {
...probe({
id: "probe:career.2024.04.dasha_boundary",
domain: "career",
year: 2024,
gain: 0.97,
yesSupports: ["05:00"],
yesConflicts: ["05:20"],
split: "career.2024.04.dasha_boundary",
}),
semantic_key: "career.2024.04.dasha_boundary",
};
const asked = [{
probe_id: may.id,
semantic_key: may.semantic_key,
candidate_split_hash: may.candidate_split_hash,
answer_class: "no" as const,
classified_from: "choice" as const,
}];
assert.equal(isDuplicateProbe(activation, asked), false);
assert.equal(selectHighestGainProbe([activation, nearby], asked)?.id, nearby.id);
const packet = {
candidateSetVersion: "05:00-05:20",
vargaDifferences: [] as const,
probes: [activation, nearby].map((item) => ({
probeId: item.id,
candidateSetVersion: "05:00-05:20",
question: item.question,
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: [...item.expected_outcomes[0].supports], conflictsCandidateIds: [...item.expected_outcomes[0].conflicts] },
{ outcomeId: "no", supportsCandidateIds: [...item.expected_outcomes[1].supports], conflictsCandidateIds: [...item.expected_outcomes[1].conflicts] },
],
candidateSplitHash: item.candidate_split_hash,
informationGain: item.information_gain,
sourceFeatures: [{ technique: "Vimshottari", calculationResultId: null }],
domain: item.domain,
year: item.year,
semanticKey: item.semantic_key,
choiceKind: "existence" as const,
})),
};
const inspected = inspectDiscriminatorProbes(packet, { askedKeys: asked.map((item) => item.semantic_key) });
assert.notEqual(inspected.selected?.semanticKey, activation.semantic_key);
assert.equal(inspected.selected?.semanticKey, nearby.semantic_key);
assert.equal(
inspected.dropped.some((item) => item.semantic_key === activation.semantic_key && item.reason === "same_year_asked"),
true,
);
});
@@ -1,22 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { inspectDiscriminatorProbes } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import { decideAfterInferenceChange } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import type { DecisionDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
buildMethodFollowupPlan,
datedLedgerAnchor,
existenceProbeAsked,
remainingReverseVerifyProbes,
sameYearProbeAsked,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import type { CandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { engineRequestBody, toEngineEvents } from "../src/lib/rectification-agentic/v9/engine-client.ts";
import { EXISTENCE_STYLE_OPTIONS } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
function existenceProbe(
domain: DiscriminatingEventProbe["domain"],
year: number,
extra: { month?: number; source?: DiscriminatingEventProbe["source"]; key?: string } = {},
extra: { month?: number; source?: DiscriminatingEventProbe["source"]; key?: string; gain?: number } = {},
): DiscriminatingEventProbe {
const month = extra.month;
const source = extra.source ?? "dasha_boundary";
@@ -29,14 +38,14 @@ function existenceProbe(
year_label: month ? `${year}${month} 月前后` : `${year} 年前后`,
month,
domain,
event_family: "入职、升职或职责明显加重",
event_family: domain === "relocation" ? "搬家或长期住到外地" : "入职、升职或职责明显加重",
source,
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: `时间范围锁定 ${year} 年。`,
role: "distinguish",
information_gain: 1.1,
information_gain: extra.gain ?? 1.1,
semantic_key: key,
candidate_split_hash: key,
candidate_ids: ["05:00", "05:20"],
@@ -201,3 +210,340 @@ test("after a D9-style answer the compare request body stays legal", () => {
const keys = (body.asked_probe_keys as string[] | undefined) ?? [];
assert.ok(keys.every((key) => key.length <= 120 && !key.includes(":varga.")));
});
const COVERED_FOR_DISCRIMINATE: MethodFollowupEvidence[] = [
dated("e-edu", "education", "education_start", "2016-09-01"),
dated("e-rel", "relationship", "relationship_start", "2018-05-01"),
dated("e-career", "career", "career_entry", "2020-04-01"),
dated("e-fam", "family", "family_event", "2019-03-01"),
{
id: "e-occ",
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
];
const CAREER_2023_05_KEY = "career.2023.05.dasha_boundary";
const CAREER_2023_ACTIVATION_KEY = "career.2023.dasha_activation";
const CAREER_2024_04_KEY = "career.2024.04.dasha_boundary";
const CAREER_2023_05 = existenceProbe("career", 2023, { month: 5, gain: 1.1, key: CAREER_2023_05_KEY });
const CAREER_2023_ACTIVATION = existenceProbe("career", 2023, {
source: "dasha_activation",
gain: 0.46,
key: CAREER_2023_ACTIVATION_KEY,
});
const CAREER_2024_04 = existenceProbe("career", 2024, { month: 4, gain: 0.97, key: CAREER_2024_04_KEY });
const RELOCATION_2015_05 = existenceProbe("relocation", 2015, { month: 5, gain: 0.74 });
const D9_STYLE: CandidateContrastPacket["probes"][number] = {
...D10_STYLE,
probeId: "contrast:varga.d9.巨蟹座/狮子座",
question: "亲密关系里更接近下面哪一种相处方式?",
candidateSplitHash: "varga.d9.巨蟹座/狮子座",
informationGain: 1.53,
sourceFeatures: [{ technique: "D9", calculationResultId: null }],
domain: "relationship",
semanticKey: "varga.d9.巨蟹座/狮子座",
styleOptions: [
{ label: "相处里更主动,也更愿意把关系往前推", answerClass: "yes", sign: "巨蟹座" },
{ label: "相处里更克制,先把分寸看清楚", answerClass: "weak_yes", sign: "狮子座" },
],
};
const D10_LOW: CandidateContrastPacket["probes"][number] = {
...D10_STYLE,
informationGain: 0.4,
};
function contrastFromEvent(probe: DiscriminatingEventProbe): CandidateContrastPacket["probes"][number] {
const yes = probe.expected_outcomes?.find((row) => row.answer_class === "yes")?.supports ?? ["05:00"];
const no = probe.expected_outcomes?.find((row) => row.answer_class === "no")?.supports ?? ["05:20"];
const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
return {
probeId: `probe:${key}`,
candidateSetVersion: "05:00-05:20",
question: probe.year_label,
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: [...yes], conflictsCandidateIds: [...no] },
{ outcomeId: "weak_yes", supportsCandidateIds: [], conflictsCandidateIds: [] },
{ outcomeId: "no", supportsCandidateIds: [...no], conflictsCandidateIds: [...yes] },
{ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] },
],
candidateSplitHash: probe.candidate_split_hash ?? key,
informationGain: probe.information_gain ?? 0,
sourceFeatures: [{ technique: "Vimshottari", calculationResultId: null }],
domain: probe.domain,
year: probe.year,
semanticKey: key,
choiceKind: "existence",
};
}
function accidentPacket(probes: readonly CandidateContrastPacket["probes"][number][]): CandidateContrastPacket {
return {
candidateSetVersion: "05:00-05:20",
vargaDifferences: [],
probes,
};
}
function conflictFromContrast(probe: CandidateContrastPacket["probes"][number]): ConflictProbe {
const yes = probe.expectedOutcomes.find((row) => row.outcomeId === "yes")?.supportsCandidateIds ?? ["05:00"];
const no = probe.expectedOutcomes.find((row) => row.outcomeId === "no")?.supportsCandidateIds ?? ["05:20"];
return {
id: probe.probeId,
semantic_key: probe.semanticKey,
candidate_split_hash: probe.candidateSplitHash,
domain: probe.domain ?? "career",
year: probe.year && probe.year > 0 ? probe.year : 0,
question: probe.question,
candidate_ids: [...new Set([...yes, ...no])],
expected_outcomes: [
{ answer_class: "yes", supports: [...yes], conflicts: [...no] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: [...no], conflicts: [...yes] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: probe.informationGain,
source: probe.choiceKind === "varga_style" ? "varga_contrast" : "dasha_boundary",
choice_kind: probe.choiceKind ?? "existence",
style_options: probe.styleOptions
? probe.styleOptions.map((item) => ({
label: item.label,
answer_class: item.answerClass,
...(item.sign ? { sign: item.sign } : {}),
}))
: [...EXISTENCE_STYLE_OPTIONS],
};
}
function answered(probe: { probeId?: string; id?: string; semanticKey?: string; semantic_key?: string; candidateSplitHash?: string; candidate_split_hash?: string }) {
return {
probe_id: probe.probeId ?? probe.id ?? "",
semantic_key: probe.semanticKey ?? probe.semantic_key ?? "",
candidate_split_hash: probe.candidateSplitHash ?? probe.candidate_split_hash ?? "",
answer_class: "no" as const,
classified_from: "choice" as const,
};
}
function accidentState(
remaining: readonly CandidateContrastPacket["probes"][number][],
asked: readonly ReturnType<typeof answered>[],
) {
const times = ["05:00", "05:20"] as const;
const probes = remaining.map(conflictFromContrast);
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("05:00", "05:20", times),
revision: asked.length + 1,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "05:00",
range_end: "05:20",
candidates: times.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: time === "05:00" ? 20 : 10,
posterior_score: time === "05:00" ? 20 : 10,
probability: time === "05:00" ? 0.67 : 0.33,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
})),
events: [
{ id: "e-edu", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: "e-rel", domain: "relationship", year: 2018, precision: "month" as const, usage: "training" as const },
{ id: "e-career", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-fam", domain: "family", year: 2019, precision: "month" as const, usage: "holdout" as const },
],
probes: [
...asked.map((item) => remaining.find((probe) => probe.semanticKey === item.semantic_key)).filter(Boolean).map((probe) => conflictFromContrast(probe as CandidateContrastPacket["probes"][number])),
...probes,
],
answered_probes: asked,
rounds: [],
last_inference_round: null,
entropy: 1.2,
representative_time: "05:00",
credible_range: ["05:00", "05:20"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded, "accident inference fixture must pass asInferenceState");
return loaded;
}
function accidentDossier(
remaining: readonly CandidateContrastPacket["probes"][number][],
asked: readonly ReturnType<typeof answered>[],
): DecisionDossier {
const state = accidentState(remaining, asked);
return {
evidence: COVERED_FOR_DISCRIMINATE,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: "fp-same-year",
candidates: [
{ candidateId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", time: "05:00", rank: 1, relativeSupport: 20 },
{ candidateId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", time: "05:20", rank: 2, relativeSupport: 10 },
],
representativeTime: "05:00",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
},
case: { acceptedTime: null, status: "active" },
};
}
test("sameYearProbeAsked is exact domain+year; nearby years stay on existenceProbeAsked", () => {
const asked = ["career.2023.05.dasha_boundary"];
assert.equal(sameYearProbeAsked(asked, "career", 2023), true);
assert.equal(sameYearProbeAsked(asked, "career", 2024), false);
assert.equal(sameYearProbeAsked(asked, "relocation", 2023), false);
assert.equal(sameYearProbeAsked(asked, "career", 0), false);
assert.equal(existenceProbeAsked(asked, "career", 2024), true);
});
test("after a May 2023 career probe, the same-year activation card is dropped not ranked", () => {
const packet = accidentPacket([
contrastFromEvent(CAREER_2023_05),
contrastFromEvent(CAREER_2023_ACTIVATION),
]);
const inspected = inspectDiscriminatorProbes(packet, {
askedKeys: [CAREER_2023_05_KEY],
});
assert.notEqual(inspected.selected?.semanticKey, CAREER_2023_ACTIVATION.semantic_key);
assert.equal(
inspected.dropped.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "same_year_asked"
)),
true,
);
const decided = decideAfterInferenceChange({
dossier: accidentDossier(
[contrastFromEvent(CAREER_2023_05), contrastFromEvent(CAREER_2023_ACTIVATION)],
[answered(contrastFromEvent(CAREER_2023_05))],
),
state: accidentState(
[contrastFromEvent(CAREER_2023_ACTIVATION)],
[answered(contrastFromEvent(CAREER_2023_05))],
),
userStopped: false,
birthDate: "1997-08-08",
});
assert.notEqual(decided.probe?.semanticKey, CAREER_2023_ACTIVATION.semantic_key);
assert.equal(
decided.droppedProbes.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "same_year_asked"
)),
true,
);
});
test("adjacent-year career probes stay eligible after a 2023 answer", () => {
const packet = accidentPacket([
contrastFromEvent(CAREER_2023_ACTIVATION),
contrastFromEvent(CAREER_2024_04),
]);
const inspected = inspectDiscriminatorProbes(packet, {
askedKeys: [CAREER_2023_05_KEY],
});
assert.equal(inspected.selected?.semanticKey, CAREER_2024_04.semantic_key);
assert.equal(
inspected.dropped.some((item) => (
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "same_year_asked"
)),
false,
);
const decided = decideAfterInferenceChange({
dossier: accidentDossier(
[contrastFromEvent(CAREER_2023_ACTIVATION), contrastFromEvent(CAREER_2024_04)],
[answered(contrastFromEvent(CAREER_2023_05))],
),
state: accidentState(
[contrastFromEvent(CAREER_2023_ACTIVATION), contrastFromEvent(CAREER_2024_04)],
[answered(contrastFromEvent(CAREER_2023_05))],
),
userStopped: false,
birthDate: "1997-08-08",
});
assert.equal(decided.probe?.semanticKey, CAREER_2024_04.semantic_key);
});
test("buildMethodFollowupPlan drops the same-year activation after the May card", () => {
const plan = buildMethodFollowupPlan({
evidence: COVERED_FOR_DISCRIMINATE,
contrastPacket: accidentPacket([
contrastFromEvent(CAREER_2023_05),
contrastFromEvent(CAREER_2023_ACTIVATION),
contrastFromEvent(CAREER_2024_04),
]),
askedProbeKeys: [CAREER_2023_05_KEY],
candidatesSeparated: false,
birthDate: "1997-08-08",
});
assert.notEqual(plan.next_followup?.semantic_key, CAREER_2023_ACTIVATION.semantic_key);
assert.equal(
plan.dropped_probes.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "same_year_asked"
)),
true,
);
assert.equal(plan.next_followup?.semantic_key, CAREER_2024_04.semantic_key);
});
test("accident replay: fifth card is not the 2023 activation after four answers", () => {
const pool = [
D9_STYLE,
contrastFromEvent(CAREER_2023_05),
contrastFromEvent(CAREER_2024_04),
contrastFromEvent(RELOCATION_2015_05),
contrastFromEvent(CAREER_2023_ACTIVATION),
D10_LOW,
];
const asked = [
answered(D9_STYLE),
answered(contrastFromEvent(CAREER_2023_05)),
answered(contrastFromEvent(CAREER_2024_04)),
answered(contrastFromEvent(RELOCATION_2015_05)),
];
const remaining = [
contrastFromEvent(CAREER_2023_ACTIVATION),
D10_LOW,
];
const decided = decideAfterInferenceChange({
dossier: accidentDossier(pool, asked),
state: accidentState(remaining, asked),
userStopped: false,
birthDate: "1997-08-08",
});
assert.notEqual(decided.probe?.semanticKey, CAREER_2023_ACTIVATION.semantic_key);
assert.equal(
decided.droppedProbes.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "same_year_asked"
)),
true,
);
assert.ok(
decided.probe?.semanticKey === D10_LOW.semanticKey
|| decided.nextAction === "ready_to_adopt"
|| decided.sessionOutcome === "adopt_representative",
);
});