fix(intelligence): region-scope signals and chokepoint evidence (#2952)

* fix(intelligence): region-scope signals and chokepoint evidence

Two review findings on PR #2940 caused MENA/SSA snapshots to silently
drop broad cross-source signals and leak foreign chokepoint events into
every region's evidence chain.

P1 - theater label matching
  seed-cross-source-signals.mjs normalizes raw values to broad display
  labels like "Middle East" and "Sub-Saharan Africa". The consumer side
  compared these against region.theaters kebab IDs (levant, persian-gulf,
  horn-of-africa). "middle east" does not substring-match any of those,
  so every MENA signal emitted with the broad label was silently dropped
  from coercive_pressure and the evidence chain. Same for SSA.

  Added signalAliases per region and a shared isSignalInRegion helper in
  shared/geography.js. Both balance-vector.mjs and evidence-collector.mjs
  now route signals through the helper, which normalizes both sides and
  matches against theater IDs or region aliases.

P2 - chokepoint region leak
  evidence-collector.mjs:62 iterated every chokepoint in the payload
  without filtering by regionId, so Taiwan Strait, Baltic, and Panama
  threat events surfaced in MENA and SSA evidence chains. Now derives
  the allowed chokepoint ID set from getRegionCorridors(regionId) and
  skips anything not owned by the region.

Added 15 unit tests covering: broad-label matching, kebab/spaced input,
cross-region rejection, and the chokepoint filter for MENA/East Asia/
Europe/SSA.

* fix(intelligence): address Greptile P2 review findings on #2952

Two P2 findings from Greptile on the region-scoping PR.

1) Drop 'eu' short alias from europe.signalAliases
   `isSignalInRegion` uses substring matching, and bare 'eu' would match
   any theater label containing those two letters ('fuel', 'neutral zone',
   'feudal'). Replaced with 'european union' which is long enough to be
   unambiguous. No seed currently emits a bare 'eu' label, so this is
   pure hardening.

2) Make THEATERS.corridorIds live data via getRegionCorridors union
   horn-of-africa declared corridorIds: ['babelm'] and caribbean declared
   corridorIds: ['panama'], but `getRegionCorridors` only consulted
   CORRIDORS.theaterId — so those entries were dead data. After yesterday's
   region-scoped chokepoint filter, Bab el-Mandeb threat events landed
   ONLY in MENA evidence (via the primary red-sea theater), never in SSA,
   even though the corridor physically borders Djibouti/Eritrea. Same for
   Panama missing from LatAm.

   `getRegionCorridors` now unions direct theater membership (via
   CORRIDORS.theaterId) with indirect claims (via THEATERS.corridorIds),
   de-duplicated by corridor id. This reflects geopolitical reality:
     - MENA + SSA both see babelm threat events
     - NA + LatAm both see panama threat events
   Scoring impact: SSA maritime_access now weighs babelm (weight 0.9),
   LatAm maritime_access now weighs panama (weight 0.6). These were
   missing buffers under the pre-fix model.

   Added regression tests for both new paths. The existing
   "SSA evidence has no chokepoints" test was inverted to assert SSA now
   DOES include babelm (and excludes MENA/East Asia corridors).
This commit is contained in:
Elie Habib
2026-04-11 20:28:55 +04:00
committed by GitHub
parent 7da202c25d
commit 08a5eb77a5
6 changed files with 401 additions and 23 deletions

View File

