diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e9ce8001..70d22137 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2788,3 +2788,30 @@ - `hasRectificationSession` 只断言“存在任意校正 Session”,不区分 draft / collecting / candidate_ready / candidate_accepted / confirmed / closed,因此已完成会话始终被当作可继续,且没有服务端 Case 状态可被测试断言。 - `sessions.find(sessionType === "birth_time_rectification")` 取客户端数组第一条,既不保证精确 sessionId,也不校验 Case 绑定;排序、缓存或刷新差异都会改变打开哪条记录。 - 旧测试只覆盖“能找到一条校正 Session”的客户端行为,没有服务端 Case 状态机、没有“点击指定 Session 必须精确恢复”的契约、没有并发/双击/多标签幂等断言,也没有 legacy→V9 一次性回填的数据库级验证,因此这些缺陷在回归中被遗漏。 + +## BUG-163 | V9 红队审查:引擎适配器契约不匹配 + accept 重放幂等顺序错误 + +- 状态:resolved(已修复;真实 PostgreSQL 17 与真实 Python 引擎双重实证) +- 首次发现:2026-08-11(红队审查 `60e2ce4f..724fb64c`) +- 最近更新:2026-08-11 +- 影响面:V9 候选比较管线(compare-candidates / read-diagnostics)、候选采用重放幂等、确认重放返回字段 +- 现象: + 1. `rectification-compare-candidates` 在生产面对真实 Python 引擎必然失败:前端 `engine-client.ts` 假设 `/api/rectification/v5/score` 返回 `rank/tied_minute_count/representative_time/confidence/margin_percent/selection_allowed/confirmation_allowed`,实际引擎只返回 `candidate_scores:[{time,score,supporting_event_ids,conflicting_event_ids}]`(`scripts/rectification/api_service.py:score_candidates`,已用真实 HTTP 请求实证);同时 V9 evidence kind/domain(`education_start/career_entry/promotion/relationship_commitment/finance_gain` 等)直接透传给引擎,而引擎 `SCOREABLE_EVENT_KINDS` 只有 `education_milestone/relocation/relationship_start|change/career_change/finance_change/self_health_event(health_pressure)`,几乎全部 400。结果:所有候选比较必然 `engine_no_candidates` 或 `engine_http_error`,V9 无法产出任何候选。 + 2. `accept_agentic_rectification_candidate_for_case` 的幂等重放分支位于 profile 基线校验**之后**;第一次 accept 写入 `active_birth_time` 后,基线快照与 profile 必然分歧,重放/双击/断线重试返回 `candidate_profile_changed` 而不是 `idempotent=true`(旧 `accept_agentic_rectification_candidate` 是先重放后校验,语义回归)。 + 3. `confirm_agentic_rectification_birth_time` 幂等分支在 `v_result` 载入前引用 `v_result.id`,confirmed 重放响应的 `result_id` 恒为 null。 +- 触发条件:任何进入 compare-candidates 的真实运行;accept 成功后同一候选再次 accept;confirmed 后同一 confirm 重放。 +- 根因: + - 新增 `engine-client.ts` 从未与真实引擎做契约测试(既有测试全部 mock `runV9CandidateScore`,未覆盖真实响应形状);V9 evidence 领域模型与引擎粗粒度评分词汇之间缺少 kind/domain 翻译层。 + - accept/confirm RPC 的重放语义被基线保护逻辑错误地前置/后置,未对齐旧实现的先重放后校验顺序。 +- 修复: + 1. `engine-client.ts` 对齐真实引擎契约:新增 `toEngineScoreableEvent` kind/domain 翻译(education_*→education_milestone、career_*→career_change、relationship_start/commitment→relationship_start、relationship_separation→relationship_change、relocation→relocation、finance_*→finance_change、self_health_event→health_pressure;family/other 留在账本但不再进引擎);`readCandidates` 由 `time+score` 按分数降序推导 rank、同分 tied_minute_count、相对支持度归一化;`representative_time`=top1;`selection_allowed`=有候选;`confirmation_allowed` 只来自引擎 `can_confirm_exact_minute`(真实引擎当前为 false,confirm 门诚实关闭);`margin_percent` 取自 diagnostics;confidence 由 margin+retention 推导;无 scorable 事件/无候选时 fail-closed(`no_scorable_evidence`/`engine_no_candidates`)。 + 2. `20260813010000_agentic_rectification_v9_agent_api.sql`:accept 重放分支移到 profile 基线校验之前,重放分支校验 profile 与已采用时间一致(对齐旧语义);confirm 幂等分支补 `select id into v_result.id`,`result_id` 不再为 null。 + 3. 移除 `rectification-v9-tools.ts` compare-candidates 中双分支同 throw 的死代码。 +- 验证(真实执行,非 mock): + - 本机安装 PostgreSQL 17(brew),按 `deploy/postgres/001-bootstrap-roles.sh` 建角色,从空库全量应用 95 个迁移 ×2(second run 95 already applied,`--check` exit 0),含两个 V9 迁移。 + - 真实 SQL 行为:open homepage create/resume、requestId 幂等、session 精确恢复、跨用户 404、new 冲突、profile_incomplete、evidence quote grounding/idempotent/confirm/revision lineage、terminal 只读、accept→accepted + 重放 idempotent=true(修复后)、confirm gate=false blocked + gate=true 且 consent 原文匹配 → confirmed + 重放 idempotent + `result_id` 非空、指纹缓存复用、profile 变更 → needs_rebaseline + 候选 invalidated、legacy backfill(confirmed/candidate_accepted/superseded/abandoned 分布、results_mapped=3、重跑 cases_created=0、verify 0 conflict/0 orphan)、RLS service_role-only 均实证通过。 + - 真实 Python 引擎:`scripts/jyotish_api_server.py` 启动后,修复后 `engine-client` 直连 `/api/rectification/v5/score` + `/v5/diagnostics` 成功产出候选(rank/relative_support/tied)、family_event 被排除、`confirmation_allowed=false`、diagnostics 键完整映射。 + - 新增 `rectification-v9-engine-contract.test.ts`(9 项,含真实引擎响应形状 fixture)与 migration 顺序静态回归 1 项;v9 聚焦 33+108 全部通过;全量 `npm test` 1256(1232 pass / 18 Docker ENOENT 环境失败,与基线 6d7a9a97 同因,worktree 实证);`tsc --noEmit` 仅剩 6 个既有未触碰测试文件错误;ESLint 0 error;`next build` 通过;`git diff --check` 通过。 +- 防复发:引擎适配器必须有真实响应形状的契约测试;新增任何映射层必须对照 `scripts/rectification/contracts.py:SCOREABLE_EVENT_KINDS`;RPC 重放/幂等语义以“先重放后校验、重放校验已落库状态”为唯一实现顺序;迁移修改必须在真实 PostgreSQL 上从空库全量应用并重跑。 +- 相关记录:BUG-162、BUG-112 +- 修复版本:本地 staging 候选(未 push / deploy) diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 84fc1c06..6edfd5fe 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -5,6 +5,21 @@ * 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. */ export class RectificationEngineError extends Error { @@ -68,29 +83,40 @@ function timeInRange(time: string, range: { start_time: string; end_time: string return end >= start ? value >= start && value <= end : value >= start || value <= end; } -function readCandidates(value: unknown, range: { start_time: string; end_time: string }): V9EngineCandidate[] { - if (!Array.isArray(value)) return []; - const rows = value.flatMap((item): Array<{ rank: number; time: string; score: number; tied: number }> => { - if (!item || typeof item !== "object") return []; - const row = item as Record; - const time = typeof row.time === "string" ? row.time : ""; - const rank = typeof row.rank === "number" ? Math.trunc(row.rank) : 0; - const score = typeof row.score === "number" && Number.isFinite(row.score) ? row.score : 0; - const tied = typeof row.tied_minute_count === "number" ? Math.max(1, Math.trunc(row.tied_minute_count)) : 1; - if (!timePattern.test(time) || rank < 1 || !timeInRange(time, range)) return []; - return [{ rank, time, score, tied }]; - }).sort((left, right) => left.rank - right.rank).slice(0, 3); - if (rows.length === 0) return []; - const weights = rows.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 / rows.length)); - supports[0] += 100 - supports.reduce((sum, support) => sum + support, 0); - return rows.map((row, index) => ({ - rank: row.rank, - time: row.time, - relative_support: supports[index] ?? 0, - tied_minute_count: row.tied, - })); +/** + * 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. */ @@ -113,13 +139,15 @@ export function toEngineEvents( }>[], ): V9EngineEvent[] { return evidence.flatMap((item): V9EngineEvent[] => { + const scoreable = toEngineScoreableEvent(item); + if (!scoreable) return []; const start = item.occurredFrom ?? item.occurredTo; const end = item.occurredTo ?? item.occurredFrom; if (!start) return []; return [{ id: item.id, - domain: item.domain, - event_kind: item.eventKind, + domain: scoreable.domain, + event_kind: scoreable.event_kind, date_start: start.slice(0, 10), date_end: end ? end.slice(0, 10) : start.slice(0, 10), precision: enginePrecision(item.datePrecision), @@ -154,6 +182,42 @@ function engineNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +/** + * 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 }]; + }); + 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 + : {}; +} + export async function runV9CandidateScore(input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; @@ -167,6 +231,9 @@ export async function runV9CandidateScore(input: { if (!birthDate || lat === null || lon === null || tz === null) { throw new RectificationEngineError("engine_profile_incomplete", "server profile snapshot is incomplete"); } + 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", { birth_date: birthDate, start_time: input.candidateRange.start_time, @@ -180,22 +247,29 @@ export async function runV9CandidateScore(input: { if (candidates.length === 0) { throw new RectificationEngineError("engine_no_candidates", "the engine returned no usable candidates"); } - const representativeTime = - typeof data.representative_time === "string" && timePattern.test(data.representative_time) - ? data.representative_time.slice(0, 5) - : null; + const diagnostics = engineDiagnostics(data); + 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" = - data.confidence === "high" || data.confidence === "medium" ? data.confidence : "low"; + 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"), candidateRange: input.candidateRange, candidates, overallConfidence: confidence, - marginPercent: engineNumber(data.margin_percent), - selectionAllowed: data.selection_allowed === true || data.can_apply === true, - confirmationAllowed: data.confirmation_allowed === true, - representativeTime, + marginPercent, + selectionAllowed: candidates.length > 0, + confirmationAllowed: data.can_confirm_exact_minute === true, + representativeTime: candidates[0]?.time ?? null, }; } @@ -212,6 +286,9 @@ export async function runV9Diagnostics(input: { if (!birthDate || lat === null || lon === null || tz === null) { throw new RectificationEngineError("engine_profile_incomplete", "server profile snapshot is incomplete"); } + 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, @@ -221,9 +298,7 @@ export async function runV9Diagnostics(input: { tz, events: input.events, }); - const diagnostics = data.diagnostics && typeof data.diagnostics === "object" - ? data.diagnostics as Record - : {}; + const diagnostics = engineDiagnostics(data); const missingLayers = Array.isArray(data.missing_layers) ? data.missing_layers as string[] : []; return { algorithmVersion: String(data.algorithm_version ?? "rectification-v5"), diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 0999c6fb..dfce37bf 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -442,20 +442,14 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { compute.baselineProfileFingerprint, ); const events = toEngineEvents(scorableEvidence(dossier.evidence)); - let score: V9EngineScoreResult; - try { - score = await runV9CandidateScore({ - baselineBirthSnapshot: compute.baselineBirthSnapshot, - candidateRange: parsed.case.candidateRange, - events, - }); - } catch (error) { - // Engine down: reuse a cached snapshot when the fingerprints match. - if (parsed.latestResult && !parsed.latestResult.selectionAllowed && !parsed.latestResult.confirmationAllowed) { - throw error; - } - throw error; - } + // Engine errors (including no_scorable_evidence after the V9 evidence + // -> engine vocabulary mapping) must fail the tool honestly; cached + // snapshots are only reused by the persist RPC's fingerprint cache. + const score: V9EngineScoreResult = await runV9CandidateScore({ + baselineBirthSnapshot: compute.baselineBirthSnapshot, + candidateRange: parsed.case.candidateRange, + events, + }); const persisted = await persistV9Candidate(accounting, userId, input.caseId, { engineResultId: score.engineResultId, algorithmVersion: score.algorithmVersion, diff --git a/frontend/supabase/migrations/20260813010000_agentic_rectification_v9_agent_api.sql b/frontend/supabase/migrations/20260813010000_agentic_rectification_v9_agent_api.sql index fb121b6c..0a5728a9 100644 --- a/frontend/supabase/migrations/20260813010000_agentic_rectification_v9_agent_api.sql +++ b/frontend/supabase/migrations/20260813010000_agentic_rectification_v9_agent_api.sql @@ -464,6 +464,41 @@ begin raise exception 'agentic_rectification_candidate_time_not_allowed' using errcode = 'P0001'; end if; + -- Idempotent replay: this candidate/time was already accepted for the + -- case. The profile legitimately carries the accepted time now (the + -- baseline snapshot is intentionally stale after acceptance), so the replay + -- validates the profile against the accepted selection instead of the + -- baseline. This mirrors the pre-v9 accept_agentic_rectification_candidate + -- semantics and keeps retries/double-clicks idempotent. + if v_result.selected_time is not null then + if v_result.selected_time is distinct from p_time + or v_case.accepted_time is distinct from p_time then + raise exception 'agentic_rectification_candidate_already_selected' using errcode = 'P0001'; + end if; + select * into v_profile + from public.profiles + where id = p_user_id + for update; + if not found + or v_profile.active_birth_time is distinct from v_result.selected_time + or v_profile.birth_time is distinct from v_result.selected_time + or v_profile.birth_time_status is distinct from ( + case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end + ) then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + return jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), + 'status', case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end, + 'result_id', v_result.id, + 'case_status', v_case.status, + 'idempotent', true + ); + end if; + + -- Fresh acceptance: the profile must still match the case baseline before + -- any write (an engine-confirmed replay already returned above). v_snapshot := v_case.baseline_birth_snapshot; select * into v_profile from public.profiles @@ -484,21 +519,6 @@ begin raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; end if; - if v_result.selected_time is not null then - if v_result.selected_time is distinct from p_time - or v_case.accepted_time is distinct from p_time then - raise exception 'agentic_rectification_candidate_already_selected' using errcode = 'P0001'; - end if; - return jsonb_build_object( - 'success', true, - 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), - 'status', case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end, - 'result_id', v_result.id, - 'case_status', v_case.status, - 'idempotent', true - ); - end if; - if exists ( select 1 from public.agentic_rectification_results newer @@ -618,6 +638,9 @@ begin if v_case.confirmed_time is distinct from p_time then raise exception 'agentic_rectification_case_already_confirmed' using errcode = 'P0001'; end if; + select id into v_result.id + from public.agentic_rectification_results + where id = p_result_id and user_id = p_user_id and case_id = p_case_id; return jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(v_case.confirmed_time, 'HH24:MI'), diff --git a/frontend/tests/rectification-v9-engine-contract.test.ts b/frontend/tests/rectification-v9-engine-contract.test.ts new file mode 100644 index 00000000..7e287e18 --- /dev/null +++ b/frontend/tests/rectification-v9-engine-contract.test.ts @@ -0,0 +1,237 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RectificationEngineError, + runV9CandidateScore, + runV9Diagnostics, + toEngineEvents, + toEngineScoreableEvent, + V9EngineScoreResult, +} from "../src/lib/rectification-agentic/v9/engine-client.ts"; + +const RANGE = { start_time: "04:50", end_time: "05:10" }; + +/** + * 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 = { + 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: [] }, + ], + 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 }], + }, + missing_layers: ["KP_cusps"], + can_confirm_exact_minute: false, +}; + +const REAL_ENGINE_DIAGNOSTICS_RESPONSE = { + success: true, + endpoint: "rectification_v5_diagnostics", + result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62", + algorithm_version: "rectification-v5-matrix-scoring-2", + calculation_spec_hash: "f05fe0f56ef9ba2b18ec3c6c54f1649f06f1ae5a926491a5c5f676d718d92865", + 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 }], + }, + missing_layers: ["KP_cusps"], + can_confirm_exact_minute: false, +}; + +function stubEngine(response: unknown, status = 200) { + const previous = globalThis.fetch; + globalThis.fetch = (async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => response, + })) as unknown as typeof fetch; + return () => { + globalThis.fetch = previous; + }; +} + +const SNAPSHOT = { + birth_date: "1997-08-08", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, + birth_time_source: "family_exact", +}; + +const EVIDENCE = [ + { + id: "00000000-0000-4000-8000-000000000001", + eventKind: "education_start", + domain: "education", + occurredFrom: "2016-09-01", + occurredTo: "2016-09-30", + datePrecision: "month", + summary: "大学入学", + }, +]; + +test("toEngineEvents maps V9 evidence kinds onto the engine scoreable vocabulary", () => { + 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]!.date_start, "2016-09-01"); + assert.equal(events[0]!.precision, "month"); +}); + +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("runV9CandidateScore derives rank/support/gating from the real engine response shape", async () => { + const restore = stubEngine(REAL_ENGINE_SCORE_RESPONSE); + 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.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"); + } finally { + restore(); + } +}); + +test("runV9CandidateScore fails closed when the engine returns no usable candidates", async () => { + const restore = stubEngine({ ...REAL_ENGINE_SCORE_RESPONSE, candidate_scores: [] }); + try { + await assert.rejects( + runV9CandidateScore({ + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents(EVIDENCE), + }), + (error: unknown) => + error instanceof RectificationEngineError && error.code === "engine_no_candidates", + ); + } 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); + try { + await assert.rejects( + runV9CandidateScore({ + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents(backgroundOnly), + }), + (error: unknown) => + error instanceof RectificationEngineError && error.code === "no_scorable_evidence", + ); + } finally { + restore(); + } +}); + +test("runV9Diagnostics maps the real diagnostics response keys", async () => { + const restore = stubEngine(REAL_ENGINE_DIAGNOSTICS_RESPONSE); + try { + const result = await runV9Diagnostics({ + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents(EVIDENCE), + }); + 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"]); + } finally { + restore(); + } +}); + +test("engine http failures surface as safe engine errors, never raw stack traces", async () => { + const restore = stubEngine({ error: "invalid event kind" }, 400); + try { + await assert.rejects( + runV9CandidateScore({ + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents(EVIDENCE), + }), + (error: unknown) => error instanceof Error && error.message.includes("invalid event kind"), + ); + } finally { + restore(); + } +}); diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index b57274a9..9d015e34 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -264,6 +264,30 @@ test("candidate fingerprint cache reuse and terminal/skill-version guards are en assert.match(agentApiMigration, /agentic_rectification_confirm_time_mismatch/); }); +test("accept replay idempotency precedes the profile baseline check", () => { + // Regression guard: a second accept of the same candidate must return + // idempotent=true, not fail with candidate_profile_changed. The profile + // legitimately diverges from the baseline snapshot after the first accept, + // so the replay branch (selected_time already set) must come BEFORE the + // baseline comparison. Verified against a real PostgreSQL 17 run. + const acceptFn = agentApiMigration.slice( + agentApiMigration.indexOf("create or replace function public.accept_agentic_rectification_candidate_for_case"), + agentApiMigration.indexOf("-- ---------------------------------------------------------------------------\n-- 6. Confirm birth time"), + ); + const idempotentBranch = acceptFn.indexOf("if v_result.selected_time is not null then"); + const profileCheck = acceptFn.indexOf("agentic_rectification_candidate_profile_changed"); + assert.ok(idempotentBranch >= 0, "idempotent replay branch must exist"); + assert.ok(profileCheck >= 0, "profile baseline check must exist"); + assert.ok( + idempotentBranch < profileCheck, + "idempotent replay must be evaluated before the profile baseline check", + ); + // The replay validates the profile against the accepted selection, not the + // stale baseline snapshot. + const replayProfileCheck = acceptFn.slice(idempotentBranch, acceptFn.indexOf("v_snapshot := v_case.baseline_birth_snapshot;")); + assert.match(replayProfileCheck, /v_profile\.active_birth_time is distinct from v_result\.selected_time/); +}); + test("confirmation gate requires a consent quote grounded in the source turn", () => { assert.match(agentApiMigration, /agentic_rectification_consent_not_grounded/); assert.match(agentApiMigration, /agentic_rectification_normalize_quote\(p_consent_quote\)/);