mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
18
shared/geography.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user