mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
82
scripts/seed-recovery-external-debt.mjs
Normal file
82
scripts/seed-recovery-external-debt.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
98
scripts/seed-recovery-fiscal-space.mjs
Normal file
98
scripts/seed-recovery-fiscal-space.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
30
scripts/seed-recovery-fuel-stocks.mjs
Normal file
30
scripts/seed-recovery-fuel-stocks.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
30
scripts/seed-recovery-import-hhi.mjs
Normal file
30
scripts/seed-recovery-import-hhi.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
66
scripts/seed-recovery-reserve-adequacy.mjs
Normal file
66
scripts/seed-recovery-reserve-adequacy.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
45
tests/seed-recovery-external-debt.test.mjs
Normal file
45
tests/seed-recovery-external-debt.test.mjs
Normal 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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
49
tests/seed-recovery-reserve-adequacy.test.mjs
Normal file
49
tests/seed-recovery-reserve-adequacy.test.mjs
Normal 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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user