The prior fix (753c676) only removed truncation at source ingestion and
alert formatting. Signals were still being cut to 120 chars in the delta
engine, 80 chars in memory snapshots, and 120 chars in the ideas LLM
context — so OSINT posts arrived at the alerter already truncated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
223 lines
10 KiB
JavaScript
223 lines
10 KiB
JavaScript
// Delta Engine v2 — compares two synthesized sweep results and produces structured changes
|
|
// Improvements: count metric thresholds, semantic TG dedup, configurable thresholds, null-safety
|
|
|
|
import { createHash } from 'crypto';
|
|
|
|
// ─── Default Thresholds ──────────────────────────────────────────────────────
|
|
// Override via config.delta.thresholds in crucix.config.mjs
|
|
|
|
const DEFAULT_NUMERIC_THRESHOLDS = {
|
|
vix: 5, // % change to flag
|
|
hy_spread: 5,
|
|
'10y2y': 10,
|
|
wti: 3,
|
|
brent: 3,
|
|
natgas: 5,
|
|
unemployment: 2,
|
|
fed_funds: 1,
|
|
'10y_yield': 3,
|
|
usd_index: 1,
|
|
mortgage: 2,
|
|
};
|
|
|
|
const DEFAULT_COUNT_THRESHOLDS = {
|
|
urgent_posts: 2, // need ±2 to matter (was 0 — any change)
|
|
thermal_total: 500, // ±500 detections (was 0 — +1 was noise)
|
|
air_total: 50, // ±50 aircraft
|
|
who_alerts: 1, // any new WHO alert matters
|
|
conflict_events: 5, // ±5 ACLED events
|
|
conflict_fatalities: 10, // ±10 fatalities
|
|
sdr_online: 3, // ±3 receivers
|
|
news_count: 5, // ±5 news items
|
|
sources_ok: 1, // any source going down matters
|
|
};
|
|
|
|
// ─── Metric Definitions ──────────────────────────────────────────────────────
|
|
|
|
const NUMERIC_METRICS = [
|
|
{ key: 'vix', extract: d => d.fred?.find(f => f.id === 'VIXCLS')?.value, label: 'VIX' },
|
|
{ key: 'hy_spread', extract: d => d.fred?.find(f => f.id === 'BAMLH0A0HYM2')?.value, label: 'HY Spread' },
|
|
{ key: '10y2y', extract: d => d.fred?.find(f => f.id === 'T10Y2Y')?.value, label: '10Y-2Y Spread' },
|
|
{ key: 'wti', extract: d => d.energy?.wti, label: 'WTI Crude' },
|
|
{ key: 'brent', extract: d => d.energy?.brent, label: 'Brent Crude' },
|
|
{ key: 'natgas', extract: d => d.energy?.natgas, label: 'Natural Gas' },
|
|
{ key: 'unemployment', extract: d => d.bls?.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE')?.value, label: 'Unemployment' },
|
|
{ key: 'fed_funds', extract: d => d.fred?.find(f => f.id === 'DFF')?.value, label: 'Fed Funds Rate' },
|
|
{ key: '10y_yield', extract: d => d.fred?.find(f => f.id === 'DGS10')?.value, label: '10Y Yield' },
|
|
{ key: 'usd_index', extract: d => d.fred?.find(f => f.id === 'DTWEXBGS')?.value, label: 'USD Index' },
|
|
{ key: 'mortgage', extract: d => d.fred?.find(f => f.id === 'MORTGAGE30US')?.value, label: '30Y Mortgage' },
|
|
];
|
|
|
|
const COUNT_METRICS = [
|
|
{ key: 'urgent_posts', extract: d => d.tg?.urgent?.length || 0, label: 'Urgent OSINT Posts' },
|
|
{ key: 'thermal_total', extract: d => d.thermal?.reduce((s, t) => s + t.det, 0) || 0, label: 'Thermal Detections' },
|
|
{ key: 'air_total', extract: d => d.air?.reduce((s, a) => s + a.total, 0) || 0, label: 'Air Activity' },
|
|
{ key: 'who_alerts', extract: d => d.who?.length || 0, label: 'WHO Alerts' },
|
|
{ key: 'conflict_events', extract: d => d.acled?.totalEvents || 0, label: 'Conflict Events' },
|
|
{ key: 'conflict_fatalities', extract: d => d.acled?.totalFatalities || 0, label: 'Conflict Fatalities' },
|
|
{ key: 'sdr_online', extract: d => d.sdr?.online || 0, label: 'SDR Receivers' },
|
|
{ key: 'news_count', extract: d => (d.news?.length ?? d.news?.count) || 0, label: 'News Items' },
|
|
{ key: 'sources_ok', extract: d => d.meta?.sourcesOk || 0, label: 'Sources OK' },
|
|
];
|
|
|
|
// Risk-sensitive keys: used for determining overall direction
|
|
const RISK_KEYS = ['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'];
|
|
|
|
// ─── Semantic Hashing for Telegram Posts ─────────────────────────────────────
|
|
|
|
/**
|
|
* Produce a normalized hash of a post's content.
|
|
* Strips timestamps, normalizes numbers, lowercases — so "BREAKING: 5 missiles at 14:32"
|
|
* and "Breaking: 7 missiles at 15:01" produce the same hash (both are "missile strike" signals).
|
|
*/
|
|
function contentHash(text) {
|
|
if (!text) return '';
|
|
const normalized = text
|
|
.toLowerCase()
|
|
.replace(/\d{1,2}:\d{2}(:\d{2})?/g, '') // strip times
|
|
.replace(/\d+/g, 'N') // normalize all numbers
|
|
.replace(/[^\w\s]/g, '') // strip punctuation
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.substring(0, 100);
|
|
return createHash('sha256').update(normalized).digest('hex').substring(0, 12);
|
|
}
|
|
|
|
// ─── Core Delta Computation ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @param {object} current - current sweep's synthesized data
|
|
* @param {object|null} previous - previous sweep's synthesized data (null on first run)
|
|
* @param {object} [thresholdOverrides] - optional: { numeric: {...}, count: {...} }
|
|
*/
|
|
export function computeDelta(current, previous, thresholdOverrides = {}) {
|
|
if (!previous) return null;
|
|
if (!current) return null;
|
|
|
|
const numThresholds = { ...DEFAULT_NUMERIC_THRESHOLDS, ...(thresholdOverrides.numeric || {}) };
|
|
const cntThresholds = { ...DEFAULT_COUNT_THRESHOLDS, ...(thresholdOverrides.count || {}) };
|
|
|
|
const signals = { new: [], escalated: [], deescalated: [], unchanged: [] };
|
|
let criticalChanges = 0;
|
|
|
|
// ─── Numeric metrics: track % change ─────────────────────────────────
|
|
|
|
for (const m of NUMERIC_METRICS) {
|
|
const curr = m.extract(current);
|
|
const prev = m.extract(previous);
|
|
if (curr == null || prev == null) continue;
|
|
|
|
const threshold = numThresholds[m.key] ?? 5;
|
|
const pctChange = prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : 0;
|
|
|
|
if (Math.abs(pctChange) > threshold) {
|
|
const entry = {
|
|
key: m.key, label: m.label, from: prev, to: curr,
|
|
pctChange: parseFloat(pctChange.toFixed(2)),
|
|
direction: pctChange > 0 ? 'up' : 'down',
|
|
severity: Math.abs(pctChange) > threshold * 3 ? 'critical' : Math.abs(pctChange) > threshold * 2 ? 'high' : 'moderate',
|
|
};
|
|
if (pctChange > 0) signals.escalated.push(entry);
|
|
else signals.deescalated.push(entry);
|
|
if (Math.abs(pctChange) > 10) criticalChanges++;
|
|
} else {
|
|
signals.unchanged.push(m.key);
|
|
}
|
|
}
|
|
|
|
// ─── Count metrics: track absolute change (with minimum thresholds) ──
|
|
|
|
for (const m of COUNT_METRICS) {
|
|
const curr = m.extract(current);
|
|
const prev = m.extract(previous);
|
|
const diff = curr - prev;
|
|
const threshold = cntThresholds[m.key] ?? 1;
|
|
|
|
if (Math.abs(diff) >= threshold) {
|
|
const pctChange = prev > 0 ? ((diff / prev) * 100) : (diff > 0 ? 100 : 0);
|
|
const entry = {
|
|
key: m.key, label: m.label, from: prev, to: curr,
|
|
change: diff, direction: diff > 0 ? 'up' : 'down',
|
|
pctChange: parseFloat(pctChange.toFixed(1)),
|
|
severity: Math.abs(diff) >= threshold * 5 ? 'critical' : Math.abs(diff) >= threshold * 2 ? 'high' : 'moderate',
|
|
};
|
|
if (diff > 0) signals.escalated.push(entry);
|
|
else signals.deescalated.push(entry);
|
|
// Count metrics only critical if the change is extreme
|
|
if (entry.severity === 'critical') criticalChanges++;
|
|
} else {
|
|
signals.unchanged.push(m.key);
|
|
}
|
|
}
|
|
|
|
// ─── New urgent Telegram posts (semantic dedup) ──────────────────────
|
|
|
|
const prevHashes = new Set(
|
|
(previous.tg?.urgent || []).map(p => contentHash(p.text))
|
|
);
|
|
|
|
for (const post of (current.tg?.urgent || [])) {
|
|
const hash = contentHash(post.text);
|
|
if (hash && !prevHashes.has(hash)) {
|
|
signals.new.push({
|
|
key: `tg_urgent:${hash}`,
|
|
text: post.text,
|
|
item: post,
|
|
reason: 'New urgent OSINT post',
|
|
});
|
|
criticalChanges++;
|
|
}
|
|
}
|
|
|
|
// ─── Nuclear anomaly state change ────────────────────────────────────
|
|
|
|
const currAnom = current.nuke?.some(n => n.anom) || false;
|
|
const prevAnom = previous.nuke?.some(n => n.anom) || false;
|
|
if (currAnom && !prevAnom) {
|
|
signals.new.push({ key: 'nuke_anomaly', reason: 'Nuclear anomaly detected', severity: 'critical' });
|
|
criticalChanges += 5;
|
|
} else if (!currAnom && prevAnom) {
|
|
signals.deescalated.push({ key: 'nuke_anomaly', label: 'Nuclear Anomaly', direction: 'resolved', severity: 'high' });
|
|
}
|
|
|
|
// ─── Source health degradation ───────────────────────────────────────
|
|
|
|
const currSourcesDown = current.health?.filter(s => s.err).length || 0;
|
|
const prevSourcesDown = previous.health?.filter(s => s.err).length || 0;
|
|
if (currSourcesDown > prevSourcesDown + 2) {
|
|
signals.new.push({
|
|
key: 'source_degradation',
|
|
reason: `${currSourcesDown - prevSourcesDown} additional sources failing (${currSourcesDown} total down)`,
|
|
severity: currSourcesDown > 5 ? 'critical' : 'moderate',
|
|
});
|
|
}
|
|
|
|
// ─── Overall direction ───────────────────────────────────────────────
|
|
|
|
let direction = 'mixed';
|
|
const riskUp = signals.escalated.filter(s => RISK_KEYS.includes(s.key)).length;
|
|
const riskDown = signals.deescalated.filter(s => RISK_KEYS.includes(s.key)).length;
|
|
if (riskUp > riskDown + 1) direction = 'risk-off';
|
|
else if (riskDown > riskUp + 1) direction = 'risk-on';
|
|
|
|
return {
|
|
timestamp: current.meta?.timestamp || new Date().toISOString(),
|
|
previous: previous.meta?.timestamp || null,
|
|
signals,
|
|
summary: {
|
|
totalChanges: signals.new.length + signals.escalated.length + signals.deescalated.length,
|
|
criticalChanges,
|
|
direction,
|
|
signalBreakdown: {
|
|
new: signals.new.length,
|
|
escalated: signals.escalated.length,
|
|
deescalated: signals.deescalated.length,
|
|
unchanged: signals.unchanged.length,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// Export thresholds for external config
|
|
export { DEFAULT_NUMERIC_THRESHOLDS, DEFAULT_COUNT_THRESHOLDS };
|