mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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',
|
||||
|
||||
@@ -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"]}}}}
|
||||
@@ -203,3 +203,5 @@ components:
|
||||
overallCoverage:
|
||||
type: number
|
||||
format: double
|
||||
rankStable:
|
||||
type: boolean
|
||||
|
||||
@@ -23,4 +23,5 @@ message ResilienceRankingItem {
|
||||
string level = 3;
|
||||
bool low_confidence = 4;
|
||||
double overall_coverage = 5;
|
||||
bool rank_stable = 6;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface ResilienceRankingItem {
|
||||
level: string;
|
||||
lowConfidence: boolean;
|
||||
overallCoverage: number;
|
||||
rankStable: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface ResilienceRankingItem {
|
||||
level: string;
|
||||
lowConfidence: boolean;
|
||||
overallCoverage: number;
|
||||
rankStable: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user