diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index 9bdd22e..a1fc019 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -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; diff --git a/src/services/intelligence/engine1.js b/src/services/intelligence/engine1.js index ae35bdf..6ec9b97 100644 --- a/src/services/intelligence/engine1.js +++ b/src/services/intelligence/engine1.js @@ -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)); diff --git a/tests/unit/mlbGradeDegradation.test.js b/tests/unit/mlbGradeDegradation.test.js new file mode 100644 index 0000000..2031ff1 --- /dev/null +++ b/tests/unit/mlbGradeDegradation.test.js @@ -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 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]); + }); +});