feat(resilience): PR 3 §3.5 point 3 — re-goalpost externalDebtCoverage (0..5 → 0..2)

Plan §2.1 diagnosis table showed externalDebtCoverage saturating at
score=100 across all 9 probe countries — including stressed states.
Signal was collapsed. Root cause: (worst=5, best=0) gave every country
with ratio < 0.5 a score above 90, and mapped Greenspan-Guidotti's
reserve-adequacy threshold (ratio=1.0) to score 80 — well into "no
worry" territory instead of the "mild warning" it should be.

Re-anchored on Greenspan-Guidotti directly: ratio=1.0 now maps to score
50 (mild warning), ratio=2.0 to score 0 (acute rollover-shock exposure).
Ratios above 2.0 clamp to 0, consistent with "beyond this point the
country is already in crisis; exact value stops mattering."

Files changed:

- _indicator-registry.ts: recoveryDebtToReserves goalposts
  {worst: 5, best: 0} → {worst: 2, best: 0}. Description updated to
  cite Greenspan-Guidotti; inline comment documents anchor + rationale.

- _dimension-scorers.ts: scoreExternalDebtCoverage normalizer bound
  changed from (0..5) to (0..2), with inline comment.

- docs/methodology/country-resilience-index.mdx: goalpost table row
  5-0 → 2-0, description cites Greenspan-Guidotti.

- docs/methodology/indicator-sources.yaml:
  * constructStatus: dead-signal → observed-mechanism (signal is now
    discriminating).
  * reviewNotes updated to describe the new anchor.
  * mechanismTestRationale names the Greenspan-Guidotti rule.

- tests/resilience-dimension-monotonicity.test.mts: updated the
  comment + picked values inside the (0..2) discriminating band (0.3
  and 1.5). Old values (1 vs 4) had 4 clamping to 0.

- tests/resilience-dimension-scorers.test.mts: NO score threshold
  relaxed >90 → >=85 (NO ratio=0.2 now scores 90, was 96).

- tests/resilience-scorers.test.mts: fixture drift:
  * domainAverages.recovery 54.83 → 47.33 (US extDebt 70 → 25).
  * baselineScore 63.63 → 60.12 (extDebt is baseline type).
  * overallScore 65.52 → 63.27.
  * stressScore / stressFactor unchanged (extDebt is baseline-only).

All 6324 data-tier tests pass. typecheck:api clean.
This commit is contained in:
Elie Habib
2026-04-22 22:15:16 +04:00
parent 1c8fae488c
commit 7f78a7561f
7 changed files with 41 additions and 29 deletions

View File

@@ -242,7 +242,7 @@ This domain forms the recovery-capacity pillar. It measures a country's ability
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence | | Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| recoveryDebtToReserves | Short-term external debt to reserves ratio (World Bank DT.DOD.DSTC.CD / FI.RES.TOTL.CD) | Lower is better | 5 - 0 | 1.00 | World Bank | Annual | | recoveryDebtToReserves | Short-term external debt to reserves ratio (World Bank DT.DOD.DSTC.CD / FI.RES.TOTL.CD); anchored on Greenspan-Guidotti reserve-adequacy rule | Lower is better | 2 - 0 | 1.00 | World Bank | Annual |
#### Import Concentration #### Import Concentration

View File

@@ -591,9 +591,9 @@
coveragePct: 0.75 coveragePct: 0.75
lastObservedYear: 2023 lastObservedYear: 2023
license: CC-BY-4.0 license: CC-BY-4.0
mechanismTestRationale: Short-term external debt to reserves ratio — rollover-shock exposure. mechanismTestRationale: Short-term external debt to reserves ratio — rollover-shock exposure. Goalpost anchored on Greenspan-Guidotti (ratio≥1 = reserve inadequacy).
constructStatus: dead-signal constructStatus: observed-mechanism
reviewNotes: Saturates at 100 for every country in the 9-country probe (goalpost 0-5 is too generous). PR 3 re-goalpost. reviewNotes: PR 3 §3.5 point 3 re-goalposted from (0..5) to (0..2). Old goalpost saturated at 100 across the 9-country probe including stressed states; new anchor maps ratio=1.0 to score 50 and ratio=2.0 to score 0.
- indicator: recoveryImportHhi - indicator: recoveryImportHhi
dimension: importConcentration dimension: importConcentration

View File

