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
236 lines
9.4 KiB
TypeScript
236 lines
9.4 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import { sessionOutcomeAllowsDelivery } from "../src/lib/rectification-agentic/core/rectification-decision.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 {
|
||
blockingMethodsCovered,
|
||
buildMethodFollowupPlan,
|
||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import type { InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||
|
||
const TRAINING_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",
|
||
},
|
||
] as const;
|
||
|
||
const FAMILY_EVIDENCE = {
|
||
id: "e-fam",
|
||
status: "confirmed",
|
||
domain: "family",
|
||
datePrecision: "year",
|
||
occurredFrom: "2023-01-01",
|
||
occurredTo: null,
|
||
eventKind: "family_event",
|
||
} as const;
|
||
|
||
const OCCUPATION_EVIDENCE = {
|
||
id: "e-occ",
|
||
status: "confirmed",
|
||
domain: "occupation",
|
||
datePrecision: "unknown",
|
||
occurredFrom: null,
|
||
occurredTo: null,
|
||
eventKind: "occupation_note",
|
||
} 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;
|
||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||
} = {},
|
||
) {
|
||
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: {
|
||
inference_state: state,
|
||
accept_allowed: true,
|
||
acceptance_allowed: true,
|
||
propose_allowed: true,
|
||
selection_allowed: true,
|
||
confirmation_allowed: false,
|
||
},
|
||
};
|
||
return {
|
||
state,
|
||
dossier: {
|
||
evidence,
|
||
conversationSummary: {
|
||
activeFocus: null,
|
||
declinedSkippedTopics: options.declinedTopics ?? [],
|
||
},
|
||
latestResult: latest,
|
||
case: { acceptedTime: null },
|
||
},
|
||
};
|
||
}
|
||
|
||
function collectionFollowup(
|
||
dossier: ReturnType<typeof producedDossier>["dossier"],
|
||
sessionOutcome: "collect_evidence" | "validate_holdout" | "adopt_representative" = "collect_evidence",
|
||
) {
|
||
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
|
||
return buildMethodFollowupPlan({
|
||
evidence: dossier.evidence,
|
||
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
|
||
sessionOutcome,
|
||
...catalog,
|
||
candidatesSeparated: true,
|
||
});
|
||
}
|
||
|
||
test("separated scores with uncovered relatives stay in collection and still ask a blocking method", () => {
|
||
const { dossier } = producedDossier(TRAINING_EVIDENCE);
|
||
const gate = trainingScoreableGate(TRAINING_EVIDENCE);
|
||
assert.equal(gate.open, true);
|
||
assert.equal(gate.trainingCount, 3);
|
||
assert.ok(gate.trainingDomainCount >= 2);
|
||
|
||
const decision = decideFromDossier(dossier);
|
||
assert.equal(decision.separation.sufficient, true);
|
||
// 原值: offer_provisional_range
|
||
// 新值: ask_fact_collection
|
||
// 原因: D5 让七条线全部轮到 + D6 要求所有线问完才交付;未覆盖的
|
||
// relatives / 搬家等仍是待问的线,出卡门槛只是必要条件(BUG-751)
|
||
// 原值(2): ask_fact_collection
|
||
// 新值(2): offer_provisional_range(回到 BUG-751 之前)
|
||
// 原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),题名原意重新成立
|
||
assert.equal(decision.nextAction, "offer_provisional_range");
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
|
||
const plan = collectionFollowup(dossier);
|
||
assert.equal(blockingMethodsCovered(plan.methods), false);
|
||
assert.notEqual(plan.next_followup?.method_id, "relatives");
|
||
assert.notEqual(plan.next_followup?.domain, "family");
|
||
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered");
|
||
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
|
||
});
|
||
|
||
test("covering family without occupation still opens adopt after scores already separated", () => {
|
||
const familyOnly = producedDossier([...TRAINING_EVIDENCE, FAMILY_EVIDENCE]);
|
||
const familyDecision = decideFromDossier(familyOnly.dossier);
|
||
// 原值: ready_to_adopt / adopt_representative / canAdopt=true
|
||
// 新值: ask_fact_collection / collect_evidence(canAdopt 仍是引擎能力位,不变)
|
||
// 原因: D5 + D6——财务、搬家、健康、职业四条线还没问,交付要等问完(BUG-751)
|
||
// 原值(2): ask_fact_collection / collect_evidence
|
||
// 新值(2): ready_to_adopt / adopt_representative(回到 BUG-751 之前)
|
||
// 原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),题名原意重新成立
|
||
assert.equal(familyDecision.nextAction, "ready_to_adopt");
|
||
assert.equal(familyDecision.sessionOutcome, "adopt_representative");
|
||
assert.equal(familyDecision.canAdopt, true);
|
||
assert.equal(familyDecision.canConfirmExactMinute, false);
|
||
});
|
||
|
||
test("covering family and occupation still opens adopt after scores already separated", () => {
|
||
const { state, dossier: collecting } = producedDossier(TRAINING_EVIDENCE);
|
||
// 原值: offer_provisional_range
|
||
// 新值: ask_fact_collection
|
||
// 原因: D5 + D6——七条线还没问完(BUG-751)
|
||
// 原值(2): ask_fact_collection / 新值(2): offer_provisional_range
|
||
// 原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),题名原意重新成立
|
||
assert.equal(decideFromDossier(collecting).nextAction, "offer_provisional_range");
|
||
|
||
const covered = [...TRAINING_EVIDENCE, FAMILY_EVIDENCE, OCCUPATION_EVIDENCE];
|
||
const { dossier } = producedDossier(covered, { previous: state });
|
||
const decision = decideFromDossier(dossier);
|
||
// 原值: S3(ready_to_adopt 或 offer_provisional_range)、canAdopt=true
|
||
// 新值: ask_fact_collection(canAdopt 仍是引擎能力位,不变)
|
||
// 原因: 同上——覆盖家人与职业后,剩下五条线仍要问(BUG-751)
|
||
// 原值(2): ask_fact_collection / collect_evidence
|
||
// 新值(2): S3 交付(ready_to_adopt 或 offer_provisional_range,sessionOutcome 允许交付)
|
||
// 原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),题名原意重新成立
|
||
assert.ok(decision.nextAction === "ready_to_adopt" || decision.nextAction === "offer_provisional_range", decision.nextAction);
|
||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
assert.equal(decision.separation.sufficient, true);
|
||
});
|
||
|
||
test("declining family and occupation also opens adopt after scores already separated", () => {
|
||
const declinedTopics = [
|
||
{ targetDomain: "family" },
|
||
{ target_domain: "occupation" },
|
||
];
|
||
const { dossier } = producedDossier(TRAINING_EVIDENCE, { declinedTopics });
|
||
const plan = collectionFollowup(dossier);
|
||
assert.equal(blockingMethodsCovered(plan.methods), true);
|
||
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "covered");
|
||
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered");
|
||
|
||
const decision = decideFromDossier(dossier);
|
||
// 原值: ready_to_adopt / adopt_representative / canAdopt=true
|
||
// 新值: ask_fact_collection / collect_evidence(canAdopt 仍是引擎能力位,不变)
|
||
// 原因: D5 + D6——只拒答了家人与职业,其余五条线仍在引导池里(BUG-751)
|
||
// 原值(2): ask_fact_collection / collect_evidence
|
||
// 新值(2): ready_to_adopt / adopt_representative(回到 BUG-751 之前)
|
||
// 原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),题名原意重新成立
|
||
assert.equal(decision.nextAction, "ready_to_adopt");
|
||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
});
|