Files
vyndr/tests/unit/canonicalParticipant.test.js
builtbykev 4aca33deb6 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
2026-08-30 20:37:01 -04:00

290 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;/);
});
});