'use strict'; /** * DEDUPE PARITY — old key vs new key on REAL prop shapes. * * The event-aware dedupe changes candidate identity, so a single champion * probability check is not sufficient evidence that ordinary slates are * unaffected. This replays BOTH keys over real prop shapes reconstructed from * production rows and reports every difference. * * Writes nothing. Deterministic. * * PARITY_DATE=2026-08-26 node scripts/dedupe-parity.js */ const { createClient } = require('@supabase/supabase-js'); const { paginate } = require('../src/utils/safePaginate'); const E = require('../src/services/event/eventIdentity'); const SPORT = process.env.PARITY_SPORT || 'mlb'; const DATE = process.env.PARITY_DATE || '2026-08-26'; function client() { const url = process.env.SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) throw new Error('SUPABASE_URL + service key required'); return createClient(url, key, { auth: { persistSession: false } }); } /** The OLD key, verbatim: player::stat_type::line. */ const oldKey = (p) => `${p.player}::${p.stat_type}::${p.line}`; /** The NEW key: event leads. */ function newKey(p) { const ev = p.canonical_event_id || (p.event_identity_method && p.event_identity_method !== 'UNSUPPORTED_SPORT' ? `unresolved-event:${p.event_identity_method}:${p.__row}` : `legacy:${p.game_date}:${String(p.away_team || '').replace(/\s+/g, '')}@${String(p.home_team || '').replace(/\s+/g, '')}`); return `${ev}::${p.player}::${p.stat_type}::${p.line}`; } function teamsFromGameId(gameId) { const tail = String(gameId || '').split(':').pop(); const [away, home] = String(tail).split('@'); return { away_team: away || null, home_team: home || null }; } async function main() { const sb = client(); const rows = await paginate( () => sb.from('model_snapshots') .select('id, player_name, player_key, stat, line, side, game_id, game_date, book') .eq('sport', SPORT).eq('game_date', DATE), { key: 'id', label: 'model_snapshots:dedupe-parity' }, ); // Schedule + rosters straight from statsapi (free, no cache layer) so the // replay does not depend on Redis being reachable. const sched = await (await fetch(`https://statsapi.mlb.com/api/v1/schedule?sportId=1&date=${DATE}`)).json(); const games = (sched.dates || []).flatMap((d) => d.games || []).map((g) => ({ gamePk: g.gamePk, gameNumber: g.gameNumber, gameDate: g.gameDate, home: { team: g.teams.home.team.name, teamId: g.teams.home.team.id }, away: { team: g.teams.away.team.name, teamId: g.teams.away.team.id }, })); // PARTICIPANT EVIDENCE — the same index production builds. const built = await E.buildPlayerTeamIndex(games, { getTeamRoster: async (id) => { const r = await (await fetch(`https://statsapi.mlb.com/api/v1/teams/${id}/roster?rosterType=active`)).json(); return (r.roster || []).map((x) => ({ id: x.person && x.person.id, name: x.person && x.person.fullName })); }, }); const playerTeams = built.index; // Reconstruct prop shapes. One row per (player, stat, line, book) — the side // is not part of the dedupe key, so both sides collapse to the same prop. const seen = new Set(); const props = []; for (const r of rows) { const k = `${r.player_key}|${r.stat}|${r.line}|${r.book}`; if (seen.has(k)) continue; seen.add(k); props.push({ __row: props.length, player: r.player_name || r.player_key, stat_type: r.stat, line: r.line, book: r.book, game_date: r.game_date, ...teamsFromGameId(r.game_id), }); } // The schedule has one game per pair on a non-doubleheader date, so teams // alone resolve; no game_time is needed and none is reconstructed. const ident = E.attachEventIdentity(SPORT, props, games, playerTeams); const oldSet = new Set(props.map(oldKey)); const newSet = new Set(props.map(newKey)); // Which OLD keys collapsed rows that the NEW key separates? const byOld = new Map(); for (const p of props) { const k = oldKey(p); if (!byOld.has(k)) byOld.set(k, new Set()); byOld.get(k).add(newKey(p)); } const split = [...byOld.entries()].filter(([, s]) => s.size > 1); console.log(`\n=== DEDUPE PARITY — ${SPORT} ${DATE} ===`); console.log(`snapshot rows read : ${rows.length}`); console.log(`reconstructed prop shapes : ${props.length}`); console.log(`participant evidence : ${built.stats.players} players / ${built.stats.teams} rosters`); console.log(`canonical event resolution : ${ident.canonical}/${ident.total}` + ` (unresolved ${ident.unresolved}, unsupported ${ident.unsupported})`); console.log(`IMPOSSIBLE-BINDING refusals : ${ident.impossible}`); if (Object.keys(ident.reasons).length) console.log(` unresolved reasons : ${JSON.stringify(ident.reasons)}`); console.log(`distinct events : ${new Set(props.map((p) => p.canonical_event_id).filter(Boolean)).size}`); console.log(''); console.log(`OLD key -> unique candidates : ${oldSet.size}`); console.log(`NEW key -> unique candidates : ${newSet.size}`); console.log(`difference : ${newSet.size - oldSet.size}`); console.log(`OLD keys the NEW key SPLITS : ${split.length}`); for (const [k, s] of split.slice(0, 5)) { console.log(` ${k}`); for (const nk of s) console.log(` -> ${nk}`); } console.log(''); if (split.length === 0) { console.log('VERDICT: NO CANDIDATE-UNIVERSE DIFFERENCE on this slate.'); console.log(' Every old key mapped to exactly one new key, so the event'); console.log(' component changed nothing here. Expected on a non-doubleheader date.'); } else { console.log('VERDICT: CANDIDATE-UNIVERSE DATA-INTEGRITY CORRECTION.'); console.log(` ${split.length} old key(s) collapsed rows from more than one real event.`); } console.log(''); } if (require.main === module) { main().then(() => process.exit(0)).catch((e) => { console.error('FAILED:', e.message); process.exit(1); }); } module.exports = { oldKey, newKey };