@@ -5,8 +5,14 @@
import { clip, num, weightedAverage, percentile } from './_helpers.mjs'; import { clip, num, weightedAverage, percentile } from './_helpers.mjs';
// Use scripts/shared mirror (not repo-root shared/): Railway service has // Use scripts/shared mirror (not repo-root shared/): Railway service has
// rootDirectory=scripts so ../../shared/ escapes the deploy root. // rootDirectory=scripts so ../../shared/ escapes the deploy root. See #2954.
import { getRegionCountries, getRegionCorridors, countryCriticality, REGIONS } from '../shared/geography.js'; import {
getRegionCountries,
getRegionCorridors,
countryCriticality,
REGIONS,
isSignalInRegion,
} from '../shared/geography.js';
import iso3ToIso2Raw from '../shared/iso3-to-iso2.json' with { type: 'json' }; import iso3ToIso2Raw from '../shared/iso3-to-iso2.json' with { type: 'json' };
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
@@ -66,14 +72,12 @@ export function computeBalanceVector(regionId, sources) {
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
function computeCoercivePressure(region, sources, drivers) { 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 xss = sources['intelligence:cross-source-signals:v1'];
const signals = Array.isArray(xss?.signals) ? xss.signals : []; 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) => isSignalInRegion(s?.theater, region));
const inRegion = signals.filter((s) => {
const t = String(s?.theater ?? '').toLowerCase();
return theaterLabels.some((label) => t.includes(label.replace(/-/g, ' ')));
});
const criticalCount = inRegion.filter((s) => /CRITICAL/i.test(String(s?.severity ?? ''))).length; const criticalCount = inRegion.filter((s) => /CRITICAL/i.test(String(s?.severity ?? ''))).length;
const highCount = inRegion.filter((s) => /HIGH/i.test(String(s?.severity ?? ''))).length; const highCount = inRegion.filter((s) => /HIGH/i.test(String(s?.severity ?? ''))).length;

View File

