// @ts-check // Balance vector computation. Deterministic, no LLM. // Mirrors the per-axis formulas in // docs/internal/pro-regional-intelligence-appendix-scoring.md. import { clip, num, weightedAverage, percentile } from './_helpers.mjs'; import { sanitizeEvidenceString } from './_sanitize.mjs'; import { CII_RISK_SCORE_CACHE_KEYS } from '../_cii-risk-cache-keys.mjs'; // Use scripts/shared mirror (not repo-root shared/): Railway service has // rootDirectory=scripts so ../../shared/ escapes the deploy root. See #2954. import { getRegionCountries, getRegionCorridors, countryCriticality, REGIONS, isSignalInRegion, } from '../shared/geography.js'; import iso3ToIso2Raw from '../shared/iso3-to-iso2.json' with { type: 'json' }; /** @type {Record} */ const ISO3_TO_ISO2 = iso3ToIso2Raw; // 1.1.0: balance vector now consumes the cross-source-signals, forecasts, // national-debt, and transit-summaries inputs that were silently dropped while // they were stored as { _seed, data } envelopes but read flat (coercive_pressure // scored 0 for every region → flat 'calm'). Fixed at the loader via // unwrapEnvelope; bumped so the scoring_version in snapshot meta makes the // resulting score shift traceable. const SCORING_VERSION = '1.1.0'; export { SCORING_VERSION }; /** * @param {string} regionId * @param {Record} sources - keyed by Redis key, see freshness.mjs * @returns {{ vector: import('../../shared/regions.types.js').BalanceVector }} */ export function computeBalanceVector(regionId, sources) { const region = REGIONS.find((r) => r.id === regionId); if (!region) throw new Error(`Unknown region: ${regionId}`); const countries = new Set(getRegionCountries(regionId)); const corridors = getRegionCorridors(regionId); const pressuresOut = []; const buffersOut = []; // ── Pressures ──────────────────────────────────────────────────────────── const coercive = computeCoercivePressure(region, sources, pressuresOut); const fragility = computeDomesticFragility(countries, sources, pressuresOut); const capital = computeCapitalStress(countries, sources, pressuresOut); const energyVuln = computeEnergyVulnerability(countries, sources, pressuresOut); // ── Buffers ────────────────────────────────────────────────────────────── const alliance = computeAllianceCohesion(regionId, sources, buffersOut); const maritime = computeMaritimeAccess(corridors, sources, buffersOut); const energyLev = computeEnergyLeverage(countries, buffersOut); const pressureMean = (coercive + fragility + capital + energyVuln) / 4; const bufferMean = (alliance + maritime + energyLev) / 3; const netBalance = bufferMean - pressureMean; return { vector: { coercive_pressure: round(coercive), domestic_fragility: round(fragility), capital_stress: round(capital), energy_vulnerability: round(energyVuln), alliance_cohesion: round(alliance), maritime_access: round(maritime), energy_leverage: round(energyLev), net_balance: round(netBalance), pressures: pressuresOut, buffers: buffersOut, }, }; } // ──────────────────────────────────────────────────────────────────────────── // Per-axis computations // ──────────────────────────────────────────────────────────────────────────── function computeCoercivePressure(region, sources, drivers) { // Cross-source signals scoped by theater label. Matching handles both // fine-grained theater IDs (levant, persian-gulf) and broad display labels // the seed emits (Middle East, Sub-Saharan Africa) — see isSignalInRegion. const xss = sources['intelligence:cross-source-signals:v1']; const signals = Array.isArray(xss?.signals) ? xss.signals : []; const inRegion = signals.filter((s) => isSignalInRegion(s?.theater, region)); const criticalCount = inRegion.filter((s) => /CRITICAL/i.test(String(s?.severity ?? ''))).length; const highCount = inRegion.filter((s) => /HIGH/i.test(String(s?.severity ?? ''))).length; // ACLED conflict counts (region-scoped via country bbox - approximate via signal theater) // For Phase 0 we approximate via cross-source signal counts since ACLED key not directly fetched. const cSignal = clip(criticalCount * 0.4 + highCount * 0.15, 0, 1); // Forecast 'rising' count for military/conflict in region const fc = sources['forecast:predictions:v2']; const forecasts = Array.isArray(fc?.predictions) ? fc.predictions : []; const risingMilitary = forecasts.filter((f) => { const trend = String(f?.trend ?? '').toLowerCase(); const domain = String(f?.domain ?? '').toLowerCase(); const fRegion = String(f?.region ?? '').toLowerCase(); return (trend === 'rising' || trend === 'escalating') && (domain === 'military' || domain === 'conflict') && fRegion.includes(region.forecastLabel.toLowerCase()); }).length; const cForecast = clip(risingMilitary / 5, 0, 1); // Vessel surge and conflict event surrogates default to mid-low when no data const cVessel = 0; const cConflict = clip(inRegion.length / 50, 0, 1); const score = 0.30 * cVessel + 0.30 * cSignal + 0.25 * cConflict + 0.15 * cForecast; if (cSignal > 0.05) { drivers.push({ axis: 'coercive_pressure', description: `${criticalCount} critical, ${highCount} high cross-source signals in region`, magnitude: round(cSignal), evidence_ids: inRegion.slice(0, 5).map((s) => sanitizeEvidenceString(s?.id ?? `xss:${s?.type ?? 'unknown'}`)), orientation: 'pressure', }); } if (cForecast > 0) { drivers.push({ axis: 'coercive_pressure', description: `${risingMilitary} rising military/conflict forecasts in region`, magnitude: round(cForecast), evidence_ids: [], orientation: 'pressure', }); } return clip(score, 0, 1); } function computeDomesticFragility(countries, sources, drivers) { const cii = sources[CII_RISK_SCORE_CACHE_KEYS.stale]; const ciiScores = Array.isArray(cii?.ciiScores) ? cii.ciiScores : []; const inRegion = ciiScores.filter((s) => countries.has(String(s?.region ?? ''))); if (!inRegion.length) return 0; // Per-country normalized score const normPerCountry = inRegion.map((s) => ({ iso: String(s.region), norm: clip(num(s.combinedScore) / 100, 0, 1), })); // Weighted base average const baseAvg = weightedAverage( normPerCountry, (item) => item.norm, (item) => countryCriticality(item.iso), ); // Tail amplification const values = normPerCountry.map((c) => c.norm); const tailP90 = percentile(values, 90); const tailMax = Math.max(...values); const score = 0.4 * baseAvg + 0.4 * tailP90 + 0.2 * tailMax; // Top driver: the country with the highest weighted contribution const top = normPerCountry .map((c) => ({ ...c, contribution: c.norm * countryCriticality(c.iso) })) .sort((a, b) => b.contribution - a.contribution)[0]; if (top && top.norm > 0.3) { const safeIso = sanitizeEvidenceString(top.iso); drivers.push({ axis: 'domestic_fragility', description: `${safeIso} CII ${(top.norm * 100).toFixed(0)} (criticality ${countryCriticality(top.iso).toFixed(1)})`, magnitude: round(top.contribution), evidence_ids: [`cii:${safeIso}`], orientation: 'pressure', }); } return clip(score, 0, 1); } function computeCapitalStress(countries, sources, drivers) { // economic:macro-signals:v1 — seed-economy.mjs emits verdict: 'BUY' | 'CASH' | 'UNKNOWN' // BUY = bullish signals dominate (low stress) // CASH = bearish, rotate to cash (high stress) // UNKNOWN = missing/stale (treat as neutral) const macro = sources['economic:macro-signals:v1']; const verdict = String(macro?.verdict ?? '').toUpperCase(); const cMacro = verdict === 'CASH' ? 1 : verdict === 'BUY' ? 0 : 0.5; // economic:national-debt:v1 shape: { entries: [{ iso3, debtToGdp, ... }] } // debtToGdp is a PERCENTAGE (e.g., 110 for 110% of GDP), not a 0-1 fraction. // Filter to region via iso3 -> iso2 lookup, then compute average debt percentage. const debt = sources['economic:national-debt:v1']; const debtEntries = Array.isArray(debt?.entries) ? debt.entries : []; const inRegionDebt = debtEntries.filter((e) => { const iso2 = ISO3_TO_ISO2[String(e?.iso3 ?? '')]; return iso2 && countries.has(iso2); }); // Neutral baseline: 60%. Saturate at 140%+ (80 pct points above neutral). const avgDebtPct = inRegionDebt.length ? inRegionDebt.reduce((sum, e) => sum + num(e.debtToGdp), 0) / inRegionDebt.length : 60; const cDebt = clip((avgDebtPct - 60) / 80, 0, 1); // economic:stress-index:v1 shape: { compositeScore, label, components, ... } // Single global object (US-based FRED composite on 0-100 scale), NOT per-country. // Apply as a global overlay that scales every region's capital_stress equally. const stress = sources['economic:stress-index:v1']; const stressComposite = num(stress?.compositeScore); const cStress = clip(stressComposite / 100, 0, 1); // Sanctions count proxy: not in default sources, leave at 0 for Phase 0. const cSanctions = 0; const score = 0.25 * cMacro + 0.20 * cDebt + 0.30 * cStress + 0.25 * cSanctions; if (cMacro > 0.6) { drivers.push({ axis: 'capital_stress', description: `Macro signals verdict: ${sanitizeEvidenceString(verdict)}`, magnitude: round(cMacro), evidence_ids: ['macro:verdict'], orientation: 'pressure', }); } if (cDebt > 0.4) { drivers.push({ axis: 'capital_stress', description: `Regional debt/GDP average: ${avgDebtPct.toFixed(0)}% across ${inRegionDebt.length} countries`, magnitude: round(cDebt), evidence_ids: ['debt:region-avg'], orientation: 'pressure', }); } if (cStress > 0.5) { drivers.push({ axis: 'capital_stress', description: `Global stress index: ${stressComposite.toFixed(0)} (${sanitizeEvidenceString(stress?.label ?? 'n/a')})`, magnitude: round(cStress), evidence_ids: ['stress:composite'], orientation: 'pressure', }); } return clip(score, 0, 1); } function computeEnergyVulnerability(countries, sources, drivers) { // energy:mix:v1:_all shape: Record // Values are OWID PERCENTAGES (0-100), not 0-1 fractions. Field is // `importShare`, not `imported`. Countries with null importShare are // excluded from the average (not treated as zero). const mix = sources['energy:mix:v1:_all']; if (!mix || typeof mix !== 'object') return 0; const entries = Object.entries(mix).filter(([iso]) => countries.has(iso)); if (!entries.length) return 0; // Vulnerability = 0.5 * import share + 0.25 * (1 - storage proxy) + 0.25 * (1 - SPR proxy) // Phase 0: only import share is reliably present per-country. let totalImport = 0; let validCount = 0; for (const [, m] of entries) { if (m == null || m.importShare == null) continue; totalImport += clip(num(m.importShare) / 100, 0, 1); validCount += 1; } const avgImport = validCount > 0 ? totalImport / validCount : 0; // Storage proxy from EU gas storage (single number for EU region) const euGas = sources['economic:eu-gas-storage:v1']; const months = Array.isArray(euGas?.months) ? euGas.months : []; const latestFill = months.length ? num(months[months.length - 1]?.fillPct) / 100 : null; const cStorage = latestFill !== null ? 1 - clip(latestFill / 0.8, 0, 1) : 0.5; // SPR proxy const spr = sources['economic:spr:v1']; const sprDays = num(spr?.daysOfCover, 90); const cSpr = 1 - clip(sprDays / 90, 0, 1); const score = 0.5 * avgImport + 0.25 * cStorage + 0.25 * cSpr; if (avgImport > 0.4 && validCount > 0) { drivers.push({ axis: 'energy_vulnerability', description: `Average import dependency ${(avgImport * 100).toFixed(0)}% across ${validCount} countries`, magnitude: round(avgImport), evidence_ids: ['energy:mix'], orientation: 'pressure', }); } return clip(score, 0, 1); } function computeAllianceCohesion(regionId, sources, drivers) { // Phase 0: rough alliance signal from forecast actor lenses. // No headline classification yet (deferred to Phase 1 LLM batch tagging). const fc = sources['forecast:predictions:v2']; const forecasts = Array.isArray(fc?.predictions) ? fc.predictions : []; const region = REGIONS.find((r) => r.id === regionId); const inRegion = forecasts.filter((f) => { const fRegion = String(f?.region ?? '').toLowerCase(); return fRegion.includes(String(region?.forecastLabel ?? '').toLowerCase()); }); const allianceRefs = inRegion.filter((f) => { const cf = JSON.stringify(f?.caseFile ?? {}).toLowerCase(); return /alliance|treaty|coordination|coalition|nato|gcc/.test(cf); }).length; const cActor = clip(allianceRefs / 5, 0, 1); // Baseline: assume neutral cohesion if no data const score = 0.4 * cActor + 0.6 * 0.5; if (cActor > 0.2) { drivers.push({ axis: 'alliance_cohesion', description: `${allianceRefs} forecast actor lenses reference alliance dynamics`, magnitude: round(cActor), evidence_ids: [], orientation: 'buffer', }); } return clip(score, 0, 1); } function computeMaritimeAccess(corridors, sources, drivers) { if (!corridors.length) return 0.7; // Inland regions: assume neutral-good const chokepointData = sources['supply_chain:chokepoints:v4']; const allCps = Array.isArray(chokepointData?.chokepoints) ? chokepointData.chokepoints : []; const cpById = new Map(); for (const cp of allCps) cpById.set(String(cp?.id ?? ''), cp); const transitData = sources['supply_chain:transit-summaries:v1']; const summaries = transitData?.summaries ?? {}; let weightedSum = 0; let totalWeight = 0; for (const corridor of corridors) { if (!corridor.chokepointId) continue; const cp = cpById.get(corridor.chokepointId); if (!cp) continue; const threatLevel = String(cp?.threatLevel ?? cp?.status ?? 'normal').toLowerCase(); const threatMap = { war_zone: 0.0, critical: 0.2, high: 0.4, elevated: 0.6, normal: 1.0 }; const mThreat = threatMap[threatLevel] ?? 0.7; const summary = summaries[corridor.chokepointId]; const wow = num(summary?.wowChangePct, 0); const mThroughput = clip((100 + wow) / 120, 0, 1); // 0% change -> 0.83, +20% -> 1.0 const mCorridor = 0.6 * mThreat + 0.4 * mThroughput; weightedSum += mCorridor * corridor.weight; totalWeight += corridor.weight; if (mThreat < 0.6) { drivers.push({ axis: 'maritime_access', description: `${corridor.label} threat level: ${sanitizeEvidenceString(threatLevel)}`, magnitude: round(1 - mThreat), evidence_ids: [`chokepoint:${corridor.chokepointId}`], orientation: 'buffer', }); } } return totalWeight > 0 ? clip(weightedSum / totalWeight, 0, 1) : 0.7; } function computeEnergyLeverage(countries, drivers) { // Producer leverage = max across region's producers const TOP_PRODUCERS = new Set(['SA', 'RU', 'US', 'IR', 'IQ', 'AE', 'CA', 'KW', 'QA', 'NG', 'NO', 'BR', 'MX', 'VE', 'AU']); const inRegion = [...countries].filter((c) => TOP_PRODUCERS.has(c)); if (!inRegion.length) return 0; // Phase 0: simple presence-based leverage. Sophistication arrives in Phase 1. const baseLeverage = 0.6; const score = inRegion.length >= 3 ? Math.min(1, baseLeverage + 0.1 * inRegion.length) : baseLeverage; drivers.push({ axis: 'energy_leverage', description: `${inRegion.length} top-15 producers in region: ${inRegion.join(', ')}`, magnitude: round(score), evidence_ids: inRegion.map((iso) => `producer:${iso}`), orientation: 'buffer', }); return clip(score, 0, 1); } function round(n) { return Math.round(n * 1000) / 1000; }