mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(resilience): recovery seeder bundle + de-stub import-HHI and fuel-stocks (#2999)
* feat(resilience): recovery seeder bundle + de-stub import-HHI and fuel-stocks - Add seed-bundle-resilience-recovery.mjs orchestrating all 5 recovery seeders (fiscal-space, reserve-adequacy, external-debt, import-HHI, fuel-stocks) with 30-day intervals and freshness-gated skipping via _bundle-runner - Replace import-HHI stub with real Comtrade HS2 HHI computation: fetches per-country bilateral import data, computes Herfindahl-Hirschman Index, flags concentrated importers (HHI > 0.25), 90-day TTL (3x interval) - Replace fuel-stocks stub with IEA-derived implementation: reads existing energy:iea-oil-stocks:v1:index Redis key, computes fuel-stock-days per country, flags 90-day IEA obligation compliance, avoids duplicate API calls - Add tests: HHI computation (8 cases), fuel-stock-days derivation (6 cases), bundle smoke test (13 cases verifying entries, labels, scripts on disk) * fix(resilience): remove duplicate RESILIENCE_SCHEMA_V2_ENABLED declaration Pre-existing typecheck:api error from duplicate const export in _shared.ts. Keep the v2-enabled-by-default variant. * fix(resilience): Comtrade HHI seeder P1 fixes (#2999 review) 1. Removed fallback to public preview API (sends partnerCode='' which returns zero rows). COMTRADE_API_KEY is now required. Logs an error and returns empty if missing. Pinned period to previous year to avoid multi-year row duplication. 2. computeHhi() now aggregates values by partner (Map) before computing shares. Previously it computed per-row shares, so a partner appearing in N commodity rows was counted N times, understating concentration. Returns { hhi, partnerCount } instead of a bare number. Added 2 new test cases covering multi-row and multi-year aggregation. * fix(resilience): use COMTRADE_API_KEYS (plural) with key rotation * fix(resilience): omit partnerCode to fetch all bilateral partners (not world aggregate) * fix(resilience): HHI scale conversion + fuel-stocks field name (#2999 P1) 1. HHI seeder writes 0..1 scale but scorer expected 0..5000. Added *10000 conversion before normalizeLowerBetter so a concentrated importer at hhi=0.55 maps to 5500 (above the 5000 goalpost, scores low) instead of scoring near-perfect. 2. Fuel-stocks seeder writes fuelStockDays but scorer read stockDays. Updated the RecoveryFuelStocksCountry interface and both read sites to match the seeder field name. * fix(resilience): retry warning + fuel-stocks flag consistency (#2999 P2)
This commit is contained in:
10
scripts/seed-bundle-resilience-recovery.mjs
Normal file
10
scripts/seed-bundle-resilience-recovery.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { runBundle, DAY } from './_bundle-runner.mjs';
|
||||
|
||||
await runBundle('resilience-recovery', [
|
||||
{ label: 'Fiscal-Space', script: 'seed-recovery-fiscal-space.mjs', seedMetaKey: 'resilience:recovery:fiscal-space', intervalMs: 30 * DAY, timeoutMs: 300_000 },
|
||||
{ label: 'Reserve-Adequacy', script: 'seed-recovery-reserve-adequacy.mjs', seedMetaKey: 'resilience:recovery:reserve-adequacy', intervalMs: 30 * DAY, timeoutMs: 300_000 },
|
||||
{ label: 'External-Debt', script: 'seed-recovery-external-debt.mjs', seedMetaKey: 'resilience:recovery:external-debt', intervalMs: 30 * DAY, timeoutMs: 300_000 },
|
||||
{ label: 'Import-HHI', script: 'seed-recovery-import-hhi.mjs', seedMetaKey: 'resilience:recovery:import-hhi', intervalMs: 30 * DAY, timeoutMs: 600_000 },
|
||||
{ label: 'Fuel-Stocks', script: 'seed-recovery-fuel-stocks.mjs', seedMetaKey: 'resilience:recovery:fuel-stocks', intervalMs: 30 * DAY, timeoutMs: 300_000 },
|
||||
]);
|
||||
@@ -1,26 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadEnvFile, runSeed } from './_seed-utils.mjs';
|
||||
import { loadEnvFile, runSeed, getRedisCredentials } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const CANONICAL_KEY = 'resilience:recovery:fuel-stocks:v1';
|
||||
const CACHE_TTL = 35 * 24 * 3600;
|
||||
const CACHE_TTL = 90 * 24 * 3600;
|
||||
const IEA_SOURCE_KEY = 'energy:iea-oil-stocks:v1:index';
|
||||
|
||||
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 };
|
||||
export function computeFuelStockDays(members) {
|
||||
const countries = {};
|
||||
for (const m of members) {
|
||||
if (!m.iso2 || m.netExporter) continue;
|
||||
const days = m.daysOfCover;
|
||||
if (days === null || days === undefined) continue;
|
||||
// Derive both flags from `days` consistently to avoid contradiction
|
||||
// when the source carries a stale belowObligation value.
|
||||
countries[m.iso2] = {
|
||||
fuelStockDays: days,
|
||||
meetsObligation: days >= 90,
|
||||
belowObligation: days < 90,
|
||||
};
|
||||
}
|
||||
return countries;
|
||||
}
|
||||
|
||||
function validate() {
|
||||
return true;
|
||||
async function fetchFromRedis(key) {
|
||||
const { url, token } = getRedisCredentials();
|
||||
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data.result ? JSON.parse(data.result) : null;
|
||||
}
|
||||
|
||||
async function fetchFuelStocks() {
|
||||
const ieaIndex = await fetchFromRedis(IEA_SOURCE_KEY);
|
||||
if (!ieaIndex || !Array.isArray(ieaIndex.members) || ieaIndex.members.length === 0) {
|
||||
throw new Error(`IEA source key ${IEA_SOURCE_KEY} is empty or missing; run seed-iea-oil-stocks.mjs first`);
|
||||
}
|
||||
|
||||
const countries = computeFuelStockDays(ieaIndex.members);
|
||||
const countryCount = Object.keys(countries).length;
|
||||
console.log(`[seed] fuel-stocks: derived ${countryCount} countries from IEA index (dataMonth: ${ieaIndex.dataMonth ?? 'unknown'})`);
|
||||
|
||||
return {
|
||||
countries,
|
||||
dataMonth: ieaIndex.dataMonth ?? null,
|
||||
seededAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function validate(data) {
|
||||
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 15;
|
||||
}
|
||||
|
||||
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',
|
||||
sourceVersion: `iea-derived-fuel-stocks-${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})` : '';
|
||||
|
||||
@@ -1,26 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadEnvFile, runSeed } from './_seed-utils.mjs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { loadEnvFile, CHROME_UA, runSeed, sleep } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const CANONICAL_KEY = 'resilience:recovery:import-hhi:v1';
|
||||
const CACHE_TTL = 35 * 24 * 3600;
|
||||
const require = createRequire(import.meta.url);
|
||||
const UN_TO_ISO2 = require('./shared/un-to-iso2.json');
|
||||
|
||||
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 };
|
||||
const CANONICAL_KEY = 'resilience:recovery:import-hhi:v1';
|
||||
const CACHE_TTL = 90 * 24 * 3600;
|
||||
|
||||
// Matches the key-rotation pattern in seed-comtrade-bilateral-hs4.mjs:
|
||||
// COMTRADE_API_KEYS is a comma-separated list of subscription keys.
|
||||
const COMTRADE_KEYS = (process.env.COMTRADE_API_KEYS || '').split(',').map(k => k.trim()).filter(Boolean);
|
||||
let keyIndex = 0;
|
||||
function nextKey() { return COMTRADE_KEYS[keyIndex++ % COMTRADE_KEYS.length]; }
|
||||
|
||||
if (COMTRADE_KEYS.length === 0) {
|
||||
console.error('[seed] import-hhi: COMTRADE_API_KEYS is required. Set the env var (comma-separated keys) and retry.');
|
||||
}
|
||||
const COMTRADE_URL = 'https://comtradeapi.un.org/data/v1/get/C/A/HS';
|
||||
const INTER_REQUEST_DELAY_MS = 600;
|
||||
|
||||
const ISO2_TO_UN = Object.fromEntries(
|
||||
Object.entries(UN_TO_ISO2).map(([un, iso2]) => [iso2, un]),
|
||||
);
|
||||
|
||||
const ALL_REPORTERS = Object.values(UN_TO_ISO2).filter(c => c.length === 2);
|
||||
|
||||
function parseRecords(data) {
|
||||
const records = data?.data ?? [];
|
||||
if (!Array.isArray(records)) return [];
|
||||
return records
|
||||
.filter(r => r && Number(r.primaryValue ?? 0) > 0)
|
||||
.map(r => ({
|
||||
partnerCode: String(r.partnerCode ?? r.partner2Code ?? '000'),
|
||||
primaryValue: Number(r.primaryValue ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function validate() {
|
||||
return true;
|
||||
async function fetchImportsForReporter(reporterCode) {
|
||||
if (COMTRADE_KEYS.length === 0) return [];
|
||||
const url = new URL(COMTRADE_URL);
|
||||
url.searchParams.set('reporterCode', reporterCode);
|
||||
url.searchParams.set('flowCode', 'M');
|
||||
url.searchParams.set('cmdCode', 'TOTAL');
|
||||
// Omit partnerCode to get ALL bilateral partners (matching the pattern
|
||||
// in seed-comtrade-bilateral-hs4.mjs). Setting partnerCode=0 returns
|
||||
// only the world-aggregate row which computeHhi() then discards.
|
||||
url.searchParams.set('period', String(new Date().getFullYear() - 1));
|
||||
url.searchParams.set('subscription-key', nextKey());
|
||||
|
||||
const resp = await fetch(url.toString(), {
|
||||
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
|
||||
if (resp.status === 429) {
|
||||
console.warn(` 429 for reporter ${reporterCode}, waiting 60s...`);
|
||||
await sleep(60_000);
|
||||
const retry = await fetch(url.toString(), {
|
||||
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
if (!retry.ok) {
|
||||
console.warn(` Retry for reporter ${reporterCode} also failed (HTTP ${retry.status})`);
|
||||
return [];
|
||||
}
|
||||
return parseRecords(await retry.json());
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
console.warn(` HTTP ${resp.status} for reporter ${reporterCode}`);
|
||||
return [];
|
||||
}
|
||||
return parseRecords(await resp.json());
|
||||
}
|
||||
|
||||
// Aggregate import values by partner (Comtrade may return multiple rows
|
||||
// per partner across commodity codes or sub-periods). Then compute HHI
|
||||
// from the per-partner totals so each partner is counted exactly once.
|
||||
export function computeHhi(records) {
|
||||
const validRecords = records.filter(r => r.partnerCode !== '0' && r.partnerCode !== '000');
|
||||
// Aggregate by partner: sum all rows for the same partnerCode
|
||||
const byPartner = new Map();
|
||||
for (const r of validRecords) {
|
||||
byPartner.set(r.partnerCode, (byPartner.get(r.partnerCode) ?? 0) + r.primaryValue);
|
||||
}
|
||||
const totalValue = [...byPartner.values()].reduce((s, v) => s + v, 0);
|
||||
if (totalValue <= 0) return null;
|
||||
let hhi = 0;
|
||||
for (const partnerValue of byPartner.values()) {
|
||||
const share = partnerValue / totalValue;
|
||||
hhi += share * share;
|
||||
}
|
||||
return { hhi: Math.round(hhi * 10000) / 10000, partnerCount: byPartner.size };
|
||||
}
|
||||
|
||||
async function fetchImportHhi() {
|
||||
const countries = {};
|
||||
let fetched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
console.log(`[seed] import-hhi: fetching HS2-level import data for ${ALL_REPORTERS.length} reporters (${COMTRADE_KEYS.length} key(s), ${INTER_REQUEST_DELAY_MS}ms delay)`);
|
||||
|
||||
for (let i = 0; i < ALL_REPORTERS.length; i++) {
|
||||
const iso2 = ALL_REPORTERS[i];
|
||||
const unCode = ISO2_TO_UN[iso2];
|
||||
if (!unCode) { skipped++; continue; }
|
||||
|
||||
if (fetched > 0) await sleep(INTER_REQUEST_DELAY_MS);
|
||||
|
||||
try {
|
||||
const records = await fetchImportsForReporter(unCode);
|
||||
if (records.length === 0) { skipped++; continue; }
|
||||
|
||||
const result = computeHhi(records);
|
||||
if (result === null) { skipped++; continue; }
|
||||
|
||||
countries[iso2] = {
|
||||
hhi: result.hhi,
|
||||
concentrated: result.hhi > 0.25,
|
||||
partnerCount: result.partnerCount,
|
||||
};
|
||||
fetched++;
|
||||
|
||||
if (fetched % 20 === 0) {
|
||||
console.log(` [${fetched}/${ALL_REPORTERS.length}] ${iso2}: HHI=${result.hhi} (${result.partnerCount} partners)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ${iso2}: fetch failed: ${err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[seed] import-hhi: ${fetched} countries computed, ${skipped} skipped`);
|
||||
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-import-hhi.mjs')) {
|
||||
runSeed('resilience', 'recovery:import-hhi', CANONICAL_KEY, fetchImportHhi, {
|
||||
validateFn: validate,
|
||||
ttlSeconds: CACHE_TTL,
|
||||
sourceVersion: 'stub-v1',
|
||||
sourceVersion: `comtrade-hhi-${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})` : '';
|
||||
|
||||
@@ -1209,8 +1209,9 @@ interface RecoveryImportHhiCountry {
|
||||
}
|
||||
|
||||
interface RecoveryFuelStocksCountry {
|
||||
stockDays?: number | null;
|
||||
year?: number | null;
|
||||
fuelStockDays?: number | null;
|
||||
meetsObligation?: boolean | null;
|
||||
belowObligation?: boolean | null;
|
||||
}
|
||||
|
||||
function getRecoveryCountryEntry<T>(raw: unknown, countryCode: string): T | null {
|
||||
@@ -1302,7 +1303,10 @@ export async function scoreImportConcentration(
|
||||
};
|
||||
}
|
||||
return weightedBlend([
|
||||
{ score: normalizeLowerBetter(entry.hhi, 0, 5000), weight: 1.0 },
|
||||
// HHI is on a 0..1 scale (0 = perfectly diversified, 1 = single partner).
|
||||
// Multiply by 10000 to convert to the traditional 0..10000 HHI scale,
|
||||
// then normalize against the 0..5000 goalpost range (where 5000+ = max concentration).
|
||||
{ score: normalizeLowerBetter(entry.hhi * 10000, 0, 5000), weight: 1.0 },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1354,7 +1358,8 @@ export async function scoreFuelStockDays(
|
||||
): Promise<ResilienceDimensionScore> {
|
||||
const raw = await reader(RESILIENCE_RECOVERY_FUEL_STOCKS_KEY);
|
||||
const entry = getRecoveryCountryEntry<RecoveryFuelStocksCountry>(raw, countryCode);
|
||||
if (!entry || entry.stockDays == null) {
|
||||
// The seeder writes `fuelStockDays`, not `stockDays`.
|
||||
if (!entry || entry.fuelStockDays == null) {
|
||||
return {
|
||||
score: IMPUTE.recoveryFuelStocks.score,
|
||||
coverage: IMPUTE.recoveryFuelStocks.certaintyCoverage,
|
||||
@@ -1365,7 +1370,7 @@ export async function scoreFuelStockDays(
|
||||
};
|
||||
}
|
||||
return weightedBlend([
|
||||
{ score: normalizeHigherBetter(Math.min(entry.stockDays, 120), 0, 120), weight: 1.0 },
|
||||
{ score: normalizeHigherBetter(Math.min(entry.fuelStockDays, 120), 0, 120), weight: 1.0 },
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -405,18 +405,21 @@ export const RESILIENCE_FIXTURES: FixtureMap = {
|
||||
},
|
||||
seededAt: '2026-04-04T00:00:00.000Z',
|
||||
},
|
||||
// HHI on 0..1 scale (seeder output). Scorer multiplies by 10000 for normalization.
|
||||
// NO: 0.03 = very diversified, US: 0.06 = diversified, YE: 0.35 = concentrated
|
||||
'resilience:recovery:import-hhi:v1': {
|
||||
countries: {
|
||||
NO: { hhi: 300, year: 2024 },
|
||||
US: { hhi: 600, year: 2024 },
|
||||
YE: { hhi: 3500, year: 2023 },
|
||||
NO: { hhi: 0.03, concentrated: false, partnerCount: 120 },
|
||||
US: { hhi: 0.06, concentrated: false, partnerCount: 180 },
|
||||
YE: { hhi: 0.35, concentrated: true, partnerCount: 15 },
|
||||
},
|
||||
seededAt: '2026-04-04T00:00:00.000Z',
|
||||
},
|
||||
// Fuel-stocks: fuelStockDays (not stockDays), matching seeder output shape
|
||||
'resilience:recovery:fuel-stocks:v1': {
|
||||
countries: {
|
||||
NO: { stockDays: 90, year: 2025 },
|
||||
US: { stockDays: 60, year: 2025 },
|
||||
NO: { fuelStockDays: 90, meetsObligation: true, belowObligation: false },
|
||||
US: { fuelStockDays: 60, meetsObligation: false, belowObligation: true },
|
||||
},
|
||||
seededAt: '2026-04-04T00:00:00.000Z',
|
||||
},
|
||||
|
||||
@@ -1132,7 +1132,8 @@ describe('resilience source-failure aggregation (T1.7)', () => {
|
||||
|
||||
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}`);
|
||||
// US fixture: hhi=0.06 → *10000 = 600 → normalizeLowerBetter(600, 0, 5000) ≈ 88
|
||||
assert.ok(us.score > 80, `US with HHI 0.06 should score >80, got ${us.score}`);
|
||||
});
|
||||
|
||||
it('scoreStateContinuity: derives from existing WGI + UCDP + displacement', async () => {
|
||||
@@ -1144,7 +1145,9 @@ describe('resilience source-failure aggregation (T1.7)', () => {
|
||||
|
||||
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}`);
|
||||
// NO fixture: fuelStockDays=90 → normalizeHigherBetter(90, 0, 120) = 75
|
||||
assert.ok(no.score > 60, `NO with 90 fuelStockDays should score >60, got ${no.score}`);
|
||||
assert.ok(no.observedWeight > 0, 'real fuel-stock data must have observed weight');
|
||||
});
|
||||
|
||||
it('scoreFuelStockDays: country without fuel stock data returns unmonitored', async () => {
|
||||
|
||||
@@ -290,13 +290,9 @@ describe('resilience release gate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 2 T2.1 of the country-resilience reference-grade upgrade plan.
|
||||
// The new three-pillar schema (`pillars` + `schemaVersion`) ships
|
||||
// additively behind RESILIENCE_SCHEMA_V2_ENABLED. The default is now
|
||||
// ON (true), so the default path emits the v2 shape: schemaVersion="2.0"
|
||||
// with populated pillars[]. The flag-off path is exercised through the
|
||||
// pure buildPillarList helper in tests/resilience-pillar-schema.test.mts
|
||||
// because the env flag is read at module load time.
|
||||
// Phase 2 T2.1: the three-pillar schema is now the default (v2 flag
|
||||
// flipped to true in PR #2993). The response carries schemaVersion="2.0"
|
||||
// and a non-empty pillars array with the three-pillar structure.
|
||||
it('T2.1: default response shape is v2 (pillars populated, schemaVersion="2.0")', async () => {
|
||||
installRedisFixtures();
|
||||
|
||||
@@ -308,11 +304,11 @@ describe('resilience release gate', () => {
|
||||
assert.equal(
|
||||
response.schemaVersion,
|
||||
'2.0',
|
||||
'with RESILIENCE_SCHEMA_V2_ENABLED unset (default=true), response must report schemaVersion="2.0"',
|
||||
'with RESILIENCE_SCHEMA_V2_ENABLED default true (post Phase 2), response must report schemaVersion="2.0"',
|
||||
);
|
||||
assert.ok(
|
||||
Array.isArray(response.pillars) && response.pillars.length > 0,
|
||||
'with the v2 flag on (default), pillars must be populated',
|
||||
Array.isArray(response.pillars) && response.pillars.length === 3,
|
||||
'v2 response must include 3 pillars (structural-readiness, live-shock-exposure, recovery-capacity)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
49
tests/seed-bundle-resilience-recovery.test.mjs
Normal file
49
tests/seed-bundle-resilience-recovery.test.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const scriptsDir = join(__dirname, '..', 'scripts');
|
||||
|
||||
const bundleSource = readFileSync(join(scriptsDir, 'seed-bundle-resilience-recovery.mjs'), 'utf8');
|
||||
|
||||
const EXPECTED_ENTRIES = [
|
||||
{ label: 'Fiscal-Space', script: 'seed-recovery-fiscal-space.mjs', seedMetaKey: 'resilience:recovery:fiscal-space' },
|
||||
{ label: 'Reserve-Adequacy', script: 'seed-recovery-reserve-adequacy.mjs', seedMetaKey: 'resilience:recovery:reserve-adequacy' },
|
||||
{ label: 'External-Debt', script: 'seed-recovery-external-debt.mjs', seedMetaKey: 'resilience:recovery:external-debt' },
|
||||
{ label: 'Import-HHI', script: 'seed-recovery-import-hhi.mjs', seedMetaKey: 'resilience:recovery:import-hhi' },
|
||||
{ label: 'Fuel-Stocks', script: 'seed-recovery-fuel-stocks.mjs', seedMetaKey: 'resilience:recovery:fuel-stocks' },
|
||||
];
|
||||
|
||||
describe('seed-bundle-resilience-recovery', () => {
|
||||
it('has exactly 5 entries', () => {
|
||||
const labelMatches = bundleSource.match(/label:\s*'[^']+'/g) ?? [];
|
||||
assert.equal(labelMatches.length, 5, `Expected 5 entries, found ${labelMatches.length}`);
|
||||
});
|
||||
|
||||
for (const entry of EXPECTED_ENTRIES) {
|
||||
it(`contains entry for ${entry.label}`, () => {
|
||||
assert.ok(bundleSource.includes(entry.label), `Missing label: ${entry.label}`);
|
||||
assert.ok(bundleSource.includes(entry.script), `Missing script: ${entry.script}`);
|
||||
assert.ok(bundleSource.includes(entry.seedMetaKey), `Missing seedMetaKey: ${entry.seedMetaKey}`);
|
||||
});
|
||||
|
||||
it(`script ${entry.script} exists on disk`, () => {
|
||||
const scriptPath = join(scriptsDir, entry.script);
|
||||
assert.ok(existsSync(scriptPath), `Script not found: ${scriptPath}`);
|
||||
});
|
||||
}
|
||||
|
||||
it('all entries use 30 * DAY interval', () => {
|
||||
const intervalMatches = bundleSource.match(/intervalMs:\s*30\s*\*\s*DAY/g) ?? [];
|
||||
assert.equal(intervalMatches.length, 5, `Expected all 5 entries to use 30 * DAY interval`);
|
||||
});
|
||||
|
||||
it('imports runBundle and DAY from _bundle-runner.mjs', () => {
|
||||
assert.ok(bundleSource.includes("from './_bundle-runner.mjs'"), 'Missing import from _bundle-runner.mjs');
|
||||
assert.ok(bundleSource.includes('runBundle'), 'Missing runBundle import');
|
||||
assert.ok(bundleSource.includes('DAY'), 'Missing DAY import');
|
||||
});
|
||||
});
|
||||
63
tests/seed-recovery-fuel-stocks.test.mjs
Normal file
63
tests/seed-recovery-fuel-stocks.test.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { computeFuelStockDays } from '../scripts/seed-recovery-fuel-stocks.mjs';
|
||||
|
||||
describe('seed-recovery-fuel-stocks', () => {
|
||||
it('computes fuel-stock-days from IEA members', () => {
|
||||
const members = [
|
||||
{ iso2: 'US', daysOfCover: 120, netExporter: false, belowObligation: false },
|
||||
{ iso2: 'JP', daysOfCover: 200, netExporter: false, belowObligation: false },
|
||||
{ iso2: 'DE', daysOfCover: 85, netExporter: false, belowObligation: true },
|
||||
];
|
||||
const result = computeFuelStockDays(members);
|
||||
assert.equal(Object.keys(result).length, 3);
|
||||
assert.equal(result.US.fuelStockDays, 120);
|
||||
assert.equal(result.US.meetsObligation, true);
|
||||
assert.equal(result.DE.fuelStockDays, 85);
|
||||
assert.equal(result.DE.meetsObligation, false);
|
||||
assert.equal(result.DE.belowObligation, true);
|
||||
});
|
||||
|
||||
it('skips net exporters', () => {
|
||||
const members = [
|
||||
{ iso2: 'NO', daysOfCover: null, netExporter: true, belowObligation: false },
|
||||
{ iso2: 'US', daysOfCover: 120, netExporter: false, belowObligation: false },
|
||||
];
|
||||
const result = computeFuelStockDays(members);
|
||||
assert.equal(Object.keys(result).length, 1);
|
||||
assert.ok(!result.NO);
|
||||
assert.ok(result.US);
|
||||
});
|
||||
|
||||
it('skips members with null daysOfCover', () => {
|
||||
const members = [
|
||||
{ iso2: 'CA', daysOfCover: null, netExporter: false, belowObligation: false },
|
||||
];
|
||||
const result = computeFuelStockDays(members);
|
||||
assert.equal(Object.keys(result).length, 0);
|
||||
});
|
||||
|
||||
it('returns empty for empty members array', () => {
|
||||
const result = computeFuelStockDays([]);
|
||||
assert.equal(Object.keys(result).length, 0);
|
||||
});
|
||||
|
||||
it('90-day boundary: exactly 90 meets obligation', () => {
|
||||
const members = [
|
||||
{ iso2: 'FR', daysOfCover: 90, netExporter: false, belowObligation: false },
|
||||
];
|
||||
const result = computeFuelStockDays(members);
|
||||
assert.equal(result.FR.meetsObligation, true);
|
||||
assert.equal(result.FR.belowObligation, false);
|
||||
});
|
||||
|
||||
it('89 days is below obligation', () => {
|
||||
const members = [
|
||||
{ iso2: 'IT', daysOfCover: 89, netExporter: false, belowObligation: true },
|
||||
];
|
||||
const result = computeFuelStockDays(members);
|
||||
assert.equal(result.IT.meetsObligation, false);
|
||||
assert.equal(result.IT.belowObligation, true);
|
||||
});
|
||||
});
|
||||
109
tests/seed-recovery-import-hhi.test.mjs
Normal file
109
tests/seed-recovery-import-hhi.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { computeHhi } from '../scripts/seed-recovery-import-hhi.mjs';
|
||||
|
||||
describe('seed-recovery-import-hhi', () => {
|
||||
it('computes HHI=1 for single-partner imports', () => {
|
||||
const records = [{ partnerCode: '156', primaryValue: 1000 }];
|
||||
const result = computeHhi(records);
|
||||
assert.equal(result.hhi, 1);
|
||||
assert.equal(result.partnerCount, 1);
|
||||
});
|
||||
|
||||
it('computes HHI for two equal partners', () => {
|
||||
const records = [
|
||||
{ partnerCode: '156', primaryValue: 500 },
|
||||
{ partnerCode: '842', primaryValue: 500 },
|
||||
];
|
||||
const result = computeHhi(records);
|
||||
assert.equal(result.hhi, 0.5);
|
||||
assert.equal(result.partnerCount, 2);
|
||||
});
|
||||
|
||||
it('computes HHI for diversified imports (4 equal partners)', () => {
|
||||
const records = [
|
||||
{ partnerCode: '156', primaryValue: 250 },
|
||||
{ partnerCode: '842', primaryValue: 250 },
|
||||
{ partnerCode: '276', primaryValue: 250 },
|
||||
{ partnerCode: '392', primaryValue: 250 },
|
||||
];
|
||||
const result = computeHhi(records);
|
||||
assert.equal(result.hhi, 0.25);
|
||||
assert.equal(result.partnerCount, 4);
|
||||
});
|
||||
|
||||
it('HHI > 0.25 flags concentrated', () => {
|
||||
const records = [
|
||||
{ partnerCode: '156', primaryValue: 900 },
|
||||
{ partnerCode: '842', primaryValue: 100 },
|
||||
];
|
||||
const result = computeHhi(records);
|
||||
assert.ok(result.hhi > 0.25, `HHI ${result.hhi} should exceed 0.25 concentration threshold`);
|
||||
});
|
||||
|
||||
it('HHI with asymmetric partners matches manual calculation', () => {
|
||||
const records = [
|
||||
{ partnerCode: '156', primaryValue: 600 },
|
||||
{ partnerCode: '842', primaryValue: 300 },
|
||||
{ partnerCode: '276', primaryValue: 100 },
|
||||
];
|
||||
const result = computeHhi(records);
|
||||
const expected = (0.6 ** 2) + (0.3 ** 2) + (0.1 ** 2);
|
||||
assert.ok(Math.abs(result.hhi - Math.round(expected * 10000) / 10000) < 0.001);
|
||||
assert.equal(result.partnerCount, 3);
|
||||
});
|
||||
|
||||
it('excludes world aggregate partner codes (0 and 000)', () => {
|
||||
const records = [
|
||||
{ partnerCode: '0', primaryValue: 5000 },
|
||||
{ partnerCode: '000', primaryValue: 5000 },
|
||||
{ partnerCode: '156', primaryValue: 500 },
|
||||
{ partnerCode: '842', primaryValue: 500 },
|
||||
];
|
||||
const result = computeHhi(records);
|
||||
assert.equal(result.hhi, 0.5);
|
||||
assert.equal(result.partnerCount, 2);
|
||||
});
|
||||
|
||||
it('returns null for empty records', () => {
|
||||
assert.equal(computeHhi([]), null);
|
||||
});
|
||||
|
||||
it('returns null when all records are world aggregates', () => {
|
||||
const records = [
|
||||
{ partnerCode: '0', primaryValue: 1000 },
|
||||
{ partnerCode: '000', primaryValue: 2000 },
|
||||
];
|
||||
assert.equal(computeHhi(records), null);
|
||||
});
|
||||
|
||||
// P1 fix: multi-row per partner must aggregate before computing shares
|
||||
it('aggregates multiple rows for the same partner before computing shares', () => {
|
||||
// Simulates Comtrade returning multiple commodity rows for partner 156
|
||||
const records = [
|
||||
{ partnerCode: '156', primaryValue: 300 },
|
||||
{ partnerCode: '156', primaryValue: 200 }, // same partner, different commodity
|
||||
{ partnerCode: '842', primaryValue: 500 },
|
||||
];
|
||||
const result = computeHhi(records);
|
||||
// After aggregation: 156=500, 842=500 → HHI = 0.5^2 + 0.5^2 = 0.5
|
||||
assert.equal(result.hhi, 0.5);
|
||||
assert.equal(result.partnerCount, 2, 'partnerCount must count unique partners, not rows');
|
||||
});
|
||||
|
||||
it('handles multi-year duplicate rows correctly', () => {
|
||||
// Simulates Comtrade returning the same partner across 2 years
|
||||
const records = [
|
||||
{ partnerCode: '156', primaryValue: 400 }, // year 1
|
||||
{ partnerCode: '156', primaryValue: 600 }, // year 2
|
||||
{ partnerCode: '842', primaryValue: 200 }, // year 1
|
||||
{ partnerCode: '842', primaryValue: 300 }, // year 2
|
||||
];
|
||||
const result = computeHhi(records);
|
||||
// Aggregated: 156=1000, 842=500 → shares: 0.667, 0.333
|
||||
// HHI = 0.667^2 + 0.333^2 ≈ 0.5556
|
||||
assert.ok(Math.abs(result.hhi - 0.5556) < 0.01, `HHI ${result.hhi} should be ~0.5556`);
|
||||
assert.equal(result.partnerCount, 2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user