feat(resilience): PR 1 — energy construct repair (flag-gated) (#3289)

* docs(resilience): PR 1 foundation — Option B framing + v2 energy construct spec

First commit in PR 1 of the resilience repair plan. Zero scoring-behaviour
change; sets up the construct contract that the code changes will implement.

Declares the framing decision required by plan section 3.2 before any
scorer code lands: Option B (power-system security) is adopted. Electricity
grids are the dominant short-horizon shock-transmission channel, and the
choice lets the v2 energy indicator set share one denominator (percent of
electricity generation) instead of mixing primary-energy and power-system
measures in a composite.

Methodology doc changes:
  - Energy Domain section now documents both the legacy indicator set
    (still the default) and the v2 indicator set (flag-gated), under a
    single #### Energy H4 heading so the methodology-doc linter still
    asserts dimension-id parity with the registry.
  - v2 indicators: importedFossilDependence (EG.ELC.FOSL.ZS x
    max(EG.IMP.CONS.ZS, 0)), lowCarbonGenerationShare (EG.ELC.NUCL.ZS +
    EG.ELC.RNEW.ZS), powerLossesPct (EG.ELC.LOSS.ZS), reserveMarginPct
    (IEA), euGasStorageStress (renamed + scoped to EU), energyPriceStress
    (retained at 0.15 weight).
  - Retired under v2: electricityConsumption, gasShare, coalShare,
    dependency (all into importedFossilDependence), renewShare.
  - electricityAccess moves from energy to infrastructure under v2.
  - Added a v2.1 changelog section documenting the flag-gated rollout,
    acceptance gates (per plan section 6), and snapshot filenames for
    the post-flag-flip captures.
  - Known-limitations items 1-3 updated to note PR 1 lands the v2
    construct behind RESILIENCE_ENERGY_V2_ENABLED (default off).

Methodology-doc linter + mdx-lint + typecheck all clean. Indicator
registry, seeders, and scorer rewrite land in subsequent commits on
this same branch.

* feat(resilience): PR 1 — RESILIENCE_ENERGY_V2_ENABLED flag + scoreEnergy v2 + registry entries

Second commit in PR 1 of the resilience repair plan. Lands the flag,
the v2 scorer code path, and the registry entries the methodology
doc referenced. Default is flag off; published rankings are unchanged
until the flag flips in a later commit (after seeders land and the
acceptance-gate rerun produces a fresh post-flip snapshot).

Changes:

  - _shared.ts: isEnergyV2Enabled() function reader on the canonical
    RESILIENCE_ENERGY_V2_ENABLED env var. Dynamic read (like
    isPillarCombineEnabled) so tests can flip per-case.

  - _dimension-scorers.ts:
    - New Redis key constants for the three v2 seed keys plus the
      reserved reserveMargin key (seeder deferred per plan §3.1
      open-question).
    - EU_GAS_STORAGE_COUNTRIES set (EU + EFTA + UK) for the renamed
      euGasStorageStress signal per plan §3.5 point 2.
    - isEnergyV2EnabledLocal() — private duplicate of the flag reader
      to avoid a circular import (_shared.ts already imports from
      this module). Same env-var contract.
    - scoreEnergy split into scoreEnergyLegacy() + scoreEnergyV2().
      Public scoreEnergy() branches on the flag. Legacy path is
      byte-identical to the pre-commit behaviour.
    - scoreEnergyV2() reads four new bulk payloads, composes
      importedFossilDependence = fossilElectricityShare × max(netImports, 0)/100
      per plan §3.2, collapses net exporters to 0, and gates
      euGasStorageStress on EU membership so non-EU countries
      re-normalise rather than getting penalised for a regional
      signal.

  - _indicator-registry.ts: four new entries under `dimension: 'energy'`
    with `tier: 'experimental'` — importedFossilDependence (0.35),
    lowCarbonGenerationShare (0.20), powerLossesPct (0.10),
    reserveMarginPct (0.10). Experimental tier keeps them out of the
    Core coverage gate until seed coverage is confirmed.

  - compare-resilience-current-vs-proposed.mjs: new
    'bulk-v1-country-value' shape family in the extraction dispatcher.
    EXTRACTION_RULES now covers the four v2 registry indicators so
    the per-indicator influence harness tracks them from day one.
    When the seeders are absent, pairedSampleSize = 0 and Pearson = 0
    — the harness output surfaces the "no influence yet" state rather
    than silently dropping the indicators.

  - tests/resilience-energy-v2.test.mts: 11 new tests pinning:
    - flag-off = legacy behaviour preserved (v2 seed keys have no
      effect when flag is off — catches accidental cross-path reads)
    - flag-on = v2 composite behaves correctly:
      - lower fossilElectricityShare raises score
      - net exporter with 90% fossil > net importer with 90% fossil
        (max(·, 0) collapse verified)
      - higher lowCarbonGenerationShare raises score (nuclear credit)
      - higher powerLossesPct lowers score
      - euGasStorageStress is invariant for non-EU, responds for DE
      - all v2 inputs absent = graceful degradation, coverage < 1.0

106 resilience tests pass (existing + 11 new). Typecheck clean. Biome
clean. No production behaviour change with flag off (default).

Next commits on this branch: three World Bank seeders for the v2 keys,
health.js + SEED_META registration (gated ON_DEMAND_KEYS until Railway
cron provisions), acceptance-gate rerun at flag-flip time.

* feat(resilience): PR 1 — three WB seeders + health registration for v2 energy construct

Third commit in PR 1. Lands the seed scripts for the three v2 energy
indicator source keys, registered in api/health.js with ON_DEMAND_KEYS
gating until Railway cron provisions.

New seeders (weekly cron cadence, 8d maxStaleMin = 2x interval):
  - scripts/seed-low-carbon-generation.mjs
    Pulls EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS from World Bank, sums per
    country into `resilience:low-carbon-generation:v1`. Partial
    coverage (one series missing) still emits a value using the
    observed half — the scorer's 0-80 saturating goalpost tolerates
    it and the underlying construct is "firm low-carbon share".

  - scripts/seed-fossil-electricity-share.mjs
    Pulls EG.ELC.FOSL.ZS into `resilience:fossil-electricity-share:v1`.
    Feeds the importedFossilDependence composite at score time
    (composite = fossilShare × max(netImports, 0) / 100 per plan §3.2).

  - scripts/seed-power-reliability.mjs
    Pulls EG.ELC.LOSS.ZS into `resilience:power-losses:v1`. Direct
    grid-integrity signal replacing the retired electricityConsumption
    wealth proxy.

All three follow the existing seed-recovery-*.mjs template:
  - Shape: { countries: { [ISO2]: { value, year } }, seededAt }
  - runSeed() from _seed-utils.mjs with schemaVersion=1, ttl=35d
  - validateFn floor of 150 countries (WB coverage is 150-180 for
    the three indicators; below 150 = transient fetch failure)
  - ISO3 → ISO2 mapping via scripts/shared/iso3-to-iso2.json

No reserveMargin seeder is shipped in this commit per plan §3.1 open
question: IEA electricity-balance coverage is sparse outside OECD+G20,
and the indicator will likely ship as 'unmonitored' with weight 0.05
if it lands at all. The Redis key (`resilience:reserve-margin:v1`) is
reserved in _dimension-scorers.ts so the v2 scorer shape is stable.

api/health.js:
  - SEED_DOMAINS: add `lowCarbonGeneration`, `fossilElectricityShare`,
    `powerLosses` → their Redis keys.
  - SEED_META: same three, pointing at `seed-meta:resilience:*` meta
    keys with maxStaleMin=11520 (8d, per the worldmonitor
    health-maxstalemin-write-cadence pattern: 2x weekly cron).
  - ON_DEMAND_KEYS: three new entries gated as TRANSITIONAL until
    Railway cron provisions and the first clean run completes. Remove
    from this set after ~7 days of green production runs.

Typecheck clean; existing 106 resilience tests pass (seeders have no
in-repo callers yet, so nothing depends on them executing). Real-API
integration tests land when Railway cron is provisioned.

Next commit: Railway cron configuration + bundle-runner wiring.

* feat(resilience): PR 1 — bundle-runner + acceptance-gate verdict + flag-flip runbook

Final commit in the PR 1 tranche. Lands the three remaining pieces so
the flag-flip is fully operable once Railway cron provisions.

  - scripts/seed-bundle-resilience-energy-v2.mjs
    Railway cron bundle wrapping the three v2 energy seeders
    (low-carbon-generation, fossil-electricity-share, power-losses).
    Weekly cadence (7-day intervalMs); the underlying data is annual
    at source so polling more frequently just hammers the World Bank
    API. 5-minute per-script timeout. Mirrors the existing
    seed-bundle-resilience-recovery.mjs pattern.

  - scripts/compare-resilience-current-vs-proposed.mjs: acceptanceGates
    block. Programmatic evaluation of plan §6 gates using the inputs
    the harness already computes:
      gate-1-spearman              Spearman vs baseline >= 0.85
      gate-2-country-drift         Max country drift vs baseline <= 15
      gate-6-cohort-median         Cohort median shift vs baseline <= 10
      gate-7-matched-pair          Every pair holds expected direction
      gate-9-effective-influence   >= 80% Core indicators measurable
      gate-universe-integrity      No cohort/pair endpoint missing from scorable
    Thresholds are encoded in a const so they can't silently soften.
    Output verdict is PASS / CONDITIONAL / BLOCK. Emitted in
    summary.acceptanceVerdict for at-a-glance PR comment pasting, with
    full per-gate detail in acceptanceGates.results.

  - docs/methodology/energy-v2-flag-flip-runbook.md
    Operator runbook for the flag flip. Pre-flip checklist (seeders
    green, health endpoint green, ON_DEMAND_KEYS graduation, Spearman
    verification), flip procedure (pre-flip snapshot, dry-run, cache
    prefix bump, Vercel env flip, post-flip snapshot, methodology
    doc reclassification), rollback procedure, and a reference table
    for the three possible verdict states.

PR 1 is now code-complete pending:
  1. Railway cron provisioning (ops, not code)
  2. Flag flip + acceptance-gate rerun (follows runbook, not code)
  3. Reserve-margin seeder (deferred per plan §3.1 open-question)

Zero scoring-behaviour change in this commit. 121 resilience tests
pass, typecheck clean.

* fix(resilience): PR 1 — drop unseeded reserveMargin from scorer + fix composite extractor

Addresses two P1 review findings on PR #3289.

Finding 1: scoreEnergyV2 read resilience:reserve-margin:v1 at weight
0.10 but no seeder ships in this PR (indicator deferred per plan
§3.1 open-question). On flag flip that slot would be permanently
null, silently renormalizing the remaining 90% of weight and
producing a construct different from what the methodology doc
describes. Fix: remove reserve-margin from the v2 reader +
blend entirely. Redistribute its 0.10 weight to powerLossesPct
(now 0.20); both are grid-integrity signals per plan §3.1, and
the original plan split electricityConsumption's 0.30 weight
across powerLossesPct + reserveMarginPct + importedFossilDependence
— without reserveMarginPct, powerLossesPct carries the shared
grid-integrity load until the IEA seeder ships.

  v2 weights now: 0.35 + 0.20 + 0.20 + 0.10 + 0.15 = 1.00
  (importedFossilDependence + lowCarbonGenerationShare +
   powerLossesPct + euGasStorageStress + energyPriceStress)

  Reserve-margin Redis key constant stays reserved so the v2
  scorer shape is stable when a future commit lands the seeder;
  split 0.10 back out of powerLossesPct at that point.

Methodology doc, _shared.ts flag comment, and v2 test suite all
updated to the 5-indicator shape. New regression test asserts
that changing reserve-margin Redis content has zero effect on
the v2 score — guards against a future commit accidentally
wiring the reader back in without its seeder.

Finding 2: scripts/compare-resilience-current-vs-proposed.mjs
measured importedFossilDependence by reading fossilElectricityShare
alone. The scorer defines it as fossilShare × max(netImports, 0)
/ 100, so the extractor zeroed out net exporters and
under-reported net importers — making gate-9 effective-influence
wrong for the centrepiece construct change of PR 1.

Fix: new 'imported-fossil-dependence-composite' extractor type
in applyExtractionRule that recomputes the same composite from
both inputs (fossilShare bulk payload + staticRecord.iea.
energyImportDependency.value). Stays in lockstep with the
scorer — drift between the two would break gate-9's
interpretation.

New unit tests pin:
  - net importer: 80% × max(60, 0) / 100 = 48 ✓
  - net exporter: 80% × max(-40, 0) / 100 = 0 ✓
  - missing either input → null

64 resilience tests pass; typecheck clean. Flag-off path is
still byte-identical to pre-PR behaviour.

* docs(resilience): PR 1 — align methodology doc with actual shipped indicators and seeders

Addresses P1 review on docs/methodology/country-resilience-index.mdx
lines 29 and 574-575. The doc still described reserveMarginPct as a
shipped v2 indicator and listed seed-net-energy-imports.mjs in the
new-seeders list, neither of which the branch actually ships.

Doc changes to match the code in this branch:

  Known-limitations item 1: restated to describe the actual v2
  replacement footprint — powerLossesPct at 0.20 (temporarily
  absorbing reserveMarginPct's 0.10) plus accessToElectricityPct
  moved to infrastructure. reserveMarginPct is named as a deferred
  companion with the split-out instructions for when its seeder
  lands.

  v2.1 changelog (Indicators added): split into "live in PR 1" and
  "deferred in PR 1" so the reader can distinguish which entries
  match real code. importedFossilDependence's composite formula
  now written out and the net-imports source attributed to the
  existing resilience:static.iea path (not a new seeder).

  v2.1 changelog (New seeders): lists the three actual files that
  ship in this branch (seed-low-carbon-generation, seed-fossil-
  electricity-share, seed-power-reliability) and explicitly notes
  seed-net-energy-imports.mjs is NOT a new seeder — the
  EG.IMP.CONS.ZS series is already fetched by seed-resilience-
  static.mjs. Adds the bundle-runner reference.

Methodology-doc linter + mdx-lint both pass (125/125). Typecheck
clean. Doc is now the source of truth for what PR 1 actually ships.

* fix(resilience): PR 1 — sync powerLossesPct registry weight with scorer (0.10 → 0.20)

Reviewer-caught mismatch between INDICATOR_REGISTRY and scoreEnergyV2.
The previous commit redistributed the deferred reserveMarginPct's 0.10
weight into powerLossesPct in the SCORER but left the REGISTRY entry
unchanged at 0.10. Two downstream effects:

  1. scripts/compare-resilience-current-vs-proposed.mjs copies
     `spec.weight` into `nominalWeight` for gate-9 reporting, so
     powerLossesPct's nominal influence would be under-reported by
     half in every post-flip acceptance run — exactly the harness PR 1
     relies on for merge evidence.
  2. Methodology doc vs registry vs scorer drift is the pattern the
     methodology-doc linter is supposed to catch; it passes here
     because the linter only checks dimension-id parity, not weights.
     Registry is now the only remaining source of truth to keep in
     lockstep with the scorer.

Change:
  - `_indicator-registry.ts` powerLossesPct.weight: 0.1 → 0.2
  - Inline comment names the deferral and instructs: "when the IEA
    electricity-balance seeder lands, split 0.10 back out and restore
    reserveMarginPct at 0.10. Keep this field in lockstep with
    scoreEnergyV2 ... because the PR 0 compare harness copies
    spec.weight into nominalWeight for gate-9 reporting."

Experimental weights per dimension invariant still holds (0.35 + 0.20
+ 0.20 = 0.75 for energy, well under the 1.0 ceiling). 64 resilience
tests pass, typecheck clean.
This commit is contained in:
Elie Habib
2026-04-22 17:10:38 +04:00
committed by GitHub
parent da0f26a3cf
commit 52659ce192
14 changed files with 1186 additions and 7 deletions

View File

@@ -188,6 +188,11 @@ const STANDALONE_KEYS = {
recoveryExternalDebt: 'resilience:recovery:external-debt:v1',
recoveryImportHhi: 'resilience:recovery:import-hhi:v1',
recoveryFuelStocks: 'resilience:recovery:fuel-stocks:v1',
// PR 1 v2 energy-construct seeds. ON_DEMAND_KEYS until Railway cron
// provisions; see below.
lowCarbonGeneration: 'resilience:low-carbon-generation:v1',
fossilElectricityShare: 'resilience:fossil-electricity-share:v1',
powerLosses: 'resilience:power-losses:v1',
goldExtended: 'market:gold-extended:v1',
goldEtfFlows: 'market:gold-etf-flows:v1',
goldCbReserves: 'market:gold-cb-reserves:v1',
@@ -379,6 +384,13 @@ const SEED_META = {
recoveryExternalDebt: { key: 'seed-meta:resilience:recovery:external-debt', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
recoveryImportHhi: { key: 'seed-meta:resilience:recovery:import-hhi', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
recoveryFuelStocks: { key: 'seed-meta:resilience:recovery:fuel-stocks', maxStaleMin: 86400 }, // monthly cron; 86400min = 60d = 2x interval
// PR 1 v2 energy seeds — weekly cron (8d * 1440 = 11520min = 2x interval).
// Listed in ON_DEMAND_KEYS below until Railway cron provisions and
// the first clean run lands; after that they graduate to the normal
// SEED_META staleness check like the recovery seeds above.
lowCarbonGeneration: { key: 'seed-meta:resilience:low-carbon-generation', maxStaleMin: 11520 },
fossilElectricityShare: { key: 'seed-meta:resilience:fossil-electricity-share', maxStaleMin: 11520 },
powerLosses: { key: 'seed-meta:resilience:power-losses', maxStaleMin: 11520 },
};
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
@@ -401,6 +413,13 @@ const ON_DEMAND_KEYS = new Set([
'resilienceRanking', // on-demand RPC cache populated after ranking requests; missing before first Pro use is expected
'recoveryFiscalSpace', 'recoveryReserveAdequacy', 'recoveryExternalDebt',
'recoveryImportHhi', 'recoveryFuelStocks', // recovery pillar: stub seeders not yet deployed, keys may be absent
// PR 1 v2 energy-construct seeds. TRANSITIONAL: the three seeders
// ship with their health registry rows in this PR but Railway cron
// is provisioned as a follow-up action. Gated as on-demand until
// the first clean run lands; graduate out of this set after ~7 days
// of successful production cron runs (verify via
// `seed-meta:resilience:{low-carbon-generation,fossil-electricity-share,power-losses}.fetchedAt`).
'lowCarbonGeneration', 'fossilElectricityShare', 'powerLosses',
'displacementPrev', // covered by cascade onto current-year displacement; empty most of the year
'fxYoy', // TRANSITIONAL (PR #3071): seed-fx-yoy Railway cron deployed manually after merge —
// gate as on-demand so a deploy-order race or first-cron-run failure doesn't

View File

@@ -26,9 +26,9 @@ This PR (the diagnostic freeze) does not change any scoring behaviour. It ships
The first-publication repair is sequenced as PR 0 → PR 1 → PR 3 → PR 2 → PR 4 under the plan above. At the time of writing (PR 0 shipping), the following six construct errors are known and scheduled:
1. **`electricityConsumption` is a wealth proxy, not a resilience signal.** Weight 0.30 on the `energy` dimension; rewards per-capita load rather than grid-integrity capacity. Replaced in PR 1 with `powerLossesPct`, `reserveMarginPct`, and `accessToElectricityPct` (the last moved to the `infrastructure` domain).
2. **Gas and coal penalized as vulnerability even when domestic.** Current `gasShare` / `coalShare` penalties conflate fossil-dominance with fossil-import-dependence. Replaced in PR 1 with a single `importedFossilDependence` composite using World Bank `EG.IMP.CONS.ZS`.
3. **No nuclear credit in `scoreEnergy`.** Nuclear-heavy generation scores no points despite firm low-carbon characteristics. Fixed in PR 1 by collapsing `renewShare` + new `nuclearShare` into a single `lowCarbonGenerationShare` indicator.
1. **`electricityConsumption` is a wealth proxy, not a resilience signal.** Weight 0.30 on the `energy` dimension; rewards per-capita load rather than grid-integrity capacity. Replaced in PR 1 by `powerLossesPct` (absorbing the full 0.20 grid-integrity share temporarily) plus the indirect effect via `accessToElectricityPct` (moved to the `infrastructure` domain). A second grid-integrity signal `reserveMarginPct` is deferred per plan §3.1 open-question (IEA electricity-balance coverage too sparse); when its seeder ships, 0.10 splits back out of `powerLossesPct`. **Status:** PR 1 lands the v2 construct behind the `RESILIENCE_ENERGY_V2_ENABLED` flag (default off); the indicator set is documented under the Energy Domain section below.
2. **Gas and coal penalized as vulnerability even when domestic.** Current `gasShare` / `coalShare` penalties conflate fossil-dominance with fossil-import-dependence. Replaced in PR 1 with a single `importedFossilDependence` composite using World Bank `EG.IMP.CONS.ZS` × `EG.ELC.FOSL.ZS` under the **Option B (power-system framing)** decision documented in the Energy Domain section.
3. **No nuclear credit in `scoreEnergy`.** Nuclear-heavy generation scores no points despite firm low-carbon characteristics. Fixed in PR 1 by collapsing `renewShare` + new nuclear share into a single `lowCarbonGenerationShare` indicator sourced from World Bank `EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS`.
4. **Sovereign-wealth buffers invisible to `reserveAdequacy`.** Current dimension only sees central-bank reserves; SWF assets are not counted. Fixed in PR 2 by splitting the dimension into `liquidReserveAdequacy` + `sovereignFiscalBuffer` with a three-component haircut (access × liquidity × transparency) and a saturating transform.
5. **Dead and regional-only signals in the global core score.** `fuelStockDays` (100% imputed globally), `euGasStorageStress` (EU-only), and `currencyExternal` (BIS 64-economy coverage) currently carry material weight despite insufficient coverage for a world ranking. Retired or scoped regional-only in PR 3.
6. **No coverage-based weight cap.** A dimension at 30% observed coverage carries the same weight as one at 95%. Fixed in PR 3 with a CI-enforced rule: no indicator with observed coverage below 70% may exceed 5% nominal weight or 5% effective influence.
@@ -135,6 +135,10 @@ Sanctions use piecewise normalization: 0 entities = score 100, 1-10 = 90-75, 11-
#### Energy
The `energy` dimension is in the middle of the PR 1 construct repair (plan §3.1§3.3). Two indicator sets coexist for one release cycle: the **legacy construct** is currently live, and the **v2 construct** ships behind the `RESILIENCE_ENERGY_V2_ENABLED` flag (default off). Active set is determined by the flag at score time; mirrors how `schemaVersion: "2.0"` was staged.
**Legacy construct (current default).** Carries three known `wealth-proxy` / denominator-mismatch flaws tracked in `docs/methodology/indicator-sources.yaml` and in "Known construct limitations" at the top of this page.
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| energyImportDependency | IEA energy import dependency (% of supply from imports) | Lower is better | 100 - 0 | 0.25 | IEA | Annual |
@@ -145,6 +149,20 @@ Sanctions use piecewise normalization: 0 entities = score 100, 1-10 = 90-75, 11-
| energyPriceStress | Mean absolute energy price change across commodities | Lower is better | 25 - 0 | 0.10 | Energy prices | Daily |
| electricityConsumption | Per-capita electricity consumption (kWh/year, World Bank EG.USE.ELEC.KH.PC) | Higher is better | 200 - 8000 | 0.30 | World Bank | Annual |
**v2 construct (framing decision: Option B, power-system security).** Under v2 the dimension measures **power-system security**, not total-energy security. Electricity grids are the dominant short-horizon shock-transmission channel; transport-fuel security enters via `fuelStockDays`-successor work, and industrial energy security enters via transition-risk indicators on the `economic` domain. The framing choice is what lets the v2 indicator set share one denominator: percent of electricity generation, not percent of primary energy supply. Any future reversal to Option A (primary-energy framing) would require rebuilding `lowCarbonGenerationShare` and `euGasStorageStress` on IEA/BP primary-energy data — out of scope for PR 1.
| Indicator | Description | Direction | Goalposts (worst-best) | Weight | Source | Cadence |
|---|---|---|---|---|---|---|
| importedFossilDependence | `EG.ELC.FOSL.ZS × max(EG.IMP.CONS.ZS, 0) / 100`: fossil share of electricity × net-energy-import share, net exporters collapsed to 0 | Lower is better | 100 - 0 | 0.35 | World Bank | Annual |
| lowCarbonGenerationShare | Nuclear + renewable share of electricity (`EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS`) | Higher is better | 0 - 80 | 0.20 | World Bank | Annual |
| powerLossesPct | Electric power transmission + distribution losses (`EG.ELC.LOSS.ZS`). Direct grid-integrity measure. Weight temporarily absorbs `reserveMarginPct`'s 0.10 until the latter's IEA seeder lands. | Lower is better | 25 - 3 | 0.20 | World Bank | Annual |
| euGasStorageStress | Same transform as `gasStorageStress`, scoped to EU-only (weight 0 for non-EU) | Lower is better | 100 - 0 | 0.10 | GIE AGSI+ | Daily |
| energyPriceStress | Mean absolute energy price change across commodities | Lower is better | 25 - 0 | 0.15 | Energy prices | Daily |
Retired under v2: `electricityConsumption` (wealth proxy, §3.1 of repair plan), `gasShare` / `coalShare` / `energyImportDependency` (replaced by `importedFossilDependence`, §3.2), `renewShare` (absorbed into `lowCarbonGenerationShare`, §3.3). `electricityAccess` moves from `energy` to the `infrastructure` domain under v2, where it acts as a grid-collapse threshold signal rather than an affluence proxy.
**Deferred under v2 (plan §3.1 open-question):** `reserveMarginPct` does not ship in PR 1. IEA electricity-balance coverage is sparse outside OECD+G20; the indicator will likely ship at `tier='unmonitored'` with weight 0.05 if it lands at all. Its Redis key is reserved in `_dimension-scorers.ts`; when a seeder lands, split 0.10 out of `powerLossesPct` and add `reserveMarginPct` at 0.10 in the scorer blend.
### Social & Governance Domain (weight 0.19)
#### Governance
@@ -547,6 +565,17 @@ Self-assessed against the standard composite-indicator review axes on a 0-10 sca
**Phase 2 acceptance gate status: met.** All three required thresholds (Validation >= 8.0, Data >= 9.0, Architecture >= 9.0) are satisfied. The gaps flagged in each axis are tracked against Phase 3 tasks in the upgrade plan.
### v2.1 (April 2026) — PR 1 energy construct repair (flag-gated)
**Status: landing.** PR 1 in the resilience repair plan (`docs/plans/2026-04-22-001-fix-resilience-scorer-structural-bias-plan.md`). Addresses construct errors §3.1, §3.2, §3.3 in one coherent PR. Lands behind `RESILIENCE_ENERGY_V2_ENABLED` (default off) so published rankings remain on the pre-repair construct until the flag flips.
- **Framing decision: Option B (power-system security).** The `energy` dimension under v2 measures power-system security, not total-energy security. See Energy Domain section above for rationale and future-reversal cost.
- **Indicators retired:** `electricityConsumption` (wealth proxy), `gasShare` / `coalShare` / `dependency` (replaced by `importedFossilDependence`), `renewShare` (absorbed into `lowCarbonGenerationShare`).
- **Indicators added (live in PR 1):** `importedFossilDependence` (composite: `EG.ELC.FOSL.ZS × max(EG.IMP.CONS.ZS, 0) / 100`, reusing the existing `resilience:static.iea.energyImportDependency.value` for net-imports), `lowCarbonGenerationShare` (`EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS`), `powerLossesPct` (`EG.ELC.LOSS.ZS`, weight absorbs the deferred `reserveMarginPct`'s 0.10 share). `accessToElectricityPct` moves to the `infrastructure` domain where it acts as a grid-collapse threshold.
- **Indicator deferred in PR 1:** `reserveMarginPct` — IEA electricity-balance seeder is out of scope per plan §3.1 open-question. Redis key name + scorer-plumbing slot reserved for the commit that ships the seeder.
- **New seeders (weekly):** `seed-low-carbon-generation.mjs` (EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS), `seed-fossil-electricity-share.mjs` (EG.ELC.FOSL.ZS), `seed-power-reliability.mjs` (EG.ELC.LOSS.ZS). Bundled by `seed-bundle-resilience-energy-v2.mjs` for a single Railway cron service. Net-energy-imports (`EG.IMP.CONS.ZS`) is NOT a new seeder — it reuses the existing `seed-resilience-static.mjs` path. All three new seed keys are gated as `ON_DEMAND_KEYS` in `api/health.js` until Railway cron provisions and the first clean run lands; graduate out of the set after ~7 days of clean runs.
- **Acceptance gates (plan §6):** Spearman vs baseline >= 0.85; no country moves >15 points; matched-pair gap signs verified; cohort median shifts capped at 10 points; per-indicator effective influence measured via the PR 0 apparatus. Results committed as `docs/snapshots/resilience-ranking-live-post-pr1-<date>.json` and `docs/snapshots/resilience-energy-v2-acceptance-<date>.json` at flag-flip time.
### Editorial notes
- This document is maintained at parity with OECD/JRC composite-indicator standards: every dimension has a named source, direction, goalpost range, weight rationale, cadence, and imputation class. A methodology doc linter (Phase 1 T1.8) validates that the list of dimensions in the indicator registry matches the list documented here and fails CI if they drift.

View File

@@ -0,0 +1,127 @@
# PR 1 energy-v2 flag-flip runbook
Operational procedure for graduating the v2 energy construct from flag-off
(default shipped in PR #3289) to flag-on. Follow this runbook in order;
each step is gated by the previous step's success.
## Pre-flip checklist
All must be green before flipping `RESILIENCE_ENERGY_V2_ENABLED=true`:
1. **Seeders provisioned and green.** Railway cron service
`seed-bundle-resilience-energy-v2` deployed, cron schedule
`0 6 * * 1` (Monday 06:00 UTC, weekly). First clean run has landed
for all three keys:
```bash
redis-cli --url $REDIS_URL GET seed-meta:resilience:low-carbon-generation
redis-cli --url $REDIS_URL GET seed-meta:resilience:fossil-electricity-share
redis-cli --url $REDIS_URL GET seed-meta:resilience:power-losses
# fetchedAt within the last 8 days, recordCount >= 150 for each
```
2. **Health endpoint green for all three keys.** `/api/health` reports
`HEALTHY` with the three keys in the `lowCarbonGeneration`,
`fossilElectricityShare`, `powerLosses` slots. If any shows
`EMPTY_DATA` or `STALE_SEED`, the flag cannot flip.
3. **ON_DEMAND_KEYS graduation.** After ≥ 7 days of clean weekly cron
runs, remove the three entries from `api/health.js` `ON_DEMAND_KEYS`
set (transitional block added in this PR). Graduating them out of
on-demand puts them under the normal CRIT alerting path.
4. **Acceptance-gate rerun with flag-off.** Baseline Spearman vs the
PR 0 freeze must remain 1.0000:
```bash
node --import tsx/esm scripts/compare-resilience-current-vs-proposed.mjs \
> /tmp/pre-flip-flag-off.json
jq '.acceptanceGates.verdict' /tmp/pre-flip-flag-off.json
# Expected: "PASS" (or "CONDITIONAL" if baseline is missing; confirm
# baseline file exists in docs/snapshots/ and re-run).
```
## Flip procedure
1. **Capture a pre-flip snapshot.**
```bash
RESILIENCE_ENERGY_V2_ENABLED=false \
node --import tsx/esm scripts/freeze-resilience-ranking.mjs \
--label "live-pre-pr1-flip-$(date +%Y-%m-%d)" \
--output docs/snapshots/
git add docs/snapshots/resilience-ranking-live-pre-pr1-flip-*.json
git commit -m "chore(resilience): pre-PR-1-flip baseline snapshot"
```
2. **Dry-run the flag flip locally.**
```bash
RESILIENCE_ENERGY_V2_ENABLED=true \
node --import tsx/esm scripts/compare-resilience-current-vs-proposed.mjs \
> /tmp/flag-on-dry-run.json
jq '.acceptanceGates' /tmp/flag-on-dry-run.json
```
Every gate must be `pass`. If any is `fail`, STOP and debug before
proceeding. Check in order:
- `gate-1-spearman`: Spearman vs baseline >= 0.85
- `gate-2-country-drift`: max country drift <= 15 points
- `gate-6-cohort-median`: cohort median shift <= 10 points
- `gate-7-matched-pair`: every matched pair holds expected direction
- `gate-9-effective-influence-baseline`: >= 80% Core indicators measurable
3. **Bump the score-cache prefix.** Add a new commit to this branch
bumping `RESILIENCE_SCORE_CACHE_PREFIX` from `v10` to `v11` in
`server/worldmonitor/resilience/v1/_shared.ts`. This guarantees the
flag flip does not serve pre-flip cached scores from the 6h TTL
window. Without this bump, the next 6h of readers would see stale
d6-formula scores even with the flag on.
4. **Flip the flag in production.**
```bash
vercel env add RESILIENCE_ENERGY_V2_ENABLED production
# Enter: true
# (or via Vercel dashboard → Settings → Environment Variables)
vercel deploy --prod
```
5. **Capture the post-flip snapshot** immediately after the first
post-deploy ranking refresh completes (check via
`GET resilience:ranking:v11` in Redis):
```bash
node --import tsx/esm scripts/freeze-resilience-ranking.mjs \
--label "live-post-pr1-$(date +%Y-%m-%d)" \
--output docs/snapshots/
git add docs/snapshots/resilience-ranking-live-post-pr1-*.json
git commit -m "chore(resilience): post-PR-1 snapshot"
```
6. **Update construct-contract language.** In
`docs/methodology/country-resilience-index.mdx`, move items 1, 2,
and 3 of the "Known construct limitations" list from "landing in
PR 1" to "landed in PR 1 vYYYY-MM-DD." Flip the energy domain
section to describe v2 as the default construct, with the legacy
construct recast as the emergency-rollback path.
## Rollback procedure
If any acceptance gate fails post-flip or a reviewer flags a regression:
1. **Flip the flag back.**
```bash
vercel env rm RESILIENCE_ENERGY_V2_ENABLED production
# OR
vercel env add RESILIENCE_ENERGY_V2_ENABLED production # enter: false
vercel deploy --prod
```
2. **Do NOT bump the cache prefix back to v10.** Let the v11 prefix
accumulate flag-off scores. The legacy scorer produces d6-formula
scores regardless of the prefix version, so rolling the prefix
backward is unnecessary and creates a second cache-key migration.
3. **Capture a rollback snapshot** for post-mortem.
## Acceptance-gate verdict reference
Generated by `scripts/compare-resilience-current-vs-proposed.mjs`:
| Verdict | Meaning | Action |
|---|---|---|
| `PASS` | All gates pass | Proceed with flag flip |
| `CONDITIONAL` | Some gates skipped (baseline missing, etc.) | Fix missing inputs before flipping |
| `BLOCK` | At least one gate failed | Do NOT flip; investigate failure |
The verdict is computed on every invocation of the compare script.
Stash the full `acceptanceGates` block in PR comments when the flip
happens.

View File

@@ -275,6 +275,25 @@ const EXTRACTION_RULES = {
gasStorageStress: { type: 'gas-storage-field', field: 'fillPct' },
energyPriceStress: { type: 'not-implemented', reason: 'Scorer input is a global mean across commodity price changes; no per-country variance' },
electricityConsumption: { type: 'static-wb-infrastructure', code: 'EG.USE.ELEC.KH.PC' },
// PR 1 v2 energy indicators — `tier: 'experimental'` until seeders
// land. The extractor reads the same bulk-payload shape the scorer
// reads: { countries: { [ISO2]: { value, year } } }. When seed is
// absent the pairedSampleSize drops to 0 and Pearson returns 0,
// surfacing the "no influence yet" state in the harness output.
// importedFossilDependence is a SCORER-LEVEL COMPOSITE, not a direct
// seed-key read: scoreEnergyV2 computes
// fossilElectricityShare × max(netImports, 0) / 100
// where netImports is staticRecord.iea.energyImportDependency.value.
// Measuring only fossilShare underreports effective influence for
// net importers (whose composite is modulated by netImports) and
// zeros out the signal entirely for net exporters. The extractor
// therefore has to recompute the same composite; the shape family
// below reads BOTH inputs per country and applies the same math.
importedFossilDependence: { type: 'imported-fossil-dependence-composite' },
lowCarbonGenerationShare: { type: 'bulk-v1-country-value', key: 'resilience:low-carbon-generation:v1' },
powerLossesPct: { type: 'bulk-v1-country-value', key: 'resilience:power-losses:v1' },
// reserveMarginPct deferred per plan §3.1 — no seeder, no registry
// entry. Add here when the IEA electricity-balance seeder lands.
// ── governanceInstitutional (all 6 WGI sub-pillars) ─────────────────
wgiVoiceAccountability: { type: 'static-wgi', code: 'VA.EST' },
@@ -426,6 +445,32 @@ const SIMPLE_EXTRACTORS = {
const direct = sanctionsCounts?.[countryCode];
return typeof direct === 'number' ? direct : null;
},
// Shape: { countries: { [ISO2]: { value, year } } }. Used by the
// PR 1 v2 energy seeders. The key is specified per-rule so the
// dispatcher can route multiple bulk-v1 payloads through one
// extractor.
'bulk-v1-country-value': (rule, { bulkV1 }, countryCode) => {
const payload = bulkV1?.[rule.key];
const entry = payload?.countries?.[countryCode];
return typeof entry?.value === 'number' ? entry.value : null;
},
// Mirrors scoreEnergyV2's `importedFossilDependence` composite:
// fossilElectricityShare × max(netImports, 0) / 100
// fossilElectricityShare lives in the PR 1 bulk key; netImports
// reuses the legacy resilience:static.iea.energyImportDependency.value
// (EG.IMP.CONS.ZS) that the static seeder already publishes. This
// extractor MUST stay in lockstep with the scorer — drift between
// the two breaks gate-9's effective-influence interpretation.
'imported-fossil-dependence-composite': (_rule, { staticRecord, bulkV1 }, countryCode) => {
const fossilPayload = bulkV1?.['resilience:fossil-electricity-share:v1'];
const fossilEntry = fossilPayload?.countries?.[countryCode];
const fossilShare = typeof fossilEntry?.value === 'number' ? fossilEntry.value : null;
const netImports = typeof staticRecord?.iea?.energyImportDependency?.value === 'number'
? staticRecord.iea.energyImportDependency.value
: null;
if (fossilShare == null || netImports == null) return null;
return fossilShare * Math.max(netImports, 0) / 100;
},
};
// Aggregator extractors wire through exported scorer helpers so the
@@ -512,12 +557,24 @@ async function readExtractionSources(countryCode, reader) {
// the same resolver so the harness pulls the same payload the scorer
// would at the moment of execution.
const currentYear = new Date().getFullYear();
// PR 1 v2 energy bulk keys. Fetched once per country (the memoized
// reader de-dupes; these bulk payloads aren't country-scoped in the
// key, so all 220 country iterations share one fetch per key.)
const BULK_V1_KEYS = [
'resilience:fossil-electricity-share:v1',
'resilience:low-carbon-generation:v1',
'resilience:power-losses:v1',
// resilience:reserve-margin:v1 intentionally omitted — no seeder,
// no registry entry, per plan §3.1 deferral. Add when the IEA
// electricity-balance seeder lands.
];
const [
staticRecord, energyMix, gasStorage, fiscalSpace, reserveAdequacy,
externalDebt, importHhi, fuelStocks, imfMacro, imfLabor,
nationalDebt, sanctionsCounts,
cyber, outages, gps, ucdp, unrest, newsThreat, displacement,
socialVelocity, tradeRestrictions, tradeBarriers,
...bulkV1Payloads
] = await Promise.all([
reader(`resilience:static:${countryCode}`),
reader(`energy:mix:v1:${countryCode}`),
@@ -541,13 +598,16 @@ async function readExtractionSources(countryCode, reader) {
reader('intelligence:social:reddit:v1'),
reader('trade:restrictions:v1:tariff-overview:50'),
reader('trade:barriers:v1:tariff-gap:50'),
...BULK_V1_KEYS.map((k) => reader(k)),
]);
const bulkV1 = Object.fromEntries(BULK_V1_KEYS.map((k, i) => [k, bulkV1Payloads[i]]));
return {
staticRecord, energyMix, gasStorage, fiscalSpace, reserveAdequacy,
externalDebt, importHhi, fuelStocks, imfMacro, imfLabor,
nationalDebt, sanctionsCounts,
cyber, outages, gps, ucdp, unrest, newsThreat, displacement,
socialVelocity, tradeRestrictions, tradeBarriers,
bulkV1,
};
}
@@ -1123,6 +1183,114 @@ async function main() {
};
}
// Acceptance-gate verdict per plan §6. Computed programmatically
// from the inputs above so every scorer-changing PR has a
// machine-readable pass/fail on every gate. Gate numbering matches
// the plan sections literally — do NOT reorder without updating the
// plan.
//
// Thresholds are encoded here (not tunable per-PR) so gate criteria
// can't silently soften. Any adjustment requires a PR touching this
// file + the plan doc in the same commit.
const GATE_THRESHOLDS = {
SPEARMAN_VS_BASELINE_MIN: 0.85,
MAX_COUNTRY_ABS_DELTA_MAX: 15,
COHORT_MEDIAN_SHIFT_MAX: 10,
};
const gates = [];
const addGate = (id, name, status, detail) => {
gates.push({ id, name, status, detail });
};
// Gate 1: Spearman vs immediate-prior baseline >= 0.85.
if (baselineComparison.status === 'ok') {
const s = baselineComparison.spearmanVsBaseline;
addGate('gate-1-spearman', 'Spearman vs baseline >= 0.85',
s >= GATE_THRESHOLDS.SPEARMAN_VS_BASELINE_MIN ? 'pass' : 'fail',
`${s} (floor ${GATE_THRESHOLDS.SPEARMAN_VS_BASELINE_MIN})`);
} else {
addGate('gate-1-spearman', 'Spearman vs baseline >= 0.85', 'skipped',
'baseline unavailable; re-run after PR 0 freeze ships');
}
// Gate 2: No country's overallScore changes by more than 15 points
// from the immediate-prior baseline.
if (baselineComparison.status === 'ok') {
const drift = baselineComparison.maxCountryAbsDelta;
addGate('gate-2-country-drift', 'Max country drift vs baseline <= 15 points',
drift <= GATE_THRESHOLDS.MAX_COUNTRY_ABS_DELTA_MAX ? 'pass' : 'fail',
`${drift}pt (ceiling ${GATE_THRESHOLDS.MAX_COUNTRY_ABS_DELTA_MAX})`);
} else {
addGate('gate-2-country-drift', 'Max country drift vs baseline <= 15 points', 'skipped',
'baseline unavailable');
}
// Gate 6: Cohort median shift vs baseline capped at 10 points.
if (baselineComparison.status === 'ok') {
const worstCohort = (baselineComparison.cohortShiftVsBaseline ?? [])
.filter((c) => !c.skipped && typeof c.medianScoreDeltaVsBaseline === 'number')
.reduce((worst, c) => {
const abs = Math.abs(c.medianScoreDeltaVsBaseline);
return abs > Math.abs(worst?.medianScoreDeltaVsBaseline ?? 0) ? c : worst;
}, null);
if (worstCohort) {
const shift = Math.abs(worstCohort.medianScoreDeltaVsBaseline);
addGate('gate-6-cohort-median', 'Cohort median shift vs baseline <= 10 points',
shift <= GATE_THRESHOLDS.COHORT_MEDIAN_SHIFT_MAX ? 'pass' : 'fail',
`worst: ${worstCohort.cohortId} ${worstCohort.medianScoreDeltaVsBaseline}pt (ceiling ${GATE_THRESHOLDS.COHORT_MEDIAN_SHIFT_MAX})`);
} else {
addGate('gate-6-cohort-median', 'Cohort median shift vs baseline <= 10 points', 'skipped',
'no cohort has baseline overlap');
}
} else {
addGate('gate-6-cohort-median', 'Cohort median shift vs baseline <= 10 points', 'skipped',
'baseline unavailable');
}
// Gate 7: Matched-pair within-pair gap signs verified. Any pair
// flipping direction or falling below minGap stops the PR.
addGate('gate-7-matched-pair', 'Matched-pair within-pair gaps hold expected direction',
matchedPairFailures.length === 0 ? 'pass' : 'fail',
matchedPairFailures.length === 0
? `${matchedPairSummary.filter((p) => !p.skipped).length}/${matchedPairSummary.filter((p) => !p.skipped).length} pairs pass`
: `${matchedPairFailures.length} pair(s) failed: ${matchedPairFailures.map((p) => p.pairId).join(', ')}`);
// Gate 9: Per-indicator effective-influence baseline present. Sign-
// and rank-order correctness against nominal weights is a post-hoc
// human-review check; this gate asserts the MEASUREMENT exists,
// which is the diagnostic-apparatus pre-requisite from PR 0.
addGate('gate-9-effective-influence-baseline',
'Per-indicator effective-influence baseline exists (>= 80% of Core implemented)',
extractionCoverage.coreTotal > 0 && (extractionCoverage.coreImplemented / extractionCoverage.coreTotal) >= 0.80
? 'pass' : 'fail',
`${extractionCoverage.coreImplemented}/${extractionCoverage.coreTotal} Core indicators measurable`);
// Gate: cohort/pair membership present in scorable universe (not
// numbered in plan §6 but is the PR 0 fail-loud addition — if any
// cohort/pair endpoint falls out of listScorableCountries, every
// other gate is being computed over a silently-partial universe).
addGate('gate-universe-integrity', 'All cohort/pair endpoints are in the scorable universe',
cohortMissingFromScorable.length === 0 ? 'pass' : 'fail',
cohortMissingFromScorable.length === 0
? `${cohortOrPairMembers.size} endpoints verified`
: `missing from scorable: ${cohortMissingFromScorable.join(', ')}`);
const acceptanceGates = {
thresholds: GATE_THRESHOLDS,
results: gates,
summary: {
total: gates.length,
pass: gates.filter((g) => g.status === 'pass').length,
fail: gates.filter((g) => g.status === 'fail').length,
skipped: gates.filter((g) => g.status === 'skipped').length,
},
verdict: gates.some((g) => g.status === 'fail')
? 'BLOCK' // any fail halts the PR per plan §6
: gates.some((g) => g.status === 'skipped')
? 'CONDITIONAL' // skipped gates need the missing inputs before final merge
: 'PASS',
};
const output = {
comparison: 'currentDomainAggregate_vs_proposedPillarCombined',
penaltyAlpha: PENALTY_ALPHA,
@@ -1144,7 +1312,9 @@ async function main() {
meanAbsScoreDelta: Math.round(meanAbsScoreDelta * 100) / 100,
maxRankAbsDelta,
matchedPairFailures: matchedPairFailures.length,
acceptanceVerdict: acceptanceGates.verdict,
},
acceptanceGates,
baselineComparison,
cohortSummary,
matchedPairSummary,

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env node
// PR 1 of the resilience repair plan. Railway cron bundle wrapping
// the three World Bank seeders that feed the v2 energy construct:
//
// - seed-low-carbon-generation.mjs → resilience:low-carbon-generation:v1
// - seed-fossil-electricity-share.mjs → resilience:fossil-electricity-share:v1
// - seed-power-reliability.mjs → resilience:power-losses:v1
//
// Cadence: weekly (7 days); data is annual at source so polling more
// frequently just hammers the World Bank API without gaining fresh
// data. maxStaleMin in api/health.js is set to 8 days (2× interval).
//
// Railway service config (set up manually via Railway dashboard or
// `railway service`):
// - Service name: seed-bundle-resilience-energy-v2
// - Builder: NIXPACKS (root Dockerfile not used for this bundle)
// - rootDirectory: "" (repo root)
// - Watch paths: scripts/seed-low-carbon-generation.mjs,
// scripts/seed-fossil-electricity-share.mjs,
// scripts/seed-power-reliability.mjs, scripts/_seed-utils.mjs,
// scripts/_bundle-runner.mjs, scripts/seed-bundle-resilience-energy-v2.mjs
// - Cron schedule: "0 6 * * 1" (Monday 06:00 UTC, weekly)
// - Required env: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
import { runBundle, DAY } from './_bundle-runner.mjs';
await runBundle('resilience-energy-v2', [
{
label: 'Low-Carbon-Generation',
script: 'seed-low-carbon-generation.mjs',
seedMetaKey: 'resilience:low-carbon-generation',
canonicalKey: 'resilience:low-carbon-generation:v1',
intervalMs: 7 * DAY,
timeoutMs: 300_000,
},
{
label: 'Fossil-Electricity-Share',
script: 'seed-fossil-electricity-share.mjs',
seedMetaKey: 'resilience:fossil-electricity-share',
canonicalKey: 'resilience:fossil-electricity-share:v1',
intervalMs: 7 * DAY,
timeoutMs: 300_000,
},
{
label: 'Power-Losses',
script: 'seed-power-reliability.mjs',
seedMetaKey: 'resilience:power-losses',
canonicalKey: 'resilience:power-losses:v1',
intervalMs: 7 * DAY,
timeoutMs: 300_000,
},
]);

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env node
// PR 1 of the resilience repair plan (§3.2). Writes the per-country
// fossil share of electricity generation. Read by scoreEnergy v2 via
// `resilience:fossil-electricity-share:v1` as the `fossilShare`
// multiplier in the importedFossilDependence composite.
//
// Source: World Bank WDI EG.ELC.FOSL.ZS — electricity production
// from oil, gas and coal sources (% of total). Annual cadence.
//
// Shape: { countries: { [ISO2]: { value: 0-100, year } }, seededAt }
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
import iso3ToIso2 from './shared/iso3-to-iso2.json' with { type: 'json' };
loadEnvFile(import.meta.url);
const WB_BASE = 'https://api.worldbank.org/v2';
const CANONICAL_KEY = 'resilience:fossil-electricity-share:v1';
const CACHE_TTL = 35 * 24 * 3600;
const INDICATOR = 'EG.ELC.FOSL.ZS';
async function fetchFossilElectricityShare() {
const pages = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const url = `${WB_BASE}/country/all/indicator/${INDICATOR}?format=json&per_page=500&page=${page}&mrv=1`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`World Bank ${INDICATOR}: HTTP ${resp.status}`);
const json = await resp.json();
totalPages = json[0]?.pages ?? 1;
pages.push(...(json[1] ?? []));
page++;
}
const countries = {};
for (const record of pages) {
const rawCode = record?.countryiso3code ?? record?.country?.id ?? '';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
if (!iso2) continue;
const value = Number(record?.value);
if (!Number.isFinite(value)) continue;
const year = Number(record?.date);
countries[iso2] = { value, year: Number.isFinite(year) ? year : null };
}
return { countries, seededAt: new Date().toISOString() };
}
function validate(data) {
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 150;
}
export function declareRecords(data) {
return Object.keys(data?.countries || {}).length;
}
if (process.argv[1]?.endsWith('seed-fossil-electricity-share.mjs')) {
runSeed('resilience', 'fossil-electricity-share', CANONICAL_KEY, fetchFossilElectricityShare, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: `wb-fossil-elec-${new Date().getFullYear()}`,
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
declareRecords,
schemaVersion: 1,
maxStaleMin: 8 * 24 * 60,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env node
// PR 1 of the resilience repair plan (§3.3). Writes the per-country
// low-carbon share of electricity generation (nuclear + renewables).
// Read by scoreEnergy v2 via `resilience:low-carbon-generation:v1`.
//
// Source: World Bank WDI. Two indicators summed per country:
// - EG.ELC.NUCL.ZS: electricity production from nuclear (% of total)
// - EG.ELC.RNEW.ZS: electricity production from renewable sources
// excluding hydroelectric (% of total)
//
// Both series are annual; WDI reports latest observed year per
// country. We fetch the most-recent value (mrv=1) and sum by ISO2.
// Missing half of the pair (e.g. a country with nuclear data but no
// renewable filing) still produces a value using just the observed
// half — the scorer treats the goalpost 0..80 as saturating, so
// partial coverage is better than `null`.
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
import iso3ToIso2 from './shared/iso3-to-iso2.json' with { type: 'json' };
loadEnvFile(import.meta.url);
const WB_BASE = 'https://api.worldbank.org/v2';
const CANONICAL_KEY = 'resilience:low-carbon-generation:v1';
const CACHE_TTL = 35 * 24 * 3600;
const INDICATORS = ['EG.ELC.NUCL.ZS', 'EG.ELC.RNEW.ZS'];
async function fetchIndicator(indicatorId) {
const pages = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const url = `${WB_BASE}/country/all/indicator/${indicatorId}?format=json&per_page=500&page=${page}&mrv=1`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`World Bank ${indicatorId}: HTTP ${resp.status}`);
const json = await resp.json();
totalPages = json[0]?.pages ?? 1;
pages.push(...(json[1] ?? []));
page++;
}
return pages;
}
function collectByIso2(records) {
const out = new Map();
for (const record of records) {
const rawCode = record?.countryiso3code ?? record?.country?.id ?? '';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
if (!iso2) continue;
const value = Number(record?.value);
if (!Number.isFinite(value)) continue;
const year = Number(record?.date);
out.set(iso2, { value, year: Number.isFinite(year) ? year : null });
}
return out;
}
async function fetchLowCarbonGeneration() {
const [nuclearRecords, renewRecords] = await Promise.all(INDICATORS.map(fetchIndicator));
const nuclearByIso = collectByIso2(nuclearRecords);
const renewByIso = collectByIso2(renewRecords);
const allIso = new Set([...nuclearByIso.keys(), ...renewByIso.keys()]);
const countries = {};
for (const iso2 of allIso) {
const nuc = nuclearByIso.get(iso2);
const ren = renewByIso.get(iso2);
const sum = (nuc?.value ?? 0) + (ren?.value ?? 0);
// Year: most-recent of the two (they can diverge by a year or two
// between filings). Use the MAX so freshness reflects newest input.
const years = [nuc?.year, ren?.year].filter((y) => y != null);
countries[iso2] = {
value: Math.min(sum, 100), // guard against impossible sums from revised filings
year: years.length > 0 ? Math.max(...years) : null,
};
}
return { countries, seededAt: new Date().toISOString() };
}
function validate(data) {
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 150;
}
export function declareRecords(data) {
return Object.keys(data?.countries || {}).length;
}
if (process.argv[1]?.endsWith('seed-low-carbon-generation.mjs')) {
runSeed('resilience', 'low-carbon-generation', CANONICAL_KEY, fetchLowCarbonGeneration, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: `wb-low-carbon-${new Date().getFullYear()}`,
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
declareRecords,
schemaVersion: 1,
maxStaleMin: 8 * 24 * 60, // weekly cadence + 1 day slack
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
// PR 1 of the resilience repair plan (§3.1). Writes per-country
// electric power transmission + distribution losses. Read by
// scoreEnergy v2 via `resilience:power-losses:v1` as the direct
// grid-integrity signal that replaces the retired electricityConsumption
// wealth proxy.
//
// Source: World Bank WDI EG.ELC.LOSS.ZS — "Electric power
// transmission and distribution losses (% of output)". Annual
// cadence. Lower is better; developing economies often report 15-25%,
// OECD economies typically 3-8%.
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
import iso3ToIso2 from './shared/iso3-to-iso2.json' with { type: 'json' };
loadEnvFile(import.meta.url);
const WB_BASE = 'https://api.worldbank.org/v2';
const CANONICAL_KEY = 'resilience:power-losses:v1';
const CACHE_TTL = 35 * 24 * 3600;
const INDICATOR = 'EG.ELC.LOSS.ZS';
async function fetchPowerLosses() {
const pages = [];
let page = 1;
let totalPages = 1;
while (page <= totalPages) {
const url = `${WB_BASE}/country/all/indicator/${INDICATOR}?format=json&per_page=500&page=${page}&mrv=1`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) throw new Error(`World Bank ${INDICATOR}: HTTP ${resp.status}`);
const json = await resp.json();
totalPages = json[0]?.pages ?? 1;
pages.push(...(json[1] ?? []));
page++;
}
const countries = {};
for (const record of pages) {
const rawCode = record?.countryiso3code ?? record?.country?.id ?? '';
const iso2 = rawCode.length === 3 ? (iso3ToIso2[rawCode] ?? null) : (rawCode.length === 2 ? rawCode : null);
if (!iso2) continue;
const value = Number(record?.value);
if (!Number.isFinite(value)) continue;
const year = Number(record?.date);
countries[iso2] = { value, year: Number.isFinite(year) ? year : null };
}
return { countries, seededAt: new Date().toISOString() };
}
function validate(data) {
return typeof data?.countries === 'object' && Object.keys(data.countries).length >= 150;
}
export function declareRecords(data) {
return Object.keys(data?.countries || {}).length;
}
if (process.argv[1]?.endsWith('seed-power-reliability.mjs')) {
runSeed('resilience', 'power-losses', CANONICAL_KEY, fetchPowerLosses, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: `wb-power-losses-${new Date().getFullYear()}`,
recordCount: (data) => Object.keys(data?.countries ?? {}).length,
declareRecords,
schemaVersion: 1,
maxStaleMin: 8 * 24 * 60,
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -260,6 +260,54 @@ const RESILIENCE_RECOVERY_EXTERNAL_DEBT_KEY = 'resilience:recovery:external-debt
const RESILIENCE_RECOVERY_IMPORT_HHI_KEY = 'resilience:recovery:import-hhi:v1';
const RESILIENCE_RECOVERY_FUEL_STOCKS_KEY = 'resilience:recovery:fuel-stocks:v1';
// PR 1 energy-construct v2 seed keys (plan §3.1§3.3). Written by
// scripts/seed-low-carbon-generation.mjs, scripts/seed-fossil-
// electricity-share.mjs, scripts/seed-power-reliability.mjs.
// Read by scoreEnergy only when isEnergyV2Enabled() is true; until
// the seeders land, the keys are absent and the v2 scorer path
// degrades gracefully (returns null per sub-indicator, which the
// weightedBlend handles via the normal coverage/imputation path).
//
// Shape (all three): { updatedAt: ISO, countries: { [ISO2]: { value: number, year: number | null } } }
// Values are percent (0-100). Composites like importedFossilDependence
// are computed at score time, not pre-aggregated in the seed.
const RESILIENCE_LOW_CARBON_GEN_KEY = 'resilience:low-carbon-generation:v1';
const RESILIENCE_FOSSIL_ELEC_SHARE_KEY = 'resilience:fossil-electricity-share:v1';
const RESILIENCE_POWER_LOSSES_KEY = 'resilience:power-losses:v1';
// reserveMarginPct is DEFERRED per plan §3.1 open-question: IEA
// electricity-balance coverage is sparse outside OECD+G20 and the
// indicator may ship at `tier='unmonitored'` with weight 0.05 if it
// ships at all. Neither scorer v2 nor any consumer reads a
// `resilience:reserve-margin:v1` key today. When the seeder lands:
// 1. Reintroduce a `RESILIENCE_RESERVE_MARGIN_KEY` constant here,
// 2. Split 0.10 out of scoreEnergyV2's powerLossesPct weight and
// add reserveMargin at 0.10,
// 3. Add the indicator back to INDICATOR_REGISTRY + EXTRACTION_RULES.
// Until then the key name is a reservation in comment form only; the
// typecheck refuses to ship a declared-but-unread constant.
// EU country set for `euGasStorageStress` in the v2 energy construct.
// GIE AGSI+ covers EU member states + a few neighbours; non-EU
// countries get weight 0 on this signal (not null) so the denominator
// re-normalises correctly per plan §3.5. Kept local to this file to
// match the GIE coverage observed at seed time. EFTA members (NO, CH,
// IS) + UK are included because GIE publishes their storage too.
const EU_GAS_STORAGE_COUNTRIES = new Set([
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT',
'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK',
'NO', 'CH', 'IS', 'GB', // EFTA + UK
]);
// Local flag reader for the PR 1 v2 energy construct. The canonical
// definition lives in _shared.ts#isEnergyV2Enabled with full comments;
// this private duplicate avoids a circular import (_shared.ts already
// imports from this module). Both readers consult the SAME env var so
// the contract is a single source of truth.
function isEnergyV2EnabledLocal(): boolean {
return (process.env.RESILIENCE_ENERGY_V2_ENABLED ?? 'false').toLowerCase() === 'true';
}
const COUNTRY_NAME_ALIASES = new Map<string, Set<string>>();
for (const [name, iso2] of Object.entries(countryNames as Record<string, string>)) {
const code = String(iso2 || '').toUpperCase();
@@ -1027,9 +1075,13 @@ export async function scoreInfrastructure(
]);
}
export async function scoreEnergy(
// Legacy energy scorer. Default path. Kept intact for one release
// cycle so flipping `RESILIENCE_ENERGY_V2_ENABLED=false` reverts to
// byte-identical scoring behaviour for every country in the published
// snapshot.
async function scoreEnergyLegacy(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
reader: ResilienceSeedReader,
): Promise<ResilienceDimensionScore> {
const [staticRecord, energyPricesRaw, energyMixRaw, storageRaw] = await Promise.all([
readStaticCountry(countryCode, reader),
@@ -1080,6 +1132,114 @@ export async function scoreEnergy(
]);
}
// PR 1 v2 energy scorer under Option B (power-system security framing).
// Activated when RESILIENCE_ENERGY_V2_ENABLED=true. Reads from the
// PR 1 seed keys (low-carbon generation, fossil-electricity share,
// power losses, reserve margin). Missing inputs degrade gracefully —
// `weightedBlend` handles null scores per the normal coverage/
// imputation path, and the v2 indicators ship `tier: 'experimental'`
// in the registry so the Core coverage gate doesn't fire while
// seeders are being provisioned.
//
// Composite construction:
// importedFossilDependence = fossilElectricityShare × max(netImports, 0) / 100
// where fossilElectricityShare is `resilience:fossil-electricity-share:v1`
// and netImports is the legacy `iea.energyImportDependency.value`
// (EG.IMP.CONS.ZS) read from the existing static seed; we reuse
// rather than re-seed per plan §3.2.
//
// euGasStorageStress: per plan §3.5 point 2, the signal is renamed
// and scoped to EU members only. Non-EU countries contribute `null`
// (not 0) so the weighted blend re-normalises without penalising
// them for a regional-only signal.
async function scoreEnergyV2(
countryCode: string,
reader: ResilienceSeedReader,
): Promise<ResilienceDimensionScore> {
// reserveMarginPct is DEFERRED per plan §3.1 (IEA coverage too sparse;
// open-question whether the indicator ships at all). Its 0.10 weight
// is absorbed into powerLossesPct (→ 0.20) so the v2 blend remains
// grid-integrity-weighted. When a reserve-margin seeder eventually
// lands, split 0.10 back out of powerLosses and add reserveMargin
// here at 0.10. The Redis key RESILIENCE_RESERVE_MARGIN_KEY stays
// reserved in this file for that commit.
const [
staticRecord, energyPricesRaw, storageRaw,
fossilShareRaw, lowCarbonRaw, powerLossesRaw,
] = await Promise.all([
readStaticCountry(countryCode, reader),
reader(RESILIENCE_ENERGY_PRICES_KEY),
reader(`energy:gas-storage:v1:${countryCode}`),
reader(RESILIENCE_FOSSIL_ELEC_SHARE_KEY),
reader(RESILIENCE_LOW_CARBON_GEN_KEY),
reader(RESILIENCE_POWER_LOSSES_KEY),
]);
// Per-country value lookup on the bulk-payload shape emitted by the
// three PR 1 seeders: { countries: { [ISO2]: { value, year } } }.
const bulkValue = (raw: unknown): number | null => {
const entry = (raw as { countries?: Record<string, { value?: number }> } | null)
?.countries?.[countryCode];
return typeof entry?.value === 'number' ? entry.value : null;
};
const fossilElectricityShare = bulkValue(fossilShareRaw);
const lowCarbonGenerationShare = bulkValue(lowCarbonRaw);
const powerLosses = bulkValue(powerLossesRaw);
const netImports = safeNum(staticRecord?.iea?.energyImportDependency?.value);
// importedFossilDependence composite. `max(netImports, 0)` collapses
// net-exporter cases (negative EG.IMP.CONS.ZS) to zero per plan §3.2.
// Division by 100 keeps the product in the [0, 100] range expected
// by normalizeLowerBetter.
const importedFossilDependence = fossilElectricityShare != null && netImports != null
? fossilElectricityShare * Math.max(netImports, 0) / 100
: null;
// euGasStorageStress — same transform as legacy storageStress, but
// null outside the EU so non-EU countries don't get penalised for a
// regional-only signal.
const storageFillPct = storageRaw != null && typeof storageRaw === 'object'
? (() => {
const raw = (storageRaw as Record<string, unknown>).fillPct;
return raw != null ? safeNum(raw) : null;
})()
: null;
const euStorageStress = EU_GAS_STORAGE_COUNTRIES.has(countryCode) && storageFillPct != null
? Math.min(1, Math.max(0, (80 - storageFillPct) / 80))
: null;
// energyPriceStress retains its exposure-modulated form but weights
// to 0.15 under v2. Exposure is now derived from fossil share of
// electricity generation (Option B framing) rather than overall
// energy import dependency.
const energyStress = getEnergyPriceStress(energyPricesRaw);
const energyStressScore = energyStress == null ? null : normalizeLowerBetter(energyStress, 0, 25);
const exposure = fossilElectricityShare != null
? Math.min(Math.max(fossilElectricityShare / 60, 0), 1.0)
: 0.5;
const exposedEnergyStress = energyStressScore == null
? null
: energyStressScore * exposure + 100 * (1 - exposure);
return weightedBlend([
{ score: importedFossilDependence == null ? null : normalizeLowerBetter(importedFossilDependence, 0, 100), weight: 0.35 },
{ score: lowCarbonGenerationShare == null ? null : normalizeHigherBetter(lowCarbonGenerationShare, 0, 80), weight: 0.20 },
{ score: powerLosses == null ? null : normalizeLowerBetter(powerLosses, 3, 25), weight: 0.20 },
{ score: euStorageStress == null ? null : normalizeLowerBetter(euStorageStress * 100, 0, 100), weight: 0.10 },
{ score: exposedEnergyStress, weight: 0.15 },
]);
}
export async function scoreEnergy(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
return isEnergyV2EnabledLocal()
? scoreEnergyV2(countryCode, reader)
: scoreEnergyLegacy(countryCode, reader);
}
export async function scoreGovernanceInstitutional(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,

View File

@@ -455,6 +455,67 @@ export const INDICATOR_REGISTRY: IndicatorSpec[] = [
license: 'open-data',
},
// ── PR 1 energy-construct v2 (tier='experimental' until RESILIENCE_ENERGY_V2_ENABLED ──
// flips default-on and seeders land). Indicators are registered so
// the per-indicator harness in scripts/compare-resilience-current-vs-
// proposed.mjs can begin tracking them, but the 'experimental' tier
// keeps them OUT of the Core coverage gate (>=180 countries required
// per Phase 2 A4) until seed coverage is confirmed at flag-flip.
{
id: 'importedFossilDependence',
dimension: 'energy',
description: 'Composite: fossil share of electricity (EG.ELC.FOSL.ZS) × max(net energy imports % of primary energy use, 0) / 100. Lower is better. Replaces gasShare + coalShare + dependency under the Option B (power-system security) framing.',
direction: 'lowerBetter',
goalposts: { worst: 100, best: 0 },
weight: 0.35,
sourceKey: 'resilience:fossil-electricity-share:v1',
scope: 'global',
cadence: 'annual',
imputation: { type: 'conservative', score: 50, certainty: 0.3 },
tier: 'experimental',
coverage: 190,
license: 'open-data',
},
{
id: 'lowCarbonGenerationShare',
dimension: 'energy',
description: 'Nuclear + renewable share of electricity generation (EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS). Absorbs the legacy renewShare and adds nuclear credit.',
direction: 'higherBetter',
goalposts: { worst: 0, best: 80 },
weight: 0.2,
sourceKey: 'resilience:low-carbon-generation:v1',
scope: 'global',
cadence: 'annual',
imputation: { type: 'conservative', score: 30, certainty: 0.3 },
tier: 'experimental',
coverage: 190,
license: 'open-data',
},
{
id: 'powerLossesPct',
dimension: 'energy',
description: 'Electric power transmission + distribution losses (World Bank EG.ELC.LOSS.ZS). Direct grid-integrity measure. Weight is 0.20 in PR 1 — it temporarily absorbs the deferred reserveMarginPct slot (plan §3.1 open-question); when the IEA electricity-balance seeder lands, split 0.10 back out and restore reserveMarginPct at 0.10. Keep this field in lockstep with scoreEnergyV2 in _dimension-scorers.ts, because the PR 0 compare harness copies spec.weight into nominalWeight for gate-9 reporting.',
direction: 'lowerBetter',
goalposts: { worst: 25, best: 3 },
weight: 0.2,
sourceKey: 'resilience:power-losses:v1',
scope: 'global',
cadence: 'annual',
imputation: { type: 'conservative', score: 50, certainty: 0.3 },
tier: 'experimental',
coverage: 188,
license: 'open-data',
},
// reserveMarginPct is DEFERRED per plan §3.1 open-question: IEA
// electricity-balance data is sparse outside OECD+G20 and the
// indicator will likely ship as tier='unmonitored' with weight 0.05
// if it lands at all. Registering the indicator before a seeder
// exists would orphan its sourceKey in the seed-meta coverage
// test. The v2 scorer still READS from resilience:reserve-margin:v1
// (key reserved in _dimension-scorers.ts) so the scorer shape
// stays stable for the commit that provides data. Add the registry
// entry in that follow-up commit.
// ── governanceInstitutional (6 sub-metrics, equal weight) ─────────────────
{
id: 'wgiVoiceAccountability',

View File

@@ -65,6 +65,46 @@ export function isPillarCombineEnabled(): boolean {
return (process.env.RESILIENCE_PILLAR_COMBINE_ENABLED ?? 'false').toLowerCase() === 'true';
}
// PR 1 of the resilience repair plan (docs/plans/2026-04-22-001-fix-
// resilience-scorer-structural-bias-plan.md §3.1§3.3): activation
// flag for the v2 energy construct. Default is `false` so activation
// is an explicit operator action.
//
// When off (default): `scoreEnergy` uses the legacy inputs
// (energyImportDependency, gasShare, coalShare, renewShare,
// electricityConsumption, gasStorageStress, energyPriceStress) and
// published rankings are unchanged.
//
// When on: `scoreEnergy` uses the v2 inputs under the Option B
// (power-system security) framing:
// - importedFossilDependence = EG.ELC.FOSL.ZS × max(EG.IMP.CONS.ZS, 0) / 100 (weight 0.35)
// - lowCarbonGenerationShare = EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS (weight 0.20)
// - powerLossesPct = EG.ELC.LOSS.ZS (weight 0.20)
// - euGasStorageStress = legacy gasStorageStress scoped to EU (weight 0.10)
// - energyPriceStress = legacy energyPriceStress (weight 0.15)
// reserveMarginPct is DEFERRED per plan §3.1 until an IEA electricity-
// balance seeder lands; its 0.10 weight is temporarily absorbed into
// powerLossesPct (0.20 = 0.10 + 0.10). When the seeder ships, split
// the 0.10 back out.
// Retired under v2: electricityConsumption, gasShare, coalShare,
// renewShare, and the legacy energyImportDependency scorer input
// (still seeded; just not used by scoreEnergy v2 because it's been
// absorbed into importedFossilDependence).
//
// Read dynamically rather than captured at module load so tests can
// flip `process.env.RESILIENCE_ENERGY_V2_ENABLED` per-case without
// re-importing the module.
//
// Cache invalidation: energy dimension scores are embedded in the
// overall score, so flipping this flag requires either bumping
// RESILIENCE_SCORE_CACHE_PREFIX or waiting for the 6h TTL to clear.
// The current PR 1 plan stages the flag flip AFTER an acceptance-
// gate rerun that produces a fresh post-flip snapshot; the cache
// prefix bump lands in the commit that performs the acceptance run.
export function isEnergyV2Enabled(): boolean {
return (process.env.RESILIENCE_ENERGY_V2_ENABLED ?? 'false').toLowerCase() === 'true';
}
export const RESILIENCE_SCORE_CACHE_TTL_SECONDS = 6 * 60 * 60;
// Ranking TTL must exceed the cron interval (6h) by enough to tolerate one
// missed/slow cron tick. With TTL==cron_interval, writing near the end of a

View File

@@ -0,0 +1,188 @@
// Contract tests for the PR 1 energy-construct v2 flag gate
// (`RESILIENCE_ENERGY_V2_ENABLED`). Pins two invariants that must
// hold for the flag to be safe to flip:
//
// 1. Flag off = legacy construct. Every test that exercised the
// pre-PR-1 scorer must keep producing the same score. Any
// cross-contamination from the v2 code path into the default
// branch is a merge-blocker.
// 2. Flag on = v2 composite. Each new indicator must move the score
// in the documented direction (monotonicity), and countries
// missing a v2 input should degrade gracefully to null per
// weighted-blend contract rather than throw.
//
// The tests use stubbed readers instead of Redis so the suite stays
// hermetic.
import test, { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { scoreEnergy, type ResilienceSeedReader } from '../server/worldmonitor/resilience/v1/_dimension-scorers.ts';
const TEST_ISO2 = 'ZZ'; // fictional country so test coverage checks don't flag it
type EnergyReaderOverrides = {
staticRecord?: unknown;
storage?: unknown;
mix?: unknown;
prices?: unknown;
// v2 seed overrides (bulk-payload shape: { countries: { [ISO2]: { value } } })
fossilElectricityShare?: number | null;
lowCarbonGenerationShare?: number | null;
powerLosses?: number | null;
// Allow explicitly returning null for entire bulk payload.
fossilBulk?: unknown;
lowCarbonBulk?: unknown;
lossesBulk?: unknown;
};
function makeBulk(iso: string, value: number | null | undefined): unknown {
if (value == null) return null;
return { countries: { [iso]: { value, year: 2024 } } };
}
function makeEnergyReader(iso: string, overrides: EnergyReaderOverrides = {}): ResilienceSeedReader {
const defaultStatic = {
iea: { energyImportDependency: { value: 40 } },
infrastructure: { indicators: { 'EG.USE.ELEC.KH.PC': { value: 3000 } } },
};
const defaultMix = { gasShare: 30, coalShare: 20, renewShare: 30 };
return async (key: string) => {
if (key === `resilience:static:${iso}`) return overrides.staticRecord ?? defaultStatic;
if (key === 'economic:energy:v1:all') return overrides.prices ?? null;
if (key === `energy:mix:v1:${iso}`) return overrides.mix ?? defaultMix;
if (key === `energy:gas-storage:v1:${iso}`) return overrides.storage ?? null;
if (key === 'resilience:fossil-electricity-share:v1') {
return overrides.fossilBulk ?? makeBulk(iso, overrides.fossilElectricityShare ?? 50);
}
if (key === 'resilience:low-carbon-generation:v1') {
return overrides.lowCarbonBulk ?? makeBulk(iso, overrides.lowCarbonGenerationShare ?? 40);
}
if (key === 'resilience:power-losses:v1') {
return overrides.lossesBulk ?? makeBulk(iso, overrides.powerLosses ?? 10);
}
return null;
};
}
// ─ Flag-off: legacy behaviour is preserved ─────────────────────────
describe('scoreEnergy — RESILIENCE_ENERGY_V2_ENABLED=false (default)', () => {
before(() => {
delete process.env.RESILIENCE_ENERGY_V2_ENABLED;
});
it('flag is off by default', () => {
assert.equal(process.env.RESILIENCE_ENERGY_V2_ENABLED, undefined);
});
it('reads legacy inputs — higher renewShare raises score', async () => {
const low = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { mix: { gasShare: 30, coalShare: 20, renewShare: 5 } }));
const high = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { mix: { gasShare: 30, coalShare: 20, renewShare: 70 } }));
assert.ok(high.score > low.score, `legacy path should respond to renewShare; got ${low.score}${high.score}`);
});
it('does NOT read the v2 seed keys — changing fossilElectricityShare has no effect', async () => {
const baseline = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { fossilElectricityShare: 10 }));
const hiFossil = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { fossilElectricityShare: 90 }));
assert.equal(baseline.score, hiFossil.score, 'legacy path must be insensitive to v2 seed keys');
});
});
// ─ Flag-on: v2 composite is used ────────────────────────────────────
describe('scoreEnergy — RESILIENCE_ENERGY_V2_ENABLED=true', () => {
before(() => {
process.env.RESILIENCE_ENERGY_V2_ENABLED = 'true';
});
after(() => {
delete process.env.RESILIENCE_ENERGY_V2_ENABLED;
});
it('flag is on', () => {
assert.equal(process.env.RESILIENCE_ENERGY_V2_ENABLED, 'true');
});
it('v2 path reads importedFossilDependence — lower fossilElectricityShare raises score', async () => {
const cleanGrid = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { fossilElectricityShare: 5 }));
const dirtyGrid = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { fossilElectricityShare: 90 }));
assert.ok(cleanGrid.score > dirtyGrid.score, `fossil share 5→90 should lower score; got ${cleanGrid.score}${dirtyGrid.score}`);
});
it('net exporter (negative EG.IMP.CONS.ZS) collapses importedFossilDependence to 0', async () => {
// Plan §3.2: max(netImports, 0) ensures net exporters are not
// penalised by the composite regardless of their fossil share.
const netExporter = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, {
staticRecord: {
iea: { energyImportDependency: { value: -80 } }, // net exporter
infrastructure: { indicators: {} },
},
fossilElectricityShare: 90, // fossil-heavy but domestic → should NOT penalise
}));
const netImporter = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, {
staticRecord: {
iea: { energyImportDependency: { value: 80 } }, // heavy importer
infrastructure: { indicators: {} },
},
fossilElectricityShare: 90,
}));
assert.ok(
netExporter.score > netImporter.score,
`net exporter (90% fossil) must score higher than net importer (90% fossil); got ${netExporter.score} vs ${netImporter.score}`,
);
});
it('higher lowCarbonGenerationShare raises score (nuclear credit)', async () => {
const noNuclear = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { lowCarbonGenerationShare: 5 }));
const heavyNuclear = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { lowCarbonGenerationShare: 75 }));
assert.ok(heavyNuclear.score > noNuclear.score, `low-carbon 5→75 should raise score; got ${noNuclear.score}${heavyNuclear.score}`);
});
it('higher powerLosses lowers score (grid-integrity penalty)', async () => {
const cleanGrid = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { powerLosses: 4 }));
const leakyGrid = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { powerLosses: 22 }));
assert.ok(cleanGrid.score > leakyGrid.score, `power losses 4→22 should lower score; got ${cleanGrid.score}${leakyGrid.score}`);
});
it('euGasStorageStress gated by EU membership — non-EU country ignores storage signal', async () => {
// TEST_ISO2 is ZZ which is NOT in EU_GAS_STORAGE_COUNTRIES. The
// storage input should be dropped from the blend regardless of
// its value.
const noStorage = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, {}));
const lowStorage = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, { storage: { fillPct: 10 } }));
assert.equal(noStorage.score, lowStorage.score, 'non-EU country should be invariant to storage fill');
});
it('euGasStorageStress applies to EU member — DE with low storage scores lower than DE with high storage', async () => {
const deLow = await scoreEnergy('DE', makeEnergyReader('DE', { storage: { fillPct: 10 } }));
const deHigh = await scoreEnergy('DE', makeEnergyReader('DE', { storage: { fillPct: 90 } }));
assert.ok(deHigh.score > deLow.score, `DE storage 10→90 should raise score; got ${deLow.score}${deHigh.score}`);
});
it('missing v2 seed inputs degrade gracefully (no throw, coverage < 1.0)', async () => {
const allMissing = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2, {
fossilBulk: null, lowCarbonBulk: null, lossesBulk: null,
}));
// Score may be null/low but must NOT throw. Coverage should be
// well below 1.0 because most inputs are absent.
assert.ok(allMissing.coverage < 1.0, `all-missing coverage should be < 1.0, got ${allMissing.coverage}`);
});
it('reserveMarginPct is NOT read in v2 path (deferred per plan §3.1)', async () => {
// Regression guard: a future commit that adds a reserveMargin
// reader to scoreEnergyV2 without landing its seeder would
// silently renormalize weights on flag-on. This test pins the
// explicit exclusion: changing the reserve-margin Redis key
// content must have zero effect on the score.
const baseline = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2));
const customReader: ResilienceSeedReader = async (key: string) => {
if (key === 'resilience:reserve-margin:v1') {
return { countries: { [TEST_ISO2]: { value: 99, year: 2024 } } };
}
return (await makeEnergyReader(TEST_ISO2)(key));
};
const withReserveMargin = await scoreEnergy(TEST_ISO2, customReader);
assert.equal(baseline.score, withReserveMargin.score,
'reserve-margin key contents must not affect the v2 score until the indicator re-ships');
});
});

