463 lines
18 KiB
TypeScript
463 lines
18 KiB
TypeScript
/**
|
|
* V9 deterministic engine client (Python, server-side only).
|
|
*
|
|
* The Python service owns Event Contract v2 normalization, candidate ranking,
|
|
* decision policy and the execution ledger. This client validates and projects
|
|
* that server contract; it never reconstructs rank, support, ties, confidence,
|
|
* permissions or executed methods from raw scores or event domains.
|
|
*/
|
|
|
|
import {
|
|
isBackgroundEvidenceKind,
|
|
isEvidenceDomain,
|
|
isEvidenceKind,
|
|
type EvidenceKind,
|
|
} from "./evidence-model";
|
|
import {
|
|
isPublicRectificationMethod,
|
|
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;
|
|
date_source?: string | null;
|
|
date_reliability?: string | null;
|
|
date_corroboration?: string | null;
|
|
date_conflict_status?: string | null;
|
|
source_turn_id?: string | null;
|
|
subject?: string | null;
|
|
}>;
|
|
|
|
export type V9EngineCandidate = Readonly<{
|
|
candidateId: string;
|
|
time: string;
|
|
rank: number;
|
|
relativeSupport: number;
|
|
tiedMinuteCount: number;
|
|
}>;
|
|
|
|
export type V9DecisionReceipt = Readonly<Record<string, unknown>>;
|
|
export type V9ExecutionLedger = readonly Readonly<Record<string, unknown>>[];
|
|
|
|
export type V9EngineScoreResult = Readonly<{
|
|
engineResultId: string;
|
|
algorithmVersion: string;
|
|
eventContractVersion: string;
|
|
policyVersion: string;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
candidates: readonly V9EngineCandidate[];
|
|
overallConfidence: "low" | "medium" | "high";
|
|
marginPercent: number | null;
|
|
selectionAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
representativeCandidateId: string | null;
|
|
representativeTime: string | null;
|
|
decisionReceipt: V9DecisionReceipt;
|
|
executionLedger: V9ExecutionLedger;
|
|
executedMethods: readonly PublicRectificationMethod[];
|
|
}>;
|
|
|
|
export type V9EngineDiagnostics = Readonly<{
|
|
algorithmVersion: string;
|
|
engineResultId: string;
|
|
eventContractVersion: string;
|
|
policyVersion: string;
|
|
diagnostics: Readonly<Record<string, unknown>>;
|
|
missingLayers: readonly string[];
|
|
canConfirmExactMinute: boolean;
|
|
decisionReceipt: V9DecisionReceipt;
|
|
executionLedger: V9ExecutionLedger;
|
|
executedMethods: readonly PublicRectificationMethod[];
|
|
}>;
|
|
|
|
const EVENT_CONTRACT_VERSION = "rectification-event-contract-v2";
|
|
const RECEIPT_VERSION = "candidate-decision-receipt-v2";
|
|
const EXECUTION_LEDGER_VERSION = "rectification-execution-ledger-v2";
|
|
const timePattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function engineNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value === "string" && value.trim()) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** Map a V9 evidence date precision to the Event Contract v2 vocabulary. */
|
|
export function enginePrecision(precision: string): V9EngineEvent["precision"] {
|
|
if (precision === "day" || precision === "month" || precision === "quarter" || precision === "range") {
|
|
return precision;
|
|
}
|
|
return "year";
|
|
}
|
|
|
|
export function toEngineEvents(
|
|
evidence: readonly Readonly<{
|
|
id: string;
|
|
eventKind: string;
|
|
domain: string;
|
|
occurredFrom: string | null;
|
|
occurredTo: string | null;
|
|
datePrecision: string;
|
|
summary: string;
|
|
dateSource?: string | null;
|
|
dateReliability?: string | null;
|
|
dateCorroboration?: string | null;
|
|
dateConflictStatus?: string | null;
|
|
sourceTurnId?: string | null;
|
|
subject?: string | null;
|
|
}>[],
|
|
): V9EngineEvent[] {
|
|
return evidence.flatMap((item): V9EngineEvent[] => {
|
|
if (!isEvidenceKind(item.eventKind) || !isEvidenceDomain(item.domain)) return [];
|
|
if (isBackgroundEvidenceKind(item.eventKind as EvidenceKind)) 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,
|
|
date_start: start.slice(0, 10),
|
|
date_end: (end ?? start).slice(0, 10),
|
|
precision: enginePrecision(item.datePrecision),
|
|
summary: item.summary,
|
|
date_source: item.dateSource ?? null,
|
|
date_reliability: item.dateReliability ?? null,
|
|
date_corroboration: item.dateCorroboration ?? null,
|
|
date_conflict_status: item.dateConflictStatus ?? null,
|
|
source_turn_id: item.sourceTurnId ?? null,
|
|
subject: item.subject ?? null,
|
|
}];
|
|
});
|
|
}
|
|
|
|
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 && typeof data === "object"
|
|
? String((data as Record<string, unknown>).error ?? (data as Record<string, unknown>).message ?? `Jyotish API ${response.status}`)
|
|
: `Jyotish API ${response.status}`;
|
|
throw new RectificationEngineError("engine_request_failed", message);
|
|
}
|
|
if (!data || typeof data !== "object") {
|
|
throw new RectificationEngineError("engine_invalid_response", "Jyotish API returned an invalid response");
|
|
}
|
|
return data as Record<string, unknown>;
|
|
}
|
|
|
|
function readCandidates(
|
|
value: unknown,
|
|
range: { start_time: string; end_time: string },
|
|
): V9EngineCandidate[] {
|
|
if (!Array.isArray(value) || value.length === 0) {
|
|
throw new RectificationEngineError("engine_invalid_candidate_decisions", "engine candidate decisions are missing");
|
|
}
|
|
const seenIds = new Set<string>();
|
|
const seenTimes = new Set<string>();
|
|
const candidates: V9EngineCandidate[] = [];
|
|
for (const item of value) {
|
|
const row = record(item);
|
|
const candidateId = row?.candidate_id;
|
|
const time = row?.time;
|
|
const rank = row?.rank;
|
|
const relativeSupport = row?.relative_support;
|
|
const tiedMinuteCount = row?.tied_minute_count;
|
|
if (
|
|
typeof candidateId !== "string" || !uuidPattern.test(candidateId)
|
|
|| typeof time !== "string" || !timePattern.test(time) || !timeInRange(time, range)
|
|
|| typeof rank !== "number" || !Number.isInteger(rank) || rank < 1
|
|
|| typeof relativeSupport !== "number" || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100
|
|
|| typeof tiedMinuteCount !== "number" || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
|
|
|| seenIds.has(candidateId) || seenTimes.has(time)
|
|
) {
|
|
throw new RectificationEngineError("engine_invalid_candidate_decisions", "engine candidate decisions are invalid");
|
|
}
|
|
seenIds.add(candidateId);
|
|
seenTimes.add(time);
|
|
candidates.push({ candidateId, time, rank, relativeSupport, tiedMinuteCount });
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
type ParsedReceipt = Readonly<{
|
|
raw: V9DecisionReceipt;
|
|
eventContractVersion: string;
|
|
policyVersion: string;
|
|
selectionAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
representativeCandidateId: string | null;
|
|
representativeTime: string | null;
|
|
overallConfidence: "low" | "medium" | "high";
|
|
marginPercent: number | null;
|
|
}>;
|
|
|
|
function invalidReceipt(): never {
|
|
throw new RectificationEngineError("engine_invalid_v2_receipt", "engine decision receipt is missing or invalid");
|
|
}
|
|
|
|
function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandidate[]): ParsedReceipt {
|
|
const row = record(value);
|
|
if (!row) return invalidReceipt();
|
|
const representativeCandidateId = row.representative_candidate_id;
|
|
const representativeTime = row.representative_time;
|
|
const confidence = row.overall_confidence;
|
|
const marginPercent = row.margin_percent === null || row.margin_percent === undefined
|
|
? null
|
|
: engineNumber(row.margin_percent);
|
|
if (
|
|
row.receipt_version !== RECEIPT_VERSION
|
|
|| row.contract_version !== "v2"
|
|
|| row.event_contract_version !== EVENT_CONTRACT_VERSION
|
|
|| typeof row.policy_version !== "string" || !row.policy_version
|
|
|| typeof row.decision_policy_version !== "string" || row.decision_policy_version !== row.policy_version
|
|
|| typeof row.display_allowed !== "boolean"
|
|
|| typeof row.selection_allowed !== "boolean"
|
|
|| typeof row.acceptance_allowed !== "boolean"
|
|
|| typeof row.confirmation_allowed !== "boolean"
|
|
|| typeof row.accept_allowed !== "boolean"
|
|
|| typeof row.confirm_allowed !== "boolean"
|
|
|| row.selection_allowed !== row.acceptance_allowed
|
|
|| row.selection_allowed !== row.accept_allowed
|
|
|| row.confirmation_allowed !== row.confirm_allowed
|
|
|| (representativeCandidateId !== null && (typeof representativeCandidateId !== "string" || !uuidPattern.test(representativeCandidateId)))
|
|
|| (representativeTime !== null && (typeof representativeTime !== "string" || !timePattern.test(representativeTime)))
|
|
|| (confidence !== "low" && confidence !== "medium" && confidence !== "high")
|
|
|| (row.margin_percent !== null && row.margin_percent !== undefined && marginPercent === null)
|
|
) {
|
|
return invalidReceipt();
|
|
}
|
|
const representative = representativeCandidateId === null
|
|
? null
|
|
: candidates.find((candidate) => candidate.candidateId === representativeCandidateId) ?? null;
|
|
if (
|
|
(representativeCandidateId === null) !== (representativeTime === null)
|
|
|| (representativeCandidateId !== null && (!representative || representative.time !== representativeTime))
|
|
|| (row.confirmation_allowed === true && row.selection_allowed !== true)
|
|
|| (row.selection_allowed === true && row.display_allowed !== true)
|
|
) {
|
|
return invalidReceipt();
|
|
}
|
|
return {
|
|
raw: row,
|
|
eventContractVersion: EVENT_CONTRACT_VERSION,
|
|
policyVersion: row.policy_version,
|
|
selectionAllowed: row.selection_allowed,
|
|
confirmationAllowed: row.confirmation_allowed,
|
|
representativeCandidateId,
|
|
representativeTime,
|
|
overallConfidence: confidence,
|
|
marginPercent,
|
|
};
|
|
}
|
|
|
|
function readExecutionLedger(version: unknown, value: unknown): V9ExecutionLedger {
|
|
if (version !== EXECUTION_LEDGER_VERSION || !Array.isArray(value) || value.length === 0) {
|
|
throw new RectificationEngineError("engine_invalid_execution_ledger", "engine execution ledger is missing or invalid");
|
|
}
|
|
return value.map((item) => {
|
|
const row = record(item);
|
|
if (
|
|
!row
|
|
|| row.ledger_version !== EXECUTION_LEDGER_VERSION
|
|
|| typeof row.stage !== "string" || !row.stage
|
|
|| (row.status !== "executed" && row.status !== "not_executed" && row.status !== "retained_not_scored")
|
|
|| typeof row.source !== "string" || !row.source
|
|
) {
|
|
throw new RectificationEngineError("engine_invalid_execution_ledger", "engine execution ledger is missing or invalid");
|
|
}
|
|
return row;
|
|
});
|
|
}
|
|
|
|
function publicMethodFromLedger(value: unknown): PublicRectificationMethod | null {
|
|
if (isPublicRectificationMethod(value)) return value;
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.toLowerCase();
|
|
if (normalized.includes("controlled_transit") || normalized.includes("gochara")) return "gochara";
|
|
if (normalized.includes("ashtakavarga")) return "ashtakavarga";
|
|
if (normalized.includes("shadbala")) return "shadbala";
|
|
if (normalized.includes("arudha")) return "arudha-pada";
|
|
if (normalized.includes("functional_benefic") || normalized.includes("functional_malefic")) return "functional-benefic-malefic";
|
|
if (normalized.includes("vimshottari") || normalized.includes("vimsottari")) return "vimshottari-dasha";
|
|
if (normalized.includes("narayana")) return "narayana-dasha";
|
|
return null;
|
|
}
|
|
|
|
function executedMethods(ledger: V9ExecutionLedger): PublicRectificationMethod[] {
|
|
const methods = new Set<PublicRectificationMethod>();
|
|
for (const entry of ledger) {
|
|
if (entry.status !== "executed") continue;
|
|
const direct = publicMethodFromLedger(entry.method);
|
|
if (direct) methods.add(direct);
|
|
if (Array.isArray(entry.technique_layers)) {
|
|
for (const layer of entry.technique_layers) {
|
|
const method = publicMethodFromLedger(layer);
|
|
if (method) methods.add(method);
|
|
}
|
|
}
|
|
}
|
|
return [...methods];
|
|
}
|
|
|
|
function engineDiagnostics(data: Record<string, unknown>): Readonly<Record<string, unknown>> {
|
|
return record(data.diagnostics) ?? {};
|
|
}
|
|
|
|
function engineRequestBody(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
events: readonly V9EngineEvent[];
|
|
}): Record<string, unknown> {
|
|
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");
|
|
}
|
|
return {
|
|
birth_date: birthDate,
|
|
start_time: input.candidateRange.start_time,
|
|
end_time: input.candidateRange.end_time,
|
|
lat,
|
|
lon,
|
|
tz,
|
|
events: input.events,
|
|
birth_time_source: snapshot.birth_time_source,
|
|
timezone_id: snapshot.timezone_id,
|
|
timezone_source: snapshot.timezone_source,
|
|
local_time_status: snapshot.local_time_status,
|
|
};
|
|
}
|
|
|
|
export async function runV9CandidateScore(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
events: readonly V9EngineEvent[];
|
|
}): Promise<V9EngineScoreResult> {
|
|
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
|
|
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
|
|
const receipt = readDecisionReceipt(data.decision_receipt, candidates);
|
|
const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger);
|
|
if (
|
|
data.event_contract_version !== receipt.eventContractVersion
|
|
|| data.decision_policy_version !== receipt.policyVersion
|
|
) {
|
|
return invalidReceipt();
|
|
}
|
|
return {
|
|
engineResultId: String(data.result_id ?? ""),
|
|
algorithmVersion: String(data.algorithm_version ?? ""),
|
|
eventContractVersion: receipt.eventContractVersion,
|
|
policyVersion: receipt.policyVersion,
|
|
candidateRange: input.candidateRange,
|
|
candidates,
|
|
overallConfidence: receipt.overallConfidence,
|
|
marginPercent: receipt.marginPercent,
|
|
selectionAllowed: receipt.selectionAllowed,
|
|
confirmationAllowed: receipt.confirmationAllowed,
|
|
representativeCandidateId: receipt.representativeCandidateId,
|
|
representativeTime: receipt.representativeTime,
|
|
decisionReceipt: receipt.raw,
|
|
executionLedger: ledger,
|
|
executedMethods: executedMethods(ledger),
|
|
};
|
|
}
|
|
|
|
export async function runV9Diagnostics(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
events: readonly V9EngineEvent[];
|
|
}): Promise<V9EngineDiagnostics> {
|
|
const data = await postEngine("/api/rectification/v5/diagnostics", engineRequestBody(input));
|
|
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
|
|
const receipt = readDecisionReceipt(data.decision_receipt, candidates);
|
|
const ledger = readExecutionLedger(data.execution_ledger_version, data.execution_ledger);
|
|
if (
|
|
data.event_contract_version !== receipt.eventContractVersion
|
|
|| data.decision_policy_version !== receipt.policyVersion
|
|
) {
|
|
return invalidReceipt();
|
|
}
|
|
const diagnostics = engineDiagnostics(data);
|
|
const missingLayers = Array.isArray(data.missing_layers)
|
|
? data.missing_layers.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
return {
|
|
algorithmVersion: String(data.algorithm_version ?? ""),
|
|
engineResultId: String(data.result_id ?? ""),
|
|
eventContractVersion: receipt.eventContractVersion,
|
|
policyVersion: receipt.policyVersion,
|
|
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: receipt.confirmationAllowed,
|
|
decisionReceipt: receipt.raw,
|
|
executionLedger: ledger,
|
|
executedMethods: executedMethods(ledger),
|
|
};
|
|
}
|
|
|
|
export const v9EngineVersion = (): string =>
|
|
process.env.RECTIFICATION_ENGINE_VERSION?.trim() || "rectification-v5";
|