/** * PARTICIPANT IDENTITY — one proven MLB human, one semantic identity. * * THREE DISTINCT CONCEPTS, never conflated: * * RAW PROVIDER IDENTITY the spelling a book published. Evidence, never * identity. Preserved verbatim in * `closing_captures.player_name` (per book, per * cycle, undeduped) — this module never mutates it. * CANONICAL PARTICIPANT MLB StatsAPI `personId`. The strongest identity * we can hold, and the ONLY one derived from the * league's own record rather than a vendor's. * SEMANTIC PRODUCT PLAYER `player_key` — what retention, the ledger and * (later) lineage key on. Derived from the * canonical participant's own name WHEN PROVEN, * and from the raw spelling only when it is not. * * The defect this repairs ran in BOTH directions on one real slate: * two spellings of ONE human survived dedupe separately (under-collapse), and * ONE human produced TWO player_keys (split). `collision_count` can only see * the first — a split makes MORE identities, not fewer. * * NAME FORMS ARE READ OFF MLB'S OWN PERSON RECORD. There is no alias table * here and there must never be one: an alias table is a list of the mistakes * we happened to notice. */ const { nameKey } = require('../../utils/playerName'); /** * The name forms a person may legitimately be published under, taken from * StatsAPI's own fields. * * `nickName` is DELIBERATELY EXCLUDED and this is load-bearing. Measured over * 821 people across the 9 games of 2026-08-30, adding `nickName` produced 14 * ambiguous keys — MLB's nickname field carries bare surnames ("Jones", * "Wilson") and shared clubhouse names: `nameKey('Smitty Smith')` is the same * string for Burch Smith (572143) and Will Smith (669257). The four forms kept * here produced ZERO ambiguous keys over the same population. */ const NAME_FORM_FIELDS = Object.freeze(['full', 'first_last', 'use_last', 'use_uselast']); function participantNameForms(person) { const p = person || {}; const last = p.lastName || ''; const raw = [ p.fullName, `${p.firstName || ''} ${last}`, `${p.useName || ''} ${last}`, `${p.useName || ''} ${p.useLastName || last}`, ]; const out = new Set(); for (const r of raw) { const k = nameKey(r); if (k) out.add(k); } return out; } /** * The semantic product identity for a proposition. * * PROVEN participant -> derived from the participant's own canonical name, so * every provider spelling of that human converges on one key. * UNPROVEN -> the raw spelling, exactly as before. We never guess: an * unresolved participant keeps behaving the way it always has. */ function semanticPlayerKey(prop) { const p = prop || {}; const canonical = p.mlb_person_id != null ? p.canonical_player_name : null; const source = canonical || p.player; return { key: nameKey(source), display_source: source, canonical: !!canonical }; } /** * STEP 3 INVARIANT, BOTH DIRECTIONS, within one canonical event. * * SPLIT one proven personId -> more than one semantic key. Expected 0. * MERGE one semantic key -> more than one proven personId. Expected 0. * * Rows whose participant is unproven are EXCLUDED from both counts. They carry * no personId, so neither statement can be made about them — counting them * would be asserting something we do not know. */ function auditParticipantIdentity(rows) { const list = Array.isArray(rows) ? rows : []; const byPerson = new Map(); const byKey = new Map(); let proven = 0; let unproven = 0; for (const r of list) { if (!r) continue; const ev = r.canonical_event_id || null; const pid = r.mlb_person_id; const key = r.player_key; if (pid == null || !ev || !key) { unproven += 1; continue; } proven += 1; const pk = `${ev}|${pid}`; const kk = `${ev}|${key}`; if (!byPerson.has(pk)) byPerson.set(pk, new Set()); byPerson.get(pk).add(key); if (!byKey.has(kk)) byKey.set(kk, new Set()); byKey.get(kk).add(pid); } const splits = []; for (const [k, s] of byPerson) { if (s.size > 1) { const [event_id, person_id] = k.split('|'); splits.push({ event_id, person_id, player_keys: [...s].sort() }); } } const merges = []; for (const [k, s] of byKey) { if (s.size > 1) { const i = k.indexOf('|'); merges.push({ event_id: k.slice(0, i), player_key: k.slice(i + 1), person_ids: [...s].sort() }); } } return { proven_rows: proven, unproven_rows: unproven, participant_identity_split_count: splits.length, participant_identity_merge_count: merges.length, splits, merges, }; } module.exports = { NAME_FORM_FIELDS, participantNameForms, semanticPlayerKey, auditParticipantIdentity, };