diff --git a/api/health.js b/api/health.js index 121cd3423..824ca3aa7 100644 --- a/api/health.js +++ b/api/health.js @@ -105,6 +105,7 @@ const SEED_META = { iranEvents: { key: 'seed-meta:conflict:iran-events', maxStaleMin: 10080 }, ucdpEvents: { key: 'seed-meta:conflict:ucdp-events', maxStaleMin: 420 }, militaryFlights: { key: 'seed-meta:military:flights', maxStaleMin: 15 }, + militaryForecastInputs: { key: 'seed-meta:military-forecast-inputs', maxStaleMin: 15 }, satellites: { key: 'seed-meta:intelligence:satellites', maxStaleMin: 180 }, weatherAlerts: { key: 'seed-meta:weather:alerts', maxStaleMin: 30 }, spending: { key: 'seed-meta:economic:spending', maxStaleMin: 120 }, diff --git a/api/seed-health.js b/api/seed-health.js index bb60e6d20..c021a77fb 100644 --- a/api/seed-health.js +++ b/api/seed-health.js @@ -34,6 +34,7 @@ const SEED_DOMAINS = { 'intelligence:gpsjam': { key: 'seed-meta:intelligence:gpsjam', intervalMin: 360 }, 'intelligence:satellites': { key: 'seed-meta:intelligence:satellites', intervalMin: 90 }, 'military:flights': { key: 'seed-meta:military:flights', intervalMin: 8 }, + 'military-forecast-inputs': { key: 'seed-meta:military-forecast-inputs', intervalMin: 8 }, 'infra:service-statuses': { key: 'seed-meta:infra:service-statuses', intervalMin: 60 }, 'supply_chain:shipping': { key: 'seed-meta:supply_chain:shipping', intervalMin: 120 }, 'supply_chain:chokepoints': { key: 'seed-meta:supply_chain:chokepoints', intervalMin: 30 }, diff --git a/scripts/_military-surges.mjs b/scripts/_military-surges.mjs index 5e29a7d96..3300e014d 100644 --- a/scripts/_military-surges.mjs +++ b/scripts/_military-surges.mjs @@ -36,6 +36,12 @@ function getComparableTheaterSnapshots(history, theaterId, sourceVersion = '') { .slice(-BASELINE_WINDOW); } +function countPersistentSnapshots(snapshots, field, baseline, minCount, thresholdFactor = 1) { + const recent = snapshots.slice(-3); + const threshold = Math.max(minCount, baseline * thresholdFactor); + return recent.filter((snapshot) => (snapshot?.[field] || 0) >= threshold).length; +} + export function summarizeMilitaryTheaters(flights, theaters, assessedAt = Date.now()) { return theaters.map((theater) => { const theaterFlights = flights.filter( @@ -112,6 +118,8 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) { const effectiveBaseline = Math.max(1, baselineCount); if (currentCount < minCount) return; if (currentCount < effectiveBaseline * surgeThreshold) return; + const field = surgeType === 'fighter' ? 'fighters' : 'transport'; + const persistenceCount = countPersistentSnapshots(priorSnapshots, field, effectiveBaseline, minCount, surgeThreshold); surges.push({ id: `${surgeType}-${summary.theaterId}`, @@ -132,6 +140,8 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) { dominantOperator: dominantOperator?.[0] || '', dominantOperatorCount: dominantOperator?.[1] || 0, historyPoints: priorSnapshots.length, + persistenceCount, + persistent: persistenceCount >= 1, assessedAt: summary.assessedAt, }); }; @@ -145,6 +155,7 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) { summary.totalFlights >= Math.max(6, Math.ceil(effectiveTotalBaseline * totalSurgeThreshold)) && totalChangePct >= 40 ) { + const persistenceCount = countPersistentSnapshots(priorSnapshots, 'totalFlights', effectiveTotalBaseline, 6, totalSurgeThreshold); surges.push({ id: `air-activity-${summary.theaterId}`, theaterId: summary.theaterId, @@ -164,6 +175,8 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) { dominantOperator: dominantOperator?.[0] || '', dominantOperatorCount: dominantOperator?.[1] || 0, historyPoints: priorSnapshots.length, + persistenceCount, + persistent: persistenceCount >= 1, assessedAt: summary.assessedAt, }); } diff --git a/scripts/seed-forecasts.mjs b/scripts/seed-forecasts.mjs index b3a37de75..0260bea80 100644 --- a/scripts/seed-forecasts.mjs +++ b/scripts/seed-forecasts.mjs @@ -22,6 +22,7 @@ const TRACE_RUNS_MAX = 50; const TRACE_REDIS_TTL_SECONDS = 60 * 24 * 60 * 60; const PUBLISH_MIN_PROBABILITY = 0; const MAX_MILITARY_SURGE_AGE_MS = 3 * 60 * 60 * 1000; +const MAX_MILITARY_BUNDLE_DRIFT_MS = 5 * 60 * 1000; const THEATER_IDS = [ 'iran-theater', 'taiwan-theater', 'baltic-theater', @@ -53,6 +54,15 @@ const THEATER_LABELS = { 'yemen-redsea-theater': 'Yemen/Red Sea', }; +const THEATER_EXPECTED_ACTORS = { + 'taiwan-theater': { countries: ['China'], operators: ['plaaf', 'plan'] }, + 'south-china-sea': { countries: ['China', 'USA', 'Japan', 'Philippines'], operators: ['plaaf', 'plan', 'usaf', 'usn'] }, + 'korea-theater': { countries: ['USA', 'South Korea', 'China', 'Japan'], operators: ['usaf', 'usn', 'plaaf'] }, + 'baltic-theater': { countries: ['NATO', 'USA', 'UK', 'Germany'], operators: ['nato', 'usaf', 'raf', 'gaf'] }, + 'blacksea-theater': { countries: ['Russia', 'NATO', 'Turkey'], operators: ['vks', 'nato'] }, + 'iran-theater': { countries: ['Iran', 'USA', 'Israel', 'UK'], operators: ['usaf', 'raf', 'iaf'] }, +}; + const CHOKEPOINT_COMMODITIES = { 'Middle East': { commodity: 'Oil', sensitivity: 0.8 }, 'Red Sea': { commodity: 'Shipping/Oil', sensitivity: 0.7 }, @@ -169,8 +179,8 @@ async function readInputKeys() { const keys = [ 'risk:scores:sebuf:stale:v1', 'temporal:anomalies:v1', - 'theater-posture:sebuf:stale:v1', - 'military:surges:stale:v1', + 'theater_posture:sebuf:stale:v1', + 'military:forecast-inputs:stale:v1', 'prediction:markets-bootstrap:v1', 'supply_chain:chokepoints:v4', 'conflict:iran-events:v1', @@ -200,7 +210,7 @@ async function readInputKeys() { ciiScores: parse(0), temporalAnomalies: parse(1), theaterPosture: parse(2), - militarySurges: parse(3), + militaryForecastInputs: parse(3), predictionMarkets: parse(4), chokepoints: normalizeChokepoints(parse(5)), iranEvents: parse(6), @@ -226,6 +236,81 @@ function normalize(value, min, max) { return Math.max(0, Math.min(1, (value - min) / (max - min))); } +function getFreshMilitaryForecastInputs(inputs, now = Date.now()) { + const bundle = inputs?.militaryForecastInputs; + if (!bundle || typeof bundle !== 'object') return null; + + const fetchedAt = Number(bundle.fetchedAt || 0); + if (!fetchedAt || now - fetchedAt > MAX_MILITARY_SURGE_AGE_MS) return null; + + const theaters = Array.isArray(bundle.theaters) ? bundle.theaters : []; + const surges = Array.isArray(bundle.surges) ? bundle.surges : []; + + const isAligned = (value) => { + const ts = Number(value || 0); + if (!ts) return true; + return Math.abs(ts - fetchedAt) <= MAX_MILITARY_BUNDLE_DRIFT_MS; + }; + + if (!theaters.every((theater) => isAligned(theater?.assessedAt))) return null; + if (!surges.every((surge) => isAligned(surge?.assessedAt))) return null; + + return bundle; +} + +function selectPrimaryMilitarySurge(theaterId, surges) { + const typePriority = { fighter: 3, airlift: 2, air_activity: 1 }; + return surges + .slice() + .sort((a, b) => { + const aScore = (typePriority[a.surgeType] || 0) * 10 + + (a.persistent ? 5 : 0) + + (a.persistenceCount || 0) * 2 + + (a.strikeCapable ? 2 : 0) + + (a.awacs > 0 || a.tankers > 0 ? 1 : 0) + + (a.surgeMultiple || 0); + const bScore = (typePriority[b.surgeType] || 0) * 10 + + (b.persistent ? 5 : 0) + + (b.persistenceCount || 0) * 2 + + (b.strikeCapable ? 2 : 0) + + (b.awacs > 0 || b.tankers > 0 ? 1 : 0) + + (b.surgeMultiple || 0); + return bScore - aScore; + })[0] || null; +} + +function computeTheaterActorScore(theaterId, surge) { + if (!surge) return 0; + const expected = THEATER_EXPECTED_ACTORS[theaterId]; + if (!expected) return 0; + + const dominantCountry = surge.dominantCountry || ''; + const dominantOperator = surge.dominantOperator || ''; + const countryMatch = dominantCountry && expected.countries.includes(dominantCountry); + const operatorMatch = dominantOperator && expected.operators.includes(dominantOperator); + + if (countryMatch || operatorMatch) return 0.12; + if (dominantCountry || dominantOperator) return -0.12; + return 0; +} + +function canPromoteMilitarySurge(posture, surge) { + if (!surge) return false; + if (surge.surgeType !== 'air_activity') return true; + if (posture === 'critical' || posture === 'elevated') return true; + if (surge.persistent || surge.surgeMultiple >= 3.5) return true; + if (surge.strikeCapable || surge.fighters >= 4 || surge.awacs > 0 || surge.tankers > 0) return true; + return false; +} + +function buildMilitaryForecastTitle(theaterId, theaterLabel, surge) { + if (!surge) return `Military posture escalation: ${theaterLabel}`; + const countryPrefix = surge.dominantCountry ? `${surge.dominantCountry}-linked ` : ''; + if (surge.surgeType === 'fighter') return `${countryPrefix}fighter surge near ${theaterLabel}`; + if (surge.surgeType === 'airlift') return `${countryPrefix}airlift surge near ${theaterLabel}`; + return `Elevated military air activity near ${theaterLabel}`; +} + function resolveCountryName(raw) { if (!raw || raw.length > 3) return raw; // already a full name or long-form const codes = loadCountryCodes(); @@ -348,15 +433,16 @@ function detectConflictScenarios(inputs) { } for (const t of theaters) { - if (!t?.id) continue; + const theaterId = t?.id || t?.theater; + if (!theaterId) continue; const posture = t.postureLevel || t.posture || ''; if (posture !== 'critical' && posture !== 'elevated') continue; - const region = THEATER_REGIONS[t.id] || t.name || t.id; + const region = THEATER_REGIONS[theaterId] || t.name || theaterId; 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 }, + { type: 'theater', value: `${t.name || theaterId} posture: ${posture}`, weight: 0.5 }, ]; const prob = posture === 'critical' ? 0.65 : 0.4; @@ -557,13 +643,10 @@ function detectPoliticalScenarios(inputs) { function detectMilitaryScenarios(inputs) { const predictions = []; - const theaters = inputs.theaterPosture?.theaters || []; + const militaryInputs = getFreshMilitaryForecastInputs(inputs); + const theaters = militaryInputs?.theaters || []; const anomalies = Array.isArray(inputs.temporalAnomalies) ? inputs.temporalAnomalies : inputs.temporalAnomalies?.anomalies || []; - const surgeFetchedAt = Number(inputs.militarySurges?.fetchedAt || 0); - const surgeIsFresh = surgeFetchedAt > 0 && (Date.now() - surgeFetchedAt) <= MAX_MILITARY_SURGE_AGE_MS; - const surgeItems = surgeIsFresh - ? (Array.isArray(inputs.militarySurges) ? inputs.militarySurges : inputs.militarySurges?.surges || []) - : []; + const surgeItems = Array.isArray(militaryInputs) ? militaryInputs : militaryInputs?.surges || []; const theatersById = new Map(theaters.map((theater) => [(theater?.id || theater?.theater), theater]).filter(([theaterId]) => !!theaterId)); const surgesByTheater = new Map(); @@ -584,15 +667,16 @@ function detectMilitaryScenarios(inputs) { const theaterSurges = surgesByTheater.get(theaterId) || []; if (!theaterId) continue; const posture = t?.postureLevel || t?.posture || ''; - const highestSurge = theaterSurges - .slice() - .sort((a, b) => (b.surgeMultiple || 0) - (a.surgeMultiple || 0))[0]; - if (posture !== 'elevated' && posture !== 'critical' && !highestSurge) continue; + const highestSurge = selectPrimaryMilitarySurge(theaterId, theaterSurges); + const surgeIsUsable = canPromoteMilitarySurge(posture, highestSurge); + if (posture !== 'elevated' && posture !== 'critical' && !surgeIsUsable) continue; const region = THEATER_REGIONS[theaterId] || t?.name || theaterId; const theaterLabel = THEATER_LABELS[theaterId] || t?.name || theaterId; const signals = []; let sourceCount = 0; + const actorScore = computeTheaterActorScore(theaterId, highestSurge); + const persistent = !!highestSurge?.persistent || (highestSurge?.surgeMultiple || 0) >= 3.5; if (posture === 'elevated' || posture === 'critical') { signals.push({ type: 'theater', value: `${theaterLabel} posture: ${posture}`, weight: 0.45 }); @@ -632,6 +716,22 @@ function detectMilitaryScenarios(inputs) { }); sourceCount++; } + if (highestSurge.persistenceCount > 0) { + signals.push({ + type: 'persistence', + value: `${highestSurge.persistenceCount} prior run(s) in ${theaterLabel} were already above baseline`, + weight: 0.18, + }); + sourceCount++; + } + if (actorScore > 0) { + signals.push({ + type: 'theater_actor_fit', + value: `${highestSurge.dominantCountry || highestSurge.dominantOperator} aligns with expected actors in ${theaterLabel}`, + weight: 0.16, + }); + sourceCount++; + } } if (t?.indicators && Array.isArray(t.indicators)) { @@ -643,16 +743,22 @@ function detectMilitaryScenarios(inputs) { } const baseLine = highestSurge - ? Math.min(0.7, 0.35 + Math.max(0, ((highestSurge.surgeMultiple || 1) - 1) * 0.12)) + ? highestSurge.surgeType === 'fighter' + ? Math.min(0.72, 0.42 + Math.max(0, ((highestSurge.surgeMultiple || 1) - 1) * 0.1)) + : highestSurge.surgeType === 'airlift' + ? Math.min(0.58, 0.32 + Math.max(0, ((highestSurge.surgeMultiple || 1) - 1) * 0.08)) + : Math.min(0.42, 0.2 + Math.max(0, ((highestSurge.surgeMultiple || 1) - 1) * 0.05)) : posture === 'critical' ? 0.6 : 0.35; const flightBoost = milFlights.length > 0 ? 0.1 : 0; const postureBoost = posture === 'critical' ? 0.12 : posture === 'elevated' ? 0.06 : 0; const supportBoost = highestSurge && (highestSurge.awacs > 0 || highestSurge.tankers > 0) ? 0.05 : 0; const strikeBoost = (t?.activeOperations?.includes?.('strike_capable') || highestSurge?.strikeCapable) ? 0.06 : 0; - const prob = Math.min(0.9, baseLine + flightBoost + postureBoost + supportBoost + strikeBoost); + const persistenceBoost = persistent ? 0.08 : 0; + const genericPenalty = highestSurge?.surgeType === 'air_activity' && !persistent ? 0.12 : 0; + const prob = Math.min(0.9, Math.max(0.05, baseLine + flightBoost + postureBoost + supportBoost + strikeBoost + persistenceBoost + actorScore - genericPenalty)); const confidence = Math.max(0.3, normalize(sourceCount, 0, 4)); const title = highestSurge - ? `Military air surge: ${theaterLabel}` + ? buildMilitaryForecastTitle(theaterId, theaterLabel, highestSurge) : `Military posture escalation: ${region}`; predictions.push(makePrediction( @@ -2773,6 +2879,7 @@ export { detectCyberScenarios, detectGpsJammingScenarios, detectFromPredictionMarkets, + getFreshMilitaryForecastInputs, loadEntityGraph, discoverGraphCascades, MARITIME_REGIONS, diff --git a/scripts/seed-military-flights.mjs b/scripts/seed-military-flights.mjs index 84c40cd6e..7cb193f2f 100644 --- a/scripts/seed-military-flights.mjs +++ b/scripts/seed-military-flights.mjs @@ -2,9 +2,11 @@ import { loadEnvFile, CHROME_UA, getRedisCredentials, acquireLock, releaseLock, withRetry, writeFreshnessMetadata, logSeedResult, verifySeedKey, extendExistingTtl } from './_seed-utils.mjs'; import { summarizeMilitaryTheaters, buildMilitarySurges, appendMilitaryHistory } from './_military-surges.mjs'; +import { spawn } from 'node:child_process'; import http from 'node:http'; import https from 'node:https'; import tls from 'node:tls'; +import { fileURLToPath } from 'node:url'; loadEnvFile(import.meta.url); @@ -19,6 +21,10 @@ const THEATER_POSTURE_BACKUP_KEY = 'theater-posture:sebuf:backup:v1'; const THEATER_POSTURE_LIVE_TTL = 900; const THEATER_POSTURE_STALE_TTL = 86400; const THEATER_POSTURE_BACKUP_TTL = 604800; +const MILITARY_FORECAST_INPUTS_LIVE_KEY = 'military:forecast-inputs:v1'; +const MILITARY_FORECAST_INPUTS_STALE_KEY = 'military:forecast-inputs:stale:v1'; +const MILITARY_FORECAST_INPUTS_LIVE_TTL = 900; +const MILITARY_FORECAST_INPUTS_STALE_TTL = 86400; const MILITARY_SURGES_LIVE_KEY = 'military:surges:v1'; const MILITARY_SURGES_STALE_KEY = 'military:surges:stale:v1'; const MILITARY_SURGES_HISTORY_KEY = 'military:surges:history:v1'; @@ -26,6 +32,7 @@ const MILITARY_SURGES_LIVE_TTL = 900; const MILITARY_SURGES_STALE_TTL = 86400; const MILITARY_SURGES_HISTORY_TTL = 604800; const MILITARY_SURGES_HISTORY_MAX = 72; +const CHAIN_FORECAST_SEED = process.env.CHAIN_FORECAST_SEED_ON_MILITARY === '1'; // ── Proxy Config ───────────────────────────────────────── const OPENSKY_PROXY_AUTH = process.env.OPENSKY_PROXY_AUTH || process.env.OREF_PROXY_AUTH || ''; @@ -636,11 +643,32 @@ async function redisGet(url, token, key) { try { return JSON.parse(data.result); } catch { return null; } } +async function triggerForecastSeedIfEnabled() { + if (!CHAIN_FORECAST_SEED) return; + + const scriptPath = fileURLToPath(new URL('./seed-forecasts.mjs', import.meta.url)); + console.log(' Triggering forecast reseed after military publish...'); + + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [scriptPath], { + stdio: 'inherit', + env: process.env, + }); + + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`forecast reseed exited with code ${code}`)); + }); + }); +} + // ── Main ─────────────────────────────────────────────────── async function main() { const startMs = Date.now(); const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const { url, token } = getRedisCredentials(); + let lockReleased = false; console.log(`=== military:flights Seed (proxy: ${PROXY_ENABLED ? 'enabled' : 'direct'}) ===`); @@ -662,8 +690,8 @@ async function main() { await releaseLock('military:flights', runId); console.error(` FETCH FAILED: ${err.message || err}`); await extendExistingTtl([LIVE_KEY], LIVE_TTL); - await extendExistingTtl([STALE_KEY, THEATER_POSTURE_STALE_KEY, MILITARY_SURGES_STALE_KEY], STALE_TTL); - await extendExistingTtl([THEATER_POSTURE_LIVE_KEY], THEATER_POSTURE_LIVE_TTL); + await extendExistingTtl([STALE_KEY, THEATER_POSTURE_STALE_KEY, MILITARY_SURGES_STALE_KEY, MILITARY_FORECAST_INPUTS_STALE_KEY], STALE_TTL); + await extendExistingTtl([THEATER_POSTURE_LIVE_KEY, MILITARY_FORECAST_INPUTS_LIVE_KEY], THEATER_POSTURE_LIVE_TTL); await extendExistingTtl([THEATER_POSTURE_BACKUP_KEY], THEATER_POSTURE_BACKUP_TTL); await extendExistingTtl([MILITARY_SURGES_LIVE_KEY], MILITARY_SURGES_LIVE_TTL); console.log(`\n=== Failed gracefully (${Math.round(Date.now() - startMs)}ms) ===`); @@ -673,11 +701,13 @@ async function main() { if (flights.length === 0) { console.log(' SKIPPED: 0 military flights — preserving stale data'); await releaseLock('military:flights', runId); + lockReleased = true; process.exit(0); } try { - const payload = { flights, fetchedAt: Date.now(), stats: { total: flights.length, byType } }; + const assessedAt = Date.now(); + const payload = { flights, fetchedAt: assessedAt, stats: { total: flights.length, byType } }; await redisSet(url, token, LIVE_KEY, payload, LIVE_TTL); await redisSet(url, token, STALE_KEY, payload, STALE_TTL); @@ -696,44 +726,72 @@ async function main() { altitude: f.altitude || 0, heading: f.heading || 0, speed: f.speed || 0, aircraftType: f.aircraftType || detectAircraftType(f.callsign), })); - const theaters = calculateTheaterPostures(theaterFlights); + const theaters = calculateTheaterPostures(theaterFlights).map((theater) => ({ + ...theater, + assessedAt, + })); const posturePayload = { theaters }; await redisSet(url, token, THEATER_POSTURE_LIVE_KEY, posturePayload, THEATER_POSTURE_LIVE_TTL); await redisSet(url, token, THEATER_POSTURE_STALE_KEY, posturePayload, THEATER_POSTURE_STALE_TTL); await redisSet(url, token, THEATER_POSTURE_BACKUP_KEY, posturePayload, THEATER_POSTURE_BACKUP_TTL); - await redisSet(url, token, 'seed-meta:theater-posture', { fetchedAt: Date.now(), recordCount: theaterFlights.length, sourceVersion: source || '' }, 604800); + await redisSet(url, token, 'seed-meta:theater-posture', { fetchedAt: assessedAt, recordCount: theaterFlights.length, sourceVersion: source || '' }, 604800); const elevated = theaters.filter((t) => t.postureLevel !== 'normal').length; console.log(` Theater posture: ${theaters.length} theaters (${elevated} elevated)`); const priorSurgeHistory = ((await redisGet(url, token, MILITARY_SURGES_HISTORY_KEY))?.history || []); - const theaterActivity = summarizeMilitaryTheaters(flights, POSTURE_THEATERS); + const theaterActivity = summarizeMilitaryTheaters(flights, POSTURE_THEATERS, assessedAt); const surges = buildMilitarySurges(theaterActivity, priorSurgeHistory, { sourceVersion: source || '' }); const surgePayload = { surges, theaters: theaterActivity, - fetchedAt: Date.now(), + fetchedAt: assessedAt, sourceVersion: source || '', }; + const forecastInputsPayload = { + fetchedAt: assessedAt, + sourceVersion: source || '', + theaters, + theaterActivity, + surges, + stats: { + totalFlights: flights.length, + elevatedTheaters: elevated, + }, + }; const surgeHistory = appendMilitaryHistory(priorSurgeHistory, { - assessedAt: Date.now(), + assessedAt, sourceVersion: source || '', theaters: theaterActivity, }, MILITARY_SURGES_HISTORY_MAX); + await redisSet(url, token, MILITARY_FORECAST_INPUTS_LIVE_KEY, forecastInputsPayload, MILITARY_FORECAST_INPUTS_LIVE_TTL); + await redisSet(url, token, MILITARY_FORECAST_INPUTS_STALE_KEY, forecastInputsPayload, MILITARY_FORECAST_INPUTS_STALE_TTL); await redisSet(url, token, MILITARY_SURGES_LIVE_KEY, surgePayload, MILITARY_SURGES_LIVE_TTL); await redisSet(url, token, MILITARY_SURGES_STALE_KEY, surgePayload, MILITARY_SURGES_STALE_TTL); await redisSet(url, token, MILITARY_SURGES_HISTORY_KEY, { history: surgeHistory }, MILITARY_SURGES_HISTORY_TTL); await redisSet(url, token, 'seed-meta:military-surges', { - fetchedAt: Date.now(), + fetchedAt: assessedAt, recordCount: surges.length, sourceVersion: source || '', }, 604800); + await redisSet(url, token, 'seed-meta:military-forecast-inputs', { + fetchedAt: assessedAt, + recordCount: theaters.length, + sourceVersion: source || '', + }, 604800); console.log(` Military surges: ${surges.length} detected (history: ${surgeHistory.length} runs)`); + await releaseLock('military:flights', runId); + lockReleased = true; + try { + await triggerForecastSeedIfEnabled(); + } catch (err) { + console.warn(` Forecast reseed failed after military publish: ${err.message || err}`); + } const durationMs = Date.now() - startMs; logSeedResult('military', flights.length, durationMs); console.log(`\n=== Done (${Math.round(durationMs)}ms) ===`); } finally { - await releaseLock('military:flights', runId); + if (!lockReleased) await releaseLock('military:flights', runId); } } diff --git a/tests/forecast-detectors.test.mjs b/tests/forecast-detectors.test.mjs index 3b589a093..66f2fb8ea 100644 --- a/tests/forecast-detectors.test.mjs +++ b/tests/forecast-detectors.test.mjs @@ -18,6 +18,7 @@ import { detectCyberScenarios, detectGpsJammingScenarios, detectFromPredictionMarkets, + getFreshMilitaryForecastInputs, normalizeChokepoints, normalizeGpsJamming, loadEntityGraph, @@ -421,6 +422,18 @@ describe('detectConflictScenarios', () => { assert.ok(result.length >= 1); assert.equal(result[0].region, 'Middle East'); }); + + it('accepts theater posture entries that use theater instead of id', () => { + const inputs = { + ciiScores: [], + theaterPosture: { theaters: [{ theater: 'taiwan-theater', name: 'Taiwan Theater', postureLevel: 'elevated' }] }, + iranEvents: [], + ucdpEvents: [], + }; + const result = detectConflictScenarios(inputs); + assert.ok(result.length >= 1); + assert.equal(result[0].region, 'Western Pacific'); + }); }); describe('detectMarketScenarios', () => { @@ -539,7 +552,7 @@ describe('detectPoliticalScenarios', () => { describe('detectMilitaryScenarios', () => { it('accepts live theater entries that use theater instead of id', () => { const inputs = { - theaterPosture: { theaters: [{ theater: 'baltic-theater', postureLevel: 'critical', activeFlights: 12 }] }, + militaryForecastInputs: { fetchedAt: Date.now(), theaters: [{ theater: 'baltic-theater', postureLevel: 'critical', activeFlights: 12 }] }, temporalAnomalies: { anomalies: [] }, }; const result = detectMilitaryScenarios(inputs); @@ -550,10 +563,45 @@ describe('detectMilitaryScenarios', () => { it('creates a military forecast from theater surge data even before posture turns elevated', () => { const inputs = { - theaterPosture: { theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }] }, temporalAnomalies: { anomalies: [] }, - militarySurges: { + militaryForecastInputs: { fetchedAt: Date.now(), + theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }], + surges: [{ + theaterId: 'taiwan-theater', + surgeType: 'fighter', + currentCount: 8, + baselineCount: 2, + surgeMultiple: 4, + persistent: true, + persistenceCount: 2, + postureLevel: 'normal', + strikeCapable: true, + fighters: 8, + tankers: 1, + awacs: 1, + dominantCountry: 'China', + dominantCountryCount: 6, + dominantOperator: 'plaaf', + }], + }, + }; + const result = detectMilitaryScenarios(inputs); + assert.equal(result.length, 1); + assert.equal(result[0].title, 'China-linked fighter surge near Taiwan Strait'); + assert.ok(result[0].probability >= 0.7); + assert.ok(result[0].signals.some((signal) => signal.type === 'mil_surge')); + assert.ok(result[0].signals.some((signal) => signal.type === 'operator')); + assert.ok(result[0].signals.some((signal) => signal.type === 'persistence')); + assert.ok(result[0].signals.some((signal) => signal.type === 'theater_actor_fit')); + }); + + it('ignores stale military surge payloads', () => { + const inputs = { + temporalAnomalies: { anomalies: [] }, + militaryForecastInputs: { + fetchedAt: Date.now() - (4 * 60 * 60 * 1000), + theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }], surges: [{ theaterId: 'taiwan-theater', surgeType: 'fighter', @@ -571,32 +619,42 @@ describe('detectMilitaryScenarios', () => { }, }; const result = detectMilitaryScenarios(inputs); - assert.equal(result.length, 1); - assert.equal(result[0].title, 'Military air surge: Taiwan Strait'); - assert.ok(result[0].probability >= 0.7); - assert.ok(result[0].signals.some((signal) => signal.type === 'mil_surge')); - assert.ok(result[0].signals.some((signal) => signal.type === 'operator')); + assert.equal(result.length, 0); }); - it('ignores stale military surge payloads', () => { + it('rejects military bundles whose theater timestamps drift from fetchedAt', () => { + const bundle = getFreshMilitaryForecastInputs({ + militaryForecastInputs: { + fetchedAt: Date.now(), + theaters: [{ theater: 'taiwan-theater', postureLevel: 'elevated', assessedAt: Date.now() - (6 * 60 * 1000) }], + surges: [], + }, + }); + assert.equal(bundle, null); + }); + + it('suppresses one-off generic air activity when it lacks persistence and theater-relevant actors', () => { const inputs = { - theaterPosture: { theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }] }, temporalAnomalies: { anomalies: [] }, - militarySurges: { - fetchedAt: Date.now() - (4 * 60 * 60 * 1000), + militaryForecastInputs: { + fetchedAt: Date.now(), + theaters: [{ theater: 'iran-theater', postureLevel: 'normal', activeFlights: 6 }], surges: [{ - theaterId: 'taiwan-theater', - surgeType: 'fighter', - currentCount: 8, - baselineCount: 2, - surgeMultiple: 4, + theaterId: 'iran-theater', + surgeType: 'air_activity', + currentCount: 6, + baselineCount: 2.7, + surgeMultiple: 2.22, + persistent: false, + persistenceCount: 0, postureLevel: 'normal', - strikeCapable: true, - fighters: 8, - tankers: 1, - awacs: 1, - dominantCountry: 'China', - dominantCountryCount: 6, + strikeCapable: false, + fighters: 0, + tankers: 0, + awacs: 0, + dominantCountry: 'Qatar', + dominantCountryCount: 4, + dominantOperator: 'other', }], }, }; diff --git a/tests/military-surges.test.mjs b/tests/military-surges.test.mjs index 74bc0535b..0883bf771 100644 --- a/tests/military-surges.test.mjs +++ b/tests/military-surges.test.mjs @@ -33,15 +33,79 @@ describe('military surge signals', () => { it('detects fighter surges against prior baseline history', () => { const history = appendMilitaryHistory([], { assessedAt: 1, - theaters: [{ theaterId: 'taiwan-theater', fighters: 2, transport: 1, totalFlights: 4 }], + theaters: [{ theaterId: 'taiwan-theater', fighters: 1, transport: 1, totalFlights: 3 }], }); const history2 = appendMilitaryHistory(history, { assessedAt: 2, - theaters: [{ theaterId: 'taiwan-theater', fighters: 2, transport: 1, totalFlights: 5 }], + theaters: [{ theaterId: 'taiwan-theater', fighters: 1, transport: 1, totalFlights: 3 }], }); const history3 = appendMilitaryHistory(history2, { assessedAt: 3, - theaters: [{ theaterId: 'taiwan-theater', fighters: 2, transport: 1, totalFlights: 4 }], + theaters: [{ theaterId: 'taiwan-theater', fighters: 1, transport: 1, totalFlights: 3 }], + }); + const history4 = appendMilitaryHistory(history3, { + assessedAt: 4, + theaters: [{ theaterId: 'taiwan-theater', fighters: 1, transport: 1, totalFlights: 3 }], + }); + const history5 = appendMilitaryHistory(history4, { + assessedAt: 5, + theaters: [{ theaterId: 'taiwan-theater', fighters: 1, transport: 1, totalFlights: 3 }], + }); + const history6 = appendMilitaryHistory(history5, { + assessedAt: 6, + theaters: [{ theaterId: 'taiwan-theater', fighters: 1, transport: 1, totalFlights: 3 }], + }); + const history7 = appendMilitaryHistory(history6, { + assessedAt: 7, + theaters: [{ theaterId: 'taiwan-theater', fighters: 6, transport: 1, totalFlights: 8 }], + }); + const history8 = appendMilitaryHistory(history7, { + assessedAt: 8, + theaters: [{ theaterId: 'taiwan-theater', fighters: 6, transport: 1, totalFlights: 8 }], + }); + const history9 = appendMilitaryHistory(history8, { + assessedAt: 9, + theaters: [{ theaterId: 'taiwan-theater', fighters: 6, transport: 1, totalFlights: 8 }], + }); + + const surges = buildMilitarySurges([{ + theaterId: 'taiwan-theater', + assessedAt: 10, + totalFlights: 10, + postureLevel: 'elevated', + strikeCapable: true, + fighters: 8, + tankers: 1, + awacs: 1, + transport: 1, + reconnaissance: 0, + bombers: 0, + drones: 0, + byOperator: { plaaf: 8 }, + byCountry: { China: 8 }, + }], history9); + + assert.ok(surges.some((surge) => surge.surgeType === 'fighter')); + const fighter = surges.find((surge) => surge.surgeType === 'fighter'); + assert.equal(fighter.theaterId, 'taiwan-theater'); + assert.equal(fighter.dominantCountry, 'China'); + assert.ok(fighter.surgeMultiple >= 2); + assert.ok(fighter.persistent); + assert.ok(fighter.persistenceCount >= 1); + }); + + it('requires recent snapshots to clear the same surge thresholds before marking persistence', () => { + const history = appendMilitaryHistory([], { + assessedAt: 1, + theaters: [{ theaterId: 'taiwan-theater', fighters: 3, transport: 1, totalFlights: 5 }], + }); + const history2 = appendMilitaryHistory(history, { + assessedAt: 2, + theaters: [{ theaterId: 'taiwan-theater', fighters: 3, transport: 1, totalFlights: 5 }], + }); + const history3 = appendMilitaryHistory(history2, { + assessedAt: 3, + theaters: [{ theaterId: 'taiwan-theater', fighters: 3, transport: 1, totalFlights: 5 }], }); const surges = buildMilitarySurges([{ @@ -61,11 +125,10 @@ describe('military surge signals', () => { byCountry: { China: 8 }, }], history3); - assert.ok(surges.some((surge) => surge.surgeType === 'fighter')); const fighter = surges.find((surge) => surge.surgeType === 'fighter'); - assert.equal(fighter.theaterId, 'taiwan-theater'); - assert.equal(fighter.dominantCountry, 'China'); - assert.ok(fighter.surgeMultiple >= 3.5); + assert.ok(fighter); + assert.equal(fighter.persistent, false); + assert.equal(fighter.persistenceCount, 0); }); it('does not build a baseline from a different source family', () => {