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
@@ -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\)/);