Files
vyndr/src/services/projectionChallenger.js
T
builtbykev 07626de3de hits-v1: built on the right structure, measured honestly, REFUTED
Hits was diagnosed as a family mismatch: 84% of hits rows trade at 0.5, so
the stat rides on P(0), and a negative binomial has unbounded support and no
notion of opportunity at all. hits-v1 models it as the bounded conversion it
is -- N ~ the player's empirical at-bat distribution, hits|N ~ Binomial(N,q),
with the multiplier scaling q (conversion) and never N (opportunity).

STEP 0 confirmed the inputs before the model existed: 30/30 real ledger
players, 100% combined-input coverage. Every read goes through knownRate --
a row with no atBats is dropped, never counted as a 0-at-bat game.

It FIRES: 158/159 hits props (99.4%) on the live production snapshot, through
the real attachProjection path. Scoping by book IDENTITY rather than price
shape kept 94 out-of-promotion-band props on the board, 93 of them modelled --
59% that a price rule would have deleted.

And it LOST. Point-in-time replay (game log truncated strictly before each
row's game_date, real grade-time multiplier), hits-only, direction-aligned,
n=242: resolution champion 0.195 / ladder 0.048 / hits-v1 0.026. Paired
bootstrap on the same rows: hits-v1 - ladder = -0.022, CI95 excluding zero.
Not promoted.

The value is in what it eliminates. The family was wrong AND the mean was not
the constraint -- hits-v1 moved the line-0.5 mean 0.554 -> 0.581 toward a
0.598 base rate while resolution fell. What is left is per-prop
discrimination: the ladder's inputs, not its distribution.

The pre-registered fallback is recorded as WRONG rather than deleted. It said
hits might be genuinely low-resolution for anyone; the champion scores 0.276
on the identical 189 rows, so there is real signal and the ceiling claim was
the comfortable reading, not the honest one. Its own control refuted it, and
that control was already in hand when the branch was written.

hits-v1 stays wired as a challenger writing its own ledger columns so the
forward accrual can confirm the backtest. Champion, ladder, ranking,
calibration, reference ruler and the four accruing verdicts are byte-identical
-- the diff has zero deleted lines.

Tests 4,156 green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 19:04:08 -04:00

374 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* proj-v1 — ABSOLUTE MATCHUP PROJECTION CHALLENGER (MLB batting, v1).
*
* A THIRD challenger, distinct from arch-v1 (market-relative p_win nudge) and
* contact-v1 (season contact quality). The champion projects P(stat > LINE);
* proj-v1 projects what the hitter will DO — an absolute per-game rate, made
* matchup-aware and PARK-RELATIVE (Phase B's raw-multiply bug solved by making
* park relative to the player's own log exposure), emitted as a FULL
* DISTRIBUTION from which the WHOLE LADDER (P≥1, P≥2, P≥3, …) derives.
*
* It NEVER abstains: a thin/hot sample yields a WIDE distribution — real mass on
* the LOW rung, honestly thin on the high rung — so thin signals become
* actionable at the right rung. Champion is READ, never written. Retained in
* proj-v1's OWN ledger columns; flag-gated PROJ_V1_ENABLED; own try in the
* snapshot. Nothing is claimed — the ledger decides, per rung, per stat.
*/
const dist = require('./projection/distribution');
const compoundTb = require('./projection/compoundTotalBases');
const binomialHits = require('./projection/binomialHits');
const matchup = require('./projection/matchupRead');
// THE RIGHT TAKEABLE AXIS. Three questions once shared one word; they no longer
// do (src/config/takeability.js). This module reads TWO of them, for two
// different purposes, and never the deprecated `takeable` field:
// isTakeableMarket — book IDENTITY. Decides what is a real market to
// MODEL. Thin, one-sided and juiced markets are all
// real; a -300 hits-over is a bet you can place.
// isWithinPromotionBand — a PRICE policy band. Decides what is worth
// SURFACING. Recorded here, never read by the model.
const { isTakeableMarket, isWithinPromotionBand } = require('../config/takeability');
const parkBase = require('./parkBase');
const { NAME_TO_ABBR } = require('./environmentContext');
// v1.1 — comparison basis changed from raw book_odds to DE-VIGGED FAIR. The
// projection model is byte-identical; the version bump marks the basis so pre-fix
// (raw-book) and post-fix (fair) rows never silently mix in the handicapper test.
const PROJ_VERSION = 'proj-v1.1';
// hits-v1 — the per-stat structural challenger for HITS. Versioned separately
// from PROJ_VERSION so a later change to the ladder never silently re-labels
// rows that were written by this model.
const HITS_VERSION = 'hits-v1';
const PRIOR_GAMES = Number(process.env.PROJ_PRIOR_GAMES || 4);
const LADDER_MAX = Number(process.env.PROJ_LADDER_MAX || 4);
// Combined non-form multiplier bound (Phase B proved <0.12 stacked). A Coors +
// wind-out + platoon + matchup prop must not swing the rate absurdly.
const COMBINED_MAX = Number(process.env.PROJ_COMBINED_MAX || 0.35);
/** MLB stat_type → statsapi game-log stat field (camelCase) + league prior rate
* fallback (per game) when a season rate is unavailable. */
const STAT_FIELD = Object.freeze({
hits: { field: 'hits', prior: 0.9 },
total_bases: { field: 'totalBases', prior: 1.45 },
home_runs: { field: 'homeRuns', prior: 0.15 },
doubles: { field: 'doubles', prior: 0.18 },
triples: { field: 'triples', prior: 0.02 },
strikeouts: { field: 'strikeOuts', prior: 1.05 },
rbi: { field: 'rbi', prior: 0.5 },
runs: { field: 'runs', prior: 0.5 },
walks: { field: 'baseOnBalls', prior: 0.32 },
});
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced
// at least SIX separate defects in this codebase, including one in a module
// written the same week its author documented the trap — per-module vigilance
// has demonstrably failed. Semantics are byte-identical to the local copy this
// replaces, so no output changes; the point is that there is now ONE rule.
const { knownNumber } = require('../utils/known');
const num = knownNumber;
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const abbrOf = (team) => {
if (!team) return null;
const s = String(team).trim();
if (/^[A-Z]{2,3}$/.test(s)) return s.toUpperCase();
return NAME_TO_ABBR[s.toLowerCase()] || null;
};
/** Park factor (multiplier ~1.0) for a team's home venue, or null. */
function parkFactorFor(teamAbbr) {
if (!teamAbbr) return null;
try {
const r = parkBase.resolveParkBase({ teamAbbr });
return r && isNum(num(r.factor)) ? num(r.factor) : null;
} catch { return null; }
}
/**
* parkBaselineFromLogs(gameLog, playerTeamAbbr) — the player's OWN average park
* exposure: home games use his park, away games use the opponent's. This is what
* makes tonight's park RELATIVE (tonight ÷ baseline), not a raw multiply. Null
* when we can't resolve enough venues (→ park contributes nothing, honest).
*/
function parkBaselineFromLogs(gameLog, playerTeamAbbr) {
const homePark = parkFactorFor(playerTeamAbbr);
const factors = [];
for (const g of gameLog || []) {
if (g == null || g.isHome == null) continue;
if (g.isHome) { if (homePark != null) factors.push(homePark); }
else {
const oppPark = parkFactorFor(abbrOf(g.opponent));
if (oppPark != null) factors.push(oppPark);
}
}
if (factors.length < 3) return null; // too few resolved venues to trust a baseline
return factors.reduce((a, b) => a + b, 0) / factors.length;
}
/**
* Season-with-recency observed counts: the FULL season anchors the absolute rate
* (a 20-game window under-samples rare stats — a slugger's HR drought produced a
* 0.11 point vs a 0.28 season rate, a fake -32pt edge, caught in the induction),
* with the last 5 games weighted 2× for current form (as the champion does).
*/
function recencyWeighted(gameLog, field, window = 200) {
// gameLog is most-recent-LAST (statsapi order); the tail is the recent form.
const rows = (gameLog || []).filter((g) => g && g.stat);
const recent = rows.slice(-window);
const n = recent.length;
let wSum = 0; let wGames = 0; const vals = [];
recent.forEach((g, i) => {
const v = num(g.stat[field]);
if (v == null) return;
const w = i >= n - 5 ? 2 : 1; // last 5 of the window count double
wSum += w * v; wGames += w; vals.push(v);
});
return { weightedSum: wSum, weightedGames: wGames, values: vals };
}
/** index of dispersion from the raw values (variance/mean) — reported, and the
* honest tell for under-dispersion (NB can't represent it → Poisson approx). */
function iodOf(values) {
const clean = (values || []).filter(isNum);
if (clean.length < 2) return null;
const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
if (mean <= 0) return null;
const variance = clean.reduce((s, v) => s + (v - mean) ** 2, 0) / (clean.length - 1);
return Math.round((variance / mean) * 1000) / 1000;
}
/**
* projectProp({ grade, gameLog, batterRow, arsenal, tonightParkFactor,
* weatherMod, platoonMult }) — the proj-v1 object for ONE prop. PURE.
* Never abstains; missing inputs simply contribute a 1.0 (documented as absent
* in the breakdown), never a fabricated push.
*/
function projectProp({
grade, gameLog = [], batterRow = null, arsenal = null,
tonightParkFactor = null, weatherMod = null, platoonMult = null,
} = {}) {
const stat = String(grade && (grade.stat_type || grade.stat) || '').toLowerCase();
const spec = STAT_FIELD[stat];
if (!spec) return null; // not an MLB batting stat proj-v1 models
const line = num(grade && grade.line);
const direction = String(grade && grade.direction || 'over').toLowerCase();
// ── RATE: prior (season anchor) + recency-weighted observed ───────────────
const seasonRate = num(grade && grade.season_avg);
const priorMean = seasonRate != null && seasonRate > 0 ? seasonRate : spec.prior;
const obs = recencyWeighted(gameLog, spec.field);
const post = dist.gammaPoissonPosterior({
priorMean, priorGames: PRIOR_GAMES,
weightedSum: obs.weightedSum, weightedGames: obs.weightedGames,
});
const formRate = post.alpha / post.beta; // pre-adjustment mean
// ── FACTORS (each documented; absent → 1.0, flagged in the breakdown) ─────
const factors = [];
const pushFactor = (label, mult, meta) => {
const m = num(mult);
const present = m != null && m !== 1;
factors.push({ label, multiplier: present ? Math.round(m * 1000) / 1000 : 1, present, ...(meta || {}) });
return present ? m : 1;
};
// PARK, RELATIVE to the player's own log exposure (Phase B fix).
let parkRel = 1;
const baseline = parkBaselineFromLogs(gameLog, abbrOf(grade && grade.team));
if (tonightParkFactor != null && baseline != null && baseline > 0) {
parkRel = clamp(tonightParkFactor / baseline, 0.85, 1.15);
}
pushFactor('park_relative', parkRel, { tonight: tonightParkFactor, baseline: baseline != null ? Math.round(baseline * 1000) / 1000 : null });
const wMult = pushFactor('weather', weatherMod);
const pMult = pushFactor('platoon', platoonMult);
const mRead = matchup.matchupMultiplier({ arsenal, hitter: matchup.hitterProfile(batterRow), statType: stat });
const mMult = pushFactor('matchup', mRead.multiplier, { components: mRead.components });
// Combined non-form multiplier, bounded so no stack runs away.
let M = parkRel * wMult * pMult * mMult;
M = clamp(M, 1 - COMBINED_MAX, 1 + COMBINED_MAX);
// ── DISTRIBUTION: shift the mean by M, keep sample-driven dispersion ───────
const nb = dist.nbFromPosterior(dist.applyRateMultiplier(post, M));
const point = dist.round3(dist.nbMean(nb));
const rungs = dist.ladder(nb, LADDER_MAX);
// ── LADDER + book implied (only the traded rung has a book number) ────────
// Compare against DE-VIGGED FAIR (the triplet's multiplicative method, via the
// grade's g.fair_prob), NOT raw book_odds. Raw book is vig-inclusive — a
// -110/-110 market implies 52.4% per side — so comparing our P against raw book
// OVERSTATES the book on both sides and biases the handicapper test in our
// favor. Fair is de-vigged (sums to 1). HONEST-NULL where fair can't be
// computed (one-sided market); NEVER a raw-book fallback (that would recreate
// the vig bias on that subset and mix two bases in one ledger).
const tradedRung = isNum(line) ? Math.max(1, Math.ceil(line)) : null;
const fairGraded = num(grade && grade.fair_prob); // de-vigged fair, GRADED side
const fairOver = fairGraded == null ? null
: Math.round((direction === 'under' ? 1 - fairGraded : fairGraded) * 1000) / 1000;
// Phase 2 flag: multiplicative de-vig mis-splits vig on juiced longshots
// (favorite-longshot bias), so a longshot fair still carries known method bias.
const longshotDevigCaveat = fairOver != null && (fairOver <= 0.25 || fairOver >= 0.75);
const ladderOut = rungs.map((r) => ({
rung: r.rung,
p_at_least: r.p_at_least,
book_implied: (tradedRung != null && r.rung === tradedRung) ? fairOver : null,
}));
const pOverLine = tradedRung != null ? dist.round3(dist.nbSurvival(nb.r, nb.p, tradedRung)) : null;
// ── TOTAL BASES, modelled as the COMPOUND OUTCOME it is (tb-v1) ───────────
// TB is a weighted sum (1B..HR = 1..4), not a count of events, so the single
// negative binomial above treats one home run as four events. Measured, that
// gave total_bases the worst result in the ladder (resolution 0.009 vs the
// champion's 0.273). This computes the exact PMF from per-component Poisson
// rates instead, and inherits the SAME combined multiplier so the two models
// differ only in structure.
//
// CHALLENGER ONLY: it is written alongside, never substituted for
// `proj_p_over_line`. The current ladder and the champion are byte-identical.
// Components underivable (thin/inconsistent log) → null, and the prop keeps
// the current ladder value. Never fabricated.
let tbCompound = null;
if (stat === 'total_bases' && tradedRung != null) {
try {
tbCompound = compoundTb.projectTotalBases({
rows: gameLog, line, multiplier: M,
});
} catch { tbCompound = null; }
}
// ── HITS, modelled as the AT-BAT-BOUNDED CONVERSION it is (hits-v1) ───────
// A hit is not a low-rate count. A hitter gets N official at-bats and converts
// each at rate q, so hits are BOUNDED by opportunity — something a negative
// binomial cannot express, since it has unbounded support and no notion of
// opportunity at all. Measured on 245 matched settled rows (direction-aligned,
// hits only): the current ladder resolves 0.0595 against the champion's
// 0.2044, and 84% of hits rows trade at 0.5 — so almost the whole stat is the
// single question P(0 hits), which is exactly where the count family hurts
// most. HYPOTHESIS, not a claim: the ledger decides.
//
// CHALLENGER ONLY: written alongside `proj_p_over_line`, never substituted for
// it. The current ladder and the champion are byte-identical. Inputs
// underivable (thin log, no at-bat counts) → null, and the prop keeps the
// current ladder value. Never fabricated.
//
// MARKET SCOPE READS IDENTITY, NOT PRICE. `market_takeable` comes from the
// book, and NOTHING about the price shape excludes a prop from being modelled:
// baseball hits markets are genuinely thin, genuinely juiced and genuinely
// one-sided, and all three are normal structure rather than a bad quote. The
// promotion band is recorded beside it and deliberately never consulted here —
// a -300 hits-over is takeable AND outside the band, and both are true at once.
let hitsBinom = null;
let hitsMarket = null;
if (stat === 'hits' && tradedRung != null) {
// The graded side's price. `book_odds` is the graded-side price and is the
// only one present on a one-sided quote, so it is a genuine fallback rather
// than a substitute for the other side. Absent → null, and the promotion
// band answers null (an unknown price is not an out-of-band price).
const sideOdds = num(direction === 'under'
? (grade && grade.under_odds)
: (grade && grade.over_odds)) ?? num(grade && grade.book_odds);
hitsMarket = {
book: (grade && grade.book) || null,
market_takeable: isTakeableMarket(grade && grade.book),
within_promotion_band: isWithinPromotionBand(sideOdds),
one_sided: (grade && grade.over_odds != null) !== (grade && grade.under_odds != null),
price_filtered: false, // stated invariant: no price-shape rule gates the model
};
try {
hitsBinom = binomialHits.projectHits({ rows: gameLog, line, multiplier: M });
} catch { hitsBinom = null; }
}
return {
proj_version: PROJ_VERSION,
proj_hits_p_over: hitsBinom ? hitsBinom.p_over_line : null,
proj_hits_meta: hitsBinom ? {
version: HITS_VERSION,
mean: hitsBinom.mean,
hit_rate: hitsBinom.hit_rate,
hit_rate_base: hitsBinom.hit_rate_base,
ab_per_game: hitsBinom.ab_per_game,
ab_distribution: hitsBinom.ab_distribution,
games_used: hitsBinom.games_used,
at_bats_observed: hitsBinom.at_bats_observed,
hits_observed: hitsBinom.hits_observed,
family: hitsBinom.family,
ab_independence_caveat: hitsBinom.ab_independence_caveat,
market: hitsMarket,
} : (hitsMarket ? { version: HITS_VERSION, market: hitsMarket, reason: 'inputs_underivable' } : null),
proj_tb_p_over: tbCompound ? tbCompound.p_over_line : null,
proj_tb_meta: tbCompound ? {
version: 'tb-v1', mean: tbCompound.mean, rates: tbCompound.rates,
games_used: tbCompound.games_used, family: tbCompound.family,
independence_caveat: tbCompound.independence_caveat,
} : null,
proj_point: point,
proj_line: line,
proj_p_over_line: pOverLine,
proj_book_implied: fairOver,
proj_distribution: {
family: 'negative_binomial',
r: dist.round3(nb.r), p: dist.round3(nb.p),
mean: point, variance: dist.round3(dist.nbVariance(nb)),
iod_observed: iodOf(obs.values),
sample_games: obs.values.length,
},
proj_ladder: ladderOut,
proj_factors: {
form_rate: dist.round3(formRate),
combined_multiplier: Math.round(M * 1000) / 1000,
breakdown: factors,
// Self-documenting comparison basis + the Phase-2 longshot caveat, so the
// ledger knows exactly what proj_book_implied is measured against.
book_implied_basis: fairOver == null ? 'none' : 'fair_multiplicative',
longshot_devig_caveat: longshotDevigCaveat,
},
proj_reason: null,
};
}
/**
* attachProjection(grades, deps) — map grades → grades + proj-v1 fields.
* deps.gameLogFor(grade) → [{date,opponent,isHome,stat}] (batter game log)
* deps.batterRowFor(grade) → statcast batter row
* deps.arsenalFor(grade) → opposing pitcher's classified arsenal (or null)
* deps.parkFor(grade) → tonight's home-park factor
* deps.weatherFor(grade) / deps.platoonFor(grade) → multipliers (reuse env ctx)
* All optional/best-effort; a missing input contributes nothing (not a push).
*/
async function attachProjection(grades, deps = {}) {
const out = [];
for (const g of grades || []) {
if (!g) { out.push(g); continue; }
let proj = null;
try {
const stat = String(g.stat_type || g.stat || '').toLowerCase();
if (STAT_FIELD[stat]) {
const gameLog = deps.gameLogFor ? await deps.gameLogFor(g) : [];
proj = projectProp({
grade: g,
gameLog: gameLog || [],
batterRow: deps.batterRowFor ? deps.batterRowFor(g) : null,
arsenal: deps.arsenalFor ? await deps.arsenalFor(g) : null,
tonightParkFactor: deps.parkFor ? deps.parkFor(g) : null,
weatherMod: deps.weatherFor ? deps.weatherFor(g) : null,
platoonMult: deps.platoonFor ? deps.platoonFor(g) : null,
});
}
} catch { proj = null; } // never break the pipeline
out.push(proj ? { ...g, ...proj } : { ...g, proj_version: PROJ_VERSION, proj_reason: 'not_modeled' });
}
return out;
}
module.exports = {
projectProp, attachProjection, parkBaselineFromLogs, recencyWeighted, parkFactorFor,
PROJ_VERSION, HITS_VERSION, STAT_FIELD, COMBINED_MAX,
};