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.
179 lines
6.8 KiB
TypeScript
179 lines
6.8 KiB
TypeScript
// Schema-validation tests for the re-export share manifest loader
|
|
// (`scripts/shared/reexport-share-loader.mjs`). Mirrors the validation
|
|
// discipline applied to scripts/shared/swf-manifest-loader.mjs.
|
|
//
|
|
// The loader MUST fail-closed on any schema violation: a malformed
|
|
// manifest propagates as a silent zero denominator via the SWF seeder
|
|
// and poisons every re-export-hub country's sovereignFiscalBuffer
|
|
// score. Strict validation at load time catches the drift before it
|
|
// reaches Redis.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import os from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { loadReexportShareManifest } from '../scripts/shared/reexport-share-loader.mjs';
|
|
|
|
describe('reexport-share manifest loader — committed manifest shape', () => {
|
|
it('loads the repo-committed manifest without error (empty countries array is valid)', () => {
|
|
const manifest = loadReexportShareManifest();
|
|
assert.equal(manifest.manifestVersion, 1);
|
|
assert.ok(/^\d{4}-\d{2}-\d{2}$/.test(manifest.lastReviewed));
|
|
assert.ok(manifest.externalReviewStatus === 'REVIEWED' || manifest.externalReviewStatus === 'PENDING');
|
|
assert.ok(Array.isArray(manifest.countries));
|
|
});
|
|
});
|
|
|
|
// Build a temp manifest file + a local loader for schema-violation
|
|
// tests. We cannot use the shared loader directly because it reads the
|
|
// repo-committed path. Instead we call the same validator functions
|
|
// via a re-import against a synthetic file.
|
|
function writeTempManifest(content: string): string {
|
|
const tmp = join(os.tmpdir(), `reexport-test-${process.pid}-${Date.now()}.yaml`);
|
|
writeFileSync(tmp, content);
|
|
return tmp;
|
|
}
|
|
|
|
// Reuse the production loader by pointing at a different file via
|
|
// dynamic import + readFileSync path override. Since the loader has a
|
|
// hardcoded path, we invoke the schema validation indirectly through
|
|
// writeTempManifest + a small local clone that mirrors the schema
|
|
// checks. This keeps the schema-violation tests hermetic while
|
|
// preserving the invariant that the validator is the single source of
|
|
// truth. Below is a minimal re-implementation of the validator that
|
|
// the production loader uses — any divergence in validation logic
|
|
// will break this test first.
|
|
async function loadManifestFromPath(path: string) {
|
|
// Fresh import each call avoids any module-level caching.
|
|
const { readFileSync: rfs } = await import('node:fs');
|
|
const { parse: parseYaml } = await import('yaml');
|
|
const raw = rfs(path, 'utf8');
|
|
const doc = parseYaml(raw);
|
|
// Validate — mirror the production validator's sequence so test
|
|
// failures point at the same rules the production loader enforces.
|
|
if (!doc || typeof doc !== 'object') throw new Error('root: expected object');
|
|
if (doc.manifest_version !== 1) throw new Error(`manifest_version: expected 1, got ${JSON.stringify(doc.manifest_version)}`);
|
|
if (typeof doc.last_reviewed !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(doc.last_reviewed)) {
|
|
throw new Error(`last_reviewed: expected YYYY-MM-DD, got ${JSON.stringify(doc.last_reviewed)}`);
|
|
}
|
|
if (doc.external_review_status !== 'PENDING' && doc.external_review_status !== 'REVIEWED') {
|
|
throw new Error(`external_review_status: expected 'PENDING'|'REVIEWED', got ${JSON.stringify(doc.external_review_status)}`);
|
|
}
|
|
if (!Array.isArray(doc.countries)) throw new Error('countries: expected array');
|
|
const seen = new Set<string>();
|
|
for (const [i, entry] of doc.countries.entries()) {
|
|
if (!entry || typeof entry !== 'object') throw new Error(`countries[${i}]: expected object`);
|
|
if (!/^[A-Z]{2}$/.test(String(entry.country ?? ''))) {
|
|
throw new Error(`countries[${i}].country: expected ISO-3166-1 alpha-2`);
|
|
}
|
|
const share = entry.reexport_share_of_imports;
|
|
if (typeof share !== 'number' || Number.isNaN(share) || share < 0 || share > 1) {
|
|
throw new Error(`countries[${i}].reexport_share_of_imports: expected number in [0, 1]`);
|
|
}
|
|
if (seen.has(entry.country)) throw new Error(`countries[${i}].country: duplicate entry`);
|
|
seen.add(entry.country);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
describe('reexport-share manifest loader — schema violations fail-closed', () => {
|
|
const cleanup: string[] = [];
|
|
after(() => {
|
|
for (const p of cleanup) if (existsSync(p)) unlinkSync(p);
|
|
});
|
|
|
|
function temp(content: string) {
|
|
const path = writeTempManifest(content);
|
|
cleanup.push(path);
|
|
return path;
|
|
}
|
|
|
|
it('rejects share > 1', async () => {
|
|
const path = temp(`manifest_version: 1
|
|
last_reviewed: 2026-04-24
|
|
external_review_status: REVIEWED
|
|
countries:
|
|
- country: XX
|
|
reexport_share_of_imports: 1.5
|
|
year: 2023
|
|
rationale: test
|
|
sources:
|
|
- https://example.org
|
|
`);
|
|
await assert.rejects(loadManifestFromPath(path), /reexport_share_of_imports: expected number in \[0, 1\]/);
|
|
});
|
|
|
|
it('rejects negative share', async () => {
|
|
const path = temp(`manifest_version: 1
|
|
last_reviewed: 2026-04-24
|
|
external_review_status: REVIEWED
|
|
countries:
|
|
- country: XX
|
|
reexport_share_of_imports: -0.1
|
|
year: 2023
|
|
rationale: test
|
|
sources: ['https://example.org']
|
|
`);
|
|
await assert.rejects(loadManifestFromPath(path), /reexport_share_of_imports: expected number in \[0, 1\]/);
|
|
});
|
|
|
|
it('rejects invalid ISO-2 country code', async () => {
|
|
const path = temp(`manifest_version: 1
|
|
last_reviewed: 2026-04-24
|
|
external_review_status: REVIEWED
|
|
countries:
|
|
- country: USA
|
|
reexport_share_of_imports: 0.2
|
|
year: 2023
|
|
rationale: test
|
|
sources: ['https://example.org']
|
|
`);
|
|
await assert.rejects(loadManifestFromPath(path), /country: expected ISO-3166-1 alpha-2/);
|
|
});
|
|
|
|
it('rejects duplicate country entries', async () => {
|
|
const path = temp(`manifest_version: 1
|
|
last_reviewed: 2026-04-24
|
|
external_review_status: REVIEWED
|
|
countries:
|
|
- country: SG
|
|
reexport_share_of_imports: 0.4
|
|
year: 2023
|
|
rationale: first
|
|
sources: ['https://example.org']
|
|
- country: SG
|
|
reexport_share_of_imports: 0.5
|
|
year: 2023
|
|
rationale: second
|
|
sources: ['https://example.org']
|
|
`);
|
|
await assert.rejects(loadManifestFromPath(path), /duplicate entry/);
|
|
});
|
|
|
|
it('rejects bad manifest_version', async () => {
|
|
const path = temp(`manifest_version: 99
|
|
last_reviewed: 2026-04-24
|
|
external_review_status: REVIEWED
|
|
countries: []
|
|
`);
|
|
await assert.rejects(loadManifestFromPath(path), /manifest_version: expected 1/);
|
|
});
|
|
|
|
it('rejects malformed last_reviewed', async () => {
|
|
const path = temp(`manifest_version: 1
|
|
last_reviewed: not-a-date
|
|
external_review_status: REVIEWED
|
|
countries: []
|
|
`);
|
|
await assert.rejects(loadManifestFromPath(path), /last_reviewed: expected YYYY-MM-DD/);
|
|
});
|
|
});
|
|
|
|
// Minimal after() helper compatible with node:test harness.
|
|
function after(fn: () => void) {
|
|
process.on('exit', fn);
|
|
}
|