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.
1354 lines
51 KiB
JavaScript
1354 lines
51 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import crypto from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { loadEnvFile, runSeed, CHROME_UA } from './_seed-utils.mjs';
|
|
import { tagRegions } from './_prediction-scoring.mjs';
|
|
|
|
const _isDirectRun = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'));
|
|
if (_isDirectRun) loadEnvFile(import.meta.url);
|
|
|
|
const CANONICAL_KEY = 'forecast:predictions:v1';
|
|
const PRIOR_KEY = 'forecast:predictions:prior:v1';
|
|
const TTL_SECONDS = 3600;
|
|
|
|
const THEATER_IDS = [
|
|
'iran-theater', 'taiwan-theater', 'baltic-theater',
|
|
'blacksea-theater', 'korea-theater', 'south-china-sea',
|
|
'east-med-theater', 'israel-gaza-theater', 'yemen-redsea-theater',
|
|
];
|
|
|
|
const THEATER_REGIONS = {
|
|
'iran-theater': 'Middle East',
|
|
'taiwan-theater': 'Western Pacific',
|
|
'baltic-theater': 'Northern Europe',
|
|
'blacksea-theater': 'Black Sea',
|
|
'korea-theater': 'Korean Peninsula',
|
|
'south-china-sea': 'South China Sea',
|
|
'east-med-theater': 'Eastern Mediterranean',
|
|
'israel-gaza-theater': 'Israel/Gaza',
|
|
'yemen-redsea-theater': 'Red Sea',
|
|
};
|
|
|
|
const CHOKEPOINT_COMMODITIES = {
|
|
'Middle East': { commodity: 'Oil', sensitivity: 0.8 },
|
|
'Red Sea': { commodity: 'Shipping/Oil', sensitivity: 0.7 },
|
|
'Israel/Gaza': { commodity: 'Gas/Oil', sensitivity: 0.5 },
|
|
'Eastern Mediterranean': { commodity: 'Gas', sensitivity: 0.4 },
|
|
'Western Pacific': { commodity: 'Semiconductors', sensitivity: 0.9 },
|
|
'South China Sea': { commodity: 'Trade goods', sensitivity: 0.6 },
|
|
'Black Sea': { commodity: 'Grain/Energy', sensitivity: 0.7 },
|
|
};
|
|
|
|
const REGION_KEYWORDS = {
|
|
'Middle East': ['mena'],
|
|
'Red Sea': ['mena'],
|
|
'Israel/Gaza': ['mena'],
|
|
'Eastern Mediterranean': ['mena', 'eu'],
|
|
'Western Pacific': ['asia'],
|
|
'South China Sea': ['asia'],
|
|
'Black Sea': ['eu'],
|
|
'Korean Peninsula': ['asia'],
|
|
'Northern Europe': ['eu'],
|
|
};
|
|
|
|
function getRedisCredentials() {
|
|
const url = process.env.UPSTASH_REDIS_REST_URL;
|
|
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
if (!url || !token) throw new Error('Missing UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN');
|
|
return { url, token };
|
|
}
|
|
|
|
async function redisGet(url, token, key) {
|
|
const resp = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!resp.ok) return null;
|
|
const data = await resp.json();
|
|
if (!data?.result) return null;
|
|
try { return JSON.parse(data.result); } catch { return null; }
|
|
}
|
|
|
|
// ── Phase 4: Input normalizers ──────────────────────────────
|
|
function normalizeChokepoints(raw) {
|
|
if (!raw?.chokepoints && !raw?.corridors) return raw;
|
|
const items = raw.chokepoints || raw.corridors || [];
|
|
return {
|
|
...raw,
|
|
chokepoints: items.map(cp => ({
|
|
...cp,
|
|
region: cp.name || cp.region || '',
|
|
riskScore: cp.disruptionScore ?? cp.riskScore ?? 0,
|
|
riskLevel: cp.status === 'red' ? 'critical' : cp.status === 'yellow' ? 'high' : cp.riskLevel || 'normal',
|
|
disrupted: cp.status === 'red' || cp.disrupted || false,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function normalizeGpsJamming(raw) {
|
|
if (!raw) return raw;
|
|
if (raw.hexes && !raw.zones) return { ...raw, zones: raw.hexes };
|
|
return raw;
|
|
}
|
|
|
|
async function warmPingChokepoints() {
|
|
const baseUrl = process.env.WM_API_BASE_URL;
|
|
if (!baseUrl) { console.log(' [Chokepoints] Warm-ping skipped (no WM_API_BASE_URL)'); return; }
|
|
try {
|
|
const resp = await fetch(`${baseUrl}/api/supply-chain/v1/get-chokepoint-status`, {
|
|
headers: { 'User-Agent': CHROME_UA, Origin: 'https://worldmonitor.app' },
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!resp.ok) console.warn(` [Chokepoints] Warm-ping failed: HTTP ${resp.status}`);
|
|
else console.log(' [Chokepoints] Warm-ping OK');
|
|
} catch (err) { console.warn(` [Chokepoints] Warm-ping error: ${err.message}`); }
|
|
}
|
|
|
|
async function readInputKeys() {
|
|
const { url, token } = getRedisCredentials();
|
|
const keys = [
|
|
'risk:scores:sebuf:stale:v1',
|
|
'temporal:anomalies:v1',
|
|
'theater-posture:sebuf:stale:v1',
|
|
'prediction:markets-bootstrap:v1',
|
|
'supply_chain:chokepoints:v4',
|
|
'conflict:iran-events:v1',
|
|
'conflict:ucdp-events:v1',
|
|
'unrest:events:v1',
|
|
'infra:outages:v1',
|
|
'cyber:threats-bootstrap:v2',
|
|
'intelligence:gpsjam:v2',
|
|
'news:insights:v1',
|
|
'news:digest:v1:full:en',
|
|
];
|
|
const pipeline = keys.map(k => ['GET', k]);
|
|
const resp = await fetch(`${url}/pipeline`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(pipeline),
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!resp.ok) throw new Error(`Redis pipeline failed: ${resp.status}`);
|
|
const results = await resp.json();
|
|
|
|
const parse = (i) => {
|
|
try { return results[i]?.result ? JSON.parse(results[i].result) : null; } catch { return null; }
|
|
};
|
|
|
|
return {
|
|
ciiScores: parse(0),
|
|
temporalAnomalies: parse(1),
|
|
theaterPosture: parse(2),
|
|
predictionMarkets: parse(3),
|
|
chokepoints: normalizeChokepoints(parse(4)),
|
|
iranEvents: parse(5),
|
|
ucdpEvents: parse(6),
|
|
unrestEvents: parse(7),
|
|
outages: parse(8),
|
|
cyberThreats: parse(9),
|
|
gpsJamming: normalizeGpsJamming(parse(10)),
|
|
newsInsights: parse(11),
|
|
newsDigest: parse(12),
|
|
};
|
|
}
|
|
|
|
function forecastId(domain, region, title) {
|
|
const hash = crypto.createHash('sha256')
|
|
.update(`${domain}:${region}:${title}`)
|
|
.digest('hex').slice(0, 8);
|
|
return `fc-${domain}-${hash}`;
|
|
}
|
|
|
|
function normalize(value, min, max) {
|
|
if (max <= min) return 0;
|
|
return Math.max(0, Math.min(1, (value - min) / (max - min)));
|
|
}
|
|
|
|
function resolveCountryName(raw) {
|
|
if (!raw || raw.length > 3) return raw; // already a full name or long-form
|
|
const codes = loadCountryCodes();
|
|
return codes[raw]?.name || raw;
|
|
}
|
|
|
|
function makePrediction(domain, region, title, probability, confidence, timeHorizon, signals) {
|
|
const now = Date.now();
|
|
return {
|
|
id: forecastId(domain, region, title),
|
|
domain,
|
|
region,
|
|
title,
|
|
scenario: '',
|
|
probability: Math.round(Math.max(0, Math.min(1, probability)) * 1000) / 1000,
|
|
confidence: Math.round(Math.max(0, Math.min(1, confidence)) * 1000) / 1000,
|
|
timeHorizon,
|
|
signals,
|
|
cascades: [],
|
|
trend: 'stable',
|
|
priorProbability: 0,
|
|
calibration: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
// Normalize CII data from sebuf proto format (server-side) to uniform shape.
|
|
// Server writes: { ciiScores: [{ region, combinedScore, trend: 'TREND_DIRECTION_RISING', components: {...} }] }
|
|
// Frontend computes: [{ code, name, score, level, trend: 'rising', components: { unrest, conflict, ... } }]
|
|
function normalizeCiiEntry(c) {
|
|
const score = c.combinedScore ?? c.score ?? c.dynamicScore ?? 0;
|
|
const code = c.region || c.code || '';
|
|
const rawTrend = (c.trend || '').toLowerCase();
|
|
const trend = rawTrend.includes('rising') ? 'rising'
|
|
: rawTrend.includes('falling') ? 'falling'
|
|
: 'stable';
|
|
const level = score >= 81 ? 'critical' : score >= 66 ? 'high' : score >= 51 ? 'elevated' : score >= 31 ? 'normal' : 'low';
|
|
const unrest = c.components?.unrest ?? c.components?.protest ?? c.components?.ciiContribution ?? c.components?.geoConvergence ?? 0;
|
|
// Resolve ISO code to full country name (prevents substring false positives: IL matching Chile)
|
|
let name = c.name || '';
|
|
if (!name && code) {
|
|
const codes = loadCountryCodes();
|
|
name = codes[code]?.name || code;
|
|
}
|
|
return { code, name, score, level, trend, change24h: c.change24h ?? 0, components: { ...c.components, unrest } };
|
|
}
|
|
|
|
function extractCiiScores(inputs) {
|
|
const raw = inputs.ciiScores;
|
|
if (!raw) return [];
|
|
// sebuf proto: { ciiScores: [...] }, frontend: array or { scores: [...] }
|
|
const arr = Array.isArray(raw) ? raw : raw.ciiScores || raw.scores || [];
|
|
return arr.map(normalizeCiiEntry);
|
|
}
|
|
|
|
function detectConflictScenarios(inputs) {
|
|
const predictions = [];
|
|
const scores = extractCiiScores(inputs);
|
|
const theaters = inputs.theaterPosture?.theaters || [];
|
|
const iran = Array.isArray(inputs.iranEvents) ? inputs.iranEvents : inputs.iranEvents?.events || [];
|
|
const ucdp = Array.isArray(inputs.ucdpEvents) ? inputs.ucdpEvents : inputs.ucdpEvents?.events || [];
|
|
|
|
for (const c of scores) {
|
|
if (!c.score || c.score <= 60) continue;
|
|
if (c.level !== 'high' && c.level !== 'critical') continue;
|
|
|
|
const signals = [
|
|
{ type: 'cii', value: `${c.name} CII ${c.score} (${c.level})`, weight: 0.4 },
|
|
];
|
|
let sourceCount = 1;
|
|
|
|
if (c.change24h && Math.abs(c.change24h) > 2) {
|
|
signals.push({ type: 'cii_delta', value: `24h change ${c.change24h > 0 ? '+' : ''}${c.change24h.toFixed(1)}`, weight: 0.2 });
|
|
sourceCount++;
|
|
}
|
|
|
|
// Use word-boundary regex to prevent substring false positives (IL matching Chile)
|
|
const countryName = c.name.toLowerCase();
|
|
const countryCode = c.code.toLowerCase();
|
|
const matchRegex = new RegExp(`\\b(${countryName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}|${countryCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\b`, 'i');
|
|
const matchingIran = iran.filter(e => matchRegex.test(e.country || e.location || ''));
|
|
if (matchingIran.length > 0) {
|
|
signals.push({ type: 'conflict_events', value: `${matchingIran.length} Iran-related events`, weight: 0.2 });
|
|
sourceCount++;
|
|
}
|
|
|
|
const matchingUcdp = ucdp.filter(e => matchRegex.test(e.country || e.location || ''));
|
|
if (matchingUcdp.length > 0) {
|
|
signals.push({ type: 'ucdp', value: `${matchingUcdp.length} UCDP events`, weight: 0.2 });
|
|
sourceCount++;
|
|
}
|
|
|
|
const ciiNorm = normalize(c.score, 50, 100);
|
|
const eventBoost = (matchingIran.length + matchingUcdp.length) > 0 ? 0.1 : 0;
|
|
const prob = Math.min(0.9, ciiNorm * 0.6 + eventBoost + (c.trend === 'rising' ? 0.1 : 0));
|
|
const confidence = Math.max(0.3, normalize(sourceCount, 0, 4));
|
|
|
|
predictions.push(makePrediction(
|
|
'conflict', c.name,
|
|
`Escalation risk: ${c.name}`,
|
|
prob, confidence, '7d', signals,
|
|
));
|
|
}
|
|
|
|
for (const t of theaters) {
|
|
if (!t?.id) continue;
|
|
const posture = t.postureLevel || t.posture || '';
|
|
if (posture !== 'critical' && posture !== 'elevated') continue;
|
|
const region = THEATER_REGIONS[t.id] || t.name || t.id;
|
|
const alreadyCovered = predictions.some(p => p.region === region);
|
|
if (alreadyCovered) continue;
|
|
|
|
const signals = [
|
|
{ type: 'theater', value: `${t.name || t.id} posture: ${posture}`, weight: 0.5 },
|
|
];
|
|
const prob = posture === 'critical' ? 0.65 : 0.4;
|
|
|
|
predictions.push(makePrediction(
|
|
'conflict', region,
|
|
`Theater escalation: ${region}`,
|
|
prob, 0.5, '7d', signals,
|
|
));
|
|
}
|
|
|
|
return predictions;
|
|
}
|
|
|
|
function detectMarketScenarios(inputs) {
|
|
const predictions = [];
|
|
const chokepoints = inputs.chokepoints?.routes || inputs.chokepoints?.chokepoints || [];
|
|
const scores = extractCiiScores(inputs);
|
|
|
|
const affectedRegions = new Set();
|
|
|
|
for (const cp of chokepoints) {
|
|
const risk = cp.riskLevel || cp.risk || '';
|
|
if (risk !== 'high' && risk !== 'critical' && (cp.riskScore || 0) < 60) continue;
|
|
const region = cp.region || cp.name || '';
|
|
if (!region) continue;
|
|
|
|
const commodity = CHOKEPOINT_COMMODITIES[region];
|
|
if (!commodity) continue;
|
|
|
|
if (affectedRegions.has(region)) continue;
|
|
affectedRegions.add(region);
|
|
|
|
const riskNorm = normalize(cp.riskScore || (risk === 'critical' ? 85 : 70), 40, 100);
|
|
const prob = Math.min(0.85, riskNorm * commodity.sensitivity);
|
|
|
|
predictions.push(makePrediction(
|
|
'market', region,
|
|
`${commodity.commodity} price impact from ${region} disruption`,
|
|
prob, 0.6, '30d',
|
|
[{ type: 'chokepoint', value: `${region} risk: ${risk}`, weight: 0.5 },
|
|
{ type: 'commodity', value: `${commodity.commodity} sensitivity: ${commodity.sensitivity}`, weight: 0.3 }],
|
|
));
|
|
}
|
|
|
|
// Map high-CII countries to their commodity-sensitive theater via entity graph
|
|
const graph = loadEntityGraph();
|
|
for (const c of scores) {
|
|
if (!c.score || c.score <= 75) continue;
|
|
// Find theater region: check entity graph links for theater nodes with commodity sensitivity
|
|
const nodeId = graph.aliases?.[c.code] || graph.aliases?.[c.name];
|
|
const node = nodeId ? graph.nodes?.[nodeId] : null;
|
|
let region = null;
|
|
if (node) {
|
|
for (const linkId of node.links || []) {
|
|
const linked = graph.nodes?.[linkId];
|
|
if (linked?.type === 'theater' && CHOKEPOINT_COMMODITIES[linked.name]) {
|
|
region = linked.name;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Fallback: direct theater region lookup
|
|
if (!region) {
|
|
const matchedTheater = Object.entries(THEATER_REGIONS).find(([id]) => {
|
|
const theaterId = graph.aliases?.[c.name] || graph.aliases?.[c.code];
|
|
return theaterId && graph.nodes?.[theaterId]?.links?.includes(id);
|
|
});
|
|
region = matchedTheater ? THEATER_REGIONS[matchedTheater[0]] : null;
|
|
}
|
|
if (!region || affectedRegions.has(region)) continue;
|
|
|
|
const commodity = CHOKEPOINT_COMMODITIES[region];
|
|
if (!commodity) continue;
|
|
affectedRegions.add(region);
|
|
|
|
const prob = Math.min(0.7, normalize(c.score, 60, 100) * commodity.sensitivity * 0.8);
|
|
predictions.push(makePrediction(
|
|
'market', region,
|
|
`${commodity.commodity} volatility from ${countryName} instability`,
|
|
prob, 0.4, '30d',
|
|
[{ type: 'cii', value: `${countryName} CII ${c.score}`, weight: 0.4 },
|
|
{ type: 'commodity', value: `${commodity.commodity} sensitivity: ${commodity.sensitivity}`, weight: 0.3 }],
|
|
));
|
|
}
|
|
|
|
return predictions;
|
|
}
|
|
|
|
function detectSupplyChainScenarios(inputs) {
|
|
const predictions = [];
|
|
const chokepoints = inputs.chokepoints?.routes || inputs.chokepoints?.chokepoints || [];
|
|
const anomalies = Array.isArray(inputs.temporalAnomalies) ? inputs.temporalAnomalies : inputs.temporalAnomalies?.anomalies || [];
|
|
const jamming = Array.isArray(inputs.gpsJamming) ? inputs.gpsJamming : inputs.gpsJamming?.zones || [];
|
|
|
|
const seenRoutes = new Set();
|
|
|
|
for (const cp of chokepoints) {
|
|
const disrupted = cp.disrupted || cp.status === 'disrupted' || (cp.riskScore || 0) > 65;
|
|
if (!disrupted) continue;
|
|
|
|
const route = cp.route || cp.name || cp.region || '';
|
|
if (!route || seenRoutes.has(route)) continue;
|
|
seenRoutes.add(route);
|
|
|
|
const signals = [
|
|
{ type: 'chokepoint', value: `${route} disruption detected`, weight: 0.5 },
|
|
];
|
|
let sourceCount = 1;
|
|
|
|
const aisGaps = anomalies.filter(a =>
|
|
(a.type === 'ais_gaps' || a.type === 'ais_gap') &&
|
|
(a.region || a.zone || '').toLowerCase().includes(route.toLowerCase()),
|
|
);
|
|
if (aisGaps.length > 0) {
|
|
signals.push({ type: 'ais_gap', value: `${aisGaps.length} AIS gap anomalies near ${route}`, weight: 0.3 });
|
|
sourceCount++;
|
|
}
|
|
|
|
const nearbyJam = jamming.filter(j =>
|
|
(j.region || j.zone || j.name || '').toLowerCase().includes(route.toLowerCase()),
|
|
);
|
|
if (nearbyJam.length > 0) {
|
|
signals.push({ type: 'gps_jamming', value: `GPS interference near ${route}`, weight: 0.2 });
|
|
sourceCount++;
|
|
}
|
|
|
|
const riskNorm = normalize(cp.riskScore || 70, 40, 100);
|
|
const prob = Math.min(0.85, riskNorm * 0.7 + (aisGaps.length > 0 ? 0.1 : 0) + (nearbyJam.length > 0 ? 0.05 : 0));
|
|
const confidence = Math.max(0.3, normalize(sourceCount, 0, 4));
|
|
|
|
predictions.push(makePrediction(
|
|
'supply_chain', cp.region || route,
|
|
`Supply chain disruption: ${route}`,
|
|
prob, confidence, '7d', signals,
|
|
));
|
|
}
|
|
|
|
return predictions;
|
|
}
|
|
|
|
function detectPoliticalScenarios(inputs) {
|
|
const predictions = [];
|
|
const scores = extractCiiScores(inputs);
|
|
const anomalies = Array.isArray(inputs.temporalAnomalies) ? inputs.temporalAnomalies : inputs.temporalAnomalies?.anomalies || [];
|
|
|
|
for (const c of scores) {
|
|
if (!c.components) continue;
|
|
const unrestComp = c.components.unrest ?? 0;
|
|
if (unrestComp <= 50) continue;
|
|
if (c.score >= 80) continue;
|
|
|
|
const countryName = c.name.toLowerCase();
|
|
const signals = [
|
|
{ type: 'unrest', value: `${c.name} unrest component: ${unrestComp}`, weight: 0.4 },
|
|
];
|
|
let sourceCount = 1;
|
|
|
|
const protestAnomalies = anomalies.filter(a =>
|
|
(a.type === 'protest' || a.type === 'unrest') &&
|
|
(a.country || a.region || '').toLowerCase().includes(countryName),
|
|
);
|
|
if (protestAnomalies.length > 0) {
|
|
const maxZ = Math.max(...protestAnomalies.map(a => a.zScore || a.z_score || 0));
|
|
signals.push({ type: 'anomaly', value: `Protest anomaly z-score: ${maxZ.toFixed(1)}`, weight: 0.3 });
|
|
sourceCount++;
|
|
}
|
|
|
|
const unrestNorm = normalize(unrestComp, 30, 100);
|
|
const anomalyBoost = protestAnomalies.length > 0 ? 0.1 : 0;
|
|
const prob = Math.min(0.8, unrestNorm * 0.6 + anomalyBoost);
|
|
const confidence = Math.max(0.3, normalize(sourceCount, 0, 4));
|
|
|
|
predictions.push(makePrediction(
|
|
'political', c.name,
|
|
`Political instability: ${c.name}`,
|
|
prob, confidence, '30d', signals,
|
|
));
|
|
}
|
|
|
|
return predictions;
|
|
}
|
|
|
|
function detectMilitaryScenarios(inputs) {
|
|
const predictions = [];
|
|
const theaters = inputs.theaterPosture?.theaters || [];
|
|
const anomalies = Array.isArray(inputs.temporalAnomalies) ? inputs.temporalAnomalies : inputs.temporalAnomalies?.anomalies || [];
|
|
|
|
for (const t of theaters) {
|
|
if (!t?.id) continue;
|
|
const posture = t.postureLevel || t.posture || '';
|
|
if (posture !== 'elevated' && posture !== 'critical') continue;
|
|
|
|
const region = THEATER_REGIONS[t.id] || t.name || t.id;
|
|
const signals = [
|
|
{ type: 'theater', value: `${t.name || t.id} posture: ${posture}`, weight: 0.5 },
|
|
];
|
|
let sourceCount = 1;
|
|
|
|
const milFlights = anomalies.filter(a =>
|
|
(a.type === 'military_flights' || a.type === 'military') &&
|
|
(a.region || a.theater || '').toLowerCase().includes(region.toLowerCase()),
|
|
);
|
|
if (milFlights.length > 0) {
|
|
const maxZ = Math.max(...milFlights.map(a => a.zScore || a.z_score || 0));
|
|
signals.push({ type: 'mil_flights', value: `Military flight anomaly z-score: ${maxZ.toFixed(1)}`, weight: 0.3 });
|
|
sourceCount++;
|
|
}
|
|
|
|
if (t.indicators && Array.isArray(t.indicators)) {
|
|
const activeIndicators = t.indicators.filter(i => i.active || i.triggered);
|
|
if (activeIndicators.length > 0) {
|
|
signals.push({ type: 'indicators', value: `${activeIndicators.length} active posture indicators`, weight: 0.2 });
|
|
sourceCount++;
|
|
}
|
|
}
|
|
|
|
const baseLine = posture === 'critical' ? 0.6 : 0.35;
|
|
const flightBoost = milFlights.length > 0 ? 0.1 : 0;
|
|
const prob = Math.min(0.85, baseLine + flightBoost);
|
|
const confidence = Math.max(0.3, normalize(sourceCount, 0, 4));
|
|
|
|
predictions.push(makePrediction(
|
|
'military', region,
|
|
`Military posture escalation: ${region}`,
|
|
prob, confidence, '7d', signals,
|
|
));
|
|
}
|
|
|
|
return predictions;
|
|
}
|
|
|
|
function detectInfraScenarios(inputs) {
|
|
const predictions = [];
|
|
const outages = Array.isArray(inputs.outages) ? inputs.outages : inputs.outages?.outages || [];
|
|
const cyber = Array.isArray(inputs.cyberThreats) ? inputs.cyberThreats : inputs.cyberThreats?.threats || [];
|
|
const jamming = Array.isArray(inputs.gpsJamming) ? inputs.gpsJamming : inputs.gpsJamming?.zones || [];
|
|
|
|
for (const o of outages) {
|
|
const rawSev = (o.severity || o.type || '').toLowerCase();
|
|
// Handle both plain strings and proto enums (SEVERITY_LEVEL_HIGH, SEVERITY_LEVEL_CRITICAL)
|
|
const severity = rawSev.includes('critical') ? 'critical'
|
|
: rawSev.includes('high') ? 'major'
|
|
: rawSev.includes('total') ? 'total'
|
|
: rawSev.includes('major') ? 'major'
|
|
: rawSev;
|
|
if (severity !== 'major' && severity !== 'total' && severity !== 'critical') continue;
|
|
|
|
const country = resolveCountryName(o.country || o.region || o.name || '');
|
|
if (!country) continue;
|
|
|
|
const countryLower = country.toLowerCase();
|
|
const signals = [
|
|
{ type: 'outage', value: `${country} ${severity} outage`, weight: 0.4 },
|
|
];
|
|
let sourceCount = 1;
|
|
|
|
const relatedCyber = cyber.filter(t =>
|
|
(t.country || t.target || t.region || '').toLowerCase().includes(countryLower),
|
|
);
|
|
if (relatedCyber.length > 0) {
|
|
signals.push({ type: 'cyber', value: `${relatedCyber.length} cyber threats targeting ${country}`, weight: 0.3 });
|
|
sourceCount++;
|
|
}
|
|
|
|
const nearbyJam = jamming.filter(j =>
|
|
(j.country || j.region || j.name || '').toLowerCase().includes(countryLower),
|
|
);
|
|
if (nearbyJam.length > 0) {
|
|
signals.push({ type: 'gps_jamming', value: `GPS interference in ${country}`, weight: 0.2 });
|
|
sourceCount++;
|
|
}
|
|
|
|
const cyberBoost = relatedCyber.length > 0 ? 0.15 : 0;
|
|
const jamBoost = nearbyJam.length > 0 ? 0.05 : 0;
|
|
const baseLine = severity === 'total' ? 0.55 : 0.4;
|
|
const prob = Math.min(0.85, baseLine + cyberBoost + jamBoost);
|
|
const confidence = Math.max(0.3, normalize(sourceCount, 0, 4));
|
|
|
|
predictions.push(makePrediction(
|
|
'infrastructure', country,
|
|
`Infrastructure cascade risk: ${country}`,
|
|
prob, confidence, '24h', signals,
|
|
));
|
|
}
|
|
|
|
return predictions;
|
|
}
|
|
|
|
// ── Phase 4: Standalone detectors ───────────────────────────
|
|
function detectUcdpConflictZones(inputs) {
|
|
const predictions = [];
|
|
const ucdp = Array.isArray(inputs.ucdpEvents) ? inputs.ucdpEvents : inputs.ucdpEvents?.events || [];
|
|
if (ucdp.length === 0) return predictions;
|
|
|
|
const byCountry = {};
|
|
for (const e of ucdp) {
|
|
const country = e.country || e.country_name || '';
|
|
if (!country) continue;
|
|
byCountry[country] = (byCountry[country] || 0) + 1;
|
|
}
|
|
|
|
for (const [country, count] of Object.entries(byCountry)) {
|
|
if (count < 10) continue;
|
|
predictions.push(makePrediction(
|
|
'conflict', country,
|
|
`Active armed conflict: ${country}`,
|
|
Math.min(0.85, normalize(count, 5, 100) * 0.7),
|
|
0.3, '30d',
|
|
[{ type: 'ucdp', value: `${count} UCDP conflict events`, weight: 0.5 }],
|
|
));
|
|
}
|
|
return predictions;
|
|
}
|
|
|
|
function detectCyberScenarios(inputs) {
|
|
const predictions = [];
|
|
const threats = Array.isArray(inputs.cyberThreats) ? inputs.cyberThreats : inputs.cyberThreats?.threats || [];
|
|
if (threats.length < 5) return predictions;
|
|
|
|
const byCountry = {};
|
|
for (const t of threats) {
|
|
const country = resolveCountryName(t.country || t.target || t.region || '');
|
|
if (!country) continue;
|
|
if (!byCountry[country]) byCountry[country] = [];
|
|
byCountry[country].push(t);
|
|
}
|
|
|
|
for (const [country, items] of Object.entries(byCountry)) {
|
|
if (items.length < 5) continue;
|
|
const types = new Set(items.map(t => t.type || t.category || 'unknown'));
|
|
predictions.push(makePrediction(
|
|
'infrastructure', country,
|
|
`Cyber threat concentration: ${country}`,
|
|
Math.min(0.7, normalize(items.length, 3, 50) * 0.6),
|
|
0.3, '7d',
|
|
[{ type: 'cyber', value: `${items.length} threats (${[...types].join(', ')})`, weight: 0.5 }],
|
|
));
|
|
}
|
|
return predictions;
|
|
}
|
|
|
|
const MARITIME_REGIONS = {
|
|
'Eastern Mediterranean': { latRange: [33, 37], lonRange: [25, 37] },
|
|
'Red Sea': { latRange: [11, 22], lonRange: [32, 54] },
|
|
'Persian Gulf': { latRange: [20, 32], lonRange: [45, 60] },
|
|
'Black Sea': { latRange: [40, 48], lonRange: [26, 42] },
|
|
'Baltic Sea': { latRange: [52, 65], lonRange: [10, 32] },
|
|
};
|
|
|
|
function detectGpsJammingScenarios(inputs) {
|
|
const predictions = [];
|
|
const zones = Array.isArray(inputs.gpsJamming) ? inputs.gpsJamming
|
|
: inputs.gpsJamming?.zones || inputs.gpsJamming?.hexes || [];
|
|
if (zones.length === 0) return predictions;
|
|
|
|
for (const [region, bounds] of Object.entries(MARITIME_REGIONS)) {
|
|
const inRegion = zones.filter(h => {
|
|
const lat = h.lat || h.latitude || 0;
|
|
const lon = h.lon || h.longitude || 0;
|
|
return lat >= bounds.latRange[0] && lat <= bounds.latRange[1]
|
|
&& lon >= bounds.lonRange[0] && lon <= bounds.lonRange[1];
|
|
});
|
|
if (inRegion.length < 3) continue;
|
|
predictions.push(makePrediction(
|
|
'supply_chain', region,
|
|
`GPS interference in ${region} shipping zone`,
|
|
Math.min(0.6, normalize(inRegion.length, 2, 30) * 0.5),
|
|
0.3, '7d',
|
|
[{ type: 'gps_jamming', value: `${inRegion.length} jamming hexes in ${region}`, weight: 0.5 }],
|
|
));
|
|
}
|
|
return predictions;
|
|
}
|
|
|
|
const MARKET_TAG_TO_REGION = {
|
|
mena: 'Middle East', eu: 'Europe', asia: 'Asia-Pacific',
|
|
america: 'Americas', latam: 'Latin America', africa: 'Africa', oceania: 'Oceania',
|
|
};
|
|
|
|
function detectFromPredictionMarkets(inputs) {
|
|
const predictions = [];
|
|
const markets = inputs.predictionMarkets?.geopolitical || [];
|
|
|
|
for (const m of markets) {
|
|
const yesPrice = (m.yesPrice || 50) / 100;
|
|
if (yesPrice < 0.6 || yesPrice > 0.9) continue;
|
|
const tags = tagRegions(m.title);
|
|
if (tags.length === 0) continue;
|
|
const region = MARKET_TAG_TO_REGION[tags[0]] || tags[0];
|
|
|
|
const titleLower = m.title.toLowerCase();
|
|
const domain = titleLower.match(/war|strike|military|attack/) ? 'conflict'
|
|
: titleLower.match(/tariff|recession|economy|gdp/) ? 'market'
|
|
: 'political';
|
|
|
|
predictions.push(makePrediction(
|
|
domain, region,
|
|
m.title.slice(0, 100),
|
|
yesPrice, 0.7, '30d',
|
|
[{ type: 'prediction_market', value: `${m.source || 'Polymarket'}: ${Math.round(yesPrice * 100)}%`, weight: 0.8 }],
|
|
));
|
|
}
|
|
return predictions.slice(0, 5);
|
|
}
|
|
|
|
// ── Phase 4: Entity graph ───────────────────────────────────
|
|
let _entityGraph = null;
|
|
function loadEntityGraph() {
|
|
if (_entityGraph) return _entityGraph;
|
|
try {
|
|
const graphPath = new URL('./data/entity-graph.json', import.meta.url);
|
|
_entityGraph = JSON.parse(readFileSync(graphPath, 'utf8'));
|
|
console.log(` [Graph] Loaded ${Object.keys(_entityGraph.nodes).length} nodes`);
|
|
return _entityGraph;
|
|
} catch (err) {
|
|
console.warn(` [Graph] Failed: ${err.message}`);
|
|
return { nodes: {}, edges: [], aliases: {} };
|
|
}
|
|
}
|
|
|
|
function discoverGraphCascades(predictions, graph) {
|
|
if (!graph?.nodes || !graph?.aliases) return;
|
|
for (const pred of predictions) {
|
|
const nodeId = graph.aliases[pred.region];
|
|
if (!nodeId) continue;
|
|
const node = graph.nodes[nodeId];
|
|
if (!node?.links) continue;
|
|
|
|
for (const linkedId of node.links) {
|
|
const linked = graph.nodes[linkedId];
|
|
if (!linked) continue;
|
|
const linkedPred = predictions.find(p =>
|
|
p !== pred && p.domain !== pred.domain && graph.aliases[p.region] === linkedId
|
|
);
|
|
if (!linkedPred) continue;
|
|
|
|
const edge = graph.edges.find(e =>
|
|
(e.from === nodeId && e.to === linkedId) || (e.from === linkedId && e.to === nodeId)
|
|
);
|
|
const coupling = (edge?.weight || 0.3) * 0.5;
|
|
pred.cascades.push({
|
|
domain: linkedPred.domain,
|
|
effect: `graph: ${edge?.relation || 'linked'} via ${linked.name}`,
|
|
probability: Math.round(Math.min(0.6, pred.probability * coupling) * 1000) / 1000,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Phase 3: Data-driven cascade rules ─────────────────────
|
|
const DEFAULT_CASCADE_RULES = [
|
|
{ from: 'conflict', to: 'supply_chain', coupling: 0.6, mechanism: 'chokepoint disruption', requiresChokepoint: true },
|
|
{ from: 'conflict', to: 'market', coupling: 0.5, mechanism: 'commodity price shock', requiresChokepoint: true },
|
|
{ from: 'political', to: 'conflict', coupling: 0.4, mechanism: 'instability escalation', minProbability: 0.6 },
|
|
{ from: 'military', to: 'conflict', coupling: 0.5, mechanism: 'force deployment', requiresCriticalPosture: true },
|
|
{ from: 'supply_chain', to: 'market', coupling: 0.4, mechanism: 'supply shortage pricing' },
|
|
];
|
|
|
|
const PREDICATE_EVALUATORS = {
|
|
requiresChokepoint: (pred) => !!CHOKEPOINT_COMMODITIES[pred.region],
|
|
requiresCriticalPosture: (pred) => pred.signals.some(s => s.type === 'theater' && s.value.includes('critical')),
|
|
minProbability: (pred, val) => pred.probability >= val,
|
|
requiresSeverity: (pred, val) => pred.signals.some(s => s.type === 'outage' && s.value.toLowerCase().includes(val)),
|
|
};
|
|
|
|
function evaluateRuleConditions(rule, pred) {
|
|
for (const [key, val] of Object.entries(rule)) {
|
|
if (['from', 'to', 'coupling', 'mechanism'].includes(key)) continue;
|
|
const evaluator = PREDICATE_EVALUATORS[key];
|
|
if (!evaluator) continue;
|
|
if (!evaluator(pred, val)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function loadCascadeRules() {
|
|
try {
|
|
const rulesPath = new URL('./data/cascade-rules.json', import.meta.url);
|
|
const raw = JSON.parse(readFileSync(rulesPath, 'utf8'));
|
|
if (!Array.isArray(raw)) throw new Error('cascade rules must be array');
|
|
const KNOWN_FIELDS = new Set(['from', 'to', 'coupling', 'mechanism', ...Object.keys(PREDICATE_EVALUATORS)]);
|
|
for (const r of raw) {
|
|
if (!r.from || !r.to || typeof r.coupling !== 'number' || !r.mechanism) {
|
|
throw new Error(`invalid rule: ${JSON.stringify(r)}`);
|
|
}
|
|
for (const key of Object.keys(r)) {
|
|
if (!KNOWN_FIELDS.has(key)) throw new Error(`unknown predicate '${key}' in rule: ${r.mechanism}`);
|
|
}
|
|
}
|
|
console.log(` [Cascade] Loaded ${raw.length} rules from JSON`);
|
|
return raw;
|
|
} catch (err) {
|
|
console.warn(` [Cascade] Failed to load rules: ${err.message}, using defaults`);
|
|
return DEFAULT_CASCADE_RULES;
|
|
}
|
|
}
|
|
|
|
function resolveCascades(predictions, rules) {
|
|
const seen = new Set();
|
|
for (const rule of rules) {
|
|
const sources = predictions.filter(p => p.domain === rule.from);
|
|
for (const src of sources) {
|
|
if (!evaluateRuleConditions(rule, src)) continue;
|
|
const cascadeProb = Math.min(0.8, src.probability * rule.coupling);
|
|
const key = `${src.id}:${rule.to}:${rule.mechanism}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
src.cascades.push({ domain: rule.to, effect: rule.mechanism, probability: +cascadeProb.toFixed(3) });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Phase 3: Probability projections ───────────────────────
|
|
const PROJECTION_CURVES = {
|
|
conflict: { h24: 0.91, d7: 1.0, d30: 0.78 },
|
|
market: { h24: 1.0, d7: 0.58, d30: 0.42 },
|
|
supply_chain: { h24: 0.91, d7: 1.0, d30: 0.64 },
|
|
political: { h24: 0.83, d7: 0.87, d30: 1.0 },
|
|
military: { h24: 1.0, d7: 0.91, d30: 0.65 },
|
|
infrastructure: { h24: 1.0, d7: 0.5, d30: 0.25 },
|
|
};
|
|
|
|
function computeProjections(predictions) {
|
|
for (const pred of predictions) {
|
|
const curve = PROJECTION_CURVES[pred.domain] || { h24: 1, d7: 1, d30: 1 };
|
|
const anchor = pred.timeHorizon === '24h' ? 'h24' : pred.timeHorizon === '30d' ? 'd30' : 'd7';
|
|
const anchorMult = curve[anchor] || 1;
|
|
const base = anchorMult > 0 ? pred.probability / anchorMult : pred.probability;
|
|
pred.projections = {
|
|
h24: Math.round(Math.min(0.95, Math.max(0.01, base * curve.h24)) * 1000) / 1000,
|
|
d7: Math.round(Math.min(0.95, Math.max(0.01, base * curve.d7)) * 1000) / 1000,
|
|
d30: Math.round(Math.min(0.95, Math.max(0.01, base * curve.d30)) * 1000) / 1000,
|
|
};
|
|
}
|
|
}
|
|
|
|
function calibrateWithMarkets(predictions, markets) {
|
|
if (!markets?.geopolitical) return;
|
|
for (const pred of predictions) {
|
|
const keywords = REGION_KEYWORDS[pred.region] || [];
|
|
if (keywords.length === 0) continue;
|
|
const match = markets.geopolitical.find(m => {
|
|
const mRegions = tagRegions(m.title);
|
|
return mRegions.some(r => keywords.includes(r));
|
|
});
|
|
if (match) {
|
|
const marketProb = (match.yesPrice || 50) / 100;
|
|
pred.calibration = {
|
|
marketTitle: match.title,
|
|
marketPrice: +marketProb.toFixed(3),
|
|
drift: +(pred.probability - marketProb).toFixed(3),
|
|
source: match.source || 'polymarket',
|
|
};
|
|
pred.probability = +(0.4 * marketProb + 0.6 * pred.probability).toFixed(3);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readPriorPredictions() {
|
|
try {
|
|
const { url, token } = getRedisCredentials();
|
|
return await redisGet(url, token, PRIOR_KEY);
|
|
} catch { return null; }
|
|
}
|
|
|
|
function computeTrends(predictions, prior) {
|
|
if (!prior?.predictions) {
|
|
for (const p of predictions) { p.trend = 'stable'; p.priorProbability = p.probability; }
|
|
return;
|
|
}
|
|
const priorMap = new Map(prior.predictions.map(p => [p.id, p]));
|
|
for (const p of predictions) {
|
|
const prev = priorMap.get(p.id);
|
|
if (!prev) { p.trend = 'stable'; p.priorProbability = p.probability; continue; }
|
|
p.priorProbability = prev.probability;
|
|
const delta = p.probability - prev.probability;
|
|
p.trend = delta > 0.05 ? 'rising' : delta < -0.05 ? 'falling' : 'stable';
|
|
}
|
|
}
|
|
|
|
// ── Phase 2: News Context + Entity Matching ────────────────
|
|
let _countryCodes = null;
|
|
function loadCountryCodes() {
|
|
if (_countryCodes) return _countryCodes;
|
|
try {
|
|
const codePath = new URL('./data/country-codes.json', import.meta.url);
|
|
_countryCodes = JSON.parse(readFileSync(codePath, 'utf8'));
|
|
return _countryCodes;
|
|
} catch { return {}; }
|
|
}
|
|
|
|
const NEWS_MATCHABLE_TYPES = new Set(['country', 'theater']);
|
|
|
|
function getSearchTermsForRegion(region) {
|
|
const terms = [region];
|
|
const codes = loadCountryCodes();
|
|
const graph = loadEntityGraph();
|
|
|
|
// 1. Country codes JSON: resolve ISO codes to names + keywords
|
|
const countryEntry = codes[region];
|
|
if (countryEntry) {
|
|
terms.push(countryEntry.name);
|
|
terms.push(...countryEntry.keywords);
|
|
}
|
|
|
|
// 2. Reverse lookup: if region is a full name (or has parenthetical suffix like "Myanmar (Burma)")
|
|
if (!countryEntry) {
|
|
const regionLower = region.toLowerCase();
|
|
const regionBase = region.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase(); // strip "(Zaire)", "(Burma)", etc.
|
|
for (const [, entry] of Object.entries(codes)) {
|
|
const nameLower = entry.name.toLowerCase();
|
|
if (nameLower === regionLower || nameLower === regionBase || regionLower.includes(nameLower)) {
|
|
terms.push(entry.name);
|
|
terms.push(...entry.keywords);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Entity graph: add linked country/theater names (not commodities)
|
|
const nodeId = graph.aliases?.[region];
|
|
const node = nodeId ? graph.nodes?.[nodeId] : null;
|
|
if (node) {
|
|
if (node.name !== region) terms.push(node.name);
|
|
for (const linkId of node.links || []) {
|
|
const linked = graph.nodes?.[linkId];
|
|
if (linked && NEWS_MATCHABLE_TYPES.has(linked.type) && linked.name.length > 2) {
|
|
terms.push(linked.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dedupe and filter short terms
|
|
return [...new Set(terms)].filter(t => t && t.length > 2);
|
|
}
|
|
|
|
function extractAllHeadlines(newsInsights, newsDigest) {
|
|
const headlines = [];
|
|
const seen = new Set();
|
|
// 1. Digest has 300+ headlines across 16 categories
|
|
if (newsDigest?.categories) {
|
|
for (const bucket of Object.values(newsDigest.categories)) {
|
|
for (const item of bucket?.items || []) {
|
|
if (item?.title && !seen.has(item.title)) { seen.add(item.title); headlines.push(item.title); }
|
|
}
|
|
}
|
|
}
|
|
// 2. Fallback to topStories if digest is empty
|
|
if (headlines.length === 0 && newsInsights?.topStories) {
|
|
for (const s of newsInsights.topStories) {
|
|
if (s?.primaryTitle && !seen.has(s.primaryTitle)) { seen.add(s.primaryTitle); headlines.push(s.primaryTitle); }
|
|
}
|
|
}
|
|
return headlines;
|
|
}
|
|
|
|
function attachNewsContext(predictions, newsInsights, newsDigest) {
|
|
const allHeadlines = extractAllHeadlines(newsInsights, newsDigest);
|
|
if (allHeadlines.length === 0) return;
|
|
|
|
for (const pred of predictions) {
|
|
const searchTerms = getSearchTermsForRegion(pred.region);
|
|
|
|
const matched = allHeadlines.filter(h => {
|
|
const lower = h.toLowerCase();
|
|
return searchTerms.some(t => lower.includes(t.toLowerCase()));
|
|
});
|
|
|
|
pred.newsContext = matched.length > 0 ? matched.slice(0, 3) : allHeadlines.slice(0, 3);
|
|
|
|
if (matched.length > 0) {
|
|
pred.signals.push({
|
|
type: 'news_corroboration',
|
|
value: `${matched.length} headline(s) mention ${pred.region} or linked entities`,
|
|
weight: 0.15,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Phase 2: Deterministic Confidence Model ────────────────
|
|
const SIGNAL_TO_SOURCE = {
|
|
cii: 'cii', cii_delta: 'cii', unrest: 'cii',
|
|
conflict_events: 'iran_events',
|
|
ucdp: 'ucdp',
|
|
theater: 'theater_posture', indicators: 'theater_posture',
|
|
mil_flights: 'temporal_anomalies', anomaly: 'temporal_anomalies',
|
|
chokepoint: 'chokepoints',
|
|
ais_gap: 'temporal_anomalies',
|
|
gps_jamming: 'gps_jamming',
|
|
outage: 'outages',
|
|
cyber: 'cyber_threats',
|
|
prediction_market: 'prediction_markets',
|
|
news_corroboration: 'news_insights',
|
|
};
|
|
|
|
function computeConfidence(predictions) {
|
|
for (const pred of predictions) {
|
|
const sources = new Set(pred.signals.map(s => SIGNAL_TO_SOURCE[s.type] || s.type));
|
|
const sourceDiversity = normalize(sources.size, 1, 4);
|
|
const calibrationAgreement = pred.calibration
|
|
? Math.max(0, 1 - Math.abs(pred.calibration.drift) * 3)
|
|
: 0.5;
|
|
const conf = 0.5 * sourceDiversity + 0.5 * calibrationAgreement;
|
|
pred.confidence = Math.round(Math.max(0.2, Math.min(1, conf)) * 1000) / 1000;
|
|
}
|
|
}
|
|
|
|
// ── Phase 2: LLM Scenario Enrichment ───────────────────────
|
|
const FORECAST_LLM_PROVIDERS = [
|
|
{ name: 'groq', envKey: 'GROQ_API_KEY', apiUrl: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.1-8b-instant', timeout: 20_000 },
|
|
{ name: 'openrouter', envKey: 'OPENROUTER_API_KEY', apiUrl: 'https://openrouter.ai/api/v1/chat/completions', model: 'google/gemini-2.5-flash', timeout: 25_000 },
|
|
];
|
|
|
|
const SCENARIO_SYSTEM_PROMPT = `You are a senior geopolitical intelligence analyst writing scenario briefs.
|
|
|
|
RULES:
|
|
- Each scenario MUST be exactly 2-3 sentences, 40-80 words.
|
|
- Each scenario MUST name at least one specific signal value from the data (e.g., "CII score of 87", "3 UCDP events", "theater posture elevated").
|
|
- Each scenario MUST state a causal mechanism (what leads to what).
|
|
- Do NOT use hedging words ("could", "might", "potentially") without citing a data point.
|
|
- Do NOT use your own knowledge. Base everything on the provided signals and headlines.
|
|
|
|
GOOD EXAMPLE:
|
|
{"index": 0, "scenario": "Iran's CII score of 87 (critical, rising) combined with 3 active UCDP conflict events indicates sustained military pressure. The elevated Middle East theater posture with 47 tracked flights suggests force projection capability is being maintained."}
|
|
|
|
BAD EXAMPLE (too generic, no signal values):
|
|
{"index": 0, "scenario": "Tensions in the Middle East continue to escalate as various factors contribute to regional instability."}
|
|
|
|
Respond with ONLY a JSON array: [{"index": 0, "scenario": "..."}, ...]`;
|
|
|
|
// Phase 3: Combined scenario + perspectives prompt for top-2 predictions
|
|
const COMBINED_SYSTEM_PROMPT = `You are a senior geopolitical intelligence analyst. For each prediction:
|
|
|
|
1. Write a SCENARIO (2-3 sentences, evidence-grounded, citing signal values)
|
|
2. Write 3 PERSPECTIVES (1-2 sentences each):
|
|
- STRATEGIC: Neutral analysis of what signals indicate
|
|
- REGIONAL: What this means for actors in the affected region
|
|
- CONTRARIAN: What factors could prevent or reverse this outcome
|
|
|
|
RULES:
|
|
- Every sentence MUST cite a specific signal value from the data
|
|
- Base everything on provided data, not your knowledge
|
|
- Do NOT use hedging without a data point
|
|
|
|
Output JSON array:
|
|
[{"index": 0, "scenario": "...", "strategic": "...", "regional": "...", "contrarian": "..."}, ...]`;
|
|
|
|
function validatePerspectives(items, predictions) {
|
|
if (!Array.isArray(items)) return [];
|
|
return items.filter(item => {
|
|
if (typeof item.index !== 'number' || item.index < 0 || item.index >= predictions.length) return false;
|
|
for (const key of ['strategic', 'regional', 'contrarian']) {
|
|
if (typeof item[key] !== 'string') return false;
|
|
item[key] = item[key].replace(/<[^>]*>/g, '').trim().slice(0, 300);
|
|
if (item[key].length < 20) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function sanitizeForPrompt(text) {
|
|
return (text || '').replace(/[\n\r]/g, ' ').replace(/[<>{}\x00-\x1f]/g, '').slice(0, 200).trim();
|
|
}
|
|
|
|
function parseLLMScenarios(text) {
|
|
const cleaned = text
|
|
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
|
.replace(/<\|thinking\|>[\s\S]*?<\|\/thinking\|>/gi, '')
|
|
.trim();
|
|
// Try complete JSON array first
|
|
const match = cleaned.match(/\[[\s\S]*\]/);
|
|
if (match) {
|
|
try { return JSON.parse(match[0]); } catch { /* fall through to repair */ }
|
|
}
|
|
// Try truncated: find opening bracket and attempt repair
|
|
const bracketIdx = cleaned.indexOf('[');
|
|
if (bracketIdx === -1) return null;
|
|
const partial = cleaned.slice(bracketIdx);
|
|
for (const suffix of ['"}]', '}]', '"]', ']']) {
|
|
try { return JSON.parse(partial + suffix); } catch { /* next */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function validateScenarios(scenarios, predictions) {
|
|
if (!Array.isArray(scenarios)) return [];
|
|
return scenarios.filter(s => {
|
|
if (!s || typeof s.scenario !== 'string' || s.scenario.length < 30) return false;
|
|
if (typeof s.index !== 'number' || s.index < 0 || s.index >= predictions.length) return false;
|
|
const pred = predictions[s.index];
|
|
const scenarioLower = s.scenario.toLowerCase();
|
|
const hasSignalRef = pred.signals.some(sig =>
|
|
scenarioLower.includes(sig.type.toLowerCase()) ||
|
|
sig.value.split(/\s+/).some(word => word.length > 3 && scenarioLower.includes(word.toLowerCase()))
|
|
);
|
|
if (!hasSignalRef) {
|
|
console.warn(` [LLM] Scenario ${s.index} rejected: no signal reference`);
|
|
return false;
|
|
}
|
|
s.scenario = s.scenario.replace(/<[^>]*>/g, '').slice(0, 500);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function callForecastLLM(systemPrompt, userPrompt) {
|
|
for (const provider of FORECAST_LLM_PROVIDERS) {
|
|
const apiKey = process.env[provider.envKey];
|
|
if (!apiKey) continue;
|
|
try {
|
|
const resp = await fetch(provider.apiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': CHROME_UA,
|
|
...(provider.name === 'openrouter' ? { 'HTTP-Referer': 'https://worldmonitor.app', 'X-Title': 'World Monitor' } : {}),
|
|
},
|
|
body: JSON.stringify({
|
|
model: provider.model,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
max_tokens: 1500,
|
|
temperature: 0.3,
|
|
}),
|
|
signal: AbortSignal.timeout(provider.timeout),
|
|
});
|
|
if (!resp.ok) { console.warn(` [LLM] ${provider.name}: HTTP ${resp.status}`); continue; }
|
|
const json = await resp.json();
|
|
const text = json.choices?.[0]?.message?.content?.trim();
|
|
if (!text || text.length < 20) continue;
|
|
return { text, model: json.model || provider.model, provider: provider.name };
|
|
} catch (err) { console.warn(` [LLM] ${provider.name}: ${err.message}`); continue; }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function redisSet(url, token, key, data, ttlSeconds) {
|
|
try {
|
|
await fetch(url, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(['SET', key, JSON.stringify(data), 'EX', ttlSeconds]),
|
|
signal: AbortSignal.timeout(5_000),
|
|
});
|
|
} catch (err) { console.warn(` [Redis] Cache write failed for ${key}: ${err.message}`); }
|
|
}
|
|
|
|
function buildCacheHash(preds) {
|
|
return crypto.createHash('sha256')
|
|
.update(JSON.stringify(preds.map(p => ({
|
|
id: p.id, d: p.domain, r: p.region, p: p.probability,
|
|
s: p.signals.map(s => s.value).join(','),
|
|
c: p.calibration?.drift,
|
|
n: (p.newsContext || []).join(','),
|
|
}))))
|
|
.digest('hex').slice(0, 16);
|
|
}
|
|
|
|
function buildUserPrompt(preds) {
|
|
const seen = new Set();
|
|
const mergedHeadlines = [];
|
|
for (const p of preds) {
|
|
for (const h of p.newsContext || []) {
|
|
if (!seen.has(h)) { seen.add(h); mergedHeadlines.push(h); }
|
|
}
|
|
}
|
|
const headlines = mergedHeadlines.slice(0, 5).map(h => `- ${sanitizeForPrompt(h)}`).join('\n');
|
|
const predsText = preds.map((p, i) => {
|
|
const sigs = p.signals.map(s => `[SIGNAL] ${sanitizeForPrompt(s.value)}`).join('\n');
|
|
const cal = p.calibration ? `\n[CALIBRATION] ${sanitizeForPrompt(p.calibration.marketTitle)} at ${Math.round(p.calibration.marketPrice * 100)}%` : '';
|
|
return `[${i}] "${sanitizeForPrompt(p.title)}" (${p.domain}, ${p.region})\nProbability: ${Math.round(p.probability * 100)}% | Horizon: ${p.timeHorizon}\n${sigs}${cal}`;
|
|
}).join('\n\n');
|
|
return headlines ? `Current top headlines:\n${headlines}\n\nPredictions to analyze:\n\n${predsText}` : `Predictions to analyze:\n\n${predsText}`;
|
|
}
|
|
|
|
async function enrichScenariosWithLLM(predictions) {
|
|
if (predictions.length === 0) return;
|
|
const { url, token } = getRedisCredentials();
|
|
|
|
// Phase 3: Top-2 get combined scenario + perspectives
|
|
const topWithPerspectives = predictions.slice(0, 2);
|
|
const scenarioOnly = predictions.slice(2, 4);
|
|
|
|
// Call 1: Combined scenario + perspectives for top-2
|
|
if (topWithPerspectives.length > 0) {
|
|
const hash = buildCacheHash(topWithPerspectives);
|
|
const cacheKey = `forecast:llm-combined:${hash}`;
|
|
const cached = await redisGet(url, token, cacheKey);
|
|
|
|
if (cached?.items) {
|
|
for (const item of cached.items) {
|
|
if (item.index >= 0 && item.index < topWithPerspectives.length) {
|
|
if (item.scenario) topWithPerspectives[item.index].scenario = item.scenario;
|
|
if (item.strategic) topWithPerspectives[item.index].perspectives = { strategic: item.strategic, regional: item.regional, contrarian: item.contrarian };
|
|
}
|
|
}
|
|
console.log(JSON.stringify({ event: 'llm_combined', cached: true, count: cached.items.length, hash }));
|
|
} else {
|
|
const t0 = Date.now();
|
|
const result = await callForecastLLM(COMBINED_SYSTEM_PROMPT, buildUserPrompt(topWithPerspectives));
|
|
if (result) {
|
|
const raw = parseLLMScenarios(result.text);
|
|
const validScenarios = validateScenarios(raw, topWithPerspectives);
|
|
const validPerspectives = validatePerspectives(raw, topWithPerspectives);
|
|
|
|
for (const s of validScenarios) {
|
|
topWithPerspectives[s.index].scenario = s.scenario;
|
|
}
|
|
for (const p of validPerspectives) {
|
|
topWithPerspectives[p.index].perspectives = { strategic: p.strategic, regional: p.regional, contrarian: p.contrarian };
|
|
}
|
|
|
|
// Cache only validated items (not raw) to prevent persisting invalid LLM output
|
|
const items = [];
|
|
for (const s of validScenarios) {
|
|
const entry = { index: s.index, scenario: s.scenario };
|
|
const p = validPerspectives.find(vp => vp.index === s.index);
|
|
if (p) { entry.strategic = p.strategic; entry.regional = p.regional; entry.contrarian = p.contrarian; }
|
|
items.push(entry);
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
event: 'llm_combined', provider: result.provider, model: result.model,
|
|
hash, count: topWithPerspectives.length,
|
|
scenarios: validScenarios.length, perspectives: validPerspectives.length,
|
|
latencyMs: Math.round(Date.now() - t0), cached: false,
|
|
}));
|
|
|
|
if (items.length > 0) await redisSet(url, token, cacheKey, { items }, 3600);
|
|
} else {
|
|
console.warn(' [LLM] Combined call failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call 2: Scenario-only for predictions 3-4
|
|
if (scenarioOnly.length > 0) {
|
|
const hash = buildCacheHash(scenarioOnly);
|
|
const cacheKey = `forecast:llm-scenarios:${hash}`;
|
|
const cached = await redisGet(url, token, cacheKey);
|
|
|
|
if (cached?.scenarios) {
|
|
for (const s of cached.scenarios) {
|
|
if (s.index >= 0 && s.index < scenarioOnly.length && s.scenario) {
|
|
scenarioOnly[s.index].scenario = s.scenario;
|
|
}
|
|
}
|
|
console.log(JSON.stringify({ event: 'llm_scenario', cached: true, count: cached.scenarios.length, hash }));
|
|
} else {
|
|
const t0 = Date.now();
|
|
const result = await callForecastLLM(SCENARIO_SYSTEM_PROMPT, buildUserPrompt(scenarioOnly));
|
|
if (result) {
|
|
const raw = parseLLMScenarios(result.text);
|
|
const valid = validateScenarios(raw, scenarioOnly);
|
|
for (const s of valid) { scenarioOnly[s.index].scenario = s.scenario; }
|
|
|
|
console.log(JSON.stringify({
|
|
event: 'llm_scenario', provider: result.provider, model: result.model,
|
|
hash, count: scenarioOnly.length, scenarios: valid.length,
|
|
latencyMs: Math.round(Date.now() - t0), cached: false,
|
|
}));
|
|
|
|
if (valid.length > 0) await redisSet(url, token, cacheKey, { scenarios: valid }, 3600);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Main pipeline ──────────────────────────────────────────
|
|
async function fetchForecasts() {
|
|
await warmPingChokepoints();
|
|
|
|
console.log(' Reading input data from Redis...');
|
|
const inputs = await readInputKeys();
|
|
const prior = await readPriorPredictions();
|
|
|
|
console.log(' Running domain detectors...');
|
|
const predictions = [
|
|
...detectConflictScenarios(inputs),
|
|
...detectMarketScenarios(inputs),
|
|
...detectSupplyChainScenarios(inputs),
|
|
...detectPoliticalScenarios(inputs),
|
|
...detectMilitaryScenarios(inputs),
|
|
...detectInfraScenarios(inputs),
|
|
...detectUcdpConflictZones(inputs),
|
|
...detectCyberScenarios(inputs),
|
|
...detectGpsJammingScenarios(inputs),
|
|
...detectFromPredictionMarkets(inputs),
|
|
];
|
|
|
|
console.log(` Generated ${predictions.length} predictions`);
|
|
|
|
attachNewsContext(predictions, inputs.newsInsights, inputs.newsDigest);
|
|
calibrateWithMarkets(predictions, inputs.predictionMarkets);
|
|
computeConfidence(predictions);
|
|
computeProjections(predictions);
|
|
const cascadeRules = loadCascadeRules();
|
|
resolveCascades(predictions, cascadeRules);
|
|
discoverGraphCascades(predictions, loadEntityGraph());
|
|
computeTrends(predictions, prior);
|
|
|
|
predictions.sort((a, b) => (b.probability * b.confidence) - (a.probability * a.confidence));
|
|
|
|
await enrichScenariosWithLLM(predictions);
|
|
|
|
return { predictions, generatedAt: Date.now() };
|
|
}
|
|
|
|
if (_isDirectRun) {
|
|
await runSeed('forecast', 'predictions', CANONICAL_KEY, fetchForecasts, {
|
|
ttlSeconds: TTL_SECONDS,
|
|
lockTtlMs: 180_000,
|
|
validateFn: (data) => Array.isArray(data?.predictions) && data.predictions.length > 0,
|
|
extraKeys: [
|
|
{
|
|
key: PRIOR_KEY,
|
|
transform: (data) => ({
|
|
predictions: data.predictions.map(p => ({ id: p.id, probability: p.probability })),
|
|
}),
|
|
ttl: 7200,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
export {
|
|
forecastId,
|
|
normalize,
|
|
makePrediction,
|
|
normalizeCiiEntry,
|
|
extractCiiScores,
|
|
resolveCascades,
|
|
calibrateWithMarkets,
|
|
computeTrends,
|
|
detectConflictScenarios,
|
|
detectMarketScenarios,
|
|
detectSupplyChainScenarios,
|
|
detectPoliticalScenarios,
|
|
detectMilitaryScenarios,
|
|
detectInfraScenarios,
|
|
attachNewsContext,
|
|
computeConfidence,
|
|
sanitizeForPrompt,
|
|
parseLLMScenarios,
|
|
validateScenarios,
|
|
validatePerspectives,
|
|
computeProjections,
|
|
loadCascadeRules,
|
|
evaluateRuleConditions,
|
|
SIGNAL_TO_SOURCE,
|
|
PREDICATE_EVALUATORS,
|
|
DEFAULT_CASCADE_RULES,
|
|
PROJECTION_CURVES,
|
|
normalizeChokepoints,
|
|
normalizeGpsJamming,
|
|
detectUcdpConflictZones,
|
|
detectCyberScenarios,
|
|
detectGpsJammingScenarios,
|
|
detectFromPredictionMarkets,
|
|
loadEntityGraph,
|
|
discoverGraphCascades,
|
|
MARITIME_REGIONS,
|
|
MARKET_TAG_TO_REGION,
|
|
resolveCountryName,
|
|
loadCountryCodes,
|
|
getSearchTermsForRegion,
|
|
extractAllHeadlines,
|
|
};
|