fix(web): persist rectification C/D answers on an append-only inference ledger
Independent Staging Quality Gate / validate (push) Failing after 4m0s
Independent Staging Quality Gate / publish (push) Has been skipped

Engine result rows stay immutable. Choice answers append transitions, and reads overlay the latest revision instead of patching the cached receipt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-24 08:22:01 +08:00
parent 29750d3835
commit 909de8b884
14 changed files with 1701 additions and 65 deletions
@@ -5,8 +5,18 @@ import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-p
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";
@@ -346,6 +356,8 @@ test("C without new evidence updates the posterior immediately and D only marks
});
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);
@@ -411,17 +423,367 @@ test("holdout and collection declines do not write a probe answer", () => {
assert.equal(collection.reason, "no_choice");
});
test("choice answers without new evidence patch inference_state in place instead of the candidate cache", () => {
test("choice answers append an inference transition instead of patching the candidate cache", () => {
const migration = readFileSync(
fileURLToPath(new URL("../supabase/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
fileURLToPath(new URL("../supabase/migrations/20260824010000_rectification_inference_transition_ledger.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.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/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
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";
let 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,
);
});