mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
127
docs/methodology/energy-v2-flag-flip-runbook.md
Normal file
127
docs/methodology/energy-v2-flag-flip-runbook.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
53
scripts/seed-bundle-resilience-energy-v2.mjs
Normal file
53
scripts/seed-bundle-resilience-energy-v2.mjs
Normal 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,
|
||||
},
|
||||
]);
|
||||
76
scripts/seed-fossil-electricity-share.mjs
Normal file
76
scripts/seed-fossil-electricity-share.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
106
scripts/seed-low-carbon-generation.mjs
Normal file
106
scripts/seed-low-carbon-generation.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
77
scripts/seed-power-reliability.mjs
Normal file
77
scripts/seed-power-reliability.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
188
tests/resilience-energy-v2.test.mts
Normal file
188
tests/resilience-energy-v2.test.mts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user