Fix MLB grade degradation: projection>0 gate, edge semantics, letter=confidence

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>
This commit is contained in:
Kev
2026-07-17 03:06:12 -04:00
parent 0d8fa76556
commit 888d103f95
3 changed files with 143 additions and 26 deletions
+27 -13
View File
@@ -241,20 +241,30 @@ function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, pr
function projectionFor(features, prop) {
const f = features || {};
const round2 = (n) => Math.round(n * 100) / 100;
if (Number.isFinite(f.l5_avg)) return round2(f.l5_avg);
if (Number.isFinite(f.l20_avg)) return round2(f.l20_avg);
// A projection must be a POSITIVE model reference. A non-positive value (0
// or negative) is not a real projection — it yields a degenerate edge
// ((line - 0) / line = 100%) and a hollow grade (the audit's projection=0
// nine). Skip non-positive candidates and fall through to the next real
// reference; when NONE is positive, return null so the read REFUSES
// (insufficient_data) instead of grading on zero.
const pos = (n) => (Number.isFinite(n) && n > 0 ? round2(n) : null);
const stat = String(prop?.stat_type || '').toLowerCase();
const per90 = f[`${stat}_per_90`];
if (Number.isFinite(per90)) return round2(per90);
if (stat === 'goals' && Number.isFinite(f.xg_per_90)) return round2(f.xg_per_90);
return null;
return pos(f.l5_avg)
?? pos(f.l20_avg)
?? pos(f[`${stat}_per_90`])
?? (stat === 'goals' ? pos(f.xg_per_90) : null);
}
// edge_pct in the legacy shape compares the model projection to the line.
function edgePctFor(features, prop) {
const ref = projectionFor(features, prop);
if (ref == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0;
const signed = prop.direction === 'over' ? (ref - prop.line) : (prop.line - ref);
// edge_pct in the legacy shape compares the model projection to the line:
// (model - line) / line, signed by direction. It is a projection-vs-line gap,
// always LABELLED MODEL in the UI. `ref` may be passed to reuse the exact
// projection the read was validated on (so edge and the persisted projection
// can never diverge); omitted, it recomputes (used by the alt-line ladder,
// where the projection is line-independent so recompute is equivalent).
function edgePctFor(features, prop, ref) {
const r = ref === undefined ? projectionFor(features, prop) : ref;
if (r == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0;
const signed = prop.direction === 'over' ? (r - prop.line) : (prop.line - r);
return Math.round((signed / prop.line) * 1000) / 10;
}
@@ -362,8 +372,12 @@ async function analyzeViaEngine1(rawProp = {}) {
// Session 58 (work-order 1.5) — no projection ⇒ no read. Without a model
// reference the edge is fictional and the grade would be hollow.
// 2026-07 hardening: the invariant is STRUCTURAL — a grade can NEVER be
// emitted with a non-positive projection. projectionFor already nulls
// non-positive references; the explicit `> 0` guard defends the law even if
// that ever changes. Refusal is the correct output (fewer graded props, honest).
const projection = projectionFor(features, { ...rawProp, line: prop.line });
if (projection == null) {
if (projection == null || !(projection > 0)) {
return insufficientDataResult(rawProp, meta?.errors);
}
@@ -387,7 +401,7 @@ async function analyzeViaEngine1(rawProp = {}) {
sport: meta.sport,
}, {
summaryOverride,
edgePct: edgePctFor(features, prop),
edgePct: edgePctFor(features, prop, projection), // reuse the validated projection
});
// The adapter's reasoning.steps was a single-element debug bag;
+18 -13
View File
@@ -16,19 +16,24 @@
const GRADE_SCALE = ['F', 'D', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'A+'];
const NEUTRAL_INDEX = 3; // 'C'
const GRADE_TO_CONFIDENCE = {
'A+': 1.00,
'A': 0.90,
'A-': 0.80,
'B+': 0.65,
'B': 0.55,
'B-': 0.45,
'C+': 0.35,
'C': 0.25,
'C-': 0.20,
'D': 0.15,
'F': 0.10,
};
// The confidence a grade carries is the MIDPOINT of that grade's band in
// grade_thresholds.json — the ONE canonical scale, shared with the Python
// engine. This enforces the law: applying the threshold table to a grade's
// displayed confidence resolves back to the SAME letter.
//
// The pre-2026-07 hand-rolled table drifted a full sub-tier LOW (B → 0.55,
// which the threshold table calls B-). Every emitted grade's confidence read
// one sub-tier under its letter — the "B at 45%" the phone audit caught. Never
// re-hardcode this; derive it from the bands so letter and confidence can't
// disagree.
const GRADE_BANDS = require('../python/data/grade_thresholds.json').grade_scale;
const GRADE_TO_CONFIDENCE = Object.fromEntries(
GRADE_SCALE.map((g) => {
const band = GRADE_BANDS[g];
const mid = band ? (band.low + band.high) / 2 : 0.25;
return [g, Math.round(mid * 1000) / 1000];
}),
);
function clampIndex(idx) {
return Math.max(0, Math.min(GRADE_SCALE.length - 1, idx));
+98
View File
@@ -0,0 +1,98 @@
'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 grades 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]);
});
});