'use strict'; /** * A6 — the join keys, resolved into a SHADOW resolve. * * A5 measured all three hits factors skipped on 596/596 because nothing sets * `prop.opponent` / `prop.opposing_pitcher`. A6 resolves them and freezes what * the factors WOULD do. The served grade must not move. */ const mk = require('../../src/services/model/matchupKeys'); const ctxSvc = require('../../src/services/model/hitsFactorContext'); const hf = require('../../src/services/model/hitsFactors'); const ff = require('../../src/services/model/factorFreeze'); const { nameKey } = require('../../src/utils/playerName'); const JUDGE = nameKey('Aaron Judge'); const fakeSb = (rows) => ({ from() { const st = { lte: null }; const q = { select() { return q; }, eq() { return q; }, lte(c, v) { if (c === 'as_of_date') st.lte = v; return q; }, order() { return q; }, async range(f, t2) { const r = st.lte ? rows.filter((x) => String(x.as_of_date) <= st.lte) : rows; return { data: r.slice(f, t2 + 1), error: null }; }, }; return q; }, }); const LINEUP = [{ as_of_date: '2026-08-09', game_date: '2026-08-09', sport: 'mlb', game_pk: 1, team: 'New York Yankees', side: 'home', player_key: JUDGE, }]; const SCHED = [{ home: { team: 'New York Yankees', probablePitcher: { id: 1, name: 'Gerrit Cole' } }, away: { team: 'Boston Red Sox', probablePitcher: { id: 2, name: 'Chris Sale' } }, }]; describe('key resolution', () => { it('resolves opponent + opposing pitcher from lineup + schedule', async () => { const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => SCHED, gameDate: '2026-08-09' }); const k = r({ player: 'Aaron Judge' }); expect(k.opponent).toBe('Boston Red Sox'); expect(k.opposing_pitcher).toBe('Chris Sale'); expect(k.source).toBe('lineup_context+schedule'); expect(k.refused).toBeNull(); }); it('REFUSES a player with no posted lineup row — never guesses from the prop teams', async () => { const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => SCHED, gameDate: '2026-08-09' }); // The prop knows both teams; the resolver still refuses, because which side // the hitter bats for is not knowable from that. const k = r({ player: 'Nobody Atall', home_team: 'NYY', away_team: 'BOS' }); expect(k.opponent).toBeNull(); expect(k.opposing_pitcher).toBeNull(); expect(k.refused).toBe('no_lineup_row'); }); it('refuses the pitcher when the game has no probable, keeping the opponent', async () => { const noSP = [{ home: { team: 'New York Yankees' }, away: { team: 'Boston Red Sox' } }]; const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => noSP, gameDate: '2026-08-09' }); const k = r({ player: 'Aaron Judge' }); expect(k.opponent).toBe('Boston Red Sox'); expect(k.opposing_pitcher).toBeNull(); expect(k.refused).toBe('no_probable_pitcher'); }); it('bounds the lineup read by as-of (A4 discipline)', async () => { const future = [{ ...LINEUP[0], as_of_date: '2026-08-20' }]; const r = await mk.build({ sb: fakeSb(future), getSchedule: async () => SCHED, gameDate: '2026-08-09', asOf: '2026-08-09' }); expect(r({ player: 'Aaron Judge' }).refused).toBe('no_lineup_row'); }); it('returns null (not a stub) when nothing loads', async () => { expect(await mk.build({})).toBeNull(); }); }); describe('the resolver: LIVE unchanged, SHADOW fires', () => { const TABLES = { batter_spray: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, source_id: 1, player_key: JUDGE, pull_gb: 0.3, straight_gb: 0.2, oppo_gb: 0.1, pull_air: 0.2, straight_air: 0.1, oppo_air: 0.1 }], team_defense: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, team: 'Boston Red Sox', position_oaa: { '3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 }, '2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 } } }], platoon_splits: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, player_key: JUDGE, vl_pa: 120, vl_ab: 110, vl_hits: 33, vr_pa: 300, vr_ab: 280, vr_hits: 70 }], statcast_aggregates: [ { sport: 'mlb', season: 2026, source_id: 1, role: 'batter', player_key: JUDGE, bats: 'R', hard_hit_pct: 40 }, { sport: 'mlb', season: 2026, source_id: 2, role: 'pitcher', player_key: nameKey('Chris Sale'), throws: 'L', hard_hit_pct: 45 }, ], }; const sbCtx = () => ({ from(table) { const q = { select() { return q; }, eq() { return q; }, lte() { return q; }, order() { return q; }, async range(f, t2) { return { data: (TABLES[table] || []).slice(f, t2 + 1), error: null }; } }; return q; }, }); const PROP = { player: 'Aaron Judge', stat_type: 'hits' }; const KEYS = { opponent: 'Boston Red Sox', opposing_pitcher: 'Chris Sale', source: 'lineup_context+schedule', refused: null }; it('WITHOUT keys nothing fires — the measured live state', async () => { const ctx = await ctxSvc.build(sbCtx()); const c = ctx(PROP, 'mlb'); const m = hf.hitsFactorMultiplier(c); expect(c.positionOaa).toBeNull(); expect(c.throws).toBeNull(); expect(m.factors_fired).toBe(0); expect(m.multiplier).toBe(1); }); it('WITH keys the factors fire — same indexes, different join', async () => { const ctx = await ctxSvc.build(sbCtx()); const c = ctx(PROP, 'mlb', KEYS); const m = hf.hitsFactorMultiplier(c); expect(c.positionOaa).not.toBeNull(); expect(c.throws).toBe('L'); expect(m.factors_fired).toBeGreaterThan(0); expect(m.multiplier).not.toBe(1); }); it('the shadow block records the would-fire result and stays recomputable', async () => { const ctx = await ctxSvc.build(sbCtx()); const live = ctx(PROP, 'mlb'); const shadowCtx = ctx(PROP, 'mlb', KEYS); const shM = hf.hitsFactorMultiplier(shadowCtx); const frozen = ff.freeze(live, PROP, ff.shadowBlock(shadowCtx, shM, KEYS)); expect(frozen.would_fire.multiplier).toBe(shM.multiplier); expect(frozen.would_fire.factors_fired).toBe(shM.factors_fired); expect(frozen.would_fire.keys.opponent).toBe('Boston Red Sox'); // Recomputable from what was stored beside it. const re = hf.hitsFactorMultiplier({ spray: live.spray, bats: live.bats, platoonSplits: live.platoonSplits, positionOaa: frozen.would_fire.shadow_inputs.position_oaa, throws: frozen.would_fire.shadow_inputs.throws, pitcherHardHit: frozen.would_fire.shadow_inputs.pitcher_hard_hit, }); expect(re.multiplier).toBe(frozen.would_fire.multiplier); }); it('the LIVE frozen inputs still show the refusal — shadow does not overwrite them', async () => { const ctx = await ctxSvc.build(sbCtx()); const live = ctx(PROP, 'mlb'); const shadowCtx = ctx(PROP, 'mlb', KEYS); const frozen = ff.freeze(live, PROP, ff.shadowBlock(shadowCtx, hf.hitsFactorMultiplier(shadowCtx), KEYS)); expect(frozen.position_oaa).toBeNull(); // what the SERVED grade saw expect(frozen.throws).toBeNull(); expect(frozen.would_fire.shadow_inputs.throws).toBe('L'); // what it WOULD have seen }); it('no shadow block when the keys were refused', async () => { const ctx = await ctxSvc.build(sbCtx()); const live = ctx(PROP, 'mlb'); expect(ff.freeze(live, PROP, null).would_fire).toBeNull(); }); }); describe('the served grade cannot read the shadow', () => { it('the engine assigns pOver only from the LIVE adjust, never from would_fire', () => { const src = require('fs').readFileSync( require('path').join(__dirname, '..', '..', 'src/services/intelligence/analyzeViaEngine1.js'), 'utf8'); const i = src.indexOf('FIX A6 — SHADOW RESOLVE'); const block = src.slice(i, i + 900); expect(block).toMatch(/shadow = ffz\.shadowBlock/); expect(block).not.toMatch(/pOver\s*=/); // and the only pOver assignment in the factor area is gated on the LIVE adj expect(src).toMatch(/if \(adj\.p_adjusted != null && adj\.factors_fired > 0\) \{\s*\n\s*pOver = adj\.p_adjusted;/); }); });