mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(resilience): add scoring handlers, stats, and client wrapper Rebased on main after #2676 (country map consolidation) merged. Scoring layer: - server/_shared/resilience-stats.ts: trend detection, Cronbach alpha - server/worldmonitor/resilience/v1/_shared.ts: score caching, ranking, history tracking with Redis sorted sets (ZRANGE WITHSCORES format) - get-resilience-score.ts: delegates validation to _shared - get-resilience-ranking.ts: ranking handler for all seeded countries - src/services/resilience.ts: browser-side RPC wrapper Test fixes: - Handler test: use dynamic date instead of hardcoded 2026-04-03 - Scorer test: use >= for NO/US comparison (tied-at-max is valid) Closes #2486 Refs #2487, #2488 Co-authored-by: Lucas Passos <lspassos1@users.noreply.github.com> * fix(resilience): address P1/P2 review findings P1 fixes: - History sorted set: use date-derived integer (20260404) as ZADD score instead of resilience score, so ZREMRANGEBYRANK trims oldest entries not lowest-scored. Member format now "YYYY-MM-DD:score". - Restore countryCode validation in get-resilience-score.ts (was removed during rebase, contradicts required query param in proto) - Ranking cold-cache: await warmup instead of fire-and-forget, so first response has real scores instead of -1 placeholders P2 fixes: - probabilityDown: derive from already-rounded probabilityUp to guarantee sum === 1.00 - Client normalizeCountryCode: reject non-ISO2 inputs --------- Co-authored-by: Lucas Passos <lspassos1@users.noreply.github.com>
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
cronbachAlpha,
|
|
detectChangepoints,
|
|
detectTrend,
|
|
exponentialSmoothing,
|
|
minMaxNormalize,
|
|
nrcForecast,
|
|
} from '../server/_shared/resilience-stats';
|
|
|
|
test('cronbachAlpha returns the expected coefficient for a known matrix', () => {
|
|
const alpha = cronbachAlpha([
|
|
[1, 2, 3],
|
|
[2, 3, 4],
|
|
[3, 4, 5],
|
|
[4, 5, 6],
|
|
]);
|
|
|
|
assert.ok(alpha > 0.99 && alpha <= 1, `expected alpha near 1, got ${alpha}`);
|
|
});
|
|
|
|
test('cronbachAlpha returns 0 for a single-row matrix', () => {
|
|
assert.equal(cronbachAlpha([[1, 2, 3]]), 0);
|
|
});
|
|
|
|
test('cronbachAlpha returns 0 when all rows are identical', () => {
|
|
assert.equal(cronbachAlpha([
|
|
[5, 5, 5],
|
|
[5, 5, 5],
|
|
[5, 5, 5],
|
|
]), 0);
|
|
});
|
|
|
|
test('detectTrend identifies rising, falling, and flat series', () => {
|
|
assert.equal(detectTrend([10, 20, 30, 40, 50]), 'rising');
|
|
assert.equal(detectTrend([50, 40, 30, 20, 10]), 'falling');
|
|
assert.equal(detectTrend([20, 20.02, 19.98, 20.01, 19.99]), 'stable');
|
|
});
|
|
|
|
test('detectTrend treats fewer than 3 values as stable', () => {
|
|
assert.equal(detectTrend([]), 'stable');
|
|
assert.equal(detectTrend([10]), 'stable');
|
|
assert.equal(detectTrend([10, 15]), 'stable');
|
|
});
|
|
|
|
test('detectChangepoints finds a structural break in a bimodal series', () => {
|
|
const changepoints = detectChangepoints([10, 11, 9, 10, 10, 50, 52, 48, 51, 49]);
|
|
assert.ok(changepoints.some((index) => index >= 5), `expected changepoint at the break, got ${changepoints}`);
|
|
});
|
|
|
|
test('detectChangepoints returns [] for constant and short series', () => {
|
|
assert.deepEqual(detectChangepoints([5, 5, 5, 5, 5, 5]), []);
|
|
assert.deepEqual(detectChangepoints([1, 2, 3, 4, 5]), []);
|
|
});
|
|
|
|
test('minMaxNormalize handles empty input, identical values, and negatives', () => {
|
|
assert.deepEqual(minMaxNormalize([]), []);
|
|
assert.deepEqual(minMaxNormalize([7, 7, 7]), [0.5, 0.5, 0.5]);
|
|
assert.deepEqual(minMaxNormalize([-10, 0, 10]), [0, 0.5, 1]);
|
|
});
|
|
|
|
test('exponentialSmoothing smooths a noisy series without changing length', () => {
|
|
const result = exponentialSmoothing([10, 20, 15, 25], 0.5);
|
|
assert.equal(result.length, 4);
|
|
assert.deepEqual(result.map((value) => Number(value.toFixed(2))), [10, 15, 15, 20]);
|
|
});
|
|
|
|
test('nrcForecast returns the requested horizon with bounded confidence intervals', () => {
|
|
const forecast = nrcForecast([45, 48, 50, 53, 57, 60], 7, 0.4);
|
|
|
|
assert.equal(forecast.values.length, 7);
|
|
assert.equal(forecast.confidenceIntervals.length, 7);
|
|
for (const value of forecast.values) {
|
|
assert.ok(value >= 0 && value <= 100, `forecast value out of bounds: ${value}`);
|
|
}
|
|
for (const interval of forecast.confidenceIntervals) {
|
|
assert.ok(interval.lower <= interval.upper, `invalid interval: ${JSON.stringify(interval)}`);
|
|
assert.ok(interval.lower >= 0 && interval.upper <= 100, `interval out of bounds: ${JSON.stringify(interval)}`);
|
|
assert.equal(interval.level, 95);
|
|
}
|
|
assert.equal(Number((forecast.probabilityUp + forecast.probabilityDown).toFixed(2)), 1);
|
|
});
|
|
|
|
test('nrcForecast falls back to a flat 50/50 outlook for short history', () => {
|
|
const forecast = nrcForecast([88], 3);
|
|
|
|
assert.deepEqual(forecast.values, [88, 88, 88]);
|
|
assert.deepEqual(forecast.confidenceIntervals, [
|
|
{ lower: 79.2, upper: 96.8, level: 95 },
|
|
{ lower: 79.2, upper: 96.8, level: 95 },
|
|
{ lower: 79.2, upper: 96.8, level: 95 },
|
|
]);
|
|
assert.equal(forecast.probabilityUp, 0.5);
|
|
assert.equal(forecast.probabilityDown, 0.5);
|
|
});
|