SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
12.3 KB · 295 lines typescript
Raw Blame History
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