Files
worldmonitor/tests/resilience-pillar-aggregation.test.mts
Elie Habib fbaf07e106 feat(resilience): flag-gated pillar-combined score activation (default off) (#3267)
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.
2026-04-22 06:52:07 +04:00

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');
});
});