Skip to content

fix(resilience): correct WB IDS indicator + switch BIS LBS → CBS dataflow (financialSystemExposure activation)#3412

Merged
koala73 merged 2 commits into
mainfrom
fix/fin-sys-exposure-seeders
Apr 25, 2026
Merged

fix(resilience): correct WB IDS indicator + switch BIS LBS → CBS dataflow (financialSystemExposure activation)#3412
koala73 merged 2 commits into
mainfrom
fix/fin-sys-exposure-seeders

Conversation

@koala73

@koala73 koala73 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

Summary

Activation-time follow-up to PR #3407 (financialSystemExposure dim). Running the 3 component seeders against production Redis surfaced two bugs in the Phase 2 design that the offline tests couldn't catch — fixing both here.

Fix #1 — WB IDS short-term external debt indicator

PR #3407 used DT.DOD.DSTC.IR.ZS × DT.DOD.DECT.GN.ZS / 100, intended to produce "short-term external debt as % of GNI." But DT.DOD.DSTC.IR.ZS is "% of total reserves", not "% of total external debt." The composed ratio was mathematically meaningless.

Smoking gun (production Redis audit, 2026-04-25):

Country OLD shortTermPctOfTotalDebt Why >100% is impossible
Argentina 164.36% short-term debt exceeds reserves
Turkey 114.52% same
Sri Lanka similar same

Fix: divide absolute USD values directly.

shortTermDebtPctGni = (DT.DOD.DSTC.CD / NY.GNP.MKTP.CD) × 100

Where DT.DOD.DSTC.CD = short-term external debt stocks (USD) and NY.GNP.MKTP.CD = GNI (USD).

Post-fix live verification (after running the corrected seeder against production):

Country New value
Brazil 4.0%
Argentina 7.77%
Turkey 13.26%
Sri Lanka 6.03%
Nigeria 9.05%

All values in the plausible 0-15% range matching real-world short-term-debt-to-GNI ratios.

Fix #2 — BIS LBS → BIS CBS dataflow

PR #3407 used WS_LBS_D_PUB (Locational Banking Statistics) on the assumption it publishes by-parent / by-counterparty cross-border claims. It does notWS_LBS_D_PUB only exposes counterparty as the aggregate 5J. Verified by direct API probe: an empty L_CP_COUNTRY position returns 200 OK with one series whose counterparty value is 5J (single-aggregate breakdown), not a per-country expansion.

The actual dataflow that publishes per-counterparty foreign claims by parent country is WS_CBS_PUB (Consolidated Banking Statistics).

CBS dimension shape (11 dims, discovered via probe)

Q.S.<L_REP_CTY>.4B.F.C.A.A.TO1.A.<L_CP_COUNTRY>
Pos Dim Value
1 FREQ Q
2 L_MEASURE S (stocks)
3 L_REP_CTY parent country (varied across 16 enumerated Western parents)
4 CBS_BANK_TYPE 4B (consolidated)
5 CBS_BASIS F (foreign claims, ultimate-risk basis)
6 L_POSITION C (claims)
7 L_INSTR A
8 REM_MATURITY A
9 CURR_TYPE_BOOK TO1
10 L_CP_SECTOR A
11 L_CP_COUNTRY counterparty (empty → all counterparties as separate series)

