Files
vyndr/tests/unit/consistencyScore.test.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

150 lines
6.8 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.
jest.mock('../../src/services/intelligence/gameLogService', () => ({
getGameLogs: async () => null,
}));
const cs = require('../../src/services/intelligence/consistencyScore');
describe('consistencyScore.classify', () => {
test('cv < 0.15 → elite', () => {
expect(cs.classify(0.10)).toEqual({ consistency: 'elite', score: 1.0 });
});
test('cv 0.15-0.30 → reliable', () => {
expect(cs.classify(0.20)).toEqual({ consistency: 'reliable', score: 0.7 });
});
test('cv 0.30-0.50 → volatile', () => {
expect(cs.classify(0.40)).toEqual({ consistency: 'volatile', score: 0.4 });
});
test('cv >= 0.50 → boom_bust', () => {
expect(cs.classify(0.80)).toEqual({ consistency: 'boom_bust', score: 0.1 });
});
});
describe('consistencyScore.statsFor', () => {
test('null for fewer than 2 samples', () => {
expect(cs.statsFor([])).toBeNull();
expect(cs.statsFor([25])).toBeNull();
});
test('null when mean is zero (can\'t divide)', () => {
expect(cs.statsFor([0, 0, 0])).toBeNull();
});
test('computes mean / stddev / cv for tight values', () => {
const s = cs.statsFor([25, 24, 26, 25, 24]);
expect(s.mean).toBeCloseTo(24.8, 1);
expect(s.cv).toBeLessThan(0.05);
});
test('computes wide cv for volatile sample', () => {
const s = cs.statsFor([5, 30, 35, 8, 28, 12]);
expect(s.cv).toBeGreaterThan(0.4);
});
});
describe('consistencyScore.getConsistency', () => {
test('classifies an elite scorer', async () => {
const logs = [
{ points: 25 }, { points: 24 }, { points: 26 }, { points: 25 }, { points: 24 },
{ points: 25 }, { points: 26 }, { points: 24 }, { points: 25 }, { points: 25 },
];
const out = await cs.getConsistency({ playerName: 'Elite', sport: 'nba', statType: 'points', gameLogs: logs });
expect(out.consistency).toBe('elite');
expect(out.games).toBe(10);
});
test('classifies boom/bust', async () => {
const logs = [
{ points: 5 }, { points: 32 }, { points: 8 }, { points: 35 }, { points: 6 },
{ points: 28 }, { points: 4 }, { points: 30 }, { points: 9 }, { points: 26 },
];
const out = await cs.getConsistency({ playerName: 'Wild', sport: 'nba', statType: 'points', gameLogs: logs });
expect(out.consistency).toBe('boom_bust');
});
test('returns unknown when game logs empty', async () => {
const out = await cs.getConsistency({ playerName: 'NoData', sport: 'nba', statType: 'points', gameLogs: [] });
expect(out.consistency).toBe('unknown');
expect(out.score).toBeNull();
});
test('combo stat (pts_reb_ast) summed before measuring', async () => {
const logs = [
{ points: 20, rebounds: 5, assists: 5 },
{ points: 22, rebounds: 5, assists: 4 },
{ points: 18, rebounds: 6, assists: 5 },
{ points: 21, rebounds: 5, assists: 5 },
];
const out = await cs.getConsistency({ playerName: 'Combo', sport: 'nba', statType: 'pts_reb_ast', gameLogs: logs });
expect(out.consistency).toBe('elite');
});
});
// ── Index-of-dispersion classifier (low-mean count stats) ───────────────────
// STANDING two-sided pin: the CV→IoD fix must recover a real +1.0 for steady
// low-mean hitters WITHOUT firing on genuine boom-bust, and must abstain on a
// thin sample. If any of these flip, the consistency signal has regressed.
describe('consistencyScore.classifyIoD (Poisson-anchored boundaries)', () => {
test('iod < 0.60 → elite (clearly under-dispersed)', () => {
expect(cs.classifyIoD(0.40)).toEqual({ consistency: 'elite', score: 1.0 });
});
test('0.60 ≤ iod < 0.85 → reliable (steadier than random)', () => {
expect(cs.classifyIoD(0.78)).toEqual({ consistency: 'reliable', score: 0.7 });
});
test('0.85 ≤ iod ≤ 1.30 → volatile / neutral (Poisson band, no factor)', () => {
expect(cs.classifyIoD(1.00)).toEqual({ consistency: 'volatile', score: 0.4 });
expect(cs.classifyIoD(1.25)).toEqual({ consistency: 'volatile', score: 0.4 });
});
test('iod > 1.30 → boom_bust (clear spike)', () => {
expect(cs.classifyIoD(2.17)).toEqual({ consistency: 'boom_bust', score: 0.1 });
});
});
describe('consistencyScore.getConsistency — low-mean IoD path', () => {
// A steady low-mean contact hitter (mean 0.8, under-dispersed) — CV would
// have blanket-classified this 'unknown'; IoD recovers the +1.0 signal.
test('steady low-mean hitter → consistent (recovers the suppressed +1.0)', async () => {
const logs = [2, 1, 0, 1, 0, 1, 1, 1, 0, 1].map((hits) => ({ hits }));
const out = await cs.getConsistency({ playerName: 'Steady', sport: 'mlb', statType: 'hits', gameLogs: logs });
expect(out.method).toBe('iod');
expect(['elite', 'reliable']).toContain(out.consistency); // engine1 → +1.0
expect(out.score).toBeGreaterThan(0);
});
// A genuine boom-bust low-mean bat (mostly 0, occasional 3) must NOT be
// mislabeled consistent — the fix stays two-sided.
test('boom-bust low-mean hitter → stays boom_bust (no false consistency)', async () => {
const logs = [0, 0, 3, 0, 0, 2, 0, 0, 3, 0].map((hits) => ({ hits }));
const out = await cs.getConsistency({ playerName: 'Spiky', sport: 'mlb', statType: 'hits', gameLogs: logs });
expect(out.method).toBe('iod');
expect(out.consistency).toBe('boom_bust'); // engine1 → 1.0
});
// A near-Poisson low-mean bat sits in the neutral band → NO factor either way.
test('Poisson-ish low-mean hitter → volatile / neutral (no factor)', async () => {
const logs = [1, 0, 1, 2, 0, 1, 1, 0, 2, 1].map((hits) => ({ hits })); // mean 0.9, iod ≈ 1
const out = await cs.getConsistency({ playerName: 'Random', sport: 'mlb', statType: 'hits', gameLogs: logs });
expect(out.method).toBe('iod');
expect(['volatile', 'reliable', 'boom_bust']).toContain(out.consistency);
});
// Sample floor: too few games for a trustworthy IoD → honest 'unknown'.
test('thin sample (< floor games) → unknown (no small-sample guess)', async () => {
const logs = [1, 1, 0, 1, 1].map((hits) => ({ hits })); // 5 games < MIN_GAMES_FOR_IOD (8)
const out = await cs.getConsistency({ playerName: 'Thin', sport: 'mlb', statType: 'hits', gameLogs: logs });
expect(out.consistency).toBe('unknown');
expect(out.score).toBeNull();
expect(out.reason).toBe('low_mean_thin_sample');
});
// The high-mean CV path is untouched by the swap (guards against blast radius).
test('high-mean stat still uses the CV path (unchanged)', async () => {
const logs = [
{ points: 25 }, { points: 24 }, { points: 26 }, { points: 25 }, { points: 24 },
{ points: 25 }, { points: 26 }, { points: 24 }, { points: 25 }, { points: 25 },
];
const out = await cs.getConsistency({ playerName: 'Elite', sport: 'nba', statType: 'points', gameLogs: logs });
expect(out.method).toBe('cv');
expect(out.consistency).toBe('elite');
});
});