feat(resilience): add rankStable flag to ranking items (#2879)

* feat(resilience): add rankStable flag to ranking items

Countries with score interval width <= 8 (p95-p05) are flagged as
rankStable=true, indicating robust ranking under weight perturbation.
Read from batch-computed intervals in Redis.

* fix(resilience): guard inverted intervals + scope fetch to scored countries

1. isRankStable rejects negative width (malformed p05 > p95)
2. fetchIntervals scoped to cachedScores.keys() instead of all countries

* fix(resilience): raw key read for intervals + bump ranking cache to v8

* fix(resilience): remove duplicate ScoreInterval interface after rebase

ScoreInterval is now generated in service_server.ts (from PR #2877).
Remove the local duplicate and re-export the generated type.
This commit is contained in:
Elie Habib
2026-04-09 22:34:36 +04:00
committed by GitHub
parent a356f66ffc
commit ce30a48664
11 changed files with 87 additions and 11 deletions

View File

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

View File

@@ -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"},"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"]}}}}
{"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"},"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"]}}}}

View File

@@ -203,3 +203,5 @@ components:
overallCoverage:
type: number
format: double
rankStable:
type: boolean

View File

@@ -23,4 +23,5 @@ message ResilienceRankingItem {
string level = 3;
bool low_confidence = 4;
double overall_coverage = 5;
bool rank_stable = 6;
}

View File

@@ -13,7 +13,7 @@ const WM_KEY = process.env.WORLDMONITOR_API_KEY || '';
const SEED_UA = 'Mozilla/5.0 (compatible; WorldMonitor-Seed/1.0)';
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v7:';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v7';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v8';
export const RESILIENCE_RANKING_CACHE_TTL_SECONDS = 6 * 60 * 60;
export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1';

View File

