378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
/**
|
|
* 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<Record<string, unknown>>;
|
|
missingLayers: readonly string[];
|
|
canConfirmExactMinute: boolean;
|
|
executedMethods: readonly PublicRectificationMethod[];
|
|
}>;
|
|
|
|
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
|
|
const DOMAIN_METHODS: Readonly<Record<string, readonly PublicRectificationMethod[]>> = {
|
|
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<string, unknown>).flatMap((eventRows) => {
|
|
if (!eventRows || typeof eventRows !== "object") return [];
|
|
return Object.values(eventRows as Record<string, unknown>).flatMap((candidate) => {
|
|
if (!candidate || typeof candidate !== "object") return [];
|
|
const layers = (candidate as Record<string, unknown>).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<PublicRectificationMethod>([
|
|
"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<Record<string, unknown>> {
|
|
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<string, unknown>;
|
|
}
|
|
|
|
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 };
|
|
events: readonly V9EngineEvent[];
|
|
}): Promise<V9EngineScoreResult> {
|
|
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<Record<string, unknown>>;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
events: readonly V9EngineEvent[];
|
|
}): Promise<V9EngineDiagnostics> {
|
|
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";
|