Files
vyndr/tests/unit/hitsFactors.test.js
T
builtbykev 43f65d30cb Wire the three proven hits factors pre-grade: transmission proven, gain
inconclusive

THE BUG THIS NEARLY SHIPPED AS A FINDING. The first audit reported 0
factors fired on all 1,140 rows. Not a result -- my paging helper ordered
by `id`, and batter_spray, team_defense, platoon_splits and
statcast_aggregates have composite primary keys with NO id column. The
query errored, the loop broke on error, and four fully-populated tables
read as empty. hitsFactorContext.js -- the PRODUCTION loader -- had the
identical defect, so live wiring would have loaded nothing and served
unadjusted while logging success. Third occurrence of this class in one
session. Both loaders now order by a real column and THROW rather than
degrade. The Phase 2 gate is what caught it: no resolution number was
quoted until transmission was proved.

PHASE 1 — pipeline is now base -> FACTORS -> CALIBRATE -> GRADE. Context
built in snapshotService BEFORE gradeAndCacheSlate (was line 640+, grade
at 454), threaded per prop, applied to p_over before p_win is set with
p_win_prefactor and a full trace retained. Hits only. Coverage 859/1140
rows (75%): 474 with all three factors, 256 two, 129 one, 281 none.

PHASE 2 — TRANSMISSION PROVEN, 12/12 sign-correct, 4/4 per factor, each
applied IN ISOLATION. My first table compared each factor's expected sign
against the COMPOSITE change and showed 3 false failures -- with three
factors firing the net can oppose any single member; that was a flaw in
the test, not the wiring. Two under-side rows confirm the flip is handled:
a factor raising p(over) correctly lowers p_win. Switch hitters (Bailey,
Bell, Rocchio) took no spray adjustment while their other factors fired
normally -- the refusal is selective, not a blanket skip.

PHASE 3/4 — both maps refit on the factor-adjusted forecast; the
shadow-duel baseline is VOID and restarts, since it accumulated against a
different forecast. Point-in-time, 765 held-out rows:

  reliability 0.00795 -> 0.00828
  RESOLUTION  0.00229 -> 0.00345   (variance explained 0.93% -> 1.39%)
  Brier       0.25398 -> 0.25305   delta -0.00093  CI [-0.00225,+0.00002]

Resolution rose 51% relative. The CI TOUCHES ZERO on 4 eval dates, so the
composition does NOT earn a proven keep -- three isolated passes did not
grant a composed pass. INCONCLUSIVE, reported as such. The gain is far
below the sum of the isolated effects, which is expected: all three run
through the same pitcher-batter confrontation and share signal.

PHASE 5 — 1.39% of variance is still far below what band separation
needs. The pivot was correct and incomplete: the plumbing defect was real
and is fixed, three proven factors reach the served number for the first
time, and transmission alone did not buy grade separation. Next arc is
factor STRENGTH and BREADTH, not more plumbing.

PHASE 6 — rbi anomaly logged, not chased: 14.51% variance explained vs
hits 1.03%, on the stat we do not serve corrected and which has no proven
factors. Either the biggest lever on the board or a mirage; it deserves
its own order.

The byte-identical invariant INVERTED for hits by design. All 13 frozen
non-hits modules verified unchanged, probabilityEstimator included -- the
factors ride outside it. No new Bonferroni slot; the composed OOS claim is
reported with its CI and not claimed as a pass.

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

89 lines
4.1 KiB
JavaScript

