import { breakingState, jaccard, newId, shingles, slugify, velocityScore } from "@websensor/core"; import { db, eventClusters, events, gte, sql, textArray, type ClusterTimelineStep } from "@websensor/db"; /** * Novelty + clustering over a rolling in-memory window of recent events (loaded from * Postgres at startup). Similarity = Jaccard over word 3-shingles of title+summary. * * 2026-09-11: clusters now track propagation (spec §24, §35, §36): first-party vs external * signals, a timeline of every signal, velocity, lead time (first-party detection → first * external report) and a breaking state (spec §34). */ interface RecentEvent { id: string; clusterId: string | null; sourceId: string; sensorId: string; eventType: string; entityIds: string[]; detectedAt: number; sh: Set; importance: number; firstParty: boolean; } const WINDOW_MS = 72 * 3600e3; const CLUSTER_WINDOW_MS = 6 * 3600e3; let recent: RecentEvent[] = []; let loaded = false; export async function loadRecent(): Promise { const since = new Date(Date.now() - WINDOW_MS); const rows = await db.execute<{ id: string; cluster_id: string | null; source_id: string; sensor_id: string; event_type: string; title: string; summary: string; detected_at: Date; importance: number; first_party: boolean | null; entity_ids: string[] | null }>(sql` select e.id, e.cluster_id, e.source_id, e.sensor_id, e.event_type, e.title, e.summary, e.detected_at, e.importance, e.first_party, (select array_agg(entity_id) from event_entities ee where ee.event_id = e.id) as entity_ids from events e where e.detected_at >= ${since} order by e.detected_at desc limit 4000`); recent = rows.rows.map((r) => ({ id: r.id, clusterId: r.cluster_id, sourceId: r.source_id, sensorId: r.sensor_id, eventType: r.event_type, entityIds: r.entity_ids ?? [], detectedAt: new Date(r.detected_at).getTime(), sh: shingles(`${r.title}\n${r.summary}`), importance: r.importance, firstParty: r.first_party ?? true })); loaded = true; } function prune(): void { const cutoff = Date.now() - WINDOW_MS; recent = recent.filter((r) => r.detectedAt >= cutoff); } export interface NoveltyResult { novelty: number; nearest: { id: string; similarity: number } | null; /** number of distinct OTHER sources reporting near-identical content */ confirmations: number; /** among those, how many are first-party channels */ firstPartyConfirmations: number; } export async function assessNovelty(text: string, sourceId: string): Promise { if (!loaded) await loadRecent(); prune(); const sh = shingles(text); let best = 0; let nearest: RecentEvent | null = null; const confirming = new Map(); for (const r of recent) { const s = jaccard(sh, r.sh); if (s > best) { best = s; nearest = r; } if (s >= 0.45 && r.sourceId !== sourceId) confirming.set(r.sourceId, r.firstParty); } return { novelty: Math.round((1 - best) * 100), nearest: nearest ? { id: nearest.id, similarity: Math.round(best * 100) / 100 } : null, confirmations: confirming.size, firstPartyConfirmations: [...confirming.values()].filter(Boolean).length }; } export interface ClusterDecision { clusterId: string; slug: string; created: boolean; /** stats after this event was attached */ eventCount: number; sourceCount: number; firstPartyCount: number; externalCount: number; velocity: number; state: string; leadTimeMs: number | null; } /** * Attach to an existing open cluster when the event shares an entity (or the same source) * with a recent event and is textually related, or when it is the same event type on the * same source within 30 minutes (e.g. one launch touching six pages). Otherwise open one. */ export async function clusterEvent(ev: { id: string; sourceId: string; sourceName: string; sensorId: string; sensorType: string; eventType: string; entityIds: string[]; detectedAt: Date; title: string; summary: string; importance: number; signal: number; categories: string[]; firstParty: boolean; sourceTier?: string }): Promise { if (!loaded) await loadRecent(); const sh = shingles(`${ev.title}\n${ev.summary}`); const now = ev.detectedAt.getTime(); let bestCluster: string | null = null; let bestScore = 0; for (const r of recent) { if (!r.clusterId || now - r.detectedAt > CLUSTER_WINDOW_MS) continue; const sharedEntity = r.entityIds.some((e) => ev.entityIds.includes(e)); const sameSource = r.sourceId === ev.sourceId; if (!sharedEntity && !sameSource) continue; const sim = jaccard(sh, r.sh); const closeInTime = now - r.detectedAt < 30 * 60e3; let score = 0; if (sim >= 0.22) score = sim + (sharedEntity ? 0.2 : 0); else if (sameSource && r.eventType === ev.eventType && closeInTime && r.sensorId !== ev.sensorId) score = 0.3; else if (sameSource && closeInTime && sim >= 0.12) score = 0.25; // cross-source, shared entity, moderately similar and same broad type → the same story reported elsewhere else if (!sameSource && sharedEntity && sim >= 0.15 && now - r.detectedAt < 2 * 3600e3) score = 0.2 + sim; if (score > bestScore) { bestScore = score; bestCluster = r.clusterId; } } const step: ClusterTimelineStep = { at: ev.detectedAt.toISOString(), eventId: ev.id, sourceId: ev.sourceId, sourceName: ev.sourceName, sensorType: ev.sensorType, firstParty: ev.firstParty, eventType: ev.eventType, importance: ev.importance }; let clusterId: string; let slug: string; let created = false; let row: { event_count: number; source_count: number; first_party_count: number; external_count: number; first_at: Date; last_at: Date; first_party_at: Date | null; first_external_at: Date | null; lead_time_ms: number | null; slug: string | null; max_importance: number }; if (bestCluster) { clusterId = bestCluster; const r = await db.execute(sql` update event_clusters set event_count = event_count + 1, last_at = greatest(last_at, ${ev.detectedAt}), max_importance = greatest(max_importance, ${ev.importance}), entity_ids = (select array(select distinct unnest(entity_ids || ${textArray(ev.entityIds)}))), categories = (select array(select distinct unnest(categories || ${textArray(ev.categories)}))), title = case when ${ev.importance} > max_importance then ${ev.title} else title end, summary = case when ${ev.importance} > max_importance then ${ev.summary} else summary end, primary_event_id = case when ${ev.importance} > max_importance then ${ev.id} else primary_event_id end, source_count = (select count(distinct source_id) from events where cluster_id = ${clusterId}) + (case when exists (select 1 from events where cluster_id = ${clusterId} and source_id = ${ev.sourceId}) then 0 else 1 end), first_party_count = first_party_count + ${ev.firstParty ? 1 : 0}, external_count = external_count + ${ev.firstParty ? 0 : 1}, first_party_at = case when ${ev.firstParty} then least(coalesce(first_party_at, ${ev.detectedAt}), ${ev.detectedAt}) else first_party_at end, first_external_at = case when ${!ev.firstParty} then least(coalesce(first_external_at, ${ev.detectedAt}), ${ev.detectedAt}) else first_external_at end, timeline = (case when jsonb_array_length(timeline) < 200 then timeline || ${JSON.stringify([step])}::jsonb else timeline end) where id = ${clusterId} returning event_count, source_count, first_party_count, external_count, first_at, last_at, first_party_at, first_external_at, lead_time_ms, slug, max_importance`); row = r.rows[0]!; slug = row.slug ?? clusterId; } else { clusterId = newId("clu"); slug = `${slugify(ev.title).slice(0, 60)}-${clusterId.slice(-6)}`; created = true; await db.insert(eventClusters).values({ id: clusterId, slug, title: ev.title, summary: ev.summary, primaryEventId: ev.id, entityIds: ev.entityIds, categories: ev.categories, eventCount: 1, maxImportance: ev.importance, firstAt: ev.detectedAt, lastAt: ev.detectedAt, sourceCount: 1, firstPartyCount: ev.firstParty ? 1 : 0, externalCount: ev.firstParty ? 0 : 1, firstPartyAt: ev.firstParty ? ev.detectedAt : null, firstExternalAt: ev.firstParty ? null : ev.detectedAt, timeline: [step], state: "watching" }); row = { event_count: 1, source_count: 1, first_party_count: ev.firstParty ? 1 : 0, external_count: ev.firstParty ? 0 : 1, first_at: ev.detectedAt, last_at: ev.detectedAt, first_party_at: ev.firstParty ? ev.detectedAt : null, first_external_at: ev.firstParty ? null : ev.detectedAt, lead_time_ms: null, slug, max_importance: ev.importance }; } // Derived: velocity, lead time, state. const windowHours = Math.max(0.25, (new Date(row.last_at).getTime() - new Date(row.first_at).getTime()) / 3600e3); const velocity = velocityScore({ signals: row.event_count, windowHours: Math.min(windowHours, 6), uniqueSources: row.source_count, firstPartySignals: row.first_party_count }); const leadTimeMs = row.first_party_at && row.first_external_at ? new Date(row.first_external_at).getTime() - new Date(row.first_party_at).getTime() : null; const confirmations = Math.max(0, row.source_count - 1); const state = breakingState({ signal: ev.signal, importance: Math.max(ev.importance, row.max_importance), velocity, confirmations, firstPartyCount: row.first_party_count, ageMinutes: (Date.now() - new Date(row.first_at).getTime()) / 60e3, sourceTier: ev.sourceTier }); await db.execute(sql`update event_clusters set velocity = ${velocity}, state = ${state}, lead_time_ms = ${leadTimeMs} where id = ${clusterId}`); recent.unshift({ id: ev.id, clusterId, sourceId: ev.sourceId, sensorId: ev.sensorId, eventType: ev.eventType, entityIds: ev.entityIds, detectedAt: now, sh, importance: ev.importance, firstParty: ev.firstParty }); if (recent.length > 6000) recent.length = 6000; return { clusterId, slug, created, eventCount: row.event_count, sourceCount: row.source_count, firstPartyCount: row.first_party_count, externalCount: row.external_count, velocity, state, leadTimeMs }; } /** Recent events of the same source published through announcement-type sensors (for silent-change detection). */ export function recentAnnouncementSimilarity(sourceId: string, text: string, sinceMs: number): number { const sh = shingles(text); let best = 0; const cutoff = Date.now() - sinceMs; for (const r of recent) { if (r.sourceId !== sourceId || r.detectedAt < cutoff) continue; if (!/announcement|product_launch|model_release|software_release|repository_release|incident|outage|maintenance|security_advisory|service_launch|patch_release/.test(r.eventType)) continue; best = Math.max(best, jaccard(sh, r.sh)); } return best; } export async function recentClusterCount(sinceMs: number): Promise { const rows = await db.select({ n: sql`count(*)` }).from(eventClusters).where(gte(eventClusters.lastAt, new Date(Date.now() - sinceMs))); return Number(rows[0]?.n ?? 0); } /** Periodic: age out states (breaking → developing/confirmed → closed) for clusters that stopped receiving signals. */ export async function refreshClusterStates(): Promise { await db.execute(sql` update event_clusters set state = case when last_at < now() - interval '72 hours' then 'closed' when state = 'breaking' and first_at < now() - interval '6 hours' then (case when source_count >= 3 then 'confirmed' else 'developing' end) when state = 'developing' and first_at < now() - interval '12 hours' then (case when source_count >= 3 then 'confirmed' else 'watching' end) when state = 'confirmed' and last_at < now() - interval '24 hours' then 'watching' else state end where state <> 'closed' and last_at >= now() - interval '10 days'`); } export { events };