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 },
|
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 },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user