diff --git a/scripts/heal-dryrun.js b/scripts/heal-dryrun.js new file mode 100644 index 0000000..8fbd1a1 --- /dev/null +++ b/scripts/heal-dryrun.js @@ -0,0 +1,143 @@ +#!/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 (00–06 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 00–06 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); }); diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index f1b1512..8cdaf98 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -564,6 +564,13 @@ async function getModelAggregate(opts = {}) { // enter a record denominator, exactly as pushes are excluded from hit_pct. // Without this, voiding a row would silently move the public record. .not('outcome', 'in', '("void","unrecoverable")') + // Session 64 (Order 2) — QUARANTINE. A row whose GRADE is untrustworthy + // (wrong_opponent_grade) stays a real public settled result but must never + // train or validate a model, so it leaves the denominator exactly as + // void/unrecoverable do. NOTE: `analysis_flags` (e.g. doubleheader) is + // deliberately NOT filtered here — those rows settle validly and belong in + // the record; they are excluded only from per-game/opponent analysis. + .is('quarantine_reason', null) // 2026-07 — a grade with a non-positive model_value had NO real projection // (the pre-fix degradation). Those locks are kept in the append-only ledger // but must not count toward the public model record — their hit/miss is diff --git a/src/services/settleSource.js b/src/services/settleSource.js index bafb88d..78a7598 100644 --- a/src/services/settleSource.js +++ b/src/services/settleSource.js @@ -135,17 +135,19 @@ async function resolveOutcome(args = {}) { if (allVoid) { return { state: 'void', value: null, reason: 'game_postponed_or_cancelled', source: 'schedule' }; } - // ── DO NOT VOID ON PLAYER-ABSENCE ALONE. ────────────────────────────── - // This branch used to return void/'player_dnp' and it was WRONG: absence - // from a date can equally mean the ROW'S DATE IS WRONG. Verified live — - // Freddie Freeman's 97-game log has no 2026-07-18 because he played the - // 17th and 19th (a doubleheader), and ledgerService derives game_date from - // the GRADE timestamp when the feed carries no game_time, so a 01:00/03:00 - // UTC snapshot labels rows with the PREVIOUS ET day. - // We cannot distinguish "did not play" from "mislabelled date" here, so we - // must not claim DNP. Unresolvable rows age out to 'unrecoverable' via the - // retry cap — honest, and excluded from the record either way. - return { state: 'unknown', value: null, reason: 'player_absent_unconfirmed', source: 'schedule' }; + // DNP VOIDING — RE-ENABLED (Order 2 Phase 4), deliberately reversing the + // Order 1.5 disable 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 that: across every bindable row the stored + // date matched a real game (MIS-DATED: 0), and the specific players 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). + // + // So the evidence here IS positive: the day's games are FINAL and the + // player has no line in a full-season log. That is a DNP — no bet existed. + // The date is trustworthy; the absence is real. + return { state: 'void', value: null, reason: 'player_dnp', source: 'schedule' }; } return { state: 'unknown', value: null, reason: 'state_undetermined', source: 'schedule' }; } diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 69e7de1..69448c3 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -179,7 +179,7 @@ describe('settleLedger — outcome + CLV vs the real result', () => { // Session 64 — BEHAVIOUR CHANGED ON PURPOSE. A missing game-log row used to // mean "pending forever". It now depends on what the day's games actually did. - test('no row + game FINAL → NOT voided (date may be wrong); attempts bump', async () => { + test('no row + game FINAL → VOID (genuine DNP; dates verified correct)', async () => { const sb = fakeSb(); sb._state.selectResults = [ [{ id: 'r1' }], @@ -190,10 +190,9 @@ describe('settleLedger — outcome + CLV vs the real result', () => { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10', getSchedule: async () => [{ status: 'Final' }], }); - expect(res.voided).toBe(0); + expect(res.voided).toBe(1); expect(res.settled).toBe(0); - expect(res.pending).toBe(1); - expect(sb._calls.updates[0].values.outcome).toBeUndefined(); + expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'void', settlement_source: 'player_dnp' }); }); test('no row + game NOT FINAL → stays pending, never voided', async () => { @@ -223,7 +222,7 @@ describe('settleLedger — outcome + CLV vs the real result', () => { const getPlayerStats = async () => ({ found: true, last10: [] }); const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10', - getSchedule: async () => [{ status: 'Final' }], // player absent, unconfirmed + getSchedule: async () => [], // nothing knowable → unrecoverable at the cap }); expect(res.unrecoverable).toBe(1); expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' }); @@ -415,3 +414,24 @@ describe('getModelAggregate — clv_distribution (n>=20 gate lives in the servic expect(clvBucketIndex(null)).toBe(-1); // absent beats wrong }); }); + +describe('Session 64 Order 2 — quarantine vs analysis_flags (two scopes)', () => { + const src = require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'src', 'services', 'ledgerService.js'), 'utf8'); + + test('getModelAggregate EXCLUDES quarantined rows from the denominator', () => { + expect(src).toMatch(/\.is\('quarantine_reason', null\)/); + }); + + test('getModelAggregate does NOT filter analysis_flags — those rows settle validly', () => { + // Doubleheader rows are unattributable per-GAME but their day-total + // settlement is real; excluding them would wrongly shrink the record. + expect(src).not.toMatch(/\.is\('analysis_flags', null\)/); + expect(src).not.toMatch(/analysis_flags.*denominator/); + }); + + test('quarantine sits alongside void/unrecoverable, not instead of them', () => { + const agg = src.slice(src.indexOf('async function getModelAggregate')); + expect(agg).toMatch(/void","unrecoverable/); + expect(agg).toMatch(/quarantine_reason/); + }); +}); diff --git a/tests/unit/settleSource.test.js b/tests/unit/settleSource.test.js index 1ee87bc..48c2c55 100644 --- a/tests/unit/settleSource.test.js +++ b/tests/unit/settleSource.test.js @@ -77,14 +77,14 @@ describe('resolveOutcome', () => { // previous ET day). Verified live: Freddie Freeman's 97-game log has no // 2026-07-18 because he played the 17th and 19th. Voiding on absence // destroyed real results. - test('player absent on a final game → UNKNOWN, never void (date may be wrong)', async () => { + test('player absent on a FINAL game → VOID (dates proven correct; DNP is real)', async () => { const r = await src.resolveOutcome({ ...base, getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }], getSchedule: async () => [{ status: 'Final' }], }); - expect(r.state).toBe('unknown'); - expect(r.reason).toBe('player_absent_unconfirmed'); + expect(r.state).toBe('void'); + expect(r.reason).toBe('player_dnp'); }); test('postponed game → VOID with the postponed reason', async () => {