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
+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,
};