Files
Jyotisha/frontend/tests/rectification-occupation-coverage-exit.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

869 lines
34 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 { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { buildMethodFollowupPlan, holdoutFollowupFor } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
canShowRectificationSelectionCards,
parseRectificationCandidateResult,
workingRectificationHouseTable,
} from "../src/lib/rectification-candidate-result.ts";
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint, parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts";
import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { interviewQuestionBlocksAdoptOffer } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import {
CASE_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const TIED = [
{ time: "04:47", score: 12 },
{ time: "04:51", score: 12 },
{ time: "04:53", score: 11 },
{ time: "04:59", score: 11 },
{ time: "05:00", score: 11 },
{ time: "05:07", score: 11 },
{ time: "05:12", score: 11 },
{ time: "05:14", score: 8 },
{ time: "05:15", score: 7 },
];
const NARROW_LEAD = [
{ time: "04:47", score: 34 },
{ time: "04:51", score: 33 },
{ time: "04:53", score: 33 },
];
function dated(
id: string,
domain: string,
eventKind: string,
occurredFrom: string,
extra: {
occurredTo?: string | null;
datePrecision?: "year" | "month" | "day" | "range" | "unknown";
status?: "confirmed" | "draft";
} = {},
) {
return {
id,
status: extra.status ?? ("confirmed" as const),
domain,
datePrecision: extra.datePrecision ?? ("month" as const),
occurredFrom,
occurredTo: extra.occurredTo ?? null,
eventKind,
};
}
/** 19-row ledger from the stalled case: 15 scoreable, 4 domains; occupation never recorded. */
const CASE_EVIDENCE = [
dated("e-edu-start", "education", "education_start", "2016-09-01"),
dated("e-edu-complete", "education", "education_completion", "2020-06-01"),
dated("e-edu-interrupt", "education", "education_interruption", "2021-01-01", {
occurredTo: "2023-07-01",
datePrecision: "range",
}),
dated("e-edu-change", "education", "education_change", "2023-01-01", { datePrecision: "year" }),
dated("e-career-entry-2020", "career", "career_entry", "2020-04-01"),
dated("e-career-exit-2020", "career", "career_exit", "2020-10-01"),
dated("e-career-entry-2024", "career", "career_entry", "2024-04-07", { datePrecision: "day" }),
dated("e-career-exit-2026", "career", "career_exit", "2026-08-11", { datePrecision: "day" }),
dated("e-career-change-2022", "career", "career_change", "2022-12-01"),
dated("e-career-change-2024", "career", "career_change", "2024-01-01", { datePrecision: "year" }),
dated("e-career-range", "career", "career_change", "2020-04-01", {
occurredTo: "2020-10-01",
datePrecision: "range",
}),
dated("e-career-draft-a", "career", "career_change", "", {
datePrecision: "unknown",
status: "draft",
occurredTo: null,
}),
dated("e-career-draft-b", "career", "career_entry", "", {
datePrecision: "unknown",
status: "draft",
occurredTo: null,
}),
dated("e-rel-2024-05", "relationship", "relationship_change", "2024-05-01"),
dated("e-rel-start", "relationship", "relationship_start", "2024-06-01"),
dated("e-rel-end", "relationship", "relationship_end", "2024-08-08", { datePrecision: "day" }),
dated("e-reloc", "relocation", "home_change", "2022-10-01"),
dated("e-family", "family", "family_event", "2016-05-01"),
].map((item) => ({
...item,
occurredFrom: item.occurredFrom || null,
}));
const OCCUPATION_FOCUS = {
intent: "collect_method_evidence" as const,
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: "collect:occupation:collect_method_evidence",
};
function yearlessProbe(layer: string, gain: number, domain: string) {
return {
probeId: `contrast:varga.${layer}.04:47/05:00`,
candidateSetVersion: "04:47-05:15",
question: `当前几个候选在 ${layer} 上还分得开。`,
expectedOutcomes: [
{ outcomeId: "yes" as const, supportsCandidateIds: ["04:47"], conflictsCandidateIds: ["05:00"] },
{ outcomeId: "no" as const, supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["04:47"] },
],
candidateSplitHash: `varga.${layer}.04:47/05:00`,
informationGain: gain,
sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }],
domain,
year: null as number | null,
semanticKey: `varga.${layer}.04:47/05:00`,
choiceKind: "existence" as const,
styleOptions: STYLE_OPTIONS.map((item) => ({
label: item.label,
answerClass: item.answer_class,
})),
};
}
const YEARLESS_PACKET = {
candidateSetVersion: "04:47-05:15",
vargaDifferences: [] as const,
probes: [
yearlessProbe("d24", 2.5, "education"),
yearlessProbe("d7", 1.22, "family"),
yearlessProbe("d4", 0.99, "relocation"),
yearlessProbe("d5", 0.5, "education"),
],
};
function occupationPlan(
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
) {
return buildMethodFollowupPlan({
evidence: CASE_EVIDENCE,
contrastPacket: YEARLESS_PACKET,
candidatesSeparated: false,
...extra,
});
}
function twelveHouses(sign: string, occupant?: string) {
return Array.from({ length: 12 }, (_, index) => ({
house: index + 1,
sign,
occupants: index === 0 && occupant ? [occupant] : [],
}));
}
const CANDIDATE_IDS = [
"88888888-8888-4888-8888-888888888881",
"88888888-8888-4888-8888-888888888882",
] as const;
test("skill version is 10.0.23 after the collect-semantics bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.23");
});
test("nineteen-row ledger opens the training gate with four scoreable domains", () => {
const gate = trainingScoreableGate(CASE_EVIDENCE);
assert.equal(gate.open, true);
assert.ok(gate.trainingCount >= 3);
assert.ok(gate.trainingDomainCount >= 2);
assert.ok(CASE_EVIDENCE.length >= 15);
});
test("career answers under an occupation collect focus do not cover occupation before ledger norm", () => {
const plan = occupationPlan();
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
assert.equal(
CASE_EVIDENCE.some((item) => item.domain === "occupation" && item.eventKind === "occupation_note"),
false,
);
});
test("answering occupation collect remaps a career-domain job description onto occupation_note", async () => {
const { applyOccupationCollectLedgerNorm } = await import(
"../src/lib/rectification-agentic/v9/evidence-model.ts"
);
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
domain: "career",
eventKind: "career_entry" as const,
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
summary: "长期做有机合成",
quote: "有机合成",
subject: "self" as const,
}]);
assert.equal(remapped[0]?.domain, "occupation");
assert.equal(remapped[0]?.eventKind, "occupation_note");
const plan = occupationPlan({
evidence: [
...CASE_EVIDENCE,
{
status: "confirmed" as const,
domain: remapped[0]!.domain,
datePrecision: remapped[0]!.datePrecision,
occurredFrom: remapped[0]!.occurredFrom,
occurredTo: remapped[0]!.occurredTo,
eventKind: remapped[0]!.eventKind,
summary: remapped[0]!.summary,
},
],
contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] },
});
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered");
assert.notEqual(plan.next_followup?.method_id, "occupation");
assert.notEqual(plan.next_followup?.ask_theme, "occupation");
});
test("occupation coverage fallback uses a closed occupation collect focus plus career evidence", () => {
const uncovered = occupationPlan({
contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] },
});
assert.equal(uncovered.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
const covered = occupationPlan({
contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] },
closedCollectFocuses: [{
questionId: OCCUPATION_FOCUS.questionId,
targetDomain: "occupation",
intent: "collect_method_evidence",
status: "resolved",
}],
});
assert.equal(covered.methods.find((item) => item.method_id === "occupation")?.status, "covered");
assert.notEqual(covered.next_followup?.method_id, "occupation");
});
test("career evidence alone still does not cover occupation", () => {
const plan = occupationPlan({
contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] },
});
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
// 原值: d2_finance leftover dated collect
// 新值: 训练门开后不再按领域轮转采集
// 原因: 收集池只在训练门关时追问(BUG-648)
assert.notEqual(plan.next_followup?.method_id, "occupation");
assert.notEqual(plan.next_followup?.intent === "collect_method_evidence" && plan.next_followup?.domain === "finance", true);
});
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);
// 原值: d2_finance leftover collect
// 新值: 训练门开后 leftover 采集不再问财务
// 原因: 同 BUG-648
assert.notEqual(plan.next_followup?.method_id, "d2_finance");
});
test("training gate open does not ask 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.notEqual(plan.next_followup?.intent, "distinguish_candidates");
assert.notEqual(plan.next_followup?.semantic_key, d10.semanticKey);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
// 原值: 训练门开后仍问签名 D10 性格卡
// 新值: D10 记 yearless_deferred
// 原因: BUG-651 性格题不得挡在结果前面
assert.equal(
plan.dropped_probes.some((item) => (
item.semantic_key === d10.semanticKey && item.reason === "yearless_deferred"
)),
true,
JSON.stringify(plan.dropped_probes),
);
});
test("training gate closed still withholds yearless varga cards", () => {
const short = [
dated("e-edu", "education", "education_start", "2016-09-01"),
dated("e-career", "career", "career_entry", "2020-04-01"),
];
assert.equal(trainingScoreableGate(short).open, false);
const plan = buildMethodFollowupPlan({
evidence: short,
contrastPacket: YEARLESS_PACKET,
candidatesSeparated: false,
});
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /^varga\.d(24|7|4|5)\./);
assert.notEqual(plan.next_followup?.intent, "distinguish_candidates");
});
test("training complete with no renderable distinguish card still allows adopt when the engine does", () => {
const blocked = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: NARROW_LEAD,
discriminatorProbe: null,
});
// 原断言 canAdopt=false、sessionOutcome=provisional_range
// → 新断言 canAdopt=true、sessionOutcome=adopt_representative。
// coverage(含 occupation)只做问询路由,不再挡采用;确认门仍 fail-closed。
assert.equal(blocked.canOfferRange, true);
assert.equal(blocked.canAdopt, true);
assert.equal(blocked.canConfirmExactMinute, false);
assert.equal(blocked.nextAction, "offer_provisional_range");
assert.equal(blocked.sessionOutcome, "adopt_representative");
const stillClosed = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: false,
candidateScores: NARROW_LEAD,
discriminatorProbe: null,
});
assert.equal(stillClosed.canOfferRange, false);
assert.equal(stillClosed.canAdopt, false);
assert.equal(stillClosed.nextAction, "ask_fact_collection");
});
test("decideFromDossier offers a range with adopt when training is complete and occupation is still uncovered", () => {
const decision = decideFromDossier({
evidence: CASE_EVIDENCE,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
candidates: NARROW_LEAD.map((item, index) => ({
candidateId: `88888888-8888-4888-8888-88888888888${index}`,
time: item.time,
rank: index + 1,
relativeSupport: item.score,
})),
representativeTime: "04:47",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(CASE_EVIDENCE as never),
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
},
},
case: { acceptedTime: null },
});
// 旧:财务/健康/职业口述未问完保持采集。
// 新:训练门开后职业未覆盖不再挡交付(BUG-648)
assert.equal(decision.canOfferRange, true);
assert.notEqual(decision.sessionOutcome, "collect_evidence");
assert.notEqual(decision.nextAction, "ask_fact_collection");
assert.equal(decision.canConfirmExactMinute, false);
});
test("agent route narrates a numeric range exit and persists a collect when no renderable distinguish card remains", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
assert.match(route, /nonConvergingRangeNarration/);
assert.doesNotMatch(route, /可以先按当前区间看盘,也可以再补一件记得时间的经历/);
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(fastPath, /persistNextInterviewIfIdle|isNonConvergingRangeOffer|canOfferRange/);
assert.doesNotMatch(fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/);
});
test("public house_table and natal_recast follow the representative minute", () => {
const matched = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:47", relativeSupport: 12, tiedMinuteCount: 1 },
{ candidateId: CANDIDATE_IDS[1], rank: 2, time: "05:00", relativeSupport: 11, tiedMinuteCount: 1 },
],
overallConfidence: "low",
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:47",
selectedTime: null,
decisionReceipt: {
house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") },
house_tables_by_time: {
"04:47": { time: "04:47", lagna: "金牛座", houses: twelveHouses("金牛座", "太阳") },
"05:00": { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") },
},
natal_recast: {
time: "05:00",
lagna: "双子座",
user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。",
unique_minute_claim: false,
confirmation_allowed: false,
},
},
});
assert.equal(matched?.representativeTime, "04:47");
assert.equal(matched?.houseTable?.time, "04:47");
assert.equal(matched?.houseTable?.lagna, "金牛座");
assert.equal(matched?.natalRecast?.time, "04:47");
assert.equal(workingRectificationHouseTable(matched!)?.time, "04:47");
});
test("a mismatched receipt house_table is not emitted with another representative minute", () => {
const mismatched = parseRectificationCandidateResult({
resultId: "11111111-1111-4111-8111-111111111111",
candidates: [
{ candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:47", relativeSupport: 12, tiedMinuteCount: 1 },
{ candidateId: CANDIDATE_IDS[1], rank: 2, time: "05:00", relativeSupport: 11, tiedMinuteCount: 1 },
],
overallConfidence: "low",
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:47",
selectedTime: null,
decisionReceipt: {
house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") },
natal_recast: {
time: "05:00",
lagna: "双子座",
user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。",
unique_minute_claim: false,
confirmation_allowed: false,
},
},
});
assert.equal(mismatched?.representativeTime, "04:47");
assert.notEqual(mismatched?.houseTable?.time, "05:00");
assert.equal(mismatched?.houseTable, null);
assert.notEqual(mismatched?.natalRecast?.time, "05:00");
});
test("tool projection does not pair a 05:00 house table with a 04:47 representative time", () => {
const decision = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: TIED,
discriminatorProbe: null,
});
const projection = latestResultToolProjection({
resultId: "55555555-5555-4555-8555-555555555555",
candidates: TIED.map((item, index) => ({
candidateId: `88888888-8888-4888-8888-88888888888${index}`,
time: item.time,
rank: index + 1,
relativeSupport: item.score,
tiedMinuteCount: 1,
})),
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:47",
selectedTime: null,
selectionKind: null,
algorithmVersion: "test",
decisionReceipt: {
house_table: { time: "05:00", lagna: "双子座", houses: twelveHouses("双子座", "水星") },
natal_recast: {
time: "05:00",
lagna: "双子座",
user_meaning: "本命宫位已按 05:00 重算(上升 双子座)。",
unique_minute_claim: false,
confirmation_allowed: false,
},
},
}, decision);
assert.notEqual(projection.house_table && (projection.house_table as { time?: string }).time, "05:00");
if (projection.representative_time === "04:47" && projection.house_table) {
assert.equal((projection.house_table as { time?: string }).time, "04:47");
}
});
test("collect focus is still not a renderable choice card", () => {
assert.equal(isRenderableChoiceOpenQuestion({
question_id: OCCUPATION_FOCUS.questionId,
prompt: "你平时主要做什么工作?",
status: "already_open",
kind: "collect_spoken",
intent: "collect_method_evidence",
domain: "occupation",
focus_id: "11111111-1111-4111-8111-111111111111",
probe_id: null,
}), false);
});
const ACCIDENT_EVIDENCE = [
dated("e-edu-1", "education", "education_start", "2016-09-01"),
dated("e-edu-2", "education", "education_completion", "2020-06-01"),
dated("e-rel-1", "relationship", "relationship_start", "2018-05-01"),
dated("e-rel-2", "relationship", "relationship_end", "2021-08-01"),
dated("e-fin-1", "finance", "finance_change", "2024-03-01"),
dated("e-career-1", "career", "career_entry", "2020-04-01"),
dated("e-career-2", "career", "career_change", "2023-07-01"),
dated("e-reloc-1", "relocation", "home_change", "2022-10-01"),
dated("e-health-1", "health", "self_health_event", "2021-11-01"),
{
id: "e-occ-1",
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
];
const FAMILY_DECLINED_TOPIC = [{
target_domain: "family",
status: "declined",
questionId: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
}];
const CLOSED_FINANCE_FOCUS = [{
questionId: "collect:finance:collect_method_evidence",
targetDomain: "finance",
intent: "collect_method_evidence",
status: "resolved",
}];
const ACCIDENT_YEARLESS = {
candidateSetVersion: "04:51-04:53",
vargaDifferences: [] as const,
probes: [
yearlessProbe("d11", 2.5, "finance"),
yearlessProbe("d2", 2.2, "finance"),
yearlessProbe("d24", 1.2, "education"),
yearlessProbe("d5", 0.5, "education"),
yearlessProbe("d4", 0.99, "relocation"),
yearlessProbe("d7", 1.22, "family"),
yearlessProbe("d12", 1.1, "family"),
],
};
const ANSWERED_SIX = [
{ semantic_key: "varga.d9.天秤座|天蝎座", answer_class: "yes" },
{ semantic_key: "varga.d10.巨蟹座|狮子座", answer_class: "yes" },
{ semantic_key: "career.2020", answer_class: "yes" },
{ semantic_key: "career.2023", answer_class: "no" },
{ semantic_key: "relocation.2022", answer_class: "no" },
{ semantic_key: "finance.2024", answer_class: "no" },
];
function coveredDomainPlan(
extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {},
) {
return buildMethodFollowupPlan({
evidence: ACCIDENT_EVIDENCE,
contrastPacket: ACCIDENT_YEARLESS,
declinedTopics: FAMILY_DECLINED_TOPIC,
closedCollectFocuses: CLOSED_FINANCE_FOCUS,
answeredProbes: ANSWERED_SIX,
sessionOutcome: "adopt_representative",
...extra,
});
}
test("yearless-to-collect does not re-ask a covered domain after occupation is done", () => {
const plan = coveredDomainPlan();
// 原值: next_followup.domain = financeL2224 取 yearless[0]=D11
// 新值: null
// 原因: 财务已有带年月事件且采集焦点已关闭;守卫跳过已覆盖领域后交付
assert.equal(plan.next_followup, null);
});
test("yearless-to-collect no longer asks an uncovered relocation domain after the training gate", () => {
const plan = coveredDomainPlan({
evidence: ACCIDENT_EVIDENCE.filter((item) => item.domain !== "relocation"),
closedCollectFocuses: CLOSED_FINANCE_FOCUS,
});
// 原值: relocation 采集
// 新值: 训练门开后不再把 yearless 对照改写成领域采集
// 原因: BUG-648
assert.notEqual(plan.next_followup?.intent, "collect_method_evidence");
});
function rpcAccidentEvidence(rows: readonly typeof ACCIDENT_EVIDENCE[number][]) {
return rows.map((item, index) => ({
id: item.id ?? `e-${index}`,
source_turn_id: "33333333-3333-4333-8333-333333333333",
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: "",
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-08T00:00:00.000Z",
}));
}
function accidentEvidenceFingerprint() {
return evidenceLedgerFingerprint(rpcAccidentEvidence(ACCIDENT_EVIDENCE).map((item) => ({
id: item.id,
eventKind: item.event_kind,
domain: item.domain,
occurredFrom: item.occurred_from,
occurredTo: item.occurred_to,
datePrecision: item.date_precision,
summary: item.summary,
status: item.status,
})) as never);
}
function yearlessInferenceProbe(layer: string, domain: string, gain: number) {
return {
id: `contrast:varga.${layer}.unsigned`,
semantic_key: `varga.${layer}.unsigned`,
candidate_split_hash: `varga.${layer}.04:51/04:53`,
domain,
year: 0,
question: `当前几个候选在 ${layer} 上还分得开。`,
candidate_ids: ["04:51", "04:53"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:51"], conflicts: ["04:53"] },
{ answer_class: "no" as const, supports: ["04:53"], conflicts: ["04:51"] },
],
information_gain: gain,
source: "varga_contrast",
choice_kind: "existence" as const,
style_options: STYLE_OPTIONS,
};
}
function accidentIdleState() {
const times = ["04:51", "04:52", "04:53"] as const;
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:51", "04:53", times),
revision: 6,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:51",
range_end: "04:53",
candidates: times.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: 30 - index,
posterior_score: 30 - index,
probability: index === 0 ? 0.5 : 0.25,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
})),
events: [
{ id: "e-edu-1", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: "e-edu-2", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-1", domain: "relationship", year: 2018, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-2", domain: "relationship", year: 2021, precision: "month" as const, usage: "holdout" as const },
{ id: "e-fin-1", domain: "finance", year: 2024, precision: "month" as const, usage: "holdout" as const },
{ id: "e-career-1", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-career-2", domain: "career", year: 2023, precision: "month" as const, usage: "training" as const },
{ id: "e-reloc-1", domain: "relocation", year: 2022, precision: "month" as const, usage: "training" as const },
{ id: "e-health-1", domain: "health", year: 2021, precision: "month" as const, usage: "training" as const },
],
probes: [
yearlessInferenceProbe("d11", "finance", 2.5),
yearlessInferenceProbe("d24", "education", 1.2),
yearlessInferenceProbe("d4", "relocation", 0.99),
yearlessInferenceProbe("d7", "family", 1.22),
],
answered_probes: ANSWERED_SIX.map((item) => ({
probe_id: `probe:${item.semantic_key}`,
semantic_key: item.semantic_key,
candidate_split_hash: item.semantic_key,
answer_class: item.answer_class as "yes" | "no",
classified_from: "choice" as const,
})),
rounds: ANSWERED_SIX.map((item, index) => ({
round: index + 1,
phase: "discrimination" as const,
probe_id: `probe:${item.semantic_key}`,
scores_before: { "04:51": 30, "04:52": 29, "04:53": 28 },
scores_after: { "04:51": 32, "04:52": 29, "04:53": 28 },
entropy_before: 1.1,
entropy_after: 1.0,
eliminated_ids: [] as string[],
winner_id: null,
kind: "informative" as const,
})),
last_inference_round: null,
entropy: 1.0,
representative_time: "04:51",
credible_range: ["04:51", "04:53"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded, "covered-domain accident inference must parse");
return loaded;
}
function accidentIdleDossier() {
const fingerprint = accidentEvidenceFingerprint();
const state = accidentIdleState();
return dossierFixture({
evidence: rpcAccidentEvidence(ACCIDENT_EVIDENCE),
evidenceCount: ACCIDENT_EVIDENCE.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: fingerprint,
candidates: [
{ candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 40, tied_minute_count: 1 },
{ candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "04:52", relative_support: 35, tied_minute_count: 1 },
{ candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "04:53", relative_support: 25, tied_minute_count: 1 },
],
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: FAMILY_DECLINED_TOPIC,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-08T00:00:00.000Z",
},
});
}
test("idle persist on the covered-domain accident delivers adopt and writes no focus", async () => {
const rpc = accidentIdleDossier();
const dossier = parseV9CaseDossier(rpc);
assert.ok(dossier, "covered-domain accident RPC dossier must parse");
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.canAdopt, true);
assert.equal(decision.stopReason, "probe_pool_exhausted");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpc,
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
throw new Error(`must not persist ${String(args.p_question_id)}`);
},
append_agentic_rectification_turn: () => ({ turn_id: "33333333-3333-4333-8333-333333333333", idempotent: false }),
finalize_agentic_rectification_turn: () => ({
turn_id: "33333333-3333-4333-8333-333333333333",
status: "completed",
idempotent: false,
}),
get_agentic_rectification_turn_receipt: () => null,
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(idle.terminalNote, true);
assert.ok(idle.hostNarration);
assert.equal(projectCurrentQuestion(null), null);
assert.equal(interviewQuestionBlocksAdoptOffer(null, false), false);
});
test("agent-run still writes the exhaustion gate when idle persist returns terminalNote", () => {
const agent = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(agent, /interviewIdle\.terminalNote && interviewIdle\.hostNarration/);
assert.match(agent, /persistExhaustionGateTurn/);
assert.match(chat, /canOfferCards = canShowRectificationSelectionCards/);
const offer = canShowRectificationSelectionCards(
parseRectificationCandidateResult({
resultId: "55555555-5555-4555-8555-555555555555",
candidates: [
{ candidateId: CANDIDATE_IDS[0], rank: 1, time: "04:51", relativeSupport: 40, tiedMinuteCount: 1 },
{ candidateId: CANDIDATE_IDS[1], rank: 2, time: "04:53", relativeSupport: 35, tiedMinuteCount: 1 },
],
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
representativeTime: "04:51",
selectedTime: null,
sessionOutcome: "adopt_representative",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
},
}),
);
assert.equal(offer, true);
});
test("holdout occupied treats health and health_pressure as the same line", () => {
const evidence = [
...ACCIDENT_EVIDENCE.filter((item) => item.domain !== "health"),
dated("e-health-ledger", "health", "self_health_event", "2021-11-01"),
];
const fields = holdoutFollowupFor({
evidence,
oosBlindPrompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
}, new Set());
// 原值: health_pressure
// 新值: null
// 原因: occupied 不认 health / health_pressure 同义时会再问一遍健康
assert.equal(fields, null);
});