/** * MLB PARTICIPANT IDENTITY CONVERGENCE * * One proven MLB human must produce ONE semantic identity, and two humans must * never produce one. Both directions are asserted, because `collision_count` * can only ever see the first: a split makes MORE identities, not fewer. * * Every fixture is built from the SEMANTIC SHAPE of a real measured case. There * is no alias table anywhere in the repair and there is none here. */ const evid = require('../../src/services/event/eventIdentity'); const gs = require('../../src/services/gradeSlateService'); const rs = require('../../src/services/retentionService'); const pid = require('../../src/services/model/participantIdentity'); const { nameKey } = require('../../src/utils/playerName'); const I = gs.__internals; const RI = rs.__internals || {}; const rowsFromSides = RI.rowsFromSides || rs.rowsFromSides; const expectedMaterialization = RI.expectedMaterialization || rs.expectedMaterialization; const EV = 'mlb:gamepk:823502'; const GAMES = [{ gamePk: 823502, home: { teamId: 147, team: 'New York Yankees' }, away: { teamId: 111, team: 'Boston Red Sox' } }]; const GAMES_DH = [ { gamePk: 900001, home: { teamId: 147, team: 'New York Yankees' }, away: { teamId: 111, team: 'Boston Red Sox' } }, { gamePk: 900002, home: { teamId: 147, team: 'New York Yankees' }, away: { teamId: 111, team: 'Boston Red Sox' } }, ]; // StatsAPI person records, verbatim in shape. NOT an alias table: these are the // league's own fields, which is the whole point of the repair. const GASPER = { id: 681508, name: 'Mickey Gasper', fullName: 'Mickey Gasper', firstName: 'Michael', lastName: 'Gasper', useName: 'Mickey', useLastName: 'Gasper' }; const JIMENEZ = { id: 677870, name: 'Leo Jiménez', fullName: 'Leo Jiménez', firstName: 'Leonardo', lastName: 'Jimenez', useName: 'Leo', useLastName: 'Jiménez' }; const BURCH = { id: 572143, name: 'Burch Smith', fullName: 'Burch Smith', firstName: 'Burch', lastName: 'Smith', useName: 'Burch', useLastName: 'Smith' }; const WILL = { id: 669257, name: 'Will Smith', fullName: 'Will Smith', firstName: 'William', lastName: 'Smith', useName: 'Will', useLastName: 'Smith' }; function indexFor(homeRoster, awayRoster, games = GAMES) { return evid.buildPlayerTeamIndex(games, { asOfDate: '2026-08-30', getTeamRoster: async (id) => (id === 147 ? homeRoster.active : awayRoster.active), getTeamRoster40: async (id) => (id === 147 ? homeRoster.forty : awayRoster.forty), }); } const EMPTY = { active: [], forty: [] }; function prop(player, stat, line, book, extra = {}) { return { player, stat_type: stat, line, book, canonical_event_id: EV, event_identity_method: 'CANONICAL', home_team: 'New York Yankees', away_team: 'Boston Red Sox', game_date: '2026-08-30', game_time: '2026-08-30T23:05:00Z', over_odds: -110, under_odds: -110, ...extra, }; } const CTX = { snapshotId: 'S', capturedAt: '2026-08-30T19:03:44Z', sport: 'mlb', gameDate: '2026-08-30', cycleHourUtc: 19, gameIdFor: () => 'G', }; function retentionRows(props) { const rows = []; for (const p of props) { const base = { player: p.player, stat_type: p.stat_type, line: p.line, book: p.book, game_time: p.game_time, canonical_event_id: p.canonical_event_id, canonical_player_name: p.canonical_player_name ?? null, mlb_person_id: p.mlb_person_id ?? null, }; const sides = ['over', 'under'].map((d) => ({ player: p.player, stat_type: p.stat_type, line: p.line, direction: d, grade: 'C', confidence: 56, })); rows.push(...rowsFromSides(base, sides, CTX)); } return rows; } /* ------------------------------------------------------------------ */ describe('STEP 6 — Gasper under-collapse (two spellings, one human)', () => { const offerings = [ prop('Mickey Gasper', 'hits', 0.5, 'betmgm'), prop('Michael Gasper', 'hits', 0.5, 'draftkings'), ]; test('PRE-REPAIR: with no proven participant, two spellings survive dedupe and then COLLIDE in retention', () => { const survivors = I.dedupeProps(offerings.map((o) => ({ ...o })), 1500); expect(survivors).toHaveLength(2); // dedupe says DISTINCT const m = expectedMaterialization(retentionRows(survivors)); expect(m.expected_identities).toBe(2); // retention says SAME expect(m.collision_count).toBeGreaterThan(0); // the 19:00 cohort's shape }); test('POST-REPAIR: the 40-man index proves the human and the two spellings are ONE proposition before grading', async () => { const built = await indexFor({ active: [], forty: [] }, { active: [], forty: [GASPER] }); const resolved = offerings.map((o) => { const who = evid.resolveParticipant(o, GAMES, built.persons); return { ...o, mlb_person_id: who && who.personId, canonical_player_name: who && who.canonicalName }; }); expect(resolved.every((r) => r.mlb_person_id === 681508)).toBe(true); const survivors = I.dedupeProps(resolved, 1500); expect(survivors).toHaveLength(1); const m = expectedMaterialization(retentionRows(survivors)); expect(m.collision_count).toBe(0); }); test('the 40-man is what does it — an ACTIVE-only index cannot identify him', async () => { const activeOnly = await evid.buildPlayerTeamIndex(GAMES, { asOfDate: '2026-08-30', getTeamRoster: async () => [], }); expect(evid.resolveParticipant(offerings[0], GAMES, activeOnly.persons)).toBeNull(); }); }); describe('STEP 7 — Leo/Leonardo split (one human, two identities)', () => { const offerings = [ prop('Leo Jiménez', 'hits', 0.5, 'betmgm'), prop('Leonardo Jimenez', 'hits', 0.5, 'draftkings'), ]; test('PRE-REPAIR: one human yields TWO semantic player identities', () => { const keys = new Set(retentionRows(offerings).map((r) => r.player_key)); expect(keys.size).toBe(2); expect([...keys].sort()).toEqual(['leo jimenez', 'leonardo jimenez']); }); test('POST-REPAIR: both spellings resolve to person 677870 and to ONE identity', async () => { const built = await indexFor({ active: [JIMENEZ], forty: [] }, EMPTY); const resolved = offerings.map((o) => { const who = evid.resolveParticipant(o, GAMES, built.persons); return { ...o, mlb_person_id: who && who.personId, canonical_player_name: who && who.canonicalName }; }); expect(resolved.map((r) => r.mlb_person_id)).toEqual([677870, 677870]); const keys = new Set(retentionRows(resolved).map((r) => r.player_key)); expect(keys.size).toBe(1); // Converges onto the key that ALREADY existed, so no new lineage family. expect([...keys]).toEqual(['leo jimenez']); }); test('the split guard sees what collision_count cannot', () => { const split = pid.auditParticipantIdentity([ { canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leo jimenez' }, { canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leonardo jimenez' }, ]); expect(split.participant_identity_split_count).toBe(1); expect(split.participant_identity_merge_count).toBe(0); const fixed = pid.auditParticipantIdentity([ { canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leo jimenez' }, { canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leo jimenez' }, ]); expect(fixed.participant_identity_split_count).toBe(0); }); }); describe('STEP 8 — two humans must never merge', () => { test('nickName is excluded: it collapses Burch Smith and Will Smith onto one key', () => { // MLB publishes 'Smitty' as the nickname of BOTH. Were nickName a name // form, these two humans would share an identity. expect(nameKey('Smitty Smith')).toBe(nameKey('Smitty Smith')); const a = pid.participantNameForms(BURCH); const b = pid.participantNameForms(WILL); const shared = [...a].filter((k) => b.has(k)); expect(shared).toEqual([]); }); test('a name key matching two people in one event FAILS CLOSED — never a guess', async () => { const twin = { ...WILL, id: 999999, fullName: 'Burch Smith', firstName: 'Burch', useName: 'Burch' }; const built = await indexFor({ active: [BURCH] }, { active: [twin] }); const who = evid.resolveParticipant(prop('Burch Smith', 'hits', 0.5, 'betmgm'), GAMES, built.persons); expect(who).toBeNull(); }); test('the merge guard reports one identity standing for two humans', () => { const merged = pid.auditParticipantIdentity([ { canonical_event_id: EV, mlb_person_id: 572143, player_key: 'burch smith' }, { canonical_event_id: EV, mlb_person_id: 669257, player_key: 'burch smith' }, ]); expect(merged.participant_identity_merge_count).toBe(1); }); test('a merged identity is REFUSED at the admission seam, not graded', () => { const p = prop('Burch Smith', 'hits', 0.5, 'betmgm'); p.event_binding_status = 'RESOLVED'; p.participant_identity_conflict = true; const gate = gs.__internals.admitForGrading([p], 'mlb'); expect(gate.admitted).toHaveLength(0); expect(gate.reasons.PARTICIPANT_IDENTITY_MERGE).toBe(1); }); }); describe('STEP 4 — unresolved participants keep their old behaviour', () => { test('no proven participant -> raw spelling identity, unchanged', () => { const id = pid.semanticPlayerKey({ player: 'Someone Unknown' }); expect(id.canonical).toBe(false); expect(id.key).toBe(nameKey('Someone Unknown')); }); test('resolution is never required — an unresolved prop still grades', async () => { const built = await indexFor(EMPTY, EMPTY); const p = prop('Someone Unknown', 'hits', 0.5, 'betmgm'); expect(evid.resolveParticipant(p, GAMES, built.persons)).toBeNull(); p.event_binding_status = 'RESOLVED'; expect(gs.__internals.admitForGrading([p], 'mlb').admitted).toHaveLength(1); }); test('unproven rows are excluded from both guards rather than counted as clean', () => { const a = pid.auditParticipantIdentity([ { canonical_event_id: EV, mlb_person_id: null, player_key: 'a' }, { canonical_event_id: EV, mlb_person_id: null, player_key: 'b' }, ]); expect(a.proven_rows).toBe(0); expect(a.unproven_rows).toBe(2); expect(a.participant_identity_split_count).toBe(0); }); }); describe('STEP 5 — namesake safety depends on EVENT SCOPE', () => { test('the same name key in two different games resolves to each game\'s own human', async () => { const twin = { ...GASPER, id: 111111 }; const g2 = [{ gamePk: 900003, home: { teamId: 121, team: 'New York Mets' }, away: { teamId: 143, team: 'Philadelphia Phillies' } }]; const b1 = await indexFor(EMPTY, { active: [], forty: [GASPER] }); const b2 = await evid.buildPlayerTeamIndex(g2, { asOfDate: '2026-08-30', getTeamRoster: async () => [], getTeamRoster40: async () => [twin], }); const w1 = evid.resolveParticipant(prop('Michael Gasper', 'hits', 0.5, 'betmgm'), GAMES, b1.persons); const w2 = evid.resolveParticipant({ player: 'Michael Gasper', canonical_event_id: 'mlb:gamepk:900003' }, g2, b2.persons); expect(w1.personId).toBe(681508); expect(w2.personId).toBe(111111); }); }); describe('STEP 24 — doubleheader: one person, two events, two propositions', () => { test('identity is stable across gamePks while propositions stay distinct', async () => { const built = await evid.buildPlayerTeamIndex(GAMES_DH, { asOfDate: '2026-08-30', getTeamRoster: async () => [], getTeamRoster40: async () => [GASPER], }); const mk = (pk) => ({ player: 'Michael Gasper', stat_type: 'hits', line: 0.5, book: 'betmgm', canonical_event_id: `mlb:gamepk:${pk}`, event_identity_method: 'CANONICAL' }); const a = mk(900001); const b = mk(900002); const wa = evid.resolveParticipant(a, GAMES_DH, built.persons); const wb = evid.resolveParticipant(b, GAMES_DH, built.persons); expect(wa.personId).toBe(wb.personId); // same human const survivors = I.dedupeProps( [{ ...a, mlb_person_id: wa.personId }, { ...b, mlb_person_id: wb.personId }], 1500, ); expect(survivors).toHaveLength(2); // two propositions }); }); describe('STEP 16 — the retained representative is not decided by position', () => { const offerings = [ prop('Mickey Gasper', 'hits', 0.5, 'pinnacle', { over_odds: -105, under_odds: -115 }), prop('Mickey Gasper', 'hits', 0.5, 'draftkings', { over_odds: -110, under_odds: -110 }), prop('Mickey Gasper', 'hits', 0.5, 'betmgm', { over_odds: -120, under_odds: 100 }), ].map((o) => ({ ...o, mlb_person_id: 681508, canonical_player_name: 'Mickey Gasper' })); const permutations = [[0, 1, 2], [2, 1, 0], [1, 2, 0], [0, 2, 1], [2, 0, 1], [1, 0, 2]]; test('every input permutation retains a byte-identical row', () => { const seen = new Set(); for (const perm of permutations) { const survivors = I.dedupeProps(perm.map((i) => ({ ...offerings[i] })), 1500); expect(survivors).toHaveLength(1); seen.add(JSON.stringify(retentionRows(survivors))); } expect(seen.size).toBe(1); }); test('the representative is chosen by the existing MODEL_BOOKS order, not by arrival', () => { const survivors = I.dedupeProps(permutations[1].map((i) => ({ ...offerings[i] })), 1500); // draftkings leads the existing MODEL_BOOKS declaration; pinnacle arrived first. expect(survivors[0].book).toBe('draftkings'); }); test('all reconstructive market fields are stable, not just the identity', () => { const payloads = permutations.map((perm) => { const s = I.dedupeProps(perm.map((i) => ({ ...offerings[i] })), 1500)[0]; return JSON.stringify([s.book, s.over_odds, s.under_odds, s.line]); }); expect(new Set(payloads).size).toBe(1); }); }); describe('preservation — the repair must not move anything else', () => { test('a player whose provider spelling already matches MLB keeps his key', () => { const before = pid.semanticPlayerKey({ player: 'Aaron Judge' }); const after = pid.semanticPlayerKey({ player: 'Aaron Judge', mlb_person_id: 592450, canonical_player_name: 'Aaron Judge' }); expect(after.key).toBe(before.key); }); test('team evidence still reads the ACTIVE roster only, so admission is unchanged', async () => { const built = await indexFor({ active: [], forty: [GASPER] }, EMPTY); // On the 40-man but not active: identifiable as a participant... expect(built.persons.get('michael gasper')).toBeTruthy(); // ...and still absent from the team-evidence index that drives the // impossible-binding refusal, exactly as before this change. expect(built.index.get('michael gasper')).toBeUndefined(); }); test('the raw provider spelling is never mutated on the prop', async () => { const built = await indexFor(EMPTY, { active: [], forty: [GASPER] }); const p = prop('Michael Gasper', 'hits', 0.5, 'draftkings'); const who = evid.resolveParticipant(p, GAMES, built.persons); p.mlb_person_id = who.personId; p.canonical_player_name = who.canonicalName; expect(p.player).toBe('Michael Gasper'); }); }); describe('retention history is APPEND-ONLY (the tooth-12 hole)', () => { /** * `ignoreDuplicates: true` IS the "history is never rewritten" property. It * makes a re-run of a cycle a no-op on rows that already exist, so the claim * a user was served can never be overwritten by a later pass. Flipping it to * false silently converts every retry into an overwrite — and nothing * asserted it, which a teeth run found by coming back green. */ test('persist upserts with ignoreDuplicates — a re-run can never overwrite a stored claim', async () => { const seen = []; const supabase = { from() { return { upsert(rows, opts) { seen.push({ n: rows.length, opts }); return Promise.resolve({ error: null }); } }; }, }; const rows = Array.from({ length: 3 }, (_, i) => ({ snapshot_id: 's', game_id: 'g', canonical_event_id: 'e', player_key: `p${i}`, stat: 'hits', line: 0.5, side: 'over', })); const out = await rs.persist(rows, { getClient: () => supabase }); expect(out.error).toBeNull(); expect(seen.length).toBeGreaterThan(0); for (const call of seen) { expect(call.opts.ignoreDuplicates).toBe(true); expect(call.opts.onConflict).toBe(rs.RETENTION_CONFLICT); } }); });