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
This commit is contained in:
@@ -78,3 +78,72 @@ describe('consistencyScore.getConsistency', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user