import assert from "node:assert/strict"; import test from "node:test"; 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 UNCOVERED_BLOCKING = new Set(["relatives", "occupation"]); 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>[]; } = {}, ) { 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 }, }; return { state, dossier: { evidence, conversationSummary: { activeFocus: null, declinedSkippedTopics: options.declinedTopics ?? [], }, latestResult: latest, case: { acceptedTime: null }, }, }; } function collectionFollowup( dossier: ReturnType["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); assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(decision.canConfirmExactMinute, false); const plan = collectionFollowup(dossier); assert.equal(blockingMethodsCovered(plan.methods), false); assert.ok(plan.next_followup, "collection must still have a question"); assert.ok( UNCOVERED_BLOCKING.has(plan.next_followup.method_id), `expected relatives or occupation, got ${plan.next_followup.method_id}`, ); 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 and occupation leaves collection after scores already separated", () => { const { state, dossier: collecting } = producedDossier(TRAINING_EVIDENCE); assert.equal(decideFromDossier(collecting).nextAction, "ask_fact_collection"); const covered = [...TRAINING_EVIDENCE, FAMILY_EVIDENCE, OCCUPATION_EVIDENCE]; const { dossier } = producedDossier(covered, { previous: state }); const decision = decideFromDossier(dossier); assert.notEqual(decision.nextAction, "ask_fact_collection"); assert.ok( decision.nextAction === "ask_holdout_validation" || decision.nextAction === "ready_to_adopt", decision.nextAction, ); assert.equal(decision.canConfirmExactMinute, false); assert.equal(decision.separation.sufficient, true); }); test("declining family and occupation also leaves collection 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); assert.notEqual(decision.nextAction, "ask_fact_collection"); assert.ok( decision.nextAction === "ask_holdout_validation" || decision.nextAction === "ready_to_adopt", decision.nextAction, ); assert.equal(decision.canConfirmExactMinute, false); });