mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(forecast): add AI Forecasts prediction module (Pro-tier)
MiroFish-inspired prediction engine that generates structured forecasts
across 6 domains (conflict, market, supply chain, political, military,
infrastructure) using existing WorldMonitor data streams.
- Proto definitions for ForecastService with GetForecasts RPC
- Dedicated seed script (seed-forecasts.mjs) with 6 domain detectors,
cross-domain cascade resolver, prediction market calibration, and
trend detection via prior snapshot comparison
- Premium-gated RPC handler (PREMIUM_RPC_PATHS enforcement)
- Lazy-loaded ForecastPanel with domain filters, probability bars,
trend arrows, signal evidence, and cascade links
- Health monitoring integration (seed-meta freshness tracking)
- Refresh scheduler with API key guard
* test(forecast): add 47 unit tests for forecast detectors and utilities
Covers forecastId, normalize, resolveCascades, calibrateWithMarkets,
computeTrends, and smoke tests for all 6 domain detectors. Exports
testable functions from seed script with direct-run guard.
* fix(forecast): domain mismatch 'infra' vs 'infrastructure', add panel category
- Seed script used 'infra' but ForecastPanel filtered on 'infrastructure',
causing Infra tab to show zero results
- Added 'forecast' to intelligence category in PANEL_CATEGORY_MAP
* fix(forecast): move CSS to one-time injection, improve type safety
- P2: Move style block from setContent to one-time document.head injection
to prevent CSS accumulation on repeated renders
- P3: Replace +toFixed(3) with Math.round for readability in seed script
- P3: Use Forecast type instead of any[] in RPC handler filter
* fix(forecast): handle sebuf proto data shapes from Redis
Detectors now normalize CII scores from server-side proto format
(combinedScore, TREND_DIRECTION_RISING, region) to uniform shape.
Outage severity handles proto enum format (SEVERITY_LEVEL_HIGH).
Added confidence floor of 0.3 for single-source predictions.
Verified against live Redis: 2 predictions generated (Iran infra
shutdown, IL political instability).
* feat(forecast): unlock AI Forecasts on web, lock desktop only (trial)
- Remove forecast RPC from PREMIUM_RPC_PATHS (web access is free)
- Panel locked on desktop only (same as oref-sirens/telegram-intel)
- Remove API key guards from data-loader and refresh scheduler
- Web users get full access during trial period
* chore: regenerate proto types with make generate
Re-ran make generate after rebasing on main. Plugin v0.7.0 dropped
@ts-nocheck from output, added it back to all 50 generated files.
Fixed 4 type errors from proto codegen changes:
- MarketSource enum -> string union type
- TemporalAnomalyProto -> TemporalAnomaly rename
- webcam lastUpdated number -> string
* fix(forecast): use chokepoints v4 key, include ciiContribution in unrest
- P1: Switch chokepoints input from stale v2 to active v4 Redis key,
matching bootstrap.js and cache-keys.ts
- P2: Add ciiContribution to unrest component fallback chain in
normalizeCiiEntry so political detector reads the correct sebuf field
* feat(forecast): Phase 2 LLM scenario enrichment + confidence model
MiroFish-inspired enhancements:
- LLM scenario narratives via Groq/OpenRouter (narrative-only, no numeric
adjustment). Evidence-grounded prompts with mandatory signal citation
and few-shot examples from MiroFish's SECTION_SYSTEM_PROMPT_TEMPLATE.
- Top-4 predictions batched into single LLM call for cost efficiency.
- News context from newsInsights attached to all predictions for LLM
prompt grounding (NOT in signals, cannot affect confidence).
- Deterministic confidence model: source diversity via SIGNAL_TO_SOURCE
mapping (deduplicates cii+cii_delta, theater+indicators) + calibration
agreement from prediction market drift. Floor 0.2, ceiling 1.0.
- Output validation: rejects scenarios without signal references.
- Truncated JSON repair for small model output.
- Structured JSON logging for LLM calls.
- Redis cache for LLM scenarios (1h TTL).
- 23 new tests (70 total), all passing.
- Live-tested: OpenRouter gemini-2.5-flash produces evidence-grounded
scenario narratives from real WorldMonitor data.
* feat(forecast): Phase 3 multi-perspective scenarios, projections, data-driven cascades
MiroFish-inspired enhancements:
- Multi-perspective LLM analysis: top-2 predictions get strategic,
regional, and contrarian viewpoints via combined LLM call
- Probability projections: domain-specific decay curves (h24/d7/d30)
anchored to timeHorizon so probability equals projections[timeHorizon]
- Data-driven cascade rules: moved from hardcoded array to JSON config
(scripts/data/cascade-rules.json) with schema validation, named
predicate evaluators, unknown key rejection, and fallback to defaults
- 4 new cascade paths: infrastructure->supply_chain, infrastructure->market
(both requiresSeverity:total), conflict->political, political->market
- Proto: added Perspectives and Projections messages to Forecast
- ForecastPanel: renders projections row and conditional perspectives toggle
- 89 tests (19 new), all passing
- Live-tested: OpenRouter produces perspectives from real data
* feat(forecast): Phase 4 data utilization + entity graph
Fixes data gaps that prevented 4 of 6 detectors from firing:
- Input normalizers: chokepoint v4 shape + GPS hexes-to-zones mapping
- Chokepoint warm-ping (production-only, requires WM_API_BASE_URL)
- Lowered CII conflict threshold from 70 to 60, gated on level=high|critical
4 new standalone detectors:
- UCDP conflict zones (10+ events per country)
- Cyber threat concentration (5+ threats per country)
- GPS jamming in maritime shipping zones (5 regions)
- Prediction markets as signals (60-90% probability markets)
Entity-relationship graph (file-based, 38 nodes):
- Countries, theaters, commodities, chokepoints, alliances
- Alias table resolves both ISO codes and display names
- Graph cascade discovery links predictions across entities
Result: 51 predictions (up from 1-2), spanning conflict, infrastructure,
and supply chain domains. 112 tests, all passing.
* fix(forecast): redis cache format, signal source mapping, type safety
Fresh-eyes audit fixes:
- BUG: redisSet used wrong Upstash API format (POST body with {value,ex}
instead of command array ['SET',key,value,'EX',ttl]). LLM cache writes
were silently failing, causing fresh LLM calls every run.
- BUG: prediction_market signal type missing from SIGNAL_TO_SOURCE,
inflating confidence for market-derived predictions.
- CLEANUP: Remove unnecessary (f as any) casts in ForecastPanel since
generated Forecast type already has projections/perspectives fields.
- CLEANUP: Bump health maxStaleMin from 60 to 90 to avoid false STALE
alerts when LLM calls add latency to seed runs.
* feat(forecast): headline-entity matching with news corroboration signals
Uses entity graph aliases to match headlines to predictions by
country/theater (excludes commodity/infrastructure nodes to prevent
false positives). Predictions with matching headlines get a
news_corroboration signal visible in the panel.
Also fixes buildUserPrompt to merge unique headlines from ALL
predictions in the LLM batch (was only reading preds[0].newsContext).
Live-tested: 13 of 51 predictions now have corroborating headlines
(Iran, Israel, Syria, Ukraine, etc). 116 tests, all passing.
* feat(forecast): add country-codes.json for headline-entity matching
56 countries with ISO codes, full names, and scoring keywords (extracted
from src/config/countries.ts + UCDP-relevant additions). Used by
attachNewsContext for richer headline matching via getSearchTermsForRegion
which combines country-codes + entity graph + keyword aliases.
14/57 predictions now have news corroboration (limited by headline
coverage, not matching quality: only 8 headlines currently available).
* feat(forecast): read 300 headlines from news digest instead of 8
Read news:digest:v1:full:en (300 headlines across 16 categories) instead
of just news:insights:v1 topStories (8 headlines). Fallback to topStories
if digest is unavailable.
Result: news corroboration jumped from 25% to 64% (38/59 predictions).
* fix(forecast): handle parenthetical country names in headline matching
Strip suffixes like '(Zaire)', '(Burma)', '(Soviet Union)' from UCDP
region names before matching against country-codes.json. Also use
includes() for reverse name lookup to catch partial matches.
Corroboration: 64% -> 69% (41/59). Remaining 18 unmatched are countries
with no current English-language news coverage.
* fix(forecast): cache validated LLM output, add digest test, log cache errors
Fresh-eyes audit fixes:
- Combined LLM cache now stores only validated items (was caching raw
unvalidated output, serving potentially invalid scenarios on cache hit)
- redisSet logs warnings on failure (was silently swallowing all errors)
- Added digest-based test for attachNewsContext (primary path was untested)
- Fixed test arity: attachNewsContext(preds, news, digest) with 3 params
* fix(forecast): remove dead confidenceFromSources, reduce warm-ping timeout
- P2: Remove confidenceFromSources (dead code, computeConfidence overwrites
all initial confidence values). Inline the formula in original detectors.
- P3: Reduce warm-ping timeout from 30s to 15s (non-critical step)
- P3: Add trial status comment on forecast panel config
* fix(forecast): resolve ISO codes to country names, fix market detector, safe pre-push
P1 fixes from code review:
- CII ISO codes (IL, IR) now resolved to full country names (Israel, Iran)
via country-codes.json. Prevents substring false positives (IL matching
Chile) in event correlation. Uses word-boundary regex for matching.
- Market detector CII-to-theater mapping now uses entity graph traversal
instead of broken theater-name substring matching. Iran correctly maps
to Middle East theater via graph links.
- Pre-push hook no longer runs destructive git checkout on proto freshness
failure. Reports mismatch and exits without modifying worktree.
990 lines
36 KiB
JavaScript
990 lines
36 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { describe, it } from 'node:test';
|
|
|
|
import {
|
|
forecastId,
|
|
normalize,
|
|
makePrediction,
|
|
resolveCascades,
|
|
calibrateWithMarkets,
|
|
computeTrends,
|
|
detectConflictScenarios,
|
|
detectMarketScenarios,
|
|
detectSupplyChainScenarios,
|
|
detectPoliticalScenarios,
|
|
detectMilitaryScenarios,
|
|
detectInfraScenarios,
|
|
detectUcdpConflictZones,
|
|
detectCyberScenarios,
|
|
detectGpsJammingScenarios,
|
|
detectFromPredictionMarkets,
|
|
normalizeChokepoints,
|
|
normalizeGpsJamming,
|
|
loadEntityGraph,
|
|
discoverGraphCascades,
|
|
attachNewsContext,
|
|
computeConfidence,
|
|
sanitizeForPrompt,
|
|
parseLLMScenarios,
|
|
validateScenarios,
|
|
validatePerspectives,
|
|
computeProjections,
|
|
loadCascadeRules,
|
|
evaluateRuleConditions,
|
|
SIGNAL_TO_SOURCE,
|
|
PREDICATE_EVALUATORS,
|
|
DEFAULT_CASCADE_RULES,
|
|
PROJECTION_CURVES,
|
|
} from '../scripts/seed-forecasts.mjs';
|
|
|
|
describe('forecastId', () => {
|
|
it('same inputs produce same ID', () => {
|
|
const a = forecastId('conflict', 'Iran', 'Escalation risk');
|
|
const b = forecastId('conflict', 'Iran', 'Escalation risk');
|
|
assert.equal(a, b);
|
|
});
|
|
|
|
it('different inputs produce different IDs', () => {
|
|
const a = forecastId('conflict', 'Iran', 'Escalation risk');
|
|
const b = forecastId('market', 'Iran', 'Oil price shock');
|
|
assert.notEqual(a, b);
|
|
});
|
|
|
|
it('ID format is fc-{domain}-{8char_hex}', () => {
|
|
const id = forecastId('conflict', 'Middle East', 'Theater escalation');
|
|
assert.match(id, /^fc-conflict-[0-9a-f]{8}$/);
|
|
});
|
|
|
|
it('domain is embedded in the ID', () => {
|
|
const id = forecastId('market', 'Red Sea', 'Oil disruption');
|
|
assert.ok(id.startsWith('fc-market-'));
|
|
});
|
|
});
|
|
|
|
describe('normalize', () => {
|
|
it('value at min returns 0', () => {
|
|
assert.equal(normalize(50, 50, 100), 0);
|
|
});
|
|
|
|
it('value at max returns 1', () => {
|
|
assert.equal(normalize(100, 50, 100), 1);
|
|
});
|
|
|
|
it('midpoint returns 0.5', () => {
|
|
assert.equal(normalize(75, 50, 100), 0.5);
|
|
});
|
|
|
|
it('value below min clamps to 0', () => {
|
|
assert.equal(normalize(10, 50, 100), 0);
|
|
});
|
|
|
|
it('value above max clamps to 1', () => {
|
|
assert.equal(normalize(200, 50, 100), 1);
|
|
});
|
|
|
|
it('min === max returns 0', () => {
|
|
assert.equal(normalize(50, 50, 50), 0);
|
|
});
|
|
|
|
it('min > max returns 0', () => {
|
|
assert.equal(normalize(50, 100, 50), 0);
|
|
});
|
|
});
|
|
|
|
describe('resolveCascades', () => {
|
|
it('conflict near chokepoint creates supply_chain and market cascades', () => {
|
|
const pred = makePrediction(
|
|
'conflict', 'Middle East', 'Escalation risk: Iran',
|
|
0.7, 0.6, '7d', [{ type: 'cii', value: 'Iran CII 85', weight: 0.4 }],
|
|
);
|
|
const predictions = [pred];
|
|
resolveCascades(predictions, DEFAULT_CASCADE_RULES);
|
|
const domains = pred.cascades.map(c => c.domain);
|
|
assert.ok(domains.includes('supply_chain'), 'should have supply_chain cascade');
|
|
assert.ok(domains.includes('market'), 'should have market cascade');
|
|
});
|
|
|
|
it('cascade probabilities capped at 0.8', () => {
|
|
const pred = makePrediction(
|
|
'conflict', 'Middle East', 'Escalation risk: Iran',
|
|
0.99, 0.9, '7d', [{ type: 'cii', value: 'high', weight: 0.4 }],
|
|
);
|
|
resolveCascades([pred], DEFAULT_CASCADE_RULES);
|
|
for (const c of pred.cascades) {
|
|
assert.ok(c.probability <= 0.8, `cascade probability ${c.probability} should be <= 0.8`);
|
|
}
|
|
});
|
|
|
|
it('deduplication within a single call: same rule does not fire twice for same source', () => {
|
|
const pred = makePrediction(
|
|
'conflict', 'Middle East', 'Escalation risk: Iran',
|
|
0.7, 0.6, '7d', [{ type: 'cii', value: 'test', weight: 0.4 }],
|
|
);
|
|
resolveCascades([pred], DEFAULT_CASCADE_RULES);
|
|
const keys = pred.cascades.map(c => `${c.domain}:${c.effect}`);
|
|
const unique = new Set(keys);
|
|
assert.equal(keys.length, unique.size, 'no duplicate cascade entries within one resolution');
|
|
});
|
|
|
|
it('no self-edges: cascade domain differs from source domain', () => {
|
|
const pred = makePrediction(
|
|
'conflict', 'Middle East', 'Escalation',
|
|
0.7, 0.6, '7d', [{ type: 'cii', value: 'test', weight: 0.4 }],
|
|
);
|
|
resolveCascades([pred], DEFAULT_CASCADE_RULES);
|
|
for (const c of pred.cascades) {
|
|
assert.notEqual(c.domain, pred.domain, `cascade domain ${c.domain} should differ from source ${pred.domain}`);
|
|
}
|
|
});
|
|
|
|
it('political > 0.6 creates conflict cascade', () => {
|
|
const pred = makePrediction(
|
|
'political', 'Iran', 'Political instability',
|
|
0.65, 0.5, '30d', [{ type: 'unrest', value: 'unrest', weight: 0.4 }],
|
|
);
|
|
resolveCascades([pred], DEFAULT_CASCADE_RULES);
|
|
const domains = pred.cascades.map(c => c.domain);
|
|
assert.ok(domains.includes('conflict'), 'political instability should cascade to conflict');
|
|
});
|
|
|
|
it('political <= 0.6 does not cascade to conflict', () => {
|
|
const pred = makePrediction(
|
|
'political', 'Iran', 'Political instability',
|
|
0.5, 0.5, '30d', [{ type: 'unrest', value: 'unrest', weight: 0.4 }],
|
|
);
|
|
resolveCascades([pred], DEFAULT_CASCADE_RULES);
|
|
assert.equal(pred.cascades.length, 0);
|
|
});
|
|
});
|
|
|
|
describe('calibrateWithMarkets', () => {
|
|
it('matching market adjusts probability with 40/60 blend', () => {
|
|
const pred = makePrediction(
|
|
'conflict', 'Middle East', 'Escalation',
|
|
0.7, 0.6, '7d', [],
|
|
);
|
|
pred.region = 'Middle East';
|
|
const markets = {
|
|
geopolitical: [{ title: 'Will Iran conflict escalate in MENA?', yesPrice: 30, source: 'polymarket' }],
|
|
};
|
|
calibrateWithMarkets([pred], markets);
|
|
const expected = +(0.4 * 0.3 + 0.6 * 0.7).toFixed(3);
|
|
assert.equal(pred.probability, expected);
|
|
assert.ok(pred.calibration !== null);
|
|
assert.equal(pred.calibration.source, 'polymarket');
|
|
});
|
|
|
|
it('no match leaves probability unchanged', () => {
|
|
const pred = makePrediction(
|
|
'conflict', 'Korean Peninsula', 'Korea escalation',
|
|
0.6, 0.5, '7d', [],
|
|
);
|
|
const originalProb = pred.probability;
|
|
const markets = {
|
|
geopolitical: [{ title: 'Will EU inflation drop?', yesPrice: 50 }],
|
|
};
|
|
calibrateWithMarkets([pred], markets);
|
|
assert.equal(pred.probability, originalProb);
|
|
assert.equal(pred.calibration, null);
|
|
});
|
|
|
|
it('drift calculated correctly', () => {
|
|
const pred = makePrediction(
|
|
'conflict', 'Middle East', 'Escalation',
|
|
0.7, 0.6, '7d', [],
|
|
);
|
|
const markets = {
|
|
geopolitical: [{ title: 'Iran MENA conflict?', yesPrice: 40 }],
|
|
};
|
|
calibrateWithMarkets([pred], markets);
|
|
assert.equal(pred.calibration.drift, +(0.7 - 0.4).toFixed(3));
|
|
});
|
|
|
|
it('null markets handled gracefully', () => {
|
|
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
|
|
calibrateWithMarkets([pred], null);
|
|
assert.equal(pred.calibration, null);
|
|
});
|
|
|
|
it('empty markets handled gracefully', () => {
|
|
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
|
|
calibrateWithMarkets([pred], {});
|
|
assert.equal(pred.calibration, null);
|
|
});
|
|
|
|
it('markets without geopolitical key handled gracefully', () => {
|
|
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
|
|
calibrateWithMarkets([pred], { crypto: [] });
|
|
assert.equal(pred.calibration, null);
|
|
});
|
|
});
|
|
|
|
describe('computeTrends', () => {
|
|
it('no prior: all trends set to stable', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.6, 0.5, '7d', []);
|
|
computeTrends([pred], null);
|
|
assert.equal(pred.trend, 'stable');
|
|
assert.equal(pred.priorProbability, pred.probability);
|
|
});
|
|
|
|
it('rising: delta > 0.05', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.7, 0.5, '7d', []);
|
|
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
|
|
computeTrends([pred], prior);
|
|
assert.equal(pred.trend, 'rising');
|
|
assert.equal(pred.priorProbability, 0.5);
|
|
});
|
|
|
|
it('falling: delta < -0.05', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.3, 0.5, '7d', []);
|
|
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
|
|
computeTrends([pred], prior);
|
|
assert.equal(pred.trend, 'falling');
|
|
});
|
|
|
|
it('stable: delta within +/- 0.05', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.52, 0.5, '7d', []);
|
|
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
|
|
computeTrends([pred], prior);
|
|
assert.equal(pred.trend, 'stable');
|
|
});
|
|
|
|
it('new prediction (no prior match): stable', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Brand new', 0.6, 0.5, '7d', []);
|
|
const prior = { predictions: [{ id: 'fc-conflict-00000000', probability: 0.5 }] };
|
|
computeTrends([pred], prior);
|
|
assert.equal(pred.trend, 'stable');
|
|
assert.equal(pred.priorProbability, pred.probability);
|
|
});
|
|
|
|
it('prior with empty predictions array: all stable', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.6, 0.5, '7d', []);
|
|
computeTrends([pred], { predictions: [] });
|
|
assert.equal(pred.trend, 'stable');
|
|
});
|
|
|
|
it('just above +0.05 threshold: rising', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.56, 0.5, '7d', []);
|
|
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
|
|
computeTrends([pred], prior);
|
|
assert.equal(pred.trend, 'rising');
|
|
});
|
|
|
|
it('just below -0.05 threshold: falling', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.44, 0.5, '7d', []);
|
|
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
|
|
computeTrends([pred], prior);
|
|
assert.equal(pred.trend, 'falling');
|
|
});
|
|
|
|
it('delta exactly at boundary: uses strict comparison (> 0.05)', () => {
|
|
const pred = makePrediction('conflict', 'Iran', 'Test', 0.549, 0.5, '7d', []);
|
|
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
|
|
computeTrends([pred], prior);
|
|
assert.equal(pred.trend, 'stable');
|
|
});
|
|
});
|
|
|
|
describe('detector smoke tests: null/empty inputs', () => {
|
|
it('detectConflictScenarios({}) returns []', () => {
|
|
assert.deepEqual(detectConflictScenarios({}), []);
|
|
});
|
|
|
|
it('detectMarketScenarios({}) returns []', () => {
|
|
assert.deepEqual(detectMarketScenarios({}), []);
|
|
});
|
|
|
|
it('detectSupplyChainScenarios({}) returns []', () => {
|
|
assert.deepEqual(detectSupplyChainScenarios({}), []);
|
|
});
|
|
|
|
it('detectPoliticalScenarios({}) returns []', () => {
|
|
assert.deepEqual(detectPoliticalScenarios({}), []);
|
|
});
|
|
|
|
it('detectMilitaryScenarios({}) returns []', () => {
|
|
assert.deepEqual(detectMilitaryScenarios({}), []);
|
|
});
|
|
|
|
it('detectInfraScenarios({}) returns []', () => {
|
|
assert.deepEqual(detectInfraScenarios({}), []);
|
|
});
|
|
|
|
it('detectors handle null arrays gracefully', () => {
|
|
const inputs = {
|
|
ciiScores: null,
|
|
temporalAnomalies: null,
|
|
theaterPosture: null,
|
|
chokepoints: null,
|
|
iranEvents: null,
|
|
ucdpEvents: null,
|
|
unrestEvents: null,
|
|
outages: null,
|
|
cyberThreats: null,
|
|
gpsJamming: null,
|
|
};
|
|
assert.deepEqual(detectConflictScenarios(inputs), []);
|
|
assert.deepEqual(detectMarketScenarios(inputs), []);
|
|
assert.deepEqual(detectSupplyChainScenarios(inputs), []);
|
|
assert.deepEqual(detectPoliticalScenarios(inputs), []);
|
|
assert.deepEqual(detectMilitaryScenarios(inputs), []);
|
|
assert.deepEqual(detectInfraScenarios(inputs), []);
|
|
});
|
|
});
|
|
|
|
describe('detectConflictScenarios', () => {
|
|
it('high CII rising score produces conflict prediction', () => {
|
|
const inputs = {
|
|
ciiScores: [{ code: 'IRN', name: 'Iran', score: 85, level: 'high', trend: 'rising' }],
|
|
theaterPosture: { theaters: [] },
|
|
iranEvents: [],
|
|
ucdpEvents: [],
|
|
};
|
|
const result = detectConflictScenarios(inputs);
|
|
assert.ok(result.length >= 1);
|
|
assert.equal(result[0].domain, 'conflict');
|
|
assert.ok(result[0].probability > 0);
|
|
assert.ok(result[0].probability <= 0.9);
|
|
});
|
|
|
|
it('low CII score is ignored', () => {
|
|
const inputs = {
|
|
ciiScores: [{ code: 'CHE', name: 'Switzerland', score: 30, level: 'low', trend: 'stable' }],
|
|
theaterPosture: { theaters: [] },
|
|
iranEvents: [],
|
|
ucdpEvents: [],
|
|
};
|
|
assert.deepEqual(detectConflictScenarios(inputs), []);
|
|
});
|
|
|
|
it('critical theater posture produces prediction', () => {
|
|
const inputs = {
|
|
ciiScores: [],
|
|
theaterPosture: { theaters: [{ id: 'iran-theater', name: 'Iran Theater', postureLevel: 'critical' }] },
|
|
iranEvents: [],
|
|
ucdpEvents: [],
|
|
};
|
|
const result = detectConflictScenarios(inputs);
|
|
assert.ok(result.length >= 1);
|
|
assert.equal(result[0].region, 'Middle East');
|
|
});
|
|
});
|
|
|
|
describe('detectMarketScenarios', () => {
|
|
it('high-risk chokepoint with known commodity produces market prediction', () => {
|
|
const inputs = {
|
|
chokepoints: { routes: [{ region: 'Middle East', riskLevel: 'critical', riskScore: 85 }] },
|
|
ciiScores: [],
|
|
};
|
|
const result = detectMarketScenarios(inputs);
|
|
assert.ok(result.length >= 1);
|
|
assert.equal(result[0].domain, 'market');
|
|
assert.ok(result[0].title.includes('Oil'));
|
|
});
|
|
|
|
it('low-risk chokepoint is ignored', () => {
|
|
const inputs = {
|
|
chokepoints: { routes: [{ region: 'Middle East', riskLevel: 'low', riskScore: 30 }] },
|
|
ciiScores: [],
|
|
};
|
|
assert.deepEqual(detectMarketScenarios(inputs), []);
|
|
});
|
|
});
|
|
|
|
describe('detectInfraScenarios', () => {
|
|
it('major outage produces infra prediction', () => {
|
|
const inputs = {
|
|
outages: [{ country: 'Syria', severity: 'major' }],
|
|
cyberThreats: [],
|
|
gpsJamming: [],
|
|
};
|
|
const result = detectInfraScenarios(inputs);
|
|
assert.ok(result.length >= 1);
|
|
assert.equal(result[0].domain, 'infrastructure');
|
|
assert.ok(result[0].title.includes('Syria'));
|
|
});
|
|
|
|
it('minor outage is ignored', () => {
|
|
const inputs = {
|
|
outages: [{ country: 'Test', severity: 'minor' }],
|
|
cyberThreats: [],
|
|
gpsJamming: [],
|
|
};
|
|
assert.deepEqual(detectInfraScenarios(inputs), []);
|
|
});
|
|
|
|
it('cyber threats boost probability', () => {
|
|
const base = {
|
|
outages: [{ country: 'Syria', severity: 'total' }],
|
|
cyberThreats: [],
|
|
gpsJamming: [],
|
|
};
|
|
const withCyber = {
|
|
outages: [{ country: 'Syria', severity: 'total' }],
|
|
cyberThreats: [{ country: 'Syria', type: 'ddos' }],
|
|
gpsJamming: [],
|
|
};
|
|
const baseResult = detectInfraScenarios(base);
|
|
const cyberResult = detectInfraScenarios(withCyber);
|
|
assert.ok(cyberResult[0].probability > baseResult[0].probability,
|
|
'cyber threats should boost probability');
|
|
});
|
|
});
|
|
|
|
// ── Phase 2 Tests ──────────────────────────────────────────
|
|
|
|
describe('attachNewsContext', () => {
|
|
it('matches headlines mentioning prediction region', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
const news = { topStories: [
|
|
{ primaryTitle: 'Iran tensions escalate after military action' },
|
|
{ primaryTitle: 'Stock market rallies on tech earnings' },
|
|
{ primaryTitle: 'Iran nuclear deal negotiations resume' },
|
|
]};
|
|
attachNewsContext(preds, news);
|
|
assert.equal(preds[0].newsContext.length, 2); // only Iran headlines
|
|
assert.ok(preds[0].newsContext[0].includes('Iran'));
|
|
});
|
|
|
|
it('adds news_corroboration signal when headlines match', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
const news = { topStories: [{ primaryTitle: 'Iran military strikes reported' }] };
|
|
attachNewsContext(preds, news);
|
|
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
|
|
assert.ok(corr, 'should have news_corroboration signal');
|
|
assert.equal(corr.weight, 0.15);
|
|
});
|
|
|
|
it('does NOT add signal when no headlines match', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
const news = { topStories: [{ primaryTitle: 'Local weather forecast sunny' }] };
|
|
attachNewsContext(preds, news);
|
|
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
|
|
assert.equal(corr, undefined);
|
|
});
|
|
|
|
it('falls back to generic headlines when no match', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
const news = { topStories: [
|
|
{ primaryTitle: 'Unrelated headline about sports' },
|
|
{ primaryTitle: 'Another unrelated story' },
|
|
{ primaryTitle: 'Third unrelated story' },
|
|
{ primaryTitle: 'Fourth unrelated story' },
|
|
]};
|
|
attachNewsContext(preds, news);
|
|
assert.equal(preds[0].newsContext.length, 3); // fallback top-3
|
|
});
|
|
|
|
it('excludes commodity node names from matching (no false positives)', () => {
|
|
// Iran links to "Oil" in entity graph, but "Oil" should NOT match headlines
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
const news = { topStories: [{ primaryTitle: 'Oil prices rise on global demand' }] };
|
|
attachNewsContext(preds, news);
|
|
// "Oil" is a commodity node, not country/theater, so should NOT match
|
|
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
|
|
assert.equal(corr, undefined, 'commodity names should not trigger corroboration');
|
|
});
|
|
|
|
it('reads headlines from digest categories (primary path)', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
const digest = { categories: {
|
|
middleeast: { items: [{ title: 'Iran launches missile test' }, { title: 'Saudi oil output stable' }] },
|
|
europe: { items: [{ title: 'EU summit concludes' }] },
|
|
}};
|
|
attachNewsContext(preds, null, digest);
|
|
assert.ok(preds[0].newsContext.length >= 1);
|
|
assert.ok(preds[0].newsContext[0].includes('Iran'));
|
|
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
|
|
assert.ok(corr, 'should have corroboration from digest headlines');
|
|
});
|
|
|
|
it('handles null newsInsights and null digest', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
attachNewsContext(preds, null, null);
|
|
assert.equal(preds[0].newsContext, undefined);
|
|
});
|
|
|
|
it('handles empty topStories with no digest', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
|
|
attachNewsContext(preds, { topStories: [] }, null);
|
|
assert.equal(preds[0].newsContext, undefined);
|
|
});
|
|
});
|
|
|
|
describe('computeConfidence', () => {
|
|
it('higher source diversity = higher confidence', () => {
|
|
const p1 = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
|
|
{ type: 'cii', value: 'test', weight: 0.4 },
|
|
]);
|
|
const p2 = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
|
|
{ type: 'cii', value: 'test', weight: 0.4 },
|
|
{ type: 'theater', value: 'test', weight: 0.3 },
|
|
{ type: 'ucdp', value: 'test', weight: 0.2 },
|
|
]);
|
|
computeConfidence([p1, p2]);
|
|
assert.ok(p2.confidence > p1.confidence);
|
|
});
|
|
|
|
it('cii and cii_delta count as one source', () => {
|
|
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
|
|
{ type: 'cii', value: 'test', weight: 0.4 },
|
|
{ type: 'cii_delta', value: 'test', weight: 0.2 },
|
|
]);
|
|
const pSingle = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
|
|
{ type: 'cii', value: 'test', weight: 0.4 },
|
|
]);
|
|
computeConfidence([p, pSingle]);
|
|
assert.equal(p.confidence, pSingle.confidence);
|
|
});
|
|
|
|
it('low calibration drift = higher confidence than high drift', () => {
|
|
const pLow = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
|
|
{ type: 'cii', value: 'test', weight: 0.4 },
|
|
]);
|
|
pLow.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.01, source: 'polymarket' };
|
|
const pHigh = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
|
|
{ type: 'cii', value: 'test', weight: 0.4 },
|
|
]);
|
|
pHigh.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.4, source: 'polymarket' };
|
|
computeConfidence([pLow, pHigh]);
|
|
assert.ok(pLow.confidence > pHigh.confidence);
|
|
});
|
|
|
|
it('high calibration drift = lower confidence', () => {
|
|
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
|
|
{ type: 'cii', value: 'test', weight: 0.4 },
|
|
]);
|
|
p.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.4, source: 'polymarket' };
|
|
computeConfidence([p]);
|
|
assert.ok(p.confidence <= 0.5);
|
|
});
|
|
|
|
it('floors at 0.2', () => {
|
|
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', []);
|
|
p.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.5, source: 'polymarket' };
|
|
computeConfidence([p]);
|
|
assert.ok(p.confidence >= 0.2);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeForPrompt', () => {
|
|
it('strips HTML tags', () => {
|
|
assert.equal(sanitizeForPrompt('<script>alert("xss")</script>hello'), 'scriptalert("xss")/scripthello');
|
|
});
|
|
|
|
it('strips newlines', () => {
|
|
assert.equal(sanitizeForPrompt('line1\nline2\rline3'), 'line1 line2 line3');
|
|
});
|
|
|
|
it('truncates to 200 chars', () => {
|
|
const long = 'x'.repeat(300);
|
|
assert.equal(sanitizeForPrompt(long).length, 200);
|
|
});
|
|
|
|
it('handles null/undefined', () => {
|
|
assert.equal(sanitizeForPrompt(null), '');
|
|
assert.equal(sanitizeForPrompt(undefined), '');
|
|
});
|
|
});
|
|
|
|
describe('parseLLMScenarios', () => {
|
|
it('parses valid JSON array', () => {
|
|
const result = parseLLMScenarios('[{"index": 0, "scenario": "Test scenario"}]');
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].index, 0);
|
|
});
|
|
|
|
it('returns null for invalid JSON', () => {
|
|
assert.equal(parseLLMScenarios('not json at all'), null);
|
|
});
|
|
|
|
it('strips thinking tags before parsing', () => {
|
|
const result = parseLLMScenarios('<think>reasoning here</think>[{"index": 0, "scenario": "Test"}]');
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
it('repairs truncated JSON array', () => {
|
|
const result = parseLLMScenarios('[{"index": 0, "scenario": "Test scenario"');
|
|
assert.ok(result !== null);
|
|
assert.equal(result[0].index, 0);
|
|
});
|
|
|
|
it('extracts JSON from surrounding text', () => {
|
|
const result = parseLLMScenarios('Here is my analysis:\n[{"index": 0, "scenario": "Test"}]\nDone.');
|
|
assert.equal(result.length, 1);
|
|
});
|
|
});
|
|
|
|
describe('validateScenarios', () => {
|
|
const preds = [
|
|
makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [
|
|
{ type: 'cii', value: 'Iran CII 87 critical', weight: 0.4 },
|
|
]),
|
|
];
|
|
|
|
it('accepts scenario with signal reference', () => {
|
|
const scenarios = [{ index: 0, scenario: 'The Iran CII score of 87 indicates critical instability in the region, driven by ongoing military activity.' }];
|
|
const valid = validateScenarios(scenarios, preds);
|
|
assert.equal(valid.length, 1);
|
|
});
|
|
|
|
it('rejects scenario without signal reference', () => {
|
|
const scenarios = [{ index: 0, scenario: 'Tensions continue to rise in the region due to various geopolitical factors and ongoing disputes.' }];
|
|
const valid = validateScenarios(scenarios, preds);
|
|
assert.equal(valid.length, 0);
|
|
});
|
|
|
|
it('rejects too-short scenario', () => {
|
|
const scenarios = [{ index: 0, scenario: 'Short.' }];
|
|
const valid = validateScenarios(scenarios, preds);
|
|
assert.equal(valid.length, 0);
|
|
});
|
|
|
|
it('rejects out-of-bounds index', () => {
|
|
const scenarios = [{ index: 5, scenario: 'Iran CII 87 indicates critical instability in the region.' }];
|
|
const valid = validateScenarios(scenarios, preds);
|
|
assert.equal(valid.length, 0);
|
|
});
|
|
|
|
it('strips HTML from scenario', () => {
|
|
const scenarios = [{ index: 0, scenario: 'The Iran CII score of 87 <b>critical</b> indicates instability in the conflict zone region.' }];
|
|
const valid = validateScenarios(scenarios, preds);
|
|
assert.equal(valid.length, 1);
|
|
assert.ok(!valid[0].scenario.includes('<b>'));
|
|
});
|
|
|
|
it('handles null/non-array input', () => {
|
|
assert.deepEqual(validateScenarios(null, preds), []);
|
|
assert.deepEqual(validateScenarios('not array', preds), []);
|
|
});
|
|
});
|
|
|
|
// ── Phase 3 Tests ──────────────────────────────────────────
|
|
|
|
describe('computeProjections', () => {
|
|
it('anchors projection to timeHorizon', () => {
|
|
const p = makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', []);
|
|
computeProjections([p]);
|
|
assert.ok(p.projections);
|
|
// probability should equal the d7 projection (anchored to 7d)
|
|
assert.equal(p.projections.d7, p.probability);
|
|
});
|
|
|
|
it('different domains produce different curves', () => {
|
|
const conflict = makePrediction('conflict', 'A', 'a', 0.5, 0.5, '7d', []);
|
|
const infra = makePrediction('infrastructure', 'B', 'b', 0.5, 0.5, '24h', []);
|
|
computeProjections([conflict, infra]);
|
|
assert.notEqual(conflict.projections.d30, infra.projections.d30);
|
|
});
|
|
|
|
it('caps at 0.95', () => {
|
|
const p = makePrediction('conflict', 'Iran', 'test', 0.9, 0.5, '7d', []);
|
|
computeProjections([p]);
|
|
assert.ok(p.projections.h24 <= 0.95);
|
|
assert.ok(p.projections.d7 <= 0.95);
|
|
assert.ok(p.projections.d30 <= 0.95);
|
|
});
|
|
|
|
it('floors at 0.01', () => {
|
|
const p = makePrediction('infrastructure', 'A', 'test', 0.02, 0.5, '24h', []);
|
|
computeProjections([p]);
|
|
assert.ok(p.projections.d30 >= 0.01);
|
|
});
|
|
|
|
it('unknown domain defaults to multiplier 1', () => {
|
|
const p = makePrediction('unknown_domain', 'X', 'test', 0.5, 0.5, '7d', []);
|
|
computeProjections([p]);
|
|
assert.equal(p.projections.h24, 0.5);
|
|
assert.equal(p.projections.d7, 0.5);
|
|
assert.equal(p.projections.d30, 0.5);
|
|
});
|
|
});
|
|
|
|
describe('validatePerspectives', () => {
|
|
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [
|
|
{ type: 'cii', value: 'Iran CII 87', weight: 0.4 },
|
|
])];
|
|
|
|
it('accepts valid perspectives', () => {
|
|
const items = [{
|
|
index: 0,
|
|
strategic: 'The CII data shows critical instability with a score of 87 in the conflict region.',
|
|
regional: 'Regional actors face mounting pressure from the elevated CII threat level.',
|
|
contrarian: 'Despite CII readings, diplomatic channels remain open and could defuse tensions.',
|
|
}];
|
|
const valid = validatePerspectives(items, preds);
|
|
assert.equal(valid.length, 1);
|
|
});
|
|
|
|
it('rejects too-short perspectives', () => {
|
|
const items = [{ index: 0, strategic: 'Short.', regional: 'Also short.', contrarian: 'Nope.' }];
|
|
assert.equal(validatePerspectives(items, preds).length, 0);
|
|
});
|
|
|
|
it('strips HTML before length check', () => {
|
|
const items = [{
|
|
index: 0,
|
|
strategic: '<b><i><span>x</span></i></b>',
|
|
regional: 'Valid regional perspective with enough characters here.',
|
|
contrarian: 'Valid contrarian perspective with enough characters here.',
|
|
}];
|
|
assert.equal(validatePerspectives(items, preds).length, 0);
|
|
});
|
|
|
|
it('handles null input', () => {
|
|
assert.deepEqual(validatePerspectives(null, preds), []);
|
|
});
|
|
|
|
it('rejects out-of-bounds index', () => {
|
|
const items = [{
|
|
index: 5,
|
|
strategic: 'Valid strategic perspective with sufficient length.',
|
|
regional: 'Valid regional perspective with sufficient length too.',
|
|
contrarian: 'Valid contrarian perspective with sufficient length too.',
|
|
}];
|
|
assert.equal(validatePerspectives(items, preds).length, 0);
|
|
});
|
|
});
|
|
|
|
describe('loadCascadeRules', () => {
|
|
it('loads rules from JSON file', () => {
|
|
const rules = loadCascadeRules();
|
|
assert.ok(Array.isArray(rules));
|
|
assert.ok(rules.length >= 5);
|
|
});
|
|
|
|
it('each rule has required fields', () => {
|
|
const rules = loadCascadeRules();
|
|
for (const r of rules) {
|
|
assert.ok(r.from, 'missing from');
|
|
assert.ok(r.to, 'missing to');
|
|
assert.ok(typeof r.coupling === 'number', 'coupling must be number');
|
|
assert.ok(r.mechanism, 'missing mechanism');
|
|
}
|
|
});
|
|
|
|
it('includes new Phase 3 rules', () => {
|
|
const rules = loadCascadeRules();
|
|
const infraToSupply = rules.find(r => r.from === 'infrastructure' && r.to === 'supply_chain');
|
|
assert.ok(infraToSupply, 'infrastructure -> supply_chain rule missing');
|
|
assert.equal(infraToSupply.requiresSeverity, 'total');
|
|
});
|
|
});
|
|
|
|
describe('evaluateRuleConditions', () => {
|
|
it('requiresChokepoint passes for chokepoint region', () => {
|
|
const pred = makePrediction('conflict', 'Middle East', 'test', 0.5, 0.5, '7d', []);
|
|
assert.ok(evaluateRuleConditions({ requiresChokepoint: true }, pred));
|
|
});
|
|
|
|
it('requiresChokepoint fails for non-chokepoint region', () => {
|
|
const pred = makePrediction('conflict', 'Northern Europe', 'test', 0.5, 0.5, '7d', []);
|
|
assert.ok(!evaluateRuleConditions({ requiresChokepoint: true }, pred));
|
|
});
|
|
|
|
it('minProbability passes when above threshold', () => {
|
|
const pred = makePrediction('political', 'Iran', 'test', 0.7, 0.5, '7d', []);
|
|
assert.ok(evaluateRuleConditions({ minProbability: 0.6 }, pred));
|
|
});
|
|
|
|
it('minProbability fails when below threshold', () => {
|
|
const pred = makePrediction('political', 'Iran', 'test', 0.3, 0.5, '7d', []);
|
|
assert.ok(!evaluateRuleConditions({ minProbability: 0.6 }, pred));
|
|
});
|
|
|
|
it('requiresSeverity checks outage signal value', () => {
|
|
const pred = makePrediction('infrastructure', 'Iran', 'test', 0.5, 0.5, '24h', [
|
|
{ type: 'outage', value: 'Iran total outage', weight: 0.4 },
|
|
]);
|
|
assert.ok(evaluateRuleConditions({ requiresSeverity: 'total' }, pred));
|
|
});
|
|
|
|
it('requiresSeverity fails for non-matching severity', () => {
|
|
const pred = makePrediction('infrastructure', 'Iran', 'test', 0.5, 0.5, '24h', [
|
|
{ type: 'outage', value: 'Iran minor outage', weight: 0.4 },
|
|
]);
|
|
assert.ok(!evaluateRuleConditions({ requiresSeverity: 'total' }, pred));
|
|
});
|
|
});
|
|
|
|
// ── Phase 4 Tests ──────────────────────────────────────────
|
|
|
|
describe('normalizeChokepoints', () => {
|
|
it('maps v4 shape to v2 fields', () => {
|
|
const v4 = { chokepoints: [{ name: 'Suez Canal', disruptionScore: 75, status: 'yellow' }] };
|
|
const result = normalizeChokepoints(v4);
|
|
assert.equal(result.chokepoints[0].region, 'Suez Canal');
|
|
assert.equal(result.chokepoints[0].riskScore, 75);
|
|
assert.equal(result.chokepoints[0].riskLevel, 'high');
|
|
assert.equal(result.chokepoints[0].disrupted, false);
|
|
});
|
|
|
|
it('maps red status to critical + disrupted', () => {
|
|
const v4 = { chokepoints: [{ name: 'Hormuz', status: 'red' }] };
|
|
const result = normalizeChokepoints(v4);
|
|
assert.equal(result.chokepoints[0].riskLevel, 'critical');
|
|
assert.equal(result.chokepoints[0].disrupted, true);
|
|
});
|
|
|
|
it('handles null', () => {
|
|
assert.equal(normalizeChokepoints(null), null);
|
|
});
|
|
});
|
|
|
|
describe('normalizeGpsJamming', () => {
|
|
it('maps hexes to zones', () => {
|
|
const raw = { hexes: [{ lat: 35, lon: 30 }] };
|
|
const result = normalizeGpsJamming(raw);
|
|
assert.ok(result.zones);
|
|
assert.equal(result.zones[0].lat, 35);
|
|
});
|
|
|
|
it('preserves existing zones', () => {
|
|
const raw = { zones: [{ lat: 10, lon: 20 }] };
|
|
const result = normalizeGpsJamming(raw);
|
|
assert.equal(result.zones[0].lat, 10);
|
|
});
|
|
|
|
it('handles null', () => {
|
|
assert.equal(normalizeGpsJamming(null), null);
|
|
});
|
|
});
|
|
|
|
describe('detectUcdpConflictZones', () => {
|
|
it('generates prediction for 10+ events in one country', () => {
|
|
const events = Array.from({ length: 15 }, () => ({ country: 'Syria' }));
|
|
const result = detectUcdpConflictZones({ ucdpEvents: { events } });
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].domain, 'conflict');
|
|
assert.equal(result[0].region, 'Syria');
|
|
});
|
|
|
|
it('skips countries with < 10 events', () => {
|
|
const events = Array.from({ length: 5 }, () => ({ country: 'Jordan' }));
|
|
assert.equal(detectUcdpConflictZones({ ucdpEvents: { events } }).length, 0);
|
|
});
|
|
|
|
it('handles empty input', () => {
|
|
assert.equal(detectUcdpConflictZones({}).length, 0);
|
|
});
|
|
});
|
|
|
|
describe('detectCyberScenarios', () => {
|
|
it('generates prediction for 5+ threats in one country', () => {
|
|
const threats = Array.from({ length: 8 }, () => ({ country: 'US', type: 'malware' }));
|
|
const result = detectCyberScenarios({ cyberThreats: { threats } });
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].domain, 'infrastructure');
|
|
});
|
|
|
|
it('skips countries with < 5 threats', () => {
|
|
const threats = Array.from({ length: 3 }, () => ({ country: 'CH', type: 'phishing' }));
|
|
assert.equal(detectCyberScenarios({ cyberThreats: { threats } }).length, 0);
|
|
});
|
|
|
|
it('handles empty input', () => {
|
|
assert.equal(detectCyberScenarios({}).length, 0);
|
|
});
|
|
});
|
|
|
|
describe('detectGpsJammingScenarios', () => {
|
|
it('generates prediction for hexes in maritime region', () => {
|
|
const zones = Array.from({ length: 5 }, () => ({ lat: 35, lon: 30 })); // Eastern Med
|
|
const result = detectGpsJammingScenarios({ gpsJamming: { zones } });
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].domain, 'supply_chain');
|
|
assert.equal(result[0].region, 'Eastern Mediterranean');
|
|
});
|
|
|
|
it('skips hexes outside maritime regions', () => {
|
|
const zones = [{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }, { lat: 2, lon: 2 }];
|
|
assert.equal(detectGpsJammingScenarios({ gpsJamming: { zones } }).length, 0);
|
|
});
|
|
});
|
|
|
|
describe('detectFromPredictionMarkets', () => {
|
|
it('generates from 60-90% markets with region', () => {
|
|
const markets = { geopolitical: [{ title: 'Will Iran strike Israel?', yesPrice: 70, source: 'polymarket' }] };
|
|
const result = detectFromPredictionMarkets({ predictionMarkets: markets });
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].domain, 'conflict');
|
|
assert.equal(result[0].region, 'Middle East');
|
|
});
|
|
|
|
it('skips markets below 60%', () => {
|
|
const markets = { geopolitical: [{ title: 'Will US enter recession?', yesPrice: 30 }] };
|
|
assert.equal(detectFromPredictionMarkets({ predictionMarkets: markets }).length, 0);
|
|
});
|
|
|
|
it('caps at 5 predictions', () => {
|
|
const markets = { geopolitical: Array.from({ length: 10 }, (_, i) => ({
|
|
title: `Will Europe face crisis ${i}?`, yesPrice: 70,
|
|
})) };
|
|
assert.ok(detectFromPredictionMarkets({ predictionMarkets: markets }).length <= 5);
|
|
});
|
|
});
|
|
|
|
describe('lowered CII conflict threshold', () => {
|
|
it('CII score 67 (high level) now triggers conflict', () => {
|
|
const result = detectConflictScenarios({
|
|
ciiScores: { ciiScores: [{ region: 'IL', combinedScore: 67, trend: 'TREND_DIRECTION_STABLE', components: {} }] },
|
|
theaterPosture: { theaters: [] },
|
|
iranEvents: { events: [] },
|
|
ucdpEvents: { events: [] },
|
|
});
|
|
assert.ok(result.length >= 1, 'should trigger at score 67');
|
|
});
|
|
|
|
it('CII score 62 (elevated level) does NOT trigger conflict', () => {
|
|
const result = detectConflictScenarios({
|
|
ciiScores: { ciiScores: [{ region: 'JO', combinedScore: 62, trend: 'TREND_DIRECTION_RISING', components: {} }] },
|
|
theaterPosture: { theaters: [] },
|
|
iranEvents: { events: [] },
|
|
ucdpEvents: { events: [] },
|
|
});
|
|
assert.equal(result.length, 0, 'should NOT trigger at score 62 (elevated)');
|
|
});
|
|
});
|
|
|
|
describe('loadEntityGraph', () => {
|
|
it('loads graph from JSON', () => {
|
|
const graph = loadEntityGraph();
|
|
assert.ok(graph.nodes);
|
|
assert.ok(graph.aliases);
|
|
assert.ok(graph.edges);
|
|
assert.ok(Object.keys(graph.nodes).length > 10);
|
|
});
|
|
|
|
it('aliases resolve country codes', () => {
|
|
const graph = loadEntityGraph();
|
|
assert.equal(graph.aliases['IR'], 'IR');
|
|
assert.equal(graph.aliases['Iran'], 'IR');
|
|
assert.equal(graph.aliases['Middle East'], 'middle-east');
|
|
});
|
|
});
|
|
|
|
describe('discoverGraphCascades', () => {
|
|
it('finds linked predictions via graph', () => {
|
|
const graph = loadEntityGraph();
|
|
const preds = [
|
|
makePrediction('conflict', 'IR', 'Iran conflict', 0.6, 0.5, '7d', []),
|
|
makePrediction('market', 'Middle East', 'Oil impact', 0.4, 0.5, '30d', []),
|
|
];
|
|
discoverGraphCascades(preds, graph);
|
|
// IR links to middle-east theater, which has Oil impact prediction
|
|
const irCascades = preds[0].cascades.filter(c => c.effect.includes('graph:'));
|
|
assert.ok(irCascades.length > 0 || preds[1].cascades.length > 0, 'should find graph cascade between Iran and Middle East');
|
|
});
|
|
|
|
it('skips same-domain predictions', () => {
|
|
const graph = loadEntityGraph();
|
|
const preds = [
|
|
makePrediction('conflict', 'IR', 'a', 0.6, 0.5, '7d', []),
|
|
makePrediction('conflict', 'Middle East', 'b', 0.5, 0.5, '7d', []),
|
|
];
|
|
discoverGraphCascades(preds, graph);
|
|
const graphCascades = preds[0].cascades.filter(c => c.effect.includes('graph:'));
|
|
assert.equal(graphCascades.length, 0, 'same domain should not cascade');
|
|
});
|
|
});
|