diff --git a/frontend/tests/rectification-coverage-collect.test.ts b/frontend/tests/rectification-coverage-collect.test.ts new file mode 100644 index 00000000..f314ef7d --- /dev/null +++ b/frontend/tests/rectification-coverage-collect.test.ts @@ -0,0 +1,197 @@ +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); +});