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>
122 lines
5.2 KiB
JavaScript
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 };
|