feat(resilience): add tradeToGdpPct to resilience static bundle (#2794)

* feat(seed): add tradeToGdpPct to resilience static bundle from WB NE.TRD.GNFS.ZS

Trade as % of GDP is needed for Phase 2 exposure-weighting of shipping
stress. Small open economies (Singapore ~300%, Belgium ~170%) will feel
shipping disruption more than large autarkies (US ~25%).

* fix(seed): bump static source version to v2 for tradeToGdp backfill

The static seeder skips re-runs within the same seed year if a snapshot
exists. Without a version bump, the new tradeToGdpPct field would never
backfill for countries already seeded in 2026.

Also added sourceVersion check to shouldSkipSeedYear() so future version
bumps automatically force a re-seed without needing to clear Redis.
This commit is contained in:
Elie Habib
2026-04-07 22:26:09 +04:00
committed by GitHub
parent e4a4705069
commit 023e2e60e7
4 changed files with 70 additions and 11 deletions

View File

@@ -29,13 +29,13 @@ export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1';
export const RESILIENCE_STATIC_META_KEY = 'seed-meta:resilience:static';
export const RESILIENCE_STATIC_PREFIX = 'resilience:static:';
export const RESILIENCE_STATIC_TTL_SECONDS = 400 * 24 * 60 * 60;
export const RESILIENCE_STATIC_SOURCE_VERSION = 'resilience-static-v1';
export const RESILIENCE_STATIC_SOURCE_VERSION = 'resilience-static-v2';
export const RESILIENCE_STATIC_WINDOW_CRON = '0 */4 1-3 10 *';
const LOCK_DOMAIN = 'resilience:static';
const LOCK_TTL_MS = 2 * 60 * 60 * 1000;
const TOTAL_DATASET_SLOTS = 8;
const COUNTRY_DATASET_FIELDS = ['wgi', 'infrastructure', 'gpi', 'rsf', 'who', 'fao', 'aquastat', 'iea'];
const TOTAL_DATASET_SLOTS = 9;
const COUNTRY_DATASET_FIELDS = ['wgi', 'infrastructure', 'gpi', 'rsf', 'who', 'fao', 'aquastat', 'iea', 'tradeToGdp'];
const WGI_INDICATORS = ['VA.EST', 'PV.EST', 'GE.EST', 'RQ.EST', 'RL.EST', 'CC.EST'];
const INFRASTRUCTURE_INDICATORS = ['EG.ELC.ACCS.ZS', 'IS.ROD.PAVE.ZS', 'EG.USE.ELEC.KH.PC'];
const WHO_INDICATORS = {
@@ -63,6 +63,7 @@ export function shouldSkipSeedYear(meta, seedYear = nowSeedYear()) {
return Boolean(
meta
&& meta.status === 'ok'
&& meta.sourceVersion === RESILIENCE_STATIC_SOURCE_VERSION
&& Number(meta.seedYear) === seedYear
&& Number.isFinite(Number(meta.recordCount))
&& Number(meta.recordCount) > 0,
@@ -640,6 +641,27 @@ async function fetchAquastatDataset() {
return buildAquastatWbMap(latest);
}
const WB_TRADE_TO_GDP_INDICATOR = 'NE.TRD.GNFS.ZS';
export function buildTradeToGdpMap(latestByCountry) {
const byCountry = new Map();
for (const [iso2, entry] of latestByCountry.entries()) {
byCountry.set(iso2, {
source: 'worldbank',
tradeToGdpPct: entry.value,
year: entry.year,
});
}
if (byCountry.size === 0) throw new Error('World Bank trade-to-GDP returned no usable rows');
return byCountry;
}
async function fetchTradeToGdpDataset() {
const rows = await fetchWorldBankIndicatorRows(WB_TRADE_TO_GDP_INDICATOR, { mrv: '12' });
const latest = selectLatestWorldBankByCountry(rows);
return buildTradeToGdpMap(latest);
}
export function finalizeCountryPayloads(datasetMaps, seedYear = nowSeedYear(), seededAt = new Date().toISOString()) {
const merged = new Map();
@@ -770,6 +792,7 @@ async function fetchAllDatasetMaps() {
{ key: 'fao', fetcher: fetchFsinDataset },
{ key: 'aquastat', fetcher: fetchAquastatDataset },
{ key: 'iea', fetcher: fetchEnergyDependencyDataset },
{ key: 'tradeToGdp', fetcher: fetchTradeToGdpDataset },
];
const results = await Promise.allSettled(adapters.map((adapter) => adapter.fetcher()));

View File

@@ -31,6 +31,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = {
fao: { peopleInCrisis: 10, phase: 'IPC Phase 1', year: 2025 },
aquastat: { indicator: 'Renewable water availability', value: 4000, year: 2024 },
iea: { energyImportDependency: { value: 15, year: 2024, source: 'IEA' } },
tradeToGdp: { source: 'worldbank', tradeToGdpPct: 70, year: 2023 },
},
'resilience:static:US': {
wgi: {
@@ -62,6 +63,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = {
fao: { peopleInCrisis: 5000, phase: 'IPC Phase 2', year: 2025 },
aquastat: { indicator: 'Renewable water availability', value: 1500, year: 2024 },
iea: { energyImportDependency: { value: 25, year: 2024, source: 'IEA' } },
tradeToGdp: { source: 'worldbank', tradeToGdpPct: 25, year: 2023 },
},
'resilience:static:YE': {
wgi: {
@@ -93,6 +95,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = {
fao: { peopleInCrisis: 2_000_000, phase: 'IPC Phase 4', year: 2025 },
aquastat: { indicator: 'Water stress', value: 85, year: 2024 },
iea: { energyImportDependency: { value: 95, year: 2024, source: 'IEA' } },
tradeToGdp: { source: 'worldbank', tradeToGdpPct: 30, year: 2023 },
},
'energy:mix:v1:NO': {
iso2: 'NO',
@@ -333,6 +336,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = {
fao: { peopleInCrisis: 1_500_000, phase: 'IPC Phase 3', year: 2025 },
aquastat: { indicator: 'Water stress', value: 72, year: 2024 },
iea: null, // Eurostat is EU-only — Lebanon absent → energy import dependency unknown
tradeToGdp: { source: 'worldbank', tradeToGdpPct: 95, year: 2023 },
},
'energy:mix:v1:LB': {
iso2: 'LB',
@@ -354,7 +358,7 @@ export const RESILIENCE_FIXTURES: FixtureMap = {
failedDatasets: [],
seedYear: 2025,
seededAt: '2026-04-03T00:00:00.000Z',
sourceVersion: 'resilience-static-v1',
sourceVersion: 'resilience-static-v2',
},
};

View File

@@ -236,7 +236,7 @@ export function buildReleaseGateFixtures(): ReleaseGateFixtureMap {
failedDatasets: [],
seedYear: 2025,
seededAt: '2026-04-04T00:00:00.000Z',
sourceVersion: 'resilience-static-v1',
sourceVersion: 'resilience-static-v2',
},
'supply_chain:shipping_stress:v1': { stressScore: 18 },
'supply_chain:transit-summaries:v1': {

View File

@@ -7,8 +7,10 @@ import { fileURLToPath } from 'node:url';
import {
RESILIENCE_STATIC_INDEX_KEY,
RESILIENCE_STATIC_META_KEY,
RESILIENCE_STATIC_SOURCE_VERSION,
buildFailureRefreshKeys,
buildManifest,
buildTradeToGdpMap,
countryRedisKey,
createCountryResolvers,
finalizeCountryPayloads,
@@ -210,6 +212,28 @@ describe('resilience static seed CSV parsers', () => {
assert.ok(result.get('DE')?.indicator?.includes('stress'), 'indicator must include "stress" to route correctly in scoreAquastatValue()');
});
});
describe('buildTradeToGdpMap', () => {
it('produces { source, tradeToGdpPct, year } shape for known countries', () => {
const input = new Map([
['NO', { value: 70.5, year: 2023 }],
['US', { value: 25.3, year: 2023 }],
['SG', { value: 318.2, year: 2023 }],
]);
const result = buildTradeToGdpMap(input);
assert.equal(result.size, 3);
const no = result.get('NO');
assert.ok(no != null);
assert.equal(no.source, 'worldbank');
assert.equal(no.tradeToGdpPct, 70.5);
assert.equal(no.year, 2023);
assert.equal(result.get('SG')?.tradeToGdpPct, 318.2);
});
it('throws when input map is empty', () => {
assert.throws(() => buildTradeToGdpMap(new Map()), /no usable rows/);
});
});
});
describe('resilience static seed parsers', () => {
@@ -290,6 +314,9 @@ describe('resilience static seed payload assembly', () => {
iea: new Map([
['NO', { source: 'eurostat-nrg_ind_id', energyImportDependency: { value: -13.3, year: 2024, source: 'eurostat' } }],
]),
tradeToGdp: new Map([
['NO', { source: 'worldbank', tradeToGdpPct: 70.5, year: 2023 }],
]),
}, 2026, '2026-04-03T12:00:00.000Z');
assert.deepEqual([...payloads.keys()].sort(), ['NO', 'US', 'YE']);
@@ -303,7 +330,8 @@ describe('resilience static seed payload assembly', () => {
fao: null,
aquastat: null,
iea: { source: 'eurostat-nrg_ind_id', energyImportDependency: { value: -13.3, year: 2024, source: 'eurostat' } },
coverage: { availableDatasets: 3, totalDatasets: 8, ratio: 0.375 },
tradeToGdp: { source: 'worldbank', tradeToGdpPct: 70.5, year: 2023 },
coverage: { availableDatasets: 4, totalDatasets: 9, ratio: 0.444 },
seedYear: 2026,
seededAt: '2026-04-03T12:00:00.000Z',
});
@@ -326,7 +354,7 @@ describe('resilience static seed payload assembly', () => {
failedDatasets: ['aquastat', 'gpi'],
seedYear: 2026,
seededAt: '2026-04-03T12:00:00.000Z',
sourceVersion: 'resilience-static-v1',
sourceVersion: RESILIENCE_STATIC_SOURCE_VERSION,
});
assert.deepEqual(buildFailureRefreshKeys(manifest), [
@@ -338,10 +366,13 @@ describe('resilience static seed payload assembly', () => {
]);
});
it('skips reruns only after a successful snapshot for the same seed year', () => {
assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150 }, 2026), true);
assert.equal(shouldSkipSeedYear({ status: 'error', seedYear: 2026, recordCount: 150 }, 2026), false);
assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2025, recordCount: 150 }, 2026), false);
it('skips reruns only after a successful snapshot for the same seed year and source version', () => {
const v = RESILIENCE_STATIC_SOURCE_VERSION;
assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150, sourceVersion: v }, 2026), true);
assert.equal(shouldSkipSeedYear({ status: 'error', seedYear: 2026, recordCount: 150, sourceVersion: v }, 2026), false);
assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2025, recordCount: 150, sourceVersion: v }, 2026), false);
assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150, sourceVersion: 'resilience-static-v1' }, 2026), false);
assert.equal(shouldSkipSeedYear({ status: 'ok', seedYear: 2026, recordCount: 150 }, 2026), false);
});
});
@@ -358,6 +389,7 @@ describe('recoverFailedDatasets', () => {
wgi: new Map([['YE', { source: 'worldbank-wgi' }]]),
infrastructure: new Map(), gpi: new Map(), rsf: new Map(),
who: new Map(), fao: faoOverride, aquastat: new Map(), iea: new Map(),
tradeToGdp: new Map(),
};
}