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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user