Files
vyndr/src/services/intelligence/consistencyScore.js
T
builtbykev 83e9da3663 Consistency classifier: CV → index of dispersion for low-mean counts
The A/D investigation found CV (std/mean) is scale-broken on count data —
for a Poisson-ish stat cv ≈ 1/sqrt(mean), so EVERY stat with mean < 4 blew
past the boom_bust cutoff regardless of behavior. The S63 stopgap made those
return 'unknown', which silently ate a real +1.0 consistency signal on every
MLB batting prop — steady low-mean hitters never got their earned factor.

Fix, fenced to the low-mean branch of consistencyScore (the only branch that
was returning 'unknown'): classify with the index of dispersion (variance/mean,
Poisson baseline 1.0) — the scale-appropriate, UNBIASED statistic for counts.
mean ≥ 4 keeps the NBA-calibrated CV path BYTE-IDENTICAL (zero NBA blast
radius). This is a bug CORRECTION, not threshold loosening: the CV thresholds
and the engine1 ±1.0 delta are unchanged.

Bands (asymmetric around Poisson 1.0, since counts are naturally mildly
over-dispersed): iod<0.60 elite / <0.85 reliable (+1.0) / ≤1.30 volatile
(neutral) / >1.30 boom_bust (−1.0). Sample floor MIN_GAMES_FOR_IOD=8 so a
thin sample abstains ('unknown') — no small-sample guess.

Validated on real 10-game logs (two-sided): Kwan hits 0.67 / Alonso hits
0.78 → reliable (RECOVERED); Alonso TB 2.57 / Henderson hits 1.33 → boom_bust
(no false consistency); HR mean 0.1 → 1.0 → neutral. Direct engine1 proof: a
strong steady prop that grades B+ today reaches A- once the +1.0 fires; a
boom-bust bat stays B (no inflation). A- now emerges NATURALLY from a real
recovered factor. Standing two-sided test pins all three directions.

Forward-only (settled grades are locked in the ledger, never re-graded).
Emitting A- ≠ proving A- — the A-tier record accrues from emission, still
measurement-gated. Full unit suite green (4 pre-existing redis/timing flakes
pass in isolation); web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-22 21:05:16 -04:00

162 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.851.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/(n1)) 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,
};