import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; const DEFAULT_SCENARIOS = [ { id: 'middle-east-energy-shock', enabled: false, name: 'Middle East energy shock', description: 'Energy supply risk building from Middle East conflict or chokepoint pressure.', regions: ['Middle East', 'Iran', 'Israel', 'Strait of Hormuz'], categories: ['osint', 'energy', 'maritime'], keywords: ['missile', 'strike', 'hormuz', 'oil', 'energy', 'blockade'], thresholds: { watching: 2, building: 4, confirmed: 7 }, invalidation: 'WTI normalizes and regional urgent signals fade for several sweeps.', }, { id: 'macro-stress-spillover', enabled: false, name: 'Macro stress spillover', description: 'Market stress spreads from volatility into credit, rates, or commodities.', regions: ['US', 'Global'], categories: ['macro', 'markets'], keywords: ['vix', 'spread', 'credit', 'yield', 'inflation', 'gold'], thresholds: { watching: 2, building: 4, confirmed: 6 }, invalidation: 'VIX and credit stress both normalize while source health remains stable.', }, { id: 'regional-escalation-risk', enabled: false, name: 'Regional escalation risk', description: 'Local conflict signals broaden across adjacent regions or source categories.', regions: ['Ukraine', 'Taiwan', 'Africa', 'Middle East'], categories: ['conflict', 'thermal', 'osint', 'air'], keywords: ['mobilization', 'intercept', 'drone', 'ballistic', 'fatalities', 'border'], thresholds: { watching: 2, building: 5, confirmed: 8 }, invalidation: 'No fresh cross-source escalation signals appear inside the configured horizon.', }, ]; export function evaluateScenarios(data, delta, runsDir) { const loaded = loadScenarioDefinitions(runsDir); if (!loaded.ok) { return { available: false, error: loaded.error, items: [], changed: [] }; } const statePath = join(runsDir, 'scenario-state.json'); const previous = readJson(statePath, {}); const evaluatedAt = data.meta?.timestamp || new Date().toISOString(); const corpus = buildCorpus(data, delta); const items = loaded.scenarios.map(def => evaluateScenario(def, corpus, previous[def.id], evaluatedAt)); const changed = items.filter(item => item.changed); writeJson(statePath, Object.fromEntries(items.map(item => [item.id, { state: item.state, score: item.score, confidence: item.confidence, lastTriggerTime: item.lastTriggerTime, updatedAt: evaluatedAt, }]))); return { available: true, path: loaded.path, items, changed, }; } export function loadScenarioDefinitions(runsDir) { const path = join(runsDir, 'scenarios.json'); try { if (!existsSync(runsDir)) mkdirSync(runsDir, { recursive: true }); if (!existsSync(path)) { writeJson(path, { version: 1, scenarios: DEFAULT_SCENARIOS, }); } const raw = JSON.parse(readFileSync(path, 'utf8')); if (!raw || !Array.isArray(raw.scenarios)) throw new Error('scenarios must be an array'); const scenarios = raw.scenarios .map(normalizeScenario) .filter(Boolean); return { ok: true, path, scenarios }; } catch (err) { return { ok: false, path, error: err.message }; } } function normalizeScenario(input) { if (!input || typeof input !== 'object') return null; const id = String(input.id || input.name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); const name = String(input.name || input.id || '').trim(); if (!id || !name) return null; const thresholds = input.thresholds || {}; return { id, enabled: input.enabled === true, name, description: String(input.description || ''), regions: arrayOfStrings(input.regions), categories: arrayOfStrings(input.categories), keywords: arrayOfStrings(input.keywords).map(s => s.toLowerCase()), thresholds: { watching: Number(thresholds.watching || 2), building: Number(thresholds.building || 4), confirmed: Number(thresholds.confirmed || 7), }, invalidation: String(input.invalidation || ''), }; } function evaluateScenario(def, corpus, previous, evaluatedAt) { if (!def.enabled) { return { ...publicScenario(def), state: 'dormant', score: 0, confidence: 0, evidence: [], changed: previous?.state && previous.state !== 'dormant', lastTriggerTime: previous?.lastTriggerTime || null, }; } const evidence = []; let score = 0; for (const keyword of def.keywords) { const hit = corpus.entries.find(entry => entry.text.includes(keyword)); if (hit) { score += 1; evidence.push({ type: 'keyword', label: keyword, source: hit.source, text: hit.original.slice(0, 180) }); } } for (const region of def.regions) { const needle = region.toLowerCase(); const hit = corpus.entries.find(entry => entry.text.includes(needle)); if (hit) { score += 1; evidence.push({ type: 'region', label: region, source: hit.source, text: hit.original.slice(0, 180) }); } } for (const category of def.categories) { if (corpus.categories.has(category.toLowerCase())) { score += 1; evidence.push({ type: 'category', label: category, source: 'sweep', text: `${category} category active` }); } } const state = score >= def.thresholds.confirmed ? 'confirmed' : score >= def.thresholds.building ? 'building' : score >= def.thresholds.watching ? 'watching' : 'dormant'; const confidence = Math.min(100, Math.round((score / Math.max(1, def.thresholds.confirmed)) * 100)); const changed = previous?.state ? previous.state !== state : state !== 'dormant'; return { ...publicScenario(def), state, score, confidence, evidence: evidence.slice(0, 6), changed, lastTriggerTime: state === 'dormant' ? (previous?.lastTriggerTime || null) : evaluatedAt, }; } function publicScenario(def) { return { id: def.id, name: def.name, description: def.description, enabled: def.enabled, invalidation: def.invalidation, }; } function buildCorpus(data, delta) { const entries = []; const categories = new Set(); const push = (source, text, category) => { if (!text) return; entries.push({ source, original: String(text), text: String(text).toLowerCase() }); if (category) categories.add(category); }; for (const signal of data.tSignals || []) push('thermal', signal, 'thermal'); for (const post of data.tg?.urgent || []) push(post.channel || 'telegram', post.text, 'osint'); for (const item of data.newsFeed || []) push(item.source || 'news', item.headline || item.title, 'news'); for (const item of data.news || []) push(item.source || 'news', item.headline || item.title, 'news'); for (const item of data.acled?.deadliestEvents || []) push('ACLED', `${item.country || ''} ${item.location || ''} ${item.event_type || ''} ${item.fatalities || ''}`, 'conflict'); for (const item of data.air || []) push('OpenSky', `${item.region} ${item.total} aircraft`, 'air'); for (const item of data.chokepoints || []) push('Maritime', `${item.label} ${item.note}`, 'maritime'); if (data.energy?.wti || data.energy?.brent) push('energy', `WTI ${data.energy.wti} Brent ${data.energy.brent}`, 'energy'); if (data.markets?.vix || data.fred?.some(f => f.id === 'VIXCLS')) push('markets', 'VIX volatility market stress', 'markets'); if (delta?.summary) push('delta', `${delta.summary.direction} ${delta.summary.totalChanges} changes ${delta.summary.criticalChanges} critical`, 'delta'); for (const signal of delta?.signals?.new || []) push('delta', signal.label || signal.reason || signal.key, 'delta'); for (const signal of delta?.signals?.escalated || []) push('delta', signal.label || signal.reason || signal.key, 'delta'); return { entries, categories }; } function arrayOfStrings(value) { return Array.isArray(value) ? value.map(v => String(v).trim()).filter(Boolean) : []; } function readJson(path, fallback) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; } } function writeJson(path, value) { writeFileSync(path, JSON.stringify(value, null, 2)); }