Files
Jyotisha/frontend/tests/rectification-holdout-renderable.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

266 lines
9.2 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 test from "node:test";
import { applyHoldoutAnswer } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideFromDossier, rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildCaseInferenceState } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import type { InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
const COVERED_EVIDENCE = [
{
id: "e-edu",
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_milestone",
},
{
id: "e-career-a",
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2018-01-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-b",
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2020-01-01",
occurredTo: null,
eventKind: "career_change",
},
{
id: "e-rel",
status: "confirmed",
domain: "relationship",
datePrecision: "year",
occurredFrom: "2024-01-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
id: "e-fam",
status: "confirmed",
domain: "family",
datePrecision: "year",
occurredFrom: "2023-01-01",
occurredTo: null,
eventKind: "family_event",
},
{
id: "e-occ",
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
{
id: "e-fin",
status: "confirmed",
domain: "finance",
datePrecision: "year",
occurredFrom: "2017-01-01",
occurredTo: null,
eventKind: "income_change",
},
{
id: "e-reloc",
status: "confirmed",
domain: "relocation",
datePrecision: "year",
occurredFrom: "2019-01-01",
occurredTo: null,
eventKind: "home_change",
},
{
id: "e-health",
status: "confirmed",
domain: "health_pressure",
datePrecision: "year",
occurredFrom: "2021-01-01",
occurredTo: null,
eventKind: "self_health_event",
},
] as const;
function producedDossier(
evidence: readonly Readonly<{
id: string;
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
}>[],
options: { previous?: InferenceState | null } = {},
) {
const state = buildCaseInferenceState({
range: { start_time: "04:55", end_time: "05:02" },
candidates: [
{ candidateId: "05:02", time: "05:02", relativeSupport: 58 },
{ candidateId: "04:55", time: "04:55", relativeSupport: 42 },
],
evidence,
probes: [],
previous: options.previous,
});
const latest = {
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: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
};
return {
state,
dossier: {
evidence,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
latestResult: latest,
case: { acceptedTime: null },
},
};
}
function holdoutFollowup(dossier: ReturnType<typeof producedDossier>["dossier"]) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
return buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "validate_holdout",
...catalog,
candidatesSeparated: true,
});
}
test("sticky holdout with unknown precision stays review-only", () => {
const { state, dossier: dated } = producedDossier(COVERED_EVIDENCE);
const holdout = state.events.find((item) => item.usage === "holdout");
assert.ok(holdout);
assert.notEqual(holdout.year, null);
const datedDecision = decideFromDossier(dated);
// 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。
// 为什么:dated holdout 只挡确认门;覆盖完成即可代表性采用。
assert.equal(datedDecision.nextAction, "ready_to_adopt");
assert.equal(datedDecision.canAdopt, true);
assert.equal(datedDecision.canConfirmExactMinute, false);
const undated = COVERED_EVIDENCE.map((item) => (
item.id === holdout.id
? { ...item, datePrecision: "unknown", occurredFrom: null, occurredTo: null }
: item
));
const { state: sticky, dossier } = producedDossier(undated, { previous: state });
const stickyHoldout = sticky.events.find((item) => item.id === holdout.id);
assert.equal(stickyHoldout?.usage, "holdout");
assert.equal(stickyHoldout?.year, null);
assert.equal(stickyHoldout?.precision, "unknown");
const decision = decideFromDossier(dossier);
assert.notEqual(decision.nextAction, "ask_holdout_validation");
// 原值: offer_provisional_range / ready_to_adopt、adopt_representative、canAdopt=true
// 新值: ask_fact_collection、collect_evidence
// 原因: D5 + D6——holdout 变成无日期后这条线回到采集池,线没问完不交付;
// 题名要的「stays review-only」(不进 validated_range)仍成立;
// canAdopt 是引擎能力位,公开侧由 canOfferRange 收起(BUG-751)
// 原值(2): ask_fact_collection / collect_evidence;新值(2): offer_provisional_range / 非 collect_evidence;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(decision.nextAction, "offer_provisional_range");
assert.notEqual(decision.sessionOutcome, "collect_evidence");
assert.notEqual(decision.sessionOutcome, "validated_range");
assert.equal(decision.validated, false);
assert.equal(decision.canAdopt, true);
assert.equal(decision.canConfirmExactMinute, false);
});
test("dated holdout asks validation with a renderable followup card", () => {
const { dossier } = producedDossier(COVERED_EVIDENCE);
const decision = decideFromDossier(dossier);
// 原断言 nextAction=ask_holdout_validation → 新断言 ready_to_adopt。
// 为什么:默认决策不再用 holdout 挡住 provisional 采用;确认门仍关。
// holdout 卡片在 validate_holdout 会话里仍可渲染,供确认路径使用。
assert.equal(decision.nextAction, "ready_to_adopt");
assert.equal(decision.canAdopt, true);
assert.equal(decision.canConfirmExactMinute, false);
const plan = holdoutFollowup(dossier);
// BUG-580: 账本已覆盖 holdout 候选领域时不再出盘外核对卡,直接交付。
assert.equal(plan.next_followup, null);
});
test("passed holdout is a validated range, not a unique minute", () => {
const { state, dossier } = producedDossier(COVERED_EVIDENCE);
const passed = applyHoldoutAnswer(state, "yes");
const decided = decideFromDossier({
...dossier,
latestResult: {
...dossier.latestResult,
decisionReceipt: {
...dossier.latestResult.decisionReceipt,
inference_state: passed,
},
},
});
assert.equal(decided.sessionOutcome, "validated_range");
assert.equal(decided.canAdopt, true);
assert.equal(decided.validated, true);
assert.equal(decided.canConfirmExactMinute, false);
assert.notEqual(decided.nextAction, "ask_holdout_validation");
});
test("failed holdout keeps existing non-validated close or discriminate path", () => {
const { state, dossier } = producedDossier(COVERED_EVIDENCE);
const failed = applyHoldoutAnswer(state, "no");
assert.equal(failed.holdout_passed, false);
const decided = decideFromDossier({
...dossier,
latestResult: {
...dossier.latestResult,
decisionReceipt: {
...dossier.latestResult.decisionReceipt,
inference_state: failed,
},
},
});
assert.equal(decided.holdoutValidation, "failed");
assert.notEqual(decided.nextAction, "ask_holdout_validation");
assert.notEqual(decided.sessionOutcome, "validated_range");
assert.equal(decided.validated, false);
assert.equal(decided.canConfirmExactMinute, false);
});
test("ask_holdout_validation is only returned when the same dossier can build a holdout followup", () => {
const fixtures = [
producedDossier(COVERED_EVIDENCE).dossier,
producedDossier(COVERED_EVIDENCE.filter((item) => item.domain !== "occupation")).dossier,
];
for (const dossier of fixtures) {
const decision = decideFromDossier(dossier);
if (decision.nextAction !== "ask_holdout_validation") continue;
const plan = holdoutFollowup(dossier);
assert.ok(plan.next_followup, "ask_holdout_validation requires a holdout followup");
assert.ok(plan.next_followup.choice_frame, "ask_holdout_validation requires a renderable card");
assert.equal(decision.canConfirmExactMinute, false);
}
});