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