f61ec6b391
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>
189 lines
8.2 KiB
JavaScript
189 lines
8.2 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* reconstruct-game-environment — GIVE THE PAST GAMES THEIR REAL CONDITIONS.
|
|
*
|
|
* `game_context` has never held a single weather reading. The reason is not the
|
|
* fetcher, which is correct and points at Open-Meteo's ARCHIVE endpoint; it is
|
|
* that nothing ever joined. The ledger keys a game as
|
|
* `mlb:2026-08-03:WashingtonNationals@PhiladelphiaPhillies` and game_context
|
|
* keys it as `mlb:823437`, so every lookup missed and the columns stayed NULL —
|
|
* which reads exactly like "the weather was unavailable" rather than "the two
|
|
* tables have never been introduced." The same class of failure as the doubled
|
|
* /leaderboard path: graceful degradation wearing the mask of honest absence.
|
|
*
|
|
* This walks the dates in the settled ledger, resolves each slug to the real
|
|
* statsapi game and venue, and writes a game_context row keyed by the LEDGER's
|
|
* slug so the join exists. Then it pulls the actual archived weather for that
|
|
* date and location.
|
|
*
|
|
* ARCHIVE, NOT FORECAST — asking the forecast endpoint about a past date returns
|
|
* a re-forecast, which is a model's opinion about the past, not the past. Absent
|
|
* stays NULL; nothing here is imputed.
|
|
*
|
|
* SUPABASE_URL=... node scripts/reconstruct-game-environment.js
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const axios = require('axios');
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const { paginate } = require('../src/utils/safePaginate');
|
|
const { uniqueKeyFor } = require('../src/utils/tableKeys');
|
|
|
|
const SB_URL = process.env.SUPABASE_URL;
|
|
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
|
const PAGE = 1000;
|
|
const SCHEDULE = (d) => `https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${d}&hydrate=venue(location)`;
|
|
const ARCHIVE = (lat, lon, date) =>
|
|
`https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lon}`
|
|
+ `&start_date=${date}&end_date=${date}`
|
|
+ '&hourly=temperature_2m,wind_speed_10m,wind_direction_10m,precipitation'
|
|
+ '&temperature_unit=fahrenheit&wind_speed_unit=mph';
|
|
|
|
const squash = (s) => String(s || '').toLowerCase().replace(/[^a-z]/g, '');
|
|
|
|
// ── THE SAFE WALK (Fix A2/A2b) ────────────────────────────────────────────
|
|
// This script used to walk pages with `.range()` and NO ORDER BY. Measured on
|
|
// production, that returned the correct row COUNT and the wrong ROWS: up to
|
|
// 33.6% of a read came back twice while an equal share never came back at all,
|
|
// so `rows.length` looked perfect while a fifth of the sample was missing.
|
|
//
|
|
// `pageSafe` routes every read through `src/utils/safePaginate`: a stable ORDER
|
|
// BY on the table's real UNIQUE key — single OR composite, looked up from
|
|
// `src/utils/tableKeys` rather than assumed — a runtime tuple-uniqueness check,
|
|
// and a THROWN error instead of a silent stop. The old unordered helper is gone
|
|
// rather than left beside it, because a dead broken helper is an invitation.
|
|
async function pageSafe(sb, table, select, apply, key = uniqueKeyFor(table)) {
|
|
return paginate(() => apply(sb.from(table).select(select)),
|
|
{ key, pageSize: PAGE, label: `${table}` });
|
|
}
|
|
|
|
/** THE MEASURED READS — main() and the harness call the same functions. */
|
|
const READS = {
|
|
ledger: (sb) => pageSafe(sb, 'ledger_entries', 'id, game_id, game_date, stat, outcome, quarantine_reason',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null).in('stat', ['hits', 'total_bases'])
|
|
.in('outcome', ['hit', 'miss'])),
|
|
};
|
|
|
|
const get = async (url) => (await axios.get(url, { timeout: 60_000 })).data;
|
|
|
|
async function main() {
|
|
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
|
|
|
const led = await pageSafe(sb, 'ledger_entries', 'id, game_id, game_date, stat, outcome, quarantine_reason',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null).in('stat', ['hits', 'total_bases'])
|
|
.in('outcome', ['hit', 'miss']));
|
|
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
|
|
|
// Distinct games, as the LEDGER names them.
|
|
const games = new Map();
|
|
for (const r of clean) if (r.game_id && !games.has(r.game_id)) games.set(r.game_id, r.game_date);
|
|
const dates = [...new Set([...games.values()])].sort();
|
|
console.error(`[env] ${games.size} distinct settled games across ${dates.length} dates`);
|
|
|
|
// date -> statsapi games, indexed by the same squashed away@home the slug uses.
|
|
const resolved = new Map();
|
|
const venues = new Map();
|
|
for (const d of dates) {
|
|
let sched = null;
|
|
try { sched = await get(SCHEDULE(d)); } catch { sched = null; }
|
|
for (const day of (sched && sched.dates) || []) {
|
|
for (const g of day.games || []) {
|
|
const away = squash(g.teams?.away?.team?.name);
|
|
const home = squash(g.teams?.home?.team?.name);
|
|
resolved.set(`${d}|${away}@${home}`, g);
|
|
const v = g.venue || {};
|
|
if (v.id && !venues.has(v.id)) {
|
|
const loc = v.location || {};
|
|
venues.set(v.id, {
|
|
venue_id: v.id,
|
|
venue_name: v.name || null,
|
|
lat: loc.defaultCoordinates?.latitude ?? null,
|
|
lon: loc.defaultCoordinates?.longitude ?? null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Join each ledger slug to its real game + venue.
|
|
const rows = []; let unmatched = 0;
|
|
for (const [gid, date] of games) {
|
|
const m = /^mlb:(\d{4}-\d{2}-\d{2}):(.+?)@(.+)$/.exec(gid);
|
|
if (!m) { unmatched += 1; continue; }
|
|
const g = resolved.get(`${m[1]}|${squash(m[2])}@${squash(m[3])}`);
|
|
if (!g) { unmatched += 1; continue; }
|
|
rows.push({
|
|
game_id: gid, // the LEDGER's key — this is the whole fix
|
|
game_date: date,
|
|
venue_id: g.venue?.id ?? null,
|
|
source_game_pk: g.gamePk ?? null,
|
|
});
|
|
}
|
|
console.error(`[env] matched ${rows.length}, unmatched ${unmatched}, venues seen ${venues.size}`);
|
|
|
|
for (let i = 0; i < rows.length; i += 200) {
|
|
const { error } = await sb.from('game_context')
|
|
.upsert(rows.slice(i, i + 200), { onConflict: 'game_id' });
|
|
if (error) console.error('[env] context write failed:', error.message);
|
|
}
|
|
|
|
// ACTUAL archived weather, one call per (venue, date) that we need.
|
|
const need = new Map();
|
|
for (const r of rows) {
|
|
const v = venues.get(r.venue_id);
|
|
if (!v || v.lat == null || v.lon == null) continue;
|
|
need.set(`${r.venue_id}|${r.game_date}`, { v, date: r.game_date });
|
|
}
|
|
console.error(`[env] fetching ${need.size} venue-days of archived weather`);
|
|
|
|
const wx = new Map();
|
|
for (const [k, { v, date }] of need) {
|
|
try {
|
|
const p = await get(ARCHIVE(v.lat, v.lon, date));
|
|
const h = p && p.hourly;
|
|
if (h && Array.isArray(h.time) && h.time.length) {
|
|
const i = Math.min(h.time.length - 1, 19); // ~7pm local, typical first pitch
|
|
wx.set(k, {
|
|
wx_temp_f: h.temperature_2m?.[i] ?? null,
|
|
wx_wind_speed_mph: h.wind_speed_10m?.[i] ?? null,
|
|
wx_wind_direction_deg: h.wind_direction_10m?.[i] ?? null,
|
|
wx_precip_mm: h.precipitation?.[i] ?? null,
|
|
wx_source: 'open_meteo_archive',
|
|
});
|
|
}
|
|
} catch { /* absent stays absent */ }
|
|
}
|
|
|
|
let withWx = 0;
|
|
for (let i = 0; i < rows.length; i += 200) {
|
|
const batch = rows.slice(i, i + 200).map((r) => {
|
|
const w = wx.get(`${r.venue_id}|${r.game_date}`);
|
|
if (w) withWx += 1;
|
|
return w ? { ...r, ...w } : r;
|
|
});
|
|
const { error } = await sb.from('game_context').upsert(batch, { onConflict: 'game_id' });
|
|
if (error) console.error('[env] weather write failed:', error.message);
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
settled_games: games.size,
|
|
matched_to_statsapi: rows.length,
|
|
unmatched,
|
|
distinct_venues: venues.size,
|
|
venue_days_requested: need.size,
|
|
venue_days_returned: wx.size,
|
|
game_rows_with_actual_weather: withWx,
|
|
source: 'open_meteo_archive (actual, not re-forecast)',
|
|
}, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|
|
}
|
|
|
|
// Exported so the read-integrity harness measures THE REAL FUNCTION.
|
|
module.exports = { READS: (typeof READS !== 'undefined' ? READS : undefined), READS_LEDGER: (typeof READS_LEDGER !== 'undefined' ? READS_LEDGER : undefined) };
|