import { eventTypeSpec } from "./taxonomy"; /** * Importance (0–100) with stored components: * 25 % intrinsic event severity · 20 % source importance · 15 % entity importance * 15 % novelty · 10 % magnitude · 5 % cross-source confirmation · 5 % user impact · 5 % unusualness */ export interface ImportanceInput { eventType: string; /** 0–100, from the source registry (tier + importance weight). */ sourceImportance: number; /** 0–100 */ entityImportance: number; /** 0–100 */ novelty: number; /** 0–100 */ magnitude: number; /** number of independent sensors/sources confirming within the cluster window */ confirmations: number; /** 0–100 — how many people are plausibly affected (pricing/terms/API/outage high). */ userImpact?: number; /** 0–100 — activity anomaly of the source at detection time. */ unusualness?: number; } export interface ImportanceComponents { severity: number; source: number; entity: number; novelty: number; magnitude: number; confirmation: number; userImpact: number; unusualness: number; } const USER_IMPACT_BY_TYPE: Record = { pricing_change: 85, terms_change: 75, policy_change: 70, API_change: 70, outage: 90, incident: 65, security_advisory: 80, vulnerability: 85, breach: 95, recall: 85, availability_change: 60, model_release: 70, product_launch: 60, monetary_policy: 90, economic_release: 70, drug_approval: 65, zero_day: 95, active_exploitation: 95, supply_chain_attack: 92, credential_leak: 88, ransomware: 85, malware_campaign: 75, patch_release: 70, service_shutdown: 80, feature_removed: 70, bankruptcy: 85, sanction: 80, emergency_notice: 92, outbreak: 90, drug_warning: 82, device_recall: 82, accident: 80, grounding: 85, legislation: 65, regulatory_action: 70, court_decision: 60, merger: 70, guidance: 65, crawler_policy_change: 45, documentation_change: 35, content_change: 25, page_removed: 40, sports_result: 15, sports_transaction: 25, }; export function computeImportance(i: ImportanceInput): { score: number; components: ImportanceComponents } { const c: ImportanceComponents = { severity: clamp(eventTypeSpec(i.eventType).severity), source: clamp(i.sourceImportance), entity: clamp(i.entityImportance), novelty: clamp(i.novelty), magnitude: clamp(i.magnitude), confirmation: clamp(Math.min(100, i.confirmations * 35)), userImpact: clamp(i.userImpact ?? USER_IMPACT_BY_TYPE[i.eventType] ?? 40), unusualness: clamp(i.unusualness ?? 30), }; 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; return { score: round1(score), components: c }; } export interface ConfidenceInput { /** 0–1 how authentic the source is (official feed = 1, third-party = 0.5). */ sourceAuthenticity: number; /** 0–1 extraction confidence (structured feed = 1, canonical HTML = 0.7, rendered = 0.6). */ extraction: number; /** 0–1 how clean the diff is (low noise ratio = high). */ diffClarity: number; /** structured data present (list/json diff) */ structured: boolean; confirmations: number; /** 0–1 LLM agreement with heuristic type (1 if no LLM used but heuristics were strong). */ llmAgreement: number; } export function computeConfidence(c: ConfidenceInput): number { 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; return round1(clamp(s * 100)); } /** Tier → source importance base. */ export function sourceImportanceFromTier(tier: string, weight = 1): number { const base: Record = { S: 92, A: 78, B: 62, C: 48, D: 35 }; return clamp((base[tier] ?? 50) * weight); } export function clamp(n: number, lo = 0, hi = 100): number { return Math.max(lo, Math.min(hi, Number.isFinite(n) ? n : lo)); } export function round1(n: number): number { return Math.round(n * 10) / 10; } /** Trending score for an entity over a window. */ export function trendingScore(input: { events: number; importanceSum: number; sources: number; prevEvents: number; silent: number }): number { const accel = input.prevEvents ? input.events / input.prevEvents : input.events ? 2 : 1; 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); return round1(clamp(s)); } /** Activity anomaly for a source: current rate vs baseline rate → 0–100. */ export function activityAnomaly(currentPerHour: number, baselinePerHour: number): number { if (baselinePerHour <= 0) return currentPerHour > 2 ? 70 : currentPerHour > 0 ? 40 : 0; const ratio = currentPerHour / baselinePerHour; if (ratio <= 1) return round1(clamp(ratio * 30)); return round1(clamp(30 + 25 * Math.log2(ratio))); } /** Daily-count anomaly (entity pages): today's count vs a 30-day baseline of daily counts → { score, ratio, pct }. */ export function dailyAnomaly(today: number, baselinePerDay: number, hoursElapsed = 24): { score: number; ratio: number; pct: number } { const expected = Math.max(0.2, baselinePerDay) * Math.max(1 / 24, Math.min(1, hoursElapsed / 24)); const ratio = today / expected; const pct = Math.round((ratio - 1) * 100); let score = 0; if (baselinePerDay <= 0) score = today >= 5 ? 75 : today >= 2 ? 45 : today ? 20 : 0; else if (ratio <= 1) score = ratio * 30; else score = Math.min(100, 30 + 22 * Math.log2(ratio)); return { score: round1(clamp(score)), ratio: Math.round(ratio * 100) / 100, pct }; } // --------------------------------------------------------------------------------------------- // 2026-09-11 — impact, velocity, WebSensor Signal Score, breaking state, explanations // --------------------------------------------------------------------------------------------- export interface ImpactInput { eventType: string; magnitude: number; entityImportance: number; /** largest absolute relative change among extracted numeric fields, in % */ maxDeltaPct?: number | null; /** number of field-level changes (prices, statuses, versions…) */ fieldChanges?: number; firstParty: boolean; } /** Impact 0–100: who and how much is plausibly affected (spec §49, §92). */ export function computeImpact(i: ImpactInput): number { const base = USER_IMPACT_BY_TYPE[i.eventType] ?? 40; let s = 0.55 * base + 0.2 * clamp(i.entityImportance) + 0.15 * clamp(i.magnitude); const d = Math.abs(i.maxDeltaPct ?? 0); if (d >= 100) s += 18; else if (d >= 40) s += 12; else if (d >= 15) s += 7; else if (d >= 5) s += 3; if ((i.fieldChanges ?? 0) >= 3) s += 4; if (!i.firstParty) s -= 8; return round1(clamp(s)); } /** Velocity 0–100 from signals observed over a window (spec §35, §37). */ export function velocityScore(input: { signals: number; windowHours: number; uniqueSources: number; firstPartySignals: number }): number { const perHour = input.signals / Math.max(0.25, input.windowHours); let s = 22 * Math.log2(1 + perHour) + 12 * Math.log2(1 + input.uniqueSources) + 6 * Math.min(3, input.firstPartySignals); if (input.signals <= 1) s = Math.min(s, 15); return round1(clamp(s)); } export interface SignalInput { importance: number; confidence: number; novelty: number; velocity: number; impact: number; anomaly: number; confirmations: number; firstParty: boolean; silent: boolean; changeClass?: string | null; sourceTier?: string; evidenceLabel?: string; } export interface ScoreReason { sign: "+" | "-"; text: string; points?: number; } /** * WebSensor Signal Score 0–100 (spec §106–107): "how worthy of immediate attention is this event?". * Deterministic, explainable — the reasons list is stored with the event. */ export function computeSignalScore(i: SignalInput): { score: number; reasons: ScoreReason[] } { const reasons: ScoreReason[] = []; 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); if (i.importance >= 80) reasons.push({ sign: "+", text: "high intrinsic importance", points: 10 }); if (i.firstParty) { s += 4; reasons.push({ sign: "+", text: "first-party evidence (the organization's own channel)", points: 4 }); } else { s -= 6; reasons.push({ sign: "-", text: "third-party report, not the organization's own channel", points: -6 }); } if (i.confirmations >= 1) { const p = Math.min(12, 4 * i.confirmations); s += p; reasons.push({ sign: "+", text: `${i.confirmations} independent confirmation${i.confirmations === 1 ? "" : "s"}`, points: p }); } if (i.silent) { s += 3; reasons.push({ sign: "+", text: "silent change (no matching announcement)", points: 3 }); } if (i.velocity >= 50) reasons.push({ sign: "+", text: "story spreading fast across sources", points: 6 }); if (i.anomaly >= 60) reasons.push({ sign: "+", text: "activity far above the source's baseline", points: 4 }); if (i.impact >= 75) reasons.push({ sign: "+", text: "broad user impact (pricing / outage / security)", points: 5 }); if (i.confidence < 50) { s -= 8; reasons.push({ sign: "-", text: "low classification confidence", points: -8 }); } if (i.evidenceLabel === "UNCONFIRMED") { s -= 5; reasons.push({ sign: "-", text: "unconfirmed — inferred from a single source", points: -5 }); } if (i.changeClass && ["cosmetic", "navigation", "timestamp", "advertisement", "boilerplate"].includes(i.changeClass)) { s = Math.min(s, 20); reasons.push({ sign: "-", text: `classified as ${i.changeClass} noise`, points: -30 }); } if (i.sourceTier === "S") reasons.push({ sign: "+", text: "tier S source (critical infrastructure / authority)", points: 3 }); if (i.sourceTier === "D") { s -= 4; reasons.push({ sign: "-", text: "low-urgency reference source", points: -4 }); } return { score: round1(clamp(s)), reasons }; } /** * Breaking is not "recent" (spec §34): it depends on signal, velocity, novelty, confirmation and age. */ export function breakingState(i: { signal: number; importance: number; velocity: number; confirmations: number; firstPartyCount: number; ageMinutes: number; sourceTier?: string }): "breaking" | "developing" | "confirmed" | "watching" | "closed" { if (i.ageMinutes > 72 * 60) return "closed"; const strong = i.signal >= 78 || i.importance >= 85; // Breaking needs corroboration or a critical (tier S) first-party source — one ordinary first-party signal is "watching". if (strong && i.ageMinutes <= 6 * 60 && (i.confirmations >= 1 || (i.firstPartyCount >= 1 && i.sourceTier === "S") || i.signal >= 90)) return "breaking"; if (i.confirmations >= 2 && i.ageMinutes <= 24 * 60) return "confirmed"; if ((i.signal >= 60 || i.velocity >= 35) && i.ageMinutes <= 12 * 60) return "developing"; return "watching"; } /** Entity ranking score (spec §105): importance × activity × velocity × quality × confirmation — not raw counts. */ export function entityRankScore(i: { importance: number; events24h: number; events7d: number; avgSignal: number; confirmedRatio: number; uniqueSources: number; baselinePerDay: number }): number { const activity = Math.log2(1 + i.events24h) * 10 + Math.log2(1 + i.events7d) * 3; const accel = i.baselinePerDay > 0 ? Math.min(3, i.events24h / i.baselinePerDay) : i.events24h ? 2 : 0; 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); return round1(clamp(s)); } /** Human explanation of the 8 importance components (transparency panel). */ export const IMPORTANCE_WEIGHTS: { key: keyof ImportanceComponents; label: string; weight: number }[] = [ { key: "severity", label: "Intrinsic event severity", weight: 25 }, { key: "source", label: "Source authority", weight: 20 }, { key: "entity", label: "Entity importance", weight: 15 }, { key: "novelty", label: "Novelty", weight: 15 }, { key: "magnitude", label: "Magnitude of change", weight: 10 }, { key: "confirmation", label: "Cross-source confirmation", weight: 5 }, { key: "userImpact", label: "User impact", weight: 5 }, { key: "unusualness", label: "Unusualness", weight: 5 }, ];