CBS uses L_REP_CTY to mean parent country (the bank's HQ jurisdiction). LBS has a separate L_PARENT_CTY dim that also exists but is only published as aggregate. Different dataflow, different semantics; the original draft conflated them.

Post-fix live verification (against production):

Country totalXborderPctGdp parentCount Notes
BR 19.6% 4 sweet spot
MX 27.0% 4 slight over-exposure
CN 3.1% 1 low integration (matches China's controlled banking)
TR 13.4% 5 sweet spot
AE 46.7% 10 over-exposed (UAE financial hub)
SG 233% 12 Iceland-2008 territory (Singapore hub)
CH 278% 10 Iceland territory (Swiss hub)

The U-shape correctly penalizes hub jurisdictions (SG, CH) at the over-exposure end while rewarding diversified parent-set coverage (high parentCount). The Component 2 + Component 4 trade-off is working as designed.

What's still pending for activation

The third component seeder, seed-fatf-listing.mjs, returns HTTP 403 from fatf-gafi.org (direct + Decodo proxy both rejected). Out of scope for this PR per user direction. Until FATF is fixed, the dim's preflight will throw on flag-flip even with WB + BIS healthy. Plan: separate follow-up PR for FATF using a different scrape strategy.

Files changed

  • scripts/seed-wb-external-debt.mjs — new indicators + combineExternalDebt({ shortTermDebtUsd, gniUsd }) direct USD ratio
  • scripts/seed-bis-lbs.mjs — full rewrite for CBS dimension shape (filename + Redis key retained for scorer-side contract continuity)
  • tests/seed-wb-external-debt.test.mjs — 8 fixtures rewritten around USD/GNI shape; new LK 2022-default ground-truth anchor
  • tests/seed-bis-lbs.test.mjs — SDMX-JSON fixtures updated to 11-dim CBS shape with CBS-specific dim values
  • docs/methodology/financial-system-exposure.md — Component 1 + 2 sections rewritten; "Common operational footguns" expanded with the LBS→CBS lesson and the WB DSTC.IR.ZS misnomer lesson

Test plan

  • npm run typecheck:api — clean
  • npm run lint + lint:md — clean
  • npm run test:data7189/7189 tests pass
  • Live production seed run — WB (190 records) + BIS CBS (201 records) both verified with realistic values
  • Sample-country audit on production Redis (BR/AR/TR/CN/SG/CH all show plausible values; no >100% intermediate ratios)

Post-Deploy Monitoring & Validation

  • What to monitor/search
    • seed-bundle-macro Railway service logs for WB-External-Debt, BIS-LBS labels — both should report state: OK on every cron tick
    • Vercel [bis-cbs] warnings (the seeder emits these only when 1-3 of 16 parent fetches fail; 4+ failures throws)
  • Validation checks
    • redis-cli GET 'seed-meta:economic:wb-external-debt' | jq '{fetchedAt, recordCount}' — recordCount ≥ 80 (LMICs floor)
    • redis-cli GET 'seed-meta:economic:bis-lbs' | jq '{fetchedAt, recordCount}' — recordCount ≥ 150 (CBS floor)
    • redis-cli GET 'economic:wb-external-debt:v1' | jq '.data.countries.AR.value' — should be 0-15 range, NOT >100
    • redis-cli GET 'economic:bis-lbs:v1' | jq '.data.countries.SG.totalXborderPctGdp' — should be ~200%+ (hub jurisdiction)
  • Expected healthy behavior
    • Both seeders run cleanly on the macro-bundle cron (WB ~5s; BIS CBS ~3-10s)
    • No >100% intermediate ratios in the WB output
    • SG / CH show high totalXborderPctGdp + high parentCount (the U-shape design)
  • Failure signal / rollback trigger
  • Validation window & owner
    • Window: first 24h after merge
    • Owner: Elie

Activation runbook (after this merges)

The same runbook from PR #3407 applies, with the seeders now actually working:

  1. Merge this PR (no second seed run needed — the production Redis already has the fixed payloads from this PR's verification step)
  2. Resolve the FATF seeder bug in a follow-up PR
  3. Once all 3 envelopes are populated, set RESILIENCE_FIN_SYS_EXPOSURE_ENABLED=true in Vercel
  4. Cohort audit + sanity-check anchors (RU/IR/KP < 20)
  5. Remove the FLAG_GATED_DARK_DIMENSIONS allow-list entry in tests/resilience-release-gate.test.mts

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via Claude Code + Compound Engineering v3.0.0

…flow

Two activation-time bugs caught when running the financialSystemExposure
seeders against production Redis (PR #3407 follow-up).

== Fix #1 — WB IDS short-term external debt indicator ==

Original code used `DT.DOD.DSTC.IR.ZS × DT.DOD.DECT.GN.ZS / 100`,
intended to compose "short-term external debt as % of GNI." But
`DT.DOD.DSTC.IR.ZS` is "% of total RESERVES" (NOT "% of total
external debt"). Argentina (164%), Turkey (114%), and Sri Lanka all
had intermediate ratios > 100% because their short-term debt exceeds
reserves — caught by Redis audit on the first live seed run.

Fix: divide absolute USD values directly.
  shortTermDebtPctGni = (DT.DOD.DSTC.CD / NY.GNP.MKTP.CD) × 100

Live verification: BR=4%, AR=7.77%, TR=13.26%, LK=6.03%, NG=9.05% —
all in plausible 0-15% range matching real-world short-term debt ratios.

== Fix #2 — BIS LBS → BIS CBS dataflow ==

Plan called for "BIS LBS by-parent" data, but `WS_LBS_D_PUB` only
exposes counterparty as the aggregate `5J`. Per-counterparty
breakdowns require `WS_CBS_PUB` (Consolidated Banking Statistics).

CBS: 11 dimensions (LBS had 12), parent is L_REP_CTY, CBS_BASIS=F
selects foreign-claims ultimate-risk basis. SDMX key:
  Q.S.<PARENT>.4B.F.C.A.A.TO1.A.

Live verification: 201 records, all 16 parents fetched, BR=19.6%,
CN=3.1%, AE=46.7%, SG=233%, CH=278% — values match real-world bank
exposure including U-shape penalty for hub jurisdictions.

Filename + Redis key (`economic:bis-lbs:v1`) retained for historical
continuity; scorer-side contract unchanged.

What's pending for full activation: FATF seeder still blocked by
HTTP 403 on fatf-gafi.org. Separate PR.

Quality gates:
  - typecheck:api / lint / lint:md: clean
  - npm run test:data: 7189/7189 pass

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
@vercel

vercel Bot commented Apr 25, 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 Preview Apr 25, 2026 8:06pm

Request Review

@mintlify

mintlify Bot commented Apr 25, 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 Apr 25, 2026, 7:39 PM

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

@greptile-apps

greptile-apps Bot commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR corrects two post-activation bugs in the financialSystemExposure dimension: (1) the WB IDS short-term debt formula now uses absolute USD values (DT.DOD.DSTC.CD / NY.GNP.MKTP.CD × 100) instead of the invalid ratio that produced >100% intermediates for Argentina and Turkey; (2) the BIS seeder switches from WS_LBS_D_PUB (which only exposes counterparty as the aggregate 5J) to WS_CBS_PUB, the correct dataflow for per-counterparty foreign claims. Both fixes are well-documented and backed by live production verification data.

Confidence Score: 4/5

Safe to merge — both formula and dataflow fixes are verified in production; only P2-level findings remain.

All findings are P2 (style/quality). The null-as-zero issue in fetchWbIndicator is pre-existing across the codebase and partially mitigated by the mrv=5 window and the gni.value <= 0 guard. The backwards-compat alias and informational BIS_AGGREGATE_CODES are cosmetic. No logic errors introduced in this PR.

scripts/seed-wb-external-debt.mjs — null WB API value handling in fetchWbIndicator; scripts/seed-bis-lbs.mjs — backwards-compat alias and unreachable aggregate-code set.

Important Files Changed

Filename Overview
scripts/seed-wb-external-debt.mjs Correctly switches from the bogus DT.DOD.DSTC.IR.ZS × DT.DOD.DECT.GN.ZS product to a direct USD ratio (DT.DOD.DSTC.CD / NY.GNP.MKTP.CD). New combineExternalDebt properly guards against zero GNI, but fetchWbIndicator silently converts WB null responses to 0 for the debt indicator.
scripts/seed-bis-lbs.mjs Full rewrite from WS_LBS_D_PUB to WS_CBS_PUB with correct 11-dim key shape; bounded-concurrency runner preserved; aggregate-code filtering upgraded with regex guard; backwards-compat alias leaks old LBS naming into public export surface.
tests/seed-wb-external-debt.test.mjs 8 fixtures rewired to USD/GNI input shape; new LK 2022 anchor (37.5%) pins scorer passthrough of raw values above the IMF 15% threshold. New zero-GNI drop test added.
tests/seed-bis-lbs.test.mjs Fixtures updated to 11-dim CBS shape; coord strings changed from 12-segment to 11-segment; CBS-specific dimension IDs correctly replace old LBS dims. All tests confirm aggregate filtering and upper-bound guard behaviour.
docs/methodology/financial-system-exposure.md Documentation accurately updated: WS_CBS_PUB key shape, corrected composition formula, expanded footguns section with LBS→CBS and DSTC.IR.ZS lessons. Redis key continuity caveat noted inline.

Sequence Diagram

sequenceDiagram
    participant Seeder as seed-wb-external-debt.mjs
    participant WB as World Bank API
    participant Combine as combineExternalDebt()
    participant Redis

    Seeder->>WB: GET DT.DOD.DSTC.CD (short-term debt USD, mrv=5)
    WB-->>Seeder: {iso2: {value, year}}
    Seeder->>WB: GET NY.GNP.MKTP.CD (GNI USD, mrv=5)
    WB-->>Seeder: {iso2: {value, year}}
    Seeder->>Combine: {shortTermDebtUsd, gniUsd}
    Combine-->>Seeder: countries {value=(debt/GNI)x100, yearMismatch}
    Seeder->>Redis: SET economic:wb-external-debt:v1

    participant BIS_Seeder as seed-bis-lbs.mjs
    participant BIS as BIS WS_CBS_PUB API
    participant GDP as World Bank GDP
    participant CombineCBS as combineCbsByCounterparty()

    loop 16 parents (concurrency=4)
        BIS_Seeder->>BIS: GET Q.S.PARENT.4B.F.C.A.A.TO1.A.
        BIS-->>BIS_Seeder: SDMX-JSON {byCounterparty, latestPeriod}
    end
    BIS_Seeder->>GDP: GET NY.GDP.MKTP.CD (all countries, mrv=3)
    GDP-->>BIS_Seeder: {iso2: {value, year}}
    BIS_Seeder->>CombineCBS: perParent + gdpByCountry
    CombineCBS-->>BIS_Seeder: countries {totalXborderPctGdp, parentCount, parents}
    BIS_Seeder->>Redis: SET economic:bis-lbs:v1
Loading

Comments Outside Diff (1)

  1. scripts/seed-wb-external-debt.mjs, line 69-71 (link)

    P2 Null WB API values silently become 0 for the debt indicator

    Number(null) === 0 in JavaScript, and Number.isFinite(0) === true, so records where the WB API returns null for DT.DOD.DSTC.CD (no IDS coverage in the mrv=5 window) get stored as { value: 0, year: <year> }. In combineExternalDebt, the debt.value < 0 guard only rejects negatives — 0 passes through and produces a false "0% of GNI" reading instead of excluding the country. The GNI indicator is protected by the gni.value <= 0 guard, but debt has no equivalent guard for zero.

Reviews (1): Last reviewed commit: "fix(resilience): correct WB indicator + ..." | Re-trigger Greptile

Comment thread scripts/seed-bis-lbs.mjs Outdated
Comment on lines 77 to 83
const BIS_AGGREGATE_CODES = new Set([
'5J', '5A', '5M', '1C', '4F', '4U', '5C', // common aggregates
'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9', // grouped aggregates
'5R', '5T', '5W', '5Z', // EM/AE/world groupings
'5J', '5A', '5M', '5C', '5R', '5T', '5W', '5Z',
'1C', '1E', '1W',
'2Z', '3P',
'4F', '4U',
'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'A9',
]);

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 BIS_AGGREGATE_CODES set is unreachable by the guarding filter chain

The filter at line 136 is: cpCode.length !== 2 || !/^[A-Z]{2}$/.test(cpCode) || BIS_AGGREGATE_CODES.has(cpCode). Every entry in BIS_AGGREGATE_CODES contains at least one digit (5J, 1C, A2, 4F, etc.), so all of them already fail the /^[A-Z]{2}$/ test and are rejected before BIS_AGGREGATE_CODES.has() is ever evaluated. The comment says "informational only," which is accurate, but the set cannot catch a BIS aggregate that is two all-alpha letters (e.g., a regional aggregate like EU) — the set would need an entry AND those codes would have to be 2-letter purely alphabetic. Worth verifying during the next CBS codelist audit whether any 2-letter all-alpha aggregate codes exist.

Comment thread scripts/seed-bis-lbs.mjs Outdated
Comment on lines +330 to +332
export { CANONICAL_KEY, CACHE_TTL, PARENT_COUNTRIES };
// Backwards-compat alias for tests that imported the original LBS name.
export { combineCbsByCounterparty as combineLbsByCounterparty };

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 Backwards-compat alias leaks LBS naming into the public export surface

export { combineCbsByCounterparty as combineLbsByCounterparty } makes the old name a first-class export of the module. If any production code imports combineLbsByCounterparty by name, it would silently receive CBS data while the name implies LBS semantics. The test file is the only currently known consumer, but the alias is visible to any future import. Consider documenting clearly in the export that this is a test-only alias to be removed once the test imports are updated.

Three fixes on top of the BIS LBS → CBS migration.

== Greptile #1 — BIS_AGGREGATE_CODES Set was dead code ==

The filter chain was:
  cpCode.length !== 2 || !/^[A-Z]{2}$/.test(cpCode) || BIS_AGGREGATE_CODES.has(cpCode)

Every entry in BIS_AGGREGATE_CODES had a digit (5J, 1C, A2, 4F, ...),
so all of them already failed the `/^[A-Z]{2}$/` regex BEFORE the Set
was consulted. The Set never caught anything in production.

Real risk the Set was supposed to mitigate: BIS introduces a 2-letter
ALL-ALPHA aggregate (e.g. `EU`) that the regex would pass. Replaced
with `ALPHA_AGGREGATE_CODES` — empty by default, audited each CBS
quarterly publish. Verified against the live `WS_CBS_PUB`
L_CP_COUNTRY codelist (252 values as of 2026-04-25): no current
2-letter all-alpha aggregates exist.

== Greptile #2 — combineLbsByCounterparty alias removed ==

`export { combineCbsByCounterparty as combineLbsByCounterparty }` made
the old LBS name a first-class export of the module. Any future code
importing `combineLbsByCounterparty` would silently get CBS data while
the name implied LBS semantics. Only the test file consumed it.

Removed the alias, renamed test imports to `combineCbsByCounterparty`.

== Self-noted finding — hub-country self-counting in Component 4 ==

Caught during the 2026-04-25 production activation audit. For
Singapore and Switzerland (both in PARENT_COUNTRIES AND on the
counterparty list), the BIS CBS payload included their own domestic
banking claims:
  SG-on-SG:  $584B
  CH-on-CH:  $2.2T

These are domestic loan books, NOT foreign-bank cross-border exposure.
Component 4 (`parentCount`) is supposed to measure "how many INDEPENDENT
FOREIGN USD-clearing routes remain if a major counterparty pulls" —
domestic banks aren't a foreign-fallback route.

Filter: drop entries where `cp === parent` when building
claimsByCpByParent. Live verification of the impact:

  Country  OLD pct      NEW pct      OLD parentCount  NEW parentCount
  SG       232.98%      126.11%      12               11
  CH       277.89%       43.69%      10                9
  US        ...          32.2%       (US-on-US excluded)
  GB        ...          65.96%      (GB-on-GB excluded)
  BR        19.62%       19.62%      4                 4 (unchanged; BR not in PARENT_COUNTRIES)
  AE        46.69%       46.69%      10               10 (unchanged; AE not in PARENT_COUNTRIES)

CH dropped from "Iceland-2008 territory" to "over-exposed" — the old
number was a measurement artifact. The new numbers reflect actual
foreign-bank exposure as the construct intends.

Tests: new "excludes self-claims" regression guard pins the SG/CH
behavior. Methodology doc gets a §"Self-exclusion rule" callout under
Component 4.

Quality gates:
  - typecheck:api / lint / lint:md: clean
  - npm run test:data: 7190/7190 pass (was 7189; +1 self-exclusion guard)
  - Live production seed run: 201 records, hub-self-exclusion verified

🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via [Claude Code](https://claude.com/claude-code) + Compound Engineering v3.0.0

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <[email protected]>
@koala73
koala73 merged commit ac60d3a into main Apr 25, 2026
11 checks passed
@koala73
koala73 deleted the fix/fin-sys-exposure-seeders branch April 25, 2026 20:12
koala73 added a commit that referenced this pull request Apr 26, 2026
…1 + HIC=0) (#3427)

* fix(resilience): repair externalDebtCoverage broken-data-source (mrv=1 trap + HIC=0 sentinel)

PR #3425's post-merge cohort dry-run surfaced that `externalDebtCoverage`
(0.25-weight recovery dimension) is awarding `debtToReservesRatio: 0` →
score 100 to **72/164 countries (44%)** — including NO/CH/DK/SE/FI/IS/
KW/AE/SG/LU. A 25%-weighted dimension that scores nearly half the
universe at 100 is not discriminating; it's structurally elevating
wealthy countries on a metric that should differentiate them.

Two-layered root cause:

1. **WB `mrv=1` coverage trap** (memory `feedback_wb_bulk_mrv1_null_coverage_trap`).
   `mrv=1` returns a SINGLE year across all countries with `value: null`
   for late-reporters; the script silently drops them. The mature
   pattern (already used by `seed-wb-external-debt.mjs` for the
   financialSystemExposure dim) is `mrv=5` + per-country
   pickLatest-non-null. This script was written before the memory was
   captured and never migrated.

2. **WB IDS dataset is LMIC-scoped.** `DT.DOD.DSTC.CD` (short-term
   external debt) returns the literal `0` (not `null`) for high-income
   countries that don't report into the IDS series — they're
   out-of-scope, not data-sparse. Under the prior code, `Number(0) → 0`
   passed `Number.isFinite()`, divided by reserves, and yielded
   `debtToReservesRatio: 0` → score 100. The construct semantically
   does not apply to HICs (which manage external debt through different
   channels), so dropping them is correct — they fall to the dim's
   existing IMPUTE fallback (`recoveryExternalDebt`: 50/0.3/'unmonitored').

**Same fix applied to `seed-recovery-reserve-adequacy.mjs`** which had
the same `mrv=1` trap on `FI.RES.TOTL.MO` (no HIC=0 sentinel issue
there because reserve-months is genuinely measured for HICs).

**Propagation:** No cache-prefix bump. Score cache `resilience:score:v15:`
has 12h TTL; the next bulk-warm tick after this seed lands will refresh
all 222 country scores against the new debt seed, so the 72 false-100
countries naturally drop to IMPUTE 50 within 12h. Manual immediate
warm: run `seed-recovery-external-debt.mjs` then `seed-resilience-scores.mjs`.

**Expected v15 ranking impact:** countries previously inflated by
false-perfect externalDebtCoverage will drop. The cohort dry-run
should be re-run post-deploy to capture the empirical signature; PR
#3425's `npm run dryrun:resilience` script does this.

**Audit lineage:** Plan 2026-04-26-001 cohort dry-run (commit
`ae7229e5e` post-merge) → user audit finding #7+#8 → this PR.
Memory `feedback_wb_bulk_mrv1_null_coverage_trap` is the established
recipe; this is the second seeder migrated to it (first was
`seed-wb-external-debt.mjs` in PR #3412).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

* fix(resilience): null-skip before Number() coercion + post-merge force-refresh script

Two reviewer findings on PR #3427's initial commit:

**P1: Number(null) === 0 defeats the latest-non-null picker.**
The mrv=5 + pickLatest fix was structurally correct but `Number(null)`
returns `0` (not `NaN`), passes `Number.isFinite()`, and lets a
`value: null` record overwrite an older non-null record in the
year-comparison branch. Net effect: the very late-reporters mrv=5
was supposed to capture (KW/QA/AE) get a final `value: 0` from a
recent null record, then the country-level `debt.value <= 0` HIC
filter drops them entirely — same end-state as before, fix defeated.

Fix: explicit `if (record?.value == null) continue` BEFORE coercion
in both seeders. Same pattern as the standard recipe documented in
memory `feedback_wb_bulk_mrv1_null_coverage_trap`. Reviewer caught
this within minutes of the initial push.

**P2: Bundle-runner freshness gate would delay propagation by ~24d.**
`scripts/_bundle-runner.mjs:240` skips bundle items where
`elapsed < intervalMs * 0.8`. For the 30d recovery bundle, that means
the new seeder code wouldn't RUN on Railway for ~24 days post-merge
even though the code is in the container. Meanwhile
`seed-resilience-scores` would keep recomputing scores against the
old debt seed → fix appears not to land.

Fix: `scripts/post-pr3427-force-refresh.mjs` — one-shot post-merge
script that invokes both seeders directly (bypassing the bundle
runner's freshness gate). Runs the two seeders in series, then
prints the follow-up bulk-warm command. Documented in the script
header and to be referenced in PR #3427's release checklist.

Both fixes are surgical — same files, no logic re-architecture.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
koala73 added a commit that referenced this pull request Apr 26, 2026
…cher (review fixup on PR #3432)

Reviewer found one more WB seeder I missed in the original PR #3432 sweep:

**P1: `scripts/seed-wb-external-debt.mjs:69` had the exact same compound trap.**
`Number(record?.value)` before any null check, then year-based latest-picker
overwrites. Unlike the SWF imports picker (where `value <= 0` accidentally
catches Number(null)=0 because imports must be positive), this script's
downstream `combineExternalDebt` filter at `:98` only rejects negative
debt — `debt.value < 0`, not `<= 0`. So a `value: null` record from a
late-reporting LMIC (KW/QA/AE publish IDS data 1-2y behind G7) would
coerce to `0`, win the year comparison, and propagate as a false
`debtToReservesRatio: 0` → `0% short-term-debt-to-GNI` for the country.

This feeds `economic:wb-external-debt:v1` consumed by the new
`financialSystemExposure` dim (PR #3412 / #3407), so the false 0% would
silently inflate the dim's score for affected LMICs.

Fix: same recipe as PRs #3427 and #3432 — explicit
`if (record?.value == null) continue;` BEFORE `Number()` coercion.

**Defense-in-depth: `scripts/seed-bis-lbs.mjs:204` (the WB GDP fetcher
inside the BIS LBS seeder) also gets the explicit null-skip.**
This one was technically safe because GDP > 0 is a real-world invariant
and the `value <= 0` filter catches Number(null)=0 by side effect. But
per the lesson now codified in skill `wb-bulk-mrv1-null-coverage-trap`,
the protection is fragile — future indicator-family changes that
relax `value <= 0` would silently lose null protection. Adding the
explicit null-skip makes the picker null-safe regardless of filter
relaxation.

**End state after this commit + PR #3427 + PR #3432 + this fixup:**
ALL WB-bulk seeders in `scripts/seed-*.mjs` either (a) have an explicit
`value == null` skip BEFORE coercion, or (b) use a non-WB endpoint
shape (BIS SDMX, IMF SDMX, etc.) where the trap doesn't apply.
Final sweep recipe documented in the commit; next reviewer can run it
verbatim to audit any new seeder.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
koala73 added a commit that referenced this pull request Apr 26, 2026
…rs (#3432)

* fix(resilience): apply mrv=5 + null-skip recipe to remaining 4 WB seeders

PR #3427 fixed the mrv=1 + Number(null)=0 compound trap in 2 recovery
seeders (external-debt + reserve-adequacy). A subsequent sweep
(`grep -lE "mrv=1[^0-9]" scripts/*.mjs`) found 4 OTHER seeders with
the same trap shape:

- `seed-fossil-electricity-share.mjs` (EG.ELC.FOSL.ZS)
- `seed-low-carbon-generation.mjs` (EG.ELC.{NUCL,RNEW,HYRO}.ZS)
- `seed-power-reliability.mjs` (EG.ELC.LOSS.ZS)
- `seed-sovereign-wealth.mjs` (NE.IMP.GNFS.CD via pickLatestPerCountry)

All four migrated to the established recipe:
1. mrv=1 → mrv=5 + per-country pickLatest
2. per_page=500 → per_page=2000 (5x records per country = need bigger page)
3. Explicit `if (record?.value == null) continue` BEFORE Number() coercion
4. Year sanity check + per-country latest comparison

**Why explicit null-skip matters more for the energy seeders:**
The 3 energy seeders use "% of" indicators (EG.ELC.{FOSL,NUCL,RNEW,
HYRO,LOSS}.ZS) where 0 IS a legitimate value (country has 0% nuclear,
0% fossil, perfect grid reliability, etc.). The accidental
null-protection trick `if (value <= 0) continue` from the SWF picker
WOULD WRONGLY DROP legitimate zeros for these. Must skip null
explicitly so the `value === 0` case can flow through to scoring.

**`seed-sovereign-wealth.mjs` was already mostly correct** (mrv=5 +
pickLatest helper + accidental null protection via `value <= 0` since
imports must be > 0). Added explicit null-skip as defense-in-depth so
a future copy-paste of `pickLatestPerCountry` for a different
indicator family doesn't silently lose null protection.

**Lineage:** Plan 2026-04-26-001 cohort dry-run → user audit Priority
Check #4 ("Replace mrv=1 with mrv=5 + latest non-null in ALL seeders")
→ this PR. Companion to PR #3427.

**Memory `wb-bulk-mrv1-null-coverage-trap`** was updated post-PR-#3427
with the explicit `Number(null) === 0` warning + production case
study — that memory is the recipe these 4 seeders now follow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

* fix(resilience): null-skip in seed-wb-external-debt + bis-lbs GDP fetcher (review fixup on PR #3432)

Reviewer found one more WB seeder I missed in the original PR #3432 sweep:

**P1: `scripts/seed-wb-external-debt.mjs:69` had the exact same compound trap.**
`Number(record?.value)` before any null check, then year-based latest-picker
overwrites. Unlike the SWF imports picker (where `value <= 0` accidentally
catches Number(null)=0 because imports must be positive), this script's
downstream `combineExternalDebt` filter at `:98` only rejects negative
debt — `debt.value < 0`, not `<= 0`. So a `value: null` record from a
late-reporting LMIC (KW/QA/AE publish IDS data 1-2y behind G7) would
coerce to `0`, win the year comparison, and propagate as a false
`debtToReservesRatio: 0` → `0% short-term-debt-to-GNI` for the country.

This feeds `economic:wb-external-debt:v1` consumed by the new
`financialSystemExposure` dim (PR #3412 / #3407), so the false 0% would
silently inflate the dim's score for affected LMICs.

Fix: same recipe as PRs #3427 and #3432 — explicit
`if (record?.value == null) continue;` BEFORE `Number()` coercion.

**Defense-in-depth: `scripts/seed-bis-lbs.mjs:204` (the WB GDP fetcher
inside the BIS LBS seeder) also gets the explicit null-skip.**
This one was technically safe because GDP > 0 is a real-world invariant
and the `value <= 0` filter catches Number(null)=0 by side effect. But
per the lesson now codified in skill `wb-bulk-mrv1-null-coverage-trap`,
the protection is fragile — future indicator-family changes that
relax `value <= 0` would silently lose null protection. Adding the
explicit null-skip makes the picker null-safe regardless of filter
relaxation.

**End state after this commit + PR #3427 + PR #3432 + this fixup:**
ALL WB-bulk seeders in `scripts/seed-*.mjs` either (a) have an explicit
`value == null` skip BEFORE coercion, or (b) use a non-WB endpoint
shape (BIS SDMX, IMF SDMX, etc.) where the trap doesn't apply.
Final sweep recipe documented in the commit; next reviewer can run it
verbatim to audit any new seeder.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

* docs: fix stale 'mrv=1' top-comment in seed-low-carbon-generation.mjs

Reviewer follow-up on PR #3432: the top-of-file rationale comment in
`seed-low-carbon-generation.mjs:22` still claimed "We fetch the
most-recent value (mrv=1)" even though the actual fetch was migrated
to mrv=5 + null-skip in commit dd0be80. Non-blocking but worth
cleaning up — this is the same doc-drift pattern memory
`feedback_doc_drift_after_behavior_fix_needs_grep_sweep` warns about.

Updated the comment to describe the current mrv=5 + per-country
pickLatest behavior and reference the relevant skill + this PR.

The other mrv=1 references in PR #3432's touched files are intentional
— they appear inside rationale comments that explain the trap before
documenting why the seeder uses mrv=5 instead. Those should NOT change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
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