import type { SeriesPoint, Signal, SignalSeverity, SnapshotPayload, StatsSettings } from '$lib/types/stats'; // ── generic anomaly detection ──────────────────────────────────────────────── export interface Anomaly { date: string; value: number; median: number; score: number; } /** * Robust, weekday-aware anomaly detection. Compares the latest point with the * median/MAD of the same weekday over the preceding `lookback` points. * Returns null when there is not enough history or the series is flat. */ export function detectAnomaly(series: SeriesPoint[], threshold: number, lookback = 28): Anomaly | null { const points = series.filter((p) => p.value !== null) as { date: string; value: number }[]; if (points.length < 5) return null; const latest = points[points.length - 1]; const weekday = new Date(latest.date + 'T00:00:00Z').getUTCDay(); const history = points .slice(0, -1) .filter((p) => new Date(p.date + 'T00:00:00Z').getUTCDay() === weekday) .slice(-lookback); if (history.length < 4) return null; const values = history.map((p) => p.value).sort((a, b) => a - b); const median = medianOf(values); const deviations = values.map((v) => Math.abs(v - median)).sort((a, b) => a - b); const mad = medianOf(deviations); if (mad === 0) return null; const score = (0.6745 * (latest.value - median)) / mad; if (Math.abs(score) < threshold) return null; return { date: latest.date, value: latest.value, median: Math.round(median * 10) / 10, score: Math.round(score * 10) / 10 }; } function medianOf(sorted: number[]): number { if (sorted.length === 0) return 0; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } // ── rule helpers ───────────────────────────────────────────────────────────── type Direction = 'up' | 'down'; interface TargetRule { id: string; title: string; action: string; value: number | null; target: number; /** At or above target is good when direction is 'up'. */ direction?: Direction; /** Absolute value that means "red" regardless of the target. */ red: number; unit?: string; } function evaluateTarget(rule: TargetRule): Signal { const { id, title, action, value, target, red, direction = 'up', unit = '%' } = rule; if (value === null) { return { id, severity: 'info', title, detail: `Not enough data yet to measure this (needs instrumented events).`, metric: id, value: null, target }; } const meets = direction === 'up' ? value >= target : value <= target; const isRed = direction === 'up' ? value < red : value > red; let severity: SignalSeverity = 'good'; if (!meets) severity = isRed ? 'bad' : 'warn'; const comparison = direction === 'up' ? value >= target ? `at or above the ${target}% target` : `below the ${target}% target` : value <= target ? `at or below the ${target}% target` : `above the ${target}% target`; return { id, severity, title, detail: `${title} is ${value}${unit}, ${comparison}.`, action: severity === 'good' ? undefined : action, metric: id, value, target }; } // ── main engine ────────────────────────────────────────────────────────────── /** * Evaluate the explicit targets plus anomaly detection. Pure: the snapshot * payload carries every number the engine needs. */ export function evaluateSignals(payload: SnapshotPayload, settings: StatsSettings): Signal[] { const { kpis, activity } = payload; const signals: Signal[] = []; signals.push( evaluateTarget({ id: 'd1_return', title: 'D1 return', action: 'Test a "come back tomorrow" prompt on the win screen.', value: kpis.d1Return, target: settings.d1_target, red: 12 }) ); signals.push( evaluateTarget({ id: 'return7', title: '7-day return', action: 'Improve the first-session experience or send a day-2 reminder.', value: kpis.return7, target: settings.return7_target, red: 15 }) ); signals.push( evaluateTarget({ id: 'completion', title: 'Completion rate', action: 'Look for friction between the first guess and the win screen.', value: kpis.completionRate, target: settings.completion_target, red: 55 }) ); // First-try rate is a band: both too high and too low are bad. if (kpis.firstTryRate === null) { signals.push({ id: 'first_try', severity: 'info', title: 'First-try rate', detail: 'Not enough data yet to measure this.', metric: 'first_try' }); } else { const v = kpis.firstTryRate; const inBand = v >= settings.first_try_min && v <= settings.first_try_max; const severe = v > 45 || v < 8; signals.push({ id: 'first_try', severity: inBand ? 'good' : severe ? 'bad' : 'warn', title: 'Verse achievability', detail: `First-try rate is ${v}% (target band ${settings.first_try_min}–${settings.first_try_max}%).${ v > settings.first_try_max ? ' Verses may be too easy / self-revealing.' : v < settings.first_try_min ? ' Verses may be too hard.' : '' }`, action: inBand ? undefined : 'Review the verse mix for the easiest / hardest books.', metric: 'first_try', value: v, target: settings.first_try_max }); } signals.push( evaluateTarget({ id: 'share_rate', title: 'Share rate', action: 'Make the share action more prominent or reward sharing.', value: kpis.shareRate, target: settings.share_target, red: 15 }) ); // Core players: week-over-week direction. if (activity.corePrevious === null) { signals.push({ id: 'core_players', severity: 'info', title: 'Core players', detail: `${activity.core} players finished 4+ times this week. No prior week to compare yet.`, metric: 'core_players', value: activity.core }); } else { const delta = activity.core - activity.corePrevious; signals.push({ id: 'core_players', severity: delta >= 0 ? 'good' : 'warn', title: 'Core players', detail: `${activity.core} players finished 4+ times this week (${delta >= 0 ? '+' : ''}${delta} vs last week).`, action: delta < 0 ? 'Check whether verse difficulty or reminders changed.' : undefined, metric: 'core_players', value: activity.core }); } // Anomaly detection on the daily completions series. const anomaly = detectAnomaly( payload.dailySeries.map((d) => ({ date: d.date, value: d.completions })), settings.anomaly_mad_threshold ); if (anomaly) { const direction = anomaly.score > 0 ? 'above' : 'below'; signals.push({ id: 'completions_anomaly', severity: anomaly.score > 0 ? 'good' : 'warn', title: 'Unusual day', detail: `${anomaly.date} had ${anomaly.value} finishes, well ${direction} the usual ${anomaly.median} for that weekday.`, action: anomaly.score < 0 ? 'Check for outages or a hard verse.' : undefined, metric: 'completions', value: anomaly.value, target: anomaly.median }); } return signals; } const SEVERITY_ORDER: Record = { bad: 0, warn: 1, info: 2, good: 3 }; export function sortSignals(signals: Signal[]): Signal[] { return signals .slice() .sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); } /** One-line verdict for the Pulse banner. */ export function verdictLine(signals: Signal[]): string { const top = sortSignals(signals)[0]; if (!top) return 'No signals yet.'; if (top.severity === 'good') return `All core metrics are on target. ${top.detail}`; if (top.severity === 'info') return top.detail; return top.detail; }