mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
247 lines
7.7 KiB
TypeScript
247 lines
7.7 KiB
TypeScript
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<SignalSeverity, number> = { 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;
|
||
}
|