/** * V9 deterministic engine client (Python, server-side only). * * Builds engine payloads exclusively from the Case's baseline birth snapshot * 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. */ import type { PublicRectificationMethod } from "./public-receipt"; export class RectificationEngineError extends Error { readonly code: string; constructor(code: string, message: string) { super(message); this.name = "RectificationEngineError"; this.code = code; } } export type V9EngineEvent = Readonly<{ id: string; domain: string; event_kind: string; date_start: string; date_end: string; precision: "day" | "month" | "quarter" | "year" | "range"; summary: string; }>; export type V9EngineCandidate = Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number; }>; export type V9EngineScoreResult = Readonly<{ engineResultId: string; algorithmVersion: string; candidateRange: { start_time: string; end_time: string }; candidates: readonly V9EngineCandidate[]; overallConfidence: "low" | "medium" | "high"; marginPercent: number | null; selectionAllowed: boolean; confirmationAllowed: boolean; representativeTime: string | null; executedMethods: readonly PublicRectificationMethod[]; }>; export type V9EngineDiagnostics = Readonly<{ algorithmVersion: string; engineResultId: string; diagnostics: Readonly>; missingLayers: readonly string[]; canConfirmExactMinute: boolean; executedMethods: readonly PublicRectificationMethod[]; }>; const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/; const DOMAIN_METHODS: Readonly> = { education: ["d24-chaturvimshamsha"], relocation: ["d4-chaturthamsha"], relationship: ["d9-navamsa"], career: ["d10-dashamsa"], finance: ["d2-hora", "d11-labhamsha"], health_pressure: ["d30-trimshamsha"], }; function techniqueLayers(value: unknown): string[] { if (!value || typeof value !== "object") return []; return Object.values(value as Record).flatMap((eventRows) => { if (!eventRows || typeof eventRows !== "object") return []; return Object.values(eventRows as Record).flatMap((candidate) => { if (!candidate || typeof candidate !== "object") return []; const layers = (candidate as Record).technique_layers; return Array.isArray(layers) ? layers.filter((item): item is string => typeof item === "string") : []; }); }); } function executedMethods( events: readonly V9EngineEvent[], layers: readonly string[], ): PublicRectificationMethod[] { const methods = new Set([ "d1-rashi", "vimshottari-dasha", "narayana-dasha", ]); for (const event of events) { for (const method of DOMAIN_METHODS[event.domain] ?? []) methods.add(method); } const normalized = layers.map((layer) => layer.toLowerCase()); if (normalized.some((layer) => layer.includes("controlled_transit") || layer.includes("gochara"))) methods.add("gochara"); if (normalized.some((layer) => layer.includes("ashtakavarga"))) methods.add("ashtakavarga"); if (normalized.some((layer) => layer.includes("shadbala"))) methods.add("shadbala"); if (normalized.some((layer) => layer.includes("arudha"))) methods.add("arudha-pada"); if (normalized.some((layer) => layer.includes("functional_benefic") || layer.includes("functional_malefic"))) { methods.add("functional-benefic-malefic"); } return [...methods]; } function clockMinute(value: string): number { const [hour = 0, minute = 0] = value.split(":").map(Number); return hour * 60 + minute; } function timeInRange(time: string, range: { start_time: string; end_time: string }): boolean { const value = clockMinute(time); const start = clockMinute(range.start_time); const end = clockMinute(range.end_time); return end >= start ? value >= start && value <= end : value >= start || value <= end; } /** * 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. */ export function enginePrecision(precision: string): V9EngineEvent["precision"] { if (precision === "day") return "day"; if (precision === "month") return "month"; if (precision === "range") return "range"; return "year"; } export function toEngineEvents( evidence: readonly Readonly<{ id: string; eventKind: string; domain: string; occurredFrom: string | null; occurredTo: string | null; datePrecision: string; summary: string; }>[], ): 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: 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), summary: item.summary, }]; }); } function engineBase(): string { return process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"; } async function postEngine(path: string, body: unknown, timeoutMs = 60_000): Promise> { const response = await fetch(`${engineBase()}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const data = await response.json().catch(() => null); if (!response.ok) { const message = data?.error || data?.message || `Jyotish API ${path} returned ${response.status}`; throw new RectificationEngineError("engine_http_error", String(message)); } if (!data || typeof data !== "object") { throw new RectificationEngineError("engine_invalid_response", `Jyotish API ${path} returned an invalid response`); } return data as Record; } 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; 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): Record { return data.diagnostics && typeof data.diagnostics === "object" ? data.diagnostics as Record : {}; } export async function runV9CandidateScore(input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; }): Promise { const snapshot = input.baselineBirthSnapshot; const birthDate = String(snapshot.birth_date ?? ""); const lat = engineNumber(snapshot.latitude); const lon = engineNumber(snapshot.longitude); const tz = engineNumber(snapshot.timezone_offset); 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, end_time: input.candidateRange.end_time, lat, lon, tz, events: input.events, }); const candidates = readCandidates(data.candidate_scores, input.candidateRange); if (candidates.length === 0) { throw new RectificationEngineError("engine_no_candidates", "the engine returned no usable candidates"); } const diagnostics = engineDiagnostics(data); const methods = executedMethods(input.events, techniqueLayers(data.event_contribution_matrix)); 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" = 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, selectionAllowed: candidates.length > 0, confirmationAllowed: data.can_confirm_exact_minute === true, representativeTime: candidates[0]?.time ?? null, executedMethods: methods, }; } export async function runV9Diagnostics(input: { baselineBirthSnapshot: Readonly>; candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; }): Promise { const snapshot = input.baselineBirthSnapshot; const birthDate = String(snapshot.birth_date ?? ""); const lat = engineNumber(snapshot.latitude); const lon = engineNumber(snapshot.longitude); const tz = engineNumber(snapshot.timezone_offset); 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, end_time: input.candidateRange.end_time, lat, lon, tz, events: input.events, }); const diagnostics = engineDiagnostics(data); const missingLayers = Array.isArray(data.missing_layers) ? data.missing_layers as string[] : []; const discriminatingLayers = Array.isArray(diagnostics.most_discriminating_layers) ? diagnostics.most_discriminating_layers.filter((item): item is string => typeof item === "string") : []; return { algorithmVersion: String(data.algorithm_version ?? "rectification-v5"), engineResultId: String(data.result_id ?? ""), diagnostics: { primary_cluster_retention_rate: diagnostics.primary_cluster_retention_rate, leave_one_event_out_retention_rate: diagnostics.leave_one_event_out_retention_rate, leave_one_domain_out_retention_rate: diagnostics.leave_one_domain_out_retention_rate, date_sensitivity_retention_rate: diagnostics.date_sensitivity_retention_rate, neighbor_support_minutes: diagnostics.neighbor_support_minutes, primary_secondary_margin_percent: diagnostics.primary_secondary_margin_percent, unstable_event_ids: diagnostics.unstable_event_ids, most_discriminating_layers: diagnostics.most_discriminating_layers, candidate_splits: diagnostics.candidate_splits, }, missingLayers, canConfirmExactMinute: data.can_confirm_exact_minute === true, executedMethods: executedMethods(input.events, discriminatingLayers), }; } export const v9EngineVersion = (): string => process.env.RECTIFICATION_ENGINE_VERSION?.trim() || "rectification-v5";