mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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
895 lines
37 KiB
JavaScript
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,
|
|
});
|