fix(rectification): harden v9 runtime after adversarial review

This commit is contained in:
Jesse
2026-08-11 17:57:47 +08:00
parent 724fb64c1a
commit eddd52d1f3
6 changed files with 446 additions and 66 deletions
+27
View File
@@ -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 成功后同一候选再次 acceptconfirmed 后同一 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_pressurefamily/other 留在账本但不再进引擎);`readCandidates``time+score` 按分数降序推导 rank、同分 tied_minute_count、相对支持度归一化;`representative_time`=top1`selection_allowed`=有候选;`confirmation_allowed` 只来自引擎 `can_confirm_exact_minute`(真实引擎当前为 false,confirm 门诚实关闭);`margin_percent` 取自 diagnosticsconfidence 由 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 17brew),按 `deploy/postgres/001-bootstrap-roles.sh` 建角色,从空库全量应用 95 个迁移 ×2second 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 backfillconfirmed/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` 12561232 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
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>): Record<string, unknown> {
return data.diagnostics && typeof data.diagnostics === "object"
? data.diagnostics as Record<string, unknown>
: {};
}
export async function runV9CandidateScore(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
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<string, unknown>
: {};
const diagnostics = engineDiagnostics(data);
const missingLayers = Array.isArray(data.missing_layers) ? data.missing_layers as string[] : [];
return {
algorithmVersion: String(data.algorithm_version ?? "rectification-v5"),
+8 -14
View File
@@ -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,
@@ -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'),
@@ -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();
}
});
@@ -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\)/);