/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: packages/registry/src/fit.ts * Purpose: Fit a CounterModel for every registry metric from its fixture, run the guardrail validation, compose derived metrics */ import type { CounterModel, DisplayHints } from "@earth-now/counter"; import { type MetricConstraints, type ValidationIssue, buildKeelingModel, buildLinearStockModel, buildLinearYtdModel, buildSeasonalYtdModel, buildStaticRtModel, buildStockSplineModel, composeLinearCombination, validateModel, } from "@earth-now/models"; import type { MetricEntry, MetricFixture } from "./schema.js"; export interface FitOptions { /** Validation horizon (pure — the caller supplies "now"). */ validateFromMs: number; validateToMs: number; } export interface FitResult { models: Map; /** Metrics whose model FAILED validation — they must NOT be served (stale badge instead). */ blocked: Array<{ metricId: string; issues: ValidationIssue[] }>; } function displayHintsOf(entry: MetricEntry, locale: "fr" | "en" = "en"): DisplayHints { const hints: DisplayHints = { decimals: entry.display.decimals, unit: entry.display.unit[locale], }; if (entry.display.sigFigs !== undefined) hints.sigFigs = entry.display.sigFigs; if (entry.display.scale !== undefined) hints.scale = entry.display.scale; return hints; } function constraintsOf(entry: MetricEntry): MetricConstraints { const c: MetricConstraints = { kind: entry.kind === "cumulative" ? "cumulative" : "stock", }; if (entry.constraints.maxAbsRatePerSec !== undefined) c.maxAbsRatePerSec = entry.constraints.maxAbsRatePerSec; if (entry.constraints.maxJumpOnRefit !== undefined) c.maxJumpOnRefit = entry.constraints.maxJumpOnRefit; return c; } function fitOne(entry: MetricEntry, fixture: MetricFixture): CounterModel { const primarySource = entry.sources[0]; if (!primarySource) throw new Error(`Metric ${entry.id} has no source`); const meta = { metricId: entry.id, sourceId: primarySource.id, observedAt: fixture.observedAt, displayHints: displayHintsOf(entry), }; switch (fixture.family) { case "seasonal-spline-v2": { const model = buildStockSplineModel(meta, { observations: fixture.observations, forecasts: fixture.forecasts, }); return fixture.uncertainty ? { ...model, uncertainty: fixture.uncertainty } : model; } case "keeling-fusion-v1": return buildKeelingModel(meta, { observations: fixture.observations }); case "seasonal-ytd-v1": return buildSeasonalYtdModel(meta, { year: fixture.year, annualTotal: fixture.annualTotal, shape: fixture.shape, }); case "linear-ytd-v1": return buildLinearYtdModel(meta, { year: fixture.year, annualTotal: fixture.annualTotal }); case "linear-stock-v1": return buildLinearStockModel(meta, { at: fixture.at, value: fixture.value, perSecond: fixture.perSecond, }); case "static-rt-v1": return buildStaticRtModel(meta, { value: fixture.value, at: fixture.at }); } } /** * Fit models for every metric. Non-derived metrics need a fixture; derived * metrics are composed from their inputs (linear-combination server-side; the * window / rate-of / depletion-countdown ops reuse the input's model — clients * apply the transform with the SAME evaluated function). */ export function fitAllModels( metrics: MetricEntry[], fixtures: Map, opts: FitOptions, ): FitResult { const models = new Map(); const blocked: FitResult["blocked"] = []; const horizon = { fromMs: opts.validateFromMs, toMs: opts.validateToMs }; const validateAndStore = (entry: MetricEntry, model: CounterModel): void => { const issues = validateModel(model, constraintsOf(entry), horizon); if (issues.length > 0) blocked.push({ metricId: entry.id, issues }); else models.set(entry.id, model); }; for (const entry of metrics) { if (entry.derived) continue; const fixture = fixtures.get(entry.id); if (!fixture) { blocked.push({ metricId: entry.id, issues: [{ code: "nan-value", message: "no fixture / no ingested data for this metric" }], }); continue; } validateAndStore(entry, fitOne(entry, fixture)); } for (const entry of metrics) { if (!entry.derived) continue; const spec = entry.derived; const inputModels = spec.inputs.map((i) => models.get(i.id)); if (inputModels.some((m) => m === undefined)) { blocked.push({ metricId: entry.id, issues: [{ code: "nan-value", message: "a derived input model is missing or blocked" }], }); continue; } const primarySource = entry.sources[0]!; const meta = { metricId: entry.id, sourceId: primarySource.id, observedAt: inputModels[0]!.observedAt, displayHints: displayHintsOf(entry), }; if (spec.op === "linear-combination") { const composed = composeLinearCombination( meta, spec.inputs.map((i, idx) => ({ model: inputModels[idx]!, weight: i.weight })), spec.constant ?? 0, ); validateAndStore(entry, composed); } else { // window / rate-of / depletion-countdown: same function, re-labelled; // the client applies the transform via packages/counter helpers. const base = inputModels[0]!; validateAndStore(entry, { ...base, metricId: entry.id, displayHints: meta.displayHints }); } } return { models, blocked }; }