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
co-authored by Cursor
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";