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'); + }); +});