Read integrity, as-of context, and the shadow matchup resolve (A1-A7)
Seven orders of measurement-first repair. The served grade does not move. A0/A1 — the unordered page walk returned the right COUNT and the wrong ROWS: 410-617 of 2,490 duplicated with an equal number never returned, while rows.length matched the server exactly. safePaginate orders on a real unique key, verifies the tuple at runtime, and THROWS on a query error instead of treating it as end-of-data. Both hits PROVES are withdrawn: they were drawn through that reader, and defense_by_direction's distinct-n was likely below the gate floor all along. A2/A2b — rolled across every reader: 11 FAIL -> 0. Composite keys pulled from pg_index (the context tables are dated-composite and had no single unique column). The unordered helper is deleted, not parked. A3 — ledgerService and retentionService defaulted the SAME env var to DIFFERENT versions, so no ledger row ever carried the marker eligibility requires. One source now. model_snapshots settlement moved onto the cron: 15,484 -> 28,894 settled, repaired-champion 0 -> 7,556. A4 — hitsFactorContext takes an as-of cutoff. Refusal over reconstruction: no row at-or-before the date means the factor does not apply, never the nearest row. Live path unchanged, proven 400/400 on real rows. A5 — factor_inputs freezes what the factor READ, never the multiplier, so an audit can recompute and check. It also recorded the finding: the three hits factors have NEVER fired. prop.opponent and prop.opposing_pitcher are read by the resolver and written by nothing. A6/A7 — matchupKeys resolves those keys from the posted lineup plus the schedule's probable pitchers, and fires the factors into a SHADOW freeze: 248 fires on 308 props, 245 of which would move the grade. The served forecast is untouched. specs/a8-shadow-factor-gate.md pre-registers the test that decides whether they ever go live. Nothing is turned on. CALIBRATION_DEPLOYED stays []. Both verdicts stay withdrawn. 4,772 tests / 371 suites green, web build exit 0, read-integrity harness 34/34. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
'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([
|
||||
paginate(() => sb.from('lineup_context')
|
||||
.select('as_of_date, game_date, sport, game_pk, team, side, player_key')
|
||||
.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, source: null, refused: 'no_lineup_row' };
|
||||
if (!key) return { ...empty, refused: 'no_player' };
|
||||
const lu = teamByPlayer.get(key);
|
||||
if (!lu || !lu.team) return empty;
|
||||
const m = matchup.get(lu.team) || matchup.get(String(lu.team).split(' ').pop());
|
||||
if (!m) return { opponent: null, opposing_pitcher: null, source: null, refused: 'team_not_in_schedule' };
|
||||
return {
|
||||
opponent: m.opponent || null,
|
||||
opposing_pitcher: m.opposing_pitcher || null,
|
||||
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 };
|
||||
Reference in New Issue
Block a user