diff --git a/scripts/regional-snapshot/balance-vector.mjs b/scripts/regional-snapshot/balance-vector.mjs index 55bd0eaa1..e901119dd 100644 --- a/scripts/regional-snapshot/balance-vector.mjs +++ b/scripts/regional-snapshot/balance-vector.mjs @@ -5,8 +5,14 @@ import { clip, num, weightedAverage, percentile } from './_helpers.mjs'; // Use scripts/shared mirror (not repo-root shared/): Railway service has -// rootDirectory=scripts so ../../shared/ escapes the deploy root. -import { getRegionCountries, getRegionCorridors, countryCriticality, REGIONS } from '../shared/geography.js'; +// 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} */ @@ -66,14 +72,12 @@ export function computeBalanceVector(regionId, sources) { // ──────────────────────────────────────────────────────────────────────────── function computeCoercivePressure(region, sources, drivers) { - // Cross-source signals scoped by theater label substring matching + // 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 theaterLabels = region.theaters; // theater IDs are kebab-case; cross-source uses display names - const inRegion = signals.filter((s) => { - const t = String(s?.theater ?? '').toLowerCase(); - return theaterLabels.some((label) => t.includes(label.replace(/-/g, ' '))); - }); + 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; diff --git a/scripts/regional-snapshot/evidence-collector.mjs b/scripts/regional-snapshot/evidence-collector.mjs index d9fbb8d3e..12e36f502 100644 --- a/scripts/regional-snapshot/evidence-collector.mjs +++ b/scripts/regional-snapshot/evidence-collector.mjs @@ -5,8 +5,8 @@ import { num } from './_helpers.mjs'; // Use scripts/shared mirror (not repo-root shared/): Railway service has -// rootDirectory=scripts so ../../shared/ escapes the deploy root. -import { REGIONS } from '../shared/geography.js'; +// rootDirectory=scripts so ../../shared/ escapes the deploy root. See #2954. +import { REGIONS, getRegionCorridors, isSignalInRegion } from '../shared/geography.js'; const MAX_EVIDENCE_PER_SNAPSHOT = 30; @@ -22,12 +22,13 @@ export function collectEvidence(regionId, sources) { /** @type {import('../../shared/regions.types.js').EvidenceItem[]} */ const out = []; - // Cross-source signals + // Cross-source signals. Match against both fine-grained theater IDs and + // the broad display labels the seed emits ("Middle East", "Sub-Saharan + // Africa") — see isSignalInRegion in shared/geography.js. const xss = sources['intelligence:cross-source-signals:v1']?.signals; if (Array.isArray(xss)) { for (const s of xss) { - const theater = String(s?.theater ?? '').toLowerCase(); - if (!region.theaters.some((t) => theater.includes(t.replace(/-/g, ' ')))) continue; + if (!isSignalInRegion(s?.theater, region)) continue; out.push({ id: String(s?.id ?? `xss:${out.length}`), type: 'market_signal', @@ -61,21 +62,30 @@ export function collectEvidence(regionId, sources) { } } - // Chokepoint status changes for region's corridors + // Chokepoint status changes — scoped to this region's corridors only. + // Without this filter, Taiwan / Baltic / Panama events would leak into + // MENA and SSA evidence chains. + const regionChokepointIds = new Set( + getRegionCorridors(regionId) + .map((c) => c.chokepointId) + .filter((id) => typeof id === 'string' && id.length > 0), + ); const cps = sources['supply_chain:chokepoints:v4']?.chokepoints; if (Array.isArray(cps)) { for (const cp of cps) { + const cpId = String(cp?.id ?? ''); + if (!regionChokepointIds.has(cpId)) continue; const threat = String(cp?.threatLevel ?? '').toLowerCase(); if (threat === 'normal' || threat === '') continue; out.push({ - id: `chokepoint:${cp.id}`, + id: `chokepoint:${cpId}`, type: 'chokepoint_status', source: 'supply-chain', - summary: `${cp?.name ?? cp?.id}: ${threat}`, + summary: `${cp?.name ?? cpId}: ${threat}`, confidence: 0.95, observed_at: Date.now(), theater: '', - corridor: String(cp?.id ?? ''), + corridor: cpId, }); } } diff --git a/scripts/shared/geography.js b/scripts/shared/geography.js index a21a7a7f7..722cb35fd 100644 --- a/scripts/shared/geography.js +++ b/scripts/shared/geography.js @@ -41,6 +41,12 @@ export const GEOGRAPHY_VERSION = '1.0.0'; * the existing forecast handler does substring matching against, so the same * label flows end-to-end without taxonomy mismatch. */ +// `signalAliases` holds broad display labels that cross-source feeds emit +// which do NOT substring-match any fine-grained theater ID. Example: +// scripts/seed-cross-source-signals.mjs normalizes raw values to "Middle East" +// or "Sub-Saharan Africa", and these would silently drop out of region +// matching if we only checked `theaters`. Kept lowercased so the matching +// helper can compare directly. export const REGIONS = [ { id: 'mena', @@ -48,6 +54,7 @@ export const REGIONS = [ forecastLabel: 'Middle East', wbCode: 'MEA', theaters: ['levant', 'persian-gulf', 'red-sea', 'north-africa'], + signalAliases: ['middle east', 'mena'], feedRegion: 'middleeast', mapView: 'mena', keyCountries: ['SA', 'IR', 'IL', 'AE', 'EG', 'IQ', 'TR'], @@ -58,6 +65,7 @@ export const REGIONS = [ forecastLabel: 'East Asia', wbCode: 'EAS', theaters: ['east-asia', 'southeast-asia'], + signalAliases: ['asia pacific', 'apac'], feedRegion: 'asia', mapView: 'asia', keyCountries: ['CN', 'JP', 'KR', 'TW', 'AU', 'SG', 'ID'], @@ -68,6 +76,10 @@ export const REGIONS = [ forecastLabel: 'Europe', wbCode: 'ECS', theaters: ['eastern-europe', 'western-europe', 'baltic', 'arctic'], + // 'europe' is long enough to avoid substring false-positives; bare 'eu' was + // removed because it would match 'fuel', 'neutral zone', etc. under the + // substring-includes matching in isSignalInRegion. + signalAliases: ['europe', 'european union'], feedRegion: 'europe', mapView: 'eu', keyCountries: ['DE', 'FR', 'GB', 'UA', 'RU', 'PL', 'IT'], @@ -78,6 +90,7 @@ export const REGIONS = [ forecastLabel: 'North America', wbCode: 'NAC', theaters: ['north-america'], + signalAliases: [], feedRegion: 'us', mapView: 'america', keyCountries: ['US', 'CA', 'MX'], @@ -88,6 +101,7 @@ export const REGIONS = [ forecastLabel: 'South Asia', wbCode: 'SAS', theaters: ['south-asia'], + signalAliases: [], feedRegion: 'asia', mapView: 'asia', keyCountries: ['IN', 'PK', 'BD', 'LK', 'AF'], @@ -98,6 +112,7 @@ export const REGIONS = [ forecastLabel: 'Latin America', wbCode: 'LCN', theaters: ['latin-america', 'caribbean'], + signalAliases: ['latam'], feedRegion: 'latam', mapView: 'latam', keyCountries: ['BR', 'AR', 'CO', 'CL', 'VE', 'PE'], @@ -108,6 +123,7 @@ export const REGIONS = [ forecastLabel: 'Africa', wbCode: 'SSF', theaters: ['horn-of-africa', 'sahel', 'southern-africa', 'central-africa'], + signalAliases: ['sub-saharan africa', 'subsaharan africa'], feedRegion: 'africa', mapView: 'africa', keyCountries: ['NG', 'ZA', 'KE', 'ET', 'SD', 'CD'], @@ -118,6 +134,7 @@ export const REGIONS = [ forecastLabel: '', wbCode: '1W', theaters: ['global-markets'], + signalAliases: ['global'], feedRegion: 'worldwide', mapView: 'global', keyCountries: ['US', 'CN', 'RU', 'DE', 'JP', 'IN', 'GB', 'SA'], @@ -267,13 +284,72 @@ export function getTheaterCorridors(theaterId) { return CORRIDORS.filter((c) => c.theaterId === theaterId); } -/** @param {string} regionId */ +/** + * Corridors owned by a region. Derived from TWO sources so shared chokepoints + * surface for every region they actually touch geographically: + * + * 1. Direct: corridors whose primary `theaterId` lives in this region. + * 2. Indirect: corridors explicitly listed in any of this region's + * `theaters[].corridorIds` — this is how a chokepoint that primarily + * belongs to another region can still be claimed by a secondary region. + * + * Example: Bab el-Mandeb (`babelm`) has `theaterId: 'red-sea'` (MENA), but + * it physically borders Djibouti and Eritrea as well, so the SSA theater + * `horn-of-africa` declares `corridorIds: ['babelm']` and picks it up here. + * Same for Panama → caribbean (LatAm) alongside its primary north-america + * theater (NA). + * + * De-duplicated by corridor id, so a corridor owned directly + indirectly + * (none today, but possible if a theater lists its own primary corridors) + * is still returned once. + * + * @param {string} regionId + */ export function getRegionCorridors(regionId) { - const theaterIds = new Set(getRegionTheaters(regionId).map((t) => t.id)); - return CORRIDORS.filter((c) => theaterIds.has(c.theaterId)); + const theaters = getRegionTheaters(regionId); + const theaterIds = new Set(theaters.map((t) => t.id)); + const indirectIds = new Set(theaters.flatMap((t) => t.corridorIds ?? [])); + const seen = new Map(); + for (const c of CORRIDORS) { + if (theaterIds.has(c.theaterId) || indirectIds.has(c.id)) { + seen.set(c.id, c); + } + } + return [...seen.values()]; } /** @param {string} iso2 */ export function countryCriticality(iso2) { return COUNTRY_CRITICALITY[iso2] ?? DEFAULT_COUNTRY_CRITICALITY; } + +/** + * Tests whether a raw theater label from a cross-source signal belongs to + * the given region. Case-insensitive substring match against both the + * fine-grained theater IDs (after kebab-to-space transform) and the region's + * `signalAliases` for broad labels the seed feeds actually emit. + * + * Example: `isSignalInRegion('Middle East', 'mena')` returns true via alias; + * `isSignalInRegion('persian-gulf', 'mena')` returns true via theater ID. + * + * @param {string | undefined | null} theater - raw theater label (free-text) + * @param {string | { id?: string, theaters?: string[], signalAliases?: string[] }} regionOrId + * @returns {boolean} + */ +export function isSignalInRegion(theater, regionOrId) { + const region = typeof regionOrId === 'string' ? getRegion(regionOrId) : regionOrId; + if (!region) return false; + // Normalize both sides: lowercase, trim, collapse dashes to spaces so that + // 'persian-gulf' and 'Persian Gulf' are treated as the same token. + const t = String(theater ?? '').toLowerCase().trim().replace(/-/g, ' '); + if (!t) return false; + const theaters = Array.isArray(region.theaters) ? region.theaters : []; + for (const label of theaters) { + if (t.includes(String(label).toLowerCase().replace(/-/g, ' '))) return true; + } + const aliases = Array.isArray(region.signalAliases) ? region.signalAliases : []; + for (const alias of aliases) { + if (t.includes(String(alias).toLowerCase().replace(/-/g, ' '))) return true; + } + return false; +} diff --git a/shared/geography.d.ts b/shared/geography.d.ts index efb110f92..6b997cf22 100644 --- a/shared/geography.d.ts +++ b/shared/geography.d.ts @@ -9,6 +9,13 @@ export interface RegionDef { forecastLabel: string; wbCode: string; theaters: string[]; + /** + * Broad display labels emitted by cross-source feeds that do not substring- + * match any fine-grained theater ID. Lowercased so the matching helper can + * compare directly. Example: MENA includes "middle east", SSA includes + * "sub-saharan africa". + */ + signalAliases: string[]; feedRegion: string; mapView: string; keyCountries: string[]; @@ -48,3 +55,14 @@ export function getRegionTheaters(regionId: string): TheaterDef[]; export function getTheaterCorridors(theaterId: string): CorridorDef[]; export function getRegionCorridors(regionId: string): CorridorDef[]; export function countryCriticality(iso2: string): number; + +/** + * Returns true when a cross-source signal's raw `theater` label belongs to + * the given region. Case-insensitive, tolerates kebab-case or spaced labels, + * and matches against both fine-grained theater IDs and the region's + * `signalAliases` for broad display labels. + */ +export function isSignalInRegion( + theater: string | null | undefined, + regionOrId: string | RegionDef, +): boolean; diff --git a/shared/geography.js b/shared/geography.js index a21a7a7f7..722cb35fd 100644 --- a/shared/geography.js +++ b/shared/geography.js @@ -41,6 +41,12 @@ export const GEOGRAPHY_VERSION = '1.0.0'; * the existing forecast handler does substring matching against, so the same * label flows end-to-end without taxonomy mismatch. */ +// `signalAliases` holds broad display labels that cross-source feeds emit +// which do NOT substring-match any fine-grained theater ID. Example: +// scripts/seed-cross-source-signals.mjs normalizes raw values to "Middle East" +// or "Sub-Saharan Africa", and these would silently drop out of region +// matching if we only checked `theaters`. Kept lowercased so the matching +// helper can compare directly. export const REGIONS = [ { id: 'mena', @@ -48,6 +54,7 @@ export const REGIONS = [ forecastLabel: 'Middle East', wbCode: 'MEA', theaters: ['levant', 'persian-gulf', 'red-sea', 'north-africa'], + signalAliases: ['middle east', 'mena'], feedRegion: 'middleeast', mapView: 'mena', keyCountries: ['SA', 'IR', 'IL', 'AE', 'EG', 'IQ', 'TR'], @@ -58,6 +65,7 @@ export const REGIONS = [ forecastLabel: 'East Asia', wbCode: 'EAS', theaters: ['east-asia', 'southeast-asia'], + signalAliases: ['asia pacific', 'apac'], feedRegion: 'asia', mapView: 'asia', keyCountries: ['CN', 'JP', 'KR', 'TW', 'AU', 'SG', 'ID'], @@ -68,6 +76,10 @@ export const REGIONS = [ forecastLabel: 'Europe', wbCode: 'ECS', theaters: ['eastern-europe', 'western-europe', 'baltic', 'arctic'], + // 'europe' is long enough to avoid substring false-positives; bare 'eu' was + // removed because it would match 'fuel', 'neutral zone', etc. under the + // substring-includes matching in isSignalInRegion. + signalAliases: ['europe', 'european union'], feedRegion: 'europe', mapView: 'eu', keyCountries: ['DE', 'FR', 'GB', 'UA', 'RU', 'PL', 'IT'], @@ -78,6 +90,7 @@ export const REGIONS = [ forecastLabel: 'North America', wbCode: 'NAC', theaters: ['north-america'], + signalAliases: [], feedRegion: 'us', mapView: 'america', keyCountries: ['US', 'CA', 'MX'], @@ -88,6 +101,7 @@ export const REGIONS = [ forecastLabel: 'South Asia', wbCode: 'SAS', theaters: ['south-asia'], + signalAliases: [], feedRegion: 'asia', mapView: 'asia', keyCountries: ['IN', 'PK', 'BD', 'LK', 'AF'], @@ -98,6 +112,7 @@ export const REGIONS = [ forecastLabel: 'Latin America', wbCode: 'LCN', theaters: ['latin-america', 'caribbean'], + signalAliases: ['latam'], feedRegion: 'latam', mapView: 'latam', keyCountries: ['BR', 'AR', 'CO', 'CL', 'VE', 'PE'], @@ -108,6 +123,7 @@ export const REGIONS = [ forecastLabel: 'Africa', wbCode: 'SSF', theaters: ['horn-of-africa', 'sahel', 'southern-africa', 'central-africa'], + signalAliases: ['sub-saharan africa', 'subsaharan africa'], feedRegion: 'africa', mapView: 'africa', keyCountries: ['NG', 'ZA', 'KE', 'ET', 'SD', 'CD'], @@ -118,6 +134,7 @@ export const REGIONS = [ forecastLabel: '', wbCode: '1W', theaters: ['global-markets'], + signalAliases: ['global'], feedRegion: 'worldwide', mapView: 'global', keyCountries: ['US', 'CN', 'RU', 'DE', 'JP', 'IN', 'GB', 'SA'], @@ -267,13 +284,72 @@ export function getTheaterCorridors(theaterId) { return CORRIDORS.filter((c) => c.theaterId === theaterId); } -/** @param {string} regionId */ +/** + * Corridors owned by a region. Derived from TWO sources so shared chokepoints + * surface for every region they actually touch geographically: + * + * 1. Direct: corridors whose primary `theaterId` lives in this region. + * 2. Indirect: corridors explicitly listed in any of this region's + * `theaters[].corridorIds` — this is how a chokepoint that primarily + * belongs to another region can still be claimed by a secondary region. + * + * Example: Bab el-Mandeb (`babelm`) has `theaterId: 'red-sea'` (MENA), but + * it physically borders Djibouti and Eritrea as well, so the SSA theater + * `horn-of-africa` declares `corridorIds: ['babelm']` and picks it up here. + * Same for Panama → caribbean (LatAm) alongside its primary north-america + * theater (NA). + * + * De-duplicated by corridor id, so a corridor owned directly + indirectly + * (none today, but possible if a theater lists its own primary corridors) + * is still returned once. + * + * @param {string} regionId + */ export function getRegionCorridors(regionId) { - const theaterIds = new Set(getRegionTheaters(regionId).map((t) => t.id)); - return CORRIDORS.filter((c) => theaterIds.has(c.theaterId)); + const theaters = getRegionTheaters(regionId); + const theaterIds = new Set(theaters.map((t) => t.id)); + const indirectIds = new Set(theaters.flatMap((t) => t.corridorIds ?? [])); + const seen = new Map(); + for (const c of CORRIDORS) { + if (theaterIds.has(c.theaterId) || indirectIds.has(c.id)) { + seen.set(c.id, c); + } + } + return [...seen.values()]; } /** @param {string} iso2 */ export function countryCriticality(iso2) { return COUNTRY_CRITICALITY[iso2] ?? DEFAULT_COUNTRY_CRITICALITY; } + +/** + * Tests whether a raw theater label from a cross-source signal belongs to + * the given region. Case-insensitive substring match against both the + * fine-grained theater IDs (after kebab-to-space transform) and the region's + * `signalAliases` for broad labels the seed feeds actually emit. + * + * Example: `isSignalInRegion('Middle East', 'mena')` returns true via alias; + * `isSignalInRegion('persian-gulf', 'mena')` returns true via theater ID. + * + * @param {string | undefined | null} theater - raw theater label (free-text) + * @param {string | { id?: string, theaters?: string[], signalAliases?: string[] }} regionOrId + * @returns {boolean} + */ +export function isSignalInRegion(theater, regionOrId) { + const region = typeof regionOrId === 'string' ? getRegion(regionOrId) : regionOrId; + if (!region) return false; + // Normalize both sides: lowercase, trim, collapse dashes to spaces so that + // 'persian-gulf' and 'Persian Gulf' are treated as the same token. + const t = String(theater ?? '').toLowerCase().trim().replace(/-/g, ' '); + if (!t) return false; + const theaters = Array.isArray(region.theaters) ? region.theaters : []; + for (const label of theaters) { + if (t.includes(String(label).toLowerCase().replace(/-/g, ' '))) return true; + } + const aliases = Array.isArray(region.signalAliases) ? region.signalAliases : []; + for (const alias of aliases) { + if (t.includes(String(alias).toLowerCase().replace(/-/g, ' '))) return true; + } + return false; +} diff --git a/tests/regional-snapshot.test.mjs b/tests/regional-snapshot.test.mjs index a3c3cead5..fb7cfbd00 100644 --- a/tests/regional-snapshot.test.mjs +++ b/tests/regional-snapshot.test.mjs @@ -16,6 +16,7 @@ import { regionForCountry, getRegionCorridors, countryCriticality, + isSignalInRegion, } from '../shared/geography.js'; import { computeBalanceVector, SCORING_VERSION } from '../scripts/regional-snapshot/balance-vector.mjs'; @@ -103,6 +104,70 @@ describe('shared/geography', () => { it('GEOGRAPHY_VERSION follows semver', () => { assert.match(GEOGRAPHY_VERSION, /^\d+\.\d+\.\d+$/); }); + + it('every region exposes a signalAliases array', () => { + for (const r of REGIONS) { + assert.ok(Array.isArray(r.signalAliases), `${r.id} missing signalAliases`); + } + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// isSignalInRegion: cross-source signal theater matching +// ──────────────────────────────────────────────────────────────────────────── +// +// Regression coverage for the silent-drop bug: seed-cross-source-signals.mjs +// emits broad display labels ("Middle East", "Sub-Saharan Africa") that +// don't substring-match any theater ID in region.theaters. The helper must +// handle both fine-grained theater IDs and these broad aliases. + +describe('isSignalInRegion', () => { + it('matches fine-grained theater IDs (kebab-case -> space)', () => { + assert.equal(isSignalInRegion('persian-gulf', 'mena'), true); + assert.equal(isSignalInRegion('persian gulf', 'mena'), true); + assert.equal(isSignalInRegion('Persian Gulf', 'mena'), true); + assert.equal(isSignalInRegion('Red Sea', 'mena'), true); + assert.equal(isSignalInRegion('North Africa', 'mena'), true); + assert.equal(isSignalInRegion('Eastern Europe', 'europe'), true); + assert.equal(isSignalInRegion('Western Europe', 'europe'), true); + assert.equal(isSignalInRegion('East Asia', 'east-asia'), true); + assert.equal(isSignalInRegion('Horn of Africa', 'sub-saharan-africa'), true); + }); + + it('matches the broad display labels the seed actually emits', () => { + // This is the core regression. Before the fix, these all returned false. + assert.equal(isSignalInRegion('Middle East', 'mena'), true); + assert.equal(isSignalInRegion('Sub-Saharan Africa', 'sub-saharan-africa'), true); + assert.equal(isSignalInRegion('Global', 'global'), true); + }); + + it('is case-insensitive and tolerates whitespace', () => { + assert.equal(isSignalInRegion(' middle east ', 'mena'), true); + assert.equal(isSignalInRegion('MIDDLE EAST', 'mena'), true); + }); + + it('rejects theater labels that belong to other regions', () => { + assert.equal(isSignalInRegion('Middle East', 'east-asia'), false); + assert.equal(isSignalInRegion('Middle East', 'sub-saharan-africa'), false); + assert.equal(isSignalInRegion('Taiwan Strait', 'mena'), false); + assert.equal(isSignalInRegion('Sub-Saharan Africa', 'mena'), false); + assert.equal(isSignalInRegion('Eastern Europe', 'mena'), false); + }); + + it('returns false for empty or missing theater', () => { + assert.equal(isSignalInRegion('', 'mena'), false); + assert.equal(isSignalInRegion(null, 'mena'), false); + assert.equal(isSignalInRegion(undefined, 'mena'), false); + }); + + it('returns false for unknown region IDs', () => { + assert.equal(isSignalInRegion('Middle East', 'not-a-region'), false); + }); + + it('accepts a region object as well as a region ID', () => { + const mena = REGIONS.find((r) => r.id === 'mena'); + assert.equal(isSignalInRegion('Middle East', mena), true); + }); }); // ──────────────────────────────────────────────────────────────────────────── @@ -558,6 +623,135 @@ describe('diffRegionalSnapshot', () => { }); }); +// ──────────────────────────────────────────────────────────────────────────── +// Evidence collector: region scoping +// ──────────────────────────────────────────────────────────────────────────── +// +// Regression coverage: the chokepoint loop used to iterate all chokepoints +// without filtering by regionId, leaking Taiwan/Baltic events into MENA/SSA +// evidence chains. + +describe('collectEvidence region scoping', () => { + const mixedChokepoints = () => ({ + 'supply_chain:chokepoints:v4': { + chokepoints: [ + { id: 'hormuz', name: 'Strait of Hormuz', threatLevel: 'elevated' }, + { id: 'suez', name: 'Suez', threatLevel: 'high' }, + { id: 'babelm', name: 'Bab el-Mandeb', threatLevel: 'high' }, + { id: 'taiwan_strait', name: 'Taiwan Strait', threatLevel: 'high' }, + { id: 'malacca', name: 'Strait of Malacca', threatLevel: 'elevated' }, + { id: 'panama', name: 'Panama Canal', threatLevel: 'elevated' }, + { id: 'danish', name: 'Danish Straits', threatLevel: 'high' }, + { id: 'bosphorus', name: 'Bosphorus', threatLevel: 'elevated' }, + ], + }, + }); + + it('MENA evidence only includes MENA-owned chokepoints', () => { + const evidence = collectEvidence('mena', mixedChokepoints()); + const ids = evidence.filter((e) => e.type === 'chokepoint_status').map((e) => e.corridor); + assert.deepEqual(ids.sort(), ['babelm', 'hormuz', 'suez']); + assert.ok(!ids.includes('taiwan_strait')); + assert.ok(!ids.includes('danish')); + assert.ok(!ids.includes('malacca')); + }); + + it('East Asia evidence only includes East Asia chokepoints', () => { + const evidence = collectEvidence('east-asia', mixedChokepoints()); + const ids = evidence.filter((e) => e.type === 'chokepoint_status').map((e) => e.corridor); + assert.deepEqual(ids.sort(), ['malacca', 'taiwan_strait']); + assert.ok(!ids.includes('hormuz')); + }); + + it('Europe evidence only includes Europe chokepoints', () => { + const evidence = collectEvidence('europe', mixedChokepoints()); + const ids = evidence.filter((e) => e.type === 'chokepoint_status').map((e) => e.corridor); + assert.deepEqual(ids.sort(), ['bosphorus', 'danish']); + }); + + it('SSA evidence includes Bab el-Mandeb via horn-of-africa.corridorIds', () => { + // Bab el-Mandeb physically borders Djibouti/Eritrea (SSA) as well as + // Yemen (MENA). It belongs to the MENA `red-sea` theater directly, but + // `horn-of-africa` claims it via corridorIds so SSA also surfaces its + // threat events. Cape of Good Hope has chokepointId:null and does not + // appear in the supply_chain:chokepoints:v4 payload, so it is absent + // here — but babelm now correctly surfaces for SSA. + const evidence = collectEvidence('sub-saharan-africa', mixedChokepoints()); + const ids = evidence.filter((e) => e.type === 'chokepoint_status').map((e) => e.corridor); + assert.deepEqual(ids.sort(), ['babelm']); + assert.ok(!ids.includes('hormuz')); + assert.ok(!ids.includes('taiwan_strait')); + }); + + it('LatAm evidence includes Panama via caribbean.corridorIds', () => { + // Panama Canal's primary theater is `north-america` (NA), but the + // `caribbean` theater claims it via corridorIds so LatAm snapshots + // also see Panama events. MENA/East Asia chokepoints must still be + // excluded from LatAm. + const evidence = collectEvidence('latam', mixedChokepoints()); + const ids = evidence.filter((e) => e.type === 'chokepoint_status').map((e) => e.corridor); + assert.deepEqual(ids.sort(), ['panama']); + assert.ok(!ids.includes('hormuz')); + assert.ok(!ids.includes('taiwan_strait')); + }); + + it('skips chokepoints with normal or empty threat levels even when in region', () => { + const evidence = collectEvidence('mena', { + 'supply_chain:chokepoints:v4': { + chokepoints: [ + { id: 'hormuz', threatLevel: 'normal' }, + { id: 'suez', threatLevel: '' }, + { id: 'babelm', threatLevel: 'high' }, + ], + }, + }); + const ids = evidence.filter((e) => e.type === 'chokepoint_status').map((e) => e.corridor); + assert.deepEqual(ids, ['babelm']); + }); + + it('cross-source signals emitted with broad "Middle East" label land in MENA evidence', () => { + // Regression for the theater-matching bug: before the fix, this signal + // was silently dropped because 'middle east' does not substring-match + // any of MENA's theater IDs ('levant', 'persian-gulf', 'red-sea', + // 'north-africa') after kebab-to-space transform. + const evidence = collectEvidence('mena', { + 'intelligence:cross-source-signals:v1': { + signals: [ + { + id: 'sig-broad-mena', + summary: 'Broad MENA pressure signal', + theater: 'Middle East', + severity: 'HIGH', + severityScore: 80, + detectedAt: Date.now(), + }, + ], + }, + }); + const xssIds = evidence.filter((e) => e.source === 'cross-source').map((e) => e.id); + assert.ok(xssIds.includes('sig-broad-mena')); + }); + + it('cross-source signals emitted with broad "Sub-Saharan Africa" label land in SSA evidence', () => { + const evidence = collectEvidence('sub-saharan-africa', { + 'intelligence:cross-source-signals:v1': { + signals: [ + { + id: 'sig-broad-ssa', + summary: 'Broad SSA unrest signal', + theater: 'Sub-Saharan Africa', + severity: 'HIGH', + severityScore: 75, + detectedAt: Date.now(), + }, + ], + }, + }); + const xssIds = evidence.filter((e) => e.source === 'cross-source').map((e) => e.id); + assert.ok(xssIds.includes('sig-broad-ssa')); + }); +}); + // ──────────────────────────────────────────────────────────────────────────── // End-to-end pipeline (no Redis) // ────────────────────────────────────────────────────────────────────────────