TypeScript 55.4%
Python 43.2%
SQL 1.2%
1import { eventTypeSpec } from "./taxonomy";23/**4 * Importance (0–100) with stored components:5 * 25 % intrinsic event severity · 20 % source importance · 15 % entity importance6 * 15 % novelty · 10 % magnitude · 5 % cross-source confirmation · 5 % user impact · 5 % unusualness7 */8export interface ImportanceInput {9 eventType: string;10 /** 0–100, from the source registry (tier + importance weight). */11 sourceImportance: number;12 /** 0–100 */13 entityImportance: number;14 /** 0–100 */15 novelty: number;16 /** 0–100 */17 magnitude: number;18 /** number of independent sensors/sources confirming within the cluster window */19 confirmations: number;20 /** 0–100 — how many people are plausibly affected (pricing/terms/API/outage high). */21 userImpact?: number;22 /** 0–100 — activity anomaly of the source at detection time. */23 unusualness?: number;24}2526export interface ImportanceComponents {27 severity: number;28 source: number;29 entity: number;30 novelty: number;31 magnitude: number;32 confirmation: number;33 userImpact: number;34 unusualness: number;35}3637const USER_IMPACT_BY_TYPE: Record<string, number> = {38 pricing_change: 85,39 terms_change: 75,40 policy_change: 70,41 API_change: 70,42 outage: 90,43 incident: 65,44 security_advisory: 80,45 vulnerability: 85,46 breach: 95,47 recall: 85,48 availability_change: 60,49 model_release: 70,50 product_launch: 60,51 monetary_policy: 90,52 economic_release: 70,53 drug_approval: 65,54 zero_day: 95,55 active_exploitation: 95,56 supply_chain_attack: 92,57 credential_leak: 88,58 ransomware: 85,59 malware_campaign: 75,60 patch_release: 70,61 service_shutdown: 80,62 feature_removed: 70,63 bankruptcy: 85,64 sanction: 80,65 emergency_notice: 92,66 outbreak: 90,67 drug_warning: 82,68 device_recall: 82,69 accident: 80,70 grounding: 85,71 legislation: 65,72 regulatory_action: 70,73 court_decision: 60,74 merger: 70,75 guidance: 65,76 crawler_policy_change: 45,77 documentation_change: 35,78 content_change: 25,79 page_removed: 40,80 sports_result: 15,81 sports_transaction: 25,82};8384export function computeImportance(i: ImportanceInput): { score: number; components: ImportanceComponents } {85 const c: ImportanceComponents = {86 severity: clamp(eventTypeSpec(i.eventType).severity),87 source: clamp(i.sourceImportance),88 entity: clamp(i.entityImportance),89 novelty: clamp(i.novelty),90 magnitude: clamp(i.magnitude),91 confirmation: clamp(Math.min(100, i.confirmations * 35)),92 userImpact: clamp(i.userImpact ?? USER_IMPACT_BY_TYPE[i.eventType] ?? 40),93 unusualness: clamp(i.unusualness ?? 30),94 };95 const score = 0.25 * c.severity + 0.2 * c.source + 0.15 * c.entity + 0.15 * c.novelty + 0.1 * c.magnitude + 0.05 * c.confirmation + 0.05 * c.userImpact + 0.05 * c.unusualness;96 return { score: round1(score), components: c };97}9899export interface ConfidenceInput {100 /** 0–1 how authentic the source is (official feed = 1, third-party = 0.5). */101 sourceAuthenticity: number;102 /** 0–1 extraction confidence (structured feed = 1, canonical HTML = 0.7, rendered = 0.6). */103 extraction: number;104 /** 0–1 how clean the diff is (low noise ratio = high). */105 diffClarity: number;106 /** structured data present (list/json diff) */107 structured: boolean;108 confirmations: number;109 /** 0–1 LLM agreement with heuristic type (1 if no LLM used but heuristics were strong). */110 llmAgreement: number;111}112113export function computeConfidence(c: ConfidenceInput): number {114 const s = 0.25 * c.sourceAuthenticity + 0.2 * c.extraction + 0.2 * c.diffClarity + 0.1 * (c.structured ? 1 : 0.5) + 0.1 * Math.min(1, c.confirmations / 2) + 0.15 * c.llmAgreement;115 return round1(clamp(s * 100));116}117118/** Tier → source importance base. */119export function sourceImportanceFromTier(tier: string, weight = 1): number {120 const base: Record<string, number> = { S: 92, A: 78, B: 62, C: 48, D: 35 };121 return clamp((base[tier] ?? 50) * weight);122}123124export function clamp(n: number, lo = 0, hi = 100): number {125 return Math.max(lo, Math.min(hi, Number.isFinite(n) ? n : lo));126}127export function round1(n: number): number {128 return Math.round(n * 10) / 10;129}130131/** Trending score for an entity over a window. */132export function trendingScore(input: { events: number; importanceSum: number; sources: number; prevEvents: number; silent: number }): number {133 const accel = input.prevEvents ? input.events / input.prevEvents : input.events ? 2 : 1;134 const s = 18 * Math.log2(1 + input.events) + 0.35 * (input.importanceSum / Math.max(1, input.events)) + 10 * Math.log2(1 + input.sources) + 12 * Math.min(2, accel) + 5 * Math.min(3, input.silent);135 return round1(clamp(s));136}137138/** Activity anomaly for a source: current rate vs baseline rate → 0–100. */139export function activityAnomaly(currentPerHour: number, baselinePerHour: number): number {140 if (baselinePerHour <= 0) return currentPerHour > 2 ? 70 : currentPerHour > 0 ? 40 : 0;141 const ratio = currentPerHour / baselinePerHour;142 if (ratio <= 1) return round1(clamp(ratio * 30));143 return round1(clamp(30 + 25 * Math.log2(ratio)));144}145146/** Daily-count anomaly (entity pages): today's count vs a 30-day baseline of daily counts → { score, ratio, pct }. */147export function dailyAnomaly(today: number, baselinePerDay: number, hoursElapsed = 24): { score: number; ratio: number; pct: number } {148 const expected = Math.max(0.2, baselinePerDay) * Math.max(1 / 24, Math.min(1, hoursElapsed / 24));149 const ratio = today / expected;150 const pct = Math.round((ratio - 1) * 100);151 let score = 0;152 if (baselinePerDay <= 0) score = today >= 5 ? 75 : today >= 2 ? 45 : today ? 20 : 0;153 else if (ratio <= 1) score = ratio * 30;154 else score = Math.min(100, 30 + 22 * Math.log2(ratio));155 return { score: round1(clamp(score)), ratio: Math.round(ratio * 100) / 100, pct };156}157158// ---------------------------------------------------------------------------------------------159// 2026-09-11 — impact, velocity, WebSensor Signal Score, breaking state, explanations160// ---------------------------------------------------------------------------------------------161162export interface ImpactInput {163 eventType: string;164 magnitude: number;165 entityImportance: number;166 /** largest absolute relative change among extracted numeric fields, in % */167 maxDeltaPct?: number | null;168 /** number of field-level changes (prices, statuses, versions…) */169 fieldChanges?: number;170 firstParty: boolean;171}172173/** Impact 0–100: who and how much is plausibly affected (spec §49, §92). */174export function computeImpact(i: ImpactInput): number {175 const base = USER_IMPACT_BY_TYPE[i.eventType] ?? 40;176 let s = 0.55 * base + 0.2 * clamp(i.entityImportance) + 0.15 * clamp(i.magnitude);177 const d = Math.abs(i.maxDeltaPct ?? 0);178 if (d >= 100) s += 18;179 else if (d >= 40) s += 12;180 else if (d >= 15) s += 7;181 else if (d >= 5) s += 3;182 if ((i.fieldChanges ?? 0) >= 3) s += 4;183 if (!i.firstParty) s -= 8;184 return round1(clamp(s));185}186187/** Velocity 0–100 from signals observed over a window (spec §35, §37). */188export function velocityScore(input: { signals: number; windowHours: number; uniqueSources: number; firstPartySignals: number }): number {189 const perHour = input.signals / Math.max(0.25, input.windowHours);190 let s = 22 * Math.log2(1 + perHour) + 12 * Math.log2(1 + input.uniqueSources) + 6 * Math.min(3, input.firstPartySignals);191 if (input.signals <= 1) s = Math.min(s, 15);192 return round1(clamp(s));193}194195export interface SignalInput {196 importance: number;197 confidence: number;198 novelty: number;199 velocity: number;200 impact: number;201 anomaly: number;202 confirmations: number;203 firstParty: boolean;204 silent: boolean;205 changeClass?: string | null;206 sourceTier?: string;207 evidenceLabel?: string;208}209210export interface ScoreReason {211 sign: "+" | "-";212 text: string;213 points?: number;214}215216/**217 * WebSensor Signal Score 0–100 (spec §106–107): "how worthy of immediate attention is this event?".218 * Deterministic, explainable — the reasons list is stored with the event.219 */220export function computeSignalScore(i: SignalInput): { score: number; reasons: ScoreReason[] } {221 const reasons: ScoreReason[] = [];222 let s = 0.42 * clamp(i.importance) + 0.14 * clamp(i.confidence) + 0.14 * clamp(i.novelty) + 0.12 * clamp(i.velocity) + 0.12 * clamp(i.impact) + 0.06 * clamp(i.anomaly);223 if (i.importance >= 80) reasons.push({ sign: "+", text: "high intrinsic importance", points: 10 });224 if (i.firstParty) {225 s += 4;226 reasons.push({ sign: "+", text: "first-party evidence (the organization's own channel)", points: 4 });227 } else {228 s -= 6;229 reasons.push({ sign: "-", text: "third-party report, not the organization's own channel", points: -6 });230 }231 if (i.confirmations >= 1) {232 const p = Math.min(12, 4 * i.confirmations);233 s += p;234 reasons.push({ sign: "+", text: `${i.confirmations} independent confirmation${i.confirmations === 1 ? "" : "s"}`, points: p });235 }236 if (i.silent) {237 s += 3;238 reasons.push({ sign: "+", text: "silent change (no matching announcement)", points: 3 });239 }240 if (i.velocity >= 50) reasons.push({ sign: "+", text: "story spreading fast across sources", points: 6 });241 if (i.anomaly >= 60) reasons.push({ sign: "+", text: "activity far above the source's baseline", points: 4 });242 if (i.impact >= 75) reasons.push({ sign: "+", text: "broad user impact (pricing / outage / security)", points: 5 });243 if (i.confidence < 50) {244 s -= 8;245 reasons.push({ sign: "-", text: "low classification confidence", points: -8 });246 }247 if (i.evidenceLabel === "UNCONFIRMED") {248 s -= 5;249 reasons.push({ sign: "-", text: "unconfirmed — inferred from a single source", points: -5 });250 }251 if (i.changeClass && ["cosmetic", "navigation", "timestamp", "advertisement", "boilerplate"].includes(i.changeClass)) {252 s = Math.min(s, 20);253 reasons.push({ sign: "-", text: `classified as ${i.changeClass} noise`, points: -30 });254 }255 if (i.sourceTier === "S") reasons.push({ sign: "+", text: "tier S source (critical infrastructure / authority)", points: 3 });256 if (i.sourceTier === "D") {257 s -= 4;258 reasons.push({ sign: "-", text: "low-urgency reference source", points: -4 });259 }260 return { score: round1(clamp(s)), reasons };261}262263/**264 * Breaking is not "recent" (spec §34): it depends on signal, velocity, novelty, confirmation and age.265 */266export function breakingState(i: { signal: number; importance: number; velocity: number; confirmations: number; firstPartyCount: number; ageMinutes: number; sourceTier?: string }): "breaking" | "developing" | "confirmed" | "watching" | "closed" {267 if (i.ageMinutes > 72 * 60) return "closed";268 const strong = i.signal >= 78 || i.importance >= 85;269 // Breaking needs corroboration or a critical (tier S) first-party source — one ordinary first-party signal is "watching".270 if (strong && i.ageMinutes <= 6 * 60 && (i.confirmations >= 1 || (i.firstPartyCount >= 1 && i.sourceTier === "S") || i.signal >= 90)) return "breaking";271 if (i.confirmations >= 2 && i.ageMinutes <= 24 * 60) return "confirmed";272 if ((i.signal >= 60 || i.velocity >= 35) && i.ageMinutes <= 12 * 60) return "developing";273 return "watching";274}275276/** Entity ranking score (spec §105): importance × activity × velocity × quality × confirmation — not raw counts. */277export function entityRankScore(i: { importance: number; events24h: number; events7d: number; avgSignal: number; confirmedRatio: number; uniqueSources: number; baselinePerDay: number }): number {278 const activity = Math.log2(1 + i.events24h) * 10 + Math.log2(1 + i.events7d) * 3;279 const accel = i.baselinePerDay > 0 ? Math.min(3, i.events24h / i.baselinePerDay) : i.events24h ? 2 : 0;280 const s = 0.3 * clamp(i.importance) + 0.25 * clamp(activity) + 0.2 * clamp(i.avgSignal) + 0.1 * clamp(i.confirmedRatio * 100) + 0.1 * clamp(Math.log2(1 + i.uniqueSources) * 25) + 0.05 * clamp(accel * 33);281 return round1(clamp(s));282}283284/** Human explanation of the 8 importance components (transparency panel). */285export const IMPORTANCE_WEIGHTS: { key: keyof ImportanceComponents; label: string; weight: number }[] = [286 { key: "severity", label: "Intrinsic event severity", weight: 25 },287 { key: "source", label: "Source authority", weight: 20 },288 { key: "entity", label: "Entity importance", weight: 15 },289 { key: "novelty", label: "Novelty", weight: 15 },290 { key: "magnitude", label: "Magnitude of change", weight: 10 },291 { key: "confirmation", label: "Cross-source confirmation", weight: 5 },292 { key: "userImpact", label: "User impact", weight: 5 },293 { key: "unusualness", label: "Unusualness", weight: 5 },294];295