Files
Jyotisha/frontend/tests/rectification-discriminator-followup-consistency.test.ts
T
Jesse_ChenandCursor f870d3d757 fix(rectification): keep dated precision cards after empty dated discriminator pool (BUG-651)
Staging gate 2558 failed because leftover tests still expected D9/D10 or leftover collect after the pool emptied. Discriminate sessions still deliver; dated precision cards still ask.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:22:55 +08:00

596 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
inspectDiscriminatorProbes,
withCompletedContrastOptions,
type CandidateDiscriminatorProbe,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
contrastPacketFromDossier,
decideFromDossier,
followupAsksRenderableDiscriminator,
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
const CAREER_2020_04: ConflictProbe = {
id: "probe:career.2020.04.dasha_boundary",
semantic_key: "career.2020.04.dasha_boundary",
candidate_split_hash: "career.2020.04",
domain: "career",
year: 2020,
question: "2020 年 4 月前后有没有入职或换工作?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.9,
source: "dasha_boundary",
choice_kind: "existence",
};
const REL_2024_05: ConflictProbe = {
id: "probe:relationship.2024.05.dasha_boundary",
semantic_key: "relationship.2024.05.dasha_boundary",
candidate_split_hash: "relationship.2024.05",
domain: "relationship",
year: 2024,
question: "2024 年 5 月前后有没有开始一段认真关系?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.85,
source: "dasha_boundary",
choice_kind: "existence",
};
const D24: ConflictProbe = {
id: "contrast:varga.d24.05:00/05:10",
semantic_key: "varga.d24.05:00/05:10",
candidate_split_hash: "varga.d24.05:00/05:10",
domain: "education",
year: 0,
question: "有没有学业或考试发挥明显失常、压力特别大的时候?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 2.5,
source: "varga_contrast",
choice_kind: "event_quality",
};
const D12: ConflictProbe = {
id: "contrast:varga.d12.05:00/05:10",
semantic_key: "varga.d12.05:00/05:10",
candidate_split_hash: "varga.d12.05:00/05:10",
domain: "family",
year: 0,
question: "家里有没有结婚、添丁或住院这类事?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.8,
source: "varga_contrast",
choice_kind: "existence",
};
const D9_STYLE_NO_OPTIONS: ConflictProbe = {
id: "contrast:varga.d9.巨蟹座/狮子座",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.4,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10_STYLE_NO_OPTIONS: ConflictProbe = {
id: "contrast:varga.d10.天秤座/天蝎座",
semantic_key: "varga.d10.天秤座/天蝎座",
candidate_split_hash: "varga.d10.天秤座/天蝎座",
domain: "career",
year: 0,
question: "平时做事更接近下面哪一种职责风格?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.2,
source: "varga_contrast",
choice_kind: "varga_style",
};
const FAMILY_2021_COLLECT = {
year: 2021,
year_label: "2021 年前后",
domain: "family" as const,
event_family: "家人结婚、添丁或住院",
source: "age_band" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: false,
unique_minute_claim: false as const,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect" as const,
phase: "evidence_collection" as const,
information_gain: 0,
semantic_key: "family.2021",
candidate_split_hash: "family:2021",
candidate_ids: [] as const,
expected_outcomes: [] as const,
choice_kind: "existence" as const,
};
const EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-exit",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
] as const;
function contrastFromConflict(probe: ConflictProbe): CandidateDiscriminatorProbe {
return {
probeId: probe.id,
candidateSetVersion: "05:00-05:10",
question: probe.question,
expectedOutcomes: probe.expected_outcomes.map((row) => ({
outcomeId: row.answer_class,
supportsCandidateIds: row.supports,
conflictsCandidateIds: row.conflicts,
})),
candidateSplitHash: probe.candidate_split_hash,
informationGain: probe.information_gain,
sourceFeatures: [{ technique: probe.domain, calculationResultId: null }],
domain: probe.domain,
year: probe.year > 0 ? probe.year : null,
semanticKey: probe.semantic_key,
choiceKind: probe.choice_kind,
};
}
function deadlockDossier(): { dossier: DecisionDossier; state: ReturnType<typeof buildInferenceState> } {
const state = buildInferenceState({
range_start: "05:00",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 18 },
{ id: "05:10", time: "05:10", relative_support: 16 },
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
],
probes: [CAREER_2020_04, REL_2024_05, D24, D12, D9_STYLE_NO_OPTIONS, D10_STYLE_NO_OPTIONS],
answered_probes: [
{
probe_id: CAREER_2020_04.id,
semantic_key: CAREER_2020_04.semantic_key,
candidate_split_hash: CAREER_2020_04.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
{
probe_id: REL_2024_05.id,
semantic_key: REL_2024_05.semantic_key,
candidate_split_hash: REL_2024_05.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
],
});
const dossier: DecisionDossier = {
evidence: EVIDENCE,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
candidates: state.candidates.map((item) => ({
candidateId: item.id,
time: item.time,
rank: item.rank,
relativeSupport: Math.round(item.posterior_score),
})),
representativeTime: state.representative_time,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
decisionReceipt: {
inference_state: state,
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
},
case: { acceptedTime: null },
};
return { dossier, state };
}
test("deadlock case: dated pool empty delivers instead of a D9/D10 style card", () => {
const { dossier } = deadlockDossier();
const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(dossier));
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));
assert.equal(d9.ok, true);
assert.equal(d10.ok, true);
if (d9.ok) assert.equal(d9.probe.choiceKind, "varga_style");
if (d10.ok) assert.equal(d10.probe.choiceKind, "varga_style");
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.equal(
plan.dropped_probes.some((item) => item.semantic_key.startsWith("varga.d9.") || item.semantic_key.startsWith("varga.d10.")),
true,
JSON.stringify(plan.dropped_probes),
);
const decision = decideFromDossier(dossier);
// 原值: ask_candidate_discriminator,出 D9/D10 性格卡
// 新值: 带年月池空即交付,性格题 yearless_deferred
// 原因: BUG-651 性格题不得挡在结果前面
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range"
|| decision.nextAction === "ask_fact_collection",
decision.nextAction,
);
assert.notEqual(decision.sessionOutcome, "discriminate_candidates");
assert.equal(decision.canConfirmExactMinute, false);
});
test("varga_style probes missing style_options complete the same way in inspect and followup ranking", () => {
const probe = contrastFromConflict(D9_STYLE_NO_OPTIONS);
const inspected = inspectDiscriminatorProbes({
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [probe],
});
assert.ok(inspected.selected);
assert.equal(inspected.selected?.choiceKind, "varga_style");
assert.equal(inspected.dropped.length, 0);
const plan = buildMethodFollowupPlan({
evidence: EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
contrastPacket: {
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [probe],
},
});
assert.notEqual(plan.next_followup?.semantic_key, probe.semanticKey);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
// 原值: 补全 style_options 后仍把 D9 当下一道区分题
// 新值: D9 记 yearless_deferred,不再占用 discriminator
// 原因: BUG-651
assert.equal(
plan.dropped_probes.some((item) => (
item.semantic_key === probe.semanticKey && item.reason === "yearless_deferred"
)),
true,
JSON.stringify(plan.dropped_probes),
);
});
test("method coverage incomplete still asks a renderable D9 style card", () => {
const { dossier } = deadlockDossier();
const decision = decideFromDossier(dossier);
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered");
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
// 原值: 方法覆盖未齐仍问 D9 性格卡
// 新值: 训练门开、带年月池空即交付
// 原因: BUG-651 性格题不得挡在结果前面
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range"
|| decision.nextAction === "ask_fact_collection",
decision.nextAction,
);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.equal(decision.probe, null);
});
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",
semantic_key: "varga.d9.05:00/05:10",
candidate_split_hash: "varga.d9.05:00/05:10",
};
const d10Clock: ConflictProbe = {
...D10_STYLE_NO_OPTIONS,
id: "contrast:varga.d10.05:00/05:10",
semantic_key: "varga.d10.05:00/05:10",
candidate_split_hash: "varga.d10.05:00/05:10",
};
const { dossier } = deadlockDossier();
const state = buildInferenceState({
range_start: "05:00",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 18 },
{ id: "05:10", time: "05:10", relative_support: 16 },
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
],
probes: [CAREER_2020_04, REL_2024_05, D24, D12, d9Clock, d10Clock],
answered_probes: [
{
probe_id: CAREER_2020_04.id,
semantic_key: CAREER_2020_04.semantic_key,
candidate_split_hash: CAREER_2020_04.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
{
probe_id: REL_2024_05.id,
semantic_key: REL_2024_05.semantic_key,
candidate_split_hash: REL_2024_05.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
],
});
const clockDossier: DecisionDossier = {
...dossier,
latestResult: {
...dossier.latestResult!,
decisionReceipt: {
inference_state: state,
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
},
};
const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(clockDossier));
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");
const catalog = rectificationFollowupCatalog(clockDossier.latestResult, clockDossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: clockDossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false);
// 原值: 落到家人采集 + probe_year 2021
// 新值: 训练门开后不落领域轮转;年龄段年份不进题干
// 原因: 撤回 BUG-642BUG-648
assert.notEqual(plan.next_followup?.domain, "family");
assert.notEqual(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.notEqual(decision.nextAction, "ask_candidate_discriminator");
assert.equal(decision.probe, null);
assert.equal(decision.canConfirmExactMinute, false);
});
test("unrenderable contrast probes are recorded in dropped_probes instead of being dropped silently", () => {
const unrenderable = contrastFromConflict({
...D9_STYLE_NO_OPTIONS,
information_gain: 0,
semantic_key: "varga.d9.dropped",
candidate_split_hash: "varga.d9.dropped",
id: "contrast:varga.d9.dropped",
});
const inspected = inspectDiscriminatorProbes({
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [unrenderable],
});
assert.equal(inspected.selected, null);
assert.equal(inspected.dropped.some((item) => item.semantic_key === unrenderable.semanticKey), true);
const plan = buildMethodFollowupPlan({
evidence: EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
contrastPacket: {
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [unrenderable],
},
});
assert.equal(
plan.dropped_probes.some((item) => item.semantic_key === unrenderable.semanticKey),
true,
JSON.stringify(plan.dropped_probes),
);
});
test("agent message path no longer returns the discriminator dead-end copy", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.doesNotMatch(fastPath, /目前没有可继续区分/);
assert.match(route, /nonConvergingRangeNarration/);
assert.match(fastPath, /persistNextInterviewIfIdle/);
assert.match(fastPath, /!plan\.next_followup/);
assert.ok(fastPath.includes("ask_candidate_discriminator"));
assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/);
assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({"));
});
test("decision.probe matches the followup the plan will actually ask", () => {
const { dossier } = deadlockDossier();
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
const decision = decideFromDossier(dossier);
// 原值: 下一问是 D9/D10decision.probe 对得上
// 新值: 性格题 deferreddecision.probe 与 next 都不是性格卡
// 原因: BUG-651
assert.notEqual(decision.nextAction, "ask_candidate_discriminator");
assert.equal(decision.probe, null);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.notEqual(plan.next_followup?.semantic_key, D24.semantic_key);
});
test("birthDate gating matches the real followup plan on an under-age event probe", () => {
const { dossier: base } = deadlockDossier();
const childhood = {
year: 2015,
year_label: "2015 年前后",
domain: "career" as const,
event_family: "入职或职责变化",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2015 年前后有没有入职或职责变化?",
role: "distinguish" as const,
phase: "candidate_discriminator" as const,
information_gain: 3.2,
semantic_key: "career.2015.dasha_activation",
candidate_split_hash: "career.2015",
candidate_ids: ["05:00", "05:10"] as const,
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
],
style_options: [
{ label: "明确发生且时间吻合", answer_class: "yes" },
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
],
choice_kind: "existence" as const,
};
const dossier: DecisionDossier = {
...base,
latestResult: base.latestResult
? {
...base.latestResult,
decisionReceipt: {
...(base.latestResult.decisionReceipt ?? {}),
discriminating_event_probes: [childhood],
},
}
: null,
};
const birthDate = "2010-01-15";
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const planOpen = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
const planGated = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
birthDate,
candidatesSeparated: false,
});
assert.equal(planOpen.next_followup?.semantic_key, childhood.semantic_key);
assert.notEqual(planGated.next_followup?.semantic_key, childhood.semantic_key);
const decision = decideFromDossier(dossier, { birthDate });
assert.equal(
decision.nextAction === "ask_candidate_discriminator",
followupAsksRenderableDiscriminator(planGated.next_followup),
);
if (followupAsksRenderableDiscriminator(planGated.next_followup)) {
assert.equal(decision.probe?.semanticKey, planGated.next_followup?.semantic_key);
} else {
assert.equal(decision.nextAction, "ask_fact_collection");
}
});