diff --git a/docs/methodology/country-resilience-index.mdx b/docs/methodology/country-resilience-index.mdx index 6384ce9ef..80a86fcf4 100644 --- a/docs/methodology/country-resilience-index.mdx +++ b/docs/methodology/country-resilience-index.mdx @@ -237,9 +237,34 @@ This domain forms the recovery-capacity pillar. It measures a country's ability #### Reserve Adequacy +PR 2 §3.4 retired `reserveAdequacy` from the core overall score. The dimension remains registered for schema continuity but pins at `coverage=0`, `score=50`, `imputationClass=null` for every country (same shape as the PR 3 `fuelStockDays` retirement — the `null` tag avoids a false "Source down" label in the widget for a deliberate construct retirement). The construct split into two dimensions that separate the liquid-reserves signal from the sovereign-wealth signal: `liquidReserveAdequacy` (below) and `sovereignFiscalBuffer` (below). See the v2.3 changelog entry for the rationale. + | Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence | |---|---|---|---|---|---|---| -| recoveryReserveMonths | Total reserves in months of imports (World Bank FI.RES.TOTL.MO) | Higher is better | 1 - 18 | 1.00 | World Bank | Annual | +| recoveryReserveMonths | Total reserves in months of imports (World Bank FI.RES.TOTL.MO) — **experimental tier, not part of core score** | Higher is better | 1 - 18 | 1.00 | World Bank | Annual | + +#### Liquid Reserve Adequacy + +PR 2 §3.4 replacement for the liquid-reserves half of the retired `reserveAdequacy`. Same upstream source (World Bank `FI.RES.TOTL.MO`, total reserves in months of imports) but re-anchored `1..12` months instead of `1..18`. Twelve months is the ballpark IMF "full reserve adequacy" benchmark for a diversified emerging-market importer; the tighter ceiling prevents wealthy commodity-exporters from claiming outsized credit for on-paper reserve stocks that are not the relevant shock-absorption buffer. The sovereign-wealth half of the split lives in `sovereignFiscalBuffer` below. + +| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence | +|---|---|---|---|---|---|---| +| recoveryLiquidReserveMonths | Total reserves in months of imports (World Bank FI.RES.TOTL.MO), re-anchored 1..12 | Higher is better | 1 - 12 | 1.00 | World Bank | Annual | + +#### Sovereign Fiscal Buffer + +PR 2 §3.4 new dimension. Measures the per-country deployable fiscal buffer from sovereign wealth fund assets, discounted by a three-component haircut (access × liquidity × transparency) per published fund governance. The composite is: + +``` +effectiveMonths = Σ [ (aum / annualImports × 12) × access × liquidity × transparency ] +score = 100 × (1 − exp(−effectiveMonths / 12)) +``` + +The exponential saturation prevents Norway-type outliers (effective months in the 100s) from dominating the recovery pillar out of proportion to their marginal resilience benefit. Countries not in the SWF manifest (`docs/methodology/swf-classification-manifest.yaml`) score `0` with **full coverage** — this is substantive absence, not imputation: a country without a deployable sovereign-wealth buffer legitimately scores lower on this dim than a SWF-holding peer. + +| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence | +|---|---|---|---|---|---|---| +| recoverySovereignWealthEffectiveMonths | Haircut-weighted sovereign-wealth assets in months of imports, saturating | Higher is better | 0 - 60 | 1.00 | Wikipedia SWF list + per-fund articles (CC-BY-SA), haircut by swf-classification-manifest.yaml | Quarterly | #### External Debt Coverage diff --git a/scripts/compare-resilience-current-vs-proposed.mjs b/scripts/compare-resilience-current-vs-proposed.mjs index 7772d195d..08a5d149c 100644 --- a/scripts/compare-resilience-current-vs-proposed.mjs +++ b/scripts/compare-resilience-current-vs-proposed.mjs @@ -343,9 +343,18 @@ const EXTRACTION_RULES = { recoveryFiscalBalance: { type: 'recovery-country-field', key: 'resilience:recovery:fiscal-space:v1', field: 'fiscalBalancePct' }, recoveryDebtToGdp: { type: 'recovery-country-field', key: 'resilience:recovery:fiscal-space:v1', field: 'debtToGdpPct' }, recoveryReserveMonths: { type: 'recovery-country-field', key: 'resilience:recovery:reserve-adequacy:v1', field: 'reserveMonths' }, + // PR 2 §3.4: replacement for recoveryReserveMonths at the tighter 1..12 + // anchor. Same seed key + field; the harness extracts the same value + // and the scorer applies the new goalpost. + recoveryLiquidReserveMonths: { type: 'recovery-country-field', key: 'resilience:recovery:reserve-adequacy:v1', field: 'reserveMonths' }, recoveryDebtToReserves: { type: 'recovery-country-field', key: 'resilience:recovery:external-debt:v1', field: 'debtToReservesRatio' }, recoveryImportHhi: { type: 'recovery-country-field', key: 'resilience:recovery:import-hhi:v1', field: 'hhi' }, recoveryFuelStockDays: { type: 'recovery-country-field', key: 'resilience:recovery:fuel-stocks:v1', field: 'stockDays' }, + // PR 2 §3.4: SWF seed. Field is totalEffectiveMonths (pre-haircut sum + // across a country's manifest funds). Countries without a manifest + // entry score 0 via the substantive-no-SWF branch in the scorer; + // the harness treats "absent from payload" as 0 for correlation math. + recoverySovereignWealthEffectiveMonths: { type: 'recovery-country-field', key: 'resilience:recovery:sovereign-wealth:v1', field: 'totalEffectiveMonths' }, // ── stateContinuity derived signals ───────────────────────────────── recoveryWgiContinuity: { type: 'static-wgi-mean' }, diff --git a/server/worldmonitor/resilience/v1/_dimension-scorers.ts b/server/worldmonitor/resilience/v1/_dimension-scorers.ts index 1c455c3fd..347be86b6 100644 --- a/server/worldmonitor/resilience/v1/_dimension-scorers.ts +++ b/server/worldmonitor/resilience/v1/_dimension-scorers.ts @@ -21,11 +21,15 @@ export type ResilienceDimensionId = | 'healthPublicService' | 'foodWater' | 'fiscalSpace' - | 'reserveAdequacy' + | 'reserveAdequacy' // RETIRED in PR 2 §3.4: replaced by + // liquidReserveAdequacy + sovereignFiscalBuffer + // (see RESILIENCE_RETIRED_DIMENSIONS below). | 'externalDebtCoverage' | 'importConcentration' | 'stateContinuity' - | 'fuelStockDays'; + | 'fuelStockDays' + | 'liquidReserveAdequacy' // PR 2 §3.4: WB FI.RES.TOTL.MO, anchors 1..12 months + | 'sovereignFiscalBuffer'; // PR 2 §3.4: SWF haircut with saturating transform export type ResilienceDomainId = | 'economic' @@ -134,11 +138,27 @@ export const IMPUTE = { bisCredit: IMPUTATION.curated_list_absent, unhcrDisplacement: { score: 85, certaintyCoverage: 0.6, imputationClass: 'stable-absence' }, // crisis_monitoring_absent, displacement-specific recoveryFiscalSpace: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, - recoveryReserveAdequacy: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, + // recoveryReserveAdequacy removed in PR 2 §3.4 — the retired + // scoreReserveAdequacy stub no longer reads from IMPUTE (it hardcodes + // coverage=0 / imputationClass=null per the retirement pattern). The + // replacement dimension's IMPUTE entry lives at + // `recoveryLiquidReserveAdequacy` below. recoveryExternalDebt: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, recoveryImportHhi: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, recoveryStateContinuity: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, recoveryFuelStocks: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, + // PR 2 §3.4 — same source as the retired reserveAdequacy + // (WB FI.RES.TOTL.MO) but the new dim re-anchors 1..12 months instead + // of 1..18. Fallback coverage identical because the upstream source + // has not changed. + recoveryLiquidReserveAdequacy: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, + // PR 2 §3.4 — used when the sovereign-wealth seed key is absent + // entirely (Railway cron has not fired yet on a fresh deploy). + // Countries NOT in the manifest but payload present are handled + // separately by the scorer as "no SWF → score 0, full coverage" + // (substantive absence, not imputation — see plan §3.4 "What happens + // to no-SWF countries"). + recoverySovereignFiscalBuffer: { score: 50, certaintyCoverage: 0.3, imputationClass: 'unmonitored' }, } as const satisfies Record; interface StaticIndicatorValue { @@ -258,6 +278,13 @@ const RESILIENCE_RECOVERY_FISCAL_SPACE_KEY = 'resilience:recovery:fiscal-space:v const RESILIENCE_RECOVERY_RESERVE_ADEQUACY_KEY = 'resilience:recovery:reserve-adequacy:v1'; const RESILIENCE_RECOVERY_EXTERNAL_DEBT_KEY = 'resilience:recovery:external-debt:v1'; const RESILIENCE_RECOVERY_IMPORT_HHI_KEY = 'resilience:recovery:import-hhi:v1'; +// PR 2 §3.4 — new SWF seed populated by scripts/seed-sovereign-wealth.mjs +// (landed in #3305, wired into the resilience-recovery Railway bundle in +// #3319). Per-country shape: { funds: [...], totalEffectiveMonths, +// annualImports, expectedFunds, matchedFunds, completeness }. Countries +// not in the manifest are absent from the payload (substantive "no SWF" +// signal, distinct from the IMPUTE fallback below). +const RESILIENCE_RECOVERY_SOVEREIGN_WEALTH_KEY = 'resilience:recovery:sovereign-wealth:v1'; // RESILIENCE_RECOVERY_FUEL_STOCKS_KEY removed in PR 3: scoreFuelStockDays // no longer reads any source key. If a new globally-comparable // recovery-fuel concept lands in a future PR, add a new key with an @@ -352,6 +379,8 @@ export const RESILIENCE_DIMENSION_DOMAINS: Record { + return { + score: 50, + coverage: 0, + observedWeight: 0, + imputedWeight: 0, + imputationClass: null, + freshness: { lastObservedAtMs: 0, staleness: '' }, + }; +} + +// PR 2 §3.4 — new dimension replacing the liquid-reserves half of the +// retired `reserveAdequacy`. Same source (World Bank `FI.RES.TOTL.MO` +// total reserves in months of imports) but re-anchored to 1..12 months +// instead of 1..18. The tighter ceiling is per the plan: "Anchors 1–12 +// months." A country at 12+ months clamps at 100; a country at 1 month +// clamps at 0. Twelve months = ballpark IMF "full reserve adequacy" +// benchmark for a diversified emerging-market importer. +export async function scoreLiquidReserveAdequacy( countryCode: string, reader: ResilienceSeedReader = defaultSeedReader, ): Promise { @@ -1450,16 +1517,95 @@ export async function scoreReserveAdequacy( const entry = getRecoveryCountryEntry(raw, countryCode); if (!entry || entry.reserveMonths == null) { return { - score: IMPUTE.recoveryReserveAdequacy.score, - coverage: IMPUTE.recoveryReserveAdequacy.certaintyCoverage, + score: IMPUTE.recoveryLiquidReserveAdequacy.score, + coverage: IMPUTE.recoveryLiquidReserveAdequacy.certaintyCoverage, observedWeight: 0, imputedWeight: 1, - imputationClass: IMPUTE.recoveryReserveAdequacy.imputationClass, + imputationClass: IMPUTE.recoveryLiquidReserveAdequacy.imputationClass, freshness: { lastObservedAtMs: 0, staleness: '' }, }; } return weightedBlend([ - { score: normalizeHigherBetter(Math.min(entry.reserveMonths, 18), 1, 18), weight: 1.0 }, + { score: normalizeHigherBetter(Math.min(entry.reserveMonths, 12), 1, 12), weight: 1.0 }, + ]); +} + +// PR 2 §3.4 — new SWF haircut dimension. Reads per-country SWF records +// from `resilience:recovery:sovereign-wealth:v1` (produced by +// scripts/seed-sovereign-wealth.mjs). Composite: +// effectiveMonths = rawSwfMonths × access × liquidity × transparency +// pre-computed in the seed payload as `totalEffectiveMonths` (sum +// across a country's manifest funds). Score: +// score = 100 × (1 − exp(−effectiveMonths / 12)) +// The exponential saturation prevents Norway-type outliers (effective +// months in the 100s) from dominating the recovery pillar out of +// proportion to their marginal resilience benefit. +// +// Three code paths: +// 1. Seed key absent entirely (Railway cron hasn't fired on fresh +// deploy) → IMPUTE fallback, score 50 / coverage 0.3 / unmonitored. +// 2. Seed key present, country in payload → saturating score. Coverage +// is derated by `completeness` so a partial-scrape on a multi-fund +// country (AE = ADIA + Mubadala, SG = GIC + Temasek) shows up +// as lower confidence rather than a silently-understated total. +// 3. Seed key present, country NOT in payload → the country has no +// sovereign wealth fund in the manifest. Per plan §3.4 "What +// happens to no-SWF countries": score 0 with FULL coverage (this +// is substantive absence, not imputation). The country stays in +// the recovery-pillar denominator with weight; 0 × weight = 0 in +// the numerator, so it correctly lowers relative recovery score +// vs SWF-holding peers. +interface RecoverySovereignWealthCountry { + totalEffectiveMonths?: number | null; + completeness?: number | null; + annualImports?: number | null; +} +interface RecoverySovereignWealthPayload { + countries?: Record; +} + +export async function scoreSovereignFiscalBuffer( + countryCode: string, + reader: ResilienceSeedReader = defaultSeedReader, +): Promise { + const raw = await reader(RESILIENCE_RECOVERY_SOVEREIGN_WEALTH_KEY); + const payload = raw as RecoverySovereignWealthPayload | null | undefined; + // Path 1 — seed key absent entirely. IMPUTE. + if (!payload || typeof payload !== 'object' || !payload.countries || typeof payload.countries !== 'object') { + return { + score: IMPUTE.recoverySovereignFiscalBuffer.score, + coverage: IMPUTE.recoverySovereignFiscalBuffer.certaintyCoverage, + observedWeight: 0, + imputedWeight: 1, + imputationClass: IMPUTE.recoverySovereignFiscalBuffer.imputationClass, + freshness: { lastObservedAtMs: 0, staleness: '' }, + }; + } + const entry = payload.countries[countryCode.toUpperCase()] ?? null; + // Path 3 — seed present, country not in manifest → no SWF. + if (!entry) { + return { + score: 0, + coverage: 1.0, + observedWeight: 1, + imputedWeight: 0, + imputationClass: null, + freshness: { lastObservedAtMs: 0, staleness: '' }, + }; + } + // Path 2 — country has SWF(s). Saturating transform on totalEffectiveMonths. + const em = typeof entry.totalEffectiveMonths === 'number' && Number.isFinite(entry.totalEffectiveMonths) + ? Math.max(0, entry.totalEffectiveMonths) + : 0; + const score = 100 * (1 - Math.exp(-em / 12)); + const completeness = typeof entry.completeness === 'number' && Number.isFinite(entry.completeness) + ? Math.max(0, Math.min(1, entry.completeness)) + : 1.0; + return weightedBlend([ + // certaintyCoverage = completeness so partial-scrapes derate confidence + // without zeroing the observed weight. The country is still a real + // observation — just with fewer of its manifest funds resolved. + { score, weight: 1.0, certaintyCoverage: completeness }, ]); } @@ -1598,6 +1744,14 @@ export async function scoreStateContinuity( // `tests/resilience-retired-dimensions-parity.test.mts`. export const RESILIENCE_RETIRED_DIMENSIONS: ReadonlySet = new Set([ 'fuelStockDays', + // PR 2 §3.4 — reserveAdequacy is retired; replaced by the split + // { liquidReserveAdequacy, sovereignFiscalBuffer }. The legacy + // scorer returns coverage=0 / imputationClass=null (same shape as + // scoreFuelStockDays post-retirement) so it's filtered from the + // confidence/coverage averages via this registry. Kept in + // RESILIENCE_DIMENSION_ORDER for structural continuity (tests, + // cached payload shape, registry membership). + 'reserveAdequacy', ]); export async function scoreFuelStockDays( @@ -1648,6 +1802,8 @@ ResilienceDimensionId, importConcentration: scoreImportConcentration, stateContinuity: scoreStateContinuity, fuelStockDays: scoreFuelStockDays, + liquidReserveAdequacy: scoreLiquidReserveAdequacy, + sovereignFiscalBuffer: scoreSovereignFiscalBuffer, }; export async function scoreAllDimensions( diff --git a/server/worldmonitor/resilience/v1/_indicator-registry.ts b/server/worldmonitor/resilience/v1/_indicator-registry.ts index fc7598db2..9386d0299 100644 --- a/server/worldmonitor/resilience/v1/_indicator-registry.ts +++ b/server/worldmonitor/resilience/v1/_indicator-registry.ts @@ -905,22 +905,83 @@ export const INDICATOR_REGISTRY: IndicatorSpec[] = [ license: 'open-data', }, - // ── reserveAdequacy (1 sub-metric) ─────────────────────────────────────── + // ── reserveAdequacy (RETIRED in PR 2 §3.4) ─────────────────────────────── + // Replaced by liquidReserveAdequacy + sovereignFiscalBuffer. The legacy + // indicator is kept in the registry at tier='experimental' so drill- + // down views that consult the registry by dimension still see + // something structural; it does not contribute to the core score. { id: 'recoveryReserveMonths', dimension: 'reserveAdequacy', - description: 'Total reserves in months of imports (World Bank FI.RES.TOTL.MO); recovery buffer against external shocks', + description: 'RETIRED in PR 2 §3.4. Legacy total-reserves-in-months-of-imports (WB FI.RES.TOTL.MO) at the 1..18 anchor. Does not contribute to the score — scoreReserveAdequacy returns coverage=0 + imputationClass=null. Superseded by recoveryLiquidReserveMonths (same source, re-anchored 1..12) + the new sovereign-wealth indicator.', direction: 'higherBetter', goalposts: { worst: 1, best: 18 }, weight: 1.0, sourceKey: 'resilience:recovery:reserve-adequacy:v1', scope: 'global', cadence: 'annual', + tier: 'experimental', + coverage: 188, + license: 'open-data', + }, + + // ── liquidReserveAdequacy (1 sub-metric) ───────────────────────────────── + // PR 2 §3.4 replacement for the liquid-reserves half of the retired + // reserveAdequacy. Same source (WB FI.RES.TOTL.MO) but re-anchored + // 1..12 months instead of 1..18. Twelve months ≈ IMF "full reserve + // adequacy" ballpark for a diversified emerging-market importer. + { + id: 'recoveryLiquidReserveMonths', + dimension: 'liquidReserveAdequacy', + description: 'Total reserves in months of imports (World Bank FI.RES.TOTL.MO), re-anchored 1..12 per plan §3.4. Immediate-liquidity buffer against short external shocks, measured at central-bank reserves only — sovereign-wealth assets are scored separately in sovereignFiscalBuffer.', + direction: 'higherBetter', + goalposts: { worst: 1, best: 12 }, + weight: 1.0, + sourceKey: 'resilience:recovery:reserve-adequacy:v1', + scope: 'global', + cadence: 'annual', tier: 'core', coverage: 188, license: 'open-data', }, + // ── sovereignFiscalBuffer (1 sub-metric) ───────────────────────────────── + // PR 2 §3.4 — scored on the SWF haircut manifest. Payload produced by + // scripts/seed-sovereign-wealth.mjs (landed in #3305, wired into + // Railway cron in #3319). Per-country totalEffectiveMonths is the sum + // across a country's manifest funds of (aum / annualImports × 12) × + // (access × liquidity × transparency). Scorer applies a saturating + // transform: score = 100 × (1 − exp(−effectiveMonths / 12)) to prevent + // Norway-type outliers from dominating the recovery pillar. + // + // Coverage for the registry entry is the current manifest size (8 + // funds across NO / AE / SA / KW / QA / SG). Countries NOT in the + // manifest score 0 with full coverage (substantive "no SWF" signal, + // not imputation) — this is by design per plan §3.4 "What happens to + // no-SWF countries." + { + id: 'recoverySovereignWealthEffectiveMonths', + dimension: 'sovereignFiscalBuffer', + description: 'Sovereign-wealth fiscal-buffer signal per plan §3.4. Seeded from Wikipedia SWF list + per-fund article infoboxes (CC-BY-SA), haircut by the classification manifest (docs/methodology/swf-classification-manifest.yaml): effectiveMonths = rawSwfMonths × access × liquidity × transparency, summed across a country\'s manifest funds. Scorer applies a saturating transform score = 100 × (1 − exp(−effectiveMonths / 12)).', + direction: 'higherBetter', + goalposts: { worst: 0, best: 60 }, + weight: 1.0, + sourceKey: 'resilience:recovery:sovereign-wealth:v1', + scope: 'global', + cadence: 'quarterly', + // tier='experimental' because the manifest ships with 8 funds (< the + // 180-country core-tier threshold / 137-country §3.6 gate). Non-SWF + // countries are scored meaningfully (0 with full coverage — a + // substantive absence, not imputation — per plan §3.4), but the + // §3.6 coverage-and-influence gate counts upstream-data coverage, + // which is 8. Graduating to 'core' requires expanding the manifest + // past 137 entries, which is a follow-up PR after external data + // partners are identified. + tier: 'experimental', + coverage: 8, + license: 'open-data', + }, + // ── externalDebtCoverage (1 sub-metric) ────────────────────────────────── { id: 'recoveryDebtToReserves', diff --git a/src/components/resilience-widget-utils.ts b/src/components/resilience-widget-utils.ts index a3e179233..c274b5d98 100644 --- a/src/components/resilience-widget-utils.ts +++ b/src/components/resilience-widget-utils.ts @@ -8,8 +8,19 @@ import type { ResilienceScoreResponse } from '@/services/resilience'; // dimensions are filtered out of the displayed coverage percentage so // a deliberate construct retirement does not silently drag the user- // facing confidence reading down for every country. +// +// Retirement index: +// - fuelStockDays (PR 3 §3.5) — IEA days-of-stock incomparable across +// net importers vs net exporters. +// - reserveAdequacy (PR 2 §3.4) — superseded by the +// liquidReserveAdequacy + +// sovereignFiscalBuffer split. +// +// The parity test parses this Set literally, so keep the array contents +// as string literals only — do not interleave comments between entries. const RESILIENCE_RETIRED_DIMENSION_IDS: ReadonlySet = new Set([ 'fuelStockDays', + 'reserveAdequacy', ]); // Gated locked-preview fixture rendered when the resilience widget is @@ -248,6 +259,12 @@ const DIMENSION_LABELS: Record = { importConcentration: 'Imports', stateContinuity: 'Continuity', fuelStockDays: 'Fuel', + // PR 2 §3.4 — new active dimensions. Labels chosen to stay short + // enough for the 19/21-cell confidence grid without leaking the + // internal ID. "Reserves" is already taken by the retired + // reserveAdequacy so the replacement disambiguates with "Liquid". + liquidReserveAdequacy: 'Liquid Reserves', + sovereignFiscalBuffer: 'Sovereign Wealth', }; export function getResilienceDimensionLabel(dimensionId: string): string { diff --git a/tests/resilience-confidence-averaging.test.mts b/tests/resilience-confidence-averaging.test.mts index c9b9ffe48..35277c55a 100644 --- a/tests/resilience-confidence-averaging.test.mts +++ b/tests/resilience-confidence-averaging.test.mts @@ -50,16 +50,20 @@ describe('computeOverallCoverage: retired-dim exclusion', () => { id: 'recovery', dimensions: [ dim('fiscalSpace', 0.9), - dim('reserveAdequacy', 0.8), - // Retired: must not pull the average down. - dim('fuelStockDays', 0), + dim('liquidReserveAdequacy', 0.8), // active replacement for reserveAdequacy + // Retired dims contribute coverage=0 in real payloads; both + // must be filtered out so the visible coverage reading + // tracks only the active dims. + dim('reserveAdequacy', 0), // retired in PR 2 §3.4 + dim('fuelStockDays', 0), // retired in PR 3 §3.5 ], }, ], } as unknown as GetResilienceScoreResponse; - // (0.9 + 0.8) / 2 = 0.85. With retired included the flat mean - // would be (0.9 + 0.8 + 0) / 3 ≈ 0.5667 — the regression shape. + // (0.9 + 0.8) / 2 = 0.85 — only the two active dims count. + // With retired included the flat mean would be + // (0.9 + 0.8 + 0 + 0) / 4 = 0.425 — the regression shape. assert.equal(computeOverallCoverage(response).toFixed(4), '0.8500'); }); diff --git a/tests/resilience-dimension-freshness.test.mts b/tests/resilience-dimension-freshness.test.mts index 8eb494b11..e08a20ee5 100644 --- a/tests/resilience-dimension-freshness.test.mts +++ b/tests/resilience-dimension-freshness.test.mts @@ -410,6 +410,14 @@ describe('INDICATOR_REGISTRY seed-meta coverage (T1.5 P1 regression lock)', () = // writes this. The registry sourceKey economic:energy:v1:all does // not strip to this shape, so SOURCE_KEY_META_OVERRIDES maps it. 'seed-meta:economic:energy-prices', + // PR 2 §3.4: seed-sovereign-wealth.mjs writes this via runSeed. Not + // yet registered in api/health.js SEED_META — per project memory + // feedback_health_required_key_needs_railway_cron_first.md, new + // seed keys go through ON_DEMAND_KEYS for ~7 days of clean Railway + // cron runs before promotion to SEED_META. A follow-up PR wires + // this once the cron has baked in; until then, allowlist it so + // the registry consistency check passes. + 'seed-meta:resilience:recovery:sovereign-wealth', ]); function extractSeedMetaKeys(filePath: string): Set { diff --git a/tests/resilience-dimension-monotonicity.test.mts b/tests/resilience-dimension-monotonicity.test.mts index 9739fb988..8406297ff 100644 --- a/tests/resilience-dimension-monotonicity.test.mts +++ b/tests/resilience-dimension-monotonicity.test.mts @@ -27,7 +27,7 @@ import { describe, it } from 'node:test'; import { scoreEnergy, - scoreReserveAdequacy, + scoreLiquidReserveAdequacy, scoreFiscalSpace, scoreExternalDebtCoverage, scoreImportConcentration, @@ -50,12 +50,14 @@ function makeRecoveryReader(keyValueMap: Record): ResilienceSee return async (key: string) => keyValueMap[key] ?? null; } -describe('resilience dimension monotonicity — scoreReserveAdequacy', () => { +// PR 2 §3.4: scoreReserveAdequacy is retired. The monotonicity contract +// moves to scoreLiquidReserveAdequacy — same source but 1..12 anchor. +describe('resilience dimension monotonicity — scoreLiquidReserveAdequacy', () => { it('higher reserveMonths → higher score', async () => { - const low = await scoreReserveAdequacy(TEST_ISO2, makeRecoveryReader({ + const low = await scoreLiquidReserveAdequacy(TEST_ISO2, makeRecoveryReader({ 'resilience:recovery:reserve-adequacy:v1': { countries: { [TEST_ISO2]: { reserveMonths: 2 } } }, })); - const high = await scoreReserveAdequacy(TEST_ISO2, makeRecoveryReader({ + const high = await scoreLiquidReserveAdequacy(TEST_ISO2, makeRecoveryReader({ 'resilience:recovery:reserve-adequacy:v1': { countries: { [TEST_ISO2]: { reserveMonths: 12 } } }, })); assert.ok(high.score > low.score, `reserveMonths 2→12 should raise score; got ${low.score} → ${high.score}`); diff --git a/tests/resilience-dimension-scorers.test.mts b/tests/resilience-dimension-scorers.test.mts index 59b3bb33d..52cca50f4 100644 --- a/tests/resilience-dimension-scorers.test.mts +++ b/tests/resilience-dimension-scorers.test.mts @@ -23,7 +23,9 @@ import { scoreFuelStockDays, scoreImportConcentration, scoreMacroFiscal, + scoreLiquidReserveAdequacy, scoreReserveAdequacy, + scoreSovereignFiscalBuffer, scoreSocialCohesion, scoreStateContinuity, scoreTradeSanctions, @@ -1100,13 +1102,16 @@ describe('resilience source-failure aggregation (T1.7)', () => { it('produce plausible country ordering for the recovery-capacity dimensions', async () => { const fiscal = await scoreTriple(scoreFiscalSpace); - const reserves = await scoreTriple(scoreReserveAdequacy); + // PR 2 §3.4: reserveAdequacy retired → test scoreLiquidReserveAdequacy + // (the replacement). Same source (WB FI.RES.TOTL.MO) but 1..12 anchor. + // Country ordering still holds: NO (14mo) > US (1mo) > YE (imputed). + const reserves = await scoreTriple(scoreLiquidReserveAdequacy); const extDebt = await scoreTriple(scoreExternalDebtCoverage); const importHhi = await scoreTriple(scoreImportConcentration); const continuity = await scoreTriple(scoreStateContinuity); assertOrdered('fiscalSpace', fiscal.no.score, fiscal.us.score, fiscal.ye.score); - assertOrdered('reserveAdequacy', reserves.no.score, reserves.us.score, reserves.ye.score); + assertOrdered('liquidReserveAdequacy', reserves.no.score, reserves.us.score, reserves.ye.score); assertOrdered('externalDebtCoverage', extDebt.no.score, extDebt.us.score, extDebt.ye.score); assertOrdered('importConcentration', importHhi.no.score, importHhi.us.score, importHhi.ye.score); assertOrdered('stateContinuity', continuity.no.score, continuity.us.score, continuity.ye.score); @@ -1127,9 +1132,100 @@ describe('resilience source-failure aggregation (T1.7)', () => { assert.equal(score.imputedWeight, 1); }); - it('scoreReserveAdequacy: high reserves score well', async () => { + // PR 2 §3.4 — scoreReserveAdequacy is retired (coverage=0 / + // imputationClass=null regardless of seed). The "high reserves score + // well" contract moves to scoreLiquidReserveAdequacy with the new + // 1..12 anchor. NO's 14 months clamps to the top of the range → 100. + it('scoreLiquidReserveAdequacy: high reserves score at the anchor ceiling', async () => { + const no = await scoreLiquidReserveAdequacy('NO', fixtureReader); + assert.ok(no.score >= 99, `NO with 14 months reserves clamped to 12 should score >=99 on the 1..12 anchor, got ${no.score}`); + assert.ok(no.coverage >= 0.99, 'observed-data path must report full coverage'); + assert.equal(no.imputationClass, null, 'observed-data path must not carry imputation class'); + }); + + it('scoreLiquidReserveAdequacy: missing data returns unmonitored imputation', async () => { + const emptyReader = async (_key: string): Promise => null; + const score = await scoreLiquidReserveAdequacy('XX', emptyReader); + assert.equal(score.imputationClass, 'unmonitored'); + assert.equal(score.observedWeight, 0); + assert.equal(score.imputedWeight, 1); + }); + + // PR 2 §3.4 — retired scoreReserveAdequacy shape. Mirrors the + // fuelStockDays retirement test (PR 3 §3.5) — coverage=0 / + // imputationClass=null regardless of seed so the confidence / + // coverage averages filter it out via RESILIENCE_RETIRED_DIMENSIONS. + it('scoreReserveAdequacy: retired — coverage=0 / null imputationClass for every country', async () => { const no = await scoreReserveAdequacy('NO', fixtureReader); - assert.ok(no.score > 70, `NO with 14 months reserves should score >70, got ${no.score}`); + const ye = await scoreReserveAdequacy('YE', fixtureReader); + for (const [label, result] of [['NO', no], ['YE', ye]] as const) { + assert.equal(result.coverage, 0, `${label}: retired dimension must have coverage=0`); + assert.equal(result.observedWeight, 0, `${label}: retired dimension must have observedWeight=0`); + assert.equal(result.imputedWeight, 0, `${label}: retired dimension must have imputedWeight=0`); + assert.equal(result.imputationClass, null, `${label}: retired dimension must not tag source-failure (intentional retirement, not a runtime outage)`); + } + }); + + // PR 2 §3.4 — scoreSovereignFiscalBuffer has three code paths per + // plan §3.4: (1) seed absent → IMPUTE, (2) seed present but country + // not in manifest → substantive "no SWF" (score=0, coverage=1.0), + // (3) country in payload → saturating transform on + // totalEffectiveMonths. + describe('scoreSovereignFiscalBuffer — three code paths', () => { + it('path 1: seed key absent → IMPUTE fallback', async () => { + const emptyReader = async (_key: string): Promise => null; + const score = await scoreSovereignFiscalBuffer('US', emptyReader); + assert.equal(score.imputationClass, 'unmonitored'); + assert.equal(score.observedWeight, 0); + assert.equal(score.imputedWeight, 1); + assert.equal(score.score, 50); + }); + + it('path 3: country not in manifest → score=0, coverage=1.0 (substantive absence)', async () => { + // Payload present but country missing → no SWF per plan §3.4 + // "What happens to no-SWF countries." Must NOT fall through to + // IMPUTE. + const reader = async (_key: string) => ({ countries: { NO: { totalEffectiveMonths: 60, completeness: 1.0 } } }); + const score = await scoreSovereignFiscalBuffer('US', reader); + assert.equal(score.score, 0, 'no-SWF country must score 0'); + assert.equal(score.coverage, 1.0, 'no-SWF country must report FULL coverage (substantive, not imputed)'); + assert.equal(score.observedWeight, 1); + assert.equal(score.imputedWeight, 0); + assert.equal(score.imputationClass, null); + }); + + it('path 2: country with SWF → saturating transform on totalEffectiveMonths', async () => { + // 60 effective months → 100 × (1 − exp(−60/12)) = 100 × (1 − e^-5) ≈ 99.33 + const reader = async (_key: string) => ({ countries: { NO: { totalEffectiveMonths: 60, completeness: 1.0 } } }); + const score = await scoreSovereignFiscalBuffer('NO', reader); + assert.ok(score.score > 98 && score.score <= 100, `60 effective months should saturate near 100, got ${score.score}`); + assert.ok(score.coverage >= 0.99, 'full completeness should map to full coverage'); + assert.equal(score.observedWeight, 1); + }); + + it('path 2: partial-scrape country derates coverage by completeness', async () => { + // AE = ADIA + Mubadala. If Mubadala's scrape drifts, completeness = 0.5. + // The score itself is still the saturating transform on whatever + // totalEffectiveMonths we got, but coverage reflects the partial-seed. + // Note: `coverage` (certaintyCoverage) is independent of `observedWeight` + // in weightedBlend — coverage degrades with completeness, observedWeight + // tracks the metric's nominal weight (still 1.0 for a single real-data + // metric). The two fields carry different semantics downstream. + const reader = async (_key: string) => ({ countries: { AE: { totalEffectiveMonths: 12, completeness: 0.5 } } }); + const score = await scoreSovereignFiscalBuffer('AE', reader); + assert.ok(score.coverage > 0.49 && score.coverage < 0.51, + `partial-scrape (completeness=0.5) must derate coverage to ~0.5, got ${score.coverage}`); + assert.equal(score.observedWeight, 1, 'observedWeight tracks metric weight (real-data), not completeness'); + assert.equal(score.imputedWeight, 0); + }); + + it('path 2: zero effective months → score 0 with observed coverage (fund exists but classification-haircut zeros it out)', async () => { + const reader = async (_key: string) => ({ countries: { XX: { totalEffectiveMonths: 0, completeness: 1.0 } } }); + const score = await scoreSovereignFiscalBuffer('XX', reader); + assert.equal(score.score, 0); + assert.equal(score.coverage, 1.0); + assert.equal(score.observedWeight, 1); + }); }); it('scoreExternalDebtCoverage: low debt-to-reserves ratio scores well', async () => { diff --git a/tests/resilience-handlers.test.mts b/tests/resilience-handlers.test.mts index 0693a0f1e..6b106f603 100644 --- a/tests/resilience-handlers.test.mts +++ b/tests/resilience-handlers.test.mts @@ -40,7 +40,10 @@ describe('resilience handlers', () => { assert.equal(response.countryCode, 'US'); assert.equal(response.domains.length, 6); - assert.equal(response.domains.flatMap((domain) => domain.dimensions).length, 19); + // 19 active + 2 retired (fuelStockDays, reserveAdequacy) = 21. Retired + // dims stay in the response for structural continuity; they're + // filtered out of confidence averages via RESILIENCE_RETIRED_DIMENSIONS. + assert.equal(response.domains.flatMap((domain) => domain.dimensions).length, 21); assert.ok(response.overallScore > 0 && response.overallScore <= 100); assert.equal(response.level, response.overallScore >= 70 ? 'high' : response.overallScore >= 40 ? 'medium' : 'low'); assert.equal(response.trend, 'rising'); diff --git a/tests/resilience-indicator-registry.test.mts b/tests/resilience-indicator-registry.test.mts index 6d0c0c72c..f101d006b 100644 --- a/tests/resilience-indicator-registry.test.mts +++ b/tests/resilience-indicator-registry.test.mts @@ -6,12 +6,12 @@ import { INDICATOR_REGISTRY } from '../server/worldmonitor/resilience/v1/_indica import type { IndicatorSpec } from '../server/worldmonitor/resilience/v1/_indicator-registry.ts'; describe('indicator registry', () => { - it('covers all 19 dimensions', () => { + it('covers all 21 dimensions (19 active + 2 retired)', () => { const coveredDimensions = new Set(INDICATOR_REGISTRY.map((i) => i.dimension)); for (const dimId of RESILIENCE_DIMENSION_ORDER) { assert.ok(coveredDimensions.has(dimId), `${dimId} has no indicators in registry`); } - assert.equal(coveredDimensions.size, 19); + assert.equal(coveredDimensions.size, 21); }); it('has no duplicate indicator ids', () => { diff --git a/tests/resilience-methodology-lint.test.mts b/tests/resilience-methodology-lint.test.mts index f3138b9f8..e42cc3137 100644 --- a/tests/resilience-methodology-lint.test.mts +++ b/tests/resilience-methodology-lint.test.mts @@ -52,6 +52,8 @@ const HEADING_TO_DIMENSION: Readonly> = { 'Import Concentration': 'importConcentration', 'State Continuity': 'stateContinuity', 'Fuel Stock Days': 'fuelStockDays', + 'Liquid Reserve Adequacy': 'liquidReserveAdequacy', + 'Sovereign Fiscal Buffer': 'sovereignFiscalBuffer', }; function findMethodologyFile(): string { diff --git a/tests/resilience-release-gate.test.mts b/tests/resilience-release-gate.test.mts index 9b8b6c799..e2b2ee525 100644 --- a/tests/resilience-release-gate.test.mts +++ b/tests/resilience-release-gate.test.mts @@ -48,22 +48,19 @@ function installRedisFixtures() { } describe('resilience release gate', () => { - it('keeps all 19 dimension scorers non-placeholder for the required countries', async () => { - // PR 3 §3.5: fuelStockDays is retired — scoreFuelStockDays emits - // coverage=0 + imputationClass=null for every country. The retirement - // is intentional (construct incomparable across net importers / net - // exporters). Allow-list it so the zero-coverage placeholder check - // still catches unintended regressions in the OTHER 18 dimensions. - // - // imputationClass=null (not 'source-failure') because the widget maps - // 'source-failure' to a "Source down: upstream seeder failed" label - // with a `!` icon — surfacing that for every country on a deliberate - // retirement would manufacture a false outage signal. - const RETIRED_DIMENSIONS = new Set(['fuelStockDays']); + it('keeps all 21 dimension scorers non-placeholder for the required countries', async () => { + // PR 3 §3.5 retired fuelStockDays; PR 2 §3.4 retired reserveAdequacy + // (superseded by the liquidReserveAdequacy + sovereignFiscalBuffer + // split). Both scorers emit coverage=0 + imputationClass=null — the + // widget maps 'source-failure' to a "Source down" label, which would + // manufacture a false outage signal on every country for a deliberate + // construct retirement. Allow-list keeps the zero-coverage placeholder + // check enforcing on the OTHER 19 dimensions. + const RETIRED_DIMENSIONS = new Set(['fuelStockDays', 'reserveAdequacy']); for (const countryCode of REQUIRED_DIMENSION_COUNTRIES) { const scores = await scoreAllDimensions(countryCode, fixtureReader); const entries = Object.entries(scores); - assert.equal(entries.length, 19, `${countryCode} should have all resilience dimensions`); + assert.equal(entries.length, 21, `${countryCode} should have all 21 resilience dimensions (19 active + 2 retired kept for structural continuity)`); for (const [dimensionId, score] of entries) { assert.ok(Number.isFinite(score.score), `${countryCode} ${dimensionId} should produce a numeric score`); if (RETIRED_DIMENSIONS.has(dimensionId)) { @@ -254,7 +251,7 @@ describe('resilience release gate', () => { ); const allDimensions = response.domains.flatMap((domain) => domain.dimensions); - assert.equal(allDimensions.length, 19, 'US response should carry all 19 dimensions'); + assert.equal(allDimensions.length, 21, 'US response should carry all 21 dimensions (19 active + 2 retired)'); for (const dimension of allDimensions) { assert.equal( typeof dimension.imputationClass, @@ -282,7 +279,7 @@ describe('resilience release gate', () => { ); const allDimensions = response.domains.flatMap((domain) => domain.dimensions); - assert.equal(allDimensions.length, 19, 'US response should carry all 19 dimensions'); + assert.equal(allDimensions.length, 21, 'US response should carry all 21 dimensions (19 active + 2 retired)'); const validLevels = ['', 'fresh', 'aging', 'stale']; for (const dimension of allDimensions) { assert.ok(dimension.freshness != null, `dimension ${dimension.id} must carry a freshness payload`); diff --git a/tests/resilience-retired-dimensions-parity.test.mts b/tests/resilience-retired-dimensions-parity.test.mts index 6c08b36a0..cc1fa2a47 100644 --- a/tests/resilience-retired-dimensions-parity.test.mts +++ b/tests/resilience-retired-dimensions-parity.test.mts @@ -31,7 +31,11 @@ function parseClientRetiredIds(): Set { 'If the constant was renamed or reformatted, update this parser to match.', ); } - const ids = match[1]! + // Strip line comments (// …) from the array body so a reviewer can + // drop an inline rationale without breaking parity. Block comments + // inside a const array are unusual enough we don't handle them. + const body = match[1]!.replace(/\/\/[^\n]*/g, ''); + const ids = body .split(',') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) diff --git a/tests/resilience-scorers.test.mts b/tests/resilience-scorers.test.mts index 3a10f403a..f8909e7ba 100644 --- a/tests/resilience-scorers.test.mts +++ b/tests/resilience-scorers.test.mts @@ -60,17 +60,25 @@ describe('resilience scorer contracts', () => { // source-failure when the adapter is in seed-meta failedDatasets. This is the // single source of truth for "no currency data"; null-imputationClass paths // on non-real-data return branches are no longer permitted. - // PR 3 §3.5: fuelStockDays removed from this set — scoreFuelStockDays - // now returns coverage=0 + imputationClass=null for every country - // (retired), so it passes the default coverage=0 assertion below - // instead of the T1.7 fall-through assertion. The `null` tag (rather - // than 'source-failure') reflects the intentional retirement — see - // the widget `formatDimensionConfidence` absent-path which would - // otherwise surface a false "Source down" label on every country. + // PR 3 §3.5: fuelStockDays retired (coverage=0 + imputationClass=null). + // PR 2 §3.4: reserveAdequacy retired (same shape). Both pass the + // default coverage=0 assertion below instead of the T1.7 fall-through + // assertion. + // + // liquidReserveAdequacy (PR 2 §3.4) is NEW and falls through to + // IMPUTE.recoveryLiquidReserveAdequacy (imputationClass=unmonitored) + // when its seed is missing — same taxonomy as the other recovery + // dims in this set. + // + // sovereignFiscalBuffer (PR 2 §3.4) falls through to + // IMPUTE.recoverySovereignFiscalBuffer when the SWF seed key is + // absent entirely. Added here alongside the other recovery + // fall-throughs. const coverageZeroExempt = new Set([ 'currencyExternal', - 'fiscalSpace', 'reserveAdequacy', 'externalDebtCoverage', + 'fiscalSpace', 'externalDebtCoverage', 'importConcentration', 'stateContinuity', + 'liquidReserveAdequacy', 'sovereignFiscalBuffer', ]); for (const [dimensionId, scorer] of Object.entries(RESILIENCE_DIMENSION_SCORERS)) { const result = await scorer('US'); @@ -103,13 +111,24 @@ describe('resilience scorer contracts', () => { // Recovery 54.83 → 47.33 after externalDebtCoverage goalpost was // tightened from (0..5) to (0..2) per §3.5 point 3 (US ratio=1.5 // now scores 25 instead of 70). + // + // PR 2 §3.4: recovery 47.33 → 48.75 after the split. The flat mean + // now covers 8 dims for US: fiscalSpace=44, reserveAdequacy=50 + // (retired, coverage=0 but still in the flat mean), externalDebt=25, + // importConcentration=88, stateContinuity=65, fuelStockDays=50 + // (retired, same shape), liquidReserveAdequacy=18 (US has ~1 month + // of reserves via WB FI.RES.TOTL.MO normalized 1..12 → 18), and + // sovereignFiscalBuffer=50 (IMPUTE fallback until Railway cron + // populates the SWF seed; US has no manifest entry). Sum 390 / 8 + // = 48.75. Coverage-weighted domain aggregation (used by the real + // scoring pipeline) is separately verified below. assert.deepEqual(domainAverages, { economic: 66.33, infrastructure: 79, energy: 80, 'social-governance': 61.75, 'health-food': 60.5, - recovery: 47.33, + recovery: 48.75, }); function round(v: number, d = 2) { return Number(v.toFixed(d)); } @@ -138,9 +157,15 @@ describe('resilience scorer contracts', () => { const stressFactor = round(Math.max(0, Math.min(1 - stressScore / 100, 0.5)), 4); // PR 3 §3.5: 62.64 → 63.63 (fuelStockDays retirement) → 60.12 - // (externalDebtCoverage goalpost tightened; US score drops from 70 - // to 25, pulling the coverage-weighted baseline mean down). - assert.equal(baselineScore, 60.12); + // (externalDebtCoverage goalpost tightened). + // PR 2 §3.4: 60.12 → 60.35 — split adds liquidReserveAdequacy + // (US ≈ 1 month WB reserves → score 18 at cov=1.0) and + // sovereignFiscalBuffer (IMPUTE at 50 / cov=0.3) into the baseline + // coverage-weighted mean. Net effect is a small upward shift + // because the retired reserveAdequacy's 50-at-coverage-weighted-1 + // is replaced by the same total weight split across the two new + // dims with different coverage profiles. + assert.equal(baselineScore, 60.35); // PR 3 §3.5: 65.84 → 67.85 (fuelStockDays retirement) → 67.21 // (currencyExternal rebuilt on IMF inflation + WB reserves, coverage // shifts and US stress score moves). stressFactor updates in lockstep: @@ -161,7 +186,12 @@ describe('resilience scorer contracts', () => { // PR 3 §3.5: 65.57 → 65.82 (fuelStockDays retirement) → 65.52 // (currencyExternal rebuild) → 63.27 (externalDebtCoverage goalpost // tightened 0..5 → 0..2; US recovery-domain contribution drops). - assert.equal(overallScore, 63.27); + // PR 2 §3.4: 63.27 → 63.6 after the reserveAdequacy split. The new + // liquidReserveAdequacy at score=18 / coverage=1.0 + sovereign- + // FiscalBuffer at score=50 / coverage=0.3 shifts the recovery- + // domain coverage-weighted mean upward (retired reserveAdequacy + // dropped out with coverage=0), lifting the overall by ~0.33. + assert.equal(overallScore, 63.6); }); it('baselineScore is computed from baseline + mixed dimensions only', async () => { @@ -234,7 +264,9 @@ describe('resilience scorer contracts', () => { assert.ok(expected > 0, 'overall should be positive'); // PR 3 §3.5: 65.82 → 65.52 (currencyExternal rebuild) → 63.27 after // externalDebtCoverage goalpost tightened from (0..5) to (0..2). - assert.equal(expected, 63.27, 'overallScore should match sum(domainScore * domainWeight); 65.52 → 63.27 after PR 3 §3.5 externalDebtCoverage re-goalpost'); + // PR 2 §3.4: 63.27 → 63.6 after reserveAdequacy retirement + the + // liquidReserveAdequacy / sovereignFiscalBuffer split. + assert.equal(expected, 63.6, 'overallScore should match sum(domainScore * domainWeight); 63.27 → 63.6 after PR 2 §3.4 reserveAdequacy split'); }); it('stressFactor is still computed (informational) and clamped to [0, 0.5]', () => { diff --git a/tests/resilience-widget.test.mts b/tests/resilience-widget.test.mts index aae7b251a..cb60750ec 100644 --- a/tests/resilience-widget.test.mts +++ b/tests/resilience-widget.test.mts @@ -187,7 +187,7 @@ test('baseResponse includes dataVersion (regression for T1.4 wiring)', () => { // scorer dimension must have a stable display label and a consistent // status classification. -test('getResilienceDimensionLabel returns short stable labels for all 19 dimensions', () => { +test('getResilienceDimensionLabel returns short stable labels for all 21 dimensions', () => { assert.equal(getResilienceDimensionLabel('macroFiscal'), 'Macro'); assert.equal(getResilienceDimensionLabel('currencyExternal'), 'Currency'); assert.equal(getResilienceDimensionLabel('tradeSanctions'), 'Trade'); @@ -207,11 +207,32 @@ test('getResilienceDimensionLabel returns short stable labels for all 19 dimensi assert.equal(getResilienceDimensionLabel('importConcentration'), 'Imports'); assert.equal(getResilienceDimensionLabel('stateContinuity'), 'Continuity'); assert.equal(getResilienceDimensionLabel('fuelStockDays'), 'Fuel'); + // PR 2 §3.4 — new active dimensions. Retired reserveAdequacy's + // label stays ('Reserves'), and the live-data replacement + // disambiguates with 'Liquid Reserves'. + assert.equal(getResilienceDimensionLabel('liquidReserveAdequacy'), 'Liquid Reserves'); + assert.equal(getResilienceDimensionLabel('sovereignFiscalBuffer'), 'Sovereign Wealth'); // Unknown dimension IDs fall through to the raw ID so the render // never silently drops a row. assert.equal(getResilienceDimensionLabel('unknownDim'), 'unknownDim'); }); +// Every ID in RESILIENCE_DIMENSION_ORDER must have a display label — +// without this coverage the widget silently leaks raw internal IDs +// into the confidence grid for any new dimension that ships without +// a matching DIMENSION_LABELS entry (PR #3324 review-catch). +test('getResilienceDimensionLabel covers every dimension in RESILIENCE_DIMENSION_ORDER', async () => { + const { RESILIENCE_DIMENSION_ORDER } = await import('../server/worldmonitor/resilience/v1/_dimension-scorers.ts'); + const leaks: string[] = []; + for (const id of RESILIENCE_DIMENSION_ORDER) { + const label = getResilienceDimensionLabel(id); + if (label === id) leaks.push(id); + } + assert.deepEqual(leaks, [], + `DIMENSION_LABELS missing entries for: ${leaks.join(', ')}. ` + + `Every new dimension must land its user-facing short label in src/components/resilience-widget-utils.ts.`); +}); + test('formatDimensionConfidence classifies observed-heavy dimensions as observed', () => { const result = formatDimensionConfidence({ id: 'macroFiscal',