f5e73ef326
Choice cards used a hardcoded domain menu and always asked existence. Rank scoring layers by remaining-minute entropy, keep finance and health volunteer-only, and ask D9/D10 style or exam quality so taps match outcomes. Co-authored-by: Cursor <cursoragent@cursor.com>
972 lines
35 KiB
TypeScript
972 lines
35 KiB
TypeScript
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<Record<string, number>>): 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<typeof buildInferenceState>;
|
||
fingerprint: string;
|
||
};
|
||
|
||
function createLedger(seed: ReturnType<typeof buildInferenceState>) {
|
||
const rows: LedgerRow[] = [];
|
||
const evidenceFp = "e".repeat(64);
|
||
const caseId = "case-1";
|
||
const engineReceipt: Record<string, unknown> = { inference_state: seed };
|
||
return {
|
||
evidenceFp,
|
||
append(input: {
|
||
expectedRevision: number;
|
||
probeId: string;
|
||
openProbeId: string;
|
||
answerClass: "yes" | "weak_yes" | "no" | "unsure";
|
||
idempotencyKey: string;
|
||
apply: () => ReturnType<typeof buildInferenceState>;
|
||
}) {
|
||
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,
|
||
);
|
||
});
|