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, applySupersedeAnswer, buildInferenceState, replayInferenceState, } from "../src/lib/rectification-agentic/core/build-state.ts"; import { composeInferenceReceipt, } from "../src/lib/rectification-agentic/core/compose-receipt.ts"; import { decisionStateFingerprint, posteriorMap, } from "../src/lib/rectification-agentic/core/decision-fingerprint.ts"; import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.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("discriminating result does not stay in event_collection", () => { const state = buildInferenceState({ range_start: "04:50", range_end: "05:10", candidates: [ { id: "05:00", time: "05:00", relative_support: 34 }, { id: "05:03", time: "05:03", relative_support: 33 }, { id: "05:04", time: "05:04", relative_support: 33 }, ], 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" }, { id: "e5", domain: "relocation", year: 2017, precision: "year" }, ], probes: [probe({ id: "open", gain: 0.3, yesSupports: ["05:00"], yesConflicts: ["05:03"] })], phase: "event_collection", }); assert.equal(state.result_status, "discriminating"); assert.equal(state.phase, "discrimination"); }); 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"); const quality = { ...probe({ id: "p-quality", domain: "education", year: 2016, gain: 0, yesSupports: ["05:00"], yesConflicts: ["05:10"] }), source: "known_event_quality", }; const skippedQuality = answersFromEvidence( [quality], [{ id: "enroll", domain: "education", year: 2016, precision: "year" }], ); assert.equal(skippedQuality.length, 0); }); 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.revision, state.revision + 1); assert.notDeepEqual(posteriorMap(denied.state.candidates), posteriorMap(state.candidates)); 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("A/B/C/D on a remaining-minute contrast probe moves the posterior", () => { const contrast: ConflictProbe = { id: "contrast:varga.d24.05:00/05:06|05:07", semantic_key: "varga.d24.05:00/05:06|05:07", candidate_split_hash: "varga.d24.05:00/05:06|05:07", domain: "education", year: 0, question: "学业盘还分得开", candidate_ids: ["05:00", "05:06", "05:07"], expected_outcomes: [ { answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] }, { answer_class: "no", supports: ["05:06", "05:07"], conflicts: ["05:00"] }, { answer_class: "unsure", supports: [], conflicts: [] }, ], information_gain: 0.16, source: "varga_contrast", }; const state = buildInferenceState({ range_start: "04:45", range_end: "05:15", candidates: [ { id: "05:00", time: "05:00", relative_support: 34 }, { id: "05:06", time: "05:06", relative_support: 33 }, { id: "05:07", time: "05:07", relative_support: 33 }, ], events: [{ id: "e1", domain: "education", year: 2016, precision: "year" }], probes: [contrast], }); const before = posteriorMap(state.candidates); const applied = applyChoiceWithoutEvidence(state, { choiceKey: "C", schema: { choice: { prompt: "那次考试有没有发挥失常?" }, probe_id: contrast.id, semantic_key: contrast.semantic_key, }, }); assert.equal(applied.applied, true); assert.equal(applied.answerClass, "no"); assert.notDeepEqual(posteriorMap(applied.state.candidates), before); assert.ok((applied.state.candidates.find((item) => item.time === "05:06")?.posterior_score ?? 0) > (applied.state.candidates.find((item) => item.time === "05:00")?.posterior_score ?? 0)); }); test("two-way style C 都不像 does not promote the other minute group", () => { const contrast = { id: "contrast:varga.d9.05:00/05:04", semantic_key: "varga.d9.05:00/05:04", candidate_split_hash: "varga.d9.05:00/05:04", domain: "relationship", year: 0, question: "这段关系更接近哪一种相处?", candidate_ids: ["05:00", "05:04"], expected_outcomes: [ { answer_class: "yes" as const, supports: ["05:00"], conflicts: ["05:04"] }, { answer_class: "weak_yes" as const, supports: ["05:04"], conflicts: ["05:00"] }, { answer_class: "unsure" as const, supports: [] as string[], conflicts: [] as string[] }, ], information_gain: 1, source: "varga_contrast", }; const state = buildInferenceState({ range_start: "04:45", range_end: "05:15", candidates: [ { id: "05:00", time: "05:00", relative_support: 34 }, { id: "05:04", time: "05:04", relative_support: 33 }, ], events: [{ id: "e1", domain: "relationship", year: 2024, precision: "year" }], probes: [contrast], }); const before = posteriorMap(state.candidates); const applied = applyChoiceWithoutEvidence(state, { choiceKey: "C", schema: { choice: { prompt: "这段关系更接近哪一种相处?", option_c: "两边都不像", }, choice_kind: "varga_style", probe_id: contrast.id, semantic_key: contrast.semantic_key, }, }); assert.equal(applied.applied, true); assert.equal(applied.answerClass, "unsure"); assert.deepEqual(posteriorMap(applied.state.candidates), before); }); 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("structured A/yes from a choice card moves the posterior", () => { const conflict = probe({ id: "p-a", domain: "education", year: 2016, gain: 0.4, 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: 2018, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, ], probes: [conflict], }); const after = applyChoiceWithoutEvidence(state, { choiceKey: "A", schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key }, }); assert.equal(after.applied, true); assert.equal(after.answerClass, "yes"); assert.equal(after.state.answered_probes.at(-1)?.classified_from, "choice"); assert.notEqual( after.state.candidates.find((item) => item.id === "05:00")?.posterior_score, state.candidates.find((item) => item.id === "05:00")?.posterior_score, ); }); test("choice action receipts are unique per case_id and action_id and close focus in the same SQL function", () => { const migration = readFileSync( fileURLToPath(new URL("../supabase/migrations/20260824020000_rectification_choice_action.sql", import.meta.url)), "utf8", ); assert.match(migration, /create table if not exists public\.agentic_rectification_choice_actions \(/); assert.match(migration, /unique \(case_id, action_id\)/); assert.match(migration, /apply_agentic_rectification_choice_action\(/); const applySql = migration.slice( migration.indexOf("create or replace function public.apply_agentic_rectification_choice_action("), ); assert.match(applySql, /append_agentic_rectification_inference_transition\(/); assert.match(applySql, /resolve_agentic_rectification_conversation_focus\(/); const appendAt = applySql.indexOf("append_agentic_rectification_inference_transition("); const resolveAt = applySql.indexOf("resolve_agentic_rectification_conversation_focus("); const returnAt = applySql.indexOf("return v_receipt;"); assert.ok(appendAt >= 0 && resolveAt > appendAt && returnAt > resolveAt); assert.equal( existsSync(new URL("../db/migrations/20260824020000_rectification_choice_action.sql", import.meta.url)), false, "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", ); }); test("choice answers append an inference transition instead of patching the candidate cache", () => { const migration = readFileSync( fileURLToPath(new URL("../supabase/migrations/20260824010000_rectification_inference_transition_ledger.sql", import.meta.url)), "utf8", ); assert.match(migration, /create table if not exists public\.agentic_rectification_inference_transitions \(/); assert.match(migration, /create or replace function public\.append_agentic_rectification_inference_transition\(/); assert.match(migration, /agentic_rectification_revision_conflict/); assert.match(migration, /agentic_rectification_stale_probe/); assert.match(migration, /idempotency_key/); assert.match(migration, /raise exception 'agentic_rectification_inference_patch_retired'/); const appendSql = migration.slice( migration.indexOf("create or replace function public.append_agentic_rectification_inference_transition("), migration.indexOf("create or replace function public.get_agentic_rectification_latest_inference_transition("), ); assert.doesNotMatch(appendSql, /update public\.agentic_rectification_results/); assert.match( migration, /compose_agentic_rectification_decision_receipt\(p_case_id, v_cached\.id, v_cached\.decision_receipt\)/, ); assert.match( migration, /compose_agentic_rectification_decision_receipt\(p_case_id, v_result_id, v_saved_decision_receipt\)/, ); assert.match( migration, /compose_agentic_rectification_decision_receipt\(v_case\.id, v_result\.id, v_result\.decision_receipt\)/, ); assert.doesNotMatch(migration, /and decision_state_fingerprint = /); assert.equal( existsSync(new URL("../db/migrations/20260824010000_rectification_inference_transition_ledger.sql", import.meta.url)), false, "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", ); const retired = readFileSync( fileURLToPath(new URL("../supabase/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)), "utf8", ); assert.match(retired, /patch_agentic_rectification_inference_state/); }); function fingerprintOf( caseId: string, evidenceFp: string, state: { candidate_set_id: string; revision: number; answered_probes: readonly { probe_id: string }[] }, ): string { return decisionStateFingerprint({ caseId, evidenceLedgerFingerprint: evidenceFp, candidateSetId: state.candidate_set_id, inferenceRevision: state.revision, answeredProbeIds: state.answered_probes.map((item) => item.probe_id), scoringPolicyVersion: INFERENCE_ALGORITHM_VERSION, }); } type LedgerRow = { revision: number; probeId: string; answerClass: string; idempotencyKey: string; inferenceState: ReturnType; fingerprint: string; }; function createLedger(seed: ReturnType) { const rows: LedgerRow[] = []; const evidenceFp = "e".repeat(64); const caseId = "case-1"; const engineReceipt: Record = { inference_state: seed }; return { evidenceFp, append(input: { expectedRevision: number; probeId: string; openProbeId: string; answerClass: "yes" | "weak_yes" | "no" | "unsure"; idempotencyKey: string; apply: () => ReturnType; }) { const existing = rows.find((row) => row.idempotencyKey === input.idempotencyKey); if (existing) { return { idempotent: true, row: existing, receipt: composeInferenceReceipt(engineReceipt, { resultId: "result-1", revision: existing.revision, probeId: existing.probeId, reason: "choice", decisionStateFingerprint: existing.fingerprint, inferenceState: existing.inferenceState, posteriorBefore: {}, posteriorAfter: posteriorMap(existing.inferenceState.candidates), scoreDeltas: {}, }, "result-1") }; } const current = rows.at(-1)?.revision ?? seed.revision; if (input.expectedRevision !== current) { const error = new Error("agentic_rectification_revision_conflict"); throw error; } if (input.probeId !== input.openProbeId) { throw new Error("agentic_rectification_stale_probe"); } const next = input.apply(); const fingerprint = fingerprintOf(caseId, evidenceFp, next); const row: LedgerRow = { revision: next.revision, probeId: input.probeId, answerClass: input.answerClass, idempotencyKey: input.idempotencyKey, inferenceState: next, fingerprint, }; rows.push(row); return { idempotent: false, row, receipt: composeInferenceReceipt(engineReceipt, { resultId: "result-1", revision: row.revision, probeId: row.probeId, reason: "choice", decisionStateFingerprint: fingerprint, inferenceState: next, posteriorBefore: {}, posteriorAfter: posteriorMap(next.candidates), scoreDeltas: {}, }, "result-1"), }; }, reread() { const latest = rows.at(-1); if (!latest) return composeInferenceReceipt(engineReceipt, null, "result-1"); return composeInferenceReceipt(engineReceipt, { resultId: "result-1", revision: latest.revision, probeId: latest.probeId, reason: "choice", decisionStateFingerprint: latest.fingerprint, inferenceState: latest.inferenceState, posteriorBefore: {}, posteriorAfter: posteriorMap(latest.inferenceState.candidates), scoreDeltas: {}, }, "result-1"); }, rows, }; } test("evidence fingerprint can stay put while decision-state fingerprint and posterior change", () => { const conflict = probe({ id: "p-cd", domain: "career", year: 2019, gain: 0.4, 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: 2018, precision: "year" }, { id: "e3", domain: "relationship", year: 2021, precision: "year" }, { id: "e4", domain: "family", year: 2023, precision: "year" }, ], probes: [conflict], }); const after = applyChoiceWithoutEvidence(state, { choiceKey: "C", schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key }, }); assert.equal(after.applied, true); assert.equal(after.state.revision, state.revision + 1); const evidenceFp = "e".repeat(64); const beforeFp = fingerprintOf("case-1", evidenceFp, state); const afterFp = fingerprintOf("case-1", evidenceFp, after.state); assert.equal(evidenceFp, "e".repeat(64)); assert.notEqual(afterFp, beforeFp); const staleReceipt = { inference_state: state, display_allowed: true }; const composed = composeInferenceReceipt(staleReceipt, { resultId: "result-1", revision: after.state.revision, probeId: conflict.id, reason: "choice", decisionStateFingerprint: afterFp, inferenceState: after.state, posteriorBefore: posteriorMap(state.candidates), posteriorAfter: posteriorMap(after.state.candidates), scoreDeltas: {}, }, "result-1"); assert.notDeepEqual( (composed.inference_state as { candidates: unknown }).candidates, (staleReceipt.inference_state as { candidates: unknown }).candidates, ); assert.equal(composed.decision_state_fingerprint, afterFp); }); test("duplicate D is idempotent, C then D supersedes, stale probes and stale revisions are rejected, replay matches", () => { const conflict = probe({ id: "p-cd", domain: "career", year: 2019, gain: 0.4, yesSupports: ["05:00"], yesConflicts: ["05:10"], }); const other = probe({ id: "p-old", domain: "relationship", year: 2021, gain: 0.2, yesSupports: ["05:00"], yesConflicts: ["05:10"], split: "05:00|2021", }); 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, other], }); const ledger = createLedger(state); const firstD = applyChoiceWithoutEvidence(state, { choiceKey: "D", schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key }, }); assert.equal(firstD.applied, true); const stale = applyChoiceWithoutEvidence(state, { choiceKey: "D", schema: { probe_id: other.id, semantic_key: other.semantic_key }, }); assert.equal(stale.reason, "stale_probe"); assert.deepEqual(posteriorMap(stale.state.candidates), posteriorMap(state.candidates)); assert.throws( () => ledger.append({ expectedRevision: state.revision, probeId: other.id, openProbeId: conflict.id, answerClass: "unsure", idempotencyKey: `choice:${other.id}:unsure`, apply: () => firstD.state, }), /stale_probe/, ); const persisted = ledger.append({ expectedRevision: state.revision, probeId: conflict.id, openProbeId: conflict.id, answerClass: "unsure", idempotencyKey: `choice:${conflict.id}:unsure`, apply: () => firstD.state, }); assert.equal(persisted.idempotent, false); assert.equal(persisted.row.revision, state.revision + 1); const again = ledger.append({ expectedRevision: state.revision, probeId: conflict.id, openProbeId: conflict.id, answerClass: "unsure", idempotencyKey: `choice:${conflict.id}:unsure`, apply: () => firstD.state, }); assert.equal(again.idempotent, true); assert.equal(again.row.revision, persisted.row.revision); assert.equal(ledger.rows.length, 1); const afterC = applyChoiceWithoutEvidence(state, { choiceKey: "C", schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key }, }); const superseded = applyChoiceWithoutEvidence(afterC.state, { choiceKey: "D", schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key }, }); assert.equal(superseded.reason, "superseded"); assert.equal(superseded.state.revision, afterC.state.revision + 1); assert.equal(superseded.state.answered_probes.filter((item) => item.probe_id === conflict.id).length, 1); assert.equal(superseded.state.answered_probes[0]?.answer_class, "unsure"); const keptC = applySupersedeAnswer(afterC.state, conflict.id, "unsure"); assert.equal(keptC.revision, superseded.state.revision); const corrections = createLedger(state); const writtenC = corrections.append({ expectedRevision: state.revision, probeId: conflict.id, openProbeId: conflict.id, answerClass: "no", idempotencyKey: `choice:${conflict.id}:no`, apply: () => afterC.state, }); const writtenD = corrections.append({ expectedRevision: afterC.state.revision, probeId: conflict.id, openProbeId: conflict.id, answerClass: "unsure", idempotencyKey: `supersede:${conflict.id}:unsure`, apply: () => superseded.state, }); assert.equal(writtenC.idempotent, false); assert.equal(writtenD.idempotent, false); assert.equal(writtenD.row.revision, writtenC.row.revision + 1); assert.equal(corrections.rows.length, 2); assert.equal(corrections.rows[0]?.answerClass, "no"); assert.equal(corrections.rows[1]?.answerClass, "unsure"); assert.throws( () => ledger.append({ expectedRevision: state.revision, probeId: conflict.id, openProbeId: conflict.id, answerClass: "no", idempotencyKey: `choice:${conflict.id}:no`, apply: () => afterC.state, }), /revision_conflict/, ); const reread = ledger.reread(); assert.deepEqual( posteriorMap((reread.inference_state as typeof firstD.state).candidates), posteriorMap(firstD.state.candidates), ); const replayed = replayInferenceState(state, firstD.state.answered_probes); assert.deepEqual(posteriorMap(replayed.candidates), posteriorMap(firstD.state.candidates)); assert.equal(replayed.revision, firstD.state.revision); const rescored = buildInferenceState({ range_start: "04:50", range_end: "05:10", candidates: [ { id: "05:00", time: "05:00", relative_support: 12 }, { id: "05:10", time: "05:10", relative_support: 8 }, { id: "05:04", time: "05:04", relative_support: 9 }, ], 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, other], previous: firstD.state, }); assert.notEqual(rescored.candidate_set_id, firstD.state.candidate_set_id); assert.equal(rescored.revision, firstD.state.revision); assert.equal( rescored.answered_probes.some((item) => item.probe_id === conflict.id && item.answer_class === "unsure"), true, ); });