Files
vyndr/scripts/dedupe-parity.js
builtbykev 8c6aef1e12 Event admission gate: an unresolved or contradicted game does not earn a Read
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
2026-08-27 16:50:50 -04:00

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 };