'use strict'; /** * matchupKeys — resolve the two join keys the hits factors have always needed. * * Spec: specs/read-integrity-harness.md §11 * * ── WHY THIS EXISTS ────────────────────────────────────────────────────── * A5 measured that all three hits factors are skipped on 596/596 real props, * because `hitsFactorContext` reads `prop.opponent` and `prop.opposing_pitcher` * and NOTHING in the pipeline ever sets them. The matchup model is built and * disconnected; these are the two wires. * * ── AT GRADE TIME, THE GAME HAS NOT HAPPENED ───────────────────────────── * `prove-hit-factors` resolves the opponent from each hitter's own GAME LOG, * which is correct for auditing a settled row and useless before first pitch — * tonight's game is not in the log yet. So the grade-time source is the pair the * schedule already publishes: * * player -> team `lineup_context` (as-of dated, one row per posted lineup) * team -> matchup `getScheduleWithPitchers(gameDate)` (probable pitchers) * * Both are as-of-correct by construction: the lineup read is bounded by * `as_of_date <= asOf` exactly like A4's context reads, and a probable pitcher is * a pre-game fact. * * ── REFUSAL OVER GUESSING ──────────────────────────────────────────────── * A prop whose player has no posted lineup row, or whose game has no probable * pitcher, resolves to NULL. It does NOT fall back to "the other team in the * prop's game_id" — a prop carries `home_team` and `away_team`, so guessing * which side a hitter bats for would be right about half the time and wrong * invisibly. A guessed opponent would feed the defence factor a real team's * fielders against the wrong hitter, which is worse than not firing. */ const { paginate } = require('../../utils/safePaginate'); const { uniqueKeyFor } = require('../../utils/tableKeys'); const { nameKey } = require('../../utils/playerName'); /** Keep the latest dated row per key within an as-of bound. */ function latestBy(rows, keyFn, dateFn) { const m = new Map(); for (const r of rows) { const k = keyFn(r); if (!k) continue; const prev = m.get(k); if (!prev || String(dateFn(r)) > String(dateFn(prev))) m.set(k, r); } return m; } /** Normalise a team name for matching across feeds ("Chicago Cubs" vs "Cubs"). */ function teamForms(name) { const s = String(name || '').trim(); if (!s) return []; const last = s.split(' ').pop(); return [...new Set([s, last])]; } /** * Build the per-slate index. * * @param {object} deps * - sb supabase client (for lineup_context) * - getSchedule async (date) => [{ home:{team,probablePitcher}, away:{...} }] * - gameDate 'YYYY-MM-DD' * - asOf as-of bound for the lineup read (default: gameDate) * @returns {object|null} { resolve(prop), stats } — null when nothing loaded */ async function build(deps = {}) { const { sb, getSchedule, gameDate } = deps; if (!sb || !getSchedule || !gameDate) return null; const asOf = deps.asOf || gameDate; let lineups = []; let games = []; try { [lineups, games] = await Promise.all([ // `batting_order` rides along on a read that already happens — it is the // OPPORTUNITY term the chain needs (PA per game is set almost entirely by // lineup slot), and fetching it separately would be a second query for a // column already in the row. Nothing on the served path reads it. paginate(() => sb.from('lineup_context') .select('as_of_date, game_date, sport, game_pk, team, side, player_key, batting_order') .eq('sport', 'mlb').eq('game_date', gameDate).lte('as_of_date', asOf), { key: uniqueKeyFor('lineup_context'), pageSize: 1000, label: 'matchupKeys:lineup_context' }), getSchedule(gameDate), ]); } catch (e) { // Surfaced, never swallowed as an empty feed (the recurring costume). console.warn('[matchupKeys] load FAILED (not an empty feed):', e.message); return null; } // player -> the team he is posted to bat for, as of the cutoff const teamByPlayer = latestBy(lineups, (r) => r.player_key, (r) => r.as_of_date); // team -> { opponent, opposing_pitcher } const matchup = new Map(); for (const g of games || []) { const h = g && g.home; const a = g && g.away; if (!h || !a || !h.team || !a.team) continue; const put = (side, other) => { const sp = other.probablePitcher && other.probablePitcher.name ? other.probablePitcher.name : null; for (const form of teamForms(side.team)) { if (!matchup.has(form)) matchup.set(form, { opponent: other.team, opposing_pitcher: sp }); } }; put(h, a); put(a, h); } const stats = { game_date: gameDate, as_of: asOf, lineup_players: teamByPlayer.size, scheduled_games: (games || []).length, teams_with_matchup: matchup.size, games_with_both_probables: (games || []).filter((g) => g && g.home && g.away && g.home.probablePitcher && g.away.probablePitcher).length, }; /** * @returns {object} { opponent, opposing_pitcher, source, refused } * Nulls are refusals — never a guess from the prop's two teams. */ function resolve(prop) { const key = nameKey(prop && (prop.player || prop.player_name)); const empty = { opponent: null, opposing_pitcher: null, batting_order: null, source: null, refused: 'no_lineup_row' }; if (!key) return { ...empty, refused: 'no_player' }; const lu = teamByPlayer.get(key); if (!lu || !lu.team) return empty; // The posted batting slot, carried whether or not the matchup resolves — a // hitter with no probable pitcher declared still has a lineup position, and // that is the opportunity term. Absent stays absent (never slot 4 by // default, which would assert he bats cleanup). const slot = lu.batting_order == null ? null : Number(lu.batting_order); const battingOrder = Number.isFinite(slot) ? slot : null; const m = matchup.get(lu.team) || matchup.get(String(lu.team).split(' ').pop()); if (!m) { return { opponent: null, opposing_pitcher: null, batting_order: battingOrder, team: lu.team, source: null, refused: 'team_not_in_schedule', }; } return { opponent: m.opponent || null, opposing_pitcher: m.opposing_pitcher || null, batting_order: battingOrder, team: lu.team, source: 'lineup_context+schedule', refused: m.opposing_pitcher ? null : 'no_probable_pitcher', }; } resolve.stats = stats; return resolve; } module.exports = { build, teamForms, latestBy };