spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: packages/models/src/validate.ts6 * Purpose: Validity guardrails — monotonicity of cumulatives, rate bounds, anchor identity, NaN checks, and jump diff between model versions7 */89import { type CounterModel, counterValue, parseIsoUtc, rateAt } from "@earth-now/counter";1011export interface MetricConstraints {12 /** "cumulative" counters must never go backwards; "stock" may move both ways. */13 kind: "stock" | "cumulative";14 /** Max |instantaneous rate| in base unit per second, from the registry. */15 maxAbsRatePerSec?: number;16 /**17 * Max allowed value jump (base unit) between the outgoing and incoming model at18 * swap time. Exceeding it BLOCKS deployment (no counter teleportation in prod).19 */20 maxJumpOnRefit?: number;21}2223export interface ValidationIssue {24 code:25 | "nan-value"26 | "negative-rate"27 | "rate-bound-exceeded"28 | "anchor-mismatch"29 | "jump-exceeded";30 message: string;31}3233export interface ValidationHorizon {34 fromMs: number;35 toMs: number;36 /** Sampling resolution (default 720 points across the horizon). */37 samples?: number;38}3940/**41 * Sample the model across the horizon and check every guardrail. Returns [] when42 * the model is deployable. Pure — the horizon is a parameter, never the clock.43 */44export function validateModel(45 model: CounterModel,46 constraints: MetricConstraints,47 horizon: ValidationHorizon,48): ValidationIssue[] {49 const issues: ValidationIssue[] = [];50 const { fromMs, toMs } = horizon;51 const samples = horizon.samples ?? 720;52 if (toMs <= fromMs) throw new Error("validateModel: empty horizon");5354 const anchorMs = parseIsoUtc(model.anchorTime);55 const atAnchor = counterValue(model, anchorMs);56 if (!Number.isFinite(atAnchor) || Math.abs(atAnchor - model.anchorValue) > tolerance(model.anchorValue)) {57 issues.push({58 code: "anchor-mismatch",59 message: `value(anchorTime)=${atAnchor} differs from anchorValue=${model.anchorValue}`,60 });61 }6263 let prev = counterValue(model, fromMs);64 for (let i = 0; i <= samples; i++) {65 const t = fromMs + ((toMs - fromMs) * i) / samples;66 const v = counterValue(model, t);67 const r = rateAt(model, t);6869 if (!Number.isFinite(v) || !Number.isFinite(r)) {70 issues.push({ code: "nan-value", message: `non-finite value/rate at ${new Date(t).toISOString()}` });71 break;72 }73 if (constraints.kind === "cumulative" && v < prev - tolerance(prev)) {74 issues.push({75 code: "negative-rate",76 message: `cumulative counter decreases near ${new Date(t).toISOString()} (${prev} → ${v})`,77 });78 break;79 }80 if (81 constraints.maxAbsRatePerSec !== undefined &&82 Math.abs(r) > constraints.maxAbsRatePerSec83 ) {84 issues.push({85 code: "rate-bound-exceeded",86 message: `|rate|=${Math.abs(r)} exceeds declared max ${constraints.maxAbsRatePerSec} at ${new Date(t).toISOString()}`,87 });88 break;89 }90 prev = v;91 }92 return issues;93}9495/** Absolute value jump between two models at the swap instant. */96export function diffModels(prev: CounterModel, next: CounterModel, atMs: number): number {97 return Math.abs(counterValue(next, atMs) - counterValue(prev, atMs));98}99100/**101 * Full deployability gate: guardrail validation of the new model over the horizon102 * plus the anti-teleportation diff against the outgoing model at swap time.103 */104export function assertDeployable(105 previous: CounterModel | null,106 next: CounterModel,107 constraints: MetricConstraints,108 swapAtMs: number,109 horizon: ValidationHorizon,110): ValidationIssue[] {111 const issues = validateModel(next, constraints, horizon);112 if (previous && constraints.maxJumpOnRefit !== undefined) {113 const jump = diffModels(previous, next, swapAtMs);114 if (jump > constraints.maxJumpOnRefit) {115 issues.push({116 code: "jump-exceeded",117 message: `refit jump ${jump} exceeds declared max ${constraints.maxJumpOnRefit} — deployment blocked`,118 });119 }120 }121 return issues;122}123124/** Numeric tolerance scaled to magnitude (float error on huge counters). */125function tolerance(v: number): number {126 return Math.max(1e-6, Math.abs(v) * 1e-9);127}128