fix(forecast): bundle military surge inputs (#1706)

This commit is contained in:
Elie Habib
2026-03-16 08:40:14 +04:00
committed by GitHub
parent d101c03009
commit a4914607bb
7 changed files with 360 additions and 59 deletions

View File

@@ -105,6 +105,7 @@ const SEED_META = {
iranEvents: { key: 'seed-meta:conflict:iran-events', maxStaleMin: 10080 }, iranEvents: { key: 'seed-meta:conflict:iran-events', maxStaleMin: 10080 },
ucdpEvents: { key: 'seed-meta:conflict:ucdp-events', maxStaleMin: 420 }, ucdpEvents: { key: 'seed-meta:conflict:ucdp-events', maxStaleMin: 420 },
militaryFlights: { key: 'seed-meta:military:flights', maxStaleMin: 15 }, 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 }, satellites: { key: 'seed-meta:intelligence:satellites', maxStaleMin: 180 },
weatherAlerts: { key: 'seed-meta:weather:alerts', maxStaleMin: 30 }, weatherAlerts: { key: 'seed-meta:weather:alerts', maxStaleMin: 30 },
spending: { key: 'seed-meta:economic:spending', maxStaleMin: 120 }, spending: { key: 'seed-meta:economic:spending', maxStaleMin: 120 },

View File

@@ -34,6 +34,7 @@ const SEED_DOMAINS = {
'intelligence:gpsjam': { key: 'seed-meta:intelligence:gpsjam', intervalMin: 360 }, 'intelligence:gpsjam': { key: 'seed-meta:intelligence:gpsjam', intervalMin: 360 },
'intelligence:satellites': { key: 'seed-meta:intelligence:satellites', intervalMin: 90 }, 'intelligence:satellites': { key: 'seed-meta:intelligence:satellites', intervalMin: 90 },
'military:flights': { key: 'seed-meta:military:flights', intervalMin: 8 }, '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 }, 'infra:service-statuses': { key: 'seed-meta:infra:service-statuses', intervalMin: 60 },
'supply_chain:shipping': { key: 'seed-meta:supply_chain:shipping', intervalMin: 120 }, 'supply_chain:shipping': { key: 'seed-meta:supply_chain:shipping', intervalMin: 120 },
'supply_chain:chokepoints': { key: 'seed-meta:supply_chain:chokepoints', intervalMin: 30 }, 'supply_chain:chokepoints': { key: 'seed-meta:supply_chain:chokepoints', intervalMin: 30 },

View File

@@ -36,6 +36,12 @@ function getComparableTheaterSnapshots(history, theaterId, sourceVersion = '') {
.slice(-BASELINE_WINDOW); .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()) { export function summarizeMilitaryTheaters(flights, theaters, assessedAt = Date.now()) {
return theaters.map((theater) => { return theaters.map((theater) => {
const theaterFlights = flights.filter( const theaterFlights = flights.filter(
@@ -112,6 +118,8 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) {
const effectiveBaseline = Math.max(1, baselineCount); const effectiveBaseline = Math.max(1, baselineCount);
if (currentCount < minCount) return; if (currentCount < minCount) return;
if (currentCount < effectiveBaseline * surgeThreshold) return; if (currentCount < effectiveBaseline * surgeThreshold) return;
const field = surgeType === 'fighter' ? 'fighters' : 'transport';
const persistenceCount = countPersistentSnapshots(priorSnapshots, field, effectiveBaseline, minCount, surgeThreshold);
surges.push({ surges.push({
id: `${surgeType}-${summary.theaterId}`, id: `${surgeType}-${summary.theaterId}`,
@@ -132,6 +140,8 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) {
dominantOperator: dominantOperator?.[0] || '', dominantOperator: dominantOperator?.[0] || '',
dominantOperatorCount: dominantOperator?.[1] || 0, dominantOperatorCount: dominantOperator?.[1] || 0,
historyPoints: priorSnapshots.length, historyPoints: priorSnapshots.length,
persistenceCount,
persistent: persistenceCount >= 1,
assessedAt: summary.assessedAt, assessedAt: summary.assessedAt,
}); });
}; };
@@ -145,6 +155,7 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) {
summary.totalFlights >= Math.max(6, Math.ceil(effectiveTotalBaseline * totalSurgeThreshold)) && summary.totalFlights >= Math.max(6, Math.ceil(effectiveTotalBaseline * totalSurgeThreshold)) &&
totalChangePct >= 40 totalChangePct >= 40
) { ) {
const persistenceCount = countPersistentSnapshots(priorSnapshots, 'totalFlights', effectiveTotalBaseline, 6, totalSurgeThreshold);
surges.push({ surges.push({
id: `air-activity-${summary.theaterId}`, id: `air-activity-${summary.theaterId}`,
theaterId: summary.theaterId, theaterId: summary.theaterId,
@@ -164,6 +175,8 @@ export function buildMilitarySurges(theaterSummaries, history, opts = {}) {
dominantOperator: dominantOperator?.[0] || '', dominantOperator: dominantOperator?.[0] || '',
dominantOperatorCount: dominantOperator?.[1] || 0, dominantOperatorCount: dominantOperator?.[1] || 0,
historyPoints: priorSnapshots.length, historyPoints: priorSnapshots.length,
persistenceCount,
persistent: persistenceCount >= 1,
assessedAt: summary.assessedAt, assessedAt: summary.assessedAt,
}); });
} }

View File

@@ -22,6 +22,7 @@ const TRACE_RUNS_MAX = 50;
const TRACE_REDIS_TTL_SECONDS = 60 * 24 * 60 * 60; const TRACE_REDIS_TTL_SECONDS = 60 * 24 * 60 * 60;
const PUBLISH_MIN_PROBABILITY = 0; const PUBLISH_MIN_PROBABILITY = 0;
const MAX_MILITARY_SURGE_AGE_MS = 3 * 60 * 60 * 1000; const MAX_MILITARY_SURGE_AGE_MS = 3 * 60 * 60 * 1000;
const MAX_MILITARY_BUNDLE_DRIFT_MS = 5 * 60 * 1000;
const THEATER_IDS = [ const THEATER_IDS = [
'iran-theater', 'taiwan-theater', 'baltic-theater', 'iran-theater', 'taiwan-theater', 'baltic-theater',
@@ -53,6 +54,15 @@ const THEATER_LABELS = {
'yemen-redsea-theater': 'Yemen/Red Sea', '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 = { const CHOKEPOINT_COMMODITIES = {
'Middle East': { commodity: 'Oil', sensitivity: 0.8 }, 'Middle East': { commodity: 'Oil', sensitivity: 0.8 },
'Red Sea': { commodity: 'Shipping/Oil', sensitivity: 0.7 }, 'Red Sea': { commodity: 'Shipping/Oil', sensitivity: 0.7 },
@@ -169,8 +179,8 @@ async function readInputKeys() {
const keys = [ const keys = [
'risk:scores:sebuf:stale:v1', 'risk:scores:sebuf:stale:v1',
'temporal:anomalies:v1', 'temporal:anomalies:v1',
'theater-posture:sebuf:stale:v1', 'theater_posture:sebuf:stale:v1',
'military:surges:stale:v1', 'military:forecast-inputs:stale:v1',
'prediction:markets-bootstrap:v1', 'prediction:markets-bootstrap:v1',
'supply_chain:chokepoints:v4', 'supply_chain:chokepoints:v4',
'conflict:iran-events:v1', 'conflict:iran-events:v1',
@@ -200,7 +210,7 @@ async function readInputKeys() {
ciiScores: parse(0), ciiScores: parse(0),
temporalAnomalies: parse(1), temporalAnomalies: parse(1),
theaterPosture: parse(2), theaterPosture: parse(2),
militarySurges: parse(3), militaryForecastInputs: parse(3),
predictionMarkets: parse(4), predictionMarkets: parse(4),
chokepoints: normalizeChokepoints(parse(5)), chokepoints: normalizeChokepoints(parse(5)),
iranEvents: parse(6), iranEvents: parse(6),
@@ -226,6 +236,81 @@ function normalize(value, min, max) {
return Math.max(0, Math.min(1, (value - min) / (max - min))); 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) { function resolveCountryName(raw) {
if (!raw || raw.length > 3) return raw; // already a full name or long-form if (!raw || raw.length > 3) return raw; // already a full name or long-form
const codes = loadCountryCodes(); const codes = loadCountryCodes();
@@ -348,15 +433,16 @@ function detectConflictScenarios(inputs) {
} }
for (const t of theaters) { for (const t of theaters) {
if (!t?.id) continue; const theaterId = t?.id || t?.theater;
if (!theaterId) continue;
const posture = t.postureLevel || t.posture || ''; const posture = t.postureLevel || t.posture || '';
if (posture !== 'critical' && posture !== 'elevated') continue; 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); const alreadyCovered = predictions.some(p => p.region === region);
if (alreadyCovered) continue; if (alreadyCovered) continue;
const signals = [ 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; const prob = posture === 'critical' ? 0.65 : 0.4;
@@ -557,13 +643,10 @@ function detectPoliticalScenarios(inputs) {
function detectMilitaryScenarios(inputs) { function detectMilitaryScenarios(inputs) {
const predictions = []; 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 anomalies = Array.isArray(inputs.temporalAnomalies) ? inputs.temporalAnomalies : inputs.temporalAnomalies?.anomalies || [];
const surgeFetchedAt = Number(inputs.militarySurges?.fetchedAt || 0); const surgeItems = Array.isArray(militaryInputs) ? militaryInputs : militaryInputs?.surges || [];
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 theatersById = new Map(theaters.map((theater) => [(theater?.id || theater?.theater), theater]).filter(([theaterId]) => !!theaterId)); const theatersById = new Map(theaters.map((theater) => [(theater?.id || theater?.theater), theater]).filter(([theaterId]) => !!theaterId));
const surgesByTheater = new Map(); const surgesByTheater = new Map();
@@ -584,15 +667,16 @@ function detectMilitaryScenarios(inputs) {
const theaterSurges = surgesByTheater.get(theaterId) || []; const theaterSurges = surgesByTheater.get(theaterId) || [];
if (!theaterId) continue; if (!theaterId) continue;
const posture = t?.postureLevel || t?.posture || ''; const posture = t?.postureLevel || t?.posture || '';
const highestSurge = theaterSurges const highestSurge = selectPrimaryMilitarySurge(theaterId, theaterSurges);
.slice() const surgeIsUsable = canPromoteMilitarySurge(posture, highestSurge);
.sort((a, b) => (b.surgeMultiple || 0) - (a.surgeMultiple || 0))[0]; if (posture !== 'elevated' && posture !== 'critical' && !surgeIsUsable) continue;
if (posture !== 'elevated' && posture !== 'critical' && !highestSurge) continue;
const region = THEATER_REGIONS[theaterId] || t?.name || theaterId; const region = THEATER_REGIONS[theaterId] || t?.name || theaterId;
const theaterLabel = THEATER_LABELS[theaterId] || t?.name || theaterId; const theaterLabel = THEATER_LABELS[theaterId] || t?.name || theaterId;
const signals = []; const signals = [];
let sourceCount = 0; let sourceCount = 0;
const actorScore = computeTheaterActorScore(theaterId, highestSurge);
const persistent = !!highestSurge?.persistent || (highestSurge?.surgeMultiple || 0) >= 3.5;
if (posture === 'elevated' || posture === 'critical') { if (posture === 'elevated' || posture === 'critical') {
signals.push({ type: 'theater', value: `${theaterLabel} posture: ${posture}`, weight: 0.45 }); signals.push({ type: 'theater', value: `${theaterLabel} posture: ${posture}`, weight: 0.45 });
@@ -632,6 +716,22 @@ function detectMilitaryScenarios(inputs) {
}); });
sourceCount++; 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)) { if (t?.indicators && Array.isArray(t.indicators)) {
@@ -643,16 +743,22 @@ function detectMilitaryScenarios(inputs) {
} }
const baseLine = highestSurge 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; : posture === 'critical' ? 0.6 : 0.35;
const flightBoost = milFlights.length > 0 ? 0.1 : 0; const flightBoost = milFlights.length > 0 ? 0.1 : 0;
const postureBoost = posture === 'critical' ? 0.12 : posture === 'elevated' ? 0.06 : 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 supportBoost = highestSurge && (highestSurge.awacs > 0 || highestSurge.tankers > 0) ? 0.05 : 0;
const strikeBoost = (t?.activeOperations?.includes?.('strike_capable') || highestSurge?.strikeCapable) ? 0.06 : 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 confidence = Math.max(0.3, normalize(sourceCount, 0, 4));
const title = highestSurge const title = highestSurge
? `Military air surge: ${theaterLabel}` ? buildMilitaryForecastTitle(theaterId, theaterLabel, highestSurge)
: `Military posture escalation: ${region}`; : `Military posture escalation: ${region}`;
predictions.push(makePrediction( predictions.push(makePrediction(
@@ -2773,6 +2879,7 @@ export {
detectCyberScenarios, detectCyberScenarios,
detectGpsJammingScenarios, detectGpsJammingScenarios,
detectFromPredictionMarkets, detectFromPredictionMarkets,
getFreshMilitaryForecastInputs,
loadEntityGraph, loadEntityGraph,
discoverGraphCascades, discoverGraphCascades,
MARITIME_REGIONS, MARITIME_REGIONS,

View File

@@ -2,9 +2,11 @@
import { loadEnvFile, CHROME_UA, getRedisCredentials, acquireLock, releaseLock, withRetry, writeFreshnessMetadata, logSeedResult, verifySeedKey, extendExistingTtl } from './_seed-utils.mjs'; 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 { summarizeMilitaryTheaters, buildMilitarySurges, appendMilitaryHistory } from './_military-surges.mjs';
import { spawn } from 'node:child_process';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import tls from 'node:tls'; import tls from 'node:tls';
import { fileURLToPath } from 'node:url';
loadEnvFile(import.meta.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_LIVE_TTL = 900;
const THEATER_POSTURE_STALE_TTL = 86400; const THEATER_POSTURE_STALE_TTL = 86400;
const THEATER_POSTURE_BACKUP_TTL = 604800; 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_LIVE_KEY = 'military:surges:v1';
const MILITARY_SURGES_STALE_KEY = 'military:surges:stale:v1'; const MILITARY_SURGES_STALE_KEY = 'military:surges:stale:v1';
const MILITARY_SURGES_HISTORY_KEY = 'military:surges:history: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_STALE_TTL = 86400;
const MILITARY_SURGES_HISTORY_TTL = 604800; const MILITARY_SURGES_HISTORY_TTL = 604800;
const MILITARY_SURGES_HISTORY_MAX = 72; const MILITARY_SURGES_HISTORY_MAX = 72;
const CHAIN_FORECAST_SEED = process.env.CHAIN_FORECAST_SEED_ON_MILITARY === '1';
// ── Proxy Config ───────────────────────────────────────── // ── Proxy Config ─────────────────────────────────────────
const OPENSKY_PROXY_AUTH = process.env.OPENSKY_PROXY_AUTH || process.env.OREF_PROXY_AUTH || ''; 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; } 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 ─────────────────────────────────────────────────── // ── Main ───────────────────────────────────────────────────
async function main() { async function main() {
const startMs = Date.now(); const startMs = Date.now();
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const { url, token } = getRedisCredentials(); const { url, token } = getRedisCredentials();
let lockReleased = false;
console.log(`=== military:flights Seed (proxy: ${PROXY_ENABLED ? 'enabled' : 'direct'}) ===`); console.log(`=== military:flights Seed (proxy: ${PROXY_ENABLED ? 'enabled' : 'direct'}) ===`);
@@ -662,8 +690,8 @@ async function main() {
await releaseLock('military:flights', runId); await releaseLock('military:flights', runId);
console.error(` FETCH FAILED: ${err.message || err}`); console.error(` FETCH FAILED: ${err.message || err}`);
await extendExistingTtl([LIVE_KEY], LIVE_TTL); await extendExistingTtl([LIVE_KEY], LIVE_TTL);
await extendExistingTtl([STALE_KEY, THEATER_POSTURE_STALE_KEY, MILITARY_SURGES_STALE_KEY], STALE_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], THEATER_POSTURE_LIVE_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([THEATER_POSTURE_BACKUP_KEY], THEATER_POSTURE_BACKUP_TTL);
await extendExistingTtl([MILITARY_SURGES_LIVE_KEY], MILITARY_SURGES_LIVE_TTL); await extendExistingTtl([MILITARY_SURGES_LIVE_KEY], MILITARY_SURGES_LIVE_TTL);
console.log(`\n=== Failed gracefully (${Math.round(Date.now() - startMs)}ms) ===`); console.log(`\n=== Failed gracefully (${Math.round(Date.now() - startMs)}ms) ===`);
@@ -673,11 +701,13 @@ async function main() {
if (flights.length === 0) { if (flights.length === 0) {
console.log(' SKIPPED: 0 military flights — preserving stale data'); console.log(' SKIPPED: 0 military flights — preserving stale data');
await releaseLock('military:flights', runId); await releaseLock('military:flights', runId);
lockReleased = true;
process.exit(0); process.exit(0);
} }
try { 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, LIVE_KEY, payload, LIVE_TTL);
await redisSet(url, token, STALE_KEY, payload, STALE_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, altitude: f.altitude || 0, heading: f.heading || 0, speed: f.speed || 0,
aircraftType: f.aircraftType || detectAircraftType(f.callsign), aircraftType: f.aircraftType || detectAircraftType(f.callsign),
})); }));
const theaters = calculateTheaterPostures(theaterFlights); const theaters = calculateTheaterPostures(theaterFlights).map((theater) => ({
...theater,
assessedAt,
}));
const posturePayload = { theaters }; const posturePayload = { theaters };
await redisSet(url, token, THEATER_POSTURE_LIVE_KEY, posturePayload, THEATER_POSTURE_LIVE_TTL); 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_STALE_KEY, posturePayload, THEATER_POSTURE_STALE_TTL);
await redisSet(url, token, THEATER_POSTURE_BACKUP_KEY, posturePayload, THEATER_POSTURE_BACKUP_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; const elevated = theaters.filter((t) => t.postureLevel !== 'normal').length;
console.log(` Theater posture: ${theaters.length} theaters (${elevated} elevated)`); console.log(` Theater posture: ${theaters.length} theaters (${elevated} elevated)`);
const priorSurgeHistory = ((await redisGet(url, token, MILITARY_SURGES_HISTORY_KEY))?.history || []); 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 surges = buildMilitarySurges(theaterActivity, priorSurgeHistory, { sourceVersion: source || '' });
const surgePayload = { const surgePayload = {
surges, surges,
theaters: theaterActivity, theaters: theaterActivity,
fetchedAt: Date.now(), fetchedAt: assessedAt,
sourceVersion: source || '', sourceVersion: source || '',
}; };
const forecastInputsPayload = {
fetchedAt: assessedAt,
sourceVersion: source || '',
theaters,
theaterActivity,
surges,
stats: {
totalFlights: flights.length,
elevatedTheaters: elevated,
},
};
const surgeHistory = appendMilitaryHistory(priorSurgeHistory, { const surgeHistory = appendMilitaryHistory(priorSurgeHistory, {
assessedAt: Date.now(), assessedAt,
sourceVersion: source || '', sourceVersion: source || '',
theaters: theaterActivity, theaters: theaterActivity,
}, MILITARY_SURGES_HISTORY_MAX); }, 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_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_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, MILITARY_SURGES_HISTORY_KEY, { history: surgeHistory }, MILITARY_SURGES_HISTORY_TTL);
await redisSet(url, token, 'seed-meta:military-surges', { await redisSet(url, token, 'seed-meta:military-surges', {
fetchedAt: Date.now(), fetchedAt: assessedAt,
recordCount: surges.length, recordCount: surges.length,
sourceVersion: source || '', sourceVersion: source || '',
}, 604800); }, 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)`); 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; const durationMs = Date.now() - startMs;
logSeedResult('military', flights.length, durationMs); logSeedResult('military', flights.length, durationMs);
console.log(`\n=== Done (${Math.round(durationMs)}ms) ===`); console.log(`\n=== Done (${Math.round(durationMs)}ms) ===`);
} finally { } finally {
await releaseLock('military:flights', runId); if (!lockReleased) await releaseLock('military:flights', runId);
} }
} }

View File

@@ -18,6 +18,7 @@ import {
detectCyberScenarios, detectCyberScenarios,
detectGpsJammingScenarios, detectGpsJammingScenarios,
detectFromPredictionMarkets, detectFromPredictionMarkets,
getFreshMilitaryForecastInputs,
normalizeChokepoints, normalizeChokepoints,
normalizeGpsJamming, normalizeGpsJamming,
loadEntityGraph, loadEntityGraph,
@@ -421,6 +422,18 @@ describe('detectConflictScenarios', () => {
assert.ok(result.length >= 1); assert.ok(result.length >= 1);
assert.equal(result[0].region, 'Middle East'); 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', () => { describe('detectMarketScenarios', () => {
@@ -539,7 +552,7 @@ describe('detectPoliticalScenarios', () => {
describe('detectMilitaryScenarios', () => { describe('detectMilitaryScenarios', () => {
it('accepts live theater entries that use theater instead of id', () => { it('accepts live theater entries that use theater instead of id', () => {
const inputs = { 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: [] }, temporalAnomalies: { anomalies: [] },
}; };
const result = detectMilitaryScenarios(inputs); const result = detectMilitaryScenarios(inputs);
@@ -550,10 +563,45 @@ describe('detectMilitaryScenarios', () => {
it('creates a military forecast from theater surge data even before posture turns elevated', () => { it('creates a military forecast from theater surge data even before posture turns elevated', () => {
const inputs = { const inputs = {
theaterPosture: { theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }] },
temporalAnomalies: { anomalies: [] }, temporalAnomalies: { anomalies: [] },
militarySurges: { militaryForecastInputs: {
fetchedAt: Date.now(), 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: [{ surges: [{
theaterId: 'taiwan-theater', theaterId: 'taiwan-theater',
surgeType: 'fighter', surgeType: 'fighter',
@@ -571,32 +619,42 @@ describe('detectMilitaryScenarios', () => {
}, },
}; };
const result = detectMilitaryScenarios(inputs); const result = detectMilitaryScenarios(inputs);
assert.equal(result.length, 1); assert.equal(result.length, 0);
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'));
}); });
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 = { const inputs = {
theaterPosture: { theaters: [{ theater: 'taiwan-theater', postureLevel: 'normal', activeFlights: 5 }] },
temporalAnomalies: { anomalies: [] }, temporalAnomalies: { anomalies: [] },
militarySurges: { militaryForecastInputs: {
fetchedAt: Date.now() - (4 * 60 * 60 * 1000), fetchedAt: Date.now(),
theaters: [{ theater: 'iran-theater', postureLevel: 'normal', activeFlights: 6 }],
surges: [{ surges: [{
theaterId: 'taiwan-theater', theaterId: 'iran-theater',
surgeType: 'fighter', surgeType: 'air_activity',
currentCount: 8, currentCount: 6,
baselineCount: 2, baselineCount: 2.7,
surgeMultiple: 4, surgeMultiple: 2.22,
persistent: false,
persistenceCount: 0,
postureLevel: 'normal', postureLevel: 'normal',
strikeCapable: true, strikeCapable: false,
fighters: 8, fighters: 0,
tankers: 1, tankers: 0,
awacs: 1, awacs: 0,
dominantCountry: 'China', dominantCountry: 'Qatar',
dominantCountryCount: 6, dominantCountryCount: 4,
dominantOperator: 'other',
}], }],
}, },
}; };

View File

@@ -33,15 +33,79 @@ describe('military surge signals', () => {
it('detects fighter surges against prior baseline history', () => { it('detects fighter surges against prior baseline history', () => {
const history = appendMilitaryHistory([], { const history = appendMilitaryHistory([], {
assessedAt: 1, 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, { const history2 = appendMilitaryHistory(history, {
assessedAt: 2, 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, { const history3 = appendMilitaryHistory(history2, {
assessedAt: 3, 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([{ const surges = buildMilitarySurges([{
@@ -61,11 +125,10 @@ describe('military surge signals', () => {
byCountry: { China: 8 }, byCountry: { China: 8 },
}], history3); }], history3);
assert.ok(surges.some((surge) => surge.surgeType === 'fighter'));
const fighter = surges.find((surge) => surge.surgeType === 'fighter'); const fighter = surges.find((surge) => surge.surgeType === 'fighter');
assert.equal(fighter.theaterId, 'taiwan-theater'); assert.ok(fighter);
assert.equal(fighter.dominantCountry, 'China'); assert.equal(fighter.persistent, false);
assert.ok(fighter.surgeMultiple >= 3.5); assert.equal(fighter.persistenceCount, 0);
}); });
it('does not build a baseline from a different source family', () => { it('does not build a baseline from a different source family', () => {