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>
298 lines
17 KiB
JavaScript
298 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* read-integrity — measure whether each learning reader returns the rows it thinks.
|
|
*
|
|
* Spec: specs/read-integrity-harness.md
|
|
*
|
|
* READ-ONLY. It issues SELECTs and a head-count. It writes nothing, anywhere —
|
|
* a test asserts this file contains no insert/update/upsert/delete/rpc.
|
|
*
|
|
* USAGE
|
|
* node scripts/read-integrity.js # every registered reader
|
|
* node scripts/read-integrity.js --id prove-hit-factors:136
|
|
* node scripts/read-integrity.js --acceptance # AC5 only: known-corrupt + known-clean
|
|
*
|
|
* WHAT IT IS FOR: the 2026-08-09 probe proved exposure cannot be reasoned from
|
|
* source — `challenger-scoreboard` walks 12 pages of a live table and is clean,
|
|
* `calibrationService.fromLedger` walks 3 pages of the same table and is a
|
|
* quarter corrupt. So every reader fix must be verified by MEASUREMENT, on the
|
|
* day, against an ordered control. Never by the presence of an ORDER BY clause.
|
|
*/
|
|
|
|
const path = require('path');
|
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const ri = require('../src/utils/readIntegrity');
|
|
const { uniqueKeyFor } = require('../src/utils/tableKeys');
|
|
|
|
// The local .env carries a transposed project ref (CLAUDE.md, S76). Allow an
|
|
// explicit override so a local run reaches the real project.
|
|
const SB_URL = process.env.READ_INTEGRITY_SUPABASE_URL || process.env.SUPABASE_URL;
|
|
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
|
|
|
const MLB = ['eq', 'sport', 'mlb'];
|
|
const PUBLIC = ['is', 'user_id', null];
|
|
const SETTLED = ['in', 'outcome', ['hit', 'miss']];
|
|
const HAS_PWIN = ['not', 'p_win', 'is', null];
|
|
const HAS_ARCH = ['not', 'archetype', 'is', null];
|
|
|
|
const LEDGER_KEY = ['id'];
|
|
// model_snapshots rows are one per prop PER CYCLE, so identity needs the cycle.
|
|
const SNAP_KEY = ['id'];
|
|
|
|
/**
|
|
* THE REGISTRY — every reader that walks pages without a stable order, as it
|
|
* stands today. Declarative so it is auditable against the cited source line.
|
|
*/
|
|
const READERS = [
|
|
// ── the hits gate ──────────────────────────────────────────────────────
|
|
{ id: 'prove-hit-factors:136', source: 'scripts/prove-hit-factors.js:136', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/prove-hit-factors').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'hits'], SETTLED, HAS_PWIN] },
|
|
{ id: 'prove-hit-factors:131', source: 'scripts/prove-hit-factors.js:131', table: 'model_snapshots',
|
|
select: 'id', key: SNAP_KEY,
|
|
readerRows: (sb) => require('../scripts/prove-hit-factors').READS.snaps(sb),
|
|
filters: [MLB, ['eq', 'stat', 'hits'], HAS_ARCH] },
|
|
|
|
// ── the other stat gates ───────────────────────────────────────────────
|
|
{ id: 'prove-tb-factors:154', source: 'scripts/prove-tb-factors.js:154', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/prove-tb-factors').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'total_bases'], SETTLED, HAS_PWIN] },
|
|
{ id: 'prove-tb-factors:149', source: 'scripts/prove-tb-factors.js:149', table: 'model_snapshots',
|
|
select: 'id', key: SNAP_KEY,
|
|
readerRows: (sb) => require('../scripts/prove-tb-factors').READS.snaps(sb),
|
|
filters: [MLB, ['eq', 'stat', 'total_bases'], HAS_ARCH] },
|
|
{ id: 'prove-runs-rbi:200(rbi)', source: 'scripts/prove-runs-rbi.js:200', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'rbi'], SETTLED] },
|
|
{ id: 'prove-runs-rbi:200(runs)', source: 'scripts/prove-runs-rbi.js:200', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'runs'], SETTLED] },
|
|
{ id: 'prove-park-weather:54', source: 'scripts/prove-park-weather.js:54', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/prove-park-weather').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'total_bases'], SETTLED] },
|
|
{ id: 'pitcher-prove-k:137', source: 'scripts/pitcher-prove-k.js:137', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/pitcher-prove-k').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'strikeouts'], SETTLED, HAS_PWIN] },
|
|
{ id: 'cluster-prove:337', source: 'scripts/cluster-prove.js:337', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/cluster-prove').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'total_bases'], SETTLED, HAS_PWIN] },
|
|
{ id: 'tb-solo-and-interactions:260', source: 'scripts/tb-solo-and-interactions.js:260', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/tb-solo-and-interactions').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'total_bases'], SETTLED, HAS_PWIN] },
|
|
{ id: 'stagea-gate-run:142', source: 'scripts/stagea-gate-run.js:142', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/stagea-gate-run').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC] },
|
|
{ id: 'skill-v1-stagea:185', source: 'scripts/skill-v1-stagea.js:185', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/skill-v1-stagea').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'hits'], SETTLED] },
|
|
|
|
// ── the status/ablation/board readers ──────────────────────────────────
|
|
{ id: 'proven-status:76', source: 'scripts/proven-status.js:76', table: 'model_snapshots',
|
|
select: 'id', key: SNAP_KEY,
|
|
readerRows: (sb) => require('../scripts/proven-status').READS.snaps(sb), filters: [MLB, HAS_ARCH] },
|
|
{ id: 'champion-ablation:173', source: 'scripts/champion-ablation.js:173', table: 'model_snapshots',
|
|
select: 'id', key: SNAP_KEY,
|
|
readerRows: (sb) => require('../scripts/champion-ablation').READS.snaps(sb),
|
|
filters: [MLB, HAS_PWIN, ['not', 'features', 'is', null]] },
|
|
{ id: 'build-grade-bands:54', source: 'scripts/build-grade-bands.js:54', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY,
|
|
readerRows: (sb) => require('../scripts/build-grade-bands').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'hits'], SETTLED, HAS_PWIN] },
|
|
{ id: 'build-grade-bands:49', source: 'scripts/build-grade-bands.js:49', table: 'model_snapshots',
|
|
select: 'id', key: SNAP_KEY,
|
|
readerRows: (sb) => require('../scripts/build-grade-bands').READS.snaps(sb), filters: [MLB, ['eq', 'stat', 'hits'], HAS_ARCH] },
|
|
{ id: 'challenger-scoreboard:112', source: 'scripts/challenger-scoreboard.js:112', table: 'ledger_entries',
|
|
select: 'id', key: LEDGER_KEY, filters: [MLB, PUBLIC, SETTLED, HAS_PWIN] },
|
|
|
|
// ── calibrationService: FIXED in A1, and measured through the REAL function ──
|
|
// `readerRows` calls the production `loadSettledRows`, so a PASS here means the
|
|
// shipped code returned a set identical to the control — not that its query was
|
|
// re-declared correctly in this registry.
|
|
{ id: 'calibrationService.fromLedger:110', source: 'src/services/model/calibrationService.js:110',
|
|
table: 'ledger_entries', select: 'id', key: LEDGER_KEY, live: true,
|
|
readerRows: (sb) => require('../src/services/model/calibrationService')
|
|
.loadSettledRows(sb, { sport: 'mlb', stat: 'hits' }),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'hits'], SETTLED, HAS_PWIN,
|
|
['lt', 'game_date', todayEt()]] },
|
|
|
|
// The PRIMARY calibrator (calibrationService is only its shadow). FIXED in A2.
|
|
{ id: 'lowParamService.fromLedger:78', source: 'src/services/model/lowParamService.js:78',
|
|
table: 'ledger_entries', select: 'id', key: LEDGER_KEY, live: true,
|
|
readerRows: (sb) => require('../src/services/model/lowParamService')
|
|
.loadSettledRows(sb, { sport: 'mlb', stat: 'hits' }),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'hits'], SETTLED, HAS_PWIN,
|
|
['lt', 'game_date', todayEt()]] },
|
|
|
|
// ── context tables — A2b: composite keys from the live schema ──────────
|
|
{ id: 'context:statcast_aggregates', source: 'scripts/prove-hit-factors.js:104', table: 'statcast_aggregates',
|
|
select: '*', key: uniqueKeyFor('statcast_aggregates'),
|
|
readerRows: (sb) => require('../scripts/prove-hit-factors').READS.statcast(sb), filters: [MLB] },
|
|
{ id: 'context:batter_spray', source: 'scripts/prove-hit-factors.js:111', table: 'batter_spray',
|
|
select: '*', key: uniqueKeyFor('batter_spray'),
|
|
readerRows: (sb) => require('../scripts/prove-hit-factors').READS.spray(sb), filters: [MLB] },
|
|
{ id: 'context:platoon_splits', source: 'scripts/prove-hit-factors.js:119', table: 'platoon_splits',
|
|
select: '*', key: uniqueKeyFor('platoon_splits'),
|
|
readerRows: (sb) => require('../scripts/prove-hit-factors').READS.platoon(sb), filters: [MLB] },
|
|
{ id: 'context:team_defense', source: 'scripts/prove-hit-factors.js:127', table: 'team_defense',
|
|
select: '*', key: uniqueKeyFor('team_defense'),
|
|
readerRows: (sb) => require('../scripts/prove-hit-factors').READS.defense(sb), filters: [MLB] },
|
|
{ id: 'context:park_dimensions', source: 'scripts/prove-tb-factors.js:138', table: 'park_dimensions',
|
|
select: '*', key: uniqueKeyFor('park_dimensions'),
|
|
readerRows: (sb) => require('../scripts/prove-tb-factors').READS.parks(sb), filters: [MLB] },
|
|
{ id: 'context:game_context', source: 'scripts/prove-tb-factors.js:142', table: 'game_context',
|
|
select: 'game_id, venue_id, wx_temp_f, wx_wind_speed_mph, wx_wind_direction_deg',
|
|
key: uniqueKeyFor('game_context'),
|
|
readerRows: (sb) => require('../scripts/prove-tb-factors').READS.gameCtx(sb), filters: [] },
|
|
{ id: 'context:hitter_opportunity', source: 'scripts/prove-runs-rbi.js:183', table: 'hitter_opportunity',
|
|
select: '*', key: uniqueKeyFor('hitter_opportunity'),
|
|
readerRows: (sb) => require('../scripts/prove-runs-rbi').READS.opportunity(sb), filters: [MLB] },
|
|
// lineup_context has no paginated READER today (lineupContextService writes it);
|
|
// measured as-written so its exposure is on the record rather than assumed.
|
|
{ id: 'context:lineup_context', source: '(no paginated reader — write-only today)', table: 'lineup_context',
|
|
select: 'as_of_date, sport, game_pk, player_key', key: uniqueKeyFor('lineup_context'), filters: [MLB] },
|
|
|
|
// ── A2b: the three legacy reads, now converted ────────────────────────
|
|
{ id: 'proven-status:66', source: 'scripts/proven-status.js:66', table: 'ledger_entries',
|
|
select: 'id', key: uniqueKeyFor('ledger_entries'),
|
|
readerRows: (sb) => require('../scripts/proven-status').READS_LEDGER.ledgerAll(sb),
|
|
filters: [MLB, PUBLIC] },
|
|
{ id: 'proven-status:85', source: 'scripts/proven-status.js:85', table: 'ledger_entries',
|
|
select: 'id', key: uniqueKeyFor('ledger_entries'),
|
|
readerRows: (sb) => require('../scripts/proven-status').READS_LEDGER.ledgerSettled(sb),
|
|
filters: [MLB, PUBLIC, SETTLED] },
|
|
{ id: 'champion-ablation:176', source: 'scripts/champion-ablation.js:176', table: 'ledger_entries',
|
|
select: 'id', key: uniqueKeyFor('ledger_entries'),
|
|
readerRows: (sb) => require('../scripts/champion-ablation').READS_LEDGER.ledger(sb),
|
|
filters: [MLB, PUBLIC, SETTLED] },
|
|
|
|
// ── A2b: found during the sweep — the last unordered ledger walker ────
|
|
// Measured 24.7% before conversion; converted in the same pass because it is
|
|
// the identical defect and the fix is the identical one-liner.
|
|
{ id: 'calibrate-hits:40', source: 'scripts/calibrate-hits.js:40', table: 'ledger_entries',
|
|
select: 'id', key: uniqueKeyFor('ledger_entries'),
|
|
readerRows: (sb) => require('../scripts/calibrate-hits').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['eq', 'stat', 'hits'], SETTLED, HAS_PWIN] },
|
|
|
|
// ── A2b: the two WRITE-scripts, previously unmeasured ─────────────────
|
|
{ id: 'backfill-context:55', source: 'scripts/backfill-context.js:55', table: 'ledger_entries',
|
|
select: 'id', key: uniqueKeyFor('ledger_entries'), writes: 'platoon_splits',
|
|
readerRows: (sb) => require('../scripts/backfill-context').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['in', 'stat', ['hits', 'total_bases']], SETTLED] },
|
|
{ id: 'backfill-context:64', source: 'scripts/backfill-context.js:64', table: 'platoon_splits',
|
|
select: 'as_of_date, sport, season, player_key', key: uniqueKeyFor('platoon_splits'),
|
|
writes: 'platoon_splits',
|
|
readerRows: (sb) => require('../scripts/backfill-context').READS.platoonHeld(sb), filters: [MLB] },
|
|
{ id: 'reconstruct-game-environment:61', source: 'scripts/reconstruct-game-environment.js:61',
|
|
table: 'ledger_entries', select: 'id', key: uniqueKeyFor('ledger_entries'), writes: 'game_context',
|
|
readerRows: (sb) => require('../scripts/reconstruct-game-environment').READS.ledger(sb),
|
|
filters: [MLB, PUBLIC, ['in', 'stat', ['hits', 'total_bases']], SETTLED] },
|
|
];
|
|
|
|
function todayEt() {
|
|
return new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
}).format(new Date());
|
|
}
|
|
|
|
/** Build the three queries a spec needs. Read-only by construction. */
|
|
function bindings(sb, spec) {
|
|
const base = () => ri.applyFilters(sb.from(spec.table).select(spec.select), spec.filters);
|
|
// A2b — the CONTROL orders by the FULL unique tuple. Ordering it on a prefix
|
|
// would leave the control itself tie-scrambled, and a control that cannot be
|
|
// trusted must never issue a PASS.
|
|
const orderCols = spec.key || ['id'];
|
|
return {
|
|
exactCount: async () => {
|
|
const { count, error } = await ri.applyFilters(
|
|
sb.from(spec.table).select(orderCols[0], { count: 'exact', head: true }), spec.filters);
|
|
if (error) throw new Error(`count: ${error.message}`);
|
|
return count;
|
|
},
|
|
// ARM (a): exactly as the reader stands — no order.
|
|
fetchUnordered: async (from, to) => {
|
|
const { data, error } = await base().range(from, to);
|
|
if (error) throw new Error(`unordered: ${error.message}`);
|
|
return data;
|
|
},
|
|
// ARM (b): identical + a stable order. The CONTROL, which must itself
|
|
// reconcile to the server count before any PASS is issued.
|
|
fetchOrdered: async (from, to) => {
|
|
let q = base();
|
|
for (const c of orderCols) q = q.order(c, { ascending: true });
|
|
const { data, error } = await q.range(from, to);
|
|
if (error) throw new Error(`ordered: ${error.message}`);
|
|
return data;
|
|
},
|
|
// Once a reader is fixed, arm (a) becomes the REAL function's output.
|
|
...(spec.readerRows ? { readerRows: () => spec.readerRows(sb) } : {}),
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required');
|
|
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
|
|
|
const argv = process.argv.slice(2);
|
|
const only = argv.includes('--id') ? argv[argv.indexOf('--id') + 1] : null;
|
|
const acceptance = argv.includes('--acceptance');
|
|
|
|
let specs = READERS;
|
|
if (only) specs = READERS.filter((s) => s.id === only);
|
|
if (acceptance) {
|
|
specs = READERS.filter((s) => ['prove-hit-factors:136', 'challenger-scoreboard:112'].includes(s.id));
|
|
}
|
|
|
|
const results = [];
|
|
for (const spec of specs) {
|
|
const r = await ri.measure(spec, bindings(sb, spec));
|
|
results.push(r);
|
|
process.stderr.write(` ${r.verdict.padEnd(15)} ${r.id} — ${r.corruption_pct ?? '?'}%\n`);
|
|
}
|
|
|
|
const summary = {
|
|
run_at: new Date().toISOString(),
|
|
readers_measured: results.length,
|
|
pass: results.filter((r) => r.verdict === ri.VERDICT.PASS).length,
|
|
fail: results.filter((r) => r.verdict === ri.VERDICT.FAIL).length,
|
|
other: results.filter((r) => ![ri.VERDICT.PASS, ri.VERDICT.FAIL].includes(r.verdict)).length,
|
|
worst_corruption_pct: results.reduce((m, r) => Math.max(m, r.corruption_pct || 0), 0),
|
|
};
|
|
|
|
if (acceptance) {
|
|
// AC5 — the harness must reproduce the probe or it is not trustworthy.
|
|
const corrupt = results.find((r) => r.id === 'prove-hit-factors:136');
|
|
const clean = results.find((r) => r.id === 'challenger-scoreboard:112');
|
|
summary.acceptance = {
|
|
known_corrupt_reader: corrupt && corrupt.id,
|
|
known_corrupt_pct: corrupt && corrupt.corruption_pct,
|
|
known_corrupt_in_16_to_25_band: !!corrupt && corrupt.corruption_pct >= 16 && corrupt.corruption_pct <= 25,
|
|
known_clean_reader: clean && clean.id,
|
|
known_clean_pct: clean && clean.corruption_pct,
|
|
known_clean_is_zero: !!clean && clean.corruption_pct === 0,
|
|
};
|
|
summary.acceptance.AC5_PASSES = summary.acceptance.known_corrupt_in_16_to_25_band
|
|
&& summary.acceptance.known_clean_is_zero;
|
|
}
|
|
|
|
console.log(JSON.stringify({ summary, results }, null, 2));
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().then(() => process.exit(0)).catch((e) => {
|
|
console.error('read-integrity FAILED:', e.message);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = { READERS, bindings };
|