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:
Kev
2026-08-11 22:49:56 -04:00
parent 387ae4d54e
commit f61ec6b391
49 changed files with 4874 additions and 308 deletions
+144
View File
@@ -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 };