diff --git a/api/health.js b/api/health.js index 4bd24e170..02b78bae9 100644 --- a/api/health.js +++ b/api/health.js @@ -152,6 +152,7 @@ const STANDALONE_KEYS = { portwatchChokepointsRef: 'portwatch:chokepoints:ref:v1', chokepointFlows: 'energy:chokepoint-flows:v1', emberElectricity: 'energy:ember:v1:_all', + resilienceIntervals: 'resilience:intervals:v1:US', }; const SEED_META = { @@ -270,6 +271,7 @@ const SEED_META = { vpdTrackerHistorical: { key: 'seed-meta:health:vpd-tracker', maxStaleMin: 2880 }, // shares seed-meta key with vpdTrackerRealtime (same run) resilienceStaticIndex: { key: 'seed-meta:resilience:static', maxStaleMin: 576000 }, // annual October snapshot; 400d threshold matches TTL and preserves prior-year data on source outages resilienceRanking: { key: 'seed-meta:resilience:ranking', maxStaleMin: 720 }, // on-demand RPC cache (6h TTL); 12h threshold catches stale rankings without paging on cold start + resilienceIntervals: { key: 'seed-meta:resilience:intervals', maxStaleMin: 20160 }, // weekly cron; 20160min = 14d = 2x interval energyExposure: { key: 'seed-meta:economic:owid-energy-mix', maxStaleMin: 50400 }, // monthly cron on 1st; 50400min = 35d = TTL matches cron cadence + 5d buffer energyMixAll: { key: 'seed-meta:economic:owid-energy-mix', maxStaleMin: 50400 }, // same seed run as energyExposure; shares seed-meta key regulatoryActions: { key: 'seed-meta:regulatory:actions', maxStaleMin: 360 }, // 2h cron; 360min = 3x interval diff --git a/api/seed-health.js b/api/seed-health.js index 820bf15ef..0421f73f7 100644 --- a/api/seed-health.js +++ b/api/seed-health.js @@ -65,6 +65,7 @@ const SEED_DOMAINS = { 'economic:grocery-basket': { key: 'seed-meta:economic:grocery-basket', intervalMin: 5040 }, // weekly seed; intervalMin = maxStaleMin / 2 'economic:bigmac': { key: 'seed-meta:economic:bigmac', intervalMin: 5040 }, // weekly seed; intervalMin = maxStaleMin / 2 'resilience:static': { key: 'seed-meta:resilience:static', intervalMin: 288000 }, // annual October snapshot; intervalMin = health.js maxStaleMin / 2 (400d alert threshold) + 'resilience:intervals': { key: 'seed-meta:resilience:intervals', intervalMin: 10080 }, // weekly cron; intervalMin = health.js maxStaleMin / 2 (20160 / 2) 'regulatory:actions': { key: 'seed-meta:regulatory:actions', intervalMin: 120 }, // 2h cron; intervalMin = maxStaleMin / 3 'economic:owid-energy-mix': { key: 'seed-meta:economic:owid-energy-mix', intervalMin: 25200 }, // monthly cron on 1st; intervalMin = health.js maxStaleMin / 2 (50400 / 2) 'economic:fao-ffpi': { key: 'seed-meta:economic:fao-ffpi', intervalMin: 43200 }, // monthly seed; intervalMin = health.js maxStaleMin / 2 (86400 / 2) diff --git a/docs/api/ResilienceService.openapi.json b/docs/api/ResilienceService.openapi.json index a66fc34ae..e5fcc89a4 100644 --- a/docs/api/ResilienceService.openapi.json +++ b/docs/api/ResilienceService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"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"},"stressFactor":{"format":"double","type":"number"},"stressScore":{"format":"double","type":"number"},"trend":{"type":"string"}},"type":"object"},"ResilienceDimension":{"properties":{"coverage":{"format":"double","type":"number"},"id":{"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"},"ResilienceRankingItem":{"properties":{"countryCode":{"type":"string"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallCoverage":{"format":"double","type":"number"},"overallScore":{"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":{"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"},"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"},"id":{"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"},"ResilienceRankingItem":{"properties":{"countryCode":{"type":"string"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallCoverage":{"format":"double","type":"number"},"overallScore":{"format":"double","type":"number"}},"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 aed93313d..6c432e9be 100644 --- a/docs/api/ResilienceService.openapi.yaml +++ b/docs/api/ResilienceService.openapi.yaml @@ -132,6 +132,8 @@ components: format: double dataVersion: type: string + scoreInterval: + $ref: '#/components/schemas/ScoreInterval' ResilienceDomain: type: object properties: @@ -164,6 +166,15 @@ components: imputedWeight: type: number format: double + ScoreInterval: + type: object + properties: + p05: + type: number + format: double + p95: + type: number + format: double GetResilienceRankingRequest: type: object GetResilienceRankingResponse: diff --git a/proto/worldmonitor/resilience/v1/get_resilience_score.proto b/proto/worldmonitor/resilience/v1/get_resilience_score.proto index 3dfca2482..1038b0b8f 100644 --- a/proto/worldmonitor/resilience/v1/get_resilience_score.proto +++ b/proto/worldmonitor/resilience/v1/get_resilience_score.proto @@ -9,6 +9,11 @@ message GetResilienceScoreRequest { string country_code = 1 [(sebuf.http.query) = { name: "countryCode", required: true }]; } +message ScoreInterval { + double p05 = 1; + double p95 = 2; +} + message GetResilienceScoreResponse { string country_code = 1; double overall_score = 2; @@ -23,4 +28,5 @@ message GetResilienceScoreResponse { double stress_score = 11; double stress_factor = 12; string data_version = 13; + ScoreInterval score_interval = 14; } diff --git a/scripts/seed-resilience-intervals.mjs b/scripts/seed-resilience-intervals.mjs new file mode 100644 index 000000000..baa376e71 --- /dev/null +++ b/scripts/seed-resilience-intervals.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +import { + acquireLockSafely, + getRedisCredentials, + loadEnvFile, + logSeedResult, + releaseLock, + writeFreshnessMetadata, +} from './_seed-utils.mjs'; + +loadEnvFile(import.meta.url); + +const API_BASE = process.env.API_BASE_URL || 'https://api.worldmonitor.app'; +const WM_KEY = process.env.WORLDMONITOR_API_KEY || ''; +const SEED_UA = 'Mozilla/5.0 (compatible; WorldMonitor-Seed/1.0)'; + +const INTERVAL_KEY_PREFIX = 'resilience:intervals:v1:'; +const INTERVAL_TTL_SECONDS = 7 * 24 * 60 * 60; +const DRAWS = 100; + +const DOMAIN_WEIGHTS = { + economic: 0.22, + infrastructure: 0.20, + energy: 0.15, + 'social-governance': 0.25, + 'health-food': 0.18, +}; + +const DOMAIN_ORDER = [ + 'economic', + 'infrastructure', + 'energy', + 'social-governance', + 'health-food', +]; + +export function computeIntervals(domainScores, domainWeights, draws = DRAWS) { + const samples = []; + for (let i = 0; i < draws; i++) { + const jittered = domainWeights.map((w) => w * (0.9 + Math.random() * 0.2)); + const sum = jittered.reduce((s, w) => s + w, 0); + const normalized = jittered.map((w) => w / sum); + const score = domainScores.reduce((s, d, idx) => s + d * normalized[idx], 0); + samples.push(score); + } + samples.sort((a, b) => a - b); + return { + p05: Math.round(samples[Math.max(0, Math.ceil(draws * 0.05) - 1)] * 10) / 10, + p95: Math.round(samples[Math.min(draws - 1, Math.ceil(draws * 0.95) - 1)] * 10) / 10, + }; +} + +async function redisPipeline(url, token, commands) { + const resp = await fetch(`${url}/pipeline`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(commands), + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`Redis pipeline HTTP ${resp.status} — ${text.slice(0, 200)}`); + } + return resp.json(); +} + +async function fetchRanking() { + const headers = { 'User-Agent': SEED_UA, Accept: 'application/json' }; + if (WM_KEY) headers['X-WorldMonitor-Key'] = WM_KEY; + const resp = await fetch(`${API_BASE}/api/resilience/v1/get-resilience-ranking`, { + headers, + signal: AbortSignal.timeout(60_000), + }); + if (!resp.ok) throw new Error(`Ranking endpoint returned HTTP ${resp.status}`); + return resp.json(); +} + +async function fetchScore(countryCode) { + const headers = { 'User-Agent': SEED_UA, Accept: 'application/json' }; + if (WM_KEY) headers['X-WorldMonitor-Key'] = WM_KEY; + const url = `${API_BASE}/api/resilience/v1/get-resilience-score?countryCode=${countryCode}`; + const resp = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) }); + if (!resp.ok) throw new Error(`Score endpoint returned HTTP ${resp.status} for ${countryCode}`); + return resp.json(); +} + +async function seedResilienceIntervals() { + const { url, token } = getRedisCredentials(); + + const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const lockResult = await acquireLockSafely('resilience:intervals', runId, 600_000); + if (!lockResult.locked) return { skipped: true, reason: 'concurrent_run' }; + + try { + console.log('[resilience-intervals] Fetching ranking...'); + const ranking = await fetchRanking(); + const allItems = [...(ranking.items ?? []), ...(ranking.greyedOut ?? [])]; + console.log(`[resilience-intervals] ${allItems.length} countries in ranking`); + + if (allItems.length === 0) { + return { skipped: true, reason: 'empty_ranking' }; + } + + const BATCH = 10; + let computed = 0; + const commands = []; + + for (let i = 0; i < allItems.length; i += BATCH) { + const batch = allItems.slice(i, i + BATCH); + const results = await Promise.allSettled( + batch.map((item) => fetchScore(item.countryCode)), + ); + + for (let j = 0; j < results.length; j++) { + const result = results[j]; + if (result.status !== 'fulfilled') { + console.warn(`[resilience-intervals] Failed ${batch[j].countryCode}: ${result.reason?.message}`); + continue; + } + const scoreData = result.value; + if (!scoreData?.domains?.length) continue; + + const domainScores = DOMAIN_ORDER.map((id) => { + const d = scoreData.domains.find((dom) => dom.id === id); + return d?.score ?? 0; + }); + const weights = DOMAIN_ORDER.map((id) => DOMAIN_WEIGHTS[id]); + + const interval = computeIntervals(domainScores, weights, DRAWS); + const payload = { + p05: interval.p05, + p95: interval.p95, + draws: DRAWS, + computedAt: new Date().toISOString(), + }; + + const key = `${INTERVAL_KEY_PREFIX}${scoreData.countryCode}`; + commands.push(['SET', key, JSON.stringify(payload), 'EX', INTERVAL_TTL_SECONDS]); + computed++; + } + } + + if (commands.length > 0) { + const PIPE_BATCH = 50; + for (let i = 0; i < commands.length; i += PIPE_BATCH) { + await redisPipeline(url, token, commands.slice(i, i + PIPE_BATCH)); + } + } + + console.log(`[resilience-intervals] Wrote ${computed}/${allItems.length} intervals`); + return { skipped: false, recordCount: computed, total: allItems.length }; + } finally { + await releaseLock('resilience:intervals', runId); + } +} + +async function main() { + const startedAt = Date.now(); + const result = await seedResilienceIntervals(); + logSeedResult('resilience:intervals', result.recordCount ?? 0, Date.now() - startedAt, { + skipped: Boolean(result.skipped), + ...(result.total != null && { total: result.total }), + ...(result.reason != null && { reason: result.reason }), + }); + if (!result.skipped) { + await writeFreshnessMetadata('resilience', 'intervals', result.recordCount ?? 0, '', 7 * 24 * 3600); + } +} + +if (process.argv[1]?.endsWith('seed-resilience-intervals.mjs')) { + main().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + console.error(`FATAL: ${message}`); + process.exit(1); + }); +} diff --git a/server/worldmonitor/resilience/v1/_shared.ts b/server/worldmonitor/resilience/v1/_shared.ts index 358e282f4..6c7d1e340 100644 --- a/server/worldmonitor/resilience/v1/_shared.ts +++ b/server/worldmonitor/resilience/v1/_shared.ts @@ -3,6 +3,7 @@ import type { ResilienceDimension, ResilienceDomain, ResilienceRankingItem, + ScoreInterval, } from '../../../../src/generated/server/worldmonitor/resilience/v1/service_server'; import { cachedFetchJson, getCachedJson, runRedisPipeline } from '../../../_shared/redis'; @@ -26,6 +27,7 @@ export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v7:'; export const RESILIENCE_HISTORY_KEY_PREFIX = 'resilience:history:v4:'; export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v7'; export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1'; +export const RESILIENCE_INTERVAL_KEY_PREFIX = 'resilience:intervals:v1:'; const RESILIENCE_STATIC_META_KEY = 'seed-meta:resilience:static'; const LOW_CONFIDENCE_COVERAGE_THRESHOLD = 0.55; @@ -54,6 +56,16 @@ function scoreCacheKey(countryCode: string): string { return `${RESILIENCE_SCORE_CACHE_PREFIX}${countryCode}`; } +function intervalCacheKey(countryCode: string): string { + return `${RESILIENCE_INTERVAL_KEY_PREFIX}${countryCode}`; +} + +async function readScoreInterval(countryCode: string): Promise { + const raw = await getCachedJson(intervalCacheKey(countryCode), true) as { p05?: number; p95?: number } | null; + if (!raw || typeof raw.p05 !== 'number' || typeof raw.p95 !== 'number') return undefined; + return { p05: raw.p05, p95: raw.p95 }; +} + function historyKey(countryCode: string): string { return `${RESILIENCE_HISTORY_KEY_PREFIX}${countryCode}`; } @@ -166,7 +178,7 @@ export async function ensureResilienceScoreCached(countryCode: string, reader?: }; } - return await cachedFetchJson( + const cached = await cachedFetchJson( scoreCacheKey(normalizedCountryCode), RESILIENCE_SCORE_CACHE_TTL_SECONDS, async () => { @@ -234,6 +246,12 @@ export async function ensureResilienceScoreCached(countryCode: string, reader?: imputationShare: 0, dataVersion: '', }; + + const scoreInterval = await readScoreInterval(normalizedCountryCode); + if (scoreInterval) { + return { ...cached, scoreInterval }; + } + return cached; } export async function listScorableCountries(): Promise { diff --git a/src/components/ResilienceWidget.ts b/src/components/ResilienceWidget.ts index b38bf04f2..cf17fc090 100644 --- a/src/components/ResilienceWidget.ts +++ b/src/components/ResilienceWidget.ts @@ -262,6 +262,12 @@ export class ResilienceWidget { 'div', { className: 'resilience-widget__overall-meta' }, h('span', { className: 'resilience-widget__overall-score' }, String(Math.round(clampScore(data.overallScore)))), + ...(data.scoreInterval + ? [h('span', { + className: 'resilience-widget__overall-interval', + title: `95% confidence interval: ${data.scoreInterval.p05} - ${data.scoreInterval.p95}`, + }, `[${Math.round(data.scoreInterval.p05)}\u2013${Math.round(data.scoreInterval.p95)}]`)] + : []), h('span', { className: 'resilience-widget__overall-level', style: { color: levelColor } }, levelLabel), h('span', { className: 'resilience-widget__overall-trend' }, `${getResilienceTrendArrow(data.trend)} ${data.trend}`), ), diff --git a/src/generated/client/worldmonitor/resilience/v1/service_client.ts b/src/generated/client/worldmonitor/resilience/v1/service_client.ts index ab26906e4..b58f3b00b 100644 --- a/src/generated/client/worldmonitor/resilience/v1/service_client.ts +++ b/src/generated/client/worldmonitor/resilience/v1/service_client.ts @@ -19,6 +19,7 @@ export interface GetResilienceScoreResponse { stressScore: number; stressFactor: number; dataVersion: string; + scoreInterval?: ScoreInterval; } export interface ResilienceDomain { @@ -36,6 +37,11 @@ export interface ResilienceDimension { imputedWeight: number; } +export interface ScoreInterval { + p05: number; + p95: number; +} + export interface GetResilienceRankingRequest { } diff --git a/src/generated/server/worldmonitor/resilience/v1/service_server.ts b/src/generated/server/worldmonitor/resilience/v1/service_server.ts index 3d448c196..070d76d2e 100644 --- a/src/generated/server/worldmonitor/resilience/v1/service_server.ts +++ b/src/generated/server/worldmonitor/resilience/v1/service_server.ts @@ -19,6 +19,7 @@ export interface GetResilienceScoreResponse { stressScore: number; stressFactor: number; dataVersion: string; + scoreInterval?: ScoreInterval; } export interface ResilienceDomain { @@ -36,6 +37,11 @@ export interface ResilienceDimension { imputedWeight: number; } +export interface ScoreInterval { + p05: number; + p95: number; +} + export interface GetResilienceRankingRequest { } diff --git a/src/services/resilience.ts b/src/services/resilience.ts index c100d57e6..872adcb17 100644 --- a/src/services/resilience.ts +++ b/src/services/resilience.ts @@ -5,12 +5,13 @@ import { type ResilienceDomain, type ResilienceDimension, type ResilienceRankingItem, + type ScoreInterval, } from '@/generated/client/worldmonitor/resilience/v1/service_client'; import { getRpcBaseUrl } from '@/services/rpc-client'; export type ResilienceScoreResponse = GetResilienceScoreResponse; export type ResilienceRankingResponse = GetResilienceRankingResponse; -export type { ResilienceDomain, ResilienceDimension, ResilienceRankingItem }; +export type { ResilienceDomain, ResilienceDimension, ResilienceRankingItem, ScoreInterval }; let _client: ResilienceServiceClient | null = null; diff --git a/tests/resilience-intervals-handler.test.mts b/tests/resilience-intervals-handler.test.mts new file mode 100644 index 000000000..7b8e1fa7a --- /dev/null +++ b/tests/resilience-intervals-handler.test.mts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import { getResilienceScore } from '../server/worldmonitor/resilience/v1/get-resilience-score.ts'; +import { createRedisFetch } from './helpers/fake-upstash-redis.mts'; +import { RESILIENCE_FIXTURES } from './helpers/resilience-fixtures.mts'; + +const originalFetch = globalThis.fetch; +const originalRedisUrl = process.env.UPSTASH_REDIS_REST_URL; +const originalRedisToken = process.env.UPSTASH_REDIS_REST_TOKEN; +const originalVercelEnv = process.env.VERCEL_ENV; + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalRedisUrl == null) delete process.env.UPSTASH_REDIS_REST_URL; + else process.env.UPSTASH_REDIS_REST_URL = originalRedisUrl; + if (originalRedisToken == null) delete process.env.UPSTASH_REDIS_REST_TOKEN; + else process.env.UPSTASH_REDIS_REST_TOKEN = originalRedisToken; + if (originalVercelEnv == null) delete process.env.VERCEL_ENV; + else process.env.VERCEL_ENV = originalVercelEnv; +}); + +describe('resilience score interval integration', () => { + it('includes scoreInterval when Redis has interval data', async () => { + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; + delete process.env.VERCEL_ENV; + + const fixtures = { + ...RESILIENCE_FIXTURES, + 'resilience:intervals:v1:US': { + p05: 65.2, + p95: 72.8, + draws: 100, + computedAt: '2026-04-06T00:00:00.000Z', + }, + }; + + const { fetchImpl } = createRedisFetch(fixtures); + globalThis.fetch = fetchImpl; + + const response = await getResilienceScore( + { request: new Request('https://example.com') } as never, + { countryCode: 'US' }, + ); + + assert.ok(response.scoreInterval, 'scoreInterval should be present'); + assert.equal(response.scoreInterval.p05, 65.2); + assert.equal(response.scoreInterval.p95, 72.8); + }); + + it('omits scoreInterval when Redis has no interval data', async () => { + process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example'; + process.env.UPSTASH_REDIS_REST_TOKEN = 'token'; + delete process.env.VERCEL_ENV; + + const { fetchImpl } = createRedisFetch(RESILIENCE_FIXTURES); + globalThis.fetch = fetchImpl; + + const response = await getResilienceScore( + { request: new Request('https://example.com') } as never, + { countryCode: 'US' }, + ); + + assert.equal(response.scoreInterval, undefined, 'scoreInterval should be absent when no interval data exists'); + }); +}); diff --git a/tests/resilience-intervals.test.mjs b/tests/resilience-intervals.test.mjs new file mode 100644 index 000000000..1012dfe17 --- /dev/null +++ b/tests/resilience-intervals.test.mjs @@ -0,0 +1,79 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { computeIntervals } from '../scripts/seed-resilience-intervals.mjs'; + +describe('computeIntervals', () => { + it('returns p05 and p95 within expected bounds', () => { + const domainScores = [80, 70, 60, 75, 65]; + const domainWeights = [0.22, 0.20, 0.15, 0.25, 0.18]; + const result = computeIntervals(domainScores, domainWeights, 1000); + + assert.equal(typeof result.p05, 'number'); + assert.equal(typeof result.p95, 'number'); + assert.ok(result.p05 < result.p95, `p05 (${result.p05}) should be less than p95 (${result.p95})`); + assert.ok(result.p05 > 0, 'p05 should be positive'); + assert.ok(result.p95 <= 100, 'p95 should not exceed 100'); + }); + + it('produces narrow interval for uniform domain scores', () => { + const domainScores = [70, 70, 70, 70, 70]; + const domainWeights = [0.22, 0.20, 0.15, 0.25, 0.18]; + const result = computeIntervals(domainScores, domainWeights, 1000); + + assert.ok(result.p95 - result.p05 < 1, `Uniform scores should produce narrow interval, got ${result.p05}-${result.p95}`); + }); + + it('produces wider interval for divergent domain scores', () => { + const domainScores = [95, 20, 80, 10, 60]; + const domainWeights = [0.22, 0.20, 0.15, 0.25, 0.18]; + const result = computeIntervals(domainScores, domainWeights, 1000); + + assert.ok(result.p95 - result.p05 > 1, `Divergent scores should produce wider interval, got ${result.p05}-${result.p95}`); + }); + + it('respects custom draw count', () => { + const domainScores = [60, 70, 80, 50, 65]; + const domainWeights = [0.22, 0.20, 0.15, 0.25, 0.18]; + const result = computeIntervals(domainScores, domainWeights, 50); + + assert.equal(typeof result.p05, 'number'); + assert.equal(typeof result.p95, 'number'); + assert.ok(result.p05 < result.p95); + }); + + it('rounds to one decimal place', () => { + const domainScores = [72, 68, 55, 81, 44]; + const domainWeights = [0.22, 0.20, 0.15, 0.25, 0.18]; + const result = computeIntervals(domainScores, domainWeights, 100); + + const p05Decimals = String(result.p05).split('.')[1]?.length ?? 0; + const p95Decimals = String(result.p95).split('.')[1]?.length ?? 0; + assert.ok(p05Decimals <= 1, `p05 should have at most 1 decimal, got ${result.p05}`); + assert.ok(p95Decimals <= 1, `p95 should have at most 1 decimal, got ${result.p95}`); + }); +}); + +describe('seed script is self-contained .mjs', () => { + it('does not import from ../server/', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, join } = await import('node:path'); + const dir = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(dir, '..', 'scripts', 'seed-resilience-intervals.mjs'), 'utf8'); + assert.equal(src.includes('../server/'), false, 'Must not import from ../server/'); + assert.equal(src.includes('tsx/esm'), false, 'Must not reference tsx/esm'); + }); + + it('all imports are local ./ relative paths', async () => { + const { readFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const { dirname, join } = await import('node:path'); + const dir = dirname(fileURLToPath(import.meta.url)); + const src = readFileSync(join(dir, '..', 'scripts', 'seed-resilience-intervals.mjs'), 'utf8'); + const imports = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1]); + for (const imp of imports) { + assert.ok(imp.startsWith('./'), `Import "${imp}" must be a local ./ relative path`); + } + }); +});