mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(resilience): PR 2 dimension wiring — split reserveAdequacy + add sovereignFiscalBuffer (#3324)
* feat(resilience): PR 2 dimension wiring — split reserveAdequacy + add sovereignFiscalBuffer Plan §3.4 follow-up to #3305 + #3319. Lands the scorer + dimension registration so the SWF seed from the Railway cron feeds a real score once the bake-in window closes. No weight rebalance yet (separate commit with Spearman sensitivity check), no health.js graduation yet (7-day ON_DEMAND window per feedback_health_required_key_needs_ railway_cron_first.md), no bootstrap wiring yet (follow-up PR). Shape of the change Retirement: - reserveAdequacy joins fuelStockDays in RESILIENCE_RETIRED_DIMENSIONS. The legacy scorer now mirrors scoreFuelStockDays: returns coverage=0 / imputationClass=null so the dimension is filtered out of the confidence / coverage averages via the registry filter in computeLowConfidence, computeOverallCoverage, and the widget's formatResilienceConfidence. Kept in RESILIENCE_DIMENSION_ORDER for structural continuity (tests, cached payload shape, registry membership). Indicator registry tier demoted to 'experimental'. Two new active dimensions: - liquidReserveAdequacy (replaces the liquid-reserves half of the retired reserveAdequacy). Same source (WB FI.RES.TOTL.MO, total reserves in months of imports) but re-anchored 1..12 months instead of 1..18. Twelve months ≈ 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. - sovereignFiscalBuffer. Reads resilience:recovery:sovereign-wealth:v1 (populated by scripts/seed-sovereign-wealth.mjs, landed in #3305 + wired into Railway cron in #3319). Computes the saturating transform: effectiveMonths = Σ [ aum/annualImports × 12 × access × liquidity × transparency ] score = 100 × (1 − exp(−effectiveMonths / 12)) Exponential saturation prevents Norway-type outliers (effective months in the 100s) from dominating the recovery pillar. Three code paths in scoreSovereignFiscalBuffer: 1. Seed key absent entirely → IMPUTE.recoverySovereignFiscalBuffer (score 50 / coverage 0.3 / unmonitored). Covers the Railway-cron bake-in window before the first successful tick. 2. Seed present, country NOT in manifest → score=0 with FULL coverage. Substantive absence, NOT imputation — per plan §3.4 "What happens to no-SWF countries." 0 × weight = 0 in the numerator, so the country correctly scores lower than SWF-holding peers on this dim. 3. Seed present, country in payload → saturating score, coverage derated by the partial-seed completeness signal (so a Mubadala or Temasek scrape drift on a multi-fund country shows up as lower confidence rather than a silently-understated total). Indicator registry: - Demoted recoveryReserveMonths (tied to retired reserveAdequacy) to tier='experimental'. - Added recoveryLiquidReserveMonths: WB FI.RES.TOTL.MO, anchors 1..12, tier='core', coverage=188. - Added recoverySovereignWealthEffectiveMonths: the new SWF signal, tier='experimental' for now because the manifest only has 8 funds (below the 180-core / 137-§3.6-gate threshold). Graduating to 'core' requires expanding the manifest past ~137 entries — a later PR. Tests updated - resilience-release-gate: 19→21 dim count; RETIRED_DIMENSIONS allow- list now includes reserveAdequacy alongside fuelStockDays. - resilience-dimension-scorers: scoreReserveAdequacy monotonicity + "high reserves score well" tests migrated to scoreLiquidReserve- Adequacy (same source, new 1..12 anchor). New retirement-shape test for scoreReserveAdequacy mirroring the PR 3 fuelStockDays retirement test. Four new scorer tests pin the three code paths of scoreSovereignFiscalBuffer (absent seed / no-SWF country / SWF country / partial-completeness derate). - resilience-scorers fixture: baseline 60.12→60.35, recovery-domain flat mean 47.33→48.75, overall 63.27→63.6. Each number commented with the driver (split adds liquidReserveAdequacy 18@1.0 + sovereign FiscalBuffer 50@0.3 at IMPUTE; retired reserveAdequacy drops out). - resilience-dimension-monotonicity: target scoreLiquidReserveAdequacy instead of scoreReserveAdequacy. - resilience-handlers: response-shape dim count 19→21. - resilience-indicator-registry: coverage 19→21 dimensions. - resilience-dimension-freshness: allowlisted the new sovereign-wealth seed-meta key in KNOWN_SEEDS_NOT_IN_HEALTH for the ON_DEMAND window. - resilience-methodology-lint HEADING_TO_DIMENSION: added the two new heading mappings. Methodology doc gets H4 sections for Liquid Reserve Adequacy and Sovereign Fiscal Buffer; Reserve Adequacy section is annotated as retired. - resilience-retired-dimensions-parity: client-side RESILIENCE_RETIRED_DIMENSION_IDS gets reserveAdequacy. Parser upgraded to strip inline `// …` comments from the array body so a future reviewer can drop a rationale next to an entry without breaking parity. - resilience-confidence-averaging: fixture updated to include both retired dims (reserveAdequacy + fuelStockDays) — confirms the registry filter correctly excludes BOTH from the visible coverage reading. Extraction harness (scripts/compare-resilience-current-vs-proposed.mjs): - recoveryLiquidReserveMonths: reads the same reserve-adequacy seed field as recoveryReserveMonths. - recoverySovereignWealthEffectiveMonths: reads the new SWF seed key on field totalEffectiveMonths. Absent-payload → 0 for correlation math (matches the substantive-no-SWF scorer branch). Out of scope for this commit (follow-ups) - Recovery-domain weight rebalance + Spearman sensitivity rerun against the PR 0 baseline. - health.js graduation (SEED_META entry + ON_DEMAND_KEYS removal) once Railway cron has ~7 days of clean runs. - api/bootstrap.js wiring once an RPC consumer needs the SWF data. - Manifest expansion past 137 countries so sovereignFiscalBuffer can graduate from tier='experimental' to tier='core'. Tests: 6573/6573 data-tier tests pass. Typecheck clean on both tsconfig configs. Biome clean on all touched files. * fix(resilience): PR 2 review — add widget labels for new dimensions P2 review finding on PR #3324. DIMENSION_LABELS in src/components/ resilience-widget-utils.ts covered only the old 19 dimension IDs, so the two new active dims (liquidReserveAdequacy, sovereignFiscalBuffer) would render with their raw internal IDs in the confidence grid for every country once the scorer started emitting them. The widget test at getResilienceDimensionLabel also asserted only the 19-label set, so the gap would have shipped silently. Fix: add user-facing short labels for both new dims. "Reserves" is already claimed by the retired reserveAdequacy, so the replacement disambiguates with "Liquid Reserves"; sovereignFiscalBuffer → "Sovereign Wealth" per the methodology doc H4 heading. Also added a regression guard — new test asserts EVERY id in RESILIENCE_DIMENSION_ORDER resolves to a non-id label. Any future dimension that ships without a matching DIMENSION_LABELS entry now fails CI loudly instead of leaking the ID into the UI. Tests: 502/502 resilience tests pass (+1 new coverage check). Typecheck clean on both configs. * fix(resilience): PR 2 review — remove dead IMPUTE.recoveryReserveAdequacy entry Greptile P2: the retired scoreReserveAdequacy stub no longer reads from IMPUTE (it hardcodes coverage=0 / imputationClass=null per the retirement pattern), making IMPUTE.recoveryReserveAdequacy dead code. Removed the entry + added a breadcrumb comment pointing at the replacement IMPUTE.recoveryLiquidReserveAdequacy. The second P2 (bootstrap.js not wired) is a deliberate non-goal — the reviewer explicitly flags "for visibility" since it's tracked in the PR body. No action this commit; bootstrap wiring lands alongside the SEED_META graduation after the ~7-day Railway-cron bake-in. Tests: 502/502 resilience tests still pass. Typecheck clean.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<string, ImputationEntry>;
|
||||
|
||||
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<ResilienceDimensionId, Resilie
|
||||
importConcentration: 'recovery',
|
||||
stateContinuity: 'recovery',
|
||||
fuelStockDays: 'recovery',
|
||||
liquidReserveAdequacy: 'recovery',
|
||||
sovereignFiscalBuffer: 'recovery',
|
||||
};
|
||||
|
||||
export const RESILIENCE_DIMENSION_ORDER: ResilienceDimensionId[] = [
|
||||
@@ -369,11 +398,13 @@ export const RESILIENCE_DIMENSION_ORDER: ResilienceDimensionId[] = [
|
||||
'healthPublicService',
|
||||
'foodWater',
|
||||
'fiscalSpace',
|
||||
'reserveAdequacy',
|
||||
'reserveAdequacy', // retired in PR 2 §3.4 — kept in order for structural continuity
|
||||
'externalDebtCoverage',
|
||||
'importConcentration',
|
||||
'stateContinuity',
|
||||
'fuelStockDays',
|
||||
'fuelStockDays', // retired in PR 3 §3.5
|
||||
'liquidReserveAdequacy', // new in PR 2 §3.4 — replaces reserveAdequacy
|
||||
'sovereignFiscalBuffer', // new in PR 2 §3.4 — SWF haircut dimension
|
||||
];
|
||||
|
||||
export const RESILIENCE_DOMAIN_ORDER: ResilienceDomainId[] = [
|
||||
@@ -407,6 +438,8 @@ export const RESILIENCE_DIMENSION_TYPES: Record<ResilienceDimensionId, Resilienc
|
||||
importConcentration: 'baseline',
|
||||
stateContinuity: 'baseline',
|
||||
fuelStockDays: 'mixed',
|
||||
liquidReserveAdequacy: 'baseline',
|
||||
sovereignFiscalBuffer: 'baseline',
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
@@ -1442,7 +1475,41 @@ export async function scoreFiscalSpace(
|
||||
]);
|
||||
}
|
||||
|
||||
// RETIRED in PR 2 §3.4. Superseded by `scoreLiquidReserveAdequacy` +
|
||||
// `scoreSovereignFiscalBuffer`. The split was the only honest treatment
|
||||
// of the construct: the previous dimension blended "central-bank reserves
|
||||
// in months of imports" with an implicit assumption that sovereign wealth
|
||||
// funds weren't state-deployable buffers, which systematically under-ranked
|
||||
// Norway / Gulf oil states / Singapore. The new two-dimension shape
|
||||
// separates the liquid-reserve signal from the SWF haircut signal.
|
||||
//
|
||||
// Shape mirrors scoreFuelStockDays (PR 3 §3.5 retirement):
|
||||
// coverage=0 + imputationClass=null so the confidence/coverage averages
|
||||
// filter it out via RESILIENCE_RETIRED_DIMENSIONS. Kept in the scorer
|
||||
// map for structural continuity; a future PR can remove the dimension
|
||||
// entirely once the cached response shape has bumped.
|
||||
export async function scoreReserveAdequacy(
|
||||
_countryCode: string,
|
||||
_reader: ResilienceSeedReader = defaultSeedReader,
|
||||
): Promise<ResilienceDimensionScore> {
|
||||
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<ResilienceDimensionScore> {
|
||||
@@ -1450,16 +1517,95 @@ export async function scoreReserveAdequacy(
|
||||
const entry = getRecoveryCountryEntry<RecoveryReserveAdequacyCountry>(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<string, RecoverySovereignWealthCountry>;
|
||||
}
|
||||
|
||||
export async function scoreSovereignFiscalBuffer(
|
||||
countryCode: string,
|
||||
reader: ResilienceSeedReader = defaultSeedReader,
|
||||
): Promise<ResilienceDimensionScore> {
|
||||
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<ResilienceDimensionId> = 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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string> = new Set([
|
||||
'fuelStockDays',
|
||||
'reserveAdequacy',
|
||||
]);
|
||||
|
||||
// Gated locked-preview fixture rendered when the resilience widget is
|
||||
@@ -248,6 +259,12 @@ const DIMENSION_LABELS: Record<string, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
|
||||
@@ -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<string, unknown>): 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}`);
|
||||
|
||||
@@ -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<unknown | null> => 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<unknown | null> => 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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -52,6 +52,8 @@ const HEADING_TO_DIMENSION: Readonly<Record<string, ResilienceDimensionId>> = {
|
||||
'Import Concentration': 'importConcentration',
|
||||
'State Continuity': 'stateContinuity',
|
||||
'Fuel Stock Days': 'fuelStockDays',
|
||||
'Liquid Reserve Adequacy': 'liquidReserveAdequacy',
|
||||
'Sovereign Fiscal Buffer': 'sovereignFiscalBuffer',
|
||||
};
|
||||
|
||||
function findMethodologyFile(): string {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -31,7 +31,11 @@ function parseClientRetiredIds(): Set<string> {
|
||||
'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)
|
||||
|
||||
@@ -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]', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user