Files
intelligence-terminal/lib/delta/engine.mjs
Greg Scher 2d166c20e8 Remove remaining text truncation across delta engine, memory, and ideas
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>
2026-03-21 12:59:30 -04:00

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 };