mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(forecast): block generic-actor cross-theater interactions + raise enrichment budget (#1916)
* fix(forecast): block generic-actor cross-theater interactions + raise enrichment budget
Root cause: actor registry uses name:category as key (e.g. "Incumbent leadership:state"),
causing unrelated situations (Israel conflict, Taiwan political) to share the same actor
ID and fire sharedActor=true in pushInteraction. This propagated into the reportable
ledger and surfaced as junk effects like Israel→Taiwan at 80% confidence.
Two-pronged fix:
1. Specificity gate in pushInteraction: sharedActor now requires avgSpecificity >= 0.75.
Generic blueprint actors ("Incumbent leadership" ~0.68, "Civil protection authorities"
~0.73) no longer qualify as structural cross-situation links. Named domain-specific
actors ("Threat actors:adversarial" ~0.95) continue to qualify.
2. MACRO_REGION_MAP + isCrossTheaterPair + gate in buildCrossSituationEffects: for
cross-theater pairs (different macro-regions) with non-exempt channels, requires
sharedActor=true AND avgActorSpecificity >= 0.90. Exempt channels: cyber_disruption,
market_repricing (legitimately global). Same-macro-region pairs (Brazil/Mexico both
AMERICAS) are unaffected.
Verified against live run 1773983083084-bu6b1f:
BLOCKED: Israel→Taiwan (MENA/EAST_ASIA, spec 0.68)
BLOCKED: Israel→US political (MENA/AMERICAS, spec 0.68)
BLOCKED: Cuba→Iran (AMERICAS/MENA, spec 0.73)
BLOCKED: Brazil→Israel (AMERICAS/MENA, spec 0.85 < 0.90)
ALLOWED: China→US cyber_disruption (exempt channel)
ALLOWED: Brazil→Mexico (same AMERICAS)
Also raises ENRICHMENT_COMBINED_MAX from 3 to 5 (total budget 6→8),
targeting enrichedRate improvement from ~38% to ~60%.
* fix(plans): fix markdown lint errors in forecast semantic quality plan
* fix(plans): fix remaining markdown lint error in plan file
This commit is contained in:
326
plans/fix-forecast-semantic-quality.md
Normal file
326
plans/fix-forecast-semantic-quality.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# Plan: Fix Forecast Semantic Quality — 3 PRs
|
||||
|
||||
## Context
|
||||
|
||||
Live run `1773983083084-bu6b1f` (2026-03-20T05:04:52) confirms two categories of
|
||||
junk effects still reaching the report surface. Root cause identified from live R2
|
||||
data and code trace. This plan fixes them precisely and independently.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause: Confirmed from Live R2 Data
|
||||
|
||||
### Bug 1: Generic blueprint actors create false `sharedActor` cross-situation links
|
||||
|
||||
The actor registry at line 2044 uses `actor.key || \`${actor.name}:${actor.category}\``
|
||||
as the actor ID. When two different situations both instantiate a generic blueprint
|
||||
actor (e.g. `"Incumbent leadership"` for conflict domain AND political domain), they
|
||||
get the SAME actor ID: `"Incumbent leadership:state"`.
|
||||
|
||||
The actor registry deduplicates them into ONE entry with `forecastIds` from both
|
||||
situations. Then `buildSituationSimulationState` filters actors per situation by
|
||||
forecastId overlap — both situations get this shared actor entry. When
|
||||
`pushInteraction` compares `source.actorId === target.actorId`, it fires `true`.
|
||||
|
||||
**Live evidence:**
|
||||
```
|
||||
Israel conflict → Taiwan political | ch: regional_spillover | sharedActor: true
|
||||
srcActorId: "Incumbent leadership:state" | actorSpec: 0.68
|
||||
|
||||
Cuba infrastructure → Iran infrastructure | ch: service_disruption | sharedActor: true
|
||||
srcActorId: "Civil protection authorities:state" | actorSpec: 0.73
|
||||
```
|
||||
|
||||
`scoreActorSpecificity("Incumbent leadership")` = 0.68. It IS already penalized by
|
||||
`GENERIC_ACTOR_NAME_MARKERS` containing `'leadership'`. The engine just never gates
|
||||
`sharedActor` on specificity before using it as a structural credit.
|
||||
|
||||
### Bug 2: No geographic theater awareness in cross-situation effect emission
|
||||
|
||||
Once an interaction makes it into `reportableInteractionLedger` via `sharedActor: true`,
|
||||
`buildCrossSituationEffects` has no concept of geographic distance between situations.
|
||||
Israel (MENA) → Taiwan (East Asia) passes all current gates if the interaction group
|
||||
has high enough score. There is no check that these are on opposite sides of the planet.
|
||||
|
||||
`CROSS_THEATER_EXEMPT_CHANNELS` doesn't exist yet. Cyber and market effects can
|
||||
legitimately cross theaters (same APT group, same commodity price). Conflict,
|
||||
political, infrastructure, and supply_chain effects should not unless the actor is
|
||||
specifically and credibly named.
|
||||
|
||||
---
|
||||
|
||||
## PR 1 — `fix(forecast): block generic-actor cross-theater interactions`
|
||||
|
||||
**File:** `scripts/seed-forecasts.mjs`
|
||||
**Scope:** 3 targeted changes + test updates
|
||||
**Risk:** Low — tightens existing gates, does not remove any data path
|
||||
|
||||
### Change A: Specificity gate in `pushInteraction` (lines 3480–3491)
|
||||
|
||||
Move `sourceSpecificity / targetSpecificity / avgSpecificity` computation ABOVE the
|
||||
`sharedActor` declaration. Add specificity threshold to `sharedActor`:
|
||||
|
||||
**Before (line 3480):**
|
||||
```js
|
||||
const sharedActor = source.actorId && target.actorId && source.actorId === target.actorId;
|
||||
const sharedChannels = ...
|
||||
...
|
||||
const sourceSpecificity = scoreActorSpecificity(source);
|
||||
const targetSpecificity = scoreActorSpecificity(target);
|
||||
const avgSpecificity = (sourceSpecificity + targetSpecificity) / 2;
|
||||
```
|
||||
|
||||
**After:**
|
||||
```js
|
||||
const sourceSpecificity = scoreActorSpecificity(source);
|
||||
const targetSpecificity = scoreActorSpecificity(target);
|
||||
const avgSpecificity = (sourceSpecificity + targetSpecificity) / 2;
|
||||
const sharedActor = source.actorId && target.actorId && source.actorId === target.actorId
|
||||
&& avgSpecificity >= 0.72;
|
||||
const sharedChannels = ...
|
||||
```
|
||||
|
||||
**Threshold justification from live data:**
|
||||
|
||||
- `"Incumbent leadership:state"` → 0.68 → BLOCKED ✓
|
||||
- `"Civil protection authorities:state"` → 0.73 → BLOCKED ✓ (just above 0.72, need check)
|
||||
- `"Threat actors:adversarial"` → 0.95 → ALLOWED (China/US cyber) ✓
|
||||
- `"External power broker:external"` → 0.85 → ALLOWED at this stage, handled by Change B
|
||||
|
||||
NOTE: "Civil protection authorities:state" scores 0.73 because:
|
||||
`base(0.55) + nonShared(+0.10) + nonGenericCategory(+0.15) + 3-word name(+0.05) - genericMarker('authorities', -0.12) = 0.73`
|
||||
|
||||
The threshold of 0.72 blocks it by 0.01. This is intentional but fragile. If concerned, raise to
|
||||
0.75 to give a clear margin. Test with both values.
|
||||
|
||||
### Change B: MACRO_REGION_MAP + cross-theater gate in `buildCrossSituationEffects`
|
||||
|
||||
Add a new constant block immediately before `buildCrossSituationEffects`:
|
||||
|
||||
```js
|
||||
const MACRO_REGION_MAP = {
|
||||
// MENA
|
||||
'Israel': 'MENA', 'Iran': 'MENA', 'Syria': 'MENA', 'Iraq': 'MENA',
|
||||
'Lebanon': 'MENA', 'Gaza': 'MENA', 'Egypt': 'MENA', 'Saudi Arabia': 'MENA',
|
||||
'Yemen': 'MENA', 'Jordan': 'MENA', 'Turkey': 'MENA', 'Libya': 'MENA',
|
||||
'Middle East': 'MENA', 'Persian Gulf': 'MENA', 'Red Sea': 'MENA',
|
||||
'Strait of Hormuz': 'MENA', 'Eastern Mediterranean': 'MENA',
|
||||
// EAST_ASIA
|
||||
'Taiwan': 'EAST_ASIA', 'China': 'EAST_ASIA', 'Japan': 'EAST_ASIA',
|
||||
'South Korea': 'EAST_ASIA', 'North Korea': 'EAST_ASIA',
|
||||
'Western Pacific': 'EAST_ASIA', 'South China Sea': 'EAST_ASIA',
|
||||
// AMERICAS
|
||||
'United States': 'AMERICAS', 'Brazil': 'AMERICAS', 'Mexico': 'AMERICAS',
|
||||
'Cuba': 'AMERICAS', 'Canada': 'AMERICAS', 'Colombia': 'AMERICAS',
|
||||
'Venezuela': 'AMERICAS', 'Argentina': 'AMERICAS', 'Peru': 'AMERICAS',
|
||||
// EUROPE
|
||||
'Russia': 'EUROPE', 'Ukraine': 'EUROPE', 'Germany': 'EUROPE',
|
||||
'France': 'EUROPE', 'United Kingdom': 'EUROPE', 'Poland': 'EUROPE',
|
||||
'Baltic Sea': 'EUROPE', 'Black Sea': 'EUROPE', 'Kerch Strait': 'EUROPE',
|
||||
'Sweden': 'EUROPE', 'Finland': 'EUROPE', 'Norway': 'EUROPE',
|
||||
// SOUTH_ASIA
|
||||
'India': 'SOUTH_ASIA', 'Pakistan': 'SOUTH_ASIA', 'Afghanistan': 'SOUTH_ASIA',
|
||||
'Bangladesh': 'SOUTH_ASIA', 'Myanmar': 'SOUTH_ASIA',
|
||||
// AFRICA
|
||||
'Congo': 'AFRICA', 'Sudan': 'AFRICA', 'Ethiopia': 'AFRICA',
|
||||
'Nigeria': 'AFRICA', 'Somalia': 'AFRICA', 'Mali': 'AFRICA',
|
||||
'Mozambique': 'AFRICA', 'Sahel': 'AFRICA',
|
||||
};
|
||||
|
||||
// Channels where cross-theater effects are legitimate regardless of geography
|
||||
const CROSS_THEATER_EXEMPT_CHANNELS = new Set(['cyber_disruption', 'market_repricing']);
|
||||
|
||||
// Minimum actor specificity to justify a named-actor cross-theater link
|
||||
const CROSS_THEATER_ACTOR_SPECIFICITY_MIN = 0.90;
|
||||
|
||||
function getMacroRegion(regions = []) {
|
||||
for (const region of regions) {
|
||||
if (MACRO_REGION_MAP[region]) return MACRO_REGION_MAP[region];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCrossTheaterPair(sourceRegions, targetRegions) {
|
||||
const src = getMacroRegion(sourceRegions);
|
||||
const tgt = getMacroRegion(targetRegions);
|
||||
return !!(src && tgt && src !== tgt);
|
||||
}
|
||||
```
|
||||
|
||||
Then inside `buildCrossSituationEffects`, immediately after the `hasDirectStructuralLink`
|
||||
line (currently line 3880), add:
|
||||
|
||||
```js
|
||||
const isCrossTheater = isCrossTheaterPair(source.regions || [], target.regions || []);
|
||||
if (
|
||||
isCrossTheater
|
||||
&& !CROSS_THEATER_EXEMPT_CHANNELS.has(group.strongestChannel)
|
||||
&& (!hasSharedActor || Number(group.avgActorSpecificity || 0) < CROSS_THEATER_ACTOR_SPECIFICITY_MIN)
|
||||
) continue;
|
||||
```
|
||||
|
||||
**Gate logic verified against live pairs:**
|
||||
|
||||
| Pair | Channel | CrossTheater | Exempt? | SharedActor | ActorSpec | Result |
|
||||
|------|---------|-------------|---------|------------|----------|--------|
|
||||
| China cyber ↔ US cyber | cyber_disruption | YES (EAST_ASIA/AMERICAS) | YES | — | — | ALLOWED ✓ |
|
||||
| Brazil ↔ Mexico conflict | security_escalation | NO (both AMERICAS) | — | true | 0.85 | ALLOWED ✓ |
|
||||
| Brazil ↔ Israel conflict | security_escalation | YES (AMERICAS/MENA) | NO | true (after PR1A) | 0.85 < 0.90 | BLOCKED ✓ |
|
||||
| Cuba ↔ Iran infra | service_disruption | YES (AMERICAS/MENA) | NO | false (after PR1A) | 0.73 | BLOCKED ✓ |
|
||||
| Israel ↔ Taiwan political | regional_spillover | YES (MENA/EAST_ASIA) | NO | false (after PR1A) | 0.68 | BLOCKED ✓ |
|
||||
| Israel ↔ US political | regional_spillover | YES (MENA/AMERICAS) | NO | false (after PR1A) | 0.68 | BLOCKED ✓ |
|
||||
| Taiwan ↔ US political | political_pressure | YES (EAST_ASIA/AMERICAS) | NO | true | 0.85 < 0.90 | BLOCKED (correct: generic actor) |
|
||||
| Baltic ↔ Black Sea supply | logistics_disruption | NO (both EUROPE) | — | — | — | ALLOWED ✓ |
|
||||
|
||||
### Change C: Export `isCrossTheaterPair` and `getMacroRegion` at bottom of file
|
||||
|
||||
Add to the `export {}` block so tests can import them:
|
||||
```js
|
||||
export {
|
||||
...existing exports...
|
||||
isCrossTheaterPair,
|
||||
getMacroRegion,
|
||||
};
|
||||
```
|
||||
|
||||
### Test changes (`tests/forecast-trace-export.test.mjs`)
|
||||
|
||||
Add 3 new tests in the `forecast run world state` describe block:
|
||||
|
||||
1. **`blocks Israel → Taiwan via generic Incumbent Leadership actor`**
|
||||
- Setup: two simulations (conflict/MENA, political/EAST_ASIA) with sharedActor=true at spec 0.68
|
||||
- Assert: `effects.length === 0`
|
||||
|
||||
2. **`allows China → US via Threat Actors cross-theater cyber_disruption`**
|
||||
- Setup: two simulations (cyber/EAST_ASIA, cyber/AMERICAS) with cyber_disruption, sharedActor=true at spec 0.95
|
||||
- Assert: `effects.length === 1`, `effects[0].channel === 'cyber_disruption'`
|
||||
|
||||
3. **`blocks Brazil → Israel via External Power Broker security_escalation`**
|
||||
- Setup: two simulations (conflict/AMERICAS, conflict/MENA) with sharedActor=true at spec 0.85
|
||||
- Assert: `effects.length === 0` (cross-theater, channel not exempt, spec < 0.90)
|
||||
|
||||
Also add unit tests for `isCrossTheaterPair` directly:
|
||||
|
||||
- `isCrossTheaterPair(['Israel'], ['Taiwan'])` → `true`
|
||||
- `isCrossTheaterPair(['Brazil'], ['Mexico'])` → `false`
|
||||
- `isCrossTheaterPair(['China'], ['unknown-region'])` → `false` (null getMacroRegion)
|
||||
|
||||
### Verification
|
||||
|
||||
Run:
|
||||
```
|
||||
node --check scripts/seed-forecasts.mjs
|
||||
tsx --test tests/forecast-trace-export.test.mjs
|
||||
```
|
||||
|
||||
Expected: 0 junk cross-theater effects in next live run. Check `report.crossSituationEffects`
|
||||
should contain only China/US cyber and Brazil/Mexico conflict.
|
||||
|
||||
---
|
||||
|
||||
## PR 2 — `fix(forecast): raise LLM enrichment budget`
|
||||
|
||||
**File:** `scripts/seed-forecasts.mjs` lines 27–28 only
|
||||
**Risk:** Very low — constant change, no logic change
|
||||
|
||||
### Change
|
||||
|
||||
```js
|
||||
// Before
|
||||
const ENRICHMENT_COMBINED_MAX = 3;
|
||||
const ENRICHMENT_SCENARIO_MAX = 3;
|
||||
|
||||
// After
|
||||
const ENRICHMENT_COMBINED_MAX = 5;
|
||||
const ENRICHMENT_SCENARIO_MAX = 3;
|
||||
```
|
||||
|
||||
Total enrichment budget: 3+3=6 → 5+3=8 per run.
|
||||
|
||||
### Why not higher?
|
||||
|
||||
- Current LLM success rate: 3/3 (100%) — headroom exists
|
||||
- 8 enriched / 13 published = 61.5% target enrichedRate (up from 38.5%)
|
||||
- Raising to 8+3=11 risks rate limits; 5+3=8 is safe headroom
|
||||
- The scenario-only path is cheaper (no perspectives/cases call), keep at 3
|
||||
|
||||
### Test
|
||||
|
||||
No logic change. The existing enrichment selection tests remain valid.
|
||||
Run `tsx --test tests/forecast-trace-export.test.mjs` as smoke test.
|
||||
|
||||
### Verification
|
||||
|
||||
After next live run, check `traceQuality.enrichedRate` in summary.json.
|
||||
Target: > 0.55. Acceptable: > 0.45.
|
||||
|
||||
---
|
||||
|
||||
## PR 3 — `fix(forecast): diagnose and fix military detector surfacing`
|
||||
|
||||
**Status: requires live data diagnosis before coding**
|
||||
|
||||
### Diagnosis step (run before writing code)
|
||||
|
||||
Pull the latest world-state and check:
|
||||
```js
|
||||
// In the live run, check what military data was available
|
||||
ws.domainStates.find(d => d.domain === 'military')
|
||||
ws.report.domainOverview // check if military appears in activeDomainCount
|
||||
```
|
||||
|
||||
Also check the seed inputs to confirm theater posture data:
|
||||
```
|
||||
curl https://api.worldmonitor.app/api/forecast-bootstrap | jq '.militaryData'
|
||||
```
|
||||
|
||||
### Two possible fixes depending on diagnosis:
|
||||
|
||||
**Path A — Detector emits 0 (theater posture all 'normal')**
|
||||
|
||||
The gate at line 719:
|
||||
```js
|
||||
if (posture !== 'elevated' && posture !== 'critical' && !surgeIsUsable) continue;
|
||||
```
|
||||
This skips all theaters at 'normal' posture unless there's a usable surge. If no current
|
||||
theater is elevated and surges are below threshold, zero military forecasts are generated.
|
||||
|
||||
Fix: Add a baseline military forecast for the most active theater even at 'normal' posture
|
||||
if surge multiple is above a lower threshold (e.g., >= 1.8x baseline). This keeps military
|
||||
present even during calm periods.
|
||||
|
||||
**Path B — Detector emits candidates but publish selection suppresses them**
|
||||
|
||||
Check `publishTelemetry.suppressedFamilySelection` and `suppressedSituationDomainCap`.
|
||||
If military forecasts exist but are suppressed, examine whether `canSelect` in the
|
||||
publish selection is blocking them. Military is already in the exemption list for
|
||||
domain cap at line 5315, but check whether `MAX_PRESELECTED_FORECASTS_PER_FAMILY`
|
||||
or `MAX_PUBLISHED_FORECASTS_PER_FAMILY_DOMAIN` is capping them before the diversity
|
||||
pass reaches them.
|
||||
|
||||
Fix for Path B: Ensure at least one military forecast is explicitly seeded in the first
|
||||
pass of the selection loop before the diversity filter runs.
|
||||
|
||||
### Not starting PR 3 until diagnosis confirms Path A or B.
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **PR 1** — `fix/forecast-cross-theater-gate` branch — highest leverage, fixes the
|
||||
junk effects that are the primary credibility problem
|
||||
2. **PR 2** — `fix/forecast-enrichment-budget` branch — 2-line change, can be done
|
||||
in the same session, independent of PR 1
|
||||
3. **PR 3** — diagnose first from live R2 data, then write a targeted fix
|
||||
|
||||
PRs 1 and 2 are independent and can be merged in any order. PR 3 is independent of both.
|
||||
|
||||
---
|
||||
|
||||
## What This Does Not Fix
|
||||
|
||||
- Enrichment quality — the LLM output quality depends on input prompt quality, not these PRs
|
||||
- Situation clustering precision — some situations have overly broad region sets (e.g. Black Sea
|
||||
market at `["Black Sea","Middle East","Red Sea","Western Pacific"]`) which will continue to
|
||||
create imprecise cross-situation scoring; this is a separate, larger issue
|
||||
- Retention/cleanup of old R2 trace files — 100+ runs/day accumulating with no TTL
|
||||
@@ -24,7 +24,7 @@ const WORLD_STATE_HISTORY_LIMIT = 6;
|
||||
const FORECAST_REFRESH_REQUEST_KEY = 'forecast:refresh-request:v1';
|
||||
const PUBLISH_MIN_PROBABILITY = 0;
|
||||
const PANEL_MIN_PROBABILITY = 0.1;
|
||||
const ENRICHMENT_COMBINED_MAX = 3;
|
||||
const ENRICHMENT_COMBINED_MAX = 5;
|
||||
const ENRICHMENT_SCENARIO_MAX = 3;
|
||||
const ENRICHMENT_MAX_PER_DOMAIN = 2;
|
||||
const ENRICHMENT_MIN_READINESS = 0.34;
|
||||
@@ -3477,7 +3477,11 @@ function buildSimulationInteractionLedger(actionLedger = [], situationSimulation
|
||||
function pushInteraction(source, target, stage) {
|
||||
if (source.situationId === target.situationId) return;
|
||||
|
||||
const sharedActor = source.actorId && target.actorId && source.actorId === target.actorId;
|
||||
const sourceSpecificity = scoreActorSpecificity(source);
|
||||
const targetSpecificity = scoreActorSpecificity(target);
|
||||
const avgSpecificity = (sourceSpecificity + targetSpecificity) / 2;
|
||||
const sharedActor = source.actorId && target.actorId && source.actorId === target.actorId
|
||||
&& avgSpecificity >= 0.75;
|
||||
const sharedChannels = uniqueSortedStrings((source.channels || []).filter((channel) => (target.channels || []).includes(channel)));
|
||||
const familyLink = source.familyId && target.familyId && source.familyId === target.familyId;
|
||||
const regionLink = intersectCount(source.regions || [], target.regions || []) > 0;
|
||||
@@ -3486,9 +3490,6 @@ function buildSimulationInteractionLedger(actionLedger = [], situationSimulation
|
||||
(source.intent === 'pressure' && target.intent === 'stabilizing')
|
||||
|| (source.intent === 'stabilizing' && target.intent === 'pressure')
|
||||
);
|
||||
const sourceSpecificity = scoreActorSpecificity(source);
|
||||
const targetSpecificity = scoreActorSpecificity(target);
|
||||
const avgSpecificity = (sourceSpecificity + targetSpecificity) / 2;
|
||||
|
||||
const score = (sharedActor ? 4 : 0)
|
||||
+ (sharedChannels.length * 2)
|
||||
@@ -3829,6 +3830,42 @@ function inferSystemEffectRelation(sourceDomain, targetDomain) {
|
||||
return relationMap[key] || '';
|
||||
}
|
||||
|
||||
const MACRO_REGION_MAP = {
|
||||
'Israel': 'MENA', 'Iran': 'MENA', 'Syria': 'MENA', 'Iraq': 'MENA', 'Lebanon': 'MENA',
|
||||
'Gaza': 'MENA', 'Egypt': 'MENA', 'Saudi Arabia': 'MENA', 'Yemen': 'MENA', 'Jordan': 'MENA',
|
||||
'Turkey': 'MENA', 'Libya': 'MENA', 'Middle East': 'MENA', 'Persian Gulf': 'MENA',
|
||||
'Red Sea': 'MENA', 'Strait of Hormuz': 'MENA', 'Eastern Mediterranean': 'MENA',
|
||||
'Taiwan': 'EAST_ASIA', 'China': 'EAST_ASIA', 'Japan': 'EAST_ASIA', 'South Korea': 'EAST_ASIA',
|
||||
'North Korea': 'EAST_ASIA', 'Western Pacific': 'EAST_ASIA', 'South China Sea': 'EAST_ASIA',
|
||||
'United States': 'AMERICAS', 'Brazil': 'AMERICAS', 'Mexico': 'AMERICAS', 'Cuba': 'AMERICAS',
|
||||
'Canada': 'AMERICAS', 'Colombia': 'AMERICAS', 'Venezuela': 'AMERICAS', 'Argentina': 'AMERICAS',
|
||||
'Peru': 'AMERICAS', 'Chile': 'AMERICAS',
|
||||
'Russia': 'EUROPE', 'Ukraine': 'EUROPE', 'Germany': 'EUROPE', 'France': 'EUROPE',
|
||||
'United Kingdom': 'EUROPE', 'Poland': 'EUROPE', 'Baltic Sea': 'EUROPE', 'Black Sea': 'EUROPE',
|
||||
'Kerch Strait': 'EUROPE', 'Sweden': 'EUROPE', 'Finland': 'EUROPE', 'Norway': 'EUROPE',
|
||||
'Romania': 'EUROPE', 'Bulgaria': 'EUROPE',
|
||||
'India': 'SOUTH_ASIA', 'Pakistan': 'SOUTH_ASIA', 'Afghanistan': 'SOUTH_ASIA',
|
||||
'Bangladesh': 'SOUTH_ASIA', 'Myanmar': 'SOUTH_ASIA',
|
||||
'Congo': 'AFRICA', 'Sudan': 'AFRICA', 'Ethiopia': 'AFRICA', 'Nigeria': 'AFRICA',
|
||||
'Somalia': 'AFRICA', 'Mali': 'AFRICA', 'Mozambique': 'AFRICA', 'Sahel': 'AFRICA',
|
||||
};
|
||||
|
||||
const CROSS_THEATER_EXEMPT_CHANNELS = new Set(['cyber_disruption', 'market_repricing']);
|
||||
const CROSS_THEATER_ACTOR_SPECIFICITY_MIN = 0.90;
|
||||
|
||||
function getMacroRegion(regions = []) {
|
||||
for (const region of regions) {
|
||||
if (MACRO_REGION_MAP[region]) return MACRO_REGION_MAP[region];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCrossTheaterPair(sourceRegions, targetRegions) {
|
||||
const src = getMacroRegion(sourceRegions);
|
||||
const tgt = getMacroRegion(targetRegions);
|
||||
return !!(src && tgt && src !== tgt);
|
||||
}
|
||||
|
||||
function canEmitCrossSituationEffect(source, strongestChannel, strongestChannelWeight, hasDirectStructuralLink = false) {
|
||||
if (!strongestChannel) return false;
|
||||
const profile = getSimulationDomainProfile(source?.dominantDomain || '');
|
||||
@@ -3880,6 +3917,11 @@ function buildCrossSituationEffects(simulationState) {
|
||||
const hasDirectStructuralLink = hasRegionLink || hasSharedActor;
|
||||
if (!canEmitCrossSituationEffect(source, group.strongestChannel, strongestChannelWeight, hasDirectStructuralLink)) continue;
|
||||
if (strongestChannelWeight < 2 && !hasDirectStructuralLink) continue;
|
||||
if (
|
||||
isCrossTheaterPair(source.regions || [], target.regions || [])
|
||||
&& !CROSS_THEATER_EXEMPT_CHANNELS.has(group.strongestChannel)
|
||||
&& (!hasSharedActor || Number(group.avgActorSpecificity || 0) < CROSS_THEATER_ACTOR_SPECIFICITY_MIN)
|
||||
) continue;
|
||||
if (
|
||||
group.strongestChannel === 'political_pressure'
|
||||
&& !hasRegionLink
|
||||
@@ -6623,6 +6665,8 @@ export {
|
||||
buildCrossSituationEffects,
|
||||
buildReportableInteractionLedger,
|
||||
buildInteractionWatchlist,
|
||||
isCrossTheaterPair,
|
||||
getMacroRegion,
|
||||
attachSituationContext,
|
||||
projectSituationClusters,
|
||||
refreshPublishedNarratives,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
buildCrossSituationEffects,
|
||||
buildReportableInteractionLedger,
|
||||
buildInteractionWatchlist,
|
||||
isCrossTheaterPair,
|
||||
getMacroRegion,
|
||||
attachSituationContext,
|
||||
projectSituationClusters,
|
||||
refreshPublishedNarratives,
|
||||
@@ -1660,7 +1662,9 @@ describe('forecast run world state', () => {
|
||||
assert.equal(reportable.length, 1);
|
||||
});
|
||||
|
||||
it('allows strong two-round shared-actor political effects without regional overlap', () => {
|
||||
it('blocks cross-theater political effects even with shared-actor when actorSpec below 0.90', () => {
|
||||
// US (AMERICAS) → Japan (EAST_ASIA) via political_pressure with actorSpec 0.87 is cross-theater.
|
||||
// The gate requires actorSpec >= 0.90 for non-exempt channels across theater boundaries.
|
||||
const effects = buildCrossSituationEffects({
|
||||
situationSimulations: [
|
||||
{
|
||||
@@ -1730,11 +1734,7 @@ describe('forecast run world state', () => {
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(effects.some((item) => (
|
||||
item.sourceSituationId === 'sit-cyber'
|
||||
&& item.targetSituationId === 'sit-market'
|
||||
&& item.channel === 'political_pressure'
|
||||
)));
|
||||
assert.equal(effects.length, 0, 'US → Japan cross-theater political_pressure at actorSpec 0.87 should be blocked');
|
||||
});
|
||||
|
||||
it('allows logistics effects with strong confidence while filtering weaker political ones', () => {
|
||||
@@ -1909,3 +1909,151 @@ describe('forecast run world state', () => {
|
||||
assert.ok((worldState.simulationState.situationSimulations || []).every((item) => item.postureScore < 0.99));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-theater gate', () => {
|
||||
it('identifies cross-theater pairs correctly', () => {
|
||||
assert.equal(isCrossTheaterPair(['Israel'], ['Taiwan']), true);
|
||||
assert.equal(isCrossTheaterPair(['Israel'], ['Iran']), false);
|
||||
assert.equal(isCrossTheaterPair(['Brazil'], ['Mexico']), false);
|
||||
assert.equal(isCrossTheaterPair(['Cuba'], ['Iran']), true);
|
||||
assert.equal(isCrossTheaterPair(['China'], ['United States']), true);
|
||||
assert.equal(isCrossTheaterPair(['Baltic Sea'], ['Black Sea']), false);
|
||||
assert.equal(isCrossTheaterPair(['Israel'], ['unknown-region']), false);
|
||||
assert.equal(isCrossTheaterPair(['unknown-a'], ['unknown-b']), false);
|
||||
});
|
||||
|
||||
it('maps regions to macro-regions', () => {
|
||||
assert.equal(getMacroRegion(['Israel', 'Gaza']), 'MENA');
|
||||
assert.equal(getMacroRegion(['Taiwan', 'Western Pacific']), 'EAST_ASIA');
|
||||
assert.equal(getMacroRegion(['Brazil']), 'AMERICAS');
|
||||
assert.equal(getMacroRegion(['Baltic Sea', 'Black Sea']), 'EUROPE');
|
||||
assert.equal(getMacroRegion(['unknown-region']), null);
|
||||
assert.equal(getMacroRegion([]), null);
|
||||
});
|
||||
|
||||
function makeSimulation(situationId, label, domain, regions, posture, postureScore, effectChannels = []) {
|
||||
return {
|
||||
situationId,
|
||||
label,
|
||||
dominantDomain: domain,
|
||||
familyId: `fam-${situationId}`,
|
||||
familyLabel: `${label} family`,
|
||||
regions,
|
||||
actorIds: [`actor-${situationId}`],
|
||||
effectChannels,
|
||||
posture,
|
||||
postureScore,
|
||||
totalPressure: posture === 'escalatory' ? 0.88 : 0.55,
|
||||
totalStabilization: posture === 'escalatory' ? 0.22 : 0.38,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInteraction(srcId, srcLabel, tgtId, tgtLabel, channel, stage, score, conf, spec, sharedActor, regionLink) {
|
||||
return {
|
||||
sourceSituationId: srcId,
|
||||
targetSituationId: tgtId,
|
||||
sourceLabel: srcLabel,
|
||||
targetLabel: tgtLabel,
|
||||
strongestChannel: channel,
|
||||
interactionType: sharedActor ? 'actor_carryover' : 'spillover',
|
||||
stage,
|
||||
score,
|
||||
confidence: conf,
|
||||
actorSpecificity: spec,
|
||||
directLinkCount: (sharedActor ? 1 : 0) + (regionLink ? 1 : 0) + 1,
|
||||
sharedActor,
|
||||
regionLink,
|
||||
sourceActorName: 'Test actor',
|
||||
targetActorName: 'Test actor',
|
||||
};
|
||||
}
|
||||
|
||||
it('blocks Israel → Taiwan via generic Incumbent Leadership (regional_spillover, spec 0.68)', () => {
|
||||
const effects = buildCrossSituationEffects({
|
||||
situationSimulations: [
|
||||
makeSimulation('sit-israel', 'Israel conflict situation', 'conflict', ['Israel'], 'escalatory', 0.88,
|
||||
[{ type: 'regional_spillover', count: 3 }]),
|
||||
makeSimulation('sit-taiwan', 'Taiwan political situation', 'political', ['Taiwan'], 'contested', 0.54),
|
||||
],
|
||||
reportableInteractionLedger: [
|
||||
makeInteraction('sit-israel', 'Israel conflict situation', 'sit-taiwan', 'Taiwan political situation',
|
||||
'regional_spillover', 'round_1', 5.2, 0.77, 0.68, true, false),
|
||||
makeInteraction('sit-israel', 'Israel conflict situation', 'sit-taiwan', 'Taiwan political situation',
|
||||
'regional_spillover', 'round_2', 5.1, 0.77, 0.68, true, false),
|
||||
],
|
||||
});
|
||||
assert.equal(effects.length, 0, 'Israel → Taiwan via generic actor should be blocked by cross-theater gate');
|
||||
});
|
||||
|
||||
it('allows China → US via Threat Actors (cyber_disruption, exempt channel)', () => {
|
||||
const effects = buildCrossSituationEffects({
|
||||
situationSimulations: [
|
||||
makeSimulation('sit-china', 'China cyber situation', 'cyber', ['China'], 'escalatory', 0.88,
|
||||
[{ type: 'cyber_disruption', count: 3 }]),
|
||||
// target must be infrastructure for cyber_disruption:infrastructure relation to exist
|
||||
makeSimulation('sit-us', 'United States infrastructure situation', 'infrastructure', ['United States'], 'contested', 0.62),
|
||||
],
|
||||
reportableInteractionLedger: [
|
||||
makeInteraction('sit-china', 'China cyber situation', 'sit-us', 'United States infrastructure situation',
|
||||
'cyber_disruption', 'round_1', 6.5, 0.91, 0.95, true, false),
|
||||
makeInteraction('sit-china', 'China cyber situation', 'sit-us', 'United States infrastructure situation',
|
||||
'cyber_disruption', 'round_2', 6.3, 0.90, 0.95, true, false),
|
||||
],
|
||||
});
|
||||
assert.equal(effects.length, 1, 'China (EAST_ASIA) → US (AMERICAS) via cyber_disruption should pass (exempt channel)');
|
||||
assert.equal(effects[0].channel, 'cyber_disruption');
|
||||
});
|
||||
|
||||
it('blocks Brazil → Israel conflict via External Power Broker (security_escalation, spec 0.85 < 0.90)', () => {
|
||||
const effects = buildCrossSituationEffects({
|
||||
situationSimulations: [
|
||||
makeSimulation('sit-brazil', 'Brazil conflict situation', 'conflict', ['Brazil'], 'escalatory', 0.84,
|
||||
[{ type: 'security_escalation', count: 3 }]),
|
||||
makeSimulation('sit-israel', 'Israel conflict situation', 'conflict', ['Israel'], 'escalatory', 0.88),
|
||||
],
|
||||
reportableInteractionLedger: [
|
||||
makeInteraction('sit-brazil', 'Brazil conflict situation', 'sit-israel', 'Israel conflict situation',
|
||||
'security_escalation', 'round_1', 5.8, 0.87, 0.85, true, false),
|
||||
makeInteraction('sit-brazil', 'Brazil conflict situation', 'sit-israel', 'Israel conflict situation',
|
||||
'security_escalation', 'round_2', 5.7, 0.86, 0.85, true, false),
|
||||
],
|
||||
});
|
||||
assert.equal(effects.length, 0, 'Brazil → Israel via generic external actor should be blocked (actorSpec 0.85 < 0.90)');
|
||||
});
|
||||
|
||||
it('allows Brazil → Mexico (same macro-region, security_escalation → infrastructure)', () => {
|
||||
const effects = buildCrossSituationEffects({
|
||||
situationSimulations: [
|
||||
makeSimulation('sit-brazil', 'Brazil conflict situation', 'conflict', ['Brazil'], 'escalatory', 0.84,
|
||||
[{ type: 'security_escalation', count: 3 }]),
|
||||
// target must be infrastructure for security_escalation:infrastructure relation to exist
|
||||
makeSimulation('sit-mexico', 'Mexico infrastructure situation', 'infrastructure', ['Mexico'], 'escalatory', 0.72),
|
||||
],
|
||||
reportableInteractionLedger: [
|
||||
makeInteraction('sit-brazil', 'Brazil conflict situation', 'sit-mexico', 'Mexico infrastructure situation',
|
||||
'security_escalation', 'round_1', 5.8, 0.87, 0.85, true, false),
|
||||
makeInteraction('sit-brazil', 'Brazil conflict situation', 'sit-mexico', 'Mexico infrastructure situation',
|
||||
'security_escalation', 'round_2', 5.7, 0.86, 0.85, true, false),
|
||||
],
|
||||
});
|
||||
assert.equal(effects.length, 1, 'Brazil → Mexico should pass (both AMERICAS, cross-theater gate does not apply)');
|
||||
assert.equal(effects[0].channel, 'security_escalation');
|
||||
});
|
||||
|
||||
it('blocks Cuba → Iran infrastructure (cross-theater, service_disruption, spec 0.73 < 0.90)', () => {
|
||||
const effects = buildCrossSituationEffects({
|
||||
situationSimulations: [
|
||||
makeSimulation('sit-cuba', 'Cuba infrastructure situation', 'infrastructure', ['Cuba'], 'contested', 0.62,
|
||||
[{ type: 'service_disruption', count: 3 }]),
|
||||
makeSimulation('sit-iran', 'Iran infrastructure situation', 'infrastructure', ['Iran'], 'contested', 0.58),
|
||||
],
|
||||
reportableInteractionLedger: [
|
||||
makeInteraction('sit-cuba', 'Cuba infrastructure situation', 'sit-iran', 'Iran infrastructure situation',
|
||||
'service_disruption', 'round_1', 5.5, 0.84, 0.73, true, false),
|
||||
makeInteraction('sit-cuba', 'Cuba infrastructure situation', 'sit-iran', 'Iran infrastructure situation',
|
||||
'service_disruption', 'round_2', 5.4, 0.83, 0.73, true, false),
|
||||
],
|
||||
});
|
||||
assert.equal(effects.length, 0, 'Cuba → Iran via generic civil-protection actor should be blocked');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user