diff --git a/CHANGELOG.md b/CHANGELOG.md index 8db4d471..97339ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-08 — 答过某年某月后,不再问同一领域同一年的整年题 + +生时校正点选题里,已经答过「2023 年 5 月前后有没有…」之后,不会再问「2023 年前后有没有…」。相邻年仍可能问,因为分开的是另一组候选。Skill 版本仍是 10.0.15。 + + ## 2026-09-08 — 范围收到后不再把已答领域再问一遍 生时校正把七个带年月领域和职业问完、范围已经收到、决策已是采用时,下一步是区间交付卡。不会再把已经答过的财务或其他领域采集题拿出来问。同一道采集题撞过题号时,也不会改个后缀再问一次。Skill 版本仍是 10.0.15。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f2c4af74..bf76bcfe 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`) + diff --git a/docs/tasks/PROGRESS-rectification-same-year-probe-dedupe-20260908.md b/docs/tasks/PROGRESS-rectification-same-year-probe-dedupe-20260908.md new file mode 100644 index 00000000..a7f06703 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-same-year-probe-dedupe-20260908.md @@ -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 题提前砍掉。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 34d2ce0f..b850f546 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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` 的出卡路径永远不跑 | 已验收通过(触发链仍 investigating,BUG-586 mitigated) | `9aec5029`(BUG-586) | | `TASK-rectification-probe-replay-loss-20260908.md` | `PROGRESS-rectification-probe-replay-loss-20260908.md` | **P0** 收敛不了的直接原因:答完 5 道题范围收到 8 分钟,之后每补一件经历触发的重算换了候选集,`buildInferenceState` 找不到已答题定义(引擎与对照包按 asked keys 排除、状态 probes 不携带、分盘 key 内嵌分钟列表)→ `rounds:[]`、posterior=prior,交付回到 29 分钟;证据轮旁白不报范围变化;交付卡闪现待证实 | 已验收通过(真实 Case 范围收到 2 分钟;BUG-589 闪现 investigating) | `df182c16`(BUG-587~589) | | `TASK-rectification-covered-domain-recollect-20260908.md` | `PROGRESS-rectification-covered-domain-recollect-20260908.md` | 范围已收到 2 分钟、决策已是采用,却再问答过的财务题:职业覆盖后的 yearless→采集分支(L2224)不看领域是否已覆盖;`shouldSkipFollowupPersist` 把它当剩余采集挡住出卡;`persistCollectFocus` 撞 id 无条件 `:next` 再插;holdout `occupied` 不认 health/health_pressure 同义 | 已验收通过 | `7ca6ade2`(BUG-590~591) | -| `TASK-rectification-same-year-probe-dedupe-20260908.md` | `PROGRESS-rectification-same-year-probe-dedupe-20260908.md` | 答完「2023 年 5 月前后」又问「2023 年前后」(BUG-559 复发):点选不重跑引擎,同一份 probes 里同域同年两题并存;TS 侧 `existenceProbeAsked` 只降权不排除,`inspectDiscriminatorProbes` 连领域+年份规则都没有。决策:同域同年硬排除、相邻年维持降权 | 待执行 | `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-593,Skill 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-505~509) | diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index c96b5f9d..5aa66231 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -127,3 +127,15 @@ - 若范围变了,证据轮旁白必须出现「范围从 … 变为 …」 - 不得出现:只说「记下了」,交付时范围弹回大约半小时,候选分数跟没答过题一样 +## 9. 同领域同年份的点选题只出一次 + +资料:家人记得大概时间,钟点任意,范围「差不多准」。地点任意公开城市。 + +开场后说到能出对照卡。若出现「某年某月前后有没有入职/换工作」,答「明确没有发生」。 + +期望: + +- 下一张点选卡不得再问同一领域同一年的整年题(例如刚答过 2023 年 5 月,不得再问 2023 年前后) +- 相邻年(例如 2024 年 4 月)仍可以问 +- 不得出现:同一句「有没有入职、换工作或职责加重」只把月份去掉再问一遍 + diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 2a42c943..bb975d6f 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -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): { + 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) diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 40f6251d..9e2ebe9c 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -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 | 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: { diff --git a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts index 2d1d3935..f3ecb8a6 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -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 | 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; diff --git a/frontend/tests/rectification-inference-machine.test.ts b/frontend/tests/rectification-inference-machine.test.ts index 3d2e95aa..2426f935 100644 --- a/frontend/tests/rectification-inference-machine.test.ts +++ b/frontend/tests/rectification-inference-machine.test.ts @@ -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, + ); +}); diff --git a/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts index 3339e4ff..da95c9e5 100644 --- a/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts +++ b/frontend/tests/rectification-probe-year-dedupe-20260906.test.ts @@ -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[], +) { + 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[], +): 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", + ); +});