Files
vyndr/tests/unit/baseballChain.test.js
T
builtbykev 6c34af3414 checkpoint: chain shadow, WNBA possession feed, baseball chain
Backup commit of uncommitted working-tree state found during Legion
recon (Tony resurrection, STEP 0). This work existed only on the
laptop disk.

- chain shadow accrual + probe script (038_chain_shadow.sql)
- WNBA possession feed: ESPN adapter, usage service, verify script
  (039_wnba_player_game.sql)
- baseball chain
- retention/snapshot service updates, tableKeys, matchupKeys
- specs: chain-v1, wnba-possession-feed, wnba-source-survey
- unit tests for the above

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnvJAkC3h5QGmb6dipoiWn
2026-08-14 16:53:37 -04:00

395 lines
17 KiB
JavaScript

'use strict';
/**
* BASEBALL'S chainFn + the shadow that runs it on the live board.
*
* Two things are being protected here. First, that the chain actually COMPUTES
* a probability from base events rather than counting how often the player has
* cleared this number — that is the whole difference between the chain and the
* incumbent counter. Second, that none of it can reach a served grade.
*/
const bc = require('../../src/services/model/baseballChain');
const cs = require('../../src/services/model/chainShadow');
const chain = require('../../src/services/model/chain');
const sk = require('../../src/services/model/skillProjection');
/**
* A real-shaped `statcast_aggregates` row. UNITS MATTER: the table stores
* PERCENTAGES (0-100), and feeding fractions in is the mistake that made
* `bip = 1 - k - bb` come out wrong and refused 568 of 576 rows on the first
* Stage A run.
*/
const batterRow = (over = {}) => ({
player_key: 'test batter',
role: 'batter',
k_pct: 22.2,
bb_pct: 8.5,
barrel_pct: 9.5,
hard_hit_pct: 44.0,
avg_exit_velo: 91.2,
avg_launch_angle: 14.0,
bats: 'R',
sample_pa: 400,
...over,
});
const pitcherRow = (over = {}) => ({
player_key: 'test pitcher',
role: 'pitcher',
source_id: 4242,
k_pct: 29.6,
bb_pct: 6.1,
hard_hit_pct: 33.0,
sample_ip: 120,
...over,
});
describe('BASEBALL chainFn — rate x opportunity, and opportunity is a LOOKUP', () => {
it('produces a probability by chaining the PA rate over expected PA', () => {
const out = bc.chainFn({ statType: 'hits', line: 0.5, batterRow: batterRow(), archetype: 'TORCH' });
expect(out).not.toBeNull();
expect(out.p).toBeGreaterThan(0);
expect(out.p).toBeLessThan(1);
// It is a CHAINED read, not a frequency table: the PA tree is on the row.
expect(out.chain_per_pa.p_hit_per_pa).toBeGreaterThan(0);
expect(out.chain_family).toBe('pa_outcome_tree_binomial');
});
it('the probability is genuinely the binomial over the PA distribution', () => {
// Recomputed independently from the same atoms — if these diverge, the
// chainFn is doing something other than what it claims.
const atom = { statType: 'hits', line: 0.5, batterRow: batterRow(), archetype: 'TORCH', expectedPa: 4 };
const out = bc.chainFn(atom);
const pa = sk.paOutcome({ batter: sk.fromStatcastRow(batterRow()), pitcher: null, park: 1, archetype: 'TORCH' });
const pmf = sk.binomialPmf(4, pa.p_hit_per_pa);
expect(out.p).toBeCloseTo(sk.atLeast(pmf, 1), 2);
});
it('LINEUP SLOT is the opportunity term — leadoff outranks the nine hole', () => {
const at = (slot) => bc.chainFn({
statType: 'hits', line: 0.5, batterRow: batterRow(), archetype: 'TORCH', lineupSlot: slot,
});
const lead = at(1);
const nine = at(9);
expect(lead.chain_expected_pa).toBeGreaterThan(nine.chain_expected_pa);
expect(lead.p).toBeGreaterThan(nine.p);
expect(lead.chain_opportunity_source).toBe('lineup_slot');
});
it('an unposted lineup falls to a REGULAR, and says which it used', () => {
const out = bc.chainFn({ statType: 'hits', line: 0.5, batterRow: batterRow(), archetype: 'TORCH' });
expect(out.chain_opportunity_source).toBe('default_regular');
const explicit = bc.chainFn({ statType: 'hits', line: 0.5, batterRow: batterRow(), expectedPa: 5 });
expect(explicit.chain_opportunity_source).toBe('explicit');
});
it('the slot table is monotone — the decline is the documented structure', () => {
const slots = Object.keys(bc.PA_BY_SLOT).map(Number).sort((a, b) => a - b);
for (let i = 1; i < slots.length; i += 1) {
expect(bc.PA_BY_SLOT[slots[i]]).toBeLessThan(bc.PA_BY_SLOT[slots[i - 1]]);
}
expect(bc.expectedPaForSlot(null)).toBeNull();
expect(bc.expectedPaForSlot(12)).toBeNull();
});
it('ARCHETYPE SELECTS THE FEATURES — the same hitter reads differently', () => {
// A BOMBER's hits ride on barrels; a GHOST's ride on beating out grounders.
// If these agreed, the archetype would be a label rather than a selector.
const row = batterRow({ barrel_pct: 16.0, avg_launch_angle: 20.0 });
const bomber = bc.chainFn({ statType: 'hits', line: 0.5, batterRow: row, archetype: 'BOMBER' });
const ghost = bc.chainFn({ statType: 'hits', line: 0.5, batterRow: row, archetype: 'GHOST' });
expect(Math.abs(bomber.p - ghost.p)).toBeGreaterThan(0.01);
});
it('the OPPOSING PITCHER moves the read, and its absence leaves it untouched', () => {
const solo = bc.chainFn({ statType: 'hits', line: 0.5, batterRow: batterRow(), archetype: 'TORCH' });
const vsAce = bc.chainFn({
statType: 'hits', line: 0.5, batterRow: batterRow(), archetype: 'TORCH', pitcherRow: pitcherRow(),
});
expect(solo.chain_pitcher_applied).toBe(false);
expect(vsAce.chain_pitcher_applied).toBe(true);
// A high-K, contact-suppressing arm lowers it.
expect(vsAce.p).toBeLessThan(solo.p);
});
it('NO BATTER PROFILE IS A REFUSAL, never a league hitter', () => {
expect(bc.chainFn({ statType: 'hits', line: 0.5, batterRow: null })).toBeNull();
expect(bc.chainFn({ statType: 'hits', line: 0.5, batterRow: {} })).toBeNull();
});
it('refuses every stat but hits, so a muddy verdict is not widened into', () => {
expect(bc.chainFn({ statType: 'total_bases', line: 1.5, batterRow: batterRow() })).toBeNull();
expect(bc.chainFn({ statType: 'rbi', line: 0.5, batterRow: batterRow() })).toBeNull();
expect(bc.chainFn({ statType: 'hits', line: null, batterRow: batterRow() })).toBeNull();
});
it('plugs into the core as a chainFn with no special-casing in chain.js', () => {
const atoms = [
{ id: 'a', statType: 'hits', line: 0.5, batterRow: batterRow(), gameId: 'G1', calibrated: false },
{ id: 'b', statType: 'hits', line: 0.5, batterRow: null, gameId: 'G1', calibrated: false },
];
const out = chain.chainAcross(atoms, { chainFn: bc.chainFn, requireCalibrated: false });
expect(out.ok).toBe(true);
expect(out.legs).toBe(1); // the unreadable atom was dropped, not zeroed
expect(out.chain_fn_refused).toBe(1);
});
it('redistribution is DORMANT in baseball — a nine-run lead changes no batting order', () => {
const legs = [{ id: 'a', p: 0.5 }];
expect(bc.redistribute(legs)).toBe(legs);
});
});
describe('THE HAND SPLIT — a matchup read, not a season read', () => {
/** A real split: .284 vs LHP, .221 vs RHP, both sides well over the floor. */
const SPLIT = {
vl: { pa: 220, atBats: 200, hits: 57 }, // .285
vr: { pa: 480, atBats: 430, hits: 95 }, // .221
};
const base = (over = {}) => ({
statType: 'hits', line: 0.5, batterRow: batterRow(), archetype: 'TORCH', expectedPa: 4, ...over,
});
it('WITHOUT a split the chain is arithmetically identical to projectSkill', () => {
// The chain is written out in baseballChain so the split can enter between
// the rate and the opportunity term. This is what stops that restructuring
// from quietly becoming a second, different model.
for (const archetype of ['TORCH', 'BOMBER', 'GHOST', null]) {
for (const pa of [3, 4, 5]) {
const mine = bc.chainFn(base({ archetype, expectedPa: pa }));
const theirs = sk.projectSkill({
batter: sk.fromStatcastRow(batterRow()), pitcher: null, park: 1,
archetype, statType: 'hits', line: 0.5, expectedPa: pa,
});
expect(mine.p).toBeCloseTo(theirs.p_over_line, 3);
expect(mine.chain_projected_value).toBeCloseTo(theirs.projected_value, 3);
}
}
});
it('FIRES when bats + throws + a thick split are all present', () => {
const out = bc.chainFn(base({ bats: 'R', throws: 'L', platoonSplits: SPLIT }));
expect(out.chain_platoon_applied).toBe(true);
expect(out.chain_platoon_reason).toBeNull();
expect(out.chain_platoon_multiplier).toBeGreaterThan(1); // RHB vs LHP = the edge
expect(out.chain_platoon_observed_split).toBeGreaterThan(0);
});
it('it enters at the RATE, not at the output probability', () => {
// p_hit_per_pa moves; the season rate is preserved beside it so the
// adjustment stays re-derivable.
const out = bc.chainFn(base({ bats: 'R', throws: 'L', platoonSplits: SPLIT }));
const season = out.chain_per_pa.p_hit_per_pa_season;
const applied = out.chain_per_pa.p_hit_per_pa;
expect(applied).not.toBeCloseTo(season, 6);
expect(applied).toBeCloseTo(season * out.chain_platoon_multiplier, 3);
});
it('and it runs in the DIRECTION tonight\'s matchup runs', () => {
const edge = bc.chainFn(base({ bats: 'R', throws: 'L', platoonSplits: SPLIT }));
const wrongSide = bc.chainFn(base({ bats: 'R', throws: 'R', platoonSplits: SPLIT }));
expect(edge.p).toBeGreaterThan(wrongSide.p);
});
it('REFUSES on a thin split — with the reason, not a league-typical guess', () => {
const thin = { vl: { pa: 40, atBats: 36, hits: 11 }, vr: { pa: 480, atBats: 430, hits: 95 } };
const out = bc.chainFn(base({ bats: 'R', throws: 'L', platoonSplits: thin }));
expect(out.chain_platoon_applied).toBe(false);
expect(out.chain_platoon_reason).toBe('insufficient_split_sample');
// The season rate stands EXACTLY untouched — a refusal is not a nudge.
expect(out.chain_per_pa.p_hit_per_pa).toBeCloseTo(out.chain_per_pa.p_hit_per_pa_season, 6);
expect(out.p).toBeCloseTo(bc.chainFn(base()).p, 6); // a refusal is EXACT, not approximate
});
it('a switch hitter is UNREADABLE, never credited with an automatic edge', () => {
const out = bc.chainFn(base({ bats: 'S', throws: 'L', platoonSplits: SPLIT }));
expect(out.chain_platoon_applied).toBe(false);
expect(out.chain_platoon_reason).toBe('switch_hitter_side_value_unknown');
});
it('names WHICH input is missing, so the fix is diagnosable', () => {
const r = (over) => bc.chainFn(base(over)).chain_platoon_reason;
expect(r({})).toBe('no_splits');
expect(r({ platoonSplits: SPLIT, bats: 'R' })).toBe('no_pitcher_hand');
expect(r({ platoonSplits: SPLIT, throws: 'L' })).toBe('no_batter_hand');
});
it('THERE IS NO FALLBACK PATH — a refused read is dropped, never a season rate', () => {
// The failure this guards is subtle: if an unreadable atom silently became a
// season-rate read, the shadow would agree with the counter for a reason
// that LOOKS like agreement, and the whole comparison would be worthless.
expect(bc.chainFn(base({ batterRow: null }))).toBeNull();
expect(bc.chainFn(base({ statType: 'rbi' }))).toBeNull();
});
it('refusals are REPORTED to the caller with a reason', () => {
const seen = [];
const ctx = { onRefusal: (id, reason) => seen.push([id, reason]) };
bc.chainFn({ ...base({ batterRow: null }), id: 'a' }, ctx);
bc.chainFn({ ...base({ statType: 'rbi' }), id: 'b' }, ctx);
bc.chainFn({ ...base({ line: null }), id: 'c' }, ctx);
expect(seen).toEqual([['a', 'no_batter_profile'], ['b', 'stat_not_chained'], ['c', 'no_line']]);
});
it('a throwing onRefusal never breaks the read', () => {
expect(() => bc.chainFn({ ...base({ batterRow: null }) }, {
onRefusal: () => { throw new Error('boom'); },
})).not.toThrow();
});
});
describe('SHADOW — fire rate and matchup rate are DIFFERENT questions', () => {
const SPLIT = { vl: { pa: 220, atBats: 200, hits: 57 }, vr: { pa: 480, atBats: 430, hits: 95 } };
const grade = (over = {}) => ({
player: 'Test Batter', stat_type: 'hits', direction: 'over', p_win: 0.62,
game_id: 'G1', team: 'New York Yankees', archetype: 'TORCH',
gradedAt: { line: 0.5 }, ...over,
});
const deps = (over = {}) => ({
statcastByKey: new Map([['test batter', batterRow()]]),
pitcherRowFor: () => pitcherRow({ throws: 'L' }),
...over,
});
it('counts the hand split separately from the fire rate', () => {
const withSplit = cs.runShadow([grade()], deps({
handSplitFor: () => ({ bats: 'R', throws: 'L', platoonSplits: SPLIT }),
}));
expect(withSplit.summary.readable).toBe(1);
expect(withSplit.summary.platoon_applied).toBe(1);
const without = cs.runShadow([grade()], deps());
expect(without.summary.readable).toBe(1); // it still FIRES...
expect(without.summary.platoon_applied).toBe(0); // ...but on the SEASON rate
expect(without.summary.platoon_reasons.no_splits).toBe(1);
});
it('a refused atom is counted ONCE, not once per reading', () => {
// chainAcross, chainUp and prepareAtoms each apply the chainFn. Running raw
// atoms through all three would report a 3x refusal count.
const out = cs.runShadow([grade({ player: 'Nobody' })], deps());
expect(out.summary.refused).toBe(1);
expect(out.summary.refusal_reasons).toEqual({ no_batter_profile: 1 });
});
it('the stored block records the split state either way', () => {
const on = [...cs.runShadow([grade()], deps({
handSplitFor: () => ({ bats: 'R', throws: 'L', platoonSplits: SPLIT }),
})).byKey.values()][0];
expect(on.platoon_applied).toBe(true);
expect(on.platoon_multiplier).toBeGreaterThan(1);
const off = [...cs.runShadow([grade()], deps()).byKey.values()][0];
expect(off.platoon_applied).toBe(false);
expect(off.platoon_reason).toBe('no_splits');
expect(off.platoon_multiplier).toBeNull();
});
it('a throwing handSplitFor degrades to a season read, never a broken slate', () => {
const out = cs.runShadow([grade()], deps({
handSplitFor: () => { throw new Error('boom'); },
}));
expect(out.summary.readable).toBe(1);
expect(out.summary.platoon_applied).toBe(0);
});
});
describe('CHAIN SHADOW — runs on the board, reaches nothing', () => {
const grade = (over = {}) => ({
player: 'Test Batter',
stat_type: 'hits',
direction: 'over',
p_win: 0.62,
grade: 'B',
game_id: 'mlb:2026-08-12:NYY@BOS',
team: 'New York Yankees',
archetype: 'TORCH',
gradedAt: { line: 0.5, odds: -130, timestamp: '2026-08-12T18:00:00Z' },
...over,
});
const deps = () => ({
statcastByKey: new Map([['test batter', batterRow()]]),
pitcherRowFor: () => pitcherRow(),
});
it('produces the (chain_p, counter_p) pair the triple needs', () => {
const out = cs.runShadow([grade()], deps());
const block = [...out.byKey.values()][0];
expect(block).toBeTruthy();
const aligned = cs.alignToSide(block, 'over', 0.62);
expect(aligned.chain_p).toBeGreaterThan(0);
expect(aligned.counter_p).toBeCloseTo(0.62, 6);
expect(aligned.divergence).toBeCloseTo(aligned.chain_p - 0.62, 4);
});
it('EVERY block declares itself UN-SERVABLE in the data, not just in a comment', () => {
const out = cs.runShadow([grade()], deps());
for (const block of out.byKey.values()) {
expect(block.servable).toBe(false);
expect(block.status).toBe('UN-SERVABLE');
expect(block.servable_reason).toMatch(/calibration gate/);
}
});
it('SIDE ALIGNMENT: an under row carries 1 - p_over, or every later read inverts', () => {
const out = cs.runShadow([grade({ direction: 'under', p_win: 0.38 })], deps());
const block = [...out.byKey.values()][0];
const over = cs.alignToSide(block, 'over', 0.62);
const under = cs.alignToSide(block, 'under', 0.38);
expect(over.chain_p + under.chain_p).toBeCloseTo(1, 4);
expect(under.side).toBe('under');
});
it('scores against the LOCKED line, not the current one', () => {
// Scoring the chain on a line the counter never saw would compare two
// forecasts of two different questions.
const g = grade({ line: 1.5, gradedAt: { line: 0.5 } });
const atoms = cs.buildAtoms([g], deps());
expect(atoms[0].line).toBe(0.5);
});
it('ignores every stat but hits, and any row with no locked line', () => {
const rows = [
grade({ stat_type: 'total_bases' }),
grade({ stat_type: 'rbi' }),
grade({ gradedAt: null, line: null }),
];
expect(cs.buildAtoms(rows, deps())).toHaveLength(0);
});
it('a player with no statcast profile yields NO BLOCK — absent, not zero', () => {
const out = cs.runShadow([grade({ player: 'Unknown Guy' })], deps());
expect(out.byKey.size).toBe(0);
expect(out.summary.refused).toBe(1);
expect(out.summary.atoms).toBe(1); // it was attempted, and that is visible
});
it('an absent block never fabricates a triple', () => {
expect(cs.alignToSide(null, 'over', 0.5)).toBeNull();
expect(cs.alignToSide({ chain_p_over: null }, 'over', 0.5)).toBeNull();
expect(cs.alignToSide({ chain_p_over: 0.4 }, '', 0.5)).toBeNull();
// An absent counter is absent — not zero, which would read as a 40pt edge.
expect(cs.alignToSide({ chain_p_over: 0.4 }, 'over', null).counter_p).toBeNull();
expect(cs.alignToSide({ chain_p_over: 0.4 }, 'over', null).divergence).toBeNull();
});
it('runs BOTH readings and reports the self-check as VACUOUS today', () => {
// Both readings derive from the same atoms because no independent team read
// exists. Agreement is arithmetic. Labelling it is the honest move.
const out = cs.runShadow([grade(), grade({ player: 'Test Batter', stat_type: 'hits' })], deps());
const block = [...out.byKey.values()][0];
expect(block.game.up_expected_hits).toBeGreaterThan(0);
expect(block.game.across_compound).toBeGreaterThan(0);
expect(block.game.self_check.vacuous).toBe(true);
expect(block.game.self_check.vacuous_reason).toMatch(/same atoms/);
});
it('an empty slate is an empty result, never a thrown snapshot', () => {
const out = cs.runShadow([], deps());
expect(out.byKey.size).toBe(0);
expect(out.summary.atoms).toBe(0);
});
});