feat(resilience): dimension freshness propagation (T1.5 propagation pass) (#2961)

* feat(resilience): dimension freshness propagation (T1.5 propagation pass)

Ships the Phase 1 T1.5 propagation pass of the country-resilience
reference-grade upgrade plan. PR #2947 shipped the staleness
classifier foundation (classifyStaleness, cadence taxonomy, three
staleness levels) and explicitly deferred the dimension-level
propagation. This PR consumes the classifier and surfaces per
dimension freshness on the ResilienceDimension response.

What this PR commits

- Proto: new DimensionFreshness message + `freshness` field on
  ResilienceDimension (last_observed_at_ms, staleness string).
- New module server/worldmonitor/resilience/v1/_dimension-freshness.ts
  that reads seed-meta values for every sourceKey in INDICATOR_REGISTRY
  and aggregates the worst staleness + oldest fetchedAt across the
  constituent indicators of each dimension.
- scoreAllDimensions decorates each dimension score with its freshness
  result before returning. The 13 dimension scorer function bodies are
  untouched: aggregation is a decoration pass at the caller level so
  this PR stays mechanical.
- Response builder: _shared.ts buildDimensionList propagates the
  freshness field to the proto output.
- Tests: 10 classifyDimensionFreshness + readFreshnessMap cases in a
  new test file + response-shape case on the release-gate test.

Aggregation rules

- last_observed_at_ms: MIN fetchedAt across the dimension's indicators
  (oldest signal = most conservative bound). 0 when no signal has
  ever been observed.
- staleness: MAX staleness level across the dimension's indicators
  (stale > aging > fresh). Empty string when the dimension has no
  indicators in the registry (defensive path).

What is deliberately NOT in this PR

- No changes to the 13 individual dimension scorer function bodies.
  Per-signal freshness inside scorers is a future enhancement.
- No widget rendering of the freshness badge (T1.6 full grid, PR 3).
- No cache key bump: additive int64/string fields with zero defaults.

Verified

- make generate clean, new interface in regenerated types
- typecheck + typecheck:api clean
- tests/resilience-dimension-freshness.test.mts all new cases pass
- tests/resilience-*.test.mts full suite pass
- test:data clean
- lint exits 0 on touched files

* fix(resilience): resolve templated sourceKeys to real seed-meta (#2961 P1)

Greptile P1 finding on PR #2961: readFreshnessMap() assumed every
INDICATOR_REGISTRY sourceKey could be fetched as seed-meta:<sourceKey>,
but most entries use placeholder templates like resilience:static:{ISO2},
energy:mix:v1:{ISO2}, and displacement:summary:v1:{year}. Those produce
literal lookups like seed-meta:resilience:static:{ISO2} which don't
exist in Redis, so the freshness map missed every templated entry and
classifyDimensionFreshness marked the affected dimensions stale even
with healthy seeds. Most Phase 1 T1.5 freshness badges were broken on
arrival.

Fix: two-layer resolution in _dimension-freshness.ts.

Layer 1 stripTemplateTokens: drop :{placeholder} and :* segments.
  'resilience:static:{ISO2}'        -> 'resilience:static'
  'resilience:static:*'             -> 'resilience:static'
  'energy:mix:v1:{ISO2}'            -> 'energy:mix:v1'
  'displacement:summary:v1:{year}'  -> 'displacement:summary:v1'

Layer 2 stripTrailingVersion: strip trailing :v\d+, mirroring
writeExtraKeyWithMeta + runSeed() in scripts/_seed-utils.mjs which
never persist the trailing version in seed-meta keys. Handles
cyber:threats:v2, infra:outages:v1, unrest:events:v1,
conflict:ucdp-events:v1, sanctions:country-counts:v1, and the
displacement v1 case above.

Layer 3 SOURCE_KEY_META_OVERRIDES: explicit table for drift cases
where the two strips still do not match the real seed-meta key.
Verified against api/seed-health.js, api/health.js, and scripts/seed-*.
Drift cases covered:
  economic:imf:macro       -> economic:imf-macro
  economic:bis:eer         -> economic:bis
  economic:energy:v1:all   -> economic:energy-prices
  energy:mix               -> economic:owid-energy-mix
  energy:gas-storage       -> energy:gas-storage-countries
  news:threat:summary      -> news:threat-summary
  intelligence:social:reddit -> intelligence:social-reddit

readFreshnessMap now deduplicates reads by resolved meta key (so
the 15+ resilience:static indicators share one Redis read) and
projects per-meta-key results back onto per-sourceKey map entries so
classifyDimensionFreshness can keep its existing interface.

Regression coverage:
- stripTemplateTokens cases for {ISO2}, {year}, and *.
- stripTrailingVersion cases for :v1 / :v2 suffixes.
- Embedded :v1 carve-out (trade:restrictions:v1:tariff-overview:50
  stays unchanged because :v1 is not trailing).
- Override cases for the seven drift entries.
- Integration test that proves every resilience:static:* / {ISO2}
  registry entry resolves to the same seed-meta and is marked fresh
  when that one key has a recent fetchedAt.
- healthPublicService end-to-end test: classifies fresh when
  seed-meta:resilience:static is recent (was stale before the fix).
- Registry-coverage assertion: every INDICATOR_REGISTRY sourceKey
  must resolve to a seed-meta key that either lives in
  api/seed-health.js, api/health.js, or the test's
  KNOWN_SEEDS_NOT_IN_HEALTH allowlist (which covers the four seeds
  written by writeExtraKeyWithMeta / runSeed that no health monitor
  tracks yet: trade:restrictions, trade:barriers,
  sanctions:country-counts, economic:energy-prices). Fails loudly if
  a future registry entry introduces an unknown sourceKey.

Note on P1 #2 (scoreCurrencyExternal absence-branch delete): that is
PR #2964's scope (T1.7 source-failure wiring), not #2961 (T1.5
propagation pass). #2961 never claimed to delete the fallback branch;
no test in this branch expects the new IMPUTE.bisEer fallback. The
reviewer conflated the two stacked PRs. #2964 owns the delete.
This commit is contained in:
Elie Habib
2026-04-12 00:27:48 +04:00
committed by GitHub
parent e5ed14f82b
commit 75b3b0026b
11 changed files with 764 additions and 14 deletions

File diff suppressed because one or more lines are too long

View File

@@ -172,6 +172,25 @@ components:
Four-class imputation taxonomy (Phase 1 T1.7). Empty string when the
dimension has any observed data. One of: "stable-absence", "unmonitored",
"source-failure", "not-applicable". See docs/methodology/country-resilience-index.mdx.
freshness:
$ref: '#/components/schemas/DimensionFreshness'
DimensionFreshness:
type: object
properties:
lastObservedAtMs:
type: string
format: int64
description: |-
Unix milliseconds when the oldest constituent signal in this
dimension was last observed (min fetchedAt across INDICATOR_REGISTRY
entries for this dimension). 0 when no signal has ever been
observed.
staleness:
type: string
description: |-
Worst staleness level across the dimension's constituent signals,
classified by classifyStaleness against each signal's cadence.
One of: "fresh", "aging", "stale". Empty string when no signals.
ScoreInterval:
type: object
properties:

View File

@@ -2,6 +2,18 @@ syntax = "proto3";
package worldmonitor.resilience.v1;
message DimensionFreshness {
// Unix milliseconds when the oldest constituent signal in this
// dimension was last observed (min fetchedAt across INDICATOR_REGISTRY
// entries for this dimension). 0 when no signal has ever been
// observed.
int64 last_observed_at_ms = 1;
// Worst staleness level across the dimension's constituent signals,
// classified by classifyStaleness against each signal's cadence.
// One of: "fresh", "aging", "stale". Empty string when no signals.
string staleness = 2;
}
message ResilienceDimension {
string id = 1;
double score = 2;
@@ -12,6 +24,11 @@ message ResilienceDimension {
// dimension has any observed data. One of: "stable-absence", "unmonitored",
// "source-failure", "not-applicable". See docs/methodology/country-resilience-index.mdx.
string imputation_class = 6;
// Freshness aggregated across the constituent signals of the
// dimension. Phase 1 T1.5 of the reference-grade upgrade plan.
// Empty `last_observed_at_ms=0` + `staleness=""` when the dimension
// has no signals tracked in the indicator registry.
DimensionFreshness freshness = 7;
}
message ResilienceDomain {

View File

@@ -0,0 +1,233 @@
// T1.5 Phase 1 of the country-resilience reference-grade upgrade plan
// (docs/internal/country-resilience-upgrade-plan.md).
//
// Propagation pass: PR #2947 shipped the staleness classifier foundation
// (classifyStaleness, cadence taxonomy, three staleness levels) and
// explicitly deferred the dimension-level propagation. This module owns
// that propagation pass.
//
// Design: aggregation happens one level above the 13 dimension scorers.
// The scorers stay unchanged; this module reads every seed-meta key
// referenced by INDICATOR_REGISTRY, builds a sourceKey → fetchedAtMs
// map, and aggregates per dimension:
// - staleness: MAX (worst) level across the dimension's indicators
// (stale > aging > fresh).
// - lastObservedAtMs: MIN (oldest) fetchedAt across the dimension's
// indicators (oldest signal is the most conservative bound).
//
// The module is pure. The Redis reader is injected so unit tests can
// pass a deterministic fake map without touching network or Redis.
import {
classifyStaleness,
type StalenessLevel,
} from '../../../_shared/resilience-freshness';
import type { ResilienceDimensionId } from './_dimension-scorers';
import { INDICATOR_REGISTRY } from './_indicator-registry';
export interface DimensionFreshnessResult {
/** Oldest (min) `fetchedAt` across the dimension's indicators. 0 when nothing ever observed. */
lastObservedAtMs: number;
/** Worst (max) staleness across the dimension's indicators. `''` when no indicators exist for the dimension. */
staleness: StalenessLevel | '';
}
// Strip `:{placeholder}` templates and `:*` wildcard segments from a
// registry sourceKey so we can project it onto a real seed-meta key.
// Cases:
// 'resilience:static:{ISO2}' -> 'resilience:static'
// 'resilience:static:*' -> 'resilience:static'
// 'energy:mix:v1:{ISO2}' -> 'energy:mix:v1'
// 'displacement:summary:v1:{year}' -> 'displacement:summary:v1'
// 'economic:imf:macro:v2' -> 'economic:imf:macro:v2' (unchanged)
function stripTemplateTokens(sourceKey: string): string {
return sourceKey.replace(/:\{[^}]+\}/g, '').replace(/:\*/g, '');
}
// Mirrors the version-strip in scripts/_seed-utils.mjs writeExtraKeyWithMeta:
// const metaKey = metaKeyOverride || `seed-meta:${key.replace(/:v\d+$/, '')}`;
// runSeed() uses `seed-meta:${domain}:${resource}` and never appends a
// version suffix. Many registry sourceKeys end in `:v1` / `:v2` for
// canonical data-key versioning, but the seed-meta variant always drops
// the trailing version. Strip it here too so we line up with reality.
function stripTrailingVersion(stripped: string): string {
return stripped.replace(/:v\d+$/, '');
}
// Explicit overrides for cases where the template/version strip still
// diverges from the real seed-meta key. Keep this table short: add an
// entry only when verified against api/seed-health.js, api/health.js,
// or the relevant scripts/seed-*.mjs runSeed() / writeExtraKeyWithMeta
// call.
//
// Key: result of `stripTrailingVersion(stripTemplateTokens(sourceKey))`.
// Value: the bare seed-meta tail (prepend `seed-meta:` to get the full key).
const SOURCE_KEY_META_OVERRIDES: Readonly<Record<string, string>> = {
// seed-imf-macro.mjs: runSeed('economic', 'imf-macro', ...) writes
// seed-meta:economic:imf-macro (dash, not colon).
'economic:imf:macro': 'economic:imf-macro',
// seed-bis-data.mjs: runSeed('economic', 'bis', ...) writes
// seed-meta:economic:bis (the sub-resource 'eer' is only in the data
// key, not the meta key).
'economic:bis:eer': 'economic:bis',
// seed-economy.mjs: runSeed('economic', 'energy-prices', ...) writes
// seed-meta:economic:energy-prices for the economic:energy:v1:all key.
// The :v1:all tail means neither template-strip nor version-strip
// normalizes this one; it has to be an explicit override.
'economic:energy:v1:all': 'economic:energy-prices',
// OWID energy mix seeder: the data keys live under energy:mix:v1:{ISO2}
// but the seed-meta is seed-meta:economic:owid-energy-mix (both
// energyExposure and energyMixAll in api/health.js point at it).
'energy:mix': 'economic:owid-energy-mix',
// GIE gas storage per-country keys share one meta key.
'energy:gas-storage': 'energy:gas-storage-countries',
// ais-relay.cjs writes seed-meta:news:threat-summary (single dash).
'news:threat:summary': 'news:threat-summary',
// ais-relay.cjs writes seed-meta:intelligence:social-reddit (single dash).
'intelligence:social:reddit': 'intelligence:social-reddit',
};
/**
* Resolve a registry `sourceKey` to the real `seed-meta:<...>` key it
* should be fetched under. Exposed for unit tests and a registry
* coverage assertion; callers of `readFreshnessMap` do not need to use
* this directly.
*
* Resolution order:
* 1. Strip `:{placeholder}` and `:*` wildcard segments.
* 2. Strip trailing `:v\d+` (mirrors writeExtraKeyWithMeta +
* runSeed() behavior in scripts/_seed-utils.mjs).
* 3. Apply `SOURCE_KEY_META_OVERRIDES` if the stripped form is still
* divergent from the real seed-meta key.
*/
export function resolveSeedMetaKey(sourceKey: string): string {
const stripped = stripTrailingVersion(stripTemplateTokens(sourceKey));
const override = SOURCE_KEY_META_OVERRIDES[stripped];
return `seed-meta:${override ?? stripped}`;
}
// Stale dominates aging dominates fresh. A single stale signal forces
// the whole dimension to stale, since the badge must represent the
// freshness floor of the dimension, not the ceiling.
const STALENESS_ORDER: Record<StalenessLevel, number> = {
fresh: 0,
aging: 1,
stale: 2,
};
/**
* Aggregate freshness across all indicators in a dimension.
*
* Pure function. Missing sourceKeys in `freshnessMap` are treated as
* "never observed" (classifyStaleness returns `stale` with infinite
* age), so a dimension with no seed-meta coverage at all collapses to
* `stale` + `lastObservedAtMs: 0`.
*
* @param dimensionId - The dimension id to aggregate for.
* @param freshnessMap - sourceKey → fetchedAtMs. Missing keys are
* treated as "never observed".
* @param nowMs - Override clock for deterministic tests. Defaults to
* `Date.now()` via the classifier.
*/
export function classifyDimensionFreshness(
dimensionId: ResilienceDimensionId,
freshnessMap: Map<string, number>,
nowMs?: number,
): DimensionFreshnessResult {
const indicators = INDICATOR_REGISTRY.filter((indicator) => indicator.dimension === dimensionId);
if (indicators.length === 0) {
// Defensive: a dimension with no registry entries gets an empty
// freshness payload rather than a spurious "stale" classification.
return { lastObservedAtMs: 0, staleness: '' };
}
let oldestMs = Number.POSITIVE_INFINITY;
let worstStaleness: StalenessLevel = 'fresh';
for (const indicator of indicators) {
const lastObservedAtMs = freshnessMap.get(indicator.sourceKey) ?? null;
const result = classifyStaleness({
lastObservedAtMs,
cadence: indicator.cadence,
nowMs,
});
if (STALENESS_ORDER[result.staleness] > STALENESS_ORDER[worstStaleness]) {
worstStaleness = result.staleness;
}
if (lastObservedAtMs != null && Number.isFinite(lastObservedAtMs) && lastObservedAtMs < oldestMs) {
oldestMs = lastObservedAtMs;
}
}
return {
lastObservedAtMs: Number.isFinite(oldestMs) ? oldestMs : 0,
staleness: worstStaleness,
};
}
/**
* Read all seed-meta keys referenced by INDICATOR_REGISTRY and return
* a `Map<sourceKey, fetchedAtMs>`. Missing or malformed seed-meta
* entries are omitted; the map lookup then returns `undefined`, which
* the classifier treats as "never observed" (stale).
*
* Registry sourceKeys that use template placeholders
* (`resilience:static:{ISO2}`, `displacement:summary:v1:{year}`, etc.)
* or trailing `:v\d+` suffixes are resolved to their real seed-meta
* keys via `resolveSeedMetaKey`. Reads are deduplicated by the resolved
* meta key so 15+ `resilience:static:*` indicators collapse to one
* Redis fetch, and results are projected back onto every registry
* sourceKey that shares the same meta key.
*
* The reader is injected so callers can pass `defaultSeedReader` in
* production or a fixture reader in tests.
*/
export async function readFreshnessMap(
reader: (key: string) => Promise<unknown | null>,
): Promise<Map<string, number>> {
const map = new Map<string, number>();
// sourceKey -> resolved seed-meta key. Preserves every registry
// sourceKey (including templated ones) so we can project back.
const sourceKeyToMetaKey = new Map<string, string>();
for (const indicator of INDICATOR_REGISTRY) {
if (!sourceKeyToMetaKey.has(indicator.sourceKey)) {
sourceKeyToMetaKey.set(indicator.sourceKey, resolveSeedMetaKey(indicator.sourceKey));
}
}
// Dedupe by resolved meta key: 15+ resilience:static:{ISO2} entries
// all share seed-meta:resilience:static, and we only want one read.
const uniqueMetaKeys = [...new Set(sourceKeyToMetaKey.values())];
const metaKeyFetchedAt = new Map<string, number>();
await Promise.all(
uniqueMetaKeys.map(async (metaKey) => {
try {
const meta = await reader(metaKey);
if (meta && typeof meta === 'object' && 'fetchedAt' in meta) {
const fetchedAt = Number((meta as { fetchedAt: unknown }).fetchedAt);
if (Number.isFinite(fetchedAt) && fetchedAt > 0) {
metaKeyFetchedAt.set(metaKey, fetchedAt);
}
}
} catch {
// Defensive: a bad seed-meta read is equivalent to the key
// being missing (classifier returns stale on undefined). This
// keeps the aggregation resilient to upstream Redis hiccups.
}
}),
);
// Project per-meta-key results back onto per-sourceKey map entries
// so classifyDimensionFreshness can keep querying by raw registry
// sourceKey without needing to know the resolution rules.
for (const [sourceKey, metaKey] of sourceKeyToMetaKey) {
const fetchedAt = metaKeyFetchedAt.get(metaKey);
if (fetchedAt != null) {
map.set(sourceKey, fetchedAt);
}
}
return map;
}

View File

@@ -2,6 +2,7 @@ import countryNames from '../../../../shared/country-names.json';
import iso2ToIso3Json from '../../../../shared/iso2-to-iso3.json';
import { normalizeCountryToken } from '../../../_shared/country-token';
import { getCachedJson } from '../../../_shared/redis';
import { classifyDimensionFreshness, readFreshnessMap } from './_dimension-freshness';
export type ResilienceDimensionId =
| 'macroFiscal'
@@ -34,6 +35,12 @@ export interface ResilienceDimensionScore {
// fully imputed (observedWeight === 0 && imputedWeight > 0), null when the
// dimension has any observed data or no data at all.
imputationClass: ImputationClass | null;
// T1.5 propagation pass: freshness aggregated across the dimension's
// constituent signals. Individual scorers return the zero value
// (`{ lastObservedAtMs: 0, staleness: '' }`); `scoreAllDimensions`
// decorates the real value in using `classifyDimensionFreshness`.
// See server/worldmonitor/resilience/v1/_dimension-freshness.ts.
freshness: { lastObservedAtMs: number; staleness: '' | 'fresh' | 'aging' | 'stale' };
}
export type ResilienceSeedReader = (key: string) => Promise<unknown | null>;
@@ -374,7 +381,7 @@ function weightedBlend(metrics: WeightedMetric[]): ResilienceDimensionScore {
const availableWeight = available.reduce((sum, metric) => sum + metric.weight, 0);
if (!availableWeight || !totalWeight) {
return { score: 0, coverage: 0, observedWeight: 0, imputedWeight: 0, imputationClass: null };
return { score: 0, coverage: 0, observedWeight: 0, imputedWeight: 0, imputationClass: null, freshness: { lastObservedAtMs: 0, staleness: '' } };
}
const weightedScore = available.reduce((sum, metric) => sum + (metric.score || 0) * metric.weight, 0) / availableWeight;
@@ -429,6 +436,7 @@ function weightedBlend(metrics: WeightedMetric[]): ResilienceDimensionScore {
observedWeight: Number(observedWeight.toFixed(4)),
imputedWeight: Number(imputedWeight.toFixed(4)),
imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
@@ -790,23 +798,24 @@ export async function scoreCurrencyExternal(
const inflScore = normalizeLowerBetter(Math.min(imfEntry!.inflationPct!, 50), 0, 50);
const blended = inflScore * 0.6 + reservesScore * 0.4;
const coverage = bisExchangeRaw != null ? 0.55 : 0.45;
return { score: roundScore(blended), coverage, observedWeight: 1, imputedWeight: 0, imputationClass: null };
return { score: roundScore(blended), coverage, observedWeight: 1, imputedWeight: 0, imputationClass: null, freshness: { lastObservedAtMs: 0, staleness: '' } };
}
if (hasInflation) {
const coverage = bisExchangeRaw != null ? 0.45 : 0.35;
return { score: normalizeLowerBetter(Math.min(imfEntry!.inflationPct!, 50), 0, 50), coverage, observedWeight: 1, imputedWeight: 0, imputationClass: null };
return { score: normalizeLowerBetter(Math.min(imfEntry!.inflationPct!, 50), 0, 50), coverage, observedWeight: 1, imputedWeight: 0, imputationClass: null, freshness: { lastObservedAtMs: 0, staleness: '' } };
}
if (hasReserves) {
const coverage = bisExchangeRaw != null ? 0.4 : 0.3;
return { score: reservesScore, coverage, observedWeight: 1, imputedWeight: 0, imputationClass: null };
return { score: reservesScore, coverage, observedWeight: 1, imputedWeight: 0, imputationClass: null, freshness: { lastObservedAtMs: 0, staleness: '' } };
}
if (bisExchangeRaw == null) return { score: 50, coverage: 0, observedWeight: 0, imputedWeight: 0, imputationClass: null };
if (bisExchangeRaw == null) return { score: 50, coverage: 0, observedWeight: 0, imputedWeight: 0, imputationClass: null, freshness: { lastObservedAtMs: 0, staleness: '' } };
return {
score: IMPUTE.bisEer.score,
coverage: IMPUTE.bisEer.certaintyCoverage,
observedWeight: 0,
imputedWeight: 1,
imputationClass: IMPUTE.bisEer.imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
@@ -1149,13 +1158,28 @@ export async function scoreAllDimensions(
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<Record<ResilienceDimensionId, ResilienceDimensionScore>> {
const memoizedReader = createMemoizedSeedReader(reader);
const entries = await Promise.all(
RESILIENCE_DIMENSION_ORDER.map(async (dimensionId) => [
dimensionId,
await RESILIENCE_DIMENSION_SCORERS[dimensionId](countryCode, memoizedReader),
] as const),
);
return Object.fromEntries(entries) as Record<ResilienceDimensionId, ResilienceDimensionScore>;
const [entries, freshnessMap] = await Promise.all([
Promise.all(
RESILIENCE_DIMENSION_ORDER.map(async (dimensionId) => [
dimensionId,
await RESILIENCE_DIMENSION_SCORERS[dimensionId](countryCode, memoizedReader),
] as const),
),
// T1.5 propagation pass: aggregate freshness at the caller level so
// the 13 dimension scorers stay mechanical. We share the memoized
// reader so each `seed-meta:<key>` read lands in the same cache as
// the scorers' source reads (though seed-meta keys don't overlap
// with the scorer keys in practice, the shared reader is cheap).
readFreshnessMap(memoizedReader),
]);
const scores = Object.fromEntries(entries) as Record<ResilienceDimensionId, ResilienceDimensionScore>;
for (const dimensionId of RESILIENCE_DIMENSION_ORDER) {
scores[dimensionId] = {
...scores[dimensionId],
freshness: classifyDimensionFreshness(dimensionId, freshnessMap),
};
}
return scores;
}
export function getResilienceDomainWeight(domainId: ResilienceDomainId): number {

View File

@@ -94,6 +94,7 @@ function buildDimensionList(
observedWeight: number;
imputedWeight: number;
imputationClass: ImputationClass | null;
freshness: { lastObservedAtMs: number; staleness: '' | 'fresh' | 'aging' | 'stale' };
}
>,
): ResilienceDimension[] {
@@ -105,6 +106,13 @@ function buildDimensionList(
imputedWeight: round(scores[dimensionId].imputedWeight, 4),
// T1.7 schema pass: empty string = dimension has any observed data.
imputationClass: scores[dimensionId].imputationClass ?? '',
// T1.5 propagation pass: proto `int64 last_observed_at_ms` comes through
// as `string` on the generated TS interface; stringify the number here
// so the response conforms to the generated type.
freshness: {
lastObservedAtMs: String(scores[dimensionId].freshness.lastObservedAtMs),
staleness: scores[dimensionId].freshness.staleness,
},
}));
}

View File

@@ -36,6 +36,12 @@ export interface ResilienceDimension {
observedWeight: number;
imputedWeight: number;
imputationClass: string;
freshness?: DimensionFreshness;
}
export interface DimensionFreshness {
lastObservedAtMs: string;
staleness: string;
}
export interface ScoreInterval {

View File

@@ -36,6 +36,12 @@ export interface ResilienceDimension {
observedWeight: number;
imputedWeight: number;
imputationClass: string;
freshness?: DimensionFreshness;
}
export interface DimensionFreshness {
lastObservedAtMs: string;
staleness: string;
}
export interface ScoreInterval {

View File

@@ -0,0 +1,396 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { describe, it } from 'node:test';
import {
classifyDimensionFreshness,
readFreshnessMap,
resolveSeedMetaKey,
} from '../server/worldmonitor/resilience/v1/_dimension-freshness.ts';
import { INDICATOR_REGISTRY } from '../server/worldmonitor/resilience/v1/_indicator-registry.ts';
import {
AGING_MULTIPLIER,
FRESH_MULTIPLIER,
cadenceUnitMs,
} from '../server/_shared/resilience-freshness.ts';
import type { ResilienceDimensionId } from '../server/worldmonitor/resilience/v1/_dimension-scorers.ts';
// T1.5 propagation pass of the country-resilience reference-grade upgrade
// plan. PR #2947 shipped the classifier foundation; this suite pins the
// dimension-level aggregation so T1.6 (full grid) and T1.9 (bootstrap
// wiring) can consume the aggregated freshness with confidence.
const NOW = 1_700_000_000_000;
function freshAt(cadenceKey: Parameters<typeof cadenceUnitMs>[0], factor = 0.5): number {
// factor < FRESH_MULTIPLIER keeps the age in the fresh band.
return NOW - cadenceUnitMs(cadenceKey) * factor;
}
function agingAt(cadenceKey: Parameters<typeof cadenceUnitMs>[0]): number {
// Between FRESH_MULTIPLIER and AGING_MULTIPLIER.
const factor = (FRESH_MULTIPLIER + AGING_MULTIPLIER) / 2;
return NOW - cadenceUnitMs(cadenceKey) * factor;
}
function staleAt(cadenceKey: Parameters<typeof cadenceUnitMs>[0]): number {
// Well beyond AGING_MULTIPLIER.
return NOW - cadenceUnitMs(cadenceKey) * (AGING_MULTIPLIER + 2);
}
function buildAllFreshMap(dimensionId: ResilienceDimensionId): Map<string, number> {
const map = new Map<string, number>();
for (const indicator of INDICATOR_REGISTRY) {
if (indicator.dimension !== dimensionId) continue;
map.set(indicator.sourceKey, freshAt(indicator.cadence));
}
return map;
}
describe('classifyDimensionFreshness (T1.5 propagation pass)', () => {
it('all indicators fresh returns fresh and the oldest fetchedAt', () => {
// macroFiscal has three indicators; two share a sourceKey but the map
// is keyed by sourceKey so duplicates collapse to one entry.
const map = buildAllFreshMap('macroFiscal');
const result = classifyDimensionFreshness('macroFiscal', map, NOW);
assert.equal(result.staleness, 'fresh');
// lastObservedAtMs must be the MIN (oldest) fetchedAt across the
// unique sourceKeys that back the dimension.
const expectedOldest = Math.min(...map.values());
assert.equal(result.lastObservedAtMs, expectedOldest);
});
it('one aging indicator + rest fresh returns aging and stays below stale', () => {
// Pick a dimension with multiple source keys so we can tip one to aging.
// socialCohesion has 3 indicators across 3 source keys.
const dimensionId: ResilienceDimensionId = 'socialCohesion';
const map = new Map<string, number>();
const indicators = INDICATOR_REGISTRY.filter((i) => i.dimension === dimensionId);
assert.ok(indicators.length >= 2);
map.set(indicators[0]!.sourceKey, agingAt(indicators[0]!.cadence));
for (let i = 1; i < indicators.length; i += 1) {
map.set(indicators[i]!.sourceKey, freshAt(indicators[i]!.cadence));
}
const result = classifyDimensionFreshness(dimensionId, map, NOW);
assert.equal(result.staleness, 'aging', 'one aging + rest fresh should escalate to aging');
});
it('one stale + one fresh returns stale (worst wins)', () => {
const dimensionId: ResilienceDimensionId = 'socialCohesion';
const map = new Map<string, number>();
const indicators = INDICATOR_REGISTRY.filter((i) => i.dimension === dimensionId);
assert.ok(indicators.length >= 2);
map.set(indicators[0]!.sourceKey, staleAt(indicators[0]!.cadence));
for (let i = 1; i < indicators.length; i += 1) {
map.set(indicators[i]!.sourceKey, freshAt(indicators[i]!.cadence));
}
const result = classifyDimensionFreshness(dimensionId, map, NOW);
assert.equal(result.staleness, 'stale', 'stale must dominate fresh in the aggregation');
});
it('empty freshnessMap collapses to stale with lastObservedAtMs=0', () => {
const emptyMap = new Map<string, number>();
const result = classifyDimensionFreshness('macroFiscal', emptyMap, NOW);
assert.equal(result.staleness, 'stale', 'no data = stale');
assert.equal(result.lastObservedAtMs, 0, 'no data = lastObservedAtMs zero');
});
it('dimension with no registry indicators returns empty payload (defensive)', () => {
// Cast forces the defensive branch; every real dimension has entries,
// but we want to pin the behavior for the defensive path.
const unknownDimension = '__not_a_real_dimension__' as ResilienceDimensionId;
const result = classifyDimensionFreshness(unknownDimension, new Map(), NOW);
assert.equal(result.staleness, '');
assert.equal(result.lastObservedAtMs, 0);
});
it('lastObservedAtMs is the MIN (oldest) across indicators, not the max', () => {
// foodWater has 4 indicators, all sharing `resilience:static:{ISO2}`
// as their sourceKey in the registry. The aggregation is keyed by
// sourceKey so duplicate keys collapse. To test the MIN behavior we
// use a dimension with distinct sourceKeys: energy (7 indicators).
const dimensionId: ResilienceDimensionId = 'energy';
const map = new Map<string, number>();
const indicators = INDICATOR_REGISTRY.filter((i) => i.dimension === dimensionId);
const uniqueKeys = [...new Set(indicators.map((i) => i.sourceKey))];
assert.ok(uniqueKeys.length >= 3, 'energy should have at least 3 unique source keys');
// Give each unique source key a distinct fetchedAt, all within the
// fresh band so staleness stays fresh and we can isolate the MIN
// calculation.
const timestamps: number[] = [];
uniqueKeys.forEach((key, index) => {
const t = NOW - (index + 1) * 1000; // oldest = last key
map.set(key, t);
timestamps.push(t);
});
const result = classifyDimensionFreshness(dimensionId, map, NOW);
const expectedMin = Math.min(...timestamps);
assert.equal(result.lastObservedAtMs, expectedMin);
});
});
describe('readFreshnessMap (T1.5 propagation pass)', () => {
it('builds the map from a fake reader that returns { fetchedAt } for some keys and null for others', async () => {
const fetchedAt = 1_699_000_000_000;
// Pick two real sourceKeys from the registry so the Set-dedupe path
// is exercised with actual registry data. Both resolve to drift
// cases (v-strip + override) so this also exercises resolveSeedMetaKey.
const sourceKeyA = 'economic:imf:macro:v2'; // macroFiscal -> seed-meta:economic:imf-macro
const sourceKeyB = 'sanctions:country-counts:v1'; // tradeSanctions -> seed-meta:sanctions:country-counts
const metaKeyA = resolveSeedMetaKey(sourceKeyA);
const metaKeyB = resolveSeedMetaKey(sourceKeyB);
const reader = async (key: string): Promise<unknown | null> => {
if (key === metaKeyA) return { fetchedAt };
if (key === metaKeyB) return { fetchedAt: fetchedAt + 1 };
return null;
};
const map = await readFreshnessMap(reader);
assert.equal(map.get(sourceKeyA), fetchedAt);
assert.equal(map.get(sourceKeyB), fetchedAt + 1);
// A key that doesn't appear in the reader output must not be in the map.
assert.ok(!map.has('bogus-key-never-seeded'));
});
it('omits malformed entries: fetchedAt not a number, NaN, zero, negative', async () => {
const sourceKey = 'economic:imf:macro:v2';
const metaKey = resolveSeedMetaKey(sourceKey);
const bogusCases: unknown[] = [
{ fetchedAt: 'not-a-number' },
{ fetchedAt: Number.NaN },
{ fetchedAt: 0 },
{ fetchedAt: -1 },
{ fetchedAt: null },
{ notAField: 123 },
null,
undefined,
'raw-string',
42,
];
for (const bogus of bogusCases) {
const reader = async (key: string): Promise<unknown | null> => {
if (key === metaKey) return bogus;
return null;
};
const map = await readFreshnessMap(reader);
assert.ok(
!map.has(sourceKey),
`malformed seed-meta ${JSON.stringify(bogus)} should be omitted from the map`,
);
}
});
it('deduplicates by resolved meta key so shared keys are read only once', async () => {
// 15+ resilience:static:{ISO2} registry entries collapse to one
// seed-meta:resilience:static read. macroFiscal has two indicators
// backed by economic:imf:macro:v2 that dedupe to one meta fetch.
const callCount = new Map<string, number>();
const reader = async (key: string): Promise<unknown | null> => {
callCount.set(key, (callCount.get(key) ?? 0) + 1);
return null;
};
await readFreshnessMap(reader);
for (const [, count] of callCount) {
assert.equal(count, 1, 'every seed-meta key should be read at most once');
}
// Spot-check: seed-meta:resilience:static was read exactly once even
// though the registry has many resilience:static:{ISO2} / * entries.
assert.equal(callCount.get('seed-meta:resilience:static'), 1);
});
it('swallows reader errors for a single key without failing the whole map', async () => {
const failingSourceKey = 'economic:imf:macro:v2';
const goodSourceKey = 'sanctions:country-counts:v1';
const failingMetaKey = resolveSeedMetaKey(failingSourceKey);
const goodMetaKey = resolveSeedMetaKey(goodSourceKey);
const reader = async (key: string): Promise<unknown | null> => {
if (key === failingMetaKey) throw new Error('redis down');
if (key === goodMetaKey) return { fetchedAt: NOW };
return null;
};
const map = await readFreshnessMap(reader);
// The failing key is absent; the good key is present.
assert.ok(!map.has(failingSourceKey));
assert.equal(map.get(goodSourceKey), NOW);
});
it('projects one seed-meta:resilience:static fetchedAt onto every resilience:static:{ISO2} / * sourceKey', async () => {
// Greptile P1 regression (#2961): readFreshnessMap used to issue
// literal seed-meta:resilience:static:{ISO2} reads, so every
// templated entry was missing from the map. Assert every registry
// sourceKey that resolves to seed-meta:resilience:static is
// populated by a single fetchedAt read.
const fetchedAt = NOW - 1_000_000;
const reader = async (key: string): Promise<unknown | null> => {
if (key === 'seed-meta:resilience:static') return { fetchedAt };
return null;
};
const map = await readFreshnessMap(reader);
const staticSourceKeys = INDICATOR_REGISTRY.filter((i) =>
/^resilience:static(:\{|:\*|$)/.test(i.sourceKey),
).map((i) => i.sourceKey);
assert.ok(staticSourceKeys.length >= 10, 'registry should have many resilience:static:* entries');
for (const sourceKey of staticSourceKeys) {
assert.equal(
map.get(sourceKey),
fetchedAt,
`registry sourceKey ${sourceKey} should be populated from seed-meta:resilience:static`,
);
}
});
it('healthPublicService classifies fresh when seed-meta:resilience:static is recent', async () => {
// End-to-end integration for the P1 fix. healthPublicService has
// three indicators, all sharing resilience:static:{ISO2} as their
// sourceKey. Before the fix, readFreshnessMap would miss all three
// and classifyDimensionFreshness returned stale on healthy seeds.
const fetchedAt = freshAt('annual', 0.1);
const reader = async (key: string): Promise<unknown | null> => {
if (key === 'seed-meta:resilience:static') return { fetchedAt };
return null;
};
const map = await readFreshnessMap(reader);
const result = classifyDimensionFreshness('healthPublicService', map, NOW);
assert.equal(
result.staleness,
'fresh',
'healthPublicService should be fresh when seed-meta:resilience:static is recent',
);
assert.equal(result.lastObservedAtMs, fetchedAt);
});
});
describe('resolveSeedMetaKey (T1.5 propagation pass, P1 fix)', () => {
it('strips {ISO2} template tokens', () => {
assert.equal(resolveSeedMetaKey('resilience:static:{ISO2}'), 'seed-meta:resilience:static');
});
it('strips :* wildcard segments', () => {
assert.equal(resolveSeedMetaKey('resilience:static:*'), 'seed-meta:resilience:static');
});
it('strips {year} template tokens and trailing :v1', () => {
// displacement:summary:v1:{year} -> strip :{year} -> displacement:summary:v1
// -> strip trailing :v1 -> displacement:summary
assert.equal(
resolveSeedMetaKey('displacement:summary:v1:{year}'),
'seed-meta:displacement:summary',
);
});
it('strips trailing :v\\d+ on ordinary version suffixes', () => {
assert.equal(resolveSeedMetaKey('cyber:threats:v2'), 'seed-meta:cyber:threats');
assert.equal(resolveSeedMetaKey('infra:outages:v1'), 'seed-meta:infra:outages');
assert.equal(resolveSeedMetaKey('unrest:events:v1'), 'seed-meta:unrest:events');
assert.equal(resolveSeedMetaKey('intelligence:gpsjam:v2'), 'seed-meta:intelligence:gpsjam');
assert.equal(
resolveSeedMetaKey('economic:national-debt:v1'),
'seed-meta:economic:national-debt',
);
assert.equal(
resolveSeedMetaKey('sanctions:country-counts:v1'),
'seed-meta:sanctions:country-counts',
);
});
it('leaves embedded :v1 alone when followed by more segments', () => {
// :v1 is not at the end, so the trailing-version strip must not
// touch it. writeExtraKeyWithMeta has the same carve-out.
assert.equal(
resolveSeedMetaKey('trade:restrictions:v1:tariff-overview:50'),
'seed-meta:trade:restrictions:v1:tariff-overview:50',
);
assert.equal(
resolveSeedMetaKey('trade:barriers:v1:tariff-gap:50'),
'seed-meta:trade:barriers:v1:tariff-gap:50',
);
});
it('applies SOURCE_KEY_META_OVERRIDES for the drift cases', () => {
// Overrides for sourceKeys that still diverge after strip.
assert.equal(resolveSeedMetaKey('economic:imf:macro:v2'), 'seed-meta:economic:imf-macro');
assert.equal(resolveSeedMetaKey('economic:bis:eer:v1'), 'seed-meta:economic:bis');
assert.equal(resolveSeedMetaKey('economic:energy:v1:all'), 'seed-meta:economic:energy-prices');
assert.equal(resolveSeedMetaKey('energy:mix:v1:{ISO2}'), 'seed-meta:economic:owid-energy-mix');
assert.equal(
resolveSeedMetaKey('energy:gas-storage:v1:{ISO2}'),
'seed-meta:energy:gas-storage-countries',
);
assert.equal(resolveSeedMetaKey('news:threat:summary:v1'), 'seed-meta:news:threat-summary');
assert.equal(
resolveSeedMetaKey('intelligence:social:reddit:v1'),
'seed-meta:intelligence:social-reddit',
);
});
});
// Registry-coverage assertion: every sourceKey in INDICATOR_REGISTRY must
// resolve to a seed-meta key that is actually written by some seeder,
// verified against the literal seed-meta:<...> strings in api/health.js
// and api/seed-health.js. This locks the drift down so a future registry
// entry with a bad sourceKey fails CI loudly instead of silently
// returning stale. To add a sourceKey that is intentionally untracked
// by the health files, allowlist it in KNOWN_SEEDS_NOT_IN_HEALTH with a
// one-line justification.
describe('INDICATOR_REGISTRY seed-meta coverage (T1.5 P1 regression lock)', () => {
// Seeds that are legitimately written by some seeder but do not appear
// in api/health.js or api/seed-health.js (e.g. because they are
// extra-key writes via writeExtraKeyWithMeta that no health monitor
// tracks yet). Each entry must be verified against scripts/seed-*.mjs
// before being added.
const KNOWN_SEEDS_NOT_IN_HEALTH: ReadonlySet<string> = new Set([
// scripts/seed-supply-chain-trade.mjs writes these via
// writeExtraKeyWithMeta. The :v\d+ is not trailing (has :tariff-*:50
// suffix) so the strip is a no-op and the meta key equals the key.
'seed-meta:trade:restrictions:v1:tariff-overview:50',
'seed-meta:trade:barriers:v1:tariff-gap:50',
// scripts/seed-sanctions-pressure.mjs afterPublish writes this via
// writeExtraKeyWithMeta(COUNTRY_COUNTS_KEY, ...). The :v1 suffix is
// stripped by writeExtraKeyWithMeta's regex, matching resolveSeedMetaKey.
'seed-meta:sanctions:country-counts',
// scripts/seed-economy.mjs: runSeed('economic', 'energy-prices', ...)
// writes this. The registry sourceKey economic:energy:v1:all does
// not strip to this shape, so SOURCE_KEY_META_OVERRIDES maps it.
'seed-meta:economic:energy-prices',
]);
function extractSeedMetaKeys(filePath: string): Set<string> {
const text = readFileSync(filePath, 'utf8');
const set = new Set<string>();
// Capture every 'seed-meta:...' literal up to the closing quote.
for (const match of text.matchAll(/['"`](seed-meta:[^'"`]+)['"`]/g)) {
set.add(match[1]!);
}
return set;
}
it('every registry sourceKey resolves to a known seed-meta key', () => {
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, '..');
const known = new Set<string>(KNOWN_SEEDS_NOT_IN_HEALTH);
for (const path of ['api/health.js', 'api/seed-health.js']) {
for (const key of extractSeedMetaKeys(resolve(repoRoot, path))) {
known.add(key);
}
}
const unknownResolutions: { sourceKey: string; metaKey: string }[] = [];
const uniqueSourceKeys = [...new Set(INDICATOR_REGISTRY.map((i) => i.sourceKey))];
for (const sourceKey of uniqueSourceKeys) {
const metaKey = resolveSeedMetaKey(sourceKey);
if (!known.has(metaKey)) {
unknownResolutions.push({ sourceKey, metaKey });
}
}
assert.deepEqual(
unknownResolutions,
[],
`INDICATOR_REGISTRY sourceKeys resolved to seed-meta keys that do not appear in api/health.js, api/seed-health.js, or KNOWN_SEEDS_NOT_IN_HEALTH. ` +
`Either update SOURCE_KEY_META_OVERRIDES in _dimension-freshness.ts or allowlist the key in KNOWN_SEEDS_NOT_IN_HEALTH with verification against scripts/seed-*.mjs: ` +
JSON.stringify(unknownResolutions, null, 2),
);
});
});

View File

@@ -25,7 +25,7 @@ import {
import { RESILIENCE_FIXTURES, fixtureReader } from './helpers/resilience-fixtures.mts';
async function scoreTriple(
scorer: (countryCode: string, reader?: (key: string) => Promise<unknown | null>) => Promise<{ score: number; coverage: number; observedWeight: number; imputedWeight: number; imputationClass: ImputationClass | null }>,
scorer: (countryCode: string, reader?: (key: string) => Promise<unknown | null>) => Promise<{ score: number; coverage: number; observedWeight: number; imputedWeight: number; imputationClass: ImputationClass | null; freshness: { lastObservedAtMs: number; staleness: '' | 'fresh' | 'aging' | 'stale' } }>,
) {
const [no, us, ye] = await Promise.all([
scorer('NO', fixtureReader),

View File

@@ -249,6 +249,47 @@ describe('resilience release gate', () => {
}
});
// T1.5 propagation pass: the serialized ResilienceDimension now carries
// a `freshness` payload aggregated across the dimension's constituent
// signals. PR #2947 shipped the classifier; this test pins the end-to-end
// response shape so the field is not silently dropped.
it('T1.5: every serialized ResilienceDimension carries a freshness payload', async () => {
installRedisFixtures();
const response = await getResilienceScore(
{ request: new Request('https://example.com?countryCode=US') } as never,
{ countryCode: 'US' },
);
const allDimensions = response.domains.flatMap((domain) => domain.dimensions);
assert.equal(allDimensions.length, 13, 'US response should carry all 13 dimensions');
const validLevels = ['', 'fresh', 'aging', 'stale'];
for (const dimension of allDimensions) {
assert.ok(dimension.freshness != null, `dimension ${dimension.id} must carry a freshness payload`);
const freshness = dimension.freshness!;
assert.equal(
typeof freshness.lastObservedAtMs,
'string',
`dimension ${dimension.id} freshness.lastObservedAtMs must be a string (proto int64), got ${typeof freshness.lastObservedAtMs}`,
);
assert.equal(
typeof freshness.staleness,
'string',
`dimension ${dimension.id} freshness.staleness must be a string`,
);
assert.ok(
validLevels.includes(freshness.staleness),
`dimension ${dimension.id} freshness.staleness="${freshness.staleness}" must be one of [${validLevels.join(', ')}]`,
);
// The serialized int64 string must parse cleanly to a non-negative
// integer so downstream consumers (widget badge, CMD+K Freshness
// column) can render it without defensive string handling.
const asNumber = Number(freshness.lastObservedAtMs);
assert.ok(Number.isFinite(asNumber), `lastObservedAtMs="${freshness.lastObservedAtMs}" must parse to a finite number`);
assert.ok(asNumber >= 0, `lastObservedAtMs="${freshness.lastObservedAtMs}" must be non-negative`);
}
});
it('T1.7: fully imputed dimension serializes a non-empty imputationClass', async () => {
// XX has no fixture: every scorer will fall through to either null (no
// data at all) or imputation. scoreFoodWater requires resilience:static