265 lines
9.1 KiB
TypeScript
265 lines
9.1 KiB
TypeScript
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: [] },
|
|
],
|
|
event_contribution_matrix: {
|
|
"00000000-0000-4000-8000-000000000001": {
|
|
"04:50": {
|
|
points: 3.85,
|
|
rule_ids: ["vim_md_domain_house", "controlled_transit_jupiter_domain_house"],
|
|
technique_layers: [
|
|
"vim_md_domain_house",
|
|
"controlled_transit_jupiter_domain_house",
|
|
"ashtakavarga_target_house_support_auxiliary",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
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");
|
|
assert.deepEqual(result.executedMethods, [
|
|
"d1-rashi",
|
|
"vimshottari-dasha",
|
|
"narayana-dasha",
|
|
"d24-chaturvimshamsha",
|
|
"gochara",
|
|
"ashtakavarga",
|
|
]);
|
|
} 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"]);
|
|
assert.deepEqual(result.executedMethods, [
|
|
"d1-rashi",
|
|
"vimshottari-dasha",
|
|
"narayana-dasha",
|
|
"d24-chaturvimshamsha",
|
|
]);
|
|
} 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();
|
|
}
|
|
});
|