Files
vyndr/scripts/prove-park-weather.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

141 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* prove-park-weather — DOES PARK GEOMETRY AND AIR READ TOTAL BASES?
*
* Run through the two-part gate like every other factor, with one addition that
* changes the answer: the rows are CLUSTERED BY GAME. Park and weather assign a
* single value to every hitter in a ballpark on a night, so eighteen prop rows
* from one game are one reading of that game's conditions. Resampling rows would
* treat them as eighteen and hand back an interval far tighter than the evidence
* supports — which is how a gate passes a factor on sample it never had.
*
* SUPABASE_URL=... node scripts/prove-park-weather.js
*/
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const pw = require('../src/services/model/parkWeather');
const fg = require('../src/services/model/factorGate');
const tl = require('../src/services/model/testLedger');
const { knownNumber } = require('../src/utils/known');
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;
// ── 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, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason',
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'total_bases')
.in('outcome', ['hit', 'miss'])),
};
/** Hit-type shares → expected total bases, so a reshape has a consequence. */
const tbFromShares = (s) => s.single + 2 * s.double + 3 * s.triple + 4 * s.home_run;
async function main() {
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
const parks = await pageSafe(sb, 'park_dimensions', '*', (q) => q.eq('sport', 'mlb'));
const byVenue = new Map();
for (const p of parks) if (!byVenue.has(p.venue_id)) byVenue.set(p.venue_id, p);
const league = pw.leagueGeometry([...byVenue.values()]);
const ctx = await pageSafe(sb, 'game_context', 'game_id, venue_id, wx_temp_f, wx_wind_speed_mph, wx_wind_direction_deg', (q) => q);
const ctxBy = new Map(ctx.map((c) => [c.game_id, c]));
const led = await pageSafe(sb, 'ledger_entries',
'id, game_id, game_date, player_key, stat, line, side, outcome, quarantine_reason, p_win, proj_hits_p_over',
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'total_bases').in('outcome', ['hit', 'miss']));
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
// League-typical hit-type shares — the shape the atom reshapes.
const BASE_SHARES = { single: 0.655, double: 0.195, triple: 0.017, home_run: 0.133 };
const baseTb = tbFromShares(BASE_SHARES);
const loss = { no_context: 0, no_venue: 0, no_park: 0, no_baseline: 0, kept: 0 };
const rows = [];
for (const r of clean) {
const c = ctxBy.get(r.game_id);
if (!c) { loss.no_context += 1; continue; }
if (c.venue_id == null) { loss.no_venue += 1; continue; }
const dims = byVenue.get(c.venue_id);
if (!dims) { loss.no_park += 1; continue; }
const baseline = knownNumber(r.p_win);
if (baseline === null) { loss.no_baseline += 1; continue; }
const read = pw.parkWeatherRead({ dims, wx: c, league });
if (!read) { loss.no_park += 1; continue; }
// The atom reshapes hit type; the consequence for total bases is the ratio
// of expected bases per hit under the reshaped shape.
const shaped = pw.applyToShares(BASE_SHARES, read);
const ratio = tbFromShares(shaped) / baseTb;
const conditioned = Math.max(0.01, Math.min(0.99, baseline * ratio));
rows.push({
cluster: r.game_id, // ONE reading per game — the whole point
baseline,
conditioned,
won: r.outcome === 'hit' ? 1 : 0,
});
loss.kept += 1;
}
// Cumulative Bonferroni across the programme lifetime — this hypothesis is
// one more test, and the bar rises for it like every other.
const mc = await tl.recordAndCount(tl.supabaseStore(sb), [
{ sport: 'mlb', stat: 'total_bases', archetype: null, interaction: 'factor:park_weather_hit_type', target: 'outcome' },
]);
const verdict = fg.adjudicate(rows, {
factor: 'park_weather_hit_type',
stat: 'total_bases',
cumulativeTests: mc.cumulative_tests,
});
const games = new Set(rows.map((r) => r.cluster)).size;
const venues = new Set(clean.map((r) => ctxBy.get(r.game_id)?.venue_id).filter((v) => v != null)).size;
console.log(JSON.stringify({
clean_settled_tb_rows: clean.length,
rows_built: rows.length,
row_loss: loss,
distinct_games: games,
distinct_venues: venues,
rows_per_game: games ? Math.round((rows.length / games) * 10) / 10 : null,
cumulative_tests: mc.cumulative_tests,
verdict,
honest_note: 'sample judged in GAMES, not prop rows — park and weather vary per game',
}, 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 };