feat(resilience): three-pillar aggregation with penalized weighted mean (T2.3) (#2990)

* feat(resilience): three-pillar aggregation with penalized weighted mean (Phase 2 T2.3)

Wire real three-pillar scoring: structural-readiness (0.40), live-shock-exposure
(0.35), recovery-capacity (0.25). Add penalizedPillarScore formula with alpha=0.50
penalty factor for backtest tuning. Set recovery domain weight to 0.25 and
redistribute existing domain weights proportionally to sum to 1.0. Bump cache
keys v8 to v9. The penalized formula is exported and tested but overallScore
stays as the v1 domain-weighted sum until the flag flips in PR 10.

* fix(resilience): update test description v8 to v9 (#2990 review)

Test descriptions said "(v8)" but assertions check v9 cache keys.
This commit is contained in:
Elie Habib
2026-04-12 10:18:42 +04:00
committed by GitHub
parent 17e34dfca7
commit 676331607a
14 changed files with 281 additions and 127 deletions

View File

@@ -139,7 +139,7 @@ const STANDALONE_KEYS = {
climateNews: 'climate:news-intelligence:v1',
pizzint: 'intelligence:pizzint:seed:v1',
resilienceStaticIndex: 'resilience:static:index:v1',
resilienceRanking: 'resilience:ranking:v8',
resilienceRanking: 'resilience:ranking:v9',
productCatalog: 'product-catalog:v2',
energySpineCountries: 'energy:spine:v1:_countries',
energyExposure: 'energy:exposure:v1:index',

View File

@@ -371,8 +371,8 @@ The CRI is designed to be auditable end-to-end: given the Redis snapshot at any
| Key | Type | TTL | Written by | Read by |
|---|---|---|---|---|
| `resilience:score:v8:{countryCode}` | JSON | 6 hours | `buildResilienceScore` in `server/worldmonitor/resilience/v1/_shared.ts` | `getResilienceScore` handler |
| `resilience:ranking:v8` | JSON | 6 hours | `buildResilienceRanking`, only when all countries are scored | `getResilienceRanking` handler |
| `resilience:score:v9:{countryCode}` | JSON | 6 hours | `buildResilienceScore` in `server/worldmonitor/resilience/v1/_shared.ts` | `getResilienceScore` handler |
| `resilience:ranking:v9` | JSON | 6 hours | `buildResilienceRanking`, only when all countries are scored | `getResilienceRanking` handler |
| `resilience:history:v4:{countryCode}` | sorted set | indefinite, trimmed to 30 days | `appendHistory` during scoring | trend and `change30d` computation |
| `resilience:intervals:v1:{countryCode}` | JSON | 6 hours | `scripts/seed-resilience-intervals.mjs` | `getResilienceScore` (optional `scoreInterval` field) |
| `seed-meta:resilience:static` | JSON | 2 hours | `scripts/seed-resilience-static.mjs` at the end of each successful seed run | scorer for `dataVersion` population, health checks |
@@ -418,7 +418,7 @@ A reference Python notebook under `docs/methodology/country-resilience-index/ref
- **T1.6** (#2949 scaffold, #2962 full grid): per-dimension confidence grid in the widget. The full grid adds an imputation-class icon column (consuming T1.7 schema) and a freshness-badge column (consuming T1.5 propagation). 5-column layout with mobile responsive breakpoint.
- **T1.7** (#2944 foundation, #2959 schema, #2964 source-failure wiring): four-class imputation taxonomy `stable-absence` / `unmonitored` / `source-failure` / `not-applicable` exposed on `ResilienceDimension.imputationClass`. The scorer aggregation pass consults `seed-meta:resilience:static.failedDatasets` and re-tags imputed dimensions as `source-failure` when the underlying adapter fetch failed. Deleted the last absence-based return branch in `scoreCurrencyExternal` so the taxonomy is the single source of truth for every imputed path.
- **T1.8** (#2946): methodology doc linter enforces dimension parity between this document and `_indicator-registry.ts`. CI fails if any dimension drifts.
- **T1.9** (this PR): cache-key / health-registry sync regression test so future version bumps in `_shared.ts` cannot silently break health probes. No cache keys were bumped in Phase 1 because every schema addition was additive with default fallbacks on the existing `resilience:score:v7` and `resilience:ranking:v8` keys.
- **T1.9** (this PR): cache-key / health-registry sync regression test so future version bumps in `_shared.ts` cannot silently break health probes. No cache keys were bumped in Phase 1 because every schema addition was additive with default fallbacks on the existing `resilience:score:v7` and `resilience:ranking:v9` keys.
**What did not change in v1.1**: the domain-weighted aggregation formula, the 5 domain structure, the 13 dimensions, the goalpost ranges, the per-dimension weights. Phase 2 owns the structural three-pillar rebuild; v1.1 is the methodology-surface and observability lift only.

View File

@@ -12,8 +12,8 @@ const API_BASE = process.env.API_BASE_URL || 'https://api.worldmonitor.app';
const WM_KEY = process.env.WORLDMONITOR_API_KEY || '';
const SEED_UA = 'Mozilla/5.0 (compatible; WorldMonitor-Seed/1.0)';
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v8:';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v8';
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v9:';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v9';
export const RESILIENCE_RANKING_CACHE_TTL_SECONDS = 6 * 60 * 60;
export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1';

View File

@@ -26,7 +26,7 @@ import { getRedisCredentials, loadEnvFile } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
// Source of truth: server/worldmonitor/resilience/v1/_shared.ts
const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v8:';
const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v9:';
const MIN_SCORED_COUNTRIES = 5;

View File

@@ -3,7 +3,7 @@
import { loadEnvFile, getRedisCredentials } from './_seed-utils.mjs';
// Source of truth: server/worldmonitor/resilience/v1/_shared.ts → RESILIENCE_SCORE_CACHE_PREFIX
const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v8:';
const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v9:';
const REFERENCE_INDICES = {
ndgain: {

View File

@@ -269,12 +269,12 @@ for (const [name, iso2] of Object.entries(countryNames as Record<string, string>
const ISO2_TO_ISO3: Record<string, string> = iso2ToIso3Json;
const RESILIENCE_DOMAIN_WEIGHTS: Record<ResilienceDomainId, number> = {
economic: 0.22,
infrastructure: 0.20,
energy: 0.15,
'social-governance': 0.25,
'health-food': 0.18,
recovery: 0,
economic: 0.17,
infrastructure: 0.15,
energy: 0.11,
'social-governance': 0.19,
'health-food': 0.13,
recovery: 0.25,
};
export const RESILIENCE_DIMENSION_DOMAINS: Record<ResilienceDimensionId, ResilienceDomainId> = {

View File

@@ -1,100 +1,64 @@
// Phase 2 T2.1 of the country-resilience reference-grade upgrade plan
// (docs/internal/country-resilience-upgrade-plan.md).
//
// Declarative pillar to domain membership for the three-pillar response
// shape. Single source of truth: PR 4 (T2.3) imports these constants
// for the real penalized-weighted-mean aggregation pass; this PR ships
// only the schema and the membership wiring with score=0, coverage=0.
//
// Pillar concept (from the plan, "Architecture target"):
// - StructuralReadiness = long-run institutional, economic, and
// infrastructure capacity. Slow-moving annual
// cadence sources.
// - LiveShockExposure = current shock pressure from health, energy,
// and other stress-cycle sources. Daily to
// weekly cadence.
// - RecoveryCapacity = fiscal space, reserves, surge capacity. NEW
// pillar composed in PR 3 / T2.2b once the
// recovery-capacity dimensions seed.
//
// Note on domain-id mapping. The plan example uses long-form names
// (StructuralReadiness, LiveShockExposure, RecoveryCapacity); the
// runtime ResilienceDomainId enum in `_dimension-scorers.ts` uses the
// kebab-case domain ids that already ship in the v1 response
// (`economic`, `infrastructure`, `energy`, `social-governance`,
// `health-food`). This module pins the mapping between the two so PR 4
// has a single import to consume.
//
// Membership invariants asserted by tests/resilience-pillar-schema.test.mts:
// 1. Every domain id listed here is a real ResilienceDomainId.
// 2. Pillar domain sets are pairwise disjoint (no domain in two pillars).
// 3. recovery-capacity is empty in this PR (PR 3 adds new dimensions
// and PR 4 wires them through).
// 4. Weights sum to exactly 1.0 and match the plan defaults
// (0.40 / 0.35 / 0.25).
import type { ResilienceDomain } from '../../../../src/generated/server/worldmonitor/resilience/v1/service_server';
import type { ResilienceDomainId } from './_dimension-scorers';
export type ResiliencePillarId =
| 'structural-readiness'
| 'live-shock-exposure'
| 'recovery-capacity';
export type ResiliencePillarId = 'structural-readiness' | 'live-shock-exposure' | 'recovery-capacity';
// Pillar to domain membership. Recovery capacity ships empty in T2.1
// (this PR) and gets its real domain set wired in PR 3 / T2.2b after
// the new recovery-capacity dimensions seed. The two existing pillars
// partition the current 5 domains: structural-readiness owns the three
// long-run capacity domains, live-shock-exposure owns the two shock
// pressure domains.
export const PILLAR_DOMAINS: Readonly<Record<ResiliencePillarId, ReadonlyArray<ResilienceDomainId>>> = {
'structural-readiness': ['economic', 'infrastructure', 'social-governance'],
'live-shock-exposure': ['energy', 'health-food'],
'recovery-capacity': [],
};
export const PILLAR_WEIGHTS: Readonly<Record<ResiliencePillarId, number>> = {
'structural-readiness': 0.40,
'live-shock-exposure': 0.35,
'recovery-capacity': 0.25,
};
export const PILLAR_ORDER: ReadonlyArray<ResiliencePillarId> = [
'structural-readiness',
'live-shock-exposure',
'recovery-capacity',
];
// Phase 2 T2.1: shaped-but-empty pillar list. PR 4 / T2.3 replaces the
// hardcoded 0 score / 0 coverage with the real penalized-weighted-mean
// aggregation. This helper is the single point that the v2 response
// branch calls; the v1 branch always returns `[]`.
//
// Filtering by membership preserves the input domain ordering so the
// pillar.domains array is deterministic and matches what
// CountryDeepDivePanel expects when it lights up in Phase 3 / T3.6.
export function buildPillarList(
domains: ResilienceDomain[],
schemaV2Enabled: boolean,
): {
export interface ResiliencePillar {
id: ResiliencePillarId;
score: number;
weight: number;
coverage: number;
domains: ResilienceDomain[];
}[] {
}
export const PILLAR_DOMAINS: Record<ResiliencePillarId, ResilienceDomainId[]> = {
'structural-readiness': ['economic', 'social-governance'],
'live-shock-exposure': ['infrastructure', 'energy', 'health-food'],
'recovery-capacity': ['recovery'],
};
export const PILLAR_WEIGHTS: Record<ResiliencePillarId, number> = {
'structural-readiness': 0.40,
'live-shock-exposure': 0.35,
'recovery-capacity': 0.25,
};
export const PILLAR_ORDER: ResiliencePillarId[] = [
'structural-readiness',
'live-shock-exposure',
'recovery-capacity',
];
export function buildPillarList(
domains: ResilienceDomain[],
schemaV2Enabled: boolean,
): ResiliencePillar[] {
if (!schemaV2Enabled) return [];
return PILLAR_ORDER.map((pillarId) => {
const memberSet = new Set<string>(PILLAR_DOMAINS[pillarId]);
const memberDomains = domains.filter((domain) => memberSet.has(domain.id));
const memberDomains = domains.filter((d) =>
PILLAR_DOMAINS[pillarId].includes(d.id as ResilienceDomainId),
);
const totalCoverage = memberDomains.reduce((sum, d) => {
const dimCoverages = d.dimensions.map((dim) => dim.coverage);
return sum + (dimCoverages.length > 0 ? dimCoverages.reduce((a, b) => a + b, 0) / dimCoverages.length : 0);
}, 0);
const pillarScore = totalCoverage > 0
? memberDomains.reduce((sum, d) => {
const avgCoverage = d.dimensions.length > 0
? d.dimensions.reduce((a, dim) => a + dim.coverage, 0) / d.dimensions.length
: 0;
return sum + d.score * avgCoverage;
}, 0) / totalCoverage
: 0;
const pillarCoverage = memberDomains.length > 0
? totalCoverage / memberDomains.length
: 0;
return {
id: pillarId,
// T2.1 ships empty; PR 4 populates with the penalized weighted mean.
score: 0,
score: Math.round(pillarScore * 100) / 100,
weight: PILLAR_WEIGHTS[pillarId],
// T2.1 ships empty; PR 4 populates from the constituent domain
// coverages once the aggregation pass lands.
coverage: 0,
coverage: Math.round(pillarCoverage * 10000) / 10000,
domains: memberDomains,
};
});

View File

@@ -41,9 +41,9 @@ export const RESILIENCE_SCHEMA_V2_ENABLED =
export const RESILIENCE_SCORE_CACHE_TTL_SECONDS = 6 * 60 * 60;
export const RESILIENCE_RANKING_CACHE_TTL_SECONDS = 6 * 60 * 60;
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v8:';
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v9:';
export const RESILIENCE_HISTORY_KEY_PREFIX = 'resilience:history:v4:';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v8';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v9';
export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1';
export const RESILIENCE_INTERVAL_KEY_PREFIX = 'resilience:intervals:v1:';
const RESILIENCE_STATIC_META_KEY = 'seed-meta:resilience:static';
@@ -136,6 +136,16 @@ function coverageWeightedMean(dimensions: ResilienceDimension[]): number {
return dimensions.reduce((sum, d) => sum + d.score * d.coverage, 0) / totalCoverage;
}
export const PENALTY_ALPHA = 0.50;
export function penalizedPillarScore(pillars: { score: number; weight: number }[]): number {
if (pillars.length === 0) return 0;
const weighted = pillars.reduce((sum, p) => sum + p.score * p.weight, 0);
const minScore = Math.min(...pillars.map((p) => p.score));
const penalty = 1 - PENALTY_ALPHA * (1 - minScore / 100);
return Math.round(weighted * penalty * 100) / 100;
}
function buildDomainList(dimensions: ResilienceDimension[]): ResilienceDomain[] {
const grouped = new Map<ResilienceDomainId, ResilienceDimension[]>();
for (const domainId of RESILIENCE_DOMAIN_ORDER) grouped.set(domainId, []);

View File

@@ -55,7 +55,7 @@ describe('resilience handlers', () => {
assert.ok(response.stressFactor >= 0 && response.stressFactor <= 0.5, `stressFactor out of bounds: ${response.stressFactor}`);
assert.equal(response.dataVersion, '2024-04-03', 'dataVersion should be the ISO date from seed-meta fetchedAt');
const cachedScore = redis.get('resilience:score:v8:US');
const cachedScore = redis.get('resilience:score:v9:US');
assert.ok(cachedScore, 'expected score cache to be written');
assert.equal(JSON.parse(cachedScore || '{}').countryCode, 'US');

View File

@@ -0,0 +1,179 @@
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 v9', () => {
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v9:');
});
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');
});
});

View File

@@ -61,11 +61,11 @@ describe('PILLAR_DOMAINS membership', () => {
}
});
it('keeps recovery-capacity empty until PR 3 / T2.2b adds the new dimensions', () => {
assert.equal(
PILLAR_DOMAINS['recovery-capacity'].length,
0,
'recovery-capacity must ship empty in T2.1; PR 3 (T2.2b) wires the new recovery-capacity dimensions',
it('recovery-capacity contains the recovery domain (wired by T2.2b)', () => {
assert.deepEqual(
[...PILLAR_DOMAINS['recovery-capacity']],
['recovery'],
'recovery-capacity must contain the recovery domain wired by PR 3 (T2.2b)',
);
});
@@ -144,7 +144,7 @@ describe('buildPillarList', () => {
assert.ok(structural, 'structural-readiness pillar must be present');
assert.deepEqual(
structural!.domains.map((domain) => domain.id),
['economic', 'infrastructure', 'social-governance'],
['economic', 'social-governance'],
'structural-readiness must contain the long-run capacity domains in input order',
);
@@ -152,16 +152,16 @@ describe('buildPillarList', () => {
assert.ok(liveShock, 'live-shock-exposure pillar must be present');
assert.deepEqual(
liveShock!.domains.map((domain) => domain.id),
['energy', 'health-food'],
['infrastructure', 'energy', 'health-food'],
'live-shock-exposure must contain the shock-pressure domains in input order',
);
const recovery = result.find((pillar) => pillar.id === 'recovery-capacity');
assert.ok(recovery, 'recovery-capacity pillar must be present');
assert.deepEqual(
recovery!.domains,
[],
'recovery-capacity ships empty in T2.1; PR 3 (T2.2b) seeds the new dimensions',
recovery!.domains.map((d) => d.id),
['recovery'],
'recovery-capacity contains the recovery domain from PR 3 (T2.2b)',
);
});
@@ -172,12 +172,13 @@ describe('buildPillarList', () => {
makeDomain('economic'),
makeDomain('energy'),
makeDomain('social-governance'),
makeDomain('recovery'),
];
const result = buildPillarList(shuffled, true);
const structural = result.find((pillar) => pillar.id === 'structural-readiness')!;
assert.deepEqual(
structural.domains.map((domain) => domain.id),
['infrastructure', 'economic', 'social-governance'],
['economic', 'social-governance'],
'pillar.domains must preserve the order of the source domains array, not PILLAR_DOMAINS membership order',
);
});

View File

@@ -46,12 +46,12 @@ describe('resilience ranking contracts', () => {
],
greyedOut: [],
};
redis.set('resilience:ranking:v8', JSON.stringify(cached));
redis.set('resilience:ranking:v9', JSON.stringify(cached));
const response = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
assert.deepEqual(response, cached);
assert.equal(redis.has('resilience:score:v8:YE'), false, 'cache hit must not trigger score warmup');
assert.equal(redis.has('resilience:score:v9:YE'), false, 'cache hit must not trigger score warmup');
});
it('returns all-greyed-out cached payload without rewarming (items=[], greyedOut non-empty)', async () => {
@@ -65,18 +65,18 @@ describe('resilience ranking contracts', () => {
{ countryCode: 'ER', overallScore: 10, level: 'critical', lowConfidence: true, overallCoverage: 0.12 },
],
};
redis.set('resilience:ranking:v8', JSON.stringify(cached));
redis.set('resilience:ranking:v9', JSON.stringify(cached));
const response = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
assert.deepEqual(response, cached);
assert.equal(redis.has('resilience:score:v8:SS'), false, 'all-greyed-out cache hit must not trigger score warmup');
assert.equal(redis.has('resilience:score:v9:SS'), false, 'all-greyed-out cache hit must not trigger score warmup');
});
it('warms missing scores synchronously and returns complete ranking on first call', async () => {
const { redis } = installRedis(RESILIENCE_FIXTURES);
const domainWithCoverage = [{ name: 'political', dimensions: [{ name: 'd1', coverage: 0.9 }] }];
redis.set('resilience:score:v8:NO', JSON.stringify({
redis.set('resilience:score:v9:NO', JSON.stringify({
countryCode: 'NO',
overallScore: 82,
level: 'high',
@@ -86,7 +86,7 @@ describe('resilience ranking contracts', () => {
lowConfidence: false,
imputationShare: 0.05,
}));
redis.set('resilience:score:v8:US', JSON.stringify({
redis.set('resilience:score:v9:US', JSON.stringify({
countryCode: 'US',
overallScore: 61,
level: 'medium',
@@ -101,20 +101,20 @@ describe('resilience ranking contracts', () => {
const totalItems = response.items.length + (response.greyedOut?.length ?? 0);
assert.equal(totalItems, 3, `expected 3 total items across ranked + greyedOut, got ${totalItems}`);
assert.ok(redis.has('resilience:score:v8:YE'), 'missing country should be warmed during first call');
assert.ok(redis.has('resilience:score:v9:YE'), 'missing country should be warmed during first call');
assert.ok(response.items.every((item) => item.overallScore >= 0), 'ranked items should all have computed scores');
assert.ok(redis.has('resilience:ranking:v8'), 'fully scored ranking should be cached');
assert.ok(redis.has('resilience:ranking:v9'), 'fully scored ranking should be cached');
});
it('sets rankStable=true when interval data exists and width <= 8', async () => {
const { redis } = installRedis(RESILIENCE_FIXTURES);
const domainWithCoverage = [{ id: 'political', score: 80, weight: 0.2, dimensions: [{ id: 'd1', score: 80, coverage: 0.9, observedWeight: 1, imputedWeight: 0 }] }];
redis.set('resilience:score:v8:NO', JSON.stringify({
redis.set('resilience:score:v9:NO', JSON.stringify({
countryCode: 'NO', overallScore: 82, level: 'high',
domains: domainWithCoverage, trend: 'stable', change30d: 1.2,
lowConfidence: false, imputationShare: 0.05,
}));
redis.set('resilience:score:v8:US', JSON.stringify({
redis.set('resilience:score:v9:US', JSON.stringify({
countryCode: 'US', overallScore: 61, level: 'medium',
domains: domainWithCoverage, trend: 'rising', change30d: 4.3,
lowConfidence: false, imputationShare: 0.1,

View File

@@ -140,7 +140,7 @@ describe('resilience scorer contracts', () => {
return round(cwMean) * getResilienceDomainWeight(domainId);
}).reduce((sum, v) => sum + v, 0),
);
assert.equal(overallScore, 68.72);
assert.equal(overallScore, 65.23);
});
it('baselineScore is computed from baseline + mixed dimensions only', async () => {
@@ -211,7 +211,7 @@ describe('resilience scorer contracts', () => {
);
assert.ok(expected > 0, 'overall should be positive');
assert.equal(expected, 68.72, 'overallScore should match sum(domainScore * domainWeight)');
assert.equal(expected, 65.23, 'overallScore should match sum(domainScore * domainWeight)');
});
it('stressFactor is still computed (informational) and clamped to [0, 0.5]', () => {

View File

@@ -10,12 +10,12 @@ import {
} from '../scripts/seed-resilience-scores.mjs';
describe('exported constants', () => {
it('RESILIENCE_RANKING_CACHE_KEY matches server-side key (v8)', () => {
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v8');
it('RESILIENCE_RANKING_CACHE_KEY matches server-side key (v9)', () => {
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v9');
});
it('RESILIENCE_SCORE_CACHE_PREFIX matches server-side prefix (v8)', () => {
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v8:');
it('RESILIENCE_SCORE_CACHE_PREFIX matches server-side prefix (v9)', () => {
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v9:');
});
it('RESILIENCE_RANKING_CACHE_TTL_SECONDS is 6 hours', () => {