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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user