diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 96983f1f..c33b39b5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6349,6 +6349,22 @@ - 复发自:BUG-411(已持久化的卡会被丢掉;这里是下一问根本没持久化) - 修复版本:待发布 +## BUG-419 | 生时纠正 holdout 落到年份事件,无星座 D9 被丢掉,题干把写作说明给用户 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`split-holdout`、`candidate-contrast-packet`、`method-followup` 出题目录 +- 用户现象:训练/盘外划分时可能把只有年份的经历留作 holdout,把日/月精度事件拿去打分。D9/D10 窗口扫描没有星座名时整题消失。区分题题干出现「不得发明年份」「Opportunity」。账本里写过「程序员」一类职责说明后,剩余 D10 区分题被当成已经问过。 +- 触发条件:至少两个领域都有两条带日期经历、其中只有事业带月/日精度;或 D9 只有换盘时刻没有 from/to 星座;或剩余分盘题进入点选;或职业备注含职责关键词而 D10 尚未真正答过。 +- 根因:holdout 回退把月日事件与全部事件拼在一起再取最后一个,年份事件排在后面。D9/D10 缺星座时仍坚持 `varga_style`,选项凑不齐就被丢掉。`remainingQuestion` 把给模型的写作说明写进 `question`。账本关键词生成的 `varga.d10` 被当成已答 asked key,前缀匹配跳过整层剩余题。 +- 修复:没有单领域孤立事件时,holdout 取最后一个月/日精度事件。D9/D10 凑不齐风格选项时降为存在题,不删题。`question` 改为对人的短问,写作说明放进 `authoringHint`。账本关键词只作 mentioned/新颖度惩罚,不跳过剩余分盘题;只有已答 `semantic_key` 或显式 `varga.d10` 才跳过。存在题 `weak_yes` 仍与 `yes` 同向半权、一次作答不淘汰。 +- 验证:`rectification-split-holdout` 锁定无孤立领域时 holdout 是日精度事业而不是年份感情。`rectification-candidate-contrast-packet` 锁定无星座 D9 降为存在题、题干不含 Opportunity、程序员提及仍保留 D10 但选未提及的 D4。`rectification-distinguish-contract` 锁定存在题 `weak_yes` 与 `yes` 同映射半权。`rectification-decide-next-action` / `rectification-eight-method` 改为 mentioned 与 answered 分集。 +- 防复发:不得把账本关键词生成的 `varga.d*` 写进 packet `askedKeys`。不得在 D9/D10 缺星座时继续要求 `varga_style` 四个风格选项。不得把 `不得发明年份` 写进对人的 `question`。holdout 回退不得再把年份事件排在月日事件后面取最后一个。 +- 相关记录:BUG-410、#39 +- 复发自:无 +- 修复版本:待发布 + ## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断 - 状态:resolved 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 c5f3cd89..40385154 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -34,6 +34,7 @@ export type CandidateDiscriminatorProbe = Readonly<{ probeId: string; candidateSetVersion: string; question: string; + authoringHint?: string; expectedOutcomes: readonly ContrastExpectedOutcome[]; candidateSplitHash: string; informationGain: number; @@ -197,7 +198,7 @@ function remainingGroupSigns( }); } -export function askedKeysFromLedgerEvidence( +export function mentionedVargaKeysFromLedgerEvidence( evidence: readonly Readonly<{ status?: string | null; domain?: string | null; @@ -283,6 +284,17 @@ export function volunteeredDomainsFromEvidence( return [...domains]; } +export function askedKeysFromLedgerEvidence( + evidence: readonly Readonly<{ + status?: string | null; + domain?: string | null; + eventKind?: string | null; + summary?: string | null; + }>[], +): string[] { + return mentionedVargaKeysFromLedgerEvidence(evidence); +} + export function askedKeysFromOccupationEvidence( evidence: readonly Readonly<{ status?: string | null; @@ -291,7 +303,7 @@ export function askedKeysFromOccupationEvidence( summary?: string | null; }>[], ): string[] { - return askedKeysFromLedgerEvidence(evidence); + return mentionedVargaKeysFromLedgerEvidence(evidence); } export function buildCandidateContrastPacket(input: { @@ -303,10 +315,12 @@ export function buildCandidateContrastPacket(input: { candidateTimes?: readonly string[]; transitions?: readonly WindowScanTransition[]; askedKeys?: readonly string[]; + mentionedKeys?: readonly string[]; volunteeredDomains?: readonly string[]; providedDomains?: readonly string[]; }): CandidateContrastPacket { const asked = new Set(input.askedKeys ?? []); + // mentionedKeys are ranking-only; passing them here must not skip remaining varga probes. const provided = new Set(input.providedDomains ?? []); const fromEngine = (input.engineProbes ?? []).flatMap((probe) => { const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null); @@ -370,11 +384,28 @@ function vargaDifferencesForPacket(input: { return input.windowDifferences; } +export function vargaLayerCovered(keys: ReadonlySet, layer: string): boolean { + if (keys.has(`varga.${layer}`) || keys.has(layer)) return true; + for (const key of keys) { + if (key.startsWith(`varga.${layer}.`)) return true; + } + return false; +} + +export function vargaLayerFromSemanticKey(key: string): string | null { + return key.match(/^varga\.(d\d+)/)?.[1] ?? null; +} + export function selectDiscriminatorProbe( packet: CandidateContrastPacket | null | undefined, - options?: { askedKeys?: readonly string[]; topCandidateTimes?: readonly string[] }, + options?: { + askedKeys?: readonly string[]; + mentionedKeys?: readonly string[]; + topCandidateTimes?: readonly string[]; + }, ): CandidateDiscriminatorProbe | null { const asked = new Set(options?.askedKeys ?? []); + const mentioned = new Set(options?.mentionedKeys ?? []); const ranked = (packet?.probes ?? []).flatMap((probe) => { const completed = withCompletedContrastOptions(probe); if (!completed) return []; @@ -389,9 +420,11 @@ export function selectDiscriminatorProbe( choiceKind: completed.choiceKind, styleOptions: completed.styleOptions, })) return []; + const layer = vargaLayerFromSemanticKey(completed.semanticKey); const askedAlready = asked.has(completed.semanticKey) || asked.has(completed.candidateSplitHash) - || asked.has(completed.probeId); + || asked.has(completed.probeId) + || (layer ? vargaLayerCovered(mentioned, layer) : false); return [{ probe: completed, score: rankDiscriminatorScore({ @@ -478,7 +511,6 @@ function effectiveContrastChoiceKind(probe: CandidateDiscriminatorProbe): Contra const key = probe.semanticKey; if (probe.choiceKind === "varga_style" && (probe.styleOptions?.length ?? 0) < 2) { if (key.startsWith("varga.d24") || key.startsWith("varga.d5")) return "event_quality"; - if (key.startsWith("varga.d9") || key.startsWith("varga.d10")) return "varga_style"; return "existence"; } if (probe.choiceKind === "varga_style" || probe.choiceKind === "event_quality" || probe.choiceKind === "existence") { @@ -637,11 +669,7 @@ function vargaProbes( } function vargaLayerAsked(asked: ReadonlySet, layer: string): boolean { - if (asked.has(`varga.${layer}`)) return true; - for (const key of asked) { - if (key === layer || key.startsWith(`varga.${layer}.`)) return true; - } - return false; + return vargaLayerCovered(asked, layer); } function vargaProbeFromRemaining( @@ -651,18 +679,23 @@ function vargaProbeFromRemaining( ): CandidateDiscriminatorProbe | null { if (split.entropy <= 0) return null; const allMinutes = split.groups.flat(); - const choiceKind = remainingChoiceKind(split.layer); + let choiceKind = remainingChoiceKind(split.layer); + let styleOptions = remainingStyleOptions(split, choiceKind); + if (choiceKind === "varga_style" && (styleOptions?.length ?? 0) < 2) { + choiceKind = "existence"; + styleOptions = remainingStyleOptions(split, choiceKind); + } const outcomes = remainingOutcomes(split.groups, allMinutes, choiceKind); const ids = new Set(outcomes.flatMap((row) => [...row.supportsCandidateIds, ...row.conflictsCandidateIds])); if (outcomes.length < 2 || ids.size < 2) return null; const semanticKey = `varga.${split.layer}.${split.groups.map((group) => group.join("|")).join("/")}`; const layerLabel = split.layer.toUpperCase(); const domain = remainingDomain(split.layer); - const styleOptions = remainingStyleOptions(split, choiceKind); return { probeId: `contrast:${semanticKey}`, candidateSetVersion, - question: remainingQuestion(split.layer, layerLabel), + question: remainingQuestion(split.layer), + authoringHint: remainingAuthoringHint(layerLabel), expectedOutcomes: outcomes, candidateSplitHash: `${candidateSetVersion}:${semanticKey}`, informationGain: split.entropy, @@ -725,10 +758,18 @@ function remainingDomain(layer: string): string { return "relationship"; } -function remainingQuestion( - layer: string, - layerLabel: string, -): string { +function remainingQuestion(layer: string): string { + if (layer === "d10") return "平时做事更接近下面哪一种职责风格?"; + if (layer === "d9") return "亲密关系里更接近下面哪一种相处方式?"; + if (layer === "d24" || layer === "d5") return "有没有学业或考试发挥明显失常、压力特别大的时候?"; + if (layer === "d4") return "有没有搬家或长期住到外地?"; + if (layer === "d7" || layer === "d12") return "家里有没有结婚、添丁或住院这类事?"; + if (layer === "d2" || layer === "d11") return "有没有收入明显变化、大笔支出或欠债?"; + if (layer === "d30") return "有没有生病、受伤或压力特别大的时候?"; + return "有没有下面这类事发生过?"; +} + +function remainingAuthoringHint(layerLabel: string): string { return `引擎给出的区分机会绑定 ${layerLabel}。按 Opportunity 的时间范围、领域和 expected_outcomes 改写成自然语言,不得发明年份、事件事实或候选映射,不得改写时间范围。`; } diff --git a/frontend/src/lib/rectification-agentic/core/split-holdout.ts b/frontend/src/lib/rectification-agentic/core/split-holdout.ts index a16ba8fa..92c846f0 100644 --- a/frontend/src/lib/rectification-agentic/core/split-holdout.ts +++ b/frontend/src/lib/rectification-agentic/core/split-holdout.ts @@ -53,8 +53,7 @@ function pickHoldoutId( const preferred = monthOrBetter.find((item) => item.domain === singletonDomain[0]); return (preferred ?? singletonDomain[1][0])?.id ?? null; } - const ranked = [...monthOrBetter, ...dated]; - return ranked[ranked.length - 1]?.id ?? null; + return (monthOrBetter.at(-1) ?? dated.at(-1))?.id ?? null; } export function trainingEventIds(events: readonly InferenceEvent[]): ReadonlySet { diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 95c86e14..0732efd4 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -8,6 +8,7 @@ import { buildCandidateContrastPacket, datedDomainsFromEvidence, + mentionedVargaKeysFromLedgerEvidence, selectDiscriminatorProbe, volunteeredDomainsFromEvidence, type CandidateContrastPacket, @@ -279,7 +280,10 @@ export function decideFromDossier( userStopped: dossier.case.status === "paused", snapshotCurrent, candidateScores: candidateScoresFromDossier(dossier.latestResult), - discriminatorProbe: selectDiscriminatorProbe(contrastPacketFromDossier(dossier)), + discriminatorProbe: selectDiscriminatorProbe(contrastPacketFromDossier(dossier), { + askedKeys: askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, dossier.evidence), + mentionedKeys: mentionedVargaKeysFromLedgerEvidence(dossier.evidence), + }), holdoutValidation: holdoutStatusFromInference(inference), accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, @@ -313,7 +317,9 @@ export function decideAfterInferenceChange(input: { candidateScores: input.state.candidates .filter((item) => item.status !== "eliminated") .map((item) => ({ time: item.time, score: item.posterior_score })), - discriminatorProbe: selectDiscriminatorProbe(contrastPacketFromState(input.state)), + discriminatorProbe: selectDiscriminatorProbe(contrastPacketFromState(input.state), { + mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.dossier.evidence), + }), holdoutValidation: holdoutStatusFromState(input.state), inferenceCredibleRange: input.state.credible_range, userStopped: input.userStopped, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 72f38ca1..bf2937f6 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -61,10 +61,12 @@ import { import { overlayChoicePromptFromSpoken } from "./turn-narration.ts"; import { decideRectification, type HoldoutValidationStatus } from "../core/rectification-decision.ts"; import { - askedKeysFromLedgerEvidence, datedDomainsFromEvidence, isStructuredDiscriminator, + mentionedVargaKeysFromLedgerEvidence, selectDiscriminatorProbe, + vargaLayerCovered, + vargaLayerFromSemanticKey, type CandidateContrastPacket, type CandidateDiscriminatorProbe, } from "../core/candidate-contrast-packet.ts"; @@ -522,6 +524,7 @@ function renderableEventProbe( probe: DiscriminatingEventProbe, askedKeys: ReadonlySet, topCandidateTimes: readonly string[], + mentionedKeys: ReadonlySet = new Set(), ): RankedDiscriminator | null { const candidateIds = probe.candidate_ids ?? candidateIdsFromProbe(probe); const styleOptions = completeStyleOptions({ @@ -537,7 +540,10 @@ function renderableEventProbe( styleOptions, })) return null; const key = probe.semantic_key ?? `${probe.domain}.${probe.year}`; - const asked = askedKeys.has(key) || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)); + const layer = vargaLayerFromSemanticKey(key); + const asked = askedKeys.has(key) + || Boolean(probe.candidate_split_hash && askedKeys.has(probe.candidate_split_hash)) + || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { kind: "event", eventProbe: probe, @@ -555,6 +561,7 @@ function renderableContrastProbe( probe: CandidateDiscriminatorProbe, askedKeys: ReadonlySet, topCandidateTimes: readonly string[], + mentionedKeys: ReadonlySet = new Set(), ): RankedDiscriminator | null { const candidateIds = [...new Set(probe.expectedOutcomes.flatMap((row) => [ ...row.supportsCandidateIds, @@ -575,9 +582,11 @@ function renderableContrastProbe( choiceKind: probe.choiceKind, styleOptions, })) return null; + const layer = vargaLayerFromSemanticKey(probe.semanticKey); const asked = askedKeys.has(probe.semanticKey) || askedKeys.has(probe.candidateSplitHash) - || askedKeys.has(probe.probeId); + || askedKeys.has(probe.probeId) + || (layer ? vargaLayerCovered(mentionedKeys, layer) : false); return { kind: "contrast", contrastProbe: probe, @@ -700,12 +709,14 @@ function rankRenderableDiscriminators(input: { eventProbes: readonly DiscriminatingEventProbe[]; contrastProbes: readonly CandidateDiscriminatorProbe[]; askedKeys: ReadonlySet; + mentionedKeys?: ReadonlySet; topCandidateTimes?: readonly string[]; providedDomains?: readonly string[]; evidence?: readonly MethodFollowupEvidence[]; }): { locked: RankedDiscriminator[]; yearless: RankedDiscriminator[] } { const top = input.topCandidateTimes ?? []; const provided = new Set(input.providedDomains ?? []); + const mentioned = input.mentionedKeys ?? new Set(); const rows: RankedDiscriminator[] = []; const seen = new Set(); const push = (row: RankedDiscriminator | null) => { @@ -718,11 +729,11 @@ function rankRenderableDiscriminators(input: { rows.push(row); }; for (const probe of input.eventProbes) { - push(renderableEventProbe(probe, input.askedKeys, top)); + push(renderableEventProbe(probe, input.askedKeys, top, mentioned)); } for (const probe of input.contrastProbes) { if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) continue; - push(renderableContrastProbe(probe, input.askedKeys, top)); + push(renderableContrastProbe(probe, input.askedKeys, top, mentioned)); } const sorted = rows.sort((left, right) => right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0)); return { @@ -1115,15 +1126,16 @@ export function buildMethodFollowupPlan(input: { focus && (focus.intent === "reverse_verify" || focus.intent === "out_of_sample_check"), ); const coverageComplete = blockingMethodsCovered(methods); + const mentionedKeys = new Set(mentionedVargaKeysFromLedgerEvidence(input.evidence)); const askedKeys = new Set([ ...(input.askedProbeKeys ?? []), - ...askedKeysFromLedgerEvidence(input.evidence), ]); const rankedCatalog = dashaCovered && meetsAcceptanceEventQuality(input.evidence) ? rankRenderableDiscriminators({ eventProbes: remainingConflictProbes(input.eventProbes, input.evidence, declined, askedKeys, input.birthDate), contrastProbes, askedKeys, + mentionedKeys, topCandidateTimes: input.topCandidateTimes, providedDomains: datedDomainsFromEvidence(input.evidence), evidence: input.evidence, @@ -1326,7 +1338,7 @@ export function buildMethodFollowupPlan(input: { user_prompt_hint: ask( contrast.question, REVERSE_VERIFY_VARGA[domain], - "按候选盘面差异核对前事,不要问两套盘哪个更像。", + contrast.authoringHint ?? "按候选盘面差异核对前事,不要问两套盘哪个更像。", ), source: "event_probe", information_gain: contrast.informationGain, @@ -1729,7 +1741,9 @@ export function projectRectificationChoiceCard( methods: plan.methods, userStopped: input.userStopped, candidateScores: input.candidateScores, - discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null) ?? undefined, + discriminatorProbe: selectDiscriminatorProbe(input.contrastPacket ?? null, { + mentionedKeys: mentionedVargaKeysFromLedgerEvidence(input.evidence), + }) ?? undefined, holdoutValidation: input.holdoutValidation, evidence: input.evidence, }); diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 3d65a03f..eff30ca2 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -100,6 +100,7 @@ import { buildCandidateContrastPacket, conflictProbesFromContrast, datedDomainsFromEvidence, + mentionedVargaKeysFromLedgerEvidence, selectDiscriminatorProbe, volunteeredDomainsFromEvidence, } from "@/lib/rectification-agentic/core/candidate-contrast-packet"; @@ -222,6 +223,20 @@ function storedSnapshotSource(latest: NonNullable[0], + evidence: readonly Readonly<{ + status?: string | null; + domain?: string | null; + eventKind?: string | null; + summary?: string | null; + }>[], +) { + return selectDiscriminatorProbe(packet, { + mentionedKeys: mentionedVargaKeysFromLedgerEvidence(evidence), + }); +} + function safeCaseProjection( dossier: ReturnType, compute: Awaited>, @@ -267,7 +282,7 @@ function safeCaseProjection( methods: collectingPlan.methods, userStopped, candidateScores, - discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + discriminatorProbe: selectLiveDiscriminator(contrastPacket, dossier.evidence), holdoutValidation, snapshotCurrent, evidence: dossier.evidence, @@ -282,7 +297,7 @@ function safeCaseProjection( userStopped, candidateScores, holdoutValidation, - discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + discriminatorProbe: selectLiveDiscriminator(contrastPacket, dossier.evidence), snapshotCurrent, evidence: dossier.evidence, }) @@ -633,7 +648,7 @@ function sessionAwareFollowupForParsed( methods: collectingPlan.methods, userStopped: parsed.case.status === "paused", candidateScores, - discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + discriminatorProbe: selectLiveDiscriminator(contrastPacket, parsed.evidence), holdoutValidation, snapshotCurrent: options?.snapshotCurrent, evidence: parsed.evidence, @@ -1762,7 +1777,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { userStopped: scored.parsed.case.status === "paused", candidateScores: candidateScoresFromLatest(latest), holdoutValidation: holdoutStatusFromLatest(latest), - discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + discriminatorProbe: selectLiveDiscriminator(contrastPacket, scored.parsed.evidence), snapshotCurrent: true, evidence: scored.parsed.evidence, accepted: Boolean(scored.parsed.case.acceptedTime), @@ -1882,7 +1897,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { userStopped, candidateScores, holdoutValidation: holdoutStatusFromLatest(latest), - discriminatorProbe: selectDiscriminatorProbe(contrastPacket), + discriminatorProbe: selectLiveDiscriminator(contrastPacket, dossier.evidence), snapshotCurrent: true, evidence: parsed.evidence, accepted: Boolean(parsed.case.acceptedTime), diff --git a/frontend/tests/rectification-candidate-contrast-packet.test.ts b/frontend/tests/rectification-candidate-contrast-packet.test.ts index 3e4b61a3..4b39eaeb 100644 --- a/frontend/tests/rectification-candidate-contrast-packet.test.ts +++ b/frontend/tests/rectification-candidate-contrast-packet.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { buildCandidateContrastPacket, + mentionedVargaKeysFromLedgerEvidence, remainingVargaSplits, selectDiscriminatorProbe, volunteeredDomainsFromEvidence, @@ -51,6 +52,9 @@ test("two-way remaining D9 keeps the other minute on weak_yes, not no", () => { assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "weak_yes")?.supportsCandidateIds, ["05:04"]); assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "no")?.supportsCandidateIds, []); assert.equal(probe.styleOptions?.some((item) => item.answerClass === "unsure"), true); + assert.doesNotMatch(probe.question, /不得发明年份|Opportunity/); + assert.match(probe.authoringHint ?? "", /不得发明年份/); + assert.match(probe.question, /相处|关系/); }); test("remaining D7 enters the pool as family existence", () => { @@ -110,3 +114,56 @@ test("existence probes leave the catalog once that domain already has dated evid assert.equal(packet.probes.some((item) => item.semanticKey.includes("career.2023")), false); assert.match(selectDiscriminatorProbe(packet)?.semanticKey ?? "", /^varga\.d24\./); }); + +test("D9 remaining split without signs degrades to existence instead of dropping", () => { + const packet = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:04"], + transitions: [{ layer: "d9", at: "05:04" }], + }); + const probe = selectDiscriminatorProbe(packet); + assert.ok(probe); + assert.equal(probe.choiceKind, "existence"); + assert.match(probe.semanticKey, /^varga\.d9\./); + assert.equal(probe.styleOptions?.length, 4); + assert.doesNotMatch(probe.question, /不得发明年份|Opportunity/); + assert.ok(probe.question.trim().length >= 8); + assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "yes")?.supportsCandidateIds, ["05:00"]); + assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "weak_yes")?.supportsCandidateIds, ["05:00"]); + assert.deepEqual(probe.expectedOutcomes.find((row) => row.outcomeId === "no")?.supportsCandidateIds, ["05:04"]); +}); + +test("occupation mention does not skip remaining D10; answered varga.d10 still does", () => { + const evidence = [{ + status: "confirmed", + domain: "occupation", + eventKind: "occupation_note", + summary: "互联网程序员 / 前端", + }]; + const mentioned = mentionedVargaKeysFromLedgerEvidence(evidence); + assert.ok(mentioned.includes("varga.d10")); + const transitions = [ + { layer: "d10", at: "05:03", from_sign: "巨蟹座", to_sign: "狮子座" }, + { layer: "d10", at: "05:04", from_sign: "狮子座", to_sign: "处女座" }, + { layer: "d4", at: "05:04", from_sign: "金牛座", to_sign: "双子座" }, + ]; + const times = ["05:00", "05:03", "05:04"]; + const mentionedPacket = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: times, + transitions, + mentionedKeys: mentioned, + }); + assert.ok(mentionedPacket.probes.some((item) => item.semanticKey.startsWith("varga.d10."))); + assert.match( + selectDiscriminatorProbe(mentionedPacket, { mentionedKeys: mentioned })?.semanticKey ?? "", + /^varga\.d4\./, + ); + const answeredPacket = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: times, + transitions, + askedKeys: ["varga.d10"], + }); + assert.equal(answeredPacket.probes.some((item) => item.semanticKey.startsWith("varga.d10.")), false); +}); diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index 15b377dd..bd7bea22 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -4,8 +4,8 @@ import test from "node:test"; import { askedEventProbeKeysFromLedgerEvidence, - askedKeysFromLedgerEvidence, buildCandidateContrastPacket, + mentionedVargaKeysFromLedgerEvidence, selectDiscriminatorProbe, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { evaluateCandidateSeparation } from "../src/lib/rectification-agentic/core/candidate-separation.ts"; @@ -202,29 +202,29 @@ test("whole-window D9/D10 signs do not synthesize a discriminator without remain assert.equal(selectDiscriminatorProbe(packet), null); }); -test("exam quality and occupation notes skip remaining D24/D10 and keep D4", () => { +test("exam quality and occupation notes keep remaining D10/D24 but prefer unmentioned D4", () => { const evidence = [ { domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" }, { domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员 / 前端", status: "confirmed" }, ]; + const mentionedKeys = mentionedVargaKeysFromLedgerEvidence(evidence); const packet = buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:04", calculationResultId: "22222222-2222-4222-8222-222222222222", candidateTimes: ["05:00", "05:03", "05:04"], transitions: [ - { layer: "d4", at: "05:00" }, - { layer: "d4", at: "05:03" }, - { layer: "d10", at: "05:00" }, - { layer: "d10", at: "05:03" }, - { layer: "d24", at: "05:00" }, - { layer: "d24", at: "05:03" }, - { layer: "d9", at: "04:52" }, - { layer: "d9", at: "05:08" }, - { layer: "d5", at: "05:15" }, + { layer: "d4", at: "05:03", from_sign: "金牛座", to_sign: "双子座" }, + { layer: "d4", at: "05:04", from_sign: "双子座", to_sign: "巨蟹座" }, + { layer: "d10", at: "05:03", from_sign: "巨蟹座", to_sign: "狮子座" }, + { layer: "d10", at: "05:04", from_sign: "狮子座", to_sign: "处女座" }, + { layer: "d24", at: "05:03", from_sign: "白羊座", to_sign: "金牛座" }, + { layer: "d24", at: "05:04", from_sign: "金牛座", to_sign: "双子座" }, ], - askedKeys: askedKeysFromLedgerEvidence(evidence), + mentionedKeys, }); - const probe = selectDiscriminatorProbe(packet); + assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d10."))); + assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d24."))); + const probe = selectDiscriminatorProbe(packet, { mentionedKeys }); assert.ok(probe); assert.match(probe.semanticKey, /varga\.d4/); assert.equal(probe.domain, "relocation"); @@ -238,11 +238,6 @@ test("exam quality and occupation notes skip remaining D24/D10 and keep D4", () }); test("encoded D24/D10/D4 remaining splits leave no discriminator", () => { - const evidence = [ - { domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" }, - { domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员", status: "confirmed" }, - { domain: "relocation", eventKind: "home_change", summary: "搬家离乡", status: "confirmed" }, - ]; const packet = buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:04", candidateTimes: ["05:00", "05:03", "05:04"], @@ -252,7 +247,7 @@ test("encoded D24/D10/D4 remaining splits leave no discriminator", () => { { layer: "d10", at: "05:00" }, { layer: "d24", at: "05:00" }, ], - askedKeys: askedKeysFromLedgerEvidence(evidence), + askedKeys: ["varga.d4", "varga.d10", "varga.d24", "varga.d5"], }); assert.equal(selectDiscriminatorProbe(packet), null); }); diff --git a/frontend/tests/rectification-distinguish-contract.test.ts b/frontend/tests/rectification-distinguish-contract.test.ts index 3857959d..98b8aa86 100644 --- a/frontend/tests/rectification-distinguish-contract.test.ts +++ b/frontend/tests/rectification-distinguish-contract.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts"; +import { applyProbeOutcome, outcomeForAnswer } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts"; import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { distinguishContractErrors } from "../src/lib/rectification-agentic/core/distinguish-contract.ts"; import { probeFromEngine } from "../src/lib/rectification-agentic/core/probes-from-engine.ts"; @@ -159,3 +159,40 @@ test("randomized hidden mutated answers change posterior only when mapped", () = 0, ); }); + +test("existence weak_yes shares yes mapping at half weight and never eliminates on one answer", () => { + const probe = probeFromEngine({ + year: 2018, + year_label: "2018 年前后", + domain: "career", + event_family: "职责变化", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "时间范围锁定 2018 年前后", + role: "distinguish", + information_gain: 0.4, + semantic_key: "career.2018", + candidate_split_hash: "set:career:2018", + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "weak_yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + }); + assert.ok(probe); + assert.deepEqual(outcomeForAnswer(probe, "yes")?.supports, outcomeForAnswer(probe, "weak_yes")?.supports); + assert.deepEqual(outcomeForAnswer(probe, "yes")?.conflicts, outcomeForAnswer(probe, "weak_yes")?.conflicts); + const scores = { "05:00": 10, "05:20": 10 }; + const yes = applyProbeOutcome(scores, probe, "yes"); + const weak = applyProbeOutcome(scores, probe, "weak_yes"); + assert.equal(yes.deltas["05:00"], 2); + assert.equal(yes.deltas["05:20"], -2); + assert.equal(weak.deltas["05:00"], 1); + assert.equal(weak.deltas["05:20"], -1); + assert.deepEqual(weak.eliminated_ids, []); + assert.equal(weak.kind, "informative"); +}); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 3cb7d8c2..9d84fb9f 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -5,7 +5,7 @@ import test from "node:test"; import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, spokenFollowupForUser } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; import { - askedKeysFromLedgerEvidence, + mentionedVargaKeysFromLedgerEvidence, buildCandidateContrastPacket, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { @@ -2184,12 +2184,11 @@ test("coverage-complete tie with encoded D24/D10 collects a dated move instead o candidateSetVersion: "05:00-05:04", candidateTimes: DUMP_SCORES.map((item) => item.time), transitions: DUMP_TRANSITIONS, - askedKeys: askedKeysFromLedgerEvidence(DUMP_COVERAGE), + mentionedKeys: mentionedVargaKeysFromLedgerEvidence(DUMP_COVERAGE), }); const plan = buildMethodFollowupPlan({ evidence: DUMP_COVERAGE, contrastPacket: packet, - askedProbeKeys: askedKeysFromLedgerEvidence(DUMP_COVERAGE), }); assert.equal(plan.next_followup?.domain, "relocation"); assert.equal(plan.next_followup?.kind_hint, "home_change"); @@ -2198,7 +2197,6 @@ test("coverage-complete tie with encoded D24/D10 collects a dated move instead o assert.match(plan.next_followup?.user_prompt_hint ?? "", /记得住时间的搬家/); assert.doesNotMatch(plan.next_followup?.kind_hint ?? "", /education_start|relationship_end/); assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /大学哪年入学|高考是 \d{4}|哪年毕业/); - assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /那段时间/); assert.equal(conversationalSessionOutcome({ selectionAllowed: true, proposeAllowed: true, @@ -2215,15 +2213,7 @@ test("coverage-complete tie with no remaining split offers a provisional range", candidateSetVersion: "05:00-05:04", candidateTimes: DUMP_SCORES.map((item) => item.time), transitions: DUMP_TRANSITIONS, - askedKeys: askedKeysFromLedgerEvidence([ - ...DUMP_COVERAGE, - { - status: "confirmed", - domain: "relocation", - eventKind: "home_change", - summary: "搬家离乡", - }, - ]), + askedKeys: ["varga.d4", "varga.d5", "varga.d9", "varga.d10", "varga.d24"], }); const plan = buildMethodFollowupPlan({ evidence: [ diff --git a/frontend/tests/rectification-split-holdout.test.ts b/frontend/tests/rectification-split-holdout.test.ts new file mode 100644 index 00000000..0dd1c525 --- /dev/null +++ b/frontend/tests/rectification-split-holdout.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { holdoutEventIds, splitHoldoutEvents } from "../src/lib/rectification-agentic/core/split-holdout.ts"; + +test("holdout fallback reserves a month-or-better event, not a trailing year event", () => { + const events = splitHoldoutEvents([ + { id: "job-a", domain: "career", year: 2018, precision: "month" }, + { id: "job-b", domain: "career", year: 2020, precision: "day" }, + { id: "love-a", domain: "relationship", year: 2016, precision: "year" }, + { id: "love-b", domain: "relationship", year: 2022, precision: "year" }, + ]); + assert.deepEqual([...holdoutEventIds(events)], ["job-b"]); + assert.equal(events.find((item) => item.id === "love-b")?.usage, "training"); + assert.equal(events.filter((item) => item.usage === "training").length, 3); +}); + +test("singleton-domain holdout still prefers that domain when training would stay at three", () => { + const events = splitHoldoutEvents([ + { id: "edu", domain: "education", year: 2016, precision: "year" }, + { id: "job-a", domain: "career", year: 2018, precision: "month" }, + { id: "job-b", domain: "career", year: 2020, precision: "month" }, + { id: "love", domain: "relationship", year: 2021, precision: "year" }, + ]); + assert.deepEqual([...holdoutEventIds(events)], ["edu"]); +});