Files
Jyotisha/frontend/tests/rectification-inference-machine.test.ts
T
Jesse_Chen 29750d3835
Independent Staging Quality Gate / validate (push) Failing after 9m53s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(web): persist C/D rectification answers without waiting for rescore
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>
2026-08-23 22:41:01 +08:00

428 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)",
);
});