From f7cc19772b6c5b0093dd9d0c26192eba73793c67 Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 28 Aug 2026 12:54:35 -0400 Subject: [PATCH] Canonical MLB participant: prove the human, then dedupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VYNDR had three definitions of "same player": raw p.player (dedupe), utils/normalize.normalizeName (features, NO nicknames), 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. The fix is not a better spelling. The event-scoped roster ALREADY carries the StatsAPI personId; buildPlayerTeamIndex was fetching and discarding it. SCOPE IS LOAD-BEARING, and this is the finding that shaped the design. Measured on the real 2026-08-28 league rosters (1,583 rows, 30 teams): a bare name key is NOT globally unique — `max muncy` (ATH 691777 / LAD 571970), `jose fermin` (665877/820862) and `luis garcia` (472610/671277) each resolve to TWO different humans. Team-scoped: 0 ambiguous. Event-scoped across 33 real events including the verified doubleheader date: 0 ambiguous. So resolution is scoped to the two teams actually playing, and FAILS CLOSED — no event, no game, no candidate, or more than one candidate yields null and the prop keeps prior behaviour. FEATURE IDENTITY PARITY, measured on both real cohorts before writing the patch (504 participants): RAW_SUCCESS/CANONICAL_SUCCESS/SAME id 365 RAW_SUCCESS/CANONICAL_SUCCESS/DIFFERENT id 0 <- required 0 RAW_SUCCESS/CANONICAL_FAIL 0 <- required 0 CANONICAL_AMBIGUOUS 0 <- required 0 RAW_FAIL/CANONICAL_SUCCESS 7 <- repair, not regression The seven improvements are exactly the alias class: Michael->Mickey Gasper, AJ->A.J. Ewing, JT->J.T. Realmuto, Mike->Michael Busch, Richard->Richie Palacios. This is why raw spelling could not be trusted: player_id_map holds `mickey gasper` and NOT `michael gasper`, so under the raw name the lookup outcome depended on which alias happened to survive dedupe. Provider arrival order was deciding model input availability. ALL SEVEN COLLISION GROUPS PROVEN SAME_PLAYER_ALIAS against the authoritative roster for the exact event date — one personId each (681715, 699625, 676356, 695491, 673357, 681508, 671739), zero false normalizations, zero unresolved — and all seven collapse to one participant under the new key, so the 14 colliding propositions merge upstream instead of being discarded downstream. FALSE-MERGE SIMULATION on both cohorts: cross-event 0, different-stat 0, different-line 0, false merges 0. Note honestly: the retained rows cannot exhibit the merges themselves, because retention already discarded the losers — so the merge half is proven directly on the alias groups, the safety half on the cohorts. RETENTION IS UNTOUCHED — schema, player_key, conflict identity and the collision counter are all unchanged. collision_count must reach 0 by upstream repair, never by making the counter lenient. A teeth proof injects raw spelling into the retention identity and the suite rejects it. Source provenance preserved: p.player is never overwritten; the participant rides beside it as mlb_person_id + canonical_player_name. Twelve teeth against a green baseline of 117 — dedupe back to raw name (3), feature lookup back to the alias (2), resolver stops blocking ambiguity (1), lookup degraded to raw (1), event scope dropped (1), event/line/stat dropped from the key (2/2/2), provenance destroyed (1), raw spelling in retention identity (2), collision_count accepted nonzero (2), and a guard proving the frozen game-date repair cannot be reverted (10). Restored byte-identically. Teeth #4 (canonical resolving to a DIFFERENT feature id) is covered by the real-data parity measurement rather than an injected branch, because it is a data comparison, not a code path — stated plainly rather than claimed as a test. No hardcoded player names in production code — a test greps for all seven. 4 files, 86 insertions. retentionService, gameBinder, oddsService, acquisitionTrace, probabilityEstimator, bookRoles, playerName, normalize and snapshotScheduler: UNCHANGED. Admission rules, MODEL_BOOK filters and model formulas: 0 changed lines. The game-date repair is intact. 391 suites / 5,339 tests pass. web tsc exit 0. Lineage stays OFF. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8 --- src/services/event/eventIdentity.js | 52 +++- src/services/gradeSlateService.js | 8 +- src/services/intelligence/computeFeatures.js | 12 +- src/services/snapshotService.js | 21 +- tests/unit/canonicalParticipant.test.js | 255 +++++++++++++++++++ 5 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 tests/unit/canonicalParticipant.test.js diff --git a/src/services/event/eventIdentity.js b/src/services/event/eventIdentity.js index 115b6d9..3048930 100644 --- a/src/services/event/eventIdentity.js +++ b/src/services/event/eventIdentity.js @@ -222,8 +222,9 @@ async function buildPlayerTeamIndex(games, deps) { const getRoster = d.getTeamRoster; const asOfDate = d.asOfDate || null; const index = new Map(); + const persons = new Map(); const out = { teams: 0, players: 0, failed: 0, as_of_date: asOfDate, date_scoped: false }; - if (typeof getRoster !== 'function') return { index, stats: out }; + if (typeof getRoster !== 'function') return { index, persons, stats: out }; const ids = new Map(); for (const g of Array.isArray(games) ? games : []) { @@ -244,15 +245,28 @@ async function buildPlayerTeamIndex(games, deps) { const abbr = teamAbbr(name); if (!abbr) continue; for (const p of roster) { - const k = nameKey(p && (p.name || p.fullName || p.player_name)); + const canonicalName = p && (p.name || p.fullName || p.player_name); + const k = nameKey(canonicalName); if (!k) continue; if (!index.has(k)) index.set(k, new Set()); index.get(k).add(abbr); + // CANONICAL PARTICIPANT. The roster row already carries the StatsAPI + // personId; it was discarded here, which is why the pipeline had no stable + // player identity and fell back to provider spellings. Candidates are kept + // PER TEAM because a bare name key is NOT globally unique — measured on the + // real 2026-08-28 league rosters, `max muncy`, `jose fermin` and + // `luis garcia` each resolve to TWO different people. + const personId = p && (p.id != null ? p.id : p.personId); + if (personId != null && abbr) { + if (!persons.has(k)) persons.set(k, []); + persons.get(k).push({ personId, canonicalName, abbr }); + } out.players += 1; } } out.date_scoped = Boolean(asOfDate) && out.teams > 0; - return { index, stats: out }; + out.persons = persons.size; + return { index, persons, stats: out }; } /** @@ -366,6 +380,37 @@ function resolveEvent(sport, prop, games, playerTeams, evidenceDateValid) { * Attach identity to a slate's props IN PLACE, returning counts so a silent * resolution collapse is visible rather than quietly reintroducing collisions. */ +/** + * CANONICAL MLB PARTICIPANT, resolved within the prop's OWN event. + * + * Scope is the whole point. A bare normalized name is not globally unique — + * measured on the real 2026-08-28 rosters, three keys map to two different + * humans each (max muncy, jose fermin, luis garcia). Scoped to the two teams + * actually playing this game, every key across 33 real events — including the + * verified doubleheader date — resolved to exactly one person. + * + * FAILS CLOSED. No event, no matching game, no candidate, or more than one + * candidate all yield null. A prop without a proven participant keeps its + * existing behaviour and is never given a guessed identity. + */ +function resolveParticipant(prop, games, persons) { + if (!prop || !persons || persons.size === 0) return null; + const evId = prop.canonical_event_id; + if (!evId) return null; + const list = Array.isArray(games) ? games : []; + const game = list.find((g) => g && canonicalEventId('mlb', g.gamePk) === evId); + if (!game) return null; + const abbrs = new Set([ + teamAbbr(game.home && game.home.team), + teamAbbr(game.away && game.away.team), + ].filter(Boolean)); + if (abbrs.size === 0) return null; + const cands = (persons.get(nameKey(prop.player)) || []).filter((c) => abbrs.has(c.abbr)); + const ids = new Set(cands.map((c) => c.personId)); + if (ids.size !== 1) return null; // 0 = unknown, >1 = ambiguous. Never guess. + return { personId: cands[0].personId, canonicalName: cands[0].canonicalName, teamAbbr: cands[0].abbr }; +} + function attachEventIdentity(sport, props, games, playerTeams, evidenceDateValid) { const list = Array.isArray(props) ? props : []; const out = { total: list.length, canonical: 0, unresolved: 0, unsupported: 0, impossible: 0, reasons: {} }; @@ -392,6 +437,7 @@ function attachEventIdentity(sport, props, games, playerTeams, evidenceDateValid } module.exports = { + resolveParticipant, resolveEvent, resolveMlbEvent, attachEventIdentity, canonicalEventId, teamsMatch, teamAbbr, checkParticipantEvidence, buildPlayerTeamIndex, evidenceIsDateValid, IDENTITY_SOURCE, IDENTITY_METHOD, IDENTITY_VERSION, SUPPORTED, BINDING_STATUS, diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index beb4f0e..cc17162 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -222,7 +222,13 @@ function dedupeProps(props, limit, stats) { examined += 1; if (!p || !p.player || !p.stat_type || p.line == null) { bump('dropped_invalid_fields'); continue; } if (!isModelBook(p.book)) { bump('dropped_non_model_book'); continue; } - const key = `${propositionEventKey(p)}::${p.player}::${p.stat_type}::${p.line}`; + // SEMANTIC PARTICIPANT, not display name. Two provider spellings of the + // same proven athlete in the same event are ONE proposition; retention used + // to be the first layer to notice, and could only discard the loser. Falls + // back to the raw name when no participant was proven, so unsupported + // sports and unresolved props behave exactly as before. + const who = p.mlb_person_id != null ? `mlb:${p.mlb_person_id}` : p.player; + const key = `${propositionEventKey(p)}::${who}::${p.stat_type}::${p.line}`; if (seen.has(key)) { bump('duplicate_identity_removed'); continue; } seen.add(key); out.push(p); diff --git a/src/services/intelligence/computeFeatures.js b/src/services/intelligence/computeFeatures.js index 2a303ef..5753fe3 100644 --- a/src/services/intelligence/computeFeatures.js +++ b/src/services/intelligence/computeFeatures.js @@ -253,6 +253,14 @@ async function computeFeaturesForProp(rawProp = {}) { const errors = []; const player = rawProp.player; + // FEATURE IDENTITY. Provider alias choice must not decide whether a proven + // athlete resolves. `player_id_map` holds `mickey gasper` and NOT + // `michael gasper`, so with the raw spelling the lookup depended on which + // alias happened to survive. When event-scoped roster resolution proved the + // participant, use the canonical roster name; otherwise the raw name, exactly + // as before. No formula changes — only which truthful representation is + // handed to the existing lookup. + const featurePlayer = rawProp.canonical_player_name || player; const statType = rawProp.stat_type || rawProp.statType; const line = Number(rawProp.line); const direction = rawProp.direction || 'over'; @@ -262,7 +270,7 @@ async function computeFeaturesForProp(rawProp = {}) { errors.push('missing required fields (player, stat_type, or line)'); } - const roster = await lookupPlayer({ player, sport }); + const roster = await lookupPlayer({ player: featurePlayer, sport }); if (!roster) errors.push('player_not_found_in_id_map'); const teamAbbr = roster?.team_abbr ?? null; @@ -285,7 +293,7 @@ async function computeFeaturesForProp(rawProp = {}) { // Session 63 — fetch the normalized per-game rows ONCE. They feed three // consumers that were all starving: the consistency factor, the probability // estimator (via meta.gameLogs), and game_count_in_7d below. - const statRows = await featureCache.getStatRows(player, sport, statType); + const statRows = await featureCache.getStatRows(featurePlayer, sport, statType); const gameContext = { home_away: game ? (game.isHome ? 'home' : 'away') : null, diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 7de5945..1d4d2a5 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -565,8 +565,9 @@ async function runSnapshot(sport, opts = {}) { const slateDate = dates.length === 1 ? dates[0] : null; let playerTeams = null; let evidenceDateValid = false; + let built = null; try { - const built = await evid.buildPlayerTeamIndex(games, { + built = await evid.buildPlayerTeamIndex(games, { getTeamRoster: (id, asOf) => mlbAdapter.getTeamRoster(id, undefined, asOf), asOfDate: slateDate, }); @@ -588,6 +589,23 @@ async function runSnapshot(sport, opts = {}) { // Without date-valid evidence a conflict can only be UNRESOLVED, never // CONTRADICTED — uncertainty must not become an accusation. const e = evid.attachEventIdentity(sp, props, games, playerTeams, evidenceDateValid); + // CANONICAL PARTICIPANT. Resolved AFTER event identity, because the + // resolution is event-scoped: a bare name key is not globally unique + // (max muncy / jose fermin / luis garcia each map to two real people), + // but within the two teams of one game it is. Fails closed — an + // unresolved prop simply carries no participant id and behaves exactly + // as before. The raw provider name is never overwritten. + let participants = 0; + if (built && built.persons) { + for (const pr of props) { + const who = evid.resolveParticipant(pr, games, built.persons); + if (!who) continue; + pr.mlb_person_id = who.personId; + pr.canonical_player_name = who.canonicalName; + participants += 1; + } + } + console.log(`[snapshot] canonical participants ${sp}: ${participants}/${props.length}`); pgRec.identity({ started: true, completed: true, threw: false, schedule_games: games.length, dates_requested: dates.length, @@ -595,6 +613,7 @@ async function runSnapshot(sport, opts = {}) { total: e.total, canonical: e.canonical, unresolved: e.unresolved, unsupported: e.unsupported, contradicted: e.impossible, reasons: { ...e.reasons }, + participants_resolved: participants, }); console.log(`[snapshot] event identity ${sp}: ${e.canonical}/${e.total} canonical, ` + `${e.unresolved} unresolved, ${e.impossible} contradicted` diff --git a/tests/unit/canonicalParticipant.test.js b/tests/unit/canonicalParticipant.test.js new file mode 100644 index 0000000..2533a53 --- /dev/null +++ b/tests/unit/canonicalParticipant.test.js @@ -0,0 +1,255 @@ +'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 and its own player_key', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8'); + expect(src).toMatch(/player_key: nameKey\(player\)/); + expect(src).toMatch(/player_name: normalizeName\(player\)\.display \|\| player/); + }); +}); + +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;/); + }); +});