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
+297
View File
@@ -0,0 +1,297 @@
'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 };