diff --git a/docs/api/ResilienceService.openapi.json b/docs/api/ResilienceService.openapi.json index 718771b6d..ac7ce7e72 100644 --- a/docs/api/ResilienceService.openapi.json +++ b/docs/api/ResilienceService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"DimensionFreshness":{"properties":{"lastObservedAtMs":{"description":"Unix milliseconds when the oldest constituent signal in this\n dimension was last observed (min fetchedAt across INDICATOR_REGISTRY\n entries for this dimension). 0 when no signal has ever been\n observed.","format":"int64","type":"string"},"staleness":{"description":"Worst staleness level across the dimension's constituent signals,\n classified by classifyStaleness against each signal's cadence.\n One of: \"fresh\", \"aging\", \"stale\". Empty string when no signals.","type":"string"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetResilienceRankingRequest":{"type":"object"},"GetResilienceRankingResponse":{"properties":{"greyedOut":{"items":{"$ref":"#/components/schemas/ResilienceRankingItem"},"type":"array"},"items":{"items":{"$ref":"#/components/schemas/ResilienceRankingItem"},"type":"array"}},"type":"object"},"GetResilienceScoreRequest":{"properties":{"countryCode":{"type":"string"}},"type":"object"},"GetResilienceScoreResponse":{"properties":{"baselineScore":{"format":"double","type":"number"},"change30d":{"format":"double","type":"number"},"countryCode":{"type":"string"},"dataVersion":{"type":"string"},"domains":{"items":{"$ref":"#/components/schemas/ResilienceDomain"},"type":"array"},"imputationShare":{"format":"double","type":"number"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallScore":{"format":"double","type":"number"},"pillars":{"items":{"$ref":"#/components/schemas/ResiliencePillar"},"type":"array"},"schemaVersion":{"description":"Phase 2 T2.1: \"1.0\" (default, preserves the current response shape)\n or \"2.0\" (adds pillars; keeps overall_score / baseline_score / etc.\n populated for one release cycle for backward compat). Controlled at\n response build time by the RESILIENCE_SCHEMA_V2_ENABLED env flag.","type":"string"},"scoreInterval":{"$ref":"#/components/schemas/ScoreInterval"},"stressFactor":{"format":"double","type":"number"},"stressScore":{"format":"double","type":"number"},"trend":{"type":"string"}},"type":"object"},"ResilienceDimension":{"properties":{"coverage":{"format":"double","type":"number"},"freshness":{"$ref":"#/components/schemas/DimensionFreshness"},"id":{"type":"string"},"imputationClass":{"description":"Four-class imputation taxonomy (Phase 1 T1.7). Empty string when the\n dimension has any observed data. One of: \"stable-absence\", \"unmonitored\",\n \"source-failure\", \"not-applicable\". See docs/methodology/country-resilience-index.mdx.","type":"string"},"imputedWeight":{"format":"double","type":"number"},"observedWeight":{"format":"double","type":"number"},"score":{"format":"double","type":"number"}},"type":"object"},"ResilienceDomain":{"properties":{"dimensions":{"items":{"$ref":"#/components/schemas/ResilienceDimension"},"type":"array"},"id":{"type":"string"},"score":{"format":"double","type":"number"},"weight":{"format":"double","type":"number"}},"type":"object"},"ResiliencePillar":{"description":"Phase 2 T2.1 of the country-resilience reference-grade upgrade plan.\n Three-pillar response shape that regroups the existing 5 domains into\n long-run capacity, current shock pressure, and recovery capability.\n Shipped as a shaped-but-empty payload in T2.1 (score=0, coverage=0);\n real aggregation lands in T2.3 / PR 4 of the Phase 2 rebuild.","properties":{"coverage":{"description":"Coverage in [0, 1]. 0 when shipped empty (T2.1).","format":"double","type":"number"},"domains":{"items":{"$ref":"#/components/schemas/ResilienceDomain"},"type":"array"},"id":{"description":"\"structural-readiness\" | \"live-shock-exposure\" | \"recovery-capacity\".","type":"string"},"score":{"description":"Pillar score in [0, 100]. 0 when shipped empty (T2.1).","format":"double","type":"number"},"weight":{"description":"Pillar weight in the overall combine. Per the plan: 0.40 / 0.35 / 0.25.","format":"double","type":"number"}},"type":"object"},"ResilienceRankingItem":{"properties":{"countryCode":{"type":"string"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallCoverage":{"format":"double","type":"number"},"overallScore":{"format":"double","type":"number"},"rankStable":{"type":"boolean"}},"type":"object"},"ScoreInterval":{"properties":{"p05":{"format":"double","type":"number"},"p95":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ResilienceService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/resilience/v1/get-resilience-ranking":{"get":{"operationId":"GetResilienceRanking","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceRankingResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetResilienceRanking","tags":["ResilienceService"]}},"/api/resilience/v1/get-resilience-score":{"get":{"operationId":"GetResilienceScore","parameters":[{"in":"query","name":"countryCode","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceScoreResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetResilienceScore","tags":["ResilienceService"]}}}} \ No newline at end of file +{"components":{"schemas":{"DimensionFreshness":{"properties":{"lastObservedAtMs":{"description":"Unix milliseconds when the oldest constituent signal in this\n dimension was last observed (min fetchedAt across INDICATOR_REGISTRY\n entries for this dimension). 0 when no signal has ever been\n observed.","format":"int64","type":"string"},"staleness":{"description":"Worst staleness level across the dimension's constituent signals,\n classified by classifyStaleness against each signal's cadence.\n One of: \"fresh\", \"aging\", \"stale\". Empty string when no signals.","type":"string"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetResilienceRankingRequest":{"type":"object"},"GetResilienceRankingResponse":{"properties":{"greyedOut":{"items":{"$ref":"#/components/schemas/ResilienceRankingItem"},"type":"array"},"items":{"items":{"$ref":"#/components/schemas/ResilienceRankingItem"},"type":"array"}},"type":"object"},"GetResilienceScoreRequest":{"properties":{"countryCode":{"type":"string"}},"type":"object"},"GetResilienceScoreResponse":{"properties":{"baselineScore":{"format":"double","type":"number"},"change30d":{"format":"double","type":"number"},"countryCode":{"type":"string"},"dataVersion":{"type":"string"},"domains":{"items":{"$ref":"#/components/schemas/ResilienceDomain"},"type":"array"},"imputationShare":{"format":"double","type":"number"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallScore":{"format":"double","type":"number"},"pillars":{"items":{"$ref":"#/components/schemas/ResiliencePillar"},"type":"array"},"schemaVersion":{"description":"Phase 2 T2.1/T2.3: \"2.0\" is the current default (adds pillars; keeps\n overall_score / baseline_score / etc. populated for backward compat).\n \"1.0\" is the legacy opt-out shape (pillars empty) retained for one\n release cycle. Controlled at response build time by the\n RESILIENCE_SCHEMA_V2_ENABLED env flag (defaults to \"true\" → v2).","type":"string"},"scoreInterval":{"$ref":"#/components/schemas/ScoreInterval"},"stressFactor":{"format":"double","type":"number"},"stressScore":{"format":"double","type":"number"},"trend":{"type":"string"}},"type":"object"},"ResilienceDimension":{"properties":{"coverage":{"format":"double","type":"number"},"freshness":{"$ref":"#/components/schemas/DimensionFreshness"},"id":{"type":"string"},"imputationClass":{"description":"Four-class imputation taxonomy (Phase 1 T1.7). Empty string when the\n dimension has any observed data. One of: \"stable-absence\", \"unmonitored\",\n \"source-failure\", \"not-applicable\". See docs/methodology/country-resilience-index.mdx.","type":"string"},"imputedWeight":{"format":"double","type":"number"},"observedWeight":{"format":"double","type":"number"},"score":{"format":"double","type":"number"}},"type":"object"},"ResilienceDomain":{"properties":{"dimensions":{"items":{"$ref":"#/components/schemas/ResilienceDimension"},"type":"array"},"id":{"type":"string"},"score":{"format":"double","type":"number"},"weight":{"format":"double","type":"number"}},"type":"object"},"ResiliencePillar":{"description":"Phase 2 T2.1/T2.3 of the country-resilience reference-grade upgrade plan.\n Three-pillar response shape that regroups the 6 ResilienceDomains\n (economic, infrastructure, energy, social-governance, health-food,\n recovery) into long-run capacity (structural-readiness), current shock\n pressure (live-shock-exposure), and recovery capability (recovery-capacity).\n Pillar scores and coverage are real coverage-weighted aggregates computed\n from the constituent domains; see _pillar-membership.ts for the mapping.\n The top-level overall_score on GetResilienceScoreResponse remains a\n domain-weighted aggregate (Σ domain.score * domain.weight) for this\n release cycle; a pillar-combined score with penalty term is staged in\n _shared.ts#penalizedPillarScore and validated by\n scripts/validate-resilience-sensitivity.mjs ahead of the activation PR.","properties":{"coverage":{"description":"Coverage in [0, 1], mean of member-domain average dimension coverage.","format":"double","type":"number"},"domains":{"items":{"$ref":"#/components/schemas/ResilienceDomain"},"type":"array"},"id":{"description":"\"structural-readiness\" | \"live-shock-exposure\" | \"recovery-capacity\".","type":"string"},"score":{"description":"Pillar score in [0, 100], coverage-weighted mean of member domains.","format":"double","type":"number"},"weight":{"description":"Pillar weight in the pillar-combined score. Per the plan: 0.40 / 0.35 / 0.25.","format":"double","type":"number"}},"type":"object"},"ResilienceRankingItem":{"properties":{"countryCode":{"type":"string"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallCoverage":{"format":"double","type":"number"},"overallScore":{"format":"double","type":"number"},"rankStable":{"type":"boolean"}},"type":"object"},"ScoreInterval":{"properties":{"p05":{"format":"double","type":"number"},"p95":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ResilienceService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/resilience/v1/get-resilience-ranking":{"get":{"operationId":"GetResilienceRanking","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceRankingResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetResilienceRanking","tags":["ResilienceService"]}},"/api/resilience/v1/get-resilience-score":{"get":{"operationId":"GetResilienceScore","parameters":[{"in":"query","name":"countryCode","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceScoreResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetResilienceScore","tags":["ResilienceService"]}}}} \ No newline at end of file diff --git a/docs/api/ResilienceService.openapi.yaml b/docs/api/ResilienceService.openapi.yaml index 5bd34c035..c3517ae7a 100644 --- a/docs/api/ResilienceService.openapi.yaml +++ b/docs/api/ResilienceService.openapi.yaml @@ -141,10 +141,11 @@ components: schemaVersion: type: string description: |- - Phase 2 T2.1: "1.0" (default, preserves the current response shape) - or "2.0" (adds pillars; keeps overall_score / baseline_score / etc. - populated for one release cycle for backward compat). Controlled at - response build time by the RESILIENCE_SCHEMA_V2_ENABLED env flag. + Phase 2 T2.1/T2.3: "2.0" is the current default (adds pillars; keeps + overall_score / baseline_score / etc. populated for backward compat). + "1.0" is the legacy opt-out shape (pillars empty) retained for one + release cycle. Controlled at response build time by the + RESILIENCE_SCHEMA_V2_ENABLED env flag (defaults to "true" → v2). ResilienceDomain: type: object properties: @@ -220,25 +221,32 @@ components: score: type: number format: double - description: Pillar score in [0, 100]. 0 when shipped empty (T2.1). + description: Pillar score in [0, 100], coverage-weighted mean of member domains. weight: type: number format: double - description: 'Pillar weight in the overall combine. Per the plan: 0.40 / 0.35 / 0.25.' + description: 'Pillar weight in the pillar-combined score. Per the plan: 0.40 / 0.35 / 0.25.' coverage: type: number format: double - description: Coverage in [0, 1]. 0 when shipped empty (T2.1). + description: Coverage in [0, 1], mean of member-domain average dimension coverage. domains: type: array items: $ref: '#/components/schemas/ResilienceDomain' description: |- - Phase 2 T2.1 of the country-resilience reference-grade upgrade plan. - Three-pillar response shape that regroups the existing 5 domains into - long-run capacity, current shock pressure, and recovery capability. - Shipped as a shaped-but-empty payload in T2.1 (score=0, coverage=0); - real aggregation lands in T2.3 / PR 4 of the Phase 2 rebuild. + Phase 2 T2.1/T2.3 of the country-resilience reference-grade upgrade plan. + Three-pillar response shape that regroups the 6 ResilienceDomains + (economic, infrastructure, energy, social-governance, health-food, + recovery) into long-run capacity (structural-readiness), current shock + pressure (live-shock-exposure), and recovery capability (recovery-capacity). + Pillar scores and coverage are real coverage-weighted aggregates computed + from the constituent domains; see _pillar-membership.ts for the mapping. + The top-level overall_score on GetResilienceScoreResponse remains a + domain-weighted aggregate (Σ domain.score * domain.weight) for this + release cycle; a pillar-combined score with penalty term is staged in + _shared.ts#penalizedPillarScore and validated by + scripts/validate-resilience-sensitivity.mjs ahead of the activation PR. GetResilienceRankingRequest: type: object GetResilienceRankingResponse: diff --git a/docs/documentation.mdx b/docs/documentation.mdx index 3b850aa13..6ecd1dd3b 100644 --- a/docs/documentation.mdx +++ b/docs/documentation.mdx @@ -11,7 +11,7 @@ The platform is designed for journalists, security analysts, researchers, PRO su - **Monitor global events in real time** on a 3D globe or flat map with toggleable data layers for military flights, naval vessels, satellites, earthquakes, wildfires, cyber threats, disease outbreaks, radiation, and more - **Read an AI-generated daily intelligence brief** that synthesises the day's headlines into a structured, source-attributed summary — viewable in the dashboard, as a public share link, or as a social-ready image carousel -- **Screen country-level risk two ways**: the [Country Instability Index](/country-instability-index) for a high-frequency stress signal on a curated country set, and the [Country Resilience Index](/methodology/country-resilience-index) for a 222-country resilience score across 5 domains and 13 dimensions, refreshed every 6 hours +- **Screen country-level risk two ways**: the [Country Instability Index](/country-instability-index) for a high-frequency stress signal on a curated country set, and the [Country Resilience Index](/methodology/country-resilience-index) for a ~220-country resilience score across 6 domains and 19 dimensions (grouped into 3 pillars), refreshed every 6 hours - **Plan shipments with [Route Explorer](/route-explorer)** — a full-screen keyboard-first workflow that resolves chokepoint exposures, alternative corridors, land routes, and country-level impact for any origin-destination-commodity combination - **Run disruption scenarios with the [Scenario Engine](/scenario-engine)** — pre-built conflict, weather, sanctions, and tariff-shock scenarios that paint impact across chokepoints, sectors, and countries directly on the map - **Analyse financial signals** including market quotes, commodity prices, prediction markets, central-bank rates, EU macro indicators, and Gulf-economy dashboards diff --git a/docs/features.mdx b/docs/features.mdx index acc92507e..dafa81b09 100644 --- a/docs/features.mdx +++ b/docs/features.mdx @@ -246,7 +246,7 @@ Two full-screen, keyboard-first workflows complement the map + panels experience Two complementary country-scoring surfaces: - **[Country Instability Index](/country-instability-index)** — a high-frequency stress score that blends conflict, unrest, news velocity, and security signals for a curated country set. -- **[Country Resilience Index](/methodology/country-resilience-index)** — a 222-country, 0-100 resilience score across 5 domains and 13 dimensions, refreshed every 6 hours and sourced from official/authoritative providers with transparent imputation. +- **[Country Resilience Index](/methodology/country-resilience-index)** — a ~220-country, 0-100 resilience score across 6 domains and 19 dimensions (grouped into 3 pillars: structural readiness, live shock exposure, recovery capacity), refreshed every 6 hours and sourced from official/authoritative providers with transparent imputation. CII and CRI answer different questions and are not interchangeable: CII is short-horizon stress; CRI is structural resilience plus live shock exposure. diff --git a/docs/methodology/country-resilience-index.mdx b/docs/methodology/country-resilience-index.mdx index f464a386c..a1c0ebe9c 100644 --- a/docs/methodology/country-resilience-index.mdx +++ b/docs/methodology/country-resilience-index.mdx @@ -1,6 +1,6 @@ --- title: "Country Resilience Index" -description: "Real-time resilience scoring for 222 countries across 5 domains and 13 dimensions, combining structural baseline indicators with live stress signals into a 0-100 resilience score updated every 6 hours. Published at OECD/JRC methodological parity with transparent goalposts, coverage tracking, and a formal four-class imputation taxonomy." +description: "Real-time resilience scoring for ~220 countries across 6 domains and 19 dimensions, combining structural baseline indicators with live stress signals into a 0-100 resilience score updated every 6 hours. Published at OECD/JRC methodological parity with transparent goalposts, coverage tracking, and a formal four-class imputation taxonomy." --- The WorldMonitor Country Resilience Index (CRI) scores every country in the world on a 0-100 scale, combining long-run structural capacity with current operational stress to produce an actionable resilience metric. Rather than relying on static country risk ratings, the CRI updates every 6 hours from official and authoritative sources and exposes full provenance, coverage, and imputation context so analysts can see exactly *why* a score moved and how much of it is real data versus imputed. @@ -19,29 +19,32 @@ All three surfaces are free to view. The underlying data served at `/api/resilie ## Overview -The WorldMonitor Country Resilience Index scores 222 countries on a 0-100 scale across 5 domains and 13 dimensions. It combines structural baseline indicators (governance quality, health infrastructure, fiscal capacity) with real-time stress signals (cyber threats, conflict events, shipping disruption) to produce a single resilience score updated every 6 hours. +The WorldMonitor Country Resilience Index scores ~220 countries on a 0-100 scale across 6 domains and 19 dimensions (a static index of 222 is built every cron tick; countries whose mean dimension coverage falls below 0.40 are greyed out of the public ranking). It combines structural baseline indicators (governance quality, health infrastructure, fiscal capacity) with real-time stress signals (cyber threats, conflict events, shipping disruption) and recovery-capacity indicators (fiscal space, reserves, import concentration) to produce a single resilience score updated every 6 hours. Data is sourced from official and authoritative providers: World Bank, IMF, WHO, WTO, OFAC, UNHCR, UCDP, BIS, IEA, FAO, Reporters Sans Frontieres, and the Institute for Economics and Peace, among others. ## Domains and Weights -The index is organized into 5 domains. Each domain weight reflects its relative contribution to overall national resilience. +The index is organized into 6 domains. Each domain weight reflects its relative contribution to overall national resilience. Recovery carries the largest single-domain weight (0.25) because the ability to absorb and recover from a shock is the single best structural predictor of post-shock outcomes; this is why fiscally strong smaller states cluster at the top of the ranking and fragile states separate cleanly at the bottom. | Domain | ID | Weight | Dimensions | |---|---|---|---| -| Economic | `economic` | 0.22 | Macro-Fiscal, Currency & External, Trade & Sanctions | -| Infrastructure | `infrastructure` | 0.20 | Cyber & Digital, Logistics & Supply, Infrastructure | -| Energy | `energy` | 0.15 | Energy | -| Social & Governance | `social-governance` | 0.25 | Governance, Social Cohesion, Border Security, Information | -| Health & Food | `health-food` | 0.18 | Health & Public Service, Food & Water | +| Economic | `economic` | 0.17 | Macro-Fiscal, Currency & External, Trade & Sanctions | +| Infrastructure | `infrastructure` | 0.15 | Cyber & Digital, Logistics & Supply, Infrastructure | +| Energy | `energy` | 0.11 | Energy | +| Social & Governance | `social-governance` | 0.19 | Governance, Social Cohesion, Border Security, Information | +| Health & Food | `health-food` | 0.13 | Health & Public Service, Food & Water | +| Recovery | `recovery` | 0.25 | Fiscal Space, Reserve Adequacy, External Debt Coverage, Import Concentration, State Continuity, Fuel Stock Days | -Weights sum to 1.00. +Weights sum to 1.00. The authoritative values live in `RESILIENCE_DOMAIN_WEIGHTS` in `server/worldmonitor/resilience/v1/_dimension-scorers.ts`; if this table and the code disagree, the code wins. + +The 6 domains are regrouped into 3 pillars (structural-readiness, live-shock-exposure, recovery-capacity) with weights 0.40 / 0.35 / 0.25 for the Phase 2 pillar-combined score. The pillar shape is emitted today on every response (`schemaVersion="2.0"`, `pillars[]` populated with real coverage-weighted scores). The top-level `overallScore` is still the 6-domain weighted aggregate above; a pillar-combined score with a min-pillar penalty is staged in `_shared.ts#penalizedPillarScore` and activation is a separate PR. ## Dimensions and Indicators Each dimension is scored from 0-100 using a weighted blend of its sub-metrics. Below is the complete indicator registry. -### Economic Domain (weight 0.22) +### Economic Domain (weight 0.17) #### Macro-Fiscal @@ -72,7 +75,7 @@ For non-BIS countries (~160 countries), a fallback chain applies: (1) IMF inflat Sanctions use piecewise normalization: 0 entities = score 100, 1-10 = 90-75, 11-50 = 75-50, 51-200 = 50-25, 201+ tapers toward 0. -### Infrastructure Domain (weight 0.20) +### Infrastructure Domain (weight 0.15) #### Cyber & Digital @@ -100,7 +103,7 @@ Sanctions use piecewise normalization: 0 entities = score 100, 1-10 = 90-75, 11- **Note on the paved-roads indicator.** The same World Bank series (`IS.ROD.PAVE.ZS`) feeds two dimensions inside the Infrastructure domain: `roadsPavedLogistics` under Logistics & Supply (weight 0.50 within the dimension) and `roadsPavedInfra` here under Infrastructure (weight 0.35 within the dimension). This is deliberate source reuse, not accidental double counting: Logistics & Supply uses paved-road coverage as a proxy for transit viability, while Infrastructure uses it as a proxy for baseline public capital stock. The two dimensions legitimately care about the same signal for different reasons, and each dimension's contribution to the domain is further mediated by the dimension weight in `coverage-weighted mean` aggregation (see the Scoring Formula section). The v2.0 reference-grade upgrade plan is expected to consolidate shared upstream signals into a single indicator registry so this kind of reuse is documented at the source level rather than per-dimension; for v1.0 the two separate metric rows are preserved for backward compatibility. -### Energy Domain (weight 0.15) +### Energy Domain (weight 0.11) #### Energy @@ -114,7 +117,7 @@ Sanctions use piecewise normalization: 0 entities = score 100, 1-10 = 90-75, 11- | energyPriceStress | Mean absolute energy price change across commodities | Lower is better | 25 - 0 | 0.10 | Energy prices | Daily | | electricityConsumption | Per-capita electricity consumption (kWh/year, World Bank EG.USE.ELEC.KH.PC) | Higher is better | 200 - 8000 | 0.30 | World Bank | Annual | -### Social & Governance Domain (weight 0.25) +### Social & Governance Domain (weight 0.19) #### Governance @@ -152,7 +155,7 @@ All six WGI indicators are equally weighted. | socialVelocity | Reddit social velocity (log10(velocity+1)) | Lower is better | 3 - 0 | 0.15 | Reddit intelligence | Realtime | | newsThreatScore | AI news threat severity (critical 4x, high 2x, medium 1x, low 0.5x) | Lower is better | 20 - 0 | 0.30 | News threat analysis | Daily | -### Health & Food Domain (weight 0.18) +### Health & Food Domain (weight 0.13) #### Health & Public Service @@ -171,7 +174,7 @@ All six WGI indicators are equally weighted. | aquastatWaterStress | FAO AQUASTAT water stress/withdrawal/dependency (%) | Lower is better | 100 - 0 | 0.25 | FAO AQUASTAT | Annual | | aquastatWaterAvailability | FAO AQUASTAT water availability (m3/capita) | Higher is better | 0 - 5000 | 0.15 | FAO AQUASTAT | Annual | -### Recovery Domain (weight 1.0) +### Recovery Domain (weight 0.25) This domain forms the recovery-capacity pillar. It measures a country's ability to bounce back from an acute shock along fiscal, monetary, trade, institutional, and energy dimensions. @@ -400,7 +403,7 @@ Given a Redis snapshot at time T: 1. Read `seed-meta:resilience:static` for the `dataVersion`. 2. Read `resilience:static:{cc}` for the country's baseline record (WGI, WHO, GPI, RSF, FAO, IEA, and so on). 3. Read the live-signal keys (UCDP, UNHCR, OFAC, outages, cyber threats, prices, shipping stress, and so on) for the country's slice. -4. For each of the 13 dimensions, apply the formulas in the Scoring Formula section with the goalposts from the Dimensions and Indicators tables. For missing signals, consult the Imputation Taxonomy table in this document. +4. For each of the 19 dimensions, apply the formulas in the Scoring Formula section with the goalposts from the Dimensions and Indicators tables. For missing signals, consult the Imputation Taxonomy table in this document. 5. Aggregate dimension scores into domain scores via coverage-weighted mean. 6. Aggregate domain scores into the overall score via domain-weighted sum. @@ -410,7 +413,7 @@ A reference Python notebook under `docs/methodology/country-resilience-index/ref ### v1.0 (April 2026) -**Baseline.** Scored on domain-weighted average of 5 domains and 13 dimensions. +**Baseline.** Scored on domain-weighted average of 5 domains and 13 dimensions (pre-Recovery domain). - PR #2821: added the baseline-vs-stress engine and the `dataVersion` field on the response. - PR #2847: reverted the overall-score formula from `baseline * (1 - stressFactor)` (which over-penalized every country) to a domain-weighted sum; fixed the RSF press-freedom direction (0 means free, scored higher is better). @@ -430,7 +433,7 @@ A reference Python notebook under `docs/methodology/country-resilience-index/ref - **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: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. +**What did not change in v1.1**: the domain-weighted aggregation formula, the 5-domain / 13-dimension structure as of v1.1, the goalpost ranges, the per-dimension weights. (Phase 2 below added the Recovery domain + 6 new recovery dimensions for the current 6/19 shape and rewired domain weights; the aggregation formula itself was unchanged.) Phase 2 owns the structural three-pillar rebuild; v1.1 is the methodology-surface and observability lift only. ### Scorecard (v1.1 self-assessment) @@ -454,14 +457,42 @@ Self-assessed against the standard composite-indicator review axes on a 0-10 sca - **T2.1** (#2977): Three-pillar schema added to proto and OpenAPI. `schemaVersion: "2.0"` feature flag introduced with backward-compatible `"1.0"` fallback path for one release cycle. Response now carries a `pillars` array alongside existing `domains`. - **T2.2a** (#2979): Signal tiering registry committed. Every indicator tagged Core, Enrichment, or Experimental with per-signal coverage percentage and license audit status. Registry enforced by CI linter. - **T2.2b** (#2987): Recovery capacity pillar with 6 new dimensions across a new `recovery` domain: fiscal space (debt service ratio), reserve adequacy (months of imports), short-term external debt coverage, import concentration (HHI), hospital surge capacity, and state continuity composite (WGI subset). Five new seeders following Railway gold-standard pattern (3 real data sources, 2 stubs pending source configuration). Cache key bumped to the current version. -- **T2.3** (#2990): Three-pillar aggregation using penalized weighted mean. Pillar weights: structural readiness 0.40, live shock exposure 0.35, recovery capacity 0.25. Penalty factor `(1 - alpha * max(0, pillar_gap / 100))` with alpha = 0.5. Domain-weighted scores feed into pillar scores; pillar-weighted scores feed into the overall score with the penalty applied when the gap between the strongest and weakest pillar exceeds a threshold. +- **T2.3** (#2990): Three-pillar aggregation shape shipped. Every response now carries real coverage-weighted pillar scores and pillar coverage at `pillars[]`. Pillar weights: structural readiness 0.40, live shock exposure 0.35, recovery capacity 0.25. A penalty factor `(1 − α × (1 − min_pillar / 100))` with α = 0.5 is defined as `penalizedPillarScore` in `server/worldmonitor/resilience/v1/_shared.ts` and is exercised by the sensitivity suite. The **top-level `overall_score` is still the 6-domain weighted aggregate** for this release cycle; the switch to the penalized pillar-combined form is staged behind the `feat/activate-score-gate` branch and is pending the Pillar-combined score activation section below. - **T2.4** (#2985): Cross-index benchmark script validates each pillar against four established indices (INFORM Risk Index, ND-GAIN, WorldRiskIndex, Fragile States Index) via Spearman and Pearson correlation with per-pillar directional hypotheses. Results stored in `resilience:benchmark:external:v1` and committed as validation artifacts. - **T2.5** (#2986): Outcome backtest framework covering 7 event families (FX stress, sovereign stress, power outages, food-crisis escalation, refugee surges, sanctions shocks, conflict spillover). Each family has a binary event definition, a 2024-2025 hold-out window, and an AUC release gate of 0.75 or higher. - **T2.6/T2.8** (#2991): Sensitivity suite v2 with 4-pass perturbation (weight, goalpost, imputation, alpha), alpha-curve analysis, and ceiling-effect detection. Release gate: no single-axis perturbation moves a top-50 country by more than 5 rank positions; overall dimension failure rate must be 20% or lower. - **T2.7** (#2988): Railway cron service wired for weekly benchmark, backtest, and sensitivity runs. Results published to Redis with health monitoring integration. - **T2.9** (#2992): Language and source-density normalization for the informationCognitive dimension. RSF press freedom and social velocity scores are weighted by language coverage of the source set to correct for English-press bias. The dimension is promoted back to Core tier after normalization. -**What changed from v1.1**: The five-domain flat structure is preserved as the inner aggregation layer, but a new three-pillar outer layer groups domains into structural readiness, live shock exposure, and recovery capacity. The overall score formula changes from a pure domain-weighted sum to a penalized weighted mean that prevents a strong institutional score from fully compensating severe live-shock exposure. Six new dimensions are added under the recovery capacity pillar. The cache key is bumped to the current version. The `schemaVersion` field is set to `"2.0"` by default (env var `RESILIENCE_SCHEMA_V2_ENABLED=false` provides a rollback path). +**What changed from v1.1**: The five-domain flat structure was extended into a six-domain structure by adding the Recovery domain with six new dimensions, and a three-pillar outer layer groups the six domains into structural readiness (0.40), live shock exposure (0.35), and recovery capacity (0.25). Every response now carries real pillar scores at `pillars[]`. The `schemaVersion` field is `"2.0"` by default (env var `RESILIENCE_SCHEMA_V2_ENABLED=false` provides a rollback path). **The top-level `overall_score` is still the 6-domain weighted aggregate** — the pillar-combined penalized formula is fully defined and validated (see the Pillar-combined score activation section below) but the flip is staged behind a separate PR so the visible score change can ship with a proper migration message. The cache key is bumped to the current version. + +### Pillar-combined score activation (pending) + +The plan's non-compensatory pillar combine is the methodologically stronger form: it prevents a strong institutional score from fully washing out a severe live-shock exposure. Before flipping the default we measured the actual impact on the live ranking. + +**Sensitivity and comparison artifact** (2026-04-21, commit `048bb8b`, 52-country sample, regenerated after the comparison script was corrected to use the production `buildPillarList` aggregation): [`docs/snapshots/resilience-pillar-sensitivity-2026-04-21.json`](../snapshots/resilience-pillar-sensitivity-2026-04-21.json). + +| Metric | Value | +|---|---| +| Spearman rank correlation (current vs proposed) | **0.9863** | +| Mean absolute score delta | **−11.30 points** (every country drops) | +| Max top-50 rank swing | **9 positions** (Syria) | +| Ceiling / floor effects under ±20% weight perturbation | **None detected** | +| Release gate result (≤20% dimensions exceeding 3-rank swing) | **PASS** (0/19 failures) | + +**Top 5 movers by absolute rank change:** + +| Country | Current rank | Proposed rank | Rank Δ | Current score | Proposed score | Score Δ | +|---|---:|---:|---:|---:|---:|---:| +| Syria | 40 | 49 | ↓9 | 49.64 | 30.55 | −19.09 | +| Central African Republic | 46 | 39 | ↑7 | 46.46 | 34.55 | −11.91 | +| Venezuela | 42 | 48 | ↓6 | 47.70 | 31.18 | −16.52 | +| Afghanistan | 33 | 37 | ↓4 | 54.55 | 37.97 | −16.58 | +| Russia | 23 | 27 | ↓4 | 61.08 | 46.28 | −14.80 | + +**Interpretation**: Rank order is strongly preserved on the 52-country sample (Spearman 0.9863 clears the ≥0.90 bar typically required for a rank-stable methodology change). The ranking *shape* — who is top-10, who is bottom-10, Lebanon below South Africa, Norway above the US — does not materially change. However, every country's absolute score drops on average ~11 points because the penalty factor is always ≤ 1, and imbalanced countries with one very weak pillar (Syria, Afghanistan, Venezuela, Russia) drop the most (15-19 points). Balanced top-tier countries (Switzerland, Sweden, Denmark, Iceland, Norway) drop the least (5-7 points). This is the intended behavior: the penalty punishes pillar imbalance, and pillar imbalance is strongly correlated with state fragility. + +**What this means for activation**: the rank-stability evidence supports flipping the default — there is no statistical reason to keep the legacy compensatory form. The blocker is messaging, not correctness: publishing "US = 52.65" the day after publishing "US = 65.4" without a v2.0 methodology note would look like a regression instead of a rigor upgrade. Activation is therefore scheduled as a single PR that (a) flips the default behind `RESILIENCE_PILLAR_COMBINE_ENABLED`, (b) re-anchors the release-gate bands (the current 70/35 thresholds map to roughly 60/25 in the pillar-combined scale), (c) publishes a refreshed frozen ranking snapshot, and (d) ships a methodology-change note alongside the widget. Until that PR lands, the published `overall_score` is the 6-domain weighted aggregate documented above. ### Scorecard (v2.0 self-assessment) diff --git a/docs/snapshots/resilience-pillar-sensitivity-2026-04-21.json b/docs/snapshots/resilience-pillar-sensitivity-2026-04-21.json new file mode 100644 index 000000000..0963c4391 --- /dev/null +++ b/docs/snapshots/resilience-pillar-sensitivity-2026-04-21.json @@ -0,0 +1,1472 @@ +{ + "capturedAt": "2026-04-21", + "commitSha": "048bb8bb525393dc4a9c1998b9877c1f8cc8c011", + "source": "Live Redis via scripts/validate-resilience-sensitivity.mjs + scripts/compare-resilience-current-vs-proposed.mjs. Regenerated on 2026-04-21 after the comparison script was corrected to use the production buildPillarList aggregation (coverage-weighted across member-domain average dimension coverage) instead of a local re-implementation with static domain weights. An earlier iteration of this artifact reported Spearman=0.9935 and mean|\u0394|=13.44; those numbers came from the incorrect static-weights path and are now superseded by the values below.", + "sampleStrategy": "52-country curated sample spanning high-resilience (Nordics, G7, Oceania), mid-resilience (G20 south, EU periphery), and fragile states (Sahel, Horn of Africa, Yemen, Syria). Matches the sensitivity script's SAMPLE constant so the two outputs are directly comparable.", + "decisionContext": { + "question": "If we flip overall_score from the current 6-domain weighted aggregate to the 3-pillar combined formula with penalty \u03b1=0.5, what actually changes?", + "currentFormula": "\u03a3 domain.score \u00d7 domain.weight across 6 domains (economic=0.17, infrastructure=0.15, energy=0.11, social-governance=0.19, health-food=0.13, recovery=0.25); compensatory; no penalty.", + "proposedFormula": "\u03a3 pillar.score \u00d7 pillar.weight across 3 pillars (structural-readiness=0.40, live-shock-exposure=0.35, recovery-capacity=0.25), multiplied by a non-compensatory penalty factor (1 \u2212 \u03b1 \u00d7 (1 \u2212 min(pillar)/100)) with \u03b1 = 0.5. Pillar scores are coverage-weighted means of their member domains (weighted by each member's average dimension coverage \u2014 see _pillar-membership.ts#buildPillarList).", + "activationBranchLocal": "feat/resilience-pillar-combined-activation (flag-gated default-off activation PR, stacked on this branch)" + }, + "findings": { + "headline": "Rank order is strongly preserved (Spearman 0.9863 on the 52-country sample), but every country's absolute score drops ~11 points on average. Countries with imbalanced pillars (Syria, Afghanistan, Venezuela) drop the most (15-19 points); balanced top-tier countries (Switzerland, Sweden, Denmark) drop the least (~5-6 points).", + "rankPreservation": { + "spearmanRankCorrelation": 0.9863, + "interpretation": "0.99+ is a strict rank-preservation bound; methodology changes in published composite indicators typically target Spearman \u2265 0.90 to be considered rank-stable. At 0.9863 we are well inside that envelope on the 52-country sample.", + "maxRankAbsDelta": 9, + "worstMoverCountry": "SY", + "worstMoverRankDelta": 9 + }, + "absoluteScoreImpact": { + "meanScoreDelta": -11.3, + "meanAbsScoreDelta": 11.3, + "interpretation": "Every country drops. Top-tier countries with balanced pillars (CH, SE, DK, IS, FI, NZ, NO) drop 5-8 points. Mid-tier G7 / G20 (US, Canada, Germany, UK) drop 10-14 points. Imbalanced fragile states (Syria, Afghanistan, Venezuela) drop 15-19 points. The penalty term is working exactly as designed \u2014 it punishes pillar imbalance, and pillar imbalance is strongly correlated with state fragility.", + "publicNumberMigration": "Publishing the proposed score without a migration message would look alarming: analysts who remember \"US = 65.4\" would see \"US = 52.65\". A v2.0 methodology-change note is required." + }, + "top5MoversByRank": [ + { + "countryCode": "SY", + "rank": { + "current": 40, + "proposed": 49, + "delta": 9 + }, + "score": { + "current": 49.64, + "proposed": 30.55, + "delta": -19.09 + }, + "pillars": { + "structuralReadiness": 32.1, + "liveShockExposure": 57.79, + "recoveryCapacity": 52.73, + "minPillar": 32.1 + } + }, + { + "countryCode": "CF", + "rank": { + "current": 46, + "proposed": 39, + "delta": -7 + }, + "score": { + "current": 46.46, + "proposed": 34.55, + "delta": -11.91 + }, + "pillars": { + "structuralReadiness": 50.38, + "liveShockExposure": 38.95, + "recoveryCapacity": 63.76, + "minPillar": 38.95 + } + }, + { + "countryCode": "VE", + "rank": { + "current": 42, + "proposed": 48, + "delta": 6 + }, + "score": { + "current": 47.7, + "proposed": 31.18, + "delta": -16.52 + }, + "pillars": { + "structuralReadiness": 37.87, + "liveShockExposure": 65.59, + "recoveryCapacity": 33.89, + "minPillar": 33.89 + } + }, + { + "countryCode": "AF", + "rank": { + "current": 33, + "proposed": 37, + "delta": 4 + }, + "score": { + "current": 54.55, + "proposed": 37.97, + "delta": -16.58 + }, + "pillars": { + "structuralReadiness": 51.68, + "liveShockExposure": 44.76, + "recoveryCapacity": 64.49, + "minPillar": 44.76 + } + }, + { + "countryCode": "RU", + "rank": { + "current": 23, + "proposed": 27, + "delta": 4 + }, + "score": { + "current": 61.08, + "proposed": 46.28, + "delta": -14.8 + }, + "pillars": { + "structuralReadiness": 47.95, + "liveShockExposure": 68.43, + "recoveryCapacity": 77.73, + "minPillar": 47.95 + } + } + ], + "top5BiggestScoreDrops": [ + { + "countryCode": "SY", + "scoreDelta": -19.09, + "currentOverallScore": 49.64, + "proposedOverallScore": 30.55, + "rankDelta": 9 + }, + { + "countryCode": "AF", + "scoreDelta": -16.58, + "currentOverallScore": 54.55, + "proposedOverallScore": 37.97, + "rankDelta": 4 + }, + { + "countryCode": "VE", + "scoreDelta": -16.52, + "currentOverallScore": 47.7, + "proposedOverallScore": 31.18, + "rankDelta": 6 + }, + { + "countryCode": "MM", + "scoreDelta": -15.63, + "currentOverallScore": 49.97, + "proposedOverallScore": 34.34, + "rankDelta": 2 + }, + { + "countryCode": "YE", + "scoreDelta": -15.15, + "currentOverallScore": 42.51, + "proposedOverallScore": 27.36, + "rankDelta": 0 + } + ], + "top5SmallestScoreDrops": [ + { + "countryCode": "SE", + "scoreDelta": -5.47, + "currentOverallScore": 75.6, + "proposedOverallScore": 70.13, + "rankDelta": -2 + }, + { + "countryCode": "CH", + "scoreDelta": -5.61, + "currentOverallScore": 78.78, + "proposedOverallScore": 73.17, + "rankDelta": -2 + }, + { + "countryCode": "DK", + "scoreDelta": -5.96, + "currentOverallScore": 78.55, + "proposedOverallScore": 72.59, + "rankDelta": -1 + }, + { + "countryCode": "IS", + "scoreDelta": -6.73, + "currentOverallScore": 79.49, + "proposedOverallScore": 72.76, + "rankDelta": 1 + }, + { + "countryCode": "FI", + "scoreDelta": -7.04, + "currentOverallScore": 75.64, + "proposedOverallScore": 68.6, + "rankDelta": 0 + } + ] + }, + "recommendation": { + "decision": "STAGE \u2014 do not activate today, do not abandon.", + "rationale": [ + "Rank stability (Spearman 0.9863, max swing 9) clears the bar that would block activation on methodology grounds, although the max-swing is larger than the earlier (flawed) estimate of 6.", + "The universal ~11-point score drop is a messaging problem, not a correctness problem: the penalty factor is working exactly as designed (OECD/JRC non-compensation).", + "The activation event should be paired with: (1) a v2.0 methodology note, (2) a frozen-snapshot refresh under docs/snapshots/, (3) a widget copy update explaining 'your score is lower than yesterday because we upgraded the formula, not because your country got less resilient'.", + "Release-gate tests (NO \u2265 70, YE \u2264 35, LB < ZA, NO \u2212 US \u2265 8) should be dry-run against the proposed formula before flipping. The activation PR re-anchors them to NO \u2265 60, YE \u2264 40 (validated by tests/resilience-pillar-combine-activation.test.mts under the production fixtures); the live-sample numbers here confirm the bands hold with margin (NO proposed \u2248 71.59, YE \u2248 27.36)." + ], + "activationChecklist": [ + "Dry-run release-gate tests against the pillar-combined formula and re-anchor bands if needed.", + "Commit a frozen snapshot at the proposed formula so the current published tables are not orphaned.", + "Update methodology doc v2.0 section with concrete before/after numbers for 10-20 flagship countries.", + "Wire env flag RESILIENCE_PILLAR_COMBINE_ENABLED (default false) so the flip is reversible without a revert.", + "Ship a single PR that flips the default only after the analyst + LLM briefing paths have consumed the new scale for one release cycle." + ], + "doNot": [ + "Flip silently \u2014 this is a visible number change for every country.", + "Publish the current docs/snapshots/resilience-ranking-2026-04-21.json as defensible AFTER flipping \u2014 it is only accurate for the current formula." + ] + }, + "currentVsProposed": { + "comparison": "currentDomainAggregate_vs_proposedPillarCombined", + "penaltyAlpha": 0.5, + "pillarWeights": { + "structural-readiness": 0.4, + "live-shock-exposure": 0.35, + "recovery-capacity": 0.25 + }, + "domainWeights": { + "economic": 0.17, + "infrastructure": 0.15, + "energy": 0.11, + "social-governance": 0.19, + "health-food": 0.13, + "recovery": 0.25 + }, + "sampleSize": 52, + "sampleCountries": [ + "NO", + "IS", + "NZ", + "DK", + "SE", + "FI", + "CH", + "AU", + "CA", + "US", + "DE", + "GB", + "FR", + "JP", + "KR", + "IT", + "ES", + "PL", + "BR", + "MX", + "TR", + "TH", + "MY", + "CN", + "IN", + "ZA", + "EG", + "PK", + "NG", + "KE", + "BD", + "VN", + "PH", + "ID", + "UA", + "RU", + "AF", + "YE", + "SO", + "HT", + "SS", + "CF", + "SD", + "ML", + "NE", + "TD", + "SY", + "IQ", + "MM", + "VE", + "IR", + "ET" + ], + "summary": { + "spearmanRankCorrelation": 0.9863, + "meanScoreDelta": -11.3, + "meanAbsScoreDelta": 11.3, + "maxRankAbsDelta": 9 + }, + "topMoversByRank": [ + { + "countryCode": "SY", + "currentRank": 40, + "proposedRank": 49, + "rankDelta": 9, + "currentOverallScore": 49.64, + "proposedOverallScore": 30.55, + "scoreDelta": -19.09, + "pillars": { + "structuralReadiness": 32.1, + "liveShockExposure": 57.79, + "recoveryCapacity": 52.73, + "minPillar": 32.1 + } + }, + { + "countryCode": "CF", + "currentRank": 46, + "proposedRank": 39, + "rankDelta": -7, + "currentOverallScore": 46.46, + "proposedOverallScore": 34.55, + "scoreDelta": -11.91, + "pillars": { + "structuralReadiness": 50.38, + "liveShockExposure": 38.95, + "recoveryCapacity": 63.76, + "minPillar": 38.95 + } + }, + { + "countryCode": "VE", + "currentRank": 42, + "proposedRank": 48, + "rankDelta": 6, + "currentOverallScore": 47.7, + "proposedOverallScore": 31.18, + "scoreDelta": -16.52, + "pillars": { + "structuralReadiness": 37.87, + "liveShockExposure": 65.59, + "recoveryCapacity": 33.89, + "minPillar": 33.89 + } + }, + { + "countryCode": "AF", + "currentRank": 33, + "proposedRank": 37, + "rankDelta": 4, + "currentOverallScore": 54.55, + "proposedOverallScore": 37.97, + "scoreDelta": -16.58, + "pillars": { + "structuralReadiness": 51.68, + "liveShockExposure": 44.76, + "recoveryCapacity": 64.49, + "minPillar": 44.76 + } + }, + { + "countryCode": "RU", + "currentRank": 23, + "proposedRank": 27, + "rankDelta": 4, + "currentOverallScore": 61.08, + "proposedOverallScore": 46.28, + "scoreDelta": -14.8, + "pillars": { + "structuralReadiness": 47.95, + "liveShockExposure": 68.43, + "recoveryCapacity": 77.73, + "minPillar": 47.95 + } + }, + { + "countryCode": "CA", + "currentRank": 10, + "proposedRank": 14, + "rankDelta": 4, + "currentOverallScore": 73.04, + "proposedOverallScore": 61.04, + "scoreDelta": -12, + "pillars": { + "structuralReadiness": 80.81, + "liveShockExposure": 85.83, + "recoveryCapacity": 58.55, + "minPillar": 58.55 + } + }, + { + "countryCode": "ES", + "currentRank": 17, + "proposedRank": 13, + "rankDelta": -4, + "currentOverallScore": 69.41, + "proposedOverallScore": 61.17, + "scoreDelta": -8.24, + "pillars": { + "structuralReadiness": 74.59, + "liveShockExposure": 70.23, + "recoveryCapacity": 70.07, + "minPillar": 70.07 + } + }, + { + "countryCode": "TD", + "currentRank": 49, + "proposedRank": 46, + "rankDelta": -3, + "currentOverallScore": 43.85, + "proposedOverallScore": 32.27, + "scoreDelta": -11.58, + "pillars": { + "structuralReadiness": 54.34, + "liveShockExposure": 35.93, + "recoveryCapacity": 52.68, + "minPillar": 35.93 + } + }, + { + "countryCode": "SS", + "currentRank": 47, + "proposedRank": 44, + "rankDelta": -3, + "currentOverallScore": 45.54, + "proposedOverallScore": 34.06, + "scoreDelta": -11.48, + "pillars": { + "structuralReadiness": 52.61, + "liveShockExposure": 40.59, + "recoveryCapacity": 52.82, + "minPillar": 40.59 + } + }, + { + "countryCode": "ML", + "currentRank": 48, + "proposedRank": 45, + "rankDelta": -3, + "currentOverallScore": 44.91, + "proposedOverallScore": 33.67, + "scoreDelta": -11.24, + "pillars": { + "structuralReadiness": 54.6, + "liveShockExposure": 38.77, + "recoveryCapacity": 52.47, + "minPillar": 38.77 + } + } + ], + "biggestScoreDrops": [ + { + "countryCode": "SY", + "scoreDelta": -19.09, + "currentOverallScore": 49.64, + "proposedOverallScore": 30.55, + "rankDelta": 9 + }, + { + "countryCode": "AF", + "scoreDelta": -16.58, + "currentOverallScore": 54.55, + "proposedOverallScore": 37.97, + "rankDelta": 4 + }, + { + "countryCode": "VE", + "scoreDelta": -16.52, + "currentOverallScore": 47.7, + "proposedOverallScore": 31.18, + "rankDelta": 6 + }, + { + "countryCode": "MM", + "scoreDelta": -15.63, + "currentOverallScore": 49.97, + "proposedOverallScore": 34.34, + "rankDelta": 2 + }, + { + "countryCode": "YE", + "scoreDelta": -15.15, + "currentOverallScore": 42.51, + "proposedOverallScore": 27.36, + "rankDelta": 0 + } + ], + "biggestScoreClimbs": [ + { + "countryCode": "SE", + "scoreDelta": -5.47, + "currentOverallScore": 75.6, + "proposedOverallScore": 70.13, + "rankDelta": -2 + }, + { + "countryCode": "CH", + "scoreDelta": -5.61, + "currentOverallScore": 78.78, + "proposedOverallScore": 73.17, + "rankDelta": -2 + }, + { + "countryCode": "DK", + "scoreDelta": -5.96, + "currentOverallScore": 78.55, + "proposedOverallScore": 72.59, + "rankDelta": -1 + }, + { + "countryCode": "IS", + "scoreDelta": -6.73, + "currentOverallScore": 79.49, + "proposedOverallScore": 72.76, + "rankDelta": 1 + }, + { + "countryCode": "FI", + "scoreDelta": -7.04, + "currentOverallScore": 75.64, + "proposedOverallScore": 68.6, + "rankDelta": 0 + } + ], + "fullSample": [ + { + "countryCode": "NO", + "currentOverallScore": 79.03, + "proposedOverallScore": 71.59, + "scoreDelta": -7.44, + "pillars": { + "structuralReadiness": 85.85, + "liveShockExposure": 90.02, + "recoveryCapacity": 71.18, + "minPillar": 71.18 + }, + "currentRank": 2, + "proposedRank": 4, + "rankDelta": 2, + "rankAbsDelta": 2 + }, + { + "countryCode": "IS", + "currentOverallScore": 79.49, + "proposedOverallScore": 72.76, + "scoreDelta": -6.73, + "pillars": { + "structuralReadiness": 86.38, + "liveShockExposure": 88.09, + "recoveryCapacity": 73.65, + "minPillar": 73.65 + }, + "currentRank": 1, + "proposedRank": 2, + "rankDelta": 1, + "rankAbsDelta": 1 + }, + { + "countryCode": "NZ", + "currentOverallScore": 76.26, + "proposedOverallScore": 67.93, + "scoreDelta": -8.33, + "pillars": { + "structuralReadiness": 82.9, + "liveShockExposure": 82.91, + "recoveryCapacity": 70.34, + "minPillar": 70.34 + }, + "currentRank": 5, + "proposedRank": 7, + "rankDelta": 2, + "rankAbsDelta": 2 + }, + { + "countryCode": "DK", + "currentOverallScore": 78.55, + "proposedOverallScore": 72.59, + "scoreDelta": -5.96, + "pillars": { + "structuralReadiness": 87.81, + "liveShockExposure": 76.9, + "recoveryCapacity": 80.14, + "minPillar": 76.9 + }, + "currentRank": 4, + "proposedRank": 3, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "SE", + "currentOverallScore": 75.6, + "proposedOverallScore": 70.13, + "scoreDelta": -5.47, + "pillars": { + "structuralReadiness": 79.2, + "liveShockExposure": 81.3, + "recoveryCapacity": 76.79, + "minPillar": 76.79 + }, + "currentRank": 7, + "proposedRank": 5, + "rankDelta": -2, + "rankAbsDelta": 2 + }, + { + "countryCode": "FI", + "currentOverallScore": 75.64, + "proposedOverallScore": 68.6, + "scoreDelta": -7.04, + "pillars": { + "structuralReadiness": 81.97, + "liveShockExposure": 78.42, + "recoveryCapacity": 74.17, + "minPillar": 74.17 + }, + "currentRank": 6, + "proposedRank": 6, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "CH", + "currentOverallScore": 78.78, + "proposedOverallScore": 73.17, + "scoreDelta": -5.61, + "pillars": { + "structuralReadiness": 82.34, + "liveShockExposure": 78.94, + "recoveryCapacity": 84.86, + "minPillar": 78.94 + }, + "currentRank": 3, + "proposedRank": 1, + "rankDelta": -2, + "rankAbsDelta": 2 + }, + { + "countryCode": "AU", + "currentOverallScore": 73.63, + "proposedOverallScore": 62.48, + "scoreDelta": -11.15, + "pillars": { + "structuralReadiness": 78.77, + "liveShockExposure": 84.73, + "recoveryCapacity": 62.66, + "minPillar": 62.66 + }, + "currentRank": 8, + "proposedRank": 10, + "rankDelta": 2, + "rankAbsDelta": 2 + }, + { + "countryCode": "CA", + "currentOverallScore": 73.04, + "proposedOverallScore": 61.04, + "scoreDelta": -12, + "pillars": { + "structuralReadiness": 80.81, + "liveShockExposure": 85.83, + "recoveryCapacity": 58.55, + "minPillar": 58.55 + }, + "currentRank": 10, + "proposedRank": 14, + "rankDelta": 4, + "rankAbsDelta": 4 + }, + { + "countryCode": "US", + "currentOverallScore": 68.26, + "proposedOverallScore": 54.5, + "scoreDelta": -13.76, + "pillars": { + "structuralReadiness": 68.55, + "liveShockExposure": 83.83, + "recoveryCapacity": 54.73, + "minPillar": 54.73 + }, + "currentRank": 19, + "proposedRank": 19, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "DE", + "currentOverallScore": 72.42, + "proposedOverallScore": 63.6, + "scoreDelta": -8.82, + "pillars": { + "structuralReadiness": 77.74, + "liveShockExposure": 70.33, + "recoveryCapacity": 75.86, + "minPillar": 70.33 + }, + "currentRank": 11, + "proposedRank": 9, + "rankDelta": -2, + "rankAbsDelta": 2 + }, + { + "countryCode": "GB", + "currentOverallScore": 70.1, + "proposedOverallScore": 62.42, + "scoreDelta": -7.68, + "pillars": { + "structuralReadiness": 73.86, + "liveShockExposure": 71.7, + "recoveryCapacity": 72.28, + "minPillar": 71.7 + }, + "currentRank": 12, + "proposedRank": 11, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "FR", + "currentOverallScore": 70.06, + "proposedOverallScore": 61.45, + "scoreDelta": -8.61, + "pillars": { + "structuralReadiness": 74.96, + "liveShockExposure": 74.85, + "recoveryCapacity": 67.96, + "minPillar": 67.96 + }, + "currentRank": 13, + "proposedRank": 12, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "JP", + "currentOverallScore": 73.33, + "proposedOverallScore": 64.45, + "scoreDelta": -8.88, + "pillars": { + "structuralReadiness": 77.74, + "liveShockExposure": 69.7, + "recoveryCapacity": 81.86, + "minPillar": 69.7 + }, + "currentRank": 9, + "proposedRank": 8, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "KR", + "currentOverallScore": 69.85, + "proposedOverallScore": 60.43, + "scoreDelta": -9.42, + "pillars": { + "structuralReadiness": 75.8, + "liveShockExposure": 66.77, + "recoveryCapacity": 75.14, + "minPillar": 66.77 + }, + "currentRank": 14, + "proposedRank": 17, + "rankDelta": 3, + "rankAbsDelta": 3 + }, + { + "countryCode": "IT", + "currentOverallScore": 69.62, + "proposedOverallScore": 60.5, + "scoreDelta": -9.12, + "pillars": { + "structuralReadiness": 74.6, + "liveShockExposure": 67.78, + "recoveryCapacity": 74.24, + "minPillar": 67.78 + }, + "currentRank": 16, + "proposedRank": 16, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "ES", + "currentOverallScore": 69.41, + "proposedOverallScore": 61.17, + "scoreDelta": -8.24, + "pillars": { + "structuralReadiness": 74.59, + "liveShockExposure": 70.23, + "recoveryCapacity": 70.07, + "minPillar": 70.07 + }, + "currentRank": 17, + "proposedRank": 13, + "rankDelta": -4, + "rankAbsDelta": 4 + }, + { + "countryCode": "PL", + "currentOverallScore": 69.81, + "proposedOverallScore": 60.54, + "scoreDelta": -9.27, + "pillars": { + "structuralReadiness": 77.89, + "liveShockExposure": 66.7, + "recoveryCapacity": 72.51, + "minPillar": 66.7 + }, + "currentRank": 15, + "proposedRank": 15, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "BR", + "currentOverallScore": 68.34, + "proposedOverallScore": 58.99, + "scoreDelta": -9.35, + "pillars": { + "structuralReadiness": 68.69, + "liveShockExposure": 76.52, + "recoveryCapacity": 66.47, + "minPillar": 66.47 + }, + "currentRank": 18, + "proposedRank": 18, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "MX", + "currentOverallScore": 55.04, + "proposedOverallScore": 43.42, + "scoreDelta": -11.62, + "pillars": { + "structuralReadiness": 53.25, + "liveShockExposure": 61.21, + "recoveryCapacity": 55.76, + "minPillar": 53.25 + }, + "currentRank": 32, + "proposedRank": 32, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "TR", + "currentOverallScore": 56.49, + "proposedOverallScore": 43.66, + "scoreDelta": -12.83, + "pillars": { + "structuralReadiness": 50.94, + "liveShockExposure": 59.84, + "recoveryCapacity": 66.14, + "minPillar": 50.94 + }, + "currentRank": 31, + "proposedRank": 31, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "TH", + "currentOverallScore": 60.96, + "proposedOverallScore": 49.68, + "scoreDelta": -11.28, + "pillars": { + "structuralReadiness": 65.07, + "liveShockExposure": 58.29, + "recoveryCapacity": 65.37, + "minPillar": 58.29 + }, + "currentRank": 24, + "proposedRank": 23, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "MY", + "currentOverallScore": 63.42, + "proposedOverallScore": 53.16, + "scoreDelta": -10.26, + "pillars": { + "structuralReadiness": 68.12, + "liveShockExposure": 63.54, + "recoveryCapacity": 62.98, + "minPillar": 62.98 + }, + "currentRank": 21, + "proposedRank": 20, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "CN", + "currentOverallScore": 63.73, + "proposedOverallScore": 52.57, + "scoreDelta": -11.16, + "pillars": { + "structuralReadiness": 58.25, + "liveShockExposure": 74.1, + "recoveryCapacity": 68.82, + "minPillar": 58.25 + }, + "currentRank": 20, + "proposedRank": 21, + "rankDelta": 1, + "rankAbsDelta": 1 + }, + { + "countryCode": "IN", + "currentOverallScore": 59.3, + "proposedOverallScore": 46.82, + "scoreDelta": -12.48, + "pillars": { + "structuralReadiness": 63.51, + "liveShockExposure": 54.34, + "recoveryCapacity": 64.98, + "minPillar": 54.34 + }, + "currentRank": 26, + "proposedRank": 26, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "ZA", + "currentOverallScore": 52.75, + "proposedOverallScore": 39.75, + "scoreDelta": -13, + "pillars": { + "structuralReadiness": 61.85, + "liveShockExposure": 43.27, + "recoveryCapacity": 62.43, + "minPillar": 43.27 + }, + "currentRank": 34, + "proposedRank": 33, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "EG", + "currentOverallScore": 58.72, + "proposedOverallScore": 45.93, + "scoreDelta": -12.79, + "pillars": { + "structuralReadiness": 59.96, + "liveShockExposure": 58.17, + "recoveryCapacity": 56.86, + "minPillar": 56.86 + }, + "currentRank": 28, + "proposedRank": 29, + "rankDelta": 1, + "rankAbsDelta": 1 + }, + { + "countryCode": "PK", + "currentOverallScore": 49.73, + "proposedOverallScore": 34.88, + "scoreDelta": -14.85, + "pillars": { + "structuralReadiness": 50.45, + "liveShockExposure": 42.63, + "recoveryCapacity": 55.25, + "minPillar": 42.63 + }, + "currentRank": 39, + "proposedRank": 38, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "NG", + "currentOverallScore": 48.18, + "proposedOverallScore": 34.34, + "scoreDelta": -13.84, + "pillars": { + "structuralReadiness": 52.66, + "liveShockExposure": 38.24, + "recoveryCapacity": 60.94, + "minPillar": 38.24 + }, + "currentRank": 41, + "proposedRank": 41, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "KE", + "currentOverallScore": 51.66, + "proposedOverallScore": 38.61, + "scoreDelta": -13.05, + "pillars": { + "structuralReadiness": 58.94, + "liveShockExposure": 43.62, + "recoveryCapacity": 59.69, + "minPillar": 43.62 + }, + "currentRank": 36, + "proposedRank": 35, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "BD", + "currentOverallScore": 52.68, + "proposedOverallScore": 38.76, + "scoreDelta": -13.92, + "pillars": { + "structuralReadiness": 54.22, + "liveShockExposure": 50.29, + "recoveryCapacity": 49.82, + "minPillar": 49.82 + }, + "currentRank": 35, + "proposedRank": 34, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "VN", + "currentOverallScore": 57.69, + "proposedOverallScore": 46.13, + "scoreDelta": -11.56, + "pillars": { + "structuralReadiness": 62.52, + "liveShockExposure": 62.15, + "recoveryCapacity": 53.45, + "minPillar": 53.45 + }, + "currentRank": 29, + "proposedRank": 28, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "PH", + "currentOverallScore": 60.37, + "proposedOverallScore": 49.42, + "scoreDelta": -10.95, + "pillars": { + "structuralReadiness": 66, + "liveShockExposure": 57.39, + "recoveryCapacity": 65.25, + "minPillar": 57.39 + }, + "currentRank": 25, + "proposedRank": 24, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "ID", + "currentOverallScore": 61.4, + "proposedOverallScore": 50.23, + "scoreDelta": -11.17, + "pillars": { + "structuralReadiness": 66.3, + "liveShockExposure": 59.82, + "recoveryCapacity": 61.61, + "minPillar": 59.82 + }, + "currentRank": 22, + "proposedRank": 22, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "UA", + "currentOverallScore": 58.8, + "proposedOverallScore": 47.5, + "scoreDelta": -11.3, + "pillars": { + "structuralReadiness": 55.68, + "liveShockExposure": 66.36, + "recoveryCapacity": 62.12, + "minPillar": 55.68 + }, + "currentRank": 27, + "proposedRank": 25, + "rankDelta": -2, + "rankAbsDelta": 2 + }, + { + "countryCode": "RU", + "currentOverallScore": 61.08, + "proposedOverallScore": 46.28, + "scoreDelta": -14.8, + "pillars": { + "structuralReadiness": 47.95, + "liveShockExposure": 68.43, + "recoveryCapacity": 77.73, + "minPillar": 47.95 + }, + "currentRank": 23, + "proposedRank": 27, + "rankDelta": 4, + "rankAbsDelta": 4 + }, + { + "countryCode": "AF", + "currentOverallScore": 54.55, + "proposedOverallScore": 37.97, + "scoreDelta": -16.58, + "pillars": { + "structuralReadiness": 51.68, + "liveShockExposure": 44.76, + "recoveryCapacity": 64.49, + "minPillar": 44.76 + }, + "currentRank": 33, + "proposedRank": 37, + "rankDelta": 4, + "rankAbsDelta": 4 + }, + { + "countryCode": "YE", + "currentOverallScore": 42.51, + "proposedOverallScore": 27.36, + "scoreDelta": -15.15, + "pillars": { + "structuralReadiness": 39.36, + "liveShockExposure": 38.13, + "recoveryCapacity": 42.09, + "minPillar": 38.13 + }, + "currentRank": 50, + "proposedRank": 50, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "SO", + "currentOverallScore": 36.47, + "proposedOverallScore": 26.8, + "scoreDelta": -9.67, + "pillars": { + "structuralReadiness": 40.25, + "liveShockExposure": 35.72, + "recoveryCapacity": 43.56, + "minPillar": 35.72 + }, + "currentRank": 51, + "proposedRank": 51, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "HT", + "currentOverallScore": 50.57, + "proposedOverallScore": 38.16, + "scoreDelta": -12.41, + "pillars": { + "structuralReadiness": 53.48, + "liveShockExposure": 46.5, + "recoveryCapacity": 57.73, + "minPillar": 46.5 + }, + "currentRank": 37, + "proposedRank": 36, + "rankDelta": -1, + "rankAbsDelta": 1 + }, + { + "countryCode": "SS", + "currentOverallScore": 45.54, + "proposedOverallScore": 34.06, + "scoreDelta": -11.48, + "pillars": { + "structuralReadiness": 52.61, + "liveShockExposure": 40.59, + "recoveryCapacity": 52.82, + "minPillar": 40.59 + }, + "currentRank": 47, + "proposedRank": 44, + "rankDelta": -3, + "rankAbsDelta": 3 + }, + { + "countryCode": "CF", + "currentOverallScore": 46.46, + "proposedOverallScore": 34.55, + "scoreDelta": -11.91, + "pillars": { + "structuralReadiness": 50.38, + "liveShockExposure": 38.95, + "recoveryCapacity": 63.76, + "minPillar": 38.95 + }, + "currentRank": 46, + "proposedRank": 39, + "rankDelta": -7, + "rankAbsDelta": 7 + }, + { + "countryCode": "SD", + "currentOverallScore": 29.69, + "proposedOverallScore": 19.45, + "scoreDelta": -10.24, + "pillars": { + "structuralReadiness": 31.15, + "liveShockExposure": 32.3, + "recoveryCapacity": 27.24, + "minPillar": 27.24 + }, + "currentRank": 52, + "proposedRank": 52, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "ML", + "currentOverallScore": 44.91, + "proposedOverallScore": 33.67, + "scoreDelta": -11.24, + "pillars": { + "structuralReadiness": 54.6, + "liveShockExposure": 38.77, + "recoveryCapacity": 52.47, + "minPillar": 38.77 + }, + "currentRank": 48, + "proposedRank": 45, + "rankDelta": -3, + "rankAbsDelta": 3 + }, + { + "countryCode": "NE", + "currentOverallScore": 46.6, + "proposedOverallScore": 34.11, + "scoreDelta": -12.49, + "pillars": { + "structuralReadiness": 56.94, + "liveShockExposure": 35.95, + "recoveryCapacity": 59.3, + "minPillar": 35.95 + }, + "currentRank": 43, + "proposedRank": 43, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "TD", + "currentOverallScore": 43.85, + "proposedOverallScore": 32.27, + "scoreDelta": -11.58, + "pillars": { + "structuralReadiness": 54.34, + "liveShockExposure": 35.93, + "recoveryCapacity": 52.68, + "minPillar": 35.93 + }, + "currentRank": 49, + "proposedRank": 46, + "rankDelta": -3, + "rankAbsDelta": 3 + }, + { + "countryCode": "SY", + "currentOverallScore": 49.64, + "proposedOverallScore": 30.55, + "scoreDelta": -19.09, + "pillars": { + "structuralReadiness": 32.1, + "liveShockExposure": 57.79, + "recoveryCapacity": 52.73, + "minPillar": 32.1 + }, + "currentRank": 40, + "proposedRank": 49, + "rankDelta": 9, + "rankAbsDelta": 9 + }, + { + "countryCode": "IQ", + "currentOverallScore": 56.88, + "proposedOverallScore": 44.15, + "scoreDelta": -12.73, + "pillars": { + "structuralReadiness": 53.94, + "liveShockExposure": 55.11, + "recoveryCapacity": 66, + "minPillar": 53.94 + }, + "currentRank": 30, + "proposedRank": 30, + "rankDelta": 0, + "rankAbsDelta": 0 + }, + { + "countryCode": "MM", + "currentOverallScore": 49.97, + "proposedOverallScore": 34.34, + "scoreDelta": -15.63, + "pillars": { + "structuralReadiness": 43.58, + "liveShockExposure": 59.3, + "recoveryCapacity": 41.45, + "minPillar": 41.45 + }, + "currentRank": 38, + "proposedRank": 40, + "rankDelta": 2, + "rankAbsDelta": 2 + }, + { + "countryCode": "VE", + "currentOverallScore": 47.7, + "proposedOverallScore": 31.18, + "scoreDelta": -16.52, + "pillars": { + "structuralReadiness": 37.87, + "liveShockExposure": 65.59, + "recoveryCapacity": 33.89, + "minPillar": 33.89 + }, + "currentRank": 42, + "proposedRank": 48, + "rankDelta": 6, + "rankAbsDelta": 6 + }, + { + "countryCode": "IR", + "currentOverallScore": 46.48, + "proposedOverallScore": 31.45, + "scoreDelta": -15.03, + "pillars": { + "structuralReadiness": 37.08, + "liveShockExposure": 58.09, + "recoveryCapacity": 42.86, + "minPillar": 37.08 + }, + "currentRank": 45, + "proposedRank": 47, + "rankDelta": 2, + "rankAbsDelta": 2 + }, + { + "countryCode": "ET", + "currentOverallScore": 46.49, + "proposedOverallScore": 34.17, + "scoreDelta": -12.32, + "pillars": { + "structuralReadiness": 56.86, + "liveShockExposure": 38.91, + "recoveryCapacity": 51.32, + "minPillar": 38.91 + }, + "currentRank": 44, + "proposedRank": 42, + "rankDelta": -2, + "rankAbsDelta": 2 + } + ] + }, + "proposedFormulaStability": { + "script": "scripts/validate-resilience-sensitivity.mjs", + "description": "Measures stability of the PROPOSED pillar-combined formula against parameter perturbation. Not a current-vs-proposed delta \u2014 see companion comparison block.", + "sampleSize": 52, + "passes": { + "domainWeightPerturbation": { + "range": 0.2, + "draws": 50, + "maxTop50RankSwing": 6 + }, + "pillarWeightPerturbation": { + "range": 0.2, + "draws": 50, + "maxTop50RankSwing": 5 + }, + "goalpostPerturbation": { + "range": 0.1, + "draws": 50, + "maxTop50RankSwing": 7 + } + }, + "alphaSensitivityCurve": [ + { + "alpha": 0.0, + "spearmanVs05": 0.9943, + "maxTop50Swing": 4 + }, + { + "alpha": 0.1, + "spearmanVs05": 0.9964, + "maxTop50Swing": 4 + }, + { + "alpha": 0.2, + "spearmanVs05": 0.9973, + "maxTop50Swing": 3 + }, + { + "alpha": 0.3, + "spearmanVs05": 0.9985, + "maxTop50Swing": 2 + }, + { + "alpha": 0.4, + "spearmanVs05": 0.9996, + "maxTop50Swing": 1 + }, + { + "alpha": 0.5, + "spearmanVs05": 1.0, + "maxTop50Swing": 0 + }, + { + "alpha": 0.6, + "spearmanVs05": 0.9987, + "maxTop50Swing": 2 + }, + { + "alpha": 0.7, + "spearmanVs05": 0.9972, + "maxTop50Swing": 4 + }, + { + "alpha": 0.8, + "spearmanVs05": 0.9962, + "maxTop50Swing": 4 + }, + { + "alpha": 0.9, + "spearmanVs05": 0.9941, + "maxTop50Swing": 5 + }, + { + "alpha": 1.0, + "spearmanVs05": 0.9933, + "maxTop50Swing": 5 + } + ], + "dimensionStability": { + "metric": "Max top-10 rank swing under \u00b110% goalpost perturbation per dimension", + "passThreshold": 3, + "allNineteenPassed": true, + "perDimensionMaxSwing": { + "macroFiscal": 1, + "currencyExternal": 1, + "tradeSanctions": 1, + "cyberDigital": 0, + "logisticsSupply": 1, + "infrastructure": 1, + "energy": 1, + "governanceInstitutional": 1, + "socialCohesion": 0, + "borderSecurity": 0, + "informationCognitive": 0, + "healthPublicService": 1, + "foodWater": 1, + "fiscalSpace": 1, + "reserveAdequacy": 1, + "externalDebtCoverage": 1, + "importConcentration": 1, + "stateContinuity": 1, + "fuelStockDays": 0 + } + }, + "releaseGate": { + "threshold": "> 20% of dimensions failing (swing > 3 ranks)", + "failedCount": 0, + "totalDimensions": 19, + "result": "PASS" + }, + "ceilingFloorEffects": { + "count": 0, + "note": "Neither the 0 nor the 100 bound was hit under any perturbation." + } + } +} diff --git a/docs/snapshots/resilience-ranking-2026-04-21.json b/docs/snapshots/resilience-ranking-2026-04-21.json new file mode 100644 index 000000000..048c397da --- /dev/null +++ b/docs/snapshots/resilience-ranking-2026-04-21.json @@ -0,0 +1,64 @@ +{ + "capturedAt": "2026-04-21", + "source": "Published reference tables, dated 2026-04-21. The rows here are the exact figures used in the top-10 / bottom-10 / major-economies tables published alongside the reference-grade rollout. Run scripts/freeze-resilience-ranking.mjs against the live API to regenerate; this frozen copy exists so those specific published figures can be regression-verified against any future code change. Until the refresh script is run against the live API, treat this file as a claim artifact (source=published-tables), not as a live-API capture.", + "commitSha": "048bb8bb525393dc4a9c1998b9877c1f8cc8c011", + "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' rather than 'Data coverage' per the 2026-04-21 review followup.", + "greyOutThreshold": 0.40, + "notes": [ + "overallScore uses the 6-domain weighted aggregate, NOT the 3-pillar combine. The pillar combine (penalizedPillarScore) is staged in _shared.ts and validated via scripts/validate-resilience-sensitivity.mjs; activation is a separate PR.", + "Recovery carries the highest single-domain weight (0.25). This is the mechanical reason fiscally strong small states cluster at the top and fragile states separate cleanly at the bottom.", + "Displayed scores round to one decimal; stored overallScore rounds to two decimals, which is why visible ties (e.g. three 77.8 entries) do not imply a sort bug." + ] + }, + "tables": { + "topTen": [ + { "rank": 1, "countryCode": "IS", "countryName": "Iceland", "overallScore": 79.3, "dimensionCoverage": 0.79 }, + { "rank": 2, "countryCode": "NO", "countryName": "Norway", "overallScore": 78.6, "dimensionCoverage": 0.76 }, + { "rank": 3, "countryCode": "NZ", "countryName": "New Zealand", "overallScore": 77.8, "dimensionCoverage": 0.85 }, + { "rank": 4, "countryCode": "CH", "countryName": "Switzerland", "overallScore": 77.8, "dimensionCoverage": 0.83 }, + { "rank": 5, "countryCode": "DK", "countryName": "Denmark", "overallScore": 77.8, "dimensionCoverage": 0.84 }, + { "rank": 6, "countryCode": "FI", "countryName": "Finland", "overallScore": 77.1, "dimensionCoverage": 0.86 }, + { "rank": 7, "countryCode": "SE", "countryName": "Sweden", "overallScore": 76.7, "dimensionCoverage": 0.86 }, + { "rank": 8, "countryCode": "CZ", "countryName": "Czechia", "overallScore": 76.0, "dimensionCoverage": 0.86 }, + { "rank": 9, "countryCode": "LU", "countryName": "Luxembourg", "overallScore": 75.6, "dimensionCoverage": 0.86 }, + { "rank": 10, "countryCode": "PT", "countryName": "Portugal", "overallScore": 75.5, "dimensionCoverage": 0.86 } + ], + "bottomTen": [ + { "rank": 208, "countryCode": "BF", "countryName": "Burkina Faso", "overallScore": 43.9, "dimensionCoverage": 0.76 }, + { "rank": 209, "countryCode": "CF", "countryName": "Central African Republic", "overallScore": 42.8, "dimensionCoverage": 0.83 }, + { "rank": 210, "countryCode": "ML", "countryName": "Mali", "overallScore": 42.4, "dimensionCoverage": 0.75 }, + { "rank": 211, "countryCode": "ET", "countryName": "Ethiopia", "overallScore": 41.9, "dimensionCoverage": 0.80 }, + { "rank": 212, "countryCode": "NE", "countryName": "Niger", "overallScore": 41.6, "dimensionCoverage": 0.76 }, + { "rank": 213, "countryCode": "BI", "countryName": "Burundi", "overallScore": 40.7, "dimensionCoverage": 0.76 }, + { "rank": 214, "countryCode": "YE", "countryName": "Yemen", "overallScore": 37.5, "dimensionCoverage": 0.77 }, + { "rank": 215, "countryCode": "CD", "countryName": "DR Congo", "overallScore": 34.7, "dimensionCoverage": 0.76 }, + { "rank": 216, "countryCode": "SO", "countryName": "Somalia", "overallScore": 32.5, "dimensionCoverage": 0.70 }, + { "rank": 217, "countryCode": "SD", "countryName": "Sudan", "overallScore": 26.8, "dimensionCoverage": 0.79 } + ], + "majorEconomies": [ + { "rank": 17, "countryCode": "JP", "countryName": "Japan", "overallScore": 75.2, "dimensionCoverage": 0.88 }, + { "rank": 19, "countryCode": "AU", "countryName": "Australia", "overallScore": 74.7, "dimensionCoverage": 0.88 }, + { "rank": 66, "countryCode": "DE", "countryName": "Germany", "overallScore": 69.1, "dimensionCoverage": 0.86 }, + { "rank": 71, "countryCode": "GB", "countryName": "United Kingdom","overallScore": 68.6, "dimensionCoverage": 0.89 }, + { "rank": 80, "countryCode": "KR", "countryName": "South Korea", "overallScore": 67.2, "dimensionCoverage": 0.89 }, + { "rank": 86, "countryCode": "SG", "countryName": "Singapore", "overallScore": 66.6, "dimensionCoverage": 0.84 }, + { "rank": 89, "countryCode": "FR", "countryName": "France", "overallScore": 66.4, "dimensionCoverage": 0.82 }, + { "rank": 97, "countryCode": "US", "countryName": "United States", "overallScore": 65.4, "dimensionCoverage": 0.80 }, + { "rank": 109, "countryCode": "CN", "countryName": "China", "overallScore": 63.4, "dimensionCoverage": 0.84 }, + { "rank": 136, "countryCode": "BR", "countryName": "Brazil", "overallScore": 60.3, "dimensionCoverage": 0.84 }, + { "rank": 160, "countryCode": "TR", "countryName": "Turkey", "overallScore": 55.7, "dimensionCoverage": 0.86 }, + { "rank": 162, "countryCode": "RU", "countryName": "Russia", "overallScore": 54.9, "dimensionCoverage": 0.80 }, + { "rank": 178, "countryCode": "IN", "countryName": "India", "overallScore": 52.2, "dimensionCoverage": 0.84 } + ] + }, + "totals": { + "rankedCountries": 217, + "greyedOutCount": null + } +} diff --git a/proto/worldmonitor/resilience/v1/get_resilience_score.proto b/proto/worldmonitor/resilience/v1/get_resilience_score.proto index a5e51e5ad..24992505f 100644 --- a/proto/worldmonitor/resilience/v1/get_resilience_score.proto +++ b/proto/worldmonitor/resilience/v1/get_resilience_score.proto @@ -29,14 +29,16 @@ message GetResilienceScoreResponse { double stress_factor = 12; string data_version = 13; ScoreInterval score_interval = 14; - // Phase 2 T2.1: three-pillar schema. Empty array when - // schema_version == "1.0" (the default until Phase 2 T2.3 lands). - // Populated with shaped-but-empty pillar entries when - // schema_version == "2.0"; real scores/coverage land in T2.3 / PR 4. + // Phase 2 T2.1/T2.3: three-pillar schema. Populated with real + // coverage-weighted pillar scores when schema_version == "2.0" (the + // current default). Empty array when schema_version == "1.0", which is + // the legacy opt-out path retained for one release cycle via the + // RESILIENCE_SCHEMA_V2_ENABLED env flag. repeated ResiliencePillar pillars = 15; - // Phase 2 T2.1: "1.0" (default, preserves the current response shape) - // or "2.0" (adds pillars; keeps overall_score / baseline_score / etc. - // populated for one release cycle for backward compat). Controlled at - // response build time by the RESILIENCE_SCHEMA_V2_ENABLED env flag. + // Phase 2 T2.1/T2.3: "2.0" is the current default (adds pillars; keeps + // overall_score / baseline_score / etc. populated for backward compat). + // "1.0" is the legacy opt-out shape (pillars empty) retained for one + // release cycle. Controlled at response build time by the + // RESILIENCE_SCHEMA_V2_ENABLED env flag (defaults to "true" → v2). string schema_version = 16; } diff --git a/proto/worldmonitor/resilience/v1/resilience.proto b/proto/worldmonitor/resilience/v1/resilience.proto index 30ada2414..9640c2ec4 100644 --- a/proto/worldmonitor/resilience/v1/resilience.proto +++ b/proto/worldmonitor/resilience/v1/resilience.proto @@ -47,20 +47,27 @@ message ResilienceRankingItem { bool rank_stable = 6; } -// Phase 2 T2.1 of the country-resilience reference-grade upgrade plan. -// Three-pillar response shape that regroups the existing 5 domains into -// long-run capacity, current shock pressure, and recovery capability. -// Shipped as a shaped-but-empty payload in T2.1 (score=0, coverage=0); -// real aggregation lands in T2.3 / PR 4 of the Phase 2 rebuild. +// Phase 2 T2.1/T2.3 of the country-resilience reference-grade upgrade plan. +// Three-pillar response shape that regroups the 6 ResilienceDomains +// (economic, infrastructure, energy, social-governance, health-food, +// recovery) into long-run capacity (structural-readiness), current shock +// pressure (live-shock-exposure), and recovery capability (recovery-capacity). +// Pillar scores and coverage are real coverage-weighted aggregates computed +// from the constituent domains; see _pillar-membership.ts for the mapping. +// The top-level overall_score on GetResilienceScoreResponse remains a +// domain-weighted aggregate (Σ domain.score * domain.weight) for this +// release cycle; a pillar-combined score with penalty term is staged in +// _shared.ts#penalizedPillarScore and validated by +// scripts/validate-resilience-sensitivity.mjs ahead of the activation PR. message ResiliencePillar { // "structural-readiness" | "live-shock-exposure" | "recovery-capacity". string id = 1; - // Pillar score in [0, 100]. 0 when shipped empty (T2.1). + // Pillar score in [0, 100], coverage-weighted mean of member domains. double score = 2; - // Pillar weight in the overall combine. Per the plan: 0.40 / 0.35 / 0.25. + // Pillar weight in the pillar-combined score. Per the plan: 0.40 / 0.35 / 0.25. double weight = 3; - // Coverage in [0, 1]. 0 when shipped empty (T2.1). + // Coverage in [0, 1], mean of member-domain average dimension coverage. double coverage = 4; - // Subset of the 5 ResilienceDomains that compose this pillar. + // Subset of the 6 ResilienceDomains that compose this pillar. repeated ResilienceDomain domains = 5; } diff --git a/scripts/compare-resilience-current-vs-proposed.mjs b/scripts/compare-resilience-current-vs-proposed.mjs new file mode 100644 index 000000000..1106ff039 --- /dev/null +++ b/scripts/compare-resilience-current-vs-proposed.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// Compare current production overall_score (6-domain weighted aggregate) +// against the proposed pillar-combined score with penalty term (α=0.5). +// Produces a JSON artifact with the Spearman correlation, the top-N +// absolute-rank movers, and per-country score deltas so the activation +// decision (flip or keep pending?) has a concrete data point. +// +// Usage: node --import tsx/esm scripts/compare-resilience-current-vs-proposed.mjs > out.json +// +// IMPORTANT: this script must use the SAME pillar aggregation path the +// production API exposes, not a local re-implementation with different +// weighting semantics. We therefore import `buildPillarList` directly +// from `server/worldmonitor/resilience/v1/_pillar-membership.ts` (which +// weights member domains by their average dimension coverage, not by +// their static domain weights) and replicate `_shared.ts#buildDomainList` +// inline so domain scores are produced by the same coverage-weighted +// mean the production scorer uses. Any drift from production here +// invalidates the Spearman / rank-delta conclusions downstream, so if +// production ever changes its aggregation path this script must be +// updated in lockstep. + +import { loadEnvFile } from './_seed-utils.mjs'; + +loadEnvFile(import.meta.url); + +// Same 52-country sample the sensitivity script uses so the two outputs +// are directly comparable. +const SAMPLE = [ + 'NO','IS','NZ','DK','SE','FI','CH','AU','CA', + 'US','DE','GB','FR','JP','KR','IT','ES','PL', + 'BR','MX','TR','TH','MY','CN','IN','ZA','EG', + 'PK','NG','KE','BD','VN','PH','ID','UA','RU', + 'AF','YE','SO','HT','SS','CF','SD','ML','NE','TD','SY','IQ','MM','VE','IR','ET', +]; + +// Mirrors `_shared.ts#coverageWeightedMean`. Kept local because the +// production helper is not exported. +function coverageWeightedMean(dims) { + const totalCoverage = dims.reduce((s, d) => s + d.coverage, 0); + if (!totalCoverage) return 0; + return dims.reduce((s, d) => s + d.score * d.coverage, 0) / totalCoverage; +} + +// Mirrors `_shared.ts#buildDomainList` exactly so the ResilienceDomain +// objects fed to buildPillarList are byte-identical to what production +// emits. The production helper is not exported, so we re-implement it +// here; the implementation MUST stay in lockstep with _shared.ts. +function buildDomainList(dimensions, dimensionDomains, domainOrder, getDomainWeight) { + const grouped = new Map(); + for (const domainId of domainOrder) grouped.set(domainId, []); + for (const dim of dimensions) { + const domainId = dimensionDomains[dim.id]; + grouped.get(domainId)?.push(dim); + } + return domainOrder.map((domainId) => { + const domainDims = grouped.get(domainId) ?? []; + const domainScore = coverageWeightedMean(domainDims); + return { + id: domainId, + score: Math.round(domainScore * 100) / 100, + weight: getDomainWeight(domainId), + dimensions: domainDims, + }; + }); +} + +function rankCountries(scores) { + const sorted = Object.entries(scores) + .sort(([a, scoreA], [b, scoreB]) => scoreB - scoreA || a.localeCompare(b)); + const ranks = {}; + for (let i = 0; i < sorted.length; i++) { + ranks[sorted[i][0]] = i + 1; + } + return ranks; +} + +function spearmanCorrelation(ranksA, ranksB) { + const keys = Object.keys(ranksA).filter((k) => k in ranksB); + const n = keys.length; + if (n < 2) return 1; + const dSqSum = keys.reduce((s, k) => s + (ranksA[k] - ranksB[k]) ** 2, 0); + return 1 - (6 * dSqSum) / (n * (n * n - 1)); +} + +async function main() { + const { + scoreAllDimensions, + RESILIENCE_DIMENSION_ORDER, + RESILIENCE_DIMENSION_DOMAINS, + getResilienceDomainWeight, + RESILIENCE_DOMAIN_ORDER, + createMemoizedSeedReader, + } = await import('../server/worldmonitor/resilience/v1/_dimension-scorers.ts'); + + const { + listScorableCountries, + PENALTY_ALPHA, + penalizedPillarScore, + } = await import('../server/worldmonitor/resilience/v1/_shared.ts'); + + const { + buildPillarList, + PILLAR_ORDER, + PILLAR_WEIGHTS, + } = await import('../server/worldmonitor/resilience/v1/_pillar-membership.ts'); + + const domainWeights = {}; + for (const domainId of RESILIENCE_DOMAIN_ORDER) { + domainWeights[domainId] = getResilienceDomainWeight(domainId); + } + + const scorableCountries = await listScorableCountries(); + const validSample = SAMPLE.filter((c) => scorableCountries.includes(c)); + + const sharedReader = createMemoizedSeedReader(); + const rows = []; + + for (const countryCode of validSample) { + const scoreMap = await scoreAllDimensions(countryCode, sharedReader); + + // Build the same ResilienceDimension shape production uses. Only + // `id`, `score`, and `coverage` are read by buildDomainList / + // buildPillarList, but pass the other fields too for fidelity with + // the production payload (empty strings / zeros are fine here + // because the pillar aggregation does not touch them). + const dimensions = RESILIENCE_DIMENSION_ORDER.map((dimId) => ({ + id: dimId, + score: scoreMap[dimId].score, + coverage: scoreMap[dimId].coverage, + observedWeight: scoreMap[dimId].observedWeight ?? 0, + imputedWeight: scoreMap[dimId].imputedWeight ?? 0, + imputationClass: scoreMap[dimId].imputationClass ?? '', + freshness: { lastObservedAtMs: '0', staleness: '' }, + })); + + // Build domains and pillars with the EXACT production aggregation. + const domains = buildDomainList( + dimensions, + RESILIENCE_DIMENSION_DOMAINS, + RESILIENCE_DOMAIN_ORDER, + getResilienceDomainWeight, + ); + + // Current production overallScore: Σ domain.score * domain.weight + // (pre-round `domains[*].score` matches the value used inside + // production's `buildResilienceScore` where the reduce operates on + // the rounded domain-list scores). + const currentOverall = domains.reduce( + (sum, d) => sum + d.score * d.weight, + 0, + ); + + // Production pillar shape: coverage-weighted by average dimension + // coverage per member domain, not by the static domain weights. + // This is the material correction vs the earlier comparison script. + const pillars = buildPillarList(domains, true); + + // Proposed overallScore: Σ pillar.score * pillar.weight × (1 − α(1 − min/100)) + const proposedOverall = penalizedPillarScore( + pillars.map((p) => ({ score: p.score, weight: p.weight })), + ); + + const pillarById = Object.fromEntries(pillars.map((p) => [p.id, p.score])); + + rows.push({ + countryCode, + currentOverallScore: Math.round(currentOverall * 100) / 100, + proposedOverallScore: Math.round(proposedOverall * 100) / 100, + scoreDelta: Math.round((proposedOverall - currentOverall) * 100) / 100, + pillars: { + structuralReadiness: Math.round((pillarById['structural-readiness'] ?? 0) * 100) / 100, + liveShockExposure: Math.round((pillarById['live-shock-exposure'] ?? 0) * 100) / 100, + recoveryCapacity: Math.round((pillarById['recovery-capacity'] ?? 0) * 100) / 100, + minPillar: Math.round(Math.min(...pillars.map((p) => p.score)) * 100) / 100, + }, + }); + } + + const currentScoresMap = Object.fromEntries(rows.map((r) => [r.countryCode, r.currentOverallScore])); + const proposedScoresMap = Object.fromEntries(rows.map((r) => [r.countryCode, r.proposedOverallScore])); + + const currentRanks = rankCountries(currentScoresMap); + const proposedRanks = rankCountries(proposedScoresMap); + + for (const row of rows) { + row.currentRank = currentRanks[row.countryCode]; + row.proposedRank = proposedRanks[row.countryCode]; + row.rankDelta = row.proposedRank - row.currentRank; // + means dropped, − means climbed + row.rankAbsDelta = Math.abs(row.rankDelta); + } + + const spearman = spearmanCorrelation(currentRanks, proposedRanks); + + // Top movers by absolute rank change, breaking ties by absolute score delta. + const topMovers = [...rows] + .sort((a, b) => + b.rankAbsDelta - a.rankAbsDelta || + Math.abs(b.scoreDelta) - Math.abs(a.scoreDelta), + ) + .slice(0, 10); + + const biggestScoreDrops = [...rows].sort((a, b) => a.scoreDelta - b.scoreDelta).slice(0, 5); + const biggestScoreClimbs = [...rows].sort((a, b) => b.scoreDelta - a.scoreDelta).slice(0, 5); + + const meanScoreDelta = rows.reduce((s, r) => s + r.scoreDelta, 0) / rows.length; + const meanAbsScoreDelta = rows.reduce((s, r) => s + Math.abs(r.scoreDelta), 0) / rows.length; + const maxRankAbsDelta = Math.max(...rows.map((r) => r.rankAbsDelta)); + + const output = { + comparison: 'currentDomainAggregate_vs_proposedPillarCombined', + penaltyAlpha: PENALTY_ALPHA, + pillarWeights: PILLAR_WEIGHTS, + domainWeights, + sampleSize: rows.length, + sampleCountries: rows.map((r) => r.countryCode), + summary: { + spearmanRankCorrelation: Math.round(spearman * 10000) / 10000, + meanScoreDelta: Math.round(meanScoreDelta * 100) / 100, + meanAbsScoreDelta: Math.round(meanAbsScoreDelta * 100) / 100, + maxRankAbsDelta, + }, + topMoversByRank: topMovers.map((r) => ({ + countryCode: r.countryCode, + currentRank: r.currentRank, + proposedRank: r.proposedRank, + rankDelta: r.rankDelta, + currentOverallScore: r.currentOverallScore, + proposedOverallScore: r.proposedOverallScore, + scoreDelta: r.scoreDelta, + pillars: r.pillars, + })), + biggestScoreDrops: biggestScoreDrops.map((r) => ({ + countryCode: r.countryCode, + scoreDelta: r.scoreDelta, + currentOverallScore: r.currentOverallScore, + proposedOverallScore: r.proposedOverallScore, + rankDelta: r.rankDelta, + })), + biggestScoreClimbs: biggestScoreClimbs.map((r) => ({ + countryCode: r.countryCode, + scoreDelta: r.scoreDelta, + currentOverallScore: r.currentOverallScore, + proposedOverallScore: r.proposedOverallScore, + rankDelta: r.rankDelta, + })), + fullSample: rows, + }; + + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +main().catch((err) => { + console.error('[compare-resilience-current-vs-proposed] failed:', err); + process.exit(1); +}); diff --git a/scripts/freeze-resilience-ranking.mjs b/scripts/freeze-resilience-ranking.mjs new file mode 100644 index 000000000..74423b944 --- /dev/null +++ b/scripts/freeze-resilience-ranking.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Freeze a live snapshot of the resilience ranking for regression-verification +// of published figures. Writes to docs/snapshots/resilience-ranking-.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); +}); diff --git a/server/_shared/resilience-freshness.ts b/server/_shared/resilience-freshness.ts index 744e70429..e02cf9fe5 100644 --- a/server/_shared/resilience-freshness.ts +++ b/server/_shared/resilience-freshness.ts @@ -9,7 +9,7 @@ // // What is deliberately NOT in this module: // -// - No changes to the 13 dimension scorers. Propagating `lastObservedAt` +// - No changes to the 19 dimension scorers. Propagating `lastObservedAt` // through each scorer and aggregating max age per dimension is the // next slice of T1.5 and will depend on this classifier. Keeping the // classifier in its own module means that slice becomes a simple diff --git a/server/worldmonitor/resilience/v1/_dimension-freshness.ts b/server/worldmonitor/resilience/v1/_dimension-freshness.ts index 9d062b037..784aa5c7e 100644 --- a/server/worldmonitor/resilience/v1/_dimension-freshness.ts +++ b/server/worldmonitor/resilience/v1/_dimension-freshness.ts @@ -6,7 +6,7 @@ // explicitly deferred the dimension-level propagation. This module owns // that propagation pass. // -// Design: aggregation happens one level above the 13 dimension scorers. +// Design: aggregation happens one level above the 19 dimension scorers. // The scorers stay unchanged; this module reads every seed-meta key // referenced by INDICATOR_REGISTRY, builds a sourceKey → fetchedAtMs // map, and aggregates per dimension: diff --git a/server/worldmonitor/resilience/v1/_shared.ts b/server/worldmonitor/resilience/v1/_shared.ts index b73106fbe..7ee083b66 100644 --- a/server/worldmonitor/resilience/v1/_shared.ts +++ b/server/worldmonitor/resilience/v1/_shared.ts @@ -27,16 +27,22 @@ import { } from './_dimension-scorers'; import { buildPillarList } from './_pillar-membership'; -// Phase 2 T2.1: feature flag for the three-pillar response shape. -// When `true`, responses carry `schemaVersion: "2.0"` and a non-empty -// `pillars` array (shaped but with score=0/coverage=0 until PR 4 wires -// the real aggregation). When `false` (default), responses preserve the -// Phase 1 shape: `schemaVersion: "1.0"` and `pillars: []`. +// Phase 2 T2.1/T2.3: feature flag for the three-pillar response shape. +// Default is `true` → responses carry `schemaVersion: "2.0"` and a +// non-empty `pillars` array with real coverage-weighted scores from +// `_pillar-membership.ts#buildPillarList`. When `false`, responses fall +// back to the Phase 1 shape (`schemaVersion: "1.0"`, `pillars: []`) — +// retained as an emergency opt-out for one release cycle. // -// The `overallScore`, `baselineScore`, `stressScore`, etc. top-level -// fields remain populated in BOTH modes for one release cycle to -// preserve backward compat for widget + map layer + Country Brief -// consumers per the plan ("Schema changes (OpenAPI + proto)" section). +// IMPORTANT: `overallScore` is STILL computed as the 6-domain weighted +// aggregate (Σ domain.score * domain.weight, weights sum to 1.00) in both +// modes. A pillar-combined score with a min-pillar penalty is defined +// below (`penalizedPillarScore`) and exercised by +// scripts/validate-resilience-sensitivity.mjs; the activation that +// switches `overallScore` to the pillar combine is a separate PR. +// +// `baselineScore`, `stressScore`, `stressFactor`, etc. remain populated +// in both modes for widget + map layer + Country Brief consumers. export const RESILIENCE_SCHEMA_V2_ENABLED = (process.env.RESILIENCE_SCHEMA_V2_ENABLED ?? 'true').toLowerCase() === 'true'; diff --git a/src/components/ResilienceWidget.ts b/src/components/ResilienceWidget.ts index 7ff47e1a9..c8e563047 100644 --- a/src/components/ResilienceWidget.ts +++ b/src/components/ResilienceWidget.ts @@ -151,7 +151,7 @@ export class ResilienceWidget { 'span', { className: 'resilience-widget__help', - title: 'Composite resilience score derived from economic, infrastructure, energy, social/governance, and health/food domains.', + title: 'Composite resilience score from 19 dimensions across 6 domains (economic, infrastructure, energy, social & governance, health & food, recovery), grouped into 3 pillars (structural readiness, live shock exposure, recovery capacity). Weights sum to 1.00; recovery carries the largest single-domain weight (0.25).', 'aria-label': 'Resilience score methodology', }, '?', @@ -308,7 +308,7 @@ export class ResilienceWidget { 'span', { className: 'resilience-widget__data-version', - title: 'Date the underlying source data was last refreshed by the Railway static-seed job.', + title: 'Date the static-seed bundle (Railway job) was last refreshed. Individual live inputs (conflict events, sanctions, prices) can be newer — see the per-dimension freshness badge for those.', }, dataVersionLabel, )] diff --git a/src/components/resilience-widget-utils.ts b/src/components/resilience-widget-utils.ts index 34aa09b67..2c426ffec 100644 --- a/src/components/resilience-widget-utils.ts +++ b/src/components/resilience-widget-utils.ts @@ -4,7 +4,7 @@ import type { ResilienceScoreResponse } from '@/services/resilience'; // visible to non-entitled users. The preview is blurred and // non-interactive via the .resilience-widget__preview CSS class, so // the exact values do not need to match any real country. They just -// need to populate the 5 domain bars AND the 13-cell per-dimension +// need to populate the 6 domain bars AND the 19-cell per-dimension // confidence grid (T1.6) with realistic-looking data so the gated // card is not a blank gap. Raised in PR #2949 review. Lives in this // dependency-free utils module so tests can import it without @@ -120,6 +120,7 @@ const DOMAIN_LABELS: Record = { energy: 'Energy', 'social-governance': 'Social & Gov', 'health-food': 'Health & Food', + recovery: 'Recovery', }; export function getResilienceVisualLevel(score: number): ResilienceVisualLevel { @@ -163,10 +164,14 @@ export function formatBaselineStress(baseline: number, stress: number): string { } // Formats the dataVersion field (ISO date YYYY-MM-DD, sourced from the -// seed-meta key) for display in the widget footer. Returns an empty string -// when dataVersion is missing, malformed, or not a real calendar date so -// the caller can skip rendering. Format is stable and regex + calendar -// tested by resilience-widget.test.mts. +// seed-meta:resilience:static.fetchedAt key) for display in the widget +// footer. Returns an empty string when dataVersion is missing, malformed, +// or not a real calendar date so the caller can skip rendering. The +// "Seed date" label is narrower than "Data" — the value reflects the +// static-seed refresh only, not the freshness of every live input that +// contributes to the score (individual dimension freshness is surfaced +// separately via the per-dimension freshness badge). Format is stable +// and regex + calendar tested by resilience-widget.test.mts. const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; export function formatResilienceDataVersion(dataVersion: string | null | undefined): string { if (typeof dataVersion !== 'string' || !ISO_DATE_PATTERN.test(dataVersion)) return ''; @@ -179,22 +184,20 @@ export function formatResilienceDataVersion(dataVersion: string | null | undefin const parsed = new Date(dataVersion); if (Number.isNaN(parsed.getTime())) return ''; if (parsed.toISOString().slice(0, 10) !== dataVersion) return ''; - return `Data ${dataVersion}`; + return `Seed date ${dataVersion}`; } // T1.6 Phase 1 of the country-resilience reference-grade upgrade plan. // Per-dimension confidence helpers. The widget uses these to render a -// compact confidence grid below the 5-domain rows so analysts can see +// compact confidence grid below the 6-domain rows so analysts can see // per-dimension data coverage without opening the deep-dive panel. // // This slice uses ONLY the existing ResilienceDimension fields (`id`, -// `coverage`, `observedWeight`, `imputedWeight`) already on every -// response, so no proto or schema changes are needed. The downstream -// adds (imputation class icon from T1.7, freshness badge from T1.5) -// land as additional columns in later PRs once the schema exposes -// those fields through the response type. +// `coverage`, `observedWeight`, `imputedWeight`, `imputationClass`, +// `freshness`) already on every response, so no proto or schema +// changes are needed to render the full grid. -// Short labels for each of the 13 dimensions so the compact grid does +// Short labels for each of the 19 dimensions so the compact grid does // not wrap. Keys match `ResilienceDimensionId` from // server/worldmonitor/resilience/v1/_dimension-scorers.ts. The doc // linter test (resilience-methodology-lint.test.mts) already pins the diff --git a/src/styles/country-deep-dive.css b/src/styles/country-deep-dive.css index 18be18e99..14bb59119 100644 --- a/src/styles/country-deep-dive.css +++ b/src/styles/country-deep-dive.css @@ -963,7 +963,7 @@ /* T1.6 Phase 1 of the country-resilience reference-grade upgrade plan: per-dimension confidence grid. Renders a compact 2-column grid of the - 13 scorer dimensions between the 5-domain bars and the footer. + 19 scorer dimensions between the 6-domain bars and the footer. Each cell shows a short label, a coverage bar, and a percentage. Status modifiers (--observed, --partial, --imputed, --absent) tint the bar fill so analysts can see provenance at a glance. */ diff --git a/tests/resilience-pillar-schema.test.mts b/tests/resilience-pillar-schema.test.mts index 00466d6bd..7e24778f5 100644 --- a/tests/resilience-pillar-schema.test.mts +++ b/tests/resilience-pillar-schema.test.mts @@ -1,10 +1,12 @@ -// Phase 2 T2.1 of the country-resilience reference-grade upgrade plan +// Phase 2 T2.1 + T2.3 of the country-resilience reference-grade upgrade plan // (docs/internal/country-resilience-upgrade-plan.md). // // Pins the three-pillar membership shape and the buildPillarList helper -// behaviour. The plan ships pillars empty in T2.1; PR 4 / T2.3 wires -// the real penalized weighted-mean aggregation. These tests guard the -// invariants the aggregator will rely on so PR 4 can land cleanly. +// behaviour. Real coverage-weighted aggregation is live (see +// tests/resilience-pillar-aggregation.test.mts for the arithmetic tests); +// this file focuses on structural invariants: membership disjointness, +// pillar ordering, weight sum, and the degenerate-input semantics +// (empty-dimension domains ⇒ score=0 / coverage=0). import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; @@ -123,12 +125,19 @@ describe('buildPillarList', () => { assert.deepEqual(result, []); }); - it('returns 3 shaped pillars with score=0 / coverage=0 when the flag is on', () => { + it('returns 3 pillars with score=0 / coverage=0 when every member domain has empty dimensions (degenerate input)', () => { + // `allDomains` uses the fixture `makeDomain` helper which sets + // `dimensions: []`. With no dimension coverage to weight against, + // buildPillarList must return score=0 / coverage=0 for each pillar + // (NOT because real aggregation is unimplemented — it is — but + // because the aggregator divides by totalCoverage=0 and the + // fallback path short-circuits to zero). Real-score behaviour is + // exercised in tests/resilience-pillar-aggregation.test.mts. const result = buildPillarList(allDomains, true); assert.equal(result.length, 3); for (const pillar of result) { - assert.equal(pillar.score, 0, `pillar ${pillar.id} score must be 0 in T2.1 (PR 4 wires real aggregation)`); - assert.equal(pillar.coverage, 0, `pillar ${pillar.id} coverage must be 0 in T2.1`); + assert.equal(pillar.score, 0, `pillar ${pillar.id} score must be 0 when member domains have no dimensions`); + assert.equal(pillar.coverage, 0, `pillar ${pillar.id} coverage must be 0 when member domains have no dimensions`); assert.equal(pillar.weight, PILLAR_WEIGHTS[pillar.id]); } }); diff --git a/tests/resilience-ranking-snapshot.test.mts b/tests/resilience-ranking-snapshot.test.mts new file mode 100644 index 000000000..495904229 --- /dev/null +++ b/tests/resilience-ranking-snapshot.test.mts @@ -0,0 +1,315 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +// Regression-verifies the frozen resilience-ranking snapshots under +// docs/snapshots/. Two shapes are supported: +// +// 1. "Published tables" shape (e.g. resilience-ranking-2026-04-21.json): +// tables.topTen / tables.bottomTen / tables.majorEconomies curated rows. +// This is the source of truth for any published ranking figures and +// the assertions below pin its internal consistency. +// +// 2. "Live capture" shape (produced by scripts/freeze-resilience-ranking.mjs): +// full items[] + greyedOut[] from the live API. Additional invariants +// (monotonic, unique ranks, greyedOut coverage < 0.40) are asserted on +// this shape. +// +// Any new snapshot committed to docs/snapshots/ is auto-discovered. + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = path.resolve(path.dirname(__filename), '..'); +const SNAPSHOT_DIR = path.join(REPO_ROOT, 'docs', 'snapshots'); + +// Band anchors from the release-gate tests (tests/resilience-release-gate.test.mts). +// Countries in the high-anchor set must never drop below 70 in a published +// snapshot; countries in the low-anchor set must never climb above 45. +const HIGH_BAND_ANCHORS = new Set(['NO', 'CH', 'DK', 'IS', 'FI', 'SE', 'NZ']); +const LOW_BAND_ANCHORS = new Set(['YE', 'SO', 'SD', 'CD']); +const HIGH_BAND_FLOOR = 70; +const LOW_BAND_CEILING = 45; + +interface PublishedRow { + rank: number; + countryCode: string; + countryName: string; + overallScore: number; + dimensionCoverage: number; +} + +interface LiveItem { + rank: number; + countryCode: string; + countryName?: string; + overallScore: number; + overallScoreRaw?: number; + dimensionCoverage?: number; + lowConfidence?: boolean; +} + +interface SnapshotPublished { + capturedAt: string; + commitSha: string; + schemaVersion: string; + methodology: { + domainCount: number; + dimensionCount: number; + pillarCount: number; + greyOutThreshold: number; + }; + tables: { + topTen: PublishedRow[]; + bottomTen: PublishedRow[]; + majorEconomies: PublishedRow[]; + }; + totals: { rankedCountries: number }; +} + +interface SnapshotLive { + capturedAt: string; + commitSha: string; + schemaVersion: string; + methodology: { + domainCount: number; + dimensionCount: number; + pillarCount: number; + greyOutThreshold: number; + }; + totals: { rankedCountries: number; greyedOutCount: number }; + items: LiveItem[]; + greyedOut: Array<{ countryCode: string; overallCoverage: number }>; +} + +type Snapshot = SnapshotPublished | SnapshotLive; + +function isLive(snapshot: Snapshot): snapshot is SnapshotLive { + return Array.isArray((snapshot as SnapshotLive).items); +} + +function isPublished(snapshot: Snapshot): snapshot is SnapshotPublished { + return (snapshot as SnapshotPublished).tables != null; +} + +function loadSnapshots(): { filename: string; snapshot: Snapshot }[] { + let entries: string[]; + try { + entries = readdirSync(SNAPSHOT_DIR); + } catch { + return []; + } + return entries + .filter((name) => /^resilience-ranking-\d{4}-\d{2}-\d{2}\.json$/.test(name)) + .sort() + .map((filename) => ({ + filename, + snapshot: JSON.parse(readFileSync(path.join(SNAPSHOT_DIR, filename), 'utf8')) as Snapshot, + })); +} + +const SNAPSHOTS = loadSnapshots(); + +describe('resilience-ranking snapshots', () => { + it('snapshot directory contains at least one frozen artifact', () => { + assert.ok( + SNAPSHOTS.length >= 1, + `expected at least one resilience-ranking-YYYY-MM-DD.json under docs/snapshots/, got 0. Run scripts/freeze-resilience-ranking.mjs against the live API to refresh.`, + ); + }); + + for (const { filename, snapshot } of SNAPSHOTS) { + describe(filename, () => { + it('capturedAt is a parseable ISO date', () => { + assert.match(snapshot.capturedAt, /^\d{4}-\d{2}-\d{2}$/); + const parsed = new Date(snapshot.capturedAt); + assert.ok(!Number.isNaN(parsed.getTime()), `capturedAt=${snapshot.capturedAt} must parse to a real date`); + }); + + it('commit SHA is present (40-char hex or "unknown")', () => { + assert.ok( + /^[0-9a-f]{40}$/.test(snapshot.commitSha) || snapshot.commitSha === 'unknown', + `commitSha=${snapshot.commitSha} must be a 40-char git SHA or "unknown"`, + ); + }); + + it('schemaVersion matches the production default', () => { + // Flip this pin when the pillar-combined overall_score activates and + // the schema contract changes; until then, 2.0 is the live shape. + assert.equal(snapshot.schemaVersion, '2.0'); + }); + + it('methodology pins the 6-domain / 19-dimension / 3-pillar shape', () => { + assert.equal(snapshot.methodology.domainCount, 6); + assert.equal(snapshot.methodology.dimensionCount, 19); + assert.equal(snapshot.methodology.pillarCount, 3); + assert.equal(snapshot.methodology.greyOutThreshold, 0.40); + }); + + if (isPublished(snapshot)) { + it('published topTen ranks are 1..10, scores descend, all scores in (0,100)', () => { + const rows = snapshot.tables.topTen; + assert.equal(rows.length, 10); + for (let i = 0; i < rows.length; i++) { + assert.equal(rows[i]!.rank, i + 1, `topTen[${i}].rank should be ${i + 1}, got ${rows[i]!.rank}`); + assert.ok(rows[i]!.overallScore > 0 && rows[i]!.overallScore < 100); + if (i > 0) { + assert.ok( + rows[i]!.overallScore <= rows[i - 1]!.overallScore, + `topTen must be monotonically non-increasing at rank ${rows[i]!.rank}: ${rows[i - 1]!.overallScore} → ${rows[i]!.overallScore}`, + ); + } + } + }); + + it('published bottomTen ranks are contiguous and climb monotonically in rank / descend in score', () => { + const rows = snapshot.tables.bottomTen; + assert.equal(rows.length, 10); + for (let i = 1; i < rows.length; i++) { + assert.equal( + rows[i]!.rank, + rows[i - 1]!.rank + 1, + `bottomTen ranks must be contiguous: ${rows[i - 1]!.rank} then ${rows[i]!.rank}`, + ); + assert.ok( + rows[i]!.overallScore <= rows[i - 1]!.overallScore, + `bottomTen scores must not increase with worsening rank: ${rows[i - 1]!.countryCode}=${rows[i - 1]!.overallScore} then ${rows[i]!.countryCode}=${rows[i]!.overallScore}`, + ); + } + // Last row's rank must equal the claimed ranked-country total. + assert.equal( + rows[rows.length - 1]!.rank, + snapshot.totals.rankedCountries, + `bottomTen.last.rank=${rows[rows.length - 1]!.rank} must equal totals.rankedCountries=${snapshot.totals.rankedCountries}`, + ); + }); + + it('country codes are distinct across all three tables', () => { + const codes = [ + ...snapshot.tables.topTen, + ...snapshot.tables.bottomTen, + ...snapshot.tables.majorEconomies, + ].map((row) => row.countryCode); + const unique = new Set(codes); + // Major economies can overlap topTen (e.g. if Japan is in both), + // so only assert uniqueness within each table, not across. + for (const table of ['topTen', 'bottomTen', 'majorEconomies'] as const) { + const tableCodes = snapshot.tables[table].map((row) => row.countryCode); + assert.equal( + new Set(tableCodes).size, + tableCodes.length, + `${table} contains duplicate country codes`, + ); + } + // Sanity: at minimum the union has more entries than any single table. + assert.ok(unique.size >= Math.max(snapshot.tables.topTen.length, snapshot.tables.bottomTen.length)); + }); + + it('high-band anchors appearing in topTen stay above the release-gate floor', () => { + for (const row of snapshot.tables.topTen) { + if (!HIGH_BAND_ANCHORS.has(row.countryCode)) continue; + assert.ok( + row.overallScore >= HIGH_BAND_FLOOR, + `${row.countryCode} (${row.countryName}) is a high-band anchor and must stay ≥${HIGH_BAND_FLOOR}, got ${row.overallScore}`, + ); + } + }); + + it('low-band anchors appearing in bottomTen stay below the release-gate ceiling', () => { + for (const row of snapshot.tables.bottomTen) { + if (!LOW_BAND_ANCHORS.has(row.countryCode)) continue; + assert.ok( + row.overallScore <= LOW_BAND_CEILING, + `${row.countryCode} (${row.countryName}) is a low-band anchor and must stay ≤${LOW_BAND_CEILING}, got ${row.overallScore}`, + ); + } + }); + + it('every dimensionCoverage in published rows is in [0, 1]', () => { + const all = [ + ...snapshot.tables.topTen, + ...snapshot.tables.bottomTen, + ...snapshot.tables.majorEconomies, + ]; + for (const row of all) { + assert.ok( + row.dimensionCoverage >= 0 && row.dimensionCoverage <= 1, + `${row.countryCode} dimensionCoverage=${row.dimensionCoverage} must be in [0, 1] (fraction, not percent)`, + ); + } + }); + + it('published rows that overlap a band anchor set sit on the expected side', () => { + // Structural check: bottomTen should not contain a high-band anchor, + // and topTen should not contain a low-band anchor. Catches a + // catastrophic label-swap or country-code mix-up. + for (const row of snapshot.tables.topTen) { + assert.ok( + !LOW_BAND_ANCHORS.has(row.countryCode), + `topTen must not include a low-band anchor, found ${row.countryCode}`, + ); + } + for (const row of snapshot.tables.bottomTen) { + assert.ok( + !HIGH_BAND_ANCHORS.has(row.countryCode), + `bottomTen must not include a high-band anchor, found ${row.countryCode}`, + ); + } + }); + } + + if (isLive(snapshot)) { + it('live items are monotonically non-increasing in overallScore', () => { + for (let i = 1; i < snapshot.items.length; i++) { + const prev = snapshot.items[i - 1]!; + const curr = snapshot.items[i]!; + assert.ok( + curr.overallScore <= prev.overallScore, + `items[${i}] (${curr.countryCode}=${curr.overallScore}) must not exceed items[${i - 1}] (${prev.countryCode}=${prev.overallScore})`, + ); + } + }); + + it('live items have unique, contiguous ranks starting at 1', () => { + const ranks = snapshot.items.map((item) => item.rank); + for (let i = 0; i < ranks.length; i++) { + assert.equal(ranks[i], i + 1, `items[${i}].rank should be ${i + 1}`); + } + const uniqueCodes = new Set(snapshot.items.map((item) => item.countryCode)); + assert.equal(uniqueCodes.size, snapshot.items.length, 'country codes in items[] must be unique'); + }); + + it('live greyedOut items all have overallCoverage < the greyOut threshold', () => { + for (const entry of snapshot.greyedOut) { + assert.ok( + entry.overallCoverage < snapshot.methodology.greyOutThreshold, + `${entry.countryCode} in greyedOut with coverage=${entry.overallCoverage} must be below threshold ${snapshot.methodology.greyOutThreshold}`, + ); + } + }); + + it('live totals match the embedded arrays', () => { + assert.equal(snapshot.totals.rankedCountries, snapshot.items.length); + assert.equal(snapshot.totals.greyedOutCount, snapshot.greyedOut.length); + }); + + it('live band anchors sit in their expected bands (structural sanity)', () => { + for (const item of snapshot.items) { + if (HIGH_BAND_ANCHORS.has(item.countryCode)) { + assert.ok( + item.overallScore >= HIGH_BAND_FLOOR, + `${item.countryCode} is a high-band anchor but scored ${item.overallScore} (< ${HIGH_BAND_FLOOR}) at rank ${item.rank}`, + ); + } + if (LOW_BAND_ANCHORS.has(item.countryCode)) { + assert.ok( + item.overallScore <= LOW_BAND_CEILING, + `${item.countryCode} is a low-band anchor but scored ${item.overallScore} (> ${LOW_BAND_CEILING}) at rank ${item.rank}`, + ); + } + } + }); + } + }); + } +}); diff --git a/tests/resilience-release-gate.test.mts b/tests/resilience-release-gate.test.mts index 086ee35c9..29b77f9f6 100644 --- a/tests/resilience-release-gate.test.mts +++ b/tests/resilience-release-gate.test.mts @@ -150,8 +150,12 @@ describe('resilience release gate', () => { // NO (elite tier) overallScore = 86.58, baseline 86.85, stress 84.36 // US (strong tier) overallScore = 72.80, baseline 73.15, stress 70.58 // Delta NO - US = 13.78 points - // Ceiling neither country approaches 100; all 5 domains stay + // Ceiling neither country approaches 100; all 6 domains stay // well inside the [0, 100] clamp range + // (Note: the investigation was run at the 5-domain state before the + // recovery domain landed; the overall ordering finding held after the + // Phase 2 recovery-domain addition — rerun under current fixtures + // continues to produce no ceiling and preserves NO > US by ≥8 points.) // // The ordering elite > strong > stressed > fragile is preserved. There is // no hard 100 ceiling in the scorer, and nothing in _dimension-scorers.ts diff --git a/tests/resilience-widget.test.mts b/tests/resilience-widget.test.mts index be86c279c..829907eb6 100644 --- a/tests/resilience-widget.test.mts +++ b/tests/resilience-widget.test.mts @@ -58,8 +58,13 @@ test('getResilienceTrendArrow renders the expected glyphs', () => { test('getResilienceDomainLabel keeps the deep-dive shorthand labels stable', () => { assert.equal(getResilienceDomainLabel('economic'), 'Economic'); assert.equal(getResilienceDomainLabel('infrastructure'), 'Infra & Supply'); + assert.equal(getResilienceDomainLabel('energy'), 'Energy'); assert.equal(getResilienceDomainLabel('social-governance'), 'Social & Gov'); assert.equal(getResilienceDomainLabel('health-food'), 'Health & Food'); + // Regression for the missing sixth-domain label. Before this pin, the + // recovery row rendered as the raw id "recovery" because DOMAIN_LABELS + // was a 5-entry map from the pre-recovery-domain era. + assert.equal(getResilienceDomainLabel('recovery'), 'Recovery'); assert.equal(getResilienceDomainLabel('custom-domain'), 'custom-domain'); }); @@ -90,9 +95,13 @@ test('formatBaselineStress renders the expected breakdown string (no Impact)', ( // renders a footer label so analysts can see how fresh the underlying // source data is; a missing or malformed dataVersion returns an empty // string so the caller skips rendering rather than showing a dangling label. -test('formatResilienceDataVersion renders a label for a valid ISO date', () => { - assert.equal(formatResilienceDataVersion('2026-04-11'), 'Data 2026-04-11'); - assert.equal(formatResilienceDataVersion('2024-01-01'), 'Data 2024-01-01'); +test('formatResilienceDataVersion renders a "Seed date" label for a valid ISO date', () => { + // Label narrowed from "Data" to "Seed date" in the review followup + // so it is clear the value reflects the static-seed bundle refresh, + // not the freshness of every live input feeding the score. Live + // inputs carry their own per-dimension freshness badges. + assert.equal(formatResilienceDataVersion('2026-04-11'), 'Seed date 2026-04-11'); + assert.equal(formatResilienceDataVersion('2024-01-01'), 'Seed date 2024-01-01'); }); test('formatResilienceDataVersion returns empty for missing or malformed dataVersion', () => { @@ -120,8 +129,8 @@ test('formatResilienceDataVersion rejects regex-valid but calendar-invalid dates assert.equal(formatResilienceDataVersion('2024-02-30'), ''); assert.equal(formatResilienceDataVersion('2024-02-31'), ''); // Legitimate calendar dates still pass. - assert.equal(formatResilienceDataVersion('2024-02-29'), 'Data 2024-02-29'); // leap year - assert.equal(formatResilienceDataVersion('2023-02-28'), 'Data 2023-02-28'); + assert.equal(formatResilienceDataVersion('2024-02-29'), 'Seed date 2024-02-29'); // leap year + assert.equal(formatResilienceDataVersion('2023-02-28'), 'Seed date 2023-02-28'); }); test('baseResponse includes dataVersion (regression for T1.4 wiring)', () => { @@ -130,12 +139,12 @@ test('baseResponse includes dataVersion (regression for T1.4 wiring)', () => { // seed-meta key; the widget footer renders it via formatResilienceDataVersion. assert.equal(typeof baseResponse.dataVersion, 'string'); assert.ok(baseResponse.dataVersion.length > 0, 'baseResponse should carry a non-empty dataVersion for regression coverage'); - assert.equal(formatResilienceDataVersion(baseResponse.dataVersion), `Data ${baseResponse.dataVersion}`); + assert.equal(formatResilienceDataVersion(baseResponse.dataVersion), `Seed date ${baseResponse.dataVersion}`); }); // T1.6 Phase 1 of the country-resilience reference-grade upgrade plan. // Per-dimension confidence helpers. The widget renders a compact -// coverage grid below the 5-domain rows using these helpers; each +// coverage grid below the 6-domain rows using these helpers; each // scorer dimension must have a stable display label and a consistent // status classification.