'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 compoundTb = require('./projection/compoundTotalBases'); const binomialHits = require('./projection/binomialHits'); const matchup = require('./projection/matchupRead'); // THE RIGHT TAKEABLE AXIS. Three questions once shared one word; they no longer // do (src/config/takeability.js). This module reads TWO of them, for two // different purposes, and never the deprecated `takeable` field: // isTakeableMarket — book IDENTITY. Decides what is a real market to // MODEL. Thin, one-sided and juiced markets are all // real; a -300 hits-over is a bet you can place. // isWithinPromotionBand — a PRICE policy band. Decides what is worth // SURFACING. Recorded here, never read by the model. const { isTakeableMarket, isWithinPromotionBand } = require('../config/takeability'); const parkBase = require('./parkBase'); const { NAME_TO_ABBR } = require('./environmentContext'); // v1.1 — comparison basis changed from raw book_odds to DE-VIGGED FAIR. The // projection model is byte-identical; the version bump marks the basis so pre-fix // (raw-book) and post-fix (fair) rows never silently mix in the handicapper test. const PROJ_VERSION = 'proj-v1.1'; // hits-v1 — the per-stat structural challenger for HITS. Versioned separately // from PROJ_VERSION so a later change to the ladder never silently re-labels // rows that were written by this model. const HITS_VERSION = 'hits-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); // MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced // at least SIX separate defects in this codebase, including one in a module // written the same week its author documented the trap — per-module vigilance // has demonstrably failed. Semantics are byte-identical to the local copy this // replaces, so no output changes; the point is that there is now ONE rule. const { knownNumber } = require('../utils/known'); const num = knownNumber; 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; } /** * Season-with-recency observed counts: the FULL season anchors the absolute rate * (a 20-game window under-samples rare stats — a slugger's HR drought produced a * 0.11 point vs a 0.28 season rate, a fake -32pt edge, caught in the induction), * with the last 5 games weighted 2× for current form (as the champion does). */ function recencyWeighted(gameLog, field, window = 200) { // gameLog is most-recent-LAST (statsapi order); the tail is the recent form. 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) ──────── // Compare against DE-VIGGED FAIR (the triplet's multiplicative method, via the // grade's g.fair_prob), NOT raw book_odds. Raw book is vig-inclusive — a // -110/-110 market implies 52.4% per side — so comparing our P against raw book // OVERSTATES the book on both sides and biases the handicapper test in our // favor. Fair is de-vigged (sums to 1). HONEST-NULL where fair can't be // computed (one-sided market); NEVER a raw-book fallback (that would recreate // the vig bias on that subset and mix two bases in one ledger). const tradedRung = isNum(line) ? Math.max(1, Math.ceil(line)) : null; const fairGraded = num(grade && grade.fair_prob); // de-vigged fair, GRADED side const fairOver = fairGraded == null ? null : Math.round((direction === 'under' ? 1 - fairGraded : fairGraded) * 1000) / 1000; // Phase 2 flag: multiplicative de-vig mis-splits vig on juiced longshots // (favorite-longshot bias), so a longshot fair still carries known method bias. const longshotDevigCaveat = fairOver != null && (fairOver <= 0.25 || fairOver >= 0.75); const ladderOut = rungs.map((r) => ({ rung: r.rung, p_at_least: r.p_at_least, book_implied: (tradedRung != null && r.rung === tradedRung) ? fairOver : null, })); const pOverLine = tradedRung != null ? dist.round3(dist.nbSurvival(nb.r, nb.p, tradedRung)) : null; // ── TOTAL BASES, modelled as the COMPOUND OUTCOME it is (tb-v1) ─────────── // TB is a weighted sum (1B..HR = 1..4), not a count of events, so the single // negative binomial above treats one home run as four events. Measured, that // gave total_bases the worst result in the ladder (resolution 0.009 vs the // champion's 0.273). This computes the exact PMF from per-component Poisson // rates instead, and inherits the SAME combined multiplier so the two models // differ only in structure. // // CHALLENGER ONLY: it is written alongside, never substituted for // `proj_p_over_line`. The current ladder and the champion are byte-identical. // Components underivable (thin/inconsistent log) → null, and the prop keeps // the current ladder value. Never fabricated. let tbCompound = null; if (stat === 'total_bases' && tradedRung != null) { try { tbCompound = compoundTb.projectTotalBases({ rows: gameLog, line, multiplier: M, }); } catch { tbCompound = null; } } // ── HITS, modelled as the AT-BAT-BOUNDED CONVERSION it is (hits-v1) ─────── // A hit is not a low-rate count. A hitter gets N official at-bats and converts // each at rate q, so hits are BOUNDED by opportunity — something a negative // binomial cannot express, since it has unbounded support and no notion of // opportunity at all. Measured on 245 matched settled rows (direction-aligned, // hits only): the current ladder resolves 0.0595 against the champion's // 0.2044, and 84% of hits rows trade at 0.5 — so almost the whole stat is the // single question P(0 hits), which is exactly where the count family hurts // most. HYPOTHESIS, not a claim: the ledger decides. // // CHALLENGER ONLY: written alongside `proj_p_over_line`, never substituted for // it. The current ladder and the champion are byte-identical. Inputs // underivable (thin log, no at-bat counts) → null, and the prop keeps the // current ladder value. Never fabricated. // // MARKET SCOPE READS IDENTITY, NOT PRICE. `market_takeable` comes from the // book, and NOTHING about the price shape excludes a prop from being modelled: // baseball hits markets are genuinely thin, genuinely juiced and genuinely // one-sided, and all three are normal structure rather than a bad quote. The // promotion band is recorded beside it and deliberately never consulted here — // a -300 hits-over is takeable AND outside the band, and both are true at once. let hitsBinom = null; let hitsMarket = null; if (stat === 'hits' && tradedRung != null) { // The graded side's price. `book_odds` is the graded-side price and is the // only one present on a one-sided quote, so it is a genuine fallback rather // than a substitute for the other side. Absent → null, and the promotion // band answers null (an unknown price is not an out-of-band price). const sideOdds = num(direction === 'under' ? (grade && grade.under_odds) : (grade && grade.over_odds)) ?? num(grade && grade.book_odds); hitsMarket = { book: (grade && grade.book) || null, market_takeable: isTakeableMarket(grade && grade.book), within_promotion_band: isWithinPromotionBand(sideOdds), one_sided: (grade && grade.over_odds != null) !== (grade && grade.under_odds != null), price_filtered: false, // stated invariant: no price-shape rule gates the model }; try { hitsBinom = binomialHits.projectHits({ rows: gameLog, line, multiplier: M }); } catch { hitsBinom = null; } } return { proj_version: PROJ_VERSION, proj_hits_p_over: hitsBinom ? hitsBinom.p_over_line : null, proj_hits_meta: hitsBinom ? { version: HITS_VERSION, mean: hitsBinom.mean, hit_rate: hitsBinom.hit_rate, hit_rate_base: hitsBinom.hit_rate_base, ab_per_game: hitsBinom.ab_per_game, ab_distribution: hitsBinom.ab_distribution, games_used: hitsBinom.games_used, at_bats_observed: hitsBinom.at_bats_observed, hits_observed: hitsBinom.hits_observed, family: hitsBinom.family, ab_independence_caveat: hitsBinom.ab_independence_caveat, market: hitsMarket, } : (hitsMarket ? { version: HITS_VERSION, market: hitsMarket, reason: 'inputs_underivable' } : null), proj_tb_p_over: tbCompound ? tbCompound.p_over_line : null, proj_tb_meta: tbCompound ? { version: 'tb-v1', mean: tbCompound.mean, rates: tbCompound.rates, games_used: tbCompound.games_used, family: tbCompound.family, independence_caveat: tbCompound.independence_caveat, } : null, proj_point: point, proj_line: line, proj_p_over_line: pOverLine, proj_book_implied: fairOver, 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, // Self-documenting comparison basis + the Phase-2 longshot caveat, so the // ledger knows exactly what proj_book_implied is measured against. book_implied_basis: fairOver == null ? 'none' : 'fair_multiplicative', longshot_devig_caveat: longshotDevigCaveat, }, 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, HITS_VERSION, STAT_FIELD, COMBINED_MAX, };