Files
vyndr/src/services/snapshotSettlementService.js
T
builtbykev f61ec6b391 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>
2026-08-11 22:49:56 -04:00

312 lines
13 KiB
JavaScript

'use strict';
/**
* snapshotSettlementService — settle `model_snapshots` ON A SCHEDULE.
*
* Spec: specs/read-integrity-harness.md §9
*
* ── WHY THIS EXISTS ──────────────────────────────────────────────────────
* `model_snapshots` is the retention table built for replay — it holds the
* model's INPUTS (the frozen feature vector) alongside its prediction, which
* `ledger_entries` does not. It was settled exactly once, by hand, via
* `scripts/settle-model-snapshots.js`. Nothing ever settled it on a cron.
*
* Measured 2026-08-09: 104,834 rows were past-dated and settleable; 89,350 of
* them had no outcome. Worse, of the 34,128 rows carrying the repaired
* champion's marker — the only rows a forward re-audit may be measured on —
* ZERO were settled. The accrual clock could never advance.
*
* ── OUTCOMES ONLY. NO CONTEXT RECONSTRUCTION. ────────────────────────────
* This joins each row to the realized stat from a box score, using the row's OWN
* keys (game_date + player_key + stat + line + side). It does not fetch, infer,
* or rebuild park, weather, defence, platoon, archetype or any other context.
* A settle pass that reconstructed context would be re-deciding what the model
* saw, which is precisely what the retention table exists to prevent.
*
* ── OUTCOME IS SIDE-ALIGNED ──────────────────────────────────────────────
* `p_win` is expressed for the GRADED SIDE, so `outcome` must be too. A raw
* `realized > line` indicator is the OVER perspective and would silently invert
* the target on every under row, making calibration measure the wrong thing.
* `actual_value` stores the raw realized stat; `outcome` stores whether the
* graded side won.
*
* ── A ROW LOGGED AFTER FIRST PITCH IS NOT A PREDICTION ───────────────────
* The pipeline runs on UTC cron hours, so a 01:00-UTC cycle is 21:00 the
* previous evening ET — same game date, three hours into the slate. Those rows
* are refused rather than settled. Preserved verbatim from the hand-run script,
* because dropping it would quietly admit post-hoc rows into every measurement.
*
* Every dependency is injectable, so the unit tests never touch a network.
*/
const { paginate } = require('../utils/safePaginate');
const { uniqueKeyFor } = require('../utils/tableKeys');
const { nameKey } = require('../utils/playerName');
const { knownNumber } = require('../utils/known');
const STATS = ['hits', 'total_bases', 'rbi', 'runs'];
const PAGE = 1000;
/** Eastern first pitch, conservatively. At or after this is in-game. */
const FIRST_PITCH_ET_HOUR = 19;
/**
* Dates fetched per run — bounded, because a cron that re-fetches 26 dates of
* box scores every five hours is a quota problem pretending to be thoroughness.
*
* NEWEST FIRST, and that ordering is a correctness property, not a preference.
* The first implementation drained oldest-first and DEADLOCKED: measured on
* production, 308 rows across the eight oldest dates are structurally
* unsettleable (184 logged after first pitch, 124 with no box-score line), so
* the window re-processed the same dead dates on every run and `dates_remaining`
* never moved. Newest-first also reaches the rows that matter — the repaired
* champion's, which are the only ones a forward re-audit may be measured on.
*/
const MAX_DATES_PER_RUN = Number(process.env.SNAPSHOT_SETTLE_MAX_DATES || 8);
/** Realized value per stat, from the box-score batting line. */
const FIELD = Object.freeze({
hits: (b) => knownNumber(b.hits),
total_bases: (b) => knownNumber(b.totalBases),
rbi: (b) => knownNumber(b.rbi),
runs: (b) => knownNumber(b.runs),
});
function isPreGame(capturedAt, gameDate) {
if (!capturedAt || !gameDate) return false;
const cap = new Date(capturedAt);
if (Number.isNaN(cap.getTime())) return false;
const et = new Date(cap.getTime() - 4 * 3600 * 1000); // EDT
const etDate = et.toISOString().slice(0, 10);
if (etDate < String(gameDate)) return true; // day before, fine
if (etDate > String(gameDate)) return false; // day after, post-game
return et.getUTCHours() < FIRST_PITCH_ET_HOUR;
}
function todayEt(now) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(now || new Date());
}
async function pool(items, fn, n = 6) {
const out = []; let i = 0;
await Promise.all(Array.from({ length: n }, async () => {
while (i < items.length) {
const idx = i; i += 1;
try { out[idx] = await fn(items[idx]); } catch { out[idx] = null; }
}
}));
return out.filter(Boolean);
}
/**
* Box-score batting lines for a set of dates, keyed `${date}|${nameKey}`.
* A doubleheader gives two lines; they are SUMMED, because the prop covers the
* day rather than a game.
*/
async function battingLines(dates, deps) {
const getJson = deps.getJson;
const games = [];
for (const d of dates) {
try {
const s = await getJson(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}`);
for (const day of (s && s.dates) || []) {
for (const g of day.games || []) {
if (String(g.status && g.status.detailedState) === 'Final') {
games.push({ pk: g.gamePk, date: g.officialDate || d });
}
}
}
} catch { /* absent day — stays absent */ }
}
const lines = {};
const loaded = await pool(games, async (g) => {
const box = await getJson(`https://statsapi.mlb.com/api/v1/game/${g.pk}/boxscore`);
const out = [];
for (const side of ['home', 'away']) {
const t = box && box.teams && box.teams[side];
if (!t) continue;
for (const id of t.batters || []) {
const pl = t.players[`ID${id}`];
const b = pl && pl.stats && pl.stats.batting;
if (!b || b.atBats == null) continue; // did not bat → absent, never zero
out.push({
date: g.date,
key: nameKey(pl.person && pl.person.fullName),
hits: b.hits, totalBases: b.totalBases, rbi: b.rbi, runs: b.runs,
});
}
}
return out;
}, deps.concurrency || 6);
for (const arr of loaded) {
for (const r of arr) {
const k = `${r.date}|${r.key}`;
if (!lines[k]) lines[k] = { ...r };
else {
lines[k].hits += r.hits; lines[k].totalBases += r.totalBases;
lines[k].rbi += r.rbi; lines[k].runs += r.runs;
}
}
}
return lines;
}
/**
* PURE — decide each row's outcome from the box-score index.
* Separated so the whole decision rule is unit-testable with no I/O.
*/
function decide(snaps, lines) {
const counts = { candidates: snaps.length, settled: 0, unresolvable: 0, orphaned: 0, post_hoc_logged: 0 };
const updates = [];
const seen = new Set();
for (const s of snaps) {
if (seen.has(s.id)) {
const e = new Error(`INTEGRITY: duplicate snapshot id ${s.id}`);
e.code = 'DUPLICATE_ROW';
throw e;
}
seen.add(s.id);
if (!isPreGame(s.captured_at, s.game_date)) {
counts.post_hoc_logged += 1; counts.unresolvable += 1; continue;
}
const line = knownNumber(s.line);
if (line === null || !s.side) { counts.unresolvable += 1; continue; }
const b = lines[`${s.game_date}|${s.player_key}`];
if (!b) { counts.orphaned += 1; continue; }
const realized = FIELD[s.stat] ? FIELD[s.stat](b) : null;
if (realized === null) { counts.unresolvable += 1; continue; }
const over = realized > line;
const won = String(s.side).toLowerCase() === 'under' ? !over : over;
updates.push({ id: s.id, outcome: won ? 'hit' : 'miss', actual_value: realized });
counts.settled += 1;
}
// CONSERVATION — hard fail. Every candidate lands in exactly one bucket.
const acc = counts.settled + counts.unresolvable + counts.orphaned;
if (acc !== counts.candidates) {
const e = new Error(`INTEGRITY: conservation violated ${acc} != ${counts.candidates}`);
e.code = 'CONSERVATION';
throw e;
}
return { counts, updates };
}
/**
* Settle one pass.
*
* @param {object} deps
* - sb supabase service client (required to do anything)
* - getJson async (url) => json
* - now () => Date
* - write default true; false = dry run
* - maxDates dates fetched this run (oldest first)
* @returns {object} { skipped?, counts, dates, written, repaired_champion_settled }
*/
async function settleSnapshots(deps = {}) {
const sb = deps.sb;
if (!sb) return { skipped: 'supabase not configured', counts: null, written: 0 };
const now = (deps.now || (() => new Date()))();
const cutoff = todayEt(now);
const write = deps.write !== false;
// Unsettled, PAST-DATED rows only — a game that has not finished cannot be
// settled, and asking would produce an orphan rather than an absence.
const snaps = await paginate(
() => sb.from('model_snapshots')
.select('id, game_date, captured_at, stat, player_key, line, side, model_version')
.eq('sport', 'mlb').in('stat', STATS).is('outcome', null)
.lt('game_date', cutoff),
{ key: uniqueKeyFor('model_snapshots'), pageSize: PAGE, label: 'snapshotSettlement' },
);
if (!snaps.length) return { counts: { candidates: 0, settled: 0, unresolvable: 0, orphaned: 0, post_hoc_logged: 0 }, dates: [], written: 0, repaired_champion_settled: 0 };
// CHOOSE DATES FROM ROWS THAT COULD ACTUALLY SETTLE.
//
// `isPreGame` is pure, so a row logged after first pitch is known-unsettleable
// WITHOUT fetching anything. Measured on production, 19,074 such rows sit in
// the recent dates; letting them pick the window meant the same dead dates
// were re-fetched on every run and the backlog never converged. Filtering
// first is what makes the drain terminate.
const settleable = snaps.filter((r) => isPreGame(r.captured_at, r.game_date));
// NEWEST FIRST: the eligible rows — the repaired champion's — are the newest,
// and an older date whose remainder cannot settle must not block them.
const allDates = [...new Set(settleable.map((r) => r.game_date))].sort().reverse();
const perWindow = deps.maxDates || MAX_DATES_PER_RUN;
const maxWindows = deps.maxWindows || Number(process.env.SNAPSHOT_SETTLE_MAX_WINDOWS || 4);
// ADVANCE PAST A DRAINED WINDOW. The newest dates keep rows that can never
// settle (a player with no box-score line never gets one), so a fixed window
// would sit on them forever while older settleable dates were never reached.
// Move to the next window when this one produces nothing, bounded so a run
// still costs a predictable number of box-score fetches.
let dates = []; let counts = null; let updates = []; let windows = 0;
for (let w = 0; w < maxWindows; w += 1) {
const slice = allDates.slice(w * perWindow, (w + 1) * perWindow);
if (!slice.length) break;
windows = w + 1;
/* eslint-disable no-await-in-loop */
const lines = await battingLines(slice, { getJson: deps.getJson, concurrency: deps.concurrency });
const res = decide(snaps.filter((r) => slice.includes(r.game_date)), lines);
/* eslint-enable no-await-in-loop */
dates = slice; counts = res.counts; updates = res.updates;
if (updates.length) break; // progress — stop here
}
if (!counts) return { counts: { candidates: 0, settled: 0, unresolvable: 0, orphaned: 0, post_hoc_logged: 0 }, dates: [], written: 0, repaired_champion_settled: 0 };
const inWindow = snaps.filter((r) => dates.includes(r.game_date));
let written = 0;
let repairedSettled = 0;
const byId = new Map(inWindow.map((r) => [r.id, r]));
if (write && updates.length) {
const settledAt = now.toISOString();
for (let i = 0; i < updates.length; i += 500) {
const batch = updates.slice(i, i + 500);
/* eslint-disable no-await-in-loop */
const results = await Promise.all(batch.map((u) => sb.from('model_snapshots')
.update({
outcome: u.outcome,
actual_value: u.actual_value,
settled_at: settledAt,
settlement_source: 'statsapi_boxscore',
})
// IDEMPOTENT: only an unsettled row is written, so a re-run can never
// overwrite an outcome that is already on the record.
.eq('id', u.id).is('outcome', null)));
/* eslint-enable no-await-in-loop */
results.forEach((r, k) => {
if (r.error) return;
written += 1;
const row = byId.get(batch[k].id);
if (row && row.model_version === require('../config/modelVersion').REPAIRED_CHAMPION_VERSION) {
repairedSettled += 1;
}
});
}
}
return {
counts,
dates,
dates_remaining: Math.max(0, allDates.length - (windows * perWindow)),
windows_scanned: windows,
// Rows that can never settle, counted rather than hidden: a row logged after
// first pitch is not a prediction and will never become one.
permanently_unsettleable: snaps.length - settleable.length,
written,
repaired_champion_settled: repairedSettled,
mode: write ? 'write' : 'dry-run',
};
}
module.exports = {
settleSnapshots, decide, isPreGame, battingLines,
STATS, FIELD, MAX_DATES_PER_RUN, FIRST_PITCH_ET_HOUR,
};