Skip to content

feat(resilience): add debtSustainabilityGap indicator (IMF DSA construct)#3669

Merged
koala73 merged 3 commits into
mainfrom
feat/debt-sustainability-gap
May 12, 2026
Merged

feat(resilience): add debtSustainabilityGap indicator (IMF DSA construct)#3669
koala73 merged 3 commits into
mainfrom
feat/debt-sustainability-gap

Conversation

@koala73

@koala73 koala73 commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds a fourth fiscalSpace indicator — debtSustainabilityGap — capturing the IMF DSA construct gap = pb − ((r−g)/(1+g))·d. Positive = debt path declining, negative = rising.
  • Computed at seed time (not score time) from 5 already-seeded IMF WEO series; scorer just normalizes the precomputed field.
  • Rebalances fiscalSpace weights from 0.4/0.3/0.3 (3 indicators) to 0.25/0.20/0.20/0.35 (4 indicators). Gap becomes the largest single slice — the only indicator that integrates pb, r, g, d, and their interaction term.

Why

The current fiscalSpace dimension blends revenue / fiscal balance / debt level. None of those — alone or blended — actually answers "is this country's debt path sustainable?" A country can have high debt + strong primary surplus + g≈r and be fine (Japan-ish), or low debt + chronic primary deficit + r>g and be in trouble. The composite penalizes the level and rewards the surplus, then averages, losing the r-g interaction term. The gap is the standard IMF DSA construct used by Article IV missions, ECB MIP scoreboard, and S&P sovereign methodology.

Plan #2 of the fiscal-resilience initiative. Plan #1 (orphan-fiscal-data UI surface) shipped in PR #3668; together they close the loop on "data we ingest but don't act on."

Formula

g    = (1 + realGdpGrowth/100) × (1 + cpiInflation/100) − 1     compounded nominal growth
r    = max(0, (primaryBalance − fiscalBalance) / debtToGdp)     effective interest rate (DSA identity)
pb*  = ((r − g) / (1 + g)) × debtToGdp                          debt-stabilizing primary balance
gap  = primaryBalance − pb*                                      positive ⇒ debt declining

r is derived from the algebraic identity (overall balance = primary balance − interest expense), so interest %GDP = primaryBalance − fiscalBalance. Avoids needing per-country sovereign yields (FRED only covers US).

Worked examples (end-to-end, verified in tests/resilience-debt-sustainability-gap.test.mts)

Country 2024 d pb fb real g infl gap normalized score
France 110 −2.5 −4.5 0.7 2.1 −1.43 44.6
Japan 250 0 −3 0.5 2.0 +3.20 100 (clamps)
Italy 137 +1.0 −3.5 0.5 2.0 −0.04 62.0
Norway 38 +10 +8 1.0 2.5 +9.36 100 (clamps)
Argentina 90 −2 −5 −3.0 200 inflation > 25% cap excluded

