6c34af3414
Backup commit of uncommitted working-tree state found during Legion recon (Tony resurrection, STEP 0). This work existed only on the laptop disk. - chain shadow accrual + probe script (038_chain_shadow.sql) - WNBA possession feed: ESPN adapter, usage service, verify script (039_wnba_player_game.sql) - baseball chain - retention/snapshot service updates, tableKeys, matchupKeys - specs: chain-v1, wnba-possession-feed, wnba-source-survey - unit tests for the above Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QnvJAkC3h5QGmb6dipoiWn
161 lines
6.7 KiB
JavaScript
161 lines
6.7 KiB
JavaScript
'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 };
|