diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e425eb55..c4140272 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3167,3 +3167,18 @@ - 防复发:不得把 Skill/Case read gate、focus 目标、attempt 成功归属或 Skill 身份降级为 Prompt 约定;公开正文、usage、Activity 与 receipt 必须只来自 `successful_attempt_id`;失败/重试不得重复扣费或重复 evidence;focus resolve 必须引用服务器持久化 focus/evidence,不能解析 Agent prose;历史 Case 必须继续绑定创建时可核验的 immutable Skill package。 - 相关记录:BUG-173、BUG-174、BUG-175、BUG-179、BUG-181、BUG-185 - 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 staging,未部署) + +## BUG-187 | 生时校正详细事件被压平,候选采用与精确确认缺少版本化服务器门禁 + +- 状态:resolved(本地候选,未部署) +- 首次发现:2026-08-14 +- 最近更新:2026-08-14 +- 影响面:V9/V10 生时校正事件评分输入、候选排序与展示、候选结果持久化、候选采用/精确确认 RPC、Activity 技法凭证。 +- 用户现象:教育、事业、关系、迁居和财务事件在进入 Python 评分前被压成粗粒度类型;Web 根据 margin、候选数量和事件 domain 自行判断置信度、采用权限与已执行技法;候选采用可能在代表分钟上隐式升级为 confirmed;所谓 candidate ID 实际是调用方提交的 `HH:MM`,不能证明来自服务器保存的候选结果。 +- 触发条件:提交 `career_entry/career_exit`、`relationship_commitment/relationship_separation` 等生命周期事件进行候选评分;展示候选卡或 Activity;采用代表候选;或调用旧的 time-based candidate RPC。 +- 根因:Python 事件合同只接受少量粗粒度 kind,TypeScript adapter 承担了有损映射、排序/tie/置信度/permission 和 technique 推断;结果表与 RPC 只保存调用方提供的布尔值和候选 JSON,没有版本化 decision receipt、真实 execution ledger 与服务器 candidate UUID 边界;历史 accept RPC 仍可把 accepted 与 confirmed 混合。 +- 修复:引入 Event Contract v2 与 Decision Policy v2,由 Python 返回版本化 `candidate_decisions`、`decision_receipt` 和真实 `execution_ledger`,日期精度进入服务器评分;Web 只做严格安全投影,不再自行推断 rank、tie、margin confidence、selection gate 或已执行技法。forward-only migration 保存政策与执行凭证,以服务器生成的 candidate UUID 绑定 result,并将 representative candidate 重写为持久化 UUID;accepted 与 confirmed 使用独立 RPC 和状态门禁,精确确认必须引用已接受候选、有效 consent/source turn,旧 time-based RPC 对 `service_role` 撤权。Case/Dossier 恢复路径同时投影完整 v2 receipt、ledger、selected candidate 与 display gate。 +- 验证:Python 服务回归 36 passed;TypeScript 核心合同 49 passed、0 failed;migration 与 PR-4 真实 PostgreSQL 场景 75 passed、0 failed;V9/V10/candidate 全量聚焦门禁 219 passed、0 failed,覆盖 request replay、payload conflict、候选 UUID 归属、representative UUID 缓存一致性、accepted/confirmed 原子分离、consent/source-turn 精确确认门禁、legacy RPC 撤权与 Case/Dossier 恢复。目标 ESLint 0 error/0 warning,Python Ruff 与 `py_compile` 通过,`git diff --check` 通过。全库 `tsc --noEmit` 仅剩 5 个既有无关测试错误:`production-data-migration.test.ts` 两处 fixture 字段缺失、`staging-backend-workflows.test.ts` 三处低 target 正则 flag。 +- 防复发:详细事件不得在 adapter 层压平;“有候选”不得等于“允许采用”;Web 不得自行推断执行技法或业务 tie;采用候选永远不能隐式确认精确分钟;candidate action 必须引用服务器持久化 result 中的 candidate UUID。 +- 相关记录:BUG-172、BUG-179、BUG-184、BUG-186 +- 修复版本:本次功能分支提交(精确 SHA 以提交与远程分支核对结果为准;未合并 staging,未部署) diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts index 7b070b83..199ac2c5 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -11,7 +11,7 @@ type RouteContext = { params: Promise<{ caseId: string }> }; const acceptSchema = z.object({ sessionId: z.string().uuid(), resultId: z.string().uuid(), - candidateId: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/), + candidateId: z.string().uuid(), requestId: z.string().uuid(), }).strict(); @@ -90,24 +90,29 @@ export async function POST(request: Request, context: RouteContext) { try { const { data, error } = await accounting.rpc( - "accept_agentic_rectification_candidate_for_case", + "accept_agentic_rectification_candidate_for_case_v2", { p_user_id: user.id, p_case_id: caseId, p_result_id: parsed.data.resultId, - p_time: parsed.data.candidateId, + p_candidate_id: parsed.data.candidateId, + p_request_id: parsed.data.requestId, }, ); if (error) throw new RectificationToolServiceError(error.message); const row = Array.isArray(data) ? data[0] : data; - if (!row || typeof row !== "object" || (row as { success?: unknown }).success !== true) { + if ( + !row || typeof row !== "object" + || (row as { success?: unknown }).success !== true + || (row as { status?: unknown }).status !== "accepted" + ) { return NextResponse.json({ error: "暂时无法采用该候选时间", code: "candidate_accept_rejected" }, { status: 409 }); } const result = row as Record; return NextResponse.json({ ok: true, saved_time: result.saved_time, - status: result.status === "confirmed" ? "confirmed" : "accepted", + status: "accepted", result_id: result.result_id, case_status: result.case_status, idempotent: result.idempotent === true, diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 22cc9c98..3c4800bb 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -162,7 +162,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [savedTime, setSavedTime] = useState(null); const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); const [candidateResult, setCandidateResult] = useState(null); - const [acceptingTime, setAcceptingTime] = useState(null); + const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [regeneratingMessageKey, setRegeneratingMessageKey] = useState(null); @@ -384,10 +384,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { void send("opening", ""); }, [readonly, send, shouldStartOpening]); - const acceptCandidate = useCallback(async (time: string) => { - if (!candidateResult || acceptingTime || readonly) return; + const acceptCandidate = useCallback(async (candidateId: string) => { + if (!candidateResult || acceptingCandidateId || readonly) return; setError(""); - setAcceptingTime(time); + setAcceptingCandidateId(candidateId); try { const response = await fetch( `/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`, @@ -397,27 +397,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { body: JSON.stringify({ sessionId, resultId: candidateResult.resultId, - candidateId: time, + candidateId, requestId: globalThis.crypto.randomUUID(), }), }, ); const payload = await response.json().catch(() => null); - if (!response.ok || payload?.ok !== true) { + if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") { throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间"); } - const status = payload.status === "confirmed" ? "confirmed" : "accepted"; - setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: status === "confirmed" ? "engine_confirmed" : "user_accepted" } : current); + setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: "user_accepted" } : current); setSavedTime(payload.saved_time); - setSavedStatus(status); - onSaved?.(payload.saved_time, status); + setSavedStatus("accepted"); + onSaved?.(payload.saved_time, "accepted"); onCompleted?.(); } catch (caught) { setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间"); } finally { - setAcceptingTime(null); + setAcceptingCandidateId(null); } - }, [acceptingTime, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]); + }, [acceptingCandidateId, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]); async function copyMessage(message: RenderMessage) { try { @@ -562,7 +561,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ); })} - {candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && ( + {candidateResult?.selectionAllowed && (
{candidateResult.confirmationAllowed ? "确认校正时间" : "当前可能的出生时间"} @@ -577,9 +576,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ); diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index ec4bdf13..aca5b850 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -1,28 +1,22 @@ /** * V9 deterministic engine client (Python, server-side only). * - * Builds engine payloads exclusively from the Case's baseline birth snapshot - * and the durable evidence ledger. The model never supplies birth data, - * candidate ranges or event arrays. Responses are compacted to safe, - * allowlisted projections before they reach the tool layer. - * - * Contract notes (verified against scripts/jyotish_api_server.py + - * scripts/rectification/api_service.py): - * * The engine's SCOREABLE_EVENT_KINDS is a coarse vocabulary - * (education_milestone / relocation / relationship_start|change / - * career_change / finance_change / self_health_event under - * health_pressure). V9 evidence kinds are mapped onto that vocabulary; - * non-scoreable kinds (family_event, other) stay in the ledger but never - * reach the engine. - * * /api/rectification/v5/score returns candidate_scores as - * [{time, score, supporting_event_ids, conflicting_event_ids}] without - * rank/tied/representative/confidence fields. Rank and tie counts are - * derived deterministically here; relative support is normalized from - * scores; representative time is the top-ranked candidate; the - * confirmation gate is bound to the engine's own can_confirm_exact_minute. + * The Python service owns Event Contract v2 normalization, candidate ranking, + * decision policy and the execution ledger. This client validates and projects + * that server contract; it never reconstructs rank, support, ties, confidence, + * permissions or executed methods from raw scores or event domains. */ -import type { PublicRectificationMethod } from "./public-receipt"; +import { + isBackgroundEvidenceKind, + isEvidenceDomain, + isEvidenceKind, + type EvidenceKind, +} from "./evidence-model"; +import { + isPublicRectificationMethod, + type PublicRectificationMethod, +} from "./public-receipt"; export class RectificationEngineError extends Error { readonly code: string; @@ -42,81 +36,75 @@ export type V9EngineEvent = Readonly<{ date_end: string; precision: "day" | "month" | "quarter" | "year" | "range"; summary: string; + date_source?: string | null; + date_reliability?: string | null; + date_corroboration?: string | null; + date_conflict_status?: string | null; + source_turn_id?: string | null; + subject?: string | null; }>; export type V9EngineCandidate = Readonly<{ - rank: number; + candidateId: string; time: string; - relative_support: number; - tied_minute_count: number; + rank: number; + relativeSupport: number; + tiedMinuteCount: number; }>; +export type V9DecisionReceipt = Readonly>; +export type V9ExecutionLedger = readonly Readonly>[]; + export type V9EngineScoreResult = Readonly<{ engineResultId: string; algorithmVersion: string; + eventContractVersion: string; + policyVersion: string; candidateRange: { start_time: string; end_time: string }; candidates: readonly V9EngineCandidate[]; overallConfidence: "low" | "medium" | "high"; marginPercent: number | null; selectionAllowed: boolean; confirmationAllowed: boolean; + representativeCandidateId: string | null; representativeTime: string | null; + decisionReceipt: V9DecisionReceipt; + executionLedger: V9ExecutionLedger; executedMethods: readonly PublicRectificationMethod[]; }>; export type V9EngineDiagnostics = Readonly<{ algorithmVersion: string; engineResultId: string; + eventContractVersion: string; + policyVersion: string; diagnostics: Readonly>; missingLayers: readonly string[]; canConfirmExactMinute: boolean; + decisionReceipt: V9DecisionReceipt; + executionLedger: V9ExecutionLedger; executedMethods: readonly PublicRectificationMethod[]; }>; +const EVENT_CONTRACT_VERSION = "rectification-event-contract-v2"; +const RECEIPT_VERSION = "candidate-decision-receipt-v2"; +const EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2"; const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/; +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; -const DOMAIN_METHODS: Readonly> = { - education: ["d24-chaturvimshamsha"], - relocation: ["d4-chaturthamsha"], - relationship: ["d9-navamsa"], - career: ["d10-dashamsa"], - finance: ["d2-hora", "d11-labhamsha"], - health_pressure: ["d30-trimshamsha"], -}; - -function techniqueLayers(value: unknown): string[] { - if (!value || typeof value !== "object") return []; - return Object.values(value as Record).flatMap((eventRows) => { - if (!eventRows || typeof eventRows !== "object") return []; - return Object.values(eventRows as Record).flatMap((candidate) => { - if (!candidate || typeof candidate !== "object") return []; - const layers = (candidate as Record).technique_layers; - return Array.isArray(layers) ? layers.filter((item): item is string => typeof item === "string") : []; - }); - }); +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; } -function executedMethods( - events: readonly V9EngineEvent[], - layers: readonly string[], -): PublicRectificationMethod[] { - const methods = new Set([ - "d1-rashi", - "vimshottari-dasha", - "narayana-dasha", - ]); - for (const event of events) { - for (const method of DOMAIN_METHODS[event.domain] ?? []) methods.add(method); +function engineNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; } - const normalized = layers.map((layer) => layer.toLowerCase()); - if (normalized.some((layer) => layer.includes("controlled_transit") || layer.includes("gochara"))) methods.add("gochara"); - if (normalized.some((layer) => layer.includes("ashtakavarga"))) methods.add("ashtakavarga"); - if (normalized.some((layer) => layer.includes("shadbala"))) methods.add("shadbala"); - if (normalized.some((layer) => layer.includes("arudha"))) methods.add("arudha-pada"); - if (normalized.some((layer) => layer.includes("functional_benefic") || layer.includes("functional_malefic"))) { - methods.add("functional-benefic-malefic"); - } - return [...methods]; + return null; } function clockMinute(value: string): number { @@ -131,47 +119,11 @@ function timeInRange(time: string, range: { start_time: string; end_time: string return end >= start ? value >= start && value <= end : value >= start || value <= end; } -/** - * The engine's scoreable (domain, kind) vocabulary (contracts.py - * SCOREABLE_EVENT_KINDS). V9 evidence kinds are mapped kind-aware so - * relationship_start/change keep their distinct engine semantics. Rows that - * map to null (family/other or unknown domains) are excluded from scoring; - * they remain evidence in the ledger. - */ -export function toEngineScoreableEvent( - item: Readonly<{ - eventKind: string; - domain: string; - }>, -): { domain: string; event_kind: string } | null { - const kind = item.eventKind; - switch (item.domain) { - case "education": - return { domain: "education", event_kind: "education_milestone" }; - case "career": - return { domain: "career", event_kind: "career_change" }; - case "relationship": - if (kind === "relationship_start" || kind === "relationship_commitment") { - return { domain: "relationship", event_kind: "relationship_start" }; - } - return { domain: "relationship", event_kind: "relationship_change" }; - case "relocation": - return { domain: "relocation", event_kind: "relocation" }; - case "finance": - return { domain: "finance", event_kind: "finance_change" }; - case "health": - return { domain: "health_pressure", event_kind: "self_health_event" }; - default: - // family, other and unknown domains are background evidence only. - return null; - } -} - -/** Map a V9 evidence date precision to the engine's precision vocabulary. */ +/** Map a V9 evidence date precision to the Event Contract v2 vocabulary. */ export function enginePrecision(precision: string): V9EngineEvent["precision"] { - if (precision === "day") return "day"; - if (precision === "month") return "month"; - if (precision === "range") return "range"; + if (precision === "day" || precision === "month" || precision === "quarter" || precision === "range") { + return precision; + } return "year"; } @@ -184,22 +136,34 @@ export function toEngineEvents( occurredTo: string | null; datePrecision: string; summary: string; + dateSource?: string | null; + dateReliability?: string | null; + dateCorroboration?: string | null; + dateConflictStatus?: string | null; + sourceTurnId?: string | null; + subject?: string | null; }>[], ): V9EngineEvent[] { return evidence.flatMap((item): V9EngineEvent[] => { - const scoreable = toEngineScoreableEvent(item); - if (!scoreable) return []; + if (!isEvidenceKind(item.eventKind) || !isEvidenceDomain(item.domain)) return []; + if (isBackgroundEvidenceKind(item.eventKind as EvidenceKind)) return []; const start = item.occurredFrom ?? item.occurredTo; const end = item.occurredTo ?? item.occurredFrom; if (!start) return []; return [{ id: item.id, - domain: scoreable.domain, - event_kind: scoreable.event_kind, + domain: item.domain, + event_kind: item.eventKind, date_start: start.slice(0, 10), - date_end: end ? end.slice(0, 10) : start.slice(0, 10), + date_end: (end ?? start).slice(0, 10), precision: enginePrecision(item.datePrecision), summary: item.summary, + date_source: item.dateSource ?? null, + date_reliability: item.dateReliability ?? null, + date_corroboration: item.dateCorroboration ?? null, + date_conflict_status: item.dateConflictStatus ?? null, + source_turn_id: item.sourceTurnId ?? null, + subject: item.subject ?? null, }]; }); } @@ -217,60 +181,180 @@ async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Prom }); const data = await response.json().catch(() => null); if (!response.ok) { - const message = data?.error || data?.message || `Jyotish API ${path} returned ${response.status}`; - throw new RectificationEngineError("engine_http_error", String(message)); + const message = data && typeof data === "object" + ? String((data as Record).error ?? (data as Record).message ?? `Jyotish API ${response.status}`) + : `Jyotish API ${response.status}`; + throw new RectificationEngineError("engine_request_failed", message); } if (!data || typeof data !== "object") { - throw new RectificationEngineError("engine_invalid_response", `Jyotish API ${path} returned an invalid response`); + throw new RectificationEngineError("engine_invalid_response", "Jyotish API returned an invalid response"); } return data as Record; } -function engineNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; +function readCandidates( + value: unknown, + range: { start_time: string; end_time: string }, +): V9EngineCandidate[] { + if (!Array.isArray(value) || value.length === 0) { + throw new RectificationEngineError("engine_invalid_candidate_decisions", "engine candidate decisions are missing"); + } + const seenIds = new Set(); + const seenTimes = new Set(); + const candidates: V9EngineCandidate[] = []; + for (const item of value) { + const row = record(item); + const candidateId = row?.candidate_id; + const time = row?.time; + const rank = row?.rank; + const relativeSupport = row?.relative_support; + const tiedMinuteCount = row?.tied_minute_count; + if ( + typeof candidateId !== "string" || !uuidPattern.test(candidateId) + || typeof time !== "string" || !timePattern.test(time) || !timeInRange(time, range) + || typeof rank !== "number" || !Number.isInteger(rank) || rank < 1 + || typeof relativeSupport !== "number" || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100 + || typeof tiedMinuteCount !== "number" || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1 + || seenIds.has(candidateId) || seenTimes.has(time) + ) { + throw new RectificationEngineError("engine_invalid_candidate_decisions", "engine candidate decisions are invalid"); + } + seenIds.add(candidateId); + seenTimes.add(time); + candidates.push({ candidateId, time, rank, relativeSupport, tiedMinuteCount }); + } + return candidates; } -/** - * Derive ranked candidates from the engine's [{time, score}] rows. The engine - * does not rank; rank = score-descending order and tied_minute_count = how - * many candidate minutes in the scan share the same score. - */ -function readCandidates(value: unknown, range: { start_time: string; end_time: string }): V9EngineCandidate[] { - if (!Array.isArray(value)) return []; - const scored = value.flatMap((item): Array<{ time: string; score: number }> => { - if (!item || typeof item !== "object") return []; - const row = item as Record; - const time = typeof row.time === "string" ? row.time : ""; - const score = typeof row.score === "number" && Number.isFinite(row.score) ? row.score : 0; - if (!timePattern.test(time) || !timeInRange(time, range)) return []; - return [{ time, score }]; +type ParsedReceipt = Readonly<{ + raw: V9DecisionReceipt; + eventContractVersion: string; + policyVersion: string; + selectionAllowed: boolean; + confirmationAllowed: boolean; + representativeCandidateId: string | null; + representativeTime: string | null; + overallConfidence: "low" | "medium" | "high"; + marginPercent: number | null; +}>; + +function invalidReceipt(): never { + throw new RectificationEngineError("engine_invalid_v2_receipt", "engine decision receipt is missing or invalid"); +} + +function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandidate[]): ParsedReceipt { + const row = record(value); + if (!row) return invalidReceipt(); + const representativeCandidateId = row.representative_candidate_id; + const representativeTime = row.representative_time; + const confidence = row.overall_confidence; + const marginPercent = row.margin_percent === null || row.margin_percent === undefined + ? null + : engineNumber(row.margin_percent); + if ( + row.receipt_version !== RECEIPT_VERSION + || row.contract_version !== "v2" + || row.event_contract_version !== EVENT_CONTRACT_VERSION + || typeof row.policy_version !== "string" || !row.policy_version + || typeof row.decision_policy_version !== "string" || row.decision_policy_version !== row.policy_version + || typeof row.display_allowed !== "boolean" + || typeof row.selection_allowed !== "boolean" + || typeof row.acceptance_allowed !== "boolean" + || typeof row.confirmation_allowed !== "boolean" + || typeof row.accept_allowed !== "boolean" + || typeof row.confirm_allowed !== "boolean" + || row.selection_allowed !== row.acceptance_allowed + || row.selection_allowed !== row.accept_allowed + || row.confirmation_allowed !== row.confirm_allowed + || (representativeCandidateId !== null && (typeof representativeCandidateId !== "string" || !uuidPattern.test(representativeCandidateId))) + || (representativeTime !== null && (typeof representativeTime !== "string" || !timePattern.test(representativeTime))) + || (confidence !== "low" && confidence !== "medium" && confidence !== "high") + || (row.margin_percent !== null && row.margin_percent !== undefined && marginPercent === null) + ) { + return invalidReceipt(); + } + const representative = representativeCandidateId === null + ? null + : candidates.find((candidate) => candidate.candidateId === representativeCandidateId) ?? null; + if ( + (representativeCandidateId === null) !== (representativeTime === null) + || (representativeCandidateId !== null && (!representative || representative.time !== representativeTime)) + || (row.confirmation_allowed === true && row.selection_allowed !== true) + || (row.selection_allowed === true && row.display_allowed !== true) + ) { + return invalidReceipt(); + } + return { + raw: row, + eventContractVersion: EVENT_CONTRACT_VERSION, + policyVersion: row.policy_version, + selectionAllowed: row.selection_allowed, + confirmationAllowed: row.confirmation_allowed, + representativeCandidateId, + representativeTime, + overallConfidence: confidence, + marginPercent, + }; +} + +function readExecutionLedger(version: unknown, value: unknown): V9ExecutionLedger { + if (version !== EXECUTION_LEDGER_VERSION || !Array.isArray(value) || value.length === 0) { + throw new RectificationEngineError("engine_invalid_execution_ledger", "engine execution ledger is missing or invalid"); + } + return value.map((item) => { + const row = record(item); + if ( + !row + || row.ledger_version !== EXECUTION_LEDGER_VERSION + || typeof row.stage !== "string" || !row.stage + || (row.status !== "executed" && row.status !== "not_executed" && row.status !== "retained_not_scored") + || typeof row.source !== "string" || !row.source + ) { + throw new RectificationEngineError("engine_invalid_execution_ledger", "engine execution ledger is missing or invalid"); + } + return row; }); - if (scored.length === 0) return []; - scored.sort((left, right) => right.score - left.score); - const top = scored.slice(0, 3); - const weights = top.map((row) => Math.max(0, row.score)); - const total = weights.reduce((sum, weight) => sum + weight, 0); - const supports = weights.map((weight) => total > 0 ? Math.round((weight / total) * 100) : Math.floor(100 / top.length)); - supports[0] += 100 - supports.reduce((sum, support) => sum + support, 0); - return top.map((row, index) => ({ - rank: index + 1, - time: row.time, - relative_support: supports[index] ?? 0, - tied_minute_count: scored.filter((candidate) => candidate.score === row.score).length, - })); } -function engineDiagnostics(data: Record): Record { - return data.diagnostics && typeof data.diagnostics === "object" - ? data.diagnostics as Record - : {}; +function publicMethodFromLedger(value: unknown): PublicRectificationMethod | null { + if (isPublicRectificationMethod(value)) return value; + if (typeof value !== "string") return null; + const normalized = value.toLowerCase(); + if (normalized.includes("controlled_transit") || normalized.includes("gochara")) return "gochara"; + if (normalized.includes("ashtakavarga")) return "ashtakavarga"; + if (normalized.includes("shadbala")) return "shadbala"; + if (normalized.includes("arudha")) return "arudha-pada"; + if (normalized.includes("functional_benefic") || normalized.includes("functional_malefic")) return "functional-benefic-malefic"; + if (normalized.includes("vimshottari") || normalized.includes("vimsottari")) return "vimshottari-dasha"; + if (normalized.includes("narayana")) return "narayana-dasha"; + return null; } -export async function runV9CandidateScore(input: { +function executedMethods(ledger: V9ExecutionLedger): PublicRectificationMethod[] { + const methods = new Set(); + for (const entry of ledger) { + if (entry.status !== "executed") continue; + const direct = publicMethodFromLedger(entry.method); + if (direct) methods.add(direct); + if (Array.isArray(entry.technique_layers)) { + for (const layer of entry.technique_layers) { + const method = publicMethodFromLedger(layer); + if (method) methods.add(method); + } + } + } + return [...methods]; +} + +function engineDiagnostics(data: Record): Readonly> { + return record(data.diagnostics) ?? {}; +} + +function engineRequestBody(input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; -}): Promise { +}): Record { const snapshot = input.baselineBirthSnapshot; const birthDate = String(snapshot.birth_date ?? ""); const lat = engineNumber(snapshot.latitude); @@ -282,7 +366,7 @@ export async function runV9CandidateScore(input: { if (input.events.length === 0) { throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine"); } - const data = await postEngine("/api/rectification/v5/score", { + return { birth_date: birthDate, start_time: input.candidateRange.start_time, end_time: input.candidateRange.end_time, @@ -290,36 +374,44 @@ export async function runV9CandidateScore(input: { lon, tz, events: input.events, - }); - const candidates = readCandidates(data.candidate_scores, input.candidateRange); - if (candidates.length === 0) { - throw new RectificationEngineError("engine_no_candidates", "the engine returned no usable candidates"); + birth_time_source: snapshot.birth_time_source, + timezone_id: snapshot.timezone_id, + timezone_source: snapshot.timezone_source, + local_time_status: snapshot.local_time_status, + }; +} + +export async function runV9CandidateScore(input: { + baselineBirthSnapshot: Readonly>; + candidateRange: { start_time: string; end_time: string }; + events: readonly V9EngineEvent[]; +}): Promise { + const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input)); + const candidates = readCandidates(data.candidate_decisions, input.candidateRange); + const receipt = readDecisionReceipt(data.decision_receipt, candidates); + const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger); + if ( + data.event_contract_version !== receipt.eventContractVersion + || data.decision_policy_version !== receipt.policyVersion + ) { + return invalidReceipt(); } - const diagnostics = engineDiagnostics(data); - const methods = executedMethods(input.events, techniqueLayers(data.event_contribution_matrix)); - const marginPercent = engineNumber(diagnostics.primary_secondary_margin_percent) - ?? engineNumber(data.margin_percent) - ?? null; - const retention = engineNumber(diagnostics.leave_one_event_out_retention_rate); - const confidence: "low" | "medium" | "high" = - marginPercent !== null && marginPercent >= 40 && retention !== null && retention >= 0.8 - ? "high" - : marginPercent !== null && marginPercent >= 20 - ? "medium" - : data.confidence === "high" || data.confidence === "medium" - ? data.confidence - : "low"; return { engineResultId: String(data.result_id ?? ""), - algorithmVersion: String(data.algorithm_version ?? "rectification-v5"), + algorithmVersion: String(data.algorithm_version ?? ""), + eventContractVersion: receipt.eventContractVersion, + policyVersion: receipt.policyVersion, candidateRange: input.candidateRange, candidates, - overallConfidence: confidence, - marginPercent, - selectionAllowed: candidates.length > 0, - confirmationAllowed: data.can_confirm_exact_minute === true, - representativeTime: candidates[0]?.time ?? null, - executedMethods: methods, + overallConfidence: receipt.overallConfidence, + marginPercent: receipt.marginPercent, + selectionAllowed: receipt.selectionAllowed, + confirmationAllowed: receipt.confirmationAllowed, + representativeCandidateId: receipt.representativeCandidateId, + representativeTime: receipt.representativeTime, + decisionReceipt: receipt.raw, + executionLedger: ledger, + executedMethods: executedMethods(ledger), }; } @@ -328,34 +420,25 @@ export async function runV9Diagnostics(input: { candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; }): Promise { - const snapshot = input.baselineBirthSnapshot; - const birthDate = String(snapshot.birth_date ?? ""); - const lat = engineNumber(snapshot.latitude); - const lon = engineNumber(snapshot.longitude); - const tz = engineNumber(snapshot.timezone_offset); - if (!birthDate || lat === null || lon === null || tz === null) { - throw new RectificationEngineError("engine_profile_incomplete", "server profile snapshot is incomplete"); + const data = await postEngine("/api/rectification/v5/diagnostics", engineRequestBody(input)); + const candidates = readCandidates(data.candidate_decisions, input.candidateRange); + const receipt = readDecisionReceipt(data.decision_receipt, candidates); + const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger); + if ( + data.event_contract_version !== receipt.eventContractVersion + || data.decision_policy_version !== receipt.policyVersion + ) { + return invalidReceipt(); } - if (input.events.length === 0) { - throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine"); - } - const data = await postEngine("/api/rectification/v5/diagnostics", { - birth_date: birthDate, - start_time: input.candidateRange.start_time, - end_time: input.candidateRange.end_time, - lat, - lon, - tz, - events: input.events, - }); const diagnostics = engineDiagnostics(data); - const missingLayers = Array.isArray(data.missing_layers) ? data.missing_layers as string[] : []; - const discriminatingLayers = Array.isArray(diagnostics.most_discriminating_layers) - ? diagnostics.most_discriminating_layers.filter((item): item is string => typeof item === "string") + const missingLayers = Array.isArray(data.missing_layers) + ? data.missing_layers.filter((item): item is string => typeof item === "string") : []; return { - algorithmVersion: String(data.algorithm_version ?? "rectification-v5"), + algorithmVersion: String(data.algorithm_version ?? ""), engineResultId: String(data.result_id ?? ""), + eventContractVersion: receipt.eventContractVersion, + policyVersion: receipt.policyVersion, diagnostics: { primary_cluster_retention_rate: diagnostics.primary_cluster_retention_rate, leave_one_event_out_retention_rate: diagnostics.leave_one_event_out_retention_rate, @@ -368,8 +451,10 @@ export async function runV9Diagnostics(input: { candidate_splits: diagnostics.candidate_splits, }, missingLayers, - canConfirmExactMinute: data.can_confirm_exact_minute === true, - executedMethods: executedMethods(input.events, discriminatingLayers), + canConfirmExactMinute: receipt.confirmationAllowed, + decisionReceipt: receipt.raw, + executionLedger: ledger, + executedMethods: executedMethods(ledger), }; } diff --git a/frontend/src/lib/rectification-agentic/v9/evidence-model.ts b/frontend/src/lib/rectification-agentic/v9/evidence-model.ts index 020dc802..8dc11ad1 100644 --- a/frontend/src/lib/rectification-agentic/v9/evidence-model.ts +++ b/frontend/src/lib/rectification-agentic/v9/evidence-model.ts @@ -7,18 +7,30 @@ export const EVIDENCE_KINDS = [ "education_start", "education_completion", "education_interruption", + "education_change", + "education_milestone", "career_entry", "career_change", "promotion", "career_pressure", "career_exit", + "business_start", "relationship_start", "relationship_commitment", "relationship_separation", + "relationship_end", + "relationship_change", "relocation", + "foreign_move", + "return", + "home_change", "finance_gain", "finance_loss", + "income_change", + "asset_change", + "finance_change", "self_health_event", + "pressure_period", "family_event", "other", ] as const; @@ -32,6 +44,7 @@ export const EVIDENCE_DOMAINS = [ "relocation", "finance", "health", + "health_pressure", "family", "other", ] as const; @@ -41,6 +54,7 @@ export type EvidenceDomain = (typeof EVIDENCE_DOMAINS)[number]; export const DATE_PRECISIONS = [ "year", "month", + "quarter", "day", "range", "unknown", diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index fdea323d..e4c1627b 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -24,6 +24,8 @@ import { } from "./public-receipt"; import { RECTIFICATION_SKILL_VERSION } from "./case-status"; +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; + /** * Minimal structural RPC client. SupabaseClient satisfies this; tests can * pass a fake with a single rpc() method. @@ -135,6 +137,10 @@ export type V9CaseDossier = Readonly<{ status: string; supersedesEvidenceId: string | null; createdAt: string; + dateSource?: string | null; + dateReliability?: string | null; + dateCorroboration?: string | null; + dateConflictStatus?: string | null; }>[]; conversationSummary: CaseConversationSummary; latestResult: V9CandidateSnapshot | null; @@ -248,9 +254,17 @@ export async function insertV9SkillRunReceipt( ); } +export type V9Candidate = Readonly<{ + candidateId: string; + time: string; + rank: number; + relativeSupport: number; + tiedMinuteCount: number; +}>; + export type V9CandidateSnapshot = Readonly<{ resultId: string; - candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[]; + candidates: readonly V9Candidate[]; overallConfidence: "low" | "medium" | "high"; selectionAllowed: boolean; confirmationAllowed: boolean; @@ -261,6 +275,10 @@ export type V9CandidateSnapshot = Readonly<{ candidateRangeFingerprint: string | null; skillVersion: string | null; algorithmVersion: string | null; + eventContractVersion: string | null; + policyVersion: string | null; + decisionReceipt: Readonly> | null; + executionLedger: readonly Readonly>[] | null; createdAt: string; invalidatedAt: string | null; }>; @@ -384,6 +402,10 @@ export function parseV9CaseDossier(value: unknown): V9CaseDossier | null { status: String(row.status ?? ""), supersedesEvidenceId: rowText(row.supersedes_evidence_id), createdAt: String(row.created_at ?? ""), + dateSource: rowText(row.date_source), + dateReliability: rowText(row.date_reliability), + dateCorroboration: rowText(row.date_corroboration), + dateConflictStatus: rowText(row.date_conflict_status), }]; }); @@ -414,22 +436,33 @@ export function parseV9CaseDossier(value: unknown): V9CaseDossier | null { } export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot | null { - if (!value || typeof value !== "object") return null; - const row = value as Record; - const resultId = typeof row.result_id === "string" ? row.result_id : ""; - if (!resultId) return null; - const candidates = rowArray(row.candidates).flatMap((item) => { - if (!item || typeof item !== "object") return []; - const candidate = item as Record; - const time = timeValue(candidate.time); - if (!time || typeof candidate.rank !== "number") return []; - return [{ - rank: Math.trunc(candidate.rank), - time, - relative_support: typeof candidate.relative_support === "number" ? Math.max(0, Math.min(100, Math.trunc(candidate.relative_support))) : 0, - tied_minute_count: typeof candidate.tied_minute_count === "number" ? Math.max(1, Math.trunc(candidate.tied_minute_count)) : 1, - }]; - }); + const row = rowObject(value); + const resultId = rowText(row?.result_id); + if (!row || !resultId || !Array.isArray(row.candidates) || row.candidates.length === 0) return null; + const candidates: V9Candidate[] = []; + const seenIds = new Set(); + for (const item of row.candidates) { + const candidate = rowObject(item); + const candidateId = rowText(candidate?.candidate_id); + const candidateTime = timeValue(candidate?.time); + const rank = rowNumber(candidate?.rank); + const relativeSupport = rowNumber(candidate?.relative_support); + const tiedMinuteCount = rowNumber(candidate?.tied_minute_count); + if ( + !candidate || !candidateId || !uuidPattern.test(candidateId) || seenIds.has(candidateId) + || !candidateTime + || rank === null || !Number.isInteger(rank) || rank < 1 + || relativeSupport === null || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100 + || tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1 + ) return null; + seenIds.add(candidateId); + candidates.push({ candidateId, time: candidateTime, rank, relativeSupport, tiedMinuteCount }); + } + const decisionReceipt = rowObject(row.decision_receipt); + const executionLedger = Array.isArray(row.execution_ledger) + && row.execution_ledger.every((item) => rowObject(item) !== null) + ? row.execution_ledger as readonly Readonly>[] + : null; return { resultId, candidates, @@ -443,6 +476,10 @@ export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot | candidateRangeFingerprint: rowText(row.candidate_range_fingerprint), skillVersion: rowText(row.skill_version), algorithmVersion: rowText(row.algorithm_version), + eventContractVersion: rowText(row.event_contract_version), + policyVersion: rowText(row.decision_policy_version) ?? rowText(decisionReceipt?.policy_version), + decisionReceipt, + executionLedger, createdAt: String(row.created_at ?? ""), invalidatedAt: rowText(row.invalidated_at), }; @@ -1167,11 +1204,14 @@ export type V9PersistedCandidate = Readonly<{ cached: boolean; candidates: V9CandidateSnapshot["candidates"]; overallConfidence: "low" | "medium" | "high"; - marginPercent: number | null; selectionAllowed: boolean; confirmationAllowed: boolean; representativeTime: string | null; algorithmVersion: string | null; + eventContractVersion: string | null; + policyVersion: string | null; + decisionReceipt: Readonly>; + executionLedger: readonly Readonly>[]; }>; export async function persistV9Candidate( @@ -1184,54 +1224,61 @@ export async function persistV9Candidate( evidenceFingerprint: string; rangeFingerprint: string; skillVersion: string; + eventContractVersion: string; + policyVersion: string; candidateRange: { start_time: string; end_time: string }; candidates: V9CandidateSnapshot["candidates"]; - overallConfidence: "low" | "medium" | "high"; - marginPercent: number | null; - selectionAllowed: boolean; - confirmationAllowed: boolean; - representativeTime: string | null; + decisionReceipt: Readonly>; + executionLedger: readonly Readonly>[]; }, ): Promise { const row = await rpc>( accounting, - "persist_agentic_rectification_candidate", + "persist_agentic_rectification_candidate_v2", { p_user_id: userId, p_case_id: caseId, p_engine_result_id: input.engineResultId, - p_algorithm_version: input.algorithmVersion, p_evidence_ledger_fingerprint: input.evidenceFingerprint, p_candidate_range_fingerprint: input.rangeFingerprint, p_skill_version: input.skillVersion, + p_algorithm_version: input.algorithmVersion, + p_event_contract_version: input.eventContractVersion, + p_decision_policy_version: input.policyVersion, p_candidate_range: input.candidateRange, - p_candidates: input.candidates, - p_overall_confidence: input.overallConfidence, - p_margin_percent: input.marginPercent, - p_selection_allowed: input.selectionAllowed, - p_confirmation_allowed: input.confirmationAllowed, - p_representative_time: input.representativeTime, + p_candidates: input.candidates.map((candidate) => ({ + candidate_id: candidate.candidateId, + time: candidate.time, + rank: candidate.rank, + relative_support: candidate.relativeSupport, + tied_minute_count: candidate.tiedMinuteCount, + })), + p_decision_receipt: input.decisionReceipt, + p_execution_ledger: input.executionLedger, }, ); - const resultId = typeof row?.result_id === "string" ? row.result_id : ""; - if (!resultId) throw new RectificationToolServiceError("invalid_candidate_result"); + const snapshot = parseV9CandidateSnapshot(row); + if (!snapshot) throw new RectificationToolServiceError("invalid_candidate_result"); return { - resultId, - cached: row?.cached === true, - candidates: parseV9CandidateSnapshot(row)?.candidates ?? input.candidates, - overallConfidence: row?.overall_confidence === "high" || row?.overall_confidence === "medium" ? row.overall_confidence : "low", - marginPercent: typeof row?.margin_percent === "number" ? row.margin_percent : input.marginPercent, - selectionAllowed: row?.selection_allowed === true, - confirmationAllowed: row?.confirmation_allowed === true, - representativeTime: timeValue(row?.representative_time), - algorithmVersion: typeof row?.algorithm_version === "string" ? row.algorithm_version : input.algorithmVersion, + resultId: snapshot.resultId, + cached: row.cached === true, + candidates: snapshot.candidates, + overallConfidence: snapshot.overallConfidence, + selectionAllowed: snapshot.selectionAllowed, + confirmationAllowed: snapshot.confirmationAllowed, + representativeTime: snapshot.representativeTime, + algorithmVersion: snapshot.algorithmVersion ?? input.algorithmVersion, + eventContractVersion: snapshot.eventContractVersion ?? input.eventContractVersion, + policyVersion: snapshot.policyVersion ?? input.policyVersion, + decisionReceipt: snapshot.decisionReceipt ?? input.decisionReceipt, + executionLedger: snapshot.executionLedger ?? input.executionLedger, }; } export type V9AcceptResult = Readonly<{ success: boolean; savedTime: string; - status: "accepted" | "confirmed"; + status: "accepted"; resultId: string; caseStatus: string; idempotent: boolean; @@ -1242,27 +1289,34 @@ export async function acceptV9Candidate( userId: string, caseId: string, resultId: string, - time: string, + candidateId: string, + requestId: string, ): Promise { + if (!uuidPattern.test(candidateId) || !uuidPattern.test(requestId)) { + throw new RectificationToolServiceError("invalid_candidate_ref"); + } const row = await rpc>( accounting, - "accept_agentic_rectification_candidate_for_case", + "accept_agentic_rectification_candidate_for_case_v2", { p_user_id: userId, p_case_id: caseId, p_result_id: resultId, - p_time: time, + p_candidate_id: candidateId, + p_request_id: requestId, }, ); - const savedTime = timeValue(row?.saved_time); - if (row?.success !== true || !savedTime) throw new RectificationToolServiceError("invalid_accept_result"); + const savedTime = timeValue(row.saved_time); + if (row.success !== true || row.status !== "accepted" || !savedTime) { + throw new RectificationToolServiceError("invalid_accept_result"); + } return { success: true, savedTime, - status: row?.status === "confirmed" ? "confirmed" : "accepted", - resultId: String(row?.result_id ?? resultId), - caseStatus: String(row?.case_status ?? "candidate_accepted"), - idempotent: row?.idempotent === true, + status: "accepted", + resultId: String(row.result_id ?? resultId), + caseStatus: String(row.case_status ?? "candidate_accepted"), + idempotent: row.idempotent === true, }; } @@ -1281,32 +1335,39 @@ export async function confirmV9BirthTime( caseId: string, input: { resultId: string; - time: string; + candidateId: string; + requestId: string; consentQuote: string; sourceTurnId: string; }, ): Promise { + if (!uuidPattern.test(input.candidateId) || !uuidPattern.test(input.requestId)) { + throw new RectificationToolServiceError("invalid_candidate_ref"); + } const row = await rpc>( accounting, - "confirm_agentic_rectification_birth_time", + "confirm_agentic_rectification_candidate_for_case_v2", { p_user_id: userId, p_case_id: caseId, p_result_id: input.resultId, - p_time: input.time, + p_candidate_id: input.candidateId, + p_request_id: input.requestId, p_consent_quote: input.consentQuote, p_source_turn_id: input.sourceTurnId, }, ); - const savedTime = timeValue(row?.saved_time); - if (row?.success !== true || !savedTime) throw new RectificationToolServiceError("invalid_confirm_result"); + const savedTime = timeValue(row.saved_time); + if (row.success !== true || row.status !== "confirmed" || !savedTime) { + throw new RectificationToolServiceError("invalid_confirm_result"); + } return { success: true, savedTime, status: "confirmed", - resultId: String(row?.result_id ?? input.resultId), - caseStatus: String(row?.case_status ?? "confirmed"), - idempotent: row?.idempotent === true, + resultId: String(row.result_id ?? input.resultId), + caseStatus: String(row.case_status ?? "confirmed"), + idempotent: row.idempotent === true, }; } diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index d3cc35ac..8a40c571 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -1,4 +1,5 @@ export type RectificationCandidate = Readonly<{ + candidateId: string; rank: number; time: string; relativeSupport: number; @@ -34,24 +35,30 @@ function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +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; + export function parseRectificationCandidateResult(value: unknown): RectificationCandidateResult | null { const snapshot = record(value); if (!snapshot || typeof snapshot.resultId !== "string") return null; + if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length === 0) return null; - const candidates = Array.isArray(snapshot.candidates) - ? snapshot.candidates.flatMap((value): RectificationCandidate[] => { - const candidate = record(value); - const candidateTime = time(candidate?.time); - const rank = finiteNumber(candidate?.rank); - if (!candidate || !candidateTime || rank === null) return []; - return [{ - rank: Math.trunc(rank), - time: candidateTime, - relativeSupport: Math.max(0, Math.trunc(finiteNumber(candidate.relative_support) ?? 0)), - tiedMinuteCount: Math.max(1, Math.trunc(finiteNumber(candidate.tied_minute_count) ?? 1)), - }]; - }) - : []; + const candidates: RectificationCandidate[] = []; + for (const value of snapshot.candidates) { + const candidate = record(value); + const candidateId = candidate?.candidateId; + const candidateTime = time(candidate?.time); + const rank = finiteNumber(candidate?.rank); + const relativeSupport = finiteNumber(candidate?.relativeSupport); + const tiedMinuteCount = finiteNumber(candidate?.tiedMinuteCount); + if ( + !candidate || typeof candidateId !== "string" || !uuidPattern.test(candidateId) + || !candidateTime + || rank === null || !Number.isInteger(rank) || rank < 1 + || relativeSupport === null || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100 + || tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1 + ) return null; + candidates.push({ candidateId, rank, time: candidateTime, relativeSupport, tiedMinuteCount }); + } return { resultId: snapshot.resultId, diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index bbcfd6a4..63c68718 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -151,7 +151,7 @@ type DossierForTools = { kindCounts: Record; latestResult: { resultId: string; - candidates: readonly { rank: number; time: string; relative_support: number; tied_minute_count: number }[]; + candidates: NonNullable["candidates"]; selectionAllowed: boolean; confirmationAllowed: boolean; representativeTime: string | null; @@ -300,7 +300,7 @@ function assertEvidenceRef(input: { evidenceId?: unknown }): string { function assertCandidateRef(input: { resultId?: unknown; candidateId?: unknown }): { resultId: string; candidateId: string } { if ( typeof input.resultId !== "string" || !uuidPattern.test(input.resultId) - || typeof input.candidateId !== "string" || !timePattern.test(input.candidateId) + || typeof input.candidateId !== "string" || !uuidPattern.test(input.candidateId) ) { throw new RectificationToolServiceError("invalid_candidate_ref"); } @@ -759,13 +759,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { evidenceFingerprint, rangeFingerprint, skillVersion: parsed.case.skillVersion, + eventContractVersion: score.eventContractVersion, + policyVersion: score.policyVersion, candidateRange: parsed.case.candidateRange, candidates: score.candidates, - overallConfidence: score.overallConfidence, - marginPercent: score.marginPercent, - selectionAllowed: score.selectionAllowed, - confirmationAllowed: score.confirmationAllowed, - representativeTime: score.representativeTime, + decisionReceipt: score.decisionReceipt, + executionLedger: score.executionLedger, }); const projection = { result_id: persisted.resultId, @@ -892,7 +891,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { inputSchema: z.object({ caseId: z.string().uuid(), resultId: z.string().uuid(), - candidateId: z.string().regex(timePattern), + candidateId: z.string().uuid(), }).strict(), execute: async (input) => { assertCaseRef(input); @@ -900,7 +899,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const inputFingerprint = canonicalToolInputFingerprint("rectification-accept-candidate", input); await receipt("rectification-accept-candidate", "candidate.accepted", "started", { inputFingerprint }); try { - const result = await acceptV9Candidate(accounting, userId, input.caseId, resultId, input.candidateId); + const result = await acceptV9Candidate(accounting, userId, input.caseId, resultId, input.candidateId, turnId); const projection = { saved_time: result.savedTime, status: result.status, @@ -927,7 +926,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { inputSchema: z.object({ caseId: z.string().uuid(), resultId: z.string().uuid(), - candidateId: z.string().regex(timePattern), + candidateId: z.string().uuid(), consentQuote: z.string().trim().min(2).max(400), }).strict(), execute: async (input) => { @@ -942,7 +941,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { try { const result = await confirmV9BirthTime(accounting, userId, input.caseId, { resultId, - time: input.candidateId, + candidateId: input.candidateId, + requestId: turnId, consentQuote: input.consentQuote, sourceTurnId: turnId, }); diff --git a/frontend/supabase/migrations/20260814030000_rectification_event_decision_contract_v2.sql b/frontend/supabase/migrations/20260814030000_rectification_event_decision_contract_v2.sql new file mode 100644 index 00000000..a632871a --- /dev/null +++ b/frontend/supabase/migrations/20260814030000_rectification_event_decision_contract_v2.sql @@ -0,0 +1,1129 @@ +-- PR-4: server-owned event/decision contract persistence and candidate UUID boundary. +-- Forward-only: historical candidate migrations and RPC definitions remain unchanged, +-- while service_role is moved to explicit V2 entry points that never accept HH:MM +-- for candidate acceptance or confirmation. + +begin; + +-- --------------------------------------------------------------------------- +-- 1. Durable decision contract fields on candidate results +-- --------------------------------------------------------------------------- + +alter table public.agentic_rectification_results + add column if not exists event_contract_version text; +alter table public.agentic_rectification_results + add column if not exists decision_policy_version text; +alter table public.agentic_rectification_results + add column if not exists decision_receipt jsonb; +alter table public.agentic_rectification_results + add column if not exists execution_ledger jsonb; +alter table public.agentic_rectification_results + add column if not exists display_allowed boolean not null default false; +alter table public.agentic_rectification_results + add column if not exists selected_candidate_id uuid; + +alter table public.agentic_rectification_results + drop constraint if exists agentic_rectification_results_event_contract_version_check; +alter table public.agentic_rectification_results + add constraint agentic_rectification_results_event_contract_version_check check ( + event_contract_version is null + or length(btrim(event_contract_version)) between 1 and 120 + ); + +alter table public.agentic_rectification_results + drop constraint if exists agentic_rectification_results_decision_policy_version_check; +alter table public.agentic_rectification_results + add constraint agentic_rectification_results_decision_policy_version_check check ( + decision_policy_version is null + or length(btrim(decision_policy_version)) between 1 and 120 + ); + +alter table public.agentic_rectification_results + drop constraint if exists agentic_rectification_results_decision_receipt_check; +alter table public.agentic_rectification_results + add constraint agentic_rectification_results_decision_receipt_check check ( + decision_receipt is null + or case + when jsonb_typeof(decision_receipt) = 'object' + and jsonb_typeof(decision_receipt -> 'display_allowed') = 'boolean' + and jsonb_typeof(decision_receipt -> 'accept_allowed') = 'boolean' + and jsonb_typeof(decision_receipt -> 'confirm_allowed') = 'boolean' + then display_allowed = (decision_receipt ->> 'display_allowed')::boolean + and selection_allowed = (decision_receipt ->> 'accept_allowed')::boolean + and confirmation_allowed = (decision_receipt ->> 'confirm_allowed')::boolean + else false + end + ); + +alter table public.agentic_rectification_results + drop constraint if exists agentic_rectification_results_execution_ledger_check; +alter table public.agentic_rectification_results + add constraint agentic_rectification_results_execution_ledger_check check ( + execution_ledger is null or jsonb_typeof(execution_ledger) = 'array' + ); + +create unique index if not exists agentic_rectification_results_candidate_owner_unique + on public.agentic_rectification_results (id, user_id, case_id); + +create index if not exists agentic_rectification_results_v2_cache_idx + on public.agentic_rectification_results ( + case_id, + evidence_ledger_fingerprint, + candidate_range_fingerprint, + skill_version, + algorithm_version, + event_contract_version, + decision_policy_version, + created_at desc + ) + where invalidated_at is null and event_contract_version is not null; + +-- --------------------------------------------------------------------------- +-- 2. Server-issued candidate UUIDs and request-id decision ledger +-- --------------------------------------------------------------------------- + +create table if not exists public.agentic_rectification_candidates ( + id uuid primary key default gen_random_uuid(), + result_id uuid not null, + user_id uuid not null, + case_id uuid not null, + ordinal integer not null check (ordinal > 0), + candidate_time time without time zone not null, + candidate_payload jsonb not null check (jsonb_typeof(candidate_payload) = 'object'), + is_representative boolean not null default false, + created_at timestamptz not null default pg_catalog.now(), + foreign key (result_id, user_id, case_id) + references public.agentic_rectification_results (id, user_id, case_id) + on delete cascade, + unique (result_id, ordinal), + unique (result_id, candidate_time) +); + +create unique index if not exists agentic_rectification_candidates_owner_unique + on public.agentic_rectification_candidates (id, result_id, user_id, case_id); +create index if not exists agentic_rectification_candidates_case_result_idx + on public.agentic_rectification_candidates (case_id, result_id, ordinal); +create unique index if not exists agentic_rectification_candidates_one_representative_idx + on public.agentic_rectification_candidates (result_id) + where is_representative; + +alter table public.agentic_rectification_results + drop constraint if exists agentic_rectification_results_selected_candidate_fk; +alter table public.agentic_rectification_results + add constraint agentic_rectification_results_selected_candidate_fk + foreign key (selected_candidate_id) + references public.agentic_rectification_candidates(id) + on delete set null; + +create table if not exists public.agentic_rectification_candidate_decisions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + case_id uuid not null, + result_id uuid not null, + candidate_id uuid not null, + request_id uuid not null, + decision_kind text not null check (decision_kind in ('accept', 'confirm')), + response jsonb not null check (jsonb_typeof(response) = 'object'), + created_at timestamptz not null default pg_catalog.now(), + foreign key (candidate_id, result_id, user_id, case_id) + references public.agentic_rectification_candidates (id, result_id, user_id, case_id), + unique (user_id, request_id) +); + +create index if not exists agentic_rectification_candidate_decisions_case_idx + on public.agentic_rectification_candidate_decisions (case_id, created_at desc); + +alter table public.agentic_rectification_candidates enable row level security; +alter table public.agentic_rectification_candidate_decisions enable row level security; + +revoke all on table public.agentic_rectification_candidates + from public, anon, authenticated, service_role; +revoke all on table public.agentic_rectification_candidate_decisions + from public, anon, authenticated, service_role; + +-- --------------------------------------------------------------------------- +-- 3. V2 candidate persistence +-- +-- Booleans and presentation/decision fields are derived from decision_receipt. +-- The caller cannot provide them as independent function arguments. Candidate +-- UUIDs are generated here and caller-provided candidate_id fields are removed. +-- --------------------------------------------------------------------------- + +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', 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', 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; + +-- --------------------------------------------------------------------------- +-- 4. V2 recovery projections: preserve existing shape and expose decision data +-- --------------------------------------------------------------------------- + +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', 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', 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; + +-- --------------------------------------------------------------------------- +-- 5. V2 acceptance: UUID lookup, request idempotency, accepted-only state +-- --------------------------------------------------------------------------- + +create or replace function public.accept_agentic_rectification_candidate_for_case_v2( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_candidate_id uuid, + p_request_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_candidate public.agentic_rectification_candidates%rowtype; + v_profile public.profiles%rowtype; + v_snapshot jsonb; + v_existing_decision public.agentic_rectification_candidate_decisions%rowtype; + v_response jsonb; +begin + if p_user_id is null or p_case_id is null or p_result_id is null + or p_candidate_id is null or p_request_id is null then + raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001'; + end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0) + ); + + 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; + + select * into v_result + from public.agentic_rectification_results + where id = p_result_id + and user_id = p_user_id + and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_candidate + from public.agentic_rectification_candidates + where id = p_candidate_id + and result_id = p_result_id + and user_id = p_user_id + and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_existing_decision + from public.agentic_rectification_candidate_decisions + where user_id = p_user_id and request_id = p_request_id + for update; + if found then + if v_existing_decision.decision_kind <> 'accept' + or v_existing_decision.case_id <> p_case_id + or v_existing_decision.result_id <> p_result_id + or v_existing_decision.candidate_id <> p_candidate_id then + raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001'; + end if; + return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true); + 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_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then + raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; + end if; + if not v_result.display_allowed or not v_result.selection_allowed then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + select * into v_profile + from public.profiles + where id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + if v_result.selected_candidate_id is not null then + if v_result.selected_candidate_id = p_candidate_id + and v_result.selected_time is not distinct from v_candidate.candidate_time + and v_case.accepted_time is not distinct from v_candidate.candidate_time + and v_result.selection_kind is not distinct from 'user_accepted' + and v_profile.active_birth_time is not distinct from v_candidate.candidate_time + and v_profile.birth_time_status is not distinct from 'accepted' then + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'status', 'accepted', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'candidate_accepted', + 'idempotent', true + ); + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response + ); + return v_response; + end if; + + if v_case.status is distinct from 'candidate_accepted' + or v_result.selection_kind is distinct from 'user_accepted' + or v_profile.active_birth_time is distinct from v_result.selected_time + or v_profile.birth_time_status is distinct from 'accepted' then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + else + if v_result.selected_time is not null then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + v_snapshot := v_case.baseline_birth_snapshot; + if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.active_birth_time is distinct from (v_snapshot ->> 'active_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + end if; + + update public.profiles + set active_birth_time = v_candidate.candidate_time, + birth_time = v_candidate.candidate_time, + birth_time_status = 'accepted', + rectification_confidence = case + when v_result.overall_confidence = 'high' then 100 + when v_result.overall_confidence = 'medium' then 70 + else 40 + end, + updated_at = pg_catalog.now() + where id = p_user_id; + + update public.agentic_rectification_results + set selected_candidate_id = p_candidate_id, + selected_time = v_candidate.candidate_time, + selection_kind = 'user_accepted', + selected_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_result.id; + + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where user_id = p_user_id + and case_id = p_case_id + and id <> v_result.id + and invalidated_at is null + and selected_time is null; + + update public.agentic_rectification_cases + set status = 'candidate_accepted', + accepted_time = v_candidate.candidate_time, + confirmed_time = null, + completed_at = null, + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'status', 'accepted', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'candidate_accepted', + 'idempotent', false + ); + + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response + ); + + return v_response; +end; +$$; + +revoke all on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid) + from public, anon, authenticated; +grant execute on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid) + to service_role; + +-- --------------------------------------------------------------------------- +-- 5. V2 confirmation: exact representative gate plus grounded user consent +-- --------------------------------------------------------------------------- + +create or replace function public.confirm_agentic_rectification_candidate_for_case_v2( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_candidate_id uuid, + p_request_id uuid, + p_consent_quote text, + p_source_turn_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_candidate public.agentic_rectification_candidates%rowtype; + v_profile public.profiles%rowtype; + v_turn public.agentic_rectification_turns%rowtype; + v_snapshot jsonb; + v_existing_decision public.agentic_rectification_candidate_decisions%rowtype; + v_response jsonb; +begin + if p_user_id is null or p_case_id is null or p_result_id is null + or p_candidate_id is null or p_request_id is null or p_source_turn_id is null + or length(btrim(coalesce(p_consent_quote, ''))) = 0 then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0) + ); + + 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; + + select * into v_result + from public.agentic_rectification_results + where id = p_result_id + and user_id = p_user_id + and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_candidate + from public.agentic_rectification_candidates + where id = p_candidate_id + and result_id = p_result_id + and user_id = p_user_id + and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + + select * into v_existing_decision + from public.agentic_rectification_candidate_decisions + where user_id = p_user_id and request_id = p_request_id + for update; + if found then + if v_existing_decision.decision_kind <> 'confirm' + or v_existing_decision.case_id <> p_case_id + or v_existing_decision.result_id <> p_result_id + or v_existing_decision.candidate_id <> p_candidate_id then + raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001'; + end if; + return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true); + end if; + + if v_case.status = 'confirmed' then + if v_result.selected_candidate_id is distinct from p_candidate_id + or v_case.confirmed_time is distinct from v_candidate.candidate_time then + raise exception 'agentic_rectification_case_already_confirmed' using errcode = 'P0001'; + end if; + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'status', 'confirmed', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'confirmed', + 'idempotent', true + ); + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'confirm', v_response + ); + return v_response; + end if; + if v_case.status in ('closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_turn + from public.agentic_rectification_turns + where id = p_source_turn_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + if v_turn.user_message is null + or position( + public.agentic_rectification_normalize_quote(p_consent_quote) + in public.agentic_rectification_normalize_quote(v_turn.user_message) + ) = 0 then + raise exception 'agentic_rectification_consent_not_grounded' using errcode = 'P0001'; + end if; + + if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then + raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; + end if; + if not v_result.confirmation_allowed then + raise exception 'agentic_rectification_confirmation_blocked' using errcode = 'P0001'; + end if; + if not v_candidate.is_representative + or v_result.representative_time is distinct from v_candidate.candidate_time then + raise exception 'agentic_rectification_confirmation_exact_gate_blocked' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + select * into v_profile + from public.profiles + where id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + if v_result.selected_candidate_id is null then + if v_result.selected_time is not null then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + v_snapshot := v_case.baseline_birth_snapshot; + if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.active_birth_time is distinct from (v_snapshot ->> 'active_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + elsif v_result.selected_candidate_id <> p_candidate_id + or v_result.selected_time is distinct from v_candidate.candidate_time + or v_result.selection_kind is distinct from 'user_accepted' + or v_case.status is distinct from 'candidate_accepted' + or v_case.accepted_time is distinct from v_candidate.candidate_time + or v_profile.active_birth_time is distinct from v_candidate.candidate_time + or v_profile.birth_time_status is distinct from 'accepted' then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + + update public.profiles + set active_birth_time = v_candidate.candidate_time, + birth_time = v_candidate.candidate_time, + birth_time_status = 'confirmed', + rectification_confidence = case + when v_result.overall_confidence = 'high' then 100 + when v_result.overall_confidence = 'medium' then 70 + else 40 + end, + updated_at = pg_catalog.now() + where id = p_user_id; + + update public.agentic_rectification_results + set selected_candidate_id = p_candidate_id, + selected_time = v_candidate.candidate_time, + selection_kind = 'engine_confirmed', + selected_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_result.id; + + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where user_id = p_user_id + and case_id = p_case_id + and id <> v_result.id + and invalidated_at is null + and selected_time is null; + + update public.agentic_rectification_cases + set status = 'confirmed', + accepted_time = v_candidate.candidate_time, + confirmed_time = v_candidate.candidate_time, + completed_at = pg_catalog.now(), + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + v_response := jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), + 'status', 'confirmed', + 'result_id', v_result.id, + 'candidate_id', v_candidate.id, + 'case_status', 'confirmed', + 'idempotent', false + ); + + insert into public.agentic_rectification_candidate_decisions ( + user_id, case_id, result_id, candidate_id, request_id, decision_kind, response + ) values ( + p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'confirm', v_response + ); + + return v_response; +end; +$$; + +revoke all on function public.confirm_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, text, uuid) + from public, anon, authenticated; +grant execute on function public.confirm_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, text, uuid) + to service_role; + +-- --------------------------------------------------------------------------- +-- 7. Retire every legacy service-role entry that accepts caller-provided time +-- --------------------------------------------------------------------------- + +revoke execute on function public.persist_agentic_rectification_candidate(uuid, uuid, text, text, text, text, text, jsonb, jsonb, text, numeric, boolean, boolean, time without time zone) from service_role; +revoke execute on function public.accept_agentic_rectification_candidate(uuid, uuid, uuid, time without time zone) from service_role; +revoke execute on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone) from service_role; +revoke execute on function public.confirm_agentic_rectification_birth_time(uuid, uuid, uuid, time without time zone, text, uuid) from service_role; + +commit; diff --git a/frontend/tests/agentic-rectification-candidate-acceptance-migration.test.ts b/frontend/tests/agentic-rectification-candidate-acceptance-migration.test.ts index 56582215..a5ee2397 100644 --- a/frontend/tests/agentic-rectification-candidate-acceptance-migration.test.ts +++ b/frontend/tests/agentic-rectification-candidate-acceptance-migration.test.ts @@ -12,6 +12,14 @@ const preservationMigration = readFileSync( "utf8", ); +const decisionContractV2Migration = readFileSync( + new URL( + "../supabase/migrations/20260814030000_rectification_event_decision_contract_v2.sql", + import.meta.url, + ), + "utf8", +); + test("candidate acceptance migration adds accepted status and durable result ownership", () => { assert.match(migration, /birth_time_status in \([\s\S]*'reported'[\s\S]*'assessing'[\s\S]*'rectifying'[\s\S]*'candidate'[\s\S]*'accepted'[\s\S]*'confirmed'[\s\S]*\)/); assert.match(migration, /create table public\.agentic_rectification_results/); @@ -101,3 +109,59 @@ test("forward repair lets the user change a previously adopted candidate", () => assert.match(preservationMigration, /v_profile\.active_birth_time is distinct from v_result\.selected_time/); assert.match(preservationMigration, /update public\.agentic_rectification_results[\s\S]*selected_time = p_time/); }); + +test("decision contract V2 accepts server candidate UUIDs and never caller-supplied times", () => { + const acceptStart = decisionContractV2Migration.indexOf( + "create or replace function public.accept_agentic_rectification_candidate_for_case_v2(", + ); + const confirmStart = decisionContractV2Migration.indexOf( + "create or replace function public.confirm_agentic_rectification_candidate_for_case_v2(", + ); + const acceptFunction = decisionContractV2Migration.slice(acceptStart, confirmStart); + const confirmFunction = decisionContractV2Migration.slice(confirmStart); + + assert.match(acceptFunction, /p_candidate_id uuid/); + assert.match(acceptFunction, /p_request_id uuid/); + assert.doesNotMatch(acceptFunction, /p_time\s+time/); + assert.match(confirmFunction, /p_candidate_id uuid/); + assert.match(confirmFunction, /p_request_id uuid/); + assert.doesNotMatch(confirmFunction, /p_time\s+time/); + + for (const functionBody of [acceptFunction, confirmFunction]) { + assert.match(functionBody, /where id = p_result_id[\s\S]*user_id = p_user_id[\s\S]*case_id = p_case_id/); + assert.match(functionBody, /where id = p_candidate_id[\s\S]*result_id = p_result_id[\s\S]*user_id = p_user_id[\s\S]*case_id = p_case_id/); + assert.match(functionBody, /v_candidate\.candidate_time/); + } +}); + +test("decision contract V2 acceptance can only write accepted state", () => { + const acceptStart = decisionContractV2Migration.indexOf( + "create or replace function public.accept_agentic_rectification_candidate_for_case_v2(", + ); + const confirmStart = decisionContractV2Migration.indexOf( + "create or replace function public.confirm_agentic_rectification_candidate_for_case_v2(", + ); + const acceptFunction = decisionContractV2Migration.slice(acceptStart, confirmStart); + + assert.match(acceptFunction, /birth_time_status = 'accepted'/); + assert.match(acceptFunction, /selection_kind = 'user_accepted'/); + assert.match(acceptFunction, /status = 'candidate_accepted'/); + assert.doesNotMatch(acceptFunction, /engine_confirmed/); + assert.doesNotMatch(acceptFunction, /birth_time_status = 'confirmed'/); +}); + +test("decision contract V2 confirmation remains a separate exact and consent-grounded gate", () => { + const confirmStart = decisionContractV2Migration.indexOf( + "create or replace function public.confirm_agentic_rectification_candidate_for_case_v2(", + ); + const confirmFunction = decisionContractV2Migration.slice(confirmStart); + + assert.match(confirmFunction, /if not v_result\.confirmation_allowed/); + assert.match(confirmFunction, /if not v_candidate\.is_representative/); + assert.match(confirmFunction, /agentic_rectification_confirmation_exact_gate_blocked/); + assert.match(confirmFunction, /public\.agentic_rectification_normalize_quote\(p_consent_quote\)/); + assert.match(confirmFunction, /public\.agentic_rectification_normalize_quote\(v_turn\.user_message\)/); + assert.match(confirmFunction, /agentic_rectification_consent_not_grounded/); + assert.match(confirmFunction, /birth_time_status = 'confirmed'/); + assert.match(confirmFunction, /selection_kind = 'engine_confirmed'/); +}); diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts index 28c22cfa..5c08cb0b 100644 --- a/frontend/tests/rectification-candidate-result.test.ts +++ b/frontend/tests/rectification-candidate-result.test.ts @@ -6,12 +6,16 @@ import { parseRectificationCandidateResult, } from "../src/lib/rectification-candidate-result.ts"; +const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881"; +const SECOND_CANDIDATE_ID = "88888888-8888-4888-8888-888888888882"; +const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883"; + const camelCaseSnapshot = { resultId: "11111111-1111-4111-8111-111111111111", candidates: [ - { rank: 1, time: "05:07", relative_support: 34, tied_minute_count: 1 }, - { rank: 2, time: "05:08", relative_support: 33, tied_minute_count: 1 }, - { rank: 3, time: "05:09", relative_support: 33, tied_minute_count: 1 }, + { candidateId: CANDIDATE_ID, rank: 1, time: "05:07", relativeSupport: 34, tiedMinuteCount: 1 }, + { candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "05:08", relativeSupport: 33, tiedMinuteCount: 1 }, + { candidateId: THIRD_CANDIDATE_ID, rank: 3, time: "05:09", relativeSupport: 33, tiedMinuteCount: 1 }, ], overallConfidence: "low", selectionAllowed: true, @@ -27,6 +31,7 @@ test("parses the camelCase Candidate Snapshot returned by the Case API", () => { assert.ok(result); assert.equal(result.resultId, camelCaseSnapshot.resultId); assert.equal(result.candidates[0]?.time, "05:07"); + assert.equal(result.candidates[0]?.candidateId, CANDIDATE_ID); assert.equal(result.candidates[0]?.relativeSupport, 34); assert.equal(result.selectionAllowed, true); assert.equal(result.confirmationAllowed, false); @@ -54,3 +59,16 @@ test("only an unselected confirmation-ready representative receives a recommenda const adopted = { ...result, selectedTime: "05:07" }; assert.equal(isRecommendedRectificationCandidate(adopted, adopted.candidates[0]!), false); }); + + +test("fails closed instead of treating an HH:MM display value as a candidate id", () => { + const result = parseRectificationCandidateResult({ + ...camelCaseSnapshot, + candidates: [{ + ...camelCaseSnapshot.candidates[0], + candidateId: "05:07", + }], + }); + + assert.equal(result, null); +}); diff --git a/frontend/tests/rectification-pr4-database.test.ts b/frontend/tests/rectification-pr4-database.test.ts new file mode 100644 index 00000000..02aa4a67 --- /dev/null +++ b/frontend/tests/rectification-pr4-database.test.ts @@ -0,0 +1,514 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + closeLocalPostgresDataPool, + createLocalPostgresDataClient, +} from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); +const migrationSql = readFileSync( + new URL("../supabase/migrations/20260814030000_rectification_event_decision_contract_v2.sql", import.meta.url), + "utf8", +); + +function dockerAvailable(): boolean { + return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + stdio: "ignore", + }).status === 0; +} + +const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function rpcError(error: unknown): string { + if (!error || typeof error !== "object") return ""; + const value = error as { message?: unknown }; + return typeof value.message === "string" ? value.message : ""; +} + +function candidateIds(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + const rows = (value as { candidates?: unknown }).candidates; + if (!Array.isArray(rows)) return []; + return rows.flatMap((row) => { + if (!row || typeof row !== "object") return []; + const id = (row as { candidate_id?: unknown }).candidate_id; + return typeof id === "string" ? [id] : []; + }); +} + +function objectField(value: unknown, field: string): Record { + if (!value || typeof value !== "object") return {}; + const nested = (value as Record)[field]; + return nested && typeof nested === "object" && !Array.isArray(nested) + ? (nested as Record) + : {}; +} + +test("PR-4 migration binds receipts to server candidate UUIDs and restores V2 result fields", () => { + const persistStart = migrationSql.indexOf( + "create or replace function public.persist_agentic_rectification_candidate_v2(", + ); + const caseStart = migrationSql.indexOf( + "create or replace function public.get_agentic_rectification_case(", + ); + const dossierStart = migrationSql.indexOf( + "create or replace function public.get_agentic_rectification_case_dossier(", + ); + const acceptStart = migrationSql.indexOf( + "create or replace function public.accept_agentic_rectification_candidate_for_case_v2(", + ); + assert.ok(persistStart >= 0 && caseStart > persistStart && dossierStart > caseStart && acceptStart > dossierStart); + + const persistFunction = migrationSql.slice(persistStart, caseStart); + assert.match(persistFunction, /p_decision_receipt - 'representative_candidate_id'/); + assert.match(persistFunction, /v_representative_candidate_id := v_candidate_id/); + assert.match(persistFunction, /\{representative_candidate_id\}/); + assert.match(persistFunction, /decision_receipt = v_saved_decision_receipt/); + assert.match(persistFunction, /'decision_receipt', v_saved_decision_receipt/); + assert.match(persistFunction, /'decision_receipt', v_cached\.decision_receipt/); + + for (const functionSql of [ + migrationSql.slice(caseStart, dossierStart), + migrationSql.slice(dossierStart, acceptStart), + ]) { + for (const field of [ + "event_contract_version", + "decision_policy_version", + "decision_receipt", + "execution_ledger", + "selected_candidate_id", + ]) { + assert.match(functionSql, new RegExp(`'${field}', v_result\.${field}`)); + } + } + const dossierFunction = migrationSql.slice(dossierStart, acceptStart); + assert.match(dossierFunction, /\(1, 'user'::text, recent\.user_message\)/); + assert.match(dossierFunction, /\(2, 'assistant'::text, recent\.assistant_message\)/); +}); + +test("PR-4 candidate decisions use server UUIDs, receipt-derived gates and separate acceptance/confirmation", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + assert.match( + migration.stdout, + /applied 20260814030000_rectification_event_decision_contract_v2\.sql/, + ); + + const userId = "10000000-0000-4000-8000-000000000001"; + const sessionId = "20000000-0000-4000-8000-000000000001"; + const secondSessionId = "20000000-0000-4000-8000-000000000002"; + const caseId = "30000000-0000-4000-8000-000000000001"; + const secondCaseId = "30000000-0000-4000-8000-000000000002"; + const consentTurnId = "40000000-0000-4000-8000-000000000001"; + const fakeCallerCandidateId = "50000000-0000-4000-8000-000000000001"; + const fingerprint = "a".repeat(64); + const snapshot = { + birth_date: "1997-08-08", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, + birth_time_source: "family_exact", + reported_birth_time: "05:00", + active_birth_time: null, + birth_time_period: null, + uncertainty_before_minutes: 10, + uncertainty_after_minutes: 10, + }; + const range = { start_time: "04:50", end_time: "05:10" }; + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id, name, email, email_verified, email_verified_at) + values ('${userId}', 'PR4 Candidate Fixture', 'pr4-candidate@example.com', true, now()) + `); + fixture.psql(` + update public.profiles + set birth_date = '1997-08-08', + reported_birth_time = '05:00', + active_birth_time = null, + birth_time = '05:00', + birth_time_status = 'reported', + birth_time_source = 'family_exact', + birth_time_period = null, + uncertainty_before_minutes = 10, + uncertainty_after_minutes = 10, + birth_place_label = '河北省邯郸市武安市', + latitude = 36.420487, + longitude = 114.209936, + timezone_id = 'Asia/Shanghai', + timezone_offset = 8 + where id = '${userId}'; + + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages) + values + ('${sessionId}', '${userId}', 'PR4 candidate case', 'general', 'birth_time_rectification', '[]'::jsonb), + ('${secondSessionId}', '${userId}', 'PR4 other case', 'general', 'birth_time_rectification', '[]'::jsonb); + + insert into public.agentic_rectification_cases ( + id, user_id, session_id, status, skill_name, skill_version, + baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range + ) values + ( + '${caseId}', '${userId}', '${sessionId}', 'candidate_ready', + 'jyotish-birth-time-rectification', '9.0.0', '${fingerprint}', + '${JSON.stringify(snapshot)}'::jsonb, '${JSON.stringify(range)}'::jsonb + ), + ( + '${secondCaseId}', '${userId}', '${secondSessionId}', 'candidate_ready', + 'jyotish-birth-time-rectification', '9.0.0', '${"b".repeat(64)}', + '${JSON.stringify(snapshot)}'::jsonb, '${JSON.stringify(range)}'::jsonb + ); + + insert into public.agentic_rectification_turns ( + id, case_id, request_id, user_message, assistant_message, status, model_name, completed_at + ) values ( + '${consentTurnId}', '${caseId}', '41000000-0000-4000-8000-000000000001', + '我确认使用05:07作为出生时间', '已记录确认请求', 'completed', 'fixture-model', now() + ); + `); + + const service = createLocalPostgresDataClient(serviceUrl, null, "service_role"); + const candidates = [ + { + candidate_id: fakeCallerCandidateId, + rank: 1, + time: "05:07", + relative_support: 60, + tied_minute_count: 1, + }, + { rank: 2, time: "05:08", relative_support: 40, tied_minute_count: 1 }, + ]; + const decisionReceipt = { + display_allowed: true, + accept_allowed: true, + confirm_allowed: true, + representative_time: "05:07", + overall_confidence: "high", + margin_percent: 20, + representative_candidate_id: fakeCallerCandidateId, + }; + const executionLedger = [ + { phase: "candidate.score", status: "completed", engine: "fixture-engine" }, + { phase: "decision.evaluate", status: "completed", policy: "decision-policy-v1" }, + ]; + const persist = (engineResultId: string, policyVersion: string) => + service.rpc("persist_agentic_rectification_candidate_v2", { + p_user_id: userId, + p_case_id: caseId, + p_engine_result_id: engineResultId, + p_evidence_ledger_fingerprint: "c".repeat(64), + p_candidate_range_fingerprint: "d".repeat(64), + p_skill_version: "9.0.0", + p_algorithm_version: "rectification-pr4-fixture-v1", + p_event_contract_version: "rectification-event-contract-v2", + p_decision_policy_version: policyVersion, + p_candidate_range: range, + p_candidates: candidates, + p_decision_receipt: decisionReceipt, + p_execution_ledger: executionLedger, + }); + + const first = await persist("pr4-engine-result-1", "decision-policy-v1"); + assert.equal(first.error, null, rpcError(first.error)); + const firstRow = first.data as Record; + const firstResultId = String(firstRow.result_id); + const firstCandidateIds = candidateIds(firstRow); + assert.equal(firstRow.cached, false); + assert.equal(firstCandidateIds.length, 2); + assert.ok(firstCandidateIds.every((id) => UUID_PATTERN.test(id))); + assert.ok(!firstCandidateIds.includes(fakeCallerCandidateId)); + assert.equal( + objectField(firstRow, "decision_receipt").representative_candidate_id, + firstCandidateIds[0], + ); + + const cached = await persist("pr4-engine-result-cache-ignored", "decision-policy-v1"); + assert.equal(cached.error, null, rpcError(cached.error)); + const cachedRow = cached.data as Record; + assert.equal(cachedRow.cached, true); + assert.equal(cachedRow.result_id, firstResultId); + assert.deepEqual(candidateIds(cachedRow), firstCandidateIds); + assert.equal( + objectField(cachedRow, "decision_receipt").representative_candidate_id, + firstCandidateIds[0], + ); + + const changedPolicy = await persist("pr4-engine-result-2", "decision-policy-v2"); + assert.equal(changedPolicy.error, null, rpcError(changedPolicy.error)); + const changedPolicyRow = changedPolicy.data as Record; + const resultId = String(changedPolicyRow.result_id); + const ids = candidateIds(changedPolicyRow); + const representativeCandidateId = ids[0]!; + const nonRepresentativeCandidateId = ids[1]!; + assert.equal(changedPolicyRow.cached, false); + assert.notEqual(resultId, firstResultId); + assert.equal( + objectField(changedPolicyRow, "decision_receipt").representative_candidate_id, + representativeCandidateId, + ); + + assert.equal( + fixture.psql(` + select (r.decision_receipt ->> 'representative_candidate_id') || ':' || c.id + from public.agentic_rectification_results r + join public.agentic_rectification_candidates c + on c.result_id = r.id and c.is_representative + where r.id = '${resultId}' + `), + `${representativeCandidateId}:${representativeCandidateId}`, + ); + + const restoredCase = await service.rpc("get_agentic_rectification_case", { + p_user_id: userId, + p_case_id: caseId, + }); + assert.equal(restoredCase.error, null, rpcError(restoredCase.error)); + const restoredCaseResult = objectField(restoredCase.data, "latest_result"); + assert.deepEqual( + { + event_contract_version: restoredCaseResult.event_contract_version, + decision_policy_version: restoredCaseResult.decision_policy_version, + representative_candidate_id: objectField( + restoredCaseResult, + "decision_receipt", + ).representative_candidate_id, + execution_ledger: restoredCaseResult.execution_ledger, + selected_candidate_id: restoredCaseResult.selected_candidate_id, + }, + { + event_contract_version: "rectification-event-contract-v2", + decision_policy_version: "decision-policy-v2", + representative_candidate_id: representativeCandidateId, + execution_ledger: executionLedger, + selected_candidate_id: null, + }, + ); + + const restoredDossier = await service.rpc("get_agentic_rectification_case_dossier", { + p_user_id: userId, + p_case_id: caseId, + }); + assert.equal(restoredDossier.error, null, rpcError(restoredDossier.error)); + const restoredDossierResult = objectField(restoredDossier.data, "latest_result"); + assert.equal(restoredDossierResult.event_contract_version, "rectification-event-contract-v2"); + assert.equal(restoredDossierResult.decision_policy_version, "decision-policy-v2"); + assert.equal( + objectField(restoredDossierResult, "decision_receipt").representative_candidate_id, + representativeCandidateId, + ); + assert.deepEqual(restoredDossierResult.execution_ledger, executionLedger); + assert.equal(restoredDossierResult.selected_candidate_id, null); + assert.deepEqual( + (restoredDossier.data as { turns?: Array<{ role?: unknown }> }).turns?.map( + (turn) => turn.role, + ), + ["user", "assistant"], + ); + + assert.equal( + fixture.psql(` + select event_contract_version || ':' || decision_policy_version || ':' || + display_allowed || ':' || selection_allowed || ':' || confirmation_allowed || ':' || + (decision_receipt ->> 'confirm_allowed') || ':' || jsonb_array_length(execution_ledger) + from public.agentic_rectification_results + where id = '${resultId}' + `), + "rectification-event-contract-v2:decision-policy-v2:true:true:true:true:2", + ); + + assert.equal( + fixture.psql(` + select + has_function_privilege('service_role', 'public.persist_agentic_rectification_candidate(uuid,uuid,text,text,text,text,text,jsonb,jsonb,text,numeric,boolean,boolean,time without time zone)', 'EXECUTE') || ':' || + has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate(uuid,uuid,uuid,time without time zone)', 'EXECUTE') || ':' || + has_function_privilege('service_role', 'public.accept_agentic_rectification_candidate_for_case(uuid,uuid,uuid,time without time zone)', 'EXECUTE') || ':' || + has_function_privilege('service_role', 'public.confirm_agentic_rectification_birth_time(uuid,uuid,uuid,time without time zone,text,uuid)', 'EXECUTE') + `), + "f:f:f:f", + ); + + const crossResult = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: firstResultId, + p_candidate_id: representativeCandidateId, + p_request_id: "60000000-0000-4000-8000-000000000001", + }); + assert.match(rpcError(crossResult.error), /agentic_rectification_candidate_not_found/); + + const crossCase = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: secondCaseId, + p_result_id: resultId, + p_candidate_id: representativeCandidateId, + p_request_id: "60000000-0000-4000-8000-000000000002", + }); + assert.match(rpcError(crossCase.error), /agentic_rectification_candidate_not_found/); + + const forged = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: "50000000-0000-4000-8000-000000000099", + p_request_id: "60000000-0000-4000-8000-000000000003", + }); + assert.match(rpcError(forged.error), /agentic_rectification_candidate_not_found/); + + const inexactConfirmation = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: nonRepresentativeCandidateId, + p_request_id: "70000000-0000-4000-8000-000000000001", + p_consent_quote: "确认使用05:07", + p_source_turn_id: consentTurnId, + }); + assert.match( + rpcError(inexactConfirmation.error), + /agentic_rectification_confirmation_exact_gate_blocked/, + ); + + const acceptRequestId = "80000000-0000-4000-8000-000000000001"; + const accepted = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: representativeCandidateId, + p_request_id: acceptRequestId, + }); + assert.equal(accepted.error, null, rpcError(accepted.error)); + assert.deepEqual( + { + status: (accepted.data as Record).status, + case_status: (accepted.data as Record).case_status, + saved_time: (accepted.data as Record).saved_time, + candidate_id: (accepted.data as Record).candidate_id, + }, + { + status: "accepted", + case_status: "candidate_accepted", + saved_time: "05:07", + candidate_id: representativeCandidateId, + }, + ); + assert.equal( + fixture.psql(` + select active_birth_time || ':' || birth_time_status || ':' || selection_kind || ':' || selected_candidate_id + from public.profiles p + join public.agentic_rectification_results r on r.user_id = p.id + where r.id = '${resultId}' + `), + `05:07:00:accepted:user_accepted:${representativeCandidateId}`, + ); + + for (const rpcName of [ + "get_agentic_rectification_case", + "get_agentic_rectification_case_dossier", + ]) { + const restoredAfterAccept = await service.rpc(rpcName, { + p_user_id: userId, + p_case_id: caseId, + }); + assert.equal(restoredAfterAccept.error, null, rpcError(restoredAfterAccept.error)); + const latestResult = objectField(restoredAfterAccept.data, "latest_result"); + assert.equal(latestResult.selected_candidate_id, representativeCandidateId); + } + + const acceptReplay = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: representativeCandidateId, + p_request_id: acceptRequestId, + }); + assert.equal(acceptReplay.error, null, rpcError(acceptReplay.error)); + assert.equal((acceptReplay.data as Record).idempotent, true); + + const requestConflict = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: nonRepresentativeCandidateId, + p_request_id: acceptRequestId, + }); + assert.match( + rpcError(requestConflict.error), + /agentic_rectification_candidate_request_conflict/, + ); + + const ungrounded = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: representativeCandidateId, + p_request_id: "90000000-0000-4000-8000-000000000001", + p_consent_quote: "这段话没有出现", + p_source_turn_id: consentTurnId, + }); + assert.match(rpcError(ungrounded.error), /agentic_rectification_consent_not_grounded/); + + const confirmRequestId = "90000000-0000-4000-8000-000000000002"; + const confirmed = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: representativeCandidateId, + p_request_id: confirmRequestId, + p_consent_quote: "确认使用05:07", + p_source_turn_id: consentTurnId, + }); + assert.equal(confirmed.error, null, rpcError(confirmed.error)); + assert.deepEqual( + { + status: (confirmed.data as Record).status, + case_status: (confirmed.data as Record).case_status, + saved_time: (confirmed.data as Record).saved_time, + }, + { status: "confirmed", case_status: "confirmed", saved_time: "05:07" }, + ); + assert.equal( + fixture.psql(` + select p.birth_time_status || ':' || r.selection_kind || ':' || c.status || ':' || + c.confirmed_time || ':' || r.selected_candidate_id + from public.profiles p + join public.agentic_rectification_results r on r.user_id = p.id + join public.agentic_rectification_cases c on c.id = r.case_id + where r.id = '${resultId}' + `), + `confirmed:engine_confirmed:confirmed:05:07:00:${representativeCandidateId}`, + ); + + const confirmReplay = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: representativeCandidateId, + p_request_id: confirmRequestId, + p_consent_quote: "确认使用05:07", + p_source_turn_id: consentTurnId, + }); + assert.equal(confirmReplay.error, null, rpcError(confirmReplay.error)); + assert.equal((confirmReplay.data as Record).idempotent, true); + } finally { + try { + await closeLocalPostgresDataPool(serviceUrl); + } finally { + fixture.stop(); + } + } +}); diff --git a/frontend/tests/rectification-v10-tool-contract.test.ts b/frontend/tests/rectification-v10-tool-contract.test.ts index 5ae89c1f..e2ee2934 100644 --- a/frontend/tests/rectification-v10-tool-contract.test.ts +++ b/frontend/tests/rectification-v10-tool-contract.test.ts @@ -8,6 +8,7 @@ import { EVIDENCE_ID, FOCUS_ID, RESULT_ID, + CANDIDATE_ID, TURN_ID, USER_ID, fakeAccounting, @@ -104,12 +105,12 @@ const validInputs: Record<(typeof EXACT_TOOL_KEYS)[number], Record { const accounting = fakeAccounting({ - persist_agentic_rectification_candidate: () => ({ + persist_agentic_rectification_candidate_v2: () => ({ ...candidateSnapshotFixture(), cached: true, }), @@ -399,26 +400,41 @@ test("same evidence + range fingerprints reuse the cached candidate snapshot", a rangeFingerprint: "c".repeat(64), skillVersion: "9.0.0", candidateRange: CANDIDATE_RANGE, - candidates: [{ rank: 1, time: "05:02", relative_support: 58, tied_minute_count: 2 }], - overallConfidence: "medium", - marginPercent: 16, - selectionAllowed: true, - confirmationAllowed: false, - representativeTime: null, + 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", + }, + executionLedger: [{ method: "d1-rashi", status: "executed" }], }); assert.equal(cached.cached, true); assert.equal(cached.resultId, RESULT_ID); - const call = accounting.calls.find((item) => item.fn === "persist_agentic_rectification_candidate"); + const call = accounting.calls.find((item) => item.fn === "persist_agentic_rectification_candidate_v2"); assert.ok(call); assert.equal(call.args.p_evidence_ledger_fingerprint, "b".repeat(64)); 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_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); }); test("accept-candidate requires a server-persisted result; no tool means no minute", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: null }), - accept_agentic_rectification_candidate_for_case: () => { + accept_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_candidate_not_found"); }, }); @@ -432,7 +448,7 @@ test("accept-candidate requires a server-persisted result; no tool means no minu (tools["rectification-accept-candidate"] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID, resultId: RESULT_ID, - candidateId: "05:02", + candidateId: CANDIDATE_ID, }), (error: unknown) => error instanceof Error && error.message.includes("candidate_not_found"), ); diff --git a/frontend/tests/rectification-v9-database.test.ts b/frontend/tests/rectification-v9-database.test.ts index efa2a41c..578d85aa 100644 --- a/frontend/tests/rectification-v9-database.test.ts +++ b/frontend/tests/rectification-v9-database.test.ts @@ -396,6 +396,7 @@ test("v9 enforces profile gating, ownership and terminal read-only", { skip: ski p_model_name: "test-model", p_model_version: "1", p_status: "completed", + p_request_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", }); assert.match(rpcError(turn.error), /agentic_rectification_case_terminal/); @@ -483,6 +484,7 @@ test("v9 evidence lifecycle: quote grounding, idempotency, confirm and revision p_model_name: "test-model", p_model_version: "1", p_status: "completed", + p_request_id: "12121212-1212-4212-8212-121212121212", }); assert.equal(turn.error, null, rpcError(turn.error)); const turnId = String((turn.data as Record).turn_id); @@ -821,6 +823,8 @@ test("v9 legacy backfill maps statuses, keeps one resumable per user and is idem test("v9 agent api migration applies, seeds the runtime flag and guards consent", { skip: skipWithoutDocker }, async () => { const fixture = startPostgresFixture(); const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + const fixtureSkillSha256 = "d".repeat(64); + const fixtureSkillSourceCommit = "e".repeat(40); try { const migration = spawnSync(process.execPath, [runnerPath], { encoding: "utf8", @@ -881,10 +885,12 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" fixture.psql(` insert into public.agentic_rectification_cases ( id, user_id, session_id, status, skill_name, skill_version, + skill_sha256, skill_source_commit, baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range ) values ( '11111111-1111-4111-8111-111111111111', '66666666-6666-4666-8666-666666666666', '22222222-2222-4222-8222-222222222222', 'draft', 'jyotish-birth-time-rectification', '9.0.0', + '${fixtureSkillSha256}', '${fixtureSkillSourceCommit}', '${fingerprint}', '{"birth_date":"1997-08-08","birth_place_label":"河北省邯郸市武安市","latitude":36.420487,"longitude":114.209936,"timezone_id":"Asia/Shanghai","timezone_offset":8,"birth_time_source":"family_exact"}'::jsonb, '{"start_time":"04:50","end_time":"05:10"}'::jsonb @@ -901,14 +907,14 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" null, "service_role", ); - const finalize = await service.rpc("finalize_agentic_rectification_turn", { + const attempt = await service.rpc("create_agentic_rectification_run_attempt", { p_user_id: "66666666-6666-4666-8666-666666666666", p_case_id: "11111111-1111-4111-8111-111111111111", p_turn_id: "33333333-3333-4333-8333-333333333333", - p_status: "completed", - p_assistant_message: "好的,我会先核对出生时间范围。", + p_attempt_number: 1, }); - assert.equal(finalize.error, null, rpcError(finalize.error)); + assert.equal(attempt.error, null, rpcError(attempt.error)); + const attemptId = String((attempt.data as Record).attempt_id); const phase = await service.rpc("insert_agentic_rectification_run_phase", { p_user_id: "66666666-6666-4666-8666-666666666666", @@ -917,6 +923,7 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" p_phase: "skill.loaded", p_tool_name: null, p_sequence: 1, + p_attempt_id: attemptId, }); assert.equal(phase.error, null, rpcError(phase.error)); @@ -933,6 +940,7 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" p_engine_version: tool === "rectification-compare-candidates" ? "fixture-engine" : null, p_safe_error_code: status === "failed" ? "fixture_failure" : null, p_executed_methods: methods, + p_attempt_id: attemptId, }); for (const result of [ await insertReceipt("rectification-read-case", "completed", []), @@ -950,6 +958,60 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" ); assert.match(rpcError(invalidMethod.error), /agentic_rectification_invalid_input/); + const skillReceipt = await service.rpc("insert_agentic_rectification_skill_run_receipt", { + p_user_id: "66666666-6666-4666-8666-666666666666", + p_case_id: "11111111-1111-4111-8111-111111111111", + p_turn_id: "33333333-3333-4333-8333-333333333333", + p_request_id: attemptId, + p_run_kind: "turn", + p_skill_name: "jyotish-birth-time-rectification", + p_skill_version: "9.0.0", + p_skill_sha256: fixtureSkillSha256, + p_source_commit: fixtureSkillSourceCommit, + }); + assert.equal(skillReceipt.error, null, rpcError(skillReceipt.error)); + + for (const [publicPhase, sequence] of [ + ["skill.bound", 2], + ["case.loaded", 3], + ["intent.classified", 4], + ["billing.settled", 5], + ["run.completed", 6], + ] as const) { + const lifecyclePhase = await service.rpc("insert_agentic_rectification_run_phase", { + p_user_id: "66666666-6666-4666-8666-666666666666", + p_case_id: "11111111-1111-4111-8111-111111111111", + p_turn_id: "33333333-3333-4333-8333-333333333333", + p_phase: publicPhase, + p_tool_name: null, + p_sequence: sequence, + p_attempt_id: attemptId, + }); + assert.equal(lifecyclePhase.error, null, rpcError(lifecyclePhase.error)); + } + + const finalizedAttempt = await service.rpc("finalize_agentic_rectification_run_attempt", { + p_user_id: "66666666-6666-4666-8666-666666666666", + p_case_id: "11111111-1111-4111-8111-111111111111", + p_turn_id: "33333333-3333-4333-8333-333333333333", + p_attempt_id: attemptId, + p_status: "completed", + p_error_code: null, + p_usage: {}, + }); + assert.equal(finalizedAttempt.error, null, rpcError(finalizedAttempt.error)); + + const finalizedTurn = await service.rpc("finalize_agentic_rectification_turn", { + p_user_id: "66666666-6666-4666-8666-666666666666", + p_case_id: "11111111-1111-4111-8111-111111111111", + p_turn_id: "33333333-3333-4333-8333-333333333333", + p_attempt_id: attemptId, + p_status: "completed", + p_assistant_message: "好的,我会先核对出生时间范围。", + p_successful_attempt_id: attemptId, + }); + assert.equal(finalizedTurn.error, null, rpcError(finalizedTurn.error)); + const receipt = await service.rpc("get_agentic_rectification_turn_receipt", { p_user_id: "66666666-6666-4666-8666-666666666666", p_case_id: "11111111-1111-4111-8111-111111111111", @@ -970,12 +1032,41 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" "vimshottari-dasha", ]); - // Consent must be grounded in the source turn before candidate lookup. - const consent = await service.rpc("confirm_agentic_rectification_birth_time", { + const consentCandidate = await service.rpc("persist_agentic_rectification_candidate_v2", { p_user_id: "66666666-6666-4666-8666-666666666666", p_case_id: "11111111-1111-4111-8111-111111111111", - p_result_id: "55555555-5555-4555-8555-555555555555", - p_time: "05:02", + p_engine_result_id: "agent-api-consent-engine", + p_evidence_ledger_fingerprint: "f".repeat(64), + p_candidate_range_fingerprint: "a".repeat(64), + p_skill_version: "9.0.0", + p_algorithm_version: "agent-api-consent-v2", + p_event_contract_version: "rectification-event-contract-v2", + p_decision_policy_version: "rectification-candidate-policy-v2", + p_candidate_range: { start_time: "04:50", end_time: "05:10" }, + p_candidates: [{ rank: 1, time: "05:02", relative_support: 100, tied_minute_count: 1 }], + p_decision_receipt: { + display_allowed: true, + accept_allowed: true, + confirm_allowed: true, + representative_time: "05:02", + overall_confidence: "high", + margin_percent: 100, + }, + p_execution_ledger: [{ phase: "decision.evaluate", status: "completed" }], + }); + assert.equal(consentCandidate.error, null, rpcError(consentCandidate.error)); + const consentCandidateRow = consentCandidate.data as Record; + const consentCandidateId = String( + (consentCandidateRow.candidates as Array>)[0]?.candidate_id ?? "", + ); + + // Consent must be grounded in the current source turn before confirmation. + const consent = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", { + p_user_id: "66666666-6666-4666-8666-666666666666", + p_case_id: "11111111-1111-4111-8111-111111111111", + p_result_id: String(consentCandidateRow.result_id), + p_candidate_id: consentCandidateId, + p_request_id: "99999999-aaaa-4aaa-8aaa-aaaaaaaaaaaa", p_consent_quote: "就用05:02", p_source_turn_id: "33333333-3333-4333-8333-333333333333", }); @@ -1046,42 +1137,65 @@ test("v9 allows changing an accepted minute within the same live candidate resul null, "service_role", ); - const persisted = await service.rpc("persist_agentic_rectification_candidate", { + const persisted = await service.rpc("persist_agentic_rectification_candidate_v2", { p_user_id: userId, p_case_id: caseId, p_engine_result_id: "candidate-reselection-engine", - p_algorithm_version: "candidate-reselection-v1", p_evidence_ledger_fingerprint: "b".repeat(64), p_candidate_range_fingerprint: "c".repeat(64), p_skill_version: skillVersion, + p_algorithm_version: "candidate-reselection-v2", + p_event_contract_version: "rectification-event-contract-v2", + p_decision_policy_version: "rectification-candidate-policy-v2", p_candidate_range: range, p_candidates: [ { rank: 1, time: "05:07", relative_support: 34, tied_minute_count: 1 }, { rank: 2, time: "05:08", relative_support: 33, tied_minute_count: 2 }, { rank: 3, time: "05:09", relative_support: 33, tied_minute_count: 2 }, ], - p_overall_confidence: "low", - p_margin_percent: 1, - p_selection_allowed: true, - p_confirmation_allowed: false, - p_representative_time: null, + p_decision_receipt: { + display_allowed: true, + accept_allowed: true, + confirm_allowed: true, + representative_time: "05:08", + overall_confidence: "low", + margin_percent: 1, + }, + p_execution_ledger: [ + { phase: "candidate.score", status: "completed", engine: "fixture-engine" }, + { phase: "decision.evaluate", status: "completed", policy: "rectification-candidate-policy-v2" }, + ], }); assert.equal(persisted.error, null, rpcError(persisted.error)); - const resultId = String((persisted.data as Record).result_id); + const persistedRow = persisted.data as Record; + const resultId = String(persistedRow.result_id); + const persistedCandidates = persistedRow.candidates as Array>; + const candidateIdFor = (time: string) => String( + persistedCandidates.find((candidate) => candidate.time === time)?.candidate_id ?? "", + ); + const firstCandidateId = candidateIdFor("05:07"); + const switchedCandidateId = candidateIdFor("05:08"); + const thirdCandidateId = candidateIdFor("05:09"); + assert.match(firstCandidateId, /^[0-9a-f-]{36}$/); + assert.match(switchedCandidateId, /^[0-9a-f-]{36}$/); + assert.match(thirdCandidateId, /^[0-9a-f-]{36}$/); - const firstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case", { + const firstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { p_user_id: userId, p_case_id: caseId, p_result_id: resultId, - p_time: "05:07", + p_candidate_id: firstCandidateId, + p_request_id: "11111111-aaaa-4aaa-8aaa-aaaaaaaaaaaa", }); assert.equal(firstAccept.error, null, rpcError(firstAccept.error)); - const switched = await service.rpc("accept_agentic_rectification_candidate_for_case", { + const switchRequestId = "22222222-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const switched = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { p_user_id: userId, p_case_id: caseId, p_result_id: resultId, - p_time: "05:08", + p_candidate_id: switchedCandidateId, + p_request_id: switchRequestId, }); assert.equal(switched.error, null, rpcError(switched.error)); assert.deepEqual( @@ -1098,39 +1212,55 @@ test("v9 allows changing an accepted minute within the same live candidate resul "05:08:00:05:08:00:accepted", ); assert.equal( - fixture.psql(`select selected_time || ':' || selection_kind from public.agentic_rectification_results where id = '${resultId}'`), - "05:08:00:user_accepted", + fixture.psql(`select selected_time || ':' || selection_kind || ':' || selected_candidate_id from public.agentic_rectification_results where id = '${resultId}'`), + `05:08:00:user_accepted:${switchedCandidateId}`, ); assert.equal( fixture.psql(`select accepted_time || ':' || status from public.agentic_rectification_cases where id = '${caseId}'`), "05:08:00:candidate_accepted", ); - const replay = await service.rpc("accept_agentic_rectification_candidate_for_case", { + const replay = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { p_user_id: userId, p_case_id: caseId, p_result_id: resultId, - p_time: "05:08", + p_candidate_id: switchedCandidateId, + p_request_id: switchRequestId, }); assert.equal(replay.error, null, rpcError(replay.error)); assert.equal((replay.data as Record).idempotent, true); - fixture.psql(` - update public.profiles - set birth_time_status = 'confirmed' - where id = '${userId}'; - update public.agentic_rectification_results - set selection_kind = 'engine_confirmed' - where id = '${resultId}'; - update public.agentic_rectification_cases - set status = 'confirmed', confirmed_time = '05:08', completed_at = now() - where id = '${caseId}'; - `); - const confirmedSwitch = await service.rpc("accept_agentic_rectification_candidate_for_case", { + const consentTurn = await service.rpc("append_agentic_rectification_turn", { + p_user_id: userId, + p_case_id: caseId, + p_user_message: "我确认使用05:08作为出生时间", + p_assistant_message: "已记录你的确认。", + p_model_name: "test-model", + p_model_version: "1", + p_status: "completed", + p_request_id: "33333333-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + }); + assert.equal(consentTurn.error, null, rpcError(consentTurn.error)); + const consentTurnId = String((consentTurn.data as Record).turn_id); + + const confirmed = await service.rpc("confirm_agentic_rectification_candidate_for_case_v2", { p_user_id: userId, p_case_id: caseId, p_result_id: resultId, - p_time: "05:09", + p_candidate_id: switchedCandidateId, + p_request_id: "44444444-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + p_consent_quote: "确认使用05:08", + p_source_turn_id: consentTurnId, + }); + assert.equal(confirmed.error, null, rpcError(confirmed.error)); + assert.equal((confirmed.data as Record).status, "confirmed"); + + const confirmedSwitch = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_candidate_id: thirdCandidateId, + p_request_id: "55555555-aaaa-4aaa-8aaa-aaaaaaaaaaaa", }); assert.match(rpcError(confirmedSwitch.error), /agentic_rectification_case_terminal/); } finally { diff --git a/frontend/tests/rectification-v9-engine-contract.test.ts b/frontend/tests/rectification-v9-engine-contract.test.ts index 45ac1fb2..d6dc71fe 100644 --- a/frontend/tests/rectification-v9-engine-contract.test.ts +++ b/frontend/tests/rectification-v9-engine-contract.test.ts @@ -6,65 +6,71 @@ import { runV9CandidateScore, runV9Diagnostics, toEngineEvents, - toEngineScoreableEvent, - V9EngineScoreResult, + type V9EngineScoreResult, } from "../src/lib/rectification-agentic/v9/engine-client.ts"; const RANGE = { start_time: "04:50", end_time: "05:10" }; +const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881"; +const SECOND_CANDIDATE_ID = "88888888-8888-4888-8888-888888888882"; +const THIRD_CANDIDATE_ID = "88888888-8888-4888-8888-888888888883"; -/** - * Real /api/rectification/v5/score response captured from - * scripts/rectification/api_service.score_candidates (run on Python 3 with - * the repository's engine). The engine never returns rank, - * tied_minute_count, representative_time, confidence, margin_percent, - * selection_allowed or confirmation_allowed -- the client must derive them. - */ -const REAL_ENGINE_SCORE_RESPONSE = { +const DECISION_RECEIPT = { + receipt_version: "candidate-decision-receipt-v2", + contract_version: "v2", + event_contract_version: "rectification-event-contract-v2", + policy_version: "rectification-candidate-policy-v2", + decision_policy_version: "rectification-candidate-policy-v2", + display_allowed: true, + selection_allowed: true, + acceptance_allowed: true, + confirmation_allowed: false, + accept_allowed: true, + confirm_allowed: false, + representative_candidate_id: CANDIDATE_ID, + representative_time: "04:50", + overall_confidence: "high", + margin_percent: 42.5, +}; + +const EXECUTION_LEDGER = [ + { ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" }, + { ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "gochara", status: "executed", source: "python-engine" }, + { ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d24-chaturvimshamsha", status: "not_executed", source: "python-engine" }, +]; + +const ENGINE_SCORE_RESPONSE_V2 = { success: true, endpoint: "rectification_v5_score", result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62", - algorithm_version: "rectification-v5-matrix-scoring-2", - calculation_spec_hash: "f05fe0f56ef9ba2b18ec3c6c54f1649f06f1ae5a926491a5c5f676d718d92865", - candidate_scores: [ - { time: "04:50", score: 3.85, supporting_event_ids: ["00000000-0000-4000-8000-000000000001"], conflicting_event_ids: [] }, - { time: "04:51", score: 2.9, supporting_event_ids: [], conflicting_event_ids: [] }, - { time: "04:52", score: 2.9, supporting_event_ids: [], conflicting_event_ids: [] }, - { time: "04:53", score: 1.2, supporting_event_ids: [], conflicting_event_ids: [] }, + algorithm_version: "rectification-event-contract-v2", + event_contract_version: "rectification-event-contract-v2", + decision_policy_version: "rectification-candidate-policy-v2", + execution_ledger_version: "rectification-execution-ledger-v2", + candidate_decisions: [ + { candidate_id: CANDIDATE_ID, time: "04:50", rank: 1, relative_support: 57, tied_minute_count: 1 }, + { candidate_id: SECOND_CANDIDATE_ID, time: "04:51", rank: 3, relative_support: 18, tied_minute_count: 2 }, + { candidate_id: THIRD_CANDIDATE_ID, time: "04:52", rank: 2, relative_support: 25, tied_minute_count: 2 }, ], - event_contribution_matrix: { - "00000000-0000-4000-8000-000000000001": { - "04:50": { - points: 3.85, - rule_ids: ["vim_md_domain_house", "controlled_transit_jupiter_domain_house"], - technique_layers: [ - "vim_md_domain_house", - "controlled_transit_jupiter_domain_house", - "ashtakavarga_target_house_support_auxiliary", - ], - }, - }, - }, + decision_receipt: DECISION_RECEIPT, + execution_ledger: EXECUTION_LEDGER, diagnostics: { - primary_cluster_retention_rate: 0.86, - leave_one_event_out_retention_rate: 0.81, - leave_one_domain_out_retention_rate: 0.9, - date_sensitivity_retention_rate: 0.72, - neighbor_support_minutes: 2, - primary_secondary_margin_percent: 42.5, - unstable_event_ids: [], - most_discriminating_layers: ["vimsottari_dasha"], - candidate_splits: [{ time: "05:02", width: 6 }], + primary_secondary_margin_percent: 99, + leave_one_event_out_retention_rate: 1, }, - missing_layers: ["KP_cusps"], - can_confirm_exact_minute: false, + candidate_scores: [ + { time: "04:59", score: 999999 }, + ], }; -const REAL_ENGINE_DIAGNOSTICS_RESPONSE = { +const ENGINE_DIAGNOSTICS_RESPONSE_V2 = { success: true, endpoint: "rectification_v5_diagnostics", result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62", - algorithm_version: "rectification-v5-matrix-scoring-2", - calculation_spec_hash: "f05fe0f56ef9ba2b18ec3c6c54f1649f06f1ae5a926491a5c5f676d718d92865", + algorithm_version: "rectification-event-contract-v2", + event_contract_version: "rectification-event-contract-v2", + decision_policy_version: "rectification-candidate-policy-v2", + execution_ledger_version: "rectification-execution-ledger-v2", + candidate_decisions: ENGINE_SCORE_RESPONSE_V2.candidate_decisions, diagnostics: { primary_cluster_retention_rate: 0.86, leave_one_event_out_retention_rate: 0.81, @@ -74,10 +80,11 @@ const REAL_ENGINE_DIAGNOSTICS_RESPONSE = { primary_secondary_margin_percent: 42.5, unstable_event_ids: [], most_discriminating_layers: ["vimsottari_dasha"], - candidate_splits: [{ time: "05:02", width: 6 }], + candidate_splits: [{ candidate_id: SECOND_CANDIDATE_ID, width: 6 }], }, missing_layers: ["KP_cusps"], - can_confirm_exact_minute: false, + decision_receipt: DECISION_RECEIPT, + execution_ledger: EXECUTION_LEDGER, }; function stubEngine(response: unknown, status = 200) { @@ -103,83 +110,100 @@ const SNAPSHOT = { const EVIDENCE = [ { id: "00000000-0000-4000-8000-000000000001", + sourceTurnId: "33333333-3333-4333-8333-333333333333", + subject: "self", eventKind: "education_start", domain: "education", occurredFrom: "2016-09-01", occurredTo: "2016-09-30", datePrecision: "month", summary: "大学入学", + status: "confirmed", + supersedesEvidenceId: null, + createdAt: "2026-08-12T10:00:06.000Z", + dateSource: "user_quote", + dateReliability: "month_exact", + dateCorroboration: "录取通知书", + dateConflictStatus: "none", }, ]; -test("toEngineEvents maps V9 evidence kinds onto the engine scoreable vocabulary", () => { +test("toEngineEvents preserves Event Contract v2 detailed kind and provenance", () => { const events = toEngineEvents(EVIDENCE); assert.equal(events.length, 1); assert.equal(events[0]!.domain, "education"); - assert.equal(events[0]!.event_kind, "education_milestone"); + assert.equal(events[0]!.event_kind, "education_start"); assert.equal(events[0]!.date_start, "2016-09-01"); assert.equal(events[0]!.precision, "month"); + assert.equal(events[0]!.date_source, "user_quote"); + assert.equal(events[0]!.date_reliability, "month_exact"); + assert.equal(events[0]!.date_corroboration, "录取通知书"); + assert.equal(events[0]!.date_conflict_status, "none"); + assert.equal(events[0]!.source_turn_id, EVIDENCE[0]!.sourceTurnId); + assert.equal(events[0]!.subject, "self"); }); -test("toEngineScoreableEvent keeps relationship start/change distinct and drops background kinds", () => { - assert.deepEqual( - toEngineScoreableEvent({ domain: "relationship", eventKind: "relationship_start" }), - { domain: "relationship", event_kind: "relationship_start" }, - ); - assert.deepEqual( - toEngineScoreableEvent({ domain: "relationship", eventKind: "relationship_separation" }), - { domain: "relationship", event_kind: "relationship_change" }, - ); - assert.deepEqual( - toEngineScoreableEvent({ domain: "career", eventKind: "promotion" }), - { domain: "career", event_kind: "career_change" }, - ); - assert.deepEqual( - toEngineScoreableEvent({ domain: "health", eventKind: "self_health_event" }), - { domain: "health_pressure", event_kind: "self_health_event" }, - ); - // Background evidence never reaches the engine scoring path. - assert.equal(toEngineScoreableEvent({ domain: "family", eventKind: "family_event" }), null); - assert.equal(toEngineScoreableEvent({ domain: "other", eventKind: "other" }), null); +test("toEngineEvents keeps all scoreable detailed v2 kinds and drops background kinds", () => { + const detailed = [ + "education_completion", + "career_entry", + "promotion", + "career_pressure", + "relationship_commitment", + "relationship_separation", + "finance_gain", + "finance_loss", + "self_health_event", + ].map((eventKind, index) => ({ + ...EVIDENCE[0]!, + id: `00000000-0000-4000-8000-${String(index + 2).padStart(12, "0")}`, + eventKind, + domain: eventKind.startsWith("education") ? "education" + : eventKind.startsWith("career") || eventKind === "promotion" ? "career" + : eventKind.startsWith("relationship") ? "relationship" + : eventKind.startsWith("finance") ? "finance" + : "health", + })); + const background = [ + { ...EVIDENCE[0]!, id: "00000000-0000-4000-8000-000000000099", eventKind: "family_event", domain: "family" }, + { ...EVIDENCE[0]!, id: "00000000-0000-4000-8000-000000000100", eventKind: "other", domain: "other" }, + ]; + assert.deepEqual(toEngineEvents([...detailed, ...background]).map((event) => event.event_kind), detailed.map((event) => event.eventKind)); }); -test("runV9CandidateScore derives rank/support/gating from the real engine response shape", async () => { - const restore = stubEngine(REAL_ENGINE_SCORE_RESPONSE); +test("runV9CandidateScore strictly consumes server candidate decisions and v2 receipt", async () => { + const restore = stubEngine(ENGINE_SCORE_RESPONSE_V2); try { const result: V9EngineScoreResult = await runV9CandidateScore({ baselineBirthSnapshot: SNAPSHOT, candidateRange: RANGE, events: toEngineEvents(EVIDENCE), }); - assert.equal(result.candidates.length, 3); - assert.deepEqual( - result.candidates.map((candidate) => candidate.time), - ["04:50", "04:51", "04:52"], - ); - assert.deepEqual(result.candidates.map((candidate) => candidate.rank), [1, 2, 3]); - assert.equal(result.candidates[0]!.relative_support, 40, "3.85/(3.85+2.9+2.9)"); - assert.equal(result.candidates[1]!.tied_minute_count, 2, "equal scores share a tie count"); + assert.deepEqual(result.candidates, [ + { candidateId: CANDIDATE_ID, time: "04:50", rank: 1, relativeSupport: 57, tiedMinuteCount: 1 }, + { candidateId: SECOND_CANDIDATE_ID, time: "04:51", rank: 3, relativeSupport: 18, tiedMinuteCount: 2 }, + { candidateId: THIRD_CANDIDATE_ID, time: "04:52", rank: 2, relativeSupport: 25, tiedMinuteCount: 2 }, + ], "Web must preserve server rank/support/tie instead of deriving or sorting them"); + assert.equal(result.representativeCandidateId, CANDIDATE_ID); assert.equal(result.representativeTime, "04:50"); assert.equal(result.selectionAllowed, true); - assert.equal(result.confirmationAllowed, false, "engine gate is the only confirm source"); - assert.equal(result.overallConfidence, "high", "margin>=40 and retention>=0.8"); - assert.equal(result.marginPercent, 42.5); - assert.equal(result.algorithmVersion, "rectification-v5-matrix-scoring-2"); - assert.deepEqual(result.executedMethods, [ - "d1-rashi", - "vimshottari-dasha", - "narayana-dasha", - "d24-chaturvimshamsha", - "gochara", - "ashtakavarga", - ]); + assert.equal(result.confirmationAllowed, false); + assert.equal(result.overallConfidence, "high"); + assert.equal(result.policyVersion, "rectification-candidate-policy-v2"); + assert.deepEqual(result.decisionReceipt, DECISION_RECEIPT); + assert.deepEqual(result.executionLedger, EXECUTION_LEDGER); + assert.deepEqual(result.executedMethods, ["d1-rashi", "gochara"], "methods come only from executed server ledger entries"); + assert.equal(JSON.stringify(result).includes("999999"), false, "raw internal score must not enter the TS projection"); } finally { restore(); } }); -test("runV9CandidateScore fails closed when the engine returns no usable candidates", async () => { - const restore = stubEngine({ ...REAL_ENGINE_SCORE_RESPONSE, candidate_scores: [] }); +test("runV9CandidateScore fails closed without a v2 decision receipt", async () => { + const missingReceipt = Object.fromEntries( + Object.entries(ENGINE_SCORE_RESPONSE_V2).filter(([key]) => key !== "decision_receipt"), + ); + const restore = stubEngine(missingReceipt); try { await assert.rejects( runV9CandidateScore({ @@ -187,44 +211,50 @@ test("runV9CandidateScore fails closed when the engine returns no usable candida candidateRange: RANGE, events: toEngineEvents(EVIDENCE), }), - (error: unknown) => - error instanceof RectificationEngineError && error.code === "engine_no_candidates", + (error: unknown) => error instanceof RectificationEngineError && error.code === "engine_invalid_v2_receipt", ); } finally { restore(); } }); -test("runV9CandidateScore fails closed when no evidence maps to the engine vocabulary", async () => { - const backgroundOnly = [ - { - id: "00000000-0000-4000-8000-000000000002", - eventKind: "family_event", - domain: "family", - occurredFrom: "2018-05-01", - occurredTo: null, - datePrecision: "year", - summary: "家庭事件", - }, - ]; - const restore = stubEngine(REAL_ENGINE_SCORE_RESPONSE); +test("runV9CandidateScore fails closed on invalid server candidate UUIDs", async () => { + const restore = stubEngine({ + ...ENGINE_SCORE_RESPONSE_V2, + candidate_decisions: [{ ...ENGINE_SCORE_RESPONSE_V2.candidate_decisions[0], candidate_id: "04:50" }], + }); try { await assert.rejects( runV9CandidateScore({ baselineBirthSnapshot: SNAPSHOT, candidateRange: RANGE, - events: toEngineEvents(backgroundOnly), + events: toEngineEvents(EVIDENCE), }), - (error: unknown) => - error instanceof RectificationEngineError && error.code === "no_scorable_evidence", + (error: unknown) => error instanceof RectificationEngineError && error.code === "engine_invalid_candidate_decisions", ); } finally { restore(); } }); -test("runV9Diagnostics maps the real diagnostics response keys", async () => { - const restore = stubEngine(REAL_ENGINE_DIAGNOSTICS_RESPONSE); +test("runV9CandidateScore fails closed when no evidence is scoreable", async () => { + const restore = stubEngine(ENGINE_SCORE_RESPONSE_V2); + try { + await assert.rejects( + runV9CandidateScore({ + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents([{ ...EVIDENCE[0]!, eventKind: "family_event", domain: "family" }]), + }), + (error: unknown) => error instanceof RectificationEngineError && error.code === "no_scorable_evidence", + ); + } finally { + restore(); + } +}); + +test("runV9Diagnostics maps diagnostics and receipt-owned confirmation", async () => { + const restore = stubEngine(ENGINE_DIAGNOSTICS_RESPONSE_V2); try { const result = await runV9Diagnostics({ baselineBirthSnapshot: SNAPSHOT, @@ -234,14 +264,8 @@ test("runV9Diagnostics maps the real diagnostics response keys", async () => { assert.equal(result.canConfirmExactMinute, false); assert.equal(result.missingLayers.join(","), "KP_cusps"); assert.equal(result.diagnostics.primary_secondary_margin_percent, 42.5); - assert.equal(result.diagnostics.leave_one_event_out_retention_rate, 0.81); - assert.deepEqual(result.diagnostics.most_discriminating_layers, ["vimsottari_dasha"]); - assert.deepEqual(result.executedMethods, [ - "d1-rashi", - "vimshottari-dasha", - "narayana-dasha", - "d24-chaturvimshamsha", - ]); + assert.deepEqual(result.executedMethods, ["d1-rashi", "gochara"]); + assert.equal(result.policyVersion, "rectification-candidate-policy-v2"); } finally { restore(); } diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index 57c47418..05698c9c 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -1135,3 +1135,121 @@ test("V10 attempts, phases, receipts and completed turns are ownership-bound", ( assert.match(receipt, /where turn_id = p_turn_id and status = 'completed'[\s\S]*order by completed_at desc, attempt_number desc/); assert.ok((receipt.match(/attempt_id = v_attempt_id/g) ?? []).length >= 4); }); + +// --------------------------------------------------------------------------- +// 20260814030000_rectification_event_decision_contract_v2.sql +// --------------------------------------------------------------------------- + +const decisionContractV2Migration = readFileSync( + new URL( + "../supabase/migrations/20260814030000_rectification_event_decision_contract_v2.sql", + import.meta.url, + ), + "utf8", +); +const decisionContractV2MigrationCopy = fileURLToPath( + new URL( + "../db/migrations/20260814030000_rectification_event_decision_contract_v2.sql", + import.meta.url, + ), +); + +test("PR-4 decision contract migration is forward-only, ordered after V10 and unique", () => { + assert.ok( + "20260814030000_rectification_event_decision_contract_v2.sql" > + "20260814020000_rectification_v10_runtime.sql", + ); + assert.match(decisionContractV2Migration, /^begin;[\s\S]*^commit;$/m); + assert.equal( + existsSync(decisionContractV2MigrationCopy), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); +}); + +test("PR-4 persists event, policy, receipt and execution ledger on results", () => { + for (const column of [ + /add column if not exists event_contract_version text/, + /add column if not exists decision_policy_version text/, + /add column if not exists decision_receipt jsonb/, + /add column if not exists execution_ledger jsonb/, + /add column if not exists display_allowed boolean/, + /add column if not exists selected_candidate_id uuid/, + ]) { + assert.match(decisionContractV2Migration, column); + } + assert.match( + decisionContractV2Migration, + /create table if not exists public\.agentic_rectification_candidates \(/, + ); + assert.match(decisionContractV2Migration, /candidate_time time without time zone not null/); + assert.match(decisionContractV2Migration, /unique \(result_id, candidate_time\)/); +}); + +test("PR-4 V2 persist signature excludes injected booleans and derives them from the receipt", () => { + const persistStart = decisionContractV2Migration.indexOf( + "create or replace function public.persist_agentic_rectification_candidate_v2(", + ); + const acceptStart = decisionContractV2Migration.indexOf( + "create or replace function public.accept_agentic_rectification_candidate_for_case_v2(", + ); + const persistFunction = decisionContractV2Migration.slice(persistStart, acceptStart); + + for (const parameter of [ + /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/, + ]) { + assert.match(persistFunction, parameter); + } + assert.doesNotMatch(persistFunction, /p_selection_allowed/); + assert.doesNotMatch(persistFunction, /p_confirmation_allowed/); + assert.doesNotMatch(persistFunction, /p_display_allowed/); + assert.match(persistFunction, /p_decision_receipt -> 'display_allowed'/); + assert.match(persistFunction, /p_decision_receipt -> 'accept_allowed'/); + assert.match(persistFunction, /p_decision_receipt -> 'confirm_allowed'/); + assert.match(persistFunction, /v_display_allowed := \(p_decision_receipt ->> 'display_allowed'\)::boolean/); + assert.match(persistFunction, /v_selection_allowed := \(p_decision_receipt ->> 'accept_allowed'\)::boolean/); + assert.match(persistFunction, /v_confirmation_allowed := \(p_decision_receipt ->> 'confirm_allowed'\)::boolean/); +}); + +test("PR-4 cache identity includes decision policy and event contract versions", () => { + const persistStart = decisionContractV2Migration.indexOf( + "create or replace function public.persist_agentic_rectification_candidate_v2(", + ); + const acceptStart = decisionContractV2Migration.indexOf( + "create or replace function public.accept_agentic_rectification_candidate_for_case_v2(", + ); + const persistFunction = decisionContractV2Migration.slice(persistStart, acceptStart); + assert.match(persistFunction, /event_contract_version = p_event_contract_version/); + assert.match(persistFunction, /decision_policy_version = p_decision_policy_version/); +}); + +test("PR-4 retires service-role access to every legacy time-based candidate RPC", () => { + for (const legacyRevoke of [ + /revoke execute on function public\.persist_agentic_rectification_candidate\(uuid, uuid, text, text, text, text, text, jsonb, jsonb, text, numeric, boolean, boolean, time without time zone\) from service_role/, + /revoke execute on function public\.accept_agentic_rectification_candidate\(uuid, uuid, uuid, time without time zone\) from service_role/, + /revoke execute on function public\.accept_agentic_rectification_candidate_for_case\(uuid, uuid, uuid, time without time zone\) from service_role/, + /revoke execute on function public\.confirm_agentic_rectification_birth_time\(uuid, uuid, uuid, time without time zone, text, uuid\) from service_role/, + ]) { + assert.match(decisionContractV2Migration, legacyRevoke); + } +}); + +test("PR-4 exposes only the explicit V2 service-role RPC signatures", () => { + assert.match( + decisionContractV2Migration, + /grant execute on function public\.persist_agentic_rectification_candidate_v2\(uuid, uuid, text, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb\)\s+to service_role/, + ); + assert.match( + decisionContractV2Migration, + /grant execute on function public\.accept_agentic_rectification_candidate_for_case_v2\(uuid, uuid, uuid, uuid, uuid\)\s+to service_role/, + ); + assert.match( + decisionContractV2Migration, + /grant execute on function public\.confirm_agentic_rectification_candidate_for_case_v2\(uuid, uuid, uuid, uuid, uuid, text, uuid\)\s+to service_role/, + ); +}); diff --git a/frontend/tests/rectification-v9-status-security.test.ts b/frontend/tests/rectification-v9-status-security.test.ts index fd8f8ea0..b39e5d58 100644 --- a/frontend/tests/rectification-v9-status-security.test.ts +++ b/frontend/tests/rectification-v9-status-security.test.ts @@ -15,6 +15,8 @@ import { import { CASE_ID, CANDIDATE_RANGE, + CANDIDATE_ID, + SECOND_CANDIDATE_ID, EVIDENCE_ID, FOCUS_ID, RESULT_ID, @@ -32,7 +34,7 @@ import { test("accepted is never upgraded to confirmed by the accept path", async () => { const accounting = fakeAccounting({ - accept_agentic_rectification_candidate_for_case: () => ({ + accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "accepted", @@ -41,23 +43,43 @@ test("accepted is never upgraded to confirmed by the accept path", async () => { idempotent: false, }), }); - const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02"); + const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID); assert.equal(result.status, "accepted"); assert.equal(result.caseStatus, "candidate_accepted"); assert.notEqual(result.status, "confirmed"); }); +test("accept fails closed if the accept-only RPC reports confirmed", async () => { + const accounting = fakeAccounting({ + accept_agentic_rectification_candidate_for_case_v2: () => ({ + success: true, + saved_time: "05:02", + status: "confirmed", + result_id: RESULT_ID, + candidate_id: CANDIDATE_ID, + case_status: "confirmed", + idempotent: false, + }), + }); + + await assert.rejects( + acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID), + (error: unknown) => error instanceof Error && error.message.includes("invalid_accept_result"), + ); +}); + test("confirmed requires the engine gate plus explicit grounded consent", async () => { // confirmation_allowed=false on the stored result blocks confirmation. const blocked = fakeAccounting({ - confirm_agentic_rectification_birth_time: () => { + confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_confirmation_blocked"); }, }); await assert.rejects( confirmV9BirthTime(blocked.client, USER_ID, CASE_ID, { resultId: RESULT_ID, - time: "05:02", + candidateId: CANDIDATE_ID, + requestId: TURN_ID, consentQuote: "就用05:02", sourceTurnId: TURN_ID, }), @@ -66,14 +88,15 @@ test("confirmed requires the engine gate plus explicit grounded consent", async // Confirming a time that is not the representative minute is rejected. const mismatch = fakeAccounting({ - confirm_agentic_rectification_birth_time: () => { + confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_confirm_time_mismatch"); }, }); await assert.rejects( confirmV9BirthTime(mismatch.client, USER_ID, CASE_ID, { resultId: RESULT_ID, - time: "04:55", + candidateId: SECOND_CANDIDATE_ID, + requestId: TURN_ID, consentQuote: "就用04:55", sourceTurnId: TURN_ID, }), @@ -82,14 +105,15 @@ test("confirmed requires the engine gate plus explicit grounded consent", async // Consent quote must be grounded in the source turn's message. const ungrounded = fakeAccounting({ - confirm_agentic_rectification_birth_time: () => { + confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_consent_not_grounded"); }, }); await assert.rejects( confirmV9BirthTime(ungrounded.client, USER_ID, CASE_ID, { resultId: RESULT_ID, - time: "05:02", + candidateId: CANDIDATE_ID, + requestId: TURN_ID, consentQuote: "用户根本没说过这句话", sourceTurnId: TURN_ID, }), @@ -100,7 +124,7 @@ test("confirmed requires the engine gate plus explicit grounded consent", async test("confirm-birth-time binds consent to the server-owned current turn", async () => { const accounting = fakeAccounting({ ...receiptHandlers, - confirm_agentic_rectification_birth_time: () => ({ + confirm_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "confirmed", @@ -122,21 +146,24 @@ test("confirm-birth-time binds consent to the server-owned current turn", async const input = { caseId: CASE_ID, resultId: RESULT_ID, - candidateId: "05:02", + candidateId: CANDIDATE_ID, consentQuote: "就用05:02", }; assert.equal(confirm.inputSchema.safeParse(input).success, true); assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false); await confirm.execute(input); - const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_birth_time"); + const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_candidate_for_case_v2"); assert.ok(call); assert.equal(call.args.p_source_turn_id, TURN_ID); + assert.equal(call.args.p_candidate_id, CANDIDATE_ID); + assert.equal(call.args.p_request_id, TURN_ID); + assert.equal("p_time" in call.args, false); }); test("candidate ownership is case-scoped: the RPC always receives the case id", async () => { const accounting = fakeAccounting({ - accept_agentic_rectification_candidate_for_case: () => ({ + accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "accepted", @@ -145,17 +172,20 @@ test("candidate ownership is case-scoped: the RPC always receives the case id", idempotent: false, }), }); - await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02"); - const call = accounting.calls.find((item) => item.fn === "accept_agentic_rectification_candidate_for_case"); + await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID); + const call = accounting.calls.find((item) => item.fn === "accept_agentic_rectification_candidate_for_case_v2"); assert.ok(call); assert.equal(call.args.p_case_id, CASE_ID); assert.equal(call.args.p_user_id, USER_ID); assert.equal(call.args.p_result_id, RESULT_ID); + assert.equal(call.args.p_candidate_id, CANDIDATE_ID); + assert.equal(call.args.p_request_id, TURN_ID); + assert.equal("p_time" in call.args, false); }); test("accept is idempotent: replaying the same selection succeeds without a second write", async () => { const accounting = fakeAccounting({ - accept_agentic_rectification_candidate_for_case: () => ({ + accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "accepted", @@ -164,7 +194,7 @@ test("accept is idempotent: replaying the same selection succeeds without a seco idempotent: true, }), }); - const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, "05:02"); + const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID); assert.equal(result.idempotent, true); assert.equal(result.status, "accepted"); }); @@ -692,12 +722,12 @@ test("no sensitive birth data in candidate accept inputs", () => { const schema = (tools["rectification-accept-candidate"] as unknown as { inputSchema: { safeParse(value: unknown): { success: boolean } }; }).inputSchema; - const valid = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: "05:02" }); + const valid = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: CANDIDATE_ID }); assert.equal(valid.success, true); const withBirth = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, - candidateId: "05:02", + candidateId: CANDIDATE_ID, birth_date: "1997-08-08", active_birth_time: "05:00", }); diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index 75881567..ac047b53 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -46,6 +46,8 @@ export const ATTEMPT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; export const FOCUS_ID = "abababab-abab-4bab-8bab-abababababab"; export const EVIDENCE_ID = "44444444-4444-4444-8444-444444444444"; export const RESULT_ID = "55555555-5555-4555-8555-555555555555"; +export const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881"; +export const SECOND_CANDIDATE_ID = "88888888-8888-4888-8888-888888888882"; export const USER_ID = "66666666-6666-4666-8666-666666666666"; export const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777"; export const RECTIFICATION_SKILL_SHA256 = "5acb3103e80993ea611b93d2c1746e70b74aff8f8636bcffc80a837b954b470d"; @@ -228,13 +230,20 @@ export function candidateSnapshotFixture(overrides: { return { result_id: RESULT_ID, candidates: overrides.candidates ?? [ - { rank: 1, time: "05:02", relative_support: 58, tied_minute_count: 2 }, - { rank: 2, time: "04:55", relative_support: 42, tied_minute_count: 3 }, + { candidate_id: CANDIDATE_ID, rank: 1, time: "05:02", relative_support: 58, tied_minute_count: 2 }, + { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:55", relative_support: 42, tied_minute_count: 3 }, ], overall_confidence: "medium", - margin_percent: 16, - selection_allowed: overrides.selectionAllowed ?? true, - confirmation_allowed: overrides.confirmationAllowed ?? false, + decision_receipt: { + receipt_version: "candidate-decision-receipt-v2", + policy_version: "rectification-candidate-policy-v2", + selection_allowed: overrides.selectionAllowed ?? true, + acceptance_allowed: overrides.selectionAllowed ?? true, + confirmation_allowed: overrides.confirmationAllowed ?? false, + representative_candidate_id: overrides.representativeTime ? CANDIDATE_ID : null, + overall_confidence: "medium", + }, + execution_ledger: [{ method: "d1-rashi", status: "executed" }], representative_time: overrides.representativeTime ?? null, selected_time: overrides.selectedTime ?? null, selection_kind: overrides.selectionKind ?? null, diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index f3e26664..a1aea7f5 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -4,13 +4,21 @@ from typing import Any from uuid import NAMESPACE_URL, uuid5 from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot -from scripts.rectification.contracts import RectificationRequest +from scripts.rectification.contracts import EVENT_CONTRACT_VERSION, RectificationRequest +from scripts.rectification.decision_policy import ( + EXECUTION_LEDGER_VERSION, + POLICY_VERSION, + build_candidate_decisions, + build_decision_receipt, + build_execution_ledger, +) from scripts.rectification.diagnostics_service import run_diagnostics from scripts.rectification.scoring_service import ( ALGORITHM_VERSION, build_event_contribution_matrix, calculation_spec, score_from_matrix, + scoreable_request, sha256, ) @@ -18,25 +26,36 @@ from scripts.rectification.scoring_service import ( def candidate_features(request: RectificationRequest) -> dict[str, Any]: spec = calculation_spec(request) spec_hash = sha256(spec) + scoring_request = scoreable_request(request) return { "algorithm_version": ALGORITHM_VERSION, + "event_contract_version": EVENT_CONTRACT_VERSION, + "decision_policy_version": POLICY_VERSION, "calculation_spec": spec, "calculation_spec_hash": spec_hash, - "candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash), + "candidate_feature_snapshot": build_candidate_feature_snapshot(scoring_request, spec_hash), "can_confirm_exact_minute": False, } def score_candidates(request: RectificationRequest) -> dict[str, Any]: - built = build_event_contribution_matrix(request) - rows = score_from_matrix(request, built) + scoring_request = scoreable_request(request) + built = build_event_contribution_matrix(scoring_request) + rows = score_from_matrix(scoring_request, built) spec = calculation_spec(request) spec_hash = sha256(spec) - diagnostics = run_diagnostics(request, rows, built) + diagnostic_values = run_diagnostics(scoring_request, rows, built) fingerprint = sha256(request) + result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")) + candidate_decisions = build_candidate_decisions(rows, result_id=result_id) + decision_receipt = build_decision_receipt(request, candidate_decisions, built, diagnostic_values) + execution_ledger = build_execution_ledger(request, built, diagnostic_values, candidate_decisions) + representative = candidate_decisions[0] if candidate_decisions else None return { - "result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")), + "result_id": result_id, "algorithm_version": ALGORITHM_VERSION, + "event_contract_version": EVENT_CONTRACT_VERSION, + "decision_policy_version": POLICY_VERSION, "calculation_spec": spec, "calculation_spec_hash": spec_hash, "candidate_scores": [{ @@ -45,17 +64,32 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]: "supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0], "conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0], } for row in rows], + "candidate_decisions": candidate_decisions, + "candidate_decision_receipt": decision_receipt, + "decision_receipt": decision_receipt, + "execution_ledger_version": EXECUTION_LEDGER_VERSION, + "execution_ledger": execution_ledger, "event_contribution_matrix": built["matrix"], - "candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash, built.get("static_contexts")), - "diagnostics": diagnostics, + "candidate_feature_snapshot": build_candidate_feature_snapshot( + scoring_request, spec_hash, built.get("static_contexts") + ), + "diagnostics": diagnostic_values, "robustness": { - "neighbor_support_minutes": diagnostics["neighbor_support_minutes"], - "leave_one_out_retention_rate": diagnostics["leave_one_event_out_retention_rate"], - "leave_one_domain_out_retention_rate": diagnostics["leave_one_domain_out_retention_rate"], - "date_sensitivity_retention_rate": diagnostics["date_sensitivity_retention_rate"], + "neighbor_support_minutes": diagnostic_values.get("neighbor_support_minutes", 0), + "leave_one_out_retention_rate": diagnostic_values.get("leave_one_event_out_retention_rate", 0), + "leave_one_domain_out_retention_rate": diagnostic_values.get("leave_one_domain_out_retention_rate", 0), + "date_sensitivity_retention_rate": diagnostic_values.get("date_sensitivity_retention_rate", 0), }, "missing_layers": built["missing_layers"], - "can_confirm_exact_minute": False, + "display_allowed": decision_receipt["display_allowed"], + "selection_allowed": decision_receipt["selection_allowed"], + "acceptance_allowed": decision_receipt["acceptance_allowed"], + "confirmation_allowed": decision_receipt["confirmation_allowed"], + "representative_candidate_id": representative["candidate_id"] if representative else None, + "representative_time": representative["time"] if representative else None, + "overall_confidence": decision_receipt["overall_confidence"], + "margin_percent": decision_receipt["margin_percent"], + "can_confirm_exact_minute": decision_receipt["confirmation_allowed"], } @@ -64,8 +98,23 @@ def diagnostics(request: RectificationRequest) -> dict[str, Any]: return { "result_id": scored["result_id"], "algorithm_version": scored["algorithm_version"], + "event_contract_version": scored["event_contract_version"], + "decision_policy_version": scored["decision_policy_version"], "calculation_spec_hash": scored["calculation_spec_hash"], + "candidate_decisions": scored["candidate_decisions"], + "candidate_decision_receipt": scored["candidate_decision_receipt"], + "decision_receipt": scored["decision_receipt"], + "execution_ledger_version": scored["execution_ledger_version"], + "execution_ledger": scored["execution_ledger"], "diagnostics": scored["diagnostics"], "missing_layers": scored["missing_layers"], - "can_confirm_exact_minute": False, + "display_allowed": scored["display_allowed"], + "selection_allowed": scored["selection_allowed"], + "acceptance_allowed": scored["acceptance_allowed"], + "confirmation_allowed": scored["confirmation_allowed"], + "representative_candidate_id": scored["representative_candidate_id"], + "representative_time": scored["representative_time"], + "overall_confidence": scored["overall_confidence"], + "margin_percent": scored["margin_percent"], + "can_confirm_exact_minute": scored["confirmation_allowed"], } diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index 232c6b23..b857cd0b 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -8,19 +8,41 @@ from typing import Any, Literal, NotRequired, TypedDict, cast DatePrecision = Literal["day", "month", "quarter", "year", "range"] +EVENT_CONTRACT_VERSION = "rectification-event-contract-v2" + +EVENT_KINDS: dict[str, frozenset[str]] = { + "education": frozenset({ + "education_start", "education_completion", "education_interruption", "education_change", + "education_milestone", # v1 compatibility + }), + "career": frozenset({ + "career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start", + }), + "relationship": frozenset({ + "relationship_start", "relationship_commitment", "relationship_separation", "relationship_end", + "relationship_change", # v1 compatibility + }), + "relocation": frozenset({"relocation", "foreign_move", "return", "home_change"}), + "finance": frozenset({"finance_gain", "finance_loss", "income_change", "asset_change", "finance_change"}), + "health": frozenset({"self_health_event", "pressure_period"}), + "health_pressure": frozenset({"self_health_event", "pressure_period"}), # v1 domain compatibility + "family": frozenset({"family_event"}), + "other": frozenset({"other"}), +} +BACKGROUND_EVENT_KINDS = frozenset({"family_event", "other"}) SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = { - "education": frozenset({"education_milestone"}), - "relocation": frozenset({"relocation"}), - "relationship": frozenset({"relationship_start", "relationship_change"}), - "career": frozenset({"career_change"}), - "finance": frozenset({"finance_change"}), - "health_pressure": frozenset({"self_health_event"}), + domain: frozenset(kind for kind in kinds if kind not in BACKGROUND_EVENT_KINDS) + for domain, kinds in EVENT_KINDS.items() + if any(kind not in BACKGROUND_EVENT_KINDS for kind in kinds) } DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"}) _BIRTH_TIME_SOURCES = frozenset({"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import"}) _LOCAL_TIME_STATUSES = frozenset({"resolved", "not_provided", "ambiguous", "nonexistent"}) _REQUEST_PROVENANCE_FIELDS = frozenset({"birth_time_source", "timezone_id", "timezone_source", "local_time_status"}) -_EVENT_PROVENANCE_FIELDS = frozenset({"date_source", "date_reliability", "date_corroboration", "date_conflict_status"}) +_EVENT_PROVENANCE_FIELDS = frozenset({ + "date_source", "date_reliability", "date_corroboration", "date_conflict_status", + "source_turn_id", "subject", +}) _REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"}) | _REQUEST_PROVENANCE_FIELDS _EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS _CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z") @@ -38,6 +60,8 @@ class LifeEvent(TypedDict): date_reliability: NotRequired[str | None] date_corroboration: NotRequired[str | None] date_conflict_status: NotRequired[str | None] + source_turn_id: NotRequired[str | None] + subject: NotRequired[Literal["self", "family", "other"]] class RectificationRequest(TypedDict): @@ -57,6 +81,10 @@ class RectificationRequest(TypedDict): JsonObject = dict[str, Any] +def is_scoreable_event(event: LifeEvent) -> bool: + return event["event_kind"] not in BACKGROUND_EVENT_KINDS + + def _bounded_number(body: dict[str, Any], name: str, minimum: float, maximum: float) -> float: value = body.get(name) if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): @@ -123,9 +151,9 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) -> except (ValueError, AttributeError) as exc: raise ValueError(f"events[{index}].id must be a UUID") from exc domain, event_kind = raw_event.get("domain"), raw_event.get("event_kind") - if domain not in SCOREABLE_EVENT_KINDS: - raise ValueError(f"events[{index}].domain is not scoreable") - if event_kind not in SCOREABLE_EVENT_KINDS[cast(str, domain)]: + if domain not in EVENT_KINDS: + raise ValueError(f"events[{index}].domain is invalid") + if event_kind not in EVENT_KINDS[cast(str, domain)]: raise ValueError(f"events[{index}].event_kind does not match domain") precision = raw_event.get("precision") if precision not in DATE_PRECISIONS: @@ -139,6 +167,13 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) -> summary = raw_event.get("summary", "") if not isinstance(summary, str) or len(summary) > 1_000: raise ValueError(f"events[{index}].summary must be a string up to 1000 characters") + subject = raw_event.get("subject") + if subject is None: + subject = "family" if domain == "family" else "other" if domain == "other" else "self" + if subject not in {"self", "family", "other"}: + raise ValueError(f"events[{index}].subject is invalid") + if event_kind not in BACKGROUND_EVENT_KINDS and subject != "self": + raise ValueError(f"events[{index}].subject must be self for scoreable events") cleaned_event: dict[str, Any] = { "id": event_id, "domain": cast(str, domain), @@ -147,11 +182,21 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) -> "date_end": end_day.isoformat(), "precision": cast(DatePrecision, precision), "summary": summary.strip(), + "subject": cast(Literal["self", "family", "other"], subject), } _copy_nullable_text(raw_event, cleaned_event, "date_source", f"events[{index}].date_source", 120) _copy_nullable_text(raw_event, cleaned_event, "date_reliability", f"events[{index}].date_reliability", 120) _copy_nullable_text(raw_event, cleaned_event, "date_corroboration", f"events[{index}].date_corroboration", 1_000) _copy_nullable_text(raw_event, cleaned_event, "date_conflict_status", f"events[{index}].date_conflict_status", 120) + if "source_turn_id" in raw_event: + source_turn_id = raw_event.get("source_turn_id") + if source_turn_id is None: + cleaned_event["source_turn_id"] = None + else: + try: + cleaned_event["source_turn_id"] = str(uuid.UUID(str(source_turn_id))) + except (ValueError, AttributeError) as exc: + raise ValueError(f"events[{index}].source_turn_id must be null or a UUID") from exc cleaned_events.append(cast(LifeEvent, cleaned_event)) cleaned_request: dict[str, Any] = { diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py new file mode 100644 index 00000000..4cd610b3 --- /dev/null +++ b/scripts/rectification/decision_policy.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +from collections.abc import Sequence +from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +from scripts.active_rectification_events import CandidateScoreRow +from scripts.rectification.contracts import ( + EVENT_CONTRACT_VERSION, + RectificationRequest, + is_scoreable_event, +) +from scripts.rectification.scoring_service import precision_weight + +POLICY_VERSION = "rectification-candidate-policy-v2" +RECEIPT_VERSION = "candidate-decision-receipt-v2" +EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2" +SCORE_QUANTUM = Decimal("0.0001") +TIE_ABSOLUTE_TOLERANCE = Decimal("0.0001") +MIN_ACCEPTANCE_EVENTS = 3 +MIN_ACCEPTANCE_DOMAINS = 2 +MIN_DATE_QUALITY_MEAN = Decimal("0.65") +MIN_DIAGNOSTIC_RETENTION = Decimal("0.75") +MIN_ACCEPTANCE_MARGIN_PERCENT = Decimal("10") + +_BAD_DATE_RELIABILITY = frozenset({"low", "uncertain", "unreliable"}) +_CLEAR_DATE_CONFLICT = frozenset({"", "none", "resolved", "no_conflict"}) + + +def _decimal(value: Any, default: str = "0") -> Decimal: + if isinstance(value, bool): + return Decimal(default) + try: + return Decimal(str(value)) + except Exception: + return Decimal(default) + + +def _quantized_score(row: CandidateScoreRow) -> Decimal: + return _decimal(row.get("score")).quantize(SCORE_QUANTUM, rounding=ROUND_HALF_UP) + + +def _relative_support(scores: Sequence[Decimal]) -> list[int]: + if not scores: + return [] + weights = [max(score, Decimal(0)) for score in scores] + total = sum(weights, Decimal(0)) + if total == 0: + base, remainder = divmod(100, len(scores)) + return [base + (1 if index < remainder else 0) for index in range(len(scores))] + + exact = [weight * Decimal(100) / total for weight in weights] + floors = [int(value.to_integral_value(rounding=ROUND_FLOOR)) for value in exact] + remaining = 100 - sum(floors) + order = sorted( + range(len(scores)), + key=lambda index: (-(exact[index] - Decimal(floors[index])), index), + ) + for index in order[:remaining]: + floors[index] += 1 + return floors + + +def build_candidate_decisions( + rows: Sequence[CandidateScoreRow], + *, + result_id: str, +) -> list[dict[str, Any]]: + ranked = sorted(rows, key=lambda row: (-_quantized_score(row), row["time"])) + public_rows = ranked[:3] + supports = _relative_support([_quantized_score(row) for row in public_rows]) + all_scores = [_quantized_score(row) for row in ranked] + decisions = [] + for index, row in enumerate(public_rows): + score = _quantized_score(row) + tied_minute_count = sum( + abs(score - other) <= TIE_ABSOLUTE_TOLERANCE + for other in all_scores + ) + decisions.append({ + "candidate_id": str(uuid5(NAMESPACE_URL, f"{POLICY_VERSION}:{result_id}:{row['time']}")), + "rank": index + 1, + "time": row["time"], + "relative_support": supports[index], + "tied_minute_count": tied_minute_count, + }) + return decisions + + +def _gate(passed: bool, **details: Any) -> dict[str, Any]: + return {"passed": passed, **details} + + +def _date_quality(events: Sequence[dict[str, Any]]) -> dict[str, Any]: + weights = [_decimal(precision_weight(str(event["precision"]))) for event in events] + total = sum(weights, Decimal(0)) + mean = total / Decimal(len(weights)) if weights else Decimal(0) + low_reliability = sorted( + event["id"] + for event in events + if str(event.get("date_reliability") or "").strip().lower() in _BAD_DATE_RELIABILITY + ) + unresolved_conflicts = sorted( + event["id"] + for event in events + if str(event.get("date_conflict_status") or "").strip().lower() not in _CLEAR_DATE_CONFLICT + ) + passed = bool(events) and mean >= MIN_DATE_QUALITY_MEAN and not low_reliability and not unresolved_conflicts + return _gate( + passed, + precision_weight_total=float(total), + precision_weight_mean=float(mean.quantize(SCORE_QUANTUM, rounding=ROUND_HALF_UP)), + minimum_precision_weight_mean=float(MIN_DATE_QUALITY_MEAN), + low_reliability_event_ids=low_reliability, + unresolved_conflict_event_ids=unresolved_conflicts, + ) + + +def _diagnostic_quality(diagnostics: dict[str, Any]) -> dict[str, Any]: + retention_names = ( + "leave_one_event_out_retention_rate", + "leave_one_domain_out_retention_rate", + "date_sensitivity_retention_rate", + ) + retentions = {name: _decimal(diagnostics.get(name)) for name in retention_names} + margin = _decimal(diagnostics.get("primary_secondary_margin_percent")) + passed = ( + all(value >= MIN_DIAGNOSTIC_RETENTION for value in retentions.values()) + and margin >= MIN_ACCEPTANCE_MARGIN_PERCENT + ) + return _gate( + passed, + minimum_retention=float(MIN_DIAGNOSTIC_RETENTION), + minimum_margin_percent=float(MIN_ACCEPTANCE_MARGIN_PERCENT), + margin_percent=float(margin), + **{name: float(value) for name, value in retentions.items()}, + ) + + +def build_decision_receipt( + request: RectificationRequest, + candidate_decisions: Sequence[dict[str, Any]], + built: dict[str, Any], + diagnostics: dict[str, Any], +) -> dict[str, Any]: + scoreable_events = [event for event in request["events"] if is_scoreable_event(event)] + domains = sorted({event["domain"] for event in scoreable_events}) + candidate_presence = _gate(bool(candidate_decisions), candidate_count=len(candidate_decisions)) + event_quality = _gate( + len(scoreable_events) >= MIN_ACCEPTANCE_EVENTS, + scoreable_event_count=len(scoreable_events), + minimum=MIN_ACCEPTANCE_EVENTS, + ) + domain_diversity = _gate( + len(domains) >= MIN_ACCEPTANCE_DOMAINS, + scoreable_domain_count=len(domains), + minimum=MIN_ACCEPTANCE_DOMAINS, + domains=domains, + ) + date_quality = _date_quality(scoreable_events) + top_tied_count = candidate_decisions[0]["tied_minute_count"] if candidate_decisions else 0 + unique_top = _gate(top_tied_count == 1, tied_minute_count=top_tied_count) + diagnostic_quality = _diagnostic_quality(diagnostics) + required_layers = _gate( + not built.get("missing_layers"), + missing_layers=sorted(built.get("missing_layers") or []), + ) + + acceptance_allowed = all(( + candidate_presence["passed"], + event_quality["passed"], + domain_diversity["passed"], + date_quality["passed"], + unique_top["passed"], + diagnostic_quality["passed"], + )) + margin = _decimal(diagnostics.get("primary_secondary_margin_percent")) + if acceptance_allowed and margin >= Decimal("20"): + overall_confidence = "high" + elif acceptance_allowed: + overall_confidence = "medium" + else: + overall_confidence = "low" + + acceptance_reasons: list[str] = [] + for passed, reason in ( + (candidate_presence["passed"], "no_candidates"), + (event_quality["passed"], "insufficient_events"), + (domain_diversity["passed"], "insufficient_domain_diversity"), + (date_quality["passed"], "low_date_quality"), + (unique_top["passed"], "tied_top_score"), + (diagnostic_quality["passed"], "insufficient_diagnostic_stability"), + ): + if not passed: + acceptance_reasons.append(reason) + confirmation_reasons = [] + if not required_layers["passed"]: + confirmation_reasons.append("missing_mandatory_layers") + confirmation_reasons.extend(("engine_exact_confirmation_not_granted", "external_validation_not_passed")) + reasons = [*acceptance_reasons, *confirmation_reasons] + + representative = candidate_decisions[0] if candidate_decisions else None + exact_confirmation = { + "passed": False, + "fail_closed": True, + "engine_granted": False, + "external_validation_status": "not_evaluated", + "required_scoreable_events": 4, + "required_scoreable_domains": 3, + "reason": "engine_and_external_validation_must_explicitly_pass", + } + receipt = { + "receipt_version": RECEIPT_VERSION, + "contract_version": "v2", + "event_contract_version": EVENT_CONTRACT_VERSION, + "policy_version": POLICY_VERSION, + "decision_policy_version": POLICY_VERSION, + "display_allowed": bool(candidate_decisions), + "selection_allowed": acceptance_allowed, + "acceptance_allowed": acceptance_allowed, + "confirmation_allowed": False, + "accept_allowed": acceptance_allowed, + "confirm_allowed": False, + "representative_candidate_id": representative["candidate_id"] if representative else None, + "representative_time": representative["time"] if representative else None, + "overall_confidence": overall_confidence, + "margin_percent": float(margin), + "reasons": reasons, + "acceptance_reasons": acceptance_reasons, + "confirmation_reasons": confirmation_reasons, + "tie_policy": { + "score_quantum": float(SCORE_QUANTUM), + "absolute_tolerance": float(TIE_ABSOLUTE_TOLERANCE), + "rounding": "ROUND_HALF_UP", + }, + "gates": { + "candidate_presence": candidate_presence, + "event_quality": event_quality, + "domain_diversity": domain_diversity, + "date_quality": date_quality, + "unique_top": unique_top, + "diagnostic_quality": diagnostic_quality, + "required_layers": required_layers, + "exact_confirmation": exact_confirmation, + }, + } + return receipt + + +def build_execution_ledger( + request: RectificationRequest, + built: dict[str, Any], + diagnostics: dict[str, Any], + candidate_decisions: Sequence[dict[str, Any]], +) -> list[dict[str, Any]]: + matrix = built.get("matrix") or {} + date_sensitivity = { + item.get("event_id"): item + for item in built.get("date_sensitivity") or [] + if isinstance(item, dict) + } + entries: list[dict[str, Any]] = [] + all_layers: set[str] = set() + for event in request["events"]: + candidates = matrix.get(event["id"], {}) + layers = sorted({ + layer + for contribution in candidates.values() + for layer in contribution.get("technique_layers", []) + }) + all_layers.update(layers) + sensitivity = date_sensitivity.get(event["id"], {}) + scoreable = is_scoreable_event(event) + entries.append({ + "ledger_version": EXECUTION_LEDGER_VERSION, + "stage": "event_scoring", + "status": "executed" if scoreable and candidates else "not_executed" if scoreable else "retained_not_scored", + "source": "python-engine", + "event_id": event["id"], + "domain": event["domain"], + "event_kind": event["event_kind"], + "date_precision": event["precision"], + "precision_weight": precision_weight(event["precision"]), + "sampled_date_count": len(sensitivity.get("sample_dates") or []), + "candidate_count": len(candidates), + "technique_layers": layers, + }) + for layer in sorted(all_layers): + entries.append({ + "ledger_version": EXECUTION_LEDGER_VERSION, + "stage": "technique_layer", + "method": layer, + "status": "executed", + "source": "python-engine", + }) + entries.extend(( + { + "ledger_version": EXECUTION_LEDGER_VERSION, + "stage": "candidate_ranking", + "status": "executed" if candidate_decisions else "not_executed", + "source": "python-decision-policy", + "candidate_count": len(candidate_decisions), + }, + { + "ledger_version": EXECUTION_LEDGER_VERSION, + "stage": "diagnostics", + "status": "executed" if diagnostics else "not_executed", + "source": "python-engine", + "metrics": sorted(diagnostics), + }, + )) + return entries diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py index f08e09cc..6b22ce43 100644 --- a/scripts/rectification/scoring_service.py +++ b/scripts/rectification/scoring_service.py @@ -3,16 +3,54 @@ from __future__ import annotations import hashlib import json from collections import defaultdict +from collections.abc import Callable, Sequence from datetime import date, timedelta from functools import lru_cache -from typing import Any, Callable, Sequence +from typing import Any from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows from scripts.active_rectification_events import CandidateScoreRow -from scripts.rectification.contracts import LifeEvent, RectificationRequest +from scripts.rectification.contracts import LifeEvent, RectificationRequest, is_scoreable_event ALGORITHM_VERSION = "rectification-v5-matrix-scoring-2" INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4" +PRECISION_WEIGHTS = { + "day": 1.0, + "month": 0.8, + "quarter": 0.65, + "year": 0.5, + "range": 0.35, +} + +_ENGINE_KIND_BY_NATIVE_KIND: dict[str, tuple[str, str]] = { + "education_start": ("education", "education_milestone"), + "education_completion": ("education", "education_milestone"), + "education_interruption": ("education", "education_milestone"), + "education_change": ("education", "education_milestone"), + "education_milestone": ("education", "education_milestone"), + "career_entry": ("career", "career_change"), + "career_change": ("career", "career_change"), + "promotion": ("career", "career_change"), + "career_pressure": ("career", "career_change"), + "career_exit": ("career", "career_change"), + "business_start": ("career", "career_change"), + "relationship_start": ("relationship", "relationship_start"), + "relationship_commitment": ("relationship", "relationship_start"), + "relationship_separation": ("relationship", "relationship_change"), + "relationship_end": ("relationship", "relationship_change"), + "relationship_change": ("relationship", "relationship_change"), + "relocation": ("relocation", "relocation"), + "foreign_move": ("relocation", "relocation"), + "return": ("relocation", "relocation"), + "home_change": ("relocation", "relocation"), + "finance_gain": ("finance", "finance_change"), + "finance_loss": ("finance", "finance_change"), + "income_change": ("finance", "finance_change"), + "asset_change": ("finance", "finance_change"), + "finance_change": ("finance", "finance_change"), + "self_health_event": ("health_pressure", "self_health_event"), + "pressure_period": ("health_pressure", "self_health_event"), +} def _parse(value: str) -> date: @@ -58,6 +96,7 @@ def sample_event_dates(event: LifeEvent) -> list[str]: def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_date: str) -> dict[str, Any]: + engine_domain, _ = _ENGINE_KIND_BY_NATIVE_KIND[event["event_kind"]] return { "birth_date": request["birth_date"], "start_time": request["start_time"], @@ -66,8 +105,8 @@ def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_dat "lon": request["lon"], "tz": request["tz"], "events": [{ - "id": event["id"], "domain": event["domain"], - "event_kind": event.get("event_kind", event["domain"]), + "id": event["id"], "domain": engine_domain, + "event_kind": event["event_kind"], "date": sampled_date, "precision": "day", "summary": event.get("summary", ""), }], } @@ -82,57 +121,103 @@ def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]: return tuple(compute_event_candidate_rows(json.loads(serialized))) -_RELATIONSHIP_SUPPORT_RULES = ( +_SUPPORT_RULES = ( "functional_benefic_auxiliary", "arudha_auxiliary", "ashtakavarga_target_house_support_auxiliary", "shadbala_sthana_drik_naisargika_support_auxiliary", "controlled_transit_jupiter_domain_house", ) -_RELATIONSHIP_CHANGE_RULES = ( +_PRESSURE_RULES = ( "functional_malefic_auxiliary", "ashtakavarga_target_house_pressure_auxiliary", "shadbala_sthana_drik_naisargika_pressure_auxiliary", "controlled_transit_saturn_domain_house", ) +_KIND_SEMANTICS: dict[str, tuple[int, float]] = { + "education_start": (1, 1.0), + "education_completion": (1, 1.2), + "education_interruption": (-1, 1.0), + "education_change": (0, 1.0), + "career_entry": (1, 1.0), + "career_change": (0, 1.0), + "promotion": (1, 1.2), + "career_pressure": (-1, 1.0), + "career_exit": (-1, 1.2), + "business_start": (1, 1.2), + "relationship_start": (1, 1.0), + "relationship_commitment": (1, 1.2), + "relationship_separation": (-1, 1.0), + "relationship_end": (-1, 1.2), + "relationship_change": (-1, 1.0), + "relocation": (0, 1.0), + "foreign_move": (1, 1.0), + "return": (1, 0.8), + "home_change": (0, 1.0), + "finance_gain": (1, 1.0), + "finance_loss": (-1, 1.0), + "income_change": (0, 1.0), + "asset_change": (0, 1.0), + "self_health_event": (-1, 1.0), + "pressure_period": (-1, 1.2), +} -def _relationship_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float: - if event_kind not in {"relationship_start", "relationship_change"}: - return 1.0 - support = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_SUPPORT_RULES) for rule in rule_ids) - change = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_CHANGE_RULES) for rule in rule_ids) - direction = support - change if event_kind == "relationship_start" else change - support - return max(0.8, min(1.2, 1 + 0.08 * direction)) + +def precision_weight(precision: str) -> float: + return PRECISION_WEIGHTS[precision] + + +def _event_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float: + direction, intensity = _KIND_SEMANTICS.get(event_kind, (0, 1.0)) + support = sum(any(rule.endswith(marker) for marker in _SUPPORT_RULES) for rule in rule_ids) + pressure = sum(any(rule.endswith(marker) for marker in _PRESSURE_RULES) for rule in rule_ids) + semantic_signal = direction * (support - pressure) * intensity + return max(0.8, min(1.2, 1 + 0.08 * semantic_signal)) def _kind_adjusted_evidence(event: LifeEvent, evidence: dict[str, Any]) -> dict[str, Any]: - if event["domain"] != "relationship": - return evidence event_kind = event["event_kind"] rules = list(evidence["rule_ids"]) + direction, _ = _KIND_SEMANTICS.get(event_kind, (0, 1.0)) + semantic_label = "support" if direction > 0 else "pressure" if direction < 0 else "change" return { **evidence, - "rule_ids": [*rules, f"event_kind_profile:{event_kind}"], - "points": round(float(evidence["points"]) * _relationship_kind_factor(event_kind, rules), 4), + "rule_ids": [*rules, f"event_kind_profile:{event_kind}:{semantic_label}"], + "points": round( + float(evidence["points"]) + * _event_kind_factor(event_kind, rules) + * precision_weight(event["precision"]), + 4, + ), } +def scoreable_request(request: RectificationRequest) -> RectificationRequest: + return {**request, "events": [event for event in request["events"] if is_scoreable_event(event)]} + + def build_event_contribution_matrix( request: RectificationRequest, row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None, ) -> dict[str, Any]: - static_contexts = None if row_provider is not None else compute_candidate_static_contexts(request) + scoring_request = scoreable_request(request) + if not scoring_request["events"]: + return { + "candidate_times": [], "matrix": {}, "date_sensitivity": [], + "missing_layers": [], "static_contexts": None, + } + static_contexts = None if row_provider is not None else compute_candidate_static_contexts(scoring_request) provider = row_provider or (lambda value: compute_event_candidate_rows(value, static_contexts=static_contexts)) matrix: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) missing_layers: set[str] = set() date_sensitivity: list[dict[str, Any]] = [] candidate_grid: list[str] | None = None - for event in request["events"]: + for event in scoring_request["events"]: samples = sample_event_dates(event) sample_rows = [] for sampled in samples: - rows = list(provider(_legacy_request(request, event, sampled))) + rows = list(provider(_legacy_request(scoring_request, event, sampled))) sample_rows.append([ {**row, "score": adjusted["points"], "evidence": [adjusted]} for row in rows @@ -184,6 +269,8 @@ def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> l for candidate_time in built["candidate_times"]: evidence = [] for event in request["events"]: + if not is_scoreable_event(event): + continue contribution = built["matrix"][event["id"]][candidate_time] evidence.append({ "event_id": event["id"], "domain": event["domain"], "candidate_time": candidate_time, diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index a9c6ef54..666c277f 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -3,8 +3,15 @@ from __future__ import annotations import unittest from datetime import date from unittest.mock import patch +from uuid import UUID from scripts.active_rectification_event_engine import _candidate_datetimes +from scripts.jyotish_api_server import ( + API_COMMAND_MAP, + TECHNIQUE_EXAMPLE_ENDPOINTS, + BadRequest, + JyotishAPIHandler, +) from scripts.rectification.api_service import diagnostics, score_candidates from scripts.rectification.contracts import normalize_rectification_request from scripts.rectification.scoring_service import ( @@ -14,16 +21,22 @@ from scripts.rectification.scoring_service import ( score_from_matrix, sha256, ) -from scripts.jyotish_api_server import ( - API_COMMAND_MAP, - TECHNIQUE_EXAMPLE_ENDPOINTS, - BadRequest, - JyotishAPIHandler, -) EVENT_ID = "00000000-0000-4000-8000-000000000001" +def event(index: int, domain: str, event_kind: str, *, precision: str = "day"): + return { + "id": f"00000000-0000-4000-8000-{index:012d}", + "domain": domain, + "event_kind": event_kind, + "date_start": "2016-09-15", + "date_end": "2016-09-15", + "precision": precision, + "summary": event_kind, + } + + def request( *, precision: str = "month", @@ -31,6 +44,8 @@ def request( domain: str = "education", start_time: str = "05:13", end_time: str = "05:15", + date_start: str = "2016-09-01", + date_end: str = "2016-09-30", ): return { "birth_date": "1997-08-08", @@ -43,8 +58,8 @@ def request( "id": EVENT_ID, "domain": domain, "event_kind": event_kind, - "date_start": "2016-09-01", - "date_end": "2016-09-30", + "date_start": date_start, + "date_end": date_end, "precision": precision, "summary": "大学入学", }], @@ -52,6 +67,101 @@ def request( class RectificationV5ServicesTest(unittest.TestCase): + def test_event_contract_v2_accepts_native_kinds_and_retains_background_events(self): + supported = { + "education": ("education_start", "education_completion", "education_interruption", "education_change"), + "career": ("career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start"), + "relationship": ("relationship_start", "relationship_commitment", "relationship_separation", "relationship_end"), + "relocation": ("relocation", "foreign_move", "return", "home_change"), + "finance": ("finance_gain", "finance_loss", "income_change", "asset_change"), + "health": ("self_health_event", "pressure_period"), + "family": ("family_event",), + "other": ("other",), + } + + for domain, kinds in supported.items(): + for event_kind in kinds: + normalized = normalize_rectification_request( + request(domain=domain, event_kind=event_kind), + today=date(2026, 7, 28), + ) + self.assertEqual(normalized["events"][0]["domain"], domain) + self.assertEqual(normalized["events"][0]["event_kind"], event_kind) + + def test_event_contract_v2_preserves_server_provenance_and_enforces_self_scoring(self): + body = request(domain="career", event_kind="career_entry") + body["events"][0].update({ + "source_turn_id": "33333333-3333-4333-8333-333333333333", + "subject": "self", + "date_source": "user_quote", + "date_reliability": "month_exact", + "date_corroboration": "劳动合同", + "date_conflict_status": "none", + }) + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + event_value = normalized["events"][0] + self.assertEqual(event_value["source_turn_id"], "33333333-3333-4333-8333-333333333333") + self.assertEqual(event_value["subject"], "self") + self.assertEqual(event_value["date_source"], "user_quote") + self.assertEqual(event_value["date_reliability"], "month_exact") + self.assertEqual(event_value["date_corroboration"], "劳动合同") + self.assertEqual(event_value["date_conflict_status"], "none") + + body["events"][0]["subject"] = "family" + with self.assertRaisesRegex(ValueError, "subject must be self for scoreable events"): + normalize_rectification_request(body, today=date(2026, 7, 28)) + + def test_background_only_events_are_retained_without_invoking_the_scoring_engine(self): + body = request() + body["events"] = [ + event(1, "family", "family_event"), + event(2, "other", "other", precision="year"), + ] + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + + def fail_if_called(_request): + self.fail("background events must not reach the scoring engine") + + built = build_event_contribution_matrix(normalized, row_provider=fail_if_called) + + self.assertEqual(built["candidate_times"], []) + self.assertEqual(built["matrix"], {}) + self.assertEqual(built["date_sensitivity"], []) + + def test_declared_date_precision_changes_real_contribution_weight(self): + def rows(value): + event = value["events"][0] + return [{ + "time": "05:13", + "score": 10, + "evidence": [{ + "event_id": event["id"], + "domain": event["domain"], + "candidate_time": "05:13", + "rule_ids": ["vim_md_domain_house"], + "points": 10, + }], + "missing_layers": [], + }] + + cases = { + "day": request(precision="day", event_kind="education_start", date_start="2016-09-15", date_end="2016-09-15"), + "month": request(precision="month", event_kind="education_start"), + "year": request( + precision="year", + event_kind="education_start", + date_start="2016-01-01", + date_end="2016-12-31", + ), + } + points = {} + for precision, body in cases.items(): + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + built = build_event_contribution_matrix(normalized, row_provider=rows) + points[precision] = built["matrix"][EVENT_ID]["05:13"]["points"] + + self.assertEqual(points, {"day": 10, "month": 8, "year": 5}) + def test_calculation_spec_hash_matches_typescript_for_integral_timezone(self): normalized = normalize_rectification_request(request(), today=date(2026, 7, 28)) self.assertEqual( @@ -105,20 +215,42 @@ class RectificationV5ServicesTest(unittest.TestCase): "1997-08-08T05:15:00", ]) - def test_shared_validator_rejects_family_and_non_self_health_scoring(self): - with self.assertRaisesRegex(ValueError, "domain is not scoreable"): - normalize_rectification_request(request(domain="family", event_kind="family_bereavement"), today=date(2026, 7, 28)) + def test_shared_validator_retains_background_events_and_rejects_non_self_health(self): + background = normalize_rectification_request( + request(domain="family", event_kind="family_event"), today=date(2026, 7, 28) + ) + self.assertEqual(background["events"][0]["event_kind"], "family_event") + with self.assertRaisesRegex(ValueError, "event_kind does not match domain"): + normalize_rectification_request( + request(domain="family", event_kind="family_bereavement"), today=date(2026, 7, 28) + ) with self.assertRaisesRegex(ValueError, "event_kind does not match domain"): normalize_rectification_request(request(domain="health_pressure", event_kind="family_health_event"), today=date(2026, 7, 28)) normalized = normalize_rectification_request(request(domain="health_pressure", event_kind="self_health_event"), today=date(2026, 7, 28)) self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event") - def test_relationship_end_is_not_scoreable(self): - with self.assertRaisesRegex(ValueError, "event_kind does not match domain"): - normalize_rectification_request( - request(domain="relationship", event_kind="relationship_end"), - today=date(2026, 7, 28), - ) + def test_relationship_end_is_native_and_uses_pressure_semantics(self): + normalized = normalize_rectification_request( + request(domain="relationship", event_kind="relationship_end", precision="day"), + today=date(2026, 7, 28), + ) + + def rows(value): + event_value = value["events"][0] + return [{ + "time": "05:13", "score": 10, + "evidence": [{ + "event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13", + "rule_ids": ["controlled_transit_saturn_domain_house", f"event_kind:{event_value['event_kind']}"], + "points": 10, + }], + "missing_layers": [], + }] + + built = build_event_contribution_matrix(normalized, row_provider=rows) + contribution = built["matrix"][EVENT_ID]["05:13"] + self.assertIn("event_kind_profile:relationship_end:pressure", contribution["rule_ids"]) + self.assertGreater(contribution["points"], 10) def test_relationship_start_and_change_use_distinct_rule_conditioned_profiles(self): def relationship_rows(_value): @@ -207,8 +339,8 @@ class RectificationV5ServicesTest(unittest.TestCase): built = build_event_contribution_matrix(normalized, row_provider=rows) scored = score_from_matrix(normalized, built) - self.assertEqual(built["matrix"][EVENT_ID]["05:14"]["points"], 11) - self.assertEqual(scored[1]["score"], 11) + self.assertEqual(built["matrix"][EVENT_ID]["05:14"]["points"], 8.8) + self.assertEqual(scored[1]["score"], 8.8) self.assertEqual(built["missing_layers"], ["KP_cusps"]) def test_formal_score_and_diagnostics_endpoints_share_the_service_bundle(self): @@ -247,6 +379,214 @@ class RectificationV5ServicesTest(unittest.TestCase): self.assertEqual(diagnostic_result["diagnostics"]["leave_one_event_out_retention_rate"], 1) self.assertFalse(diagnostic_result["can_confirm_exact_minute"]) + def test_score_endpoint_returns_candidate_decision_receipt_v2_and_real_execution_ledger(self): + body = request() + body["events"] = [ + event(1, "education", "education_start"), + event(2, "career", "promotion", precision="month"), + event(3, "finance", "finance_gain"), + event(4, "family", "family_event", precision="year"), + ] + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + scored_ids = [item["id"] for item in normalized["events"][:3]] + built = { + "candidate_times": ["05:13", "05:14", "05:15"], + "matrix": { + event_id: { + "05:13": {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}, + "05:14": {"points": 2, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}, + "05:15": {"points": 1, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}, + } + for event_id in scored_ids + }, + "date_sensitivity": [ + { + "event_id": event_id, + "declared_date_range": {"start": "2016-09-15", "end": "2016-09-15", "precision": "day"}, + "sample_dates": ["2016-09-15"], + "winner_retention_rate": 1, + "score_variance": 0, + "sample_winners": ["05:13"], + } + for event_id in scored_ids + ], + "missing_layers": [], + "static_contexts": [], + } + diagnostic_values = { + "primary_cluster_retention_rate": 1, + "leave_one_event_out_retention_rate": .9, + "leave_one_domain_out_retention_rate": .9, + "date_sensitivity_retention_rate": .9, + "neighbor_support_minutes": 1, + "primary_secondary_margin_percent": 30, + "event_date_sensitivity": built["date_sensitivity"], + } + feature = { + "calculation_spec_hash": "0" * 64, + "algorithm_version": "rectification-v5-matrix-scoring-2", + "candidate_count": 3, + "feature_hash": "1" * 64, + "features": [], + } + with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch( + "scripts.rectification.api_service.build_candidate_feature_snapshot", return_value=feature + ), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values): + first = score_candidates(normalized) + second = score_candidates(normalized) + + self.assertEqual(first["event_contract_version"], "rectification-event-contract-v2") + self.assertEqual(first["candidate_decisions"], second["candidate_decisions"]) + self.assertEqual(sum(item["relative_support"] for item in first["candidate_decisions"]), 100) + for rank, candidate in enumerate(first["candidate_decisions"], start=1): + self.assertEqual(set(candidate), {"candidate_id", "rank", "time", "relative_support", "tied_minute_count"}) + self.assertEqual(candidate["rank"], rank) + UUID(candidate["candidate_id"]) + + receipt = first["candidate_decision_receipt"] + self.assertEqual(receipt["receipt_version"], "candidate-decision-receipt-v2") + self.assertTrue(receipt["selection_allowed"]) + self.assertTrue(receipt["acceptance_allowed"]) + self.assertFalse(receipt["confirmation_allowed"]) + self.assertTrue(receipt["gates"]["domain_diversity"]["passed"]) + self.assertTrue(receipt["gates"]["date_quality"]["passed"]) + self.assertTrue(receipt["gates"]["exact_confirmation"]["fail_closed"]) + + self.assertEqual(first["decision_receipt"], receipt) + self.assertEqual(first["decision_policy_version"], "rectification-candidate-policy-v2") + self.assertTrue(receipt["display_allowed"]) + self.assertTrue(receipt["accept_allowed"]) + self.assertFalse(receipt["confirm_allowed"]) + self.assertIsNotNone(receipt["representative_candidate_id"]) + self.assertEqual(receipt["representative_time"], "05:13") + + entries = first["execution_ledger"] + background = next(item for item in entries if item.get("event_id") == normalized["events"][3]["id"]) + self.assertEqual(background["status"], "retained_not_scored") + executed = next(item for item in entries if item.get("event_id") == normalized["events"][0]["id"]) + self.assertEqual(executed["technique_layers"], ["vim_md_domain_house"]) + + def test_diagnostics_endpoint_mirrors_candidate_decision_policy_fields(self): + normalized = normalize_rectification_request(request(), today=date(2026, 7, 28)) + scored = { + "result_id": "00000000-0000-4000-8000-000000000099", + "algorithm_version": "rectification-v5-matrix-scoring-2", + "event_contract_version": "rectification-event-contract-v2", + "decision_policy_version": "rectification-candidate-policy-v2", + "calculation_spec_hash": "0" * 64, + "candidate_decisions": [], + "candidate_decision_receipt": {}, + "decision_receipt": {}, + "execution_ledger_version": "rectification-execution-ledger-v2", + "execution_ledger": [], + "diagnostics": {}, + "missing_layers": [], + "display_allowed": False, + "selection_allowed": False, + "acceptance_allowed": False, + "confirmation_allowed": False, + "representative_candidate_id": None, + "representative_time": None, + "overall_confidence": "low", + "margin_percent": 0.0, + } + with patch("scripts.rectification.api_service.score_candidates", return_value=scored): + result = diagnostics(normalized) + + for field in ( + "display_allowed", + "selection_allowed", + "acceptance_allowed", + "confirmation_allowed", + "representative_candidate_id", + "representative_time", + "overall_confidence", + "margin_percent", + ): + self.assertEqual(result[field], scored[field]) + self.assertFalse(result["can_confirm_exact_minute"]) + + def test_single_domain_or_quantized_top_tie_blocks_candidate_acceptance(self): + body = request() + body["events"] = [ + event(1, "career", "career_entry"), + event(2, "career", "promotion"), + event(3, "career", "career_change"), + ] + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + built = { + "candidate_times": ["05:13", "05:14"], + "matrix": { + item["id"]: { + "05:13": {"points": 3.333346, "rule_ids": ["D10:test"], "technique_layers": ["D10"]}, + "05:14": {"points": 3.333333, "rule_ids": ["D10:test"], "technique_layers": ["D10"]}, + } + for item in normalized["events"] + }, + "date_sensitivity": [], + "missing_layers": [], + "static_contexts": [], + } + diagnostic_values = { + "primary_cluster_retention_rate": 1, + "leave_one_event_out_retention_rate": 1, + "leave_one_domain_out_retention_rate": 1, + "date_sensitivity_retention_rate": 1, + "neighbor_support_minutes": 1, + "primary_secondary_margin_percent": 50, + } + with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch( + "scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={} + ), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values): + result = score_candidates(normalized) + + receipt = result["candidate_decision_receipt"] + self.assertFalse(receipt["acceptance_allowed"]) + self.assertIn("insufficient_domain_diversity", receipt["reasons"]) + self.assertIn("tied_top_score", receipt["reasons"]) + self.assertEqual(result["candidate_decisions"][0]["tied_minute_count"], 2) + self.assertEqual(receipt["tie_policy"]["score_quantum"], .0001) + self.assertEqual(receipt["tie_policy"]["absolute_tolerance"], .0001) + + def test_low_date_quality_blocks_acceptance_even_with_multiple_domains_and_a_unique_candidate(self): + body = request() + body["events"] = [ + event(1, "education", "education_start", precision="year"), + event(2, "career", "promotion", precision="year"), + event(3, "finance", "finance_gain", precision="year"), + ] + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + built = { + "candidate_times": ["05:13", "05:14"], + "matrix": { + item["id"]: { + "05:13": {"points": 4, "rule_ids": ["test"], "technique_layers": ["test"]}, + "05:14": {"points": 1, "rule_ids": ["test"], "technique_layers": ["test"]}, + } + for item in normalized["events"] + }, + "date_sensitivity": [], + "missing_layers": [], + "static_contexts": [], + } + diagnostic_values = { + "leave_one_event_out_retention_rate": 1, + "leave_one_domain_out_retention_rate": 1, + "date_sensitivity_retention_rate": 1, + "primary_secondary_margin_percent": 50, + } + with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch( + "scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={} + ), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values): + result = score_candidates(normalized) + + receipt = result["candidate_decision_receipt"] + self.assertTrue(result["candidate_decisions"]) + self.assertFalse(receipt["acceptance_allowed"]) + self.assertFalse(receipt["gates"]["date_quality"]["passed"]) + self.assertIn("low_date_quality", receipt["reasons"]) + self.assertFalse(result["can_confirm_exact_minute"]) + def test_http_registry_exposes_all_v5_endpoints(self): expected = { "rectification-v5-candidate-features": "/api/rectification/v5/candidate-features", @@ -259,7 +599,9 @@ class RectificationV5ServicesTest(unittest.TestCase): def test_http_handler_enforces_subject_and_event_kind_boundaries(self): handler = object.__new__(JyotishAPIHandler) - with self.assertRaisesRegex(BadRequest, "domain is not scoreable"): + retained = handler._rectification_v5_request(request(domain="family", event_kind="family_event")) + self.assertEqual(retained["events"][0]["event_kind"], "family_event") + with self.assertRaisesRegex(BadRequest, "event_kind does not match domain"): handler._rectification_v5_request(request(domain="family", event_kind="family_bereavement")) with self.assertRaisesRegex(BadRequest, "event_kind does not match domain"): handler._rectification_v5_request(request(domain="health_pressure", event_kind="family_health_event"))