'use strict'; /** * CANONICAL MLB PARTICIPANT IDENTITY. * * VYNDR had three definitions of "same player": raw `p.player` (dedupe), * `utils/normalize.normalizeName` (features, NO nickname handling), and * `utils/playerName.nameKey` (retention, WITH nicknames). Retention was the * first layer to notice they disagreed, and all it could do was discard the * loser — 28 collisions in the blocked controlled cohort. * * The fix is not a better spelling. The event-scoped roster already carries the * StatsAPI personId; it was being thrown away. Scope is load-bearing: measured * on the real 2026-08-28 league rosters, `max muncy`, `jose fermin` and * `luis garcia` each resolve to TWO different people, so a bare name key is NOT * globally unique. Within one game's two rosters, all 33 real events tested * (including the verified doubleheader date) resolved uniquely. */ const fs = require('fs'); const path = require('path'); const evid = require('../../src/services/event/eventIdentity'); const { nameKey } = require('../../src/utils/playerName'); const { normalizeName: featNorm } = require('../../src/utils/normalize'); const { __internals } = require('../../src/services/gradeSlateService'); const ROOT = path.resolve(__dirname, '..', '..'); const G = (pk, home, away) => ({ gamePk: pk, home: { team: home }, away: { team: away } }); const personsOf = (rows) => { const m = new Map(); for (const r of rows) { const k = nameKey(r.canonicalName); if (!m.has(k)) m.set(k, []); m.get(k).push(r); } return m; }; const prop = (o = {}) => ({ player: 'P', stat_type: 'hits', line: 0.5, book: 'draftkings', ...o }); describe('SCOPE IS LOAD-BEARING — a name key is not a player id', () => { // Real league data: two different humans, one normalized key. const persons = personsOf([ { personId: 691777, canonicalName: 'Max Muncy', abbr: 'ATH' }, { personId: 571970, canonicalName: 'Max Muncy', abbr: 'LAD' }, ]); const games = [G(1, 'Los Angeles Dodgers', 'San Diego Padres'), G(2, 'Athletics', 'Los Angeles Dodgers')]; test('one team in the event -> resolves to that team’s person', () => { const r = evid.resolveParticipant(prop({ player: 'Max Muncy', canonical_event_id: 'mlb:gamepk:1' }), games, persons); expect(r.personId).toBe(571970); expect(r.teamAbbr).toBe('LAD'); }); test('BOTH Muncys in the same event -> REFUSES, never guesses', () => { expect(evid.resolveParticipant(prop({ player: 'Max Muncy', canonical_event_id: 'mlb:gamepk:2' }), games, persons)).toBeNull(); }); test('no event, no game, or unknown player -> null (fails closed)', () => { expect(evid.resolveParticipant(prop({ player: 'Max Muncy' }), games, persons)).toBeNull(); expect(evid.resolveParticipant(prop({ player: 'Max Muncy', canonical_event_id: 'mlb:gamepk:99' }), games, persons)).toBeNull(); expect(evid.resolveParticipant(prop({ player: 'Nobody Here', canonical_event_id: 'mlb:gamepk:1' }), games, persons)).toBeNull(); }); test('an empty persons index never resolves', () => { expect(evid.resolveParticipant(prop({ player: 'Max Muncy', canonical_event_id: 'mlb:gamepk:1' }), games, new Map())).toBeNull(); }); }); describe('ALIAS CLASSES — generic, with the seven as regression evidence', () => { const CASES = [ ['accent', 'Heriberto Hernández', 'Heriberto Hernandez', 681715, 'MIA', 'Miami Marlins'], ['suffix', 'Luis Robert Jr.', 'Luis Robert', 673357, 'NYM', 'New York Mets'], ['case', 'Jonny DeLuca', 'Jonny Deluca', 676356, 'TB', 'Tampa Bay Rays'], ['suffix II', 'Michael Harris II', 'Michael Harris', 671739, 'ATL', 'Atlanta Braves'], ['nickname', 'Mickey Gasper', 'Michael Gasper', 681508, 'BOS', 'Boston Red Sox'], ['formal', 'Joshua Báez', 'Josh Baez', 695491, 'STL', 'St. Louis Cardinals'], ['nick+suf', 'Jimmy Crooks', 'Jimmy Crooks III', 699625, 'STL', 'St. Louis Cardinals'], ]; for (const [label, canonical, alias, personId, abbr, teamName] of CASES) { test(`${label}: "${alias}" resolves to the same person as "${canonical}"`, () => { const persons = personsOf([{ personId, canonicalName: canonical, abbr }]); const games = [G(7, teamName, 'Chicago Cubs')]; const a = evid.resolveParticipant(prop({ player: alias, canonical_event_id: 'mlb:gamepk:7' }), games, persons); const c = evid.resolveParticipant(prop({ player: canonical, canonical_event_id: 'mlb:gamepk:7' }), games, persons); expect(a).not.toBeNull(); expect(a.personId).toBe(personId); expect(c.personId).toBe(personId); expect(a.canonicalName).toBe(canonical); }); } test('the fix is GENERIC — no player name is hardcoded in production code', () => { for (const f of ['src/services/event/eventIdentity.js', 'src/services/gradeSlateService.js', 'src/services/intelligence/computeFeatures.js', 'src/services/snapshotService.js']) { const src = fs.readFileSync(path.join(ROOT, f), 'utf8') .replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); for (const name of ['Gasper', 'Hernández', 'Hernandez', 'Baez', 'Crooks', 'DeLuca', 'Muncy']) { expect(src).not.toContain(name); } } }); }); describe('DEDUPE BY PARTICIPANT, NOT DISPLAY NAME', () => { const dd = (rows) => __internals.dedupeProps(rows, 1000); const base = { stat_type: 'hits', line: 0.5, book: 'draftkings', canonical_event_id: 'mlb:gamepk:7' }; test('two aliases of ONE proven participant collapse to ONE proposition', () => { const out = dd([ { ...base, player: 'Michael Gasper', mlb_person_id: 681508 }, { ...base, player: 'Mickey Gasper', mlb_person_id: 681508 }, ]); expect(out).toHaveLength(1); }); test('the SAME person in TWO events stays TWO propositions', () => { const out = dd([ { ...base, player: 'Mickey Gasper', mlb_person_id: 681508, canonical_event_id: 'mlb:gamepk:824514' }, { ...base, player: 'Mickey Gasper', mlb_person_id: 681508, canonical_event_id: 'mlb:gamepk:824478' }, ]); expect(out).toHaveLength(2); }); test('same person, different STAT stays two', () => { expect(dd([ { ...base, player: 'A', mlb_person_id: 1 }, { ...base, player: 'A', mlb_person_id: 1, stat_type: 'total_bases' }, ])).toHaveLength(2); }); test('same person, different LINE stays two', () => { expect(dd([ { ...base, player: 'A', mlb_person_id: 1 }, { ...base, player: 'A', mlb_person_id: 1, line: 1.5 }, ])).toHaveLength(2); }); test('DIFFERENT people are never merged, even with one lossy name key', () => { const out = dd([ { ...base, player: 'Max Muncy', mlb_person_id: 691777 }, { ...base, player: 'Max Muncy', mlb_person_id: 571970 }, ]); expect(out).toHaveLength(2); }); test('unresolved props fall back to the raw name — prior behaviour exactly', () => { expect(dd([{ ...base, player: 'Michael Gasper' }, { ...base, player: 'Mickey Gasper' }])).toHaveLength(2); expect(dd([{ ...base, player: 'Same Guy' }, { ...base, player: 'Same Guy' }])).toHaveLength(1); }); test('the dedupe key uses the participant when present, the raw name otherwise', () => { const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8'); expect(src).toMatch(/const who = p\.mlb_person_id != null \? `mlb:\$\{p\.mlb_person_id\}` : p\.player;/); expect(src).toMatch(/\$\{propositionEventKey\(p\)\}::\$\{who\}::\$\{p\.stat_type\}::\$\{p\.line\}/); }); }); describe('FEATURE IDENTITY IS CANONICAL, NOT THE SURVIVING ALIAS', () => { const src = fs.readFileSync(path.join(ROOT, 'src/services/intelligence/computeFeatures.js'), 'utf8'); test('the lookup consumes the canonical roster name when proven', () => { expect(src).toMatch(/const featurePlayer = rawProp\.canonical_player_name \|\| player;/); expect(src).toMatch(/lookupPlayer\(\{ player: featurePlayer, sport \}\)/); expect(src).toMatch(/getStatRows\(featurePlayer, sport, statType\)/); }); test('this is exactly why raw spelling could not be trusted', () => { // player_id_map holds `mickey gasper`, NOT `michael gasper` (verified in // production). Under the raw name the lookup outcome depended on which // alias survived dedupe. expect(featNorm('Mickey Gasper')).toBe('mickey gasper'); expect(featNorm('Michael Gasper')).toBe('michael gasper'); expect(featNorm('Mickey Gasper')).not.toBe(featNorm('Michael Gasper')); // The retention normalizer DOES unify them — that disagreement is the bug. expect(nameKey('Mickey Gasper')).toBe(nameKey('Michael Gasper')); }); test('no canonical name -> the raw name, unchanged from before', () => { expect(src).toMatch(/rawProp\.canonical_player_name \|\| player/); }); }); describe('SOURCE PROVENANCE IS PRESERVED', () => { test('the raw provider name is never overwritten', () => { const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8'); const i = src.indexOf('CANONICAL PARTICIPANT. Resolved AFTER event identity'); const block = src.slice(i, i + 900); expect(block).toMatch(/pr\.mlb_person_id = who\.personId;/); expect(block).toMatch(/pr\.canonical_player_name = who\.canonicalName;/); expect(block).not.toMatch(/pr\.player\s*=/); }); test('retention still stores the SOURCE name', () => { const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8'); expect(src).toMatch(/player_name: normalizeName\(player\)\.display \|\| player/); }); /** * This assertion used to grep for `player_key: nameKey(player)`. * * That expression was standing in for a PROPERTY — that retention's collision * counter is an independent detector of the producer failing to collapse a * human — and a grep verifies the spelling instead of the property. The * semantic key now prefers the league's own record when the participant is * PROVEN, which is what stops one human becoming two identities. The property * the grep was protecting is asserted directly below, in both modes, and it * survives: when the producer emits two rows for one human, retention still * files them under one identity and still reports the collision. */ test('retention still catches the producer under-collapsing — participant PROVEN', () => { const retention = require('../../src/services/retentionService'); const rows = retention.rowsFromSides( { player: 'Mickey Gasper', stat_type: 'hits', line: 0.5, mlb_person_id: 681508, canonical_player_name: 'Mickey Gasper' }, [{ direction: 'over', grade: 'C' }], { snapshotId: 's', sport: 'mlb', gameDate: '2026-08-30', gameIdFor: () => 'g' }, ).concat(retention.rowsFromSides( { player: 'Michael Gasper', stat_type: 'hits', line: 0.5, mlb_person_id: 681508, canonical_player_name: 'Mickey Gasper' }, [{ direction: 'over', grade: 'C' }], { snapshotId: 's', sport: 'mlb', gameDate: '2026-08-30', gameIdFor: () => 'g' }, )); expect(retention.expectedMaterialization(rows).collision_count).toBe(1); }); test('retention still catches the producer under-collapsing — participant UNPROVEN', () => { const retention = require('../../src/services/retentionService'); const mk = (player) => retention.rowsFromSides( { player, stat_type: 'hits', line: 0.5 }, [{ direction: 'over', grade: 'C' }], { snapshotId: 's', sport: 'mlb', gameDate: '2026-08-30', gameIdFor: () => 'g' }, ); // No personId anywhere: the key falls back to the raw spelling exactly as // it always did, and the two spellings still meet on one identity. expect(retention.expectedMaterialization(mk('Mickey Gasper').concat(mk('Michael Gasper'))).collision_count).toBe(1); }); }); describe('RETENTION REMAINS AN INDEPENDENT CHECK', () => { const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8'); test('the conflict identity is unchanged and carries NO raw spelling', () => { const retention = require('../../src/services/retentionService'); expect(retention.RETENTION_CONFLICT.split(',')).toEqual( ['snapshot_id', 'game_id', 'canonical_event_id', 'player_key', 'stat', 'line', 'side']); expect(retention.RETENTION_CONFLICT).not.toMatch(/player_name|mlb_person_id/); }); test('the collision counter still exists and still disqualifies', () => { const retention = require('../../src/services/retentionService'); const rows = [ { snapshot_id: 's', game_id: 'g', canonical_event_id: 'e', player_key: 'a', stat: 'hits', line: 0.5, side: 'over' }, { snapshot_id: 's', game_id: 'g', canonical_event_id: 'e', player_key: 'a', stat: 'hits', line: 0.5, side: 'over' }, ]; const exp = retention.expectedMaterialization(rows); expect(exp.collision_count).toBe(1); expect(retention.reconcileMaterialization({ expected: exp, actualIdentities: exp.identities, transportStatus: retention.TERMINAL.COMPLETE, }).status).toBe(retention.MATERIALIZATION.COLLISION); expect(src).toMatch(/collision_count/); }); }); describe('THE THREE-NORMALIZER CONTRACT', () => { test('semantic participant identity is independent of every name normalizer', () => { const persons = personsOf([{ personId: 681508, canonicalName: 'Mickey Gasper', abbr: 'BOS' }]); const games = [G(7, 'Boston Red Sox', 'New York Yankees')]; const a = evid.resolveParticipant(prop({ player: 'Michael Gasper', canonical_event_id: 'mlb:gamepk:7' }), games, persons); const b = evid.resolveParticipant(prop({ player: 'Mickey Gasper', canonical_event_id: 'mlb:gamepk:7' }), games, persons); // Same identity despite the raw strings differing AND the feature // normalizer disagreeing about them. expect(a.personId).toBe(b.personId); expect(featNorm('Michael Gasper')).not.toBe(featNorm('Mickey Gasper')); // And the id is not derived from a name at all. expect(typeof a.personId).toBe('number'); }); test('the normalizers keep their existing narrow purposes', () => { const rs = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8'); expect(rs).toMatch(/nameKey/); const cf = fs.readFileSync(path.join(ROOT, 'src/services/intelligence/computeFeatures.js'), 'utf8'); expect(cf).toMatch(/normalizeName/); // Neither was globally replaced by the other. expect(cf).not.toMatch(/require\(.*playerName.*\)/); }); }); describe('THE GAME-DATE REPAIR IS UNTOUCHED', () => { test('the fast-path date assignment is still present', () => { const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8'); expect(src).toMatch(/if \(!p\.game_date\) p\.game_date = etFast;/); expect(src).toMatch(/const etFast = p\.game_time \? etDate\(p\.game_time\) : null;/); }); });