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 - 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)
110 lines
4.0 KiB
JavaScript
110 lines
4.0 KiB
JavaScript
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);
|
|
});
|
|
});
|