File size: 10,985 Bytes
fa9c65f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 | /**
* Geographic convergence core — dependency-free so it can run in the browser
* bundle, on Vercel Edge, and inside esbuild-bundled server handlers alike.
*
* Nothing here reaches for the DOM, bundler-injected module metadata, or the
* `@/` alias. The client wrapper lives in `src/services/geo-convergence.ts`;
* server callers construct their own `GeoConvergenceEngine` per request.
*/
import { haversineKm } from './geo-distance';
export { haversineKm } from './geo-distance';
export type GeoEventType = 'protest' | 'military_flight' | 'military_vessel' | 'earthquake';
/** Events older than this drop out of a cell. */
export const GEO_CONVERGENCE_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Distinct domains that must co-occur in one cell before it alerts. */
export const GEO_CONVERGENCE_THRESHOLD = 3;
/** Proximity lookups report cells with at least this many domains. */
export const GEO_NEARBY_MIN_TYPES = 2;
/** Reverse-geocode search radii, most specific first. */
export const GEO_CONFLICT_ZONE_RADIUS_KM = 300;
export const GEO_WATERWAY_RADIUS_KM = 200;
export const GEO_HOTSPOT_RADIUS_KM = 150;
export const GEO_EVENT_TYPE_LABELS: Record<GeoEventType, string> = {
protest: 'protests',
military_flight: 'military flights',
military_vessel: 'naval vessels',
earthquake: 'seismic activity',
};
/** Minimal shape every domain feed collapses to before ingestion. */
export interface GeoEventInput {
lat: number;
lon: number;
/** Epoch ms; falls back to the engine clock when omitted. */
time?: number;
}
export interface GeoConvergenceAlert {
cellId: string;
lat: number;
lon: number;
types: GeoEventType[];
totalEvents: number;
score: number;
}
export interface GeoCellEventSnapshot {
type: GeoEventType;
count: number;
/** Epoch ms. */
lastSeen: number;
}
export interface GeoCellSnapshot {
id: string;
lat: number;
lon: number;
/** Epoch ms of the first event that created the cell. */
firstSeen: number;
events: GeoCellEventSnapshot[];
}
export interface GeoNearbyConvergence {
score: number;
types: number;
}
/** Structural view of a named point (intel hotspot, strategic waterway). */
export interface GeoNamedPlace {
name: string;
lat: number;
lon: number;
}
/** Structural view of a conflict zone. `center` is GeoJSON order: [lon, lat]. */
export interface GeoConflictZoneCenter {
name: string;
center: readonly [number, number];
}
/** Named-place datasets injected by the caller; the core ships no geography. */
export interface GeoPlaceDatasets {
conflictZones?: readonly GeoConflictZoneCenter[];
waterways?: readonly GeoNamedPlace[];
hotspots?: readonly GeoNamedPlace[];
}
export function getCellId(lat: number, lon: number): string {
return `${Math.floor(lat)},${Math.floor(lon)}`;
}
/** 25 points per co-occurring domain, plus a volume boost capped at 25. */
export function scoreGeoCell(typeCount: number, totalEvents: number): number {
const typeScore = typeCount * 25;
const countBoost = Math.min(25, totalEvents * 2);
return Math.min(100, typeScore + countBoost);
}
/** Reverse-geocode a coordinate against caller-supplied named places. */
export function getLocationName(lat: number, lon: number, places: GeoPlaceDatasets = {}): string {
// Check conflict zones first (most relevant for convergence)
for (const zone of places.conflictZones ?? []) {
const [zoneLon, zoneLat] = zone.center;
const dist = haversineKm(lat, lon, zoneLat, zoneLon);
if (dist < GEO_CONFLICT_ZONE_RADIUS_KM) {
return zone.name.replace(' Conflict', '').replace(' Civil War', '');
}
}
// Check strategic waterways
for (const waterway of places.waterways ?? []) {
const dist = haversineKm(lat, lon, waterway.lat, waterway.lon);
if (dist < GEO_WATERWAY_RADIUS_KM) {
return waterway.name;
}
}
// Check intel hotspots (major cities)
let nearestHotspot: { name: string; dist: number } | null = null;
for (const hotspot of places.hotspots ?? []) {
const dist = haversineKm(lat, lon, hotspot.lat, hotspot.lon);
if (dist < GEO_HOTSPOT_RADIUS_KM && (!nearestHotspot || dist < nearestHotspot.dist)) {
nearestHotspot = { name: hotspot.name, dist };
}
}
if (nearestHotspot) {
// Return just the name - caller adds "in" prefix
return nearestHotspot.name;
}
// Regional fallback based on lat/lon ranges
if (lat >= 25 && lat <= 40 && lon >= 25 && lon <= 75) return 'Middle East';
if (lat >= 30 && lat <= 45 && lon >= 100 && lon <= 145) return 'East Asia';
if (lat >= -10 && lat <= 25 && lon >= 90 && lon <= 130) return 'Southeast Asia';
if (lat >= 35 && lat <= 70 && lon >= -10 && lon <= 40) return 'Europe';
if (lat >= 44 && lat <= 75 && lon >= 20 && lon <= 180) return 'Russia';
if (lat >= -35 && lat <= 35 && lon >= -20 && lon <= 55) return 'Africa';
if (lat >= 25 && lat <= 50 && lon >= -125 && lon <= -65) return 'North America';
if (lat >= -60 && lat <= 15 && lon >= -80 && lon <= -30) return 'South America';
return `${lat.toFixed(1)}°, ${lon.toFixed(1)}°`;
}
/**
* Structurally identical to the client's `CorrelationSignalCore`, restated here
* so the core does not depend on src/ types.
*/
export interface GeoConvergenceSignal {
id: string;
type: 'geo_convergence';
title: string;
description: string;
confidence: number;
timestamp: Date;
data: {
newsVelocity: number;
relatedTopics: GeoEventType[];
};
}
export interface GeoSignalOptions {
places?: GeoPlaceDatasets;
/** Defaults to `sig-<uuid>`; the client injects its shared id generator. */
generateId?: () => string;
now?: () => Date;
}
export function geoConvergenceToSignal(
alert: GeoConvergenceAlert,
options: GeoSignalOptions = {},
): GeoConvergenceSignal {
const typeDescriptions = alert.types.map((t) => GEO_EVENT_TYPE_LABELS[t]).join(', ');
const locationName = getLocationName(alert.lat, alert.lon, options.places ?? {});
return {
id: options.generateId ? options.generateId() : `sig-${crypto.randomUUID()}`,
type: 'geo_convergence',
title: `Geographic Convergence (${alert.types.length} types)`,
description: `${typeDescriptions} in ${locationName} - ${alert.totalEvents} events/24h`,
confidence: alert.score / 100,
timestamp: options.now ? options.now() : new Date(),
data: {
newsVelocity: alert.totalEvents,
relatedTopics: alert.types,
},
};
}
export interface GeoConvergenceEngineOptions {
windowMs?: number;
convergenceThreshold?: number;
nearbyMinTypes?: number;
/** Epoch-ms clock; injectable so pruning is deterministic under test. */
now?: () => number;
}
interface GeoCell {
id: string;
lat: number;
lon: number;
events: Map<GeoEventType, { count: number; lastSeen: number }>;
firstSeen: number;
}
/**
* Rolling one-degree grid of multi-domain activity. Each instance owns its own
* cells, so a server request can build a throwaway engine without touching the
* long-lived client one.
*/
export class GeoConvergenceEngine {
private readonly cells = new Map<string, GeoCell>();
private readonly windowMs: number;
private readonly convergenceThreshold: number;
private readonly nearbyMinTypes: number;
private readonly now: () => number;
constructor(options: GeoConvergenceEngineOptions = {}) {
this.windowMs = options.windowMs ?? GEO_CONVERGENCE_WINDOW_MS;
this.convergenceThreshold = options.convergenceThreshold ?? GEO_CONVERGENCE_THRESHOLD;
this.nearbyMinTypes = options.nearbyMinTypes ?? GEO_NEARBY_MIN_TYPES;
this.now = options.now ?? (() => Date.now());
}
ingest(lat: number, lon: number, type: GeoEventType, timestamp: number = this.now()): void {
const cellId = getCellId(lat, lon);
let cell = this.cells.get(cellId);
if (!cell) {
cell = {
id: cellId,
lat: Math.floor(lat) + 0.5,
lon: Math.floor(lon) + 0.5,
events: new Map(),
firstSeen: timestamp,
};
this.cells.set(cellId, cell);
}
const existing = cell.events.get(type);
cell.events.set(type, {
count: (existing?.count ?? 0) + 1,
lastSeen: timestamp,
});
}
ingestEvents(events: readonly GeoEventInput[], type: GeoEventType): void {
for (const e of events) {
this.ingest(e.lat, e.lon, type, e.time ?? this.now());
}
}
/**
* Emit one alert per cell that has reached the domain threshold, skipping any
* cell id already in `seenAlerts`. Newly alerted ids are added to that set.
*/
detect(seenAlerts: Set<string>): GeoConvergenceAlert[] {
this.prune();
const alerts: GeoConvergenceAlert[] = [];
for (const [cellId, cell] of this.cells) {
if (cell.events.size >= this.convergenceThreshold) {
if (seenAlerts.has(cellId)) continue;
const types = Array.from(cell.events.keys());
const totalEvents = Array.from(cell.events.values()).reduce((sum, d) => sum + d.count, 0);
alerts.push({
cellId,
lat: cell.lat,
lon: cell.lon,
types,
totalEvents,
score: scoreGeoCell(cell.events.size, totalEvents),
});
seenAlerts.add(cellId);
}
}
return alerts.sort((a, b) => b.score - a.score);
}
/** Strongest multi-domain cell within `radiusKm`, or null if there is none. */
alertsNear(lat: number, lon: number, radiusKm: number): GeoNearbyConvergence | null {
this.prune();
let maxScore = 0;
let maxTypes = 0;
for (const cell of this.cells.values()) {
const dist = haversineKm(lat, lon, cell.lat, cell.lon);
if (dist <= radiusKm && cell.events.size >= this.nearbyMinTypes) {
const types = cell.events.size;
const totalEvents = Array.from(cell.events.values()).reduce((sum, d) => sum + d.count, 0);
const score = scoreGeoCell(types, totalEvents);
if (score > maxScore) {
maxScore = score;
maxTypes = types;
}
}
}
return maxScore > 0 ? { score: maxScore, types: maxTypes } : null;
}
clear(): void {
this.cells.clear();
}
/** Live cell count — deliberately does not prune. */
cellCount(): number {
return this.cells.size;
}
/** Detached copy of the grid for debugging; deliberately does not prune. */
snapshot(): GeoCellSnapshot[] {
return Array.from(this.cells.values(), (cell) => ({
id: cell.id,
lat: cell.lat,
lon: cell.lon,
firstSeen: cell.firstSeen,
events: Array.from(cell.events, ([type, data]) => ({
type,
count: data.count,
lastSeen: data.lastSeen,
})),
}));
}
private prune(): void {
const cutoff = this.now() - this.windowMs;
for (const [cellId, cell] of this.cells) {
for (const [type, data] of cell.events) {
if (data.lastSeen < cutoff) {
cell.events.delete(type);
}
}
if (cell.events.size === 0) {
this.cells.delete(cellId);
}
}
}
}
|