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:
@@ -27,24 +27,53 @@ const { knownNumber, knownRate } = require('../src/utils/known');
|
||||
const { nameKey } = require('../src/utils/playerName');
|
||||
const sd = require('../src/services/model/sprayDefense');
|
||||
const pss = require('../src/services/model/platoonSeverity');
|
||||
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 ARCHS = (process.env.HF_ARCHETYPES || 'BOMBER,GHOST,ALL').split(',');
|
||||
|
||||
async function page(sb, table, select, apply) {
|
||||
const out = [];
|
||||
for (let from = 0; ; from += PAGE) {
|
||||
const { data, error } = await apply(sb.from(table).select(select)).range(from, from + PAGE - 1);
|
||||
if (error) throw error;
|
||||
if (!data || data.length === 0) break;
|
||||
out.push(...data);
|
||||
if (data.length < PAGE) break;
|
||||
}
|
||||
return out;
|
||||
// ── FIX A2 (2026-08-09) — THE SAFE WALK ───────────────────────────────────
|
||||
// `page()` above walks with 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. `pageSafe` routes the same
|
||||
// call through `src/utils/safePaginate`, which orders on a UNIQUE key on every
|
||||
// page, verifies uniqueness at runtime, and THROWS on a query error instead of
|
||||
// treating it as end-of-data.
|
||||
//
|
||||
// `page()` SURVIVES only for the context tables (statcast_aggregates,
|
||||
// batter_spray, team_defense, platoon_splits, park_dimensions, game_context...).
|
||||
// Those have COMPOSITE primary keys with no single unique column, so
|
||||
// safePaginate cannot express them. They measure 0% corruption today; making
|
||||
// them safe needs a composite-key ordering the helper does not yet have. Do not
|
||||
// use `page()` for ledger_entries or model_snapshots.
|
||||
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, named once.
|
||||
*
|
||||
* `main()` calls these and so does the read-integrity harness, so a PASS there
|
||||
* is a statement about the code that actually runs. A registry that restated
|
||||
* the query would verify the restatement instead.
|
||||
*/
|
||||
const READS = {
|
||||
statcast: (sb) => pageSafe(sb, 'statcast_aggregates', '*', (q) => q.eq('sport', 'mlb')),
|
||||
spray: (sb) => pageSafe(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb')),
|
||||
platoon: (sb) => pageSafe(sb, 'platoon_splits', '*', (q) => q.eq('sport', 'mlb')),
|
||||
defense: (sb) => pageSafe(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb')),
|
||||
snaps: (sb) => pageSafe(sb, 'model_snapshots', 'id, player_key, game_date, archetype, stat',
|
||||
(q) => q.eq('sport', 'mlb').eq('stat', 'hits').not('archetype', 'is', null)),
|
||||
ledger: (sb) => pageSafe(sb, 'ledger_entries',
|
||||
'id, game_id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason, env_park_base',
|
||||
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits')
|
||||
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null)),
|
||||
};
|
||||
|
||||
/**
|
||||
* THE FACTORS. Each returns a MULTIPLIER on the base rate, or null when the
|
||||
* input is absent — an absent factor must leave the baseline untouched rather
|
||||
@@ -101,14 +130,14 @@ 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 statcast = await page(sb, 'statcast_aggregates', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const statcast = await pageSafe(sb, 'statcast_aggregates', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const batters = new Map(); const pitchersById = new Map();
|
||||
for (const r of statcast) {
|
||||
const prof = sk.fromStatcastRow(r);
|
||||
if (r.role === 'pitcher' && r.source_id != null) pitchersById.set(Number(r.source_id), prof);
|
||||
if (r.role === 'batter' && r.player_key) batters.set(r.player_key, prof);
|
||||
}
|
||||
const sprayRows = await page(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const sprayRows = await pageSafe(sb, 'batter_spray', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const sprayByKey = new Map();
|
||||
for (const r of sprayRows) {
|
||||
if (!r.player_key) continue;
|
||||
@@ -116,7 +145,7 @@ async function main() {
|
||||
if (!prev || String(r.as_of_date) > String(prev.as_of_date)) sprayByKey.set(r.player_key, r);
|
||||
}
|
||||
|
||||
const platRows = await page(sb, 'platoon_splits', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const platRows = await pageSafe(sb, 'platoon_splits', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const platByKey = new Map();
|
||||
for (const r of platRows) {
|
||||
if (!r.player_key) continue;
|
||||
@@ -124,19 +153,15 @@ async function main() {
|
||||
if (!prev || String(r.as_of_date) > String(prev.as_of_date)) platByKey.set(r.player_key, r);
|
||||
}
|
||||
|
||||
const defRows = await page(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const defRows = await pageSafe(sb, 'team_defense', '*', (q) => q.eq('sport', 'mlb'));
|
||||
const defByTeam = new Map();
|
||||
for (const d of defRows) defByTeam.set(d.team, d);
|
||||
|
||||
const snaps = await page(sb, 'model_snapshots', 'player_key, game_date, archetype, stat',
|
||||
(q) => q.eq('sport', 'mlb').eq('stat', 'hits').not('archetype', 'is', null));
|
||||
const snaps = await READS.snaps(sb);
|
||||
const archOf = new Map();
|
||||
for (const s of snaps) archOf.set(`${s.player_key}|${s.game_date}`, s.archetype);
|
||||
|
||||
const led = await page(sb, 'ledger_entries',
|
||||
'id, game_id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason, env_park_base',
|
||||
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits')
|
||||
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null));
|
||||
const led = await READS.ledger(sb);
|
||||
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
||||
|
||||
// Opponent faced, from each hitter's own game log.
|
||||
@@ -308,4 +333,9 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user