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
+21 -5
View File
@@ -5487,15 +5487,31 @@
- 状态:resolved
- 首次发现:2026-08-23
- 最近更新:2026-08-23
- 最近更新:2026-08-24
- 影响面:`rectification-agentic/core``method_followup_plan``discriminating_event_probes``rectification-v9-tools`、decision receipt `inference_state`
- 用户现象:纠正会话一直在收集经历并提问,但候选时间不固定、不淘汰,轮次增加后仍给不出可信区间或代表性时间。覆盖齐后低信息探针不再挡出牌,高信息未答探针仍可能挡出牌。
- 触发条件:出生时间范围为若干分钟;已有带日期事件;引擎给出多枚候选;Agent 继续用自然语言追问。
- 根因:收集、提问、打分和收口都挤在对话 Agent 里。没有版本化候选集、事件×候选评分账本、由候选差异算出的探针,也没有统一收敛评估。Prompt 要求“提出冲突问题”并不更新后验;职业/领域覆盖被误当成收敛。
- 修复:新增服务端推断状态机:固定 `candidate_set_id`、事件分层 holdout、等价分钟聚类、纯函数 `applyProbeOutcome`、按信息增益选探针、统一 `evaluateConvergence`。分数只由 reducer 更新。Python 探针带 `information_gain` / `expected_outcomes`;followup 只改写程序给出的探针。decision receipt 持久化 `inference_state`。点选 C/D/B 且没有新证据时,`rectification-resolve-focus` 当场把后验写入最新 result 的 `inference_state`,不再等下一次带日期事件重算。高信息未答探针在覆盖齐后仍可挡出牌;无 `information_gain` 的旧探针保持 BUG-361 不挡出牌。无法区分时返回可信区间,最大轮次不是 `converged`
- 验证:`frontend/tests/rectification-inference-machine.test.ts` 锁定有效回答降低熵、淘汰不可复活、重复切分禁用、最大轮次≠成功、等价分钟返回区间、holdout 不参与训练、赢家需两轮稳定、C 无新证据立刻更新后验、D 只记录已问切分、盘外核对与收集拒答不写探针;`frontend/tests/rectification-v10-conversation-focus.test.ts` 锁定 resolve-focus C 走 `patch_agentic_rectification_inference_state` 而不是候选缓存;`frontend/tests/rectification-eight-method.test.ts` 锁定同领域不同年份仍问冲突探针、高信息探针覆盖后仍挡出牌、无增益探针覆盖后不挡出牌。
- 防复发:禁止 Agent 直接改候选分数或宣布收敛。禁止把方法覆盖或职业笔记当成 `result_status=converged`。同一 `semantic_key` / `candidate_split_hash` 不得连问。淘汰候选不得在同一 `candidate_set_id` 复活。最大轮次只能是 `max_rounds_reached`。点选 C/D/B 且没有新证据时必须走 `patch_agentic_rectification_inference_state` 当场写入 `decision_receipt.inference_state`,禁止走候选 persist RPC 的证据指纹缓存。盘外核对与收集阶段拒答不得写探针。下一轮 followup 必须读已答切分,避免把同一探针再问一遍。不得改已哈希 Skill `10.0.11`
- 相关记录:BUG-361、BUG-351
- 修复:新增服务端推断状态机:固定 `candidate_set_id`、事件分层 holdout、等价分钟聚类、纯函数 `applyProbeOutcome`、按信息增益选探针、统一 `evaluateConvergence`。分数只由 reducer 更新。Python 探针带 `information_gain` / `expected_outcomes`;followup 只改写程序给出的探针。decision receipt 持久化 `inference_state`。点选 C/D/B 且没有新证据时,`rectification-resolve-focus` 当场把后验写入追加的推断 transition,读路径 overlay 到引擎回执,不再等下一次带日期事件重算。高信息未答探针在覆盖齐后仍可挡出牌;无 `information_gain` 的旧探针保持 BUG-361 不挡出牌。无法区分时返回可信区间,最大轮次不是 `converged`
- 验证:`frontend/tests/rectification-inference-machine.test.ts` 锁定有效回答降低熵、淘汰不可复活、重复切分禁用、最大轮次≠成功、等价分钟返回区间、holdout 不参与训练、赢家需两轮稳定、C 无新证据立刻更新后验、D 只记录已问切分、盘外核对与收集拒答不写探针;`frontend/tests/rectification-v10-conversation-focus.test.ts` 锁定 resolve-focus C 走 `append_agentic_rectification_inference_transition` 而不是候选缓存或原地 patch`frontend/tests/rectification-eight-method.test.ts` 锁定同领域不同年份仍问冲突探针、高信息探针覆盖后仍挡出牌、无增益探针覆盖后不挡出牌。
- 防复发:禁止 Agent 直接改候选分数或宣布收敛。禁止把方法覆盖或职业笔记当成 `result_status=converged`。同一 `semantic_key` / `candidate_split_hash` 不得连问。淘汰候选不得在同一 `candidate_set_id` 复活。最大轮次只能是 `max_rounds_reached`。点选 C/D/B 且没有新证据时必须走 `append_agentic_rectification_inference_transition` 追加不可变 transition,禁止 `jsonb_set` 已落库的引擎 `decision_receipt`,禁止走候选 persist RPC 的证据指纹缓存来写后验。读路径必须 overlay 最新 transition。盘外核对与收集阶段拒答不得写探针。下一轮 followup 必须读已答切分,避免把同一探针再问一遍。不得改已哈希 Skill `10.0.11`
- 相关记录:BUG-361、BUG-351、BUG-363
- 复发自:BUG-361(覆盖门槛和出牌行为修了,但仍没有候选后验更新闭环)
- 修复版本:`29750d38`
## BUG-363 | 生时纠正推断后验原地改 receiptpersist-v2 只认 evidence fingerprint
- 状态:resolved
- 首次发现:2026-08-24
- 最近更新:2026-08-24
- 影响面:`agentic_rectification_inference_transitions``append_agentic_rectification_inference_transition``compose_agentic_rectification_decision_receipt``persist_agentic_rectification_candidate_v2``get_agentic_rectification_case_dossier``rectification-resolve-focus`
- 用户现象:C/D 回答可以改后验,但数据库里只剩更新后的分数,无法回放哪次回答改变了它;证据没变时 persist-v2 仍可能把旧 receipt 当成当前世界。
- 触发条件:点选 C/D 且没有新增带日期证据;随后读 Case、命中 persist-v2 缓存、刷新或重试同一回答。
- 根因:BUG-362 热修复用 `patch_agentic_rectification_inference_state` 对最新 `decision_receipt.inference_state``jsonb_set`。执行回执被原地修改。缓存键仍是 evidence/range/policy fingerprint,不含推断修订。
- 修复:追加不可变 `agentic_rectification_inference_transitions`。C/D 走 `append_agentic_rectification_inference_transition`(乐观锁 `expected_revision``stale_probe`、幂等键)。引擎结果行不再被 patch。persist-v2 HIT/MISS 与 dossier/get_case 用 `compose_agentic_rectification_decision_receipt` overlay 与当前 `result_id` 匹配的最新 transition。`decision_state_fingerprint` 存在 transition 上,不进入 persist-v2 等值匹配。`patch_agentic_rectification_inference_state` 改为 `inference_patch_retired`。不改已哈希 Skill `10.0.11`。P0 inference ledger implemented, verification pending。
- 验证:`frontend/tests/rectification-inference-machine.test.ts` 锁定 C 立刻改后验且 `revision+1`、evidence fingerprint 不变时 decision-state fingerprint 与 posterior 变、重复 D 幂等、C→D 新 revision 且旧 transition 仍在、旧 probe 为 `stale_probe`、过期 revision 为 `revision_conflict`、reducer replay 等于当前 posterior、候选集变化的 persist-v2 MISS 仍 replay 已答探针、append SQL 不 UPDATE 引擎行;`frontend/tests/rectification-v10-conversation-focus.test.ts` 锁定 resolve-focus C 走 append RPC`frontend/tests/rectification-v9-agent.test.ts` 锁定 persist-v2 查找键不含 `p_decision_state_fingerprint`HIT 返回 overlay 后的 `inference_state``frontend/tests/rectification-v9-case-service.test.ts` 锁定 409 `stale_probe` / `revision_conflict` / `inference_patch_retired`。P0 inference ledger implemented, verification pending。
- 防复发:禁止 `jsonb_set` 已落库引擎 `decision_receipt` 来写后验。禁止把 inference revision 加进 persist-v2 等值匹配。有效 C/D 必须追加 transition。读 `inference_state` 必须 overlay 最新且 `result_id` 匹配的 transition。重复提交必须幂等。过期 `probe_id` 必须 409,不得改当前 posterior。不得改已哈希 Skill `10.0.11`
- 相关记录:BUG-362
- 复发自:BUG-362(C/D 写入后验修了,但用原地 patch 绕过缓存,receipt 不可审计)
- 修复版本:待发布
@@ -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;
}
}
@@ -0,0 +1,892 @@
-- Append-only rectification inference ledger. Engine result rows stay immutable.
-- C/D answers insert a transition and overlay inference_state on read.
-- Business schema only; do not copy into frontend/db/migrations (BUG-127 / BUG-144).
begin;
create table if not exists public.agentic_rectification_inference_transitions (
id uuid primary key default gen_random_uuid(),
case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade,
result_id uuid not null references public.agentic_rectification_results(id) on delete cascade,
previous_revision integer not null check (previous_revision >= 0),
revision integer not null check (revision > previous_revision),
candidate_set_id text not null check (length(btrim(candidate_set_id)) > 0),
probe_id text,
semantic_key text,
candidate_split_hash text,
answer_class text check (answer_class is null or answer_class in ('yes', 'weak_yes', 'no', 'unsure')),
raw_answer text,
posterior_before jsonb not null default '{}'::jsonb check (jsonb_typeof(posterior_before) = 'object'),
posterior_after jsonb not null default '{}'::jsonb check (jsonb_typeof(posterior_after) = 'object'),
score_deltas jsonb not null default '{}'::jsonb check (jsonb_typeof(score_deltas) = 'object'),
inference_state jsonb not null check (jsonb_typeof(inference_state) = 'object'),
decision_state_fingerprint text not null check (decision_state_fingerprint ~ '^[0-9a-f]{64}$'),
reason text not null check (reason in ('choice', 'supersede', 'already_answered')),
idempotency_key text not null check (length(btrim(idempotency_key)) > 0),
receipt_after jsonb not null check (jsonb_typeof(receipt_after) = 'object'),
created_at timestamptz not null default pg_catalog.now(),
unique (case_id, revision),
unique (case_id, idempotency_key)
);
create index if not exists agentic_rectification_inference_transitions_case_revision_idx
on public.agentic_rectification_inference_transitions (case_id, revision desc);
create index if not exists agentic_rectification_inference_transitions_result_idx
on public.agentic_rectification_inference_transitions (result_id, revision desc);
alter table public.agentic_rectification_inference_transitions enable row level security;
revoke all on table public.agentic_rectification_inference_transitions
from public, anon, authenticated;
grant all on table public.agentic_rectification_inference_transitions to service_role;
create or replace function public.compose_agentic_rectification_decision_receipt(
p_case_id uuid,
p_result_id uuid,
p_receipt jsonb
)
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_transition public.agentic_rectification_inference_transitions%rowtype;
v_receipt jsonb;
begin
v_receipt := case
when p_receipt is not null and jsonb_typeof(p_receipt) = 'object' then p_receipt
else '{}'::jsonb
end;
if p_case_id is null or p_result_id is null then
return v_receipt;
end if;
select * into v_transition
from public.agentic_rectification_inference_transitions
where case_id = p_case_id
and result_id = p_result_id
order by revision desc
limit 1;
if not found then
return v_receipt;
end if;
return jsonb_set(
jsonb_set(v_receipt, '{inference_state}', v_transition.inference_state, true),
'{decision_state_fingerprint}',
to_jsonb(v_transition.decision_state_fingerprint),
true
);
end;
$$;
revoke all on function public.compose_agentic_rectification_decision_receipt(uuid, uuid, jsonb)
from public, anon, authenticated;
grant execute on function public.compose_agentic_rectification_decision_receipt(uuid, uuid, jsonb)
to service_role;
create or replace function public.append_agentic_rectification_inference_transition(
p_user_id uuid,
p_case_id uuid,
p_expected_revision integer,
p_probe_id text,
p_open_probe_id text,
p_semantic_key text,
p_candidate_split_hash text,
p_answer_class text,
p_raw_answer text,
p_inference_state jsonb,
p_posterior_before jsonb,
p_posterior_after jsonb,
p_score_deltas jsonb,
p_decision_state_fingerprint text,
p_reason text,
p_idempotency_key text,
p_candidate_set_id text
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_result public.agentic_rectification_results%rowtype;
v_existing public.agentic_rectification_inference_transitions%rowtype;
v_current_revision integer;
v_next_revision integer;
v_receipt jsonb;
v_id uuid;
v_focus_probe_id text;
v_last_probe_id text;
begin
if p_user_id is null or p_case_id is null
or p_expected_revision is null or p_expected_revision < 0
or p_inference_state is null or jsonb_typeof(p_inference_state) <> 'object'
or p_posterior_before is null or jsonb_typeof(p_posterior_before) <> 'object'
or p_posterior_after is null or jsonb_typeof(p_posterior_after) <> 'object'
or p_score_deltas is null or jsonb_typeof(p_score_deltas) <> 'object'
or p_decision_state_fingerprint is null
or p_decision_state_fingerprint !~ '^[0-9a-f]{64}$'
or p_reason is null or p_reason not in ('choice', 'supersede', 'already_answered')
or length(btrim(coalesce(p_idempotency_key, ''))) = 0
or length(btrim(coalesce(p_candidate_set_id, ''))) = 0
or p_answer_class is null or p_answer_class not in ('yes', 'weak_yes', 'no', 'unsure')
or length(btrim(coalesce(p_probe_id, ''))) = 0 then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id
for update;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
end if;
select * into v_result
from public.agentic_rectification_results
where case_id = p_case_id
and invalidated_at is null
order by created_at desc
limit 1
for update;
if not found then
raise exception 'agentic_rectification_result_not_found' using errcode = 'P0001';
end if;
if v_result.decision_receipt is null
or jsonb_typeof(v_result.decision_receipt) <> 'object' then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
select * into v_existing
from public.agentic_rectification_inference_transitions
where case_id = p_case_id
and idempotency_key = p_idempotency_key;
if found then
v_receipt := public.compose_agentic_rectification_decision_receipt(
p_case_id, v_result.id, v_result.decision_receipt
);
return jsonb_build_object(
'transition_id', v_existing.id,
'result_id', v_result.id,
'revision', v_existing.revision,
'idempotent', true,
'decision_receipt', v_receipt,
'decision_state_fingerprint', v_existing.decision_state_fingerprint
);
end if;
select coalesce(max(revision), 0) into v_current_revision
from public.agentic_rectification_inference_transitions
where case_id = p_case_id;
v_current_revision := greatest(
v_current_revision,
coalesce((v_result.decision_receipt -> 'inference_state' ->> 'revision')::integer, 0)
);
if p_expected_revision is distinct from v_current_revision then
raise exception 'agentic_rectification_revision_conflict' using errcode = 'P0001';
end if;
if p_probe_id is distinct from nullif(btrim(coalesce(p_open_probe_id, '')), '')
and p_reason is distinct from 'supersede' then
raise exception 'agentic_rectification_stale_probe' using errcode = 'P0001';
end if;
select nullif(btrim(coalesce(v_focus.expected_answer_schema ->> 'probe_id', '')), '')
into v_focus_probe_id
from public.agentic_rectification_conversation_focuses v_focus
where v_focus.case_id = p_case_id
and v_focus.status = 'active'
order by v_focus.asked_at desc, v_focus.id desc
limit 1;
if v_focus_probe_id is not null and p_probe_id is distinct from v_focus_probe_id then
if p_reason is distinct from 'supersede' then
raise exception 'agentic_rectification_stale_probe' using errcode = 'P0001';
end if;
select probe_id into v_last_probe_id
from public.agentic_rectification_inference_transitions
where case_id = p_case_id
order by revision desc
limit 1;
if p_probe_id is distinct from v_last_probe_id then
raise exception 'agentic_rectification_stale_probe' using errcode = 'P0001';
end if;
end if;
v_next_revision := v_current_revision + 1;
v_receipt := jsonb_set(
jsonb_set(v_result.decision_receipt, '{inference_state}', p_inference_state, true),
'{decision_state_fingerprint}',
to_jsonb(p_decision_state_fingerprint),
true
);
insert into public.agentic_rectification_inference_transitions (
case_id, result_id, previous_revision, revision, candidate_set_id,
probe_id, semantic_key, candidate_split_hash, answer_class, raw_answer,
posterior_before, posterior_after, score_deltas, inference_state,
decision_state_fingerprint, reason, idempotency_key, receipt_after
) values (
p_case_id, v_result.id, p_expected_revision, v_next_revision, p_candidate_set_id,
p_probe_id, nullif(btrim(coalesce(p_semantic_key, '')), ''),
nullif(btrim(coalesce(p_candidate_split_hash, '')), ''),
p_answer_class, nullif(btrim(coalesce(p_raw_answer, '')), ''),
p_posterior_before, p_posterior_after, p_score_deltas, p_inference_state,
p_decision_state_fingerprint, p_reason, p_idempotency_key, v_receipt
) returning id into v_id;
return jsonb_build_object(
'transition_id', v_id,
'result_id', v_result.id,
'revision', v_next_revision,
'idempotent', false,
'decision_receipt', v_receipt,
'decision_state_fingerprint', p_decision_state_fingerprint
);
end;
$$;
revoke all on function public.append_agentic_rectification_inference_transition(
uuid, uuid, integer, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text, text
) from public, anon, authenticated;
grant execute on function public.append_agentic_rectification_inference_transition(
uuid, uuid, integer, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text, text
) to service_role;
create or replace function public.get_agentic_rectification_latest_inference_transition(
p_user_id uuid,
p_case_id uuid
)
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_transition public.agentic_rectification_inference_transitions%rowtype;
begin
if p_user_id is null or p_case_id is null then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
select * into v_transition
from public.agentic_rectification_inference_transitions
where case_id = p_case_id
order by revision desc
limit 1;
if not found then
return null;
end if;
return jsonb_build_object(
'id', v_transition.id,
'result_id', v_transition.result_id,
'previous_revision', v_transition.previous_revision,
'revision', v_transition.revision,
'probe_id', v_transition.probe_id,
'reason', v_transition.reason,
'decision_state_fingerprint', v_transition.decision_state_fingerprint,
'inference_state', v_transition.inference_state,
'posterior_before', v_transition.posterior_before,
'posterior_after', v_transition.posterior_after,
'score_deltas', v_transition.score_deltas
);
end;
$$;
revoke all on function public.get_agentic_rectification_latest_inference_transition(uuid, uuid)
from public, anon, authenticated;
grant execute on function public.get_agentic_rectification_latest_inference_transition(uuid, uuid)
to service_role;
create or replace function public.list_agentic_rectification_inference_transitions(
p_user_id uuid,
p_case_id uuid
)
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_rows jsonb;
begin
if p_user_id is null or p_case_id is null then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
select coalesce(jsonb_agg(jsonb_build_object(
'id', item.id,
'result_id', item.result_id,
'previous_revision', item.previous_revision,
'revision', item.revision,
'probe_id', item.probe_id,
'semantic_key', item.semantic_key,
'candidate_split_hash', item.candidate_split_hash,
'answer_class', item.answer_class,
'reason', item.reason,
'decision_state_fingerprint', item.decision_state_fingerprint,
'inference_state', item.inference_state,
'posterior_before', item.posterior_before,
'posterior_after', item.posterior_after,
'score_deltas', item.score_deltas
) order by item.revision), '[]'::jsonb)
into v_rows
from public.agentic_rectification_inference_transitions item
where item.case_id = p_case_id;
return v_rows;
end;
$$;
revoke all on function public.list_agentic_rectification_inference_transitions(uuid, uuid)
from public, anon, authenticated;
grant execute on function public.list_agentic_rectification_inference_transitions(uuid, uuid)
to service_role;
create or replace function public.patch_agentic_rectification_inference_state(
p_user_id uuid,
p_case_id uuid,
p_inference_state jsonb
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
begin
raise exception 'agentic_rectification_inference_patch_retired' using errcode = 'P0001';
end;
$$;
create or replace function public.persist_agentic_rectification_candidate_v2(
p_user_id uuid,
p_case_id uuid,
p_engine_result_id text,
p_evidence_ledger_fingerprint text,
p_candidate_range_fingerprint text,
p_skill_version text,
p_algorithm_version text,
p_event_contract_version text,
p_decision_policy_version text,
p_candidate_range jsonb,
p_candidates jsonb,
p_decision_receipt jsonb,
p_execution_ledger jsonb
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_cached public.agentic_rectification_results%rowtype;
v_snapshot jsonb;
v_result_id uuid;
v_candidate jsonb;
v_candidate_id uuid;
v_candidate_time time without time zone;
v_candidate_time_text text;
v_candidate_ordinal integer;
v_saved_candidate jsonb;
v_saved_candidates jsonb := '[]'::jsonb;
v_seen_times text[] := array[]::text[];
v_display_allowed boolean;
v_selection_allowed boolean;
v_confirmation_allowed boolean;
v_overall_confidence text;
v_margin_percent numeric;
v_representative_time_text text;
v_representative_time time without time zone;
v_representative_candidate_id uuid;
v_representative_count integer := 0;
v_saved_decision_receipt jsonb;
begin
if p_user_id is null or p_case_id is null
or length(btrim(coalesce(p_engine_result_id, ''))) = 0
or length(btrim(coalesce(p_evidence_ledger_fingerprint, ''))) = 0
or length(btrim(coalesce(p_candidate_range_fingerprint, ''))) = 0
or length(btrim(coalesce(p_skill_version, ''))) = 0
or length(btrim(coalesce(p_algorithm_version, ''))) = 0
or length(btrim(coalesce(p_event_contract_version, ''))) = 0
or length(btrim(coalesce(p_decision_policy_version, ''))) = 0 then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
if p_candidate_range is null or jsonb_typeof(p_candidate_range) <> 'object' then
raise exception 'agentic_rectification_invalid_candidate_range' using errcode = 'P0001';
end if;
if p_candidates is null or jsonb_typeof(p_candidates) <> 'array'
or jsonb_array_length(p_candidates) = 0 then
raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001';
end if;
if p_decision_receipt is null or jsonb_typeof(p_decision_receipt) <> 'object'
or jsonb_typeof(p_decision_receipt -> 'display_allowed') is distinct from 'boolean'
or jsonb_typeof(p_decision_receipt -> 'accept_allowed') is distinct from 'boolean'
or jsonb_typeof(p_decision_receipt -> 'confirm_allowed') is distinct from 'boolean'
or jsonb_typeof(p_decision_receipt -> 'overall_confidence') is distinct from 'string' then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
if p_execution_ledger is null or jsonb_typeof(p_execution_ledger) <> 'array' then
raise exception 'agentic_rectification_invalid_execution_ledger' using errcode = 'P0001';
end if;
v_display_allowed := (p_decision_receipt ->> 'display_allowed')::boolean;
v_selection_allowed := (p_decision_receipt ->> 'accept_allowed')::boolean;
v_confirmation_allowed := (p_decision_receipt ->> 'confirm_allowed')::boolean;
v_overall_confidence := p_decision_receipt ->> 'overall_confidence';
v_saved_decision_receipt := p_decision_receipt - 'representative_candidate_id';
if v_overall_confidence not in ('low', 'medium', 'high') then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
if (p_decision_receipt ? 'margin_percent')
and jsonb_typeof(p_decision_receipt -> 'margin_percent') not in ('number', 'null') then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
v_margin_percent := case
when jsonb_typeof(p_decision_receipt -> 'margin_percent') = 'number'
then (p_decision_receipt ->> 'margin_percent')::numeric
else null
end;
if v_selection_allowed and not v_display_allowed then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
if v_confirmation_allowed and not v_selection_allowed then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
v_representative_time_text := nullif(btrim(coalesce(p_decision_receipt ->> 'representative_time', '')), '');
if v_representative_time_text is not null then
if v_representative_time_text !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
v_representative_time := v_representative_time_text::time without time zone;
end if;
if v_confirmation_allowed and v_representative_time is null then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id
for update;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
end if;
if v_case.skill_version is distinct from p_skill_version then
raise exception 'agentic_rectification_skill_version_mismatch' using errcode = 'P0001';
end if;
select * into v_cached
from public.agentic_rectification_results
where case_id = p_case_id
and invalidated_at is null
and evidence_ledger_fingerprint = p_evidence_ledger_fingerprint
and candidate_range_fingerprint = p_candidate_range_fingerprint
and skill_version = p_skill_version
and algorithm_version = p_algorithm_version
and event_contract_version = p_event_contract_version
and decision_policy_version = p_decision_policy_version
order by created_at desc
limit 1;
if found then
return jsonb_build_object(
'result_id', v_cached.id,
'cached', true,
'candidates', v_cached.candidates,
'overall_confidence', v_cached.overall_confidence,
'margin_percent', v_cached.margin_percent,
'display_allowed', v_cached.display_allowed,
'selection_allowed', v_cached.selection_allowed,
'confirmation_allowed', v_cached.confirmation_allowed,
'representative_time', v_cached.representative_time,
'algorithm_version', v_cached.algorithm_version,
'event_contract_version', v_cached.event_contract_version,
'decision_policy_version', v_cached.decision_policy_version,
'decision_receipt', public.compose_agentic_rectification_decision_receipt(p_case_id, v_cached.id, v_cached.decision_receipt),
'execution_ledger', v_cached.execution_ledger
);
end if;
v_snapshot := v_case.baseline_birth_snapshot;
insert into public.agentic_rectification_results (
user_id, session_id, case_id,
engine_result_id, canonical_input_hash, algorithm_version,
evidence_ledger_fingerprint, candidate_range_fingerprint, skill_version,
event_contract_version, decision_policy_version,
candidate_range, candidates,
overall_confidence, margin_percent,
display_allowed, selection_allowed, confirmation_allowed, representative_time,
decision_receipt, execution_ledger,
baseline_birth_date, baseline_reported_birth_time, baseline_active_birth_time,
baseline_birth_time_source, baseline_birth_time_period,
baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes,
baseline_latitude, baseline_longitude, baseline_timezone_offset
) values (
v_case.user_id, v_case.session_id, v_case.id,
p_engine_result_id, p_evidence_ledger_fingerprint, p_algorithm_version,
p_evidence_ledger_fingerprint, p_candidate_range_fingerprint, p_skill_version,
p_event_contract_version, p_decision_policy_version,
p_candidate_range, '[]'::jsonb,
v_overall_confidence, v_margin_percent,
v_display_allowed, v_selection_allowed, v_confirmation_allowed, v_representative_time,
v_saved_decision_receipt, p_execution_ledger,
(v_snapshot ->> 'birth_date')::date,
(v_snapshot ->> 'reported_birth_time')::time without time zone,
(v_snapshot ->> 'active_birth_time')::time without time zone,
v_snapshot ->> 'birth_time_source',
v_snapshot ->> 'birth_time_period',
(v_snapshot ->> 'uncertainty_before_minutes')::integer,
(v_snapshot ->> 'uncertainty_after_minutes')::integer,
(v_snapshot ->> 'latitude')::double precision,
(v_snapshot ->> 'longitude')::double precision,
(v_snapshot ->> 'timezone_offset')::double precision
) returning id into v_result_id;
for v_candidate, v_candidate_ordinal in
select item.value, item.ordinality::integer
from pg_catalog.jsonb_array_elements(p_candidates) with ordinality as item(value, ordinality)
loop
if jsonb_typeof(v_candidate) <> 'object' then
raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001';
end if;
v_candidate_time_text := nullif(btrim(coalesce(v_candidate ->> 'time', '')), '');
if v_candidate_time_text is null
or v_candidate_time_text !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
or v_candidate_time_text = any(v_seen_times) then
raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001';
end if;
v_seen_times := pg_catalog.array_append(v_seen_times, v_candidate_time_text);
v_candidate_time := v_candidate_time_text::time without time zone;
v_candidate_id := gen_random_uuid();
if v_representative_time is not null
and v_candidate_time is not distinct from v_representative_time then
v_representative_count := v_representative_count + 1;
v_representative_candidate_id := v_candidate_id;
end if;
v_saved_candidate := jsonb_set(
v_candidate - 'candidate_id',
'{candidate_id}',
to_jsonb(v_candidate_id::text),
true
);
insert into public.agentic_rectification_candidates (
id, result_id, user_id, case_id, ordinal,
candidate_time, candidate_payload, is_representative
) values (
v_candidate_id, v_result_id, p_user_id, p_case_id, v_candidate_ordinal,
v_candidate_time, v_saved_candidate,
v_representative_time is not null and v_candidate_time is not distinct from v_representative_time
);
v_saved_candidates := v_saved_candidates || jsonb_build_array(v_saved_candidate);
end loop;
if v_representative_time is not null and v_representative_count <> 1 then
raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001';
end if;
if v_representative_candidate_id is not null then
v_saved_decision_receipt := jsonb_set(
v_saved_decision_receipt,
'{representative_candidate_id}',
to_jsonb(v_representative_candidate_id::text),
true
);
end if;
update public.agentic_rectification_results
set candidates = v_saved_candidates,
decision_receipt = v_saved_decision_receipt,
updated_at = pg_catalog.now()
where id = v_result_id;
return jsonb_build_object(
'result_id', v_result_id,
'cached', false,
'candidates', v_saved_candidates,
'overall_confidence', v_overall_confidence,
'margin_percent', v_margin_percent,
'display_allowed', v_display_allowed,
'selection_allowed', v_selection_allowed,
'confirmation_allowed', v_confirmation_allowed,
'representative_time', v_representative_time,
'algorithm_version', p_algorithm_version,
'event_contract_version', p_event_contract_version,
'decision_policy_version', p_decision_policy_version,
'decision_receipt', public.compose_agentic_rectification_decision_receipt(p_case_id, v_result_id, v_saved_decision_receipt),
'execution_ledger', p_execution_ledger
);
end;
$$;
revoke all on function public.persist_agentic_rectification_candidate_v2(uuid, uuid, text, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb)
from public, anon, authenticated;
grant execute on function public.persist_agentic_rectification_candidate_v2(uuid, uuid, text, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb)
to service_role;
create or replace function public.get_agentic_rectification_case(
p_user_id uuid,
p_case_id uuid
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_result public.agentic_rectification_results%rowtype;
v_evidence_count bigint;
v_turn_count bigint;
begin
if p_user_id is null or p_case_id is null then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
select count(*) into v_evidence_count
from public.agentic_rectification_evidence
where case_id = v_case.id;
select count(*) into v_turn_count
from public.agentic_rectification_turns
where case_id = v_case.id;
select * into v_result
from public.agentic_rectification_results
where case_id = v_case.id
and invalidated_at is null
order by created_at desc
limit 1;
return jsonb_build_object(
'case_id', v_case.id,
'session_id', v_case.session_id,
'status', v_case.status,
'skill_name', v_case.skill_name,
'skill_version', v_case.skill_version,
'candidate_range', v_case.candidate_range,
'accepted_time', v_case.accepted_time,
'confirmed_time', v_case.confirmed_time,
'created_at', v_case.created_at,
'last_activity_at', v_case.last_activity_at,
'completed_at', v_case.completed_at,
'closed_reason', v_case.closed_reason,
'evidence_count', v_evidence_count,
'turn_count', v_turn_count,
'latest_result', case
when v_result.id is null then null
else jsonb_build_object(
'result_id', v_result.id,
'candidates', v_result.candidates,
'overall_confidence', v_result.overall_confidence,
'display_allowed', v_result.display_allowed,
'selection_allowed', v_result.selection_allowed,
'confirmation_allowed', v_result.confirmation_allowed,
'representative_time', v_result.representative_time,
'selected_candidate_id', v_result.selected_candidate_id,
'selected_time', v_result.selected_time,
'selection_kind', v_result.selection_kind,
'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint,
'candidate_range_fingerprint', v_result.candidate_range_fingerprint,
'skill_version', v_result.skill_version,
'algorithm_version', v_result.algorithm_version,
'event_contract_version', v_result.event_contract_version,
'decision_policy_version', v_result.decision_policy_version,
'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt),
'execution_ledger', v_result.execution_ledger,
'created_at', v_result.created_at,
'invalidated_at', v_result.invalidated_at
)
end
);
end;
$$;
revoke all on function public.get_agentic_rectification_case(uuid, uuid)
from public, anon, authenticated;
grant execute on function public.get_agentic_rectification_case(uuid, uuid)
to service_role;
create or replace function public.get_agentic_rectification_case_dossier(
p_user_id uuid,
p_case_id uuid
)
returns jsonb
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.agentic_rectification_cases%rowtype;
v_turns jsonb;
v_evidence jsonb;
v_summary public.agentic_rectification_case_conversation_summaries%rowtype;
v_result public.agentic_rectification_results%rowtype;
v_evidence_count bigint;
v_turn_count bigint;
begin
if p_user_id is null or p_case_id is null then
raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001';
end if;
select * into v_case
from public.agentic_rectification_cases
where id = p_case_id and user_id = p_user_id;
if not found then
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
end if;
select coalesce(jsonb_agg(jsonb_build_object(
'id', recent.id,
'role', message.role,
'text', message.text,
'status', recent.status,
'created_at', recent.created_at,
'completed_at', recent.completed_at
) order by recent.created_at, recent.id, message.ordinal), '[]'::jsonb)
into v_turns
from (
select t.*
from public.agentic_rectification_turns t
where t.case_id = v_case.id
order by t.created_at desc, t.id desc
limit 50
) recent
cross join lateral (
values
(1, 'user'::text, recent.user_message),
(2, 'assistant'::text, recent.assistant_message)
) as message(ordinal, role, text)
where message.text is not null;
select coalesce(jsonb_agg(jsonb_build_object(
'id', e.id,
'source_turn_id', e.source_turn_id,
'subject', e.subject,
'event_kind', e.event_kind,
'domain', e.domain,
'occurred_from', e.occurred_from,
'occurred_to', e.occurred_to,
'date_precision', e.date_precision,
'summary', e.summary,
'status', e.status,
'supersedes_evidence_id', e.supersedes_evidence_id,
'created_at', e.created_at
) order by e.created_at, e.id), '[]'::jsonb)
into v_evidence
from public.agentic_rectification_evidence e
where e.case_id = v_case.id;
select count(*) into v_evidence_count
from public.agentic_rectification_evidence where case_id = v_case.id;
select count(*) into v_turn_count
from public.agentic_rectification_turns where case_id = v_case.id;
select * into v_summary
from public.agentic_rectification_case_conversation_summaries
where case_id = v_case.id;
if not found then
perform public.refresh_agentic_rectification_case_conversation_summary(v_case.id);
select * into v_summary
from public.agentic_rectification_case_conversation_summaries
where case_id = v_case.id;
end if;
select * into v_result
from public.agentic_rectification_results
where case_id = v_case.id and invalidated_at is null
order by created_at desc, id desc
limit 1;
return jsonb_build_object(
'case', jsonb_build_object(
'case_id', v_case.id,
'session_id', v_case.session_id,
'status', v_case.status,
'skill_name', v_case.skill_name,
'skill_version', v_case.skill_version,
'candidate_range', v_case.candidate_range,
'accepted_time', v_case.accepted_time,
'confirmed_time', v_case.confirmed_time,
'completed_at', v_case.completed_at,
'closed_reason', v_case.closed_reason,
'last_activity_at', v_case.last_activity_at,
'evidence_count', v_evidence_count,
'turn_count', v_turn_count
),
'turns', v_turns,
'evidence', v_evidence,
'conversation_summary', jsonb_build_object(
'confirmed_evidence_summary', v_summary.confirmed_evidence_summary,
'pending_revisions', v_summary.pending_revisions,
'active_focus', v_summary.active_focus,
'declined_skipped_topics', v_summary.declined_skipped_topics,
'candidate_divergence_summary', v_summary.candidate_divergence_summary,
'missing_evidence_categories', v_summary.missing_evidence_categories,
'last_result_policy', v_summary.last_result_policy,
'summary_version', v_summary.summary_version,
'updated_at', v_summary.updated_at
),
'latest_result', case when v_result.id is null then null else jsonb_build_object(
'result_id', v_result.id,
'candidates', v_result.candidates,
'overall_confidence', v_result.overall_confidence,
'display_allowed', v_result.display_allowed,
'selection_allowed', v_result.selection_allowed,
'confirmation_allowed', v_result.confirmation_allowed,
'representative_time', v_result.representative_time,
'selected_candidate_id', v_result.selected_candidate_id,
'selected_time', v_result.selected_time,
'selection_kind', v_result.selection_kind,
'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint,
'candidate_range_fingerprint', v_result.candidate_range_fingerprint,
'skill_version', v_result.skill_version,
'algorithm_version', v_result.algorithm_version,
'event_contract_version', v_result.event_contract_version,
'decision_policy_version', v_result.decision_policy_version,
'decision_receipt', public.compose_agentic_rectification_decision_receipt(v_case.id, v_result.id, v_result.decision_receipt),
'execution_ledger', v_result.execution_ledger,
'created_at', v_result.created_at,
'invalidated_at', v_result.invalidated_at
) end
);
end;
$$;
revoke all on function public.get_agentic_rectification_case_dossier(uuid, uuid)
from public, anon, authenticated;
grant execute on function public.get_agentic_rectification_case_dossier(uuid, uuid)
to service_role;
commit;
@@ -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,
);
});
+26 -10
View File
@@ -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");
});