@@ -5,8 +5,8 @@
import { num } from './_helpers.mjs'; import { num } from './_helpers.mjs';
// Use scripts/shared mirror (not repo-root shared/): Railway service has // Use scripts/shared mirror (not repo-root shared/): Railway service has
// rootDirectory=scripts so ../../shared/ escapes the deploy root. // rootDirectory=scripts so ../../shared/ escapes the deploy root. See #2954.
import { REGIONS } from '../shared/geography.js'; import { REGIONS, getRegionCorridors, isSignalInRegion } from '../shared/geography.js';
const MAX_EVIDENCE_PER_SNAPSHOT = 30; const MAX_EVIDENCE_PER_SNAPSHOT = 30;
@@ -22,12 +22,13 @@ export function collectEvidence(regionId, sources) {
/** @type {import('../../shared/regions.types.js').EvidenceItem[]} */ /** @type {import('../../shared/regions.types.js').EvidenceItem[]} */
const out = []; 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; const xss = sources['intelligence:cross-source-signals:v1']?.signals;
if (Array.isArray(xss)) { if (Array.isArray(xss)) {
for (const s of xss) { for (const s of xss) {
const theater = String(s?.theater ?? '').toLowerCase(); if (!isSignalInRegion(s?.theater, region)) continue;
if (!region.theaters.some((t) => theater.includes(t.replace(/-/g, ' ')))) continue;
out.push({ out.push({
id: String(s?.id ?? `xss:${out.length}`), id: String(s?.id ?? `xss:${out.length}`),
type: 'market_signal', 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; const cps = sources['supply_chain:chokepoints:v4']?.chokepoints;
if (Array.isArray(cps)) { if (Array.isArray(cps)) {
for (const cp of cps) { for (const cp of cps) {
const cpId = String(cp?.id ?? '');
if (!regionChokepointIds.has(cpId)) continue;
const threat = String(cp?.threatLevel ?? '').toLowerCase(); const threat = String(cp?.threatLevel ?? '').toLowerCase();
if (threat === 'normal' || threat === '') continue; if (threat === 'normal' || threat === '') continue;
out.push({ out.push({
id: `chokepoint:${cp.id}`, id: `chokepoint:${cpId}`,
type: 'chokepoint_status', type: 'chokepoint_status',
source: 'supply-chain', source: 'supply-chain',
summary: `${cp?.name ?? cp?.id}: ${threat}`, summary: `${cp?.name ?? cpId}: ${threat}`,
confidence: 0.95, confidence: 0.95,
observed_at: Date.now(), observed_at: Date.now(),
theater: '', theater: '',
corridor: String(cp?.id ?? ''), corridor: cpId,
}); });
} }
} }

View File

@@ -41,6 +41,12 @@ export const GEOGRAPHY_VERSION = '1.0.0';
* the existing forecast handler does substring matching against, so the same * the existing forecast handler does substring matching against, so the same
* label flows end-to-end without taxonomy mismatch. * 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 = [ export const REGIONS = [
{ {
id: 'mena', id: 'mena',
@@ -48,6 +54,7 @@ export const REGIONS = [
forecastLabel: 'Middle East', forecastLabel: 'Middle East',
wbCode: 'MEA', wbCode: 'MEA',
theaters: ['levant', 'persian-gulf', 'red-sea', 'north-africa'], theaters: ['levant', 'persian-gulf', 'red-sea', 'north-africa'],
signalAliases: ['middle east', 'mena'],
feedRegion: 'middleeast', feedRegion: 'middleeast',
mapView: 'mena', mapView: 'mena',
keyCountries: ['SA', 'IR', 'IL', 'AE', 'EG', 'IQ', 'TR'], keyCountries: ['SA', 'IR', 'IL', 'AE', 'EG', 'IQ', 'TR'],
@@ -58,6 +65,7 @@ export const REGIONS = [
forecastLabel: 'East Asia', forecastLabel: 'East Asia',
wbCode: 'EAS', wbCode: 'EAS',
theaters: ['east-asia', 'southeast-asia'], theaters: ['east-asia', 'southeast-asia'],
signalAliases: ['asia pacific', 'apac'],
feedRegion: 'asia', feedRegion: 'asia',
mapView: 'asia', mapView: 'asia',
keyCountries: ['CN', 'JP', 'KR', 'TW', 'AU', 'SG', 'ID'], keyCountries: ['CN', 'JP', 'KR', 'TW', 'AU', 'SG', 'ID'],
@@ -68,6 +76,10 @@ export const REGIONS = [
forecastLabel: 'Europe', forecastLabel: 'Europe',
wbCode: 'ECS', wbCode: 'ECS',
theaters: ['eastern-europe', 'western-europe', 'baltic', 'arctic'], 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', feedRegion: 'europe',
mapView: 'eu', mapView: 'eu',
keyCountries: ['DE', 'FR', 'GB', 'UA', 'RU', 'PL', 'IT'], keyCountries: ['DE', 'FR', 'GB', 'UA', 'RU', 'PL', 'IT'],
@@ -78,6 +90,7 @@ export const REGIONS = [
forecastLabel: 'North America', forecastLabel: 'North America',
wbCode: 'NAC', wbCode: 'NAC',
theaters: ['north-america'], theaters: ['north-america'],
signalAliases: [],
feedRegion: 'us', feedRegion: 'us',
mapView: 'america', mapView: 'america',
keyCountries: ['US', 'CA', 'MX'], keyCountries: ['US', 'CA', 'MX'],
@@ -88,6 +101,7 @@ export const REGIONS = [
forecastLabel: 'South Asia', forecastLabel: 'South Asia',
wbCode: 'SAS', wbCode: 'SAS',
theaters: ['south-asia'], theaters: ['south-asia'],
signalAliases: [],
feedRegion: 'asia', feedRegion: 'asia',
mapView: 'asia', mapView: 'asia',
keyCountries: ['IN', 'PK', 'BD', 'LK', 'AF'], keyCountries: ['IN', 'PK', 'BD', 'LK', 'AF'],
@@ -98,6 +112,7 @@ export const REGIONS = [
forecastLabel: 'Latin America', forecastLabel: 'Latin America',
wbCode: 'LCN', wbCode: 'LCN',
theaters: ['latin-america', 'caribbean'], theaters: ['latin-america', 'caribbean'],
signalAliases: ['latam'],
feedRegion: 'latam', feedRegion: 'latam',
mapView: 'latam', mapView: 'latam',
keyCountries: ['BR', 'AR', 'CO', 'CL', 'VE', 'PE'], keyCountries: ['BR', 'AR', 'CO', 'CL', 'VE', 'PE'],
@@ -108,6 +123,7 @@ export const REGIONS = [
forecastLabel: 'Africa', forecastLabel: 'Africa',
wbCode: 'SSF', wbCode: 'SSF',
theaters: ['horn-of-africa', 'sahel', 'southern-africa', 'central-africa'], theaters: ['horn-of-africa', 'sahel', 'southern-africa', 'central-africa'],
signalAliases: ['sub-saharan africa', 'subsaharan africa'],
feedRegion: 'africa', feedRegion: 'africa',
mapView: 'africa', mapView: 'africa',
keyCountries: ['NG', 'ZA', 'KE', 'ET', 'SD', 'CD'], keyCountries: ['NG', 'ZA', 'KE', 'ET', 'SD', 'CD'],
@@ -118,6 +134,7 @@ export const REGIONS = [
forecastLabel: '', forecastLabel: '',
wbCode: '1W', wbCode: '1W',
theaters: ['global-markets'], theaters: ['global-markets'],
signalAliases: ['global'],
feedRegion: 'worldwide', feedRegion: 'worldwide',
mapView: 'global', mapView: 'global',
keyCountries: ['US', 'CN', 'RU', 'DE', 'JP', 'IN', 'GB', 'SA'], keyCountries: ['US', 'CN', 'RU', 'DE', 'JP', 'IN', 'GB', 'SA'],
@@ -267,13 +284,72 @@ export function getTheaterCorridors(theaterId) {
return CORRIDORS.filter((c) => c.theaterId === 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) { export function getRegionCorridors(regionId) {
const theaterIds = new Set(getRegionTheaters(regionId).map((t) => t.id)); const theaters = getRegionTheaters(regionId);
return CORRIDORS.filter((c) => theaterIds.has(c.theaterId)); 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 */ /** @param {string} iso2 */
export function countryCriticality(iso2) { export function countryCriticality(iso2) {
return COUNTRY_CRITICALITY[iso2] ?? DEFAULT_COUNTRY_CRITICALITY; 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;
}

18
shared/geography.d.ts vendored
View File

@@ -9,6 +9,13 @@ export interface RegionDef {
forecastLabel: string; forecastLabel: string;
wbCode: string; wbCode: string;
theaters: 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; feedRegion: string;
mapView: string; mapView: string;
keyCountries: string[]; keyCountries: string[];
@@ -48,3 +55,14 @@ export function getRegionTheaters(regionId: string): TheaterDef[];
export function getTheaterCorridors(theaterId: string): CorridorDef[]; export function getTheaterCorridors(theaterId: string): CorridorDef[];
export function getRegionCorridors(regionId: string): CorridorDef[]; export function getRegionCorridors(regionId: string): CorridorDef[];
export function countryCriticality(iso2: string): number; 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;

View File

@@ -41,6 +41,12 @@ export const GEOGRAPHY_VERSION = '1.0.0';
* the existing forecast handler does substring matching against, so the same * the existing forecast handler does substring matching against, so the same
* label flows end-to-end without taxonomy mismatch. * 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 = [ export const REGIONS = [
{ {
id: 'mena', id: 'mena',
@@ -48,6 +54,7 @@ export const REGIONS = [
forecastLabel: 'Middle East', forecastLabel: 'Middle East',
wbCode: 'MEA', wbCode: 'MEA',
theaters: ['levant', 'persian-gulf', 'red-sea', 'north-africa'], theaters: ['levant', 'persian-gulf', 'red-sea', 'north-africa'],
signalAliases: ['middle east', 'mena'],
feedRegion: 'middleeast', feedRegion: 'middleeast',
mapView: 'mena', mapView: 'mena',
keyCountries: ['SA', 'IR', 'IL', 'AE', 'EG', 'IQ', 'TR'], keyCountries: ['SA', 'IR', 'IL', 'AE', 'EG', 'IQ', 'TR'],
@@ -58,6 +65,7 @@ export const REGIONS = [
forecastLabel: 'East Asia', forecastLabel: 'East Asia',
wbCode: 'EAS', wbCode: 'EAS',
theaters: ['east-asia', 'southeast-asia'], theaters: ['east-asia', 'southeast-asia'],
signalAliases: ['asia pacific', 'apac'],
feedRegion: 'asia', feedRegion: 'asia',
mapView: 'asia', mapView: 'asia',
keyCountries: ['CN', 'JP', 'KR', 'TW', 'AU', 'SG', 'ID'], keyCountries: ['CN', 'JP', 'KR', 'TW', 'AU', 'SG', 'ID'],
@@ -68,6 +76,10 @@ export const REGIONS = [
forecastLabel: 'Europe', forecastLabel: 'Europe',
wbCode: 'ECS', wbCode: 'ECS',
theaters: ['eastern-europe', 'western-europe', 'baltic', 'arctic'], 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', feedRegion: 'europe',
mapView: 'eu', mapView: 'eu',
keyCountries: ['DE', 'FR', 'GB', 'UA', 'RU', 'PL', 'IT'], keyCountries: ['DE', 'FR', 'GB', 'UA', 'RU', 'PL', 'IT'],
@@ -78,6 +90,7 @@ export const REGIONS = [
forecastLabel: 'North America', forecastLabel: 'North America',
wbCode: 'NAC', wbCode: 'NAC',
theaters: ['north-america'], theaters: ['north-america'],
signalAliases: [],
feedRegion: 'us', feedRegion: 'us',
mapView: 'america', mapView: 'america',
keyCountries: ['US', 'CA', 'MX'], keyCountries: ['US', 'CA', 'MX'],
@@ -88,6 +101,7 @@ export const REGIONS = [
forecastLabel: 'South Asia', forecastLabel: 'South Asia',
wbCode: 'SAS', wbCode: 'SAS',
theaters: ['south-asia'], theaters: ['south-asia'],
signalAliases: [],
feedRegion: 'asia', feedRegion: 'asia',
mapView: 'asia', mapView: 'asia',
keyCountries: ['IN', 'PK', 'BD', 'LK', 'AF'], keyCountries: ['IN', 'PK', 'BD', 'LK', 'AF'],
@@ -98,6 +112,7 @@ export const REGIONS = [
forecastLabel: 'Latin America', forecastLabel: 'Latin America',
wbCode: 'LCN', wbCode: 'LCN',
theaters: ['latin-america', 'caribbean'], theaters: ['latin-america', 'caribbean'],
signalAliases: ['latam'],
feedRegion: 'latam', feedRegion: 'latam',
mapView: 'latam', mapView: 'latam',
keyCountries: ['BR', 'AR', 'CO', 'CL', 'VE', 'PE'], keyCountries: ['BR', 'AR', 'CO', 'CL', 'VE', 'PE'],
@@ -108,6 +123,7 @@ export const REGIONS = [
forecastLabel: 'Africa', forecastLabel: 'Africa',
wbCode: 'SSF', wbCode: 'SSF',
theaters: ['horn-of-africa', 'sahel', 'southern-africa', 'central-africa'], theaters: ['horn-of-africa', 'sahel', 'southern-africa', 'central-africa'],
signalAliases: ['sub-saharan africa', 'subsaharan africa'],
feedRegion: 'africa', feedRegion: 'africa',
mapView: 'africa', mapView: 'africa',
keyCountries: ['NG', 'ZA', 'KE', 'ET', 'SD', 'CD'], keyCountries: ['NG', 'ZA', 'KE', 'ET', 'SD', 'CD'],
@@ -118,6 +134,7 @@ export const REGIONS = [
forecastLabel: '', forecastLabel: '',
wbCode: '1W', wbCode: '1W',
theaters: ['global-markets'], theaters: ['global-markets'],
signalAliases: ['global'],
feedRegion: 'worldwide', feedRegion: 'worldwide',
mapView: 'global', mapView: 'global',
keyCountries: ['US', 'CN', 'RU', 'DE', 'JP', 'IN', 'GB', 'SA'], keyCountries: ['US', 'CN', 'RU', 'DE', 'JP', 'IN', 'GB', 'SA'],
@@ -267,13 +284,72 @@ export function getTheaterCorridors(theaterId) {
return CORRIDORS.filter((c) => c.theaterId === 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) { export function getRegionCorridors(regionId) {
const theaterIds = new Set(getRegionTheaters(regionId).map((t) => t.id)); const theaters = getRegionTheaters(regionId);
return CORRIDORS.filter((c) => theaterIds.has(c.theaterId)); 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 */ /** @param {string} iso2 */
export function countryCriticality(iso2) { export function countryCriticality(iso2) {
return COUNTRY_CRITICALITY[iso2] ?? DEFAULT_COUNTRY_CRITICALITY; 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;
}

View File

@@ -16,6 +16,7 @@ import {
regionForCountry, regionForCountry,
getRegionCorridors, getRegionCorridors,
countryCriticality, countryCriticality,
isSignalInRegion,
} from '../shared/geography.js'; } from '../shared/geography.js';
import { computeBalanceVector, SCORING_VERSION } from '../scripts/regional-snapshot/balance-vector.mjs'; import { computeBalanceVector, SCORING_VERSION } from '../scripts/regional-snapshot/balance-vector.mjs';
@@ -103,6 +104,70 @@ describe('shared/geography', () => {
it('GEOGRAPHY_VERSION follows semver', () => { it('GEOGRAPHY_VERSION follows semver', () => {
assert.match(GEOGRAPHY_VERSION, /^\d+\.\d+\.\d+$/); 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) // End-to-end pipeline (no Redis)
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────