@@ -1479,8 +1479,12 @@ export async function scoreExternalDebtCoverage(
freshness: { lastObservedAtMs: 0, staleness: '' }, freshness: { lastObservedAtMs: 0, staleness: '' },
}; };
} }
// PR 3 §3.5 point 3: goalpost re-anchored on Greenspan-Guidotti.
// Ratio 1.0 (short-term debt matches reserves) = score 50; ratio 2.0
// = score 0 (acute rollover-shock exposure). See registry entry
// recoveryDebtToReserves for the construct rationale.
return weightedBlend([ return weightedBlend([
{ score: normalizeLowerBetter(entry.debtToReservesRatio, 0, 5), weight: 1.0 }, { score: normalizeLowerBetter(entry.debtToReservesRatio, 0, 2), weight: 1.0 },
]); ]);
} }

View File

@@ -925,9 +925,16 @@ export const INDICATOR_REGISTRY: IndicatorSpec[] = [
{ {
id: 'recoveryDebtToReserves', id: 'recoveryDebtToReserves',
dimension: 'externalDebtCoverage', dimension: 'externalDebtCoverage',
description: 'Short-term external debt to reserves ratio (World Bank DT.DOD.DSTC.CD / FI.RES.TOTL.CD); values above 1 signal reserve inadequacy for debt service', description: 'Short-term external debt to reserves ratio (World Bank DT.DOD.DSTC.CD / FI.RES.TOTL.CD); Greenspan-Guidotti rule treats ratio≥1 as reserve inadequacy, ratio≥2 as acute rollover-shock exposure',
direction: 'lowerBetter', direction: 'lowerBetter',
goalposts: { worst: 5, best: 0 }, // PR 3 §3.5 point 3: re-goalposted from (0..5) to (0..2). Old goalpost
// saturated at 100 across the full 9-country probe including stressed
// states. New anchor: ratio=1.0 (Greenspan-Guidotti reserve-adequacy
// threshold) maps to score 50; ratio=2.0 (double the threshold, acute
// distress) maps to 0. Ratios above 2.0 clamp to 0 — consistent with
// "beyond this point the precise value stops mattering, the country
// is already in a rollover-crisis regime."
goalposts: { worst: 2, best: 0 },
weight: 1.0, weight: 1.0,
sourceKey: 'resilience:recovery:external-debt:v1', sourceKey: 'resilience:recovery:external-debt:v1',
scope: 'global', scope: 'global',

View File

@@ -98,12 +98,12 @@ describe('resilience dimension monotonicity — scoreExternalDebtCoverage', () =
} }
it('higher debtToReservesRatio → lower score', async () => { it('higher debtToReservesRatio → lower score', async () => {
// NOTE: the current scorer saturates at 100 for ratio ≤ 0 (goalpost // PR 3 §3.5 point 3: goalpost is now lower-better worst=2 best=0
// lower-better, worst=5 best=0). Picking values inside the 0-5 band // (Greenspan-Guidotti anchor). Any ratio ≥ 2 clamps to 0, so pick
// to get a meaningful gradient. PR 3 §3.6 re-goalposts this. // values inside the discriminating band to get a meaningful gradient.
const good = await scoreWith(1); const good = await scoreWith(0.3);
const bad = await scoreWith(4); const bad = await scoreWith(1.5);
assert.ok(good.score > bad.score, `debtToReservesRatio 1→4 should lower score; got ${good.score}${bad.score}`); assert.ok(good.score > bad.score, `debtToReservesRatio 0.3→1.5 should lower score; got ${good.score}${bad.score}`);
}); });
}); });

View File

