/** * Rolling z-score helpers for anomaly retrain / hybrid detection. */ /** * @param {number[]} values * @returns {{ mean: number, stdev: number, n: number }} */ export function stats(values) { const xs = values.filter((v) => v != null && Number.isFinite(v)) const n = xs.length if (!n) return { mean: 0, stdev: 0, n: 0 } const mean = xs.reduce((a, b) => a + b, 0) / n if (n < 2) return { mean, stdev: 0, n } let sumSq = 0 for (const v of xs) sumSq += (v - mean) ** 2 const stdev = Math.sqrt(sumSq / (n - 1)) return { mean, stdev, n } } /** * @param {number} value * @param {number} mean * @param {number} stdev */ export function zScore(value, mean, stdev) { if (!Number.isFinite(value) || !Number.isFinite(mean)) return 0 if (!stdev || stdev < 1e-9) return value === mean ? 0 : value > mean ? 3 : -3 return (value - mean) / stdev } /** * Derive warn/crit absolute thresholds from baseline + z multipliers. * For `>` comparator: warn = mean + warnZ*stdev, crit = mean + critZ*stdev * For `<` comparator: warn = mean - warnZ*stdev, crit = mean - critZ*stdev * * @param {{ mean: number, stdev: number }} s * @param {string} comparator * @param {{ warnZ?: number, critZ?: number, minStdev?: number }} [opts] */ export function thresholdsFromStats(s, comparator, opts = {}) { const warnZ = opts.warnZ ?? 2 const critZ = opts.critZ ?? 3 const minStdev = opts.minStdev ?? 0.01 const stdev = Math.max(s.stdev, minStdev) const op = comparator || '>' if (op === '<' || op === '<=') { return { warn: s.mean - warnZ * stdev, crit: s.mean - critZ * stdev, mean: s.mean, stdev, } } return { warn: s.mean + warnZ * stdev, crit: s.mean + critZ * stdev, mean: s.mean, stdev, } } /** * Extract numeric dimension series from MetricStore points. * @param {import('./store.js').MetricStore} store * @param {string} chart * @param {string} dimension * @param {number} [maxPoints] * @returns {number[]} */ export function seriesFromStore(store, chart, dimension, maxPoints = 300) { const entry = store.series?.get?.(chart) const points = entry?.points || [] const slice = points.slice(-maxPoints) /** @type {number[]} */ const out = [] for (const p of slice) { const v = p.values?.[dimension] if (v != null && Number.isFinite(v)) out.push(v) } return out }