mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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.
180 lines
6.4 KiB
TypeScript
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');
|
|
});
|
|
});
|