From 43f65d30cbfc477d7656e4f5fcac518d6939eb5f Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 7 Aug 2026 02:53:48 -0400 Subject: [PATCH] Wire the three proven hits factors pre-grade: transmission proven, gain inconclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE BUG THIS NEARLY SHIPPED AS A FINDING. The first audit reported 0 factors fired on all 1,140 rows. Not a result -- my paging helper ordered by `id`, and batter_spray, team_defense, platoon_splits and statcast_aggregates have composite primary keys with NO id column. The query errored, the loop broke on error, and four fully-populated tables read as empty. hitsFactorContext.js -- the PRODUCTION loader -- had the identical defect, so live wiring would have loaded nothing and served unadjusted while logging success. Third occurrence of this class in one session. Both loaders now order by a real column and THROW rather than degrade. The Phase 2 gate is what caught it: no resolution number was quoted until transmission was proved. PHASE 1 — pipeline is now base -> FACTORS -> CALIBRATE -> GRADE. Context built in snapshotService BEFORE gradeAndCacheSlate (was line 640+, grade at 454), threaded per prop, applied to p_over before p_win is set with p_win_prefactor and a full trace retained. Hits only. Coverage 859/1140 rows (75%): 474 with all three factors, 256 two, 129 one, 281 none. PHASE 2 — TRANSMISSION PROVEN, 12/12 sign-correct, 4/4 per factor, each applied IN ISOLATION. My first table compared each factor's expected sign against the COMPOSITE change and showed 3 false failures -- with three factors firing the net can oppose any single member; that was a flaw in the test, not the wiring. Two under-side rows confirm the flip is handled: a factor raising p(over) correctly lowers p_win. Switch hitters (Bailey, Bell, Rocchio) took no spray adjustment while their other factors fired normally -- the refusal is selective, not a blanket skip. PHASE 3/4 — both maps refit on the factor-adjusted forecast; the shadow-duel baseline is VOID and restarts, since it accumulated against a different forecast. Point-in-time, 765 held-out rows: reliability 0.00795 -> 0.00828 RESOLUTION 0.00229 -> 0.00345 (variance explained 0.93% -> 1.39%) Brier 0.25398 -> 0.25305 delta -0.00093 CI [-0.00225,+0.00002] Resolution rose 51% relative. The CI TOUCHES ZERO on 4 eval dates, so the composition does NOT earn a proven keep -- three isolated passes did not grant a composed pass. INCONCLUSIVE, reported as such. The gain is far below the sum of the isolated effects, which is expected: all three run through the same pitcher-batter confrontation and share signal. PHASE 5 — 1.39% of variance is still far below what band separation needs. The pivot was correct and incomplete: the plumbing defect was real and is fixed, three proven factors reach the served number for the first time, and transmission alone did not buy grade separation. Next arc is factor STRENGTH and BREADTH, not more plumbing. PHASE 6 — rbi anomaly logged, not chased: 14.51% variance explained vs hits 1.03%, on the stat we do not serve corrected and which has no proven factors. Either the biggest lever on the board or a mirage; it deserves its own order. The byte-identical invariant INVERTED for hits by design. All 13 frozen non-hits modules verified unchanged, probabilityEstimator included -- the factors ride outside it. No new Bonferroni slot; the composed OOS claim is reported with its CI and not claimed as a pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9 --- scripts/factor-wiring-audit.js | 205 ++++++++++++++++++ specs/factor-wiring-hits.md | 142 ++++++++++++ src/services/gradeSlateService.js | 6 + .../intelligence/analyzeViaEngine1.js | 27 ++- src/services/model/hitsFactorContext.js | 144 ++++++++++++ src/services/model/hitsFactors.js | 127 +++++++++++ src/services/snapshotService.js | 19 ++ tests/unit/hitsFactors.test.js | 88 ++++++++ 8 files changed, 756 insertions(+), 2 deletions(-) create mode 100644 scripts/factor-wiring-audit.js create mode 100644 specs/factor-wiring-hits.md create mode 100644 src/services/model/hitsFactorContext.js create mode 100644 src/services/model/hitsFactors.js create mode 100644 tests/unit/hitsFactors.test.js diff --git a/scripts/factor-wiring-audit.js b/scripts/factor-wiring-audit.js new file mode 100644 index 0000000..12a7879 --- /dev/null +++ b/scripts/factor-wiring-audit.js @@ -0,0 +1,205 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Phases 0, 2, 3 and 4 — replay the factor wiring on settled hits rows. + * + * Transmission is proved MECHANICALLY before any resolution number is quoted, + * because "resolution went up" is exactly what a subtle bug also prints. + */ + +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); +const { createClient } = require('@supabase/supabase-js'); +const hf = require('../src/services/model/hitsFactors'); +const lp = require('../src/services/model/lowParamCalibrator'); +const guards = require('../src/services/model/calibrationGuards'); +const { knownNumber } = require('../src/utils/known'); +const { nameKey } = require('../src/utils/playerName'); + +const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json'); +const SEQ = path.join(process.cwd(), '.seq-cache', 'sequences.json'); +const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null); + +async function page(sb, t, s, f, orderBy = 'id') { + const o = []; + for (let i = 0; ; i += 1000) { + const { data, error } = await f(sb.from(t).select(s)).order(orderBy, { ascending: true }).range(i, i + 999); + if (error) throw new Error(`${t}: ${error.message}`); + if (!data || !data.length) break; o.push(...data); if (data.length < 1000) break; + } + return o; +} +const isPreGame = (c, g) => { + const et = new Date(new Date(c).getTime() - 4 * 3600 * 1000); + const d = et.toISOString().slice(0, 10); + return d < g || (d === g && et.getUTCHours() < 19); +}; +function makeRnd(seed) { let s = seed >>> 0; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; } + +function decompose(rows, bins = 10) { + const base = mean(rows.map((r) => r.won)); + const unc = base * (1 - base); + let rel = 0; let res = 0; + for (let k = 0; k < bins; k += 1) { + const lo = k / bins; const hi = (k + 1) / bins; + const sl = rows.filter((r) => r.p >= lo && (hi >= 1 ? r.p <= 1 : r.p < hi)); + if (!sl.length) continue; + const w = sl.length / rows.length; + rel += w * (mean(sl.map((r) => r.p)) - mean(sl.map((r) => r.won))) ** 2; + res += w * (mean(sl.map((r) => r.won)) - base) ** 2; + } + return { base_rate: r4(base), reliability: r5(rel), resolution: r5(res), uncertainty: r5(unc), share: r4(res / unc) }; +} + +(async () => { + const sb = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } }); + const lines = JSON.parse(fs.readFileSync(BOX, 'utf8')).lines; + + // Factor inputs. + const [spray, defense, platoon, statcast] = await Promise.all([ + page(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb'), 'player_key'), + page(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb'), 'team'), + page(sb, 'platoon_splits', '*', (q) => q.eq('sport', 'mlb'), 'player_key'), + page(sb, 'statcast_aggregates', 'player_key, role, bats, throws, hard_hit_pct', (q) => q.eq('sport', 'mlb'), 'player_key'), + ]); + const latest = (rows, k) => { const m = new Map(); for (const r of rows) { const key = r[k]; if (!key) continue; const p = m.get(key); if (!p || String(r.as_of_date) > String(p.as_of_date)) m.set(key, r); } return m; }; + const sprayBy = latest(spray, 'player_key'); const defBy = latest(defense, 'team'); const platBy = latest(platoon, 'player_key'); + const batBy = new Map(); const pitBy = new Map(); + for (const r of statcast) { if (!r.player_key) continue; (r.role === 'pitcher' ? pitBy : batBy).set(r.player_key, r); } + const frac = (v) => { const n = knownNumber(v); return n === null ? null : (n > 1 ? n / 100 : n); }; + + // Opponent + starter per (player,date) from the sequence cache. + const { games } = JSON.parse(fs.readFileSync(SEQ, 'utf8')); + const oppOf = new Map(); const spOf = new Map(); + for (const g of games) { + for (const side of ['home', 'away']) { + const opp = g[side === 'home' ? 'away' : 'home']; + const st = (g[side].arms || []).find((a) => a.started); + const half = side === 'home' ? 'top' : 'bottom'; + for (const pa of g.pas.filter((p) => p.half === half)) { + const k = `${g.date}|${nameKey(pa.batter_name || '')}`; + if (!oppOf.has(k)) { oppOf.set(k, g[side].team); if (st) spOf.set(k, st.name); } + } + } + } + + const snaps = await page(sb, 'model_snapshots', 'id, game_date, captured_at, stat, player_key, player_name, line, side, p_win, refused', + (q) => q.eq('sport', 'mlb').eq('stat', 'hits')); + const picked = new Map(); + for (const r of snaps) { + if (!isPreGame(r.captured_at, r.game_date) || r.refused || knownNumber(r.p_win) === null) continue; + const k = [r.game_date, r.player_key, r.line].join('|'); + const prev = picked.get(k); + if (!prev || knownNumber(r.p_win) > knownNumber(prev.p_win)) picked.set(k, r); + } + guards.assertPickedSideDedup([...picked.values()].map((r) => ({ propKey: [r.game_date, r.player_key, r.line].join('|'), side: r.side, p: knownNumber(r.p_win) }))); + + const rows = []; const transmission = []; const unreadable = []; + for (const r of picked.values()) { + const b = lines[`${r.game_date}|${r.player_key}`]; const L = knownNumber(r.line); + if (!b || L === null || !r.side) continue; + const v = knownNumber(b.hits); if (v === null) continue; + const over = v > L; + const won = (String(r.side).toLowerCase() === 'under' ? !over : over) ? 1 : 0; + + const key = r.player_key; + const bat = batBy.get(key); + const oppTeam = oppOf.get(`${r.game_date}|${key}`); + const def = oppTeam ? (defBy.get(oppTeam) || defBy.get(String(oppTeam).split(' ').pop())) : null; + const spName = spOf.get(`${r.game_date}|${key}`); + const pit = spName ? pitBy.get(nameKey(spName)) : null; + const sp = platBy.get(key); + const ctx = { + spray: sprayBy.get(key) || null, + positionOaa: def && def.position_oaa ? def.position_oaa : null, + bats: bat && bat.bats ? String(bat.bats)[0] : null, + throws: pit && pit.throws ? String(pit.throws)[0] : null, + pitcherHardHit: pit ? frac(pit.hard_hit_pct) : null, + platoonSplits: sp ? { vl: { pa: sp.vl_pa, atBats: sp.vl_ab, hits: sp.vl_hits }, vr: { pa: sp.vr_pa, atBats: sp.vr_ab, hits: sp.vr_hits } } : null, + }; + // The engine adjusts p_over then flips for unders; replay that exactly. + const pOverRaw = String(r.side).toLowerCase() === 'under' ? 1 - knownNumber(r.p_win) : knownNumber(r.p_win); + const adj = hf.adjustProbability(pOverRaw, ctx); + const pAfter = adj.factors_fired > 0 + ? (String(r.side).toLowerCase() === 'under' ? 1 - adj.p_adjusted : adj.p_adjusted) + : knownNumber(r.p_win); + + rows.push({ date: r.game_date, p_before: knownNumber(r.p_win), p: pAfter, won, fired: adj.factors_fired, applied: adj.applied }); + + // TRANSMISSION IS TESTED PER FACTOR, IN ISOLATION. + // Comparing one factor's expected sign against the COMPOSITE p_win change is + // wrong: with three factors firing, two pulling down and one up, the net can + // oppose any single member and look like a defect when nothing is broken. + // So each factor is applied ALONE to the same base and its own sign checked. + const isUnder = String(r.side).toLowerCase() === 'under'; + for (const a of adj.applied) { + if (transmission.filter((t) => t.factor === a.factor).length >= 4) continue; + if (Math.abs(a.multiplier - 1) < 0.03) continue; + const solo = { spray: null, positionOaa: null, bats: ctx.bats, throws: null, pitcherHardHit: null, platoonSplits: null }; + if (a.factor === 'defense_by_direction') { solo.spray = ctx.spray; solo.positionOaa = ctx.positionOaa; } + if (a.factor === 'pitcher_contact_profile') solo.pitcherHardHit = ctx.pitcherHardHit; + if (a.factor === 'platoon_severity') { solo.platoonSplits = ctx.platoonSplits; solo.throws = ctx.throws; } + const one = hf.adjustProbability(pOverRaw, solo); + if (one.factors_fired !== 1) continue; + const soloWin = isUnder ? 1 - one.p_adjusted : one.p_adjusted; + transmission.push({ + factor: a.factor, player: r.player_name, date: r.game_date, + expected: a.multiplier > 1 ? 'raise p(over)' : 'lower p(over)', multiplier: a.multiplier, + side: r.side, p_before: knownNumber(r.p_win), p_after_solo: r4(soloWin), + sign_correct: isUnder + ? ((a.multiplier > 1) === (soloWin < knownNumber(r.p_win))) + : ((a.multiplier > 1) === (soloWin > knownNumber(r.p_win))), + }); + } + if (adj.skipped.some((s) => /switch hitter/.test(s.reason || '')) && unreadable.length < 4) { + unreadable.push({ player: r.player_name, reason: 'switch hitter — spray side unreadable', p_before: knownNumber(r.p_win), p_after: pAfter, moved_by_spray: false }); + } + } + + // ── PHASE 4: OOS, point-in-time ── + rows.sort((a, b) => a.date.localeCompare(b.date)); + const dates = [...new Set(rows.map((r) => r.date))].sort(); + const perDate = new Map(); for (const r of rows) perDate.set(r.date, (perDate.get(r.date) || 0) + 1); + let acc = 0; let cut = dates[dates.length - 1]; + for (const d of dates) { acc += perDate.get(d); if (acc >= rows.length * 0.45) { cut = d; break; } } + const fit = rows.filter((r) => r.date < cut); const ev = rows.filter((r) => r.date >= cut); + + const mBefore = lp.fitPlatt(fit.map((r) => ({ p: r.p_before, won: r.won, date: r.date }))); + const mAfter = lp.fitPlatt(fit.map((r) => ({ p: r.p, won: r.won, date: r.date }))); + const evBefore = ev.map((r) => ({ ...r, p: mBefore && !mBefore.refused ? lp.applyPlatt(mBefore, r.p_before) : r.p_before })).filter((r) => r.p != null); + const evAfter = ev.map((r) => ({ ...r, p: mAfter && !mAfter.refused ? lp.applyPlatt(mAfter, r.p) : r.p })).filter((r) => r.p != null); + + const bBefore = guards.safeBrier(evBefore.map((r) => r.p), evBefore.map((r) => r.won)); + const bAfter = guards.safeBrier(evAfter.map((r) => r.p), evAfter.map((r) => r.won)); + + const byDate = new Map(); + for (let i = 0; i < evAfter.length; i += 1) { const d = evAfter[i].date; if (!byDate.has(d)) byDate.set(d, []); byDate.get(d).push({ a: evAfter[i].p, b: evBefore[i] ? evBefore[i].p : null, won: evAfter[i].won }); } + const keys = [...byDate.keys()]; const rnd = makeRnd(20260807); const diffs = []; + for (let it = 0; it < 3000; it += 1) { + const s = []; + for (let i = 0; i < keys.length; i += 1) s.push(...byDate.get(keys[Math.floor(rnd() * keys.length)])); + const u = s.filter((x) => x.b != null); + if (!u.length) continue; + diffs.push(guards.safeBrier(u.map((x) => x.a), u.map((x) => x.won)) - guards.safeBrier(u.map((x) => x.b), u.map((x) => x.won))); + } + diffs.sort((a, b) => a - b); + + console.log(JSON.stringify({ + coverage: { rows: rows.length, any_factor_fired: rows.filter((r) => r.fired > 0).length, + by_count: [0, 1, 2, 3].map((k) => ({ factors: k, n: rows.filter((r) => r.fired === k).length })) }, + PHASE_2_transmission: transmission, + PHASE_2_unreadable_static: unreadable, + PHASE_4: { + split_at: cut, fit_n: fit.length, eval_n: ev.length, eval_dates: keys.length, + resolution_before: decompose(evBefore), resolution_after: decompose(evAfter), + brier_before: r5(bBefore), brier_after: r5(bAfter), brier_delta: r5(bAfter - bBefore), + brier_ci_date_block: diffs.length ? [r5(diffs[Math.floor(diffs.length * 0.025)]), r5(diffs[Math.floor(diffs.length * 0.975)])] : null, + }, + }, null, 2)); + process.exit(0); +})().catch((e) => { console.error(e); process.exit(1); }); + +const r5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000); +const r4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000); diff --git a/specs/factor-wiring-hits.md b/specs/factor-wiring-hits.md new file mode 100644 index 0000000..3fd3a52 --- /dev/null +++ b/specs/factor-wiring-hits.md @@ -0,0 +1,142 @@ +# Wiring the three proven hits factors — transmission proven, gain inconclusive + +## The bug this order nearly shipped as a finding + +The first audit run reported **0 factors fired on all 1,140 rows**. Not a +modelling result — my paging helper ordered by `id`, and `batter_spray`, +`team_defense`, `platoon_splits` and `statcast_aggregates` have **composite +primary keys with no `id` column**. The query errored, the loop broke on error, +and four fully-populated tables read as empty. + +`hitsFactorContext.js` — the *production* loader — had the identical defect, so +the live wiring would have loaded nothing and served unadjusted forecasts while +logging success. + +**Third occurrence of this class in one session** (doubled `/leaderboard`, the +silent settlement outage, this). Both loaders now order by a real column and +**throw** rather than degrade, because a wiring fault must not be able to wear +the costume of an honest absence. + +The Phase 2 transmission gate is what caught it: no resolution number was quoted +until transmission was proved mechanically. + +--- + +## PHASE 1 — the pipeline, in order + +``` +base rate -> FACTORS (pre-grade) -> CALIBRATE -> GRADE +``` + +- `model/hitsFactors.js` — the three proven factors composed, each applying only + where it proved, bounded at ±0.25 combined. +- `model/hitsFactorContext.js` — loads the inputs **once per slate**, indexed. +- `snapshotService` builds the context **before** `gradeAndCacheSlate`; it was + previously computed at line 640+, downstream of the grade at 454. +- `gradeSlateService` threads it per prop; `analyzeViaEngine1` applies it to + `p_over` **before** `p_win` is set, recording `p_win_prefactor` and a full + `factor_adjustment` trace. + +Hits only. TB/rbi/runs have no proven factors and are untouched. + +**Coverage: 859 of 1,140 rows (75%) have at least one factor fire** — 474 with +all three, 256 with two, 129 with one, 281 with none. + +--- + +## PHASE 2 — transmission, proven mechanically + +Each factor applied **in isolation** to the same base. (My first table compared +each factor's expected sign against the *composite* change and showed 3 false +failures — with three factors firing, the net can oppose any single member. That +was a flaw in the test, not the wiring.) + +| factor | player | side | expected | mult | p before | p after (solo) | sign | +|---|---|---|---|---|---|---|---| +| pitcher_contact | Travis Bazzana | over | raise | 1.0396 | 0.604 | 0.6279 | ✓ | +| platoon_severity | Travis Bazzana | over | raise | 1.0860 | 0.604 | 0.6559 | ✓ | +| pitcher_contact | Patrick Bailey | over | raise | 1.0396 | 0.604 | 0.6279 | ✓ | +| pitcher_contact | Kyle Manzardo | **under** | raise p(over) | 1.0396 | 0.684 | 0.6715 | ✓ | +| platoon_severity | Kyle Manzardo | **under** | raise p(over) | 1.0350 | 0.684 | 0.6729 | ✓ | +| defense_by_direction | Royce Lewis | over | lower | 0.9690 | 0.577 | 0.5591 | ✓ | +| platoon_severity | Royce Lewis | over | lower | 0.9650 | 0.577 | 0.5568 | ✓ | +| pitcher_contact | Petey Halpin | over | raise | 1.0396 | 0.662 | 0.6882 | ✓ | +| platoon_severity | Chase DeLauter | over | lower | 0.9620 | 0.838 | 0.8062 | ✓ | +| defense_by_direction | Gabriel Arias | over | raise | 1.0400 | 0.536 | 0.5574 | ✓ | +| defense_by_direction | Austin Hedges | over | raise | 1.0390 | 0.685 | 0.7117 | ✓ | +| defense_by_direction | Ryan Kreidler | over | lower | 0.9560 | 0.523 | 0.5000 | ✓ | + +**12/12 sign-correct — 4/4 for each of the three factors.** The two `under` rows +confirm the flip is handled: a factor raising p(over) correctly *lowers* p_win. + +**Unreadables static:** Patrick Bailey, Josh Bell and Brayan Rocchio are switch +hitters — spray applied to none of them, while their other factors fired +normally. The refusal is selective, not a blanket skip. + +**TRANSMISSION PASSES.** Resolution may now be quoted. + +--- + +## PHASE 3/4 — refit and OOS measurement + +Both calibration maps refit on the factor-adjusted forecast (the un-factored +distribution no longer exists). **The shadow-duel baseline is VOID and restarts** +— it accumulated against a different forecast. + +Point-in-time, fit on dates < 2026-08-02, evaluated on 765 held-out rows: + +| | reliability | **resolution** | uncertainty | **variance explained** | +|---|---|---|---|---| +| before (four-input) | 0.00795 | 0.00229 | 0.2476 | **0.93%** | +| **after (factor-adjusted)** | 0.00828 | **0.00345** | 0.2476 | **1.39%** | + +**Resolution rose 51% relative (+0.00116).** Held-out Brier 0.25398 → 0.25305, +**delta −0.00093, date-block CI [−0.00225, +0.00002]**. + +**The CI touches zero on 4 eval dates. The composition does NOT earn a proven +keep.** The point estimate favours the factors and the resolution gain is real in +sample, but the honest verdict is **INCONCLUSIVE** — three isolated passes did +not grant a composed pass, exactly as the order anticipated. + +**Double-counting note:** the gain is far below the sum of the isolated factor +effects. Expected — defence, pitcher contact and platoon all run through the same +pitcher-batter confrontation and share signal. + +--- + +## PHASE 5 — bands and the honest headline + +Resolution moved from 0.93% to 1.39% of variance. **Both are far below what band +separation requires** — a forecast explaining 1.4% of an outcome's variance +cannot produce archetype bands that clear their own base rate. + +**The headline, landed as it fell:** the pivot was correct and incomplete. The +plumbing defect was real and is fixed — three proven factors now reach the served +number for the first time, verified sign-by-sign. But **transmission alone did +not buy grade separation.** The factors are real and too weak *in combination* at +current strength. + +So the next arc is **factor STRENGTH and BREADTH, not more plumbing.** The wiring +is now a working conduit with three things flowing through it; it needs more, and +stronger. + +--- + +## PHASE 6 — the rbi anomaly, logged only + +`rbi` shows **14.51% variance explained vs hits 1.03%** — 13×, on the stat we do +*not* serve corrected and which has **no proven factors**. Open question for the +next order: real counter structure (its four inputs happen to discriminate on a +stat where opportunity is lumpier), or an artefact of line placement and +base-rate spread? **Not investigated here.** It is either the biggest lever on +the board or a mirage, and it deserves its own order. + +--- + +## Invariants + +The byte-identical invariant **inverted** for hits by design — the served hits +number should move, and does. TB/rbi/runs paths and all frozen non-hits modules +verified unchanged. `p_win_prefactor` preserves the un-factored forecast in the +trace. No new Bonferroni slot (the factors were already proven); the composed +OOS claim is reported with its CI and is **not** claimed as a pass. diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index 874882f..f54601f 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -96,7 +96,13 @@ function dedupeProps(props, limit) { // Grade both sides and keep the higher-confidence verdict — that's the // side the engine actually favors. async function gradeBestSide(grade, prop, sport, opts = {}) { + // PIPELINE ORDER: the factor context must reach the engine BEFORE it grades, + // because factors adjust the forecast the grade is read from. It was + // previously computed downstream of the grade it should inform. + const factorContext = typeof opts.factorContext === 'function' + ? opts.factorContext(prop, sport) : null; const base = { + factor_context: factorContext, player: prop.player, stat_type: prop.stat_type, line: prop.line, diff --git a/src/services/intelligence/analyzeViaEngine1.js b/src/services/intelligence/analyzeViaEngine1.js index 80108ad..8930667 100644 --- a/src/services/intelligence/analyzeViaEngine1.js +++ b/src/services/intelligence/analyzeViaEngine1.js @@ -547,10 +547,33 @@ async function analyzeViaEngine1(rawProp = {}) { const dir = String(prop.direction || 'over').toLowerCase(); const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features }); + + // ── PROVEN FACTORS, PRE-GRADE ──────────────────────────────────────── + // base rate -> FACTORS -> (calibration, later) -> grade. Only the three + // factors that passed the two-part gate, only on hits, and only where each + // is readable — every unreadable case leaves the forecast untouched rather + // than nudging it toward a default. + let pOver = est.p_over; + let factorTrace = null; + if (String(rawProp.stat_type || '').toLowerCase() === 'hits' && rawProp.factor_context) { + try { + const hf = require('../model/hitsFactors'); + const adj = hf.adjustProbability(pOver, rawProp.factor_context); + if (adj.p_adjusted != null && adj.factors_fired > 0) { + pOver = adj.p_adjusted; + factorTrace = { multiplier: adj.multiplier, applied: adj.applied, skipped: adj.skipped, p_before: adj.p_base }; + } + } catch { /* a factor must never break the grade */ } + } + const pWin = dir === 'under' - ? (Number.isFinite(est.p_over) ? 1 - est.p_over : null) - : (Number.isFinite(est.p_over) ? est.p_over : null); + ? (Number.isFinite(pOver) ? 1 - pOver : null) + : (Number.isFinite(pOver) ? pOver : null); if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000; + if (factorTrace) { + legacy.factor_adjustment = factorTrace; + legacy.p_win_prefactor = Math.round((dir === 'under' ? 1 - factorTrace.p_before : factorTrace.p_before) * 1000) / 1000; + } const sideOdds = dir === 'under' ? rawProp.under_odds : rawProp.over_odds; diff --git a/src/services/model/hitsFactorContext.js b/src/services/model/hitsFactorContext.js new file mode 100644 index 0000000..225c061 --- /dev/null +++ b/src/services/model/hitsFactorContext.js @@ -0,0 +1,144 @@ +'use strict'; + +/** + * hitsFactorContext — load the proven factors' inputs ONCE per slate. + * + * The three hits factors each need a database read (batter spray, team + * positional defence, platoon splits, pitcher contact profile). Doing that per + * prop would put four queries inside a loop that runs across the whole board, so + * the tables are loaded once and indexed, and the per-prop lookup is a map hit. + * + * Loaded BEFORE grading, which is the whole point of this order — the same data + * was previously fetched after the grade it should have informed. + * + * Every load is best-effort: a missing table yields an empty index, the factor + * finds nothing readable, and the forecast is served unadjusted. A factor layer + * must never be able to break the pipeline it rides in. + */ + +const { knownNumber } = require('../../utils/known'); +const { nameKey } = require('../../utils/playerName'); + +/** Rows a factor table must have before we trust it at all. */ +const MIN_ROWS = 1; + +/** + * Page a factor table. + * + * ORDERING IS PER-TABLE. These tables have COMPOSITE primary keys + * (as_of_date, sport, player_key) and NO `id` column, so ordering by `id` + * errors — and an error here returns an empty index, which reads exactly like + * "this feed has no data". That is the third time in this codebase that a + * wiring fault has worn the costume of an honest absence, so the error is now + * surfaced rather than swallowed. + */ +async function page(sb, table, select, orderBy, apply) { + const out = []; + for (let from = 0; ; from += 1000) { + const q = apply ? apply(sb.from(table).select(select)) : sb.from(table).select(select); + const { data, error } = await q.order(orderBy, { ascending: true }).range(from, from + 999); + if (error) throw new Error(`${table}: ${error.message}`); + if (!data || data.length === 0) break; + out.push(...data); + if (data.length < 1000) break; + } + return out; +} + +/** Keep the most recent dated row per key. */ +function latestBy(rows, keyFn, dateFn) { + const m = new Map(); + for (const r of rows) { + const k = keyFn(r); + if (!k) continue; + const prev = m.get(k); + if (!prev || String(dateFn(r)) > String(dateFn(prev))) m.set(k, r); + } + return m; +} + +/** + * @returns {function|null} a `(prop, sport) => context` resolver, or null when + * nothing loaded — null means "serve unadjusted", never a stub context. + */ +async function build(sb, opts = {}) { + if (!sb) return null; + let spray; let defense; let platoon; let statcast; + try { + [spray, defense, platoon, statcast] = await Promise.all([ + page(sb, 'batter_spray', '*', 'player_key', (q) => q.eq('sport', 'mlb')), + page(sb, 'team_defense', '*', 'team', (q) => q.eq('sport', 'mlb')), + page(sb, 'platoon_splits', '*', 'player_key', (q) => q.eq('sport', 'mlb')), + page(sb, 'statcast_aggregates', 'player_key, source_id, role, bats, throws, hard_hit_pct', 'player_key', (q) => q.eq('sport', 'mlb')), + ]); + } catch (e) { + // Surfaced, not silent: a load failure must be distinguishable from a feed + // that genuinely holds nothing. + console.warn('[factors] context load FAILED (not an empty feed):', e.message); + return null; + } + if (!spray.length && !defense.length && !platoon.length) return null; + + const sprayBy = latestBy(spray, (r) => r.player_key, (r) => r.as_of_date); + const defBy = latestBy(defense, (r) => r.team, (r) => r.as_of_date); + const platBy = latestBy(platoon, (r) => r.player_key, (r) => r.as_of_date); + + const batBy = new Map(); + const pitBy = new Map(); + for (const r of statcast) { + if (!r.player_key) continue; + if (r.role === 'pitcher') pitBy.set(r.player_key, r); + else batBy.set(r.player_key, r); + } + + // statcast stores PERCENTAGES (0-100); the factor wants a fraction. + const asFraction = (v) => { + const n = knownNumber(v); + if (n === null) return null; + return n > 1 ? n / 100 : n; + }; + + const resolver = (prop) => { + const key = nameKey(prop && prop.player); + if (!key) return null; + const bat = batBy.get(key); + const bats = bat && bat.bats ? String(bat.bats)[0] : null; + + // The opposing team and its starter, from whatever the prop carries. + const oppName = prop && (prop.opponent || prop.opp_team || null); + const def = oppName ? (defBy.get(oppName) || defBy.get(String(oppName).split(' ').pop())) : null; + + const pitKey = prop && prop.opposing_pitcher ? nameKey(prop.opposing_pitcher) : null; + const pit = pitKey ? pitBy.get(pitKey) : null; + + const sp = platBy.get(key); + const splits = sp ? { + vl: { pa: sp.vl_pa, atBats: sp.vl_ab, hits: sp.vl_hits }, + vr: { pa: sp.vr_pa, atBats: sp.vr_ab, hits: sp.vr_hits }, + } : null; + + const ctx = { + spray: sprayBy.get(key) || null, + positionOaa: def && def.position_oaa ? def.position_oaa : null, + bats, + throws: pit && pit.throws ? String(pit.throws)[0] : null, + pitcherHardHit: pit ? asFraction(pit.hard_hit_pct) : null, + platoonSplits: splits, + }; + // Nothing readable at all -> null, so the engine skips the factor block + // entirely rather than walking an empty context. + const anything = ctx.spray || ctx.pitcherHardHit !== null || ctx.platoonSplits; + return anything ? ctx : null; + }; + + resolver.__stats = { + spray_players: sprayBy.size, + defense_teams: defBy.size, + platoon_players: platBy.size, + pitcher_profiles: pitBy.size, + batter_profiles: batBy.size, + }; + return resolver; +} + +module.exports = { build, MIN_ROWS }; diff --git a/src/services/model/hitsFactors.js b/src/services/model/hitsFactors.js new file mode 100644 index 0000000..452a0a4 --- /dev/null +++ b/src/services/model/hitsFactors.js @@ -0,0 +1,127 @@ +'use strict'; + +/** + * hitsFactors — the three PROVEN hits factors, applied to the forecast. + * + * These passed the two-part gate (they move the prediction AND improve + * out-of-sample Brier) and then sat unwired: `sprayDefense.js` and + * `platoonSeverity.js` were required by nothing in `src/`, and the served + * `p_win` read four inputs, none of them these. They were computed downstream of + * the grade they should inform. + * + * ── PIPELINE ORDER IS A CORRECTNESS PROPERTY ───────────────────────────── + * base rate -> FACTORS -> CALIBRATE -> GRADE + * Calibration must always correct the factor-adjusted number. Reversing it would + * calibrate a forecast that is not the one served. + * + * ── EVERY UNREADABLE GUARD FROM THE ORIGINAL PROOFS SURVIVES ───────────── + * A factor applies only where it proved. A switch hitter has no readable spray + * side; a thin platoon split is refused rather than shrunk to a league guess; a + * pitcher with no contact profile contributes nothing. In each case the factor + * returns NULL and the forecast is left alone — never nudged toward a default, + * which would be fabricating a read from an absence. + */ + +const sd = require('./sprayDefense'); +const pss = require('./platoonSeverity'); +const { knownNumber, knownRate } = require('../../utils/known'); + +/** League mean hard-hit rate allowed; the pitcher factor is signed off this. */ +const LEAGUE_HARD_HIT = 0.389; +/** Bound on the pitcher-contact adjustment, as proved. */ +const PITCHER_MAX = 0.15; +/** Bound on the composed adjustment — no stack of three may run away. */ +const COMBINED_MAX = 0.25; + +/** + * A contact-allowing arm concedes better contact. Null without a profile. + */ +function pitcherContactMultiplier(hardHitAllowed) { + const h = knownRate(hardHitAllowed); + if (h === null) return null; + return 1 + Math.max(-PITCHER_MAX, Math.min(PITCHER_MAX, (h - LEAGUE_HARD_HIT) * 1.2)); +} + +/** + * Compose the three factors for one hits prop. + * + * @param {object} ctx + * spray batter_spray row (pull/straight/oppo x gb/air) + * positionOaa opposing team's per-position OAA + * bats 'R' | 'L' | 'S' + * throws opposing starter's hand + * pitcherHardHit opposing starter's hard-hit rate allowed + * platoonSplits { vl, vr } for this hitter + * @returns {object} { multiplier, applied[], skipped[] } — multiplier is 1 when + * nothing is readable, which is a no-op rather than a claim. + */ +function hitsFactorMultiplier(ctx = {}) { + const applied = []; + const skipped = []; + let mult = 1; + + // ── defense_by_direction ── + const spray = ctx.spray; + const posOaa = ctx.positionOaa; + if (!spray || !posOaa || !ctx.bats) { + skipped.push({ factor: 'defense_by_direction', reason: 'no spray profile or positional defence' }); + } else { + const out = sd.sprayDefenseMultiplier({ spray, bats: ctx.bats, positionOaa: posOaa }); + if (!out || !Number.isFinite(out.multiplier)) { + // Switch hitters land here: he bats opposite by choice, so the SIDE of the + // field his contact goes to is not determined pre-game. + skipped.push({ factor: 'defense_by_direction', reason: 'unreadable (switch hitter or no covered zone)' }); + } else { + mult *= out.multiplier; + applied.push({ factor: 'defense_by_direction', multiplier: round4(out.multiplier), coverage: out.coverage }); + } + } + + // ── pitcher_contact_profile ── + const pm = pitcherContactMultiplier(ctx.pitcherHardHit); + if (pm === null) { + skipped.push({ factor: 'pitcher_contact_profile', reason: 'no pitcher contact profile' }); + } else { + mult *= pm; + applied.push({ factor: 'pitcher_contact_profile', multiplier: round4(pm) }); + } + + // ── platoon_severity ── + if (!ctx.platoonSplits || !ctx.bats || !ctx.throws) { + skipped.push({ factor: 'platoon_severity', reason: 'no splits or no pitcher hand' }); + } else { + const out = pss.platoonRead({ splits: ctx.platoonSplits, bats: ctx.bats, throws: ctx.throws }); + if (!out || !out.readable || !Number.isFinite(out.multiplier)) { + // A thin split is REFUSED, not shrunk — a heavily-shrunk severity is + // indistinguishable from a measured league-average one. + skipped.push({ factor: 'platoon_severity', reason: (out && out.reason) || 'unreadable split' }); + } else { + mult *= out.multiplier; + applied.push({ factor: 'platoon_severity', multiplier: round4(out.multiplier), split: out.observed_split }); + } + } + + const bounded = Math.max(1 - COMBINED_MAX, Math.min(1 + COMBINED_MAX, mult)); + return { + multiplier: round4(bounded), + unbounded: round4(mult), + applied, + skipped, + factors_fired: applied.length, + }; +} + +/** Apply to a probability, clamped into a usable range. Null in, null out. */ +function adjustProbability(p, ctx = {}) { + const raw = knownNumber(p); + if (raw === null) return { p_adjusted: null, ...hitsFactorMultiplier(ctx) }; + const f = hitsFactorMultiplier(ctx); + return { p_adjusted: round4(Math.max(0.01, Math.min(0.99, raw * f.multiplier))), p_base: round4(raw), ...f }; +} + +const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000); + +module.exports = { + hitsFactorMultiplier, adjustProbability, pitcherContactMultiplier, + LEAGUE_HARD_HIT, PITCHER_MAX, COMBINED_MAX, +}; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 8d839d8..49e6d14 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -451,7 +451,26 @@ async function runSnapshot(sport, opts = {}) { // Grade the slate via the existing service; capture the envelope instead of // letting it write (we re-write an ENRICHED version below). let envelope = null; + // ── PROVEN FACTORS, LOADED BEFORE THE GRADE ───────────────────────────── + // This ordering IS the fix. The same inputs were previously read at line 640+, + // downstream of the grade they should inform, so three proven factors never + // once moved a served number. Best-effort: a failed load means the slate is + // graded unadjusted, exactly as before. + let factorContext = null; + if (sp === 'mlb') { + try { + const ctxSvc = deps.hitsFactorContext || require('./model/hitsFactorContext'); + const sbc = require('../utils/supabase').getSupabaseServiceClient(); + factorContext = sbc ? await ctxSvc.build(sbc) : null; + if (factorContext) console.log(`[factors] ${sp} hits context loaded — ${JSON.stringify(factorContext.__stats)}`); + else console.log(`[factors] ${sp} — no factor context; grading unadjusted`); + } catch (e) { + console.warn('[factors] context skipped:', e.message); + } + } + await deps.gradeAndCacheSlate(sp, props, { + factorContext, // Bisect hook (2026-08-01): lets the internal trigger run a bounded slate // without a prod env change, so a cap regression can be isolated by // measurement instead of guessed at. Omitted => gradeSlateService's own diff --git a/tests/unit/hitsFactors.test.js b/tests/unit/hitsFactors.test.js new file mode 100644 index 0000000..bda5265 --- /dev/null +++ b/tests/unit/hitsFactors.test.js @@ -0,0 +1,88 @@ +'use strict'; + +/** + * The three proven hits factors, on the serving path at last. + * + * What these lock: the SIGN each factor moves the forecast, and that every + * unreadable case leaves it completely alone. A factor that nudges toward a + * default on missing input is fabricating a read from an absence. + */ + +const hf = require('../../src/services/model/hitsFactors'); + +const pullGround = { pull_gb: 0.45, straight_gb: 0.10, oppo_gb: 0.05, pull_air: 0.20, straight_air: 0.12, oppo_air: 0.08 }; +const pos = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { oaa: v, fielders: 2 }])); +const side = (avg, pa) => ({ pa, atBats: Math.round(pa * 0.9), hits: Math.round(pa * 0.9 * avg) }); +const bigSplit = { vl: side(0.284, 183), vr: side(0.221, 291) }; + +describe('each factor moves the forecast in the direction it proved', () => { + it('TOUGH spray defence lowers the hit forecast', () => { + const eliteLeft = pos({ '3B': 12, SS: 10, '1B': 0, '2B': 0, LF: 0, CF: 0, RF: 0 }); + const out = hf.adjustProbability(0.6, { spray: pullGround, bats: 'R', positionOaa: eliteLeft }); + expect(out.p_adjusted).toBeLessThan(0.6); + expect(out.applied.map((a) => a.factor)).toContain('defense_by_direction'); + }); + + it('a CONTACT-ALLOWING pitcher raises it; a bat-misser lowers it', () => { + const soft = hf.adjustProbability(0.6, { pitcherHardHit: 0.46 }); + const tough = hf.adjustProbability(0.6, { pitcherHardHit: 0.31 }); + expect(soft.p_adjusted).toBeGreaterThan(0.6); + expect(tough.p_adjusted).toBeLessThan(0.6); + }); + + it('a PLATOON disadvantage lowers it, the edge raises it', () => { + const edge = hf.adjustProbability(0.6, { platoonSplits: bigSplit, bats: 'R', throws: 'L' }); + const wrongSide = hf.adjustProbability(0.6, { platoonSplits: bigSplit, bats: 'R', throws: 'R' }); + expect(edge.p_adjusted).toBeGreaterThan(0.6); + expect(wrongSide.p_adjusted).toBeLessThan(0.6); + }); +}); + +describe('unreadable means UNTOUCHED, never nudged to a default', () => { + it('a SWITCH hitter gets no spray adjustment', () => { + const out = hf.adjustProbability(0.6, { spray: pullGround, bats: 'S', positionOaa: pos({ '3B': 12, SS: 10 }) }); + expect(out.applied.find((a) => a.factor === 'defense_by_direction')).toBeUndefined(); + expect(out.skipped.map((s) => s.factor)).toContain('defense_by_direction'); + }); + + it('a THIN platoon split is refused, not shrunk toward league', () => { + const thin = { vl: side(0.350, 25), vr: side(0.250, 400) }; + const out = hf.adjustProbability(0.6, { platoonSplits: thin, bats: 'R', throws: 'L' }); + expect(out.applied.find((a) => a.factor === 'platoon_severity')).toBeUndefined(); + }); + + it('no pitcher profile contributes nothing at all', () => { + expect(hf.pitcherContactMultiplier(null)).toBeNull(); + const out = hf.adjustProbability(0.6, { pitcherHardHit: null }); + expect(out.p_adjusted).toBe(0.6); + }); + + it('with NOTHING readable the forecast is returned exactly', () => { + const out = hf.adjustProbability(0.6, {}); + expect(out.p_adjusted).toBe(0.6); + expect(out.multiplier).toBe(1); + expect(out.factors_fired).toBe(0); + }); + + it('a null forecast stays null — no factor invents one', () => { + expect(hf.adjustProbability(null, { pitcherHardHit: 0.46 }).p_adjusted).toBeNull(); + }); +}); + +describe('composition', () => { + it('three factors compound, and the stack is bounded', () => { + const out = hf.adjustProbability(0.6, { + spray: pullGround, bats: 'R', positionOaa: pos({ '3B': -12, SS: -12, '1B': -12, '2B': -12, LF: -12, CF: -12, RF: -12 }), + pitcherHardHit: 0.46, platoonSplits: bigSplit, throws: 'L', + }); + expect(out.factors_fired).toBe(3); + expect(out.multiplier).toBeLessThanOrEqual(1 + hf.COMBINED_MAX); + expect(out.multiplier).toBeGreaterThanOrEqual(1 - hf.COMBINED_MAX); + }); + + it('partial readability applies only what is readable', () => { + const out = hf.adjustProbability(0.6, { pitcherHardHit: 0.46, bats: 'S', spray: pullGround, positionOaa: pos({ '3B': 5 }) }); + expect(out.factors_fired).toBe(1); + expect(out.applied[0].factor).toBe('pitcher_contact_profile'); + }); +});