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:
Kev
2026-07-22 21:05:16 -04:00
parent 3440738d9e
commit 83e9da3663
3 changed files with 163 additions and 27 deletions
+13 -9
View File
@@ -110,26 +110,30 @@ describe('gameCountInWindow (powers heavy_workload_7d)', () => {
});
});
describe('consistency CV floor (Session 63 calibration guard)', () => {
describe('consistency low-mean classifier (CV → index of dispersion)', () => {
const cs = require('../../src/services/intelligence/consistencyScore');
test('CV is refused below the mean floor — a low-count MLB stat is NOT boom_bust', async () => {
// Real Pete Alonso hits log: mean 0.60, cv 1.17. Pre-guard this classified
// boom_bust and stamped -1.0 on essentially every MLB prop.
test('low-mean MLB stat classifies on IoD, not blanket CV boom_bust', async () => {
// Real Pete Alonso hits log: mean 0.60. Under the raw CV (1.17) this was
// boom_bust; the S63 stopgap made it 'unknown'; the IoD fix recovers it —
// IoD = variance/mean = 0.82 → steadier than random → 'reliable' (+1.0).
// The original point still holds: it is NOT wrongly stamped boom_bust.
const logs = [0, 0, 0, 1, 2, 1, 0, 1, 1, 0].map((hits) => ({ hits }));
const res = await cs.getConsistency({ statType: 'hits', gameLogs: logs });
expect(res.consistency).toBe('unknown');
expect(res.reason).toBe('low_mean_cv_unreliable');
expect(res.method).toBe('iod');
expect(['elite', 'reliable']).toContain(res.consistency);
expect(res.consistency).not.toBe('boom_bust');
});
test('CV still classifies normally above the floor (NBA-scale stat)', async () => {
const logs = [20, 22, 19, 21, 20, 23, 18, 21, 20, 22].map((points) => ({ points }));
const res = await cs.getConsistency({ statType: 'points', gameLogs: logs });
expect(res.method).toBe('cv');
expect(['elite', 'reliable']).toContain(res.consistency);
});
test('cvIsMeaningful is the explicit gate', () => {
expect(cs.cvIsMeaningful(0.6)).toBe(false);
expect(cs.cvIsMeaningful(12)).toBe(true);
test('cvIsMeaningful is the CV/IoD split point (not a blanket refusal)', () => {
expect(cs.cvIsMeaningful(0.6)).toBe(false); // < 4 → IoD branch
expect(cs.cvIsMeaningful(12)).toBe(true); // ≥ 4 → CV branch
});
});