/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/models/src/validate.ts * Purpose: Validity guardrails — monotonicity of cumulatives, rate bounds, anchor identity, NaN checks, and jump diff between model versions */ import { type CounterModel, counterValue, parseIsoUtc, rateAt } from "@earth-now/counter"; export interface MetricConstraints { /** "cumulative" counters must never go backwards; "stock" may move both ways. */ kind: "stock" | "cumulative"; /** Max |instantaneous rate| in base unit per second, from the registry. */ maxAbsRatePerSec?: number; /** * Max allowed value jump (base unit) between the outgoing and incoming model at * swap time. Exceeding it BLOCKS deployment (no counter teleportation in prod). */ maxJumpOnRefit?: number; } export interface ValidationIssue { code: | "nan-value" | "negative-rate" | "rate-bound-exceeded" | "anchor-mismatch" | "jump-exceeded"; message: string; } export interface ValidationHorizon { fromMs: number; toMs: number; /** Sampling resolution (default 720 points across the horizon). */ samples?: number; } /** * Sample the model across the horizon and check every guardrail. Returns [] when * the model is deployable. Pure — the horizon is a parameter, never the clock. */ export function validateModel( model: CounterModel, constraints: MetricConstraints, horizon: ValidationHorizon, ): ValidationIssue[] { const issues: ValidationIssue[] = []; const { fromMs, toMs } = horizon; const samples = horizon.samples ?? 720; if (toMs <= fromMs) throw new Error("validateModel: empty horizon"); const anchorMs = parseIsoUtc(model.anchorTime); const atAnchor = counterValue(model, anchorMs); if (!Number.isFinite(atAnchor) || Math.abs(atAnchor - model.anchorValue) > tolerance(model.anchorValue)) { issues.push({ code: "anchor-mismatch", message: `value(anchorTime)=${atAnchor} differs from anchorValue=${model.anchorValue}`, }); } let prev = counterValue(model, fromMs); for (let i = 0; i <= samples; i++) { const t = fromMs + ((toMs - fromMs) * i) / samples; const v = counterValue(model, t); const r = rateAt(model, t); if (!Number.isFinite(v) || !Number.isFinite(r)) { issues.push({ code: "nan-value", message: `non-finite value/rate at ${new Date(t).toISOString()}` }); break; } if (constraints.kind === "cumulative" && v < prev - tolerance(prev)) { issues.push({ code: "negative-rate", message: `cumulative counter decreases near ${new Date(t).toISOString()} (${prev} → ${v})`, }); break; } if ( constraints.maxAbsRatePerSec !== undefined && Math.abs(r) > constraints.maxAbsRatePerSec ) { issues.push({ code: "rate-bound-exceeded", message: `|rate|=${Math.abs(r)} exceeds declared max ${constraints.maxAbsRatePerSec} at ${new Date(t).toISOString()}`, }); break; } prev = v; } return issues; } /** Absolute value jump between two models at the swap instant. */ export function diffModels(prev: CounterModel, next: CounterModel, atMs: number): number { return Math.abs(counterValue(next, atMs) - counterValue(prev, atMs)); } /** * Full deployability gate: guardrail validation of the new model over the horizon * plus the anti-teleportation diff against the outgoing model at swap time. */ export function assertDeployable( previous: CounterModel | null, next: CounterModel, constraints: MetricConstraints, swapAtMs: number, horizon: ValidationHorizon, ): ValidationIssue[] { const issues = validateModel(next, constraints, horizon); if (previous && constraints.maxJumpOnRefit !== undefined) { const jump = diffModels(previous, next, swapAtMs); if (jump > constraints.maxJumpOnRefit) { issues.push({ code: "jump-exceeded", message: `refit jump ${jump} exceeds declared max ${constraints.maxJumpOnRefit} — deployment blocked`, }); } } return issues; } /** Numeric tolerance scaled to magnitude (float error on huge counters). */ function tolerance(v: number): number { return Math.max(1e-6, Math.abs(v) * 1e-9); }