import assert from "node:assert/strict"; import test from "node:test"; import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts"; import { answersFromEvidence, applyAnswerToState, buildInferenceState, } from "../src/lib/rectification-agentic/core/build-state.ts"; import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { HOLDOUT_MESSAGE_PREFIX } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { clusterEquivalentCandidates } from "../src/lib/rectification-agentic/core/cluster-candidates.ts"; import { evaluateConvergence } from "../src/lib/rectification-agentic/core/convergence-evaluator.ts"; import { isDuplicateProbe } from "../src/lib/rectification-agentic/core/duplicate-probes.ts"; import { entropyFromScores } from "../src/lib/rectification-agentic/core/entropy.ts"; import { selectHighestGainProbe } from "../src/lib/rectification-agentic/core/select-probe.ts"; import { holdoutEventIds, splitHoldoutEvents } from "../src/lib/rectification-agentic/core/split-holdout.ts"; import type { ConflictProbe, InferenceCandidate, ProbeAnswer } from "../src/lib/rectification-agentic/core/types.ts"; function probe(input: { id: string; domain?: string; year?: number; gain: number; yesSupports: readonly string[]; yesConflicts: readonly string[]; split?: string; }): ConflictProbe { return { id: input.id, semantic_key: `${input.domain ?? "career"}.${input.year ?? 2019}`, candidate_split_hash: input.split ?? `${input.yesSupports.join(",")}|${input.yesConflicts.join(",")}`, domain: input.domain ?? "career", year: input.year ?? 2019, question: "是否发生", candidate_ids: [...input.yesSupports, ...input.yesConflicts], expected_outcomes: [ { answer_class: "yes", supports: input.yesSupports, conflicts: input.yesConflicts }, { answer_class: "no", supports: input.yesConflicts, conflicts: input.yesSupports }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: input.gain, source: "dasha_boundary", }; } function candidates(scores: Readonly>): InferenceCandidate[] { return Object.entries(scores).map(([id, score], index) => ({ id, time: id, cluster_range: [id, id] as const, prior_score: 10, posterior_score: score, probability: score, status: "active" as const, rank: index + 1, strong_conflict_count: 0, })); } test("an informative answer lowers entropy and cannot revive an eliminated candidate", () => { const conflict = probe({ id: "p1", gain: 0.3, yesSupports: ["05:00"], yesConflicts: ["05:10"], }); const before = { "04:50": 10, "05:00": 10, "05:10": 10 }; const first = applyProbeOutcome(before, conflict, "yes"); assert.equal(first.kind, "informative"); assert.ok(entropyFromScores(first.scores) < entropyFromScores(before)); assert.ok(first.eliminated_ids.includes("05:10")); const next = buildInferenceState({ range_start: "04:50", range_end: "05:10", candidates: [ { id: "04:50", time: "04:50", relative_support: 10 }, { id: "05:00", time: "05:00", relative_support: 10 }, { id: "05:10", time: "05:10", relative_support: 10 }, ], events: [ { id: "e1", domain: "education", year: 2016, precision: "month" }, { id: "e2", domain: "career", year: 2019, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, ], probes: [conflict], answered_probes: [{ probe_id: "p1", semantic_key: conflict.semantic_key, candidate_split_hash: conflict.candidate_split_hash, answer_class: "no", classified_from: "choice", }], }); assert.equal(next.candidates.find((item) => item.id === "05:00")?.status, "eliminated"); const revived = applyAnswerToState(next, "p1", "yes"); assert.equal(revived.candidates.find((item) => item.id === "05:00")?.status, "eliminated"); }); test("an unsure answer is low-information and the next probe cannot reuse the same split", () => { const first = probe({ id: "p-split", domain: "relationship", year: 2019, gain: 0.4, yesSupports: ["05:00"], yesConflicts: ["05:10"], split: "05:00|05:10", }); const second = probe({ id: "p-repeat", domain: "relationship", year: 2019, gain: 0.5, yesSupports: ["05:00"], yesConflicts: ["05:10"], split: "05:00|05:10", }); const third = probe({ id: "p-other", domain: "career", year: 2022, gain: 0.2, yesSupports: ["04:50"], yesConflicts: ["05:10"], split: "04:50|05:10", }); const applied = applyProbeOutcome({ "05:00": 10, "05:10": 10 }, first, "unsure"); assert.equal(applied.kind, "low_information"); assert.deepEqual(applied.scores, { "05:00": 10, "05:10": 10 }); const asked: ProbeAnswer[] = [{ probe_id: first.id, semantic_key: first.semantic_key, candidate_split_hash: first.candidate_split_hash, answer_class: "unsure", classified_from: "choice", }]; assert.equal(isDuplicateProbe(second, asked), true); assert.equal(selectHighestGainProbe([first, second, third], asked)?.id, "p-other"); }); test("max rounds is not success and equivalent minutes return a range", () => { const clustered = clusterEquivalentCandidates([ { id: "a", time: "04:58", score: 12 }, { id: "b", time: "05:00", score: 12 }, { id: "c", time: "05:01", score: 12 }, ]); assert.equal(clustered.length, 1); assert.equal(clustered[0]?.range_start, "04:58"); assert.equal(clustered[0]?.range_end, "05:01"); const state = buildInferenceState({ range_start: "04:58", range_end: "05:04", candidates: [ { id: "a", time: "04:58", relative_support: 12 }, { id: "b", time: "05:00", relative_support: 12 }, { id: "c", time: "05:01", relative_support: 12 }, ], events: [ { id: "e1", domain: "education", year: 2016, precision: "month" }, { id: "e2", domain: "career", year: 2019, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, ], probes: [], }); assert.equal(state.result_status, "credible_range"); assert.deepEqual(state.credible_range, ["04:58", "05:01"]); const exhausted = evaluateConvergence({ candidates: candidates({ a: 0.45, b: 0.35, c: 0.2 }).map((item, index) => ({ ...item, probability: [0.45, 0.35, 0.2][index] ?? 0, })), events: splitHoldoutEvents([ { id: "e1", domain: "education", year: 2016, precision: "month" }, { id: "e2", domain: "career", year: 2019, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, { id: "e5", domain: "health", year: 2018, precision: "year" }, ]), probes: [probe({ id: "open", gain: 0.3, yesSupports: ["a"], yesConflicts: ["b"] })], answered_probes: [], rounds: [ { round: 1, phase: "discrimination", probe_id: "r1", scores_before: {}, scores_after: {}, entropy_before: 1, entropy_after: 0.9, eliminated_ids: [], winner_id: "a", kind: "informative", }, { round: 2, phase: "discrimination", probe_id: "r2", scores_before: {}, scores_after: {}, entropy_before: 0.9, entropy_after: 0.8, eliminated_ids: [], winner_id: "a", kind: "informative", }, ], credible_range: null, max_rounds: 2, }); assert.equal(exhausted.result_status, "max_rounds_reached"); assert.equal(exhausted.converged, false); }); test("holdout events stay out of training and a winner must stay stable for two rounds", () => { const events = splitHoldoutEvents([ { id: "edu", domain: "education", year: 2016, precision: "month" }, { id: "job", domain: "career", year: 2019, precision: "year" }, { id: "love", domain: "relationship", year: 2021, precision: "year" }, { id: "home", domain: "relocation", year: 2023, precision: "day" }, { id: "health", domain: "health", year: 2018, precision: "year" }, ]); assert.equal(holdoutEventIds(events).size, 1); assert.equal(events.filter((item) => item.usage === "training").length, 4); const oneRound = evaluateConvergence({ candidates: [ { ...candidates({ "05:00": 12 })[0]!, probability: 0.8, posterior_score: 12 }, { ...candidates({ "05:10": 4 })[0]!, id: "05:10", time: "05:10", probability: 0.2, posterior_score: 4 }, ], events, probes: [], answered_probes: [], rounds: [{ round: 1, phase: "discrimination", probe_id: "p", scores_before: {}, scores_after: {}, entropy_before: 1, entropy_after: 0.4, eliminated_ids: [], winner_id: "05:00", kind: "informative", }], credible_range: null, }); assert.equal(oneRound.converged, false); const twoRounds = evaluateConvergence({ candidates: [ { id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 8, posterior_score: 14, probability: 0.82, status: "active", rank: 1, strong_conflict_count: 0, }, { id: "05:10", time: "05:10", cluster_range: ["05:10", "05:10"], prior_score: 8, posterior_score: 4, probability: 0.18, status: "active", rank: 2, strong_conflict_count: 0, }, ], events, probes: [], answered_probes: [], rounds: [ { round: 1, phase: "discrimination", probe_id: "p1", scores_before: {}, scores_after: {}, entropy_before: 1, entropy_after: 0.5, eliminated_ids: [], winner_id: "05:00", kind: "informative", }, { round: 2, phase: "discrimination", probe_id: "p2", scores_before: {}, scores_after: {}, entropy_before: 0.5, entropy_after: 0.3, eliminated_ids: [], winner_id: "05:00", kind: "informative", }, ], credible_range: null, }); assert.equal(twoRounds.converged, true); assert.equal(twoRounds.result_status, "converged"); const matching = answersFromEvidence( [probe({ id: "p-job", domain: "career", year: 2019, gain: 0.2, yesSupports: ["05:00"], yesConflicts: ["05:10"] })], [{ id: "job", domain: "career", year: 2019, precision: "year" }], ); assert.equal(matching[0]?.classified_from, "evidence"); }); test("C without new evidence updates the posterior immediately and D only marks the split asked", () => { const conflict = probe({ id: "p-cd", domain: "career", year: 2019, gain: 0.4, yesSupports: ["05:00"], yesConflicts: ["05:10"], split: "05:00|05:10", }); const state = buildInferenceState({ range_start: "04:50", range_end: "05:10", candidates: [ { id: "05:00", time: "05:00", relative_support: 10 }, { id: "05:10", time: "05:10", relative_support: 10 }, ], events: [ { id: "e1", domain: "education", year: 2016, precision: "month" }, { id: "e2", domain: "career", year: 2018, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, ], probes: [conflict], }); const denied = applyChoiceWithoutEvidence(state, { choiceKey: "C", status: "declined", schema: { choice: { prompt: "2019 年前后有没有入职或职责加重?" }, semantic_key: conflict.semantic_key }, }); assert.equal(denied.applied, true); assert.equal(denied.answerClass, "no"); assert.equal(denied.state.candidates.find((item) => item.id === "05:00")?.status, "eliminated"); assert.ok(denied.state.entropy < state.entropy); assert.equal(denied.state.answered_probes.some((item) => item.semantic_key === conflict.semantic_key), true); const unsure = applyChoiceWithoutEvidence(state, { choiceKey: "D", status: "skipped", userMessage: "D. 不记得 / 不确定", schema: { choice: { prompt: "2019 年前后有没有入职或职责加重?" }, semantic_key: conflict.semantic_key }, }); assert.equal(unsure.applied, true); assert.equal(unsure.answerClass, "unsure"); assert.deepEqual( unsure.state.candidates.map((item) => item.posterior_score), state.candidates.map((item) => item.posterior_score), ); assert.equal(selectHighestGainProbe([conflict, probe({ id: "p-other", domain: "relationship", year: 2021, gain: 0.2, yesSupports: ["05:00"], yesConflicts: ["05:10"], split: "05:00|2021", })], unsure.state.answered_probes)?.id, "p-other"); }); test("holdout and collection declines do not write a probe answer", () => { const conflict = probe({ id: "p-holdout", gain: 0.3, yesSupports: ["05:00"], yesConflicts: ["05:10"], }); const state = buildInferenceState({ range_start: "04:50", range_end: "05:10", candidates: [ { id: "05:00", time: "05:00", relative_support: 10 }, { id: "05:10", time: "05:10", relative_support: 10 }, ], events: [ { id: "e1", domain: "education", year: 2016, precision: "month" }, { id: "e2", domain: "career", year: 2019, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, ], probes: [conflict], }); const holdout = applyChoiceWithoutEvidence(state, { choiceKey: "C", userMessage: `${HOLDOUT_MESSAGE_PREFIX}:C. 没有明显发生`, schema: { choice: { prompt: "盘外核对" }, scoring: false }, questionId: "relatives:family_event:holdout", }); assert.equal(holdout.reason, "holdout"); assert.equal(holdout.state.answered_probes.length, state.answered_probes.length); const collection = applyChoiceWithoutEvidence(state, { status: "declined", schema: { required: ["year"] }, }); assert.equal(collection.reason, "no_choice"); }); test("choice answers without new evidence patch inference_state in place instead of the candidate cache", () => { const migration = readFileSync( fileURLToPath(new URL("../supabase/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)), "utf8", ); assert.match(migration, /create or replace function public\.patch_agentic_rectification_inference_state\(/); assert.match(migration, /jsonb_set\(v_result\.decision_receipt, '\{inference_state\}', p_inference_state, true\)/); assert.doesNotMatch(migration, /persist_agentic_rectification_candidate_v2/); assert.equal( existsSync(new URL("../db/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)), false, "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", ); });