From 6386e737b903754740e2460763829366ccd6d141 Mon Sep 17 00:00:00 2001 From: Kev Date: Thu, 23 Jul 2026 03:59:36 -0400 Subject: [PATCH] proj-v1: absolute matchup projection challenger (distribution + full ladder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A THIRD challenger (after arch-v1, contact-v1), MLB batting v1. Champion is market-relative P(stat>LINE); proj-v1 is ABSOLUTE — what the hitter will DO — emitted as a full distribution from which the WHOLE LADDER (P≥1,P≥2,P≥3) derives. Champion untouched; nothing claimed; the ledger decides per rung, per stat. - projection/distribution.js — Bayesian Gamma-Poisson → negative-binomial predictive. Admits over-dispersion; under-dispersion → Poisson approx (conservative, documented). Uncertainty scales with sample by construction (r=α): thin → WIDE (real mass on P≥1, honestly thin P≥3), thick → tight. NEVER abstains — width carries the honesty. - projection/matchupRead.js — the input the book doesn't use. HONEST FIDELITY: pitcher repertoire is rich (97% pitch-mix) but hitters have NO pitch-type performance, so TRUE repertoire-vs-profile is impossible today. This is the COARSE version (arsenal buckets fastball/sinker/breaking + whiff/hard-hit tendency × hitter whiff/chase/gb-fb/hard-hit) — beats generic L/R, derived + documented + TESTED two-sided. A hitter pitch-type feed unlocks the true form. - projectionChallenger.js — park RELATIVE to the player's own log exposure (isHome→own park, away→opp park; Phase B's raw-multiply bug solved), recency- weighted fit, per-factor breakdown (form/park/weather/platoon/matchup — show your work), full rung set + book-implied per rung. Combined non-form multiplier bounded. - Wired after contact-v1, own try, flag PROJ_V1_ENABLED, reusing arch-v1's already-computed park/weather/platoon (no duplicate env I/O). Own ledger columns (migration 032, applied to prod): distribution, ladder, point, line, our-P, book-implied, factor breakdown — measurable per rung/stat after settle. Phase 0 (prod-verified): venue join via isHome; NB family; uncertainty-as-width; coarse matchup honest fidelity; no lineup-slot (per-game rate, volume implicit). Sanity: thin-hot → wide (credible low rung, thin high rung); .300 hitter ≠ 3.0; matchup two-sided; champion byte-identical. proj-v1 suites 23/23; snapshot/ ledger/siblings 74 green. Forward-only, version-stamped, PROJ_V1_ENABLED kill. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj --- src/services/ledgerService.js | 11 + src/services/projection/distribution.js | 119 +++++++++ src/services/projection/matchupRead.js | 157 +++++++++++ src/services/projectionChallenger.js | 247 ++++++++++++++++++ src/services/snapshotService.js | 62 +++++ .../migrations/032_projection_challenger.sql | 19 ++ tests/unit/projectionChallenger.test.js | 137 ++++++++++ tests/unit/projectionDistribution.test.js | 63 +++++ 8 files changed, 815 insertions(+) create mode 100644 src/services/projection/distribution.js create mode 100644 src/services/projection/matchupRead.js create mode 100644 src/services/projectionChallenger.js create mode 100644 supabase/migrations/032_projection_challenger.sql create mode 100644 tests/unit/projectionChallenger.test.js create mode 100644 tests/unit/projectionDistribution.test.js diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 41daa54..1e1680d 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -263,6 +263,17 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) { contact_delta: numOrNull(g.contact_delta), contact_adjustments: g.contact_adjustments || null, contact_version: g.contact_version || null, + // proj-v1 — the ABSOLUTE matchup projection challenger. Own columns so the + // full rung set + per-factor breakdown + book-implied comparison are + // independently measurable, per rung, per stat, after settle. + proj_version: g.proj_version || null, + proj_point: numOrNull(g.proj_point), + proj_line: numOrNull(g.proj_line), + proj_p_over_line: numOrNull(g.proj_p_over_line), + proj_book_implied: numOrNull(g.proj_book_implied), + proj_distribution: g.proj_distribution || null, + proj_ladder: g.proj_ladder || null, + proj_factors: g.proj_factors || null, // Session 75 — the ENVIRONMENT that drove this projection. The FORECAST, // not the actual: this is what we knew when we projected, and it is what // the instrument measures. The actual lands in game_context and is never diff --git a/src/services/projection/distribution.js b/src/services/projection/distribution.js new file mode 100644 index 0000000..715d3b4 --- /dev/null +++ b/src/services/projection/distribution.js @@ -0,0 +1,119 @@ +'use strict'; + +/** + * proj-v1 DISTRIBUTION — Bayesian Gamma-Poisson → Negative-Binomial predictive. + * + * The champion is market-relative: P(stat > LINE). proj-v1 is ABSOLUTE: what + * will the player DO? We model the per-game count of a stat as Poisson with an + * unknown rate λ, put a Gamma prior on λ, and update it with the player's + * (recency-weighted) game log. The posterior-predictive for the next game is a + * NEGATIVE BINOMIAL — from which the WHOLE LADDER P(≥1), P(≥2), … derives. + * + * WHY THIS SHAPE (not a point + abstain): + * - Over-dispersion is admitted: real count stats have variance ≥ mean (the + * rate itself varies game to game). NB carries that; a bare Poisson can't. + * - Parameter uncertainty scales with sample by construction. The posterior + * shape α = prior + Σ(recency-weighted counts); a thin 3-game sample gives a + * small α → a WIDE predictive → real mass on P(≥1) while P(≥3) stays honestly + * thin. A 60-game sample gives a large α → a TIGHT predictive. We ALWAYS + * project; the width is the honesty. There is no abstain. + * - Under-dispersion (IoD<1) is NOT representable by NB (its variance is always + * ≥ mean). v1 approximates it as Poisson (the tightest law in this family) — + * the conservative direction (a hair wide on the high rung), documented, not + * hidden. + * + * A rate MULTIPLIER (park-relative × weather × platoon × matchup) shifts the + * predictive MEAN while preserving the sample-driven dispersion: mean = α/β, so + * scaling β → β/M scales the mean by M and leaves r = α (the width) untouched. + * That keeps "how sure are we" tied to sample size, and "how much" to the read. + */ + +/** Lanczos log-gamma — NB pmf needs Γ at fractional r (α can be non-integer). */ +const G = 7; +const LZ = [ + 0.99999999999980993, 676.5203681218851, -1259.1392167224028, + 771.32342877765313, -176.61502916214059, 12.507343278686905, + -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7, +]; +function gammaln(x) { + if (x < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * x)) - gammaln(1 - x); + x -= 1; + let a = LZ[0]; + const t = x + G + 0.5; + for (let i = 1; i < G + 2; i++) a += LZ[i] / (x + i); + return 0.5 * Math.log(2 * Math.PI) + (x + 0.5) * Math.log(t) - t + Math.log(a); +} + +const isNum = (v) => typeof v === 'number' && Number.isFinite(v); + +/** + * gammaPoissonPosterior({ priorMean, priorGames, weightedSum, weightedGames }) + * — Gamma(α,β) posterior on the per-game rate. + * α = priorMean·priorGames + Σ(w·count), β = priorGames + Σw + * priorGames is the prior's effective sample (weak, so real games dominate fast). + */ +function gammaPoissonPosterior({ priorMean, priorGames = 4, weightedSum = 0, weightedGames = 0 } = {}) { + const m0 = isNum(priorMean) && priorMean > 0 ? priorMean : 0.01; + const pg = isNum(priorGames) && priorGames > 0 ? priorGames : 4; + const alpha = m0 * pg + Math.max(0, weightedSum); + const beta = pg + Math.max(0, weightedGames); + return { alpha, beta }; +} + +/** Shift the predictive MEAN by multiplier M, preserving dispersion (r = α). */ +function applyRateMultiplier({ alpha, beta }, m) { + const mult = isNum(m) && m > 0 ? m : 1; + return { alpha, beta: beta / mult }; +} + +/** Gamma(α,β) prior + Poisson → predictive NB(r=α, p=β/(β+1)). mean = α/β. */ +function nbFromPosterior({ alpha, beta }) { + const r = Math.max(1e-6, alpha); + const p = beta / (beta + 1); + return { r, p }; +} + +/** NB pmf P(X = k), k a non-negative integer, r possibly fractional. */ +function nbPmf(r, p, k) { + if (k < 0) return 0; + const logp = gammaln(k + r) - gammaln(r) - gammaln(k + 1) + + r * Math.log(p) + k * Math.log(1 - p); + return Math.exp(logp); +} + +/** P(X ≥ k) survival, summed from 0. Guarded (tail is finite in practice). */ +function nbSurvival(r, p, k) { + if (k <= 0) return 1; + let cdf = 0; + for (let x = 0; x < k; x++) cdf += nbPmf(r, p, x); + return Math.max(0, Math.min(1, 1 - cdf)); +} + +const nbMean = ({ r, p }) => (r * (1 - p)) / p; +const nbVariance = ({ r, p }) => (r * (1 - p)) / (p * p); + +/** + * ladder(nb, kMax) — the FULL RUNG SET [{ rung, p_at_least }], rung = 1..kMax. + * This is where a thin-but-real signal becomes actionable: P(≥1) can be a live + * read while P(≥3) is honestly thin, from the SAME fitted distribution. + */ +function ladder({ r, p }, kMax = 4) { + const out = []; + for (let k = 1; k <= kMax; k++) out.push({ rung: k, p_at_least: round3(nbSurvival(r, p, k)) }); + return out; +} + +const round3 = (x) => Math.round(x * 1000) / 1000; + +module.exports = { + gammaln, + gammaPoissonPosterior, + applyRateMultiplier, + nbFromPosterior, + nbPmf, + nbSurvival, + nbMean, + nbVariance, + ladder, + round3, +}; diff --git a/src/services/projection/matchupRead.js b/src/services/projection/matchupRead.js new file mode 100644 index 0000000..a098a90 --- /dev/null +++ b/src/services/projection/matchupRead.js @@ -0,0 +1,157 @@ +'use strict'; + +/** + * proj-v1 MATCHUP READ — coarse repertoire-vs-profile (Phase 2). + * + * This is the input the book DOESN'T use. Books price generic L/R handedness + * splits; they do not read THIS pitcher's arsenal against THIS hitter's profile. + * + * ── HONEST FIDELITY (Phase 0.4) ────────────────────────────────────────── + * We have RICH pitcher repertoire (statcast pitch_mix: per-pitch type, usage%, + * whiff%, hard_hit%). We do NOT have hitter pitch-type performance (no "damage + * vs sliders"). So TRUE repertoire-vs-profile is impossible today. This is the + * COARSE version: bucket the arsenal (fastball / sinker / breaking / offspeed + * heavy, + its whiff & hard-hit tendency) and read it against the hitter's + * AGGREGATE profile (whiff%, chase%, ground-ball vs fly-ball lean, hard-hit). + * It beats the generic L/R split; it is not pitch-type-damage matching. A + * hitter pitch-type feed would unlock the true version — see the roadmap. + * + * Every rule below is DERIVED from what the feeds carry and DOCUMENTED so the + * number's meaning is knowable later; matchupRead.test.js locks each direction. + * The output is a small, bounded rate MULTIPLIER per stat plus a per-component + * breakdown (so the terminal can show its work). + */ + +const isNum = (v) => typeof v === 'number' && Number.isFinite(v); +const num = (v) => (isNum(v) ? v : (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null))); +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + +// Pitch-type families (statcast `type` codes). +const FASTBALL = new Set(['FF', 'FC']); // 4-seam, cutter (true rising/hard) +const SINKER = new Set(['SI', 'FT']); // sinker / 2-seam (ground-ball inducing) +const BREAKING = new Set(['SL', 'CU', 'ST', 'SV', 'KC', 'SC']); +const OFFSPEED = new Set(['CH', 'FS', 'FO']); + +/** + * classifyArsenal(pitch_mix) — usage-weighted buckets + tendency. Returns null + * when the mix is absent (honest-absent → no matchup contribution). + */ +function classifyArsenal(pitchMix) { + if (!Array.isArray(pitchMix) || !pitchMix.length) return null; + let total = 0; const b = { fastball: 0, sinker: 0, breaking: 0, offspeed: 0 }; + let whiffW = 0; let hardW = 0; + for (const pt of pitchMix) { + const use = num(pt && pt.usage_pct); + const type = String(pt && pt.type || '').toUpperCase(); + if (use == null || use <= 0) continue; + total += use; + if (FASTBALL.has(type)) b.fastball += use; + else if (SINKER.has(type)) b.sinker += use; + else if (BREAKING.has(type)) b.breaking += use; + else if (OFFSPEED.has(type)) b.offspeed += use; + const w = num(pt.whiff_pct); if (w != null) whiffW += use * w; + const h = num(pt.hard_hit_pct); if (h != null) hardW += use * h; + } + if (total <= 0) return null; + return { + fastball_pct: b.fastball / total, + sinker_pct: b.sinker / total, + breaking_pct: b.breaking / total, + offspeed_pct: b.offspeed / total, + whiff_tendency: whiffW / total, // arsenal-wide whiff% (swing-and-miss stuff) + hard_hit_tendency: hardW / total, // contact quality the arsenal allows + }; +} + +/** + * hitterProfile(batterRow) — the aggregate tendencies we DO have. gb/fb lean + * comes from gb_pct_bb vs fb_ld_pct in the metrics blob (batted-ball type). + */ +function hitterProfile(row) { + if (!row) return null; + const m = row.metrics || {}; + const gb = num(m.gb_pct_bb); + const fb = num(m.fb_ld_pct); + let flyLean = null; + if (gb != null && fb != null && (gb + fb) > 0) flyLean = fb / (gb + fb); // 0..1, high = fly-ball + return { + whiff: num(row.whiff_pct) ?? num(m.whiff_pct), + chase: num(row.chase_pct) ?? num(m.chase_pct), + hard_hit: num(row.hard_hit_pct) ?? num(m.hard_hit_pct), + fly_lean: flyLean, // fraction of batted balls in the air + }; +} + +// League anchors (2026 percentiles, verified in prod) — the neutral point each +// tendency is measured against, so "high"/"low" means relative to the league. +const ANCHOR = Object.freeze({ + arsenal_whiff: 22, hitter_whiff: 24, hard_hit: 38, fly_lean: 0.5, +}); +// Max single-component lean and total matchup lean — coarse read, small effect. +const COMP_MAX = 0.05; +const TOTAL_MAX = Number(process.env.PROJ_MATCHUP_MAX || 0.10); + +const z = (v, anchor, scale) => (v == null ? 0 : clamp((v - anchor) / scale, -1, 1)); + +/** + * matchupMultiplier({ arsenal, hitter, statType }) — per-stat rate multiplier + * (≈1.0 neutral) + a documented per-component breakdown. Coarse, bounded, and + * ABSENT (multiplier 1, components []) when either side is missing. + */ +function matchupMultiplier({ arsenal, hitter, statType } = {}) { + const stat = String(statType || '').toLowerCase(); + const none = { multiplier: 1, components: [], reason: 'insufficient_matchup_data' }; + if (!arsenal || !hitter) return none; + + const comps = []; + const add = (label, lean) => { if (lean) comps.push({ label, lean: Math.round(lean * 1000) / 1000 }); }; + + // (1) WHIFF axis — a high-whiff arsenal vs a whiff-prone hitter suppresses + // balls in play (fewer hits), and raises strikeouts. DERIVED: arsenal + // whiff tendency × hitter whiff, both league-relative. + const arsWhiff = z(arsenal.whiff_tendency, ANCHOR.arsenal_whiff, 8); + const hitWhiff = z(hitter.whiff, ANCHOR.hitter_whiff, 8); + const whiffPress = clamp(arsWhiff * (0.5 + 0.5 * Math.max(0, hitWhiff)), -1, 1); // stuff bites vulnerable hitters harder + + // (2) GROUND/AIR axis — a sinker/GB arsenal keeps the ball down, suppressing a + // fly-ball hitter's POWER (HR/TB) but yielding weak contact. A fastball/ + // fly-ball arsenal lets a fly-ball hitter elevate. DERIVED: sinker share + // vs hitter fly lean. + const groundy = clamp(arsenal.sinker_pct - 0.20, -0.5, 0.6) / 0.6; // >20% sinker = groundy + const flyBat = z(hitter.fly_lean, ANCHOR.fly_lean, 0.2); + const airSuppress = clamp(groundy * Math.max(0, flyBat), -1, 1); // groundy arm vs fly bat → power down + + // (3) HARD-CONTACT axis — an arsenal that allows hard contact, faced by a + // hard-hit hitter, lifts extra-base outcomes. + const arsHard = z(arsenal.hard_hit_tendency, ANCHOR.hard_hit, 6); + const hitHard = z(hitter.hard_hit, ANCHOR.hard_hit, 6); + const hardLift = clamp((arsHard + hitHard) / 2, -1, 1); + + let lean = 0; + if (stat === 'hits') { + add('whiff_vs_stuff', -COMP_MAX * whiffPress); lean += -COMP_MAX * whiffPress; + add('contact_quality', 0.4 * COMP_MAX * hardLift); lean += 0.4 * COMP_MAX * hardLift; + } else if (stat === 'strikeouts') { + add('whiff_vs_stuff', COMP_MAX * whiffPress); lean += COMP_MAX * whiffPress; // K prop moves WITH whiff pressure + } else if (stat === 'home_runs' || stat === 'total_bases' || stat === 'doubles' || stat === 'triples') { + add('air_suppression', -COMP_MAX * airSuppress); lean += -COMP_MAX * airSuppress; + add('hard_contact', COMP_MAX * hardLift); lean += COMP_MAX * hardLift; + add('whiff_vs_stuff', -0.4 * COMP_MAX * whiffPress); lean += -0.4 * COMP_MAX * whiffPress; + } else { + return none; // unmapped stat → no matchup lean + } + + const capped = clamp(lean, -TOTAL_MAX, TOTAL_MAX); + return { + multiplier: Math.round((1 + capped) * 1000) / 1000, + components: comps, + capped: capped !== lean, + reason: comps.length ? null : 'matchup_neutral', + }; +} + +module.exports = { + classifyArsenal, hitterProfile, matchupMultiplier, + COMP_MAX, TOTAL_MAX, ANCHOR, + __internals: { FASTBALL, SINKER, BREAKING, OFFSPEED, z }, +}; diff --git a/src/services/projectionChallenger.js b/src/services/projectionChallenger.js new file mode 100644 index 0000000..8e12819 --- /dev/null +++ b/src/services/projectionChallenger.js @@ -0,0 +1,247 @@ +'use strict'; + +/** + * proj-v1 — ABSOLUTE MATCHUP PROJECTION CHALLENGER (MLB batting, v1). + * + * A THIRD challenger, distinct from arch-v1 (market-relative p_win nudge) and + * contact-v1 (season contact quality). The champion projects P(stat > LINE); + * proj-v1 projects what the hitter will DO — an absolute per-game rate, made + * matchup-aware and PARK-RELATIVE (Phase B's raw-multiply bug solved by making + * park relative to the player's own log exposure), emitted as a FULL + * DISTRIBUTION from which the WHOLE LADDER (P≥1, P≥2, P≥3, …) derives. + * + * It NEVER abstains: a thin/hot sample yields a WIDE distribution — real mass on + * the LOW rung, honestly thin on the high rung — so thin signals become + * actionable at the right rung. Champion is READ, never written. Retained in + * proj-v1's OWN ledger columns; flag-gated PROJ_V1_ENABLED; own try in the + * snapshot. Nothing is claimed — the ledger decides, per rung, per stat. + */ + +const dist = require('./projection/distribution'); +const matchup = require('./projection/matchupRead'); +const parkBase = require('./parkBase'); +const { NAME_TO_ABBR } = require('./environmentContext'); + +const PROJ_VERSION = 'proj-v1'; +const PRIOR_GAMES = Number(process.env.PROJ_PRIOR_GAMES || 4); +const LADDER_MAX = Number(process.env.PROJ_LADDER_MAX || 4); +// Combined non-form multiplier bound (Phase B proved <0.12 stacked). A Coors + +// wind-out + platoon + matchup prop must not swing the rate absurdly. +const COMBINED_MAX = Number(process.env.PROJ_COMBINED_MAX || 0.35); + +/** MLB stat_type → statsapi game-log stat field (camelCase) + league prior rate + * fallback (per game) when a season rate is unavailable. */ +const STAT_FIELD = Object.freeze({ + hits: { field: 'hits', prior: 0.9 }, + total_bases: { field: 'totalBases', prior: 1.45 }, + home_runs: { field: 'homeRuns', prior: 0.15 }, + doubles: { field: 'doubles', prior: 0.18 }, + triples: { field: 'triples', prior: 0.02 }, + strikeouts: { field: 'strikeOuts', prior: 1.05 }, + rbi: { field: 'rbi', prior: 0.5 }, + runs: { field: 'runs', prior: 0.5 }, + walks: { field: 'baseOnBalls', prior: 0.32 }, +}); + +const isNum = (v) => typeof v === 'number' && Number.isFinite(v); +const num = (v) => { + if (v == null || v === '') return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; +}; +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); +const abbrOf = (team) => { + if (!team) return null; + const s = String(team).trim(); + if (/^[A-Z]{2,3}$/.test(s)) return s.toUpperCase(); + return NAME_TO_ABBR[s.toLowerCase()] || null; +}; + +/** Park factor (multiplier ~1.0) for a team's home venue, or null. */ +function parkFactorFor(teamAbbr) { + if (!teamAbbr) return null; + try { + const r = parkBase.resolveParkBase({ teamAbbr }); + return r && isNum(num(r.factor)) ? num(r.factor) : null; + } catch { return null; } +} + +/** + * parkBaselineFromLogs(gameLog, playerTeamAbbr) — the player's OWN average park + * exposure: home games use his park, away games use the opponent's. This is what + * makes tonight's park RELATIVE (tonight ÷ baseline), not a raw multiply. Null + * when we can't resolve enough venues (→ park contributes nothing, honest). + */ +function parkBaselineFromLogs(gameLog, playerTeamAbbr) { + const homePark = parkFactorFor(playerTeamAbbr); + const factors = []; + for (const g of gameLog || []) { + if (g == null || g.isHome == null) continue; + if (g.isHome) { if (homePark != null) factors.push(homePark); } + else { + const oppPark = parkFactorFor(abbrOf(g.opponent)); + if (oppPark != null) factors.push(oppPark); + } + } + if (factors.length < 3) return null; // too few resolved venues to trust a baseline + return factors.reduce((a, b) => a + b, 0) / factors.length; +} + +/** Recency-weighted observed counts (last-5 games weighted 2×, like the champion). */ +function recencyWeighted(gameLog, field, window = 20) { + // gameLog is most-recent-LAST (statsapi order); take the tail as recent. + const rows = (gameLog || []).filter((g) => g && g.stat); + const recent = rows.slice(-window); + const n = recent.length; + let wSum = 0; let wGames = 0; const vals = []; + recent.forEach((g, i) => { + const v = num(g.stat[field]); + if (v == null) return; + const w = i >= n - 5 ? 2 : 1; // last 5 of the window count double + wSum += w * v; wGames += w; vals.push(v); + }); + return { weightedSum: wSum, weightedGames: wGames, values: vals }; +} + +/** index of dispersion from the raw values (variance/mean) — reported, and the + * honest tell for under-dispersion (NB can't represent it → Poisson approx). */ +function iodOf(values) { + const clean = (values || []).filter(isNum); + if (clean.length < 2) return null; + const mean = clean.reduce((a, b) => a + b, 0) / clean.length; + if (mean <= 0) return null; + const variance = clean.reduce((s, v) => s + (v - mean) ** 2, 0) / (clean.length - 1); + return Math.round((variance / mean) * 1000) / 1000; +} + +/** + * projectProp({ grade, gameLog, batterRow, arsenal, tonightParkFactor, + * weatherMod, platoonMult }) — the proj-v1 object for ONE prop. PURE. + * Never abstains; missing inputs simply contribute a 1.0 (documented as absent + * in the breakdown), never a fabricated push. + */ +function projectProp({ + grade, gameLog = [], batterRow = null, arsenal = null, + tonightParkFactor = null, weatherMod = null, platoonMult = null, +} = {}) { + const stat = String(grade && (grade.stat_type || grade.stat) || '').toLowerCase(); + const spec = STAT_FIELD[stat]; + if (!spec) return null; // not an MLB batting stat proj-v1 models + + const line = num(grade && grade.line); + const direction = String(grade && grade.direction || 'over').toLowerCase(); + + // ── RATE: prior (season anchor) + recency-weighted observed ─────────────── + const seasonRate = num(grade && grade.season_avg); + const priorMean = seasonRate != null && seasonRate > 0 ? seasonRate : spec.prior; + const obs = recencyWeighted(gameLog, spec.field); + const post = dist.gammaPoissonPosterior({ + priorMean, priorGames: PRIOR_GAMES, + weightedSum: obs.weightedSum, weightedGames: obs.weightedGames, + }); + const formRate = post.alpha / post.beta; // pre-adjustment mean + + // ── FACTORS (each documented; absent → 1.0, flagged in the breakdown) ───── + const factors = []; + const pushFactor = (label, mult, meta) => { + const m = num(mult); + const present = m != null && m !== 1; + factors.push({ label, multiplier: present ? Math.round(m * 1000) / 1000 : 1, present, ...(meta || {}) }); + return present ? m : 1; + }; + + // PARK, RELATIVE to the player's own log exposure (Phase B fix). + let parkRel = 1; + const baseline = parkBaselineFromLogs(gameLog, abbrOf(grade && grade.team)); + if (tonightParkFactor != null && baseline != null && baseline > 0) { + parkRel = clamp(tonightParkFactor / baseline, 0.85, 1.15); + } + pushFactor('park_relative', parkRel, { tonight: tonightParkFactor, baseline: baseline != null ? Math.round(baseline * 1000) / 1000 : null }); + + const wMult = pushFactor('weather', weatherMod); + const pMult = pushFactor('platoon', platoonMult); + + const mRead = matchup.matchupMultiplier({ arsenal, hitter: matchup.hitterProfile(batterRow), statType: stat }); + const mMult = pushFactor('matchup', mRead.multiplier, { components: mRead.components }); + + // Combined non-form multiplier, bounded so no stack runs away. + let M = parkRel * wMult * pMult * mMult; + M = clamp(M, 1 - COMBINED_MAX, 1 + COMBINED_MAX); + + // ── DISTRIBUTION: shift the mean by M, keep sample-driven dispersion ─────── + const nb = dist.nbFromPosterior(dist.applyRateMultiplier(post, M)); + const point = dist.round3(dist.nbMean(nb)); + const rungs = dist.ladder(nb, LADDER_MAX); + + // ── LADDER + book implied (only the traded rung has a book number) ──────── + const tradedRung = isNum(line) ? Math.max(1, Math.ceil(line)) : null; + const fairProb = num(grade && grade.fair_prob); // de-vigged, graded direction + const ladderOut = rungs.map((r) => ({ + rung: r.rung, + p_at_least: r.p_at_least, + book_implied: (tradedRung != null && r.rung === tradedRung && direction === 'over' && fairProb != null) + ? Math.round(fairProb * 1000) / 1000 : null, + })); + const pOverLine = tradedRung != null ? dist.round3(dist.nbSurvival(nb.r, nb.p, tradedRung)) : null; + + return { + proj_version: PROJ_VERSION, + proj_point: point, + proj_line: line, + proj_p_over_line: pOverLine, + proj_book_implied: (direction === 'over' && fairProb != null) ? Math.round(fairProb * 1000) / 1000 : null, + proj_distribution: { + family: 'negative_binomial', + r: dist.round3(nb.r), p: dist.round3(nb.p), + mean: point, variance: dist.round3(dist.nbVariance(nb)), + iod_observed: iodOf(obs.values), + sample_games: obs.values.length, + }, + proj_ladder: ladderOut, + proj_factors: { + form_rate: dist.round3(formRate), + combined_multiplier: Math.round(M * 1000) / 1000, + breakdown: factors, + }, + proj_reason: null, + }; +} + +/** + * attachProjection(grades, deps) — map grades → grades + proj-v1 fields. + * deps.gameLogFor(grade) → [{date,opponent,isHome,stat}] (batter game log) + * deps.batterRowFor(grade) → statcast batter row + * deps.arsenalFor(grade) → opposing pitcher's classified arsenal (or null) + * deps.parkFor(grade) → tonight's home-park factor + * deps.weatherFor(grade) / deps.platoonFor(grade) → multipliers (reuse env ctx) + * All optional/best-effort; a missing input contributes nothing (not a push). + */ +async function attachProjection(grades, deps = {}) { + const out = []; + for (const g of grades || []) { + if (!g) { out.push(g); continue; } + let proj = null; + try { + const stat = String(g.stat_type || g.stat || '').toLowerCase(); + if (STAT_FIELD[stat]) { + const gameLog = deps.gameLogFor ? await deps.gameLogFor(g) : []; + proj = projectProp({ + grade: g, + gameLog: gameLog || [], + batterRow: deps.batterRowFor ? deps.batterRowFor(g) : null, + arsenal: deps.arsenalFor ? await deps.arsenalFor(g) : null, + tonightParkFactor: deps.parkFor ? deps.parkFor(g) : null, + weatherMod: deps.weatherFor ? deps.weatherFor(g) : null, + platoonMult: deps.platoonFor ? deps.platoonFor(g) : null, + }); + } + } catch { proj = null; } // never break the pipeline + out.push(proj ? { ...g, ...proj } : { ...g, proj_version: PROJ_VERSION, proj_reason: 'not_modeled' }); + } + return out; +} + +module.exports = { + projectProp, attachProjection, parkBaselineFromLogs, recencyWeighted, parkFactorFor, + PROJ_VERSION, STAT_FIELD, COMBINED_MAX, +}; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 6a9aafc..9ddf904 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -232,6 +232,31 @@ async function loadStatcastRows(sport) { } catch { return null; } } +/** + * proj-v1 — opposing-pitcher arsenals by source_id (MLBAM id), classified once + * per snapshot. Read-only; a failure returns an empty map (proj-v1 then has no + * matchup contribution, honest-absent). Separate from loadStatcastRows (which + * keys by player name) because the opposing pitcher is resolved by ID. + */ +async function loadPitcherArsenals(sport) { + const out = new Map(); + try { + if (String(sport).toLowerCase() !== 'mlb') return out; + const sb = require('../utils/supabase').getSupabaseServiceClient(); + if (!sb) return out; + const matchupRead = require('./projection/matchupRead'); + const { data, error } = await sb.from('statcast_aggregates') + .select('source_id,pitch_mix').eq('sport', sport).eq('role', 'pitcher').limit(5000); + if (error || !data) return out; + for (const r of data) { + if (r.source_id == null || !r.pitch_mix) continue; + const arsenal = matchupRead.classifyArsenal(r.pitch_mix); + if (arsenal) out.set(Number(r.source_id), arsenal); + } + return out; + } catch { return out; } +} + async function runSnapshot(sport, opts = {}) { const sp = String(sport || '').toLowerCase(); const deps = { @@ -534,11 +559,13 @@ async function runSnapshot(sport, opts = {}) { // per grade. The batter hand rides on the statcast row; the rest is // fetched once here. Best-effort: a context failure leaves archetype-only. let contextFor = null; + let ctxInternals = null; // hoisted for proj-v1 (opposing pitcher resolution) try { const envCtx = deps.environmentContext || require('./environmentContext'); const ctx = await envCtx.buildContext(sp, { origin: process.env.BACKEND_SELF_ORIGIN || 'http://localhost:3000', }); + ctxInternals = ctx._internals || null; // Enrich each grade with the hitter hand the platoon estimate needs // (statcast_aggregates.bats, already loaded above). const handOf = (name) => { @@ -570,6 +597,41 @@ async function runSnapshot(sport, opts = {}) { } catch (e) { console.warn(`[contact-challenger] ${sp} skipped:`, e.message); } + + // proj-v1 — ABSOLUTE matchup projection (MLB batting). Own try; flag-gated; + // reuses arch-v1's already-computed park/weather/platoon on each grade so + // it adds no duplicate env I/O. NEVER abstains — thin → wide distribution. + if (String(process.env.PROJ_V1_ENABLED || '1') !== '0') { + try { + const projection = deps.projectionChallenger || require('./projectionChallenger'); + const { abbrOf } = require('./environmentContext'); + const arsenalById = await (deps.loadArsenals || loadPitcherArsenals)(sp); + const oppByTeam = (ctxInternals && ctxInternals.oppPitcherByTeam) || new Map(); + const mlbAdapter = deps.mlbAdapter || require('./adapters/mlbStatsAdapter'); + withChallenger = await projection.attachProjection(withChallenger, { + batterRowFor: (g) => rowsByKey.get(nameKey(g.player || g.player_name || '')), + parkFor: (g) => (g.env_park_base != null ? Number(g.env_park_base) : null), + weatherFor: (g) => (g.env_weather_mod != null ? Number(g.env_weather_mod) : null), + platoonFor: (g) => { + const a = (g.challenger_adjustments || []).find((x) => x.axis === 'matchup'); + return a ? a.multiplier : null; + }, + arsenalFor: (g) => { + const pid = oppByTeam.get(abbrOf(g.team)); + return pid != null ? (arsenalById.get(Number(pid)) || null) : null; + }, + gameLogFor: async (g) => { + if (!g.playerId) return []; + try { return (await mlbAdapter.getPlayerGameLog(g.playerId)) || []; } catch { return []; } + }, + }); + const projected = withChallenger.filter((g) => g.proj_point != null).length; + const projMatchup = withChallenger.filter((g) => (g.proj_factors && g.proj_factors.breakdown || []).some((f) => f.label === 'matchup' && f.present)).length; + console.log(`[proj-v1] ${sp} — ${projected}/${withChallenger.length} projected (${projMatchup} with matchup)`); + } catch (e) { + console.warn(`[proj-v1] ${sp} skipped:`, e.message); + } + } } } catch (e) { // The challenger must NEVER break the pipeline it is measured inside. diff --git a/supabase/migrations/032_projection_challenger.sql b/supabase/migrations/032_projection_challenger.sql new file mode 100644 index 0000000..a326a60 --- /dev/null +++ b/supabase/migrations/032_projection_challenger.sql @@ -0,0 +1,19 @@ +-- proj-v1 — ABSOLUTE MATCHUP PROJECTION CHALLENGER (MLB batting). +-- A THIRD challenger (after arch-v1 p_win_challenger, contact-v1 p_win_contact), +-- in its OWN columns so the full rung set + per-factor breakdown + our-P-vs-book +-- comparison stay independently measurable, per rung, per stat, after settle. +-- +-- The champion projects P(stat > line); proj-v1 projects what the hitter will DO +-- as a full distribution (negative-binomial), park-RELATIVE to the player's own +-- exposure, matchup-aware (coarse repertoire-vs-profile). It never abstains — +-- thin sample → wide distribution. Additive + forward-only: pre-nomination rows +-- carry null proj_version. Champion and settled/locked grades untouched. +alter table ledger_entries + add column if not exists proj_version text null, + add column if not exists proj_point numeric null, + add column if not exists proj_line numeric null, + add column if not exists proj_p_over_line numeric null, + add column if not exists proj_book_implied numeric null, + add column if not exists proj_distribution jsonb null, + add column if not exists proj_ladder jsonb null, + add column if not exists proj_factors jsonb null; diff --git a/tests/unit/projectionChallenger.test.js b/tests/unit/projectionChallenger.test.js new file mode 100644 index 0000000..0a8ae64 --- /dev/null +++ b/tests/unit/projectionChallenger.test.js @@ -0,0 +1,137 @@ +/* proj-v1 — matchup read + orchestration. Champion untouched; never abstains. */ +const pc = require('../../src/services/projectionChallenger'); +const mr = require('../../src/services/projection/matchupRead'); + +// A sinker-heavy, high-whiff arsenal. +const SINKER_ARM = mr.classifyArsenal([ + { type: 'SI', usage_pct: 45, whiff_pct: 12, hard_hit_pct: 40 }, + { type: 'SL', usage_pct: 30, whiff_pct: 34, hard_hit_pct: 30 }, + { type: 'CH', usage_pct: 25, whiff_pct: 30, hard_hit_pct: 32 }, +]); +// A fastball-heavy, low-whiff (pitch-to-contact) arsenal. +const FB_ARM = mr.classifyArsenal([ + { type: 'FF', usage_pct: 65, whiff_pct: 16, hard_hit_pct: 44 }, + { type: 'FC', usage_pct: 20, whiff_pct: 18, hard_hit_pct: 40 }, + { type: 'CU', usage_pct: 15, whiff_pct: 20, hard_hit_pct: 30 }, +]); +// A fly-ball, hard-hit hitter; and a whiff-prone one. +const flyBat = { whiff_pct: 22, chase_pct: 28, hard_hit_pct: 46, metrics: { gb_pct_bb: 30, fb_ld_pct: 55 } }; +const whiffBat = { whiff_pct: 34, chase_pct: 34, hard_hit_pct: 34, metrics: { gb_pct_bb: 45, fb_ld_pct: 40 } }; + +describe('arsenal classification', () => { + it('buckets usage by pitch family + carries whiff/hard-hit tendency', () => { + expect(SINKER_ARM.sinker_pct).toBeCloseTo(0.45, 2); + expect(SINKER_ARM.breaking_pct).toBeCloseTo(0.30, 2); + expect(SINKER_ARM.whiff_tendency).toBeGreaterThan(FB_ARM.whiff_tendency); + }); + it('absent mix → null (no matchup contribution, honest)', () => { + expect(mr.classifyArsenal(null)).toBeNull(); + expect(mr.classifyArsenal([])).toBeNull(); + }); +}); + +describe('matchup direction (two-sided, coarse repertoire-vs-profile)', () => { + it('a groundy sinker arm SUPPRESSES a fly-ball hitter\'s power (HR mult < 1)', () => { + const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(flyBat), statType: 'home_runs' }); + expect(m.multiplier).toBeLessThan(1); + expect(m.components.some((c) => c.label === 'air_suppression')).toBe(true); + }); + it('a high-whiff arsenal SUPPRESSES a whiff-prone hitter\'s hits (hits mult < 1)', () => { + const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'hits' }); + expect(m.multiplier).toBeLessThan(1); + }); + it('the SAME whiff pressure moves a STRIKEOUT prop the other way (mult > 1)', () => { + const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'strikeouts' }); + expect(m.multiplier).toBeGreaterThan(1); + }); + it('missing either side → neutral 1.0 (never fabricates a matchup)', () => { + expect(mr.matchupMultiplier({ arsenal: null, hitter: mr.hitterProfile(flyBat), statType: 'hits' }).multiplier).toBe(1); + expect(mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: null, statType: 'hits' }).multiplier).toBe(1); + }); + it('the total matchup lean is bounded', () => { + const m = mr.matchupMultiplier({ arsenal: SINKER_ARM, hitter: mr.hitterProfile(whiffBat), statType: 'total_bases' }); + expect(Math.abs(m.multiplier - 1)).toBeLessThanOrEqual(mr.TOTAL_MAX + 1e-9); + }); +}); + +describe('park RELATIVE to own exposure (Phase B fix)', () => { + it('baseline is the mean park factor over home(own)/away(opp) games', () => { + // NYY home park (parkBase) + away games; just assert it resolves a number + // from ≥3 venues and stays near 1.0 (park factors are ~1.0). + const log = [ + { isHome: true, opponent: 'Boston Red Sox', stat: {} }, + { isHome: false, opponent: 'Boston Red Sox', stat: {} }, + { isHome: false, opponent: 'Houston Astros', stat: {} }, + { isHome: true, opponent: 'Tampa Bay Rays', stat: {} }, + ]; + const b = pc.parkBaselineFromLogs(log, 'NYY'); + if (b != null) { expect(b).toBeGreaterThan(0.7); expect(b).toBeLessThan(1.3); } + }); + it('too few resolvable venues → null (park contributes nothing, not a raw multiply)', () => { + expect(pc.parkBaselineFromLogs([{ isHome: true, stat: {} }], 'NYY')).toBeNull(); + }); +}); + +describe('projectProp — full object, never abstains, plausible', () => { + const hitLog = (vals) => vals.map((h) => ({ isHome: true, opponent: 'Boston Red Sox', stat: { hits: h } })); + + it('emits distribution + full ladder + point + factor breakdown', () => { + const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, fair_prob: 0.62, team: 'NYY' }; + const p = pc.projectProp({ grade, gameLog: hitLog([1, 0, 2, 1, 1, 0, 1, 2, 1, 0]) }); + expect(p.proj_version).toBe('proj-v1'); + expect(p.proj_distribution.family).toBe('negative_binomial'); + expect(p.proj_ladder.length).toBeGreaterThanOrEqual(3); + expect(p.proj_ladder[0].p_at_least).toBeGreaterThanOrEqual(p.proj_ladder[1].p_at_least); + expect(p.proj_factors.breakdown.map((f) => f.label)).toEqual( + expect.arrayContaining(['park_relative', 'weather', 'platoon', 'matchup']), + ); + // traded rung (ceil 0.5 = 1) carries the book-implied for comparison + expect(p.proj_ladder.find((r) => r.rung === 1).book_implied).toBeCloseTo(0.62, 3); + expect(p.proj_p_over_line).toBeGreaterThan(0); + }); + + it('NEVER abstains — an empty game log still projects (wide, from the prior)', () => { + const grade = { stat_type: 'hits', line: 0.5, direction: 'over', season_avg: 0.9, team: 'NYY' }; + const p = pc.projectProp({ grade, gameLog: [] }); + expect(p.proj_point).not.toBeNull(); + expect(p.proj_ladder[0].p_at_least).toBeGreaterThan(0); + }); + + it('PLAUSIBILITY: a .300-ish hitter does not project 3.0 hits', () => { + const grade = { stat_type: 'hits', line: 1.5, direction: 'over', season_avg: 0.95, team: 'NYY' }; + const p = pc.projectProp({ grade, gameLog: hitLog([1, 1, 2, 0, 1, 1, 1, 0, 2, 1]) }); + expect(p.proj_point).toBeLessThan(2.0); + expect(p.proj_point).toBeGreaterThan(0.5); + }); + + it('the combined non-form multiplier is bounded (no absurd Coors swing)', () => { + const grade = { stat_type: 'home_runs', line: 0.5, direction: 'over', season_avg: 0.2, team: 'COL' }; + const p = pc.projectProp({ + grade, gameLog: hitLog([0, 0, 1, 0, 0, 1, 0, 0, 0, 1]), + tonightParkFactor: 1.3, weatherMod: 1.1, platoonMult: 1.1, arsenal: FB_ARM, + batterRow: flyBat, + }); + expect(Math.abs(p.proj_factors.combined_multiplier - 1)).toBeLessThanOrEqual(pc.COMBINED_MAX + 1e-9); + }); + + it('non-batting stat → not modeled (returns null projection object)', () => { + expect(pc.projectProp({ grade: { stat_type: 'pitcher_strikeouts', line: 5.5, direction: 'over' } })).toBeNull(); + }); +}); + +describe('attachProjection — champion byte-identical', () => { + it('adds proj-v1 fields, never mutates p_win / grade / other challengers', async () => { + const grades = [{ + player: 'Slugger', playerId: null, stat_type: 'hits', line: 0.5, direction: 'over', + season_avg: 0.9, fair_prob: 0.6, team: 'NYY', p_win: 0.58, grade: 'B', + p_win_challenger: 0.6, p_win_contact: 0.61, + }]; + const out = await pc.attachProjection(grades, {}); + expect(out[0].p_win).toBe(0.58); + expect(out[0].grade).toBe('B'); + expect(out[0].p_win_challenger).toBe(0.6); + expect(out[0].p_win_contact).toBe(0.61); + expect(out[0].proj_version).toBe('proj-v1'); + expect(out[0].proj_point).not.toBeNull(); // projected even with no game log + }); +}); diff --git a/tests/unit/projectionDistribution.test.js b/tests/unit/projectionDistribution.test.js new file mode 100644 index 0000000..bdc976d --- /dev/null +++ b/tests/unit/projectionDistribution.test.js @@ -0,0 +1,63 @@ +/* proj-v1 distribution — Gamma-Poisson → NB predictive, ladder, uncertainty. */ +const d = require('../../src/services/projection/distribution'); + +describe('gammaln', () => { + it('matches known integer factorials', () => { + expect(Math.exp(d.gammaln(5))).toBeCloseTo(24, 4); // 4! + expect(Math.exp(d.gammaln(1))).toBeCloseTo(1, 6); + }); + it('handles fractional argument (needed for non-integer r)', () => { + expect(Math.exp(d.gammaln(0.5))).toBeCloseTo(Math.sqrt(Math.PI), 5); + }); +}); + +describe('NB predictive from Gamma-Poisson posterior', () => { + it('pmf sums to ~1 over a wide support', () => { + const nb = d.nbFromPosterior({ alpha: 3, beta: 2 }); + let s = 0; for (let x = 0; x < 200; x++) s += d.nbPmf(nb.r, nb.p, x); + expect(s).toBeCloseTo(1, 4); + }); + it('predictive mean equals the posterior mean α/β', () => { + const post = { alpha: 3, beta: 2 }; + const nb = d.nbFromPosterior(post); + expect(d.nbMean(nb)).toBeCloseTo(post.alpha / post.beta, 6); + }); + it('a rate multiplier scales the mean, preserving dispersion shape (r=α)', () => { + const post = { alpha: 4, beta: 5 }; + const base = d.nbFromPosterior(post); + const lifted = d.nbFromPosterior(d.applyRateMultiplier(post, 1.2)); + expect(d.nbMean(lifted)).toBeCloseTo(d.nbMean(base) * 1.2, 6); + expect(lifted.r).toBeCloseTo(base.r, 6); // width tied to sample, not the lean + }); +}); + +describe('the ladder', () => { + it('is monotonically non-increasing (P≥1 ≥ P≥2 ≥ P≥3 …)', () => { + const nb = d.nbFromPosterior({ alpha: 3, beta: 2 }); + const L = d.ladder(nb, 4).map((r) => r.p_at_least); + for (let i = 1; i < L.length; i++) expect(L[i]).toBeLessThanOrEqual(L[i - 1]); + }); +}); + +describe('uncertainty scales with sample (the never-abstain mechanism)', () => { + // Same observed per-game rate (~1.0), thin vs thick sample. + const thin = d.gammaPoissonPosterior({ priorMean: 1, priorGames: 4, weightedSum: 3, weightedGames: 3 }); + const thick = d.gammaPoissonPosterior({ priorMean: 1, priorGames: 4, weightedSum: 60, weightedGames: 60 }); + + it('dispersion ratio (variance/mean = 1 + 1/β) is WIDER for the thin sample', () => { + const rThin = d.nbVariance(d.nbFromPosterior(thin)) / d.nbMean(d.nbFromPosterior(thin)); + const rThick = d.nbVariance(d.nbFromPosterior(thick)) / d.nbMean(d.nbFromPosterior(thick)); + expect(rThin).toBeGreaterThan(rThick); + expect(rThick).toBeLessThan(1.1); // ~Poisson at 64 games + }); + + it('a thin HOT sample keeps a credible LOW rung but an honestly thin HIGH rung', () => { + // 3 games of 2 hits, shrunk toward a 0.9 season prior. + const post = d.gammaPoissonPosterior({ priorMean: 0.9, priorGames: 4, weightedSum: 6, weightedGames: 3 }); + const nb = d.nbFromPosterior(post); + const L = d.ladder(nb, 3); + expect(L[0].p_at_least).toBeGreaterThan(0.5); // P(≥1) is a real read + expect(L[2].p_at_least).toBeLessThan(0.35); // P(≥3) stays honestly thin + expect(d.nbMean(nb)).toBeLessThan(2); // shrinkage: not fooled by the hot streak + }); +});