fix(rectification): keep candidate state coherent
This commit is contained in:
@@ -4,7 +4,6 @@ import test from "node:test";
|
||||
import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts";
|
||||
import {
|
||||
answersFromEvidence,
|
||||
applyAnswerToState,
|
||||
applySupersedeAnswer,
|
||||
buildInferenceState,
|
||||
replayInferenceState,
|
||||
@@ -24,7 +23,6 @@ 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";
|
||||
@@ -93,47 +91,104 @@ test("discriminating result does not stay in event_collection", () => {
|
||||
assert.equal(state.phase, "discrimination");
|
||||
});
|
||||
|
||||
test("an informative answer lowers entropy and cannot revive an eliminated candidate", () => {
|
||||
const conflict = probe({
|
||||
id: "p1",
|
||||
test("repeated strong conflicts eliminate a high-support candidate without double-counting replay", () => {
|
||||
const conflicts = [2015, 2016, 2017].map((year, index) => probe({
|
||||
id: `p${index + 1}`,
|
||||
year,
|
||||
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");
|
||||
}));
|
||||
const first = applyProbeOutcome({ "05:00": 10, "05:10": 10 }, conflicts[0]!, "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",
|
||||
assert.deepEqual(first.eliminated_ids, []);
|
||||
assert.equal(first.strong_conflict_counts["05:10"], 1);
|
||||
|
||||
const answers: ProbeAnswer[] = conflicts.map((item) => ({
|
||||
probe_id: item.id,
|
||||
semantic_key: item.semantic_key,
|
||||
candidate_split_hash: item.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
}));
|
||||
const state = buildInferenceState({
|
||||
range_start: "05:00",
|
||||
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 },
|
||||
{ id: "05:00", time: "05:00", relative_support: 34 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 58 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "education", year: 2016, precision: "month" },
|
||||
{ id: "e1", domain: "education", year: 2018, 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",
|
||||
}],
|
||||
probes: conflicts,
|
||||
answered_probes: answers,
|
||||
});
|
||||
assert.equal(next.candidates.find((item) => item.id === "05:00")?.status, "eliminated");
|
||||
assert.equal(next.last_inference_round?.kind, "informative");
|
||||
assert.ok(next.last_inference_round?.score_deltas);
|
||||
assert.notDeepEqual(next.last_inference_round?.scores_before, next.last_inference_round?.scores_after);
|
||||
const revived = applyAnswerToState(next, "p1", "yes");
|
||||
assert.equal(revived.candidates.find((item) => item.id === "05:00")?.status, "eliminated");
|
||||
const rejected = state.candidates.find((item) => item.id === "05:10");
|
||||
assert.equal(rejected?.strong_conflict_count, 3);
|
||||
assert.equal(rejected?.status, "eliminated");
|
||||
assert.notEqual(state.result_status, "converged");
|
||||
|
||||
const rebuilt = buildInferenceState({
|
||||
range_start: state.range_start,
|
||||
range_end: state.range_end,
|
||||
candidates: state.candidates.map((item) => ({
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
relative_support: item.prior_score,
|
||||
})),
|
||||
events: state.events,
|
||||
probes: state.probes,
|
||||
previous: state,
|
||||
});
|
||||
assert.equal(rebuilt.candidates.find((item) => item.id === "05:10")?.strong_conflict_count, 3);
|
||||
assert.equal(rebuilt.candidates.find((item) => item.id === "05:10")?.status, "eliminated");
|
||||
|
||||
const replayed = replayInferenceState(state, answers);
|
||||
assert.equal(replayed.candidates.find((item) => item.id === "05:10")?.strong_conflict_count, 3);
|
||||
assert.equal(replayed.candidates.find((item) => item.id === "05:10")?.status, "eliminated");
|
||||
|
||||
const corrected = applySupersedeAnswer(state, conflicts[2]!.id, "unsure");
|
||||
assert.equal(corrected.candidates.find((item) => item.id === "05:10")?.strong_conflict_count, 2);
|
||||
assert.notEqual(corrected.candidates.find((item) => item.id === "05:10")?.status, "eliminated");
|
||||
});
|
||||
|
||||
test("strong-conflict elimination always leaves one survivor and cannot declare it converged", () => {
|
||||
const conflicts = [2015, 2016, 2017].map((year, index) => probe({
|
||||
id: `all-conflict-${index + 1}`,
|
||||
year,
|
||||
gain: 0.3,
|
||||
yesSupports: [],
|
||||
yesConflicts: ["05:00", "05:10"],
|
||||
}));
|
||||
const state = buildInferenceState({
|
||||
range_start: "05:00",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 58 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 34 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "education", year: 2018, 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: conflicts,
|
||||
answered_probes: conflicts.map((item) => ({
|
||||
probe_id: item.id,
|
||||
semantic_key: item.semantic_key,
|
||||
candidate_split_hash: item.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
})),
|
||||
});
|
||||
assert.equal(state.candidates.filter((item) => item.status !== "eliminated").length, 1);
|
||||
assert.equal(state.candidates.every((item) => item.strong_conflict_count === 3), true);
|
||||
assert.notEqual(state.result_status, "converged");
|
||||
});
|
||||
|
||||
test("an unsure answer is low-information and the next probe cannot reuse the same split", () => {
|
||||
@@ -393,7 +448,11 @@ test("C without new evidence updates the posterior immediately and D only marks
|
||||
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.equal(denied.state.candidates.find((item) => item.id === "05:00")?.status, "active");
|
||||
assert.ok(
|
||||
(denied.state.candidates.find((item) => item.id === "05:00")?.posterior_score ?? 0)
|
||||
< (denied.state.candidates.find((item) => item.id === "05:10")?.posterior_score ?? 0),
|
||||
);
|
||||
assert.ok(denied.state.entropy < state.entropy);
|
||||
assert.equal(denied.state.answered_probes.some((item) => item.semantic_key === conflict.semantic_key), true);
|
||||
|
||||
@@ -811,6 +870,20 @@ test("evidence fingerprint can stay put while decision-state fingerprint and pos
|
||||
(staleReceipt.inference_state as { candidates: unknown }).candidates,
|
||||
);
|
||||
assert.equal(composed.decision_state_fingerprint, afterFp);
|
||||
|
||||
const revisionMismatch = composeInferenceReceipt(staleReceipt, {
|
||||
resultId: "result-1",
|
||||
revision: after.state.revision + 1,
|
||||
probeId: conflict.id,
|
||||
reason: "choice",
|
||||
decisionStateFingerprint: afterFp,
|
||||
inferenceState: after.state,
|
||||
posteriorBefore: posteriorMap(state.candidates),
|
||||
posteriorAfter: posteriorMap(after.state.candidates),
|
||||
scoreDeltas: {},
|
||||
}, "result-1");
|
||||
assert.equal(revisionMismatch.inference_state, state);
|
||||
assert.equal(revisionMismatch.decision_state_fingerprint, undefined);
|
||||
});
|
||||
|
||||
test("persisted focus can answer a lower-gain probe while conflicting schema identities stay stale", () => {
|
||||
|
||||
Reference in New Issue
Block a user