Files
vyndr/scripts/heal-dryrun.js
T
builtbykev b33612675d Heal execute: quarantine markers, re-enable DNP voiding, two exclusion scopes
Order 2 Phases 2 + 4. Pre-heal rollback point secured first:
vyndr-20260720-093821.dump (856,890 bytes) VERIFIED ON THE BOX, not just
exit 0.

MIGRATION 027 — two DISTINCT exclusion scopes, deliberately separate:
- quarantine_reason: the row's GRADE is untrustworthy (wrong_opponent_grade).
  The row REMAINS a real public settled result — the bet happened, the
  outcome is real — but it must never train or validate, so
  getModelAggregate now excludes it from the denominator alongside
  void/unrecoverable.
- analysis_flags: the row is VALID for settlement and the record but
  unattributable for PER-GAME analysis (doubleheader dates). Explicitly NOT
  filtered from aggregates.
Collapsing these would either wrongly drop 166 doubleheader rows from the
record or wrongly keep 25 wrong-opponent grades inside model validation.
Tests assert both directions, including that analysis_flags is NOT filtered.
Also adds re_settled_at + settlement_source to model_snapshots.

DNP VOIDING RE-ENABLED — reversing my own Order 1.5 disable, with scrutiny,
because its premise was FALSE. Order 1.5 assumed a missing player row meant
the row's DATE was wrong. The Phase 0 dry-run disproved it: across every
bindable row the stored date matched a real game (MIS-DATED: 0), and the
players I had cited as counter-evidence were genuine DNPs on their true
dates (Freeman 07-18; Kwan/Hedges/Davis 07-17 — their teams played, they
did not). The evidence is positive: games FINAL + no line in a full-season
log = no bet existed.

I got this wrong twice tonight in opposite directions; the dry-run is what
caught it. Recording the reasoning in the code so the next reader sees why
the flag flipped back.

Suite 282/3386 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 05:41:41 -04:00

