diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2362c823..594624cc 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6799,3 +6799,19 @@ - 复发自:无 - 修复版本:待发布 +## BUG-444 | 无年份分盘对比题重新出成无星座依据的点选卡 + +- 状态:resolved +- 首次发现:2026-08-30 +- 最近更新:2026-08-30 +- 影响面:`isRenderableProbe` / `inspectDiscriminatorProbes` / `rankRenderableDiscriminators` / `buildChoiceFrame` / `shouldAttachChoiceFrame`、无年份 remaining-layer 分盘对比题 +- 用户现象:家里有没有结婚添丁住院、有没有搬家、学业压力、收入变化、生病受伤这类题可以在题干没有任何时间范围的情况下出成四选一点选卡;选项仍写「明确发生且时间吻合」「这段记不清楚」。答了以后按分组顺序灌进后验,候选被无依据地拉开或压掉。 +- 触发条件:`REMAINING_LAYERS` 产出 `year: 0` 的分盘对比探针;训练门已开;出题层把无年份 `varga.*` 一律当成可渲染计分卡。 +- 根因:三段同时成立。(1) 为满足 BUG-440「同领域可渲染计分卡优先于不计分口述采集题」,把 `choice-card` 的无年份豁免从性格倾向题放宽到所有 `semantic_key` 以 `varga.` 开头的探针,重新打开了 BUG-414 关掉的门。(2) 存在类和质量类固定选项预设了题干里没有的时间范围(「时间吻合」「这段」)。(3) 非 `varga_style` 的分盘对比题 `remainingStyleOptions` 不绑定星座,`remainingOutcomes` 按分组顺序映射 yes / weak_yes / no,属于无依据计分。 +- 修复:没有具体年份或月份时,只有 `choice_kind === varga_style` 且计分选项带 `sign` 才允许铸卡。同一条判定走共享的 `isRenderableProbe`,同时作用于 `inspectDiscriminatorProbes` 与 `rankRenderableDiscriminators`。被排除的探针进入 `dropped_probes`,理由 `yearless_ungrounded_contrast`。题干无时间范围时选项不得含「时间吻合」「这段」。`oos_blind` 且无年份的 followup 显式不得挂 `choice_frame`。覆盖门与区间出口保持 BUG-442:训练门已开且没有可渲染区分卡时仍可 `canOfferRange`。计划回落到带年份的采集题。未改 confirmation gate、answer_class 映射、探针身份或 hash。Skill 保持 10.0.13。 +- 验证:`frontend/tests/rectification-yearless-ungrounded.test.ts` 逐层锁无年份 d4/d7/d12/d2/d11/d30(existence)与 d24/d5(event_quality)不出卡;带 sign 的无年份 d9/d10 仍出卡,缺 sign 的 varga_style 不出卡;带年份的 dasha_boundary / age_band / known_event_quality / reserved_event 仍出卡;被排除探针出现在 `dropped_probes`;决策层与出题层同一探针池结论一致;oos_blind 无年份无卡。修复前:上述无年份 existence/quality 层会铸卡,决策可能落 `ask_candidate_discriminator`。BUG-440 collect-stall、BUG-437 hash、BUG-442 occupation 覆盖与区间出口、BUG-414 无年份家人卡、BUG-443 风格文案保持绿。 +- 防复发:只有选项绑定星座的性格倾向题可以没有时间范围;其余分盘对比题必须有具体年份或月份;无时间范围的卡不得有预设时间的选项;被规则排除的探针必须记入 `dropped_probes`;不得为了「有题可问」重新放宽本条。判定必须看 `kind` 与 `sign` 字段,不得按层名白名单,不得对题干做语义分析。 +- 相关记录:BUG-432、BUG-440 +- 复发自:BUG-414 +- 修复版本:待发布 + 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 011043de..b2257e36 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -437,6 +437,7 @@ export function inspectDiscriminatorProbes( expectedOutcomeCount: completed.probe.expectedOutcomes.length, choiceKind: completed.probe.choiceKind, styleOptions: completed.probe.styleOptions, + year: completed.probe.year, }); if (!renderable.ok) { dropped.push({ diff --git a/frontend/src/lib/rectification-agentic/v9/choice-card.ts b/frontend/src/lib/rectification-agentic/v9/choice-card.ts index f1d07f71..5e9cee54 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-card.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-card.ts @@ -9,7 +9,7 @@ */ import type { AnswerClass } from "../core/types"; -import { completeStyleOptions, clippedProbeLabel } from "./probe-question-contract"; +import { completeStyleOptions, clippedProbeLabel, canRenderYearlessChoice } from "./probe-question-contract"; import type { DiscriminatingEventProbe, EventProbeChoiceKind, EventProbeStyleOption } from "./refinement-packet"; import type { InternalVargaObservation } from "./varga-observations"; @@ -337,8 +337,11 @@ function hypothesisFor( if (!styleOptions.ok) return null; const period = periodFor(evidence, domain, probes, birthDate, followup); const kind = followup.choice_kind ?? probe.choice_kind ?? "existence"; - const yearlessVarga = (followup.semantic_key ?? probe.semantic_key ?? "").startsWith("varga."); - if (kind !== "varga_style" && !isConcreteChoicePeriod(period) && !yearlessVarga) return null; + if (kind === "varga_style") { + if (!canRenderYearlessChoice({ choiceKind: kind, styleOptions: styleOptions.options })) return null; + } else if (!isConcreteChoicePeriod(period)) { + return null; + } const prompt = eventQuestionPrompt(period, probe.event_family, kind, domain); const why = probe.user_meaning?.trim() || followup.user_prompt_hint.trim(); if (!why) return null; diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 37acfc28..9243dad9 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -47,13 +47,14 @@ * do not stamp a ledger year onto a yearless scoring card. An open varga * discriminator must resolve from the contrast packet when Python event * probes have no row in that domain. Scoring reverse-inference cards need - * the engine year or month. Yearless varga cards stay out of the dated - * ranking until the training gate is open. Uncovered relationship, career, - * and family still ask a same-domain card or spoken collect first. - * Occupation collect must not block those yearless cards once training - * events already pass. If the next ask would be a spoken - * collect in the same domain, prefer a renderable yearless scoring card - * instead of an unscoreable collect question. + * the engine year or month. Yearless contrast is renderable only when + * choice_kind is varga_style and scoring options carry signs. Other + * remaining-layer probes stay in the packet but are dropped as + * yearless_ungrounded_contrast. Uncovered relationship, career, and + * family still ask a same-domain signed style card or dated spoken + * collect first. Occupation collect must not block signed D9/D10 cards + * once training events already pass. Do not re-widen yearless existence + * or quality cards to keep the interview moving. */ import { @@ -600,8 +601,10 @@ function renderableEventProbe( expectedOutcomeCount: probe.expected_outcomes?.length, choiceKind: probe.choice_kind, styleOptions: styleOptions.options, + year: probe.year, + yearLabel: probe.year_label, }); - if (!renderable.ok) { + if (!renderable.ok && renderable.reason !== "yearless_ungrounded_contrast") { return { row: null, dropped: droppedFromProbe(key, probe.information_gain ?? 0, renderable.reason) }; } const layer = vargaLayerFromSemanticKey(key); @@ -620,7 +623,9 @@ function renderableEventProbe( topCandidateTimes, }), }, - dropped: null, + dropped: renderable.ok + ? null + : droppedFromProbe(key, probe.information_gain ?? 0, renderable.reason), }; } @@ -653,8 +658,9 @@ function renderableContrastProbe( expectedOutcomeCount: working.expectedOutcomes.length, choiceKind: working.choiceKind, styleOptions, + year: working.year, }); - if (!renderable.ok) { + if (!renderable.ok && renderable.reason !== "yearless_ungrounded_contrast") { return { row: null, dropped: droppedFromProbe(working.semanticKey, working.informationGain, renderable.reason), @@ -677,7 +683,9 @@ function renderableContrastProbe( topCandidateTimes, }), }, - dropped: null, + dropped: renderable.ok + ? null + : droppedFromProbe(working.semanticKey, working.informationGain, renderable.reason), }; } @@ -853,10 +861,12 @@ function agentHint( } export function shouldAttachChoiceFrame( - item: Pick, + item: Pick, evidence: readonly MethodFollowupEvidence[] = [], ): boolean { - if (item.intent === "out_of_sample_check" || item.source === "oos_blind") return true; + if (item.intent === "out_of_sample_check" || item.source === "oos_blind") { + return (item.probe_year ?? 0) > 0; + } if (item.intent === "reverse_verify" || item.source === "reverse_verify") return true; if (item.intent === "clarify_event") return true; if (item.source === "event_probe") return true; @@ -1377,7 +1387,7 @@ export function buildMethodFollowupPlan(input: { ); return { methods, - next_followup: fields ? makeFollowup(fields, false, true) : null, + next_followup: fields ? makeFollowup(fields, false) : null, deferred_followup: null, session_outcome: sessionOutcome ?? "collect_evidence", stop_domain_rotation: true, @@ -1816,7 +1826,7 @@ export function buildMethodFollowupPlan(input: { input.oosBlindPrompts?.[0], (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null, ); - if (fields) next = makeFollowup(fields, false, true); + if (fields) next = makeFollowup(fields, false); } else if (horaryStatus === "uncovered") { next = sameDomainYearlessCard("horary") ?? makeFollowup({ method_id: "horary", 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 519d3f89..252ee2b3 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -32,7 +32,8 @@ export type ProbeRejectReason = | StyleOptionsRejectReason | "zero_gain" | "insufficient_candidates" - | "insufficient_outcomes"; + | "insufficient_outcomes" + | "yearless_ungrounded_contrast"; export type StyleOptionsResult = | { ok: true; options: ProbeStyleOption[] } @@ -78,8 +79,11 @@ export const VARGA_UNSURE_STYLE_OPTION: ProbeStyleOption = { }; export const FORBIDDEN_COPY_TOKENS = ["外貌", "体质", "胎记", "疤痕", "伤疤", "身高", "体型"] as const; +export const YEARLESS_PERIOD_COPY_MARKERS = ["时间吻合", "这段"] as const; export const LABEL_MIN = 4; export const LABEL_MAX = 80; +const PLACEHOLDER_PERIOD_LABEL = /^(当前这几个候选|那段时间)$/; +const CONCRETE_YEAR_IN_LABEL = /(?:19|20)\d{2}/; const FORBIDDEN_COPY = /外貌|体质|胎记|疤痕|伤疤|身高|体型|(?:[01]?\d|2[0-3]):[0-5]\d/; @@ -220,12 +224,51 @@ export function isRenderableStyleOptions(options: readonly ProbeStyleOption[] | && options.every((item) => !/·(?:yes|weak_yes|no|unsure)$/.test(item.label)); } +export function probeHasConcretePeriod(input: { + year?: number | null; + yearLabel?: string | null; +}): boolean { + if ((input.year ?? 0) <= 0) return false; + const label = (input.yearLabel ?? "").trim(); + if (!label) return true; + if (PLACEHOLDER_PERIOD_LABEL.test(label)) return false; + return CONCRETE_YEAR_IN_LABEL.test(label); +} + +export function scoringOptionsHaveSigns(options: readonly ProbeStyleOption[]): boolean { + const yes = options.find((item) => item.answer_class === "yes"); + const weakYes = options.find((item) => item.answer_class === "weak_yes"); + if (!yes?.sign?.trim() || !weakYes?.sign?.trim()) return false; + const none = options.find((item) => item.answer_class === "no"); + if (!none) return false; + if (none.sign?.trim()) return true; + return none.label === VARGA_NONE_STYLE_OPTION.label; +} + +export function optionCopyPresupposesPeriod(options: readonly ProbeStyleOption[]): boolean { + return options.some((item) => ( + YEARLESS_PERIOD_COPY_MARKERS.some((marker) => item.label.includes(marker)) + )); +} + +export function canRenderYearlessChoice(input: { + choiceKind?: string | null; + styleOptions: readonly ProbeStyleOption[]; +}): boolean { + if (probeQuestionKind(input.choiceKind) !== "varga_style") return false; + if (!scoringOptionsHaveSigns(input.styleOptions)) return false; + return !optionCopyPresupposesPeriod(input.styleOptions); +} + export function isRenderableProbe(input: { informationGain?: number | null; candidateIds?: readonly string[] | null; expectedOutcomeCount?: number | null; choiceKind?: string | null; styleOptions?: readonly unknown[] | null; + year?: number | null; + yearLabel?: string | null; + hasConcretePeriod?: boolean; }): ProbeRenderResult { const gain = typeof input.informationGain === "number" && Number.isFinite(input.informationGain) ? input.informationGain @@ -238,6 +281,19 @@ export function isRenderableProbe(input: { styleOptions: input.styleOptions, }); if (!styles.ok) return { ok: false, reason: styles.reason }; + const kind = probeQuestionKind(input.choiceKind); + const concrete = kind === "varga_style" + ? false + : input.hasConcretePeriod ?? probeHasConcretePeriod({ + year: input.year, + yearLabel: input.yearLabel, + }); + if (!concrete && !canRenderYearlessChoice({ + choiceKind: input.choiceKind, + styleOptions: styles.options, + })) { + return { ok: false, reason: "yearless_ungrounded_contrast" }; + } return { ok: true }; } diff --git a/frontend/tests/rectification-candidate-contrast-packet.test.ts b/frontend/tests/rectification-candidate-contrast-packet.test.ts index 92194429..b56cf6d5 100644 --- a/frontend/tests/rectification-candidate-contrast-packet.test.ts +++ b/frontend/tests/rectification-candidate-contrast-packet.test.ts @@ -5,6 +5,7 @@ import { readFileSync } from "node:fs"; import { buildCandidateContrastPacket, + inspectDiscriminatorProbes, isSameCandidateSplit, mentionedVargaKeysFromLedgerEvidence, remainingVargaSplits, @@ -72,12 +73,10 @@ test("remaining D7 enters the pool as family existence", () => { candidateTimes: ["05:00", "05:04"], remainingSplits: splits, }); - const probe = selectDiscriminatorProbe(packet); - assert.equal(probe?.domain, "family"); - assert.equal(probe?.choiceKind, "existence"); - assert.ok((probe?.informationGain ?? 0) > 0); - assert.equal((probe?.expectedOutcomes.length ?? 0) >= 2, true); - assert.match(probe?.candidateSplitHash ?? "", /05:00-05:04/); + assert.ok(packet.probes.some((item) => item.domain === "family" && item.choiceKind === "existence")); + const inspected = inspectDiscriminatorProbes(packet); + assert.equal(inspected.selected, null); + assert.equal(inspected.dropped.some((item) => item.reason === "yearless_ungrounded_contrast"), true); }); test("finance remaining splits stay out unless volunteered", () => { @@ -116,25 +115,28 @@ 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\./); + assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d24."))); + const inspected = inspectDiscriminatorProbes(packet); + assert.equal(inspected.selected, null); + assert.equal(inspected.dropped.some((item) => ( + item.semantic_key.startsWith("varga.d24.") && item.reason === "yearless_ungrounded_contrast" + )), true); }); -test("D9 remaining split without signs degrades to existence instead of dropping", () => { +test("D9 remaining split without signs stays in the packet as existence but is not a card", () => { 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"]); + const inPacket = packet.probes.find((item) => item.semanticKey.startsWith("varga.d9.")); + assert.ok(inPacket); + assert.equal(inPacket.choiceKind, "existence"); + const inspected = inspectDiscriminatorProbes(packet); + assert.equal(inspected.selected, null); + assert.equal(inspected.dropped.some((item) => ( + item.semantic_key.startsWith("varga.d9.") && item.reason === "yearless_ungrounded_contrast" + )), true); }); test("occupation mention does not skip remaining D10; answered varga.d10 still does", () => { @@ -161,7 +163,7 @@ test("occupation mention does not skip remaining D10; answered varga.d10 still d assert.ok(mentionedPacket.probes.some((item) => item.semanticKey.startsWith("varga.d10."))); assert.match( selectDiscriminatorProbe(mentionedPacket, { mentionedKeys: mentioned })?.semanticKey ?? "", - /^varga\.d4\./, + /^varga\.d10\./, ); const answeredPacket = buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:04", @@ -170,6 +172,7 @@ test("occupation mention does not skip remaining D10; answered varga.d10 still d askedKeys: ["varga.d10"], }); assert.equal(answeredPacket.probes.some((item) => item.semanticKey.startsWith("varga.d10.")), false); + assert.equal(selectDiscriminatorProbe(answeredPacket)?.semanticKey.startsWith("varga.d4.") ?? false, false); }); test("a stale candidate-set prefix is re-minted instead of stacked", () => { diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index da0760f6..a8f71385 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -342,12 +342,14 @@ test("yearless D24 quality cards do not borrow a recorded education year", () => style_options: QUALITY_STYLE_OPTIONS, }], }); - assert.ok(frame); - assert.doesNotMatch(frame.period, /2016/); - assert.doesNotMatch(frame.prompt, /2016/); + const period = frame?.period ?? ""; + const prompt = frame?.prompt ?? ""; + assert.equal(frame, null); + assert.doesNotMatch(period, /2016/); + assert.doesNotMatch(prompt, /2016/); }); -test("yearless family cards attach a scoring frame without borrowing another domain's year", () => { +test("yearless family cards no longer mint a scoring frame", () => { const frame = buildChoiceFrame({ method_id: "relatives", ask_theme: "family_event", @@ -373,10 +375,11 @@ test("yearless family cards attach a scoring frame without borrowing another dom semantic_key: "varga.d12.05:00/05:07", }], }); - assert.ok(frame); - assert.doesNotMatch(frame.period, /2016/); - assert.doesNotMatch(frame.prompt, /2016/); - assert.match(frame.neither_label + frame.option_a_hint + frame.option_b_hint, /明确没有发生|家人/); + const period = frame?.period ?? ""; + const prompt = frame?.prompt ?? ""; + assert.equal(frame, null); + assert.doesNotMatch(period, /2016/); + assert.doesNotMatch(prompt, /2016/); }); test("yearless family cards do not borrow a recorded family year", () => { @@ -405,9 +408,11 @@ test("yearless family cards do not borrow a recorded family year", () => { semantic_key: "varga.d12.05:00/05:07", }], }); - assert.ok(frame); - assert.doesNotMatch(frame.period, /2018/); - assert.doesNotMatch(frame.prompt, /2018/); + const period = frame?.period ?? ""; + const prompt = frame?.prompt ?? ""; + assert.equal(frame, null); + assert.doesNotMatch(period, /2018/); + assert.doesNotMatch(prompt, /2018/); }); test("GET upgrades a persisted placeholder stem to the recorded year lock", () => { @@ -1394,7 +1399,7 @@ const D9_STYLE_OPTIONS = [ { sign: "天秤座", label: "和谐、美感、合作", answer_class: "yes" }, { sign: "天蝎座", label: "深刻、占有欲、转化", answer_class: "weak_yes" }, { sign: "射手座", label: "自由、哲学、冒险", answer_class: "no" }, - { label: "这段记不清楚", answer_class: "unsure" }, + { label: "一时说不好", answer_class: "unsure" }, ] as const; function staleSplitDossier(schemaHash: string) { diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index c6ecf66e..790b49b9 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -3,7 +3,6 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { applyAnswerToState, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; -import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; import { decideFromDossier, @@ -12,7 +11,6 @@ import { } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { focusStatusForAnswer } from "../src/lib/rectification-agentic/v9/choice-action.ts"; -import { serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts"; import { @@ -404,7 +402,7 @@ test("skill version stays 10.0.13 for this collect-stall fix", () => { assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13"); }); -test("revision 5 with uncovered relatives asks the yearless D12 distinguish card, not a family spoken collect", () => { +test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => { const state = revision5State(); assert.equal(state.revision, 5); assert.equal(state.answered_probes.length, 4); @@ -417,50 +415,30 @@ test("revision 5 with uncovered relatives asks the yearless D12 distinguish card const plan = planFrom(revision5Dossier(state)); assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered"); - assert.equal(plan.next_followup?.intent, "distinguish_candidates"); - assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); - assert.ok(plan.next_followup?.choice_frame); - assert.notEqual(plan.next_followup?.source, "method_coverage"); - const copy = serverOwnedChoiceCopy(plan.next_followup!.choice_frame!); - assert.ok(copy?.options.some((item) => item.label === "明确没有发生" && item.answer_class === "no")); - assert.equal(copy?.options.find((item) => item.key === "C")?.answer_class, "no"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.domain, "family"); + assert.equal(plan.next_followup?.choice_frame, null); + assert.equal(plan.next_followup?.probe_year, 2021); + assert.match(plan.next_followup?.year_label ?? "", /2021/); + assert.equal(plan.dropped_probes.some((item) => ( + item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast" + )), true); }); -test("answering C on the D12 card declines the family focus and scores the no branch", () => { - const state = revision5State(); - const plan = planFrom(revision5Dossier(state)); - assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); +test("denying the dated family collect declines relatives and leaves the D12 card unasked", () => { + const plan = planFrom(revision5Dossier()); assert.equal(plan.next_followup?.domain, "family"); - const copy = serverOwnedChoiceCopy(plan.next_followup!.choice_frame!); - assert.equal(copy?.options.find((item) => item.key === "C")?.answer_class, "no"); + assert.equal(plan.next_followup?.choice_frame, null); assert.equal(focusStatusForAnswer("no", "C"), "declined"); - const applied = applyChoiceWithoutEvidence(state, { - choiceKey: "C", - schema: { - choice: copy, - probe_id: D12.id, - semantic_key: D12.semantic_key, - candidate_split_hash: D12.candidate_split_hash, - }, - }); - assert.equal(applied.applied, true); - assert.equal(applied.answerClass, "no"); - assert.equal(applied.probeId, D12.id); - const recorded = applied.state.answered_probes.find((item) => item.probe_id === D12.id); - assert.equal(recorded?.answer_class, "no"); - const noBranch = D12.expected_outcomes.find((item) => item.answer_class === "no"); - assert.deepEqual(noBranch?.supports, ["05:00", "05:07", "05:12"]); - assert.deepEqual(noBranch?.conflicts, ["04:47", "04:51", "04:53", "04:59"]); - - const next = planFrom(revision5Dossier(applied.state, { + const next = planFrom(revision5Dossier(revision5State(), { declinedTopics: [{ target_domain: "family", status: "declined" }], })); assert.equal(next.methods.find((item) => item.method_id === "relatives")?.status, "covered"); assert.notEqual(next.next_followup?.domain, "family"); if (next.next_followup?.choice_frame) { assert.equal(next.next_followup.intent, "distinguish_candidates"); - assert.match(next.next_followup.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); + assert.doesNotMatch(next.next_followup.semantic_key ?? "", /^varga\.d(12|24|7|4|5)\./); } else { assert.equal(next.next_followup?.domain, "occupation"); assert.equal(next.next_followup?.intent, "collect_method_evidence"); @@ -499,7 +477,7 @@ test("persistServerOwnedFocus writes family as target_domain so a no answer beco assert.equal(persisted.status, "created"); const call = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus"); assert.equal(call?.args.p_target_domain, "family"); - assert.equal(call?.args.p_intent, "distinguish_candidates"); + assert.equal(call?.args.p_intent, "collect_method_evidence"); }); test("occupation collect denial declines the focus and advances coverage to horary", async () => { diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index 25f7fb40..c13eb6fb 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -204,7 +204,7 @@ test("whole-window D9/D10 signs do not synthesize a discriminator without remain assert.equal(selectDiscriminatorProbe(packet), null); }); -test("exam quality and occupation notes keep remaining D10/D24 but prefer unmentioned D4", () => { +test("exam quality and occupation notes keep remaining D10 and drop yearless D4/D24 from selection", () => { const evidence = [ { domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" }, { domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员 / 前端", status: "confirmed" }, @@ -228,15 +228,9 @@ test("exam quality and occupation notes keep remaining D10/D24 but prefer unment 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"); - assert.ok(probe.informationGain > 0); - assert.ok(probe.expectedOutcomes.length >= 2); - const mapped = new Set(probe.expectedOutcomes.flatMap((row) => [ - ...row.supportsCandidateIds, - ...row.conflictsCandidateIds, - ])); - assert.ok(mapped.size >= 2); + assert.match(probe.semanticKey, /varga\.d10/); + assert.equal(probe.domain, "career"); + assert.equal(probe.choiceKind, "varga_style"); }); test("encoded D24/D10/D4 remaining splits leave no discriminator", () => { @@ -254,7 +248,7 @@ test("encoded D24/D10/D4 remaining splits leave no discriminator", () => { assert.equal(selectDiscriminatorProbe(packet), null); }); -test("remaining-candidate D24 split beats window D10 signs", () => { +test("remaining-candidate D24 split is not a selectable discriminator", () => { const packet = buildCandidateContrastPacket({ candidateSetVersion: "05:00-05:07", calculationResultId: "22222222-2222-4222-8222-222222222222", @@ -271,14 +265,7 @@ test("remaining-candidate D24 split beats window D10 signs", () => { askedKeys: ["education.2016", "varga.d10"], }); const probe = selectDiscriminatorProbe(packet); - assert.ok(probe); - assert.match(probe.semanticKey, /varga\.d24/); - assert.equal(probe.domain, "education"); - assert.deepEqual(probe.expectedOutcomes[0]?.supportsCandidateIds, ["05:00"]); - assert.ok(probe.expectedOutcomes[0]?.conflictsCandidateIds.includes("05:06")); - assert.ok(probe.expectedOutcomes.every((row) => ( - row.supportsCandidateIds.every((id) => id.includes(":")) - ))); + assert.equal(probe, null); }); test("user stop completes with a credible range instead of trapping on exact-minute", () => { diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 112011de..20b90100 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -7,7 +7,7 @@ import { publicDecisionFields, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts"; -import { selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import { inspectDiscriminatorProbes, selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; import { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; @@ -337,11 +337,13 @@ test("scored inference catalog outranks a low-gain Python career probe when snap case: { acceptedTime: null }, }; const packet = contrastPacketFromDossier(dossier); - const selected = selectDiscriminatorProbe(packet); + const inspected = inspectDiscriminatorProbes(packet); assert.equal(packet.probes.some((probe) => probe.semanticKey.includes("career.2023")), false); - assert.match(selected?.semanticKey ?? "", /^varga\.d24\./); - assert.ok((selected?.informationGain ?? 0) > 2); - assert.doesNotMatch(selected?.semanticKey ?? "", /career\.2023/); + assert.ok(packet.probes.some((probe) => probe.semanticKey.startsWith("varga.d24."))); + assert.equal(inspected.selected, null); + assert.equal(inspected.dropped.some((item) => ( + item.semantic_key.startsWith("varga.d24.") && item.reason === "yearless_ungrounded_contrast" + )), true); const plan = buildMethodFollowupPlan({ evidence, diff --git a/frontend/tests/rectification-discriminator-followup-consistency.test.ts b/frontend/tests/rectification-discriminator-followup-consistency.test.ts index 0e6f2fde..e1d8234f 100644 --- a/frontend/tests/rectification-discriminator-followup-consistency.test.ts +++ b/frontend/tests/rectification-discriminator-followup-consistency.test.ts @@ -264,7 +264,7 @@ function deadlockDossier(): { dossier: DecisionDossier; state: ReturnType { const { dossier } = deadlockDossier(); const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(dossier)); - assert.equal(inspected.selected?.semanticKey, D24.semantic_key); + assert.equal(inspected.selected?.semanticKey, D9_STYLE_NO_OPTIONS.semantic_key); const d9 = withCompletedContrastOptions(contrastFromConflict(D9_STYLE_NO_OPTIONS)); const d10 = withCompletedContrastOptions(contrastFromConflict(D10_STYLE_NO_OPTIONS)); @@ -345,7 +345,7 @@ test("method coverage incomplete still asks a renderable D9 style card", () => { assert.equal(decision.probe?.semanticKey, plan.next_followup?.semantic_key); }); -test("clock-key varga_style without signs fail-closes to a same-domain D12 card instead of a dead-end discriminator", () => { +test("clock-key varga_style without signs fail-closes to dated family collect instead of a dead-end discriminator", () => { const d9Clock: ConflictProbe = { ...D9_STYLE_NO_OPTIONS, id: "contrast:varga.d9.05:00/05:10", @@ -401,8 +401,10 @@ test("clock-key varga_style without signs fail-closes to a same-domain D12 card }, }; const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(clockDossier)); - assert.equal(inspected.selected?.semanticKey, D24.semantic_key); - assert.equal(inspected.selected?.choiceKind, "event_quality"); + assert.equal(inspected.selected, null); + assert.equal(inspected.dropped.some((item) => ( + item.semantic_key === d9Clock.semantic_key && item.reason === "yearless_ungrounded_contrast" + )), true); const d9 = withCompletedContrastOptions(contrastFromConflict(d9Clock)); assert.equal(d9.ok, true); if (d9.ok) assert.equal(d9.probe.choiceKind, "existence"); @@ -415,16 +417,17 @@ test("clock-key varga_style without signs fail-closes to a same-domain D12 card ...catalog, candidatesSeparated: false, }); - assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), true); - assert.equal(plan.next_followup?.intent, "distinguish_candidates"); - assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); - assert.ok(plan.next_followup?.choice_frame); + assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.domain, "family"); + assert.equal(plan.next_followup?.choice_frame, null); + assert.equal(plan.next_followup?.probe_year, 2021); assert.notEqual(plan.next_followup?.semantic_key, d9Clock.semantic_key); assert.notEqual(plan.next_followup?.semantic_key, d10Clock.semantic_key); const decision = decideFromDossier(clockDossier); - assert.equal(decision.nextAction, "ask_candidate_discriminator"); - assert.equal(decision.probe?.semanticKey, plan.next_followup?.semantic_key); + assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); + assert.equal(decision.probe, null); assert.equal(decision.canConfirmExactMinute, false); }); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 25b16559..66ea3574 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -2000,7 +2000,7 @@ test("yearless family contrast yields to a dated career discriminator", () => { assert.doesNotMatch(plan.next_followup?.choice_frame?.prompt ?? "", /那段时间/); }); -test("yearless family contrast without a dated discriminator asks the D12 distinguish card", () => { +test("yearless family contrast without a dated discriminator asks dated family collect", () => { const plan = buildMethodFollowupPlan({ evidence: [ ...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary" && item.domain !== "family"), @@ -2012,12 +2012,29 @@ test("yearless family contrast without a dated discriminator asks the D12 distin probes: [YEARLESS_D12], }, candidatesSeparated: false, + evidenceCollectionProbes: [{ + year: 2021, + year_label: "2021 年前后", + domain: "family", + event_family: "家人结婚、添丁或住院", + source: "age_band", + tracks: ["vimshottari", "narayana"], + tracks_agree: false, + unique_minute_claim: false, + user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", + role: "collect", + phase: "evidence_collection", + information_gain: 0, + semantic_key: "family.2021", + }], }); - assert.equal(plan.next_followup?.intent, "distinguish_candidates"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); assert.equal(plan.next_followup?.domain, "family"); - assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d12/); - assert.ok(plan.next_followup?.choice_frame); - assert.notEqual(plan.next_followup?.source, "method_coverage"); + assert.equal(plan.next_followup?.choice_frame, null); + assert.equal(plan.next_followup?.probe_year, 2021); + assert.equal(plan.dropped_probes.some((item) => ( + item.semantic_key.startsWith("varga.d12") && item.reason === "yearless_ungrounded_contrast" + )), true); }); test("family collect attaches the collection-probe year instead of a yearless D24 card", () => { @@ -2076,7 +2093,7 @@ test("family collect attaches the collection-probe year instead of a yearless D2 assert.match(spokenFollowupForUser(plan.next_followup) ?? "", /2021 年前后,家里有没有/); }); -test("already-open yearless family card keeps the D12 scoring frame", () => { +test("already-open yearless family card is abandoned instead of kept as a scoring frame", () => { const plan = buildMethodFollowupPlan({ evidence: [ ...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary" && item.domain !== "family"), @@ -2088,6 +2105,21 @@ test("already-open yearless family card keeps the D12 scoring frame", () => { probes: [YEARLESS_D12], }, candidatesSeparated: false, + evidenceCollectionProbes: [{ + year: 2021, + year_label: "2021 年前后", + domain: "family", + event_family: "家人结婚、添丁或住院", + source: "age_band", + tracks: ["vimshottari", "narayana"], + tracks_agree: false, + unique_minute_claim: false, + user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。", + role: "collect", + phase: "evidence_collection", + information_gain: 0, + semantic_key: "family.2021", + }], activeFocus: { intent: "distinguish_candidates", targetDomain: "family", @@ -2109,9 +2141,10 @@ test("already-open yearless family card keeps the D12 scoring frame", () => { }, }, }); - assert.equal(plan.next_followup?.intent, "distinguish_candidates"); - assert.ok(plan.next_followup?.choice_frame); - assert.match(plan.next_followup?.semantic_key ?? "", /varga\.d12/); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.domain, "family"); + assert.equal(plan.next_followup?.choice_frame, null); + assert.equal(plan.next_followup?.probe_year, 2021); }); test("already-open dated career card stays ahead of a yearless D24 catalog row", () => { diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index e9f6bfb0..650d9b6d 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -242,14 +242,47 @@ test("career evidence alone still does not cover occupation", () => { assert.equal(plan.next_followup?.method_id, "occupation"); }); -test("training gate open and coverage incomplete can select a yearless varga card", () => { +test("training gate open does not mint yearless existence or quality varga cards", () => { const plan = occupationPlan(); assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered"); + assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); + assert.equal(plan.dropped_probes.some((item) => item.reason === "yearless_ungrounded_contrast"), true); + assert.equal(plan.next_followup?.method_id, "occupation"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.equal(plan.next_followup?.choice_frame, null); +}); + +test("training gate open still asks a signed yearless D10 style card", () => { + const d10 = { + probeId: "contrast:varga.d10.巨蟹座/狮子座", + candidateSetVersion: "04:47-05:15", + question: "平时做事,你更接近下面哪一种?", + expectedOutcomes: [ + { outcomeId: "yes" as const, supportsCandidateIds: ["04:47"], conflictsCandidateIds: ["05:00"] }, + { outcomeId: "weak_yes" as const, supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["04:47"] }, + ], + candidateSplitHash: "varga.d10.巨蟹座/狮子座", + informationGain: 1.4, + sourceFeatures: [{ technique: "D10", calculationResultId: null }], + domain: "career", + year: null as number | null, + semanticKey: "varga.d10.巨蟹座/狮子座", + choiceKind: "varga_style" as const, + styleOptions: [ + { label: "做事以照顾人为主,在意团队里的感受", answerClass: "yes" as const, sign: "巨蟹座" }, + { label: "习惯带头,也不排斥站到台前", answerClass: "weak_yes" as const, sign: "狮子座" }, + ], + }; + const plan = occupationPlan({ + contrastPacket: { + ...YEARLESS_PACKET, + probes: [...YEARLESS_PACKET.probes, d10], + }, + }); assert.equal(plan.next_followup?.intent, "distinguish_candidates"); assert.ok(plan.next_followup?.choice_frame); - assert.match(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./); - assert.notEqual(plan.next_followup?.method_id, "occupation"); - assert.notEqual(plan.next_followup?.source, "method_coverage"); + assert.equal(plan.next_followup?.semantic_key, d10.semanticKey); + assert.equal(plan.next_followup?.choice_kind, "varga_style"); }); test("training gate closed still withholds yearless varga cards", () => { diff --git a/frontend/tests/rectification-probe-question-contract.test.ts b/frontend/tests/rectification-probe-question-contract.test.ts index f8778fcc..cf2d8ed0 100644 --- a/frontend/tests/rectification-probe-question-contract.test.ts +++ b/frontend/tests/rectification-probe-question-contract.test.ts @@ -55,6 +55,8 @@ test("illegal clock or appearance copy cannot become a renderable probe", () => candidateIds: ["05:00", "05:04"], expectedOutcomeCount: 2, choiceKind: "existence", + year: 2016, + yearLabel: "2016 年前后", styleOptions: [ { label: "08:12 左右发生", answer_class: "yes" }, ], @@ -78,6 +80,61 @@ test("illegal clock or appearance copy cannot become a renderable probe", () => assert.equal(appearance.ok ? null : appearance.reason, "forbidden_copy"); }); +test("yearless existence and quality probes are ungrounded; signed varga_style is not", () => { + const yearlessExistence = isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:04"], + expectedOutcomeCount: 2, + choiceKind: "existence", + year: 0, + }); + assert.equal(yearlessExistence.ok, false); + assert.equal(yearlessExistence.ok ? null : yearlessExistence.reason, "yearless_ungrounded_contrast"); + const yearlessQuality = isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:04"], + expectedOutcomeCount: 2, + choiceKind: "event_quality", + year: 0, + }); + assert.equal(yearlessQuality.ok, false); + assert.equal(yearlessQuality.ok ? null : yearlessQuality.reason, "yearless_ungrounded_contrast"); + const signedStyle = isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:04"], + expectedOutcomeCount: 2, + choiceKind: "varga_style", + year: 0, + styleOptions: [ + { label: "做事以照顾人为主,在意团队里的感受", answer_class: "yes", sign: "巨蟹座" }, + { label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes", sign: "狮子座" }, + ], + }); + assert.equal(signedStyle.ok, true); + const unsignedStyle = isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:04"], + expectedOutcomeCount: 2, + choiceKind: "varga_style", + year: 0, + styleOptions: [ + { label: "做事以照顾人为主,在意团队里的感受", answer_class: "yes" }, + { label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" }, + ], + }); + assert.equal(unsignedStyle.ok, false); + assert.equal(unsignedStyle.ok ? null : unsignedStyle.reason, "yearless_ungrounded_contrast"); + const datedExistence = isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:04"], + expectedOutcomeCount: 2, + choiceKind: "existence", + year: 2016, + yearLabel: "2016 年前后", + }); + assert.equal(datedExistence.ok, true); +}); + test("duplicate visible labels uniquify with an index, not answer_class", () => { const completed = completeStyleOptions({ choiceKind: "varga_style", diff --git a/frontend/tests/rectification-varga-style-weight.test.ts b/frontend/tests/rectification-varga-style-weight.test.ts index 9b7fcf24..9844fc9b 100644 --- a/frontend/tests/rectification-varga-style-weight.test.ts +++ b/frontend/tests/rectification-varga-style-weight.test.ts @@ -191,11 +191,13 @@ test("engine varga.d9/d10 without style_options scores with the render effective }], }; const scored = conflictProbesFromContrast(packet); - const rendered = inspectDiscriminatorProbes(packet).selected; + const inspected = inspectDiscriminatorProbes(packet); assert.equal(scored.length, 1, semanticKey); - assert.ok(rendered, semanticKey); - assert.equal(scored[0]?.choice_kind, rendered.choiceKind, semanticKey); - assert.equal(rendered.choiceKind, "existence", semanticKey); + assert.equal(inspected.selected, null, semanticKey); + assert.equal(inspected.dropped.some((item) => ( + item.semantic_key === semanticKey && item.reason === "yearless_ungrounded_contrast" + )), true, semanticKey); + assert.equal(scored[0]?.choice_kind, "existence", semanticKey); } }); diff --git a/frontend/tests/rectification-yearless-ungrounded.test.ts b/frontend/tests/rectification-yearless-ungrounded.test.ts new file mode 100644 index 00000000..8e44cb4c --- /dev/null +++ b/frontend/tests/rectification-yearless-ungrounded.test.ts @@ -0,0 +1,318 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { inspectDiscriminatorProbes } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; +import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts"; +import { + decideFromDossier, + followupAsksRenderableDiscriminator, + type DecisionDossier, +} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { buildMethodFollowupPlan, shouldAttachChoiceFrame } from "../src/lib/rectification-agentic/v9/method-followup.ts"; +import { + isRenderableProbe, + isRenderableStyleOptions, +} from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; +import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; +import { d9StyleLabel, d10StyleLabel } from "../src/lib/rectification-agentic/v9/varga-type-tables.ts"; + +const YEARLESS_LAYERS = [ + { layer: "d4", kind: "existence" as const, domain: "relocation", family: "搬家或长期住到外地" }, + { layer: "d7", kind: "existence" as const, domain: "family", family: "家人结婚、添丁或住院" }, + { layer: "d12", kind: "existence" as const, domain: "family", family: "家人结婚、添丁或住院" }, + { layer: "d2", kind: "existence" as const, domain: "finance", family: "收入明显变化、大笔支出或欠债" }, + { layer: "d11", kind: "existence" as const, domain: "finance", family: "收入明显变化、大笔支出或欠债" }, + { layer: "d30", kind: "existence" as const, domain: "health_pressure", family: "生病、受伤或压力特别大" }, + { layer: "d24", kind: "event_quality" as const, domain: "education", family: "学业或考试发挥失常、压力特别大" }, + { layer: "d5", kind: "event_quality" as const, domain: "education", family: "学业或考试发挥失常、压力特别大" }, +] as const; + +const DATED_EXISTENCE: DiscriminatingEventProbe = { + year: 2016, + year_label: "2016 年前后", + domain: "relocation", + event_family: "搬家、离乡或长期异地", + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "年份锁定 2016 年前后。", + role: "distinguish", + information_gain: 0.8, + candidate_ids: ["05:00", "05:20"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] }, + { answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] }, + ], + choice_kind: "existence", + semantic_key: "relocation.2016.dasha_boundary", +}; + +function yearlessProbe(layer: string, kind: "existence" | "event_quality" | "varga_style", domain: string, family: string): DiscriminatingEventProbe { + return { + year: 0, + year_label: "当前这几个候选", + domain: domain as DiscriminatingEventProbe["domain"], + event_family: family, + source: "dasha_activation", + tracks: ["vimshottari", "narayana"], + tracks_agree: true, + unique_minute_claim: false, + user_meaning: "对照分盘差异只作校时方法,不是命运承诺。", + role: "distinguish", + information_gain: 1.2, + candidate_ids: ["05:00", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] }, + ], + choice_kind: kind, + semantic_key: `varga.${layer}.05:00/05:07`, + }; +} + +function contrastFromYearless(layer: string, kind: "existence" | "event_quality" | "varga_style", domain: string, extra: Record = {}) { + return { + probeId: `contrast:varga.${layer}.05:00/05:07`, + candidateSetVersion: "05:00-05:07", + question: "当前几个候选还分得开。", + expectedOutcomes: [ + { outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07"] }, + { outcomeId: "no", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00"] }, + ], + candidateSplitHash: `varga.${layer}.05:00/05:07`, + informationGain: 1.4, + sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }], + domain, + year: null as number | null, + semanticKey: `varga.${layer}.05:00/05:07`, + choiceKind: kind, + ...extra, + }; +} + +function frameFor(probe: DiscriminatingEventProbe) { + return buildChoiceFrame({ + method_id: "dasha_events", + ask_theme: "dated_event", + domain: probe.domain, + user_prompt_hint: "unused", + choice_kind: probe.choice_kind, + semantic_key: probe.semantic_key, + style_options: probe.style_options, + }, { probes: [probe] }); +} + +test("yearless existence and quality varga layers do not mint a choice frame", () => { + for (const row of YEARLESS_LAYERS) { + const frame = frameFor(yearlessProbe(row.layer, row.kind, row.domain, row.family)); + assert.equal(frame, null, `yearless ${row.layer} should not card`); + } +}); + +test("yearless signed D9/D10 varga_style still cards; unsigned varga_style does not", () => { + const signed = frameFor({ + ...yearlessProbe("d9", "varga_style", "relationship", "相处方式更接近其中一种"), + style_options: [ + { sign: "天秤座", label: d9StyleLabel("天秤座"), answer_class: "yes" }, + { sign: "天蝎座", label: d9StyleLabel("天蝎座"), answer_class: "weak_yes" }, + ], + }); + assert.ok(signed); + const copy = serverOwnedChoiceCopy(signed!); + assert.ok(copy); + assert.equal(isRenderableStyleOptions(copy!.options.map((item) => ({ + label: item.label, + answer_class: item.answer_class, + }))), true); + assert.equal(copy!.options.some((item) => item.label.includes("时间吻合") || item.label.includes("这段")), false); + assert.equal(signed!.option_a_answer_class, "yes"); + assert.equal(signed!.option_b_answer_class, "weak_yes"); + + const unsigned = frameFor({ + ...yearlessProbe("d9", "varga_style", "relationship", "相处方式更接近其中一种"), + semantic_key: "varga.d9.05:00/05:07", + style_options: [ + { label: d9StyleLabel("天秤座"), answer_class: "yes" }, + { label: d9StyleLabel("天蝎座"), answer_class: "weak_yes" }, + ], + }); + assert.equal(unsigned, null); + + const d10 = frameFor({ + ...yearlessProbe("d10", "varga_style", "career", "做事风格更接近其中一种"), + style_options: [ + { sign: "巨蟹座", label: d10StyleLabel("巨蟹座"), answer_class: "yes" }, + { sign: "狮子座", label: d10StyleLabel("狮子座"), answer_class: "weak_yes" }, + ], + }); + assert.ok(d10); +}); + +test("dated dasha, age-band, quality, and reserved-event cards still mint", () => { + assert.ok(frameFor(DATED_EXISTENCE)); + assert.match(frameFor(DATED_EXISTENCE)!.prompt, /2016 年前后/); + const month = frameFor({ + ...DATED_EXISTENCE, + year: 2018, + year_label: "2018 年 3 月前后", + month: 3, + semantic_key: "relocation.2018.03.dasha_boundary", + }); + assert.ok(month); + assert.match(month!.prompt, /2018 年 3 月/); + const quality = frameFor({ + ...DATED_EXISTENCE, + year: 2020, + year_label: "2020 年前后", + domain: "education", + event_family: "学业或考试发挥失常、压力特别大", + source: "known_event_quality", + choice_kind: "event_quality", + semantic_key: "education.2020.known_event_quality", + }); + assert.ok(quality); + assert.match(quality!.prompt, /2020 年前后/); + const holdout = buildChoiceFrame({ + method_id: "oos_blind", + ask_theme: "oos_blind", + domain: "family", + user_prompt_hint: "unused", + choice_kind: "existence", + }, { + scoring: false, + probes: [{ + ...DATED_EXISTENCE, + year: 2023, + year_label: "2023 年前后", + domain: "family", + event_family: "家人结婚、添丁或住院", + user_meaning: "盘外核对:只核对家人事件,不计入候选评分。", + semantic_key: "family.2023.holdout", + }], + }); + assert.ok(holdout); +}); + +test("yearless ungrounded probes are dropped with a recognizable reason", () => { + const probes = YEARLESS_LAYERS.map((row) => contrastFromYearless(row.layer, row.kind, row.domain)); + const inspected = inspectDiscriminatorProbes({ + candidateSetVersion: "05:00-05:07", + vargaDifferences: [], + probes, + }); + assert.equal(inspected.selected, null); + for (const row of YEARLESS_LAYERS) { + const dropped = inspected.dropped.find((item) => item.semantic_key === `varga.${row.layer}.05:00/05:07`); + assert.ok(dropped, row.layer); + assert.equal(dropped!.reason, "yearless_ungrounded_contrast"); + } + const plan = buildMethodFollowupPlan({ + evidence: [ + { status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null }, + { status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null }, + { status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2024-01-01", occurredTo: null }, + { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2021-01-01", occurredTo: null }, + ], + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + contrastPacket: { candidateSetVersion: "05:00-05:07", vargaDifferences: [], probes }, + }); + assert.equal(plan.dropped_probes.some((item) => item.reason === "yearless_ungrounded_contrast"), true); +}); + +test("decision and ask layer agree when only yearless ungrounded contrast remains", () => { + const probes = YEARLESS_LAYERS.map((row) => contrastFromYearless(row.layer, row.kind, row.domain)); + const evidence = [ + { id: "e-edu", status: "confirmed", domain: "education", datePrecision: "year" as const, occurredFrom: "2016-01-01", occurredTo: null, eventKind: "education_start" }, + { id: "e-career", status: "confirmed", domain: "career", datePrecision: "year" as const, occurredFrom: "2020-01-01", occurredTo: null, eventKind: "career_entry" }, + { id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "year" as const, occurredFrom: "2024-01-01", occurredTo: null, eventKind: "relationship_end" }, + ]; + const dossier: DecisionDossier = { + evidence, + conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, + latestResult: { + resultId: "55555555-5555-4555-8555-555555555555", + candidates: [ + { candidateId: "05:00", time: "05:00", rank: 1, relativeSupport: 18 }, + { candidateId: "05:07", time: "05:07", rank: 2, relativeSupport: 16 }, + ], + representativeTime: "05:00", + decisionReceipt: { + contrast_packet: { candidateSetVersion: "05:00-05:07", vargaDifferences: [], probes }, + }, + }, + case: { acceptedTime: null }, + }; + const plan = buildMethodFollowupPlan({ + evidence, + sessionOutcome: "discriminate_candidates", + candidatesSeparated: false, + contrastPacket: { candidateSetVersion: "05:00-05:07", vargaDifferences: [], probes }, + }); + const decision = decideFromDossier(dossier); + assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false); + assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); + assert.equal(decision.probe, null); +}); + +test("yearless cards cannot keep period-presupposing option copy; oos_blind without a year has no frame", () => { + const signed = completeAndCheck(); + assert.equal(signed, true); + assert.equal(shouldAttachChoiceFrame({ + intent: "out_of_sample_check", + ask_theme: "holdout", + source: "oos_blind", + }), false); + assert.equal(shouldAttachChoiceFrame({ + intent: "out_of_sample_check", + ask_theme: "holdout", + source: "oos_blind", + probe_year: 2023, + year_label: "2023 年前后", + }), true); + const plan = buildMethodFollowupPlan({ + evidence: [ + { status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null }, + { status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null }, + { status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2024-01-01", occurredTo: null }, + { status: "confirmed", domain: "family", datePrecision: "year", occurredFrom: "2021-01-01", occurredTo: null }, + { status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" }, + ], + sessionOutcome: "validate_holdout", + oosBlindPrompts: [{ + domain: "family", + user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?", + used_for_scoring: false, + }], + candidatesSeparated: true, + }); + assert.ok(plan.next_followup); + assert.equal(plan.next_followup!.choice_frame, null); + assert.equal(plan.next_followup!.source, "oos_blind"); +}); + +function completeAndCheck(): boolean { + const renderable = isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:07"], + expectedOutcomeCount: 2, + choiceKind: "varga_style", + year: 0, + styleOptions: [ + { sign: "天秤座", label: d9StyleLabel("天秤座"), answer_class: "yes" }, + { sign: "天蝎座", label: d9StyleLabel("天蝎座"), answer_class: "weak_yes" }, + ], + }); + assert.equal(renderable.ok, true); + const leaked = isRenderableProbe({ + informationGain: 1.2, + candidateIds: ["05:00", "05:07"], + expectedOutcomeCount: 2, + choiceKind: "existence", + year: 0, + }); + assert.equal(leaked.ok, false); + assert.equal(leaked.ok ? null : leaked.reason, "yearless_ungrounded_contrast"); + return renderable.ok; +} diff --git a/scripts/rectification/probe_question_contract.py b/scripts/rectification/probe_question_contract.py index 1377a9cf..5fdb76bc 100644 --- a/scripts/rectification/probe_question_contract.py +++ b/scripts/rectification/probe_question_contract.py @@ -34,6 +34,9 @@ LABEL_MAX = 80 _FORBIDDEN = FORBIDDEN_COPY_TOKENS _CLOCK = re.compile(r"(?:[01]?\d|2[0-3]):[0-5]\d") _ANSWER_CLASS_SUFFIX = re.compile(r"·(?:yes|weak_yes|no|unsure)$") +_PLACEHOLDER_PERIOD = re.compile(r"^(当前这几个候选|那段时间)$") +_CONCRETE_YEAR = re.compile(r"(?:19|20)\d{2}") +YEARLESS_PERIOD_COPY_MARKERS = ("时间吻合", "这段") _CONTRACT_PATH = Path(__file__).resolve().parents[2] / "contracts" / "probe-question-v1.json" @@ -217,4 +220,43 @@ def is_renderable_probe(probe: dict[str, Any]) -> ProbeRenderOk | ProbeRenderErr completed = complete_style_options(probe.get("choice_kind"), probe.get("style_options")) if not completed.get("ok"): return {"ok": False, "reason": str(completed.get("reason") or "not_renderable")} + kind = probe_question_kind(probe.get("choice_kind")) + options = completed.get("options") or [] + concrete = False if kind == "varga_style" else _probe_has_concrete_period(probe) + if not concrete and not _can_render_yearless_choice(kind, options): + return {"ok": False, "reason": "yearless_ungrounded_contrast"} return {"ok": True} + + +def _probe_has_concrete_period(probe: dict[str, Any]) -> bool: + year = probe.get("year") or 0 + if not isinstance(year, (int, float)) or year <= 0: + return False + label = str(probe.get("year_label") or probe.get("yearLabel") or "").strip() + if not label: + return True + if _PLACEHOLDER_PERIOD.match(label): + return False + return bool(_CONCRETE_YEAR.search(label)) + + +def _can_render_yearless_choice(kind: str, options: Sequence[dict[str, str]]) -> bool: + if kind != "varga_style": + return False + by_class = {item.get("answer_class"): item for item in options} + yes = by_class.get("yes") or {} + weak_yes = by_class.get("weak_yes") or {} + none = by_class.get("no") or {} + if not str(yes.get("sign") or "").strip() or not str(weak_yes.get("sign") or "").strip(): + return False + if str(none.get("sign") or "").strip(): + signed = True + else: + signed = none.get("label") == VARGA_NONE_STYLE_OPTION["label"] + if not signed: + return False + return not any( + marker in str(item.get("label") or "") + for item in options + for marker in YEARLESS_PERIOD_COPY_MARKERS + ) diff --git a/tests/test_probe_question_contract.py b/tests/test_probe_question_contract.py index fc7a5485..cabbcaa3 100644 --- a/tests/test_probe_question_contract.py +++ b/tests/test_probe_question_contract.py @@ -59,6 +59,60 @@ class ProbeQuestionContractTests(unittest.TestCase): self.assertFalse(result["ok"]) self.assertEqual(result.get("reason"), "forbidden_copy") + def test_yearless_ungrounded_contrast_is_rejected(self) -> None: + existence = is_renderable_probe({ + "information_gain": 1.2, + "candidate_ids": ["05:00", "05:04"], + "expected_outcomes": [{}, {}], + "choice_kind": "existence", + "year": 0, + }) + self.assertFalse(existence["ok"]) + self.assertEqual(existence.get("reason"), "yearless_ungrounded_contrast") + quality = is_renderable_probe({ + "information_gain": 1.2, + "candidate_ids": ["05:00", "05:04"], + "expected_outcomes": [{}, {}], + "choice_kind": "event_quality", + "year": 0, + }) + self.assertFalse(quality["ok"]) + self.assertEqual(quality.get("reason"), "yearless_ungrounded_contrast") + signed = is_renderable_probe({ + "information_gain": 1.2, + "candidate_ids": ["05:00", "05:04"], + "expected_outcomes": [{}, {}], + "choice_kind": "varga_style", + "year": 0, + "style_options": [ + {"label": "做事以照顾人为主,在意团队里的感受", "answer_class": "yes", "sign": "巨蟹座"}, + {"label": "习惯带头,也不排斥站到台前", "answer_class": "weak_yes", "sign": "狮子座"}, + ], + }) + self.assertTrue(signed["ok"]) + unsigned = is_renderable_probe({ + "information_gain": 1.2, + "candidate_ids": ["05:00", "05:04"], + "expected_outcomes": [{}, {}], + "choice_kind": "varga_style", + "year": 0, + "style_options": [ + {"label": "做事以照顾人为主,在意团队里的感受", "answer_class": "yes"}, + {"label": "习惯带头,也不排斥站到台前", "answer_class": "weak_yes"}, + ], + }) + self.assertFalse(unsigned["ok"]) + self.assertEqual(unsigned.get("reason"), "yearless_ungrounded_contrast") + dated = is_renderable_probe({ + "information_gain": 1.2, + "candidate_ids": ["05:00", "05:04"], + "expected_outcomes": [{}, {}], + "choice_kind": "existence", + "year": 2016, + "year_label": "2016 年前后", + }) + self.assertTrue(dated["ok"]) + def test_duplicate_labels_uniquify_with_index(self) -> None: completed = complete_style_options("varga_style", [ {"label": "相处主动热情", "answer_class": "yes"},