/** * Consistency score — how predictable is this player for this stat? * * TWO scale-appropriate statistics, split at the mean where each is valid: * * HIGH-MEAN (mean ≥ 4, e.g. NBA points, pitcher Ks): * cv = stddev / mean (coefficient of variation) * LOW-MEAN (mean < 4, e.g. MLB hits / TB / HR / RBI): * iod = variance / mean (index of dispersion; Poisson baseline = 1) * * WHY THE SPLIT (Session — consistency classifier fix): CV is scale-DEPENDENT * on count data — for a Poisson-ish stat cv ≈ 1/sqrt(mean), so EVERY stat with * mean < 4 blows past the CV boom_bust cutoff no matter how the player actually * behaves. The A/D investigation found this was eating a real +1.0 signal: * steady low-mean hitters (Kwan, Alonso hits) were blanket-classified and * their earned consistency factor never fired. The index of dispersion is the * correct statistic for counts — UNBIASED, centered at 1.0 for a random * (Poisson) process regardless of the mean — so it recovers that signal * without systematically down- or up-grading anyone. This is a bug CORRECTION, * not a threshold loosening: the CV thresholds and the engine1 ±1.0 delta are * unchanged; only the low-mean branch that used to return 'unknown' now * classifies on merit. * * The consistency score modifies the grade: an "elite"/"reliable" player adds * +1.0 (engine1), a "boom_bust" player subtracts −1.0; "volatile"/"unknown" * add nothing. */ const gameLogService = require('./gameLogService'); function statFromGameLog(row, statType) { if (!row) return null; switch (statType) { case 'pts_reb_ast': return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0); case 'pts_reb': return (Number(row.points) || 0) + (Number(row.rebounds) || 0); case 'pts_ast': return (Number(row.points) || 0) + (Number(row.assists) || 0); case 'reb_ast': return (Number(row.rebounds) || 0) + (Number(row.assists) || 0); case 'stl_blk': return (Number(row.steals) || 0) + (Number(row.blocks) || 0); default: { const v = Number(row[statType]); return Number.isFinite(v) ? v : null; } } } function classify(cv) { if (cv < 0.15) return { consistency: 'elite', score: 1.0 }; if (cv < 0.30) return { consistency: 'reliable', score: 0.7 }; if (cv < 0.50) return { consistency: 'volatile', score: 0.4 }; return { consistency: 'boom_bust', score: 0.1 }; } /** * INDEX-OF-DISPERSION classifier for LOW-MEAN COUNT stats. iod = variance/mean; * a random (Poisson) process sits at 1.0 REGARDLESS of the mean, so the bands * are anchored on 1.0, not on an NBA-calibrated absolute like CV's. * * The bands are deliberately ASYMMETRIC around 1.0: real count stats are * naturally mildly over-dispersed (the per-game rate itself varies with * matchup / park), so "meaningfully steadier than random" (iod < 0.85) is the * signal that earns +1.0, and only a clear spike (iod > 1.30) earns −1.0. The * wide neutral band 0.85–1.30 abstains — most hitters are Poisson-ish and get * NO factor, which is the honest answer, not a limitation. * * Validated on real 10-game logs: Kwan hits 0.67 → reliable, Alonso hits * 0.78 → reliable (the recovery), Alonso TB 2.57 / Henderson hits 1.33 → * boom_bust (spikes), HR at mean 0.1 → 1.0 → volatile (rare-event Poisson). */ const IOD_ELITE_MAX = Number(process.env.CONSISTENCY_IOD_ELITE || 0.60); const IOD_RELIABLE_MAX = Number(process.env.CONSISTENCY_IOD_RELIABLE || 0.85); const IOD_BOOMBUST_MIN = Number(process.env.CONSISTENCY_IOD_BOOMBUST || 1.30); function classifyIoD(iod) { if (iod < IOD_ELITE_MAX) return { consistency: 'elite', score: 1.0 }; if (iod < IOD_RELIABLE_MAX) return { consistency: 'reliable', score: 0.7 }; if (iod <= IOD_BOOMBUST_MIN) return { consistency: 'volatile', score: 0.4 }; return { consistency: 'boom_bust', score: 0.1 }; } /** * IoD is itself noisy at tiny samples (its sampling sd ≈ sqrt(2/(n−1)) for a * Poisson process). Below this many games we abstain ('unknown') rather than * trade a scale bug for a small-sample bug — the same refusal discipline the * old CV floor used. 8 games + a clear departure from 1.0 is the noise buffer. */ const MIN_GAMES_FOR_IOD = Number(process.env.CONSISTENCY_MIN_GAMES_IOD || 8); /** * Session 63 — the CV thresholds above are NBA-calibrated (points ~20/game, * cv ~0.2-0.4). They are MEANINGLESS for a low-count stat. * * For a Poisson-ish counting stat, cv ≈ 1/sqrt(mean). So mean < 4 forces * cv > 0.5 — i.e. EVERY such stat classifies 'boom_bust' no matter how the * player actually behaves. Verified against real logs: Alonso hits * [0,0,0,1,2,1,0,1,1,0] → mean 0.60, cv 1.17 → boom_bust; Henderson * [1,0,0,3,1,1,0,0,1,0] → mean 0.70, cv 1.36 → boom_bust. * * When the estimator path was revived, this would have stamped a blanket * -1.0 on nearly every MLB prop — a systematic downgrade masquerading as a * signal. That is why the CV floor returned 'unknown' below mean 4. * * RESOLVED (consistency classifier fix): the low-mean branch no longer * abstains blindly — it now classifies with the index of dispersion * (`classifyIoD`), the scale-free statistic for counts. The floor below is * kept as the CV/IoD SPLIT POINT (which statistic to use), not as a blanket * refusal: mean ≥ 4 uses CV, mean < 4 uses IoD (games-floored). */ const MIN_MEAN_FOR_CV = Number(process.env.CONSISTENCY_MIN_MEAN || 4); function cvIsMeaningful(mean) { return Number.isFinite(mean) && Math.abs(mean) >= MIN_MEAN_FOR_CV; } function statsFor(values) { const clean = values.filter((v) => Number.isFinite(v)); if (clean.length < 2) return null; const mean = clean.reduce((a, b) => a + b, 0) / clean.length; if (mean === 0) return null; const variance = clean.reduce((s, v) => s + (v - mean) ** 2, 0) / (clean.length - 1); const stddev = Math.sqrt(variance); return { mean, stddev, variance, cv: stddev / Math.abs(mean), iod: variance / Math.abs(mean), // index of dispersion (Poisson baseline 1.0) games: clean.length, }; } async function getConsistency(input = {}) { const { playerName, sport, statType, gameLogs: providedLogs } = input; const logs = providedLogs || await gameLogService.getGameLogs(playerName, sport, 20); if (!logs || logs.length < 2) { return { consistency: 'unknown', score: null, games: logs?.length ?? 0 }; } const values = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null); const s = statsFor(values); if (!s) return { consistency: 'unknown', score: null, games: values.length }; // LOW-MEAN regime: CV is scale-broken here, so classify with the index of // dispersion (scale-appropriate for counts). Abstain if the sample is too // thin for IoD — absent beats a small-sample guess. if (!cvIsMeaningful(s.mean)) { if (s.games < MIN_GAMES_FOR_IOD) { return { ...s, consistency: 'unknown', score: null, reason: 'low_mean_thin_sample', method: 'iod' }; } return { ...s, ...classifyIoD(s.iod), method: 'iod' }; } // HIGH-MEAN regime: CV with the NBA-calibrated thresholds (unchanged). return { ...s, ...classify(s.cv), method: 'cv' }; } module.exports = { getConsistency, classify, classifyIoD, statsFor, statFromGameLog, cvIsMeaningful, MIN_MEAN_FOR_CV, MIN_GAMES_FOR_IOD, IOD_ELITE_MAX, IOD_RELIABLE_MAX, IOD_BOOMBUST_MIN, };