Files
worldmonitor/scripts/seed-cross-source-signals.mjs
Elie Habib 044598346e feat(seed-contract): PR 2a — runSeed envelope dual-write + 91 seeders migrated (#3097)
* feat(seed-contract): PR 2a — runSeed envelope dual-write + 91 seeders migrated

Opt-in contract path in runSeed: when opts.declareRecords is provided, write
{_seed, data} envelope to the canonical key alongside legacy seed-meta:*
(dual-write). State machine: OK / OK_ZERO / RETRY with zeroIsValid opt.
declareRecords throws or returns non-integer → hard fail (contract violation).
extraKeys[*] support per-key declareRecords; each extra key writes its own
envelope. Legacy seeders (no declareRecords) entirely unchanged.

Migrated all 91 scripts/seed-*.mjs to contract mode. Each exports
declareRecords returning the canonical record count, and passes
schemaVersion: 1 + maxStaleMin (matched to api/health.js SEED_META, or 2.5x
interval where no registry entry exists). Contract conformance reports 84/86
seeders with full descriptor (2 pre-existing warnings).

Legacy seed-meta keys still written so unmigrated readers keep working;
follow-up slices flip health.js + readers to envelope-first.

Tests: 61/61 PR 1 tests still pass.

Next slices for PR 2:
- api/health.js registry collapse + 15 seed-bundle-*.mjs canonicalKey wiring
- reader migration (mcp, resilience, aviation, displacement, regional-snapshot)
- direct writers — ais-relay.cjs, consumer-prices-core publish.ts
- public-boundary stripSeedEnvelope + test migration

Plan: docs/plans/2026-04-14-002-fix-runseed-zero-record-lockout-plan.md

* fix(seed-contract): unwrap envelopes in internal cross-seed readers

After PR 2a enveloped 91 canonical keys as {_seed, data}, every script-side
reader that returned the raw parsed JSON started silently handing callers the
envelope instead of the bare payload. WoW baselines (bigmac, grocery-basket,
fear-greed) saw undefined .countries / .composite; seed-climate-anomalies saw
undefined .normals from climate:zone-normals:v1; seed-thermal-escalation saw
undefined .fireDetections from wildfire:fires:v1; seed-forecasts' ~40-key
pipeline batch returned envelopes for every input.

Fix: route every script-side reader through unwrapEnvelope(...).data. Legacy
bare-shape values pass through unchanged (unwrapEnvelope returns
{_seed: null, data: raw} for any non-envelope shape).

Changed:
- scripts/_seed-utils.mjs: import unwrapEnvelope; redisGet, readSeedSnapshot,
  verifySeedKey all unwrap. Exported new readCanonicalValue() helper for
  cross-seed consumers.
- 18 seed-*.mjs scripts with local redisGet-style helpers or inline fetch
  patched to unwrap via the envelope source module (subagent sweep).
- scripts/seed-forecasts.mjs pipeline batch: parse() unwraps each result.
- scripts/seed-energy-spine.mjs redisMget: unwraps each result.

Tests:
- tests/seed-utils-envelope-reads.test.mjs: 7 new cases covering envelope
  + legacy + null paths for readSeedSnapshot and verifySeedKey.
- Full seed suite: 67/67 pass (was 61, +6 new).

Addresses both of user's P1 findings on PR #3097.

* feat(seed-contract): envelope-aware reads in server + api helpers

Every RPC and public-boundary reader now automatically strips _seed from
contract-mode canonical keys. Legacy bare-shape values pass through unchanged
(unwrapEnvelope no-ops on non-envelope shapes).

Changed helpers (one-place fix — unblocks ~60 call sites):
- server/_shared/redis.ts: getRawJson, getCachedJson, getCachedJsonBatch
  unwrap by default. cachedFetchJson inherits via getCachedJson.
- api/_upstash-json.js: readJsonFromUpstash unwraps (covers api/mcp.ts
  tool responses + all its canonical-key reads).
- api/bootstrap.js: getCachedJsonBatch unwraps (public-boundary —
  clients never see envelope metadata).

Left intentionally unchanged:
- api/health.js / api/seed-health.js: read only seed-meta:* keys which
  remain bare-shape during dual-write. unwrapEnvelope already imported at
  the meta-read boundary (PR 1) as a defensive no-op.

Tests: 67/67 seed tests pass. typecheck + typecheck:api clean.

This is the blast-radius fix the PR #3097 review called out — external
readers that would otherwise see {_seed, data} after the writer side
migrated.

* fix(test): strip export keyword in vm.runInContext'd seed source

cross-source-signals-regulatory.test.mjs loads scripts/seed-cross-source-signals.mjs
via vm.runInContext, which cannot parse ESM `export` syntax. PR 2a added
`export function declareRecords` to every seeder, which broke this test's
static-analysis approach.

Fix: strip the `export` keyword from the declareRecords line in the
preprocessed source string so the function body still evaluates as a plain
declaration.

Full test:data suite: 5307/5307 pass. typecheck + typecheck:api clean.

* feat(seed-contract): consumer-prices publish.ts writes envelopes

Wrap the 5 canonical keys written by consumer-prices-core/src/jobs/publish.ts
(overview, movers:7d/30d, freshness, categories:7d/30d/90d, retailer-spread,
basket-series) in {_seed, data} envelopes. Legacy seed-meta:<key> writes
preserved for dual-write.

Inlined a buildEnvelope helper (10 lines) rather than taking a cross-package
dependency — consumer-prices-core is a standalone npm package. Documented the
four-file parity contract (mjs source, ts mirror, js edge mirror, this copy).

Contract fields: sourceVersion='consumer-prices-core-publish-v1', schemaVersion=1,
state='OK' (recordCount>0) or 'OK_ZERO' (legitimate zero).

Typecheck: no new errors in publish.ts.

* fix(seed-contract): 3 more server-side readers unwrap envelopes

Found during final audit:

- server/worldmonitor/resilience/v1/_shared.ts: resilience score reader
  parsed cached GetResilienceScoreResponse raw. Contract-mode seed-resilience-scores
  now envelopes those keys.
- server/worldmonitor/resilience/v1/get-resilience-ranking.ts: p05/p95
  interval lookup parsed raw from seed-resilience-scores' extra-key path.
- server/worldmonitor/infrastructure/v1/_shared.ts: mgetJson() used for
  count-source keys (wildfire:fires:v1, news:insights:v1) which are both
  contract-mode now.

All three now unwrap via server/_shared/seed-envelope. Legacy shapes pass
through unchanged.

Typecheck clean.

* feat(seed-contract): ais-relay.cjs direct writes produce envelopes

32 canonical-key write sites in scripts/ais-relay.cjs now produce {_seed, data}
envelopes. Inlined buildEnvelope() (CJS module can't require ESM source) +
envelopeWrite(key, data, ttlSeconds, meta) wrapper. Enveloped keys span market
bootstrap, aviation, cyber-threats, theater-posture, weather-alerts, economic
spending/fred/worldbank, tech-events, corridor-risk, usni-fleet, shipping-stress,
social:reddit, wsb-tickers, pizzint, product-catalog, chokepoint transits,
ucdp-events, satellites, oref.

Left bare (not seeded data keys): seed-meta:* (dual-write legacy),
classifyCacheKey LLM cache, notam:prev-closed-state internal state,
wm:notif:scan-dedup flags.

Updated tests/ucdp-seed-resilience.test.mjs regex to accept both upstashSet
(pre-contract) and envelopeWrite (post-contract) call patterns.

* feat(seed-contract): 15 bundle files add canonicalKey for envelope gate

54 bundle sections across 12 files now declare canonicalKey alongside the
existing seedMetaKey. _bundle-runner.mjs (from PR 1) prefers canonicalKey
when both are present — gates section runs on envelope._seed.fetchedAt
read directly from the data key, eliminating the meta-outlives-data class
of bugs.

Files touched:
- climate (5), derived-signals (2), ecb-eu (3), energy-sources (6),
  health (2), imf-extended (4), macro (10), market-backup (9),
  portwatch (4), relay-backup (2), resilience-recovery (5), static-ref (2)

Skipped (14 sections, 3 whole bundles): multi-key writers, dynamic
templated keys (displacement year-scoped), or non-runSeed orchestrators
(regional brief cron, resilience-scores' 222-country publish, validation/
benchmark scripts). These continue to use seedMetaKey or their own gate.

seedMetaKey preserved everywhere — dual-write. _bundle-runner.mjs falls
back to legacy when canonicalKey is absent.

All 15 bundles pass node --check. test:data: 5307/5307. typecheck:all: clean.

* fix(seed-contract): 4 PR #3097 review P1s — transform/declareRecords mismatches + envelope leaks

Addresses both P1 findings and the extra-key seed-meta leak surfaced in review:

1. runSeed helper-level invariant: seed-meta:* keys NEVER envelope.
   scripts/_seed-utils.mjs exports shouldEnvelopeKey(key) — returns false for
   any key starting with 'seed-meta:'. Both atomicPublish (canonical) and
   writeExtraKey (extras) gate the envelope wrap through this helper. Fixes
   seed-iea-oil-stocks' ANALYSIS_META_EXTRA_KEY silently getting enveloped,
   which broke health.js parsing the value as bare {fetchedAt, recordCount}.
   Also defends against any future manual writeExtraKey(..., envelopeMeta)
   call that happens to target a seed-meta:* key.

2. seed-token-panels canonical + extras fixed.
   publishTransform returns data.defi (the defi panel itself, shape {tokens}).
   Old declareRecords counted data.defi.tokens + data.ai.tokens + data.other.tokens
   on the transformed payload → 0 → RETRY path → canonical market:defi-tokens:v1
   never wrote, and because runSeed returned before the extraKeys loop,
   market:ai-tokens:v1 + market:other-tokens:v1 stayed stale too.
   New: declareRecords counts data.tokens on the transformed shape. AI_KEY +
   OTHER_KEY extras reuse the same function (transforms return structurally
   identical panels). Added isMain guard so test imports don't fire runSeed.

3. api/product-catalog.js cached reader unwraps envelope.
   ais-relay.cjs now envelopes product-catalog:v2 via envelopeWrite(). The
   edge reader did raw JSON.parse(result) and returned {_seed, data} to
   clients, breaking the cached path. Fix: import unwrapEnvelope from
   ./_seed-envelope.js, apply after JSON.parse. One site — :238-241 is
   downstream of getFromCache(), so the single reader fix covers both.

4. Regression lock tests/seed-contract-transform-regressions.test.mjs (11 cases):
   - shouldEnvelopeKey invariant: seed-meta:* false, canonical true
   - Token-panels declareRecords works on transformed shape (canonical + both extras)
   - Explicit repro of pre-fix buggy signature returning 0 — guards against revert
   - resolveRecordCount accepts 0, rejects non-integer
   - Product-catalog envelope unwrap returns bare shape; legacy passes through

Verification:
- npm run test:data → 5318/5318 pass (was 5307 — 11 new regressions)
- npm run typecheck:all → clean
- node --check on every modified script

iea-oil-stocks canonical declareRecords was NOT broken (user confirmed during
review — buildIndex preserves .members); only its ANALYSIS_META_EXTRA_KEY
was affected, now covered generically by commit 1's helper invariant.

* fix(seed-contract): seed-token-panels validateFn also runs on post-transform shape

Review finding: fixing declareRecords wasn't sufficient — atomicPublish() runs
validateFn(publishData) on the transformed payload too. seed-token-panels'
validate() checked data.defi/.ai/.other on the transformed {tokens} shape,
returned false, and runSeed took the early skipped-write branch (before even
reaching the declareRecords RETRY logic). Net effect: same as before the
declareRecords fix — canonical + both extras stayed stale.

Fix: validate() now checks the canonical defi panel directly (Array.isArray
(data?.tokens) && has at least one t.price > 0). AI/OTHER panels are validated
implicitly by their own extraKey declareRecords on write.

Audited the other 9 seeders with publishTransform (bls-series, bis-extended,
bis-data, gdelt-intel, trade-flows, iea-oil-stocks, jodi-gas, sanctions-pressure,
forecasts): all validateFn's correctly target the post-transform shape. Only
token-panels regressed.

Added 4 regression tests (tests/seed-contract-transform-regressions.test.mjs):
- validate accepts transformed panel with priced tokens
- validate rejects all-zero-price tokens
- validate rejects empty/missing tokens
- Explicit pre-fix repro (buggy old signature fails on transformed shape)

Verification:
- npm run test:data → 5322/5322 pass (was 5318; +4 new)
- npm run typecheck:all → clean
- node --check clean

* feat(seed-contract): add /api/seed-contract-probe validation endpoint

Single machine-readable gate for 'is PR #3097 working in production'.
Replaces the curl/jq ritual with one authenticated edge call that returns
HTTP 200 ok:true or 503 + failing check list.

What it validates:
- 8 canonical keys have {_seed, data} envelopes with required data fields
  and minRecords floors (fsi-eu, zone-normals, 3 token panels + minRecords
  guard against token-panels RETRY regression, product-catalog, wildfire,
  earthquakes).
- 2 seed-meta:* keys remain BARE (shouldEnvelopeKey invariant; guards
  against iea-oil-stocks ANALYSIS_META_EXTRA_KEY-class regressions).
- /api/product-catalog + /api/bootstrap responses contain no '_seed' leak.

Auth: x-probe-secret header must match RELAY_SHARED_SECRET (reuses existing
Vercel↔Railway internal trust boundary).

Probe logic is exported (checkProbe, checkPublicBoundary, DEFAULT_PROBES) for
hermetic testing. tests/seed-contract-probe.test.mjs covers every branch:
envelope pass/fail on field/records/shape, bare pass/fail on shape/field,
missing/malformed JSON, Redis non-2xx, boundary seed-leak detection,
DEFAULT_PROBES sanity (seed-meta invariant present, token-panels minRecords
guard present).

Usage:
  curl -H "x-probe-secret: $RELAY_SHARED_SECRET" \
       https://api.worldmonitor.app/api/seed-contract-probe

PR 3 will extend the probe with a stricter mode that asserts seed-meta:*
keys are GONE (not just bare) once legacy dual-write is removed.

Verification:
- tests/seed-contract-probe.test.mjs → 15/15 pass
- npm run test:data → 5338/5338 (was 5322; +16 new incl. conformance)
- npm run typecheck:all → clean

* fix(seed-contract): tighten probe — minRecords on AI/OTHER + cache-path source header

Review P2 findings: the probe's stated guards were weaker than advertised.

1. market:ai-tokens:v1 + market:other-tokens:v1 probes claimed to guard the
   token-panels extra-key RETRY regression but only checked shape='envelope'
   + dataHas:['tokens']. If an extra-key declareRecords regressed to 0, both
   probes would still pass because checkProbe() only inspects _seed.recordCount
   when minRecords is set. Now both enforce minRecords: 1.

2. /api/product-catalog boundary check only asserted no '_seed' leak — which
   is also true for the static fallback path. A broken cached reader
   (getFromCache returning null or throwing) could serve fallback silently
   and still pass this probe. Now:
   - api/product-catalog.js emits X-Product-Catalog-Source: cache|dodo|fallback
     on the response (the json() helper gained an optional source param wired
     to each of the three branches).
   - checkPublicBoundary declaratively requires that header's value match
     'cache' for /api/product-catalog, so a fallback-serve fails the probe
     with reason 'source:fallback!=cache' or 'source:missing!=cache'.

Test updates (tests/seed-contract-probe.test.mjs):
- Boundary check reworked to use a BOUNDARY_CHECKS config with optional
  requireSourceHeader per endpoint.
- New cases: served-from-cache passes, served-from-fallback fails with source
  mismatch, missing header fails, seed-leak still takes precedence, bad
  status fails.
- Token-panels sanity test now asserts minRecords≥1 on all 3 panels.

Verification:
- tests/seed-contract-probe.test.mjs → 17/17 pass (was 15, +2 net)
- npm run test:data → 5340/5340
- npm run typecheck:all → clean
2026-04-15 09:16:27 +04:00

895 lines
37 KiB
JavaScript

#!/usr/bin/env node
import { loadEnvFile, runSeed, getRedisCredentials } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'intelligence:cross-source-signals:v1';
const CACHE_TTL = 1800; // 30min TTL, 15min cron cadence
// ── Source Redis keys ─────────────────────────────────────────────────────────
const SOURCE_KEYS = [
'thermal:escalation:v1',
'intelligence:gpsjam:v2',
'military:flights:v1',
'unrest:events:v1',
'intelligence:advisories-bootstrap:v1',
'market:stocks-bootstrap:v1',
'market:commodities-bootstrap:v1',
'cyber:threats-bootstrap:v2',
'supply_chain:shipping:v2',
'sanctions:pressure:v1',
'seismology:earthquakes:v1',
'radiation:observations:v1',
'infra:outages:v1',
'wildfire:fires:v1',
`displacement:summary:v1:${new Date().getFullYear()}`,
'forecast:predictions:v2',
'intelligence:gdelt-intel:v1',
'gdelt:intel:tone:military',
'gdelt:intel:tone:nuclear',
'gdelt:intel:tone:maritime',
'weather:alerts:v1',
'risk:scores:sebuf:stale:v1',
'regulatory:actions:v1',
];
// ── Theater classification helpers ────────────────────────────────────────────
const REGION_THEATER_MAP = {
'eastern europe': 'Eastern Europe',
'ukraine': 'Eastern Europe',
'russia': 'Eastern Europe',
'belarus': 'Eastern Europe',
'middle east': 'Middle East',
'israel': 'Middle East',
'gaza': 'Middle East',
'iran': 'Middle East',
'iraq': 'Middle East',
'syria': 'Middle East',
'lebanon': 'Middle East',
'yemen': 'Middle East',
'saudi': 'Middle East',
'red sea': 'Red Sea',
'gulf of aden': 'Red Sea',
'persian gulf': 'Persian Gulf',
'strait of hormuz': 'Persian Gulf',
'east asia': 'East Asia',
'south china sea': 'East Asia',
'taiwan': 'East Asia',
'korea': 'East Asia',
'china': 'East Asia',
'japan': 'East Asia',
'south asia': 'South Asia',
'india': 'South Asia',
'pakistan': 'South Asia',
'africa': 'Sub-Saharan Africa',
'sahel': 'Sub-Saharan Africa',
'sudan': 'Sub-Saharan Africa',
'ethiopia': 'Sub-Saharan Africa',
'somalia': 'Sub-Saharan Africa',
'latin america': 'Latin America',
'venezuela': 'Latin America',
'colombia': 'Latin America',
'north america': 'North America',
'europe': 'Western Europe',
'balkans': 'Western Europe',
'arctic': 'Arctic',
'global': 'Global',
'global markets': 'Global Markets',
};
function normalizeTheater(raw) {
if (!raw) return 'Global';
const lower = String(raw).toLowerCase();
for (const [key, theater] of Object.entries(REGION_THEATER_MAP)) {
if (lower.includes(key)) return theater;
}
// Title-case the raw value as fallback
return String(raw).trim().replace(/\b\w/g, c => c.toUpperCase()) || 'Global';
}
// ── Signal category mapping for composite detection ────────────────────────────
const TYPE_CATEGORY = {
CROSS_SOURCE_SIGNAL_TYPE_THERMAL_SPIKE: 'kinetic',
CROSS_SOURCE_SIGNAL_TYPE_GPS_JAMMING: 'electronic_warfare',
CROSS_SOURCE_SIGNAL_TYPE_MILITARY_FLIGHT_SURGE: 'military',
CROSS_SOURCE_SIGNAL_TYPE_UNREST_SURGE: 'civil',
CROSS_SOURCE_SIGNAL_TYPE_OREF_ALERT_CLUSTER: 'kinetic',
CROSS_SOURCE_SIGNAL_TYPE_VIX_SPIKE: 'financial',
CROSS_SOURCE_SIGNAL_TYPE_COMMODITY_SHOCK: 'economic',
CROSS_SOURCE_SIGNAL_TYPE_CYBER_ESCALATION: 'cyber',
CROSS_SOURCE_SIGNAL_TYPE_SHIPPING_DISRUPTION: 'maritime',
CROSS_SOURCE_SIGNAL_TYPE_SANCTIONS_SURGE: 'diplomatic',
CROSS_SOURCE_SIGNAL_TYPE_EARTHQUAKE_SIGNIFICANT: 'natural',
CROSS_SOURCE_SIGNAL_TYPE_RADIATION_ANOMALY: 'radiological',
CROSS_SOURCE_SIGNAL_TYPE_INFRASTRUCTURE_OUTAGE: 'infrastructure',
CROSS_SOURCE_SIGNAL_TYPE_WILDFIRE_ESCALATION: 'natural',
CROSS_SOURCE_SIGNAL_TYPE_DISPLACEMENT_SURGE: 'humanitarian',
CROSS_SOURCE_SIGNAL_TYPE_FORECAST_DETERIORATION: 'intelligence',
CROSS_SOURCE_SIGNAL_TYPE_MARKET_STRESS: 'financial',
CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME: 'natural',
CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION: 'information',
CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE: 'intelligence',
CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION: 'policy',
};
// Base severity weights for each signal type
// Base severity weights per signal type. These are multiplied by a domain-
// specific factor (e.g. anomaly score, % change) to produce severityScore.
// Scoring thresholds: >=3.5 → CRITICAL, >=2.5 → HIGH, >=1.5 → MEDIUM, else LOW.
// Higher weight = a weaker domain signal can still reach HIGH/CRITICAL.
// Composite escalation starts at 4.0 and grows with categoryMap.size.
const BASE_WEIGHT = {
CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION: 4.0, // synthetic — grows with co-firing count
CROSS_SOURCE_SIGNAL_TYPE_THERMAL_SPIKE: 3.0, // high kinetic significance
CROSS_SOURCE_SIGNAL_TYPE_MILITARY_FLIGHT_SURGE: 3.0, // high kinetic significance
CROSS_SOURCE_SIGNAL_TYPE_OREF_ALERT_CLUSTER: 3.5, // active alert = direct threat
CROSS_SOURCE_SIGNAL_TYPE_RADIATION_ANOMALY: 3.5, // catastrophic potential
CROSS_SOURCE_SIGNAL_TYPE_GPS_JAMMING: 2.5, // active EW operation
CROSS_SOURCE_SIGNAL_TYPE_UNREST_SURGE: 2.5, // civil instability
CROSS_SOURCE_SIGNAL_TYPE_CYBER_ESCALATION: 2.5, // active APT operation
CROSS_SOURCE_SIGNAL_TYPE_EARTHQUAKE_SIGNIFICANT: 2.5, // immediate humanitarian
CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE: 2.5, // composite CII deterioration
CROSS_SOURCE_SIGNAL_TYPE_VIX_SPIKE: 2.0, // financial stress indicator
CROSS_SOURCE_SIGNAL_TYPE_COMMODITY_SHOCK: 2.0, // supply shock proxy
CROSS_SOURCE_SIGNAL_TYPE_SHIPPING_DISRUPTION: 2.0, // logistics/trade impact
CROSS_SOURCE_SIGNAL_TYPE_INFRASTRUCTURE_OUTAGE: 2.0, // operational disruption
CROSS_SOURCE_SIGNAL_TYPE_DISPLACEMENT_SURGE: 2.0, // humanitarian — lagging
CROSS_SOURCE_SIGNAL_TYPE_MARKET_STRESS: 2.0, // broad market indicator
CROSS_SOURCE_SIGNAL_TYPE_SANCTIONS_SURGE: 1.5, // policy action — slow burn
CROSS_SOURCE_SIGNAL_TYPE_WILDFIRE_ESCALATION: 1.5, // environmental — regional
CROSS_SOURCE_SIGNAL_TYPE_FORECAST_DETERIORATION: 1.5, // predictive — lower confidence
CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME: 1.5, // environmental — regional
CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION: 1.5, // sentiment — lagging
CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION: 2.0, // policy action — direct market impact
};
function scoreTier(score) {
if (score >= 3.5) return 'CROSS_SOURCE_SIGNAL_SEVERITY_CRITICAL';
if (score >= 2.5) return 'CROSS_SOURCE_SIGNAL_SEVERITY_HIGH';
if (score >= 1.5) return 'CROSS_SOURCE_SIGNAL_SEVERITY_MEDIUM';
return 'CROSS_SOURCE_SIGNAL_SEVERITY_LOW';
}
function safeNum(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
// ── Read all source keys in parallel via Upstash pipeline ─────────────────────
async function readAllSourceKeys() {
const { url, token } = getRedisCredentials();
const pipeline = SOURCE_KEYS.map(k => ['GET', k]);
const resp = await fetch(`${url}/pipeline`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(pipeline),
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) throw new Error(`Redis pipeline: HTTP ${resp.status}`);
const results = await resp.json();
const data = {};
for (let i = 0; i < SOURCE_KEYS.length; i++) {
const raw = results[i]?.result;
if (!raw) continue;
try { data[SOURCE_KEYS[i]] = JSON.parse(raw); } catch { /* skip malformed */ }
}
return data;
}
// ── Signal extractors (one per signal type) ───────────────────────────────────
function extractThermalSpike(d) {
const payload = d['thermal:escalation:v1'];
if (!payload) return [];
const clusters = Array.isArray(payload.clusters) ? payload.clusters : [];
const spikes = clusters.filter(c => c.status === 'spike' || (safeNum(c.anomalyScore) > 2));
if (spikes.length === 0) return [];
const signals = [];
for (const c of spikes.slice(0, 5)) {
const theater = normalizeTheater(c.region || c.name || '');
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_THERMAL_SPIKE'] * Math.min(3, safeNum(c.anomalyScore) || 1.5);
signals.push({
id: `thermal:${c.id || (c.name || c.region || 'unknown').replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_THERMAL_SPIKE',
theater,
summary: `Thermal spike detected: ${c.name || c.region || 'unknown'} — anomaly score ${safeNum(c.anomalyScore).toFixed(1)}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: safeNum(c.detectedAt) || Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals;
}
function extractGpsJamming(d) {
const payload = d['intelligence:gpsjam:v2'];
if (!payload) return [];
const hexes = Array.isArray(payload.hexes) ? payload.hexes : [];
const highHexes = hexes.filter(h => h.level === 'high' || (safeNum(h.npAvg) < 0.5 && safeNum(h.npAvg) > 0));
if (highHexes.length === 0) return [];
// Group by region
const regionMap = new Map();
for (const h of highHexes) {
const theater = normalizeTheater(h.region || '');
const existing = regionMap.get(theater) || { count: 0, theater };
existing.count += 1;
regionMap.set(theater, existing);
}
return [...regionMap.values()].slice(0, 3).map(({ theater, count }) => {
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_GPS_JAMMING'] * Math.min(2, 1 + count / 50);
return {
id: `gpsjam:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_GPS_JAMMING',
theater,
summary: `GPS jamming detected: ${count} high-interference hexagons in ${theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: safeNum(payload.fetchedAt) || Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}
function extractMilitaryFlightSurge(d) {
const payload = d['military:flights:v1'] || d['military:flights:stale:v1'];
if (!payload) return [];
const flights = Array.isArray(payload.flights) ? payload.flights : [];
if (flights.length < 5) return [];
// Group by callsign country prefix / region
const regionMap = new Map();
for (const f of flights) {
const theater = normalizeTheater(f.region || f.country || f.origin || '');
regionMap.set(theater, (regionMap.get(theater) || 0) + 1);
}
const signals = [];
for (const [theater, count] of regionMap) {
if (count < 3) continue;
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_MILITARY_FLIGHT_SURGE'] * Math.min(2, 1 + count / 20);
signals.push({
id: `mil-flights:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_MILITARY_FLIGHT_SURGE',
theater,
summary: `Military flight surge: ${count} active sorties tracked in ${theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: safeNum(payload.fetchedAt) || Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals.slice(0, 3);
}
function extractUnrestSurge(d) {
const payload = d['unrest:events:v1'];
if (!payload) return [];
const events = Array.isArray(payload.events) ? payload.events : (Array.isArray(payload) ? payload : []);
if (events.length === 0) return [];
const cutoff = Date.now() - 24 * 3600 * 1000;
const recent = events.filter(e => safeNum(e.date || e.timestamp || e.created_at) > cutoff || !e.date);
const regionMap = new Map();
for (const ev of recent) {
const theater = normalizeTheater(ev.region || ev.country || ev.location || '');
regionMap.set(theater, (regionMap.get(theater) || 0) + 1);
}
const signals = [];
for (const [theater, count] of regionMap) {
if (count < 3) continue;
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_UNREST_SURGE'] * Math.min(2, 1 + count / 10);
signals.push({
id: `unrest:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_UNREST_SURGE',
theater,
summary: `Unrest surge: ${count} events in ${theater} in past 24h`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals.slice(0, 3);
}
function extractOrefAlertCluster(d) {
const payload = d['intelligence:advisories-bootstrap:v1'];
if (!payload) return [];
const advisories = Array.isArray(payload.advisories) ? payload.advisories : [];
const critical = advisories.filter(a => String(a.level || '').toLowerCase() === 'do not travel');
if (critical.length === 0) return [];
return critical.slice(0, 3).map(a => {
const theater = normalizeTheater(a.region || a.country || '');
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_OREF_ALERT_CLUSTER'];
return {
id: `advisory:${(a.country || a.region || 'unknown').replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_OREF_ALERT_CLUSTER',
theater,
summary: `"Do Not Travel" advisory: ${a.country || a.region || 'unknown'}${a.reason ? `${a.reason}` : ''}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}
function extractVixSpike(d) {
const payload = d['market:stocks-bootstrap:v1'];
if (!payload) return [];
const quotes = Array.isArray(payload.quotes) ? payload.quotes : [];
const vix = quotes.find(q => q.symbol === '^VIX' || q.symbol === 'VIX' || q.display === 'VIX');
if (!vix || safeNum(vix.price) < 25) return [];
const vixVal = safeNum(vix.price);
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_VIX_SPIKE'] * (vixVal > 40 ? 2 : vixVal > 30 ? 1.5 : 1.2);
return [{
id: 'vix:global-markets',
type: 'CROSS_SOURCE_SIGNAL_TYPE_VIX_SPIKE',
theater: 'Global Markets',
summary: `VIX elevated at ${vixVal.toFixed(1)} — fear index signals market stress`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
}];
}
function extractCommodityShock(d) {
const payload = d['market:commodities-bootstrap:v1'];
if (!payload) return [];
const quotes = Array.isArray(payload.quotes) ? payload.quotes : [];
const signals = [];
for (const q of quotes) {
const change = safeNum(q.change);
if (Math.abs(change) < 5) continue;
const theater = (q.symbol === 'OIL' || q.symbol === 'CL=F' || q.display?.includes('Oil')) ? 'Persian Gulf' : 'Global Markets';
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_COMMODITY_SHOCK'] * Math.min(2, Math.abs(change) / 5);
signals.push({
id: `commodity:${(q.symbol || q.display || 'unknown').replace(/[^a-z0-9]/gi, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_COMMODITY_SHOCK',
theater,
summary: `Commodity shock: ${q.display || q.symbol} ${change > 0 ? '+' : ''}${change.toFixed(1)}% — ${Math.abs(change) > 10 ? 'extreme' : 'significant'} move`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals.slice(0, 3);
}
function extractCyberEscalation(d) {
const payload = d['cyber:threats-bootstrap:v2'];
if (!payload) return [];
const threats = Array.isArray(payload.threats) ? payload.threats : (Array.isArray(payload) ? payload : []);
const critical = threats.filter(t => t.severity === 'critical' || t.severity === 'high');
if (critical.length === 0) return [];
const regionMap = new Map();
for (const t of critical) {
const theater = normalizeTheater(t.targetCountry || t.region || t.country || '');
regionMap.set(theater, (regionMap.get(theater) || 0) + 1);
}
const signals = [];
for (const [theater, count] of regionMap) {
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_CYBER_ESCALATION'] * Math.min(2, 1 + count / 5);
signals.push({
id: `cyber:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_CYBER_ESCALATION',
theater,
summary: `Cyber escalation: ${count} critical/high threat${count > 1 ? 's' : ''} targeting ${theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals.slice(0, 2);
}
function extractShippingDisruption(d) {
const payload = d['supply_chain:shipping:v2'];
if (!payload) return [];
const routes = Array.isArray(payload.routes) ? payload.routes : (Array.isArray(payload) ? payload : []);
const disrupted = routes.filter(r => r.disrupted || r.status === 'disrupted' || safeNum(r.rerouting) > 10);
if (disrupted.length === 0) return [];
const theater = disrupted.some(r => String(r.name || '').toLowerCase().includes('red sea') || String(r.route || '').toLowerCase().includes('red sea'))
? 'Red Sea' : 'Global';
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_SHIPPING_DISRUPTION'] * Math.min(2, 1 + disrupted.length / 3);
return [{
id: `shipping:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_SHIPPING_DISRUPTION',
theater,
summary: `Shipping disruption: ${disrupted.length} route${disrupted.length > 1 ? 's' : ''} affected in ${theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
}];
}
function extractSanctionsSurge(d) {
const payload = d['sanctions:pressure:v1'];
if (!payload) return [];
const newCount = safeNum(payload.newEntryCount);
if (newCount < 5) return [];
const topCountry = (payload.countries || [])[0];
const theater = normalizeTheater(topCountry?.countryName || '');
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_SANCTIONS_SURGE'] * Math.min(2, 1 + newCount / 20);
return [{
id: `sanctions:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_SANCTIONS_SURGE',
theater,
summary: `Sanctions surge: ${newCount} new designations — ${topCountry?.countryName || 'multiple countries'} most targeted`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
}];
}
function extractEarthquakeSignificant(d) {
const payload = d['seismology:earthquakes:v1'];
if (!payload) return [];
const quakes = Array.isArray(payload.earthquakes) ? payload.earthquakes : (Array.isArray(payload) ? payload : []);
const significant = quakes.filter(q => safeNum(q.magnitude) >= 6.5);
if (significant.length === 0) return [];
return significant.slice(0, 2).map(q => {
const theater = normalizeTheater(q.place || q.region || q.country || '');
const mag = safeNum(q.magnitude);
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_EARTHQUAKE_SIGNIFICANT'] * (mag >= 7.5 ? 2 : mag >= 7.0 ? 1.5 : 1.2);
return {
id: `quake:${q.id || q.code || `${String(q.latitude || '0')}-${String(q.longitude || '0')}-${mag.toFixed(1)}`}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_EARTHQUAKE_SIGNIFICANT',
theater,
summary: `M${mag.toFixed(1)} earthquake — ${q.place || theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: safeNum(q.time) || safeNum(q.timestamp) || Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}
function extractRadiationAnomaly(d) {
const payload = d['radiation:observations:v1'];
if (!payload) return [];
const observations = Array.isArray(payload.observations) ? payload.observations : (Array.isArray(payload) ? payload : []);
const anomalies = observations.filter(o => o.alert || o.status === 'alert' || safeNum(o.value) > safeNum(o.threshold) * 1.5);
if (anomalies.length === 0) return [];
return anomalies.slice(0, 2).map(a => {
const locationStr = a.locationName || a.stationName || a.country || a.region || 'unknown station';
const theater = normalizeTheater(a.country || a.region || locationStr);
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_RADIATION_ANOMALY'];
return {
id: `radiation:${a.id || a.stationId || locationStr.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_RADIATION_ANOMALY',
theater,
summary: `Radiation anomaly: ${locationStr}${a.value || 'elevated'} reading`,
severity: scoreTier(score),
severityScore: score,
detectedAt: safeNum(a.timestamp) || safeNum(a.measuredAt) || Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}
function extractInfrastructureOutage(d) {
const payload = d['infra:outages:v1'];
if (!payload) return [];
const outages = Array.isArray(payload.outages) ? payload.outages : (Array.isArray(payload) ? payload : []);
const major = outages.filter(o => o.severity === 'major' || o.severity === 'critical' || safeNum(o.affectedUsers) > 100000);
if (major.length === 0) return [];
const regionMap = new Map();
for (const o of major) {
const theater = normalizeTheater(o.region || o.country || o.location || 'Global');
regionMap.set(theater, (regionMap.get(theater) || 0) + 1);
}
const signals = [];
for (const [theater, count] of regionMap) {
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_INFRASTRUCTURE_OUTAGE'] * Math.min(2, 1 + count / 3);
signals.push({
id: `outage:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_INFRASTRUCTURE_OUTAGE',
theater,
summary: `Infrastructure outage: ${count} major service failure${count > 1 ? 's' : ''} in ${theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals.slice(0, 2);
}
function extractWildfireEscalation(d) {
const payload = d['wildfire:fires:v1'];
if (!payload) return [];
const fires = Array.isArray(payload.fires) ? payload.fires : (Array.isArray(payload) ? payload : []);
const extreme = fires.filter(f => f.radiativePower > 5000 || f.severity === 'extreme' || safeNum(f.brightness) > 400);
if (extreme.length === 0) return [];
const regionMap = new Map();
for (const f of extreme) {
const theater = normalizeTheater(f.region || f.country || '');
regionMap.set(theater, (regionMap.get(theater) || 0) + 1);
}
const signals = [];
for (const [theater, count] of regionMap) {
if (count < 5) continue;
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_WILDFIRE_ESCALATION'] * Math.min(2, 1 + count / 50);
signals.push({
id: `wildfire:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_WILDFIRE_ESCALATION',
theater,
summary: `Wildfire escalation: ${count} extreme thermal detections in ${theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals.slice(0, 2);
}
function extractDisplacementSurge(d) {
const payload = d[`displacement:summary:v1:${new Date().getFullYear()}`];
if (!payload) return [];
const crises = Array.isArray(payload.crises) ? payload.crises : (Array.isArray(payload) ? payload : []);
const surges = crises.filter(c => safeNum(c.newDisplacements) > 50000 || c.trend === 'rising');
if (surges.length === 0) return [];
return surges.slice(0, 2).map(c => {
const theater = normalizeTheater(c.country || c.region || '');
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_DISPLACEMENT_SURGE'] * Math.min(2, 1 + safeNum(c.newDisplacements) / 100000);
return {
id: `displacement:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_DISPLACEMENT_SURGE',
theater,
summary: `Displacement surge: ${c.country || theater}${safeNum(c.newDisplacements).toLocaleString()} new displaced persons`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}
function extractForecastDeterioration(d) {
const payload = d['forecast:predictions:v2'];
if (!payload) return [];
const predictions = Array.isArray(payload.predictions) ? payload.predictions : (Array.isArray(payload) ? payload : []);
const deteriorating = predictions.filter(p => p.trend === 'deteriorating' || p.direction === 'negative' || safeNum(p.probability) > 0.65);
if (deteriorating.length === 0) return [];
return deteriorating.slice(0, 2).map(p => {
const theater = normalizeTheater(p.region || p.country || p.theater || '');
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_FORECAST_DETERIORATION'] * Math.min(2, 1 + safeNum(p.probability));
return {
id: `forecast:${p.id || (p.title || p.label || theater).replace(/\s+/g, '-').toLowerCase().slice(0, 40)}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_FORECAST_DETERIORATION',
theater,
summary: `Forecast deterioration: ${p.title || p.label || 'Geopolitical risk'}${Math.round(safeNum(p.probability) * 100)}% probability`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}
function extractMarketStress(d) {
const payload = d['market:stocks-bootstrap:v1'];
if (!payload) return [];
const quotes = Array.isArray(payload.quotes) ? payload.quotes : [];
const spx = quotes.find(q => q.symbol === '^GSPC' || q.symbol === 'SPX' || q.display === 'S&P 500');
if (!spx) return [];
const change = safeNum(spx.change);
if (Math.abs(change) < 2) return [];
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_MARKET_STRESS'] * Math.min(2, Math.abs(change) / 2);
return [{
id: 'market-stress:global',
type: 'CROSS_SOURCE_SIGNAL_TYPE_MARKET_STRESS',
theater: 'Global Markets',
summary: `Market stress: S&P 500 ${change > 0 ? '+' : ''}${change.toFixed(1)}% — ${Math.abs(change) > 4 ? 'extreme' : 'significant'} session move`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
}];
}
function extractWeatherExtreme(d) {
const payload = d['weather:alerts:v1'];
if (!payload) return [];
const alerts = Array.isArray(payload.alerts) ? payload.alerts : (Array.isArray(payload) ? payload : []);
const extreme = alerts.filter(a => a.severity === 'extreme' || a.category === 'extreme');
if (extreme.length === 0) return [];
const regionMap = new Map();
for (const a of extreme) {
const theater = normalizeTheater(a.area || a.country || a.region || '');
regionMap.set(theater, (regionMap.get(theater) || 0) + 1);
}
const signals = [];
for (const [theater, count] of regionMap) {
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME'] * Math.min(2, 1 + count / 5);
signals.push({
id: `weather:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME',
theater,
summary: `Extreme weather: ${count} active extreme alert${count > 1 ? 's' : ''} in ${theater}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
return signals.slice(0, 2);
}
const GDELT_TONE_TOPICS = ['military', 'nuclear', 'maritime'];
function extractMediaToneDeterioration(d) {
const signals = [];
for (const topic of GDELT_TONE_TOPICS) {
const tonePayload = d[`gdelt:intel:tone:${topic}`];
if (!tonePayload) continue;
const series = Array.isArray(tonePayload.data) ? tonePayload.data : [];
if (series.length < 3) continue;
const last3 = series.slice(-3);
const vals = last3.map(p => safeNum(p.value));
const isDeclining = vals[0] > vals[1] && vals[1] > vals[2];
const finalVal = vals[2];
if (!isDeclining || finalVal >= -1.5) continue;
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION'] * Math.min(2, Math.abs(finalVal) / 3);
signals.push({
id: `gdelt-tone:${topic}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION',
theater: topic === 'maritime' ? 'Indo-Pacific' : 'Global',
summary: `Media tone deterioration: ${topic} coverage tone ${finalVal.toFixed(2)} (3-point declining trend)`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
}
// Fallback: bundled gdelt-intel topics array if per-topic keys unavailable
if (signals.length === 0) {
const payload = d['intelligence:gdelt-intel:v1'];
const topics = Array.isArray(payload?.topics) ? payload.topics : [];
for (const topic of topics) {
const avgTone = safeNum(topic.avgTone || topic.tone);
if (avgTone > -3) continue;
const theater = normalizeTheater(topic.region || topic.country || '');
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION'] * Math.min(2, Math.abs(avgTone) / 3);
signals.push({
id: `gdelt-tone:${(topic.id || topic.label || 'unknown').replace(/\s+/g, '-').toLowerCase().slice(0, 40)}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION',
theater,
summary: `Media tone deterioration: "${topic.label || topic.topic}" avg tone ${avgTone.toFixed(1)}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
});
if (signals.length >= 2) break;
}
}
return signals.slice(0, 2);
}
function extractRiskScoreSpike(d) {
const payload = d['risk:scores:sebuf:stale:v1'];
if (!payload) return [];
const ciiScores = Array.isArray(payload.ciiScores) ? payload.ciiScores : [];
const spiking = ciiScores.filter(s => safeNum(s.combinedScore) > 80 || s.trend === 'TREND_DIRECTION_RISING');
if (spiking.length === 0) return [];
return spiking.slice(0, 3).map(s => {
const theater = normalizeTheater(s.region || '');
const score = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE'] * Math.min(2, safeNum(s.combinedScore) / 60);
return {
id: `risk:${(s.region || 'unknown').replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE',
theater,
summary: `Risk score spike: ${s.region || 'unknown'} CII score ${safeNum(s.combinedScore).toFixed(0)} — trend ${s.trend === 'TREND_DIRECTION_RISING' ? 'rising' : 'elevated'}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: Date.now(),
contributingTypes: [],
signalCount: 0,
};
});
}
function extractRegulatoryAction(d) {
const payload = d['regulatory:actions:v1'];
if (!payload) return [];
const cutoff = Date.now() - 48 * 3600 * 1000;
const tierPriority = { high: 0, medium: 1 };
const actions = Array.isArray(payload.actions) ? payload.actions : [];
const recent = actions
.map((action) => ({
action,
publishedAtTs: safeNum(Date.parse(action.publishedAt)),
}))
.filter(({ action, publishedAtTs }) => (action.tier === 'high' || action.tier === 'medium') && publishedAtTs > cutoff)
.sort((a, b) => {
const tierOrder = tierPriority[a.action.tier] - tierPriority[b.action.tier];
if (tierOrder !== 0) return tierOrder;
return b.publishedAtTs - a.publishedAtTs;
})
.slice(0, 3);
if (recent.length === 0) return [];
return recent.map(({ action, publishedAtTs }) => {
const tierMult = action.tier === 'high' ? 1.5 : 1.0;
const score = BASE_WEIGHT.CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION * tierMult;
return {
id: `regulatory:${action.id ?? 'unknown'}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION',
theater: 'Global Markets',
summary: `${action.agency ?? 'Unknown agency'}: ${action.title ?? 'No title'}`,
severity: scoreTier(score),
severityScore: score,
detectedAt: publishedAtTs,
contributingTypes: [],
signalCount: 0,
};
});
}
// ── Composite escalation detector ─────────────────────────────────────────────
// Fires when >=3 signals from DIFFERENT categories share the same theater.
function detectCompositeEscalation(signals) {
const theaterMap = new Map();
for (const sig of signals) {
if (sig.type === 'CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION') continue;
const category = TYPE_CATEGORY[sig.type] || 'other';
if (!theaterMap.has(sig.theater)) theaterMap.set(sig.theater, new Map());
const categoryMap = theaterMap.get(sig.theater);
if (!categoryMap.has(category)) categoryMap.set(category, []);
categoryMap.get(category).push(sig);
}
const composites = [];
for (const [theater, categoryMap] of theaterMap) {
if (categoryMap.size < 3) continue; // Need >=3 distinct categories
const contributingTypes = [];
let totalScore = 0;
let signalCount = 0;
for (const [, categorySigs] of categoryMap) {
for (const s of categorySigs) {
contributingTypes.push(s.type.replace('CROSS_SOURCE_SIGNAL_TYPE_', '').replace(/_/g, ' ').toLowerCase());
totalScore += s.severityScore;
signalCount++;
}
}
const compositeScore = BASE_WEIGHT['CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION'] * Math.min(3, 1 + categoryMap.size / 3) + totalScore * 0.2;
composites.push({
id: `composite:${theater.replace(/\s+/g, '-').toLowerCase()}`,
type: 'CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION',
theater,
summary: `Composite escalation in ${theater}: ${categoryMap.size} signal categories co-firing (${contributingTypes.slice(0, 4).join(', ')}${contributingTypes.length > 4 ? ' ...' : ''})`,
severity: scoreTier(compositeScore),
severityScore: compositeScore,
detectedAt: Date.now(),
contributingTypes: [...new Set(contributingTypes)],
signalCount,
});
}
return composites;
}
// ── Main aggregator ───────────────────────────────────────────────────────────
async function aggregateCrossSourceSignals() {
console.log(' Reading source keys...');
const sourceData = await readAllSourceKeys();
const foundKeys = Object.keys(sourceData);
const missingKeys = SOURCE_KEYS.filter(k => !foundKeys.includes(k));
console.log(` Found ${foundKeys.length}/${SOURCE_KEYS.length} source keys populated`);
if (missingKeys.length > 0) {
console.log(` Missing keys (${missingKeys.length}): ${missingKeys.join(', ')}`);
}
const allSignals = [];
// Run all extractors; each handles missing data gracefully (returns [])
const extractors = [
extractThermalSpike,
extractGpsJamming,
extractMilitaryFlightSurge,
extractUnrestSurge,
extractOrefAlertCluster,
extractVixSpike,
extractCommodityShock,
extractCyberEscalation,
extractShippingDisruption,
extractSanctionsSurge,
extractEarthquakeSignificant,
extractRadiationAnomaly,
extractInfrastructureOutage,
extractWildfireEscalation,
extractDisplacementSurge,
extractForecastDeterioration,
extractMarketStress,
extractWeatherExtreme,
extractMediaToneDeterioration,
extractRiskScoreSpike,
extractRegulatoryAction,
];
for (const extractor of extractors) {
try {
const extracted = extractor(sourceData);
allSignals.push(...extracted);
} catch (err) {
console.warn(` Extractor ${extractor.name} failed: ${err.message}`);
}
}
console.log(` Extracted ${allSignals.length} raw signals`);
// Detect composite escalation zones
const composites = detectCompositeEscalation(allSignals);
console.log(` Detected ${composites.length} composite escalation zone(s)`);
// Merge composites at the front, then sort remainder by severity desc, detectedAt desc
const sortedSignals = allSignals.sort((a, b) =>
(b.severityScore - a.severityScore) || (b.detectedAt - a.detectedAt)
);
const MAX_SIGNALS = 30;
const finalSignals = [...composites, ...sortedSignals].slice(0, MAX_SIGNALS);
return {
signals: finalSignals,
evaluatedAt: Date.now(),
compositeCount: composites.length,
};
}
function validate(data) {
// Allow publishing even if no signals fired (empty array is valid — no escalation)
return Array.isArray(data?.signals);
}
export function declareRecords(data) {
return Array.isArray(data?.signals) ? data.signals.length : 0;
}
runSeed('intelligence', 'cross-source-signals', CANONICAL_KEY, aggregateCrossSourceSignals, {
ttlSeconds: CACHE_TTL,
validateFn: validate,
sourceVersion: 'cross-source-v1',
recordCount: (data) => data.signals?.length ?? 0,
afterPublish: async (data) => {
const { url, token } = getRedisCredentials();
const metaKey = 'seed-meta:intelligence:cross-source-signals';
const meta = { fetchedAt: Date.now(), recordCount: data.signals?.length ?? 0 };
await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(['SET', metaKey, JSON.stringify(meta), 'EX', 86400 * 7]),
signal: AbortSignal.timeout(5_000),
}).catch(err => console.warn(` seed-meta write failed: ${err.message}`));
},
declareRecords,
schemaVersion: 1,
maxStaleMin: 30,
});