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.
167 lines
5.8 KiB
JavaScript
167 lines
5.8 KiB
JavaScript
// Loader + validator for the re-export share manifest at
|
|
// scripts/shared/reexport-share-manifest.yaml.
|
|
//
|
|
// Mirrors the swf-manifest-loader.mjs pattern:
|
|
// - Co-located with the YAML so the Railway recovery-bundle container
|
|
// (rootDirectory=scripts/) ships both together under a single COPY.
|
|
// - Pure JS (no Redis, no env mutations) so the SWF seeder can import
|
|
// it at top-level without touching the I/O layer.
|
|
// - Strict schema validation at load time so a malformed manifest
|
|
// fails the seeder cold, not silently.
|
|
//
|
|
// See plan `docs/plans/2026-04-24-002-fix-resilience-cohort-ranking-
|
|
// structural-audit-plan.md` §PR 3A for the construct rationale.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { parse as parseYaml } from 'yaml';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const MANIFEST_PATH = resolve(here, './reexport-share-manifest.yaml');
|
|
|
|
/**
|
|
* @typedef {Object} ReexportShareEntry
|
|
* @property {string} country ISO-3166-1 alpha-2
|
|
* @property {number} reexportShareOfImports 0..1 inclusive
|
|
* @property {number} year reference year (e.g. 2023)
|
|
* @property {string} rationale one-line summary of the cited source
|
|
* @property {string[]} sources list of URLs / citations
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} ReexportShareManifest
|
|
* @property {number} manifestVersion
|
|
* @property {string} lastReviewed
|
|
* @property {'PENDING'|'REVIEWED'} externalReviewStatus
|
|
* @property {ReexportShareEntry[]} countries
|
|
*/
|
|
|
|
function fail(msg) {
|
|
throw new Error(`[reexport-manifest] ${msg}`);
|
|
}
|
|
|
|
function assertZeroToOne(value, path) {
|
|
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 1) {
|
|
fail(`${path}: expected number in [0, 1], got ${JSON.stringify(value)}`);
|
|
}
|
|
}
|
|
|
|
function assertIso2(value, path) {
|
|
if (typeof value !== 'string' || !/^[A-Z]{2}$/.test(value)) {
|
|
fail(`${path}: expected ISO-3166-1 alpha-2 country code, got ${JSON.stringify(value)}`);
|
|
}
|
|
}
|
|
|
|
function assertNonEmptyString(value, path) {
|
|
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
fail(`${path}: expected non-empty string, got ${JSON.stringify(value)}`);
|
|
}
|
|
}
|
|
|
|
function assertYear(value, path) {
|
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 2000 || value > 2100) {
|
|
fail(`${path}: expected integer year in [2000, 2100], got ${JSON.stringify(value)}`);
|
|
}
|
|
}
|
|
|
|
function validateSources(sources, path) {
|
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
fail(`${path}: expected non-empty array`);
|
|
}
|
|
for (const [srcIdx, src] of sources.entries()) {
|
|
assertNonEmptyString(src, `${path}[${srcIdx}]`);
|
|
}
|
|
return sources.slice();
|
|
}
|
|
|
|
function validateCountryEntry(raw, idx, seenCountries) {
|
|
const path = `countries[${idx}]`;
|
|
if (!raw || typeof raw !== 'object') fail(`${path}: expected object`);
|
|
const c = /** @type {Record<string, unknown>} */ (raw);
|
|
|
|
assertIso2(c.country, `${path}.country`);
|
|
assertZeroToOne(c.reexport_share_of_imports, `${path}.reexport_share_of_imports`);
|
|
assertYear(c.year, `${path}.year`);
|
|
assertNonEmptyString(c.rationale, `${path}.rationale`);
|
|
const sources = validateSources(c.sources, `${path}.sources`);
|
|
|
|
const countryCode = /** @type {string} */ (c.country);
|
|
if (seenCountries.has(countryCode)) {
|
|
fail(`${path}.country: duplicate entry for ${countryCode}`);
|
|
}
|
|
seenCountries.add(countryCode);
|
|
|
|
return {
|
|
country: countryCode,
|
|
reexportShareOfImports: /** @type {number} */ (c.reexport_share_of_imports),
|
|
year: /** @type {number} */ (c.year),
|
|
rationale: /** @type {string} */ (c.rationale),
|
|
sources,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load and validate the re-export share manifest.
|
|
* Throws with a detailed path-prefixed error on schema violation; a
|
|
* broken manifest MUST fail the seeder cold — silently proceeding with
|
|
* a partial read would leave some countries' net-imports denominator
|
|
* wrong without signal.
|
|
*
|
|
* @returns {ReexportShareManifest}
|
|
*/
|
|
export function loadReexportShareManifest() {
|
|
const raw = readFileSync(MANIFEST_PATH, 'utf8');
|
|
const doc = parseYaml(raw);
|
|
if (!doc || typeof doc !== 'object') {
|
|
fail(`root: expected object, got ${typeof doc}`);
|
|
}
|
|
|
|
const version = doc.manifest_version;
|
|
if (version !== 1) fail(`manifest_version: expected 1, got ${JSON.stringify(version)}`);
|
|
|
|
const lastReviewed = doc.last_reviewed;
|
|
if (typeof lastReviewed !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(lastReviewed)) {
|
|
fail(`last_reviewed: expected YYYY-MM-DD, got ${JSON.stringify(lastReviewed)}`);
|
|
}
|
|
|
|
const status = doc.external_review_status;
|
|
if (status !== 'PENDING' && status !== 'REVIEWED') {
|
|
fail(`external_review_status: expected 'PENDING'|'REVIEWED', got ${JSON.stringify(status)}`);
|
|
}
|
|
|
|
const rawCountries = doc.countries;
|
|
if (!Array.isArray(rawCountries)) {
|
|
fail(`countries: expected array, got ${typeof rawCountries}`);
|
|
}
|
|
const seen = new Set();
|
|
const countries = rawCountries.map((r, i) => validateCountryEntry(r, i, seen));
|
|
|
|
return {
|
|
manifestVersion: 1,
|
|
lastReviewed,
|
|
externalReviewStatus: /** @type {'PENDING'|'REVIEWED'} */ (status),
|
|
countries,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read the manifest and return an ISO2 → reexportShareOfImports lookup.
|
|
* Countries missing from the manifest return undefined — the SWF seeder
|
|
* MUST treat undefined as "no adjustment, use gross imports."
|
|
*
|
|
* @returns {Map<string, { reexportShareOfImports: number, year: number, sources: string[] }>}
|
|
*/
|
|
export function loadReexportShareByCountry() {
|
|
const manifest = loadReexportShareManifest();
|
|
const map = new Map();
|
|
for (const entry of manifest.countries) {
|
|
map.set(entry.country, {
|
|
reexportShareOfImports: entry.reexportShareOfImports,
|
|
year: entry.year,
|
|
sources: entry.sources,
|
|
});
|
|
}
|
|
return map;
|
|
}
|