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:
Kev
2026-08-04 20:30:57 -04:00
parent 20c45cbcd1
commit de0077f6f9
3 changed files with 351 additions and 2 deletions
+100
View File
@@ -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();
});
});