Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/engine-client.ts
T
Jesse_Chen 397b6ef7c2
Staging Backend Quality Gate / validate (pull_request) Failing after 12m4s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
fix(rectification): surface real activity context
2026-08-12 09:28:47 +08:00

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";