mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(resilience): add score confidence intervals via batch Monte Carlo (#2877)
* feat(resilience): add score confidence intervals via batch Monte Carlo
Weekly cron perturbs domain weights ±10% across 100 draws per country,
stores p05/p95 in Redis. Score handler reads intervals and includes
them in the API response as ScoreInterval { p05, p95 }.
Proto field 14 (score_interval) added to GetResilienceScoreResponse.
* chore: regenerate proto types and OpenAPI docs for ScoreInterval
* fix(resilience): add seed-meta + lock + fix interval cache + percentile formula
1. Write seed-meta:resilience:intervals for health monitoring
2. Add distributed lock to prevent concurrent cron overlap
3. Move scoreInterval read outside 6h score cache boundary
4. Fix percentile index from floor to ceil-1 (nearest-rank)
* fix(health): add resilience:intervals to health + seed-health registries
* fix(seed): skip seed-meta on no-op runs + register intervals in health check
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]}}}}
|
||||
{"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"]}}}}
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
176
scripts/seed-resilience-intervals.mjs
Normal file
176
scripts/seed-resilience-intervals.mjs
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<ScoreInterval | undefined> {
|
||||
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<GetResilienceScoreResponse>(
|
||||
const cached = await cachedFetchJson<GetResilienceScoreResponse>(
|
||||
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<string[]> {
|
||||
|
||||
@@ -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}`),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
67
tests/resilience-intervals-handler.test.mts
Normal file
67
tests/resilience-intervals-handler.test.mts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
79
tests/resilience-intervals.test.mjs
Normal file
79
tests/resilience-intervals.test.mjs
Normal file
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user