proj-v1: absolute matchup projection challenger (distribution + full ladder)
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -263,6 +263,17 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
|
|||||||
contact_delta: numOrNull(g.contact_delta),
|
contact_delta: numOrNull(g.contact_delta),
|
||||||
contact_adjustments: g.contact_adjustments || null,
|
contact_adjustments: g.contact_adjustments || null,
|
||||||
contact_version: g.contact_version || 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,
|
// 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
|
// 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
|
// the instrument measures. The actual lands in game_context and is never
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -232,6 +232,31 @@ async function loadStatcastRows(sport) {
|
|||||||
} catch { return null; }
|
} 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 = {}) {
|
async function runSnapshot(sport, opts = {}) {
|
||||||
const sp = String(sport || '').toLowerCase();
|
const sp = String(sport || '').toLowerCase();
|
||||||
const deps = {
|
const deps = {
|
||||||
@@ -534,11 +559,13 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
// per grade. The batter hand rides on the statcast row; the rest is
|
// per grade. The batter hand rides on the statcast row; the rest is
|
||||||
// fetched once here. Best-effort: a context failure leaves archetype-only.
|
// fetched once here. Best-effort: a context failure leaves archetype-only.
|
||||||
let contextFor = null;
|
let contextFor = null;
|
||||||
|
let ctxInternals = null; // hoisted for proj-v1 (opposing pitcher resolution)
|
||||||
try {
|
try {
|
||||||
const envCtx = deps.environmentContext || require('./environmentContext');
|
const envCtx = deps.environmentContext || require('./environmentContext');
|
||||||
const ctx = await envCtx.buildContext(sp, {
|
const ctx = await envCtx.buildContext(sp, {
|
||||||
origin: process.env.BACKEND_SELF_ORIGIN || 'http://localhost:3000',
|
origin: process.env.BACKEND_SELF_ORIGIN || 'http://localhost:3000',
|
||||||
});
|
});
|
||||||
|
ctxInternals = ctx._internals || null;
|
||||||
// Enrich each grade with the hitter hand the platoon estimate needs
|
// Enrich each grade with the hitter hand the platoon estimate needs
|
||||||
// (statcast_aggregates.bats, already loaded above).
|
// (statcast_aggregates.bats, already loaded above).
|
||||||
const handOf = (name) => {
|
const handOf = (name) => {
|
||||||
@@ -570,6 +597,41 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[contact-challenger] ${sp} skipped:`, e.message);
|
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) {
|
} catch (e) {
|
||||||
// The challenger must NEVER break the pipeline it is measured inside.
|
// The challenger must NEVER break the pipeline it is measured inside.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user