mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
233
server/worldmonitor/resilience/v1/_dimension-freshness.ts
Normal file
233
server/worldmonitor/resilience/v1/_dimension-freshness.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
396
tests/resilience-dimension-freshness.test.mts
Normal file
396
tests/resilience-dimension-freshness.test.mts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user