Skip to content

feat(resilience): PR 3A — net-imports denominator for sovereignFiscalBuffer#3380

Merged
koala73 merged 1 commit into
mainfrom
fix/resilience-pr3a-net-imports
Apr 24, 2026
Merged

feat(resilience): PR 3A — net-imports denominator for sovereignFiscalBuffer#3380
koala73 merged 1 commit into
mainfrom
fix/resilience-pr3a-net-imports

Conversation

@koala73

@koala73 koala73 commented Apr 24, 2026

Copy link
Copy Markdown
Owner

Summary

PR 3A of the cohort-audit plan (docs/plans/2026-04-24-002-fix-resilience-cohort-ranking-structural-audit-plan.md). The construct correction: the SWF rawMonths denominator was gross imports, which double-counted flow-through trade that never represents domestic consumption for re-export hub economies.

netImports = grossImports × (1 − reexportShareOfImports)
rawMonths  = aum / netImports × 12

Countries NOT in the re-export manifest get gross imports unchanged (status-quo fallback).

Cut from origin/main — not stacked. Required by PR 4 (which is currently drafted as #3376).

Plan acceptance gates — verified synthetically

Gate Status
Construct invariant: A (share=0.6) vs B (share=0) w/ same SWF + same gross imports → A's rawMonths is exactly 2.5× B's (= 1/(1−0.6)) ✅ pinned
SWF-heavy exporter cohort (share ≤ 5%) — rawMonths lift negligible (< 5%) ✅ pinned
Monotonicity: larger share → smaller denominator → larger rawMonths ✅ pinned
Re-export hub cohort — proportional lift with share ✅ pinned

What shipped

1. Re-export share manifest infrastructure

  • scripts/shared/reexport-share-manifest.yaml (new, empty) — schema committed. Candidates documented inline (HK, SG, NL, BE, PA, AE, MY, LT). Entries populated one-by-one in follow-up PRs with UNCTAD Handbook citations — one figure per PR so each is individually auditable.
  • scripts/shared/reexport-share-loader.mjs (new) — loader + strict validator mirroring swf-manifest-loader.mjs pattern. Fail-closed on any schema violation.
  • scripts/seed-recovery-reexport-share.mjs (new) — publishes resilience:recovery:reexport-share:v1 from the manifest. Empty manifest = valid (no countries = no adjustments).

2. SWF seeder uses net-imports denominator

  • scripts/seed-sovereign-wealth.mjs exports computeNetImports(gross, share) — pure helper, unit-tested.
  • Per-country loop reads manifest, computes denominatorImports, applies to rawMonths. Payload now records all three values (annualImports gross for audit, denominatorImports used in math, reexportShareOfImports for provenance).

3. Bundle wiring

  • Reexport-Share runs BEFORE Sovereign-Wealth in the recovery bundle so the SWF seeder reads fresh re-export data in the same cron tick.
  • tests/seed-bundle-resilience-recovery.test.mjs expected-entries bumped 6 → 7.

4. Cache-prefix bump (per cache-prefix-bump-propagation-scope skill)

Prefix Before After
RESILIENCE_SCORE_CACHE_PREFIX resilience:score:v11: resilience:score:v12:
RESILIENCE_RANKING_CACHE_KEY resilience:ranking:v11 resilience:ranking:v12
RESILIENCE_HISTORY_KEY_PREFIX resilience:history:v6: resilience:history:v7:

Sites bumped (source + 6 mirrors + 4 test files = 11 files):

  • Source of truth: server/worldmonitor/resilience/v1/_shared.ts
  • Mirrors: scripts/seed-resilience-scores.mjs, scripts/validate-resilience-correlation.mjs, scripts/backtest-resilience-outcomes.mjs, scripts/validate-resilience-backtest.mjs, scripts/benchmark-resilience-external.mjs, api/health.js
  • Tests: tests/resilience-ranking.test.mts (29 edits), tests/resilience-handlers.test.mts (4), tests/resilience-scores-seed.test.mjs (2), tests/resilience-pillar-aggregation.test.mts (1)

New parity test in tests/resilience-cache-keys-health-sync.test.mts — reads every declared mirror file, asserts (a) canonical prefix present AND (b) no stale v literals in non-comment code. Caught and fixed a legacy log-line in scripts/seed-resilience-scores.mjs that still referenced v9; refactored to use the RESILIENCE_RANKING_CACHE_KEY constant so future bumps self-update.

History v6→v7 is critical: per the skill's "history-trap" note, a 30-day rolling window that mixes v11 (gross-imports) and v12 (net-imports) scores manufactures false "falling" trends on deploy day. Rotation forces a clean window.

What this PR explicitly does NOT do

  • liquidReserveAdequacy denominator fix. The plan's PR 3A wording mentions both dims, but WB's FI.RES.TOTL.MO is a PRE-COMPUTED ratio; applying a post-hoc net-imports adjustment mixes WB's denominator year with our manifest year, and the math change belongs in PR 3B (unified liquidity) where α calibration is explicit.
  • Live re-export share entries. Manifest ships EMPTY; entries with UNCTAD citations are one-per-PR follow-ups so each figure is individually auditable.

Testing

  • tests/resilience-net-imports-denominator.test.mts9 pass (construct contract + cohort invariants)
  • tests/reexport-share-loader.test.mts7 pass (committed-manifest shape + 6 schema violations)
  • tests/resilience-cache-keys-health-sync.test.mts5 pass (existing 3 + 2 new mirror parity checks)
  • tests/seed-bundle-resilience-recovery.test.mjs17 pass (bumped 6 → 7)
  • npm run test:data6714 pass / 0 fail
  • npm run typecheck / typecheck:api — green
  • npm run lint / lint:md — clean

Post-Deploy Monitoring & Validation

What to monitor/search

  • Logs: Railway cron seed-bundle-resilience-recovery — specifically the new Reexport-Share section + the SWF seeder's "re-export manifest is empty" / "re-export adjustment applied to N country/countries" line.
  • Keys: resilience:recovery:reexport-share:v1 populated, resilience:recovery:sovereign-wealth:v1 contains denominatorImports + reexportShareOfImports fields per country, resilience:score:v12:* populating from cold, resilience:ranking:v12 present.
  • Metrics: /api/health must remain HEALTHY; resilienceRanking health check now watches v12.

Validation checks (queries/commands)

# After the next recovery-bundle cron tick:
redis-cli --url $UPSTASH_REDIS_REST_URL GET resilience:recovery:reexport-share:v1 \
  | jq '.manifestVersion, (.countries | keys)'
# Expected: manifestVersion=1, countries={} (empty on this PR's landing)

redis-cli --url $UPSTASH_REDIS_REST_URL GET resilience:recovery:sovereign-wealth:v1 \
  | jq '.countries | to_entries | .[0:2] | map({cc: .key, gross: .value.annualImports, denom: .value.denominatorImports, share: .value.reexportShareOfImports})'
# Expected: all countries show denom == gross (empty manifest = no adjustment)

# Score + ranking cache repopulation (5-15 minutes after deploy):
redis-cli --url $UPSTASH_REDIS_REST_URL EXISTS resilience:ranking:v12
redis-cli --url $UPSTASH_REDIS_REST_URL GET resilience:score:v12:US | jq '.overallScore'

Expected healthy behavior

  • SWF seeder's re-export adjustment log: "re-export manifest is empty; all countries use gross imports as the rawMonths denominator (status-quo behaviour)" (because no entries shipped in this PR).
  • All 6714 tests pass in CI.
  • /api/health shows resilienceRanking → HEALTHY on v12 within 1h of deploy.

Failure signal(s) / rollback trigger

  • Ranking stuck on v11 (not rebuilding on v12): investigate seed-resilience-scores cron + ranking cache.
  • SWF seeder crashes on manifest load: almost certainly a schema parse error — rollback the loader / manifest change.
  • /api/health CRIT on resilience:recovery:reexport-share: first-run gate; give it one cron cycle, then investigate.

Validation window & owner

  • Window: 24h after merge
  • Owner: Elie (@eliehabib)

Related


🤖 Generated with Claude Opus 4.7 (1M context) via Claude Code + Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.7 (1M context) [email protected]

…Buffer

PR 3A of cohort-audit plan 2026-04-24-002. Construct correction for
re-export hubs: the SWF rawMonths denominator was gross imports, which
double-counted flow-through trade that never represents domestic
consumption. Net-imports fix:

  rawMonths = aum / (grossImports × (1 − reexportShareOfImports)) × 12

applied to any country in the re-export share manifest. Countries NOT
in the manifest get gross imports unchanged (status-quo fallback).

Plan acceptance gates — verified synthetically in this PR:

  Construct invariant. Two synthetic countries, same SWF, same gross
  imports. A re-exports 60%; B re-exports 0%. Post-fix, A's rawMonths
  is 2.5× B's (1/(1-0.6) = 2.5). Pinned in
  tests/resilience-net-imports-denominator.test.mts.

  SWF-heavy exporter invariant. Country with share ≤ 5%: rawMonths
  lift < 5% vs baseline (negligible). Pinned.

What shipped

1. Re-export share manifest infrastructure.
   - scripts/shared/reexport-share-manifest.yaml (new, empty) — schema
     committed; entries populated in follow-up PRs with UNCTAD
     Handbook citations.
   - scripts/shared/reexport-share-loader.mjs (new) — loader + strict
     validator, mirrors swf-manifest-loader.mjs.
   - scripts/seed-recovery-reexport-share.mjs (new) — publishes
     resilience:recovery:reexport-share:v1 from manifest. Empty
     manifest = valid (no countries, no adjustment).

2. SWF seeder uses net-imports denominator.
   - scripts/seed-sovereign-wealth.mjs exports computeNetImports(gross,
     share) — pure helper, unit-tested.
   - Per-country loop: reads manifest, computes denominatorImports,
     applies to rawMonths math.
   - Payload records annualImports (gross, audit), denominatorImports
     (used in math), reexportShareOfImports (provenance).
   - Summary log reports which countries had a net-imports adjustment
     applied with source year.

3. Bundle wiring.
   - Reexport-Share runs BEFORE Sovereign-Wealth in the recovery
     bundle so the SWF seeder reads fresh re-export data in the same
     cron tick.
   - tests/seed-bundle-resilience-recovery.test.mjs expected-entries
     updated (6 → 7) with ordering preservation.

4. Cache-prefix bump (per cache-prefix-bump-propagation-scope skill).
   - RESILIENCE_SCORE_CACHE_PREFIX: v11 → v12
   - RESILIENCE_RANKING_CACHE_KEY: v11 → v12
   - RESILIENCE_HISTORY_KEY_PREFIX: v6 → v7 (history rotation prevents
     30-day rolling window from mixing pre/post-fix scores and
     manufacturing false "falling" trends on deploy day).
   - Source of truth: server/worldmonitor/resilience/v1/_shared.ts
   - Mirrored in: scripts/seed-resilience-scores.mjs,
     scripts/validate-resilience-correlation.mjs,
     scripts/backtest-resilience-outcomes.mjs,
     scripts/validate-resilience-backtest.mjs,
     scripts/benchmark-resilience-external.mjs, api/health.js
   - Test literals bumped in 4 test files (26 line edits).
   - EXTENDED tests/resilience-cache-keys-health-sync.test.mts with
     a parity pass that reads every known mirror file and asserts
     both (a) canonical prefix present AND (b) no stale v<older>
     literals in non-comment code. Found one legacy log-line that
     still referenced v9 (scripts/seed-resilience-scores.mjs:342)
     and refactored it to use the RESILIENCE_RANKING_CACHE_KEY
     constant so future bumps self-update.

Explicitly NOT in this PR

- liquidReserveAdequacy denominator fix. The plan's PR 3A wording
  mentions both dims, but the RESERVES ratio (WB FI.RES.TOTL.MO) is a
  PRE-COMPUTED WB series; applying a post-hoc net-imports adjustment
  mixes WB's denominator year with our manifest-year, and the math
  change belongs in PR 3B (unified liquidity) where the α calibration
  is explicit. This PR stays scoped to sovereignFiscalBuffer.
- Live re-export share entries. The manifest ships EMPTY in this PR;
  entries with UNCTAD citations are one-per-PR follow-ups so each
  figure is individually auditable.

Verified

- tests/resilience-net-imports-denominator.test.mts — 9 pass (construct
  contract: 2.5× ratio gate, monotonicity, boundary rejections,
  backward-compat on missing manifest entry, cohort-proportionality,
  SWF-heavy-exporter-unchanged)
- tests/reexport-share-loader.test.mts — 7 pass (committed-manifest
  shape + 6 schema-violation rejections)
- tests/resilience-cache-keys-health-sync.test.mts — 5 pass (existing 3
  + 2 new parity checks across all mirror files)
- tests/seed-bundle-resilience-recovery.test.mjs — 17 pass (expected
  entries bumped to 7)
- npm run test:data — 6714 pass / 0 fail
- npm run typecheck / typecheck:api — green
- npm run lint / lint:md — clean

Deployment notes

Score + ranking + history cache prefixes all bump in the same deploy.
Per established v10→v11 precedent (and the cache-prefix-bump-
propagation-scope skill):
- Score / ranking: 6h TTL — the new prefix populates via the Railway
  resilience-scores cron within one tick.
- History: 30d ring — the v7 ring starts empty; the first 30 days
  post-deploy lack baseline points, so trend / change30d will read as
  "no change" until v7 accumulates a window.
- Legacy v11 keys can be deleted from Redis at any time post-deploy
  (no reader references them). Leaving them in place costs storage
  but does no harm.
@vercel

vercel Bot commented Apr 24, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Apr 24, 2026 0:34am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces the net-imports denominator fix for the sovereignFiscalBuffer dimension: for re-export hub economies, rawMonths now uses grossImports × (1 − reexportShareOfImports) as the denominator instead of raw gross imports. The manifest ships empty (no countries adjusted yet); follow-up PRs will populate entries with UNCTAD citations. Cache prefixes are bumped to v12/v7 to flush stale gross-imports-denominated scores.

  • P1: assertZeroToOne in reexport-share-loader.mjs accepts reexportShareOfImports: 1.0 (upper-bound check is > 1), but computeNetImports throws on share >= 1. A future manifest entry with share=1.0 passes schema validation and then crashes the SWF seeder at runtime for that country. Tightening the loader's check to >= 1 closes the gap before any live entries are added.

Confidence Score: 4/5

Safe to merge with the P1 loader bound fix applied — manifest is empty so no country is affected today, but the inconsistency must be closed before the first live entry lands

One P1 finding: schema allows share=1.0 but the math function throws on it, so the first manifest entry with that value would crash the seeder in production. The remaining findings are P2 (stale test description strings, after() shim). Since the manifest ships empty this PR, the P1 cannot trigger on landing, but should be fixed before any follow-up entry PRs are merged.

scripts/shared/reexport-share-loader.mjs — assertZeroToOne upper-bound needs to be tightened to >= 1 before live manifest entries are added

Important Files Changed

Filename Overview
scripts/shared/reexport-share-loader.mjs New manifest loader with strict schema validation — but assertZeroToOne accepts share=1.0 while computeNetImports rejects it, creating a latent runtime crash path
scripts/seed-sovereign-wealth.mjs Adds net-imports denominator via computeNetImports; correctly falls back to gross imports for unlisted countries; exports function for unit tests; provenance audit fields added to payload
scripts/seed-recovery-reexport-share.mjs New seeder publishing manifest to Redis; correctly handles empty manifest as a no-op; validate() and declareRecords() are sound
scripts/shared/reexport-share-manifest.yaml New manifest ships intentionally empty with schema committed; candidate countries documented inline for follow-up PRs
scripts/seed-bundle-resilience-recovery.mjs Adds Reexport-Share step before Sovereign-Wealth; ordering and timeout are correct
server/worldmonitor/resilience/v1/_shared.ts Cache prefix/ranking/history bumped v11→v12 / v6→v7 consistently; rationale documented inline
tests/resilience-net-imports-denominator.test.mts New test file; pins 2.5× ratio acceptance gate, monotonicity, boundary validation, and cohort invariants from the plan
tests/reexport-share-loader.test.mts New schema-violation test suite; local after() shim routes cleanup via process.on('exit') instead of node:test harness; test validator omits year/rationale/sources field checks
tests/resilience-scores-seed.test.mjs Cache key assertions correctly bumped to v12, but both test description strings still read "(v11)"
api/health.js Single-line ranking key bump v11→v12; correct

Sequence Diagram

sequenceDiagram
    participant Cron as Railway Cron
    participant RS as seed-recovery-reexport-share
    participant YAML as reexport-share-manifest.yaml
    participant Redis as Redis
    participant SWF as seed-sovereign-wealth
    participant WB as World Bank API

    Cron->>RS: run Reexport-Share step
    RS->>YAML: loadReexportShareManifest()
    YAML-->>RS: manifest (empty on this PR)
    RS->>Redis: SET resilience:recovery:reexport-share:v1

    Cron->>SWF: run Sovereign-Wealth step
    SWF->>YAML: loadReexportShareByCountry()
    YAML-->>SWF: Map<ISO2, {share, year, sources}>
    SWF->>WB: fetchAnnualImportsUsd()
    WB-->>SWF: grossImports per country
    loop per country
        SWF->>SWF: computeNetImports(gross, share ?? 0)
        SWF->>SWF: rawMonths = aum / denominatorImports x 12
    end
    SWF->>Redis: SET resilience:recovery:sovereign-wealth:v1
    Note over SWF,Redis: payload includes annualImports (gross), denominatorImports (net), reexportShareOfImports
Loading

Reviews (1): Last reviewed commit: "feat(resilience): PR 3A — net-imports de..." | Re-trigger Greptile

Comment on lines +52 to +55
fail(`${path}: expected ISO-3166-1 alpha-2 country code, got ${JSON.stringify(value)}`);
}
}

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.

