Files
worldmonitor/tests/seed-recovery-import-hhi.test.mjs
Elie Habib 21331b9a1e 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)
2026-04-12 11:39:48 +04:00

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