/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/models/test/builders-validate.test.ts * Purpose: Builders + guardrails — annual totals, monotonicity, rate bounds, anti-teleportation diff gate */ import { describe, expect, it } from "vitest"; import { counterValue, rateAt } from "@earth-now/counter"; import { assertDeployable, buildKeelingModel, buildLinearYtdModel, buildSeasonalYtdModel, buildStaticRtModel, buildStockSplineModel, diffModels, secondsInUtcYear, validateModel, } from "../src/index"; const meta = { metricId: "test_metric", sourceId: "test_source", observedAt: "2026-01-01T00:00:00.000Z", displayHints: { decimals: 0, unit: "units" }, }; describe("buildLinearYtdModel (L0)", () => { const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1_000_000 }); it("starts at 0 on Jan 1 UTC and ends the year at the annual total", () => { expect(counterValue(m, Date.parse("2026-01-01T00:00:00Z"))).toBe(0); expect(counterValue(m, Date.parse("2027-01-01T00:00:00Z"))).toBeCloseTo(1_000_000, 3); }); it("is leap-aware", () => { expect(secondsInUtcYear(2024)).toBe(366 * 86_400); expect(secondsInUtcYear(2026)).toBe(365 * 86_400); }); }); describe("buildSeasonalYtdModel (L1)", () => { const m = buildSeasonalYtdModel(meta, { year: 2026, annualTotal: 1_000_000, shape: [{ period: "year", order: 1, relativeAmplitude: 0.3, phase: 0.5 }], }); it("keeps the annual total within 0.5 % despite seasonality", () => { const end = counterValue(m, Date.parse("2027-01-01T00:00:00Z")); expect(Math.abs(end - 1_000_000) / 1_000_000).toBeLessThan(0.005); }); it("rate stays strictly positive (cumulative honesty)", () => { for (let d = 0; d < 365; d += 7) { const t = Date.parse("2026-01-01T00:00:00Z") + d * 86_400_000; expect(rateAt(m, t)).toBeGreaterThan(0); } }); it("rejects shapes that would allow a negative rate", () => { expect(() => buildSeasonalYtdModel(meta, { year: 2026, annualTotal: 1000, shape: [{ period: "year", order: 1, relativeAmplitude: 1.2, phase: 0 }], }), ).toThrow(/negative rate/); }); }); describe("buildStockSplineModel (L2)", () => { const m = buildStockSplineModel(meta, { observations: [ { time: "2023-07-01T00:00:00.000Z", value: 8_045_000_000 }, { time: "2024-07-01T00:00:00.000Z", value: 8_119_000_000 }, { time: "2025-07-01T00:00:00.000Z", value: 8_192_000_000 }, ], forecasts: [ { time: "2026-07-01T00:00:00.000Z", value: 8_262_000_000 }, { time: "2027-07-01T00:00:00.000Z", value: 8_330_000_000 }, ], }); it("anchors on the last real observation", () => { expect(m.anchorTime).toBe("2025-07-01T00:00:00.000Z"); expect(counterValue(m, Date.parse(m.anchorTime))).toBeCloseTo(8_192_000_000, 3); }); it("interpolates monotonically between obs and forecast", () => { const issues = validateModel( m, { kind: "stock", maxAbsRatePerSec: 10 }, { fromMs: Date.parse("2023-07-01T00:00:00Z"), toMs: Date.parse("2027-07-01T00:00:00Z") }, ); expect(issues).toEqual([]); const mid = counterValue(m, Date.parse("2026-01-01T00:00:00Z")); expect(mid).toBeGreaterThan(8_192_000_000); expect(mid).toBeLessThan(8_262_000_000); }); }); describe("buildKeelingModel (L2 vedette)", () => { // Synthetic monthly CO₂ with known trend and cycle. const obs: Array<{ time: string; value: number }> = []; for (let y = 2021; y <= 2025; y++) { for (let mth = 0; mth < 12; mth++) { const t = Date.UTC(y, mth, 15); const tau = (t - Date.UTC(2000, 0, 1)) / 1000; const yearS = 365.2425 * 86_400; obs.push({ time: new Date(t).toISOString(), value: 400 + (2.5 / yearS) * tau + 3 * Math.cos(((2 * Math.PI) / yearS) * tau - 0.7), }); } } const m = buildKeelingModel(meta, { observations: obs }); it("anchor equals the fitted value at the last observation", () => { const anchorMs = Date.parse(m.anchorTime); expect(counterValue(m, anchorMs)).toBeCloseTo(m.anchorValue, 6); }); it("projects the seasonal cycle forward (≈ ±3 around the trend)", () => { // The synthetic cycle 3·cos(ωτ − 0.7) peaks at year-fraction 0.111 (≈ Feb 10) // and bottoms at 0.611 (≈ Aug 11): peak-to-trough ≈ 6 minus half a year of trend. const peak = counterValue(m, Date.parse("2026-02-10T00:00:00Z")); const trough = counterValue(m, Date.parse("2026-08-11T00:00:00Z")); expect(peak - trough).toBeGreaterThan(3); }); }); describe("guardrails", () => { it("validateModel flags a decreasing cumulative", () => { const bad = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" }); const withNegativeRate = { ...bad, rateFn: { kind: "linear" as const, perSecond: -1 }, }; const issues = validateModel( withNegativeRate, { kind: "cumulative" }, { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-01-02T00:00:00Z") }, ); expect(issues.some((i) => i.code === "negative-rate")).toBe(true); }); it("validateModel flags a rate-bound violation", () => { const m = buildLinearYtdModel(meta, { year: 2026, annualTotal: 1e12 }); const issues = validateModel( m, { kind: "cumulative", maxAbsRatePerSec: 10 }, { fromMs: Date.parse("2026-01-01T00:00:00Z"), toMs: Date.parse("2026-02-01T00:00:00Z") }, ); expect(issues.some((i) => i.code === "rate-bound-exceeded")).toBe(true); }); it("assertDeployable blocks teleportation between refits", () => { const prev = buildStaticRtModel(meta, { value: 100, at: "2026-01-01T00:00:00.000Z" }); const next = buildStaticRtModel(meta, { value: 250, at: "2026-01-02T00:00:00.000Z" }); const swapAt = Date.parse("2026-01-02T00:00:00Z"); expect(diffModels(prev, next, swapAt)).toBe(150); const issues = assertDeployable( prev, next, { kind: "stock", maxJumpOnRefit: 100 }, swapAt, { fromMs: swapAt, toMs: swapAt + 86_400_000 }, ); expect(issues.some((i) => i.code === "jump-exceeded")).toBe(true); // Within threshold: deployable. const ok = assertDeployable( prev, next, { kind: "stock", maxJumpOnRefit: 200 }, swapAt, { fromMs: swapAt, toMs: swapAt + 86_400_000 }, ); expect(ok).toEqual([]); }); });