fix(rectification): keep candidate state coherent
Independent Staging Quality Gate / validate (push) Failing after 15m32s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-27 13:59:38 +08:00
parent 518119acbd
commit 5f855c610c
30 changed files with 2109 additions and 167 deletions
@@ -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", () => {