'use strict'; /** * hitsFactorContext as-of — Fix A4. * * The contamination this locks out: without a cutoff, `latestBy` returns TODAY'S * dated row, so auditing a 2026-08-07 grade would join a profile built from * games played after it. A clean read of the wrong date is still contamination. * * Fake client throughout; nothing here touches a network. */ const ctxSvc = require('../../src/services/model/hitsFactorContext'); const hf = require('../../src/services/model/hitsFactors'); const { nameKey } = require('../../src/utils/playerName'); // Derived, never hardcoded: nameKey resolves nicknames ('Chris' -> 'christopher'), // so a literal key would silently miss and look like an honest absence. const BATTER = nameKey('Aaron Judge'); const PITCHER = nameKey('Chris Sale'); /** Records every filter so an unapplied as-of bound fails the test. */ function fakeSb(tables) { const calls = []; return { calls, from(table) { const state = { table, lte: null, eq: {} }; calls.push(state); const q = { select() { return q; }, eq(c, v) { state.eq[c] = v; return q; }, lte(c, v) { if (c === 'as_of_date') state.lte = v; return q; }, order() { return q; }, async range(from, to) { let rows = tables[table] || []; if (state.lte) rows = rows.filter((r) => String(r.as_of_date) <= state.lte); return { data: rows.slice(from, to + 1), error: null }; }, }; return q; }, }; } const spray = (d, key, id) => ({ as_of_date: d, sport: 'mlb', season: 2026, source_id: id, player_key: key, pull_gb: 0.3, straight_gb: 0.2, oppo_gb: 0.1, pull_air: 0.2, straight_air: 0.1, oppo_air: 0.1, }); // positionOaa is { position: { oaa, fielders } } — not a bare number. const defense = (d, team) => ({ as_of_date: d, sport: 'mlb', season: 2026, team, position_oaa: { '3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 }, '2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 }, }, }); const platoon = (d, key) => ({ as_of_date: d, sport: 'mlb', season: 2026, player_key: key, vl_pa: 120, vl_ab: 110, vl_hits: 33, vr_pa: 300, vr_ab: 280, vr_hits: 70, }); const sc = (d, key, id, role, extra = {}) => ({ as_of_date: d, sport: 'mlb', season: 2026, source_id: id, role, player_key: key, bats: 'R', throws: 'L', hard_hit_pct: 40, ...extra, }); const TABLES = { batter_spray: [spray('2026-08-05', BATTER, 1), spray('2026-08-09', BATTER, 1)], team_defense: [defense('2026-08-05', 'Red Sox'), defense('2026-08-09', 'Red Sox')], platoon_splits: [platoon('2026-08-05', BATTER), platoon('2026-08-09', BATTER)], statcast_aggregates: [sc(null, BATTER, 1, 'batter'), sc(null, PITCHER, 2, 'pitcher', { hard_hit_pct: 30 })], statcast_history: [ sc('2026-08-05', BATTER, 1, 'batter'), sc('2026-08-09', BATTER, 1, 'batter'), sc('2026-08-05', PITCHER, 2, 'pitcher', { hard_hit_pct: 30 }), sc('2026-08-09', PITCHER, 2, 'pitcher', { hard_hit_pct: 99 }), // today's value ], }; const PROP = { player: 'Aaron Judge', opponent: 'Red Sox', opposing_pitcher: 'Chris Sale' }; describe('as-of bound is APPLIED, and only when asked', () => { it('LIVE (no asOf) applies no as_of filter and reads statcast_aggregates', async () => { const sb = fakeSb(TABLES); const r = await ctxSvc.build(sb); expect(r.asOf).toBeNull(); expect(r.__stats.mode).toBe('live'); expect(r.__stats.statcast_source).toBe('statcast_aggregates'); expect(sb.calls.every((c) => c.lte === null)).toBe(true); expect(sb.calls.map((c) => c.table)).toContain('statcast_aggregates'); expect(sb.calls.map((c) => c.table)).not.toContain('statcast_history'); }); it('AS-OF applies lte on EVERY dated table and reads statcast_history', async () => { const sb = fakeSb(TABLES); const r = await ctxSvc.build(sb, { asOf: '2026-08-07' }); expect(r.__stats.mode).toBe('as-of (audit)'); expect(r.__stats.statcast_source).toBe('statcast_history'); for (const t of ['batter_spray', 'team_defense', 'platoon_splits', 'statcast_history']) { const c = sb.calls.find((x) => x.table === t); expect(c.lte).toBe('2026-08-07'); } }); }); describe('THE CONTAMINATION, LOCKED OUT', () => { it('never returns a row dated after asOf', async () => { const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' }); const c = r(PROP); expect(c.spray.as_of_date).toBe('2026-08-05'); // NOT the 08-09 row expect(c.as_of).toBe('2026-08-07'); }); it("picks up TODAY's value on the live path — proving the test can tell them apart", async () => { const live = await ctxSvc.build(fakeSb(TABLES)); const past = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' }); // The pitcher's hard-hit rate moved 30 -> 99 on 08-09. expect(live(PROP).pitcherHardHit).toBeCloseTo(0.30, 5); expect(past(PROP).pitcherHardHit).toBeCloseTo(0.30, 5); const today = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-09' }); expect(today(PROP).pitcherHardHit).toBeCloseTo(0.99, 5); }); it('takes the LATEST row within the bound, not the earliest', async () => { const tables = { ...TABLES, batter_spray: [spray('2026-08-01', BATTER, 1), spray('2026-08-05', BATTER, 1), spray('2026-08-09', BATTER, 1)] }; const r = await ctxSvc.build(fakeSb(tables), { asOf: '2026-08-07' }); expect(r(PROP).spray.as_of_date).toBe('2026-08-05'); }); }); describe('REFUSAL OVER RECONSTRUCTION', () => { it('an entity with NO row at-or-before asOf yields null — never the nearest row', async () => { // Every dated row is AFTER the cutoff. The honest answer is "no context". const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-07-01' }); expect(r).toBeNull(); // nothing loaded at all → serve unadjusted }); it('refuses ONE entity while still serving another', async () => { const tables = { ...TABLES, batter_spray: [spray('2026-08-09', BATTER, 1)], // only AFTER the cutoff platoon_splits: [platoon('2026-08-05', BATTER)], // present at the cutoff }; const r = await ctxSvc.build(fakeSb(tables), { asOf: '2026-08-07' }); const c = r(PROP); expect(c.spray).toBeNull(); // refused expect(c.platoonSplits).not.toBeNull(); }); it('a refused input leaves the BASE RATE UNTOUCHED (factor does not apply)', () => { // The end-to-end consequence: no spray → no defense_by_direction multiplier. const withSpray = hf.hitsFactorMultiplier({ spray: TABLES.batter_spray[0], positionOaa: TABLES.team_defense[0].position_oaa, bats: 'R', }); const refused = hf.hitsFactorMultiplier({ spray: null, positionOaa: TABLES.team_defense[0].position_oaa, bats: 'R', }); expect(refused.applied.map((a) => a.factor)).not.toContain('defense_by_direction'); expect(refused.skipped.map((s) => s.factor)).toContain('defense_by_direction'); // and with nothing else readable the multiplier is exactly 1 — a no-op. expect(refused.multiplier).toBe(1); expect(withSpray.factors_fired).toBeGreaterThan(refused.factors_fired); }); it('adjustProbability is unchanged when every input is refused', () => { const out = hf.adjustProbability(0.42, { spray: null, positionOaa: null, bats: null }); expect(out.p_adjusted).toBe(0.42); }); }); describe('archetype is CARRIED, never invented', () => { it('passes the graded row archetype through', async () => { const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' }); expect(r({ ...PROP, archetype: 'BOMBER' }).archetype).toBe('BOMBER'); }); it('is null when the caller supplies none — no context table holds it', async () => { const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' }); expect(r(PROP).archetype).toBeNull(); }); }); describe('the context read stays on safePaginate with the real composite key', () => { it('orders by the full unique tuple of each table', () => { const src = require('fs').readFileSync( require('path').join(__dirname, '..', '..', 'src/services/model/hitsFactorContext.js'), 'utf8'); expect(src).toMatch(/uniqueKeyFor\(table\)/); expect(src).toMatch(/require\('\.\.\/\.\.\/utils\/safePaginate'\)/); // No hand-rolled walk left behind. expect(src).not.toMatch(/for \(let from = 0/); }); });