Architecture

  • Seed-time computation: the gap is computed inside scripts/seed-recovery-fiscal-space.mjs and stored as debtSustainabilityGapPct per country in the canonical Redis blob (resilience:recovery:fiscal-space:v1). Scorer reads the precomputed field — matches every other resilience scorer's single-blob pattern.
  • Pure exported helpers: latestCommonYear, computeDebtSustainabilityGap, buildFiscalSpaceCountries, validateFiscalSpace are exported so tests skip ESM mocking. FISCAL_SPACE_VALIDATION_FLOORS and INFLATION_GAP_CAP_PCT exported as named constants.
  • Year alignment: gap inputs read at latestCommonYear across the 5 formula series only (revenue NOT in alignment check — it's not in the formula). Year-mismatched countries → gap=null; fiscal-3 still scores them.
  • Country inclusion: stays anchored to fiscal-3 union (revenue ∪ balance ∪ debt). Existing skip guard preserved verbatim — an IMF fiscal-series outage cannot emit phantom countries with null fiscal-3.
  • Two-floor validate: payload rejected if <150 countries have fiscal-3 OR <100 countries have gap inputs. Either floor failing → previous canonical blob keeps serving (_seed-utils.mjs:1010-1017 strict-floor behavior).
  • Tier classification: enrichment (not Core), because the inflation cap excludes ~5-10 hyperinflation countries by design — structurally cannot meet the Core ≥180 coverage invariant. Scorer doesn't differentiate by tier; this is a quality classification only.

Policy decisions (locked 2026-05-12)

  1. Goalposts: worst=-5 / best=+3 (DSA distress envelope)
  2. Inflation cap: 25% (excludes Argentina/Lebanon/Venezuela from gap; fiscal-3 still scores them)
  3. Weights: 0.25/0.20/0.20/0.35 (gap as largest single slice)
  4. Backtest acceptance: ≤10 countries shifting >±5 ranks in economy pillar

Test plan

  • npx tsc --noEmit clean
  • npx biome lint clean on touched files (8 modified + 3 new)
  • 750/750 resilience tests pass (full suite)
    • 12 formula unit tests (worked examples + edge cases)
    • 20 seeder contract tests (helpers + 6-country fixture + two-floor validate)
    • 7 scorer integration tests (5-country scenarios + null redistribution)
    • 89 dimension-scorer baseline tests (existing parity)
    • 36 indicator-registry + doc-parity tests
  • Production seeder ran end-to-end: 191 records, both floor checks passed
  • Backtest comparison: Spearman 0.9731, max country drift 9.63pt, cohort median 3.17pt (all within thresholds)
  • Baseline updates documented in tests/resilience-scorers.test.mts with plan-002 → plan-2026-05-12 trace

Backtest verdict (acceptance criteria)

Gate Status Result Threshold
Spearman rank correlation ✅ PASS 0.9731 floor 0.85
Max country drift ✅ PASS 9.63pt ceiling 15
Cohort median shift ✅ PASS worst 3.17pt ceiling 10
Universe integrity ✅ PASS 93 endpoints
Core indicator measurability ✅ PASS 47/51 floor 80%
Matched-pair gaps ⚠️ PRE-EXISTING 2 failures

Plan's stated acceptance criterion ("≤10 countries shifting >±5 ranks") is satisfied.

Known concern — matched-pair gate (pre-existing, not introduced by this PR)

Two matched pairs were ALREADY failing on main before this PR:

  • fr-vs-de: currentGap=−5.19 (failing) → proposedGap=−6.70 (amplified by 3.79pt)
  • sg-vs-ch: currentGap=−13.31 (failing) → proposedGap=−16.51 (amplified by 3.20pt)

My change amplifies the pre-existing miss but does not introduce a new directional failure. The amplification is structurally explainable: France's high-debt + mild-deficit profile takes a small additional gap-indicator penalty. Recommend treating as separate tech-debt follow-up — the matched-pair calibration predates this PR.

Post-Deploy Monitoring & Validation

Pre-merge (done)

  • All tests pass (750/750)
  • Local seeder dry-run successful (191 records)
  • Formula worked examples pinned in unit tests ±0.01 tolerance

Post-merge / post-deploy

  • Wait 1-2 seeder cron ticks (the cron interval is monthly per seed-bundle-resilience-recovery.mjs:5, so the honest verification window is 30-60 days after merge — first tick may hiccup, second is the backstop). Direct Redis GET on resilience:recovery:fiscal-space:v1 and assert ≥100 countries have debtSustainabilityGapPct: number. Spot-check France/Japan/Norway against worked examples ±0.5 (tolerance accommodates IMF data refresh).
  • Run scripts/compare-resilience-current-vs-proposed.mjs against pre/post Redis snapshots to confirm the expected directional shifts (Italy/Greece drop, Japan rises, Norway/Singapore rise, Argentina/Lebanon/Venezuela excluded from gap signal but fiscal-3 unchanged).
  • /api/health shows seeder freshness — useful, but not field-level coverage. Don't rely on health for field verification.

Rollback

  • Revert the PR. Seeder schemaVersion drops back to 1 on next tick. Old scorer reads the new blob fine — extra fields are ignored, the 3 fiscal-3 indicators score returns. Zero data loss; a few hours of degraded scoring between revert and next seeder tick.

Plan provenance

plans/add-debt-sustainability-gap-indicator.md — approved by Codex after 4 rounds of review on 2026-05-12. Implementation review by Codex on the actual diff also completed before merge. Plan-doc is included in this PR for design-rationale provenance.

Captures the IMF DSA construct gap = pb − ((r−g)/(1+g))·d. Positive =
debt path declining, negative = rising. The gap is the only indicator
that integrates pb, r, g, d, and their interaction term — the existing
fiscal-3 (revenue / fiscal balance / debt level) blend the state of
public finances but not the trajectory.

Architecture (single-blob, seed-time computation):

- scripts/seed-recovery-fiscal-space.mjs: pulls 3 additional WEO series
  (GGXONLB_NGDP, NGDP_RPCH, PCPIPCH) alongside the existing 3, computes
  gap per country at seed time, stores as debtSustainabilityGapPct in
  the canonical Redis blob. Schema v1 → v2 (additive — old scorer still
  reads new blob fine). Exports pure helpers (latestCommonYear,
  computeDebtSustainabilityGap, buildFiscalSpaceCountries,
  validateFiscalSpace) + named constants (FISCAL_SPACE_VALIDATION_FLOORS,
  INFLATION_GAP_CAP_PCT) so tests skip ESM mocking.
- scoreFiscalSpace: 4th sub-score wired at weight 0.35,
  normalizeHigherBetter(-5, 3). Existing 3 rebalanced to 0.25/0.20/0.20
  (sum = 1.0).
- _indicator-registry.ts: new entry, tier='enrichment' (the inflation
  cap excludes ~5-10 hyperinflation countries by design, structurally
  cannot meet Core ≥180 invariant; scorer doesn't differentiate by tier
  — quality classification only).
- compare-resilience-current-vs-proposed.mjs: one new EXTRACTION_RULES
  row matching the existing recovery-country-field shape.

Year alignment: gap inputs read at latestCommonYear across the 5
formula series (revenue NOT in alignment — it's not in the formula).
Year-mismatched countries get gap=null; fiscal-3 still scores them.

Two-failure-mode validation:
- System outage (<150 fiscal-3 OR <100 gap-inputs) → seeder rejects the
  whole payload; last canonical blob serves.
- Per-country gap unavailability (year misalignment, hyperinflation cap,
  partial growth/inflation) → that country emits gap=null; scorer's
  weightedBlend redistributes weight across the remaining 3.

Hyperinflation cap: CPI > 25% drops gap to null for Argentina/Lebanon/
Venezuela. The inflation-tax term would otherwise produce misleadingly
positive gaps that mask real fiscal pathology. Fiscal-3 still scores.

Policy decisions locked 2026-05-12: goalposts -5/+3 (DSA distress);
inflation cap 25%; weights 0.25/0.20/0.20/0.35; backtest acceptance
≤10 countries shifting >±5 ranks in economy pillar.

Tests: 750/750 resilience suite green. 12 formula unit tests + 20
seeder contract tests + 7 scorer integration tests + 89 dimension
baseline updates (recovery 48.75 → 49.63, overall 65.64 → 66.02 from
the rebalance + new sub-score on US fixture which now has gap inputs).

Production verification: seeder ran end-to-end against real IMF SDMX,
wrote 191 records, both validate floors passed.

Backtest acceptance:
- Spearman 0.9731 (floor 0.85) ✓
- Max country drift 9.63pt (ceiling 15) ✓
- Cohort median 3.17pt (ceiling 10) ✓
- Universe integrity 93 endpoints ✓
- Matched-pair gate: 2 pre-existing failures (fr-vs-de, sg-vs-ch)
  amplified by ~3pt — these pairs already failed on main before this
  PR; structural tech-debt follow-up, not introduced here.

Plan provenance: plans/add-debt-sustainability-gap-indicator.md
approved by Codex after 4 rounds; implementation reviewed by Codex on
the actual diff with APPROVED verdict.
@vercel

vercel Bot commented May 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 12, 2026 10:20am

Request Review

@greptile-apps

greptile-apps Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds debtSustainabilityGap as a fourth fiscalSpace indicator (IMF DSA construct: gap = pb − ((r−g)/(1+g))·d), computed at seed time from five aligned WEO series and stored in the existing canonical Redis blob. The scorer is updated to a 4-indicator blend (weights 0.25/0.20/0.20/0.35), with null-gap fallback handled by weightedBlend weight redistribution.

  • Seeder (seed-recovery-fiscal-space.mjs): fetches 3 new WEO series (GGXONLB_NGDP, NGDP_RPCH, PCPIPCH), exports pure helpers for direct testing, adds a two-floor validator (fiscal-3 ≥ 150, gap ≥ 100), and bumps schemaVersion to 2 — rollback-safe since the old scorer ignores unknown fields.
  • Scorer (_dimension-scorers.ts): debtSustainabilityGapPct added to the interface; scoreFiscalSpace now blends four indicators with the gap carrying the largest single weight (0.35) against goalposts −5/+3.
  • Tests (3 new files, 39+ new cases): formula unit tests, seeder contract tests, and scorer integration tests are comprehensive; two minor test-quality issues are noted inline.

Confidence Score: 4/5

The seeder, scorer, and registry changes are internally consistent and the rollback path is safe since the old scorer ignores unknown fields in the Redis blob.

The formula derivation is arithmetically correct (verified against all four worked examples), the two-floor validation prevents a data-outage from silently emitting a broken payload, and the null-gap weight redistribution works correctly through the existing weightedBlend mechanism. The only findings are in new test files: one assertion will start failing in 2027 when its hardcoded year rolls out of the weoYears() scan window, and the Italy tolerance is loose enough to miss meaningful formula drift. Neither touches production scoring logic.

tests/seed-recovery-fiscal-space.test.mts (hardcoded 2024 year in latestCommonYear suite) and tests/resilience-debt-sustainability-gap.test.mts (Italy ±0.5 tolerance).

Important Files Changed

Filename Overview
scripts/seed-recovery-fiscal-space.mjs Core seeder refactored to compute the IMF DSA gap at seed time; adds 3 new WEO series, exports pure helpers for testability, implements two-floor validation, and bumps schemaVersion to 2. Logic is correct and the skip-guard preserving existing fiscal-3 country inclusion is unchanged.
server/worldmonitor/resilience/v1/_dimension-scorers.ts Adds debtSustainabilityGapPct to the RecoveryFiscalSpaceCountry interface and updates scoreFiscalSpace to a 4-indicator blend (weights 0.25/0.20/0.20/0.35). The null-gap fallback through weightedBlend redistributes weight correctly over the remaining three indicators.
server/worldmonitor/resilience/v1/_indicator-registry.ts Registers debtSustainabilityGap as an enrichment-tier indicator (coverage ~150) and updates the three sibling weights from 0.4/0.3/0.3 to 0.25/0.20/0.20. Weight invariant (sum = 1.0) is preserved.
tests/resilience-debt-sustainability-gap.test.mts New formula unit tests pinning 5 worked examples and 7 edge cases. France/Japan/Norway/Argentina are tightly asserted (±0.01); the Italy test uses a ±0.5 tolerance that is ~50× looser and could mask a meaningful formula regression.
tests/seed-recovery-fiscal-space.test.mts New seeder contract tests for latestCommonYear, buildFiscalSpaceCountries, and validateFiscalSpace. The first latestCommonYear test hardcodes year keys '2024'/'2023' that will fall outside the weoYears() rolling window in 2027, causing a spurious failure.
tests/resilience-fiscal-space.test.mts New scorer integration tests covering Norway/Italy/Greece-shape profiles, null-gap weight redistribution, unmonitored imputation, ordering invariants, and explicit-null vs missing-field equivalence. Well-structured and uses dynamic year alignment.
tests/resilience-scorers.test.mts Baseline score assertions updated (48.75 → 49.63 domain average, 62.72 → 63.29 baseline, 65.64 → 66.02 overall) with clear plan-provenance comments. Deterministic re-pin with no logic changes.
tests/helpers/resilience-fixtures.mts Fixture expanded with gap-indicator fields for NO and US (full inputs with computed gaps), plus a YE entry deliberately omitting gap fields to exercise the null-fallback path.

Sequence Diagram

sequenceDiagram
    participant IMF as IMF WEO SDMX
    participant Seed as seed-recovery-fiscal-space.mjs
    participant Redis as Redis (canonical blob)
    participant Scorer as scoreFiscalSpace()
    participant Client as API caller

    Note over Seed: Cron tick
    Seed->>IMF: fetch GGR_NGDP (revenue)
    Seed->>IMF: fetch GGXCNL_NGDP (overall balance)
    Seed->>IMF: fetch GGXWDG_NGDP (debt)
    Seed->>IMF: fetch GGXONLB_NGDP (primary balance) [NEW]
    Seed->>IMF: fetch NGDP_RPCH (real growth) [NEW]
    Seed->>IMF: fetch PCPIPCH (CPI inflation) [NEW]
    Seed->>Seed: "buildFiscalSpaceCountries()<br/>latestCommonYear() aligns 5 inputs<br/>computeDebtSustainabilityGap() per country"
    Note over Seed: CPI > 25% gap=null
    Note over Seed: year mismatch gap=null
    Seed->>Seed: "validateFiscalSpace()<br/>fiscal-3 >= 150 AND gap >= 100"
    alt validation passes
        Seed->>Redis: "SETEX resilience:recovery:fiscal-space:v1 schemaVersion=2"
    else validation fails
        Note over Redis: previous canonical blob keeps serving
    end
    Client->>Scorer: scoreFiscalSpace(countryCode, reader)
    Scorer->>Redis: GET resilience:recovery:fiscal-space:v1
    Scorer->>Scorer: "weightedBlend revenue*0.25 balance*0.20 debt*0.20 gap*0.35"
    Scorer-->>Client: score + coverage + imputationClass
Loading

Reviews (1): Last reviewed commit: "feat(resilience): add debtSustainability..." | Re-trigger Greptile

Comment on lines +19 to +28
it('returns the most recent year present in every map', () => {
const y = latestCommonYear([
byYear(['2024', 110], ['2023', 105]),
byYear(['2024', -2.5], ['2023', -2.0]),
byYear(['2024', -4.5]),
byYear(['2024', 0.7]),
byYear(['2024', 2.1]),
]);
assert.equal(y, 2024);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The first latestCommonYear test hardcodes '2024' and '2023' as year keys. latestCommonYear calls weoYears() which returns a rolling 3-year window ([currentYear, currentYear-1, currentYear-2]). In 2026 the window is [2026, 2025, 2024], so 2024 is still reachable and the test passes. In 2027 the window shifts to [2027, 2026, 2025], 2024 falls out of scope, and latestCommonYear returns null — breaking the assertion. Every other test in this file correctly derives years from new Date().getFullYear() (see buildFiscalSpaceCountries suite at line 69).

Suggested change
it('returns the most recent year present in every map', () => {
const y = latestCommonYear([
byYear(['2024', 110], ['2023', 105]),
byYear(['2024', -2.5], ['2023', -2.0]),
byYear(['2024', -4.5]),
byYear(['2024', 0.7]),
byYear(['2024', 2.1]),
]);
assert.equal(y, 2024);
});
it('returns the most recent year present in every map', () => {
const currentYear = new Date().getFullYear();
const Y = String(currentYear);
const Y1 = String(currentYear - 1);
const y = latestCommonYear([
byYear([Y, 110], [Y1, 105]),
byYear([Y, -2.5], [Y1, -2.0]),
byYear([Y, -4.5]),
byYear([Y, 0.7]),
byYear([Y, 2.1]),
]);
assert.equal(y, currentYear);
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 9401d7031 — all 3 latestCommonYear tests now derive years from new Date().getFullYear(). Caught two additional silent-degradation cases (lines 50-58 and 60-63) while fixing the explicit time-bomb you flagged at line 28: those don't outright break in 2027 but they stop testing what they claim to test (returning null because years are out of scope rather than because there's no overlap / NaN). All 3 now exercise the intended branches in every future year.

Comment on lines +52 to +54
assert.ok(gap !== null, 'gap should be defined for Italy inputs');
// Plan example: gap ≈ -0.04 (right at the stabilizing point)
assert.ok(Math.abs(gap!) < 0.5, `expected gap near 0 for stabilizing Italy, got ${gap}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The Italy assertion uses a tolerance of < 0.5 — roughly 50× looser than every other worked example in this suite (< 0.01). The plan documentation pins the expected value at ≈ −0.04, so any formula regression that keeps the result inside (−0.5, +0.5) would still pass silently. The formula produces ≈ −0.035 for these inputs, so a tight pin is straightforward.

Suggested change
assert.ok(gap !== null, 'gap should be defined for Italy inputs');
// Plan example: gap ≈ -0.04 (right at the stabilizing point)
assert.ok(Math.abs(gap!) < 0.5, `expected gap near 0 for stabilizing Italy, got ${gap}`);
assert.ok(gap !== null, 'gap should be defined for Italy inputs');
// Plan example: gap ≈ -0.04 (right at the stabilizing point)
assert.ok(Math.abs(gap! - -0.04) < 0.01, `expected ≈ -0.04 for stabilizing Italy, got ${gap}`);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 9401d7031 — Italy tolerance tightened from < 0.5 to < -0.04, 0.01 matching the other 4 worked-example assertions. The wider tolerance would have silently passed any formula regression producing values in (-0.5, +0.5).

@mintlify

mintlify Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview May 12, 2026, 9:40 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Two test-only fixes addressing Greptile review. No production code touched.

1. Italy worked example: tighten tolerance 0.5 → 0.01.
   The previous tolerance was ~50× looser than every other worked
   example, and would have masked formula regressions producing values
   anywhere in (-0.5, +0.5). The formula produces ≈ -0.04 (right at the
   debt-stabilizing point); the tight pin matches the other 4 cases.

2. latestCommonYear suite: replace hardcoded '2024'/'2023' year keys
   with new Date().getFullYear() derivations across all 3 affected
   tests. weoYears() is a rolling 3-year window [current, -1, -2], so
   the hardcoded values fall out of scope starting in 2027. The first
   test would have outright broken; the other two would have silently
   degraded (still returning null but for the wrong reason — out of
   scope rather than the intended no-overlap or NaN branch).

Tests: 32/32 in the affected files pass.
Three suggestions from the PR review, all non-blocking but worth folding
in while iterating:

1. maxStaleMin 86400 → 129600 (60d → 90d, 2x → 3x monthly cron). Per the
   health-maxstalemin-write-cadence skill (rule: write_interval × 2-3),
   the current 60d sits at the project convention floor. 90d gives an
   extra missed-tick margin against month-2 cron hiccups. Mirrored in
   api/health.js:419 so the alarm threshold tracks the seeder's own
   declaration.

2. buildFiscalSpaceCountries: capture per-iso3 byYear dicts once instead
   of re-indexing on every value read. Negligible at N=190 (~5 fewer
   hash lookups per country) but the cleanup is honest — the second
   lookup was needless indirection after latestCommonYear had already
   pulled the same iso3 access.

Note: PR review framed item 1 as "86400 (24h) tight for monthly WEO" —
that was a unit misread (the field is minutes, so 86400 = 60d, not 24h).
Current value was actually loose, not tight. The bump to 3x is "extra
margin" not "tightening". Documented in the comments at both sites.

Tests: 128/128 across the affected test files. No behavioral change
for the scorer; just operational hygiene + readability.
@koala73
koala73 merged commit 674a307 into main May 12, 2026
12 checks passed
@koala73
koala73 deleted the feat/debt-sustainability-gap branch May 12, 2026 10:38
koala73 added a commit that referenced this pull request May 19, 2026
#3838)

Follow-up to PR #3669 motivated by post-deploy verification: with the
original 25% cap, Lebanon scored #1 globally on this indicator at 14.6%
CPI — the formula's nominal-growth term (18.6% nominal GDP growth in
Lebanon's 2026 forecast) mechanically erodes its 139% debt-to-GDP ratio,
producing a +25.8pt gap that masks the country's actual fiscal/banking
fragility.

The 25% cap was a guess (round number above Turkey's 28%). 10% has a
principled basis: above ~10% CPI, inflation expectations un-anchor and
the inflation-tax mechanism becomes part of a debt CRISIS rather than a
debt RESOLUTION. Post-WWII US/UK/France did successfully inflate away
war debts at 10-20% — but only with capital controls, financial
repression, deep domestic bond markets, and stable institutions. None
of those apply to Lebanon. The IMF DSA framework's own implicit scope
is sub-10% inflation regimes; this PR aligns our cap with that.

Impact:
- 15 countries currently with gap drop to null: Lebanon, Egypt, Nigeria,
  Kazakhstan, Bolivia, Ethiopia, Angola, Myanmar, Suriname, Haiti,
  Malawi, Burundi, South Sudan, Zambia, Kyrgyzstan.
- Coverage: 176 → 161 (still well above the 100-country validate floor).
- New best-5 (anticipated, will verify post-deploy): Norway, Greece,
  Oman, UAE, Cyprus — all defensible.
- New worst-5: Ukraine, Bahrain, Algeria, Botswana, Qatar — also
  defensible (wartime, oil-dependent, etc.).
- Fiscal-3 indicators still score every dropped country; only the
  fourth sub-score (gap) goes null. weightedBlend redistributes weight.

Diagnostic context (NOT addressed in this PR — separate concerns):
The same post-deploy analysis revealed two other failure modes that
this PR does NOT fix: small-economy noise (Timor-Leste gap=-50,
Kiribati gap=-16 — IMF data sparse for tiny economies amplifies into
extreme gap values) and low-debt instability (Brunei gap=-10.6 at
debt=1%). Those are arguably separate issues; this PR scope is just
the inflation-tax distortion (Lebanon-class), which is what users
would most obviously misinterpret.

Files:
- scripts/seed-recovery-fiscal-space.mjs: INFLATION_GAP_CAP_PCT 25 → 10,
  expanded comment with the rationale + WWII analog
- server/worldmonitor/resilience/v1/_indicator-registry.ts: updated
  description + tier comment; coverage 150 → 140 (realistic post-cap)
- docs/methodology/country-resilience-index.mdx: prose update
- docs/methodology/indicator-sources.yaml: reviewNotes update
- tests/seed-recovery-fiscal-space.test.mts: cap-pin assertion 25 → 10
  with provenance comment

Tests: 149/149 affected resilience tests pass.
Lint + tsc clean.

No change to: formula, goalposts, weights, validation floors, schemaVersion.
Backward-compatible — the next seeder run repopulates the canonical blob
with the new cap; old scorer code reads the new blob fine.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant