fix(resilience): fail closed on missing v2 energy seeds + health CRIT on absent inputs (#3363)

* fix(resilience): fail closed on missing v2 energy seeds + health CRIT on absent inputs

PR #3289 shipped the v2 energy construct behind RESILIENCE_ENERGY_V2_ENABLED
(default false). Audit on 2026-04-24 after the user flagged "AE only moved
1.49 points — we added nuclear credit, we should see more" revealed two
safety gaps that made a future flag flip unsafe:

1. scoreEnergyV2 silently fell back to IMPUTE when any of its three
   required Redis seeds (low-carbon-generation, fossil-electricity-share,
   power-losses) was null. A future operator flipping the flag with
   seeds absent would produce fabricated-looking numbers for every
   country with zero operator signal.

2. api/health.js had those three seed labels in BOTH SEED_META (CRIT on
   missing) AND ON_DEMAND_KEYS (which demotes CRIT to WARN). The demotion
   won. Health has been reporting WARNING on a scorer dependency that has
   been 100% missing since PR #3289 merged — no paging trail existed.

Changes:

  server/worldmonitor/resilience/v1/_dimension-scorers.ts
    - Add ResilienceConfigurationError with missingKeys[] payload.
    - scoreEnergy: preflight the three v2 seeds when flag=true. Throw
      ResilienceConfigurationError listing the specific absent keys.
    - scoreAllDimensions: wrap per-dimension dispatch in try/catch so a
      thrown ResilienceConfigurationError routes to the source-failure
      shape (imputationClass='source-failure', coverage=0) for that ONE
      dimension — country keeps scoring other dims normally. Log once
      per country-dimension pair so the gap is audit-traceable.

  api/health.js
    - Remove lowCarbonGeneration / fossilElectricityShare / powerLosses
      from ON_DEMAND_KEYS. They stay in BOOTSTRAP_KEYS + SEED_META.
    - Replace the transitional comment with a hard "do NOT add these
      back" note pointing at the scorer's fail-closed gate.

  tests/resilience-energy-v2.test.mts
    - New test: flag on + ALL three seeds missing → throws
      ResilienceConfigurationError naming all three keys.
    - New test: flag on + only one seed missing → throws naming ONLY
      the missing key (operator-clarity guard).
    - New test: flag on + all seeds present → v2 runs normally.
    - Update the file-level invariant comment to reflect the new
      fail-closed contract (replacing the prior "degrade gracefully"
      wording that codified the silent-IMPUTE bug).
    - Note: fixture's `??` fallbacks coerce null-overrides into real
      data, so the preflight tests use a direct-reader helper.

  docs/methodology/country-resilience-index.mdx
    - New "Fail-closed semantics" paragraph in the v2 Energy section
      documenting the throw + source-failure + health-CRIT contract.

Non-goals (intentional):
  - This PR does NOT flip RESILIENCE_ENERGY_V2_ENABLED.
  - This PR does NOT provision seed-bundle-resilience-energy-v2 on Railway.
  - This PR does NOT touch RESILIENCE_PILLAR_COMBINE_ENABLED.

Operational effect post-merge:
  - /api/health flips from WARNING → CRITICAL on the three v2 seed-meta
    entries. That is the intended alarm; it reveals that the Railway
    bundle was never provisioned.
  - scoreEnergy behavior with flag=false is unchanged (legacy path).
  - scoreEnergy behavior with flag=true + seeds present is unchanged.
  - scoreEnergy behavior with flag=true + seeds absent changes from
    "silently IMPUTE all 217 countries" to "source-failure on the
    energy dim for every country, visible in widget + API response".

Tests: 511/511 resilience-* pass. Biome clean. Lint:md clean.

Related plan: docs/plans/2026-04-24-001-fix-resilience-v2-fail-closed-on-missing-seeds-plan.md

* docs(resilience): scrub stale ON_DEMAND_KEYS references for v2 energy seeds

Greptile P2 on PR #3363: four stale references implied the three v2
energy seeds were still gated as ON_DEMAND_KEYS (WARN-on-missing) even
though this PR's api/health.js change removed them (now strict
SEED_META = CRIT on missing). Scrubbing each:

  - api/health.js:196 (BOOTSTRAP_KEYS comment) — was "ON_DEMAND_KEYS
    until Railway cron provisions; see below." Updated to cite plan
    2026-04-24-001 and the strict-SEED_META posture.
  - api/health.js:398 (SEED_META comment) — was "Listed in ON_DEMAND_KEYS
    below until Railway cron provisions..." Updated for same reason.
  - docs/methodology/country-resilience-index.mdx:635 — v2.1 changelog
    entry said seed keys were ON_DEMAND_KEYS until graduation. Replaced
    with the fail-closed contract description.
  - docs/methodology/energy-v2-flag-flip-runbook.md:25 — step 3 said
    "ON_DEMAND_KEYS graduation" was required at flag-flip time.
    Rewrote to explain no graduation step is needed because the
    posture was removed pre-activation.

