One human, one semantic identity — MLB participant convergence

The collision autopsy left two unrepaired defects, running in OPPOSITE
directions, and `outbound_collision_count` can only ever see one of them.

UNDER-COLLAPSE. Dedupe keys on `mlb:<personId>` when the participant is
proven and on the RAW PROVIDER SPELLING when it is not. Mickey Gasper
(681508) is on Boston's 40-man and not on its active roster, so an
active-only index could not identify him and every book's spelling of him
survived dedupe as its own proposition — retention was the first layer to
notice, far too late, and could only discard the loser.

SPLIT. The mirror image, and invisible to the collision metric because it
makes MORE identities, not fewer: Leo Jiménez (677870) is published as both
"Leo Jiménez" and "Leonardo Jimenez", so one human became two semantic
players in one game. Measured across the 15 MLB cohort slices since the
canonical-participant repair, this is a recurring class, not one case:
cam/cameron smith (5 slices), mitch/mitchell bratt, zac/zachary thornton,
leo/leonardo jimenez.

THE REPAIR READS MLB'S OWN RECORD. `hydrate=person` on the roster call the
pipeline already makes returns firstName / useName / useLastName, so the
legitimate name forms for a human come from the league rather than from an
alias table. An alias table is a list of the mistakes we happened to notice.
`nickName` is DELIBERATELY EXCLUDED: over 821 people it produced 14
ambiguous keys, because MLB's nickname field carries bare surnames and
shared clubhouse names — `nameKey('Smitty Smith')` is one string for both
Burch Smith and Will Smith. The four forms kept produce ZERO ambiguity.

Canonical participant reach widens to the 40-man; TEAM EVIDENCE still reads
the ACTIVE roster alone, so event admission and the impossible-binding
refusal are unchanged. Identity still fails closed: a name matching more
than one person in the event resolves to nobody.

CONTINUITY, MEASURED BEFORE WRITING ANY CODE. Over the real 19:00 cohort,
208 of 209 player_keys are unchanged and the one that moves is the defect —
`leonardo jimenez` converging onto `leo jimenez`, a key that already exists.
No new lineage family. The natural key contains game_date, so chains never
span dates and a forward change cannot fork a closed one.

DETERMINISTIC REPRESENTATIVE. Which book's payload survives was decided by
position. It is now decided by the existing MODEL_BOOKS declaration order —
reused, not authored; inventing a sportsbook ranking to settle a tiebreak
would be a market judgement smuggled in as a bug fix — with book name and a
content tiebreak. Stable under every input permutation.

TWO GUARDS, BOTH DIRECTIONS. split (one person, many identities) and merge
(one identity, many people). A merge is refused at the same single admission
seam event identity already uses; a split is counted and alerted but does not
cut the board, because it duplicates an identity rather than asserting a
falsehood.

RETENTION REMAINS AN INDEPENDENT CHECK. The old assertion grepped the source
for `player_key: nameKey(player)`. That expression stood in for a PROPERTY,
and a grep verifies a spelling. Replaced with the property itself, asserted
in both modes: when the producer emits two rows for one human, retention
still files them under one identity and still reports the collision.

Replay of the real cohort through the repair: 3,129 offerings, 100%
participants resolved, every one of 207 participants on exactly ONE semantic
key, collision 0, split 0, merge 0.

Suite 396/5,455/0 · tsc 0 · 15/15 teeth. Tooth 12 came back green first
time and that was a coverage hole, not a safe defect: nothing asserted
retention's append-only upsert. It does now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
Kev
2026-08-30 20:37:01 -04:00
parent cc5bdf5797
commit 4aca33deb6
14 changed files with 882 additions and 23 deletions
+134
View File
@@ -0,0 +1,134 @@
/**
* 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,
};