mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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.
271 lines
13 KiB
JavaScript
271 lines
13 KiB
JavaScript
// Contract test for the registry-driven per-indicator extraction plan
|
||
// used by scripts/compare-resilience-current-vs-proposed.mjs. Pins two
|
||
// acceptance-apparatus invariants:
|
||
//
|
||
// 1. Every indicator in INDICATOR_REGISTRY has a corresponding
|
||
// EXTRACTION_RULES row (implemented OR not-implemented with a
|
||
// reason). No silent omissions.
|
||
// 2. All six repair-plan construct-risk indicators (energy mix +
|
||
// electricity consumption + energy import dependency + WGI
|
||
// sub-pillars + recovery fiscal indicators) are 'implemented'
|
||
// in the harness, so PR 1 / PR 3 / PR 4 can measure
|
||
// pre-vs-post effective-influence against their baselines.
|
||
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
const scriptMod = await import('../scripts/compare-resilience-current-vs-proposed.mjs');
|
||
const registryMod = await import('../server/worldmonitor/resilience/v1/_indicator-registry.ts');
|
||
|
||
const { buildIndicatorExtractionPlan, applyExtractionRule, EXTRACTION_RULES } = scriptMod;
|
||
const { INDICATOR_REGISTRY } = registryMod;
|
||
|
||
test('every INDICATOR_REGISTRY entry has an EXTRACTION_RULES row', () => {
|
||
const missing = INDICATOR_REGISTRY.filter((spec) => !(spec.id in EXTRACTION_RULES));
|
||
assert.deepEqual(
|
||
missing.map((s) => s.id),
|
||
[],
|
||
'new indicator(s) added to INDICATOR_REGISTRY without adding an EXTRACTION_RULES entry; ' +
|
||
'add an extractor or an explicit { type: "not-implemented", reason }',
|
||
);
|
||
});
|
||
|
||
test('extraction plan row exists for every registry entry', () => {
|
||
const plan = buildIndicatorExtractionPlan(INDICATOR_REGISTRY);
|
||
assert.equal(plan.length, INDICATOR_REGISTRY.length);
|
||
for (const entry of plan) {
|
||
assert.ok(['implemented', 'not-implemented', 'unregistered-in-harness'].includes(entry.extractionStatus));
|
||
}
|
||
});
|
||
|
||
test('"not-implemented" rows carry a reason string', () => {
|
||
const plan = buildIndicatorExtractionPlan(INDICATOR_REGISTRY);
|
||
for (const entry of plan) {
|
||
if (entry.extractionStatus === 'not-implemented') {
|
||
assert.ok(
|
||
typeof entry.reason === 'string' && entry.reason.length > 0,
|
||
`indicator ${entry.indicator} marked not-implemented but has no reason`,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('all construct-risk indicators flagged by the repair plan are implemented', () => {
|
||
// The repair plan §3.1–§3.2, §4.3, §4.4 specifically names these
|
||
// indicators as the ones whose effective influence must be
|
||
// measurable pre- and post-change. If any becomes 'not-implemented',
|
||
// the acceptance apparatus for that PR evaporates. IDs match
|
||
// INDICATOR_REGISTRY exactly — the registry renames macroFiscal
|
||
// fiscal-space sub-indicators with a `recovery*` prefix when they
|
||
// live in the fiscalSpace dimension.
|
||
const mustBeImplemented = [
|
||
'gasShare',
|
||
'coalShare',
|
||
'renewShare',
|
||
'electricityConsumption',
|
||
'energyImportDependency',
|
||
'govRevenuePct',
|
||
'recoveryGovRevenue',
|
||
'recoveryFiscalBalance',
|
||
'recoveryDebtToGdp',
|
||
'recoveryReserveMonths',
|
||
'recoveryDebtToReserves',
|
||
'recoveryImportHhi',
|
||
];
|
||
const plan = buildIndicatorExtractionPlan(INDICATOR_REGISTRY);
|
||
const byId = Object.fromEntries(plan.map((p) => [p.indicator, p]));
|
||
for (const id of mustBeImplemented) {
|
||
assert.ok(byId[id], `construct-risk indicator ${id} is not in the extraction plan`);
|
||
assert.equal(
|
||
byId[id].extractionStatus,
|
||
'implemented',
|
||
`construct-risk indicator ${id} must be extractable; got "${byId[id].extractionStatus}": ${byId[id].reason ?? ''}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('core-tier indicator coverage meets a minimum floor', () => {
|
||
// Drives the extractionCoverage summary in the output. Floor raised
|
||
// after wiring the exported scorer-aggregate helpers (summarizeCyber,
|
||
// summarizeOutages, summarizeGps, summarizeUcdp, summarizeUnrest,
|
||
// getThreatSummaryScore, getCountryDisplacement, countTradeRestrictions,
|
||
// countTradeBarriers). The only Core-tier indicators still unextracted
|
||
// are those whose scorer inputs are genuinely global scalars
|
||
// (shippingStress, transitDisruption, energyPriceStress) or require
|
||
// unexported time-series helpers (fxVolatility, fxDeviation,
|
||
// aquastatWaterAvailability, householdDebtService, etc.).
|
||
const plan = buildIndicatorExtractionPlan(INDICATOR_REGISTRY);
|
||
const coreTotal = plan.filter((p) => p.tier === 'core').length;
|
||
const coreImplemented = plan.filter((p) => p.tier === 'core' && p.extractionStatus === 'implemented').length;
|
||
assert.ok(
|
||
coreImplemented / coreTotal >= 0.80,
|
||
`core-tier extraction coverage fell below 80%: ${coreImplemented}/${coreTotal}`,
|
||
);
|
||
});
|
||
|
||
test('the three "no per-country variance" indicators stay not-implemented with correct reason', () => {
|
||
// shippingStress / transitDisruption / energyPriceStress are
|
||
// scorer-level GLOBAL scalars — Pearson(global, overall) is 0 or
|
||
// NaN by construction. They must NOT be marked implemented: any
|
||
// future implementation that appears to extract them is wrong
|
||
// unless it re-expresses them as per-country effective contribution.
|
||
const plan = buildIndicatorExtractionPlan(INDICATOR_REGISTRY);
|
||
const byId = Object.fromEntries(plan.map((p) => [p.indicator, p]));
|
||
for (const id of ['shippingStress', 'transitDisruption', 'energyPriceStress']) {
|
||
assert.equal(byId[id]?.extractionStatus, 'not-implemented', `${id} should stay not-implemented (no per-country variance)`);
|
||
assert.match(byId[id].reason, /no per-country variance|global/i);
|
||
}
|
||
});
|
||
|
||
test('applyExtractionRule — static-path navigates nested object fields', () => {
|
||
const rule = { type: 'static-path', path: ['iea', 'energyImportDependency', 'value'] };
|
||
const sources = { staticRecord: { iea: { energyImportDependency: { value: 42 } } } };
|
||
assert.equal(applyExtractionRule(rule, sources, 'AE'), 42);
|
||
});
|
||
|
||
test('applyExtractionRule — recovery-country-field uses .countries[iso2].<field>', () => {
|
||
const rule = { type: 'recovery-country-field', key: 'resilience:recovery:fiscal-space:v1', field: 'govRevenuePct' };
|
||
const sources = { fiscalSpace: { countries: { AE: { govRevenuePct: 30 } } } };
|
||
assert.equal(applyExtractionRule(rule, sources, 'AE'), 30);
|
||
});
|
||
|
||
test('applyExtractionRule — static-wgi reads .wgi.indicators[code].value', () => {
|
||
// WGI keys are World-Bank standard codes (VA.EST, PV.EST, etc.)
|
||
const rule = { type: 'static-wgi', code: 'RL.EST' };
|
||
const sources = { staticRecord: { wgi: { indicators: { 'RL.EST': { value: 1.2 } } } } };
|
||
assert.equal(applyExtractionRule(rule, sources, 'DE'), 1.2);
|
||
});
|
||
|
||
test('applyExtractionRule — static-wgi-mean averages all six WGI sub-pillars', () => {
|
||
const rule = { type: 'static-wgi-mean' };
|
||
const sources = { staticRecord: { wgi: { indicators: {
|
||
'VA.EST': { value: 1.0 },
|
||
'PV.EST': { value: -1.0 },
|
||
'GE.EST': { value: 0.5 },
|
||
'RQ.EST': { value: -0.5 },
|
||
'RL.EST': { value: 2.0 },
|
||
'CC.EST': { value: 0.0 },
|
||
} } } };
|
||
assert.equal(applyExtractionRule(rule, sources, 'DE'), (1.0 + -1.0 + 0.5 + -0.5 + 2.0 + 0.0) / 6);
|
||
});
|
||
|
||
test('applyExtractionRule — missing values return null (pairwise-drop contract)', () => {
|
||
const rule = { type: 'static-path', path: ['iea', 'energyImportDependency', 'value'] };
|
||
assert.equal(applyExtractionRule(rule, {}, 'AE'), null);
|
||
assert.equal(applyExtractionRule(rule, { staticRecord: null }, 'AE'), null);
|
||
assert.equal(applyExtractionRule(rule, { staticRecord: { iea: null } }, 'AE'), null);
|
||
});
|
||
|
||
test('applyExtractionRule — not-implemented rules short-circuit to null', () => {
|
||
const rule = { type: 'not-implemented', reason: 'test' };
|
||
assert.equal(applyExtractionRule(rule, {}, 'AE'), null);
|
||
});
|
||
|
||
test('applyExtractionRule — summarize-cyber wires through exported scorer helper', () => {
|
||
const rule = { type: 'summarize-cyber' };
|
||
const cyber = { threats: [{ country: 'AE', severity: 'CRITICALITY_LEVEL_CRITICAL' }] };
|
||
// Pass a stub helper to prove the rule dispatches through it.
|
||
const helpers = {
|
||
summarizeCyber: (raw, cc) => ({
|
||
weightedCount: raw.threats.filter((t) => t.country === cc).length * 3,
|
||
}),
|
||
};
|
||
assert.equal(applyExtractionRule(rule, { cyber }, 'AE', helpers), 3);
|
||
// Without the helper available, rule falls back to null.
|
||
assert.equal(applyExtractionRule(rule, { cyber }, 'AE', {}), null);
|
||
});
|
||
|
||
test('applyExtractionRule — summarize-outages-penalty computes 4/2/1 weighting', () => {
|
||
const rule = { type: 'summarize-outages-penalty' };
|
||
const outages = { outages: [] };
|
||
const helpers = {
|
||
summarizeOutages: () => ({ total: 1, major: 2, partial: 3 }),
|
||
};
|
||
// penalty = 1*4 + 2*2 + 3*1 = 11
|
||
assert.equal(applyExtractionRule(rule, { outages }, 'AE', helpers), 11);
|
||
});
|
||
|
||
test('applyExtractionRule — displacement-field reads per-country entry by field name', () => {
|
||
const rule = { type: 'displacement-field', field: 'totalDisplaced' };
|
||
const displacement = {};
|
||
const helpers = {
|
||
getCountryDisplacement: () => ({ totalDisplaced: 12345, hostTotal: 678 }),
|
||
};
|
||
assert.equal(applyExtractionRule(rule, { displacement }, 'SY', helpers), 12345);
|
||
});
|
||
|
||
test('applyExtractionRule — count-trade-restrictions uses scorer-exported counter', () => {
|
||
const rule = { type: 'count-trade-restrictions' };
|
||
const tradeRestrictions = { restrictions: [] };
|
||
const helpers = { countTradeRestrictions: () => 5 };
|
||
assert.equal(applyExtractionRule(rule, { tradeRestrictions }, 'AE', helpers), 5);
|
||
// Zero coerces to null (pairwise-drop contract for empty signals).
|
||
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
|
||
// which family the reading belongs to. A stress-family country must
|
||
// NOT contribute a reading to the availability extractor, and vice
|
||
// versa, otherwise the Pearson correlation mixes two different
|
||
// construct scales.
|
||
const stressRule = { type: 'static-aquastat-stress' };
|
||
const availabilityRule = { type: 'static-aquastat-availability' };
|
||
|
||
const stressCountry = { staticRecord: { aquastat: { value: 42, indicator: 'Water stress (withdrawal/availability)' } } };
|
||
const availabilityCountry = { staticRecord: { aquastat: { value: 1500, indicator: 'Renewable water availability per capita' } } };
|
||
const unknownCountry = { staticRecord: { aquastat: { value: 99, indicator: 'Some unrecognised tag' } } };
|
||
const missingCountry = { staticRecord: { aquastat: { value: null, indicator: 'stress' } } };
|
||
|
||
// Stress-tagged country: only the stress extractor returns the value.
|
||
assert.equal(applyExtractionRule(stressRule, stressCountry, 'AE'), 42);
|
||
assert.equal(applyExtractionRule(availabilityRule, stressCountry, 'AE'), null);
|
||
|
||
// Availability-tagged country: only the availability extractor returns.
|
||
assert.equal(applyExtractionRule(availabilityRule, availabilityCountry, 'DE'), 1500);
|
||
assert.equal(applyExtractionRule(stressRule, availabilityCountry, 'DE'), null);
|
||
|
||
// Unknown tag: neither extractor returns (pairwise-drop).
|
||
assert.equal(applyExtractionRule(stressRule, unknownCountry, 'XX'), null);
|
||
assert.equal(applyExtractionRule(availabilityRule, unknownCountry, 'XX'), null);
|
||
|
||
// Missing value: null regardless of tag.
|
||
assert.equal(applyExtractionRule(stressRule, missingCountry, 'XX'), null);
|
||
});
|