@@ -6,6 +6,8 @@ import type {
ScoreInterval,
} from '../../../../src/generated/server/worldmonitor/resilience/v1/service_server';
export type { ScoreInterval };
import { cachedFetchJson, getCachedJson, runRedisPipeline } from '../../../_shared/redis';
import { detectTrend, round } from '../../../_shared/resilience-stats';
import {
@@ -25,10 +27,11 @@ export const RESILIENCE_SCORE_CACHE_TTL_SECONDS = 6 * 60 * 60;
export const RESILIENCE_RANKING_CACHE_TTL_SECONDS = 6 * 60 * 60;
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v7:';
export const RESILIENCE_HISTORY_KEY_PREFIX = 'resilience:history:v4:';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v7';
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v8';
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 RANK_STABLE_MAX_INTERVAL_WIDTH = 8;
const LOW_CONFIDENCE_COVERAGE_THRESHOLD = 0.55;
const LOW_CONFIDENCE_IMPUTATION_SHARE_THRESHOLD = 0.40;
@@ -292,9 +295,16 @@ function computeOverallCoverage(response: GetResilienceScoreResponse): number {
return coverages.reduce((sum, coverage) => sum + coverage, 0) / coverages.length;
}
function isRankStable(interval: ScoreInterval | null | undefined): boolean {
if (!interval) return false;
const width = interval.p95 - interval.p05;
return Number.isFinite(width) && width >= 0 && width <= RANK_STABLE_MAX_INTERVAL_WIDTH;
}
export function buildRankingItem(
countryCode: string,
response?: GetResilienceScoreResponse | null,
interval?: ScoreInterval | null,
): ResilienceRankingItem {
if (!response) {
return {
@@ -303,6 +313,7 @@ export function buildRankingItem(
level: 'unknown',
lowConfidence: true,
overallCoverage: 0,
rankStable: false,
};
}
@@ -312,6 +323,7 @@ export function buildRankingItem(
level: response.level,
lowConfidence: response.lowConfidence,
overallCoverage: computeOverallCoverage(response),
rankStable: isRankStable(interval),
};
}

View File

@@ -8,6 +8,7 @@ import type {
import { getCachedJson, runRedisPipeline } from '../../../_shared/redis';
import {
GREY_OUT_COVERAGE_THRESHOLD,
RESILIENCE_INTERVAL_KEY_PREFIX,
RESILIENCE_RANKING_CACHE_KEY,
RESILIENCE_RANKING_CACHE_TTL_SECONDS,
buildRankingItem,
@@ -15,6 +16,7 @@ import {
listScorableCountries,
sortRankingItems,
warmMissingResilienceScores,
type ScoreInterval,
} from './_shared';
const RESILIENCE_RANKING_META_KEY = 'seed-meta:resilience:ranking';
@@ -29,6 +31,23 @@ const RESILIENCE_RANKING_META_TTL_SECONDS = 7 * 24 * 60 * 60;
// 200 covers the full static index (~130-180 countries) in a single cold-cache pass.
const SYNC_WARM_LIMIT = 200;
async function fetchIntervals(countryCodes: string[]): Promise<Map<string, ScoreInterval>> {
if (countryCodes.length === 0) return new Map();
const results = await runRedisPipeline(countryCodes.map((cc) => ['GET', `${RESILIENCE_INTERVAL_KEY_PREFIX}${cc}`]), true);
const map = new Map<string, ScoreInterval>();
for (let i = 0; i < countryCodes.length; i++) {
const raw = results[i]?.result;
if (typeof raw !== 'string') continue;
try {
const parsed = JSON.parse(raw) as { p05?: number; p95?: number };
if (typeof parsed.p05 === 'number' && typeof parsed.p95 === 'number') {
map.set(countryCodes[i]!, { p05: parsed.p05, p95: parsed.p95 });
}
} catch { /* ignore malformed interval entries */ }
}
return map;
}
export const getResilienceRanking: ResilienceServiceHandler['getResilienceRanking'] = async (
_ctx: ServerContext,
_req: GetResilienceRankingRequest,
@@ -50,7 +69,8 @@ export const getResilienceRanking: ResilienceServiceHandler['getResilienceRankin
}
}
const allItems = countryCodes.map((countryCode) => buildRankingItem(countryCode, cachedScores.get(countryCode)));
const intervals = await fetchIntervals([...cachedScores.keys()]);
const allItems = countryCodes.map((countryCode) => buildRankingItem(countryCode, cachedScores.get(countryCode), intervals.get(countryCode)));
const response: GetResilienceRankingResponse = {
items: sortRankingItems(allItems.filter((item) => item.overallCoverage >= GREY_OUT_COVERAGE_THRESHOLD)),
greyedOut: allItems.filter((item) => item.overallCoverage < GREY_OUT_COVERAGE_THRESHOLD),

View File

@@ -56,6 +56,7 @@ export interface ResilienceRankingItem {
level: string;
lowConfidence: boolean;
overallCoverage: number;
rankStable: boolean;
}
export interface FieldViolation {

View File

@@ -56,6 +56,7 @@ export interface ResilienceRankingItem {
level: string;
lowConfidence: boolean;
overallCoverage: number;
rankStable: boolean;
}
export interface FieldViolation {

View File

@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import { getResilienceRanking } from '../server/worldmonitor/resilience/v1/get-resilience-ranking.ts';
import { sortRankingItems } from '../server/worldmonitor/resilience/v1/_shared.ts';
import { buildRankingItem, sortRankingItems } from '../server/worldmonitor/resilience/v1/_shared.ts';
import { installRedis } from './helpers/fake-upstash-redis.mts';
import { RESILIENCE_FIXTURES } from './helpers/resilience-fixtures.mts';
@@ -46,7 +46,7 @@ describe('resilience ranking contracts', () => {
],
greyedOut: [],
};
redis.set('resilience:ranking:v7', JSON.stringify(cached));
redis.set('resilience:ranking:v8', JSON.stringify(cached));
const response = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
@@ -65,7 +65,7 @@ describe('resilience ranking contracts', () => {
{ countryCode: 'ER', overallScore: 10, level: 'critical', lowConfidence: true, overallCoverage: 0.12 },
],
};
redis.set('resilience:ranking:v7', JSON.stringify(cached));
redis.set('resilience:ranking:v8', JSON.stringify(cached));
const response = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
@@ -103,6 +103,45 @@ describe('resilience ranking contracts', () => {
assert.equal(totalItems, 3, `expected 3 total items across ranked + greyedOut, got ${totalItems}`);
assert.ok(redis.has('resilience:score:v7:YE'), 'missing country should be warmed during first call');
assert.ok(response.items.every((item) => item.overallScore >= 0), 'ranked items should all have computed scores');
assert.ok(redis.has('resilience:ranking:v7'), 'fully scored ranking should be cached');
assert.ok(redis.has('resilience:ranking:v8'), 'fully scored ranking should be cached');
});
it('sets rankStable=true when interval data exists and width <= 8', async () => {
const { redis } = installRedis(RESILIENCE_FIXTURES);
const domainWithCoverage = [{ id: 'political', score: 80, weight: 0.2, dimensions: [{ id: 'd1', score: 80, coverage: 0.9, observedWeight: 1, imputedWeight: 0 }] }];
redis.set('resilience:score:v7:NO', JSON.stringify({
countryCode: 'NO', overallScore: 82, level: 'high',
domains: domainWithCoverage, trend: 'stable', change30d: 1.2,
lowConfidence: false, imputationShare: 0.05,
}));
redis.set('resilience:score:v7:US', JSON.stringify({
countryCode: 'US', overallScore: 61, level: 'medium',
domains: domainWithCoverage, trend: 'rising', change30d: 4.3,
lowConfidence: false, imputationShare: 0.1,
}));
redis.set('resilience:intervals:v1:NO', JSON.stringify({ p05: 78, p95: 84 }));
redis.set('resilience:intervals:v1:US', JSON.stringify({ p05: 50, p95: 72 }));
const response = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
const no = response.items.find((item) => item.countryCode === 'NO');
const us = response.items.find((item) => item.countryCode === 'US');
assert.equal(no?.rankStable, true, 'NO interval width 6 should be stable');
assert.equal(us?.rankStable, false, 'US interval width 22 should be unstable');
});
it('defaults rankStable=false when no interval data exists', () => {
const item = buildRankingItem('ZZ', {
countryCode: 'ZZ', overallScore: 50, level: 'medium',
domains: [], trend: 'stable', change30d: 0,
lowConfidence: false, imputationShare: 0,
baselineScore: 50, stressScore: 50, stressFactor: 0.5, dataVersion: '',
});
assert.equal(item.rankStable, false, 'missing interval should default to unstable');
});
it('returns rankStable=false for null response (unscored country)', () => {
const item = buildRankingItem('XX');
assert.equal(item.rankStable, false);
});
});

View File

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