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/test/builders-validate.test.ts6 * Purpose: Builders + guardrails — annual totals, monotonicity, rate bounds, anti-teleportation diff gate7 */89import { describe, expect, it } from "vitest";10import { counterValue, rateAt } from "@earth-now/counter";11import {12 assertDeployable,13 buildKeelingModel,14 buildLinearYtdModel,15 buildSeasonalYtdModel,16 buildStaticRtModel,17 buildStockSplineModel,18 diffModels,19 secondsInUtcYear,20 validateModel,21} from "../src/index";2223const meta = {24 metricId: "test_metric",25 sourceId: "test_source",26 observedAt: "2026-01-01T00:00:00.000Z",27 displayHints: { decimals: 0, unit: "units" },28};2930describe("buildLinearYtdModel (L0)", () => {31 const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1_000_000 });3233 it("starts at 0 on Jan 1 UTC and ends the year at the annual total", () => {34 expect(counterValue(m, Date.parse("2026-01-01T00:00:00Z"))).toBe(0);35 expect(counterValue(m, Date.parse("2027-01-01T00:00:00Z"))).toBeCloseTo(1_000_000, 3);36 });3738 it("is leap-aware", () => {39 expect(secondsInUtcYear(2024)).toBe(366 * 86_400);40 expect(secondsInUtcYear(2026)).toBe(365 * 86_400);41 });42});4344describe("buildSeasonalYtdModel (L1)", () => {45 const m = buildSeasonalYtdModel(meta, {46 year: 2026,47 annualTotal: 1_000_000,48 shape: [{ period: "year", order: 1, relativeAmplitude: 0.3, phase: 0.5 }],49 });5051 it("keeps the annual total within 0.5 % despite seasonality", () => {52 const end = counterValue(m, Date.parse("2027-01-01T00:00:00Z"));53 expect(Math.abs(end - 1_000_000) / 1_000_000).toBeLessThan(0.005);54 });5556 it("rate stays strictly positive (cumulative honesty)", () => {57 for (let d = 0; d < 365; d += 7) {58 const t = Date.parse("2026-01-01T00:00:00Z") + d * 86_400_000;59 expect(rateAt(m, t)).toBeGreaterThan(0);60 }61 });6263 it("rejects shapes that would allow a negative rate", () => {64 expect(() =>65 buildSeasonalYtdModel(meta, {66 year: 2026,67 annualTotal: 1000,68 shape: [{ period: "year", order: 1, relativeAmplitude: 1.2, phase: 0 }],69 }),70 ).toThrow(/negative rate/);71 });72});7374describe("buildStockSplineModel (L2)", () => {75 const m = buildStockSplineModel(meta, {76 observations: [77 { time: "2023-07-01T00:00:00.000Z", value: 8_045_000_000 },78 { time: "2024-07-01T00:00:00.000Z", value: 8_119_000_000 },79 { time: "2025-07-01T00:00:00.000Z", value: 8_192_000_000 },80 ],81 forecasts: [82 { time: "2026-07-01T00:00:00.000Z", value: 8_262_000_000 },83 { time: "2027-07-01T00:00:00.000Z", value: 8_330_000_000 },84 ],85 });8687 it("anchors on the last real observation", () => {88 expect(m.anchorTime).toBe("2025-07-01T00:00:00.000Z");89 expect(counterValue(m, Date.parse(m.anchorTime))).toBeCloseTo(8_192_000_000, 3);90 });9192 it("interpolates monotonically between obs and forecast", () => {93 const issues = validateModel(94 m,95 { kind: "stock", maxAbsRatePerSec: 10 },96 { fromMs: Date.parse("2023-07-01T00:00:00Z"), toMs: Date.parse("2027-07-01T00:00:00Z") },97 );98 expect(issues).toEqual([]);99 const mid = counterValue(m, Date.parse("2026-01-01T00:00:00Z"));100 expect(mid).toBeGreaterThan(8_192_000_000);101 expect(mid).toBeLessThan(8_262_000_000);102 });103});104105describe("buildKeelingModel (L2 vedette)", () => {106 // Synthetic monthly CO₂ with known trend and cycle.107 const obs: Array<{ time: string; value: number }> = [];108 for (let y = 2021; y <= 2025; y++) {109 for (let mth = 0; mth < 12; mth++) {110 const t = Date.UTC(y, mth, 15);111 const tau = (t - Date.UTC(2000, 0, 1)) / 1000;112 const yearS = 365.2425 * 86_400;113 obs.push({114 time: new Date(t).toISOString(),115 value: 400 + (2.5 / yearS) * tau + 3 * Math.cos(((2 * Math.PI) / yearS) * tau - 0.7),116 });117 }118 }119 const m = buildKeelingModel(meta, { observations: obs });120121 it("anchor equals the fitted value at the last observation", () => {122 const anchorMs = Date.parse(m.anchorTime);123 expect(counterValue(m, anchorMs)).toBeCloseTo(m.anchorValue, 6);124 });125126 it("projects the seasonal cycle forward (≈ ±3 around the trend)", () => {127 // The synthetic cycle 3·cos(ωτ − 0.7) peaks at year-fraction 0.111 (≈ Feb 10)128 // and bottoms at 0.611 (≈ Aug 11): peak-to-trough ≈ 6 minus half a year of trend.129 const peak = counterValue(m, Date.parse("2026-02-10T00:00:00Z"));130 const trough = counterValue(m, Date.parse("2026-08-11T00:00:00Z"));131 expect(peak - trough).toBeGreaterThan(3);132 });133});134135describe("guardrails", () => {136 it("validateModel flags a decreasing cumulative", () => {137 const bad = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" });138 const withNegativeRate = {139 ...bad,140 rateFn: { kind: "linear" as const, perSecond: -1 },141 };142 const issues = validateModel(143 withNegativeRate,144 { kind: "cumulative" },145 { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-01-02T00:00:00Z") },146 );147 expect(issues.some((i) => i.code === "negative-rate")).toBe(true);148 });149150 it("validateModel flags a rate-bound violation", () => {151 const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1e12 });152 const issues = validateModel(153 m,154 { kind: "cumulative", maxAbsRatePerSec: 10 },155 { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-02-01T00:00:00Z") },156 );157 expect(issues.some((i) => i.code === "rate-bound-exceeded")).toBe(true);158 });159160 it("assertDeployable blocks teleportation between refits", () => {161 const prev = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" });162 const next = buildStaticRtModel(meta, { value: 250, at: "2026-01-02T00:00:00.000Z" });163 const swapAt = Date.parse("2026-01-02T00:00:00Z");164 expect(diffModels(prev, next, swapAt)).toBe(150);165 const issues = assertDeployable(166 prev,167 next,168 { kind: "stock", maxJumpOnRefit: 100 },169 swapAt,170 { fromMs: swapAt, toMs: swapAt + 86_400_000 },171 );172 expect(issues.some((i) => i.code === "jump-exceeded")).toBe(true);173 // Within threshold: deployable.174 const ok = assertDeployable(175 prev,176 next,177 { kind: "stock", maxJumpOnRefit: 200 },178 swapAt,179 { fromMs: swapAt, toMs: swapAt + 86_400_000 },180 );181 expect(ok).toEqual([]);182 });183});184