4aca33deb6
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
336 lines
16 KiB
JavaScript
336 lines
16 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
});
|