MLB canonical event identity, impossible-binding refusal, event-aware dedupe, publication commit
Release-isolated slice built from 41ba38e. Ships ONLY the event-integrity +
publication + lineage-canary closure; the 90-path development tree stays
undeployed.
- canonical MLB event identity from statsapi gamePk (mlb:gamepk:<pk>), with
event_identity_source/method/version recorded. The id is canonical; the
binding is derived and says so.
- IMPOSSIBLE-BINDING REFUSAL. Verified in prod 2026-08-26: Joe Mack (Marlins)
was bound to Dodgers@Braves and Yandy Diaz (Rays) to Rangers@WhiteSox, both
from one book in the 01:00/03:01 UTC cycles after their own games began. Root
cause is source market data, not the binder. A prop whose player's team is not
an event participant now refuses; unknown team preserves uncertainty.
- event-aware dedupe: books still collapse, events no longer do. An unresolved
MLB event fractures rather than falling back to the collision-prone
date+teams key.
- publication commit moved AFTER the authoritative Redis slate write, with
exact parity-gap identity when the product publishes and the record does not.
- lineage dual-write behind LINEAGE_CANARY_SPORTS, DISABLED for this deploy.
Excluded deliberately: WNBA feed/chain, market ontology, PerformanceDistribution,
calibration certification, truth diagnostics, applyRevision Phase-1, and the
analyzeViaEngine1 confidence-rounding change (a served field).
Suite 380/5,040/0 from this worktree; web tsc exit 0; champion output identical
to production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user