8c6aef1e12
A fractured identity keeps two bad records from merging. It does not make an unknown game true. Until now a prop with an unresolved or verified-impossible event still continued into grading under that synthetic key; it no longer does. - event_binding_status contract: RESOLVED / UNRESOLVED / AMBIGUOUS / CONTRADICTED / UNSUPPORTED. CONTRADICTED and UNRESOLVED stay distinct — one means we know the association is wrong, the other that we do not know. - ONE admission gate (gradeSlateService.admitForGrading), before dedupe and before grading. Admission requires RESOLVED *and* a canonical_event_id; a legacy derived game_id can never satisfy it. Rejected props are returned, not discarded, so retention keeps them as evidence. - Roster evidence is now DATE-SCOPED. statsapi honours ?date= and it changes the answer (Joe Mack is on the 2026-08-26 Marlins roster, absent on 2026-04-15). Evidence that does not describe the slate's date can only yield UNRESOLVED, never CONTRADICTED — uncertainty must not become an accusation. - A mis-nested market is REFUSED, never re-bound. Knowing Joe Mack is a Marlin does not license moving a provider record into the Marlins game; that would invent provenance. Measured on the real 2026-08-26 slate: 2,878 props -> 2,874 admitted, 4 rejected (EVENT_PLAYER_TEAM_CONTRADICTION), each player's correct game still resolving. Doubleheader 824514/824478 both remain independently RESOLVED and admitted. No change to probability, projection, side, grade, confidence, ranking, normalization or calibration. Lineage remains disabled. Suite 380/5,061/0 from the release worktree; web tsc exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
150 lines
6.5 KiB
JavaScript
150 lines
6.5 KiB
JavaScript
'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, {
|
|
asOfDate: DATE,
|
|
getTeamRoster: async (id, asOf) => {
|
|
const d = asOf ? `&date=${asOf}` : '';
|
|
const r = await (await fetch(`https://statsapi.mlb.com/api/v1/teams/${id}/roster?rosterType=active${d}`)).json();
|
|
return (r.roster || []).map((x) => ({ id: x.person && x.person.id, name: x.person && x.person.fullName }));
|
|
},
|
|
});
|
|
const playerTeams = built.index;
|
|
const evidenceDateValid = E.evidenceIsDateValid(built.stats, DATE);
|
|
|
|
// 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, evidenceDateValid);
|
|
|
|
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(`evidence date-valid : ${evidenceDateValid} (as of ${DATE})`);
|
|
console.log(`CONTRADICTED bindings : ${ident.impossible}`);
|
|
const gate = require('../src/services/gradeSlateService').__internals.admitForGrading(props, SPORT);
|
|
console.log(`ADMITTED to grading : ${gate.admitted.length}`);
|
|
console.log(`REJECTED before grading : ${gate.rejected.length} ${JSON.stringify(gate.reasons)}`);
|
|
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 };
|