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