mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(resilience): baseline vs stress scoring engine (#2821)
* feat(resilience): baseline vs stress scoring engine Splits the resilience index into structural capacity (baselineScore) and active disruption (stressScore) using the dimension type tags from RESILIENCE_DIMENSION_TYPES (baseline/stress/mixed). overallScore = baselineScore * (1 - stressFactor) where stressFactor is clamped to [0, 0.5]. Mixed dimensions contribute to both scores. Proto fields 10-12 added (baseline_score, stress_score, stress_factor). Widget updated to display baseline/stress breakdown. Cache keys bumped v4 -> v5 for atomic rollout. * fix(resilience): bump history key to v2 for baseline/stress formula change The overallScore formula changed from domain-weighted-sum to baselineScore * (1 - stressFactor). Old history entries are incomparable, causing fake change30d drops of -20 to -30 points. Versioned history key starts a clean series.
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:v4',
|
||||
resilienceRanking: 'resilience:ranking:v5',
|
||||
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":{"change30d":{"format":"double","type":"number"},"countryCode":{"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"},"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"},"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"]}}}}
|
||||
@@ -121,6 +121,15 @@ components:
|
||||
imputationShare:
|
||||
type: number
|
||||
format: double
|
||||
baselineScore:
|
||||
type: number
|
||||
format: double
|
||||
stressScore:
|
||||
type: number
|
||||
format: double
|
||||
stressFactor:
|
||||
type: number
|
||||
format: double
|
||||
ResilienceDomain:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -19,4 +19,7 @@ message GetResilienceScoreResponse {
|
||||
double change_30d = 7;
|
||||
bool low_confidence = 8;
|
||||
double imputation_share = 9;
|
||||
double baseline_score = 10;
|
||||
double stress_score = 11;
|
||||
double stress_factor = 12;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ loadEnvFile(import.meta.url);
|
||||
const LOCK_DOMAIN = 'resilience:scores';
|
||||
const LOCK_TTL_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v4:';
|
||||
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v4';
|
||||
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v5:';
|
||||
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v5';
|
||||
export const RESILIENCE_RANKING_CACHE_TTL_SECONDS = 6 * 60 * 60;
|
||||
export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1';
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { detectTrend, round } from '../../../_shared/resilience-stats';
|
||||
import {
|
||||
RESILIENCE_DIMENSION_DOMAINS,
|
||||
RESILIENCE_DIMENSION_ORDER,
|
||||
RESILIENCE_DIMENSION_TYPES,
|
||||
RESILIENCE_DOMAIN_ORDER,
|
||||
createMemoizedSeedReader,
|
||||
getResilienceDomainWeight,
|
||||
@@ -21,9 +22,9 @@ import {
|
||||
|
||||
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:v4:';
|
||||
export const RESILIENCE_HISTORY_KEY_PREFIX = 'resilience:history:';
|
||||
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v4';
|
||||
export const RESILIENCE_SCORE_CACHE_PREFIX = 'resilience:score:v5:';
|
||||
export const RESILIENCE_HISTORY_KEY_PREFIX = 'resilience:history:v2:';
|
||||
export const RESILIENCE_RANKING_CACHE_KEY = 'resilience:ranking:v5';
|
||||
export const RESILIENCE_STATIC_INDEX_KEY = 'resilience:static:index:v1';
|
||||
|
||||
const LOW_CONFIDENCE_COVERAGE_THRESHOLD = 0.55;
|
||||
@@ -151,6 +152,9 @@ export async function ensureResilienceScoreCached(countryCode: string, reader?:
|
||||
return {
|
||||
countryCode: '',
|
||||
overallScore: 0,
|
||||
baselineScore: 0,
|
||||
stressScore: 0,
|
||||
stressFactor: 0.5,
|
||||
level: 'unknown',
|
||||
domains: [],
|
||||
trend: 'stable',
|
||||
@@ -167,9 +171,18 @@ export async function ensureResilienceScoreCached(countryCode: string, reader?:
|
||||
const scoreMap = await scoreAllDimensions(normalizedCountryCode, reader);
|
||||
const dimensions = buildDimensionList(scoreMap);
|
||||
const domains = buildDomainList(dimensions);
|
||||
const overallScore = round(
|
||||
domains.reduce((sum, domain) => sum + domain.score * domain.weight, 0),
|
||||
);
|
||||
|
||||
const baselineDims: ResilienceDimension[] = [];
|
||||
const stressDims: ResilienceDimension[] = [];
|
||||
for (const dim of dimensions) {
|
||||
const dimType = RESILIENCE_DIMENSION_TYPES[dim.id as ResilienceDimensionId];
|
||||
if (dimType === 'baseline' || dimType === 'mixed') baselineDims.push(dim);
|
||||
if (dimType === 'stress' || dimType === 'mixed') stressDims.push(dim);
|
||||
}
|
||||
const baselineScore = round(coverageWeightedMean(baselineDims));
|
||||
const stressScore = round(coverageWeightedMean(stressDims));
|
||||
const stressFactor = round(Math.max(0, Math.min(1 - stressScore / 100, 0.5)), 4);
|
||||
const overallScore = round(baselineScore * (1 - stressFactor));
|
||||
|
||||
const totalImputed = dimensions.reduce((sum, d) => sum + (d.imputedWeight ?? 0), 0);
|
||||
const totalObserved = dimensions.reduce((sum, d) => sum + (d.observedWeight ?? 0), 0);
|
||||
@@ -187,6 +200,9 @@ export async function ensureResilienceScoreCached(countryCode: string, reader?:
|
||||
return {
|
||||
countryCode: normalizedCountryCode,
|
||||
overallScore,
|
||||
baselineScore,
|
||||
stressScore,
|
||||
stressFactor,
|
||||
level: classifyResilienceLevel(overallScore),
|
||||
domains,
|
||||
trend: detectTrend(scoreSeries),
|
||||
@@ -199,6 +215,9 @@ export async function ensureResilienceScoreCached(countryCode: string, reader?:
|
||||
) ?? {
|
||||
countryCode: normalizedCountryCode,
|
||||
overallScore: 0,
|
||||
baselineScore: 0,
|
||||
stressScore: 0,
|
||||
stressFactor: 0.5,
|
||||
level: 'unknown',
|
||||
domains: [],
|
||||
trend: 'stable',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { invokeTauri } from '@/services/tauri-bridge';
|
||||
import { h, replaceChildren } from '@/utils/dom-utils';
|
||||
import {
|
||||
RESILIENCE_VISUAL_LEVEL_COLORS,
|
||||
formatBaselineStress,
|
||||
formatResilienceChange30d,
|
||||
formatResilienceConfidence,
|
||||
getResilienceDomainLabel,
|
||||
@@ -19,6 +20,9 @@ import type { CountryEnergyProfileData } from './CountryBriefPanel';
|
||||
const LOCKED_PREVIEW: ResilienceScoreResponse = {
|
||||
countryCode: 'US',
|
||||
overallScore: 73,
|
||||
baselineScore: 82,
|
||||
stressScore: 58,
|
||||
stressFactor: 0.21,
|
||||
level: 'high',
|
||||
domains: [
|
||||
{ id: 'economic', score: 82, weight: 0.22, dimensions: [] },
|
||||
@@ -262,6 +266,14 @@ export class ResilienceWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
...(data.baselineScore != null && data.stressScore != null && data.stressFactor != null
|
||||
? [h(
|
||||
'div',
|
||||
{ className: 'resilience-widget__baseline-stress' },
|
||||
h('span', { className: 'resilience-widget__baseline-stress-text' },
|
||||
formatBaselineStress(data.baselineScore, data.stressScore, data.stressFactor)),
|
||||
)]
|
||||
: []),
|
||||
h(
|
||||
'div',
|
||||
{ className: 'resilience-widget__domains' },
|
||||
|
||||
@@ -52,3 +52,10 @@ export function formatResilienceChange30d(change30d: number): string {
|
||||
const sign = change30d > 0 ? '+' : '';
|
||||
return `30d ${sign}${rounded}`;
|
||||
}
|
||||
|
||||
export function formatBaselineStress(baseline: number, stress: number, stressFactor: number): string {
|
||||
const b = Number.isFinite(baseline) ? Math.round(baseline) : 0;
|
||||
const s = Number.isFinite(stress) ? Math.round(stress) : 0;
|
||||
const impact = Number.isFinite(stressFactor) ? Math.round(stressFactor * 100) : 0;
|
||||
return `Baseline: ${b} | Stress: ${s} | Impact: -${impact}%`;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface GetResilienceScoreResponse {
|
||||
change30d: number;
|
||||
lowConfidence: boolean;
|
||||
imputationShare: number;
|
||||
baselineScore: number;
|
||||
stressScore: number;
|
||||
stressFactor: number;
|
||||
}
|
||||
|
||||
export interface ResilienceDomain {
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface GetResilienceScoreResponse {
|
||||
change30d: number;
|
||||
lowConfidence: boolean;
|
||||
imputationShare: number;
|
||||
baselineScore: number;
|
||||
stressScore: number;
|
||||
stressFactor: number;
|
||||
}
|
||||
|
||||
export interface ResilienceDomain {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('resilience handlers', () => {
|
||||
delete process.env.VERCEL_ENV;
|
||||
|
||||
const { fetchImpl, redis, sortedSets } = createRedisFetch(RESILIENCE_FIXTURES);
|
||||
sortedSets.set('resilience:history:US', [
|
||||
sortedSets.set('resilience:history:v2:US', [
|
||||
{ member: '2026-04-01:20', score: 20260401 },
|
||||
{ member: '2026-04-02:30', score: 20260402 },
|
||||
]);
|
||||
@@ -47,17 +47,23 @@ describe('resilience handlers', () => {
|
||||
assert.ok(response.change30d > 0);
|
||||
assert.equal(typeof response.lowConfidence, 'boolean');
|
||||
assert.ok(response.imputationShare >= 0 && response.imputationShare <= 1, `imputationShare out of bounds: ${response.imputationShare}`);
|
||||
assert.equal(typeof response.baselineScore, 'number', 'baselineScore should be present');
|
||||
assert.equal(typeof response.stressScore, 'number', 'stressScore should be present');
|
||||
assert.equal(typeof response.stressFactor, 'number', 'stressFactor should be present');
|
||||
assert.ok(response.baselineScore >= 0 && response.baselineScore <= 100, `baselineScore out of bounds: ${response.baselineScore}`);
|
||||
assert.ok(response.stressScore >= 0 && response.stressScore <= 100, `stressScore out of bounds: ${response.stressScore}`);
|
||||
assert.ok(response.stressFactor >= 0 && response.stressFactor <= 0.5, `stressFactor out of bounds: ${response.stressFactor}`);
|
||||
|
||||
const cachedScore = redis.get('resilience:score:v4:US');
|
||||
const cachedScore = redis.get('resilience:score:v5:US');
|
||||
assert.ok(cachedScore, 'expected score cache to be written');
|
||||
assert.equal(JSON.parse(cachedScore || '{}').countryCode, 'US');
|
||||
|
||||
const history = sortedSets.get('resilience:history:US') ?? [];
|
||||
const history = sortedSets.get('resilience:history:v2:US') ?? [];
|
||||
assert.ok(history.some((entry) => entry.member.startsWith(today + ':')), 'expected today history member to be written');
|
||||
|
||||
await getResilienceScore({ request: new Request('https://example.com') } as never, {
|
||||
countryCode: 'US',
|
||||
});
|
||||
assert.equal((sortedSets.get('resilience:history:US') ?? []).length, history.length, 'cache hit must not append history');
|
||||
assert.equal((sortedSets.get('resilience:history:v2:US') ?? []).length, history.length, 'cache hit must not append history');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,12 +46,12 @@ describe('resilience ranking contracts', () => {
|
||||
],
|
||||
greyedOut: [],
|
||||
};
|
||||
redis.set('resilience:ranking:v4', JSON.stringify(cached));
|
||||
redis.set('resilience:ranking:v5', JSON.stringify(cached));
|
||||
|
||||
const response = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
|
||||
|
||||
assert.deepEqual(response, cached);
|
||||
assert.equal(redis.has('resilience:score:v4:YE'), false, 'cache hit must not trigger score warmup');
|
||||
assert.equal(redis.has('resilience:score:v5:YE'), false, 'cache hit must not trigger score warmup');
|
||||
});
|
||||
|
||||
it('returns all-greyed-out cached payload without rewarming (items=[], greyedOut non-empty)', async () => {
|
||||
@@ -65,18 +65,18 @@ describe('resilience ranking contracts', () => {
|
||||
{ countryCode: 'ER', overallScore: 10, level: 'critical', lowConfidence: true, overallCoverage: 0.12 },
|
||||
],
|
||||
};
|
||||
redis.set('resilience:ranking:v4', JSON.stringify(cached));
|
||||
redis.set('resilience:ranking:v5', JSON.stringify(cached));
|
||||
|
||||
const response = await getResilienceRanking({ request: new Request('https://example.com') } as never, {});
|
||||
|
||||
assert.deepEqual(response, cached);
|
||||
assert.equal(redis.has('resilience:score:v4:SS'), false, 'all-greyed-out cache hit must not trigger score warmup');
|
||||
assert.equal(redis.has('resilience:score:v5:SS'), false, 'all-greyed-out cache hit must not trigger score warmup');
|
||||
});
|
||||
|
||||
it('warms missing scores synchronously and returns complete ranking on first call', async () => {
|
||||
const { redis } = installRedis(RESILIENCE_FIXTURES);
|
||||
const domainWithCoverage = [{ name: 'political', dimensions: [{ name: 'd1', coverage: 0.9 }] }];
|
||||
redis.set('resilience:score:v4:NO', JSON.stringify({
|
||||
redis.set('resilience:score:v5:NO', JSON.stringify({
|
||||
countryCode: 'NO',
|
||||
overallScore: 82,
|
||||
level: 'high',
|
||||
@@ -86,7 +86,7 @@ describe('resilience ranking contracts', () => {
|
||||
lowConfidence: false,
|
||||
imputationShare: 0.05,
|
||||
}));
|
||||
redis.set('resilience:score:v4:US', JSON.stringify({
|
||||
redis.set('resilience:score:v5:US', JSON.stringify({
|
||||
countryCode: 'US',
|
||||
overallScore: 61,
|
||||
level: 'medium',
|
||||
@@ -101,8 +101,8 @@ describe('resilience ranking contracts', () => {
|
||||
|
||||
const totalItems = response.items.length + (response.greyedOut?.length ?? 0);
|
||||
assert.equal(totalItems, 3, `expected 3 total items across ranked + greyedOut, got ${totalItems}`);
|
||||
assert.ok(redis.has('resilience:score:v4:YE'), 'missing country should be warmed during first call');
|
||||
assert.ok(redis.has('resilience:score:v5: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:v4'), 'fully scored ranking should be cached');
|
||||
assert.ok(redis.has('resilience:ranking:v5'), 'fully scored ranking should be cached');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,8 @@ describe('resilience release gate', () => {
|
||||
),
|
||||
);
|
||||
for (const response of highAnchors) {
|
||||
assert.ok(response.overallScore >= 75, `${response.countryCode} should remain in the high-resilience band`);
|
||||
assert.ok(response.overallScore >= 60, `${response.countryCode} should remain in the high-resilience band (baseline*stress formula)`);
|
||||
assert.ok(response.baselineScore > response.stressScore * 0.8, `${response.countryCode} baseline should be >= 80% of stress for resilient countries`);
|
||||
}
|
||||
|
||||
const lowAnchors = await Promise.all(
|
||||
@@ -94,7 +95,8 @@ describe('resilience release gate', () => {
|
||||
),
|
||||
);
|
||||
for (const response of lowAnchors) {
|
||||
assert.ok(response.overallScore <= 30, `${response.countryCode} should remain in the low-resilience band`);
|
||||
assert.ok(response.overallScore <= 20, `${response.countryCode} should remain in the low-resilience band (baseline*stress formula)`);
|
||||
assert.ok(response.stressScore < 40, `${response.countryCode} (fragile) should have stressScore < 40`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RESILIENCE_DIMENSION_DOMAINS,
|
||||
RESILIENCE_DIMENSION_ORDER,
|
||||
RESILIENCE_DIMENSION_SCORERS,
|
||||
RESILIENCE_DIMENSION_TYPES,
|
||||
RESILIENCE_DOMAIN_ORDER,
|
||||
getResilienceDomainWeight,
|
||||
scoreAllDimensions,
|
||||
@@ -69,10 +70,6 @@ describe('resilience scorer contracts', () => {
|
||||
return [domainId, average];
|
||||
}));
|
||||
|
||||
const overallScore = Number(RESILIENCE_DOMAIN_ORDER.reduce((sum, domainId) => {
|
||||
return sum + domainAverages[domainId] * getResilienceDomainWeight(domainId);
|
||||
}, 0).toFixed(2));
|
||||
|
||||
assert.deepEqual(domainAverages, {
|
||||
economic: 66.33,
|
||||
infrastructure: 79,
|
||||
@@ -80,7 +77,114 @@ describe('resilience scorer contracts', () => {
|
||||
'social-governance': 61.75,
|
||||
'health-food': 60.5,
|
||||
});
|
||||
assert.equal(overallScore, 68.72);
|
||||
|
||||
function round(v: number, d = 2) { return Number(v.toFixed(d)); }
|
||||
function coverageWeightedMean(dims: { score: number; coverage: number }[]) {
|
||||
const totalCov = dims.reduce((s, d) => s + d.coverage, 0);
|
||||
if (!totalCov) return 0;
|
||||
return dims.reduce((s, d) => s + d.score * d.coverage, 0) / totalCov;
|
||||
}
|
||||
|
||||
const dimensions = RESILIENCE_DIMENSION_ORDER.map((id) => ({
|
||||
id,
|
||||
score: round(scoreMap[id].score),
|
||||
coverage: round(scoreMap[id].coverage),
|
||||
}));
|
||||
const baselineDims = dimensions.filter((d) => {
|
||||
const t = RESILIENCE_DIMENSION_TYPES[d.id as keyof typeof RESILIENCE_DIMENSION_TYPES];
|
||||
return t === 'baseline' || t === 'mixed';
|
||||
});
|
||||
const stressDims = dimensions.filter((d) => {
|
||||
const t = RESILIENCE_DIMENSION_TYPES[d.id as keyof typeof RESILIENCE_DIMENSION_TYPES];
|
||||
return t === 'stress' || t === 'mixed';
|
||||
});
|
||||
|
||||
const baselineScore = round(coverageWeightedMean(baselineDims));
|
||||
const stressScore = round(coverageWeightedMean(stressDims));
|
||||
const stressFactor = round(Math.max(0, Math.min(1 - stressScore / 100, 0.5)), 4);
|
||||
const overallScore = round(baselineScore * (1 - stressFactor));
|
||||
|
||||
assert.equal(baselineScore, 67.85);
|
||||
assert.equal(stressScore, 67.85);
|
||||
assert.equal(stressFactor, 0.3215);
|
||||
assert.equal(overallScore, 46.04);
|
||||
});
|
||||
|
||||
it('baselineScore is computed from baseline + mixed dimensions only', async () => {
|
||||
installRedis(RESILIENCE_FIXTURES);
|
||||
const scoreMap = await scoreAllDimensions('US');
|
||||
|
||||
const baselineDimIds = RESILIENCE_DIMENSION_ORDER.filter((id) => {
|
||||
const t = RESILIENCE_DIMENSION_TYPES[id];
|
||||
return t === 'baseline' || t === 'mixed';
|
||||
});
|
||||
const stressOnlyDimIds = RESILIENCE_DIMENSION_ORDER.filter((id) => RESILIENCE_DIMENSION_TYPES[id] === 'stress');
|
||||
|
||||
assert.ok(baselineDimIds.length > 0, 'should have baseline dims');
|
||||
for (const id of stressOnlyDimIds) {
|
||||
assert.ok(!baselineDimIds.includes(id), `stress-only dimension ${id} should not appear in baseline set`);
|
||||
}
|
||||
assert.ok(baselineDimIds.includes('macroFiscal'), 'macroFiscal should be in baseline set');
|
||||
assert.ok(baselineDimIds.includes('infrastructure'), 'infrastructure should be in baseline set');
|
||||
assert.ok(baselineDimIds.includes('logisticsSupply'), 'mixed logisticsSupply should be in baseline set');
|
||||
});
|
||||
|
||||
it('stressScore is computed from stress + mixed dimensions only', async () => {
|
||||
installRedis(RESILIENCE_FIXTURES);
|
||||
const scoreMap = await scoreAllDimensions('US');
|
||||
|
||||
const stressDimIds = RESILIENCE_DIMENSION_ORDER.filter((id) => {
|
||||
const t = RESILIENCE_DIMENSION_TYPES[id];
|
||||
return t === 'stress' || t === 'mixed';
|
||||
});
|
||||
const baselineOnlyDimIds = RESILIENCE_DIMENSION_ORDER.filter((id) => RESILIENCE_DIMENSION_TYPES[id] === 'baseline');
|
||||
|
||||
assert.ok(stressDimIds.length > 0, 'should have stress dims');
|
||||
for (const id of baselineOnlyDimIds) {
|
||||
assert.ok(!stressDimIds.includes(id), `baseline-only dimension ${id} should not appear in stress set`);
|
||||
}
|
||||
assert.ok(stressDimIds.includes('currencyExternal'), 'currencyExternal should be in stress set');
|
||||
assert.ok(stressDimIds.includes('borderSecurity'), 'borderSecurity should be in stress set');
|
||||
assert.ok(stressDimIds.includes('energy'), 'mixed energy should be in stress set');
|
||||
});
|
||||
|
||||
it('overallScore = baselineScore * (1 - stressFactor)', async () => {
|
||||
installRedis(RESILIENCE_FIXTURES);
|
||||
const scoreMap = await scoreAllDimensions('US');
|
||||
function round(v: number, d = 2) { return Number(v.toFixed(d)); }
|
||||
function coverageWeightedMean(dims: { score: number; coverage: number }[]) {
|
||||
const totalCov = dims.reduce((s, d) => s + d.coverage, 0);
|
||||
if (!totalCov) return 0;
|
||||
return dims.reduce((s, d) => s + d.score * d.coverage, 0) / totalCov;
|
||||
}
|
||||
const dimensions = RESILIENCE_DIMENSION_ORDER.map((id) => ({
|
||||
id, score: round(scoreMap[id].score), coverage: round(scoreMap[id].coverage),
|
||||
}));
|
||||
const baselineDims = dimensions.filter((d) => {
|
||||
const t = RESILIENCE_DIMENSION_TYPES[d.id as keyof typeof RESILIENCE_DIMENSION_TYPES];
|
||||
return t === 'baseline' || t === 'mixed';
|
||||
});
|
||||
const stressDims = dimensions.filter((d) => {
|
||||
const t = RESILIENCE_DIMENSION_TYPES[d.id as keyof typeof RESILIENCE_DIMENSION_TYPES];
|
||||
return t === 'stress' || t === 'mixed';
|
||||
});
|
||||
const bs = round(coverageWeightedMean(baselineDims));
|
||||
const ss = round(coverageWeightedMean(stressDims));
|
||||
const sf = round(Math.max(0, Math.min(1 - ss / 100, 0.5)), 4);
|
||||
const expected = round(bs * (1 - sf));
|
||||
assert.ok(expected > 0, 'overall should be positive');
|
||||
assert.equal(expected, 46.04, 'overallScore should match the baseline * (1 - stressFactor) formula');
|
||||
});
|
||||
|
||||
it('stressFactor is clamped to [0, 0.5]', () => {
|
||||
function clampStressFactor(stressScore: number) {
|
||||
return Math.max(0, Math.min(1 - stressScore / 100, 0.5));
|
||||
}
|
||||
assert.equal(clampStressFactor(100), 0, 'perfect stress score = zero factor');
|
||||
assert.equal(clampStressFactor(0), 0.5, 'zero stress score = max factor 0.5');
|
||||
assert.equal(clampStressFactor(50), 0.5, 'stress 50 = clamped to 0.5');
|
||||
assert.ok(clampStressFactor(70) >= 0 && clampStressFactor(70) <= 0.5, 'stress 70 within bounds');
|
||||
assert.ok(clampStressFactor(110) >= 0, 'stress above 100 still clamped');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -180,11 +180,11 @@ describe('buildRankingPayload', () => {
|
||||
|
||||
describe('exported constants', () => {
|
||||
it('RESILIENCE_RANKING_CACHE_KEY matches server-side key', () => {
|
||||
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v4');
|
||||
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v5');
|
||||
});
|
||||
|
||||
it('RESILIENCE_SCORE_CACHE_PREFIX matches server-side prefix', () => {
|
||||
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v4:');
|
||||
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v5:');
|
||||
});
|
||||
|
||||
it('RESILIENCE_RANKING_CACHE_TTL_SECONDS is 6 hours', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
formatBaselineStress,
|
||||
formatResilienceChange30d,
|
||||
formatResilienceConfidence,
|
||||
getResilienceDomainLabel,
|
||||
@@ -13,6 +14,9 @@ import type { ResilienceScoreResponse } from '../src/services/resilience';
|
||||
const baseResponse: ResilienceScoreResponse = {
|
||||
countryCode: 'US',
|
||||
overallScore: 73,
|
||||
baselineScore: 82,
|
||||
stressScore: 58,
|
||||
stressFactor: 0.21,
|
||||
level: 'high',
|
||||
domains: [
|
||||
{ id: 'economic', score: 80, weight: 0.22, dimensions: [
|
||||
@@ -63,3 +67,10 @@ test('formatResilienceChange30d preserves explicit sign formatting', () => {
|
||||
assert.equal(formatResilienceChange30d(-1.26), '30d -1.3');
|
||||
assert.equal(formatResilienceChange30d(0), '30d 0.0');
|
||||
});
|
||||
|
||||
test('formatBaselineStress renders the expected breakdown string', () => {
|
||||
assert.equal(formatBaselineStress(72.1, 58.3, 0.21), 'Baseline: 72 | Stress: 58 | Impact: -21%');
|
||||
assert.equal(formatBaselineStress(80, 100, 0), 'Baseline: 80 | Stress: 100 | Impact: -0%');
|
||||
assert.equal(formatBaselineStress(50, 0, 0.5), 'Baseline: 50 | Stress: 0 | Impact: -50%');
|
||||
assert.equal(formatBaselineStress(NaN, 50, 0.1), 'Baseline: 0 | Stress: 50 | Impact: -10%');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user