Files
worldmonitor/tests/forecast-detectors.test.mjs
Elie Habib 45f5e5a457 feat(forecast): AI Forecasts prediction module (#1579)
* 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.
2026-03-15 01:42:04 +04:00

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');
});
});