fix(web): persist rectification C/D answers on an append-only inference ledger
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -368,7 +368,7 @@ test("batch preserves three independent items, parses all outcomes, and keeps it
|
||||
);
|
||||
});
|
||||
|
||||
test("resolve-focus C without new evidence patches inference_state on the latest result", async () => {
|
||||
test("resolve-focus C without new evidence appends an inference transition", async () => {
|
||||
const inference = buildInferenceState({
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
@@ -401,7 +401,7 @@ test("resolve-focus C without new evidence patches inference_state on the latest
|
||||
});
|
||||
const snapshot = candidateSnapshotFixture();
|
||||
snapshot.decision_receipt = { ...snapshot.decision_receipt, inference_state: inference };
|
||||
let patchedState: unknown = null;
|
||||
let appendedState: unknown = null;
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
@@ -416,18 +416,24 @@ test("resolve-focus C without new evidence patches inference_state on the latest
|
||||
option_c: "没有明显发生",
|
||||
option_d: "不记得 / 不确定",
|
||||
},
|
||||
probe_id: "p-cd",
|
||||
semantic_key: "career.2019",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
patch_agentic_rectification_inference_state: (_fn, args) => {
|
||||
patchedState = args.p_inference_state;
|
||||
append_agentic_rectification_inference_transition: (_fn, args) => {
|
||||
appendedState = args.p_inference_state;
|
||||
return {
|
||||
transition_id: "99999999-9999-4999-8999-999999999999",
|
||||
result_id: RESULT_ID,
|
||||
revision: (args.p_expected_revision as number) + 1,
|
||||
idempotent: false,
|
||||
decision_state_fingerprint: args.p_decision_state_fingerprint,
|
||||
decision_receipt: {
|
||||
...snapshot.decision_receipt,
|
||||
inference_state: args.p_inference_state,
|
||||
decision_state_fingerprint: args.p_decision_state_fingerprint,
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -448,11 +454,19 @@ test("resolve-focus C without new evidence patches inference_state on the latest
|
||||
assert.equal(resolved.status, "declined");
|
||||
assert.equal(resolved.evidence_id, null);
|
||||
assert.ok(resolved.inference_state);
|
||||
assert.ok(patchedState);
|
||||
const answers = (patchedState as { answered_probes?: Array<{ answer_class?: string; semantic_key?: string }> }).answered_probes ?? [];
|
||||
assert.ok(appendedState);
|
||||
const answers = (appendedState as { answered_probes?: Array<{ answer_class?: string; semantic_key?: string }> }).answered_probes ?? [];
|
||||
assert.equal(answers.some((item) => item.semantic_key === "career.2019" && item.answer_class === "no"), true);
|
||||
const appendCall = accounting.calls.find((call) => call.fn === "append_agentic_rectification_inference_transition");
|
||||
assert.ok(appendCall);
|
||||
assert.equal(appendCall.args.p_probe_id, "p-cd");
|
||||
assert.equal(appendCall.args.p_reason, "choice");
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "persist_agentic_rectification_candidate_v2"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
accounting.calls.some((call) => call.fn === "patch_agentic_rectification_inference_state"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -456,10 +456,26 @@ test("a failed opening does not let the next turn skip the server Skill load gat
|
||||
});
|
||||
|
||||
test("same evidence + range fingerprints reuse the cached candidate snapshot", async () => {
|
||||
const inputReceipt = {
|
||||
receipt_version: "candidate-decision-receipt-v2",
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
representative_candidate_id: null,
|
||||
overall_confidence: "medium",
|
||||
inference_state: { revision: 1, candidates: [{ id: "05:02", posterior_score: 10 }] },
|
||||
};
|
||||
const composedReceipt = {
|
||||
...inputReceipt,
|
||||
inference_state: { revision: 2, candidates: [{ id: "05:02", posterior_score: 18 }] },
|
||||
decision_state_fingerprint: "a".repeat(64),
|
||||
};
|
||||
const accounting = fakeAccounting({
|
||||
persist_agentic_rectification_candidate_v2: () => ({
|
||||
...candidateSnapshotFixture(),
|
||||
cached: true,
|
||||
decision_receipt: composedReceipt,
|
||||
}),
|
||||
});
|
||||
const cached = await persistV9Candidate(accounting.client, USER_ID, CASE_ID, {
|
||||
@@ -472,15 +488,7 @@ test("same evidence + range fingerprints reuse the cached candidate snapshot", a
|
||||
eventContractVersion: "rectification-event-contract-v2",
|
||||
policyVersion: "rectification-candidate-policy-v2",
|
||||
candidates: [{ candidateId: CANDIDATE_ID, rank: 1, time: "05:02", relativeSupport: 58, tiedMinuteCount: 2 }],
|
||||
decisionReceipt: {
|
||||
receipt_version: "candidate-decision-receipt-v2",
|
||||
policy_version: "rectification-candidate-policy-v2",
|
||||
selection_allowed: true,
|
||||
acceptance_allowed: true,
|
||||
confirmation_allowed: false,
|
||||
representative_candidate_id: null,
|
||||
overall_confidence: "medium",
|
||||
},
|
||||
decisionReceipt: inputReceipt,
|
||||
executionLedger: [{ method: "d1-rashi", status: "executed" }],
|
||||
});
|
||||
assert.equal(cached.cached, true);
|
||||
@@ -491,12 +499,20 @@ test("same evidence + range fingerprints reuse the cached candidate snapshot", a
|
||||
assert.equal(call.args.p_skill_version, "9.0.0");
|
||||
assert.equal(call.args.p_event_contract_version, "rectification-event-contract-v2");
|
||||
assert.equal(call.args.p_decision_policy_version, "rectification-candidate-policy-v2");
|
||||
assert.deepEqual(call.args.p_decision_receipt, cached.decisionReceipt);
|
||||
assert.deepEqual(call.args.p_decision_receipt, inputReceipt);
|
||||
assert.notDeepEqual(cached.decisionReceipt, inputReceipt);
|
||||
assert.deepEqual(cached.decisionReceipt, composedReceipt);
|
||||
assert.equal(
|
||||
(cached.decisionReceipt.inference_state as { revision?: number }).revision,
|
||||
2,
|
||||
);
|
||||
assert.equal(cached.decisionReceipt.decision_state_fingerprint, "a".repeat(64));
|
||||
assert.deepEqual(call.args.p_execution_ledger, cached.executionLedger);
|
||||
assert.equal("p_selection_allowed" in call.args, false);
|
||||
assert.equal("p_confirmation_allowed" in call.args, false);
|
||||
assert.equal("p_representative_time" in call.args, false);
|
||||
assert.equal("p_margin_percent" in call.args, false);
|
||||
assert.equal("p_decision_state_fingerprint" in call.args, false);
|
||||
});
|
||||
|
||||
test("accept-candidate requires a server-persisted result; no tool means no minute", async () => {
|
||||
|
||||
@@ -664,4 +664,16 @@ test("rpc errors map to safe public views without leaking database text", async
|
||||
const publicLegacy = mapRectificationRpcError(new Error("agentic_rectification_skill_identity_unverifiable"));
|
||||
assert.equal(publicLegacy.status, 409);
|
||||
assert.equal(publicLegacy.code, "skill_identity_unverifiable");
|
||||
|
||||
const staleProbe = mapRectificationRpcError(new Error("agentic_rectification_stale_probe"));
|
||||
assert.equal(staleProbe.status, 409);
|
||||
assert.equal(staleProbe.code, "stale_probe");
|
||||
|
||||
const revisionConflict = mapRectificationRpcError(new Error("agentic_rectification_revision_conflict"));
|
||||
assert.equal(revisionConflict.status, 409);
|
||||
assert.equal(revisionConflict.code, "revision_conflict");
|
||||
|
||||
const patchRetired = mapRectificationRpcError(new Error("agentic_rectification_inference_patch_retired"));
|
||||
assert.equal(patchRetired.status, 409);
|
||||
assert.equal(patchRetired.code, "inference_patch_retired");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user