P1 Schema allows share = 1.0 but computeNetImports rejects it

assertZeroToOne accepts values in [0, 1] (the upper-bound check is value > 1, so 1.0 passes), but computeNetImports guards share >= 1 and throws. A manifest entry written with reexportShareOfImports: 1.0 would be validated and loaded silently, then crash the SWF seeder mid-run for that country. The fix is to tighten the upper bound here to exclude 1:

Suggested change
fail(`${path}: expected ISO-3166-1 alpha-2 country code, got ${JSON.stringify(value)}`);
}
}
function assertZeroToOne(value, path) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value >= 1) {
fail(`${path}: expected number in [0, 1), got ${JSON.stringify(value)}`);
}
}

Comment on lines 13 to +18
it('RESILIENCE_RANKING_CACHE_KEY matches server-side key (v11)', () => {
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v11');
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v12');
});

it('RESILIENCE_SCORE_CACHE_PREFIX matches server-side prefix (v11)', () => {
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v11:');
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v12:');

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 Stale version labels in test descriptions

Both test descriptions still reference (v11) while asserting v12 values. The resilience-pillar-aggregation.test.mts test similarly still reads 'RESILIENCE_SCORE_CACHE_PREFIX is v10' after two cache bumps. These don't affect correctness but make the test output misleading when a bump is missed.

Suggested change
it('RESILIENCE_RANKING_CACHE_KEY matches server-side key (v11)', () => {
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v11');
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v12');
});
it('RESILIENCE_SCORE_CACHE_PREFIX matches server-side prefix (v11)', () => {
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v11:');
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v12:');
it('RESILIENCE_RANKING_CACHE_KEY matches server-side key (v12)', () => {
assert.equal(RESILIENCE_RANKING_CACHE_KEY, 'resilience:ranking:v12');
});
it('RESILIENCE_SCORE_CACHE_PREFIX matches server-side prefix (v12)', () => {
assert.equal(RESILIENCE_SCORE_CACHE_PREFIX, 'resilience:score:v12:');
});

Comment on lines +170 to +173
`);
await assert.rejects(loadManifestFromPath(path), /last_reviewed: expected YYYY-MM-DD/);
});
});

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 Custom after() bypasses the node:test lifecycle

The local after() function routes cleanup via process.on('exit', fn) rather than the test runner's own after hook. process.on('exit') callbacks run synchronously during process teardown, which means they execute after the test reporter has already finished — temp files will appear to accumulate between test runs if the process exits uncleanly. Import after from node:test and remove the local shim:

import { describe, it, after } from 'node:test';

Then drop the bottom-of-file shim entirely.

koala73 added a commit that referenced this pull request Apr 24, 2026
…s + flag-count

Addresses P1 + 2 P2 Greptile findings on #3376 (draft).

1. **P1 — GIC tier contradiction.** GIC was listed as a canonical 0.7
   ("Explicit stabilization with rule") precedent AND rated 0.6 in
   the alignment table with an "arguably 0.7" note. That inconsistency
   makes the rubric unusable as-is for PR 4b review. Removed GIC from
   the 0.7 precedent list and explicitly marked it as a 0.7 *candidate*
   (pending PR 4b evaluation), not a 0.7 *precedent*. KIA General
   Reserve Fund stays as the canonical 0.7 example; Norway GPFG
   remains the borderline case for fiscal-rule caps.

2. **P2 — ADIA liquidity midpoint inconsistency.** Methodology text
   said the rubric uses "midpoint" for ranged disclosures and cited
   ADIA 55-70% → 0.7 tier. But midpoint(55-70) = 62.5%, which sits
   in the 0.5 tier band (50-65%). Fixed the methodology to state the
   rubric uses the **upper-bound** of a disclosed range (fund's own
   statement of maximum public-market allocation), which keeps ADIA
   at 0.7 tier (70% upper bound fits 65-85% band). Added forward-
   compatibility note: if future ADIA disclosures tighten the range
   so the upper bound drops below 65%, the rubric directs the rating
   to 0.5.

3. **P2 — Flag-count header.** "(6 of 24 coefficients)" was wrong;
   the enumeration below lists 8 coefficients across 5 funds.
   Corrected to "8 coefficients across 5 funds" with the fund-by-fund
   count inline so the header math is self-verifying.

Verified

- `npm run lint:md` — clean
- `npm run typecheck` — green (pure docs PR, no behaviour change)

This PR remains in draft pending #3380 (PR 3A — net-imports denominator)
merge per the plan's PR 4 → after PR 3A sequencing.
@koala73
koala73 merged commit 184e82c into main Apr 24, 2026
11 checks passed
@koala73
koala73 deleted the fix/resilience-pr3a-net-imports branch April 24, 2026 14:14
koala73 added a commit that referenced this pull request Apr 24, 2026
…ts, no manifest changes) (#3376)

* docs(resilience): PR 4a — SWF classification rubric (tiers + precedents, no manifest changes)

PR 4a of cohort-audit plan 2026-04-24-002. First half of the plan's PR 4
(full-manifest re-rate) split into:

- PR 4a (this): pure documentation — central rubric defining tiers
  + concrete precedents per axis. No manifest changes.
- PR 4b (deferred): apply the rubric to revise specific coefficients
  in `scripts/shared/swf-classification-manifest.yaml`. Behaviour-
  changing; belongs in a separate PR with cohort snapshots and
  methodology review.

This split addresses the plan's concern that PR 4 "may not be outcome-
predetermined" by separating the evaluative framework from its
application. PR 4a makes every current manifest value evaluable against
a benchmark; PR 4b applies the benchmark.

Shipped

- `docs/methodology/swf-classification-rubric.md` — new doc.
  Sections:
    1. Introduction + scope (rubric vs manifest boundary)
    2. Access axis: 5 named tiers (0.1, 0.3, 0.5, 0.7, 0.9) w/
       concrete precedents per tier, plus edge cases for
       fiscal-rule caps (Norway GPFG) and state holding
       companies (Temasek)
    3. Liquidity axis: 6 tiers (0.1, 0.3, 0.5, 0.7, 0.9, 1.0) w/
       precedents + listed-vs-directly-owned real-estate edge case
    4. Transparency axis: 6 tiers grounded in LM Transparency
       Index + IFSWF membership + annual-report granularity, plus
       edge cases for LM=10 w/o holdings-level disclosure and
       sealed filings (KIA)
    5. Current manifest × rubric alignment — 24 coefficients reviewed;
       6 flagged as "arguably higher/lower under the rubric" with
       directional-impact analysis marked INFORMATIONAL, not
       motivation for revision
    6. How-to-use playbook for manifest-edit PRs (add/revise/rubric-
       revise workflows)

Findings (informational only — no PR changes)

Six ratings flagged as potentially under-/over-stated against the
rubric. Per the plan's anti-pattern note (rank-targeted acceptance
criteria), the flags are INFORMATIONAL: a future manifest-edit PR
should revise only when the rubric + cited evidence support the
change, not to hit a target ranking.

Flagged (with directional impact if revised upward):

  - Mubadala access 0.4 → arguably 0.5; transparency 0.6 → 0.7
    (haircut 0.12 → 0.175, +46% access × transparency product)
  - PIF access 0.4 → arguably 0.5; liquidity 0.4 → arguably 0.3
    (net small effect — opposite directions partially cancel)
  - KIA transparency 0.4 → arguably 0.5 (haircut +25%)
  - QIA access 0.4 → arguably 0.5; transparency 0.4 → arguably 0.5
    (haircut +56%)
  - GIC access 0.6 → arguably 0.7 (haircut +17%)

Not flagged: GPFG, ADIA, Temasek (all 9 coefficients align with
their rubric tiers).

Verified

- `npm run test:data` — 6694 pass / 0 fail (unchanged — pure docs PR)
- `npm run typecheck` / `typecheck:api` — green
- `npm run lint:md` — clean

Not in this PR

- Manifest coefficient changes (PR 4b)
- Cohort-sanity snapshot before/after (PR 4b)
- Live-data audit of IFSWF engagement + LM index current values
  (requires web fetch — not in scope for a doc PR)

* fix(resilience): PR 4a review — resolve GIC/ADIA rubric contradictions + flag-count

Addresses P1 + 2 P2 Greptile findings on #3376 (draft).

1. **P1 — GIC tier contradiction.** GIC was listed as a canonical 0.7
   ("Explicit stabilization with rule") precedent AND rated 0.6 in
   the alignment table with an "arguably 0.7" note. That inconsistency
   makes the rubric unusable as-is for PR 4b review. Removed GIC from
   the 0.7 precedent list and explicitly marked it as a 0.7 *candidate*
   (pending PR 4b evaluation), not a 0.7 *precedent*. KIA General
   Reserve Fund stays as the canonical 0.7 example; Norway GPFG
   remains the borderline case for fiscal-rule caps.

2. **P2 — ADIA liquidity midpoint inconsistency.** Methodology text
   said the rubric uses "midpoint" for ranged disclosures and cited
   ADIA 55-70% → 0.7 tier. But midpoint(55-70) = 62.5%, which sits
   in the 0.5 tier band (50-65%). Fixed the methodology to state the
   rubric uses the **upper-bound** of a disclosed range (fund's own
   statement of maximum public-market allocation), which keeps ADIA
   at 0.7 tier (70% upper bound fits 65-85% band). Added forward-
   compatibility note: if future ADIA disclosures tighten the range
   so the upper bound drops below 65%, the rubric directs the rating
   to 0.5.

3. **P2 — Flag-count header.** "(6 of 24 coefficients)" was wrong;
   the enumeration below lists 8 coefficients across 5 funds.
   Corrected to "8 coefficients across 5 funds" with the fund-by-fund
   count inline so the header math is self-verifying.

Verified

- `npm run lint:md` — clean
- `npm run typecheck` — green (pure docs PR, no behaviour change)

This PR remains in draft pending #3380 (PR 3A — net-imports denominator)
merge per the plan's PR 4 → after PR 3A sequencing.
koala73 added a commit that referenced this pull request Apr 28, 2026
…+ net-imports parity (v17.1) (#3482)

* feat(resilience): plan 002 §U8 — methodology rewrite, doc-parity test, widget polish + net-imports parity for liquidReserveAdequacy (v17.1)

Closes the plan 2026-04-26-002 sequence. Five things land together:

1. **§U8.1 — net-imports denominator parity for liquidReserveAdequacy.**
   PR #3380 shipped `computeNetImports(grossImports, reexportShareOfImports)`
   for `sovereignFiscalBuffer` via the SWF seeder, sourced from
   `resilience:recovery:reexport-share:v1` (Comtrade-backed via PR #3385).
   The same correction was structurally needed on the sibling
   `liquidReserveAdequacy` dimension — re-export hubs (AE 35.5%, PA
   similar) consume WB `FI.RES.TOTL.MO` which is computed at WB source
   against gross imports, double-counting goods that flow through
   without settling as domestic consumption. v17.1 multiplies WB's
   pre-computed months by `1/(1−reexportShare)` for hub countries
   (algebraic inverse of dividing the denominator) — yields the same
   adjusted-months a custom `reserves/(net-imports/12)` calc would,
   without re-fetching raw `FI.RES.TOTL.CD` + `BM.GSR.GNFS.CD` series.
   Non-hub countries unchanged (status quo). Expected impact at next
   ranking refresh: AE liquidReserveAdequacy 38 → ~64 (a +26-point dim
   swing); PA similar magnitude.

2. **§U8 — methodology doc comprehensive v17 changelog.** Single
   coherent narrative covering the universe + coverage rebuild:
   source-comprehensiveness flag, coverage penalty multiplier,
   per-capita normalization with 0.5M tiny-state floor, headline-
   eligible gate, symmetric cache-hit gate filtering. Anchored to the
   live `resilience:ranking:v17` cohort numbers captured 2026-04-28
   post-#3477 merge: median(Nordics)=78.52 vs median(GCC)=70.53
   (+7.98pt gap), min(G7)=64.31 vs max(LIC)=53.73 (+10.58pt gap), 1
   microstate (MO at #4) in top-20. Documents 3 known open construct
   gaps honestly: economic-complexity / industrial-base indicator,
   importConcentration coverage gap on AE, cyberDigital transient
   zeros.

3. **§U8 — new doc-parity test (`tests/resilience-doc-parity.test.mts`).**
   Asserts the doc's prose claims match `_shared.ts` (cache prefix
   constants), `RESILIENCE_DOMAIN_ORDER` (6 domains), and
   `RESILIENCE_DIMENSION_ORDER` − `RESILIENCE_RETIRED_DIMENSIONS` (20
   active dimensions). Caught real drift: Redis keys table named v11
   prefixes despite live v17, and the headline "19 dimensions" claim
   was off-by-one after PR 2 §3.4 added liquidReserveAdequacy +
   sovereignFiscalBuffer. Fixed both as part of this commit.

4. **§U8 — widget polish.** `formatResilienceConfidence` now renders
   "Outside headline ranking" when `headlineEligible: false`, distinct
   from "Low confidence — sparse data" when `lowConfidence: true`. The
   two reasons are different and analysts should see them as different.
   Order matters: lowConfidence is more specific so it wins when both
   flags fire. New test pins the precedence.

5. **MDX-unsafe `<digit/letter` sweep + `lint:md`.** Both clean.

89 dimension-scorer tests + 36 widget tests + 5 doc-parity tests + 41
ranking + headline-gate tests all green (171 total, 0 fail).

* fix(resilience): demote v17.1 changelog H4 to bold-paragraph (methodology-lint compat)

CI methodology-lint test (T1.8) requires every H4 in
docs/methodology/country-resilience-index.mdx to map to a known
scorer dimension via HEADING_TO_DIMENSION — that's how the doc-vs-
registry parity gate works. The v17.1 changelog sub-section I added
(`#### v17.1 — Net-imports denominator parity for liquidReserveAdequacy
(U8.1)`) is a non-dimension subsection and tripped the linter.

Demoted to a bold lead-in paragraph (`**v17.1 — …**`). Same content,
same prose, just no H4. The lint comment explicitly anticipates this
case ("if a future edit adds a legitimate non-dimension H4, either
upgrade it to H3 or add an explicit allowlist") — bold-paragraph is
the cleanest of the three options and matches how Mintlify renders
sub-changelog notes elsewhere in the doc.

Local re-run: methodology-lint + doc-parity 10/10 pass; full test:data
suite 7606/7606 pass, 0 fail.

* fix(resilience): plan 002 §U8 review fixes (P1 cache bump v17→v18, P2 doc TTL/dim drift, P2 stale-count tightening)

Three review findings + a Greptile P2 perf note. All addressed locally.

P1 — cache invalidation missing for the §U8.1 scorer-formula change.
liquidReserveAdequacy now adjusts reserveMonths by re-export share for
AE/PA, but the prior commit kept score/ranking at v17 and history at
v12. The `_formula` tag is binary 'd6'|'pc' and does NOT detect intra-
'd6' scorer changes — without this bump, cached v17 AE/PA scores
(gross-imports-denominated) would continue to serve until TTL expiry,
defeating the construct fix this PR delivers. Bumped:
  - RESILIENCE_SCORE_CACHE_PREFIX     v17 → v18  (_shared.ts:164)
  - RESILIENCE_RANKING_CACHE_KEY      v17 → v18  (_shared.ts:235)
  - RESILIENCE_HISTORY_KEY_PREFIX     v12 → v13  (_shared.ts:217)
  - script mirrors:                   seed-resilience-scores.mjs,
                                      backtest, validate, benchmark
  - api/health.js:                    resilienceRanking key
History bumps in lockstep so the rolling 30-day window doesn't mix
pre/post-fix points and manufacture a false "improving" trend on day
one. Same pattern as PR 3A's v11→v12 lockstep for the SWF-side fix.

P2 — Redis keys table TTL + write-gate prose drift. Doc claimed:
  resilience:ranking:v17 | 6 hours | "only when all countries are scored"
Actual code: 12h TTL (RESILIENCE_RANKING_CACHE_TTL_SECONDS) and
publishes when coverage >= RANKING_CACHE_MIN_COVERAGE = 0.75. Updated
the table to reflect both. Same correction caught by the parity test
that flagged the v11/v17 drift in the prior commit.

P2 — stale "19 dimensions" claim still in the doc. The v2.0 scorecard
Data row at line 750 still said "19 dimensions across 6 domains"
because the parity test only required ONE correct mention. Tightened
the test to fail on any "<N> dimensions" mention in the
plausible-current-total band [15, 25] that doesn't equal activeCount
or totalCount — historical sub-counts (e.g. v1.0's "13 dimensions",
recovery pillar's "6 dimensions") are outside the band and stay
untouched. Updated the stale line to "20 active dimensions across 6
domains (plus 2 structurally-retired dimensions)".

Bonus — addressed a load of test-side cache-key literal duplications
(plan 002 §U8 follow-up):
  - tests/resilience-ranking.test.mts: 27 hardcoded
    'resilience:score:v17:XX' / 'resilience:history:v12:XX' literals
    swapped to ${RESILIENCE_SCORE_CACHE_PREFIX}/${RESILIENCE_HISTORY_KEY_PREFIX}
    template-literal references.
  - tests/resilience-handlers.test.mts: same.
  - tests/resilience-pillar-aggregation.test.mts: pinned-to-version
    assertion replaced with structural-shape regex.
  - tests/resilience-scores-seed.test.mjs: same.
This was caught by PR #3477 review for the ranking key; the score and
history prefixes had the same drift trap that resurfaced now. The
proper fix per the project skill (proto-required-field-add-needs-cache-
backfill Trap 5b) is to import the constants — never hardcode literals.

Greptile P2 (per-country Redis read for reexport-share map) — verified
benign: createMemoizedSeedReader caches by key, and _shared.ts:992's
`sharedReader` is shared across all 196 countries in the warm path
(getOrComputeResilienceScores), so reexport-share is fetched ONCE per
ranking compute, not 196 times. Single-country handler creates a fresh
memoized reader per request → 1 read per request. No fix needed.

Verification: 214/214 resilience tests pass, 7606/7606 npm test:data
pass, typecheck clean, lint:md clean.
fuleinist pushed a commit to fuleinist/worldmonitor that referenced this pull request May 9, 2026
…+ net-imports parity (v17.1) (koala73#3482)

* feat(resilience): plan 002 §U8 — methodology rewrite, doc-parity test, widget polish + net-imports parity for liquidReserveAdequacy (v17.1)

Closes the plan 2026-04-26-002 sequence. Five things land together:

1. **§U8.1 — net-imports denominator parity for liquidReserveAdequacy.**
   PR koala73#3380 shipped `computeNetImports(grossImports, reexportShareOfImports)`
   for `sovereignFiscalBuffer` via the SWF seeder, sourced from
   `resilience:recovery:reexport-share:v1` (Comtrade-backed via PR koala73#3385).
   The same correction was structurally needed on the sibling
   `liquidReserveAdequacy` dimension — re-export hubs (AE 35.5%, PA
   similar) consume WB `FI.RES.TOTL.MO` which is computed at WB source
   against gross imports, double-counting goods that flow through
   without settling as domestic consumption. v17.1 multiplies WB's
   pre-computed months by `1/(1−reexportShare)` for hub countries
   (algebraic inverse of dividing the denominator) — yields the same
   adjusted-months a custom `reserves/(net-imports/12)` calc would,
   without re-fetching raw `FI.RES.TOTL.CD` + `BM.GSR.GNFS.CD` series.
   Non-hub countries unchanged (status quo). Expected impact at next
   ranking refresh: AE liquidReserveAdequacy 38 → ~64 (a +26-point dim
   swing); PA similar magnitude.

2. **§U8 — methodology doc comprehensive v17 changelog.** Single
   coherent narrative covering the universe + coverage rebuild:
   source-comprehensiveness flag, coverage penalty multiplier,
   per-capita normalization with 0.5M tiny-state floor, headline-
   eligible gate, symmetric cache-hit gate filtering. Anchored to the
   live `resilience:ranking:v17` cohort numbers captured 2026-04-28
   post-koala73#3477 merge: median(Nordics)=78.52 vs median(GCC)=70.53
   (+7.98pt gap), min(G7)=64.31 vs max(LIC)=53.73 (+10.58pt gap), 1
   microstate (MO at #4) in top-20. Documents 3 known open construct
   gaps honestly: economic-complexity / industrial-base indicator,
   importConcentration coverage gap on AE, cyberDigital transient
   zeros.

3. **§U8 — new doc-parity test (`tests/resilience-doc-parity.test.mts`).**
   Asserts the doc's prose claims match `_shared.ts` (cache prefix
   constants), `RESILIENCE_DOMAIN_ORDER` (6 domains), and
   `RESILIENCE_DIMENSION_ORDER` − `RESILIENCE_RETIRED_DIMENSIONS` (20
   active dimensions). Caught real drift: Redis keys table named v11
   prefixes despite live v17, and the headline "19 dimensions" claim
   was off-by-one after PR 2 §3.4 added liquidReserveAdequacy +
   sovereignFiscalBuffer. Fixed both as part of this commit.

4. **§U8 — widget polish.** `formatResilienceConfidence` now renders
   "Outside headline ranking" when `headlineEligible: false`, distinct
   from "Low confidence — sparse data" when `lowConfidence: true`. The
   two reasons are different and analysts should see them as different.
   Order matters: lowConfidence is more specific so it wins when both
   flags fire. New test pins the precedence.

5. **MDX-unsafe `<digit/letter` sweep + `lint:md`.** Both clean.

89 dimension-scorer tests + 36 widget tests + 5 doc-parity tests + 41
ranking + headline-gate tests all green (171 total, 0 fail).

* fix(resilience): demote v17.1 changelog H4 to bold-paragraph (methodology-lint compat)

CI methodology-lint test (T1.8) requires every H4 in
docs/methodology/country-resilience-index.mdx to map to a known
scorer dimension via HEADING_TO_DIMENSION — that's how the doc-vs-
registry parity gate works. The v17.1 changelog sub-section I added
(`#### v17.1 — Net-imports denominator parity for liquidReserveAdequacy
(U8.1)`) is a non-dimension subsection and tripped the linter.

Demoted to a bold lead-in paragraph (`**v17.1 — …**`). Same content,
same prose, just no H4. The lint comment explicitly anticipates this
case ("if a future edit adds a legitimate non-dimension H4, either
upgrade it to H3 or add an explicit allowlist") — bold-paragraph is
the cleanest of the three options and matches how Mintlify renders
sub-changelog notes elsewhere in the doc.

Local re-run: methodology-lint + doc-parity 10/10 pass; full test:data
suite 7606/7606 pass, 0 fail.

* fix(resilience): plan 002 §U8 review fixes (P1 cache bump v17→v18, P2 doc TTL/dim drift, P2 stale-count tightening)

Three review findings + a Greptile P2 perf note. All addressed locally.

P1 — cache invalidation missing for the §U8.1 scorer-formula change.
liquidReserveAdequacy now adjusts reserveMonths by re-export share for
AE/PA, but the prior commit kept score/ranking at v17 and history at
v12. The `_formula` tag is binary 'd6'|'pc' and does NOT detect intra-
'd6' scorer changes — without this bump, cached v17 AE/PA scores
(gross-imports-denominated) would continue to serve until TTL expiry,
defeating the construct fix this PR delivers. Bumped:
  - RESILIENCE_SCORE_CACHE_PREFIX     v17 → v18  (_shared.ts:164)
  - RESILIENCE_RANKING_CACHE_KEY      v17 → v18  (_shared.ts:235)
  - RESILIENCE_HISTORY_KEY_PREFIX     v12 → v13  (_shared.ts:217)
  - script mirrors:                   seed-resilience-scores.mjs,
                                      backtest, validate, benchmark
  - api/health.js:                    resilienceRanking key
History bumps in lockstep so the rolling 30-day window doesn't mix
pre/post-fix points and manufacture a false "improving" trend on day
one. Same pattern as PR 3A's v11→v12 lockstep for the SWF-side fix.

P2 — Redis keys table TTL + write-gate prose drift. Doc claimed:
  resilience:ranking:v17 | 6 hours | "only when all countries are scored"
Actual code: 12h TTL (RESILIENCE_RANKING_CACHE_TTL_SECONDS) and
publishes when coverage >= RANKING_CACHE_MIN_COVERAGE = 0.75. Updated
the table to reflect both. Same correction caught by the parity test
that flagged the v11/v17 drift in the prior commit.

P2 — stale "19 dimensions" claim still in the doc. The v2.0 scorecard
Data row at line 750 still said "19 dimensions across 6 domains"
because the parity test only required ONE correct mention. Tightened
the test to fail on any "<N> dimensions" mention in the
plausible-current-total band [15, 25] that doesn't equal activeCount
or totalCount — historical sub-counts (e.g. v1.0's "13 dimensions",
recovery pillar's "6 dimensions") are outside the band and stay
untouched. Updated the stale line to "20 active dimensions across 6
domains (plus 2 structurally-retired dimensions)".

Bonus — addressed a load of test-side cache-key literal duplications
(plan 002 §U8 follow-up):
  - tests/resilience-ranking.test.mts: 27 hardcoded
    'resilience:score:v17:XX' / 'resilience:history:v12:XX' literals
    swapped to ${RESILIENCE_SCORE_CACHE_PREFIX}/${RESILIENCE_HISTORY_KEY_PREFIX}
    template-literal references.
  - tests/resilience-handlers.test.mts: same.
  - tests/resilience-pillar-aggregation.test.mts: pinned-to-version
    assertion replaced with structural-shape regex.
  - tests/resilience-scores-seed.test.mjs: same.
This was caught by PR koala73#3477 review for the ranking key; the score and
history prefixes had the same drift trap that resurfaced now. The
proper fix per the project skill (proto-required-field-add-needs-cache-
backfill Trap 5b) is to import the constants — never hardcode literals.

Greptile P2 (per-country Redis read for reexport-share map) — verified
benign: createMemoizedSeedReader caches by key, and _shared.ts:992's
`sharedReader` is shared across all 196 countries in the warm path
(getOrComputeResilienceScores), so reexport-share is fetched ONCE per
ranking compute, not 196 times. Single-country handler creates a fresh
memoized reader per request → 1 read per request. No fix needed.

Verification: 214/214 resilience tests pass, 7606/7606 npm test:data
pass, typecheck clean, lint:md clean.
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