feat(resilience): recovery capacity pillar — 6 new dimensions + 5 seeders (Phase 2 T2.2b) (#2987)

* feat(resilience): recovery capacity pillar — 6 new dimensions + 5 seeders (Phase 2 T2.2b)

Add the recovery-capacity pillar with 6 new dimensions:
- fiscalSpace: IMF GGR_G01_GDP_PT + GGXCNL_G01_GDP_PT + GGXWDG_NGDP_PT
- reserveAdequacy: World Bank FI.RES.TOTL.MO
- externalDebtCoverage: WB DT.DOD.DSTC.CD / FI.RES.TOTL.CD ratio
- importConcentration: UN Comtrade HHI (stub seeder)
- stateContinuity: derived from WGI + UCDP + displacement (no new fetch)
- fuelStockDays: IEA/EIA (stub seeder, Enrichment tier)

Each dimension has a scorer in _dimension-scorers.ts, registry entries in
_indicator-registry.ts, methodology doc subsections, and fixture data.

Seeders: fiscal-space (real, IMF WEO), reserve-adequacy (real, WB API),
external-debt (real, WB API), import-hhi (stub), fuel-stocks (stub).

Recovery domain weight is 0 until PR 4 (T2.3) ships the penalized weighted
mean across pillars. The domain appears in responses structurally but does
not affect the overall score.

Bootstrap: STANDALONE_KEYS + SEED_META + EMPTY_DATA_OK_KEYS + ON_DEMAND_KEYS
all updated in api/health.js. Source-failure mapping updated for
stateContinuity (WGI adapter). Widget labels and LOCKED_PREVIEW updated.

All 282 resilience tests pass, typecheck clean, methodology lint clean.

* fix(resilience): ISO3→ISO2 normalization in WB recovery seeders (#2987 P1)

Both seed-recovery-reserve-adequacy.mjs and seed-recovery-external-debt.mjs
used countryiso3code from the World Bank API response then immediately
rejected codes where length !== 2. WB returns ISO3 codes (USA, DEU, etc.),
so all real rows were silently dropped and the feed was always empty.

Fix: import scripts/shared/iso3-to-iso2.json and normalize before the
length check. Also removed from EMPTY_DATA_OK_KEYS in health.js since
empty results now indicate a real failure, not a structural absence.

* fix(resilience): remove unused import + no-op overrides (#2987 review)

* fix(test): update release-gate to expect 6 domains after recovery pillar
This commit is contained in:
Elie Habib
2026-04-12 10:10:10 +04:00
committed by GitHub
parent 0ed56d0326
commit 17e34dfca7
24 changed files with 1074 additions and 28 deletions

View File

@@ -159,6 +159,11 @@ const STANDALONE_KEYS = {
sprPolicies: 'energy:spr-policies:v1',
regionalSnapshots: 'intelligence:regional-snapshots:summary:v1',
regionalBriefs: 'intelligence:regional-briefs:summary:v1',
recoveryFiscalSpace: 'resilience:recovery:fiscal-space:v1',
recoveryReserveAdequacy: 'resilience:recovery:reserve-adequacy:v1',
recoveryExternalDebt: 'resilience:recovery:external-debt:v1',
recoveryImportHhi: 'resilience:recovery:import-hhi:v1',
recoveryFuelStocks: 'resilience:recovery:fuel-stocks:v1',
};
const SEED_META = {
@@ -301,6 +306,11 @@ const SEED_META = {
chokepointFlows: { key: 'seed-meta:energy:chokepoint-flows', maxStaleMin: 720 }, // 6h cron; 720min = 2x interval
emberElectricity: { key: 'seed-meta:energy:ember', maxStaleMin: 2880 }, // daily cron (08:00 UTC); 2880min = 48h = 2x interval
chokepointExposure: { key: 'seed-meta:supply_chain:chokepoint-exposure', maxStaleMin: 2880 }, // daily cron; 2880min = 48h = 2x interval
recoveryFiscalSpace: { key: 'seed-meta:resilience:recovery:fiscal-space', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
recoveryReserveAdequacy: { key: 'seed-meta:resilience:recovery:reserve-adequacy', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
recoveryExternalDebt: { key: 'seed-meta:resilience:recovery:external-debt', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
recoveryImportHhi: { key: 'seed-meta:resilience:recovery:import-hhi', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
recoveryFuelStocks: { key: 'seed-meta:resilience:recovery:fuel-stocks', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
};
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
@@ -319,6 +329,8 @@ const ON_DEMAND_KEYS = new Set([
'simulationOutcomeLatest', // written by writeSimulationOutcome after simulation runs; only present after first successful simulation
'newsThreatSummary', // relay classify loop — only written when mergedByCountry has entries; absent on quiet news periods
'resilienceRanking', // on-demand RPC cache populated after ranking requests; missing before first Pro use is expected
'recoveryFiscalSpace', 'recoveryReserveAdequacy', 'recoveryExternalDebt',
'recoveryImportHhi', 'recoveryFuelStocks', // recovery pillar: stub seeders not yet deployed, keys may be absent
]);
// Keys where 0 records is a valid healthy state (e.g. no airports closed,
@@ -329,6 +341,8 @@ const EMPTY_DATA_OK_KEYS = new Set([
'earningsCalendar', 'econCalendar', 'cotPositioning',
'usniFleet', // usniFleetStale covers the fallback; relay outages → WARN not CRIT
'newsThreatSummary', // only written when classify produces country matches; quiet news periods = 0 countries, no write
'recoveryFiscalSpace',
'recoveryImportHhi', 'recoveryFuelStocks', // recovery pillar seeds: stub seeders write empty payloads until real sources are wired
]);
// Cascade groups: if any key in the group has data, all empty siblings are OK.

View File

@@ -161,6 +161,54 @@ All six WGI indicators are equally weighted.
| aquastatWaterStress | FAO AQUASTAT water stress/withdrawal/dependency (%) | Lower is better | 100 - 0 | 0.25 | FAO AQUASTAT | Annual |
| aquastatWaterAvailability | FAO AQUASTAT water availability (m3/capita) | Higher is better | 0 - 5000 | 0.15 | FAO AQUASTAT | Annual |
### Recovery Domain (weight 1.0)
This domain forms the recovery-capacity pillar. It measures a country's ability to bounce back from an acute shock along fiscal, monetary, trade, institutional, and energy dimensions.
#### Fiscal Space
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| recoveryGovRevenue | Government revenue as % of GDP (IMF GGR_G01_GDP_PT) | Higher is better | 5 - 45 | 0.40 | IMF | Annual |
| recoveryFiscalBalance | General government net lending/borrowing as % of GDP (IMF GGXCNL_G01_GDP_PT) | Higher is better | -15 - 5 | 0.30 | IMF | Annual |
| recoveryDebtToGdp | General government gross debt as % of GDP (IMF GGXWDG_NGDP_PT) | Lower is better | 150 - 0 | 0.30 | IMF | Annual |
#### Reserve Adequacy
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| recoveryReserveMonths | Total reserves in months of imports (World Bank FI.RES.TOTL.MO) | Higher is better | 1 - 18 | 1.00 | World Bank | Annual |
#### External Debt Coverage
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| recoveryDebtToReserves | Short-term external debt to reserves ratio (World Bank DT.DOD.DSTC.CD / FI.RES.TOTL.CD) | Lower is better | 5 - 0 | 1.00 | World Bank | Annual |
#### Import Concentration
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| recoveryImportHhi | Herfindahl-Hirschman Index of import partner concentration (UN Comtrade HS2 bilateral) | Lower is better | 5000 - 0 | 1.00 | UN Comtrade | Annual |
#### State Continuity
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| recoveryWgiContinuity | Mean WGI score as institutional durability proxy | Higher is better | -2.5 - 2.5 | 0.50 | World Bank | Annual |
| recoveryConflictPressure | UCDP conflict metric inverted to state continuity | Lower is better | 30 - 0 | 0.30 | UCDP | Realtime |
| recoveryDisplacementVelocity | UNHCR displacement as state continuity signal | Lower is better | 7 - 0 | 0.20 | UNHCR | Annual |
State continuity is a derived dimension: it reads from existing WGI, UCDP, and displacement keys rather than a dedicated seeder.
#### Fuel Stock Days
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| recoveryFuelStockDays | Days of fuel stock cover (IEA Oil Stocks / EIA Weekly Petroleum Status) | Higher is better | 0 - 120 | 1.00 | IEA/EIA | Monthly |
Fuel stock days is an Enrichment-tier signal (coverage ~45 countries, IEA/OECD members). Countries without fuel stock data are imputed with the `unmonitored` class.
## Normalization
All indicators are normalized to a 0-100 scale using **goalpost scaling** (also called min-max normalization with domain-specific anchors).

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
import iso3ToIso2 from './shared/iso3-to-iso2.json' with { type: 'json' };
loadEnvFile(import.meta.url);
const WB_BASE = 'https://api.worldbank.org/v2';
const CANONICAL_KEY = 'resilience:recovery:external-debt:v1';
const CACHE_TTL = 35 * 24 * 3600;
const DEBT_INDICATOR = 'DT.DOD.DSTC.CD';
const RESERVES_INDICATOR = 'FI.RES.TOTL.CD';
async function fetchWbIndicator(indicator) {
const out = {};
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const url = `${WB_BASE}/country/all/indicator/${indicator}?format=json&per_page=500&page=${page}&mrnev=1`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`World Bank ${indicator}: HTTP ${resp.status}`);
const json = await resp.json();
const meta = json[0];
const records = json[1] ?? [];
totalPages = meta?.pages ?? 1;
for (const record of records) {
const rawCode = record?.countryiso3code ?? record?.country?.id ?? '';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
if (!iso2) continue;
const value = Number(record?.value);
if (!Number.isFinite(value)) continue;
out[iso2] = { value, year: Number(record?.date) || null };
}
page++;
}
return out;
}
async function fetchExternalDebt() {
const [debtMap, reservesMap] = await Promise.all([
fetchWbIndicator(DEBT_INDICATOR),
fetchWbIndicator(RESERVES_INDICATOR),
]);
const countries = {};
const allCodes = new Set([...Object.keys(debtMap), ...Object.keys(reservesMap)]);
for (const code of allCodes) {
const debt = debtMap[code];
const reserves = reservesMap[code];
if (!debt || !reserves || reserves.value <= 0) continue;
countries[code] = {
debtToReservesRatio: Math.round((debt.value / reserves.value) * 1000) / 1000,
year: debt.year ?? reserves.year ?? null,
};
}
return { countries, seededAt: new Date().toISOString() };
}
function validate(data) {
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 80;
}
if (process.argv[1]?.endsWith('seed-recovery-external-debt.mjs')) {
runSeed('resilience', 'recovery:external-debt', CANONICAL_KEY, fetchExternalDebt, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: `wb-debt-reserves-${new Date().getFullYear()}`,
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed, loadSharedConfig } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const IMF_BASE = 'https://www.imf.org/external/datamapper/api/v1';
const CANONICAL_KEY = 'resilience:recovery:fiscal-space:v1';
const CACHE_TTL = 35 * 24 * 3600;
const ISO2_TO_ISO3 = loadSharedConfig('iso2-to-iso3.json');
const ISO3_TO_ISO2 = Object.fromEntries(Object.entries(ISO2_TO_ISO3).map(([k, v]) => [v, k]));
const AGGREGATE_CODES = new Set([
'ADVEC', 'EMEDE', 'EURO', 'MECA', 'OEMDC', 'WEOWORLD', 'EU',
'AS5', 'DA', 'EDE', 'MAE', 'OAE', 'SSA', 'WE', 'EMDE', 'G20',
]);
function isAggregate(code) {
if (!code || code.length !== 3) return true;
return AGGREGATE_CODES.has(code) || code.endsWith('Q');
}
function weoYears() {
const y = new Date().getFullYear();
return [`${y}`, `${y - 1}`, `${y - 2}`];
}
async function fetchImfIndicator(indicator) {
const url = `${IMF_BASE}/${indicator}?periods=${weoYears().join(',')}`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`IMF ${indicator}: HTTP ${resp.status}`);
const data = await resp.json();
return data?.values?.[indicator] ?? {};
}
function latestValue(byYear) {
for (const year of weoYears()) {
const v = Number(byYear?.[year]);
if (Number.isFinite(v)) return { value: v, year: Number(year) };
}
return null;
}
async function fetchFiscalSpace() {
const [revenueData, balanceData, debtData] = await Promise.all([
fetchImfIndicator('GGR_G01_GDP_PT'),
fetchImfIndicator('GGXCNL_G01_GDP_PT'),
fetchImfIndicator('GGXWDG_NGDP_PT'),
]);
const countries = {};
const allIso3 = new Set([
...Object.keys(revenueData),
...Object.keys(balanceData),
...Object.keys(debtData),
]);
for (const iso3 of allIso3) {
if (isAggregate(iso3)) continue;
const iso2 = ISO3_TO_ISO2[iso3];
if (!iso2) continue;
const rev = latestValue(revenueData[iso3]);
const bal = latestValue(balanceData[iso3]);
const debt = latestValue(debtData[iso3]);
if (!rev && !bal && !debt) continue;
countries[iso2] = {
govRevenuePct: rev?.value ?? null,
fiscalBalancePct: bal?.value ?? null,
debtToGdpPct: debt?.value ?? null,
year: rev?.year ?? bal?.year ?? debt?.year ?? null,
};
}
return { countries, seededAt: new Date().toISOString() };
}
function validate(data) {
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 150;
}
if (process.argv[1]?.endsWith('seed-recovery-fiscal-space.mjs')) {
runSeed('resilience', 'recovery:fiscal-space', CANONICAL_KEY, fetchFiscalSpace, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: `imf-weo-fiscal-${new Date().getFullYear()}`,
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { loadEnvFile, runSeed } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'resilience:recovery:fuel-stocks:v1';
const CACHE_TTL = 35 * 24 * 3600;
async function fetchFuelStocks() {
console.log('[seed] fuel-stocks: STUB — IEA/EIA source not yet configured, writing empty payload');
return { countries: {}, seededAt: new Date().toISOString(), stub: true };
}
function validate() {
return true;
}
if (process.argv[1]?.endsWith('seed-recovery-fuel-stocks.mjs')) {
runSeed('resilience', 'recovery:fuel-stocks', CANONICAL_KEY, fetchFuelStocks, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'stub-v1',
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { loadEnvFile, runSeed } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'resilience:recovery:import-hhi:v1';
const CACHE_TTL = 35 * 24 * 3600;
async function fetchImportHhi() {
console.log('[seed] import-hhi: STUB — UN Comtrade HHI computation requires per-country bilateral queries with API key, writing empty payload');
return { countries: {}, seededAt: new Date().toISOString(), stub: true };
}
function validate() {
return true;
}
if (process.argv[1]?.endsWith('seed-recovery-import-hhi.mjs')) {
runSeed('resilience', 'recovery:import-hhi', CANONICAL_KEY, fetchImportHhi, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'stub-v1',
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
import iso3ToIso2 from './shared/iso3-to-iso2.json' with { type: 'json' };
loadEnvFile(import.meta.url);
const WB_BASE = 'https://api.worldbank.org/v2';
const CANONICAL_KEY = 'resilience:recovery:reserve-adequacy:v1';
const CACHE_TTL = 35 * 24 * 3600;
const INDICATOR = 'FI.RES.TOTL.MO';
async function fetchReserveAdequacy() {
const pages = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const url = `${WB_BASE}/country/all/indicator/${INDICATOR}?format=json&per_page=500&page=${page}&mrnev=1`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`World Bank ${INDICATOR}: HTTP ${resp.status}`);
const json = await resp.json();
const meta = json[0];
const records = json[1] ?? [];
totalPages = meta?.pages ?? 1;
pages.push(...records);
page++;
}
const countries = {};
for (const record of pages) {
const rawCode = record?.countryiso3code ?? record?.country?.id ?? '';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
if (!iso2) continue;
const value = Number(record?.value);
if (!Number.isFinite(value)) continue;
const year = Number(record?.date);
countries[iso2] = {
reserveMonths: value,
year: Number.isFinite(year) ? year : null,
};
}
return { countries, seededAt: new Date().toISOString() };
}
function validate(data) {
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 100;
}
if (process.argv[1]?.endsWith('seed-recovery-reserve-adequacy.mjs')) {
runSeed('resilience', 'recovery:reserve-adequacy', CANONICAL_KEY, fetchReserveAdequacy, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: `wb-reserves-${new Date().getFullYear()}`,
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -18,14 +18,21 @@ export type ResilienceDimensionId =
| 'borderSecurity'
| 'informationCognitive'
| 'healthPublicService'
| 'foodWater';
| 'foodWater'
| 'fiscalSpace'
| 'reserveAdequacy'
| 'externalDebtCoverage'
| 'importConcentration'
| 'stateContinuity'
| 'fuelStockDays';
export type ResilienceDomainId =
| 'economic'
| 'infrastructure'
| 'energy'
| 'social-governance'
| 'health-food';
| 'health-food'
| 'recovery';
export interface ResilienceDimensionScore {
score: number;
@@ -125,6 +132,12 @@ export const IMPUTE = {
bisEer: IMPUTATION.curated_list_absent,
bisCredit: IMPUTATION.curated_list_absent,
unhcrDisplacement: { score: 85, certaintyCoverage: 0.6, imputationClass: 'stable-absence' }, // crisis_monitoring_absent, displacement-specific
recoveryFiscalSpace: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' },
recoveryReserveAdequacy: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' },
recoveryExternalDebt: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' },
recoveryImportHhi: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' },
recoveryStateContinuity: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' },
recoveryFuelStocks: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' },
} as const satisfies Record<string, ImputationEntry>;
interface StaticIndicatorValue {
@@ -238,6 +251,12 @@ const RESILIENCE_NEWS_THREAT_SUMMARY_KEY = 'news:threat:summary:v1';
const RESILIENCE_ENERGY_PRICES_KEY = 'economic:energy:v1:all';
const RESILIENCE_ENERGY_MIX_KEY_PREFIX = 'energy:mix:v1:';
const RESILIENCE_RECOVERY_FISCAL_SPACE_KEY = 'resilience:recovery:fiscal-space:v1';
const RESILIENCE_RECOVERY_RESERVE_ADEQUACY_KEY = 'resilience:recovery:reserve-adequacy:v1';
const RESILIENCE_RECOVERY_EXTERNAL_DEBT_KEY = 'resilience:recovery:external-debt:v1';
const RESILIENCE_RECOVERY_IMPORT_HHI_KEY = 'resilience:recovery:import-hhi:v1';
const RESILIENCE_RECOVERY_FUEL_STOCKS_KEY = 'resilience:recovery:fuel-stocks:v1';
const COUNTRY_NAME_ALIASES = new Map<string, Set<string>>();
for (const [name, iso2] of Object.entries(countryNames as Record<string, string>)) {
const code = String(iso2 || '').toUpperCase();
@@ -255,6 +274,7 @@ const RESILIENCE_DOMAIN_WEIGHTS: Record<ResilienceDomainId, number> = {
energy: 0.15,
'social-governance': 0.25,
'health-food': 0.18,
recovery: 0,
};
export const RESILIENCE_DIMENSION_DOMAINS: Record<ResilienceDimensionId, ResilienceDomainId> = {
@@ -271,6 +291,12 @@ export const RESILIENCE_DIMENSION_DOMAINS: Record<ResilienceDimensionId, Resilie
informationCognitive: 'social-governance',
healthPublicService: 'health-food',
foodWater: 'health-food',
fiscalSpace: 'recovery',
reserveAdequacy: 'recovery',
externalDebtCoverage: 'recovery',
importConcentration: 'recovery',
stateContinuity: 'recovery',
fuelStockDays: 'recovery',
};
export const RESILIENCE_DIMENSION_ORDER: ResilienceDimensionId[] = [
@@ -287,6 +313,12 @@ export const RESILIENCE_DIMENSION_ORDER: ResilienceDimensionId[] = [
'informationCognitive',
'healthPublicService',
'foodWater',
'fiscalSpace',
'reserveAdequacy',
'externalDebtCoverage',
'importConcentration',
'stateContinuity',
'fuelStockDays',
];
export const RESILIENCE_DOMAIN_ORDER: ResilienceDomainId[] = [
@@ -295,6 +327,7 @@ export const RESILIENCE_DOMAIN_ORDER: ResilienceDomainId[] = [
'energy',
'social-governance',
'health-food',
'recovery',
];
export type ResilienceDimensionType = 'baseline' | 'stress' | 'mixed';
@@ -313,6 +346,12 @@ export const RESILIENCE_DIMENSION_TYPES: Record<ResilienceDimensionId, Resilienc
informationCognitive: 'stress',
healthPublicService: 'baseline',
foodWater: 'mixed',
fiscalSpace: 'baseline',
reserveAdequacy: 'baseline',
externalDebtCoverage: 'baseline',
importConcentration: 'baseline',
stateContinuity: 'baseline',
fuelStockDays: 'mixed',
};
function clamp(value: number, min: number, max: number): number {
@@ -1142,6 +1181,189 @@ export async function scoreFoodWater(
]);
}
interface RecoveryFiscalSpaceCountry {
govRevenuePct?: number | null;
fiscalBalancePct?: number | null;
debtToGdpPct?: number | null;
year?: number | null;
}
interface RecoveryReserveAdequacyCountry {
reserveMonths?: number | null;
year?: number | null;
}
interface RecoveryExternalDebtCountry {
debtToReservesRatio?: number | null;
year?: number | null;
}
interface RecoveryImportHhiCountry {
hhi?: number | null;
year?: number | null;
}
interface RecoveryFuelStocksCountry {
stockDays?: number | null;
year?: number | null;
}
function getRecoveryCountryEntry<T>(raw: unknown, countryCode: string): T | null {
const countries = (raw as { countries?: Record<string, T> } | null)?.countries;
if (!countries || typeof countries !== 'object') return null;
return (countries[countryCode.toUpperCase()] as T | undefined) ?? null;
}
export async function scoreFiscalSpace(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
const raw = await reader(RESILIENCE_RECOVERY_FISCAL_SPACE_KEY);
const entry = getRecoveryCountryEntry<RecoveryFiscalSpaceCountry>(raw, countryCode);
if (!entry) {
return {
score: IMPUTE.recoveryFiscalSpace.score,
coverage: IMPUTE.recoveryFiscalSpace.certaintyCoverage,
observedWeight: 0,
imputedWeight: 1,
imputationClass: IMPUTE.recoveryFiscalSpace.imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
return weightedBlend([
{ score: entry.govRevenuePct == null ? null : normalizeHigherBetter(entry.govRevenuePct, 5, 45), weight: 0.4 },
{ score: entry.fiscalBalancePct == null ? null : normalizeHigherBetter(entry.fiscalBalancePct, -15, 5), weight: 0.3 },
{ score: entry.debtToGdpPct == null ? null : normalizeLowerBetter(entry.debtToGdpPct, 0, 150), weight: 0.3 },
]);
}
export async function scoreReserveAdequacy(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
const raw = await reader(RESILIENCE_RECOVERY_RESERVE_ADEQUACY_KEY);
const entry = getRecoveryCountryEntry<RecoveryReserveAdequacyCountry>(raw, countryCode);
if (!entry || entry.reserveMonths == null) {
return {
score: IMPUTE.recoveryReserveAdequacy.score,
coverage: IMPUTE.recoveryReserveAdequacy.certaintyCoverage,
observedWeight: 0,
imputedWeight: 1,
imputationClass: IMPUTE.recoveryReserveAdequacy.imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
return weightedBlend([
{ score: normalizeHigherBetter(Math.min(entry.reserveMonths, 18), 1, 18), weight: 1.0 },
]);
}
export async function scoreExternalDebtCoverage(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
const raw = await reader(RESILIENCE_RECOVERY_EXTERNAL_DEBT_KEY);
const entry = getRecoveryCountryEntry<RecoveryExternalDebtCountry>(raw, countryCode);
if (!entry || entry.debtToReservesRatio == null) {
return {
score: IMPUTE.recoveryExternalDebt.score,
coverage: IMPUTE.recoveryExternalDebt.certaintyCoverage,
observedWeight: 0,
imputedWeight: 1,
imputationClass: IMPUTE.recoveryExternalDebt.imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
return weightedBlend([
{ score: normalizeLowerBetter(entry.debtToReservesRatio, 0, 5), weight: 1.0 },
]);
}
export async function scoreImportConcentration(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
const raw = await reader(RESILIENCE_RECOVERY_IMPORT_HHI_KEY);
const entry = getRecoveryCountryEntry<RecoveryImportHhiCountry>(raw, countryCode);
if (!entry || entry.hhi == null) {
return {
score: IMPUTE.recoveryImportHhi.score,
coverage: IMPUTE.recoveryImportHhi.certaintyCoverage,
observedWeight: 0,
imputedWeight: 1,
imputationClass: IMPUTE.recoveryImportHhi.imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
return weightedBlend([
{ score: normalizeLowerBetter(entry.hhi, 0, 5000), weight: 1.0 },
]);
}
export async function scoreStateContinuity(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
const [staticRecord, ucdpRaw, displacementRaw] = await Promise.all([
readStaticCountry(countryCode, reader),
reader(RESILIENCE_UCDP_KEY),
reader(`${RESILIENCE_DISPLACEMENT_PREFIX}:${new Date().getFullYear()}`),
]);
const wgiValues = getStaticWgiValues(staticRecord);
const wgiMean = mean(wgiValues);
const ucdpSummary = summarizeUcdp(ucdpRaw, countryCode);
const ucdpRawScore = ucdpSummary.eventCount * 2 + ucdpSummary.typeWeight + Math.sqrt(ucdpSummary.deaths);
const displacement = getCountryDisplacement(displacementRaw, countryCode);
const totalDisplaced = safeNum(displacement?.totalDisplaced);
if (wgiMean == null && ucdpSummary.eventCount === 0 && totalDisplaced == null) {
return {
score: IMPUTE.recoveryStateContinuity.score,
coverage: IMPUTE.recoveryStateContinuity.certaintyCoverage,
observedWeight: 0,
imputedWeight: 1,
imputationClass: IMPUTE.recoveryStateContinuity.imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
return weightedBlend([
{ score: wgiMean == null ? null : normalizeHigherBetter(wgiMean, -2.5, 2.5), weight: 0.5 },
{ score: normalizeLowerBetter(ucdpRawScore, 0, 30), weight: 0.3 },
{
score: totalDisplaced == null
? null
: normalizeLowerBetter(Math.log10(Math.max(1, totalDisplaced)), 0, 7),
weight: 0.2,
},
]);
}
export async function scoreFuelStockDays(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
const raw = await reader(RESILIENCE_RECOVERY_FUEL_STOCKS_KEY);
const entry = getRecoveryCountryEntry<RecoveryFuelStocksCountry>(raw, countryCode);
if (!entry || entry.stockDays == null) {
return {
score: IMPUTE.recoveryFuelStocks.score,
coverage: IMPUTE.recoveryFuelStocks.certaintyCoverage,
observedWeight: 0,
imputedWeight: 1,
imputationClass: IMPUTE.recoveryFuelStocks.imputationClass,
freshness: { lastObservedAtMs: 0, staleness: '' },
};
}
return weightedBlend([
{ score: normalizeHigherBetter(Math.min(entry.stockDays, 120), 0, 120), weight: 1.0 },
]);
}
export const RESILIENCE_DIMENSION_SCORERS: Record<
ResilienceDimensionId,
(countryCode: string, reader?: ResilienceSeedReader) => Promise<ResilienceDimensionScore>
@@ -1159,6 +1381,12 @@ ResilienceDimensionId,
informationCognitive: scoreInformationCognitive,
healthPublicService: scoreHealthPublicService,
foodWater: scoreFoodWater,
fiscalSpace: scoreFiscalSpace,
reserveAdequacy: scoreReserveAdequacy,
externalDebtCoverage: scoreExternalDebtCoverage,
importConcentration: scoreImportConcentration,
stateContinuity: scoreStateContinuity,
fuelStockDays: scoreFuelStockDays,
};
export async function scoreAllDimensions(
@@ -1174,7 +1402,7 @@ export async function scoreAllDimensions(
] as const),
),
// T1.5 propagation pass: aggregate freshness at the caller level so
// the 13 dimension scorers stay mechanical. We share the memoized
// the 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).

View File

@@ -752,4 +752,156 @@ export const INDICATOR_REGISTRY: IndicatorSpec[] = [
coverage: 188,
license: 'open-data',
},
// ── fiscalSpace (3 sub-metrics) ──────────────────────────────────────────
{
id: 'recoveryGovRevenue',
dimension: 'fiscalSpace',
description: 'Government revenue as % of GDP (IMF GGR_G01_GDP_PT); fiscal mobilization capacity for recovery',
direction: 'higherBetter',
goalposts: { worst: 5, best: 45 },
weight: 0.4,
sourceKey: 'resilience:recovery:fiscal-space:v1',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 190,
license: 'open-data',
},
{
id: 'recoveryFiscalBalance',
dimension: 'fiscalSpace',
description: 'General government net lending/borrowing as % of GDP (IMF GGXCNL_G01_GDP_PT); deficit signals reduced recovery firepower',
direction: 'higherBetter',
goalposts: { worst: -15, best: 5 },
weight: 0.3,
sourceKey: 'resilience:recovery:fiscal-space:v1',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 190,
license: 'open-data',
},
{
id: 'recoveryDebtToGdp',
dimension: 'fiscalSpace',
description: 'General government gross debt as % of GDP (IMF GGXWDG_NGDP_PT); high debt limits recovery borrowing capacity',
direction: 'lowerBetter',
goalposts: { worst: 150, best: 0 },
weight: 0.3,
sourceKey: 'resilience:recovery:fiscal-space:v1',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 190,
license: 'open-data',
},
// ── reserveAdequacy (1 sub-metric) ───────────────────────────────────────
{
id: 'recoveryReserveMonths',
dimension: 'reserveAdequacy',
description: 'Total reserves in months of imports (World Bank FI.RES.TOTL.MO); recovery buffer against external shocks',
direction: 'higherBetter',
goalposts: { worst: 1, best: 18 },
weight: 1.0,
sourceKey: 'resilience:recovery:reserve-adequacy:v1',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 188,
license: 'open-data',
},
// ── externalDebtCoverage (1 sub-metric) ──────────────────────────────────
{
id: 'recoveryDebtToReserves',
dimension: 'externalDebtCoverage',
description: 'Short-term external debt to reserves ratio (World Bank DT.DOD.DSTC.CD / FI.RES.TOTL.CD); values above 1 signal reserve inadequacy for debt service',
direction: 'lowerBetter',
goalposts: { worst: 5, best: 0 },
weight: 1.0,
sourceKey: 'resilience:recovery:external-debt:v1',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 185,
license: 'open-data',
},
// ── importConcentration (1 sub-metric) ───────────────────────────────────
{
id: 'recoveryImportHhi',
dimension: 'importConcentration',
description: 'Herfindahl-Hirschman Index of import partner concentration (UN Comtrade HS2 bilateral); higher HHI = more dependent on fewer partners = slower recovery if a key partner is disrupted',
direction: 'lowerBetter',
goalposts: { worst: 5000, best: 0 },
weight: 1.0,
sourceKey: 'resilience:recovery:import-hhi:v1',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 190,
license: 'public-domain',
},
// ── stateContinuity (3 sub-metrics, derived from existing keys) ──────────
{
id: 'recoveryWgiContinuity',
dimension: 'stateContinuity',
description: 'Mean WGI score as institutional durability proxy; higher governance = better state continuity under shock',
direction: 'higherBetter',
goalposts: { worst: -2.5, best: 2.5 },
weight: 0.5,
sourceKey: 'resilience:static:{ISO2}',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 214,
license: 'public-domain',
},
{
id: 'recoveryConflictPressure',
dimension: 'stateContinuity',
description: 'UCDP conflict metric inverted to state continuity; active conflict directly undermines state continuity',
direction: 'lowerBetter',
goalposts: { worst: 30, best: 0 },
weight: 0.3,
sourceKey: 'conflict:ucdp-events:v1',
scope: 'global',
cadence: 'realtime',
tier: 'core',
coverage: 193,
license: 'research-only',
},
{
id: 'recoveryDisplacementVelocity',
dimension: 'stateContinuity',
description: 'UNHCR displacement as state continuity signal; mass displacement signals state function breakdown',
direction: 'lowerBetter',
goalposts: { worst: 7, best: 0 },
weight: 0.2,
sourceKey: 'displacement:summary:v1:{year}',
scope: 'global',
cadence: 'annual',
tier: 'core',
coverage: 200,
license: 'open-data',
},
// ── fuelStockDays (1 sub-metric) ─────────────────────────────────────────
{
id: 'recoveryFuelStockDays',
dimension: 'fuelStockDays',
description: 'Days of fuel stock cover (IEA Oil Stocks / EIA Weekly Petroleum Status); strategic buffer for energy-dependent recovery',
direction: 'higherBetter',
goalposts: { worst: 0, best: 120 },
weight: 1.0,
sourceKey: 'resilience:recovery:fuel-stocks:v1',
scope: 'global',
cadence: 'monthly',
tier: 'enrichment',
coverage: 45,
license: 'open-data',
},
];

View File

@@ -4,7 +4,7 @@
// instead of the table default (stable-absence / unmonitored).
//
// This is the ONLY place in the resilience pipeline that distinguishes
// "country not in curated source" from "seed upstream is down". The 13
// "country not in curated source" from "seed upstream is down". The
// dimension scorers stay oblivious.
import type { ResilienceDimensionId, ResilienceSeedReader } from './_dimension-scorers';
@@ -28,7 +28,7 @@ export const DATASET_TO_DIMENSIONS: Readonly<Record<string, ReadonlyArray<Resili
// WGI (Worldwide Governance Indicators) drives the governance signal
// in governanceInstitutional (primary) and indirectly macroFiscal
// (fiscal institutional quality weight).
wgi: ['governanceInstitutional', 'macroFiscal'],
wgi: ['governanceInstitutional', 'macroFiscal', 'stateContinuity'],
// World Bank infrastructure indicators feed both the infrastructure
// dimension (primary) and logisticsSupply (paved roads sub-signal).
infrastructure: ['infrastructure', 'logisticsSupply'],

View File

@@ -76,6 +76,19 @@ export const LOCKED_PREVIEW: ResilienceScoreResponse = {
{ id: 'foodWater', score: 50, coverage: 0.85, observedWeight: 0.85, imputedWeight: 0.15, imputationClass: '', freshness: { lastObservedAtMs: LOCKED_PREVIEW_STALE_AT_MS, staleness: 'stale' } },
],
},
{
id: 'recovery',
score: 65,
weight: 1.0,
dimensions: [
{ id: 'fiscalSpace', score: 72, coverage: 0.9, observedWeight: 0.9, imputedWeight: 0.1, imputationClass: '', freshness: { lastObservedAtMs: LOCKED_PREVIEW_FRESH_AT_MS, staleness: 'fresh' } },
{ id: 'reserveAdequacy', score: 55, coverage: 0.85, observedWeight: 0.85, imputedWeight: 0.15, imputationClass: '', freshness: { lastObservedAtMs: LOCKED_PREVIEW_FRESH_AT_MS, staleness: 'fresh' } },
{ id: 'externalDebtCoverage', score: 60, coverage: 0.8, observedWeight: 0.8, imputedWeight: 0.2, imputationClass: '', freshness: { lastObservedAtMs: LOCKED_PREVIEW_FRESH_AT_MS, staleness: 'fresh' } },
{ id: 'importConcentration', score: 70, coverage: 0.75, observedWeight: 0.75, imputedWeight: 0.25, imputationClass: 'unmonitored', freshness: { lastObservedAtMs: LOCKED_PREVIEW_AGING_AT_MS, staleness: 'aging' } },
{ id: 'stateContinuity', score: 80, coverage: 0.92, observedWeight: 0.92, imputedWeight: 0.08, imputationClass: '', freshness: { lastObservedAtMs: LOCKED_PREVIEW_FRESH_AT_MS, staleness: 'fresh' } },
{ id: 'fuelStockDays', score: 50, coverage: 0.3, observedWeight: 0, imputedWeight: 1, imputationClass: 'unmonitored', freshness: { lastObservedAtMs: LOCKED_PREVIEW_STALE_AT_MS, staleness: 'stale' } },
],
},
],
trend: 'rising',
change30d: 2.4,
@@ -200,6 +213,12 @@ const DIMENSION_LABELS: Record<string, string> = {
informationCognitive: 'Info',
healthPublicService: 'Health',
foodWater: 'Food',
fiscalSpace: 'Fiscal',
reserveAdequacy: 'Reserves',
externalDebtCoverage: 'Ext Debt',
importConcentration: 'Imports',
stateContinuity: 'Continuity',
fuelStockDays: 'Fuel',
};
export function getResilienceDimensionLabel(dimensionId: string): string {

View File

@@ -381,6 +381,45 @@ export const RESILIENCE_FIXTURES: FixtureMap = {
fetchedAt: 1712102400000,
recordCount: 196,
},
'resilience:recovery:fiscal-space:v1': {
countries: {
NO: { govRevenuePct: 42, fiscalBalancePct: 10, debtToGdpPct: 40, year: 2025 },
US: { govRevenuePct: 30, fiscalBalancePct: -6, debtToGdpPct: 122, year: 2025 },
YE: { govRevenuePct: 8, fiscalBalancePct: -10, debtToGdpPct: 80, year: 2024 },
},
seededAt: '2026-04-04T00:00:00.000Z',
},
'resilience:recovery:reserve-adequacy:v1': {
countries: {
NO: { reserveMonths: 14, year: 2024 },
US: { reserveMonths: 3, year: 2024 },
YE: { reserveMonths: 0.5, year: 2023 },
},
seededAt: '2026-04-04T00:00:00.000Z',
},
'resilience:recovery:external-debt:v1': {
countries: {
NO: { debtToReservesRatio: 0.2, year: 2024 },
US: { debtToReservesRatio: 1.5, year: 2024 },
YE: { debtToReservesRatio: 4.0, year: 2023 },
},
seededAt: '2026-04-04T00:00:00.000Z',
},
'resilience:recovery:import-hhi:v1': {
countries: {
NO: { hhi: 300, year: 2024 },
US: { hhi: 600, year: 2024 },
YE: { hhi: 3500, year: 2023 },
},
seededAt: '2026-04-04T00:00:00.000Z',
},
'resilience:recovery:fuel-stocks:v1': {
countries: {
NO: { stockDays: 90, year: 2025 },
US: { stockDays: 60, year: 2025 },
},
seededAt: '2026-04-04T00:00:00.000Z',
},
};
export function fixtureReader(key: string): Promise<unknown | null> {

View File

@@ -409,5 +409,45 @@ export function buildReleaseGateFixtures(): ReleaseGateFixtureMap {
fixtures['intelligence:social:reddit:v1'] = { posts: socialPosts };
fixtures['news:threat:summary:v1'] = threatSummary;
const recoveryFiscalCountries: Record<string, Record<string, unknown>> = {};
const recoveryReserveCountries: Record<string, Record<string, unknown>> = {};
const recoveryDebtCountries: Record<string, Record<string, unknown>> = {};
const recoveryImportCountries: Record<string, Record<string, unknown>> = {};
const recoveryFuelCountries: Record<string, Record<string, unknown>> = {};
for (const descriptor of descriptors) {
const quality = qualityFor(descriptor.profile);
recoveryFiscalCountries[descriptor.code] = {
govRevenuePct: round(clamp(quality * 0.4 + 5, 5, 45), 1),
fiscalBalancePct: round(clamp(quality * 0.2 - 12, -15, 5), 1),
debtToGdpPct: round(clamp(200 - quality * 1.7, 15, 180), 1),
year: 2025,
};
recoveryReserveCountries[descriptor.code] = {
reserveMonths: round(clamp(quality * 0.16 + 0.5, 0.5, 18), 1),
year: 2024,
};
recoveryDebtCountries[descriptor.code] = {
debtToReservesRatio: round(clamp(5 - quality * 0.045, 0.1, 5), 3),
year: 2024,
};
recoveryImportCountries[descriptor.code] = {
hhi: Math.round(clamp(5500 - quality * 50, 100, 5000)),
year: 2024,
};
if (quality > 60) {
recoveryFuelCountries[descriptor.code] = {
stockDays: Math.round(clamp(quality * 1.1 + 10, 20, 120)),
year: 2025,
};
}
}
fixtures['resilience:recovery:fiscal-space:v1'] = { countries: recoveryFiscalCountries, seededAt: '2026-04-04T00:00:00.000Z' };
fixtures['resilience:recovery:reserve-adequacy:v1'] = { countries: recoveryReserveCountries, seededAt: '2026-04-04T00:00:00.000Z' };
fixtures['resilience:recovery:external-debt:v1'] = { countries: recoveryDebtCountries, seededAt: '2026-04-04T00:00:00.000Z' };
fixtures['resilience:recovery:import-hhi:v1'] = { countries: recoveryImportCountries, seededAt: '2026-04-04T00:00:00.000Z' };
fixtures['resilience:recovery:fuel-stocks:v1'] = { countries: recoveryFuelCountries, seededAt: '2026-04-04T00:00:00.000Z' };
return fixtures;
}

View File

@@ -18,8 +18,14 @@ import {
scoreInformationCognitive,
scoreInfrastructure,
scoreLogisticsSupply,
scoreExternalDebtCoverage,
scoreFiscalSpace,
scoreFuelStockDays,
scoreImportConcentration,
scoreMacroFiscal,
scoreReserveAdequacy,
scoreSocialCohesion,
scoreStateContinuity,
scoreTradeSanctions,
} from '../server/worldmonitor/resilience/v1/_dimension-scorers.ts';
import { RESILIENCE_FIXTURES, fixtureReader } from './helpers/resilience-fixtures.mts';
@@ -79,7 +85,7 @@ describe('resilience dimension scorers', () => {
assertOrdered('foodWater', foodWater.no.score, foodWater.us.score, foodWater.ye.score);
});
it('returns all 13 dimensions with bounded scores and coverage', async () => {
it('returns all 19 dimensions with bounded scores and coverage', async () => {
const dimensions = await scoreAllDimensions('US', fixtureReader);
assert.deepEqual(Object.keys(dimensions).sort(), [...RESILIENCE_DIMENSION_ORDER].sort());
@@ -1084,4 +1090,76 @@ describe('resilience source-failure aggregation (T1.7)', () => {
assert.equal(dims.currencyExternal.imputationClass, 'unmonitored',
`currencyExternal must keep unmonitored on healthy seed, got ${dims.currencyExternal.imputationClass}`);
});
it('produce plausible country ordering for the recovery-capacity dimensions', async () => {
const fiscal = await scoreTriple(scoreFiscalSpace);
const reserves = await scoreTriple(scoreReserveAdequacy);
const extDebt = await scoreTriple(scoreExternalDebtCoverage);
const importHhi = await scoreTriple(scoreImportConcentration);
const continuity = await scoreTriple(scoreStateContinuity);
assertOrdered('fiscalSpace', fiscal.no.score, fiscal.us.score, fiscal.ye.score);
assertOrdered('reserveAdequacy', reserves.no.score, reserves.us.score, reserves.ye.score);
assertOrdered('externalDebtCoverage', extDebt.no.score, extDebt.us.score, extDebt.ye.score);
assertOrdered('importConcentration', importHhi.no.score, importHhi.us.score, importHhi.ye.score);
assertOrdered('stateContinuity', continuity.no.score, continuity.us.score, continuity.ye.score);
});
it('scoreFiscalSpace: country with strong fiscal position scores high', async () => {
const no = await scoreFiscalSpace('NO', fixtureReader);
assert.ok(no.score > 70, `NO should score >70 with strong fiscal space, got ${no.score}`);
assert.ok(no.coverage > 0.8, `NO should have high coverage with all 3 metrics, got ${no.coverage}`);
assert.equal(no.imputationClass, null, 'real data must not carry imputation class');
});
it('scoreFiscalSpace: missing data returns unmonitored imputation', async () => {
const emptyReader = async (_key: string): Promise<unknown | null> => null;
const score = await scoreFiscalSpace('XX', emptyReader);
assert.equal(score.imputationClass, 'unmonitored');
assert.equal(score.observedWeight, 0);
assert.equal(score.imputedWeight, 1);
});
it('scoreReserveAdequacy: high reserves score well', async () => {
const no = await scoreReserveAdequacy('NO', fixtureReader);
assert.ok(no.score > 70, `NO with 14 months reserves should score >70, got ${no.score}`);
});
it('scoreExternalDebtCoverage: low debt-to-reserves ratio scores well', async () => {
const no = await scoreExternalDebtCoverage('NO', fixtureReader);
assert.ok(no.score > 90, `NO with ratio 0.2 should score >90, got ${no.score}`);
});
it('scoreImportConcentration: low HHI scores well', async () => {
const us = await scoreImportConcentration('US', fixtureReader);
assert.ok(us.score > 85, `US with HHI 400 should score >85, got ${us.score}`);
});
it('scoreStateContinuity: derives from existing WGI + UCDP + displacement', async () => {
const no = await scoreStateContinuity('NO', fixtureReader);
assert.ok(no.score > 70, `NO should score >70 on state continuity, got ${no.score}`);
assert.ok(no.observedWeight > 0, 'state continuity must have observed weight from WGI');
assert.equal(no.imputationClass, null, 'NO has real data, no imputation class');
});
it('scoreFuelStockDays: country with stock data scores based on coverage', async () => {
const no = await scoreFuelStockDays('NO', fixtureReader);
assert.ok(no.score > 60, `NO with 90 stock days should score >60, got ${no.score}`);
});
it('scoreFuelStockDays: country without fuel stock data returns unmonitored', async () => {
const ye = await scoreFuelStockDays('YE', fixtureReader);
assert.equal(ye.imputationClass, 'unmonitored');
assert.equal(ye.observedWeight, 0);
});
it('recovery domain is present in scoreAllDimensions output', async () => {
const dims = await scoreAllDimensions('US', fixtureReader);
assert.ok('fiscalSpace' in dims, 'fiscalSpace must be in scoreAllDimensions output');
assert.ok('reserveAdequacy' in dims, 'reserveAdequacy must be in scoreAllDimensions output');
assert.ok('externalDebtCoverage' in dims, 'externalDebtCoverage must be in scoreAllDimensions output');
assert.ok('importConcentration' in dims, 'importConcentration must be in scoreAllDimensions output');
assert.ok('stateContinuity' in dims, 'stateContinuity must be in scoreAllDimensions output');
assert.ok('fuelStockDays' in dims, 'fuelStockDays must be in scoreAllDimensions output');
});
});

View File

@@ -39,8 +39,8 @@ describe('resilience handlers', () => {
});
assert.equal(response.countryCode, 'US');
assert.equal(response.domains.length, 5);
assert.equal(response.domains.flatMap((domain) => domain.dimensions).length, 13);
assert.equal(response.domains.length, 6);
assert.equal(response.domains.flatMap((domain) => domain.dimensions).length, 19);
assert.ok(response.overallScore > 0 && response.overallScore <= 100);
assert.equal(response.level, response.overallScore >= 70 ? 'high' : response.overallScore >= 40 ? 'medium' : 'low');
assert.equal(response.trend, 'rising');

View File

@@ -6,12 +6,12 @@ import { INDICATOR_REGISTRY } from '../server/worldmonitor/resilience/v1/_indica
import type { IndicatorSpec } from '../server/worldmonitor/resilience/v1/_indicator-registry.ts';
describe('indicator registry', () => {
it('covers all 13 dimensions', () => {
it('covers all 19 dimensions', () => {
const coveredDimensions = new Set(INDICATOR_REGISTRY.map((i) => i.dimension));
for (const dimId of RESILIENCE_DIMENSION_ORDER) {
assert.ok(coveredDimensions.has(dimId), `${dimId} has no indicators in registry`);
}
assert.equal(coveredDimensions.size, 13);
assert.equal(coveredDimensions.size, 19);
});
it('has no duplicate indicator ids', () => {

View File

@@ -48,6 +48,8 @@ describe('signal tiering registry (Phase 2 T2.2a)', () => {
// UCDP global conflict events: research-only license, kept Core per
// parent plan section "Signal tiering". Tracked in Phase 2 A9.
'ucdpConflict',
// UCDP reused in recovery-capacity stateContinuity dimension.
'recoveryConflictPressure',
]);
const unexcused = offending.filter((s) => {
const id = s.split(' ')[0];

View File

@@ -46,6 +46,12 @@ const HEADING_TO_DIMENSION: Readonly<Record<string, ResilienceDimensionId>> = {
'Information & Cognitive': 'informationCognitive',
'Health & Public Service': 'healthPublicService',
'Food & Water': 'foodWater',
'Fiscal Space': 'fiscalSpace',
'Reserve Adequacy': 'reserveAdequacy',
'External Debt Coverage': 'externalDebtCoverage',
'Import Concentration': 'importConcentration',
'State Continuity': 'stateContinuity',
'Fuel Stock Days': 'fuelStockDays',
};
function findMethodologyFile(): string {

View File

@@ -48,11 +48,11 @@ function installRedisFixtures() {
}
describe('resilience release gate', () => {
it('keeps all 13 dimension scorers non-placeholder for the required countries', async () => {
it('keeps all 19 dimension scorers non-placeholder for the required countries', async () => {
for (const countryCode of REQUIRED_DIMENSION_COUNTRIES) {
const scores = await scoreAllDimensions(countryCode, fixtureReader);
const entries = Object.entries(scores);
assert.equal(entries.length, 13, `${countryCode} should have all resilience dimensions`);
assert.equal(entries.length, 19, `${countryCode} should have all resilience dimensions`);
for (const [dimensionId, score] of entries) {
assert.ok(Number.isFinite(score.score), `${countryCode} ${dimensionId} should produce a numeric score`);
assert.ok(score.coverage > 0, `${countryCode} ${dimensionId} should not fall back to zero-coverage placeholder scoring`);
@@ -234,7 +234,7 @@ describe('resilience release gate', () => {
);
const allDimensions = response.domains.flatMap((domain) => domain.dimensions);
assert.equal(allDimensions.length, 13, 'US response should carry all 13 dimensions');
assert.equal(allDimensions.length, 19, 'US response should carry all 19 dimensions');
for (const dimension of allDimensions) {
assert.equal(
typeof dimension.imputationClass,
@@ -262,7 +262,7 @@ describe('resilience release gate', () => {
);
const allDimensions = response.domains.flatMap((domain) => domain.dimensions);
assert.equal(allDimensions.length, 13, 'US response should carry all 13 dimensions');
assert.equal(allDimensions.length, 19, 'US response should carry all 19 dimensions');
const validLevels = ['', 'fresh', 'aging', 'stale'];
for (const dimension of allDimensions) {
assert.ok(dimension.freshness != null, `dimension ${dimension.id} must carry a freshness payload`);
@@ -335,7 +335,7 @@ describe('resilience release gate', () => {
assert.equal(typeof response.stressFactor, 'number');
assert.equal(typeof response.level, 'string');
assert.ok(Array.isArray(response.domains));
assert.equal(response.domains.length, 5, 'v1 shape keeps all 5 domains under the top-level domains[] field');
assert.equal(response.domains.length, 6, 'v1 shape keeps all 6 domains under the top-level domains[] field');
assert.equal(typeof response.imputationShare, 'number');
assert.equal(typeof response.lowConfidence, 'boolean');
assert.equal(typeof response.dataVersion, 'string');

View File

@@ -60,7 +60,11 @@ describe('resilience scorer contracts', () => {
// source-failure when the adapter is in seed-meta failedDatasets. This is the
// single source of truth for "no currency data"; null-imputationClass paths
// on non-real-data return branches are no longer permitted.
const coverageZeroExempt = new Set(['currencyExternal']);
const coverageZeroExempt = new Set([
'currencyExternal',
'fiscalSpace', 'reserveAdequacy', 'externalDebtCoverage',
'importConcentration', 'stateContinuity', 'fuelStockDays',
]);
for (const [dimensionId, scorer] of Object.entries(RESILIENCE_DIMENSION_SCORERS)) {
const result = await scorer('US');
assert.ok(result.score >= 0 && result.score <= 100, `${dimensionId} fallback score out of bounds: ${result.score}`);
@@ -94,6 +98,7 @@ describe('resilience scorer contracts', () => {
energy: 80,
'social-governance': 61.75,
'health-food': 60.5,
recovery: 54.83,
});
function round(v: number, d = 2) { return Number(v.toFixed(d)); }
@@ -121,9 +126,9 @@ describe('resilience scorer contracts', () => {
const stressScore = round(coverageWeightedMean(stressDims));
const stressFactor = round(Math.max(0, Math.min(1 - stressScore / 100, 0.5)), 4);
assert.equal(baselineScore, 67.85);
assert.equal(stressScore, 67.85);
assert.equal(stressFactor, 0.3215);
assert.equal(baselineScore, 62.23);
assert.equal(stressScore, 65.84);
assert.equal(stressFactor, 0.3416);
const overallScore = round(
RESILIENCE_DOMAIN_ORDER.map((domainId) => {

View File

@@ -78,29 +78,32 @@ describe('resilience source-failure module', () => {
});
describe('failedDimensionsFromDatasets', () => {
it('maps wgi to governanceInstitutional and macroFiscal', () => {
it('maps wgi to governanceInstitutional, macroFiscal, and stateContinuity', () => {
const affected = failedDimensionsFromDatasets(['wgi']);
assert.equal(affected.has('governanceInstitutional'), true);
assert.equal(affected.has('macroFiscal'), true);
assert.equal(affected.size, 2);
assert.equal(affected.has('stateContinuity'), true);
assert.equal(affected.size, 3);
});
it('deduplicates dimensions across multiple failed adapters', () => {
// wgi → {governanceInstitutional, macroFiscal}, gpi → {socialCohesion}.
// Union has 3 entries, no duplication because the adapters touch
// disjoint dimensions.
// Union has 4 entries, no duplication because the adapters touch
// disjoint dimensions (wgi -> 3 dims + gpi -> 1 dim).
const affected = failedDimensionsFromDatasets(['wgi', 'gpi']);
assert.equal(affected.size, 3);
assert.equal(affected.size, 4);
assert.equal(affected.has('governanceInstitutional'), true);
assert.equal(affected.has('macroFiscal'), true);
assert.equal(affected.has('stateContinuity'), true);
assert.equal(affected.has('socialCohesion'), true);
});
it('ignores unknown adapter keys without throwing', () => {
const affected = failedDimensionsFromDatasets(['not-a-real-adapter', 'wgi']);
assert.equal(affected.size, 2);
assert.equal(affected.size, 3);
assert.equal(affected.has('governanceInstitutional'), true);
assert.equal(affected.has('macroFiscal'), true);
assert.equal(affected.has('stateContinuity'), true);
});
it('returns an empty set for an empty input', () => {
@@ -137,6 +140,12 @@ describe('resilience source-failure module', () => {
'informationCognitive',
'healthPublicService',
'foodWater',
'fiscalSpace',
'reserveAdequacy',
'externalDebtCoverage',
'importConcentration',
'stateContinuity',
'fuelStockDays',
]);
for (const [adapter, dims] of Object.entries(DATASET_TO_DIMENSIONS)) {
for (const dim of dims) {

View File

@@ -139,7 +139,7 @@ test('baseResponse includes dataVersion (regression for T1.4 wiring)', () => {
// scorer dimension must have a stable display label and a consistent
// status classification.
test('getResilienceDimensionLabel returns short stable labels for all 13 dimensions', () => {
test('getResilienceDimensionLabel returns short stable labels for all 19 dimensions', () => {
assert.equal(getResilienceDimensionLabel('macroFiscal'), 'Macro');
assert.equal(getResilienceDimensionLabel('currencyExternal'), 'Currency');
assert.equal(getResilienceDimensionLabel('tradeSanctions'), 'Trade');
@@ -153,6 +153,12 @@ test('getResilienceDimensionLabel returns short stable labels for all 13 dimensi
assert.equal(getResilienceDimensionLabel('informationCognitive'), 'Info');
assert.equal(getResilienceDimensionLabel('healthPublicService'), 'Health');
assert.equal(getResilienceDimensionLabel('foodWater'), 'Food');
assert.equal(getResilienceDimensionLabel('fiscalSpace'), 'Fiscal');
assert.equal(getResilienceDimensionLabel('reserveAdequacy'), 'Reserves');
assert.equal(getResilienceDimensionLabel('externalDebtCoverage'), 'Ext Debt');
assert.equal(getResilienceDimensionLabel('importConcentration'), 'Imports');
assert.equal(getResilienceDimensionLabel('stateContinuity'), 'Continuity');
assert.equal(getResilienceDimensionLabel('fuelStockDays'), 'Fuel');
// Unknown dimension IDs fall through to the raw ID so the render
// never silently drops a row.
assert.equal(getResilienceDimensionLabel('unknownDim'), 'unknownDim');
@@ -272,9 +278,9 @@ test('collectDimensionConfidences returns an empty list for an empty response',
// representative card instead of a blank gap between the domain rows
// and the footer. If a future edit accidentally drops a dimension
// from the preview, this regression test fails loudly.
test('LOCKED_PREVIEW populates all 13 dimensions for the gated preview (PR #2949 review)', () => {
test('LOCKED_PREVIEW populates all 19 dimensions for the gated preview (PR #2949 review)', () => {
const all = collectDimensionConfidences(LOCKED_PREVIEW.domains);
assert.equal(all.length, 13, `locked preview should carry all 13 dimensions, got ${all.length}`);
assert.equal(all.length, 19, `locked preview should carry all 19 dimensions, got ${all.length}`);
// Every cell should resolve to a short label (no raw IDs leaking through).
for (const dim of all) {
assert.ok(

View File

@@ -0,0 +1,45 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const iso3ToIso2 = JSON.parse(readFileSync(join(__dirname, '..', 'scripts', 'shared', 'iso3-to-iso2.json'), 'utf8'));
describe('seed-recovery-external-debt ISO3→ISO2', () => {
it('iso3-to-iso2.json maps common WB API ISO3 codes to ISO2', () => {
assert.equal(iso3ToIso2['USA'], 'US');
assert.equal(iso3ToIso2['DEU'], 'DE');
assert.equal(iso3ToIso2['BRA'], 'BR');
assert.equal(iso3ToIso2['IND'], 'IN');
assert.equal(iso3ToIso2['ZAF'], 'ZA');
});
it('normalizes ISO3 countryiso3code from WB response to ISO2', () => {
const rawCode = 'DEU';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
assert.equal(iso2, 'DE');
});
it('passes through already-ISO2 codes', () => {
const rawCode = 'DE';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
assert.equal(iso2, 'DE');
});
it('rejects codes that are neither ISO2 nor ISO3', () => {
for (const bad of ['', 'X', 'ABCD']) {
const iso2 = bad.length === 3 ? (iso3ToIso2[bad] ?? null) : (bad.length === 2 ? bad : null);
assert.equal(iso2, null, `"${bad}" should be rejected`);
}
});
it('rejects WB aggregate codes (e.g. WLD, EAS) that have no ISO2 mapping', () => {
const aggregates = ['WLD', 'EAS', 'ECS', 'LCN', 'MEA', 'SAS', 'SSF'];
for (const agg of aggregates) {
const iso2 = agg.length === 3 ? (iso3ToIso2[agg] ?? null) : null;
assert.equal(iso2, null, `WB aggregate "${agg}" should not map to ISO2`);
}
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const iso3ToIso2 = JSON.parse(readFileSync(join(__dirname, '..', 'scripts', 'shared', 'iso3-to-iso2.json'), 'utf8'));
describe('seed-recovery-reserve-adequacy ISO3→ISO2', () => {
it('iso3-to-iso2.json maps common WB API ISO3 codes to ISO2', () => {
assert.equal(iso3ToIso2['USA'], 'US');
assert.equal(iso3ToIso2['DEU'], 'DE');
assert.equal(iso3ToIso2['GBR'], 'GB');
assert.equal(iso3ToIso2['JPN'], 'JP');
assert.equal(iso3ToIso2['CHN'], 'CN');
});
it('normalizes ISO3 countryiso3code from WB response to ISO2', () => {
const rawCode = 'USA';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
assert.equal(iso2, 'US');
});
it('passes through already-ISO2 codes', () => {
const rawCode = 'US';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
assert.equal(iso2, 'US');
});
it('rejects codes that are neither ISO2 nor ISO3', () => {
for (const bad of ['', 'X', 'ABCD', '1A']) {
const iso2 = bad.length === 3 ? (iso3ToIso2[bad] ?? null) : (bad.length === 2 ? bad : null);
if (bad.length === 2) {
assert.ok(iso2, `2-char code "${bad}" should pass through`);
} else {
assert.equal(iso2, null, `"${bad}" should be rejected`);
}
}
});
it('rejects WB aggregate codes (e.g. WLD, EAS) that have no ISO2 mapping', () => {
const aggregates = ['WLD', 'EAS', 'ECS', 'LCN', 'MEA', 'SAS', 'SSF'];
for (const agg of aggregates) {
const iso2 = agg.length === 3 ? (iso3ToIso2[agg] ?? null) : null;
assert.equal(iso2, null, `WB aggregate "${agg}" should not map to ISO2`);
}
});
});