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,