No code change. Tests still 14/14 on the energy-v2 suite, lint:md clean.

* fix(docs): escape MDX-unsafe `<=` in energy-v2 runbook to unblock Mintlify

Mintlify deploy on PR #3363 failed with
`Unexpected character '=' (U+003D) before name` at
`docs/methodology/energy-v2-flag-flip-runbook.md`. Two lines had
`<=` in plain prose, which MDX tries to parse as a JSX-tag-start.

Replaced both with `≤` (U+2264) — and promoted the two existing `>=`
on adjacent lines to `≥` for consistency. Prose is clearer and MDX
safe.

Same pattern as `mdx-unsafe-patterns-in-md` skill; also adjacent to
PR #3344's `(<137 countries)` fix.
This commit is contained in:
Elie Habib
2026-04-24 09:37:18 +04:00
committed by GitHub
parent c517b2fb17
commit d521924253
5 changed files with 187 additions and 39 deletions

View File

@@ -193,8 +193,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.
// PR 1 v2 energy-construct seeds. STRICT SEED_META (not ON_DEMAND):
// plan 2026-04-24-001 removed these from ON_DEMAND_KEYS so /api/health
// reports CRIT (not WARN) when they are absent. This is the intended
// alarm on the Railway bundle-not-provisioned state. See the ON_DEMAND_KEYS
// comment block below for the full rationale.
lowCarbonGeneration: 'resilience:low-carbon-generation:v1',
fossilElectricityShare: 'resilience:fossil-electricity-share:v1',
powerLosses: 'resilience:power-losses:v1',
@@ -395,9 +398,9 @@ const SEED_META = {
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.
// STRICT SEED_META (not ON_DEMAND): plan 2026-04-24-001 made /api/health
// CRIT on absent/stale so operators see the Railway-bundle gap before
// the flag flips. See the ON_DEMAND_KEYS "do not add back" note below.
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 },
@@ -423,13 +426,16 @@ 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',
// NOTE (2026-04-24, plan 2026-04-24-001): the PR 1 v2 energy seeds
// (`lowCarbonGeneration`, `fossilElectricityShare`, `powerLosses`)
// are INTENTIONALLY NOT listed in ON_DEMAND_KEYS. They stay strict
// SEED_META so `/api/health` returns CRIT (not WARN) when they are
// absent — which is the alarm a future operator needs before flipping
// `RESILIENCE_ENERGY_V2_ENABLED=true`. The scorer fails closed via
// ResilienceConfigurationError if the flag flips before the seeds
// populate (server/worldmonitor/resilience/v1/_dimension-scorers.ts
// #scoreEnergy). Do NOT add these labels back to ON_DEMAND_KEYS
// without revisiting that plan.
'displacementPrev', // covered by cascade onto current-year displacement; empty most of the year
'fxYoy', // TRANSITIONAL (PR #3071): seed-fx-yoy Railway cron deployed manually after merge —
// gate as on-demand so a deploy-order race or first-cron-run failure doesn't

View File

