SPB Git

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%
4.2 KB · 128 lines typescript
Raw Blame History
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