Cache work that depends only on the candidate minute, drop unread response fields, and reject windows above 241 candidates before any chart is built. Eleven canonical scenario hashes match 77057a819. Sealed and reported-offset replays match the 2026-10-08 records (changed_trial_count 8 and 581) and are stored as scoring_perf_2026_10_13.
Co-Authored-By: Grok (agent loop)
1180 lines
45 KiB
TypeScript
1180 lines
45 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 {
|
|
evidenceSubjectForDomain,
|
|
isBackgroundEvidenceKind,
|
|
isEvidenceDomain,
|
|
isEvidenceKind,
|
|
type EvidenceKind,
|
|
} from "./evidence-model";
|
|
import {
|
|
isPublicRectificationMethod,
|
|
type PublicRectificationMethod,
|
|
} from "./public-receipt";
|
|
import {
|
|
parseWindowScan,
|
|
type WindowScan,
|
|
} from "./varga-observations";
|
|
import { questionContractVersionIsCompatible } from "./probe-question-contract";
|
|
import { resolveAyanamsa } from "../../ayanamsa.ts";
|
|
import { candidateSegmentMetadata } from "./segment-contract.ts";
|
|
import type { AscendantSignature } from "../core/segment-summary.ts";
|
|
import { isDatedScoringAlgorithmVersion, requestCandidateIntervals, readCandidatePosition, type CandidatePosition, type DatedCandidateRange } from "../core/candidate-window.ts";
|
|
import { parseBlockScanPayload, type BlockScanBlock } from "./block-scan.ts";
|
|
import { recordEngineCallTiming, reportTurnProgress } from "./turn-instrumentation.ts";
|
|
|
|
export type EngineCallFailureKind = "busy" | "http_error" | "timeout" | "bad_payload";
|
|
|
|
export const RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT = 2;
|
|
export const RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS = 6_000;
|
|
|
|
export class RectificationEngineError extends Error {
|
|
readonly code: string;
|
|
readonly kind?: EngineCallFailureKind;
|
|
readonly httpStatus?: number;
|
|
readonly retryAfterSeconds?: number;
|
|
readonly elapsedMs?: number;
|
|
readonly path?: string;
|
|
readonly errorName?: string;
|
|
|
|
constructor(
|
|
code: string,
|
|
message: string,
|
|
extras?: Readonly<{
|
|
kind?: EngineCallFailureKind;
|
|
httpStatus?: number;
|
|
retryAfterSeconds?: number;
|
|
elapsedMs?: number;
|
|
path?: string;
|
|
errorName?: string;
|
|
}>,
|
|
) {
|
|
super(message);
|
|
this.name = "RectificationEngineError";
|
|
this.code = code;
|
|
this.kind = extras?.kind;
|
|
this.httpStatus = extras?.httpStatus;
|
|
this.retryAfterSeconds = extras?.retryAfterSeconds;
|
|
this.elapsedMs = extras?.elapsedMs;
|
|
this.path = extras?.path;
|
|
this.errorName = extras?.errorName;
|
|
}
|
|
}
|
|
|
|
export function engineFailureKind(error: unknown): EngineCallFailureKind | null {
|
|
if (error instanceof RectificationEngineError && error.kind) return error.kind;
|
|
if (error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
return "timeout";
|
|
}
|
|
if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
|
|
return "timeout";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function parseEngineRetryAfterSeconds(value: string | null | undefined): number | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
const seconds = Number(trimmed);
|
|
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
|
}
|
|
const at = Date.parse(trimmed);
|
|
if (!Number.isFinite(at)) return null;
|
|
const seconds = (at - Date.now()) / 1000;
|
|
return seconds > 0 ? seconds : 0;
|
|
}
|
|
|
|
export function nextEngineBusyBackoffMs(input: {
|
|
retryAfterSeconds?: number | null;
|
|
retriesUsed: number;
|
|
backoffUsedMs: number;
|
|
}): number | null {
|
|
if (input.retriesUsed >= RECTIFICATION_ENGINE_BUSY_RETRY_LIMIT) return null;
|
|
const remaining = RECTIFICATION_ENGINE_BUSY_BACKOFF_BUDGET_MS - input.backoffUsedMs;
|
|
if (remaining < 0) return null;
|
|
const requested = Math.max(0, Math.round((input.retryAfterSeconds ?? 1) * 1000));
|
|
if (requested === 0) return 0;
|
|
if (remaining <= 0) return null;
|
|
return Math.min(requested, remaining);
|
|
}
|
|
|
|
const defaultEngineBusyWait = (ms: number) => new Promise<void>((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
|
|
let engineBusyWait = defaultEngineBusyWait;
|
|
|
|
export function setEngineBusyWaitForTests(wait: ((ms: number) => Promise<void>) | null): void {
|
|
engineBusyWait = wait ?? defaultEngineBusyWait;
|
|
}
|
|
|
|
function logEngineCallFailure(input: {
|
|
path: string;
|
|
kind: EngineCallFailureKind;
|
|
elapsedMs: number;
|
|
httpStatus?: number;
|
|
errorName?: string;
|
|
}): void {
|
|
console.warn("rectification_engine_failed", {
|
|
path: input.path,
|
|
status: input.kind,
|
|
httpStatus: input.httpStatus ?? null,
|
|
errorName: input.errorName ?? null,
|
|
elapsedMs: input.elapsedMs,
|
|
});
|
|
}
|
|
|
|
function persistenceCodeForKind(kind: EngineCallFailureKind): string {
|
|
if (kind === "timeout") return "timeout";
|
|
if (kind === "bad_payload") return "engine_invalid_response";
|
|
return "engine_request_failed";
|
|
}
|
|
|
|
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 = CandidatePosition & Readonly<{
|
|
candidateId: string;
|
|
time: string;
|
|
rank: number;
|
|
relativeSupport: number;
|
|
tiedMinuteCount: number;
|
|
signature?: AscendantSignature;
|
|
rawScore?: number;
|
|
clusterTimes?: readonly string[];
|
|
clusterStart?: string;
|
|
clusterEnd?: string;
|
|
}>;
|
|
|
|
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: DatedCandidateRange;
|
|
candidates: readonly V9EngineCandidate[];
|
|
overallConfidence: "low" | "medium" | "high";
|
|
marginPercent: number | null;
|
|
acceptanceAllowed: boolean;
|
|
selectionAllowed: boolean;
|
|
proposeAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
representativeCandidateId: string | null;
|
|
representativeTime: string | null;
|
|
decisionReceipt: V9DecisionReceipt;
|
|
executionLedger: V9ExecutionLedger;
|
|
executedMethods: readonly PublicRectificationMethod[];
|
|
windowScan: WindowScan | null;
|
|
}>;
|
|
|
|
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) && item.eventKind !== "horary_query") 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: evidenceSubjectForDomain(item.domain, item.subject),
|
|
}];
|
|
});
|
|
}
|
|
|
|
function engineBase(): string {
|
|
return process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200";
|
|
}
|
|
|
|
function retryAfterFromHeaders(headers: { get?(name: string): string | null } | undefined): number | null {
|
|
if (!headers || typeof headers.get !== "function") return null;
|
|
return parseEngineRetryAfterSeconds(headers.get("retry-after") ?? headers.get("Retry-After"));
|
|
}
|
|
|
|
/** Engine paths that recompare candidates against the chart (BUG-1047 stage). */
|
|
const RESCORING_ENGINE_PATHS = new Set([
|
|
"/api/rectification/v5/score",
|
|
"/api/rectification/v5/block_scan",
|
|
]);
|
|
|
|
async function readEngineJson(path: string, init: RequestInit, timeoutMs: number): Promise<Record<string, unknown>> {
|
|
if (RESCORING_ENGINE_PATHS.has(path)) reportTurnProgress("rescoring");
|
|
const started = Date.now();
|
|
try {
|
|
return await readEngineJsonTimed(path, init, timeoutMs, started);
|
|
} catch (error) {
|
|
recordEngineCallTiming({
|
|
path,
|
|
ms: Date.now() - started,
|
|
outcome: error instanceof RectificationEngineError && error.kind ? error.kind : "http_error",
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readEngineJsonTimed(
|
|
path: string,
|
|
init: RequestInit,
|
|
timeoutMs: number,
|
|
started: number,
|
|
): Promise<Record<string, unknown>> {
|
|
try {
|
|
const response = await fetch(`${engineBase()}${path}`, {
|
|
...init,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const elapsedMs = Date.now() - started;
|
|
const data = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
const errorText = data && typeof data === "object"
|
|
? String((data as Record<string, unknown>).error ?? (data as Record<string, unknown>).message ?? "")
|
|
: "";
|
|
const errorCode = data && typeof data === "object"
|
|
? String((data as Record<string, unknown>).error_code ?? "")
|
|
: "";
|
|
const tooManyCandidates = response.status === 400
|
|
&& (errorCode === "too_many_candidates" || errorText.includes("too_many_candidates"));
|
|
const kind: EngineCallFailureKind = response.status === 429
|
|
? "busy"
|
|
: tooManyCandidates
|
|
? "bad_payload"
|
|
: "http_error";
|
|
logEngineCallFailure({
|
|
path,
|
|
kind,
|
|
elapsedMs,
|
|
httpStatus: response.status,
|
|
});
|
|
const message = errorText || `Jyotish API ${response.status}`;
|
|
throw new RectificationEngineError(tooManyCandidates ? "too_many_candidates" : persistenceCodeForKind(kind), message, {
|
|
kind,
|
|
httpStatus: response.status,
|
|
retryAfterSeconds: retryAfterFromHeaders(response.headers) ?? undefined,
|
|
elapsedMs,
|
|
path,
|
|
});
|
|
}
|
|
if (!data || typeof data !== "object") {
|
|
logEngineCallFailure({
|
|
path,
|
|
kind: "bad_payload",
|
|
elapsedMs,
|
|
httpStatus: response.status,
|
|
});
|
|
throw new RectificationEngineError("engine_invalid_response", "Jyotish API returned an invalid response", {
|
|
kind: "bad_payload",
|
|
httpStatus: response.status,
|
|
elapsedMs,
|
|
path,
|
|
});
|
|
}
|
|
recordEngineCallTiming({ path, ms: Date.now() - started, outcome: "ok" });
|
|
return data as Record<string, unknown>;
|
|
} catch (error) {
|
|
if (error instanceof RectificationEngineError) throw error;
|
|
const elapsedMs = Date.now() - started;
|
|
const errorName = error instanceof Error ? error.name : "Error";
|
|
const kind: EngineCallFailureKind = errorName === "TimeoutError" || errorName === "AbortError"
|
|
? "timeout"
|
|
: "http_error";
|
|
logEngineCallFailure({ path, kind, elapsedMs, errorName });
|
|
throw new RectificationEngineError(persistenceCodeForKind(kind), "Jyotish API request failed", {
|
|
kind,
|
|
elapsedMs,
|
|
path,
|
|
errorName,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function postEngine(
|
|
path: string,
|
|
body: unknown,
|
|
timeoutMs = 60_000,
|
|
options?: Readonly<{ busyRetry?: boolean }>,
|
|
): Promise<Record<string, unknown>> {
|
|
const init: RequestInit = {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
};
|
|
if (!options?.busyRetry) return readEngineJson(path, init, timeoutMs);
|
|
let retriesUsed = 0;
|
|
let backoffUsedMs = 0;
|
|
while (true) {
|
|
try {
|
|
return await readEngineJson(path, init, timeoutMs);
|
|
} catch (error) {
|
|
if (!(error instanceof RectificationEngineError) || error.kind !== "busy") throw error;
|
|
const waitMs = nextEngineBusyBackoffMs({
|
|
retryAfterSeconds: error.retryAfterSeconds,
|
|
retriesUsed,
|
|
backoffUsedMs,
|
|
});
|
|
if (waitMs == null) throw error;
|
|
if (waitMs > 0) await engineBusyWait(waitMs);
|
|
retriesUsed += 1;
|
|
backoffUsedMs += waitMs;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getEngine(path: string, timeoutMs = 5_000): Promise<Record<string, unknown>> {
|
|
return readEngineJson(path, {
|
|
method: "GET",
|
|
headers: { accept: "application/json" },
|
|
}, timeoutMs);
|
|
}
|
|
|
|
/** Coarse product id is not a scoring identity; never treat it as live algorithm. */
|
|
const COARSE_ENGINE_PRODUCT_ID = "rectification-v5";
|
|
|
|
export type LiveEngineScoringIdentity = Readonly<{
|
|
algorithmVersion: string | null;
|
|
policyVersion: string | null;
|
|
}>;
|
|
|
|
export function liveEngineScoringIdentityFromEnv(
|
|
env: NodeJS.ProcessEnv | Record<string, string | undefined> = process.env,
|
|
): LiveEngineScoringIdentity {
|
|
const engine = env.RECTIFICATION_ENGINE_VERSION?.trim() || "";
|
|
const algorithm = env.RECTIFICATION_ALGORITHM_VERSION?.trim()
|
|
|| (engine && engine !== COARSE_ENGINE_PRODUCT_ID ? engine : "");
|
|
const policy = env.RECTIFICATION_DECISION_POLICY_VERSION?.trim() || "";
|
|
return {
|
|
algorithmVersion: algorithm || null,
|
|
policyVersion: policy || null,
|
|
};
|
|
}
|
|
|
|
export function scoringIdentityFromEnginePayload(
|
|
payload: Readonly<Record<string, unknown>> | null | undefined,
|
|
): LiveEngineScoringIdentity {
|
|
const algorithm = typeof payload?.algorithm_version === "string" ? payload.algorithm_version.trim() : "";
|
|
const policy = typeof payload?.decision_policy_version === "string"
|
|
? payload.decision_policy_version.trim()
|
|
: "";
|
|
return {
|
|
algorithmVersion: algorithm || null,
|
|
policyVersion: policy || null,
|
|
};
|
|
}
|
|
|
|
export function scoringIdentityIsTrusted(identity: LiveEngineScoringIdentity): boolean {
|
|
return Boolean(identity.algorithmVersion && identity.policyVersion);
|
|
}
|
|
|
|
export function scoringIdentityMatches(
|
|
stored: { algorithmVersion?: string | null; policyVersion?: string | null } | null | undefined,
|
|
live: LiveEngineScoringIdentity,
|
|
): boolean {
|
|
return Boolean(stored && scoringIdentityIsTrusted(live)
|
|
&& stored.algorithmVersion === live.algorithmVersion
|
|
&& stored.policyVersion === live.policyVersion);
|
|
}
|
|
|
|
export function cachedEngineScoreIsReusable(
|
|
stored: {
|
|
evidenceLedgerFingerprint?: string | null;
|
|
candidateRangeFingerprint?: string | null;
|
|
algorithmVersion?: string | null;
|
|
policyVersion?: string | null;
|
|
} | null | undefined,
|
|
fingerprints: { evidenceLedgerFingerprint: string; candidateRangeFingerprint: string },
|
|
live: LiveEngineScoringIdentity,
|
|
): boolean {
|
|
if (!stored) return false;
|
|
if (stored.evidenceLedgerFingerprint !== fingerprints.evidenceLedgerFingerprint) return false;
|
|
if (stored.candidateRangeFingerprint !== fingerprints.candidateRangeFingerprint) return false;
|
|
return scoringIdentityMatches(stored, live);
|
|
}
|
|
|
|
/**
|
|
* In-process memo for `/v5/versions` when the deployment does not pin the
|
|
* scoring identity in env (BUG-1047 D5). One turn asked the engine for its
|
|
* versions several times. Only a complete identity pair is kept; failures and
|
|
* partial payloads are always re-read. The memo is scoped to the transport
|
|
* (`fetch` implementation) and engine base URL, never to a user or Case.
|
|
*/
|
|
export const RECTIFICATION_ENGINE_VERSIONS_CACHE_TTL_MS = 30_000;
|
|
|
|
type VersionsMemo = { identity: LiveEngineScoringIdentity; expiresAt: number };
|
|
const versionsMemo = new WeakMap<object, Map<string, VersionsMemo>>();
|
|
|
|
async function readNativeScoringIdentity(): Promise<LiveEngineScoringIdentity> {
|
|
const transport = globalThis.fetch as unknown as object;
|
|
const base = engineBase();
|
|
const now = Date.now();
|
|
const memo = versionsMemo.get(transport)?.get(base);
|
|
if (memo && memo.expiresAt > now) return { ...memo.identity };
|
|
const native = scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions"));
|
|
if (scoringIdentityIsTrusted(native)) {
|
|
let byBase = versionsMemo.get(transport);
|
|
if (!byBase) {
|
|
byBase = new Map();
|
|
versionsMemo.set(transport, byBase);
|
|
}
|
|
byBase.set(base, { identity: native, expiresAt: now + RECTIFICATION_ENGINE_VERSIONS_CACHE_TTL_MS });
|
|
}
|
|
return native;
|
|
}
|
|
|
|
export async function readV9EngineScoringIdentity(): Promise<LiveEngineScoringIdentity> {
|
|
const fromEnv = liveEngineScoringIdentityFromEnv();
|
|
if (scoringIdentityIsTrusted(fromEnv)) return fromEnv;
|
|
try {
|
|
const native = await readNativeScoringIdentity();
|
|
// Partial overrides cannot invent the missing half of a computation identity.
|
|
// A conflicting partial override is not a coherent identity pair.
|
|
if ((fromEnv.algorithmVersion && fromEnv.algorithmVersion !== native.algorithmVersion)
|
|
|| (fromEnv.policyVersion && fromEnv.policyVersion !== native.policyVersion)) {
|
|
return { algorithmVersion: null, policyVersion: null };
|
|
}
|
|
return native;
|
|
} catch {
|
|
return { algorithmVersion: null, policyVersion: null };
|
|
}
|
|
}
|
|
|
|
function readClusterCoverage(row: Record<string, unknown> | null): {
|
|
clusterTimes?: readonly string[];
|
|
clusterStart?: string;
|
|
clusterEnd?: string;
|
|
} {
|
|
if (!row) return {};
|
|
const times = Array.isArray(row.cluster_times)
|
|
? row.cluster_times.flatMap((item) => (
|
|
typeof item === "string" && timePattern.test(item) ? [item] : []
|
|
))
|
|
: [];
|
|
const start = typeof row.cluster_start === "string" && timePattern.test(row.cluster_start)
|
|
? row.cluster_start
|
|
: times[0];
|
|
const end = typeof row.cluster_end === "string" && timePattern.test(row.cluster_end)
|
|
? row.cluster_end
|
|
: times[times.length - 1];
|
|
if (!start || !end) return {};
|
|
return {
|
|
...(times.length > 0 ? { clusterTimes: times } : {}),
|
|
clusterStart: start,
|
|
clusterEnd: end,
|
|
};
|
|
}
|
|
|
|
function readCandidates(
|
|
value: unknown,
|
|
range: DatedCandidateRange,
|
|
dated = false,
|
|
): 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);
|
|
const cluster = readClusterCoverage(row);
|
|
const position = row ? readCandidatePosition(row) : null;
|
|
if (dated && (!position || !range.candidate_intervals?.length
|
|
|| position.cluster_intervals!.some((part) => {
|
|
const declared = range.candidate_intervals![part.segment_index];
|
|
return !declared || part.start_at < declared.start_at || part.end_at > declared.end_at
|
|
|| (Date.parse(`${part.start_at}:00Z`) - Date.parse(`${range.candidate_intervals![0]!.start_at}:00Z`)) / 60000 !== part.start_offset_minutes;
|
|
}))) {
|
|
throw new RectificationEngineError("engine_invalid_candidate_decisions", "dated candidate coverage is missing or invalid");
|
|
}
|
|
candidates.push({
|
|
...(position ?? {}),
|
|
candidateId,
|
|
time,
|
|
rank,
|
|
relativeSupport,
|
|
tiedMinuteCount,
|
|
...cluster,
|
|
});
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
type ParsedReceipt = Readonly<{
|
|
raw: V9DecisionReceipt;
|
|
eventContractVersion: string;
|
|
policyVersion: string;
|
|
acceptanceAllowed: boolean;
|
|
selectionAllowed: boolean;
|
|
proposeAllowed: 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.propose_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)
|
|
|| (row.propose_allowed === true && row.selection_allowed !== true)
|
|
|| !questionContractVersionIsCompatible(row.question_contract_version ?? row.question_contract)
|
|
) {
|
|
return invalidReceipt();
|
|
}
|
|
return {
|
|
raw: row,
|
|
eventContractVersion: EVENT_CONTRACT_VERSION,
|
|
policyVersion: row.policy_version,
|
|
acceptanceAllowed: row.acceptance_allowed,
|
|
selectionAllowed: row.selection_allowed,
|
|
proposeAllowed: row.propose_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];
|
|
}
|
|
|
|
export function executedMethodsFromLedger(
|
|
ledger: V9ExecutionLedger | null | undefined,
|
|
): PublicRectificationMethod[] {
|
|
if (!ledger) return [];
|
|
return executedMethods(ledger);
|
|
}
|
|
|
|
function engineDiagnostics(data: Record<string, unknown>): Readonly<Record<string, unknown>> {
|
|
return record(data.diagnostics) ?? {};
|
|
}
|
|
|
|
export const ENGINE_ASKED_PROBE_KEY_MAX_LENGTH = 200;
|
|
|
|
export function sanitizeAskedProbeKeysForEngine(
|
|
keys: readonly string[] | null | undefined,
|
|
): string[] {
|
|
const seen = new Set<string>();
|
|
const next: string[] = [];
|
|
for (const raw of keys ?? []) {
|
|
const key = raw.trim();
|
|
if (!key) continue;
|
|
if (key.length > ENGINE_ASKED_PROBE_KEY_MAX_LENGTH || key.includes(":varga.")) {
|
|
console.warn(
|
|
`[rectification-v9] dropping asked_probe_key length=${key.length} varga_hash=${key.includes(":varga.")}`,
|
|
);
|
|
continue;
|
|
}
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
next.push(key);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* BUG-1178: the engine rejects the whole request when any event lies outside
|
|
* [birth_date, today] (`events[i] dates must be between birth_date and today`). A typo'd future
|
|
* year, or any 「今年 / 这个月」 event whose period ends after today, then failed every rescore
|
|
* of the Case and the delivery card never came. Before sending: drop events that start after
|
|
* the cap or end before birth; clip the rest into the window. The cap is yesterday (UTC) so the
|
|
* engine's own clock can never be behind ours.
|
|
*/
|
|
export function boundEngineEventDates(
|
|
events: readonly V9EngineEvent[],
|
|
birthDate: string,
|
|
now: Date = new Date(),
|
|
): V9EngineEvent[] {
|
|
const cap = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
|
|
const floor = /^\d{4}-\d{2}-\d{2}$/.test(birthDate) ? birthDate : null;
|
|
return events.flatMap((event): V9EngineEvent[] => {
|
|
if (event.date_start > cap) return [];
|
|
if (floor && event.date_end < floor) return [];
|
|
const start = floor && event.date_start < floor ? floor : event.date_start;
|
|
const end = event.date_end > cap ? cap : event.date_end;
|
|
return start === event.date_start && end === event.date_end ? [event] : [{ ...event, date_start: start, date_end: end }];
|
|
});
|
|
}
|
|
|
|
export function engineRequestBody(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: DatedCandidateRange;
|
|
events: readonly V9EngineEvent[];
|
|
askedProbeKeys?: readonly string[];
|
|
declinedDomains?: readonly string[];
|
|
columnTimes?: readonly string[];
|
|
refreshProbes?: boolean;
|
|
}): 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");
|
|
}
|
|
const events = boundEngineEventDates(input.events, birthDate);
|
|
if (events.length === 0) {
|
|
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
|
|
}
|
|
const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys);
|
|
const columnTimes = [...new Set(
|
|
(input.columnTimes ?? [])
|
|
.map((value) => value.slice(0, 5))
|
|
.filter((value) => timePattern.test(value)),
|
|
)].slice(0, 64);
|
|
return {
|
|
birth_date: birthDate,
|
|
candidate_intervals: requestCandidateIntervals(snapshot, input.candidateRange),
|
|
start_time: input.candidateRange.start_time,
|
|
end_time: input.candidateRange.end_time,
|
|
lat,
|
|
lon,
|
|
tz,
|
|
ayanamsa: resolveAyanamsa(snapshot),
|
|
node_mode: "mean",
|
|
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,
|
|
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
|
|
...(sanitizeDeclinedDomainsForEngine(input.declinedDomains).length
|
|
? { declined_domains: sanitizeDeclinedDomainsForEngine(input.declinedDomains) }
|
|
: {}),
|
|
...(columnTimes.length ? { column_times: columnTimes } : {}),
|
|
...(input.refreshProbes === true ? { refresh_probes: true } : {}),
|
|
};
|
|
}
|
|
|
|
const ENGINE_COLLECT_DOMAINS = new Set([
|
|
"education",
|
|
"career",
|
|
"relocation",
|
|
"relationship",
|
|
"family",
|
|
"finance",
|
|
"health_pressure",
|
|
]);
|
|
|
|
export function sanitizeDeclinedDomainsForEngine(
|
|
domains: readonly string[] | null | undefined,
|
|
): string[] {
|
|
const seen = new Set<string>();
|
|
const next: string[] = [];
|
|
for (const raw of domains ?? []) {
|
|
const domain = raw.trim();
|
|
if (!ENGINE_COLLECT_DOMAINS.has(domain) || seen.has(domain)) continue;
|
|
seen.add(domain);
|
|
next.push(domain);
|
|
}
|
|
return next;
|
|
}
|
|
|
|
export type V9VedastroValidateResult = Readonly<{
|
|
status: "passed" | "failed" | "not_evaluated";
|
|
canConfirmExactMinute: boolean;
|
|
minuteSensitiveStatus: "passed" | "failed" | "not_evaluated";
|
|
searchEventsSupportsLocalWinner: boolean;
|
|
failure: Readonly<{
|
|
code: "timeout" | "engine_request_failed" | "engine_invalid_response" | "unknown";
|
|
}> | null;
|
|
raw: Readonly<Record<string, unknown>>;
|
|
}>;
|
|
|
|
function vedastroValidateStatus(value: unknown): "passed" | "failed" | "not_evaluated" {
|
|
if (value === "pass" || value === "passed") return "passed";
|
|
if (value === "fail" || value === "failed") return "failed";
|
|
return "not_evaluated";
|
|
}
|
|
|
|
export function mergeVedastroValidateIntoReceipt(
|
|
receipt: Readonly<Record<string, unknown>>,
|
|
validation: V9VedastroValidateResult,
|
|
): Record<string, unknown> {
|
|
const next = { ...receipt };
|
|
const gates = record(next.gates) ? { ...record(next.gates)! } : {};
|
|
const exact = record(gates.exact_confirmation) ? { ...record(gates.exact_confirmation)! } : {};
|
|
exact.vedastro_event_validation = {
|
|
status: validation.status,
|
|
search_events_primary_supports_local_winner: validation.searchEventsSupportsLocalWinner,
|
|
can_confirm_exact_minute: validation.canConfirmExactMinute,
|
|
failure: validation.failure,
|
|
};
|
|
const minuteSensitiveStatus = validation.status === "passed"
|
|
? validation.minuteSensitiveStatus
|
|
: validation.status === "failed"
|
|
? "failed"
|
|
: "not_evaluated";
|
|
if (
|
|
(minuteSensitiveStatus === "passed" || minuteSensitiveStatus === "failed")
|
|
&& (exact.external_validation_status === "not_evaluated" || exact.external_validation_status == null)
|
|
) {
|
|
exact.external_validation_status = minuteSensitiveStatus;
|
|
}
|
|
gates.exact_confirmation = exact;
|
|
next.gates = gates;
|
|
next.unique_minute_claim = false;
|
|
return next;
|
|
}
|
|
|
|
export async function runV9VedastroValidate(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: DatedCandidateRange;
|
|
events: readonly V9EngineEvent[];
|
|
candidateTimes: readonly [string, string];
|
|
}): Promise<V9VedastroValidateResult> {
|
|
const unevaluated = (): V9VedastroValidateResult => ({
|
|
status: "not_evaluated",
|
|
canConfirmExactMinute: false,
|
|
minuteSensitiveStatus: "not_evaluated",
|
|
searchEventsSupportsLocalWinner: false,
|
|
failure: null,
|
|
raw: { status: "not_evaluated", can_confirm_exact_minute: false },
|
|
});
|
|
if (input.candidateTimes[0] === input.candidateTimes[1]) return unevaluated();
|
|
try {
|
|
const data = await postEngine(
|
|
"/api/rectification/v5/vedastro-validate",
|
|
{ ...engineRequestBody(input), candidate_times: [...input.candidateTimes] },
|
|
12_000,
|
|
);
|
|
const eventValidation = record(data.event_validation);
|
|
const minuteValidation = record(data.minute_sensitive_validation);
|
|
const status = vedastroValidateStatus(data.status);
|
|
const minuteSensitiveStatus = status === "passed"
|
|
? vedastroValidateStatus(minuteValidation?.status)
|
|
: status === "failed"
|
|
? "failed"
|
|
: "not_evaluated";
|
|
return {
|
|
status,
|
|
canConfirmExactMinute: data.can_confirm_exact_minute === true
|
|
&& status === "passed"
|
|
&& minuteSensitiveStatus === "passed",
|
|
minuteSensitiveStatus,
|
|
searchEventsSupportsLocalWinner: eventValidation?.search_events_primary_supports_local_winner === true,
|
|
failure: null,
|
|
raw: data,
|
|
};
|
|
} catch (error) {
|
|
const kind = engineFailureKind(error);
|
|
const code = kind === "timeout" || (error instanceof RectificationEngineError && error.code === "timeout")
|
|
? "timeout"
|
|
: error instanceof RectificationEngineError && (error.kind === "bad_payload" || error.code === "engine_invalid_response")
|
|
? "engine_invalid_response"
|
|
: error instanceof RectificationEngineError
|
|
? "engine_request_failed"
|
|
: error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError")
|
|
? "timeout"
|
|
: "unknown";
|
|
return {
|
|
status: "failed",
|
|
canConfirmExactMinute: false,
|
|
minuteSensitiveStatus: "failed",
|
|
searchEventsSupportsLocalWinner: false,
|
|
failure: { code },
|
|
raw: { status: "failed", failure: { code }, can_confirm_exact_minute: false },
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function runV9CandidateScore(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: DatedCandidateRange;
|
|
events: readonly V9EngineEvent[];
|
|
askedProbeKeys?: readonly string[];
|
|
declinedDomains?: readonly string[];
|
|
columnTimes?: readonly string[];
|
|
refreshProbes?: boolean;
|
|
}): Promise<V9EngineScoreResult> {
|
|
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input), 60_000, { busyRetry: true });
|
|
const datedRange = { ...input.candidateRange, candidate_intervals: requestCandidateIntervals(input.baselineBirthSnapshot, input.candidateRange) };
|
|
const dated = isDatedScoringAlgorithmVersion(data.algorithm_version);
|
|
const metadata = candidateSegmentMetadata(data);
|
|
const candidates = readCandidates(data.candidate_decisions, datedRange, dated).map((candidate) => ({ ...candidate, ...metadata.byTime.get(candidate.time) }));
|
|
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,
|
|
acceptanceAllowed: receipt.acceptanceAllowed,
|
|
selectionAllowed: receipt.selectionAllowed,
|
|
proposeAllowed: receipt.proposeAllowed,
|
|
confirmationAllowed: receipt.confirmationAllowed,
|
|
representativeCandidateId: receipt.representativeCandidateId,
|
|
representativeTime: receipt.representativeTime,
|
|
decisionReceipt: dated ? { ...receipt.raw, candidate_window_contract: "dated-v1", declared_birth_date: String(input.baselineBirthSnapshot.birth_date), candidate_intervals: datedRange.candidate_intervals, candidate_timezone_offset: Number(input.baselineBirthSnapshot.timezone_offset), candidate_timezone_id: String(input.baselineBirthSnapshot.timezone_id ?? "") } : receipt.raw,
|
|
executionLedger: ledger,
|
|
executedMethods: executedMethods(ledger),
|
|
windowScan: parseWindowScan(engineDiagnostics(data).window_scan),
|
|
};
|
|
}
|
|
|
|
export async function runV9Diagnostics(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: DatedCandidateRange;
|
|
events: readonly V9EngineEvent[];
|
|
askedProbeKeys?: readonly string[];
|
|
}): Promise<V9EngineDiagnostics> {
|
|
const data = await postEngine("/api/rectification/v5/diagnostics", engineRequestBody(input));
|
|
const datedRange = { ...input.candidateRange, candidate_intervals: requestCandidateIntervals(input.baselineBirthSnapshot, input.candidateRange) };
|
|
const dated = isDatedScoringAlgorithmVersion(data.algorithm_version);
|
|
const metadata = candidateSegmentMetadata(data);
|
|
const candidates = readCandidates(data.candidate_decisions, datedRange, dated).map((candidate) => ({ ...candidate, ...metadata.byTime.get(candidate.time) }));
|
|
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,
|
|
window_scan: parseWindowScan(diagnostics.window_scan),
|
|
},
|
|
missingLayers,
|
|
canConfirmExactMinute: receipt.confirmationAllowed,
|
|
decisionReceipt: dated ? { ...receipt.raw, candidate_window_contract: "dated-v1", declared_birth_date: String(input.baselineBirthSnapshot.birth_date), candidate_intervals: datedRange.candidate_intervals, candidate_timezone_offset: Number(input.baselineBirthSnapshot.timezone_offset), candidate_timezone_id: String(input.baselineBirthSnapshot.timezone_id ?? "") } : receipt.raw,
|
|
executionLedger: ledger,
|
|
executedMethods: executedMethods(ledger),
|
|
};
|
|
}
|
|
|
|
// Keep the receipt default aligned with Python's ALGORITHM_VERSION; persisted
|
|
// receipts retain their original identity (this is not a Skill/open gate).
|
|
export const v9EngineVersion = (): string =>
|
|
process.env.RECTIFICATION_ENGINE_VERSION?.trim() || "rectification-v5-matrix-scoring-12";
|
|
|
|
export type V9RangeReading = Readonly<{
|
|
stableThemes: readonly string[];
|
|
sensitiveThemes: readonly string[];
|
|
claimBoundary: string | null;
|
|
themeSensitivity: Readonly<Record<string, unknown>>;
|
|
window: Readonly<Record<string, unknown>> | null;
|
|
}>;
|
|
|
|
export async function runV9RangeReading(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: { start_time: string; end_time: string; representative_time?: string };
|
|
representativeTime?: string | null;
|
|
birthTimeAccuracy?: "provisional" | "approximate";
|
|
}): Promise<V9RangeReading | null> {
|
|
try {
|
|
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) return null;
|
|
const representative = input.representativeTime
|
|
?? input.candidateRange.representative_time
|
|
?? input.candidateRange.start_time;
|
|
const data = await postEngine("/api/rectification/v5/range_reading", {
|
|
birth_date: birthDate,
|
|
lat,
|
|
lon,
|
|
tz,
|
|
ayanamsa: resolveAyanamsa(snapshot),
|
|
node_mode: "mean",
|
|
birth_time_accuracy: input.birthTimeAccuracy ?? "provisional",
|
|
representative_time: representative,
|
|
candidate_range: {
|
|
start_time: input.candidateRange.start_time,
|
|
end_time: input.candidateRange.end_time,
|
|
representative_time: representative,
|
|
},
|
|
}, 8_000);
|
|
const stable = Array.isArray(data.stable_themes)
|
|
? data.stable_themes.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
const sensitive = Array.isArray(data.sensitive_themes)
|
|
? data.sensitive_themes.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
return {
|
|
stableThemes: stable,
|
|
sensitiveThemes: sensitive,
|
|
claimBoundary: typeof data.claim_boundary === "string" ? data.claim_boundary : null,
|
|
themeSensitivity: record(data.theme_sensitivity) ?? {},
|
|
window: record(data.window),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type V9BlockScanResult = Readonly<{
|
|
resultId: string | null;
|
|
algorithmVersion: string;
|
|
policyVersion: string | null;
|
|
minuteStep: number;
|
|
blocks: readonly BlockScanBlock[];
|
|
}>;
|
|
|
|
export async function runV9BlockScan(input: {
|
|
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
|
|
candidateRange: DatedCandidateRange;
|
|
events: readonly V9EngineEvent[];
|
|
blocks?: readonly { period: string; start_time: string; end_time: string }[];
|
|
minuteStep?: number;
|
|
}): Promise<V9BlockScanResult> {
|
|
const data = await postEngine("/api/rectification/v5/block_scan", {
|
|
...engineRequestBody(input),
|
|
minute_step: input.minuteStep ?? 10,
|
|
...(input.blocks?.length ? { blocks: input.blocks } : {}),
|
|
}, 60_000, { busyRetry: true });
|
|
const parsed = parseBlockScanPayload({
|
|
blocks: data.blocks,
|
|
algorithm_version: data.algorithm_version,
|
|
minute_step: data.minute_step,
|
|
evidence_ledger_fingerprint: data.evidence_ledger_fingerprint,
|
|
});
|
|
if (!parsed) {
|
|
throw new RectificationEngineError("engine_invalid_response", "block_scan payload is invalid");
|
|
}
|
|
return {
|
|
resultId: typeof data.result_id === "string" ? data.result_id : null,
|
|
algorithmVersion: parsed.algorithmVersion ?? String(data.algorithm_version ?? ""),
|
|
policyVersion: scoringIdentityFromEnginePayload(record(data.decision_receipt)).policyVersion,
|
|
minuteStep: parsed.minuteStep ?? 10,
|
|
blocks: parsed.blocks,
|
|
};
|
|
}
|