fix(rectification): harden v9 runtime after adversarial review
This commit is contained in:
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user