diff --git a/docs/methodology/country-resilience-index/validation/benchmark-results.json b/docs/methodology/country-resilience-index/validation/benchmark-results.json new file mode 100644 index 000000000..0b82d2d53 --- /dev/null +++ b/docs/methodology/country-resilience-index/validation/benchmark-results.json @@ -0,0 +1,9 @@ +{ + "generatedAt": 0, + "license": "FSI data: Fund for Peace, non-commercial academic license. For internal validation only.", + "hypotheses": [], + "correlations": {}, + "outliers": [], + "sourceStatus": {}, + "_note": "Placeholder. Run `node scripts/benchmark-resilience-external.mjs` to populate with live data." +} diff --git a/scripts/benchmark-resilience-external.mjs b/scripts/benchmark-resilience-external.mjs new file mode 100644 index 000000000..9af7a52c3 --- /dev/null +++ b/scripts/benchmark-resilience-external.mjs @@ -0,0 +1,449 @@ +#!/usr/bin/env node + +// Cross-index benchmark: compares WorldMonitor resilience scores against +// INFORM Global, ND-GAIN, WorldRiskIndex, and FSI using Spearman/Pearson. +// +// FSI data sourced from the Fund for Peace under non-commercial academic license. +// WorldMonitor uses FSI scores for internal validation benchmarking only. +// FSI scores are NOT displayed in the product UI or included in the public ranking. + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { loadEnvFile, getRedisCredentials, CHROME_UA } from './_seed-utils.mjs'; + +loadEnvFile(import.meta.url); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const VALIDATION_DIR = join(__dirname, '..', 'docs', 'methodology', 'country-resilience-index', 'validation'); +const REFERENCE_DIR = join(VALIDATION_DIR, 'reference-data'); +const REDIS_KEY = 'resilience:benchmark:external:v1'; +const REDIS_TTL = 7 * 24 * 60 * 60; + +const INFORM_CSV_URL = 'https://drmkc.jrc.ec.europa.eu/inform-index/Portals/0/InfoRM/INFORM_Composite_2024.csv'; +const NDGAIN_CSV_URL = 'https://gain.nd.edu/assets/522870/nd_gain_countryindex_2023data.csv'; +const WRI_CSV_URL = 'https://weltrisikobericht.de/download/2944/'; +const FSI_CSV_URL = 'https://fragilestatesindex.org/wp-content/uploads/2024/06/fsi-2024.csv'; + +export const HYPOTHESES = [ + { index: 'INFORM', pillar: 'overall', direction: 'negative', minSpearman: 0.60 }, + { index: 'ND-GAIN', pillar: 'structural-readiness', direction: 'positive', minSpearman: 0.65 }, + { index: 'WorldRiskIndex', pillar: 'overall', direction: 'negative', minSpearman: 0.55 }, + { index: 'FSI', pillar: 'overall', direction: 'negative', minSpearman: 0.60 }, +]; + +const ISO3_TO_ISO2 = buildIso3ToIso2Map(); + +function buildIso3ToIso2Map() { + const mapping = { + AFG:'AF',ALB:'AL',DZA:'DZ',AND:'AD',AGO:'AO',ATG:'AG',ARG:'AR',ARM:'AM',AUS:'AU',AUT:'AT', + AZE:'AZ',BHS:'BS',BHR:'BH',BGD:'BD',BRB:'BB',BLR:'BY',BEL:'BE',BLZ:'BZ',BEN:'BJ',BTN:'BT', + BOL:'BO',BIH:'BA',BWA:'BW',BRA:'BR',BRN:'BN',BGR:'BG',BFA:'BF',BDI:'BI',KHM:'KH',CMR:'CM', + CAN:'CA',CPV:'CV',CAF:'CF',TCD:'TD',CHL:'CL',CHN:'CN',COL:'CO',COM:'KM',COG:'CG',COD:'CD', + CRI:'CR',CIV:'CI',HRV:'HR',CUB:'CU',CYP:'CY',CZE:'CZ',DNK:'DK',DJI:'DJ',DMA:'DM',DOM:'DO', + ECU:'EC',EGY:'EG',SLV:'SV',GNQ:'GQ',ERI:'ER',EST:'EE',SWZ:'SZ',ETH:'ET',FJI:'FJ',FIN:'FI', + FRA:'FR',GAB:'GA',GMB:'GM',GEO:'GE',DEU:'DE',GHA:'GH',GRC:'GR',GRD:'GD',GTM:'GT',GIN:'GN', + GNB:'GW',GUY:'GY',HTI:'HT',HND:'HN',HUN:'HU',ISL:'IS',IND:'IN',IDN:'ID',IRN:'IR',IRQ:'IQ', + IRL:'IE',ISR:'IL',ITA:'IT',JAM:'JM',JPN:'JP',JOR:'JO',KAZ:'KZ',KEN:'KE',KIR:'KI',PRK:'KP', + KOR:'KR',KWT:'KW',KGZ:'KG',LAO:'LA',LVA:'LV',LBN:'LB',LSO:'LS',LBR:'LR',LBY:'LY',LIE:'LI', + LTU:'LT',LUX:'LU',MDG:'MG',MWI:'MW',MYS:'MY',MDV:'MV',MLI:'ML',MLT:'MT',MHL:'MH',MRT:'MR', + MUS:'MU',MEX:'MX',FSM:'FM',MDA:'MD',MCO:'MC',MNG:'MN',MNE:'ME',MAR:'MA',MOZ:'MZ',MMR:'MM', + NAM:'NA',NRU:'NR',NPL:'NP',NLD:'NL',NZL:'NZ',NIC:'NI',NER:'NE',NGA:'NG',MKD:'MK',NOR:'NO', + OMN:'OM',PAK:'PK',PLW:'PW',PAN:'PA',PNG:'PG',PRY:'PY',PER:'PE',PHL:'PH',POL:'PL',PRT:'PT', + QAT:'QA',ROU:'RO',RUS:'RU',RWA:'RW',KNA:'KN',LCA:'LC',VCT:'VC',WSM:'WS',STP:'ST',SAU:'SA', + SEN:'SN',SRB:'RS',SYC:'SC',SLE:'SL',SGP:'SG',SVK:'SK',SVN:'SI',SLB:'SB',SOM:'SO',ZAF:'ZA', + SSD:'SS',ESP:'ES',LKA:'LK',SDN:'SD',SUR:'SR',SWE:'SE',CHE:'CH',SYR:'SY',TWN:'TW',TJK:'TJ', + TZA:'TZ',THA:'TH',TLS:'TL',TGO:'TG',TON:'TO',TTO:'TT',TUN:'TN',TUR:'TR',TKM:'TM',TUV:'TV', + UGA:'UG',UKR:'UA',ARE:'AE',GBR:'GB',USA:'US',URY:'UY',UZB:'UZ',VUT:'VU',VEN:'VE',VNM:'VN', + YEM:'YE',ZMB:'ZM',ZWE:'ZW',PSE:'PS',XKX:'XK',COK:'CK',NIU:'NU', + }; + return mapping; +} + +function toIso2(code) { + if (!code) return null; + const c = code.trim().toUpperCase(); + if (/^[A-Z]{2}$/.test(c)) return c; + if (/^[A-Z]{3}$/.test(c)) return ISO3_TO_ISO2[c] || null; + return null; +} + +function parseCSV(text) { + const lines = text.split('\n').filter(l => l.trim()); + if (lines.length < 2) return []; + const headers = parseCSVLine(lines[0]); + return lines.slice(1).map(line => { + const values = parseCSVLine(line); + const row = {}; + headers.forEach((h, i) => { row[h.trim()] = (values[i] || '').trim(); }); + return row; + }); +} + +function parseCSVLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + for (const ch of line) { + if (ch === '"') { inQuotes = !inQuotes; continue; } + if (ch === ',' && !inQuotes) { result.push(current); current = ''; continue; } + current += ch; + } + result.push(current); + return result; +} + +async function fetchCSV(url, label) { + try { + const resp = await fetch(url, { + headers: { 'User-Agent': CHROME_UA }, + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const text = await resp.text(); + console.log(`[benchmark] Fetched ${label} live (${text.length} bytes)`); + return { text, source: 'live' }; + } catch (err) { + console.warn(`[benchmark] Live fetch failed for ${label}: ${err.message}`); + const refPath = join(REFERENCE_DIR, `${label.toLowerCase().replace(/[^a-z0-9]/g, '-')}.csv`); + if (existsSync(refPath)) { + const text = readFileSync(refPath, 'utf8'); + console.log(`[benchmark] Loaded ${label} from reference CSV (${text.length} bytes)`); + return { text, source: 'stub' }; + } + console.warn(`[benchmark] No reference CSV at ${refPath}, skipping ${label}`); + return { text: null, source: 'unavailable' }; + } +} + +function findColumn(headers, ...candidates) { + const lower = headers.map(h => h.toLowerCase().trim()); + for (const c of candidates) { + const idx = lower.findIndex(h => h.includes(c.toLowerCase())); + if (idx >= 0) return headers[idx]; + } + return null; +} + +export async function fetchInformGlobal() { + const { text, source } = await fetchCSV(INFORM_CSV_URL, 'INFORM'); + if (!text) return { scores: new Map(), source }; + const rows = parseCSV(text); + const scores = new Map(); + let isoCol = findColumn(Object.keys(rows[0] || {}), 'iso3', 'iso', 'country_iso'); + let scoreCol = findColumn(Object.keys(rows[0] || {}), 'inform_risk', 'inform', 'risk_score', 'composite'); + for (const row of rows) { + const keys = Object.keys(row); + const code = toIso2(row[isoCol || keys[0]]); + const val = parseFloat(row[scoreCol || keys[keys.length - 1]]); + if (code && !Number.isNaN(val)) scores.set(code, val); + } + return { scores, source }; +} + +export async function fetchNdGain() { + const { text, source } = await fetchCSV(NDGAIN_CSV_URL, 'ND-GAIN'); + if (!text) return { scores: new Map(), source }; + const rows = parseCSV(text); + const scores = new Map(); + const isoCol = findColumn(Object.keys(rows[0] || {}), 'iso3', 'iso', 'country'); + const scoreCol = findColumn(Object.keys(rows[0] || {}), 'gain', 'nd-gain', 'score', 'readiness', 'index'); + for (const row of rows) { + const keys = Object.keys(row); + const code = toIso2(row[isoCol || keys[0]]); + const val = parseFloat(row[scoreCol || keys[keys.length - 1]]); + if (code && !Number.isNaN(val)) scores.set(code, val); + } + return { scores, source }; +} + +export async function fetchWorldRiskIndex() { + const { text, source } = await fetchCSV(WRI_CSV_URL, 'WorldRiskIndex'); + if (!text) return { scores: new Map(), source }; + const rows = parseCSV(text); + const scores = new Map(); + const isoCol = findColumn(Object.keys(rows[0] || {}), 'iso3', 'iso', 'country_code'); + const scoreCol = findColumn(Object.keys(rows[0] || {}), 'worldriskindex', 'wri', 'risk_index', 'score'); + for (const row of rows) { + const keys = Object.keys(row); + const code = toIso2(row[isoCol || keys[0]]); + const val = parseFloat(row[scoreCol || keys[keys.length - 1]]); + if (code && !Number.isNaN(val)) scores.set(code, val); + } + return { scores, source }; +} + +export async function fetchFsi() { + const { text, source } = await fetchCSV(FSI_CSV_URL, 'FSI'); + if (!text) return { scores: new Map(), source }; + const rows = parseCSV(text); + const scores = new Map(); + const isoCol = findColumn(Object.keys(rows[0] || {}), 'iso', 'country_code', 'code'); + const scoreCol = findColumn(Object.keys(rows[0] || {}), 'total', 'fsi', 'score', 'fragility'); + for (const row of rows) { + const keys = Object.keys(row); + const code = toIso2(row[isoCol || keys[0]]); + const val = parseFloat(row[scoreCol || keys[keys.length - 1]]); + if (code && !Number.isNaN(val)) scores.set(code, val); + } + return { scores, source }; +} + +export function rankArray(arr) { + const sorted = arr.map((v, i) => ({ v, i })).sort((a, b) => a.v - b.v); + const ranks = new Array(arr.length); + let i = 0; + while (i < sorted.length) { + let j = i; + while (j < sorted.length && sorted[j].v === sorted[i].v) j++; + const avgRank = (i + j + 1) / 2; + for (let k = i; k < j; k++) ranks[sorted[k].i] = avgRank; + i = j; + } + return ranks; +} + +export function spearman(x, y) { + if (x.length !== y.length || x.length < 3) return NaN; + const rx = rankArray(x); + const ry = rankArray(y); + return pearson(rx, ry); +} + +export function pearson(x, y) { + const n = x.length; + if (n < 3) return NaN; + const mx = x.reduce((s, v) => s + v, 0) / n; + const my = y.reduce((s, v) => s + v, 0) / n; + let num = 0, dx = 0, dy = 0; + for (let i = 0; i < n; i++) { + const a = x[i] - mx; + const b = y[i] - my; + num += a * b; + dx += a * a; + dy += b * b; + } + const denom = Math.sqrt(dx * dy); + return denom === 0 ? 0 : num / denom; +} + +export function detectOutliers(wmScores, extScores, countryCodes) { + if (wmScores.length < 5) return []; + const rx = rankArray(wmScores); + const ry = rankArray(extScores); + const n = rx.length; + const mRx = rx.reduce((s, v) => s + v, 0) / n; + const mRy = ry.reduce((s, v) => s + v, 0) / n; + + let slope_num = 0, slope_den = 0; + for (let i = 0; i < n; i++) { + slope_num += (rx[i] - mRx) * (ry[i] - mRy); + slope_den += (rx[i] - mRx) ** 2; + } + const slope = slope_den === 0 ? 0 : slope_num / slope_den; + const intercept = mRy - slope * mRx; + + const residuals = rx.map((r, i) => ry[i] - (slope * r + intercept)); + const meanRes = residuals.reduce((s, v) => s + v, 0) / n; + const stdRes = Math.sqrt(residuals.reduce((s, v) => s + (v - meanRes) ** 2, 0) / n); + if (stdRes === 0) return []; + + return residuals + .map((r, i) => ({ i, z: (r - meanRes) / stdRes })) + .filter(({ z }) => Math.abs(z) > 2) + .map(({ i, z }) => ({ + countryCode: countryCodes[i], + wmScore: wmScores[i], + externalScore: extScores[i], + residual: Math.round(z * 100) / 100, + })); +} + +function generateCommentary(outlier, indexName, wmScores, _extScores) { + const { countryCode, residual } = outlier; + const wmHigh = outlier.wmScore > median(wmScores); + const direction = residual > 0 ? 'higher' : 'lower'; + + const templates = { + 'INFORM': wmHigh + ? `${countryCode}: WM scores high (fiscal/institutional capacity); INFORM penalizes geographic/hazard exposure` + : `${countryCode}: WM scores low (limited structural buffers); INFORM rates risk ${direction} than WM resilience inversion`, + 'ND-GAIN': wmHigh + ? `${countryCode}: WM structural readiness aligns with ND-GAIN readiness; external rank ${direction} than expected` + : `${countryCode}: WM structural readiness diverges from ND-GAIN; possible data-vintage or indicator-coverage gap`, + 'WorldRiskIndex': wmHigh + ? `${countryCode}: WM rates resilience high; WRI emphasizes exposure/vulnerability dimensions differently` + : `${countryCode}: WM rates resilience low; WRI susceptibility weighting drives rank ${direction}`, + 'FSI': wmHigh + ? `${countryCode}: WM resilience high; FSI fragility captures governance/legitimacy dimensions WM weights differently` + : `${countryCode}: WM resilience low; FSI cohesion/economic indicators drive ${direction} fragility rank`, + }; + return templates[indexName] || `${countryCode}: WM diverges from ${indexName} by ${residual} sigma`; +} + +function median(arr) { + if (arr.length === 0) return 0; + const s = [...arr].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +async function readWmScoresFromRedis() { + const { url, token } = getRedisCredentials(); + const rankingResp = await fetch(`${url}/get/${encodeURIComponent('resilience:ranking:v8')}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!rankingResp.ok) throw new Error(`Failed to read ranking: HTTP ${rankingResp.status}`); + const rankingData = await rankingResp.json(); + if (!rankingData.result) throw new Error('No ranking data in Redis'); + const ranking = JSON.parse(rankingData.result); + const scores = new Map(); + for (const item of ranking) { + if (item.countryCode && typeof item.overallScore === 'number' && item.overallScore > 0) { + scores.set(item.countryCode, item.overallScore); + } + } + console.log(`[benchmark] Read ${scores.size} WM resilience scores from Redis`); + return scores; +} + +function alignScores(wmScores, externalScores) { + const commonCodes = []; + const wmArr = []; + const extArr = []; + for (const [code, wm] of wmScores) { + const ext = externalScores.get(code); + if (ext != null && !Number.isNaN(ext)) { + commonCodes.push(code); + wmArr.push(wm); + extArr.push(ext); + } + } + return { commonCodes, wmArr, extArr }; +} + +function evaluateHypothesis(hypothesis, sp) { + const absSpearman = Math.abs(sp); + const directionCorrect = hypothesis.direction === 'negative' ? sp < 0 : sp > 0; + return directionCorrect && absSpearman >= hypothesis.minSpearman; +} + +export async function runBenchmark(opts = {}) { + const wmScores = opts.wmScores || await readWmScoresFromRedis(); + + const fetchers = [ + { name: 'INFORM', fn: opts.fetchInform || fetchInformGlobal }, + { name: 'ND-GAIN', fn: opts.fetchNdGain || fetchNdGain }, + { name: 'WorldRiskIndex', fn: opts.fetchWri || fetchWorldRiskIndex }, + { name: 'FSI', fn: opts.fetchFsi || fetchFsi }, + ]; + + const externalResults = {}; + const sourceStatus = {}; + for (const { name, fn } of fetchers) { + const result = await fn(); + externalResults[name] = result.scores; + sourceStatus[name] = result.source; + } + + const correlations = {}; + const allOutliers = []; + const hypothesisResults = []; + + for (const { name } of fetchers) { + const extScores = externalResults[name]; + if (!extScores || extScores.size === 0) { + correlations[name] = { spearman: NaN, pearson: NaN, n: 0 }; + continue; + } + + const { commonCodes, wmArr, extArr } = alignScores(wmScores, extScores); + const sp = spearman(wmArr, extArr); + const pe = pearson(wmArr, extArr); + correlations[name] = { + spearman: Math.round(sp * 10000) / 10000, + pearson: Math.round(pe * 10000) / 10000, + n: commonCodes.length, + }; + + const outliers = detectOutliers(wmArr, extArr, commonCodes); + for (const o of outliers) { + const commentary = generateCommentary(o, name, wmArr, extArr); + allOutliers.push({ ...o, index: name, commentary }); + } + } + + for (const h of HYPOTHESES) { + const corr = correlations[h.index]; + const sp = corr?.spearman ?? NaN; + const pass = !Number.isNaN(sp) && evaluateHypothesis(h, sp); + hypothesisResults.push({ + index: h.index, + pillar: h.pillar, + direction: h.direction, + expected: h.minSpearman, + actual: Number.isNaN(sp) ? null : Math.round(sp * 10000) / 10000, + pass, + }); + } + + const result = { + generatedAt: Date.now(), + license: 'FSI data: Fund for Peace, non-commercial academic license. For internal validation only.', + hypotheses: hypothesisResults, + correlations, + outliers: allOutliers, + sourceStatus, + }; + + if (!opts.dryRun) { + mkdirSync(VALIDATION_DIR, { recursive: true }); + writeFileSync( + join(VALIDATION_DIR, 'benchmark-results.json'), + JSON.stringify(result, null, 2) + '\n', + ); + console.log(`[benchmark] Wrote benchmark-results.json`); + + try { + const { url, token } = getRedisCredentials(); + const payload = JSON.stringify(result); + const resp = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(['SET', REDIS_KEY, payload, 'EX', REDIS_TTL]), + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) console.warn('[benchmark] Redis write failed:', resp.status); + console.log(`[benchmark] Wrote to Redis key ${REDIS_KEY} (TTL ${REDIS_TTL}s)`); + } catch (err) { + console.warn(`[benchmark] Redis write failed: ${err.message}`); + } + } + + return result; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + runBenchmark() + .then(result => { + console.log('\n=== Benchmark Results ==='); + console.log(`Hypotheses: ${result.hypotheses.filter(h => h.pass).length}/${result.hypotheses.length} passed`); + for (const h of result.hypotheses) { + console.log(` ${h.pass ? 'PASS' : 'FAIL'} ${h.index} (${h.pillar}): expected ${h.direction} >= ${h.expected}, got ${h.actual}`); + } + console.log(`\nCorrelations:`); + for (const [name, c] of Object.entries(result.correlations)) { + console.log(` ${name}: spearman=${c.spearman}, pearson=${c.pearson}, n=${c.n}`); + } + console.log(`\nOutliers: ${result.outliers.length}`); + for (const o of result.outliers.slice(0, 10)) { + console.log(` ${o.countryCode} (${o.index}): residual=${o.residual} - ${o.commentary}`); + } + }) + .catch(err => { + console.error('[benchmark] Fatal:', err); + process.exit(1); + }); +} diff --git a/tests/benchmark-resilience-external.test.mjs b/tests/benchmark-resilience-external.test.mjs new file mode 100644 index 000000000..31ffbcc77 --- /dev/null +++ b/tests/benchmark-resilience-external.test.mjs @@ -0,0 +1,272 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + spearman, + pearson, + rankArray, + detectOutliers, + HYPOTHESES, + runBenchmark, +} from '../scripts/benchmark-resilience-external.mjs'; + +describe('rankArray', () => { + it('assigns sequential ranks for distinct values', () => { + assert.deepEqual(rankArray([10, 30, 20]), [1, 3, 2]); + }); + + it('assigns average ranks for tied values', () => { + assert.deepEqual(rankArray([10, 20, 20, 30]), [1, 2.5, 2.5, 4]); + }); + + it('handles single element', () => { + assert.deepEqual(rankArray([5]), [1]); + }); + + it('handles all tied', () => { + assert.deepEqual(rankArray([7, 7, 7]), [2, 2, 2]); + }); +}); + +describe('pearson', () => { + it('returns 1 for perfectly correlated arrays', () => { + const r = pearson([1, 2, 3, 4, 5], [2, 4, 6, 8, 10]); + assert.ok(Math.abs(r - 1) < 1e-10, `expected ~1, got ${r}`); + }); + + it('returns -1 for perfectly inversely correlated arrays', () => { + const r = pearson([1, 2, 3, 4, 5], [10, 8, 6, 4, 2]); + assert.ok(Math.abs(r - (-1)) < 1e-10, `expected ~-1, got ${r}`); + }); + + it('returns near 0 for uncorrelated arrays', () => { + const r = pearson([1, 2, 3, 4, 5, 6], [3, 1, 6, 2, 5, 4]); + assert.ok(Math.abs(r) < 0.5, `expected near 0, got ${r}`); + }); + + it('returns NaN for arrays shorter than 3', () => { + assert.ok(Number.isNaN(pearson([1, 2], [3, 4]))); + }); + + it('returns 0 when one array is constant', () => { + assert.equal(pearson([1, 1, 1, 1], [1, 2, 3, 4]), 0); + }); +}); + +describe('spearman', () => { + it('returns 1 for monotonically increasing relationship', () => { + const r = spearman([1, 2, 3, 4, 5], [10, 20, 30, 40, 50]); + assert.ok(Math.abs(r - 1) < 1e-10, `expected ~1, got ${r}`); + }); + + it('returns -1 for monotonically decreasing relationship', () => { + const r = spearman([1, 2, 3, 4, 5], [50, 40, 30, 20, 10]); + assert.ok(Math.abs(r - (-1)) < 1e-10, `expected ~-1, got ${r}`); + }); + + it('handles non-linear monotonic relationship', () => { + const r = spearman([1, 2, 3, 4, 5], [1, 4, 9, 16, 25]); + assert.ok(Math.abs(r - 1) < 1e-10, `expected ~1 for monotonic, got ${r}`); + }); + + it('returns NaN for arrays shorter than 3', () => { + assert.ok(Number.isNaN(spearman([1], [2]))); + }); +}); + +describe('detectOutliers', () => { + it('returns empty for small arrays', () => { + assert.deepEqual(detectOutliers([1, 2, 3], [3, 2, 1], ['A', 'B', 'C']), []); + }); + + it('detects an outlier in synthetic data', () => { + const wm = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + const ext = [100, 90, 80, 70, 60, 50, 40, 30, 20, 500]; + const codes = ['AA', 'BB', 'CC', 'DD', 'EE', 'FF', 'GG', 'HH', 'II', 'JJ']; + const outliers = detectOutliers(wm, ext, codes); + assert.ok(outliers.length > 0, 'expected at least one outlier'); + assert.ok(outliers.some(o => o.countryCode === 'JJ'), 'expected JJ to be an outlier'); + }); + + it('returns empty when relationship is perfectly linear', () => { + const wm = [10, 20, 30, 40, 50, 60, 70, 80]; + const ext = [80, 70, 60, 50, 40, 30, 20, 10]; + const codes = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + const outliers = detectOutliers(wm, ext, codes); + assert.equal(outliers.length, 0, 'perfect linear should have no outliers'); + }); +}); + +describe('HYPOTHESES', () => { + it('has 4 hypothesis entries', () => { + assert.equal(HYPOTHESES.length, 4); + }); + + it('each hypothesis has required fields', () => { + for (const h of HYPOTHESES) { + assert.ok(h.index, 'missing index'); + assert.ok(h.pillar, 'missing pillar'); + assert.ok(['positive', 'negative'].includes(h.direction), `invalid direction: ${h.direction}`); + assert.ok(typeof h.minSpearman === 'number', 'minSpearman must be a number'); + assert.ok(h.minSpearman > 0 && h.minSpearman < 1, `minSpearman out of range: ${h.minSpearman}`); + } + }); + + it('INFORM expects negative correlation', () => { + const inform = HYPOTHESES.find(h => h.index === 'INFORM'); + assert.equal(inform.direction, 'negative'); + }); + + it('ND-GAIN expects positive correlation', () => { + const ndgain = HYPOTHESES.find(h => h.index === 'ND-GAIN'); + assert.equal(ndgain.direction, 'positive'); + }); +}); + +describe('runBenchmark (mocked)', () => { + it('produces correct output shape with mock data', async () => { + const wmScores = new Map([ + ['US', 85], ['GB', 78], ['DE', 80], ['FR', 76], ['JP', 82], + ['IN', 45], ['BR', 50], ['NG', 30], ['SO', 20], ['CH', 88], + ]); + + const mockInform = async () => ({ + scores: new Map([ + ['US', 2.1], ['GB', 2.5], ['DE', 2.3], ['FR', 2.8], ['JP', 2.0], + ['IN', 5.5], ['BR', 4.8], ['NG', 7.2], ['SO', 8.5], ['CH', 1.8], + ]), + source: 'mock', + }); + + const mockNdGain = async () => ({ + scores: new Map([ + ['US', 72], ['GB', 70], ['DE', 71], ['FR', 68], ['JP', 73], + ['IN', 42], ['BR', 45], ['NG', 35], ['SO', 28], ['CH', 75], + ]), + source: 'mock', + }); + + const mockWri = async () => ({ + scores: new Map([ + ['US', 3.8], ['GB', 4.2], ['DE', 3.6], ['FR', 4.5], ['JP', 5.1], + ['IN', 7.1], ['BR', 5.9], ['NG', 9.3], ['SO', 12.1], ['CH', 2.9], + ]), + source: 'mock', + }); + + const mockFsi = async () => ({ + scores: new Map([ + ['US', 38], ['GB', 36], ['DE', 30], ['FR', 35], ['JP', 28], + ['IN', 72], ['BR', 68], ['NG', 98], ['SO', 112], ['CH', 25], + ]), + source: 'mock', + }); + + const result = await runBenchmark({ + wmScores, + fetchInform: mockInform, + fetchNdGain: mockNdGain, + fetchWri: mockWri, + fetchFsi: mockFsi, + dryRun: true, + }); + + assert.ok(result.generatedAt > 0, 'missing generatedAt'); + assert.ok(result.license, 'missing FSI license note'); + assert.equal(result.hypotheses.length, 4, 'expected 4 hypotheses'); + assert.ok(result.correlations.INFORM, 'missing INFORM correlation'); + assert.ok(result.correlations['ND-GAIN'], 'missing ND-GAIN correlation'); + assert.ok(result.correlations.WorldRiskIndex, 'missing WorldRiskIndex correlation'); + assert.ok(result.correlations.FSI, 'missing FSI correlation'); + assert.ok(Array.isArray(result.outliers), 'outliers must be an array'); + assert.ok(result.sourceStatus, 'missing sourceStatus'); + + for (const [, corr] of Object.entries(result.correlations)) { + assert.ok(typeof corr.spearman === 'number', 'spearman must be number'); + assert.ok(typeof corr.pearson === 'number', 'pearson must be number'); + assert.ok(typeof corr.n === 'number', 'n must be number'); + assert.equal(corr.n, 10, `expected 10 countries, got ${corr.n}`); + } + + for (const h of result.hypotheses) { + assert.ok(typeof h.pass === 'boolean', 'pass must be boolean'); + assert.ok(h.index, 'hypothesis must have index'); + assert.ok(h.pillar, 'hypothesis must have pillar'); + } + }); + + it('INFORM and FSI show negative correlation with mock data', async () => { + const wmScores = new Map([ + ['US', 85], ['GB', 78], ['DE', 80], ['FR', 76], ['JP', 82], + ['IN', 45], ['BR', 50], ['NG', 30], ['SO', 20], ['CH', 88], + ]); + + const mockInform = async () => ({ + scores: new Map([ + ['US', 2.1], ['GB', 2.5], ['DE', 2.3], ['FR', 2.8], ['JP', 2.0], + ['IN', 5.5], ['BR', 4.8], ['NG', 7.2], ['SO', 8.5], ['CH', 1.8], + ]), + source: 'mock', + }); + const emptyFetcher = async () => ({ scores: new Map(), source: 'mock' }); + + const result = await runBenchmark({ + wmScores, + fetchInform: mockInform, + fetchNdGain: emptyFetcher, + fetchWri: emptyFetcher, + fetchFsi: emptyFetcher, + dryRun: true, + }); + + const informCorr = result.correlations.INFORM; + assert.ok(informCorr.spearman < 0, `INFORM spearman should be negative, got ${informCorr.spearman}`); + }); + + it('handles empty external indices gracefully', async () => { + const wmScores = new Map([['US', 85], ['GB', 78], ['DE', 80]]); + const emptyFetcher = async () => ({ scores: new Map(), source: 'unavailable' }); + + const result = await runBenchmark({ + wmScores, + fetchInform: emptyFetcher, + fetchNdGain: emptyFetcher, + fetchWri: emptyFetcher, + fetchFsi: emptyFetcher, + dryRun: true, + }); + + assert.equal(result.hypotheses.filter(h => h.pass).length, 0, 'no hypotheses should pass with empty data'); + assert.equal(result.outliers.length, 0, 'no outliers with empty data'); + }); + + it('outlier entries have commentary', async () => { + const n = 20; + const wmScores = new Map(); + const informScores = new Map(); + const codes = []; + for (let i = 0; i < n; i++) { + const code = String.fromCharCode(65 + Math.floor(i / 26)) + String.fromCharCode(65 + (i % 26)); + codes.push(code); + wmScores.set(code, 10 + i * 4); + informScores.set(code, 9 - i * 0.4); + } + wmScores.set(codes[n - 1], 10); + informScores.set(codes[n - 1], 0.5); + + const result = await runBenchmark({ + wmScores, + fetchInform: async () => ({ scores: informScores, source: 'mock' }), + fetchNdGain: async () => ({ scores: new Map(), source: 'mock' }), + fetchWri: async () => ({ scores: new Map(), source: 'mock' }), + fetchFsi: async () => ({ scores: new Map(), source: 'mock' }), + dryRun: true, + }); + + for (const o of result.outliers) { + assert.ok(o.commentary, `outlier ${o.countryCode} missing commentary`); + assert.ok(typeof o.residual === 'number', 'residual must be number'); + assert.ok(o.index, 'outlier must have index name'); + } + }); +});