Files
vyndr/src/services/model/matchupKeys.js
T
builtbykev 6c34af3414 checkpoint: chain shadow, WNBA possession feed, baseball chain
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
2026-08-14 16:53:37 -04:00

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 };