mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(forecast): bundle military surge inputs (#1706)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user