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:
Kev
2026-07-23 03:59:36 -04:00
parent b6f12daa98
commit 6386e737b9
8 changed files with 815 additions and 0 deletions
+11
View File
@@ -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
+119
View File
@@ -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,
};
+157
View File
@@ -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 },
};
+247
View File
@@ -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,
};
+62
View File
@@ -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.
@@ -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;
+137
View File
@@ -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
});
});
+63
View File
@@ -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
});
});