feat(resilience): add debtSustainabilityGap indicator (IMF DSA construct)#3669
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryAdds
Confidence Score: 4/5The 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 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
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "feat(resilience): add debtSustainability..." | Re-trigger Greptile |
| 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); | ||
| }); |
There was a problem hiding this comment.
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).
| 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); | |
| }); |
There was a problem hiding this comment.
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.
| 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}`); |
There was a problem hiding this comment.
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.
| 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}`); |
There was a problem hiding this comment.
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).
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 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.
#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.
Summary
fiscalSpaceindicator —debtSustainabilityGap— capturing the IMF DSA constructgap = pb − ((r−g)/(1+g))·d. Positive = debt path declining, negative = rising.fiscalSpaceweights from0.4/0.3/0.3(3 indicators) to0.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
fiscalSpacedimension 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
ris derived from the algebraic identity (overall balance = primary balance − interest expense), sointerest %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)Architecture
scripts/seed-recovery-fiscal-space.mjsand stored asdebtSustainabilityGapPctper 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.latestCommonYear,computeDebtSustainabilityGap,buildFiscalSpaceCountries,validateFiscalSpaceare exported so tests skip ESM mocking.FISCAL_SPACE_VALIDATION_FLOORSandINFLATION_GAP_CAP_PCTexported as named constants.latestCommonYearacross 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.<150countries have fiscal-3 OR<100countries have gap inputs. Either floor failing → previous canonical blob keeps serving (_seed-utils.mjs:1010-1017strict-floor behavior).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)
worst=-5 / best=+3(DSA distress envelope)Test plan
npx tsc --noEmitcleannpx biome lintclean on touched files (8 modified + 3 new)tests/resilience-scorers.test.mtswith plan-002 → plan-2026-05-12 traceBacktest verdict (acceptance criteria)
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
mainbefore 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)
Post-merge / post-deploy
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 onresilience:recovery:fiscal-space:v1and assert ≥100 countries havedebtSustainabilityGapPct: number. Spot-check France/Japan/Norway against worked examples ±0.5 (tolerance accommodates IMF data refresh).scripts/compare-resilience-current-vs-proposed.mjsagainst 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/healthshows seeder freshness — useful, but not field-level coverage. Don't rely on health for field verification.Rollback
schemaVersiondrops 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.