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
This commit is contained in:
@@ -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); });
|
||||||
@@ -564,6 +564,13 @@ async function getModelAggregate(opts = {}) {
|
|||||||
// enter a record denominator, exactly as pushes are excluded from hit_pct.
|
// enter a record denominator, exactly as pushes are excluded from hit_pct.
|
||||||
// Without this, voiding a row would silently move the public record.
|
// Without this, voiding a row would silently move the public record.
|
||||||
.not('outcome', 'in', '("void","unrecoverable")')
|
.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
|
// 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
|
// (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
|
// but must not count toward the public model record — their hit/miss is
|
||||||
|
|||||||
@@ -135,17 +135,19 @@ async function resolveOutcome(args = {}) {
|
|||||||
if (allVoid) {
|
if (allVoid) {
|
||||||
return { state: 'void', value: null, reason: 'game_postponed_or_cancelled', source: 'schedule' };
|
return { state: 'void', value: null, reason: 'game_postponed_or_cancelled', source: 'schedule' };
|
||||||
}
|
}
|
||||||
// ── DO NOT VOID ON PLAYER-ABSENCE ALONE. ──────────────────────────────
|
// DNP VOIDING — RE-ENABLED (Order 2 Phase 4), deliberately reversing the
|
||||||
// This branch used to return void/'player_dnp' and it was WRONG: absence
|
// Order 1.5 disable because its premise was FALSE.
|
||||||
// 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
|
// Order 1.5 assumed a missing player row meant the ROW'S DATE was wrong.
|
||||||
// 17th and 19th (a doubleheader), and ledgerService derives game_date from
|
// The Phase 0 dry-run disproved that: across every bindable row the stored
|
||||||
// the GRADE timestamp when the feed carries no game_time, so a 01:00/03:00
|
// date matched a real game (MIS-DATED: 0), and the specific players cited
|
||||||
// UTC snapshot labels rows with the PREVIOUS ET day.
|
// as counter-evidence were genuine DNPs on their true dates (Freeman
|
||||||
// We cannot distinguish "did not play" from "mislabelled date" here, so we
|
// 07-18, Kwan/Hedges/Davis 07-17 — their teams played, they did not).
|
||||||
// must not claim DNP. Unresolvable rows age out to 'unrecoverable' via the
|
//
|
||||||
// retry cap — honest, and excluded from the record either way.
|
// So the evidence here IS positive: the day's games are FINAL and the
|
||||||
return { state: 'unknown', value: null, reason: 'player_absent_unconfirmed', source: 'schedule' };
|
// 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' };
|
return { state: 'unknown', value: null, reason: 'state_undetermined', source: 'schedule' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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();
|
const sb = fakeSb();
|
||||||
sb._state.selectResults = [
|
sb._state.selectResults = [
|
||||||
[{ id: 'r1' }],
|
[{ id: 'r1' }],
|
||||||
@@ -190,10 +190,9 @@ describe('settleLedger — outcome + CLV vs the real result', () => {
|
|||||||
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
||||||
getSchedule: async () => [{ status: 'Final' }],
|
getSchedule: async () => [{ status: 'Final' }],
|
||||||
});
|
});
|
||||||
expect(res.voided).toBe(0);
|
expect(res.voided).toBe(1);
|
||||||
expect(res.settled).toBe(0);
|
expect(res.settled).toBe(0);
|
||||||
expect(res.pending).toBe(1);
|
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'void', settlement_source: 'player_dnp' });
|
||||||
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no row + game NOT FINAL → stays pending, never voided', async () => {
|
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 getPlayerStats = async () => ({ found: true, last10: [] });
|
||||||
const res = await ledger.settleLedger('mlb', {
|
const res = await ledger.settleLedger('mlb', {
|
||||||
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
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(res.unrecoverable).toBe(1);
|
||||||
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
|
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
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,14 +77,14 @@ describe('resolveOutcome', () => {
|
|||||||
// previous ET day). Verified live: Freddie Freeman's 97-game log has no
|
// 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
|
// 2026-07-18 because he played the 17th and 19th. Voiding on absence
|
||||||
// destroyed real results.
|
// 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({
|
const r = await src.resolveOutcome({
|
||||||
...base,
|
...base,
|
||||||
getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }],
|
getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }],
|
||||||
getSchedule: async () => [{ status: 'Final' }],
|
getSchedule: async () => [{ status: 'Final' }],
|
||||||
});
|
});
|
||||||
expect(r.state).toBe('unknown');
|
expect(r.state).toBe('void');
|
||||||
expect(r.reason).toBe('player_absent_unconfirmed');
|
expect(r.reason).toBe('player_dnp');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('postponed game → VOID with the postponed reason', async () => {
|
test('postponed game → VOID with the postponed reason', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user