'use strict'; /** * hitsFactorContext — load the proven factors' inputs ONCE per slate. * * The three hits factors each need a database read (batter spray, team * positional defence, platoon splits, pitcher contact profile). Doing that per * prop would put four queries inside a loop that runs across the whole board, so * the tables are loaded once and indexed, and the per-prop lookup is a map hit. * * Loaded BEFORE grading, which is the whole point of this order — the same data * was previously fetched after the grade it should have informed. * * Every load is best-effort: a missing table yields an empty index, the factor * finds nothing readable, and the forecast is served unadjusted. A factor layer * must never be able to break the pipeline it rides in. */ const { knownNumber } = require('../../utils/known'); const { nameKey } = require('../../utils/playerName'); /** Rows a factor table must have before we trust it at all. */ const MIN_ROWS = 1; /** * Page a factor table. * * ORDERING IS PER-TABLE. These tables have COMPOSITE primary keys * (as_of_date, sport, player_key) and NO `id` column, so ordering by `id` * errors — and an error here returns an empty index, which reads exactly like * "this feed has no data". That is the third time in this codebase that a * wiring fault has worn the costume of an honest absence, so the error is now * surfaced rather than swallowed. */ async function page(sb, table, select, orderBy, apply) { const out = []; for (let from = 0; ; from += 1000) { const q = apply ? apply(sb.from(table).select(select)) : sb.from(table).select(select); const { data, error } = await q.order(orderBy, { ascending: true }).range(from, from + 999); if (error) throw new Error(`${table}: ${error.message}`); if (!data || data.length === 0) break; out.push(...data); if (data.length < 1000) break; } return out; } /** Keep the most recent dated row per key. */ 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; } /** * @returns {function|null} a `(prop, sport) => context` resolver, or null when * nothing loaded — null means "serve unadjusted", never a stub context. */ async function build(sb, opts = {}) { if (!sb) return null; let spray; let defense; let platoon; let statcast; try { [spray, defense, platoon, statcast] = await Promise.all([ page(sb, 'batter_spray', '*', 'player_key', (q) => q.eq('sport', 'mlb')), page(sb, 'team_defense', '*', 'team', (q) => q.eq('sport', 'mlb')), page(sb, 'platoon_splits', '*', 'player_key', (q) => q.eq('sport', 'mlb')), page(sb, 'statcast_aggregates', 'player_key, source_id, role, bats, throws, hard_hit_pct', 'player_key', (q) => q.eq('sport', 'mlb')), ]); } catch (e) { // Surfaced, not silent: a load failure must be distinguishable from a feed // that genuinely holds nothing. console.warn('[factors] context load FAILED (not an empty feed):', e.message); return null; } if (!spray.length && !defense.length && !platoon.length) return null; const sprayBy = latestBy(spray, (r) => r.player_key, (r) => r.as_of_date); const defBy = latestBy(defense, (r) => r.team, (r) => r.as_of_date); const platBy = latestBy(platoon, (r) => r.player_key, (r) => r.as_of_date); const batBy = new Map(); const pitBy = new Map(); for (const r of statcast) { if (!r.player_key) continue; if (r.role === 'pitcher') pitBy.set(r.player_key, r); else batBy.set(r.player_key, r); } // statcast stores PERCENTAGES (0-100); the factor wants a fraction. const asFraction = (v) => { const n = knownNumber(v); if (n === null) return null; return n > 1 ? n / 100 : n; }; const resolver = (prop) => { const key = nameKey(prop && prop.player); if (!key) return null; const bat = batBy.get(key); const bats = bat && bat.bats ? String(bat.bats)[0] : null; // The opposing team and its starter, from whatever the prop carries. const oppName = prop && (prop.opponent || prop.opp_team || null); const def = oppName ? (defBy.get(oppName) || defBy.get(String(oppName).split(' ').pop())) : null; const pitKey = prop && prop.opposing_pitcher ? nameKey(prop.opposing_pitcher) : null; const pit = pitKey ? pitBy.get(pitKey) : null; const sp = platBy.get(key); const splits = sp ? { vl: { pa: sp.vl_pa, atBats: sp.vl_ab, hits: sp.vl_hits }, vr: { pa: sp.vr_pa, atBats: sp.vr_ab, hits: sp.vr_hits }, } : null; const ctx = { spray: sprayBy.get(key) || null, positionOaa: def && def.position_oaa ? def.position_oaa : null, bats, throws: pit && pit.throws ? String(pit.throws)[0] : null, pitcherHardHit: pit ? asFraction(pit.hard_hit_pct) : null, platoonSplits: splits, }; // Nothing readable at all -> null, so the engine skips the factor block // entirely rather than walking an empty context. const anything = ctx.spray || ctx.pitcherHardHit !== null || ctx.platoonSplits; return anything ? ctx : null; }; resolver.__stats = { spray_players: sprayBy.size, defense_teams: defBy.size, platoon_players: platBy.size, pitcher_profiles: pitBy.size, batter_profiles: batBy.size, }; return resolver; } module.exports = { build, MIN_ROWS };