'use strict';
/**
* The three proven hits factors, on the serving path at last.
*
* What these lock: the SIGN each factor moves the forecast, and that every
* unreadable case leaves it completely alone. A factor that nudges toward a
* default on missing input is fabricating a read from an absence.
*/
const hf = require('../../src/services/model/hitsFactors');
const pullGround = { pull_gb: 0.45, straight_gb: 0.10, oppo_gb: 0.05, pull_air: 0.20, straight_air: 0.12, oppo_air: 0.08 };
const pos = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { oaa: v, fielders: 2 }]));
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) };
describe('each factor moves the forecast in the direction it proved', () => {
it('TOUGH spray defence lowers the hit forecast', () => {
const eliteLeft = pos({ '3B': 12, SS: 10, '1B': 0, '2B': 0, LF: 0, CF: 0, RF: 0 });
const out = hf.adjustProbability(0.6, { spray: pullGround, bats: 'R', positionOaa: eliteLeft });
expect(out.p_adjusted).toBeLessThan(0.6);
expect(out.applied.map((a) => a.factor)).toContain('defense_by_direction');
});
it('a CONTACT-ALLOWING pitcher raises it; a bat-misser lowers it', () => {
const soft = hf.adjustProbability(0.6, { pitcherHardHit: 0.46 });
const tough = hf.adjustProbability(0.6, { pitcherHardHit: 0.31 });
expect(soft.p_adjusted).toBeGreaterThan(0.6);
expect(tough.p_adjusted).toBeLessThan(0.6);
});
it('a PLATOON disadvantage lowers it, the edge raises it', () => {
const edge = hf.adjustProbability(0.6, { platoonSplits: bigSplit, bats: 'R', throws: 'L' });
const wrongSide = hf.adjustProbability(0.6, { platoonSplits: bigSplit, bats: 'R', throws: 'R' });
expect(edge.p_adjusted).toBeGreaterThan(0.6);
expect(wrongSide.p_adjusted).toBeLessThan(0.6);
});
});
describe('unreadable means UNTOUCHED, never nudged to a default', () => {
it('a SWITCH hitter gets no spray adjustment', () => {
const out = hf.adjustProbability(0.6, { spray: pullGround, bats: 'S', positionOaa: pos({ '3B': 12, SS: 10 }) });
expect(out.applied.find((a) => a.factor === 'defense_by_direction')).toBeUndefined();
expect(out.skipped.map((s) => s.factor)).toContain('defense_by_direction');
});
it('a THIN platoon split is refused, not shrunk toward league', () => {
const thin = { vl: side(0.350, 25), vr: side(0.250, 400) };
const out = hf.adjustProbability(0.6, { platoonSplits: thin, bats: 'R', throws: 'L' });
expect(out.applied.find((a) => a.factor === 'platoon_severity')).toBeUndefined();
});
it('no pitcher profile contributes nothing at all', () => {
expect(hf.pitcherContactMultiplier(null)).toBeNull();
const out = hf.adjustProbability(0.6, { pitcherHardHit: null });
expect(out.p_adjusted).toBe(0.6);
});
it('with NOTHING readable the forecast is returned exactly', () => {
const out = hf.adjustProbability(0.6, {});
expect(out.p_adjusted).toBe(0.6);
expect(out.multiplier).toBe(1);
expect(out.factors_fired).toBe(0);
});
it('a null forecast stays null — no factor invents one', () => {
expect(hf.adjustProbability(null, { pitcherHardHit: 0.46 }).p_adjusted).toBeNull();
});
});
describe('composition', () => {
it('three factors compound, and the stack is bounded', () => {
const out = hf.adjustProbability(0.6, {
spray: pullGround, bats: 'R', positionOaa: pos({ '3B': -12, SS: -12, '1B': -12, '2B': -12, LF: -12, CF: -12, RF: -12 }),
pitcherHardHit: 0.46, platoonSplits: bigSplit, throws: 'L',
});
expect(out.factors_fired).toBe(3);
expect(out.multiplier).toBeLessThanOrEqual(1 + hf.COMBINED_MAX);
expect(out.multiplier).toBeGreaterThanOrEqual(1 - hf.COMBINED_MAX);
});
it('partial readability applies only what is readable', () => {
const out = hf.adjustProbability(0.6, { pitcherHardHit: 0.46, bats: 'S', spray: pullGround, positionOaa: pos({ '3B': 5 }) });
expect(out.factors_fired).toBe(1);
expect(out.applied[0].factor).toBe('pitcher_contact_profile');
});
});