@@ -166,6 +166,8 @@ Retired under v2: `electricityConsumption` (wealth proxy, §3.1 of repair plan),
**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.
**Fail-closed semantics (plan `2026-04-24-001`).** When `RESILIENCE_ENERGY_V2_ENABLED=true` but any of the three required seeds (`resilience:fossil-electricity-share:v1`, `resilience:low-carbon-generation:v1`, `resilience:power-losses:v1`) is absent from Redis, the scorer throws `ResilienceConfigurationError` at dispatch rather than silently falling back to IMPUTE. The error is caught per-dimension in `scoreAllDimensions` and surfaces as `imputationClass='source-failure'` with `coverage=0`, visible in the widget and the API response. `/api/health` also reports CRIT on the three `seed-meta:resilience:\{low-carbon-generation,fossil-electricity-share,power-losses\}` entries when they are absent or stale. The flag is only safe to flip AFTER `seed-bundle-resilience-energy-v2` is provisioned on Railway and health reports green on all three.
### Social & Governance Domain (weight 0.19)
#### Governance
@@ -630,7 +632,7 @@ Self-assessed against the standard composite-indicator review axes on a 0-10 sca
- **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 + EG.ELC.HYRO.ZS` — hydro summed explicitly because WB RNEW excludes hydroelectric), `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 + EG.ELC.HYRO.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.
- **New seeders (weekly):** `seed-low-carbon-generation.mjs` (EG.ELC.NUCL.ZS + EG.ELC.RNEW.ZS + EG.ELC.HYRO.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 seed-meta keys are registered as STRICT `SEED_META` entries in `api/health.js` (NOT `ON_DEMAND_KEYS`) per plan `2026-04-24-001`: `/api/health` reports CRIT on absence/staleness so the Railway-bundle-not-provisioned state is visible before a future flag flip, and the scorer fails closed (`ResilienceConfigurationError` → source-failure) if the flag flips before seeds populate.
- **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.
### v2.2 (April 2026) — PR 3 dead-signal cleanup

View File

@@ -22,10 +22,15 @@ All must be green before flipping `RESILIENCE_ENERGY_V2_ENABLED=true`:
`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.
3. **Health-registry state (no code change needed at flip time).** Per
plan `2026-04-24-001` the three v2 seed labels are already STRICT
`SEED_META` entries — NOT in `ON_DEMAND_KEYS`. `/api/health` reports
CRIT on absent/stale data from the moment the Railway bundle is
provisioned. No "graduation" step is required at flag-flip time;
this transitional posture was removed before the flag-flip activation
path to keep the scorer and health layers in fail-closed lockstep
(scorer throws `ResilienceConfigurationError` → source-failure;
health reports CRIT; both surface the gap independently).
4. **Acceptance-gate rerun with flag-off.** Baseline Spearman vs the
PR 0 freeze must remain 1.0000:
```bash
@@ -56,11 +61,11 @@ All must be green before flipping `RESILIENCE_ENERGY_V2_ENABLED=true`:
```
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-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
- `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

View File

@@ -339,6 +339,24 @@ function isEnergyV2EnabledLocal(): boolean {
return (process.env.RESILIENCE_ENERGY_V2_ENABLED ?? 'false').toLowerCase() === 'true';
}
/**
* Thrown by the v2 energy dispatch when `RESILIENCE_ENERGY_V2_ENABLED=true`
* but one or more of the required Redis seeds
* (`resilience:low-carbon-generation:v1`, `resilience:fossil-electricity-share:v1`,
* `resilience:power-losses:v1`) is absent. Fail-closed surfaces the
* misconfiguration via the source-failure path instead of silently
* producing IMPUTE scores that look computed. See
* `docs/plans/2026-04-24-001-fix-resilience-v2-fail-closed-on-missing-seeds-plan.md`.
*/
export class ResilienceConfigurationError extends Error {
readonly missingKeys: readonly string[];
constructor(message: string, missingKeys: readonly string[]) {
super(message);
this.name = 'ResilienceConfigurationError';
this.missingKeys = missingKeys;
}
}
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();
@@ -1309,9 +1327,36 @@ export async function scoreEnergy(
countryCode: string,
reader: ResilienceSeedReader = defaultSeedReader,
): Promise<ResilienceDimensionScore> {
return isEnergyV2EnabledLocal()
? scoreEnergyV2(countryCode, reader)
: scoreEnergyLegacy(countryCode, reader);
if (!isEnergyV2EnabledLocal()) {
return scoreEnergyLegacy(countryCode, reader);
}
// Flag is ON — preflight the required seeds before routing to v2.
// A null from any of these would let scoreEnergyV2 score every country
// via the IMPUTE fallback with no signal to the operator (weightedBlend
// silently collapses null indicators to the imputation path). Fail-closed:
// throw ResilienceConfigurationError, caught at scoreAllDimensions and
// surfaced as imputationClass='source-failure' on the energy dimension.
// See docs/plans/2026-04-24-001-fix-resilience-v2-fail-closed-on-missing-seeds-plan.md.
const [fossilShareRaw, lowCarbonRaw, powerLossesRaw] = await Promise.all([
reader(RESILIENCE_FOSSIL_ELEC_SHARE_KEY),
reader(RESILIENCE_LOW_CARBON_GEN_KEY),
reader(RESILIENCE_POWER_LOSSES_KEY),
]);
const missing: string[] = [];
if (fossilShareRaw == null) missing.push(RESILIENCE_FOSSIL_ELEC_SHARE_KEY);
if (lowCarbonRaw == null) missing.push(RESILIENCE_LOW_CARBON_GEN_KEY);
if (powerLossesRaw == null) missing.push(RESILIENCE_POWER_LOSSES_KEY);
if (missing.length > 0) {
throw new ResilienceConfigurationError(
`RESILIENCE_ENERGY_V2_ENABLED=true but required v2 energy seeds are absent: ${missing.join(', ')}. ` +
`Provision seed-bundle-resilience-energy-v2 on Railway and confirm seeds populate BEFORE flipping the flag. ` +
'Or set RESILIENCE_ENERGY_V2_ENABLED=false to revert to the legacy energy construct.',
missing,
);
}
return scoreEnergyV2(countryCode, reader);
}
export async function scoreGovernanceInstitutional(
@@ -1855,10 +1900,41 @@ export async function scoreAllDimensions(
const memoizedReader = createMemoizedSeedReader(reader);
const [entries, freshnessMap, failedDatasets] = await Promise.all([
Promise.all(
RESILIENCE_DIMENSION_ORDER.map(async (dimensionId) => [
dimensionId,
await RESILIENCE_DIMENSION_SCORERS[dimensionId](countryCode, memoizedReader),
] as const),
RESILIENCE_DIMENSION_ORDER.map(async (dimensionId) => {
try {
const score = await RESILIENCE_DIMENSION_SCORERS[dimensionId](countryCode, memoizedReader);
return [dimensionId, score] as const;
} catch (err) {
// ResilienceConfigurationError (e.g. v2 energy flag flipped without
// seeds) surfaces here. Fail-closed per dimension, not per country:
// the country keeps scoring other dims normally, and this dim
// carries imputationClass='source-failure' + coverage=0 so the
// consumer sees the gap explicitly. The T1.7 decoration pass below
// reads this shape and leaves it alone; no double-tagging.
if (err instanceof ResilienceConfigurationError) {
console.warn(
`[Resilience] configuration-error dim=${dimensionId} country=${countryCode} missing=${err.missingKeys.join(',')} — routing to source-failure`,
);
// Match weightedBlend's empty-data shape (score=0 NOT null
// because the type declares score: number; coverage=0 marks
// "no data") + explicit source-failure tag so the T1.7
// decoration pass downstream recognises this as misconfiguration
// rather than IMPUTE. Freshness decorated by the caller
// alongside the other scores.
const sourceFailureScore: ResilienceDimensionScore = {
score: 0,
coverage: 0,
observedWeight: 0,
imputedWeight: 1,
imputationClass: 'source-failure',
freshness: { lastObservedAtMs: 0, staleness: '' },
};
return [dimensionId, sourceFailureScore] as const;
}
// Any other error is a bug, not misconfiguration — let it surface.
throw err;
}
}),
),
// T1.5 propagation pass: aggregate freshness at the caller level so
// the dimension scorers stay mechanical. We share the memoized

View File

@@ -7,9 +7,14 @@
// 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.
// in the documented direction (monotonicity). When any REQUIRED
// v2 seed is absent, the dispatch throws
// `ResilienceConfigurationError` (fail-closed) so the operator
// sees the misconfiguration via the source-failure path instead
// of IMPUTE numbers that look computed. Pre-2026-04-24 the
// scorer silently degraded to IMPUTE; plan
// `docs/plans/2026-04-24-001-fix-resilience-v2-fail-closed-on-missing-seeds-plan.md`
// inverts that contract.
//
// The tests use stubbed readers instead of Redis so the suite stays
// hermetic.
@@ -17,7 +22,11 @@
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';
import {
ResilienceConfigurationError,
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
@@ -159,13 +168,63 @@ describe('scoreEnergy — RESILIENCE_ENERGY_V2_ENABLED=true', () => {
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}`);
// Fail-closed contract (plan 2026-04-24-001). When flag=true but any
// required v2 seed is absent, the dispatch throws. Silent IMPUTE
// fallback would produce fabricated-looking numbers with zero operator
// signal — the exact failure the user post-mortem-caught on 2026-04-24.
// The shared makeEnergyReader fixture uses `??` fallbacks that coerce
// null-overrides back into default data, so these fail-closed tests
// need a direct reader that can authoritatively return null for a
// specific key without the fixture's defaulting logic.
const makeReaderWithMissingV2Seeds = (missing: Set<string>): ResilienceSeedReader => {
const base = makeEnergyReader(TEST_ISO2);
return async (key: string) => (missing.has(key) ? null : base(key));
};
it('flag on + ALL three v2 seeds missing → throws ResilienceConfigurationError naming all three keys', async () => {
const reader = makeReaderWithMissingV2Seeds(new Set([
'resilience:fossil-electricity-share:v1',
'resilience:low-carbon-generation:v1',
'resilience:power-losses:v1',
]));
await assert.rejects(
scoreEnergy(TEST_ISO2, reader),
(err: unknown) => {
assert.ok(err instanceof ResilienceConfigurationError, `expected ResilienceConfigurationError, got ${err}`);
assert.ok(err.message.includes('resilience:fossil-electricity-share:v1'), 'error must name missing fossil-share key');
assert.ok(err.message.includes('resilience:low-carbon-generation:v1'), 'error must name missing low-carbon key');
assert.ok(err.message.includes('resilience:power-losses:v1'), 'error must name missing power-losses key');
assert.equal(err.missingKeys.length, 3, 'missingKeys must enumerate every absent seed');
return true;
},
);
});
it('flag on + partial missing (only fossil-share absent) → throws naming ONLY the missing key', async () => {
// Operator-clarity: the error must tell the operator WHICH seed is
// broken so they can fix that specific seeder rather than chase
// all three.
const reader = makeReaderWithMissingV2Seeds(new Set([
'resilience:fossil-electricity-share:v1',
]));
await assert.rejects(
scoreEnergy(TEST_ISO2, reader),
(err: unknown) => {
assert.ok(err instanceof ResilienceConfigurationError);
assert.deepEqual([...err.missingKeys].sort(), ['resilience:fossil-electricity-share:v1']);
assert.ok(!err.message.includes('low-carbon-generation'), 'must not mention keys that ARE present');
assert.ok(!err.message.includes('power-losses'), 'must not mention keys that ARE present');
return true;
},
);
});
it('flag on + ALL three v2 seeds present → v2 runs normally, no throw', async () => {
// Regression guard for the happy path — ensuring the preflight
// check does not block a correctly-configured v2 activation.
const result = await scoreEnergy(TEST_ISO2, makeEnergyReader(TEST_ISO2));
assert.equal(typeof result.score, 'number', 'happy-path v2 must produce a numeric score');
assert.ok(result.coverage > 0, 'happy-path v2 must report positive coverage');
});
it('reserveMarginPct is NOT read in v2 path (deferred per plan §3.1)', async () => {