83e9da3663
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
140 lines
5.9 KiB
JavaScript
140 lines
5.9 KiB
JavaScript
/**
|
|
* Session 63 — grade-range restoration.
|
|
*
|
|
* Locks the three structural facts the S63 audit found and fixed:
|
|
* 1. L20 has a NEGATIVE branch (there was no downside path at all).
|
|
* 2. With the previously-starving factors alive, A and D are REACHABLE.
|
|
* 3. `confidence` is explicitly labelled as grade-derived, not a probability.
|
|
*
|
|
* These are arithmetic/structural assertions on the engine, NOT a claim about
|
|
* how often A should occur in the wild — that is the live distribution report.
|
|
*/
|
|
|
|
const engine1 = require('../../src/services/intelligence/engine1');
|
|
const { toLegacyShape } = require('../../src/utils/gradeAdapter');
|
|
const featureCache = require('../../src/services/intelligence/featureCache');
|
|
|
|
const prop = (direction = 'over', line = 10) => ({ line, direction });
|
|
|
|
describe('L20 symmetry (the missing downside path)', () => {
|
|
test('L20 BELOW the line now subtracts on an OVER', () => {
|
|
const factors = engine1.__internals
|
|
? engine1.__internals.computeFactors({ features: { l20_avg: 5 }, prop: prop('over', 10) })
|
|
: null;
|
|
const res = engine1.gradeProp({ features: { l20_avg: 5 }, prop: prop('over', 10) });
|
|
// Whether or not internals are exported, the graded result must be BELOW
|
|
// the neutral 'C' — previously l20 could only ever add.
|
|
expect(['F', 'D', 'C-']).toContain(res.grade);
|
|
if (factors) {
|
|
expect(factors.find((f) => f.label === 'l20_contradicts_over').delta).toBe(-1.0);
|
|
}
|
|
});
|
|
|
|
test('L20 ABOVE the line still adds on an OVER (unchanged)', () => {
|
|
const res = engine1.gradeProp({ features: { l20_avg: 15 }, prop: prop('over', 10) });
|
|
expect(['C+', 'B-', 'B']).toContain(res.grade);
|
|
});
|
|
|
|
test('L20 ABOVE the line subtracts on an UNDER (mirrored)', () => {
|
|
const res = engine1.gradeProp({ features: { l20_avg: 15 }, prop: prop('under', 10) });
|
|
expect(['F', 'D', 'C-']).toContain(res.grade);
|
|
});
|
|
});
|
|
|
|
describe('A and D are reachable once the starving factors are alive', () => {
|
|
test('A emits when the real signals stack (the merit path)', () => {
|
|
const res = engine1.gradeProp({
|
|
features: {
|
|
l5_avg: 14, // +1.0 hot vs line
|
|
l20_avg: 13, // +1.0 season confirms
|
|
opp_rank_stat: 0.85, // +1.0 weak defense (was permanently null)
|
|
home_away: 1.0, // +0.5
|
|
rest_days: 3, // +0.5
|
|
},
|
|
consistency: { consistency: 'elite', score: 0.9 }, // +1.0 (was 'unknown')
|
|
prop: prop('over', 10),
|
|
});
|
|
expect(['A-', 'A', 'A+']).toContain(res.grade);
|
|
});
|
|
|
|
test('D/F emits when the real signals stack against (the merit path)', () => {
|
|
const res = engine1.gradeProp({
|
|
features: {
|
|
l5_avg: 6, // -1.0 cold vs line
|
|
l20_avg: 7, // -1.0 season contradicts (NEW branch)
|
|
opp_rank_stat: 0.1, // -1.0 top defense
|
|
home_away: 0.0,
|
|
rest_days: 0, // -0.5 back-to-back
|
|
game_count_in_7d: 5, // -0.5 heavy workload (was never populated)
|
|
},
|
|
consistency: { consistency: 'boom_bust' }, // -1.0
|
|
trap: { composite: 0.8 }, // -1.0
|
|
prop: prop('over', 10),
|
|
});
|
|
expect(['F', 'D']).toContain(res.grade);
|
|
});
|
|
|
|
test('a neutral feature set still lands at C — no inflation', () => {
|
|
const res = engine1.gradeProp({ features: {}, prop: prop('over', 10) });
|
|
expect(res.grade).toBe('C');
|
|
});
|
|
});
|
|
|
|
describe('confidence is labelled as derived, not a probability', () => {
|
|
test('toLegacyShape marks confidence_basis', () => {
|
|
const out = toLegacyShape(
|
|
{ grade: 'B', confidence: 0.63, all_factors: [] },
|
|
{ player: 'X', stat_type: 'hits', line: 1.5, direction: 'over' },
|
|
);
|
|
expect(out.confidence_basis).toBe('grade_band');
|
|
});
|
|
});
|
|
|
|
describe('gameCountInWindow (powers heavy_workload_7d)', () => {
|
|
const now = Date.UTC(2026, 6, 19);
|
|
const day = 86_400_000;
|
|
|
|
test('counts only games inside the window', () => {
|
|
const rows = [
|
|
{ date: new Date(now - 1 * day).toISOString(), hits: 1 },
|
|
{ date: new Date(now - 3 * day).toISOString(), hits: 2 },
|
|
{ date: new Date(now - 20 * day).toISOString(), hits: 0 },
|
|
];
|
|
expect(featureCache.gameCountInWindow(rows, 7, now)).toBe(2);
|
|
});
|
|
|
|
test('returns null (absent, not 0) when there are no dated rows', () => {
|
|
expect(featureCache.gameCountInWindow([], 7, now)).toBeNull();
|
|
expect(featureCache.gameCountInWindow([{ hits: 1 }], 7, now)).toBeNull();
|
|
expect(featureCache.gameCountInWindow(null, 7, now)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('consistency low-mean classifier (CV → index of dispersion)', () => {
|
|
const cs = require('../../src/services/intelligence/consistencyScore');
|
|
|
|
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.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 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
|
|
});
|
|
});
|