888d103f95
The #1 board item — three grading bugs the phone audit surfaced, all in the live Node grade path (engine1 + analyzeViaEngine1), fixed at the source. 1. PROJECTION=0 NOW REFUSES. projectionFor returned l5_avg even when it was 0 (finite, so the `== null` gate passed it) — 9/25 live grades graded on a zero projection, producing a degenerate edge and a hollow grade. Now a non-positive reference is not a projection: projectionFor skips it and falls through to the next POSITIVE reference (l5 -> l20 -> per_90 -> xg); when none is positive it returns null and the read REFUSES (insufficient_data). The gate also gained an explicit `> 0` guard so the invariant is structural — a grade can never be emitted with a non-positive projection. Fewer graded props, honest. 2. EDGE_PCT. The formula was already (model - line) / line signed by direction — Kev's intended semantics. The broken {20,60,100,140} cluster was the proj=0 degeneracy ((line - 0)/line = 100%); with #1 those refuse, so the fabricated 100s vanish and real edges flow. The main-line edge now reuses the VALIDATED projection (edgePctFor accepts an optional ref) so edge and the persisted projection can never diverge. Frontend |edge|>40 guard stays as a safety net. 3. LETTER == THRESHOLD_TABLE(CONFIDENCE). engine1's hand-rolled GRADE_TO_CONFIDENCE drifted a full sub-tier low (B -> 0.55, which the canonical grade_thresholds.json calls B-) — the "B at 45%" the audit caught. Now confidence is DERIVED from each grade's band MIDPOINT in grade_thresholds.json (one source of truth, shared with the Python engine), so applying the threshold table to any grade's displayed confidence resolves back to the same letter. Proven for all 11 grades. Regression locks: tests/unit/mlbGradeDegradation.test.js (14 tests). Backend suite 269/3253 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
5.0 KiB
JavaScript
99 lines
5.0 KiB
JavaScript
'use strict';
|
||
|
||
// MLB grade-degradation regression locks (2026-07-17). Three bugs the phone
|
||
// audit surfaced, fixed in engine1 + analyzeViaEngine1. See
|
||
// specs/audit-data/mlb-grade-degradation.md.
|
||
|
||
const engine1 = require('../../src/services/intelligence/engine1');
|
||
const { __internals } = require('../../src/services/intelligence/analyzeViaEngine1');
|
||
const { projectionFor, edgePctFor, insufficientDataResult } = __internals;
|
||
const gradeAdapter = require('../../src/utils/gradeAdapter');
|
||
const { fourLetterGrade, legacyConfidence } = gradeAdapter.__internals;
|
||
const BANDS = require('../../src/services/python/data/grade_thresholds.json').grade_scale;
|
||
|
||
// The canonical threshold table: displayed 0-100 confidence -> 11-step letter.
|
||
function thresholdTable(conf0to100) {
|
||
const p = conf0to100 / 100;
|
||
for (const [letter, band] of Object.entries(BANDS)) {
|
||
if (p >= band.low && p <= band.high) return letter;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
describe('FIX #1 — projection <= 0 REFUSES (a grade is never emitted with no projection)', () => {
|
||
test('a zero l5_avg is not a projection -> null (falls through to nothing)', () => {
|
||
expect(projectionFor({ l5_avg: 0 }, { stat_type: 'home_runs' })).toBeNull();
|
||
});
|
||
test('a negative reference is not a projection -> null', () => {
|
||
expect(projectionFor({ l5_avg: -1.2 }, { stat_type: 'hits' })).toBeNull();
|
||
});
|
||
test('a zero PRIMARY reference falls through to the next POSITIVE one', () => {
|
||
// l5 degenerate 0, but l20 is real -> use l20 (player still has a projection)
|
||
expect(projectionFor({ l5_avg: 0, l20_avg: 1.2 }, { stat_type: 'total_bases' })).toBe(1.2);
|
||
});
|
||
test('all references zero/absent -> null -> the read refuses', () => {
|
||
expect(projectionFor({ l5_avg: 0, l20_avg: 0 }, { stat_type: 'hits' })).toBeNull();
|
||
expect(projectionFor({}, { stat_type: 'hits' })).toBeNull();
|
||
});
|
||
test('a real positive projection is preserved', () => {
|
||
expect(projectionFor({ l5_avg: 1.4 }, { stat_type: 'total_bases' })).toBe(1.4);
|
||
});
|
||
test('the refusal shape carries grade=null, projection=null, insufficient_data', () => {
|
||
const r = insufficientDataResult({ player: 'X', stat_type: 'home_runs', line: 0.5 }, []);
|
||
expect(r.grade).toBeNull();
|
||
expect(r.projection).toBeNull();
|
||
expect(r.insufficient_data).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('FIX #2 — edge_pct is (model - line) / line, never the projection=0 degeneracy', () => {
|
||
test('a proj=0 under no longer yields a fabricated +100% (ref nulled upstream)', () => {
|
||
// projectionFor now nulls a 0 reference, so edgePctFor sees ref=null -> 0
|
||
expect(edgePctFor({ l5_avg: 0 }, { line: 0.5, direction: 'under', stat_type: 'home_runs' })).toBe(0);
|
||
});
|
||
test('a real over edge = (proj - line) / line * 100', () => {
|
||
// proj 0.6 vs line 0.5 over -> (0.6-0.5)/0.5 = 20%
|
||
expect(edgePctFor({ l5_avg: 0.6 }, { line: 0.5, direction: 'over', stat_type: 'home_runs' })).toBe(20);
|
||
});
|
||
test('a real under edge flips sign', () => {
|
||
// proj 6 vs line 6.5 under -> (6.5-6)/6.5 ~ 7.7%
|
||
expect(edgePctFor({ l5_avg: 6 }, { line: 6.5, direction: 'under', stat_type: 'strikeouts' })).toBeCloseTo(7.7, 1);
|
||
});
|
||
test('a passed ref is used verbatim (edge and persisted projection cannot diverge)', () => {
|
||
expect(edgePctFor({ l5_avg: 999 }, { line: 0.5, direction: 'over' }, 0.6)).toBe(20);
|
||
});
|
||
test('edges over a plausible slate are NOT quantized to a degenerate cluster', () => {
|
||
// varied real projections + lines -> a spread of values, none the fake 100
|
||
const slate = [
|
||
{ f: { l5_avg: 1.4 }, line: 1.5, dir: 'under' },
|
||
{ f: { l5_avg: 6.9 }, line: 6.5, dir: 'over' },
|
||
{ f: { l5_avg: 2.1 }, line: 2.5, dir: 'under' },
|
||
{ f: { l5_avg: 0.7 }, line: 0.5, dir: 'over' },
|
||
];
|
||
const edges = slate.map((s) => edgePctFor(s.f, { line: s.line, direction: s.dir }));
|
||
expect(edges).not.toContain(100);
|
||
expect(new Set(edges).size).toBeGreaterThan(1); // not one repeated value
|
||
});
|
||
});
|
||
|
||
describe('FIX #3 — letter == threshold_table(displayed_confidence) for EVERY grade', () => {
|
||
test('each 11-step grade’s confidence resolves back to the same letter', () => {
|
||
for (const g of engine1.GRADE_SCALE) {
|
||
const displayedConf = legacyConfidence(engine1.GRADE_TO_CONFIDENCE[g]); // 0-100
|
||
const resolved = thresholdTable(displayedConf); // 11-step
|
||
expect(resolved).toBe(g); // the midpoint lands squarely back in the grade's own band
|
||
}
|
||
});
|
||
test('the 4-letter collapse also agrees (what the leaderboard shows)', () => {
|
||
for (const g of engine1.GRADE_SCALE) {
|
||
const displayedConf = legacyConfidence(engine1.GRADE_TO_CONFIDENCE[g]);
|
||
const resolvedLetter = fourLetterGrade(thresholdTable(displayedConf));
|
||
expect(resolvedLetter).toBe(fourLetterGrade(g));
|
||
}
|
||
});
|
||
test('confidence values stay strictly ascending', () => {
|
||
const conf = engine1.GRADE_SCALE.map((g) => engine1.GRADE_TO_CONFIDENCE[g]);
|
||
for (let i = 1; i < conf.length; i += 1) expect(conf[i]).toBeGreaterThan(conf[i - 1]);
|
||
});
|
||
});
|