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
@@ -50,11 +50,12 @@ export function buildInferenceState(input: {
input.range_end,
input.candidates.map((item) => item.time),
);
const previous = input.previous?.candidate_set_id === setId ? input.previous : null;
const sameSet = input.previous?.candidate_set_id === setId;
const previous = sameSet ? input.previous : null;
const events = splitHoldoutEvents(input.events);
const prior = Object.fromEntries(input.candidates.map((item) => [item.id, item.relative_support]));
const trainingPrior = subtractHoldout(prior, input.candidates, events, input.event_ledger);
const answers = mergeAnswers(previous?.answered_probes ?? [], input.answered_probes ?? []);
const answers = mergeAnswers(input.previous?.answered_probes ?? [], input.answered_probes ?? []);
const seenRoundIds = new Set((previous?.rounds ?? []).map((item) => item.probe_id));
const eliminated = new Set(
(previous?.candidates ?? []).filter((item) => item.status === "eliminated").map((item) => item.id),
@@ -65,7 +66,9 @@ export function buildInferenceState(input: {
for (const answer of answers) {
if (answer.answer_class === "yes") continue;
const probe = input.probes.find((item) => item.id === answer.probe_id)
?? previous?.probes.find((item) => item.id === answer.probe_id);
?? input.probes.find((item) => item.semantic_key === answer.semantic_key)
?? input.previous?.probes.find((item) => item.id === answer.probe_id)
?? input.previous?.probes.find((item) => item.semantic_key === answer.semantic_key);
if (!probe) continue;
const before = { ...scores };
const applied = applyProbeOutcome(scores, probe, answer.answer_class, { eliminatedIds: eliminated });
@@ -130,13 +133,13 @@ export function buildInferenceState(input: {
&& item.cluster_range[1] === top.cluster_range[1]
)),
);
const alreadyAnswered = new Set((previous?.answered_probes ?? []).map((item) => item.probe_id));
const alreadyAnswered = new Set((input.previous?.answered_probes ?? []).map((item) => item.probe_id));
const newAnswerCount = answers.filter((item) => !alreadyAnswered.has(item.probe_id)).length;
const draft: InferenceState = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: setId,
revision: Math.max(1, (previous?.revision ?? 0) + (newAnswerCount > 0 ? 1 : 0)),
phase: input.phase ?? previous?.phase ?? "discrimination",
revision: Math.max(1, (input.previous?.revision ?? 0) + (newAnswerCount > 0 ? 1 : 0)),
phase: input.phase ?? input.previous?.phase ?? "discrimination",
result_status: "discriminating",
range_start: input.range_start,
range_end: input.range_end,
@@ -171,6 +174,45 @@ export function applyAnswerToState(
): InferenceState {
const probe = state.probes.find((item) => item.id === probeId);
if (!probe) return state;
return rebuildWithAnswers(state, [{
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: answer,
classified_from: "choice",
}]);
}
export function applySupersedeAnswer(
state: InferenceState,
probeId: string,
answer: AnswerClass,
): InferenceState {
const live = state.probes.find((item) => item.id === probeId);
const previousAnswer = state.answered_probes.find((item) => item.probe_id === probeId);
const semanticKey = live?.semantic_key ?? previousAnswer?.semantic_key;
const splitHash = live?.candidate_split_hash ?? previousAnswer?.candidate_split_hash;
if (!semanticKey || !splitHash) return state;
const remaining = state.answered_probes.filter((item) => (
item.probe_id !== probeId && item.semantic_key !== semanticKey
));
const rounds = state.rounds.filter((item) => item.probe_id !== probeId);
return rebuildWithAnswers(
{ ...state, answered_probes: remaining, rounds },
[{
probe_id: probeId,
semantic_key: semanticKey,
candidate_split_hash: splitHash,
answer_class: answer,
classified_from: "choice",
}],
);
}
export function replayInferenceState(
state: InferenceState,
answers: readonly ProbeAnswer[],
): InferenceState {
return buildInferenceState({
range_start: state.range_start,
range_end: state.range_end,
@@ -181,14 +223,8 @@ export function applyAnswerToState(
})),
events: state.events,
probes: state.probes,
previous: state,
answered_probes: [{
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: answer,
classified_from: "choice",
}],
previous: { ...state, answered_probes: [], rounds: [] },
answered_probes: answers,
});
}
@@ -219,6 +255,22 @@ export function classifyChoiceAnswer(key: string): AnswerClass {
return "unsure";
}
function rebuildWithAnswers(state: InferenceState, incoming: readonly ProbeAnswer[]): InferenceState {
return 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,
answered_probes: incoming,
});
}
function mergeAnswers(previous: readonly ProbeAnswer[], incoming: readonly ProbeAnswer[]): ProbeAnswer[] {
const rows = [...previous];
for (const item of incoming) {
@@ -0,0 +1,48 @@
import type { InferenceState } from "./types.ts";
export type InferenceTransitionSnapshot = Readonly<{
id?: string;
resultId: string;
revision: number;
probeId: string | null;
reason: string;
idempotent?: boolean;
decisionStateFingerprint: string;
inferenceState: InferenceState;
posteriorBefore: Readonly<Record<string, number>>;
posteriorAfter: Readonly<Record<string, number>>;
scoreDeltas: Readonly<Record<string, number>>;
}>;
export function asInferenceState(value: unknown): InferenceState | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const state = value as InferenceState;
return state.algorithm_version && Array.isArray(state.candidates) ? state : null;
}
/**
* Overlay the latest inference revision onto an immutable engine receipt.
* Only apply when the transition was recorded against this engine result.
*/
export function composeInferenceReceipt(
engineReceipt: Readonly<Record<string, unknown>> | null | undefined,
transition: InferenceTransitionSnapshot | null | undefined,
resultId?: string | null,
): Record<string, unknown> {
const receipt = engineReceipt && typeof engineReceipt === "object" && !Array.isArray(engineReceipt)
? { ...engineReceipt }
: {};
if (!transition) return receipt;
if (resultId && transition.resultId && transition.resultId !== resultId) return receipt;
return {
...receipt,
inference_state: transition.inferenceState,
decision_state_fingerprint: transition.decisionStateFingerprint,
};
}
export function previousInferenceFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): InferenceState | null {
return asInferenceState(receipt?.inference_state);
}
@@ -0,0 +1,42 @@
import { createHash } from "node:crypto";
export const DECISION_STATE_FINGERPRINT_VERSION = "v9-decision-state-v1";
export type DecisionStateFingerprintInput = Readonly<{
caseId: string;
evidenceLedgerFingerprint: string;
candidateSetId: string;
inferenceRevision: number;
answeredProbeIds: readonly string[];
scoringPolicyVersion: string;
}>;
export function decisionStateFingerprint(input: DecisionStateFingerprintInput): string {
return createHash("sha256")
.update([
DECISION_STATE_FINGERPRINT_VERSION,
input.caseId,
input.evidenceLedgerFingerprint,
input.candidateSetId,
String(input.inferenceRevision),
[...input.answeredProbeIds].sort().join(","),
input.scoringPolicyVersion,
].join("|"))
.digest("hex");
}
export function posteriorMap(
candidates: readonly Readonly<{ id: string; posterior_score: number }>[],
): Record<string, number> {
return Object.fromEntries(candidates.map((item) => [item.id, item.posterior_score]));
}
export function scoreDeltas(
before: Readonly<Record<string, number>>,
after: Readonly<Record<string, number>>,
): Record<string, number> {
const ids = new Set([...Object.keys(before), ...Object.keys(after)]);
return Object.fromEntries(
[...ids].map((id) => [id, (after[id] ?? 0) - (before[id] ?? 0)]),
);
}
@@ -8,3 +8,5 @@ export * from "./split-holdout.ts";
export * from "./convergence-evaluator.ts";
export * from "./build-state.ts";
export * from "./probes-from-engine.ts";
export * from "./decision-fingerprint.ts";
export * from "./compose-receipt.ts";
@@ -261,6 +261,9 @@ const KNOWN_RPC_ERROR_CODES = new Map<string, { status: number; code: string; me
["agentic_rectification_evidence_not_confirmable", { status: 409, code: "evidence_not_confirmable", message: "该事件当前不能确认" }],
["agentic_rectification_evidence_not_revisable", { status: 409, code: "evidence_not_revisable", message: "该事件当前不能修订" }],
["agentic_rectification_precision_downgrade", { status: 422, code: "precision_downgrade", message: "不能把已确认的更细日期精度改粗" }],
["agentic_rectification_stale_probe", { status: 409, code: "stale_probe", message: "这道区分题已经过期,请回答当前问题" }],
["agentic_rectification_revision_conflict", { status: 409, code: "revision_conflict", message: "推断状态已更新,请刷新后再试" }],
["agentic_rectification_inference_patch_retired", { status: 409, code: "inference_patch_retired", message: "不能再原地修改推断回执" }],
]);
export type RectificationServiceErrorView = {
@@ -1,8 +1,10 @@
import {
answersFromEvidence,
applyAnswerToState,
applySupersedeAnswer,
buildInferenceState,
classifyChoiceAnswer,
nextProbe,
type EngineEventInput,
} from "../core/build-state.ts";
import { isDuplicateProbe } from "../core/duplicate-probes.ts";
@@ -44,14 +46,7 @@ export function askedProbeKeysFromReceipt(
return keys;
}
export function previousInferenceFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): InferenceState | null {
const value = receipt?.inference_state;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const state = value as InferenceState;
return state.algorithm_version && Array.isArray(state.candidates) ? state : null;
}
export { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
export function compactInferenceProjection(state: InferenceState | null | undefined): Record<string, unknown> | null {
if (!state) return null;
@@ -179,8 +174,7 @@ export function matchProbeForChoice(
const splitHash = asText(row?.candidate_split_hash);
const probes = state.probes;
if (probeId) {
const found = probes.find((item) => item.id === probeId);
if (found) return found;
return probes.find((item) => item.id === probeId) ?? null;
}
if (semanticKey) {
const found = probes.find((item) => item.semantic_key === semanticKey);
@@ -215,7 +209,7 @@ export function stampChoiceSchemaWithProbe(
export type ChoiceWithoutEvidenceResult = Readonly<{
applied: boolean;
reason: "applied" | "no_choice" | "holdout" | "no_probe" | "already_answered";
reason: "applied" | "no_choice" | "holdout" | "no_probe" | "already_answered" | "stale_probe" | "superseded";
state: InferenceState;
answerClass: AnswerClass | null;
probeId: string | null;
@@ -242,14 +236,41 @@ export function applyChoiceWithoutEvidence(
if (!choiceKey) {
return { applied: false, reason: "no_choice", state, answerClass: null, probeId: null };
}
const submittedProbeId = asText(asRecord(input.schema)?.probe_id);
const probe = matchProbeForChoice(state, input.schema, input.domain);
if (!probe) {
return { applied: false, reason: "no_probe", state, answerClass: null, probeId: null };
return { applied: false, reason: submittedProbeId ? "stale_probe" : "no_probe", state, answerClass: null, probeId: submittedProbeId };
}
const openProbeId = nextProbe(state)?.id ?? null;
const lastAnsweredId = state.answered_probes.at(-1)?.probe_id ?? null;
const answerClass = classifyChoiceAnswer(choiceKey);
if (
(submittedProbeId && submittedProbeId !== openProbeId && submittedProbeId !== lastAnsweredId)
|| (openProbeId && probe.id !== openProbeId && probe.id !== lastAnsweredId)
) {
return { applied: false, reason: "stale_probe", state, answerClass, probeId: probe.id };
}
const existing = state.answered_probes.find((item) => (
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
));
if (existing) {
if (existing.answer_class === answerClass) {
return { applied: false, reason: "already_answered", state, answerClass, probeId: probe.id };
}
if (probe.id === lastAnsweredId) {
return {
applied: true,
reason: "superseded",
state: applySupersedeAnswer(state, probe.id, answerClass),
answerClass,
probeId: probe.id,
};
}
return { applied: false, reason: "stale_probe", state, answerClass, probeId: probe.id };
}
if (isDuplicateProbe(probe, state.answered_probes)) {
return { applied: false, reason: "already_answered", state, answerClass: classifyChoiceAnswer(choiceKey), probeId: probe.id };
return { applied: false, reason: "already_answered", state, answerClass, probeId: probe.id };
}
const answerClass = classifyChoiceAnswer(choiceKey);
return {
applied: true,
reason: "applied",
@@ -23,6 +23,12 @@ import {
type PublicRectificationTool,
} from "./public-receipt";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
import {
asInferenceState,
type InferenceTransitionSnapshot,
} from "../core/compose-receipt";
import { INFERENCE_ALGORITHM_VERSION } from "../core/types";
import { decisionStateFingerprint } from "../core/decision-fingerprint";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -1325,27 +1331,124 @@ export async function persistV9Candidate(
};
}
function parseTransitionSnapshot(value: unknown): InferenceTransitionSnapshot | null {
const row = rowObject(value);
const inference = asInferenceState(row?.inference_state);
const resultId = rowText(row?.result_id);
const fingerprint = rowText(row?.decision_state_fingerprint);
const revision = rowNumber(row?.revision);
if (!row || !inference || !resultId || !fingerprint || revision === null) return null;
return {
id: rowText(row.id) ?? undefined,
resultId,
revision,
probeId: rowText(row.probe_id),
reason: String(row.reason ?? "choice"),
idempotent: row.idempotent === true,
decisionStateFingerprint: fingerprint,
inferenceState: inference,
posteriorBefore: rowObject(row.posterior_before) as Record<string, number> ?? {},
posteriorAfter: rowObject(row.posterior_after) as Record<string, number> ?? {},
scoreDeltas: rowObject(row.score_deltas) as Record<string, number> ?? {},
};
}
export async function loadLatestInferenceTransition(
accounting: AccountingClient,
userId: string,
caseId: string,
): Promise<InferenceTransitionSnapshot | null> {
const row = await rpc<unknown>(
accounting,
"get_agentic_rectification_latest_inference_transition",
{ p_user_id: userId, p_case_id: caseId },
);
return parseTransitionSnapshot(row);
}
export type PersistInferenceTransitionInput = Readonly<{
expectedRevision: number;
probeId: string;
openProbeId: string;
semanticKey: string;
candidateSplitHash: string;
answerClass: string;
rawAnswer: string;
inferenceState: Readonly<Record<string, unknown>>;
posteriorBefore: Readonly<Record<string, number>>;
posteriorAfter: Readonly<Record<string, number>>;
scoreDeltas: Readonly<Record<string, number>>;
decisionStateFingerprint: string;
reason: "choice" | "supersede" | "already_answered";
idempotencyKey: string;
candidateSetId: string;
}>;
export async function persistV9InferenceState(
accounting: AccountingClient,
userId: string,
caseId: string,
inferenceState: Readonly<Record<string, unknown>>,
): Promise<Readonly<{ resultId: string; decisionReceipt: Readonly<Record<string, unknown>> }>> {
input: PersistInferenceTransitionInput,
): Promise<Readonly<{
resultId: string;
revision: number;
idempotent: boolean;
decisionReceipt: Readonly<Record<string, unknown>>;
decisionStateFingerprint: string;
}>> {
const row = await rpc<Record<string, unknown>>(
accounting,
"patch_agentic_rectification_inference_state",
"append_agentic_rectification_inference_transition",
{
p_user_id: userId,
p_case_id: caseId,
p_inference_state: inferenceState,
p_expected_revision: input.expectedRevision,
p_probe_id: input.probeId,
p_open_probe_id: input.openProbeId,
p_semantic_key: input.semanticKey,
p_candidate_split_hash: input.candidateSplitHash,
p_answer_class: input.answerClass,
p_raw_answer: input.rawAnswer,
p_inference_state: input.inferenceState,
p_posterior_before: input.posteriorBefore,
p_posterior_after: input.posteriorAfter,
p_score_deltas: input.scoreDeltas,
p_decision_state_fingerprint: input.decisionStateFingerprint,
p_reason: input.reason,
p_idempotency_key: input.idempotencyKey,
p_candidate_set_id: input.candidateSetId,
},
);
const resultId = rowText(row.result_id);
const decisionReceipt = rowObject(row.decision_receipt);
if (!resultId || !decisionReceipt) {
throw new RectificationToolServiceError("invalid_inference_patch");
const revision = rowNumber(row.revision);
const fingerprint = rowText(row.decision_state_fingerprint) ?? input.decisionStateFingerprint;
if (!resultId || !decisionReceipt || revision === null) {
throw new RectificationToolServiceError("invalid_inference_transition");
}
return { resultId, decisionReceipt };
return {
resultId,
revision,
idempotent: row.idempotent === true,
decisionReceipt,
decisionStateFingerprint: fingerprint,
};
}
export function inferenceFingerprintForState(
caseId: string,
evidenceLedgerFingerprint: string,
state: { candidate_set_id: string; revision: number; answered_probes: readonly { probe_id: string }[] },
scoringPolicyVersion = INFERENCE_ALGORITHM_VERSION,
): string {
return decisionStateFingerprint({
caseId,
evidenceLedgerFingerprint,
candidateSetId: state.candidate_set_id,
inferenceRevision: state.revision,
answeredProbeIds: state.answered_probes.map((item) => item.probe_id),
scoringPolicyVersion,
});
}
export type V9AcceptResult = Readonly<{
@@ -1504,6 +1607,9 @@ export function safeToolErrorCode(error: unknown): string {
"precision_downgrade",
"offer_not_allowed",
"no_candidate_result",
"stale_probe",
"revision_conflict",
"inference_patch_retired",
];
if (error instanceof RectificationToolServiceError && known.includes(error.code)) {
return error.code;
+53 -3
View File
@@ -29,6 +29,7 @@ import {
transitionV9CaseStatus,
persistV9Candidate,
persistV9InferenceState,
inferenceFingerprintForState,
acceptV9Candidate,
confirmV9BirthTime,
closeV9Case,
@@ -71,6 +72,10 @@ import {
previousInferenceFromReceipt,
stampChoiceSchemaWithProbe,
} from "@/lib/rectification-agentic/v9/inference-adapter";
import {
posteriorMap,
scoreDeltas,
} from "@/lib/rectification-agentic/core/decision-fingerprint";
import { buildSkillVerificationReport } from "@/lib/rectification-agentic/v9/skill-verification-report";
import {
internalObservationsFromWindowScan,
@@ -827,19 +832,64 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
questionId: focus.questionId,
domain: focus.targetDomain,
});
if (applied.applied) {
if (applied.reason === "stale_probe") {
throw new RectificationToolServiceError("stale_probe");
}
const persistable = applied.applied
|| applied.reason === "already_answered"
|| applied.reason === "superseded";
if (persistable && applied.probeId && applied.answerClass) {
const probe = applied.state.probes.find((item) => item.id === applied.probeId)
?? previous.probes.find((item) => item.id === applied.probeId);
const schemaProbeId = typeof focus.expectedAnswerSchema?.probe_id === "string"
? focus.expectedAnswerSchema.probe_id
: applied.probeId;
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
const fingerprint = inferenceFingerprintForState(
input.caseId,
evidenceFp,
applied.state,
);
const persisted = await persistV9InferenceState(
accounting,
userId,
input.caseId,
applied.state as unknown as Record<string, unknown>,
{
expectedRevision: previous.revision,
probeId: applied.probeId,
openProbeId: schemaProbeId,
semanticKey: probe?.semantic_key ?? "",
candidateSplitHash: probe?.candidate_split_hash ?? "",
answerClass: applied.answerClass,
rawAnswer: input.choiceKey ?? applied.answerClass,
inferenceState: applied.state as unknown as Record<string, unknown>,
posteriorBefore: posteriorMap(previous.candidates),
posteriorAfter: posteriorMap(applied.state.candidates),
scoreDeltas: scoreDeltas(
posteriorMap(previous.candidates),
posteriorMap(applied.state.candidates),
),
decisionStateFingerprint: fingerprint,
reason: applied.reason === "superseded"
? "supersede"
: applied.reason === "already_answered"
? "already_answered"
: "choice",
idempotencyKey: `${applied.reason === "superseded" ? "supersede" : "choice"}:${applied.probeId}:${applied.answerClass}`,
candidateSetId: applied.state.candidate_set_id,
},
);
inferenceProjection = compactInferenceProjection(
previousInferenceFromReceipt(persisted.decisionReceipt) ?? applied.state,
);
}
}
} catch {
} catch (error) {
if (error instanceof RectificationToolServiceError) {
const code = safeToolErrorCode(error);
if (code === "stale_probe" || code === "revision_conflict") throw error;
}
inferenceProjection = null;
}
}