View File

@@ -203,6 +203,41 @@ test('applyExtractionRule — count-trade-restrictions uses scorer-exported coun
assert.equal(applyExtractionRule(rule, { tradeRestrictions }, 'AE', { countTradeRestrictions: () => 0 }), null);
});
test('applyExtractionRule — imported-fossil-dependence recomputes the scorer composite', () => {
// PR 1 §3.2: the scorer computes
// importedFossilDependence = fossilElectricityShare × max(netImports, 0) / 100
// Extractor MUST recompute the same composite, otherwise gate-9's
// effective-influence measurement for this indicator is wrong for
// every net-exporter (composite collapses to 0) and under-reports
// every net-importer (modulated by netImports).
const rule = { type: 'imported-fossil-dependence-composite' };
// Net importer: fossilShare 80% × max(60, 0) / 100 = 48
const netImporter = {
staticRecord: { iea: { energyImportDependency: { value: 60 } } },
bulkV1: {
'resilience:fossil-electricity-share:v1': { countries: { AE: { value: 80 } } },
},
};
assert.equal(applyExtractionRule(rule, netImporter, 'AE'), 48);
// Net exporter: max(-40, 0) = 0 → composite = 0 regardless of fossilShare
const netExporter = {
staticRecord: { iea: { energyImportDependency: { value: -40 } } },
bulkV1: {
'resilience:fossil-electricity-share:v1': { countries: { NO: { value: 90 } } },
},
};
assert.equal(applyExtractionRule(rule, netExporter, 'NO'), 0);
// Missing either input → null
assert.equal(applyExtractionRule(rule, { staticRecord: null, bulkV1: {} }, 'XX'), null);
assert.equal(applyExtractionRule(rule, {
staticRecord: { iea: { energyImportDependency: { value: 50 } } },
bulkV1: { 'resilience:fossil-electricity-share:v1': { countries: {} } },
}, 'XX'), null);
});
test('applyExtractionRule — aquastat stress vs availability gated by indicator tag', () => {
// Mirror scoreAquastatValue in _dimension-scorers.ts: both indicators
// share .aquastat.value, but the .aquastat.indicator tag classifies

View File

@@ -52,9 +52,16 @@ describe('indicator registry', () => {
}
});
it('every dimension has weights that sum to a consistent total', () => {
it('every dimension has non-experimental weights that sum to ~1.0', () => {
// Weight-sum invariant applies to the CURRENTLY-ACTIVE indicator
// set only. Indicators at tier='experimental' are flag-gated
// / in-progress work (e.g. the PR 1 v2 energy construct lands
// behind RESILIENCE_ENERGY_V2_ENABLED; until the flag flips,
// these indicators are NOT part of the live score and their
// weights must not be counted against the 1.0 invariant).
const byDimension = new Map<string, IndicatorSpec[]>();
for (const spec of INDICATOR_REGISTRY) {
if (spec.tier === 'experimental') continue;
const list = byDimension.get(spec.dimension) ?? [];
list.push(spec);
byDimension.set(spec.dimension, list);
@@ -63,7 +70,38 @@ describe('indicator registry', () => {
const totalWeight = specs.reduce((sum, s) => sum + s.weight, 0);
assert.ok(
Math.abs(totalWeight - 1) < 0.01,
`${dimId} weights sum to ${totalWeight.toFixed(4)}, expected ~1.0`,
`${dimId} non-experimental weights sum to ${totalWeight.toFixed(4)}, expected ~1.0`,
);
}
});
it('experimental weights are bounded at or below 1.0 per dimension', () => {
// Loose invariant for experimental indicators. A dimension's
// experimental set may only carry PART of the post-promotion
// weight — if some legacy indicators are RETAINED across the
// construct-repair (e.g. PR 1 retains energyPriceStress at a
// different weight and renames gasStorageStress to
// euGasStorageStress, both already in the non-experimental set),
// the experimental-only subsum will be < 1.0.
//
// Post-promotion weight-sum correctness for flag-gated indicator
// sets is the SCORER's responsibility to verify (via the flag-on
// behavioural tests in resilience-energy-v2.test.mts), not the
// registry's. This test enforces only the upper bound: no
// dimension should accumulate experimental weight in excess of
// the total it will eventually ship under the flag.
const byDimension = new Map<string, IndicatorSpec[]>();
for (const spec of INDICATOR_REGISTRY) {
if (spec.tier !== 'experimental') continue;
const list = byDimension.get(spec.dimension) ?? [];
list.push(spec);
byDimension.set(spec.dimension, list);
}
for (const [dimId, specs] of byDimension) {
const experimentalWeight = specs.reduce((sum, s) => sum + s.weight, 0);
assert.ok(
experimentalWeight <= 1.0 + 0.01,
`${dimId} experimental weights sum to ${experimentalWeight.toFixed(4)}, must not exceed 1.0`,
);
}
});