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:
Elie Habib
2026-04-12 11:39:48 +04:00
committed by GitHub
parent c081556121
commit 21331b9a1e
10 changed files with 445 additions and 39 deletions

View 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 },
]);

View File

@@ -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})` : '';

View File

@@ -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})` : '';

View File

@@ -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 },
]);
}

View File

@@ -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',
},

View File

@@ -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 () => {

View File

@@ -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)',
);
});

View 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');
});
});

View 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);
});
});

View 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);
});
});