Files
worldmonitor/tests/resilience-pillar-aggregation.test.mts
Elie Habib 184e82cb40 feat(resilience): PR 3A — net-imports denominator for sovereignFiscalBuffer (#3380)
PR 3A of cohort-audit plan 2026-04-24-002. Construct correction for
re-export hubs: the SWF rawMonths denominator was gross imports, which
double-counted flow-through trade that never represents domestic
consumption. Net-imports fix:

  rawMonths = aum / (grossImports × (1 − reexportShareOfImports)) × 12

applied to any country in the re-export share manifest. Countries NOT
in the manifest get gross imports unchanged (status-quo fallback).

Plan acceptance gates — verified synthetically in this PR:

  Construct invariant. Two synthetic countries, same SWF, same gross
  imports. A re-exports 60%; B re-exports 0%. Post-fix, A's rawMonths
  is 2.5× B's (1/(1-0.6) = 2.5). Pinned in
  tests/resilience-net-imports-denominator.test.mts.

  SWF-heavy exporter invariant. Country with share ≤ 5%: rawMonths
  lift < 5% vs baseline (negligible). Pinned.

What shipped

1. Re-export share manifest infrastructure.
   - scripts/shared/reexport-share-manifest.yaml (new, empty) — schema
     committed; entries populated in follow-up PRs with UNCTAD
     Handbook citations.
   - scripts/shared/reexport-share-loader.mjs (new) — loader + strict
     validator, mirrors swf-manifest-loader.mjs.
   - scripts/seed-recovery-reexport-share.mjs (new) — publishes
     resilience:recovery:reexport-share:v1 from manifest. Empty
     manifest = valid (no countries, no adjustment).

2. SWF seeder uses net-imports denominator.
   - scripts/seed-sovereign-wealth.mjs exports computeNetImports(gross,
     share) — pure helper, unit-tested.
   - Per-country loop: reads manifest, computes denominatorImports,
     applies to rawMonths math.
   - Payload records annualImports (gross, audit), denominatorImports
     (used in math), reexportShareOfImports (provenance).
   - Summary log reports which countries had a net-imports adjustment
     applied with source year.

3. Bundle wiring.
   - Reexport-Share runs BEFORE Sovereign-Wealth in the recovery
     bundle so the SWF seeder reads fresh re-export data in the same
     cron tick.
   - tests/seed-bundle-resilience-recovery.test.mjs expected-entries
     updated (6 → 7) with ordering preservation.

4. Cache-prefix bump (per cache-prefix-bump-propagation-scope skill).
   - RESILIENCE_SCORE_CACHE_PREFIX: v11 → v12
   - RESILIENCE_RANKING_CACHE_KEY: v11 → v12
   - RESILIENCE_HISTORY_KEY_PREFIX: v6 → v7 (history rotation prevents
     30-day rolling window from mixing pre/post-fix scores and
     manufacturing false "falling" trends on deploy day).
   - Source of truth: server/worldmonitor/resilience/v1/_shared.ts
   - Mirrored in: scripts/seed-resilience-scores.mjs,
     scripts/validate-resilience-correlation.mjs,
     scripts/backtest-resilience-outcomes.mjs,
     scripts/validate-resilience-backtest.mjs,
     scripts/benchmark-resilience-external.mjs, api/health.js
   - Test literals bumped in 4 test files (26 line edits).
   - EXTENDED tests/resilience-cache-keys-health-sync.test.mts with
     a parity pass that reads every known mirror file and asserts
     both (a) canonical prefix present AND (b) no stale v<older>
     literals in non-comment code. Found one legacy log-line that
     still referenced v9 (scripts/seed-resilience-scores.mjs:342)
     and refactored it to use the RESILIENCE_RANKING_CACHE_KEY
     constant so future bumps self-update.

Explicitly NOT in this PR

- liquidReserveAdequacy denominator fix. The plan's PR 3A wording
  mentions both dims, but the RESERVES ratio (WB FI.RES.TOTL.MO) is a
  PRE-COMPUTED WB series; applying a post-hoc net-imports adjustment
  mixes WB's denominator year with our manifest-year, and the math
  change belongs in PR 3B (unified liquidity) where the α calibration
  is explicit. This PR stays scoped to sovereignFiscalBuffer.
- Live re-export share entries. The manifest ships EMPTY in this PR;
  entries with UNCTAD citations are one-per-PR follow-ups so each
  figure is individually auditable.

Verified

- tests/resilience-net-imports-denominator.test.mts — 9 pass (construct
  contract: 2.5× ratio gate, monotonicity, boundary rejections,
  backward-compat on missing manifest entry, cohort-proportionality,
  SWF-heavy-exporter-unchanged)
- tests/reexport-share-loader.test.mts — 7 pass (committed-manifest
  shape + 6 schema-violation rejections)
- tests/resilience-cache-keys-health-sync.test.mts — 5 pass (existing 3
  + 2 new parity checks across all mirror files)
- tests/seed-bundle-resilience-recovery.test.mjs — 17 pass (expected
  entries bumped to 7)
- npm run test:data — 6714 pass / 0 fail
- npm run typecheck / typecheck:api — green
- npm run lint / lint:md — clean

Deployment notes

Score + ranking + history cache prefixes all bump in the same deploy.
Per established v10→v11 precedent (and the cache-prefix-bump-
propagation-scope skill):
- Score / ranking: 6h TTL — the new prefix populates via the Railway
  resilience-scores cron within one tick.
- History: 30d ring — the v7 ring starts empty; the first 30 days
  post-deploy lack baseline points, so trend / change30d will read as
  "no change" until v7 accumulates a window.
- Legacy v11 keys can be deleted from Redis at any time post-deploy
  (no reader references them). Leaving them in place costs storage
  but does no harm.
2026-04-24 18:14:04 +04:00

180 lines
6.4 KiB
TypeScript

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
PENALTY_ALPHA,
RESILIENCE_SCORE_CACHE_PREFIX,
penalizedPillarScore,
} from '../server/worldmonitor/resilience/v1/_shared.ts';
import {
PILLAR_DOMAINS,
PILLAR_ORDER,
PILLAR_WEIGHTS,
buildPillarList,
type ResiliencePillarId,
} from '../server/worldmonitor/resilience/v1/_pillar-membership.ts';
import type { ResilienceDomain } from '../src/generated/server/worldmonitor/resilience/v1/service_server.ts';
function makeDomain(id: string, score: number, coverage: number): ResilienceDomain {
return {
id,
score,
weight: 0.17,
dimensions: [
{ id: `${id}-d1`, score, coverage, observedWeight: coverage, imputedWeight: 1 - coverage, imputationClass: '', freshness: { lastObservedAtMs: '0', staleness: '' } },
],
};
}
describe('penalizedPillarScore', () => {
it('returns 0 for empty pillars', () => {
assert.equal(penalizedPillarScore([]), 0);
});
it('equal pillar scores produce minimal penalty (penalty factor approaches 1)', () => {
const pillars = [
{ score: 60, weight: 0.40 },
{ score: 60, weight: 0.35 },
{ score: 60, weight: 0.25 },
];
const result = penalizedPillarScore(pillars);
const weighted = 60 * 0.40 + 60 * 0.35 + 60 * 0.25;
const penalty = 1 - 0.5 * (1 - 60 / 100);
assert.equal(result, Math.round(weighted * penalty * 100) / 100);
});
it('one pillar at 0 applies maximum penalty (factor = 0.5 at alpha=0.5)', () => {
const pillars = [
{ score: 80, weight: 0.40 },
{ score: 70, weight: 0.35 },
{ score: 0, weight: 0.25 },
];
const result = penalizedPillarScore(pillars);
const weighted = 80 * 0.40 + 70 * 0.35 + 0 * 0.25;
const penalty = 1 - 0.5 * (1 - 0 / 100);
assert.equal(result, Math.round(weighted * penalty * 100) / 100);
assert.equal(penalty, 0.5);
});
it('realistic scores (S=70, L=45, R=60) produce expected value', () => {
const pillars = [
{ score: 70, weight: 0.40 },
{ score: 45, weight: 0.35 },
{ score: 60, weight: 0.25 },
];
const result = penalizedPillarScore(pillars);
const weighted = 70 * 0.40 + 45 * 0.35 + 60 * 0.25;
const minScore = 45;
const penalty = 1 - 0.5 * (1 - minScore / 100);
const expected = Math.round(weighted * penalty * 100) / 100;
assert.equal(result, expected);
assert.ok(result > 0 && result < 100, `result=${result} should be in (0,100)`);
});
it('all pillars at 100 produce no penalty (factor = 1.0)', () => {
const pillars = [
{ score: 100, weight: 0.40 },
{ score: 100, weight: 0.35 },
{ score: 100, weight: 0.25 },
];
const result = penalizedPillarScore(pillars);
assert.equal(result, 100);
});
});
describe('buildPillarList', () => {
it('returns empty array when schemaV2Enabled is false', () => {
const domains: ResilienceDomain[] = [makeDomain('economic', 75, 0.9)];
assert.deepEqual(buildPillarList(domains, false), []);
});
it('produces 3 pillars with non-zero scores from real domain data', () => {
const domains: ResilienceDomain[] = [
makeDomain('economic', 75, 0.9),
makeDomain('social-governance', 65, 0.85),
makeDomain('infrastructure', 70, 0.8),
makeDomain('energy', 60, 0.7),
makeDomain('health-food', 55, 0.75),
makeDomain('recovery', 50, 0.6),
];
const pillars = buildPillarList(domains, true);
assert.equal(pillars.length, 3);
for (const pillar of pillars) {
assert.ok(pillar.score > 0, `pillar ${pillar.id} score should be > 0, got ${pillar.score}`);
assert.ok(pillar.coverage > 0, `pillar ${pillar.id} coverage should be > 0, got ${pillar.coverage}`);
}
});
it('recovery-capacity pillar contains the recovery domain', () => {
const domains: ResilienceDomain[] = [
makeDomain('economic', 75, 0.9),
makeDomain('social-governance', 65, 0.85),
makeDomain('infrastructure', 70, 0.8),
makeDomain('energy', 60, 0.7),
makeDomain('health-food', 55, 0.75),
makeDomain('recovery', 50, 0.6),
];
const pillars = buildPillarList(domains, true);
const recovery = pillars.find((p) => p.id === 'recovery-capacity');
assert.ok(recovery, 'recovery-capacity pillar should exist');
assert.equal(recovery!.domains.length, 1, 'recovery-capacity pillar should have 1 domain');
assert.equal(recovery!.domains[0]!.id, 'recovery');
});
it('pillar weights match PILLAR_WEIGHTS', () => {
const domains: ResilienceDomain[] = [
makeDomain('economic', 75, 0.9),
makeDomain('social-governance', 65, 0.85),
makeDomain('infrastructure', 70, 0.8),
makeDomain('energy', 60, 0.7),
makeDomain('health-food', 55, 0.75),
makeDomain('recovery', 50, 0.6),
];
const pillars = buildPillarList(domains, true);
for (const pillar of pillars) {
assert.equal(pillar.weight, PILLAR_WEIGHTS[pillar.id as ResiliencePillarId]);
}
});
it('structural-readiness contains economic + social-governance', () => {
const domains: ResilienceDomain[] = [
makeDomain('economic', 75, 0.9),
makeDomain('social-governance', 65, 0.85),
makeDomain('infrastructure', 70, 0.8),
makeDomain('energy', 60, 0.7),
makeDomain('health-food', 55, 0.75),
makeDomain('recovery', 50, 0.6),
];
const pillars = buildPillarList(domains, true);
const sr = pillars.find((p) => p.id === 'structural-readiness')!;
const domainIds = sr.domains.map((d) => d.id).sort();
assert.deepEqual(domainIds, ['economic', 'social-governance']);
});
});
describe('pillar constants', () => {
it('PENALTY_ALPHA equals 0.50', () => {
assert.equal(PENALTY_ALPHA, 0.50);
});
it('RESILIENCE_SCORE_CACHE_PREFIX is v10', () => {
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v12:');
});
it('PILLAR_ORDER has 3 entries', () => {
assert.equal(PILLAR_ORDER.length, 3);
});
it('pillar weights sum to 1.0', () => {
const sum = PILLAR_ORDER.reduce((s, id) => s + PILLAR_WEIGHTS[id], 0);
assert.ok(Math.abs(sum - 1.0) < 0.001, `pillar weights sum to ${sum}, expected 1.0`);
});
it('every domain appears in exactly one pillar', () => {
const allDomains = PILLAR_ORDER.flatMap((id) => PILLAR_DOMAINS[id]);
const unique = new Set(allDomains);
assert.equal(allDomains.length, unique.size, 'no domain should appear in multiple pillars');
assert.equal(unique.size, 6, 'all 6 domains should be covered');
});
});