@@ -1133,8 +1133,9 @@ describe('resilience source-failure aggregation (T1.7)', () => {
}); });
it('scoreExternalDebtCoverage: low debt-to-reserves ratio scores well', async () => { it('scoreExternalDebtCoverage: low debt-to-reserves ratio scores well', async () => {
// PR 3 §3.5: goalpost tightened (5→2). NO ratio=0.2 → (2-0.2)/2 = 90.
const no = await scoreExternalDebtCoverage('NO', fixtureReader); const no = await scoreExternalDebtCoverage('NO', fixtureReader);
assert.ok(no.score > 90, `NO with ratio 0.2 should score >90, got ${no.score}`); assert.ok(no.score >= 85, `NO with ratio 0.2 should score >=85, got ${no.score}`);
}); });
it('scoreImportConcentration: low HHI scores well', async () => { it('scoreImportConcentration: low HHI scores well', async () => {

View File

@@ -96,16 +96,17 @@ describe('resilience scorer contracts', () => {
return [domainId, average]; return [domainId, average];
})); }));
// PR 3 §3.5: economic 68.33 → 66.33 after currencyExternal was rebuilt // PR 3 §3.5: economic 68.33 → 66.33 after currencyExternal rebuild.
// on IMF inflation + WB reserves (no BIS). US's currencyExternal score // Recovery 54.83 → 47.33 after externalDebtCoverage goalpost was
// shifts slightly vs the old BIS-composite path. // tightened from (0..5) to (0..2) per §3.5 point 3 (US ratio=1.5
// now scores 25 instead of 70).
assert.deepEqual(domainAverages, { assert.deepEqual(domainAverages, {
economic: 66.33, economic: 66.33,
infrastructure: 79, infrastructure: 79,
energy: 80, energy: 80,
'social-governance': 61.75, 'social-governance': 61.75,
'health-food': 60.5, 'health-food': 60.5,
recovery: 54.83, recovery: 47.33,
}); });
function round(v: number, d = 2) { return Number(v.toFixed(d)); } function round(v: number, d = 2) { return Number(v.toFixed(d)); }
@@ -133,11 +134,10 @@ describe('resilience scorer contracts', () => {
const stressScore = round(coverageWeightedMean(stressDims)); const stressScore = round(coverageWeightedMean(stressDims));
const stressFactor = round(Math.max(0, Math.min(1 - stressScore / 100, 0.5)), 4); const stressFactor = round(Math.max(0, Math.min(1 - stressScore / 100, 0.5)), 4);
// PR 3 §3.5: 62.64 → 63.63 after fuelStockDays retirement (coverage=0 // PR 3 §3.5: 62.64 → 63.63 (fuelStockDays retirement) → 60.12
// drops it from baselineDims coverage-weighted mean; the remaining // (externalDebtCoverage goalpost tightened; US score drops from 70
// baseline+mixed dims re-weight slightly higher). currencyExternal // to 25, pulling the coverage-weighted baseline mean down).
// rebuild is stress-only, so baselineScore is unaffected by that. assert.equal(baselineScore, 60.12);
assert.equal(baselineScore, 63.63);
// PR 3 §3.5: 65.84 → 67.85 (fuelStockDays retirement) → 67.21 // PR 3 §3.5: 65.84 → 67.85 (fuelStockDays retirement) → 67.21
// (currencyExternal rebuilt on IMF inflation + WB reserves, coverage // (currencyExternal rebuilt on IMF inflation + WB reserves, coverage
// shifts and US stress score moves). stressFactor updates in lockstep: // shifts and US stress score moves). stressFactor updates in lockstep:
@@ -156,9 +156,9 @@ describe('resilience scorer contracts', () => {
}).reduce((sum, v) => sum + v, 0), }).reduce((sum, v) => sum + v, 0),
); );
// PR 3 §3.5: 65.57 → 65.82 (fuelStockDays retirement) → 65.52 // PR 3 §3.5: 65.57 → 65.82 (fuelStockDays retirement) → 65.52
// (currencyExternal rebuild shifts economic domain coverage-weighted // (currencyExternal rebuild) → 63.27 (externalDebtCoverage goalpost
// score slightly, pulling overall down by 0.30). // tightened 0..5 → 0..2; US recovery-domain contribution drops).
assert.equal(overallScore, 65.52); assert.equal(overallScore, 63.27);
}); });
it('baselineScore is computed from baseline + mixed dimensions only', async () => { it('baselineScore is computed from baseline + mixed dimensions only', async () => {
@@ -229,9 +229,9 @@ describe('resilience scorer contracts', () => {
); );
assert.ok(expected > 0, 'overall should be positive'); assert.ok(expected > 0, 'overall should be positive');
// PR 3 §3.5: 65.82 (after fuelStockDays retirement) → 65.52 after // PR 3 §3.5: 65.82 → 65.52 (currencyExternal rebuild) → 63.27 after
// currencyExternal rebuilt on IMF inflation + WB reserves (no BIS). // externalDebtCoverage goalpost tightened from (0..5) to (0..2).
assert.equal(expected, 65.52, 'overallScore should match sum(domainScore * domainWeight); 65.82 → 65.52 after PR 3 §3.5 currencyExternal rebuild'); assert.equal(expected, 63.27, 'overallScore should match sum(domainScore * domainWeight); 65.52 → 63.27 after PR 3 §3.5 externalDebtCoverage re-goalpost');
}); });
it('stressFactor is still computed (informational) and clamped to [0, 0.5]', () => { it('stressFactor is still computed (informational) and clamped to [0, 0.5]', () => {