144 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* HEAL DRY-RUN (Order 2, Phase 0) — REPORT ONLY. WRITES NOTHING.
*
* Classifies every ledger row's stored game_date against the real schedule and
* re-derives what opponent a grade would have been bound to, so the heal is
* planned against verified ground truth instead of the "by hour" proxy.
*
* KEY QUESTION IT ANSWERS: baseball is played in SERIES. A one-day-off opponent
* bind may land on the SAME opponent, in which case the grade was never harmed.
* Only a bind that changes the opponent is real damage.
*/
const sched = require('../src/services/scheduleService');
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
function teamsFromGameId(gameId) {
// mlb:2026-07-16:NewYorkMets@PhiladelphiaPhillies (may carry "(Game1)")
const m = String(gameId || '').match(/^([a-z]+):(\d{4}-\d{2}-\d{2}):(.+)@(.+)$/);
if (!m) return null;
return { sport: m[1], date: m[2], away: m[3].replace(/\(Game\d+\)/i, ''), home: m[4].replace(/\(Game\d+\)/i, '') };
}
function addDays(d, n) {
const t = new Date(`${d}T12:00:00Z`);
t.setUTCDate(t.getUTCDate() + n);
return t.toISOString().slice(0, 10);
}
const schedCache = new Map();
async function scheduleFor(sport, date) {
const k = `${sport}:${date}`;
if (!schedCache.has(k)) {
try { schedCache.set(k, (await sched.getSchedule(sport, date)) || []); }
catch { schedCache.set(k, []); }
}
return schedCache.get(k);
}
const abbrOf = (t) => norm(t && (t.abbreviation || t.name || t.displayName));
const nameOf = (t) => String((t && (t.displayName || t.name || t.abbreviation)) || '');
/** Find the game a team played on a date. Returns {opponent, isHome, id, count}. */
async function gameForTeam(sport, date, teamName) {
const games = await scheduleFor(sport, date);
const want = norm(teamName);
const hits = [];
for (const g of games) {
const h = abbrOf(g.homeTeam);
const a = abbrOf(g.awayTeam);
const match = (x) => !!x && !!want && (x === want || x.includes(want) || want.includes(x));
if (match(h)) hits.push({ opponent: nameOf(g.awayTeam), isHome: true, id: g.id });
else if (match(a)) hits.push({ opponent: nameOf(g.homeTeam), isHome: false, id: g.id });
}
return hits.length ? { ...hits[0], count: hits.length } : null;
}
(async () => {
// Supabase REST is unreachable from this dev box (same restriction as :5432),
// so rows are exported via the MCP SQL path and read from a local JSON file.
const rows = JSON.parse(require('fs').readFileSync(process.argv[2], 'utf8'));
console.log(`rows fetched: ${rows.length}\n`);
const dateClass = { CORRECT: 0, MISDATED: 0, UNBINDABLE: 0 };
const oppClass = { MATCH: 0, MISMATCH: 0, UNDETERMINED: 0, NOT_AFFECTED: 0 };
const misdatedEx = [];
const mismatchEx = [];
const dh = [];
const unbindableBy = {};
const mismatchBy = {};
const dhRows = new Set();
const mismatchIds = [];
for (const r of rows) {
const t = teamsFromGameId(r.game_id);
const hourUtc = Number(r.hour);
const affected = hourUtc <= 6; // window proven live (0006 UTC)
if (!t) { dateClass.UNBINDABLE += 1; oppClass.UNDETERMINED += 1;
const k=`${r.sport} ${r.game_date} (no game_id parse)`; unbindableBy[k]=(unbindableBy[k]||0)+1; continue; }
// --- date axis: did these teams actually play on the stored date? ---
const onStored = await gameForTeam(r.sport, r.game_date, t.home);
if (onStored) {
dateClass.CORRECT += 1;
if (onStored.count > 1) { dh.push({ id: r.id, date: r.game_date, team: t.home, games: onStored.count }); dhRows.add(r.id); }
} else {
const next = await gameForTeam(r.sport, addDays(r.game_date, 1), t.home);
dateClass[next ? 'MISDATED' : 'UNBINDABLE'] += 1;
if (!next) { const k=`${r.sport} ${r.game_date}`; unbindableBy[k]=(unbindableBy[k]||0)+1; }
if (next && misdatedEx.length < 6) {
misdatedEx.push({ player: r.player_name, stored: r.game_date, real: addDays(r.game_date, 1), outcome: r.outcome });
}
}
// --- grade axis: would the wrong-day bind have changed the OPPONENT? ---
if (!affected) { oppClass.NOT_AFFECTED += 1; continue; }
const trueDate = onStored ? r.game_date : addDays(r.game_date, 1);
const trueGame = onStored || await gameForTeam(r.sport, trueDate, t.home);
const boundGame = await gameForTeam(r.sport, addDays(trueDate, -1), t.home); // ESPN "yesterday"
if (!trueGame) { oppClass.UNDETERMINED += 1; continue; }
if (!boundGame) { oppClass.MATCH += 1; continue; } // no prior game → nothing wrong to bind
if (norm(trueGame.opponent) === norm(boundGame.opponent) && trueGame.isHome === boundGame.isHome) {
oppClass.MATCH += 1; // SERIES: same opponent, no harm
} else {
oppClass.MISMATCH += 1;
const k=`${r.sport} ${trueDate} settled=${r.outcome||'PENDING'}`; mismatchBy[k]=(mismatchBy[k]||0)+1;
mismatchIds.push({ id: r.id, sport: r.sport, date: trueDate, outcome: r.outcome,
graded_vs: boundGame.opponent, true_opponent: trueGame.opponent });
if (mismatchEx.length < 8) {
mismatchEx.push({
player: r.player_name, date: trueDate,
graded_vs: `${boundGame.opponent}${boundGame.isHome ? ' (H)' : ' (A)'}`,
true_opponent: `${trueGame.opponent}${trueGame.isHome ? ' (H)' : ' (A)'}`,
});
}
}
}
console.log('=== 0.1 DATE AXIS ===');
console.log(JSON.stringify(dateClass, null, 2));
if (misdatedEx.length) { console.log('mis-dated examples:'); misdatedEx.forEach((e) => console.log(' ', JSON.stringify(e))); }
console.log('\n=== 0.4 GRADE AXIS (affected window 0006 UTC) ===');
console.log(JSON.stringify(oppClass, null, 2));
if (mismatchEx.length) { console.log('opponent MISMATCH examples:'); mismatchEx.forEach((e) => console.log(' ', JSON.stringify(e))); }
console.log('\n=== UNBINDABLE breakdown (sport date -> rows) ===');
Object.entries(unbindableBy).sort().forEach(([k,v])=>console.log(` ${k}: ${v}`));
console.log('\n=== MISMATCH breakdown (sport trueDate settled? -> rows) ===');
Object.entries(mismatchBy).sort().forEach(([k,v])=>console.log(` ${k}: ${v}`));
console.log('\n=== 0.6 DOUBLEHEADER: rows on a date where the team played 2 games ===');
console.log(` affected rows: ${dhRows.size}`);
const byTeam={}; dh.forEach(d=>{const k=`${d.date} ${d.team}`; byTeam[k]=(byTeam[k]||0)+1;});
Object.entries(byTeam).sort().forEach(([k,v])=>console.log(` ${k}: ${v} rows`));
require('fs').writeFileSync('/tmp/claude-1000/-home-kev-mastermind-vyndr/a6b79396-5b11-477d-8353-0672bd5789c1/scratchpad/mismatch_ids.json', JSON.stringify(mismatchIds, null, 1));
require('fs').writeFileSync('/tmp/claude-1000/-home-kev-mastermind-vyndr/a6b79396-5b11-477d-8353-0672bd5789c1/scratchpad/dh_ids.json', JSON.stringify([...dhRows], null, 1));
console.log(`\nID files written: mismatch=${mismatchIds.length} doubleheader=${dhRows.size}`);
await new Promise((r) => process.stdout.write('', r));
process.exit(0);
})().catch((e) => { console.error('dry-run failed:', e.message); process.exit(1); });