diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 75a80352..bcffc29a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 | 生时纠正推断后验原地改 receipt,persist-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 不可审计) - 修复版本:待发布 diff --git a/frontend/src/lib/rectification-agentic/core/build-state.ts b/frontend/src/lib/rectification-agentic/core/build-state.ts index 68027eed..d129f1b5 100644 --- a/frontend/src/lib/rectification-agentic/core/build-state.ts +++ b/frontend/src/lib/rectification-agentic/core/build-state.ts @@ -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) { diff --git a/frontend/src/lib/rectification-agentic/core/compose-receipt.ts b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts new file mode 100644 index 00000000..0282f24d --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts @@ -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>; + posteriorAfter: Readonly>; + scoreDeltas: Readonly>; +}>; + +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> | null | undefined, + transition: InferenceTransitionSnapshot | null | undefined, + resultId?: string | null, +): Record { + 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> | null | undefined, +): InferenceState | null { + return asInferenceState(receipt?.inference_state); +} diff --git a/frontend/src/lib/rectification-agentic/core/decision-fingerprint.ts b/frontend/src/lib/rectification-agentic/core/decision-fingerprint.ts new file mode 100644 index 00000000..bd364b50 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/core/decision-fingerprint.ts @@ -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 { + return Object.fromEntries(candidates.map((item) => [item.id, item.posterior_score])); +} + +export function scoreDeltas( + before: Readonly>, + after: Readonly>, +): Record { + const ids = new Set([...Object.keys(before), ...Object.keys(after)]); + return Object.fromEntries( + [...ids].map((id) => [id, (after[id] ?? 0) - (before[id] ?? 0)]), + ); +} diff --git a/frontend/src/lib/rectification-agentic/core/index.ts b/frontend/src/lib/rectification-agentic/core/index.ts index 8dcf1b4c..a7eba876 100644 --- a/frontend/src/lib/rectification-agentic/core/index.ts +++ b/frontend/src/lib/rectification-agentic/core/index.ts @@ -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"; diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index 5edf5f21..77df1aa9 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -261,6 +261,9 @@ const KNOWN_RPC_ERROR_CODES = new Map> | 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 | 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", diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 9dfbd2da..72a5bfc6 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -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 ?? {}, + posteriorAfter: rowObject(row.posterior_after) as Record ?? {}, + scoreDeltas: rowObject(row.score_deltas) as Record ?? {}, + }; +} + +export async function loadLatestInferenceTransition( + accounting: AccountingClient, + userId: string, + caseId: string, +): Promise { + const row = await rpc( + 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>; + posteriorBefore: Readonly>; + posteriorAfter: Readonly>; + scoreDeltas: Readonly>; + decisionStateFingerprint: string; + reason: "choice" | "supersede" | "already_answered"; + idempotencyKey: string; + candidateSetId: string; +}>; + export async function persistV9InferenceState( accounting: AccountingClient, userId: string, caseId: string, - inferenceState: Readonly>, -): Promise> }>> { + input: PersistInferenceTransitionInput, +): Promise>; + decisionStateFingerprint: string; +}>> { const row = await rpc>( 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; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 3a0c4eaf..da3eeef2 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -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, + { + 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, + 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; } } diff --git a/frontend/supabase/migrations/20260824010000_rectification_inference_transition_ledger.sql b/frontend/supabase/migrations/20260824010000_rectification_inference_transition_ledger.sql new file mode 100644 index 00000000..f4867176 --- /dev/null +++ b/frontend/supabase/migrations/20260824010000_rectification_inference_transition_ledger.sql @@ -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; diff --git a/frontend/tests/rectification-inference-machine.test.ts b/frontend/tests/rectification-inference-machine.test.ts index 69a6a3f5..c0693931 100644 --- a/frontend/tests/rectification-inference-machine.test.ts +++ b/frontend/tests/rectification-inference-machine.test.ts @@ -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; + fingerprint: string; +}; + +function createLedger(seed: ReturnType) { + const rows: LedgerRow[] = []; + const evidenceFp = "e".repeat(64); + const caseId = "case-1"; + let engineReceipt: Record = { inference_state: seed }; + return { + evidenceFp, + append(input: { + expectedRevision: number; + probeId: string; + openProbeId: string; + answerClass: "yes" | "weak_yes" | "no" | "unsure"; + idempotencyKey: string; + apply: () => ReturnType; + }) { + 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, + ); }); diff --git a/frontend/tests/rectification-v10-conversation-focus.test.ts b/frontend/tests/rectification-v10-conversation-focus.test.ts index 92760f6d..1ba329f1 100644 --- a/frontend/tests/rectification-v10-conversation-focus.test.ts +++ b/frontend/tests/rectification-v10-conversation-focus.test.ts @@ -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, + ); }); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index f15b173e..eb90e33a 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -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 () => { diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index dbc67df9..05fb3ae8 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -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"); });