diff --git a/api/health.js b/api/health.js index 7226b349a..c1195ac90 100644 --- a/api/health.js +++ b/api/health.js @@ -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. diff --git a/docs/methodology/country-resilience-index.mdx b/docs/methodology/country-resilience-index.mdx index d47449f58..1689775a4 100644 --- a/docs/methodology/country-resilience-index.mdx +++ b/docs/methodology/country-resilience-index.mdx @@ -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). diff --git a/scripts/seed-recovery-external-debt.mjs b/scripts/seed-recovery-external-debt.mjs new file mode 100644 index 000000000..77832f183 --- /dev/null +++ b/scripts/seed-recovery-external-debt.mjs @@ -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); + }); +} diff --git a/scripts/seed-recovery-fiscal-space.mjs b/scripts/seed-recovery-fiscal-space.mjs new file mode 100644 index 000000000..36741da25 --- /dev/null +++ b/scripts/seed-recovery-fiscal-space.mjs @@ -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); + }); +} diff --git a/scripts/seed-recovery-fuel-stocks.mjs b/scripts/seed-recovery-fuel-stocks.mjs new file mode 100644 index 000000000..75ec30be8 --- /dev/null +++ b/scripts/seed-recovery-fuel-stocks.mjs @@ -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); + }); +} diff --git a/scripts/seed-recovery-import-hhi.mjs b/scripts/seed-recovery-import-hhi.mjs new file mode 100644 index 000000000..088b1d11f --- /dev/null +++ b/scripts/seed-recovery-import-hhi.mjs @@ -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); + }); +} diff --git a/scripts/seed-recovery-reserve-adequacy.mjs b/scripts/seed-recovery-reserve-adequacy.mjs new file mode 100644 index 000000000..a1a099318 --- /dev/null +++ b/scripts/seed-recovery-reserve-adequacy.mjs @@ -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); + }); +} diff --git a/server/worldmonitor/resilience/v1/_dimension-scorers.ts b/server/worldmonitor/resilience/v1/_dimension-scorers.ts index d91305aaa..6eaf6b21d 100644 --- a/server/worldmonitor/resilience/v1/_dimension-scorers.ts +++ b/server/worldmonitor/resilience/v1/_dimension-scorers.ts @@ -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; 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>(); for (const [name, iso2] of Object.entries(countryNames as Record)) { const code = String(iso2 || '').toUpperCase(); @@ -255,6 +274,7 @@ const RESILIENCE_DOMAIN_WEIGHTS: Record = { energy: 0.15, 'social-governance': 0.25, 'health-food': 0.18, + recovery: 0, }; export const RESILIENCE_DIMENSION_DOMAINS: Record = { @@ -271,6 +291,12 @@ export const RESILIENCE_DIMENSION_DOMAINS: Record(raw: unknown, countryCode: string): T | null { + const countries = (raw as { countries?: Record } | 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 { + const raw = await reader(RESILIENCE_RECOVERY_FISCAL_SPACE_KEY); + const entry = getRecoveryCountryEntry(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 { + const raw = await reader(RESILIENCE_RECOVERY_RESERVE_ADEQUACY_KEY); + const entry = getRecoveryCountryEntry(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 { + const raw = await reader(RESILIENCE_RECOVERY_EXTERNAL_DEBT_KEY); + const entry = getRecoveryCountryEntry(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 { + const raw = await reader(RESILIENCE_RECOVERY_IMPORT_HHI_KEY); + const entry = getRecoveryCountryEntry(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 { + 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 { + const raw = await reader(RESILIENCE_RECOVERY_FUEL_STOCKS_KEY); + const entry = getRecoveryCountryEntry(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 @@ -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:` 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). diff --git a/server/worldmonitor/resilience/v1/_indicator-registry.ts b/server/worldmonitor/resilience/v1/_indicator-registry.ts index 8d8dcdb10..4e65d322c 100644 --- a/server/worldmonitor/resilience/v1/_indicator-registry.ts +++ b/server/worldmonitor/resilience/v1/_indicator-registry.ts @@ -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', + }, ]; diff --git a/server/worldmonitor/resilience/v1/_source-failure.ts b/server/worldmonitor/resilience/v1/_source-failure.ts index 9ffed5ae4..2c7f20acc 100644 --- a/server/worldmonitor/resilience/v1/_source-failure.ts +++ b/server/worldmonitor/resilience/v1/_source-failure.ts @@ -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 = { 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 { diff --git a/tests/helpers/resilience-fixtures.mts b/tests/helpers/resilience-fixtures.mts index c3e65885b..d7ae651cf 100644 --- a/tests/helpers/resilience-fixtures.mts +++ b/tests/helpers/resilience-fixtures.mts @@ -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 { diff --git a/tests/helpers/resilience-release-fixtures.mts b/tests/helpers/resilience-release-fixtures.mts index f3ac11517..da043af4f 100644 --- a/tests/helpers/resilience-release-fixtures.mts +++ b/tests/helpers/resilience-release-fixtures.mts @@ -409,5 +409,45 @@ export function buildReleaseGateFixtures(): ReleaseGateFixtureMap { fixtures['intelligence:social:reddit:v1'] = { posts: socialPosts }; fixtures['news:threat:summary:v1'] = threatSummary; + const recoveryFiscalCountries: Record> = {}; + const recoveryReserveCountries: Record> = {}; + const recoveryDebtCountries: Record> = {}; + const recoveryImportCountries: Record> = {}; + const recoveryFuelCountries: Record> = {}; + + 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; } diff --git a/tests/resilience-dimension-scorers.test.mts b/tests/resilience-dimension-scorers.test.mts index 3731c62b0..d41d37030 100644 --- a/tests/resilience-dimension-scorers.test.mts +++ b/tests/resilience-dimension-scorers.test.mts @@ -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 => 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'); + }); }); diff --git a/tests/resilience-handlers.test.mts b/tests/resilience-handlers.test.mts index b8578906a..f7fdc1a72 100644 --- a/tests/resilience-handlers.test.mts +++ b/tests/resilience-handlers.test.mts @@ -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'); diff --git a/tests/resilience-indicator-registry.test.mts b/tests/resilience-indicator-registry.test.mts index 194f16e3f..d55b5bfa0 100644 --- a/tests/resilience-indicator-registry.test.mts +++ b/tests/resilience-indicator-registry.test.mts @@ -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', () => { diff --git a/tests/resilience-indicator-tiering.test.mts b/tests/resilience-indicator-tiering.test.mts index e250ac0f6..4b62082ff 100644 --- a/tests/resilience-indicator-tiering.test.mts +++ b/tests/resilience-indicator-tiering.test.mts @@ -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]; diff --git a/tests/resilience-methodology-lint.test.mts b/tests/resilience-methodology-lint.test.mts index d9938bb23..f3138b9f8 100644 --- a/tests/resilience-methodology-lint.test.mts +++ b/tests/resilience-methodology-lint.test.mts @@ -46,6 +46,12 @@ const HEADING_TO_DIMENSION: Readonly> = { '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 { diff --git a/tests/resilience-release-gate.test.mts b/tests/resilience-release-gate.test.mts index 6309a6db3..3f94d69bb 100644 --- a/tests/resilience-release-gate.test.mts +++ b/tests/resilience-release-gate.test.mts @@ -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'); diff --git a/tests/resilience-scorers.test.mts b/tests/resilience-scorers.test.mts index 433890d76..b571254d9 100644 --- a/tests/resilience-scorers.test.mts +++ b/tests/resilience-scorers.test.mts @@ -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) => { diff --git a/tests/resilience-source-failure.test.mts b/tests/resilience-source-failure.test.mts index 91cf6daf9..e12238d0c 100644 --- a/tests/resilience-source-failure.test.mts +++ b/tests/resilience-source-failure.test.mts @@ -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) { diff --git a/tests/resilience-widget.test.mts b/tests/resilience-widget.test.mts index 61fd1e3c2..be86c279c 100644 --- a/tests/resilience-widget.test.mts +++ b/tests/resilience-widget.test.mts @@ -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( diff --git a/tests/seed-recovery-external-debt.test.mjs b/tests/seed-recovery-external-debt.test.mjs new file mode 100644 index 000000000..2180a150e --- /dev/null +++ b/tests/seed-recovery-external-debt.test.mjs @@ -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`); + } + }); +}); diff --git a/tests/seed-recovery-reserve-adequacy.test.mjs b/tests/seed-recovery-reserve-adequacy.test.mjs new file mode 100644 index 000000000..29824e5bd --- /dev/null +++ b/tests/seed-recovery-reserve-adequacy.test.mjs @@ -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`); + } + }); +});