mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Wires the non-compensatory 3-pillar combined overall_score behind a RESILIENCE_PILLAR_COMBINE_ENABLED env flag. Default is false so this PR ships zero behavior change in production. When flipped true the top-level overall_score switches from the 6-domain weighted aggregate to penalizedPillarScore(pillars) with alpha 0.5 and pillar weights 0.40 / 0.35 / 0.25. Evidence from docs/snapshots/resilience-pillar-sensitivity-2026-04-21: - Spearman rank correlation current vs proposed 0.9935 - Mean score delta -13.44 points (every country drops, penalty is always at most 1) - Max top-50 rank swing 6 positions (Russia) - No ceiling or floor effects under plus/minus 20pct perturbation - Release gate PASS 0/19 Code change in server/worldmonitor/resilience/v1/_shared.ts: - New isPillarCombineEnabled() reads env dynamically so tests can flip state without reloading the module - overallScore branches on (isPillarCombineEnabled() AND RESILIENCE_SCHEMA_V2_ENABLED AND pillars.length > 0); otherwise falls through to the 6-domain aggregate (unchanged default path) - RESILIENCE_SCORE_CACHE_PREFIX bumped v9 to v10 - RESILIENCE_RANKING_CACHE_KEY bumped v9 to v10 Cache invalidation: the version bump forces both per-country score cache and ranking cache to recompute from the current code path on first read after a flag flip. Without the bump, 6-domain values cached under the flag-off path would continue to serve for up to 6-12 hours after the flip, producing a ragged mix of formulas. Ripple of v9 to v10: - api/health.js registry entry - scripts/seed-resilience-scores.mjs (both keys) - scripts/validate-resilience-correlation.mjs, scripts/backtest-resilience-outcomes.mjs, scripts/validate-resilience-backtest.mjs, scripts/benchmark-resilience-external.mjs - tests/resilience-ranking.test.mts 24 fixture usages - tests/resilience-handlers.test.mts - tests/resilience-scores-seed.test.mjs explicit pin - tests/resilience-pillar-aggregation.test.mts explicit pin - docs/methodology/country-resilience-index.mdx New tests/resilience-pillar-combine-activation.test.mts: 7 assertions exercising the flag-on path against the release fixtures with re-anchored bands (NO at least 60, YE/SO at most 40, NO greater than US preserved, elite greater than fragile). Regression guard verifies flipping the flag back off restores the 6-domain aggregate. tests/resilience-ranking-snapshot.test.mts: band thresholds now resolve from a METHODOLOGY_BANDS table keyed on snapshot.methodologyFormula. Backward compatible (missing formula defaults to domain-weighted-6d bands). Snapshots: - docs/snapshots/resilience-ranking-2026-04-21.json tagged methodologyFormula domain-weighted-6d - docs/snapshots/resilience-ranking-pillar-combined-projected-2026-04-21.json new: top/bottom/major-economies tables projected from the 52-country sensitivity sample. Explicitly tagged projected (NOT a full-universe live capture). When the flag is flipped in production, run scripts/freeze-resilience-ranking.mjs to capture the authoritative full-universe snapshot. Methodology doc: Pillar-combined score activation section rewritten to describe the flag-gated mechanism (activation is an env-var flip, no code deploy) and the rollback path. Verification: npm run typecheck:all clean, 397/397 resilience tests pass (up from 390, +7 activation tests). Activation plan: 1. Merge this PR with flag default false (zero behavior change) 2. Set RESILIENCE_PILLAR_COMBINE_ENABLED=true in Vercel and Railway env 3. Redeploy or wait for next cold start; v9 to v10 bump forces every country to be rescored on first read 4. Run scripts/freeze-resilience-ranking.mjs against the flag-on deployment and commit the resulting snapshot 5. Ship a v2.0 methodology-change note explaining the re-anchored scale so analysts understand the universal ~13 point score drop is a scale rebase, not a country-level regression Rollback: set RESILIENCE_PILLAR_COMBINE_ENABLED=false, flush resilience:score:v10:* and resilience:ranking:v10 keys (or wait for TTLs). The 6-domain formula stays alongside the pillar combine in _shared.ts and needs no code change to come back.
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:v10:');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|