Causally-correct platoon + park-dimensions ingest
Applying the method that worked for defence to the two factors the code flagged as still crude. PLATOON. The flat version is 'lefty versus righty, add a boost', and it failed the two-part gate for the same reason team-average defence did: it is not the unit the causal story runs through. The advantage is only worth what THIS hitter's split is actually worth -- measured on a real hitter, .284 against left-handed pitching versus .221 against right-handed, a 63-point split, where the flat factor applied the same six percent to him and to a hitter with none. Most of the work is sample discipline, and the second rule matters more than the first. Severity shrinks toward the league split weighted by the SMALLER side's plate appearances, because a 500-against-40 split is a 40-PA read. And below a floor it REFUSES outright rather than shrinking, because a heavily-shrunk severity is indistinguishable from a measured league-average one and those are different claims -- without the refusal the atom would quietly assert a league-typical split about every September call-up in the league. Switch hitters turn out to be the easy case misread as the hard one. He bats opposite by choice so the direction is never in doubt, but the per-side value of his swing is a different question and one this sample cannot answer, so he is unreadable rather than credited with an automatic edge. PARK DIMENSIONS. Free from statsapi's venue endpoint, which carries fence distances, roof, turf and elevation outright -- Wrigley returns 355 down the left line, 400 to centre, 353 to right, at 595 feet. parkFactors holds run COEFFICIENTS, which structurally cannot express a park that turns outs into hits without scoring, and that is why the crude park factor failed. The park join is by the venue the game is ACTUALLY at, carried from the schedule feed, never inferred from the home team -- neutral-site and international games break that assumption and they break it silently. A venue with no geometry at all is absent rather than a park with zero dimensions. Both tables dated in the primary key. Venue geometry changes rarely but it does change, and by now that is the default rather than a lesson. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -69,7 +69,7 @@ async function getJson(url, opts = {}) {
|
||||
*/
|
||||
async function fetchLineups(date, opts = {}) {
|
||||
const d = String(date || dateET()).slice(0, 10);
|
||||
const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=lineups`;
|
||||
const url = `${BASE}/schedule?sportId=1&date=${d}&hydrate=lineups,venue`;
|
||||
let data = null;
|
||||
try { data = await getJson(url, opts); } catch { return []; }
|
||||
const games = ((data && data.dates) || []).flatMap((day) => day.games || []);
|
||||
@@ -86,6 +86,9 @@ async function fetchLineups(date, opts = {}) {
|
||||
if (!p || !p.fullName) return;
|
||||
out.push({
|
||||
game_pk: g.gamePk ?? null,
|
||||
// The venue the game is ACTUALLY at — never inferred from the home
|
||||
// team, which is wrong for neutral-site and international games.
|
||||
venue_id: (g.venue && knownNumber(g.venue.id)) ?? null,
|
||||
game_date: String(g.gameDate || '').slice(0, 10) || d,
|
||||
team,
|
||||
side,
|
||||
@@ -144,6 +147,65 @@ async function fetchOpportunity(sourceId, season, opts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PLATOON SPLITS — a hitter's own vs-LHP / vs-RHP sample, from the same
|
||||
* situational-splits endpoint the RISP opportunity uses. One call per hitter.
|
||||
*
|
||||
* Returns null when the splits are unavailable — never a symmetric guess, which
|
||||
* would assert the hitter has no platoon split at all.
|
||||
*/
|
||||
async function fetchPlatoonSplits(sourceId, season, opts = {}) {
|
||||
const id = knownNumber(sourceId);
|
||||
if (id == null) return null;
|
||||
const url = `${BASE}/people/${id}/stats?stats=statSplits&sitCodes=vl,vr&season=${season}&group=hitting`;
|
||||
let data = null;
|
||||
try { data = await getJson(url, opts); } catch { return null; }
|
||||
const splits = ((data && data.stats) || []).flatMap((s) => s.splits || []);
|
||||
if (splits.length === 0) return null;
|
||||
const out = {};
|
||||
for (const sp of splits) {
|
||||
const code = sp && sp.split && sp.split.code;
|
||||
const st = (sp && sp.stat) || {};
|
||||
if (code !== 'vl' && code !== 'vr') continue;
|
||||
out[`${code}_pa`] = knownNumber(st.plateAppearances);
|
||||
out[`${code}_ab`] = knownNumber(st.atBats);
|
||||
out[`${code}_hits`] = knownNumber(st.hits);
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PARK DIMENSIONS — venue geometry from statsapi. Free, and the input a
|
||||
* hits/hit-type park factor needs; parkFactors holds run coefficients, which
|
||||
* cannot express a park that turns outs into hits without scoring.
|
||||
*/
|
||||
async function fetchParkDimensions(venueId, opts = {}) {
|
||||
const id = knownNumber(venueId);
|
||||
if (id == null) return null;
|
||||
const url = `${BASE}/venues/${id}?hydrate=location,fieldInfo`;
|
||||
let data = null;
|
||||
try { data = await getJson(url, opts); } catch { return null; }
|
||||
const v = ((data && data.venues) || [])[0];
|
||||
if (!v) return null;
|
||||
const f = v.fieldInfo || {};
|
||||
const loc = v.location || {};
|
||||
const dims = {
|
||||
venue_id: id,
|
||||
venue_name: v.name || null,
|
||||
left_line: knownNumber(f.leftLine),
|
||||
left_center: knownNumber(f.leftCenter),
|
||||
center: knownNumber(f.center),
|
||||
right_center: knownNumber(f.rightCenter),
|
||||
right_line: knownNumber(f.rightLine),
|
||||
roof_type: f.roofType || null,
|
||||
turf_type: f.turfType || null,
|
||||
elevation: knownNumber(loc.elevation),
|
||||
};
|
||||
// A venue with no geometry at all is ABSENT, not a park with zero dimensions.
|
||||
const hasGeometry = ['left_line', 'center', 'right_line'].some((k) => dims[k] !== null);
|
||||
return hasGeometry ? dims : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a slate's lineups. Best-effort like every other side-write: a context
|
||||
* failure must never break the pipeline it rides in.
|
||||
@@ -203,6 +265,36 @@ async function refreshContext(opts = {}) {
|
||||
});
|
||||
}
|
||||
summary.opportunity_rows = rows.length;
|
||||
|
||||
// PLATOON — same endpoint family, one call per hitter (season aggregate).
|
||||
const platoonRows = [];
|
||||
const getPlat = opts.fetchPlatoonSplits || fetchPlatoonSplits;
|
||||
for (const p of ids.slice(0, opts.maxPlayers || 400)) {
|
||||
const sp = await getPlat(p.source_id, season, opts);
|
||||
if (!sp) continue; // absent, never a symmetric guess
|
||||
platoonRows.push({ player_key: p.player_key, player_name: p.player_name, source_id: p.source_id, ...sp });
|
||||
}
|
||||
summary.platoon_rows = platoonRows.length;
|
||||
if (sb && platoonRows.length) {
|
||||
const res = await persistPlatoon(sb, platoonRows, { sport, season, asOf: opts.asOfDate });
|
||||
summary.platoon = res.written;
|
||||
if (res.error) summary.platoon_error = res.error;
|
||||
}
|
||||
|
||||
// PARK DIMENSIONS — one call per distinct venue on the slate.
|
||||
const venueIds = [...new Set(lineups.map((l) => l.venue_id).filter((v) => v != null))];
|
||||
const dimRows = [];
|
||||
const getDims = opts.fetchParkDimensions || fetchParkDimensions;
|
||||
for (const vid of venueIds) {
|
||||
const d = await getDims(vid, opts);
|
||||
if (d) dimRows.push(d);
|
||||
}
|
||||
summary.park_dimension_rows = dimRows.length;
|
||||
if (sb && dimRows.length) {
|
||||
const res = await persistParkDimensions(sb, dimRows, { sport, asOf: opts.asOfDate });
|
||||
summary.park_dimensions = res.written;
|
||||
if (res.error) summary.park_dimensions_error = res.error;
|
||||
}
|
||||
if (sb && rows.length) {
|
||||
const res = await persistOpportunity(sb, rows, { sport, season, asOf: opts.asOfDate });
|
||||
summary.opportunity = res.written;
|
||||
@@ -216,7 +308,26 @@ async function refreshContext(opts = {}) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function persistPlatoon(sb, rows, { sport = 'mlb', season, asOf = null } = {}) {
|
||||
if (!sb || !rows || rows.length === 0) return { written: 0 };
|
||||
const as_of_date = asOf || dateET();
|
||||
const { error } = await sb.from('platoon_splits')
|
||||
.upsert(rows.map((r) => ({ ...r, sport, season, as_of_date })),
|
||||
{ onConflict: 'as_of_date,sport,season,player_key' });
|
||||
return error ? { written: 0, error: error.message } : { written: rows.length };
|
||||
}
|
||||
|
||||
async function persistParkDimensions(sb, rows, { sport = 'mlb', asOf = null } = {}) {
|
||||
if (!sb || !rows || rows.length === 0) return { written: 0 };
|
||||
const as_of_date = asOf || dateET();
|
||||
const { error } = await sb.from('park_dimensions')
|
||||
.upsert(rows.map((r) => ({ ...r, sport, as_of_date })),
|
||||
{ onConflict: 'as_of_date,sport,venue_id' });
|
||||
return error ? { written: 0, error: error.message } : { written: rows.length };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchLineups, fetchOpportunity, persistLineups, persistOpportunity,
|
||||
fetchLineups, fetchOpportunity, fetchPlatoonSplits, fetchParkDimensions,
|
||||
persistLineups, persistOpportunity, persistPlatoon, persistParkDimensions,
|
||||
refreshContext, dateET,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* platoonSeverity — THE CAUSALLY-CORRECT PLATOON ATOM.
|
||||
*
|
||||
* The crude version is "left-handed hitter versus right-handed pitcher, add a
|
||||
* boost." It failed the two-part gate for hits (Brier −0.0039, corrected
|
||||
* interval spanning zero) for the same reason team-average defence did: it is
|
||||
* not the unit the causal story runs through.
|
||||
*
|
||||
* The platoon advantage is only worth what THIS hitter's split is actually
|
||||
* worth. Some left-handed hitters genuinely cannot hit left-handed pitching;
|
||||
* others have essentially no split at all, and applying a flat league boost to
|
||||
* both describes neither. Measured on a real hitter: .284 against left-handed
|
||||
* pitching versus .221 against right-handed — a 63-point split, where the flat
|
||||
* factor would have applied the same ±6% to a hitter with none.
|
||||
*
|
||||
* ── SAMPLE DISCIPLINE, WHICH IS MOST OF THE WORK ─────────────────────────
|
||||
* A split measured over 40 plate appearances is noise wearing a decimal point.
|
||||
* Two rules, and the second matters more:
|
||||
*
|
||||
* SHRINK the observed split toward the league split, weighted by sample.
|
||||
* An established hitter keeps his own number; a thin one is pulled
|
||||
* toward what hitters like him do.
|
||||
* REFUSE below a floor there is no reading at all. `null`, not a shrunk
|
||||
* guess — because a heavily-shrunk severity is indistinguishable
|
||||
* from a measured league-average one, and those are different claims.
|
||||
*
|
||||
* Without the refusal the atom would quietly assert "this hitter has a
|
||||
* league-typical split" about every September call-up in the league.
|
||||
*
|
||||
* ── SWITCH HITTERS ARE THE EASY CASE, NOT THE HARD ONE ───────────────────
|
||||
* A switch hitter bats opposite the pitcher by choice, so he has the platoon
|
||||
* advantage in every plate appearance. What varies is how much that side of his
|
||||
* swing is worth, which is a different question and one we do not have the
|
||||
* per-side sample to answer — so he is UNREADABLE rather than credited with an
|
||||
* automatic edge.
|
||||
*/
|
||||
|
||||
const { knownNumber, knownRate } = require('../../utils/known');
|
||||
|
||||
/**
|
||||
* League platoon split in batting average — the shrinkage anchor. The advantage
|
||||
* is real and modest; the point of this atom is that its SIZE varies by hitter.
|
||||
*/
|
||||
const LEAGUE_SPLIT = 0.020;
|
||||
/** Plate appearances at which a hitter's own split is worth half the weight. */
|
||||
const STABILIZE_PA = 250;
|
||||
/** Below this, on the smaller side, there is no reading. */
|
||||
const MIN_SIDE_PA = 60;
|
||||
/** Bound on how far platoon may move a hit probability. */
|
||||
const MAX_EFFECT = 0.10;
|
||||
|
||||
/**
|
||||
* The platoon read for one hitter against tonight's pitcher.
|
||||
*
|
||||
* @param {object} splits { vl: {pa, hits, atBats}, vr: {pa, hits, atBats} }
|
||||
* @param {string} bats 'R' | 'L' | 'S'
|
||||
* @param {string} throws tonight's pitcher hand, 'R' | 'L'
|
||||
* @returns {object|null} null when unreadable — never a fabricated severity.
|
||||
*/
|
||||
function platoonRead({ splits, bats, throws } = {}) {
|
||||
const hand = String(bats || '').toUpperCase()[0];
|
||||
const arm = String(throws || '').toUpperCase()[0];
|
||||
if (!splits || (arm !== 'R' && arm !== 'L')) return null;
|
||||
|
||||
// A switch hitter always bats opposite, so the DIRECTION is never in doubt —
|
||||
// but the per-side value of his swing is a question this sample cannot answer.
|
||||
if (hand === 'S') {
|
||||
return { readable: false, reason: 'switch_hitter_side_value_unknown', multiplier: null };
|
||||
}
|
||||
if (hand !== 'R' && hand !== 'L') return null;
|
||||
|
||||
const vl = splits.vl || {};
|
||||
const vr = splits.vr || {};
|
||||
const rate = (s) => {
|
||||
const h = knownRate(s.hits); const ab = knownRate(s.atBats);
|
||||
return h !== null && ab !== null && ab > 0 ? h / ab : null;
|
||||
};
|
||||
const rl = rate(vl); const rr = rate(vr);
|
||||
const paL = knownRate(vl.pa) ?? 0;
|
||||
const paR = knownRate(vr.pa) ?? 0;
|
||||
if (rl === null || rr === null) return { readable: false, reason: 'missing_split', multiplier: null };
|
||||
|
||||
// THE REFUSAL. The smaller side governs — a 500/40 split is a 40-PA read.
|
||||
const smaller = Math.min(paL, paR);
|
||||
if (smaller < MIN_SIDE_PA) {
|
||||
return {
|
||||
readable: false,
|
||||
reason: 'insufficient_split_sample',
|
||||
smaller_side_pa: smaller,
|
||||
multiplier: null,
|
||||
};
|
||||
}
|
||||
|
||||
// From THIS hitter's perspective tonight: opposite hand is the advantage.
|
||||
const facingOpposite = hand !== arm;
|
||||
const advantageRate = hand === 'R' ? rl : rr; // R hits L, L hits R
|
||||
const disadvantageRate = hand === 'R' ? rr : rl;
|
||||
const observedSplit = advantageRate - disadvantageRate;
|
||||
|
||||
// SHRINK toward the league split by sample.
|
||||
const w = smaller / (smaller + STABILIZE_PA);
|
||||
const severity = w * observedSplit + (1 - w) * LEAGUE_SPLIT;
|
||||
|
||||
// Applied in the direction tonight's matchup actually runs, and scaled by the
|
||||
// hitter's own base rate so the multiplier is proportional rather than additive.
|
||||
const baseRate = (advantageRate + disadvantageRate) / 2;
|
||||
if (!(baseRate > 0)) return { readable: false, reason: 'no_base_rate', multiplier: null };
|
||||
const signed = facingOpposite ? severity / 2 : -severity / 2;
|
||||
const effect = Math.max(-MAX_EFFECT, Math.min(MAX_EFFECT, signed / baseRate));
|
||||
|
||||
return {
|
||||
readable: true,
|
||||
multiplier: round3(1 + effect),
|
||||
observed_split: round3(observedSplit),
|
||||
shrunk_severity: round3(severity),
|
||||
shrink_weight: round3(w),
|
||||
smaller_side_pa: smaller,
|
||||
facing_opposite_hand: facingOpposite,
|
||||
};
|
||||
}
|
||||
|
||||
/** A checkable sentence, or nothing. No fluent fallback for an absent read. */
|
||||
function explain(read, bats, throws) {
|
||||
if (!read || !read.readable) return null;
|
||||
const pts = Math.round(read.observed_split * 1000);
|
||||
const dir = read.facing_opposite_hand ? 'has the platoon edge' : 'is on the wrong side of it';
|
||||
return `${bats}HB vs ${throws}HP — ${dir}; his measured split is ${pts} points of average `
|
||||
+ `over ${read.smaller_side_pa} PA on the short side (shrunk ${read.shrink_weight} toward league)`;
|
||||
}
|
||||
|
||||
const round3 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000);
|
||||
|
||||
module.exports = {
|
||||
platoonRead, explain,
|
||||
LEAGUE_SPLIT, STABILIZE_PA, MIN_SIDE_PA, MAX_EFFECT,
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* The causally-correct platoon atom.
|
||||
*
|
||||
* The flat version applies the same boost to a hitter with a 63-point split and
|
||||
* one with none. These tests lock the thing that fixes that — and the refusal
|
||||
* that stops a thin split becoming a fabricated severity.
|
||||
*/
|
||||
|
||||
const ps = require('../../src/services/model/platoonSeverity');
|
||||
|
||||
/** vl/vr with a given average over a given number of PA. */
|
||||
const side = (avg, pa) => ({ pa, atBats: Math.round(pa * 0.9), hits: Math.round(pa * 0.9 * avg) });
|
||||
|
||||
const bigSplit = { vl: side(0.284, 183), vr: side(0.221, 291) }; // real hitter
|
||||
const noSplit = { vl: side(0.260, 200), vr: side(0.258, 300) };
|
||||
|
||||
describe('the severity is the hitter\'s own, not the league\'s', () => {
|
||||
it('a hitter with a real split gets a real move; one without gets almost none', () => {
|
||||
const strong = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' });
|
||||
const flat = ps.platoonRead({ splits: noSplit, bats: 'R', throws: 'L' });
|
||||
expect(strong.readable).toBe(true);
|
||||
expect(flat.readable).toBe(true);
|
||||
// The whole point: the flat factor would have moved these identically.
|
||||
expect(strong.multiplier).toBeGreaterThan(flat.multiplier);
|
||||
expect(strong.observed_split).toBeGreaterThan(0.05);
|
||||
expect(Math.abs(flat.observed_split)).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it('direction follows tonight\'s matchup, not a stored label', () => {
|
||||
const edge = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' });
|
||||
const wrongSide = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'R' });
|
||||
expect(edge.facing_opposite_hand).toBe(true);
|
||||
expect(wrongSide.facing_opposite_hand).toBe(false);
|
||||
expect(edge.multiplier).toBeGreaterThan(1);
|
||||
expect(wrongSide.multiplier).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sample discipline — shrink, then refuse', () => {
|
||||
it('shrinks a thin split toward league and keeps an established one', () => {
|
||||
const thin = ps.platoonRead({ splits: { vl: side(0.400, 70), vr: side(0.200, 300) }, bats: 'R', throws: 'L' });
|
||||
const deep = ps.platoonRead({ splits: { vl: side(0.400, 900), vr: side(0.200, 900) }, bats: 'R', throws: 'L' });
|
||||
// Same raw 200-point split; the thin one must not be believed as much.
|
||||
expect(thin.shrunk_severity).toBeLessThan(deep.shrunk_severity);
|
||||
expect(thin.shrink_weight).toBeLessThan(deep.shrink_weight);
|
||||
});
|
||||
|
||||
it('REFUSES below the floor rather than shrinking to a league-average guess', () => {
|
||||
// A heavily-shrunk severity is indistinguishable from a MEASURED
|
||||
// league-average one, and those are different claims.
|
||||
const r = ps.platoonRead({ splits: { vl: side(0.350, 25), vr: side(0.250, 400) }, bats: 'R', throws: 'L' });
|
||||
expect(r.readable).toBe(false);
|
||||
expect(r.reason).toBe('insufficient_split_sample');
|
||||
expect(r.multiplier).toBeNull();
|
||||
expect(r.smaller_side_pa).toBe(25);
|
||||
});
|
||||
|
||||
it('the SMALLER side governs — 500 against 40 is a 40-PA read', () => {
|
||||
const r = ps.platoonRead({ splits: { vl: side(0.300, 40), vr: side(0.250, 500) }, bats: 'R', throws: 'L' });
|
||||
expect(r.readable).toBe(false);
|
||||
expect(r.smaller_side_pa).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('switch hitters are unreadable, not automatically credited', () => {
|
||||
it('does not hand a switch hitter a free edge', () => {
|
||||
// He always bats opposite, so the DIRECTION is never in doubt — but the
|
||||
// per-side value of his swing is a question this sample cannot answer.
|
||||
const r = ps.platoonRead({ splits: bigSplit, bats: 'S', throws: 'L' });
|
||||
expect(r.readable).toBe(false);
|
||||
expect(r.reason).toBe('switch_hitter_side_value_unknown');
|
||||
expect(r.multiplier).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('honesty', () => {
|
||||
it('missing a split side is unreadable, never assumed symmetric', () => {
|
||||
const r = ps.platoonRead({ splits: { vl: side(0.300, 200) }, bats: 'R', throws: 'L' });
|
||||
expect(r.readable).toBe(false);
|
||||
expect(r.reason).toBe('missing_split');
|
||||
});
|
||||
|
||||
it('no pitcher hand → no read at all', () => {
|
||||
expect(ps.platoonRead({ splits: bigSplit, bats: 'R', throws: null })).toBeNull();
|
||||
expect(ps.platoonRead({ splits: null, bats: 'R', throws: 'L' })).toBeNull();
|
||||
});
|
||||
|
||||
it('the effect is bounded however extreme the split', () => {
|
||||
const absurd = ps.platoonRead({ splits: { vl: side(0.900, 500), vr: side(0.050, 500) }, bats: 'R', throws: 'L' });
|
||||
expect(absurd.multiplier).toBeLessThanOrEqual(1 + ps.MAX_EFFECT + 1e-9);
|
||||
});
|
||||
|
||||
it('reasoning is emitted only for a real read', () => {
|
||||
const good = ps.platoonRead({ splits: bigSplit, bats: 'R', throws: 'L' });
|
||||
expect(ps.explain(good, 'R', 'L')).toMatch(/measured split is \d+ points/);
|
||||
expect(ps.explain({ readable: false }, 'R', 'L')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user