Files
vyndr/scripts/build-grade-bands.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

122 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* build-grade-bands — publish what each letter actually means, per archetype.
*
* Runs the real settled ledger through gradeBands. Because no factor has passed
* the gate for any archetype, every band comes back a BASE-RATE read — which is
* the honest answer today, and the output states it rather than leaving a reader
* to infer it.
*
* SUPABASE_URL=... node scripts/build-grade-bands.js
*/
require('dotenv').config();
const { createClient } = require('@supabase/supabase-js');
const gb = require('../src/services/model/gradeBands');
const tl = require('../src/services/model/testLedger');
const cal = require('../src/services/model/calibration');
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 STAT = process.env.BAND_STAT || 'hits';
const PAGE = 1000;
/**
* PROVEN, PER ARCHETYPE. Empty, and that is the measured state — see
* specs/per-archetype-re-audit.md. Nothing may be added here that has not
* cleared the gate FOR THAT ARCHETYPE; pooled proof does not qualify a slot.
*/
const PROVEN_BY_ARCHETYPE = Object.freeze({});
// ── 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 — main() and the harness call the same functions. */
const READS = {
snaps: (sb, stat = STAT) => pageSafe(sb, 'model_snapshots', 'id, player_key, game_date, archetype, stat',
(q) => q.eq('sport', 'mlb').eq('stat', stat).not('archetype', 'is', null)),
ledger: (sb, stat = STAT) => pageSafe(sb, 'ledger_entries',
'id, player_key, game_date, outcome, p_win, quarantine_reason',
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', stat)
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null)),
};
async function main() {
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
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 READS.ledger(sb);
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
const byArch = new Map();
for (const r of clean) {
const a = String(archOf.get(`${r.player_key}|${r.game_date}`) || 'UNLABELLED').toUpperCase();
if (!byArch.has(a)) byArch.set(a, []);
byArch.get(a).push({ p: knownNumber(r.p_win), won: r.outcome === 'hit' ? 1 : 0 });
}
const mc = await tl.recordAndCount(tl.supabaseStore(sb),
[...byArch.keys()].map((a) => ({
sport: 'mlb', stat: STAT, archetype: a === 'UNLABELLED' ? null : a,
interaction: 'grade_band_lift', target: 'outcome',
})));
// Calibration is measured, not assumed. Today it is certified for hits only in
// a middle band (specs — held-out error 0.477->0.506, 0.587->0.580), which is
// NOT the same as an archetype's probabilities being calibrated.
const certified = typeof cal.certifyBands === 'function';
const out = [];
for (const [arch, rows] of [...byArch.entries()].sort((a, b) => b[1].length - a[1].length)) {
out.push(gb.buildBands(rows, {
archetype: arch,
cumulativeTests: mc.cumulative_tests,
proven: Boolean(PROVEN_BY_ARCHETYPE[arch]),
calibrated: false, // no archetype's distribution is certified calibrated
}));
}
console.log(JSON.stringify({
stat: STAT,
settled_rows: clean.length,
archetypes: byArch.size,
cumulative_tests: mc.cumulative_tests,
calibration_helper_present: certified,
proven_by_archetype: PROVEN_BY_ARCHETYPE,
note: 'every band is a BASE-RATE read — no factor has passed the gate for any archetype',
bands: out,
}, 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 };