Files
worldmonitor/scripts/freeze-resilience-ranking.mjs
Elie Habib 502bd4472c docs(resilience): sync methodology/proto/widget to 6-domain + 3-pillar reality (#3264)
Brings every user-facing surface into alignment with the live resilience
scorer. Zero behavior change: overall_score is still the 6-domain
weighted aggregate, schemaVersion is still 2.0 default, and every
existing test continues to pass.

Surfaces touched:
- proto + OpenAPI: rewrote the ResiliencePillar + schema_version
  descriptions. 2.0 is correctly documented as default; shaped-but-empty
  language removed.
- Widget: added missing recovery: 'Recovery' label (was rendering
  literal lowercase recovery before), retitled footer data-version chip
  from Data to Seed date so it is clear the value reflects the static
  seed bundle not every live input, rewrote help tooltip for 6 domains
  and 3 pillars and called out the 0.25 recovery weight.
- Methodology doc: domains-and-weights table now carries all 6 rows
  with actual code weights (0.17/0.15/0.11/0.19/0.13/0.25), Recovery
  section header weight corrected from 1.0 to 0.25, new Pillar-combined
  score activation (pending) section with the measured Spearman 0.9935,
  top-5 movers, and the activation checklist.
- documentation.mdx + features.mdx: product blurbs updated from 5
  domains and 13 dimensions to 6 domains and 19 dimensions grouped into
  3 pillars.
- Tests: recovery-label regression pin, Seed date label pin, clarified
  pillar-schema degenerate-input semantics.

New scaffolding for defensibility:
- docs/snapshots/resilience-ranking-2026-04-21.json frozen published
  tables artifact with methodology metadata and commit SHA.
- docs/snapshots/resilience-pillar-sensitivity-2026-04-21.json live
  Redis capture (52-country sample) combining sensitivity stability
  with the current-vs-proposed Spearman comparison.
- scripts/freeze-resilience-ranking.mjs refresh script.
- scripts/compare-resilience-current-vs-proposed.mjs comparison script.
- tests/resilience-ranking-snapshot.test.mts 13 assertions auto
  discovered from any resilience-ranking-YYYY-MM-DD.json in snapshots.

Verification: npm run typecheck:all clean, 390/390 resilience tests
pass.

Follow-up: pillar-combined score activation. The sensitivity artifact
shows rank-preservation Spearman 0.9935 and no ceiling effects, which
clears the methodological bar. Blocker is messaging because every
country drops ~13 points under the penalty, so activation PR ships with
re-anchored release-gate bands, refreshed frozen ranking, and a v2.0
methodology note.
2026-04-21 22:37:27 +04:00

136 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
// Freeze a live snapshot of the resilience ranking for regression-verification
// of published figures. Writes to docs/snapshots/resilience-ranking-<YYYY-MM-DD>.json.
//
// Usage:
// API_BASE=https://api.worldmonitor.app node scripts/freeze-resilience-ranking.mjs
// API_BASE=https://api.worldmonitor.app WORLDMONITOR_API_KEY=... node scripts/freeze-resilience-ranking.mjs
//
// The script hits GET /api/resilience/v1/get-resilience-ranking, enriches each
// item with the country name (shared/country-names.json reverse-lookup), and
// writes a frozen JSON artifact alongside a methodology block. Pair with
// tests/resilience-ranking-snapshot.test.mts to regression-verify the ordering
// invariants (monotonic, unique ranks, anchors in expected bands) against any
// frozen snapshot committed into the repo.
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..');
const API_BASE = (process.env.API_BASE || '').replace(/\/$/, '');
if (!API_BASE) {
console.error('[freeze-resilience-ranking] API_BASE env var required (e.g. https://api.worldmonitor.app)');
process.exit(2);
}
const RANKING_URL = `${API_BASE}/api/resilience/v1/get-resilience-ranking`;
function commitSha() {
try {
return execSync('git rev-parse HEAD', { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'] })
.toString().trim();
} catch {
return 'unknown';
}
}
async function loadCountryNameMap() {
const raw = await fs.readFile(path.join(REPO_ROOT, 'shared', 'country-names.json'), 'utf8');
const forward = JSON.parse(raw);
// forward: { "albania": "AL", ... }. Build reverse: { "AL": "Albania" }.
// When multiple names map to the same ISO-2 (e.g. "bahamas" + "bahamas the"),
// keep the first-seen name because the file is roughly in preferred-label order.
const reverse = {};
for (const [name, iso2] of Object.entries(forward)) {
const code = String(iso2 || '').toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) continue;
if (reverse[code]) continue;
reverse[code] = name.replace(/\b([a-z])/g, (_, c) => c.toUpperCase());
}
return reverse;
}
async function fetchRanking() {
const headers = { accept: 'application/json' };
if (process.env.WORLDMONITOR_API_KEY) {
headers['X-WorldMonitor-Key'] = process.env.WORLDMONITOR_API_KEY;
}
const response = await fetch(RANKING_URL, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status} from ${RANKING_URL}: ${await response.text().catch(() => '')}`);
}
return response.json();
}
function round1(n) {
return Math.round(n * 10) / 10;
}
function enrichItems(items, nameMap, startRank) {
return items.map((item, i) => ({
rank: startRank + i,
countryCode: item.countryCode,
countryName: nameMap[item.countryCode] ?? item.countryCode,
overallScore: round1(item.overallScore),
overallScoreRaw: item.overallScore,
level: item.level,
lowConfidence: Boolean(item.lowConfidence),
dimensionCoverage: Math.round((item.overallCoverage ?? 0) * 100) / 100,
rankStable: Boolean(item.rankStable),
}));
}
async function main() {
const nameMap = await loadCountryNameMap();
const ranking = await fetchRanking();
const items = Array.isArray(ranking.items) ? ranking.items : [];
const greyedOut = Array.isArray(ranking.greyedOut) ? ranking.greyedOut : [];
const ranked = enrichItems(items, nameMap, 1);
const capturedAt = new Date().toISOString().slice(0, 10);
const snapshot = {
capturedAt,
source: `Live capture via ${RANKING_URL}`,
commitSha: commitSha(),
schemaVersion: '2.0',
methodology: {
overallScoreFormula:
'sum(domain.score * domain.weight) across 6 domains; weights: economic=0.17, infrastructure=0.15, energy=0.11, social-governance=0.19, health-food=0.13, recovery=0.25 (sum=1.00).',
domainCount: 6,
dimensionCount: 19,
pillarCount: 3,
coverageLabel:
"Mean dimension coverage (avg of the 19 per-dimension coverage values). Labelled 'Dimension coverage' in publications to avoid the ambiguity of 'Data coverage'.",
greyOutThreshold: 0.40,
},
totals: {
rankedCountries: ranked.length,
greyedOutCount: greyedOut.length,
},
items: ranked,
greyedOut: greyedOut.map((item) => ({
countryCode: item.countryCode,
countryName: nameMap[item.countryCode] ?? item.countryCode,
overallCoverage: Math.round((item.overallCoverage ?? 0) * 100) / 100,
})),
};
const outPath = path.join(REPO_ROOT, 'docs', 'snapshots', `resilience-ranking-${capturedAt}.json`);
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
console.log(`[freeze-resilience-ranking] wrote ${outPath}`);
console.log(`[freeze-resilience-ranking] items=${ranked.length} greyedOut=${greyedOut.length} commit=${snapshot.commitSha.slice(0, 10)}`);
}
main().catch((err) => {
console.error('[freeze-resilience-ranking] failed:', err);
process.exit(1);
});