29750d3835
Choice C/D without new evidence never changed the candidate posterior until the next dated-event rescore, and persist-v2 would cache-hit on the same evidence fingerprint. Patch the latest decision_receipt.inference_state in place so the next follow-up sees the asked split immediately. Co-authored-by: Cursor <cursoragent@cursor.com>
428 lines
15 KiB
TypeScript
428 lines
15 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,
|
||
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<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("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)",
|
||
);
|
||
});
|