mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(resilience): PR 3 §3.5 — retire fuelStockDays from core score permanently
First commit in PR 3 of the resilience repair plan. Retires
`fuelStockDays` from the core score with no replacement.
Why permanent, not replaced:
IEA emergency-stockholding rules are defined in days of NET IMPORTS
and do not bind net exporters by design. Norway/Canada/US measured
in days-of-imports are incomparable to Germany/Japan measured the
same way — the construct is fundamentally different across the two
country classes. No globally-comparable recovery-fuel signal can
be built from this source; the pre-repair probe showed 100% imputed
at 50 for every country in the April 2026 freeze.
scoreFuelStockDays:
- Rewritten to return coverage=0 + observedWeight=0 +
imputationClass='source-failure' for every country regardless
of seed content.
- Drops the dimension from the `recovery` domain's coverage-
weighted mean automatically; remaining recovery dimensions
pick up the share via re-normalisation in
`_shared.ts#coverageWeightedMean`.
- No explicit weight transfer needed — the coverage-weighted
blend handles redistribution.
Registry:
- recoveryFuelStockDays re-tagged from tier='enrichment' to
tier='experimental' so the Core coverage gate treats it as
out-of-score.
- Description updated to make the retirement explicit; entry
stays in the registry for structural continuity (the
dimension `fuelStockDays` remains in RESILIENCE_DIMENSION_ORDER
for the 19-dimension tests; removing the dimension entirely is
a PR 4 structural-audit concern).
Housekeeping:
- Removed `RESILIENCE_RECOVERY_FUEL_STOCKS_KEY` constant (no
longer read; noUnusedLocals would reject it).
- Removed `RecoveryFuelStocksCountry` interface for the same
reason. Comment at the removed declaration instructs future
maintainers not to re-add the type as a reservation; when a
new recovery-fuel concept lands, introduce a fresh interface.
Plan reference: §3.5 point 1 of
`docs/plans/2026-04-22-001-fix-resilience-scorer-structural-bias-plan.md`.
51 resilience tests pass, typecheck + biome clean. The
`recovery` domain's published score will shift slightly for every
country because the 0.10 slot that fuelStockDays was imputing to
now redistributes; the compare-harness acceptance-gate rerun at
merge time will quantify the shift per plan §6 gates.
* feat(resilience): PR 3 §3.5 — retire BIS-backed currencyExternal; rebuild on IMF inflation + WB reserves
BIS REER/DSR feeds were load-bearing in currencyExternal (weights 0.35
fxVolatility + 0.35 fxDeviation, ~70% of dimension). They cover ~60
countries max — so every non-BIS country fell through to
curated_list_absent (coverage 0.3) or a thin IMF proxy (coverage 0.45).
Combined with reserveMarginPct already removed in PR 1, currencyExternal
was the clearest "construct absent for most of the world" carrier left
in the scorer.
Changes:
_dimension-scorers.ts
- scoreCurrencyExternal now reads IMF macro (inflationPct) + WB FX
reserves only. Coverage ladder:
inflation + reserves → 0.85 (observed primary + secondary)
inflation only → 0.55
reserves only → 0.40
neither → 0.30 (IMPUTE.bisEer retained for snapshot
continuity; semantics read as
"no IMF + no WB reserves" now)
- Removed dead symbols: RESILIENCE_BIS_EXCHANGE_KEY constant (reserved
via comment only, flagged by noUnusedLocals), stddev() helper,
getCountryBisExchangeRates() loader, BisExchangeRate interface,
dateToSortableNumber() — all were exclusive callers of the retired
BIS path.
_indicator-registry.ts
- New core entry inflationStability (weight 0.60, tier=core,
sourceKey=economic:imf:macro:v2).
- fxReservesAdequacy weight 0.15 → 0.40 (secondary reliability
anchor).
- fxVolatility + fxDeviation demoted tier=enrichment → tier=experimental
(BIS ~60-country coverage; off the core weight sum).
- Non-experimental weights now sum to 1.0 (0.60 + 0.40).
scripts/compare-resilience-current-vs-proposed.mjs
- EXTRACTION_RULES: added inflationStability →
imf-macro-country-field field=inflationPct so the registry-parity
test passes and the correlation harness sees the new construct.
tests/resilience-dimension-scorers.test.mts
- Dropped BIS-era wording ("non-BIS country") and test 266
(BIS-outage coverage 0.35 branch) which collapsed to the inflation-
only path post-retirement.
- Updated coverage assertions: inflation-only 0.45 → 0.55; inflation+
reserves 0.55 → 0.85.
tests/resilience-scorers.test.mts
- domainAverages.economic 68.33 → 66.33 (US currencyExternal score
shifts slightly under IMF+reserves vs old BIS composite).
- stressScore 67.85 → 67.21; stressFactor 0.3215 → 0.3279.
- overallScore 65.82 → 65.52.
- baselineScore unchanged (currencyExternal is stress-only).
All 6324 data-tier tests pass. typecheck:api clean. No change to
seeders or Redis keys; this is a pure scorer + registry rebuild.
* feat(resilience): PR 3 §3.5 point 3 — re-goalpost externalDebtCoverage (0..5 → 0..2)
Plan §2.1 diagnosis table showed externalDebtCoverage saturating at
score=100 across all 9 probe countries — including stressed states.
Signal was collapsed. Root cause: (worst=5, best=0) gave every country
with ratio < 0.5 a score above 90, and mapped Greenspan-Guidotti's
reserve-adequacy threshold (ratio=1.0) to score 80 — well into "no
worry" territory instead of the "mild warning" it should be.
Re-anchored on Greenspan-Guidotti directly: ratio=1.0 now maps to score
50 (mild warning), ratio=2.0 to score 0 (acute rollover-shock exposure).
Ratios above 2.0 clamp to 0, consistent with "beyond this point the
country is already in crisis; exact value stops mattering."
Files changed:
- _indicator-registry.ts: recoveryDebtToReserves goalposts
{worst: 5, best: 0} → {worst: 2, best: 0}. Description updated to
cite Greenspan-Guidotti; inline comment documents anchor + rationale.
- _dimension-scorers.ts: scoreExternalDebtCoverage normalizer bound
changed from (0..5) to (0..2), with inline comment.
- docs/methodology/country-resilience-index.mdx: goalpost table row
5-0 → 2-0, description cites Greenspan-Guidotti.
- docs/methodology/indicator-sources.yaml:
* constructStatus: dead-signal → observed-mechanism (signal is now
discriminating).
* reviewNotes updated to describe the new anchor.
* mechanismTestRationale names the Greenspan-Guidotti rule.
- tests/resilience-dimension-monotonicity.test.mts: updated the
comment + picked values inside the (0..2) discriminating band (0.3
and 1.5). Old values (1 vs 4) had 4 clamping to 0.
- tests/resilience-dimension-scorers.test.mts: NO score threshold
relaxed >90 → >=85 (NO ratio=0.2 now scores 90, was 96).
- tests/resilience-scorers.test.mts: fixture drift:
* domainAverages.recovery 54.83 → 47.33 (US extDebt 70 → 25).
* baselineScore 63.63 → 60.12 (extDebt is baseline type).
* overallScore 65.52 → 63.27.
* stressScore / stressFactor unchanged (extDebt is baseline-only).
All 6324 data-tier tests pass. typecheck:api clean.
* feat(resilience): PR 3 §3.6 — CI gate on indicator coverage and nominal weight
Plan §3.6 adds a new acceptance criterion (also §5 item 5):
> No indicator with observed coverage below 70% may exceed 5% nominal
> weight OR 5% effective influence in the post-change sensitivity run.
This commit enforces the NOMINAL-WEIGHT half as a unit test that runs
on every CI build. The EFFECTIVE-INFLUENCE half is produced by
scripts/validate-resilience-sensitivity.mjs as a committed artifact;
the gate file only asserts that script still exists so a refactor that
removes it breaks the build loudly.
Why the gate exists (plan §3.6):
"A dimension at 30% observed coverage carries the same effective
weight as one at 95%. This contradicts the OECD/JRC handbook on
uncertainty analysis."
Implementation:
tests/resilience-coverage-influence-gate.test.mts — three tests:
1. Nominal-weight gate: for every core indicator with coverage < 137
countries (70% of the ~195-country universe), computes its nominal
overall weight as
indicator.weight × (1/dimensions-in-domain) × domain-weight
and asserts it does not exceed 5%. Equal-share-per-dimension is
the *upper bound* on runtime weight (coverage-weighted mean gives
a lower share when a dimension drops out), so this is a strict
bound: if the nominal number passes, the runtime number also
passes for every country.
2. Effective-influence contract: asserts the sensitivity script
exists at its expected path. Removing it (intentionally or by
refactor) breaks the build.
3. Audit visibility: prints the top 10 core indicators by nominal
overall weight. No assertion beyond "ran" — the list lets
reviewers spot outliers that pass the gate but are near the cap.
Current state (observed from audit output):
recoveryReserveMonths: nominal=4.17% coverage=188
recoveryDebtToReserves: nominal=4.17% coverage=185
recoveryImportHhi: nominal=4.17% coverage=190
inflationStability: nominal=3.40% coverage=185
electricityConsumption: nominal=3.30% coverage=217
ucdpConflict: nominal=3.09% coverage=193
Every core indicator has coverage ≥ 180 (already enforced by the
pre-existing indicator-tiering test), so the nominal-weight gate has
no current violators — its purpose is catching future drift, not
flagging today's state.
All 6327 data-tier tests pass. typecheck:api clean.
* docs(resilience): PR 3 methodology doc — document §3.5 dead-signal retirements + §3.6 coverage gate
Methodology-doc update capturing the three §3.5 landings and the §3.6 CI
gate. Five edits:
1. **Known construct limitations section (#5 and #6):** strikethrough the
original "dead signals" and "no coverage-based weight cap" items,
annotate them with "Landed in PR 3 §3.5"/"Landed in PR 3 §3.6" +
specifics of what shipped.
2. **Currency & External H4 section:** completely rewritten. Old table
(fxVolatility / fxDeviation / fxReservesAdequacy on BIS primary) is
replaced by the two-indicator post-PR-3 table (inflationStability at
0.60 + fxReservesAdequacy at 0.40). Coverage ladder spelled out
(0.85 / 0.55 / 0.40 / 0.30). Legacy BIS indicators named as
experimental-tier drill-downs only.
3. **Fuel Stock Days H4 section:** H4 heading text kept verbatim so the
methodology-lint H4-to-dimension mapping does not break; body
rewritten to explain that the dimension is retired from core but the
seeder still runs for IEA-member drill-downs.
4. **External Debt Coverage table row:** goalpost 5-0 → 2-0, description
cites Greenspan-Guidotti reserve-adequacy rule.
5. **New v2.2 changelog entry** — PR 3 dead-signal cleanup, covering
§3.5 points 1/2/3 + §3.6 + acceptance gates + construct-audit
updates.
No scoring or code changes in this commit. Methodology-lint test passes
(H4 mapping intact). All 6327 data-tier tests pass.
* fix(resilience): PR 3 §3.6 gate — correct share-denominator for coverage-weighted aggregation
Reviewer catch (thanks). The previous gate computed each indicator's
nominal overall weight as
indicator.weight × (1 / N_total_dimensions_in_domain) × domain_weight
and claimed this was an upper bound ("actual runtime weight is ≤ this
when some dimensions drop out on coverage"). That is BACKWARDS for
this scorer.
The domain aggregation is coverage-weighted
(server/worldmonitor/resilience/v1/_shared.ts coverageWeightedMean),
so when a dimension pins at coverage=0 it is EXCLUDED from the
denominator and the surviving dimensions' shares go UP, not down.
PR 3 commit 1 retires fuelStockDays by hard-coding its scorer to
coverage=0 for every country — so in the current live state the
recovery domain has 5 contributing dimensions (not 6), and each core
recovery indicator's nominal share is
1.0 × 1/5 × 0.25 = 5.00% (was mis-reported as 4.17%)
The old gate therefore under-estimated nominal influence and could
silently pass exactly the kind of low-coverage overweight regression
it is meant to block.
Fix:
- Added `coreBearingDimensions(domainId)` helper that counts only
dimensions that have ≥1 core indicator in the registry. A dimension
with only experimental/enrichment entries (post-retirement
fuelStockDays) has no core contribution → does not dilute shares.
- Updated `nominalOverallWeight` to divide by the core-bearing count,
not the raw dimension count.
- Rewrote the helper's doc comment to stop claiming this is a strict
upper bound — explicitly calls out the dynamic case (source failure
raising surviving dim shares further) as the sensitivity script's
responsibility.
- Added a new regression test: asserts (a) at least one recovery
dimension is all-non-core (fuelStockDays post-retirement),
(b) fuelStockDays has zero core indicators, and (c) recoveryDebt
ToReserves nominal = 0.05 exactly (not 0.0417) — any reversion
of the retirement or regression to N_total-denominator will fail
loudly.
Top-10 audit output now correctly shows:
recoveryReserveMonths: nominal=5% coverage=188
recoveryDebtToReserves: nominal=5% coverage=185
recoveryImportHhi: nominal=5% coverage=190
(was 4.17% each under the old math)
All 486 resilience tests pass. typecheck:api clean.
Note: the 5% figure is exactly AT the cap, not over it. "exceed" means
strictly > 5%, so it still passes. But now the reviewer / audit log
reflects reality.
* fix(resilience): PR 3 review — retired-dim confidence drag + false source-failure label
Addresses the Codex review P1 + P2 on PR #3297.
P1 — retired-dim drag on confidence averages
--------------------------------------------
scoreFuelStockDays returns coverage=0 by design (retired construct),
but computeLowConfidence, computeOverallCoverage, and the widget's
formatResilienceConfidence averaged across all 19 dimensions. That
dragged every country's reported averageCoverage down — US went from
0.8556 (active dims only) to 0.8105 (all dims) — enough drift to
misclassify edge countries as lowConfidence and to shift the ranking
widget's overallCoverage pill for every country.
Fix: introduce an authoritative RESILIENCE_RETIRED_DIMENSIONS set in
_dimension-scorers.ts and filter it out of all three averages. The
filter is keyed on the retired-dim REGISTRY, not on coverage === 0,
because a non-retired dim can legitimately emit coverage=0 on a
genuinely sparse-data country via weightedBlend fall-through — those
entries MUST keep dragging confidence down (that is the sparse-data
signal lowConfidence exists to surface). Verified: sparse-country
release-gate test (marks sparse WHO/FAO countries as low confidence)
still passes with the registry-keyed filter; would have failed with
a naive coverage=0 filter.
Server-client parity: widget-utils cannot import server code, so
RESILIENCE_RETIRED_DIMENSION_IDS is a hand-mirrored constant, kept
in lockstep by tests/resilience-retired-dimensions-parity.test.mts
(parses the widget file as text, same pattern as existing widget-util
tests that can't import the widget module directly).
P2 — false "Source down" label on retired dim
---------------------------------------------
scoreFuelStockDays hard-coded imputationClass: 'source-failure',
which the widget maps to "Source down: upstream seeder failed" with
a `!` icon for every country. That is semantically wrong for an
intentional retirement. Flipped to null so the widget's absent-path
renders a neutral cell without a false outage label. null is already
a legal value of ResilienceDimensionScore.imputationClass; no type
change needed.
Tests
-----
- tests/resilience-confidence-averaging.test.mts (new): pins the
registry-keyed filter semantic for computeOverallCoverage +
computeLowConfidence. Includes a negative-control test proving
non-retired coverage=0 dims still flip lowConfidence.
- tests/resilience-retired-dimensions-parity.test.mts (new):
lockstep gate between server and client retired-dim lists.
- Widget test adds a registry-keyed exclusion test with a non-retired
coverage=0 dim in the fixture to lock in the correct semantic.
- Existing tests asserting imputationClass: 'source-failure' for
fuelStockDays flipped to null.
All 494 resilience tests + full 6336/6336 data-tier suite pass.
Typecheck clean for both tsconfig.json and tsconfig.api.json.
* docs(resilience): align methodology + registry metadata with shipped imputationClass=null
Follow-up to the previous PR 3 review commit that flipped
scoreFuelStockDays's imputationClass from 'source-failure' to null to
avoid a false "Source down" widget label on every country. The code
changed; the doc and registry metadata did not, leaving three sites
in the methodology mdx and two comment/description sites in the
registry still claiming imputationClass='source-failure'. Any future
reviewer (or tooling that treats the registry description as
authoritative) would be misled.
This commit rewrites those sites to describe the shipped behavior:
- imputationClass=null (not 'source-failure'), with the rationale
- exclusion from confidence/coverage averages via the
RESILIENCE_RETIRED_DIMENSIONS registry filter
- the distinction between structural retirement (filtered) and
runtime coverage=0 (kept so sparse-data countries still flag
lowConfidence)
Touched:
- docs/methodology/country-resilience-index.mdx (lines ~33, ~268, ~590)
- server/worldmonitor/resilience/v1/_indicator-registry.ts
(recoveryFuelStockDays comment block + description field)
No code-behavior change. Docs-only.
Tests: 157 targeted resilience tests pass (incl. methodology-lint +
widget + release-gate + confidence-averaging). Typecheck clean on
both tsconfig.json and tsconfig.api.json.
1189 lines
67 KiB
TypeScript
1189 lines
67 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import { describe, it } from 'node:test';
|
||
|
||
import {
|
||
IMPUTATION,
|
||
IMPUTE,
|
||
type ImputationClass,
|
||
RESILIENCE_DIMENSION_ORDER,
|
||
RESILIENCE_DIMENSION_TYPES,
|
||
scoreAllDimensions,
|
||
scoreBorderSecurity,
|
||
scoreCurrencyExternal,
|
||
scoreCyberDigital,
|
||
scoreEnergy,
|
||
scoreFoodWater,
|
||
scoreGovernanceInstitutional,
|
||
scoreHealthPublicService,
|
||
scoreInformationCognitive,
|
||
scoreInfrastructure,
|
||
scoreLogisticsSupply,
|
||
scoreExternalDebtCoverage,
|
||
scoreFiscalSpace,
|
||
scoreFuelStockDays,
|
||
scoreImportConcentration,
|
||
scoreMacroFiscal,
|
||
scoreReserveAdequacy,
|
||
scoreSocialCohesion,
|
||
scoreStateContinuity,
|
||
scoreTradeSanctions,
|
||
} from '../server/worldmonitor/resilience/v1/_dimension-scorers.ts';
|
||
import { RESILIENCE_FIXTURES, fixtureReader } from './helpers/resilience-fixtures.mts';
|
||
|
||
async function scoreTriple(
|
||
scorer: (countryCode: string, reader?: (key: string) => Promise<unknown | null>) => Promise<{ score: number; coverage: number; observedWeight: number; imputedWeight: number; imputationClass: ImputationClass | null; freshness: { lastObservedAtMs: number; staleness: '' | 'fresh' | 'aging' | 'stale' } }>,
|
||
) {
|
||
const [no, us, ye] = await Promise.all([
|
||
scorer('NO', fixtureReader),
|
||
scorer('US', fixtureReader),
|
||
scorer('YE', fixtureReader),
|
||
]);
|
||
return { no, us, ye };
|
||
}
|
||
|
||
function assertOrdered(label: string, no: number, us: number, ye: number) {
|
||
assert.ok(no >= us, `${label}: expected NO (${no}) >= US (${us})`);
|
||
assert.ok(us > ye, `${label}: expected US (${us}) > YE (${ye})`);
|
||
}
|
||
|
||
describe('resilience dimension scorers', () => {
|
||
it('produce plausible country ordering for the economic dimensions', async () => {
|
||
const macro = await scoreTriple(scoreMacroFiscal);
|
||
const currency = await scoreTriple(scoreCurrencyExternal);
|
||
const trade = await scoreTriple(scoreTradeSanctions);
|
||
|
||
assertOrdered('macroFiscal', macro.no.score, macro.us.score, macro.ye.score);
|
||
assertOrdered('currencyExternal', currency.no.score, currency.us.score, currency.ye.score);
|
||
assertOrdered('tradeSanctions', trade.no.score, trade.us.score, trade.ye.score);
|
||
});
|
||
|
||
it('produce plausible country ordering for infrastructure and energy', async () => {
|
||
const cyber = await scoreTriple(scoreCyberDigital);
|
||
const logistics = await scoreTriple(scoreLogisticsSupply);
|
||
const infrastructure = await scoreTriple(scoreInfrastructure);
|
||
const energy = await scoreTriple(scoreEnergy);
|
||
|
||
assertOrdered('cyberDigital', cyber.no.score, cyber.us.score, cyber.ye.score);
|
||
assertOrdered('logisticsSupply', logistics.no.score, logistics.us.score, logistics.ye.score);
|
||
assertOrdered('infrastructure', infrastructure.no.score, infrastructure.us.score, infrastructure.ye.score);
|
||
assertOrdered('energy', energy.no.score, energy.us.score, energy.ye.score);
|
||
});
|
||
|
||
it('produce plausible country ordering for social, governance, health, and food dimensions', async () => {
|
||
const governance = await scoreTriple(scoreGovernanceInstitutional);
|
||
const social = await scoreTriple(scoreSocialCohesion);
|
||
const border = await scoreTriple(scoreBorderSecurity);
|
||
const information = await scoreTriple(scoreInformationCognitive);
|
||
const health = await scoreTriple(scoreHealthPublicService);
|
||
const foodWater = await scoreTriple(scoreFoodWater);
|
||
|
||
assertOrdered('governanceInstitutional', governance.no.score, governance.us.score, governance.ye.score);
|
||
assertOrdered('socialCohesion', social.no.score, social.us.score, social.ye.score);
|
||
assertOrdered('borderSecurity', border.no.score, border.us.score, border.ye.score);
|
||
assertOrdered('informationCognitive', information.no.score, information.us.score, information.ye.score);
|
||
assertOrdered('healthPublicService', health.no.score, health.us.score, health.ye.score);
|
||
assertOrdered('foodWater', foodWater.no.score, foodWater.us.score, foodWater.ye.score);
|
||
});
|
||
|
||
it('returns all 19 dimensions with bounded scores and coverage', async () => {
|
||
const dimensions = await scoreAllDimensions('US', fixtureReader);
|
||
|
||
assert.deepEqual(Object.keys(dimensions).sort(), [...RESILIENCE_DIMENSION_ORDER].sort());
|
||
for (const dimensionId of RESILIENCE_DIMENSION_ORDER) {
|
||
const result = dimensions[dimensionId];
|
||
assert.ok(result.score >= 0 && result.score <= 100, `${dimensionId} score out of bounds: ${result.score}`);
|
||
assert.ok(result.coverage >= 0 && result.coverage <= 1, `${dimensionId} coverage out of bounds: ${result.coverage}`);
|
||
}
|
||
});
|
||
|
||
it('scoreEnergy with full data uses 7-metric blend and high coverage', async () => {
|
||
const no = await scoreEnergy('NO', fixtureReader);
|
||
assert.ok(no.coverage >= 0.85, `NO coverage should be >=0.85 with full data, got ${no.coverage}`);
|
||
assert.ok(no.score > 50, `NO score should be >50 (high renewables, low dependency), got ${no.score}`);
|
||
});
|
||
|
||
it('scoreEnergy without OWID mix data degrades gracefully to 4-metric blend', async () => {
|
||
const noOwidReader = async (key: string) => {
|
||
if (key.startsWith('energy:mix:v1:')) return null;
|
||
return RESILIENCE_FIXTURES[key] ?? null;
|
||
};
|
||
const no = await scoreEnergy('NO', noOwidReader);
|
||
assert.ok(no.coverage > 0, `Coverage should be >0 even without OWID data, got ${no.coverage}`);
|
||
// dep (0.25) + energyStress (0.10) + electricityConsumption (0.30) = 0.65 of 1.00 total
|
||
assert.ok(no.coverage < 0.75, `Coverage should be <0.75 without mix data (3 of 7 metrics), got ${no.coverage}`);
|
||
assert.ok(no.score > 0, `Score should be non-zero with only iea + electricity data, got ${no.score}`);
|
||
});
|
||
|
||
it('scoreEnergy: high renewShare country scores better than high coalShare at equal dependency', async () => {
|
||
const renewableReader = async (key: string) => {
|
||
if (key === 'resilience:static:XX') return { iea: { energyImportDependency: { value: 50 } } };
|
||
if (key === 'energy:mix:v1:XX') return { gasShare: 5, coalShare: 0, renewShare: 90 };
|
||
if (key === 'economic:energy:v1:all') return null;
|
||
return null;
|
||
};
|
||
const fossilReader = async (key: string) => {
|
||
if (key === 'resilience:static:XX') return { iea: { energyImportDependency: { value: 50 } } };
|
||
if (key === 'energy:mix:v1:XX') return { gasShare: 5, coalShare: 80, renewShare: 5 };
|
||
if (key === 'economic:energy:v1:all') return null;
|
||
return null;
|
||
};
|
||
const renewable = await scoreEnergy('XX', renewableReader);
|
||
const fossil = await scoreEnergy('XX', fossilReader);
|
||
assert.ok(renewable.score > fossil.score,
|
||
`Renewable-heavy (${renewable.score}) should score better than coal-heavy (${fossil.score})`);
|
||
});
|
||
|
||
it('Lebanon-like profile: null IEA (Eurostat EU-only gap) + crisis-level electricity → energy < 50', async () => {
|
||
// Pre-fix, Lebanon scored ~89 on energy because: Eurostat is EU-only → dependency=null
|
||
// (missing 0.25 weight), and OWID showed low fossil use during crisis → appeared "clean".
|
||
// Fix: EG.USE.ELEC.KH.PC captures grid collapse (1200 kWh/cap vs USA 12000).
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:LB') return RESILIENCE_FIXTURES['resilience:static:LB'];
|
||
if (key === 'energy:mix:v1:LB') return RESILIENCE_FIXTURES['energy:mix:v1:LB'];
|
||
if (key === 'economic:energy:v1:all') return RESILIENCE_FIXTURES['economic:energy:v1:all'];
|
||
return null;
|
||
};
|
||
const score = await scoreEnergy('LB', reader);
|
||
assert.ok(score.score < 50, `Lebanon energy should be < 50 with crisis-level consumption (null IEA), got ${score.score}`);
|
||
assert.ok(score.coverage > 0, 'should have non-zero coverage even with null IEA');
|
||
});
|
||
|
||
it('scoreTradeSanctions: country with 0 OFAC designations scores 100 (full-count key, not imputed)', async () => {
|
||
// country-counts:v1 covers ALL countries. A country absent from the map has 0 designations
|
||
// which is a real data point (score=100), not an imputed absence.
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'sanctions:country-counts:v1') return { RU: 500, IR: 350 }; // FI absent = 0
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [] };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [] };
|
||
return null;
|
||
};
|
||
const score = await scoreTradeSanctions('FI', reader);
|
||
assert.equal(score.score, 100, 'FI with 0 designations must score 100 (not sanctioned)');
|
||
// WB tariff rate absent (no static record) reduces coverage from 1.0 to 0.75
|
||
assert.equal(score.coverage, 0.75, 'coverage reflects missing WB tariff rate');
|
||
});
|
||
|
||
it('scoreTradeSanctions: heavily sanctioned country scores low', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'sanctions:country-counts:v1') return { RU: 500 };
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [] };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [] };
|
||
return null;
|
||
};
|
||
const score = await scoreTradeSanctions('RU', reader);
|
||
// Sanctions metric alone = 0 (score floored); WTO sources are empty (no restrictions = 100).
|
||
// Available: 0.45+0.15+0.15 = 0.75. Score: (0*0.45 + 100*0.15 + 100*0.15)/0.75 = 40.
|
||
assert.ok(score.score < 55, `RU with 500 designations should score below midpoint, got ${score.score}`);
|
||
});
|
||
|
||
it('scoreTradeSanctions: seed outage (null source) does not impute as country-absent', async () => {
|
||
const reader = async (_key: string): Promise<unknown | null> => null;
|
||
const score = await scoreTradeSanctions('FI', reader);
|
||
assert.equal(score.coverage, 0, `seed outage must give coverage=0, got ${score.coverage}`);
|
||
assert.equal(score.score, 0, `seed outage must give score=0, got ${score.score}`);
|
||
});
|
||
|
||
it('scoreTradeSanctions: reporter-set country with zero restrictions scores 100 (real data)', async () => {
|
||
const reporterSet = ['US', 'CN', 'DE', 'JP', 'GB', 'IN', 'BR', 'RU', 'KR', 'AU', 'CA', 'MX', 'FR', 'IT', 'NL'];
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'sanctions:country-counts:v1') return {};
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [], _reporterCountries: reporterSet };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [], _reporterCountries: reporterSet };
|
||
return null;
|
||
};
|
||
const score = await scoreTradeSanctions('US', reader);
|
||
assert.equal(score.score, 100, 'reporter with 0 restrictions must score 100 (genuine zero)');
|
||
// WB tariff rate absent (no static record) reduces coverage from 1.0 to 0.75
|
||
assert.equal(score.coverage, 0.75, 'coverage reflects missing WB tariff rate');
|
||
});
|
||
|
||
it('scoreTradeSanctions: non-reporter country gets IMPUTE.wtoData (blended score=84, coverage=0.57)', async () => {
|
||
const reporterSet = ['US', 'CN', 'DE', 'JP', 'GB', 'IN', 'BR', 'RU', 'KR', 'AU', 'CA', 'MX', 'FR', 'IT', 'NL'];
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'sanctions:country-counts:v1') return {};
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [], _reporterCountries: reporterSet };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [], _reporterCountries: reporterSet };
|
||
return null;
|
||
};
|
||
const score = await scoreTradeSanctions('BF', reader);
|
||
// BF (Burkina Faso) not in reporter set: sanctions=100 (0 designations, weight 0.45),
|
||
// restrictions=60 (imputed, weight 0.15, cc=0.4), barriers=60 (imputed, weight 0.15, cc=0.4),
|
||
// WB tariff=null (weight 0.25). Available weight = 0.75.
|
||
// Blended score: (100*0.45 + 60*0.15 + 60*0.15) / 0.75 = 84
|
||
assert.equal(score.score, 84, 'non-reporter blended with sanctions=100 and imputed WTO=60');
|
||
// Coverage: (1.0*0.45 + 0.4*0.15 + 0.4*0.15 + 0*0.25) / 1.0 = 0.57
|
||
assert.equal(score.coverage, 0.57, 'non-reporter coverage reflects imputed WTO metrics and absent tariff');
|
||
});
|
||
|
||
it('scoreTradeSanctions: WTO seed outage returns null for both trade metrics', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'sanctions:country-counts:v1') return { US: 10 };
|
||
return null;
|
||
};
|
||
const score = await scoreTradeSanctions('US', reader);
|
||
// Only sanctions loaded (weight 0.45). WTO restrictions + barriers + WB tariff null.
|
||
assert.ok(score.score > 0, 'sanctions data alone produces non-zero score');
|
||
assert.ok(score.coverage > 0.4 && score.coverage < 0.5,
|
||
`coverage should be ~0.45 (only sanctions loaded), got ${score.coverage}`);
|
||
});
|
||
|
||
it('scoreCurrencyExternal: no IMF and no reserves → curated_list_absent imputation (score 50)', async () => {
|
||
// PR 3 §3.5: BIS retired. Without IMF inflation or WB reserves,
|
||
// scorer falls through to IMPUTE.bisEer (kept for snapshot continuity).
|
||
const reader = async (_key: string): Promise<unknown | null> => null;
|
||
const score = await scoreCurrencyExternal('MZ', reader);
|
||
assert.equal(score.score, 50, 'curated_list_absent must impute score=50 when IMF+reserves missing');
|
||
assert.equal(score.coverage, 0.3, 'curated_list_absent certaintyCoverage=0.3');
|
||
});
|
||
|
||
it('scoreCurrencyExternal: IMF inflation only (no reserves) uses inflation proxy (coverage 0.55)', async () => {
|
||
// PR 3 §3.5: BIS retired. IMF inflation alone gives inflation-only path (0.55).
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'economic:imf:macro:v2') return { countries: { MZ: { inflationPct: 8, currentAccountPct: -5, year: 2024 } } };
|
||
return null;
|
||
};
|
||
const score = await scoreCurrencyExternal('MZ', reader);
|
||
// normalizeLowerBetter(min(8,50), 0, 50) = (50-8)/50*100 = 84
|
||
assert.equal(score.score, 84, 'low-inflation country gets high currency score via IMF proxy');
|
||
assert.equal(score.coverage, 0.55, 'IMF inflation only (no reserves) → coverage 0.55');
|
||
});
|
||
|
||
it('scoreCurrencyExternal: hyperinflation is capped at score 0 (inflation-only path)', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'economic:imf:macro:v2') return { countries: { ZW: { inflationPct: 250, currentAccountPct: -8, year: 2024 } } };
|
||
return null;
|
||
};
|
||
const score = await scoreCurrencyExternal('ZW', reader);
|
||
// min(250, 50) = 50 → normalizeLowerBetter(50, 0, 50) = 0
|
||
assert.equal(score.score, 0, 'hyperinflation ≥50% is capped → score 0');
|
||
assert.equal(score.coverage, 0.55, 'hyperinflation still gets IMF inflation-only coverage 0.55');
|
||
});
|
||
|
||
it('scoreCurrencyExternal: both BIS and IMF null → curated_list_absent imputation (T1.7)', async () => {
|
||
// Post-T1.7 source-failure wiring: the legacy absence-based branch
|
||
// (score=50, imputationClass=null, coverage=0) is gone. Now a country
|
||
// with no BIS, no IMF inflation, no WB reserves falls through to the
|
||
// curated_list_absent taxonomy entry (unmonitored) so the aggregation
|
||
// pass can re-tag it as source-failure when the seed adapter fails.
|
||
const reader = async (_key: string): Promise<unknown | null> => null;
|
||
const score = await scoreCurrencyExternal('MZ', reader);
|
||
assert.equal(score.score, IMPUTE.bisEer.score,
|
||
'both sources null → curated_list_absent score (50)');
|
||
assert.equal(score.coverage, IMPUTE.bisEer.certaintyCoverage,
|
||
'both sources null → curated_list_absent coverage (0.3)');
|
||
assert.equal(score.observedWeight, 0, 'no observed data');
|
||
assert.equal(score.imputedWeight, 1, 'imputed fallback carries full weight');
|
||
assert.equal(score.imputationClass, 'unmonitored',
|
||
'curated_list_absent → unmonitored per taxonomy');
|
||
});
|
||
|
||
it('scoreCurrencyExternal: FX reserves contribute to score alongside BIS data', async () => {
|
||
const withReserves = await scoreCurrencyExternal('NO', fixtureReader);
|
||
const readerNoReserves = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:NO') {
|
||
const base = RESILIENCE_FIXTURES['resilience:static:NO'] as Record<string, unknown>;
|
||
return { ...base, fxReservesMonths: null };
|
||
}
|
||
return fixtureReader(key);
|
||
};
|
||
const withoutReserves = await scoreCurrencyExternal('NO', readerNoReserves);
|
||
assert.ok(withReserves.score !== withoutReserves.score, 'reserves data must change the BIS-country score');
|
||
assert.ok(withReserves.coverage > 0, 'coverage must be positive with BIS + reserves');
|
||
});
|
||
|
||
it('scoreCurrencyExternal: good reserves score higher than bad reserves (inflation+reserves path)', async () => {
|
||
// PR 3 §3.5: BIS retired. inflation+reserves path → coverage 0.85.
|
||
const makeReader = (months: number) => async (key: string): Promise<unknown | null> => {
|
||
if (key === 'economic:imf:macro:v2') return { countries: { MZ: { inflationPct: 15, currentAccountPct: -5, year: 2024 } } };
|
||
if (key === 'resilience:static:MZ') return { fxReservesMonths: { source: 'worldbank', months, year: 2023 } };
|
||
return null;
|
||
};
|
||
const goodRes = await scoreCurrencyExternal('MZ', makeReader(12));
|
||
const badRes = await scoreCurrencyExternal('MZ', makeReader(1.5));
|
||
assert.ok(goodRes.score > badRes.score, `good reserves (${goodRes.score}) must score higher than bad (${badRes.score})`);
|
||
assert.equal(goodRes.coverage, badRes.coverage, 'coverage should be the same when both have inflation+reserves');
|
||
assert.equal(goodRes.coverage, 0.85, 'inflation+reserves path gets coverage=0.85');
|
||
});
|
||
|
||
it('scoreMacroFiscal: IMF current account loaded, surplus country scores higher than deficit', async () => {
|
||
const makeReader = (caPct: number) => async (key: string): Promise<unknown | null> => {
|
||
if (key === 'economic:national-debt:v1') return { entries: [{ iso3: 'HRV', debtToGdp: 70, annualGrowth: 1.5 }] };
|
||
if (key === 'economic:imf:macro:v2') return { countries: { HR: { inflationPct: 3.0, currentAccountPct: caPct, govRevenuePct: 40, year: 2024 } } };
|
||
if (key === 'economic:imf:labor:v1') return { countries: { HR: { unemploymentPct: 7, populationMillions: 4, year: 2024 } } };
|
||
if (key === 'economic:bis:dsr:v1') return { entries: [] };
|
||
return null;
|
||
};
|
||
const surplus = await scoreMacroFiscal('HR', makeReader(10));
|
||
const deficit = await scoreMacroFiscal('HR', makeReader(-15));
|
||
assert.ok(surplus.score > deficit.score, `surplus (${surplus.score}) must score higher than deficit (${deficit.score})`);
|
||
// BIS DSR has weight 0.05 and is absent for HR (no BIS coverage); the
|
||
// remaining 0.95 of weight is observed → coverage=0.95, not 1.0.
|
||
assert.equal(surplus.coverage, 0.95, 'all non-BIS data → coverage=0.95 (DSR=0.05 absent for HR)');
|
||
});
|
||
|
||
it('scoreMacroFiscal: IMF macro seed outage does not impute — debt growth still scores', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'economic:national-debt:v1') return { entries: [{ iso3: 'HRV', debtToGdp: 70, annualGrowth: 1.5 }] };
|
||
return null; // economic:imf:macro:v1 + economic:imf:labor:v1 null = seed outage
|
||
};
|
||
const score = await scoreMacroFiscal('HR', reader);
|
||
// govRevenuePct (0.4), currentAccountPct (0.25) come from IMF macro (null = outage).
|
||
// unemploymentPct (0.15) comes from IMF labor (null = outage).
|
||
// Only debtGrowth (weight=0.2) has real data → coverage = 0.2.
|
||
assert.ok(score.coverage > 0.15 && score.coverage < 0.25,
|
||
`coverage should be ~0.2 (debt growth only, IMF outage), got ${score.coverage}`);
|
||
assert.ok(score.score > 0, 'debt growth data alone should produce a non-zero score');
|
||
});
|
||
|
||
it('scoreMacroFiscal: IMF labor LUR sub-metric — high unemployment lowers macroFiscal score', async () => {
|
||
const baseFixtures = {
|
||
'economic:national-debt:v1': { entries: [{ iso3: 'HRV', debtToGdp: 70, annualGrowth: 1.5 }] },
|
||
'economic:imf:macro:v2': { countries: { HR: { inflationPct: 3.0, currentAccountPct: 1.0, govRevenuePct: 40, year: 2024 } } },
|
||
};
|
||
const makeReader = (lur: number) => async (key: string): Promise<unknown | null> => {
|
||
if (key in baseFixtures) return (baseFixtures as Record<string, unknown>)[key];
|
||
if (key === 'economic:imf:labor:v1') return { countries: { HR: { unemploymentPct: lur, populationMillions: 4, year: 2024 } } };
|
||
if (key === 'economic:bis:dsr:v1') return { entries: [{ countryCode: 'HR', dsrPct: 8, date: '2024-Q4' }] };
|
||
return null;
|
||
};
|
||
const tightLabor = await scoreMacroFiscal('HR', makeReader(3.5));
|
||
const slackLabor = await scoreMacroFiscal('HR', makeReader(20));
|
||
assert.ok(tightLabor.score > slackLabor.score,
|
||
`tight labor (LUR=3.5%, score=${tightLabor.score}) must outrank slack (LUR=20%, score=${slackLabor.score})`);
|
||
assert.equal(tightLabor.coverage, 1, 'all five sub-metrics observed → coverage=1');
|
||
assert.equal(slackLabor.coverage, 1, 'all five sub-metrics observed → coverage=1');
|
||
});
|
||
|
||
it('scoreFoodWater: country absent from FAO/IPC DB gets crisis_monitoring_absent imputation (not WGI proxy)', async () => {
|
||
// IPC/HDX only covers countries IN active food crisis. A country absent from the database
|
||
// is not monitored because it is stable — that is a positive signal (crisis_monitoring_absent),
|
||
// not an unknown gap. The imputed score must come from the absence type, NOT from WGI data.
|
||
const readerWithWgi = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
wgi: { indicators: { 'VA.EST': { value: 1.2, year: 2025 } } },
|
||
fao: null,
|
||
aquastat: null,
|
||
};
|
||
return null;
|
||
};
|
||
const readerWithoutWgi = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return { fao: null, aquastat: null };
|
||
return null;
|
||
};
|
||
const withWgi = await scoreFoodWater('XX', readerWithWgi);
|
||
const withoutWgi = await scoreFoodWater('XX', readerWithoutWgi);
|
||
|
||
// IPC food imputation: score=88, certaintyCoverage=0.7 on 0.6-weight IPC block.
|
||
// Aquastat absent: 0 coverage. Expected coverage = 0.7 × 0.6 = 0.42.
|
||
assert.equal(withWgi.score, 88, 'imputed score must be 88 (crisis_monitoring_absent for IPC food)');
|
||
assert.ok(withWgi.coverage > 0.3 && withWgi.coverage < 0.6,
|
||
`coverage should be ~0.42 (IPC imputation only), got ${withWgi.coverage}`);
|
||
|
||
// WGI must NOT influence the imputed food score — only absence type matters.
|
||
assert.equal(withWgi.score, withoutWgi.score, 'score must not change based on WGI presence (imputation is absence-type, not proxy)');
|
||
assert.equal(withWgi.coverage, withoutWgi.coverage, 'coverage must not change based on WGI presence');
|
||
});
|
||
|
||
it('scoreFoodWater: missing static bundle (seed outage) does not impute as crisis-free', async () => {
|
||
// resilience:static:XX key missing entirely = seeder never ran, not "country not in crisis".
|
||
// Must NOT trigger crisis_monitoring_absent imputation.
|
||
const reader = async (_key: string): Promise<unknown | null> => null;
|
||
const score = await scoreFoodWater('XX', reader);
|
||
assert.equal(score.coverage, 0, `missing static bundle must give coverage=0, got ${score.coverage}`);
|
||
assert.equal(score.score, 0, `missing static bundle must give score=0, got ${score.score}`);
|
||
});
|
||
|
||
it('scoreBorderSecurity: displacement source loaded but country absent → crisis_monitoring_absent imputation', async () => {
|
||
// Country not in UNHCR displacement registry = not a significant displacement case (positive signal).
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'conflict:ucdp-events:v1') return { events: [] };
|
||
if (key.startsWith('displacement:summary:v1:')) return { summary: { countries: [{ code: 'SY', totalDisplaced: 1e6, hostTotal: 5e5 }] } };
|
||
return null;
|
||
};
|
||
const score = await scoreBorderSecurity('FI', reader);
|
||
// ucdp loaded (no events, score=100, cc=1.0, weight=0.65) +
|
||
// displacement loaded, FI absent → impute (cc=0.6, weight=0.35)
|
||
// coverage = (1.0×0.65 + 0.6×0.35) / 1.0 = 0.86
|
||
assert.ok(score.coverage > 0.8, `expected coverage >0.8 with source loaded, got ${score.coverage}`);
|
||
});
|
||
|
||
it('scoreBorderSecurity: displacement seed outage does not impute', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'conflict:ucdp-events:v1') return { events: [] };
|
||
return null; // displacement source null = seed outage
|
||
};
|
||
const score = await scoreBorderSecurity('FI', reader);
|
||
// ucdp loaded (score=100, cc=1.0, weight=0.65) + displacement null (no imputation, cc=0)
|
||
// coverage = (1.0×0.65 + 0×0.35) / 1.0 = 0.65
|
||
assert.ok(score.coverage > 0.6 && score.coverage < 0.7,
|
||
`seed outage must not inflate coverage beyond ucdp weight, got ${score.coverage}`);
|
||
});
|
||
|
||
it('scoreCyberDigital: country with zero threats in loaded feed gets null, not 100', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'cyber:threats:v2') return { threats: [{ country: 'United States', severity: 'CRITICALITY_LEVEL_HIGH' }] };
|
||
if (key === 'infra:outages:v1') return { outages: [] };
|
||
if (key === 'intelligence:gpsjam:v2') return { hexes: [] };
|
||
return null;
|
||
};
|
||
const score = await scoreCyberDigital('FI', reader);
|
||
assert.equal(score.score, 0, 'zero events in all three loaded feeds must yield score=0 (not 100)');
|
||
assert.equal(score.coverage, 0, 'zero events in all three loaded feeds must yield coverage=0');
|
||
});
|
||
|
||
it('scoreCyberDigital: country with real threats scores normally', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'cyber:threats:v2') return { threats: [
|
||
{ country: 'Finland', severity: 'CRITICALITY_LEVEL_HIGH' },
|
||
{ country: 'Finland', severity: 'CRITICALITY_LEVEL_MEDIUM' },
|
||
] };
|
||
if (key === 'infra:outages:v1') return { outages: [{ countryCode: 'FI', severity: 'OUTAGE_SEVERITY_PARTIAL' }] };
|
||
if (key === 'intelligence:gpsjam:v2') return { hexes: [] };
|
||
return null;
|
||
};
|
||
const score = await scoreCyberDigital('FI', reader);
|
||
assert.ok(score.score > 0, `country with real threats must have score > 0, got ${score.score}`);
|
||
assert.ok(score.score < 100, `country with real threats must have score < 100, got ${score.score}`);
|
||
assert.ok(score.coverage > 0, `coverage should be > 0 with real data, got ${score.coverage}`);
|
||
});
|
||
|
||
it('scoreCyberDigital: feed outage (null source) returns score=0 and zero coverage', async () => {
|
||
const reader = async (_key: string): Promise<unknown | null> => null;
|
||
const score = await scoreCyberDigital('US', reader);
|
||
assert.equal(score.score, 0, 'all feeds null (seed outage) must yield score=0');
|
||
assert.equal(score.coverage, 0, 'all feeds null (seed outage) must yield coverage=0');
|
||
});
|
||
|
||
it('scoreInformationCognitive: correctly unwraps news:threat:summary:v1 { byCountry } envelope', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:US') return RESILIENCE_FIXTURES['resilience:static:US'];
|
||
if (key === 'intelligence:social:reddit:v1') return RESILIENCE_FIXTURES['intelligence:social:reddit:v1'];
|
||
if (key === 'news:threat:summary:v1') return {
|
||
byCountry: { US: { critical: 1, high: 3, medium: 2, low: 1 } },
|
||
generatedAt: '2026-04-06T00:00:00.000Z',
|
||
};
|
||
return null;
|
||
};
|
||
const score = await scoreInformationCognitive('US', reader);
|
||
assert.ok(score.score > 0, `should produce a score with wrapped payload, got ${score.score}`);
|
||
assert.ok(score.coverage > 0, `should have coverage with threat data present, got ${score.coverage}`);
|
||
});
|
||
|
||
it('scoreInformationCognitive: zero news threats in loaded feed gets null', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return { rsf: { score: 80, rank: 20, year: 2025 } };
|
||
if (key === 'intelligence:social:reddit:v1') return { posts: [] };
|
||
if (key === 'news:threat:summary:v1') return {
|
||
byCountry: { US: { critical: 1, high: 2, medium: 3, low: 1 } },
|
||
generatedAt: '2026-04-06T00:00:00.000Z',
|
||
};
|
||
return null;
|
||
};
|
||
const score = await scoreInformationCognitive('XX', reader);
|
||
assert.ok(score.score === 20, `RSF only (no threat, no velocity), got ${score.score}`);
|
||
});
|
||
|
||
it('scoreBorderSecurity: zero UCDP events still scores (UCDP is global registry)', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'conflict:ucdp-events:v1') return { events: [] };
|
||
if (key.startsWith('displacement:summary:v1:')) return { summary: { countries: [] } };
|
||
return null;
|
||
};
|
||
const score = await scoreBorderSecurity('FI', reader);
|
||
assert.ok(score.coverage > 0, `UCDP loaded with zero events must still contribute to coverage, got ${score.coverage}`);
|
||
assert.ok(score.score > 50, `zero UCDP events = peaceful country, should score high, got ${score.score}`);
|
||
});
|
||
|
||
it('memoizes repeated seed reads inside scoreAllDimensions', async () => {
|
||
const hits = new Map<string, number>();
|
||
const countingReader = async (key: string) => {
|
||
hits.set(key, (hits.get(key) ?? 0) + 1);
|
||
return RESILIENCE_FIXTURES[key] ?? null;
|
||
};
|
||
|
||
await scoreAllDimensions('US', countingReader);
|
||
|
||
for (const [key, count] of hits.entries()) {
|
||
assert.equal(count, 1, `expected ${key} to be read once, got ${count}`);
|
||
}
|
||
});
|
||
|
||
it('weightedBlend returns observedWeight and imputedWeight', async () => {
|
||
const result = await scoreMacroFiscal('US', fixtureReader);
|
||
assert.ok(typeof result.observedWeight === 'number', 'observedWeight must be a number');
|
||
assert.ok(typeof result.imputedWeight === 'number', 'imputedWeight must be a number');
|
||
assert.ok(result.observedWeight >= 0, 'observedWeight must be >= 0');
|
||
assert.ok(result.imputedWeight >= 0, 'imputedWeight must be >= 0');
|
||
});
|
||
|
||
it('imputationShare = 0 when all data is real (US has full IMF + debt data)', async () => {
|
||
const dimensions = await scoreAllDimensions('US', fixtureReader);
|
||
const totalImputed = Object.values(dimensions).reduce((s, d) => s + d.imputedWeight, 0);
|
||
const totalObserved = Object.values(dimensions).reduce((s, d) => s + d.observedWeight, 0);
|
||
const imputationShare = (totalImputed + totalObserved) > 0
|
||
? totalImputed / (totalImputed + totalObserved)
|
||
: 0;
|
||
assert.ok(imputationShare < 0.15, `US imputationShare should be low with rich data, got ${imputationShare.toFixed(4)}`);
|
||
});
|
||
|
||
it('imputationShare > 0 when crisis_monitoring_absent imputation is active', async () => {
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
wgi: { indicators: { VA: { value: 1.5, year: 2025 } } },
|
||
fao: null,
|
||
aquastat: null,
|
||
};
|
||
return null;
|
||
};
|
||
const result = await scoreFoodWater('XX', reader);
|
||
assert.ok(result.imputedWeight > 0, `crisis_monitoring_absent imputation must produce imputedWeight > 0, got ${result.imputedWeight}`);
|
||
assert.equal(result.observedWeight, 0, 'no real data available, observedWeight should be 0');
|
||
});
|
||
|
||
it('every dimension has a type tag (baseline/stress/mixed)', () => {
|
||
for (const dimId of RESILIENCE_DIMENSION_ORDER) {
|
||
assert.ok(RESILIENCE_DIMENSION_TYPES[dimId], `${dimId} missing type tag`);
|
||
assert.ok(
|
||
['baseline', 'stress', 'mixed'].includes(RESILIENCE_DIMENSION_TYPES[dimId]),
|
||
`${dimId} has invalid type`,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('scoreLogisticsSupply: high trade/GDP country feels more shipping stress than autarky', async () => {
|
||
const makeReader = (tradeToGdpPct: number) => async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
infrastructure: { indicators: { 'IS.ROD.PAVE.ZS': { value: 80, year: 2025 } } },
|
||
tradeToGdp: { tradeToGdpPct, year: 2023, source: 'worldbank' },
|
||
};
|
||
if (key === 'supply_chain:shipping_stress:v1') return { stressScore: 70 };
|
||
if (key === 'supply_chain:transit-summaries:v1') return { summaries: { suez: { disruptionPct: 10, incidentCount7d: 5 } } };
|
||
return null;
|
||
};
|
||
const openEconomy = await scoreLogisticsSupply('XX', makeReader(100));
|
||
const autarky = await scoreLogisticsSupply('XX', makeReader(10));
|
||
assert.ok(openEconomy.score < autarky.score,
|
||
`Open economy (trade/GDP=100%, score=${openEconomy.score}) should score lower than autarky (trade/GDP=10%, score=${autarky.score}) under shipping stress`);
|
||
});
|
||
|
||
it('scoreLogisticsSupply: missing tradeToGdp defaults to 0.5 exposure factor', async () => {
|
||
const withTrade25 = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
infrastructure: { indicators: { 'IS.ROD.PAVE.ZS': { value: 80, year: 2025 } } },
|
||
tradeToGdp: { tradeToGdpPct: 25, year: 2023, source: 'worldbank' },
|
||
};
|
||
if (key === 'supply_chain:shipping_stress:v1') return { stressScore: 70 };
|
||
if (key === 'supply_chain:transit-summaries:v1') return { summaries: { suez: { disruptionPct: 10, incidentCount7d: 5 } } };
|
||
return null;
|
||
};
|
||
const withoutTrade = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
infrastructure: { indicators: { 'IS.ROD.PAVE.ZS': { value: 80, year: 2025 } } },
|
||
};
|
||
if (key === 'supply_chain:shipping_stress:v1') return { stressScore: 70 };
|
||
if (key === 'supply_chain:transit-summaries:v1') return { summaries: { suez: { disruptionPct: 10, incidentCount7d: 5 } } };
|
||
return null;
|
||
};
|
||
const known = await scoreLogisticsSupply('XX', withTrade25);
|
||
const unknown = await scoreLogisticsSupply('XX', withoutTrade);
|
||
assert.equal(known.score, unknown.score,
|
||
`trade/GDP=25% gives exposure=0.5 which equals the default 0.5, so scores should match`);
|
||
});
|
||
|
||
it('scoreEnergy: high import dependency country feels more energy price stress', async () => {
|
||
const makeReader = (importDep: number) => async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
iea: { energyImportDependency: { value: importDep, year: 2024, source: 'IEA' } },
|
||
infrastructure: { indicators: { 'EG.USE.ELEC.KH.PC': { value: 5000, year: 2025 } } },
|
||
};
|
||
if (key === 'economic:energy:v1:all') return { prices: [{ change: 15 }, { change: -12 }, { change: 18 }] };
|
||
return null;
|
||
};
|
||
const highDep = await scoreEnergy('XX', makeReader(90));
|
||
const lowDep = await scoreEnergy('XX', makeReader(10));
|
||
assert.ok(highDep.score < lowDep.score,
|
||
`High import dependency (90%, score=${highDep.score}) should score lower than low dependency (10%, score=${lowDep.score}) under energy price stress`);
|
||
});
|
||
|
||
it('scoreEnergy: missing import dependency defaults to 0.5 exposure factor (between high and low)', async () => {
|
||
const makeReader = (iea: unknown) => async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
iea,
|
||
infrastructure: { indicators: { 'EG.USE.ELEC.KH.PC': { value: 5000, year: 2025 } } },
|
||
};
|
||
if (key === 'economic:energy:v1:all') return { prices: [{ change: 15 }, { change: -12 }, { change: 18 }] };
|
||
return null;
|
||
};
|
||
const highDep = await scoreEnergy('XX', makeReader({ energyImportDependency: { value: 90, year: 2024, source: 'IEA' } }));
|
||
const missingDep = await scoreEnergy('XX', makeReader(null));
|
||
const lowDep = await scoreEnergy('XX', makeReader({ energyImportDependency: { value: 5, year: 2024, source: 'IEA' } }));
|
||
const zeroDep = await scoreEnergy('XX', makeReader({ energyImportDependency: { value: 0, year: 2024, source: 'IEA' } }));
|
||
const exporterDep = await scoreEnergy('XX', makeReader({ energyImportDependency: { value: -30, year: 2024, source: 'IEA' } }));
|
||
assert.ok(missingDep.score <= lowDep.score,
|
||
`Missing dependency (score=${missingDep.score}) should score <= low dep (score=${lowDep.score}) since default exposure=0.5 is moderate`);
|
||
assert.ok(missingDep.score >= highDep.score,
|
||
`Missing dependency (score=${missingDep.score}) should score >= high dep (score=${highDep.score})`);
|
||
// The clamp at _dimension-scorers.ts:847 floors negative dependency to 0 exposure.
|
||
// A net exporter (-30) must produce the same score as dependency=0, proving the clamp works.
|
||
assert.equal(exporterDep.score, zeroDep.score,
|
||
`Net exporter (score=${exporterDep.score}) must equal zero-dependency (score=${zeroDep.score}) — negative values should clamp to 0 exposure`);
|
||
});
|
||
|
||
it('scoreLogisticsSupply: static bundle outage (null) excludes exposure-weighted stress metrics', async () => {
|
||
const outageReader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return null;
|
||
if (key === 'supply_chain:shipping_stress:v1') return { stressScore: 80 };
|
||
if (key === 'supply_chain:transit-summaries:v1') return { summaries: { suez: { disruptionPct: 15, incidentCount7d: 8 } } };
|
||
return null;
|
||
};
|
||
const result = await scoreLogisticsSupply('XX', outageReader);
|
||
assert.equal(result.score, 0, 'All metrics null when static bundle is missing and no roads data');
|
||
assert.equal(result.coverage, 0, 'Coverage should be 0 when all sub-metrics are null');
|
||
|
||
const withStaticReader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
infrastructure: { indicators: { 'IS.ROD.PAVE.ZS': { value: 80, year: 2025 } } },
|
||
};
|
||
if (key === 'supply_chain:shipping_stress:v1') return { stressScore: 80 };
|
||
if (key === 'supply_chain:transit-summaries:v1') return { summaries: { suez: { disruptionPct: 15, incidentCount7d: 8 } } };
|
||
return null;
|
||
};
|
||
const withStatic = await scoreLogisticsSupply('XX', withStaticReader);
|
||
assert.ok(withStatic.score > 0, `Static bundle present should produce non-zero score (got ${withStatic.score})`);
|
||
assert.ok(withStatic.coverage > result.coverage, 'Coverage should be higher with static bundle present');
|
||
});
|
||
|
||
it('scoreEnergy: static bundle outage (null) excludes exposure-weighted energy price stress', async () => {
|
||
const outageReader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return null;
|
||
if (key === 'economic:energy:v1:all') return { prices: [{ change: 20 }, { change: -15 }, { change: 25 }] };
|
||
return null;
|
||
};
|
||
const result = await scoreEnergy('XX', outageReader);
|
||
assert.equal(result.score, 0, 'All metrics null when static bundle is missing');
|
||
assert.equal(result.coverage, 0, 'Coverage should be 0 when all sub-metrics are null');
|
||
|
||
const withStaticReader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
iea: { energyImportDependency: { value: 60, year: 2024, source: 'IEA' } },
|
||
infrastructure: { indicators: { 'EG.USE.ELEC.KH.PC': { value: 5000, year: 2025 } } },
|
||
};
|
||
if (key === 'economic:energy:v1:all') return { prices: [{ change: 20 }, { change: -15 }, { change: 25 }] };
|
||
return null;
|
||
};
|
||
const withStatic = await scoreEnergy('XX', withStaticReader);
|
||
assert.ok(withStatic.score > 0, `Static bundle present should produce non-zero score (got ${withStatic.score})`);
|
||
assert.ok(withStatic.coverage > result.coverage, 'Coverage should be higher with static bundle present');
|
||
});
|
||
|
||
it('scoreHealthPublicService: physician density contributes to score', async () => {
|
||
const makeReader = (physiciansPer1k: number) => async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
who: { indicators: {
|
||
uhcIndex: { value: 75, year: 2024 },
|
||
measlesCoverage: { value: 90, year: 2024 },
|
||
hospitalBeds: { value: 3, year: 2024 },
|
||
physiciansPer1k: { value: physiciansPer1k, year: 2024 },
|
||
healthExpPerCapitaUsd: { value: 2000, year: 2024 },
|
||
} },
|
||
};
|
||
return null;
|
||
};
|
||
const highDoc = await scoreHealthPublicService('XX', makeReader(4.5));
|
||
const lowDoc = await scoreHealthPublicService('XX', makeReader(0.3));
|
||
assert.ok(highDoc.score > lowDoc.score,
|
||
`High physician density (${highDoc.score}) should score better than low (${lowDoc.score})`);
|
||
});
|
||
|
||
it('scoreHealthPublicService: health expenditure contributes to score', async () => {
|
||
const makeReader = (healthExp: number) => async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return {
|
||
who: { indicators: {
|
||
uhcIndex: { value: 75, year: 2024 },
|
||
measlesCoverage: { value: 90, year: 2024 },
|
||
hospitalBeds: { value: 3, year: 2024 },
|
||
physiciansPer1k: { value: 2.0, year: 2024 },
|
||
healthExpPerCapitaUsd: { value: healthExp, year: 2024 },
|
||
} },
|
||
};
|
||
return null;
|
||
};
|
||
const highExp = await scoreHealthPublicService('XX', makeReader(6000));
|
||
const lowExp = await scoreHealthPublicService('XX', makeReader(100));
|
||
assert.ok(highExp.score > lowExp.score,
|
||
`High health expenditure (${highExp.score}) should score better than low (${lowExp.score})`);
|
||
});
|
||
});
|
||
|
||
// T1.7 Phase 1 of the country-resilience reference-grade upgrade plan.
|
||
// Foundation-only slice: the 4-class imputation taxonomy (stable-absence,
|
||
// unmonitored, source-failure, not-applicable) is defined as an exported
|
||
// type, and every entry in the IMPUTATION and IMPUTE tables carries an
|
||
// imputationClass tag. These tests pin the classification so downstream
|
||
// work (T1.5 source-recency badges, T1.6 widget dimension confidence) can
|
||
// consume the taxonomy without risk of drift.
|
||
describe('resilience imputation taxonomy (T1.7)', () => {
|
||
const VALID_CLASSES: readonly ImputationClass[] = [
|
||
'stable-absence',
|
||
'unmonitored',
|
||
'source-failure',
|
||
'not-applicable',
|
||
] as const;
|
||
|
||
function assertValidClass(label: string, value: string): void {
|
||
assert.ok(
|
||
(VALID_CLASSES as readonly string[]).includes(value),
|
||
`${label} has imputationClass="${value}", expected one of [${VALID_CLASSES.join(', ')}]`,
|
||
);
|
||
}
|
||
|
||
it('IMPUTATION entries carry the expected semantic classes', () => {
|
||
// Crisis-monitoring sources (IPC, UCDP, UNHCR) publish globally; absence
|
||
// means the country is stable, so it is tagged stable-absence.
|
||
assert.equal(IMPUTATION.crisis_monitoring_absent.imputationClass, 'stable-absence');
|
||
assert.equal(IMPUTATION.crisis_monitoring_absent.score, 85);
|
||
assert.equal(IMPUTATION.crisis_monitoring_absent.certaintyCoverage, 0.7);
|
||
|
||
// Curated-list sources (BIS, WTO) may not cover every country; absence
|
||
// is ambiguous, so it is tagged unmonitored.
|
||
assert.equal(IMPUTATION.curated_list_absent.imputationClass, 'unmonitored');
|
||
assert.equal(IMPUTATION.curated_list_absent.score, 50);
|
||
assert.equal(IMPUTATION.curated_list_absent.certaintyCoverage, 0.3);
|
||
});
|
||
|
||
it('every IMPUTATION entry has a valid imputationClass', () => {
|
||
for (const [key, entry] of Object.entries(IMPUTATION)) {
|
||
assertValidClass(`IMPUTATION.${key}`, entry.imputationClass);
|
||
}
|
||
});
|
||
|
||
it('IMPUTE per-metric overrides inherit or override the class consistently', () => {
|
||
// Food-specific crisis-monitoring override (IPC phase data).
|
||
assert.equal(IMPUTE.ipcFood.imputationClass, 'stable-absence');
|
||
// Trade-specific curated-list override (WTO trade restrictions).
|
||
assert.equal(IMPUTE.wtoData.imputationClass, 'unmonitored');
|
||
// Displacement-specific crisis-monitoring override (UNHCR flows).
|
||
assert.equal(IMPUTE.unhcrDisplacement.imputationClass, 'stable-absence');
|
||
|
||
// Shared references: bisEer and bisCredit alias IMPUTATION.curated_list_absent
|
||
// so their class must match exactly (same object reference, same tag).
|
||
assert.equal(IMPUTE.bisEer.imputationClass, 'unmonitored');
|
||
assert.equal(IMPUTE.bisCredit.imputationClass, 'unmonitored');
|
||
assert.equal(IMPUTE.bisEer, IMPUTATION.curated_list_absent);
|
||
assert.equal(IMPUTE.bisCredit, IMPUTATION.curated_list_absent);
|
||
});
|
||
|
||
it('every IMPUTE entry has a valid imputationClass', () => {
|
||
for (const [key, entry] of Object.entries(IMPUTE)) {
|
||
assertValidClass(`IMPUTE.${key}`, entry.imputationClass);
|
||
}
|
||
});
|
||
|
||
it('stable-absence entries score higher than unmonitored, across BOTH tables (semantic sanity)', () => {
|
||
// stable-absence = strong positive signal (feed is comprehensive,
|
||
// nothing happened). unmonitored = we do not know, penalized.
|
||
// The invariant must hold across every entry in both IMPUTATION and
|
||
// IMPUTE, otherwise a per-metric override can silently break the
|
||
// ordering (e.g. a `stable-absence` override with a score lower than
|
||
// an `unmonitored` entry would pass a tables-only check but violate
|
||
// the taxonomy's semantic meaning).
|
||
//
|
||
// Raised in review of PR #2944: the earlier version of this test
|
||
// only checked the two base entries in IMPUTATION and would have
|
||
// missed a regression in an IMPUTE override.
|
||
const allEntries = [
|
||
...Object.entries(IMPUTATION).map(([k, v]) => ({ label: `IMPUTATION.${k}`, entry: v })),
|
||
...Object.entries(IMPUTE).map(([k, v]) => ({ label: `IMPUTE.${k}`, entry: v })),
|
||
];
|
||
|
||
const stableAbsence = allEntries.filter((e) => e.entry.imputationClass === 'stable-absence');
|
||
const unmonitored = allEntries.filter((e) => e.entry.imputationClass === 'unmonitored');
|
||
|
||
assert.ok(stableAbsence.length > 0, 'expected at least one stable-absence entry across both tables');
|
||
assert.ok(unmonitored.length > 0, 'expected at least one unmonitored entry across both tables');
|
||
|
||
const minStableScore = Math.min(...stableAbsence.map((e) => e.entry.score));
|
||
const maxUnmonitoredScore = Math.max(...unmonitored.map((e) => e.entry.score));
|
||
assert.ok(
|
||
minStableScore > maxUnmonitoredScore,
|
||
`every stable-absence entry must score higher than every unmonitored entry. ` +
|
||
`min stable-absence score = ${minStableScore}, max unmonitored score = ${maxUnmonitoredScore}. ` +
|
||
`stable-absence entries: ${stableAbsence.map((e) => `${e.label}=${e.entry.score}`).join(', ')}. ` +
|
||
`unmonitored entries: ${unmonitored.map((e) => `${e.label}=${e.entry.score}`).join(', ')}.`,
|
||
);
|
||
|
||
const minStableCertainty = Math.min(...stableAbsence.map((e) => e.entry.certaintyCoverage));
|
||
const maxUnmonitoredCertainty = Math.max(...unmonitored.map((e) => e.entry.certaintyCoverage));
|
||
assert.ok(
|
||
minStableCertainty > maxUnmonitoredCertainty,
|
||
`every stable-absence entry must have higher certaintyCoverage than every unmonitored entry. ` +
|
||
`min stable-absence certainty = ${minStableCertainty}, max unmonitored certainty = ${maxUnmonitoredCertainty}. ` +
|
||
`stable-absence entries: ${stableAbsence.map((e) => `${e.label}=${e.entry.certaintyCoverage}`).join(', ')}. ` +
|
||
`unmonitored entries: ${unmonitored.map((e) => `${e.label}=${e.entry.certaintyCoverage}`).join(', ')}.`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// T1.7 schema pass: imputationClass propagation through weightedBlend and
|
||
// the direct early-return paths that bypass weightedBlend (e.g.
|
||
// scoreCurrencyExternal when BIS EER is the only source). These tests use
|
||
// real scorers with crafted readers so weightedBlend's aggregation
|
||
// semantics are exercised without exporting it.
|
||
describe('resilience dimension imputationClass propagation (T1.7)', () => {
|
||
it('single fully-imputed metric: foodWater reports stable-absence via IMPUTE.ipcFood', async () => {
|
||
// resilience:static:{ISO2} loaded with fao:null and aquastat:null → the
|
||
// IPC metric imputes (weight 0.6) and aquastat is null (weight 0.4).
|
||
// availableWeight = 0.6, observed = 0, imputed = 0.6 → fully imputed,
|
||
// dominant class is stable-absence (the only class present).
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:XX') return { fao: null, aquastat: null };
|
||
return null;
|
||
};
|
||
const result = await scoreFoodWater('XX', reader);
|
||
assert.equal(result.observedWeight, 0, 'no observed data');
|
||
assert.ok(result.imputedWeight > 0, 'imputed data present');
|
||
assert.equal(result.imputationClass, 'stable-absence',
|
||
`foodWater should propagate stable-absence from IMPUTE.ipcFood, got ${result.imputationClass}`);
|
||
});
|
||
|
||
it('single fully-imputed metric: tradeSanctions reports unmonitored via IMPUTE.wtoData', async () => {
|
||
// Non-reporter in WTO restrictions + barriers, no sanctions/tariff data.
|
||
// Both imputed metrics share the unmonitored class.
|
||
const reporterSet = ['US', 'DE'];
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [], _reporterCountries: reporterSet };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [], _reporterCountries: reporterSet };
|
||
return null;
|
||
};
|
||
const result = await scoreTradeSanctions('BF', reader);
|
||
assert.equal(result.observedWeight, 0, 'no observed data for BF in this reader');
|
||
assert.ok(result.imputedWeight > 0, 'WTO imputation should produce imputed weight');
|
||
assert.equal(result.imputationClass, 'unmonitored',
|
||
`tradeSanctions should propagate unmonitored from IMPUTE.wtoData, got ${result.imputationClass}`);
|
||
});
|
||
|
||
it('observed + imputed: imputationClass is null when the dimension has any real data', async () => {
|
||
// Mix: real sanctions data (observed) + WTO impute (imputed) → observedWeight > 0
|
||
// means imputationClass must be null.
|
||
const reporterSet = ['US'];
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'sanctions:country-counts:v1') return { BF: 2 };
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [], _reporterCountries: reporterSet };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [], _reporterCountries: reporterSet };
|
||
return null;
|
||
};
|
||
const result = await scoreTradeSanctions('BF', reader);
|
||
assert.ok(result.observedWeight > 0, 'sanctions provide observed weight');
|
||
assert.ok(result.imputedWeight > 0, 'WTO still imputes for non-reporter');
|
||
assert.equal(result.imputationClass, null,
|
||
`observed + imputed must yield null imputationClass, got ${result.imputationClass}`);
|
||
});
|
||
|
||
it('zero observed + zero imputed: imputationClass is null (true no-data case)', async () => {
|
||
// cyberDigital with all sources null returns score=0 coverage=0 (no
|
||
// data at all). This must not be mislabelled as an imputation class.
|
||
const reader = async (_key: string): Promise<unknown | null> => null;
|
||
const result = await scoreCyberDigital('XX', reader);
|
||
assert.equal(result.observedWeight, 0);
|
||
assert.equal(result.imputedWeight, 0);
|
||
assert.equal(result.imputationClass, null,
|
||
`no-data case must yield null imputationClass, got ${result.imputationClass}`);
|
||
});
|
||
|
||
it('scoreCurrencyExternal early-return: curated_list_absent propagates unmonitored', async () => {
|
||
// BIS loaded but country not listed, IMF macro null, no reserves → the
|
||
// function early-returns with IMPUTE.bisEer, which aliases
|
||
// IMPUTATION.curated_list_absent → unmonitored.
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'economic:bis:eer:v1') return { rates: [{ countryCode: 'US', realChange: 1.0, realEer: 100, date: '2025-09' }] };
|
||
return null;
|
||
};
|
||
const result = await scoreCurrencyExternal('MZ', reader);
|
||
assert.equal(result.observedWeight, 0);
|
||
assert.equal(result.imputedWeight, 1);
|
||
assert.equal(result.imputationClass, 'unmonitored',
|
||
`scoreCurrencyExternal BIS-absent early return must propagate unmonitored, got ${result.imputationClass}`);
|
||
});
|
||
|
||
it('scoreBorderSecurity: UNHCR displacement absent propagates stable-absence', async () => {
|
||
// UCDP loaded but zero events for XX, displacement loaded but country
|
||
// absent → IMPUTE.unhcrDisplacement (stable-absence) on the 0.35
|
||
// weight metric. The UCDP metric is observed (0 events → score != null),
|
||
// which means the dimension still has observedWeight > 0 and the
|
||
// imputationClass must be null.
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'conflict:ucdp-events:v1') return { events: [] };
|
||
if (key.startsWith('displacement:summary:v1')) return { summary: { countries: [] } };
|
||
return null;
|
||
};
|
||
const result = await scoreBorderSecurity('XX', reader);
|
||
assert.ok(result.observedWeight > 0, 'UCDP contributes observed weight');
|
||
assert.equal(result.imputationClass, null,
|
||
`observed + imputed mix must yield null imputationClass, got ${result.imputationClass}`);
|
||
});
|
||
|
||
it('scoreBorderSecurity: UCDP outage + displacement impute → fully imputed stable-absence', async () => {
|
||
// UCDP source null (returns null score, excluded), displacement loaded
|
||
// with country absent → only the imputed unhcrDisplacement metric
|
||
// contributes. observedWeight = 0, imputedWeight > 0, dominant class
|
||
// is stable-absence.
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key.startsWith('displacement:summary:v1')) return { summary: { countries: [] } };
|
||
return null;
|
||
};
|
||
const result = await scoreBorderSecurity('XX', reader);
|
||
assert.equal(result.observedWeight, 0, 'UCDP null → no observed');
|
||
assert.ok(result.imputedWeight > 0, 'displacement imputed');
|
||
assert.equal(result.imputationClass, 'stable-absence',
|
||
`borderSecurity with only displacement impute must be stable-absence, got ${result.imputationClass}`);
|
||
});
|
||
});
|
||
|
||
describe('resilience source-failure aggregation (T1.7)', () => {
|
||
// Builds a reader that delegates to the baseline fixtures but overrides
|
||
// a subset of keys. Lets us simulate "WGI adapter failed at seed time"
|
||
// while keeping the country's other data intact.
|
||
function makeOverrideReader(
|
||
overrides: Record<string, unknown | null>,
|
||
): (key: string) => Promise<unknown | null> {
|
||
return async (key: string) => {
|
||
if (key in overrides) return overrides[key];
|
||
return (RESILIENCE_FIXTURES as Record<string, unknown>)[key] ?? null;
|
||
};
|
||
}
|
||
|
||
it('re-tags imputed dimensions when their adapter is in failedDatasets', async () => {
|
||
// Case: WGI adapter failed at seed time AND the country has no real
|
||
// WGI data in the static record. governanceInstitutional is fully
|
||
// imputed (observedWeight === 0) → must flip from its default class
|
||
// to source-failure. macroFiscal depends on a different data path
|
||
// (IMF + debt) so it stays observed and is NOT re-tagged even
|
||
// though it is in the wgi→dimensions affected set.
|
||
const reader = makeOverrideReader({
|
||
'resilience:static:US': {
|
||
// wgi key omitted → scoreGovernanceInstitutional sees no data
|
||
infrastructure: {
|
||
indicators: {
|
||
'EG.ELC.ACCS.ZS': { value: 100, year: 2025 },
|
||
'IS.ROD.PAVE.ZS': { value: 74, year: 2025 },
|
||
'EG.USE.ELEC.KH.PC': { value: 12000, year: 2025 },
|
||
'IT.NET.BBND.P2': { value: 35, year: 2025 },
|
||
},
|
||
},
|
||
gpi: { score: 2.4, rank: 132, year: 2025 },
|
||
rsf: { score: 30, rank: 45, year: 2025 },
|
||
who: {
|
||
indicators: {
|
||
hospitalBeds: { value: 2.8, year: 2024 },
|
||
uhcIndex: { value: 82, year: 2024 },
|
||
measlesCoverage: { value: 91, year: 2024 },
|
||
physiciansPer1k: { value: 2.6, year: 2024 },
|
||
healthExpPerCapitaUsd: { value: 12000, year: 2024 },
|
||
},
|
||
},
|
||
fao: { peopleInCrisis: 5000, phase: 'IPC Phase 2', year: 2025 },
|
||
aquastat: { indicator: 'Renewable water availability', value: 1500, year: 2024 },
|
||
iea: { energyImportDependency: { value: 25, year: 2024, source: 'IEA' } },
|
||
tradeToGdp: { source: 'worldbank', tradeToGdpPct: 25, year: 2023 },
|
||
fxReservesMonths: { source: 'worldbank', months: 2.5, year: 2023 },
|
||
appliedTariffRate: { source: 'worldbank', value: 3.5, year: 2023 },
|
||
},
|
||
'seed-meta:resilience:static': {
|
||
fetchedAt: 1712102400000,
|
||
recordCount: 196,
|
||
failedDatasets: ['wgi'],
|
||
},
|
||
});
|
||
const dims = await scoreAllDimensions('US', reader);
|
||
// governanceInstitutional is fully imputed (no WGI) → coverage=0,
|
||
// score=0, imputationClass=null from weightedBlend. Even with the
|
||
// source-failure set, it stays null because the decoration only
|
||
// re-tags when imputationClass was already non-null. To exercise
|
||
// the real re-tagging branch, tradeSanctions is the right target:
|
||
// it has a WTO imputation fallback, and we put tradeToGdp into the
|
||
// failed set below in the next test case. For this test, simply
|
||
// assert the infrastructure row (in wgi's affected set only through
|
||
// the logistics mapping) stays correct: the decoration does not
|
||
// touch dimensions that produced real-data scores.
|
||
assert.equal(dims.infrastructure.imputationClass, null,
|
||
'real-data infrastructure must not be re-tagged even if its adapter is failed');
|
||
});
|
||
|
||
it('re-tags already-imputed dimensions to source-failure via tradeSanctions path', async () => {
|
||
// tradeSanctions imputes via IMPUTE.wtoData (unmonitored) when a
|
||
// country is absent from the WTO reporter sets. Mark the
|
||
// appliedTariffRate adapter as failed → the tradeSanctions dim,
|
||
// which the mapping says depends on appliedTariffRate, keeps its
|
||
// imputed WTO class from wbWto but the decoration flips it to
|
||
// source-failure.
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
// Non-reporter → WTO imputation kicks in on both metrics.
|
||
const reporterSet = ['US', 'DE'];
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [], _reporterCountries: reporterSet };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [], _reporterCountries: reporterSet };
|
||
if (key === 'resilience:static:BF') return { /* no appliedTariffRate */ };
|
||
if (key === 'seed-meta:resilience:static') {
|
||
return { fetchedAt: 1, recordCount: 196, failedDatasets: ['appliedTariffRate'] };
|
||
}
|
||
return null;
|
||
};
|
||
const dims = await scoreAllDimensions('BF', reader);
|
||
// tradeSanctions had imputationClass='unmonitored' from the raw
|
||
// scorer (WTO impute), then the decoration pass flipped it to
|
||
// 'source-failure' because appliedTariffRate is in failedDatasets
|
||
// and its mapping includes tradeSanctions.
|
||
assert.equal(dims.tradeSanctions.observedWeight, 0, 'no observed data for BF');
|
||
assert.ok(dims.tradeSanctions.imputedWeight > 0, 'WTO impute carries weight');
|
||
assert.equal(dims.tradeSanctions.imputationClass, 'source-failure',
|
||
`tradeSanctions must flip to source-failure when appliedTariffRate is in failedDatasets, got ${dims.tradeSanctions.imputationClass}`);
|
||
});
|
||
|
||
it('does not re-tag real-data dimensions even when their adapter is in failedDatasets', async () => {
|
||
// US with full fixture data; claim all adapters failed. Every
|
||
// dimension with observedWeight > 0 must keep imputationClass=null
|
||
// because the seed failing did not prevent us from producing a
|
||
// real-data score (prior-snapshot recovery path semantics).
|
||
const reader = makeOverrideReader({
|
||
'seed-meta:resilience:static': {
|
||
fetchedAt: 1,
|
||
recordCount: 196,
|
||
failedDatasets: ['wgi', 'infrastructure', 'gpi', 'rsf', 'who', 'fao', 'aquastat', 'iea', 'tradeToGdp', 'fxReservesMonths', 'appliedTariffRate'],
|
||
},
|
||
});
|
||
const dims = await scoreAllDimensions('US', reader);
|
||
// US has full observed data for governanceInstitutional (WGI), so
|
||
// even though wgi is in failedDatasets, the decoration must NOT
|
||
// re-tag it — the dimension's imputationClass was already null.
|
||
assert.ok(dims.governanceInstitutional.observedWeight > 0, 'US has real WGI data');
|
||
assert.equal(dims.governanceInstitutional.imputationClass, null,
|
||
'real-data governance must not be re-tagged');
|
||
assert.ok(dims.healthPublicService.observedWeight > 0, 'US has real WHO data');
|
||
assert.equal(dims.healthPublicService.imputationClass, null,
|
||
'real-data health must not be re-tagged');
|
||
});
|
||
|
||
it('leaves unaffected dimensions alone when unrelated adapters fail', async () => {
|
||
// BF with WTO-impute for tradeSanctions (unmonitored), but the
|
||
// failed set contains only `wgi`. tradeSanctions is NOT in wgi's
|
||
// affected set (only governanceInstitutional, macroFiscal), so its
|
||
// unmonitored class must stay put.
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
const reporterSet = ['US', 'DE'];
|
||
if (key === 'trade:restrictions:v1:tariff-overview:50') return { restrictions: [], _reporterCountries: reporterSet };
|
||
if (key === 'trade:barriers:v1:tariff-gap:50') return { barriers: [], _reporterCountries: reporterSet };
|
||
if (key === 'seed-meta:resilience:static') {
|
||
return { fetchedAt: 1, recordCount: 196, failedDatasets: ['wgi'] };
|
||
}
|
||
return null;
|
||
};
|
||
const dims = await scoreAllDimensions('BF', reader);
|
||
assert.equal(dims.tradeSanctions.imputationClass, 'unmonitored',
|
||
`tradeSanctions is not in wgi's affected set; class must stay unmonitored, got ${dims.tradeSanctions.imputationClass}`);
|
||
});
|
||
|
||
it('is a no-op when seed-meta has no failedDatasets (healthy seed path)', async () => {
|
||
// Healthy seed: failedDatasets empty / missing. The decoration pass
|
||
// does nothing and every imputed dimension keeps its taxonomy class.
|
||
const reader = async (key: string): Promise<unknown | null> => {
|
||
if (key === 'resilience:static:MZ') return null;
|
||
if (key === 'seed-meta:resilience:static') {
|
||
return { fetchedAt: 1, recordCount: 196 };
|
||
}
|
||
return null;
|
||
};
|
||
const dims = await scoreAllDimensions('MZ', reader);
|
||
// currencyExternal hits the curated_list_absent fall-through → unmonitored.
|
||
// Must NOT become source-failure.
|
||
assert.equal(dims.currencyExternal.imputationClass, 'unmonitored',
|
||
`currencyExternal must keep unmonitored on healthy seed, got ${dims.currencyExternal.imputationClass}`);
|
||
});
|
||
|
||
it('produce plausible country ordering for the recovery-capacity dimensions', async () => {
|
||
const fiscal = await scoreTriple(scoreFiscalSpace);
|
||
const reserves = await scoreTriple(scoreReserveAdequacy);
|
||
const extDebt = await scoreTriple(scoreExternalDebtCoverage);
|
||
const importHhi = await scoreTriple(scoreImportConcentration);
|
||
const continuity = await scoreTriple(scoreStateContinuity);
|
||
|
||
assertOrdered('fiscalSpace', fiscal.no.score, fiscal.us.score, fiscal.ye.score);
|
||
assertOrdered('reserveAdequacy', reserves.no.score, reserves.us.score, reserves.ye.score);
|
||
assertOrdered('externalDebtCoverage', extDebt.no.score, extDebt.us.score, extDebt.ye.score);
|
||
assertOrdered('importConcentration', importHhi.no.score, importHhi.us.score, importHhi.ye.score);
|
||
assertOrdered('stateContinuity', continuity.no.score, continuity.us.score, continuity.ye.score);
|
||
});
|
||
|
||
it('scoreFiscalSpace: country with strong fiscal position scores high', async () => {
|
||
const no = await scoreFiscalSpace('NO', fixtureReader);
|
||
assert.ok(no.score > 70, `NO should score >70 with strong fiscal space, got ${no.score}`);
|
||
assert.ok(no.coverage > 0.8, `NO should have high coverage with all 3 metrics, got ${no.coverage}`);
|
||
assert.equal(no.imputationClass, null, 'real data must not carry imputation class');
|
||
});
|
||
|
||
it('scoreFiscalSpace: missing data returns unmonitored imputation', async () => {
|
||
const emptyReader = async (_key: string): Promise<unknown | null> => null;
|
||
const score = await scoreFiscalSpace('XX', emptyReader);
|
||
assert.equal(score.imputationClass, 'unmonitored');
|
||
assert.equal(score.observedWeight, 0);
|
||
assert.equal(score.imputedWeight, 1);
|
||
});
|
||
|
||
it('scoreReserveAdequacy: high reserves score well', async () => {
|
||
const no = await scoreReserveAdequacy('NO', fixtureReader);
|
||
assert.ok(no.score > 70, `NO with 14 months reserves should score >70, got ${no.score}`);
|
||
});
|
||
|
||
it('scoreExternalDebtCoverage: low debt-to-reserves ratio scores well', async () => {
|
||
// PR 3 §3.5: goalpost tightened (5→2). NO ratio=0.2 → (2-0.2)/2 = 90.
|
||
const no = await scoreExternalDebtCoverage('NO', fixtureReader);
|
||
assert.ok(no.score >= 85, `NO with ratio 0.2 should score >=85, got ${no.score}`);
|
||
});
|
||
|
||
it('scoreImportConcentration: low HHI scores well', async () => {
|
||
const us = await scoreImportConcentration('US', fixtureReader);
|
||
// US fixture: hhi=0.06 → *10000 = 600 → normalizeLowerBetter(600, 0, 5000) ≈ 88
|
||
assert.ok(us.score > 80, `US with HHI 0.06 should score >80, got ${us.score}`);
|
||
});
|
||
|
||
it('scoreStateContinuity: derives from existing WGI + UCDP + displacement', async () => {
|
||
const no = await scoreStateContinuity('NO', fixtureReader);
|
||
assert.ok(no.score > 70, `NO should score >70 on state continuity, got ${no.score}`);
|
||
assert.ok(no.observedWeight > 0, 'state continuity must have observed weight from WGI');
|
||
assert.equal(no.imputationClass, null, 'NO has real data, no imputation class');
|
||
});
|
||
|
||
// PR 3 §3.5: fuelStockDays retired permanently from the core score.
|
||
// scoreFuelStockDays returns coverage=0 + observedWeight=0 +
|
||
// imputationClass=null for every country regardless of seed content —
|
||
// the previous two behavioural tests no longer apply because there is
|
||
// no distinction between "has data" and "missing data" any more. New
|
||
// regression test: assert the retirement shape holds identically for
|
||
// a country that USED to have data and a country that never did, so no
|
||
// future commit silently re-enables the old branch.
|
||
//
|
||
// imputationClass is pinned to `null` (not 'source-failure') because
|
||
// 'source-failure' renders as "Source down: upstream seeder failed"
|
||
// with a `!` icon in the widget — semantically wrong for an intentional
|
||
// retirement. `null` lets the widget render the dimension as a neutral
|
||
// "absent" cell without a false outage label.
|
||
it('scoreFuelStockDays: retired — returns coverage=0 + null imputationClass for every country', async () => {
|
||
const no = await scoreFuelStockDays('NO', fixtureReader);
|
||
const ye = await scoreFuelStockDays('YE', fixtureReader);
|
||
for (const [label, result] of [['NO', no], ['YE', ye]] as const) {
|
||
assert.equal(result.coverage, 0, `${label}: retired dimension must have coverage=0`);
|
||
assert.equal(result.observedWeight, 0, `${label}: retired dimension must have observedWeight=0`);
|
||
assert.equal(result.imputedWeight, 0, `${label}: retired dimension must have imputedWeight=0`);
|
||
assert.equal(result.imputationClass, null, `${label}: retired dimension must not tag source-failure (intentional retirement, not a runtime outage)`);
|
||
}
|
||
});
|
||
|
||
it('recovery domain is present in scoreAllDimensions output', async () => {
|
||
const dims = await scoreAllDimensions('US', fixtureReader);
|
||
assert.ok('fiscalSpace' in dims, 'fiscalSpace must be in scoreAllDimensions output');
|
||
assert.ok('reserveAdequacy' in dims, 'reserveAdequacy must be in scoreAllDimensions output');
|
||
assert.ok('externalDebtCoverage' in dims, 'externalDebtCoverage must be in scoreAllDimensions output');
|
||
assert.ok('importConcentration' in dims, 'importConcentration must be in scoreAllDimensions output');
|
||
assert.ok('stateContinuity' in dims, 'stateContinuity must be in scoreAllDimensions output');
|
||
assert.ok('fuelStockDays' in dims, 'fuelStockDays must be in scoreAllDimensions output');
|
||
});
|
||
});
|